@llman-sdd/cli 0.1.4 → 0.3.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.
Files changed (2) hide show
  1. package/package.json +4 -5
  2. package/src/main.ts +1143 -123
package/src/main.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  #!/usr/bin/env bun
2
- import { spawnSync } from 'node:child_process';
3
- import { existsSync, readFileSync, writeFileSync } from 'node:fs';
4
- import { join } from 'node:path';
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
3
+ import { join, resolve } from 'node:path';
5
4
 
6
5
  import {
7
6
  VERSION,
@@ -9,16 +8,19 @@ import {
9
8
  buildReview,
10
9
  collectChanges,
11
10
  collectSpecs,
11
+ currentBranch,
12
+ defaultBranch,
12
13
  discoverSpecs,
13
14
  embeddedTemplates,
14
15
  graphMermaid,
16
+ loadTreeWithAutoRebuild,
15
17
  makeEmbeddedTemplateIo,
18
+ morphologyOfScenarios,
16
19
  nextReqId,
17
20
  changeDiff,
18
21
  deriveChangeId,
19
22
  finalizeChange,
20
23
  loadConfig,
21
- loadTree,
22
24
  newChange,
23
25
  renderChangesJson,
24
26
  runContextRetrieval,
@@ -26,6 +28,7 @@ import {
26
28
  renderReviewHtml,
27
29
  renderSpecsJson,
28
30
  renderSpecsList,
31
+ resolveChangeId,
29
32
  runInit,
30
33
  scaffoldSpec,
31
34
  showChangeJson,
@@ -37,12 +40,34 @@ import {
37
40
  runList,
38
41
  runThaw,
39
42
  validateAllSpecs,
43
+ validateCapability,
40
44
  TEMPLATES_ROOT,
41
45
  resolveChatConfig,
42
46
  unavailableResult,
43
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,
44
69
  } from '@llman-sdd/core';
45
- import { Command } from 'commander';
70
+ import { Command, Option } from 'commander';
46
71
 
47
72
  import { makeCliGit, makeIo } from './io.ts';
48
73
 
@@ -67,51 +92,442 @@ function loadSpecEntries(): ReturnType<typeof discoverSpecs> {
67
92
  return discoverSpecs('llmanspec/specs', makeIo(process.cwd()));
68
93
  }
69
94
 
70
- function runValidateSpecs(options: { specs?: boolean; check: boolean }): number {
71
- const report = validateAllSpecs(loadSpecEntries(), makeIo(process.cwd()));
72
- for (const line of report.lines) console.log(line);
73
-
74
- let failed = report.failed;
75
- if (options.check && existsSync('llmanspec/config.yaml')) {
76
- const config = loadConfig(readFileSync('llmanspec/config.yaml', 'utf8'));
77
- const runCommand = config.bdd?.run_command;
78
- if (runCommand) {
79
- const proc = spawnSync(runCommand, { shell: true, stdio: 'inherit' });
80
- if ((proc.status ?? 1) !== 0) failed = true;
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)`);
81
127
  }
128
+ return resolved;
129
+ } catch (error) {
130
+ console.error((error as Error).message);
131
+ process.exitCode = 1;
132
+ return null;
82
133
  }
83
- return failed ? 1 : 0;
84
134
  }
85
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
+
86
163
  const program = new Command();
87
164
 
165
+ // v1/clap parity for arg-parsing errors: `error: unexpected argument ...` rc=2.
166
+ // Must run before subcommand registration (children copy _exitCallback at
167
+ // creation time); exitOverride turns commander's process.exit into a throw.
168
+ program.exitOverride();
169
+ program.configureOutput({ outputError: () => {} });
170
+
88
171
  program.name('llman-sdd').description('Spec-driven development workflow').version(version);
172
+ program.option(
173
+ '--max-scan-depth <n>',
174
+ 'max depth when scanning llmanspec/changes/ for proposal.md (min 1, default 8)',
175
+ '8',
176
+ );
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)');
89
180
 
90
181
  program
91
182
  .command('init')
92
183
  .description('Initialize llmanspec in your project (--update to refresh existing)')
184
+ .argument('[path]', 'target directory (created when missing; defaults to cwd)')
93
185
  .option('--update', 'refresh an existing installation')
94
186
  .option('--locale <locale>', 'locale for generated templates (defaults to config or en)')
95
- .action((options: { update?: boolean; locale?: string }) => {
96
- const result = runInit(makeIo(process.cwd()), templateIo, {
97
- update: options.update ?? false,
98
- locale: options.locale,
99
- version,
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
+
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,
100
234
  });
101
- const removed = result.removed.length > 0 ? `, removed: ${result.removed.join(', ')}` : '';
102
- console.log(`initialized llmanspec (${result.skills.length} skills${removed})`);
103
- });
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
+ ];
104
359
 
105
360
  program
106
361
  .command('validate')
107
- .description('Validate specs under llmanspec/specs (structural gates)')
108
- .option('--specs', 'validate specs (default and only scope for now)')
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)')
109
372
  .option('--no-check', 'skip the bdd.run_command check (structural validation only)')
110
- .action((options: { specs?: boolean; check: boolean }) => {
111
- // Node 下管道 stdout 写入异步,process.exit 会截断输出;exitCode 等价且安全。
112
- process.exitCode = runValidateSpecs(options);
113
- });
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)));
114
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
+ );
115
531
  const change = program
116
532
  .command('change')
117
533
  .description('Change lifecycle: new / start / attach / next-id / diff / finalize');
@@ -121,151 +537,507 @@ change
121
537
  .description('Create a change draft (exactly one of <id> or --from)')
122
538
  .argument('[id]')
123
539
  .option('--from <description>', 'description the id is derived from')
124
- .action((id: string | undefined, options: { from?: string }) => {
125
- if ((id === undefined) === (options.from === undefined)) {
126
- console.error('<CHANGE> and --from are mutually exclusive; pass one or the other');
127
- process.exitCode = 1;
128
- return;
129
- }
130
- const io = makeIo(process.cwd());
131
- const result = newChange(io, { id, from: options.from });
132
- if (options.from !== undefined) console.log(`derived change id: ${result.id}`);
133
- console.log(result.path);
134
- });
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
+ );
135
586
 
136
587
  change
137
588
  .command('start')
138
589
  .description('Bind the change to a new feature branch (clean tree + default branch gates)')
139
590
  .argument('<id>')
140
- .option('--branch-prefix <prefix>', 'feature branch prefix', 'sdd/')
141
- .action((id: string, options: { branchPrefix: string }) => {
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;
142
598
  const git = makeCliGit(process.cwd());
143
- const result = startChange(git, makeIo(process.cwd()), id, {
144
- branchPrefix: options.branchPrefix,
599
+ const config = loadCliConfig();
600
+ const result = startChange(git, makeIo(process.cwd()), resolved.id, {
601
+ branchPrefix: options.branchPrefix ?? config?.sdd?.branch_prefix ?? 'sdd/',
145
602
  });
146
603
  console.log(
147
- `started change \`${id}\` → branch \`${result.branch}\` (base ${result.baseBranch}@${result.baseSha.slice(0, 7)})`,
604
+ `started change \`${resolved.id}\` → branch \`${result.branch}\` base \`${result.baseSha}\` base-branch \`${result.baseBranch}\``,
148
605
  );
149
606
  });
150
607
 
151
608
  change
152
609
  .command('attach')
153
- .description('Bind the change to the current branch (no gates)')
610
+ .description('Bind the change to the current feature branch')
154
611
  .argument('<id>')
155
- .action((id: string) => {
156
- const result = attachChange(makeCliGit(process.cwd()), makeIo(process.cwd()), id);
157
- console.log(`attached change \`${id}\` → branch \`${result.branch}\``);
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
+ );
158
624
  });
159
625
 
160
626
  change
161
627
  .command('next-id')
162
- .description('Preview the derived change id for a description')
163
- .requiredOption('--from <description>', 'description the id is derived from')
164
- .action((options: { from: string }) => {
165
- console.log(deriveChangeId(options.from));
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}`);
166
646
  });
167
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
+
168
713
  change
169
714
  .command('diff')
170
715
  .description('Print the bound branch diff vs base')
171
716
  .argument('<id>')
172
- .action((id: string) => {
173
- console.log(changeDiff(makeCliGit(process.cwd()), makeIo(process.cwd()), 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);
174
736
  });
175
737
 
176
738
  change
177
739
  .command('finalize')
178
- .description('Merge the feature branch, archive docs, and close out with one commit')
740
+ .description('Validate, merge the feature branch, archive docs, close out with one commit')
179
741
  .argument('<id>')
180
742
  .option('--into <branch>', 'merge target override (defaults to base_branch)')
181
- .option('--method <method>', 'merge method: squash (default) or ff', 'squash')
182
- .action((id: string, options: { into?: string; method: string }) => {
183
- if (options.method !== 'squash' && options.method !== 'ff') {
184
- console.error(`invalid --method: ${options.method}`);
185
- process.exitCode = 1;
186
- return;
187
- }
188
- const result = finalizeChange(makeCliGit(process.cwd()), makeIo(process.cwd()), id, {
189
- into: options.into,
190
- method: options.method,
191
- });
192
- for (const w of result.warnings) console.error(`[WARNING] ${w}`);
193
- console.log(
194
- `finalized \`${id}\` → ${result.archiveDir} (commit "${result.commitSubject}" on ${result.target})`,
195
- );
196
- });
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
+ );
197
789
 
