@llman-sdd/cli 0.1.3 → 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.
- package/package.json +2 -2
- package/src/main.ts +1086 -123
package/src/main.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
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
|
-
.
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
locale
|
|
99
|
-
|
|
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
|
|
102
|
-
|
|
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
|
|
108
|
-
.
|
|
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
|
-
.
|
|
111
|
-
|
|
112
|
-
|
|
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
|
+
}
|
|
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;
|
|
114
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
|
-
.
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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(
|
|
141
|
-
|
|
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}\`
|
|
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
|
|
559
|
+
.description('Bind the change to the current feature branch')
|
|
154
560
|
.argument('<id>')
|
|
155
|
-
.
|
|
156
|
-
|
|
157
|
-
|
|
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
|
|
163
|
-
.
|
|
164
|
-
.action((options: {
|
|
165
|
-
|
|
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
|
-
.
|
|
173
|
-
|
|
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('
|
|
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(
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
.
|
|
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
|
-
|
|
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
|
-
|
|
212
|
-
|
|
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
|
|
768
|
+
.description('Show a change or spec')
|
|
220
769
|
.argument('<item>')
|
|
221
|
-
.option('--output <format>', '
|
|
770
|
+
.option('--output <format>', 'json | compact | meta-only | no-scenarios | deltas | reqs-only')
|
|
222
771
|
.option('--type <itemType>', 'item type hint: change|spec')
|
|
223
|
-
.
|
|
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
|
|
228
|
-
if (!existsSync(
|
|
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${
|
|
817
|
+
console.log(`## Spec\n${raw}${morphology}`);
|
|
238
818
|
return;
|
|
239
819
|
}
|
|
240
|
-
|
|
241
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
.
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
.
|
|
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 =
|
|
281
|
-
|
|
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
|
-
.
|
|
288
|
-
|
|
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)')
|
|
@@ -315,19 +1086,26 @@ archive
|
|
|
315
1086
|
.option('--list', 'list entries already in the cold-backup archive')
|
|
316
1087
|
.action(
|
|
317
1088
|
async (options: { before?: string; keepRecent: string; dryRun?: boolean; list?: boolean }) => {
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
1089
|
+
try {
|
|
1090
|
+
const sz = await makeWasmSevenZip();
|
|
1091
|
+
const root = process.cwd();
|
|
1092
|
+
const io = makeIo(root);
|
|
1093
|
+
if (options.list) {
|
|
1094
|
+
for (const line of await runList(io, sz, root)) console.log(line);
|
|
1095
|
+
return;
|
|
1096
|
+
}
|
|
1097
|
+
const result = await runFreeze(io, sz, root, {
|
|
1098
|
+
before: options.before,
|
|
1099
|
+
keepRecent: Number(options.keepRecent),
|
|
1100
|
+
dryRun: options.dryRun,
|
|
1101
|
+
});
|
|
1102
|
+
for (const line of result.lines) console.log(line);
|
|
1103
|
+
} catch (error) {
|
|
1104
|
+
// Emscripten aborts (e.g. wasm load failure) throw raw RuntimeErrors —
|
|
1105
|
+
// keep the CLI surface one-line like thaw does.
|
|
1106
|
+
console.error((error as Error).message);
|
|
1107
|
+
process.exitCode = 1;
|
|
324
1108
|
}
|
|
325
|
-
const result = await runFreeze(io, sz, root, {
|
|
326
|
-
before: options.before,
|
|
327
|
-
keepRecent: Number(options.keepRecent),
|
|
328
|
-
dryRun: options.dryRun,
|
|
329
|
-
});
|
|
330
|
-
for (const line of result.lines) console.log(line);
|
|
331
1109
|
},
|
|
332
1110
|
);
|
|
333
1111
|
|
|
@@ -343,15 +1121,19 @@ archive
|
|
|
343
1121
|
},
|
|
344
1122
|
[] as string[],
|
|
345
1123
|
)
|
|
346
|
-
.
|
|
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
|
+
}
|
|
347
1129
|
const sz = await makeWasmSevenZip();
|
|
348
1130
|
const root = process.cwd();
|
|
349
1131
|
const io = makeIo(root);
|
|
350
1132
|
try {
|
|
351
|
-
const result = await runThaw(io, sz, root, options.change);
|
|
1133
|
+
const result = await runThaw(io, sz, root, options.change, { dest: options.dest });
|
|
352
1134
|
for (const line of result.lines) console.log(line);
|
|
353
1135
|
} catch (error) {
|
|
354
|
-
console.error((error as Error).message);
|
|
1136
|
+
console.error(`Error: ${(error as Error).message}`);
|
|
355
1137
|
process.exitCode = 1;
|
|
356
1138
|
}
|
|
357
1139
|
});
|
|
@@ -370,13 +1152,28 @@ review
|
|
|
370
1152
|
: null;
|
|
371
1153
|
const bindings = config?.bdd?.bindings?.filter((b) => b.kind === 'tags') ?? [];
|
|
372
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
|
+
}
|
|
373
1166
|
const activeChanges = collectChanges(io, process.cwd(), new Date());
|
|
374
1167
|
const result = buildReview(
|
|
375
1168
|
{
|
|
376
|
-
entries
|
|
1169
|
+
entries,
|
|
377
1170
|
bindings: bindings.map((b) => ({ kind: 'tags', tags: b.tags })),
|
|
378
1171
|
boundChangeCount: activeChanges.filter((c) => c.hasBinding).length,
|
|
379
1172
|
activeChanges,
|
|
1173
|
+
capability: options.capability,
|
|
1174
|
+
git: makeCliGit(process.cwd()),
|
|
1175
|
+
root: process.cwd(),
|
|
1176
|
+
specsDir: 'llmanspec/specs',
|
|
380
1177
|
},
|
|
381
1178
|
io,
|
|
382
1179
|
);
|
|
@@ -393,6 +1190,121 @@ review
|
|
|
393
1190
|
if (result.exitCode !== 0) process.exitCode = result.exitCode;
|
|
394
1191
|
});
|
|
395
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
|
+
|
|
396
1308
|
const indexCmd = program
|
|
397
1309
|
.command('index')
|
|
398
1310
|
.description('Index management commands (rebuild, check freshness)');
|
|
@@ -400,11 +1312,14 @@ const indexCmd = program
|
|
|
400
1312
|
indexCmd
|
|
401
1313
|
.command('rebuild')
|
|
402
1314
|
.description('Rebuild the pageindex tree from spec IR (no LLM)')
|
|
403
|
-
.
|
|
1315
|
+
.option('--backend <name>', 'index backend (pageindex only)')
|
|
1316
|
+
.action((options: { backend?: string }) => {
|
|
1317
|
+
resolveBackend(options.backend);
|
|
404
1318
|
const result = rebuildIndex(makeIo(process.cwd()), 'llmanspec/specs', loadSpecEntries(), {
|
|
405
1319
|
chatModel: process.env.LLMAN_SDD_INDEX_CHAT_MODEL ?? '',
|
|
406
1320
|
});
|
|
407
|
-
for (const line of result.lines) console.
|
|
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));
|
|
408
1323
|
});
|
|
409
1324
|
|
|
410
1325
|
indexCmd
|
|
@@ -422,7 +1337,9 @@ program
|
|
|
422
1337
|
.option('--task <task>', 'natural language task description')
|
|
423
1338
|
.option('--paths <paths>', 'comma-separated file paths')
|
|
424
1339
|
.option('--top <n>', 'max entries per tier', '5')
|
|
425
|
-
.
|
|
1340
|
+
.option('--backend <name>', 'retrieval backend (pageindex only)')
|
|
1341
|
+
.action(async (options: { task?: string; paths?: string; top?: string; backend?: string }) => {
|
|
1342
|
+
resolveBackend(options.backend);
|
|
426
1343
|
if (!options.task && !options.paths) {
|
|
427
1344
|
console.error('at least one of --task or --paths is required');
|
|
428
1345
|
process.exitCode = 1;
|
|
@@ -453,8 +1370,54 @@ program
|
|
|
453
1370
|
console.log(JSON.stringify(result, null, 2));
|
|
454
1371
|
});
|
|
455
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
|
+
|
|
456
1388
|
async function main(): Promise<void> {
|
|
457
|
-
|
|
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
|
+
}
|
|
458
1421
|
}
|
|
459
1422
|
|
|
460
1423
|
await main();
|