@fission-ai/openspec 1.13.0 → 1.13.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/README.md +16 -4
  2. package/dist/commands/change.js +12 -1
  3. package/dist/commands/config.d.ts +9 -0
  4. package/dist/commands/config.js +161 -22
  5. package/dist/commands/feedback.js +6 -4
  6. package/dist/commands/schema.js +30 -9
  7. package/dist/commands/store.js +5 -2
  8. package/dist/commands/validate.d.ts +7 -0
  9. package/dist/commands/validate.js +63 -1
  10. package/dist/commands/workflow/instructions.js +69 -22
  11. package/dist/commands/workflow/new-change.js +20 -0
  12. package/dist/commands/workflow/shared.d.ts +5 -0
  13. package/dist/commands/workflow/shared.js +8 -0
  14. package/dist/commands/workflow/status.d.ts +5 -1
  15. package/dist/commands/workflow/status.js +42 -5
  16. package/dist/core/archive.js +260 -77
  17. package/dist/core/artifact-graph/outputs.d.ts +1 -1
  18. package/dist/core/artifact-graph/outputs.js +38 -8
  19. package/dist/core/artifact-graph/schema.d.ts +14 -0
  20. package/dist/core/artifact-graph/schema.js +52 -0
  21. package/dist/core/artifact-graph/types.js +12 -1
  22. package/dist/core/change-status-policy.d.ts +14 -0
  23. package/dist/core/change-status-policy.js +14 -6
  24. package/dist/core/command-generation/adapters/continue.js +4 -0
  25. package/dist/core/command-generation/adapters/kilocode.d.ts +1 -1
  26. package/dist/core/command-generation/adapters/kilocode.js +2 -2
  27. package/dist/core/command-generation/generator.js +2 -0
  28. package/dist/core/completion-tip.js +7 -7
  29. package/dist/core/completions/installers/bash-installer.js +15 -7
  30. package/dist/core/completions/installers/shell-quote.d.ts +11 -0
  31. package/dist/core/completions/installers/shell-quote.js +13 -0
  32. package/dist/core/completions/installers/zsh-installer.js +3 -2
  33. package/dist/core/config-schema.d.ts +1 -1
  34. package/dist/core/config.d.ts +16 -0
  35. package/dist/core/config.js +27 -1
  36. package/dist/core/global-config.d.ts +30 -2
  37. package/dist/core/global-config.js +57 -3
  38. package/dist/core/init.js +22 -6
  39. package/dist/core/legacy-cleanup.d.ts +5 -1
  40. package/dist/core/legacy-cleanup.js +207 -14
  41. package/dist/core/list.js +55 -11
  42. package/dist/core/migration.js +6 -1
  43. package/dist/core/parsers/change-parser.d.ts +12 -1
  44. package/dist/core/parsers/change-parser.js +85 -87
  45. package/dist/core/parsers/markdown-parser.js +4 -3
  46. package/dist/core/parsers/requirement-blocks.d.ts +78 -6
  47. package/dist/core/parsers/requirement-blocks.js +155 -38
  48. package/dist/core/parsers/requirement-text.d.ts +13 -2
  49. package/dist/core/parsers/requirement-text.js +34 -7
  50. package/dist/core/parsers/spec-structure.js +4 -1
  51. package/dist/core/project-config.d.ts +9 -1
  52. package/dist/core/project-config.js +11 -2
  53. package/dist/core/references.d.ts +20 -0
  54. package/dist/core/references.js +56 -0
  55. package/dist/core/shared/skill-content-equivalence.js +5 -1
  56. package/dist/core/shared/skill-generation.js +36 -8
  57. package/dist/core/shared/tool-detection.js +9 -3
  58. package/dist/core/specs-apply.d.ts +20 -0
  59. package/dist/core/specs-apply.js +101 -17
  60. package/dist/core/store/git.d.ts +33 -0
  61. package/dist/core/store/git.js +60 -6
  62. package/dist/core/store/operations.d.ts +1 -1
  63. package/dist/core/store/operations.js +32 -10
  64. package/dist/core/store/registry.d.ts +2 -1
  65. package/dist/core/store/registry.js +5 -1
  66. package/dist/core/templates/optional-workflow.d.ts +69 -0
  67. package/dist/core/templates/optional-workflow.js +140 -0
  68. package/dist/core/templates/workflows/apply-change.js +18 -3
  69. package/dist/core/templates/workflows/archive-change.js +70 -21
  70. package/dist/core/templates/workflows/bulk-archive-change.js +85 -21
  71. package/dist/core/templates/workflows/continue-change.js +14 -4
  72. package/dist/core/templates/workflows/explore.js +34 -16
  73. package/dist/core/templates/workflows/ff-change.js +21 -5
  74. package/dist/core/templates/workflows/new-change.js +15 -3
  75. package/dist/core/templates/workflows/onboard.js +76 -41
  76. package/dist/core/templates/workflows/project-root.d.ts +29 -0
  77. package/dist/core/templates/workflows/project-root.js +38 -0
  78. package/dist/core/templates/workflows/propose.js +22 -5
  79. package/dist/core/templates/workflows/sync-specs.js +40 -1
  80. package/dist/core/templates/workflows/update-change.js +66 -27
  81. package/dist/core/templates/workflows/verify-change.js +121 -49
  82. package/dist/core/update.d.ts +12 -0
  83. package/dist/core/update.js +78 -10
  84. package/dist/core/validation/purpose-placeholder.js +24 -9
  85. package/dist/core/validation/task-checkboxes.d.ts +34 -0
  86. package/dist/core/validation/task-checkboxes.js +181 -0
  87. package/dist/core/validation/validator.d.ts +53 -1
  88. package/dist/core/validation/validator.js +160 -45
  89. package/dist/core/version-check.d.ts +14 -7
  90. package/dist/core/version-check.js +63 -17
  91. package/dist/core/worksets.js +6 -4
  92. package/dist/prompts/searchable-multi-select.d.ts +7 -0
  93. package/dist/prompts/searchable-multi-select.js +35 -6
  94. package/dist/telemetry/config.js +14 -2
  95. package/dist/telemetry/index.d.ts +4 -3
  96. package/dist/telemetry/index.js +22 -10
  97. package/dist/telemetry/opt-out.d.ts +17 -0
  98. package/dist/telemetry/opt-out.js +27 -0
  99. package/dist/utils/file-system.js +20 -3
  100. package/dist/utils/line-endings.d.ts +34 -0
  101. package/dist/utils/line-endings.js +46 -0
  102. package/dist/utils/nested-change.d.ts +35 -0
  103. package/dist/utils/nested-change.js +186 -0
  104. package/dist/utils/spec-discovery.d.ts +20 -0
  105. package/dist/utils/spec-discovery.js +59 -0
  106. package/dist/utils/task-progress.d.ts +1 -1
  107. package/dist/utils/task-progress.js +38 -6
  108. package/package.json +5 -5
  109. package/schemas/spec-driven/schema.yaml +18 -3
  110. package/schemas/spec-driven/templates/design.md +2 -0
  111. package/schemas/spec-driven/templates/proposal.md +2 -0
  112. package/schemas/spec-driven/templates/spec.md +2 -0
  113. package/schemas/spec-driven/templates/tasks.md +2 -0
