Skip to content
androidandPublic

About

Sync OpenSpec changes with work-tracker issues — a standalone single-binary tool

Topics

Resources

Stars

4 stars

Watchers

0 watching

Forks

Latest commit

 

History

325 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

specsync

npm CI license

Make your OpenSpec changes and your issue tracker the same thing.

specsync projects OpenSpec changes into an external work tracker (GitHub Issues today; more providers planned) and keeps them in sync. An OpenSpec change and a tracker issue describe the same work in two forms — the rich, local, spec-driven form and the shareable, durable, human-facing form. specsync renders one into the other, idempotently.

It is a single self-contained binary that depends only on the Go standard library, so it runs in any OpenSpec project regardless of that project's language.

This repo dogfoods itself. Every issue here is generated from an OpenSpec change in openspec/changes/ by specsync — via the Sync specs → issues workflow. The backlog you see is the spec set: open any issue to read its proposal and live task checklist. That workflow is also a copy-pasteable reference for keeping your own repo's specs and issues in sync.

Why

OpenSpec keeps requirements out of chat history and in reviewable spec files. But teams often want those specs to also live where the rest of the world tracks work — a backlog, a board, a set of issues. Maintaining both by hand means writing intent twice and reconciling it forever.

specsync removes the double-entry: write the spec once, and it appears (and stays current) as an issue.

Install

# npm (recommended) — installs the prebuilt binary for your platform
npm i -g @androidand/specsync

# Go
go install github.com/androidand/specsync/cmd/specsync@latest

# or grab a prebuilt binary from the Releases page

The npm package is a thin wrapper: its postinstall downloads the matching prebuilt binary (linux/darwin, amd64/arm64) from the GitHub release, so there is no Go toolchain or build step.

Requirements

  • No Go toolchain for the npm or prebuilt-binary installs — the binary is self-contained (Go stdlib only).
  • gh CLI, authenticated — the default GitHub provider shells out to gh; check with gh auth status.
  • Node >= 16 — only for the npm wrapper's install shim.
  • Platforms: linux and macOS (darwin) on amd64/arm64. No Windows binary today.

The npm installer verifies the downloaded archive against the release SHA-256 checksums before extracting it. On a supported platform, download, checksum, or extraction failures fail the npm installation instead of leaving a successful but unusable specsync command. Unsupported platforms should use the Go install command or download a compatible release binary directly.

Usage

Run it from a repo that has an openspec/ directory (no OpenSpec yet? see openspec.dev — openspec init scaffolds one), with gh authenticated for that repo's origin (gh auth login if gh auth status fails).

Always -dry-run first in a new repo — it makes zero API calls and never touches local state:

specsync -dry-run            # preview the gh commands + rendered issue bodies (safe)
specsync -dry-run -change X    # preview a single change
specsync                     # create/update issues for every change
specsync -change X             # sync just one change
specsync -repo owner/name      # target a specific repo (see resolution order below)
specsync -openspec path/to/openspec   # point at a non-default openspec dir

Repo resolution order, first match wins:

  1. -repo owner/name (explicit flag — overrides everything).
  2. gh repo set-default — the repository configured for this checkout, since that is the user's stated intent.
  3. The origin remote.

Every gh invocation carries an explicit --repo. When origin and upstream name different repositories (fork divergence), specsync targets origin and reports the divergence. It refuses to write to the upstream parent without an explicit -repo — a fork user's internal planning content should not silently appear on someone else's repository.

Commands

See what is going on. Start with specsync changes.

Command What it does
audit Audit OpenSpec changes for structural issues.
audit-tasks Audit that task status matches code changes (dogfooding).
changes List all OpenSpec changes with status.
flow Show this repo's flow and mark where your current work is in it.
ideas List open stage:intake issues for the repo.
next Say the next step for your current work, and the command for it.
overview List all work in all repositories, grouped by epic.
relate Show the work connected to a change or a file path.
scan Scan for existing work in a code area or topic.
status Show where your current work stands, or every change with -everything.
trace Trace dependencies and relationships of a change.

Plan work. Start with specsync pull -issue <n>.

Command What it does
adopt Bind an existing local change to an existing tracker issue, when neither one knows about the other.
epic Create a coordination issue and wire children to it.
idea Capture a free-text idea as a stage:intake issue.
init Create a scratch openspec/ here and keep it out of git.
link Cross-link two or more OpenSpec changes.
new Create a change from a short template, after showing likely duplicates; the issue is created only on -yes.
note Append a discovery line to a change's discoveries.md.
park Stop work so another session can resume: save, set the stage and leave a note.
pull Pull an issue into a local OpenSpec change.
save Commit your work safely: refuses secrets and forbidden trailers, pushes only when asked.
set-priority Set or unset a change's numeric priority override.
set-stage Set the workflow stage of a change.
slice Start a slice: create its branch and worktree, stacked on the previous slice.
slices List a change's slices with progress, order, budget and state.
spinoff Spin off emergent work from a discovery.
start Start or resume work on a change in its own worktree, the same way every time.
update Bring your branch up to date with its base or upstream: a plan first, then -apply.
workspace Workspace hooks: setup finds the change from the branch or Orca's linked issue and sets it active; archive sets it back to auto.

Sync with the tracker. Start with specsync -dry-run -change <slug>.

Command What it does
sync Synchronize an OpenSpec change with GitHub Issues.

Review and release. Start with specsync verify.

Command What it does
archive Archive a completed change: final push, close, then retention.
changelog Generate release notes from OpenSpec changes and commits.
gates Run the repo's configured gates in order and stamp the commit when they pass.
lint Check titles and issue or PR text against the short-text standard.
pr Check a branch is ready for a pull request (prep) and open one with a short generated body (open).
pr-body Generate the correct PR body fragment for a change.
release-plan Inspect release impact and generate release notes.
review-map Turn a branch into a review checklist, or prove a rewritten branch has the same content.
validate Validate OpenSpec change structure.
verify Verify PR references to their change issues, or with -refs the commit and request references in change documents.

