Skip to content

feat(cli): reach upstream's archive and sync surfaces - #64

Merged
replygirl merged 28 commits into
mainfrom
worktree-archive-and-sync-parity
Oct 5, 2026
Merged

replygirl merged 28 commits into
mainfrom
worktree-archive-and-sync-parity

Conversation

@replygirl

@replygirl replygirl commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Summary

cospec archive and /cospec:sync-specs were the last two places where an
openspec invocation behaved differently under cospec. This PR closes both
gaps:

  • cospec archive --no-validate is accepted (forwarded to the wrapped
    archive -y) instead of refused as pending, while the tasks gate,
    archive/verification-incomplete, archive/scenario-preservation, the
    namespace-folder refusal, the slot check and on-disk verification still run.
  • cospec archive --json now answers with upstream's keys
    (archive/root/status) on both success and every refusal, instead of
    stderr-only prose for gate refusals.
  • Every relayed remedy from the wrapped archive is now respelled through the
    remedies.ts allowlist.
  • The Specs: line names why a sync was skipped (--skip-specs, no specs
    artifact, no delta specs) instead of collapsing everything into skipped.
  • Namespace folders are refused up front with the binary's own message and fix,
    not a validation report.
  • archive/new-spec-non-added no longer falsely refuses REMOVED operations on
    a capability with no living spec.
  • New cospec sync-specs <change> (and /cospec:sync-specs) merges a change's
    delta specs into the main specs early — via the binary's own archive -y run
    on a scratch copy — without archiving the change, so upstream's sync
    workflow has a cospec counterpart. A later archive of an early-synced change
    is the binary's own no-op merge.

BREAKING

(verbatim from the archived proposal)

  • 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 <namespace folder> 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.

Review findings fixed

Four findings from review were fixed on this branch:

  • a symlink scratch escape in sync-specs's scratch-root handling
  • an unreadable, unrelated spec document breaking an unrelated archive/sync run
  • revalidation now runs before reporting "nothing to sync"
  • a symlinked spec deletion is now handled correctly during sync

Archive

mise run cospec -- archive archive-and-sync-parity ran clean with no flags,
moving the change to
openspec/changes/archive/2026-10-05-archive-and-sync-parity/ as the final
commit on this branch:

commit 2ef08a037b2206088ff0676bb47650f051966fd0
Author: replygirl <rg@replygirl.club>
Date:   Mon Oct 5 08:49:09 2026 -0500

    chore(archive): archive archive-and-sync-parity

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

 .../.openspec.yaml                                 |   0
 .../blocking-changes.md                            |   0
 .../2026-10-05-archive-and-sync-parity}/design.md  |   0
 .../proposal.md                                    |   0
 .../specs/archive-integrity/spec.md                |   0
 .../specs/harness-workflows/spec.md                |   0
 .../specs/json-document-parity/spec.md             |   0
 .../specs/nested-change-detection/spec.md          |   0
 .../specs/spec-sync/spec.md                        |   0
 .../2026-10-05-archive-and-sync-parity}/tasks.md   |   0
 .../verification.md                                |   0
 openspec/specs/archive-integrity/spec.md           | 137 +++++++++++++++
 openspec/specs/harness-workflows/spec.md           |  28 ++++
 openspec/specs/json-document-parity/spec.md        |  67 ++++++++
 openspec/specs/nested-change-detection/spec.md     |  22 ++-
 openspec/specs/spec-sync/spec.md                   | 183 +++++++++++++++++++++
 16 files changed, 436 insertions(+), 1 deletion(-)

🤖 Generated with Claude Code

replygirl and others added 28 commits October 5, 2026 08:19
Artifacts for roadmap R7 (rows 38-42) and the R7 obligations: archive
--no-validate, archive's upstream JSON envelopes on every refusal, the
shared verbatim-view scenario-preservation helper, respelled relays,
namespace-folder refusal, new-spec-non-added on REMOVED, and the new
cospec sync-specs command run through the binary's archive on a scratch
copy. validate --strict clean; apply exit 0.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Append the archive-and-sync-parity builders to test/contract/fixtures.ts:
feat v2 changes (done tasks, resolved verification, the composed schema
copied in) for ADDED on a new capability, MODIFIED, REMOVED, RENAMED and a
retired capability; the three verbatim-view fixtures; the bare-verification,
incomplete-task, scenario-drop and revalidation-only variants; a namespace
folder; ADDED+REMOVED, REMOVED-only and REMOVED-only-with-marker on a new
capability; a symlinked capability alias; a short carried Purpose; a no-delta
feat, a skip_specs feat and a chore change; and the mode-000 archive case.