198
790
  program
199
791
  .command('list')
200
792
  .description('List changes or specs')
793
+ .option('--changes', 'list changes (v1 explicit scope flag; default)')
201
794
  .option('--specs', 'list specs instead of changes')
202
795
  .option('--json', 'machine-readable output')
203
- .action((options: { specs?: boolean; json?: boolean }) => {
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
+ };
204
812
  if (options.specs) {
205
813
  const summaries = collectSpecs(loadSpecEntries());
206
- console.log(
207
- options.json ? renderSpecsJson(summaries) : renderSpecsList(summaries).join('\n'),
208
- );
814
+ emit(options.json ? renderSpecsJson(summaries) : renderSpecsList(summaries).join('\n'));
209
815
  return;
210
816
  }
211
- const changes = collectChanges(makeIo(process.cwd()), process.cwd(), new Date());
212
- console.log(
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(
213
824
  options.json ? renderChangesJson(changes) : renderChangesList(changes, new Date()).join('\n'),
214
825
  );
215
826
  });
216
827
 
217
828
  program
218
829
  .command('show')
219
- .description('Show a change (JSON) or a spec (text)')
830
+ .description('Show a change or spec')
220
831
  .argument('<item>')
221
- .option('--output <format>', 'output format: json, meta-only, reqs-only, no-scenarios')
832
+ .option('--output <format>', 'json | compact | meta-only | no-scenarios | deltas | reqs-only')
222
833
  .option('--type <itemType>', 'item type hint: change|spec')