@@ -0,0 +1,34 @@
1
+ export interface TaskCheckboxDocument {
2
+ path: string;
3
+ content: string;
4
+ }
5
+ export interface TaskCheckboxIssue {
6
+ path: string;
7
+ line: number;
8
+ message: string;
9
+ }
10
+ /**
11
+ * Reports tracked task files that list work as plain bullets or numbered items
12
+ * instead of checkboxes (#354).
13
+ *
14
+ * Progress counts checkboxes and nothing else, so a tasks file written as a
15
+ * bare list is worse than an empty one: `openspec list` prints "No tasks",
16
+ * `openspec status` treats the change as having no work left, and
17
+ * `openspec archive` has no incomplete task to warn about. The file looks
18
+ * finished to the tool and unfinished to the reader.
19
+ *
20
+ * Reported only when the change's *whole* tracked set has zero checkboxes.
21
+ * One nested tasks file of prose beside a real checklist is not the failure
22
+ * this catches, and a change that is mid-authoring keeps its progress the
23
+ * moment a single checkbox exists.
24
+ *
25
+ * The scan for the offending line looks at rendered content only: fenced
26
+ * blocks, HTML comments, YAML front matter and top-level indented code are
27
+ * skipped. Every one of those
28
+ * exclusions can only *silence* a warning, never drop a real task — that is the
29
+ * opposite trade from the task parser, where fence awareness would hide work
30
+ * that `archive` must still refuse, and it is why the parser stays literal
31
+ * while this scan does not.
32
+ */
33
+ export declare function findMissingTaskCheckboxIssues(documents: readonly TaskCheckboxDocument[]): TaskCheckboxIssue[];
34
+ //# sourceMappingURL=task-checkboxes.d.ts.map
@@ -0,0 +1,181 @@
1
+ import { parseTaskLines } from '../../utils/task-progress.js';
2
+ /**
3
+ * A list item that carries text but no checkbox: `- item`, `* item`, `+ item`,
4
+ * `1. item`, `1) item`. Checkbox lines match this too, so callers must rule the
5
+ * document set out on checkbox count first.
6
+ *
7
+ * Matches at any indent; the caller decides how deep is too deep. See
8
+ * `CODE_BLOCK_COLUMN`.
9
+ *
10
+ * A thematic break (`---`, `***`, `- - -`) is not a list item: the run has no
11
+ * text after it, and this pattern requires a non-space character. `* * *` is
12
+ * the one break spelled like a list of `*` items, and it is accepted as a list
13
+ * item rather than special-cased, because a file whose only list-shaped line is
14
+ * a horizontal rule still has zero tasks — the warning stays true, it just
15
+ * points at an odd line.
16
+ *
17
+ * An ordered marker runs at most nine digits, as CommonMark and the scenario
18
+ * bullet pattern in `specs-apply.ts` both require.
19
+ */
20
+ const LIST_ITEM = /^\s*(?:[-*+]|\d{1,9}[.)])\s+\S/;
21
+ /**
22
+ * The indent at which a top-level line stops being content and becomes an
23
+ * indented code block, per CommonMark.
24
+ *
25
+ * The fence logic already treats four spaces as code; the list scan did not,
26
+ * so a sample of terminal output written as ` - example output` was
27
+ * reported as an uncheckboxed task list, and under `--strict` that false
28
+ * positive failed validation on a correct file.
29
+ *
30
+ * Genuine nested lists survive the cut, because this scan reports the *first*
31
+ * list item it finds and a nested item always sits under a shallower parent.
32
+ * That parent is what gets reported, exactly as before. A list-shaped line
33
+ * four columns deep with no shallower item above it is not nested under
34
+ * anything, which is precisely what makes it code.
35
+ */
36
+ const CODE_BLOCK_COLUMN = 4;
37
+ /** Leading whitespace of a line in visual columns, a tab counting as four. */
38
+ function indentColumns(line) {
39
+ let column = 0;
40
+ for (const char of line) {
41
+ if (char === ' ')
42
+ column += 1;
43
+ else if (char === '\t')
44
+ column += CODE_BLOCK_COLUMN - (column % CODE_BLOCK_COLUMN);
45
+ else
46
+ break;
47
+ }
48
+ return column;
49
+ }
50
+ /**
51
+ * A fenced block delimiter: a run of three or more backticks or tildes, plus
52
+ * whatever follows it on the line (an info string on an opener, nothing on a
53
+ * valid closer).
54
+ *
55
+ * Indented by at most three spaces, as CommonMark requires: at four, the line is
56
+ * an indented code block rather than a fence, and treating it as an opener would
57
+ * leave the scan inside a block that never began and hide every list below it.
58
+ *
59
+ * Deliberately unanchored at the end: `.` does not match `\r`, so `(.*)$` would
60
+ * fail on every line of a CRLF file and blind the scan to fences entirely.
61
+ */
62
+ const FENCE = /^ {0,3}(`{3,}|~{3,})(.*)/;
63
+ /**
64
+ * The YAML front-matter delimiter: exactly three dashes. A longer run is a
65
+ * thematic break, so `----` must not open a block that swallows the list under
66
+ * it until the next `---`.
67
+ */
68
+ const FRONT_MATTER = /^-{3}\s*$/;
69
+ const COMMENT_OPEN = '<!--';
70
+ const COMMENT_CLOSE = '-->';
71
+ /**
72
+ * Reports tracked task files that list work as plain bullets or numbered items
73
+ * instead of checkboxes (#354).
74
+ *
75
+ * Progress counts checkboxes and nothing else, so a tasks file written as a
76
+ * bare list is worse than an empty one: `openspec list` prints "No tasks",
77
+ * `openspec status` treats the change as having no work left, and
78
+ * `openspec archive` has no incomplete task to warn about. The file looks
79
+ * finished to the tool and unfinished to the reader.
80
+ *
81
+ * Reported only when the change's *whole* tracked set has zero checkboxes.
82
+ * One nested tasks file of prose beside a real checklist is not the failure
83
+ * this catches, and a change that is mid-authoring keeps its progress the
84
+ * moment a single checkbox exists.
85
+ *
86
+ * The scan for the offending line looks at rendered content only: fenced
87
+ * blocks, HTML comments, YAML front matter and top-level indented code are
88
+ * skipped. Every one of those
89
+ * exclusions can only *silence* a warning, never drop a real task — that is the
90
+ * opposite trade from the task parser, where fence awareness would hide work
91
+ * that `archive` must still refuse, and it is why the parser stays literal
92
+ * while this scan does not.
93
+ */
94
+ export function findMissingTaskCheckboxIssues(documents) {
95
+ if (documents.length === 0)
96
+ return [];
97
+ if (documents.some((document) => parseTaskLines(document.content).length > 0))
98
+ return [];
99
+ const issues = [];
100
+ for (const document of documents) {
101
+ const line = findFirstListItemLine(document.content);
102
+ if (line === undefined)
103
+ continue;
104
+ issues.push({
105
+ path: document.path,
106
+ line,
107
+ message: 'This change counts as 0 tasks: no line in its tracked task files is a checkbox, ' +
108
+ 'so "openspec list" and "openspec status" report no work and "openspec archive" ' +
109
+ 'has nothing to flag as incomplete. Write each task as "- [ ] 1.1 Description".',
110
+ });
111
+ }
112
+ return issues;
113
+ }
114
+ /** 1-based line of the first list item in rendered content, if any. */
115
+ function findFirstListItemLine(content) {
116
+ const lines = content.split('\n');
117
+ let openFence;
118
+ let inComment = false;
119
+ let index = skipFrontMatter(lines);
120
+ for (; index < lines.length; index++) {
121
+ const line = lines[index];
122
+ if (inComment) {
123
+ if (line.includes(COMMENT_CLOSE))
124
+ inComment = false;
125
+ continue;
126
+ }
127
+ const fence = line.match(FENCE);
128
+ if (fence) {
129
+ const marker = fence[1][0];
130
+ const length = fence[1].length;
131
+ if (openFence === undefined) {
132
+ openFence = { marker, length };
133
+ }
134
+ else if (marker === openFence.marker &&
135
+ length >= openFence.length &&
136
+ fence[2].trim() === '') {
137
+ // CommonMark: a closer matches its opener's character, runs at least as
138
+ // long, and carries no info string. A shorter or annotated run inside a
139
+ // block is content, so a ```` ``` ```` sample nested in a ```` ```` ````
140
+ // block does not end the block early and expose its bullets.
141
+ openFence = undefined;
142
+ }
143
+ continue;
144
+ }
145
+ if (openFence !== undefined)
146
+ continue;
147
+ // Only a comment that opens the line hides it. `- [ ] 1.1 do it <!-- note`
148
+ // is a task line first, and the template's own `## 1. <!-- Task Group -->`
149
+ // must not swallow the checklist that follows it.
150
+ if (line.trimStart().startsWith(COMMENT_OPEN)) {
151
+ if (!line.includes(COMMENT_CLOSE, line.indexOf(COMMENT_OPEN) + COMMENT_OPEN.length)) {
152
+ inComment = true;
153
+ }
154
+ continue;
155
+ }
156
+ if (LIST_ITEM.test(line) && indentColumns(line) < CODE_BLOCK_COLUMN)
157
+ return index + 1;
158
+ }
159
+ return undefined;
160
+ }
161
+ /**
162
+ * Index of the first line after a YAML front-matter block, or 0 when the
163
+ * document does not open with one. A list under `tags:` is metadata about the
164
+ * file, never the work it tracks, and pointing a "write checkboxes" warning at
165
+ * it would name the wrong line.
166
+ */
167
+ function skipFrontMatter(lines) {
168
+ // A UTF-8 BOM, prepended by Windows editors and PowerShell redirects, would
169
+ // otherwise hide the opener and expose a `tags:` list to the scan.
170
+ if (lines.length === 0 || !FRONT_MATTER.test(lines[0].replace(/^/, '').trimEnd())) {
171
+ return 0;
172
+ }
173
+ for (let index = 1; index < lines.length; index++) {
174
+ if (FRONT_MATTER.test(lines[index].trimEnd()))
175
+ return index + 1;
176
+ }
177
+ // An unterminated opener is a thematic break, not front matter: rewinding to
178
+ // the top keeps every list in the document visible to the scan.
179
+ return 0;
180
+ }
181
+ //# sourceMappingURL=task-checkboxes.js.map
@@ -29,7 +29,53 @@ export declare class Validator {
29
29
  mainSpecsDir?: string;
30
30
  projectRoot?: string;
31
31
  }): Promise<ValidationReport>;