Housekeeping. Start with specsync doctor.

Command What it does
agent-help Show how to use any specsync command, for people and agents.
cleanup List finished worktrees and, with -apply, remove them and their branches.
doctor Diagnose skill installation, environment, and context health.
install-skill Install the specsync skill for your coding agent.
trust Allow specsync to run the commands in this repo's specsync.yml.
version Print the specsync version.

First run, end to end

Say you already have a change at openspec/changes/dark-mode/ (write one by hand, or scaffold it with the OpenSpec CLI — specsync doesn't create changes, only syncs them):

cd your-repo                    # has openspec/ and a gh-authenticated origin
specsync -dry-run -change dark-mode   # preview: no API calls, no local state touched
specsync -change dark-mode            # specsync: 1 created, 0 updated

That's it — an issue now exists with the proposal as its body and the tasks as a checklist, linked back to dark-mode by an identity marker. Re-run specsync -change dark-mode any time the change updates; it updates the same issue rather than creating a new one. Drop -change dark-mode to sync every change under openspec/changes/.

All subcommands, at a glance:

specsync [sync]          # project changes -> issues (default command)
specsync pull            # pull an issue into a local change
specsync adopt           # bind an existing change and an existing issue as the same work
specsync scan            # what already exists in an area?
specsync trace           # print the raw spec<->commit<->issue link graph
specsync link            # cross-link two or more changes
specsync spinoff         # spawn emergent work as a linked sibling
specsync epic            # create a coordination issue and wire children
specsync idea            # capture a thought as a GitHub issue (intake)
specsync ideas           # list open intake issues
specsync changes         # list local changes with state and priority
specsync set-stage       # set/unset a change's explicit workflow stage
specsync set-priority    # set a change's priority (used by agents to pick next work)
specsync note            # append a discovery to a change
specsync validate        # check change folders for structural issues
specsync verify          # run OpenSpec's Verify phase (acceptance checklist) for a change
specsync audit-tasks     # flag changes where code shipped but tasks are still unchecked
specsync pr-body         # emit a PR-body fragment (Closes #N) for a change
specsync release-plan    # shipped changes + advisory semver bump
specsync changelog       # Keep a Changelog section from shipped changes
specsync audit           # archived changes vs. merged PRs
specsync install-skill   # install the bundled agent skill
specsync agent-help      # command guidance/schema for agents (--json for machine-readable)
specsync doctor          # diagnose the local specsync/OpenSpec setup and skill installs
specsync version         # print the binary version

Dry-run flags — sync, pull, link, and spinoff support -dry-run. Beads can be previewed through specsync -dry-run -provider beads. scan, trace, release-plan, and changelog (unless -apply) are read-only and do not take a dry-run flag.

Flags come before positional arguments (standard Go flag parsing): specsync scan -json cmd/ auth, not specsync scan cmd/ auth -json.

Choosing a provider: -provider beads

The default provider is github — human-facing issues via the gh CLI. Pass -provider beads to project the same changes into a local Beads graph via the bd CLI instead (agent-facing; ignores -repo):

specsync -dry-run -provider beads    # preview the bd commands
specsync -provider beads -change X     # project one change into the beads graph

Pluggable spec sources

specsync supports multiple spec formats through the SpecSource interface. The default is openspec (reads openspec/changes/), but the architecture allows future formats like Beads or custom loaders.

specsync sync --spec openspec    # default: OpenSpec format
specsync sync --spec beads       # placeholder: Beads (not yet implemented)

To add a new spec format, implement the SpecSource interface:

type SpecSource interface {
    Name() string
    LoadChanges(specDir string) ([]Change, error)
    SaveChange(change Change) error
}

The interface is source-agnostic — Sync, Pull, Board, and other logic operate on []Change regardless of where the data comes from. Adding a new format requires only implementing the interface; no changes to core logic.

Multi-provider sync (fan-out)

-provider is repeatable. When you pass multiple providers, specsync projects each change into every provider independently — a star topology: OpenSpec is the single source of truth, and each provider is a separate edge from that center. There are no provider-to-provider edges; specsync never routes state from one tracker to another.

specsync -provider github -provider beads    # fan-out to both
specsync -provider github -provider beads -dry-run   # preview

Reconciliation works per-provider: before each push, specsync reads the current state from that provider's issue and merges any inbound changes into tasks.md (monotonic union — a check from any provider is absorbed). After pushing to a newly created issue, it reconciles once more to pick up any pre-existing state (e.g., Beads children already closed before the epic was created).

Failure isolation: if one provider fails, the other providers for the same change still proceed. The error is reported per-provider in the result without aborting the entire run.

Ref coexistence: each provider's ref is stored under its own key in refs.json (e.g., "github" and "beads"), so they never collide.

Provider contract

specsync uses a WorkProvider interface so the core engine is provider-agnostic. Implementations must be idempotent: Push with an existing ref updates; without one it creates (and should defend against duplicates via Find).

type WorkProvider interface {
    Name() string                         // ref-cache key, e.g. "github"
    Push(ctx, item WorkItem, existing *Ref) (Ref, error)
    Find(ctx, slug string) (*Ref, error)  // locate existing projection by slug
}

Optional capabilities are detected via type assertion so a minimal provider need not implement everything:

Interface Purpose Used by
IssueReader Read an existing item by ID pull, reconcile, mcp
IssueMarkerWriter Persist identity marker into item body pull (rediscoverability), mcp
TaskStateReader Report external task done-state reconcile
BoardProjector Project onto a GitHub Projects board pull
IssueSearcher Find open issues by free-text query scan
CommentCapable Post comments on items mcp
SubItemCapable Create sub-items under a parent mcp
CustomFieldCapable Read/write custom fields on items mcp
OpenSpecSource Read OpenSpec change metadata/deltas release-plan

Available providers (selectable via -provider):

  • github — default, shells out to gh CLI (human-facing issues)
  • beads — local agent graph via bd CLI (agent-facing)
  • mcp — delegates to an external MCP server (see below)

To add a new provider, implement WorkProvider and register it in makeProvider in cmd/specsync/main.go.

The mcp provider

-provider mcp projects changes through an external MCP server instead of gh — a Linear, Jira, GitHub, or in-house work-management server, whatever your project already talks to. It speaks the current protocol (2026-07-28, stateless, per-request _meta) and falls back automatically to a legacy (pre-2026-07-28, initialize-handshake) server when the modern probe fails, per the spec's own backward-compatibility algorithm — you don't have to know which era a given server speaks.

specsync -dry-run -provider mcp -change my-feature
specsync -provider mcp -change my-feature
specsync -provider mcp -mcp-config custom-path.json -change my-feature

Configure it via a committed .specsync-mcp.json at the repo root (default path; override with -mcp-config). It is deliberately not under .specsync/, which is entirely gitignored — this file has no secrets in it and is meant to be shared:

{
  "server": "linear",
  "tools": {
    "createIssue": "create_issue",
    "updateIssue": "update_issue",
    "find": "search_issues",
    "comment": "add_comment",
    "addSubItem": "add_sub_issue",
    "removeSubItem": "remove_sub_issue",
    "setCustomField": "set_field"
  }
}
  • server (optional) names an entry in the project's own .mcp.json — the file Claude Code and other agent harnesses already use to declare MCP servers — and reuses its command/args/env (stdio) or url/headers (HTTP) instead of duplicating them. If the project already has a work-tracker MCP server configured for agent use, specsync piggybacks on it. Without server, set transport ("stdio" or "http") plus command/args or url directly. tokenEnv names an environment variable holding a bearer token (never a literal secret in the committed file); any string field pulled from a .mcp.json entry also honors ${VAR} expansion, matching that format's own convention.
  • tools maps specsync's operations to the server's actual tool names. specsync discovers the server's tools (tools/list) and, for any operation without an explicit mapping, tries a conservative set of common naming variants (create_issue, new_issue, ...); if nothing matches confidently it fails loudly and lists every tool the server actually advertises — it never guesses silently.

How specsync picks GitHub-via-gh vs. GitHub-via-MCP vs. GitLab-via-MCP vs. anything else: it doesn't — you do, entirely through config. -provider github vs. -provider mcp is your choice of transport (the gh CLI vs. a generic MCP client). Within -provider mcp, which actual tracker you're talking to is 100% determined by what .specsync-mcp.json's command/args (stdio) or url (HTTP) point at — specsync has no built-in notion of "this is GitHub" or "this is GitLab." Every server names its tools, arguments, and result shapes differently, which is why the rest of this config exists:

  • context — static arguments merged into every call. MCP is stateless (no "current repo" implied by the connection), so any repo/project-scoping the server's tools require has to come from here, e.g. {"owner":"...", "repo":"..."} for GitHub's tools.
  • toolArgs — static, per-operation arguments merged in last (highest precedence). For servers that fold several specsync operations into one tool selected by a fixed parameter, e.g. GitHub's issue_write handles both create and update via {"method":"create"} / {"method":"update"}.
  • findQuery — templates the query text sent to the find tool ({marker} = the literal identity comment, {slug} = the bare change slug). Defaults to the literal marker text; some search tools need the tracker's own query syntax instead (GitHub's wants "specsync:change={slug} in:body", not a literal string match).
  • idField / idFieldNumeric — the argument key (and, if needed, JSON-number typing) an existing item's identifier is sent under for updates/comments/etc. Defaults to a plain string "id"; GitHub wants a numeric "issue_number".
  • findIdField — the field read from a found item as its identifier, tried before the "id"/"number" default. Needed when a server's result shape carries more than one id-shaped field and the generic default picks the wrong one — GitHub's search results carry both an internal database id and the repo-scoped number actually used elsewhere; id is wrong.

