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