Skip to content

Commit a325305

Browse files
clay-goodclaude
andauthored
fix(resolution): converge validate, view, and archive onto canonical resolution (#1182, #1202, #1156) (#1280)
* docs(openspec): propose resolution/validation parity bug bundle (#1182, #1202, #1156) Planning artifacts only (proposal/design/spec deltas/tasks) for a focused bug-fix bundle. Three read/validate paths silently diverge from the canonical logic a sibling command already gets right: - #1182 validate ignores workspace planning homes that status/instructions resolve - #1202 view counts only changes/<name>/tasks.md, ignoring the schema tasks glob - #1156 the SHALL/MUST body-keyword hint fires for deltas but not main specs Fix converges each divergent path onto the canonical one; parity is asserted by test. No new surface, no behavior change to the already-correct paths. Validates --strict. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(openspec): bulletproof the parity bundle after adversarial source review Hardened all three bugs after tracing each path to source with parallel verification agents. Material corrections: - #1182: reframed from "workspace planning home resolution" (planning homes are repo-only; the feature is now 'stores', and validate already accepts --store) to the real, reproducible-at-HEAD mechanism: validate's proposal.md membership gate (getActiveChangeIds) vs status/instructions' directory-existence rule (validateChangeExists). Pulled nested specs/<area>/<cap> delta discovery and bulk --all into scope; noted show.ts sibling. - #1202: widened from view-only to the shared helper's real blast radius — also the archive incomplete-task gate (silently archives unfinished glob-tasks changes: data safety) and a 2nd hardcoded copy in change.ts. Pinned apply.tracks as the source, change-dir scope containment, and the no-schema fallback. Added cli-archive delta for the gate. - #1156: the main-spec parser discards the requirement header before Zod runs, so the hint can't be "lifted" — fix needs header recovery (reuse requirement-blocks) + Zod de-dup, and the main-spec message can't be byte-identical to the delta's (no ADDED prefix). Pinned the actionable sentence + single-emission + regression scenarios across all main-spec surfaces. 4 deltas (cli-validate x2, cli-view, cli-archive). Validates --strict. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(openspec): deep-harden the parity bundle with empirical reproduction Round 2 of bulletproofing: 3 parallel agents reproduced every bug against the built (pre-fix) CLI and traced fix sites. This pass corrected two substantive errors in my own prior spec and closed several gaps. #1202 (two corrections to the prior draft): - apply.tracks is a FILENAME that selects the tracked artifact, NOT a glob; the glob is that artifact's `generates`. status resolves via resolveArtifactOutputs(changeDir, artifact.generates). Fixed all wording. - "view/archive counts equal status" is FALSE: status checks file EXISTENCE, not checkboxes (proven: status calls a 3/5 change isComplete:true). Deleted the two count-parity scenarios; reframed as resolution-mechanism parity (same files). - Added schema-resolution-failure fallback (resolveSchema throws; helper must catch or view/list/archive crash). Added projectRoot param + 6-site wiring. - Empirically PROVEN data-safety bug: archive moved a 3/5 unfinished change into changes/archive/. #1182: - Found a THIRD getActiveChangeIds site (interactive selector, validate.ts:97). - Proven: --all with a lone proposal-less change exits 0 silently. Added exit-code scenarios. Trimmed over-scope: getSpecIds spec-side is NOT a bug; no store-specific scenario needed; noun-form scoped out. #1156: - Refine-relaxation regression resolved: deltas don't use the Zod refine (validate imperatively), so REMOVE it (not relax) once applySpecRules owns both header-only and no-keyword cases. Added RENAMED (out-of-scope), lowercase, and the new no-body-line-valid-today scenarios; pinned exact message + prefix. Still 4 deltas; validates --strict; empirical evidence section added to design. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: converge validate/view/archive onto canonical resolution (#1182, #1202, #1156) Implements the resolution/validation parity bug bundle planned in openspec/changes/fix-validate-view-resolution-parity. Each fix points a divergent read/validate path at the canonical implementation a sibling command already gets right, with parity tests guarding against re-forking. #1182 — validate resolves changes like status. validate now resolves a change by directory existence (shared getAvailableChanges) instead of requiring proposal.md, at all three sites (targeted, bulk, interactive selector). A scaffolded/still-authoring change is validated rather than reported Unknown item; a resolved-but-invalid change exits non-zero. show.ts and the deprecated noun-form change validate are scoped out. #1182b — validateChangeDeltaSpecs recurses the nested multi-area layout (specs/<area>/<capability>/spec.md) via a new findDeltaSpecFiles walker, so a resolved multi-area change validates its deltas instead of reporting "No delta sections found". #1202 — getTaskProgressForChange resolves task progress through the tracked-tasks artifact's generates glob (the same resolveArtifactOutputs status uses), aggregating checkboxes across every matched tasks.md scoped to the change dir, with a never-throw fallback to a single top-level tasks.md. Updates all four callers (view/list/archive x2) for the new projectRoot arg and folds the second copy in change.ts onto the helper. Fixes view's Draft misclassification and the archive incomplete-task gate that let an unfinished glob-tasks change archive (data safety). #1156 — the SHALL/MUST body-keyword hint applies to main specs. applySpecRules recovers the requirement header via extractRequirementsSection and emits the targeted hint (header-only) or generic message (no keyword), exactly once; the Zod refine is removed (deltas never used it). The actionable sentence is byte-identical to the change-delta path. Adds parity/regression tests (Decision 7): validate<->status resolution incl. exit code, view/archive resolve the same files as status, and the main-spec<->delta actionable-sentence parity. Full suite green (1791 passed; only the pre-existing, environment-specific zsh-installer failures remain). Change validates --strict; all 36 repo specs pass --specs --strict with no new false positives. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 546224e commit a325305

21 files changed

Lines changed: 1033 additions & 78 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@fission-ai/openspec": patch
3+
---
4+
5+
### Bug Fixes
6+
7+
- **`validate` resolves changes like `status`**`openspec validate <change>` (and `--all`/`--changes` and the interactive selector) now resolves a change by directory existence, matching `status`/`instructions`, instead of requiring `proposal.md`. A scaffolded or still-authoring change is validated rather than reported as `Unknown item`, and a resolved-but-invalid change now exits non-zero. Delta discovery also recurses the nested `specs/<area>/<capability>/spec.md` layout. (#1182)
8+
- **Task progress reads nested/glob `tasks.md`**`openspec view`, `list`, and the `archive` incomplete-task gate now resolve task progress through the tracked-tasks artifact's `generates` glob (the same file-resolution `status` uses), so a change whose tasks live in nested `tasks.md` files is classified correctly and can no longer archive while unfinished. (#1202)
9+
- **SHALL/MUST body-keyword hint applies to main specs** — A main-spec requirement whose normative keyword sits only in the `### Requirement:` header now receives the same targeted "move it to the body line" remediation as a change delta, emitted exactly once. (#1156)
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
schema: spec-driven
2+
created: 2026-06-29
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
# Design
2+
3+
## Context
4+
5+
This is a bug-fix bundle, not a feature. The three issues are grouped because they share one structural defect: a read/validate command forks its own resolution or validation logic instead of reusing the canonical implementation a sibling command already gets right. Fixing them together lets the implementation converge the divergent paths onto shared helpers in one pass, and lets one set of *parity tests* guard all three against future drift.
6+
7+
The unifying invariant this change establishes:
8+
9+
> A command that reports on or validates a change MUST resolve files the same way `openspec status` does, and MUST produce the same requirement-quality messages the change-delta validator does. Divergence is a bug, and parity is asserted by test.
10+
11+
Every claim below was verified against source at the base commit `546224e` **and reproduced empirically against the built (pre-fix) CLI**. The reproductions are summarized under "Empirical evidence." The framings that changed under this review are flagged inline; two of them (the #1202 `apply.tracks` mechanism and the "agrees with status" count claim) were corrections to an earlier draft of this very proposal.
12+
13+
## Root causes (verified)
14+
15+
| # | Symptom | Canonical path (correct) | Divergent path (bug) | Anchor |
16+
|---|---------|--------------------------|----------------------|--------|
17+
| #1182 | `validate <change>``Unknown item`; `--all` → "No items found" | `status`/`instructions` resolve by **directory existence** (`validateChangeExists`) | `validate` resolves via `getActiveChangeIds`, which **requires `proposal.md`** | `src/commands/validate.ts:97,120,238`; `src/utils/item-discovery.ts:11-16`; `src/commands/workflow/shared.ts:168-170`; scaffolder omits proposal.md `src/utils/change-utils.ts:121-210` |
18+
| #1182b | resolved nested-layout change → "No delta sections found" | spec-driven specs glob is `specs/**/*.md` | `validateChangeDeltaSpecs` discovers deltas one level deep only | `src/core/validation/validator.ts:115-138,265` |
19+
| #1202 | `view` shows a tasked change as `Draft`; `archive` archives an unfinished change | `status` tests the tasks **artifact's `generates` glob** via `resolveArtifactOutputs` | `getTaskProgressForChange` hardcodes `changes/<name>/tasks.md` | `src/utils/task-progress.ts:28`; callers `src/core/view.ts:100`, `src/core/list.ts:112`, `src/core/archive.ts:342,540`; 2nd copy `src/commands/change.ts:111,164`; helper `src/core/artifact-graph/outputs.ts:17`; tracks type `src/core/artifact-graph/types.ts:18` |
20+
| #1156 | Main-spec SHALL-in-header-only → generic error; delta → targeted hint | Delta validator runs `containsShallOrMust` + `buildMissingShallOrMustMessage` | Main-spec requirement validation falls through to generic `REQUIREMENT_NO_SHALL`; header is discarded before validation | `src/core/validation/validator.ts:167-189,443-463`; `src/core/schemas/base.schema.ts:11-14`; `src/core/parsers/markdown-parser.ts:220-226` |
21+
22+
## Empirical evidence (built pre-fix CLI, fresh `init`'d projects)
23+
24+
- **#1182:** `new change foo` writes `changes/foo/.openspec.yaml` only. `status --change foo` resolves (exit 0); `validate foo``Unknown item 'foo'` (exit 1); `validate --all` with foo as the sole change → `No items found to validate` (**exit 0**, a silent CI failure). Writing `proposal.md` flips `validate` to resolve — confirming the exact lever. A valid two-level `specs/<area>/<cap>/spec.md` change → `No deltas found` (#1182b); one-level control validates clean.
25+
- **#1202:** project-local schema with tasks `generates: "**/tasks.md"`; change `foo` = `backend/tasks.md` (2/2) + `frontend/tasks.md` (1/3) = 3/5, no top-level file. `status``4/4 artifacts complete, isComplete:true` (file existence, not checkboxes); `view`**Draft**; `list``No tasks`; `list --json``totalTasks:0`. `archive foo --skip-specs --no-validate --yes` **moved the unfinished change into `changes/archive/`** — the incomplete-task gate was wholly bypassed. Baselines (default schema top-level `tasks.md`; bare project) classify correctly and are preserved by the fix.
26+
- **#1156:** main spec, SHALL in header only → generic `Requirement must contain SHALL or MUST keyword`. The same mistake as an ADDED/MODIFIED delta → the targeted hint. RENAMED delta → no error (no body). No-keyword-anywhere main spec → generic error. Lowercase `shall` → error on both paths. **Header-only with no body line at all → reported VALID today** (parser keeps `text` = header, which contains SHALL).
27+
28+
## Decisions
29+
30+
### Decision 1 — Converge, don't re-implement
31+
32+
Each fix points the divergent path at the *existing* canonical implementation rather than writing a second copy. A second copy is what created every one of these bugs.
33+
34+
### Decision 2 — #1182: the lever is the membership gate, not "workspace homes"
35+
36+
The original framing (validate doesn't understand workspace planning homes) is wrong at HEAD: planning homes are repo-only (`PlanningHomeKind = 'repo'`), the workspace feature is now **stores**, and `validate` already accepts `--store` and resolves the store root through the same `resolveRootForCommand` as `status`. The actual, reproducible divergence is that `validate` gates change membership on `proposal.md` (`getActiveChangeIds`) at **three** sites — targeted (validate.ts:120), bulk (validate.ts:238), and the interactive "pick one" selector (validate.ts:97) — while `status`/`instructions` gate on directory existence (`validateChangeExists`). Since `createChange` writes `.openspec.yaml` but not `proposal.md`, any scaffolded or still-authoring change resolves everywhere except `validate`.
37+
38+
The fix: `validate` resolves a change by directory existence within the already-resolved root, at all three sites. This is store-correct for free (the store root is resolved identically by all three commands), so the reported store/workspace symptom is covered transitively, and no store-specific scenario is needed. `getChangeDir`/`resolveCurrentPlanningHomeSync` are **not** the lever (the former is a pure path join with no membership decision).
39+
40+
Two boundaries confirmed empirically and held out of scope: (a) the **spec** side is correct — `getSpecIds` requires `spec.md`, and `spec show` agrees, so a spec dir without `spec.md` is correctly "not found"; no spec-side scenario is added. (b) The deprecated noun-form `openspec change validate <name>` already resolves a passed name by directory existence (change.ts:215) but is cwd-based (cannot reach a `--store` root) and its JSON mode does not set a non-zero exit on invalid — pre-existing noun-form defects, explicitly not addressed here.
41+
42+
### Decision 3 — #1182: nested delta discovery is in scope
43+
44+
Resolution success is not validation success. `validateChangeDeltaSpecs` discovers deltas exactly one directory deep (`changeDir/specs/<dir>/spec.md`), but the multi-area layout that motivates stores/workspaces is `changeDir/specs/<area>/<capability>/spec.md`. Without recursing, a resolved multi-area change reports "No delta sections found" (reproduced). So delta discovery is extended to the nested layout in this change; otherwise the #1182 fix does not actually let the reported change validate.
45+
46+
### Decision 4 — #1202: resolve via the tracked artifact's `generates` glob, and parity is resolution-only
47+
48+
The fix lands in the shared helper `getTaskProgressForChange`, correcting all four call sites at once; the spec pins two consumers explicitly (`cli-view` — the filed Draft symptom; `cli-archive` — the incomplete-task gate, a data-safety regression that lets an unfinished change archive). `openspec list` is corrected by the same helper; the independent second copy in `openspec change list` (`change.ts:111,164`, its own `countTasks`) is folded onto the shared helper by a task — not left as an orphan.
49+
50+
Two corrections to an earlier draft, both load-bearing:
51+
52+
- **`apply.tracks` is a filename that *selects* the artifact; it is not the glob.** `apply.tracks` is typed `string | null` and is consumed elsewhere as a literal path (`path.join(changeDir, tracks)` + `existsSync`), so `apply.tracks: "**/tasks.md"` cannot match nested files. The glob `status` actually uses is the tracked artifact's **`generates`**, resolved by `resolveArtifactOutputs(changeDir, artifact.generates)`. So the fix identifies the tracked-tasks artifact (the artifact whose `generates` equals `apply.tracks`, falling back to artifact id `tasks` when no `apply` block is present), then counts checkboxes across `resolveArtifactOutputs(changeDir, thatArtifact.generates)`. `resolveArtifactOutputs` roots `fast-glob` at the change directory (so a sibling `changes/archive/` or another change's `tasks.md` cannot match) and de-dups via a `Set` (so no double counting).
53+
- **`status` checks file *existence*, not checkbox completion.** Empirically `status` calls a 3/5 change `4/4 complete, isComplete:true`. So the parity established here is **resolution-mechanism parity** (`view`/`archive` resolve the same set of files `status` resolves), not count parity — `view`/`archive` additionally count checkboxes. Any "view/archive task counts equal status" claim is false and is removed from the spec.
54+
55+
The signature gains `projectRoot` (needed to resolve project-local schemas via `resolveSchema`); all four call sites plus the two `change.ts` sites can derive it. `resolveSchema` **throws** on an unresolvable/misnamed schema, whereas the current helper never throws — so the helper MUST catch and fall back to single-file `tasks.md`, or `view`/`list`/`archive` would crash on a project whose config names a deleted schema. This fallback is specified and tested.
56+
57+
### Decision 5 — #1156: recover the header, remove the refine, pin an exact (not byte-identical) message
58+
59+
The targeted delta hint works because the delta parser keeps the requirement header (`RequirementBlock.name`) separate from the body. The **main-spec parser overwrites the header with the first body line** (`markdown-parser.ts:220-226`) before validation, so the Zod refine that emits `REQUIREMENT_NO_SHALL` never sees the header and cannot detect "keyword in header only."
60+
61+
The fix:
62+
63+
1. **Recover the header.** Reuse the header-preserving parser `src/core/parsers/requirement-blocks.ts` (`extractRequirementsSection`, which yields header+body pairs and is the same source the delta path trusts) and run the existing `containsShallOrMust` + `buildMissingShallOrMustMessage` detection in the imperative main-spec rules (`applySpecRules`, validator.ts:290-329), which already loops requirements and has the raw content.
64+
2. **Remove the Zod refine, don't merely relax it.** Change deltas do **not** use the refine — they validate imperatively in `validateChangeDeltaSpecs` (proven: a no-keyword delta emits the imperative `must contain SHALL or MUST` base string, not the Zod `REQUIREMENT_NO_SHALL` string). So the refine is exercised only on the main-spec path. Once the imperative rule in `applySpecRules` owns **both** sub-cases — keyword-in-header-only → targeted hint, and keyword-nowhere → generic message — the `.refine` on `RequirementSchema` (base.schema.ts:11-14) is **removed entirely**. Keeping a conditional refine "for the no-keyword case only" risks double-emission on the header-only case, which the "exactly one issue" scenario forbids.
65+
3. **Message.** The actionable sentence is byte-identical to the delta path; the prefix differs (main specs have no `ADDED`/`MODIFIED`). The main-spec message is: `Requirement "<name>" must contain SHALL or MUST in the requirement body, not only in the header. Move the SHALL/MUST statement to the line immediately after the "### Requirement: ..." header.` Generalize `buildMissingShallOrMustMessage` to accept the prefix so the actionable sentence lives in one place and cannot drift between paths. Lowercase is rejected via the shared `\b(SHALL|MUST)\b` regex (converging the main-spec path off the case-sensitive Zod `.includes`).
66+
67+
RENAMED requirements carry no body and are not subject to the hint (the `'ADDED' | 'MODIFIED'` action set is correct); a scenario pins this so it is not mistaken for a gap.
68+
69+
### Decision 6 — Additive coverage, with one intended behavior change called out
70+
71+
The fixes are additive coverage: changes that already have `proposal.md`, single-file `tasks.md` projects, projects with no resolvable schema, and delta-spec validation produce byte-identical output before and after. One main-spec case is an **intended** behavior change, not an unchanged case: a requirement with the keyword in the header and **no body line at all** is reported valid today and becomes a body-keyword hint under header recovery (the delta path already errors on this case). This is called out explicitly so it is not discovered as an accidental regression; every other previously-passing case is unchanged.
72+
73+
### Decision 7 — Parity is the test strategy
74+
75+
Tests assert *agreement*, not just fixed outputs in isolation:
76+
77+
- a change that `status --change <name>` resolves (including a proposal-less and a store change) is also resolved by `validate <name>`, included by `validate --all`, and listed by the interactive selector; a resolved-but-invalid change exits non-zero;
78+
- for a schema whose tracked-tasks `generates` is `**/tasks.md`, `view`, `list`, and the `archive` gate resolve the **same set of files** `status` resolves (and additionally count checkboxes consistently with each other);
79+
- a requirement with SHALL/MUST in the header only yields the same actionable sentence whether it appears in `openspec/specs/**` or a change delta, emitted exactly once.
80+
81+
Parity assertions fail loudly if a future refactor re-forks any path.
82+
83+
### Decision 8 — Scope boundary against sibling proposals
84+
85+
- #1112 (delta header absent from base passing `validate`, aborting at `archive`) is an *authoring* false-positive resolved by the deterministic `sync --check` gate in the sync/unarchive proposal — out of scope here.
86+
- Artifact *completeness* gaps (a half-written or skipped artifact reported as done, #1084/#1260) belong to the artifact-graph/update-workflow proposal — out of scope here. #1202 is narrower: *where* task counts are read from, not whether the tasks are complete.
87+
88+
## Risks and mitigations
89+
90+
- **Risk:** relaxing the change membership gate changes ambiguity behavior when a name exists as both a change directory and a spec. **Mitigation:** preserve the existing ambiguity/`--type` semantics; only swap the change-membership predicate (proposal.md → directory existence) at all three sites, keeping `getSpecIds` as the spec predicate. Covered by an ambiguity scenario.
91+
- **Risk:** the task-progress signature change breaks the other call sites, or crashes on an unresolvable schema. **Mitigation:** update all six sites (four helper callers + two `change.ts` copies) in the same change; catch `resolveSchema` failure and fall back to single-file `tasks.md`; assert `view`/`archive` resolve the same files as `status`.
92+
- **Risk:** the glob over-matches or the archive gate regresses. **Mitigation:** reuse `resolveArtifactOutputs` (rooted at the change dir, de-duped); add scope-containment and archive-gate scenarios.
93+
- **Risk:** removing the Zod refine drops the no-keyword error on the main-spec path. **Mitigation:** the imperative `applySpecRules` rule must own the no-keyword case before the refine is removed; assert the no-keyword regression and single emission. Delta validation is untouched (it never used the refine).

0 commit comments

Comments
 (0)