@llman-sdd/cli 0.3.1 → 0.5.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/src/main.ts CHANGED
@@ -1,168 +1,27 @@
1
1
  #!/usr/bin/env bun
2
- import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
3
- import { join, resolve } from 'node:path';
4
-
5
- import {
6
- VERSION,
7
- attachChange,
8
- buildReview,
9
- collectChanges,
10
- collectSpecs,
11
- currentBranch,
12
- defaultBranch,
13
- discoverSpecs,
14
- embeddedTemplates,
15
- graphMermaid,
16
- loadTreeWithAutoRebuild,
17
- makeEmbeddedTemplateIo,
18
- morphologyOfScenarios,
19
- nextReqId,
20
- changeDiff,
21
- deriveChangeId,
22
- finalizeChange,
23
- loadConfig,
24
- newChange,
25
- renderChangesJson,
26
- runContextRetrieval,
27
- renderChangesList,
28
- renderReviewHtml,
29
- renderSpecsJson,
30
- renderSpecsList,
31
- resolveChangeId,
32
- runInit,
33
- scaffoldSpec,
34
- showChangeJson,
35
- startChange,
36
- makeWasmSevenZip,
37
- checkIndexFreshness,
38
- rebuildIndex,
39
- runFreeze,
40
- runList,
41
- runThaw,
42
- validateAllSpecs,
43
- validateCapability,
44
- TEMPLATES_ROOT,
45
- resolveChatConfig,
46
- unavailableResult,
47
- type TemplateIo,
48
- addReq,
49
- addScenario,
50
- archiveChange,
51
- validateChange,
52
- applyStrict,
53
- evaluateStaleness,
54
- buildReqRegistry,
55
- splitVerb,
56
- notApplicableStaleness,
57
- compileChangeIdPattern,
58
- renderChangeIdTemplate,
59
- nextUniqueNumber,
60
- changeDiffInfo,
61
- STAGE_ORDER,
62
- harvestUniqueNumbers,
63
- planDedupe,
64
- resolveReq,
65
- renderConfigOverview,
66
- skillsJson,
67
- type StalenessInfo,
68
- type ChangeIssue,
69
- } from '@llman-sdd/core';
70
- import { Command, Option } from 'commander';
71
-
72
- import { makeCliGit, makeIo } from './io.ts';
73
-
74
- // Injected at binary build time by scripts/build-binary.ts; falls back to the
75
- // package version when running from source.
76
- const version = process.env.LLMAN_SDD_VERSION ?? VERSION;
77
-
78
- // Compiled single-file binaries have no on-disk templates and Bun <= 1.4.x has
79
- // no embedding mechanism, so build-binary.ts injects the template table via
80
- // define; every non-compiled run keeps reading the real filesystem (npm/source/
81
- // Node). Both init and `review --export-html` resolve through this one seam.
82
- const embedded = embeddedTemplates();
83
- const templateIo: TemplateIo = embedded
84
- ? makeEmbeddedTemplateIo(embedded)
85
- : {
86
- exists: (p) => existsSync(p),
87
- readText: (p) => readFileSync(p, 'utf8'),
88
- };
89
-
90
- /** Parse all capability specs under llmanspec/specs via core discovery. */
91
- function loadSpecEntries(): ReturnType<typeof discoverSpecs> {
92
- return discoverSpecs('llmanspec/specs', makeIo(process.cwd()));
93
- }
94
-
95
- function loadCliConfig(): ReturnType<typeof loadConfig> | null {
96
- if (!existsSync('llmanspec/config.yaml')) return null;
97
- const config = loadConfig(readFileSync('llmanspec/config.yaml', 'utf8'));
98
- compileChangeIdPattern(config.change_id?.pattern);
99
- return config;
100
- }
101
-
102
- function cliMaxScanDepth(): number {
103
- const raw = program.opts().maxScanDepth as string | undefined;
104
- const n = raw !== undefined ? Number(raw) : 8;
105
- if (!Number.isInteger(n) || n < 1) {
106
- console.error(`Error: --max-scan-depth must be >= 1 (got ${raw})`);
107
- process.exit(1);
108
- }
109
- return n;
110
- }
111
-
112
- /**
113
- * r61: shared v1-r112 change id resolution for every change-taking command.
114
- * Emits the `(prefix match)` hint on stderr and exits with the resolver's
115
- * error message when resolution fails; returns null after reporting.
116
- */
117
- function resolveChangeIdOrExit(
118
- input: string,
119
- opts: { suppressHint?: boolean } = {},
120
- ): { id: string; viaPrefix: boolean } | null {
121
- try {
122
- const resolved = resolveChangeId(makeIo(process.cwd()), process.cwd(), input, {
123
- maxScanDepth: cliMaxScanDepth(),
124
- });
125
- if (resolved.viaPrefix && opts.suppressHint !== true) {
126
- console.error(`'${input}' -> '${resolved.id}' (prefix match)`);
127
- }
128
- return resolved;
129
- } catch (error) {
130
- console.error((error as Error).message);
131
- process.exitCode = 1;
132
- return null;
133
- }
134
- }
135
-
136
- /** r63: default-branch dirty live specs — workspace-level guard, once per run. */
137
- function warnDirtySpecsOnDefaultBranch(): void {
138
- const git = makeCliGit(process.cwd());
139
- const current = currentBranch(git);
140
- if (current === null || current !== defaultBranch(git)) return;
141
- const out = git.runOpt(['status', '--porcelain', '--', 'llmanspec/specs']) ?? '';
142
- if (out.trim() === '') return;
143
- console.error(
144
- `[WARNING] llmanspec/specs: live specs dirty on default branch \`${current}\`: do not commit unimplemented contracts to the default branch. Switch to the change's bound branch (or \`llman-sdd change start <id>\`) before editing llmanspec/specs/.`,
145
- );
146
- }
147
-
148
- function runValidateSweep(): boolean {
149
- const report = validateAllSpecs(loadSpecEntries(), makeIo(process.cwd()));
150
- return report.verdicts.some((v) => !v.ok);
151
- }
152
-
153
- const SKILL_DESCRIPTIONS: Record<string, string> = {
154
- 'llman-sdd-continue': 'Fill in missing change artifacts',
155
- 'llman-sdd-ff': 'Fast-forward propose: planning shell → Branch binding → Specs landing',
156
- 'llman-sdd-validate': 'Standalone validation skill',
157
- 'llman-sdd-arch-review': 'Scan shallow modules for deepening candidates',
158
- 'llman-sdd-wayfinder': 'Plan large foggy work as a decision map',
159
- 'llman-sdd-research': 'Delegate external research to a background agent',
160
- };
161
- const skillDesc = (name: string): string => SKILL_DESCRIPTIONS[name] ?? '';
162
-
2
+ import { Command } from 'commander';
3
+
4
+ import { CliError, version } from './cli-shared.ts';
5
+ import { registerArchive } from './commands/archive.ts';
6
+ import { registerChange } from './commands/change.ts';
7
+ import { registerConfig } from './commands/config.ts';
8
+ import { registerContext } from './commands/context.ts';
9
+ import { registerGraph } from './commands/graph.ts';
10
+ import { registerIndex } from './commands/index.ts';
11
+ import { registerInit } from './commands/init.ts';
12
+ import { registerList } from './commands/list.ts';
13
+ import { registerProject } from './commands/project.ts';
14
+ import { registerReview } from './commands/review.ts';
15
+ import { registerShow } from './commands/show.ts';
16
+ import { registerSpec } from './commands/spec.ts';
17
+ import { registerValidate } from './commands/validate.ts';
18
+
19
+ // Assembly order is load-bearing: commander children copy _exitCallback at
20
+ // creation time, so exitOverride/configureOutput must run before any
21
+ // `registerXxx` call, and the register calls below must keep the original
22
+ // top-level command order (help text renders in registration order).
163
23
  const program = new Command();
164
24
 
165
- // v1/clap parity for arg-parsing errors: `error: unexpected argument ...` rc=2.
166
25
  // Must run before subcommand registration (children copy _exitCallback at
167
26
  // creation time); exitOverride turns commander's process.exit into a throw.
168
27
  program.exitOverride();
