@fission-ai/openspec 1.13.1 → 1.13.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -3
- package/dist/commands/workflow/instructions.js +36 -8
- package/dist/commands/workflow/shared.d.ts +5 -0
- package/dist/core/archive.js +243 -76
- package/dist/core/artifact-graph/outputs.d.ts +1 -1
- package/dist/core/artifact-graph/outputs.js +38 -8
- package/dist/core/command-generation/adapters/continue.js +4 -0
- package/dist/core/command-generation/adapters/kilocode.d.ts +1 -1
- package/dist/core/command-generation/adapters/kilocode.js +2 -2
- package/dist/core/init.js +9 -3
- package/dist/core/legacy-cleanup.d.ts +1 -1
- package/dist/core/legacy-cleanup.js +29 -4
- package/dist/core/parsers/requirement-blocks.d.ts +34 -3
- package/dist/core/parsers/requirement-blocks.js +65 -23
- package/dist/core/specs-apply.d.ts +20 -0
- package/dist/core/specs-apply.js +19 -6
- package/dist/core/templates/workflows/archive-change.js +30 -16
- package/dist/core/templates/workflows/bulk-archive-change.js +28 -14
- package/dist/core/templates/workflows/continue-change.js +0 -2
- package/dist/core/templates/workflows/explore.js +2 -2
- package/dist/core/templates/workflows/ff-change.js +2 -2
- package/dist/core/templates/workflows/onboard.js +9 -4
- package/dist/core/templates/workflows/update-change.js +21 -9
- package/dist/core/templates/workflows/verify-change.js +115 -52
- package/dist/core/update.js +20 -7
- package/dist/core/validation/purpose-placeholder.js +24 -9
- package/dist/core/validation/validator.js +6 -5
- package/dist/utils/file-system.js +20 -3
- package/dist/utils/line-endings.d.ts +34 -0
- package/dist/utils/line-endings.js +46 -0
- package/package.json +2 -2
- package/schemas/spec-driven/schema.yaml +8 -0
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import type { ToolCommandAdapter } from '../types.js';
|
|
8
8
|
/**
|
|
9
9
|
* Kilo Code adapter for command generation.
|
|
10
|
-
* File path: .
|
|
10
|
+
* File path: .kilo/command/opsx-<id>.md
|
|
11
11
|
* Format: Plain markdown without frontmatter
|
|
12
12
|
*/
|
|
13
13
|
export declare const kilocodeAdapter: ToolCommandAdapter;
|
|
@@ -7,13 +7,13 @@
|
|
|
7
7
|
import path from 'path';
|
|
8
8
|
/**
|
|
9
9
|
* Kilo Code adapter for command generation.
|
|
10
|
-
* File path: .
|
|
10
|
+
* File path: .kilo/command/opsx-<id>.md
|
|
11
11
|
* Format: Plain markdown without frontmatter
|
|
12
12
|
*/
|
|
13
13
|
export const kilocodeAdapter = {
|
|
14
14
|
toolId: 'kilocode',
|
|
15
15
|
getFilePath(commandId) {
|
|
16
|
-
return path.join('.
|
|
16
|
+
return path.join('.kilo', 'command', `opsx-${commandId}.md`);
|
|
17
17
|
},
|
|
18
18
|
formatFile(content) {
|
|
19
19
|
return `${content.body}
|
package/dist/core/init.js
CHANGED
|
@@ -992,9 +992,15 @@ export class InitCommand {
|
|
|
992
992
|
// Tools with no slash surface (e.g. Rovo Dev) reference skills as
|
|
993
993
|
// prose ("the openspec-propose skill"); phrase the hint so it reads
|
|
994
994
|
// as an instruction rather than a dead command with an argument.
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
995
|
+
if (usesNaturalLanguageSkillReferences(tool.value)) {
|
|
996
|
+
hint = `Start your first change: ask ${tool.name} to use ${skillReference} with "your idea"`;
|
|
997
|
+
}
|
|
998
|
+
else if (tool.value === 'codex') {
|
|
999
|
+
hint = `Start your first change: ${skillReference} "your idea" (Codex CLI or IDE); in the Codex desktop app, select ${skillReference.slice(1)} from Skills in the sidebar`;
|
|
1000
|
+
}
|
|
1001
|
+
else {
|
|
1002
|
+
hint = `Start your first change: ${skillReference} "your idea"`;
|
|
1003
|
+
}
|
|
998
1004
|
}
|
|
999
1005
|
else {
|
|
1000
1006
|
continue;
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Legacy cleanup module for detecting and removing OpenSpec artifacts
|
|
3
3
|
* from previous init versions during the migration to the skill-based workflow.
|
|
4
4
|
*/
|
|
5
|
-
import type
|
|
5
|
+
import { type WorkflowId } from './profiles.js';
|
|
6
6
|
/**
|
|
7
7
|
* Legacy config file names from the old ToolRegistry.
|
|
8
8
|
* These were config files created at project root with OpenSpec markers.
|
|
@@ -4,10 +4,11 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import path from 'path';
|
|
6
6
|
import os from 'os';
|
|
7
|
-
import { promises as fs } from 'fs';
|
|
7
|
+
import { promises as fs, constants as fsConstants } from 'fs';
|
|
8
8
|
import chalk from 'chalk';
|
|
9
9
|
import { FileSystemUtils, removeMarkerBlock as removeMarkerBlockUtil } from '../utils/file-system.js';
|
|
10
10
|
import { OPENSPEC_MARKERS } from './config.js';
|
|
11
|
+
import { ALL_WORKFLOWS } from './profiles.js';
|
|
11
12
|
/**
|
|
12
13
|
* Legacy config file names from the old ToolRegistry.
|
|
13
14
|
* These were config files created at project root with OpenSpec markers.
|
|
@@ -24,6 +25,13 @@ export const LEGACY_CONFIG_FILES = [
|
|
|
24
25
|
];
|
|
25
26
|
/** The three commands the old SlashCommandRegistry wrote into each directory. */
|
|
26
27
|
const LEGACY_DIRECTORY_COMMAND_FILES = ['proposal.md', 'apply.md', 'archive.md'];
|
|
28
|
+
/** Exact Kilo workflow files written by OpenSpec before the command path moved. */
|
|
29
|
+
const LEGACY_KILOCODE_COMMAND_FILES = [
|
|
30
|
+
...ALL_WORKFLOWS.map(workflow => `.kilocode/workflows/opsx-${workflow}.md`),
|
|
31
|
+
'.kilocode/workflows/openspec-proposal.md',
|
|
32
|
+
'.kilocode/workflows/openspec-apply.md',
|
|
33
|
+
'.kilocode/workflows/openspec-archive.md',
|
|
34
|
+
];
|
|
27
35
|
/**
|
|
28
36
|
* Legacy slash command patterns from the old SlashCommandRegistry.
|
|
29
37
|
* These map toolId to the path pattern where legacy commands were created.
|
|
@@ -48,7 +56,12 @@ export const LEGACY_SLASH_COMMAND_PATHS = {
|
|
|
48
56
|
// belong to `devin` — the id Windsurf became. Only `.windsurf/` is listed:
|
|
49
57
|
// `.devin/` postdates the opsx rename and never held `openspec-*` files.
|
|
50
58
|
'devin': { type: 'files', pattern: '.windsurf/workflows/openspec-*.md' },
|
|
51
|
-
|
|
59
|
+
// Kilo now writes commands under `.kilo/command/`. Clean up both generations
|
|
60
|
+
// of OpenSpec workflows from Kilo's legacy `.kilocode/workflows/` folder.
|
|
61
|
+
'kilocode': {
|
|
62
|
+
type: 'files',
|
|
63
|
+
pattern: LEGACY_KILOCODE_COMMAND_FILES,
|
|
64
|
+
},
|
|
52
65
|
'kiro': { type: 'files', pattern: '.kiro/prompts/openspec-*.prompt.md' },
|
|
53
66
|
'github-copilot': { type: 'files', pattern: '.github/prompts/openspec-*.prompt.md' },
|
|
54
67
|
'amazon-q': { type: 'files', pattern: '.amazonq/prompts/openspec-*.md' },
|
|
@@ -331,15 +344,27 @@ async function settleLegacyCommandDir(projectPath, dirPath, result) {
|
|
|
331
344
|
* a same-named file without them is the user's.
|
|
332
345
|
*/
|
|
333
346
|
async function isGeneratedLegacyCommand(filePath) {
|
|
347
|
+
// Judge the opened handle, not the path, so the file checked is the file
|
|
348
|
+
// read. O_NOFOLLOW refuses a link and O_NONBLOCK keeps a FIFO from hanging;
|
|
349
|
+
// Windows has neither flag, so a link is refused there by lstat instead.
|
|
350
|
+
const { O_RDONLY, O_NOFOLLOW, O_NONBLOCK } = fsConstants;
|
|
351
|
+
let handle;
|
|
334
352
|
try {
|
|
335
|
-
|
|
353
|
+
handle = await fs.open(filePath, O_RDONLY | (O_NOFOLLOW ?? 0) | (O_NONBLOCK ?? 0));
|
|
354
|
+
if (O_NOFOLLOW === undefined && (await fs.lstat(filePath)).isSymbolicLink()) {
|
|
336
355
|
return false;
|
|
337
356
|
}
|
|
338
|
-
|
|
357
|
+
if (!(await handle.stat()).isFile()) {
|
|
358
|
+
return false;
|
|
359
|
+
}
|
|
360
|
+
return hasOpenSpecMarkers(await handle.readFile('utf-8'));
|
|
339
361
|
}
|
|
340
362
|
catch {
|
|
341
363
|
return false;
|
|
342
364
|
}
|
|
365
|
+
finally {
|
|
366
|
+
await handle?.close();
|
|
367
|
+
}
|
|
343
368
|
}
|
|
344
369
|
/**
|
|
345
370
|
* Detects legacy global slash command files.
|
|
@@ -95,14 +95,45 @@ export interface DeltaPlan {
|
|
|
95
95
|
* Parse a delta-formatted spec change file content into a DeltaPlan with raw blocks.
|
|
96
96
|
*/
|
|
97
97
|
export declare function parseDeltaSpec(content: string): DeltaPlan;
|
|
98
|
+
/** Both directions of the scenario-name comparison, plus the two totals. */
|
|
99
|
+
export interface ScenarioNameDiff {
|
|
100
|
+
/** Names the current block has that the incoming block does not cover. */
|
|
101
|
+
missing: string[];
|
|
102
|
+
/** Names the incoming block introduces that the current block does not have. */
|
|
103
|
+
added: string[];
|
|
104
|
+
/** Level-4 headers in the current block. */
|
|
105
|
+
currentCount: number;
|
|
106
|
+
/** Level-4 headers in the incoming block. */
|
|
107
|
+
incomingCount: number;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Compare the scenario names of a current requirement block and an incoming
|
|
111
|
+
* (MODIFIED) one, in both directions.
|
|
112
|
+
*
|
|
113
|
+
* `missing` is the loss the guard exists to catch: a MODIFIED requirement
|
|
114
|
+
* replaces the whole block, so every name there would be dropped from the main
|
|
115
|
+
* spec. `added` and the two counts are reported alongside it, because they are
|
|
116
|
+
* the first thing a reader checks once it fires (#1697) - a block that omits
|
|
117
|
+
* two names and introduces two is shaped like a rename, one that omits two and
|
|
118
|
+
* introduces none is shaped like a truncation. Neither is proof, and intent is
|
|
119
|
+
* not recoverable from structure, so this decides nothing and only says what
|
|
120
|
+
* the two blocks contain.
|
|
121
|
+
*/
|
|
122
|
+
export declare function diffScenarioNames(current: RequirementBlock, incoming: RequirementBlock): ScenarioNameDiff;
|
|
98
123
|
/**
|
|
99
124
|
* Scenario names the current requirement block has and the incoming
|
|
100
125
|
* (MODIFIED) block does not. A MODIFIED requirement replaces the whole block,
|
|
101
126
|
* so every name reported here would be dropped from the main spec.
|
|
102
127
|
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
* about what counts as a dropped
|
|
128
|
+
* The `missing` half of diffScenarioNames, which archive (refusing to apply
|
|
129
|
+
* the block) and validate (reporting the same loss at authoring time, #1477)
|
|
130
|
+
* both go through, so the two cannot disagree about what counts as a dropped
|
|
131
|
+
* scenario.
|
|
106
132
|
*/
|
|
107
133
|
export declare function findMissingCurrentScenarios(current: RequirementBlock, incoming: RequirementBlock): string[];
|
|
134
|
+
/**
|
|
135
|
+
* The one sentence archive and validate both append when the guard fires, so
|
|
136
|
+
* the counts a reader sees cannot differ between the two commands.
|
|
137
|
+
*/
|
|
138
|
+
export declare function describeScenarioBalance(diff: ScenarioNameDiff): string;
|
|
108
139
|
//# sourceMappingURL=requirement-blocks.d.ts.map
|
|
@@ -380,37 +380,79 @@ function parseRenamedPairs(sectionBody, unpaired) {
|
|
|
380
380
|
drop('FROM', pending.name, pending.line);
|
|
381
381
|
return pairs;
|
|
382
382
|
}
|
|
383
|
+
/**
|
|
384
|
+
* Compare the scenario names of a current requirement block and an incoming
|
|
385
|
+
* (MODIFIED) one, in both directions.
|
|
386
|
+
*
|
|
387
|
+
* `missing` is the loss the guard exists to catch: a MODIFIED requirement
|
|
388
|
+
* replaces the whole block, so every name there would be dropped from the main
|
|
389
|
+
* spec. `added` and the two counts are reported alongside it, because they are
|
|
390
|
+
* the first thing a reader checks once it fires (#1697) - a block that omits
|
|
391
|
+
* two names and introduces two is shaped like a rename, one that omits two and
|
|
392
|
+
* introduces none is shaped like a truncation. Neither is proof, and intent is
|
|
393
|
+
* not recoverable from structure, so this decides nothing and only says what
|
|
394
|
+
* the two blocks contain.
|
|
395
|
+
*/
|
|
396
|
+
export function diffScenarioNames(current, incoming) {
|
|
397
|
+
const currentNames = parseScenarioBlocks(current.raw).map((scenario) => scenario.name);
|
|
398
|
+
const incomingNames = parseScenarioBlocks(incoming.raw).map((scenario) => scenario.name);
|
|
399
|
+
// Multiplicity-aware: a name present N times on one side and M times on the
|
|
400
|
+
// other leaves max(0, N - M) instances unmatched. Set membership would treat
|
|
401
|
+
// N>M as fully covered and let archive silently drop duplicates (residual
|
|
402
|
+
// #1246 / duplicate-scenario-name blind spot).
|
|
403
|
+
const unmatched = (names, against) => {
|
|
404
|
+
const remaining = new Map();
|
|
405
|
+
for (const name of against)
|
|
406
|
+
remaining.set(name, (remaining.get(name) ?? 0) + 1);
|
|
407
|
+
const out = [];
|
|
408
|
+
for (const name of names) {
|
|
409
|
+
const left = remaining.get(name) ?? 0;
|
|
410
|
+
if (left > 0)
|
|
411
|
+
remaining.set(name, left - 1);
|
|
412
|
+
else
|
|
413
|
+
out.push(name);
|
|
414
|
+
}
|
|
415
|
+
return out;
|
|
416
|
+
};
|
|
417
|
+
return {
|
|
418
|
+
missing: unmatched(currentNames, incomingNames),
|
|
419
|
+
added: unmatched(incomingNames, currentNames),
|
|
420
|
+
currentCount: currentNames.length,
|
|
421
|
+
incomingCount: incomingNames.length,
|
|
422
|
+
};
|
|
423
|
+
}
|
|
383
424
|
/**
|
|
384
425
|
* Scenario names the current requirement block has and the incoming
|
|
385
426
|
* (MODIFIED) block does not. A MODIFIED requirement replaces the whole block,
|
|
386
427
|
* so every name reported here would be dropped from the main spec.
|
|
387
428
|
*
|
|
388
|
-
*
|
|
389
|
-
*
|
|
390
|
-
* about what counts as a dropped
|
|
429
|
+
* The `missing` half of diffScenarioNames, which archive (refusing to apply
|
|
430
|
+
* the block) and validate (reporting the same loss at authoring time, #1477)
|
|
431
|
+
* both go through, so the two cannot disagree about what counts as a dropped
|
|
432
|
+
* scenario.
|
|
391
433
|
*/
|
|
392
434
|
export function findMissingCurrentScenarios(current, incoming) {
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
const
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
if (remaining > 0) {
|
|
407
|
-
remainingIncoming.set(name, remaining - 1);
|
|
408
|
-
}
|
|
409
|
-
else {
|
|
410
|
-
missing.push(name);
|
|
411
|
-
}
|
|
435
|
+
return diffScenarioNames(current, incoming).missing;
|
|
436
|
+
}
|
|
437
|
+
/** At most this many added names are listed before the rest are counted. */
|
|
438
|
+
const MAX_LISTED_ADDED_SCENARIOS = 3;
|
|
439
|
+
/**
|
|
440
|
+
* The one sentence archive and validate both append when the guard fires, so
|
|
441
|
+
* the counts a reader sees cannot differ between the two commands.
|
|
442
|
+
*/
|
|
443
|
+
export function describeScenarioBalance(diff) {
|
|
444
|
+
const count = (value) => `${value} ${value === 1 ? 'scenario' : 'scenarios'}`;
|
|
445
|
+
const scale = `The modified block has ${count(diff.incomingCount)}; the current spec has ${count(diff.currentCount)}.`;
|
|
446
|
+
if (diff.added.length === 0) {
|
|
447
|
+
return `${scale} It adds none.`;
|
|
412
448
|
}
|
|
413
|
-
|
|
449
|
+
const listed = diff.added
|
|
450
|
+
.slice(0, MAX_LISTED_ADDED_SCENARIOS)
|
|
451
|
+
.map((name) => `"${name}"`)
|
|
452
|
+
.join(', ');
|
|
453
|
+
const rest = diff.added.length - MAX_LISTED_ADDED_SCENARIOS;
|
|
454
|
+
const names = rest > 0 ? `${listed} and ${rest} more` : listed;
|
|
455
|
+
return `${scale} It adds ${count(diff.added.length)} not in the current spec: ${names}.`;
|
|
414
456
|
}
|
|
415
457
|
/**
|
|
416
458
|
* Any non-fenced level-4 header on the given (masked) line. Reuses the spec
|
|
@@ -102,6 +102,26 @@ export declare function retireSpec(update: SpecUpdate, mainSpecsDir: string, opt
|
|
|
102
102
|
displacedPath?: string;
|
|
103
103
|
}>;
|
|
104
104
|
export declare function finalizeRetiredSpec(target: string, displacedPath: string, mainSpecsDir: string): Promise<void>;
|
|
105
|
+
/**
|
|
106
|
+
* Remove now-empty directories from `startDir` upward, never leaving the real
|
|
107
|
+
* `boundaryDir` and never removing that directory itself.
|
|
108
|
+
*
|
|
109
|
+
* The boundary is a parameter rather than the specs root directly so the walk's
|
|
110
|
+
* containment is stated at the call site, where the root it must not escape is
|
|
111
|
+
* the thing being reasoned about.
|
|
112
|
+
*
|
|
113
|
+
* The guard re-runs every iteration, so stepping to the LEXICAL parent is safe:
|
|
114
|
+
* a parent that is not the real one is simply re-resolved and rejected. Errors
|
|
115
|
+
* are swallowed and end the walk - ENOTEMPTY and ENOENT are correct outcomes (a
|
|
116
|
+
* file arriving mid-walk must win), and a permissions failure leaves an empty
|
|
117
|
+
* directory behind, which the next successful archive clears.
|
|
118
|
+
*
|
|
119
|
+
* Not race-free: an attacker who can swap an ancestor between the check and the
|
|
120
|
+
* `rmdir` could get an empty directory outside the root removed. Closing that
|
|
121
|
+
* needs fd-relative syscalls Node does not expose, and it requires local write
|
|
122
|
+
* access to `openspec/specs` during an archive.
|
|
123
|
+
*/
|
|
124
|
+
export declare function pruneEmptyDirs(startDir: string, boundaryDir: string): Promise<void>;
|
|
105
125
|
/**
|
|
106
126
|
* Write an updated spec to disk.
|
|
107
127
|
*/
|
package/dist/core/specs-apply.js
CHANGED
|
@@ -8,13 +8,14 @@ import { promises as fs } from 'fs';
|
|
|
8
8
|
import { randomUUID } from 'crypto';
|
|
9
9
|
import path from 'path';
|
|
10
10
|
import chalk from 'chalk';
|
|
11
|
-
import { extractRequirementsSection,
|
|
11
|
+
import { extractRequirementsSection, diffScenarioNames, describeScenarioBalance, foldRequirementName, parseDeltaSpec, normalizeRequirementName, } from './parsers/requirement-blocks.js';
|
|
12
12
|
import { findMainSpecStructureIssues } from './parsers/spec-structure.js';
|
|
13
13
|
import { buildCodeFenceMask } from './parsers/code-fence.js';
|
|
14
14
|
import { MarkdownParser } from './parsers/markdown-parser.js';
|
|
15
15
|
import { MIN_PURPOSE_LENGTH, PURPOSE_PLACEHOLDER_PREFIX, PURPOSE_PLACEHOLDER_SUFFIX, } from './validation/constants.js';
|
|
16
16
|
import { discoverSpecFiles } from '../utils/spec-discovery.js';
|
|
17
17
|
import { FileSystemUtils } from '../utils/file-system.js';
|
|
18
|
+
import { matchLineEnding } from '../utils/line-endings.js';
|
|
18
19
|
function isLexicallyWithin(allowedDirectory, targetPath) {
|
|
19
20
|
const relative = path.relative(path.resolve(allowedDirectory), path.resolve(targetPath));
|
|
20
21
|
return (relative === '' ||
|
|
@@ -383,9 +384,9 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
|
|
|
383
384
|
if (!modHeaderMatch || normalizeRequirementName(modHeaderMatch[1]) !== key) {
|
|
384
385
|
throw new Error(`${specName} MODIFIED failed for header "### Requirement: ${mod.name}" - header mismatch in content`);
|
|
385
386
|
}
|
|
386
|
-
const
|
|
387
|
-
if (
|
|
388
|
-
throw new Error(`${specName} MODIFIED failed for header "### Requirement: ${mod.name}" - current spec contains scenario(s) not present in the modified block: ${
|
|
387
|
+
const scenarioDiff = diffScenarioNames(currentBlock, mod);
|
|
388
|
+
if (scenarioDiff.missing.length > 0) {
|
|
389
|
+
throw new Error(`${specName} MODIFIED failed for header "### Requirement: ${mod.name}" - current spec contains scenario(s) not present in the modified block: ${scenarioDiff.missing.map(name => `"${name}"`).join(', ')}. ${describeScenarioBalance(scenarioDiff)} Refresh the change spec before archiving to avoid dropping scenarios.`);
|
|
389
390
|
}
|
|
390
391
|
// Identical content means the modification was already synced to the
|
|
391
392
|
// baseline (early-sync pattern) — count only real replacements, so a
|
|
@@ -995,7 +996,7 @@ async function isInsideRealDir(realPath, dir) {
|
|
|
995
996
|
* needs fd-relative syscalls Node does not expose, and it requires local write
|
|
996
997
|
* access to `openspec/specs` during an archive.
|
|
997
998
|
*/
|
|
998
|
-
async function pruneEmptyDirs(startDir, boundaryDir) {
|
|
999
|
+
export async function pruneEmptyDirs(startDir, boundaryDir) {
|
|
999
1000
|
let boundary;
|
|
1000
1001
|
try {
|
|
1001
1002
|
boundary = await fs.realpath(boundaryDir);
|
|
@@ -1040,11 +1041,23 @@ export async function writeUpdatedSpec(update, rebuilt, counts, options = {}) {
|
|
|
1040
1041
|
// Create target directory if needed
|
|
1041
1042
|
const targetDir = path.dirname(update.target);
|
|
1042
1043
|
await fs.mkdir(targetDir, { recursive: true });
|
|
1044
|
+
// The parsers normalize CRLF to LF on read, so `rebuilt` is always LF. Write
|
|
1045
|
+
// it back with the convention the file already used, or a Windows checkout
|
|
1046
|
+
// (core.autocrlf=true) sees every line of the spec change when one
|
|
1047
|
+
// requirement moved. A spec that does not exist yet stays LF.
|
|
1048
|
+
// Only a missing file means "no convention to match". Swallowing every error
|
|
1049
|
+
// would read an existing but unreadable spec as absent and rewrite it as LF.
|
|
1050
|
+
const previous = await fs.readFile(update.target, 'utf-8').catch((error) => {
|
|
1051
|
+
if (error.code === 'ENOENT')
|
|
1052
|
+
return undefined;
|
|
1053
|
+
throw error;
|
|
1054
|
+
});
|
|
1055
|
+
const toWrite = previous === undefined ? rebuilt : matchLineEnding(rebuilt, previous);
|
|
1043
1056
|
await options.beforeMutate?.();
|
|
1044
1057
|
// Preserve the established in-place write semantics: symlink referents,
|
|
1045
1058
|
// hard-linked specs, ACLs, extended attributes, and filesystems without hard
|
|
1046
1059
|
// links must continue to behave as they did before capability retirement.
|
|
1047
|
-
await fs.writeFile(update.target,
|
|
1060
|
+
await fs.writeFile(update.target, toWrite);
|
|
1048
1061
|
if (options.silent)
|
|
1049
1062
|
return;
|
|
1050
1063
|
const specName = update.id;
|
|
@@ -81,20 +81,27 @@ ${PROJECT_ROOT_GUARD}
|
|
|
81
81
|
|
|
82
82
|
3. **Check task completion status**
|
|
83
83
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
84
|
+
Run \`openspec list --json\` with the same selected-root flags and find the
|
|
85
|
+
entry in \`changes\` whose \`name\` exactly matches the selected change.
|
|
86
|
+
Require exactly one match and nonnegative integer \`totalTasks\` and
|
|
87
|
+
\`completedTasks\`, with \`completedTasks <= totalTasks\`. The CLI resolves
|
|
88
|
+
the schema's tracked task files, including custom artifact names, output
|
|
89
|
+
paths, and globs.
|
|
90
|
+
Incomplete tasks = \`totalTasks - completedTasks\`.
|
|
91
|
+
|
|
92
|
+
Do not infer task completion from artifact status or the absence of a
|
|
93
|
+
top-level \`tasks.md\`. If the lookup fails, returns invalid JSON, omits or
|
|
94
|
+
duplicates the selected change, or returns invalid counts, report the problem
|
|
95
|
+
and stop before syncing or archiving.
|
|
96
|
+
The CLI counts only \`x\`/\`X\` checkbox markers as complete;
|
|
97
|
+
other markers, including unfamiliar ones, remain incomplete.
|
|
91
98
|
|
|
92
99
|
**If incomplete tasks found:**
|
|
93
100
|
- Display warning showing count of incomplete tasks
|
|
94
101
|
- Ask the user to confirm they want to proceed
|
|
95
102
|
- Proceed if user confirms
|
|
96
103
|
|
|
97
|
-
**If
|
|
104
|
+
**If \`totalTasks\` is zero:** Proceed without a task-related warning.
|
|
98
105
|
|
|
99
106
|
4. **Assess delta spec sync state**
|
|
100
107
|
|
|
@@ -274,20 +281,27 @@ ${PROJECT_ROOT_GUARD}
|
|
|
274
281
|
|
|
275
282
|
3. **Check task completion status**
|
|
276
283
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
+
Run \`openspec list --json\` with the same selected-root flags and find the
|
|
285
|
+
entry in \`changes\` whose \`name\` exactly matches the selected change.
|
|
286
|
+
Require exactly one match and nonnegative integer \`totalTasks\` and
|
|
287
|
+
\`completedTasks\`, with \`completedTasks <= totalTasks\`. The CLI resolves
|
|
288
|
+
the schema's tracked task files, including custom artifact names, output
|
|
289
|
+
paths, and globs.
|
|
290
|
+
Incomplete tasks = \`totalTasks - completedTasks\`.
|
|
291
|
+
|
|
292
|
+
Do not infer task completion from artifact status or the absence of a
|
|
293
|
+
top-level \`tasks.md\`. If the lookup fails, returns invalid JSON, omits or
|
|
294
|
+
duplicates the selected change, or returns invalid counts, report the problem
|
|
295
|
+
and stop before syncing or archiving.
|
|
296
|
+
The CLI counts only \`x\`/\`X\` checkbox markers as complete;
|
|
297
|
+
other markers, including unfamiliar ones, remain incomplete.
|
|
284
298
|
|
|
285
299
|
**If incomplete tasks found:**
|
|
286
300
|
- Display warning showing count of incomplete tasks
|
|
287
301
|
- Prompt user for confirmation to continue
|
|
288
302
|
- Proceed if user confirms
|
|
289
303
|
|
|
290
|
-
**If
|
|
304
|
+
**If \`totalTasks\` is zero:** Proceed without a task-related warning.
|
|
291
305
|
|
|
292
306
|
4. **Assess delta spec sync state**
|
|
293
307
|
|
|
@@ -37,7 +37,7 @@ ${PROJECT_ROOT_GUARD}
|
|
|
37
37
|
2. **Prompt for change selection**
|
|
38
38
|
|
|
39
39
|
Ask the user to choose changes (multi-select):
|
|
40
|
-
- Show each change
|
|
40
|
+
- Show each change name and task status from the list output
|
|
41
41
|
- Include an option for "All changes"
|
|
42
42
|
- Allow any number of selections (1+ works, 2+ is the typical use case)
|
|
43
43
|
|
|
@@ -70,17 +70,24 @@ ${PROJECT_ROOT_GUARD}
|
|
|
70
70
|
|
|
71
71
|
3. **Batch validation - gather status for all selected changes**
|
|
72
72
|
|
|
73
|
+
Run \`openspec list --json\` once with the same selected-root flags for task
|
|
74
|
+
progress. If the lookup fails, returns invalid JSON, or omits any selected
|
|
75
|
+
change, contains a duplicate selected change, or returns invalid counts,
|
|
76
|
+
report the problem and stop before syncing or archiving the batch.
|
|
77
|
+
|
|
73
78
|
For each selected change, collect:
|
|
74
79
|
|
|
75
80
|
a. **Artifact status** - Run \`openspec status --change "<name>" --json\`
|
|
76
81
|
- Parse \`schemaName\`, \`artifacts\`, \`planningHome\`, \`changeRoot\`, \`artifactPaths\`, and \`actionContext\`
|
|
77
82
|
- Note which artifacts are \`done\` vs other states
|
|
78
83
|
|
|
79
|
-
b. **Task completion** -
|
|
80
|
-
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
-
|
|
84
|
+
b. **Task completion** - Find the \`changes\` entry from the list response whose \`name\` exactly matches this change
|
|
85
|
+
- Require nonnegative integer \`totalTasks\` and \`completedTasks\`, with \`completedTasks <= totalTasks\`
|
|
86
|
+
- Incomplete tasks = \`totalTasks - completedTasks\`
|
|
87
|
+
- The CLI resolves the schema's tracked task files, including custom artifact names, output paths, and globs
|
|
88
|
+
- Do not infer task completion from artifact status, an artifact id of \`tasks\`, or the absence of a top-level \`tasks.md\`
|
|
89
|
+
- The CLI counts only \`x\`/\`X\` checkbox markers as complete; other markers remain incomplete
|
|
90
|
+
- If \`totalTasks\` is zero, note as "No tasks"
|
|
84
91
|
|
|
85
92
|
c. **Delta specs** - Check \`artifactPaths.specs.existingOutputPaths\` from status JSON
|
|
86
93
|
- List which capability specs exist
|
|
@@ -194,7 +201,7 @@ ${PROJECT_ROOT_GUARD}
|
|
|
194
201
|
Process changes in the determined order (respecting conflict resolution):
|
|
195
202
|
|
|
196
203
|
a. **Sync included delta specs**:
|
|
197
|
-
- Run the
|
|
204
|
+
- ${optionalWorkflow('sync', 'Run the `openspec-sync-specs` workflow inline (agent-driven intelligent merge)', 'Perform the delta-to-main-spec merge inline yourself (agent-driven intelligent merge)')} only for changes with entries in \`includedDeltas\`, passing only the included delta paths and explicitly instructing it to ignore that change's \`excludedDeltas\`. Wait for it to finish.
|
|
198
205
|
- For conflicts, apply in resolved order.
|
|
199
206
|
- Pass that change's fetched specs-rule snapshot into inline sync; inline
|
|
200
207
|
sync must reuse it without fetching instructions again
|
|
@@ -347,7 +354,7 @@ No active changes found. Create a new change to get started.
|
|
|
347
354
|
- Archive directory target uses the current date, computed once in step 3d and reused at the move: YYYY-MM-DD-<name>; a name that already starts with a \`YYYY-MM-DD-\` prefix is used as-is (never stack a second date)
|
|
348
355
|
- If archive target exists, fail that change but continue with others
|
|
349
356
|
- Check every archive target in step 3, before the first main-spec write; a change whose target exists is never synced or moved
|
|
350
|
-
- If sync is requested, run the
|
|
357
|
+
- If sync is requested, ${optionalWorkflow('sync', 'run the `openspec-sync-specs` workflow inline (agent-driven)', 'perform the delta-to-main-spec merge inline (agent-driven)')} for each change with included delta specs
|
|
351
358
|
- Carry the per-delta \`includedDeltas\` and \`excludedDeltas\` decisions into execution; sync and verify only included deltas
|
|
352
359
|
- Report every excluded delta as \`sync skipped\` without treating the archive itself as skipped
|
|
353
360
|
- Never archive a change while a spec sync is still in flight — run the sync inline and verify main specs at \`<planningHome.root>/openspec/specs/<capability-path>/spec.md\` before moving \`changeRoot\`
|
|
@@ -395,7 +402,7 @@ ${PROJECT_ROOT_GUARD}
|
|
|
395
402
|
2. **Prompt for change selection**
|
|
396
403
|
|
|
397
404
|
Ask the user to choose changes (multi-select):
|
|
398
|
-
- Show each change
|
|
405
|
+
- Show each change name and task status from the list output
|
|
399
406
|
- Include an option for "All changes"
|
|
400
407
|
- Allow any number of selections (1+ works, 2+ is the typical use case)
|
|
401
408
|
|
|
@@ -428,17 +435,24 @@ ${PROJECT_ROOT_GUARD}
|
|
|
428
435
|
|
|
429
436
|
3. **Batch validation - gather status for all selected changes**
|
|
430
437
|
|
|
438
|
+
Run \`openspec list --json\` once with the same selected-root flags for task
|
|
439
|
+
progress. If the lookup fails, returns invalid JSON, or omits any selected
|
|
440
|
+
change, contains a duplicate selected change, or returns invalid counts,
|
|
441
|
+
report the problem and stop before syncing or archiving the batch.
|
|
442
|
+
|
|
431
443
|
For each selected change, collect:
|
|
432
444
|
|
|
433
445
|
a. **Artifact status** - Run \`openspec status --change "<name>" --json\`
|
|
434
446
|
- Parse \`schemaName\`, \`artifacts\`, \`planningHome\`, \`changeRoot\`, \`artifactPaths\`, and \`actionContext\`
|
|
435
447
|
- Note which artifacts are \`done\` vs other states
|
|
436
448
|
|
|
437
|
-
b. **Task completion** -
|
|
438
|
-
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
-
|
|
449
|
+
b. **Task completion** - Find the \`changes\` entry from the list response whose \`name\` exactly matches this change
|
|
450
|
+
- Require nonnegative integer \`totalTasks\` and \`completedTasks\`, with \`completedTasks <= totalTasks\`
|
|
451
|
+
- Incomplete tasks = \`totalTasks - completedTasks\`
|
|
452
|
+
- The CLI resolves the schema's tracked task files, including custom artifact names, output paths, and globs
|
|
453
|
+
- Do not infer task completion from artifact status, an artifact id of \`tasks\`, or the absence of a top-level \`tasks.md\`
|
|
454
|
+
- The CLI counts only \`x\`/\`X\` checkbox markers as complete; other markers remain incomplete
|
|
455
|
+
- If \`totalTasks\` is zero, note as "No tasks"
|
|
442
456
|
|
|
443
457
|
c. **Delta specs** - Check \`artifactPaths.specs.existingOutputPaths\` from status JSON
|
|
444
458
|
- List which capability specs exist
|
|
@@ -30,7 +30,6 @@ ${PROJECT_ROOT_GUARD}
|
|
|
30
30
|
|
|
31
31
|
When prompting, present the top 3-4 most recently modified changes as options, showing:
|
|
32
32
|
- Change name
|
|
33
|
-
- Schema (from \`schema\` field if present, otherwise "spec-driven")
|
|
34
33
|
- Status (e.g., "0/5 tasks", "complete", "no tasks")
|
|
35
34
|
- How recently it was modified (from \`lastModified\` field)
|
|
36
35
|
|
|
@@ -149,7 +148,6 @@ ${PROJECT_ROOT_GUARD}
|
|
|
149
148
|
|
|
150
149
|
When prompting, present the top 3-4 most recently modified changes as options, showing:
|
|
151
150
|
- Change name
|
|
152
|
-
- Schema (from \`schema\` field if present, otherwise "spec-driven")
|
|
153
151
|
- Status (e.g., "0/5 tasks", "complete", "no tasks")
|
|
154
152
|
- How recently it was modified (from \`lastModified\` field)
|
|
155
153
|
|
|
@@ -128,7 +128,7 @@ openspec list --json
|
|
|
128
128
|
|
|
129
129
|
This tells you:
|
|
130
130
|
- If there are active changes
|
|
131
|
-
- Their names
|
|
131
|
+
- Their names and task status
|
|
132
132
|
- What the user might be working on
|
|
133
133
|
|
|
134
134
|
That is the *change* list - work in flight. It does not include the project's durable capabilities, so list those too:
|
|
@@ -459,7 +459,7 @@ openspec list --json
|
|
|
459
459
|
|
|
460
460
|
This tells you:
|
|
461
461
|
- If there are active changes
|
|
462
|
-
- Their names
|
|
462
|
+
- Their names and task status
|
|
463
463
|
- What the user might be working on
|
|
464
464
|
|
|
465
465
|
That is the *change* list - work in flight. It does not include the project's durable capabilities, so list those too:
|
|
@@ -88,7 +88,7 @@ ${PROJECT_ROOT_GUARD}
|
|
|
88
88
|
- Dependencies are enablers, not gates: if a required artifact is still \`blocked\` only because you skipped a conditional dependency, write it anyway
|
|
89
89
|
- Stop when every artifact in the required set is \`done\`, \`skipped\`, or was deliberately skipped
|
|
90
90
|
|
|
91
|
-
c. **If an artifact requires user input** (unclear context):
|
|
91
|
+
c. **If an artifact requires user input** (critically unclear context):
|
|
92
92
|
- Ask the user to clarify
|
|
93
93
|
- Then continue with creation
|
|
94
94
|
|
|
@@ -206,7 +206,7 @@ ${PROJECT_ROOT_GUARD}
|
|
|
206
206
|
- Dependencies are enablers, not gates: if a required artifact is still \`blocked\` only because you skipped a conditional dependency, write it anyway
|
|
207
207
|
- Stop when every artifact in the required set is \`done\`, \`skipped\`, or was deliberately skipped
|
|
208
208
|
|
|
209
|
-
c. **If an artifact requires user input** (unclear context):
|
|
209
|
+
c. **If an artifact requires user input** (critically unclear context):
|
|
210
210
|
- Ask the user to clarify
|
|
211
211
|
- Then continue with creation
|
|
212
212
|
|
|
@@ -415,7 +415,7 @@ Save to the \`resolvedOutputPath\` from \`openspec instructions design --change
|
|
|
415
415
|
|
|
416
416
|
Finally, we break the work into implementation tasks—checkboxes that drive the apply phase.
|
|
417
417
|
|
|
418
|
-
These should be small, clear, and in logical order.
|
|
418
|
+
These should be small, clear, and in logical order. Each group carries the tests and documentation for its own work - the last group is only for integration checks.
|
|
419
419
|
\`\`\`
|
|
420
420
|
|
|
421
421
|
**DO:** Generate tasks based on specs and design:
|
|
@@ -438,12 +438,17 @@ Here are the implementation tasks:
|
|
|
438
438
|
|
|
439
439
|
---
|
|
440
440
|
|
|
441
|
-
Each checkbox becomes a unit of work in the apply phase.
|
|
441
|
+
Each checkbox becomes a unit of work in the apply phase. Does this task breakdown look right?
|
|
442
442
|
\`\`\`
|
|
443
443
|
|
|
444
|
-
**PAUSE** - Wait for user
|
|
444
|
+
**PAUSE** - Wait for user approval/feedback.
|
|
445
|
+
|
|
446
|
+
After approval, save to the \`resolvedOutputPath\` from \`openspec instructions tasks --change "<name>" --json\`.
|
|
447
|
+
|
|
448
|
+
Then ask:
|
|
449
|
+
> "Tasks are saved. Ready to implement?"
|
|
445
450
|
|
|
446
|
-
|
|
451
|
+
**PAUSE** - Wait for user to confirm before implementation.
|
|
447
452
|
|
|
448
453
|
---
|
|
449
454
|
|