32
- private collectTaskNumberingIssues;
32
+ /**
33
+ * Lints the change's task files.
34
+ *
35
+ * Two checks with deliberately different reach. Checkbox formatting is read
36
+ * from whatever the change's own schema declares as its tracked task output,
37
+ * because every schema's progress, apply and archive behavior is computed by
38
+ * counting checkboxes in exactly those files. Numbering stays scoped to the
39
+ * built-in `spec-driven` schema, whose template is the one that numbers tasks
40
+ * in the first place.
41
+ *
42
+ * The checkbox check never falls back to a bare top-level `tasks.md`: a file
43
+ * no artifact declares is not a tracked task list, and warning about its
44
+ * formatting would be a guess about a file the tool does not read.
45
+ */
46
+ private collectTaskFileIssues;
47
+ /**
48
+ * Reads task files into change-relative documents, counting the ones that
49
+ * exist but could not be read. A file that is simply absent is not counted:
50
+ * the resolver only returns files it found, so the remaining read failures
51
+ * are permissions and I/O, and a check that reasons over the whole set needs
52
+ * to know its evidence was incomplete.
53
+ */
54
+ private readTaskDocuments;
55
+ /**
56
+ * Names a task file relative to its change, POSIX-separated.
57
+ *
58
+ * Both sides are canonicalized first. `resolveArtifactOutputs` hands back real
59
+ * paths, while `changeDir` carries whatever spelling the caller resolved, and
60
+ * the two can differ without being apart: on Windows a short 8.3 alias
61
+ * (`RUNNER~1`) against its expanded form turned `tasks.md` into a
62
+ * `../../../..`-prefixed absolute path in the report, and a symlinked project
63
+ * directory does the same elsewhere. Canonicalizing recovers the real
64
+ * relationship. The Windows CI job is the regression guard — the mismatch
65
+ * cannot be staged on POSIX, where the spawned CLI's `process.cwd()` is
66
+ * already physical.
67
+ *
68
+ * A path that still escapes would be a resolver bug rather than a spelling
69
+ * difference, but the report must never leak an absolute filesystem path, so
70
+ * the file name stands in.
71
+ */
72
+ private taskDocumentPath;
73
+ /**
74
+ * True when the change resolves to the package's own `spec-driven` schema. A
75
+ * project schema that merely reuses the name is not it, so checks written
76
+ * against the built-in template never fire on someone else's task format.
77
+ */
78
+ private usesBuiltInSpecDrivenSchema;
33
79
  /**
34
80
  * Report MODIFIED requirements whose block omits a scenario the main spec
35
81
  * still carries. Uses the same comparison archive applies, so validate can
@@ -72,6 +118,12 @@ export declare class Validator {
72
118
  */
