@fission-ai/openspec 1.5.0 → 1.6.0-beta.1
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.
- package/README.md +13 -1
- package/dist/commands/change.d.ts +0 -1
- package/dist/commands/change.js +6 -40
- package/dist/commands/validate.d.ts +8 -0
- package/dist/commands/validate.js +16 -4
- package/dist/core/archive.js +15 -16
- package/dist/core/command-generation/adapters/claude.d.ts +1 -1
- package/dist/core/command-generation/adapters/claude.js +3 -1
- package/dist/core/command-generation/adapters/index.d.ts +2 -0
- package/dist/core/command-generation/adapters/index.js +2 -0
- package/dist/core/command-generation/adapters/oh-my-pi.d.ts +21 -0
- package/dist/core/command-generation/adapters/oh-my-pi.js +44 -0
- package/dist/core/command-generation/adapters/trae.d.ts +13 -0
- package/dist/core/command-generation/adapters/trae.js +48 -0
- package/dist/core/command-generation/registry.js +4 -0
- package/dist/core/config.js +1 -0
- package/dist/core/init.js +3 -2
- package/dist/core/list.js +18 -9
- package/dist/core/openspec-root.js +18 -15
- package/dist/core/parsers/change-parser.js +2 -1
- package/dist/core/parsers/markdown-parser.d.ts +0 -3
- package/dist/core/parsers/markdown-parser.js +5 -59
- package/dist/core/parsers/requirement-blocks.d.ts +11 -0
- package/dist/core/parsers/requirement-blocks.js +42 -8
- package/dist/core/parsers/requirement-text.d.ts +55 -0
- package/dist/core/parsers/requirement-text.js +136 -0
- package/dist/core/profile-sync-drift.js +1 -0
- package/dist/core/profiles.d.ts +2 -2
- package/dist/core/profiles.js +2 -1
- package/dist/core/schemas/base.schema.js +7 -2
- package/dist/core/shared/allowed-tools.d.ts +12 -0
- package/dist/core/shared/allowed-tools.js +12 -0
- package/dist/core/shared/skill-generation.js +5 -1
- package/dist/core/shared/tool-detection.d.ts +2 -2
- package/dist/core/shared/tool-detection.js +2 -0
- package/dist/core/specs-apply.js +35 -1
- package/dist/core/store/operations.js +20 -0
- package/dist/core/templates/skill-templates.d.ts +1 -0
- package/dist/core/templates/skill-templates.js +1 -0
- package/dist/core/templates/workflows/update-change.d.ts +10 -0
- package/dist/core/templates/workflows/update-change.js +167 -0
- package/dist/core/update.js +4 -4
- package/dist/core/validation/validator.d.ts +7 -0
- package/dist/core/validation/validator.js +101 -38
- package/dist/core/view.js +1 -1
- package/dist/utils/task-progress.d.ts +10 -1
- package/dist/utils/task-progress.js +66 -2
- package/package.json +21 -18
|
@@ -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
|
package/dist/core/update.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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 =
|
|
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({
|
|
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(
|
|
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({
|
|
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(
|
|
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
|
-
|
|
380
|
-
//
|
|
381
|
-
|
|
382
|
-
//
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
|
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(
|
|
415
|
-
const base = `${
|
|
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
|
-
|
|
423
|
-
|
|
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
|
-
|
|
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
|
-
|
|
20
|
-
|
|
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';
|