@llman-sdd/cli 0.2.0 → 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.
- package/package.json +4 -5
- 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.
|
|
4
|
-
"description": "Spec-driven development workflow CLI (
|
|
3
|
+
"version": "0.3.0",
|
|
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.
|
|
30
|
+
"@llman-sdd/core": "0.2.0",
|
|
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
|
-
|
|
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
|
-
{
|
|
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:
|
|
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:
|
|
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 '${
|
|
487
|
+
console.log(`Change '${changeId}' is valid`);
|
|
439
488
|
} else {
|
|
440
|
-
console.error(`Change '${
|
|
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
|
|
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
|
|
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}
|
|
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
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
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
|
-
|
|
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 ===
|
|
917
|
+
const change = changes.find((c) => c.name === changeId);
|
|
856
918
|
console.log(`Stage: ${change?.stage ?? 'draft'}`);
|
|
857
|
-
console.log(`path: ${
|
|
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
|
-
|
|
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/
|
|
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
|
-
|
|
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
|
}
|