73
119
  private buildMissingShallOrMustMessage;
74
120
  private countScenarios;
121
+ /**
122
+ * Why a requirement with a visible scenario header still has no scenario:
123
+ * the header has no body, and the spec path archive validates against does
124
+ * not count it. Empty when the block has no bare scenario header.
125
+ */
126
+ private emptyScenarioHint;
75
127
  private formatSectionList;
76
128
  }
77
129
  //# sourceMappingURL=validator.d.ts.map
@@ -5,14 +5,15 @@ import { MarkdownParser } from '../parsers/markdown-parser.js';
5
5
  import { ChangeParser } from '../parsers/change-parser.js';
6
6
  import { findSpecUpdates, buildUpdatedSpec } from '../specs-apply.js';
7
7
  import { MIN_PURPOSE_LENGTH, MAX_REQUIREMENT_TEXT_LENGTH, VALIDATION_MESSAGES } from './constants.js';
8
- import { parseDeltaSpec, foldRequirementName, normalizeRequirementName, extractRequirementsSection, findMissingCurrentScenarios, } from '../parsers/requirement-blocks.js';
9
- import { extractRequirementBody as extractRequirementBodyShared, containsShallOrMust as containsShallOrMustShared, countScenarios as countScenariosShared, } from '../parsers/requirement-text.js';
8
+ import { parseDeltaSpec, foldRequirementName, normalizeRequirementName, extractRequirementsSection, diffScenarioNames, describeScenarioBalance, } from '../parsers/requirement-blocks.js';
9
+ import { extractRequirementBody as extractRequirementBodyShared, containsShallOrMust as containsShallOrMustShared, countScenarios as countScenariosShared, countEmptyScenarios, } from '../parsers/requirement-text.js';
10
10
  import { findMainSpecStructureIssues } from '../parsers/spec-structure.js';