Tool contract. Beyond the above, specsync can't infer argument/result shapes from an arbitrary server's schema, so the mapped tools must follow this convention: createIssue/updateIssue take {title, body, stage, priority?, labels?, closed?} plus your context/toolArgs/idField additions, and return structuredContent (or JSON in the first text content block, or an object wrapping the item under a common key like items) with at least an id and a url; find takes {query} and returns a list in the same tolerant shapes, first match whose body (when returned) contains the identity marker wins; comment/setCustomField/addSubItem/removeSubItem take the obvious {id, body} / {id, field, value} / {parentId, childId} shapes.

Verified against a real server. -provider mcp has been validated end-to-end against the official github-mcp-server (PAT auth, no OAuth needed for local/stdio use) — create, rediscover via search, and update, compared directly against -provider github on the same repo. Here's the config that worked:

{
  "transport": "stdio",
  "command": "github-mcp-server",
  "args": ["stdio", "--toolsets", "issues"],
  "env": {"GITHUB_PERSONAL_ACCESS_TOKEN": "${GH_TOKEN}"},
  "context": {"owner": "your-org", "repo": "your-repo"},
  "findQuery": "specsync:change={slug} in:body",
  "findIdField": "number",
  "idField": "issue_number",
  "idFieldNumeric": true,
  "tools": {
    "createIssue": "issue_write",
    "updateIssue": "issue_write",
    "find": "search_issues"
  },
  "toolArgs": {
    "createIssue": {"method": "create"},
    "updateIssue": {"method": "update"}
  }
}