223
- .action((item: string, options: { output?: string; type?: string }) => {
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.
224
849
  const isSpec =
225
850
  options.type === 'spec' || existsSync(join('llmanspec', 'specs', `${item}.feature`));
851
+
226
852
  if (isSpec) {
227
- const path = join('llmanspec', 'specs', `${item}.feature`);
228
- if (!existsSync(path)) {
853
+ const specPath = join('llmanspec', 'specs', `${item}.feature`);
854
+ if (!existsSync(specPath)) {
229
855
  console.error(`spec not found: ${item}`);
230
856
  process.exitCode = 1;
231
857
  return;
232
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();
233
875
  const summary = collectSpecs(loadSpecEntries()).find((x) => x.id === item);
234
876
  const morphology = summary
235
- ? `\n\n## Morphology\nruleCount=${summary.morphology.ruleCount} enforced=${summary.morphology.ruleEnforcedCount} manual=${summary.morphology.ruleManualCount} pending=${summary.morphology.rulePendingCount} acceptanceCount=${summary.morphology.acceptanceCount}`
877
+ ? `\n\n## Morphology\nruleCount=${summary.morphology.ruleCount} enforced=${summary.morphology.ruleEnforcedCount} pending=${summary.morphology.rulePendingCount} acceptanceCount=${summary.morphology.acceptanceCount}`
236
878
  : '';
237
- console.log(`## Spec\n${readFileSync(path, 'utf8').trimEnd()}${morphology}`);
879
+ console.log(`## Spec\n${raw}${morphology}`);
238
880
  return;
239
881
  }
240
- if (options.output !== 'json') {
241
- console.error('only --output json is supported for changes (text format pending)');
242
- process.exitCode = 1;
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));
243
911
  return;
244
912
  }
245
- const result = showChangeJson(
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(
246
923
  {
247
924
  io: makeIo(process.cwd()),
248
925
  git: makeCliGit(process.cwd()),
249
926
  root: process.cwd(),
250
927
  specsDir: 'llmanspec/specs',
251
928
  },
252
- item,
253
- );
254
- console.log(JSON.stringify(result, null, 2));
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}`);
255
934
  });
256
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
+
257
1013
  program
258
1014
  .command('graph')
259
1015
  .description('Generate a change dependency graph (mermaid)')
1016
+ .argument('[change]', 'seed change id (BFS over depends_on)')
260
1017
  .option('--format <format>', 'output format', 'mermaid')
261
- .action((options: { format: string }) => {
262
- if (options.format !== 'mermaid') {
263
- console.error(`unsupported format: ${options.format}`);
264
- process.exitCode = 1;
265
- return;
266
- }
267
- console.log(graphMermaid(makeIo(process.cwd()), process.cwd()).join('\n'));
268
- });
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
+ );
269
1041
 
270
1042
  const spec = program.command('spec').description('Spec authoring helpers');
271
1043
 
@@ -273,23 +1045,71 @@ spec
273
1045
  .command('skeleton')
274
1046
  .description('Generate a single-track spec skeleton for a capability')
275
1047
  .argument('<capability>')
276
- .action((capability: string) => {
1048
+ .option('--force', 'overwrite an existing spec file')
1049
+ .action((capability: string, options: { force?: boolean }) => {
277
1050
  const locale = existsSync('llmanspec/config.yaml')
278
1051
  ? loadConfig(readFileSync('llmanspec/config.yaml', 'utf8')).locale
279
1052
  : 'en';
280
- const path = scaffoldSpec(makeIo(process.cwd()), 'llmanspec/specs', capability, locale);
281
- console.log(`wrote ${path}`);
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}`);
282
1063
  });