11
11
  import { FileSystemUtils } from '../../utils/file-system.js';
12
- import { discoverSpecFiles, hasAnyFileUnder } from '../../utils/spec-discovery.js';
12
+ import { discoverSpecFiles, findUnreadDeltaFiles, hasAnyFileUnder } from '../../utils/spec-discovery.js';
13
13
  import { METADATA_FILENAME, readSkipSpecsMarker, resolveSchemaForChange, } from '../../utils/change-metadata.js';
14
14
  import { resolveTaskFilesForChange } from '../../utils/task-progress.js';
15
15
  import { findTaskNumberingIssues } from './task-numbering.js';
16
+ import { findMissingTaskCheckboxIssues } from './task-checkboxes.js';
16
17
  import { findPurposePlaceholderIssue } from './purpose-placeholder.js';
17
18
  import { getPackageSchemasDir, getSchemaDir } from '../artifact-graph/index.js';
18
19
  export class Validator {
@@ -174,6 +175,34 @@ export class Validator {
174
175
  : `Header "### ${stray.header}" in ${stray.section} is not a "### Requirement:" header and is ignored by validation. Use "### Requirement: ${stray.header}" if it should be validated as a requirement.`,
175
176
  });
176
177
  }
178
+ // A FROM:/TO: line that never formed a pair. Reported here so the author
179
+ // learns at authoring time, rather than having archive either skip the
180
+ // rename or apply it to a requirement they never named.
181
+ for (const unpaired of plan.unpairedRenames) {
182
+ const missing = unpaired.side === 'FROM' ? 'TO' : 'FROM';
183
+ issues.push({
184
+ level: 'ERROR',
185
+ path: entryPath,
186
+ line: unpaired.line,
187
+ message: `RENAMED ${unpaired.side}: "${unpaired.name}" has no matching ${missing}: line. Write each rename as a FROM: line followed immediately by its TO: line.`,
188
+ });
189
+ }
190
+ // A well-formed requirement written outside every delta section. The
191
+ // reader only looks inside the four delta sections, so this block is
192
+ // ignored - reported as a WARNING rather than an ERROR because a
193
+ // handful of pre-format archived changes still carry this shape, and
194
+ // the fix is to move the block, not to reject the change outright.
195
+ for (const orphan of plan.orphanedRequirements) {
196
+ const where = orphan.section
197
+ ? `under "## ${orphan.section}"`
198
+ : 'above the first "## " section';
199
+ issues.push({
200
+ level: 'WARNING',
201
+ path: entryPath,
202
+ line: orphan.line,
203
+ message: `Requirement "${orphan.name}" is ${where}, which is not a delta section, so it is ignored. Move it under "## ADDED Requirements", "## MODIFIED Requirements", "## REMOVED Requirements", or "## RENAMED Requirements".`,
204
+ });
205
+ }
177
206
  const sectionNames = [];