A smoke row per builder pins how `openspec validate --strict` and
`cospec validate --strict` read it. Two cospec rows are test.failing on this
tree (new-added-removed, new-removed-only-marked): new-spec-non-added still
fires on REMOVED until task 2.1.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Write the contract rows 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
pinned binary's answer at test time through the upstream oracle, and the
archive document rows through support/key-oracle.ts. The key oracle gains
a path class (archive.path/root.path compared relative to each tool's own
root, since cospec and the binary archive separate copies) and the
tasks-gate fix as a named collision: cospec's --force-incomplete wins
because --yes, a no-op under cospec, does not lift its stricter gate.

Failing on this tree: 35 rows marked test.failing (1.1-1.6, 2.1, 2.2 x4,
2.3 x7, 2.4, 2.6, 4.1, 5.1, 6.1-6.3, 7.1 x3, 7.2 x5, 8.1, 8.2 sweep), plus
the 2 cospec smoke rows from 1.1. Passing already and left plain: 3.1-3.3
(the gate already reads the verbatim view, design fact 1), 6.4 x2 and the
8.2 pass-through row. mise run test:contract: 2620 pass, 0 fail.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Write the contract rows of verification groups 9-12 and 5.2 in
sync-specs.test.ts, plus the sync-specs halves of 8.1 and 8.2. Every
byte-equality row compares the main specs against `openspec archive c1 -y`
on a copy (file list and sha256 per file, and the directory list so a
pruned capability directory shows). Each cospec run gets a private TMPDIR,
so a row proves the scratch tree is gone afterwards, or, with TMPDIR
unwritable, that the command never made one.

All 33 rows are test.failing: `cospec sync-specs` does not exist yet, and
every refusal row asserts its own message or code, not just exit 1.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
archive/new-spec-non-added now skips REMOVED as well as ADDED on a
capability with no living spec: the binary's merge ignores that REMOVED
with a warning ("nothing to remove") and applies the rest. MODIFIED and
RENAMED stay ERRORs. A REMOVED-only delta without retire_capabilities is
still refused, by archive/rebuilt-spec-invalid: once the precondition no
longer suppresses it, the rebuilt check already fires on the skeleton spec
(no requirement), so no extension was needed; under the marker it is the
binary's skip and reports nothing.

Unit table 6.5 plus the two REMOVED-only cases. Flips rows 6.1 and 6.3 and
the two cospec smoke rows; 6.4 stays green. Row 6.2 also asserts cospec's
"Specs: already in sync" line, which is task 3.5's, so it flips there. The
archive-parity fixture `new-spec-non-added` (REMOVED-only, no marker) now
names archive/rebuilt-spec-invalid as the rule that refuses it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Move changeDeltaOps and the archive/scenario-preservation step out of
commands/archive.ts into core/scenario-gate.ts, unchanged in behavior:
scenarioGate(base, caps) reads each living spec through parseLivingSpec and
returns {drops, livingCaps}, and scenarioRefusal(command, drops) prints the
refusal archive printed before. The inputs stay typed on the verbatim view.

Unit row 3.4: a type-level test refuses an AdvisoryDeltaOp argument, and no
command calls findScenarioDrops directly. Rows 3.1-3.3 still green.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
core/archive-output.ts builds the failure document every `cospec archive`
refusal prints under --json: cospec's change/type/archived/reason, then the
binary's archive: null, root and status[0], each diagnostic in the binary's
code and message (ported from dist/core/archive.js) where the binary
refuses the same input, its message and fix respelled through the
allowlist. archive.ts routes every refusal through one `refuse`, exports
jsonFailurePayload = { archive: null } for the resolver document, refuses
an invalid change name with archive_change_name_invalid before the lookup,
and lists every change directory in the not-found message as the binary
does. The revalidation document keeps every key of its report; a delegated
failure carries archive_error with the binary's last reason line and keeps
openspecExit.

