Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .claude/skills/specsync/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ specsync handles **tracker sync** (OpenSpec ↔ GitHub/Beads).

## Basic Workflow

0. **Check before you start**: `specsync topology -json` shows which changes already have an issue, branch, and worktree — so you don't duplicate work another agent has started.
1. **Create or pull**: `specsync pull -issue N` or create a change in `openspec/changes/<slug>/`
2. **Implement**: Write code, update `tasks.md`
3. **Sync**: `specsync -dry-run -change <slug>` then `specsync -change <slug>`
Expand Down
34 changes: 32 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ 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:

Expand All @@ -95,28 +96,57 @@ 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.

### 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):

```bash
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:

```bash
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 topology # join change <-> issue <-> branch <-> worktree across stores
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`, `topology`, `trace`,
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.

Expand Down
26 changes: 17 additions & 9 deletions change.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,15 +142,17 @@ const (
type Change struct {
Dir string // absolute path to the change folder
Slug string
Title string // first H1 of proposal.md, falling back to Slug
Body string // proposal.md contents
TasksMarkdown string // tasks.md contents, may be ""
Links []Ref // resolved related issue refs from links.md (## Related)
BlockedBy []Ref // resolved blocked-by refs from links.md (## Blocked by)
Blocks []Ref // resolved blocks refs from links.md (## Blocks)
OriginalAsk string // original-ask.md contents, may be ""
Discoveries string // discoveries.md contents, may be ""
DesignNotes string // design.md contents, may be ""
Title string // first H1 of proposal.md, falling back to Slug
Body string // proposal.md contents
TasksMarkdown string // tasks.md contents, may be ""
Links []Ref // resolved related issue refs from links.md (## Related)
BlockedBy []Ref // resolved blocked-by refs from links.md (## Blocked by)
Blocks []Ref // resolved blocks refs from links.md (## Blocks)
OriginalAsk string // original-ask.md contents, may be ""
Discoveries string // discoveries.md contents, may be ""
DesignNotes string // design.md contents, may be ""
Deltas []SpecDelta // specs/**/spec.md behaviour contracts, may be empty
Targets []string // provider keys from the committed specsync.yml
Archived bool
Significant bool // true when the change has a significant marker, design.md, or >5 tasks
Progress TaskProgress // what the task checklist says
Expand Down Expand Up @@ -247,6 +249,12 @@ func LoadChange(dir string, archived bool, openspecDir string) (*Change, error)
if design, err := os.ReadFile(filepath.Join(dir, "design.md")); err == nil {
c.DesignNotes = string(design)
}
deltas, err := LoadSpecDeltas(dir)
if err != nil {
return nil, fmt.Errorf("load spec deltas for %s: %w", slug, err)
}
c.Deltas = deltas
c.Targets = ChangeTargets(dir)
// Load baseline task count from metadata.json (set on pull).
if meta, err := LoadChangeMetadata(dir); err == nil && meta != nil && meta.BaselineTaskCount != nil {
c.BaselineTasks = meta.BaselineTaskCount
Expand Down
1 change: 0 additions & 1 deletion cmd/specsync/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ specsync handles **tracker sync** (OpenSpec ↔ GitHub/Beads).

## Basic Workflow

0. **Check before you start**: `specsync topology -json` shows which changes already have an issue, branch, and worktree — so you don't duplicate work another agent has started.
1. **Create or pull**: `specsync pull -issue N` or create a change in `openspec/changes/<slug>/`
2. **Implement**: Write code, update `tasks.md`
3. **Sync**: `specsync -dry-run -change <slug>` then `specsync -change <slug>`
Expand Down
43 changes: 0 additions & 43 deletions cmd/specsync/agent_help.go
Original file line number Diff line number Diff line change
Expand Up @@ -335,49 +335,6 @@ var commandMetadata = map[string]AgentCommandHelp{
"specsync changes -json",
},
},
"topology": {
Command: "topology",
Description: "Join every change with its tracker issue, git branch, and worktree across coordinated stores — read-only, for an agent to check before starting work.",
Mutates: false,
Workflow: AgentCommandWorkflow{
Position: "planning",
RelatedBefore: []string{"changes"},
RelatedAfter: []string{"pull", "sync"},
},
Flags: []AgentCommandFlag{
{
Name: "change",
Type: "string",
Required: false,
Description: "Scope to one change (default: every change)",
},
{
Name: "all",
Type: "boolean",
Required: false,
Default: false,
Description: "Include archived changes",
},
{
Name: "json",
Type: "boolean",
Required: false,
Default: false,
Description: "Emit machine-readable JSON output",
},
},
SafetyRules: []string{
"Read-only operation — mutates nothing, not even the local ref cache",
"Every source (openspec, gh, git worktrees) is optional; a missing one narrows a row instead of failing it",
"Exits non-zero only when no row could be produced at all",
},
Examples: []string{
"specsync topology",
"specsync topology -json",
"specsync topology -change my-change",
"specsync topology -all",
},
},
"set-stage": {
Command: "set-stage",
Description: "Set the workflow stage of a change.",
Expand Down
7 changes: 2 additions & 5 deletions cmd/specsync/changelog.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import (
// tool unless -force.
func runChangelog(args []string) {
fs := flag.NewFlagSet("changelog", flag.ExitOnError)
openspec := fs.String("openspec", "openspec", "path to the openspec/ directory")
openspec, storeFlag := addRootFlags(fs)
since := fs.String("since", "", "range start (default: latest tag)")
until := fs.String("until", "", "range end (default: HEAD)")
versionFlag := fs.String("version", "", "version label for the section (default: latest tag + advisory bump)")
Expand All @@ -35,10 +35,7 @@ func runChangelog(args []string) {
_ = fs.Parse(args)
checkArgs(fs, args, 0)

abs, err := filepath.Abs(*openspec)
if err != nil {
fail(err)
}
abs := resolveRoot(fs, openspec, storeFlag).Dir
ctx := context.Background()
scope := specsync.Scope{Since: *since, Until: *until}

Expand Down
4 changes: 3 additions & 1 deletion cmd/specsync/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,9 +114,11 @@ func runDoctor(args []string) {
doctorContext(*jsonFlag)
case "skill":
doctorSkill(*jsonFlag)
case "store":
doctorStore(*jsonFlag)
default:
fmt.Fprintf(os.Stderr, "specsync doctor: unknown subcommand %q\n", subcommand)
fmt.Fprintf(os.Stderr, "Available: claude, install, context, skill\n")
fmt.Fprintf(os.Stderr, "Available: claude, install, context, skill, store\n")
os.Exit(2)
}
}
Expand Down
126 changes: 126 additions & 0 deletions cmd/specsync/doctor_store.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package main

import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"

specsync "github.com/androidand/specsync"
)

// StoreDiagnostic reports the health of the resolved spec root, with the
// checks that matter when planning lives in a store shared by a team.
type StoreDiagnostic struct {
Root string `json:"root"`
Rule string `json:"rule"`
StoreID string `json:"store_id,omitempty"`
IsStore bool `json:"is_store"`
Registered bool `json:"registered"`
BehindRemote bool `json:"behind_remote"`
Dirty bool `json:"dirty"`
Changes int `json:"changes"`
MissingTargets []string `json:"missing_targets,omitempty"`
Uncontracted []string `json:"uncontracted,omitempty"`
Problems []string `json:"problems,omitempty"`
}

func doctorStore(asJSON bool) {
cwd, err := os.Getwd()
if err != nil {
fail(err)
}
root, err := specsync.ResolveSpecRoot("openspec", false, "", cwd)
if err != nil {
fail(err)
}
d := StoreDiagnostic{Root: root.Dir, Rule: string(root.Rule), StoreID: root.StoreID, IsStore: root.IsStore()}

if err := root.Validate(); err != nil {
d.Problems = append(d.Problems, err.Error())
emitStoreDiagnostic(d, asJSON)
return
}

if root.IsStore() {
if _, err := specsync.StoreLocation(root.StoreID); err == nil {
d.Registered = true
} else {
d.Problems = append(d.Problems, err.Error())
}
storeRoot := filepath.Dir(root.Dir)
if out, err := exec.Command("git", "-C", storeRoot, "status", "--porcelain").Output(); err == nil && len(strings.TrimSpace(string(out))) > 0 {
d.Dirty = true
d.Problems = append(d.Problems, "store has uncommitted changes — commit and push so teammates see them")
}
_ = exec.Command("git", "-C", storeRoot, "fetch", "--quiet").Run()
if out, err := exec.Command("git", "-C", storeRoot, "rev-list", "--count", "HEAD..@{upstream}").Output(); err == nil {
if n := strings.TrimSpace(string(out)); n != "" && n != "0" {
d.BehindRemote = true
d.Problems = append(d.Problems, fmt.Sprintf("store is %s commit(s) behind its remote — run: git -C %s pull", n, storeRoot))
}
}
}

changes, err := specsync.LoadChanges(root.Dir)
if err != nil {
fail(err)
}
for _, c := range changes {
if c.Archived {
continue
}
d.Changes++
if root.IsStore() && len(c.Targets) == 0 {
d.MissingTargets = append(d.MissingTargets, c.Slug)
}
if len(c.Deltas) == 0 {
d.Uncontracted = append(d.Uncontracted, c.Slug)
}
}
if len(d.MissingTargets) > 0 {
d.Problems = append(d.Problems, fmt.Sprintf(
"%d change(s) in the store declare no targets — they will be filed wherever the shell happens to be standing",
len(d.MissingTargets)))
}
emitStoreDiagnostic(d, asJSON)
}

func emitStoreDiagnostic(d StoreDiagnostic, asJSON bool) {
if asJSON {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(d); err != nil {
fail(err)
}
if len(d.Problems) > 0 {
os.Exit(1)
}
return
}
fmt.Printf("spec root: %s\n", d.Root)
fmt.Printf(" resolved via: %s\n", d.Rule)
if d.IsStore {
fmt.Printf(" store: %s (registered: %v, dirty: %v, behind remote: %v)\n", d.StoreID, d.Registered, d.Dirty, d.BehindRemote)
} else {
fmt.Printf(" store: none (repo-local)\n")
}
fmt.Printf(" active changes: %d\n", d.Changes)
if len(d.MissingTargets) > 0 {
fmt.Printf(" without targets: %s\n", strings.Join(d.MissingTargets, ", "))
}
if len(d.Uncontracted) > 0 {
fmt.Printf(" without a specs/ delta: %s\n", strings.Join(d.Uncontracted, ", "))
}
if len(d.Problems) == 0 {
fmt.Println("\nno problems found")
return
}
fmt.Println()
for _, p := range d.Problems {
fmt.Printf(" ✗ %s\n", p)
}
os.Exit(1)
}
8 changes: 2 additions & 6 deletions cmd/specsync/epic.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"flag"
"fmt"
"os/exec"
"path/filepath"
"strings"

"github.com/androidand/specsync"
Expand All @@ -18,7 +17,7 @@ import (
// openspec/changes/epic-scaffold-command for the full design.
func runEpic(args []string) {
fs := flag.NewFlagSet("epic", flag.ExitOnError)
openspec := fs.String("openspec", "openspec", "path to the openspec/ directory")
openspec, storeFlag := addRootFlags(fs)
repo := fs.String("repo", "", "target repo for the epic issue, as owner/name (default: auto-detect from git remote)")
var children stringSlice
fs.Var(&children, "child", "a child to attach: local change slug, owner/repo#N, bare #N (resolved against --repo), or issue URL (repeatable)")
Expand All @@ -42,10 +41,7 @@ func runEpic(args []string) {
}
}

abs, err := filepath.Abs(*openspec)
if err != nil {
fail(err)
}
abs := resolveRoot(fs, openspec, storeFlag).Dir

epicProvider := makeProvider(targetRepo, *dryRun, "github", "")

Expand Down
Loading
Loading