178
207
  if (plan.sectionPresence.added)
179
208
  sectionNames.push('## ADDED Requirements');
@@ -225,7 +254,7 @@ export class Validator {
225
254
  }
226
255
  const scenarioCount = this.countScenarios(block.raw);
227
256
  if (scenarioCount < 1) {
228
- issues.push({ level: 'ERROR', path: entryPath, message: `ADDED "${block.name}" must include at least one scenario` });
257
+ issues.push({ level: 'ERROR', path: entryPath, message: `ADDED "${block.name}" must include at least one scenario${this.emptyScenarioHint(block.raw)}` });
229
258
  }
230
259
  }
231
260
  // Validate MODIFIED
@@ -257,7 +286,7 @@ export class Validator {
257
286
  }
258
287
  const scenarioCount = this.countScenarios(block.raw);
259
288
  if (scenarioCount < 1) {
260
- issues.push({ level: 'ERROR', path: entryPath, message: `MODIFIED "${block.name}" must include at least one scenario` });
289
+ issues.push({ level: 'ERROR', path: entryPath, message: `MODIFIED "${block.name}" must include at least one scenario${this.emptyScenarioHint(block.raw)}` });
261
290
  }
262
291
  }
263
292
  // Run archive's scenario-loss check here too, so the change fails at
@@ -358,6 +387,17 @@ export class Validator {
358
387
  throw error;
359
388
  }
360
389
  }