283
1064
 
284
1065
  spec
285
1066
  .command('next-req-id')
286
1067
  .description('Allocate the next free global req id (rN)')
287
- .action(() => {
288
- console.log(nextReqId(makeIo(process.cwd()), 'llmanspec/specs'));
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);
289
1073
  });
290
1074
 
291
1075
  const project = program.command('project').description('Project management commands');
292
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
+
293
1113
  project
294
1114
  .command('migrate')
295
1115
  .description('Legacy migration entry (informational only)')
@@ -350,22 +1170,26 @@ archive
350
1170
  },
351
1171
  [] as string[],
352
1172
  )
353
- .action(async (options: { change: string[] }) => {
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
+ }
354
1178
  const sz = await makeWasmSevenZip();
355
1179
  const root = process.cwd();
356
1180
  const io = makeIo(root);
357
1181
  try {
358
- const result = await runThaw(io, sz, root, options.change);
1182
+ const result = await runThaw(io, sz, root, options.change, { dest: options.dest });
359
1183
  for (const line of result.lines) console.log(line);
360
1184
  } catch (error) {
361
- console.error((error as Error).message);
1185
+ console.error(`Error: ${(error as Error).message}`);
362
1186
  process.exitCode = 1;
363
1187
  }
364
1188
  });
365
1189
 
366
1190
  const review = program
367
1191
  .command('review')
368
- .description('Aggregate review: pending/manual/unbound/stale signals plus a validate sweep');
1192
+ .description('Aggregate review: pending/unbound/stale signals plus a validate sweep');
369
1193
 
370
1194
  review
371
1195
  .option('--capability <capability>', 'restrict the sweep to one capability/spec id')
@@ -377,13 +1201,28 @@ review
377
1201
  : null;
378
1202
  const bindings = config?.bdd?.bindings?.filter((b) => b.kind === 'tags') ?? [];
379
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
+ }
380
1215
  const activeChanges = collectChanges(io, process.cwd(), new Date());
381
1216
  const result = buildReview(
382
1217
  {
383
- entries: loadSpecEntries(),
1218
+ entries,
384
1219
  bindings: bindings.map((b) => ({ kind: 'tags', tags: b.tags })),
385
1220
  boundChangeCount: activeChanges.filter((c) => c.hasBinding).length,
386
1221
  activeChanges,
1222
+ capability: options.capability,
1223
+ git: makeCliGit(process.cwd()),
1224
+ root: process.cwd(),
1225
+ specsDir: 'llmanspec/specs',
387
1226
  },
388
1227
  io,
389
1228
  );
@@ -400,6 +1239,121 @@ review
400
1239
  if (result.exitCode !== 0) process.exitCode = result.exitCode;
401
1240
  });
402
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
+
403
1357
  const indexCmd = program
404
1358
  .command('index')
405
1359
  .description('Index management commands (rebuild, check freshness)');
@@ -407,11 +1361,14 @@ const indexCmd = program
407
1361
  indexCmd
408
1362
  .command('rebuild')
409
1363
  .description('Rebuild the pageindex tree from spec IR (no LLM)')
