@fission-ai/openspec 1.13.0 → 1.13.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 +1 -1
- package/dist/commands/change.js +12 -1
- package/dist/commands/config.d.ts +9 -0
- package/dist/commands/config.js +161 -22
- package/dist/commands/feedback.js +6 -4
- package/dist/commands/schema.js +30 -9
- package/dist/commands/store.js +5 -2
- package/dist/commands/validate.d.ts +7 -0
- package/dist/commands/validate.js +63 -1
- package/dist/commands/workflow/instructions.js +33 -14
- package/dist/commands/workflow/new-change.js +20 -0
- package/dist/commands/workflow/shared.js +8 -0
- package/dist/commands/workflow/status.d.ts +5 -1
- package/dist/commands/workflow/status.js +42 -5
- package/dist/core/archive.js +17 -1
- package/dist/core/artifact-graph/schema.d.ts +14 -0
- package/dist/core/artifact-graph/schema.js +52 -0
- package/dist/core/artifact-graph/types.js +12 -1
- package/dist/core/change-status-policy.d.ts +14 -0
- package/dist/core/change-status-policy.js +14 -6
- package/dist/core/command-generation/generator.js +2 -0
- package/dist/core/completion-tip.js +7 -7
- package/dist/core/completions/installers/bash-installer.js +15 -7
- package/dist/core/completions/installers/shell-quote.d.ts +11 -0
- package/dist/core/completions/installers/shell-quote.js +13 -0
- package/dist/core/completions/installers/zsh-installer.js +3 -2
- package/dist/core/config-schema.d.ts +1 -1
- package/dist/core/config.d.ts +16 -0
- package/dist/core/config.js +27 -1
- package/dist/core/global-config.d.ts +30 -2
- package/dist/core/global-config.js +57 -3
- package/dist/core/init.js +13 -3
- package/dist/core/legacy-cleanup.d.ts +4 -0
- package/dist/core/legacy-cleanup.js +180 -12
- package/dist/core/list.js +55 -11
- package/dist/core/migration.js +6 -1
- package/dist/core/parsers/change-parser.d.ts +12 -1
- package/dist/core/parsers/change-parser.js +85 -87
- package/dist/core/parsers/markdown-parser.js +4 -3
- package/dist/core/parsers/requirement-blocks.d.ts +44 -3
- package/dist/core/parsers/requirement-blocks.js +90 -15
- package/dist/core/parsers/requirement-text.d.ts +13 -2
- package/dist/core/parsers/requirement-text.js +34 -7
- package/dist/core/parsers/spec-structure.js +4 -1
- package/dist/core/project-config.d.ts +9 -1
- package/dist/core/project-config.js +11 -2
- package/dist/core/references.d.ts +20 -0
- package/dist/core/references.js +56 -0
- package/dist/core/shared/skill-content-equivalence.js +5 -1
- package/dist/core/shared/skill-generation.js +36 -8
- package/dist/core/shared/tool-detection.js +9 -3
- package/dist/core/specs-apply.js +82 -11
- package/dist/core/store/git.d.ts +33 -0
- package/dist/core/store/git.js +60 -6
- package/dist/core/store/operations.d.ts +1 -1
- package/dist/core/store/operations.js +32 -10
- package/dist/core/store/registry.d.ts +2 -1
- package/dist/core/store/registry.js +5 -1
- package/dist/core/templates/optional-workflow.d.ts +69 -0
- package/dist/core/templates/optional-workflow.js +140 -0
- package/dist/core/templates/workflows/apply-change.js +18 -3
- package/dist/core/templates/workflows/archive-change.js +50 -15
- package/dist/core/templates/workflows/bulk-archive-change.js +63 -13
- package/dist/core/templates/workflows/continue-change.js +14 -2
- package/dist/core/templates/workflows/explore.js +32 -14
- package/dist/core/templates/workflows/ff-change.js +19 -3
- package/dist/core/templates/workflows/new-change.js +15 -3
- package/dist/core/templates/workflows/onboard.js +67 -37
- package/dist/core/templates/workflows/project-root.d.ts +29 -0
- package/dist/core/templates/workflows/project-root.js +38 -0
- package/dist/core/templates/workflows/propose.js +22 -5
- package/dist/core/templates/workflows/sync-specs.js +40 -1
- package/dist/core/templates/workflows/update-change.js +52 -25
- package/dist/core/templates/workflows/verify-change.js +12 -3
- package/dist/core/update.d.ts +12 -0
- package/dist/core/update.js +58 -3
- package/dist/core/validation/task-checkboxes.d.ts +34 -0
- package/dist/core/validation/task-checkboxes.js +181 -0
- package/dist/core/validation/validator.d.ts +53 -1
- package/dist/core/validation/validator.js +154 -40
- package/dist/core/version-check.d.ts +14 -7
- package/dist/core/version-check.js +63 -17
- package/dist/core/worksets.js +6 -4
- package/dist/prompts/searchable-multi-select.d.ts +7 -0
- package/dist/prompts/searchable-multi-select.js +35 -6
- package/dist/telemetry/config.js +14 -2
- package/dist/telemetry/index.d.ts +4 -3
- package/dist/telemetry/index.js +22 -10
- package/dist/telemetry/opt-out.d.ts +17 -0
- package/dist/telemetry/opt-out.js +27 -0
- package/dist/utils/nested-change.d.ts +35 -0
- package/dist/utils/nested-change.js +186 -0
- package/dist/utils/spec-discovery.d.ts +20 -0
- package/dist/utils/spec-discovery.js +59 -0
- package/dist/utils/task-progress.d.ts +1 -1
- package/dist/utils/task-progress.js +38 -6
- package/package.json +4 -4
- package/schemas/spec-driven/schema.yaml +10 -3
- package/schemas/spec-driven/templates/design.md +2 -0
- package/schemas/spec-driven/templates/proposal.md +2 -0
- package/schemas/spec-driven/templates/spec.md +2 -0
- package/schemas/spec-driven/templates/tasks.md +2 -0
|
@@ -9,9 +9,10 @@ import path from 'path';
|
|
|
9
9
|
import * as fs from 'fs';
|
|
10
10
|
import { loadChangeContext, generateInstructions, resolveSchema, resolveArtifactOutputPath, resolveArtifactOutputs, } from '../../core/artifact-graph/index.js';
|
|
11
11
|
import { isSpecsArtifactPath } from '../../core/artifact-graph/outputs.js';
|
|
12
|
+
import { findUnreadDeltaFiles } from '../../utils/spec-discovery.js';
|
|
12
13
|
import { getChangeDir, resolveCurrentPlanningHomeSync, } from '../../core/planning-home.js';
|
|
13
14
|
import { resolveRootForCommand, withStoreFlag, toPlanningHome, toRootOutput, } from '../../core/root-selection.js';
|
|
14
|
-
import { assembleReferenceIndex, renderReferencedStoresBlock, renderReferencedStoresSection, } from '../../core/references.js';
|
|
15
|
+
import { assembleReferenceIndex, escapeEnvelopeAttribute, escapeEnvelopeTags, renderReferencedStoresBlock, renderReferencedStoresSection, sanitizeInline, } from '../../core/references.js';
|
|
15
16
|
import { readRegistrySnapshot } from '../../core/store/registry.js';
|
|
16
17
|
import { loadOperationInputs, readProjectConfig, } from '../../core/project-config.js';
|
|
17
18
|
import { validateChangeExists, validateSchemaExists, } from './shared.js';
|
|
@@ -95,8 +96,12 @@ export async function instructionsCommand(artifactId, options) {
|
|
|
95
96
|
}
|
|
96
97
|
export function printInstructionsText(instructions, isBlocked) {
|
|
97
98
|
const { artifactId, changeName, schemaName, changeDir, resolvedOutputPath, description, instruction, context, rules, template, dependencies, unlocks, } = instructions;
|
|
98
|
-
// Opening tag
|
|
99
|
-
|
|
99
|
+
// Opening tag. The change name is a directory name read from disk, and the
|
|
100
|
+
// read path rejects only separators and NUL - a quote in it would otherwise
|
|
101
|
+
// close the attribute and forge siblings on this tag.
|
|
102
|
+
console.log(`<artifact id="${escapeEnvelopeAttribute(artifactId)}"` +
|
|
103
|
+
` change="${escapeEnvelopeAttribute(changeName)}"` +
|
|
104
|
+
` schema="${escapeEnvelopeAttribute(schemaName)}">`);
|
|
100
105
|
console.log();
|
|
101
106
|
// Artifacts skipped via skip_specs get no creation directive: emitting the
|
|
102
107
|
// task/template anyway would prompt an agent to write spec files that
|
|
@@ -120,15 +125,15 @@ export function printInstructionsText(instructions, isBlocked) {
|
|
|
120
125
|
}
|
|
121
126
|
// Task directive
|
|
122
127
|
console.log('<task>');
|
|
123
|
-
console.log(`Create the ${artifactId} artifact for change "${changeName}".`);
|
|
124
|
-
console.log(description);
|
|
128
|
+
console.log(`Create the ${escapeEnvelopeTags(artifactId)} artifact for change "${escapeEnvelopeTags(changeName)}".`);
|
|
129
|
+
console.log(escapeEnvelopeTags(description));
|
|
125
130
|
console.log('</task>');
|
|
126
131
|
console.log();
|
|
127
132
|
// Project context (AI constraint - do not include in output)
|
|
128
133
|
if (context) {
|
|
129
134
|
console.log('<project_context>');
|
|
130
135
|
console.log('<!-- This is background information for you. Do NOT include this in your output. -->');
|
|
131
|
-
console.log(context);
|
|
136
|
+
console.log(escapeEnvelopeTags(context));
|
|
132
137
|
console.log('</project_context>');
|
|
133
138
|
console.log();
|
|
134
139
|
}
|
|
@@ -142,7 +147,9 @@ export function printInstructionsText(instructions, isBlocked) {
|
|
|
142
147
|
console.log('<rules>');
|
|
143
148
|
console.log('<!-- These are constraints for you to follow. Do NOT include this in your output. -->');
|
|
144
149
|
for (const rule of rules) {
|
|
145
|
-
|
|
150
|
+
// Flattened so a newline cannot forge a sibling bullet, but never
|
|
151
|
+
// truncated: these are instructions an agent has to follow in full.
|
|
152
|
+
console.log(`- ${escapeEnvelopeTags(sanitizeInline(rule, Infinity))}`);
|
|
146
153
|
}
|
|
147
154
|
console.log('</rules>');
|
|
148
155
|
console.log();
|
|
@@ -166,7 +173,7 @@ export function printInstructionsText(instructions, isBlocked) {
|
|
|
166
173
|
const fullPath = path.join(changeDir, dep.path);
|
|
167
174
|
console.log(`<dependency id="${dep.id}" status="${status}">`);
|
|
168
175
|
console.log(` <path>${fullPath}</path>`);
|
|
169
|
-
console.log(` <description>${dep.description}</description>`);
|
|
176
|
+
console.log(` <description>${escapeEnvelopeTags(dep.description)}</description>`);
|
|
170
177
|
console.log('</dependency>');
|
|
171
178
|
}
|
|
172
179
|
console.log('</dependencies>');
|
|
@@ -180,14 +187,17 @@ export function printInstructionsText(instructions, isBlocked) {
|
|
|
180
187
|
// Instruction (guidance)
|
|
181
188
|
if (instruction) {
|
|
182
189
|
console.log('<instruction>');
|
|
183
|
-
console.log(instruction.trim());
|
|
190
|
+
console.log(escapeEnvelopeTags(instruction.trim()));
|
|
184
191
|
console.log('</instruction>');
|
|
185
192
|
console.log();
|
|
186
193
|
}
|
|
187
194
|
// Template
|
|
188
195
|
console.log('<template>');
|
|
189
196
|
console.log('<!-- Use this as the structure for your output file. Fill in the sections. -->');
|
|
190
|
-
|
|
197
|
+
// Copied verbatim into the artifact file, so its `<!-- ... -->` comments and
|
|
198
|
+
// `<placeholder>` markers must survive - only the envelope's own closing
|
|
199
|
+
// tags are neutralized.
|
|
200
|
+
console.log(escapeEnvelopeTags(template.trim()));
|
|
191
201
|
console.log('</template>');
|
|
192
202
|
console.log();
|
|
193
203
|
// Success criteria placeholder
|
|
@@ -298,8 +308,12 @@ function collectMissingPrerequisites(input) {
|
|
|
298
308
|
* reached tasks yet, the missing specs are the next step rather than a warning.
|
|
299
309
|
* Schemas that declare no spec-producing artifact carry `skip_specs` from
|
|
300
310
|
* creation, so this never fires on them.
|
|
311
|
+
*
|
|
312
|
+
* A delta file the merge path never reads (specs/<capability>.md, a note
|
|
313
|
+
* beside spec.md) still satisfies the specs glob, so it reads as written here
|
|
314
|
+
* while validate rejects it and archive would drop it. Each one is named.
|
|
301
315
|
*/
|
|
302
|
-
function collectApplyWarnings(input) {
|
|
316
|
+
async function collectApplyWarnings(input) {
|
|
303
317
|
const { state, schema, changeDir, changeName, skippedArtifacts } = input;
|
|
304
318
|
if (state === 'blocked')
|
|
305
319
|
return [];
|
|
@@ -308,9 +322,11 @@ function collectApplyWarnings(input) {
|
|
|
308
322
|
return [];
|
|
309
323
|
if (specArtifacts.some((artifact) => skippedArtifacts?.has(artifact.id)))
|
|
310
324
|
return [];
|
|
325
|
+
const warnings = (await findUnreadDeltaFiles(path.join(changeDir, 'specs'))).map((file) => `specs/${file.path} is not a capability's spec.md, so \`openspec validate ${changeName}\` rejects it and archive never merges it. ` +
|
|
326
|
+
`Move its requirements into specs/${file.expected}.`);
|
|
311
327
|
const hasDeltas = specArtifacts.some((artifact) => resolveArtifactOutputs(changeDir, artifact.generates).length > 0);
|
|
312
328
|
if (hasDeltas)
|
|
313
|
-
return
|
|
329
|
+
return warnings;
|
|
314
330
|
const metadataPath = path.join(changeDir, METADATA_FILENAME);
|
|
315
331
|
// The command names the artifact this schema actually declares, never the
|
|
316
332
|
// literal `specs`. A schema whose spec-producing artifact is `contracts` was
|
|
@@ -320,6 +336,7 @@ function collectApplyWarnings(input) {
|
|
|
320
336
|
// a placeholder rather than a guess.
|
|
321
337
|
const specTarget = specArtifacts.length === 1 ? specArtifacts[0].id : '<artifact-id>';
|
|
322
338
|
return [
|
|
339
|
+
...warnings,
|
|
323
340
|
`This change has no delta specs and does not declare \`skip_specs: true\`, so \`openspec validate ${changeName}\` fails on it. ` +
|
|
324
341
|
`Write the delta specs before implementing (\`openspec instructions ${specTarget} --change ${changeName}\`), ` +
|
|
325
342
|
`or add \`skip_specs: true\` to ${metadataPath} if this change really changes no specified behavior.`,
|
|
@@ -440,7 +457,7 @@ export async function generateApplyInstructions(projectRoot, changeName, schemaN
|
|
|
440
457
|
state = 'ready';
|
|
441
458
|
instruction = schemaInstruction?.trim() ?? 'Read context files, work through pending tasks, mark complete as you go.\nPause if you hit blockers or need clarification.';
|
|
442
459
|
}
|
|
443
|
-
const warnings = collectApplyWarnings({
|
|
460
|
+
const warnings = await collectApplyWarnings({
|
|
444
461
|
state,
|
|
445
462
|
schema,
|
|
446
463
|
changeDir,
|
|
@@ -599,13 +616,15 @@ export function printArchiveInstructionsText(instructions) {
|
|
|
599
616
|
function printOperationInputsText(inputs) {
|
|
600
617
|
if (inputs.context) {
|
|
601
618
|
console.log('### Project Context (required instruction input)');
|
|
619
|
+
// Printed verbatim on purpose. Escaping a leading `#` would also fire inside
|
|
620
|
+
// fenced code (`# install deps`), so heading forgery is not guarded here.
|
|
602
621
|
console.log(inputs.context);
|
|
603
622
|
console.log();
|
|
604
623
|
}
|
|
605
624
|
if (inputs.operationGuidance && inputs.operationGuidance.length > 0) {
|
|
606
625
|
console.log('### Operation Guidance (advisory)');
|
|
607
626
|
for (const guidance of inputs.operationGuidance) {
|
|
608
|
-
console.log(`- ${guidance}`);
|
|
627
|
+
console.log(`- ${sanitizeInline(guidance, Infinity)}`);
|
|
609
628
|
}
|
|
610
629
|
console.log();
|
|
611
630
|
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* root; initiative linking and workspace affected areas are no longer part of
|
|
7
7
|
* this command.
|
|
8
8
|
*/
|
|
9
|
+
import chalk from 'chalk';
|
|
9
10
|
import ora from 'ora';
|
|
10
11
|
import path from 'path';
|
|
11
12
|
import { createChange, validateChangeName } from '../../utils/change-utils.js';
|
|
@@ -33,6 +34,24 @@ function printCreatedChangeHuman(payload, root) {
|
|
|
33
34
|
console.log(`Schema: ${payload.change.schema}`);
|
|
34
35
|
console.log(`Next: ${withStoreFlag(root, `openspec status --change ${payload.change.id}`)}`);
|
|
35
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* An implicit root is the fallback taken when no `openspec/` directory was
|
|
39
|
+
* found: creating a change there materializes OpenSpec in whatever directory
|
|
40
|
+
* the caller happened to be in, which is how an agent ends up adopting a
|
|
41
|
+
* project that never ran `openspec init` (#1645). The creation itself stays
|
|
42
|
+
* zero-config; this only makes it visible.
|
|
43
|
+
*/
|
|
44
|
+
function printImplicitRootNotice(root) {
|
|
45
|
+
if (root.source !== 'implicit') {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const openspecDir = path.dirname(root.changesDir);
|
|
49
|
+
const relative = path.relative(process.cwd(), openspecDir);
|
|
50
|
+
const location = relative && !relative.startsWith('..') ? relative : openspecDir;
|
|
51
|
+
console.log();
|
|
52
|
+
console.log(chalk.dim(`Note: no OpenSpec root was found here, so one was created at ${location}/.`));
|
|
53
|
+
console.log(chalk.dim('Run `openspec init` to finish setting this project up, or delete that directory if you meant a different project.'));
|
|
54
|
+
}
|
|
36
55
|
export async function newChangeCommand(name, options) {
|
|
37
56
|
const spinner = options.json ? undefined : ora();
|
|
38
57
|
try {
|
|
@@ -89,6 +108,7 @@ export async function newChangeCommand(name, options) {
|
|
|
89
108
|
}
|
|
90
109
|
spinner?.stop();
|
|
91
110
|
printCreatedChangeHuman(payload, root);
|
|
111
|
+
printImplicitRootNotice(root);
|
|
92
112
|
}
|
|
93
113
|
catch (error) {
|
|
94
114
|
spinner?.stop();
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import chalk from 'chalk';
|
|
8
8
|
import path from 'path';
|
|
9
|
+
import { describeNestedChange, findNestedChangesIn, } from '../../utils/nested-change.js';
|
|
9
10
|
import * as fs from 'fs';
|
|
10
11
|
import { getSchemaDir, listSchemas } from '../../core/artifact-graph/index.js';
|
|
11
12
|
import { isRootSelectionError } from '../../core/root-selection.js';
|
|
@@ -144,6 +145,13 @@ export async function validateChangeExists(changeName, projectRoot, changesDir =
|
|
|
144
145
|
}
|
|
145
146
|
throw new Error(`Change '${changeName}' not found. Available changes:\n ${available.join('\n ')}`);
|
|
146
147
|
}
|
|
148
|
+
// The directory exists but is a namespace folder wrapping nested change
|
|
149
|
+
// directories. Every artifact lookup below it would report "not started" for
|
|
150
|
+
// work that is in fact there, so say what is actually wrong instead (#1846).
|
|
151
|
+
const nested = await findNestedChangesIn(changesDir, changeName);
|
|
152
|
+
if (nested) {
|
|
153
|
+
throw new Error(describeNestedChange(nested));
|
|
154
|
+
}
|
|
147
155
|
return changeName;
|
|
148
156
|
}
|
|
149
157
|
/**
|
|
@@ -14,5 +14,9 @@ export interface StatusOptions {
|
|
|
14
14
|
}
|
|
15
15
|
export declare const BATCH_STATUS_FAILURE_PAYLOAD: Record<string, unknown>;
|
|
16
16
|
export declare function statusCommand(options: StatusOptions): Promise<void>;
|
|
17
|
-
export
|
|
17
|
+
export interface PrintStatusTextOptions {
|
|
18
|
+
/** Selected store id, so the printed command carries `--store`. */
|
|
19
|
+
storeId?: string;
|
|
20
|
+
}
|
|
21
|
+
export declare function printStatusText(status: ChangeStatus, options?: PrintStatusTextOptions): void;
|
|
18
22
|
//# sourceMappingURL=status.d.ts.map
|
|
@@ -8,7 +8,9 @@ import chalk from 'chalk';
|
|
|
8
8
|
import { getChangeDir } from '../../core/planning-home.js';
|
|
9
9
|
import { resolveRootForCommand, toPlanningHome, toRootOutput, withStoreFlag, isStoreSelectedRoot, } from '../../core/root-selection.js';
|
|
10
10
|
import { loadChangeContext, formatChangeStatus, } from '../../core/artifact-graph/index.js';
|
|
11
|
+
import { resolveNextStep } from '../../core/change-status-policy.js';
|
|
11
12
|
import { asStatus } from '../shared-output.js';
|
|
13
|
+
import { describeNestedChange, findNestedChanges, } from '../../utils/nested-change.js';
|
|
12
14
|
import { validateChangeExists, validateSchemaExists, getAvailableChanges, getStatusIndicator, getStatusColor, } from './shared.js';
|
|
13
15
|
// The --all --json failure null-shape. Root-selection failures (handled in
|
|
14
16
|
// resolveRootForCommand) and thrown errors (caught by the CLI wrapper) must
|
|
@@ -37,12 +39,16 @@ export async function statusCommand(options) {
|
|
|
37
39
|
const projectRoot = root.path;
|
|
38
40
|
const rootOutput = toRootOutput(root);
|
|
39
41
|
const newChangeHint = withStoreFlag(root, 'openspec new change <name>');
|
|
42
|
+
// One store-flag decision serves the JSON `nextSteps` sentence and the text
|
|
43
|
+
// `Next:` line, so a store-selected root can never carry `--store` in one
|
|
44
|
+
// and drop it from the other.
|
|
45
|
+
const storeOptions = isStoreSelectedRoot(root) ? { storeId: root.storeId } : {};
|
|
40
46
|
// Single definition of "load one change's status" so the batch and
|
|
41
47
|
// single-change payloads can never drift apart.
|
|
42
48
|
const loadStatus = (changeName) => formatChangeStatus(loadChangeContext(projectRoot, changeName, options.schema, {
|
|
43
49
|
changeDir: getChangeDir(planningHome, changeName),
|
|
44
50
|
planningHome,
|
|
45
|
-
}),
|
|
51
|
+
}), storeOptions);
|
|
46
52
|
// Handle no-changes case gracefully — status is informational,
|
|
47
53
|
// so "no changes" is a valid state, not an error.
|
|
48
54
|
if (!options.change) {
|
|
@@ -66,7 +72,23 @@ export async function statusCommand(options) {
|
|
|
66
72
|
// with the same comparator validate --all uses so the two batch
|
|
67
73
|
// commands order a given change set identically.
|
|
68
74
|
const entries = [];
|
|
75
|
+
// The sweep reads each directory straight through `loadStatus`, so a
|
|
76
|
+
// namespace folder wrapping nested changes would report a whole
|
|
77
|
+
// artifact plan for work that is not there (#1846). It carries the same
|
|
78
|
+
// per-change diagnostic a malformed change does.
|
|
79
|
+
const nestedByName = new Map((await findNestedChanges(root.changesDir, available)).map((finding) => [
|
|
80
|
+
finding.name,
|
|
81
|
+
finding,
|
|
82
|
+
]));
|
|
69
83
|
for (const changeName of available.sort((a, b) => a.localeCompare(b))) {
|
|
84
|
+
const nested = nestedByName.get(changeName);
|
|
85
|
+
if (nested) {
|
|
86
|
+
entries.push({
|
|
87
|
+
changeName,
|
|
88
|
+
status: [asStatus(new Error(describeNestedChange(nested)), 'change_error')],
|
|
89
|
+
});
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
70
92
|
try {
|
|
71
93
|
entries.push(loadStatus(changeName));
|
|
72
94
|
}
|
|
@@ -90,7 +112,7 @@ export async function statusCommand(options) {
|
|
|
90
112
|
console.log();
|
|
91
113
|
}
|
|
92
114
|
if ('artifacts' in entry) {
|
|
93
|
-
printStatusText(entry);
|
|
115
|
+
printStatusText(entry, storeOptions);
|
|
94
116
|
}
|
|
95
117
|
else {
|
|
96
118
|
console.log(chalk.red(`✗ ${entry.changeName}: ${entry.status[0]?.message}`));
|
|
@@ -122,14 +144,14 @@ export async function statusCommand(options) {
|
|
|
122
144
|
console.log(JSON.stringify({ ...status, root: rootOutput }, null, 2));
|
|
123
145
|
return;
|
|
124
146
|
}
|
|
125
|
-
printStatusText(status);
|
|
147
|
+
printStatusText(status, storeOptions);
|
|
126
148
|
}
|
|
127
149
|
catch (error) {
|
|
128
150
|
spinner?.stop();
|
|
129
151
|
throw error;
|
|
130
152
|
}
|
|
131
153
|
}
|
|
132
|
-
export function printStatusText(status) {
|
|
154
|
+
export function printStatusText(status, options = {}) {
|
|
133
155
|
const doneCount = status.artifacts.filter((a) => a.status === 'done').length;
|
|
134
156
|
const skippedCount = status.artifacts.filter((a) => a.status === 'skipped').length;
|
|
135
157
|
const total = status.artifacts.length - skippedCount;
|
|
@@ -153,9 +175,24 @@ export function printStatusText(status) {
|
|
|
153
175
|
}
|
|
154
176
|
console.log(line);
|
|
155
177
|
}
|
|
156
|
-
|
|
178
|
+
// Derived from the same inputs as the JSON `nextSteps` sentence, so the two
|
|
179
|
+
// surfaces always name the same command. Without this line the text surface
|
|
180
|
+
// reports state and no verb, which leaves someone resuming a change - after a
|
|
181
|
+
// lost session, or on a change they did not start - with nowhere to go.
|
|
182
|
+
const nextStep = resolveNextStep({
|
|
183
|
+
changeName: status.changeName,
|
|
184
|
+
artifactStatuses: status.artifacts,
|
|
185
|
+
allArtifactsComplete: status.isPlanningComplete,
|
|
186
|
+
...(options.storeId ? { storeId: options.storeId } : {}),
|
|
187
|
+
});
|
|
188
|
+
if (status.isPlanningComplete || nextStep) {
|
|
157
189
|
console.log();
|
|
190
|
+
}
|
|
191
|
+
if (status.isPlanningComplete) {
|
|
158
192
|
console.log(chalk.green('All planning artifacts complete!'));
|
|
159
193
|
}
|
|
194
|
+
if (nextStep) {
|
|
195
|
+
console.log(`Next: ${nextStep.command}`);
|
|
196
|
+
}
|
|
160
197
|
}
|
|
161
198
|
//# sourceMappingURL=status.js.map
|
package/dist/core/archive.js
CHANGED
|
@@ -8,11 +8,12 @@ import { VALIDATION_MESSAGES } from './validation/constants.js';
|
|
|
8
8
|
import chalk from 'chalk';
|
|
9
9
|
import { emitStoreRootBanner, isRootSelectionError, resolveOpenSpecRoot, toRootOutput, withStoreFlag, isStoreSelectedRoot, } from './root-selection.js';
|
|
10
10
|
import { findSpecUpdates, buildUpdatedSpec, writeUpdatedSpec, retireSpec, finalizeRetiredSpec, } from './specs-apply.js';
|
|
11
|
-
import { discoverSpecFiles, hasAnyFileUnder } from '../utils/spec-discovery.js';
|
|
11
|
+
import { discoverSpecFiles, findUnreadDeltaFiles, hasAnyFileUnder } from '../utils/spec-discovery.js';
|
|
12
12
|
import { METADATA_FILENAME, readRetireCapabilitiesMarker, readSkipSpecsMarker } from '../utils/change-metadata.js';
|
|
13
13
|
import { confirmPrompt, isNonInteractivePromptError } from '../utils/interactive.js';
|
|
14
14
|
import { FileSystemUtils } from '../utils/file-system.js';
|
|
15
15
|
import { folderStyleNameProblem } from './id.js';
|
|
16
|
+
import { describeNestedChange, findNestedChangesIn, } from '../utils/nested-change.js';
|
|
16
17
|
function isMissingPathError(error) {
|
|
17
18
|
return (typeof error === 'object' &&
|
|
18
19
|
error !== null &&
|
|
@@ -949,6 +950,14 @@ export class ArchiveCommand {
|
|
|
949
950
|
? `Change '${changeName}' not found. Available changes: ${available.join(', ')}`
|
|
950
951
|
: `Change '${changeName}' not found. No active changes exist in this root.`);
|
|
951
952
|
}
|
|
953
|
+
// Archiving a namespace folder moves an active, unfinished change into the
|
|
954
|
+
// archive under a name nobody will look for, and never applies its deltas.
|
|
955
|
+
// That is silent data loss, so it is refused outright rather than warned
|
|
956
|
+
// about (#1846).
|
|
957
|
+
const nested = await findNestedChangesIn(changesDir, changeName);
|
|
958
|
+
if (nested) {
|
|
959
|
+
throw new ArchiveBlockedError('archive_change_is_namespace_folder', `Cannot archive '${changeName}': ${describeNestedChange(nested)}`, `Rename openspec/changes/${nested.nested[0]}/ to a flat change directory, then archive it.`);
|
|
960
|
+
}
|
|
952
961
|
const skipValidation = options.validate === false || options.noValidate === true;
|
|
953
962
|
// Validate specs and change before archiving
|
|
954
963
|
if (!skipValidation) {
|
|
@@ -993,6 +1002,13 @@ export class ArchiveCommand {
|
|
|
993
1002
|
// folder, so only a regular file counts.
|
|
994
1003
|
const rootSpecStat = await fs.stat(path.join(changeSpecsDir, 'spec.md')).catch(() => null);
|
|
995
1004
|
let hasDeltaSpecs = rootSpecStat?.isFile() === true;
|
|
1005
|
+
// Likewise for delta sections in any other file the merge path does not
|
|
1006
|
+
// read (specs/<capability>.md, a note beside spec.md): without this the
|
|
1007
|
+
// zero-delta leniency below archives the change as done with nothing
|
|
1008
|
+
// merged, although validate rejects it.
|
|
1009
|
+
if (!hasDeltaSpecs) {
|
|
1010
|
+
hasDeltaSpecs = (await findUnreadDeltaFiles(changeSpecsDir)).length > 0;
|
|
1011
|
+
}
|
|
996
1012
|
// A change that declares skip_specs must not carry any file under
|
|
997
1013
|
// specs/ — validate reports that as a conflict, so archive has to run
|
|
998
1014
|
// the same check instead of skipping validation because the files
|
|
@@ -10,4 +10,18 @@ export declare function loadSchema(filePath: string): SchemaYaml;
|
|
|
10
10
|
* Parses and validates an artifact schema from YAML content.
|
|
11
11
|
*/
|
|
12
12
|
export declare function parseSchema(yamlContent: string): SchemaYaml;
|
|
13
|
+
/**
|
|
14
|
+
* Describes an `apply.tracks` value that is not exactly equal to any artifact's
|
|
15
|
+
* `generates` value, or returns undefined when there is nothing to report.
|
|
16
|
+
*
|
|
17
|
+
* The tracked-tasks lookups select the artifact whose `generates` string equals
|
|
18
|
+
* `tracks`, so this is a progress-discovery problem, not a claim that nothing
|
|
19
|
+
* produces the file: a glob `generates: tasks/*.md` really does generate
|
|
20
|
+
* `tracks: tasks/main.md`, yet the strings differ, so the lookup still misses.
|
|
21
|
+
* Either way apply keeps working (it reads the path directly), but `openspec
|
|
22
|
+
* list` and `openspec status` fall back to counting the top-level `tasks.md`,
|
|
23
|
+
* and apply's remedy cannot name an artifact to build. A typo such as
|
|
24
|
+
* `task.md` is the other usual cause.
|
|
25
|
+
*/
|
|
26
|
+
export declare function findApplyTracksWarning(schema: SchemaYaml): string | undefined;
|
|
13
27
|
//# sourceMappingURL=schema.d.ts.map
|
|
@@ -30,6 +30,8 @@ export function parseSchema(yamlContent) {
|
|
|
30
30
|
validateNoDuplicateIds(schema.artifacts);
|
|
31
31
|
// Check that all requires references are valid
|
|
32
32
|
validateRequiresReferences(schema.artifacts);
|
|
33
|
+
// Check that the apply phase names artifacts this schema declares
|
|
34
|
+
validateApplyReferences(schema);
|
|
33
35
|
// Check for cycles
|
|
34
36
|
validateNoCycles(schema.artifacts);
|
|
35
37
|
return schema;
|
|
@@ -59,6 +61,56 @@ function validateRequiresReferences(artifacts) {
|
|
|
59
61
|
}
|
|
60
62
|
}
|
|
61
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* Validates that every `apply.requires` id is an artifact the schema declares.
|
|
66
|
+
*
|
|
67
|
+
* Apply skips an id that no artifact declares, so a typo silently dropped that
|
|
68
|
+
* artifact from the apply gate. An unknown artifact `requires` is already a
|
|
69
|
+
* load error, and this is the same kind of reference.
|
|
70
|
+
*
|
|
71
|
+
* `apply.tracks` is deliberately not checked here. It is a path, not an id:
|
|
72
|
+
* apply reads it as written, so a schema whose `tracks` value does not exactly
|
|
73
|
+
* match any `generates` value (a hand-written `TODO.md`, or `tasks/main.md`
|
|
74
|
+
* under a glob `generates: tasks/*.md` that really does produce it) works
|
|
75
|
+
* today, and failing the load would break every command on it.
|
|
76
|
+
* `openspec schema validate` reports that case as a warning instead
|
|
77
|
+
* (see `findApplyTracksWarning`).
|
|
78
|
+
*/
|
|
79
|
+
function validateApplyReferences(schema) {
|
|
80
|
+
const apply = schema.apply;
|
|
81
|
+
if (!apply)
|
|
82
|
+
return;
|
|
83
|
+
const validIds = schema.artifacts.map(a => a.id);
|
|
84
|
+
for (const req of apply.requires) {
|
|
85
|
+
if (!validIds.includes(req)) {
|
|
86
|
+
throw new SchemaValidationError(`Invalid apply.requires reference: '${req}' does not exist (artifacts: ${validIds.join(', ')})`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Describes an `apply.tracks` value that is not exactly equal to any artifact's
|
|
92
|
+
* `generates` value, or returns undefined when there is nothing to report.
|
|
93
|
+
*
|
|
94
|
+
* The tracked-tasks lookups select the artifact whose `generates` string equals
|
|
95
|
+
* `tracks`, so this is a progress-discovery problem, not a claim that nothing
|
|
96
|
+
* produces the file: a glob `generates: tasks/*.md` really does generate
|
|
97
|
+
* `tracks: tasks/main.md`, yet the strings differ, so the lookup still misses.
|
|
98
|
+
* Either way apply keeps working (it reads the path directly), but `openspec
|
|
99
|
+
* list` and `openspec status` fall back to counting the top-level `tasks.md`,
|
|
100
|
+
* and apply's remedy cannot name an artifact to build. A typo such as
|
|
101
|
+
* `task.md` is the other usual cause.
|
|
102
|
+
*/
|
|
103
|
+
export function findApplyTracksWarning(schema) {
|
|
104
|
+
const tracks = schema.apply?.tracks;
|
|
105
|
+
if (tracks == null || schema.artifacts.some(a => a.generates === tracks))
|
|
106
|
+
return undefined;
|
|
107
|
+
return (`apply.tracks '${tracks}' does not exactly match any artifact's generates value ` +
|
|
108
|
+
`(generates: ${schema.artifacts.map(a => a.generates).join(', ')}), ` +
|
|
109
|
+
`so OpenSpec cannot tell which artifact's progress it tracks. ` +
|
|
110
|
+
`Apply still reads that file as written, but list and status count tasks.md instead. ` +
|
|
111
|
+
`Make apply.tracks exactly equal one of those generates values, ` +
|
|
112
|
+
`or confirm that file is maintained outside the artifact graph.`);
|
|
113
|
+
}
|
|
62
114
|
/**
|
|
63
115
|
* Validates that there are no cyclic dependencies.
|
|
64
116
|
* Uses DFS to detect cycles and reports the full cycle path.
|
|
@@ -18,6 +18,9 @@ function relativePathSchema(fieldName) {
|
|
|
18
18
|
});
|
|
19
19
|
}
|
|
20
20
|
// Artifact definition schema
|
|
21
|
+
// Upper bound on artifacts in one schema. Keeps `validateNoCycles`' recursive
|
|
22
|
+
// DFS well inside the stack limit for any accepted input.
|
|
23
|
+
const MAX_ARTIFACTS = 1000;
|
|
21
24
|
export const ArtifactSchema = z.object({
|
|
22
25
|
id: z.string().min(1, { error: 'Artifact ID is required' }),
|
|
23
26
|
generates: relativePathSchema('generates field'),
|
|
@@ -40,7 +43,15 @@ export const SchemaYamlSchema = z.object({
|
|
|
40
43
|
name: z.string().min(1, { error: 'Schema name is required' }),
|
|
41
44
|
version: z.number().int().positive({ error: 'Version must be a positive integer' }),
|
|
42
45
|
description: z.string().optional(),
|
|
43
|
-
artifacts: z
|
|
46
|
+
artifacts: z
|
|
47
|
+
.array(ArtifactSchema)
|
|
48
|
+
.min(1, { error: 'At least one artifact required' })
|
|
49
|
+
// Bounded so a hostile schema cannot drive the cycle-detection DFS past the
|
|
50
|
+
// V8 stack limit and crash with an uncaught RangeError instead of a
|
|
51
|
+
// validation error.
|
|
52
|
+
.max(MAX_ARTIFACTS, {
|
|
53
|
+
error: `A schema may declare at most ${MAX_ARTIFACTS} artifacts`,
|
|
54
|
+
}),
|
|
44
55
|
// Optional apply phase configuration (for schema-aware apply instructions)
|
|
45
56
|
apply: ApplyPhaseSchema.optional(),
|
|
46
57
|
});
|
|
@@ -33,5 +33,19 @@ export interface ActionContextInput {
|
|
|
33
33
|
}
|
|
34
34
|
export declare function summarizePlanningHome(planningHome: PlanningHome | undefined): PlanningHomeSummary | undefined;
|
|
35
35
|
export declare function buildActionContext(input: ActionContextInput): ActionContext;
|
|
36
|
+
/**
|
|
37
|
+
* The one next action for a change, in both the forms the CLI needs.
|
|
38
|
+
*
|
|
39
|
+
* `sentence` is what the JSON `nextSteps` contract publishes; `command` is the
|
|
40
|
+
* bare command the text surface prints. Both are built here so the two
|
|
41
|
+
* surfaces can never name a different next step.
|
|
42
|
+
*/
|
|
43
|
+
export interface ChangeNextStep {
|
|
44
|
+
/** Ready-to-run command, including any `--store` flag. */
|
|
45
|
+
command: string;
|
|
46
|
+
/** Sentence form carried by the JSON `nextSteps` array. */
|
|
47
|
+
sentence: string;
|
|
48
|
+
}
|
|
49
|
+
export declare function resolveNextStep(input: ChangeNextStepsInput): ChangeNextStep | undefined;
|
|
36
50
|
export declare function buildNextSteps(input: ChangeNextStepsInput): string[];
|
|
37
51
|
//# sourceMappingURL=change-status-policy.d.ts.map
|
|
@@ -20,16 +20,24 @@ export function buildActionContext(input) {
|
|
|
20
20
|
constraints: ['Repo-local change artifacts and implementation edits are scoped to this project.'],
|
|
21
21
|
};
|
|
22
22
|
}
|
|
23
|
-
export function
|
|
23
|
+
export function resolveNextStep(input) {
|
|
24
24
|
const readyArtifact = input.artifactStatuses.find((artifact) => artifact.status === 'ready');
|
|
25
|
-
const steps = [];
|
|
26
25
|
const storeFlag = input.storeId ? ` --store ${input.storeId}` : '';
|
|
27
26
|
if (readyArtifact) {
|
|
28
|
-
|
|
27
|
+
const command = `openspec instructions ${readyArtifact.id} --change "${input.changeName}"${storeFlag} --json`;
|
|
28
|
+
return { command, sentence: `Run ${command} before writing that artifact.` };
|
|
29
29
|
}
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
if (input.allArtifactsComplete) {
|
|
31
|
+
const command = `openspec instructions apply --change "${input.changeName}"${storeFlag} --json`;
|
|
32
|
+
return {
|
|
33
|
+
command,
|
|
34
|
+
sentence: `All planning artifacts are complete. Run ${command} to inspect implementation progress.`,
|
|
35
|
+
};
|
|
32
36
|
}
|
|
33
|
-
return
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
export function buildNextSteps(input) {
|
|
40
|
+
const step = resolveNextStep(input);
|
|
41
|
+
return step ? [step.sentence] : [];
|
|
34
42
|
}
|
|
35
43
|
//# sourceMappingURL=change-status-policy.js.map
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { getInvocationForAdapter, needsInvocationRewrite } from './invocation.js';
|
|
7
7
|
import { transformCommandInvocations } from '../../utils/command-references.js';
|
|
8
|
+
import { assertWorkflowConditionalsResolved } from '../templates/optional-workflow.js';
|
|
8
9
|
/**
|
|
9
10
|
* Generate a single command file using the provided adapter.
|
|
10
11
|
*
|
|
@@ -20,6 +21,7 @@ import { transformCommandInvocations } from '../../utils/command-references.js';
|
|
|
20
21
|
* @returns Generated command with path and file content
|
|
21
22
|
*/
|
|
22
23
|
export function generateCommand(content, adapter) {
|
|
24
|
+
assertWorkflowConditionalsResolved(content.body, `Command '${content.id}' was generated without resolving its optional-workflow blocks`);
|
|
23
25
|
const invocation = getInvocationForAdapter(adapter);
|
|
24
26
|
const formatted = needsInvocationRewrite(invocation)
|
|
25
27
|
? { ...content, body: transformCommandInvocations(content.body, invocation) }
|
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
* non-TTY runs, which are deferred rather than consumed (see `silent`)
|
|
19
19
|
*/
|
|
20
20
|
import * as fs from 'node:fs';
|
|
21
|
-
import * as path from 'node:path';
|
|
22
21
|
import { getGlobalConfigPath } from './global-config.js';
|
|
22
|
+
import { writeFileAtomically } from './file-state.js';
|
|
23
23
|
import { isCiEnvironment } from '../utils/ci.js';
|
|
24
24
|
import { detectShell } from '../utils/shell-detection.js';
|
|
25
25
|
import { CompletionFactory } from './completions/factory.js';
|
|
@@ -94,13 +94,13 @@ function readRawConfig() {
|
|
|
94
94
|
* write down to this one key, and the rename keeps a reader from ever seeing a
|
|
95
95
|
* half-written config.
|
|
96
96
|
*/
|
|
97
|
-
function markTipSeen() {
|
|
97
|
+
async function markTipSeen() {
|
|
98
98
|
const configPath = getGlobalConfigPath();
|
|
99
99
|
const current = readRawConfig() ?? {};
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
100
|
+
// The shared atomic writer: randomized temp name, owner-only mode, temp file
|
|
101
|
+
// removed on failure. A predictable `<config>.<pid>.tmp` at the default mode
|
|
102
|
+
// is both guessable and world-readable once renamed over the config.
|
|
103
|
+
await writeFileAtomically(configPath, JSON.stringify({ ...current, completionTipSeen: true }, null, 2) + '\n');
|
|
104
104
|
}
|
|
105
105
|
/**
|
|
106
106
|
* Print the completion tip once, the first time the CLI runs.
|
|
@@ -121,7 +121,7 @@ export async function maybeShowCompletionTip(options = {}) {
|
|
|
121
121
|
const decision = await decideTip();
|
|
122
122
|
// Record before printing: if the flag cannot be persisted, staying quiet
|
|
123
123
|
// beats reprinting the tip on every future run.
|
|
124
|
-
markTipSeen();
|
|
124
|
+
await markTipSeen();
|
|
125
125
|
if (decision === 'show') {
|
|
126
126
|
console.error(`\n${COMPLETION_TIP_MESSAGE}`);
|
|
127
127
|
}
|
|
@@ -2,6 +2,7 @@ import { promises as fs } from 'fs';
|
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import os from 'os';
|
|
4
4
|
import { FileSystemUtils } from '../../../utils/file-system.js';
|
|
5
|
+
import { shellSingleQuote } from './shell-quote.js';
|
|
5
6
|
/**
|
|
6
7
|
* Installer for Bash completion scripts.
|
|
7
8
|
* Supports bash-completion package and standalone installations.
|
|
@@ -105,10 +106,11 @@ export class BashInstaller {
|
|
|
105
106
|
* @returns Configuration content
|
|
106
107
|
*/
|
|
107
108
|
generateBashrcConfig(completionsDir) {
|
|
109
|
+
const quotedDir = shellSingleQuote(completionsDir);
|
|
108
110
|
return [
|
|
109
111
|
'# OpenSpec shell completions configuration',
|
|
110
|
-
`if [ -d
|
|
111
|
-
` for f in
|
|
112
|
+
`if [ -d ${quotedDir} ]; then`,
|
|
113
|
+
` for f in ${quotedDir}/*; do`,
|
|
112
114
|
' [ -f "$f" ] && . "$f"',
|
|
113
115
|
' done',
|
|
114
116
|
'fi',
|
|
@@ -177,9 +179,11 @@ export class BashInstaller {
|
|
|
177
179
|
}
|
|
178
180
|
// Remove lines between markers (inclusive)
|
|
179
181
|
lines.splice(startIndex, endIndex - startIndex + 1);
|
|
180
|
-
//
|
|
181
|
-
|
|
182
|
-
|
|
182
|
+
// Install puts the block at the top of the file followed by one blank
|
|
183
|
+
// separator line; drop that line too so the file reads as it did before.
|
|
184
|
+
// Everything else, including the file's final newline, is left as is.
|
|
185
|
+
if (startIndex === 0 && lines.length > 0 && lines[0].trim() === '') {
|
|
186
|
+
lines.shift();
|
|
183
187
|
}
|
|
184
188
|
// Write back
|
|
185
189
|
await fs.writeFile(bashrcPath, lines.join('\n'), 'utf-8');
|
|
@@ -281,14 +285,18 @@ export class BashInstaller {
|
|
|
281
285
|
*/
|
|
282
286
|
generateInstructions(installedPath) {
|
|
283
287
|
const completionsDir = path.dirname(installedPath);
|
|
288
|
+
// Quoted exactly like the auto-configured block: these lines are printed
|
|
289
|
+
// for the user to paste into their own rc file, so an expansion left in
|
|
290
|
+
// them runs on every future shell start.
|
|
291
|
+
const quotedDir = shellSingleQuote(completionsDir);
|
|
284
292
|
return [
|
|
285
293
|
'Completion script installed successfully.',
|
|
286
294
|
'',
|
|
287
295
|
'To enable completions, add the following to your ~/.bashrc file:',
|
|
288
296
|
'',
|
|
289
297
|
` # Source OpenSpec completions`,
|
|
290
|
-
` if [ -d
|
|
291
|
-
` for f in
|
|
298
|
+
` if [ -d ${quotedDir} ]; then`,
|
|
299
|
+
` for f in ${quotedDir}/*; do`,
|
|
292
300
|
' [ -f "$f" ] && . "$f"',
|
|
293
301
|
' done',
|
|
294
302
|
' fi',
|