390
+ // The same drop happens to delta sections in any other file the merge
391
+ // path does not read (specs/<capability>.md, a note beside spec.md),
392
+ // while the artifact graph's specs/**/*.md glob counts it as written.
393
+ const unreadDeltaFiles = await findUnreadDeltaFiles(specsDir);
394
+ for (const file of unreadDeltaFiles) {
395
+ issues.push({
396
+ level: 'ERROR',
397
+ path: file.path,
398
+ message: `Delta spec found at specs/${file.path}. Delta specs must be a spec.md inside a capability folder — this file is ignored when the change is applied or archived. Move its requirements into specs/${file.expected}.`,
399
+ });
400
+ }
361
401
  for (const { path: specPath, sections } of emptySectionSpecs) {
362
402
  issues.push({
363
403
  level: 'ERROR',
@@ -397,10 +437,10 @@ export class Validator {
397
437
  if (skipSpecs && specsDirHasFiles) {
398
438
  issues.push({ level: 'ERROR', path: 'file', message: VALIDATION_MESSAGES.CHANGE_SKIP_SPECS_CONFLICT });
399
439
  }
400
- // The root-level error already names the file and the fix; adding "No
401
- // deltas found" on top would contradict it, since the deltas are sitting in
402
- // the file just reported.
403
- if (totalDeltas === 0 && !hasRootLevelSpec) {
440
+ // The root-level and unread-file errors already name the file and the fix;
441
+ // adding "No deltas found" on top would contradict them, since the deltas
442
+ // are sitting in the files just reported.
443
+ if (totalDeltas === 0 && !hasRootLevelSpec && unreadDeltaFiles.length === 0) {
404
444
  if (skipSpecs && !specsDirHasFiles) {
405
445
  issues.push({ level: 'INFO', path: 'file', message: VALIDATION_MESSAGES.CHANGE_SKIP_SPECS_ACCEPTED });
406
446
  }
@@ -409,56 +449,120 @@ export class Validator {
409
449
  }
410
450
  }
411
451
  if (options.projectRoot) {
412
- issues.push(...await this.collectTaskNumberingIssues(changeDir, options.projectRoot));
452
+ issues.push(...await this.collectTaskFileIssues(changeDir, options.projectRoot));
413
453
  }
414
454
  return this.createReport(issues);
415
455
  }
416
- async collectTaskNumberingIssues(changeDir, projectRoot) {
456
+ /**
457
+ * Lints the change's task files.
458
+ *
459
+ * Two checks with deliberately different reach. Checkbox formatting is read
460
+ * from whatever the change's own schema declares as its tracked task output,
461
+ * because every schema's progress, apply and archive behavior is computed by
462
+ * counting checkboxes in exactly those files. Numbering stays scoped to the
463
+ * built-in `spec-driven` schema, whose template is the one that numbers tasks
464
+ * in the first place.
465
+ *
466
+ * The checkbox check never falls back to a bare top-level `tasks.md`: a file
467
+ * no artifact declares is not a tracked task list, and warning about its
468
+ * formatting would be a guess about a file the tool does not read.
469
+ */
470
+ async collectTaskFileIssues(changeDir, projectRoot) {
471
+ let trackedFiles;
417
472
  try {
418
- const schemaName = resolveSchemaForChange(changeDir, undefined, projectRoot).replace(/\.ya?ml$/, '');
419
- const schemaDir = getSchemaDir(schemaName, projectRoot);
420
- const builtInSchemaDir = path.join(getPackageSchemasDir(), 'spec-driven');
421
- if (schemaName !== 'spec-driven' ||
422
- schemaDir === null ||
423
- FileSystemUtils.canonicalizeExistingPath(schemaDir) !==
424
- FileSystemUtils.canonicalizeExistingPath(builtInSchemaDir)) {
425
- return [];
426
- }
473
+ trackedFiles = resolveTaskFilesForChange(changeDir, projectRoot);
427
474
  }
428
475
  catch {
429
476
  return [];
430
477
  }
431
- let taskFiles;
432
- try {
433
- taskFiles = resolveTaskFilesForChange(changeDir, projectRoot);
434
- }
435
- catch {
436
- return [];
478
+ const files = trackedFiles.length > 0 ? trackedFiles : [path.join(changeDir, 'tasks.md')];
479
+ const { documents, unreadable } = await this.readTaskDocuments(changeDir, files);
480
+ const toWarning = (issue) => ({
481
+ level: 'WARNING',
482
+ path: issue.path,
483
+ line: issue.line,
484
+ message: issue.message,
485
+ });
486
+ const issues = [];
487
+ // "No file here holds a checkbox" is a claim about the whole tracked set, so
488
+ // a file that exists but could not be read withdraws it: the checkboxes may
489
+ // be in exactly that file. `validate --archived` is the surface that reports
490
+ // an unreadable task file loudly (#205); this one must not guess from it.
491
+ if (trackedFiles.length > 0 && unreadable === 0) {
492
+ issues.push(...findMissingTaskCheckboxIssues(documents).map(toWarning));
437
493
  }
438
- if (taskFiles.length === 0) {
439
- taskFiles = [path.join(changeDir, 'tasks.md')];
494
+ if (this.usesBuiltInSpecDrivenSchema(changeDir, projectRoot)) {
495
+ issues.push(...findTaskNumberingIssues(documents).map(toWarning));
440
496
  }
497
+ return issues;
498
+ }
499
+ /**
500
+ * Reads task files into change-relative documents, counting the ones that
501
+ * exist but could not be read. A file that is simply absent is not counted:
502
+ * the resolver only returns files it found, so the remaining read failures
503
+ * are permissions and I/O, and a check that reasons over the whole set needs
504
+ * to know its evidence was incomplete.
505
+ */
506
+ async readTaskDocuments(changeDir, files) {
441
507
  const documents = [];
442
- for (const taskFile of taskFiles) {
508
+ let unreadable = 0;
509
+ for (const file of files) {
443
510
  let content;
444
511
  try {
445
- content = await fs.readFile(taskFile, 'utf-8');
512
+ content = await fs.readFile(file, 'utf-8');
446
513
  }
447
- catch {
514
+ catch (error) {
515
+ if (error?.code !== 'ENOENT')
516
+ unreadable++;
448
517
  continue;
449
518
  }
450
- documents.push({
451
- path: FileSystemUtils.toPosixPath(path.relative(changeDir, taskFile)),
452
- content,
453
- });
519
+ documents.push({ path: this.taskDocumentPath(changeDir, file), content });
454
520
  }
455
521
  documents.sort((left, right) => left.path.localeCompare(right.path));
456
- return findTaskNumberingIssues(documents).map((issue) => ({
457
- level: 'WARNING',
458
- path: issue.path,
459
- line: issue.line,
460
- message: issue.message,
461
- }));
522
+ return { documents, unreadable };
523
+ }
524
+ /**
525
+ * Names a task file relative to its change, POSIX-separated.
526
+ *
527
+ * Both sides are canonicalized first. `resolveArtifactOutputs` hands back real
528
+ * paths, while `changeDir` carries whatever spelling the caller resolved, and
529
+ * the two can differ without being apart: on Windows a short 8.3 alias
530
+ * (`RUNNER~1`) against its expanded form turned `tasks.md` into a
531
+ * `../../../..`-prefixed absolute path in the report, and a symlinked project
532
+ * directory does the same elsewhere. Canonicalizing recovers the real
533
+ * relationship. The Windows CI job is the regression guard — the mismatch
534
+ * cannot be staged on POSIX, where the spawned CLI's `process.cwd()` is
535
+ * already physical.
536
+ *
537
+ * A path that still escapes would be a resolver bug rather than a spelling
538
+ * difference, but the report must never leak an absolute filesystem path, so
539
+ * the file name stands in.
540
+ */
541
+ taskDocumentPath(changeDir, file) {
542
+ const relative = path.relative(FileSystemUtils.canonicalizeExistingPath(changeDir), FileSystemUtils.canonicalizeExistingPath(file));
543
+ const escapes = relative === '' || relative.startsWith('..') || path.isAbsolute(relative);
544
+ return escapes ? path.basename(file) : FileSystemUtils.toPosixPath(relative);
545
+ }
546
+ /**
547
+ * True when the change resolves to the package's own `spec-driven` schema. A
548
+ * project schema that merely reuses the name is not it, so checks written
549
+ * against the built-in template never fire on someone else's task format.
550
+ */
551
+ usesBuiltInSpecDrivenSchema(changeDir, projectRoot) {
552
+ try {
553
+ const schemaName = resolveSchemaForChange(changeDir, undefined, projectRoot).replace(/\.ya?ml$/, '');
554
+ if (schemaName !== 'spec-driven')
555
+ return false;
556
+ const schemaDir = getSchemaDir(schemaName, projectRoot);
557
+ if (schemaDir === null)
558
+ return false;
559
+ const builtInSchemaDir = path.join(getPackageSchemasDir(), 'spec-driven');
560
+ return (FileSystemUtils.canonicalizeExistingPath(schemaDir) ===
561
+ FileSystemUtils.canonicalizeExistingPath(builtInSchemaDir));
562
+ }
563
+ catch {
564
+ return false;
565
+ }
462
566
  }
463
567
  /**
464
568
  * Report MODIFIED requirements whose block omits a scenario the main spec
@@ -535,15 +639,16 @@ export class Validator {
535
639
  const current = currentBlockFor(key);
536
640
  if (!current)
537
641
  continue;
538
- const missing = findMissingCurrentScenarios(current, block);
539
- if (missing.length === 0)
642
+ const diff = diffScenarioNames(current, block);
643
+ if (diff.missing.length === 0)
540
644
  continue;
541
645
  issues.push({
542
646
  level: 'ERROR',
543
647
  path: entryPath,
544
648
  message: `MODIFIED "${block.name}" omits scenario(s) the current spec still has: ` +
545
- `${missing.map(name => `"${name}"`).join(', ')}. ` +
546
- 'Copy them into the MODIFIED block (a MODIFIED requirement replaces the whole block, so archive refuses to drop them).',
649
+ `${diff.missing.map(name => `"${name}"`).join(', ')}. ` +
650
+ `${describeScenarioBalance(diff)} ` +
651
+ 'Copy the omitted scenarios into the MODIFIED block (a MODIFIED requirement replaces the whole block, so archive refuses to drop them).',
547
652
  });
548
653
  }
549
654
  return issues;
@@ -797,6 +902,16 @@ export class Validator {
797
902
  // example is not a real scenario. Drop the header line (index 0).
798
903
  return countScenariosShared(blockRaw.split('\n').slice(1));
799
904
  }
905
+ /**
906
+ * Why a requirement with a visible scenario header still has no scenario:
907
+ * the header has no body, and the spec path archive validates against does
908
+ * not count it. Empty when the block has no bare scenario header.
909
+ */
910
+ emptyScenarioHint(blockRaw) {
911
+ if (countEmptyScenarios(blockRaw.split('\n').slice(1)) === 0)
912
+ return '';
913
+ return ' (a scenario header with no body under it does not count; add its steps, e.g. "- **WHEN** ..." and "- **THEN** ...")';
914
+ }
800
915
  formatSectionList(sections) {
801
916
  if (sections.length === 0)
802
917
  return '';
@@ -1,12 +1,14 @@
1
1
  /**
2
- * The registry to ask: only the environment variable npm exports (under
3
- * `npm run`, or an explicit export). Deliberately not a `registry=` line from
4
- * any .npmrc — letting file contents choose the destination of an outbound
5
- * request is a flow worth avoiding for a convenience this small, and a project
6
- * file would travel with a cloned repository. Anyone on a private mirror can
7
- * export `npm_config_registry`, or turn the check off entirely.
2
+ * The registry to ask. A configured registry is honored only over TLS: a
3
+ * cleartext hop would aim the request at a link-local or internal address and
4
+ * let anyone on the path choose the "newer version" answer.
5
+ *
6
+ * A rejected registry disables the check rather than falling back to public
7
+ * npm. Falling back would send a request an organization on an internal mirror
8
+ * deliberately avoided, and would then report a version resolved against a
9
+ * registry the eventual `npm install -g` does not use.
8
10
  */
9
- export declare function registryUrl(): string;
11
+ export declare function registryUrl(): string | null;
10
12
  /**
11
13
  * Compares two semver-ish versions. Returns 1 when a > b, -1 when a < b, 0
12
14
  * otherwise. Prereleases sort below their release (1.7.0-beta.1 < 1.7.0).
@@ -92,6 +94,11 @@ export declare function buildUpgradeCommandLines(installDir: string | null, proj
92
94
  * that may not be the one on PATH, a project dependency belongs to that
93
95
  * project's package manager, an npx/dlx cache has nothing to upgrade, and a
94
96
  * source checkout is not an install at all.
97
+ *
98
+ * A non-default registry is also disqualifying. `npm install -g` resolves
99
+ * against whatever `npm_config_registry` says, which npm may have read from a
100
+ * cloned repository's .npmrc — so such a registry may still inform the check,
101
+ * but must never be the thing that offers to install a global package.
95
102
  */
96
103
  export declare function canSelfUpgrade(installDir: string | null, projectPath: string): boolean;
97
104
  /**