@fission-ai/openspec 1.8.0 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.d.ts +32 -0
- package/dist/cli/index.js +80 -9
- package/dist/commands/change.js +1 -0
- package/dist/commands/config.d.ts +5 -0
- package/dist/commands/config.js +19 -4
- package/dist/commands/feedback.js +28 -7
- package/dist/commands/schema.js +193 -28
- package/dist/commands/validate.d.ts +23 -0
- package/dist/commands/validate.js +148 -4
- package/dist/commands/workflow/schemas.d.ts +2 -0
- package/dist/commands/workflow/schemas.js +9 -2
- package/dist/core/archive.js +74 -10
- package/dist/core/artifact-graph/instruction-loader.js +2 -7
- package/dist/core/artifact-graph/outputs.d.ts +4 -0
- package/dist/core/artifact-graph/outputs.js +7 -0
- package/dist/core/artifact-graph/resolver.d.ts +0 -12
- package/dist/core/artifact-graph/resolver.js +14 -0
- package/dist/core/command-generation/adapters/command-code.d.ts +19 -0
- package/dist/core/command-generation/adapters/command-code.js +34 -0
- package/dist/core/command-generation/adapters/index.d.ts +1 -0
- package/dist/core/command-generation/adapters/index.js +1 -0
- package/dist/core/command-generation/adapters/opencode.d.ts +2 -1
- package/dist/core/command-generation/adapters/opencode.js +13 -2
- package/dist/core/command-generation/registry.js +2 -0
- package/dist/core/completion-tip.d.ts +15 -0
- package/dist/core/completion-tip.js +133 -0
- package/dist/core/completions/command-registry.js +10 -0
- package/dist/core/completions/factory.d.ts +11 -0
- package/dist/core/completions/installers/bash-installer.d.ts +7 -0
- package/dist/core/completions/installers/bash-installer.js +15 -0
- package/dist/core/completions/installers/fish-installer.d.ts +7 -0
- package/dist/core/completions/installers/fish-installer.js +15 -0
- package/dist/core/completions/installers/powershell-installer.d.ts +7 -0
- package/dist/core/completions/installers/powershell-installer.js +15 -0
- package/dist/core/config-prompts.js +19 -10
- package/dist/core/config-schema.d.ts +1 -0
- package/dist/core/config-schema.js +2 -0
- package/dist/core/config.d.ts +1 -0
- package/dist/core/config.js +17 -15
- package/dist/core/global-config.d.ts +2 -0
- package/dist/core/init.d.ts +5 -0
- package/dist/core/init.js +110 -14
- package/dist/core/legacy-cleanup.d.ts +10 -0
- package/dist/core/legacy-cleanup.js +48 -26
- package/dist/core/parsers/requirement-blocks.js +41 -9
- package/dist/core/parsers/requirement-text.d.ts +8 -0
- package/dist/core/parsers/requirement-text.js +4 -3
- package/dist/core/profiles.d.ts +1 -1
- package/dist/core/profiles.js +11 -2
- package/dist/core/root-selection.d.ts +1 -1
- package/dist/core/shared-skill-target.d.ts +15 -0
- package/dist/core/shared-skill-target.js +30 -0
- package/dist/core/specs-apply.js +11 -4
- package/dist/core/templates/workflows/apply-change.js +3 -0
- package/dist/core/templates/workflows/feedback.js +1 -0
- package/dist/core/templates/workflows/onboard.js +4 -4
- package/dist/core/templates/workflows/propose.js +2 -2
- package/dist/core/templates/workflows/store-selection.d.ts +1 -1
- package/dist/core/templates/workflows/store-selection.js +1 -1
- package/dist/core/templates/workflows/update-change.js +4 -4
- package/dist/core/update.js +49 -11
- package/dist/core/validation/task-numbering.d.ts +16 -0
- package/dist/core/validation/task-numbering.js +57 -0
- package/dist/core/validation/validator.d.ts +5 -2
- package/dist/core/validation/validator.js +57 -3
- package/dist/telemetry/index.d.ts +3 -1
- package/dist/telemetry/index.js +10 -3
- package/dist/utils/change-metadata.js +17 -13
- package/dist/utils/change-utils.js +5 -0
- package/dist/utils/interactive.d.ts +30 -1
- package/dist/utils/interactive.js +105 -2
- package/dist/utils/task-progress.d.ts +34 -1
- package/dist/utils/task-progress.js +52 -28
- package/package.json +3 -6
- package/schemas/spec-driven/schema.yaml +17 -7
- package/scripts/postinstall.js +0 -83
|
@@ -7,15 +7,33 @@ import { isInteractive, resolveNoInteractive } from '../utils/interactive.js';
|
|
|
7
7
|
import { getSpecIds } from '../utils/item-discovery.js';
|
|
8
8
|
import { getAvailableChanges } from './workflow/shared.js';
|
|
9
9
|
import { nearestMatches } from '../utils/match.js';
|
|
10
|
+
import { promises as fs } from 'fs';
|
|
11
|
+
import { getTaskProgressDetailForChange } from '../utils/task-progress.js';
|
|
12
|
+
import { FileSystemUtils } from '../utils/file-system.js';
|
|
10
13
|
export class ValidateCommand {
|
|
11
14
|
async execute(itemName, options = {}) {
|
|
12
|
-
const
|
|
15
|
+
const bulk = options.all || options.changes || options.specs;
|
|
16
|
+
const root = await resolveRootForCommand(options, {
|
|
17
|
+
json: options.json,
|
|
18
|
+
...(bulk ? { allowImplicitRoot: false } : {}),
|
|
19
|
+
});
|
|
13
20
|
if (!root) {
|
|
14
21
|
return;
|
|
15
22
|
}
|
|
16
23
|
const interactive = isInteractive(options);
|
|
24
|
+
// Archived-task linting is its own scope: it checks task completion of
|
|
25
|
+
// already-archived changes, not delta specs (whose operations are already
|
|
26
|
+
// applied). Handled before the other bulk flags so `--archived` is explicit
|
|
27
|
+
// and never alters an existing invocation's behavior (#205).
|
|
28
|
+
if (options.archived) {
|
|
29
|
+
await this.runArchivedTaskValidation(root, {
|
|
30
|
+
json: !!options.json,
|
|
31
|
+
noInteractive: resolveNoInteractive(options),
|
|
32
|
+
});
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
17
35
|
// Handle bulk flags first
|
|
18
|
-
if (
|
|
36
|
+
if (bulk) {
|
|
19
37
|
await this.runBulkValidation(root, {
|
|
20
38
|
changes: !!options.all || !!options.changes,
|
|
21
39
|
specs: !!options.all || !!options.specs,
|
|
@@ -145,7 +163,10 @@ export class ValidateCommand {
|
|
|
145
163
|
if (type === 'change') {
|
|
146
164
|
const changeDir = path.join(root.changesDir, id);
|
|
147
165
|
const start = Date.now();
|
|
148
|
-
const report = await validator.validateChangeDeltaSpecs(changeDir, {
|
|
166
|
+
const report = await validator.validateChangeDeltaSpecs(changeDir, {
|
|
167
|
+
mainSpecsDir: root.specsDir,
|
|
168
|
+
projectRoot: root.path,
|
|
169
|
+
});
|
|
149
170
|
const durationMs = Date.now() - start;
|
|
150
171
|
this.printReport('change', id, report, durationMs, opts.json, root);
|
|
151
172
|
// Non-zero exit if invalid (keeps enriched output test semantics)
|
|
@@ -222,7 +243,10 @@ export class ValidateCommand {
|
|
|
222
243
|
queue.push(async () => {
|
|
223
244
|
const start = Date.now();
|
|
224
245
|
const changeDir = path.join(root.changesDir, id);
|
|
225
|
-
const report = await validator.validateChangeDeltaSpecs(changeDir, {
|
|
246
|
+
const report = await validator.validateChangeDeltaSpecs(changeDir, {
|
|
247
|
+
mainSpecsDir: root.specsDir,
|
|
248
|
+
projectRoot: root.path,
|
|
249
|
+
});
|
|
226
250
|
const durationMs = Date.now() - start;
|
|
227
251
|
return { id, type: 'change', valid: report.valid, issues: report.issues, durationMs };
|
|
228
252
|
});
|
|
@@ -322,6 +346,126 @@ export class ValidateCommand {
|
|
|
322
346
|
}
|
|
323
347
|
process.exitCode = failed > 0 ? 1 : 0;
|
|
324
348
|
}
|
|
349
|
+
/**
|
|
350
|
+
* Lists archived change ids from the resolved root's archive directory,
|
|
351
|
+
* mirroring `getArchivedChangeIds` but store-aware (uses `root.archiveDir`
|
|
352
|
+
* rather than a cwd-relative path). Directories only, hidden entries skipped.
|
|
353
|
+
*
|
|
354
|
+
* Only a missing archive directory (ENOENT) is an empty list; a permission
|
|
355
|
+
* error, an I/O error, or an `archive` path that is a file (ENOTDIR) is a real
|
|
356
|
+
* failure and must not read as "no archived changes" — that would let a
|
|
357
|
+
* pre-commit lint pass without inspecting anything (#205).
|
|
358
|
+
*/
|
|
359
|
+
async listArchivedChangeIds(root) {
|
|
360
|
+
try {
|
|
361
|
+
const entries = await fs.readdir(root.archiveDir, { withFileTypes: true });
|
|
362
|
+
return entries
|
|
363
|
+
.filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'))
|
|
364
|
+
.map((entry) => entry.name)
|
|
365
|
+
.sort();
|
|
366
|
+
}
|
|
367
|
+
catch (error) {
|
|
368
|
+
if (error?.code === 'ENOENT')
|
|
369
|
+
return [];
|
|
370
|
+
throw error;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Validates that every archived change has all of its tasks completed.
|
|
375
|
+
*
|
|
376
|
+
* An archived change is expected to be finished; an archived change with
|
|
377
|
+
* unchecked tasks is a real integrity problem the normal validate flow never
|
|
378
|
+
* surfaces, because active-change discovery excludes the archive directory
|
|
379
|
+
* (#205). Reuses the same task-progress counting `status`, `list`, and
|
|
380
|
+
* `archive` rely on, so what counts as a task never forks. Changes with no
|
|
381
|
+
* tasks pass (nothing to complete).
|
|
382
|
+
*/
|
|
383
|
+
async runArchivedTaskValidation(root, opts) {
|
|
384
|
+
// List first (may throw on a real archive-read failure), then start the
|
|
385
|
+
// spinner so a thrown error never leaves a spinner spinning.
|
|
386
|
+
const ids = await this.listArchivedChangeIds(root);
|
|
387
|
+
const spinner = !opts.json && !opts.noInteractive ? ora('Validating archived changes...').start() : undefined;
|
|
388
|
+
// The archive is append-only and can hold thousands of changes; a single
|
|
389
|
+
// run resolves them all under one constant projectRoot (root.path), so
|
|
390
|
+
// memoize the schema→glob lookup to avoid re-parsing the same schema.yaml
|
|
391
|
+
// once per change. The loop is intentionally sequential: the per-change work
|
|
392
|
+
// is dominated by synchronous schema/config resolution, which a promise pool
|
|
393
|
+
// cannot overlap on Node's single thread — a pool would add complexity for
|
|
394
|
+
// no real gain here.
|
|
395
|
+
const schemaGlobCache = new Map();
|
|
396
|
+
const results = [];
|
|
397
|
+
let passed = 0;
|
|
398
|
+
let failed = 0;
|
|
399
|
+
for (const id of ids) {
|
|
400
|
+
const start = Date.now();
|
|
401
|
+
const issues = [];
|
|
402
|
+
try {
|
|
403
|
+
// The explicit root.path override is load-bearing: an archived change
|
|
404
|
+
// lives one directory deeper (changes/archive/<id>), so the default
|
|
405
|
+
// "../../.." projectRoot derivation would be wrong without it.
|
|
406
|
+
const progress = await getTaskProgressDetailForChange(root.archiveDir, id, root.path, schemaGlobCache);
|
|
407
|
+
// A tasks file that exists but cannot be read must fail loudly, not be
|
|
408
|
+
// silently counted as "no tasks" and pass. Report one issue per file,
|
|
409
|
+
// pathed like every other validate issue (POSIX, root-relative).
|
|
410
|
+
for (const file of progress.unreadable) {
|
|
411
|
+
issues.push({
|
|
412
|
+
level: 'ERROR',
|
|
413
|
+
path: FileSystemUtils.toPosixPath(path.relative(root.path, file)),
|
|
414
|
+
message: 'could not read task file',
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
const incomplete = Math.max(progress.total - progress.completed, 0);
|
|
418
|
+
if (incomplete > 0) {
|
|
419
|
+
issues.push({
|
|
420
|
+
level: 'ERROR',
|
|
421
|
+
path: 'tasks.md',
|
|
422
|
+
message: `${incomplete} incomplete task${incomplete === 1 ? '' : 's'} (${progress.completed}/${progress.total} completed)`,
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
catch (error) {
|
|
427
|
+
issues.push({ level: 'ERROR', path: 'tasks.md', message: error?.message || 'Unknown error' });
|
|
428
|
+
}
|
|
429
|
+
const valid = issues.length === 0;
|
|
430
|
+
if (valid)
|
|
431
|
+
passed++;
|
|
432
|
+
else
|
|
433
|
+
failed++;
|
|
434
|
+
results.push({ id, type: 'change', valid, issues, durationMs: Date.now() - start });
|
|
435
|
+
}
|
|
436
|
+
spinner?.stop();
|
|
437
|
+
const summary = {
|
|
438
|
+
totals: { items: results.length, passed, failed },
|
|
439
|
+
byType: { change: summarizeType(results, 'change') },
|
|
440
|
+
};
|
|
441
|
+
if (opts.json) {
|
|
442
|
+
const out = { items: results, summary, version: '1.0', root: toRootOutput(root) };
|
|
443
|
+
console.log(JSON.stringify(out, null, 2));
|
|
444
|
+
process.exitCode = failed > 0 ? 1 : 0;
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
if (results.length === 0) {
|
|
448
|
+
console.log('No archived changes found.');
|
|
449
|
+
process.exitCode = 0;
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
// Use the same `<type>/<id>` prefix bulk validation prints, so the plain
|
|
453
|
+
// output maps to the JSON `type` ('change') and stays greppable the same way.
|
|
454
|
+
for (const res of results) {
|
|
455
|
+
if (res.valid) {
|
|
456
|
+
console.log(`✓ change/${res.id}`);
|
|
457
|
+
}
|
|
458
|
+
else {
|
|
459
|
+
console.error(`✗ change/${res.id}`);
|
|
460
|
+
for (const issue of res.issues) {
|
|
461
|
+
const prefix = issue.level === 'ERROR' ? '✗' : issue.level === 'WARNING' ? '⚠' : 'ℹ';
|
|
462
|
+
console.error(` ${prefix} ${issue.message}`);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
console.log(`Totals: ${summary.totals.passed} passed, ${summary.totals.failed} failed (${summary.totals.items} items)`);
|
|
467
|
+
process.exitCode = failed > 0 ? 1 : 0;
|
|
468
|
+
}
|
|
325
469
|
}
|
|
326
470
|
function summarizeType(results, type) {
|
|
327
471
|
const filtered = results.filter(r => r.type === type);
|
|
@@ -5,12 +5,19 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import chalk from 'chalk';
|
|
7
7
|
import { listSchemasWithInfo } from '../../core/artifact-graph/index.js';
|
|
8
|
+
import { resolveRootForCommand } from '../../core/root-selection.js';
|
|
8
9
|
// -----------------------------------------------------------------------------
|
|
9
10
|
// Command Implementation
|
|
10
11
|
// -----------------------------------------------------------------------------
|
|
11
12
|
export async function schemasCommand(options) {
|
|
12
|
-
const
|
|
13
|
-
|
|
13
|
+
const root = await resolveRootForCommand(options, {
|
|
14
|
+
json: options.json,
|
|
15
|
+
failurePayload: { schemas: [], root: null },
|
|
16
|
+
});
|
|
17
|
+
if (!root) {
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const schemas = listSchemasWithInfo(root.path);
|
|
14
21
|
if (options.json) {
|
|
15
22
|
console.log(JSON.stringify(schemas, null, 2));
|
|
16
23
|
return;
|
package/dist/core/archive.js
CHANGED
|
@@ -10,7 +10,7 @@ import { emitStoreRootBanner, isRootSelectionError, resolveOpenSpecRoot, toRootO
|
|
|
10
10
|
import { findSpecUpdates, buildUpdatedSpec, writeUpdatedSpec, retireSpec, finalizeRetiredSpec, } from './specs-apply.js';
|
|
11
11
|
import { discoverSpecFiles, hasAnyFileUnder } from '../utils/spec-discovery.js';
|
|
12
12
|
import { METADATA_FILENAME, readRetireCapabilitiesMarker, readSkipSpecsMarker } from '../utils/change-metadata.js';
|
|
13
|
-
import { isNonInteractivePromptError } from '../utils/interactive.js';
|
|
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
16
|
function isMissingPathError(error) {
|
|
@@ -46,6 +46,33 @@ export async function isRetirableSpec(specName, rebuilt) {
|
|
|
46
46
|
return (errors.length > 0 &&
|
|
47
47
|
errors.every((issue) => issue.message === VALIDATION_MESSAGES.SPEC_NO_REQUIREMENTS));
|
|
48
48
|
}
|
|
49
|
+
/**
|
|
50
|
+
* How much of one blocking line the abort is willing to show. A line long
|
|
51
|
+
* enough to fill the screen would push the way out of the abort off it.
|
|
52
|
+
*/
|
|
53
|
+
const UNACCOUNTED_LINE_MAX = 200;
|
|
54
|
+
/**
|
|
55
|
+
* The first few lines a retirement would delete without being able to name
|
|
56
|
+
* them, quoted, with a count for the rest. Capped so a long tail cannot bury
|
|
57
|
+
* the rest of the abort.
|
|
58
|
+
*
|
|
59
|
+
* The lines are authored spec content printed verbatim to a terminal, so they
|
|
60
|
+
* get the same treatment as a change directory name (`describeChangeName`): a
|
|
61
|
+
* raw CR could forge a line of its own, and an ESC could redraw the screen.
|
|
62
|
+
* Truncation counts code points so a cut never leaves half a surrogate pair.
|
|
63
|
+
*/
|
|
64
|
+
function describeUnaccountedContent(lines) {
|
|
65
|
+
const shown = lines
|
|
66
|
+
.slice(0, 3)
|
|
67
|
+
.map((line) => {
|
|
68
|
+
const safe = [...line.replace(/[\u0000-\u001f\u007f]/g, '?')];
|
|
69
|
+
const clipped = safe.slice(0, UNACCOUNTED_LINE_MAX).join('');
|
|
70
|
+
return `"${safe.length > UNACCOUNTED_LINE_MAX ? `${clipped}\u2026` : clipped}"`;
|
|
71
|
+
})
|
|
72
|
+
.join(', ');
|
|
73
|
+
const rest = lines.length > 3 ? `, and ${lines.length - 3} more line(s)` : '';
|
|
74
|
+
return `${shown}${rest}`;
|
|
75
|
+
}
|
|
49
76
|
async function isRetirementCandidate(update, built, skipValidation) {
|
|
50
77
|
return (!skipValidation &&
|
|
51
78
|
built.noRequirementBlocks &&
|
|
@@ -202,9 +229,8 @@ function rerunCommand(root, changeName, options) {
|
|
|
202
229
|
* `User force closed the prompt` (#1479).
|
|
203
230
|
*/
|
|
204
231
|
async function confirmOrBlock(prompt, blocked) {
|
|
205
|
-
const { confirm } = await import('@inquirer/prompts');
|
|
206
232
|
try {
|
|
207
|
-
return await
|
|
233
|
+
return await confirmPrompt(prompt);
|
|
208
234
|
}
|
|
209
235
|
catch (error) {
|
|
210
236
|
if (isNonInteractivePromptError(error)) {
|
|
@@ -1253,17 +1279,24 @@ export class ArchiveCommand {
|
|
|
1253
1279
|
const specName = p.update.id;
|
|
1254
1280
|
const report = await new Validator().validateSpecContent(specName, p.rebuilt);
|
|
1255
1281
|
if (!report.valid) {
|
|
1282
|
+
// This run is what emptied the capability, and "no
|
|
1283
|
+
// requirements" is the only thing wrong with the spec that
|
|
1284
|
+
// would be written - so retiring it is what archive would do,
|
|
1285
|
+
// and what stands in the way of that is worth saying. Not
|
|
1286
|
+
// always the *only* fix: a live requirement can be hiding in a
|
|
1287
|
+
// second `## Requirements` section the validator never reaches,
|
|
1288
|
+
// and merging the sections fixes that spec without a deletion.
|
|
1289
|
+
const emptiedByThisRun = p.update.exists &&
|
|
1290
|
+
p.counts.removed > 0 &&
|
|
1291
|
+
p.noRequirementBlocks &&
|
|
1292
|
+
(await isRetirableSpec(specName, p.rebuilt));
|
|
1256
1293
|
// The dead end #1302 describes: the rebuilt spec is unwritable
|
|
1257
1294
|
// for exactly one reason, and retiring the capability is the
|
|
1258
1295
|
// fix - but only the author can authorise deleting the spec, so
|
|
1259
1296
|
// the abort names the marker instead of just rejecting. Says so
|
|
1260
1297
|
// only when the marker is the ONLY thing missing, so it never
|
|
1261
1298
|
// sends someone after a marker that would not have helped.
|
|
1262
|
-
const
|
|
1263
|
-
p.update.exists &&
|
|
1264
|
-
p.counts.removed > 0 &&
|
|
1265
|
-
(await isRetirementCandidate(p.update, p, false));
|
|
1266
|
-
const retirementHint = retirementWouldFix
|
|
1299
|
+
const retirementHint = !retirementDeclared && emptiedByThisRun && p.unaccountedContent.length === 0
|
|
1267
1300
|
? `This change removes the last requirement '${specName}' has. To retire the` +
|
|
1268
1301
|
` capability and delete its spec, add \`retire_capabilities: true\` to the` +
|
|
1269
1302
|
` change's ${METADATA_FILENAME} (alongside its \`schema:\`, which that file` +
|
|
@@ -1272,6 +1305,27 @@ export class ArchiveCommand {
|
|
|
1272
1305
|
? ` The marker present now cannot be honored (${retirementMarker.invalidReason}).`
|
|
1273
1306
|
: '')
|
|
1274
1307
|
: undefined;
|
|
1308
|
+
// #1696: the marker is missing AND the file holds content a
|
|
1309
|
+
// retirement cannot account for, so this abort said nothing at
|
|
1310
|
+
// all - just "must have at least one requirement", with no way
|
|
1311
|
+
// forward. It names the content instead of the marker, on
|
|
1312
|
+
// purpose: the marker is only ever named when adding it would
|
|
1313
|
+
// really let the archive through, and here it would not. Once
|
|
1314
|
+
// the content is resolved the rerun names the marker.
|
|
1315
|
+
const blockedRetirementHint = !retirementDeclared && emptiedByThisRun && p.unaccountedContent.length > 0
|
|
1316
|
+
? `This change removes the last requirement '${specName}' has, so the rebuilt ` +
|
|
1317
|
+
`spec has none left and cannot be written. Retiring the capability is what ` +
|
|
1318
|
+
`archive does instead, and it is refused while the spec holds content the ` +
|
|
1319
|
+
`merge cannot safely account for and deleting the file would take with it: ` +
|
|
1320
|
+
`${describeUnaccountedContent(p.unaccountedContent)}. ` +
|
|
1321
|
+
'Move it into `## Purpose` or a canonical requirement, or delete the spec by hand, then rerun.' +
|
|
1322
|
+
// Said here too, because an author looking at a marker they
|
|
1323
|
+
// believe authorises the deletion should not have to clear
|
|
1324
|
+
// the content first to find out it was never read.
|
|
1325
|
+
(retirementMarker.invalidReason
|
|
1326
|
+
? ` The marker present now cannot be honored (${retirementMarker.invalidReason}).`
|
|
1327
|
+
: '')
|
|
1328
|
+
: undefined;
|
|
1275
1329
|
// The marker was set and retirement was still refused. Saying
|
|
1276
1330
|
// nothing left the author who did exactly what the docs asked
|
|
1277
1331
|
// back in the original dead end with no signal that their
|
|
@@ -1283,13 +1337,13 @@ export class ArchiveCommand {
|
|
|
1283
1337
|
(await isRetirableSpec(specName, p.rebuilt))
|
|
1284
1338
|
? `'${specName}' declares retire_capabilities, but the spec holds content the merge ` +
|
|
1285
1339
|
`cannot safely account for and deleting the file would take with it: ` +
|
|
1286
|
-
`${p.unaccountedContent
|
|
1287
|
-
`${p.unaccountedContent.length > 3 ? `, and ${p.unaccountedContent.length - 3} more line(s)` : ''}. ` +
|
|
1340
|
+
`${describeUnaccountedContent(p.unaccountedContent)}. ` +
|
|
1288
1341
|
'Move it into `## Purpose` or a canonical requirement, or delete the spec by hand.'
|
|
1289
1342
|
: undefined;
|
|
1290
1343
|
if (json) {
|
|
1291
1344
|
throw new ArchiveBlockedError('archive_spec_validation_failed', `Rebuilt spec for '${specName}' failed validation. No files were changed.`, refusalReason ??
|
|
1292
1345
|
retirementHint ??
|
|
1346
|
+
blockedRetirementHint ??
|
|
1293
1347
|
`Run ${withStoreFlag(root, `openspec validate ${specName}`)} after fixing the change deltas.`);
|
|
1294
1348
|
}
|
|
1295
1349
|
console.log(chalk.red(`\nValidation errors in rebuilt spec for ${specName} (will not write changes):`));
|
|
@@ -1301,6 +1355,8 @@ export class ArchiveCommand {
|
|
|
1301
1355
|
}
|
|
1302
1356
|
if (retirementHint)
|
|
1303
1357
|
console.log(chalk.yellow(` → ${retirementHint}`));
|
|
1358
|
+
if (blockedRetirementHint)
|
|
1359
|
+
console.log(chalk.yellow(` → ${blockedRetirementHint}`));
|
|
1304
1360
|
if (refusalReason)
|
|
1305
1361
|
console.log(chalk.yellow(` → ${refusalReason}`));
|
|
1306
1362
|
console.log('Aborted. No files were changed.');
|
|
@@ -1603,6 +1659,14 @@ export class ArchiveCommand {
|
|
|
1603
1659
|
console.log('No active changes found.');
|
|
1604
1660
|
return null;
|
|
1605
1661
|
}
|
|
1662
|
+
// A picker needs a real terminal, and @inquirer's `select` writes ANSI
|
|
1663
|
+
// cursor escapes to stdout even when it is redirected — the same #1526
|
|
1664
|
+
// mechanism the confirm prompts were fixed for. When either stream is not a
|
|
1665
|
+
// TTY, refuse up front with the guidance the caught ExitPromptError would
|
|
1666
|
+
// give, rather than render an escape-spewing menu into a pipe or file.
|
|
1667
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
1668
|
+
throw new ArchiveBlockedError('archive_change_name_required', 'A change name is required: no terminal is available to choose one from a list.', withStoreFlag(root, `openspec archive <change-name> ${rerunFlags(options).join(' ')}`));
|
|
1669
|
+
}
|
|
1606
1670
|
// Build choices with progress inline to avoid duplicate lists
|
|
1607
1671
|
let choices = changeDirs.map(name => ({ name, value: name }));
|
|
1608
1672
|
try {
|
|
@@ -3,7 +3,7 @@ import * as path from 'node:path';
|
|
|
3
3
|
import { getSchemaDir, resolveSchema, listSchemasWithInfo } from './resolver.js';
|
|
4
4
|
import { ArtifactGraph } from './graph.js';
|
|
5
5
|
import { detectCompleted } from './state.js';
|
|
6
|
-
import { resolveArtifactOutputPath, resolveArtifactOutputs } from './outputs.js';
|
|
6
|
+
import { isSpecsArtifactPath, resolveArtifactOutputPath, resolveArtifactOutputs, } from './outputs.js';
|
|
7
7
|
import { readChangeMetadata, resolveSchemaForChange } from '../../utils/change-metadata.js';
|
|
8
8
|
import { FileSystemUtils } from '../../utils/file-system.js';
|
|
9
9
|
import { buildActionContext, buildNextSteps, summarizePlanningHome, } from '../change-status-policy.js';
|
|
@@ -92,12 +92,7 @@ export function loadChangeContext(projectRoot, changeName, schemaName, options =
|
|
|
92
92
|
const skippedArtifacts = new Set();
|
|
93
93
|
if (metadata?.skip_specs) {
|
|
94
94
|
for (const artifact of graph.getAllArtifacts()) {
|
|
95
|
-
|
|
96
|
-
// identically to 'specs/...', so the skip set must too, or validate
|
|
97
|
-
// would honor the marker while instructions tell the agent to create
|
|
98
|
-
// the very files the conflict gate polices.
|
|
99
|
-
const generates = artifact.generates.replace(/^(?:\.\/)+/, '');
|
|
100
|
-
if (generates.startsWith('specs/') && !completed.has(artifact.id)) {
|
|
95
|
+
if (isSpecsArtifactPath(artifact.generates) && !completed.has(artifact.id)) {
|
|
101
96
|
completed.add(artifact.id);
|
|
102
97
|
skippedArtifacts.add(artifact.id);
|
|
103
98
|
}
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
* Checks if a path contains glob pattern characters.
|
|
3
3
|
*/
|
|
4
4
|
export declare function isGlobPattern(pattern: string): boolean;
|
|
5
|
+
/**
|
|
6
|
+
* Returns whether an artifact generates files under the change's specs/ tree.
|
|
7
|
+
*/
|
|
8
|
+
export declare function isSpecsArtifactPath(generates: string): boolean;
|
|
5
9
|
export declare function resolveArtifactOutputPath(changeDir: string, generates: string): string;
|
|
6
10
|
/**
|
|
7
11
|
* Resolves an artifact's output path(s) to concrete files that currently exist.
|
|
@@ -8,6 +8,13 @@ import { FileSystemUtils } from '../../utils/file-system.js';
|
|
|
8
8
|
export function isGlobPattern(pattern) {
|
|
9
9
|
return pattern.includes('*') || pattern.includes('?') || pattern.includes('[');
|
|
10
10
|
}
|
|
11
|
+
/**
|
|
12
|
+
* Returns whether an artifact generates files under the change's specs/ tree.
|
|
13
|
+
*/
|
|
14
|
+
export function isSpecsArtifactPath(generates) {
|
|
15
|
+
const normalized = path.posix.normalize(FileSystemUtils.toPosixPath(generates));
|
|
16
|
+
return normalized.startsWith('specs/');
|
|
17
|
+
}
|
|
11
18
|
export function resolveArtifactOutputPath(changeDir, generates) {
|
|
12
19
|
const outputPath = path.join(changeDir, generates);
|
|
13
20
|
FileSystemUtils.assertPathWithin(changeDir, outputPath);
|
|
@@ -23,18 +23,6 @@ export declare function getUserSchemasDir(): string;
|
|
|
23
23
|
* @returns The path to the project's schemas directory
|
|
24
24
|
*/
|
|
25
25
|
export declare function getProjectSchemasDir(projectRoot: string): string;
|
|
26
|
-
/**
|
|
27
|
-
* Determines whether a directory entry represents a schema directory candidate.
|
|
28
|
-
*
|
|
29
|
-
* Returns true for real directories and for symlinks whose target is a
|
|
30
|
-
* directory. `fs.Dirent.isDirectory()` reports the raw entry type, so a symlink
|
|
31
|
-
* (even one pointing at a directory) has `isDirectory() === false`; we
|
|
32
|
-
* dereference such entries via `fs.statSync` to admit symlinked schema dirs
|
|
33
|
-
* while still rejecting symlinks-to-files and broken/dangling symlinks.
|
|
34
|
-
*
|
|
35
|
-
* @param parentDir - The directory containing the entry
|
|
36
|
-
* @param entry - The directory entry from `fs.readdirSync(..., { withFileTypes: true })`
|
|
37
|
-
*/
|
|
38
26
|
export declare function isSchemaDir(parentDir: string, entry: fs.Dirent): boolean;
|
|
39
27
|
/**
|
|
40
28
|
* Resolves a schema name to its directory path.
|
|
@@ -52,7 +52,21 @@ export function getProjectSchemasDir(projectRoot) {
|
|
|
52
52
|
* @param parentDir - The directory containing the entry
|
|
53
53
|
* @param entry - The directory entry from `fs.readdirSync(..., { withFileTypes: true })`
|
|
54
54
|
*/
|
|
55
|
+
/**
|
|
56
|
+
* Directories `schema fork` creates transiently while swapping a fork into
|
|
57
|
+
* place: a staging copy (`.fork-staging-<rand>`, created via mkdtemp) and a
|
|
58
|
+
* backup of the previous destination (`<name>.fork-backup-<pid>-<ts>`). Either
|
|
59
|
+
* can briefly coexist with real schemas in the schemas dir, so discovery must
|
|
60
|
+
* never surface them. Real schema names are kebab-case (no dots), so excluding
|
|
61
|
+
* these dot-bearing temp names can never hide a legitimate schema.
|
|
62
|
+
*/
|
|
63
|
+
function isOwnedForkTempDir(name) {
|
|
64
|
+
return name.startsWith('.fork-staging-') || name.includes('.fork-backup-');
|
|
65
|
+
}
|
|
55
66
|
export function isSchemaDir(parentDir, entry) {
|
|
67
|
+
if (isOwnedForkTempDir(entry.name)) {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
56
70
|
if (entry.isDirectory()) {
|
|
57
71
|
return true;
|
|
58
72
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Command Code Command Adapter
|
|
3
|
+
*
|
|
4
|
+
* Command Code reads custom slash commands from `.commandcode/commands/`. The
|
|
5
|
+
* command name is the markdown filename without its `.md` extension, so
|
|
6
|
+
* `opsx-<id>.md` registers `/opsx-<id>` — the same flat naming Cursor and
|
|
7
|
+
* OpenCode use. See https://commandcode.ai/docs/reference/slash-commands.
|
|
8
|
+
*/
|
|
9
|
+
import type { ToolCommandAdapter } from '../types.js';
|
|
10
|
+
/**
|
|
11
|
+
* Command Code adapter for command generation.
|
|
12
|
+
* File path: .commandcode/commands/opsx-<id>.md
|
|
13
|
+
* Format: plain Markdown with $ARGUMENTS injected after the input contract
|
|
14
|
+
*
|
|
15
|
+
* Command Code executes the full trimmed file body and substitutes invocation
|
|
16
|
+
* arguments only where the body includes one of its argument placeholders.
|
|
17
|
+
*/
|
|
18
|
+
export declare const commandCodeAdapter: ToolCommandAdapter;
|
|
19
|
+
//# sourceMappingURL=command-code.d.ts.map
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Command Code Command Adapter
|
|
3
|
+
*
|
|
4
|
+
* Command Code reads custom slash commands from `.commandcode/commands/`. The
|
|
5
|
+
* command name is the markdown filename without its `.md` extension, so
|
|
6
|
+
* `opsx-<id>.md` registers `/opsx-<id>` — the same flat naming Cursor and
|
|
7
|
+
* OpenCode use. See https://commandcode.ai/docs/reference/slash-commands.
|
|
8
|
+
*/
|
|
9
|
+
import path from 'path';
|
|
10
|
+
const COMMAND_CODE_INPUT_HEADING = /^\*\*Input\*\*:[^\n]*$/m;
|
|
11
|
+
function injectCommandCodeArgs(body) {
|
|
12
|
+
if (/^\*\*Provided arguments\*\*:\s*(?:\$(?:ARGUMENTS|@)|\$\{(?:ARGUMENTS|@)\})\s*$/m.test(body)) {
|
|
13
|
+
return body;
|
|
14
|
+
}
|
|
15
|
+
return body.replace(COMMAND_CODE_INPUT_HEADING, (heading) => `${heading}\n**Provided arguments**: $ARGUMENTS`);
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Command Code adapter for command generation.
|
|
19
|
+
* File path: .commandcode/commands/opsx-<id>.md
|
|
20
|
+
* Format: plain Markdown with $ARGUMENTS injected after the input contract
|
|
21
|
+
*
|
|
22
|
+
* Command Code executes the full trimmed file body and substitutes invocation
|
|
23
|
+
* arguments only where the body includes one of its argument placeholders.
|
|
24
|
+
*/
|
|
25
|
+
export const commandCodeAdapter = {
|
|
26
|
+
toolId: 'command-code',
|
|
27
|
+
getFilePath(commandId) {
|
|
28
|
+
return path.join('.commandcode', 'commands', `opsx-${commandId}.md`);
|
|
29
|
+
},
|
|
30
|
+
formatFile(content) {
|
|
31
|
+
return `${injectCommandCodeArgs(content.body)}\n`;
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
//# sourceMappingURL=command-code.js.map
|
|
@@ -9,6 +9,7 @@ export { auggieAdapter } from './auggie.js';
|
|
|
9
9
|
export { bobAdapter } from './bob.js';
|
|
10
10
|
export { claudeAdapter } from './claude.js';
|
|
11
11
|
export { clineAdapter } from './cline.js';
|
|
12
|
+
export { commandCodeAdapter } from './command-code.js';
|
|
12
13
|
export { codebuddyAdapter } from './codebuddy.js';
|
|
13
14
|
export { continueAdapter } from './continue.js';
|
|
14
15
|
export { costrictAdapter } from './costrict.js';
|
|
@@ -9,6 +9,7 @@ export { auggieAdapter } from './auggie.js';
|
|
|
9
9
|
export { bobAdapter } from './bob.js';
|
|
10
10
|
export { claudeAdapter } from './claude.js';
|
|
11
11
|
export { clineAdapter } from './cline.js';
|
|
12
|
+
export { commandCodeAdapter } from './command-code.js';
|
|
12
13
|
export { codebuddyAdapter } from './codebuddy.js';
|
|
13
14
|
export { continueAdapter } from './continue.js';
|
|
14
15
|
export { costrictAdapter } from './costrict.js';
|
|
@@ -7,7 +7,8 @@ import type { ToolCommandAdapter } from '../types.js';
|
|
|
7
7
|
/**
|
|
8
8
|
* OpenCode adapter for command generation.
|
|
9
9
|
* File path: .opencode/commands/opsx-<id>.md
|
|
10
|
-
* Frontmatter: description
|
|
10
|
+
* Frontmatter: description. $ARGUMENTS is injected after the complete input
|
|
11
|
+
* contract because OpenCode only passes arguments through explicit placeholders.
|
|
11
12
|
*/
|
|
12
13
|
export declare const opencodeAdapter: ToolCommandAdapter;
|
|
13
14
|
//# sourceMappingURL=opencode.d.ts.map
|
|
@@ -5,10 +5,21 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import path from 'path';
|
|
7
7
|
import { escapeYamlValue } from '../yaml.js';
|
|
8
|
+
const OPENCODE_INPUT_BLOCK = /^\*\*Input\*\*:[^\r\n]*(?:\r?\n(?!\r?\n)[^\r\n]*)*/m;
|
|
9
|
+
const OPENCODE_NO_INPUT = /^\*\*Input\*\*:\s*None required\b/im;
|
|
10
|
+
const OPENCODE_ARGUMENT_PLACEHOLDER = /\$(?:ARGUMENTS\b|[1-9]\d*\b)/;
|
|
11
|
+
function injectOpenCodeArgs(body) {
|
|
12
|
+
if (OPENCODE_ARGUMENT_PLACEHOLDER.test(body) || OPENCODE_NO_INPUT.test(body)) {
|
|
13
|
+
return body;
|
|
14
|
+
}
|
|
15
|
+
const eol = body.includes('\r\n') ? '\r\n' : '\n';
|
|
16
|
+
return body.replace(OPENCODE_INPUT_BLOCK, (input) => `${input}${eol}**Provided arguments**: $ARGUMENTS`);
|
|
17
|
+
}
|
|
8
18
|
/**
|
|
9
19
|
* OpenCode adapter for command generation.
|
|
10
20
|
* File path: .opencode/commands/opsx-<id>.md
|
|
11
|
-
* Frontmatter: description
|
|
21
|
+
* Frontmatter: description. $ARGUMENTS is injected after the complete input
|
|
22
|
+
* contract because OpenCode only passes arguments through explicit placeholders.
|
|
12
23
|
*/
|
|
13
24
|
export const opencodeAdapter = {
|
|
14
25
|
toolId: 'opencode',
|
|
@@ -20,7 +31,7 @@ export const opencodeAdapter = {
|
|
|
20
31
|
description: ${escapeYamlValue(content.description)}
|
|
21
32
|
---
|
|
22
33
|
|
|
23
|
-
${content.body}
|
|
34
|
+
${injectOpenCodeArgs(content.body)}
|
|
24
35
|
`;
|
|
25
36
|
},
|
|
26
37
|
};
|
|
@@ -10,6 +10,7 @@ import { auggieAdapter } from './adapters/auggie.js';
|
|
|
10
10
|
import { bobAdapter } from './adapters/bob.js';
|
|
11
11
|
import { claudeAdapter } from './adapters/claude.js';
|
|
12
12
|
import { clineAdapter } from './adapters/cline.js';
|
|
13
|
+
import { commandCodeAdapter } from './adapters/command-code.js';
|
|
13
14
|
import { devinAdapter } from './adapters/devin.js';
|
|
14
15
|
import { codebuddyAdapter } from './adapters/codebuddy.js';
|
|
15
16
|
import { continueAdapter } from './adapters/continue.js';
|
|
@@ -45,6 +46,7 @@ export class CommandAdapterRegistry {
|
|
|
45
46
|
CommandAdapterRegistry.register(bobAdapter);
|
|
46
47
|
CommandAdapterRegistry.register(claudeAdapter);
|
|
47
48
|
CommandAdapterRegistry.register(clineAdapter);
|
|
49
|
+
CommandAdapterRegistry.register(commandCodeAdapter);
|
|
48
50
|
CommandAdapterRegistry.register(devinAdapter);
|
|
49
51
|
CommandAdapterRegistry.register(codebuddyAdapter);
|
|
50
52
|
CommandAdapterRegistry.register(continueAdapter);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export declare const COMPLETION_TIP_MESSAGE = "Tip: Run 'openspec completion install' for shell completions";
|
|
2
|
+
export interface CompletionTipOptions {
|
|
3
|
+
/**
|
|
4
|
+
* Skip printing without marking the tip as seen, so it still appears on the
|
|
5
|
+
* user's first later run that can safely carry it. Used for runs nobody would
|
|
6
|
+
* read the tip from: JSON output, and stderr that is not a terminal.
|
|
7
|
+
*/
|
|
8
|
+
silent?: boolean;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Print the completion tip once, the first time the CLI runs.
|
|
12
|
+
* Never throws — a hint must not break a command.
|
|
13
|
+
*/
|
|
14
|
+
export declare function maybeShowCompletionTip(options?: CompletionTipOptions): Promise<void>;
|
|
15
|
+
//# sourceMappingURL=completion-tip.d.ts.map
|