410
- .action(() => {
1364
+ .option('--backend <name>', 'index backend (pageindex only)')
1365
+ .action((options: { backend?: string }) => {
1366
+ resolveBackend(options.backend);
411
1367
  const result = rebuildIndex(makeIo(process.cwd()), 'llmanspec/specs', loadSpecEntries(), {
412
1368
  chatModel: process.env.LLMAN_SDD_INDEX_CHAT_MODEL ?? '',
413
1369
  });
414
- for (const line of result.lines) console.log(line);
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));
415
1372
  });
416
1373
 
417
1374
  indexCmd
@@ -429,25 +1386,37 @@ program
429
1386
  .option('--task <task>', 'natural language task description')
430
1387
  .option('--paths <paths>', 'comma-separated file paths')
431
1388
  .option('--top <n>', 'max entries per tier', '5')
432
- .action(async (options: { task?: string; paths?: string; top?: string }) => {
1389
+ .option('--backend <name>', 'retrieval backend (pageindex only)')
1390
+ .action(async (options: { task?: string; paths?: string; top?: string; backend?: string }) => {
1391
+ resolveBackend(options.backend);
433
1392
  if (!options.task && !options.paths) {
434
1393
  console.error('at least one of --task or --paths is required');
435
1394
  process.exitCode = 1;
436
1395
  return;
437
1396
  }
438
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;
439
1415
  if (config === null) {
440
1416
  // v1 parity: unavailable/error JSON on stdout, exit 0.
441
1417
  console.log(JSON.stringify(unavailableResult(), null, 2));
442
1418
  return;
443
1419
  }
444
- const tree = loadTree(makeIo(process.cwd()));
445
- if (tree === null) {
446
- const missing = unavailableResult();
447
- missing.status.qualityNote = 'index missing — run `llman-sdd index rebuild` first';
448
- console.log(JSON.stringify(missing, null, 2));
449
- return;
450
- }
451
1420
  const result = await runContextRetrieval({
452
1421
  config,
453
1422
  task: options.task ?? '',
@@ -460,8 +1429,59 @@ program
460
1429
  console.log(JSON.stringify(result, null, 2));
461
1430
  });
462
1431
 
1432
+ function commandChain(argv: string[]): string {
1433
+ let node: Command = program;
1434
+ let chain = '';
1435
+ let rest = argv.slice(2);
1436
+ while (rest.length > 0) {
1437
+ let child: Command | undefined;
1438
+ for (const cmd of node.commands) {
1439
+ if (cmd.name() === rest[0]) {
1440
+ child = cmd;
1441
+ break;
1442
+ }
1443
+ }
1444
+ if (child === undefined) break;
1445
+ chain += ` ${child.name()}`;
1446
+ node = child;
1447
+ rest = rest.slice(1);
1448
+ }
1449
+ return chain;
1450
+ }
1451
+
463
1452
  async function main(): Promise<void> {
464
- await program.parseAsync(process.argv);
1453
+ try {
1454
+ await program.parseAsync(process.argv);
1455
+ } catch (error) {
1456
+ const comErr = error as { code?: string; exitCode?: number; message?: string };
1457
+ if (
1458
+ comErr?.code === 'commander.version' ||
1459
+ comErr?.code === 'commander.help' ||
1460
+ comErr?.code === 'commander.helpDisplayed' ||
1461
+ comErr?.exitCode === 0
1462
+ ) {
1463
+ // version/help already rendered to stdout; exit code stays 0.
1464
+ return;
1465
+ }
1466
+ if (comErr?.code === 'commander.unknownOption') {
1467
+ const raw = String(comErr.message ?? '');
1468
+ const arg = raw.replace(/^error: unknown option ['"]/u, '').replace(/['"]?\s*$/u, '') ?? '';
1469
+ const chain = commandChain(process.argv);
1470
+ console.error(`error: unexpected argument '${arg}' found`);
1471
+ console.error('');
1472
+ console.error(`Usage: llman-sdd${chain} [OPTIONS]`);
1473
+ console.error('');
1474
+ console.error("For more information, try '--help'.");
1475
+ process.exitCode = 2;
1476
+ return;
1477
+ }
1478
+
1479
+ // v1 parity: expected domain errors surface as a single `Error: <message>`
1480
+ // line on stderr with exit code 1 (no Bun stack trace).
1481
+ const message = error instanceof Error ? error.message : String(error);
1482
+ console.error(message.startsWith('Error: ') ? message : `Error: ${message}`);
1483
+ process.exitCode = 1;
1484
+ }
465
1485
  }
466
1486
 
467
1487
  await main();