diff --git a/cmd/specsync/main.go b/cmd/specsync/main.go index 90bbd23..2cef36e 100644 --- a/cmd/specsync/main.go +++ b/cmd/specsync/main.go @@ -136,6 +136,11 @@ func runPull(args []string) { if res.Tasks != "" { printPreview("tasks.md", res.Tasks) } + if res.MarkerPresent { + fmt.Printf("\nissue %s already carries the marker %s (no edit needed)\n", *issue, res.Marker) + } else { + fmt.Printf("\nwould add marker to issue %s body: %s\n", *issue, res.Marker) + } return } fmt.Printf("specsync: pulled issue %s -> %s\n", *issue, dest) diff --git a/github.go b/github.go index 2b22820..81f6e01 100644 --- a/github.go +++ b/github.go @@ -6,6 +6,7 @@ import ( "fmt" "os/exec" "strings" + "sync" ) // GitHubProvider projects changes onto GitHub Issues using the `gh` CLI. It @@ -15,6 +16,11 @@ type GitHubProvider struct { repo string // optional "owner/name"; empty = auto-detect from git remote // run executes gh and returns trimmed stdout. Overridable in tests. run func(ctx context.Context, args ...string) (string, error) + + // nameOnce memoizes the canonical cache key so Name() resolves the concrete + // repo (auto-detect included) exactly once instead of on every call. + nameOnce sync.Once + name string } // NewGitHubProvider returns a provider that drives the real `gh` binary, @@ -48,11 +54,30 @@ func runGH(ctx context.Context, args ...string) (string, error) { return strings.TrimSpace(string(out)), nil } +// Name is the ref-cache key. It resolves the concrete target repo — from -repo +// or, failing that, the git-remote auto-detect gh itself would use — and keys +// canonically as "github:owner/repo". Two providers aimed at the same repo thus +// share one key, whichever way the repo was supplied, so a ref cached by `pull` +// is found by a later auto-detected `sync`. Resolution is memoized because it +// shells out; when the repo can't be resolved it degrades to the bare "github". func (p *GitHubProvider) Name() string { - if p.repo != "" { - return "github:" + p.repo + p.nameOnce.Do(func() { + p.name = p.resolveKey(context.Background()) + }) + return p.name +} + +func (p *GitHubProvider) resolveKey(ctx context.Context) string { + repo := p.repo + if repo == "" { + if out, err := p.run(ctx, "repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"); err == nil { + repo = strings.TrimSpace(out) + } } - return "github" + if repo == "" { + return "github" + } + return "github:" + repo } // repoFlag returns ["--repo", "owner/name"] when a repo override is set, @@ -107,6 +132,22 @@ func (p *GitHubProvider) renderBody(item WorkItem) string { return marker(item.Slug) + "\n\n" + item.Body } +// EnsureMarker upserts the identity marker into issue id's body so the link +// survives loss of the local ref cache: a later sync rediscovers the issue via +// Find. Idempotent — a body already carrying the marker is left untouched and no +// gh write happens. It satisfies the IssueMarkerWriter capability used by pull. +func (p *GitHubProvider) EnsureMarker(ctx context.Context, id, slug, body string) (bool, error) { + if strings.Contains(body, marker(slug)) { + return false, nil + } + args := append([]string{"issue", "edit", id}, p.repoFlag()...) + args = append(args, "--body", marker(slug)+"\n\n"+body) + if _, err := p.run(ctx, args...); err != nil { + return false, err + } + return true, nil +} + func (p *GitHubProvider) Push(ctx context.Context, item WorkItem, existing *Ref) (Ref, error) { labels := desiredLabels(item) if err := p.ensureLabels(ctx, labels); err != nil { diff --git a/openspec/changes/epic-and-subissue-projection/.status b/openspec/changes/epic-and-subissue-projection/.status index 0aa502e..1e1362a 100644 --- a/openspec/changes/epic-and-subissue-projection/.status +++ b/openspec/changes/epic-and-subissue-projection/.status @@ -1 +1 @@ -triaged +researched diff --git a/openspec/changes/epic-and-subissue-projection/proposal.md b/openspec/changes/epic-and-subissue-projection/proposal.md index 29554d1..15f1beb 100644 --- a/openspec/changes/epic-and-subissue-projection/proposal.md +++ b/openspec/changes/epic-and-subissue-projection/proposal.md @@ -8,16 +8,47 @@ gets a branch, a worktree, and — once implementation starts — a focused spec specsync should understand this hierarchy so an epic stays a coordination shell while each sub-issue maps one-to-one to an OpenSpec change. -## What +GitHub now models this natively: **sub-issues** (parent↔child) went GA in 2025, +support cross-repo/cross-org via the GraphQL `subIssueUrl`, and are read/written +through `gh api graphql` (`subIssues`, `parent`, `subIssuesSummary`; +`addSubIssue`/`removeSubIssue`/`reprioritizeSubIssue`). So the hierarchy should be +projected onto real sub-issues — not a markdown checklist that only renders. -- Recognize an epic by a convention (a label/marker such as `type:epic`), not by - having a spec of its own — an epic is a list/coordination issue. -- Map each sub-issue to one OpenSpec change (one change ⇄ one sub-issue). -- Keep the epic's body in sync with the set of sub-issues (checklist or links), - while each sub-issue's body is driven by its change's proposal + tasks. +## What Changes -## Scope +- Recognize an **epic** by convention (a `type:epic` label/marker), not by having + a spec of its own — an epic is a coordination issue. +- Add a typed **`parent`** edge to a change's `links.md` (the committed relationship + layer; never `refs.json`, which stays identity-only). A child change with a + `## Parent` entry is projected as a **sub-issue** of that parent via the GitHub + sub-issues API, cross-repo-safe through `subIssueUrl`. +- **Reconcile the parent edge both ways** against a last-synced **baseline** (a + gitignored snapshot in `.specsync/`, the merge base). Because an edge is binary + (present/absent), the 3-way reconcile has no ambiguous conflict: an edge added in + `links.md` is pushed to GitHub; a parent attached on GitHub by a human is + **pulled into `links.md`**; an edge removed on either side is removed from the + other. `links.md` and the tracker converge — neither goes stale, and a human's + UI edit is honored rather than discarded. +- **Roll up** the epic body from its live `subIssuesSummary` (count + completion), + while each sub-issue's body stays driven by its change's proposal + tasks. -- Epic detection convention + a `parent` link on a change. -- Sub-issue projection: child change ⇄ sub-issue. -- Epic body roll-up of its sub-issues. +### Out of scope / explicitly deferred +- `blocked-by` / `blocks` dependencies (→ `issue-dependency-sync`) +- Deriving relationships from OpenSpec `references:` (→ `openspec-references-coordination`) +- Reordering sub-issues (`reprioritizeSubIssue`) — add when a real need appears +- Non-GitHub providers — sub-issue projection is a GitHub capability; others gain + an equivalent when they exist (`pluggable-providers`) + +## Capabilities + +### New Capabilities +- `sub-issue-projection` — project a child change's `## Parent` edge onto a GitHub + sub-issue (cross-repo via `subIssueUrl`), delta-reconciled against `links.md`, + with epic-body roll-up from `subIssuesSummary`. + +## Impact +- New code: typed-edge parsing in `links.md` (a `## Parent` section beside the + existing `## Related`), and a sub-issue reconcile that shells `gh api graphql`. +- Reuses the asserted-graph line (`work-graph`) and the per-layer reconcile model + (`two-way-reconcile`); stdlib-only; shells out. Builds on the typed-`links.md` + groundwork in `link-by-issue-reference`. diff --git a/openspec/changes/epic-and-subissue-projection/specs/sub-issue-projection/spec.md b/openspec/changes/epic-and-subissue-projection/specs/sub-issue-projection/spec.md new file mode 100644 index 0000000..c2b2c93 --- /dev/null +++ b/openspec/changes/epic-and-subissue-projection/specs/sub-issue-projection/spec.md @@ -0,0 +1,57 @@ +# sub-issue-projection + +## ADDED Requirements + +### Requirement: Recognize an epic without a spec of its own +specsync SHALL treat a GitHub issue carrying the `type:epic` label as an epic — a +coordination shell — and SHALL NOT require or create an OpenSpec change for it. + +#### Scenario: An epic is coordination-only +- **WHEN** an issue labeled `type:epic` is encountered as a `## Parent` +- **THEN** specsync does not expect a local change for it +- **AND** it does not author a proposal or tasks for the epic + +### Requirement: Project a change's parent edge onto a GitHub sub-issue +specsync SHALL read a `## Parent` entry from a change's `links.md` and make that +change's issue a sub-issue of the named parent, using the GitHub sub-issues API +(`addSubIssue`), passing the child's issue URL (`subIssueUrl`) so a parent in a +different repository works. + +#### Scenario: Child projected under its parent +- **WHEN** a change's `links.md` has `## Parent` → `owner/repo#10` and the change is synced +- **THEN** the change's issue is attached as a sub-issue of `owner/repo#10` +- **AND** a parent in a different repo is attached via its issue URL + +### Requirement: Reconcile the parent edge both ways against a baseline +specsync SHALL reconcile the parent edge bidirectionally against a last-synced +baseline stored in the gitignored `.specsync/` cache. Because the edge is binary, +each case resolves without ambiguity: an edge in `links.md` but not the baseline is +pushed to GitHub; a parent on GitHub but not the baseline is pulled into +`links.md`; an edge in the baseline but missing from `links.md` is removed on +GitHub; an edge in the baseline but missing on GitHub is removed from `links.md`. +After reconcile the baseline is updated to the converged set. + +#### Scenario: Local removal detaches the sub-issue +- **WHEN** the `## Parent` entry is deleted from `links.md` (it was in the baseline) and the change is synced +- **THEN** specsync detaches the sub-issue on GitHub +- **AND** the baseline no longer records it + +#### Scenario: A parent attached on GitHub is pulled into links.md +- **WHEN** a person attaches this issue under a parent on GitHub (not in the baseline) and the change is synced +- **THEN** specsync writes the parent into `links.md` as a `## Parent` entry +- **AND** the edge is not discarded + +#### Scenario: Removal on GitHub clears the local entry +- **WHEN** a parent that was in the baseline is detached on GitHub and the change is synced +- **THEN** specsync removes the `## Parent` entry from `links.md` +- **AND** does not re-push the edge + +### Requirement: Roll up the epic body from its sub-issues +specsync SHALL keep an epic issue's body roll-up in sync with its live +`subIssuesSummary` (total and completed), without overwriting a child sub-issue's +own body. + +#### Scenario: Epic reflects child completion +- **WHEN** an epic has three sub-issues, one closed +- **THEN** its roll-up reports the total and completed counts from `subIssuesSummary` +- **AND** each child's body remains driven by its own change diff --git a/openspec/changes/epic-and-subissue-projection/tasks.md b/openspec/changes/epic-and-subissue-projection/tasks.md index 21367a3..6e9cef0 100644 --- a/openspec/changes/epic-and-subissue-projection/tasks.md +++ b/openspec/changes/epic-and-subissue-projection/tasks.md @@ -1,6 +1,20 @@ # Tasks: Epic & sub-issue projection -- [ ] Define the epic convention (type:epic marker/label) and a parent link field -- [ ] Project a child change to a sub-issue under its epic -- [ ] Roll up sub-issue references into the epic body -- [ ] Do not require or create a spec for the epic itself +## Typed links (`links.md`, core) +- [ ] Parse a `## Parent` section in `links.md` (one entry: `#N` / `owner/repo#N` / URL), beside the existing `## Related` +- [ ] Keep `## Related` behavior unchanged; `refs.json` stays identity-only + +## Sub-issue projection (GitHub, `gh api graphql`) +- [ ] Read the issue's current parent/sub-issues (`parent`, `subIssues`, `subIssuesSummary`) +- [ ] Attach a child via `addSubIssue` using `subIssueUrl` (cross-repo/cross-org safe); resolve node ids as needed +- [ ] Maintain a gitignored `.specsync/` baseline of the last-synced parent edge (the merge base) +- [ ] Reconcile both ways against the baseline: push local add, pull GitHub add into `links.md`, remove on the opposite side for a removal recorded in the baseline; `removeSubIssue` for a local removal; update the baseline to the converged set + +## Epic handling +- [ ] Detect `type:epic`; do not require or create a change/spec for the epic +- [ ] Roll up the epic body from `subIssuesSummary` (total/completed); never overwrite a child's body + +## Boundaries & tests +- [ ] Stdlib-only; shell out to `gh api graphql`; `boundary_test.go` green +- [ ] Fake-runner tests: attach, detach-on-removal, cross-repo via URL, unmanaged-edge gap, epic roll-up +- [ ] Update the specsync skill with the `## Parent` syntax and the epic convention diff --git a/openspec/changes/issue-dependency-sync/.status b/openspec/changes/issue-dependency-sync/.status new file mode 100644 index 0000000..1e1362a --- /dev/null +++ b/openspec/changes/issue-dependency-sync/.status @@ -0,0 +1 @@ +researched diff --git a/openspec/changes/issue-dependency-sync/proposal.md b/openspec/changes/issue-dependency-sync/proposal.md new file mode 100644 index 0000000..c22c774 --- /dev/null +++ b/openspec/changes/issue-dependency-sync/proposal.md @@ -0,0 +1,53 @@ +# Sync issue dependencies (blocked-by / blocks) + +## Why + +Work that spans a backend and a frontend almost always has a *direction*: the +frontend change can't ship until the backend change lands. Today specsync can +record that two specs are "related" (`## Related`), but "related" is symmetric and +loses the dependency direction — the exact thing a planner and an agent need to +sequence the work. + +GitHub now models this as a first-class relationship: **issue dependencies** +(`blockedBy` / `blocking`) went GA in 2025, with real mutations (`addBlockedBy`, +`removeBlockedBy`) and a summary field (`issueDependenciesSummary`), read/written +via `gh api graphql` (there is no native `gh issue` flag yet). So a directed +local edge should project onto a real dependency, not be flattened into "related". + +## What Changes + +- Add directed typed edges to `links.md`: **`## Blocked by`** and **`## Blocks`** + (the inverse). Entries take the usual forms (`#N` / `owner/repo#N` / URL), so a + backend issue in another repo can block a frontend change. +- On sync, project each `## Blocked by` entry onto a GitHub **issue dependency** + via `addBlockedBy`, cross-repo by node id. `## Blocks` is the same edge seen from + the other end (write it as the blocker's `blockedBy`). +- **Reconcile dependencies both ways** against a last-synced **baseline** (a + gitignored snapshot in `.specsync/`, the merge base). Each edge is binary, so the + 3-way reconcile is unambiguous: an edge added in `links.md` is pushed + (`addBlockedBy`); a dependency added on GitHub by a human is **pulled into + `links.md`**; an edge removed on either side is removed from the other + (`removeBlockedBy` for the GitHub side). Dependencies converge in both + directions — none go stale, and UI-added dependencies are honored, not discarded. + +### Out of scope / explicitly deferred +- Parent/sub-issue hierarchy (→ `epic-and-subissue-projection`) +- `duplicateOf` and other relationship types — add when a real need appears +- Cycle detection across dependencies — GitHub rejects cycles itself; specsync + surfaces the error rather than pre-validating +- Non-GitHub providers — dependency sync is a GitHub capability; others gain an + equivalent when they exist (`pluggable-providers`) + +## Capabilities + +### New Capabilities +- `issue-dependency-sync` — project directed `## Blocked by` / `## Blocks` edges + from `links.md` onto GitHub issue dependencies, delta-reconciled, cross-repo. + +## Impact +- New code: parse `## Blocked by` / `## Blocks` in `links.md`; a dependency + reconcile that shells `gh api graphql` (`issueDependenciesSummary`, `blockedBy`; + `addBlockedBy` / `removeBlockedBy`). +- Same asserted-graph and per-layer-reconcile model as the sibling changes; + stdlib-only; shells out. Composes with `epic-and-subissue-projection` (both are + typed `links.md` edges projected onto native GitHub relationships). diff --git a/openspec/changes/issue-dependency-sync/specs/issue-dependency-sync/spec.md b/openspec/changes/issue-dependency-sync/specs/issue-dependency-sync/spec.md new file mode 100644 index 0000000..191ab27 --- /dev/null +++ b/openspec/changes/issue-dependency-sync/specs/issue-dependency-sync/spec.md @@ -0,0 +1,57 @@ +# issue-dependency-sync + +## ADDED Requirements + +### Requirement: Declare directed dependency edges in links.md +specsync SHALL parse `## Blocked by` and `## Blocks` sections in a change's +`links.md`, each listing issue references (`#N` / `owner/repo#N` / URL). These are +directed edges, distinct from the symmetric `## Related` section. + +#### Scenario: A frontend change blocked by a backend issue +- **WHEN** a change's `links.md` has `## Blocked by` → `ExopenGitHub/FusionHub#3489` +- **THEN** specsync treats that backend issue as blocking this change's issue +- **AND** the dependency direction is preserved (not flattened to "related") + +### Requirement: Project dependencies onto GitHub issue dependencies +On sync specsync SHALL make the issue's dependencies match `links.md`: a +`## Blocked by` entry is projected via `addBlockedBy`, and a `## Blocks` entry is +projected as the named issue being blocked by this one. References in another repo +are linked by node id. + +#### Scenario: Blocked-by projected to GitHub +- **WHEN** a change with a `## Blocked by` entry is synced +- **THEN** the change's issue gains a GitHub dependency on the named blocker +- **AND** a blocker in a different repo is linked across repos + +### Requirement: Reconcile dependencies both ways against a baseline +specsync SHALL reconcile the dependency edge set bidirectionally against a +last-synced baseline stored in the gitignored `.specsync/` cache. Each edge is +binary, so every case resolves without ambiguity: an edge in `links.md` but not the +baseline is pushed (`addBlockedBy`); a dependency on GitHub but not the baseline is +pulled into `links.md`; an edge in the baseline but missing from `links.md` is +removed on GitHub (`removeBlockedBy`); an edge in the baseline but missing on GitHub +is removed from `links.md`. After reconcile the baseline becomes the converged set. + +#### Scenario: Local removal clears the dependency on GitHub +- **WHEN** a `## Blocked by` entry that was in the baseline is deleted from `links.md` and the change is synced +- **THEN** specsync removes the dependency on GitHub +- **AND** the baseline no longer records it + +#### Scenario: A dependency added on GitHub is pulled into links.md +- **WHEN** a person adds a blocked-by dependency on GitHub (not in the baseline) and the change is synced +- **THEN** specsync writes it into `links.md` as a `## Blocked by` entry +- **AND** the dependency is not discarded + +#### Scenario: Removal on GitHub clears the local entry +- **WHEN** a dependency that was in the baseline is removed on GitHub and the change is synced +- **THEN** specsync removes the `## Blocked by` entry from `links.md` +- **AND** does not re-push it + +### Requirement: Surface dependency conflicts rather than pre-validating +specsync SHALL rely on GitHub to reject invalid dependencies (e.g. a cycle) and +SHALL surface that error, rather than maintaining its own cycle check. + +#### Scenario: GitHub rejects a cycle +- **WHEN** projecting a dependency would create a cycle and GitHub returns an error +- **THEN** specsync reports the error +- **AND** does not silently drop the edge diff --git a/openspec/changes/issue-dependency-sync/tasks.md b/openspec/changes/issue-dependency-sync/tasks.md new file mode 100644 index 0000000..9ce1d63 --- /dev/null +++ b/openspec/changes/issue-dependency-sync/tasks.md @@ -0,0 +1,18 @@ +# Tasks: sync issue dependencies + +## Typed links (`links.md`, core) +- [ ] Parse `## Blocked by` and `## Blocks` sections (entries: `#N` / `owner/repo#N` / URL) +- [ ] Treat them as directed edges, distinct from the symmetric `## Related` + +## Dependency projection (GitHub, `gh api graphql`) +- [ ] Read current dependencies (`issueDependenciesSummary`, `blockedBy`, `blocking`) +- [ ] Resolve node ids for cross-repo references; `addBlockedBy` for `## Blocked by` +- [ ] `## Blocks` projects as the named issue's `blockedBy` (the inverse edge) +- [ ] Maintain a gitignored `.specsync/` baseline of the last-synced dependency set (the merge base) +- [ ] Reconcile both ways against the baseline: push local adds, pull GitHub adds into `links.md`, `removeBlockedBy` for local removals, remove from `links.md` for GitHub removals; update the baseline to the converged set +- [ ] Surface GitHub's error on an invalid/cyclic dependency rather than pre-validating + +## Boundaries & tests +- [ ] Stdlib-only; shell out to `gh api graphql`; `boundary_test.go` green +- [ ] Fake-runner tests: add blocked-by, inverse `## Blocks`, cross-repo, remove-on-removal, unmanaged-edge gap, cycle-error surfaced +- [ ] Update the specsync skill with the `## Blocked by` / `## Blocks` syntax diff --git a/openspec/changes/openspec-references-coordination/.status b/openspec/changes/openspec-references-coordination/.status new file mode 100644 index 0000000..1e1362a --- /dev/null +++ b/openspec/changes/openspec-references-coordination/.status @@ -0,0 +1 @@ +researched diff --git a/openspec/changes/openspec-references-coordination/proposal.md b/openspec/changes/openspec-references-coordination/proposal.md new file mode 100644 index 0000000..38267bd --- /dev/null +++ b/openspec/changes/openspec-references-coordination/proposal.md @@ -0,0 +1,59 @@ +# Coordinate across repos via OpenSpec references and worksets + +## Why + +A backend repo and a frontend repo are often worked at the same time, in two +worktrees, on branches that depend on each other. The agent in one folder needs to +know the other exists, where it is on disk, and which of its changes/issues relate +— so it can compare and stay in sync. + +OpenSpec 1.5.0 already provides the local half of this and we should **embrace it +rather than reinvent it**: +- **`references:`** in the committed `openspec/config.yaml` declares which sibling + OpenSpec repos ("stores") this project depends on; `openspec context --json` + resolves a working set (root + referenced stores) and surfaces the upstream spec + index to agents. +- **`openspec workset`** is a machine-local named set of folders opened together + (e.g. `front=…/frontend back=…/backend`) — the two-worktree ergonomics. + +What is missing is the bridge to the tracker: the *local* reference graph and the +*GitHub* relationship graph are not kept in sync, and specsync's own planning +output is blind to referenced siblings. specsync owns that bridge. + +## What Changes + +- **Read OpenSpec coordination, don't duplicate it.** specsync reads + `openspec context --json` (referenced stores + their resolved local paths) and + `openspec workset list --json` (folder sets) — it adds **no** path registry of + its own. Machine-local data stays in OpenSpec; nothing new is committed. +- **Surface referenced siblings in planning output.** `scan`/`relate` (and a new + `--references` view) report, for the current repo, each referenced sibling repo, + its local folder, and its related changes/issues — so an agent in the frontend + worktree can locate and compare with the backend worktree. +- **Suggest, never auto-create, the tracker edge.** Where a reference implies a + dependency, specsync *suggests* a `## Blocked by` entry for the user/agent to + confirm; it does not silently write GitHub dependencies from a repo-level + reference. The actual projection stays with the explicit typed-link sync + (`issue-dependency-sync`, `epic-and-subissue-projection`). This keeps the + "capture cheaply, reconcile gently, never enforce" line. + +### Out of scope / explicitly deferred +- Projecting relationships to GitHub — that is the sibling changes' job; this one + only reads OpenSpec coordination and surfaces/suggests +- Managing OpenSpec stores/worksets (creating, registering) — that is `openspec`'s + job; specsync only reads them +- Following references more than one level deep (OpenSpec itself resolves one level) + +## Capabilities + +### New Capabilities +- `openspec-reference-coordination` — read OpenSpec `references:`/`workset` to make + specsync's planning output aware of sibling repos and their local folders, and + suggest the matching tracker edges without auto-creating them. + +## Impact +- New code: read `openspec context --json` / `openspec workset list --json` (same + CLI-as-source-of-truth, version-guarded, tolerant-parse discipline the trace + features already use); surface siblings in `scan`/`relate`. +- Degrades cleanly when OpenSpec lacks references/worksets or the binary is older — + the feature simply reports nothing extra. Stdlib-only; shells out. diff --git a/openspec/changes/openspec-references-coordination/specs/openspec-reference-coordination/spec.md b/openspec/changes/openspec-references-coordination/specs/openspec-reference-coordination/spec.md new file mode 100644 index 0000000..4dfb79b --- /dev/null +++ b/openspec/changes/openspec-references-coordination/specs/openspec-reference-coordination/spec.md @@ -0,0 +1,44 @@ +# openspec-reference-coordination + +## ADDED Requirements + +### Requirement: Read OpenSpec coordination instead of duplicating it +specsync SHALL discover sibling repos from OpenSpec's own coordination data — the +`references:` resolved by `openspec context --json` and the folder sets from +`openspec workset list --json` — and SHALL NOT maintain its own registry of repos +or local paths. + +#### Scenario: Siblings come from OpenSpec, not a specsync file +- **WHEN** the repo's `openspec/config.yaml` declares `references: [backend]` +- **THEN** specsync learns the backend store and its local path from `openspec context --json` +- **AND** specsync writes no path registry of its own + +### Requirement: Surface referenced siblings in planning output +specsync SHALL report, for the current repo, each referenced sibling repo, its +resolved local folder, and the sibling's related changes/issues, so an agent in +one worktree can locate and compare with another. + +#### Scenario: An agent in the frontend worktree sees the backend +- **WHEN** `scan`/`relate` runs in a repo that references the backend store +- **THEN** the output lists the backend repo, its local folder, and its related changes/issues +- **AND** an agent can open or compare that folder + +### Requirement: Suggest, never auto-create, the tracker edge +When a reference implies a dependency, specsync SHALL suggest a `## Blocked by` +entry for confirmation and SHALL NOT write a GitHub dependency directly from a +repo-level reference. Projecting confirmed edges remains the job of the explicit +typed-link sync. + +#### Scenario: Reference suggests a dependency +- **WHEN** the frontend repo references the backend store and a related backend issue exists +- **THEN** specsync suggests adding `## Blocked by` → the backend issue +- **AND** it does not create the GitHub dependency until the entry is confirmed in `links.md` + +### Requirement: Degrade cleanly without OpenSpec coordination data +specsync SHALL function when no `references:`/`workset` exist or the `openspec` +binary is older or absent, simply reporting no extra siblings rather than failing. + +#### Scenario: No references configured +- **WHEN** the repo declares no `references:` and has no worksets +- **THEN** planning output omits the siblings section +- **AND** no error is raised diff --git a/openspec/changes/openspec-references-coordination/tasks.md b/openspec/changes/openspec-references-coordination/tasks.md new file mode 100644 index 0000000..a37839f --- /dev/null +++ b/openspec/changes/openspec-references-coordination/tasks.md @@ -0,0 +1,19 @@ +# Tasks: coordinate via OpenSpec references and worksets + +## Read OpenSpec coordination (no new registry) +- [ ] Read `openspec context --json` → referenced stores + resolved local paths (root + referenced_store members) +- [ ] Read `openspec workset list --json` → named folder sets for local ergonomics +- [ ] Version-guard + tolerant-parse the JSON (same discipline as the OpenSpec trace adapter); degrade cleanly when absent/older + +## Surface in planning output +- [ ] Add referenced siblings to `scan`/`relate`: sibling repo, local folder, its related changes/issues +- [ ] Optional `--references` view that lists just the coordination graph + +## Suggest tracker edges (never auto-create) +- [ ] Where a reference implies a dependency, suggest a `## Blocked by` entry for confirmation +- [ ] Do not write any GitHub relationship here — projection stays with issue-dependency-sync / epic-and-subissue-projection + +## Boundaries & tests +- [ ] Read-only; stdlib-only; shells out to `openspec`; `boundary_test.go` green +- [ ] Fake-runner tests: references surfaced, worksets surfaced, suggestion emitted, clean degradation with no references +- [ ] Update the specsync skill: how references/worksets feed the two-worktree workflow diff --git a/openspec/changes/stable-projection-ref-key/proposal.md b/openspec/changes/stable-projection-ref-key/proposal.md new file mode 100644 index 0000000..51725ad --- /dev/null +++ b/openspec/changes/stable-projection-ref-key/proposal.md @@ -0,0 +1,77 @@ +# Stabilize the projection ref-cache key so sync never duplicates an issue + +## Why + +Two-way sync silently created a **duplicate issue** in a real run, defeating the +whole point of the ref cache. Reproduced against `ExopenGitHub/ExoKit`: + +1. A change was created by `specsync pull -issue 15` (issue-first flow). `pull` + constructs a repo-scoped provider, so the ref was cached under the key + `"github:ExopenGitHub/ExoKit"` (see `GitHubProvider.Name()` in `github.go:51`). +2. A later `specsync -slug ` was run **without `-repo`**. That provider + auto-detects the repo via the git remote but leaves `p.repo` empty, so + `Name()` returns the bare `"github"`. +3. `syncOne` (`sync.go:68`) looks up `refs[prov.Name()]` — `refs["github"]` — which + is absent, so `hadRef=false` and it calls `Push(existing=nil)`. +4. `Push` (`github.go:118`) defends against duplicates with `Find`, which matches + **only by the body marker** ``. Issue #15 had no + marker (`pull` does not write the marker back into the source issue), so `Find` + returned nil and `Push` **created a new issue (#16)** instead of updating #15. + +Two independent gaps line up to produce the duplicate: + +- **The ref-cache key is unstable.** For the *same repository*, an auto-detected + provider (`"github"`) and an explicit/`pull`-constructed one (`"github:owner/repo"`) + key the cache differently. A ref saved under one is invisible under the other, so + `hadRef` is wrongly `false`. +- **A `pull`-linked issue is un-rediscoverable.** `pull` binds a change to an + existing issue but never persists the identity marker into that issue, so once the + cache key is missed, `Find` cannot recover the link and the create path fires. + +The ref cache is documented as "purely an optimization … rebuilt via the provider's +Find" (`cache.go:11`). That contract only holds if (a) the key is stable and (b) the +marker actually exists on linked issues. Both were violated. + +## What Changes + +- **The GitHub provider resolves its repo once and keys the cache canonically.** + `Name()` always returns `"github:owner/repo"` for the concrete target repo, + whether the repo came from `-repo` or from git-remote auto-detection. Auto-detected + and explicit-repo providers pointing at the same repo now share one cache key, so a + ref saved by `pull` is found by a later `sync`. +- **Ref lookup is backward-compatible and self-healing.** `syncOne` resolves a + change's ref by the canonical key and, failing that, by the legacy bare-provider + key (`"github"`), migrating a hit to the canonical key on the next save. Existing + `refs.json` caches keep working without a manual edit. +- **`pull` writes the identity marker into the source issue.** Pulling issue `#N` + edits the issue body to carry ``, so the link is + durable: even if the cache is deleted, a later `sync` rediscovers `#N` via `Find` + and updates it instead of creating a duplicate. (Honors `-dry-run`: preview only.) + +### Out of scope / explicitly deferred +- Providers other than GitHub — Beads keys refs by its own provider name and is + unaffected; an equivalent guarantee arrives with each provider. +- De-duplicating issues that were *already* created by this bug — this change + prevents new duplicates; cleaning up existing pairs stays a manual/`link` task. +- Changing the marker format or the `Find` search query. + +## Capabilities + +### New Capabilities +- `stable-projection-identity` — a change's projection is identified by a repo-stable + cache key plus a durable on-issue marker, so `sync` updates the existing issue + across `pull`/`push` and `-repo`/auto-detect combinations and never creates a + duplicate. + +## Impact + +- `github.go`: `NewGitHubProvider` resolves the remote repo at construction (or + lazily on first use) so `Name()` yields `"github:owner/repo"` consistently; + `repoFlag()` unchanged behaviorally. +- `sync.go` (`syncOne`) / `cache.go`: canonical-key lookup with a legacy-key + fallback and migrate-on-save. +- `pull.go`: after fetching, push the marker into the source issue body (idempotent + upsert), guarded by `-dry-run`. +- Stays stdlib-only and shells out to `gh`. Extends the ref-cache model from + `two-way-reconcile` and the `github:owner/repo` keying introduced with + `cross-repo-linked-issues`; depends on no in-flight change. diff --git a/openspec/changes/stable-projection-ref-key/specs/stable-projection-identity/spec.md b/openspec/changes/stable-projection-ref-key/specs/stable-projection-identity/spec.md new file mode 100644 index 0000000..754f59f --- /dev/null +++ b/openspec/changes/stable-projection-ref-key/specs/stable-projection-identity/spec.md @@ -0,0 +1,45 @@ +# stable-projection-identity + +## ADDED Requirements + +### Requirement: Repo-stable ref-cache key +The GitHub provider SHALL key the ref cache by the concrete target repository, +`"github:owner/repo"`, regardless of whether the repo was supplied via `-repo` or +auto-detected from the git remote. Two providers targeting the same repository SHALL +produce the same cache key. + +#### Scenario: Auto-detected and explicit-repo providers share a key +- **WHEN** a change is bound by `specsync pull -issue N` (repo-scoped provider) and later synced by `specsync -slug ` without `-repo` +- **THEN** both operations resolve the same `"github:owner/repo"` cache key +- **AND** the later sync sees the cached ref (`hadRef` is true) and updates the existing issue + +#### Scenario: No duplicate when the marker is absent +- **WHEN** the cached ref resolves to an issue whose body has no identity marker +- **THEN** sync updates that issue via the cached ref +- **AND** it does not create a new issue + +### Requirement: Backward-compatible, self-healing ref lookup +When resolving a change's ref, specsync SHALL first look up the canonical +`"github:owner/repo"` key and, if absent, fall back to the legacy bare `"github"` +key. A ref found under the legacy key SHALL be re-saved under the canonical key on +the next persist, and SHALL NOT trigger a create. + +#### Scenario: Legacy cache keeps working +- **WHEN** an existing `refs.json` holds a binding under the bare `"github"` key +- **THEN** a sync finds it and updates the linked issue +- **AND** the ref is rewritten under the canonical `"github:owner/repo"` key + +### Requirement: Pull persists the identity marker on the source issue +When `pull` links a change to an existing issue `#N`, specsync SHALL write the +identity marker `` into that issue's body (idempotent +upsert), so the link is rediscoverable by `Find` even if the local ref cache is lost. + +#### Scenario: Rediscovery after cache loss +- **WHEN** a change is pulled from issue `#N`, then its `.specsync/` cache is deleted, then `specsync -slug ` runs +- **THEN** `Find` locates `#N` by its marker +- **AND** sync updates `#N` instead of creating a duplicate + +#### Scenario: Pull dry-run previews the marker edit +- **WHEN** `specsync pull -issue N -dry-run` runs +- **THEN** it reports the marker it would add to issue `#N` +- **AND** it makes no GitHub write diff --git a/openspec/changes/stable-projection-ref-key/tasks.md b/openspec/changes/stable-projection-ref-key/tasks.md new file mode 100644 index 0000000..624f15e --- /dev/null +++ b/openspec/changes/stable-projection-ref-key/tasks.md @@ -0,0 +1,21 @@ +# Tasks + +## Repo-stable ref-cache key +- [x] `GitHubProvider` resolves its concrete repo (from `-repo` or git-remote auto-detect) so `Name()` returns `"github:owner/repo"` consistently +- [x] Keep `repoFlag()` behavior unchanged for `gh` invocation (auto-detect still allowed on the wire) +- [x] Unit test: auto-detected and `-repo` providers for the same repo yield the same `Name()` + +## Backward-compatible ref lookup +- [x] `syncOne` resolves the ref by canonical key, falling back to legacy bare `"github"` +- [x] Migrate a legacy-key hit to the canonical key on next save +- [x] Unit test: a `refs.json` with a bare `"github"` key updates the linked issue (no create) and is rewritten canonically + +## Pull persists the identity marker +- [x] `pull` upserts `` into the source issue body (idempotent) +- [x] Honor `-dry-run` (preview the marker edit, no GitHub write) +- [x] Unit test: after pull + cache deletion, a sync rediscovers the issue via `Find` and updates it (no duplicate) + +## Verification +- [x] `go build ./...` and `go test ./...` green +- [x] `gofmt` clean +- [ ] Manual: re-run the ExoKit repro (pull-linked issue + auto-detect push) updates, not duplicates diff --git a/provider.go b/provider.go index d97169c..d36952e 100644 --- a/provider.go +++ b/provider.go @@ -57,6 +57,18 @@ type IssueReader interface { Get(ctx context.Context, id string) (FetchedItem, error) } +// IssueMarkerWriter is an optional, type-asserted provider capability: persist +// the identity marker into an existing item's body. `pull` uses it so a change +// linked to an existing issue stays rediscoverable via Find even if the local +// ref cache is lost. Providers that always embed the marker on write (or model +// identity differently) simply don't implement it. +type IssueMarkerWriter interface { + // EnsureMarker upserts the identity marker for slug into item id's body, + // given its current body. It reports whether a write occurred and is + // idempotent: a body already carrying the marker triggers no write. + EnsureMarker(ctx context.Context, id, slug, body string) (bool, error) +} + // TaskStateReader is an optional, type-asserted provider capability: report the // external done-state of a change's tasks, keyed by normalized task text. It // exists because not every provider models tasks as checkboxes inside one item diff --git a/pull.go b/pull.go index 4b1f211..e63f408 100644 --- a/pull.go +++ b/pull.go @@ -26,6 +26,10 @@ type PullResult struct { Proposal string Tasks string Links []string // URLs from the ## Related section, for dry-run display + Marker string // identity marker upserted into the source issue + // MarkerPresent reports whether the source issue already carried the marker, + // i.e. no write was (or would be) needed. Drives the dry-run preview. + MarkerPresent bool } // Pull materializes a local OpenSpec change from an existing issue. The change @@ -62,12 +66,14 @@ func Pull(ctx context.Context, opts PullOptions) (PullResult, error) { proposal, tasks, relatedURLs := splitBody(item.Body, item.Title) res := PullResult{ - Slug: slug, - Dir: filepath.Join(opts.OpenSpecDir, "changes", slug), - IssueURL: item.URL, - Proposal: proposal, - Tasks: tasks, - Links: relatedURLs, + Slug: slug, + Dir: filepath.Join(opts.OpenSpecDir, "changes", slug), + IssueURL: item.URL, + Proposal: proposal, + Tasks: tasks, + Links: relatedURLs, + Marker: marker(slug), + MarkerPresent: strings.Contains(item.Body, marker(slug)), } if opts.DryRun { @@ -102,6 +108,13 @@ func Pull(ctx context.Context, opts PullOptions) (PullResult, error) { if err := saveRef(res.Dir, opts.Provider.Name(), ref); err != nil { return PullResult{}, err } + // Persist the identity marker into the source issue so the link is durable: + // even if the ref cache is deleted, a later sync rediscovers it via Find. + if mw, ok := opts.Provider.(IssueMarkerWriter); ok { + if _, err := mw.EnsureMarker(ctx, item.ID, slug, item.Body); err != nil { + return PullResult{}, fmt.Errorf("persist identity marker: %w", err) + } + } return res, nil } diff --git a/stable_projection_test.go b/stable_projection_test.go new file mode 100644 index 0000000..f206b1a --- /dev/null +++ b/stable_projection_test.go @@ -0,0 +1,165 @@ +package specsync + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +// A repo supplied via -repo and one auto-detected from the git remote must key +// the ref cache identically, so a ref saved by pull is found by a later sync. +func TestGitHubNameKeyIsRepoStable(t *testing.T) { + explicit := NewGitHubProviderFuncWithRepo("o/r", func(context.Context, ...string) (string, error) { + return "", nil + }) + auto := NewGitHubProviderFunc(func(_ context.Context, args ...string) (string, error) { + if len(args) >= 2 && args[0] == "repo" && args[1] == "view" { + return "o/r", nil + } + return "", nil + }) + if explicit.Name() != "github:o/r" { + t.Fatalf("explicit key = %q, want github:o/r", explicit.Name()) + } + if auto.Name() != explicit.Name() { + t.Fatalf("auto-detected key %q != explicit key %q", auto.Name(), explicit.Name()) + } +} + +// A refs.json written before the key became repo-qualified must still resolve: +// the legacy bare "github" key updates the linked issue (never creates) and is +// re-saved under the canonical key. +func TestSyncLegacyKeyUpdatesAndMigrates(t *testing.T) { + root := t.TempDir() + cdir := filepath.Join(root, "changes", "c1") + mustWrite(t, filepath.Join(cdir, "proposal.md"), "# C1\n\nbody\n") + if err := saveRef(cdir, "github", Ref{Provider: "github", ID: "7", URL: "https://github.com/o/r/issues/7"}); err != nil { + t.Fatalf("seed legacy ref: %v", err) + } + + var calls [][]string + prov := NewGitHubProviderFuncWithRepo("o/r", func(_ context.Context, args ...string) (string, error) { + calls = append(calls, args) + switch { + case args[0] == "issue" && args[1] == "view": + return `{"labels":[]}`, nil + case args[0] == "issue" && args[1] == "list": + return "[]", nil + case args[0] == "issue" && args[1] == "create": + return "https://github.com/o/r/issues/99", nil + default: + return "", nil + } + }) + + if _, err := Sync(context.Background(), Options{OpenSpecDir: root, Provider: prov, Slug: "c1"}); err != nil { + t.Fatalf("Sync: %v", err) + } + if findCall(calls, "issue", "create") != nil { + t.Fatalf("legacy-key hit must update, not create: %v", calls) + } + if findCall(calls, "issue", "edit", "7") == nil { + t.Fatalf("expected `issue edit 7`, calls: %v", calls) + } + + refs, err := loadRefs(cdir) + if err != nil { + t.Fatalf("loadRefs: %v", err) + } + if refs["github:o/r"].ID != "7" { + t.Fatalf("ref not migrated to canonical key: %#v", refs) + } +} + +// The core invariant: a pulled change whose ref cache is later lost is still +// updated (not duplicated) because pull persisted the identity marker, which +// Find rediscovers. +func TestPullThenCacheLossRediscoversViaMarker(t *testing.T) { + root := t.TempDir() + issue := fakeIssue{ + Number: 7, + URL: "https://github.com/o/r/issues/7", + Title: "Round trip", + State: "open", + Body: "# Round trip\n\nbody\n", + } + // body is the issue's live body; pull's marker upsert mutates it in place. + body := issue.Body + runner := func(_ context.Context, args ...string) (string, error) { + switch { + case args[0] == "repo" && args[1] == "view": + return "o/r", nil + case args[0] == "issue" && args[1] == "view": + if jsonFields(args) == "labels" { + return `{"labels":[]}`, nil + } + i := issue + i.Body = body + b, _ := json.Marshal(i) + return string(b), nil + case args[0] == "issue" && args[1] == "edit": + if v := flagValue(args, "--body"); v != "" { + body = v + } + return "", nil + case args[0] == "issue" && args[1] == "list": + return fmt.Sprintf(`[{"number":7,"url":%q,"body":%q}]`, issue.URL, body), nil + default: + return "", nil + } + } + + prov := NewGitHubProviderFunc(runner) + res, err := Pull(context.Background(), PullOptions{OpenSpecDir: root, Provider: prov, IssueID: "7"}) + if err != nil { + t.Fatalf("Pull: %v", err) + } + if !strings.Contains(body, marker(res.Slug)) { + t.Fatalf("pull did not persist the identity marker into the issue body: %q", body) + } + + // Simulate total cache loss, then sync via a fresh auto-detect provider. + if err := os.RemoveAll(filepath.Join(res.Dir, ".specsync")); err != nil { + t.Fatalf("clear cache: %v", err) + } + var calls [][]string + prov2 := NewGitHubProviderFunc(func(ctx context.Context, args ...string) (string, error) { + calls = append(calls, args) + return runner(ctx, args...) + }) + if _, err := Sync(context.Background(), Options{OpenSpecDir: root, Provider: prov2, Slug: res.Slug}); err != nil { + t.Fatalf("Sync after cache loss: %v", err) + } + if findCall(calls, "issue", "create") != nil { + t.Fatalf("must not create a duplicate; calls: %v", calls) + } + if findCall(calls, "issue", "edit", "7") == nil { + t.Fatalf("expected `issue edit 7` via marker rediscovery; calls: %v", calls) + } +} + +// A dry-run pull reports the marker it would add but writes nothing to GitHub. +func TestPullDryRunPreviewsMarkerNoWrite(t *testing.T) { + root := t.TempDir() + issue := fakeIssue{Number: 7, URL: "u", Title: "T", State: "open", Body: "# T\n\nbody\n"} + var calls [][]string + prov := NewGitHubProviderFunc(ghRunner(issue, &calls)) + + res, err := Pull(context.Background(), PullOptions{OpenSpecDir: root, Provider: prov, IssueID: "7", DryRun: true}) + if err != nil { + t.Fatalf("Pull: %v", err) + } + if res.Marker != marker(res.Slug) { + t.Fatalf("marker preview = %q, want %q", res.Marker, marker(res.Slug)) + } + if res.MarkerPresent { + t.Fatalf("marker should be reported absent for a body without it") + } + if findCall(calls, "issue", "edit") != nil { + t.Fatalf("dry-run must not edit the issue; calls: %v", calls) + } +} diff --git a/sync.go b/sync.go index ad1c47c..9e3edd6 100644 --- a/sync.go +++ b/sync.go @@ -65,7 +65,15 @@ func syncOne(ctx context.Context, prov WorkProvider, c Change, dryRun, reconcile if err != nil { return Ref{}, false, nil, err } - existing, hadRef := refs[prov.Name()] + // Resolve by the canonical key, then fall back to the legacy bare "github" + // key so an existing refs.json — written before the key was repo-qualified — + // keeps updating its issue instead of creating a duplicate. saveRef below + // persists under the canonical key, migrating the hit going forward. + key := prov.Name() + existing, hadRef := refs[key] + if !hadRef && strings.HasPrefix(key, "github:") { + existing, hadRef = refs["github"] + } var existingPtr *Ref if hadRef { existingPtr = &existing