Skip to content

project-sync — Canonical Playbook

Mirror the current state of all docs/PHASE_XX.md task checkboxes to GitHub Issues and a GitHub Projects v2 Kanban board. Idempotent — safe to run on every commit or on demand.

This document is the single source of truth for the project-sync workflow.

In an integrated project, runtime wrappers under .claude/skills/project-sync/SKILL.md (Claude Code) and plugins/sdd-workflow/{commands,skills}/project-sync/… (Codex) point here. The wrappers are thin stubs — every workflow detail lives in this file.

Input

/project-sync — sync all phases to the GitHub board /project-sync [XX] — sync single phase (two-digit, e.g. 01) /project-sync --dry-run — print diff without applying any changes /project-sync --setup — create GitHub Project + columns + sdd-workflow label; write config to docs/STACK.md /project-sync [XX] --dry-run — dry-run for a single phase

  • XX — zero-padded phase number (e.g. 01). If omitted, all discovered docs/PHASE_XX.md files are processed.
  • --dry-run — compute and print the full change queue but do not call any GitHub write API.
  • --setup — one-time initialisation: creates a GitHub Project, status columns, and the sdd-workflow label; writes config to docs/STACK.md § GitHub Project. Must be run before the first sync.

Source of truth

The markdown files are always the source of truth. The GitHub board is a read-only view. Writing back from GitHub to markdown (e.g. issue closed on GitHub → uncheck box) is out of scope to avoid two-source-of-truth problems.

Required reads

  • docs/PHASE_XX.md (all, or specified) — task checkboxes and phase status
  • docs/STATE.md — phase status cross-check (informational only)
  • docs/STACK.md § GitHub Project — project number and field IDs written by --setup

Idempotency mechanism

Every GitHub Issue created by this workflow has a hidden marker in its body:

```

```

On every run the workflow fetches all issues labelled sdd-workflow, parses their markers, builds a lookup map (PHASE_XX/KEY) → {issue_number, state, project_item_id}, diffs against current markdown state, and applies only the delta.

