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