@llman-sdd/cli 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +4 -5
  2. package/src/main.ts +114 -50
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@llman-sdd/cli",
3
- "version": "0.2.0",
4
- "description": "Spec-driven development workflow CLI (provides both `llman-sdd` and `llmanspec` commands)",
3
+ "version": "0.3.1",
4
+ "description": "Spec-driven development workflow CLI (the `llman-sdd` command)",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -9,8 +9,7 @@
9
9
  "directory": "apps/cli"
10
10
  },
11
11
  "bin": {
12
- "llman-sdd": "./src/main.ts",
13
- "llmanspec": "./src/main.ts"
12
+ "llman-sdd": "./src/main.ts"
14
13
  },
15
14
  "files": [
16
15
  "src"
@@ -28,7 +27,7 @@
28
27
  "build": "bun run ./scripts/build-binary.ts"
29
28
  },
30
29
  "dependencies": {
31
- "@llman-sdd/core": "0.1.4",
30
+ "@llman-sdd/core": "0.3.1",
32
31
  "commander": "^15.0.0"
33
32
  },
34
33
  "engines": {
package/src/main.ts CHANGED
@@ -8,16 +8,19 @@ import {
8
8
  buildReview,
9
9
  collectChanges,
10
10
  collectSpecs,
11
+ currentBranch,
12
+ defaultBranch,
11
13
  discoverSpecs,
12
14
  embeddedTemplates,
13
15
  graphMermaid,
16
+ loadTreeWithAutoRebuild,
14
17
  makeEmbeddedTemplateIo,
18
+ morphologyOfScenarios,
15
19
  nextReqId,
16
20
  changeDiff,
17
21
  deriveChangeId,
18
22
  finalizeChange,
19
23
  loadConfig,
20
- loadTree,
21
24
  newChange,
22
25
  renderChangesJson,
23
26
  runContextRetrieval,
@@ -25,6 +28,7 @@ import {
25
28
  renderReviewHtml,
26
29
  renderSpecsJson,
27
30
  renderSpecsList,
31
+ resolveChangeId,
28
32
  runInit,
29
33
  scaffoldSpec,
30
34
  showChangeJson,
@@ -105,6 +109,42 @@ function cliMaxScanDepth(): number {
105
109
  return n;
106
110
  }
107
111
 
112
+ /**
113
+ * r61: shared v1-r112 change id resolution for every change-taking command.
114
+ * Emits the `(prefix match)` hint on stderr and exits with the resolver's
115
+ * error message when resolution fails; returns null after reporting.
116
+ */
117
+ function resolveChangeIdOrExit(
118
+ input: string,
119
+ opts: { suppressHint?: boolean } = {},
120
+ ): { id: string; viaPrefix: boolean } | null {
121
+ try {
122
+ const resolved = resolveChangeId(makeIo(process.cwd()), process.cwd(), input, {
123
+ maxScanDepth: cliMaxScanDepth(),
124
+ });
125
+ if (resolved.viaPrefix && opts.suppressHint !== true) {
126
+ console.error(`'${input}' -> '${resolved.id}' (prefix match)`);
127
+ }
128
+ return resolved;
129
+ } catch (error) {
130
+ console.error((error as Error).message);
131
+ process.exitCode = 1;
132
+ return null;
133
+ }
134
+ }
135
+
136
+ /** r63: default-branch dirty live specs — workspace-level guard, once per run. */
137
+ function warnDirtySpecsOnDefaultBranch(): void {
138
+ const git = makeCliGit(process.cwd());
139
+ const current = currentBranch(git);
140
+ if (current === null || current !== defaultBranch(git)) return;
141
+ const out = git.runOpt(['status', '--porcelain', '--', 'llmanspec/specs']) ?? '';
142
+ if (out.trim() === '') return;
143
+ console.error(
144
+ `[WARNING] llmanspec/specs: live specs dirty on default branch \`${current}\`: do not commit unimplemented contracts to the default branch. Switch to the change's bound branch (or \`llman-sdd change start <id>\`) before editing llmanspec/specs/.`,
145
+ );
146
+ }
147
+
108
148
  function runValidateSweep(): boolean {
109
149
  const report = validateAllSpecs(loadSpecEntries(), makeIo(process.cwd()));
110
150
  return report.verdicts.some((v) => !v.ok);
@@ -225,6 +265,7 @@ function specV1Items(opts: { strict?: boolean }): VItem[] {
225
265
 
226
266
  function changeV1Items(names: string[], opts: { stage?: string; strict?: boolean }): VItem[] {
227
267
  const io = makeIo(process.cwd());
268
+ const git = makeCliGit(process.cwd());
228
269
  const config = loadCliConfig();
229
270
  const items: VItem[] = [];
230
271
  for (const name of names) {
@@ -237,7 +278,7 @@ function changeV1Items(names: string[], opts: { stage?: string; strict?: boolean
237
278
  min_completion_ratio: config?.archive?.min_completion_ratio ?? null,
238
279
  change_id_pattern: config?.change_id?.pattern ?? null,
239
280
  },
240
- { stage: opts.stage as never, strict: opts.strict === true },
281
+ { stage: opts.stage as never, strict: opts.strict === true, git },
241
282
  );
242
283
  items.push({
243
284
  id: name,
@@ -351,6 +392,7 @@ program
351
392
  process.exitCode = 1;
352
393
  return;
353
394
  }
395
+ warnDirtySpecsOnDefaultBranch();
354
396
  if (options.type !== undefined && options.type !== 'change' && options.type !== 'spec') {
355
397
  console.error(`invalid --type: ${options.type}`);
356
398
  process.exitCode = 1;
@@ -401,32 +443,39 @@ program
401
443
  process.exitCode = mine.valid ? 0 : 1;
402
444
  return;
403
445
  }
404
- // change single
446
+ // change single (r61: v1 r112 prefix resolution on the change id)
405
447
  const io = makeIo(process.cwd());
406
448
  const root = process.cwd();
449
+ const resolved = resolveChangeIdOrExit(item, { suppressHint: options.json === true });
450
+ if (resolved === null) return;
451
+ const changeId = resolved.id;
407
452
  const res = validateChange(
408
453
  io,
409
454
  root,
410
- item,
455
+ changeId,
411
456
  {
412
457
  strict_defer: loadCliConfig()?.archive?.strict_defer ?? null,
413
458
  min_completion_ratio: loadCliConfig()?.archive?.min_completion_ratio ?? null,
414
459
  change_id_pattern: loadCliConfig()?.change_id?.pattern ?? null,
415
460
  },
416
- { stage: options.stage as never, strict: options.strict === true },
461
+ {
462
+ stage: options.stage as never,
463
+ strict: options.strict === true,
464
+ git: makeCliGit(process.cwd()),
465
+ },
417
466
  );
418
467
  const infos = res.issues.filter((i) => i.level === 'INFO');
419
468
  if (options.json) {
420
469
  renderValidateJson(
421
470
  [
422
471
  {
423
- id: item,
472
+ id: changeId,
424
473
  type: 'change',
425
474
  valid: res.valid,
426
475
  issues: res.issues,
427
476
  durationMs: 0,
428
477
  staleness: notApplicableStaleness(),
429
- matchedViaPrefix: false,
478
+ matchedViaPrefix: resolved.viaPrefix,
430
479
  },
431
480
  ],
432
481
  options.compactJson === true,
@@ -435,9 +484,9 @@ program
435
484
  return;
436
485
  }
437
486
  if (res.valid) {
438
- console.log(`Change '${item}' is valid`);
487
+ console.log(`Change '${changeId}' is valid`);
439
488
  } else {
440
- console.error(`Change '${item}' has issues`);
489
+ console.error(`Change '${changeId}' has issues`);
441
490
  for (const issue of res.issues.filter((i) => i.level !== 'INFO'))
442
491
  console.error(` [${issue.level}] ${issue.path}: ${issue.message}`);
443
492
  console.error('Next steps:');
@@ -544,13 +593,15 @@ change
544
593
  'feature branch prefix (default: sdd.branch_prefix config, then sdd/)',
545
594
  )
546
595
  .action((id: string, options: { branchPrefix?: string }) => {
596
+ const resolved = resolveChangeIdOrExit(id);
597
+ if (resolved === null) return;
547
598
  const git = makeCliGit(process.cwd());
548
599
  const config = loadCliConfig();
549
- const result = startChange(git, makeIo(process.cwd()), id, {
600
+ const result = startChange(git, makeIo(process.cwd()), resolved.id, {
550
601
  branchPrefix: options.branchPrefix ?? config?.sdd?.branch_prefix ?? 'sdd/',
551
602
  });
552
603
  console.log(
553
- `started change \`${id}\` → branch \`${result.branch}\` base \`${result.baseSha}\` base-branch \`${result.baseBranch}\``,
604
+ `started change \`${resolved.id}\` → branch \`${result.branch}\` base \`${result.baseSha}\` base-branch \`${result.baseBranch}\``,
554
605
  );
555
606
  });
556
607
 
@@ -561,12 +612,14 @@ change
561
612
  .option('--force', 'rebind an already attached change to the current branch')
562
613
  .option('--base <branch>', 'explicit fork-point branch to record')
563
614
  .action((id: string, options: { force?: boolean; base?: string }) => {
564
- const result = attachChange(makeCliGit(process.cwd()), makeIo(process.cwd()), id, {
615
+ const resolved = resolveChangeIdOrExit(id);
616
+ if (resolved === null) return;
617
+ const result = attachChange(makeCliGit(process.cwd()), makeIo(process.cwd()), resolved.id, {
565
618
  force: options.force,
566
619
  base: options.base,
567
620
  });
568
621
  console.log(
569
- `attached change \`${id}\` → branch \`${result.branch}\` base \`${result.baseSha}\` base-branch \`${result.baseBranch}\``,
622
+ `attached change \`${resolved.id}\` → branch \`${result.branch}\` base \`${result.baseSha}\` base-branch \`${result.baseBranch}\``,
570
623
  );
571
624
  });
572
625
 
@@ -607,10 +660,13 @@ change
607
660
  options: { into?: string; method?: string; dryRun?: boolean; force?: boolean },
608
661
  ) => {
609
662
  if (options.method !== undefined && options.method !== 'squash' && options.method !== 'ff') {
610
- console.error(`invalid --method \`${options.method}\` (squash | ff)`);
663
+ console.error(`invalid --method: ${options.method}`);
611
664
  process.exitCode = 1;
612
665
  return;
613
666
  }
667
+ const resolved = resolveChangeIdOrExit(id);
668
+ if (resolved === null) return;
669
+ id = resolved.id;
614
670
  const io = makeIo(process.cwd());
615
671
  if (options.dryRun) {
616
672
  const date = new Date().toISOString().slice(0, 10);
@@ -661,6 +717,9 @@ change
661
717
  .option('--json', 'emit {change, branch, base, commitCount}')
662
718
  .option('--export-patch <path>', 'write the diff to a file instead of stdout')
663
719
  .action((id: string, options: { json?: boolean; exportPatch?: string }) => {
720
+ const resolved = resolveChangeIdOrExit(id);
721
+ if (resolved === null) return;
722
+ id = resolved.id;
664
723
  const git = makeCliGit(process.cwd());
665
724
  if (options.json) {
666
725
  const info = changeDiffInfo(git, makeIo(process.cwd()), id);
@@ -705,6 +764,9 @@ change
705
764
  return;
706
765
  }
707
766
  }
767
+ const resolved = resolveChangeIdOrExit(id);
768
+ if (resolved === null) return;
769
+ id = resolved.id;
708
770
  const config = loadCliConfig();
709
771
  const method = options.method ?? config?.sdd?.merge_method ?? 'squash';
710
772
  const result = finalizeChange(makeCliGit(process.cwd()), makeIo(process.cwd()), id, {
@@ -812,20 +874,19 @@ program
812
874
  const raw = readFileSync(specPath, 'utf8').trimEnd();
813
875
  const summary = collectSpecs(loadSpecEntries()).find((x) => x.id === item);
814
876
  const morphology = summary
815
- ? `\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}`
816
878
  : '';
817
879
  console.log(`## Spec\n${raw}${morphology}`);
818
880
  return;
819
881
  }
820
882
 
821
883
  // ---- change ----
822
- const proposalPath = join('llmanspec', 'changes', item, 'proposal.md');
823
- if (!existsSync(proposalPath)) {
824
- console.error(`change not found: ${item}`);
825
- process.exitCode = 1;
826
- return;
827
- }
828
- const proposal = readFileSync(proposalPath, 'utf8');
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');
829
890
  if (asJson) {
830
891
  // v1 parse_change gates: Why first, then What Changes (json only).
831
892
  if (!hasSection(proposal, 'Why')) {
@@ -843,7 +904,8 @@ program
843
904
  root: process.cwd(),
844
905
  specsDir: 'llmanspec/specs',
845
906
  },
846
- item,
907
+ changeId,
908
+ { matchedViaPrefix: viaPrefix },
847
909
  );
848
910
  console.log(JSON.stringify(result, null, asCompact ? 0 : 2));
849
911
  return;
@@ -852,9 +914,9 @@ program
852
914
  const changes = collectChanges(makeIo(process.cwd()), process.cwd(), new Date(), {
853
915
  maxScanDepth: cliMaxScanDepth(),
854
916
  });
855
- const change = changes.find((c) => c.name === item);
917
+ const change = changes.find((c) => c.name === changeId);
856
918
  console.log(`Stage: ${change?.stage ?? 'draft'}`);
857
- console.log(`path: ${item}`);
919
+ console.log(`path: ${changeId}`);
858
920
  process.stdout.write(proposal);
859
921
  if (!proposal.endsWith('\n')) console.log();
860
922
  const gates = showChangeJson(
@@ -864,7 +926,7 @@ program
864
926
  root: process.cwd(),
865
927
  specsDir: 'llmanspec/specs',
866
928
  },
867
- item,
929
+ changeId,
868
930
  ).gateChecks as { name: string; pass: boolean; hint: string }[];
869
931
  const passCount = gates.filter((g) => g.pass).length;
870
932
  console.log(`Gates: ${passCount}/${gates.length} pass`);
@@ -901,20 +963,7 @@ function renderSpecJson(
901
963
  const purpose = doc?.header.purpose ?? '';
902
964
  const humans = doc?.scenarios.filter((s) => s.classification === 'human') ?? [];
903
965
  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
- };
966
+ const morphology = morphologyOfScenarios(doc?.scenarios ?? []);
918
967
  if (opts.metaOnly) {
919
968
  return {
920
969
  id: item,
@@ -1140,7 +1189,7 @@ archive
1140
1189
 
1141
1190
  const review = program
1142
1191
  .command('review')
1143
- .description('Aggregate review: pending/manual/unbound/stale signals plus a validate sweep');
1192
+ .description('Aggregate review: pending/unbound/stale signals plus a validate sweep');
1144
1193
 
1145
1194
  review
1146
1195
  .option('--capability <capability>', 'restrict the sweep to one capability/spec id')
@@ -1346,18 +1395,28 @@ program
1346
1395
  return;
1347
1396
  }
1348
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;
1349
1415
  if (config === null) {
1350
1416
  // v1 parity: unavailable/error JSON on stdout, exit 0.
1351
1417
  console.log(JSON.stringify(unavailableResult(), null, 2));
1352
1418
  return;
1353
1419
  }
1354
- const tree = loadTree(makeIo(process.cwd()));
1355
- if (tree === null) {
1356
- const missing = unavailableResult();
1357
- missing.status.qualityNote = 'index missing — run `llman-sdd index rebuild` first';
1358
- console.log(JSON.stringify(missing, null, 2));
1359
- return;
1360
- }
1361
1420
  const result = await runContextRetrieval({
1362
1421
  config,
1363
1422
  task: options.task ?? '',
@@ -1375,12 +1434,17 @@ function commandChain(argv: string[]): string {
1375
1434
  let chain = '';
1376
1435
  let rest = argv.slice(2);
1377
1436
  while (rest.length > 0) {
1378
- const child = node.commands.find((c) => c.name() === rest[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
+ }
1379
1444
  if (child === undefined) break;
1380
1445
  chain += ` ${child.name()}`;
1381
1446
  node = child;
1382
1447
  rest = rest.slice(1);
1383
- // eslint-disable-next-line no-loop-func
1384
1448
  }
1385
1449
  return chain;
1386
1450
  }