That validation pass also found a real, shared bug: Find-based duplicate defense (both this provider and GitHubProvider look up an existing item by marker before creating, since the local ref cache is deliberately never committed — see Lifecycle discipline) can race a tracker's search-index propagation lag moments after a different, cache-less run (e.g. a prior CI run) just created the same item. A short bounded retry (~1.2s worst case, nothing on a cache hit or a genuine find) now backs off before concluding "doesn't exist yet."

Scope cuts (revisit if they bite you): no renegotiation against a modern server that rejects protocol version 2026-07-28 — it errors out naming what the server supports instead. No support for Multi Round-Trip Requests / elicitation (resultType: "input_required" surfaces as a clear error) — a non-interactive CLI has no human to elicit input from. No legacy HTTP+SSE transport (deprecated upstream); legacy fallback covers stdio's initialize handshake and legacy-era plain-JSON-RPC-over-POST HTTP servers.

Issue-first: pull an issue into a change

Work often starts on the tracker — someone files an issue first. specsync pull reads that issue and scaffolds a local OpenSpec change from it, so you can plan it as a spec and keep syncing:

specsync pull -issue 42              # issue 42 -> openspec/changes/<slug>/
specsync pull -issue 42 -dry-run     # read the issue, show what would be written
specsync pull -issue 42 -init        # repo has no openspec/: make a scratch one first
specsync pull -issue 42 -change my-feature   # override the derived slug

