From e3c8d3480a76e63289d717d0aa37ace3c51ac487 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Wed, 22 Jul 2026 10:44:06 -0500 Subject: [PATCH] feat(validate): accept zero-delta changes that declare skip_specs Squashed for rebase; see PR #1399 for the full commit history. --- .changeset/skip-specs-explicit-zero-delta.md | 5 + docs/agent-contract.md | 4 +- docs/cli.md | 8 +- docs/concepts.md | 2 +- docs/examples.md | 11 +- schemas/spec-driven/schema.yaml | 13 + schemas/spec-driven/templates/proposal.md | 6 +- skills/openspec-archive-change/SKILL.md | 4 +- skills/openspec-continue-change/SKILL.md | 5 +- skills/openspec-ff-change/SKILL.md | 6 +- skills/openspec-propose/SKILL.md | 6 +- skills/openspec-update-change/SKILL.md | 2 +- src/commands/change.ts | 27 +- src/commands/validate.ts | 23 +- src/commands/workflow/instructions.ts | 28 +- src/commands/workflow/shared.ts | 8 +- src/commands/workflow/status.ts | 10 +- src/core/archive.ts | 28 +- src/core/artifact-graph/instruction-loader.ts | 63 ++- src/core/change-metadata/schema.ts | 6 + src/core/change-status-policy.ts | 2 +- .../templates/workflows/archive-change.ts | 8 +- .../templates/workflows/continue-change.ts | 10 +- src/core/templates/workflows/ff-change.ts | 12 +- src/core/templates/workflows/propose.ts | 12 +- src/core/templates/workflows/update-change.ts | 4 +- src/core/validation/constants.ts | 8 +- src/core/validation/validator.ts | 62 ++- src/utils/change-metadata.ts | 106 +++- src/utils/spec-discovery.ts | 39 ++ test/commands/validate.test.ts | 42 ++ .../workflow-instructions-skipped.test.ts | 157 ++++++ test/core/archive.test.ts | 97 ++++ .../artifact-graph/instruction-loader.test.ts | 75 +++ test/core/templates/propose.test.ts | 27 +- .../templates/skill-templates-parity.test.ts | 30 +- test/core/validation.skip-specs.test.ts | 453 ++++++++++++++++++ test/utils/change-metadata.test.ts | 17 + 38 files changed, 1344 insertions(+), 82 deletions(-) create mode 100644 .changeset/skip-specs-explicit-zero-delta.md create mode 100644 test/commands/workflow-instructions-skipped.test.ts create mode 100644 test/core/validation.skip-specs.test.ts diff --git a/.changeset/skip-specs-explicit-zero-delta.md b/.changeset/skip-specs-explicit-zero-delta.md new file mode 100644 index 0000000000..edd0886c2d --- /dev/null +++ b/.changeset/skip-specs-explicit-zero-delta.md @@ -0,0 +1,5 @@ +--- +"@fission-ai/openspec": minor +--- + +Add `skip_specs: true` change metadata for work with no spec-level behavior change (pure refactors, tooling, docs). `openspec validate` accepts a zero-delta change that declares the marker (honored only when the metadata parses under the shared change-metadata schema and names a schema that loads) and errors when the marker and delta specs are both present, the artifact graph no longer blocks `tasks` on spec files for such changes, `openspec status` renders the specs stage as explicitly skipped, and the propose/specs guidance points to the marker instead of contradicting the validator. diff --git a/docs/agent-contract.md b/docs/agent-contract.md index 28ae66a26c..c2429d10eb 100644 --- a/docs/agent-contract.md +++ b/docs/agent-contract.md @@ -55,10 +55,10 @@ Change: `{ "id", "title", "deltaCount", "deltas": [...], "root" }`. Spec: `{ "id `{ "items": [ { "id", "type": "change"|"spec", "valid", "issues": [ { "level", "path", "message", "line"?, "column"? } ], "durationMs" } ], "summary": { "totals": {items,passed,failed}, "byType": {...} }, "version": "1.0", "root" }`. Exit 1 when any item fails. ### 4.4 `status --json` -`{ "changeName", "schemaName", "planningHome"?: { "kind", "root", "changesDir", "defaultSchema" }, "changeRoot", "artifactPaths": { "": {outputPath, resolvedOutputPath, existingOutputPaths} }, "nextSteps": ["..."], "actionContext": { "mode": "repo-local", "sourceOfTruth": "repo", "planningArtifacts", "linkedContext", "allowedEditRoots", "requiresAffectedAreaSelection", "constraints" }, "isComplete", "applyRequires", "artifacts": [ {id, outputPath, status: "done"|"ready"|"blocked", requires, missingDeps?} ], "root" }`. Each artifact's `requires` is its direct dependency ids (present for every status, so the transitive required set is computable even when the artifact is `done`); `missingDeps` appears only when `blocked`. No active changes: `{ "changes": [], "message", "root" }`, exit 0. +`{ "changeName", "schemaName", "planningHome"?: { "kind", "root", "changesDir", "defaultSchema" }, "changeRoot", "artifactPaths": { "": {outputPath, resolvedOutputPath, existingOutputPaths} }, "nextSteps": ["..."], "actionContext": { "mode": "repo-local", "sourceOfTruth": "repo", "planningArtifacts", "linkedContext", "allowedEditRoots", "requiresAffectedAreaSelection", "constraints" }, "isComplete", "applyRequires", "artifacts": [ {id, outputPath, status: "done"|"skipped"|"ready"|"blocked", requires, missingDeps?} ], "root" }`. Each artifact's `requires` is its direct dependency ids (present for every status, so the transitive required set is computable even when the artifact is `done`); `missingDeps` appears only when `blocked`. `"skipped"` marks an artifact whose `generates` path is under `specs/` in a change whose `.openspec.yaml` declares `skip_specs: true`; it satisfies dependencies but must not be created. No active changes: `{ "changes": [], "message", "root" }`, exit 0. ### 4.5 `instructions --json` -`{ "changeName", "artifactId", "schemaName", "changeDir", "planningHome"?, "outputPath", "resolvedOutputPath", "existingOutputPaths", "description", "instruction"?, "context"?, "rules"?, "references"?: ReferenceIndexEntry[], "template", "dependencies": [{id,done,path,description}], "unlocks", "root" }`. +`{ "changeName", "artifactId", "schemaName", "changeDir", "planningHome"?, "outputPath", "resolvedOutputPath", "existingOutputPaths", "description", "instruction"?, "context"?, "rules"?, "references"?: ReferenceIndexEntry[], "skipped"?, "warning"?, "template", "dependencies": [{id,done,path,description,skipped?}], "unlocks", "root" }`. `"skipped": true` (with `"warning"`) appears when the change declares `skip_specs: true` and this artifact is skipped — do not create its files. A dependency entry with `skipped: true` is satisfied without files — do not try to read its paths. `ReferenceIndexEntry`: `{ "store_id", "root"?, "specs"?: [{id,summary}], "fetch"?, "status": [] }` — resolved entries carry root/specs/fetch; unresolved carry store_id + warning status. Index capped at 50KB (`reference_index_truncated`). diff --git a/docs/cli.md b/docs/cli.md index 8a75845cb2..ba7d2f5ab7 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -493,6 +493,8 @@ Validate changes and specs for structural issues. openspec validate [item-name] [options] ``` +A change with zero spec deltas fails validation unless its `.openspec.yaml` declares `skip_specs: true` (for pure refactors, tooling, or docs work — see [Recipe 5](examples.md#recipe-5-a-refactor-with-no-behavior-change)). + **Arguments:** | Argument | Required | Description | @@ -587,7 +589,7 @@ openspec archive [change-name] [options] | Option | Description | |--------|-------------| | `-y, --yes` | Skip confirmation prompts | -| `--skip-specs` | Skip spec updates (for infrastructure/tooling/doc-only changes) | +| `--skip-specs` | Skip spec updates for one archive run. A change that permanently has no spec deltas should declare `skip_specs: true` in its `.openspec.yaml` instead — it archives with no flag | | `--no-validate` | Skip validation (requires confirmation) | **Examples:** @@ -693,6 +695,8 @@ Progress: 2/4 artifacts complete [-] tasks (blocked by: design) ``` +A change that declares `skip_specs: true` shows its specs stage as `[~] specs (skipped: change declares skip_specs)` and excludes it from the progress count. + **Output (JSON):** ```json @@ -759,6 +763,8 @@ openspec instructions design --change add-dark-mode --json - Content from dependency artifacts - Per-artifact rules from config +For an artifact skipped via `skip_specs: true`, the output is a warning only (JSON adds `skipped`/`warning` fields) — the artifact must not be created. + --- ### `openspec templates` diff --git a/docs/concepts.md b/docs/concepts.md index b929a588a7..cafb78fd0c 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -190,7 +190,7 @@ openspec/changes/add-dark-mode/ ├── proposal.md # Why and what ├── design.md # How (technical approach) ├── tasks.md # Implementation checklist -├── .openspec.yaml # Change metadata (optional) +├── .openspec.yaml # Change metadata (optional): schema, created, skip_specs └── specs/ # Delta specs └── ui/ └── spec.md # What's changing in ui/spec.md diff --git a/docs/examples.md b/docs/examples.md index cedf85c377..306c7502f7 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -140,7 +140,16 @@ AI: Created the change. The proposal states the goal (split the Ready for implementation. ``` -When you archive a change that doesn't touch specs, you can tell the terminal command to skip the spec step: +Declare the empty delta explicitly by setting `skip_specs: true` in the change's `.openspec.yaml`: + +```yaml +schema: spec-driven +skip_specs: true +``` + +Without the marker, `openspec validate` rejects a change with zero deltas (so a forgotten specs phase still gets caught); with it, validation passes and `openspec status` shows the specs stage as explicitly skipped rather than pending. If the refactor turns out to change behavior after all, remove `skip_specs` from `.openspec.yaml` and write the delta specs — validate treats the marker plus spec files as a conflict, so the stale marker can't linger silently. + +Archiving a marked change needs no extra flags (there are no deltas to merge). Independently, the `--skip-specs` flag tells the terminal command to skip the spec step explicitly: ```bash $ openspec archive refactor-payment-module --skip-specs diff --git a/schemas/spec-driven/schema.yaml b/schemas/spec-driven/schema.yaml index 216422e2d3..fd0a2e131f 100644 --- a/schemas/spec-driven/schema.yaml +++ b/schemas/spec-driven/schema.yaml @@ -21,6 +21,14 @@ artifacts: proposal and specs phases. Research existing specs before filling this in. Each capability listed here will need a corresponding spec file. + Every change must either declare at least one capability (new or + modified) or explicitly opt out of specs: `openspec validate` rejects a + change with zero deltas unless the change's `.openspec.yaml` sets + `skip_specs: true`. Use `skip_specs: true` only when no spec-level + behavior changes (pure refactor, tooling, docs) - specs describe + behavior, so if behavior does not change, no spec should change either. + Do not invent a requirement just to satisfy validation. + Keep it concise (1-2 pages). Focus on the "why" not the "how" - implementation details belong in design.md. @@ -55,6 +63,11 @@ artifacts: - New capabilities: use the exact kebab-case name from the proposal (specs//spec.md). - Modified capabilities: use the existing spec folder name from openspec/specs// when creating the delta spec at specs//spec.md. + There must be at least one spec file unless the change's `.openspec.yaml` + sets `skip_specs: true` (no spec-level behavior change) - `openspec validate` + rejects a zero-delta change without that marker. If the proposal lists no + capabilities and `skip_specs` is not set, revisit the proposal first. + Delta operations (use ## headers): - **ADDED Requirements**: New capabilities - **MODIFIED Requirements**: Changed behavior - MUST include full updated content diff --git a/schemas/spec-driven/templates/proposal.md b/schemas/spec-driven/templates/proposal.md index c79b85d44d..fb8d99c3f8 100644 --- a/schemas/spec-driven/templates/proposal.md +++ b/schemas/spec-driven/templates/proposal.md @@ -15,7 +15,11 @@ ### Modified Capabilities + Use existing spec names from openspec/specs/. Leave empty if no requirement + changes. A change with no capabilities at all (pure refactor, tooling, docs) + must set `skip_specs: true` in its .openspec.yaml - openspec validate rejects + a zero-delta change without that marker. Do not invent a requirement just to + satisfy validation. --> - ``: ## Impact diff --git a/skills/openspec-archive-change/SKILL.md b/skills/openspec-archive-change/SKILL.md index b531e87641..35c3e66cf5 100644 --- a/skills/openspec-archive-change/SKILL.md +++ b/skills/openspec-archive-change/SKILL.md @@ -33,9 +33,9 @@ Archive a completed change in the experimental workflow. Parse the JSON to understand: - `schemaName`: The workflow being used - `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context - - `artifacts`: List of artifacts with their status (`done` or other) + - `artifacts`: List of artifacts with their status (`done`, `skipped`, or other) - **If any artifacts are not `done`:** + **If any artifacts are neither `done` nor `skipped`** (skipped artifacts satisfy the requirement - the change declares skip_specs): - Display warning listing incomplete artifacts - Use **AskUserQuestion tool** to confirm user wants to proceed - Proceed if user confirms diff --git a/skills/openspec-continue-change/SKILL.md b/skills/openspec-continue-change/SKILL.md index cbce137c0c..b86fc8a4ca 100644 --- a/skills/openspec-continue-change/SKILL.md +++ b/skills/openspec-continue-change/SKILL.md @@ -37,7 +37,7 @@ Continue working on a change by creating the next artifact. ``` Parse the JSON to understand current state. The response includes: - `schemaName`: The workflow schema being used (e.g., "spec-driven") - - `artifacts`: Array of artifacts with their status ("done", "ready", "blocked") + - `artifacts`: Array of artifacts with their status ("done", "skipped", "ready", "blocked") - `isComplete`: Boolean indicating if all artifacts are complete - `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context. Use these instead of assuming repo-local paths. @@ -65,7 +65,8 @@ Continue working on a change by creating the next artifact. - `template`: The structure to use for your output file - `instruction`: Schema-specific guidance - `resolvedOutputPath`: Resolved path or pattern to write the artifact - - `dependencies`: Completed artifacts to read for context + - `dependencies`: Completed artifacts to read for context (entries with `skipped: true` have no files - do not look for them) + - `skipped`/`warning`: present when the change declares skip_specs and this artifact must NOT be created - pick another artifact - **Create the artifact file**: - Read any completed dependency files for context - always re-read them from disk, even if you saw them earlier in the conversation (the user may have edited them) - If the `instruction` field delegates creation to a specific skill or command, invoke it to produce the artifact instead of writing the file yourself, then verify the artifact file exists at `resolvedOutputPath` diff --git a/skills/openspec-ff-change/SKILL.md b/skills/openspec-ff-change/SKILL.md index 70bf2960fb..24ce39c6ba 100644 --- a/skills/openspec-ff-change/SKILL.md +++ b/skills/openspec-ff-change/SKILL.md @@ -57,6 +57,7 @@ Fast-forward through artifact creation - generate everything needed to start imp - `rules`: Artifact-specific rules (constraints for you - do NOT include in output) - `template`: The structure to use for your output file - `instruction`: Schema-specific guidance for this artifact type + - `skipped`/`warning`: present when the change declares skip_specs and this artifact must NOT be created - stop and pick another artifact - `resolvedOutputPath`: Resolved path or pattern to write the artifact - `dependencies`: Completed artifacts to read for context - Read any completed dependency files for context - always re-read them from disk, even if you saw them earlier in the conversation (the user may have edited them) @@ -69,10 +70,11 @@ Fast-forward through artifact creation - generate everything needed to start imp - After creating each artifact, re-run `openspec status --change "" --json` - The required set is `applyRequires` plus every artifact reachable from those by following the `requires` edges in `status --json` - walk them transitively (spec-driven closes over proposal, specs, design, tasks). Leave artifacts outside that set alone - `status` is file-existence only, so an `applyRequires` artifact reading `done` does NOT mean its dependencies exist - writing `tasks.md` early marks `tasks` done while `specs` was never written. Use each artifact's `requires` edges, not its `status`, to build the required set: a `done` artifact still lists what it depends on + - An artifact already reading `status: "skipped"` is satisfied: the change declares `skip_specs` in `.openspec.yaml`, so its files must NOT exist. Never try to create one - Create every artifact in the required set that is missing, then re-check - creating one can unblock others - - Skip one only when its own `instruction` says it is conditional: run `openspec instructions --change "" --json` and skip only if its `instruction` field marks it optional (e.g. "create only if..."). Spec-driven's `design.md` qualifies; `specs` never does. Tell the user, and do not reconsider it + - Skip one only when `status` already reports it `skipped`, or when its own `instruction` says it is conditional: run `openspec instructions --change "" --json` and skip only if its `instruction` field marks it optional (e.g. "create only if..."). Spec-driven's `design.md` qualifies; `specs` qualifies only via the `skipped` status above, never by your own judgment. Tell the user, and do not reconsider it - Dependencies are enablers, not gates: if a required artifact is still `blocked` only because you skipped a conditional dependency, write it anyway - - Stop when every artifact in the required set is `done` or was deliberately skipped + - Stop when every artifact in the required set is `done`, `skipped`, or was deliberately skipped c. **If an artifact requires user input** (unclear context): - Use **AskUserQuestion tool** to clarify diff --git a/skills/openspec-propose/SKILL.md b/skills/openspec-propose/SKILL.md index 07493a840e..6b8c7fe791 100644 --- a/skills/openspec-propose/SKILL.md +++ b/skills/openspec-propose/SKILL.md @@ -67,6 +67,7 @@ When ready to implement, run /openspec-apply-change - `rules`: Artifact-specific rules (constraints for you - do NOT include in output) - `template`: The structure to use for your output file - `instruction`: Schema-specific guidance for this artifact type + - `skipped`/`warning`: present when the change declares skip_specs and this artifact must NOT be created - stop and pick another artifact - `resolvedOutputPath`: Resolved path or pattern to write the artifact - `dependencies`: Completed artifacts to read for context - Read any completed dependency files for context - always re-read them from disk, even if you saw them earlier in the conversation (the user may have edited them) @@ -79,10 +80,11 @@ When ready to implement, run /openspec-apply-change - After creating each artifact, re-run `openspec status --change "" --json` - The required set is `applyRequires` plus every artifact reachable from those by following the `requires` edges in `status --json` - walk them transitively (spec-driven closes over proposal, specs, design, tasks). Leave artifacts outside that set alone - `status` is file-existence only, so an `applyRequires` artifact reading `done` does NOT mean its dependencies exist - writing `tasks.md` early marks `tasks` done while `specs` was never written. Use each artifact's `requires` edges, not its `status`, to build the required set: a `done` artifact still lists what it depends on + - An artifact already reading `status: "skipped"` is satisfied: the change declares `skip_specs` in `.openspec.yaml`, so its files must NOT exist. Never try to create one - Create every artifact in the required set that is missing, then re-check - creating one can unblock others - - Skip one only when its own `instruction` says it is conditional: run `openspec instructions --change "" --json` and skip only if its `instruction` field marks it optional (e.g. "create only if..."). Spec-driven's `design.md` qualifies; `specs` never does. Tell the user, and do not reconsider it + - Skip one only when `status` already reports it `skipped`, or when its own `instruction` says it is conditional: run `openspec instructions --change "" --json` and skip only if its `instruction` field marks it optional (e.g. "create only if..."). Spec-driven's `design.md` qualifies; `specs` qualifies only via the `skipped` status above, never by your own judgment. Tell the user, and do not reconsider it - Dependencies are enablers, not gates: if a required artifact is still `blocked` only because you skipped a conditional dependency, write it anyway - - Stop when every artifact in the required set is `done` or was deliberately skipped + - Stop when every artifact in the required set is `done`, `skipped`, or was deliberately skipped c. **If an artifact requires user input** (unclear context): - Use **AskUserQuestion tool** to clarify diff --git a/skills/openspec-update-change/SKILL.md b/skills/openspec-update-change/SKILL.md index b17762c490..d708818204 100644 --- a/skills/openspec-update-change/SKILL.md +++ b/skills/openspec-update-change/SKILL.md @@ -37,7 +37,7 @@ Revise a change's existing planning artifacts and keep them coherent. Never edit ``` Parse the JSON to understand current state. The response includes: - `schemaName`: The workflow schema being used (e.g., "spec-driven") - - `artifacts`: Array of artifacts with their status ("done", "ready", "blocked") + - `artifacts`: Array of artifacts with their status ("done", "skipped", "ready", "blocked") - `isComplete`: Boolean indicating if all artifacts are complete - `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context. Use these instead of assuming repo-local paths. diff --git a/src/commands/change.ts b/src/commands/change.ts index 561fb3d6ab..5df0f94140 100644 --- a/src/commands/change.ts +++ b/src/commands/change.ts @@ -2,6 +2,7 @@ import { promises as fs } from 'fs'; import path from 'path'; import { JsonConverter } from '../core/converters/json-converter.js'; import { Validator } from '../core/validation/validator.js'; +import { VALIDATION_MESSAGES } from '../core/validation/constants.js'; import { ChangeParser } from '../core/parsers/change-parser.js'; import { Change } from '../core/schemas/index.js'; import type { RootOutput } from '../core/root-selection.js'; @@ -218,7 +219,7 @@ export class ChangeCommand { console.error(`${prefix} [${label}] ${issue.path}: ${issue.message}`); }); // Next steps footer to guide fixing issues - this.printNextSteps(); + this.printNextSteps(report.issues); if (!options?.json) { process.exitCode = 1; } @@ -251,11 +252,27 @@ export class ChangeCommand { return match ? match[1].trim() : changeName; } - private printNextSteps(): void { + private printNextSteps(issues: Array<{ message: string }> = []): void { const bullets: string[] = []; - bullets.push('- Ensure change has deltas in specs/: use headers ## ADDED/MODIFIED/REMOVED/RENAMED Requirements'); - bullets.push('- Each requirement MUST include at least one #### Scenario: block'); - bullets.push('- Debug parsed deltas: openspec change show --json --deltas-only'); + // Branch on the exact marker messages: the generic no-deltas guidance + // also mentions skip_specs and must not trigger the marker bullets. + const conflictIssue = issues.some(i => + i.message.includes(VALIDATION_MESSAGES.CHANGE_SKIP_SPECS_CONFLICT) + ); + const invalidMarkerIssue = issues.some(i => + i.message.includes(VALIDATION_MESSAGES.CHANGE_SKIP_SPECS_INVALID_METADATA) + ); + if (conflictIssue) { + bullets.push('- This change declares skip_specs (no spec deltas): delete the files under specs/, or remove skip_specs from .openspec.yaml if requirements do change'); + bullets.push('- skip_specs is only honored when .openspec.yaml is valid change metadata (schema: is required)'); + } else if (invalidMarkerIssue) { + bullets.push('- Fix .openspec.yaml so the skip_specs marker can be honored (schema: is required)'); + bullets.push('- Or remove skip_specs from .openspec.yaml and add delta specs instead'); + } else { + bullets.push('- Ensure change has deltas in specs/: use headers ## ADDED/MODIFIED/REMOVED/RENAMED Requirements'); + bullets.push('- Each requirement MUST include at least one #### Scenario: block'); + bullets.push('- Debug parsed deltas: openspec change show --json --deltas-only'); + } console.error('Next steps:'); bullets.forEach(b => console.error(` ${b}`)); } diff --git a/src/commands/validate.ts b/src/commands/validate.ts index 708c66024e..eb44ede0f9 100644 --- a/src/commands/validate.ts +++ b/src/commands/validate.ts @@ -1,6 +1,7 @@ import ora from 'ora'; import path from 'path'; import { Validator } from '../core/validation/validator.js'; +import { VALIDATION_MESSAGES } from '../core/validation/constants.js'; import { resolveRootForCommand, toRootOutput, @@ -226,13 +227,29 @@ export class ValidateCommand { const prefix = issue.level === 'ERROR' ? '✗' : issue.level === 'WARNING' ? '⚠' : 'ℹ'; console.error(`${prefix} [${label}] ${issue.path}: ${issue.message}`); } - this.printNextSteps(type, id, root); + this.printNextSteps(type, id, root, report.issues); } } - private printNextSteps(type: ItemType, id: string, root: ResolvedOpenSpecRoot): void { + private printNextSteps(type: ItemType, id: string, root: ResolvedOpenSpecRoot, issues: Array<{ message: string }> = []): void { const bullets: string[] = []; - if (type === 'change') { + // The delta-authoring bullets contradict a marker-related error ("add + // deltas" vs "remove skip_specs or the files"), so branch on the exact + // marker messages - the generic no-deltas guidance also mentions + // skip_specs, which must not trigger this. + const conflictIssue = issues.some(i => + i.message.includes(VALIDATION_MESSAGES.CHANGE_SKIP_SPECS_CONFLICT) + ); + const invalidMarkerIssue = issues.some(i => + i.message.includes(VALIDATION_MESSAGES.CHANGE_SKIP_SPECS_INVALID_METADATA) + ); + if (type === 'change' && conflictIssue) { + bullets.push('- This change declares skip_specs (no spec deltas): delete the files under specs/, or remove skip_specs from .openspec.yaml if requirements do change'); + bullets.push('- skip_specs is only honored when .openspec.yaml is valid change metadata (schema: naming a known schema is required)'); + } else if (type === 'change' && invalidMarkerIssue) { + bullets.push('- Fix .openspec.yaml so the skip_specs marker can be honored (schema: naming a known schema is required)'); + bullets.push('- Or remove skip_specs from .openspec.yaml and add delta specs instead'); + } else if (type === 'change') { bullets.push('- Ensure change has deltas in specs/: use headers ## ADDED/MODIFIED/REMOVED/RENAMED Requirements'); bullets.push('- Each requirement MUST include at least one #### Scenario: block'); bullets.push(`- Debug parsed deltas: ${withStoreFlag(root, `openspec show ${id} --json --deltas-only`)}`); diff --git a/src/commands/workflow/instructions.ts b/src/commands/workflow/instructions.ts index 8be371c45c..aeed79270a 100644 --- a/src/commands/workflow/instructions.ts +++ b/src/commands/workflow/instructions.ts @@ -189,6 +189,18 @@ export function printInstructionsText(instructions: ArtifactInstructions, isBloc console.log(``); console.log(); + // Artifacts skipped via skip_specs get no creation directive: emitting the + // task/template anyway would prompt an agent to write spec files that + // validate then rejects as conflicting with the marker. + if (instructions.skipped) { + console.log(''); + console.log(instructions.warning ?? 'This artifact is skipped (skip_specs is set in .openspec.yaml).'); + console.log(''); + console.log(); + console.log(''); + return; + } + // Warning for blocked artifacts if (isBlocked) { const missing = dependencies.filter((d) => !d.done).map((d) => d.id); @@ -238,6 +250,15 @@ export function printInstructionsText(instructions: ArtifactInstructions, isBloc console.log('Read the current contents of these files before creating this artifact (re-read them from disk even if you saw them earlier - they may have been edited):'); console.log(); for (const dep of dependencies) { + // A dependency satisfied via skip_specs has no files by design: telling + // the agent to read them (or calling them "done") would send it hunting + // for spec files that must not exist. + if (dep.skipped) { + console.log(``); + console.log(` Skipped: the change declares skip_specs, so this artifact has no files to read.`); + console.log(''); + continue; + } const status = dep.done ? 'done' : 'missing'; const fullPath = path.join(changeDir, dep.path); console.log(``); @@ -354,9 +375,14 @@ export async function generateApplyInstructions( const tracksFile = applyConfig?.tracks ?? null; const schemaInstruction = applyConfig?.instruction ?? null; - // Check which required artifacts are missing + // Check which required artifacts are missing. Artifacts the change skips + // via skip_specs count as present - their files must not exist, and + // status already reports them complete, so apply cannot block on them. const missingArtifacts: string[] = []; for (const artifactId of requiredArtifactIds) { + if (context.skippedArtifacts?.has(artifactId)) { + continue; + } const artifact = schema.artifacts.find((a) => a.id === artifactId); if (artifact && resolveArtifactOutputs(changeDir, artifact.generates).length === 0) { missingArtifacts.push(artifactId); diff --git a/src/commands/workflow/shared.ts b/src/commands/workflow/shared.ts index 122c663018..dbfd830863 100644 --- a/src/commands/workflow/shared.ts +++ b/src/commands/workflow/shared.ts @@ -84,13 +84,15 @@ export function isColorDisabled(): boolean { /** * Gets the color function based on status. */ -export function getStatusColor(status: 'done' | 'ready' | 'blocked'): (text: string) => string { +export function getStatusColor(status: 'done' | 'skipped' | 'ready' | 'blocked'): (text: string) => string { if (isColorDisabled()) { return (text: string) => text; } switch (status) { case 'done': return chalk.green; + case 'skipped': + return chalk.gray; case 'ready': return chalk.yellow; case 'blocked': @@ -101,11 +103,13 @@ export function getStatusColor(status: 'done' | 'ready' | 'blocked'): (text: str /** * Gets the status indicator for an artifact. */ -export function getStatusIndicator(status: 'done' | 'ready' | 'blocked'): string { +export function getStatusIndicator(status: 'done' | 'skipped' | 'ready' | 'blocked'): string { const color = getStatusColor(status); switch (status) { case 'done': return color('[x]'); + case 'skipped': + return color('[~]'); case 'ready': return color('[ ]'); case 'blocked': diff --git a/src/commands/workflow/status.ts b/src/commands/workflow/status.ts index 4374744bf5..2a09b48edb 100644 --- a/src/commands/workflow/status.ts +++ b/src/commands/workflow/status.ts @@ -123,14 +123,16 @@ export async function statusCommand(options: StatusOptions): Promise { export function printStatusText(status: ChangeStatus): void { const doneCount = status.artifacts.filter((a) => a.status === 'done').length; - const total = status.artifacts.length; + const skippedCount = status.artifacts.filter((a) => a.status === 'skipped').length; + const total = status.artifacts.length - skippedCount; console.log(`Change: ${status.changeName}`); console.log(`Schema: ${status.schemaName}`); if (status.changeRoot) { console.log(`Change root: ${status.changeRoot}`); } - console.log(`Progress: ${doneCount}/${total} artifacts complete`); + const skippedSuffix = skippedCount > 0 ? ` (${skippedCount} skipped)` : ''; + console.log(`Progress: ${doneCount}/${total} artifacts complete${skippedSuffix}`); console.log(); for (const artifact of status.artifacts) { @@ -138,6 +140,10 @@ export function printStatusText(status: ChangeStatus): void { const color = getStatusColor(artifact.status); let line = `${indicator} ${artifact.id}`; + if (artifact.status === 'skipped') { + line += color(' (skipped: change declares skip_specs)'); + } + if (artifact.status === 'blocked' && artifact.missingDeps && artifact.missingDeps.length > 0) { line += color(` (blocked by: ${artifact.missingDeps.join(', ')})`); } diff --git a/src/core/archive.ts b/src/core/archive.ts index 9e861070a7..6c35868c6c 100644 --- a/src/core/archive.ts +++ b/src/core/archive.ts @@ -19,7 +19,8 @@ import { writeUpdatedSpec, type SpecUpdate, } from './specs-apply.js'; -import { discoverSpecFiles } from '../utils/spec-discovery.js'; +import { discoverSpecFiles, hasAnyFileUnder } from '../utils/spec-discovery.js'; +import { readSkipSpecsMarker } from '../utils/change-metadata.js'; function isMissingPathError(error: unknown): boolean { return ( @@ -294,6 +295,31 @@ export class ArchiveCommand { // folder, so only a regular file counts. const rootSpecStat = await fs.stat(path.join(changeSpecsDir, 'spec.md')).catch(() => null); let hasDeltaSpecs = rootSpecStat?.isFile() === true; + // A change that declares skip_specs must not carry any file under + // specs/ — validate reports that as a conflict, so archive has to run + // the same check instead of skipping validation because the files + // happen to have no delta headers. A marker that cannot be honored + // (skip_specs mentioned but the metadata fails the shared shape, or + // names a schema that does not resolve) also + // forces validation, so archive and validate always agree about the + // marker. Unreadable specs/ fails closed into validation too. (An + // UNMARKED zero-delta change still archives with only non-blocking + // proposal warnings — a gap that predates the marker and is left + // unchanged here.) + if (!hasDeltaSpecs) { + const marker = readSkipSpecsMarker(changeDir); + if (marker.invalidReason) { + hasDeltaSpecs = true; + } else if (marker.declared) { + let specsDirHasFiles = true; + try { + specsDirHasFiles = await hasAnyFileUnder(changeSpecsDir); + } catch { + // fall through with true: let validation surface the conflict + } + hasDeltaSpecs = specsDirHasFiles; + } + } for (const { specFile } of hasDeltaSpecs ? [] : await discoverSpecFiles(changeSpecsDir)) { try { const content = await fs.readFile(specFile, 'utf-8'); diff --git a/src/core/artifact-graph/instruction-loader.ts b/src/core/artifact-graph/instruction-loader.ts index aec7f47f22..f43e2c4d12 100644 --- a/src/core/artifact-graph/instruction-loader.ts +++ b/src/core/artifact-graph/instruction-loader.ts @@ -55,6 +55,12 @@ export interface ChangeContext { planningHome?: PlanningHome; /** Parsed change metadata, when present */ metadata?: ChangeMetadata; + /** + * Artifact IDs counted as complete only because the change declares + * skip_specs, not because their files exist. Kept separate so status can + * render them as skipped rather than done. + */ + skippedArtifacts?: Set; } export interface LoadChangeContextOptions { @@ -98,8 +104,21 @@ export interface ArtifactInstructions { dependencies: DependencyInfo[]; /** Artifacts that become available after completing this one */ unlocks: string[]; + /** True when the change declares skip_specs and this artifact is skipped */ + skipped?: boolean; + /** Present only when skipped: tells the consumer not to create the artifact */ + warning?: string; } +/** + * Warning attached to instructions for an artifact skipped via skip_specs. + * Carried in the JSON payload too, so agents driving the CLI with --json see + * the same do-not-create signal as the text output. + */ +export const SKIP_SPECS_INSTRUCTIONS_WARNING = + 'This change declares skip_specs: true in .openspec.yaml (no spec-level behavior changes), so this artifact is skipped.\n' + + 'Do not create spec files - they will conflict with that marker. If requirements now change, remove skip_specs from .openspec.yaml and rerun this command.'; + /** * Dependency information including path and description. */ @@ -112,6 +131,8 @@ export interface DependencyInfo { path: string; /** Description of the dependency artifact */ description: string; + /** True when the dependency is satisfied via skip_specs - no files exist to read */ + skipped?: boolean; } /** @@ -122,8 +143,8 @@ export interface ArtifactStatus { id: string; /** Output path pattern */ outputPath: string; - /** Status: done, ready, or blocked */ - status: 'done' | 'ready' | 'blocked'; + /** Status: done, skipped (via skip_specs), ready, or blocked */ + status: 'done' | 'skipped' | 'ready' | 'blocked'; /** Artifact IDs this artifact directly requires (its `requires` edges). * Present for every status so callers can compute the transitive required * set even when the artifact is already `done` (file-existence status does @@ -242,6 +263,25 @@ export function loadChangeContext( const graph = ArtifactGraph.fromSchema(schema); const completed = detectCompleted(graph, changeDir); + // A change that declares skip_specs has no spec deltas by design, so + // artifacts generating into specs/ count as complete; otherwise the graph + // would block their dependents (e.g. tasks) on files that must not exist. + // Tracked separately so status renders them as skipped, not done. + const skippedArtifacts = new Set(); + if (metadata?.skip_specs) { + for (const artifact of graph.getAllArtifacts()) { + // A schema may write generates as './specs/...' - the globs treat that + // identically to 'specs/...', so the skip set must too, or validate + // would honor the marker while instructions tell the agent to create + // the very files the conflict gate polices. + const generates = artifact.generates.replace(/^(?:\.\/)+/, ''); + if (generates.startsWith('specs/') && !completed.has(artifact.id)) { + completed.add(artifact.id); + skippedArtifacts.add(artifact.id); + } + } + } + return { graph, completed, @@ -251,6 +291,7 @@ export function loadChangeContext( projectRoot, ...(options.planningHome ? { planningHome: options.planningHome } : {}), ...(metadata ? { metadata } : {}), + ...(skippedArtifacts.size > 0 ? { skippedArtifacts } : {}), }; } @@ -287,7 +328,7 @@ export function generateInstructions( } const templateContent = loadTemplate(context.schemaName, artifact.template, context.projectRoot); - const dependencies = getDependencyInfo(artifact, context.graph, context.completed); + const dependencies = getDependencyInfo(artifact, context.graph, context.completed, context.skippedArtifacts); const unlocks = getUnlockedArtifacts(context.graph, artifactId); // Use projectRoot from context if not explicitly provided @@ -340,6 +381,9 @@ export function generateInstructions( context: configContext, rules: configRules, ...(options.references !== undefined ? { references: options.references } : {}), + ...(context.skippedArtifacts?.has(artifact.id) + ? { skipped: true, warning: SKIP_SPECS_INSTRUCTIONS_WARNING } + : {}), template: templateContent, dependencies, unlocks, @@ -352,7 +396,8 @@ export function generateInstructions( function getDependencyInfo( artifact: Artifact, graph: ArtifactGraph, - completed: CompletedSet + completed: CompletedSet, + skippedArtifacts?: Set ): DependencyInfo[] { return artifact.requires.map(id => { const depArtifact = graph.getArtifact(id); @@ -361,6 +406,7 @@ function getDependencyInfo( done: completed.has(id), path: depArtifact?.generates ?? id, description: depArtifact?.description ?? '', + ...(skippedArtifacts?.has(id) ? { skipped: true } : {}), }; }); } @@ -406,6 +452,15 @@ export function formatChangeStatus( existingOutputPaths: resolveArtifactOutputs(context.changeDir, artifact.generates), }; + if (context.skippedArtifacts?.has(artifact.id)) { + return { + id: artifact.id, + outputPath: artifact.generates, + status: 'skipped' as const, + requires: artifact.requires, + }; + } + if (context.completed.has(artifact.id)) { return { id: artifact.id, diff --git a/src/core/change-metadata/schema.ts b/src/core/change-metadata/schema.ts index d97d9a9a3f..40c231d409 100644 --- a/src/core/change-metadata/schema.ts +++ b/src/core/change-metadata/schema.ts @@ -33,6 +33,12 @@ export const ChangeMetadataSchema = z.object({ goal: z.string().min(1).optional(), affected_areas: z.array(z.string().min(1)).optional(), initiative: InitiativeLinkSchema.optional(), + // Declares that this change intentionally has no spec deltas (pure refactor, + // tooling, or docs work). Validation accepts zero deltas, and the artifact + // graph counts artifacts whose `generates` path lives under specs/ as + // complete - that path prefix, not the artifact id, is the contract custom + // schemas inherit. + skip_specs: z.boolean().optional(), }); export type ChangeMetadata = z.infer; diff --git a/src/core/change-status-policy.ts b/src/core/change-status-policy.ts index ebc669904c..aac089fbef 100644 --- a/src/core/change-status-policy.ts +++ b/src/core/change-status-policy.ts @@ -19,7 +19,7 @@ export interface ActionContext { export interface ChangeStatusPolicyArtifact { id: string; - status: 'done' | 'ready' | 'blocked'; + status: 'done' | 'skipped' | 'ready' | 'blocked'; } export interface ChangeNextStepsInput { diff --git a/src/core/templates/workflows/archive-change.ts b/src/core/templates/workflows/archive-change.ts index d0c1e6fd14..85441751d0 100644 --- a/src/core/templates/workflows/archive-change.ts +++ b/src/core/templates/workflows/archive-change.ts @@ -35,9 +35,9 @@ ${STORE_SELECTION_GUIDANCE} Parse the JSON to understand: - \`schemaName\`: The workflow being used - \`planningHome\`, \`changeRoot\`, \`artifactPaths\`, and \`actionContext\`: path and scope context - - \`artifacts\`: List of artifacts with their status (\`done\` or other) + - \`artifacts\`: List of artifacts with their status (\`done\`, \`skipped\`, or other) - **If any artifacts are not \`done\`:** + **If any artifacts are neither \`done\` nor \`skipped\`** (skipped artifacts satisfy the requirement - the change declares skip_specs): - Display warning listing incomplete artifacts - Use **AskUserQuestion tool** to confirm user wants to proceed - Proceed if user confirms @@ -168,9 +168,9 @@ ${STORE_SELECTION_GUIDANCE} Parse the JSON to understand: - \`schemaName\`: The workflow being used - \`planningHome\`, \`changeRoot\`, \`artifactPaths\`, and \`actionContext\`: path and scope context - - \`artifacts\`: List of artifacts with their status (\`done\` or other) + - \`artifacts\`: List of artifacts with their status (\`done\`, \`skipped\`, or other) - **If any artifacts are not \`done\`:** + **If any artifacts are neither \`done\` nor \`skipped\`** (skipped artifacts satisfy the requirement - the change declares skip_specs): - Display warning listing incomplete artifacts - Prompt user for confirmation to continue - Proceed if user confirms diff --git a/src/core/templates/workflows/continue-change.ts b/src/core/templates/workflows/continue-change.ts index 8c5d8b4ace..8d4ae73e70 100644 --- a/src/core/templates/workflows/continue-change.ts +++ b/src/core/templates/workflows/continue-change.ts @@ -39,7 +39,7 @@ ${STORE_SELECTION_GUIDANCE} \`\`\` Parse the JSON to understand current state. The response includes: - \`schemaName\`: The workflow schema being used (e.g., "spec-driven") - - \`artifacts\`: Array of artifacts with their status ("done", "ready", "blocked") + - \`artifacts\`: Array of artifacts with their status ("done", "skipped", "ready", "blocked") - \`isComplete\`: Boolean indicating if all artifacts are complete - \`planningHome\`, \`changeRoot\`, \`artifactPaths\`, and \`actionContext\`: path and scope context. Use these instead of assuming repo-local paths. @@ -67,7 +67,8 @@ ${STORE_SELECTION_GUIDANCE} - \`template\`: The structure to use for your output file - \`instruction\`: Schema-specific guidance - \`resolvedOutputPath\`: Resolved path or pattern to write the artifact - - \`dependencies\`: Completed artifacts to read for context + - \`dependencies\`: Completed artifacts to read for context (entries with \`skipped: true\` have no files - do not look for them) + - \`skipped\`/\`warning\`: present when the change declares skip_specs and this artifact must NOT be created - pick another artifact - **Create the artifact file**: - Read any completed dependency files for context - always re-read them from disk, even if you saw them earlier in the conversation (the user may have edited them) - If the \`instruction\` field delegates creation to a specific skill or command, invoke it to produce the artifact instead of writing the file yourself, then verify the artifact file exists at \`resolvedOutputPath\` @@ -153,7 +154,7 @@ ${STORE_SELECTION_GUIDANCE} \`\`\` Parse the JSON to understand current state. The response includes: - \`schemaName\`: The workflow schema being used (e.g., "spec-driven") - - \`artifacts\`: Array of artifacts with their status ("done", "ready", "blocked") + - \`artifacts\`: Array of artifacts with their status ("done", "skipped", "ready", "blocked") - \`isComplete\`: Boolean indicating if all artifacts are complete - \`planningHome\`, \`changeRoot\`, \`artifactPaths\`, and \`actionContext\`: path and scope context. Use these instead of assuming repo-local paths. @@ -181,7 +182,8 @@ ${STORE_SELECTION_GUIDANCE} - \`template\`: The structure to use for your output file - \`instruction\`: Schema-specific guidance - \`resolvedOutputPath\`: Resolved path or pattern to write the artifact - - \`dependencies\`: Completed artifacts to read for context + - \`dependencies\`: Completed artifacts to read for context (entries with \`skipped: true\` have no files - do not look for them) + - \`skipped\`/\`warning\`: present when the change declares skip_specs and this artifact must NOT be created - pick another artifact - **Create the artifact file**: - Read any completed dependency files for context - always re-read them from disk, even if you saw them earlier in the conversation (the user may have edited them) - If the \`instruction\` field delegates creation to a specific skill or command, invoke it to produce the artifact instead of writing the file yourself, then verify the artifact file exists at \`resolvedOutputPath\` diff --git a/src/core/templates/workflows/ff-change.ts b/src/core/templates/workflows/ff-change.ts index b60d2c7b2b..b656397ea5 100644 --- a/src/core/templates/workflows/ff-change.ts +++ b/src/core/templates/workflows/ff-change.ts @@ -59,6 +59,7 @@ ${STORE_SELECTION_GUIDANCE} - \`rules\`: Artifact-specific rules (constraints for you - do NOT include in output) - \`template\`: The structure to use for your output file - \`instruction\`: Schema-specific guidance for this artifact type + - \`skipped\`/\`warning\`: present when the change declares skip_specs and this artifact must NOT be created - stop and pick another artifact - \`resolvedOutputPath\`: Resolved path or pattern to write the artifact - \`dependencies\`: Completed artifacts to read for context - Read any completed dependency files for context - always re-read them from disk, even if you saw them earlier in the conversation (the user may have edited them) @@ -71,10 +72,11 @@ ${STORE_SELECTION_GUIDANCE} - After creating each artifact, re-run \`openspec status --change "" --json\` - The required set is \`applyRequires\` plus every artifact reachable from those by following the \`requires\` edges in \`status --json\` - walk them transitively (spec-driven closes over proposal, specs, design, tasks). Leave artifacts outside that set alone - \`status\` is file-existence only, so an \`applyRequires\` artifact reading \`done\` does NOT mean its dependencies exist - writing \`tasks.md\` early marks \`tasks\` done while \`specs\` was never written. Use each artifact's \`requires\` edges, not its \`status\`, to build the required set: a \`done\` artifact still lists what it depends on + - An artifact already reading \`status: "skipped"\` is satisfied: the change declares \`skip_specs\` in \`.openspec.yaml\`, so its files must NOT exist. Never try to create one - Create every artifact in the required set that is missing, then re-check - creating one can unblock others - - Skip one only when its own \`instruction\` says it is conditional: run \`openspec instructions --change "" --json\` and skip only if its \`instruction\` field marks it optional (e.g. "create only if..."). Spec-driven's \`design.md\` qualifies; \`specs\` never does. Tell the user, and do not reconsider it + - Skip one only when \`status\` already reports it \`skipped\`, or when its own \`instruction\` says it is conditional: run \`openspec instructions --change "" --json\` and skip only if its \`instruction\` field marks it optional (e.g. "create only if..."). Spec-driven's \`design.md\` qualifies; \`specs\` qualifies only via the \`skipped\` status above, never by your own judgment. Tell the user, and do not reconsider it - Dependencies are enablers, not gates: if a required artifact is still \`blocked\` only because you skipped a conditional dependency, write it anyway - - Stop when every artifact in the required set is \`done\` or was deliberately skipped + - Stop when every artifact in the required set is \`done\`, \`skipped\`, or was deliberately skipped c. **If an artifact requires user input** (unclear context): - Use **AskUserQuestion tool** to clarify @@ -170,6 +172,7 @@ ${STORE_SELECTION_GUIDANCE} - \`rules\`: Artifact-specific rules (constraints for you - do NOT include in output) - \`template\`: The structure to use for your output file - \`instruction\`: Schema-specific guidance for this artifact type + - \`skipped\`/\`warning\`: present when the change declares skip_specs and this artifact must NOT be created - stop and pick another artifact - \`resolvedOutputPath\`: Resolved path or pattern to write the artifact - \`dependencies\`: Completed artifacts to read for context - Read any completed dependency files for context - always re-read them from disk, even if you saw them earlier in the conversation (the user may have edited them) @@ -182,10 +185,11 @@ ${STORE_SELECTION_GUIDANCE} - After creating each artifact, re-run \`openspec status --change "" --json\` - The required set is \`applyRequires\` plus every artifact reachable from those by following the \`requires\` edges in \`status --json\` - walk them transitively (spec-driven closes over proposal, specs, design, tasks). Leave artifacts outside that set alone - \`status\` is file-existence only, so an \`applyRequires\` artifact reading \`done\` does NOT mean its dependencies exist - writing \`tasks.md\` early marks \`tasks\` done while \`specs\` was never written. Use each artifact's \`requires\` edges, not its \`status\`, to build the required set: a \`done\` artifact still lists what it depends on + - An artifact already reading \`status: "skipped"\` is satisfied: the change declares \`skip_specs\` in \`.openspec.yaml\`, so its files must NOT exist. Never try to create one - Create every artifact in the required set that is missing, then re-check - creating one can unblock others - - Skip one only when its own \`instruction\` says it is conditional: run \`openspec instructions --change "" --json\` and skip only if its \`instruction\` field marks it optional (e.g. "create only if..."). Spec-driven's \`design.md\` qualifies; \`specs\` never does. Tell the user, and do not reconsider it + - Skip one only when \`status\` already reports it \`skipped\`, or when its own \`instruction\` says it is conditional: run \`openspec instructions --change "" --json\` and skip only if its \`instruction\` field marks it optional (e.g. "create only if..."). Spec-driven's \`design.md\` qualifies; \`specs\` qualifies only via the \`skipped\` status above, never by your own judgment. Tell the user, and do not reconsider it - Dependencies are enablers, not gates: if a required artifact is still \`blocked\` only because you skipped a conditional dependency, write it anyway - - Stop when every artifact in the required set is \`done\` or was deliberately skipped + - Stop when every artifact in the required set is \`done\`, \`skipped\`, or was deliberately skipped c. **If an artifact requires user input** (unclear context): - Use **AskUserQuestion tool** to clarify diff --git a/src/core/templates/workflows/propose.ts b/src/core/templates/workflows/propose.ts index 9083a2cd09..81d0e66c72 100644 --- a/src/core/templates/workflows/propose.ts +++ b/src/core/templates/workflows/propose.ts @@ -69,6 +69,7 @@ ${STORE_SELECTION_GUIDANCE} - \`rules\`: Artifact-specific rules (constraints for you - do NOT include in output) - \`template\`: The structure to use for your output file - \`instruction\`: Schema-specific guidance for this artifact type + - \`skipped\`/\`warning\`: present when the change declares skip_specs and this artifact must NOT be created - stop and pick another artifact - \`resolvedOutputPath\`: Resolved path or pattern to write the artifact - \`dependencies\`: Completed artifacts to read for context - Read any completed dependency files for context - always re-read them from disk, even if you saw them earlier in the conversation (the user may have edited them) @@ -81,10 +82,11 @@ ${STORE_SELECTION_GUIDANCE} - After creating each artifact, re-run \`openspec status --change "" --json\` - The required set is \`applyRequires\` plus every artifact reachable from those by following the \`requires\` edges in \`status --json\` - walk them transitively (spec-driven closes over proposal, specs, design, tasks). Leave artifacts outside that set alone - \`status\` is file-existence only, so an \`applyRequires\` artifact reading \`done\` does NOT mean its dependencies exist - writing \`tasks.md\` early marks \`tasks\` done while \`specs\` was never written. Use each artifact's \`requires\` edges, not its \`status\`, to build the required set: a \`done\` artifact still lists what it depends on + - An artifact already reading \`status: "skipped"\` is satisfied: the change declares \`skip_specs\` in \`.openspec.yaml\`, so its files must NOT exist. Never try to create one - Create every artifact in the required set that is missing, then re-check - creating one can unblock others - - Skip one only when its own \`instruction\` says it is conditional: run \`openspec instructions --change "" --json\` and skip only if its \`instruction\` field marks it optional (e.g. "create only if..."). Spec-driven's \`design.md\` qualifies; \`specs\` never does. Tell the user, and do not reconsider it + - Skip one only when \`status\` already reports it \`skipped\`, or when its own \`instruction\` says it is conditional: run \`openspec instructions --change "" --json\` and skip only if its \`instruction\` field marks it optional (e.g. "create only if..."). Spec-driven's \`design.md\` qualifies; \`specs\` qualifies only via the \`skipped\` status above, never by your own judgment. Tell the user, and do not reconsider it - Dependencies are enablers, not gates: if a required artifact is still \`blocked\` only because you skipped a conditional dependency, write it anyway - - Stop when every artifact in the required set is \`done\` or was deliberately skipped + - Stop when every artifact in the required set is \`done\`, \`skipped\`, or was deliberately skipped c. **If an artifact requires user input** (unclear context): - Use **AskUserQuestion tool** to clarify @@ -190,6 +192,7 @@ ${STORE_SELECTION_GUIDANCE} - \`rules\`: Artifact-specific rules (constraints for you - do NOT include in output) - \`template\`: The structure to use for your output file - \`instruction\`: Schema-specific guidance for this artifact type + - \`skipped\`/\`warning\`: present when the change declares skip_specs and this artifact must NOT be created - stop and pick another artifact - \`resolvedOutputPath\`: Resolved path or pattern to write the artifact - \`dependencies\`: Completed artifacts to read for context - Read any completed dependency files for context - always re-read them from disk, even if you saw them earlier in the conversation (the user may have edited them) @@ -202,10 +205,11 @@ ${STORE_SELECTION_GUIDANCE} - After creating each artifact, re-run \`openspec status --change "" --json\` - The required set is \`applyRequires\` plus every artifact reachable from those by following the \`requires\` edges in \`status --json\` - walk them transitively (spec-driven closes over proposal, specs, design, tasks). Leave artifacts outside that set alone - \`status\` is file-existence only, so an \`applyRequires\` artifact reading \`done\` does NOT mean its dependencies exist - writing \`tasks.md\` early marks \`tasks\` done while \`specs\` was never written. Use each artifact's \`requires\` edges, not its \`status\`, to build the required set: a \`done\` artifact still lists what it depends on + - An artifact already reading \`status: "skipped"\` is satisfied: the change declares \`skip_specs\` in \`.openspec.yaml\`, so its files must NOT exist. Never try to create one - Create every artifact in the required set that is missing, then re-check - creating one can unblock others - - Skip one only when its own \`instruction\` says it is conditional: run \`openspec instructions --change "" --json\` and skip only if its \`instruction\` field marks it optional (e.g. "create only if..."). Spec-driven's \`design.md\` qualifies; \`specs\` never does. Tell the user, and do not reconsider it + - Skip one only when \`status\` already reports it \`skipped\`, or when its own \`instruction\` says it is conditional: run \`openspec instructions --change "" --json\` and skip only if its \`instruction\` field marks it optional (e.g. "create only if..."). Spec-driven's \`design.md\` qualifies; \`specs\` qualifies only via the \`skipped\` status above, never by your own judgment. Tell the user, and do not reconsider it - Dependencies are enablers, not gates: if a required artifact is still \`blocked\` only because you skipped a conditional dependency, write it anyway - - Stop when every artifact in the required set is \`done\` or was deliberately skipped + - Stop when every artifact in the required set is \`done\`, \`skipped\`, or was deliberately skipped c. **If an artifact requires user input** (unclear context): - Use **AskUserQuestion tool** to clarify diff --git a/src/core/templates/workflows/update-change.ts b/src/core/templates/workflows/update-change.ts index cf5475a6fe..a5780c4872 100644 --- a/src/core/templates/workflows/update-change.ts +++ b/src/core/templates/workflows/update-change.ts @@ -39,7 +39,7 @@ ${STORE_SELECTION_GUIDANCE} \`\`\` Parse the JSON to understand current state. The response includes: - \`schemaName\`: The workflow schema being used (e.g., "spec-driven") - - \`artifacts\`: Array of artifacts with their status ("done", "ready", "blocked") + - \`artifacts\`: Array of artifacts with their status ("done", "skipped", "ready", "blocked") - \`isComplete\`: Boolean indicating if all artifacts are complete - \`planningHome\`, \`changeRoot\`, \`artifactPaths\`, and \`actionContext\`: path and scope context. Use these instead of assuming repo-local paths. @@ -125,7 +125,7 @@ ${STORE_SELECTION_GUIDANCE} \`\`\` Parse the JSON to understand current state. The response includes: - \`schemaName\`: The workflow schema being used (e.g., "spec-driven") - - \`artifacts\`: Array of artifacts with their status ("done", "ready", "blocked") + - \`artifacts\`: Array of artifacts with their status ("done", "skipped", "ready", "blocked") - \`isComplete\`: Boolean indicating if all artifacts are complete - \`planningHome\`, \`changeRoot\`, \`artifactPaths\`, and \`actionContext\`: path and scope context. Use these instead of assuming repo-local paths. diff --git a/src/core/validation/constants.ts b/src/core/validation/constants.ts index a6cf0de60f..d08cd47ebe 100644 --- a/src/core/validation/constants.ts +++ b/src/core/validation/constants.ts @@ -26,6 +26,12 @@ export const VALIDATION_MESSAGES = { CHANGE_WHY_TOO_LONG: `Why section should not exceed ${MAX_WHY_SECTION_LENGTH} characters`, CHANGE_WHAT_EMPTY: 'What Changes section cannot be empty', CHANGE_NO_DELTAS: 'Change must have at least one delta', + CHANGE_SKIP_SPECS_CONFLICT: + 'skip_specs is set in .openspec.yaml but spec files exist under specs/. Remove skip_specs or delete the delta spec files', + CHANGE_SKIP_SPECS_ACCEPTED: + 'skip_specs is set in .openspec.yaml: change declares no spec-level behavior changes, zero deltas accepted', + CHANGE_SKIP_SPECS_INVALID_METADATA: + 'skip_specs is set but .openspec.yaml is not valid change metadata, so the marker is not honored. Fix the metadata', CHANGE_TOO_MANY_DELTAS: `Consider splitting changes with more than ${MAX_DELTAS_PER_CHANGE} deltas`, DELTA_SPEC_EMPTY: 'Spec name cannot be empty', DELTA_DESCRIPTION_EMPTY: 'Delta description cannot be empty', @@ -38,7 +44,7 @@ export const VALIDATION_MESSAGES = { // Guidance snippets (appended to primary messages for remediation) GUIDE_NO_DELTAS: - 'No deltas found. Ensure your change has a specs/ directory with capability folders (e.g. specs/http-server/spec.md) containing .md files that use delta headers (## ADDED/MODIFIED/REMOVED/RENAMED Requirements) and that each requirement includes at least one "#### Scenario:" block. Tip: run "openspec change show --json --deltas-only" to inspect parsed deltas.', + 'No deltas found. Ensure your change has a specs/ directory with capability folders (e.g. specs/http-server/spec.md) containing .md files that use delta headers (## ADDED/MODIFIED/REMOVED/RENAMED Requirements) and that each requirement includes at least one "#### Scenario:" block. If this change intentionally modifies no specs (pure refactor, tooling, docs), set "skip_specs: true" in the change\'s .openspec.yaml instead. Tip: run "openspec change show --json --deltas-only" to inspect parsed deltas.', GUIDE_MISSING_SPEC_SECTIONS: 'Missing required sections. Expected headers: "## Purpose" and "## Requirements". Example:\n## Purpose\n[brief purpose]\n\n## Requirements\n### Requirement: Clear requirement statement\nUsers SHALL ...\n\n#### Scenario: Descriptive name\n- **WHEN** ...\n- **THEN** ...', GUIDE_MISSING_CHANGE_SECTIONS: diff --git a/src/core/validation/validator.ts b/src/core/validation/validator.ts index 4dcc9a2fd1..4b59ed6cb1 100644 --- a/src/core/validation/validator.ts +++ b/src/core/validation/validator.ts @@ -18,7 +18,8 @@ import { } from '../parsers/requirement-text.js'; import { findMainSpecStructureIssues } from '../parsers/spec-structure.js'; import { FileSystemUtils } from '../../utils/file-system.js'; -import { discoverSpecFiles } from '../../utils/spec-discovery.js'; +import { discoverSpecFiles, hasAnyFileUnder } from '../../utils/spec-discovery.js'; +import { METADATA_FILENAME, readSkipSpecsMarker } from '../../utils/change-metadata.js'; export class Validator { private strictMode: boolean; @@ -85,13 +86,28 @@ export class Validator { const content = readFileSync(filePath, 'utf-8'); const changeDir = path.dirname(filePath); const parser = new ChangeParser(content, changeDir); - + const change = await parser.parseChangeWithDeltas(changeName); - + const result = ChangeSchema.safeParse(change); - + + const marker = readSkipSpecsMarker(changeDir); + if (marker.invalidReason) { + issues.push({ level: 'ERROR', path: METADATA_FILENAME, message: this.formatInvalidMarkerMessage(marker.invalidReason) }); + } + if (!result.success) { - issues.push(...this.convertZodErrors(result.error)); + let zodIssues = this.convertZodErrors(result.error); + // Only the no-deltas error is marker-aware here: the marker+files + // conflict is validateChangeDeltaSpecs's job, and every caller of + // this proposal-level pass (archive's non-blocking warnings) pairs + // it with that gate. + if (marker.declared) { + zodIssues = zodIssues.filter( + issue => !issue.message.startsWith(VALIDATION_MESSAGES.CHANGE_NO_DELTAS) + ); + } + issues.push(...zodIssues); } issues.push(...this.applyChangeRules(change, content)); @@ -323,16 +339,50 @@ export class Validator { }); } + const marker = readSkipSpecsMarker(changeDir); + if (marker.invalidReason) { + issues.push({ level: 'ERROR', path: METADATA_FILENAME, message: this.formatInvalidMarkerMessage(marker.invalidReason) }); + } + + // ANY file under specs/ contradicts the marker - not just parsed deltas. + // Headerless or stray files would be silently dropped at archive time (and + // some still satisfy the artifact graph's specs/** glob) while the change + // claims to have nothing, so they must surface as an explicit conflict. + // Probed only when the marker is declared, and unreadable specs/ (a stray + // `specs` file, permission errors) fails closed as a conflict: the marker + // claims nothing is there, and validate must not crash where the + // historical path degraded to "no deltas". + const skipSpecs = marker.declared; + let specsDirHasFiles = false; + if (skipSpecs) { + try { + specsDirHasFiles = await hasAnyFileUnder(specsDir); + } catch { + specsDirHasFiles = true; + } + } + if (skipSpecs && specsDirHasFiles) { + issues.push({ level: 'ERROR', path: 'file', message: VALIDATION_MESSAGES.CHANGE_SKIP_SPECS_CONFLICT }); + } + // The root-level error already names the file and the fix; adding "No // deltas found" on top would contradict it, since the deltas are sitting in // the file just reported. if (totalDeltas === 0 && !hasRootLevelSpec) { - issues.push({ level: 'ERROR', path: 'file', message: this.enrichTopLevelError('change', VALIDATION_MESSAGES.CHANGE_NO_DELTAS) }); + if (skipSpecs && !specsDirHasFiles) { + issues.push({ level: 'INFO', path: 'file', message: VALIDATION_MESSAGES.CHANGE_SKIP_SPECS_ACCEPTED }); + } else if (!skipSpecs) { + issues.push({ level: 'ERROR', path: 'file', message: this.enrichTopLevelError('change', VALIDATION_MESSAGES.CHANGE_NO_DELTAS) }); + } } return this.createReport(issues); } + private formatInvalidMarkerMessage(invalidReason: string): string { + return `${VALIDATION_MESSAGES.CHANGE_SKIP_SPECS_INVALID_METADATA} (${invalidReason})`; + } + private convertZodErrors(error: ZodError): ValidationIssue[] { return error.issues.map(err => { let message = err.message; diff --git a/src/utils/change-metadata.ts b/src/utils/change-metadata.ts index 7bc233ab53..1381ff4f5a 100644 --- a/src/utils/change-metadata.ts +++ b/src/utils/change-metadata.ts @@ -2,7 +2,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import * as yaml from 'yaml'; import { ChangeMetadataSchema, type ChangeMetadata } from '../core/change-metadata/index.js'; -import { listSchemas } from '../core/artifact-graph/resolver.js'; +import { listSchemas, resolveSchema } from '../core/artifact-graph/resolver.js'; import { readProjectConfig } from '../core/project-config.js'; export const METADATA_FILENAME = '.openspec.yaml'; @@ -196,3 +196,107 @@ export function resolveSchemaForChange( // 4. Default return 'spec-driven'; } + +export interface SkipSpecsMarker { + /** + * True when the metadata parses under ChangeMetadataSchema, sets + * skip_specs: true, and names a schema that loads. + */ + declared: boolean; + /** + * Set when the marker cannot be honored: skip_specs appears in a file that + * fails the metadata contract, or the metadata file exists but cannot be + * read at all (so whether the marker is set cannot even be determined). + */ + invalidReason?: string; +} + +/** + * Non-throwing read of the skip_specs marker. The marker only counts when the + * metadata would load for status/instructions: the file parses under + * ChangeMetadataSchema, its schema name passes readChangeMetadata's + * listSchemas membership check, AND the schema itself loads via resolveSchema + * (a schema.yaml that exists but does not parse fails status just the same). + * Validate and archive must never honor metadata the rest of the CLI rejects, + * in either direction. The project root for schema resolution is derived from + * changeDir exactly like resolveSchemaForChange (changeDir is + * /openspec/changes/ for every root type, including store roots). + * Missing metadata means "not declared"; a marker that cannot be honored + * yields invalidReason so callers can say why. + */ +export function readSkipSpecsMarker(changeDir: string): SkipSpecsMarker { + let raw: string; + try { + raw = fs.readFileSync(path.join(changeDir, METADATA_FILENAME), 'utf-8'); + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') { + return { declared: false }; + } + // The file exists but cannot be read (EACCES, EISDIR, ...). Status and + // instructions reject the change outright here, and whether a marker is + // set cannot be determined - fail closed rather than let archive treat + // the change as unmarked while every metadata-reading surface errors. + const message = + err instanceof Error ? err.message : String(err); + return { + declared: false, + invalidReason: `the metadata file cannot be read (${message})`, + }; + } + + let parsed: unknown; + try { + parsed = yaml.parse(raw); + } catch { + // Anchored so a comment like "# maybe add skip_specs later" does not + // claim the marker was set. + return /^\s*(['"]?)skip_specs\1\s*:/m.test(raw) + ? { declared: false, invalidReason: 'the file is not valid YAML' } + : { declared: false }; + } + + const result = ChangeMetadataSchema.safeParse(parsed); + if (result.success) { + if (result.data.skip_specs !== true) { + return { declared: false }; + } + // Schema loading is checked only when the marker is set: a broken schema + // on an ordinary change is status's problem to report, but honoring a + // marker that status rejects would let validate/archive pass what the + // rest of the CLI refuses to load. The membership check mirrors + // readChangeMetadata (which rejects names like 'spec-driven.yaml' that + // resolveSchema alone would normalize and accept); resolveSchema then + // proves the schema actually parses. Any failure fails closed. + try { + const projectRoot = path.resolve(changeDir, '../../..'); + if (!listSchemas(projectRoot).includes(result.data.schema)) { + return { + declared: false, + invalidReason: `schema: unknown schema '${result.data.schema}'`, + }; + } + resolveSchema(result.data.schema, projectRoot); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { declared: false, invalidReason: message }; + } + return { declared: true }; + } + + // Key presence, not value: skip_specs: "yes" must surface as unhonorable, + // not vanish while the zero-delta guidance tells the user to set the very + // marker they set. An explicit skip_specs: false is the opposite of setting + // the marker, so it must not drag unrelated metadata problems into + // validate - the change simply is not marked. + const markerMentioned = + typeof parsed === 'object' && + parsed !== null && + 'skip_specs' in parsed && + (parsed as Record).skip_specs !== false; + if (markerMentioned) { + const first = result.error.issues[0]; + const where = first.path.length > 0 ? `${first.path.join('.')}: ` : ''; + return { declared: false, invalidReason: `${where}${first.message}` }; + } + return { declared: false }; +} diff --git a/src/utils/spec-discovery.ts b/src/utils/spec-discovery.ts index 9030fcca80..7282498041 100644 --- a/src/utils/spec-discovery.ts +++ b/src/utils/spec-discovery.ts @@ -46,3 +46,42 @@ export async function discoverSpecFiles(specsRoot: string): Promise (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); } + +/** + * True when any regular non-dot file exists anywhere under the given + * directory. Used by validate/archive to detect content under a change's + * specs/ that contradicts a declared skip_specs marker - including files that + * discoverSpecFiles ignores (a root spec.md, stray non-spec.md notes), since + * anything there would be silently dropped or misread while the change claims + * to have nothing. Dot entries (.DS_Store, .gitkeep, dot-directories) are + * skipped to match discoverSpecFiles - they are invisible to every other + * code path, so they must not count as spec content. Symlinks DO count + * (without being followed): the artifact graph's globs follow them, so a + * symlinked spec would read as existing content while the change claims to + * have none - it contradicts the marker like any regular file. A missing + * directory returns false; other read failures are thrown for the caller to + * decide. + */ +export async function hasAnyFileUnder(dirPath: string): Promise { + let entries; + try { + entries = await fs.readdir(dirPath, { withFileTypes: true }); + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') { + return false; + } + throw err; + } + for (const entry of entries) { + if (entry.name.startsWith('.')) { + continue; + } + if (entry.isFile() || entry.isSymbolicLink()) { + return true; + } + if (entry.isDirectory() && (await hasAnyFileUnder(path.join(dirPath, entry.name)))) { + return true; + } + } + return false; +} diff --git a/test/commands/validate.test.ts b/test/commands/validate.test.ts index 65e9ce80e2..f3db80e486 100644 --- a/test/commands/validate.test.ts +++ b/test/commands/validate.test.ts @@ -69,6 +69,48 @@ describe('top-level validate command', () => { expect(result.stderr).toContain('Nothing to validate. Try one of:'); }); + it('shows marker-specific next steps on a skip_specs conflict, not delta-authoring guidance', async () => { + const chDir = path.join(changesDir, 'marked-conflict'); + const strayDir = path.join(chDir, 'specs', 'notes'); + await fs.mkdir(strayDir, { recursive: true }); + await fs.writeFile(path.join(strayDir, 'spec.md'), '# headerless notes\n', 'utf-8'); + await fs.writeFile( + path.join(chDir, '.openspec.yaml'), + 'schema: spec-driven\nskip_specs: true\n', + 'utf-8' + ); + + const result = await runCLI(['validate', 'marked-conflict', '--type', 'change'], { cwd: testDir }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('delete the files under specs/'); + expect(result.stderr).not.toContain('Ensure change has deltas in specs/'); + }); + + it('leads with the metadata fix when the marker is unhonorable and no spec files exist', async () => { + const chDir = path.join(changesDir, 'marked-invalid'); + await fs.mkdir(chDir, { recursive: true }); + // skip_specs without the required schema field, and nothing under specs/: + // "delete the files" would describe files that don't exist. + await fs.writeFile(path.join(chDir, '.openspec.yaml'), 'skip_specs: true\n', 'utf-8'); + + const result = await runCLI(['validate', 'marked-invalid', '--type', 'change'], { cwd: testDir }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('Fix .openspec.yaml so the skip_specs marker can be honored'); + expect(result.stderr).not.toContain('delete the files under specs/'); + }); + + it('keeps delta-authoring next steps for a plain zero-delta change', async () => { + // The generic no-deltas guidance itself mentions skip_specs; that string + // must not flip the footer into marker mode. + const chDir = path.join(changesDir, 'plain-empty'); + await fs.mkdir(chDir, { recursive: true }); + + const result = await runCLI(['validate', 'plain-empty', '--type', 'change'], { cwd: testDir }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('Ensure change has deltas in specs/'); + expect(result.stderr).not.toContain('delete the files under specs/'); + }); + it('validates all with --all and outputs JSON summary', async () => { const result = await runCLI(['validate', '--all', '--json'], { cwd: testDir }); expect(result.exitCode).toBe(0); diff --git a/test/commands/workflow-instructions-skipped.test.ts b/test/commands/workflow-instructions-skipped.test.ts new file mode 100644 index 0000000000..5b90be8239 --- /dev/null +++ b/test/commands/workflow-instructions-skipped.test.ts @@ -0,0 +1,157 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { + loadChangeContext, + generateInstructions, + formatChangeStatus, +} from '../../src/core/artifact-graph/instruction-loader.js'; +import { + printInstructionsText, + generateApplyInstructions, +} from '../../src/commands/workflow/instructions.js'; +import { printStatusText } from '../../src/commands/workflow/status.js'; + +describe('printInstructionsText for skip_specs changes', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-test-')); + const changeDir = path.join(tempDir, 'openspec', 'changes', 'my-change'); + fs.mkdirSync(changeDir, { recursive: true }); + fs.writeFileSync(path.join(changeDir, 'proposal.md'), '# Proposal'); + fs.writeFileSync( + path.join(changeDir, '.openspec.yaml'), + 'schema: spec-driven\nskip_specs: true\n' + ); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + function capture(artifactId: string): string { + const lines: string[] = []; + vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + lines.push(args.join(' ')); + }); + const context = loadChangeContext(tempDir, 'my-change'); + const instructions = generateInstructions(context, artifactId); + const isBlocked = instructions.dependencies.some((d) => !d.done); + printInstructionsText(instructions, isBlocked); + vi.restoreAllMocks(); + return lines.join('\n'); + } + + it('emits only the warning for a skipped artifact, no creation directive', () => { + const output = capture('specs'); + + expect(output).toContain('skip_specs: true'); + expect(output).toContain('Do not create spec files'); + expect(output).toContain(''); + expect(output).not.toContain(''); + expect(output).not.toContain('