From b53da3b1ead9de4133e91bb2ffcb05cc8cb031aa Mon Sep 17 00:00:00 2001 From: AndreasS Date: Tue, 29 Sep 2026 09:58:53 +0200 Subject: [PATCH 1/3] wip: checkpoint openspec-store-support work staged in the shared checkout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captures uncommitted work found staged in the main checkout (spec store support, spec deltas, and the Verify phase) so it isn't lost while it gets reconciled against current main. Known issue at this checkpoint: pull.go and repo.go were reverted to an older version, dropping maxSlugLen/capSlug/ dirExists/nextAvailableSlug from already-merged main work — go test fails to build until that's reconciled. --- .claude/skills/specsync/SKILL.md | 1 - README.md | 34 +- change.go | 26 +- cmd/specsync/SKILL.md | 1 - cmd/specsync/agent_help.go | 43 --- cmd/specsync/changelog.go | 7 +- cmd/specsync/doctor.go | 4 +- cmd/specsync/doctor_store.go | 126 ++++++++ cmd/specsync/epic.go | 8 +- cmd/specsync/main.go | 138 ++++---- cmd/specsync/relate.go | 7 +- cmd/specsync/root.go | 73 +++++ cmd/specsync/traceability.go | 56 ++-- cmd/specsync/traceability_prbody_test.go | 48 +-- cmd/specsync/verify_spec.go | 87 +++++ npm/skills/specsync/SKILL.md | 1 - .../2026-09-17-spec-issue-linker/links.md | 1 + .../2026-09-17-spec-issue-linker/proposal.md | 35 ++ .../2026-09-17-spec-issue-linker/tasks.md | 25 ++ .../changes/commit-change-stage/proposal.md | 27 ++ .../specs/change-state/spec.md | 31 ++ .../changes/openspec-store-support/design.md | 57 ++++ .../openspec-store-support/proposal.md | 40 +++ .../specs/spec-verification/spec.md | 62 ++++ .../specs/store-resolution/spec.md | 74 +++++ .../changes/openspec-store-support/tasks.md | 20 ++ .../openspec-upstream-feedback/proposal.md | 17 + .../openspec-upstream-feedback/tasks.md | 5 + .../proposal.md | 66 ++++ .../tasks.md | 22 ++ openspec/specs/audit-tasks/spec.md | 4 +- openspec/specs/audit/spec.md | 4 +- openspec/specs/commit-source/spec.md | 3 +- openspec/specs/conventional-commits/spec.md | 4 +- .../specs/github-projects-projection/spec.md | 4 +- openspec/specs/go-library-api/spec.md | 4 +- openspec/specs/launch-readiness/spec.md | 4 +- openspec/specs/openspec-source/spec.md | 4 +- openspec/specs/planning-scan/spec.md | 4 +- openspec/specs/release-impact/spec.md | 4 +- openspec/specs/release-plan/spec.md | 4 +- openspec/specs/release-tool-detection/spec.md | 4 +- openspec/specs/site-deployment/spec.md | 3 +- .../specs/stable-projection-identity/spec.md | 4 +- openspec/specs/trace-model/spec.md | 4 +- openspec/specs/trace/spec.md | 3 +- pull.go | 98 +----- pull_test.go | 20 +- repo.go | 26 +- site/features.json | 8 - skills/specsync/SKILL.md | 1 - spec_delta.go | 199 ++++++++++++ spec_delta_test.go | 188 +++++++++++ store.go | 299 ++++++++++++++++++ store_test.go | 128 ++++++++ sync.go | 14 - verify.go | 173 ++++++++++ 57 files changed, 2007 insertions(+), 350 deletions(-) create mode 100644 cmd/specsync/doctor_store.go create mode 100644 cmd/specsync/root.go create mode 100644 cmd/specsync/verify_spec.go create mode 100644 openspec/changes/archive/2026-09-17-spec-issue-linker/links.md create mode 100644 openspec/changes/archive/2026-09-17-spec-issue-linker/proposal.md create mode 100644 openspec/changes/archive/2026-09-17-spec-issue-linker/tasks.md create mode 100644 openspec/changes/commit-change-stage/proposal.md create mode 100644 openspec/changes/commit-change-stage/specs/change-state/spec.md create mode 100644 openspec/changes/openspec-store-support/design.md create mode 100644 openspec/changes/openspec-store-support/proposal.md create mode 100644 openspec/changes/openspec-store-support/specs/spec-verification/spec.md create mode 100644 openspec/changes/openspec-store-support/specs/store-resolution/spec.md create mode 100644 openspec/changes/openspec-store-support/tasks.md create mode 100644 openspec/changes/openspec-upstream-feedback/proposal.md create mode 100644 openspec/changes/openspec-upstream-feedback/tasks.md create mode 100644 openspec/changes/warn-before-creating-a-duplicate-issue/proposal.md create mode 100644 openspec/changes/warn-before-creating-a-duplicate-issue/tasks.md create mode 100644 spec_delta.go create mode 100644 spec_delta_test.go create mode 100644 store.go create mode 100644 store_test.go create mode 100644 verify.go diff --git a/.claude/skills/specsync/SKILL.md b/.claude/skills/specsync/SKILL.md index 4916d9f..d9702ae 100644 --- a/.claude/skills/specsync/SKILL.md +++ b/.claude/skills/specsync/SKILL.md @@ -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//` 2. **Implement**: Write code, update `tasks.md` 3. **Sync**: `specsync -dry-run -change ` then `specsync -change ` diff --git a/README.md b/README.md index d286dd3..af0fb39 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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. diff --git a/change.go b/change.go index aa44114..ed6a06f 100644 --- a/change.go +++ b/change.go @@ -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 @@ -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 diff --git a/cmd/specsync/SKILL.md b/cmd/specsync/SKILL.md index 4916d9f..d9702ae 100644 --- a/cmd/specsync/SKILL.md +++ b/cmd/specsync/SKILL.md @@ -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//` 2. **Implement**: Write code, update `tasks.md` 3. **Sync**: `specsync -dry-run -change ` then `specsync -change ` diff --git a/cmd/specsync/agent_help.go b/cmd/specsync/agent_help.go index a1bac38..ec60829 100644 --- a/cmd/specsync/agent_help.go +++ b/cmd/specsync/agent_help.go @@ -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.", diff --git a/cmd/specsync/changelog.go b/cmd/specsync/changelog.go index 5fc019a..35f0194 100644 --- a/cmd/specsync/changelog.go +++ b/cmd/specsync/changelog.go @@ -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)") @@ -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} diff --git a/cmd/specsync/doctor.go b/cmd/specsync/doctor.go index 33f2d0e..6acd0d8 100644 --- a/cmd/specsync/doctor.go +++ b/cmd/specsync/doctor.go @@ -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) } } diff --git a/cmd/specsync/doctor_store.go b/cmd/specsync/doctor_store.go new file mode 100644 index 0000000..4ff3ced --- /dev/null +++ b/cmd/specsync/doctor_store.go @@ -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) +} diff --git a/cmd/specsync/epic.go b/cmd/specsync/epic.go index 3d39b94..7e9ec57 100644 --- a/cmd/specsync/epic.go +++ b/cmd/specsync/epic.go @@ -5,7 +5,6 @@ import ( "flag" "fmt" "os/exec" - "path/filepath" "strings" "github.com/androidand/specsync" @@ -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)") @@ -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", "") diff --git a/cmd/specsync/main.go b/cmd/specsync/main.go index 79a0a38..6315e7e 100644 --- a/cmd/specsync/main.go +++ b/cmd/specsync/main.go @@ -33,7 +33,7 @@ var knownSubcommands = map[string]bool{ "sync": true, "audit": true, "audit-tasks": true, "validate": true, "spinoff": true, "pr-body": true, "verify": true, "agent-help": true, "doctor": true, "idea": true, "ideas": true, "archive": true, - "epic": true, "adopt": true, "topology": true, + "epic": true, "adopt": true, } // knownConfusions maps a word someone might reach for by habit (e.g. git's @@ -94,7 +94,7 @@ func deprecatedSlugFlag(args []string) error { func main() { cmd, rest, err := resolveSubcommand(os.Args[1:]) if err != nil { - fmt.Fprintf(os.Stderr, "specsync: %v\n\nRun with no subcommand (optionally with flags) to sync, or use one of: pull, adopt, link, scan, trace, release-plan, changelog, install-skill, changes, set-stage, set-priority, note, audit, audit-tasks, validate, spinoff, pr-body, verify, idea, ideas, archive, epic, topology\n", err) + fmt.Fprintf(os.Stderr, "specsync: %v\n\nRun with no subcommand (optionally with flags) to sync, or use one of: pull, adopt, link, scan, trace, release-plan, changelog, install-skill, changes, set-stage, set-priority, note, audit, audit-tasks, validate, spinoff, pr-body, verify, idea, ideas, archive, epic\n", err) os.Exit(2) } @@ -151,8 +151,6 @@ func main() { runArchive(rest) case "epic": runEpic(rest) - case "topology": - runTopology(rest) default: runSync(rest) } @@ -328,8 +326,9 @@ func (s *stringSlice) Set(v string) error { // runSync projects every OpenSpec change into the tracker (spec -> issue). func runSync(args []string) { fs := flag.NewFlagSet("sync", flag.ExitOnError) - openspec := fs.String("openspec", "openspec", "path to the openspec/ directory") + openspec, store := addRootFlags(fs) change := fs.String("change", "", "sync only this change (default: all changes)") + all := fs.Bool("all", false, "sync every change in the resolved root (required to sweep a store)") repo := fs.String("repo", "", "target repo as owner/name (default: auto-detect from git remote)") var providerNames stringSlice fs.Var(&providerNames, "provider", "work provider: github, beads, mcp (repeatable; auto-detect when absent)") @@ -349,11 +348,13 @@ func runSync(args []string) { _ = fs.Parse(args) checkArgs(fs, args, 0) - abs, err := filepath.Abs(*openspec) - if err != nil { - fail(err) - } + root := resolveRoot(fs, openspec, store) + guardStoreScope(root, *change, *all) + abs := root.Dir repoRoot := filepath.Dir(abs) + if root.IsStore() { + fmt.Printf("root: store %s (%s)\n", root.StoreID, abs) + } // Resolve board: -project flag → openspec/specsync.yml → no board. resolvedBoard, err := specsync.ResolveBoard(*project, repoRoot) @@ -374,9 +375,27 @@ func runSync(args []string) { StatusMapping: statusMapping, } + // A scoped change may declare its target repos. Honour them ahead of + // git-remote auto-detection, and fan out to one provider per target so a + // single plan can hold its backend and frontend halves. + targetRepos := scopedTargetRepos(abs, *change, *repo) + if len(targetRepos) > 0 && *dryRun { + fmt.Printf("targets: %s (declared by the change)\n", strings.Join(targetRepos, ", ")) + } + // Build provider set. When -provider is absent, auto-detect a single one. var providers []specsync.WorkProvider - if len(providerNames) == 0 { + if len(targetRepos) > 0 && len(providerNames) == 0 { + if *dryRun { + fmt.Printf("DRY RUN — no github calls are made\n") + } + for _, tr := range targetRepos { + providers = append(providers, makeProvider(tr, *dryRun, "github", *mcpConfig)) + } + if *dryRun { + fmt.Println() + } + } else if len(providerNames) == 0 { provider, providerReason := detectProvider("", repoRoot) prov := makeProvider(*repo, *dryRun, provider, *mcpConfig) providers = []specsync.WorkProvider{prov} @@ -567,7 +586,7 @@ func runSync(args []string) { // (issue -> spec). A dry run reads the issue but writes nothing to disk. func runPull(args []string) { fs := flag.NewFlagSet("pull", flag.ExitOnError) - openspec := fs.String("openspec", "openspec", "path to the openspec/ directory") + openspec, storeFlag := addRootFlags(fs) issue := fs.String("issue", "", "issue number to pull into a local change (auto-resolved from branch name like feat/42-change when omitted)") change := fs.String("change", "", "change name (default: derived from the issue title)") repo := fs.String("repo", "", "source repo as owner/name (default: auto-detect from git remote)") @@ -583,10 +602,7 @@ func runPull(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 repoRoot := filepath.Dir(abs) resolvedBoard, err := specsync.ResolveBoard(*project, repoRoot) @@ -643,8 +659,21 @@ func runPull(args []string) { } return } + // Record where the issue came from, so a change living in a store still + // knows its target repo when the working directory no longer implies one. + if prov := makeProvider(*repo, false, "github", ""); prov != nil { + if gp, ok := prov.(*specsync.GitHubProvider); ok { + if key := gp.Name(); specsync.RepoFromProviderKey(key) != "" { + if err := specsync.WriteChangeTargets(filepath.Join(abs, "changes", res.Slug), []string{key}); err != nil { + fmt.Fprintf(os.Stderr, "specsync: could not record target: %v\n", err) + } + } + } + } + fmt.Printf("specsync: pulled issue %s -> %s\n", res.IssueID, dest) fmt.Println(" + proposal.md") + fmt.Println(" + specsync.yml (target)") if res.Tasks != "" { fmt.Println(" + tasks.md") } @@ -661,7 +690,7 @@ func runPull(args []string) { // declares that an already-existing pair are the same work. func runAdopt(args []string) { fs := flag.NewFlagSet("adopt", flag.ExitOnError) - openspec := fs.String("openspec", "openspec", "path to the openspec/ directory") + openspec, storeFlag := addRootFlags(fs) issue := fs.String("issue", "", "issue number to bind the change to (required)") change := fs.String("change", "", "change slug (default: derived from the current branch name, e.g. feat/42-change)") repo := fs.String("repo", "", "target repo as owner/name (default: auto-detect from git remote)") @@ -677,10 +706,7 @@ func runAdopt(args []string) { fail(fmt.Errorf("-issue is required")) } - abs, err := filepath.Abs(*openspec) - if err != nil { - fail(err) - } + abs := resolveRoot(fs, openspec, storeFlag).Dir res, err := specsync.Adopt(context.Background(), specsync.AdoptOptions{ OpenSpecDir: abs, @@ -850,7 +876,7 @@ func ensureWorktree(ctx context.Context, worktreePath, branchName string) error // Usage: specsync link [flags] [...] func runLink(args []string) { fs := flag.NewFlagSet("link", flag.ExitOnError) - openspec := fs.String("openspec", "openspec", "path to the openspec/ directory") + openspec, storeFlag := addRootFlags(fs) dryRun := fs.Bool("dry-run", false, "show what would change without writing files or calling GitHub") repo := fs.String("repo", "", "repo (owner/name) for bare issue refs") _ = fs.Parse(args) @@ -861,10 +887,7 @@ func runLink(args []string) { fail(fmt.Errorf("link: at least 2 arguments required\nusage: specsync link [...]")) } - abs, err := filepath.Abs(*openspec) - if err != nil { - fail(err) - } + abs := resolveRoot(fs, openspec, storeFlag).Dir result, err := specsync.Link(context.Background(), specsync.LinkOptions{ OpenSpecDir: abs, @@ -1285,7 +1308,7 @@ func shellJoin(args []string) string { // runChanges lists OpenSpec changes with state and priority. func runChanges(args []string) { fs := flag.NewFlagSet("changes", flag.ExitOnError) - openspec := fs.String("openspec", "openspec", "path to the openspec/ directory") + openspec, storeFlag := addRootFlags(fs) stages := fs.String("stage", "", "filter by stages (comma-separated, e.g. backlog,blocked)") sortBy := fs.String("sort", "stage", "sort order: stage (canonical), priority, or slug") asJSON := fs.Bool("json", false, "output as JSON") @@ -1298,7 +1321,7 @@ func runChanges(args []string) { fail(fmt.Errorf("invalid sort value %q: must be stage, priority, or slug", *sortBy)) } - changes, err := specsync.LoadChanges(*openspec) + changes, err := specsync.LoadChanges(resolveRoot(fs, openspec, storeFlag).Dir) if err != nil { fail(err) } @@ -1524,7 +1547,7 @@ func changeMetadata(change *specsync.Change) specsync.ChangeMetadata { // stage field is touched: an explicit priority survives set-stage auto. func runSetStage(args []string) { fs := flag.NewFlagSet("set-stage", flag.ExitOnError) - openspec := fs.String("openspec", "openspec", "path to the openspec/ directory") + openspec, storeFlag := addRootFlags(fs) if err := fs.Parse(args); err != nil { fail(err) } @@ -1534,7 +1557,7 @@ func runSetStage(args []string) { } changeName, stage := fs.Arg(0), fs.Arg(1) - change := mutableChange(*openspec, changeName, false) + change := mutableChange(resolveRoot(fs, openspec, storeFlag).Dir, changeName, false) meta := changeMetadata(change) if stage == "auto" { @@ -1559,7 +1582,7 @@ func runSetStage(args []string) { // runSetPriority sets a change's priority. func runSetPriority(args []string) { fs := flag.NewFlagSet("set-priority", flag.ExitOnError) - openspec := fs.String("openspec", "openspec", "path to the openspec/ directory") + openspec, storeFlag := addRootFlags(fs) if err := fs.Parse(args); err != nil { fail(err) } @@ -1569,7 +1592,7 @@ func runSetPriority(args []string) { } changeName, priorityArg := fs.Arg(0), fs.Arg(1) - change := mutableChange(*openspec, changeName, true) + change := mutableChange(resolveRoot(fs, openspec, storeFlag).Dir, changeName, true) meta := changeMetadata(change) if priorityArg == "unset" { @@ -1593,7 +1616,7 @@ func runSetPriority(args []string) { // runNote appends a discovery line to the ## Discoveries section of a change. func runNote(args []string) { fs := flag.NewFlagSet("note", flag.ExitOnError) - openspec := fs.String("openspec", "openspec", "path to the openspec/ directory") + openspec, storeFlag := addRootFlags(fs) dryRun := fs.Bool("dry-run", false, "show what would be written without modifying files") if err := fs.Parse(args); err != nil { fail(err) @@ -1604,7 +1627,7 @@ func runNote(args []string) { } changeName, text := fs.Arg(0), fs.Arg(1) - c, err := specsync.LoadChangeBySlug(*openspec, changeName) + c, err := specsync.LoadChangeBySlug(resolveRoot(fs, openspec, storeFlag).Dir, changeName) if err != nil { fail(err) } @@ -1632,7 +1655,7 @@ func runNote(args []string) { // existing change. func runSpinoff(args []string) { fs := flag.NewFlagSet("spinoff", flag.ExitOnError) - openspec := fs.String("openspec", "openspec", "path to the openspec/ directory") + openspec, storeFlag := addRootFlags(fs) from := fs.String("from", "", "parent change slug (required)") task := fs.String("task", "", "task index to spin off (1-based); omit for free text mode") repo := fs.String("repo", "", "target repo as owner/name (default: same as parent)") @@ -1669,10 +1692,7 @@ func runSpinoff(args []string) { fail(fmt.Errorf("spinoff: either -task or -text is required")) } - abs, err := filepath.Abs(*openspec) - if err != nil { - fail(err) - } + abs := resolveRoot(fs, openspec, storeFlag).Dir res, err := specsync.Spinoff(context.Background(), specsync.SpinoffOptions{ OpenSpecDir: abs, @@ -1728,7 +1748,7 @@ func runSpinoff(args []string) { // each as unmerged, shipped, or orphaned. func runAudit(args []string) { fs := flag.NewFlagSet("audit", flag.ExitOnError) - openspec := fs.String("openspec", "openspec", "path to the openspec/ directory") + openspec, storeFlag := addRootFlags(fs) repo := fs.String("repo", "", "target repo as owner/name (default: auto-detect from git remote)") asJSON := fs.Bool("json", false, "output as JSON") failOnUnmerged := fs.Bool("fail-on-unmerged", false, "exit non-zero when unmerged changes exist") @@ -1738,10 +1758,7 @@ func runAudit(args []string) { } checkArgs(fs, args, 0) - abs, err := filepath.Abs(*openspec) - if err != nil { - fail(err) - } + abs := resolveRoot(fs, openspec, storeFlag).Dir changes, err := specsync.LoadChanges(abs) if err != nil { @@ -1856,7 +1873,7 @@ func fail(err error) { // where code exists but tasks remain unchecked — the dogfooding failure mode. func runAuditTasks(args []string) { fs := flag.NewFlagSet("audit-tasks", flag.ExitOnError) - openspec := fs.String("openspec", "openspec", "path to the openspec/ directory") + openspec, storeFlag := addRootFlags(fs) asJSON := fs.Bool("json", false, "output as JSON") failOnMismatch := fs.Bool("fail-on-mismatch", false, "exit non-zero when mismatches exist") if err := fs.Parse(args); err != nil { @@ -1864,10 +1881,7 @@ func runAuditTasks(args []string) { } checkArgs(fs, args, 0) - abs, err := filepath.Abs(*openspec) - if err != nil { - fail(err) - } + abs := resolveRoot(fs, openspec, storeFlag).Dir changes, err := specsync.LoadChanges(abs) if err != nil { @@ -1944,17 +1958,14 @@ func runAuditTasks(args []string) { // valid metadata, well-formed stages. Reports all issues in one pass. func runValidate(args []string) { fs := flag.NewFlagSet("validate", flag.ExitOnError) - openspec := fs.String("openspec", "openspec", "path to the openspec/ directory") + openspec, storeFlag := addRootFlags(fs) asJSON := fs.Bool("json", false, "output as JSON") if err := fs.Parse(args); err != nil { fail(err) } checkArgs(fs, args, 0) - abs, err := filepath.Abs(*openspec) - if err != nil { - fail(err) - } + abs := resolveRoot(fs, openspec, storeFlag).Dir result := specsync.ValidateChanges(abs) @@ -1990,7 +2001,7 @@ func runValidate(args []string) { // verbatim text plus a capture timestamp. func runIdea(args []string) { fs := flag.NewFlagSet("idea", flag.ExitOnError) - openspec := fs.String("openspec", "openspec", "path to the openspec/ directory") + openspec, storeFlag := addRootFlags(fs) repo := fs.String("repo", "", "repo as owner/name (default: config ideas_repo, $SPECSYNC_IDEAS_REPO, or current repo)") if err := fs.Parse(args); err != nil { fail(err) @@ -1998,10 +2009,7 @@ func runIdea(args []string) { checkArgs(fs, args, 1) // idea text is the one expected positional // Resolve repo: -repo flag → openspec/specsync.yml → SPECSYNC_IDEAS_REPO → auto-detect. - abs, err := filepath.Abs(*openspec) - if err != nil { - fail(err) - } + abs := resolveRoot(fs, openspec, storeFlag).Dir repoRoot := filepath.Dir(abs) targetRepo := specsync.ResolveIdeasRepo(*repo, repoRoot) @@ -2062,7 +2070,7 @@ func runIdea(args []string) { // runIdeas lists open stage:intake issues for the repo. func runIdeas(args []string) { fs := flag.NewFlagSet("ideas", flag.ExitOnError) - openspec := fs.String("openspec", "openspec", "path to the openspec/ directory") + openspec, storeFlag := addRootFlags(fs) repo := fs.String("repo", "", "repo as owner/name (default: config ideas_repo, $SPECSYNC_IDEAS_REPO, or current repo)") asJSON := fs.Bool("json", false, "output as JSON") if err := fs.Parse(args); err != nil { @@ -2070,10 +2078,7 @@ func runIdeas(args []string) { } checkArgs(fs, args, 0) - abs, err := filepath.Abs(*openspec) - if err != nil { - fail(err) - } + abs := resolveRoot(fs, openspec, storeFlag).Dir repoRoot := filepath.Dir(abs) targetRepo := specsync.ResolveIdeasRepo(*repo, repoRoot) @@ -2142,7 +2147,7 @@ func runIdeas(args []string) { // are unchecked. func runArchive(args []string) { fs := flag.NewFlagSet("archive", flag.ExitOnError) - openspec := fs.String("openspec", "openspec", "path to the openspec/ directory") + openspec, storeFlag := addRootFlags(fs) change := fs.String("change", "", "change to archive (required)") repo := fs.String("repo", "", "target repo as owner/name (default: auto-detect)") retain := fs.String("retain", "", "retention policy: move (keep) or prune (delete)") @@ -2157,10 +2162,7 @@ func runArchive(args []string) { fail(fmt.Errorf("archive: -change is required")) } - abs, err := filepath.Abs(*openspec) - if err != nil { - fail(err) - } + abs := resolveRoot(fs, openspec, storeFlag).Dir // Build provider. var provider specsync.WorkProvider diff --git a/cmd/specsync/relate.go b/cmd/specsync/relate.go index 6749007..c056bcd 100644 --- a/cmd/specsync/relate.go +++ b/cmd/specsync/relate.go @@ -19,7 +19,7 @@ import ( // queries gh/git for edge data. It never writes to disk or the tracker. func runRelate(args []string) { fs := flag.NewFlagSet("relate", flag.ExitOnError) - openspec := fs.String("openspec", "openspec", "path to the openspec/ directory") + openspec, storeFlag := addRootFlags(fs) change := fs.String("change", "", "change slug to relate") path := fs.String("path", "", "file path to find related changes (alternative to -change)") repo := fs.String("repo", "", "target repo as owner/name (default: auto-detect from git remote)") @@ -32,10 +32,7 @@ func runRelate(args []string) { fail(fmt.Errorf("relate: specify only one of -change or -path, not both")) } - abs, err := filepath.Abs(*openspec) - if err != nil { - fail(err) - } + abs := resolveRoot(fs, openspec, storeFlag).Dir repoRoot := filepath.Dir(abs) // Resolve repo: -repo flag → gh repo view → empty. diff --git a/cmd/specsync/root.go b/cmd/specsync/root.go new file mode 100644 index 0000000..48cc3e8 --- /dev/null +++ b/cmd/specsync/root.go @@ -0,0 +1,73 @@ +package main + +import ( + "flag" + "fmt" + "os" + + specsync "github.com/androidand/specsync" +) + +// addRootFlags registers the spec-root flags shared by every command that +// reads changes. -store is accepted everywhere -openspec is. +func addRootFlags(fs *flag.FlagSet) (openspec, store *string) { + openspec = fs.String("openspec", "openspec", "path to the openspec/ directory") + store = fs.String("store", "", "registered OpenSpec store id to operate on (default: openspec/config.yaml `store:`, else ./openspec)") + return openspec, store +} + +// resolveRoot applies the spec-root resolution order and fails loudly rather +// than letting a command print an empty result against a root that isn't there. +func resolveRoot(fs *flag.FlagSet, openspec, store *string) specsync.ResolvedRoot { + flagSet := false + fs.Visit(func(f *flag.Flag) { + if f.Name == "openspec" { + flagSet = true + } + }) + cwd, err := os.Getwd() + if err != nil { + fail(err) + } + root, err := specsync.ResolveSpecRoot(*openspec, flagSet, *store, cwd) + if err != nil { + fail(err) + } + if err := root.Validate(); err != nil { + fail(err) + } + return root +} + +// guardStoreScope refuses an unscoped write against a store. A store spans +// repositories, so "every change" is an org-wide blast radius rather than +// one repo's backlog. +func guardStoreScope(root specsync.ResolvedRoot, slug string, all bool) { + if !root.IsStore() || slug != "" || all { + return + } + fmt.Fprintf(os.Stderr, + "specsync: refusing an unscoped sync against store %q — a store spans repositories.\n"+ + "Pass -change for one change, or -all to sync every change in the store.\n", + root.StoreID) + os.Exit(2) +} + +// scopedTargetRepos returns the GitHub repos declared by a single scoped +// change. It yields nothing when the run is unscoped or when -repo was given, +// because an explicit flag always wins. +func scopedTargetRepos(openspecDir, slug, explicitRepo string) []string { + if slug == "" || explicitRepo != "" { + return nil + } + changes, err := specsync.LoadChanges(openspecDir) + if err != nil { + return nil + } + for _, c := range changes { + if c.Slug == slug { + return specsync.GitHubTargetRepos(c.Dir) + } + } + return nil +} diff --git a/cmd/specsync/traceability.go b/cmd/specsync/traceability.go index 990a7f7..dad6c72 100644 --- a/cmd/specsync/traceability.go +++ b/cmd/specsync/traceability.go @@ -16,7 +16,7 @@ import ( // runTrace prints the raw resolved trace graph for a scope (debugging/scripting). func runTrace(args []string) { fs := flag.NewFlagSet("trace", flag.ExitOnError) - openspec := fs.String("openspec", "openspec", "path to the openspec/ directory") + openspec, storeFlag := addRootFlags(fs) change := fs.String("change", "", "scope to a single change slug") since := fs.String("since", "", "range start (default: latest tag)") until := fs.String("until", "", "range end (default: HEAD)") @@ -24,10 +24,7 @@ func runTrace(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 scope := specsync.Scope{Change: *change, Since: *since, Until: *until} tr := resolve(abs, scope) @@ -41,7 +38,7 @@ func runTrace(args []string) { // runScan answers "what already exists here?" for an area before planning. func runScan(args []string) { fs := flag.NewFlagSet("scan", flag.ExitOnError) - openspec := fs.String("openspec", "openspec", "path to the openspec/ directory") + openspec, storeFlag := addRootFlags(fs) openspecs := fs.String("openspecs", "", "comma-separated list of openspec directories for cross-repo scanning") asJSON := fs.Bool("json", false, "emit JSON for a planning agent") references := fs.Bool("references", false, "also show OpenSpec references and worksets") @@ -53,6 +50,8 @@ func runScan(args []string) { fail(fmt.Errorf("scan: give an area — one or more paths and/or a topic\nusage: specsync scan [topic]")) } + defaultRoot := resolveRoot(fs, openspec, storeFlag).Dir + // Determine if this is a cross-repo scan var abs []string if *openspecs != "" { @@ -66,12 +65,7 @@ func runScan(args []string) { abs = append(abs, d) } } else { - // Single-repo scan mode - d, err := filepath.Abs(*openspec) - if err != nil { - fail(err) - } - abs = append(abs, d) + abs = append(abs, defaultRoot) } ctx := context.Background() @@ -211,7 +205,7 @@ func openIssuesInArea(ctx context.Context, in specsync.TraceInput, topic string) // runReleasePlan prints the read-only follow-up report for a revision range. func runReleasePlan(args []string) { fs := flag.NewFlagSet("release-plan", 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)") asJSON := fs.Bool("json", false, "emit JSON") @@ -221,10 +215,7 @@ func runReleasePlan(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} @@ -506,7 +497,7 @@ func archiveCompletedChanges(openspecDir string, candidates []string) ([]string, // body file. Idempotent — re-running does not stack duplicate lines. func runPRBody(args []string) { fs := flag.NewFlagSet("pr-body", flag.ExitOnError) - openspec := fs.String("openspec", "openspec", "path to the openspec/ directory") + openspec, storeFlag := addRootFlags(fs) change := fs.String("change", "", "change slug (required)") bodyFile := fs.String("body-file", "", "file whose contents are merged after the reference line") repo := fs.String("repo", "", "target repo as owner/name (default: auto-detect from git remote)") @@ -517,10 +508,7 @@ func runPRBody(args []string) { fail(fmt.Errorf("pr-body: -change is required")) } - abs, err := filepath.Abs(*openspec) - if err != nil { - fail(err) - } + abs := resolveRoot(fs, openspec, storeFlag).Dir c, err := specsync.LoadChangeBySlug(abs, *change) if err != nil { @@ -633,16 +621,28 @@ func ensureNoDuplicateReference(body string, allComplete bool, issueID string) s // matches a change slug but whose body has no reference to that change's issue. func runVerify(args []string) { fs := flag.NewFlagSet("verify", flag.ExitOnError) - openspec := fs.String("openspec", "openspec", "path to the openspec/ directory") + openspec, store := addRootFlags(fs) repo := fs.String("repo", "", "target repo as owner/name (default: auto-detect from git remote)") + change := fs.String("change", "", "verify this change against its own spec deltas (OpenSpec's Verify phase)") + asJSON := fs.Bool("json", false, "output as JSON") + checklist := fs.Bool("checklist", false, "print only the acceptance checklist, for pasting into a PR") _ = fs.Parse(args) checkArgs(fs, args, 0) - abs, err := filepath.Abs(*openspec) - if err != nil { - fail(err) + root := resolveRoot(fs, openspec, store) + + // -change verifies one change against its behaviour contract; without it, + // verify sweeps the release for change -> issue -> PR traceability. + if *change != "" || *checklist { + if *change == "" { + fail(fmt.Errorf("-checklist needs -change ")) + } + runVerifySpec(root, *change, *asJSON, *checklist) + return } + abs := root.Dir + // Load all changes. changes, err := specsync.LoadChanges(abs) if err != nil { @@ -651,9 +651,9 @@ func runVerify(args []string) { // Build a map: change slug -> synced issue number. type changeIssue struct { - slug string + slug string issueNum string - url string + url string } var changeIssues []changeIssue for _, c := range changes { diff --git a/cmd/specsync/traceability_prbody_test.go b/cmd/specsync/traceability_prbody_test.go index 944de8f..f4fbdcf 100644 --- a/cmd/specsync/traceability_prbody_test.go +++ b/cmd/specsync/traceability_prbody_test.go @@ -134,40 +134,40 @@ func TestBranchMatchesChange(t *testing.T) { func TestPRBodyReferencesIssue(t *testing.T) { tests := []struct { - name string - body string - issueNum string - want bool + name string + body string + issueNum string + want bool }{ { - name: "simple reference", - body: "This PR implements the feature\nRefs #42", - issueNum: "42", - want: true, + name: "simple reference", + body: "This PR implements the feature\nRefs #42", + issueNum: "42", + want: true, }, { - name: "closes reference", - body: "Closes #42\nAll tasks done", - issueNum: "42", - want: true, + name: "closes reference", + body: "Closes #42\nAll tasks done", + issueNum: "42", + want: true, }, { - name: "part of reference", - body: "Part of #42\nPhase 1 of 3", - issueNum: "42", - want: true, + name: "part of reference", + body: "Part of #42\nPhase 1 of 3", + issueNum: "42", + want: true, }, { - name: "different issue", - body: "Refs #43\nNot related to anything", - issueNum: "42", - want: false, + name: "different issue", + body: "Refs #43\nNot related to anything", + issueNum: "42", + want: false, }, { - name: "no reference", - body: "Just a regular PR body\nNo issue references", - issueNum: "42", - want: false, + name: "no reference", + body: "Just a regular PR body\nNo issue references", + issueNum: "42", + want: false, }, } diff --git a/cmd/specsync/verify_spec.go b/cmd/specsync/verify_spec.go new file mode 100644 index 0000000..dece87e --- /dev/null +++ b/cmd/specsync/verify_spec.go @@ -0,0 +1,87 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + + specsync "github.com/androidand/specsync" +) + +// runVerifySpec implements OpenSpec's Verify phase: it walks a change's spec +// deltas and reports the acceptance checklist a reviewer works through before +// the change is archived. Without this step the contract written during +// Propose is never read again, which is how a spec becomes pure ceremony. +func runVerifySpec(root specsync.ResolvedRoot, slug string, asJSON, checklist bool) { + changes, err := specsync.LoadChanges(root.Dir) + if err != nil { + fail(err) + } + var target *specsync.Change + for i := range changes { + if changes[i].Slug == slug { + target = &changes[i] + break + } + } + if target == nil { + fail(fmt.Errorf("no change %q under %s", slug, root.Dir)) + } + + report := specsync.Verify(*target) + + if checklist { + section := specsync.VerificationSection(target.Deltas) + if section == "" { + fail(fmt.Errorf("change %q has no specs/ delta, so there is no acceptance checklist to render", slug)) + } + fmt.Println(section) + return + } + + if asJSON { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + if err := enc.Encode(report); err != nil { + fail(err) + } + if !report.OK() { + os.Exit(1) + } + return + } + + fmt.Printf("verify %s\n", report.Slug) + if report.Contracted { + fmt.Printf(" %d requirement(s), %d scenario(s), tasks %s\n\n", report.Requirements, report.Scenarios, report.Tasks) + } else { + fmt.Printf(" no behaviour contract, tasks %s\n\n", report.Tasks) + } + for _, c := range report.Checks { + fmt.Printf(" %s %s\n", checkGlyph(c.Status), c.Name) + if c.Detail != "" { + fmt.Printf(" %s\n", c.Detail) + } + } + if len(report.Criteria) > 0 { + fmt.Printf("\n acceptance criteria\n") + for _, line := range report.Criteria { + fmt.Printf(" - [ ] %s\n", line) + } + } + if !report.OK() { + fmt.Fprintln(os.Stderr, "\nverify failed") + os.Exit(1) + } +} + +func checkGlyph(s specsync.CheckStatus) string { + switch s { + case specsync.CheckPass: + return "✓" + case specsync.CheckFail: + return "✗" + default: + return "!" + } +} diff --git a/npm/skills/specsync/SKILL.md b/npm/skills/specsync/SKILL.md index 4916d9f..d9702ae 100644 --- a/npm/skills/specsync/SKILL.md +++ b/npm/skills/specsync/SKILL.md @@ -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//` 2. **Implement**: Write code, update `tasks.md` 3. **Sync**: `specsync -dry-run -change ` then `specsync -change ` diff --git a/openspec/changes/archive/2026-09-17-spec-issue-linker/links.md b/openspec/changes/archive/2026-09-17-spec-issue-linker/links.md new file mode 100644 index 0000000..5de8c0c --- /dev/null +++ b/openspec/changes/archive/2026-09-17-spec-issue-linker/links.md @@ -0,0 +1 @@ +- androidand/specsync#160 diff --git a/openspec/changes/archive/2026-09-17-spec-issue-linker/proposal.md b/openspec/changes/archive/2026-09-17-spec-issue-linker/proposal.md new file mode 100644 index 0000000..1e0a944 --- /dev/null +++ b/openspec/changes/archive/2026-09-17-spec-issue-linker/proposal.md @@ -0,0 +1,35 @@ +# Spec ↔ issue linker + +## Why + +A spec and its issue can originate from either side: an issue created first (by a +human or an orchestrator) that later gains a spec, or a spec authored first that +needs an issue. specsync must resolve "which issue is this change" robustly +across both, without inventing a parallel identity scheme. + +Issue-first scaffolding is handled by the dedicated `github-issue-pull` change. +This change focuses on deterministic link resolution once local and tracker +artifacts exist. + +## What + +Add a `Linker` abstraction that resolves a change to its issue ref by consulting, +in priority order: + +1. the current git branch name (issue-linked branches encode the issue number), +2. the `` marker in issue bodies, +3. the local `.specsync/` ref cache, +4. an external relation source (e.g. an MCP that knows issue↔branch↔repo links). + +The first hit wins; the result is cached. This makes issue-first and spec-first +flows converge on the same link. + +## Scope + +- `Linker` interface + chained resolver. +- Branch-name resolver (configurable pattern). +- Wire the resolver into the sync engine ahead of provider `Find`. + +## Release note + +Linker resolves spec↔issue from branch name, marker, or cache — sync updates the right issue instead of creating a duplicate. diff --git a/openspec/changes/archive/2026-09-17-spec-issue-linker/tasks.md b/openspec/changes/archive/2026-09-17-spec-issue-linker/tasks.md new file mode 100644 index 0000000..4977c20 --- /dev/null +++ b/openspec/changes/archive/2026-09-17-spec-issue-linker/tasks.md @@ -0,0 +1,25 @@ +# Tasks: Spec ↔ issue linker + +## 1. Linker interface + +- [x] 1.1 Define `Linker` interface in a new `linker.go`: `Resolve(ctx, changeDir) (*Ref, error)` +- [x] 1.2 Define chained resolver: tries each resolver in order, first hit wins + +## 2. Resolvers + +- [x] 2.1 Branch-name resolver: configurable pattern (e.g. `feat/(\d+)-.*` → issue `#1`) +- [x] 2.2 Marker resolver: parse `` from issue body via provider +- [x] 2.3 Cache resolver: read `.specsync/refs.json` (existing behavior, refactored behind Linker) +- [x] 2.4 External resolver: optional hook for MCP or other external relation sources + +## 3. Integration + +- [x] 3.1 Wire Linker into sync engine ahead of provider `Find` +- [x] 3.2 Use Linker in `pull` for issue-first flow (resolve branch → issue) +- [x] 3.3 Support spec-first creation when no link resolves (existing behavior) + +## 4. Verification + +- [x] 4.1 Tests: branch-name pattern matching, marker parsing, cache lookup, chain order +- [x] 4.2 Integration test: sync resolves issue via branch name without explicit ref +- [x] 4.3 Update the specsync skill: how Linker resolves issues from branches/markers diff --git a/openspec/changes/commit-change-stage/proposal.md b/openspec/changes/commit-change-stage/proposal.md new file mode 100644 index 0000000..ab5655a --- /dev/null +++ b/openspec/changes/commit-change-stage/proposal.md @@ -0,0 +1,27 @@ +# Commit a change's stage so a shared plan agrees with itself + +## Why +specsync advertises workflow stages as "committed as metadata rather than +external labels" — the argument for keeping planning state in the repo instead +of in tracker labels. It is not true. `Stage` is written to +`.specsync/metadata.json`, and the whole `.specsync/` directory is gitignored: +`git ls-files` finds zero tracked files under it. + +Repo-local and solo, nobody notices. It breaks the moment planning is shared: +in a store, every teammate's clone has its own private idea of what stage each +change is in, and a stage set by one person is invisible to everyone else. + +Surfaced while implementing openspec-store-support, which hit the same defect +for `targets` and resolved it by declaring them in a committed per-change +`specsync.yml`. Stage belongs in the same file. + +## What Changes +- Read `stage` and `priority` from the committed per-change `specsync.yml`. +- Keep reading `.specsync/metadata.json` as a fallback, and migrate on the next + write, so existing local state is not lost. +- Keep the projection cache (`refs.json`, `board.json`, `deps.json`) gitignored: + those are caches, and committing them would leak projection ids. + +## Impact +- Affected specs: change-state +- Affected code: store.go, change.go, the set-stage and set-priority commands diff --git a/openspec/changes/commit-change-stage/specs/change-state/spec.md b/openspec/changes/commit-change-stage/specs/change-state/spec.md new file mode 100644 index 0000000..b2cd672 --- /dev/null +++ b/openspec/changes/commit-change-stage/specs/change-state/spec.md @@ -0,0 +1,31 @@ +# change-state + +## Purpose +Defines which of a change's state travels with the repository and which stays +local to one machine — the distinction that decides whether a shared plan means +the same thing to everybody reading it. + +## ADDED Requirements + +### Requirement: Stage and priority are committed +specsync SHALL persist a change's stage and priority in a committed file inside +the change folder, so that a stage set by one person is the stage every other +clone reads. + +#### Scenario: A teammate sees the stage +- **WHEN** one person sets a change's stage and commits +- **THEN** a teammate who pulls reads that same stage +- **AND** the value is not in the gitignored projection cache + +#### Scenario: Existing local state is migrated, not lost +- **WHEN** a change has a stage only in `.specsync/metadata.json` +- **THEN** specsync reads it +- **AND** the next write records it in the committed file + +### Requirement: The projection cache stays local +specsync SHALL keep tracker ids, board bindings and dependency baselines out of +version control, because they identify a projection rather than describe a plan. + +#### Scenario: Refs are never committed +- **WHEN** a change is synced and its refs are cached +- **THEN** the cache remains under the gitignored `.specsync/` directory diff --git a/openspec/changes/openspec-store-support/design.md b/openspec/changes/openspec-store-support/design.md new file mode 100644 index 0000000..cce3649 --- /dev/null +++ b/openspec/changes/openspec-store-support/design.md @@ -0,0 +1,57 @@ +# Design + +## Where `targets` lives +A committed `specsync.yml` inside the change folder, holding a `targets` array in +the same provider-key format as `refs.json` (`"github:owner/name"`), so targets +and refs line up by key. + +Rejected: `.specsync/metadata.json`, the obvious first choice and wrong. The whole +`.specsync/` directory is gitignored — `git ls-files` finds zero tracked files +under it — because it holds the projection cache, which must never be committed. +Targets have the opposite requirement: a store is shared, so every teammate's +clone must agree on where a change belongs. A target that does not survive a +clone is not a declaration, it is a local guess. + +Note this also means `stage` is machine-local today, which contradicts the +promise that stages are committed metadata rather than external labels. Out of +scope here, but tracked: see `commit-change-stage`. + +Rejected: a `targets:` key in `openspec/specsync.yml`. In store-only mode that file +is one-per-store, but targets are one-per-change. + +Rejected: parsing `links.md`. It records relationships between changes, not the +change's own destination, and it is append-only prose. + +## Resolution order +`repo.go` gains `RuleChangeTarget` between the explicit flag and gh's own detection: + + -repo flag → change targets → gh default → origin → upstream + +A change with no targets resolves exactly as today, which keeps every repo-local +setup working untouched. + +## Store root resolution + -openspec flag → -store → openspec/config.yaml `store:` → ./openspec + +The registry is OpenSpec's own (`~/.openspec/`). specsync reads it rather than +keeping a second registry, and shells out to `openspec store list --json` so the +format stays OpenSpec's to change. + +## Verify +specsync did not parse `specs/` deltas at all: `Change` carried proposal, tasks, +design and links, but no behaviour contract. That absence is why the Verify phase +was unreachable — there was nothing to verify against. + +`Verify` reads a change's deltas and reports the acceptance checklist a reviewer +works through. A change with no deltas is reported as uncontracted and warns +rather than fails: forcing a contract onto a dependency bump is exactly how specs +become the ceremony their critics describe. + +`verify` already existed as a release-traceability sweep. It keeps that behaviour +when unscoped, and verifies one change against its spec when given `-change`. + +## Upstream first +`targets` answers a question OpenSpec leaves open. File it upstream before shipping +a private format, and match whatever shape is proposed so convergence is cheap. If +OpenSpec adopts an equivalent field, specsync reads that and treats its own as a +deprecated alias. diff --git a/openspec/changes/openspec-store-support/proposal.md b/openspec/changes/openspec-store-support/proposal.md new file mode 100644 index 0000000..2aeeb79 --- /dev/null +++ b/openspec/changes/openspec-store-support/proposal.md @@ -0,0 +1,40 @@ +# Support OpenSpec stores as a first-class spec root + +## Why +OpenSpec 1.5 ships stores (beta): planning lives in its own git repo, and each code +repo carries one line — `store: ` in `openspec/config.yaml`. This removes the +single biggest adoption objection specsync faces, that planning markdown lands in +the code repo and in every PR diff. + +specsync does not understand stores. Verified against a real store (openspec 1.5.0): + +- Bare `specsync` in a store-configured code repo lists **zero** changes and exits 0. + Silent empty result, no diagnostic. +- Run from inside the store, repo resolution falls through to the store's own + `origin` and targets the **planning** repo. Issues get filed in the wrong place, + silently. +- The same change synced from two different code repos targets whichever repo the + shell happens to be in. A change has no way to declare where it belongs. + +The last point is the root cause: nothing in a change names its target repo, so +`repo.go` can only ask the working directory. OpenSpec's own store docs do not +define this either — changes in a store are location-agnostic by design. + +## What Changes +- Read `store:` from `openspec/config.yaml` so bare specsync resolves the store. +- Add `-store ` resolving through OpenSpec's machine registry. +- Add `targets` to a committed per-change `specsync.yml`; insert it into repo + resolution ahead of git-remote auto-detection. +- Fan out one change to several target repos, cross-linking the resulting issues. +- Refuse an unscoped whole-store sync unless `-all` is passed. +- Extend `doctor` with store health: resolved, registered, behind remote, dirty, + changes missing targets. + +- Parse `specs/` deltas, which specsync never did, and add the Verify phase on + top of them. + +## Impact +- Affected specs: store-resolution, spec-verification +- Affected code: store.go, spec_delta.go, verify.go, repo.go, change.go, + cmd/specsync/{root,verify_spec,doctor_store,main,traceability}.go +- Repo-local mode is unchanged: no `store:`, no `targets`, identical behaviour. diff --git a/openspec/changes/openspec-store-support/specs/spec-verification/spec.md b/openspec/changes/openspec-store-support/specs/spec-verification/spec.md new file mode 100644 index 0000000..5de6f3f --- /dev/null +++ b/openspec/changes/openspec-store-support/specs/spec-verification/spec.md @@ -0,0 +1,62 @@ +# spec-verification + +## Purpose +Defines how specsync reads a change's behaviour contract and reports whether the +change is ready to archive — OpenSpec's Verify phase, the step between applying +a change and archiving it, without which the contract written during Propose is +never read again. + +## ADDED Requirements + +### Requirement: Spec deltas are parsed into a change +specsync SHALL parse every `specs/**/spec.md` under a change into capabilities, +requirements and scenarios, recording each requirement's delta operation. + +#### Scenario: Requirements and scenarios are counted +- **WHEN** a change carries a delta with two requirements and three scenarios +- **THEN** the loaded change reports two requirements and three scenarios +- **AND** each requirement carries its ADDED or MODIFIED operation + +#### Scenario: A change without specs loads cleanly +- **WHEN** a change has no `specs/` directory +- **THEN** it loads without error and reports no deltas + +### Requirement: Verification reports an acceptance checklist +specsync SHALL render a change's scenarios as a reviewer-facing checklist, one +line per scenario, qualified by capability and requirement. + +#### Scenario: Checklist is rendered for a PR +- **WHEN** `specsync verify -change -checklist` runs on a contracted change +- **THEN** each scenario appears as an unchecked list item under its requirement +- **AND** the requirement's delta operation is shown + +### Requirement: Unverifiable requirements fail verification +specsync SHALL fail verification for a requirement with no scenario, and for a +requirement whose first body line carries neither SHALL nor MUST, because +neither can be checked against an implementation. + +#### Scenario: Requirement without a scenario fails +- **WHEN** a delta declares a requirement with no `#### Scenario:` block +- **THEN** verification fails naming that requirement + +#### Scenario: Non-normative requirement fails +- **WHEN** a requirement's first body line says "should" rather than SHALL or MUST +- **THEN** verification fails naming that requirement + +#### Scenario: Open tasks fail +- **WHEN** a change has unchecked tasks +- **THEN** verification fails reporting how many remain + +### Requirement: An uncontracted change warns rather than fails +specsync SHALL report a change with no spec delta as uncontracted and warn, not +fail. Not every change alters observable behaviour, and requiring a contract for +a chore is how a specification becomes ceremony. + +#### Scenario: A chore verifies successfully +- **WHEN** a change with no `specs/` delta has all its tasks checked +- **THEN** verification succeeds +- **AND** it warns that nothing will accumulate into `openspec/specs/` on archive + +#### Scenario: A placeholder purpose warns +- **WHEN** a capability's Purpose is empty or still says TBD +- **THEN** verification warns naming that capability diff --git a/openspec/changes/openspec-store-support/specs/store-resolution/spec.md b/openspec/changes/openspec-store-support/specs/store-resolution/spec.md new file mode 100644 index 0000000..afef711 --- /dev/null +++ b/openspec/changes/openspec-store-support/specs/store-resolution/spec.md @@ -0,0 +1,74 @@ +# store-resolution + +## Purpose +Defines how specsync locates its OpenSpec spec root when planning lives in a +standalone store repo, and how a change declares which code repositories it +targets once the working directory no longer implies one. + +## ADDED Requirements + +### Requirement: Store declared by a code repo is resolved +specsync SHALL resolve its spec root to a store's registered location when the +working directory's `openspec/config.yaml` declares `store: `. + +#### Scenario: Bare invocation in a store-configured repo +- **WHEN** `specsync changes` runs in a code repo whose `openspec/config.yaml` declares `store: exopen-plans` +- **THEN** the changes listed are those in the store +- **AND** no `openspec/changes/` directory is required in the code repo + +#### Scenario: Explicit flag wins over declared store +- **WHEN** `-openspec ` is passed in a repo that also declares `store:` +- **THEN** the flag's path is used + +### Requirement: An unresolvable spec root fails loudly +specsync SHALL exit non-zero with a diagnostic naming what it looked for when no +spec root can be resolved, and SHALL NOT report an empty result set as success. + +#### Scenario: Declared store is not registered +- **WHEN** `openspec/config.yaml` declares a store that is not in the machine registry +- **THEN** specsync exits non-zero naming the store id and the registration command +- **AND** it does not print an empty changes table + +### Requirement: A change declares its target repositories +specsync SHALL read an optional `targets` array of provider keys from a change's +committed `specsync.yml`, and when it is present SHALL resolve the target +repository from it rather than from the working directory's git remote. The +declaration SHALL live outside the gitignored `.specsync/` cache so that it +survives a clone. + +#### Scenario: Targets survive a clone +- **WHEN** a change declaring targets is committed and cloned by a teammate +- **THEN** the teammate's specsync resolves the same target repository +- **AND** the declaration is not part of the gitignored projection cache + +#### Scenario: Target overrides the ambient remote +- **WHEN** a change declares `targets: ["github:ExopenGitHub/FusionHub"]` and is synced from the store's own directory +- **THEN** the issue is created in `ExopenGitHub/FusionHub` +- **AND** not in the store's own repository + +#### Scenario: Explicit flag still wins +- **WHEN** `-repo ExopenGitHub/portal` is passed for a change targeting FusionHub +- **THEN** the flag's repo is used + +#### Scenario: No targets preserves current behaviour +- **WHEN** a change declares no targets +- **THEN** the repository resolves via gh detection and the git remote, as before + +### Requirement: One change projects to each of its targets +specsync SHALL maintain one issue per target repository when a change declares +several targets, cross-link those issues, and key each ref by its target. + +#### Scenario: Backend and frontend halves stay linked +- **WHEN** a change targets both `github:ExopenGitHub/FusionHub` and `github:ExopenGitHub/portal` +- **THEN** an issue exists in each repository, each carrying the change's identity marker +- **AND** each issue links to the other +- **AND** `refs.json` holds both under their own provider keys + +### Requirement: A store is not swept by an unscoped sync +specsync SHALL require `-change` or an explicit `-all` before writing when the +resolved spec root is a store. + +#### Scenario: Unscoped sync against a store is refused +- **WHEN** bare `specsync` runs with a store as its spec root and neither `-change` nor `-all` is given +- **THEN** it exits non-zero explaining that a store spans repositories +- **AND** no provider write occurs diff --git a/openspec/changes/openspec-store-support/tasks.md b/openspec/changes/openspec-store-support/tasks.md new file mode 100644 index 0000000..45bd0c2 --- /dev/null +++ b/openspec/changes/openspec-store-support/tasks.md @@ -0,0 +1,20 @@ +- [ ] File upstream OpenSpec feedback proposing a standard target-repo field on a change +- [ ] Report the `openspec view` store bug upstream: it ignores `store:` and reports 0 changes while `list`/`status`/`validate` resolve correctly +- [ ] Report the undocumented validator rule upstream: SHALL/MUST is only recognised on a requirement's first body line +- [x] Read `store:` from `openspec/config.yaml` in spec-root resolution +- [x] Add `-store `, resolving via `openspec store list --json` +- [x] Tolerate the several key names OpenSpec has used for a store's local path (`root`, `location`, `path`) while stores are beta +- [x] Fail loudly when a spec root resolves to nothing instead of printing an empty table +- [x] Route every command that reads changes through the shared root resolver +- [x] Add `targets` to a committed per-change `specsync.yml` +- [x] Add `RuleChangeTarget` to repo resolution ahead of gh/origin detection +- [x] Have `pull` record the source issue's repo as the change's target +- [x] Fan out a multi-target change to one provider per target repo +- [x] Require `-change` or `-all` when the spec root is a store +- [x] Add `doctor store`: root, registration, dirty, behind-remote, missing targets, uncontracted changes +- [x] Parse `specs/**/spec.md` deltas into the Change model +- [x] Add the Verify phase: `specsync verify -change `, with `-json` and `-checklist` +- [~] dropped: `targets` in `.specsync/metadata.json` — the whole directory is gitignored (0 tracked files), so a target would never survive a clone. Moved to a committed per-change `specsync.yml`. Surfaced that `stage` has the same defect; tracked separately as commit-change-stage +- [ ] Cross-link the issues a multi-target change creates, so each half names the other +- [ ] Project the acceptance checklist into the issue body as a collapsed Verification section +- [ ] Add `specsync store migrate` to move changes/specs from N code repos into one store, writing targets from each source repo diff --git a/openspec/changes/openspec-upstream-feedback/proposal.md b/openspec/changes/openspec-upstream-feedback/proposal.md new file mode 100644 index 0000000..78af490 --- /dev/null +++ b/openspec/changes/openspec-upstream-feedback/proposal.md @@ -0,0 +1,17 @@ +# Report the OpenSpec defects found while adding store support + +## Why +Building store support surfaced three defects and one gap in OpenSpec 1.5.0, +all reproducible. They cost real debugging time and will cost every other +adopter the same, and the gap is one specsync is about to work around with a +private format — better to propose the standard first. + +No behaviour of specsync changes here, so this change carries no spec delta. +It is tracked as a change rather than as loose issues because the fourth item +gates a design decision in openspec-store-support. + +## What Changes +Nothing in this repository. Four reports filed upstream. + +## Impact +- Affected specs: none — this is coordination work, not a behaviour change diff --git a/openspec/changes/openspec-upstream-feedback/tasks.md b/openspec/changes/openspec-upstream-feedback/tasks.md new file mode 100644 index 0000000..da7b7cf --- /dev/null +++ b/openspec/changes/openspec-upstream-feedback/tasks.md @@ -0,0 +1,5 @@ +- [ ] `openspec view` ignores a declared store: run from a code repo whose `openspec/config.yaml` says `store: ` and it reports "0 specs, 0 changes" and exits 0, while `list`, `status` and `validate` all resolve the store correctly. It also rejects `--store`. The one command that presents itself as the dashboard is the one that silently shows an empty one +- [ ] Validator only recognises SHALL/MUST on a requirement's first body line. A requirement whose normative verb wraps to line two fails with "must contain SHALL or MUST", which reads as though the word is absent. Either scan the whole requirement body or say which line was checked +- [ ] Archive leaves `## Purpose\nTBD - created by archiving change . Update Purpose after archive.` and nothing ever prompts again. 16 of 18 capabilities in this repo still carried the placeholder months later. Either prompt on archive or omit the placeholder so the gap is visible +- [ ] Propose a standard target-repository field on a change. Store docs describe changes as location-agnostic, but a change in a shared store must name the code repo it targets or every tracker-sync tool invents its own. specsync is shipping `targets` in a committed per-change `specsync.yml`; happy to converge on whatever shape upstream prefers +- [ ] `openspec validate` fails any change with no `specs/` delta ("Change must have at least one delta"). That makes a coordination or chore change unrepresentable, and pushes people either to invent a hollow requirement or to stop using changes for that work. A change with no behaviour delta is a legitimate shape; warn rather than fail, or let a schema opt out diff --git a/openspec/changes/warn-before-creating-a-duplicate-issue/proposal.md b/openspec/changes/warn-before-creating-a-duplicate-issue/proposal.md new file mode 100644 index 0000000..e542baa --- /dev/null +++ b/openspec/changes/warn-before-creating-a-duplicate-issue/proposal.md @@ -0,0 +1,66 @@ +# Say when a sync is about to open a new issue, and what it might duplicate + +## Why + +`specsync -change ` opens a new issue whenever the change has no ref yet. It never looks at +what is already open in the target repo, and its output gives the reader no way to notice. + +Reproduced while working an incident: a change was written for remediation work, synced, and opened +a new issue — while the issue that had tracked that same work for two days sat open in the same +repo. The duplicate was caught by a human reading the board, not by the tool. + +Two things made it easy to walk into. + +**1. `-dry-run` uses the same word as a real run.** The preview ends: + +``` + created https://github.com///issues/0 [github:owner/repo] (my-change) +specsync: 1 created, 0 updated +``` + +`created` is also what an update-path preview prints for the *other* branch of the same `if`, so the +line scans as routine projection output. The one detail that distinguishes a create — `issues/0`, +the placeholder for a number that does not exist yet — is the least conspicuous thing on the line. +Nothing says *this will open a new issue*. + +**2. Nothing looks for prior art.** `sync.go` decides with `created := !hadRef` — purely "do we have +a ref cached for this change". The CLI already has `scan` (find related work) and `adopt` (bind a +change to an existing issue), so the pieces exist; nothing routes you to them at the one moment they +matter, which is immediately before a create. + +## What + +### Make the create path unmistakable in a dry run + +On the create path, `-dry-run` should name what it is about to do rather than describe the result in +the past tense. Something that cannot be mistaken for an update — a distinct verb, and the absence +of a real issue number stated rather than implied. + +### Offer prior art before creating + +Before a create, list open issues in the target repo and surface the plausible matches, with the +`adopt` command spelled out: + +``` + NEW ISSUE (no issue exists yet) [github:owner/repo] (my-change) + 2 open issues may already cover this: + #128 Widget sync starves under load (shared: widget, sync) + #131 Widget totals double-counted (shared: widget) + → bind instead with: specsync adopt -issue 128 -change my-change +``` + +Matching should be on **shared distinctive words**, not title similarity. This matters and is worth +stating plainly: in the case that prompted this, the two titles shared exactly one word. Two people +describing the same problem rarely phrase it alike, and a similarity score high enough to avoid false +positives would have scored that pair near zero. Shared jargon — a subsystem, a source system, a +component — is a far better signal than string distance. + +This is advisory. It prints and proceeds; it does not block, and it does not need to be right. A +list that is wrong costs the reader two seconds. A duplicate costs a cleanup. + +## Non-goals + +- Blocking or refusing a create. Duplicate detection is a heuristic and must never gate a sync. +- Changing `adopt` or `scan`. Both work; they are simply not reachable from where the mistake happens. +- Semantic or embedding-based matching. A stopword list and a word intersection is enough for a hint, + and it stays offline. diff --git a/openspec/changes/warn-before-creating-a-duplicate-issue/tasks.md b/openspec/changes/warn-before-creating-a-duplicate-issue/tasks.md new file mode 100644 index 0000000..2a8d754 --- /dev/null +++ b/openspec/changes/warn-before-creating-a-duplicate-issue/tasks.md @@ -0,0 +1,22 @@ +## Phase 1 — make the create path legible + +- [ ] Distinguish the create branch from the update branch in the dry-run printer + (`cmd/specsync/main.go`), so a preview about to open an issue cannot read as a projection +- [ ] Stop printing the `issues/0` placeholder URL on the create path; state that no issue exists yet +- [ ] A test asserting a create-path dry run and an update-path dry run do not produce the same verb + +## Phase 2 — surface prior art + +- [ ] Add distinctive-word extraction: normalise, drop words under 4 characters, drop a small + stopword list, dedupe +- [ ] Before a create, list open issues in the target repo and rank by shared distinctive words +- [ ] Print the top few with the shared words shown, and the `specsync adopt -issue N -change ` + line ready to copy +- [ ] Fetch failures must degrade to printing nothing — a hint is never worth failing a sync over + +## Phase 3 — prove it + +- [ ] A test with two differently-phrased titles sharing one distinctive word asserting the match is + found. This is the case that motivated the change, so it is the one that must pass +- [ ] A test asserting unrelated titles produce no suggestions +- [ ] A test asserting a provider error during the lookup leaves the sync result unchanged diff --git a/openspec/specs/audit-tasks/spec.md b/openspec/specs/audit-tasks/spec.md index 974c221..26e9009 100644 --- a/openspec/specs/audit-tasks/spec.md +++ b/openspec/specs/audit-tasks/spec.md @@ -1,7 +1,9 @@ # audit-tasks Specification ## Purpose -TBD - created by archiving change fix-task-dogfooding. Update Purpose after archive. +Detects drift between what a change's tasks.md claims and what the repository +shows, so a task list stays a description of the work rather than an +aspiration nobody rechecks. ## Requirements ### Requirement: Scan changes for task-drift specsync SHALL provide an `audit-tasks` command that loads all OpenSpec changes, diff --git a/openspec/specs/audit/spec.md b/openspec/specs/audit/spec.md index 458a1e7..e2439ba 100644 --- a/openspec/specs/audit/spec.md +++ b/openspec/specs/audit/spec.md @@ -1,7 +1,9 @@ # audit Specification ## Purpose -TBD - created by archiving change audit-archived-not-merged. Update Purpose after archive. +Finds work that was archived without ever shipping. Archiving is the step that +turns a change into an accumulated spec, so an archive with no merged PR behind +it quietly corrupts the record of what the system does. ## Requirements ### Requirement: Read PR state from GitHub specsync SHALL provide methods on `GitHubProvider` to query open and recently diff --git a/openspec/specs/commit-source/spec.md b/openspec/specs/commit-source/spec.md index 9bfe7cc..9abbb48 100644 --- a/openspec/specs/commit-source/spec.md +++ b/openspec/specs/commit-source/spec.md @@ -1,7 +1,8 @@ # commit-source Specification ## Purpose -TBD - created by archiving change add-change-traceability-model. Update Purpose after archive. +Reads a revision range's commits as structured records, so release tooling can +reason about history without any component re-implementing git plumbing. ## Requirements ### Requirement: Read commits for a revision range specsync SHALL provide a `CommitSource` capability that returns the parsed diff --git a/openspec/specs/conventional-commits/spec.md b/openspec/specs/conventional-commits/spec.md index dcc455b..ca48bb1 100644 --- a/openspec/specs/conventional-commits/spec.md +++ b/openspec/specs/conventional-commits/spec.md @@ -1,7 +1,9 @@ # conventional-commits Specification ## Purpose -TBD - created by archiving change add-change-traceability-model. Update Purpose after archive. +Parses Conventional Commit headers into type, scope, breaking-change marker and +issue references, tolerating messages that do not follow the convention rather +than rejecting them. ## Requirements ### Requirement: Parse the Conventional Commits header specsync SHALL parse a commit message header of the form diff --git a/openspec/specs/github-projects-projection/spec.md b/openspec/specs/github-projects-projection/spec.md index f8e8558..36988dc 100644 --- a/openspec/specs/github-projects-projection/spec.md +++ b/openspec/specs/github-projects-projection/spec.md @@ -1,7 +1,9 @@ # github-projects-projection Specification ## Purpose -TBD - created by archiving change github-projects-compatibility. Update Purpose after archive. +Projects a change's stage onto a GitHub Projects board: resolving the Status +field from the project's own schema, respecting human moves, and never +inferring a board that was not explicitly configured. ## Requirements ### Requirement: Opt-in target project specsync SHALL accept an optional target GitHub ProjectV2 for `sync` and `pull`, diff --git a/openspec/specs/go-library-api/spec.md b/openspec/specs/go-library-api/spec.md index e38e59e..fd1982e 100644 --- a/openspec/specs/go-library-api/spec.md +++ b/openspec/specs/go-library-api/spec.md @@ -1,7 +1,9 @@ # go-library-api Specification ## Purpose -TBD - created by archiving change correct-library-import-path. Update Purpose after archive. +Keeps specsync usable as an imported Go library, not only as a CLI: the +documented import path resolves and archived records describe what actually +shipped under it. ## Requirements ### Requirement: The documented import path resolves diff --git a/openspec/specs/launch-readiness/spec.md b/openspec/specs/launch-readiness/spec.md index 91ed416..c0f6e20 100644 --- a/openspec/specs/launch-readiness/spec.md +++ b/openspec/specs/launch-readiness/spec.md @@ -1,7 +1,9 @@ # launch-readiness Specification ## Purpose -TBD - created by archiving change launch-readiness. Update Purpose after archive. +The minimum a released version owes a first-time user: a README covering the +shipped CLI surface, discoverable package metadata, and a binary that can report +its own version. ## Requirements ### Requirement: The README documents the full shipped CLI surface The README SHALL document every subcommand the released binary accepts diff --git a/openspec/specs/openspec-source/spec.md b/openspec/specs/openspec-source/spec.md index beeae4a..7f0f7ac 100644 --- a/openspec/specs/openspec-source/spec.md +++ b/openspec/specs/openspec-source/spec.md @@ -1,7 +1,9 @@ # openspec-source Specification ## Purpose -TBD - created by archiving change add-change-traceability-model. Update Purpose after archive. +Sources OpenSpec data through the openspec CLI rather than parsing spec files +directly, treating its JSON as a version-scoped contract and degrading +gracefully when the binary is absent. ## Requirements ### Requirement: Source OpenSpec data via the openspec CLI specsync SHALL obtain OpenSpec change metadata, requirement deltas, and diff --git a/openspec/specs/planning-scan/spec.md b/openspec/specs/planning-scan/spec.md index 6195802..a1a2f07 100644 --- a/openspec/specs/planning-scan/spec.md +++ b/openspec/specs/planning-scan/spec.md @@ -1,7 +1,9 @@ # planning-scan Specification ## Purpose -TBD - created by archiving change add-planning-scan. Update Purpose after archive. +Answers "what already exists here?" before new work is planned — existing +changes, open issues with no linked change — deterministically and read-only, +without inferring relationships that were never declared. ## Requirements ### Requirement: Report what already exists for an area specsync SHALL provide a `scan` command that, given an area (path globs and/or a diff --git a/openspec/specs/release-impact/spec.md b/openspec/specs/release-impact/spec.md index b55f1c4..844c30a 100644 --- a/openspec/specs/release-impact/spec.md +++ b/openspec/specs/release-impact/spec.md @@ -1,7 +1,9 @@ # release-impact Specification ## Purpose -TBD - created by archiving change add-release-traceability. Update Purpose after archive. +Infers an advisory SemVer bump from several independent signals, including +OpenSpec requirement deltas, so a behaviour change that never touched a public +symbol still registers as one. ## Requirements ### Requirement: Infer an advisory SemVer bump from multiple signals specsync SHALL infer an advisory bump (`none`, `patch`, `minor`, or `major`) by diff --git a/openspec/specs/release-plan/spec.md b/openspec/specs/release-plan/spec.md index 6cc5213..1b9d651 100644 --- a/openspec/specs/release-plan/spec.md +++ b/openspec/specs/release-plan/spec.md @@ -1,7 +1,9 @@ # release-plan Specification ## Purpose -TBD - created by archiving change add-release-traceability. Update Purpose after archive. +Reports what a release actually shipped and what is still loose, reading from +the trace graph. Read-only by default: writing to specs is gated behind an +explicit flag. ## Requirements ### Requirement: Report what shipped and what is loose specsync SHALL provide a `release-plan` report for a revision range that lists diff --git a/openspec/specs/release-tool-detection/spec.md b/openspec/specs/release-tool-detection/spec.md index 4f3a7e9..7aeffe3 100644 --- a/openspec/specs/release-tool-detection/spec.md +++ b/openspec/specs/release-tool-detection/spec.md @@ -1,7 +1,9 @@ # release-tool-detection Specification ## Purpose -TBD - created by archiving change add-release-traceability. Update Purpose after archive. +Detects the project's own release tool from filesystem evidence and defers to +it, reporting responsibilities rather than invoking anything. specsync plans a +release; it does not perform one. ## Requirements ### Requirement: Detect the project's release tool by filesystem evidence specsync SHALL detect which release tool a project uses by probing for common diff --git a/openspec/specs/site-deployment/spec.md b/openspec/specs/site-deployment/spec.md index b0b0699..50979c5 100644 --- a/openspec/specs/site-deployment/spec.md +++ b/openspec/specs/site-deployment/spec.md @@ -1,7 +1,8 @@ # site-deployment Specification ## Purpose -TBD - created by archiving change deploy-standalone-site. Update Purpose after archive. +Keeps the product site to one canonical source deployed from main, with the +legacy route permanently redirected, so documentation cannot silently fork. ## Requirements ### Requirement: The product site has one canonical source diff --git a/openspec/specs/stable-projection-identity/spec.md b/openspec/specs/stable-projection-identity/spec.md index 4f450d9..cadf155 100644 --- a/openspec/specs/stable-projection-identity/spec.md +++ b/openspec/specs/stable-projection-identity/spec.md @@ -1,7 +1,9 @@ # stable-projection-identity Specification ## Purpose -TBD - created by archiving change stable-projection-ref-key. Update Purpose after archive. +Guarantees that a change resolves to the same tracker issue however it was +bound — pulled, adopted, or synced with or without an explicit repo — so a lost +local cache never produces a duplicate issue. ## Requirements ### Requirement: Repo-stable ref-cache key The GitHub provider SHALL key the ref cache by the concrete target repository, diff --git a/openspec/specs/trace-model/spec.md b/openspec/specs/trace-model/spec.md index 5b24884..6b9bd69 100644 --- a/openspec/specs/trace-model/spec.md +++ b/openspec/specs/trace-model/spec.md @@ -1,7 +1,9 @@ # trace-model Specification ## Purpose -TBD - created by archiving change add-change-traceability-model. Update Purpose after archive. +The traceability graph linking a change to its issues, commits and PRs, with the +provenance of every link recorded, resolvable over a change, a revision range, +or an area of the codebase. ## Requirements ### Requirement: Represent a change's traceability graph specsync SHALL model the relationships between an OpenSpec change and its diff --git a/openspec/specs/trace/spec.md b/openspec/specs/trace/spec.md index 2ebdcf1..25bca43 100644 --- a/openspec/specs/trace/spec.md +++ b/openspec/specs/trace/spec.md @@ -1,7 +1,8 @@ # trace Specification ## Purpose -TBD - created by archiving change add-release-traceability. Update Purpose after archive. +Exposes the resolved traceability graph as a read-only command, in human and +machine-readable form. ## Requirements ### Requirement: Expose the resolved trace graph as a command specsync SHALL provide a `trace` command that prints the resolved trace graph for diff --git a/pull.go b/pull.go index 0fba1f6..0058345 100644 --- a/pull.go +++ b/pull.go @@ -96,37 +96,6 @@ func Pull(ctx context.Context, opts PullOptions) (PullResult, error) { return PullResult{}, fmt.Errorf("could not derive a change name from issue %s; pass -change", opts.IssueID) } - // A slug collision (two issues landing on the same directory — a - // generated slug and, more rarely, an explicit -change) must not - // silently overwrite an unrelated change's proposal.md/tasks.md. - // - // Two cases are left alone (existing behavior): re-pulling the *same* - // issue into its own existing directory (the intentional refresh path), - // and an explicit -change into a directory with no ref cache at all - // (a hand-authored, never-synced local change — the human named this - // exact directory, and binding an issue's content into it is the normal - // spec-first-then-pull flow; see TestPullRecordsTaskBase). - // - // A genuine conflict — the directory's ref cache names a *different* - // issue — refuses an explicit -change (pass a different one, or - // `specsync adopt`) and suffixes an auto-derived one instead of - // clobbering the other change. An auto-derived slug colliding with an - // untracked hand-authored directory is also suffixed, since nothing - // asked for that specific name. - if candidateDir := filepath.Join(opts.OpenSpecDir, "changes", slug); dirExists(candidateDir) { - existing, _ := LoadRefs(candidateDir) - conflictsWithDifferentIssue := len(existing) > 0 && !refsContainIssue(existing, issueID) - collidesWithUntrackedDir := len(existing) == 0 && opts.Slug == "" - if conflictsWithDifferentIssue { - if opts.Slug != "" { - return PullResult{}, fmt.Errorf("pull: change %q already exists and is not linked to issue %s; pass a different -change or use `specsync adopt`", slug, issueID) - } - slug = nextAvailableSlug(opts.OpenSpecDir, slug) - } else if collidesWithUntrackedDir { - slug = nextAvailableSlug(opts.OpenSpecDir, slug) - } - } - proposal, tasks, relatedURLs, origAsk, design, disc := splitBody(item.Body, item.Title) // The body may carry only a linked stub for an overflowed design.md; // recover the real content from the comment when supported. @@ -511,17 +480,8 @@ func slugFromMarker(body string) string { return strings.TrimSpace(rest[:j]) } -// maxSlugLen caps a generated slug's length. Long enough for -// add-agent-help-command-style names with room to spare, short enough to -// type as a -change flag and to keep in every log line and path. Applies to -// the slug itself, not to a prefix a caller adds afterward (e.g. epic.go's -// "epic:" marker prefix) — the prefix is fixed overhead, not part of the -// generated name. -const maxSlugLen = 48 - // slugify turns a title into a kebab-case slug: lowercase, with each run of -// non-alphanumeric characters collapsed to a single hyphen and trimmed, then -// capped at maxSlugLen. +// non-alphanumeric characters collapsed to a single hyphen and trimmed. func slugify(s string) string { var b strings.Builder pendingHyphen := false @@ -537,59 +497,5 @@ func slugify(s string) string { pendingHyphen = true } } - return capSlug(b.String(), maxSlugLen) -} - -// capSlug truncates s to at most max characters, preferring a word boundary -// (the last hyphen at or before the cut) so truncation never splits a word. -// When the first word alone exceeds max, it hard-truncates instead of -// returning "" — an empty slug is treated as failure by every caller. -func capSlug(s string, max int) string { - if len(s) <= max { - return s - } - cut := s[:max] - if i := strings.LastIndexByte(cut, '-'); i > 0 { - cut = cut[:i] - } - return strings.TrimRight(cut, "-") -} - -// dirExists reports whether path exists (any type), for the pull collision -// guard below. -func dirExists(path string) bool { - _, err := os.Stat(path) - return err == nil -} - -// refsContainIssue reports whether any cached ref in refs points at issueID — -// i.e. the existing directory tracks the very issue being pulled, making this -// an intentional refresh rather than a collision. -func refsContainIssue(refs map[string]Ref, issueID string) bool { - for _, r := range refs { - if r.ID == issueID { - return true - } - } - return false -} - -// nextAvailableSlug finds the first "-2", "-3", ... whose change -// directory doesn't exist yet, trimming base to keep the result within -// maxSlugLen when the numeric suffix would otherwise push it over. -func nextAvailableSlug(openspecDir, base string) string { - for i := 2; i < 1000; i++ { - suffix := fmt.Sprintf("-%d", i) - candidate := base + suffix - if len(candidate) > maxSlugLen { - candidate = capSlug(base, maxSlugLen-len(suffix)) + suffix - } - if !dirExists(filepath.Join(openspecDir, "changes", candidate)) { - return candidate - } - } - // Exhausted a generous range of suffixes — fall back to the last one - // tried rather than looping forever; pull will simply overwrite it, - // same as today's pre-existing behavior. - return fmt.Sprintf("%s-999", base) + return b.String() } diff --git a/pull_test.go b/pull_test.go index 5388dcc..a7dd222 100644 --- a/pull_test.go +++ b/pull_test.go @@ -644,24 +644,12 @@ func TestPull_RequiresIssueForNonGithub(t *testing.T) { type stubNonGithubProvider struct{} -func (s *stubNonGithubProvider) Name() string { return "beads" } -func (s *stubNonGithubProvider) Find(context.Context, string) (*Ref, error) { return nil, nil } -func (s *stubNonGithubProvider) Push(context.Context, WorkItem, *Ref) (Ref, error) { return Ref{}, nil } -func (s *stubNonGithubProvider) Get(context.Context, string) (FetchedItem, error) { - return FetchedItem{}, nil -} +func (s *stubNonGithubProvider) Name() string { return "beads" } +func (s *stubNonGithubProvider) Find(context.Context, string) (*Ref, error) { return nil, nil } +func (s *stubNonGithubProvider) Push(context.Context, WorkItem, *Ref) (Ref, error) { return Ref{}, nil } +func (s *stubNonGithubProvider) Get(context.Context, string) (FetchedItem, error) { return FetchedItem{}, nil } func TestPull_CannotResolveFromBranch(t *testing.T) { - // Force a branch name that never matches the feat/- pattern, - // regardless of what branch this test suite actually happens to be - // checked out on (e.g. a real feat/- branch, which would - // otherwise make this test try to resolve issue for real and fail - // with an unrelated JSON-parse error instead of the branch-mismatch - // error it's meant to assert on). - prev := currentBranchFn - currentBranchFn = func() (string, error) { return "main", nil } - defer func() { currentBranchFn = prev }() - dir := t.TempDir() // On a branch that doesn't match the pattern, Pull should error. _, err := Pull(context.Background(), PullOptions{ diff --git a/repo.go b/repo.go index fdc24c7..ed765cb 100644 --- a/repo.go +++ b/repo.go @@ -11,9 +11,10 @@ import ( type RepoRule string const ( - RuleExplicit RepoRule = "explicit" // -repo flag - RuleDefault RepoRule = "gh-set-default" // gh repo set-default - RuleOrigin RepoRule = "origin" // git remote origin + RuleExplicit RepoRule = "explicit" // -repo flag + RuleChangeTarget RepoRule = "change-target" // targets: in the change's specsync.yml + RuleDefault RepoRule = "gh-set-default" // gh repo set-default + RuleOrigin RepoRule = "origin" // git remote origin ) // ResolvedRepo holds the resolved repository and which rule selected it. @@ -24,10 +25,18 @@ type ResolvedRepo struct { // RepoResolver resolves the target repository explicitly. type RepoResolver struct { - Explicit string // -repo flag, empty if not set + Explicit string // -repo flag, empty if not set + Targets []string // repos declared by the change, empty if none run func(ctx context.Context, args ...string) (string, error) } +// WithTargets returns the resolver with the change's declared target repos +// inserted ahead of git-remote auto-detection. +func (r *RepoResolver) WithTargets(targets []string) *RepoResolver { + r.Targets = targets + return r +} + // NewRepoResolver returns a resolver that shells out to git/gh. func NewRepoResolver(explicit string) *RepoResolver { return &RepoResolver{Explicit: explicit, run: runGH} @@ -39,12 +48,19 @@ func NewRepoResolverFunc(explicit string, run func(ctx context.Context, args ... } // Resolve returns the target repo and which rule selected it. -// Order: explicit flag → gh-set-default → origin. +// Order: explicit flag → change targets → gh-set-default → origin. // Returns an error only if no resolution path succeeds. +// +// Change targets precede auto-detection because a store's changes are not +// bound to the working directory: without them, the repo a change lands in +// would be whichever repo the shell happens to be standing in. func (r *RepoResolver) Resolve(ctx context.Context) (ResolvedRepo, error) { if r.Explicit != "" { return ResolvedRepo{Repo: r.Explicit, Rule: RuleExplicit}, nil } + if len(r.Targets) > 0 { + return ResolvedRepo{Repo: r.Targets[0], Rule: RuleChangeTarget}, nil + } // Try gh repo set-default. if out, err := r.run(ctx, "repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"); err == nil { diff --git a/site/features.json b/site/features.json index 2e03a9a..157d60d 100644 --- a/site/features.json +++ b/site/features.json @@ -19,14 +19,6 @@ "title": "Planning scan", "body": "specsync scan shows what already exists for an area — in-flight changes, loose issues, recent commits — before you plan." }, - { - "group": "plan", - "icon": "flow", - "title": "See who's already working what", - "body": "specsync topology joins every change with its tracker issue, git branch, and worktree across coordinated stores — a read-only check before an agent starts, so it doesn't duplicate work already in flight.", - "status": "soon", - "issue": 168 - }, { "group": "plan", "icon": "followup", diff --git a/skills/specsync/SKILL.md b/skills/specsync/SKILL.md index 4916d9f..d9702ae 100644 --- a/skills/specsync/SKILL.md +++ b/skills/specsync/SKILL.md @@ -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//` 2. **Implement**: Write code, update `tasks.md` 3. **Sync**: `specsync -dry-run -change ` then `specsync -change ` diff --git a/spec_delta.go b/spec_delta.go new file mode 100644 index 0000000..e85dcfa --- /dev/null +++ b/spec_delta.go @@ -0,0 +1,199 @@ +package specsync + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" +) + +// DeltaOp is the operation a delta section performs on the accumulated spec. +type DeltaOp string + +const ( + OpAdded DeltaOp = "ADDED" + OpModified DeltaOp = "MODIFIED" + OpRemoved DeltaOp = "REMOVED" + OpRenamed DeltaOp = "RENAMED" +) + +// Scenario is a "#### Scenario:" block: a name and its WHEN/THEN/AND steps. +type Scenario struct { + Name string `json:"name"` + Steps []string `json:"steps,omitempty"` +} + +// Requirement is a "### Requirement:" block within a delta section. +type Requirement struct { + Name string `json:"name"` + Op DeltaOp `json:"op"` + Text string `json:"text,omitempty"` + Scenarios []Scenario `json:"scenarios,omitempty"` +} + +// Normative reports whether the requirement's first body line carries SHALL or +// MUST, which is what `openspec validate` enforces. +func (r Requirement) Normative() bool { + for _, line := range strings.Split(r.Text, "\n") { + if strings.TrimSpace(line) == "" { + continue + } + return strings.Contains(line, "SHALL") || strings.Contains(line, "MUST") + } + return false +} + +// SpecDelta is one capability's delta file inside a change's specs/ folder. +type SpecDelta struct { + Capability string `json:"capability"` + Path string `json:"path"` + Purpose string `json:"purpose,omitempty"` + Requirements []Requirement `json:"requirements,omitempty"` +} + +// LoadSpecDeltas reads every specs/**/spec.md under a change folder. A change +// with no specs/ directory returns nil without error: not every change carries +// a behaviour contract. +func LoadSpecDeltas(changeDir string) ([]SpecDelta, error) { + root := filepath.Join(changeDir, "specs") + info, err := os.Stat(root) + if err != nil || !info.IsDir() { + return nil, nil + } + var out []SpecDelta + err = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() || !strings.HasSuffix(d.Name(), ".md") { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read %s: %w", path, err) + } + rel, err := filepath.Rel(root, path) + if err != nil { + rel = path + } + capability := filepath.ToSlash(filepath.Dir(rel)) + if capability == "." { + capability = strings.TrimSuffix(d.Name(), ".md") + } + delta := parseSpecDelta(string(data)) + delta.Capability = capability + delta.Path = path + out = append(out, delta) + return nil + }) + if err != nil { + return nil, err + } + sort.Slice(out, func(i, j int) bool { return out[i].Capability < out[j].Capability }) + return out, nil +} + +func parseSpecDelta(src string) SpecDelta { + var d SpecDelta + var op DeltaOp + var purpose []string + inPurpose := false + var req *Requirement + var scn *Scenario + + flushScenario := func() { + if req != nil && scn != nil { + req.Scenarios = append(req.Scenarios, *scn) + } + scn = nil + } + flushRequirement := func() { + flushScenario() + if req != nil { + req.Text = strings.TrimSpace(req.Text) + d.Requirements = append(d.Requirements, *req) + } + req = nil + } + + for _, line := range strings.Split(src, "\n") { + trimmed := strings.TrimSpace(line) + switch { + case strings.HasPrefix(trimmed, "#### Scenario:"): + flushScenario() + if req != nil { + scn = &Scenario{Name: strings.TrimSpace(strings.TrimPrefix(trimmed, "#### Scenario:"))} + } + inPurpose = false + case strings.HasPrefix(trimmed, "### Requirement:"): + flushRequirement() + req = &Requirement{ + Name: strings.TrimSpace(strings.TrimPrefix(trimmed, "### Requirement:")), + Op: op, + } + inPurpose = false + case strings.HasPrefix(trimmed, "## "): + flushRequirement() + heading := strings.TrimSpace(strings.TrimPrefix(trimmed, "## ")) + inPurpose = strings.EqualFold(heading, "Purpose") + if parsed, ok := parseDeltaHeading(heading); ok { + op = parsed + } + default: + switch { + case scn != nil: + if strings.HasPrefix(trimmed, "-") { + step := strings.TrimSpace(strings.TrimPrefix(trimmed, "-")) + if step != "" { + scn.Steps = append(scn.Steps, step) + } + } + case req != nil: + req.Text += line + "\n" + case inPurpose: + purpose = append(purpose, line) + } + } + } + flushRequirement() + d.Purpose = strings.TrimSpace(strings.Join(purpose, "\n")) + return d +} + +// parseDeltaHeading recognises "ADDED Requirements" and its siblings. The +// operation word may appear anywhere in the heading so that both +// "## ADDED Requirements" and "## Requirements (ADDED)" are understood. +func parseDeltaHeading(heading string) (DeltaOp, bool) { + upper := strings.ToUpper(heading) + if !strings.Contains(upper, "REQUIREMENT") { + return "", false + } + for _, op := range []DeltaOp{OpAdded, OpModified, OpRemoved, OpRenamed} { + if strings.Contains(upper, string(op)) { + return op, true + } + } + return "", false +} + +// RequirementCount returns the total requirements across every delta. +func RequirementCount(deltas []SpecDelta) int { + n := 0 + for _, d := range deltas { + n += len(d.Requirements) + } + return n +} + +// ScenarioCount returns the total scenarios across every delta. +func ScenarioCount(deltas []SpecDelta) int { + n := 0 + for _, d := range deltas { + for _, r := range d.Requirements { + n += len(r.Scenarios) + } + } + return n +} diff --git a/spec_delta_test.go b/spec_delta_test.go new file mode 100644 index 0000000..dfaa0d5 --- /dev/null +++ b/spec_delta_test.go @@ -0,0 +1,188 @@ +package specsync + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +const sampleDelta = `# credential-health + +## Purpose +How the platform surfaces dead credentials. + +## ADDED Requirements + +### Requirement: Dead credentials pause one integration +The system SHALL pause only the affected integration. + +#### Scenario: Definitive rejection pauses one +- **WHEN** a refresh is definitively rejected +- **THEN** that integration is paused +- **AND** its siblings keep running + +#### Scenario: Transient failure does not pause +- **WHEN** a refresh times out +- **THEN** it is retried + +## MODIFIED Requirements + +### Requirement: Notification reaches the customer +Notification SHALL reach domain admins by email. + +#### Scenario: Admins are emailed +- **WHEN** an integration is paused +- **THEN** admins receive an email +` + +func TestParseSpecDeltaOperationsAndScenarios(t *testing.T) { + d := parseSpecDelta(sampleDelta) + if d.Purpose != "How the platform surfaces dead credentials." { + t.Fatalf("purpose: %q", d.Purpose) + } + if len(d.Requirements) != 2 { + t.Fatalf("want 2 requirements, got %d", len(d.Requirements)) + } + first := d.Requirements[0] + if first.Op != OpAdded { + t.Fatalf("first op: %s", first.Op) + } + if len(first.Scenarios) != 2 { + t.Fatalf("first scenarios: %d", len(first.Scenarios)) + } + if got := first.Scenarios[0].Steps; len(got) != 3 { + t.Fatalf("steps: %v", got) + } + if second := d.Requirements[1]; second.Op != OpModified { + t.Fatalf("second op: %s", second.Op) + } +} + +func TestRequirementNormativeReadsFirstLineOnly(t *testing.T) { + ok := Requirement{Text: "The system SHALL do it.\nMore prose."} + if !ok.Normative() { + t.Fatal("SHALL on the first line should be normative") + } + // openspec validate only reads the first body line; mirror that exactly. + late := Requirement{Text: "When something happens,\nthe system SHALL do it."} + if late.Normative() { + t.Fatal("SHALL on a later line should not count") + } + none := Requirement{Text: "The system should probably do it."} + if none.Normative() { + t.Fatal("should is not normative") + } +} + +func TestLoadSpecDeltasAbsentSpecsIsNotAnError(t *testing.T) { + deltas, err := LoadSpecDeltas(t.TempDir()) + if err != nil || deltas != nil { + t.Fatalf("got %v, %v", deltas, err) + } +} + +func TestLoadSpecDeltasUsesDirectoryAsCapability(t *testing.T) { + dir := t.TempDir() + capDir := filepath.Join(dir, "specs", "credential-health") + if err := os.MkdirAll(capDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(capDir, "spec.md"), []byte(sampleDelta), 0o644); err != nil { + t.Fatal(err) + } + deltas, err := LoadSpecDeltas(dir) + if err != nil { + t.Fatal(err) + } + if len(deltas) != 1 || deltas[0].Capability != "credential-health" { + t.Fatalf("got %+v", deltas) + } + if RequirementCount(deltas) != 2 || ScenarioCount(deltas) != 3 { + t.Fatalf("counts: %d reqs, %d scenarios", RequirementCount(deltas), ScenarioCount(deltas)) + } +} + +func TestVerifyFlagsMissingScenarioAndNonNormative(t *testing.T) { + c := Change{ + Slug: "x", + Deltas: []SpecDelta{{ + Capability: "cap", + Purpose: "Real purpose.", + Requirements: []Requirement{ + {Name: "no scenarios", Text: "It SHALL work."}, + {Name: "not normative", Text: "It should work.", Scenarios: []Scenario{{Name: "s", Steps: []string{"**THEN** ok"}}}}, + }, + }}, + TasksMarkdown: "- [x] done\n", + } + r := Verify(c) + if r.OK() { + t.Fatal("expected failure") + } + var missing, nonNormative bool + for _, ch := range r.Checks { + if ch.Status != CheckFail { + continue + } + if ch.Name == "requirement without scenario: no scenarios" { + missing = true + } + if ch.Name == "requirement without SHALL/MUST: not normative" { + nonNormative = true + } + } + if !missing || !nonNormative { + t.Fatalf("checks: %+v", r.Checks) + } +} + +func TestVerifyUncontractedChangeWarnsButPasses(t *testing.T) { + r := Verify(Change{Slug: "chore", TasksMarkdown: "- [x] bump dep\n"}) + if !r.OK() { + t.Fatalf("a change with no delta should not fail: %+v", r.Checks) + } + if r.Contracted { + t.Fatal("should be reported as uncontracted") + } +} + +func TestVerifyFailsOnOpenTasks(t *testing.T) { + r := Verify(Change{Slug: "x", TasksMarkdown: "- [x] one\n- [ ] two\n"}) + if r.OK() { + t.Fatal("open tasks should fail verification") + } +} + +func TestVerifyWarnsOnTBDPurpose(t *testing.T) { + c := Change{Slug: "x", TasksMarkdown: "- [x] a\n", Deltas: []SpecDelta{{ + Capability: "cap", + Purpose: "TBD - created by archiving change foo.", + Requirements: []Requirement{{Name: "r", Text: "It SHALL work.", Scenarios: []Scenario{{Name: "s", Steps: []string{"**THEN** ok"}}}}}, + }}} + r := Verify(c) + var warned bool + for _, ch := range r.Checks { + if ch.Status == CheckWarn && ch.Name == "capability purpose: cap" { + warned = true + } + } + if !warned { + t.Fatalf("checks: %+v", r.Checks) + } +} + +func TestVerificationSectionRendersChecklist(t *testing.T) { + deltas := []SpecDelta{{Capability: "cap", Requirements: []Requirement{ + {Name: "r", Op: OpAdded, Scenarios: []Scenario{{Name: "s", Steps: []string{"**WHEN** x", "**THEN** y"}}}}, + }}} + got := VerificationSection(deltas) + for _, want := range []string{"### cap", "**r** (added)", "- [ ] s", " - **WHEN** x"} { + if !strings.Contains(got, want) { + t.Fatalf("missing %q in:\n%s", want, got) + } + } + if VerificationSection(nil) != "" { + t.Fatal("no deltas should render nothing") + } +} diff --git a/store.go b/store.go new file mode 100644 index 0000000..accac3c --- /dev/null +++ b/store.go @@ -0,0 +1,299 @@ +package specsync + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// ChangeConfigPath is the committed per-change config, relative to a change +// folder. It is deliberately NOT under .specsync/, which is gitignored: a +// change's targets must travel with the change to every teammate's clone. +const ChangeConfigPath = "specsync.yml" + +// StoreMarkerPath identifies a standalone OpenSpec store, relative to the +// store root (the parent of its openspec/ directory). +const StoreMarkerPath = ".openspec-store/store.yaml" + +// RootRule names which branch of the spec-root resolution order was taken. +type RootRule string + +const ( + RootRuleFlag RootRule = "flag" // -openspec + RootRuleStore RootRule = "store-flag" // -store + RootRuleDeclare RootRule = "config-store" // openspec/config.yaml store: + RootRuleLocal RootRule = "repo-local" // ./openspec +) + +// ResolvedRoot is a spec root and how it was found. +type ResolvedRoot struct { + Dir string // absolute path to the openspec/ directory + Rule RootRule + StoreID string // non-empty when the root is a store +} + +// IsStore reports whether the root lives in a standalone store repo. +func (r ResolvedRoot) IsStore() bool { return r.StoreID != "" } + +// ResolveSpecRoot locates the openspec/ directory to operate on. +// Order: -openspec flag → -store id → config.yaml store: → ./openspec. +// +// flagSet distinguishes "-openspec was passed" from "-openspec defaulted", +// because the default value is itself a valid relative path. +func ResolveSpecRoot(flagPath string, flagSet bool, storeID, cwd string) (ResolvedRoot, error) { + if flagSet && flagPath != "" { + abs, err := filepath.Abs(flagPath) + if err != nil { + return ResolvedRoot{}, err + } + return ResolvedRoot{Dir: abs, Rule: RootRuleFlag, StoreID: storeIDAt(abs)}, nil + } + if storeID != "" { + dir, err := StoreLocation(storeID) + if err != nil { + return ResolvedRoot{}, err + } + return ResolvedRoot{Dir: filepath.Join(dir, "openspec"), Rule: RootRuleStore, StoreID: storeID}, nil + } + local := filepath.Join(cwd, "openspec") + if declared := ReadDeclaredStore(local); declared != "" { + dir, err := StoreLocation(declared) + if err != nil { + return ResolvedRoot{}, fmt.Errorf("%s declares store %q: %w", filepath.Join(local, "config.yaml"), declared, err) + } + return ResolvedRoot{Dir: filepath.Join(dir, "openspec"), Rule: RootRuleDeclare, StoreID: declared}, nil + } + abs, err := filepath.Abs(flagPath) + if err != nil { + return ResolvedRoot{}, err + } + return ResolvedRoot{Dir: abs, Rule: RootRuleLocal, StoreID: storeIDAt(abs)}, nil +} + +// Validate reports an actionable error when the resolved root holds no +// changes directory, rather than letting callers print an empty result. +func (r ResolvedRoot) Validate() error { + if info, err := os.Stat(r.Dir); err != nil || !info.IsDir() { + if r.IsStore() { + return fmt.Errorf("store %q has no openspec/ directory at %s", r.StoreID, r.Dir) + } + return fmt.Errorf("no openspec/ directory at %s (pass -openspec, -store, or declare `store:` in openspec/config.yaml)", r.Dir) + } + changes := filepath.Join(r.Dir, "changes") + if info, err := os.Stat(changes); err != nil || !info.IsDir() { + return fmt.Errorf("no changes/ directory at %s", changes) + } + return nil +} + +// storeIDAt returns the store id when the given openspec/ dir sits inside a +// store repo, or "" when it does not. +func storeIDAt(openspecDir string) string { + marker := filepath.Join(filepath.Dir(openspecDir), StoreMarkerPath) + data, err := os.ReadFile(marker) + if err != nil { + return "" + } + if id := scalarField(string(data), "id"); id != "" { + return id + } + return filepath.Base(filepath.Dir(openspecDir)) +} + +// ReadDeclaredStore returns the `store:` id declared in /config.yaml, +// or "" when the file is absent or declares none. +func ReadDeclaredStore(openspecDir string) string { + data, err := os.ReadFile(filepath.Join(openspecDir, "config.yaml")) + if err != nil { + return "" + } + return scalarField(string(data), "store") +} + +// storeListEntry tolerates the several names OpenSpec has used for a store's +// local path while stores are beta. +type storeListEntry struct { + ID string `json:"id"` + Root string `json:"root"` + Location string `json:"location"` + Path string `json:"path"` +} + +func (e storeListEntry) location() string { + for _, v := range []string{e.Root, e.Location, e.Path} { + if v != "" { + return v + } + } + return "" +} + +// StoreLocation resolves a store id to its local path using OpenSpec's own +// machine registry. specsync keeps no second registry; the format stays +// OpenSpec's to change. +func StoreLocation(id string) (string, error) { + out, err := exec.Command("openspec", "store", "list", "--json").Output() + if err != nil { + return "", fmt.Errorf("openspec store list --json (is openspec installed?): %w", err) + } + entries, err := parseStoreList(out) + if err != nil { + return "", err + } + var known []string + for _, e := range entries { + loc := e.location() + if e.ID == id { + if loc == "" { + return "", fmt.Errorf("store %q is registered without a location", id) + } + return loc, nil + } + known = append(known, e.ID) + } + if len(known) == 0 { + return "", fmt.Errorf("store %q is not registered; run: openspec store register ", id) + } + return "", fmt.Errorf("store %q is not registered (known: %s); run: openspec store register ", id, strings.Join(known, ", ")) +} + +// parseStoreList accepts either a bare array or an object wrapping one, since +// stores are beta and the envelope has moved between releases. +func parseStoreList(data []byte) ([]storeListEntry, error) { + var direct []storeListEntry + if err := json.Unmarshal(data, &direct); err == nil { + return direct, nil + } + var wrapped struct { + Stores []storeListEntry `json:"stores"` + Items []storeListEntry `json:"items"` + } + if err := json.Unmarshal(data, &wrapped); err != nil { + return nil, fmt.Errorf("parse openspec store list output: %w", err) + } + if len(wrapped.Stores) > 0 { + return wrapped.Stores, nil + } + return wrapped.Items, nil +} + +// ChangeTargets returns the provider keys a change declares in its committed +// specsync.yml, or nil when it declares none. +func ChangeTargets(changeDir string) []string { + data, err := os.ReadFile(filepath.Join(changeDir, ChangeConfigPath)) + if err != nil { + return nil + } + return parseTargets(string(data)) +} + +// WriteChangeTargets records a change's target provider keys, preserving any +// other keys already in the file. +func WriteChangeTargets(changeDir string, targets []string) error { + if len(targets) == 0 { + return nil + } + path := filepath.Join(changeDir, ChangeConfigPath) + var kept []string + if data, err := os.ReadFile(path); err == nil { + inTargets := false + for _, line := range strings.Split(string(data), "\n") { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "targets:") { + inTargets = true + continue + } + if inTargets { + if strings.HasPrefix(trimmed, "-") || trimmed == "" { + continue + } + inTargets = false + } + if trimmed != "" { + kept = append(kept, line) + } + } + } + var b strings.Builder + for _, line := range kept { + b.WriteString(line + "\n") + } + b.WriteString("targets:\n") + for _, t := range targets { + b.WriteString(" - " + t + "\n") + } + return os.WriteFile(path, []byte(b.String()), 0o644) +} + +// RepoFromProviderKey extracts "owner/name" from a "github:owner/name" key. +// Keys for other providers, and bare keys, yield "". +func RepoFromProviderKey(key string) string { + rest, ok := strings.CutPrefix(key, "github:") + if !ok || rest == "" { + return "" + } + return rest +} + +// GitHubTargetRepos returns the GitHub repos a change targets, in order. +func GitHubTargetRepos(changeDir string) []string { + var out []string + for _, t := range ChangeTargets(changeDir) { + if repo := RepoFromProviderKey(t); repo != "" { + out = append(out, repo) + } + } + return out +} + +func parseTargets(src string) []string { + var out []string + inTargets := false + for _, line := range strings.Split(src, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + if strings.HasPrefix(trimmed, "targets:") { + inTargets = true + if v := strings.TrimSpace(strings.TrimPrefix(trimmed, "targets:")); v != "" && v != "[]" { + for _, part := range strings.Split(strings.Trim(v, "[]"), ",") { + if p := strings.Trim(strings.TrimSpace(part), `"'`); p != "" { + out = append(out, p) + } + } + inTargets = false + } + continue + } + if !inTargets { + continue + } + if !strings.HasPrefix(trimmed, "-") { + break + } + if v := strings.Trim(strings.TrimSpace(strings.TrimPrefix(trimmed, "-")), `"'`); v != "" { + out = append(out, v) + } + } + return out +} + +// scalarField reads a top-level "key: value" scalar from minimal YAML. +func scalarField(src, key string) string { + for _, line := range strings.Split(src, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + k, v, ok := strings.Cut(trimmed, ":") + if !ok || strings.TrimSpace(k) != key { + continue + } + return strings.Trim(strings.TrimSpace(v), `"'`) + } + return "" +} diff --git a/store_test.go b/store_test.go new file mode 100644 index 0000000..a4f3e6c --- /dev/null +++ b/store_test.go @@ -0,0 +1,128 @@ +package specsync + +import ( + "os" + "path/filepath" + "testing" +) + +func TestParseTargetsBlockAndInline(t *testing.T) { + block := "targets:\n - github:acme/backend\n - github:acme/frontend\n" + got := parseTargets(block) + if len(got) != 2 || got[0] != "github:acme/backend" || got[1] != "github:acme/frontend" { + t.Fatalf("block form: got %v", got) + } + inline := parseTargets(`targets: [github:acme/backend, "github:acme/frontend"]`) + if len(inline) != 2 || inline[1] != "github:acme/frontend" { + t.Fatalf("inline form: got %v", inline) + } + if got := parseTargets("board: acme/6\n"); len(got) != 0 { + t.Fatalf("no targets key should yield none, got %v", got) + } +} + +func TestWriteChangeTargetsPreservesOtherKeys(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ChangeConfigPath) + if err := os.WriteFile(path, []byte("board: acme/6\ntargets:\n - github:acme/old\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := WriteChangeTargets(dir, []string{"github:acme/new"}); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if got := string(data); got != "board: acme/6\ntargets:\n - github:acme/new\n" { + t.Fatalf("unexpected rewrite:\n%q", got) + } + if got := ChangeTargets(dir); len(got) != 1 || got[0] != "github:acme/new" { + t.Fatalf("round-trip: got %v", got) + } +} + +func TestGitHubTargetReposSkipsOtherProviders(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, ChangeConfigPath), + []byte("targets:\n - github:acme/backend\n - beads\n - mcp:linear\n"), 0o644); err != nil { + t.Fatal(err) + } + got := GitHubTargetRepos(dir) + if len(got) != 1 || got[0] != "acme/backend" { + t.Fatalf("got %v", got) + } +} + +func TestResolveSpecRootPrefersFlagOverDeclaredStore(t *testing.T) { + cwd := t.TempDir() + if err := os.MkdirAll(filepath.Join(cwd, "openspec"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(cwd, "openspec", "config.yaml"), []byte("store: some-store\n"), 0o644); err != nil { + t.Fatal(err) + } + explicit := filepath.Join(cwd, "elsewhere") + root, err := ResolveSpecRoot(explicit, true, "", cwd) + if err != nil { + t.Fatal(err) + } + if root.Rule != RootRuleFlag || root.Dir != explicit { + t.Fatalf("got %+v", root) + } +} + +func TestResolveSpecRootFallsBackToRepoLocal(t *testing.T) { + cwd := t.TempDir() + if err := os.MkdirAll(filepath.Join(cwd, "openspec", "changes"), 0o755); err != nil { + t.Fatal(err) + } + root, err := ResolveSpecRoot("openspec", false, "", cwd) + if err != nil { + t.Fatal(err) + } + if root.Rule != RootRuleLocal || root.IsStore() { + t.Fatalf("got %+v", root) + } +} + +func TestResolvedRootValidateReportsMissingChanges(t *testing.T) { + dir := t.TempDir() + if err := ResolveRootFor(dir).Validate(); err == nil { + t.Fatal("expected an error for a root with no changes/ directory") + } +} + +// ResolveRootFor is a test helper mirroring a repo-local resolution. +func ResolveRootFor(dir string) ResolvedRoot { + return ResolvedRoot{Dir: dir, Rule: RootRuleLocal} +} + +func TestStoreIDAtDetectsMarker(t *testing.T) { + storeRoot := t.TempDir() + if err := os.MkdirAll(filepath.Join(storeRoot, ".openspec-store"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(storeRoot, StoreMarkerPath), []byte("version: 1\nid: team-plans\n"), 0o644); err != nil { + t.Fatal(err) + } + if got := storeIDAt(filepath.Join(storeRoot, "openspec")); got != "team-plans" { + t.Fatalf("got %q", got) + } + if got := storeIDAt(filepath.Join(t.TempDir(), "openspec")); got != "" { + t.Fatalf("non-store should yield empty, got %q", got) + } +} + +func TestParseStoreListEnvelopes(t *testing.T) { + wrapped := []byte(`{"stores":[{"id":"a","root":"/tmp/a"}]}`) + got, err := parseStoreList(wrapped) + if err != nil || len(got) != 1 || got[0].location() != "/tmp/a" { + t.Fatalf("wrapped: %v %v", got, err) + } + bare := []byte(`[{"id":"b","location":"/tmp/b"}]`) + got, err = parseStoreList(bare) + if err != nil || len(got) != 1 || got[0].location() != "/tmp/b" { + t.Fatalf("bare: %v %v", got, err) + } +} diff --git a/sync.go b/sync.go index 5c45808..a3e262e 100644 --- a/sync.go +++ b/sync.go @@ -3,7 +3,6 @@ package specsync import ( "context" "fmt" - "os" "strings" ) @@ -176,7 +175,6 @@ func Sync(ctx context.Context, opts Options) (Result, error) { var firstRef Ref var firstCreated bool var firstClosedDeferred string - var boardPlan BoardPlan providerResults := make([]ProviderResult, 0, len(providers)) for _, prov := range providers { @@ -262,17 +260,6 @@ func Sync(ctx context.Context, opts Options) (Result, error) { firstClosedDeferred = ref.ClosedDeferred } - if opts.Project.Configured() { - if bp, ok := prov.(BoardProjector); ok { - plan, berr := bp.ProjectOntoBoard(ctx, opts.Project, ref, item, opts.DryRun, c.Dir) - if berr != nil { - fmt.Fprintf(os.Stderr, "specsync: warning: board projection for %s failed: %v\n", c.Slug, berr) - } else if boardPlan == (BoardPlan{}) { - boardPlan = plan - } - } - } - if opts.DryRun { // A dry run must never mutate local state. } else { @@ -357,7 +344,6 @@ func Sync(ctx context.Context, opts Options) (Result, error) { Flips: allFlips, TitleSuggestion: suggestion, BoardConfigured: opts.Project.Configured(), - Board: boardPlan, ClosedDeferred: firstClosedDeferred, Providers: providerResults, }) diff --git a/verify.go b/verify.go new file mode 100644 index 0000000..ef519b0 --- /dev/null +++ b/verify.go @@ -0,0 +1,173 @@ +package specsync + +import ( + "fmt" + "strings" +) + +// CheckStatus is the outcome of one verification check. +type CheckStatus string + +const ( + CheckPass CheckStatus = "pass" + CheckFail CheckStatus = "fail" + CheckWarn CheckStatus = "warn" +) + +// Check is a single verifiable statement about a change. +type Check struct { + Status CheckStatus `json:"status"` + Name string `json:"name"` + Detail string `json:"detail,omitempty"` +} + +// VerifyReport is the result of verifying one change against its own spec. +type VerifyReport struct { + Slug string `json:"slug"` + Contracted bool `json:"contracted"` + Requirements int `json:"requirements"` + Scenarios int `json:"scenarios"` + Tasks string `json:"tasks"` + Checks []Check `json:"checks"` + Criteria []string `json:"criteria,omitempty"` +} + +// OK reports whether no check failed. +func (r VerifyReport) OK() bool { + for _, c := range r.Checks { + if c.Status == CheckFail { + return false + } + } + return true +} + +// Verify walks a change's spec deltas and task list and reports whether the +// change is ready to archive. It checks the shape of the contract, not the +// code: what it produces is the acceptance checklist a reviewer works through. +// +// A change with no deltas is not a failure. Not every piece of work changes +// observable behaviour, and forcing a contract onto a dependency bump is how +// specs become the ceremony their critics say they are. Such a change is +// reported as uncontracted, and only its tasks are checked. +func Verify(c Change) VerifyReport { + total, done := CountCheckboxes(c.TasksMarkdown) + r := VerifyReport{ + Slug: c.Slug, + Contracted: len(c.Deltas) > 0, + Requirements: RequirementCount(c.Deltas), + Scenarios: ScenarioCount(c.Deltas), + Tasks: fmt.Sprintf("%d/%d", done, total), + } + + if !r.Contracted { + r.Checks = append(r.Checks, Check{ + Status: CheckWarn, + Name: "behaviour contract", + Detail: "no specs/ delta: nothing accumulates into openspec/specs/ when this is archived. Correct for a fix or chore; add a delta if this changes observable behaviour", + }) + } else { + r.Checks = append(r.Checks, Check{ + Status: CheckPass, + Name: "behaviour contract", + Detail: fmt.Sprintf("%d requirement(s), %d scenario(s) across %d capability(ies)", r.Requirements, r.Scenarios, len(c.Deltas)), + }) + } + + for _, d := range c.Deltas { + if d.Purpose == "" || strings.Contains(strings.ToUpper(d.Purpose), "TBD") { + r.Checks = append(r.Checks, Check{ + Status: CheckWarn, + Name: "capability purpose: " + d.Capability, + Detail: "Purpose is missing or still TBD; it is the paragraph a newcomer reads first", + }) + } + for _, req := range d.Requirements { + if len(req.Scenarios) == 0 { + r.Checks = append(r.Checks, Check{ + Status: CheckFail, + Name: "requirement without scenario: " + req.Name, + Detail: "every requirement needs at least one #### Scenario: block, or it cannot be verified", + }) + } + if !req.Normative() { + r.Checks = append(r.Checks, Check{ + Status: CheckFail, + Name: "requirement without SHALL/MUST: " + req.Name, + Detail: "openspec validate reads the first line of the requirement body for normative language", + }) + } + for _, s := range req.Scenarios { + if !hasOutcome(s) { + r.Checks = append(r.Checks, Check{ + Status: CheckWarn, + Name: "scenario without an outcome: " + s.Name, + Detail: "a scenario needs a THEN step to be checkable", + }) + } + } + } + } + + switch { + case total == 0: + r.Checks = append(r.Checks, Check{Status: CheckWarn, Name: "tasks", Detail: "no tasks recorded"}) + case done < total: + r.Checks = append(r.Checks, Check{ + Status: CheckFail, + Name: "tasks", + Detail: fmt.Sprintf("%d of %d task(s) still open", total-done, total), + }) + default: + r.Checks = append(r.Checks, Check{Status: CheckPass, Name: "tasks", Detail: r.Tasks + " complete"}) + } + + r.Criteria = AcceptanceCriteria(c.Deltas) + return r +} + +func hasOutcome(s Scenario) bool { + for _, step := range s.Steps { + if strings.Contains(strings.ToUpper(step), "THEN") { + return true + } + } + return false +} + +// AcceptanceCriteria renders a change's scenarios as reviewer-facing lines, +// one per scenario, qualified by capability and requirement. +func AcceptanceCriteria(deltas []SpecDelta) []string { + var out []string + for _, d := range deltas { + for _, req := range d.Requirements { + for _, s := range req.Scenarios { + out = append(out, fmt.Sprintf("%s — %s: %s", d.Capability, req.Name, s.Name)) + } + } + } + return out +} + +// VerificationSection renders the acceptance checklist projected into an +// issue body, so the contract a reviewer must check travels with the issue. +func VerificationSection(deltas []SpecDelta) string { + if len(deltas) == 0 { + return "" + } + var b strings.Builder + for _, d := range deltas { + fmt.Fprintf(&b, "### %s\n\n", d.Capability) + for _, req := range d.Requirements { + fmt.Fprintf(&b, "**%s** (%s)\n\n", req.Name, strings.ToLower(string(req.Op))) + for _, s := range req.Scenarios { + fmt.Fprintf(&b, "- [ ] %s\n", s.Name) + for _, step := range s.Steps { + fmt.Fprintf(&b, " - %s\n", step) + } + } + b.WriteString("\n") + } + } + return strings.TrimRight(b.String(), "\n") +} From 29cc8a8bf279234a8613fe8d852aaf3eb07451de Mon Sep 17 00:00:00 2001 From: AndreasS Date: Tue, 29 Sep 2026 10:05:10 +0200 Subject: [PATCH 2/3] fix: restore slug-capping and board-projection wiring lost in the openspec-store-support rebase The staged openspec-store-support work reverted pull.go and sync.go to versions that predated two already-merged main features: slug-length capping/collision-avoidance (#167, dirExists/nextAvailableSlug/capSlug/ maxSlugLen) and wiring sync's -project flag to actually call ProjectOntoBoard (#165). Restores both so `go test ./...` builds and passes again. --- pull.go | 98 +++++++++++++++++++++++++++++++++++++++++++++++++++++++-- sync.go | 14 +++++++++ 2 files changed, 110 insertions(+), 2 deletions(-) diff --git a/pull.go b/pull.go index 0058345..1c49668 100644 --- a/pull.go +++ b/pull.go @@ -96,6 +96,37 @@ func Pull(ctx context.Context, opts PullOptions) (PullResult, error) { return PullResult{}, fmt.Errorf("could not derive a change name from issue %s; pass -change", opts.IssueID) } + // A slug collision (two issues landing on the same directory — a + // generated slug and, more rarely, an explicit -change) must not + // silently overwrite an unrelated change's proposal.md/tasks.md. + // + // Two cases are left alone (existing behavior): re-pulling the *same* + // issue into its own existing directory (the intentional refresh path), + // and an explicit -change into a directory with no ref cache at all + // (a hand-authored, never-synced local change — the human named this + // exact directory, and binding an issue's content into it is the normal + // spec-first-then-pull flow; see TestPullRecordsTaskBase). + // + // A genuine conflict — the directory's ref cache names a *different* + // issue — refuses an explicit -change (pass a different one, or + // `specsync adopt`) and suffixes an auto-derived one instead of + // clobbering the other change. An auto-derived slug colliding with an + // untracked hand-authored directory is also suffixed, since nothing + // asked for that specific name. + if candidateDir := filepath.Join(opts.OpenSpecDir, "changes", slug); dirExists(candidateDir) { + existing, _ := LoadRefs(candidateDir) + conflictsWithDifferentIssue := len(existing) > 0 && !refsContainIssue(existing, issueID) + collidesWithUntrackedDir := len(existing) == 0 && opts.Slug == "" + if conflictsWithDifferentIssue { + if opts.Slug != "" { + return PullResult{}, fmt.Errorf("pull: change %q already exists and is not linked to issue %s; pass a different -change or use `specsync adopt`", slug, issueID) + } + slug = nextAvailableSlug(opts.OpenSpecDir, slug) + } else if collidesWithUntrackedDir { + slug = nextAvailableSlug(opts.OpenSpecDir, slug) + } + } + proposal, tasks, relatedURLs, origAsk, design, disc := splitBody(item.Body, item.Title) // The body may carry only a linked stub for an overflowed design.md; // recover the real content from the comment when supported. @@ -480,8 +511,17 @@ func slugFromMarker(body string) string { return strings.TrimSpace(rest[:j]) } +// maxSlugLen caps a generated slug's length. Long enough for +// add-agent-help-command-style names with room to spare, short enough to +// type as a -change flag and to keep in every log line and path. Applies to +// the slug itself, not to a prefix a caller adds afterward (e.g. epic.go's +// "epic:" marker prefix) — the prefix is fixed overhead, not part of the +// generated name. +const maxSlugLen = 48 + // slugify turns a title into a kebab-case slug: lowercase, with each run of -// non-alphanumeric characters collapsed to a single hyphen and trimmed. +// non-alphanumeric characters collapsed to a single hyphen and trimmed, then +// capped at maxSlugLen. func slugify(s string) string { var b strings.Builder pendingHyphen := false @@ -497,5 +537,59 @@ func slugify(s string) string { pendingHyphen = true } } - return b.String() + return capSlug(b.String(), maxSlugLen) +} + +// capSlug truncates s to at most max characters, preferring a word boundary +// (the last hyphen at or before the cut) so truncation never splits a word. +// When the first word alone exceeds max, it hard-truncates instead of +// returning "" — an empty slug is treated as failure by every caller. +func capSlug(s string, max int) string { + if len(s) <= max { + return s + } + cut := s[:max] + if i := strings.LastIndexByte(cut, '-'); i > 0 { + cut = cut[:i] + } + return strings.TrimRight(cut, "-") +} + +// dirExists reports whether path exists (any type), for the pull collision +// guard above. +func dirExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +// refsContainIssue reports whether any cached ref in refs points at issueID — +// i.e. the existing directory tracks the very issue being pulled, making this +// an intentional refresh rather than a collision. +func refsContainIssue(refs map[string]Ref, issueID string) bool { + for _, r := range refs { + if r.ID == issueID { + return true + } + } + return false +} + +// nextAvailableSlug finds the first "-2", "-3", ... whose change +// directory doesn't exist yet, trimming base to keep the result within +// maxSlugLen when the numeric suffix would otherwise push it over. +func nextAvailableSlug(openspecDir, base string) string { + for i := 2; i < 1000; i++ { + suffix := fmt.Sprintf("-%d", i) + candidate := base + suffix + if len(candidate) > maxSlugLen { + candidate = capSlug(base, maxSlugLen-len(suffix)) + suffix + } + if !dirExists(filepath.Join(openspecDir, "changes", candidate)) { + return candidate + } + } + // Exhausted a generous range of suffixes — fall back to the last one + // tried rather than looping forever; pull will simply overwrite it, + // same as today's pre-existing behavior. + return fmt.Sprintf("%s-999", base) } diff --git a/sync.go b/sync.go index a3e262e..5c45808 100644 --- a/sync.go +++ b/sync.go @@ -3,6 +3,7 @@ package specsync import ( "context" "fmt" + "os" "strings" ) @@ -175,6 +176,7 @@ func Sync(ctx context.Context, opts Options) (Result, error) { var firstRef Ref var firstCreated bool var firstClosedDeferred string + var boardPlan BoardPlan providerResults := make([]ProviderResult, 0, len(providers)) for _, prov := range providers { @@ -260,6 +262,17 @@ func Sync(ctx context.Context, opts Options) (Result, error) { firstClosedDeferred = ref.ClosedDeferred } + if opts.Project.Configured() { + if bp, ok := prov.(BoardProjector); ok { + plan, berr := bp.ProjectOntoBoard(ctx, opts.Project, ref, item, opts.DryRun, c.Dir) + if berr != nil { + fmt.Fprintf(os.Stderr, "specsync: warning: board projection for %s failed: %v\n", c.Slug, berr) + } else if boardPlan == (BoardPlan{}) { + boardPlan = plan + } + } + } + if opts.DryRun { // A dry run must never mutate local state. } else { @@ -344,6 +357,7 @@ func Sync(ctx context.Context, opts Options) (Result, error) { Flips: allFlips, TitleSuggestion: suggestion, BoardConfigured: opts.Project.Configured(), + Board: boardPlan, ClosedDeferred: firstClosedDeferred, Providers: providerResults, }) From 933284ca62f5bbd2e97e8515259101915c5c3538 Mon Sep 17 00:00:00 2001 From: AndreasS Date: Tue, 29 Sep 2026 17:29:06 +0200 Subject: [PATCH 3/3] fix(#173): scope this branch to openspec-store-support only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old branch's flat diff also carried three unrelated changes along for the ride (spec-issue-linker archival, warn-before-creating-a-duplicate-issue, and a missing tasks.md on commit-change-stage) that don't belong in this PR and broke CI's structural validate gate. Drops the two fully unrelated ones, and adds tasks.md to commit-change-stage — a legitimate follow-up spun off from this feature's own tasks.md, just not yet planned into tasks. --- .../2026-09-17-spec-issue-linker/links.md | 1 - .../2026-09-17-spec-issue-linker/proposal.md | 35 ---------- .../2026-09-17-spec-issue-linker/tasks.md | 25 ------- openspec/changes/commit-change-stage/tasks.md | 3 + .../proposal.md | 66 ------------------- .../tasks.md | 22 ------- 6 files changed, 3 insertions(+), 149 deletions(-) delete mode 100644 openspec/changes/archive/2026-09-17-spec-issue-linker/links.md delete mode 100644 openspec/changes/archive/2026-09-17-spec-issue-linker/proposal.md delete mode 100644 openspec/changes/archive/2026-09-17-spec-issue-linker/tasks.md create mode 100644 openspec/changes/commit-change-stage/tasks.md delete mode 100644 openspec/changes/warn-before-creating-a-duplicate-issue/proposal.md delete mode 100644 openspec/changes/warn-before-creating-a-duplicate-issue/tasks.md diff --git a/openspec/changes/archive/2026-09-17-spec-issue-linker/links.md b/openspec/changes/archive/2026-09-17-spec-issue-linker/links.md deleted file mode 100644 index 5de8c0c..0000000 --- a/openspec/changes/archive/2026-09-17-spec-issue-linker/links.md +++ /dev/null @@ -1 +0,0 @@ -- androidand/specsync#160 diff --git a/openspec/changes/archive/2026-09-17-spec-issue-linker/proposal.md b/openspec/changes/archive/2026-09-17-spec-issue-linker/proposal.md deleted file mode 100644 index 1e0a944..0000000 --- a/openspec/changes/archive/2026-09-17-spec-issue-linker/proposal.md +++ /dev/null @@ -1,35 +0,0 @@ -# Spec ↔ issue linker - -## Why - -A spec and its issue can originate from either side: an issue created first (by a -human or an orchestrator) that later gains a spec, or a spec authored first that -needs an issue. specsync must resolve "which issue is this change" robustly -across both, without inventing a parallel identity scheme. - -Issue-first scaffolding is handled by the dedicated `github-issue-pull` change. -This change focuses on deterministic link resolution once local and tracker -artifacts exist. - -## What - -Add a `Linker` abstraction that resolves a change to its issue ref by consulting, -in priority order: - -1. the current git branch name (issue-linked branches encode the issue number), -2. the `` marker in issue bodies, -3. the local `.specsync/` ref cache, -4. an external relation source (e.g. an MCP that knows issue↔branch↔repo links). - -The first hit wins; the result is cached. This makes issue-first and spec-first -flows converge on the same link. - -## Scope - -- `Linker` interface + chained resolver. -- Branch-name resolver (configurable pattern). -- Wire the resolver into the sync engine ahead of provider `Find`. - -## Release note - -Linker resolves spec↔issue from branch name, marker, or cache — sync updates the right issue instead of creating a duplicate. diff --git a/openspec/changes/archive/2026-09-17-spec-issue-linker/tasks.md b/openspec/changes/archive/2026-09-17-spec-issue-linker/tasks.md deleted file mode 100644 index 4977c20..0000000 --- a/openspec/changes/archive/2026-09-17-spec-issue-linker/tasks.md +++ /dev/null @@ -1,25 +0,0 @@ -# Tasks: Spec ↔ issue linker - -## 1. Linker interface - -- [x] 1.1 Define `Linker` interface in a new `linker.go`: `Resolve(ctx, changeDir) (*Ref, error)` -- [x] 1.2 Define chained resolver: tries each resolver in order, first hit wins - -## 2. Resolvers - -- [x] 2.1 Branch-name resolver: configurable pattern (e.g. `feat/(\d+)-.*` → issue `#1`) -- [x] 2.2 Marker resolver: parse `` from issue body via provider -- [x] 2.3 Cache resolver: read `.specsync/refs.json` (existing behavior, refactored behind Linker) -- [x] 2.4 External resolver: optional hook for MCP or other external relation sources - -## 3. Integration - -- [x] 3.1 Wire Linker into sync engine ahead of provider `Find` -- [x] 3.2 Use Linker in `pull` for issue-first flow (resolve branch → issue) -- [x] 3.3 Support spec-first creation when no link resolves (existing behavior) - -## 4. Verification - -- [x] 4.1 Tests: branch-name pattern matching, marker parsing, cache lookup, chain order -- [x] 4.2 Integration test: sync resolves issue via branch name without explicit ref -- [x] 4.3 Update the specsync skill: how Linker resolves issues from branches/markers diff --git a/openspec/changes/commit-change-stage/tasks.md b/openspec/changes/commit-change-stage/tasks.md new file mode 100644 index 0000000..707fca4 --- /dev/null +++ b/openspec/changes/commit-change-stage/tasks.md @@ -0,0 +1,3 @@ +- [ ] Read `stage` and `priority` from the committed per-change `specsync.yml` +- [ ] Keep reading `.specsync/metadata.json` as a fallback, migrating on next write +- [ ] Update `set-stage` and `set-priority` to write `specsync.yml` instead of `.specsync/metadata.json` diff --git a/openspec/changes/warn-before-creating-a-duplicate-issue/proposal.md b/openspec/changes/warn-before-creating-a-duplicate-issue/proposal.md deleted file mode 100644 index e542baa..0000000 --- a/openspec/changes/warn-before-creating-a-duplicate-issue/proposal.md +++ /dev/null @@ -1,66 +0,0 @@ -# Say when a sync is about to open a new issue, and what it might duplicate - -## Why - -`specsync -change ` opens a new issue whenever the change has no ref yet. It never looks at -what is already open in the target repo, and its output gives the reader no way to notice. - -Reproduced while working an incident: a change was written for remediation work, synced, and opened -a new issue — while the issue that had tracked that same work for two days sat open in the same -repo. The duplicate was caught by a human reading the board, not by the tool. - -Two things made it easy to walk into. - -**1. `-dry-run` uses the same word as a real run.** The preview ends: - -``` - created https://github.com///issues/0 [github:owner/repo] (my-change) -specsync: 1 created, 0 updated -``` - -`created` is also what an update-path preview prints for the *other* branch of the same `if`, so the -line scans as routine projection output. The one detail that distinguishes a create — `issues/0`, -the placeholder for a number that does not exist yet — is the least conspicuous thing on the line. -Nothing says *this will open a new issue*. - -**2. Nothing looks for prior art.** `sync.go` decides with `created := !hadRef` — purely "do we have -a ref cached for this change". The CLI already has `scan` (find related work) and `adopt` (bind a -change to an existing issue), so the pieces exist; nothing routes you to them at the one moment they -matter, which is immediately before a create. - -## What - -### Make the create path unmistakable in a dry run - -On the create path, `-dry-run` should name what it is about to do rather than describe the result in -the past tense. Something that cannot be mistaken for an update — a distinct verb, and the absence -of a real issue number stated rather than implied. - -### Offer prior art before creating - -Before a create, list open issues in the target repo and surface the plausible matches, with the -`adopt` command spelled out: - -``` - NEW ISSUE (no issue exists yet) [github:owner/repo] (my-change) - 2 open issues may already cover this: - #128 Widget sync starves under load (shared: widget, sync) - #131 Widget totals double-counted (shared: widget) - → bind instead with: specsync adopt -issue 128 -change my-change -``` - -Matching should be on **shared distinctive words**, not title similarity. This matters and is worth -stating plainly: in the case that prompted this, the two titles shared exactly one word. Two people -describing the same problem rarely phrase it alike, and a similarity score high enough to avoid false -positives would have scored that pair near zero. Shared jargon — a subsystem, a source system, a -component — is a far better signal than string distance. - -This is advisory. It prints and proceeds; it does not block, and it does not need to be right. A -list that is wrong costs the reader two seconds. A duplicate costs a cleanup. - -## Non-goals - -- Blocking or refusing a create. Duplicate detection is a heuristic and must never gate a sync. -- Changing `adopt` or `scan`. Both work; they are simply not reachable from where the mistake happens. -- Semantic or embedding-based matching. A stopword list and a word intersection is enough for a hint, - and it stays offline. diff --git a/openspec/changes/warn-before-creating-a-duplicate-issue/tasks.md b/openspec/changes/warn-before-creating-a-duplicate-issue/tasks.md deleted file mode 100644 index 2a8d754..0000000 --- a/openspec/changes/warn-before-creating-a-duplicate-issue/tasks.md +++ /dev/null @@ -1,22 +0,0 @@ -## Phase 1 — make the create path legible - -- [ ] Distinguish the create branch from the update branch in the dry-run printer - (`cmd/specsync/main.go`), so a preview about to open an issue cannot read as a projection -- [ ] Stop printing the `issues/0` placeholder URL on the create path; state that no issue exists yet -- [ ] A test asserting a create-path dry run and an update-path dry run do not produce the same verb - -## Phase 2 — surface prior art - -- [ ] Add distinctive-word extraction: normalise, drop words under 4 characters, drop a small - stopword list, dedupe -- [ ] Before a create, list open issues in the target repo and rank by shared distinctive words -- [ ] Print the top few with the shared words shown, and the `specsync adopt -issue N -change ` - line ready to copy -- [ ] Fetch failures must degrade to printing nothing — a hint is never worth failing a sync over - -## Phase 3 — prove it - -- [ ] A test with two differently-phrased titles sharing one distinctive word asserting the match is - found. This is the case that motivated the change, so it is the one that must pass -- [ ] A test asserting unrelated titles produce no suggestions -- [ ] A test asserting a provider error during the lookup leaves the sync result unchanged