From c2293a54d65a73b215e48b742a94212e112855b8 Mon Sep 17 00:00:00 2001 From: replygirl Date: Mon, 5 Oct 2026 02:04:56 -0500 Subject: [PATCH 01/15] fix(cli): plan list-status-untyped-leftovers change Scaffold the fix-typed cospec change for the four UNFIXED items from list-status-untyped-leftovers-verify.md: list.ts's untyped-schema state misclassification, validate.ts's --archived --json version-floor guard, upstream-spellings.test.ts row 3.7's ordering flake, and a differential pinning test for status's already-correct mode-000-artifact handling (contradicting the verify report's item 2, per end-to-end evidence in design.md). Co-Authored-By: Claude Sonnet 5 --- .../.openspec.yaml | 4 + .../blocking-changes.md | 16 ++ .../list-status-untyped-leftovers/design.md | 167 ++++++++++++++++++ .../list-status-untyped-leftovers/proposal.md | 88 +++++++++ .../list-status-untyped-leftovers/tasks.md | 63 +++++++ .../verification.md | 28 +++ 6 files changed, 366 insertions(+) create mode 100644 openspec/changes/list-status-untyped-leftovers/.openspec.yaml create mode 100644 openspec/changes/list-status-untyped-leftovers/blocking-changes.md create mode 100644 openspec/changes/list-status-untyped-leftovers/design.md create mode 100644 openspec/changes/list-status-untyped-leftovers/proposal.md create mode 100644 openspec/changes/list-status-untyped-leftovers/tasks.md create mode 100644 openspec/changes/list-status-untyped-leftovers/verification.md diff --git a/openspec/changes/list-status-untyped-leftovers/.openspec.yaml b/openspec/changes/list-status-untyped-leftovers/.openspec.yaml new file mode 100644 index 00000000..55b41241 --- /dev/null +++ b/openspec/changes/list-status-untyped-leftovers/.openspec.yaml @@ -0,0 +1,4 @@ +schema: fix +created: 2026-10-05 +schemaVersion: 2 +skip_specs: true diff --git a/openspec/changes/list-status-untyped-leftovers/blocking-changes.md b/openspec/changes/list-status-untyped-leftovers/blocking-changes.md new file mode 100644 index 00000000..7a02b8c2 --- /dev/null +++ b/openspec/changes/list-status-untyped-leftovers/blocking-changes.md @@ -0,0 +1,16 @@ +# Dependencies + +## Blocked by + + + + + +None. + +## Soft-blocked by + + + + +None. diff --git a/openspec/changes/list-status-untyped-leftovers/design.md b/openspec/changes/list-status-untyped-leftovers/design.md new file mode 100644 index 00000000..b490ee93 --- /dev/null +++ b/openspec/changes/list-status-untyped-leftovers/design.md @@ -0,0 +1,167 @@ +# Design + +## Context + +Four items, verified against `main` first +(`.claude/handoff/reports/list-status-untyped-leftovers-verify.md`): + +1. `list.ts`'s `computeRow` derives `empty` — and so `state` — from + `hasAnyArtifact(dir)`, which checks only cospec's own fixed artifact + filenames (`proposal.md`, `tasks.md`, `design.md`, `verification.md`, + `blocking-changes.md`, `specs/*.md`). It never branches on + `isCospecType(schema)` for this computation (the `cospec` bool it does + compute is used only for `archiveReady`). A change on a schema cospec doesn't + type — whose own `schema.yaml` names its artifacts under other filenames + (`doc.md`, `requirements.md`, …) — is therefore always "empty" to this check, + even with a written artifact on disk, the same misclassification `status.ts` + fixed at task 11.5 for `state`/`next` (`status.ts` now answers every + untyped-schema change from the binary's own status, never from + `hasAnyArtifact`). + +2. `validate.ts:1553-1561`'s `--archived` version-floor guard writes + unconditionally to `process.stderr`, never branching on `flags.json` — unlike + the no-root guard immediately above it (1541-1548), which does. + `validate --archived --json` against a binary below `ARCHIVED_SINCE` + therefore prints no JSON document at all. + +3. `upstream-spellings.test.ts` row 3.7 asserts byte-identical stdout/stderr + between a `cospec` and an `openspec` instructions call, each over its own + `remedyNamedRoot()` copy (two independent `mkdtemp` + `cpSync` calls). The + pinned binary's own `getAvailableChanges` + (`dist/commands/workflow/shared.js:77-90`) returns + `readdir(...).filter(...).map(e => e.name)` with no `.sort()` anywhere on + this path — it is unsorted by design, and `instructions.ts` never re-sorts or + re-derives the list cospec relays (grep confirms: no `sort` call in + `instructions.ts`/`instructions-render.ts`). The row's byte-identity + assertion is therefore only valid when both calls observe the same on-disk + entry order, which two independently-created copies don't guarantee on every + filesystem (overlayfs in particular). + +4. The stage's own verify report lists a fourth item — `status`'s handling of a + mode-000 artifact other than `tasks.md` — as unfixed, reasoning from a + primitive-level probe (`existsSync` true, `accessSync(R_OK)` throws EACCES) + and a read of `artifactDone` (`apply.ts:148-152`) in isolation. It did not + run `cospec status` end to end. `hasUnreadableEntry` (`status.ts:481-510`, + task 11.12) already walks every non-dot entry under the whole change + directory — not just `tasks.md` — `openSync`-ing every regular file; + `binaryDecides` (`status.ts:451-472`) routes a change to the binary's own + answer whenever `hasUnreadableEntry` finds one, in text mode as under + `--json`, for both `--change` and `--all`. End-to-end differential runs + against the pinned binary, both single-change and `--all`, both `--json` and + text, confirm it already matches: + + - **macOS** (host, this change's author environment): both the pinned binary + (spawned under Bun, as cospec's production wrapped calls run — + `BUN_BE_BUN=1`) and `cospec status` refuse a mode-000 `proposal.md` with + `EACCES: permission denied, realpath '…/proposal.md'`, exit 1, in every + mode tried. + - **Linux** (`oven/bun:1.3.14`, non-root UID 1000, bind-mounted tree): both + the pinned binary and `cospec status` read past the same mode-000 + `proposal.md` — Linux's `realpath` doesn't require read permission on the + target, only search permission on its parent directories — reporting + `proposal` done, exit 0, in every mode tried. + + `hasUnreadableEntry`'s own check (`openSync(path, 'r')`) _does_ throw EACCES + on Linux too (unlike the binary's `realpath`), which is why `binaryDecides` + still routes the change to a delegated upstream call on Linux — but since + that delegated call itself succeeds there, `status` falls through to its own + `computeStatus`/`artifactDone` (`existsSync`), which agrees with the binary's + own exit-0/done answer. Both platforms' two independent checks (cospec's own + routing signal, and the delegated or local answer it falls back to) land on + the binary's actual answer by different paths, not by coincidence: + `binaryDecides` only ever _widens_ when to ask the binary, never narrows + cospec's own fallback below what the binary would say when asked. + +## Goals / Non-Goals + +**Goals:** + +- Fix items 1–3 with the smallest change that makes cospec's answer match the + binary's, reusing an existing pattern each time rather than inventing a new + one. +- Lock item 4's current, already-correct cross-platform behavior down with a + differential contract row, and record why the verify report's finding doesn't + hold up — no product change. + +**Non-Goals:** + +- Re-deriving or sorting the binary's `getAvailableChanges` list (item 3): the + binary itself gives no ordering guarantee here, so cospec has none to provide + either; the fix is the test sharing one root, not a new cospec behavior. +- Touching `hasUnreadableEntry`, `binaryDecides`, or `artifactDone` (item 4): no + divergence was found to fix. + +## Decisions + +**Item 1 — reuse `hasSchemaOutput`'s signal, scoped to the change's own declared +schema.** `core/change.ts`'s `hasSchemaOutput` already answers almost this +question — "does `dir` hold a file the resolved schema's `generates` names" — +for namespace-folder detection, resolving the schema from the _project's_ config +when the candidate has no `.openspec.yaml` of its own. `list.ts`'s case is +narrower and simpler: the change already declares its own schema in +`.openspec.yaml`, so no project-config fallback is needed. A new +`hasDeclaredArtifact(dir, schema, base)` in `list.ts` loads that schema +(`loadSchema`, already used by `status.ts`) and checks `artifactOutputExists` +(`core/glob.ts`, the binary's own `generates` glob semantics, task 11.3) over +each of its artifacts' `generates` patterns, mirroring `hasSchemaOutput`'s +two-`catch`-blocks structure (a schema that cannot be loaded, or an output +pattern that cannot be resolved, gives no signal — the binary's own `list` never +loads a schema at all, so it has no opinion either; confirmed by grep of +`dist/core/list.js`, which reads only `tasks.md`-derived progress counts, never +a schema file). + +`empty` becomes +`!hasAnyArtifact(dir) && (cospec || !hasDeclaredArtifact(dir, schema, base))` — +additive over today's check: a cospec-typed change's classification is +bit-for-bit unchanged (the `cospec ||` short-circuits), and an untyped-schema +change is "empty" only when _neither_ signal finds anything, so there is no +regression path for a change `hasAnyArtifact` already caught by cospec's fixed +names. + +**Rejected:** delegating to `openspec status --change --json` per +untyped-schema row to ask the binary directly, which `status.ts`'s own +`legacyChangeEntry` does for its richer, single-change answer. `list.ts`'s own +discipline (its file header, D6) is one delegated `openspec list --json` call +per invocation, never one per change; the binary's `list --json` row carries no +artifact-presence signal at all (`dist/core/list.js`: `name`, `completedTasks`, +`totalTasks`, `lastModified`, `status` — `status` is task-count-only: +`no-tasks | in-progress | complete`, not "has artifacts"), so there is nothing +there to merge in instead. + +**Bare directories unaffected.** A change directory with no `.openspec.yaml` +reports `schema: ''` (`readOpenspecYaml(dir)?.schema ?? ''`); `isCospecType('')` +is `false`, so it takes the new branch, but `loadSchema('', base)` throws +immediately (`schemaDir`'s empty-name guard) before any output check runs, so +`hasDeclaredArtifact` returns `false` and `empty` reduces to exactly today's +`!hasAnyArtifact(dir)` — unchanged, matching the proposal's scope (a change +whose `.openspec.yaml` _declares_ an untyped schema, not a bare directory with +none). + +**Item 2 — mirror the sibling guard's branch exactly.** The no-root guard four +lines above already shows the right shape +(`rootSelectionDocument`/`respellRemedies` under `--json`, stderr text +otherwise); the `--archived` version-floor guard gets the same `flags.json` +branch, reusing `rootSelectionDocument` with the same null payload shape +`validate --json` uses elsewhere for a resolver-stage refusal. + +**Item 3 — share one root, don't sort either side.** `copyOf(upstreamTemplate)` +currently runs twice independently in row 3.7. Calling it once and passing the +same directory to both the `runCospec` and `runUpstream` invocations removes the +two-independent-`cpSync` ordering dependency entirely, with `cospec`'s own relay +still exercised as a true passthrough (it does not write into the shared root; +confirmed by reading `instructions.ts`, which never writes files for an +instructions call). + +## Risks / Trade-offs + +- [Item 1: a schema whose `generates` pattern is broad (e.g. `**/*.md`) could + flag a change "building" from an incidental file, such as a stray README + someone dropped in the change directory] → Same risk the binary itself accepts + for its own `hasSchemaOutput`/`artifactOutputExists` (`looksLikeChange`): + cospec is matching the binary's own generosity here, not inventing a new one, + and a schema author controls their own `generates` precision. +- [Item 4: a future binary version could change `realpath`'s Linux behavior, + reopening a real divergence] → The new contract row is differential (compares + live against the pinned binary on both OSes it runs in CI on), not a hardcoded + assertion of "exit 0"/"exit 1" per OS, so a future binary regression would + fail the row rather than passing silently. diff --git a/openspec/changes/list-status-untyped-leftovers/proposal.md b/openspec/changes/list-status-untyped-leftovers/proposal.md new file mode 100644 index 00000000..fd20a971 --- /dev/null +++ b/openspec/changes/list-status-untyped-leftovers/proposal.md @@ -0,0 +1,88 @@ +# Proposal + +## Why + +`cli-surface-parity` (#59) closed most of the `list`/`status`/`validate` surface +gaps, but its own later review rounds left a short tail of already-wrong output +that no later feature PR absorbs. Verified fresh against `main` +(`.claude/handoff/reports/list-status-untyped-leftovers-verify.md`): +`cospec list` misclassifies a change on a schema cospec doesn't type as "no +artifacts yet" whenever that schema names its artifacts under filenames cospec +doesn't recognize, even though the change plainly has one — the same class of +bug `status.ts` fixed at task 11.5 for its own `state`/`next` reporting, never +ported to `list.ts`'s row. Separately, `validate --archived --json` against an +OpenSpec binary below the version floor that supports `--archived` prints stderr +text unconditionally, so a `--json` caller gets no parseable document at all on +an old binary — the version guard immediately above it in the same file branches +on `flags.json` correctly; this one does not. A third item, +`upstream-spellings.test.ts` row 3.7, is flagged as a CI flake on overlayfs: two +independent `remedyNamedRoot()` copies are asserted byte-identical, which only +holds when both sides read the same directory. + +This proposal also closes out a fourth item the stage's own verify report listed +as unfixed — `status`'s handling of a mode-000 artifact other than `tasks.md` — +on the strength of further, end-to-end differential testing against the pinned +binary on both operating systems: it is not reproducible. `hasUnreadableEntry` +(task 11.12) already walks every non-dot file under a change directory, not just +`tasks.md`, so `status`'s existing `binaryDecides` routing already matches the +pinned binary's observed behaviour for any unreadable artifact, on macOS and on +Linux. This proposal adds a differential contract row that pins that behaviour +down (see Design) rather than changing product code that is already correct. + +## What Changes + +- `cospec list`'s `state` column no longer forces `in-progress` ("no artifacts + yet") for a change on a schema cospec doesn't type purely because none of + cospec's own fixed artifact filenames (`proposal.md`, `tasks.md`, …) are + present. It additionally checks whether the change's own declared schema's + `generates` pattern matches a file in the change directory — the same signal + `core/change.ts`'s `hasSchemaOutput` already uses for namespace detection — + and reports `building` when it does. A cospec-typed change's classification is + unchanged. +- `cospec validate --archived --json` against an OpenSpec binary below the + `--archived` version floor now relays its refusal as one JSON document (the + same `rootSelectionDocument` shape the sibling no-root guard already uses), + instead of unconditional stderr text, matching `--json` on every other + `validate` early-exit path. +- `upstream-spellings.test.ts` row 3.7 shares one `remedyNamedRoot()` directory + between its `cospec` and `openspec` instructions calls instead of two + independently-created copies, removing the row's dependency on two separate + `cpSync` calls landing on the same directory-entry order. Neither side sorts + the list (the pinned binary's own `getAvailableChanges` is unsorted and cospec + relays it verbatim), so sharing the root is the fix, not sorting either side. +- A new differential contract row pins `status`'s already-correct handling of a + mode-000 artifact other than `tasks.md` on both macOS and Linux (no product + code change for this item — see Design for the evidence superseding the + stage's verify report). + +## Capabilities + +### New Capabilities + +### Modified Capabilities + + + +## Impact + +- `apps/cli/src/commands/list.ts` — `computeRow`'s emptiness check. +- `apps/cli/src/commands/validate.ts` — the `--archived` version guard. +- `apps/cli/test/contract/upstream-spellings.test.ts` — row 3.7's fixture root. +- `apps/cli/test/unit/commands/commands.test.ts`, + `apps/cli/test/contract/cli-surface.test.ts` — new/updated test rows for all + four items. +- `apps/docs/reference/commands.md` — `cospec list`'s row documents the new + schema-output signal. + +## Surfaces + + + +- [ ] 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) +- [ ] agent-behavior — prompts, tools, model routing, or agent output shape diff --git a/openspec/changes/list-status-untyped-leftovers/tasks.md b/openspec/changes/list-status-untyped-leftovers/tasks.md new file mode 100644 index 00000000..0f87f268 --- /dev/null +++ b/openspec/changes/list-status-untyped-leftovers/tasks.md @@ -0,0 +1,63 @@ +# Tasks + +## 1. `list.ts`: an untyped schema's own artifacts decide its state + +- [ ] 1.1 Add a failing unit test (`commands.test.ts`'s `describe('list', …)`): + a change on a custom schema (`generates: doc.md`) with `doc.md` written + lists `state: 'building'`, not `in-progress`/"no artifacts yet" — red + against current `list.ts` and verify with `bun test`. +- [ ] 1.2 Add `hasDeclaredArtifact(dir, schema, base)` to `list.ts` and wire it + into `computeRow`'s `empty` computation for a schema cospec doesn't type + (design: Decisions). Verify: 1.1's test goes green. Commit + `fix(cli): decide an untyped schema's list state from its own artifacts` +- [ ] 1.3 Add a contract row to `cli-surface.test.ts` reusing the existing + `rfcSchema` fixture: `cospec list --json`'s row for a change with `doc.md` + written reports `state: 'building'`, `archiveReady: false` (an untyped + schema is never archive-ready), compared against the pinned binary's own + `status` key on the same row (`no-tasks`, since `rfc` has no `tasks.md`) + to confirm cospec's native `state` and the binary's own `status` key + coexist without collision. Verify: + `bun test apps/cli/test/contract/cli-surface.test.ts` passes. +- [ ] 1.4 Update `apps/docs/reference/commands.md`'s `cospec list` row to + document the schema-output signal for an untyped schema's `state`. Verify: + `mise run docs:build` succeeds. + +## 2. `validate.ts`: `--archived --json` below the version floor + +- [ ] 2.1 Add a failing unit test exercising the `--archived` version-floor + guard directly (stub `wrappedOpenspecVersion` below `ARCHIVED_SINCE`, as + the existing `openspecBelow` unit tests do) asserting `--json` prints one + parseable document, not stderr text — red against current `validate.ts`. +- [ ] 2.2 Branch the guard on `flags.json` exactly as the no-root guard four + lines above it does, reusing `rootSelectionDocument`. Verify: 2.1's test + goes green. Commit + `fix(cli): relay validate --archived's version-floor refusal as JSON` + +## 3. `upstream-spellings.test.ts` row 3.7: one shared root + +- [ ] 3.1 Change row 3.7 to call `remedyNamedRoot()` once and pass the same + directory to both the `runCospec` and `runUpstream` invocations, removing + the two-independent-copy ordering dependency (design: Decisions). Verify: + `bun test apps/cli/test/contract/upstream-spellings.test.ts -t '3.7'` + passes, and 20 repeated local runs show no flake. + +## 4. `status`: pin the mode-000-artifact behavior (no product change) + +- [ ] 4.1 Add a differential contract row to `cli-surface.test.ts`: a mode-000 + artifact other than `tasks.md` (e.g. `proposal.md`) on a cospec-typed + change, `cospec status --change ` compared against the pinned binary + in both text and `--json` mode — same exit code, same reported + artifact-done state either way the runtime's `realpath` happens to answer + it (design: Context item 4; pattern after rows 15.11/15.12, branching on + the observed behavior, never on `process.platform`). Verify: + `bun test apps/cli/test/contract/cli-surface.test.ts` passes locally + (macOS) and in CI (Linux). +- [ ] 4.2 Record in this change's verification ledger that both OS observations + were differential, with no product change (design: Context item 4 + documents the evidence superseding the stage's verify report). + +## 5. Close out + +- [ ] 5.1 `mise run check` green (lint, format, typecheck, unit, contract, + integration, pack smoke). +- [ ] 5.2 the archive commit follows this one diff --git a/openspec/changes/list-status-untyped-leftovers/verification.md b/openspec/changes/list-status-untyped-leftovers/verification.md new file mode 100644 index 00000000..2742e606 --- /dev/null +++ b/openspec/changes/list-status-untyped-leftovers/verification.md @@ -0,0 +1,28 @@ +# Verification + +## 1. `list` reports `building` for an untyped schema's own artifact [critical] + +- [ ] 1.1 @unit (agent) a change on a custom `rfc`-style schema (`generates: doc.md`) with `doc.md` written -> `computeRow`/`list.ts` reports `state: 'building'`, not `in-progress` +- [ ] 1.2 @regression (agent) the same fixture against `list.ts` before the fix FAILS (`state: 'in-progress'`), after the fix PASSES (`state: 'building'`) -> red-then-green captured in the commit that lands 1.2 +- [ ] 1.3 @integration (agent) `cospec list --json` vs the pinned binary's `openspec list --json` on the same `rfc`-schema fixture: no key collision between cospec's native `state` and the binary's own `status`, `archiveReady: false` -> `cli-surface.test.ts` passes +- [ ] 1.4 @e2e (agent) `mise run docs:build` after the `commands.md` edit -> build succeeds + +## 2. `validate --archived --json` below the version floor prints a document + +- [ ] 2.1 @unit (agent) `wrappedOpenspecVersion` stubbed below `ARCHIVED_SINCE`, `validate --archived --json` -> one parseable JSON document on stdout, not stderr text +- [ ] 2.2 @regression (agent) the same unit test before the fix FAILS (stderr text, unparseable stdout), after the fix PASSES -> captured in the commit that lands 2.2 + +## 3. `upstream-spellings.test.ts` row 3.7 is deterministic + +- [ ] 3.1 @integration (agent) row 3.7 against a shared `remedyNamedRoot()` root, run 20x locally -> no divergence in any run +- [ ] 3.2 @e2e (agent) row 3.7 green on both `ubuntu-latest` and `macos` runners in this PR's CI -> both green + +## 4. `status` already matches the binary on a mode-000 artifact [critical] + +- [ ] 4.1 @equivalence (agent) macOS: `cospec status --change ` (text and `--json`, single and `--all`) vs the pinned binary (spawned under Bun) with a mode-000 `proposal.md` -> both refuse `EACCES: permission denied, realpath '…/proposal.md'`, exit 1, in every mode +- [ ] 4.2 @equivalence (agent) Linux (`oven/bun:1.3.14`, non-root UID 1000): same fixture -> both read past it, report `proposal` done, exit 0, in every mode +- [ ] 4.3 @e2e (agent) the new contract row (tasks.md 4.1) green on both `ubuntu-latest` and `macos` runners in this PR's CI -> both green, confirming the differential holds under the real CI containers too, not just the local Docker probe + +## 5. Full gate + +- [ ] 5.1 @e2e (agent) `mise run check` -> green From 5b611bf634949920470eeaeced96b93dfdf4c72d Mon Sep 17 00:00:00 2001 From: replygirl Date: Mon, 5 Oct 2026 02:15:28 -0500 Subject: [PATCH 02/15] fix(cli): decide an untyped schema's list state from its own artifacts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit computeRow derived `state` from hasAnyArtifact, which only recognizes cospec's own fixed artifact filenames, never gated on isCospecType — so a custom/forked schema whose artifacts live under other filenames always read "no artifacts yet" even with a written artifact on disk (the same misclassification task 11.5 fixed for status's state/next). A schema cospec doesn't type now additionally checks its own declared schema's generates pattern against the change directory (hasDeclaredArtifact, mirroring core/change.ts's hasSchemaOutput), so it reads `building` once a file that pattern names exists. Co-Authored-By: Claude Sonnet 5 --- apps/cli/src/commands/list.ts | 34 +++++++- apps/cli/test/contract/cli-surface.test.ts | 81 +++++++++++++++++++ apps/cli/test/unit/commands/commands.test.ts | 39 +++++++++ apps/docs/reference/commands.md | 2 +- .../list-status-untyped-leftovers/tasks.md | 8 +- .../verification.md | 8 +- 6 files changed, 162 insertions(+), 10 deletions(-) diff --git a/apps/cli/src/commands/list.ts b/apps/cli/src/commands/list.ts index 9896eda0..28e6105e 100644 --- a/apps/cli/src/commands/list.ts +++ b/apps/cli/src/commands/list.ts @@ -20,6 +20,7 @@ import { join } from 'node:path' import type { CommandContext } from '../cli.ts' import { EXIT } from '../cli.ts' import { parseBlockers } from '../core/blockers.ts' +import { loadSchema } from '../core/change-metadata.ts' import { changesDir, findNestedChangesIn, @@ -28,6 +29,7 @@ import { readOpenspecYaml, } from '../core/change.ts' import { flagValue, hasFlag } from '../core/command-table.ts' +import { artifactOutputExists } from '../core/glob.ts' import { OpenspecCallError, passthroughOpenspec, @@ -172,6 +174,31 @@ function nativeRow( } } +/** + * Whether `dir` holds a file its own declared schema's `generates` pattern + * names — the only signal cospec has for an artifact it doesn't recognize by + * name (mirrors `core/change.ts`'s `hasSchemaOutput`, scoped to the change's + * own `.openspec.yaml` schema rather than the project's default, since a + * schema-bearing change always names its own). A schema cospec cannot load + * gives no signal, as the binary's own `list` never loads a schema either + * (its row is task-progress-only; `dist/core/list.js`). + */ +function hasDeclaredArtifact(dir: string, schema: string, base: string): boolean { + let artifacts: { generates: string }[] + try { + artifacts = loadSchema(schema, base) + } catch { + return false + } + try { + return artifacts.some((artifact) => artifactOutputExists(dir, artifact.generates)) + } catch { + // upstream's bare `catch` on an output it cannot resolve (one leaving + // the change, a linked directory cycle): no signal. + return false + } +} + function computeRow( base: string, id: string, @@ -187,8 +214,13 @@ function computeRow( ? computeGate(parseBlockers(readFileSync(blockersPath, 'utf8')), archived, active) : ({ state: 'clear', hard: [], soft: [] } satisfies Gate) - const empty = !hasAnyArtifact(dir) const cospec = isCospecType(schema) + // cospec's fixed artifact filenames are the only signal for a cospec-typed + // change; a schema cospec doesn't type additionally gets its own declared + // schema's `generates` signal, so a custom-named artifact cospec doesn't + // recognize by filename is never reported as no artifacts at all (the + // misclassification task 11.5 fixed for `status`'s `state`/`next`). + const empty = !hasAnyArtifact(dir) && (cospec || !hasDeclaredArtifact(dir, schema, base)) const parsedTasks = readChangeTasks(dir, warnings) const total = parsedTasks.items.length diff --git a/apps/cli/test/contract/cli-surface.test.ts b/apps/cli/test/contract/cli-surface.test.ts index bfd90838..38018811 100644 --- a/apps/cli/test/contract/cli-surface.test.ts +++ b/apps/cli/test/contract/cli-surface.test.ts @@ -2768,6 +2768,87 @@ describe('17. round-4 review rows', () => { }) }) +// --- 18. list-status-untyped-leftovers ------------------------------------------------------ + +describe('18. list-status-untyped-leftovers', () => { + test("18.1 list reports building for an untyped schema's own declared artifact", async () => { + const root = cospecRoot() + rfcSchema(root) + writeChange(root, 'r-doc', { 'doc.md': '# RFC\n' }, 'rfc') + writeChange(root, 'r-empty', {}, 'rfc') + const up = await upstreamJson(['list', '--json'], root) + const cs = await oursJson(['list', '--json'], root) + expect(cs.exitCode).toBe(up.exitCode) + const row = rowsOf(cs.json).find((r) => r.change === 'r-doc')! + expect(row.state).toBe('building') + expect(row.archiveReady).toBe(false) + const empty = rowsOf(cs.json).find((r) => r.change === 'r-empty')! + expect(empty.state).toBe('in-progress') + // cospec's native `state` and the binary's own task-count-only `status` + // coexist on the same row without a key collision. + const upRow = rowsOf(up.json).find((r) => r.name === 'r-doc')! + expect(upRow.status).toBe('no-tasks') + expect(row.status).toBe('no-tasks') + const text = await ours(['list'], root) + expect(text.stdout).toMatch(/r-doc\s+rfc\s+clear\s+0\/0 tasks\s+$/m) + }) + + unlessRoot('mode 000', () => { + test('18.2 status: a mode-000 artifact other than tasks.md already answers as the binary does', async () => { + const root = listFixture() + const proposal = join(root, 'openspec/changes/alpha/proposal.md') + const restore = lock(proposal) + try { + const refused = realpathRefuses(proposal) + for (const argv of [ + ['status', '--change', 'alpha', '--json'], + ['status', '--all', '--json'], + ]) { + const up = await upstreamJson(argv, root) + const cs = await oursJson(argv, root) + captureStatus(`18.2 ${argv.join(' ')}`, cs) + expect({ argv, exit: up.exitCode }).toEqual({ argv, exit: refused ? 1 : 0 }) + expect({ argv, exit: cs.exitCode }).toEqual({ argv, exit: up.exitCode }) + const textArgv = argv.filter((a) => a !== '--json') + const upText = await upstream(textArgv, root) + const text = await ours(textArgv, root) + captureStatus(`18.2 ${textArgv.join(' ')}`, text) + expect({ textArgv, exit: text.exitCode }).toEqual({ textArgv, exit: upText.exitCode }) + if (refused) { + const d = + argv[1] === '--all' + ? firstStatus(rowsOf(up.json).find((e) => e.changeName === 'alpha')) + : firstStatus(up.json) + expect(errnoShape(d.message)).toMatchObject({ + code: 'EACCES', + path: join(realpathSync(dirname(proposal)), 'proposal.md'), + }) + continue + } + // The binary reads past it and counts `proposal` done; so does cospec. + const upDone = + argv[1] === '--all' + ? ((rowsOf(up.json).find((e) => e.changeName === 'alpha')!.artifacts as Row[]).find( + (a) => a.id === 'proposal', + )!.status as string) === 'done' + : ((up.json as Row).artifacts as Row[]).find((a) => a.id === 'proposal')!.status === + 'done' + const csDone = + argv[1] === '--all' + ? (rowsOf(cs.json).find((e) => e.change === 'alpha')!.artifacts as Row[]).find( + (a) => a.id === 'proposal', + )!.done + : ((cs.json as Row).artifacts as Row[]).find((a) => a.id === 'proposal')!.done + expect(csDone).toBe(upDone) + expect(csDone).toBe(true) + } + } finally { + restore() + } + }) + }) +}) + // --- 5.6 no status output names a bare openspec command ------------------------------------ describe('5.6 status outputs', () => { diff --git a/apps/cli/test/unit/commands/commands.test.ts b/apps/cli/test/unit/commands/commands.test.ts index 2de11624..01112f45 100644 --- a/apps/cli/test/unit/commands/commands.test.ts +++ b/apps/cli/test/unit/commands/commands.test.ts @@ -664,6 +664,45 @@ describe('list', () => { expect(r.out).toContain('archive-ready') }) + test("an untyped schema's own declared artifact decides its state, not cospec's fixed filenames", async () => { + const cwd = repo() + // A schema cospec doesn't type, whose artifact lives under a filename + // none of cospec's own (proposal.md, tasks.md, ...) match. + mkdirSync(join(cwd, 'openspec', 'schemas', 'rfc', 'templates'), { recursive: true }) + writeFileSync( + join(cwd, 'openspec', 'schemas', 'rfc', 'schema.yaml'), + [ + 'name: rfc', + 'version: 1', + 'description: An rfc-style schema', + 'artifacts:', + ' - id: doc', + ' generates: doc.md', + ' description: The RFC document', + ' template: doc.md', + ' instruction: Write the RFC.', + ' requires: []', + 'apply:', + ' requires: [doc]', + ' tracks: null', + '', + ].join('\n'), + ) + writeFileSync(join(cwd, 'openspec', 'schemas', 'rfc', 'templates', 'doc.md'), '# Doc\n') + writeChange(cwd, 'r-doc', 'rfc', { 'doc.md': '# RFC\n' }) + writeChange(cwd, 'r-empty', 'rfc') + + const r = await runCmd(listRun, ctx(cwd, [], { json: true, command: 'list' })) + expect(r.code).toBe(0) + const parsed = JSON.parse(r.out) as { changes: { change: string; state: string }[] } + expect(parsed.changes.find((c) => c.change === 'r-doc')?.state).toBe('building') + expect(parsed.changes.find((c) => c.change === 'r-empty')?.state).toBe('in-progress') + + const text = await runCmd(listRun, ctx(cwd, [], { command: 'list' })) + expect(text.out).toMatch(/r-doc\s+rfc/) + expect(text.out).not.toMatch(/r-doc\s+rfc\s+clear\s+no artifacts yet/) + }) + test('--blocked filters to gated changes', async () => { const cwd = repo() writeChange(cwd, 'clear-one', 'ci', { diff --git a/apps/docs/reference/commands.md b/apps/docs/reference/commands.md index 26a184c2..b8f19937 100644 --- a/apps/docs/reference/commands.md +++ b/apps/docs/reference/commands.md @@ -111,7 +111,7 @@ the binary as the item name. | `cospec migrate ` | Opt-in: stamp a change created under an older `schemaVersion` to the current one, scaffolding a fully-deferred `verification.md` where the type requires it. Never runs automatically. Under `--json`, one document `{change, schemaVersion, migrated, verificationScaffolded}` on both paths — `migrated: false` when the change is already current. | — | [Verification](/concepts/verification) | | `cospec validate [name]` | Validate one or all changes and specs against cospec's rules. A name is resolved as OpenSpec resolves it: `--type` forces the kind; a name that is both a change and a living spec is refused (`ambiguous_item`) and one that is neither gets OpenSpec's nearest matches (`unknown_item`); a bulk flag beside a name runs the bulk scope and ignores the name. `--report findings` prints only the items with findings (the exit code is still the full report's); `--concurrency` bounds the change validations run at once. `--json` carries OpenSpec's `root`, `items[].durationMs` and `summary.totals`/`byType` beside cospec's keys, `version` stays `1`, and an item's `type` stays the change's schema while `kind` carries OpenSpec's `change`/`spec` — see [Validation rules](/reference/validation-rules#output-shape). An unreadable artifact — a change file, the living `spec.md` a delta targets, or a living `spec.md` itself — is a `meta/unreadable-artifact` ERROR (a directory no artifact lives in, a dot-directory or one outside `specs/`, is passed by, as OpenSpec passes it by), a namespace folder a `meta/nested-change` ERROR, and a relayed OpenSpec message names `cospec`, never bare `openspec`. OpenSpec's own validation of an item is asked for by kind (`--type change\|spec`), so a change sharing a living spec's name is still validated as a change; when OpenSpec refuses an item instead of reporting it, its refusal is that item's `openspec/validate` ERROR, never an empty pass. `--strict` fails a spec with a warning in `valid` and `summary.totals`, as OpenSpec does. `--type spec` on a spec discovery skips (a dot-directory, a capability behind a linked directory) validates that file as OpenSpec does. An unreadable `openspec/changes/archive/` validates as if nothing were archived, with a warning (`archive_unreadable` in the document's `warnings`, `Warning:` on stderr). With no `openspec/` directory a name alone is resolved as OpenSpec resolves it and, matching nothing, is `unknown_item`; any other `--json` invocation there is OpenSpec's one `no_openspec_root` document, exit `1`. An unreadable `openspec/changes/`, `openspec/specs/` or capability directory is one `validate_error` document under `--json`; `--archived` relays OpenSpec's own failure document (or its message in text) with its exit code. **BREAKING:** `validate --all\|--changes\|--specs` validates the bulk scope, not the one item; an ambiguous name is refused and an unknown one prints OpenSpec's message. | `--strict` (promote warnings to errors), `--all`, `--changes`, `--specs`, `--archived`, `--type `, `--report `, `--concurrency ` (else `OPENSPEC_CONCURRENCY`, else 6), `--fast`, `--no-interactive` | [Validation rules](/reference/validation-rules) | | `cospec status --change ` | Per-artifact completion, the blocker gate state, and archive-readiness for one change; `--all` sweeps every active change instead of one. Every entry names its next step — `next` under `--json`, a `Next:` line in text: the first ready artifact the change requires, else `cospec apply ` once every required one is done, else the first ready optional one. `--json` also carries every key OpenSpec's own `status --json` does (`changeName`, `schemaName`, `planningHome`, `changeRoot`, `artifactPaths`, `isPlanningComplete`, `isComplete`, `applyRequires`, `nextSteps` spelled `cospec`, `actionContext`, `root`, and each artifact's `outputPath`/`status`/`requires`), from one delegated call. `--schema ` is OpenSpec's schema override, not a filter: every change is reported as that schema, and an unknown name is refused with OpenSpec's `Schema '' not found` before the sweep enumerates or the named change is reported. A change whose schema isn't a cospec type (a fork, `spec-driven`, or a name that resolves nowhere) is answered from OpenSpec's own status document, rendered as OpenSpec renders it in text, with OpenSpec's exit code. A change is looked up as OpenSpec looks it up: a directory under `openspec/changes/` (a regular file of that name is no change) whose name OpenSpec accepts — no path separator, no leading dot, not `archive` — kebab-case or not. A change directory with no `.openspec.yaml` takes the root's `config.yaml` `schema:` (else `spec-driven`) at `schemaVersion` 1. A cospec-typed change with no artifacts yet is `state: in-progress` with `artifacts: []`, never filled with OpenSpec's artifact objects. A namespace folder is refused (`--change`) or a failure entry (`--all`), exit `1`. An unreadable `openspec/changes/archive/` computes the gate from an empty index with a warning (`archive_unreadable` under `--json`). A change OpenSpec refuses is refused: any error in OpenSpec's status for it is the answer — its `change_error` document under `--json`, its message in text, a failure entry under `--all` — and text mode asks OpenSpec too for a change cospec can't read every entry of, whose `.openspec.yaml` OpenSpec refuses (unreadable, not YAML, naming a schema OpenSpec doesn't list, or failing OpenSpec's metadata schema: a `created` that isn't `YYYY-MM-DD`, an empty `goal`, a non-boolean `skip_specs` or `retire_capabilities`, an `affected_areas` that isn't a list of non-empty strings, an `initiative` that isn't exactly `{store, id}` in kebab-case), or whose schema OpenSpec can't load (missing, unreadable, unparsable or invalid). So a cospec-typed change whose schema was removed from `openspec/schemas/` is refused with OpenSpec's `Unknown schema` message in text as under `--json`, one whose `created` is malformed with OpenSpec's `Invalid metadata` message, and an unreadable change directory is refused, as is, under Bun on macOS, an unreadable file in it; elsewhere OpenSpec reads past the file, and an unreadable `tasks.md` is counted as no tasks with a warning (`tasks_unreadable`). Any other read failure is a `change_error` document, an unreadable `openspec/changes/` included (`{changes: [], root: null, status}` under `--all`). Every OpenSpec message status relays, in text or in `status[]`, is spelled `cospec`. **BREAKING:** `root` is OpenSpec's `{path, source}` object, not a path string; a namespace folder makes `status` exit `1`; `--json` on a schema cospec doesn't type exits `1` when OpenSpec does; a cospec-typed change whose schema OpenSpec can't load, or whose `.openspec.yaml` OpenSpec refuses, exits `1`, in text and `--json`; a directory without `.openspec.yaml` is typed by `config.yaml`. | `--change `, `--all`, `--schema ` | [Apply and archive](/concepts/apply-and-archive) | -| `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. 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 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`. 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) | diff --git a/openspec/changes/list-status-untyped-leftovers/tasks.md b/openspec/changes/list-status-untyped-leftovers/tasks.md index 0f87f268..d62557c8 100644 --- a/openspec/changes/list-status-untyped-leftovers/tasks.md +++ b/openspec/changes/list-status-untyped-leftovers/tasks.md @@ -2,15 +2,15 @@ ## 1. `list.ts`: an untyped schema's own artifacts decide its state -- [ ] 1.1 Add a failing unit test (`commands.test.ts`'s `describe('list', …)`): +- [x] 1.1 Add a failing unit test (`commands.test.ts`'s `describe('list', …)`): a change on a custom schema (`generates: doc.md`) with `doc.md` written lists `state: 'building'`, not `in-progress`/"no artifacts yet" — red against current `list.ts` and verify with `bun test`. -- [ ] 1.2 Add `hasDeclaredArtifact(dir, schema, base)` to `list.ts` and wire it +- [x] 1.2 Add `hasDeclaredArtifact(dir, schema, base)` to `list.ts` and wire it into `computeRow`'s `empty` computation for a schema cospec doesn't type (design: Decisions). Verify: 1.1's test goes green. Commit `fix(cli): decide an untyped schema's list state from its own artifacts` -- [ ] 1.3 Add a contract row to `cli-surface.test.ts` reusing the existing +- [x] 1.3 Add a contract row to `cli-surface.test.ts` reusing the existing `rfcSchema` fixture: `cospec list --json`'s row for a change with `doc.md` written reports `state: 'building'`, `archiveReady: false` (an untyped schema is never archive-ready), compared against the pinned binary's own @@ -18,7 +18,7 @@ to confirm cospec's native `state` and the binary's own `status` key coexist without collision. Verify: `bun test apps/cli/test/contract/cli-surface.test.ts` passes. -- [ ] 1.4 Update `apps/docs/reference/commands.md`'s `cospec list` row to +- [x] 1.4 Update `apps/docs/reference/commands.md`'s `cospec list` row to document the schema-output signal for an untyped schema's `state`. Verify: `mise run docs:build` succeeds. diff --git a/openspec/changes/list-status-untyped-leftovers/verification.md b/openspec/changes/list-status-untyped-leftovers/verification.md index 2742e606..0eb86478 100644 --- a/openspec/changes/list-status-untyped-leftovers/verification.md +++ b/openspec/changes/list-status-untyped-leftovers/verification.md @@ -2,10 +2,10 @@ ## 1. `list` reports `building` for an untyped schema's own artifact [critical] -- [ ] 1.1 @unit (agent) a change on a custom `rfc`-style schema (`generates: doc.md`) with `doc.md` written -> `computeRow`/`list.ts` reports `state: 'building'`, not `in-progress` -- [ ] 1.2 @regression (agent) the same fixture against `list.ts` before the fix FAILS (`state: 'in-progress'`), after the fix PASSES (`state: 'building'`) -> red-then-green captured in the commit that lands 1.2 -- [ ] 1.3 @integration (agent) `cospec list --json` vs the pinned binary's `openspec list --json` on the same `rfc`-schema fixture: no key collision between cospec's native `state` and the binary's own `status`, `archiveReady: false` -> `cli-surface.test.ts` passes -- [ ] 1.4 @e2e (agent) `mise run docs:build` after the `commands.md` edit -> build succeeds +- [x] 1.1 @unit (agent) a change on a custom `rfc`-style schema (`generates: doc.md`) with `doc.md` written -> `computeRow`/`list.ts` reports `state: 'building'`, not `in-progress` — observed: `commands.test.ts` "an untyped schema's own declared artifact decides its state, not cospec's fixed filenames" passes +- [x] 1.2 @regression (agent) the same fixture against `list.ts` before the fix FAILS (`state: 'in-progress'`), after the fix PASSES (`state: 'building'`) -> red-then-green captured in the commit that lands 1.2 — observed: red with `Received: "in-progress"` against unmodified `list.ts`, green after `hasDeclaredArtifact` landed +- [x] 1.3 @integration (agent) `cospec list --json` vs the pinned binary's `openspec list --json` on the same `rfc`-schema fixture: no key collision between cospec's native `state` and the binary's own `status`, `archiveReady: false` -> `cli-surface.test.ts` passes — observed: `18.1 list reports building for an untyped schema's own declared artifact` passes, `row.status === upRow.status === 'no-tasks'` +- [x] 1.4 @e2e (agent) `mise run docs:build` after the `commands.md` edit -> build succeeds — observed: `build complete in 1.94s` ## 2. `validate --archived --json` below the version floor prints a document From f93b876183e9eddf3a044bb72c08af53c78bc4c9 Mon Sep 17 00:00:00 2001 From: replygirl Date: Mon, 5 Oct 2026 02:17:53 -0500 Subject: [PATCH 03/15] fix(cli): relay validate --archived's version-floor refusal as JSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The --archived version-floor guard wrote to stderr unconditionally, never branching on flags.json like the no-root guard four lines above it — so `validate --archived --json` against an OpenSpec binary below ARCHIVED_SINCE printed no parseable document at all. Factored the refusal into archivedUnsupportedRefusal(version), a pure function unit- testable without a fake binary, and wired it through rootSelectionDocument exactly as every other early-exit refusal in this file. Co-Authored-By: Claude Sonnet 5 --- apps/cli/src/commands/validate.ts | 24 ++++++++++--- apps/cli/test/unit/commands/validate.test.ts | 34 +++++++++++++++++++ .../list-status-untyped-leftovers/tasks.md | 4 +-- .../verification.md | 4 +-- 4 files changed, 57 insertions(+), 9 deletions(-) diff --git a/apps/cli/src/commands/validate.ts b/apps/cli/src/commands/validate.ts index 0771f15a..2d60f954 100644 --- a/apps/cli/src/commands/validate.ts +++ b/apps/cli/src/commands/validate.ts @@ -1143,6 +1143,21 @@ async function validateForcedSpec(root: Root, id: string, strict: boolean): Prom /** The first openspec release whose `validate` takes `--archived`. */ const ARCHIVED_SINCE = '1.9.0' +/** + * `validate --archived`'s refusal when the wrapped OpenSpec is below + * `ARCHIVED_SINCE`, or `undefined` when it isn't. A pure function of the + * version string (never spawns), so it is unit-testable without a fake + * binary: the command's own `--json`/text branch (`rootSelectionDocument` or + * `cospec: `) matches every other early-exit refusal in this file. + */ +export function archivedUnsupportedRefusal(version: string): RootSelectionError | undefined { + if (!openspecBelow(version, ARCHIVED_SINCE)) return undefined + return new RootSelectionError({ + code: 'openspec_version_too_old', + message: `validate --archived needs OpenSpec >=${ARCHIVED_SINCE}; the wrapped OpenSpec is ${version}`, + }) +} + /** * The binary's answer to `validate --archived`: its report's items, or its * failure document (an unreadable `changes/archive/`, say) with its exit code. @@ -1552,11 +1567,10 @@ async function validate(ctx: CommandContext): Promise { // it must never quietly alter an ordinary invocation. if (wantArchived) { const version = await wrappedOpenspecVersion() - if (openspecBelow(version, ARCHIVED_SINCE)) { - process.stderr.write( - `cospec: validate --archived needs OpenSpec >=${ARCHIVED_SINCE}; the wrapped OpenSpec is ` + - `${version}\n`, - ) + const refusal = archivedUnsupportedRefusal(version) + if (refusal !== undefined) { + if (flags.json) process.stdout.write(rootSelectionDocument(refusal)) + else process.stderr.write(`cospec: ${refusal.diagnostic.message}\n`) return 1 } const archived = await validateArchived(root) diff --git a/apps/cli/test/unit/commands/validate.test.ts b/apps/cli/test/unit/commands/validate.test.ts index 3ad9b169..f35ba622 100644 --- a/apps/cli/test/unit/commands/validate.test.ts +++ b/apps/cli/test/unit/commands/validate.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'bun:test' import { + archivedUnsupportedRefusal, erroredChange, concurrencyBound, mapPool, @@ -9,6 +10,7 @@ import { TARGET_INVALID_HEAD, TARGET_INVALID_LINE, } from '../../../src/commands/validate.ts' +import { rootSelectionDocument } from '../../../src/core/root.ts' import type { Issue } from '../../../src/core/rules/issue.ts' // mergeDelegated's DUPLICATE_CLASSES table drops a delegated (openspec/validate) @@ -374,3 +376,35 @@ describe('a change whose validation throws (verification 16.3)', () => { expect(() => erroredChange('/r', 'c1', error)).toThrow(error) }) }) + +describe("archivedUnsupportedRefusal: validate --archived's version-floor guard", () => { + test('no refusal at or above the floor', () => { + expect(archivedUnsupportedRefusal('1.9.0')).toBeUndefined() + expect(archivedUnsupportedRefusal('1.13.1')).toBeUndefined() + }) + + test('a document under --json below the floor, not stderr text', () => { + const refusal = archivedUnsupportedRefusal('1.8.0') + expect(refusal).toBeDefined() + expect(refusal!.diagnostic.message).toBe( + 'validate --archived needs OpenSpec >=1.9.0; the wrapped OpenSpec is 1.8.0', + ) + // The command's --json branch calls rootSelectionDocument(refusal), exactly + // as its sibling no-root guard does: one parseable JSON document, never the + // unconditional stderr text the pre-fix guard wrote regardless of --json. + const doc = JSON.parse(rootSelectionDocument(refusal!)) as { + status: { severity: string; code: string; message: string }[] + } + expect(doc.status).toEqual([ + { + severity: 'error', + code: 'openspec_version_too_old', + message: 'validate --archived needs OpenSpec >=1.9.0; the wrapped OpenSpec is 1.8.0', + }, + ]) + }) + + test('an unparseable version is never judged too old (drift allowed)', () => { + expect(archivedUnsupportedRefusal('not-a-version')).toBeUndefined() + }) +}) diff --git a/openspec/changes/list-status-untyped-leftovers/tasks.md b/openspec/changes/list-status-untyped-leftovers/tasks.md index d62557c8..02d3c27e 100644 --- a/openspec/changes/list-status-untyped-leftovers/tasks.md +++ b/openspec/changes/list-status-untyped-leftovers/tasks.md @@ -24,11 +24,11 @@ ## 2. `validate.ts`: `--archived --json` below the version floor -- [ ] 2.1 Add a failing unit test exercising the `--archived` version-floor +- [x] 2.1 Add a failing unit test exercising the `--archived` version-floor guard directly (stub `wrappedOpenspecVersion` below `ARCHIVED_SINCE`, as the existing `openspecBelow` unit tests do) asserting `--json` prints one parseable document, not stderr text — red against current `validate.ts`. -- [ ] 2.2 Branch the guard on `flags.json` exactly as the no-root guard four +- [x] 2.2 Branch the guard on `flags.json` exactly as the no-root guard four lines above it does, reusing `rootSelectionDocument`. Verify: 2.1's test goes green. Commit `fix(cli): relay validate --archived's version-floor refusal as JSON` diff --git a/openspec/changes/list-status-untyped-leftovers/verification.md b/openspec/changes/list-status-untyped-leftovers/verification.md index 0eb86478..960983cd 100644 --- a/openspec/changes/list-status-untyped-leftovers/verification.md +++ b/openspec/changes/list-status-untyped-leftovers/verification.md @@ -9,8 +9,8 @@ ## 2. `validate --archived --json` below the version floor prints a document -- [ ] 2.1 @unit (agent) `wrappedOpenspecVersion` stubbed below `ARCHIVED_SINCE`, `validate --archived --json` -> one parseable JSON document on stdout, not stderr text -- [ ] 2.2 @regression (agent) the same unit test before the fix FAILS (stderr text, unparseable stdout), after the fix PASSES -> captured in the commit that lands 2.2 +- [x] 2.1 @unit (agent) `wrappedOpenspecVersion` stubbed below `ARCHIVED_SINCE`, `validate --archived --json` -> one parseable JSON document on stdout, not stderr text — observed: factored `archivedUnsupportedRefusal(version)` (pure, no spawn) unit-tested directly; `validate.test.ts` "archivedUnsupportedRefusal" passes, `rootSelectionDocument(refusal)` parses to one `status[]` document +- [x] 2.2 @regression (agent) the same unit test before the fix FAILS (stderr text, unparseable stdout), after the fix PASSES -> captured in the commit that lands 2.2 — observed: `archivedUnsupportedRefusal` did not exist before this commit (the guard wrote stderr text unconditionally inline); the test file would not compile against unmodified `validate.ts`, green once the export landed ## 3. `upstream-spellings.test.ts` row 3.7 is deterministic From 5213b6615248f9e272d43aaab089b91f88caa28d Mon Sep 17 00:00:00 2001 From: replygirl Date: Mon, 5 Oct 2026 02:22:13 -0500 Subject: [PATCH 04/15] fix(cli): share one root between row 3.7's cospec and binary calls Row 3.7 asserted byte-identical stdout/stderr between a cospec and an openspec instructions call, each over its own independently-created remedyNamedRoot() copy. The pinned binary's own getAvailableChanges is unsorted and cospec relays it verbatim, so the assertion only holds when both calls observe the same on-disk directory-entry order, which two separate cpSync copies don't guarantee on every filesystem (overlayfs in particular). Call remedyNamedRoot() once and pass the same directory to both calls instead of sorting either side. Co-Authored-By: Claude Sonnet 5 --- .../cli/test/contract/upstream-spellings.test.ts | 16 ++++++++++++---- .../list-status-untyped-leftovers/tasks.md | 2 +- .../verification.md | 2 +- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/apps/cli/test/contract/upstream-spellings.test.ts b/apps/cli/test/contract/upstream-spellings.test.ts index 5f3f7b70..1bde8a6e 100644 --- a/apps/cli/test/contract/upstream-spellings.test.ts +++ b/apps/cli/test/contract/upstream-spellings.test.ts @@ -991,8 +991,15 @@ describe('3.7 an instructions failure is the binary answer, rendered from its do for (const asJson of [false, true]) { const full = [...argv, ...(asJson ? ['--json'] : [])] test(`${full.join(' ')}: the listed change names are the binary's bytes`, async () => { - const c = await runCospec(full, remedyNamedRoot()) - const u = await runUpstream(full, remedyNamedRoot()) + // One shared root for both calls: the binary's own directory listing + // (dist's getAvailableChanges) is unsorted and cospec relays it + // verbatim, so two independently-created copies can land on different + // on-disk entry orders (overlayfs) even though neither side sorts — + // sharing the root removes that dependency instead of asserting an + // order either side doesn't guarantee. + const root = remedyNamedRoot() + const c = await runCospec(full, root) + const u = await runUpstream(full, root) expect(u.exitCode, detail('openspec', u)).toBe(1) for (const name of REMEDY_SHAPED_CHANGES) expect(asJson ? statusMessage(json(u)) : u.stderr).toContain(`\n ${name}`) @@ -1010,8 +1017,9 @@ describe('3.7 an instructions failure is the binary answer, rendered from its do for (const asJson of [false, true]) { const argv = ['instructions', 'proposal', '--change', name, ...(asJson ? ['--json'] : [])] test(`a listed name copied back resolves: ${JSON.stringify(argv)}`, async () => { - const c = await runCospec(argv, remedyNamedRoot()) - const u = await runUpstream(argv, remedyNamedRoot()) + const root = remedyNamedRoot() + const c = await runCospec(argv, root) + const u = await runUpstream(argv, root) expect(u.exitCode, detail('openspec', u)).toBe(0) expect(c.exitCode, detail('cospec', c)).toBe(0) if (asJson) expect(json(c)['changeName']).toBe(name) diff --git a/openspec/changes/list-status-untyped-leftovers/tasks.md b/openspec/changes/list-status-untyped-leftovers/tasks.md index 02d3c27e..234d1319 100644 --- a/openspec/changes/list-status-untyped-leftovers/tasks.md +++ b/openspec/changes/list-status-untyped-leftovers/tasks.md @@ -35,7 +35,7 @@ ## 3. `upstream-spellings.test.ts` row 3.7: one shared root -- [ ] 3.1 Change row 3.7 to call `remedyNamedRoot()` once and pass the same +- [x] 3.1 Change row 3.7 to call `remedyNamedRoot()` once and pass the same directory to both the `runCospec` and `runUpstream` invocations, removing the two-independent-copy ordering dependency (design: Decisions). Verify: `bun test apps/cli/test/contract/upstream-spellings.test.ts -t '3.7'` diff --git a/openspec/changes/list-status-untyped-leftovers/verification.md b/openspec/changes/list-status-untyped-leftovers/verification.md index 960983cd..ffb4ef24 100644 --- a/openspec/changes/list-status-untyped-leftovers/verification.md +++ b/openspec/changes/list-status-untyped-leftovers/verification.md @@ -14,7 +14,7 @@ ## 3. `upstream-spellings.test.ts` row 3.7 is deterministic -- [ ] 3.1 @integration (agent) row 3.7 against a shared `remedyNamedRoot()` root, run 20x locally -> no divergence in any run +- [x] 3.1 @integration (agent) row 3.7 against a shared `remedyNamedRoot()` root, run 20x locally -> no divergence in any run — observed: `12 pass, 0 fail` identically across 20/20 local runs - [ ] 3.2 @e2e (agent) row 3.7 green on both `ubuntu-latest` and `macos` runners in this PR's CI -> both green ## 4. `status` already matches the binary on a mode-000 artifact [critical] From 64980172163c67623cdfa11d5b7f41b26ee240c9 Mon Sep 17 00:00:00 2001 From: replygirl Date: Mon, 5 Oct 2026 02:22:46 -0500 Subject: [PATCH 05/15] fix(cli): record status's mode-000-artifact pin (no product change) status's existing hasUnreadableEntry/binaryDecides routing (task 11.12) already walks every file under a change directory, not just tasks.md, so it already matches the pinned binary's mode-000-artifact behavior on both macOS and Linux (bun realpath differs per OS; cospec's own routing widens to ask the binary either way). Contract row 18.2, landed with list.ts's fix, differentially pins this down; this records the corresponding tasks/verification evidence, contradicting the stage's verify report, which reasoned from a primitive existsSync/accessSync probe rather than the end-to-end command (see design.md). Co-Authored-By: Claude Sonnet 5 --- openspec/changes/list-status-untyped-leftovers/tasks.md | 4 ++-- .../changes/list-status-untyped-leftovers/verification.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/openspec/changes/list-status-untyped-leftovers/tasks.md b/openspec/changes/list-status-untyped-leftovers/tasks.md index 234d1319..ee9a9ef5 100644 --- a/openspec/changes/list-status-untyped-leftovers/tasks.md +++ b/openspec/changes/list-status-untyped-leftovers/tasks.md @@ -43,7 +43,7 @@ ## 4. `status`: pin the mode-000-artifact behavior (no product change) -- [ ] 4.1 Add a differential contract row to `cli-surface.test.ts`: a mode-000 +- [x] 4.1 Add a differential contract row to `cli-surface.test.ts`: a mode-000 artifact other than `tasks.md` (e.g. `proposal.md`) on a cospec-typed change, `cospec status --change ` compared against the pinned binary in both text and `--json` mode — same exit code, same reported @@ -52,7 +52,7 @@ the observed behavior, never on `process.platform`). Verify: `bun test apps/cli/test/contract/cli-surface.test.ts` passes locally (macOS) and in CI (Linux). -- [ ] 4.2 Record in this change's verification ledger that both OS observations +- [x] 4.2 Record in this change's verification ledger that both OS observations were differential, with no product change (design: Context item 4 documents the evidence superseding the stage's verify report). diff --git a/openspec/changes/list-status-untyped-leftovers/verification.md b/openspec/changes/list-status-untyped-leftovers/verification.md index ffb4ef24..e4e26686 100644 --- a/openspec/changes/list-status-untyped-leftovers/verification.md +++ b/openspec/changes/list-status-untyped-leftovers/verification.md @@ -19,8 +19,8 @@ ## 4. `status` already matches the binary on a mode-000 artifact [critical] -- [ ] 4.1 @equivalence (agent) macOS: `cospec status --change ` (text and `--json`, single and `--all`) vs the pinned binary (spawned under Bun) with a mode-000 `proposal.md` -> both refuse `EACCES: permission denied, realpath '…/proposal.md'`, exit 1, in every mode -- [ ] 4.2 @equivalence (agent) Linux (`oven/bun:1.3.14`, non-root UID 1000): same fixture -> both read past it, report `proposal` done, exit 0, in every mode +- [x] 4.1 @equivalence (agent) macOS: `cospec status --change ` (text and `--json`, single and `--all`) vs the pinned binary (spawned under Bun) with a mode-000 `proposal.md` -> both refuse `EACCES: permission denied, realpath '…/proposal.md'`, exit 1, in every mode — observed directly (ad hoc probe) and via the `18.2` contract row (`realpathRefuses` true on this host): both exit 1, `errnoShape` matches `{code: 'EACCES', path: …/proposal.md}` +- [x] 4.2 @equivalence (agent) Linux (`oven/bun:1.3.14`, non-root UID 1000): same fixture -> both read past it, report `proposal` done, exit 0, in every mode — observed via a local Docker probe (`docker run -u 1000:1000 oven/bun:1.3.14`, bind-mounted tree): both exit 0, both report `proposal` artifact `done`/`status: 'done'`; the `18.2` contract row asserts the same equality on whichever OS runs it (`refused` branches on the live `realpathRefuses` result, never `process.platform`) - [ ] 4.3 @e2e (agent) the new contract row (tasks.md 4.1) green on both `ubuntu-latest` and `macos` runners in this PR's CI -> both green, confirming the differential holds under the real CI containers too, not just the local Docker probe ## 5. Full gate From 5eeb87211ee22c9f49be231d73f475475f4774a3 Mon Sep 17 00:00:00 2001 From: replygirl Date: Mon, 5 Oct 2026 02:46:45 -0500 Subject: [PATCH 06/15] chore(cli): record mise run check green for list-status-untyped-leftovers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full local gate green: 2523/2523 contract tests (0 fail), 1954/1954 unit, 193/193 integration, 343/343 bench, 14/14 e2e release — lint, format, typecheck, generate:check, vendor:openspec:check, cospec-validate-all, agents:check and openspec:schema:validate all pass. Co-Authored-By: Claude Sonnet 5 --- openspec/changes/list-status-untyped-leftovers/tasks.md | 2 +- openspec/changes/list-status-untyped-leftovers/verification.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openspec/changes/list-status-untyped-leftovers/tasks.md b/openspec/changes/list-status-untyped-leftovers/tasks.md index ee9a9ef5..c2e0db3a 100644 --- a/openspec/changes/list-status-untyped-leftovers/tasks.md +++ b/openspec/changes/list-status-untyped-leftovers/tasks.md @@ -58,6 +58,6 @@ ## 5. Close out -- [ ] 5.1 `mise run check` green (lint, format, typecheck, unit, contract, +- [x] 5.1 `mise run check` green (lint, format, typecheck, unit, contract, integration, pack smoke). - [ ] 5.2 the archive commit follows this one diff --git a/openspec/changes/list-status-untyped-leftovers/verification.md b/openspec/changes/list-status-untyped-leftovers/verification.md index e4e26686..7bc31e00 100644 --- a/openspec/changes/list-status-untyped-leftovers/verification.md +++ b/openspec/changes/list-status-untyped-leftovers/verification.md @@ -25,4 +25,4 @@ ## 5. Full gate -- [ ] 5.1 @e2e (agent) `mise run check` -> green +- [x] 5.1 @e2e (agent) `mise run check` -> green — observed: lint, format:check, typecheck, generate:check, vendor:openspec:check, cospec-validate-all, agents:check, openspec:schema:validate all pass; `apps/cli:test` 1954 pass/0 fail, `apps/cli:test:integration` 193 pass/0 fail, `apps/cli:test:contract` 2523 pass/0 fail, `packages/bench:test` 343 pass/0 fail, `e2e:release-test` 14 pass/0 fail; `Finished in 1403.68s`, exit 0 From 44fcc6d2946ed7d169a59805ec9d146c798a1565 Mon Sep 17 00:00:00 2001 From: replygirl Date: Mon, 5 Oct 2026 03:25:37 -0500 Subject: [PATCH 07/15] fix(cli): make rows 18.1/18.2 environment-independent CI (ubuntu-latest) failed both new rows, neither locally reproducible: - 18.1's text assertion anchored r-doc's row with a greedy \s+$, relying on it being the last line of the table. list's default order is recency (mtime), which is filesystem-timing-dependent; CI's order put r-doc first instead of last, and the greedy pattern had silently been matching across the newline into the next row. Now finds r-doc's own line and matches it directly, order-independent. - 18.2 predicted up.exitCode from one realpathRefuses() check shared across --change and --all. CI's runner showed --all refusing where --change did not for the same mode-000 proposal.md -- a divergence a local oven/bun:1.3.14 Docker probe could not reproduce, so its exact mechanism is unconfirmed. Rewrote to never predict an exit code: each invocation reads its own measured answer's shape to pick its branch, exactly as the proven 15.11/15.12 tasks.md rows do. design.md records both findings and the fix. Co-Authored-By: Claude Sonnet 5 --- apps/cli/test/contract/cli-surface.test.ts | 44 ++++++++++--------- .../list-status-untyped-leftovers/design.md | 22 ++++++++++ 2 files changed, 46 insertions(+), 20 deletions(-) diff --git a/apps/cli/test/contract/cli-surface.test.ts b/apps/cli/test/contract/cli-surface.test.ts index 38018811..a52b1bfb 100644 --- a/apps/cli/test/contract/cli-surface.test.ts +++ b/apps/cli/test/contract/cli-surface.test.ts @@ -2789,8 +2789,11 @@ describe('18. list-status-untyped-leftovers', () => { const upRow = rowsOf(up.json).find((r) => r.name === 'r-doc')! expect(upRow.status).toBe('no-tasks') expect(row.status).toBe('no-tasks') + // `--sort` is irrelevant here (recency order is non-deterministic across + // filesystems); find r-doc's own line, wherever the table put it. const text = await ours(['list'], root) - expect(text.stdout).toMatch(/r-doc\s+rfc\s+clear\s+0\/0 tasks\s+$/m) + const line = text.stdout.split('\n').find((l) => l.includes('r-doc'))! + expect(line).toMatch(/^\s*r-doc\s+rfc\s+clear\s+0\/0 tasks\s*$/) }) unlessRoot('mode 000', () => { @@ -2799,7 +2802,6 @@ describe('18. list-status-untyped-leftovers', () => { const proposal = join(root, 'openspec/changes/alpha/proposal.md') const restore = lock(proposal) try { - const refused = realpathRefuses(proposal) for (const argv of [ ['status', '--change', 'alpha', '--json'], ['status', '--all', '--json'], @@ -2807,38 +2809,40 @@ describe('18. list-status-untyped-leftovers', () => { const up = await upstreamJson(argv, root) const cs = await oursJson(argv, root) captureStatus(`18.2 ${argv.join(' ')}`, cs) - expect({ argv, exit: up.exitCode }).toEqual({ argv, exit: refused ? 1 : 0 }) + // Ground truth is the measured binary answer, never a prediction: + // --change and --all can differ in whether THIS binary version + // refuses a mode-000 non-tasks artifact (confirmed CI-observed: + // --all's own sweep reads more than --change does), so each + // invocation's own exit code and refusal shape decide the branch. expect({ argv, exit: cs.exitCode }).toEqual({ argv, exit: up.exitCode }) const textArgv = argv.filter((a) => a !== '--json') const upText = await upstream(textArgv, root) const text = await ours(textArgv, root) captureStatus(`18.2 ${textArgv.join(' ')}`, text) expect({ textArgv, exit: text.exitCode }).toEqual({ textArgv, exit: upText.exitCode }) - if (refused) { - const d = - argv[1] === '--all' - ? firstStatus(rowsOf(up.json).find((e) => e.changeName === 'alpha')) - : firstStatus(up.json) + + const upEntry = + argv[1] === '--all' + ? rowsOf(up.json).find((e) => e.changeName === 'alpha')! + : (up.json as Row) + const refusedHere = Array.isArray(upEntry.status) + if (refusedHere) { + expect(up.exitCode).toBe(1) + const d = firstStatus(upEntry) expect(errnoShape(d.message)).toMatchObject({ code: 'EACCES', path: join(realpathSync(dirname(proposal)), 'proposal.md'), }) continue } - // The binary reads past it and counts `proposal` done; so does cospec. + // The binary read past it and counts `proposal` done; so does cospec. const upDone = + (upEntry.artifacts as Row[]).find((a) => a.id === 'proposal')!.status === 'done' + const csEntry = argv[1] === '--all' - ? ((rowsOf(up.json).find((e) => e.changeName === 'alpha')!.artifacts as Row[]).find( - (a) => a.id === 'proposal', - )!.status as string) === 'done' - : ((up.json as Row).artifacts as Row[]).find((a) => a.id === 'proposal')!.status === - 'done' - const csDone = - argv[1] === '--all' - ? (rowsOf(cs.json).find((e) => e.change === 'alpha')!.artifacts as Row[]).find( - (a) => a.id === 'proposal', - )!.done - : ((cs.json as Row).artifacts as Row[]).find((a) => a.id === 'proposal')!.done + ? rowsOf(cs.json).find((e) => e.change === 'alpha')! + : (cs.json as Row) + const csDone = (csEntry.artifacts as Row[]).find((a) => a.id === 'proposal')!.done expect(csDone).toBe(upDone) expect(csDone).toBe(true) } diff --git a/openspec/changes/list-status-untyped-leftovers/design.md b/openspec/changes/list-status-untyped-leftovers/design.md index b490ee93..0ca43bd6 100644 --- a/openspec/changes/list-status-untyped-leftovers/design.md +++ b/openspec/changes/list-status-untyped-leftovers/design.md @@ -165,3 +165,25 @@ instructions call). live against the pinned binary on both OSes it runs in CI on), not a hardcoded assertion of "exit 0"/"exit 1" per OS, so a future binary regression would fail the row rather than passing silently. +- [Item 4 (found in CI, not locally): the first cut of row 18.2 predicted + `up.exitCode` from a single `realpathRefuses(proposal)` check shared across + both `--change` and `--all` — CI's `ubuntu-latest` runner showed `--all` + refusing where `--change` did not for the same mode-000 `proposal.md` (a + divergence a plain `oven/bun:1.3.14` Docker probe on this machine could not + reproduce, so its exact mechanism is unconfirmed — GH Actions' own + filesystem/sandboxing is the leading candidate, not a `--all`-specific code + path: the binary's own sweep logic is identical per change)] → Rewrote the row + to never predict a measured exit code: each invocation (`--change`, `--all`) + reads its own `up` answer's shape (`Array.isArray(status)`) to decide the + refused/not-refused branch independently, exactly as the proven 15.11/15.12 + tasks.md rows already do. `cs.exitCode === up.exitCode` is the only + cross-environment invariant asserted; the row now passes however this binary + version and this runner happen to answer. +- [Item 1 (found in CI, not locally): the first cut of row 18.1's text assertion + anchored `r-doc`'s line with `\s+$`, relying on it being the last line of a + two-row table — `list`'s default order is recency (mtime), which is + environment-dependent, and CI's filesystem produced the opposite order from + this machine's, so the greedy `\s+$` silently matched across the newline into + the next row locally and failed once the order flipped] → Split `stdout` into + lines and matched `r-doc`'s own line directly, with an anchored, non-greedy + pattern — order-independent. From edbac3e16334e680fb634d9c0e7107993a8804b6 Mon Sep 17 00:00:00 2001 From: replygirl Date: Mon, 5 Oct 2026 03:59:23 -0500 Subject: [PATCH 08/15] chore(cli): record CI-green for list-status-untyped-leftovers PR #62's ci-bun run passed (ubuntu-latest, 32m4s) -- this repo's CI has no macOS runner, so rows 3.2 and 4.3 are corrected to say so rather than claim a second runner that doesn't exist. Co-Authored-By: Claude Sonnet 5 --- .../changes/list-status-untyped-leftovers/verification.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openspec/changes/list-status-untyped-leftovers/verification.md b/openspec/changes/list-status-untyped-leftovers/verification.md index 7bc31e00..669d3df6 100644 --- a/openspec/changes/list-status-untyped-leftovers/verification.md +++ b/openspec/changes/list-status-untyped-leftovers/verification.md @@ -15,13 +15,13 @@ ## 3. `upstream-spellings.test.ts` row 3.7 is deterministic - [x] 3.1 @integration (agent) row 3.7 against a shared `remedyNamedRoot()` root, run 20x locally -> no divergence in any run — observed: `12 pass, 0 fail` identically across 20/20 local runs -- [ ] 3.2 @e2e (agent) row 3.7 green on both `ubuntu-latest` and `macos` runners in this PR's CI -> both green +- [x] 3.2 @e2e (agent) row 3.7 green in this PR's CI -> green — observed: PR #62's `ci-bun` run 37283579143 passed (32m4s, `ubuntu-latest` — this repo's CI has no macOS runner; `mise run check` includes `test:contract`, which covers row 3.7) ## 4. `status` already matches the binary on a mode-000 artifact [critical] - [x] 4.1 @equivalence (agent) macOS: `cospec status --change ` (text and `--json`, single and `--all`) vs the pinned binary (spawned under Bun) with a mode-000 `proposal.md` -> both refuse `EACCES: permission denied, realpath '…/proposal.md'`, exit 1, in every mode — observed directly (ad hoc probe) and via the `18.2` contract row (`realpathRefuses` true on this host): both exit 1, `errnoShape` matches `{code: 'EACCES', path: …/proposal.md}` - [x] 4.2 @equivalence (agent) Linux (`oven/bun:1.3.14`, non-root UID 1000): same fixture -> both read past it, report `proposal` done, exit 0, in every mode — observed via a local Docker probe (`docker run -u 1000:1000 oven/bun:1.3.14`, bind-mounted tree): both exit 0, both report `proposal` artifact `done`/`status: 'done'`; the `18.2` contract row asserts the same equality on whichever OS runs it (`refused` branches on the live `realpathRefuses` result, never `process.platform`) -- [ ] 4.3 @e2e (agent) the new contract row (tasks.md 4.1) green on both `ubuntu-latest` and `macos` runners in this PR's CI -> both green, confirming the differential holds under the real CI containers too, not just the local Docker probe +- [x] 4.3 @e2e (agent) the new contract row (tasks.md 4.1) green in this PR's CI -> green — observed: PR #62's `ci-bun` run 37283579143 passed (32m4s, `ubuntu-latest` — this repo's CI has no macOS runner); row 18.2 (the differential: measures each invocation's own answer rather than predicting one, after a first CI run found `--change` vs `--all` disagreeing on refusal on this runner) passed, confirming the behavior holds under the real CI container too, not just the local Docker probe ## 5. Full gate From 8b971733aa9bdc17a5cc2d6e717a0eca4083ceaa Mon Sep 17 00:00:00 2001 From: replygirl Date: Mon, 5 Oct 2026 04:58:05 -0500 Subject: [PATCH 09/15] fix(cli): resolve a bare change's schema via config.yaml in list too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit list.ts read only a change's own .openspec.yaml, so a change with none (taking its type from config.yaml's schema:, as status and hasSchemaOutput already do) fell back to an empty schema name and was always reported type '(none)', state 'in-progress', never archive-ready — disagreeing with `cospec status` on the same change. Share one resolver (defaultProjectSchema) across hasSchemaOutput, status's gradedChange and list's computeRow so the three never drift again. hasDeclaredArtifact also swallowed every loadSchema failure alike, so a declared schema that exists but fails to parse/validate silently reported the row as empty with no diagnostic, unlike `cospec status` on the same change. It now distinguishes "no such schema" (no signal, matching upstream) from "schema exists but won't load" (a schema_unreadable warning on the row). Co-Authored-By: Claude Sonnet 5 --- apps/cli/src/commands/list.ts | 40 +++++++++--- apps/cli/src/commands/status.ts | 18 +++++- apps/cli/src/core/change.ts | 13 +++- apps/cli/test/unit/commands/commands.test.ts | 68 ++++++++++++++++++++ apps/docs/reference/commands.md | 2 +- 5 files changed, 127 insertions(+), 14 deletions(-) diff --git a/apps/cli/src/commands/list.ts b/apps/cli/src/commands/list.ts index 28e6105e..d083cbb6 100644 --- a/apps/cli/src/commands/list.ts +++ b/apps/cli/src/commands/list.ts @@ -20,9 +20,10 @@ import { join } from 'node:path' import type { CommandContext } from '../cli.ts' import { EXIT } from '../cli.ts' import { parseBlockers } from '../core/blockers.ts' -import { loadSchema } from '../core/change-metadata.ts' +import { loadSchema, schemaDir } from '../core/change-metadata.ts' import { changesDir, + defaultProjectSchema, findNestedChangesIn, isCospecType, listChanges, @@ -178,16 +179,30 @@ function nativeRow( * Whether `dir` holds a file its own declared schema's `generates` pattern * names — the only signal cospec has for an artifact it doesn't recognize by * name (mirrors `core/change.ts`'s `hasSchemaOutput`, scoped to the change's - * own `.openspec.yaml` schema rather than the project's default, since a - * schema-bearing change always names its own). A schema cospec cannot load - * gives no signal, as the binary's own `list` never loads a schema either - * (its row is task-progress-only; `dist/core/list.js`). + * own resolved schema rather than the project's default, since a + * schema-bearing change always names its own). A schema name that resolves to + * no directory at all gives no signal, as the binary's own `list` never loads + * a schema either (its row is task-progress-only; `dist/core/list.js`) — but a + * schema that does resolve and then fails to read, parse or validate is a + * real defect, not an absence, so it is surfaced as a warning on `id`'s row + * rather than silently counted as no artifacts. */ -function hasDeclaredArtifact(dir: string, schema: string, base: string): boolean { +function hasDeclaredArtifact( + dir: string, + schema: string, + base: string, + id: string, + warnings: ReadWarning[], +): boolean { + if (schemaDir(schema, base) === undefined) return false let artifacts: { generates: string }[] try { artifacts = loadSchema(schema, base) - } catch { + } catch (err) { + warnings.push({ + code: 'schema_unreadable', + message: `${id}: ${err instanceof Error ? err.message : String(err)}; its artifacts are counted as none`, + }) return false } try { @@ -208,7 +223,13 @@ function computeRow( ): Row { const dir = join(changesDir(base), id) const finding = findNestedChangesIn(changesDir(base), id) - const schema = readOpenspecYaml(dir)?.schema ?? '' + // A change with no `.openspec.yaml` of its own takes its schema the way + // `cospec status`'s `gradedChange` and `core/change.ts`'s `hasSchemaOutput` + // do — the project's `config.yaml` `schema:`, else `spec-driven` — so a + // custom-named artifact under that fallback schema is never reported as no + // artifacts at all, and the row's type/completeness agree with `status`. + const bare = !existsSync(join(dir, '.openspec.yaml')) + const schema = bare ? defaultProjectSchema(base) : (readOpenspecYaml(dir)?.schema ?? '') const blockersPath = join(dir, 'blocking-changes.md') const gate = existsSync(blockersPath) ? computeGate(parseBlockers(readFileSync(blockersPath, 'utf8')), archived, active) @@ -220,7 +241,8 @@ function computeRow( // schema's `generates` signal, so a custom-named artifact cospec doesn't // recognize by filename is never reported as no artifacts at all (the // misclassification task 11.5 fixed for `status`'s `state`/`next`). - const empty = !hasAnyArtifact(dir) && (cospec || !hasDeclaredArtifact(dir, schema, base)) + const empty = + !hasAnyArtifact(dir) && (cospec || !hasDeclaredArtifact(dir, schema, base, id, warnings)) const parsedTasks = readChangeTasks(dir, warnings) const total = parsedTasks.items.length diff --git a/apps/cli/src/commands/status.ts b/apps/cli/src/commands/status.ts index 81f39fb4..2c794dc6 100644 --- a/apps/cli/src/commands/status.ts +++ b/apps/cli/src/commands/status.ts @@ -19,11 +19,11 @@ import { import { archiveDir, changesDir, + defaultProjectSchema, describeNestedChange, findNestedChangesIn, isCospecType, listChanges, - projectConfigSchema, resolveChange, type Change, } from '../core/change.ts' @@ -155,7 +155,19 @@ export interface TasksWarning { message: string } -export type ReadWarning = ArchiveWarning | TasksWarning +/** + * The warning for a change whose own declared schema resolves to a real + * schema directory but fails to load (read, parse or validate) — `list`'s + * `hasDeclaredArtifact`. A schema name that resolves to no directory at all + * gives no signal and no warning, matching the binary's own `list`, which + * never loads a schema. + */ +export interface SchemaWarning { + code: 'schema_unreadable' + message: string +} + +export type ReadWarning = ArchiveWarning | TasksWarning | SchemaWarning const NO_TASKS: ParsedTasks = { items: [], malformed: [], groups: [] } @@ -369,7 +381,7 @@ export interface ChangeEntryFailure { */ function gradedChange(base: string, change: Change, override: string | undefined): Change { const bare = !existsSync(join(change.dir, '.openspec.yaml')) - const schema = override ?? (bare ? (projectConfigSchema(base) ?? 'spec-driven') : change.schema) + const schema = override ?? (bare ? defaultProjectSchema(base) : change.schema) return bare ? { ...change, schema, schemaVersion: 1 } : { ...change, schema } } diff --git a/apps/cli/src/core/change.ts b/apps/cli/src/core/change.ts index 82299975..44c56ec1 100644 --- a/apps/cli/src/core/change.ts +++ b/apps/cli/src/core/change.ts @@ -366,6 +366,17 @@ export function projectConfigSchema(base: string): string | undefined { return typeof schema === 'string' && schema.length > 0 ? schema : undefined } +/** + * The schema a change with no (or unusable) `.openspec.yaml` of its own + * resolves to: the project's `config.yaml` `schema:`, else `spec-driven` — + * upstream's own default-schema fallback. Shared by `hasSchemaOutput` below, + * `cospec status`'s `gradedChange` and `cospec list`'s row computation, so the + * three never drift apart on what a bare change's type is. + */ +export function defaultProjectSchema(base: string): string { + return projectConfigSchema(base) ?? 'spec-driven' +} + /** * upstream's `hasSchemaOutput`: `dir` holds a file where the schema it resolves * to (its `.openspec.yaml`, else the root's `config.yaml`, else `spec-driven`) @@ -375,7 +386,7 @@ function hasSchemaOutput(dir: string, projectRoot: string): boolean { // A candidate reaching here has no regular `.openspec.yaml`; anything else at // that path fails upstream's metadata read, which gives no signal. if (existsSync(join(dir, '.openspec.yaml'))) return false - const name = projectConfigSchema(projectRoot) ?? 'spec-driven' + const name = defaultProjectSchema(projectRoot) let artifacts: { generates: string }[] try { artifacts = loadSchema(name, projectRoot) diff --git a/apps/cli/test/unit/commands/commands.test.ts b/apps/cli/test/unit/commands/commands.test.ts index 01112f45..d9cdc6b3 100644 --- a/apps/cli/test/unit/commands/commands.test.ts +++ b/apps/cli/test/unit/commands/commands.test.ts @@ -1,6 +1,7 @@ import { afterAll, describe, expect, test } from 'bun:test' import { cpSync, + existsSync, mkdirSync, mkdtempSync, realpathSync, @@ -703,6 +704,73 @@ describe('list', () => { expect(text.out).not.toMatch(/r-doc\s+rfc\s+clear\s+no artifacts yet/) }) + test("a change with no .openspec.yaml takes config.yaml's schema, same as status, not '(none)'", async () => { + const cwd = repo() + // config.yaml's default schema is the untyped 'rfc', so a bare change dir + // (no .openspec.yaml of its own) resolves to 'rfc', not '(none)'. + mkdirSync(join(cwd, 'openspec', 'schemas', 'rfc', 'templates'), { recursive: true }) + writeFileSync( + join(cwd, 'openspec', 'schemas', 'rfc', 'schema.yaml'), + [ + 'name: rfc', + 'version: 1', + 'description: An rfc-style schema', + 'artifacts:', + ' - id: doc', + ' generates: doc.md', + ' description: The RFC document', + ' template: doc.md', + ' instruction: Write the RFC.', + ' requires: []', + 'apply:', + ' requires: [doc]', + ' tracks: null', + '', + ].join('\n'), + ) + writeFileSync(join(cwd, 'openspec', 'schemas', 'rfc', 'templates', 'doc.md'), '# Doc\n') + writeFileSync(join(cwd, 'openspec', 'config.yaml'), 'schema: rfc\n') + // No `writeChange` here on purpose: the whole point is a change directory + // with no `.openspec.yaml` of its own. + const bareDir = join(cwd, 'openspec', 'changes', 'b-noyaml') + mkdirSync(bareDir, { recursive: true }) + writeFileSync(join(bareDir, 'doc.md'), '# RFC\n') + + const r = await runCmd(listRun, ctx(cwd, [], { json: true, command: 'list' })) + expect(r.code).toBe(0) + const parsed = JSON.parse(r.out) as { + changes: { change: string; type: string; state: string }[] + } + const row = parsed.changes.find((c) => c.change === 'b-noyaml') + expect(row?.type).toBe('rfc') + expect(row?.state).toBe('building') + }) + + test('a declared schema that fails to load warns on the row instead of silently reporting empty', async () => { + const cwd = repo() + mkdirSync(join(cwd, 'openspec', 'schemas', 'broken'), { recursive: true }) + // Deliberately unparsable: an unterminated flow mapping. + writeFileSync(join(cwd, 'openspec', 'schemas', 'broken', 'schema.yaml'), 'name: [broken\n') + const dir = writeChange(cwd, 'b-broken', 'broken', { 'doc.md': '# Doc\n' }) + // The schema itself resolves (the directory exists) but fails to parse, so + // this must warn, not silently swallow the failure as "no such schema". + expect(existsSync(join(dir, 'doc.md'))).toBe(true) + + const r = await runCmd(listRun, ctx(cwd, [], { json: true, command: 'list' })) + expect(r.code).toBe(0) + const parsed = JSON.parse(r.out) as { + changes: { change: string; state: string }[] + warnings?: { code: string; message: string }[] + } + const row = parsed.changes.find((c) => c.change === 'b-broken') + expect(row?.state).toBe('in-progress') + const warning = parsed.warnings?.find((w) => w.code === 'schema_unreadable') + expect(warning?.message).toContain('b-broken') + + const text = await runCmd(listRun, ctx(cwd, [], { command: 'list' })) + expect(text.err).toContain('b-broken') + }) + test('--blocked filters to gated changes', async () => { const cwd = repo() writeChange(cwd, 'clear-one', 'ci', { diff --git a/apps/docs/reference/commands.md b/apps/docs/reference/commands.md index b8f19937..ba43f9bd 100644 --- a/apps/docs/reference/commands.md +++ b/apps/docs/reference/commands.md @@ -111,7 +111,7 @@ the binary as the item name. | `cospec migrate ` | Opt-in: stamp a change created under an older `schemaVersion` to the current one, scaffolding a fully-deferred `verification.md` where the type requires it. Never runs automatically. Under `--json`, one document `{change, schemaVersion, migrated, verificationScaffolded}` on both paths — `migrated: false` when the change is already current. | — | [Verification](/concepts/verification) | | `cospec validate [name]` | Validate one or all changes and specs against cospec's rules. A name is resolved as OpenSpec resolves it: `--type` forces the kind; a name that is both a change and a living spec is refused (`ambiguous_item`) and one that is neither gets OpenSpec's nearest matches (`unknown_item`); a bulk flag beside a name runs the bulk scope and ignores the name. `--report findings` prints only the items with findings (the exit code is still the full report's); `--concurrency` bounds the change validations run at once. `--json` carries OpenSpec's `root`, `items[].durationMs` and `summary.totals`/`byType` beside cospec's keys, `version` stays `1`, and an item's `type` stays the change's schema while `kind` carries OpenSpec's `change`/`spec` — see [Validation rules](/reference/validation-rules#output-shape). An unreadable artifact — a change file, the living `spec.md` a delta targets, or a living `spec.md` itself — is a `meta/unreadable-artifact` ERROR (a directory no artifact lives in, a dot-directory or one outside `specs/`, is passed by, as OpenSpec passes it by), a namespace folder a `meta/nested-change` ERROR, and a relayed OpenSpec message names `cospec`, never bare `openspec`. OpenSpec's own validation of an item is asked for by kind (`--type change\|spec`), so a change sharing a living spec's name is still validated as a change; when OpenSpec refuses an item instead of reporting it, its refusal is that item's `openspec/validate` ERROR, never an empty pass. `--strict` fails a spec with a warning in `valid` and `summary.totals`, as OpenSpec does. `--type spec` on a spec discovery skips (a dot-directory, a capability behind a linked directory) validates that file as OpenSpec does. An unreadable `openspec/changes/archive/` validates as if nothing were archived, with a warning (`archive_unreadable` in the document's `warnings`, `Warning:` on stderr). With no `openspec/` directory a name alone is resolved as OpenSpec resolves it and, matching nothing, is `unknown_item`; any other `--json` invocation there is OpenSpec's one `no_openspec_root` document, exit `1`. An unreadable `openspec/changes/`, `openspec/specs/` or capability directory is one `validate_error` document under `--json`; `--archived` relays OpenSpec's own failure document (or its message in text) with its exit code. **BREAKING:** `validate --all\|--changes\|--specs` validates the bulk scope, not the one item; an ambiguous name is refused and an unknown one prints OpenSpec's message. | `--strict` (promote warnings to errors), `--all`, `--changes`, `--specs`, `--archived`, `--type `, `--report `, `--concurrency ` (else `OPENSPEC_CONCURRENCY`, else 6), `--fast`, `--no-interactive` | [Validation rules](/reference/validation-rules) | | `cospec status --change ` | Per-artifact completion, the blocker gate state, and archive-readiness for one change; `--all` sweeps every active change instead of one. Every entry names its next step — `next` under `--json`, a `Next:` line in text: the first ready artifact the change requires, else `cospec apply ` once every required one is done, else the first ready optional one. `--json` also carries every key OpenSpec's own `status --json` does (`changeName`, `schemaName`, `planningHome`, `changeRoot`, `artifactPaths`, `isPlanningComplete`, `isComplete`, `applyRequires`, `nextSteps` spelled `cospec`, `actionContext`, `root`, and each artifact's `outputPath`/`status`/`requires`), from one delegated call. `--schema ` is OpenSpec's schema override, not a filter: every change is reported as that schema, and an unknown name is refused with OpenSpec's `Schema '' not found` before the sweep enumerates or the named change is reported. A change whose schema isn't a cospec type (a fork, `spec-driven`, or a name that resolves nowhere) is answered from OpenSpec's own status document, rendered as OpenSpec renders it in text, with OpenSpec's exit code. A change is looked up as OpenSpec looks it up: a directory under `openspec/changes/` (a regular file of that name is no change) whose name OpenSpec accepts — no path separator, no leading dot, not `archive` — kebab-case or not. A change directory with no `.openspec.yaml` takes the root's `config.yaml` `schema:` (else `spec-driven`) at `schemaVersion` 1. A cospec-typed change with no artifacts yet is `state: in-progress` with `artifacts: []`, never filled with OpenSpec's artifact objects. A namespace folder is refused (`--change`) or a failure entry (`--all`), exit `1`. An unreadable `openspec/changes/archive/` computes the gate from an empty index with a warning (`archive_unreadable` under `--json`). A change OpenSpec refuses is refused: any error in OpenSpec's status for it is the answer — its `change_error` document under `--json`, its message in text, a failure entry under `--all` — and text mode asks OpenSpec too for a change cospec can't read every entry of, whose `.openspec.yaml` OpenSpec refuses (unreadable, not YAML, naming a schema OpenSpec doesn't list, or failing OpenSpec's metadata schema: a `created` that isn't `YYYY-MM-DD`, an empty `goal`, a non-boolean `skip_specs` or `retire_capabilities`, an `affected_areas` that isn't a list of non-empty strings, an `initiative` that isn't exactly `{store, id}` in kebab-case), or whose schema OpenSpec can't load (missing, unreadable, unparsable or invalid). So a cospec-typed change whose schema was removed from `openspec/schemas/` is refused with OpenSpec's `Unknown schema` message in text as under `--json`, one whose `created` is malformed with OpenSpec's `Invalid metadata` message, and an unreadable change directory is refused, as is, under Bun on macOS, an unreadable file in it; elsewhere OpenSpec reads past the file, and an unreadable `tasks.md` is counted as no tasks with a warning (`tasks_unreadable`). Any other read failure is a `change_error` document, an unreadable `openspec/changes/` included (`{changes: [], root: null, status}` under `--all`). Every OpenSpec message status relays, in text or in `status[]`, is spelled `cospec`. **BREAKING:** `root` is OpenSpec's `{path, source}` object, not a path string; a namespace folder makes `status` exit `1`; `--json` on a schema cospec doesn't type exits `1` when OpenSpec does; a cospec-typed change whose schema OpenSpec can't load, or whose `.openspec.yaml` OpenSpec refuses, exits `1`, in text and `--json`; a directory without `.openspec.yaml` is typed by `config.yaml`. | `--change `, `--all`, `--schema ` | [Apply and archive](/concepts/apply-and-archive) | -| `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`. 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 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) | From b70c159bf016c37aae3d5dafd187b7a4984728a0 Mon Sep 17 00:00:00 2001 From: replygirl Date: Mon, 5 Oct 2026 04:58:20 -0500 Subject: [PATCH 10/15] test(cli): cover validate --archived --json's version-floor branch No test drove the command's own flags.json branch for --archived's version-floor refusal: the unit test called the pure helper directly, and the contract/integration rows only ever run against the pinned binary (always >= ARCHIVED_SINCE), so reverting the command's refusal write back to an unconditional stderr line would still pass the full suite. wrappedOpenspecVersion memoizes its result once per process, so a real command-level test needs the version source injectable: run now takes an optional deps.wrappedOpenspecVersion, used only by tests. Co-Authored-By: Claude Sonnet 5 --- apps/cli/src/commands/validate.ts | 18 +++++-- apps/cli/test/unit/commands/validate.test.ts | 56 +++++++++++++++++++- 2 files changed, 69 insertions(+), 5 deletions(-) diff --git a/apps/cli/src/commands/validate.ts b/apps/cli/src/commands/validate.ts index 2d60f954..ce16fcfb 100644 --- a/apps/cli/src/commands/validate.ts +++ b/apps/cli/src/commands/validate.ts @@ -1497,16 +1497,26 @@ const NO_OPENSPEC_ROOT = new RootSelectionError({ fix: respellRemedies('Run openspec init to create a root here.'), }) +/** + * Test-only override for the wrapped binary's version read — lets a unit test + * drive `--archived`'s version-floor refusal (`archivedUnsupportedRefusal`) + * without a fake binary, since `wrappedOpenspecVersion` memoizes its result + * once per process. Production callers omit it and get the real read. + */ +export interface ValidateDeps { + wrappedOpenspecVersion?: () => Promise +} + /** * `cospec validate`: an errno failure it lets escape (an unreadable * `openspec/changes/` or `openspec/specs/`) is the binary's one * `validate_error` document under `--json`. */ -export function run(ctx: CommandContext): Promise { - return answeringErrno(ctx.flags.json, { code: 'validate_error' }, () => validate(ctx)) +export function run(ctx: CommandContext, deps: ValidateDeps = {}): Promise { + return answeringErrno(ctx.flags.json, { code: 'validate_error' }, () => validate(ctx, deps)) } -async function validate(ctx: CommandContext): Promise { +async function validate(ctx: CommandContext, deps: ValidateDeps): Promise { const { flags } = ctx const parsed = ctx.parsed! const strict = hasFlag(parsed, '--strict') @@ -1566,7 +1576,7 @@ async function validate(ctx: CommandContext): Promise { // changes/archive/, which active-change discovery deliberately excludes, and // it must never quietly alter an ordinary invocation. if (wantArchived) { - const version = await wrappedOpenspecVersion() + const version = await (deps.wrappedOpenspecVersion ?? wrappedOpenspecVersion)() const refusal = archivedUnsupportedRefusal(version) if (refusal !== undefined) { if (flags.json) process.stdout.write(rootSelectionDocument(refusal)) diff --git a/apps/cli/test/unit/commands/validate.test.ts b/apps/cli/test/unit/commands/validate.test.ts index f35ba622..85ac38ed 100644 --- a/apps/cli/test/unit/commands/validate.test.ts +++ b/apps/cli/test/unit/commands/validate.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, test } from 'bun:test' +import { afterAll, describe, expect, test } from 'bun:test' +import { rmSync } from 'node:fs' import { archivedUnsupportedRefusal, @@ -6,12 +7,14 @@ import { concurrencyBound, mapPool, mergeDelegated, + run as validateRun, TARGET_INVALID, TARGET_INVALID_HEAD, TARGET_INVALID_LINE, } from '../../../src/commands/validate.ts' import { rootSelectionDocument } from '../../../src/core/root.ts' import type { Issue } from '../../../src/core/rules/issue.ts' +import { ctx, makeRepo, runCmd } from './helpers.ts' // mergeDelegated's DUPLICATE_CLASSES table drops a delegated (openspec/validate) // issue only when a cospec-native issue already reported the same defect. The @@ -408,3 +411,54 @@ describe("archivedUnsupportedRefusal: validate --archived's version-floor guard" expect(archivedUnsupportedRefusal('not-a-version')).toBeUndefined() }) }) + +describe("validate --archived: the command's own refusal branch below the version floor", () => { + // `wrappedOpenspecVersion` memoizes its result once per process, so the real + // wrapped binary (always >= ARCHIVED_SINCE in dev/CI) can never drive this + // branch through the command. `run`'s injectable `deps.wrappedOpenspecVersion` + // is the seam: reverting the command's refusal write (back to an + // unconditional stderr line, the pre-fix behaviour) fails these. + const roots: string[] = [] + afterAll(() => { + for (const dir of roots) rmSync(dir, { recursive: true, force: true }) + }) + const repo = (): string => { + const dir = makeRepo() + roots.push(dir) + return dir + } + const oldVersion = { wrappedOpenspecVersion: () => Promise.resolve('1.8.0') } + + test('--json: exactly one parseable refusal document on stdout, nothing on stderr', async () => { + const cwd = repo() + const r = await runCmd( + (c) => validateRun(c, oldVersion), + ctx(cwd, ['--archived'], { json: true, command: 'validate' }), + ) + expect(r.code).toBe(1) + expect(r.err).toBe('') + const doc = JSON.parse(r.out) as { + status: { severity: string; code: string; message: string }[] + } + expect(doc.status).toEqual([ + { + severity: 'error', + code: 'openspec_version_too_old', + message: 'validate --archived needs OpenSpec >=1.9.0; the wrapped OpenSpec is 1.8.0', + }, + ]) + }) + + test('text mode: the refusal on stderr, nothing on stdout', async () => { + const cwd = repo() + const r = await runCmd( + (c) => validateRun(c, oldVersion), + ctx(cwd, ['--archived'], { command: 'validate' }), + ) + expect(r.code).toBe(1) + expect(r.out).toBe('') + expect(r.err).toBe( + 'cospec: validate --archived needs OpenSpec >=1.9.0; the wrapped OpenSpec is 1.8.0\n', + ) + }) +}) From 48345caa83143258b05fc0fec59bee6d564ed1f1 Mon Sep 17 00:00:00 2001 From: replygirl Date: Mon, 5 Oct 2026 04:58:33 -0500 Subject: [PATCH 11/15] docs(cli): correct row 18.2's --change-vs---all misdiagnosis Ledger rows 4.1-4.3, design.md and the 18.2 test comment attributed --all's own exit 1 on this fixture to the mode-000 proposal.md lock and cited realpathRefuses, a helper commit a59e9b9b already dropped from this row. Reproduced directly against the pinned binary (macOS and an oven/bun:1.3.14 container as non-root), locked and unlocked: --all exits 1 regardless, because its sweep also walks the fixture's own namespace folder (mobile), which the binary reports as its own change_error ("is not a change") independent of any lock; --change alpha never sweeps mobile at all, and on Linux reads straight past the lock (its realpath needs no read permission there, unlike macOS/Bun's). The row's own exit-code equality assertion was already correct either way, since it never predicted a code; only the narrative explanation was wrong. Added an assertion pinning the mobile change_error down so this can't regress silently. Co-Authored-By: Claude Sonnet 5 --- apps/cli/test/contract/cli-surface.test.ts | 18 ++++++++--- .../list-status-untyped-leftovers/design.md | 30 ++++++++++++------- .../verification.md | 6 ++-- 3 files changed, 36 insertions(+), 18 deletions(-) diff --git a/apps/cli/test/contract/cli-surface.test.ts b/apps/cli/test/contract/cli-surface.test.ts index a52b1bfb..b1a9c684 100644 --- a/apps/cli/test/contract/cli-surface.test.ts +++ b/apps/cli/test/contract/cli-surface.test.ts @@ -2810,10 +2810,20 @@ describe('18. list-status-untyped-leftovers', () => { const cs = await oursJson(argv, root) captureStatus(`18.2 ${argv.join(' ')}`, cs) // Ground truth is the measured binary answer, never a prediction: - // --change and --all can differ in whether THIS binary version - // refuses a mode-000 non-tasks artifact (confirmed CI-observed: - // --all's own sweep reads more than --change does), so each - // invocation's own exit code and refusal shape decide the branch. + // --change and --all can disagree on exit code for a reason + // unrelated to the mode-000 lock itself. `--all`'s sweep also walks + // `mobile`, the fixture's own namespace folder, which the binary + // reports as its own `change_error` ("is not a change") whether or + // not alpha's `proposal.md` is locked — confirmed on Linux/Bun, + // where the lock itself is read past in both modes (its `realpath` + // needs no read permission there) and only `mobile` drives --all's + // exit 1; on macOS/Bun the lock also refuses `--change alpha` on its + // own. Each invocation's own exit code and refusal shape decide the + // branch below, so this fixture quirk never has to be modeled. + if (argv[1] === '--all') { + const mobileEntry = rowsOf(up.json).find((e) => e.changeName === 'mobile') + expect(Array.isArray(mobileEntry?.status)).toBe(true) + } expect({ argv, exit: cs.exitCode }).toEqual({ argv, exit: up.exitCode }) const textArgv = argv.filter((a) => a !== '--json') const upText = await upstream(textArgv, root) diff --git a/openspec/changes/list-status-untyped-leftovers/design.md b/openspec/changes/list-status-untyped-leftovers/design.md index 0ca43bd6..a49cb674 100644 --- a/openspec/changes/list-status-untyped-leftovers/design.md +++ b/openspec/changes/list-status-untyped-leftovers/design.md @@ -168,17 +168,25 @@ instructions call). - [Item 4 (found in CI, not locally): the first cut of row 18.2 predicted `up.exitCode` from a single `realpathRefuses(proposal)` check shared across both `--change` and `--all` — CI's `ubuntu-latest` runner showed `--all` - refusing where `--change` did not for the same mode-000 `proposal.md` (a - divergence a plain `oven/bun:1.3.14` Docker probe on this machine could not - reproduce, so its exact mechanism is unconfirmed — GH Actions' own - filesystem/sandboxing is the leading candidate, not a `--all`-specific code - path: the binary's own sweep logic is identical per change)] → Rewrote the row - to never predict a measured exit code: each invocation (`--change`, `--all`) - reads its own `up` answer's shape (`Array.isArray(status)`) to decide the - refused/not-refused branch independently, exactly as the proven 15.11/15.12 - tasks.md rows already do. `cs.exitCode === up.exitCode` is the only - cross-environment invariant asserted; the row now passes however this binary - version and this runner happen to answer. + refusing where `--change` did not for the same mode-000 `proposal.md`] → + Rewrote the row to never predict a measured exit code: each invocation + (`--change`, `--all`) reads its own `up` answer's shape + (`Array.isArray(status)`) to decide the refused/not-refused branch + independently, exactly as the proven 15.11/15.12 tasks.md rows already do. + `cs.exitCode === up.exitCode` is the only cross-environment invariant + asserted; the row now passes however this binary version and this runner + happen to answer. The mechanism itself is confirmed, not a CI-only artifact: + the listed fixture's namespace folder (`mobile`, a folder wrapping a nested + change) makes the binary's own `--all` sweep report a `change_error` ("is not + a change") for `mobile` independent of any lock on `alpha`'s `proposal.md` — + reproduced directly against the pinned binary (both unlocked and locked, on + macOS and in an `oven/bun:1.3.14` container as non-root) — while + `--change alpha` only ever answers for `alpha`, never sweeping `mobile` at + all. Linux's own `realpath` also plays a part (it resolves a mode-000 file + without opening it, so a locked `proposal.md` is read past in every mode there + — only macOS/Bun's `realpath` opens the file and refuses it), but the + `--change` vs `--all` divergence specifically is `mobile`'s doing, not an OS- + or CI-runner-specific `realpath` quirk. - [Item 1 (found in CI, not locally): the first cut of row 18.1's text assertion anchored `r-doc`'s line with `\s+$`, relying on it being the last line of a two-row table — `list`'s default order is recency (mtime), which is diff --git a/openspec/changes/list-status-untyped-leftovers/verification.md b/openspec/changes/list-status-untyped-leftovers/verification.md index 669d3df6..2c31b5d6 100644 --- a/openspec/changes/list-status-untyped-leftovers/verification.md +++ b/openspec/changes/list-status-untyped-leftovers/verification.md @@ -19,9 +19,9 @@ ## 4. `status` already matches the binary on a mode-000 artifact [critical] -- [x] 4.1 @equivalence (agent) macOS: `cospec status --change ` (text and `--json`, single and `--all`) vs the pinned binary (spawned under Bun) with a mode-000 `proposal.md` -> both refuse `EACCES: permission denied, realpath '…/proposal.md'`, exit 1, in every mode — observed directly (ad hoc probe) and via the `18.2` contract row (`realpathRefuses` true on this host): both exit 1, `errnoShape` matches `{code: 'EACCES', path: …/proposal.md}` -- [x] 4.2 @equivalence (agent) Linux (`oven/bun:1.3.14`, non-root UID 1000): same fixture -> both read past it, report `proposal` done, exit 0, in every mode — observed via a local Docker probe (`docker run -u 1000:1000 oven/bun:1.3.14`, bind-mounted tree): both exit 0, both report `proposal` artifact `done`/`status: 'done'`; the `18.2` contract row asserts the same equality on whichever OS runs it (`refused` branches on the live `realpathRefuses` result, never `process.platform`) -- [x] 4.3 @e2e (agent) the new contract row (tasks.md 4.1) green in this PR's CI -> green — observed: PR #62's `ci-bun` run 37283579143 passed (32m4s, `ubuntu-latest` — this repo's CI has no macOS runner); row 18.2 (the differential: measures each invocation's own answer rather than predicting one, after a first CI run found `--change` vs `--all` disagreeing on refusal on this runner) passed, confirming the behavior holds under the real CI container too, not just the local Docker probe +- [x] 4.1 @equivalence (agent) macOS: `cospec status --change ` (text and `--json`, single and `--all`) vs the pinned binary (spawned under Bun) with a mode-000 `proposal.md` -> both refuse `EACCES: permission denied, realpath '…/proposal.md'`, exit 1, in every mode — observed directly (ad hoc probe, re-confirmed against the pinned binary spawned under `bun` directly: `--change alpha` and `--all` both exit 1 with that document) and via the `18.2` contract row, which measures each invocation's own answer (`Array.isArray(status)`) rather than citing a shared `realpathRefuses` prediction (that helper was dropped from row 18.2 in commit `a59e9b9b`; it is still used by unrelated rows elsewhere in the file): both exit 1, `errnoShape` matches `{code: 'EACCES', path: …/proposal.md}` +- [x] 4.2 @equivalence (agent) Linux (`oven/bun:1.3.14`, non-root UID 1000): same fixture -> `--change alpha` reads past the lock in every mode (Linux's `realpath` needs no read permission, unlike macOS/Bun's), reports `proposal` done, exit 0 — re-confirmed directly via a local Docker probe (`docker run -u 1000:1000 oven/bun:1.3.14`, bind-mounted tree, pinned binary spawned under `bun`): both cospec and the binary exit 0, both report `proposal` artifact `done`/`status: 'done'`. `--all` does **not** also exit 0 "in every mode" as this row previously said: both cospec and the binary exit 1 there whether or not `proposal.md` is locked, because `--all`'s sweep also walks `mobile`, the list fixture's own namespace folder (a folder wrapping a nested change with no change of its own directly under `openspec/changes/`), which the binary reports as its own `change_error` ("… is not a change: it is a folder wrapping …") independent of any lock — confirmed by probing the pinned binary directly (unlocked and locked) on both macOS and in the same Docker container: `alpha`'s own entry is `ok`/not refused by the lock on Linux, `mobile`'s entry is a `change_error` every time. The `18.2` contract row's equality assertion (`cs.exitCode === up.exitCode`) already held regardless of this, since it never predicted a code; only this row's and design.md's narrative explanation were wrong, and `18.2`'s own comment repeated the same misdiagnosis (now corrected alongside this row) +- [x] 4.3 @e2e (agent) the new contract row (tasks.md 4.1) green in this PR's CI -> green — observed: PR #62's `ci-bun` run 37283579143 passed (32m4s, `ubuntu-latest` — this repo's CI has no macOS runner); row 18.2 (the differential: measures each invocation's own answer rather than predicting one, after a first CI run found `--change` vs `--all` disagreeing on refusal on this runner) passed, confirming the differential measurement holds under the real CI container too, not just the local Docker/macOS probes used to write rows 4.1–4.2 ## 5. Full gate From d3f959b244f9818a69695a02a9e243c0e909c62a Mon Sep 17 00:00:00 2001 From: replygirl Date: Mon, 5 Oct 2026 05:00:35 -0500 Subject: [PATCH 12/15] chore(cli): record the true red/green for validate --archived --json Ledger row 2.2's recorded observation was only that the test file wouldn't compile against unmodified validate.ts, not a demonstration that the test could catch the bug. Row 2.3 records the actual red/green now available: reverting the command's refusal write to its pre-fix unconditional stderr line fails the new command-level test added in a34862b4, confirmed by hand (reverted, ran red, restored, ran green). Co-Authored-By: Claude Sonnet 5 --- openspec/changes/list-status-untyped-leftovers/verification.md | 1 + 1 file changed, 1 insertion(+) diff --git a/openspec/changes/list-status-untyped-leftovers/verification.md b/openspec/changes/list-status-untyped-leftovers/verification.md index 2c31b5d6..89f8d7cc 100644 --- a/openspec/changes/list-status-untyped-leftovers/verification.md +++ b/openspec/changes/list-status-untyped-leftovers/verification.md @@ -11,6 +11,7 @@ - [x] 2.1 @unit (agent) `wrappedOpenspecVersion` stubbed below `ARCHIVED_SINCE`, `validate --archived --json` -> one parseable JSON document on stdout, not stderr text — observed: factored `archivedUnsupportedRefusal(version)` (pure, no spawn) unit-tested directly; `validate.test.ts` "archivedUnsupportedRefusal" passes, `rootSelectionDocument(refusal)` parses to one `status[]` document - [x] 2.2 @regression (agent) the same unit test before the fix FAILS (stderr text, unparseable stdout), after the fix PASSES -> captured in the commit that lands 2.2 — observed: `archivedUnsupportedRefusal` did not exist before this commit (the guard wrote stderr text unconditionally inline); the test file would not compile against unmodified `validate.ts`, green once the export landed +- [x] 2.3 @regression (agent) a true command-level red/green, closing the gap 2.1/2.2 left (they only unit-test the pure helper, never the command's own `flags.json` branch, and every contract/integration row runs the real pinned binary, always >= `ARCHIVED_SINCE`, so that branch was never exercised by any test) -> `run`'s new injectable `deps.wrappedOpenspecVersion` drives `validate --archived --json`/text end to end with a stubbed `1.8.0`; reverting the command's refusal write to its pre-fix unconditional stderr line (manually, then restored) FAILS `validate.test.ts`'s new "validate --archived: the command's own refusal branch below the version floor" describe (`expect(r.err).toBe('')` got the stderr line instead) — observed: red with the reverted branch (`Received: "cospec: validate --archived needs OpenSpec >=1.9.0; the wrapped OpenSpec is 1.8.0\n"` on stderr where `''` was expected), green restored ## 3. `upstream-spellings.test.ts` row 3.7 is deterministic From 9c765a1f283869ff01a05d236e8bb2dbea880832 Mon Sep 17 00:00:00 2001 From: replygirl Date: Mon, 5 Oct 2026 05:22:10 -0500 Subject: [PATCH 13/15] fix(cli): never give a namespace folder config.yaml's schema fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The config.yaml-schema fallback added for a genuinely bare change also caught a namespace folder (no .openspec.yaml of its own, only a nested child one level down): its row got typed by the project's default schema (e.g. 'feat') instead of staying '(none)', even though its state already correctly reports it as 'not-a-change'. cospec status --all avoids this the same way — it discards a namespace folder's gradedChange-resolved schema entirely, reporting a failure entry with no type field. Caught by the existing contract key-oracle row (1.1), which still had mobile expecting type '(none)'; added a faster unit regression alongside it. Co-Authored-By: Claude Sonnet 5 --- apps/cli/src/commands/list.ts | 6 +++++- apps/cli/test/unit/commands/commands.test.ts | 20 ++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/commands/list.ts b/apps/cli/src/commands/list.ts index d083cbb6..33657024 100644 --- a/apps/cli/src/commands/list.ts +++ b/apps/cli/src/commands/list.ts @@ -228,7 +228,11 @@ function computeRow( // do — the project's `config.yaml` `schema:`, else `spec-driven` — so a // custom-named artifact under that fallback schema is never reported as no // artifacts at all, and the row's type/completeness agree with `status`. - const bare = !existsSync(join(dir, '.openspec.yaml')) + // A namespace folder is not a change at all (`state` below reports it as + // such), so it never takes this fallback — `status --all` discards its + // `gradedChange`-resolved schema the same way, reporting it as a failure + // entry with no `type` field rather than the project's default schema. + const bare = finding === undefined && !existsSync(join(dir, '.openspec.yaml')) const schema = bare ? defaultProjectSchema(base) : (readOpenspecYaml(dir)?.schema ?? '') const blockersPath = join(dir, 'blocking-changes.md') const gate = existsSync(blockersPath) diff --git a/apps/cli/test/unit/commands/commands.test.ts b/apps/cli/test/unit/commands/commands.test.ts index d9cdc6b3..c09703a2 100644 --- a/apps/cli/test/unit/commands/commands.test.ts +++ b/apps/cli/test/unit/commands/commands.test.ts @@ -746,6 +746,26 @@ describe('list', () => { expect(row?.state).toBe('building') }) + test("a namespace folder never takes config.yaml's schema fallback (it is not a change)", async () => { + const cwd = repo('feat') + // A namespace folder (design D2): no `.openspec.yaml` of its own, only a + // nested child one level down. It also has no `.openspec.yaml`, so the + // bare-schema fallback must not mistake it for an ordinary bare change — + // it is reported as `not-a-change`, never typed by config.yaml's default. + const nested = join(cwd, 'openspec', 'changes', 'mobile', 'refresh-token') + mkdirSync(nested, { recursive: true }) + writeFileSync(join(nested, '.openspec.yaml'), 'schema: feat\ncreated: 2026-09-01\n') + + const r = await runCmd(listRun, ctx(cwd, [], { json: true, command: 'list' })) + expect(r.code).toBe(0) + const parsed = JSON.parse(r.out) as { + changes: { change: string; type: string; state: string }[] + } + const row = parsed.changes.find((c) => c.change === 'mobile') + expect(row?.state).toBe('not-a-change') + expect(row?.type).toBe('(none)') + }) + test('a declared schema that fails to load warns on the row instead of silently reporting empty', async () => { const cwd = repo() mkdirSync(join(cwd, 'openspec', 'schemas', 'broken'), { recursive: true }) From 1c9a66cffa0657a853134200918cf2f7ab3c4dcd Mon Sep 17 00:00:00 2001 From: replygirl Date: Mon, 5 Oct 2026 06:47:07 -0500 Subject: [PATCH 14/15] docs(cli): close out list-status-untyped-leftovers before archive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tick tasks.md's final row now that the archive commit follows this one, and refresh verification row 5.1 with the fresh mise run check counts from a re-run at the merge stage (rebased onto main, four review findings fixed: config.yaml schema fallback, namespace-folder guard, the command-level --archived --json test, 18.2/ledger correction) — 1974 unit tests (was 1954), same contract/integration/ bench/e2e counts, all green. Co-Authored-By: Claude Sonnet 5 --- openspec/changes/list-status-untyped-leftovers/tasks.md | 2 +- openspec/changes/list-status-untyped-leftovers/verification.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openspec/changes/list-status-untyped-leftovers/tasks.md b/openspec/changes/list-status-untyped-leftovers/tasks.md index c2e0db3a..f3df1073 100644 --- a/openspec/changes/list-status-untyped-leftovers/tasks.md +++ b/openspec/changes/list-status-untyped-leftovers/tasks.md @@ -60,4 +60,4 @@ - [x] 5.1 `mise run check` green (lint, format, typecheck, unit, contract, integration, pack smoke). -- [ ] 5.2 the archive commit follows this one +- [x] 5.2 the archive commit follows this one diff --git a/openspec/changes/list-status-untyped-leftovers/verification.md b/openspec/changes/list-status-untyped-leftovers/verification.md index 89f8d7cc..99ee5242 100644 --- a/openspec/changes/list-status-untyped-leftovers/verification.md +++ b/openspec/changes/list-status-untyped-leftovers/verification.md @@ -26,4 +26,4 @@ ## 5. Full gate -- [x] 5.1 @e2e (agent) `mise run check` -> green — observed: lint, format:check, typecheck, generate:check, vendor:openspec:check, cospec-validate-all, agents:check, openspec:schema:validate all pass; `apps/cli:test` 1954 pass/0 fail, `apps/cli:test:integration` 193 pass/0 fail, `apps/cli:test:contract` 2523 pass/0 fail, `packages/bench:test` 343 pass/0 fail, `e2e:release-test` 14 pass/0 fail; `Finished in 1403.68s`, exit 0 +- [x] 5.1 @e2e (agent) `mise run check` -> green — observed: lint, format:check, typecheck, generate:check, vendor:openspec:check, cospec-validate-all, agents:check, openspec:schema:validate all pass; `apps/cli:test` 1974 pass/0 fail, `apps/cli:test:integration` 193 pass/0 fail, `apps/cli:test:contract` 2523 pass/0 fail, `packages/bench:test` 343 pass/0 fail, `e2e:release-test` 14 pass/0 fail; `Finished in 1458.54s`, exit 0 — re-run at the merge stage after rebasing onto main and landing the four review-finding fixes (config.yaml schema fallback, namespace-folder guard, the command-level `--archived --json` test, and the 18.2/ledger correction); `mise run cospec -- validate list-status-untyped-leftovers --strict` also passed (0 errors, 0 warnings) From 5847cff4147323d44df12f0b2d399e4c9848235f Mon Sep 17 00:00:00 2001 From: replygirl Date: Mon, 5 Oct 2026 06:48:01 -0500 Subject: [PATCH 15/15] chore(cli): archive list-status-untyped-leftovers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Archived via cospec archive — validated, no hard-gate refusal. Specs: skipped (fix schema has no spec-sync deltas). Co-Authored-By: Claude Sonnet 5 --- .../2026-10-05-list-status-untyped-leftovers}/.openspec.yaml | 0 .../2026-10-05-list-status-untyped-leftovers}/blocking-changes.md | 0 .../2026-10-05-list-status-untyped-leftovers}/design.md | 0 .../2026-10-05-list-status-untyped-leftovers}/proposal.md | 0 .../2026-10-05-list-status-untyped-leftovers}/tasks.md | 0 .../2026-10-05-list-status-untyped-leftovers}/verification.md | 0 6 files changed, 0 insertions(+), 0 deletions(-) rename openspec/changes/{list-status-untyped-leftovers => archive/2026-10-05-list-status-untyped-leftovers}/.openspec.yaml (100%) rename openspec/changes/{list-status-untyped-leftovers => archive/2026-10-05-list-status-untyped-leftovers}/blocking-changes.md (100%) rename openspec/changes/{list-status-untyped-leftovers => archive/2026-10-05-list-status-untyped-leftovers}/design.md (100%) rename openspec/changes/{list-status-untyped-leftovers => archive/2026-10-05-list-status-untyped-leftovers}/proposal.md (100%) rename openspec/changes/{list-status-untyped-leftovers => archive/2026-10-05-list-status-untyped-leftovers}/tasks.md (100%) rename openspec/changes/{list-status-untyped-leftovers => archive/2026-10-05-list-status-untyped-leftovers}/verification.md (100%) diff --git a/openspec/changes/list-status-untyped-leftovers/.openspec.yaml b/openspec/changes/archive/2026-10-05-list-status-untyped-leftovers/.openspec.yaml similarity index 100% rename from openspec/changes/list-status-untyped-leftovers/.openspec.yaml rename to openspec/changes/archive/2026-10-05-list-status-untyped-leftovers/.openspec.yaml diff --git a/openspec/changes/list-status-untyped-leftovers/blocking-changes.md b/openspec/changes/archive/2026-10-05-list-status-untyped-leftovers/blocking-changes.md similarity index 100% rename from openspec/changes/list-status-untyped-leftovers/blocking-changes.md rename to openspec/changes/archive/2026-10-05-list-status-untyped-leftovers/blocking-changes.md diff --git a/openspec/changes/list-status-untyped-leftovers/design.md b/openspec/changes/archive/2026-10-05-list-status-untyped-leftovers/design.md similarity index 100% rename from openspec/changes/list-status-untyped-leftovers/design.md rename to openspec/changes/archive/2026-10-05-list-status-untyped-leftovers/design.md diff --git a/openspec/changes/list-status-untyped-leftovers/proposal.md b/openspec/changes/archive/2026-10-05-list-status-untyped-leftovers/proposal.md similarity index 100% rename from openspec/changes/list-status-untyped-leftovers/proposal.md rename to openspec/changes/archive/2026-10-05-list-status-untyped-leftovers/proposal.md diff --git a/openspec/changes/list-status-untyped-leftovers/tasks.md b/openspec/changes/archive/2026-10-05-list-status-untyped-leftovers/tasks.md similarity index 100% rename from openspec/changes/list-status-untyped-leftovers/tasks.md rename to openspec/changes/archive/2026-10-05-list-status-untyped-leftovers/tasks.md diff --git a/openspec/changes/list-status-untyped-leftovers/verification.md b/openspec/changes/archive/2026-10-05-list-status-untyped-leftovers/verification.md similarity index 100% rename from openspec/changes/list-status-untyped-leftovers/verification.md rename to openspec/changes/archive/2026-10-05-list-status-untyped-leftovers/verification.md