Task key derivation

  • If a scope item contains an explicit bracket ID (e.g. **[B1]**, [F2], [I3]): use that as the key verbatim (e.g. B1, F2).
  • Otherwise: derive a positional key T01, T02, … (sequential index within ## Scope, 1-based, zero-padded to two digits).
  • Key format in the marker: PHASE_XX/B1 or PHASE_XX/T01.

Status → Project column mapping

Markdown state Project column
task [ ], phase ⏳ pending Todo
task [ ], phase 🔄 in-progress In Progress
task [ ], phase ⚠️ NEEDS_REVIEW Needs Review
task [ ], phase ❌ blocked Blocked
task [x] (any phase status) Done

Full lifecycle operations

Trigger Operation
Task in markdown, no matching issue Create issue + add to project + set column
Task title changed (same key) Update issue title
Task [ ], matching issue is closed (not sdd-removed) Reopen issue + set column
Task [x], matching issue is open Close issue + set column to Done
Issue column differs from expected Set column
Task removed from ## Scope Close issue + add label sdd-removed
No change No-op

Procedure

Step 1 — Prerequisite check

  1. If --setup flag is present: run the Setup sub-procedure (§ below), then stop.
  2. Check gh auth status. If not authenticated, stop:

    "Not authenticated. Run gh auth login first, then re-run /project-sync."

  3. Confirm remote is GitHub: git remote get-url origin. Extract <owner>/<repo>. If the remote is not a github.com URL, stop with a clear message.
  4. Read docs/STACK.md § GitHub Project. If the section does not exist, stop:

    "GitHub Project not configured. Run /project-sync --setup first."

  5. Extract from the section: project_number, status_field_id, and option IDs for each column (Todo, In Progress, Needs Review, Blocked, Done).

Step 2 — Parse markdown state

  1. Discover phase files: docs/PHASE_XX.md matching the pattern (all, or only the specified XX).
  2. For each phase file extract:
  3. phase_number — from filename (e.g. 01)
  4. phase_title — from the # PHASE XX — … header line
  5. phase_status — from the Status row of ## Phase Metadata table (emoji + word)
  6. scope_items[] — every - [ ] or - [x] line under ## Scope:
    • key — explicit [ID] from the line if present, else T<NN> (positional)
    • title — text of the checkbox item, stripped of the ID prefix
    • checkedtrue if [x] or [X], false if [ ]

Step 3 — Fetch GitHub state

  1. Run: bash gh issue list --repo <owner>/<repo> --label sdd-workflow --state all --limit 500 \ --json number,title,state,body,labels
  2. For each issue, parse <!-- sdd-sync: PHASE_XX/KEY --> from the body.
  3. Build lookup map: (PHASE_XX/KEY) → {number, state}.
  4. To get project_item_id for column updates: query the project's items via gh project item-list <project_number> --owner <owner> --format json --limit 500 and join on issue URL.

Step 4 — Compute diff

For each scope item in each phase file:

  1. Compute target_column from the status mapping table above.
  2. Look up (PHASE_XX/KEY) in the GitHub map:
  3. Not found → QUEUE create
  4. Found, title differs → QUEUE update-title
  5. Found, issue closed (not sdd-removed) + task unchecked → QUEUE reopen
  6. Found, issue open + task checked → QUEUE close
  7. Found, open + unchecked, column differs from target → QUEUE set-column
  8. Otherwise → no-op

For each GitHub issue whose (PHASE_XX/KEY) is NOT present in any parsed phase file:

  • If issue does NOT already have label sdd-removed → QUEUE archive

Step 5 — Dry-run check

If --dry-run was passed: print the full diff queue (all queued operations with their type, phase, key, and title). Print a summary count per operation type. Stop. Do not call any write API.

Step 6 — Apply changes

Execute queued operations in this order: createupdate-titlearchiveclosereopenset-column.

create

```bash gh issue create \ --repo / \ --title "[PHASE_XX][KEY] " \ --body "<!-- sdd-sync: PHASE_XX/KEY --> <strong>Phase:</strong> PHASE_XX — <phase_title> <strong>Task ID:</strong> KEY</p> <hr /> <p><em>Synced from `docs/PHASE_XX.md` by `/project-sync`</em>" \ --label sdd-workflow \ --label phase-XX ```</p> <p>Then add to project and set column:</p> <p><code>bash gh project item-add <project_number> --owner <owner> --url <issue_url> gh project item-edit --project-id <project_id> --id <item_id> \ --field-id <status_field_id> --single-select-option-id <target_option_id></code></p> <h4 id="update-title">update-title</h4> <p><code>bash gh issue edit <number> --repo <owner>/<repo> --title "[PHASE_XX][KEY] <new_title>"</code></p> <p><strong>archive</strong> (task removed from scope)</p> <p><code>bash gh issue edit <number> --repo <owner>/<repo> --add-label sdd-removed gh issue close <number> --repo <owner>/<repo></code></p> <p><strong>close</strong> (task completed in markdown)</p> <p><code>bash gh issue close <number> --repo <owner>/<repo> gh project item-edit ... --single-select-option-id <done_option_id></code></p> <p><strong>reopen</strong> (task unchecked in markdown, issue was closed)</p> <p><code>bash gh issue reopen <number> --repo <owner>/<repo> gh project item-edit ... --single-select-option-id <target_option_id></code></p> <p><strong>set-column</strong> (column out of sync)</p> <p><code>bash gh project item-edit --project-id <project_id> --id <item_id> \ --field-id <status_field_id> --single-select-option-id <target_option_id></code></p> <h3 id="step-7-report">Step 7 — Report</h3> <p>```</p> <h2 id="project-sync-complete">project-sync complete</h2> <p>Phase(s): PHASE_01, PHASE_02 GitHub repo: <owner>/<repo> | Project: #<N></p> <p>Created: 3 issues Updated: 1 issue title Closed: 2 issues (done) Reopened: 0 Archived: 1 issue (removed from scope) Column: 4 issues re-placed No-op: 12 issues</p> <p>Next: view board at https://github.com/orgs/<owner>/projects/<N> or run <code>/project-sync --dry-run</code> to preview future changes. ```</p> <hr /> <h2 id="setup-sub-procedure-setup">Setup sub-procedure (<code>--setup</code>)</h2> <p>Run once per project before the first <code>/project-sync</code>.</p> <ol> <li>Check <code>docs/STACK.md</code> for an existing <code>## GitHub Project</code> section. If found, ask the user to confirm overwrite before continuing.</li> <li>Determine <code><owner></code> from <code>git remote get-url origin</code>.</li> <li>Create the GitHub Project: <code>bash gh project create --owner <owner> --title "<ProjectName> Board" --format json</code> Capture <code>project_number</code> and <code>project_id</code> from output.</li> <li>Add a single-select Status field with five options — <strong>Todo</strong>, <strong>In Progress</strong>, <strong>Needs Review</strong>, <strong>Blocked</strong>, <strong>Done</strong> — using the GraphQL mutation <code>addProjectV2SingleSelectField</code> via <code>gh api graphql</code>.</li> <li>Fetch the field ID and option IDs: <code>bash gh project field-list <project_number> --owner <owner> --format json</code></li> <li>Create the <code>sdd-workflow</code> label if it does not exist: <code>bash gh label create sdd-workflow --color 0075ca --repo <owner>/<repo></code></li> <li>Append (or replace) <code>## GitHub Project</code> section in <code>docs/STACK.md</code>: ```markdown ## GitHub Project</li> </ol> <table> <thead> <tr> <th>Key</th> <th>Value</th> </tr> </thead> <tbody> <tr> <td>Project number</td> <td><code><N></code></td> </tr> <tr> <td>Project ID</td> <td><code><PVT_xxx></code></td> </tr> <tr> <td>Status field ID</td> <td><code><PVTSSF_xxx></code></td> </tr> <tr> <td>Option: Todo</td> <td><code><opt-id-todo></code></td> </tr> <tr> <td>Option: In Progress</td> <td><code><opt-id-inprogress></code></td> </tr> <tr> <td>Option: Needs Review</td> <td><code><opt-id-needsreview></code></td> </tr> <tr> <td>Option: Blocked</td> <td><code><opt-id-blocked></code></td> </tr> <tr> <td>Option: Done</td> <td><code><opt-id-done></code></td> </tr> <tr> <td>```</td> <td></td> </tr> <tr> <td>8. Report: "Setup complete. Run <code>/project-sync</code> to perform the first sync."</td> <td></td> </tr> </tbody> </table> <hr /> <h2 id="rules">Rules</h2> <ul> <li>Never modify any <code>docs/PHASE_XX.md</code> or other markdown file.</li> <li>Never hard-delete GitHub Issues — only close + label <code>sdd-removed</code>.</li> <li><code>--dry-run</code> must not call any write API. All reads are allowed.</li> <li>If any <code>gh</code> command fails (network, auth, rate limit): stop immediately. Print the failed command and the error. Do not silently swallow errors or skip remaining items.</li> <li>Cap issue body at 2 000 characters. Truncate with <code>…(see docs/PHASE_XX.md)</code> if needed.</li> <li>If <code>docs/STACK.md</code> has no <code>## GitHub Project</code> section and <code>--setup</code> was not passed: stop with the prescribed message from Step 1. Do not create the section automatically.</li> <li>Issues labelled <code>sdd-removed</code> are never reopened by the sync, even if a task with the same key reappears (treat as a new task → create a new issue).</li> </ul> <h2 id="done-when">Done when</h2> <ul> <li><code>--setup</code>: <code>docs/STACK.md § GitHub Project</code> exists with all field IDs filled.</li> <li>Sync: all scope items from targeted phase files have a corresponding open or closed GitHub Issue with the correct sync marker, title, column placement, and open/closed state.</li> <li>The report lists created, updated, closed, reopened, archived, column-changed, and no-op counts.</li> </ul> <aside class="md-source-file"> <span class="md-source-file__fact"> <span class="md-icon" title="Last update"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M21 13.1c-.1 0-.3.1-.4.2l-1 1 2.1 2.1 1-1c.2-.2.2-.6 0-.8l-1.3-1.3c-.1-.1-.2-.2-.4-.2m-1.9 1.8-6.1 6V23h2.1l6.1-6.1zM12.5 7v5.2l4 2.4-1 1L11 13V7zM11 21.9c-5.1-.5-9-4.8-9-9.9C2 6.5 6.5 2 12 2c5.3 0 9.6 4.1 10 9.3-.3-.1-.6-.2-1-.2s-.7.1-1 .2C19.6 7.2 16.2 4 12 4c-4.4 0-8 3.6-8 8 0 4.1 3.1 7.5 7.1 7.9l-.1.2z"/></svg> </span> <span class="git-revision-date-localized-plugin git-revision-date-localized-plugin-date" title="May 9, 2026 15:54:17 UTC">May 9, 2026</span> </span> </aside> </article> </div> <script>var target=document.getElementById(location.hash.slice(1));target&&target.name&&(target.checked=target.name.startsWith("__tabbed_"))</script> </div> <button type="button" class="md-top md-icon" data-md-component="top" hidden> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M13 20h-2V8l-5.5 5.5-1.42-1.42L12 4.16l7.92 7.92-1.42 1.42L13 8z"/></svg> Back to top </button> </main> <footer class="md-footer"> <div class="md-footer-meta md-typeset"> <div class="md-footer-meta__inner md-grid"> <div class="md-copyright"> Made with <a href="https://squidfunk.github.io/mkdocs-material/" target="_blank" rel="noopener"> Material for MkDocs </a> </div> </div> </div> </footer> </div> <div class="md-dialog" data-md-component="dialog"> <div class="md-dialog__inner md-typeset"></div> </div> <script id="__config" type="application/json">{"base": "../..", "features": ["navigation.sections", "navigation.top", "toc.follow", "content.code.copy", "search.suggest", "search.highlight"], "search": "../../assets/javascripts/workers/search.d50fe291.min.js", "tags": null, "translations": {"clipboard.copied": "Copied to clipboard", "clipboard.copy": "Copy to clipboard", "search.result.more.one": "1 more on this page", "search.result.more.other": "# more on this page", "search.result.none": "No matching documents", "search.result.one": "1 matching document", "search.result.other": "# matching documents", "search.result.placeholder": "Type to start searching", "search.result.term.missing": "Missing", "select.version": "Select version"}, "version": null}</script> <script src="../../assets/javascripts/bundle.50899def.min.js"></script> <script src="https://unpkg.com/mermaid@10/dist/mermaid.min.js"></script> </body> </html>