@llman-sdd/cli 0.1.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 +37 -0
- package/src/io.ts +70 -0
- package/src/main.ts +507 -0
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@llman-sdd/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Spec-driven development workflow CLI",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+ssh://git@github.com/StrayDragon/llman-sdd.git",
|
|
9
|
+
"directory": "apps/cli"
|
|
10
|
+
},
|
|
11
|
+
"bin": {
|
|
12
|
+
"llman-sdd": "./src/main.ts"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"src"
|
|
16
|
+
],
|
|
17
|
+
"type": "module",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": "./src/main.ts"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"lint": "bun run --cwd ../.. lint",
|
|
23
|
+
"lint:fix": "bun run --cwd ../.. lint:fix",
|
|
24
|
+
"format:check": "bun run --cwd ../.. format:check",
|
|
25
|
+
"format:write": "bun run --cwd ../.. format:write",
|
|
26
|
+
"typecheck": "bun run --cwd ../.. typecheck",
|
|
27
|
+
"build": "bun run ./scripts/build-binary.ts"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@llman-sdd/core": "0.1.0",
|
|
31
|
+
"commander": "^15.0.0"
|
|
32
|
+
},
|
|
33
|
+
"engines": {
|
|
34
|
+
"bun": ">=1.4.0",
|
|
35
|
+
"node": ">=24.0.0"
|
|
36
|
+
}
|
|
37
|
+
}
|
package/src/io.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single CLI-side IO adapter: one root-relative CliIo object over node:fs
|
|
3
|
+
* that structurally satisfies every core port (FsIo, DiscoveryIo, IndexIo,
|
|
4
|
+
* ChangeFsIo / GraphFsIo / ShowFsIo, HashIo, FreezeIo). Relative paths resolve
|
|
5
|
+
* against root; absolute paths pass through untouched.
|
|
6
|
+
*/
|
|
7
|
+
import {
|
|
8
|
+
existsSync,
|
|
9
|
+
mkdirSync,
|
|
10
|
+
readdirSync,
|
|
11
|
+
readFileSync,
|
|
12
|
+
renameSync,
|
|
13
|
+
rmSync,
|
|
14
|
+
statSync,
|
|
15
|
+
writeFileSync,
|
|
16
|
+
} from 'node:fs';
|
|
17
|
+
import { isAbsolute, join } from 'node:path';
|
|
18
|
+
|
|
19
|
+
import { makeSpawnGit, type GitLike } from '@llman-sdd/core';
|
|
20
|
+
|
|
21
|
+
export interface CliIo {
|
|
22
|
+
exists(path: string): boolean;
|
|
23
|
+
readText(path: string): string;
|
|
24
|
+
writeText(path: string, content: string): void;
|
|
25
|
+
rename(from: string, to: string): void;
|
|
26
|
+
listDir(path: string): string[];
|
|
27
|
+
isDirectory(path: string): boolean;
|
|
28
|
+
remove(path: string): void;
|
|
29
|
+
mkdirp(path: string): void;
|
|
30
|
+
processAlive(pid: number): boolean;
|
|
31
|
+
mtimeMs(path: string): number;
|
|
32
|
+
removeDir(path: string): void;
|
|
33
|
+
moveDir(from: string, to: string): void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function makeIo(root: string): CliIo {
|
|
37
|
+
const full = (p: string): string => (isAbsolute(p) ? p : join(root, p));
|
|
38
|
+
const parentOf = (p: string): string => p.slice(0, p.lastIndexOf('/')) || '.';
|
|
39
|
+
return {
|
|
40
|
+
exists: (p) => existsSync(full(p)),
|
|
41
|
+
readText: (p) => readFileSync(full(p), 'utf8'),
|
|
42
|
+
writeText: (p, content) => {
|
|
43
|
+
mkdirSync(parentOf(full(p)), { recursive: true });
|
|
44
|
+
writeFileSync(full(p), content);
|
|
45
|
+
},
|
|
46
|
+
rename: (from, to) => {
|
|
47
|
+
mkdirSync(parentOf(full(to)), { recursive: true });
|
|
48
|
+
renameSync(full(from), full(to));
|
|
49
|
+
},
|
|
50
|
+
listDir: (p) => readdirSync(full(p)),
|
|
51
|
+
isDirectory: (p) => existsSync(full(p)) && statSync(full(p)).isDirectory(),
|
|
52
|
+
remove: (p) => rmSync(full(p), { force: true }),
|
|
53
|
+
mkdirp: (p) => mkdirSync(full(p), { recursive: true }),
|
|
54
|
+
processAlive: (pid) => {
|
|
55
|
+
try {
|
|
56
|
+
process.kill(pid, 0);
|
|
57
|
+
return true;
|
|
58
|
+
} catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
mtimeMs: (p) => statSync(full(p)).mtimeMs,
|
|
63
|
+
removeDir: (p) => rmSync(full(p), { recursive: true, force: true }),
|
|
64
|
+
moveDir: (from, to) => renameSync(full(from), full(to)),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function makeCliGit(root: string): GitLike {
|
|
69
|
+
return makeSpawnGit(root);
|
|
70
|
+
}
|
package/src/main.ts
ADDED
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
VERSION,
|
|
8
|
+
attachChange,
|
|
9
|
+
buildReview,
|
|
10
|
+
collectChanges,
|
|
11
|
+
collectSpecs,
|
|
12
|
+
graphMermaid,
|
|
13
|
+
nextReqId,
|
|
14
|
+
changeDiff,
|
|
15
|
+
deriveChangeId,
|
|
16
|
+
finalizeChange,
|
|
17
|
+
loadConfig,
|
|
18
|
+
loadTree,
|
|
19
|
+
newChange,
|
|
20
|
+
parseCapability,
|
|
21
|
+
renderChangesJson,
|
|
22
|
+
runContextRetrieval,
|
|
23
|
+
renderChangesList,
|
|
24
|
+
renderSpecsJson,
|
|
25
|
+
renderSpecsList,
|
|
26
|
+
runInit,
|
|
27
|
+
scaffoldSpec,
|
|
28
|
+
showChangeJson,
|
|
29
|
+
startChange,
|
|
30
|
+
makeWasmSevenZip,
|
|
31
|
+
checkIndexFreshness,
|
|
32
|
+
rebuildIndex,
|
|
33
|
+
runFreeze,
|
|
34
|
+
runList,
|
|
35
|
+
runThaw,
|
|
36
|
+
validateAllSpecs,
|
|
37
|
+
type TagBinding,
|
|
38
|
+
type GitLike,
|
|
39
|
+
resolveChatConfig,
|
|
40
|
+
unavailableResult,
|
|
41
|
+
} from '@llman-sdd/core';
|
|
42
|
+
import { Command } from 'commander';
|
|
43
|
+
|
|
44
|
+
import { makeCliGit, makeIo } from './io.ts';
|
|
45
|
+
|
|
46
|
+
// Injected at binary build time by scripts/build-binary.ts; falls back to the
|
|
47
|
+
// package version when running from source.
|
|
48
|
+
const version = process.env.LLMAN_SDD_VERSION ?? VERSION;
|
|
49
|
+
|
|
50
|
+
function collectFeatureFiles(dir: string): string[] {
|
|
51
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory()) return [];
|
|
52
|
+
const out: string[] = [];
|
|
53
|
+
for (const name of readdirSync(dir).toSorted()) {
|
|
54
|
+
const full = join(dir, name);
|
|
55
|
+
if (statSync(full).isDirectory()) out.push(...collectFeatureFiles(full));
|
|
56
|
+
else if (name.endsWith('.feature')) out.push(full);
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function runValidateSpecs(options: { specs?: boolean; check: boolean }): number {
|
|
62
|
+
const entries = collectFeatureFiles('llmanspec/specs').map((path) => ({
|
|
63
|
+
fileName: path,
|
|
64
|
+
doc: parseCapability(readFileSync(path, 'utf8'), path),
|
|
65
|
+
}));
|
|
66
|
+
const report = validateAllSpecs(entries, makeIo(process.cwd()));
|
|
67
|
+
for (const line of report.lines) console.log(line);
|
|
68
|
+
|
|
69
|
+
let failed = report.failed;
|
|
70
|
+
if (options.check && existsSync('llmanspec/config.yaml')) {
|
|
71
|
+
const config = loadConfig(readFileSync('llmanspec/config.yaml', 'utf8'));
|
|
72
|
+
const runCommand = config.bdd?.run_command;
|
|
73
|
+
if (runCommand) {
|
|
74
|
+
const proc = spawnSync(runCommand, { shell: true, stdio: 'inherit' });
|
|
75
|
+
if ((proc.status ?? 1) !== 0) failed = true;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return failed ? 1 : 0;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const program = new Command();
|
|
82
|
+
|
|
83
|
+
program.name('llman-sdd').description('Spec-driven development workflow').version(version);
|
|
84
|
+
|
|
85
|
+
program
|
|
86
|
+
.command('init')
|
|
87
|
+
.description('Initialize llmanspec in your project (--update to refresh existing)')
|
|
88
|
+
.option('--update', 'refresh an existing installation')
|
|
89
|
+
.option('--locale <locale>', 'locale for generated templates (defaults to config or en)')
|
|
90
|
+
.action((options: { update?: boolean; locale?: string }) => {
|
|
91
|
+
const result = runInit(process.cwd(), {
|
|
92
|
+
update: options.update ?? false,
|
|
93
|
+
locale: options.locale,
|
|
94
|
+
version,
|
|
95
|
+
});
|
|
96
|
+
const removed = result.removed.length > 0 ? `, removed: ${result.removed.join(', ')}` : '';
|
|
97
|
+
console.log(`initialized llmanspec (${result.skills.length} skills${removed})`);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
program
|
|
101
|
+
.command('validate')
|
|
102
|
+
.description('Validate specs under llmanspec/specs (structural gates)')
|
|
103
|
+
.option('--specs', 'validate specs (default and only scope for now)')
|
|
104
|
+
.option('--no-check', 'skip the bdd.run_command check (structural validation only)')
|
|
105
|
+
.action((options: { specs?: boolean; check: boolean }) => {
|
|
106
|
+
process.exit(runValidateSpecs(options));
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const change = program
|
|
110
|
+
.command('change')
|
|
111
|
+
.description('Change lifecycle: new / start / attach / next-id / diff / finalize');
|
|
112
|
+
|
|
113
|
+
change
|
|
114
|
+
.command('new')
|
|
115
|
+
.description('Create a change draft (exactly one of <id> or --from)')
|
|
116
|
+
.argument('[id]')
|
|
117
|
+
.option('--from <description>', 'description the id is derived from')
|
|
118
|
+
.action((id: string | undefined, options: { from?: string }) => {
|
|
119
|
+
if ((id === undefined) === (options.from === undefined)) {
|
|
120
|
+
console.error('<CHANGE> and --from are mutually exclusive; pass one or the other');
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|
|
123
|
+
const io = makeIo(process.cwd());
|
|
124
|
+
const result = newChange(io, { id, from: options.from });
|
|
125
|
+
if (options.from !== undefined) console.log(`derived change id: ${result.id}`);
|
|
126
|
+
console.log(result.path);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
change
|
|
130
|
+
.command('start')
|
|
131
|
+
.description('Bind the change to a new feature branch (clean tree + default branch gates)')
|
|
132
|
+
.argument('<id>')
|
|
133
|
+
.option('--branch-prefix <prefix>', 'feature branch prefix', 'sdd/')
|
|
134
|
+
.action((id: string, options: { branchPrefix: string }) => {
|
|
135
|
+
const git: GitLike = makeCliGit(process.cwd());
|
|
136
|
+
const result = startChange(git, makeIo(process.cwd()), id, {
|
|
137
|
+
branchPrefix: options.branchPrefix,
|
|
138
|
+
});
|
|
139
|
+
console.log(
|
|
140
|
+
`started change \`${id}\` → branch \`${result.branch}\` (base ${result.baseBranch}@${result.baseSha.slice(0, 7)})`,
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
change
|
|
145
|
+
.command('attach')
|
|
146
|
+
.description('Bind the change to the current branch (no gates)')
|
|
147
|
+
.argument('<id>')
|
|
148
|
+
.action((id: string) => {
|
|
149
|
+
const result = attachChange(makeCliGit(process.cwd()), makeIo(process.cwd()), id);
|
|
150
|
+
console.log(`attached change \`${id}\` → branch \`${result.branch}\``);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
change
|
|
154
|
+
.command('next-id')
|
|
155
|
+
.description('Preview the derived change id for a description')
|
|
156
|
+
.requiredOption('--from <description>', 'description the id is derived from')
|
|
157
|
+
.action((options: { from: string }) => {
|
|
158
|
+
console.log(deriveChangeId(options.from));
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
change
|
|
162
|
+
.command('diff')
|
|
163
|
+
.description('Print the bound branch diff vs base')
|
|
164
|
+
.argument('<id>')
|
|
165
|
+
.action((id: string) => {
|
|
166
|
+
console.log(changeDiff(makeCliGit(process.cwd()), makeIo(process.cwd()), id));
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
change
|
|
170
|
+
.command('finalize')
|
|
171
|
+
.description('Merge the feature branch, archive docs, and close out with one commit')
|
|
172
|
+
.argument('<id>')
|
|
173
|
+
.option('--into <branch>', 'merge target override (defaults to base_branch)')
|
|
174
|
+
.option('--method <method>', 'merge method: squash (default) or ff', 'squash')
|
|
175
|
+
.action((id: string, options: { into?: string; method: string }) => {
|
|
176
|
+
if (options.method !== 'squash' && options.method !== 'ff') {
|
|
177
|
+
console.error(`invalid --method: ${options.method}`);
|
|
178
|
+
process.exit(1);
|
|
179
|
+
}
|
|
180
|
+
const result = finalizeChange(makeCliGit(process.cwd()), makeIo(process.cwd()), id, {
|
|
181
|
+
into: options.into,
|
|
182
|
+
method: options.method,
|
|
183
|
+
});
|
|
184
|
+
for (const w of result.warnings) console.error(`[WARNING] ${w}`);
|
|
185
|
+
console.log(
|
|
186
|
+
`finalized \`${id}\` → ${result.archiveDir} (commit "${result.commitSubject}" on ${result.target})`,
|
|
187
|
+
);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
program
|
|
191
|
+
.command('list')
|
|
192
|
+
.description('List changes or specs')
|
|
193
|
+
.option('--specs', 'list specs instead of changes')
|
|
194
|
+
.option('--json', 'machine-readable output')
|
|
195
|
+
.action((options: { specs?: boolean; json?: boolean }) => {
|
|
196
|
+
if (options.specs) {
|
|
197
|
+
const entries = collectFeatureFiles('llmanspec/specs').map((path) => ({
|
|
198
|
+
fileName: path,
|
|
199
|
+
doc: parseCapability(readFileSync(path, 'utf8'), path),
|
|
200
|
+
}));
|
|
201
|
+
const summaries = collectSpecs(entries);
|
|
202
|
+
console.log(
|
|
203
|
+
options.json ? renderSpecsJson(summaries) : renderSpecsList(summaries).join('\n'),
|
|
204
|
+
);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
const changes = collectChanges(makeIo(process.cwd()), process.cwd(), new Date());
|
|
208
|
+
console.log(
|
|
209
|
+
options.json ? renderChangesJson(changes) : renderChangesList(changes, new Date()).join('\n'),
|
|
210
|
+
);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
program
|
|
214
|
+
.command('show')
|
|
215
|
+
.description('Show a change (JSON) or a spec (text)')
|
|
216
|
+
.argument('<item>')
|
|
217
|
+
.option('--output <format>', 'output format: json, meta-only, reqs-only, no-scenarios')
|
|
218
|
+
.option('--type <itemType>', 'item type hint: change|spec')
|
|
219
|
+
.action((item: string, options: { output?: string; type?: string }) => {
|
|
220
|
+
const isSpec =
|
|
221
|
+
options.type === 'spec' || existsSync(join('llmanspec', 'specs', `${item}.feature`));
|
|
222
|
+
if (isSpec) {
|
|
223
|
+
const path = join('llmanspec', 'specs', `${item}.feature`);
|
|
224
|
+
if (!existsSync(path)) {
|
|
225
|
+
console.error(`spec not found: ${item}`);
|
|
226
|
+
process.exit(1);
|
|
227
|
+
}
|
|
228
|
+
const entries = collectFeatureFiles('llmanspec/specs').map((specPath) => ({
|
|
229
|
+
fileName: specPath,
|
|
230
|
+
doc: parseCapability(readFileSync(specPath, 'utf8'), specPath),
|
|
231
|
+
}));
|
|
232
|
+
const summary = collectSpecs(entries).find((x) => x.id === item);
|
|
233
|
+
const morphology = summary
|
|
234
|
+
? `\n\n## Morphology\nruleCount=${summary.morphology.ruleCount} enforced=${summary.morphology.ruleEnforcedCount} manual=${summary.morphology.ruleManualCount} pending=${summary.morphology.rulePendingCount} acceptanceCount=${summary.morphology.acceptanceCount}`
|
|
235
|
+
: '';
|
|
236
|
+
console.log(`## Spec\n${readFileSync(path, 'utf8').trimEnd()}${morphology}`);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
if (options.output !== 'json') {
|
|
240
|
+
console.error('only --output json is supported for changes (text format pending)');
|
|
241
|
+
process.exit(1);
|
|
242
|
+
}
|
|
243
|
+
const io = makeIo(process.cwd());
|
|
244
|
+
const result = showChangeJson(
|
|
245
|
+
{
|
|
246
|
+
io,
|
|
247
|
+
discovery: makeIo(process.cwd()),
|
|
248
|
+
root: process.cwd(),
|
|
249
|
+
specsDir: 'llmanspec/specs',
|
|
250
|
+
now: new Date(),
|
|
251
|
+
},
|
|
252
|
+
item,
|
|
253
|
+
);
|
|
254
|
+
console.log(JSON.stringify(result, null, 2));
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
program
|
|
258
|
+
.command('graph')
|
|
259
|
+
.description('Generate a change dependency graph (mermaid)')
|
|
260
|
+
.option('--format <format>', 'output format', 'mermaid')
|
|
261
|
+
.action((options: { format: string }) => {
|
|
262
|
+
if (options.format !== 'mermaid') {
|
|
263
|
+
console.error(`unsupported format: ${options.format}`);
|
|
264
|
+
process.exit(1);
|
|
265
|
+
}
|
|
266
|
+
console.log(graphMermaid(makeIo(process.cwd()), process.cwd()).join('\n'));
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
const spec = program.command('spec').description('Spec authoring helpers');
|
|
270
|
+
|
|
271
|
+
spec
|
|
272
|
+
.command('skeleton')
|
|
273
|
+
.description('Generate a single-track spec skeleton for a capability')
|
|
274
|
+
.argument('<capability>')
|
|
275
|
+
.action((capability: string) => {
|
|
276
|
+
const locale = existsSync('llmanspec/config.yaml')
|
|
277
|
+
? loadConfig(readFileSync('llmanspec/config.yaml', 'utf8')).locale
|
|
278
|
+
: 'en';
|
|
279
|
+
const path = scaffoldSpec(makeIo(process.cwd()), 'llmanspec/specs', capability, locale);
|
|
280
|
+
console.log(`wrote ${path}`);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
spec
|
|
284
|
+
.command('next-req-id')
|
|
285
|
+
.description('Allocate the next free global req id (rN)')
|
|
286
|
+
.action(() => {
|
|
287
|
+
console.log(nextReqId(makeIo(process.cwd()), 'llmanspec/specs'));
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
const project = program.command('project').description('Project management commands');
|
|
291
|
+
|
|
292
|
+
project
|
|
293
|
+
.command('migrate')
|
|
294
|
+
.description('Legacy migration entry (informational only)')
|
|
295
|
+
.action(() => {
|
|
296
|
+
console.log('legacy 迁移实现(spec.toon / specs-flatten 等)不随本工具提供;');
|
|
297
|
+
console.log(
|
|
298
|
+
'本工具直接读取既有 llmanspec 布局(config.yaml / specs/*.feature / changes/),零迁移可读。',
|
|
299
|
+
);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
const archive = program
|
|
303
|
+
.command('archive')
|
|
304
|
+
.description(
|
|
305
|
+
'Archive workflow commands (cold backup). Prefer `change finalize` to seal a change',
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
archive
|
|
309
|
+
.command('freeze')
|
|
310
|
+
.description('Freeze dated archived changes into a single 7z cold backup')
|
|
311
|
+
.option('--before <date>', 'freeze entries older than this date (YYYY-MM-DD)')
|
|
312
|
+
.option('--keep-recent <n>', 'keep N most recent candidates unfrozen', '0')
|
|
313
|
+
.option('--dry-run', 'list candidates without freezing')
|
|
314
|
+
.option('--list', 'list entries already in the cold-backup archive')
|
|
315
|
+
.action(
|
|
316
|
+
async (options: { before?: string; keepRecent: string; dryRun?: boolean; list?: boolean }) => {
|
|
317
|
+
const sz = await makeWasmSevenZip();
|
|
318
|
+
const root = process.cwd();
|
|
319
|
+
const io = makeIo(root);
|
|
320
|
+
if (options.list) {
|
|
321
|
+
for (const line of await runList(io, sz, root)) console.log(line);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
const result = await runFreeze(io, sz, root, {
|
|
325
|
+
before: options.before,
|
|
326
|
+
keepRecent: Number(options.keepRecent),
|
|
327
|
+
dryRun: options.dryRun,
|
|
328
|
+
});
|
|
329
|
+
for (const line of result.lines) console.log(line);
|
|
330
|
+
},
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
archive
|
|
334
|
+
.command('thaw')
|
|
335
|
+
.description('Restore archived change directories from the cold-backup archive')
|
|
336
|
+
.requiredOption(
|
|
337
|
+
'--change <name>',
|
|
338
|
+
'archived change directory to restore (repeatable)',
|
|
339
|
+
(v: string, prev: string[]) => {
|
|
340
|
+
prev.push(v);
|
|
341
|
+
return prev;
|
|
342
|
+
},
|
|
343
|
+
[] as string[],
|
|
344
|
+
)
|
|
345
|
+
.action(async (options: { change: string[] }) => {
|
|
346
|
+
const sz = await makeWasmSevenZip();
|
|
347
|
+
const root = process.cwd();
|
|
348
|
+
const io = makeIo(root);
|
|
349
|
+
try {
|
|
350
|
+
const result = await runThaw(io, sz, root, options.change);
|
|
351
|
+
for (const line of result.lines) console.log(line);
|
|
352
|
+
} catch (error) {
|
|
353
|
+
console.error((error as Error).message);
|
|
354
|
+
process.exit(1);
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
const review = program
|
|
359
|
+
.command('review')
|
|
360
|
+
.description('Aggregate review: pending/manual/unbound/stale signals plus a validate sweep');
|
|
361
|
+
|
|
362
|
+
review
|
|
363
|
+
.option('--capability <capability>', 'restrict the sweep to one capability/spec id')
|
|
364
|
+
.option('--json', 'emit structured JSON (signals + summary)')
|
|
365
|
+
.option('--export-html <path>', 'write a self-contained HTML report')
|
|
366
|
+
.action((options: { capability?: string; json?: boolean; exportHtml?: string }) => {
|
|
367
|
+
const entries = collectFeatureFiles('llmanspec/specs').map((path) => ({
|
|
368
|
+
fileName: path,
|
|
369
|
+
doc: parseCapability(readFileSync(path, 'utf8'), path),
|
|
370
|
+
}));
|
|
371
|
+
const config = existsSync('llmanspec/config.yaml')
|
|
372
|
+
? loadConfig(readFileSync('llmanspec/config.yaml', 'utf8'))
|
|
373
|
+
: null;
|
|
374
|
+
const bindings = config?.bdd?.bindings?.filter((b) => b.kind === 'tags') ?? [];
|
|
375
|
+
const changeIo = makeIo(process.cwd());
|
|
376
|
+
const boundCount = collectChanges(changeIo, process.cwd(), new Date()).filter(
|
|
377
|
+
(c) => c.hasBinding,
|
|
378
|
+
).length;
|
|
379
|
+
const activeChanges = collectChanges(changeIo, process.cwd(), new Date());
|
|
380
|
+
const result = buildReview(
|
|
381
|
+
{
|
|
382
|
+
entries,
|
|
383
|
+
bindings: bindings as TagBinding[],
|
|
384
|
+
boundChangeCount: boundCount,
|
|
385
|
+
activeChanges,
|
|
386
|
+
},
|
|
387
|
+
makeIo(process.cwd()),
|
|
388
|
+
);
|
|
389
|
+
if (options.exportHtml !== undefined) {
|
|
390
|
+
writeFileSync(options.exportHtml, renderReviewHtml(result));
|
|
391
|
+
console.log(`wrote ${options.exportHtml}`);
|
|
392
|
+
}
|
|
393
|
+
if (options.json) {
|
|
394
|
+
console.log(JSON.stringify({ signals: result.signals, summary: result.summary }, null, 2));
|
|
395
|
+
} else {
|
|
396
|
+
console.log(result.lines.join('\n'));
|
|
397
|
+
}
|
|
398
|
+
if (result.exitCode !== 0) process.exit(result.exitCode);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
/** Self-contained HTML report via the v1 shared/review.html template. */
|
|
402
|
+
function renderReviewHtml(result: {
|
|
403
|
+
signals: { kind: string; capability: string; count: number; detail: string }[];
|
|
404
|
+
summary: { criticalCount: number; warningCount: number };
|
|
405
|
+
}): string {
|
|
406
|
+
// main.ts sits at <root>/apps/cli/src — three levels up is the repo root.
|
|
407
|
+
const templatePath = join(
|
|
408
|
+
import.meta.dirname ?? '.',
|
|
409
|
+
'..',
|
|
410
|
+
'..',
|
|
411
|
+
'..',
|
|
412
|
+
'packages',
|
|
413
|
+
'core',
|
|
414
|
+
'templates',
|
|
415
|
+
'shared',
|
|
416
|
+
'review.html',
|
|
417
|
+
);
|
|
418
|
+
const template = readFileSync(templatePath, 'utf8');
|
|
419
|
+
const esc = (input: string): string =>
|
|
420
|
+
input.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>');
|
|
421
|
+
let mermaid = 'graph TD\n';
|
|
422
|
+
const sigJson: unknown[] = [];
|
|
423
|
+
result.signals.forEach((s, idx) => {
|
|
424
|
+
const label = esc(
|
|
425
|
+
`${s.capability} [${s.kind}] = ${s.count} — ${s.detail === '' ? 'ok' : s.detail}`,
|
|
426
|
+
);
|
|
427
|
+
mermaid += ` s${idx}["${label}"]\n`;
|
|
428
|
+
sigJson.push({ kind: s.kind, capability: s.capability, count: s.count, detail: s.detail });
|
|
429
|
+
});
|
|
430
|
+
return template
|
|
431
|
+
.replaceAll('__CRITICAL__', String(result.summary.criticalCount))
|
|
432
|
+
.replaceAll('__WARNING__', String(result.summary.warningCount))
|
|
433
|
+
.replaceAll('__SIGNALS__', JSON.stringify(sigJson))
|
|
434
|
+
.replaceAll('__MERMAID__', mermaid)
|
|
435
|
+
.replaceAll('__GENERATED__', new Date().toISOString());
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const indexCmd = program
|
|
439
|
+
.command('index')
|
|
440
|
+
.description('Index management commands (rebuild, check freshness)');
|
|
441
|
+
|
|
442
|
+
indexCmd
|
|
443
|
+
.command('rebuild')
|
|
444
|
+
.description('Rebuild the pageindex tree from spec IR (no LLM)')
|
|
445
|
+
.action(() => {
|
|
446
|
+
const indexIo = makeIo(process.cwd());
|
|
447
|
+
const entries = collectFeatureFiles('llmanspec/specs').map((path) => ({
|
|
448
|
+
fileName: path,
|
|
449
|
+
doc: parseCapability(readFileSync(path, 'utf8'), path),
|
|
450
|
+
}));
|
|
451
|
+
const result = rebuildIndex(indexIo, process.cwd(), 'llmanspec/specs', entries, {
|
|
452
|
+
chatModel: process.env.LLMAN_SDD_INDEX_CHAT_MODEL ?? '',
|
|
453
|
+
});
|
|
454
|
+
for (const line of result.lines) console.log(line);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
indexCmd
|
|
458
|
+
.command('check')
|
|
459
|
+
.description('Check index freshness without rebuilding')
|
|
460
|
+
.action(() => {
|
|
461
|
+
const result = checkIndexFreshness(makeIo(process.cwd()), process.cwd(), 'llmanspec/specs');
|
|
462
|
+
for (const line of result.lines) console.log(line);
|
|
463
|
+
if (!result.fresh) process.exit(1);
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
program
|
|
467
|
+
.command('context')
|
|
468
|
+
.description('Get specs relevant to a task (agent-oriented, pageindex agentic retrieval)')
|
|
469
|
+
.option('--task <task>', 'natural language task description')
|
|
470
|
+
.option('--paths <paths>', 'comma-separated file paths')
|
|
471
|
+
.option('--top <n>', 'max entries per tier', '5')
|
|
472
|
+
.action((options: { task?: string; paths?: string; top?: string }) => {
|
|
473
|
+
if (!options.task && !options.paths) {
|
|
474
|
+
console.error('at least one of --task or --paths is required');
|
|
475
|
+
process.exit(1);
|
|
476
|
+
}
|
|
477
|
+
const config = resolveChatConfig(process.env as Record<string, string | undefined>);
|
|
478
|
+
if (config === null) {
|
|
479
|
+
// v1 parity: unavailable/error JSON on stdout, exit 0.
|
|
480
|
+
console.log(JSON.stringify(unavailableResult(), null, 2));
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
const tree = loadTree(makeIo(process.cwd()), process.cwd());
|
|
484
|
+
if (tree === null) {
|
|
485
|
+
const missing = unavailableResult();
|
|
486
|
+
missing.status.qualityNote = 'index missing — run `llman-sdd index rebuild` first';
|
|
487
|
+
console.log(JSON.stringify(missing, null, 2));
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
void runContextRetrieval({
|
|
491
|
+
config,
|
|
492
|
+
task: options.task ?? '',
|
|
493
|
+
paths: options.paths,
|
|
494
|
+
top: Number(options.top),
|
|
495
|
+
tree,
|
|
496
|
+
readFile: (p) => readFileSync(p, 'utf8'),
|
|
497
|
+
root: process.cwd(),
|
|
498
|
+
}).then((resolved) => {
|
|
499
|
+
console.log(JSON.stringify(resolved, null, 2));
|
|
500
|
+
});
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
async function main(): Promise<void> {
|
|
504
|
+
await program.parseAsync(process.argv);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
await main();
|