@@ -174,1260 +33,20 @@ program.option(
174
33
  'max depth when scanning llmanspec/changes/ for proposal.md (min 1, default 8)',
175
34
  '8',
176
35
  );
177
- // v1 global flag surface parity: accepted everywhere; v2 has no interactive
178
- // prompts to disable, so it is a no-op.
179
- program.option('--no-interactive', 'disable interactive prompts (accepted for v1 parity)');
180
-
181
- program
182
- .command('init')
183
- .description('Initialize llmanspec in your project (--update to refresh existing)')
184
- .argument('[path]', 'target directory (created when missing; defaults to cwd)')
185
- .option('--update', 'refresh an existing installation')
186
- .option('--locale <locale>', 'locale for generated templates (defaults to config or en)')
187
- .option('--lang <locale>', 'alias of --locale')
188
- .action(
189
- (path: string | undefined, options: { update?: boolean; locale?: string; lang?: string }) => {
190
- if (options.locale !== undefined && options.lang !== undefined) {
191
- console.error('--locale and --lang are mutually exclusive (they are aliases)');
192
- process.exitCode = 1;
193
- return;
194
- }
195
- let root = process.cwd();
196
- if (path !== undefined) {
197
- root = resolve(path);
198
- mkdirSync(root, { recursive: true });
199
- }
200
- const result = runInit(makeIo(root), templateIo, {
201
- update: options.update ?? false,
202
- locale: options.locale ?? options.lang,
203
- version,
204
- });
205
- const removed = result.removed.length > 0 ? `, removed: ${result.removed.join(', ')}` : '';
206
- console.log(`initialized llmanspec (${result.skills.length} skills${removed})`);
207
- },
208
- );
209
36
 