pull writes proposal.md (from the issue body, titled by the issue) and tasks.md (from a ## Tasks checklist when present, else an empty starter so the change validates), and links the change to the issue so a later specsync push updates that same issue. A dry run reads the issue but writes nothing.

A repo with no openspec/ is normal. specsync init (or pull -init) creates a scratch openspec/changes/ and adds /openspec/ to the repo's local git exclude file (git rev-parse --git-path info/exclude, which a clone shares with its linked worktrees; it never edits a tracked .gitignore). It is idempotent, refuses where openspec/ is tracked, and nothing creates the folder without it. To keep specs in a shared store instead, pass -store <id> or set SPECSYNC_DEFAULT_STORE (or default-store: in ~/.config/specsync/config.yaml); the default store applies only when there is no local openspec/.

See all your work

specsync status in a worktree shows where that work stands. When its change has an epic, blockers or links, it also lists every related item in the clones around it, marks where you are, and ends with the next step. specsync overview lists all work in all repos under the workspace roots (-root, SPECSYNC_ROOTS, else the folder holding the current clone, two levels deep), grouped by epic, then work under no epic, then branches with no change:

specsync overview -attention             # only work that needs a look
specsync overview -repo api -epic #1     # scopes combine
specsync overview -mine -json            # for agents

Each row also says how to resume it: resume is cd <worktree>, or the agent session reported by an optional provider (-sessions-cmd or SPECSYNC_SESSIONS_CMD, a command that prints JSON; taken only from the flag or environment, never from a repository file; see docs/sessions-provider.md), and the last specsync note on the change is shown with it.

Relations come only from what is asserted: an epic link confirmed by the tracker, ## Blocked by / ## Blocks in links.md, and the request that closes an issue, which also names a change's branch when its name follows no convention. Both commands are read-only, and mark a result incomplete, with the reason, when a source such as the tracker was unreachable.

Catch oversized branches early

specsync status flags a branch that is oversized (files, changed lines or commits over the budget), has mixed-scopes (commits with more than one conventional scope, such as feat(billing) and feat(onboarding)), or is oversized with no-slice-plan (its change declares no ## Slice headings). It is advice, never a gate. Size comes from git alone.

specsync verify -size                          # advisory, exits 0
specsync verify -size -fail-on-oversized       # a CI gate, if you want one
specsync verify -size -budget files=15,lines=400 -exclude go.sum -exclude 'vendor/'

-exclude takes globs: * stays within a directory, ** crosses directories, a trailing / means everything under that directory, and a glob with no slash matches the file name at any depth. status, overview and verify -size all accept -budget and -exclude.

The budget is -budget, then a budget: files<=5 line in the change (verify -size finds the change from the branch name), then SPECSYNC_BUDGET, then defaults of 30 files, 1000 lines and 30 commits per measure. Write files=0 to remove a limit. Only key=n and key<=n are accepted. Flags apply to unmerged work only: no size flag appears once the request is merged or closed. Defaults are policy, not a claim about the right size.

Plan a large change as slices

Declare the parts of a change in tasks.md before the code. A ## Slice <n>: <title> heading starts a slice, with optional after: 1, 2, budget: files<=15, lines<=400 and contract: <one sentence> lines; the checkboxes below belong to it. A change without slice headings behaves as before, and specsync never proposes or infers slices.

specsync slices -change my-change              # progress, order, budget and state of each slice
specsync slice start -change my-change -dry-run 2
specsync slice start -change my-change 2       # branch and worktree, stacked on slice 1 when `after: 1`
specsync -change my-change -slices -dry-run    # child issue per slice, blocked-by per `after`

validate reports slice numbers that are not contiguous, after targets that are missing or form a cycle, tasks under no slice, and slices without a contract. status shows which slice a branch was started for and uses that slice's budget as the size threshold. -slices is off by default; a provider that cannot nest issues gets the slices listed in the issue body instead. sync -slices heals on every run (it adds a missing child, nesting or edge and skips what exists), and exits non-zero listing any step that failed. specsync records the order and reports state; it creates no pull request.

cleanup — remove finished work

specsync cleanup                 # dry run: what is finished, why, and what would be done
specsync cleanup -apply          # remove the worktree and its local branch (-assume-no-sessions if no provider)
specsync cleanup -root ~/dev     # every repo under a folder

A worktree is finished only when its work is provably in the base: its request merged with this head into the base branch (a request merged into another feature branch proves nothing), or the head is already in the base, the branch's reflog shows commits made on it and no reset (a reset can discard commits, so a reset branch needs a merged request), and the tree is clean. A request closed without merging is never a candidate, and old work with unpushed commits is listed as needing attention, never removed.

Skipped, with the reason shown: dirty trees or a git status that cannot be read, worktrees with ignored files (a top-level node_modules, dist, build, target, .next, vendor or __pycache__ alone does not block; a nested one does; -include-ignored overrides), open requests, protected branches (main, master, develop, trunk, staging, production, prod, gh-pages, release/*, hotfix/*, cleanup.protect globs where a trailing /* covers every depth, the base and default branch on any remote, the one checked out in the main checkout), branches with commits from several authors (or whose authors cannot be told), a config with problems (fix openspec/specsync.yml: ..., never silently defaulted), the current worktree, the main checkout, worktrees with a live session, and items whose hooks cannot run because the config is not trusted. Live sessions are checked only when a session provider is configured (-sessions-cmd): without one a dry run warns, and -apply refuses unless -assume-no-sessions is given.

Each candidate is checked again from fresh facts just before it is touched, then done in this order: hooks (in the main checkout, never the worktree, with SPECSYNC_WORKTREE set, a 60 s limit and capped output; they must not rely on the working directory), remove the worktree, delete the local branch (only if its tip is still the verified head; the result says how to restore it and the branch's config is removed), delete the remote branch (only when its upstream has the same name, its tip, read now, is this head, and no open request targets it). A dry run writes nothing: no git objects and no request cache. A failed hook leaves the worktree and branch in place. Cleanup never archives: with archive: true and a closed issue it prints specsync archive -change <slug> as a follow-up. specsync trust lists every gate and cleanup hook, and trust -yes records them. Configure it in openspec/specsync.yml:

cleanup:
  remote_branch: keep   # keep | delete
  archive: false        # true prints the archive command as a follow-up when the issue is closed
  min_age_days: 0       # age before unpushed work is listed as needing attention
  hooks: []             # for example: make stop
  protect: []           # branch globs that are never removed, for example: keep/*

scan — what already exists here?

Run before planning new work. Give it an area — one or more paths and/or a free-text topic (required) — and it lists related OpenSpec changes, open issues with no linked change, and recent commits touching that area:

specsync scan cmd/ auth          # area = the cmd/ path + the topic "auth"
specsync scan -json pkg/api      # machine-readable, for a planning agent

link — cross-link changes

Records each change's issue URL in the others' links.md and re-syncs them so a ## Related section appears in every linked issue:

specsync link -dry-run slug-a slug-b   # preview links.md + Related sections
specsync link slug-a slug-b            # write links and update both issues

spinoff — spawn emergent work as a linked sibling

When a discovery doesn't belong in the current change, spin it off into its own scoped change instead of scope-creep:

specsync spinoff -from my-change -task 3 -kind bug       # from task 3
specsync spinoff -from my-change -text "fix X" -dry-run  # free text

Extracts text from the parent's task (or uses -text), scaffolds a new change with a seeded proposal.md, marks the parent task as moved ([>] moved: <child>), and links the two.

epic — create a coordination issue and wire children

Mints a type:epic coordination issue and attaches all children as ## Related cross-references. Idempotent — re-running with the same title finds the existing epic instead of duplicating. Children can be local change slugs (synced first if needed), issue refs (#N, owner/repo#N), or full URLs.

specsync epic "Feature X: cross-repo widgets" \
  --repo androidand/planning \
  --child androidand/backend#12 \
  --child frontend-widget-view

Until epic-and-subissue-projection lands, children are wired as Related links in both directions. Once that lands, children are attached as native GitHub sub-issues and the epic body rolls up from subIssuesSummary.

idea — capture an idea as a GitHub issue

Drop any thought, vague or elaborate, into a durable inbox. An idea is just a GitHub issue labeled stage:intake — ordinary enough that teammates and managers can see it, no spec workflow required to use it.

specsync idea "Use WebAssembly for the renderer"
echo "Long-form idea text that spans multiple paragraphs..." | specsync idea
specsync idea "Quick thought" --repo my-personal-ideas

Title is derived mechanically (first line / first sentence, truncated to 70 chars). Body is the verbatim text plus a capture timestamp. No AI in the capture path — capture never waits, never fails on model errors.

Configure a default ideas repo for cwd-independent capture:

# openspec/specsync.yml
ideas_repo: my-org/ideas

Or use the SPECSYNC_IDEAS_REPO environment variable.

openspec/specsync.yml format

specsync reads this file with a small built-in parser for a YAML subset: block maps, block lists (- item, or - key: value for a map item), inline {k: v} maps and [a, b] lists of scalars, quoted and bare scalars, and # comments. Anchors, aliases, tags, multi-line scalars, nested inline collections, duplicate keys and tab indentation are errors reported with their line number. Nested keys never act as top-level ones, so board: inside a section does not change the board.

Your flow, gates and the next step

flow: pr-review          # minimal | pr-review (default) | strict, or a list of steps
gates:
  - {name: lint, run: "make lint"}
  - {name: e2e, run: "make e2e", optional: true}
worktrees:
  dir: "../worktrees"
  branch: "{type}/{issue}-{slug}"
  hooks: ["make setup"]       # run in a new worktree, after trust
  launcher: git               # git (default), orca, or auto: Orca when installed
save: {forbid_trailers: [Co-Authored-By], forbid_files: ["*.sql"]}
pr: {confirm: [tests-run, manual-test]}
cleanup: {remote_branch: keep, archive: true}
sync: {strategy: rebase, after_review: merge, force: with-lease}

Steps are new, start, build, gate, review, merge, cleanup. specsync next reads the current work's state (the same facts as status) and prints the next step and its command; specsync flow marks where the work is. Both only report. A gate counts as run when the commit it passed for is written to <git-dir>/specsync/gates-passed. specsync doctor config validates the file, with line numbers. Commands defined in the file run only after specsync trust records its hash for you and this repository: specsync trust prints them and records nothing, specsync trust -yes records trust, and a changed file needs trust again. Until then next and flow point at specsync trust instead of the commands.

Start, check and open work the same way every time

The flow verbs do the mechanical steps and refuse unsafe states; the conventions come from the config above, never from code. All of them print a plan with -dry-run (or without -apply) and change nothing then. See docs/flow-verbs.md for moving a personal script onto them.

  • specsync new <title> creates a change from a short template after showing likely duplicate changes and open issues; the tracker issue is created only with -yes, and only once the template placeholders have been edited away.
  • specsync start <slug|issue> reuses the change's worktree or creates one from worktrees:, runs the start hooks there (after trust) and refuses when the session provider reports a live session. With worktrees.launcher: orca (or -launcher orca) Orca creates it, linked to the change's issue; see Working with Orca.
  • specsync save -m <msg> commits tracked changes (untracked files only with -add), refuses secret-looking files and forbidden trailers, pushes only with -push (by explicit refspec to the branch's own name), and not onto a branch with an open pull request without -push-to-request. -sync previews the tracker sync first and creates a new issue only with -yes. It scans only the lines a change adds (and the message), refuses a detached HEAD, and takes -allow <file> for a reviewed false positive. specsync park -m <note> saves, sets the stage and leaves a note.
  • specsync gates runs the configured gates after trust and stamps the commit when a full run passes on a clean tree.
  • specsync update plans how to bring the branch current (the sync: strategy); -apply runs it, stops at a conflict, and -push publishes a rebase of a pushed branch only with -force-with-lease, leased on a freshly fetched sha; shared branches, and branches whose pull request state is open or unknown, are merged, never rewritten.
  • specsync pr prep checks tasks, freshness, the gate stamp and the size budget; specsync pr open creates the pull request with the generated short body once prep passes and every pr.confirm entry is given with -confirm.

Keep issue and PR text short

specsync lint -change my-change          # title and proposal, as an issue
specsync lint -title 'fix(sync): keep links out of sections' -fail
specsync lint -pr 42                     # a pull request's title and body
specsync pr-body -change my-change -template   # a short body from the change

lint checks titles (Conventional Commits for PRs, sentence case for issues), headers, sentence and paragraph length and a word budget, then reports the line, rule and a suggestion. It exits 0 unless -fail is given and never edits text. pr-body -template fills Summary, Changes, Review focus, Size, Verification and Closes from the change, each within a word budget, or fills the sections of your .github/pull_request_template.md. Change the standard in openspec/specsync.yml:

text:
  profile: conventional   # or plain: titles need not be Conventional Commits
  pr_title: 72            # limits; 0 turns one off
  issue_words: 150
  headers: [Non-goals]    # added to the allowed headers
  filler: [leverage]      # added to the phrases to avoid
  template: docs/pr.md    # default: the usual .github locations

ideas — list open intake issues

See what you've captured that nobody has triaged yet:

specsync ideas                    # table output, oldest first
specsync ideas -json              # JSON for scripting

Triage: pull → triage → act

An idea graduates by being pulled into a change (specsync pull -issue <n>), which creates a local spec and flips the issue label from stage:intake to stage:active on the next sync. Closing with a reason is a legitimate disposition — a written "why not" closes the loop on an idea rather than losing it.

trace — the raw link graph

Prints the resolved trace graph — changes, commits, issues, and the links between them — for debugging or scripting:

specsync trace -change my-feature      # scope to one change
specsync trace -since v0.3.0 -json     # commits since a tag, as JSON

Non-goals (parked for future changes):

  • graph.json export — no consumer exists yet
  • Graphify / inferred edges — code-symbol edges would pollute an asserted graph
  • Release-plan report — a future query over this same graph

release-plan — advisory follow-up report

Read-only report over a revision range (default: latest tag → HEAD): shipped changes, loose ends, archive candidates, and an advisory semver bump. It detects your release tool (e.g. goreleaser) and defers to it — the bump is advice, not action:

specsync release-plan                  # since the latest tag
specsync release-plan -since v0.3.0 -json
specsync release-plan -fail-on-archive-candidates
specsync release-plan -archive-completed

For release hygiene, run specsync release-plan -fail-on-archive-candidates in CI/release checks. It exits non-zero when shipped changes with fully completed tasks are still unarchived in openspec/changes/.

release-plan remains read-only by default. -archive-completed is the explicit mutation flag that moves shipped+complete changes into openspec/changes/archive/.

archive — close, label, and retire a completed change

specsync archive runs the full close-out lifecycle for one change: final sync, close the issue, label it, then apply a retention policy to the local folder.

specsync archive -change my-feature                    # move or prune, per policy
specsync archive -change my-feature -retain move        # force keep in git
specsync archive -change my-feature -retain prune       # force delete the local folder
specsync archive -change my-feature -force              # override unchecked-task refusal
specsync archive -change my-feature -dry-run            # print the plan, mutate nothing
specsync archive -dry-run                               # inside the change's worktree

The branch names the change

Inside a change's worktree you can leave the change out of set-stage, note and archive: specsync takes it from the branch and says so on stderr. The branch's last path segment is matched against active changes, so both feat/42-my-feature and an orchestrator's someuser/my-feature (Orca's default <user>/<workspace>) resolve to my-feature. The default branch never resolves, so an agent on main still has to name the change. Bare specsync prints its commands and writes nothing; syncing every change takes specsync -all.

specsync set-stage active                 # on feat/42-my-feature
specsync note "retry needs a backoff cap"

Steps: (1) final push so the issue reflects current scope and task state; (2) close the issue and add the spec:archived label; (3) apply retention to openspec/changes/<slug>/ — move relocates it to openspec/changes/archive/<slug>/ (kept in git), prune deletes it (the closed issue is the record).

Retention policy resolves in order: -retain flag → retain= key in .specsync/config → a significance heuristic (a change with a significant marker file, a design.md, or more than 5 tasks defaults to move; otherwise prune). Archiving refuses when tasks are unchecked unless -force is passed.

Working with Orca

Orca runs one agent per worktree and hands it the linked issue as its brief. With specsync that issue is the spec, so the two fit: Orca runs the workspaces, specsync keeps the plan and the issue in step. Add the hooks to your repo's orca.yaml:

scripts:
  setup: |
    specsync workspace setup
  archive: |
    specsync workspace archive -sync

Orca reads the setup hook from the new worktree's orca.yaml but the archive hook from the primary checkout's (the folder you added to Orca), so commit the file on the branch that checkout is on, not only on feature branches.

workspace setup finds the workspace's change from its branch, or from the issue Orca linked it to (orca worktree current --json). If that issue has no change yet, it pulls one, so a workspace started from the issue list becomes a change. It then sets the stage to active. In a fresh worktree, where the gitignored ref caches are missing, the issue's specsync:change= marker is what finds a committed change. workspace archive sets the stage back to auto, derived from the tasks, so an abandoned workspace doesn't leave the change stuck as active; it needs -sync to reach the tracker, because the stage is kept per checkout and Orca removes the worktree. A workspace that names no change is left alone and the hook exits 0. Both accept -dry-run.

To have specsync start create the worktree as an Orca workspace, set worktrees.launcher: orca (or auto, to use Orca only where it is installed), or pass -launcher orca. start then runs orca worktree create from the main checkout, which must be the folder you added to Orca, with the change's branch and issue, and -agent / -prompt start an agent in it. It fails if Orca put the workspace on another branch (Orca renames a taken one), and removes any upstream Orca set, so the branch is not published until you push it. Orca is found through $ORCA_CLI_COMMAND, then orca-ide on Linux (where a bare orca is the screen reader), then orca.

Let one tool move issue status. Orca's Linear status sync and specsync's stage projection (-status-map on a Projects board) both change it; turn on one, not both.

changelog — a changelog generated from your specs, not your commits

Commit-log changelogs are noise: chore, wip, squash messages. specsync changelog builds a Keep a Changelog section from what actually shipped — one entry per OpenSpec change in the revision range, in plain language, grouped into Added/Changed/Fixed/Removed/ Security:

specsync changelog                     # preview the section for latest tag -> HEAD
specsync changelog -release-notes      # bare body, for `goreleaser --release-notes`
specsync changelog -apply              # write/replace the section in CHANGELOG.md

Entry text comes from an optional ## Release note section in the change's proposal.md — written at planning time, reviewed in the issue like everything else — falling back to the proposal title. The category comes from OpenSpec requirement deltas (added/changed/removed) and linked commit types; a change with no deltas and all-fix commits lands under Fixed, any feat under Added. Commits that link to no change still surface honestly (a loose feat/fix is included; plumbing commits like chore/docs/ci are counted, not silently dropped). -apply is idempotent — re-running replaces that version's section in place — and defers to a release tool that already owns the changelog (release-please, changesets, …) unless you pass -force.

audit — did the PR actually land?

An archived change means the spec work is done — not that the PR merged. specsync audit cross-references archived changes against GitHub PRs to find the gap:

specsync audit                              # table of archived changes vs. PR state
specsync audit -json                        # machine-readable, for CI
specsync audit -fail-on-unmerged            # exit non-zero if any unmerged
specsync audit -mark-shipped                # write shipped metadata for merged PRs

For each archived change, audit checks: open PR → "unmerged", merged PR → "shipped", no PR → "orphaned". PR matching uses the specsync marker in the PR body, branch name convention (feat/<issue>-<slug>), or PR title.

Use -mark-shipped to write stage: shipped to each confirmed change's .specsync/metadata.json. This records the lifecycle step: active → complete → archived → shipped.

Project a synced change onto a GitHub Projects (v2) board — the issue is added to the board, its Status follows the change's stage, and the acting user is assigned:

specsync -project my-org/6                        # sync + project onto board 6
specsync -project my-org/6 -status-map "active=In Progress,archived=Done"

Board resolution order, first match wins:

  1. -project owner/number (explicit flag — overrides everything).
  2. Repository-local declaration: openspec/specsync.yml with board: owner/number.
  3. No board — specsync makes zero board calls.

There is deliberately no global board default (no env var, no ~/.config setting). A shell-wide value that spans every repository is the exact mechanism by which personal work reaches a work board. If the board's owner differs from the resolved repository's owner, specsync refuses unless -project names it explicitly.

Unset (the default), specsync makes zero board calls — completely backward-compatible. Status option names resolve case-insensitively against the board's own schema (never hard-coded ids), so a stock "Todo / In Progress / Done" board works out of the box; -status-map (or $SPECSYNC_STATUS_MAP) overrides the stage→Status names explicitly and fails loud on an unknown name. specsync never clobbers a Status or assignee it didn't set itself, and -dry-run previews the board plan with zero GraphQL calls.

install-skill — install the agent skill

Installs the bundled specsync SKILL.md into agent skill directories so coding agents know how to drive the tool:

specsync install-skill --all           # Claude Code and Agent Skills
specsync install-skill --claude-code   # ~/.claude/skills/specsync
specsync install-skill --agents        # ~/.agents/skills/specsync

--agents installs the generic Agent Skills copy used by Codex, OpenCode and other hosts; --codex and --opencode are aliases for it. --drift reports installed copies that differ from the bundled skill, and --rollback restores the bundled copy over local edits.

version

specsync version (also -version / --version) prints the binary version. Release builds stamp the real version; source builds print dev and the VCS revision.

OpenSpec Workflow (Teams)

OpenSpec has become the go-to planning layer for many developers. specsync extends that model for teams working on:

  • large codebases
  • multi-repo planning
  • customization and integrations
  • better collaboration

Lifecycle discipline

Treat OpenSpec as an active planning lifecycle, not a one-off document dump:

  1. propose — define intent in openspec/changes/<slug>/proposal.md
  2. tasks — define execution in tasks.md
  3. apply — implement and check off tasks
  4. sync — project and reconcile with tracker issues via specsync

Both paths are first-class:

  • spec-first: author local change, then run specsync -dry-run -change <slug> and specsync -change <slug>
  • issue-first: start from issue, run specsync pull, then continue with specsync -change <slug>

.status and stage labels

OpenSpec natively gives active/archived lifecycle via folder location. Optionally add <change>/.status for richer workflow stages. specsync maps that value to a stage:<name> label on the projected issue.

Check-in policy (intentional and contextual)

specsync supports two valid team patterns:

  • tracked OpenSpec (like this repo): keep openspec/ in git for public dogfooding and auditability.
  • local OpenSpec (common in enterprise monorepos): keep OpenSpec/Beads as local planning artifacts and sync the durable collaboration surface to issues.

The tool is intentionally neutral: it reduces noise and friction either way, by keeping issue tracking and spec planning synchronized.

OpenSpec CLI usage boundary

specsync keeps file-based parsing as the baseline so it works even when the OpenSpec CLI is unavailable. Teams can optionally run OpenSpec CLI checks locally or in CI when they want stricter lifecycle validation.

How it works

openspec/changes/<slug>/          ->  Change      (proposal.md, tasks.md, .status)
Change                            ->  WorkItem    (title, body, stage, labels)
WorkItem                          ->  issue       (via a pluggable provider)
  • Identity — each issue body carries an <!-- specsync:change=<slug> --> marker. That marker is the durable link; the issue number is only cached locally. Lose the cache and specsync rediscovers the issue by its marker.
  • Idempotent — running again updates the same issue; it never duplicates.
  • Body — proposal.md becomes the issue body; tasks.md is rendered as a task-list checklist so the tracker shows progress.
  • Two-way task state — a normal sync also merges checkbox state back from the issue into tasks.md (a box ticked on GitHub sticks), then pushes the merged result. The merge is a monotonic union — a task is done if either side marked it done — so a teammate's tick on the issue is captured without ever reverting un-pushed local progress. Spec still wins task wording and order; only the checkbox flips. Disable with -reconcile=false. Dry runs never read or write, so reconcile applies only on real syncs.
  • Task states — beyond [ ] (todo) and [x] (done), tasks.md supports [~] (dropped — superseded or removed) and [>] (moved — relocated to another change). Dropped and moved tasks are excluded from progress calculations; only todo and done are "live". A compact "Plan changes" footer in the issue body shows the breakdown: +3 added · 2 done · 1 dropped · 1 moved.
  • Discoveries — a ## Discoveries section captures findings during implementation without requiring scope decisions. Use specsync note [<slug>] "<text>" to append a discovery. The section is rendered into the issue and stripped on pull, keeping local and tracker state in sync.
  • Original ask — when you specsync pull an issue, the original body is saved as original-ask.md (write-once, never overwritten). It renders as ## Original ask in the issue body, providing a stable reference to what was requested before scope evolved.
  • Design notes — a design.md renders as ## Design notes in the issue body, between ## Original ask and ## Discoveries, so decisions made while planning survive even if the local worktree is lost. Pull is write-once like original-ask.md: local design.md can be richer than what's synced (mid-edit, not yet pushed), so a pull never clobbers it. When design.md is large enough to push the body over GitHub's size limit, sync posts it as a linked issue comment instead of inlining it, and keeps that comment up to date on every re-sync; if design.md later shrinks back under the limit, sync moves it back into the body and marks the old comment stale rather than deleting it.
  • Collapsed by default — a synced issue shows title and task checklist first: the Proposal, Original ask, Design notes, and Discoveries sections render inside native <details> blocks, collapsed in GitHub's UI. Nothing is removed — each is one click away — and pull recovers their content via an HTML-comment marker inside the block, independent of the visible label, so re-labeling a section doesn't break round-tripping. A design notes section that has overflowed to a linked comment (above) has no marker of its own — its content lives in the comment, not the body.
  • Stage — derived automatically: active while any task is unchecked, complete once every task is checked (before archiving), and archived once the change moves under changes/archive/. Add -close-completed to keep the issue's open/closed state aligned too: completion closes it and new unchecked work reopens it. Write a richer stage name into <change>/.status to override the derived value; an explicit complete closes with the flag, while any other explicit stage remains open.
  • Managed labels are opt-in, off by default — specsync and stage:<stage> are not added unless you pass -labels. Neither is read back by specsync itself: issue identity is the body marker, and stage for board users is the Projects Status field, so adding them unconditionally was pure noise in a repo where every issue is already specsync's. priority:<n> is unaffected — it's always added when a priority is set. Pass -labels for a mixed-source repo where native-label filtering ("show me only the specsync issues") is actually useful. Turning it off after having it on cleans up: the next sync removes specsync/stage:* from issues that already carry them, not just stops adding more.
  • Local cache — projection ids live in a gitignored <change>/.specsync/ directory, never in git.

License

MIT — see LICENSE.

About

Sync OpenSpec changes with work-tracker issues — a standalone single-binary tool

Topics

Resources

Stars

4 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages