@fission-ai/openspec 1.5.0 → 1.6.0

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 (48) hide show
  1. package/README.md +13 -1
  2. package/dist/commands/change.d.ts +0 -1
  3. package/dist/commands/change.js +6 -40
  4. package/dist/commands/validate.d.ts +8 -0
  5. package/dist/commands/validate.js +16 -4
  6. package/dist/core/archive.js +15 -16
  7. package/dist/core/command-generation/adapters/claude.d.ts +1 -1
  8. package/dist/core/command-generation/adapters/claude.js +3 -1
  9. package/dist/core/command-generation/adapters/index.d.ts +2 -0
  10. package/dist/core/command-generation/adapters/index.js +2 -0
  11. package/dist/core/command-generation/adapters/oh-my-pi.d.ts +21 -0
  12. package/dist/core/command-generation/adapters/oh-my-pi.js +44 -0
  13. package/dist/core/command-generation/adapters/trae.d.ts +13 -0
  14. package/dist/core/command-generation/adapters/trae.js +48 -0
  15. package/dist/core/command-generation/registry.js +4 -0
  16. package/dist/core/config.js +1 -0
  17. package/dist/core/init.js +3 -2
  18. package/dist/core/list.js +18 -9
  19. package/dist/core/openspec-root.js +18 -15
  20. package/dist/core/parsers/change-parser.js +2 -1
  21. package/dist/core/parsers/markdown-parser.d.ts +0 -3
  22. package/dist/core/parsers/markdown-parser.js +5 -59
  23. package/dist/core/parsers/requirement-blocks.d.ts +11 -0
  24. package/dist/core/parsers/requirement-blocks.js +42 -8
  25. package/dist/core/parsers/requirement-text.d.ts +55 -0
  26. package/dist/core/parsers/requirement-text.js +136 -0
  27. package/dist/core/profile-sync-drift.js +1 -0
  28. package/dist/core/profiles.d.ts +2 -2
  29. package/dist/core/profiles.js +2 -1
  30. package/dist/core/schemas/base.schema.js +7 -2
  31. package/dist/core/shared/allowed-tools.d.ts +12 -0
  32. package/dist/core/shared/allowed-tools.js +12 -0
  33. package/dist/core/shared/skill-generation.js +5 -1
  34. package/dist/core/shared/tool-detection.d.ts +2 -2
  35. package/dist/core/shared/tool-detection.js +2 -0
  36. package/dist/core/specs-apply.js +35 -1
  37. package/dist/core/store/operations.js +20 -0
  38. package/dist/core/templates/skill-templates.d.ts +1 -0
  39. package/dist/core/templates/skill-templates.js +1 -0
  40. package/dist/core/templates/workflows/update-change.d.ts +10 -0
  41. package/dist/core/templates/workflows/update-change.js +167 -0
  42. package/dist/core/update.js +4 -4
  43. package/dist/core/validation/validator.d.ts +7 -0
  44. package/dist/core/validation/validator.js +101 -38
  45. package/dist/core/view.js +1 -1
  46. package/dist/utils/task-progress.d.ts +10 -1
  47. package/dist/utils/task-progress.js +66 -2
  48. package/package.json +1 -1
@@ -4,6 +4,7 @@ import * as os from 'node:os';
4
4
  import * as path from 'node:path';
5
5
  import { promisify } from 'node:util';
6
6
  import { FileSystemUtils } from '../../utils/file-system.js';
7
+ import { classifyOpenSpecDir, storePointerProblem, } from '../project-config.js';
7
8
  import { ANCHORED_OPENSPEC_DIRS, DIRECTORY_ANCHOR_FILE_NAME, OPENSPEC_ROOT_DIR, ensureOpenSpecRoot, inspectOpenSpecRoot, rollbackCreatedPaths, } from '../openspec-root.js';
8
9
  import { STORE_METADATA_DIR_NAME, getStoreMetadataDir, getStoreMetadataPath, getStoreRegistryPath, listStoreRegistryEntries, readStoreRegistryState, readOptionalStoreMetadataState, resolveGitStoreBackendConfig, validateStoreId, writeStoreMetadataState, } from './foundation.js';
9
10
  import { StoreError, makeStoreDiagnostic } from './errors.js';
@@ -53,6 +54,23 @@ function alreadyRegisteredDiagnostic(id) {
53
54
  target: 'store.registry',
54
55
  });
55
56
  }
57
+ function assertNotConfigOnlyPointerRoot(storeRoot) {
58
+ const { hasPlanningShape, pointer } = classifyOpenSpecDir(storeRoot);
59
+ if (hasPlanningShape || pointer.filePath === null)
60
+ return;
61
+ if (pointer.malformed) {
62
+ throw new StoreError(`The store declaration in ${pointer.filePath} is invalid (${storePointerProblem(pointer.malformed)}).`, 'invalid_store_pointer', {
63
+ target: 'store.pointer',
64
+ fix: `Fix or remove the store: line in ${pointer.filePath} before registering this path as a store.`,
65
+ });
66
+ }
67
+ if (pointer.value !== undefined) {
68
+ throw new StoreError(`This repo's planning is externalized to store '${pointer.value}' (${pointer.filePath}); it is not itself a store root.`, 'store_root_pointer_declared', {
69
+ target: 'store.pointer',
70
+ fix: 'Register the checkout for the declared store, or remove the store: line first to convert this repo into a local store root.',
71
+ });
72
+ }
73
+ }
56
74
  function createdPath(relativePath, absolutePath, kind) {
57
75
  return {
58
76
  relativePath,
@@ -233,6 +251,7 @@ async function prepareSetupPlan(input) {
233
251
  let metadata = null;
234
252
  let backend;
235
253
  if (kind === 'directory') {
254
+ assertNotConfigOnlyPointerRoot(storeRoot);
236
255
  metadata = await readStoreMetadataForOperation(storeRoot);
237
256
  if (metadata) {
238
257
  if (metadata.id !== id) {
@@ -442,6 +461,7 @@ export async function registerExistingStore(input) {
442
461
  fix: 'Pass an existing store directory.',
443
462
  });
444
463
  }
464
+ assertNotConfigOnlyPointerRoot(storeRoot);
445
465
  const openspecRoot = await inspectOpenSpecRoot(storeRoot);
446
466
  if (!openspecRoot.healthy) {
447
467
  const problems = openspecRoot.diagnostics.map((diagnostic) => diagnostic.message).join(' ') ||
@@ -8,6 +8,7 @@ export { getExploreSkillTemplate, getOpsxExploreCommandTemplate } from './workfl
8
8
  export { getNewChangeSkillTemplate, getOpsxNewCommandTemplate } from './workflows/new-change.js';
9
9
  export { getContinueChangeSkillTemplate, getOpsxContinueCommandTemplate } from './workflows/continue-change.js';
10
10
  export { getApplyChangeSkillTemplate, getOpsxApplyCommandTemplate } from './workflows/apply-change.js';
11
+ export { getUpdateChangeSkillTemplate, getOpsxUpdateCommandTemplate } from './workflows/update-change.js';
11
12
  export { getFfChangeSkillTemplate, getOpsxFfCommandTemplate } from './workflows/ff-change.js';
12
13
  export { getSyncSpecsSkillTemplate, getOpsxSyncCommandTemplate } from './workflows/sync-specs.js';
13
14
  export { getArchiveChangeSkillTemplate, getOpsxArchiveCommandTemplate } from './workflows/archive-change.js';
@@ -7,6 +7,7 @@ export { getExploreSkillTemplate, getOpsxExploreCommandTemplate } from './workfl
7
7
  export { getNewChangeSkillTemplate, getOpsxNewCommandTemplate } from './workflows/new-change.js';
8
8
  export { getContinueChangeSkillTemplate, getOpsxContinueCommandTemplate } from './workflows/continue-change.js';
9
9
  export { getApplyChangeSkillTemplate, getOpsxApplyCommandTemplate } from './workflows/apply-change.js';
10
+ export { getUpdateChangeSkillTemplate, getOpsxUpdateCommandTemplate } from './workflows/update-change.js';
10
11
  export { getFfChangeSkillTemplate, getOpsxFfCommandTemplate } from './workflows/ff-change.js';
11
12
  export { getSyncSpecsSkillTemplate, getOpsxSyncCommandTemplate } from './workflows/sync-specs.js';
12
13
  export { getArchiveChangeSkillTemplate, getOpsxArchiveCommandTemplate } from './workflows/archive-change.js';
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Skill Template Workflow Modules
3
+ *
4
+ * This file is generated by splitting the legacy monolithic
5
+ * templates file into workflow-focused modules.
6
+ */
7
+ import type { SkillTemplate, CommandTemplate } from '../types.js';
8
+ export declare function getUpdateChangeSkillTemplate(): SkillTemplate;
9
+ export declare function getOpsxUpdateCommandTemplate(): CommandTemplate;
10
+ //# sourceMappingURL=update-change.d.ts.map
@@ -0,0 +1,167 @@
1
+ import { STORE_SELECTION_GUIDANCE } from './store-selection.js';
2
+ export function getUpdateChangeSkillTemplate() {
3
+ return {
4
+ name: 'openspec-update-change',
5
+ description: "Update an OpenSpec change by revising its existing planning artifacts and keeping them coherent with one another. Use when the user wants to revise a change's plan, fold new decisions into it, or reconcile its artifacts after an edit. Never edits code.",
6
+ instructions: `Revise a change's existing planning artifacts and keep them coherent. Never edit code.
7
+
8
+ ${STORE_SELECTION_GUIDANCE}
9
+
10
+ **Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
11
+
12
+ **Steps**
13
+
14
+ 1. **If no change name provided, prompt for selection**
15
+
16
+ Run \`openspec list --json\` to get available changes sorted by most recently modified. Then use the **AskUserQuestion tool** to let the user select which change to update.
17
+
18
+ Present the top 3-4 most recently modified changes as options, showing:
19
+ - Change name
20
+ - Schema (from \`schema\` field if present, otherwise "spec-driven")
21
+ - Status (e.g., "0/5 tasks", "complete", "no tasks")
22
+ - How recently it was modified (from \`lastModified\` field)
23
+
24
+ Mark the most recently modified change as "(Recommended)" since it's likely what the user wants to update.
25
+
26
+ **IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
27
+
28
+ 2. **Get the change's artifacts**
29
+ \`\`\`bash
30
+ openspec status --change "<name>" --json
31
+ \`\`\`
32
+ Parse the JSON to understand current state. The response includes:
33
+ - \`schemaName\`: The workflow schema being used (e.g., "spec-driven")
34
+ - \`artifacts\`: Array of artifacts with their status ("done", "ready", "blocked")
35
+ - \`isComplete\`: Boolean indicating if all artifacts are complete
36
+ - \`planningHome\`, \`changeRoot\`, \`artifactPaths\`, and \`actionContext\`: path and scope context. Use these instead of assuming repo-local paths.
37
+
38
+ The artifact ids and paths come from the active schema - do NOT assume them, and do NOT branch on hardcoded artifact names. Custom schemas must work unchanged.
39
+
40
+ The files to edit are \`artifactPaths.<id>.existingOutputPaths\` - the concrete files that exist on disk, already glob-expanded for glob artifacts (e.g. \`specs/**/*.md\`). Do NOT write to \`resolvedOutputPath\`: for a glob artifact it is still the glob pattern, not a real file.
41
+
42
+ 3. **Understand the request**
43
+ - If the user asked for a specific revision ("the design now uses X"), that is the starting edit.
44
+ - If they only said "update" / "make this coherent", treat it as a coherence review: read the existing artifacts and check them against each other for contradictions, gaps, and duplication.
45
+
46
+ 4. **Read and reconcile**
47
+ - Read the artifact(s) the request touches and the change's other existing artifacts.
48
+ - Apply the requested edit. Then check every other existing artifact against it - in ANY direction: an edit to a later artifact may require revising an earlier one, not only the other way around. Build order is a useful reading order, not a constraint on which artifacts may be revised.
49
+ - Note everything that is now inconsistent, missing, or contradictory.
50
+ - Revise only files that already exist (\`existingOutputPaths\`). Do NOT create artifacts that don't exist yet, and do NOT invent new files under a glob artifact - note them and point the user to \`/opsx:continue\` to create them.
51
+ - If the change is already coherent, say so and make no edits.
52
+
53
+ 5. **Confirm and apply, one artifact at a time**
54
+ - Show each proposed revision and why. Write only after the user confirms.
55
+ - If the user rejects a revision, do not write it - leave that artifact unchanged.
56
+ - When a substantial rewrite is needed, get that artifact's rules and template first:
57
+ \`\`\`bash
58
+ openspec instructions <artifact-id> --change "<name>" --json
59
+ \`\`\`
60
+
61
+ 6. **Point to the next step (guidance only - NEVER act on it)**
62
+ - Artifacts still missing -> suggest \`/opsx:continue\` to create them.
63
+ - Change already implemented (tasks checked off / already applied) -> the code may no longer match the revised plan; suggest \`/opsx:apply\` to carry the delta into code.
64
+ - Everything done and implemented -> suggest \`/opsx:archive\`.
65
+
66
+ **Output**
67
+
68
+ After each invocation, show:
69
+ - Which artifacts were revised (and which proposed revisions were rejected)
70
+ - Anything deferred to \`/opsx:continue\` (not-yet-created artifacts or files)
71
+ - Where the change stands and the recommended next command
72
+
73
+ **Guardrails**
74
+ - Planning artifacts only - NEVER edit implementation code. If the revised plan implies code changes, stop and point to \`/opsx:apply\`.
75
+ - Use the artifact ids and paths reported by \`openspec status\`; never branch on hardcoded artifact names.
76
+ - Edit only the concrete files in \`existingOutputPaths\`; never write to a glob \`resolvedOutputPath\`.
77
+ - Do not advance the build frontier: no new artifacts, no new files under glob artifacts - that is \`/opsx:continue\`'s job.
78
+ - Confirm every edit with the user before writing.
79
+ - If the request changes the change's *intent* rather than refining it, recommend starting fresh with \`/opsx:new\` (the "Update vs. Start Fresh" heuristic).`,
80
+ license: 'MIT',
81
+ compatibility: 'Requires openspec CLI.',
82
+ metadata: { author: 'openspec', version: '1.0' },
83
+ };
84
+ }
85
+ export function getOpsxUpdateCommandTemplate() {
86
+ return {
87
+ name: 'OPSX: Update',
88
+ description: "Update a change - revise existing planning artifacts and keep them coherent (Experimental)",
89
+ category: 'Workflow',
90
+ tags: ['workflow', 'artifacts', 'experimental'],
91
+ content: `Revise a change's existing planning artifacts and keep them coherent. Never edit code.
92
+
93
+ ${STORE_SELECTION_GUIDANCE}
94
+
95
+ **Input**: Optionally specify a change name after \`/opsx:update\` (e.g., \`/opsx:update add-auth\`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
96
+
97
+ **Steps**
98
+
99
+ 1. **If no change name provided, prompt for selection**
100
+
101
+ Run \`openspec list --json\` to get available changes sorted by most recently modified. Then use the **AskUserQuestion tool** to let the user select which change to update.
102
+
103
+ Present the top 3-4 most recently modified changes as options, showing:
104
+ - Change name
105
+ - Schema (from \`schema\` field if present, otherwise "spec-driven")
106
+ - Status (e.g., "0/5 tasks", "complete", "no tasks")
107
+ - How recently it was modified (from \`lastModified\` field)
108
+
109
+ Mark the most recently modified change as "(Recommended)" since it's likely what the user wants to update.
110
+
111
+ **IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
112
+
113
+ 2. **Get the change's artifacts**
114
+ \`\`\`bash
115
+ openspec status --change "<name>" --json
116
+ \`\`\`
117
+ Parse the JSON to understand current state. The response includes:
118
+ - \`schemaName\`: The workflow schema being used (e.g., "spec-driven")
119
+ - \`artifacts\`: Array of artifacts with their status ("done", "ready", "blocked")
120
+ - \`isComplete\`: Boolean indicating if all artifacts are complete
121
+ - \`planningHome\`, \`changeRoot\`, \`artifactPaths\`, and \`actionContext\`: path and scope context. Use these instead of assuming repo-local paths.
122
+
123
+ The artifact ids and paths come from the active schema - do NOT assume them, and do NOT branch on hardcoded artifact names. Custom schemas must work unchanged.
124
+
125
+ The files to edit are \`artifactPaths.<id>.existingOutputPaths\` - the concrete files that exist on disk, already glob-expanded for glob artifacts (e.g. \`specs/**/*.md\`). Do NOT write to \`resolvedOutputPath\`: for a glob artifact it is still the glob pattern, not a real file.
126
+
127
+ 3. **Understand the request**
128
+ - If the user asked for a specific revision ("the design now uses X"), that is the starting edit.
129
+ - If they only said "update" / "make this coherent", treat it as a coherence review: read the existing artifacts and check them against each other for contradictions, gaps, and duplication.
130
+
131
+ 4. **Read and reconcile**
132
+ - Read the artifact(s) the request touches and the change's other existing artifacts.
133
+ - Apply the requested edit. Then check every other existing artifact against it - in ANY direction: an edit to a later artifact may require revising an earlier one, not only the other way around. Build order is a useful reading order, not a constraint on which artifacts may be revised.
134
+ - Note everything that is now inconsistent, missing, or contradictory.
135
+ - Revise only files that already exist (\`existingOutputPaths\`). Do NOT create artifacts that don't exist yet, and do NOT invent new files under a glob artifact - note them and point the user to \`/opsx:continue\` to create them.
136
+ - If the change is already coherent, say so and make no edits.
137
+
138
+ 5. **Confirm and apply, one artifact at a time**
139
+ - Show each proposed revision and why. Write only after the user confirms.
140
+ - If the user rejects a revision, do not write it - leave that artifact unchanged.
141
+ - When a substantial rewrite is needed, get that artifact's rules and template first:
142
+ \`\`\`bash
143
+ openspec instructions <artifact-id> --change "<name>" --json
144
+ \`\`\`
145
+
146
+ 6. **Point to the next step (guidance only - NEVER act on it)**
147
+ - Artifacts still missing -> suggest \`/opsx:continue\` to create them.
148
+ - Change already implemented (tasks checked off / already applied) -> the code may no longer match the revised plan; suggest \`/opsx:apply\` to carry the delta into code.
149
+ - Everything done and implemented -> suggest \`/opsx:archive\`.
150
+
151
+ **Output**
152
+
153
+ After each invocation, show:
154
+ - Which artifacts were revised (and which proposed revisions were rejected)
155
+ - Anything deferred to \`/opsx:continue\` (not-yet-created artifacts or files)
156
+ - Where the change stands and the recommended next command
157
+
158
+ **Guardrails**
159
+ - Planning artifacts only - NEVER edit implementation code. If the revised plan implies code changes, stop and point to \`/opsx:apply\`.
160
+ - Use the artifact ids and paths reported by \`openspec status\`; never branch on hardcoded artifact names.
161
+ - Edit only the concrete files in \`existingOutputPaths\`; never write to a glob \`resolvedOutputPath\`.
162
+ - Do not advance the build frontier: no new artifacts, no new files under glob artifacts - that is \`/opsx:continue\`'s job.
163
+ - Confirm every edit with the user before writing.
164
+ - If the request changes the change's *intent* rather than refining it, recommend starting fresh with \`/opsx:new\` (the "Update vs. Start Fresh" heuristic).`
165
+ };
166
+ }
167
+ //# sourceMappingURL=update-change.js.map
@@ -130,8 +130,8 @@ export class UpdateCommand {
130
130
  for (const { template, dirName } of skillTemplates) {
131
131
  const skillDir = path.join(skillsDir, dirName);
132
132
  const skillFile = path.join(skillDir, 'SKILL.md');
133
- // Use hyphen-based command references for OpenCode
134
- const transformer = (tool.value === 'opencode' || tool.value === 'pi') ? transformToHyphenCommands : undefined;
133
+ // Use hyphen-based command references for tools where filename === command name (oh-my-pi, opencode, pi)
134
+ const transformer = (tool.value === 'opencode' || tool.value === 'pi' || tool.value === 'oh-my-pi') ? transformToHyphenCommands : undefined;
135
135
  const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer);
136
136
  await FileSystemUtils.writeFile(skillFile, skillContent);
137
137
  }
@@ -523,8 +523,8 @@ export class UpdateCommand {
523
523
  for (const { template, dirName } of skillTemplates) {
524
524
  const skillDir = path.join(skillsDir, dirName);
525
525
  const skillFile = path.join(skillDir, 'SKILL.md');
526
- // Use hyphen-based command references for OpenCode
527
- const transformer = (tool.value === 'opencode' || tool.value === 'pi') ? transformToHyphenCommands : undefined;
526
+ // Use hyphen-based command references for tools where filename === command name (oh-my-pi, opencode, pi)
527
+ const transformer = (tool.value === 'opencode' || tool.value === 'pi' || tool.value === 'oh-my-pi') ? transformToHyphenCommands : undefined;
528
528
  const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer);
529
529
  await FileSystemUtils.writeFile(skillFile, skillContent);
530
530
  }
@@ -18,6 +18,13 @@ export declare class Validator {
18
18
  * - No duplicates within sections; no cross-section conflicts per spec
19
19
  */
20
20
  validateChangeDeltaSpecs(changeDir: string): Promise<ValidationReport>;
21
+ /**
22
+ * Recursively collect every delta `spec.md` under a change's specs directory,
23
+ * so both the one-level (specs/<capability>/spec.md) and nested multi-area
24
+ * (specs/<area>/<capability>/spec.md) layouts are discovered (#1182b).
25
+ * Returns absolute paths, sorted for deterministic issue ordering.
26
+ */
27
+ private findDeltaSpecFiles;
21
28
  private convertZodErrors;
22
29
  private applySpecRules;
23
30
  private applyChangeRules;
@@ -4,7 +4,8 @@ import { SpecSchema, ChangeSchema } from '../schemas/index.js';
4
4
  import { MarkdownParser } from '../parsers/markdown-parser.js';
5
5
  import { ChangeParser } from '../parsers/change-parser.js';
6
6
  import { MIN_PURPOSE_LENGTH, MAX_REQUIREMENT_TEXT_LENGTH, VALIDATION_MESSAGES } from './constants.js';
7
- import { parseDeltaSpec, normalizeRequirementName } from '../parsers/requirement-blocks.js';
7
+ import { parseDeltaSpec, normalizeRequirementName, extractRequirementsSection } from '../parsers/requirement-blocks.js';
8
+ import { extractRequirementBody as extractRequirementBodyShared, containsShallOrMust as containsShallOrMustShared, countScenarios as countScenariosShared, } from '../parsers/requirement-text.js';
8
9
  import { findMainSpecStructureIssues } from '../parsers/spec-structure.js';
9
10
  import { FileSystemUtils } from '../../utils/file-system.js';
10
11
  export class Validator {
@@ -98,12 +99,12 @@ export class Validator {
98
99
  const missingHeaderSpecs = [];
99
100
  const emptySectionSpecs = [];
100
101
  try {
101
- const entries = await fs.readdir(specsDir, { withFileTypes: true });
102
- for (const entry of entries) {
103
- if (!entry.isDirectory())
104
- continue;
105
- const specName = entry.name;
106
- const specFile = path.join(specsDir, specName, 'spec.md');
102
+ // Discover delta specs at any depth so the nested multi-area layout
103
+ // (specs/<area>/<capability>/spec.md) is validated, not just the
104
+ // one-level specs/<capability>/spec.md layout (#1182b). The spec-driven
105
+ // specs glob is specs/**/*.md; delta files are always named spec.md.
106
+ const specFiles = await this.findDeltaSpecFiles(specsDir);
107
+ for (const specFile of specFiles) {
107
108
  let content;
108
109
  try {
109
110
  content = await fs.readFile(specFile, 'utf-8');
@@ -112,7 +113,24 @@ export class Validator {
112
113
  continue;
113
114
  }
114
115
  const plan = parseDeltaSpec(content);
115
- const entryPath = `${specName}/spec.md`;
116
+ const entryPath = FileSystemUtils.toPosixPath(path.relative(specsDir, specFile));
117
+ // Surface (as INFO, never a failure) the non-canonical level-3 headers
118
+ // the delta reader skipped while parsing ADDED/MODIFIED sections —
119
+ // without this note a stray divider like "### Documentation
120
+ // Requirements" would pass validate <change> while failing
121
+ // archive/validate <spec>. The list comes from the parse itself, so it
122
+ // reflects exactly what the reader skipped.
123
+ for (const stray of plan.skippedHeaders) {
124
+ const nameless = /^requirement:?$/i.test(stray.header);
125
+ issues.push({
126
+ level: 'INFO',
127
+ path: entryPath,
128
+ line: stray.line,
129
+ message: nameless
130
+ ? `Header "### ${stray.header}" in ${stray.section} is missing a requirement name and is ignored by validation. Add a name, e.g. "### Requirement: <name>".`
131
+ : `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.`,
132
+ });
133
+ }
116
134
  const sectionNames = [];
117
135
  if (plan.sectionPresence.added)
118
136
  sectionNames.push('## ADDED Requirements');
@@ -147,10 +165,16 @@ export class Validator {
147
165
  }
148
166
  const requirementText = this.extractRequirementText(block.raw);
149
167
  if (!requirementText) {
150
- issues.push({ level: 'ERROR', path: entryPath, message: `ADDED "${block.name}" is missing requirement text` });
168
+ issues.push({
169
+ level: 'ERROR',
170
+ path: entryPath,
171
+ message: this.containsShallOrMust(block.name)
172
+ ? this.buildMissingShallOrMustMessage(`ADDED "${block.name}"`, block.name)
173
+ : `ADDED "${block.name}" is missing requirement text`,
174
+ });
151
175
  }
152
176
  else if (!this.containsShallOrMust(requirementText)) {
153
- issues.push({ level: 'ERROR', path: entryPath, message: this.buildMissingShallOrMustMessage('ADDED', block.name) });
177
+ issues.push({ level: 'ERROR', path: entryPath, message: this.buildMissingShallOrMustMessage(`ADDED "${block.name}"`, block.name) });
154
178
  }
155
179
  const scenarioCount = this.countScenarios(block.raw);
156
180
  if (scenarioCount < 1) {
@@ -169,10 +193,16 @@ export class Validator {
169
193
  }
170
194
  const requirementText = this.extractRequirementText(block.raw);
171
195
  if (!requirementText) {
172
- issues.push({ level: 'ERROR', path: entryPath, message: `MODIFIED "${block.name}" is missing requirement text` });
196
+ issues.push({
197
+ level: 'ERROR',
198
+ path: entryPath,
199
+ message: this.containsShallOrMust(block.name)
200
+ ? this.buildMissingShallOrMustMessage(`MODIFIED "${block.name}"`, block.name)
201
+ : `MODIFIED "${block.name}" is missing requirement text`,
202
+ });
173
203
  }
174
204
  else if (!this.containsShallOrMust(requirementText)) {
175
- issues.push({ level: 'ERROR', path: entryPath, message: this.buildMissingShallOrMustMessage('MODIFIED', block.name) });
205
+ issues.push({ level: 'ERROR', path: entryPath, message: this.buildMissingShallOrMustMessage(`MODIFIED "${block.name}"`, block.name) });
176
206
  }
177
207
  const scenarioCount = this.countScenarios(block.raw);
178
208
  if (scenarioCount < 1) {
@@ -256,6 +286,35 @@ export class Validator {
256
286
  }
257
287
  return this.createReport(issues);
258
288
  }
289
+ /**
290
+ * Recursively collect every delta `spec.md` under a change's specs directory,
291
+ * so both the one-level (specs/<capability>/spec.md) and nested multi-area
292
+ * (specs/<area>/<capability>/spec.md) layouts are discovered (#1182b).
293
+ * Returns absolute paths, sorted for deterministic issue ordering.
294
+ */
295
+ async findDeltaSpecFiles(specsDir) {
296
+ const results = [];
297
+ const walk = async (dir) => {
298
+ let entries;
299
+ try {
300
+ entries = await fs.readdir(dir, { withFileTypes: true });
301
+ }
302
+ catch {
303
+ return;
304
+ }
305
+ for (const entry of entries) {
306
+ const full = path.join(dir, entry.name);
307
+ if (entry.isDirectory()) {
308
+ await walk(full);
309
+ }
310
+ else if (entry.isFile() && entry.name === 'spec.md') {
311
+ results.push(full);
312
+ }
313
+ }
314
+ };
315
+ await walk(specsDir);
316
+ return results.sort();
317
+ }
259
318
  convertZodErrors(error) {
260
319
  return error.issues.map(err => {
261
320
  let message = err.message;
@@ -302,6 +361,23 @@ export class Validator {
302
361
  });
303
362
  }
304
363
  });
364
+ // SHALL/MUST body-keyword enforcement for main specs (#1156). The main-spec
365
+ // parser collapses the requirement header into `text`, so we recover the
366
+ // header+body pairs here (the same source the delta path trusts) and reuse
367
+ // the delta detection: a body that omits the keyword errors, with the
368
+ // targeted "move it to the body line" hint when the keyword is in the header
369
+ // only and the generic message otherwise. Emitted exactly once per
370
+ // requirement (the Zod refine that used to emit a generic error is removed).
371
+ extractRequirementsSection(content).bodyBlocks.forEach((block, index) => {
372
+ const requirementText = this.extractRequirementText(block.raw);
373
+ if (!requirementText || !this.containsShallOrMust(requirementText)) {
374
+ issues.push({
375
+ level: 'ERROR',
376
+ path: `requirements[${index}]`,
377
+ message: this.buildMissingShallOrMustMessage(`Requirement "${block.name}"`, block.name),
378
+ });
379
+ }
380
+ });
305
381
  return issues;
306
382
  }
307
383
  applyChangeRules(change, content) {
@@ -376,30 +452,16 @@ export class Validator {
376
452
  return report.valid;
377
453
  }
378
454
  extractRequirementText(blockRaw) {
379
- const lines = blockRaw.split('\n');
380
- // Skip header line (index 0)
381
- let i = 1;
382
- // Find the first substantial text line, skipping metadata and blank lines
383
- for (; i < lines.length; i++) {
384
- const line = lines[i];
385
- // Stop at scenario headers
386
- if (/^####\s+/.test(line))
387
- break;
388
- const trimmed = line.trim();
389
- // Skip blank lines
390
- if (trimmed.length === 0)
391
- continue;
392
- // Skip metadata lines (lines starting with ** like **ID**, **Priority**, etc.)
393
- if (/^\*\*[^*]+\*\*:/.test(trimmed))
394
- continue;
395
- // Found first non-metadata, non-blank line - this is the requirement text
396
- return trimmed;
397
- }
398
- // No requirement text found
399
- return undefined;
455
+ // Delegate to the shared, fence-/metadata-/multi-line-aware body reader.
456
+ // Validation intentionally does not use the parser/display header-title
457
+ // fallback for canonical `### Requirement:` blocks: #1280 requires a
458
+ // SHALL/MUST that appears only in the header to receive the body-keyword
459
+ // hint. Line 0 is the `### Requirement: ...` header.
460
+ const [, ...bodyLines] = blockRaw.split('\n');
461
+ return extractRequirementBodyShared(bodyLines) || undefined;
400
462
  }
401
463
  containsShallOrMust(text) {
402
- return /\b(SHALL|MUST)\b/.test(text);
464
+ return containsShallOrMustShared(text);
403
465
  }
404
466
  /**
405
467
  * Build an error message for a requirement block whose body lacks SHALL/MUST.
@@ -411,16 +473,17 @@ export class Validator {
411
473
  * on the requirement body line (the line right after the header), so we point
412
474
  * the author at that exact fix when the keyword is found in the header only.
413
475
  */
414
- buildMissingShallOrMustMessage(action, blockName) {
415
- const base = `${action} "${blockName}" must contain SHALL or MUST`;
476
+ buildMissingShallOrMustMessage(prefix, blockName) {
477
+ const base = `${prefix} must contain SHALL or MUST`;
416
478
  if (this.containsShallOrMust(blockName)) {
417
479
  return `${base} in the requirement body, not only in the header. Move the SHALL/MUST statement to the line immediately after the "### Requirement: ..." header.`;
418
480
  }
419
481
  return base;
420
482
  }
421
483
  countScenarios(blockRaw) {
422
- const matches = blockRaw.match(/^####\s+/gm);
423
- return matches ? matches.length : 0;
484
+ // Fence-aware count via the shared reader: a `#### Scenario:` inside a fenced
485
+ // example is not a real scenario. Drop the header line (index 0).
486
+ return countScenariosShared(blockRaw.split('\n').slice(1));
424
487
  }
425
488
  formatSectionList(sections) {
426
489
  if (sections.length === 0)
package/dist/core/view.js CHANGED
@@ -70,7 +70,7 @@ export class ViewCommand {
70
70
  const entries = fs.readdirSync(changesDir, { withFileTypes: true });
71
71
  for (const entry of entries) {
72
72
  if (entry.isDirectory() && entry.name !== 'archive') {
73
- const progress = await getTaskProgressForChange(changesDir, entry.name);
73
+ const progress = await getTaskProgressForChange(changesDir, entry.name, path.dirname(openspecDir));
74
74
  if (progress.total === 0) {
75
75
  // No tasks defined yet - still in planning/draft phase
76
76
  draft.push({ name: entry.name });
@@ -3,6 +3,15 @@ export interface TaskProgress {
3
3
  completed: number;
4
4
  }
5
5
  export declare function countTasksFromContent(content: string): TaskProgress;
6
- export declare function getTaskProgressForChange(changesDir: string, changeName: string): Promise<TaskProgress>;
6
+ /**
7
+ * Computes a change's task progress by resolving its tracked-tasks artifact and
8
+ * counting checkboxes across every file matched by that artifact's `generates`
9
+ * glob — the same file-resolution `openspec status` uses to detect the tasks
10
+ * artifact (`resolveArtifactOutputs`) — so progress is no longer blind to nested
11
+ * `tasks.md` files (#1202). Falls back to a single top-level `tasks.md` (exactly
12
+ * as before) when the schema is unresolvable, no tracked-tasks artifact is found,
13
+ * or the glob matches no file. Never throws.
14
+ */
15
+ export declare function getTaskProgressForChange(changesDir: string, changeName: string, projectRoot: string): Promise<TaskProgress>;
7
16
  export declare function formatTaskStatus(progress: TaskProgress): string;
8
17
  //# sourceMappingURL=task-progress.d.ts.map
@@ -1,5 +1,7 @@
1
1
  import { promises as fs } from 'fs';
2
2
  import path from 'path';
3
+ import { resolveArtifactOutputs, resolveSchema } from '../core/artifact-graph/index.js';
4
+ import { resolveSchemaForChange } from './change-metadata.js';
3
5
  const TASK_PATTERN = /^[-*]\s+\[[\sx]\]/i;
4
6
  const COMPLETED_TASK_PATTERN = /^[-*]\s+\[x\]/i;
5
7
  export function countTasksFromContent(content) {
@@ -16,8 +18,37 @@ export function countTasksFromContent(content) {
16
18
  }
17
19
  return { total, completed };
18
20
  }
19
- export async function getTaskProgressForChange(changesDir, changeName) {
20
- const tasksPath = path.join(changesDir, changeName, 'tasks.md');
21
+ /**
22
+ * Identifies the change's tracked-tasks artifact: the artifact whose `generates`
23
+ * equals the schema's `apply.tracks` value, falling back to the artifact with id
24
+ * `tasks` when no `apply` block declares what it tracks. (`apply.tracks` is a
25
+ * filename that *selects* the artifact; the glob is that artifact's `generates`.)
26
+ */
27
+ function findTrackedTasksArtifact(schema) {
28
+ const tracks = schema.apply?.tracks;
29
+ if (tracks != null) {
30
+ return schema.artifacts.find((a) => a.generates === tracks);
31
+ }
32
+ return schema.artifacts.find((a) => a.id === 'tasks');
33
+ }
34
+ /**
35
+ * Resolves the tracked-tasks artifact's output glob for a change, or undefined
36
+ * when the schema cannot be resolved or no tracked-tasks artifact exists.
37
+ * `resolveSchema` throws on an unresolvable/misnamed schema; we swallow that so
38
+ * the caller falls back to a single top-level `tasks.md` and never crashes.
39
+ */
40
+ function resolveTrackedTasksGlob(changeDir, projectRoot) {
41
+ try {
42
+ const schemaName = resolveSchemaForChange(changeDir, undefined, projectRoot);
43
+ const schema = resolveSchema(schemaName, projectRoot);
44
+ return findTrackedTasksArtifact(schema)?.generates;
45
+ }
46
+ catch {
47
+ return undefined;
48
+ }
49
+ }
50
+ async function countSingleTopLevelTasksFile(changeDir) {
51
+ const tasksPath = path.join(changeDir, 'tasks.md');
21
52
  try {
22
53
  const content = await fs.readFile(tasksPath, 'utf-8');
23
54
  return countTasksFromContent(content);
@@ -26,6 +57,39 @@ export async function getTaskProgressForChange(changesDir, changeName) {
26
57
  return { total: 0, completed: 0 };
27
58
  }
28
59
  }
60
+ /**
61
+ * Computes a change's task progress by resolving its tracked-tasks artifact and
62
+ * counting checkboxes across every file matched by that artifact's `generates`
63
+ * glob — the same file-resolution `openspec status` uses to detect the tasks
64
+ * artifact (`resolveArtifactOutputs`) — so progress is no longer blind to nested
65
+ * `tasks.md` files (#1202). Falls back to a single top-level `tasks.md` (exactly
66
+ * as before) when the schema is unresolvable, no tracked-tasks artifact is found,
67
+ * or the glob matches no file. Never throws.
68
+ */
69
+ export async function getTaskProgressForChange(changesDir, changeName, projectRoot) {
70
+ const changeDir = path.join(changesDir, changeName);
71
+ const generates = resolveTrackedTasksGlob(changeDir, projectRoot);
72
+ if (generates) {
73
+ const files = resolveArtifactOutputs(changeDir, generates);
74
+ if (files.length > 0) {
75
+ let total = 0;
76
+ let completed = 0;
77
+ for (const file of files) {
78
+ try {
79
+ const content = await fs.readFile(file, 'utf-8');
80
+ const progress = countTasksFromContent(content);
81
+ total += progress.total;
82
+ completed += progress.completed;
83
+ }
84
+ catch {
85
+ // Swallow files that vanish between glob and read, as before.
86
+ }
87
+ }
88
+ return { total, completed };
89
+ }
90
+ }
91
+ return countSingleTopLevelTasksFile(changeDir);
92
+ }
29
93
  export function formatTaskStatus(progress) {
30
94
  if (progress.total === 0)
31
95
  return 'No tasks';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fission-ai/openspec",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "AI-native system for spec-driven development",
5
5
  "keywords": [
6
6
  "openspec",