Unit enumeration 2.5: one case per refusal reason archive.ts names (the
three post-delegation ones with the binary stubbed), plus a source check
that no refusal returns outside `refuse`. Flips rows 2.3 (unknown change,
invalid name, revalidation, incomplete tasks, taken slot, scenario drop),
2.4 and 2.6; the oracle rows pair status[] entries by code. 2.3's
namespace-folder row flips with task 3.3.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`cospec archive <folder>` runs findNestedChangesIn right after the change
resolves and before revalidation, as the binary does, and refuses it with
the binary's message (`Cannot archive '<name>': <explanation>`) and fix on
stderr, or as one archive_change_is_namespace_folder document under --json,
exit 1, with nothing moved and no meta/nested-change report. The refusal
enumeration gains its namespace-folder case. Flips rows 5.1 and 2.3's
namespace-folder row.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`cospec archive` now runs the binary's two checks at the binary's two
points, on the same runtime, so it diverges per OS exactly as the binary
does (design D7): first, before the change resolves, the binary's
assertPathWithin over changes/, changes/archive/ and specs/ through
realpathSync.native (glob.ts's port, now exported), refused as
archive_path_outside_root; then, at the slot check, an lstat of the slot
whose non-ENOENT errno is archive_error in the runtime's own words.
Between them the archive directory is read the degraded way
(readValidateContext's empty index plus a warning naming it, the
self-blocker check on an empty map, basenames listing nothing), so nothing
crashes. buildValidateContext, now unused, is removed.

Flips row 4.1 (macOS: archive_path_outside_root, one stderr line). Row 4.2
ran in oven/bun:1.3.14 as uid 1000 with the worktree read-only at /w: both
tools answer archive_error, EACCES statx on the slot, nothing moved, no
lock; recorded in the ledger. The refusal enumeration gains its
archive-unreadable case.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The success document gains the binary's
archive: {change, archivedAs, path, specsUpdated, totals?, warnings?} and
root, with every cospec key unchanged. core/archive-output.ts reads the
binary's own fixed lines from the human-mode archive it wrapped: its
`Totals:` line, `Specs updated successfully.` / `Specs already in sync; no
files changed.`, and its spec-merge warnings plus each retirement's note,
which is the list its own --json carries (recorded in design D5, where the
binary wins over the original wording). totals is left out under
--skip-specs or when no Totals line was printed, and specsUpdated then
falls back to whether any main-spec file's bytes changed.

The Specs line names why no sync ran (`skipped (--skip-specs)`,
`none (the <type> schema has no specs artifact)`, `none (no delta specs,
so no spec sync)`), with specsSkipReason flag/schema/no-deltas in JSON
beside the unchanged specs: "skipped", and reads `already in sync` when
the binary said so.

Unit table 7.3. Flips rows 2.1, 2.2 (x4), 7.1 (x3), 7.2 (x5), and 6.2,
whose in-sync Specs line this task adds.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Every relay of the wrapped archive's output now goes through
respellRemedies: the captured block of the aborted and half-state reports,
the relayed warnings in both modes (the Warning: lines and JSON warnings),
archive.warnings, and the failure document's message and fix. The
allowlist rewrites only upstream's exact sentences, so a path, a change
name or spec text passes through byte-for-byte; remedy-sources.ts already
classifies these core/archive.js lines as relayed and needs no new entry.

Flips rows 8.1 (archive) and 8.2's sweep of archive outputs, which now
sweeps only what `cospec archive` itself printed in each row.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`cospec archive <change> --no-validate` skips cospec's revalidation and
forwards --no-validate to the wrapped `archive -y`, so the binary skips its
own validation as an openspec user asked (design D3). The namespace-folder
check, tasks gate, archive/verification-incomplete, slot check,
archive/scenario-preservation, on-disk verification and spot-check all
still run, and a banner on stderr (both modes) says so before the first of
them.

The flag moves from pending to handled in core/command-table.ts and its
parity-pending.yaml entry is deleted. The unit tests that used it as their
pending-flag example now use `init --language`; archive's help and
completion list it.

Flips rows 1.1-1.6. archive-no-validate.test.ts has no test.failing left.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
core/scratch-root.ts (design D11): syncThroughScratch copies what the
binary's archive reads — config.yaml/config.yml, schemas/, specs/, the one
change and an empty changes/archive/ — into a mkdtemp directory under the
OS temp directory (links copied verbatim), runs an injected runner there,
and confirms the run archived the scratch copy (exit 0, no abort, the
change gone, one archive entry holding .openspec.yaml). It then diffs the
scratch specs/ against its pre-run copy, refuses if the real specs/ changed
while the run worked, copies back each written file atomically (an
existing file's mode kept), unlinks each deleted one, prunes directories
the run emptied, and re-reads the real tree to verify exactly that landed.
A symbolic link that leads outside the copied paths is refused before any
copy, naming it. The scratch directory is removed in a finally, so a
claim file or a partial write can only ever exist there.

Unit rows 11.2 (a runner that claims, writes partially and fails leaves
the real tree byte-identical and the scratch gone) and 11.5 (the real
specs changing mid-run refuses and writes nothing), plus the copy-back,
retirement-prune and link cases.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`cospec sync-specs <change>` merges a change's delta specs into the main
specs and leaves the change active (design D11). It resolves the change as
archive does (an invalid name, an unknown change and a namespace folder,
`Cannot sync '<name>'`, refused with archive's codes), says there is
nothing to sync for a schema with no specs artifact, skip_specs: true or no
delta files, runs archive's revalidation and the shared
scenario-preservation gate, then runs the pinned binary's own `archive -y`
on the scratch copy (expected exits 0 and 1, the scratch post-condition in
core/scratch-root.ts) and copies back what it changed. Text prints a
`Synced:` line per file and the binary's Totals, or that the specs were
already in sync, with relayed warnings respelled; --json prints one
document, and every refusal archive's failure document with synced: false.

The command-table row (json and store accepted, one required `change`
positional) and its cli.ts dispatch entry land here; scenario-gate's
importers are now archive.ts and sync-specs.ts.

Flips 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: sync-specs.test.ts has no test.failing
left. Reachability green.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The sync-specs workflow now does upstream's `sync`: select the change,
preview the merge (`cospec validate <slug>`, the delta files, which main
specs it will create, change or delete, any retirement and its marker),
run `cospec sync-specs <slug>`, and relay the result. The "no supported
mid-flight sync" text is gone; the retirement section stays. The archive
workflow names the early-sync no-op (both hard gates still run), and both
bodies stop saying a REMOVED on a new capability is refused. The
sync-specs description in harness.yaml becomes "Merge a change's delta
specs into the main specs without archiving it, exactly as archive
would", keeping its trigger phrases (design D13).

`mise run generate` regenerated every harness; the render goldens and
snapshots are rewritten for the two bodies only. Row 13.1's canon render
test checks every harness body; generate:check reports no drift.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Bring every page design D14 names to the shipped behavior, each fact on
the page that owns it: commands.md gets archive's --no-validate, the
Specs line reasons, the archive and sync-specs JSON documents and codes,
and a sync-specs row; apply-and-archive.md gets the namespace-folder
refusal, --no-validate's gates, early-synced no-op archives and REMOVED on
a new capability; docs/apply-archive.md the archive step order around the
binary's own directory checks and the scratch-tree design;
validation-rules.md archive/new-spec-non-added's trigger; harness-setup.md
the sync <-> sync-specs sentence. docs/harness-integration.md, which still
said a mid-flight sync is unsupported, now describes the shipped workflow.
mise run docs:build is green.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
.agents/shared.md's workflow step 6 names `cospec sync-specs <slug>` as
the way to land a change's main specs early (archive's own merge on a
scratch copy, the change left active, its later archive a no-op merge with
both hard gates). The "JSON documents are additive" paragraph lists
archive as a mirrored command instead of "next", says where archive's
upstream keys come from, and names the tasks-gate `status[].fix` in
NAMED_COLLISIONS with why cospec's value wins. `mise run agents:sync`
propagated it to CLAUDE.md and AGENTS.md; agents:check is green.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Record the observed result after `->` on every verification row the
agent owns (4.2's container run was recorded with task 3.4), confirm row
14.1 (no test.failing or test.todo left in archive-no-validate.test.ts or
sync-specs.test.ts) and row 14.2 (`mise run check` green: unit 2004,
integration 193, contract 2653, bench 343, release-test 14, all 0 fail;
`mise run test:pack` 2 pass). `cospec validate archive-and-sync-parity
--strict` is clean. Row 13.4 stays `[ ]`: it is the human-held DeepSeek
eval (task 8.2).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`cospec archive` never reads `skip_specs:` on a change that has delta
files: its revalidation refuses the conflict (deltas/skip-specs-conflict),
exit 1. `cospec sync-specs` checked the marker first and answered
"Nothing to sync" with exit 0, a false clear of a change archive refuses.
The new row (failing on this tree) runs both on the conflicting fixture,
with TMPDIR unwritable so no scratch run can start.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
sync-specs now reads `skip_specs: true` only for a change with no delta
files, as archive does. Beside delta files the marker is a conflict the
archive-precondition revalidation refuses (deltas/skip-specs-conflict), so
sync-specs falls through to that revalidation and refuses with archive's
report and exit 1 instead of answering "Nothing to sync" with exit 0.
Flips the row pinned in the previous commit; ledger row 10.2 records it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Rows 1.1 and 14.2 named pre-rebase commits that no longer exist on the
PR branch; point them at the post-rebase shas and the check run
actually observed on the rebased tree (70c4e65).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A delta kept in a file the merge never reads (specs/spec.md,
specs/<capability>.md, a note beside spec.md) is no delta to the
capability walk, so sync-specs answered "Nothing to sync: c1 has no delta
specs" with exit 0 while cospec archive and the binary's archive refuse
the same change (deltas/unread-file). sync-specs now runs archive's
revalidation before the no-deltas and skip_specs answers; only a schema
with no specs artifact still answers without running anything. Contract
rows 10.2 pin the three shapes against archive's own report.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
archive's specsUpdated fallback hashed every file under openspec/specs/,
so one unrelated spec this process cannot read (mode 000) crashed
`cospec archive --json` with a raw EACCES and empty stdout, where the
binary's archive reads only each delta's target and archives. The
snapshot now covers just the living spec.md each delta capability
targets (absent when missing; an unreadable one by its metadata, left to
the binary to answer). Contract rows 1.7 pin the --no-validate parity
and the one-document answer without the flag.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The scratch copy kept links verbatim, so an absolute in-tree alias
(openspec/specs/alias -> <root>/openspec/specs/widgets) still pointed at
the real tree: the binary merged straight into the real widgets/spec.md,
then sync-specs refused with "nothing was written". Each copied path is
now read at its real path and every link inside it re-pointed at the
scratch copy of its target; a symlinked specs/ is walked for escapes.

The copy-back diffed only files, so a retired capability whose spec.md
is a link kept the link and its directory ("already in sync") while the
binary's archive deletes both. Every entry kind is now diffed: a removed
link is deleted, a link replaced by a file is written, and a link the
run created or re-pointed is a breach thrown before any write.

An unrelated spec this process cannot read crashed the copy and the
fingerprint with a raw EACCES; it is now copied as an empty placeholder
of the same mode and fingerprinted by its metadata. Rows 9.5, 11.4,
11.6 and six scratch-root unit cases pin each shape.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The unread-delta, absolute-alias and linked-spec builders join
R7_FIXTURES, so the smoke rows confirm both validators read each one as
its flags say (72 pass). The mode-000 builder stays out: the binary's
validate answers it per OS, as its doc comment now says.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Ticks tasks.md 8.3 (rebase onto main 80ee385, bun install
--frozen-lockfile, mise run check) and refreshes verification row 14.2's
stale evidence (70c4e65/unit 2019 from a prior close-out) with this
merge-stage run on tree 6380655: unit 2025, integration 193, contract
2678 (1391.9s), bench 343, release-test 14, 0 fail; test:pack 2 pass;
docs:build and cospec validate --strict both clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@replygirl
replygirl force-pushed the worktree-archive-and-sync-parity branch from 9f03aa9 to 2ef08a0 Compare October 5, 2026 13:50
@replygirl
replygirl marked this pull request as ready for review October 5, 2026 13:50
Copilot AI balanced review requested due to automatic review settings October 5, 2026 13:50

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@replygirl
replygirl merged commit 12fa887 into main Oct 5, 2026
12 checks passed
@replygirl
replygirl deleted the worktree-archive-and-sync-parity branch October 5, 2026 14:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants