diff --git a/.agents/shared.md b/.agents/shared.md index 2bbcacbf..0aa923bb 100644 --- a/.agents/shared.md +++ b/.agents/shared.md @@ -126,8 +126,11 @@ through `cospec`: `archive/scenario-preservation` — no `--force`), delegates to `openspec archive`, verifies the move on disk, and fans out blocker sync. Schemas with no specs artifact (`ci`, `chore`, `docs`, …) correctly produce - no spec-sync deltas here. **Timing is non-negotiable** — see "Branch, PR, and - merge flow" below. + no spec-sync deltas here. To land a change's main specs before it is done, + `mise run cospec -- sync-specs ` runs archive's own merge on a scratch + copy and leaves the change active; its later archive is a no-op merge with + both hard gates still run. **Timing is non-negotiable** — see "Branch, PR, + and merge flow" below. Steps 1–2 also have `/cospec:new` + `/cospec:ff`/`/cospec:continue` as entry-point variants of `/cospec:propose`, and you can optionally dress-rehearse @@ -240,17 +243,21 @@ fixture proving it reads the view it is registered under. A new rule or gate takes the verbatim view unless no commented line can ever trigger it. **JSON documents are additive** — a cospec `--json` document that mirrors an -OpenSpec command (`status`, `list`, `validate`, and `archive --json` next) -carries every key upstream's document does: computed natively where cospec owns -the fact (`validate`'s report keys), otherwise from one delegated call per +OpenSpec command (`status`, `list`, `validate`, `archive`) carries every key +upstream's document does: computed natively where cospec owns the fact +(`validate`'s report keys; `archive`'s `archive`/`root`, read from what the +wrapped human-mode archive reported and what cospec verified on disk, and its +failure documents, one per refusal), otherwise from one delegated call per invocation — never one per change — merged by identity through `mergeUpstream` (`apps/cli/src/core/upstream-keys.ts`). No cospec key is removed and no cospec -value changed; every envelope keeps `version: 1`. The key oracle -(`apps/cli/test/contract/support/key-oracle.ts`, rows in `cli-surface.test.ts`) -is the gate: it fails on a missing upstream key, an upstream value reported -differently, a changed pre-existing cospec key, and any collision outside its -named list (`NAMED_COLLISIONS`: `version`, validate's `items[].type`), each -entry stating why cospec's value wins. +value changed; every envelope that carried `version: 1` keeps it. The key oracle +(`apps/cli/test/contract/support/key-oracle.ts`, rows in `cli-surface.test.ts` +and `archive-no-validate.test.ts`) is the gate: it fails on a missing upstream +key, an upstream value reported differently, a changed pre-existing cospec key, +and any collision outside its named list (`NAMED_COLLISIONS`: `version`, +validate's `items[].type`, and archive's tasks-gate `status[].fix`, where +cospec's `--force-incomplete` wins because `--yes`, a no-op under cospec, does +not lift its stricter gate), each entry stating why cospec's value wins. **Wrapped-call discipline** — every call into the wrapped binary declares its expected exit codes, a stdout deny-list, and an observable post-condition. Trust diff --git a/.agents/skills/cospec-archive-change/SKILL.md b/.agents/skills/cospec-archive-change/SKILL.md index 4f5db3cd..84aac07c 100644 --- a/.agents/skills/cospec-archive-change/SKILL.md +++ b/.agents/skills/cospec-archive-change/SKILL.md @@ -6,7 +6,7 @@ compatibility: Requires the cospec CLI (@aligned-team/cospec). metadata: author: cospec generatedBy: cospec@0.8.3 - contentHash: sha256:5c738047656ddb62b491db73be4646970619cfe5f01aee6779924b5bd8ef3373 + contentHash: sha256:06ced83cc52c3802650079fd3a3c303bdc97302d803b45574762e7f5bd67a1eb --- Archive a completed change. `cospec archive` validates it, merges its spec @@ -29,9 +29,15 @@ Relay the summary it prints verbatim: what was archived, which spec deltas were applied (`+a ~m -r →n`) or skipped, which sibling changes had blocker boxes checked, and which changes are now unblocked. -A change that introduces a brand-new capability (no living spec yet) may only -ADD requirements there — `cospec validate` refuses a MODIFIED, REMOVED, or -RENAMED op targeting it before archive ever runs the merge. +A change that introduces a brand-new capability (no living spec yet) may ADD +requirements there, and a REMOVED there is a no-op the merge warns about; +`cospec validate` refuses a MODIFIED or RENAMED op targeting it before archive +ever runs the merge. + +A change whose specs were synced early with `$cospec-sync-specs (Codex) or /cospec-sync-specs (other agents)` archives as a +no-op merge: the summary says the specs were already in sync, and both hard +gates (`archive/verification-incomplete`, `archive/scenario-preservation`) still +run. ## 3. On failure diff --git a/.agents/skills/cospec-sync-specs/SKILL.md b/.agents/skills/cospec-sync-specs/SKILL.md index f52ffadd..eaea1e58 100644 --- a/.agents/skills/cospec-sync-specs/SKILL.md +++ b/.agents/skills/cospec-sync-specs/SKILL.md @@ -1,28 +1,28 @@ --- name: cospec-sync-specs -description: Explain how spec sync works (it runs inside archive) and preview what would merge. Also use when the user says "cospec sync specs", "sync the specs", or "openspec sync". +description: Merge a change's delta specs into the main specs without archiving it, exactly as archive would. Also use when the user says "cospec sync specs", "sync the specs", or "openspec sync". license: MIT compatibility: Requires the cospec CLI (@aligned-team/cospec). metadata: author: cospec generatedBy: cospec@0.8.3 - contentHash: sha256:1bfa89a12c71041a0dfa9dc59c5007a6cae904ca8a880cb87dbaad91fa4b4814 + contentHash: sha256:629fe8bb821e930ca0b8c1ebb26381f8a60b6bc20453a4691a4df9f5d169e8c7 --- -Explain and preview spec synchronization. Spec sync is not a standalone step in -cospec. +Merge a change's delta specs into the main specs under `openspec/specs/` without +archiving the change. `cospec sync-specs` runs the archive's own merge — the +pinned OpenSpec archive, on a scratch copy of the specs — and copies back only +the main-spec files it changed, so the result is byte-for-byte what +`cospec archive` would write. The change stays active, and its later archive is +a no-op merge. -Delta specs in a change are merged into the living specs under `openspec/specs/` -**only** by `cospec archive`, which applies the merge and then verifies it as -one coupled operation. There is no supported mid-flight "sync now without -archiving" path. This is deliberate: a partial merge would leave a tree that -neither validates nor archives cleanly. +## 1. Select the change -## Preview what would merge +If the user named one, use it. Otherwise run `cospec list --json`: if exactly +one active change exists, use it and announce `Using change: `; if more +than one is plausible, ask. -If the user did not name a change, run `cospec list --json`: if exactly one -active change exists, use it and announce `Using change: `; if more than -one is plausible, ask. +## 2. Preview the merge ``` cospec validate @@ -31,13 +31,26 @@ cospec validate This runs the archive-precondition checks (targets exist, no zero-op deltas, no ADDED collisions, scenarios are well-formed) and reports anything that would make the merge fail. Then read the delta files under -`openspec/changes//specs/**/spec.md` to see the exact ADDED / MODIFIED / -REMOVED / RENAMED operations. +`openspec/changes//specs/**/spec.md` and tell the user which main specs +the sync will create, change or delete: the ADDED / MODIFIED / REMOVED / RENAMED +operations per capability, and any retirement (below) with its marker. -A delta that targets a capability with no living spec yet may only ADD -requirements — any MODIFIED, REMOVED, or RENAMED op there is a validate-time -ERROR (`archive/new-spec-non-added`), not something that surfaces later at merge -time. +A delta that targets a capability with no living spec yet may ADD requirements +there, and a REMOVED there is a no-op the merge warns about; a MODIFIED or +RENAMED op targeting it is a validate-time ERROR (`archive/new-spec-non-added`). + +## 3. Sync + +``` +cospec sync-specs +``` + +Relay what it prints: one `Synced:` line per main-spec file written or deleted +and the merge's totals, or that the specs were already in sync. The merge is the +archive's own, so a refusal here is the refusal archive would give — relay it +verbatim and fix what it names; never edit a main spec by hand to get past it. +Nothing is written when it refuses, and the change is never archived by this +step. ## Retiring a capability @@ -49,8 +62,9 @@ the marker the merge refuses and reports the missing marker as the blocking condition. Deleting the file also deletes its `## Purpose` — name both when you report a retirement, and give the user a way to recover the file. -## Actually sync +## Afterwards -Run `$cospec-archive-change (Codex) or /cospec-archive-change (other agents)` when the change is complete. The merge happens there, is -verified, and blocker check-offs fan out automatically. To sanity-check the -living specs on their own, run `cospec validate --specs`. +The change is still active: finish its tasks and verification, then run +`$cospec-archive-change (Codex) or /cospec-archive-change (other agents)`. Its merge finds the specs already in sync, and both hard +archive gates still run. To sanity-check the living specs on their own, run +`cospec validate --specs`. diff --git a/.claude/commands/cospec/archive.md b/.claude/commands/cospec/archive.md index 47cd6d18..635018a5 100644 --- a/.claude/commands/cospec/archive.md +++ b/.claude/commands/cospec/archive.md @@ -8,7 +8,7 @@ tags: metadata: author: cospec generatedBy: cospec@0.8.3 - contentHash: sha256:d31ab736702e834b863f53218615046ce0d07111014acda12131333653f2a56a + contentHash: sha256:4ef8b0e5e83be85e5cf8f1c811e6947bbaeaec8a6df188fa96609d6a46aa220f --- Archive a completed change. `cospec archive` validates it, merges its spec @@ -31,9 +31,15 @@ Relay the summary it prints verbatim: what was archived, which spec deltas were applied (`+a ~m -r →n`) or skipped, which sibling changes had blocker boxes checked, and which changes are now unblocked. -A change that introduces a brand-new capability (no living spec yet) may only -ADD requirements there — `cospec validate` refuses a MODIFIED, REMOVED, or -RENAMED op targeting it before archive ever runs the merge. +A change that introduces a brand-new capability (no living spec yet) may ADD +requirements there, and a REMOVED there is a no-op the merge warns about; +`cospec validate` refuses a MODIFIED or RENAMED op targeting it before archive +ever runs the merge. + +A change whose specs were synced early with `/cospec:sync-specs` archives as a +no-op merge: the summary says the specs were already in sync, and both hard +gates (`archive/verification-incomplete`, `archive/scenario-preservation`) still +run. ## 3. On failure diff --git a/.claude/commands/cospec/sync-specs.md b/.claude/commands/cospec/sync-specs.md index cd2cf02e..28b8c139 100644 --- a/.claude/commands/cospec/sync-specs.md +++ b/.claude/commands/cospec/sync-specs.md @@ -1,6 +1,6 @@ --- name: "COSPEC: Sync specs" -description: Explain how spec sync works (it runs inside archive) and preview what would merge. Also use when the user says "cospec sync specs", "sync the specs", or "openspec sync". +description: Merge a change's delta specs into the main specs without archiving it, exactly as archive would. Also use when the user says "cospec sync specs", "sync the specs", or "openspec sync". category: Workflow tags: - cospec @@ -8,23 +8,23 @@ tags: metadata: author: cospec generatedBy: cospec@0.8.3 - contentHash: sha256:8a7fceb611f7097e7ba242b56cc99aa60a68727d1afdc9e9137146742657d282 + contentHash: sha256:c6671819783b5458bb4ceb83e6c0ea0716c94d919a1b813d5ef25fef16307d21 --- -Explain and preview spec synchronization. Spec sync is not a standalone step in -cospec. +Merge a change's delta specs into the main specs under `openspec/specs/` without +archiving the change. `cospec sync-specs` runs the archive's own merge — the +pinned OpenSpec archive, on a scratch copy of the specs — and copies back only +the main-spec files it changed, so the result is byte-for-byte what +`cospec archive` would write. The change stays active, and its later archive is +a no-op merge. -Delta specs in a change are merged into the living specs under `openspec/specs/` -**only** by `cospec archive`, which applies the merge and then verifies it as -one coupled operation. There is no supported mid-flight "sync now without -archiving" path. This is deliberate: a partial merge would leave a tree that -neither validates nor archives cleanly. +## 1. Select the change -## Preview what would merge +If the user named one, use it. Otherwise run `cospec list --json`: if exactly +one active change exists, use it and announce `Using change: `; if more +than one is plausible, ask. -If the user did not name a change, run `cospec list --json`: if exactly one -active change exists, use it and announce `Using change: `; if more than -one is plausible, ask. +## 2. Preview the merge ``` cospec validate @@ -33,13 +33,26 @@ cospec validate This runs the archive-precondition checks (targets exist, no zero-op deltas, no ADDED collisions, scenarios are well-formed) and reports anything that would make the merge fail. Then read the delta files under -`openspec/changes//specs/**/spec.md` to see the exact ADDED / MODIFIED / -REMOVED / RENAMED operations. +`openspec/changes//specs/**/spec.md` and tell the user which main specs +the sync will create, change or delete: the ADDED / MODIFIED / REMOVED / RENAMED +operations per capability, and any retirement (below) with its marker. -A delta that targets a capability with no living spec yet may only ADD -requirements — any MODIFIED, REMOVED, or RENAMED op there is a validate-time -ERROR (`archive/new-spec-non-added`), not something that surfaces later at merge -time. +A delta that targets a capability with no living spec yet may ADD requirements +there, and a REMOVED there is a no-op the merge warns about; a MODIFIED or +RENAMED op targeting it is a validate-time ERROR (`archive/new-spec-non-added`). + +## 3. Sync + +``` +cospec sync-specs +``` + +Relay what it prints: one `Synced:` line per main-spec file written or deleted +and the merge's totals, or that the specs were already in sync. The merge is the +archive's own, so a refusal here is the refusal archive would give — relay it +verbatim and fix what it names; never edit a main spec by hand to get past it. +Nothing is written when it refuses, and the change is never archived by this +step. ## Retiring a capability @@ -51,8 +64,9 @@ the marker the merge refuses and reports the missing marker as the blocking condition. Deleting the file also deletes its `## Purpose` — name both when you report a retirement, and give the user a way to recover the file. -## Actually sync +## Afterwards -Run `/cospec:archive` when the change is complete. The merge happens there, is -verified, and blocker check-offs fan out automatically. To sanity-check the -living specs on their own, run `cospec validate --specs`. +The change is still active: finish its tasks and verification, then run +`/cospec:archive`. Its merge finds the specs already in sync, and both hard +archive gates still run. To sanity-check the living specs on their own, run +`cospec validate --specs`. diff --git a/.claude/skills/cospec-archive-change/SKILL.md b/.claude/skills/cospec-archive-change/SKILL.md index 6f93b91b..2e4f3cab 100644 --- a/.claude/skills/cospec-archive-change/SKILL.md +++ b/.claude/skills/cospec-archive-change/SKILL.md @@ -6,7 +6,7 @@ compatibility: Requires the cospec CLI (@aligned-team/cospec). metadata: author: cospec generatedBy: cospec@0.8.3 - contentHash: sha256:d31ab736702e834b863f53218615046ce0d07111014acda12131333653f2a56a + contentHash: sha256:4ef8b0e5e83be85e5cf8f1c811e6947bbaeaec8a6df188fa96609d6a46aa220f --- Archive a completed change. `cospec archive` validates it, merges its spec @@ -29,9 +29,15 @@ Relay the summary it prints verbatim: what was archived, which spec deltas were applied (`+a ~m -r →n`) or skipped, which sibling changes had blocker boxes checked, and which changes are now unblocked. -A change that introduces a brand-new capability (no living spec yet) may only -ADD requirements there — `cospec validate` refuses a MODIFIED, REMOVED, or -RENAMED op targeting it before archive ever runs the merge. +A change that introduces a brand-new capability (no living spec yet) may ADD +requirements there, and a REMOVED there is a no-op the merge warns about; +`cospec validate` refuses a MODIFIED or RENAMED op targeting it before archive +ever runs the merge. + +A change whose specs were synced early with `/cospec:sync-specs` archives as a +no-op merge: the summary says the specs were already in sync, and both hard +gates (`archive/verification-incomplete`, `archive/scenario-preservation`) still +run. ## 3. On failure diff --git a/.claude/skills/cospec-sync-specs/SKILL.md b/.claude/skills/cospec-sync-specs/SKILL.md index 3491202d..b00fbb07 100644 --- a/.claude/skills/cospec-sync-specs/SKILL.md +++ b/.claude/skills/cospec-sync-specs/SKILL.md @@ -1,28 +1,28 @@ --- name: cospec-sync-specs -description: Explain how spec sync works (it runs inside archive) and preview what would merge. Also use when the user says "cospec sync specs", "sync the specs", or "openspec sync". +description: Merge a change's delta specs into the main specs without archiving it, exactly as archive would. Also use when the user says "cospec sync specs", "sync the specs", or "openspec sync". license: MIT compatibility: Requires the cospec CLI (@aligned-team/cospec). metadata: author: cospec generatedBy: cospec@0.8.3 - contentHash: sha256:8a7fceb611f7097e7ba242b56cc99aa60a68727d1afdc9e9137146742657d282 + contentHash: sha256:c6671819783b5458bb4ceb83e6c0ea0716c94d919a1b813d5ef25fef16307d21 --- -Explain and preview spec synchronization. Spec sync is not a standalone step in -cospec. +Merge a change's delta specs into the main specs under `openspec/specs/` without +archiving the change. `cospec sync-specs` runs the archive's own merge — the +pinned OpenSpec archive, on a scratch copy of the specs — and copies back only +the main-spec files it changed, so the result is byte-for-byte what +`cospec archive` would write. The change stays active, and its later archive is +a no-op merge. -Delta specs in a change are merged into the living specs under `openspec/specs/` -**only** by `cospec archive`, which applies the merge and then verifies it as -one coupled operation. There is no supported mid-flight "sync now without -archiving" path. This is deliberate: a partial merge would leave a tree that -neither validates nor archives cleanly. +## 1. Select the change -## Preview what would merge +If the user named one, use it. Otherwise run `cospec list --json`: if exactly +one active change exists, use it and announce `Using change: `; if more +than one is plausible, ask. -If the user did not name a change, run `cospec list --json`: if exactly one -active change exists, use it and announce `Using change: `; if more than -one is plausible, ask. +## 2. Preview the merge ``` cospec validate @@ -31,13 +31,26 @@ cospec validate This runs the archive-precondition checks (targets exist, no zero-op deltas, no ADDED collisions, scenarios are well-formed) and reports anything that would make the merge fail. Then read the delta files under -`openspec/changes//specs/**/spec.md` to see the exact ADDED / MODIFIED / -REMOVED / RENAMED operations. +`openspec/changes//specs/**/spec.md` and tell the user which main specs +the sync will create, change or delete: the ADDED / MODIFIED / REMOVED / RENAMED +operations per capability, and any retirement (below) with its marker. -A delta that targets a capability with no living spec yet may only ADD -requirements — any MODIFIED, REMOVED, or RENAMED op there is a validate-time -ERROR (`archive/new-spec-non-added`), not something that surfaces later at merge -time. +A delta that targets a capability with no living spec yet may ADD requirements +there, and a REMOVED there is a no-op the merge warns about; a MODIFIED or +RENAMED op targeting it is a validate-time ERROR (`archive/new-spec-non-added`). + +## 3. Sync + +``` +cospec sync-specs +``` + +Relay what it prints: one `Synced:` line per main-spec file written or deleted +and the merge's totals, or that the specs were already in sync. The merge is the +archive's own, so a refusal here is the refusal archive would give — relay it +verbatim and fix what it names; never edit a main spec by hand to get past it. +Nothing is written when it refuses, and the change is never archived by this +step. ## Retiring a capability @@ -49,8 +62,9 @@ the marker the merge refuses and reports the missing marker as the blocking condition. Deleting the file also deletes its `## Purpose` — name both when you report a retirement, and give the user a way to recover the file. -## Actually sync +## Afterwards -Run `/cospec:archive` when the change is complete. The merge happens there, is -verified, and blocker check-offs fan out automatically. To sanity-check the -living specs on their own, run `cospec validate --specs`. +The change is still active: finish its tasks and verification, then run +`/cospec:archive`. Its merge finds the specs already in sync, and both hard +archive gates still run. To sanity-check the living specs on their own, run +`cospec validate --specs`. diff --git a/.opencode/commands/cospec-archive.md b/.opencode/commands/cospec-archive.md index 4423dd97..832ea4e8 100644 --- a/.opencode/commands/cospec-archive.md +++ b/.opencode/commands/cospec-archive.md @@ -3,7 +3,7 @@ description: Archive a completed change — validate, merge specs, verify, and f metadata: author: cospec generatedBy: cospec@0.8.3 - contentHash: sha256:70ef3ee289bf010b42e94bca2c2274d276842d5018fc6c9a199547679a317da6 + contentHash: sha256:52b0c6d92cb08508c8c44746052fffaa4ed31bb322c485737619cc3971a98794 --- Archive a completed change. `cospec archive` validates it, merges its spec @@ -28,9 +28,15 @@ Relay the summary it prints verbatim: what was archived, which spec deltas were applied (`+a ~m -r →n`) or skipped, which sibling changes had blocker boxes checked, and which changes are now unblocked. -A change that introduces a brand-new capability (no living spec yet) may only -ADD requirements there — `cospec validate` refuses a MODIFIED, REMOVED, or -RENAMED op targeting it before archive ever runs the merge. +A change that introduces a brand-new capability (no living spec yet) may ADD +requirements there, and a REMOVED there is a no-op the merge warns about; +`cospec validate` refuses a MODIFIED or RENAMED op targeting it before archive +ever runs the merge. + +A change whose specs were synced early with `/cospec-sync-specs` archives as a +no-op merge: the summary says the specs were already in sync, and both hard +gates (`archive/verification-incomplete`, `archive/scenario-preservation`) still +run. ## 3. On failure diff --git a/.opencode/commands/cospec-sync-specs.md b/.opencode/commands/cospec-sync-specs.md index 5bccc9b6..72b6ae97 100644 --- a/.opencode/commands/cospec-sync-specs.md +++ b/.opencode/commands/cospec-sync-specs.md @@ -1,27 +1,27 @@ --- -description: Explain how spec sync works (it runs inside archive) and preview what would merge. Also use when the user says "cospec sync specs", "sync the specs", or "openspec sync". +description: Merge a change's delta specs into the main specs without archiving it, exactly as archive would. Also use when the user says "cospec sync specs", "sync the specs", or "openspec sync". metadata: author: cospec generatedBy: cospec@0.8.3 - contentHash: sha256:78a4d09275959566ff92a490de91a93a695dd0acdbc259620b3c4156c61ba16c + contentHash: sha256:a64fe2fd251a64fb752391a0bc7298ca49edde939b1442bfedc3d0e6e5f33992 --- -Explain and preview spec synchronization. Spec sync is not a standalone step in -cospec. - -Delta specs in a change are merged into the living specs under `openspec/specs/` -**only** by `cospec archive`, which applies the merge and then verifies it as -one coupled operation. There is no supported mid-flight "sync now without -archiving" path. This is deliberate: a partial merge would leave a tree that -neither validates nor archives cleanly. +Merge a change's delta specs into the main specs under `openspec/specs/` without +archiving the change. `cospec sync-specs` runs the archive's own merge — the +pinned OpenSpec archive, on a scratch copy of the specs — and copies back only +the main-spec files it changed, so the result is byte-for-byte what +`cospec archive` would write. The change stays active, and its later archive is +a no-op merge. **Provided arguments**: $ARGUMENTS -## Preview what would merge +## 1. Select the change + +If the user named one, use it. Otherwise run `cospec list --json`: if exactly +one active change exists, use it and announce `Using change: `; if more +than one is plausible, ask. -If the user did not name a change, run `cospec list --json`: if exactly one -active change exists, use it and announce `Using change: `; if more than -one is plausible, ask. +## 2. Preview the merge ``` cospec validate @@ -30,13 +30,26 @@ cospec validate This runs the archive-precondition checks (targets exist, no zero-op deltas, no ADDED collisions, scenarios are well-formed) and reports anything that would make the merge fail. Then read the delta files under -`openspec/changes//specs/**/spec.md` to see the exact ADDED / MODIFIED / -REMOVED / RENAMED operations. +`openspec/changes//specs/**/spec.md` and tell the user which main specs +the sync will create, change or delete: the ADDED / MODIFIED / REMOVED / RENAMED +operations per capability, and any retirement (below) with its marker. + +A delta that targets a capability with no living spec yet may ADD requirements +there, and a REMOVED there is a no-op the merge warns about; a MODIFIED or +RENAMED op targeting it is a validate-time ERROR (`archive/new-spec-non-added`). + +## 3. Sync + +``` +cospec sync-specs +``` -A delta that targets a capability with no living spec yet may only ADD -requirements — any MODIFIED, REMOVED, or RENAMED op there is a validate-time -ERROR (`archive/new-spec-non-added`), not something that surfaces later at merge -time. +Relay what it prints: one `Synced:` line per main-spec file written or deleted +and the merge's totals, or that the specs were already in sync. The merge is the +archive's own, so a refusal here is the refusal archive would give — relay it +verbatim and fix what it names; never edit a main spec by hand to get past it. +Nothing is written when it refuses, and the change is never archived by this +step. ## Retiring a capability @@ -48,8 +61,9 @@ the marker the merge refuses and reports the missing marker as the blocking condition. Deleting the file also deletes its `## Purpose` — name both when you report a retirement, and give the user a way to recover the file. -## Actually sync +## Afterwards -Run `/cospec-archive` when the change is complete. The merge happens there, is -verified, and blocker check-offs fan out automatically. To sanity-check the -living specs on their own, run `cospec validate --specs`. +The change is still active: finish its tasks and verification, then run +`/cospec-archive`. Its merge finds the specs already in sync, and both hard +archive gates still run. To sanity-check the living specs on their own, run +`cospec validate --specs`. diff --git a/.opencode/skills/cospec-archive-change/SKILL.md b/.opencode/skills/cospec-archive-change/SKILL.md index 969e83d1..191aa29f 100644 --- a/.opencode/skills/cospec-archive-change/SKILL.md +++ b/.opencode/skills/cospec-archive-change/SKILL.md @@ -6,7 +6,7 @@ compatibility: Requires the cospec CLI (@aligned-team/cospec). metadata: author: cospec generatedBy: cospec@0.8.3 - contentHash: sha256:4b9b6b08eb117becabf1d8f885fed7169b1712f092ea8d8653e2cb82220510e8 + contentHash: sha256:1f0e3bc2a8cf332628b0a74650bb73691913e94445ae43074167315b946548f6 --- Archive a completed change. `cospec archive` validates it, merges its spec @@ -29,9 +29,15 @@ Relay the summary it prints verbatim: what was archived, which spec deltas were applied (`+a ~m -r →n`) or skipped, which sibling changes had blocker boxes checked, and which changes are now unblocked. -A change that introduces a brand-new capability (no living spec yet) may only -ADD requirements there — `cospec validate` refuses a MODIFIED, REMOVED, or -RENAMED op targeting it before archive ever runs the merge. +A change that introduces a brand-new capability (no living spec yet) may ADD +requirements there, and a REMOVED there is a no-op the merge warns about; +`cospec validate` refuses a MODIFIED or RENAMED op targeting it before archive +ever runs the merge. + +A change whose specs were synced early with `/cospec-sync-specs` archives as a +no-op merge: the summary says the specs were already in sync, and both hard +gates (`archive/verification-incomplete`, `archive/scenario-preservation`) still +run. ## 3. On failure diff --git a/.opencode/skills/cospec-sync-specs/SKILL.md b/.opencode/skills/cospec-sync-specs/SKILL.md index 8e8a0f8c..071559e3 100644 --- a/.opencode/skills/cospec-sync-specs/SKILL.md +++ b/.opencode/skills/cospec-sync-specs/SKILL.md @@ -1,28 +1,28 @@ --- name: cospec-sync-specs -description: Explain how spec sync works (it runs inside archive) and preview what would merge. Also use when the user says "cospec sync specs", "sync the specs", or "openspec sync". +description: Merge a change's delta specs into the main specs without archiving it, exactly as archive would. Also use when the user says "cospec sync specs", "sync the specs", or "openspec sync". license: MIT compatibility: Requires the cospec CLI (@aligned-team/cospec). metadata: author: cospec generatedBy: cospec@0.8.3 - contentHash: sha256:dc48d3f5037277912711548e57c0feab65c5a8c64c32bf6070334632a9dd60d0 + contentHash: sha256:6557ec661d62b69c5956fe188cb2b2cd37639bfceb3fb7b6bb2330c0db9321ed --- -Explain and preview spec synchronization. Spec sync is not a standalone step in -cospec. +Merge a change's delta specs into the main specs under `openspec/specs/` without +archiving the change. `cospec sync-specs` runs the archive's own merge — the +pinned OpenSpec archive, on a scratch copy of the specs — and copies back only +the main-spec files it changed, so the result is byte-for-byte what +`cospec archive` would write. The change stays active, and its later archive is +a no-op merge. -Delta specs in a change are merged into the living specs under `openspec/specs/` -**only** by `cospec archive`, which applies the merge and then verifies it as -one coupled operation. There is no supported mid-flight "sync now without -archiving" path. This is deliberate: a partial merge would leave a tree that -neither validates nor archives cleanly. +## 1. Select the change -## Preview what would merge +If the user named one, use it. Otherwise run `cospec list --json`: if exactly +one active change exists, use it and announce `Using change: `; if more +than one is plausible, ask. -If the user did not name a change, run `cospec list --json`: if exactly one -active change exists, use it and announce `Using change: `; if more than -one is plausible, ask. +## 2. Preview the merge ``` cospec validate @@ -31,13 +31,26 @@ cospec validate This runs the archive-precondition checks (targets exist, no zero-op deltas, no ADDED collisions, scenarios are well-formed) and reports anything that would make the merge fail. Then read the delta files under -`openspec/changes//specs/**/spec.md` to see the exact ADDED / MODIFIED / -REMOVED / RENAMED operations. +`openspec/changes//specs/**/spec.md` and tell the user which main specs +the sync will create, change or delete: the ADDED / MODIFIED / REMOVED / RENAMED +operations per capability, and any retirement (below) with its marker. -A delta that targets a capability with no living spec yet may only ADD -requirements — any MODIFIED, REMOVED, or RENAMED op there is a validate-time -ERROR (`archive/new-spec-non-added`), not something that surfaces later at merge -time. +A delta that targets a capability with no living spec yet may ADD requirements +there, and a REMOVED there is a no-op the merge warns about; a MODIFIED or +RENAMED op targeting it is a validate-time ERROR (`archive/new-spec-non-added`). + +## 3. Sync + +``` +cospec sync-specs +``` + +Relay what it prints: one `Synced:` line per main-spec file written or deleted +and the merge's totals, or that the specs were already in sync. The merge is the +archive's own, so a refusal here is the refusal archive would give — relay it +verbatim and fix what it names; never edit a main spec by hand to get past it. +Nothing is written when it refuses, and the change is never archived by this +step. ## Retiring a capability @@ -49,8 +62,9 @@ the marker the merge refuses and reports the missing marker as the blocking condition. Deleting the file also deletes its `## Purpose` — name both when you report a retirement, and give the user a way to recover the file. -## Actually sync +## Afterwards -Run `/cospec-archive` when the change is complete. The merge happens there, is -verified, and blocker check-offs fan out automatically. To sanity-check the -living specs on their own, run `cospec validate --specs`. +The change is still active: finish its tasks and verification, then run +`/cospec-archive`. Its merge finds the specs already in sync, and both hard +archive gates still run. To sanity-check the living specs on their own, run +`cospec validate --specs`. diff --git a/AGENTS.md b/AGENTS.md index 8dce6d2b..6d861cc6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -130,8 +130,11 @@ through `cospec`: `archive/scenario-preservation` — no `--force`), delegates to `openspec archive`, verifies the move on disk, and fans out blocker sync. Schemas with no specs artifact (`ci`, `chore`, `docs`, …) correctly produce - no spec-sync deltas here. **Timing is non-negotiable** — see "Branch, PR, and - merge flow" below. + no spec-sync deltas here. To land a change's main specs before it is done, + `mise run cospec -- sync-specs ` runs archive's own merge on a scratch + copy and leaves the change active; its later archive is a no-op merge with + both hard gates still run. **Timing is non-negotiable** — see "Branch, PR, + and merge flow" below. Steps 1–2 also have `/cospec:new` + `/cospec:ff`/`/cospec:continue` as entry-point variants of `/cospec:propose`, and you can optionally dress-rehearse @@ -244,17 +247,21 @@ fixture proving it reads the view it is registered under. A new rule or gate takes the verbatim view unless no commented line can ever trigger it. **JSON documents are additive** — a cospec `--json` document that mirrors an -OpenSpec command (`status`, `list`, `validate`, and `archive --json` next) -carries every key upstream's document does: computed natively where cospec owns -the fact (`validate`'s report keys), otherwise from one delegated call per +OpenSpec command (`status`, `list`, `validate`, `archive`) carries every key +upstream's document does: computed natively where cospec owns the fact +(`validate`'s report keys; `archive`'s `archive`/`root`, read from what the +wrapped human-mode archive reported and what cospec verified on disk, and its +failure documents, one per refusal), otherwise from one delegated call per invocation — never one per change — merged by identity through `mergeUpstream` (`apps/cli/src/core/upstream-keys.ts`). No cospec key is removed and no cospec -value changed; every envelope keeps `version: 1`. The key oracle -(`apps/cli/test/contract/support/key-oracle.ts`, rows in `cli-surface.test.ts`) -is the gate: it fails on a missing upstream key, an upstream value reported -differently, a changed pre-existing cospec key, and any collision outside its -named list (`NAMED_COLLISIONS`: `version`, validate's `items[].type`), each -entry stating why cospec's value wins. +value changed; every envelope that carried `version: 1` keeps it. The key oracle +(`apps/cli/test/contract/support/key-oracle.ts`, rows in `cli-surface.test.ts` +and `archive-no-validate.test.ts`) is the gate: it fails on a missing upstream +key, an upstream value reported differently, a changed pre-existing cospec key, +and any collision outside its named list (`NAMED_COLLISIONS`: `version`, +validate's `items[].type`, and archive's tasks-gate `status[].fix`, where +cospec's `--force-incomplete` wins because `--yes`, a no-op under cospec, does +not lift its stricter gate), each entry stating why cospec's value wins. **Wrapped-call discipline** — every call into the wrapped binary declares its expected exit codes, a stdout deny-list, and an observable post-condition. Trust diff --git a/CLAUDE.md b/CLAUDE.md index 1fb4d0af..81fe6cba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -126,8 +126,11 @@ through `cospec`: `archive/scenario-preservation` — no `--force`), delegates to `openspec archive`, verifies the move on disk, and fans out blocker sync. Schemas with no specs artifact (`ci`, `chore`, `docs`, …) correctly produce - no spec-sync deltas here. **Timing is non-negotiable** — see "Branch, PR, and - merge flow" below. + no spec-sync deltas here. To land a change's main specs before it is done, + `mise run cospec -- sync-specs ` runs archive's own merge on a scratch + copy and leaves the change active; its later archive is a no-op merge with + both hard gates still run. **Timing is non-negotiable** — see "Branch, PR, + and merge flow" below. Steps 1–2 also have `/cospec:new` + `/cospec:ff`/`/cospec:continue` as entry-point variants of `/cospec:propose`, and you can optionally dress-rehearse @@ -240,17 +243,21 @@ fixture proving it reads the view it is registered under. A new rule or gate takes the verbatim view unless no commented line can ever trigger it. **JSON documents are additive** — a cospec `--json` document that mirrors an -OpenSpec command (`status`, `list`, `validate`, and `archive --json` next) -carries every key upstream's document does: computed natively where cospec owns -the fact (`validate`'s report keys), otherwise from one delegated call per +OpenSpec command (`status`, `list`, `validate`, `archive`) carries every key +upstream's document does: computed natively where cospec owns the fact +(`validate`'s report keys; `archive`'s `archive`/`root`, read from what the +wrapped human-mode archive reported and what cospec verified on disk, and its +failure documents, one per refusal), otherwise from one delegated call per invocation — never one per change — merged by identity through `mergeUpstream` (`apps/cli/src/core/upstream-keys.ts`). No cospec key is removed and no cospec -value changed; every envelope keeps `version: 1`. The key oracle -(`apps/cli/test/contract/support/key-oracle.ts`, rows in `cli-surface.test.ts`) -is the gate: it fails on a missing upstream key, an upstream value reported -differently, a changed pre-existing cospec key, and any collision outside its -named list (`NAMED_COLLISIONS`: `version`, validate's `items[].type`), each -entry stating why cospec's value wins. +value changed; every envelope that carried `version: 1` keeps it. The key oracle +(`apps/cli/test/contract/support/key-oracle.ts`, rows in `cli-surface.test.ts` +and `archive-no-validate.test.ts`) is the gate: it fails on a missing upstream +key, an upstream value reported differently, a changed pre-existing cospec key, +and any collision outside its named list (`NAMED_COLLISIONS`: `version`, +validate's `items[].type`, and archive's tasks-gate `status[].fix`, where +cospec's `--force-incomplete` wins because `--yes`, a no-op under cospec, does +not lift its stricter gate), each entry stating why cospec's value wins. **Wrapped-call discipline** — every call into the wrapped binary declares its expected exit codes, a stdout deny-list, and an observable post-condition. Trust diff --git a/apps/cli/src/canon/workflows/archive.md b/apps/cli/src/canon/workflows/archive.md index 2cbad9d4..77a04fb0 100644 --- a/apps/cli/src/canon/workflows/archive.md +++ b/apps/cli/src/canon/workflows/archive.md @@ -18,9 +18,15 @@ Relay the summary it prints verbatim: what was archived, which spec deltas were applied (`+a ~m -r →n`) or skipped, which sibling changes had blocker boxes checked, and which changes are now unblocked. -A change that introduces a brand-new capability (no living spec yet) may only -ADD requirements there — `cospec validate` refuses a MODIFIED, REMOVED, or -RENAMED op targeting it before archive ever runs the merge. +A change that introduces a brand-new capability (no living spec yet) may ADD +requirements there, and a REMOVED there is a no-op the merge warns about; +`cospec validate` refuses a MODIFIED or RENAMED op targeting it before archive +ever runs the merge. + +A change whose specs were synced early with `/cospec:sync-specs` archives as a +no-op merge: the summary says the specs were already in sync, and both hard +gates (`archive/verification-incomplete`, `archive/scenario-preservation`) still +run. ## 3. On failure diff --git a/apps/cli/src/canon/workflows/harness.yaml b/apps/cli/src/canon/workflows/harness.yaml index 37346178..d468096a 100644 --- a/apps/cli/src/canon/workflows/harness.yaml +++ b/apps/cli/src/canon/workflows/harness.yaml @@ -95,7 +95,7 @@ workflows: skill: cospec-sync-specs title: Sync specs description: >- - Explain how spec sync works (it runs inside archive) and preview what would merge. + Merge a change's delta specs into the main specs without archiving it, exactly as archive would. Also use when the user says "cospec sync specs", "sync the specs", or "openspec sync". takesArguments: true - id: explore diff --git a/apps/cli/src/canon/workflows/sync-specs.md b/apps/cli/src/canon/workflows/sync-specs.md index a1c17b15..6d0e022c 100644 --- a/apps/cli/src/canon/workflows/sync-specs.md +++ b/apps/cli/src/canon/workflows/sync-specs.md @@ -1,17 +1,17 @@ -Explain and preview spec synchronization. Spec sync is not a standalone step in -cospec. +Merge a change's delta specs into the main specs under `openspec/specs/` without +archiving the change. `cospec sync-specs` runs the archive's own merge — the +pinned OpenSpec archive, on a scratch copy of the specs — and copies back only +the main-spec files it changed, so the result is byte-for-byte what +`cospec archive` would write. The change stays active, and its later archive is +a no-op merge. -Delta specs in a change are merged into the living specs under `openspec/specs/` -**only** by `cospec archive`, which applies the merge and then verifies it as -one coupled operation. There is no supported mid-flight "sync now without -archiving" path. This is deliberate: a partial merge would leave a tree that -neither validates nor archives cleanly. +## 1. Select the change -## Preview what would merge +If the user named one, use it. Otherwise run `cospec list --json`: if exactly +one active change exists, use it and announce `Using change: `; if more +than one is plausible, ask. -If the user did not name a change, run `cospec list --json`: if exactly one -active change exists, use it and announce `Using change: `; if more than -one is plausible, ask. +## 2. Preview the merge ``` cospec validate @@ -20,13 +20,26 @@ cospec validate This runs the archive-precondition checks (targets exist, no zero-op deltas, no ADDED collisions, scenarios are well-formed) and reports anything that would make the merge fail. Then read the delta files under -`openspec/changes//specs/**/spec.md` to see the exact ADDED / MODIFIED / -REMOVED / RENAMED operations. +`openspec/changes//specs/**/spec.md` and tell the user which main specs +the sync will create, change or delete: the ADDED / MODIFIED / REMOVED / RENAMED +operations per capability, and any retirement (below) with its marker. -A delta that targets a capability with no living spec yet may only ADD -requirements — any MODIFIED, REMOVED, or RENAMED op there is a validate-time -ERROR (`archive/new-spec-non-added`), not something that surfaces later at merge -time. +A delta that targets a capability with no living spec yet may ADD requirements +there, and a REMOVED there is a no-op the merge warns about; a MODIFIED or +RENAMED op targeting it is a validate-time ERROR (`archive/new-spec-non-added`). + +## 3. Sync + +``` +cospec sync-specs +``` + +Relay what it prints: one `Synced:` line per main-spec file written or deleted +and the merge's totals, or that the specs were already in sync. The merge is the +archive's own, so a refusal here is the refusal archive would give — relay it +verbatim and fix what it names; never edit a main spec by hand to get past it. +Nothing is written when it refuses, and the change is never archived by this +step. ## Retiring a capability @@ -38,8 +51,9 @@ the marker the merge refuses and reports the missing marker as the blocking condition. Deleting the file also deletes its `## Purpose` — name both when you report a retirement, and give the user a way to recover the file. -## Actually sync +## Afterwards -Run `/cospec:archive` when the change is complete. The merge happens there, is -verified, and blocker check-offs fan out automatically. To sanity-check the -living specs on their own, run `cospec validate --specs`. +The change is still active: finish its tasks and verification, then run +`/cospec:archive`. Its merge finds the specs already in sync, and both hard +archive gates still run. To sanity-check the living specs on their own, run +`cospec validate --specs`. diff --git a/apps/cli/src/cli.ts b/apps/cli/src/cli.ts index b6170bf5..962015c2 100644 --- a/apps/cli/src/cli.ts +++ b/apps/cli/src/cli.ts @@ -98,6 +98,7 @@ export const COMMAND_MODULES: Record Promise import('./commands/instructions.ts'), apply: () => import('./commands/apply.ts'), archive: () => import('./commands/archive.ts'), + 'sync-specs': () => import('./commands/sync-specs.ts'), 'sync-blockers': () => import('./commands/sync-blockers.ts'), store: () => import('./commands/store.ts'), context: () => import('./commands/context.ts'), diff --git a/apps/cli/src/commands/archive.ts b/apps/cli/src/commands/archive.ts index 159eb1d7..937b235c 100644 --- a/apps/cli/src/commands/archive.ts +++ b/apps/cli/src/commands/archive.ts @@ -7,15 +7,37 @@ // merge, relays the wrapped binary's non-blocking warnings, then fans blocker // check-offs out across sibling changes and prints the flywheel summary. -import { existsSync, readdirSync, readFileSync } from 'node:fs' -import { join, relative } from 'node:path' +import { createHash } from 'node:crypto' +import { + existsSync, + lstatSync, + readdirSync, + readFileSync, + realpathSync, + statSync, + type Dirent, +} from 'node:fs' +import { join } from 'node:path' import type { CommandContext } from '../cli.ts' import { EXIT } from '../cli.ts' +import { + changeNameProblem, + diagnostics, + failureDocument, + readArchiveSummary, + relayedReason, + type ArchiveDiagnostic, + type ArchiveRefusalReason, +} from '../core/archive-output.ts' import { parseBlockers, syncBlockers } from '../core/blockers.ts' import { archiveDir, + changesDir, + describeNestedChange, + findNestedChangesIn, isCospecType, + listChangeDirs, listChanges, openspecDir, resolveChange, @@ -23,24 +45,24 @@ import { type Change, } from '../core/change.ts' import { hasFlag } from '../core/command-table.ts' -import { - findScenarioDrops, - parseDeltaSpec, - parseLivingSpec, - quoteScenarioNames, - SCENARIO_DROP_HINT, - SCENARIO_DROP_NOTE_RETIRED, - type DeltaOp, -} from '../core/deltas.ts' +import { parseLivingSpec, type DeltaOp } from '../core/deltas.ts' +import { assertPathWithin } from '../core/glob.ts' import { spawnOpenspec, threadedArgv } from '../core/openspec.ts' -import { renderHuman, renderJson, type ItemReport } from '../core/report.ts' +import { respellRemedies } from '../core/remedies.ts' +import { renderHuman, renderJson } from '../core/report.ts' import { resolveRoot } from '../core/root.ts' import { enforcedApplyRequires, TYPE_ARTIFACTS, type CospecType } from '../core/rules/type-facts.ts' -import { capabilityForDeltaFile, isDeltaSpecFile } from '../core/spec-paths.ts' +import { + changeDeltaOps, + scenarioGate, + scenarioRefusal, + type CapabilityDeltas, +} from '../core/scenario-gate.ts' import { parseTasks } from '../core/tasks.ts' +import { rootOutput } from '../core/upstream-keys.ts' import { computeVerificationVerdict, parseVerification } from '../core/verification.ts' import { archiveMap, atomicWrite, closest, computeGate } from './apply.ts' -import { buildValidateContext, validateChange } from './validate.ts' +import { readValidateContext, validateChange } from './validate.ts' const ABORTED_RE = /\bAborted\b/ const CANCELLED_RE = /\bArchive cancelled\b/ @@ -106,57 +128,6 @@ export function collectArchiveWarnings(stdout: string): string[] { return warnings } -interface CapabilityDeltas { - capability: string - ops: DeltaOp[] -} - -/** - * All change-side delta ops grouped by capability path - * (`specs//spec.md`). - * - * Only files literally named `spec.md` count, matching openspec's own change - * parser and `discoverSpecFiles` on the living side. Companion markdown an - * author keeps in a capability directory (`README.md`, `notes.md`, a - * `spec-old.md` backup) is content `openspec archive` never merges, so parsing - * it here would feed phantom ops to both hard gates below. - * - * The capability is the whole directory chain under `specs/`, so a nested - * `specs/platform/session-layout/spec.md` groups under `platform/session-layout` - * — the path openspec merges it to (`findSpecUpdates`, 1.6.0 #1353) and the path - * every living-spec lookup below joins. Keying on the outermost directory - * instead, as this did, pointed both hard archive gates at - * `openspec/specs/platform/spec.md`, which does not exist, silently turning them - * into no-ops for every nested spec. - * - * A `.md` sitting directly in `specs/` has no capability at all; openspec 1.7.0 - * blocks that layout outright, so it contributes no ops rather than inventing a - * capability named after the file. - */ -function changeDeltaOps(changeDir: string): CapabilityDeltas[] { - const root = join(changeDir, 'specs') - if (!existsSync(root)) return [] - const byCap = new Map() - const walk = (dir: string): void => { - for (const entry of readdirSync(dir, { withFileTypes: true })) { - const child = join(dir, entry.name) - if (entry.isDirectory()) { - if (!entry.name.startsWith('.')) walk(child) - continue - } - if (!entry.isFile() || !isDeltaSpecFile(entry.name)) continue - const capability = capabilityForDeltaFile(relative(changeDir, child)) - if (capability === undefined) continue - const parsed = parseDeltaSpec(readFileSync(child, 'utf8'), child, capability) - const list = byCap.get(parsed.capability) ?? [] - list.push(...parsed.ops) - byCap.set(parsed.capability, list) - } - } - walk(root) - return [...byCap.entries()].map(([capability, ops]) => ({ capability, ops })) -} - /** * Today's date in the process's local time zone, matching openspec's own * `formatLocalDate` (`src/utils/date.ts`). `toISOString()` is UTC, so from any @@ -193,17 +164,112 @@ export function isArchiveTargetFor(changeId: string, dirName: string): boolean { return new RegExp(`^\\d{4}-\\d{2}-\\d{2}-${escapeRegExp(changeId)}$`).test(dirName) } +/** + * The directories under `dir`. A directory that cannot be read lists nothing: + * the slot check has already answered for one the binary could not use, and + * an unreadable one it could use is reported by step 9 as what it saw. + */ function basenames(dir: string): string[] { - if (!existsSync(dir)) return [] - return readdirSync(dir, { withFileTypes: true }) - .filter((e) => e.isDirectory()) - .map((e) => e.name) + let entries: Dirent[] + try { + entries = readdirSync(dir, { withFileTypes: true }) + } catch (error) { + if (isErrno(error)) return [] + throw error + } + return entries.filter((e) => e.isDirectory()).map((e) => e.name) +} + +/** Whether `path` exists (`lstat`), or the errno message when it cannot be told. */ +function slotTaken(path: string): boolean | string { + try { + lstatSync(path) + return true + } catch (error) { + if (!isErrno(error)) throw error + return error.code === 'ENOENT' ? false : error.message + } +} + +function isErrno(error: unknown): error is NodeJS.ErrnoException { + return typeof (error as NodeJS.ErrnoException | undefined)?.code === 'string' +} + +/** + * The binary's first step (`ArchiveCommand.run`): each managed directory must + * resolve inside its parent, through the runtime's `realpath`. A directory the + * runtime cannot canonicalize (macOS's `realpath` on a mode-000 directory) + * fails it, as it fails the binary's. + */ +function managedDirOutsideRoot(base: string): string | undefined { + const changes = changesDir(base) + const pairs: [string, string][] = [ + [base, changes], + [changes, archiveDir(base)], + [base, join(openspecDir(base), 'specs')], + ] + for (const [allowed, managed] of pairs) { + try { + assertPathWithin(allowed, managed) + } catch (error) { + if (!(error instanceof Error)) throw error + return managed + } + } + return undefined } function escapeRegExp(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } +const NO_VALIDATE_BANNER = + "cospec archive: --no-validate skips revalidation, cospec's and the binary's; still running: " + + 'the namespace-folder check, the tasks gate, archive/verification-incomplete, the archive-slot ' + + 'check, archive/scenario-preservation and the on-disk verification.\n' + +/** Why no spec sync ran (`specsSkipReason`). */ +type SpecsSkipReason = 'flag' | 'schema' | 'no-deltas' + +const SKIP_LINES: Record string> = { + flag: () => 'skipped (--skip-specs)', + schema: (schema) => `none (the ${schema} schema has no specs artifact)`, + 'no-deltas': () => 'none (no delta specs, so no spec sync)', +} + +/** + * sha256 of each living `spec.md` the merge can write or delete — one per + * delta capability, by path — or `absent`. Nothing else under `specs/` is + * read, as the binary's archive reads nothing else: an unrelated spec no one + * can read must not fail an archive the binary completes. One this command + * cannot read is fingerprinted by its metadata, so the binary gives the + * answer it gives for it. + */ +function specFingerprint(base: string, caps: readonly CapabilityDeltas[]): Map { + const out = new Map() + for (const { capability } of caps) { + const path = join(openspecDir(base), 'specs', ...capability.split('/'), 'spec.md') + out.set(path, fileFingerprint(path)) + } + return out +} + +function fileFingerprint(path: string): string { + try { + return createHash('sha256').update(readFileSync(path)).digest('hex') + } catch (error) { + const code = (error as NodeJS.ErrnoException | undefined)?.code + if (code === 'ENOENT') return 'absent' + if (code !== 'EACCES' && code !== 'EPERM') throw error + const stat = statSync(path) + return `unreadable:${stat.mode}:${stat.size}:${stat.mtimeMs}` + } +} + +function sameFingerprint(a: ReadonlyMap, b: ReadonlyMap): boolean { + return a.size === b.size && [...a].every(([path, hash]) => b.get(path) === hash) +} + interface OpCounts { added: number modified: number @@ -223,12 +289,11 @@ function countOps(caps: CapabilityDeltas[]): OpCounts { return c } -function printReport(report: ItemReport, ctx: CommandContext): void { - const out = ctx.flags.json - ? renderJson([report]) - : renderHuman([report], { noColor: ctx.flags.noColor, title: 'cospec archive' }) - process.stdout.write(out) -} +/** + * The `--json` payload a root-selection failure prints ahead of `status`, as + * the binary's `printJsonFailure(undefined, …)` does. + */ +export const jsonFailurePayload = { archive: null } as const /** * What the capability's other operations do to a name, which is what makes the @@ -291,10 +356,51 @@ export async function run(ctx: CommandContext): Promise { const parsed = ctx.parsed! const userSkipSpecs = hasFlag(parsed, '--skip-specs') const forceIncomplete = hasFlag(parsed, '--force-incomplete') + const noValidate = hasFlag(parsed, '--no-validate') // Required in the table: the parser has refused a missing one. const name = parsed.positionals[0]! + // Every refusal below answers `--json` with exactly one document; text mode + // keeps the prose each path writes to stderr. + let type: string | undefined + const refuse = ( + reason: ArchiveRefusalReason, + diagnostic: ArchiveDiagnostic, + extra?: Readonly>, + moved = false, + ): number => { + if (flags.json) + process.stdout.write( + failureDocument({ + change: name, + ...(type === undefined ? {} : { type }), + reason, + diagnostic, + root, + moved, + ...(extra === undefined ? {} : { extra }), + }), + ) + return EXIT.failure + } + + // stderr, so `--json`'s stdout stays the one document. + if (noValidate) process.stderr.write(NO_VALIDATE_BANNER) + + // Step 0: the binary's root confinement, before anything is read. + const outside = managedDirOutsideRoot(base) + if (outside !== undefined) { + const diag = diagnostics.pathOutsideRoot(outside) + process.stderr.write(`cospec archive: ${diag.message}\n`) + return refuse('archive-unreadable', diag) + } + // Step 1: resolve change + schema (legacy still archives; step 2 delegates). + const nameProblem = changeNameProblem(name) + if (nameProblem !== undefined) { + process.stderr.write(`cospec archive: ${nameProblem}\n`) + return refuse('invalid-name', diagnostics.invalidName(nameProblem)) + } const change = resolveChange(base, name) if (change === undefined) { process.stderr.write(`cospec archive: unknown change '${name}'\n`) @@ -303,8 +409,31 @@ export async function run(ctx: CommandContext): Promise { listChanges(base).map((c) => c.id), ) if (suggestion !== undefined) process.stderr.write(`Did you mean '${suggestion}'?\n`) - return EXIT.failure + return refuse( + 'unknown-change', + diagnostics.notFound( + name, + listChangeDirs(base).map((c) => c.id), + ), + ) + } + type = change.schema + + // A namespace folder is refused before anything reads it as a change, as + // the binary refuses it: archiving it would move the nested changes away + // unapplied. + const nested = findNestedChangesIn(changesDir(base), change.id) + if (nested !== undefined) { + const diag = diagnostics.namespaceFolder( + change.id, + describeNestedChange(nested), + nested.nested[0]!, + 'archive', + ) + process.stderr.write(`cospec archive: ${diag.message}\n${diag.fix!}\n`) + return refuse('namespace-folder', diag) } + const resolution = resolveSchema(base, change.schema) // Step 6 (decided early — needed for validation scope + snapshot): skip specs @@ -316,14 +445,35 @@ export async function run(ctx: CommandContext): Promise { ? TYPE_ARTIFACTS[change.schema as keyof typeof TYPE_ARTIFACTS].declared.includes('specs') : false const preOps = changeDeltaOps(change.dir) - const skipSpecs = userSkipSpecs || !declaresSpecs || preOps.length === 0 - - // Step 2: full validation (archive-precondition family unless skipping specs). - const vctx = buildValidateContext(base) - const report = await validateChange(root, change, vctx, { strict: false, fast: skipSpecs }) - if (!report.valid) { - printReport(report, ctx) - return EXIT.failure + const skipReason: SpecsSkipReason | undefined = userSkipSpecs + ? 'flag' + : !declaresSpecs + ? 'schema' + : preOps.length === 0 + ? 'no-deltas' + : undefined + const skipSpecs = skipReason !== undefined + + // Step 2: full validation (archive-precondition family unless skipping specs), + // unless `--no-validate` asked to skip it — it is forwarded below, so the + // binary skips its own as an `openspec` user asked. + // An archive directory that cannot be read is read as empty, with a + // warning: the slot check below answers for it as the binary does. + const { ctx: vctx, warning } = readValidateContext(base) + if (warning !== undefined) process.stderr.write(`Warning: ${warning.message}\n`) + const report = noValidate + ? undefined + : await validateChange(root, change, vctx, { strict: false, fast: skipSpecs }) + if (report !== undefined && !report.valid) { + if (!flags.json) + process.stdout.write( + renderHuman([report], { noColor: flags.noColor, title: 'cospec archive' }), + ) + // Under --json the report keeps every key it carried; the refusal's join it. + const reportDoc = flags.json + ? (JSON.parse(renderJson([report])) as Record) + : undefined + return refuse('validation', diagnostics.validationFailed(change.id, root), reportDoc) } // Step 3: tasks gate (stricter than openspec — -y alone does not waive). @@ -338,7 +488,7 @@ export async function run(ctx: CommandContext): Promise { ) for (const t of incomplete) process.stderr.write(` - [ ] ${t.text}\n`) process.stderr.write('re-run with --force-incomplete to archive anyway.\n') - return EXIT.failure + return refuse('tasks-incomplete', diagnostics.tasksIncomplete(change.id, incomplete.length)) } // Step 3b: verification-incomplete gate (DESIGN §3.5 step 1). Runs whenever @@ -375,7 +525,10 @@ export async function run(ctx: CommandContext): Promise { process.stderr.write( 'resolve each row as `[x] … -> `, or defer it as `[~] … -> defer: `.\n', ) - return EXIT.failure + return refuse( + 'archive/verification-incomplete', + diagnostics.verificationIncomplete(change.id), + ) } } @@ -384,7 +537,7 @@ export async function run(ctx: CommandContext): Promise { if (existsSync(ownBlockersPath)) { const ownGate = computeGate( parseBlockers(readFileSync(ownBlockersPath, 'utf8')), - archiveMap(base), + warning === undefined ? archiveMap(base) : new Map(), new Set(listChanges(base).map((c) => c.id)), ) if (ownGate.hard.length > 0) @@ -396,15 +549,25 @@ export async function run(ctx: CommandContext): Promise { // Step 5: collision pre-check for today's slot (openspec archives as // YYYY-MM-DD-, stamped in the LOCAL zone — see formatLocalDate). const slot = DATE_PREFIXED_RE.test(change.id) ? change.id : `${formatLocalDate()}-${change.id}` - if (existsSync(join(archiveDir(base), slot))) { + // `lstat`, as the binary's `assertArchiveDestinationAvailable`: any errno + // but ENOENT is its `archive_error`, in the runtime's own words. + const taken = slotTaken(join(archiveDir(base), slot)) + if (typeof taken === 'string') { + process.stderr.write(`cospec archive: ${taken}\n`) + return refuse('archive-unreadable', diagnostics.error(taken)) + } + if (taken) { process.stderr.write( `cospec archive: archive slot '${slot}' already exists — rename or remove it first.\n`, ) - return EXIT.failure + return refuse('slot-exists', diagnostics.targetExists(slot)) } - // Step 7: snapshot. + // Step 7: snapshot — the archive's entries, and the bytes of each living spec + // the merge can write, which say whether it changed anything when the binary + // does not. const preArchiveDirs = new Set(basenames(archiveDir(base))) + const preSpecs = skipSpecs ? undefined : specFingerprint(base, preOps) // Step 7b: scenario-preservation gate (DESIGN §3.5 step 2) — before delegating // to `openspec archive`, specs-bearing changes only. Below openspec 1.8.0 the @@ -415,40 +578,20 @@ export async function run(ctx: CommandContext): Promise { // `livingCaps` doubles as step 10's record of which capabilities had a living // spec BEFORE the merge, so a spec that disappears can be told apart from one // that never existed. - const livingCaps = new Set() + let livingCaps = new Set() if (!skipSpecs && preOps.length > 0) { - const livingSpecs = new Map( - [...new Set(preOps.map((c) => c.capability))] - .map((cap): [string, ReturnType] | undefined => { - const p = join(openspecDir(base), 'specs', cap, 'spec.md') - if (!existsSync(p)) return undefined - livingCaps.add(cap) - return [cap, parseLivingSpec(readFileSync(p, 'utf8'))] - }) - .filter((e): e is [string, ReturnType] => e !== undefined), - ) - const drops = findScenarioDrops(preOps, livingSpecs) - if (drops.length > 0) { - process.stderr.write( - 'cospec archive: scenario-preservation gate refused — a MODIFIED requirement drops scenarios:\n', - ) - // The count clause keeps its shape even for a same-count name swap, where - // it reads `2 -> 2`: the missing-name clause carries the finding there. - for (const d of drops) - process.stderr.write( - ` ${d.capability}: "${d.name}" ${d.livingCount} -> ${d.deltaCount} scenario(s)${ - d.missingNames.length > 0 ? `; missing: ${quoteScenarioNames(d.missingNames)}` : '' - }\n`, - ) - if (drops.some((d) => d.noted)) process.stderr.write(`${SCENARIO_DROP_NOTE_RETIRED}.\n`) - process.stderr.write(`${SCENARIO_DROP_HINT}.\n`) - return EXIT.failure + const gate = scenarioGate(base, preOps) + livingCaps = gate.livingCaps + if (gate.drops.length > 0) { + process.stderr.write(scenarioRefusal('archive', gate.drops)) + return refuse('archive/scenario-preservation', diagnostics.scenarioDropped(gate.drops)) } } // Step 8: execute. const archiveArgs = [change.id, '-y'] if (skipSpecs) archiveArgs.push('--skip-specs') + if (noValidate) archiveArgs.push('--no-validate') const res = await spawnOpenspec(threadedArgv(['archive'], root.storeArgs, archiveArgs), root.cwd) // Step 9: verify (date-agnostic — survives midnight rollover). @@ -462,13 +605,19 @@ export async function run(ctx: CommandContext): Promise { const success = res.exitCode === 0 && !abortedOutput && moved && targetHasYaml if (!success) { - return reportArchiveFailure(ctx, change, res, { + const aborted = reportArchiveFailure(change, res, { moved, newDirs, target, targetHasYaml, abortedOutput, }) + return refuse( + aborted ? 'aborted' : 'half-state', + diagnostics.error(relayedReason(`${res.stdout}\n${res.stderr}`)), + { openspecExit: res.exitCode }, + moved, + ) } // Step 10: post-merge spot-check (skipped when no specs merged). @@ -503,10 +652,9 @@ export async function run(ctx: CommandContext): Promise { } } if (misses.length > 0) { - process.stderr.write( - `cospec archive: change was archived but spec merge verification failed for: ${misses.join('; ')} — this is a cospec/openspec invariant breach; please file a bug.\n`, - ) - return EXIT.failure + const breach = `change was archived but spec merge verification failed for: ${misses.join('; ')} — this is a cospec/openspec invariant breach; please file a bug.` + process.stderr.write(`cospec archive: ${breach}\n`) + return refuse('spec-verification-failed', diagnostics.error(breach), undefined, true) } } @@ -527,15 +675,28 @@ export async function run(ctx: CommandContext): Promise { } } - // Step 12: flywheel summary. + // Step 12: flywheel summary. What the binary applied comes from its own + // `Totals:` and in-sync lines; a binary in range that prints neither leaves + // `totals` out and `specsUpdated` to what the disk shows. const counts = countOps(preOps) - const specsLine = skipSpecs - ? 'skipped' - : preOps.length === 0 - ? 'none' - : `+${counts.added} ~${counts.modified} -${counts.removed} →${counts.renamed} applied and verified` - - const warnings = collectArchiveWarnings(res.stdout) + const summary = readArchiveSummary( + res.stdout, + preOps.map((c) => c.capability), + ) + const specsUpdated = skipSpecs + ? false + : (summary.specsUpdated ?? + !sameFingerprint(preSpecs ?? new Map(), specFingerprint(base, preOps))) + const specsLine = + skipReason !== undefined + ? SKIP_LINES[skipReason](change.schema) + : summary.specsUpdated === false + ? 'already in sync' + : `+${counts.added} ~${counts.modified} -${counts.removed} →${counts.renamed} applied and verified` + + // Relayed, so each allowlisted upstream remedy in them is spelled cospec. + const warnings = collectArchiveWarnings(res.stdout).map(respellRemedies) + const archiveWarnings = summary.warnings.map(respellRemedies) if (flags.json) { process.stdout.write( @@ -546,9 +707,19 @@ export async function run(ctx: CommandContext): Promise { archived: true, target, specs: skipSpecs ? 'skipped' : counts, + ...(skipReason === undefined ? {} : { specsSkipReason: skipReason }), retired, warnings, blockers: { checkedOff, nowUnblocked }, + archive: { + change: change.id, + archivedAs: target, + path: realpathSync(join(archiveDir(base), target!)), + specsUpdated, + ...(skipSpecs || summary.totals === undefined ? {} : { totals: summary.totals }), + ...(archiveWarnings.length > 0 ? { warnings: archiveWarnings } : {}), + }, + root: rootOutput(root), }, null, 2, @@ -580,19 +751,22 @@ interface VerifyState { abortedOutput: boolean } -/** Step 9 failure branch: clean abort vs. loud half-state. */ +/** + * Step 9 failure branch: clean abort vs. loud half-state, on stderr. True for + * a clean abort (nothing moved), false for a half-state. + */ function reportArchiveFailure( - ctx: CommandContext, change: Change, res: { stdout: string; stderr: string; exitCode: number }, state: VerifyState, -): number { - const captured = `${res.stdout}${res.stderr}` +): boolean { + const captured = respellRemedies(`${res.stdout}${res.stderr}`) .split('\n') .map((l) => ` ${l}`) .join('\n') - if (!state.moved && state.newDirs.length === 0) { + const aborted = !state.moved && state.newDirs.length === 0 + if (aborted) { process.stderr.write( 'The wrapped OpenSpec archive did not archive the change (it exited 0 but aborted).\n', ) @@ -612,20 +786,5 @@ function reportArchiveFailure( process.stderr.write(`${captured}\n`) process.stderr.write('Manual inspection required — the archive is in an inconsistent state.\n') } - - if (ctx.flags.json) - process.stdout.write( - `${JSON.stringify( - { - change: change.id, - type: change.schema, - archived: false, - reason: !state.moved && state.newDirs.length === 0 ? 'aborted' : 'half-state', - openspecExit: res.exitCode, - }, - null, - 2, - )}\n`, - ) - return EXIT.failure + return aborted } diff --git a/apps/cli/src/commands/sync-specs.ts b/apps/cli/src/commands/sync-specs.ts new file mode 100644 index 00000000..c49fed2f --- /dev/null +++ b/apps/cli/src/commands/sync-specs.ts @@ -0,0 +1,262 @@ +// `cospec sync-specs ` — merge a change's delta specs into the main +// specs without archiving it (design D11). It refuses what `cospec archive` +// refuses before any merge (the namespace folder, the archive-precondition +// revalidation, the scenario-preservation gate), then runs the pinned binary's +// own `archive -y` on a scratch copy of what that archive reads and copies +// back only the main-spec files the run changed, so the main specs come out +// byte-for-byte as archive would write them and a later `cospec archive` is +// the binary's early-sync no-op. The change stays active; the tasks gate and +// `archive/verification-incomplete` do not run, because nothing is archived. + +import type { CommandContext } from '../cli.ts' +import { EXIT } from '../cli.ts' +import { + changeNameProblem, + diagnostics, + failureDocument, + readArchiveSummary, + type ArchiveDiagnostic, + type ArchiveRefusalReason, +} from '../core/archive-output.ts' +import { + changesDir, + describeNestedChange, + findNestedChangesIn, + isCospecType, + listChangeDirs, + listChanges, + resolveChange, + resolveSchema, +} from '../core/change.ts' +import { + enforceExpectation, + spawnOpenspec, + wrappedCallLabel, + type OpenspecResult, +} from '../core/openspec.ts' +import { respellRemedies } from '../core/remedies.ts' +import { renderHuman, renderJson } from '../core/report.ts' +import { resolveRoot } from '../core/root.ts' +import { TYPE_ARTIFACTS } from '../core/rules/type-facts.ts' +import { changeDeltaOps, scenarioGate, scenarioRefusal } from '../core/scenario-gate.ts' +import { ScratchRefusal, syncThroughScratch, type ScratchRun } from '../core/scratch-root.ts' +import { rootOutput } from '../core/upstream-keys.ts' +import { closest } from './apply.ts' +import { collectArchiveWarnings } from './archive.ts' +import { readValidateContext, validateChange } from './validate.ts' + +/** + * The `--json` payload a root-selection failure prints ahead of `status`. + */ +export const jsonFailurePayload = { synced: false } as const + +/** Why there is nothing to sync (`skipReason`). */ +type NothingToSync = 'schema' | 'skip-specs' | 'no-deltas' + +const SCRATCH_REASONS: Record = { + 'scratch-run': 'scratch-run', + 'specs-changed': 'specs-changed', + 'symlink-escape': 'symlink-escape', +} + +/** The wrapped call: exit 0 (archived) or 1 (refused) are its answers; anything else is a violation. */ +async function archiveInScratch(id: string, scratch: string): Promise { + const args = ['archive', id, '-y'] + const res: OpenspecResult = await spawnOpenspec(args, scratch) + enforceExpectation(wrappedCallLabel(args), res, { exitCodes: [0, 1] }) + return res +} + +export async function run(ctx: CommandContext): Promise { + const { flags } = ctx + const root = await resolveRoot(ctx) + const base = root.base + // Required in the table: the parser has refused a missing one. + const name = ctx.parsed!.positionals[0]! + + let type: string | undefined + const refuse = ( + reason: ArchiveRefusalReason, + diagnostic: ArchiveDiagnostic, + extra?: Readonly>, + ): number => { + if (flags.json) + process.stdout.write( + failureDocument({ + change: name, + ...(type === undefined ? {} : { type }), + reason, + diagnostic, + root, + outcome: 'synced', + ...(extra === undefined ? {} : { extra }), + }), + ) + return EXIT.failure + } + + // Step 1: resolve the change as `cospec archive` does. + const nameProblem = changeNameProblem(name) + if (nameProblem !== undefined) { + process.stderr.write(`cospec sync-specs: ${nameProblem}\n`) + return refuse('invalid-name', diagnostics.invalidName(nameProblem)) + } + const change = resolveChange(base, name) + if (change === undefined) { + process.stderr.write(`cospec sync-specs: unknown change '${name}'\n`) + const suggestion = closest( + name, + listChanges(base).map((c) => c.id), + ) + if (suggestion !== undefined) process.stderr.write(`Did you mean '${suggestion}'?\n`) + return refuse( + 'unknown-change', + diagnostics.notFound( + name, + listChangeDirs(base).map((c) => c.id), + ), + ) + } + type = change.schema + const nested = findNestedChangesIn(changesDir(base), change.id) + if (nested !== undefined) { + const diag = diagnostics.namespaceFolder( + change.id, + describeNestedChange(nested), + nested.nested[0]!, + 'sync', + ) + process.stderr.write(`cospec sync-specs: ${diag.message}\n${diag.fix!}\n`) + return refuse('namespace-folder', diag) + } + + // Nothing to sync: the reasons archive skips its spec sync for, with + // `skip_specs: true` named on its own — only for a change with no delta + // files, as archive reads it: beside delta files the marker is a conflict + // the revalidation below refuses, as archive's does. + const resolution = resolveSchema(base, change.schema) + const declaresSpecs = + resolution.kind === 'legacy' + ? true + : isCospecType(change.schema) + ? TYPE_ARTIFACTS[change.schema as keyof typeof TYPE_ARTIFACTS].declared.includes('specs') + : false + const caps = changeDeltaOps(change.dir) + const nothing: NothingToSync | undefined = !declaresSpecs + ? 'schema' + : caps.length > 0 + ? undefined + : change.skipSpecs === true + ? 'skip-specs' + : 'no-deltas' + const nothingToSync = (why: NothingToSync): number => { + const text = { + schema: `the ${change.schema} schema has no specs artifact`, + 'skip-specs': `${change.id} declares skip_specs: true`, + 'no-deltas': `${change.id} has no delta specs`, + }[why] + if (!flags.json) process.stdout.write(`Nothing to sync: ${text}.\n`) + else + process.stdout.write( + `${JSON.stringify( + { + change: change.id, + type: change.schema, + synced: false, + skipReason: why, + files: { written: [], deleted: [] }, + warnings: [], + root: rootOutput(root), + }, + null, + 2, + )}\n`, + ) + return EXIT.success + } + if (nothing === 'schema') return nothingToSync(nothing) + + // Step 2: archive's pre-merge checks — its revalidation, then the + // scenario-preservation gate. The revalidation runs before "no delta specs" + // is answered: a delta in a file the merge never reads (a root `spec.md`, a + // `.md`, a note beside `spec.md`) is no delta to `caps`, and is + // exactly what archive's revalidation refuses. + const { ctx: vctx, warning } = readValidateContext(base) + if (warning !== undefined) process.stderr.write(`Warning: ${warning.message}\n`) + const report = await validateChange(root, change, vctx, { strict: false, fast: false }) + if (!report.valid) { + if (!flags.json) + process.stdout.write( + renderHuman([report], { noColor: flags.noColor, title: 'cospec sync-specs' }), + ) + const reportDoc = flags.json + ? (JSON.parse(renderJson([report])) as Record) + : undefined + return refuse('validation', diagnostics.validationFailed(change.id, root), reportDoc) + } + if (nothing !== undefined) return nothingToSync(nothing) + const gate = scenarioGate(base, caps) + if (gate.drops.length > 0) { + process.stderr.write(scenarioRefusal('sync-specs', gate.drops)) + return refuse('archive/scenario-preservation', diagnostics.scenarioDropped(gate.drops)) + } + + // Steps 3–5: the binary's archive on a scratch copy, copied back and verified. + let synced + try { + synced = await syncThroughScratch(base, change.id, (scratch) => + archiveInScratch(change.id, scratch), + ) + } catch (error) { + if (!(error instanceof ScratchRefusal)) throw error + const reason = respellRemedies(error.message) + if (error.kind === 'scratch-run' && error.run !== undefined) { + process.stderr.write(`cospec sync-specs: the wrapped OpenSpec archive refused: ${reason}\n`) + const captured = respellRemedies(`${error.run.stdout}${error.run.stderr}`) + .split('\n') + .map((l) => ` ${l}`) + .join('\n') + process.stderr.write(`${captured}\nNothing was written to the main specs.\n`) + } else process.stderr.write(`cospec sync-specs: ${reason}\n`) + return refuse(SCRATCH_REASONS[error.kind], diagnostics.error(error.message)) + } + + const summary = readArchiveSummary( + synced.run.stdout, + caps.map((c) => c.capability), + ) + const warnings = collectArchiveWarnings(synced.run.stdout).map(respellRemedies) + + if (flags.json) { + process.stdout.write( + `${JSON.stringify( + { + change: change.id, + type: change.schema, + synced: true, + ...(summary.totals === undefined ? {} : { totals: summary.totals }), + files: { written: synced.written, deleted: synced.deleted }, + warnings, + root: rootOutput(root), + }, + null, + 2, + )}\n`, + ) + return EXIT.success + } + + const lines: string[] = [] + if (synced.written.length === 0 && synced.deleted.length === 0) + lines.push('Specs: already in sync; no files changed') + else { + for (const file of synced.written) lines.push(`Synced: ${file} (written)`) + for (const file of synced.deleted) lines.push(`Synced: ${file} (deleted)`) + const t = summary.totals + if (t !== undefined) + lines.push(`Totals: + ${t.added}, ~ ${t.modified}, - ${t.removed}, → ${t.renamed}`) + } + for (const w of warnings) lines.push(`Warning: ${w}`) + process.stdout.write(`${lines.join('\n')}\n`) + return EXIT.success +} diff --git a/apps/cli/src/commands/validate.ts b/apps/cli/src/commands/validate.ts index ce16fcfb..4a6dc699 100644 --- a/apps/cli/src/commands/validate.ts +++ b/apps/cli/src/commands/validate.ts @@ -932,21 +932,14 @@ function archiveSlugsIn(base: string): Set { ) } -/** The validate context as `archive` reads it: an unreadable archive refuses. */ -export function buildValidateContext(base: string): ValidateContext { - return { - archiveSlugs: archiveSlugsIn(base), - activeSlugs: new Set(listChanges(base).map((c) => c.id)), - } -} - /** * The validate context `validate` and `apply` read (task 11.7). The binary's * `validate` and `instructions apply` never read `openspec/changes/archive/`, * so an unreadable one must not fail them: it is read as empty, which can only * add an issue (a blocker or revert citation naming an archived change reads * as dangling) or keep a blocker open, never clear one, and the warning names - * the directory. `archive` keeps `buildValidateContext`, which refuses. + * the directory. `archive` reads it the same way, and its slot check then + * answers for the unreadable directory as the binary's does. */ export function readValidateContext(base: string): { ctx: ValidateContext diff --git a/apps/cli/src/core/archive-output.ts b/apps/cli/src/core/archive-output.ts new file mode 100644 index 00000000..3a407b59 --- /dev/null +++ b/apps/cli/src/core/archive-output.ts @@ -0,0 +1,274 @@ +// What `cospec archive` and `cospec sync-specs` answer (design D5, D6): the +// failure document every refusal prints under `--json`, each diagnostic in the +// binary's own code and message (`dist/core/archive.js`, 1.13.1) wherever the +// binary refuses the same input, and the reader of the binary's fixed +// human-mode lines a successful wrapped archive printed. + +import type { ScenarioDrop } from './deltas.ts' +import { respellRemedies } from './remedies.ts' +import type { ResolvedRoot } from './root.ts' +import { rootOutput } from './upstream-keys.ts' + +/** One `status[]` entry, as the binary's `ArchiveBlockedError` carries it. */ +export interface ArchiveDiagnostic { + severity: 'error' + code: string + message: string + fix?: string +} + +/** + * Why cospec refused, in its own vocabulary (the document's `reason`). The + * first two predate the binary's keys; every other value names one refusal. + */ +export const ARCHIVE_REFUSAL_REASONS = [ + 'aborted', + 'half-state', + 'spec-verification-failed', + 'unknown-change', + 'invalid-name', + 'namespace-folder', + 'validation', + 'tasks-incomplete', + 'archive/verification-incomplete', + 'slot-exists', + 'archive/scenario-preservation', + 'archive-unreadable', + 'scratch-run', + 'specs-changed', + 'symlink-escape', +] as const + +export type ArchiveRefusalReason = (typeof ARCHIVE_REFUSAL_REASONS)[number] + +function diagnostic(code: string, message: string, fix?: string): ArchiveDiagnostic { + return { severity: 'error', code, message, ...(fix === undefined ? {} : { fix }) } +} + +/** upstream's `withStoreFlag`: ` --store ` for a store-selected root. */ +function storeFlag(root: ResolvedRoot | undefined): string { + return root?.store === undefined ? '' : ` --store ${root.store}` +} + +/** + * upstream's `folderStyleNameProblem(name, 'Change name')`: the names archive + * refuses before it looks for the change. + */ +export function changeNameProblem(name: string): string | undefined { + if (name.length === 0) return 'Change name must not be empty' + if (name === '.' || name === '..') return `Change name must not be '${name}'` + if (/[\\/]/u.test(name)) return 'Change name must not contain path separators' + return undefined +} + +export const diagnostics = { + invalidName: (problem: string): ArchiveDiagnostic => + diagnostic('archive_change_name_invalid', problem), + notFound: (name: string, available: readonly string[]): ArchiveDiagnostic => + diagnostic( + 'archive_change_not_found', + available.length > 0 + ? `Change '${name}' not found. Available changes: ${available.join(', ')}` + : `Change '${name}' not found. No active changes exist in this root.`, + ), + /** `verb` is `archive` or `sync`; the binary's is `archive`. */ + namespaceFolder: ( + name: string, + explanation: string, + firstNested: string, + verb: 'archive' | 'sync', + ): ArchiveDiagnostic => + diagnostic( + 'archive_change_is_namespace_folder', + `Cannot ${verb} '${name}': ${explanation}`, + `Rename openspec/changes/${firstNested}/ to a flat change directory, then ${verb} it.`, + ), + validationFailed: (name: string, root: ResolvedRoot | undefined): ArchiveDiagnostic => + diagnostic( + 'archive_validation_failed', + `Validation failed for change '${name}'.`, + `Run openspec validate ${name}${storeFlag(root)} for details, fix the errors, or rerun with --no-validate.`, + ), + /** cospec's own fix: `--yes` does not lift cospec's stricter gate. */ + tasksIncomplete: (name: string, count: number): ArchiveDiagnostic => + diagnostic( + 'archive_tasks_incomplete', + `${count} incomplete task(s) found for change '${name}'.`, + 'Complete the tasks or rerun with --force-incomplete.', + ), + /** cospec-only: the binary archives such a change. */ + verificationIncomplete: (name: string): ArchiveDiagnostic => + diagnostic( + 'archive_verification_incomplete', + `verification.md is not fully resolved for change '${name}'.`, + 'Resolve each row as `[x] … -> `, or defer it as `[~] … -> defer: `.', + ), + targetExists: (slot: string): ArchiveDiagnostic => + diagnostic('archive_target_exists', `Archive '${slot}' already exists.`), + /** + * The binary's merge refusal for the first drop it would abort on: its merge + * visits capabilities in path order and a delta's MODIFIED blocks in order. + */ + scenarioDropped: (drops: readonly ScenarioDrop[]): ArchiveDiagnostic => { + const first = drops.toSorted((a, b) => (a.capability < b.capability ? -1 : 1))[0]! + const names = first.missingNames.map((n) => `"${n}"`).join(', ') + return diagnostic( + 'archive_spec_update_failed', + `${first.capability} MODIFIED failed for header "### Requirement: ${first.name}" - current spec contains scenario(s) not present in the modified block: ${names}. Refresh the change spec before archiving to avoid dropping scenarios.`, + 'Fix the change delta specs and rerun. No files were changed.', + ) + }, + pathOutsideRoot: (dir: string): ArchiveDiagnostic => + diagnostic( + 'archive_path_outside_root', + `Refusing to archive through a path outside the OpenSpec root: ${dir}`, + ), + /** The binary's code for a failure it does not classify. */ + error: (message: string): ArchiveDiagnostic => diagnostic('archive_error', message), +} + +/** `diag` with each allowlisted upstream remedy in it spelled through cospec. */ +export function respellDiagnostic(diag: ArchiveDiagnostic): ArchiveDiagnostic { + return { + ...diag, + message: respellRemedies(diag.message), + ...(diag.fix === undefined ? {} : { fix: respellRemedies(diag.fix) }), + } +} + +export interface FailureDocument { + /** The change as named on the command line. */ + change: string + /** The change's schema, once the change resolved. */ + type?: string + reason: ArchiveRefusalReason + diagnostic: ArchiveDiagnostic + /** The resolved root; absent only when none resolved, as in the binary. */ + root?: ResolvedRoot + /** `archive` (the default) reports `archived: false`; `sync-specs` reports `synced: false`. */ + outcome?: 'archived' | 'synced' + /** Keys a refusal carries beyond these (the revalidation report's). */ + extra?: Readonly> + /** `archived: true` for a refusal after the change already moved. */ + moved?: boolean +} + +/** + * The one `--json` document of a refusal: cospec's `change`/`type`/`archived` + * (or `synced`)/`reason`, then the binary's `archive: null`, `root` and + * `status`, the diagnostic respelled. + */ +export function failureDocument(doc: FailureDocument): string { + const outcome = doc.outcome ?? 'archived' + const body: Record = { + ...doc.extra, + change: doc.change, + ...(doc.type === undefined ? {} : { type: doc.type }), + [outcome]: doc.moved === true, + reason: doc.reason, + ...(outcome === 'archived' ? { archive: null } : {}), + ...(doc.root === undefined ? {} : { root: rootOutput(doc.root) }), + status: [respellDiagnostic(doc.diagnostic)], + } + return `${JSON.stringify(body, null, 2)}\n` +} + +/** The binary's own closing line of an aborted merge (`dist/core/archive.js`). */ +const ABORTED_LINE = 'Aborted. No files were changed.' + +/** `failWithError`'s line for an error it was thrown (`✖ Error: `). */ +const ERROR_LINE_RE = /^(?:✖ )?Error: (.+)$/ + +/** + * The reason a failed wrapped archive gave: its last non-blank line, skipping + * its fixed `Aborted. No files were changed.` closer; when that line is the + * CLI's `Error: `, the message, as the binary's `--json` document + * carries it. + */ +export function relayedReason(output: string): string { + const lines = output + .split('\n') + .map((l) => l.trim()) + .filter((l) => l !== '' && l !== ABORTED_LINE) + const last = lines.at(-1) + if (last === undefined) return 'the wrapped OpenSpec archive gave no reason' + return ERROR_LINE_RE.exec(last)?.[1] ?? last +} + +/** The binary's per-operation totals (`writeTotals`). */ +export interface ArchiveTotals { + added: number + modified: number + removed: number + renamed: number +} + +/** What the binary's human-mode archive output says it applied. */ +export interface ArchiveSummary { + /** Its `Totals:` line; absent when it printed none (no spec sync ran). */ + totals?: ArchiveTotals + /** + * `true` after `Specs updated successfully.`, `false` after + * `Specs already in sync; no files changed.`, absent when it printed neither. + */ + specsUpdated?: boolean + /** The spec-merge warnings its `--json` document would carry (`archive.warnings`). */ + warnings: string[] +} + +// Fixed lines only the binary writes (`dist/core/archive.js`, +// `dist/core/specs-apply.js`, 1.13.1), matched whole. +const TOTALS_RE = /^Totals: \+ (\d+), ~ (\d+), - (\d+), → (\d+)$/ +const UPDATED_LINE = 'Specs updated successfully.' +const IN_SYNC_LINE = 'Specs already in sync; no files changed.' +const SPEC_WARNING_RE = /^⚠️ {2}Warning: (.+)$/ +const RETIRING_RE = /^Retiring (.+): all requirements removed\.$/ +const RECOVERY_RE = /^ {3}(\S.*)$/ + +/** + * Read the binary's own summary of a successful human-mode archive. + * `capabilities` are the change's delta capabilities, which name the one each + * `Retiring ` line retired; its JSON note is rebuilt from that line and + * the recovery line under it. + */ +export function readArchiveSummary( + stdout: string, + capabilities: readonly string[] = [], +): ArchiveSummary { + const summary: ArchiveSummary = { warnings: [] } + const lines = stdout.split('\n').map((l) => l.replace(/\r$/, '')) + for (let i = 0; i < lines.length; i++) { + const line = lines[i]! + const totals = TOTALS_RE.exec(line) + if (totals !== null) { + summary.totals = { + added: Number(totals[1]), + modified: Number(totals[2]), + removed: Number(totals[3]), + renamed: Number(totals[4]), + } + continue + } + if (line === UPDATED_LINE) summary.specsUpdated = true + else if (line === IN_SYNC_LINE) summary.specsUpdated = false + const warning = SPEC_WARNING_RE.exec(line) + if (warning !== null) { + summary.warnings.push(warning[1]!) + continue + } + const retiring = RETIRING_RE.exec(line) + const recovery = RECOVERY_RE.exec(lines[i + 1] ?? '') + if (retiring === null || recovery === null) continue + const path = retiring[1]! + const id = capabilities.find( + (c) => + path === `openspec/specs/${c}/spec.md` || path.endsWith(`/openspec/specs/${c}/spec.md`), + ) + if (id === undefined) continue + summary.warnings.push( + `${id} - capability retired; deleted the main spec (all requirements removed, declared by retire_capabilities) at ${path}. Its section(s) went with it: Purpose. ${recovery[1]!}`, + ) + i++ + } + return summary +} diff --git a/apps/cli/src/core/command-table.ts b/apps/cli/src/core/command-table.ts index d1319898..8da5b9f5 100644 --- a/apps/cli/src/core/command-table.ts +++ b/apps/cli/src/core/command-table.ts @@ -634,11 +634,21 @@ export const COMMAND_TABLE: readonly CommandRow[] = [ }), upstream({ name: '--no-validate', - description: 'Skip validation (not recommended)', - status: pending('archive-and-sync-parity'), + description: + "Skip revalidation, cospec's and the binary's (not recommended; every other gate still runs)", }), ], }, + { + name: 'sync-specs', + summary: "Merge a change's delta specs into the main specs without archiving it", + hidden: false, + parse: 'table', + json: 'accepted', + store: 'accepted', + positionals: [cospecArg({ name: 'change', required: true })], + flags: [], + }, { name: 'sync-blockers', summary: 'Reconcile blocking-changes.md checkboxes', diff --git a/apps/cli/src/core/glob.ts b/apps/cli/src/core/glob.ts index 20c3e0ba..dbf6b5d6 100644 --- a/apps/cli/src/core/glob.ts +++ b/apps/cli/src/core/glob.ts @@ -99,8 +99,8 @@ function canonicalizePotentialPath(targetPath: string): string { } } -/** The binary's `FileSystemUtils.assertPathWithin`. */ -function assertPathWithin(allowedDirectory: string, targetPath: string): void { +/** The binary's `FileSystemUtils.assertPathWithin` (also archive's root confinement). */ +export function assertPathWithin(allowedDirectory: string, targetPath: string): void { const resolvedDirectory = resolve(allowedDirectory) const resolvedTarget = resolve(targetPath) if (!isPathWithin(resolvedDirectory, resolvedTarget)) diff --git a/apps/cli/src/core/rules/archive.ts b/apps/cli/src/core/rules/archive.ts index 9afb5703..b4f68ce2 100644 --- a/apps/cli/src/core/rules/archive.ts +++ b/apps/cli/src/core/rules/archive.ts @@ -538,10 +538,14 @@ export function archiveRules( const pathFor = (i: number): string => group.paths[i] ?? `specs/${capability}/spec.md` if (living === undefined) { - // archive/new-spec-non-added — a brand-new capability may only ADD. + // archive/new-spec-non-added — the binary's merge refuses MODIFIED and + // RENAMED on a capability with no living spec. A REMOVED there is + // ignored with a warning ("nothing to remove") and the rest applied, so + // a REMOVED-only delta is the rebuilt spec's to refuse (it has no + // requirement), or the binary's skip under `retire_capabilities`. for (let i = 0; i < group.ops.length; i++) { const op = group.ops[i]! - if (op.operation === 'ADDED') continue + if (op.operation === 'ADDED' || op.operation === 'REMOVED') continue const name = op.operation === 'RENAMED' ? op.fromName : op.name issues.push({ level: 'ERROR', diff --git a/apps/cli/src/core/scenario-gate.ts b/apps/cli/src/core/scenario-gate.ts new file mode 100644 index 00000000..34495aa0 --- /dev/null +++ b/apps/cli/src/core/scenario-gate.ts @@ -0,0 +1,120 @@ +// The `archive/scenario-preservation` hard gate (DESIGN §3.5 step 2), shared by +// `cospec archive` and `cospec sync-specs` so the two refuse exactly where the +// binary's archive merge does. It reads both documents the way that merge +// reads them — fences masked, HTML comments kept: the change's deltas through +// `parseDeltaSpec` and each living spec through `parseLivingSpec`, whose top +// level is that view. The inputs are typed on the verbatim view, so the +// advisory (comment-masked) parse cannot reach this gate. + +import { existsSync, readdirSync, readFileSync } from 'node:fs' +import { join, relative } from 'node:path' + +import { openspecDir } from './change.ts' +import { + findScenarioDrops, + parseDeltaSpec, + parseLivingSpec, + quoteScenarioNames, + SCENARIO_DROP_HINT, + SCENARIO_DROP_NOTE_RETIRED, + type DeltaOp, + type LivingSpec, + type ScenarioDrop, +} from './deltas.ts' +import { capabilityForDeltaFile, isDeltaSpecFile } from './spec-paths.ts' + +export interface CapabilityDeltas { + capability: string + ops: DeltaOp[] +} + +/** + * All change-side delta ops grouped by capability path + * (`specs//spec.md`). + * + * Only files literally named `spec.md` count, matching openspec's own change + * parser and `discoverSpecFiles` on the living side. Companion markdown an + * author keeps in a capability directory (`README.md`, `notes.md`, a + * `spec-old.md` backup) is content `openspec archive` never merges, so parsing + * it here would feed phantom ops to both hard gates. + * + * The capability is the whole directory chain under `specs/`, so a nested + * `specs/platform/session-layout/spec.md` groups under `platform/session-layout` + * — the path openspec merges it to (`findSpecUpdates`, 1.6.0 #1353) and the path + * every living-spec lookup joins. Keying on the outermost directory instead + * pointed both hard archive gates at `openspec/specs/platform/spec.md`, which + * does not exist, silently turning them into no-ops for every nested spec. + * + * A `.md` sitting directly in `specs/` has no capability at all; openspec 1.7.0 + * blocks that layout outright, so it contributes no ops rather than inventing a + * capability named after the file. + */ +export function changeDeltaOps(changeDir: string): CapabilityDeltas[] { + const root = join(changeDir, 'specs') + if (!existsSync(root)) return [] + const byCap = new Map() + const walk = (dir: string): void => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const child = join(dir, entry.name) + if (entry.isDirectory()) { + if (!entry.name.startsWith('.')) walk(child) + continue + } + if (!entry.isFile() || !isDeltaSpecFile(entry.name)) continue + const capability = capabilityForDeltaFile(relative(changeDir, child)) + if (capability === undefined) continue + const parsed = parseDeltaSpec(readFileSync(child, 'utf8'), child, capability) + const list = byCap.get(parsed.capability) ?? [] + list.push(...parsed.ops) + byCap.set(parsed.capability, list) + } + } + walk(root) + return [...byCap.entries()].map(([capability, ops]) => ({ capability, ops })) +} + +export interface ScenarioGateResult { + /** Each MODIFIED requirement that drops a living scenario. */ + drops: ScenarioDrop[] + /** + * The capabilities that had a living spec when the gate ran, so a caller can + * tell a spec the merge deleted from one that never existed. + */ + livingCaps: Set +} + +/** + * Run the gate for `caps` against the living specs under `base`'s + * `openspec/specs/`. + */ +export function scenarioGate(base: string, caps: readonly CapabilityDeltas[]): ScenarioGateResult { + const livingCaps = new Set() + const livingSpecs = new Map() + for (const cap of new Set(caps.map((c) => c.capability))) { + const path = join(openspecDir(base), 'specs', cap, 'spec.md') + if (!existsSync(path)) continue + livingCaps.add(cap) + livingSpecs.set(cap, parseLivingSpec(readFileSync(path, 'utf8'))) + } + return { drops: findScenarioDrops(caps, livingSpecs), livingCaps } +} + +/** + * The refusal `command` prints on stderr for `drops`. The count clause keeps + * its shape even for a same-count name swap, where it reads `2 -> 2`: the + * missing-name clause carries the finding there. + */ +export function scenarioRefusal(command: string, drops: readonly ScenarioDrop[]): string { + const lines = [ + `cospec ${command}: scenario-preservation gate refused — a MODIFIED requirement drops scenarios:`, + ...drops.map( + (d) => + ` ${d.capability}: "${d.name}" ${d.livingCount} -> ${d.deltaCount} scenario(s)${ + d.missingNames.length > 0 ? `; missing: ${quoteScenarioNames(d.missingNames)}` : '' + }`, + ), + ] + if (drops.some((d) => d.noted)) lines.push(`${SCENARIO_DROP_NOTE_RETIRED}.`) + lines.push(`${SCENARIO_DROP_HINT}.`) + return `${lines.join('\n')}\n` +} diff --git a/apps/cli/src/core/scratch-root.ts b/apps/cli/src/core/scratch-root.ts new file mode 100644 index 00000000..a548a815 --- /dev/null +++ b/apps/cli/src/core/scratch-root.ts @@ -0,0 +1,374 @@ +// `cospec sync-specs`'s scratch run (design D11): the wrapped binary's own +// `archive -y` runs on a copy of what that archive reads — the root's +// `config.yaml`/`config.yml`, `schemas/`, `specs/`, the one change and an empty +// `changes/archive/` — in a fresh directory under the OS temp directory, and +// only the main-spec entries that run created, changed or deleted are copied +// back. A link inside the copied paths is re-pointed at its target's scratch +// copy, so an absolute link (or one that climbs out and back in) aliases the +// scratch tree as it aliases the real one, never the real tree itself. A file +// or directory this command cannot read is copied as an empty placeholder of +// the same mode, so the binary meets the same refusal there it meets in the +// real tree. The binary never runs in the real tree, so its archive claim +// (`.openspec-archive.lock`), its move and anything a failed run leaves behind +// exist only in the scratch tree, which is removed whatever happens. + +import { createHash } from 'node:crypto' +import { + chmodSync, + copyFileSync, + cpSync, + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + readlinkSync, + realpathSync, + renameSync, + rmdirSync, + rmSync, + statSync, + symlinkSync, + unlinkSync, + writeFileSync, + type Stats, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, isAbsolute, join, relative, sep } from 'node:path' + +import { relayedReason } from './archive-output.ts' + +/** What one scratch run of the wrapped archive printed and exited with. */ +export interface ScratchRun { + stdout: string + stderr: string + exitCode: number +} + +/** Runs the wrapped archive with `scratchBase` as its working directory. */ +export type ScratchRunner = (scratchBase: string) => Promise + +export interface ScratchSync { + /** Main-spec files the run created or changed, relative to the root. */ + written: string[] + /** Main-spec files and links the run deleted, relative to the root. */ + deleted: string[] + run: ScratchRun +} + +export type ScratchRefusalKind = 'symlink-escape' | 'scratch-run' | 'specs-changed' + +/** A refusal of the scratch sync, with nothing written to the real tree. */ +export class ScratchRefusal extends Error { + readonly kind: ScratchRefusalKind + /** The run the refusal judged, when one happened. */ + readonly run?: ScratchRun + + constructor(kind: ScratchRefusalKind, message: string, run?: ScratchRun) { + super(message) + this.name = 'ScratchRefusal' + this.kind = kind + if (run !== undefined) this.run = run + } +} + +const CONFIG_FILES = ['config.yaml', 'config.yml'] as const + +/** A path under `openspec/` the binary's archive reads: as spelled there, and its real path. */ +interface CopiedRoot { + rel: string + real: string +} + +/** The paths under `openspec/` the binary's archive reads. */ +function copiedRoots(openspec: string, changeId: string): CopiedRoot[] { + return [ + ...CONFIG_FILES.filter((f) => existsSync(join(openspec, f))), + ...['schemas', 'specs'].filter((d) => existsSync(join(openspec, d))), + join('changes', changeId), + ].map((rel) => ({ rel, real: realpathSync(join(openspec, rel)) })) +} + +function within(parent: string, child: string): boolean { + const rel = relative(parent, child) + return rel === '' || (!rel.startsWith(`..${sep}`) && rel !== '..' && !isAbsolute(rel)) +} + +/** Whether `error` says this process may not read the entry (rather than that it is missing or broken). */ +function unreadable(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException | undefined)?.code + return code === 'EACCES' || code === 'EPERM' +} + +/** A directory's entries, or `undefined` when this process may not list it. */ +function listable(dir: string): string[] | undefined { + try { + return readdirSync(dir) + } catch (error) { + if (unreadable(error)) return undefined + throw error + } +} + +/** The copied root holding the real path `target` (the deepest, should two nest). */ +function rootHolding(roots: readonly CopiedRoot[], target: string): CopiedRoot | undefined { + return roots + .filter((r) => within(r.real, target)) + .toSorted((a, b) => b.real.length - a.real.length)[0] +} + +/** + * The first symbolic link under the copied paths whose target leaves them, as + * a path relative to the root — the binary would write through it into the + * real tree. A link inside them is copied re-pointed at the scratch copy of its + * target, so the scratch run sees the same aliasing the real tree has. Each + * copied path is walked at its real path, as it is copied. + */ +export function symlinkEscape(base: string, changeId: string): string | undefined { + const openspec = join(base, 'openspec') + const roots = copiedRoots(openspec, changeId) + const leaves = (link: string): boolean => { + let target: string + try { + target = realpathSync(link) + } catch (error) { + // A dangling link cannot be proven to stay inside. + if ((error as NodeJS.ErrnoException | undefined)?.code === 'ENOENT') return true + throw error + } + return rootHolding(roots, target) === undefined + } + const walk = (path: string, shown: string): string | undefined => { + const stat = lstatSync(path) + if (stat.isSymbolicLink()) return leaves(path) ? shown : undefined + if (!stat.isDirectory()) return undefined + // An unlistable directory is copied empty: no link in it reaches the run. + for (const name of (listable(path) ?? []).toSorted()) { + const found = walk(join(path, name), `${shown}/${name}`) + if (found !== undefined) return found + } + return undefined + } + for (const root of roots) { + const found = walk(root.real, ['openspec', ...root.rel.split(sep)].join('/')) + if (found !== undefined) return found + } + return undefined +} + +/** + * Copy `src` (a real path) to `dst`: a link re-pointed by `scratchFor` at the + * scratch copy of its target, a directory this process cannot list as an + * empty one, a file it cannot read as an empty file of the same mode. + */ +function copyEntry(src: string, dst: string, scratchFor: (target: string) => string): void { + const stat = lstatSync(src) + if (stat.isSymbolicLink()) { + symlinkSync(relative(dirname(dst), scratchFor(realpathSync(src))), dst) + } else if (stat.isDirectory()) { + mkdirSync(dst) + for (const name of listable(src) ?? []) copyEntry(join(src, name), join(dst, name), scratchFor) + } else if (stat.isFile()) { + try { + copyFileSync(src, dst) + } catch (error) { + if (!unreadable(error)) throw error + writeFileSync(dst, '') + chmodSync(dst, stat.mode & 0o7777) + } + } else cpSync(src, dst) +} + +/** Copy every root into `scratchOpenspec`, each read at its real path. */ +function copyRoots(roots: readonly CopiedRoot[], scratchOpenspec: string): void { + const scratchFor = (target: string): string => { + const root = rootHolding(roots, target) + // `symlinkEscape` has refused every link that leaves the roots. + if (root === undefined) + throw new Error(`sync-specs: ${target} left the copied paths after they were checked`) + return join(scratchOpenspec, root.rel, relative(root.real, target)) + } + for (const root of roots) copyEntry(root.real, join(scratchOpenspec, root.rel), scratchFor) +} + +/** + * Every entry under `dir`, relative to it: a file by its sha256, a link by the + * target it holds (never followed), a directory by `dir`. A file this process + * cannot read is fingerprinted by its metadata, a directory it cannot list as + * `unlisted` with its metadata, unwalked. + */ +function fingerprint(dir: string): Map { + const out = new Map() + const walk = (abs: string): void => { + for (const name of listable(abs) ?? []) { + const child = join(abs, name) + const rel = relative(dir, child) + const stat = lstatSync(child) + if (stat.isSymbolicLink()) out.set(rel, `link:${readlinkSync(child)}`) + else if (stat.isDirectory()) { + if (listable(child) === undefined) out.set(rel, `unlisted:${stat.mode}:${stat.mtimeMs}`) + else { + out.set(rel, 'dir') + walk(child) + } + } else if (stat.isFile()) out.set(rel, fileFingerprint(child, stat)) + else out.set(rel, `other:${stat.mode}`) + } + } + if (existsSync(dir)) walk(dir) + return out +} + +function fileFingerprint(path: string, stat: Stats): string { + try { + return `file:${createHash('sha256').update(readFileSync(path)).digest('hex')}` + } catch (error) { + if (!unreadable(error)) throw error + return `unreadable:${stat.mode}:${stat.size}:${stat.mtimeMs}` + } +} + +function sameMap(a: ReadonlyMap, b: ReadonlyMap): boolean { + return a.size === b.size && [...a].every(([k, v]) => b.get(k) === v) +} + +/** A path under `specs/` as the root spells it (`openspec/specs//spec.md`). */ +function asRoot(rel: string): string { + return ['openspec', 'specs', ...rel.split(sep)].join('/') +} + +/** Write `bytes` to `path` through a sibling temp file, keeping an existing file's mode. */ +function writeAtomically(path: string, bytes: Buffer): void { + mkdirSync(dirname(path), { recursive: true }) + const tmp = `${path}.cospec-tmp` + writeFileSync(tmp, bytes) + if (existsSync(path)) chmodSync(tmp, statSync(path).mode & 0o7777) + renameSync(tmp, path) +} + +/** The fixed text that says the wrapped archive did not complete. */ +const ABORTED_RE = /\bAborted\b/ +const CANCELLED_RE = /\bArchive cancelled\b/ + +/** + * Whether the run completed in the scratch tree: exit 0, no abort, the change + * gone from `changes/`, and exactly one new archive entry holding its + * `.openspec.yaml`. That proves the binary resolved the scratch root — the + * real change is untouched — and finished its archive. + */ +function scratchArchived(scratchOpenspec: string, changeId: string, run: ScratchRun): boolean { + if (run.exitCode !== 0 || ABORTED_RE.test(run.stdout) || CANCELLED_RE.test(run.stdout)) + return false + if (existsSync(join(scratchOpenspec, 'changes', changeId))) return false + const entries = readdirSync(join(scratchOpenspec, 'changes/archive')) + return ( + entries.length === 1 && + existsSync(join(scratchOpenspec, 'changes/archive', entries[0]!, '.openspec.yaml')) + ) +} + +/** + * Sync `changeId`'s delta specs into `base`'s `openspec/specs/` through a + * scratch run of `runner`. Throws `ScratchRefusal` — with nothing written to + * the real tree — when a link would let the run escape, when the run did not + * archive the scratch copy, or when the real main specs changed while it ran. + */ +export async function syncThroughScratch( + base: string, + changeId: string, + runner: ScratchRunner, +): Promise { + const openspec = join(base, 'openspec') + const realSpecs = join(openspec, 'specs') + const escape = symlinkEscape(base, changeId) + if (escape !== undefined) + throw new ScratchRefusal( + 'symlink-escape', + `${escape} is a symbolic link that leads outside the tree sync-specs copies; the wrapped archive would write through it into the real tree`, + ) + + const realBefore = fingerprint(realSpecs) + const scratch = mkdtempSync(join(tmpdir(), 'cospec-sync-')) + try { + const scratchOpenspec = join(scratch, 'openspec') + mkdirSync(join(scratchOpenspec, 'changes/archive'), { recursive: true }) + copyRoots(copiedRoots(openspec, changeId), scratchOpenspec) + mkdirSync(join(scratchOpenspec, 'specs'), { recursive: true }) + const scratchSpecs = join(scratchOpenspec, 'specs') + + const scratchBefore = fingerprint(scratchSpecs) + const run = await runner(scratch) + if (!scratchArchived(scratchOpenspec, changeId, run)) + throw new ScratchRefusal('scratch-run', relayedReason(`${run.stdout}\n${run.stderr}`), run) + + // Every entry kind is diffed: a link the run removed (a retired capability + // whose `spec.md` is a link) is deleted like a file, and one it replaced + // with a file is written. The binary never creates or re-points a link, + // nor touches what it cannot read, so such a change is a breach, thrown + // before anything is copied back. + const scratchAfter = fingerprint(scratchSpecs) + const written: string[] = [] + for (const [rel, kind] of scratchAfter) { + const before = scratchBefore.get(rel) + if (before === kind || (kind === 'dir' && before === undefined)) continue + if (!kind.startsWith('file:')) + throw new Error( + `sync-specs: the scratch run left ${asRoot(rel)} as ${describeKind(kind)}${before === undefined ? '' : ` where it was ${describeKind(before)}`}; nothing was written`, + ) + written.push(rel) + } + written.sort() + const gone = [...scratchBefore].filter(([rel]) => !scratchAfter.has(rel)) + const deleted = gone + .filter(([, kind]) => kind !== 'dir') + .map(([rel]) => rel) + .toSorted() + const pruned = gone + .filter(([, kind]) => kind === 'dir') + .map(([rel]) => rel) + // Deepest first, so a parent is empty when its turn comes. + .toSorted((a, b) => b.length - a.length) + + if (!sameMap(realBefore, fingerprint(realSpecs))) + throw new ScratchRefusal( + 'specs-changed', + 'the main specs changed while sync ran; nothing was written', + run, + ) + + for (const rel of written) + writeAtomically(join(realSpecs, rel), readFileSync(join(scratchSpecs, rel))) + for (const rel of deleted) unlinkSync(join(realSpecs, rel)) + for (const rel of pruned) { + const dir = join(realSpecs, rel) + if (existsSync(dir) && listable(dir)?.length === 0) rmdirSync(dir) + } + + // Step 5: what landed is exactly what the run wrote, and nothing else moved. + const realAfter = fingerprint(realSpecs) + for (const [rel, kind] of realBefore) + if (!written.includes(rel) && !deleted.includes(rel) && !pruned.includes(rel)) + if (realAfter.get(rel) !== kind) + throw new Error(`sync-specs: ${rel} changed although the scratch run did not touch it`) + for (const rel of written) + if (realAfter.get(rel) !== scratchAfter.get(rel)) + throw new Error(`sync-specs: ${rel} does not hold the bytes the scratch run wrote`) + for (const rel of deleted) + if (realAfter.has(rel)) throw new Error(`sync-specs: ${rel} is still present`) + + return { written: written.map(asRoot), deleted: deleted.map(asRoot), run } + } finally { + rmSync(scratch, { recursive: true, force: true }) + } +} + +/** A fingerprint kind, in words. */ +function describeKind(kind: string): string { + if (kind.startsWith('file:')) return 'a file' + if (kind.startsWith('link:')) return `a link to ${kind.slice('link:'.length)}` + if (kind === 'dir') return 'a directory' + if (kind.startsWith('unreadable:')) return 'an unreadable file' + if (kind.startsWith('unlisted:')) return 'an unlistable directory' + return 'a special file' +} diff --git a/apps/cli/test/contract/archive-no-validate.test.ts b/apps/cli/test/contract/archive-no-validate.test.ts new file mode 100644 index 00000000..7a63aa30 --- /dev/null +++ b/apps/cli/test/contract/archive-no-validate.test.ts @@ -0,0 +1,775 @@ +// archive-and-sync-parity: `cospec archive` beside the pinned binary's own +// archive — `--no-validate`, the JSON documents, the scenario-preservation +// gate on the verbatim view, an unreadable archive directory, namespace +// folders, REMOVED on a new capability, the Specs line and relayed remedies. +// Every expectation about upstream is read from the binary at test time. + +import { afterAll, describe, expect, test } from 'bun:test' +import { existsSync, readdirSync, readFileSync, realpathSync, renameSync } from 'node:fs' +import { join, relative } from 'node:path' + +import { formatLocalDate } from '../../src/commands/archive.ts' +import { COMMAND_TABLE } from '../../src/core/command-table.ts' +import { respellRemedies } from '../../src/core/remedies.ts' +import { errnoShape } from '../fixtures/errno.ts' +import { + cleanupAll, + cospec, + hashTree, + mkTempRepo, + openspec, + writeFiles, +} from '../fixtures/support.ts' +import { + R7_ARCHIVE_UNREADABLE, + R7_BARE_VERIFICATION, + R7_CHORE, + R7_COMMENT_KEPT, + R7_COMMENTED_LIVING_HEADER, + R7_COMMENTED_LIVING_SCENARIO, + R7_DELTA_INVALID, + R7_FIXTURES, + R7_INCOMPLETE_TASK, + R7_MODIFIED, + R7_NAMESPACE, + R7_NEW_ADDED_REMOVED, + R7_NEW_MODIFIED, + R7_NEW_REMOVED_ONLY, + R7_NEW_REMOVED_ONLY_MARKED, + R7_NEW_RENAMED, + R7_NO_DELTA, + R7_RETIRED, + R7_REVALIDATION_ONLY, + R7_SCENARIO_DROP, + R7_SHORT_PURPOSE, + R7_SYNCED_MODIFIED, + R7_SYNCED_SHAPES, + R7_UNRELATED_UNREADABLE, + restoreArchiveMode, + restoreUnrelatedMode, + type R7Fixture, +} from './fixtures.ts' +import { byKey, checkNativeKeys, compareDocuments, type OracleSpec } from './support/key-oracle.ts' +import { oracle, oracleEnv } from './support/upstream-oracle.ts' + +afterAll(cleanupAll) + +/** Build `fixture` in a fresh repo, run `fn`, and leave the tree removable. */ +async function withFixture( + fixture: R7Fixture, + fn: (root: string, name: string) => Promise, +): Promise { + const root = mkTempRepo({ git: true }) + const name = fixture.build(root) + try { + return await fn(root, name) + } finally { + if (fixture.key === 'archive-unreadable') restoreArchiveMode(root) + } +} + +describe('fixtures: each builder reads as both validators are told it does', () => { + for (const fixture of R7_FIXTURES) { + test(`${fixture.key}: openspec validate --strict`, () => + withFixture(fixture, async (root, name) => { + const res = await openspec(['validate', name, '--strict'], root) + expect({ valid: res.exitCode === 0, out: res.stdout + res.stderr }).toEqual({ + valid: fixture.binaryValid, + out: expect.any(String), + }) + })) + test(`${fixture.key}: cospec validate --strict`, () => + withFixture(fixture, async (root, name) => { + const res = await cospec(['validate', name, '--strict'], { cwd: root }) + expect({ valid: res.exitCode === 0, out: res.stdout + res.stderr }).toEqual({ + valid: fixture.cospecValid, + out: expect.any(String), + }) + })) + } +}) + +// --- harness ------------------------------------------------------------------- + +interface Run { + exitCode: number + stdout: string + stderr: string +} + +/** Every output a row captured from `cospec archive`, for row 8.2. */ +const CAPTURED: { row: string; text: string }[] = [] + +/** `cospec ` in `root`, under the sandbox env the oracle's child sees. */ +async function own(row: string, root: string, args: string[]): Promise { + const res = await cospec(args, { cwd: root, env: oracleEnv(root) }) + // Row 8.2 sweeps what `archive` itself printed, not the other commands a row runs. + if (args[0] === 'archive') CAPTURED.push({ row, text: `${res.stdout}\n${res.stderr}` }) + return res +} + +/** The pinned binary with `args`, in `root`. */ +function binary(root: string, args: string[]): Promise { + return oracle(args, root) +} + +/** `fixture` built twice: `root` for cospec, `copy` for the binary. */ +function twin(fixture: R7Fixture): { root: string; copy: string; name: string } { + const root = mkTempRepo({ git: true }) + const copy = mkTempRepo({ git: true }) + const name = fixture.build(root) + fixture.build(copy) + return { root, copy, name } +} + +/** The one JSON document `stdout` must be. */ +function document(stdout: string): Record { + return JSON.parse(stdout) as Record +} + +const specsOf = (root: string): Record => + existsSync(join(root, 'openspec/specs')) ? hashTree(join(root, 'openspec/specs')) : {} + +const changeDir = (root: string, name: string): string => join(root, 'openspec/changes', name) + +const archived = (root: string, name: string): boolean => !existsSync(changeDir(root, name)) + +/** Every `.openspec-archive.lock` under `root`. */ +function locks(root: string): string[] { + const out: string[] = [] + const walk = (dir: string): void => { + let entries + try { + entries = readdirSync(dir, { withFileTypes: true }) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EACCES') return + throw error + } + for (const entry of entries) { + const child = join(dir, entry.name) + if (entry.name === '.openspec-archive.lock') out.push(relative(root, child)) + if (entry.isDirectory() && entry.name !== '.git' && entry.name !== '.oracle-home') walk(child) + } + } + walk(root) + return out +} + +/** `message` with `root`'s canonical path written ``. */ +const underRoot = (root: string, message: string): string => + message.replaceAll(realpathSync(root), '') + +const slotFor = (name: string): string => `${formatLocalDate()}-${name}` + +/** The banner `--no-validate` prints before its first gate. */ +function expectBanner(stderr: string): void { + expect(stderr).toContain('--no-validate skips revalidation') + expect(stderr).toContain('archive/verification-incomplete') + expect(stderr).toContain('archive/scenario-preservation') +} + +/** cospec's archive keys before this change, and their values for a fixture. */ +const COSPEC_KEYS = [ + 'change', + 'type', + 'archived', + 'target', + 'specs', + 'retired', + 'warnings', + 'blockers', +] as const + +function nativeSuccess( + name: string, + specs: Record | 'skipped', + warnings: string[] = [], +): Record { + return { + change: name, + type: 'feat', + archived: true, + target: slotFor(name), + specs, + retired: [], + warnings, + blockers: { checkedOff: [], nowUnblocked: [] }, + } +} + +/** A failure document's `status[]`, paired by code. */ +const STATUS = { 'status[]': { upstream: byKey('code'), cospec: byKey('code') } } as const + +const roots = (copy: string, root: string): OracleSpec['paths'] => ({ + keys: ['archive.path', 'root.path'], + upstreamRoot: realpathSync(copy), + cospecRoot: realpathSync(root), +}) + +// --- 1. --no-validate skips only revalidation ---------------------------------- + +describe('1. archive --no-validate skips only revalidation', () => { + test('1.1 the pending entry is gone and the flag is handled in the command table', () => { + const pendingYaml = readFileSync(join(import.meta.dir, 'parity-pending.yaml'), 'utf8') + expect(pendingYaml.match(/owner: archive-and-sync-parity/g) ?? []).toEqual([]) + const flag = COMMAND_TABLE.find((r) => r.name === 'archive')?.flags.find( + (f) => f.name === '--no-validate', + ) + expect(flag).toBeDefined() + expect(JSON.stringify(flag!.status)).not.toContain('pending') + }) + + test('1.2 a bare [ ] verification row is still refused', async () => { + const root = mkTempRepo({ git: true }) + const name = R7_BARE_VERIFICATION.build(root) + const res = await own('1.2', root, ['archive', name, '--no-validate']) + expect(res.exitCode).toBe(1) + expect(res.stderr).toContain('verification.md is not fully resolved') + expectBanner(res.stderr) + expect(archived(root, name)).toBe(false) + }) + + test('1.3 a scenario-dropping MODIFIED is still refused, as the binary refuses it', async () => { + const { root, copy, name } = twin(R7_SCENARIO_DROP) + const before = specsOf(root) + const res = await own('1.3', root, ['archive', name, '--no-validate']) + expect(res.exitCode).toBe(1) + expect(res.stderr).toContain('scenario-preservation gate refused') + expectBanner(res.stderr) + expect(archived(root, name)).toBe(false) + expect(specsOf(root)).toEqual(before) + // Recorded beside it: the binary under the flag refuses the merge too. + const up = await binary(copy, ['archive', name, '-y', '--no-validate']) + expect({ exit: up.exitCode, archived: archived(copy, name) }).toEqual({ + exit: 1, + archived: false, + }) + }) + + test('1.4 an incomplete task is still refused', async () => { + const root = mkTempRepo({ git: true }) + const name = R7_INCOMPLETE_TASK.build(root) + const res = await own('1.4', root, ['archive', name, '--no-validate']) + expect(res.exitCode).toBe(1) + expect(res.stderr).toContain('incomplete task(s) — refusing to archive') + expectBanner(res.stderr) + expect(archived(root, name)).toBe(false) + }) + + test('1.5 a change only revalidation refuses archives, as the binary archives it', async () => { + const { root, copy, name } = twin(R7_REVALIDATION_ONLY) + const res = await own('1.5', root, ['archive', name, '--no-validate']) + const up = await binary(copy, ['archive', name, '-y', '--no-validate']) + expect([res.exitCode, up.exitCode]).toEqual([0, 0]) + expect([archived(root, name), archived(copy, name)]).toEqual([true, true]) + expect(specsOf(root)).toEqual(specsOf(copy)) + expectBanner(res.stderr) + }) + + test('1.6 --json prints one document on stdout and the banner on stderr only', async () => { + const root = mkTempRepo({ git: true }) + const name = R7_MODIFIED.build(root) + const res = await own('1.6', root, ['archive', name, '--no-validate', '--json']) + expect(res.exitCode).toBe(0) + expect(document(res.stdout).archived).toBe(true) + expect(res.stdout).not.toContain('revalidation') + expectBanner(res.stderr) + }) + + test('1.7 an unrelated unreadable living spec: archived as the binary archives it, one document', async () => { + const { root, copy, name } = twin(R7_UNRELATED_UNREADABLE) + try { + const res = await own('1.7', root, ['archive', name, '--no-validate', '--json']) + const up = await binary(copy, ['archive', name, '-y', '--no-validate', '--json']) + expect([res.exitCode, up.exitCode]).toEqual([0, 0]) + expect(document(res.stdout)).toMatchObject({ archived: true }) + expect([archived(root, name), archived(copy, name)]).toEqual([true, true]) + restoreUnrelatedMode(root) + restoreUnrelatedMode(copy) + expect(specsOf(root)).toEqual(specsOf(copy)) + } finally { + restoreUnrelatedMode(root) + restoreUnrelatedMode(copy) + } + }) + + test('1.7 without --no-validate: one document, exit as the revalidation reads the tree', async () => { + const root = mkTempRepo({ git: true }) + const name = R7_UNRELATED_UNREADABLE.build(root) + try { + const res = await own('1.7', root, ['archive', name, '--json']) + const doc = document(res.stdout) + expect(doc).toMatchObject({ change: name, archived: res.exitCode === 0 }) + } finally { + restoreUnrelatedMode(root) + } + }) +}) + +// --- 2. archive's JSON documents carry the binary's keys ------------------------ + +describe("2. archive's JSON documents carry the binary's keys", () => { + test('2.1 a MODIFIED archive: archive and root as the binary reports them', async () => { + const { root, copy, name } = twin(R7_MODIFIED) + const res = await own('2.1', root, ['archive', name, '--json']) + const up = await binary(copy, ['archive', name, '-y', '--json']) + expect([res.exitCode, up.exitCode]).toEqual([0, 0]) + const cs = document(res.stdout) + const upDoc = document(up.stdout) + expect(compareDocuments(upDoc, cs, { paths: roots(copy, root) }).failures).toEqual([]) + const native = nativeSuccess(name, { added: 0, modified: 1, removed: 0, renamed: 0 }) + expect(checkNativeKeys(cs, native, COSPEC_KEYS)).toEqual([]) + }) + + test('2.2 --skip-specs: no totals, specsUpdated false, as the binary reports', async () => { + const { root, copy, name } = twin(R7_MODIFIED) + const res = await own('2.2', root, ['archive', name, '--skip-specs', '--json']) + const up = await binary(copy, ['archive', name, '-y', '--skip-specs', '--json']) + expect([res.exitCode, up.exitCode]).toEqual([0, 0]) + const cs = document(res.stdout) + const upDoc = document(up.stdout) + expect((upDoc.archive as Record).totals).toBeUndefined() + expect(compareDocuments(upDoc, cs, { paths: roots(copy, root) }).failures).toEqual([]) + expect((cs.archive as Record).totals).toBeUndefined() + expect(checkNativeKeys(cs, nativeSuccess(name, 'skipped'), COSPEC_KEYS)).toEqual([]) + }) + + test('2.2 an already-synced change: zero totals, specsUpdated false, as the binary reports', async () => { + const { root, copy, name } = twin(R7_SYNCED_MODIFIED) + const res = await own('2.2', root, ['archive', name, '--json']) + const up = await binary(copy, ['archive', name, '-y', '--json']) + expect([res.exitCode, up.exitCode]).toEqual([0, 0]) + const upDoc = document(up.stdout) + expect((upDoc.archive as Record).specsUpdated).toBe(false) + expect( + compareDocuments(upDoc, document(res.stdout), { paths: roots(copy, root) }).failures, + ).toEqual([]) + }) + + test("2.2 a merge that warns: archive.warnings is the binary's", async () => { + const { root, copy, name } = twin(R7_NEW_ADDED_REMOVED) + const res = await own('2.2', root, ['archive', name, '--json']) + const up = await binary(copy, ['archive', name, '-y', '--json']) + expect([res.exitCode, up.exitCode]).toEqual([0, 0]) + const upDoc = document(up.stdout) + expect((upDoc.archive as Record).warnings).toEqual([ + expect.stringContaining('nothing to remove'), + ]) + expect( + compareDocuments(upDoc, document(res.stdout), { paths: roots(copy, root) }).failures, + ).toEqual([]) + }) + + test("2.2 a retirement: archive.warnings carries the binary's retirement note", async () => { + const { root, copy, name } = twin(R7_RETIRED) + const res = await own('2.2', root, ['archive', name, '--json']) + const up = await binary(copy, ['archive', name, '-y', '--json']) + expect([res.exitCode, up.exitCode]).toEqual([0, 0]) + const upDoc = document(up.stdout) + expect((upDoc.archive as Record).warnings).toEqual([ + expect.stringContaining('capability retired'), + ]) + expect( + compareDocuments(upDoc, document(res.stdout), { paths: roots(copy, root) }).failures, + ).toEqual([]) + }) + + /** One failure row: cospec's document beside the binary's for the same refusal. */ + async function failureRow( + row: string, + root: string, + copy: string, + args: string[], + binaryArgs: string[], + collisions: string[] = [], + ): Promise> { + const res = await own(row, root, args) + const up = await binary(copy, binaryArgs) + expect([res.exitCode, up.exitCode]).toEqual([1, 1]) + const cs = document(res.stdout) + const upDoc = document(up.stdout) + expect(upDoc.archive).toBeNull() + expect(cs.archive).toBeNull() + expect(cs.archived).toBe(false) + expect(typeof cs.reason).toBe('string') + const spec: OracleSpec = { + paths: { + keys: ['root.path'], + upstreamRoot: realpathSync(copy), + cospecRoot: realpathSync(root), + }, + identities: STATUS, + respelled: ['status[].fix'], + collisions, + } + expect(compareDocuments(upDoc, cs, spec).failures).toEqual([]) + return cs + } + + test('2.3 unknown change', async () => { + const { root, copy } = twin(R7_MODIFIED) + const cs = await failureRow( + '2.3', + root, + copy, + ['archive', 'nope', '--json'], + ['archive', 'nope', '--json'], + ) + expect((cs.status as { code: string }[])[0]!.code).toBe('archive_change_not_found') + }) + + test('2.3 invalid change name', async () => { + const { root, copy } = twin(R7_MODIFIED) + const cs = await failureRow( + '2.3', + root, + copy, + ['archive', 'a/b', '--json'], + ['archive', 'a/b', '--json'], + ) + expect((cs.status as { code: string }[])[0]!.code).toBe('archive_change_name_invalid') + }) + + test('2.3 namespace folder', async () => { + const { root, copy, name } = twin(R7_NAMESPACE) + const cs = await failureRow( + '2.3', + root, + copy, + ['archive', name, '--json'], + ['archive', name, '--json'], + ) + expect((cs.status as { code: string }[])[0]!.code).toBe('archive_change_is_namespace_folder') + }) + + test('2.3 revalidation failure', async () => { + const { root, copy, name } = twin(R7_DELTA_INVALID) + const cs = await failureRow( + '2.3', + root, + copy, + ['archive', name, '--json'], + ['archive', name, '--json'], + ) + expect((cs.status as { code: string }[])[0]!.code).toBe('archive_validation_failed') + // The revalidation document keeps every key its report carried. + for (const key of ['version', 'items', 'summary']) expect(cs).toHaveProperty(key) + }) + + test('2.3 incomplete tasks (the binary without -y)', async () => { + const { root, copy, name } = twin(R7_INCOMPLETE_TASK) + const cs = await failureRow( + '2.3', + root, + copy, + ['archive', name, '--json'], + ['archive', name, '--json'], + ['status[].fix'], + ) + expect((cs.status as { code: string }[])[0]!.code).toBe('archive_tasks_incomplete') + }) + + test('2.3 taken archive slot', async () => { + const { root, copy, name } = twin(R7_MODIFIED) + for (const r of [root, copy]) + writeFiles(r, { + [`openspec/changes/archive/${slotFor(name)}/.openspec.yaml`]: + 'schema: feat\ncreated: 2026-10-05\nschemaVersion: 2\n', + }) + const cs = await failureRow( + '2.3', + root, + copy, + ['archive', name, '--json'], + ['archive', name, '--json'], + ) + expect((cs.status as { code: string }[])[0]!.code).toBe('archive_target_exists') + }) + + test('2.3 scenario-dropping MODIFIED (the binary with -y)', async () => { + const { root, copy, name } = twin(R7_SCENARIO_DROP) + // cospec's revalidation runs the advisory scenario rule at WARNING; the + // hard gate is what refuses, so the change passes revalidation first. + const cs = await failureRow( + '2.3', + root, + copy, + ['archive', name, '--json'], + ['archive', name, '-y', '--json'], + ) + expect((cs.status as { code: string }[])[0]!.code).toBe('archive_spec_update_failed') + expect(cs.reason).toBe('archive/scenario-preservation') + }) + + test('2.4 a bare [ ] verification row is one cospec-only document', async () => { + const root = mkTempRepo({ git: true }) + const name = R7_BARE_VERIFICATION.build(root) + const res = await own('2.4', root, ['archive', name, '--json']) + expect(res.exitCode).toBe(1) + const cs = document(res.stdout) + expect(cs.archive).toBeNull() + expect(cs.reason).toBe('archive/verification-incomplete') + expect((cs.status as { code: string }[])[0]!.code).toBe('archive_verification_incomplete') + }) + + test("2.6 an unknown --store is the resolver document with archive's payload", async () => { + const { root, copy, name } = twin(R7_MODIFIED) + const res = await own('2.6', root, ['archive', name, '--json', '--store', 'nope']) + const up = await binary(copy, ['archive', name, '--json', '--store', 'nope']) + expect([res.exitCode, up.exitCode]).toEqual([1, 1]) + const upDoc = document(up.stdout) + expect(upDoc).toEqual({ archive: null, status: expect.any(Array) }) + // The message is cospec's own resolver wording, which every command shares + // (`core/root.ts`); the code, target and fix are the binary's. + const spec: OracleSpec = { + identities: STATUS, + respelled: ['status[].fix'], + verdict: ['status[].message'], + } + expect(compareDocuments(upDoc, document(res.stdout), spec).failures).toEqual([]) + }) +}) + +// --- 3. the scenario-preservation gate reads the verbatim view ------------------- + +describe('3. the scenario-preservation gate reads the verbatim view', () => { + test('3.1 a scenario kept only inside a comment: validate, cospec archive and the binary accept', async () => { + const { root, copy, name } = twin(R7_COMMENT_KEPT) + const validated = await own('3.1', root, ['validate', name, '--strict']) + expect(validated.exitCode).toBe(0) + const res = await own('3.1', root, ['archive', name]) + const up = await binary(copy, ['archive', name, '-y']) + expect([res.exitCode, up.exitCode]).toEqual([0, 0]) + expect([archived(root, name), archived(copy, name)]).toEqual([true, true]) + expect(specsOf(root)).toEqual(specsOf(copy)) + }) + + test('3.2 a commented living scenario the delta omits: both archives refuse', async () => { + const { root, copy, name } = twin(R7_COMMENTED_LIVING_SCENARIO) + const before = specsOf(root) + const res = await own('3.2', root, ['archive', name]) + const up = await binary(copy, ['archive', name, '-y']) + expect([res.exitCode, up.exitCode]).toEqual([1, 1]) + expect([archived(root, name), archived(copy, name)]).toEqual([false, false]) + expect([specsOf(root), specsOf(copy)]).toEqual([before, before]) + expect(res.stderr).toContain('scenario-preservation gate refused') + expect(res.stderr).toContain('"Render a hidden widget"') + }) + + test('3.3 a commented living requirement header: both archive, byte-identical', async () => { + const { root, copy, name } = twin(R7_COMMENTED_LIVING_HEADER) + const res = await own('3.3', root, ['archive', name]) + const up = await binary(copy, ['archive', name, '-y']) + expect([res.exitCode, up.exitCode]).toEqual([0, 0]) + expect(specsOf(root)).toEqual(specsOf(copy)) + }) +}) + +// --- 4. an unreadable archive directory is one answer --------------------------- + +describe('4. an unreadable archive directory is one answer', () => { + test("4.1 mode 000: cospec's code is the binary's on this runtime, text and --json", async () => { + const { root, copy, name } = twin(R7_ARCHIVE_UNREADABLE) + try { + const res = await own('4.1', root, ['archive', name, '--json']) + const text = await own('4.1', root, ['archive', name]) + const up = await binary(copy, ['archive', name, '-y', '--json']) + expect([res.exitCode, text.exitCode, up.exitCode]).toEqual([1, 1, 1]) + const upStatus = (document(up.stdout).status as { code: string; message: string }[])[0]! + const csStatus = (document(res.stdout).status as { code: string; message: string }[])[0]! + expect(csStatus.code).toBe(upStatus.code) + if (upStatus.code === 'archive_error') { + const upErr = errnoShape(underRoot(copy, upStatus.message)) + const csErr = errnoShape(underRoot(root, csStatus.message)) + expect(csErr).toEqual(upErr) + } else expect(underRoot(root, csStatus.message)).toBe(underRoot(copy, upStatus.message)) + expect(text.stdout).toBe('') + // Where the binary's first step refuses (macOS), nothing else ran: one + // line. Where its slot check refuses (Linux), cospec's degraded archive + // read warned first, naming the directory. + const lines = text.stderr.trim().split('\n') + expect(lines).toHaveLength(upStatus.code === 'archive_path_outside_root' ? 1 : 2) + expect(lines.at(-1)).toBe(`cospec archive: ${csStatus.message}`) + expect(text.stderr).not.toContain(' at ') + expect([archived(root, name), archived(copy, name)]).toEqual([false, false]) + } finally { + restoreArchiveMode(root) + restoreArchiveMode(copy) + } + expect([locks(root), locks(copy)]).toEqual([[], []]) + }) +}) + +// --- 5. a namespace folder ------------------------------------------------------ + +describe('5. archive refuses a namespace folder as the binary does', () => { + test("5.1 text and --json: the binary's message and fix, nothing moved", async () => { + const { root, copy, name } = twin(R7_NAMESPACE) + const text = await own('5.1', root, ['archive', name]) + const res = await own('5.1', root, ['archive', name, '--json']) + const up = await binary(copy, ['archive', name, '-y', '--json']) + expect([text.exitCode, res.exitCode, up.exitCode]).toEqual([1, 1, 1]) + const upStatus = (document(up.stdout).status as Record[])[0]! + const csStatus = (document(res.stdout).status as Record[])[0]! + expect(csStatus).toEqual(upStatus) + expect(upStatus.code).toBe('archive_change_is_namespace_folder') + expect(text.stderr).toContain(`${upStatus.message}\n`) + expect(text.stderr).toContain(upStatus.fix!) + expect(text.stderr).toStartWith("cospec archive: Cannot archive 'mobile': ") + expect(`${text.stdout}${text.stderr}${res.stdout}`).not.toContain('meta/nested-change') + expect(existsSync(join(changeDir(root, name), 'refresh/proposal.md'))).toBe(true) + }) +}) + +// --- 6. a new capability refuses only MODIFIED and RENAMED ------------------------ + +interface Report { + items: { issues: { rule: string; level: string }[] }[] +} + +async function ownRules(row: string, root: string, name: string): Promise { + const res = await own(row, root, ['validate', name, '--strict', '--json']) + return (document(res.stdout) as unknown as Report).items.flatMap((i) => + i.issues.filter((x) => x.level === 'ERROR').map((x) => x.rule), + ) +} + +describe('6. a new capability refuses only MODIFIED and RENAMED', () => { + test('6.1 ADDED + REMOVED: both validate, both archive byte-identical, the warning relayed', async () => { + const { root, copy, name } = twin(R7_NEW_ADDED_REMOVED) + expect(await ownRules('6.1', root, name)).toEqual([]) + expect((await binary(copy, ['validate', name, '--strict'])).exitCode).toBe(0) + const res = await own('6.1', root, ['archive', name]) + const up = await binary(copy, ['archive', name, '-y']) + expect([res.exitCode, up.exitCode]).toEqual([0, 0]) + expect(specsOf(root)).toEqual(specsOf(copy)) + expect(res.stdout).toContain( + 'Warning: widgets - 1 REMOVED requirement(s) ignored for new spec (nothing to remove).', + ) + }) + + test('6.2 REMOVED-only under the marker: validate passes, archive reports in sync', async () => { + const { root, copy, name } = twin(R7_NEW_REMOVED_ONLY_MARKED) + expect(await ownRules('6.2', root, name)).toEqual([]) + const res = await own('6.2', root, ['archive', name]) + const up = await binary(copy, ['archive', name, '-y']) + expect([res.exitCode, up.exitCode]).toEqual([0, 0]) + expect(up.stdout).toContain('Specs already in sync; no files changed.') + expect(res.stdout).toContain('Specs: already in sync') + }) + + test('6.3 REMOVED-only without the marker: rebuilt-spec-invalid, refused before delegating', async () => { + const { root, copy, name } = twin(R7_NEW_REMOVED_ONLY) + const rules = await ownRules('6.3', root, name) + expect(rules).toContain('archive/rebuilt-spec-invalid') + expect(rules).not.toContain('archive/new-spec-non-added') + const res = await own('6.3', root, ['archive', name]) + expect(res.exitCode).toBe(1) + expect(res.stdout).toContain('archive/rebuilt-spec-invalid') + expect(archived(root, name)).toBe(false) + const up = await binary(copy, ['archive', name, '-y']) + expect({ exit: up.exitCode, archived: archived(copy, name) }).toEqual({ + exit: 1, + archived: false, + }) + }) + + for (const fixture of [R7_NEW_MODIFIED, R7_NEW_RENAMED]) + test(`6.4 ${fixture.key}: archive/new-spec-non-added is still an ERROR`, async () => { + const root = mkTempRepo({ git: true }) + const name = fixture.build(root) + expect(await ownRules('6.4', root, name)).toContain('archive/new-spec-non-added') + }) +}) + +// --- 7. the Specs line and early-synced archives --------------------------------- + +describe('7. the Specs line and early-synced archives', () => { + const cases: { + label: string + fixture: R7Fixture + args: string[] + line: string + reason: string + }[] = [ + { + label: 'a feat change with no delta files', + fixture: R7_NO_DELTA, + args: [], + line: 'Specs: none (no delta specs, so no spec sync)', + reason: 'no-deltas', + }, + { + label: '--skip-specs', + fixture: R7_MODIFIED, + args: ['--skip-specs'], + line: 'Specs: skipped (--skip-specs)', + reason: 'flag', + }, + { + label: 'a chore change', + fixture: R7_CHORE, + args: [], + line: 'Specs: none (the chore schema has no specs artifact)', + reason: 'schema', + }, + ] + for (const c of cases) + test(`7.1 ${c.label}: the Specs line and specsSkipReason name the reason`, async () => { + const text = mkTempRepo({ git: true }) + const json = mkTempRepo({ git: true }) + const name = c.fixture.build(text) + c.fixture.build(json) + const t = await own('7.1', text, ['archive', name, ...c.args]) + expect(t.exitCode).toBe(0) + expect(t.stdout).toContain(`${c.line}\n`) + const j = await own('7.1', json, ['archive', name, ...c.args, '--json']) + expect(j.exitCode).toBe(0) + const doc = document(j.stdout) + expect([doc.specs, doc.specsSkipReason]).toEqual(['skipped', c.reason]) + }) + + for (const fixture of R7_SYNCED_SHAPES) + test(`7.2 ${fixture.key}: both archive, the binary in sync, Specs: already in sync`, async () => { + const { root, copy, name } = twin(fixture) + const res = await own('7.2', root, ['archive', name]) + const up = await binary(copy, ['archive', name, '-y']) + expect([res.exitCode, up.exitCode]).toEqual([0, 0]) + expect(up.stdout).toContain('Specs already in sync; no files changed.') + expect(res.stdout).toContain('Specs: already in sync\n') + expect(res.stderr).not.toContain('invariant breach') + expect(specsOf(root)).toEqual(specsOf(copy)) + }) +}) + +// --- 8. relayed archive output is spelled cospec --------------------------------- + +describe('8. relayed archive output is spelled cospec', () => { + test('8.1 the carried-Purpose warning names cospec validate', async () => { + const root = mkTempRepo({ git: true }) + const name = R7_SHORT_PURPOSE.build(root) + const res = await own('8.1', root, ['archive', name]) + expect(res.exitCode).toBe(0) + expect(res.stdout).toMatch( + /Warning: {2}widgets - carried Purpose is under \d+ characters; cospec validate --strict reports it as too brief\.\n/, + ) + }) + + test('8.2 a change name containing openspec passes through byte-for-byte', async () => { + const root = mkTempRepo({ git: true }) + const built = R7_MODIFIED.build(root) + renameSync(changeDir(root, built), changeDir(root, 'openspec-widgets')) + const res = await own('8.2', root, ['archive', 'openspec-widgetz', '--json']) + expect(res.exitCode).toBe(1) + expect(res.stdout + res.stderr).toContain("'openspec-widgetz'") + }) + + // Runs last: every output the rows above captured. + test('8.2 no captured output names a bare allowlisted openspec command', () => { + expect(CAPTURED.length).toBeGreaterThan(20) + const bare = CAPTURED.filter(({ text }) => respellRemedies(text) !== text).map(({ row }) => row) + expect(bare).toEqual([]) + // A row capturing the purpose warning must exist for this to mean anything. + expect(CAPTURED.some(({ row }) => row === '8.1')).toBe(true) + }) +}) diff --git a/apps/cli/test/contract/cli-surface.test.ts b/apps/cli/test/contract/cli-surface.test.ts index b1a9c684..bb0b071b 100644 --- a/apps/cli/test/contract/cli-surface.test.ts +++ b/apps/cli/test/contract/cli-surface.test.ts @@ -442,7 +442,7 @@ describe('the key oracle', () => { expect(compareDocuments(up, broken, vspec).failures).toEqual([ expect.stringContaining('items[].type: named collision broken'), ]) - expect(NAMED_COLLISIONS.map((c) => c.path)).toEqual(['version', 'items[].type']) + expect(NAMED_COLLISIONS.map((c) => c.path)).toEqual(['version', 'items[].type', 'status[].fix']) }) test('a respelled path must carry the binary value spelled through cospec', () => { diff --git a/apps/cli/test/contract/fixtures.ts b/apps/cli/test/contract/fixtures.ts index 3ed7bc9a..08ec8380 100644 --- a/apps/cli/test/contract/fixtures.ts +++ b/apps/cli/test/contract/fixtures.ts @@ -2,10 +2,18 @@ // tree into a temp repo so the SAME change can be handed to both `cospec` and the // real `openspec` binary for parity/gotcha comparison (DESIGN §8.2). -import { mkdirSync, writeFileSync } from 'node:fs' -import { dirname, join } from 'node:path' - -import { writeFiles } from '../fixtures/support.ts' +import { + chmodSync, + cpSync, + mkdirSync, + rmSync, + symlinkSync, + unlinkSync, + writeFileSync, +} from 'node:fs' +import { dirname, join, relative } from 'node:path' + +import { REPO_ROOT, writeFiles } from '../fixtures/support.ts' /** Full-variant proposal that clears feat's proposal/sections + why-substantive. */ const PROPOSAL = `# ${'change'} @@ -632,10 +640,13 @@ The system SHALL cache a rendered widget. }, { key: 'new-spec-non-added', - rule: 'archive/new-spec-non-added', + rule: 'archive/rebuilt-spec-invalid', expectAbort: true, build(root) { - // No living spec for this capability, yet the delta uses REMOVED. + // No living spec for this capability, yet the delta only REMOVEs. The + // binary ignores the REMOVED ("nothing to remove") and refuses the + // rebuilt spec, which has no requirement — so that is the rule that + // fires (archive-and-sync-parity design D9), not new-spec-non-added. writeChangeShell(root, 'new-spec-non-added', { 'widgets/spec.md': `## REMOVED Requirements @@ -829,3 +840,723 @@ ${marker} TO: \`### Requirement: Widget memoization\` `, }) } + +// --- archive-and-sync-parity (archive --no-validate, archive's JSON, sync-specs) --- +// +// Every builder below writes a `feat` change at `schemaVersion: 2` with done +// tasks and a resolved verification ledger, so `cospec validate --strict`, +// `cospec archive` and the binary's own archive all accept it, except for the +// one thing the builder is named for. The composed cospec schema is copied in +// (`withCospecSchema`), because the binary honours `retire_capabilities:` and +// reads `tracks:` only for a change whose `schema:` it can load. + +/** Copy this repo's composed cospec schema `schema` into `root`. */ +export function withCospecSchema(root: string, schema: string): void { + cpSync(join(REPO_ROOT, 'openspec/schemas', schema), join(root, 'openspec/schemas', schema), { + recursive: true, + }) +} + +const V2_PROPOSAL = `# change + +## Why + +The widgets capability has to change shape for the next release, and the main +specs must say so; without this change the archive would describe a widget +behaviour the product no longer has. + +## What Changes + +- Change the widgets capability as the spec deltas describe. + +## Capabilities + +### Modified Capabilities + +- widgets + +## Impact + +- No breaking changes. + +## Surfaces + +- [ ] interactive — a user-visible/interactive surface (UI, TUI, CLI UX) +` + +/** A proposal that only cospec's typed rules refuse: it has no `## Why`. */ +const V2_PROPOSAL_NO_WHY = V2_PROPOSAL.replace( + /## Why\n\n[\s\S]*?\n\n## What Changes/, + '## What Changes', +) + +const V2_TASKS_DONE = `## 1. Implementation + +- [x] 1.1 Implement the capability +` + +const V2_TASKS_INCOMPLETE = `## 1. Implementation + +- [x] 1.1 Implement the capability +- [ ] 1.2 Add a covering test +` + +const V2_VERIFICATION_DONE = `# Verification + +## 1. Widgets behave [critical] + +- [x] 1.1 @integration (agent) render a widget -> rendered +` + +const V2_VERIFICATION_BARE = `# Verification + +## 1. Widgets behave [critical] + +- [ ] 1.1 @integration (agent) render a widget -> rendered +` + +const CHORE_PROPOSAL = `## Why + +The build scripts carry a stale path that every release has to work around; +this removes the path so the release steps run without the manual fix-up. + +## What Changes + +- Remove the stale path from the build scripts. + +## Impact + +- No user-facing change. +` + +export interface V2ChangeOptions { + /** `.openspec.yaml` lines after `schemaVersion: 2`. */ + yamlExtra?: string + tasks?: string + /** `false` writes no verification.md. */ + verification?: string | false + proposal?: string +} + +/** A `feat` v2 change named `name` with `deltas` under its `specs/`. */ +export function writeV2Change( + root: string, + name: string, + deltas: Record, + opts: V2ChangeOptions = {}, +): void { + withCospecSchema(root, 'feat') + const change = `openspec/changes/${name}` + const files: Record = { + [`${change}/.openspec.yaml`]: `schema: feat\ncreated: 2026-10-05\nschemaVersion: 2\n${opts.yamlExtra ?? ''}`, + [`${change}/proposal.md`]: opts.proposal ?? V2_PROPOSAL, + [`${change}/blocking-changes.md`]: BLOCKERS, + [`${change}/tasks.md`]: opts.tasks ?? V2_TASKS_DONE, + } + if (opts.verification !== false) + files[`${change}/verification.md`] = opts.verification ?? V2_VERIFICATION_DONE + for (const [rel, body] of Object.entries(deltas)) files[`${change}/specs/${rel}`] = body + writeFiles(root, files) + mkdirSync(join(root, 'openspec/specs'), { recursive: true }) + mkdirSync(join(root, 'openspec/changes/archive'), { recursive: true }) +} + +const RENDERING_BLOCK = `### Requirement: Widget rendering + +The system SHALL render a widget when requested. + +#### Scenario: Render a widget + +- **WHEN** a caller requests a widget +- **THEN** a widget is rendered +` + +const RENDERING_MODIFIED_BLOCK = `### Requirement: Widget rendering + +The system SHALL render a widget promptly when requested. + +#### Scenario: Render a widget + +- **WHEN** a caller requests a widget +- **THEN** a widget is rendered +` + +const LEGACY_REMOVED = `## REMOVED Requirements + +### Requirement: Widget legacy mode + +**Reason**: The legacy mode is gone. + +**Migration**: None. +` + +/** A living requirement with two visible scenarios. */ +const RENDERING_TWO_SCENARIOS = ` +### Requirement: Widget rendering + +The system SHALL render a widget when requested. + +#### Scenario: Render a widget + +- **WHEN** a caller requests a widget +- **THEN** a widget is rendered + +#### Scenario: Render an empty widget + +- **WHEN** a caller requests an empty widget +- **THEN** a placeholder is rendered +` + +/** One fixture: what it builds, and how each side's validate reads it. */ +export interface R7Fixture { + key: string + /** Writes the fixture into `root` and returns its change name. */ + build(root: string): string + /** + * `openspec validate --strict` exits 0. A change with no delta files + * fails it (`Change must have at least one delta`) and still archives. + */ + binaryValid: boolean + /** `cospec validate --strict` exits 0. */ + cospecValid: boolean +} + +function r7( + key: string, + binaryValid: boolean, + cospecValid: boolean, + build: (root: string) => string, +): R7Fixture { + return { key, build, binaryValid, cospecValid } +} + +/** ADDED on a capability with no living spec. */ +export const R7_ADDED_NEW = r7('added-new', true, true, (root) => { + writeV2Change(root, 'c1', { 'widgets/spec.md': `## ADDED Requirements\n\n${RENDERING_BLOCK}` }) + return 'c1' +}) + +/** MODIFIED keeping every living scenario. */ +export const R7_MODIFIED = r7('modified', true, true, (root) => { + writeLivingSpec(root, 'widgets', livingSpec('widgets', LIVING_TWO_REQS)) + writeV2Change(root, 'c1', { + 'widgets/spec.md': `## MODIFIED Requirements\n\n${RENDERING_MODIFIED_BLOCK}`, + }) + return 'c1' +}) + +/** REMOVED of one of two living requirements. */ +export const R7_REMOVED = r7('removed', true, true, (root) => { + writeLivingSpec(root, 'widgets', livingSpec('widgets', LIVING_TWO_REQS)) + writeV2Change(root, 'c1', { + 'widgets/spec.md': `## REMOVED Requirements + +### Requirement: ${MARKER_TARGET} + +**Reason**: Caching moved elsewhere. + +**Migration**: None. +`, + }) + return 'c1' +}) + +/** RENAMED of a living requirement. */ +export const R7_RENAMED = r7('renamed', true, true, (root) => { + writeLivingSpec(root, 'widgets', livingSpec('widgets', LIVING_TWO_REQS)) + writeV2Change(root, 'c1', { + 'widgets/spec.md': `## RENAMED Requirements + +- FROM: \`### Requirement: ${MARKER_TARGET}\` +- TO: \`### Requirement: Widget memoization\` +`, + }) + return 'c1' +}) + +/** REMOVED of a capability's last requirement under `retire_capabilities: true`. */ +export const R7_RETIRED = r7('retired', true, true, (root) => { + writeLivingSpec(root, 'widgets', livingSpec('widgets', LIVING_WIDGET_REQ)) + writeV2Change( + root, + 'c1', + { + 'widgets/spec.md': `## REMOVED Requirements + +### Requirement: Widget rendering + +**Reason**: The capability is retired. + +**Migration**: None. +`, + }, + { yamlExtra: 'retire_capabilities: true\n' }, + ) + return 'c1' +}) + +/** The delta shapes `sync-specs` must write byte-for-byte as archive does. */ +export const R7_SYNC_SHAPES: readonly R7Fixture[] = [ + R7_ADDED_NEW, + R7_MODIFIED, + R7_REMOVED, + R7_RENAMED, + R7_RETIRED, +] + +/** A MODIFIED block keeping one living scenario only inside an HTML comment. */ +export const R7_COMMENT_KEPT = r7('comment-kept-scenario', true, true, (root) => { + writeLivingSpec(root, 'widgets', livingSpec('widgets', RENDERING_TWO_SCENARIOS)) + writeV2Change(root, 'c1', { + 'widgets/spec.md': `## MODIFIED Requirements + +${RENDERING_MODIFIED_BLOCK} + +`, + }) + return 'c1' +}) + +/** A living requirement with a third scenario inside a comment the MODIFIED block omits. */ +export const R7_COMMENTED_LIVING_SCENARIO = r7( + 'commented-living-scenario', + false, + false, + (root) => { + writeLivingSpec( + root, + 'widgets', + livingSpec( + 'widgets', + `${RENDERING_TWO_SCENARIOS} + +`, + ), + ) + writeV2Change(root, 'c1', { + 'widgets/spec.md': `## MODIFIED Requirements +${RENDERING_TWO_SCENARIOS.replace('render a widget when', 'render a widget promptly when')}`, + }) + return 'c1' + }, +) + +/** A living spec ending in a commented requirement header; MODIFIED keeps every visible scenario. */ +export const R7_COMMENTED_LIVING_HEADER = r7('commented-living-header', true, true, (root) => { + writeLivingSpec( + root, + 'widgets', + livingSpec( + 'widgets', + `${LIVING_WIDGET_REQ} + +`, + ), + ) + writeV2Change(root, 'c1', { + 'widgets/spec.md': `## MODIFIED Requirements\n\n${RENDERING_MODIFIED_BLOCK}`, + }) + return 'c1' +}) + +/** The MODIFIED fixture with a bare `[ ]` verification row. */ +export const R7_BARE_VERIFICATION = r7('bare-verification', true, true, (root) => { + R7_MODIFIED.build(root) + writeFileSync(join(root, 'openspec/changes/c1/verification.md'), V2_VERIFICATION_BARE) + return 'c1' +}) + +/** The MODIFIED fixture with an incomplete task. */ +export const R7_INCOMPLETE_TASK = r7('incomplete-task', true, true, (root) => { + R7_MODIFIED.build(root) + writeFileSync(join(root, 'openspec/changes/c1/tasks.md'), V2_TASKS_INCOMPLETE) + return 'c1' +}) + +/** A MODIFIED block that drops one of the living requirement's two scenarios. */ +export const R7_SCENARIO_DROP = r7('scenario-drop', false, false, (root) => { + writeLivingSpec(root, 'widgets', livingSpec('widgets', RENDERING_TWO_SCENARIOS)) + writeV2Change(root, 'c1', { + 'widgets/spec.md': `## MODIFIED Requirements\n\n${RENDERING_MODIFIED_BLOCK}`, + }) + return 'c1' +}) + +/** The MODIFIED fixture with a proposal only cospec's typed rules refuse. */ +export const R7_REVALIDATION_ONLY = r7('revalidation-only', true, false, (root) => { + R7_MODIFIED.build(root) + writeFileSync(join(root, 'openspec/changes/c1/proposal.md'), V2_PROPOSAL_NO_WHY) + return 'c1' +}) + +/** A namespace folder `mobile/` wrapping the change `mobile/refresh/`. */ +export const R7_NAMESPACE = r7('namespace-folder', false, false, (root) => { + R7_MODIFIED.build(root) + const nested = join(root, 'openspec/changes/mobile/refresh') + mkdirSync(dirname(nested), { recursive: true }) + cpSync(join(root, 'openspec/changes/c1'), nested, { recursive: true }) + return 'mobile' +}) + +/** ADDED + REMOVED on a capability with no living spec. */ +export const R7_NEW_ADDED_REMOVED = r7('new-added-removed', true, true, (root) => { + writeV2Change(root, 'c1', { + 'widgets/spec.md': `## ADDED Requirements\n\n${RENDERING_BLOCK}\n${LEGACY_REMOVED}`, + }) + return 'c1' +}) + +/** REMOVED-only on a capability with no living spec, without the retire marker. */ +export const R7_NEW_REMOVED_ONLY = r7('new-removed-only', true, false, (root) => { + writeV2Change(root, 'c1', { 'widgets/spec.md': LEGACY_REMOVED }) + return 'c1' +}) + +/** REMOVED-only on a capability with no living spec, under `retire_capabilities: true`. */ +export const R7_NEW_REMOVED_ONLY_MARKED = r7('new-removed-only-marked', true, true, (root) => { + writeV2Change( + root, + 'c1', + { 'widgets/spec.md': LEGACY_REMOVED }, + { yamlExtra: 'retire_capabilities: true\n' }, + ) + return 'c1' +}) + +/** + * `openspec/specs/alias -> widgets` with a delta for each name: `cospec + * validate --strict` passes it, and the binary's archive refuses it after + * taking its archive claim (`… resolve to the same target …`). + */ +export const R7_SYMLINKED_ALIAS = r7('symlinked-alias', true, true, (root) => { + writeLivingSpec(root, 'widgets', livingSpec('widgets', LIVING_TWO_REQS)) + symlinkSync('widgets', join(root, 'openspec/specs/alias')) + writeV2Change(root, 'c1', { + 'widgets/spec.md': `## MODIFIED Requirements\n\n${RENDERING_MODIFIED_BLOCK}`, + 'alias/spec.md': `## MODIFIED Requirements + +### Requirement: ${MARKER_TARGET} + +The system SHALL cache a rendered widget for a minute. + +#### Scenario: Cache a widget + +- **WHEN** a caller requests the same widget twice +- **THEN** the second request is served from cache +`, + }) + return 'c1' +}) + +/** A new capability whose delta carries a Purpose under the binary's minimum length. */ +export const R7_SHORT_PURPOSE = r7('short-purpose', true, true, (root) => { + writeV2Change(root, 'c1', { + 'widgets/spec.md': `## Purpose\n\nWidgets.\n\n## ADDED Requirements\n\n${RENDERING_BLOCK}`, + }) + return 'c1' +}) + +/** + * A `feat` change with no delta files. `--strict` reports its missing `specs` + * artifact; archive's own (non-strict) revalidation reads that as INFO. + */ +export const R7_NO_DELTA = r7('no-delta-feat', false, false, (root) => { + writeV2Change(root, 'c1', {}) + return 'c1' +}) + +/** A `feat` change with no delta files that declares `skip_specs: true`. */ +export const R7_SKIP_SPECS = r7('skip-specs-feat', true, true, (root) => { + writeV2Change(root, 'c1', {}, { yamlExtra: 'skip_specs: true\n' }) + return 'c1' +}) + +/** A `chore` change (its schema has no specs artifact). */ +export const R7_CHORE = r7('chore', false, true, (root) => { + withCospecSchema(root, 'chore') + const change = 'openspec/changes/c1' + writeFiles(root, { + [`${change}/.openspec.yaml`]: 'schema: chore\ncreated: 2026-10-05\nschemaVersion: 2\n', + [`${change}/proposal.md`]: CHORE_PROPOSAL, + [`${change}/blocking-changes.md`]: BLOCKERS, + [`${change}/tasks.md`]: V2_TASKS_DONE, + }) + mkdirSync(join(root, 'openspec/specs'), { recursive: true }) + mkdirSync(join(root, 'openspec/changes/archive'), { recursive: true }) + return 'c1' +}) + +/** + * The MODIFIED fixture with `openspec/changes/archive/` at mode 000. The + * caller restores the mode (`restoreArchiveMode`) before cleanup. + */ +export const R7_ARCHIVE_UNREADABLE = r7('archive-unreadable', true, true, (root) => { + R7_MODIFIED.build(root) + chmodSync(join(root, 'openspec/changes/archive'), 0o000) + return 'c1' +}) + +/** Undo `R7_ARCHIVE_UNREADABLE`'s mode so the tree can be removed. */ +export function restoreArchiveMode(root: string): void { + chmodSync(join(root, 'openspec/changes/archive'), 0o755) +} + +/** + * MODIFIED on a capability with no living spec. The binary's `validate + * --strict` passes it with an INFO (`Archive would refuse this delta`). + */ +export const R7_NEW_MODIFIED = r7('new-modified', true, false, (root) => { + writeV2Change(root, 'c1', { + 'widgets/spec.md': `## MODIFIED Requirements\n\n${RENDERING_MODIFIED_BLOCK}`, + }) + return 'c1' +}) + +/** RENAMED on a capability with no living spec. */ +export const R7_NEW_RENAMED = r7('new-renamed', true, false, (root) => { + writeV2Change(root, 'c1', { + 'widgets/spec.md': `## RENAMED Requirements + +- FROM: \`### Requirement: ${MARKER_TARGET}\` +- TO: \`### Requirement: Widget memoization\` +`, + }) + return 'c1' +}) + +/** An ADDED requirement with no scenario: both validators, and both archives, refuse it. */ +export const R7_DELTA_INVALID = r7('delta-invalid', false, false, (root) => { + writeV2Change(root, 'c1', { + 'widgets/spec.md': `## ADDED Requirements + +### Requirement: Widget rendering + +The system SHALL render a widget when requested. +`, + }) + return 'c1' +}) + +// The early-sync shapes (verification 7.2): each delta is already reflected in +// the living spec, so the binary's archive reports `Specs already in sync`. + +/** An ADDED block identical to the living requirement. */ +export const R7_SYNCED_ADDED = r7('synced-added', true, true, (root) => { + writeLivingSpec(root, 'widgets', livingSpec('widgets', `\n${RENDERING_BLOCK}`)) + writeV2Change(root, 'c1', { 'widgets/spec.md': `## ADDED Requirements\n\n${RENDERING_BLOCK}` }) + return 'c1' +}) + +/** A REMOVED whose requirement is already gone. */ +export const R7_SYNCED_REMOVED = r7('synced-removed', true, true, (root) => { + writeLivingSpec(root, 'widgets', livingSpec('widgets', LIVING_WIDGET_REQ)) + writeV2Change(root, 'c1', { + 'widgets/spec.md': `## REMOVED Requirements + +### Requirement: ${MARKER_TARGET} + +**Reason**: Caching moved elsewhere. + +**Migration**: None. +`, + }) + return 'c1' +}) + +/** A RENAMED already applied: the source is gone and the target present. */ +export const R7_SYNCED_RENAMED = r7('synced-renamed', true, true, (root) => { + writeLivingSpec( + root, + 'widgets', + livingSpec('widgets', LIVING_TWO_REQS.replace(MARKER_TARGET, 'Widget memoization')), + ) + writeV2Change(root, 'c1', { + 'widgets/spec.md': `## RENAMED Requirements + +- FROM: \`### Requirement: ${MARKER_TARGET}\` +- TO: \`### Requirement: Widget memoization\` +`, + }) + return 'c1' +}) + +/** A MODIFIED block identical to the living requirement. */ +export const R7_SYNCED_MODIFIED = r7('synced-modified', true, true, (root) => { + writeLivingSpec(root, 'widgets', livingSpec('widgets', `\n${RENDERING_MODIFIED_BLOCK}`)) + writeV2Change(root, 'c1', { + 'widgets/spec.md': `## MODIFIED Requirements\n\n${RENDERING_MODIFIED_BLOCK}`, + }) + return 'c1' +}) + +/** Each early-sync shape, the already-retired capability last. */ +export const R7_SYNCED_SHAPES: readonly R7Fixture[] = [ + R7_SYNCED_ADDED, + R7_SYNCED_REMOVED, + R7_SYNCED_RENAMED, + R7_SYNCED_MODIFIED, + R7_NEW_REMOVED_ONLY_MARKED, +] + +/** The MODIFIED fixture declaring `skip_specs: true` beside its delta file: a conflict both validators refuse. */ +export const R7_SKIP_SPECS_WITH_DELTA = r7('skip-specs-with-delta', false, false, (root) => { + R7_MODIFIED.build(root) + writeFileSync( + join(root, 'openspec/changes/c1/.openspec.yaml'), + 'schema: feat\ncreated: 2026-10-05\nschemaVersion: 2\nskip_specs: true\n', + ) + return 'c1' +}) + +/** + * A `feat` change whose only delta sits in a file the merge never reads, at + * `rel` under its `specs/`: both archives refuse it. + */ +function unreadDelta(key: string, rel: string): R7Fixture { + return r7(key, false, false, (root) => { + writeV2Change(root, 'c1', { [rel]: `## ADDED Requirements\n\n${RENDERING_BLOCK}` }) + return 'c1' + }) +} + +/** The unread-delta-file shapes: a root `spec.md`, a flat `.md`, a note beside a capability. */ +export const R7_UNREAD_DELTAS: readonly R7Fixture[] = [ + unreadDelta('unread-root-spec', 'spec.md'), + unreadDelta('unread-flat-file', 'widgets.md'), + unreadDelta('unread-note', 'widgets/notes.md'), +] + +/** + * `R7_MODIFIED` with an ABSOLUTE `openspec/specs/alias` link to the root's own + * `widgets/` and the delta under `alias/` only: the binary merges through the + * alias into `widgets/spec.md`. + */ +export const R7_ABSOLUTE_ALIAS = r7('absolute-alias', true, true, (root) => { + writeLivingSpec(root, 'widgets', livingSpec('widgets', LIVING_TWO_REQS)) + symlinkSync(join(root, 'openspec/specs/widgets'), join(root, 'openspec/specs/alias')) + writeV2Change(root, 'c1', { + 'alias/spec.md': `## MODIFIED Requirements\n\n${RENDERING_MODIFIED_BLOCK}`, + }) + return 'c1' +}) + +/** + * `R7_SYMLINKED_ALIAS` with the alias an ABSOLUTE link: the binary refuses + * after its claim, as it refuses the relative one. + */ +export const R7_ABSOLUTE_ALIAS_CONFLICT = r7('absolute-alias-conflict', true, true, (root) => { + R7_SYMLINKED_ALIAS.build(root) + const alias = join(root, 'openspec/specs/alias') + unlinkSync(alias) + symlinkSync(join(root, 'openspec/specs/widgets'), alias) + return 'c1' +}) + +/** + * The living `widgets` spec kept at `specs/`, with `specs/widgets/spec.md` + * a relative link to it. + */ +function linkedLivingSpec(root: string, kept: string, requirements: string): void { + const file = join(root, 'openspec/specs', kept) + mkdirSync(dirname(file), { recursive: true }) + writeFileSync(file, livingSpec('widgets', requirements)) + mkdirSync(join(root, 'openspec/specs/widgets'), { recursive: true }) + symlinkSync( + relative(join(root, 'openspec/specs/widgets'), file), + join(root, 'openspec/specs/widgets/spec.md'), + ) +} + +/** + * `R7_RETIRED` with the living `spec.md` a link to `specs/shared/widgets.md`: + * the binary unlinks the link and prunes `widgets/`. + */ +export const R7_RETIRED_LINKED = r7('retired-linked', true, true, (root) => { + R7_RETIRED.build(root) + rmSync(join(root, 'openspec/specs/widgets'), { recursive: true }) + linkedLivingSpec(root, 'shared/widgets.md', LIVING_WIDGET_REQ) + return 'c1' +}) + +/** + * `R7_MODIFIED` with the living `spec.md` a link to `widgets/living.md`: the + * binary writes through it. (A link leaving the capability directory is one + * the binary's `validate` refuses.) + */ +export const R7_MODIFIED_LINKED = r7('modified-linked', true, true, (root) => { + R7_MODIFIED.build(root) + rmSync(join(root, 'openspec/specs/widgets'), { recursive: true }) + linkedLivingSpec(root, 'widgets/living.md', LIVING_TWO_REQS) + return 'c1' +}) + +/** + * `R7_MODIFIED` beside an unrelated living spec no one can read (mode 000). + * Its validate flags hold where `realpath` resolves a mode-000 file (Linux); + * on macOS the binary's `validate` fails on it, so it stays out of + * `R7_FIXTURES`' smoke rows. + */ +export const R7_UNRELATED_UNREADABLE = r7('unrelated-unreadable', true, true, (root) => { + R7_MODIFIED.build(root) + writeLivingSpec(root, 'other', livingSpec('other', LIVING_WIDGET_REQ)) + chmodSync(join(root, 'openspec/specs/other/spec.md'), 0o000) + return 'c1' +}) + +/** Undo `R7_UNRELATED_UNREADABLE`'s mode so the tree can be read and removed. */ +export function restoreUnrelatedMode(root: string): void { + chmodSync(join(root, 'openspec/specs/other/spec.md'), 0o644) +} + +/** Every archive-and-sync-parity builder, for the smoke rows. */ +export const R7_FIXTURES: readonly R7Fixture[] = [ + ...R7_SYNC_SHAPES, + R7_COMMENT_KEPT, + R7_COMMENTED_LIVING_SCENARIO, + R7_COMMENTED_LIVING_HEADER, + R7_BARE_VERIFICATION, + R7_INCOMPLETE_TASK, + R7_SCENARIO_DROP, + R7_REVALIDATION_ONLY, + R7_NAMESPACE, + R7_NEW_ADDED_REMOVED, + R7_NEW_REMOVED_ONLY, + R7_NEW_REMOVED_ONLY_MARKED, + R7_SYMLINKED_ALIAS, + R7_SHORT_PURPOSE, + R7_NO_DELTA, + R7_SKIP_SPECS, + R7_CHORE, + R7_ARCHIVE_UNREADABLE, + R7_NEW_MODIFIED, + R7_NEW_RENAMED, + R7_DELTA_INVALID, + R7_SYNCED_ADDED, + R7_SYNCED_REMOVED, + R7_SYNCED_RENAMED, + R7_SYNCED_MODIFIED, + ...R7_UNREAD_DELTAS, + R7_ABSOLUTE_ALIAS, + R7_ABSOLUTE_ALIAS_CONFLICT, + R7_RETIRED_LINKED, + R7_MODIFIED_LINKED, +] diff --git a/apps/cli/test/contract/parity-pending.yaml b/apps/cli/test/contract/parity-pending.yaml index 513b2563..9fa757ed 100644 --- a/apps/cli/test/contract/parity-pending.yaml +++ b/apps/cli/test/contract/parity-pending.yaml @@ -16,9 +16,6 @@ # `source: cli` marks a hidden surface the four registry sources cannot # produce; the test verifies it against the pinned binary instead. -# --- archive-and-sync-parity ------------------------------------------------------ -- { kind: flag, path: [archive], flag: --no-validate, owner: archive-and-sync-parity } - # --- tool-matrix ------------------------------------------------------------------ - { kind: tool, id: amazon-q, owner: tool-matrix } - { kind: tool, id: antigravity, owner: tool-matrix } diff --git a/apps/cli/test/contract/reachability.test.ts b/apps/cli/test/contract/reachability.test.ts index 36284bf4..cc7ab8de 100644 --- a/apps/cli/test/contract/reachability.test.ts +++ b/apps/cli/test/contract/reachability.test.ts @@ -1056,11 +1056,11 @@ describe('reachability: negative cases (ledger 4.1, 4.3, 4.5)', () => { }) test('a pending entry whose owner disagrees with the table fails', () => { - const pending = PENDING.filter((pe) => !(pe.kind === 'flag' && pe.flag === '--no-validate')) - pending.push({ kind: 'flag', path: ['archive'], flag: '--no-validate', owner: 'tool-matrix' }) + const pending = PENDING.filter((pe) => !(pe.kind === 'flag' && pe.flag === '--language')) + pending.push({ kind: 'flag', path: ['init'], flag: '--language', owner: 'tool-matrix' }) const failures = checkReachability({ ...model, pending }) expect(failures).toContain( - "flag `archive --no-validate` is pending on 'tool-matrix' in parity-pending.yaml but on 'archive-and-sync-parity' in the command table", + "flag `init --language` is pending on 'tool-matrix' in parity-pending.yaml but on 'workflow-profiles' in the command table", ) }) diff --git a/apps/cli/test/contract/support/key-oracle.ts b/apps/cli/test/contract/support/key-oracle.ts index f66af9d8..b40ddffc 100644 --- a/apps/cli/test/contract/support/key-oracle.ts +++ b/apps/cli/test/contract/support/key-oracle.ts @@ -10,6 +10,8 @@ // by entry through an identity per path, never by index, so the two tools may // order a sweep differently; any other array is compared whole. +import { relative } from 'node:path' + import { respellWholeRemedy } from '../../../src/core/remedies.ts' /** How a shared path is compared. */ @@ -20,6 +22,7 @@ export type PathClass = | 'kept' | 'collision' | 'respelled' + | 'path' | 'equal' /** One side's identity for an array entry; `undefined` for an entry with none. */ @@ -70,8 +73,34 @@ export const NAMED_COLLISIONS: readonly NamedCollision[] = [ return undefined }, }, + { + path: 'status[].fix', + reason: + "cospec's archive tasks gate is stricter than the binary's: `--yes` (a declared no-op under cospec, which never prompts) does not lift it, so the fix names the flag that does, `--force-incomplete`", + check: (up, cs) => { + if (up.code !== 'archive_tasks_incomplete') + return `named only for archive_tasks_incomplete, not ${JSON.stringify(up.code)}` + if (up.fix !== 'Complete the tasks or rerun with --yes.') + return `the binary's fix is ${JSON.stringify(up.fix)}` + if (cs.fix !== 'Complete the tasks or rerun with --force-incomplete.') + return `cospec's fix is ${JSON.stringify(cs.fix)}` + return undefined + }, + }, ] +/** + * Absolute paths each tool reports under its own root, when the two runs + * cannot share one (an archive moves the change, so the binary archives a + * copy): equal when each is the same path relative to its own root. Both + * roots are canonical (`realpathSync`), as the binary reports them. + */ +export interface PathRoots { + readonly keys: readonly string[] + readonly upstreamRoot: string + readonly cospecRoot: string +} + export interface OracleSpec { /** Identity per array-of-objects path. */ readonly identities?: Readonly> @@ -89,6 +118,8 @@ export interface OracleSpec { readonly kept?: readonly string[] /** The named collisions this command's document may carry (from `NAMED_COLLISIONS`). */ readonly collisions?: readonly string[] + /** Paths compared relative to each tool's root. */ + readonly paths?: PathRoots } export interface OracleResult { @@ -167,6 +198,12 @@ interface Compiled { respelled: Segments[] kept: Segments[] collisions: [Segments, NamedCollision][] + paths: Segments[] + roots?: PathRoots +} + +function underOwnRoot(root: string, value: unknown): unknown { + return typeof value === 'string' ? relative(root, value) : value } function compile(spec: OracleSpec): Compiled { @@ -182,6 +219,8 @@ function compile(spec: OracleSpec): Compiled { if (entry === undefined) throw new Error(`key oracle: '${p}' is not a named collision`) return [segments(p), entry] }), + paths: (spec.paths?.keys ?? []).map(segments), + ...(spec.paths === undefined ? {} : { roots: spec.paths }), } } @@ -192,6 +231,7 @@ function classify(c: Compiled, path: Segments): PathClass { if (c.verdict.some((p) => matches(p, path))) return 'verdict' if (c.kept.some((p) => matches(p, path))) return 'kept' if (c.respelled.some((p) => matches(p, path))) return 'respelled' + if (c.paths.some((p) => matches(p, path))) return 'path' return 'equal' } @@ -241,6 +281,16 @@ export function compareDocuments( ) return } + if (cls === 'path') { + const roots = c.roots! + const want = underOwnRoot(roots.upstreamRoot, up) + const got = underOwnRoot(roots.cospecRoot, cs) + if (typeof up !== 'string' || !same(want, got)) + failures.push( + `${label}: ${JSON.stringify(cs)} is not the binary's path ${JSON.stringify(up)} under its own root (${JSON.stringify(got)} vs ${JSON.stringify(want)})`, + ) + return + } if (cls === 'respelled') { const want = typeof up === 'string' ? respellWholeRemedy(up) : up if (!same(want, cs)) diff --git a/apps/cli/test/contract/sync-specs.test.ts b/apps/cli/test/contract/sync-specs.test.ts new file mode 100644 index 00000000..53a751e6 --- /dev/null +++ b/apps/cli/test/contract/sync-specs.test.ts @@ -0,0 +1,577 @@ +// archive-and-sync-parity: `cospec sync-specs ` merges a change's +// delta specs into the main specs without archiving it, by running the pinned +// binary's own `archive -y` on a scratch copy and copying back the main-spec +// files it changed. Every row compares against `openspec archive c1 -y` on a +// copy of the same fixture (file list and sha256 per file), read at test time. + +import { afterAll, describe, expect, test } from 'bun:test' +import { + chmodSync, + cpSync, + existsSync, + mkdirSync, + readdirSync, + readlinkSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import { join, relative } from 'node:path' + +import { formatLocalDate } from '../../src/commands/archive.ts' +import { respellRemedies } from '../../src/core/remedies.ts' +import { cleanupAll, cospec, hashTree, mkTempRepo, writeFiles } from '../fixtures/support.ts' +import { + R7_ABSOLUTE_ALIAS, + R7_ABSOLUTE_ALIAS_CONFLICT, + R7_ADDED_NEW, + R7_CHORE, + R7_DELTA_INVALID, + R7_MODIFIED, + R7_MODIFIED_LINKED, + R7_NAMESPACE, + R7_NO_DELTA, + R7_RETIRED_LINKED, + R7_SCENARIO_DROP, + R7_SHORT_PURPOSE, + R7_SKIP_SPECS, + R7_SKIP_SPECS_WITH_DELTA, + R7_SYMLINKED_ALIAS, + R7_SYNC_SHAPES, + R7_UNREAD_DELTAS, + R7_UNRELATED_UNREADABLE, + restoreUnrelatedMode, + writeLivingSpec, + type R7Fixture, +} from './fixtures.ts' +import { makeSandbox, setupStore } from './support/root-sandbox.ts' +import { oracle, oracleEnv } from './support/upstream-oracle.ts' + +afterAll(cleanupAll) + +// --- harness ------------------------------------------------------------------- + +interface Run { + exitCode: number + stdout: string + stderr: string +} + +/** Every output a row captured from `cospec sync-specs`, for row 8.2. */ +const CAPTURED: { row: string; text: string }[] = [] + +/** + * A private temp directory for one cospec run (its `TMPDIR`), so a row can + * prove the scratch tree is gone afterwards — or, made unwritable, that the + * command never tried to create one. + */ +function privateTmp(): string { + const dir = join(mkTempRepo(), 'tmp') + mkdirSync(dir) + return dir +} + +/** `cospec ` in `root` under the oracle's sandbox env and `tmp` as `TMPDIR`. */ +async function own( + row: string, + root: string, + args: string[], + tmp: string = privateTmp(), + env: Record = oracleEnv(root), +): Promise { + const res = await cospec(args, { cwd: root, env: { ...env, TMPDIR: tmp } }) + // Row 8.2 sweeps what `sync-specs` itself printed, not the other commands a row runs. + if (args[0] === 'sync-specs') CAPTURED.push({ row, text: `${res.stdout}\n${res.stderr}` }) + return res +} + +/** `cospec ` with `TMPDIR` unwritable: a scratch directory cannot be made. */ +async function ownWithoutScratch(row: string, root: string, args: string[]): Promise { + const tmp = privateTmp() + chmodSync(tmp, 0o500) + try { + return await own(row, root, args, tmp) + } finally { + chmodSync(tmp, 0o755) + } +} + +function binary(root: string, args: string[]): Promise { + return oracle(args, root) +} + +function twin(fixture: R7Fixture): { root: string; copy: string; name: string } { + const root = mkTempRepo({ git: true }) + const copy = mkTempRepo({ git: true }) + const name = fixture.build(root) + fixture.build(copy) + return { root, copy, name } +} + +function document(stdout: string): Record { + return JSON.parse(stdout) as Record +} + +const specsOf = (root: string): Record => + existsSync(join(root, 'openspec/specs')) ? hashTree(join(root, 'openspec/specs')) : {} + +/** Every directory under `openspec/specs/`, so a pruned directory shows. */ +function specDirs(root: string): string[] { + const out: string[] = [] + const walk = (dir: string): void => { + for (const e of readdirSync(dir, { withFileTypes: true })) + if (e.isDirectory()) { + out.push(relative(root, join(dir, e.name))) + walk(join(dir, e.name)) + } + } + if (existsSync(join(root, 'openspec/specs'))) walk(join(root, 'openspec/specs')) + return out.toSorted() +} + +/** Every symbolic link under `openspec/specs/`, with the target it holds. */ +function linksOf(root: string): [string, string][] { + const out: [string, string][] = [] + const walk = (dir: string): void => { + for (const e of readdirSync(dir, { withFileTypes: true })) { + const child = join(dir, e.name) + if (e.isSymbolicLink()) out.push([relative(root, child), readlinkSync(child)]) + else if (e.isDirectory()) walk(child) + } + } + if (existsSync(join(root, 'openspec/specs'))) walk(join(root, 'openspec/specs')) + return out.toSorted() +} + +const openspecOf = (root: string): Record => hashTree(join(root, 'openspec')) + +const changeOf = (root: string, name: string): Record => + hashTree(join(root, 'openspec/changes', name)) + +const archiveEntries = (root: string): string[] => + readdirSync(join(root, 'openspec/changes/archive')) + +/** Every `.openspec-archive.lock` under `root`. */ +function locks(root: string): string[] { + const out: string[] = [] + const walk = (dir: string): void => { + for (const e of readdirSync(dir, { withFileTypes: true })) { + const child = join(dir, e.name) + if (e.name === '.openspec-archive.lock') out.push(relative(root, child)) + if (e.isDirectory() && e.name !== '.git' && e.name !== '.oracle-home') walk(child) + } + } + walk(root) + return out +} + +/** Mark the fixture's verification row bare again. */ +function unresolveVerification(root: string, name: string): void { + writeFileSync( + join(root, 'openspec/changes', name, 'verification.md'), + '# Verification\n\n## 1. Widgets behave [critical]\n\n- [ ] 1.1 @integration (agent) render a widget -> rendered\n', + ) +} + +// --- 9. sync-specs writes archive's main specs and leaves the change active ------- + +describe("9. sync-specs writes archive's main specs and leaves the change active", () => { + for (const fixture of R7_SYNC_SHAPES) + test(`9.1 ${fixture.key}: the main specs are the binary's archive's, byte for byte`, async () => { + const { root, copy, name } = twin(fixture) + const change = changeOf(root, name) + const tmp = privateTmp() + const res = await own('9.1', root, ['sync-specs', name], tmp) + const up = await binary(copy, ['archive', name, '-y']) + expect([res.exitCode, up.exitCode]).toEqual([0, 0]) + expect(specsOf(root)).toEqual(specsOf(copy)) + expect(specDirs(root)).toEqual(specDirs(copy)) + expect(changeOf(root, name)).toEqual(change) + expect(archiveEntries(root)).toEqual([]) + expect(readdirSync(tmp)).toEqual([]) + }) + + for (const fixture of R7_SYNC_SHAPES) + test(`9.2 ${fixture.key}: a later archive is the no-op merge`, async () => { + const root = mkTempRepo({ git: true }) + const name = fixture.build(root) + expect((await own('9.2', root, ['sync-specs', name])).exitCode).toBe(0) + const synced = specsOf(root) + const res = await own('9.2', root, ['archive', name]) + expect(res.exitCode).toBe(0) + expect(existsSync(join(root, 'openspec/changes', name))).toBe(false) + expect(res.stdout).toContain('Specs: already in sync\n') + expect(specsOf(root)).toEqual(synced) + }) + + for (const fixture of R7_SYNC_SHAPES) + test(`9.3 ${fixture.key}: the verification gate still runs after a sync`, async () => { + const root = mkTempRepo({ git: true }) + const name = fixture.build(root) + expect((await own('9.3', root, ['sync-specs', name])).exitCode).toBe(0) + unresolveVerification(root, name) + const res = await own('9.3', root, ['archive', name]) + expect(res.exitCode).toBe(1) + expect(res.stderr).toContain('verification.md is not fully resolved') + }) + + test('9.3 modified: the scenario gate still runs after a sync', async () => { + const root = mkTempRepo({ git: true }) + const name = R7_MODIFIED.build(root) + expect((await own('9.3', root, ['sync-specs', name])).exitCode).toBe(0) + const living = join(root, 'openspec/specs/widgets/spec.md') + const text = await Bun.file(living).text() + writeFileSync( + living, + text.replace( + '- **THEN** a widget is rendered\n', + '- **THEN** a widget is rendered\n\n#### Scenario: Render a large widget\n\n- **WHEN** a caller requests a large widget\n- **THEN** it is rendered at scale\n', + ), + ) + const res = await own('9.3', root, ['archive', name]) + expect(res.exitCode).toBe(1) + expect(res.stderr).toContain('scenario-preservation gate refused') + }) + + test('9.4 a second sync reports in sync and writes nothing', async () => { + const root = mkTempRepo({ git: true }) + const name = R7_MODIFIED.build(root) + expect((await own('9.4', root, ['sync-specs', name])).exitCode).toBe(0) + const once = openspecOf(root) + const res = await own('9.4', root, ['sync-specs', name]) + expect(res.exitCode).toBe(0) + expect(res.stdout).toContain('Specs: already in sync; no files changed\n') + expect(res.stdout).not.toContain('Synced:') + expect(openspecOf(root)).toEqual(once) + }) + + // A file-level spec.md link, and an absolute in-tree capability alias: the + // binary writes through, deletes or re-points exactly as it would in place. + for (const fixture of [R7_RETIRED_LINKED, R7_MODIFIED_LINKED, R7_ABSOLUTE_ALIAS]) + test(`9.5 ${fixture.key}: the main specs are the binary's archive's, byte for byte`, async () => { + const { root, copy, name } = twin(fixture) + const tmp = privateTmp() + const res = await own('9.5', root, ['sync-specs', name, '--json'], tmp) + const up = await binary(copy, ['archive', name, '-y']) + expect([res.exitCode, up.exitCode]).toEqual([0, 0]) + expect(document(res.stdout)).toMatchObject({ synced: true }) + expect(specsOf(root)).toEqual(specsOf(copy)) + expect(specDirs(root)).toEqual(specDirs(copy)) + expect(linksOf(root)).toEqual(linksOf(copy).map(([l, t]) => [l, t.replace(copy, root)])) + expect(readdirSync(tmp)).toEqual([]) + }) +}) + +// --- 10. sync-specs refuses what archive refuses ----------------------------------- + +describe('10. sync-specs refuses what archive refuses', () => { + test('10.1 a scenario-dropping MODIFIED: refused, nothing written, no scratch left', async () => { + const { root, copy, name } = twin(R7_SCENARIO_DROP) + const before = openspecOf(root) + const tmp = privateTmp() + const res = await own('10.1', root, ['sync-specs', name], tmp) + expect(res.exitCode).toBe(1) + expect(res.stderr).toContain('cospec sync-specs: scenario-preservation gate refused') + expect(openspecOf(root)).toEqual(before) + expect(readdirSync(tmp)).toEqual([]) + const up = await binary(copy, ['archive', name, '-y']) + expect(up.exitCode).toBe(1) + }) + + test('10.2 a change revalidation refuses: the report, nothing spawned or written', async () => { + const root = mkTempRepo({ git: true }) + const name = R7_DELTA_INVALID.build(root) + const before = openspecOf(root) + const res = await ownWithoutScratch('10.2', root, ['sync-specs', name]) + expect(res.exitCode).toBe(1) + expect(res.stdout).toContain('cospec sync-specs') + expect(res.stdout).toContain('deltas/requirement-shape') + expect(openspecOf(root)).toEqual(before) + }) + test('10.2 skip_specs: true beside a delta file: refused as archive refuses it', async () => { + const root = mkTempRepo({ git: true }) + const name = R7_SKIP_SPECS_WITH_DELTA.build(root) + const archived = await own('10.2', root, ['archive', name]) + expect(archived.exitCode).toBe(1) + expect(archived.stdout).toContain('deltas/skip-specs-conflict') + const before = openspecOf(root) + const res = await ownWithoutScratch('10.2', root, ['sync-specs', name]) + expect(res.exitCode).toBe(1) + expect(res.stdout).toContain('cospec sync-specs') + expect(res.stdout).toContain('deltas/skip-specs-conflict') + expect(openspecOf(root)).toEqual(before) + }) + + for (const fixture of R7_UNREAD_DELTAS) + test(`10.2 ${fixture.key}: a delta file the merge never reads is refused as archive refuses it`, async () => { + const { root, copy, name } = twin(fixture) + const archived = await own('10.2', copy, ['archive', name]) + expect(archived.exitCode).toBe(1) + const rules = [...new Set(archived.stdout.match(/\b[a-z]+\/[a-z-]+\b/g) ?? [])].filter((r) => + r.startsWith('deltas/'), + ) + expect(rules).not.toEqual([]) + expect((await binary(copy, ['archive', name, '-y'])).exitCode).toBe(1) + const before = openspecOf(root) + const res = await ownWithoutScratch('10.2', root, ['sync-specs', name]) + expect(res.exitCode).toBe(1) + expect(res.stdout).toContain('cospec sync-specs') + for (const rule of rules) expect(res.stdout).toContain(rule) + expect(res.stdout).not.toContain('Nothing to sync') + expect(openspecOf(root)).toEqual(before) + const j = await ownWithoutScratch('10.2', root, ['sync-specs', name, '--json']) + expect(j.exitCode).toBe(1) + expect(document(j.stdout)).toMatchObject({ change: name, synced: false }) + }) +}) + +// --- 11. a failed scratch run leaves nothing in the real tree ---------------------- + +describe('11. a failed scratch run leaves nothing in the real tree', () => { + test('11.1 the binary refuses after its claim: its reason relayed, no lock, tree unchanged', async () => { + const root = mkTempRepo({ git: true }) + const name = R7_SYMLINKED_ALIAS.build(root) + expect((await own('11.1', root, ['validate', name, '--strict'])).exitCode).toBe(0) + const before = openspecOf(root) + const tmp = privateTmp() + const res = await own('11.1', root, ['sync-specs', name], tmp) + expect(res.exitCode).toBe(1) + expect(res.stderr).toContain('resolve to the same target') + expect(locks(root)).toEqual([]) + expect(openspecOf(root)).toEqual(before) + expect(readdirSync(tmp)).toEqual([]) + }) + + test('11.3 sibling changes and a taken archive slot are never copied', async () => { + const root = mkTempRepo({ git: true }) + const name = R7_MODIFIED.build(root) + for (const sibling of ['c2', 'c3']) + cpSync(join(root, 'openspec/changes', name), join(root, 'openspec/changes', sibling), { + recursive: true, + }) + writeFiles(root, { + [`openspec/changes/archive/${formatLocalDate()}-${name}/.openspec.yaml`]: + 'schema: feat\ncreated: 2026-10-05\nschemaVersion: 2\n', + }) + const changes = hashTree(join(root, 'openspec/changes')) + const specs = specsOf(root) + const res = await own('11.3', root, ['sync-specs', name]) + expect(res.exitCode).toBe(0) + expect(hashTree(join(root, 'openspec/changes'))).toEqual(changes) + expect(Object.keys(specsOf(root))).toEqual(Object.keys(specs)) + expect(specsOf(root)).not.toEqual(specs) + }) + + test('11.4 a symlink leading outside the copied tree is refused before any spawn', async () => { + const root = mkTempRepo({ git: true }) + const name = R7_MODIFIED.build(root) + const outside = mkTempRepo() + writeLivingSpec(outside, 'ext', '# ext\n') + symlinkSync(join(outside, 'openspec/specs/ext'), join(root, 'openspec/specs/ext')) + const before = openspecOf(root) + const res = await ownWithoutScratch('11.4', root, ['sync-specs', name]) + expect(res.exitCode).toBe(1) + expect(res.stderr).toContain('openspec/specs/ext') + expect(res.stderr).toContain('leads outside') + expect(openspecOf(root)).toEqual(before) + }) + + test('11.4 an absolute in-tree alias the binary refuses: the real tree is byte-unchanged', async () => { + const { root, copy, name } = twin(R7_ABSOLUTE_ALIAS_CONFLICT) + const up = await binary(copy, ['archive', name, '-y']) + expect(up.exitCode).toBe(1) + const before = openspecOf(root) + const tmp = privateTmp() + const text = await own('11.4', root, ['sync-specs', name], tmp) + const json = await own('11.4', root, ['sync-specs', name, '--json'], tmp) + expect([text.exitCode, json.exitCode]).toEqual([1, 1]) + expect(text.stderr).toContain('resolve to the same target') + const status = (document(json.stdout).status as { code: string; message: string }[])[0]! + expect(status.message).toContain('resolve to the same target') + expect(locks(root)).toEqual([]) + expect(openspecOf(root)).toEqual(before) + expect(readdirSync(tmp)).toEqual([]) + }) + + test('11.6 an unrelated unreadable living spec: answered as archive answers, one --json document', async () => { + const { root, copy, name } = twin(R7_UNRELATED_UNREADABLE) + const upstream = mkTempRepo({ git: true }) + R7_UNRELATED_UNREADABLE.build(upstream) + try { + const tmp = privateTmp() + const res = await own('11.6', root, ['sync-specs', name, '--json'], tmp) + const archived = await own('11.6', copy, ['archive', name, '--json']) + const up = await binary(upstream, ['archive', name, '-y']) + const doc = document(res.stdout) + const archiveDoc = document(archived.stdout) + expect(readdirSync(tmp)).toEqual([]) + expect(res.exitCode).toBe(archived.exitCode) + for (const dir of [root, copy, upstream]) restoreUnrelatedMode(dir) + if (archived.exitCode === 0) { + // Where cospec's revalidation reads the tree as the binary's archive + // does (Linux), the sync is the binary's merge. + expect(up.exitCode).toBe(0) + expect(doc).toMatchObject({ change: name, synced: true }) + expect(specsOf(root)).toEqual(specsOf(upstream)) + } else { + // macOS's realpath fails on a mode-000 file, and with it the + // revalidation: both commands refuse the same way, writing nothing. + expect(doc).toMatchObject({ change: name, synced: false, reason: archiveDoc.reason }) + expect(specsOf(root)).toEqual(specsOf(copy)) + } + } finally { + for (const dir of [root, copy, upstream]) restoreUnrelatedMode(dir) + } + }) +}) + +// --- 12. sync-specs output ---------------------------------------------------------- + +describe('12. sync-specs output', () => { + test('12.1 text and --json on the MODIFIED fixture', async () => { + const text = mkTempRepo({ git: true }) + const json = mkTempRepo({ git: true }) + const name = R7_MODIFIED.build(text) + R7_MODIFIED.build(json) + const t = await own('12.1', text, ['sync-specs', name]) + expect(t.exitCode).toBe(0) + expect(t.stdout).toBe( + 'Synced: openspec/specs/widgets/spec.md (written)\nTotals: + 0, ~ 1, - 0, → 0\n', + ) + const j = await own('12.1', json, ['sync-specs', name, '--json']) + expect(j.exitCode).toBe(0) + expect(document(j.stdout)).toEqual({ + change: name, + type: 'feat', + synced: true, + totals: { added: 0, modified: 1, removed: 0, renamed: 0 }, + files: { written: ['openspec/specs/widgets/spec.md'], deleted: [] }, + warnings: [], + root: { path: expect.any(String), source: 'nearest' }, + }) + }) + + test('12.1 text on a new capability names the created file', async () => { + const root = mkTempRepo({ git: true }) + const name = R7_ADDED_NEW.build(root) + const res = await own('12.1', root, ['sync-specs', name]) + expect(res.exitCode).toBe(0) + expect(res.stdout).toContain('Synced: openspec/specs/widgets/spec.md (written)\n') + expect(res.stdout).toContain('Totals: + 1, ~ 0, - 0, → 0\n') + }) + + const nothing: { fixture: R7Fixture; why: string; reason: string }[] = [ + { fixture: R7_CHORE, why: 'the chore schema has no specs artifact', reason: 'schema' }, + { fixture: R7_NO_DELTA, why: 'c1 has no delta specs', reason: 'no-deltas' }, + { fixture: R7_SKIP_SPECS, why: 'c1 declares skip_specs: true', reason: 'skip-specs' }, + ] + for (const n of nothing) + test(`12.2 ${n.fixture.key}: nothing to sync, and why, spawning nothing`, async () => { + const text = mkTempRepo({ git: true }) + const json = mkTempRepo({ git: true }) + const name = n.fixture.build(text) + n.fixture.build(json) + const before = openspecOf(text) + const t = await ownWithoutScratch('12.2', text, ['sync-specs', name]) + expect(t.exitCode).toBe(0) + expect(t.stdout).toBe(`Nothing to sync: ${n.why}.\n`) + expect(openspecOf(text)).toEqual(before) + const j = await ownWithoutScratch('12.2', json, ['sync-specs', name, '--json']) + expect(j.exitCode).toBe(0) + const doc = document(j.stdout) + expect([doc.synced, doc.skipReason]).toEqual([false, n.reason]) + }) + + test("12.3 --store syncs the store's main specs and leaves the cwd repo alone", async () => { + const sb = await makeSandbox([]) + const store = await setupStore(sb, 'alpha') + const name = R7_MODIFIED.build(store) + const repo = join(sb.dir, 'repo') + mkdirSync(repo) + R7_MODIFIED.build(repo) + const repoBefore = openspecOf(repo) + const storeBefore = specsOf(store) + const res = await own( + '12.3', + repo, + ['sync-specs', name, '--store', 'alpha'], + privateTmp(), + sb.env, + ) + expect(res.exitCode).toBe(0) + expect(specsOf(store)).not.toEqual(storeBefore) + expect(openspecOf(repo)).toEqual(repoBefore) + }) + + test('12.4 an unknown change under --json is one failure document', async () => { + const root = mkTempRepo({ git: true }) + R7_MODIFIED.build(root) + const res = await own('12.4', root, ['sync-specs', 'nope', '--json']) + expect(res.exitCode).toBe(1) + const doc = document(res.stdout) + expect(doc).toMatchObject({ change: 'nope', synced: false }) + expect((doc.status as { code: string; message: string }[])[0]).toMatchObject({ + code: 'archive_change_not_found', + message: "Change 'nope' not found. Available changes: c1", + }) + }) + + test("12.4 a refused scratch run under --json carries archive_error and the binary's reason", async () => { + const root = mkTempRepo({ git: true }) + const name = R7_SYMLINKED_ALIAS.build(root) + const res = await own('12.4', root, ['sync-specs', name, '--json']) + expect(res.exitCode).toBe(1) + const doc = document(res.stdout) + expect(doc).toMatchObject({ change: name, synced: false }) + const status = (doc.status as { code: string; message: string }[])[0]! + expect(status.code).toBe('archive_error') + expect(status.message).toContain('resolve to the same target') + }) +}) + +// --- 5.2 a namespace folder ---------------------------------------------------------- + +describe('5.2 sync-specs refuses a namespace folder', () => { + test("text and --json: Cannot sync 'mobile', nothing spawned or written", async () => { + const root = mkTempRepo({ git: true }) + const name = R7_NAMESPACE.build(root) + const before = openspecOf(root) + const t = await ownWithoutScratch('5.2', root, ['sync-specs', name]) + expect(t.exitCode).toBe(1) + expect(t.stderr).toStartWith( + 'cospec sync-specs: Cannot sync \'mobile\': "mobile" is not a change', + ) + expect(t.stderr).toContain( + 'Rename openspec/changes/mobile/refresh/ to a flat change directory, then sync it.', + ) + const j = await ownWithoutScratch('5.2', root, ['sync-specs', name, '--json']) + expect(j.exitCode).toBe(1) + const status = (document(j.stdout).status as { code: string; message: string }[])[0]! + expect(status.code).toBe('archive_change_is_namespace_folder') + expect(status.message).toStartWith("Cannot sync 'mobile': ") + expect(openspecOf(root)).toEqual(before) + }) +}) + +// --- 8. relayed output is spelled cospec ---------------------------------------------- + +describe('8. relayed sync-specs output is spelled cospec', () => { + test('8.1 the carried-Purpose warning names cospec validate', async () => { + const root = mkTempRepo({ git: true }) + const name = R7_SHORT_PURPOSE.build(root) + const res = await own('8.1', root, ['sync-specs', name]) + expect(res.exitCode).toBe(0) + expect(res.stdout).toMatch( + /Warning: {2}widgets - carried Purpose is under \d+ characters; cospec validate --strict reports it as too brief\.\n/, + ) + }) + + // Runs last: every output the rows above captured. + test('8.2 no captured output names a bare allowlisted openspec command', () => { + expect(CAPTURED.some(({ row }) => row === '8.1')).toBe(true) + // Only rows where sync-specs ran to an answer of its own count: today's + // tree has no such command, so this row is failing-first. + expect(CAPTURED.every(({ text }) => !text.includes("unknown command 'sync-specs'"))).toBe(true) + const bare = CAPTURED.filter(({ text }) => respellRemedies(text) !== text).map(({ row }) => row) + expect(bare).toEqual([]) + }) +}) diff --git a/apps/cli/test/contract/unknown-option-differential.test.ts b/apps/cli/test/contract/unknown-option-differential.test.ts index 61f4e771..b448e489 100644 --- a/apps/cli/test/contract/unknown-option-differential.test.ts +++ b/apps/cli/test/contract/unknown-option-differential.test.ts @@ -407,12 +407,6 @@ const PENDING_ROWS: readonly Row[] = [ expect: 'pending', pendingFlag: '--no-copilot-cloud', }, - { - argv: ['archive', '--no-validate', 'x'], - command: 'archive', - expect: 'pending', - pendingFlag: '--no-validate', - }, // Pending subcommands (the BREAKING note names these). { argv: ['completion', 'install'], @@ -485,6 +479,14 @@ const CLI_SURFACE_ROWS: readonly Row[] = [ }, ] +/** + * The flag change `archive-and-sync-parity` implements: a pending row refused + * as not supported yet, which now parses and runs as the binary does. + */ +const ARCHIVE_SYNC_ROWS: readonly Row[] = [ + { argv: ['archive', '--no-validate', 'x'], command: 'archive', expect: 'same' }, +] + /** * Rows cospec does not answer as the binary does yet, keyed by argv, run as * `test.failing` until the commit implementing each surface removes its key. @@ -847,6 +849,10 @@ describe('unknown-option differential: cli-surface-parity flags', () => { register(CLI_SURFACE_ROWS) }) +describe('unknown-option differential: archive-and-sync-parity flags', () => { + register(ARCHIVE_SYNC_ROWS) +}) + describe('unknown-option differential: forward commands relay the binary', () => { register(FORWARD_ROWS) diff --git a/apps/cli/test/unit/__golden__/harness-render/agents/.agents/skills/cospec-archive-change/SKILL.md b/apps/cli/test/unit/__golden__/harness-render/agents/.agents/skills/cospec-archive-change/SKILL.md index f1f9c2a9..59ea8b63 100644 --- a/apps/cli/test/unit/__golden__/harness-render/agents/.agents/skills/cospec-archive-change/SKILL.md +++ b/apps/cli/test/unit/__golden__/harness-render/agents/.agents/skills/cospec-archive-change/SKILL.md @@ -6,7 +6,7 @@ compatibility: Requires the cospec CLI (@aligned-team/cospec). metadata: author: cospec generatedBy: cospec@test - contentHash: sha256:5c738047656ddb62b491db73be4646970619cfe5f01aee6779924b5bd8ef3373 + contentHash: sha256:06ced83cc52c3802650079fd3a3c303bdc97302d803b45574762e7f5bd67a1eb --- Archive a completed change. `cospec archive` validates it, merges its spec @@ -29,9 +29,15 @@ Relay the summary it prints verbatim: what was archived, which spec deltas were applied (`+a ~m -r →n`) or skipped, which sibling changes had blocker boxes checked, and which changes are now unblocked. -A change that introduces a brand-new capability (no living spec yet) may only -ADD requirements there — `cospec validate` refuses a MODIFIED, REMOVED, or -RENAMED op targeting it before archive ever runs the merge. +A change that introduces a brand-new capability (no living spec yet) may ADD +requirements there, and a REMOVED there is a no-op the merge warns about; +`cospec validate` refuses a MODIFIED or RENAMED op targeting it before archive +ever runs the merge. + +A change whose specs were synced early with `$cospec-sync-specs (Codex) or /cospec-sync-specs (other agents)` archives as a +no-op merge: the summary says the specs were already in sync, and both hard +gates (`archive/verification-incomplete`, `archive/scenario-preservation`) still +run. ## 3. On failure diff --git a/apps/cli/test/unit/__golden__/harness-render/agents/.agents/skills/cospec-sync-specs/SKILL.md b/apps/cli/test/unit/__golden__/harness-render/agents/.agents/skills/cospec-sync-specs/SKILL.md index ed31ed65..05d9309d 100644 --- a/apps/cli/test/unit/__golden__/harness-render/agents/.agents/skills/cospec-sync-specs/SKILL.md +++ b/apps/cli/test/unit/__golden__/harness-render/agents/.agents/skills/cospec-sync-specs/SKILL.md @@ -1,28 +1,28 @@ --- name: cospec-sync-specs -description: Explain how spec sync works (it runs inside archive) and preview what would merge. Also use when the user says "cospec sync specs", "sync the specs", or "openspec sync". +description: Merge a change's delta specs into the main specs without archiving it, exactly as archive would. Also use when the user says "cospec sync specs", "sync the specs", or "openspec sync". license: MIT compatibility: Requires the cospec CLI (@aligned-team/cospec). metadata: author: cospec generatedBy: cospec@test - contentHash: sha256:1bfa89a12c71041a0dfa9dc59c5007a6cae904ca8a880cb87dbaad91fa4b4814 + contentHash: sha256:629fe8bb821e930ca0b8c1ebb26381f8a60b6bc20453a4691a4df9f5d169e8c7 --- -Explain and preview spec synchronization. Spec sync is not a standalone step in -cospec. +Merge a change's delta specs into the main specs under `openspec/specs/` without +archiving the change. `cospec sync-specs` runs the archive's own merge — the +pinned OpenSpec archive, on a scratch copy of the specs — and copies back only +the main-spec files it changed, so the result is byte-for-byte what +`cospec archive` would write. The change stays active, and its later archive is +a no-op merge. -Delta specs in a change are merged into the living specs under `openspec/specs/` -**only** by `cospec archive`, which applies the merge and then verifies it as -one coupled operation. There is no supported mid-flight "sync now without -archiving" path. This is deliberate: a partial merge would leave a tree that -neither validates nor archives cleanly. +## 1. Select the change -## Preview what would merge +If the user named one, use it. Otherwise run `cospec list --json`: if exactly +one active change exists, use it and announce `Using change: `; if more +than one is plausible, ask. -If the user did not name a change, run `cospec list --json`: if exactly one -active change exists, use it and announce `Using change: `; if more than -one is plausible, ask. +## 2. Preview the merge ``` cospec validate @@ -31,13 +31,26 @@ cospec validate This runs the archive-precondition checks (targets exist, no zero-op deltas, no ADDED collisions, scenarios are well-formed) and reports anything that would make the merge fail. Then read the delta files under -`openspec/changes//specs/**/spec.md` to see the exact ADDED / MODIFIED / -REMOVED / RENAMED operations. +`openspec/changes//specs/**/spec.md` and tell the user which main specs +the sync will create, change or delete: the ADDED / MODIFIED / REMOVED / RENAMED +operations per capability, and any retirement (below) with its marker. -A delta that targets a capability with no living spec yet may only ADD -requirements — any MODIFIED, REMOVED, or RENAMED op there is a validate-time -ERROR (`archive/new-spec-non-added`), not something that surfaces later at merge -time. +A delta that targets a capability with no living spec yet may ADD requirements +there, and a REMOVED there is a no-op the merge warns about; a MODIFIED or +RENAMED op targeting it is a validate-time ERROR (`archive/new-spec-non-added`). + +## 3. Sync + +``` +cospec sync-specs +``` + +Relay what it prints: one `Synced:` line per main-spec file written or deleted +and the merge's totals, or that the specs were already in sync. The merge is the +archive's own, so a refusal here is the refusal archive would give — relay it +verbatim and fix what it names; never edit a main spec by hand to get past it. +Nothing is written when it refuses, and the change is never archived by this +step. ## Retiring a capability @@ -49,8 +62,9 @@ the marker the merge refuses and reports the missing marker as the blocking condition. Deleting the file also deletes its `## Purpose` — name both when you report a retirement, and give the user a way to recover the file. -## Actually sync +## Afterwards -Run `$cospec-archive-change (Codex) or /cospec-archive-change (other agents)` when the change is complete. The merge happens there, is -verified, and blocker check-offs fan out automatically. To sanity-check the -living specs on their own, run `cospec validate --specs`. +The change is still active: finish its tasks and verification, then run +`$cospec-archive-change (Codex) or /cospec-archive-change (other agents)`. Its merge finds the specs already in sync, and both hard +archive gates still run. To sanity-check the living specs on their own, run +`cospec validate --specs`. diff --git a/apps/cli/test/unit/__golden__/harness-render/agents/index.json b/apps/cli/test/unit/__golden__/harness-render/agents/index.json index a35fbb0c..e71885f8 100644 --- a/apps/cli/test/unit/__golden__/harness-render/agents/index.json +++ b/apps/cli/test/unit/__golden__/harness-render/agents/index.json @@ -11,7 +11,7 @@ "kind": "skill", "workflow": "archive", "harness": "agents", - "contentHash": "sha256:5c738047656ddb62b491db73be4646970619cfe5f01aee6779924b5bd8ef3373" + "contentHash": "sha256:06ced83cc52c3802650079fd3a3c303bdc97302d803b45574762e7f5bd67a1eb" }, { "path": ".agents/skills/cospec-bulk-archive-change/SKILL.md", @@ -67,7 +67,7 @@ "kind": "skill", "workflow": "sync-specs", "harness": "agents", - "contentHash": "sha256:1bfa89a12c71041a0dfa9dc59c5007a6cae904ca8a880cb87dbaad91fa4b4814" + "contentHash": "sha256:629fe8bb821e930ca0b8c1ebb26381f8a60b6bc20453a4691a4df9f5d169e8c7" }, { "path": ".agents/skills/cospec-update-change/SKILL.md", diff --git a/apps/cli/test/unit/__golden__/harness-render/all/.agents/skills/cospec-archive-change/SKILL.md b/apps/cli/test/unit/__golden__/harness-render/all/.agents/skills/cospec-archive-change/SKILL.md index f1f9c2a9..59ea8b63 100644 --- a/apps/cli/test/unit/__golden__/harness-render/all/.agents/skills/cospec-archive-change/SKILL.md +++ b/apps/cli/test/unit/__golden__/harness-render/all/.agents/skills/cospec-archive-change/SKILL.md @@ -6,7 +6,7 @@ compatibility: Requires the cospec CLI (@aligned-team/cospec). metadata: author: cospec generatedBy: cospec@test - contentHash: sha256:5c738047656ddb62b491db73be4646970619cfe5f01aee6779924b5bd8ef3373 + contentHash: sha256:06ced83cc52c3802650079fd3a3c303bdc97302d803b45574762e7f5bd67a1eb --- Archive a completed change. `cospec archive` validates it, merges its spec @@ -29,9 +29,15 @@ Relay the summary it prints verbatim: what was archived, which spec deltas were applied (`+a ~m -r →n`) or skipped, which sibling changes had blocker boxes checked, and which changes are now unblocked. -A change that introduces a brand-new capability (no living spec yet) may only -ADD requirements there — `cospec validate` refuses a MODIFIED, REMOVED, or -RENAMED op targeting it before archive ever runs the merge. +A change that introduces a brand-new capability (no living spec yet) may ADD +requirements there, and a REMOVED there is a no-op the merge warns about; +`cospec validate` refuses a MODIFIED or RENAMED op targeting it before archive +ever runs the merge. + +A change whose specs were synced early with `$cospec-sync-specs (Codex) or /cospec-sync-specs (other agents)` archives as a +no-op merge: the summary says the specs were already in sync, and both hard +gates (`archive/verification-incomplete`, `archive/scenario-preservation`) still +run. ## 3. On failure diff --git a/apps/cli/test/unit/__golden__/harness-render/all/.agents/skills/cospec-sync-specs/SKILL.md b/apps/cli/test/unit/__golden__/harness-render/all/.agents/skills/cospec-sync-specs/SKILL.md index ed31ed65..05d9309d 100644 --- a/apps/cli/test/unit/__golden__/harness-render/all/.agents/skills/cospec-sync-specs/SKILL.md +++ b/apps/cli/test/unit/__golden__/harness-render/all/.agents/skills/cospec-sync-specs/SKILL.md @@ -1,28 +1,28 @@ --- name: cospec-sync-specs -description: Explain how spec sync works (it runs inside archive) and preview what would merge. Also use when the user says "cospec sync specs", "sync the specs", or "openspec sync". +description: Merge a change's delta specs into the main specs without archiving it, exactly as archive would. Also use when the user says "cospec sync specs", "sync the specs", or "openspec sync". license: MIT compatibility: Requires the cospec CLI (@aligned-team/cospec). metadata: author: cospec generatedBy: cospec@test - contentHash: sha256:1bfa89a12c71041a0dfa9dc59c5007a6cae904ca8a880cb87dbaad91fa4b4814 + contentHash: sha256:629fe8bb821e930ca0b8c1ebb26381f8a60b6bc20453a4691a4df9f5d169e8c7 --- -Explain and preview spec synchronization. Spec sync is not a standalone step in -cospec. +Merge a change's delta specs into the main specs under `openspec/specs/` without +archiving the change. `cospec sync-specs` runs the archive's own merge — the +pinned OpenSpec archive, on a scratch copy of the specs — and copies back only +the main-spec files it changed, so the result is byte-for-byte what +`cospec archive` would write. The change stays active, and its later archive is +a no-op merge. -Delta specs in a change are merged into the living specs under `openspec/specs/` -**only** by `cospec archive`, which applies the merge and then verifies it as -one coupled operation. There is no supported mid-flight "sync now without -archiving" path. This is deliberate: a partial merge would leave a tree that -neither validates nor archives cleanly. +## 1. Select the change -## Preview what would merge +If the user named one, use it. Otherwise run `cospec list --json`: if exactly +one active change exists, use it and announce `Using change: `; if more +than one is plausible, ask. -If the user did not name a change, run `cospec list --json`: if exactly one -active change exists, use it and announce `Using change: `; if more than -one is plausible, ask. +## 2. Preview the merge ``` cospec validate @@ -31,13 +31,26 @@ cospec validate This runs the archive-precondition checks (targets exist, no zero-op deltas, no ADDED collisions, scenarios are well-formed) and reports anything that would make the merge fail. Then read the delta files under -`openspec/changes//specs/**/spec.md` to see the exact ADDED / MODIFIED / -REMOVED / RENAMED operations. +`openspec/changes//specs/**/spec.md` and tell the user which main specs +the sync will create, change or delete: the ADDED / MODIFIED / REMOVED / RENAMED +operations per capability, and any retirement (below) with its marker. -A delta that targets a capability with no living spec yet may only ADD -requirements — any MODIFIED, REMOVED, or RENAMED op there is a validate-time -ERROR (`archive/new-spec-non-added`), not something that surfaces later at merge -time. +A delta that targets a capability with no living spec yet may ADD requirements +there, and a REMOVED there is a no-op the merge warns about; a MODIFIED or +RENAMED op targeting it is a validate-time ERROR (`archive/new-spec-non-added`). + +## 3. Sync + +``` +cospec sync-specs +``` + +Relay what it prints: one `Synced:` line per main-spec file written or deleted +and the merge's totals, or that the specs were already in sync. The merge is the +archive's own, so a refusal here is the refusal archive would give — relay it +verbatim and fix what it names; never edit a main spec by hand to get past it. +Nothing is written when it refuses, and the change is never archived by this +step. ## Retiring a capability @@ -49,8 +62,9 @@ the marker the merge refuses and reports the missing marker as the blocking condition. Deleting the file also deletes its `## Purpose` — name both when you report a retirement, and give the user a way to recover the file. -## Actually sync +## Afterwards -Run `$cospec-archive-change (Codex) or /cospec-archive-change (other agents)` when the change is complete. The merge happens there, is -verified, and blocker check-offs fan out automatically. To sanity-check the -living specs on their own, run `cospec validate --specs`. +The change is still active: finish its tasks and verification, then run +`$cospec-archive-change (Codex) or /cospec-archive-change (other agents)`. Its merge finds the specs already in sync, and both hard +archive gates still run. To sanity-check the living specs on their own, run +`cospec validate --specs`. diff --git a/apps/cli/test/unit/__golden__/harness-render/all/.claude/commands/cospec/archive.md b/apps/cli/test/unit/__golden__/harness-render/all/.claude/commands/cospec/archive.md index 7beb4fd0..e02f03a1 100644 --- a/apps/cli/test/unit/__golden__/harness-render/all/.claude/commands/cospec/archive.md +++ b/apps/cli/test/unit/__golden__/harness-render/all/.claude/commands/cospec/archive.md @@ -8,7 +8,7 @@ tags: metadata: author: cospec generatedBy: cospec@test - contentHash: sha256:d31ab736702e834b863f53218615046ce0d07111014acda12131333653f2a56a + contentHash: sha256:4ef8b0e5e83be85e5cf8f1c811e6947bbaeaec8a6df188fa96609d6a46aa220f --- Archive a completed change. `cospec archive` validates it, merges its spec @@ -31,9 +31,15 @@ Relay the summary it prints verbatim: what was archived, which spec deltas were applied (`+a ~m -r →n`) or skipped, which sibling changes had blocker boxes checked, and which changes are now unblocked. -A change that introduces a brand-new capability (no living spec yet) may only -ADD requirements there — `cospec validate` refuses a MODIFIED, REMOVED, or -RENAMED op targeting it before archive ever runs the merge. +A change that introduces a brand-new capability (no living spec yet) may ADD +requirements there, and a REMOVED there is a no-op the merge warns about; +`cospec validate` refuses a MODIFIED or RENAMED op targeting it before archive +ever runs the merge. + +A change whose specs were synced early with `/cospec:sync-specs` archives as a +no-op merge: the summary says the specs were already in sync, and both hard +gates (`archive/verification-incomplete`, `archive/scenario-preservation`) still +run. ## 3. On failure diff --git a/apps/cli/test/unit/__golden__/harness-render/all/.claude/commands/cospec/sync-specs.md b/apps/cli/test/unit/__golden__/harness-render/all/.claude/commands/cospec/sync-specs.md index be4787bb..1a7a5631 100644 --- a/apps/cli/test/unit/__golden__/harness-render/all/.claude/commands/cospec/sync-specs.md +++ b/apps/cli/test/unit/__golden__/harness-render/all/.claude/commands/cospec/sync-specs.md @@ -1,6 +1,6 @@ --- name: "COSPEC: Sync specs" -description: Explain how spec sync works (it runs inside archive) and preview what would merge. Also use when the user says "cospec sync specs", "sync the specs", or "openspec sync". +description: Merge a change's delta specs into the main specs without archiving it, exactly as archive would. Also use when the user says "cospec sync specs", "sync the specs", or "openspec sync". category: Workflow tags: - cospec @@ -8,23 +8,23 @@ tags: metadata: author: cospec generatedBy: cospec@test - contentHash: sha256:8a7fceb611f7097e7ba242b56cc99aa60a68727d1afdc9e9137146742657d282 + contentHash: sha256:c6671819783b5458bb4ceb83e6c0ea0716c94d919a1b813d5ef25fef16307d21 --- -Explain and preview spec synchronization. Spec sync is not a standalone step in -cospec. +Merge a change's delta specs into the main specs under `openspec/specs/` without +archiving the change. `cospec sync-specs` runs the archive's own merge — the +pinned OpenSpec archive, on a scratch copy of the specs — and copies back only +the main-spec files it changed, so the result is byte-for-byte what +`cospec archive` would write. The change stays active, and its later archive is +a no-op merge. -Delta specs in a change are merged into the living specs under `openspec/specs/` -**only** by `cospec archive`, which applies the merge and then verifies it as -one coupled operation. There is no supported mid-flight "sync now without -archiving" path. This is deliberate: a partial merge would leave a tree that -neither validates nor archives cleanly. +## 1. Select the change -## Preview what would merge +If the user named one, use it. Otherwise run `cospec list --json`: if exactly +one active change exists, use it and announce `Using change: `; if more +than one is plausible, ask. -If the user did not name a change, run `cospec list --json`: if exactly one -active change exists, use it and announce `Using change: `; if more than -one is plausible, ask. +## 2. Preview the merge ``` cospec validate @@ -33,13 +33,26 @@ cospec validate This runs the archive-precondition checks (targets exist, no zero-op deltas, no ADDED collisions, scenarios are well-formed) and reports anything that would make the merge fail. Then read the delta files under -`openspec/changes//specs/**/spec.md` to see the exact ADDED / MODIFIED / -REMOVED / RENAMED operations. +`openspec/changes//specs/**/spec.md` and tell the user which main specs +the sync will create, change or delete: the ADDED / MODIFIED / REMOVED / RENAMED +operations per capability, and any retirement (below) with its marker. -A delta that targets a capability with no living spec yet may only ADD -requirements — any MODIFIED, REMOVED, or RENAMED op there is a validate-time -ERROR (`archive/new-spec-non-added`), not something that surfaces later at merge -time. +A delta that targets a capability with no living spec yet may ADD requirements +there, and a REMOVED there is a no-op the merge warns about; a MODIFIED or +RENAMED op targeting it is a validate-time ERROR (`archive/new-spec-non-added`). + +## 3. Sync + +``` +cospec sync-specs +``` + +Relay what it prints: one `Synced:` line per main-spec file written or deleted +and the merge's totals, or that the specs were already in sync. The merge is the +archive's own, so a refusal here is the refusal archive would give — relay it +verbatim and fix what it names; never edit a main spec by hand to get past it. +Nothing is written when it refuses, and the change is never archived by this +step. ## Retiring a capability @@ -51,8 +64,9 @@ the marker the merge refuses and reports the missing marker as the blocking condition. Deleting the file also deletes its `## Purpose` — name both when you report a retirement, and give the user a way to recover the file. -## Actually sync +## Afterwards -Run `/cospec:archive` when the change is complete. The merge happens there, is -verified, and blocker check-offs fan out automatically. To sanity-check the -living specs on their own, run `cospec validate --specs`. +The change is still active: finish its tasks and verification, then run +`/cospec:archive`. Its merge finds the specs already in sync, and both hard +archive gates still run. To sanity-check the living specs on their own, run +`cospec validate --specs`. diff --git a/apps/cli/test/unit/__golden__/harness-render/all/.claude/skills/cospec-archive-change/SKILL.md b/apps/cli/test/unit/__golden__/harness-render/all/.claude/skills/cospec-archive-change/SKILL.md index e2fd686c..ab0ef3bf 100644 --- a/apps/cli/test/unit/__golden__/harness-render/all/.claude/skills/cospec-archive-change/SKILL.md +++ b/apps/cli/test/unit/__golden__/harness-render/all/.claude/skills/cospec-archive-change/SKILL.md @@ -6,7 +6,7 @@ compatibility: Requires the cospec CLI (@aligned-team/cospec). metadata: author: cospec generatedBy: cospec@test - contentHash: sha256:d31ab736702e834b863f53218615046ce0d07111014acda12131333653f2a56a + contentHash: sha256:4ef8b0e5e83be85e5cf8f1c811e6947bbaeaec8a6df188fa96609d6a46aa220f --- Archive a completed change. `cospec archive` validates it, merges its spec @@ -29,9 +29,15 @@ Relay the summary it prints verbatim: what was archived, which spec deltas were applied (`+a ~m -r →n`) or skipped, which sibling changes had blocker boxes checked, and which changes are now unblocked. -A change that introduces a brand-new capability (no living spec yet) may only -ADD requirements there — `cospec validate` refuses a MODIFIED, REMOVED, or -RENAMED op targeting it before archive ever runs the merge. +A change that introduces a brand-new capability (no living spec yet) may ADD +requirements there, and a REMOVED there is a no-op the merge warns about; +`cospec validate` refuses a MODIFIED or RENAMED op targeting it before archive +ever runs the merge. + +A change whose specs were synced early with `/cospec:sync-specs` archives as a +no-op merge: the summary says the specs were already in sync, and both hard +gates (`archive/verification-incomplete`, `archive/scenario-preservation`) still +run. ## 3. On failure diff --git a/apps/cli/test/unit/__golden__/harness-render/all/.claude/skills/cospec-sync-specs/SKILL.md b/apps/cli/test/unit/__golden__/harness-render/all/.claude/skills/cospec-sync-specs/SKILL.md index 737eedd7..7ca83573 100644 --- a/apps/cli/test/unit/__golden__/harness-render/all/.claude/skills/cospec-sync-specs/SKILL.md +++ b/apps/cli/test/unit/__golden__/harness-render/all/.claude/skills/cospec-sync-specs/SKILL.md @@ -1,28 +1,28 @@ --- name: cospec-sync-specs -description: Explain how spec sync works (it runs inside archive) and preview what would merge. Also use when the user says "cospec sync specs", "sync the specs", or "openspec sync". +description: Merge a change's delta specs into the main specs without archiving it, exactly as archive would. Also use when the user says "cospec sync specs", "sync the specs", or "openspec sync". license: MIT compatibility: Requires the cospec CLI (@aligned-team/cospec). metadata: author: cospec generatedBy: cospec@test - contentHash: sha256:8a7fceb611f7097e7ba242b56cc99aa60a68727d1afdc9e9137146742657d282 + contentHash: sha256:c6671819783b5458bb4ceb83e6c0ea0716c94d919a1b813d5ef25fef16307d21 --- -Explain and preview spec synchronization. Spec sync is not a standalone step in -cospec. +Merge a change's delta specs into the main specs under `openspec/specs/` without +archiving the change. `cospec sync-specs` runs the archive's own merge — the +pinned OpenSpec archive, on a scratch copy of the specs — and copies back only +the main-spec files it changed, so the result is byte-for-byte what +`cospec archive` would write. The change stays active, and its later archive is +a no-op merge. -Delta specs in a change are merged into the living specs under `openspec/specs/` -**only** by `cospec archive`, which applies the merge and then verifies it as -one coupled operation. There is no supported mid-flight "sync now without -archiving" path. This is deliberate: a partial merge would leave a tree that -neither validates nor archives cleanly. +## 1. Select the change -## Preview what would merge +If the user named one, use it. Otherwise run `cospec list --json`: if exactly +one active change exists, use it and announce `Using change: `; if more +than one is plausible, ask. -If the user did not name a change, run `cospec list --json`: if exactly one -active change exists, use it and announce `Using change: `; if more than -one is plausible, ask. +## 2. Preview the merge ``` cospec validate @@ -31,13 +31,26 @@ cospec validate This runs the archive-precondition checks (targets exist, no zero-op deltas, no ADDED collisions, scenarios are well-formed) and reports anything that would make the merge fail. Then read the delta files under -`openspec/changes//specs/**/spec.md` to see the exact ADDED / MODIFIED / -REMOVED / RENAMED operations. +`openspec/changes//specs/**/spec.md` and tell the user which main specs +the sync will create, change or delete: the ADDED / MODIFIED / REMOVED / RENAMED +operations per capability, and any retirement (below) with its marker. -A delta that targets a capability with no living spec yet may only ADD -requirements — any MODIFIED, REMOVED, or RENAMED op there is a validate-time -ERROR (`archive/new-spec-non-added`), not something that surfaces later at merge -time. +A delta that targets a capability with no living spec yet may ADD requirements +there, and a REMOVED there is a no-op the merge warns about; a MODIFIED or +RENAMED op targeting it is a validate-time ERROR (`archive/new-spec-non-added`). + +## 3. Sync + +``` +cospec sync-specs +``` + +Relay what it prints: one `Synced:` line per main-spec file written or deleted +and the merge's totals, or that the specs were already in sync. The merge is the +archive's own, so a refusal here is the refusal archive would give — relay it +verbatim and fix what it names; never edit a main spec by hand to get past it. +Nothing is written when it refuses, and the change is never archived by this +step. ## Retiring a capability @@ -49,8 +62,9 @@ the marker the merge refuses and reports the missing marker as the blocking condition. Deleting the file also deletes its `## Purpose` — name both when you report a retirement, and give the user a way to recover the file. -## Actually sync +## Afterwards -Run `/cospec:archive` when the change is complete. The merge happens there, is -verified, and blocker check-offs fan out automatically. To sanity-check the -living specs on their own, run `cospec validate --specs`. +The change is still active: finish its tasks and verification, then run +`/cospec:archive`. Its merge finds the specs already in sync, and both hard +archive gates still run. To sanity-check the living specs on their own, run +`cospec validate --specs`. diff --git a/apps/cli/test/unit/__golden__/harness-render/all/.opencode/commands/cospec-archive.md b/apps/cli/test/unit/__golden__/harness-render/all/.opencode/commands/cospec-archive.md index 685594f2..2a6084b8 100644 --- a/apps/cli/test/unit/__golden__/harness-render/all/.opencode/commands/cospec-archive.md +++ b/apps/cli/test/unit/__golden__/harness-render/all/.opencode/commands/cospec-archive.md @@ -3,7 +3,7 @@ description: Archive a completed change — validate, merge specs, verify, and f metadata: author: cospec generatedBy: cospec@test - contentHash: sha256:70ef3ee289bf010b42e94bca2c2274d276842d5018fc6c9a199547679a317da6 + contentHash: sha256:52b0c6d92cb08508c8c44746052fffaa4ed31bb322c485737619cc3971a98794 --- Archive a completed change. `cospec archive` validates it, merges its spec @@ -28,9 +28,15 @@ Relay the summary it prints verbatim: what was archived, which spec deltas were applied (`+a ~m -r →n`) or skipped, which sibling changes had blocker boxes checked, and which changes are now unblocked. -A change that introduces a brand-new capability (no living spec yet) may only -ADD requirements there — `cospec validate` refuses a MODIFIED, REMOVED, or -RENAMED op targeting it before archive ever runs the merge. +A change that introduces a brand-new capability (no living spec yet) may ADD +requirements there, and a REMOVED there is a no-op the merge warns about; +`cospec validate` refuses a MODIFIED or RENAMED op targeting it before archive +ever runs the merge. + +A change whose specs were synced early with `/cospec-sync-specs` archives as a +no-op merge: the summary says the specs were already in sync, and both hard +gates (`archive/verification-incomplete`, `archive/scenario-preservation`) still +run. ## 3. On failure diff --git a/apps/cli/test/unit/__golden__/harness-render/all/.opencode/commands/cospec-sync-specs.md b/apps/cli/test/unit/__golden__/harness-render/all/.opencode/commands/cospec-sync-specs.md index 15ed0e65..ecd06af7 100644 --- a/apps/cli/test/unit/__golden__/harness-render/all/.opencode/commands/cospec-sync-specs.md +++ b/apps/cli/test/unit/__golden__/harness-render/all/.opencode/commands/cospec-sync-specs.md @@ -1,27 +1,27 @@ --- -description: Explain how spec sync works (it runs inside archive) and preview what would merge. Also use when the user says "cospec sync specs", "sync the specs", or "openspec sync". +description: Merge a change's delta specs into the main specs without archiving it, exactly as archive would. Also use when the user says "cospec sync specs", "sync the specs", or "openspec sync". metadata: author: cospec generatedBy: cospec@test - contentHash: sha256:78a4d09275959566ff92a490de91a93a695dd0acdbc259620b3c4156c61ba16c + contentHash: sha256:a64fe2fd251a64fb752391a0bc7298ca49edde939b1442bfedc3d0e6e5f33992 --- -Explain and preview spec synchronization. Spec sync is not a standalone step in -cospec. - -Delta specs in a change are merged into the living specs under `openspec/specs/` -**only** by `cospec archive`, which applies the merge and then verifies it as -one coupled operation. There is no supported mid-flight "sync now without -archiving" path. This is deliberate: a partial merge would leave a tree that -neither validates nor archives cleanly. +Merge a change's delta specs into the main specs under `openspec/specs/` without +archiving the change. `cospec sync-specs` runs the archive's own merge — the +pinned OpenSpec archive, on a scratch copy of the specs — and copies back only +the main-spec files it changed, so the result is byte-for-byte what +`cospec archive` would write. The change stays active, and its later archive is +a no-op merge. **Provided arguments**: $ARGUMENTS -## Preview what would merge +## 1. Select the change + +If the user named one, use it. Otherwise run `cospec list --json`: if exactly +one active change exists, use it and announce `Using change: `; if more +than one is plausible, ask. -If the user did not name a change, run `cospec list --json`: if exactly one -active change exists, use it and announce `Using change: `; if more than -one is plausible, ask. +## 2. Preview the merge ``` cospec validate @@ -30,13 +30,26 @@ cospec validate This runs the archive-precondition checks (targets exist, no zero-op deltas, no ADDED collisions, scenarios are well-formed) and reports anything that would make the merge fail. Then read the delta files under -`openspec/changes//specs/**/spec.md` to see the exact ADDED / MODIFIED / -REMOVED / RENAMED operations. +`openspec/changes//specs/**/spec.md` and tell the user which main specs +the sync will create, change or delete: the ADDED / MODIFIED / REMOVED / RENAMED +operations per capability, and any retirement (below) with its marker. + +A delta that targets a capability with no living spec yet may ADD requirements +there, and a REMOVED there is a no-op the merge warns about; a MODIFIED or +RENAMED op targeting it is a validate-time ERROR (`archive/new-spec-non-added`). + +## 3. Sync + +``` +cospec sync-specs +``` -A delta that targets a capability with no living spec yet may only ADD -requirements — any MODIFIED, REMOVED, or RENAMED op there is a validate-time -ERROR (`archive/new-spec-non-added`), not something that surfaces later at merge -time. +Relay what it prints: one `Synced:` line per main-spec file written or deleted +and the merge's totals, or that the specs were already in sync. The merge is the +archive's own, so a refusal here is the refusal archive would give — relay it +verbatim and fix what it names; never edit a main spec by hand to get past it. +Nothing is written when it refuses, and the change is never archived by this +step. ## Retiring a capability @@ -48,8 +61,9 @@ the marker the merge refuses and reports the missing marker as the blocking condition. Deleting the file also deletes its `## Purpose` — name both when you report a retirement, and give the user a way to recover the file. -## Actually sync +## Afterwards -Run `/cospec-archive` when the change is complete. The merge happens there, is -verified, and blocker check-offs fan out automatically. To sanity-check the -living specs on their own, run `cospec validate --specs`. +The change is still active: finish its tasks and verification, then run +`/cospec-archive`. Its merge finds the specs already in sync, and both hard +archive gates still run. To sanity-check the living specs on their own, run +`cospec validate --specs`. diff --git a/apps/cli/test/unit/__golden__/harness-render/all/.opencode/skills/cospec-archive-change/SKILL.md b/apps/cli/test/unit/__golden__/harness-render/all/.opencode/skills/cospec-archive-change/SKILL.md index a28e804c..cddbc576 100644 --- a/apps/cli/test/unit/__golden__/harness-render/all/.opencode/skills/cospec-archive-change/SKILL.md +++ b/apps/cli/test/unit/__golden__/harness-render/all/.opencode/skills/cospec-archive-change/SKILL.md @@ -6,7 +6,7 @@ compatibility: Requires the cospec CLI (@aligned-team/cospec). metadata: author: cospec generatedBy: cospec@test - contentHash: sha256:4b9b6b08eb117becabf1d8f885fed7169b1712f092ea8d8653e2cb82220510e8 + contentHash: sha256:1f0e3bc2a8cf332628b0a74650bb73691913e94445ae43074167315b946548f6 --- Archive a completed change. `cospec archive` validates it, merges its spec @@ -29,9 +29,15 @@ Relay the summary it prints verbatim: what was archived, which spec deltas were applied (`+a ~m -r →n`) or skipped, which sibling changes had blocker boxes checked, and which changes are now unblocked. -A change that introduces a brand-new capability (no living spec yet) may only -ADD requirements there — `cospec validate` refuses a MODIFIED, REMOVED, or -RENAMED op targeting it before archive ever runs the merge. +A change that introduces a brand-new capability (no living spec yet) may ADD +requirements there, and a REMOVED there is a no-op the merge warns about; +`cospec validate` refuses a MODIFIED or RENAMED op targeting it before archive +ever runs the merge. + +A change whose specs were synced early with `/cospec-sync-specs` archives as a +no-op merge: the summary says the specs were already in sync, and both hard +gates (`archive/verification-incomplete`, `archive/scenario-preservation`) still +run. ## 3. On failure diff --git a/apps/cli/test/unit/__golden__/harness-render/all/.opencode/skills/cospec-sync-specs/SKILL.md b/apps/cli/test/unit/__golden__/harness-render/all/.opencode/skills/cospec-sync-specs/SKILL.md index 5e5d2538..966f6de4 100644 --- a/apps/cli/test/unit/__golden__/harness-render/all/.opencode/skills/cospec-sync-specs/SKILL.md +++ b/apps/cli/test/unit/__golden__/harness-render/all/.opencode/skills/cospec-sync-specs/SKILL.md @@ -1,28 +1,28 @@ --- name: cospec-sync-specs -description: Explain how spec sync works (it runs inside archive) and preview what would merge. Also use when the user says "cospec sync specs", "sync the specs", or "openspec sync". +description: Merge a change's delta specs into the main specs without archiving it, exactly as archive would. Also use when the user says "cospec sync specs", "sync the specs", or "openspec sync". license: MIT compatibility: Requires the cospec CLI (@aligned-team/cospec). metadata: author: cospec generatedBy: cospec@test - contentHash: sha256:dc48d3f5037277912711548e57c0feab65c5a8c64c32bf6070334632a9dd60d0 + contentHash: sha256:6557ec661d62b69c5956fe188cb2b2cd37639bfceb3fb7b6bb2330c0db9321ed --- -Explain and preview spec synchronization. Spec sync is not a standalone step in -cospec. +Merge a change's delta specs into the main specs under `openspec/specs/` without +archiving the change. `cospec sync-specs` runs the archive's own merge — the +pinned OpenSpec archive, on a scratch copy of the specs — and copies back only +the main-spec files it changed, so the result is byte-for-byte what +`cospec archive` would write. The change stays active, and its later archive is +a no-op merge. -Delta specs in a change are merged into the living specs under `openspec/specs/` -**only** by `cospec archive`, which applies the merge and then verifies it as -one coupled operation. There is no supported mid-flight "sync now without -archiving" path. This is deliberate: a partial merge would leave a tree that -neither validates nor archives cleanly. +## 1. Select the change -## Preview what would merge +If the user named one, use it. Otherwise run `cospec list --json`: if exactly +one active change exists, use it and announce `Using change: `; if more +than one is plausible, ask. -If the user did not name a change, run `cospec list --json`: if exactly one -active change exists, use it and announce `Using change: `; if more than -one is plausible, ask. +## 2. Preview the merge ``` cospec validate @@ -31,13 +31,26 @@ cospec validate This runs the archive-precondition checks (targets exist, no zero-op deltas, no ADDED collisions, scenarios are well-formed) and reports anything that would make the merge fail. Then read the delta files under -`openspec/changes//specs/**/spec.md` to see the exact ADDED / MODIFIED / -REMOVED / RENAMED operations. +`openspec/changes//specs/**/spec.md` and tell the user which main specs +the sync will create, change or delete: the ADDED / MODIFIED / REMOVED / RENAMED +operations per capability, and any retirement (below) with its marker. -A delta that targets a capability with no living spec yet may only ADD -requirements — any MODIFIED, REMOVED, or RENAMED op there is a validate-time -ERROR (`archive/new-spec-non-added`), not something that surfaces later at merge -time. +A delta that targets a capability with no living spec yet may ADD requirements +there, and a REMOVED there is a no-op the merge warns about; a MODIFIED or +RENAMED op targeting it is a validate-time ERROR (`archive/new-spec-non-added`). + +## 3. Sync + +``` +cospec sync-specs +``` + +Relay what it prints: one `Synced:` line per main-spec file written or deleted +and the merge's totals, or that the specs were already in sync. The merge is the +archive's own, so a refusal here is the refusal archive would give — relay it +verbatim and fix what it names; never edit a main spec by hand to get past it. +Nothing is written when it refuses, and the change is never archived by this +step. ## Retiring a capability @@ -49,8 +62,9 @@ the marker the merge refuses and reports the missing marker as the blocking condition. Deleting the file also deletes its `## Purpose` — name both when you report a retirement, and give the user a way to recover the file. -## Actually sync +## Afterwards -Run `/cospec-archive` when the change is complete. The merge happens there, is -verified, and blocker check-offs fan out automatically. To sanity-check the -living specs on their own, run `cospec validate --specs`. +The change is still active: finish its tasks and verification, then run +`/cospec-archive`. Its merge finds the specs already in sync, and both hard +archive gates still run. To sanity-check the living specs on their own, run +`cospec validate --specs`. diff --git a/apps/cli/test/unit/__golden__/harness-render/all/index.json b/apps/cli/test/unit/__golden__/harness-render/all/index.json index 1224a125..89ed8914 100644 --- a/apps/cli/test/unit/__golden__/harness-render/all/index.json +++ b/apps/cli/test/unit/__golden__/harness-render/all/index.json @@ -11,7 +11,7 @@ "kind": "skill", "workflow": "archive", "harness": "codex", - "contentHash": "sha256:5c738047656ddb62b491db73be4646970619cfe5f01aee6779924b5bd8ef3373" + "contentHash": "sha256:06ced83cc52c3802650079fd3a3c303bdc97302d803b45574762e7f5bd67a1eb" }, { "path": ".agents/skills/cospec-bulk-archive-change/SKILL.md", @@ -67,7 +67,7 @@ "kind": "skill", "workflow": "sync-specs", "harness": "codex", - "contentHash": "sha256:1bfa89a12c71041a0dfa9dc59c5007a6cae904ca8a880cb87dbaad91fa4b4814" + "contentHash": "sha256:629fe8bb821e930ca0b8c1ebb26381f8a60b6bc20453a4691a4df9f5d169e8c7" }, { "path": ".agents/skills/cospec-update-change/SKILL.md", @@ -95,7 +95,7 @@ "kind": "command", "workflow": "archive", "harness": "claude", - "contentHash": "sha256:d31ab736702e834b863f53218615046ce0d07111014acda12131333653f2a56a" + "contentHash": "sha256:4ef8b0e5e83be85e5cf8f1c811e6947bbaeaec8a6df188fa96609d6a46aa220f" }, { "path": ".claude/commands/cospec/bulk-archive.md", @@ -151,7 +151,7 @@ "kind": "command", "workflow": "sync-specs", "harness": "claude", - "contentHash": "sha256:8a7fceb611f7097e7ba242b56cc99aa60a68727d1afdc9e9137146742657d282" + "contentHash": "sha256:c6671819783b5458bb4ceb83e6c0ea0716c94d919a1b813d5ef25fef16307d21" }, { "path": ".claude/commands/cospec/update.md", @@ -179,7 +179,7 @@ "kind": "skill", "workflow": "archive", "harness": "claude", - "contentHash": "sha256:d31ab736702e834b863f53218615046ce0d07111014acda12131333653f2a56a" + "contentHash": "sha256:4ef8b0e5e83be85e5cf8f1c811e6947bbaeaec8a6df188fa96609d6a46aa220f" }, { "path": ".claude/skills/cospec-bulk-archive-change/SKILL.md", @@ -235,7 +235,7 @@ "kind": "skill", "workflow": "sync-specs", "harness": "claude", - "contentHash": "sha256:8a7fceb611f7097e7ba242b56cc99aa60a68727d1afdc9e9137146742657d282" + "contentHash": "sha256:c6671819783b5458bb4ceb83e6c0ea0716c94d919a1b813d5ef25fef16307d21" }, { "path": ".claude/skills/cospec-update-change/SKILL.md", @@ -270,7 +270,7 @@ "kind": "command", "workflow": "archive", "harness": "opencode", - "contentHash": "sha256:70ef3ee289bf010b42e94bca2c2274d276842d5018fc6c9a199547679a317da6" + "contentHash": "sha256:52b0c6d92cb08508c8c44746052fffaa4ed31bb322c485737619cc3971a98794" }, { "path": ".opencode/commands/cospec-bulk-archive.md", @@ -326,7 +326,7 @@ "kind": "command", "workflow": "sync-specs", "harness": "opencode", - "contentHash": "sha256:78a4d09275959566ff92a490de91a93a695dd0acdbc259620b3c4156c61ba16c" + "contentHash": "sha256:a64fe2fd251a64fb752391a0bc7298ca49edde939b1442bfedc3d0e6e5f33992" }, { "path": ".opencode/commands/cospec-update.md", @@ -354,7 +354,7 @@ "kind": "skill", "workflow": "archive", "harness": "opencode", - "contentHash": "sha256:4b9b6b08eb117becabf1d8f885fed7169b1712f092ea8d8653e2cb82220510e8" + "contentHash": "sha256:1f0e3bc2a8cf332628b0a74650bb73691913e94445ae43074167315b946548f6" }, { "path": ".opencode/skills/cospec-bulk-archive-change/SKILL.md", @@ -410,7 +410,7 @@ "kind": "skill", "workflow": "sync-specs", "harness": "opencode", - "contentHash": "sha256:dc48d3f5037277912711548e57c0feab65c5a8c64c32bf6070334632a9dd60d0" + "contentHash": "sha256:6557ec661d62b69c5956fe188cb2b2cd37639bfceb3fb7b6bb2330c0db9321ed" }, { "path": ".opencode/skills/cospec-update-change/SKILL.md", diff --git a/apps/cli/test/unit/__golden__/harness-render/claude/.claude/commands/cospec/archive.md b/apps/cli/test/unit/__golden__/harness-render/claude/.claude/commands/cospec/archive.md index 7beb4fd0..e02f03a1 100644 --- a/apps/cli/test/unit/__golden__/harness-render/claude/.claude/commands/cospec/archive.md +++ b/apps/cli/test/unit/__golden__/harness-render/claude/.claude/commands/cospec/archive.md @@ -8,7 +8,7 @@ tags: metadata: author: cospec generatedBy: cospec@test - contentHash: sha256:d31ab736702e834b863f53218615046ce0d07111014acda12131333653f2a56a + contentHash: sha256:4ef8b0e5e83be85e5cf8f1c811e6947bbaeaec8a6df188fa96609d6a46aa220f --- Archive a completed change. `cospec archive` validates it, merges its spec @@ -31,9 +31,15 @@ Relay the summary it prints verbatim: what was archived, which spec deltas were applied (`+a ~m -r →n`) or skipped, which sibling changes had blocker boxes checked, and which changes are now unblocked. -A change that introduces a brand-new capability (no living spec yet) may only -ADD requirements there — `cospec validate` refuses a MODIFIED, REMOVED, or -RENAMED op targeting it before archive ever runs the merge. +A change that introduces a brand-new capability (no living spec yet) may ADD +requirements there, and a REMOVED there is a no-op the merge warns about; +`cospec validate` refuses a MODIFIED or RENAMED op targeting it before archive +ever runs the merge. + +A change whose specs were synced early with `/cospec:sync-specs` archives as a +no-op merge: the summary says the specs were already in sync, and both hard +gates (`archive/verification-incomplete`, `archive/scenario-preservation`) still +run. ## 3. On failure diff --git a/apps/cli/test/unit/__golden__/harness-render/claude/.claude/commands/cospec/sync-specs.md b/apps/cli/test/unit/__golden__/harness-render/claude/.claude/commands/cospec/sync-specs.md index be4787bb..1a7a5631 100644 --- a/apps/cli/test/unit/__golden__/harness-render/claude/.claude/commands/cospec/sync-specs.md +++ b/apps/cli/test/unit/__golden__/harness-render/claude/.claude/commands/cospec/sync-specs.md @@ -1,6 +1,6 @@ --- name: "COSPEC: Sync specs" -description: Explain how spec sync works (it runs inside archive) and preview what would merge. Also use when the user says "cospec sync specs", "sync the specs", or "openspec sync". +description: Merge a change's delta specs into the main specs without archiving it, exactly as archive would. Also use when the user says "cospec sync specs", "sync the specs", or "openspec sync". category: Workflow tags: - cospec @@ -8,23 +8,23 @@ tags: metadata: author: cospec generatedBy: cospec@test - contentHash: sha256:8a7fceb611f7097e7ba242b56cc99aa60a68727d1afdc9e9137146742657d282 + contentHash: sha256:c6671819783b5458bb4ceb83e6c0ea0716c94d919a1b813d5ef25fef16307d21 --- -Explain and preview spec synchronization. Spec sync is not a standalone step in -cospec. +Merge a change's delta specs into the main specs under `openspec/specs/` without +archiving the change. `cospec sync-specs` runs the archive's own merge — the +pinned OpenSpec archive, on a scratch copy of the specs — and copies back only +the main-spec files it changed, so the result is byte-for-byte what +`cospec archive` would write. The change stays active, and its later archive is +a no-op merge. -Delta specs in a change are merged into the living specs under `openspec/specs/` -**only** by `cospec archive`, which applies the merge and then verifies it as -one coupled operation. There is no supported mid-flight "sync now without -archiving" path. This is deliberate: a partial merge would leave a tree that -neither validates nor archives cleanly. +## 1. Select the change -## Preview what would merge +If the user named one, use it. Otherwise run `cospec list --json`: if exactly +one active change exists, use it and announce `Using change: `; if more +than one is plausible, ask. -If the user did not name a change, run `cospec list --json`: if exactly one -active change exists, use it and announce `Using change: `; if more than -one is plausible, ask. +## 2. Preview the merge ``` cospec validate @@ -33,13 +33,26 @@ cospec validate This runs the archive-precondition checks (targets exist, no zero-op deltas, no ADDED collisions, scenarios are well-formed) and reports anything that would make the merge fail. Then read the delta files under -`openspec/changes//specs/**/spec.md` to see the exact ADDED / MODIFIED / -REMOVED / RENAMED operations. +`openspec/changes//specs/**/spec.md` and tell the user which main specs +the sync will create, change or delete: the ADDED / MODIFIED / REMOVED / RENAMED +operations per capability, and any retirement (below) with its marker. -A delta that targets a capability with no living spec yet may only ADD -requirements — any MODIFIED, REMOVED, or RENAMED op there is a validate-time -ERROR (`archive/new-spec-non-added`), not something that surfaces later at merge -time. +A delta that targets a capability with no living spec yet may ADD requirements +there, and a REMOVED there is a no-op the merge warns about; a MODIFIED or +RENAMED op targeting it is a validate-time ERROR (`archive/new-spec-non-added`). + +## 3. Sync + +``` +cospec sync-specs +``` + +Relay what it prints: one `Synced:` line per main-spec file written or deleted +and the merge's totals, or that the specs were already in sync. The merge is the +archive's own, so a refusal here is the refusal archive would give — relay it +verbatim and fix what it names; never edit a main spec by hand to get past it. +Nothing is written when it refuses, and the change is never archived by this +step. ## Retiring a capability @@ -51,8 +64,9 @@ the marker the merge refuses and reports the missing marker as the blocking condition. Deleting the file also deletes its `## Purpose` — name both when you report a retirement, and give the user a way to recover the file. -## Actually sync +## Afterwards -Run `/cospec:archive` when the change is complete. The merge happens there, is -verified, and blocker check-offs fan out automatically. To sanity-check the -living specs on their own, run `cospec validate --specs`. +The change is still active: finish its tasks and verification, then run +`/cospec:archive`. Its merge finds the specs already in sync, and both hard +archive gates still run. To sanity-check the living specs on their own, run +`cospec validate --specs`. diff --git a/apps/cli/test/unit/__golden__/harness-render/claude/.claude/skills/cospec-archive-change/SKILL.md b/apps/cli/test/unit/__golden__/harness-render/claude/.claude/skills/cospec-archive-change/SKILL.md index e2fd686c..ab0ef3bf 100644 --- a/apps/cli/test/unit/__golden__/harness-render/claude/.claude/skills/cospec-archive-change/SKILL.md +++ b/apps/cli/test/unit/__golden__/harness-render/claude/.claude/skills/cospec-archive-change/SKILL.md @@ -6,7 +6,7 @@ compatibility: Requires the cospec CLI (@aligned-team/cospec). metadata: author: cospec generatedBy: cospec@test - contentHash: sha256:d31ab736702e834b863f53218615046ce0d07111014acda12131333653f2a56a + contentHash: sha256:4ef8b0e5e83be85e5cf8f1c811e6947bbaeaec8a6df188fa96609d6a46aa220f --- Archive a completed change. `cospec archive` validates it, merges its spec @@ -29,9 +29,15 @@ Relay the summary it prints verbatim: what was archived, which spec deltas were applied (`+a ~m -r →n`) or skipped, which sibling changes had blocker boxes checked, and which changes are now unblocked. -A change that introduces a brand-new capability (no living spec yet) may only -ADD requirements there — `cospec validate` refuses a MODIFIED, REMOVED, or -RENAMED op targeting it before archive ever runs the merge. +A change that introduces a brand-new capability (no living spec yet) may ADD +requirements there, and a REMOVED there is a no-op the merge warns about; +`cospec validate` refuses a MODIFIED or RENAMED op targeting it before archive +ever runs the merge. + +A change whose specs were synced early with `/cospec:sync-specs` archives as a +no-op merge: the summary says the specs were already in sync, and both hard +gates (`archive/verification-incomplete`, `archive/scenario-preservation`) still +run. ## 3. On failure diff --git a/apps/cli/test/unit/__golden__/harness-render/claude/.claude/skills/cospec-sync-specs/SKILL.md b/apps/cli/test/unit/__golden__/harness-render/claude/.claude/skills/cospec-sync-specs/SKILL.md index 737eedd7..7ca83573 100644 --- a/apps/cli/test/unit/__golden__/harness-render/claude/.claude/skills/cospec-sync-specs/SKILL.md +++ b/apps/cli/test/unit/__golden__/harness-render/claude/.claude/skills/cospec-sync-specs/SKILL.md @@ -1,28 +1,28 @@ --- name: cospec-sync-specs -description: Explain how spec sync works (it runs inside archive) and preview what would merge. Also use when the user says "cospec sync specs", "sync the specs", or "openspec sync". +description: Merge a change's delta specs into the main specs without archiving it, exactly as archive would. Also use when the user says "cospec sync specs", "sync the specs", or "openspec sync". license: MIT compatibility: Requires the cospec CLI (@aligned-team/cospec). metadata: author: cospec generatedBy: cospec@test - contentHash: sha256:8a7fceb611f7097e7ba242b56cc99aa60a68727d1afdc9e9137146742657d282 + contentHash: sha256:c6671819783b5458bb4ceb83e6c0ea0716c94d919a1b813d5ef25fef16307d21 --- -Explain and preview spec synchronization. Spec sync is not a standalone step in -cospec. +Merge a change's delta specs into the main specs under `openspec/specs/` without +archiving the change. `cospec sync-specs` runs the archive's own merge — the +pinned OpenSpec archive, on a scratch copy of the specs — and copies back only +the main-spec files it changed, so the result is byte-for-byte what +`cospec archive` would write. The change stays active, and its later archive is +a no-op merge. -Delta specs in a change are merged into the living specs under `openspec/specs/` -**only** by `cospec archive`, which applies the merge and then verifies it as -one coupled operation. There is no supported mid-flight "sync now without -archiving" path. This is deliberate: a partial merge would leave a tree that -neither validates nor archives cleanly. +## 1. Select the change -## Preview what would merge +If the user named one, use it. Otherwise run `cospec list --json`: if exactly +one active change exists, use it and announce `Using change: `; if more +than one is plausible, ask. -If the user did not name a change, run `cospec list --json`: if exactly one -active change exists, use it and announce `Using change: `; if more than -one is plausible, ask. +## 2. Preview the merge ``` cospec validate @@ -31,13 +31,26 @@ cospec validate This runs the archive-precondition checks (targets exist, no zero-op deltas, no ADDED collisions, scenarios are well-formed) and reports anything that would make the merge fail. Then read the delta files under -`openspec/changes//specs/**/spec.md` to see the exact ADDED / MODIFIED / -REMOVED / RENAMED operations. +`openspec/changes//specs/**/spec.md` and tell the user which main specs +the sync will create, change or delete: the ADDED / MODIFIED / REMOVED / RENAMED +operations per capability, and any retirement (below) with its marker. -A delta that targets a capability with no living spec yet may only ADD -requirements — any MODIFIED, REMOVED, or RENAMED op there is a validate-time -ERROR (`archive/new-spec-non-added`), not something that surfaces later at merge -time. +A delta that targets a capability with no living spec yet may ADD requirements +there, and a REMOVED there is a no-op the merge warns about; a MODIFIED or +RENAMED op targeting it is a validate-time ERROR (`archive/new-spec-non-added`). + +## 3. Sync + +``` +cospec sync-specs +``` + +Relay what it prints: one `Synced:` line per main-spec file written or deleted +and the merge's totals, or that the specs were already in sync. The merge is the +archive's own, so a refusal here is the refusal archive would give — relay it +verbatim and fix what it names; never edit a main spec by hand to get past it. +Nothing is written when it refuses, and the change is never archived by this +step. ## Retiring a capability @@ -49,8 +62,9 @@ the marker the merge refuses and reports the missing marker as the blocking condition. Deleting the file also deletes its `## Purpose` — name both when you report a retirement, and give the user a way to recover the file. -## Actually sync +## Afterwards -Run `/cospec:archive` when the change is complete. The merge happens there, is -verified, and blocker check-offs fan out automatically. To sanity-check the -living specs on their own, run `cospec validate --specs`. +The change is still active: finish its tasks and verification, then run +`/cospec:archive`. Its merge finds the specs already in sync, and both hard +archive gates still run. To sanity-check the living specs on their own, run +`cospec validate --specs`. diff --git a/apps/cli/test/unit/__golden__/harness-render/claude/index.json b/apps/cli/test/unit/__golden__/harness-render/claude/index.json index 13193155..431cffbb 100644 --- a/apps/cli/test/unit/__golden__/harness-render/claude/index.json +++ b/apps/cli/test/unit/__golden__/harness-render/claude/index.json @@ -11,7 +11,7 @@ "kind": "command", "workflow": "archive", "harness": "claude", - "contentHash": "sha256:d31ab736702e834b863f53218615046ce0d07111014acda12131333653f2a56a" + "contentHash": "sha256:4ef8b0e5e83be85e5cf8f1c811e6947bbaeaec8a6df188fa96609d6a46aa220f" }, { "path": ".claude/commands/cospec/bulk-archive.md", @@ -67,7 +67,7 @@ "kind": "command", "workflow": "sync-specs", "harness": "claude", - "contentHash": "sha256:8a7fceb611f7097e7ba242b56cc99aa60a68727d1afdc9e9137146742657d282" + "contentHash": "sha256:c6671819783b5458bb4ceb83e6c0ea0716c94d919a1b813d5ef25fef16307d21" }, { "path": ".claude/commands/cospec/update.md", @@ -95,7 +95,7 @@ "kind": "skill", "workflow": "archive", "harness": "claude", - "contentHash": "sha256:d31ab736702e834b863f53218615046ce0d07111014acda12131333653f2a56a" + "contentHash": "sha256:4ef8b0e5e83be85e5cf8f1c811e6947bbaeaec8a6df188fa96609d6a46aa220f" }, { "path": ".claude/skills/cospec-bulk-archive-change/SKILL.md", @@ -151,7 +151,7 @@ "kind": "skill", "workflow": "sync-specs", "harness": "claude", - "contentHash": "sha256:8a7fceb611f7097e7ba242b56cc99aa60a68727d1afdc9e9137146742657d282" + "contentHash": "sha256:c6671819783b5458bb4ceb83e6c0ea0716c94d919a1b813d5ef25fef16307d21" }, { "path": ".claude/skills/cospec-update-change/SKILL.md", diff --git a/apps/cli/test/unit/__golden__/harness-render/codex/.agents/skills/cospec-archive-change/SKILL.md b/apps/cli/test/unit/__golden__/harness-render/codex/.agents/skills/cospec-archive-change/SKILL.md index f1f9c2a9..59ea8b63 100644 --- a/apps/cli/test/unit/__golden__/harness-render/codex/.agents/skills/cospec-archive-change/SKILL.md +++ b/apps/cli/test/unit/__golden__/harness-render/codex/.agents/skills/cospec-archive-change/SKILL.md @@ -6,7 +6,7 @@ compatibility: Requires the cospec CLI (@aligned-team/cospec). metadata: author: cospec generatedBy: cospec@test - contentHash: sha256:5c738047656ddb62b491db73be4646970619cfe5f01aee6779924b5bd8ef3373 + contentHash: sha256:06ced83cc52c3802650079fd3a3c303bdc97302d803b45574762e7f5bd67a1eb --- Archive a completed change. `cospec archive` validates it, merges its spec @@ -29,9 +29,15 @@ Relay the summary it prints verbatim: what was archived, which spec deltas were applied (`+a ~m -r →n`) or skipped, which sibling changes had blocker boxes checked, and which changes are now unblocked. -A change that introduces a brand-new capability (no living spec yet) may only -ADD requirements there — `cospec validate` refuses a MODIFIED, REMOVED, or -RENAMED op targeting it before archive ever runs the merge. +A change that introduces a brand-new capability (no living spec yet) may ADD +requirements there, and a REMOVED there is a no-op the merge warns about; +`cospec validate` refuses a MODIFIED or RENAMED op targeting it before archive +ever runs the merge. + +A change whose specs were synced early with `$cospec-sync-specs (Codex) or /cospec-sync-specs (other agents)` archives as a +no-op merge: the summary says the specs were already in sync, and both hard +gates (`archive/verification-incomplete`, `archive/scenario-preservation`) still +run. ## 3. On failure diff --git a/apps/cli/test/unit/__golden__/harness-render/codex/.agents/skills/cospec-sync-specs/SKILL.md b/apps/cli/test/unit/__golden__/harness-render/codex/.agents/skills/cospec-sync-specs/SKILL.md index ed31ed65..05d9309d 100644 --- a/apps/cli/test/unit/__golden__/harness-render/codex/.agents/skills/cospec-sync-specs/SKILL.md +++ b/apps/cli/test/unit/__golden__/harness-render/codex/.agents/skills/cospec-sync-specs/SKILL.md @@ -1,28 +1,28 @@ --- name: cospec-sync-specs -description: Explain how spec sync works (it runs inside archive) and preview what would merge. Also use when the user says "cospec sync specs", "sync the specs", or "openspec sync". +description: Merge a change's delta specs into the main specs without archiving it, exactly as archive would. Also use when the user says "cospec sync specs", "sync the specs", or "openspec sync". license: MIT compatibility: Requires the cospec CLI (@aligned-team/cospec). metadata: author: cospec generatedBy: cospec@test - contentHash: sha256:1bfa89a12c71041a0dfa9dc59c5007a6cae904ca8a880cb87dbaad91fa4b4814 + contentHash: sha256:629fe8bb821e930ca0b8c1ebb26381f8a60b6bc20453a4691a4df9f5d169e8c7 --- -Explain and preview spec synchronization. Spec sync is not a standalone step in -cospec. +Merge a change's delta specs into the main specs under `openspec/specs/` without +archiving the change. `cospec sync-specs` runs the archive's own merge — the +pinned OpenSpec archive, on a scratch copy of the specs — and copies back only +the main-spec files it changed, so the result is byte-for-byte what +`cospec archive` would write. The change stays active, and its later archive is +a no-op merge. -Delta specs in a change are merged into the living specs under `openspec/specs/` -**only** by `cospec archive`, which applies the merge and then verifies it as -one coupled operation. There is no supported mid-flight "sync now without -archiving" path. This is deliberate: a partial merge would leave a tree that -neither validates nor archives cleanly. +## 1. Select the change -## Preview what would merge +If the user named one, use it. Otherwise run `cospec list --json`: if exactly +one active change exists, use it and announce `Using change: `; if more +than one is plausible, ask. -If the user did not name a change, run `cospec list --json`: if exactly one -active change exists, use it and announce `Using change: `; if more than -one is plausible, ask. +## 2. Preview the merge ``` cospec validate @@ -31,13 +31,26 @@ cospec validate This runs the archive-precondition checks (targets exist, no zero-op deltas, no ADDED collisions, scenarios are well-formed) and reports anything that would make the merge fail. Then read the delta files under -`openspec/changes//specs/**/spec.md` to see the exact ADDED / MODIFIED / -REMOVED / RENAMED operations. +`openspec/changes//specs/**/spec.md` and tell the user which main specs +the sync will create, change or delete: the ADDED / MODIFIED / REMOVED / RENAMED +operations per capability, and any retirement (below) with its marker. -A delta that targets a capability with no living spec yet may only ADD -requirements — any MODIFIED, REMOVED, or RENAMED op there is a validate-time -ERROR (`archive/new-spec-non-added`), not something that surfaces later at merge -time. +A delta that targets a capability with no living spec yet may ADD requirements +there, and a REMOVED there is a no-op the merge warns about; a MODIFIED or +RENAMED op targeting it is a validate-time ERROR (`archive/new-spec-non-added`). + +## 3. Sync + +``` +cospec sync-specs +``` + +Relay what it prints: one `Synced:` line per main-spec file written or deleted +and the merge's totals, or that the specs were already in sync. The merge is the +archive's own, so a refusal here is the refusal archive would give — relay it +verbatim and fix what it names; never edit a main spec by hand to get past it. +Nothing is written when it refuses, and the change is never archived by this +step. ## Retiring a capability @@ -49,8 +62,9 @@ the marker the merge refuses and reports the missing marker as the blocking condition. Deleting the file also deletes its `## Purpose` — name both when you report a retirement, and give the user a way to recover the file. -## Actually sync +## Afterwards -Run `$cospec-archive-change (Codex) or /cospec-archive-change (other agents)` when the change is complete. The merge happens there, is -verified, and blocker check-offs fan out automatically. To sanity-check the -living specs on their own, run `cospec validate --specs`. +The change is still active: finish its tasks and verification, then run +`$cospec-archive-change (Codex) or /cospec-archive-change (other agents)`. Its merge finds the specs already in sync, and both hard +archive gates still run. To sanity-check the living specs on their own, run +`cospec validate --specs`. diff --git a/apps/cli/test/unit/__golden__/harness-render/codex/index.json b/apps/cli/test/unit/__golden__/harness-render/codex/index.json index 3052cb20..fbfe8362 100644 --- a/apps/cli/test/unit/__golden__/harness-render/codex/index.json +++ b/apps/cli/test/unit/__golden__/harness-render/codex/index.json @@ -11,7 +11,7 @@ "kind": "skill", "workflow": "archive", "harness": "codex", - "contentHash": "sha256:5c738047656ddb62b491db73be4646970619cfe5f01aee6779924b5bd8ef3373" + "contentHash": "sha256:06ced83cc52c3802650079fd3a3c303bdc97302d803b45574762e7f5bd67a1eb" }, { "path": ".agents/skills/cospec-bulk-archive-change/SKILL.md", @@ -67,7 +67,7 @@ "kind": "skill", "workflow": "sync-specs", "harness": "codex", - "contentHash": "sha256:1bfa89a12c71041a0dfa9dc59c5007a6cae904ca8a880cb87dbaad91fa4b4814" + "contentHash": "sha256:629fe8bb821e930ca0b8c1ebb26381f8a60b6bc20453a4691a4df9f5d169e8c7" }, { "path": ".agents/skills/cospec-update-change/SKILL.md", diff --git a/apps/cli/test/unit/__golden__/harness-render/opencode/.opencode/commands/cospec-archive.md b/apps/cli/test/unit/__golden__/harness-render/opencode/.opencode/commands/cospec-archive.md index 685594f2..2a6084b8 100644 --- a/apps/cli/test/unit/__golden__/harness-render/opencode/.opencode/commands/cospec-archive.md +++ b/apps/cli/test/unit/__golden__/harness-render/opencode/.opencode/commands/cospec-archive.md @@ -3,7 +3,7 @@ description: Archive a completed change — validate, merge specs, verify, and f metadata: author: cospec generatedBy: cospec@test - contentHash: sha256:70ef3ee289bf010b42e94bca2c2274d276842d5018fc6c9a199547679a317da6 + contentHash: sha256:52b0c6d92cb08508c8c44746052fffaa4ed31bb322c485737619cc3971a98794 --- Archive a completed change. `cospec archive` validates it, merges its spec @@ -28,9 +28,15 @@ Relay the summary it prints verbatim: what was archived, which spec deltas were applied (`+a ~m -r →n`) or skipped, which sibling changes had blocker boxes checked, and which changes are now unblocked. -A change that introduces a brand-new capability (no living spec yet) may only -ADD requirements there — `cospec validate` refuses a MODIFIED, REMOVED, or -RENAMED op targeting it before archive ever runs the merge. +A change that introduces a brand-new capability (no living spec yet) may ADD +requirements there, and a REMOVED there is a no-op the merge warns about; +`cospec validate` refuses a MODIFIED or RENAMED op targeting it before archive +ever runs the merge. + +A change whose specs were synced early with `/cospec-sync-specs` archives as a +no-op merge: the summary says the specs were already in sync, and both hard +gates (`archive/verification-incomplete`, `archive/scenario-preservation`) still +run. ## 3. On failure diff --git a/apps/cli/test/unit/__golden__/harness-render/opencode/.opencode/commands/cospec-sync-specs.md b/apps/cli/test/unit/__golden__/harness-render/opencode/.opencode/commands/cospec-sync-specs.md index 15ed0e65..ecd06af7 100644 --- a/apps/cli/test/unit/__golden__/harness-render/opencode/.opencode/commands/cospec-sync-specs.md +++ b/apps/cli/test/unit/__golden__/harness-render/opencode/.opencode/commands/cospec-sync-specs.md @@ -1,27 +1,27 @@ --- -description: Explain how spec sync works (it runs inside archive) and preview what would merge. Also use when the user says "cospec sync specs", "sync the specs", or "openspec sync". +description: Merge a change's delta specs into the main specs without archiving it, exactly as archive would. Also use when the user says "cospec sync specs", "sync the specs", or "openspec sync". metadata: author: cospec generatedBy: cospec@test - contentHash: sha256:78a4d09275959566ff92a490de91a93a695dd0acdbc259620b3c4156c61ba16c + contentHash: sha256:a64fe2fd251a64fb752391a0bc7298ca49edde939b1442bfedc3d0e6e5f33992 --- -Explain and preview spec synchronization. Spec sync is not a standalone step in -cospec. - -Delta specs in a change are merged into the living specs under `openspec/specs/` -**only** by `cospec archive`, which applies the merge and then verifies it as -one coupled operation. There is no supported mid-flight "sync now without -archiving" path. This is deliberate: a partial merge would leave a tree that -neither validates nor archives cleanly. +Merge a change's delta specs into the main specs under `openspec/specs/` without +archiving the change. `cospec sync-specs` runs the archive's own merge — the +pinned OpenSpec archive, on a scratch copy of the specs — and copies back only +the main-spec files it changed, so the result is byte-for-byte what +`cospec archive` would write. The change stays active, and its later archive is +a no-op merge. **Provided arguments**: $ARGUMENTS -## Preview what would merge +## 1. Select the change + +If the user named one, use it. Otherwise run `cospec list --json`: if exactly +one active change exists, use it and announce `Using change: `; if more +than one is plausible, ask. -If the user did not name a change, run `cospec list --json`: if exactly one -active change exists, use it and announce `Using change: `; if more than -one is plausible, ask. +## 2. Preview the merge ``` cospec validate @@ -30,13 +30,26 @@ cospec validate This runs the archive-precondition checks (targets exist, no zero-op deltas, no ADDED collisions, scenarios are well-formed) and reports anything that would make the merge fail. Then read the delta files under -`openspec/changes//specs/**/spec.md` to see the exact ADDED / MODIFIED / -REMOVED / RENAMED operations. +`openspec/changes//specs/**/spec.md` and tell the user which main specs +the sync will create, change or delete: the ADDED / MODIFIED / REMOVED / RENAMED +operations per capability, and any retirement (below) with its marker. + +A delta that targets a capability with no living spec yet may ADD requirements +there, and a REMOVED there is a no-op the merge warns about; a MODIFIED or +RENAMED op targeting it is a validate-time ERROR (`archive/new-spec-non-added`). + +## 3. Sync + +``` +cospec sync-specs +``` -A delta that targets a capability with no living spec yet may only ADD -requirements — any MODIFIED, REMOVED, or RENAMED op there is a validate-time -ERROR (`archive/new-spec-non-added`), not something that surfaces later at merge -time. +Relay what it prints: one `Synced:` line per main-spec file written or deleted +and the merge's totals, or that the specs were already in sync. The merge is the +archive's own, so a refusal here is the refusal archive would give — relay it +verbatim and fix what it names; never edit a main spec by hand to get past it. +Nothing is written when it refuses, and the change is never archived by this +step. ## Retiring a capability @@ -48,8 +61,9 @@ the marker the merge refuses and reports the missing marker as the blocking condition. Deleting the file also deletes its `## Purpose` — name both when you report a retirement, and give the user a way to recover the file. -## Actually sync +## Afterwards -Run `/cospec-archive` when the change is complete. The merge happens there, is -verified, and blocker check-offs fan out automatically. To sanity-check the -living specs on their own, run `cospec validate --specs`. +The change is still active: finish its tasks and verification, then run +`/cospec-archive`. Its merge finds the specs already in sync, and both hard +archive gates still run. To sanity-check the living specs on their own, run +`cospec validate --specs`. diff --git a/apps/cli/test/unit/__golden__/harness-render/opencode/.opencode/skills/cospec-archive-change/SKILL.md b/apps/cli/test/unit/__golden__/harness-render/opencode/.opencode/skills/cospec-archive-change/SKILL.md index a28e804c..cddbc576 100644 --- a/apps/cli/test/unit/__golden__/harness-render/opencode/.opencode/skills/cospec-archive-change/SKILL.md +++ b/apps/cli/test/unit/__golden__/harness-render/opencode/.opencode/skills/cospec-archive-change/SKILL.md @@ -6,7 +6,7 @@ compatibility: Requires the cospec CLI (@aligned-team/cospec). metadata: author: cospec generatedBy: cospec@test - contentHash: sha256:4b9b6b08eb117becabf1d8f885fed7169b1712f092ea8d8653e2cb82220510e8 + contentHash: sha256:1f0e3bc2a8cf332628b0a74650bb73691913e94445ae43074167315b946548f6 --- Archive a completed change. `cospec archive` validates it, merges its spec @@ -29,9 +29,15 @@ Relay the summary it prints verbatim: what was archived, which spec deltas were applied (`+a ~m -r →n`) or skipped, which sibling changes had blocker boxes checked, and which changes are now unblocked. -A change that introduces a brand-new capability (no living spec yet) may only -ADD requirements there — `cospec validate` refuses a MODIFIED, REMOVED, or -RENAMED op targeting it before archive ever runs the merge. +A change that introduces a brand-new capability (no living spec yet) may ADD +requirements there, and a REMOVED there is a no-op the merge warns about; +`cospec validate` refuses a MODIFIED or RENAMED op targeting it before archive +ever runs the merge. + +A change whose specs were synced early with `/cospec-sync-specs` archives as a +no-op merge: the summary says the specs were already in sync, and both hard +gates (`archive/verification-incomplete`, `archive/scenario-preservation`) still +run. ## 3. On failure diff --git a/apps/cli/test/unit/__golden__/harness-render/opencode/.opencode/skills/cospec-sync-specs/SKILL.md b/apps/cli/test/unit/__golden__/harness-render/opencode/.opencode/skills/cospec-sync-specs/SKILL.md index 5e5d2538..966f6de4 100644 --- a/apps/cli/test/unit/__golden__/harness-render/opencode/.opencode/skills/cospec-sync-specs/SKILL.md +++ b/apps/cli/test/unit/__golden__/harness-render/opencode/.opencode/skills/cospec-sync-specs/SKILL.md @@ -1,28 +1,28 @@ --- name: cospec-sync-specs -description: Explain how spec sync works (it runs inside archive) and preview what would merge. Also use when the user says "cospec sync specs", "sync the specs", or "openspec sync". +description: Merge a change's delta specs into the main specs without archiving it, exactly as archive would. Also use when the user says "cospec sync specs", "sync the specs", or "openspec sync". license: MIT compatibility: Requires the cospec CLI (@aligned-team/cospec). metadata: author: cospec generatedBy: cospec@test - contentHash: sha256:dc48d3f5037277912711548e57c0feab65c5a8c64c32bf6070334632a9dd60d0 + contentHash: sha256:6557ec661d62b69c5956fe188cb2b2cd37639bfceb3fb7b6bb2330c0db9321ed --- -Explain and preview spec synchronization. Spec sync is not a standalone step in -cospec. +Merge a change's delta specs into the main specs under `openspec/specs/` without +archiving the change. `cospec sync-specs` runs the archive's own merge — the +pinned OpenSpec archive, on a scratch copy of the specs — and copies back only +the main-spec files it changed, so the result is byte-for-byte what +`cospec archive` would write. The change stays active, and its later archive is +a no-op merge. -Delta specs in a change are merged into the living specs under `openspec/specs/` -**only** by `cospec archive`, which applies the merge and then verifies it as -one coupled operation. There is no supported mid-flight "sync now without -archiving" path. This is deliberate: a partial merge would leave a tree that -neither validates nor archives cleanly. +## 1. Select the change -## Preview what would merge +If the user named one, use it. Otherwise run `cospec list --json`: if exactly +one active change exists, use it and announce `Using change: `; if more +than one is plausible, ask. -If the user did not name a change, run `cospec list --json`: if exactly one -active change exists, use it and announce `Using change: `; if more than -one is plausible, ask. +## 2. Preview the merge ``` cospec validate @@ -31,13 +31,26 @@ cospec validate This runs the archive-precondition checks (targets exist, no zero-op deltas, no ADDED collisions, scenarios are well-formed) and reports anything that would make the merge fail. Then read the delta files under -`openspec/changes//specs/**/spec.md` to see the exact ADDED / MODIFIED / -REMOVED / RENAMED operations. +`openspec/changes//specs/**/spec.md` and tell the user which main specs +the sync will create, change or delete: the ADDED / MODIFIED / REMOVED / RENAMED +operations per capability, and any retirement (below) with its marker. -A delta that targets a capability with no living spec yet may only ADD -requirements — any MODIFIED, REMOVED, or RENAMED op there is a validate-time -ERROR (`archive/new-spec-non-added`), not something that surfaces later at merge -time. +A delta that targets a capability with no living spec yet may ADD requirements +there, and a REMOVED there is a no-op the merge warns about; a MODIFIED or +RENAMED op targeting it is a validate-time ERROR (`archive/new-spec-non-added`). + +## 3. Sync + +``` +cospec sync-specs +``` + +Relay what it prints: one `Synced:` line per main-spec file written or deleted +and the merge's totals, or that the specs were already in sync. The merge is the +archive's own, so a refusal here is the refusal archive would give — relay it +verbatim and fix what it names; never edit a main spec by hand to get past it. +Nothing is written when it refuses, and the change is never archived by this +step. ## Retiring a capability @@ -49,8 +62,9 @@ the marker the merge refuses and reports the missing marker as the blocking condition. Deleting the file also deletes its `## Purpose` — name both when you report a retirement, and give the user a way to recover the file. -## Actually sync +## Afterwards -Run `/cospec-archive` when the change is complete. The merge happens there, is -verified, and blocker check-offs fan out automatically. To sanity-check the -living specs on their own, run `cospec validate --specs`. +The change is still active: finish its tasks and verification, then run +`/cospec-archive`. Its merge finds the specs already in sync, and both hard +archive gates still run. To sanity-check the living specs on their own, run +`cospec validate --specs`. diff --git a/apps/cli/test/unit/__golden__/harness-render/opencode/index.json b/apps/cli/test/unit/__golden__/harness-render/opencode/index.json index 50ec89ff..d64302be 100644 --- a/apps/cli/test/unit/__golden__/harness-render/opencode/index.json +++ b/apps/cli/test/unit/__golden__/harness-render/opencode/index.json @@ -11,7 +11,7 @@ "kind": "command", "workflow": "archive", "harness": "opencode", - "contentHash": "sha256:70ef3ee289bf010b42e94bca2c2274d276842d5018fc6c9a199547679a317da6" + "contentHash": "sha256:52b0c6d92cb08508c8c44746052fffaa4ed31bb322c485737619cc3971a98794" }, { "path": ".opencode/commands/cospec-bulk-archive.md", @@ -67,7 +67,7 @@ "kind": "command", "workflow": "sync-specs", "harness": "opencode", - "contentHash": "sha256:78a4d09275959566ff92a490de91a93a695dd0acdbc259620b3c4156c61ba16c" + "contentHash": "sha256:a64fe2fd251a64fb752391a0bc7298ca49edde939b1442bfedc3d0e6e5f33992" }, { "path": ".opencode/commands/cospec-update.md", @@ -95,7 +95,7 @@ "kind": "skill", "workflow": "archive", "harness": "opencode", - "contentHash": "sha256:4b9b6b08eb117becabf1d8f885fed7169b1712f092ea8d8653e2cb82220510e8" + "contentHash": "sha256:1f0e3bc2a8cf332628b0a74650bb73691913e94445ae43074167315b946548f6" }, { "path": ".opencode/skills/cospec-bulk-archive-change/SKILL.md", @@ -151,7 +151,7 @@ "kind": "skill", "workflow": "sync-specs", "harness": "opencode", - "contentHash": "sha256:dc48d3f5037277912711548e57c0feab65c5a8c64c32bf6070334632a9dd60d0" + "contentHash": "sha256:6557ec661d62b69c5956fe188cb2b2cd37639bfceb3fb7b6bb2330c0db9321ed" }, { "path": ".opencode/skills/cospec-update-change/SKILL.md", diff --git a/apps/cli/test/unit/cli.test.ts b/apps/cli/test/unit/cli.test.ts index eacbe4f7..242ffa07 100644 --- a/apps/cli/test/unit/cli.test.ts +++ b/apps/cli/test/unit/cli.test.ts @@ -250,8 +250,9 @@ describe('cli dispatcher: help renders from the command table', () => { expect(init.out).toContain('--no-animation') // An alias flag is an offered flag: upstream's `init --help` lists `--tools`. expect(init.out).toMatch(/^ {2}--tools +OpenSpec's spelling of --harness/m) + // `archive --no-validate` is handled (change `archive-and-sync-parity`). const archive = await dispatch(['archive', '--help']) - expect(archive.out).not.toContain('--no-validate') + expect(archive.out).toMatch(/^ {2}--no-validate +Skip revalidation/m) expect(archive.out).toContain('--skip-specs') }) @@ -287,9 +288,9 @@ describe('cli dispatcher: table rows parse before the module loads', () => { }) test('a pending flag is refused as not supported yet', async () => { - const r = await dispatch(['archive', 'x', '--no-validate']) + const r = await dispatch(['init', '--language', 'fr', '.']) expect(r.code).toBe(1) - expect(r.err).toBe("cospec archive: '--no-validate' is not supported yet\n") + expect(r.err).toBe("cospec init: '--language' is not supported yet\n") }) test('a value-taking flag with no value is refused', async () => { diff --git a/apps/cli/test/unit/commands/archive-refusals.test.ts b/apps/cli/test/unit/commands/archive-refusals.test.ts new file mode 100644 index 00000000..9b5c37ee --- /dev/null +++ b/apps/cli/test/unit/commands/archive-refusals.test.ts @@ -0,0 +1,270 @@ +// Verification 2.5: every refusal path of `cospec archive` answers `--json` +// with exactly one document on stdout and exit 1. The table below has one case +// per refusal reason `commands/archive.ts` names, so a new refusal fails here +// until it has a case; a refusal that returns without the shared `refuse` +// (and so without a document) fails the source check. The wrapped binary is +// stubbed for the three refusals that come after delegation. + +import { afterAll, describe, expect, test } from 'bun:test' +import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' + +import { formatLocalDate, run as archiveRun } from '../../../src/commands/archive.ts' +import { ARCHIVE_REFUSAL_REASONS } from '../../../src/core/archive-output.ts' +import { PINNED_OPENSPEC_VERSION } from '../../../src/core/openspec.ts' +import { + ctx, + DONE_TASKS, + EMPTY_BLOCKERS, + LITE_PROPOSAL, + makeRepo, + runCmd, + writeArchived, + writeChange, +} from './helpers.ts' + +const roots: string[] = [] +function repo(): string { + const dir = makeRepo() + roots.push(dir) + return dir +} +afterAll(() => { + for (const dir of roots) { + chmodSync(join(dir, 'openspec/changes/archive'), 0o755) + rmSync(dir, { recursive: true, force: true }) + } +}) + +const SOURCE = readFileSync(join(import.meta.dir, '../../../src/commands/archive.ts'), 'utf8') + +const FEAT_PROPOSAL = `# change + +## Why + +The widgets capability has to change shape for the next release, and the main +specs must say so; without this change the archive would describe a widget +behaviour the product no longer has. + +## What Changes + +- Change the widgets capability. + +## Capabilities + +### Modified Capabilities + +- widgets + +## Impact + +- No breaking changes. + +## Surfaces + +- [ ] interactive — a user-visible/interactive surface (UI, TUI, CLI UX) +` + +const REQUIREMENT = (scenarios: string): string => `### Requirement: Widget rendering + +The system SHALL render a widget when requested. +${scenarios}` + +const RENDER = ` +#### Scenario: Render a widget + +- **WHEN** a caller requests a widget +- **THEN** a widget is rendered +` + +const EMPTY = ` +#### Scenario: Render an empty widget + +- **WHEN** a caller requests an empty widget +- **THEN** a placeholder is rendered +` + +function living(cwd: string, body: string): void { + mkdirSync(join(cwd, 'openspec/specs/widgets'), { recursive: true }) + writeFileSync( + join(cwd, 'openspec/specs/widgets/spec.md'), + `# Widgets Specification\n\n## Purpose\n\nReal purpose text for widgets.\n\n## Requirements\n\n${body}`, + ) +} + +function feat(cwd: string, id: string, delta: string, files: Record = {}): void { + writeChange(cwd, id, 'feat', { + 'proposal.md': FEAT_PROPOSAL, + 'blocking-changes.md': EMPTY_BLOCKERS, + 'tasks.md': DONE_TASKS, + 'specs/widgets/spec.md': delta, + ...files, + }) +} + +const CI = { + 'proposal.md': LITE_PROPOSAL, + 'blocking-changes.md': EMPTY_BLOCKERS, + 'tasks.md': DONE_TASKS, +} + +interface Case { + /** Builds the repo and returns the change name to archive. */ + build(cwd: string): string + /** What the stubbed binary does with `archive -y`; absent = never spawned. */ + binary?: (cwd: string, id: string) => number +} + +const ADDED_NEW = `## ADDED Requirements\n\n${REQUIREMENT(RENDER)}` + +const CASES: Record = { + 'invalid-name': { build: () => 'a/b' }, + // macOS refuses at the root confinement, Linux at the slot `lstat`: the + // same reason either way. + 'archive-unreadable': { + build(cwd) { + writeChange(cwd, 'c', 'ci', CI) + chmodSync(join(cwd, 'openspec/changes/archive'), 0o000) + return 'c' + }, + }, + 'unknown-change': { + build(cwd) { + writeChange(cwd, 'c', 'ci', CI) + return 'nope' + }, + }, + 'namespace-folder': { + build(cwd) { + writeChange(cwd, 'mobile/refresh', 'ci', CI) + rmSync(join(cwd, 'openspec/changes/mobile/.openspec.yaml'), { force: true }) + return 'mobile' + }, + }, + validation: { + build(cwd) { + feat(cwd, 'c', `## ADDED Requirements\n\n${REQUIREMENT('')}`) + return 'c' + }, + }, + 'tasks-incomplete': { + build(cwd) { + writeChange(cwd, 'c', 'ci', { ...CI, 'tasks.md': '## 1. G\n\n- [ ] 1.1 not yet\n' }) + return 'c' + }, + }, + 'archive/verification-incomplete': { + build(cwd) { + writeChange(cwd, 'c', 'ci', CI) + writeFileSync( + join(cwd, 'openspec/changes/c/.openspec.yaml'), + 'schema: fix\ncreated: 2026-10-05\nschemaVersion: 2\n', + ) + writeFileSync( + join(cwd, 'openspec/changes/c/verification.md'), + '## 1. Works\n\n- [ ] 1.1 @regression rerun the case -> passes\n', + ) + return 'c' + }, + }, + 'slot-exists': { + build(cwd) { + writeChange(cwd, 'c', 'ci', CI) + writeArchived(cwd, `${formatLocalDate()}-c`, 'ci') + return 'c' + }, + }, + 'archive/scenario-preservation': { + build(cwd) { + living(cwd, REQUIREMENT(`${RENDER}${EMPTY}`)) + feat(cwd, 'c', `## MODIFIED Requirements\n\n${REQUIREMENT(RENDER)}`) + return 'c' + }, + }, + aborted: { + build(cwd) { + feat(cwd, 'c', ADDED_NEW) + return 'c' + }, + binary: () => 1, + }, + 'half-state': { + build(cwd) { + feat(cwd, 'c', ADDED_NEW) + return 'c' + }, + binary(cwd, id) { + rmSync(join(cwd, 'openspec/changes', id), { recursive: true }) + return 0 + }, + }, + 'spec-verification-failed': { + build(cwd) { + feat(cwd, 'c', ADDED_NEW) + return 'c' + }, + binary(cwd, id) { + // Moves the change as an archive would, but never writes the ADDED spec. + renameSync( + join(cwd, 'openspec/changes', id), + join(cwd, 'openspec/changes/archive', `${formatLocalDate()}-${id}`), + ) + return 0 + }, + }, +} + +/** Run `archive --json` in-process, the binary answered by `binary`. */ +async function archiveJson(cwd: string, id: string, binary: Case['binary']) { + const originalSpawn = Bun.spawn + let spawned = 0 + // @ts-expect-error — test-only override of Bun.spawn's overloaded signature. + Bun.spawn = (cmd: string[], opts: Parameters[1]) => { + const argv = cmd.slice(3) + // Revalidation's delegated `validate` is the real binary's. + if (argv[0] === 'validate') return originalSpawn(cmd, opts) + const version = argv.length === 1 && argv[0] === '--version' + if (!version) spawned++ + const exitCode = version ? 0 : (binary?.(cwd, id) ?? 0) + return { + stdout: new Response(version ? `${PINNED_OPENSPEC_VERSION}\n` : '').body, + stderr: new Response('').body, + exited: Promise.resolve(exitCode), + } + } + try { + const r = await runCmd(archiveRun, ctx(cwd, [id], { json: true, command: 'archive' })) + return { ...r, spawned } + } finally { + Bun.spawn = originalSpawn + } +} + +describe('2.5 every archive refusal under --json is one document', () => { + test('no refusal in commands/archive.ts returns without the shared document', () => { + // The one `return EXIT.failure` is `refuse`'s own. + expect(SOURCE.match(/return EXIT\.failure/g)).toHaveLength(1) + expect(SOURCE).toMatch(/const refuse = \([^]*?return EXIT\.failure\n {2}\}/) + }) + + test('every refusal reason archive.ts names has a case', () => { + const named: string[] = ARCHIVE_REFUSAL_REASONS.filter((r) => SOURCE.includes(`'${r}'`)) + expect(named.toSorted()).toEqual(Object.keys(CASES).toSorted()) + }) + + for (const [reason, c] of Object.entries(CASES)) + test(`${reason}: one document on stdout, exit 1`, async () => { + const cwd = repo() + const id = c.build(cwd) + const r = await archiveJson(cwd, id, c.binary) + expect(r.code).toBe(1) + expect(r.spawned).toBe(c.binary === undefined ? 0 : 1) + const doc = JSON.parse(r.out) as Record + expect(r.out.trim().endsWith('}')).toBe(true) + expect(doc.reason).toBe(reason) + expect(doc.archive).toBeNull() + expect(doc.status).toEqual([expect.objectContaining({ severity: 'error' })]) + if (reason === 'validation') + for (const key of ['version', 'items', 'summary']) expect(doc).toHaveProperty(key) + }) +}) diff --git a/apps/cli/test/unit/core/archive-output.test.ts b/apps/cli/test/unit/core/archive-output.test.ts new file mode 100644 index 00000000..3d077999 --- /dev/null +++ b/apps/cli/test/unit/core/archive-output.test.ts @@ -0,0 +1,95 @@ +// Verification 7.3: the reader of the pinned binary's human-mode archive +// summary, on stdout captured from `openspec archive c1 -y` (1.13.1). + +import { describe, expect, test } from 'bun:test' + +import { readArchiveSummary, relayedReason } from '../../../src/core/archive-output.ts' + +const APPLIED = `Task status: ✓ Complete +Specs to update: + widgets: create +⚠️ Warning: widgets - 1 REMOVED requirement(s) ignored for new spec (nothing to remove). +Applying changes to openspec/specs/widgets/spec.md: + + 1 added +Totals: + 1, ~ 0, - 0, → 0 +Specs updated successfully. +Change 'c1' archived as '2026-10-05-c1'. +` + +const IN_SYNC = `Task status: ✓ Complete +Specs to update: + widgets: create +⚠️ Warning: widgets - 1 REMOVED requirement(s) ignored for new spec (nothing to remove). +Totals: + 0, ~ 0, - 0, → 0 +Specs already in sync; no files changed. +Change 'c1' archived as '2026-10-05-c1'. +` + +const SKIPPED = `Task status: ✓ Complete +Skipping spec updates (--skip-specs flag provided). +Change 'c1' archived as '2026-10-05-c1'. +` + +const RETIRED = `Task status: ✓ Complete +Specs to update: + widgets: update +Retiring openspec/specs/widgets/spec.md: all requirements removed. + If it was committed, restore it with: git checkout HEAD -- ":(top)openspec/specs/widgets/spec.md" +Totals: + 0, ~ 0, - 1, → 0 +Specs updated successfully. +Change 'c1' archived as '2026-10-05-c1'. +` + +describe('readArchiveSummary', () => { + test('an applied merge: totals, specsUpdated true and the merge warning', () => { + expect(readArchiveSummary(APPLIED, ['widgets'])).toEqual({ + totals: { added: 1, modified: 0, removed: 0, renamed: 0 }, + specsUpdated: true, + warnings: ['widgets - 1 REMOVED requirement(s) ignored for new spec (nothing to remove).'], + }) + }) + + test('an in-sync merge: zero totals, specsUpdated false', () => { + const summary = readArchiveSummary(IN_SYNC, ['widgets']) + expect(summary.totals).toEqual({ added: 0, modified: 0, removed: 0, renamed: 0 }) + expect(summary.specsUpdated).toBe(false) + }) + + test('--skip-specs: no totals and no specsUpdated, left to the caller', () => { + expect(readArchiveSummary(SKIPPED, ['widgets'])).toEqual({ warnings: [] }) + }) + + test('a stdout with no Totals line has no totals', () => { + expect(readArchiveSummary("Change 'c1' archived as '2026-10-05-c1'.\n").totals).toBeUndefined() + }) + + test("a retirement becomes the binary's JSON note", () => { + expect(readArchiveSummary(RETIRED, ['widgets']).warnings).toEqual([ + 'widgets - capability retired; deleted the main spec (all requirements removed, declared by retire_capabilities) at openspec/specs/widgets/spec.md. Its section(s) went with it: Purpose. If it was committed, restore it with: git checkout HEAD -- ":(top)openspec/specs/widgets/spec.md"', + ]) + }) + + test('a line that only resembles one is not read', () => { + expect( + readArchiveSummary(' Totals: + 1, ~ 0, - 0, → 0\nSpecs updated successfully!\n'), + ).toEqual({ + warnings: [], + }) + }) +}) + +describe('relayedReason', () => { + test("the line before the binary's Aborted closer", () => { + expect( + relayedReason('Specs to update:\nwidgets MODIFIED failed\nAborted. No files were changed.\n'), + ).toBe('widgets MODIFIED failed') + }) + + test("the CLI's Error line, its message", () => { + expect( + relayedReason( + "Task status: ✓ Complete\n✖ Error: Spec updates for 'a' and 'b' resolve to the same target x.\n", + ), + ).toBe("Spec updates for 'a' and 'b' resolve to the same target x.") + }) +}) diff --git a/apps/cli/test/unit/core/command-table.test.ts b/apps/cli/test/unit/core/command-table.test.ts index 6d7b9a3b..f73f8049 100644 --- a/apps/cli/test/unit/core/command-table.test.ts +++ b/apps/cli/test/unit/core/command-table.test.ts @@ -257,7 +257,8 @@ describe('parseCommandArgs — refusals', () => { expect(refused('list', ['--store-path', '/x', 'extra']).kind).toBe('too-many-arguments') expect(refused('list', ['--store-path=/x', 'extra']).kind).toBe('too-many-arguments') expect(refused('validate', ['--store-path', '/x', 'a', 'b']).kind).toBe('too-many-arguments') - expect(refused('archive', ['c', '--store-path', '/x', '--no-validate']).kind).toBe('pending') + // `archive --no-validate` is handled now, so only --store-path is left to refuse. + expect(refused('archive', ['c', '--store-path', '/x', '--no-validate']).kind).toBe('store-path') // Its value is consumed, so it never counts as a positional. expect(refused('validate', ['--store-path', '/x', 'a']).kind).toBe('store-path') }) @@ -328,7 +329,6 @@ const EXPECTED_PENDING: [string, string, PendingOwner][] = [ ['init', '--profile', 'workflow-profiles'], ['init', '--copilot-cloud', 'github-copilot'], ['init', '--no-copilot-cloud', 'github-copilot'], - ['archive', '--no-validate', 'archive-and-sync-parity'], ['completion', 'install', 'completion-install'], ['completion', 'uninstall', 'completion-install'], ['completion', 'powershell', 'completion-install'], @@ -371,7 +371,6 @@ describe('pending surfaces', () => { 'init --profile': ['--profile', 'core'], 'init --copilot-cloud': ['--copilot-cloud'], 'init --no-copilot-cloud': ['--no-copilot-cloud'], - 'archive --no-validate': ['c', '--no-validate'], 'completion install': ['install', 'zsh', '--verbose'], 'completion uninstall': ['uninstall', '-y'], 'completion powershell': ['powershell'], @@ -430,6 +429,7 @@ describe('table shape', () => { 'instructions', 'apply', 'archive', + 'sync-specs', 'sync-blockers', 'store', 'context', diff --git a/apps/cli/test/unit/core/completions.test.ts b/apps/cli/test/unit/core/completions.test.ts index 640efdc6..bebe788c 100644 --- a/apps/cli/test/unit/core/completions.test.ts +++ b/apps/cli/test/unit/core/completions.test.ts @@ -103,9 +103,15 @@ describe('buildCompletionSpec — matches COMMAND_TABLE', () => { ]) }) - test('archive: handled flags plus the accepted no-op --yes; pending --no-validate absent', () => { + test('archive: handled flags plus the accepted no-op --yes', () => { const archive = spec.commands.find((c) => c.name === 'archive')! - expect(archive.flags).toEqual(['--skip-specs', '--force-incomplete', '-y', '--yes']) + expect(archive.flags).toEqual([ + '--skip-specs', + '--force-incomplete', + '-y', + '--yes', + '--no-validate', + ]) }) test('init: pending flags (--language, --profile, --copilot-cloud, …) absent', () => { diff --git a/apps/cli/test/unit/core/scenario-gate.test.ts b/apps/cli/test/unit/core/scenario-gate.test.ts new file mode 100644 index 00000000..636072f3 --- /dev/null +++ b/apps/cli/test/unit/core/scenario-gate.test.ts @@ -0,0 +1,56 @@ +// Verification 3.4: the shared scenario-preservation gate takes only the +// verbatim view, and every command reaches it through `core/scenario-gate.ts`. + +import { describe, expect, test } from 'bun:test' +import { readdirSync, readFileSync } from 'node:fs' +import { join } from 'node:path' + +import { + parseAdvisoryDelta, + parseDeltaSpec, + type AdvisoryDeltaOp, +} from '../../../src/core/deltas.ts' +import { scenarioGate, type CapabilityDeltas } from '../../../src/core/scenario-gate.ts' +import { mkTempRepo } from '../../fixtures/support.ts' + +const DELTA = `## MODIFIED Requirements + +### Requirement: Widget rendering + +The system SHALL render a widget. + +#### Scenario: Render a widget + +- **WHEN** a caller asks +- **THEN** a widget is rendered +` + +describe('core/scenario-gate.ts', () => { + test('its input is typed on the verbatim view only', () => { + const verbatim: CapabilityDeltas = { + capability: 'widgets', + ops: parseDeltaSpec(DELTA, 'specs/widgets/spec.md', 'widgets').ops, + } + const advisory: AdvisoryDeltaOp[] = parseAdvisoryDelta( + DELTA, + 'specs/widgets/spec.md', + 'widgets', + ).ops + const root = mkTempRepo() + expect(scenarioGate(root, [verbatim]).drops).toEqual([]) + // @ts-expect-error — a comment-masked op is not a gate input. + expect(scenarioGate(root, [{ capability: 'widgets', ops: advisory }]).drops).toEqual([]) + }) + + test('no command calls findScenarioDrops directly; archive and sync-specs import the gate', () => { + const dir = join(import.meta.dir, '../../../src/commands') + const direct = readdirSync(dir).filter((f) => + readFileSync(join(dir, f), 'utf8').includes('findScenarioDrops'), + ) + expect(direct).toEqual([]) + const importers = readdirSync(dir).filter((f) => + readFileSync(join(dir, f), 'utf8').includes("from '../core/scenario-gate.ts'"), + ) + expect(importers.toSorted()).toEqual(['archive.ts', 'sync-specs.ts']) + }) +}) diff --git a/apps/cli/test/unit/core/scratch-root.test.ts b/apps/cli/test/unit/core/scratch-root.test.ts new file mode 100644 index 00000000..9a09f512 --- /dev/null +++ b/apps/cli/test/unit/core/scratch-root.test.ts @@ -0,0 +1,258 @@ +// Verification 11.2 and 11.5: `core/scratch-root.ts` with an injected runner +// standing in for the wrapped archive, so a failed or raced run can be staged +// exactly. + +import { afterAll, describe, expect, test } from 'bun:test' +import { + chmodSync, + existsSync, + lstatSync, + mkdirSync, + readFileSync, + readlinkSync, + realpathSync, + renameSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import { join } from 'node:path' + +import { + ScratchRefusal, + symlinkEscape, + syncThroughScratch, + type ScratchRun, +} from '../../../src/core/scratch-root.ts' +import { cleanupAll, hashTree, mkTempRepo, writeFiles } from '../../fixtures/support.ts' + +afterAll(cleanupAll) + +const LIVING = '# widgets\n\n## Purpose\n\nWidgets.\n\n## Requirements\n' + +/** A root with one living spec and a change `c1` with one delta. */ +function root(): string { + const dir = mkTempRepo() + writeFiles(dir, { + 'openspec/config.yaml': 'schema: feat\n', + 'openspec/specs/widgets/spec.md': LIVING, + 'openspec/changes/c1/.openspec.yaml': 'schema: feat\n', + 'openspec/changes/c1/specs/widgets/spec.md': '## ADDED Requirements\n', + 'openspec/changes/c2/.openspec.yaml': 'schema: feat\n', + }) + mkdirSync(join(dir, 'openspec/changes/archive'), { recursive: true }) + return dir +} + +/** What a completed wrapped archive leaves in the scratch tree. */ +function archiveIn(scratch: string, id: string): void { + renameSync( + join(scratch, 'openspec/changes', id), + join(scratch, 'openspec/changes/archive', `2026-10-05-${id}`), + ) +} + +const ok: ScratchRun = { stdout: 'Specs updated successfully.\n', stderr: '', exitCode: 0 } + +describe('syncThroughScratch', () => { + test('a completed run copies back what it wrote and deleted, and leaves the change active', async () => { + const dir = root() + const changes = hashTree(join(dir, 'openspec/changes')) + let seen = '' + const result = await syncThroughScratch(dir, 'c1', async (scratch) => { + seen = scratch + // Only what the archive reads is there: no sibling change, an empty archive. + expect(existsSync(join(scratch, 'openspec/changes/c2'))).toBe(false) + expect(existsSync(join(scratch, 'openspec/config.yaml'))).toBe(true) + writeFileSync(join(scratch, 'openspec/specs/widgets/spec.md'), `${LIVING}\nmerged\n`) + writeFiles(scratch, { 'openspec/specs/gadgets/spec.md': 'new\n' }) + archiveIn(scratch, 'c1') + return ok + }) + expect(result.written).toEqual([ + 'openspec/specs/gadgets/spec.md', + 'openspec/specs/widgets/spec.md', + ]) + expect(result.deleted).toEqual([]) + expect(hashTree(join(dir, 'openspec/changes'))).toEqual(changes) + expect(await Bun.file(join(dir, 'openspec/specs/widgets/spec.md')).text()).toBe( + `${LIVING}\nmerged\n`, + ) + expect(existsSync(seen)).toBe(false) + }) + + test('a retirement deletes the spec and prunes its emptied directory', async () => { + const dir = root() + const result = await syncThroughScratch(dir, 'c1', async (scratch) => { + rmSync(join(scratch, 'openspec/specs/widgets'), { recursive: true }) + archiveIn(scratch, 'c1') + return ok + }) + expect(result.deleted).toEqual(['openspec/specs/widgets/spec.md']) + expect(existsSync(join(dir, 'openspec/specs/widgets'))).toBe(false) + }) + + test('11.2 a run that claims, writes and fails leaves the real tree byte-identical', async () => { + const dir = root() + const before = hashTree(dir) + let seen = '' + const failed = syncThroughScratch(dir, 'c1', async (scratch) => { + seen = scratch + writeFileSync(join(scratch, 'openspec/changes/archive/.openspec-archive.lock'), '{}') + writeFileSync(join(scratch, 'openspec/specs/widgets/spec.md'), 'partial') + return { + stdout: 'Task status: ✓ Complete\n', + stderr: '✖ Error: the run broke\n', + exitCode: 1, + } + }) + await expect(failed).rejects.toThrow(ScratchRefusal) + await expect(failed).rejects.toThrow('the run broke') + expect(hashTree(dir)).toEqual(before) + expect(existsSync(seen)).toBe(false) + }) + + test('a run that exits 0 without archiving the scratch copy is refused', async () => { + const dir = root() + const before = hashTree(dir) + const refused = syncThroughScratch(dir, 'c1', async () => ({ + stdout: 'Aborted. No files were changed.\n', + stderr: '', + exitCode: 0, + })) + await expect(refused).rejects.toMatchObject({ kind: 'scratch-run' }) + expect(hashTree(dir)).toEqual(before) + }) + + test('11.5 the real main specs changing while the run works refuses, writing nothing', async () => { + const dir = root() + const refused = syncThroughScratch(dir, 'c1', async (scratch) => { + writeFileSync(join(scratch, 'openspec/specs/widgets/spec.md'), 'merged') + writeFileSync(join(dir, 'openspec/specs/widgets/spec.md'), 'edited meanwhile') + archiveIn(scratch, 'c1') + return ok + }) + await expect(refused).rejects.toMatchObject({ kind: 'specs-changed' }) + await expect(refused).rejects.toThrow('the main specs changed while sync ran') + expect(await Bun.file(join(dir, 'openspec/specs/widgets/spec.md')).text()).toBe( + 'edited meanwhile', + ) + }) +}) + +describe('syncThroughScratch: links and unreadable entries', () => { + test('an absolute in-tree link is re-pointed into the scratch copy, never the real tree', async () => { + const dir = root() + const realAlias = join(dir, 'openspec/specs/widgets') + symlinkSync(realAlias, join(dir, 'openspec/specs/alias')) + const result = await syncThroughScratch(dir, 'c1', async (scratch) => { + const alias = join(scratch, 'openspec/specs/alias') + expect(realpathSync(alias)).toBe(realpathSync(join(scratch, 'openspec/specs/widgets'))) + // The binary merges through the alias, as it would in place. + writeFileSync(join(alias, 'spec.md'), `${LIVING}\nmerged\n`) + archiveIn(scratch, 'c1') + return ok + }) + expect(result.written).toEqual(['openspec/specs/widgets/spec.md']) + expect(readlinkSync(join(dir, 'openspec/specs/alias'))).toBe(realAlias) + expect(await Bun.file(join(realAlias, 'spec.md')).text()).toBe(`${LIVING}\nmerged\n`) + }) + + test('a link through a symlinked specs directory is walked, and one leading out named', () => { + const dir = root() + const kept = mkTempRepo() + renameSync(join(dir, 'openspec/specs'), join(kept, 'specs')) + symlinkSync(join(kept, 'specs'), join(dir, 'openspec/specs')) + expect(symlinkEscape(dir, 'c1')).toBeUndefined() + symlinkSync(mkTempRepo(), join(kept, 'specs/ext')) + expect(symlinkEscape(dir, 'c1')).toBe('openspec/specs/ext') + }) + + test('a retired capability whose spec.md is a link: the link is deleted and its directory pruned', async () => { + const dir = root() + writeFiles(dir, { 'openspec/specs/shared/widgets.md': LIVING }) + rmSync(join(dir, 'openspec/specs/widgets/spec.md')) + symlinkSync('../shared/widgets.md', join(dir, 'openspec/specs/widgets/spec.md')) + const result = await syncThroughScratch(dir, 'c1', async (scratch) => { + rmSync(join(scratch, 'openspec/specs/widgets'), { recursive: true }) + archiveIn(scratch, 'c1') + return ok + }) + expect(result).toMatchObject({ written: [], deleted: ['openspec/specs/widgets/spec.md'] }) + expect(existsSync(join(dir, 'openspec/specs/widgets'))).toBe(false) + expect(await Bun.file(join(dir, 'openspec/specs/shared/widgets.md')).text()).toBe(LIVING) + }) + + test('a link the run replaced with a file is written over the link', async () => { + const dir = root() + writeFiles(dir, { 'openspec/specs/shared/widgets.md': LIVING }) + rmSync(join(dir, 'openspec/specs/widgets/spec.md')) + symlinkSync('../shared/widgets.md', join(dir, 'openspec/specs/widgets/spec.md')) + const result = await syncThroughScratch(dir, 'c1', async (scratch) => { + const spec = join(scratch, 'openspec/specs/widgets/spec.md') + rmSync(spec) + writeFileSync(spec, 'merged\n') + archiveIn(scratch, 'c1') + return ok + }) + expect(result.written).toEqual(['openspec/specs/widgets/spec.md']) + expect(lstatSync(join(dir, 'openspec/specs/widgets/spec.md')).isFile()).toBe(true) + expect(await Bun.file(join(dir, 'openspec/specs/shared/widgets.md')).text()).toBe(LIVING) + }) + + test('a link the run created is a breach, thrown before anything is written', async () => { + const dir = root() + const before = hashTree(dir) + const breached = syncThroughScratch(dir, 'c1', async (scratch) => { + writeFileSync(join(scratch, 'openspec/specs/widgets/spec.md'), 'merged\n') + symlinkSync('widgets', join(scratch, 'openspec/specs/alias')) + archiveIn(scratch, 'c1') + return ok + }) + await expect(breached).rejects.toThrow('openspec/specs/alias as a link to widgets') + expect(hashTree(dir)).toEqual(before) + }) + + test.skipIf(process.getuid?.() === 0)( + 'an unrelated spec no one can read is copied as an unreadable placeholder; the sync completes', + async () => { + const dir = root() + writeFiles(dir, { 'openspec/specs/other/spec.md': LIVING }) + const other = join(dir, 'openspec/specs/other/spec.md') + chmodSync(other, 0o000) + try { + const result = await syncThroughScratch(dir, 'c1', async (scratch) => { + const placeholder = join(scratch, 'openspec/specs/other/spec.md') + expect(statSync(placeholder).mode & 0o777).toBe(0) + expect(() => readFileSync(placeholder)).toThrow() + writeFileSync(join(scratch, 'openspec/specs/widgets/spec.md'), `${LIVING}\nmerged\n`) + archiveIn(scratch, 'c1') + return ok + }) + expect(result).toMatchObject({ written: ['openspec/specs/widgets/spec.md'], deleted: [] }) + expect(statSync(other).mode & 0o777).toBe(0) + } finally { + chmodSync(other, 0o644) + } + expect(await Bun.file(join(dir, 'openspec/specs/other/spec.md')).text()).toBe(LIVING) + }, + ) +}) + +describe('symlinkEscape', () => { + test('a link inside the copied tree is kept; one leading out is named', () => { + const dir = root() + symlinkSync('widgets', join(dir, 'openspec/specs/alias')) + expect(symlinkEscape(dir, 'c1')).toBeUndefined() + const outside = mkTempRepo() + symlinkSync(outside, join(dir, 'openspec/specs/ext')) + expect(symlinkEscape(dir, 'c1')).toBe('openspec/specs/ext') + }) + + test('a link into a sibling change leads outside: it is never copied', () => { + const dir = root() + symlinkSync(join(dir, 'openspec/changes/c2'), join(dir, 'openspec/changes/c1/sibling')) + expect(symlinkEscape(dir, 'c1')).toBe('openspec/changes/c1/sibling') + }) +}) diff --git a/apps/cli/test/unit/harness/__snapshots__/render.test.ts.snap b/apps/cli/test/unit/harness/__snapshots__/render.test.ts.snap index f5f8e3a8..f07eb2dd 100644 --- a/apps/cli/test/unit/harness/__snapshots__/render.test.ts.snap +++ b/apps/cli/test/unit/harness/__snapshots__/render.test.ts.snap @@ -1348,7 +1348,7 @@ compatibility: Requires the cospec CLI (@aligned-team/cospec). metadata: author: cospec generatedBy: cospec@test - contentHash: sha256:d31ab736702e834b863f53218615046ce0d07111014acda12131333653f2a56a + contentHash: sha256:4ef8b0e5e83be85e5cf8f1c811e6947bbaeaec8a6df188fa96609d6a46aa220f --- Archive a completed change. \`cospec archive\` validates it, merges its spec @@ -1371,9 +1371,15 @@ Relay the summary it prints verbatim: what was archived, which spec deltas were applied (\`+a ~m -r →n\`) or skipped, which sibling changes had blocker boxes checked, and which changes are now unblocked. -A change that introduces a brand-new capability (no living spec yet) may only -ADD requirements there — \`cospec validate\` refuses a MODIFIED, REMOVED, or -RENAMED op targeting it before archive ever runs the merge. +A change that introduces a brand-new capability (no living spec yet) may ADD +requirements there, and a REMOVED there is a no-op the merge warns about; +\`cospec validate\` refuses a MODIFIED or RENAMED op targeting it before archive +ever runs the merge. + +A change whose specs were synced early with \`/cospec:sync-specs\` archives as a +no-op merge: the summary says the specs were already in sync, and both hard +gates (\`archive/verification-incomplete\`, \`archive/scenario-preservation\`) still +run. ## 3. On failure @@ -1422,7 +1428,7 @@ tags: metadata: author: cospec generatedBy: cospec@test - contentHash: sha256:d31ab736702e834b863f53218615046ce0d07111014acda12131333653f2a56a + contentHash: sha256:4ef8b0e5e83be85e5cf8f1c811e6947bbaeaec8a6df188fa96609d6a46aa220f --- Archive a completed change. \`cospec archive\` validates it, merges its spec @@ -1445,9 +1451,15 @@ Relay the summary it prints verbatim: what was archived, which spec deltas were applied (\`+a ~m -r →n\`) or skipped, which sibling changes had blocker boxes checked, and which changes are now unblocked. -A change that introduces a brand-new capability (no living spec yet) may only -ADD requirements there — \`cospec validate\` refuses a MODIFIED, REMOVED, or -RENAMED op targeting it before archive ever runs the merge. +A change that introduces a brand-new capability (no living spec yet) may ADD +requirements there, and a REMOVED there is a no-op the merge warns about; +\`cospec validate\` refuses a MODIFIED or RENAMED op targeting it before archive +ever runs the merge. + +A change whose specs were synced early with \`/cospec:sync-specs\` archives as a +no-op merge: the summary says the specs were already in sync, and both hard +gates (\`archive/verification-incomplete\`, \`archive/scenario-preservation\`) still +run. ## 3. On failure @@ -1654,29 +1666,29 @@ unblocked. "content": "--- name: cospec-sync-specs -description: Explain how spec sync works (it runs inside archive) and preview what would merge. Also use when the user says "cospec sync specs", "sync the specs", or "openspec sync". +description: Merge a change's delta specs into the main specs without archiving it, exactly as archive would. Also use when the user says "cospec sync specs", "sync the specs", or "openspec sync". license: MIT compatibility: Requires the cospec CLI (@aligned-team/cospec). metadata: author: cospec generatedBy: cospec@test - contentHash: sha256:8a7fceb611f7097e7ba242b56cc99aa60a68727d1afdc9e9137146742657d282 + contentHash: sha256:c6671819783b5458bb4ceb83e6c0ea0716c94d919a1b813d5ef25fef16307d21 --- -Explain and preview spec synchronization. Spec sync is not a standalone step in -cospec. +Merge a change's delta specs into the main specs under \`openspec/specs/\` without +archiving the change. \`cospec sync-specs\` runs the archive's own merge — the +pinned OpenSpec archive, on a scratch copy of the specs — and copies back only +the main-spec files it changed, so the result is byte-for-byte what +\`cospec archive\` would write. The change stays active, and its later archive is +a no-op merge. -Delta specs in a change are merged into the living specs under \`openspec/specs/\` -**only** by \`cospec archive\`, which applies the merge and then verifies it as -one coupled operation. There is no supported mid-flight "sync now without -archiving" path. This is deliberate: a partial merge would leave a tree that -neither validates nor archives cleanly. +## 1. Select the change -## Preview what would merge +If the user named one, use it. Otherwise run \`cospec list --json\`: if exactly +one active change exists, use it and announce \`Using change: \`; if more +than one is plausible, ask. -If the user did not name a change, run \`cospec list --json\`: if exactly one -active change exists, use it and announce \`Using change: \`; if more than -one is plausible, ask. +## 2. Preview the merge \`\`\` cospec validate @@ -1685,13 +1697,26 @@ cospec validate This runs the archive-precondition checks (targets exist, no zero-op deltas, no ADDED collisions, scenarios are well-formed) and reports anything that would make the merge fail. Then read the delta files under -\`openspec/changes//specs/**/spec.md\` to see the exact ADDED / MODIFIED / -REMOVED / RENAMED operations. +\`openspec/changes//specs/**/spec.md\` and tell the user which main specs +the sync will create, change or delete: the ADDED / MODIFIED / REMOVED / RENAMED +operations per capability, and any retirement (below) with its marker. + +A delta that targets a capability with no living spec yet may ADD requirements +there, and a REMOVED there is a no-op the merge warns about; a MODIFIED or +RENAMED op targeting it is a validate-time ERROR (\`archive/new-spec-non-added\`). -A delta that targets a capability with no living spec yet may only ADD -requirements — any MODIFIED, REMOVED, or RENAMED op there is a validate-time -ERROR (\`archive/new-spec-non-added\`), not something that surfaces later at merge -time. +## 3. Sync + +\`\`\` +cospec sync-specs +\`\`\` + +Relay what it prints: one \`Synced:\` line per main-spec file written or deleted +and the merge's totals, or that the specs were already in sync. The merge is the +archive's own, so a refusal here is the refusal archive would give — relay it +verbatim and fix what it names; never edit a main spec by hand to get past it. +Nothing is written when it refuses, and the change is never archived by this +step. ## Retiring a capability @@ -1703,11 +1728,12 @@ the marker the merge refuses and reports the missing marker as the blocking condition. Deleting the file also deletes its \`## Purpose\` — name both when you report a retirement, and give the user a way to recover the file. -## Actually sync +## Afterwards -Run \`/cospec:archive\` when the change is complete. The merge happens there, is -verified, and blocker check-offs fan out automatically. To sanity-check the -living specs on their own, run \`cospec validate --specs\`. +The change is still active: finish its tasks and verification, then run +\`/cospec:archive\`. Its merge finds the specs already in sync, and both hard +archive gates still run. To sanity-check the living specs on their own, run +\`cospec validate --specs\`. " , "kind": "skill", @@ -1717,7 +1743,7 @@ living specs on their own, run \`cospec validate --specs\`. "content": "--- name: "COSPEC: Sync specs" -description: Explain how spec sync works (it runs inside archive) and preview what would merge. Also use when the user says "cospec sync specs", "sync the specs", or "openspec sync". +description: Merge a change's delta specs into the main specs without archiving it, exactly as archive would. Also use when the user says "cospec sync specs", "sync the specs", or "openspec sync". category: Workflow tags: - cospec @@ -1725,23 +1751,23 @@ tags: metadata: author: cospec generatedBy: cospec@test - contentHash: sha256:8a7fceb611f7097e7ba242b56cc99aa60a68727d1afdc9e9137146742657d282 + contentHash: sha256:c6671819783b5458bb4ceb83e6c0ea0716c94d919a1b813d5ef25fef16307d21 --- -Explain and preview spec synchronization. Spec sync is not a standalone step in -cospec. +Merge a change's delta specs into the main specs under \`openspec/specs/\` without +archiving the change. \`cospec sync-specs\` runs the archive's own merge — the +pinned OpenSpec archive, on a scratch copy of the specs — and copies back only +the main-spec files it changed, so the result is byte-for-byte what +\`cospec archive\` would write. The change stays active, and its later archive is +a no-op merge. -Delta specs in a change are merged into the living specs under \`openspec/specs/\` -**only** by \`cospec archive\`, which applies the merge and then verifies it as -one coupled operation. There is no supported mid-flight "sync now without -archiving" path. This is deliberate: a partial merge would leave a tree that -neither validates nor archives cleanly. +## 1. Select the change -## Preview what would merge +If the user named one, use it. Otherwise run \`cospec list --json\`: if exactly +one active change exists, use it and announce \`Using change: \`; if more +than one is plausible, ask. -If the user did not name a change, run \`cospec list --json\`: if exactly one -active change exists, use it and announce \`Using change: \`; if more than -one is plausible, ask. +## 2. Preview the merge \`\`\` cospec validate @@ -1750,13 +1776,26 @@ cospec validate This runs the archive-precondition checks (targets exist, no zero-op deltas, no ADDED collisions, scenarios are well-formed) and reports anything that would make the merge fail. Then read the delta files under -\`openspec/changes//specs/**/spec.md\` to see the exact ADDED / MODIFIED / -REMOVED / RENAMED operations. +\`openspec/changes//specs/**/spec.md\` and tell the user which main specs +the sync will create, change or delete: the ADDED / MODIFIED / REMOVED / RENAMED +operations per capability, and any retirement (below) with its marker. + +A delta that targets a capability with no living spec yet may ADD requirements +there, and a REMOVED there is a no-op the merge warns about; a MODIFIED or +RENAMED op targeting it is a validate-time ERROR (\`archive/new-spec-non-added\`). + +## 3. Sync + +\`\`\` +cospec sync-specs +\`\`\` -A delta that targets a capability with no living spec yet may only ADD -requirements — any MODIFIED, REMOVED, or RENAMED op there is a validate-time -ERROR (\`archive/new-spec-non-added\`), not something that surfaces later at merge -time. +Relay what it prints: one \`Synced:\` line per main-spec file written or deleted +and the merge's totals, or that the specs were already in sync. The merge is the +archive's own, so a refusal here is the refusal archive would give — relay it +verbatim and fix what it names; never edit a main spec by hand to get past it. +Nothing is written when it refuses, and the change is never archived by this +step. ## Retiring a capability @@ -1768,11 +1807,12 @@ the marker the merge refuses and reports the missing marker as the blocking condition. Deleting the file also deletes its \`## Purpose\` — name both when you report a retirement, and give the user a way to recover the file. -## Actually sync +## Afterwards -Run \`/cospec:archive\` when the change is complete. The merge happens there, is -verified, and blocker check-offs fan out automatically. To sanity-check the -living specs on their own, run \`cospec validate --specs\`. +The change is still active: finish its tasks and verification, then run +\`/cospec:archive\`. Its merge finds the specs already in sync, and both hard +archive gates still run. To sanity-check the living specs on their own, run +\`cospec validate --specs\`. " , "kind": "command", @@ -2913,7 +2953,7 @@ compatibility: Requires the cospec CLI (@aligned-team/cospec). metadata: author: cospec generatedBy: cospec@test - contentHash: sha256:5c738047656ddb62b491db73be4646970619cfe5f01aee6779924b5bd8ef3373 + contentHash: sha256:06ced83cc52c3802650079fd3a3c303bdc97302d803b45574762e7f5bd67a1eb --- Archive a completed change. \`cospec archive\` validates it, merges its spec @@ -2936,9 +2976,15 @@ Relay the summary it prints verbatim: what was archived, which spec deltas were applied (\`+a ~m -r →n\`) or skipped, which sibling changes had blocker boxes checked, and which changes are now unblocked. -A change that introduces a brand-new capability (no living spec yet) may only -ADD requirements there — \`cospec validate\` refuses a MODIFIED, REMOVED, or -RENAMED op targeting it before archive ever runs the merge. +A change that introduces a brand-new capability (no living spec yet) may ADD +requirements there, and a REMOVED there is a no-op the merge warns about; +\`cospec validate\` refuses a MODIFIED or RENAMED op targeting it before archive +ever runs the merge. + +A change whose specs were synced early with \`$cospec-sync-specs (Codex) or /cospec-sync-specs (other agents)\` archives as a +no-op merge: the summary says the specs were already in sync, and both hard +gates (\`archive/verification-incomplete\`, \`archive/scenario-preservation\`) still +run. ## 3. On failure @@ -3061,29 +3107,29 @@ unblocked. "content": "--- name: cospec-sync-specs -description: Explain how spec sync works (it runs inside archive) and preview what would merge. Also use when the user says "cospec sync specs", "sync the specs", or "openspec sync". +description: Merge a change's delta specs into the main specs without archiving it, exactly as archive would. Also use when the user says "cospec sync specs", "sync the specs", or "openspec sync". license: MIT compatibility: Requires the cospec CLI (@aligned-team/cospec). metadata: author: cospec generatedBy: cospec@test - contentHash: sha256:1bfa89a12c71041a0dfa9dc59c5007a6cae904ca8a880cb87dbaad91fa4b4814 + contentHash: sha256:629fe8bb821e930ca0b8c1ebb26381f8a60b6bc20453a4691a4df9f5d169e8c7 --- -Explain and preview spec synchronization. Spec sync is not a standalone step in -cospec. +Merge a change's delta specs into the main specs under \`openspec/specs/\` without +archiving the change. \`cospec sync-specs\` runs the archive's own merge — the +pinned OpenSpec archive, on a scratch copy of the specs — and copies back only +the main-spec files it changed, so the result is byte-for-byte what +\`cospec archive\` would write. The change stays active, and its later archive is +a no-op merge. -Delta specs in a change are merged into the living specs under \`openspec/specs/\` -**only** by \`cospec archive\`, which applies the merge and then verifies it as -one coupled operation. There is no supported mid-flight "sync now without -archiving" path. This is deliberate: a partial merge would leave a tree that -neither validates nor archives cleanly. +## 1. Select the change -## Preview what would merge +If the user named one, use it. Otherwise run \`cospec list --json\`: if exactly +one active change exists, use it and announce \`Using change: \`; if more +than one is plausible, ask. -If the user did not name a change, run \`cospec list --json\`: if exactly one -active change exists, use it and announce \`Using change: \`; if more than -one is plausible, ask. +## 2. Preview the merge \`\`\` cospec validate @@ -3092,13 +3138,26 @@ cospec validate This runs the archive-precondition checks (targets exist, no zero-op deltas, no ADDED collisions, scenarios are well-formed) and reports anything that would make the merge fail. Then read the delta files under -\`openspec/changes//specs/**/spec.md\` to see the exact ADDED / MODIFIED / -REMOVED / RENAMED operations. +\`openspec/changes//specs/**/spec.md\` and tell the user which main specs +the sync will create, change or delete: the ADDED / MODIFIED / REMOVED / RENAMED +operations per capability, and any retirement (below) with its marker. + +A delta that targets a capability with no living spec yet may ADD requirements +there, and a REMOVED there is a no-op the merge warns about; a MODIFIED or +RENAMED op targeting it is a validate-time ERROR (\`archive/new-spec-non-added\`). -A delta that targets a capability with no living spec yet may only ADD -requirements — any MODIFIED, REMOVED, or RENAMED op there is a validate-time -ERROR (\`archive/new-spec-non-added\`), not something that surfaces later at merge -time. +## 3. Sync + +\`\`\` +cospec sync-specs +\`\`\` + +Relay what it prints: one \`Synced:\` line per main-spec file written or deleted +and the merge's totals, or that the specs were already in sync. The merge is the +archive's own, so a refusal here is the refusal archive would give — relay it +verbatim and fix what it names; never edit a main spec by hand to get past it. +Nothing is written when it refuses, and the change is never archived by this +step. ## Retiring a capability @@ -3110,11 +3169,12 @@ the marker the merge refuses and reports the missing marker as the blocking condition. Deleting the file also deletes its \`## Purpose\` — name both when you report a retirement, and give the user a way to recover the file. -## Actually sync +## Afterwards -Run \`$cospec-archive-change (Codex) or /cospec-archive-change (other agents)\` when the change is complete. The merge happens there, is -verified, and blocker check-offs fan out automatically. To sanity-check the -living specs on their own, run \`cospec validate --specs\`. +The change is still active: finish its tasks and verification, then run +\`$cospec-archive-change (Codex) or /cospec-archive-change (other agents)\`. Its merge finds the specs already in sync, and both hard +archive gates still run. To sanity-check the living specs on their own, run +\`cospec validate --specs\`. " , "kind": "skill", @@ -4651,7 +4711,7 @@ compatibility: Requires the cospec CLI (@aligned-team/cospec). metadata: author: cospec generatedBy: cospec@test - contentHash: sha256:4b9b6b08eb117becabf1d8f885fed7169b1712f092ea8d8653e2cb82220510e8 + contentHash: sha256:1f0e3bc2a8cf332628b0a74650bb73691913e94445ae43074167315b946548f6 --- Archive a completed change. \`cospec archive\` validates it, merges its spec @@ -4674,9 +4734,15 @@ Relay the summary it prints verbatim: what was archived, which spec deltas were applied (\`+a ~m -r →n\`) or skipped, which sibling changes had blocker boxes checked, and which changes are now unblocked. -A change that introduces a brand-new capability (no living spec yet) may only -ADD requirements there — \`cospec validate\` refuses a MODIFIED, REMOVED, or -RENAMED op targeting it before archive ever runs the merge. +A change that introduces a brand-new capability (no living spec yet) may ADD +requirements there, and a REMOVED there is a no-op the merge warns about; +\`cospec validate\` refuses a MODIFIED or RENAMED op targeting it before archive +ever runs the merge. + +A change whose specs were synced early with \`/cospec-sync-specs\` archives as a +no-op merge: the summary says the specs were already in sync, and both hard +gates (\`archive/verification-incomplete\`, \`archive/scenario-preservation\`) still +run. ## 3. On failure @@ -4720,7 +4786,7 @@ description: Archive a completed change — validate, merge specs, verify, and f metadata: author: cospec generatedBy: cospec@test - contentHash: sha256:70ef3ee289bf010b42e94bca2c2274d276842d5018fc6c9a199547679a317da6 + contentHash: sha256:52b0c6d92cb08508c8c44746052fffaa4ed31bb322c485737619cc3971a98794 --- Archive a completed change. \`cospec archive\` validates it, merges its spec @@ -4745,9 +4811,15 @@ Relay the summary it prints verbatim: what was archived, which spec deltas were applied (\`+a ~m -r →n\`) or skipped, which sibling changes had blocker boxes checked, and which changes are now unblocked. -A change that introduces a brand-new capability (no living spec yet) may only -ADD requirements there — \`cospec validate\` refuses a MODIFIED, REMOVED, or -RENAMED op targeting it before archive ever runs the merge. +A change that introduces a brand-new capability (no living spec yet) may ADD +requirements there, and a REMOVED there is a no-op the merge warns about; +\`cospec validate\` refuses a MODIFIED or RENAMED op targeting it before archive +ever runs the merge. + +A change whose specs were synced early with \`/cospec-sync-specs\` archives as a +no-op merge: the summary says the specs were already in sync, and both hard +gates (\`archive/verification-incomplete\`, \`archive/scenario-preservation\`) still +run. ## 3. On failure @@ -4949,29 +5021,29 @@ unblocked. "content": "--- name: cospec-sync-specs -description: Explain how spec sync works (it runs inside archive) and preview what would merge. Also use when the user says "cospec sync specs", "sync the specs", or "openspec sync". +description: Merge a change's delta specs into the main specs without archiving it, exactly as archive would. Also use when the user says "cospec sync specs", "sync the specs", or "openspec sync". license: MIT compatibility: Requires the cospec CLI (@aligned-team/cospec). metadata: author: cospec generatedBy: cospec@test - contentHash: sha256:dc48d3f5037277912711548e57c0feab65c5a8c64c32bf6070334632a9dd60d0 + contentHash: sha256:6557ec661d62b69c5956fe188cb2b2cd37639bfceb3fb7b6bb2330c0db9321ed --- -Explain and preview spec synchronization. Spec sync is not a standalone step in -cospec. +Merge a change's delta specs into the main specs under \`openspec/specs/\` without +archiving the change. \`cospec sync-specs\` runs the archive's own merge — the +pinned OpenSpec archive, on a scratch copy of the specs — and copies back only +the main-spec files it changed, so the result is byte-for-byte what +\`cospec archive\` would write. The change stays active, and its later archive is +a no-op merge. -Delta specs in a change are merged into the living specs under \`openspec/specs/\` -**only** by \`cospec archive\`, which applies the merge and then verifies it as -one coupled operation. There is no supported mid-flight "sync now without -archiving" path. This is deliberate: a partial merge would leave a tree that -neither validates nor archives cleanly. +## 1. Select the change -## Preview what would merge +If the user named one, use it. Otherwise run \`cospec list --json\`: if exactly +one active change exists, use it and announce \`Using change: \`; if more +than one is plausible, ask. -If the user did not name a change, run \`cospec list --json\`: if exactly one -active change exists, use it and announce \`Using change: \`; if more than -one is plausible, ask. +## 2. Preview the merge \`\`\` cospec validate @@ -4980,13 +5052,26 @@ cospec validate This runs the archive-precondition checks (targets exist, no zero-op deltas, no ADDED collisions, scenarios are well-formed) and reports anything that would make the merge fail. Then read the delta files under -\`openspec/changes//specs/**/spec.md\` to see the exact ADDED / MODIFIED / -REMOVED / RENAMED operations. +\`openspec/changes//specs/**/spec.md\` and tell the user which main specs +the sync will create, change or delete: the ADDED / MODIFIED / REMOVED / RENAMED +operations per capability, and any retirement (below) with its marker. + +A delta that targets a capability with no living spec yet may ADD requirements +there, and a REMOVED there is a no-op the merge warns about; a MODIFIED or +RENAMED op targeting it is a validate-time ERROR (\`archive/new-spec-non-added\`). + +## 3. Sync + +\`\`\` +cospec sync-specs +\`\`\` -A delta that targets a capability with no living spec yet may only ADD -requirements — any MODIFIED, REMOVED, or RENAMED op there is a validate-time -ERROR (\`archive/new-spec-non-added\`), not something that surfaces later at merge -time. +Relay what it prints: one \`Synced:\` line per main-spec file written or deleted +and the merge's totals, or that the specs were already in sync. The merge is the +archive's own, so a refusal here is the refusal archive would give — relay it +verbatim and fix what it names; never edit a main spec by hand to get past it. +Nothing is written when it refuses, and the change is never archived by this +step. ## Retiring a capability @@ -4998,11 +5083,12 @@ the marker the merge refuses and reports the missing marker as the blocking condition. Deleting the file also deletes its \`## Purpose\` — name both when you report a retirement, and give the user a way to recover the file. -## Actually sync +## Afterwards -Run \`/cospec-archive\` when the change is complete. The merge happens there, is -verified, and blocker check-offs fan out automatically. To sanity-check the -living specs on their own, run \`cospec validate --specs\`. +The change is still active: finish its tasks and verification, then run +\`/cospec-archive\`. Its merge finds the specs already in sync, and both hard +archive gates still run. To sanity-check the living specs on their own, run +\`cospec validate --specs\`. " , "kind": "skill", @@ -5011,29 +5097,29 @@ living specs on their own, run \`cospec validate --specs\`. { "content": "--- -description: Explain how spec sync works (it runs inside archive) and preview what would merge. Also use when the user says "cospec sync specs", "sync the specs", or "openspec sync". +description: Merge a change's delta specs into the main specs without archiving it, exactly as archive would. Also use when the user says "cospec sync specs", "sync the specs", or "openspec sync". metadata: author: cospec generatedBy: cospec@test - contentHash: sha256:78a4d09275959566ff92a490de91a93a695dd0acdbc259620b3c4156c61ba16c + contentHash: sha256:a64fe2fd251a64fb752391a0bc7298ca49edde939b1442bfedc3d0e6e5f33992 --- -Explain and preview spec synchronization. Spec sync is not a standalone step in -cospec. - -Delta specs in a change are merged into the living specs under \`openspec/specs/\` -**only** by \`cospec archive\`, which applies the merge and then verifies it as -one coupled operation. There is no supported mid-flight "sync now without -archiving" path. This is deliberate: a partial merge would leave a tree that -neither validates nor archives cleanly. +Merge a change's delta specs into the main specs under \`openspec/specs/\` without +archiving the change. \`cospec sync-specs\` runs the archive's own merge — the +pinned OpenSpec archive, on a scratch copy of the specs — and copies back only +the main-spec files it changed, so the result is byte-for-byte what +\`cospec archive\` would write. The change stays active, and its later archive is +a no-op merge. **Provided arguments**: $ARGUMENTS -## Preview what would merge +## 1. Select the change -If the user did not name a change, run \`cospec list --json\`: if exactly one -active change exists, use it and announce \`Using change: \`; if more than -one is plausible, ask. +If the user named one, use it. Otherwise run \`cospec list --json\`: if exactly +one active change exists, use it and announce \`Using change: \`; if more +than one is plausible, ask. + +## 2. Preview the merge \`\`\` cospec validate @@ -5042,13 +5128,26 @@ cospec validate This runs the archive-precondition checks (targets exist, no zero-op deltas, no ADDED collisions, scenarios are well-formed) and reports anything that would make the merge fail. Then read the delta files under -\`openspec/changes//specs/**/spec.md\` to see the exact ADDED / MODIFIED / -REMOVED / RENAMED operations. +\`openspec/changes//specs/**/spec.md\` and tell the user which main specs +the sync will create, change or delete: the ADDED / MODIFIED / REMOVED / RENAMED +operations per capability, and any retirement (below) with its marker. + +A delta that targets a capability with no living spec yet may ADD requirements +there, and a REMOVED there is a no-op the merge warns about; a MODIFIED or +RENAMED op targeting it is a validate-time ERROR (\`archive/new-spec-non-added\`). + +## 3. Sync + +\`\`\` +cospec sync-specs +\`\`\` -A delta that targets a capability with no living spec yet may only ADD -requirements — any MODIFIED, REMOVED, or RENAMED op there is a validate-time -ERROR (\`archive/new-spec-non-added\`), not something that surfaces later at merge -time. +Relay what it prints: one \`Synced:\` line per main-spec file written or deleted +and the merge's totals, or that the specs were already in sync. The merge is the +archive's own, so a refusal here is the refusal archive would give — relay it +verbatim and fix what it names; never edit a main spec by hand to get past it. +Nothing is written when it refuses, and the change is never archived by this +step. ## Retiring a capability @@ -5060,11 +5159,12 @@ the marker the merge refuses and reports the missing marker as the blocking condition. Deleting the file also deletes its \`## Purpose\` — name both when you report a retirement, and give the user a way to recover the file. -## Actually sync +## Afterwards -Run \`/cospec-archive\` when the change is complete. The merge happens there, is -verified, and blocker check-offs fan out automatically. To sanity-check the -living specs on their own, run \`cospec validate --specs\`. +The change is still active: finish its tasks and verification, then run +\`/cospec-archive\`. Its merge finds the specs already in sync, and both hard +archive gates still run. To sanity-check the living specs on their own, run +\`cospec validate --specs\`. " , "kind": "command", diff --git a/apps/cli/test/unit/harness/sync-workflows.test.ts b/apps/cli/test/unit/harness/sync-workflows.test.ts new file mode 100644 index 00000000..511a6060 --- /dev/null +++ b/apps/cli/test/unit/harness/sync-workflows.test.ts @@ -0,0 +1,43 @@ +// Verification 13.1: every rendered `sync-specs` body runs the sync through +// the CLI after a preview, and every rendered `archive` body names the +// early-sync no-op (change archive-and-sync-parity). + +import { describe, expect, test } from 'bun:test' + +import { renderHarnessFiles } from '../../../src/harness/render.ts' +import { TEST_VERSION, TYPE_TABLE } from './fixtures.ts' + +const files = renderHarnessFiles({ + harnesses: ['claude', 'codex', 'opencode', 'agents'], + typeTable: TYPE_TABLE, + version: TEST_VERSION, +}) +const bodies = (workflow: string): { path: string; body: string }[] => + files.filter((f) => f.workflow === workflow).map((f) => ({ path: f.path, body: f.body })) + +describe('the sync-specs workflow', () => { + test('renders for every harness', () => { + expect(bodies('sync-specs').length).toBeGreaterThan(0) + }) + + for (const { path, body } of bodies('sync-specs')) + test(`${path}: previews, then runs cospec sync-specs, with no bare openspec`, () => { + const preview = body.indexOf('cospec validate ') + const sync = body.indexOf('cospec sync-specs ') + expect(preview).toBeGreaterThan(-1) + expect(sync).toBeGreaterThan(preview) + expect(body).not.toMatch(/mid-flight/i) + expect(body).not.toMatch(/no supported .*sync/i) + expect(body).not.toMatch(/(^|[^/\w-])openspec (?!archive's|archive,)[a-z]/m) + }) +}) + +describe('the archive workflow', () => { + for (const { path, body } of bodies('archive')) + test(`${path}: names the early-sync no-op and both hard gates`, () => { + // The workflow reference is spelled per harness (`/cospec:sync-specs`, …). + expect(body).toMatch(/synced early with `[^`]*sync-specs[^`]*` archives as a\s+no-op merge/) + expect(body).toContain('archive/verification-incomplete') + expect(body).toContain('archive/scenario-preservation') + }) +}) diff --git a/apps/cli/test/unit/rules/archive.test.ts b/apps/cli/test/unit/rules/archive.test.ts index bc33e5d7..ed569ce9 100644 --- a/apps/cli/test/unit/rules/archive.test.ts +++ b/apps/cli/test/unit/rules/archive.test.ts @@ -60,6 +60,54 @@ describe('archiveRules', () => { expect(rules(archiveRules(change(text)))).toContain('archive/new-spec-non-added') }) + // Verification 6.5: on a capability with no living spec the binary's merge + // refuses only MODIFIED and RENAMED (`specs-apply.js`); a REMOVED there is + // ignored with a warning ("nothing to remove"), as an ADDED is applied. + const NEW_CAPABILITY_OPS: [string, string, boolean][] = [ + ['ADDED', ADD, false], + [ + 'REMOVED', + `${ADD}\n## REMOVED Requirements\n\n### Requirement: Gone\n\n**Reason**: gone.\n`, + false, + ], + [ + 'MODIFIED', + '## MODIFIED Requirements\n\n### Requirement: Whatever\n\nThe system SHALL x.\n\n#### Scenario: s\n\n- **WHEN** a\n- **THEN** b\n', + true, + ], + [ + 'RENAMED', + '## RENAMED Requirements\n\n- FROM: `### Requirement: Old`\n- TO: `### Requirement: New`\n', + true, + ], + ] + for (const [op, text, fires] of NEW_CAPABILITY_OPS) + test(`6.5 archive/new-spec-non-added on a new capability: ${op} ${fires ? 'fires' : 'does not'}`, () => { + expect(rules(archiveRules(change(text))).includes('archive/new-spec-non-added')).toBe(fires) + }) + + const REMOVED_ONLY = '## REMOVED Requirements\n\n### Requirement: Gone\n\n**Reason**: gone.\n' + + test('a REMOVED-only delta on a new capability is refused by the rebuilt spec, not the op', () => { + const found = rules(archiveRules(change(REMOVED_ONLY))) + expect(found).toContain('archive/rebuilt-spec-invalid') + expect(found).not.toContain('archive/new-spec-non-added') + }) + + test("the same delta under retire_capabilities is the binary's skip: nothing to report", () => { + const marked = makeChange({ + ...change(REMOVED_ONLY), + openspecYaml: { + present: true, + parseable: true, + schema: 'feat', + retireCapabilities: true, + }, + retireMarker: { declared: true }, + }) + expect(archiveRules(marked)).toEqual([]) + }) + test('archive/no-ops: header present, zero operations', () => { expect(rules(archiveRules(change('## ADDED Requirements\n\n(nothing parseable)\n')))).toContain( 'archive/no-ops', diff --git a/apps/docs/concepts/apply-and-archive.md b/apps/docs/concepts/apply-and-archive.md index 8bb4a516..4b114588 100644 --- a/apps/docs/concepts/apply-and-archive.md +++ b/apps/docs/concepts/apply-and-archive.md @@ -111,7 +111,7 @@ equivalent of persisting `skip_specs: true` in `.openspec.yaml` (see then the persisted marker, then the structural default that a spec-bearing type must show deltas. ::: -## `cospec archive [--skip-specs] [--force-incomplete] [--json]` +## `cospec archive [--skip-specs] [--force-incomplete] [--no-validate] [--json]` Archive is the step that moves a change out of `openspec/changes/` and merges its spec deltas into the living specs. Its steps run in a fixed order, and two @@ -119,8 +119,12 @@ of them are hard gates with no `--force` flag: **Pre-flight** -1. Resolve the change and its schema. -2. Run full validation — errors exit `1`. +1. Resolve the change and its schema. A namespace folder — a directory wrapping + nested changes rather than a change of its own — is refused here with + OpenSpec's message (`Cannot archive '': …`), before anything reads it. +2. Run full validation — errors exit `1`. `--no-validate` skips this step and is + passed on to OpenSpec, which then skips its own validation as well (see + below). 3. **Tasks gate.** Any unchecked task in `tasks.md` exits `1` unless you pass `--force-incomplete`. Note that `-y` alone does not waive this — an automated caller can't skip real work just by auto-confirming prompts. @@ -131,7 +135,9 @@ of them are hard gates with no `--force` flag: today. 6. Decide whether to pass `--skip-specs` to OpenSpec — forced by the flag, by the type having no `specs` artifact, or by the change having no - `specs/**/spec.md` files. + `specs/**/spec.md` files. The summary's `Specs:` line names which one: + `skipped (--skip-specs)`, `none (the schema has no specs artifact)` or + `none (no delta specs, so no spec sync)`. **The two hard gates**, both run before delegation, both exit `1` with no override: @@ -173,6 +179,18 @@ override: 1.0.0–1.7.x inside the accepted `>=1.0.0 <2.0.0` range — 1.8.0+ runs its own overlapping check, making cospec's gate defence-in-depth from there on. ::: +**`--no-validate` skips revalidation only** + +`cospec archive --no-validate` skips step 2 and passes `--no-validate` +to OpenSpec's archive, so neither tool revalidates the change — what an +`openspec archive --no-validate` user asked for. Every other step still runs: +the namespace-folder refusal, the tasks gate, both hard gates below, the slot +check, the on-disk verification and the spot-check. A banner on stderr says so +before the first of them, in text and `--json` mode alike. Under the flag +OpenSpec also skips its rebuilt-spec validation and retires no capability, so a +`REMOVED` that empties a spec writes it empty instead of deleting it. cospec +never prompts, so there is no confirmation to answer. + **Early-synced operations are not blockers** A delta is sometimes written after its spec change already landed in the living @@ -228,6 +246,23 @@ compare exactly here: a fold variant (`REMOVED Widget rendering` beside `ADDED WIDGET RENDERING`) is a different name to that check, and OpenSpec archives it. +A `MODIFIED` whose block is identical to the living requirement, and a +`REMOVED`-only delta under `retire_capabilities: true` on a capability whose +spec is already gone, are no-ops too. A change whose every operation is already +reflected in the living specs — synced early with +[`cospec sync-specs`](/reference/commands), or by hand — archives as a no-op +merge: OpenSpec reports the specs already in sync, the `Specs:` line reads +`already in sync`, and both hard gates still run. + +On a capability with no living spec at all, `ADDED` is applied and `REMOVED` is +a no-op OpenSpec warns about +(`… REMOVED requirement(s) ignored for new spec (nothing to remove)`); only +`MODIFIED` and `RENAMED` are refused there (`archive/new-spec-non-added`). A +delta that only `REMOVE`s on such a capability, without +`retire_capabilities: true`, is still refused — by +`archive/rebuilt-spec-invalid`, because the spec it would write has no +requirement. + Everything else stays an ERROR: an `ADDED` collision whose body differs, a `RENAMED` with FROM and TO both absent, a `RENAMED` applied while both are present, a `RENAMED` whose TO collides with an `ADDED` in the same delta — a @@ -236,8 +271,8 @@ early sync — and a `MODIFIED` whose target is absent. **Execute and verify** -7. Delegate to `openspec archive -y [--skip-specs]` and capture its - stdout, stderr, and exit code. +7. Delegate to `openspec archive -y [--skip-specs] [--no-validate]` and + capture its stdout, stderr, and exit code. 8. **Verify on the filesystem — never trust the exit code alone.** OpenSpec can print `Aborted` (or thin a spec's scenarios during merge) and still exit `0`. cospec's verifier checks directly: the source change directory is gone, and a @@ -271,8 +306,10 @@ On the success path, `cospec archive` no longer swallows the wrapped binary's own non-blocking warnings — a `Warning:` line per relayed warning, and a `Retired:` line naming any capability whose living spec the merge deleted. Both also appear in `--json`, as `warnings: string[]` and `retired: string[]` — -always present, `[]` when nothing to report; the rest of the single-change JSON -shape is unchanged. +always present, `[]` when nothing to report. Relayed text is spelled `cospec`. +The JSON document also carries OpenSpec's own `archive` and `root` keys, and +every refusal answers one document; both shapes are on +[Command reference](/reference/commands). ### Capability retirement diff --git a/apps/docs/guide/harness-setup.md b/apps/docs/guide/harness-setup.md index c501cbf7..eaa979b7 100644 --- a/apps/docs/guide/harness-setup.md +++ b/apps/docs/guide/harness-setup.md @@ -13,14 +13,15 @@ description: `sync-specs`, `explore`, `onboard`, `update` — into your agent harness by writing project files directly. This is cospec's full parity set with opsx 1.13.1: every live opsx workflow has a cospec-adapted counterpart (opsx `sync` -maps to cospec `sync-specs`, opsx `update` to cospec `update`), and cospec -always emits the complete set to every configured harness — there's no -core/custom profile split to opt into. There is no marketplace, no plugin -package, and no global state under your home directory: everything lands inside -the repo, under version control, and `cospec update` regenerates it in place. -Every generated workflow body calls only `cospec` commands, never bare -`openspec`, so a harness needs exactly one permission entry to run the whole -loop. +maps to cospec `sync-specs`, which merges a change's delta specs into the main +specs without archiving it through `cospec sync-specs`; opsx `update` maps to +cospec `update`), and cospec always emits the complete set to every configured +harness — there's no core/custom profile split to opt into. There is no +marketplace, no plugin package, and no global state under your home directory: +everything lands inside the repo, under version control, and `cospec update` +regenerates it in place. Every generated workflow body calls only `cospec` +commands, never bare `openspec`, so a harness needs exactly one permission entry +to run the whole loop. ## What gets written diff --git a/apps/docs/reference/commands.md b/apps/docs/reference/commands.md index ba43f9bd..789c7dd7 100644 --- a/apps/docs/reference/commands.md +++ b/apps/docs/reference/commands.md @@ -114,7 +114,8 @@ the binary as the item name. | `cospec list` | List active changes with type, gate state, task progress, and archive-readiness columns, in OpenSpec's order and membership: most recently modified first, or by name with `--sort name` (any other value is the default, as in OpenSpec). `--json` rows also carry OpenSpec's `name`, `completedTasks`, `totalTasks`, `lastModified`, `status` and `nested`, and the document its `warnings` and `root`, from one delegated call. A namespace folder's row reads `not a change` (state `not-a-change`) with OpenSpec's `Warning:` after the table. A cospec-typed change's `state` (`in-progress`/`building`) is cospec's own fixed artifact filenames; a change on a schema cospec doesn't type additionally checks that schema's own `generates` pattern against the change directory, so a custom-named artifact cospec doesn't recognize by filename still reads `building`, not forced `in-progress`. A change directory with no `.openspec.yaml` takes the root's `config.yaml` `schema:` (else `spec-driven`) for this, the same fallback `status` and OpenSpec's own `hasSchemaOutput` use, so its row's type and completeness agree with `status`'s; a declared schema that resolves to a real schema directory but fails to read, parse or validate warns (`schema_unreadable`) rather than silently reporting the row as empty. An unreadable `openspec/changes/archive/` lists normally with a warning (`archive_unreadable`); a read failure OpenSpec refuses is OpenSpec's `list_error` answer; an unreadable `tasks.md` OpenSpec lists past counts as no tasks with a warning (`tasks_unreadable`); an unreadable `blocking-changes.md` fails only its row (`error`), exit `1`. `--specs` instead lists living specs by requirement count (`--json` carries `root`); a failure OpenSpec reports there is relayed — its document under `--json`, `cospec: ` and its `Fix:` line in text — exit `1`. **BREAKING:** the default order is most recent first — pass `--sort name` for the old order; outside an OpenSpec root `list` answers OpenSpec's own `no_openspec_root` refusal (its message and `Fix:` line, or its document under `--json`), exit `1`, where it printed `No active changes.` | `--blocked` (only changes with a non-clear gate), `--specs`, `--sort ` | [Apply and archive](/concepts/apply-and-archive) | | `cospec instructions [artifact] --change ` | Print the authoring instructions for one artifact of a change (e.g. `proposal`, `verification`, `tasks`, `archive`). `archive` is a read-only relay of the wrapped `openspec instructions archive`, not an alias for `cospec archive` (requires openspec >=1.7.0). `--schema ` forwards to the wrapped call; both `artifact` and `--change` are optional, as upstream declares them — with either missing, the wrapped binary answers instead of a cospec-side refusal (its `Available changes`/`Valid artifacts` message), so `--json` gets exactly one document on every path. `instructions apply --change ` is always `cospec apply ` — the gate, from any directory and for any slug, with `apply`'s own refusals (no `openspec/` tree, an unknown change) — never OpenSpec's ungated apply instructions. `--schema` is refused there, before the gate runs, exit `1` (`cospec instructions: '--schema' does not apply to 'apply' …` on stderr, or one `{status: [{severity, code: "schema_not_applicable", message}]}` document under `--json`): OpenSpec's `instructions apply --schema` answers from another schema's apply requirements, while the gate enforces the change's own. Every other artifact's answer is built from the wrapped binary's own `--json` document: only the commands OpenSpec writes into it itself are respelled to `cospec` — each referenced store's `Fetch:` recipe and `Fix:` remedy (`references[].fetch`, `references[].status[].fix`, rewritten only where the whole value is one of OpenSpec's own remedies) and, for a change on OpenSpec's built-in `spec-driven` schema as the package ships it (not a project or user copy), that schema's own lines naming a bare `openspec` command. Your template, context, rules, spec summaries, store ids and paths are exactly what OpenSpec prints; text mode is OpenSpec's instruction layout rendered from the rewritten document, byte-identical to OpenSpec's wherever nothing was respelled. Every failure — an unknown change, a missing artifact or `--change`, `apply` or `archive` without a change — is OpenSpec's own answer rendered from its `--json` document: only a message or fix that is wholly one of OpenSpec's remedies names `cospec` (`Create one with: cospec new `), and the change names it lists under `Available changes` are exactly your directory names, whatever they read like. | `--change `, `--schema `, `--allow-soft` | [Workflow](/guide/workflow) | | `cospec apply ` | The gate: check blockers and required artifacts before you implement. | `--allow-soft` (proceed past a soft block), `--skip-specs` (one-shot equivalent of a persisted `skip_specs: true` marker) | [Apply and archive](/concepts/apply-and-archive) | -| `cospec archive ` | Validate, gate on tasks and verification, archive via OpenSpec, verify the move on disk, and fan out blocker sync. `--json` adds `warnings`/`retired` arrays (always present, `[]` when empty). | `--skip-specs`, `--force-incomplete` | [Apply and archive](/concepts/apply-and-archive) | +| `cospec archive ` | Validate, gate on tasks and verification, archive via OpenSpec, verify the move on disk, and fan out blocker sync. The `Specs:` line names why no spec sync ran (`skipped (--skip-specs)`, `none (the schema has no specs artifact)`, `none (no delta specs, so no spec sync)`) or reads `already in sync` for an early-synced change. `--json` adds `warnings`/`retired` arrays (always present, `[]` when empty), `specsSkipReason` (`flag`/`schema`/`no-deltas`) when no sync ran, and OpenSpec's `archive` and `root` keys; every refusal is one document (see below). | `--skip-specs`, `--force-incomplete`, `--no-validate` (skip revalidation, cospec's and OpenSpec's; every other gate still runs) | [Apply and archive](/concepts/apply-and-archive) | +| `cospec sync-specs ` | Merge a change's delta specs into the main specs without archiving it: archive's revalidation and scenario-preservation gate, then OpenSpec's own `archive -y` on a scratch copy under the OS temp directory, and only the main-spec files it changed copied back, byte-for-byte as `cospec archive` would write them. A symbolic link inside the copy, absolute or relative, points at the scratch copy of its target, so the run never writes through it into your tree; one leading outside the copied paths is refused before anything runs. A spec it cannot read is copied as an unreadable placeholder, so an unrelated one fails nothing. The change stays active, and its later archive is a no-op merge. Prints a `Synced:` line per file written or deleted and OpenSpec's totals, `already in sync`, or `Nothing to sync: ` (exit `0`, no merge run) for a schema with no specs artifact, or — once archive's revalidation passes, so a delta kept in a file the merge never reads (`specs/spec.md`, `specs/.md`, a note beside `spec.md`) is refused as archive refuses it — for `skip_specs: true` or no delta files. `--json`: `{change, type, synced, totals, files: {written, deleted}, warnings, root}`; a refusal is archive's failure document with `synced: false`. | — | [Apply and archive](/concepts/apply-and-archive) | | `cospec sync-blockers` | Check off blocking-changes entries whose target has shipped, across all active changes. | `--check` (report only, no writes), `--change ` | [Blocking changes](/concepts/blocking-changes) | | `cospec store ` | First-class wrap of the store lifecycle: `setup`/`register`/`unregister`/`remove`/`list` (`ls`)/`doctor`. `setup`/`register` auto-run `cospec init --harness none` on success. No subcommand, an unknown one, an option where it belongs, or anything after `--` (`cospec store`, `store bogus`, `store --bogus`, `store -- --bogus`) gets OpenSpec's own refusal, exit `1` — under `--json` its one `unknown_store_subcommand` document. Every relayed diagnostic's `fix`, and on failure its `message`, names the `cospec` command, text and `--json`. | `--no-cospec-init` (`setup`/`register` only) | [Stores](/concepts/stores) | | `cospec context` | Read-only cross-repo working-set brief across a repo and its `references:` stores. The reference block's commands — each `Fetch:` and `Fix:` line, and under `--json` `members[].fetch`, `members[].status[].fix` and `status[].fix` — name `cospec`, spelled from OpenSpec's own document only where the whole value is one of OpenSpec's reference remedies; store ids, paths and a declared clone remote are printed as OpenSpec prints them. | `--json`, `--code-workspace `, `--force` | [Stores](/concepts/stores) | @@ -177,6 +178,31 @@ call, an unreadable `openspec/changes/` — is one `change_error` document, exit clears, its own failure document is the answer, spelled `cospec` (`cospec apply: ` and its `Fix:` line in text). ::: +::: tip `archive` and `sync-specs` JSON documents A successful +`cospec archive --json` keeps every cospec key and adds OpenSpec's own: +`archive: { change, archivedAs, path, specsUpdated, totals?, warnings? }` and +`root: { path, source, store_id? }`. `totals` and `specsUpdated` are what +OpenSpec reported applying (its `Totals:` and in-sync lines); `totals` is absent +when no spec sync ran, `warnings` when OpenSpec reported none. Every refusal is +one document on stdout, exit `1`: cospec's `change`, `type`, `archived: false` +and `reason`, then `archive: null`, `root` (absent when no root resolved) and +`status: [{ severity, code, message, fix? }]`, with OpenSpec's code and message +wherever OpenSpec refuses the same input — `archive_change_not_found`, +`archive_change_name_invalid`, `archive_change_is_namespace_folder`, +`archive_validation_failed` (the revalidation report's keys kept), +`archive_tasks_incomplete` (fix: `--force-incomplete`, since `--yes` does not +lift cospec's tasks gate), `archive_target_exists`, `archive_spec_update_failed` +(scenario preservation), `archive_path_outside_root` or `archive_error` for an +unreadable `openspec/changes/archive/` (whichever OpenSpec answers on that +runtime: the first on macOS, the second on Linux), and `archive_error` for a +failure after delegation, carrying OpenSpec's own reason. A bare `[ ]` +verification row is the cospec-only `archive_verification_incomplete`. A root +that can't be selected answers `{ "archive": null, "status": [...] }`. +`sync-specs` refuses with the same codes, `synced: false` in place of +`archived`, and `archive_error` with OpenSpec's reason when its scratch run is +refused. Relayed text in both — warnings, refusals, `message` and `fix` — is +spelled `cospec`. ::: + ## Read-only and personal commands `store`, `context`, `workset`, `show`, `view`, `schemas`, `schema which`/ diff --git a/apps/docs/reference/validation-rules.md b/apps/docs/reference/validation-rules.md index 4cff0f02..10ca273a 100644 --- a/apps/docs/reference/validation-rules.md +++ b/apps/docs/reference/validation-rules.md @@ -276,7 +276,7 @@ archive reads. | ------------------------------- | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `archive/no-ops` | E | a delta file parses to zero operations | | `archive/target-missing` | E | a MODIFIED / RENAMED-FROM / REMOVED target is absent from the spec as the merge has it when that operation runs — the living spec with this delta's earlier operations applied, in OpenSpec's order (RENAMED, REMOVED, MODIFIED, ADDED) — so a header an earlier RENAMED in the same delta created resolves, and one it carried away does not (the message then names where an earlier RENAMED took it, or says an earlier operation removed it). Two early-sync shapes are exempt, matching openspec: a REMOVED target that is already gone, and a RENAMED whose FROM is gone while its TO is present (which also suppresses the living-spec half of this delta's `archive/added-exists` TO-collision). Either exemption is withheld — and the hint then names the exact header — when a name that survives to that operation folds equal to the target (case-insensitive, whitespace-collapsed) without being it, because that is a mistyped header the binary aborts on | -| `archive/new-spec-non-added` | E | a capability with no living spec has MODIFIED/RENAMED/REMOVED operations | +| `archive/new-spec-non-added` | E | a capability with no living spec has MODIFIED or RENAMED operations (a REMOVED there is OpenSpec's no-op warning; a REMOVED-only delta is `archive/rebuilt-spec-invalid`'s) | | `archive/added-exists` | E | an ADDED target already exists — in the spec as the merge has it by the ADDED phase, so a header this delta's own RENAMED vacated is free to re-use — with a body (normalized raw text) that differs from the living requirement — an identical block is treated as an already-synced no-op, matching openspec's own early-sync behavior; a RENAMED-TO collides with an existing or ADDED name regardless of body, except that the living-name half is suppressed for an already-applied rename (see `archive/target-missing`) — the ADDED half is delta-internal and always checked. Also fires (OpenSpec 1.13.1) when an ADDED name or a RENAMED-TO target _folds_ onto an existing requirement name — case-insensitive, interior-whitespace runs collapsed — even without an exact match, because applying it would leave two contradicting copies of one requirement in the spec. Every arm reads the spec as the merge has it when that operation runs (the living spec with the delta's earlier operations applied, in OpenSpec's order: RENAMED, REMOVED, MODIFIED, ADDED), so a delta collides with itself too — two ADDED names that fold onto each other, an ADDED folding onto the delta's own RENAMED target, a second RENAMED target folding onto the first — reported on the later operation, for a new capability as much as a living one; the message names whether the twin is a living requirement or one an earlier operation in the delta wrote. Two exclusions from the fold check: the rename's own source name, and the early-sync exemptions above. A name the same delta removes or renames away needs none — it is already gone from the spec the fold check reads. Also fires, once per ADDED and ahead of every other arm, when the same delta file REMOVEs or MODIFIES the ADDED's exact name (`ADDED "" is also REMOVED in this delta` / `… is also MODIFIED …`), for a new capability or a living one, even when the ADDED block is identical to the living requirement: OpenSpec refuses a requirement one delta both adds and removes, or both adds and modifies, before any merge runs, so re-using a header the same delta REMOVEs is refused. Names compare exactly here, so a fold variant (`REMOVED Widget rendering` + `ADDED WIDGET RENDERING`) is not this conflict | | `archive/target-invalid` | E | the target living spec has one of the three defects OpenSpec's archive refuses to update past, before merging anything (`target spec is structurally invalid`) — a delta header (`## ADDED Requirements` and its siblings, any case, any spacing between the words), a `### Requirement:` outside the `## Requirements` section, or a second requirement under a name already declared there (names compared after the closing-`#` strip). Those three are read as OpenSpec reads them: fenced lines are excluded, HTML comments are not — a commented-out requirement under `## Purpose`, or a delta header on its own line inside a comment, is refused too — and a UTF-8 BOM is kept, so a BOM before a first-line `## Requirements` hides that header and every requirement reads as outside it. The message names each defect's line. A living spec with no `## Requirements` is not one of them: the merge appends the section, so an ADDED against it archives; a living spec with no `## Purpose` text is `archive/rebuilt-spec-invalid`'s | | `archive/split-requirement` | E | a skipped `###` header (see `deltas/skipped-header`) inside an ADDED or MODIFIED block that leaves a piece of the block the rebuilt spec refuses. OpenSpec's archive appends the block verbatim, then re-validates the rebuilt spec, whose reader takes every `###` header as a requirement of its own — so the header cuts the block, and a piece with no scenario aborts the archive (`Requirement must have at least one scenario`). A scenario is what that reader counts: any deeper header with a body under the piece, a `#####` included — the verdict is read off the rebuilt spec's own parse, so a `### Notes` whose only child is a `##### Sub-case` with steps archives and stays a `deltas/skipped-header` INFO. Fires on the header when it sits between the requirement's text and its first scenario, or when no scenario follows it before the next header; one followed by a scenario of its own archives and stays a `deltas/skipped-header` INFO — unless its title is blank (`### `) and no line of text sits between it and that scenario, which leaves a requirement with no text (`Requirement text cannot be empty`). Reads HTML comments as the archive does, so a header inside `` splits the block too; a fenced header is content, and a visible `### Scenario:` line is `deltas/scenario-depth`'s alone. A header on one line with its comment (``) is no header at all. The same cut inside a living requirement the delta keeps is `archive/rebuilt-spec-invalid`'s | diff --git a/docs/apply-archive.md b/docs/apply-archive.md index ed98259c..22eff991 100644 --- a/docs/apply-archive.md +++ b/docs/apply-archive.md @@ -22,6 +22,58 @@ Dangling blocker slugs cannot false-pass either: `blockers/dangling-ref` fails validation before the gate is even evaluated (see [validation.md](validation.md)). +## Archive's step order around the binary's own checks + +`commands/archive.ts` runs the binary's two directory checks at the binary's two +points, through the same runtime calls, so an unreadable +`openspec/changes/archive/` gets the binary's answer on each OS: + +1. Before the change resolves, the binary's `assertPathWithin` over `changes/`, + `changes/archive/` and `specs/` (`core/glob.ts`'s port, through + `realpathSync.native`) — `archive_path_outside_root` where `realpath` refuses + a mode-000 directory (macOS). +2. The name check, the change lookup, and the namespace-folder refusal + (`findNestedChangesIn`), before revalidation. +3. Revalidation (skipped under `--no-validate`), reading the archive directory + the degraded way: an empty index plus a warning naming it, never a throw. +4. The tasks gate, `archive/verification-incomplete`, the self-blocker warning. +5. The slot check as an `lstat`, as the binary's + `assertArchiveDestinationAvailable` does — any errno but `ENOENT` is + `archive_error` in the runtime's own words (Linux's `EACCES … statx`). +6. `archive/scenario-preservation` (`core/scenario-gate.ts`, shared with + `sync-specs`), then the wrapped `archive -y`, the on-disk verification and + the spot-check. + +Every refusal goes through one `refuse` that prints the failure document under +`--json` (`core/archive-output.ts`); +`test/unit/commands/archive-refusals.test.ts` fails when a new refusal returns +without it. + +## `sync-specs` runs the binary's archive on a scratch tree + +`cospec sync-specs` never ports the merge: byte-identity with `archive` is only +provable by running archive's own merge. `core/scratch-root.ts` copies what the +binary's archive reads — `config.yaml`/`config.yml`, `schemas/`, `specs/`, the +one change and an empty `changes/archive/` (sibling changes and the real archive +are never read by it, and copying the archive could collide on today's slot) — +into `mkdtemp` under the OS temp directory, each read at its real path. A link +inside the copied paths is copied re-pointed at the scratch copy of its target, +so an absolute in-tree alias aliases the scratch tree, never the real one; a +link that leads outside them is refused first, because the binary would write +through it into the real tree. A file this process cannot read is copied as an +empty file of the same mode (a directory it cannot list, empty), so an unrelated +unreadable spec — which the binary's archive never reads — fails nothing. The +binary is spawned there with no `--store`: a directory holding `specs/` and +`changes/` is a real root and wins the nearest-root walk. The run must leave the +scratch change archived (exit 0, no abort, one archive entry with its +`.openspec.yaml`); then the scratch `specs/` is diffed against its pre-run copy, +every entry kind included (a linked `spec.md` the run removed is deleted, one it +replaced with a file is written), the real `specs/` is re-fingerprinted (a +change while the binary ran refuses, writing nothing), and only the written, +deleted and pruned paths are applied and re-read. The scratch directory is +removed in a `finally`, so the binary's `.openspec-archive.lock` — or any +partial write of a failed run — can only ever exist there. + ## Blocker sync `cospec sync-blockers [--check] [--change ] [--json]` is the standalone form diff --git a/docs/harness-integration.md b/docs/harness-integration.md index 2a84d83e..d3710cc2 100644 --- a/docs/harness-integration.md +++ b/docs/harness-integration.md @@ -121,10 +121,12 @@ root. fan-out. A failure is reported and the loop continues; it is never fatal to the batch. Never hand-`mkdir`/`mv`, and never `--force` a spec collision — edit the later delta instead. -- **sync-specs** — an honest body: spec sync is performed and verified by - `cospec archive` as one coupled step. To preview, run `cospec validate ` - and read the delta files. Mid-flight merging without archive is not supported. - Maps to opsx's `sync` workflow — see the name-mapping note below. +- **sync-specs** — merges a change's delta specs into the main specs without + archiving it: preview with `cospec validate ` and the delta files, then run + `cospec sync-specs `, which runs the binary's own archive merge on a + scratch copy, so a later `cospec archive` is a no-op merge with both hard + gates still run. Maps to opsx's `sync` workflow — see the name-mapping note + below. - **explore** — thinking-mode exploration; may create artifacts, never implementation code. - **onboard** — guided first real change, EXPLAIN→DO→SHOW→PAUSE: steers to a @@ -142,10 +144,10 @@ root. ### Name mapping -- `/opsx:sync` maps to `/cospec:sync-specs` — same job (preview/explain spec - merge, which only really happens inside `archive`), kept under its existing - cospec name rather than renamed to avoid churning tests, docs, and muscle - memory for zero gain. +- `/opsx:sync` maps to `/cospec:sync-specs` — same job (merge the delta specs + into the main specs without archiving), kept under its existing cospec name + rather than renamed to avoid churning tests, docs, and muscle memory for zero + gain. - `/opsx:update` maps to `/cospec:update` — see **update** above. - opsx's `feedback` workflow has no cospec workflow counterpart; not part of parity. (Wrapping `openspec feedback` itself as a disciplined passthrough CLI diff --git a/openspec/changes/archive/2026-10-05-archive-and-sync-parity/.openspec.yaml b/openspec/changes/archive/2026-10-05-archive-and-sync-parity/.openspec.yaml new file mode 100644 index 00000000..6faacc1c --- /dev/null +++ b/openspec/changes/archive/2026-10-05-archive-and-sync-parity/.openspec.yaml @@ -0,0 +1,3 @@ +schema: feat +created: 2026-10-05 +schemaVersion: 2 diff --git a/openspec/changes/archive/2026-10-05-archive-and-sync-parity/blocking-changes.md b/openspec/changes/archive/2026-10-05-archive-and-sync-parity/blocking-changes.md new file mode 100644 index 00000000..f6a2b1cd --- /dev/null +++ b/openspec/changes/archive/2026-10-05-archive-and-sync-parity/blocking-changes.md @@ -0,0 +1,39 @@ +# Dependencies + +## Blocked by + +- [x] `cli-surface-parity` — the namespace-folder detector + (`findNestedChangesIn`, `describeNestedChange`) archive's refusal calls, + `rootOutput` and the key oracle (`support/key-oracle.ts`) the archive + envelopes are proven with, and the per-module `jsonFailurePayload` hook + _(archived 2026-10-05)_ +- [x] `validation-parity` — the verbatim/advisory view split (`parseDeltaSpec`'s + `Delta`, `LivingSpec.archive`) the scenario-preservation helper reads, and + `archive/rebuilt-spec-invalid`, which still refuses a REMOVED-only delta + on a new capability once `archive/new-spec-non-added` stops firing on it + _(archived 2026-09-28)_ +- [x] `unknown-option-contract` — the command table the `sync-specs` row and the + `archive --no-validate` flag live in, the reachability test, and the + pending entry this change removes _(archived 2026-09-28)_ +- [x] `upstream-spellings` — the `core/remedies.ts` allowlist entries for + `core/archive.js` that archive's relays are spelled through _(archived + 2026-09-28)_ +- [x] `root-resolution-parity` — the resolver's `source`, the upstream oracle + helpers, and the `rootSelectionDocument` path a root failure under + `archive --json` answers through _(archived 2026-09-28)_ +- [x] `pin-node-oracle` — the oracle running the pinned binary under Bun with + the product's spawn env, and errno rows compared by code and path + _(archived 2026-09-28)_ + +## Soft-blocked by + +None. + +## Notes + +`cli-surface-parity` merged in #59, and this change starts from that `main`. The +R8 adapter-table refactor (merged) and the `harness-receipt-and-doctor-scope` +fix, which runs alongside this change, touch `harness/*`, `init.ts`, `update.ts` +and `doctor.ts`. None of those are in this change's files. Both still pass +through `mise run generate`, so this change's canon edits regenerate on top of +whatever has merged at rebase time. diff --git a/openspec/changes/archive/2026-10-05-archive-and-sync-parity/design.md b/openspec/changes/archive/2026-10-05-archive-and-sync-parity/design.md new file mode 100644 index 00000000..27c356e6 --- /dev/null +++ b/openspec/changes/archive/2026-10-05-archive-and-sync-parity/design.md @@ -0,0 +1,453 @@ +# Design + +## Context + +The proposal lists the gaps and the specs state the behavior. This section +carries only the current state the approach depends on. Every upstream fact was +read from the pinned package's `dist/core/archive.js`, +`dist/core/specs-apply.js` and `dist/core/root-selection.js`, then probed by +running the binary under Bun with HOME, every XDG directory, `CODEX_HOME` and +`ZDOTDIR` redirected into a throwaway sandbox. Facts marked "Linux" were probed +in `oven/bun:1.3.14` as uid 1000, with the worktree mounted read-only at `/w`. + +- `commands/archive.ts` runs: resolve → `validateChange` (archive-precondition + family unless specs are skipped) → tasks gate → + `archive/verification-incomplete` → self-blocker warning → slot collision + check → `archive/scenario-preservation` (inline, lines 412–446) → + `openspec archive -y [--skip-specs]` in human mode → on-disk verification + → post-merge spot-check → blocker fan-out. Every gate refusal writes stderr + prose and no JSON document. The only JSON failure document is + `reportArchiveFailure`'s + `{change, type, archived: false, reason, openspecExit}`, and it relays the + binary's output with no respelling. +- `archive --no-validate` is `pending('archive-and-sync-parity')` in + `core/command-table.ts`, with the matching `parity-pending.yaml` entry. +- `findScenarioDrops` (`core/deltas.ts`) already takes a `ScenarioBaseline` + branded to the verbatim view, and `archive.ts` passes `parseLivingSpec(…)` + (whose top level is the verbatim view) and `parseDeltaSpec` deltas (`Delta`, + fences masked, comments kept). validation-parity left the gate on that view. +- `core/remedies.ts` already carries every `core/archive.js` sentence and + command (`archive/*`, `validation/purpose-placeholder`). `remedy-sources.ts` + lists them as relayed, but `archive.ts` never calls `respellRemedies`. +- `findNestedChangesIn`, `describeNestedChange` (`core/change.ts`), `rootOutput` + and the additive merge (`core/upstream-keys.ts`), and the key oracle + (`test/contract/support/key-oracle.ts`) all exist from cli-surface-parity. +- The binary's archive claim is + `openspec/changes/archive/.openspec-archive.lock`, taken just before the first + spec write or the move and released in a `finally`. Issue #60 was a doubled + process run, not this code path, and was fixed in 0.8.3 by + standalone-json-once. + +Probed facts, including where the binary contradicts the roadmap row and wins: + +1. **The verbatim-view obligation is already met on `main`.** A MODIFIED block + that keeps a living scenario only inside a comment archives under + `cospec validate --strict`, `cospec archive` and the binary. A commented + living scenario the block omits is refused by both archives, and the binary + names it. A commented requirement header at the end of the living spec blocks + neither. R7 still moves the gate into the shared helper, and the ledger RUNS + these three fixtures through `cospec archive` and the binary (verification + group 3), so a later regression can't pass on a type check. +2. **Row 39 is broader in the binary than in the roadmap's T2 wording.** On a + capability with no living spec, `buildUpdatedSpec` warns + `N REMOVED requirement(s) ignored for new spec (nothing to remove)` and + continues. ADDED + REMOVED archives without `retire_capabilities`. + REMOVED-only under the marker archives as + `Specs already in sync; no files changed.` (`decideSpecOutcome` → `skip`). + REMOVED-only without the marker is refused with + `Spec must have at least one requirement`: the rebuilt spec has none, so the + cause is not the REMOVED. The binary's `validate --strict` passes all three. + So T2 never fires on REMOVED (D9). +3. **The success document also carries `warnings`.** The binary's `archive` + object is `{change, archivedAs, path, specsUpdated, totals?, warnings?}`. + `totals` is absent under `--skip-specs`, `warnings` is absent when empty, and + `path` and `root.path` are canonical (`/private/var/…` on macOS). The roadmap + row lists the keys without `warnings`, and cospec adds it as the binary does. +4. **An unreadable archive directory gets a runtime-dependent answer.** At mode + 000 the binary answers `archive_path_outside_root` + (`Refusing to archive through a path outside the OpenSpec root: `) + under Bun on macOS. There `FileSystemUtils.assertPathWithin`'s + `realpathSync.native` fails on the directory. Under Bun on Linux it answers + `archive_error` (`EACCES: permission denied, statx '/'`) + from `assertArchiveDestinationAvailable`'s `lstat`. Both exit 1, and both + leave no lock. cospec today prints `cospec: EACCES … scandir` and no + document. +5. **`--no-validate` in the binary also turns off retirement and rebuilt-spec + validation** (`decideSpecOutcome`: "Under --no-validate … nothing is + retired"). Under `--json` the binary refuses without `--yes` + (`archive_confirmation_required`). Human mode with `-y` prints + `⚠️ WARNING: Skipping validation may archive invalid specs.` and archives. +6. **Root resolution in a scratch directory.** `resolveOpenSpecRoot` walks to + the nearest `openspec/`. A directory with `specs/` or `changes/` is a real + root, and it wins over a `store:` pointer in its `config.yaml`, with only a + stderr warning. A scratch tree that always has both directories therefore + always resolves to itself. +7. **The binary refuses a symlinked capability alias after taking its claim.** + With `openspec/specs/alias -> widgets` and deltas for both, the binary throws + `Spec updates for 'alias' and 'widgets' resolve to the same target …` and + releases the claim. `cospec validate --strict` passes this tree. This is a + real binary refusal that cospec's pre-merge checks don't predict, so the + no-lock row uses it (D11). + +## Goals / Non-Goals + +**Goals:** + +- Every archive answer under `--json` is one document carrying the binary's keys + and codes, with no cospec key removed or changed. +- `sync-specs` writes nothing the binary's own archive wouldn't, and runs the + binary nowhere but a scratch tree. +- `archive` makes no more wrapped calls than before. `sync-specs` makes exactly + one. + +**Non-Goals:** + +- The binary's interactive archive picker and its confirmation prompts. cospec + archive never prompts, and that is its documented opinion (`--yes` is a + declared no-op). +- Narrowing a sync to a subset of a change's delta files, as upstream's agent + workflow lets `bulk-archive` do. Bulk-archive's collision resolution is R13's + T6 (`canon-workflow-parity`), which edits delta files and archives every + change through `cospec archive`. +- Upstream's agent-driven "intelligent merge" in `sync` (an ADDED for an + existing requirement treated as MODIFIED, unmentioned scenarios preserved). + cospec's sync is the archive merge itself, which is what keeps the later + archive a no-op and the hard gates meaningful. + +## Decisions + +### D1. Tracks and files + +| Track | Files (exclusive within this change) | +| ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| T5 | `test/contract/archive-no-validate.test.ts` (new), `test/contract/sync-specs.test.ts` (new), shared fixture builders in `test/contract/fixtures.ts` (append only) | +| T2 | `core/rules/archive.ts`, its unit test, `test/unit/rules/views.test.ts` (only if a fixture changes) | +| T1 | `commands/archive.ts`, `core/scenario-gate.ts` (new), `core/archive-output.ts` (new: the Totals/in-sync line reader and the failure-document builder, shared with T3), the `archive --no-validate` hunk in `core/command-table.ts`, `parity-pending.yaml` | +| T3 | `commands/sync-specs.ts` (new), `core/scratch-root.ts` (new: scratch copy, copy-back, cleanup), the `sync-specs` row in `core/command-table.ts`, the dispatch entry in `cli.ts` | +| T4 | `canon/workflows/sync-specs.md`, `canon/workflows/archive.md`, the `sync-specs` description in `canon/workflows/harness.yaml`, and the regenerated harness trees | +| Docs | the pages in D14, `.agents/shared.md` (then `mise run agents:sync`) | + +The roadmap's "`cli.ts` (`COMMANDS` only)" means the command table, per the +standing reading recorded for R1. `command-table.ts` is shared by T1 and T3. +Each touches only its own row, in the task named for it. + +### D2. Order: tests first, then T2, T1, T3, T4 + +T5's contract rows land first as `test.failing`, and each implementing task +flips exactly its own rows in the commit that makes them pass. T2 goes before T1 +because T1's early-synced retired-capability row (D10) and T3's +retired-capability sync fixture both need `new-spec-non-added` to stop firing on +REMOVED. T1 goes before T3 because sync-specs calls T1's scenario helper and +failure-document builder. T4's canon goes after T3, because the workflow body +names a command that has to exist. + +### D3. `--no-validate` is forwarded + +`--no-validate` skips cospec's `validateChange` call and adds `--no-validate` to +the wrapped `archive -y`. Everything else in D-order still runs: the +namespace-folder check (D8), tasks gate, verification gate, slot check, scenario +helper, on-disk verification and spot-check. The banner goes to stderr before +the first gate, in both modes, because stdout under `--json` is the document. + +_Rejected: skip cospec's step but let the binary validate._ An `openspec` user +passes the flag precisely to get past the binary's validation, so under `cospec` +it would still refuse. That is a swap-in regression. The cost of forwarding is +fact 5: the binary also writes instead of retiring, and skips rebuilt-spec +validation. The docs say so, and the spot-check already accepts a written-empty +spec (REMOVED names absent). + +### D4. The scenario-preservation helper + +`core/scenario-gate.ts` exports one function that takes the root base and the +change's per-capability ops (`changeDeltaOps`, moved there from `archive.ts` +unchanged) and returns `{drops, livingCaps}`, plus one renderer for the refusal +lines `archive.ts` prints today, and the binary's sentence for the first drop +(D6). It reads living specs through `parseLivingSpec` and passes the +`LivingSpec` itself (verbatim top level) to `findScenarioDrops`. The input types +stay the branded verbatim ones, so the advisory `AdvisoryDelta`/`advisory` views +cannot reach it, as `views.test.ts` already enforces for the rule. Behavior is +unchanged by the move (fact 1). The helper exists so `sync-specs` refuses +exactly where `archive` does. + +### D5. The success document is built from what cospec observed + +`archive` is filled in by cospec: + +- `change` and `archivedAs` from step 9's verified target. +- `path` as `realpathSync(join(archiveDir, target))`. +- `specsUpdated` and `totals` from the binary's own human-mode lines. Those are + `Totals: + a, ~ m, - r, → n`, and `Specs updated successfully.` versus + `Specs already in sync; no files changed.`, read by one line reader in + `core/archive-output.ts`. Each is fixed text only the binary writes, found by + its exact shape, never by a pattern over free text. +- `warnings` from the binary's own warning lines, only when non-empty. The + binary wins over the wording above (probed while implementing): its JSON + `warnings` holds its spec-merge warnings (each `⚠️ Warning:` line) and one + note per retired capability, never the proposal warnings or the + tasks-with-`--yes` line `collectArchiveWarnings` also relays. So the line + reader rebuilds exactly that list (a retirement's note from its `Retiring` + line and the recovery line under it), and cospec's own top-level `warnings` + key keeps `collectArchiveWarnings`' relay unchanged. + +`root` is `rootOutput(root)`. Under `--skip-specs` there is no `totals` key and +`specsUpdated` is `false`, as in the binary. If a binary inside the accepted +range prints no `Totals:` line, `totals` is left out rather than invented, and +`specsUpdated` falls back to whether the bytes of any living `spec.md` a delta +targets changed between step 7's snapshot and step 10. That needs the snapshot +hashes, which step 10's spot-check reads anyway. Only those files are read, as +the binary's archive reads no other main spec, so an unrelated spec this process +cannot read never fails an archive the binary completes; a target it cannot read +is fingerprinted by its metadata and left to the binary to answer. + +_Rejected: switch the wrapped call to `archive --json`._ The binary prints +proposal warnings only in human mode (`if (!json)`), so `--json` would delete +the shipped "Wrapped archive warnings are relayed" behavior. `archive --json` +also may not exist in every binary inside `>=1.0.0 <2.0.0`. + +### D6. One failure document for every refusal + +`core/archive-output.ts` builds +`{change, type, archived: false, reason, archive: null, root?, status: [diagnostic]}`. +`reason` keeps its existing values (`aborted`, `half-state`) and gains +`unknown-change`, `invalid-name`, `namespace-folder`, `validation`, +`tasks-incomplete`, `archive/verification-incomplete`, `slot-exists`, +`archive/scenario-preservation` and `archive-unreadable`. `code`/`message` +follow the spec's table, each message ported from the pinned dist's own template +and pinned by a contract row against the binary: + +- `Change '' not found. Available changes: ` (or + `… No active changes exist in this root.`). +- `Validation failed for change ''.` +- ` incomplete task(s) found for change ''.` +- `Archive '' already exists.` +- `Cannot archive '': `. +- For scenario preservation, the binary's + ` MODIFIED failed for header "### Requirement: " - current spec contains scenario(s) not present in the modified block: "". Refresh the change spec before archiving to avoid dropping scenarios.` + for the first drop in the binary's merge order. Text mode keeps cospec's full + multi-drop report. + +`fix` is the allowlist's cospec spelling where upstream's names a command, or +cospec's own remedy where cospec's gate differs. The tasks gate says +`Complete the tasks or rerun with --force-incomplete.` (a named collision in the +oracle row, because the binary's `--yes` does not lift cospec's stricter gate). +The verification gate's code `archive_verification_incomplete` is cospec-only. +The binary has no such refusal (it archives that change), so no oracle row +compares it, and an integration row pins it instead. + +The revalidation document is the existing report object with these keys added, +so no cospec key leaves it. A root-selection failure goes through `cli.ts`'s +`rootSelectionDocument` with +`export const jsonFailurePayload = { archive: null }`, the binary's payload. A +delegated failure (aborted or half-state) carries `archive_error`, its message +set to the binary's last non-blank reason line from the relayed output, +respelled. + +_Rejected: classify delegated failures into the binary's specific codes by +matching its human-mode headlines._ That is a pattern over free output, and the +binary's specific failures are already predicted, refused and coded by cospec's +pre-flight family before delegation. `archive_error` is the binary's own code +for an unclassified failure. + +### D7. An unreadable archive directory + +`archive` runs the binary's two checks at the binary's two points, on the same +runtime. First, before the change resolves (the binary's first step), it runs a +port of `assertPathWithin` for `changesDir`, `archiveDir` and `specsDir` through +`realpathSync.native`. Its failure is `archive_path_outside_root` with the +binary's message. Second, at cospec's existing slot check, it runs an `lstat` of +the slot path, where a non-ENOENT errno is `archive_error` with the runtime's +errno message. Running the same calls as the binary on the same runtime is what +makes cospec diverge per OS exactly as the binary does (fact 4). Between the two +checks, every read of the archive directory (the archive index behind +revalidation and the self-blocker warning, and step 7's snapshot) uses +cli-surface-parity's degraded read: an empty index plus a warning naming the +directory, never a throw. That way the answer on a runtime where the path check +passes comes from the slot check, as the binary's does. It also replaces the +unguarded `readdirSync` in `basenames` that crashes today. Text mode prints the +message on stderr and exits 1. Rows compare by code and path through +`test/fixtures/errno.ts`, never the sentence. The macOS row runs in the suite. +The Linux row runs in CI's `ubuntu-latest` job and once in the container recipe +above. + +### D8. Namespace folder before revalidation + +`findNestedChangesIn(changesDir, id)` runs right after the change resolves and +before `validateChange`, as in the binary. Its refusal uses the binary's message +and fix (spec). `sync-specs` calls the same check with its own two sentences. +The validate-time `meta/nested-change` report stays for `validate`. + +### D9. `archive/new-spec-non-added` (T2) + +In the `living === undefined` arm of `core/rules/archive.ts`, REMOVED is skipped +along with ADDED. MODIFIED and RENAMED stay ERRORs with today's message. + +The REMOVED-only no-marker case is not refused by `archive/rebuilt-spec-invalid` +on `main` today. That rule runs only when no `MERGE_PRECONDITIONS` ERROR fired +for the capability, and `new-spec-non-added` is one of them. The probe shows a +single `new-spec-non-added` ERROR for that fixture. `rebuildSpec` already builds +the binary's skeleton for a new capability whose ops are ADDED/REMOVED only, and +the ported retirement decision skips a spec with nothing on disk. So once T2 +lifts the precondition, the rebuilt check is what must refuse the no-marker case +(`Spec must have at least one requirement`) and clear the marked case. T2 owns +making that true: task 2.1 flips row 6.3 from failing, and extends the rebuilt +check to the skeleton in the same commit if it does not fire there. Without +that, validate would pass a delta the binary's archive refuses, which is a false +PASS (verification 6.3). + +The delegated-duplicate pairing that names `new-spec-non-added` +(spec-parsing-and-discovery's dry-run message) stays, since that message is only +raised for MODIFIED/RENAMED. + +### D10. Early-synced operations and the Specs line + +The spot-check already judges an identical ADDED, an absent REMOVED, an applied +RENAMED and an identical MODIFIED as landed, against the net effect. The +remaining early-sync shape is a capability retired before archive. Its living +spec is absent at step 7, so `livingCaps` doesn't hold it and its REMOVED names +read as absent, which already passes once T2 lets it validate. The `Specs:` line +prints `already in sync` whenever D5's reader saw the binary's in-sync line, and +`+a ~m -r →n applied and verified` otherwise. The skip reasons print as +`skipped (--skip-specs)`, `none (the schema has no specs artifact)` and +`none (no delta specs, so no spec sync)`, and `specsSkipReason` is `flag`, +`schema` or `no-deltas`. + +### D11. `sync-specs` (T3) + +Steps follow the spec. Mechanics: + +- **Scratch layout.** `mkdtempSync(join(tmpdir(), 'cospec-sync-'))` holds + `openspec/`. Into it go `config.yaml`/`config.yml` (if present), `schemas/`, + `specs/` and `changes//`, plus an empty `changes/archive/`. Each copied + path is read at its real path (a symlinked `specs/` is copied, not linked). A + file this process cannot read is copied as an empty file of the same mode, and + a directory it cannot list as an empty directory, so the binary meets the + refusal it meets in the real tree only if it reads one — and the binary's + archive reads no main spec but a delta's target, so an unrelated unreadable + spec fails neither. Copying the real archive would let today's slot collide (a + cospec archive of a same-named change earlier today), and sibling changes are + never read by the binary's archive. That is why this departs from the + roadmap's "copy of the root's `openspec/` tree": the binary reads only this + subset, and anything more is a way to fail that `archive` wouldn't. +- **Symlinks.** Before copying, every symlink under the copied paths is + resolved. One that leads outside the copied subtree is refused, naming it, + because the binary would write through it into the real tree. One inside is + copied as a relative link to the scratch copy of its target, so the binary + sees the same aliasing (fact 7) — and an absolute link, or one that climbs out + of the root and back in, aliases the scratch tree, never the real one. +- **Spawn.** `spawnOpenspec(['archive', id, '-y'], scratchRoot)` with no store + args (fact 6). Wrapped-call discipline: expected exits `{0, 1}`. The stdout + deny-list is the existing archive one (`Aborted`, `Archive cancelled`). The + observed post-condition is `/openspec/changes/` gone and exactly + one `/openspec/changes/archive/` holding `.openspec.yaml`. That + proves the run resolved the scratch root and completed, and the real + `openspec/changes/` is still present, which proves it did not run in the + real tree. +- **Copy-back.** Before the copy, a fingerprint (sha256 per file, the target + each link holds, metadata for an entry this process cannot read, plus the + entry list) is taken of the real `openspec/specs/`. After a verified run, the + scratch `specs/` is diffed against the scratch's pre-run copy, every entry + kind included, to get the written and deleted sets: a file created or changed + (a link the binary replaced with a file too) is written, and a file or link it + removed (a retired capability's linked `spec.md`) is deleted. The binary never + creates or re-points a link nor touches what it cannot read, so such a change + is an invariant breach thrown before any write. Then the real `specs/` is + fingerprinted again. If it changed while the binary ran, the command refuses + and writes nothing. Otherwise each written file is applied with `atomicWrite` + (mode preserved for existing files), each deleted file or link is unlinked, + and directories the binary pruned are removed up to `specs/`. Step 5 re-reads + each written file and compares bytes, checks each deleted path is absent, and + re-fingerprints everything else. +- **Cleanup.** `rmSync(scratch, {recursive: true, force: true})` in a `finally`. + The claim file can only ever exist inside the scratch tree, which is removed + with it. The real tree is written only in the copy-back, after a verified run. +- **Output.** Text prints a `Synced:` line per file and a `Totals:` summary from + D5's reader, plus relayed warnings. JSON follows the spec. A pre-merge refusal + reuses archive's failure-document builder (with `synced: false` in place of + `archived`), and a scratch-run refusal carries `archive_error` and the + relayed, respelled reason, as D6. + +_Rejected: run `openspec archive` in the real tree and move the change back._ +That creates the claim and the move in the real tree, so a crash leaves exactly +the state #60 described, and undoing a move is not atomic. _Rejected: port the +merge._ Byte-identity with archive is only provable by running archive's own +merge. + +### D12. Relays respelled + +`reportArchiveFailure`'s captured block, `collectArchiveWarnings`' messages +(both modes) and the failure document's `message`/`fix` go through +`respellRemedies`, whose allowlist rewrites only exact upstream sentences, so +paths and spec text pass through byte-for-byte. `sync-specs` relays through the +same calls. Every `core/archive.js` line in `remedy-sources.ts` that is now +really relayed keeps its relayed classification. The enumeration test needs no +new entries, and a contract row asserts no relayed line names a bare allowlisted +`openspec` command. + +### D13. Canon (T4) + +`sync-specs.md` is rewritten: select the change, preview +(`cospec validate `, read the deltas, name what will be created, changed +or deleted, and note any retirement and its marker), run +`cospec sync-specs `, report. The retirement section stays. `archive.md` +gains one sentence on the early-sync no-op. The `harness.yaml` description +becomes "Merge a change's delta specs into the main specs without archiving it, +exactly as archive would", and keeps the existing trigger phrases. +`mise run generate` regenerates every harness, and `generate:check` is the drift +gate. + +### D14. Docs + +| Page | Fact it owns | +| -------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| `apps/docs/reference/commands.md` | `archive --no-validate`, archive's JSON documents and codes, the `Specs:` line, `sync-specs` (new row) | +| `apps/docs/concepts/apply-and-archive.md` | early sync, the no-op archive, `--no-validate`'s gates | +| `docs/apply-archive.md` | the archive step order (namespace check, D7 checks), the scratch design | +| `apps/docs/guide/harness-setup.md` | the `sync`↔`sync-specs` sentence | +| `apps/docs/reference/validation-rules.md` | `archive/new-spec-non-added`'s trigger | +| `docs/validation.md` | the same rule in the archive family list | +| `apps/docs/concepts/how-it-relates-to-openspec.md` | any sentence naming archive's pending flag, the JSON gap or sync | + +A grep for `no-validate`, `sync-specs`, `mid-flight` and `new-spec-non-added` +across `apps/docs` and `docs` is the completeness check. `.agents/shared.md` +step 6 gains `cospec sync-specs` as the way to land main specs early. Its "JSON +documents are additive" paragraph stops calling `archive --json` "next" and +names the tasks-gate `fix` in `NAMED_COLLISIONS` with why cospec's value wins. +Then `mise run agents:sync`. + +## Operational surface + +The interactive surface is `cospec archive`'s human and `--json` output, the new +`cospec sync-specs` command, and the `/cospec:sync-specs` and `/cospec:archive` +workflow bodies in every harness. + +- One flag (`archive --no-validate`) becomes accepted, and one command is new. +- Archive's JSON gains keys, and every refusal now prints a document. Exit codes + change only as BREAKING lists. +- `sync-specs` spawns the wrapped binary once. It runs in a scratch directory + under the OS temp directory (`$TMPDIR` on macOS, `/tmp` on Linux), whose size + is the change plus the root's `specs/` and `schemas/`, and it is deleted + before the command exits. + +There's no bind address, container, secret or connection limit. The wrapped +binary is still resolved by path at the pinned version, inside the accepted +range. Every contract row spawns it the way the suite does, under Bun with the +product env and a sandboxed HOME. The Linux row also runs in the +`oven/bun:1.3.14` container as a non-root user. + +## Risks / Trade-offs + +- [The binary's human-mode `Totals:` or in-sync line changes in a later in-range + version] → The reader matches the 1.13.1 text exactly and degrades to "no + `totals`, disk-observed `specsUpdated`". A contract row pins the text against + the pinned dist, so a pin bump fails loudly. +- [A user's spec tree is large, so the scratch copy is slow] → Only `specs/`, + `schemas/`, the config and one change are copied. That is what the binary + reads anyway. +- [The real `specs/` changes while the binary runs] → The pre/post fingerprint + refuses, and nothing is written. +- [`--no-validate` lets the binary write an empty-requirement spec where it + would have retired one] → That is the binary's documented behavior under the + flag, and the docs say so. Both hard gates still run. +- [The per-OS unreadable-archive answer differs between developer machines and + CI] → Rows compare against the binary's answer on the runtime they run on, + never a fixed prediction, as cli-surface-parity's mode-000 rows do. diff --git a/openspec/changes/archive/2026-10-05-archive-and-sync-parity/proposal.md b/openspec/changes/archive/2026-10-05-archive-and-sync-parity/proposal.md new file mode 100644 index 00000000..155667d9 --- /dev/null +++ b/openspec/changes/archive/2026-10-05-archive-and-sync-parity/proposal.md @@ -0,0 +1,201 @@ +# Proposal + +## Why + +`cospec archive` and `/cospec:sync-specs` are the last two places where an +`openspec` invocation does something different under `cospec`, so the +swap-in-correctness release can't ship until they match. Each gap below was +probed against the pinned binary (1.13.1) run under Bun in a sandboxed HOME, and +on Linux in an `oven/bun:1.3.14` container where the answer depends on the OS: + +- **`archive --no-validate` is refused as pending.** The binary skips its + validation, prints a warning and archives. A script that passes the flag gets + `'--no-validate' is not supported yet`, exit 1, from `cospec`. +- **No upstream keys in archive's JSON.** The binary answers + `{archive: {change, archivedAs, path, specsUpdated, totals?, warnings?}, root}` + on success and + `{archive: null, root, status: [{severity, code, message, fix?}]}` on failure. + cospec's success document has neither key. Its failure document is + `{change, type, archived: false, reason, openspecExit}`, and only for a + failure after delegation. Every gate refusal under `--json` (tasks, + verification, scenario preservation, slot collision, validation, unknown + change) prints stderr prose and no JSON document at all. With + `openspec/changes/archive/` at mode 000, `archive --json` crashes with a + bare `EACCES` line. The binary answers one failure document there: + `archive_path_outside_root` under Bun on macOS, `archive_error` naming `statx` + under Bun on Linux. +- **Remedies aren't respelled.** When the wrapped archive fails, cospec relays + its output with every `openspec …` remedy left bare. The allowlist entries for + those sentences already exist in `core/remedies.ts`, but archive never calls + them. +- **Skipped sync gets no line.** A change with no delta specs is folded into + `Specs: skipped`, the same as `--skip-specs`, and nothing says no spec sync + happened. +- **A namespace folder gets a validation report.** `cospec archive mobile` on a + folder that wraps `mobile/refresh/` prints `validate`'s report. The binary + refuses it up front with `archive_change_is_namespace_folder` and a fix. +- **REMOVED on a new capability is falsely refused.** + `archive/new-spec-non-added` refuses every REMOVED operation on a capability + with no living spec. The binary ignores those with a warning. ADDED + REMOVED + archives, and REMOVED-only under `retire_capabilities: true` archives as + "already in sync". REMOVED-only without the marker is refused, but because the + rebuilt spec has no requirements, not because of the REMOVED. The binary's + `validate --strict` accepts all three. +- **No way to sync without archiving.** Upstream's `sync` workflow merges a + change's delta specs into the main specs without archiving it. cospec's + `sync-specs` workflow says there is "no supported mid-flight sync" and only + previews. + +## What Changes + +- **`cospec archive --no-validate`** skips cospec's revalidation step and is + forwarded to the wrapped `archive -y`, so the binary skips its own validation + as an `openspec` user asked. The tasks gate, + `archive/verification-incomplete`, `archive/scenario-preservation`, the + namespace-folder refusal, the slot check and the on-disk verification still + run, and a stderr banner says revalidation was skipped and which gates still + ran. The pending entry leaves `parity-pending.yaml`. +- **One scenario-preservation helper.** The `archive/scenario-preservation` hard + gate moves out of `commands/archive.ts` into a shared helper. It reads the + verbatim view (fences masked, comments kept), as the archive does, and + `archive` and `sync-specs` both call it. +- **Archive's JSON documents.** + - Success adds + `archive: {change, archivedAs, path, specsUpdated, totals?, warnings?}` and + `root: {path, source, store_id?}`. `specsUpdated` and `totals` are the + binary's applied values, read from its own `Totals:` and + `Specs updated successfully.` / `Specs already in sync; no files changed.` + lines. `path` is the canonical archive path. `warnings` is present only when + the binary reported any. Every cospec key keeps its value. + - Every refusal under `--json` is one document: `archive: null`, `root` (left + out when no root resolved, as the binary leaves it out), + `status: [{severity, code, message, fix?}]`, and cospec's own + `change`/`type`/`archived: false`/`reason`, exit 1. `code` is the binary's + code wherever the binary refuses the same input: `archive_change_not_found`, + `archive_change_name_invalid`, `archive_change_is_namespace_folder`, + `archive_validation_failed`, `archive_tasks_incomplete`, + `archive_target_exists`, `archive_spec_update_failed` (scenario + preservation), `archive_path_outside_root`, and `archive_error`. + `archive/verification-incomplete` has no upstream counterpart and carries + the cospec-only code `archive_verification_incomplete`. `fix` is cospec's + spelling of the remedy cospec accepts. + - An unreadable `openspec/changes/archive/` answers the binary's code for the + runtime it runs on (`archive_path_outside_root` or `archive_error`). In text + mode it's one stderr line, never a crash. +- **Relayed remedies.** Every relay of the wrapped archive's output (the aborted + and half-state reports, the relayed warnings, the failure document's + `message`/`fix`) is spelled through `respellRemedies`. +- **The Specs line.** `Specs:` says which reason skipped the sync: + `--skip-specs`, a schema with no specs artifact, or no delta specs. JSON gains + `specsSkipReason`, and the existing `specs` value doesn't change. A change + whose deltas were already synced reports `already in sync` instead of + `applied`. +- **Namespace folders.** `cospec archive ` is refused before + revalidation, using R6's `findNestedChangesIn`, with the binary's message and + fix, on stderr or as the failure document, exit 1. +- **Early-synced changes archive as no-op merges.** The post-merge spot-check + already accepts identical ADDED, absent REMOVED, applied RENAMED and identical + MODIFIED operations. A capability that was retired early now archives too. +- **`archive/new-spec-non-added`** fires only on MODIFIED and RENAMED. REMOVED + on a capability with no living spec is the binary's "nothing to remove" no-op. + A REMOVED-only delta without `retire_capabilities` is still refused, by + `archive/rebuilt-spec-invalid`, because the rebuilt spec has no requirements. +- **New `cospec sync-specs `** merges a change's delta specs into the + main specs without archiving it, in five steps: + 1. Run archive's pre-merge checks: archive-precondition validation, then the + scenario-preservation helper. + 2. Run the pinned binary's own `archive -y` on a scratch copy of what the + binary reads (`config.yaml`/`config.yml`, `schemas/`, `specs/`, the change, + an empty `changes/archive/`) in a fresh OS temp directory. + 3. Copy back only the main-spec files that run created, changed or deleted. + 4. Verify them on disk. + 5. Leave the change active. + + The main specs come out byte-for-byte as `cospec archive` would write them, so + a later archive is the binary's early-sync no-op. A failed scratch run leaves + no `.openspec-archive.lock` and no file in the real tree. `--json` and + `--store` are accepted. + +- **`/cospec:sync-specs`** does upstream's `sync`: it previews the merge, then + runs `cospec sync-specs `. The "no mid-flight sync" text is removed. + `/cospec:archive` notes that a change synced early archives as a no-op merge. + The workflow's description changes, so its skill trigger text changes. +- **BREAKING:** + - `cospec archive --json` refusals now print a JSON document on stdout. They + used to print only stderr prose (gate refusals, unknown change, slot + collision). The validation refusal's document is no longer `validate`'s bare + report: it keeps that report's keys and adds `archive`, `root`, `status`, + `change`, `type`, `archived` and `reason`. + - `cospec archive ` reports + `archive_change_is_namespace_folder` (the binary's message and fix) instead + of a `meta/nested-change` validation report. A script grepping the validate + output for this case needs the new text. + - `cospec archive --no-validate` archives where it exited 1 as pending, and + skips the binary's validation too (so the binary also retires no capability, + as it does under the flag). + - The `Specs:` line of a no-delta change reads `none` and names the reason, + where it read `skipped`. + - `/cospec:sync-specs` writes main specs. It used to only preview. + - `cospec validate` stops reporting `archive/new-spec-non-added` on REMOVED + operations. + +## Capabilities + +### New Capabilities + +- `spec-sync`: `cospec sync-specs` merges a change's delta specs into the main + specs early, through the binary's own archive on a scratch copy, so the result + is byte-identical to archive's and a later archive is a no-op merge. + +### Modified Capabilities + +- `archive-integrity`: `--no-validate`, the shared scenario-preservation helper + on the verbatim view, the skip-reason line, early-synced no-op archives + including a retired capability, respelled relays, and REMOVED on a new + capability. +- `json-document-parity`: archive's success and failure documents carry the + binary's keys, and every archive refusal under `--json` is one document. +- `nested-change-detection`: archive refuses a namespace folder as the binary + does. +- `harness-workflows`: the `sync-specs` workflow runs `cospec sync-specs`, and + the `archive` workflow names the early-sync no-op. + +## Impact + +- `apps/cli/src/commands/archive.ts` (T1), `apps/cli/src/core/scenario-gate.ts` + (new shared helper, T1), `apps/cli/src/core/rules/archive.ts` (T2), + `apps/cli/src/commands/sync-specs.ts` (new, T3), + `apps/cli/src/core/command-table.ts` (the `sync-specs` row, and + `archive --no-validate` moving from pending to handled), `apps/cli/src/cli.ts` + (the `sync-specs` dispatch entry), + `apps/cli/test/contract/parity-pending.yaml` (the `archive --no-validate` + entry removed). +- Canon: `apps/cli/src/canon/workflows/{sync-specs,archive}.md` and the + `sync-specs` description in `harness.yaml`, plus the regenerated harness trees + (`mise run generate`). +- Tests: `apps/cli/test/contract/archive-no-validate.test.ts` and + `apps/cli/test/contract/sync-specs.test.ts` (new). These hold the gate + fixtures, the verbatim-view differential fixtures, the archive key-oracle rows + through the existing `support/key-oracle.ts`, and the sync fixtures. Plus unit + tests for the helper, the scratch copy and copy-back, and the Totals-line + parser. +- Docs: `apps/docs/concepts/apply-and-archive.md`, `docs/apply-archive.md`, + `apps/docs/reference/commands.md`, `apps/docs/guide/harness-setup.md` (the + `sync`↔`sync-specs` sentence), `apps/docs/reference/validation-rules.md` and + `docs/validation.md` (the `archive/new-spec-non-added` trigger), + `apps/docs/concepts/how-it-relates-to-openspec.md` where it names either fact, + and `.agents/shared.md` (then `mise run agents:sync`). +- JSON: keys added to archive's documents, and a document on every archive + refusal. The new command has its own document. Exit codes change only where + BREAKING says. +- No new dependency. `sync-specs` makes one wrapped call, and `archive` makes no + more calls than before. + +## Surfaces + +- [x] interactive — a user-visible/interactive surface (UI, TUI, CLI UX) +- [ ] deploy — deploy/runtime/CI-execution topology (infra, Dockerfile, workflow + runtime, secrets, bind address) +- [ ] integration — a third-party/external contract (SDK, OAuth, schema/id-type + reconciliation) +- [x] agent-behavior — prompts, tools, model routing, or agent output shape diff --git a/openspec/changes/archive/2026-10-05-archive-and-sync-parity/specs/archive-integrity/spec.md b/openspec/changes/archive/2026-10-05-archive-and-sync-parity/specs/archive-integrity/spec.md new file mode 100644 index 00000000..92308e06 --- /dev/null +++ b/openspec/changes/archive/2026-10-05-archive-and-sync-parity/specs/archive-integrity/spec.md @@ -0,0 +1,140 @@ +# Spec Delta + +## ADDED Requirements + +### Requirement: --no-validate skips only revalidation + +`cospec archive --no-validate` SHALL skip only cospec's revalidation +step (the archive-precondition validation `validateChange` runs) and SHALL +forward `--no-validate` to the wrapped `archive -y` call, so the binary skips +its own validation as the flag asks. The namespace-folder refusal, the tasks +gate, `archive/verification-incomplete`, the archive-slot check, +`archive/scenario-preservation`, the on-disk move verification and the +post-merge spot-check SHALL still run. Before any of them, cospec SHALL print a +banner on stderr, in text and `--json` mode alike, saying that revalidation was +skipped and naming the gates that still run. cospec SHALL NOT prompt: its +archive never prompts, so the binary's `--json --no-validate` confirmation +refusal (`archive_confirmation_required`) has no cospec counterpart. + +#### Scenario: Both hard gates still refuse under --no-validate + +- **WHEN** `cospec archive c1 --no-validate` runs on a change with a bare `[ ]` + verification row, and again on a change whose MODIFIED block drops a living + scenario +- **THEN** each is refused by its hard gate with exit 1, nothing is moved, and + stderr carries the revalidation-skipped banner + +#### Scenario: A change validation would refuse archives + +- **WHEN** `cospec archive c1 --no-validate` runs on a change that only + revalidation refuses (a delta the binary also archives under `--no-validate`) +- **THEN** the change archives with exit 0, the banner is on stderr, and the + main specs equal what `openspec archive c1 -y --no-validate` writes on a copy + +### Requirement: One scenario-preservation helper reads the verbatim view + +The `archive/scenario-preservation` hard gate SHALL live in one shared helper +that `cospec archive` and `cospec sync-specs` both call, and that reads the +living spec and the delta the way the binary's archive reads them: code fences +masked and HTML comments kept. A scenario that sits inside a comment in the +living spec SHALL count as a living scenario, and a scenario the MODIFIED block +keeps only inside a comment SHALL count as kept, so the gate refuses and passes +exactly where `openspec archive` does. + +#### Scenario: A scenario kept only inside a comment archives on both sides + +- **WHEN** a MODIFIED block keeps one living scenario only inside an HTML + comment +- **THEN** `cospec validate --strict`, `cospec archive` and + `openspec archive -y` each accept it, and both archives write the same main + spec + +#### Scenario: A commented living scenario the delta omits is refused on both sides + +- **WHEN** the living requirement carries a third scenario inside an HTML + comment and the MODIFIED block repeats only the two visible ones +- **THEN** `cospec archive` refuses with `archive/scenario-preservation` naming + that scenario, and `openspec archive -y` refuses and changes nothing + +#### Scenario: A commented requirement header in the living spec blocks neither side + +- **WHEN** the living spec ends with a requirement header and its scenario + inside an HTML comment, and a MODIFIED block keeps every scenario of a visible + requirement +- **THEN** `cospec archive` and `openspec archive -y` both archive it + +### Requirement: Archive names why no spec sync happened + +When `cospec archive` passes `--skip-specs` to the wrapped archive, its `Specs:` +line SHALL name which reason applied: the user passed `--skip-specs`, the +change's schema has no specs artifact, or the change has no delta spec files. +Under `--json` the document SHALL carry `specsSkipReason` with the value `flag`, +`schema` or `no-deltas`, and the existing `specs: "skipped"` value SHALL stay as +it is. + +#### Scenario: A specs-bearing change with no delta files says so + +- **WHEN** `cospec archive c1` runs on a `feat` change with no files under + `specs/` +- **THEN** the `Specs:` line says there were no delta specs and so no spec sync, + and `--json` carries `specsSkipReason: "no-deltas"` + +### Requirement: An early-synced change archives as a no-op merge + +`cospec archive` SHALL archive a change whose every delta operation is already +reflected in the main specs, as left by `cospec sync-specs` or by hand. That +covers an identical ADDED, an absent REMOVED, an applied RENAMED, an identical +MODIFIED, and a capability whose spec was already retired. The post-merge +spot-check SHALL accept each such operation, and the `Specs:` line SHALL report +that the specs were already in sync whenever the wrapped archive reports so, not +that operations were applied. + +#### Scenario: A retired capability that is already gone archives + +- **WHEN** a change under `retire_capabilities: true` REMOVEs every requirement + of a capability whose `spec.md` is already deleted +- **THEN** `cospec validate --strict` and `cospec archive` accept it, the + archive exits 0, and the binary's "already in sync" report is reflected in the + `Specs:` line + +### Requirement: A brand-new capability refuses only MODIFIED and RENAMED + +`archive/new-spec-non-added` SHALL be an ERROR only on a MODIFIED or RENAMED +operation that targets a capability with no living spec. A REMOVED operation +there SHALL NOT be reported by it: the binary ignores it with a warning +("nothing to remove") and applies the rest of the delta. A delta whose only +operations on a new capability are REMOVED, without `retire_capabilities: true`, +SHALL still be refused, by `archive/rebuilt-spec-invalid`, because the spec the +binary would write has no requirements. + +#### Scenario: ADDED with REMOVED on a new capability archives + +- **WHEN** a delta for a capability with no living spec ADDs one requirement and + REMOVEs another +- **THEN** `cospec validate --strict` reports no `archive/new-spec-non-added`, + and `cospec archive` and `openspec archive -y` both archive it with the ADDED + requirement written + +#### Scenario: REMOVED-only on a new capability without the marker is still refused + +- **WHEN** a delta for a capability with no living spec only REMOVEs, and the + change does not declare `retire_capabilities: true` +- **THEN** `cospec validate --strict` reports `archive/rebuilt-spec-invalid`, + `cospec archive` refuses before delegating, and `openspec archive -y` refuses + too + +### Requirement: Relayed archive output is spelled cospec + +Every place `cospec archive` relays the wrapped archive's output (the aborted +and half-state reports, the relayed merge warnings, and the `message` and `fix` +of its failure document) SHALL pass that text through `respellRemedies`, so a +sentence on the remedy allowlist names `cospec`, while a path, a change name and +authored spec text pass through byte-for-byte. + +#### Scenario: A relayed merge warning names cospec + +- **WHEN** the wrapped archive creates a new capability whose carried Purpose is + under the minimum length and cospec relays the binary's warning +- **THEN** the relayed `Warning:` line reads + `… cospec validate --strict reports it as too brief.`, and no relayed line + names a bare `openspec` command on the allowlist diff --git a/openspec/changes/archive/2026-10-05-archive-and-sync-parity/specs/harness-workflows/spec.md b/openspec/changes/archive/2026-10-05-archive-and-sync-parity/specs/harness-workflows/spec.md new file mode 100644 index 00000000..51444f65 --- /dev/null +++ b/openspec/changes/archive/2026-10-05-archive-and-sync-parity/specs/harness-workflows/spec.md @@ -0,0 +1,31 @@ +# Spec Delta + +## ADDED Requirements + +### Requirement: The sync-specs workflow syncs through cospec sync-specs + +The `sync-specs` workflow body SHALL do what upstream's `sync` workflow does, +merge a change's delta specs into the main specs without archiving it, and SHALL +do it only through the CLI: preview the merge (`cospec validate ` and the +change's delta files), say which main specs will be created, changed or deleted, +then run `cospec sync-specs ` and report its result. It SHALL NOT instruct +the agent to edit a main spec by hand, and SHALL NOT say that a mid-flight sync +is unsupported. It SHALL say that the merge is the archive's own, byte-for-byte, +so a refusal there is the same refusal archive would give, and that the change +stays active. The workflow's `harness.yaml` description SHALL say it merges a +change's delta specs into the main specs without archiving, and SHALL keep its +natural-phrasing triggers. The `archive` workflow body SHALL say that a change +whose specs were synced early archives as a no-op merge, with both hard gates +still run. + +#### Scenario: The rendered sync-specs body runs the command + +- **WHEN** the `sync-specs` workflow renders for every harness +- **THEN** each body instructs `cospec sync-specs ` after a preview, + carries no "no mid-flight sync" text, and names no bare `openspec` command + +#### Scenario: The rendered archive body names the early-sync no-op + +- **WHEN** the `archive` workflow renders +- **THEN** it says a change synced early with `/cospec:sync-specs` archives as a + no-op merge and still passes both hard gates diff --git a/openspec/changes/archive/2026-10-05-archive-and-sync-parity/specs/json-document-parity/spec.md b/openspec/changes/archive/2026-10-05-archive-and-sync-parity/specs/json-document-parity/spec.md new file mode 100644 index 00000000..e4681c8c --- /dev/null +++ b/openspec/changes/archive/2026-10-05-archive-and-sync-parity/specs/json-document-parity/spec.md @@ -0,0 +1,70 @@ +# Spec Delta + +## ADDED Requirements + +### Requirement: Archive's JSON documents carry the binary's keys + +`cospec archive --json` SHALL add the binary's keys to its success +document without changing any cospec key: +`archive: {change, archivedAs, path, specsUpdated, totals?, warnings?}` and +`root: {path, source, store_id?}`. `archivedAs` SHALL be the archive directory +verified on disk. `path` SHALL be its canonical absolute path. `specsUpdated` +and `totals` SHALL be the values the wrapped archive reported applying, read +from its own `Totals:` line and its `Specs updated successfully.` or +`Specs already in sync; no files changed.` line. `totals` SHALL be absent when +no spec sync ran, and `warnings` SHALL be absent when the binary reported none. +A key oracle row SHALL compare the document with the binary's own +`archive -y --json` on a copy of the same fixture. + +#### Scenario: The success document matches the binary's keys + +- **WHEN** `cospec archive c1 --json` archives a MODIFIED change, and + `openspec archive c1 -y --json` archives a copy +- **THEN** cospec's `archive` and `root` equal the binary's, apart from the + copy's own path, and every cospec key keeps its native value + +### Requirement: Every archive refusal under --json is one document + +Under `--json` every refusal of `cospec archive` SHALL be exactly one document +on stdout, with exit 1: `archive: null`, `root` (absent when no root resolved, +as in the binary), `status: [{severity: "error", code, message, fix?}]`, and +cospec's `change`, `type`, `archived: false` and `reason`. `code` and `message` +SHALL be the binary's for every refusal the binary makes on the same input: + +| Refusal | `code` | +| --------------------------------- | ------------------------------------ | +| unknown change | `archive_change_not_found` | +| invalid change name | `archive_change_name_invalid` | +| namespace folder | `archive_change_is_namespace_folder` | +| revalidation failed | `archive_validation_failed` | +| incomplete tasks | `archive_tasks_incomplete` | +| archive slot taken | `archive_target_exists` | +| scenario preservation | `archive_spec_update_failed` | +| unreadable archive directory | the binary's code on that runtime | +| a delegated failure cospec relays | `archive_error` | + +For scenario preservation the `message` SHALL be the binary's sentence for the +first dropped requirement its merge would abort on. For a delegated failure it +SHALL be the wrapped archive's own last reason line, respelled. +`archive/verification-incomplete` has no upstream counterpart and SHALL carry +the code `archive_verification_incomplete`. `fix` SHALL be cospec's spelling of +the remedy cospec accepts (`--force-incomplete` for the tasks gate, where the +binary names `--yes`). The revalidation refusal SHALL keep the keys of the +report it carried before. A root-selection failure SHALL answer through the +shared resolver document with archive's payload `{archive: null}`. + +#### Scenario: A gate refusal prints a document + +- **WHEN** `cospec archive c1 --json` runs on a change with an incomplete task +- **THEN** stdout is one document with `archive: null`, `root`, and + `status[0].code` `archive_tasks_incomplete` with the binary's message, and the + exit code is 1 + +#### Scenario: An unreadable archive directory is one document + +- **WHEN** `openspec/changes/archive/` is mode 000 and + `cospec archive c1 --json` runs +- **THEN** stdout is one document whose `status[0].code` equals the binary's on + the same runtime (`archive_path_outside_root` under Bun on macOS, + `archive_error` naming the archive path under Bun on Linux), compared by code + and path, and the exit code is 1 diff --git a/openspec/changes/archive/2026-10-05-archive-and-sync-parity/specs/nested-change-detection/spec.md b/openspec/changes/archive/2026-10-05-archive-and-sync-parity/specs/nested-change-detection/spec.md new file mode 100644 index 00000000..652c51f3 --- /dev/null +++ b/openspec/changes/archive/2026-10-05-archive-and-sync-parity/specs/nested-change-detection/spec.md @@ -0,0 +1,71 @@ +# Spec Delta + +## MODIFIED Requirements + +### Requirement: Status, list and validate report a namespace folder as one + +A namespace folder SHALL be reported with the binary's explanation, verbatim: +`"" is not a change: it is a folder wrapping openspec/changes//, … . … Rename each nested change to a flat name (for example "").` + +- `cospec status --change ` SHALL refuse it, on stderr in text mode and + as a `{status: [{severity: "error", code: "change_error", message}]}` document + under `--json`, and exit 1. +- `cospec status --all` SHALL report the folder as a failure entry carrying the + explanation, keep every other change's entry, and exit 1. +- `cospec list` SHALL keep the folder's row, show `not a change` in place of its + task count, set the row's `state` to `not-a-change` and `nested` to the nested + ids, print `Warning: ` after the table, and add a `warnings` + entry `{code: "nested_change_directory", name, nested, message}` under + `--json`. +- `cospec validate` SHALL report the folder, singly or in a bulk scope, as + exactly one `meta/nested-change` ERROR carrying the explanation, run no other + rule on it and delegate nothing for it. + +`cospec archive` and `cospec sync-specs` are covered by "Archive refuses a +namespace folder as the binary does". + +#### Scenario: Status refuses a namespace folder + +- **WHEN** `cospec status --change mobile --json` runs on a namespace folder +- **THEN** stdout is one document whose `status[0]` has code `change_error` and + the binary's explanation, and the command exits 1 + +#### Scenario: The sweep carries the folder as a failure + +- **WHEN** `cospec status --all --json` runs in a root with a namespace folder + and two changes +- **THEN** both changes have full entries, the folder's entry carries the + explanation, and the command exits 1 + +#### Scenario: List marks the folder + +- **WHEN** `cospec list` runs in that root +- **THEN** the folder's row reads `not a change` and the binary's warning + follows the table + +#### Scenario: Validate reports only the nesting + +- **WHEN** `cospec validate mobile --json` runs +- **THEN** the item carries exactly one issue, `meta/nested-change` at ERROR, + and no `meta/openspec-yaml` issue + +## ADDED Requirements + +### Requirement: Archive refuses a namespace folder as the binary does + +`cospec archive ` SHALL refuse a namespace folder before revalidation or +any gate runs, using the shared detector, with the binary's message +`Cannot archive '': ` and its fix +`Rename openspec/changes// to a flat change directory, then archive it.`, +on stderr in text mode and as one failure document with code +`archive_change_is_namespace_folder` under `--json`, and exit 1. +`cospec sync-specs ` SHALL refuse it the same way, with `sync` in place +of `archive` in both sentences. Nothing SHALL be moved, written or delegated. + +#### Scenario: Archive refuses the folder with the binary's answer + +- **WHEN** `cospec archive mobile --json` and + `openspec archive mobile -y --json` run on a folder wrapping `mobile/refresh/` +- **THEN** both print one document whose `status[0]` has code + `archive_change_is_namespace_folder` with equal `message` and `fix`, both exit + 1, and `openspec/changes/mobile/` is unchanged diff --git a/openspec/changes/archive/2026-10-05-archive-and-sync-parity/specs/spec-sync/spec.md b/openspec/changes/archive/2026-10-05-archive-and-sync-parity/specs/spec-sync/spec.md new file mode 100644 index 00000000..e5339ed3 --- /dev/null +++ b/openspec/changes/archive/2026-10-05-archive-and-sync-parity/specs/spec-sync/spec.md @@ -0,0 +1,183 @@ +# Spec Delta + +## Purpose + +Defines `cospec sync-specs`, which merges an active change's delta specs into +the main specs under `openspec/specs/` without archiving the change. The merge +is the wrapped binary's own archive merge, run on a scratch copy and copied back +file by file, so the main specs come out byte-for-byte as `cospec archive` would +write them, the real tree is never touched by the binary, and a later archive of +the same change is the binary's early-sync no-op. + +## ADDED Requirements + +### Requirement: Sync-specs merges a change's delta specs without archiving it + +`cospec sync-specs ` SHALL run these steps in order, and SHALL stop at +the first one that refuses, writing nothing to the real tree: + +1. Resolve the root and the change as `cospec archive` does. A namespace folder + SHALL be refused with the binary's explanation, as archive refuses it. +2. Run archive's pre-merge checks on the change: the archive-precondition + validation `cospec archive` runs, then the shared scenario-preservation + helper. A refusal SHALL print the same report or gate message archive prints + and exit 1. +3. Run the pinned binary's `archive -y` on a scratch copy of the root + (see "The scratch run never touches the real tree"). +4. Copy back into the real `openspec/specs/` only the files that run created, + changed or deleted, and prune a directory the binary's run left empty there. +5. Verify on disk that every copied file's bytes equal the scratch file's, that + every deleted file is absent, and that no other file under `openspec/specs/` + changed. + +The change SHALL stay active: its directory, its artifacts, `tasks.md` and +`verification.md` SHALL be byte-identical after the command, and nothing SHALL +be written under `openspec/changes/archive/`. The tasks gate and +`archive/verification-incomplete` SHALL NOT run, because nothing is archived. + +#### Scenario: A MODIFIED delta is synced and the change stays active + +- **WHEN** `cospec sync-specs add-widget` runs on a change whose delta MODIFIES + `Widget rendering`, keeping every living scenario +- **THEN** `openspec/specs/widgets/spec.md` carries the modified requirement, + `openspec/changes/add-widget/` is unchanged, and the command exits 0 + +#### Scenario: A scenario-dropping MODIFIED is refused before any write + +- **WHEN** the delta's MODIFIED block omits a scenario the living requirement + has +- **THEN** the command prints the `archive/scenario-preservation` refusal, exits + 1, and no file under `openspec/` changes + +### Requirement: The synced main specs are byte-identical to archive's + +For every delta shape (ADDED on an existing or a new capability, MODIFIED, +REMOVED, RENAMED, and a REMOVED that retires a capability under +`retire_capabilities: true`) the files `cospec sync-specs` leaves under +`openspec/specs/` SHALL be byte-identical to the ones +`openspec archive -y` writes on a copy of the same tree, including a +retired capability's deleted `spec.md` and its pruned directory. Because they +are, a following `cospec archive ` SHALL see every operation as the +early-sync no-op the binary performs (an identical ADDED, an absent REMOVED, an +applied RENAMED, an identical MODIFIED, a retired capability) and SHALL archive +the change with both hard gates run and no further change to `openspec/specs/`. + +#### Scenario: Each delta shape matches the binary's archive + +- **WHEN** `cospec sync-specs` runs on each of the ADDED, MODIFIED, REMOVED, + RENAMED and retired-capability fixtures, and `openspec archive -y` runs on a + copy of each +- **THEN** the two `openspec/specs/` trees are byte-identical on every fixture + +#### Scenario: A linked living spec is synced as the binary archives it + +- **WHEN** a capability's living `spec.md` is a relative symbolic link to a file + elsewhere under `openspec/specs/`, and `cospec sync-specs` runs on a change + that MODIFIES it and on one that retires it under `retire_capabilities: true` +- **THEN** each `openspec/specs/` tree — files, directories and links — is the + one `openspec archive -y` leaves on a copy: the MODIFIED written through the + link, the retired capability's link deleted and its directory pruned + +#### Scenario: Archiving a synced change is a no-op merge + +- **WHEN** `cospec archive` runs on each fixture after `cospec sync-specs`, with + every task done and every verification row resolved +- **THEN** the change archives, exit 0, the `Specs:` line says the specs were + already in sync, and `openspec/specs/` is unchanged by the archive + +#### Scenario: The hard gates still run after a sync + +- **WHEN** the same synced fixture carries a bare `[ ]` verification row +- **THEN** `cospec archive` refuses it with `archive/verification-incomplete` + and exits 1 + +### Requirement: The scratch run never touches the real tree + +The binary's run SHALL happen in a fresh directory created under the OS temp +directory, holding `openspec/config.yaml` or `openspec/config.yml` if present, +`openspec/schemas/`, `openspec/specs/`, `openspec/changes//` and an +empty `openspec/changes/archive/`, with the binary spawned there with that +directory as its working directory. Every other file and directory under the +real `openspec/`, including sibling changes and the real +`openspec/changes/archive/`, SHALL NOT be copied. The binary SHALL be spawned +with no `--store`, so its nearest-root walk resolves the scratch directory. The +command SHALL confirm that resolution from the run's own observable output +before copying anything back. A symbolic link in the copied subtree that leads +outside it SHALL be refused before the run, naming the link, because the binary +would write through it into the real tree. A symbolic link inside it, absolute +or relative, SHALL be copied pointing at the scratch copy of its target, so the +run never writes through a link into the real tree. A file or directory under +the copied subtree that the command cannot read SHALL NOT fail the command: the +binary's archive reads no main spec but a delta's target, so an unrelated +unreadable spec SHALL leave the sync answering as `cospec archive` answers. The +scratch directory SHALL be removed when the command ends, whether the run +succeeded or failed. A failed run SHALL leave the real tree byte-identical, with +no `.openspec-archive.lock` and no other new file anywhere under it, and SHALL +relay the binary's reason with its remedies spelled `cospec`. + +#### Scenario: A refused scratch run leaves nothing behind + +- **WHEN** the binary refuses the scratch run after taking its archive claim + (two capability directories resolving to one spec through a symlink inside + `openspec/specs/`) +- **THEN** `cospec sync-specs` exits 1 with the binary's reason, no + `.openspec-archive.lock` exists anywhere under the real root, every file under + the real `openspec/` is byte-identical to before, and the scratch directory is + gone + +#### Scenario: An absolute alias inside the specs never writes the real tree + +- **WHEN** `openspec/specs/alias` is an absolute symbolic link to the root's own + `openspec/specs/widgets/`, and `cospec sync-specs` runs on a change whose + deltas the binary refuses after its claim (`alias` and `widgets` resolving to + one spec), and on one whose only delta MODIFIES `alias` +- **THEN** the refused run exits 1 with the binary's reason and leaves every + file under the real `openspec/` byte-identical, and the other exits 0 with + `openspec/specs/` byte-identical to `openspec archive -y`'s on a copy + +#### Scenario: Sibling changes and the archive are never copied + +- **WHEN** `cospec sync-specs` runs in a root with two other active changes and + an archived change of today's date and the same name +- **THEN** the scratch run succeeds and only `openspec/specs/` files change in + the real tree + +### Requirement: Sync-specs reports what it changed + +In text mode, `cospec sync-specs` SHALL print one line per main-spec file it +wrote or deleted, then a summary carrying the binary's own applied totals. When +the binary reported the specs already in sync, it SHALL say so and write +nothing. When the change's schema has no specs artifact, it SHALL print that +there is nothing to sync and why, run nothing, and exit 0. When the change has +no delta specs or declares `skip_specs: true`, it SHALL first run archive's +revalidation and refuse what it refuses, so a delta kept in a file the merge +never reads (a `spec.md` at the root of the change's `specs/`, a +`specs/.md`, a note beside a capability's `spec.md`) is refused as +archive refuses it; otherwise it SHALL print that there is nothing to sync and +why, run no merge, and exit 0. The binary's merge warnings SHALL be relayed, +respelled. Under `--json` it SHALL print one document: +`{change, type, synced, totals, files: {written, deleted}, warnings, root}` on +success, and on any refusal +`{change, synced: false, status: [{severity, code, message, fix?}]}` with the +binary's diagnostic code for a scratch-run refusal, the code archive's document +uses for a pre-merge refusal, and exit 1. `--store` SHALL be accepted, and the +synced specs are then the selected store's. + +#### Scenario: An already-synced change writes nothing + +- **WHEN** `cospec sync-specs` runs a second time on the same change +- **THEN** it reports the specs already in sync, writes no file, and exits 0 + +#### Scenario: A change with no delta specs has nothing to sync + +- **WHEN** `cospec sync-specs` runs on a `chore` change +- **THEN** it prints that the `chore` schema has no specs artifact, spawns + nothing, and exits 0 + +#### Scenario: A delta in a file the merge never reads is refused + +- **WHEN** `cospec sync-specs` runs on a `feat` change whose only + `## ADDED Requirements` sits in `specs/spec.md`, `specs/widgets.md` or + `specs/widgets/notes.md` +- **THEN** it prints the revalidation report `cospec archive` prints for the + same change, exits 1, and writes nothing diff --git a/openspec/changes/archive/2026-10-05-archive-and-sync-parity/tasks.md b/openspec/changes/archive/2026-10-05-archive-and-sync-parity/tasks.md new file mode 100644 index 00000000..1a54d2ea --- /dev/null +++ b/openspec/changes/archive/2026-10-05-archive-and-sync-parity/tasks.md @@ -0,0 +1,139 @@ +# Tasks + +Tracks follow design D1, in design D2's order. Contract rows are written first +(T5) as `test.failing`, and each implementing task flips exactly its own rows in +the commit that makes them pass. Every task ends with `mise run check` green and +one conventional commit (subject at most 72 characters, the harness's +Co-Authored-By trailer, never `--no-verify`), with its box ticked in that same +commit. Every probe of the pinned binary runs under the suite's oracle or a +sandboxed HOME/XDG. + +## 1. T5 — fixtures and failing contract rows first (`apps/cli/test/contract/{archive-no-validate,sync-specs}.test.ts`) + +- [x] 1.1 Add the fixture builders to `test/contract/fixtures.ts` (append only): + feat v2 changes with resolved verification and done tasks for ADDED (new + capability), MODIFIED, REMOVED, RENAMED and retired-capability deltas; the + three verbatim-view differential fixtures (comment-kept scenario, + commented living scenario, commented living requirement header); the + bare-`[ ]` verification, incomplete-task, scenario-drop and + revalidation-only variants (a proposal with no `## Why`, which only + cospec's typed rules refuse); the namespace folder; ADDED + REMOVED, + REMOVED-only and REMOVED-only-with-marker on a new capability; the + symlinked-alias tree; a short-Purpose new capability; a no-delta feat and + a `chore` change; and the mode-000 archive case. Verify by a smoke row per + builder that `openspec validate --strict` reads it. Commit + `test(cli): add archive and sync-specs fixtures` +- [x] 1.2 Write every contract row of verification groups 1–3, 4.1, 5.1, + 6.1–6.4, 7.1, 7.2, 8 and 2.3 in `archive-no-validate.test.ts`, each + reading the binary's answer at test time through the upstream oracle, and + the archive key-oracle rows through `support/key-oracle.ts`, adding the + tasks-gate `fix` to `NAMED_COLLISIONS` with why cospec's value wins. Mark + each row that fails on this tree `test.failing` and record the failing + count in the commit body. Verify with `mise run test:contract` green. + Commit `test(cli): pin archive parity differentials as failing rows` +- [x] 1.3 Write every contract row of verification groups 9–12 and 5.2 in + `sync-specs.test.ts`, each comparing against `openspec archive -y` on a + copy (file list and sha256 per file), and all `test.failing` (the command + doesn't exist yet). Verify with `mise run test:contract` green. Commit + `test(cli): pin sync-specs differentials as failing rows` + +## 2. T2 — `archive/new-spec-non-added` (`apps/cli/src/core/rules/archive.ts`) + +- [x] 2.1 Skip REMOVED in the `living === undefined` arm (design D9), with the + unit table 6.5. Make `archive/rebuilt-spec-invalid` refuse the + REMOVED-only no-marker case on the skeleton spec once the precondition no + longer suppresses it, extending the rebuilt check to the skeleton if it + does not fire there (design D9). Flip rows 6.1, 6.2 and 6.3, and keep 6.4 + green. Verify with those rows and the unit table. Commit + `fix(validate): let REMOVED on a new capability pass as OpenSpec does` + +## 3. T1 — `cospec archive` (`apps/cli/src/commands/archive.ts`, `apps/cli/src/core/scenario-gate.ts`, `apps/cli/src/core/archive-output.ts`) + +- [x] 3.1 Move `changeDeltaOps` and the scenario-preservation step into + `core/scenario-gate.ts` (design D4), unchanged in behavior, with the + type-level test 3.4. Verify with rows 3.1–3.3 still green and + `grep -n findScenarioDrops apps/cli/src/commands` empty. Commit + `refactor(cli): share the scenario-preservation gate` +- [x] 3.2 Add `core/archive-output.ts`'s failure-document builder (design D6), + `jsonFailurePayload = { archive: null }`, and one document on every + refusal path, with the unit enumeration 2.5. Flip rows 2.3–2.6. Commit + `feat(cli): answer every archive refusal with one JSON document` +- [x] 3.3 Refuse a namespace folder before revalidation (design D8). Flip row + 5.1. Commit + `feat(cli): refuse a namespace folder as OpenSpec archive does` +- [x] 3.4 Add the path-confinement check and the guarded slot `lstat`, and route + archive-directory reads through the degraded index (design D7). Flip row + 4.1. Run row 4.2 in the Linux container and record its observed output in + the ledger. Commit + `fix(cli): answer an unreadable archive directory as OpenSpec does` +- [x] 3.5 Add the Totals/in-sync line reader with unit table 7.3, the success + document's `archive` and `root` (design D5), the `Specs:` skip reasons, + `specsSkipReason` and the `already in sync` line (design D10). Flip rows + 2.1, 2.2, 7.1 and 7.2. Commit + `feat(cli): add OpenSpec's archive and root keys to archive's JSON` +- [x] 3.6 Wire `respellRemedies` into every relay (design D12). Flip rows 8.1 + (archive half) and 8.2 (archive outputs). Commit + `fix(cli): spell relayed archive remedies as cospec` +- [x] 3.7 Accept `--no-validate` (design D3): skip `validateChange`, forward the + flag, print the banner. Move the flag from pending to handled in + `core/command-table.ts` and delete its `parity-pending.yaml` entry in this + commit. Flip rows 1.1–1.6. Commit + `feat(cli): accept archive --no-validate and keep every hard gate` + +## 4. T3 — `cospec sync-specs` (`apps/cli/src/commands/sync-specs.ts`, `apps/cli/src/core/scratch-root.ts`) + +- [x] 4.1 Add `core/scratch-root.ts` (design D11: the scratch layout, the + symlink-escape check, the pre/post fingerprint, copy-back with deletions + and pruning, and cleanup in `finally`) with unit rows 11.2 and 11.5. + Verify with those unit rows. Commit + `feat(cli): copy a root's spec inputs to a scratch tree and back` +- [x] 4.2 Add `commands/sync-specs.ts` (design D11 steps, wrapped-call + discipline, text and `--json` output), its `core/command-table.ts` row + (`json` and `store` accepted, one required `change` positional) and its + `cli.ts` dispatch entry. Flip rows 9.1–9.4, 10.1, 10.2, 11.1, 11.3, 11.4, + 12.1–12.4, 5.2 and the sync-specs halves of 8.1 and 8.2. Verify with those + rows and the reachability test green. Commit + `feat(cli): add sync-specs, archive's merge without the archive` + +## 5. T4 — canon (`apps/cli/src/canon/workflows/{sync-specs,archive}.md`, `harness.yaml`) + +- [x] 5.1 Rewrite `sync-specs.md`, add the early-sync sentence to `archive.md`, + update the `sync-specs` description in `harness.yaml` (design D13), and + run `mise run generate`. Verify with row 13.1 and + `mise run generate:check` clean. Commit + `feat(canon): sync specs through cospec sync-specs` + +## 6. Docs + +- [x] 6.1 Update every page in design D14 to the shipped behavior, record the + grep in row 13.2, and run `mise run docs:build`. Verify with row 13.2. + Commit `docs(cli): document archive parity and sync-specs` + +## 7. Agent guidance + +- [x] 7.1 Add `cospec sync-specs` to `.agents/shared.md`'s workflow section + (step 6), and update its "JSON documents are additive" paragraph for + archive and the new named collision (design D14), then + `mise run agents:sync`. Verify with row 13.3. Commit + `docs(agents): name sync-specs in the cospec workflow` + +## 8. Close-out + +- [x] 8.1 Record observed evidence after `->` on every verification row, confirm + row 14.1 (no `test.failing`/`test.todo` left in the two new test files) + and row 14.2 (`mise run check` green), and run + `mise run cospec -- validate archive-and-sync-parity --strict` clean. + Commit `docs(cli): record archive-and-sync-parity evidence` +- [x] 8.2 Ask the user to run verification row 13.4 (`mise run eval:e2e` needs + the human-held DeepSeek key) on the branch head and on `main`, and record + the scores they report on the row. Verify by the row carrying both runs' + scores. Commit `docs(cli): record the archive-and-sync-parity eval run` -> + resolved as a deferred row (see verification 13.4) +- [x] 8.3 Rebase onto `main` (`--force-with-lease`, no merge commit), rerun + `bun install --frozen-lockfile` and `mise run check`, and confirm + `git log main..HEAD` shows only this change's commits. The archive commit + follows this one -> 2026-10-05: already rebased onto `80ee3855` (fetch + showed no new commits); `bun install --frozen-lockfile` reported no + changes; `mise run check` exit 0 (unit 2025, integration 193, contract + 2678, bench 343, release-test 14, 0 fail); `git log main..HEAD` lists only + this change's 23 commits diff --git a/openspec/changes/archive/2026-10-05-archive-and-sync-parity/verification.md b/openspec/changes/archive/2026-10-05-archive-and-sync-parity/verification.md new file mode 100644 index 00000000..0cf5c262 --- /dev/null +++ b/openspec/changes/archive/2026-10-05-archive-and-sync-parity/verification.md @@ -0,0 +1,97 @@ +# Verification + +## 1. `archive --no-validate` skips only revalidation [critical] + +- [x] 1.1 @integration (agent) `grep -c 'owner: archive-and-sync-parity' apps/cli/test/contract/parity-pending.yaml` before and after, and the reachability test inside `mise run test:contract` -> 2026-10-05: 1 before (a7b5377a, `archive --no-validate`), 0 after (5013cbe0); the flag is handled in the command table, reachability.test.ts green and its owner-disagreement negative case now uses `init --language`; row 1.1 in archive-no-validate.test.ts passes (shas as rebased onto main 80ee3855 at close-out) +- [x] 1.2 @equivalence (agent) archive-no-validate.test.ts: `cospec archive c1 --no-validate` on a feat v2 change with a bare `[ ]` verification row -> archive-no-validate.test.ts row 1.2 passes: exit 1, `verification.md is not fully resolved` on stderr, change unmoved, the `--no-validate skips revalidation` banner naming both hard gates on stderr +- [x] 1.3 @equivalence (agent) archive-no-validate.test.ts: `cospec archive c1 --no-validate` on a change whose MODIFIED block drops a living scenario -> archive-no-validate.test.ts row 1.3 passes: exit 1, `scenario-preservation gate refused`, banner on stderr, change unmoved, main specs byte-identical; the binary's `archive c1 -y --no-validate` on a copy also exits 1 and moves nothing (its merge refuses the scenario loss even under the flag) +- [x] 1.4 @equivalence (agent) archive-no-validate.test.ts: `cospec archive c1 --no-validate` on a change with an incomplete task -> archive-no-validate.test.ts row 1.4 passes: exit 1, `incomplete task(s) — refusing to archive`, banner on stderr, change unmoved +- [x] 1.5 @equivalence (agent) archive-no-validate.test.ts: a change only revalidation refuses (a delta the binary archives under `--no-validate`), `cospec archive c1 --no-validate` beside `openspec archive c1 -y --no-validate` on a copy -> archive-no-validate.test.ts row 1.5 passes: both exit 0 and archive, the two `openspec/specs/` trees hash-identical, banner on stderr +- [x] 1.6 @e2e (agent) `cospec archive c1 --no-validate --json` through the real CLI -> archive-no-validate.test.ts row 1.6 passes: stdout is one JSON document (`archived: true`) with no banner text in it; the banner is on stderr +- [x] 1.7 @equivalence (agent) archive-no-validate.test.ts: an unrelated living spec at mode 000 beside the MODIFIED fixture, `cospec archive c1 --no-validate --json` beside `openspec archive c1 -y --no-validate --json` on a copy, and `cospec archive c1 --json` -> archive-no-validate.test.ts rows 1.7 (x2) pass on macOS: both archives exit 0 with one `archived: true` document and hash-identical main specs (failing before the fix: cospec exited 1 with empty stdout on `EACCES … open '…/specs/other/spec.md'`); without the flag stdout is one document whose `archived` matches the exit code; archive now fingerprints only the delta targets' `spec.md` + +## 2. Archive's JSON documents carry the binary's keys [critical] + +- [x] 2.1 @equivalence (agent) key oracle row: `cospec archive c1 --json` on a MODIFIED fixture beside `openspec archive c1 -y --json` on a copy -> archive-no-validate.test.ts row 2.1 passes: compareDocuments(binary, cospec) has no failures with `archive.path`/`root.path` in the oracle's new path class (relative to each tool's own canonical root), and checkNativeKeys over change/type/archived/target/specs/retired/warnings/blockers reports nothing changed +- [x] 2.2 @equivalence (agent) key oracle row: `--skip-specs` and a change already synced -> archive-no-validate.test.ts rows 2.2 (x4) pass: --skip-specs has no `totals` and `specsUpdated: false` on both; the already-synced MODIFIED has zero totals and `specsUpdated: false` as the binary reports; ADDED+REMOVED on a new capability carries the binary's `nothing to remove` warning; a retirement carries the binary's retirement note (rebuilt from its `Retiring` and recovery lines, design D5 note) +- [x] 2.3 @equivalence (agent) key oracle failure rows, cospec `archive --json` beside `openspec archive --json`: unknown change, invalid change name, namespace folder, revalidation failure, incomplete tasks (binary without `-y`), taken archive slot, scenario-dropping MODIFIED (binary with `-y`) -> archive-no-validate.test.ts rows 2.3 (x7) pass: unknown change, invalid name (`a/b`), namespace folder, revalidation failure, incomplete tasks (binary without -y), taken slot and scenario drop (binary with -y) each one document with `archive: null`, root by path class, `status[]` paired by code, message equal, fix equal or respelled; the tasks-gate fix is the oracle's `status[].fix` named collision; exit 1 on both +- [x] 2.4 @integration (agent) `cospec archive c1 --json` on a bare `[ ]` verification row -> archive-no-validate.test.ts row 2.4 passes: one document, `reason` `archive/verification-incomplete`, `status[0].code` `archive_verification_incomplete`, exit 1 +- [x] 2.5 @regression (agent) every refusal path in `commands/archive.ts` under `--json` (enumerated in a unit table that fails when a new early return lacks a document) -> test/unit/commands/archive-refusals.test.ts: 14 pass (one case per refusal reason archive.ts names — 12 — plus the source check that the only `return EXIT.failure` is `refuse`'s and the case-coverage check); the validation case keeps version/items/summary +- [x] 2.6 @integration (agent) `cospec archive c1 --json --store nope` -> archive-no-validate.test.ts row 2.6 passes: `{archive: null, status}` on both, code/target equal and fix respelled; the message is compared by presence and type only, because cospec's resolver wording is its own across every command (see the implement report) + +## 3. The scenario-preservation gate reads the verbatim view [critical] + +- [x] 3.1 @equivalence (agent) RUN the comment-kept fixture (a MODIFIED block keeping one living scenario only inside an HTML comment) through `cospec validate --strict`, `cospec archive c1` and `openspec archive c1 -y` on copies -> archive-no-validate.test.ts row 3.1 passes (passed on main too, design fact 1): `cospec validate --strict` exit 0, both archives exit 0, main specs hash-identical +- [x] 3.2 @equivalence (agent) RUN the commented-living-scenario fixture (living requirement has a third scenario inside a comment, MODIFIED repeats the two visible ones) through `cospec archive c1` and `openspec archive c1 -y` -> archive-no-validate.test.ts row 3.2 passes: both exit 1, both changes unmoved, living specs unchanged; cospec stderr names `scenario-preservation gate refused` and `"Render a hidden widget"` +- [x] 3.3 @equivalence (agent) RUN the commented-requirement-header fixture (living spec ends with a requirement header and scenario inside a comment, MODIFIED keeps every scenario of a visible requirement) through `cospec archive c1` and `openspec archive c1 -y` -> archive-no-validate.test.ts row 3.3 passes: both exit 0 with hash-identical main specs +- [x] 3.4 @unit (agent) `core/scenario-gate.ts`: the helper's inputs are the verbatim-branded types (a type-level test refuses an `AdvisoryDelta`/`LivingSpec.advisory` argument), and `archive.ts` and `sync-specs.ts` both import it -> test/unit/core/scenario-gate.test.ts: 2 pass — `@ts-expect-error` on an AdvisoryDeltaOp argument compiles under `mise run typecheck` (so the gate refuses the masked view at the type level), no file under src/commands names findScenarioDrops, and the gate's importers are exactly archive.ts and sync-specs.ts + +## 4. An unreadable archive directory is one answer [critical] + +- [x] 4.1 @equivalence (agent) `openspec/changes/archive/` at mode 000, `cospec archive c1 --json` and text beside `openspec archive c1 -y --json` on a copy, under Bun on macOS -> archive-no-validate.test.ts row 4.1 passes on macOS: the binary answers `archive_path_outside_root` and so does cospec, message equal with each root written ``; text mode one stderr line, no stack; exit 1; nothing moved; no `.openspec-archive.lock` on either side +- [x] 4.2 @runtime (agent) the same row in the Linux container (`oven/bun:1.3.14`, uid 1000, worktree mounted read-only at `/w`, fixture copied inside the container) and on CI's `ubuntu-latest` job -> container (oven/bun:1.3.14, uid 1000, worktree read-only at /w, fixtures under the container's /tmp), 2026-10-05: cospec `archive c1 --json` and the binary's `archive c1 -y --json` both exit 1 with `status[0].code` `archive_error`, message `EACCES: permission denied, statx '/openspec/changes/archive/2026-10-05-c1'` (same errno, syscall and slot path); text mode prints the degraded-read warning naming the archive directory, then that one refusal line; nothing moved and no `.openspec-archive.lock` on either side; row 4.1 itself also passes there. CI's ubuntu-latest contract job runs the same row 4.1 on every push + +## 5. Archive refuses a namespace folder as the binary does + +- [x] 5.1 @equivalence (agent) `cospec archive mobile` and `--json` beside `openspec archive mobile -y --json` on a folder wrapping `mobile/refresh/` -> archive-no-validate.test.ts row 5.1 passes: text stderr starts `cospec archive: Cannot archive 'mobile': ` and carries the binary's message and fix; the JSON `status[0]` equals the binary's; exit 1; `mobile/refresh/` untouched; no `meta/nested-change` printed +- [x] 5.2 @integration (agent) `cospec sync-specs mobile` and `--json` -> sync-specs.test.ts row 5.2 passes with TMPDIR unwritable (no scratch directory could be made): `Cannot sync 'mobile': …` and `… then sync it.` on stderr, `archive_change_is_namespace_folder` under --json, exit 1, `openspec/` hash-identical + +## 6. A new capability refuses only MODIFIED and RENAMED [critical] + +- [x] 6.1 @equivalence (agent) ADDED + REMOVED on a capability with no living spec: `cospec validate --strict`, `cospec archive`, `openspec validate --strict` and `openspec archive -y` -> archive-no-validate.test.ts row 6.1 passes: no ERROR from `cospec validate --strict --json`, the binary's validate exits 0, both archives exit 0 with hash-identical main specs, cospec relays `Warning: widgets - 1 REMOVED requirement(s) ignored for new spec (nothing to remove).` +- [x] 6.2 @equivalence (agent) REMOVED-only under `retire_capabilities: true` on a capability with no living spec -> archive-no-validate.test.ts row 6.2 passes: `cospec validate --strict` clean, both archives exit 0, the binary prints `Specs already in sync; no files changed.` and cospec's Specs line reads `already in sync` +- [x] 6.3 @regression (agent) REMOVED-only WITHOUT the marker on a capability with no living spec -> archive-no-validate.test.ts row 6.3 passes: validate reports `archive/rebuilt-spec-invalid` and no `archive/new-spec-non-added`; `cospec archive` exits 1 on that report before delegating (change unmoved); the binary's `archive -y` on a copy exits 1 and moves nothing +- [x] 6.4 @regression (agent) MODIFIED and RENAMED on a capability with no living spec -> archive-no-validate.test.ts rows 6.4 (MODIFIED, RENAMED) pass: `archive/new-spec-non-added` is still an ERROR on each +- [x] 6.5 @unit (agent) `core/rules/archive.ts` table for the `living === undefined` arm: ADDED, REMOVED, MODIFIED, RENAMED -> test/unit/rules/archive.test.ts table 6.5: ADDED and REMOVED do not produce the rule, MODIFIED and RENAMED do; plus REMOVED-only refused by `archive/rebuilt-spec-invalid` and the marked variant reporting nothing; rules units 351 pass + +## 7. The Specs line and early-synced archives + +- [x] 7.1 @e2e (agent) `cospec archive` on a feat change with no delta files, with `--skip-specs`, and on a `chore` change, text and `--json` -> archive-no-validate.test.ts rows 7.1 (x3) pass: `Specs: none (no delta specs, so no spec sync)` / `skipped (--skip-specs)` / `none (the chore schema has no specs artifact)`; `specsSkipReason` `no-deltas` / `flag` / `schema`; `specs` stays `"skipped"` +- [x] 7.2 @equivalence (agent) each early-sync shape (identical ADDED, absent REMOVED, applied RENAMED, identical MODIFIED, a capability already retired) hand-prepared on the living specs, `cospec archive` beside `openspec archive -y` on a copy -> archive-no-validate.test.ts rows 7.2 (x5: identical ADDED, absent REMOVED, applied RENAMED, identical MODIFIED, already-retired capability) pass: both exit 0, the binary prints `Specs already in sync; no files changed.`, cospec prints `Specs: already in sync`, no invariant breach, main specs hash-identical +- [x] 7.3 @unit (agent) the `core/archive-output.ts` line reader on the pinned binary's captured stdout for an applied merge, an in-sync merge and `--skip-specs` -> test/unit/core/archive-output.test.ts: 8 pass — applied (+1, specsUpdated true, the merge warning), in-sync (zero totals, false), --skip-specs (neither), no Totals line (no totals), the retirement note, a look-alike line not read, and relayedReason's two shapes + +## 8. Relayed archive output is spelled cospec + +- [x] 8.1 @equivalence (agent) a new capability whose carried Purpose is under the minimum, through `cospec archive` -> rows 8.1 pass in both files: `cospec archive` and `cospec sync-specs` on the short-Purpose fixture relay `Warning: widgets - carried Purpose is under 50 characters; cospec validate --strict reports it as too brief.` +- [x] 8.2 @integration (agent) grep every archive and sync-specs output the contract rows capture (text, stderr and JSON `message`/`fix`) -> the 8.2 sweeps in both files pass: respellRemedies leaves every captured `cospec archive` and `cospec sync-specs` output unchanged, so none names a bare allowlisted command; `openspec-widgetz` in a not-found message passes through byte-for-byte + +## 9. `cospec sync-specs` writes archive's main specs and leaves the change active [critical] + +- [x] 9.1 @equivalence (agent) sync-specs.test.ts, for each of the ADDED (new capability), MODIFIED, REMOVED, RENAMED and retired-capability (`retire_capabilities: true`, last requirement REMOVED) fixtures: `cospec sync-specs c1` beside `openspec archive c1 -y` on a copy -> sync-specs.test.ts rows 9.1 (x5) pass: the file list and sha256 of every file under `openspec/specs/` equal the binary's archive on a copy, the directory list too (the retired capability's directory pruned on both), the change directory hash-identical, `changes/archive/` empty, the private TMPDIR empty afterwards +- [x] 9.2 @e2e (agent) on each fixture after 9.1, with every task done and every verification row resolved: `cospec archive c1` -> sync-specs.test.ts rows 9.2 (x5) pass: after the sync `cospec archive` exits 0, the change is archived, `Specs: already in sync`, `openspec/specs/` unchanged by the archive +- [x] 9.3 @e2e (agent) each 9.1 fixture synced, then given a bare `[ ]` verification row -> sync-specs.test.ts rows 9.3 (x5 + 1) pass: each synced fixture with a bare `[ ]` row is refused by `archive/verification-incomplete`, exit 1; the synced MODIFIED fixture with a scenario added to its living spec is refused by `archive/scenario-preservation` +- [x] 9.4 @e2e (agent) `cospec sync-specs c1` twice on the MODIFIED fixture -> sync-specs.test.ts row 9.4 passes: the second run prints `Specs: already in sync; no files changed`, no `Synced:` line, `openspec/` hash-identical, exit 0 +- [x] 9.5 @equivalence (agent) sync-specs.test.ts, a retirement whose living `spec.md` is a relative link to `specs/shared/widgets.md`, a MODIFIED through a `spec.md` linked to `widgets/living.md`, and a MODIFIED delta under an absolute in-tree alias `specs/alias -> /openspec/specs/widgets`: `cospec sync-specs c1 --json` beside `openspec archive c1 -y` on a copy -> sync-specs.test.ts rows 9.5 (x3) pass: both exit 0, `synced: true`, files, directories and links under `openspec/specs/` equal the binary's (the retired link deleted and `widgets/` pruned; the alias merged into `widgets/spec.md` and the link kept), TMPDIR empty; before the fix the retirement left `widgets/` and its link behind and the alias run wrote the real tree then refused `specs-changed`. scratch-root.test.ts adds 6 unit cases: the absolute link re-pointed into scratch, a symlinked `specs/` walked for escapes, a linked spec.md deleted and pruned, a link replaced by a file written over it, a link the run created thrown before any write, an unreadable unrelated spec copied as a mode-000 placeholder (13 pass; 5 of the 6 fail on the previous scratch-root.ts) + +## 10. `cospec sync-specs` refuses what archive refuses [critical] + +- [x] 10.1 @equivalence (agent) a scenario-dropping MODIFIED -> sync-specs.test.ts row 10.1 passes: `cospec sync-specs: scenario-preservation gate refused`, exit 1, `openspec/` hash-identical, TMPDIR empty; the binary's archive on a copy exits 1 +- [x] 10.2 @integration (agent) a change revalidation refuses (an ADDED colliding with a differing living block) -> sync-specs.test.ts row 10.2 passes with TMPDIR unwritable: the `cospec sync-specs` validation report naming `deltas/requirement-shape` on stdout, exit 1, `openspec/` hash-identical; and a `skip_specs: true` change beside a delta file is refused with `deltas/skip-specs-conflict` exactly as `cospec archive` refuses it (exit 1, TMPDIR unwritable, nothing written); and a delta kept in `specs/spec.md`, `specs/widgets.md` or `specs/widgets/notes.md` (a file the merge never reads) is refused with the same `deltas/*` rule `cospec archive` prints for it, never `Nothing to sync` (rows 10.2 x3, TMPDIR unwritable, nothing written, `--json` one `synced: false` document; the binary's `archive -y` exits 1 too) + +## 11. A failed scratch run leaves nothing in the real tree [critical] + +- [x] 11.1 @equivalence (agent) the symlinked-alias fixture (`openspec/specs/alias -> sync-specs.test.ts row 11.1 passes: `cospec validate --strict`exit 0; sync exits 1 with the binary's`resolve to the same target`on stderr; no`.openspec-archive.lock`anywhere under the root;`openspec/` hash-identical; TMPDIR empty +- [x] 11.2 @unit (agent) `core/scratch-root.ts` with an injected runner that writes `.openspec-archive.lock` and a partial spec into the scratch tree and exits 1 -> test/unit/core/scratch-root.test.ts 11.2 passes: a runner that writes the lock and a partial spec into the scratch tree and exits 1 leaves the real tree hash-identical, the scratch directory gone, and the ScratchRefusal names `the run broke` +- [x] 11.3 @integration (agent) a root with two sibling changes and today's archive slot already taken by a same-named archived change -> sync-specs.test.ts row 11.3 passes: with siblings c2, c3 and today's `-c1` archived, exit 0; `openspec/changes/` hash-identical; only `openspec/specs/` files changed +- [x] 11.4 @integration (agent) a symlink under `openspec/specs/` leading outside the copied subtree -> sync-specs.test.ts row 11.4 passes with TMPDIR unwritable: refused naming `openspec/specs/ext` and `leads outside`, exit 1, `openspec/` hash-identical +- [x] 11.5 @unit (agent) copy-back with the real `specs/` changed between the pre- and post-run fingerprints (injected) -> test/unit/core/scratch-root.test.ts 11.5 passes: the real spec edited while the runner works refuses with `the main specs changed while sync ran`, and the real file keeps the concurrent edit, nothing written over it +- [x] 11.6 @integration (agent) the absolute-alias conflict (`specs/alias` an absolute link to the root's own `widgets/`, deltas for both) and an unrelated mode-000 living spec beside the MODIFIED fixture -> sync-specs.test.ts rows 11.4 (absolute) and 11.6 pass: the absolute alias, text and --json, exits 1 with the binary's `resolve to the same target`, no lock, `openspec/` hash-identical, TMPDIR empty (before the fix: `the main specs changed while sync ran; nothing was written` after the binary had written the real `widgets/spec.md`); with the unreadable spec, sync-specs answers as `cospec archive` does on a twin, one `--json` document, exit codes equal — on macOS both refuse at revalidation (the binary's `validate` fails `realpath` on a mode-000 file), where the revalidation passes the sync must equal the binary's archive (scratch-root.test.ts pins the placeholder copy on every runtime) + +## 12. `cospec sync-specs` output + +- [x] 12.1 @e2e (agent) `cospec sync-specs c1` text on the MODIFIED and new-capability fixtures -> sync-specs.test.ts rows 12.1 (x2) pass: MODIFIED text is exactly `Synced: openspec/specs/widgets/spec.md (written)` + `Totals: + 0, ~ 1, - 0, → 0`; --json is `{change, type, synced: true, totals, files: {written, deleted}, warnings: [], root}`; the new capability's text names the written file and `+ 1` +- [x] 12.2 @e2e (agent) `cospec sync-specs` on a `chore` change, a feat change with no delta files, and a change with `skip_specs: true` -> sync-specs.test.ts rows 12.2 (x3) pass with TMPDIR unwritable: `Nothing to sync: the chore schema has no specs artifact.` / `… c1 has no delta specs.` / `… c1 declares skip_specs: true.`, exit 0, nothing written; --json `synced: false` with `skipReason` schema / no-deltas / skip-specs +- [x] 12.3 @integration (agent) `cospec sync-specs c1 --store ` on a registered store fixture -> sync-specs.test.ts row 12.3 passes: with store `alpha` set up through the binary, `--store alpha` from a separate repo changes the store's `openspec/specs/` and leaves the repo's `openspec/` hash-identical +- [x] 12.4 @integration (agent) `cospec sync-specs nope --json` and a refused scratch run under `--json` -> sync-specs.test.ts rows 12.4 (x2) pass: `sync-specs nope --json` is one `archive_change_not_found` document with the binary's message; the refused scratch run is one `archive_error` document carrying `resolve to the same target`; exit 1 + +## 13. Canon, docs and agent guidance + +- [x] 13.1 @integration (agent) `mise run generate` then `mise run generate:check` -> `mise run generate` then `mise run generate:check`: `cospec update --check: no drift`; test/unit/harness/sync-workflows.test.ts 11 pass — every harness's sync-specs body has `cospec validate ` before `cospec sync-specs `, no mid-flight/no-sync text, no bare openspec command, and every archive body names the early-sync no-op and both hard gates +- [x] 13.2 @integration (agent) `grep -rn 'no-validate\|sync-specs\|mid-flight\|new-spec-non-added' apps/docs docs` -> the grep hits 53 lines (excluding the built dist): commands.md (archive row, sync-specs row, JSON tip), apply-and-archive.md (--no-validate, early sync, new capability), harness-setup.md (the sync sentence), validation-rules.md (new-spec-non-added's trigger), docs/apply-archive.md (step order, scratch design), docs/harness-integration.md (sync-specs workflow and name mapping), plus unchanged rule-list mentions in how-it-relates-to-openspec.md, docs/validation.md and docs/architecture.md that remain true; none says mid-flight sync is unsupported; `mise run docs:build` green +- [x] 13.3 @integration (agent) `mise run agents:sync` after the `.agents/shared.md` edit, then `mise run agents:check` -> `mise run agents:sync` then `mise run agents:check`: `All shared blocks are in sync.`; `.agents/shared.md`, CLAUDE.md and AGENTS.md each name `mise run cospec -- sync-specs ` in workflow step 6 +- [~] 13.4 @eval (human) `mise run eval:e2e` with the human-held `DEEPSEEK_API_KEY` in the process environment, against the regenerated `archive` and `sync-specs` bodies -> defer: eval is advisory and never gates CI; its key is human-held and the row was not in the roadmap's acceptance evidence + +## 14. Close-out + +- [x] 14.1 @integration (agent) `grep -n 'test.failing\|test.todo' apps/cli/test/contract/archive-no-validate.test.ts apps/cli/test/contract/sync-specs.test.ts` -> the grep finds no match in either file (0 lines); every row written failing-first has flipped +- [x] 14.2 @integration (agent) `mise run check` on the final implementation commit -> re-observed at the merge stage after rebasing onto main 80ee3855 (tree 63806558, already an ancestor-free fast-forward of 80ee3855): `mise run check` exit 0 — lint, format:check, typecheck, generate:check, vendor:openspec:check, openspec:schema:validate, agents:check and cospec-validate-all green; unit 2025 pass, integration 193 pass, contract 2678 pass (1391.9 s), bench 343 pass, release-test 14 pass, 0 fail anywhere; `mise run test:pack` (not part of `check`) 2 pass; `mise run docs:build` green; `cospec validate archive-and-sync-parity --strict` 0 errors, 0 warnings diff --git a/openspec/specs/archive-integrity/spec.md b/openspec/specs/archive-integrity/spec.md index deb803f7..44cb9ff6 100644 --- a/openspec/specs/archive-integrity/spec.md +++ b/openspec/specs/archive-integrity/spec.md @@ -990,3 +990,140 @@ wrapped binary's validate reports. MODIFIED of a RENAMED source, or a RENAMED target the delta ADDs - **THEN** a native `archive/*` ERROR names the requirement, and no relayed finding appears + +### Requirement: --no-validate skips only revalidation + +`cospec archive --no-validate` SHALL skip only cospec's revalidation +step (the archive-precondition validation `validateChange` runs) and SHALL +forward `--no-validate` to the wrapped `archive -y` call, so the binary skips +its own validation as the flag asks. The namespace-folder refusal, the tasks +gate, `archive/verification-incomplete`, the archive-slot check, +`archive/scenario-preservation`, the on-disk move verification and the +post-merge spot-check SHALL still run. Before any of them, cospec SHALL print a +banner on stderr, in text and `--json` mode alike, saying that revalidation was +skipped and naming the gates that still run. cospec SHALL NOT prompt: its +archive never prompts, so the binary's `--json --no-validate` confirmation +refusal (`archive_confirmation_required`) has no cospec counterpart. + +#### Scenario: Both hard gates still refuse under --no-validate + +- **WHEN** `cospec archive c1 --no-validate` runs on a change with a bare `[ ]` + verification row, and again on a change whose MODIFIED block drops a living + scenario +- **THEN** each is refused by its hard gate with exit 1, nothing is moved, and + stderr carries the revalidation-skipped banner + +#### Scenario: A change validation would refuse archives + +- **WHEN** `cospec archive c1 --no-validate` runs on a change that only + revalidation refuses (a delta the binary also archives under `--no-validate`) +- **THEN** the change archives with exit 0, the banner is on stderr, and the + main specs equal what `openspec archive c1 -y --no-validate` writes on a copy + +### Requirement: One scenario-preservation helper reads the verbatim view + +The `archive/scenario-preservation` hard gate SHALL live in one shared helper +that `cospec archive` and `cospec sync-specs` both call, and that reads the +living spec and the delta the way the binary's archive reads them: code fences +masked and HTML comments kept. A scenario that sits inside a comment in the +living spec SHALL count as a living scenario, and a scenario the MODIFIED block +keeps only inside a comment SHALL count as kept, so the gate refuses and passes +exactly where `openspec archive` does. + +#### Scenario: A scenario kept only inside a comment archives on both sides + +- **WHEN** a MODIFIED block keeps one living scenario only inside an HTML + comment +- **THEN** `cospec validate --strict`, `cospec archive` and + `openspec archive -y` each accept it, and both archives write the same main + spec + +#### Scenario: A commented living scenario the delta omits is refused on both sides + +- **WHEN** the living requirement carries a third scenario inside an HTML + comment and the MODIFIED block repeats only the two visible ones +- **THEN** `cospec archive` refuses with `archive/scenario-preservation` naming + that scenario, and `openspec archive -y` refuses and changes nothing + +#### Scenario: A commented requirement header in the living spec blocks neither side + +- **WHEN** the living spec ends with a requirement header and its scenario + inside an HTML comment, and a MODIFIED block keeps every scenario of a visible + requirement +- **THEN** `cospec archive` and `openspec archive -y` both archive it + +### Requirement: Archive names why no spec sync happened + +When `cospec archive` passes `--skip-specs` to the wrapped archive, its `Specs:` +line SHALL name which reason applied: the user passed `--skip-specs`, the +change's schema has no specs artifact, or the change has no delta spec files. +Under `--json` the document SHALL carry `specsSkipReason` with the value `flag`, +`schema` or `no-deltas`, and the existing `specs: "skipped"` value SHALL stay as +it is. + +#### Scenario: A specs-bearing change with no delta files says so + +- **WHEN** `cospec archive c1` runs on a `feat` change with no files under + `specs/` +- **THEN** the `Specs:` line says there were no delta specs and so no spec sync, + and `--json` carries `specsSkipReason: "no-deltas"` + +### Requirement: An early-synced change archives as a no-op merge + +`cospec archive` SHALL archive a change whose every delta operation is already +reflected in the main specs, as left by `cospec sync-specs` or by hand. That +covers an identical ADDED, an absent REMOVED, an applied RENAMED, an identical +MODIFIED, and a capability whose spec was already retired. The post-merge +spot-check SHALL accept each such operation, and the `Specs:` line SHALL report +that the specs were already in sync whenever the wrapped archive reports so, not +that operations were applied. + +#### Scenario: A retired capability that is already gone archives + +- **WHEN** a change under `retire_capabilities: true` REMOVEs every requirement + of a capability whose `spec.md` is already deleted +- **THEN** `cospec validate --strict` and `cospec archive` accept it, the + archive exits 0, and the binary's "already in sync" report is reflected in the + `Specs:` line + +### Requirement: A brand-new capability refuses only MODIFIED and RENAMED + +`archive/new-spec-non-added` SHALL be an ERROR only on a MODIFIED or RENAMED +operation that targets a capability with no living spec. A REMOVED operation +there SHALL NOT be reported by it: the binary ignores it with a warning +("nothing to remove") and applies the rest of the delta. A delta whose only +operations on a new capability are REMOVED, without `retire_capabilities: true`, +SHALL still be refused, by `archive/rebuilt-spec-invalid`, because the spec the +binary would write has no requirements. + +#### Scenario: ADDED with REMOVED on a new capability archives + +- **WHEN** a delta for a capability with no living spec ADDs one requirement and + REMOVEs another +- **THEN** `cospec validate --strict` reports no `archive/new-spec-non-added`, + and `cospec archive` and `openspec archive -y` both archive it with the ADDED + requirement written + +#### Scenario: REMOVED-only on a new capability without the marker is still refused + +- **WHEN** a delta for a capability with no living spec only REMOVEs, and the + change does not declare `retire_capabilities: true` +- **THEN** `cospec validate --strict` reports `archive/rebuilt-spec-invalid`, + `cospec archive` refuses before delegating, and `openspec archive -y` refuses + too + +### Requirement: Relayed archive output is spelled cospec + +Every place `cospec archive` relays the wrapped archive's output (the aborted +and half-state reports, the relayed merge warnings, and the `message` and `fix` +of its failure document) SHALL pass that text through `respellRemedies`, so a +sentence on the remedy allowlist names `cospec`, while a path, a change name and +authored spec text pass through byte-for-byte. + +#### Scenario: A relayed merge warning names cospec + +- **WHEN** the wrapped archive creates a new capability whose carried Purpose is + under the minimum length and cospec relays the binary's warning +- **THEN** the relayed `Warning:` line reads + `… cospec validate --strict reports it as too brief.`, and no relayed line + names a bare `openspec` command on the allowlist diff --git a/openspec/specs/harness-workflows/spec.md b/openspec/specs/harness-workflows/spec.md index f62dc5bc..8cdf0beb 100644 --- a/openspec/specs/harness-workflows/spec.md +++ b/openspec/specs/harness-workflows/spec.md @@ -489,3 +489,31 @@ The opsx leftover scan is not narrowed by this requirement. - **WHEN** `.claude/commands/cospec/propose.md` references `/cospec:not-a-real-workflow` and `cospec doctor` runs - **THEN** doctor reports a `dangling-ref` ERROR naming that file + +### Requirement: The sync-specs workflow syncs through cospec sync-specs + +The `sync-specs` workflow body SHALL do what upstream's `sync` workflow does, +merge a change's delta specs into the main specs without archiving it, and SHALL +do it only through the CLI: preview the merge (`cospec validate ` and the +change's delta files), say which main specs will be created, changed or deleted, +then run `cospec sync-specs ` and report its result. It SHALL NOT instruct +the agent to edit a main spec by hand, and SHALL NOT say that a mid-flight sync +is unsupported. It SHALL say that the merge is the archive's own, byte-for-byte, +so a refusal there is the same refusal archive would give, and that the change +stays active. The workflow's `harness.yaml` description SHALL say it merges a +change's delta specs into the main specs without archiving, and SHALL keep its +natural-phrasing triggers. The `archive` workflow body SHALL say that a change +whose specs were synced early archives as a no-op merge, with both hard gates +still run. + +#### Scenario: The rendered sync-specs body runs the command + +- **WHEN** the `sync-specs` workflow renders for every harness +- **THEN** each body instructs `cospec sync-specs ` after a preview, + carries no "no mid-flight sync" text, and names no bare `openspec` command + +#### Scenario: The rendered archive body names the early-sync no-op + +- **WHEN** the `archive` workflow renders +- **THEN** it says a change synced early with `/cospec:sync-specs` archives as a + no-op merge and still passes both hard gates diff --git a/openspec/specs/json-document-parity/spec.md b/openspec/specs/json-document-parity/spec.md index 2e59704c..72f44b59 100644 --- a/openspec/specs/json-document-parity/spec.md +++ b/openspec/specs/json-document-parity/spec.md @@ -123,3 +123,70 @@ document on stdout and nothing else there: - **THEN** stdout is `{changes: [], root: null, status: [{…, code: "unknown_store", message, target, fix}]}` and the exit code is 1, as the binary answers + +### Requirement: Archive's JSON documents carry the binary's keys + +`cospec archive --json` SHALL add the binary's keys to its success +document without changing any cospec key: +`archive: {change, archivedAs, path, specsUpdated, totals?, warnings?}` and +`root: {path, source, store_id?}`. `archivedAs` SHALL be the archive directory +verified on disk. `path` SHALL be its canonical absolute path. `specsUpdated` +and `totals` SHALL be the values the wrapped archive reported applying, read +from its own `Totals:` line and its `Specs updated successfully.` or +`Specs already in sync; no files changed.` line. `totals` SHALL be absent when +no spec sync ran, and `warnings` SHALL be absent when the binary reported none. +A key oracle row SHALL compare the document with the binary's own +`archive -y --json` on a copy of the same fixture. + +#### Scenario: The success document matches the binary's keys + +- **WHEN** `cospec archive c1 --json` archives a MODIFIED change, and + `openspec archive c1 -y --json` archives a copy +- **THEN** cospec's `archive` and `root` equal the binary's, apart from the + copy's own path, and every cospec key keeps its native value + +### Requirement: Every archive refusal under --json is one document + +Under `--json` every refusal of `cospec archive` SHALL be exactly one document +on stdout, with exit 1: `archive: null`, `root` (absent when no root resolved, +as in the binary), `status: [{severity: "error", code, message, fix?}]`, and +cospec's `change`, `type`, `archived: false` and `reason`. `code` and `message` +SHALL be the binary's for every refusal the binary makes on the same input: + +| Refusal | `code` | +| --------------------------------- | ------------------------------------ | +| unknown change | `archive_change_not_found` | +| invalid change name | `archive_change_name_invalid` | +| namespace folder | `archive_change_is_namespace_folder` | +| revalidation failed | `archive_validation_failed` | +| incomplete tasks | `archive_tasks_incomplete` | +| archive slot taken | `archive_target_exists` | +| scenario preservation | `archive_spec_update_failed` | +| unreadable archive directory | the binary's code on that runtime | +| a delegated failure cospec relays | `archive_error` | + +For scenario preservation the `message` SHALL be the binary's sentence for the +first dropped requirement its merge would abort on. For a delegated failure it +SHALL be the wrapped archive's own last reason line, respelled. +`archive/verification-incomplete` has no upstream counterpart and SHALL carry +the code `archive_verification_incomplete`. `fix` SHALL be cospec's spelling of +the remedy cospec accepts (`--force-incomplete` for the tasks gate, where the +binary names `--yes`). The revalidation refusal SHALL keep the keys of the +report it carried before. A root-selection failure SHALL answer through the +shared resolver document with archive's payload `{archive: null}`. + +#### Scenario: A gate refusal prints a document + +- **WHEN** `cospec archive c1 --json` runs on a change with an incomplete task +- **THEN** stdout is one document with `archive: null`, `root`, and + `status[0].code` `archive_tasks_incomplete` with the binary's message, and the + exit code is 1 + +#### Scenario: An unreadable archive directory is one document + +- **WHEN** `openspec/changes/archive/` is mode 000 and + `cospec archive c1 --json` runs +- **THEN** stdout is one document whose `status[0].code` equals the binary's on + the same runtime (`archive_path_outside_root` under Bun on macOS, + `archive_error` naming the archive path under Bun on Linux), compared by code + and path, and the exit code is 1 diff --git a/openspec/specs/nested-change-detection/spec.md b/openspec/specs/nested-change-detection/spec.md index 38e2fc45..2f5f8da2 100644 --- a/openspec/specs/nested-change-detection/spec.md +++ b/openspec/specs/nested-change-detection/spec.md @@ -81,7 +81,8 @@ A namespace folder SHALL be reported with the binary's explanation, verbatim: exactly one `meta/nested-change` ERROR carrying the explanation, run no other rule on it and delegate nothing for it. -`cospec archive` is not covered by this requirement. +`cospec archive` and `cospec sync-specs` are covered by "Archive refuses a +namespace folder as the binary does". #### Scenario: Status refuses a namespace folder @@ -107,3 +108,22 @@ A namespace folder SHALL be reported with the binary's explanation, verbatim: - **WHEN** `cospec validate mobile --json` runs - **THEN** the item carries exactly one issue, `meta/nested-change` at ERROR, and no `meta/openspec-yaml` issue + +### Requirement: Archive refuses a namespace folder as the binary does + +`cospec archive ` SHALL refuse a namespace folder before revalidation or +any gate runs, using the shared detector, with the binary's message +`Cannot archive '': ` and its fix +`Rename openspec/changes// to a flat change directory, then archive it.`, +on stderr in text mode and as one failure document with code +`archive_change_is_namespace_folder` under `--json`, and exit 1. +`cospec sync-specs ` SHALL refuse it the same way, with `sync` in place +of `archive` in both sentences. Nothing SHALL be moved, written or delegated. + +#### Scenario: Archive refuses the folder with the binary's answer + +- **WHEN** `cospec archive mobile --json` and + `openspec archive mobile -y --json` run on a folder wrapping `mobile/refresh/` +- **THEN** both print one document whose `status[0]` has code + `archive_change_is_namespace_folder` with equal `message` and `fix`, both exit + 1, and `openspec/changes/mobile/` is unchanged diff --git a/openspec/specs/spec-sync/spec.md b/openspec/specs/spec-sync/spec.md new file mode 100644 index 00000000..f8cadaac --- /dev/null +++ b/openspec/specs/spec-sync/spec.md @@ -0,0 +1,183 @@ +# spec-sync Specification + +## Purpose + +Defines `cospec sync-specs`, which merges an active change's delta specs into +the main specs under `openspec/specs/` without archiving the change. The merge +is the wrapped binary's own archive merge, run on a scratch copy and copied back +file by file, so the main specs come out byte-for-byte as `cospec archive` would +write them, the real tree is never touched by the binary, and a later archive of +the same change is the binary's early-sync no-op. + +## Requirements + +### Requirement: Sync-specs merges a change's delta specs without archiving it + +`cospec sync-specs ` SHALL run these steps in order, and SHALL stop at +the first one that refuses, writing nothing to the real tree: + +1. Resolve the root and the change as `cospec archive` does. A namespace folder + SHALL be refused with the binary's explanation, as archive refuses it. +2. Run archive's pre-merge checks on the change: the archive-precondition + validation `cospec archive` runs, then the shared scenario-preservation + helper. A refusal SHALL print the same report or gate message archive prints + and exit 1. +3. Run the pinned binary's `archive -y` on a scratch copy of the root + (see "The scratch run never touches the real tree"). +4. Copy back into the real `openspec/specs/` only the files that run created, + changed or deleted, and prune a directory the binary's run left empty there. +5. Verify on disk that every copied file's bytes equal the scratch file's, that + every deleted file is absent, and that no other file under `openspec/specs/` + changed. + +The change SHALL stay active: its directory, its artifacts, `tasks.md` and +`verification.md` SHALL be byte-identical after the command, and nothing SHALL +be written under `openspec/changes/archive/`. The tasks gate and +`archive/verification-incomplete` SHALL NOT run, because nothing is archived. + +#### Scenario: A MODIFIED delta is synced and the change stays active + +- **WHEN** `cospec sync-specs add-widget` runs on a change whose delta MODIFIES + `Widget rendering`, keeping every living scenario +- **THEN** `openspec/specs/widgets/spec.md` carries the modified requirement, + `openspec/changes/add-widget/` is unchanged, and the command exits 0 + +#### Scenario: A scenario-dropping MODIFIED is refused before any write + +- **WHEN** the delta's MODIFIED block omits a scenario the living requirement + has +- **THEN** the command prints the `archive/scenario-preservation` refusal, exits + 1, and no file under `openspec/` changes + +### Requirement: The synced main specs are byte-identical to archive's + +For every delta shape (ADDED on an existing or a new capability, MODIFIED, +REMOVED, RENAMED, and a REMOVED that retires a capability under +`retire_capabilities: true`) the files `cospec sync-specs` leaves under +`openspec/specs/` SHALL be byte-identical to the ones +`openspec archive -y` writes on a copy of the same tree, including a +retired capability's deleted `spec.md` and its pruned directory. Because they +are, a following `cospec archive ` SHALL see every operation as the +early-sync no-op the binary performs (an identical ADDED, an absent REMOVED, an +applied RENAMED, an identical MODIFIED, a retired capability) and SHALL archive +the change with both hard gates run and no further change to `openspec/specs/`. + +#### Scenario: Each delta shape matches the binary's archive + +- **WHEN** `cospec sync-specs` runs on each of the ADDED, MODIFIED, REMOVED, + RENAMED and retired-capability fixtures, and `openspec archive -y` runs on a + copy of each +- **THEN** the two `openspec/specs/` trees are byte-identical on every fixture + +#### Scenario: A linked living spec is synced as the binary archives it + +- **WHEN** a capability's living `spec.md` is a relative symbolic link to a file + elsewhere under `openspec/specs/`, and `cospec sync-specs` runs on a change + that MODIFIES it and on one that retires it under `retire_capabilities: true` +- **THEN** each `openspec/specs/` tree — files, directories and links — is the + one `openspec archive -y` leaves on a copy: the MODIFIED written through the + link, the retired capability's link deleted and its directory pruned + +#### Scenario: Archiving a synced change is a no-op merge + +- **WHEN** `cospec archive` runs on each fixture after `cospec sync-specs`, with + every task done and every verification row resolved +- **THEN** the change archives, exit 0, the `Specs:` line says the specs were + already in sync, and `openspec/specs/` is unchanged by the archive + +#### Scenario: The hard gates still run after a sync + +- **WHEN** the same synced fixture carries a bare `[ ]` verification row +- **THEN** `cospec archive` refuses it with `archive/verification-incomplete` + and exits 1 + +### Requirement: The scratch run never touches the real tree + +The binary's run SHALL happen in a fresh directory created under the OS temp +directory, holding `openspec/config.yaml` or `openspec/config.yml` if present, +`openspec/schemas/`, `openspec/specs/`, `openspec/changes//` and an +empty `openspec/changes/archive/`, with the binary spawned there with that +directory as its working directory. Every other file and directory under the +real `openspec/`, including sibling changes and the real +`openspec/changes/archive/`, SHALL NOT be copied. The binary SHALL be spawned +with no `--store`, so its nearest-root walk resolves the scratch directory. The +command SHALL confirm that resolution from the run's own observable output +before copying anything back. A symbolic link in the copied subtree that leads +outside it SHALL be refused before the run, naming the link, because the binary +would write through it into the real tree. A symbolic link inside it, absolute +or relative, SHALL be copied pointing at the scratch copy of its target, so the +run never writes through a link into the real tree. A file or directory under +the copied subtree that the command cannot read SHALL NOT fail the command: the +binary's archive reads no main spec but a delta's target, so an unrelated +unreadable spec SHALL leave the sync answering as `cospec archive` answers. The +scratch directory SHALL be removed when the command ends, whether the run +succeeded or failed. A failed run SHALL leave the real tree byte-identical, with +no `.openspec-archive.lock` and no other new file anywhere under it, and SHALL +relay the binary's reason with its remedies spelled `cospec`. + +#### Scenario: A refused scratch run leaves nothing behind + +- **WHEN** the binary refuses the scratch run after taking its archive claim + (two capability directories resolving to one spec through a symlink inside + `openspec/specs/`) +- **THEN** `cospec sync-specs` exits 1 with the binary's reason, no + `.openspec-archive.lock` exists anywhere under the real root, every file under + the real `openspec/` is byte-identical to before, and the scratch directory is + gone + +#### Scenario: An absolute alias inside the specs never writes the real tree + +- **WHEN** `openspec/specs/alias` is an absolute symbolic link to the root's own + `openspec/specs/widgets/`, and `cospec sync-specs` runs on a change whose + deltas the binary refuses after its claim (`alias` and `widgets` resolving to + one spec), and on one whose only delta MODIFIES `alias` +- **THEN** the refused run exits 1 with the binary's reason and leaves every + file under the real `openspec/` byte-identical, and the other exits 0 with + `openspec/specs/` byte-identical to `openspec archive -y`'s on a copy + +#### Scenario: Sibling changes and the archive are never copied + +- **WHEN** `cospec sync-specs` runs in a root with two other active changes and + an archived change of today's date and the same name +- **THEN** the scratch run succeeds and only `openspec/specs/` files change in + the real tree + +### Requirement: Sync-specs reports what it changed + +In text mode, `cospec sync-specs` SHALL print one line per main-spec file it +wrote or deleted, then a summary carrying the binary's own applied totals. When +the binary reported the specs already in sync, it SHALL say so and write +nothing. When the change's schema has no specs artifact, it SHALL print that +there is nothing to sync and why, run nothing, and exit 0. When the change has +no delta specs or declares `skip_specs: true`, it SHALL first run archive's +revalidation and refuse what it refuses, so a delta kept in a file the merge +never reads (a `spec.md` at the root of the change's `specs/`, a +`specs/.md`, a note beside a capability's `spec.md`) is refused as +archive refuses it; otherwise it SHALL print that there is nothing to sync and +why, run no merge, and exit 0. The binary's merge warnings SHALL be relayed, +respelled. Under `--json` it SHALL print one document: +`{change, type, synced, totals, files: {written, deleted}, warnings, root}` on +success, and on any refusal +`{change, synced: false, status: [{severity, code, message, fix?}]}` with the +binary's diagnostic code for a scratch-run refusal, the code archive's document +uses for a pre-merge refusal, and exit 1. `--store` SHALL be accepted, and the +synced specs are then the selected store's. + +#### Scenario: An already-synced change writes nothing + +- **WHEN** `cospec sync-specs` runs a second time on the same change +- **THEN** it reports the specs already in sync, writes no file, and exits 0 + +#### Scenario: A change with no delta specs has nothing to sync + +- **WHEN** `cospec sync-specs` runs on a `chore` change +- **THEN** it prints that the `chore` schema has no specs artifact, spawns + nothing, and exits 0 + +#### Scenario: A delta in a file the merge never reads is refused + +- **WHEN** `cospec sync-specs` runs on a `feat` change whose only + `## ADDED Requirements` sits in `specs/spec.md`, `specs/widgets.md` or + `specs/widgets/notes.md` +- **THEN** it prints the revalidation report `cospec archive` prints for the + same change, exits 1, and writes nothing