210
- interface VItem {
211
- id: string;
212
- type: string;
213
- valid: boolean;
214
- issues: ChangeIssue[];
215
- durationMs: number;
216
- staleness: StalenessInfo;
217
- matchedViaPrefix: boolean;
218
- }
219
-
220
- function specV1Items(opts: { strict?: boolean }): VItem[] {
221
- const io = makeIo(process.cwd());
222
- const git = makeCliGit(process.cwd());
223
- const entries = discoverSpecs('llmanspec/specs', io);
224
- const registry = buildReqRegistry(entries);
225
- const duplicateIds = new Set(registry.duplicates.flatMap((d) => d.reqId));
226
- const structurallyClean = entries.every((e) => e.doc.errors.length === 0);
227
- const duplicatesFor = (reqId: string): boolean => structurallyClean && duplicateIds.has(reqId);
228
-
229
- const items: VItem[] = [];
230
- for (const entry of entries) {
231
- const cap = entry.doc.header.capability ?? entry.fileName.replace(/\.feature$/u, '');
232
- const verdict = validateCapability(entry as never, duplicatesFor, io, {
233
- strict: opts.strict === true,
234
- });
235
- const specRel = entry.fileName.startsWith('llmanspec/')
236
- ? entry.fileName
237
- : `llmanspec/specs/${entry.fileName}`;
238
- const staleness = evaluateStaleness({
239
- git,
240
- root: process.cwd(),
241
- specRel,
242
- scope: entry.doc.header.scope?.split(',').map((s) => s.trim()) ?? [],
243
- baseRefEnv: process.env.LLMANSPEC_BASE_REF,
244
- });
245
- let issues: ChangeIssue[] = verdict.items.map((i) => ({
246
- level: i.level,
247
- path: i.id,
248
- message: i.message,
249
- }));
250
- if (opts.strict === true) issues = [...issues, ...applyStrict(staleness.issues)];
251
- else issues = [...issues, ...staleness.issues];
252
- items.push({
253
- id: cap,
254
- type: 'spec',
255
- valid: issues.every((i) => i.level !== 'ERROR'),
256
- issues,
257
- durationMs: 0,
258
- staleness: staleness.info,
259
- matchedViaPrefix: false,
260
- });
261
- }
262
- items.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : a.type.localeCompare(b.type)));
263
- return items;
264
- }
265
-
266
- function changeV1Items(names: string[], opts: { stage?: string; strict?: boolean }): VItem[] {
267
- const io = makeIo(process.cwd());
268
- const git = makeCliGit(process.cwd());
269
- const config = loadCliConfig();
270
- const items: VItem[] = [];
271
- for (const name of names) {
272
- const res = validateChange(
273
- io,
274
- process.cwd(),
275
- name,
276
- {
277
- strict_defer: config?.archive?.strict_defer ?? null,
278
- min_completion_ratio: config?.archive?.min_completion_ratio ?? null,
279
- change_id_pattern: config?.change_id?.pattern ?? null,
280
- },
281
- { stage: opts.stage as never, strict: opts.strict === true, git },
282
- );
283
- items.push({
284
- id: name,
285
- type: 'change',
286
- valid: res.valid,
287
- issues: res.issues,
288
- durationMs: 0,
289
- staleness: notApplicableStaleness(),
290
- matchedViaPrefix: false,
291
- });
292
- }
293
- items.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : a.type.localeCompare(b.type)));
294
- return items;
295
- }
296
-
297
- function printStalenessLines(info: StalenessInfo): void {
298
- if (info.status === 'NOTAPPLICABLE') return;
299
- console.log(`Staleness: ${info.status}`);
300
- if (info.touchedPaths.length > 0)
301
- console.log(`Touched scope paths: ${info.touchedPaths.join(', ')}`);
302
- if (info.specUpdated) console.log('Spec file updated since base.');
303
- if (info.dirty) console.log('Working tree is dirty; results may be unreliable.');
304
- for (const note of info.notes) console.log(`Note: ${note}`);
305
- }
306
-
307
- function renderValidateText(items: VItem[]): void {
308
- const passed = items.filter((i) => i.valid).length;
309
- const failed = items.length - passed;
310
- for (const item of items) {
311
- if (item.valid) {
312
- console.log(`OK ${item.type}/${item.id}`);
313
- } else {
314
- console.error(`FAIL ${item.type}/${item.id}`);
315
- for (const issue of item.issues) {
316
- console.error(` [${issue.level}] ${issue.path}: ${issue.message}`);
317
- }
318
- }
319
- if (item.type === 'spec') printStalenessLines(item.staleness);
320
- }
321
- console.log(`Totals: ${passed} passed, ${failed} failed (${items.length} items)`);
322
- }
323
-
324
- function renderValidateJson(items: VItem[], compact: boolean): void {
325
- const types = [...new Set(items.map((i) => i.type))] as string[];
326
- const summary = {
327
- totals: {
328
- items: items.length,
329
- passed: items.filter((i) => i.valid).length,
330
- failed: items.filter((i) => !i.valid).length,
331
- },
332
- byType: types.reduce<Record<string, { items: number; passed: number; failed: number }>>(
333
- (acc, t) => {
334
- const of = items.filter((i) => i.type === t);
335
- acc[t] = {
336
- items: of.length,
337
- passed: of.filter((i) => i.valid).length,
338
- failed: of.filter((i) => !i.valid).length,
339
- };
340
- return acc;
341
- },
342
- {},
343
- ),
344
- };
345
- const out = JSON.stringify({ items, summary, version: '1.0' }, null, compact ? 0 : 2);
346
- console.log(compact ? JSON.stringify(JSON.parse(out)) : out);
347
- }
348
-
349
- const SPEC_NEXT_STEPS = [
350
- '- Ensure spec ISON includes `purpose` and `requirements`',
351
- '- Each requirement MUST include at least one scenario object',
352
- '- Re-run with --json to see structured report',
353
- ];
354
- const CHANGE_NEXT_STEPS = [
355
- '- Edit live single-track specs (`llmanspec/specs/<capability>.feature`) on the feature branch (@human constraints and @executable acceptance share one track, linked via @req); run `llman sdd change start <id>` or `change attach <id>`',
356
- '- Ensure proposal.md, design.md (if needed), and tasks.md are complete before apply',
357
- '- Debug change state: llman sdd show <id> --json --type change',
358
- ];
359
-
360
- program
361
- .command('validate')
362
- .description('Validate specs and changes (structural gates + stage/completion rules)')
363
- .argument('[item]', 'spec id or change id (auto-disambiguated)')
364
- .option('--all', 'validate all specs and all changes')
365
- .option('--changes', 'restrict scope to changes')
366
- .option('--specs', 'restrict scope to specs')
367
- .option('--type <type>', 'force disambiguation: change | spec')
368
- .option('--stage <stage>', 'change stage gate: draft | designed | planned | full')
369
- .option('--strict', 'warnings also make the exit code non-zero')
370
- .option('--json', 'emit {items:[{id,type,valid,issues}]}')
371
- .option('--compact-json', 'single-line --json (requires --json)')
372
- .option('--no-check', 'skip the bdd.run_command check (structural validation only)')
373
- .option('--check', 'run the bdd.run_command check (default when configured; accepted alias)')
374
- .action(
375
- (
376
- item: string | undefined,
377
- options: {
378
- all?: boolean;
379
- changes?: boolean;
380
- specs?: boolean;
381
- type?: string;
382
- stage?: string;
383
- strict?: boolean;
384
- json?: boolean;
385
- compactJson?: boolean;
386
- noCheck?: boolean;
387
- check?: boolean;
388
- },
389
- ) => {
390
- if (options.compactJson && !options.json) {
391
- console.error('--compact-json requires --json');
392
- process.exitCode = 1;
393
- return;
394
- }
395
- warnDirtySpecsOnDefaultBranch();
396
- if (options.type !== undefined && options.type !== 'change' && options.type !== 'spec') {
397
- console.error(`invalid --type: ${options.type}`);
398
- process.exitCode = 1;
399
- return;
400
- }
401
- if (
402
- options.stage !== undefined &&
403
- !(STAGE_ORDER as readonly string[]).includes(options.stage)
404
- ) {
405
- console.error(`invalid --stage: ${options.stage}`);
406
- process.exitCode = 1;
407
- return;
408
- }
409
-
410
- // ---- single item (auto-disambiguate: spec first, then change) ----
411
- if (item !== undefined) {
412
- const entries = discoverSpecs('llmanspec/specs', makeIo(process.cwd()));
413
- const specEntry =
414
- options.type === 'change'
415
- ? undefined
416
- : entries.find(
417
- (e) => (e.doc.header.capability ?? e.fileName.replace(/\.feature$/u, '')) === item,
418
- );
419
- if (specEntry !== undefined) {
420
- const items = specV1Items({ strict: options.strict });
421
- const mine = items.find((i) => i.id === item);
422
- if (mine === undefined) {
423
- console.error(`no spec or change matches: ${item}`);
424
- process.exitCode = 1;
425
- return;
426
- }
427
- if (options.json) {
428
- renderValidateJson([mine], options.compactJson === true);
429
- process.exitCode = mine.valid ? 0 : 1;
430
- return;
431
- }
432
- if (mine.valid) {
433
- console.log(`Specification '${item}' is valid`);
434
- } else {
435
- console.error(`Specification '${item}' has issues`);
436
- for (const issue of mine.issues)
437
- console.error(` [${issue.level}] ${issue.path}: ${issue.message}`);
438
- console.error('Next steps:');
439
- for (const s of SPEC_NEXT_STEPS) console.error(s);
440
- }
441
- if (mine.type === 'spec') printStalenessLines(mine.staleness);
442
- if (!mine.valid) console.error('Error: validation failed');
443
- process.exitCode = mine.valid ? 0 : 1;
444
- return;
445
- }
446
- // change single (r61: v1 r112 prefix resolution on the change id)
447
- const io = makeIo(process.cwd());
448
- const root = process.cwd();
449
- const resolved = resolveChangeIdOrExit(item, { suppressHint: options.json === true });
450
- if (resolved === null) return;
451
- const changeId = resolved.id;
452
- const res = validateChange(
453
- io,
454
- root,
455
- changeId,
456
- {
457
- strict_defer: loadCliConfig()?.archive?.strict_defer ?? null,
458
- min_completion_ratio: loadCliConfig()?.archive?.min_completion_ratio ?? null,
459
- change_id_pattern: loadCliConfig()?.change_id?.pattern ?? null,
460
- },
461
- {
462
- stage: options.stage as never,
463
- strict: options.strict === true,
464
- git: makeCliGit(process.cwd()),
465
- },
466
- );
467
- const infos = res.issues.filter((i) => i.level === 'INFO');
468
- if (options.json) {
469
- renderValidateJson(
470
- [
471
- {
472
- id: changeId,
473
- type: 'change',
474
- valid: res.valid,
475
- issues: res.issues,
476
- durationMs: 0,
477
- staleness: notApplicableStaleness(),
478
- matchedViaPrefix: resolved.viaPrefix,
479
- },
480
- ],
481
- options.compactJson === true,
482
- );
483
- process.exitCode = res.valid ? 0 : 1;
484
- return;
485
- }
486
- if (res.valid) {
487
- console.log(`Change '${changeId}' is valid`);
488
- } else {
489
- console.error(`Change '${changeId}' has issues`);
490
- for (const issue of res.issues.filter((i) => i.level !== 'INFO'))
491
- console.error(` [${issue.level}] ${issue.path}: ${issue.message}`);
492
- console.error('Next steps:');
493
- for (const s of CHANGE_NEXT_STEPS) console.error(s);
494
- }
495
- for (const info of infos) console.error(`[${info.level}] ${info.path}: ${info.message}`);
496
- if (!res.valid) console.error('Error: validation failed');
497
- process.exitCode = res.valid ? 0 : 1;
498
- return;
499
- }
500
-
501
- // ---- bulk ----
502
- const specScope = options.all || !options.changes || options.specs === true;
503
- const changeScope = options.all || options.changes === true;
504
- const defaultSpecsOnly = !options.all && !options.changes;
505
- const effectiveSpecs = defaultSpecsOnly ? true : specScope;
506
- const effectiveChanges = defaultSpecsOnly ? false : changeScope;
507
-
508
- let items: VItem[] = [];
509
- if (effectiveSpecs) items = items.concat(specV1Items({ strict: options.strict }));
510
- if (effectiveChanges) {
511
- const names = collectChanges(makeIo(process.cwd()), process.cwd(), new Date(), {
512
- maxScanDepth: cliMaxScanDepth(),
513
- }).map((c) => c.name);
514
- items = items.concat(
515
- changeV1Items(names, { stage: options.stage, strict: options.strict }),
516
- );
517
- }
518
- items.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : a.type.localeCompare(b.type)));
519
-
520
- if (options.json) {
521
- renderValidateJson(items, options.compactJson === true);
522
- if (items.some((i) => !i.valid)) console.error('Error: validation failed');
523
- process.exitCode = items.some((i) => !i.valid) ? 1 : 0;
524
- return;
525
- }
526
- renderValidateText(items);
527
- if (items.some((i) => !i.valid)) console.error('Error: validation failed');
528
- process.exitCode = items.some((i) => !i.valid) ? 1 : 0;
529
- },
530
- );
531
- const change = program
532
- .command('change')
533
- .description('Change lifecycle: new / start / attach / next-id / diff / finalize');
534
-
535
- change
536
- .command('new')
537
- .description('Create a change draft (exactly one of <id> or --from)')
538
- .argument('[id]')
539
- .option('--from <description>', 'description the id is derived from')
540
- .option('--verb <verb>', 'explicit verb for change_id.template rendering')
541
- .option('--force', 'overwrite an existing proposal.md')
542
- .option('--dry-run', 'print the resulting id without creating anything')
543
- .action(
544
- (
545
- id: string | undefined,
546
- options: { from?: string; verb?: string; force?: boolean; dryRun?: boolean },
547
- ) => {
548
- if ((id === undefined) === (options.from === undefined)) {
549
- console.error('<CHANGE> and --from are mutually exclusive; pass one or the other');
550
- process.exitCode = 1;
551
- return;
552
- }
553
- if (options.dryRun) {
554
- console.log(id ?? deriveChangeId(options.from as string));
555
- return;
556
- }
557
- const cliConfig = loadCliConfig();
558
- const template = cliConfig?.change_id?.template;
559
- if (options.from !== undefined && template) {
560
- const slug = deriveChangeId(options.from);
561
- const { verb, subject } = splitVerb(slug, (options as { verb?: string }).verb);
562
- const derived = renderChangeIdTemplate(template, {
563
- llman_sdd_unique_id: nextUniqueNumber(
564
- {
565
- listDir: (p) => readdirSync(resolve(p)),
566
- isDirectory: (p) => statSync(resolve(p)).isDirectory(),
567
- },
568
- 'llmanspec',
569
- ),
570
- verb,
571
- subject,
572
- date: new Date().toISOString().slice(0, 10),
573
- });
574
- console.log(`derived change id: ${derived}`);
575
- const io = makeIo(process.cwd());
576
- const result = newChange(io, { id: derived, force: options.force });
577
- console.log(`./${result.path}`);
578
- return;
579
- }
580
- const io = makeIo(process.cwd());
581
- const result = newChange(io, { id, from: options.from, force: options.force });
582
- if (options.from !== undefined) console.log(`derived change id: ${result.id}`);
583
- console.log(`./${result.path}`);
584
- },
585
- );
586
-
587
- change
588
- .command('start')
589
- .description('Bind the change to a new feature branch (clean tree + default branch gates)')
590
- .argument('<id>')
591
- .option(
592
- '--branch-prefix <prefix>',
593
- 'feature branch prefix (default: sdd.branch_prefix config, then sdd/)',
594
- )
595
- .action((id: string, options: { branchPrefix?: string }) => {
596
- const resolved = resolveChangeIdOrExit(id);
597
- if (resolved === null) return;
598
- const git = makeCliGit(process.cwd());
599
- const config = loadCliConfig();
600
- const result = startChange(git, makeIo(process.cwd()), resolved.id, {
601
- branchPrefix: options.branchPrefix ?? config?.sdd?.branch_prefix ?? 'sdd/',
602
- });
603
- console.log(
604
- `started change \`${resolved.id}\` → branch \`${result.branch}\` base \`${result.baseSha}\` base-branch \`${result.baseBranch}\``,
605
- );
606
- });
607
-
608
- change
609
- .command('attach')
610
- .description('Bind the change to the current feature branch')
611
- .argument('<id>')
612
- .option('--force', 'rebind an already attached change to the current branch')
613
- .option('--base <branch>', 'explicit fork-point branch to record')
614
- .action((id: string, options: { force?: boolean; base?: string }) => {
615
- const resolved = resolveChangeIdOrExit(id);
616
- if (resolved === null) return;
617
- const result = attachChange(makeCliGit(process.cwd()), makeIo(process.cwd()), resolved.id, {
618
- force: options.force,
619
- base: options.base,
620
- });
621
- console.log(
622
- `attached change \`${resolved.id}\` → branch \`${result.branch}\` base \`${result.baseSha}\` base-branch \`${result.baseBranch}\``,
623
- );
624
- });
625
-
626
- change
627
- .command('next-id')
628
- .description('Preview the next free change id number (read-only)')
629
- .option('--json', 'emit {maxNumber, nextNumber, warnings}')
630
- .action((options: { json?: boolean }) => {
631
- const harvest = harvestUniqueNumbers(
632
- {
633
- listDir: (p) => readdirSync(resolve(p)),
634
- isDirectory: (p) => statSync(resolve(p)).isDirectory(),
635
- },
636
- 'llmanspec',
637
- );
638
- if (options.json) {
639
- console.log(JSON.stringify(harvest, null, 2));
640
- return;
641
- }
642
- if (harvest.maxNumber === null) console.log('no numbered change dirs found in tree');
643
- else console.log(`max number in tree: ${harvest.maxNumber}`);
644
- console.log(`next free number: ${harvest.nextNumber}`);
645
- for (const w of harvest.warnings) console.error(`warning: ${w}`);
646
- });
647
-
648
- change
649
- .command('archive')
650
- .description('Independent seal-off: task gates + merge + archive rename + commit')
651
- .argument('<id>')
652
- .option('--into <branch>', 'target branch to merge into')
653
- .option('--method <method>', 'merge method: squash | ff')
654
- .option('--dry-run', 'print the rename plan only')
655
- .option('--skip-specs', 'legacy flag accepted for v1 parity (no longer merges deltas)')
656
- .addOption(new Option('--force', 'skip task and git gates').hideHelp())
657
- .action(
658
- (
659
- id: string,
660
- options: { into?: string; method?: string; dryRun?: boolean; force?: boolean },
661
- ) => {
662
- if (options.method !== undefined && options.method !== 'squash' && options.method !== 'ff') {
663
- console.error(`invalid --method: ${options.method}`);
664
- process.exitCode = 1;
665
- return;
666
- }
667
- const resolved = resolveChangeIdOrExit(id);
668
- if (resolved === null) return;
669
- id = resolved.id;
670
- const io = makeIo(process.cwd());
671
- if (options.dryRun) {
672
- const date = new Date().toISOString().slice(0, 10);
673
- console.log(
674
- `Would move llmanspec/changes/${id} -> llmanspec/changes/archive/${date}-${id}`,
675
- );
676
- return;
677
- }
678
- const config = existsSync('llmanspec/config.yaml')
679
- ? loadConfig(readFileSync('llmanspec/config.yaml', 'utf8'))
680
- : null;
681
- // v1 task gate: blocked output + options list before the error.
682
- if (!options.force) {
683
- const tasksPath = `llmanspec/changes/${id}/tasks.md`;
684
- if (existsSync(tasksPath)) {
685
- const pending: string[] = [];
686
- for (const line of readFileSync(tasksPath, 'utf8').split('\n')) {
687
- const m = line.match(/^\s*-\s+\[ \]\s*(.*)$/u);
688
- if (m && m[1] !== undefined) pending.push(m[1].trim());
689
- }
690
- if (pending.length > 0) {
691
- console.error(`Archive blocked: ${pending.length} unchecked task(s).`);
692
- for (const item of pending) console.error(` - [ ] ${item}`);
693
- console.error(
694
- 'Options:\n 1. Complete the remaining tasks\n 2. Use --force to archive anyway (not recommended)',
695
- );
696
- throw new Error('archive blocked by unchecked tasks');
697
- }
698
- }
699
- }
700
- const result = archiveChange(makeCliGit(process.cwd()), io, id, {
701
- into: options.into,
702
- method: options.method as 'squash' | 'ff' | undefined,
703
- force: options.force,
704
- minCompletionRatio: config?.archive?.min_completion_ratio ?? undefined,
705
- });
706
- const archiveName = (result.archiveDir ?? '').slice(
707
- (result.archiveDir ?? '').lastIndexOf('/') + 1,
708
- );
709
- console.log(`Change '${id}' archived as '${archiveName}'.`);
710
- },
711
- );
712
-
713
- change
714
- .command('diff')
715
- .description('Print the bound branch diff vs base')
716
- .argument('<id>')
717
- .option('--json', 'emit {change, branch, base, commitCount}')
718
- .option('--export-patch <path>', 'write the diff to a file instead of stdout')
719
- .action((id: string, options: { json?: boolean; exportPatch?: string }) => {
720
- const resolved = resolveChangeIdOrExit(id);
721
- if (resolved === null) return;
722
- id = resolved.id;
723
- const git = makeCliGit(process.cwd());
724
- if (options.json) {
725
- const info = changeDiffInfo(git, makeIo(process.cwd()), id);
726
- console.log(JSON.stringify(info, null, 2));
727
- return;
728
- }
729
- const diff = changeDiff(git, makeIo(process.cwd()), id);
730
- if (options.exportPatch !== undefined) {
731
- writeFileSync(options.exportPatch, diff);
732
- console.log(`wrote ${options.exportPatch}`);
733
- return;
734
- }
735
- console.log(diff);
736
- });
737
-
738
- change
739
- .command('finalize')
740
- .description('Validate, merge the feature branch, archive docs, close out with one commit')
741
- .argument('<id>')
742
- .option('--into <branch>', 'merge target override (defaults to base_branch)')
743
- .option(
744
- '--method <method>',
745
- 'merge method: squash | ff (default: sdd.merge_method config, then squash)',
746
- )
747
- .option('--no-check', 'skip the pre-merge validation sweep')
748
- .option('--no-commit', 'skip the close-out commit (manual/CI history)')
749
- .action(
750
- (
751
- id: string,
752
- options: { into?: string; method?: string; check?: boolean; commit?: boolean },
753
- ) => {
754
- if (options.method !== undefined && options.method !== 'squash' && options.method !== 'ff') {
755
- console.error(`invalid --method: ${options.method}`);
756
- process.exitCode = 1;
757
- return;
758
- }
759
- if (options.check !== false) {
760
- const failed = runValidateSweep();
761
- if (failed) {
762
- console.error('finalize aborted: validation sweep failed (use --no-check to skip)');
763
- process.exitCode = 1;
764
- return;
765
- }
766
- }
767
- const resolved = resolveChangeIdOrExit(id);
768
- if (resolved === null) return;
769
- id = resolved.id;
770
- const config = loadCliConfig();
771
- const method = options.method ?? config?.sdd?.merge_method ?? 'squash';
772
- const result = finalizeChange(makeCliGit(process.cwd()), makeIo(process.cwd()), id, {
773
- into: options.into,
774
- method: method as 'squash' | 'ff',
775
- noCommit: options.commit === false,
776
- });
777
- for (const w of result.warnings) console.error(`[WARNING] ${w}`);
778
- if (options.commit === false) {
779
- console.log(
780
- `finalized \`${id}\` → ${result.archiveDir} on ${result.target} (close-out commit skipped — run: git add -A && git commit -m "archive(sdd): ${id}")`,
781
- );
782
- return;
783
- }
784
- console.log(
785
- `finalized \`${id}\` → ${result.archiveDir} (commit "${result.commitSubject}" on ${result.target})`,
786
- );
787
- },
788
- );
789
-
790
- program
791
- .command('list')
792
- .description('List changes or specs')
793
- .option('--changes', 'list changes (v1 explicit scope flag; default)')
794
- .option('--specs', 'list specs instead of changes')
795
- .option('--json', 'machine-readable output')
796
- .option('--compact-json', 'single-line --json (requires --json)')
797
- .option('--sort <order>', 'recent (mtime desc, default) | name')
798
- .action((options: { specs?: boolean; json?: boolean; compactJson?: boolean; sort?: string }) => {
799
- if (options.compactJson && !options.json) {
800
- console.error('--compact-json requires --json');
801
- process.exitCode = 1;
802
- return;
803
- }
804
- if (options.sort !== undefined && options.sort !== 'recent' && options.sort !== 'name') {
805
- console.error(`invalid --sort: ${options.sort}`);
806
- process.exitCode = 1;
807
- return;
808
- }
809
- const emit = (text: string): void => {
810
- console.log(options.compactJson ? text.replaceAll('\n', '') : text);
811
- };
812
- if (options.specs) {
813
- const summaries = collectSpecs(loadSpecEntries());
814
- emit(options.json ? renderSpecsJson(summaries) : renderSpecsList(summaries).join('\n'));
815
- return;
816
- }
817
- let changes = collectChanges(makeIo(process.cwd()), process.cwd(), new Date(), {
818
- maxScanDepth: cliMaxScanDepth(),
819
- });
820
- if (options.sort === 'name') {
821
- changes = [...changes].toSorted((a, b) => a.name.localeCompare(b.name));
822
- }
823
- emit(
824
- options.json ? renderChangesJson(changes) : renderChangesList(changes, new Date()).join('\n'),
825
- );
826
- });
827
-
828
- program
829
- .command('show')
830
- .description('Show a change or spec')
831
- .argument('<item>')
832
- .option('--output <format>', 'json | compact | meta-only | no-scenarios | deltas | reqs-only')
833
- .option('--type <itemType>', 'item type hint: change|spec')
834
- .option('-r, --requirement <n>', 'spec only: show a specific requirement by 1-based index')
835
- .action((item: string, options: { output?: string; type?: string; requirement?: string }) => {
836
- const outTokens = new Set(
837
- (options.output ?? '')
838
- .split(',')
839
- .map((t) => t.trim())
840
- .filter((t) => t !== ''),
841
- );
842
- const asJson = outTokens.has('json');
843
- const asCompact = outTokens.has('compact');
844
- const metaOnly = outTokens.has('meta-only');
845
- const noScenarios = outTokens.has('no-scenarios');
846
- const reqsOnly = outTokens.has('reqs-only');
847
- // v1: unknown output tokens are rejected by clap; script consumers rely on
848
- // the deprecation being a no-op render rather than an error.
849
- const isSpec =
850
- options.type === 'spec' || existsSync(join('llmanspec', 'specs', `${item}.feature`));
851
-
852
- if (isSpec) {
853
- const specPath = join('llmanspec', 'specs', `${item}.feature`);
854
- if (!existsSync(specPath)) {
855
- console.error(`spec not found: ${item}`);
856
- process.exitCode = 1;
857
- return;
858
- }
859
- if (asJson) {
860
- console.log(
861
- JSON.stringify(
862
- renderSpecJson(item, {
863
- metaOnly,
864
- noScenarios: noScenarios || reqsOnly,
865
- }),
866
- null,
867
- asCompact ? 0 : 2,
868
- ),
869
- );
870
- return;
871
- }
872
- // text mode: v1 ignores all output modifiers (meta-only/no-scenarios/-r)
873
- // and renders the full source + morphology.
874
- const raw = readFileSync(specPath, 'utf8').trimEnd();
875
- const summary = collectSpecs(loadSpecEntries()).find((x) => x.id === item);
876
- const morphology = summary
877
- ? `\n\n## Morphology\nruleCount=${summary.morphology.ruleCount} enforced=${summary.morphology.ruleEnforcedCount} pending=${summary.morphology.rulePendingCount} acceptanceCount=${summary.morphology.acceptanceCount}`
878
- : '';
879
- console.log(`## Spec\n${raw}${morphology}`);
880
- return;
881
- }
882
-
883
- // ---- change ----
884
- // r61: v1 r112 prefix chain — exact > unique prefix > multiple > not found.
885
- const resolved = resolveChangeIdOrExit(item, { suppressHint: asJson });
886
- if (resolved === null) return;
887
- const changeId = resolved.id;
888
- const viaPrefix = resolved.viaPrefix;
889
- const proposal = readFileSync(join('llmanspec', 'changes', changeId, 'proposal.md'), 'utf8');
890
- if (asJson) {
891
- // v1 parse_change gates: Why first, then What Changes (json only).
892
- if (!hasSection(proposal, 'Why')) {
893
- process.exitCode = 1;
894
- throw new Error('Change must have a Why section');
895
- }
896
- if (!hasSection(proposal, 'What Changes')) {
897
- process.exitCode = 1;
898
- throw new Error('Change must have a What Changes section');
899
- }
900
- const result = showChangeJson(
901
- {
902
- io: makeIo(process.cwd()),
903
- git: makeCliGit(process.cwd()),
904
- root: process.cwd(),
905
- specsDir: 'llmanspec/specs',
906
- },
907
- changeId,
908
- { matchedViaPrefix: viaPrefix },
909
- );
910
- console.log(JSON.stringify(result, null, asCompact ? 0 : 2));
911
- return;
912
- }
913
- // text: Stage / path / content / Gates trailer (no section gates).
914
- const changes = collectChanges(makeIo(process.cwd()), process.cwd(), new Date(), {
915
- maxScanDepth: cliMaxScanDepth(),
916
- });
917
- const change = changes.find((c) => c.name === changeId);
918
- console.log(`Stage: ${change?.stage ?? 'draft'}`);
919
- console.log(`path: ${changeId}`);
920
- process.stdout.write(proposal);
921
- if (!proposal.endsWith('\n')) console.log();
922
- const gates = showChangeJson(
923
- {
924
- io: makeIo(process.cwd()),
925
- git: makeCliGit(process.cwd()),
926
- root: process.cwd(),
927
- specsDir: 'llmanspec/specs',
928
- },
929
- changeId,
930
- ).gateChecks as { name: string; pass: boolean; hint: string }[];
931
- const passCount = gates.filter((g) => g.pass).length;
932
- console.log(`Gates: ${passCount}/${gates.length} pass`);
933
- for (const g of gates.filter((g) => !g.pass)) console.log(`✗ ${g.name}: ${g.hint}`);
934
- });
935
-
936
- function hasSection(proposal: string, heading: string): boolean {
937
- return (
938
- new RegExp(`^## ${heading.replace(/[/\\]/u, '')}\\s*$`, 'mu').test(proposal) ||
939
- proposal.includes(`## ${heading}`)
940
- );
941
- }
942
-
943
- function renderSpecJson(
944
- item: string,
945
- opts: { metaOnly: boolean; noScenarios: boolean },
946
- ): Record<string, unknown> {
947
- const entry = loadSpecEntries().find(
948
- (e) => (e.doc.header.capability ?? e.fileName.replace(/\.feature$/u, '')) === item,
949
- );
950
- const doc = entry?.doc as
951
- | {
952
- header: { capability: string | null; purpose: string | null };
953
- scenarios: {
954
- name: string;
955
- classification: string;
956
- reqIds: string[];
957
- statement: string;
958
- steps: { kind: string; text: string }[];
959
- }[];
960
- }
961
- | undefined;
962
- const cap = entry ? (doc?.header.capability ?? item) : item;
963
- const purpose = doc?.header.purpose ?? '';
964
- const humans = doc?.scenarios.filter((s) => s.classification === 'human') ?? [];
965
- const acceptances = doc?.scenarios.filter((s) => s.classification === 'executable') ?? [];
966
- const morphology = morphologyOfScenarios(doc?.scenarios ?? []);
967
- if (opts.metaOnly) {
968
- return {
969
- id: item,
970
- featureId: cap,
971
- title: cap,
972
- purpose,
973
- overview: purpose,
974
- requirementCount: humans.length,
975
- morphology,
976
- };
977
- }
978
- const requirements = humans.map((rule) => ({
979
- reqId: rule.reqIds[0] ?? '',
980
- title: rule.name,
981
- text: rule.statement,
982
- scenarios: opts.noScenarios
983
- ? []
984
- : acceptances
985
- .filter((a) => a.reqIds.some((rid) => rule.reqIds.includes(rid)))
986
- .map((a) => ({
987
- id: a.name,
988
- rawText: `GIVEN: ${a.steps
989
- .filter((s) => s.kind === 'given')
990
- .map((s) => s.text)
991
- .join('\n')}\nWHEN: ${a.steps
992
- .filter((s) => s.kind === 'when')
993
- .map((s) => s.text)
994
- .join('\n')}\nTHEN: ${a.steps
995
- .filter((s) => s.kind === 'then')
996
- .map((s) => s.text)
997
- .join('\n')}`,
998
- source: 'acceptance',
999
- reqIds: a.reqIds,
1000
- })),
1001
- }));
1002
- return {
1003
- id: item,
1004
- title: cap,
1005
- purpose,
1006
- overview: purpose,
1007
- requirementCount: humans.length,
1008
- requirements,
1009
- morphology,
1010
- };
1011
- }
1012
-
1013
- program
1014
- .command('graph')
1015
- .description('Generate a change dependency graph (mermaid)')
1016
- .argument('[change]', 'seed change id (BFS over depends_on)')
1017
- .option('--format <format>', 'output format', 'mermaid')
1018
- .option('--scope <scope>', 'active | archived | all (comma-combined)', 'active')
1019
- .option('--depth <n>', 'seed BFS depth (default: 1)')
1020
- .action(
1021
- (change: string | undefined, options: { format: string; scope?: string; depth?: string }) => {
1022
- if (options.format !== 'mermaid') {
1023
- process.exitCode = 1;
1024
- throw new Error(`Unsupported format: ${options.format}. Supported: mermaid`);
1025
- }
1026
- const depth = options.depth !== undefined ? Number(options.depth) : undefined;
1027
- if (depth !== undefined && (!Number.isInteger(depth) || depth < 0)) {
1028
- console.error(`invalid --depth: ${options.depth}`);
1029
- process.exitCode = 1;
1030
- return;
1031
- }
1032
- console.log(
1033
- graphMermaid(makeIo(process.cwd()), process.cwd(), {
1034
- scope: options.scope,
1035
- depth: depth ?? 1,
1036
- seed: change,
1037
- }).join('\n'),
1038
- );
1039
- },
1040
- );
1041
-
1042
- const spec = program.command('spec').description('Spec authoring helpers');
1043
-
1044
- spec
1045
- .command('skeleton')
1046
- .description('Generate a single-track spec skeleton for a capability')
1047
- .argument('<capability>')
1048
- .option('--force', 'overwrite an existing spec file')
1049
- .action((capability: string, options: { force?: boolean }) => {
1050
- const locale = existsSync('llmanspec/config.yaml')
1051
- ? loadConfig(readFileSync('llmanspec/config.yaml', 'utf8')).locale
1052
- : 'en';
1053
- const path = join('llmanspec', 'specs', `${capability}.feature`);
1054
- if (!options.force && existsSync(path)) {
1055
- console.error(`spec already exists: ${path} (use --force to overwrite)`);
1056
- process.exitCode = 1;
1057
- return;
1058
- }
1059
- const written = scaffoldSpec(makeIo(process.cwd()), 'llmanspec/specs', capability, locale, {
1060
- force: options.force,
1061
- });
1062
- console.log(`wrote ${written}`);
1063
- });
1064
-
1065
- spec
1066
- .command('next-req-id')
1067
- .description('Allocate the next free global req id (rN)')
1068
- .option('--json', 'emit {reqId}')
1069
- .action((options: { json?: boolean }) => {
1070
- const reqId = nextReqId(makeIo(process.cwd()), 'llmanspec/specs');
1071
- if (options.json) console.log(JSON.stringify({ reqId }, null, 2));
1072
- else console.log(reqId);
1073
- });
1074
-
1075
- const project = program.command('project').description('Project management commands');
1076
-
1077
- project
1078
- .command('dedupe-req-ids')
1079
- .description('Remap globally duplicated req ids (report with --dry-run)')
1080
- .option('--dry-run', 'report the remap plan without writing')
1081
- .action((options: { dryRun?: boolean }) => {
1082
- // v1 parity: dedupe registry covers @human (rule) req ids only.
1083
- const entries = loadSpecEntries();
1084
- const owners = new Map<string, string[]>();
1085
- for (const e of entries) {
1086
- for (const sc of e.doc.scenarios) {
1087
- if (sc.classification !== 'human') continue;
1088
- for (const rid of sc.reqIds) {
1089
- const list = owners.get(rid) ?? [];
1090
- if (!list.includes(e.fileName)) list.push(e.fileName);
1091
- owners.set(rid, list);
1092
- }
1093
- }
1094
- }
1095
- const duplicates = [...owners.entries()]
1096
- .filter(([, files]) => files.length > 1)
1097
- .map(([reqId, files]) => ({ reqId, files }));
1098
- if (duplicates.length === 0) {
1099
- console.log('No colliding req_id values in llmanspec/specs.');
1100
- return;
1101
- }
1102
- const io = makeIo(process.cwd());
1103
- const plan = planDedupe(entries, io, 'llmanspec/specs', duplicates);
1104
- // v1 output: `{cap}: {from} → {to}` per remap (prefix in dry-run) + count line.
1105
- for (const item of plan) {
1106
- const cap = item.remapFile.replace(/^.*specs\//u, '').replace(/\.feature$/u, '');
1107
- const prefix = options.dryRun ? '[dry-run] ' : '';
1108
- console.log(`${prefix}${cap}: ${item.reqId} → ${item.newReqId}`);
1109
- }
1110
- console.log(`${plan.length} remapping(s)${options.dryRun ? ' (dry-run)' : ''}`);
1111
- });
1112
-
1113
- project
1114
- .command('migrate')
1115
- .description('Legacy migration entry (informational only)')
1116
- .action(() => {
1117
- console.log('legacy 迁移实现(spec.toon / specs-flatten 等)不随本工具提供;');
1118
- console.log(
1119
- '本工具直接读取既有 llmanspec 布局(config.yaml / specs/*.feature / changes/),零迁移可读。',
1120
- );
1121
- });
1122
-
1123
- const archive = program
1124
- .command('archive')
1125
- .description(
1126
- 'Archive workflow commands (cold backup). Prefer `change finalize` to seal a change',
1127
- );
1128
-
1129
- archive
1130
- .command('freeze')
1131
- .description('Freeze dated archived changes into a single 7z cold backup')
1132
- .option('--before <date>', 'freeze entries older than this date (YYYY-MM-DD)')
1133
- .option('--keep-recent <n>', 'keep N most recent candidates unfrozen', '0')
1134
- .option('--dry-run', 'list candidates without freezing')
1135
- .option('--list', 'list entries already in the cold-backup archive')
1136
- .action(
1137
- async (options: { before?: string; keepRecent: string; dryRun?: boolean; list?: boolean }) => {
1138
- try {
1139
- const sz = await makeWasmSevenZip();
1140
- const root = process.cwd();
1141
- const io = makeIo(root);
1142
- if (options.list) {
1143
- for (const line of await runList(io, sz, root)) console.log(line);
1144
- return;
1145
- }
1146
- const result = await runFreeze(io, sz, root, {
1147
- before: options.before,
1148
- keepRecent: Number(options.keepRecent),
1149
- dryRun: options.dryRun,
1150
- });
1151
- for (const line of result.lines) console.log(line);
1152
- } catch (error) {
1153
- // Emscripten aborts (e.g. wasm load failure) throw raw RuntimeErrors —
1154
- // keep the CLI surface one-line like thaw does.
1155
- console.error((error as Error).message);
1156
- process.exitCode = 1;
1157
- }
1158
- },
1159
- );
1160
-
1161
- archive
1162
- .command('thaw')
1163
- .description('Restore archived change directories from the cold-backup archive')
1164
- .requiredOption(
1165
- '--change <name>',
1166
- 'archived change directory to restore (repeatable)',
1167
- (v: string, prev: string[]) => {
1168
- prev.push(v);
1169
- return prev;
1170
- },
1171
- [] as string[],
1172
- )
1173
- .option('--dest <path>', 'restore into this directory (created when missing)')
1174
- .action(async (options: { change: string[]; dest?: string }) => {
1175
- if (options.dest !== undefined) {
1176
- mkdirSync(resolve(options.dest), { recursive: true });
1177
- }
1178
- const sz = await makeWasmSevenZip();
1179
- const root = process.cwd();
1180
- const io = makeIo(root);
1181
- try {
1182
- const result = await runThaw(io, sz, root, options.change, { dest: options.dest });
1183
- for (const line of result.lines) console.log(line);
1184
- } catch (error) {
1185
- console.error(`Error: ${(error as Error).message}`);
1186
- process.exitCode = 1;
1187
- }
1188
- });
1189
-
1190
- const review = program
1191
- .command('review')
1192
- .description('Aggregate review: pending/unbound/stale signals plus a validate sweep');
1193
-
1194
- review
1195
- .option('--capability <capability>', 'restrict the sweep to one capability/spec id')
1196
- .option('--json', 'emit structured JSON (signals + summary)')
1197
- .option('--export-html <path>', 'write a self-contained HTML report')
1198
- .action((options: { capability?: string; json?: boolean; exportHtml?: string }) => {
1199
- const config = existsSync('llmanspec/config.yaml')
1200
- ? loadConfig(readFileSync('llmanspec/config.yaml', 'utf8'))
1201
- : null;
1202
- const bindings = config?.bdd?.bindings?.filter((b) => b.kind === 'tags') ?? [];
1203
- const io = makeIo(process.cwd());
1204
- const entries = loadSpecEntries();
1205
- if (options.capability !== undefined) {
1206
- const known = new Set(
1207
- entries.map((e) => e.doc.header.capability ?? e.fileName.replace(/\.feature$/u, '')),
1208
- );
1209
- if (!known.has(options.capability)) {
1210
- console.error(`capability \`${options.capability}\` not found`);
1211
- process.exitCode = 1;
1212
- return;
1213
- }
1214
- }
1215
- const activeChanges = collectChanges(io, process.cwd(), new Date());
1216
- const result = buildReview(
1217
- {
1218
- entries,
1219
- bindings: bindings.map((b) => ({ kind: 'tags', tags: b.tags })),
1220
- boundChangeCount: activeChanges.filter((c) => c.hasBinding).length,
1221
- activeChanges,
1222
- capability: options.capability,
1223
- git: makeCliGit(process.cwd()),
1224
- root: process.cwd(),
1225
- specsDir: 'llmanspec/specs',
1226
- },
1227
- io,
1228
- );
1229
- if (options.exportHtml !== undefined) {
1230
- const template = templateIo.readText(join(TEMPLATES_ROOT, 'shared', 'review.html'));
1231
- writeFileSync(options.exportHtml, renderReviewHtml(template, result));
1232
- console.log(`wrote ${options.exportHtml}`);
1233
- }
1234
- if (options.json) {
1235
- console.log(JSON.stringify({ signals: result.signals, summary: result.summary }, null, 2));
1236
- } else {
1237
- console.log(result.lines.join('\n'));
1238
- }
1239
- if (result.exitCode !== 0) process.exitCode = result.exitCode;
1240
- });
1241
-
1242
- spec
1243
- .command('add-req')
1244
- .alias('add-requirement')
1245
- .description('Append a @human rule scenario to a capability spec')
1246
- .argument('<capability>')
1247
- .argument('<req_id>')
1248
- .requiredOption('--title <title>', 'rule title')
1249
- .requiredOption('--statement <statement>', 'rule statement (must contain MUST/SHALL)')
1250
- .action((capability: string, reqId: string, options: { title: string; statement: string }) => {
1251
- const io = makeIo(process.cwd());
1252
- const path = addReq(io, 'llmanspec/specs', loadSpecEntries(), {
1253
- capability,
1254
- reqId,
1255
- title: options.title,
1256
- statement: options.statement,
1257
- });
1258
- console.log(path);
1259
- });
1260
-
1261
- spec
1262
- .command('add-scenario')
1263
- .description('Append an @executable acceptance scenario bound to a req')
1264
- .argument('<capability>')
1265
- .argument('<req_id>')
1266
- .argument('<scenario_id>')
1267
- .option('--given <given>', 'Given step (optional)')
1268
- .requiredOption('--when <when>', 'When step')
1269
- .requiredOption('--then <then>', 'Then step')
1270
- .action(
1271
- (
1272
- capability: string,
1273
- reqId: string,
1274
- scenarioId: string,
1275
- options: { given?: string; when: string; then: string },
1276
- ) => {
1277
- const io = makeIo(process.cwd());
1278
- const path = addScenario(io, 'llmanspec/specs', loadSpecEntries(), {
1279
- capability,
1280
- reqId,
1281
- scenarioId,
1282
- given: options.given,
1283
- when: options.when,
1284
- thenText: options.then,
1285
- });
1286
- console.log(path);
1287
- },
1288
- );
1289
-
1290
- spec
1291
- .command('resolve-req')
1292
- .description('Resolve an rN to its capability and statement')
1293
- .argument('<req_id>')
1294
- .action((reqId: string) => {
1295
- const resolved = resolveReq(loadSpecEntries(), reqId);
1296
- if (resolved === null) {
1297
- console.error(`req id not found: ${reqId}`);
1298
- process.exitCode = 1;
1299
- return;
1300
- }
1301
- console.log(`reqId: ${resolved.reqId}`);
1302
- console.log(`capability: ${resolved.capability}`);
1303
- console.log(`title: ${resolved.title}`);
1304
- console.log(`statement: ${resolved.statement.replaceAll('\n', ' ')}`);
1305
- console.log('harness:');
1306
- for (const h of resolved.harness) console.log(` - ${h}`);
1307
- });
1308
-
1309
- const configCmd = program
1310
- .command('config')
1311
- .description('Project configuration commands (view/edit config.yaml)');
1312
-
1313
- configCmd.description('Print a read-only llmanspec/config.yaml overview').action(() => {
1314
- const source = readFileSync('llmanspec/config.yaml', 'utf8');
1315
- console.log(renderConfigOverview(source).join('\n'));
1316
- });
1317
-
1318
- configCmd
1319
- .command('skills')
1320
- .description('Manage extra_skills (non-interactive)')
1321
- .option('--json', 'emit {enabled, available}')
1322
- .option('--no-interactive', 'print state instead of launching the interactive picker')
1323
- .action((options: { json?: boolean }) => {
1324
- const path = 'llmanspec/config.yaml';
1325
- const info = skillsJson(readFileSync(path, 'utf8'));
1326
- if (options.json) {
1327
- console.log(JSON.stringify(info, null, 2));
1328
- return;
1329
- }
1330
- const enabled = info.enabled;
1331
- const available = info.available;
1332
- console.log('Enabled optional skills:');
1333
- if (enabled.length === 0) console.log(' (none)');
1334
- else for (const s of enabled) console.log(` [x] ${s}`);
1335
- console.log();
1336
- console.log('Available but not enabled:');
1337
- for (const s of available) {
1338
- if (!enabled.includes(s)) console.log(` [ ] ${s} — ${skillDesc(s)}`);
1339
- }
1340
- console.log();
1341
- console.log('(Run without --no-interactive to edit interactively.)');
1342
- });
1343
-
1344
- function resolveBackend(flag: string | undefined): 'pageindex' {
1345
- const chosen = flag ?? process.env.LLMAN_SDD_INDEX_BACKEND ?? 'pageindex';
1346
- if (chosen === 'rag') {
1347
- throw new Error(
1348
- 'Backend `rag` is no longer supported. Use the default pageindex backend instead:\nSet `LLMAN_SDD_INDEX_CHAT_MODEL` to a tool-calling chat model, then\nrun `llman sdd index rebuild`.',
1349
- );
1350
- }
1351
- if (chosen !== 'pageindex') {
1352
- throw new Error(`Unsupported backend: ${chosen}`);
1353
- }
1354
- return 'pageindex';
1355
- }
1356
-
1357
- const indexCmd = program
1358
- .command('index')
1359
- .description('Index management commands (rebuild, check freshness)');
1360
-
1361
- indexCmd
1362
- .command('rebuild')
1363
- .description('Rebuild the pageindex tree from spec IR (no LLM)')
1364
- .option('--backend <name>', 'index backend (pageindex only)')
1365
- .action((options: { backend?: string }) => {
1366
- resolveBackend(options.backend);
1367
- const result = rebuildIndex(makeIo(process.cwd()), 'llmanspec/specs', loadSpecEntries(), {
1368
- chatModel: process.env.LLMAN_SDD_INDEX_CHAT_MODEL ?? '',
1369
- });
1370
- for (const line of result.lines.slice(0, -1)) console.error(line);
1371
- if (result.lines.length > 0) console.log(result.lines.at(-1));
1372
- });
1373
-
1374
- indexCmd
1375
- .command('check')
1376
- .description('Check index freshness without rebuilding')
1377
- .action(() => {
1378
- const result = checkIndexFreshness(makeIo(process.cwd()), 'llmanspec/specs');
1379
- for (const line of result.lines) console.log(line);
1380
- if (!result.fresh) process.exitCode = 1;
1381
- });
1382
-
1383
- program
1384
- .command('context')
1385
- .description('Get specs relevant to a task (agent-oriented, pageindex agentic retrieval)')
1386
- .option('--task <task>', 'natural language task description')
1387
- .option('--paths <paths>', 'comma-separated file paths')
1388
- .option('--top <n>', 'max entries per tier', '5')
1389
- .option('--backend <name>', 'retrieval backend (pageindex only)')
1390
- .action(async (options: { task?: string; paths?: string; top?: string; backend?: string }) => {
1391
- resolveBackend(options.backend);
1392
- if (!options.task && !options.paths) {
1393
- console.error('at least one of --task or --paths is required');
1394
- process.exitCode = 1;
1395
- return;
1396
- }
1397
- const config = resolveChatConfig(process.env as Record<string, string | undefined>);
1398
- // r62: lazy refresh runs BEFORE the chat-model gate (v1 r97 — the index
1399
- // self-heals even when retrieval subsequently fails with api_error).
1400
- const refresh = loadTreeWithAutoRebuild(
1401
- makeIo(process.cwd()),
1402
- 'llmanspec/specs',
1403
- loadSpecEntries(),
1404
- { chatModel: process.env.LLMAN_SDD_INDEX_CHAT_MODEL ?? '' },
1405
- );
1406
- if (refresh.tree === null || refresh.error !== null) {
1407
- const failed = unavailableResult();
1408
- failed.status.errorKind = 'index_rebuild_failed';
1409
- failed.status.qualityNote =
1410
- refresh.error ?? 'index rebuild failed — run `llman-sdd index rebuild`';
1411
- console.log(JSON.stringify(failed, null, 2));
1412
- return;
1413
- }
1414
- const tree = refresh.tree;
1415
- if (config === null) {
1416
- // v1 parity: unavailable/error JSON on stdout, exit 0.
1417
- console.log(JSON.stringify(unavailableResult(), null, 2));
1418
- return;
1419
- }
1420
- const result = await runContextRetrieval({
1421
- config,
1422
- task: options.task ?? '',
1423
- paths: options.paths,
1424
- top: Number(options.top),
1425
- tree,
1426
- readFile: (p) => readFileSync(p, 'utf8'),
1427
- root: process.cwd(),
1428
- });
1429
- console.log(JSON.stringify(result, null, 2));
1430
- });
37
+ registerInit(program);
38
+ registerValidate(program);
39
+ registerChange(program);
40
+ registerList(program);
41
+ registerShow(program);
42
+ registerGraph(program);
43
+ registerSpec(program);
44
+ registerProject(program);
45
+ registerArchive(program);
46
+ registerReview(program);
47
+ registerConfig(program);
48
+ registerIndex(program);
49
+ registerContext(program);
1431
50
 
1432
51
  function commandChain(argv: string[]): string {
1433
52
  let node: Command = program;
@@ -1463,11 +82,16 @@ async function main(): Promise<void> {
1463
82
  // version/help already rendered to stdout; exit code stays 0.
1464
83
  return;
1465
84
  }
85
+
86
+ // commander strips its own `error: ` short prefix — single `Error: ` + rc=2.
87
+ const commanderMsg = String(comErr?.message ?? '');
88
+ const stripCommanderPrefix = (text: string): string => text.replace(/^error:\s*/u, '');
89
+
1466
90
  if (comErr?.code === 'commander.unknownOption') {
1467
- const raw = String(comErr.message ?? '');
1468
- const arg = raw.replace(/^error: unknown option ['"]/u, '').replace(/['"]?\s*$/u, '') ?? '';
91
+ const raw = stripCommanderPrefix(commanderMsg);
92
+ const arg = raw.replace(/^unknown option ['"]/u, '').replace(/['"]?\s*$/u, '') ?? '';
1469
93
  const chain = commandChain(process.argv);
1470
- console.error(`error: unexpected argument '${arg}' found`);
94
+ console.error(`Error: unknown option '${arg}'`);
1471
95
  console.error('');
1472
96
  console.error(`Usage: llman-sdd${chain} [OPTIONS]`);
1473
97
  console.error('');
@@ -1475,10 +99,24 @@ async function main(): Promise<void> {
1475
99
  process.exitCode = 2;
1476
100
  return;
1477
101
  }
102
+ if (comErr?.code === 'commander.unknownCommand') {
103
+ const raw = stripCommanderPrefix(commanderMsg);
104
+ console.error(
105
+ `Error: unknown command '${raw.replace(/^unknown command ['"]/u, '').replace(/['"]?\s*$/u, '')}'`,
106
+ );
107
+ process.exitCode = 2;
108
+ return;
109
+ }
110
+ if (comErr instanceof CliError) {
111
+ const message = stripCommanderPrefix(comErr.message);
112
+ console.error(message.startsWith('Error: ') ? message : `Error: ${message}`);
113
+ process.exitCode = comErr.exitCode;
114
+ return;
115
+ }
1478
116
 
1479
- // v1 parity: expected domain errors surface as a single `Error: <message>`
117
+ // predecessor parity: expected domain errors surface as a single `Error: <message>`
1480
118
  // line on stderr with exit code 1 (no Bun stack trace).
1481
- const message = error instanceof Error ? error.message : String(error);
119
+ const message = stripCommanderPrefix(error instanceof Error ? error.message : String(error));
1482
120
  console.error(message.startsWith('Error: ') ? message : `Error: ${message}`);
1483
121
  process.exitCode = 1;
1484
122
  }