@llman-sdd/cli 0.6.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/cli-shared.ts +22 -0
- package/src/commands/archive.ts +5 -0
- package/src/commands/change.ts +4 -4
- package/src/commands/context.ts +3 -0
- package/src/commands/init.ts +39 -4
- package/src/commands/spec.ts +67 -29
- package/src/commands/validate.ts +179 -15
- package/src/io.ts +11 -2
- package/src/progress.ts +59 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@llman-sdd/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.1",
|
|
4
4
|
"description": "Spec-driven development workflow CLI (the `llman-sdd` command)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"build": "bun run ./scripts/build-binary.ts"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@llman-sdd/core": "0.
|
|
30
|
+
"@llman-sdd/core": "0.7.1",
|
|
31
31
|
"commander": "^15.0.0"
|
|
32
32
|
},
|
|
33
33
|
"engines": {
|
package/src/cli-shared.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Command modules depend one-way on this file; it must not import any of them.
|
|
5
5
|
*/
|
|
6
6
|
import { existsSync, readFileSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
7
8
|
|
|
8
9
|
import {
|
|
9
10
|
discoverSpecs,
|
|
@@ -12,6 +13,7 @@ import {
|
|
|
12
13
|
loadConfigDetail,
|
|
13
14
|
makeEmbeddedTemplateIo,
|
|
14
15
|
resolveChangeId,
|
|
16
|
+
resolveInstanceRoot,
|
|
15
17
|
type TemplateIo,
|
|
16
18
|
} from '@llman-sdd/core';
|
|
17
19
|
import type { Command } from 'commander';
|
|
@@ -75,6 +77,26 @@ export function loadSpecEntries(): ReturnType<typeof discoverSpecs> {
|
|
|
75
77
|
return discoverSpecs('llmanspec/specs', newIo());
|
|
76
78
|
}
|
|
77
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Per-root specs dir (r94): the nearest llmanspec/ instance at or above the
|
|
82
|
+
* start dir (--directory override). Identity 'llmanspec/specs' when the cwd
|
|
83
|
+
* is itself the instance root — legacy relative output paths stay
|
|
84
|
+
* byte-identical there.
|
|
85
|
+
*/
|
|
86
|
+
export function resolveRootSpecsDir(directory?: string): string {
|
|
87
|
+
const start = directory ?? process.cwd();
|
|
88
|
+
const root = resolveInstanceRoot(start, newIo());
|
|
89
|
+
if (root === null) {
|
|
90
|
+
throw new CliError(`no llman spec root at or above: ${start}`);
|
|
91
|
+
}
|
|
92
|
+
return root === process.cwd() ? 'llmanspec/specs' : join(root, 'llmanspec', 'specs');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Spec entries of the resolved instance root (per-root registry, r94). */
|
|
96
|
+
export function loadRootSpecEntries(directory?: string): ReturnType<typeof discoverSpecs> {
|
|
97
|
+
return discoverSpecs(resolveRootSpecsDir(directory), newIo());
|
|
98
|
+
}
|
|
99
|
+
|
|
78
100
|
/** One-time legacy `bdd:` migration warning — printed to stderr by the first
|
|
79
101
|
* config-reading command of a process (specs-check-config-and-unbound-feed). */
|
|
80
102
|
let legacyBddWarningShown = false;
|
package/src/commands/archive.ts
CHANGED
|
@@ -14,6 +14,7 @@ import type { Command } from 'commander';
|
|
|
14
14
|
|
|
15
15
|
import { CliError } from '../cli-shared.ts';
|
|
16
16
|
import { makeCliGit, makeIo } from '../io.ts';
|
|
17
|
+
import { progressNote } from '../progress.ts';
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* r24: warn (never block) when freeze/thaw runs outside the main checkout —
|
|
@@ -69,6 +70,8 @@ export function registerArchive(program: Command): void {
|
|
|
69
70
|
for (const line of await runList(io, sz, root)) console.log(line);
|
|
70
71
|
return;
|
|
71
72
|
}
|
|
73
|
+
// 7z 压缩可能耗时 — 进度提示防止看上去卡住(B1);dry-run/list 不走压缩
|
|
74
|
+
if (!options.dryRun) progressNote('freeze.pack', root);
|
|
72
75
|
const result = await runFreeze(io, sz, root, {
|
|
73
76
|
before: options.before,
|
|
74
77
|
keepRecent: Number(options.keepRecent),
|
|
@@ -105,6 +108,8 @@ export function registerArchive(program: Command): void {
|
|
|
105
108
|
const sz = await makeEmbeddedSevenZip();
|
|
106
109
|
const io = makeIo(root);
|
|
107
110
|
try {
|
|
111
|
+
// 7z 解包可能耗时 — 进度提示防止看上去卡住(B1)
|
|
112
|
+
progressNote('thaw.unpack', root);
|
|
108
113
|
const result = await runThaw(io, sz, root, options.change, { dest: options.dest });
|
|
109
114
|
for (const line of result.lines) console.log(line);
|
|
110
115
|
} catch (error) {
|
package/src/commands/change.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync, lstatSync, readFileSync, readdirSync } from 'node:fs';
|
|
2
|
-
import { resolve } from 'node:path';
|
|
2
|
+
import { basename, resolve } from 'node:path';
|
|
3
3
|
|
|
4
4
|
import {
|
|
5
5
|
CLOSE_OUT_TASK_HINT,
|
|
@@ -291,9 +291,9 @@ export function registerChange(program: Command): void {
|
|
|
291
291
|
skipCleanTree: true,
|
|
292
292
|
today: new Date().toISOString().slice(0, 10),
|
|
293
293
|
});
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
);
|
|
294
|
+
// node:path.basename — platform-aware (win32 backslashes would defeat
|
|
295
|
+
// a hand-rolled lastIndexOf('/')).
|
|
296
|
+
const archiveName = basename(result.archiveDir ?? '');
|
|
297
297
|
console.log(`Change '${id}' archived as '${archiveName}'.`);
|
|
298
298
|
if (result.executedIn !== null) {
|
|
299
299
|
console.log(`executed in target worktree ${result.executedIn}`);
|
package/src/commands/context.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
import type { Command } from 'commander';
|
|
10
10
|
|
|
11
11
|
import { CliError, loadSpecEntries, newIo, resolveBackend } from '../cli-shared.ts';
|
|
12
|
+
import { progressNote } from '../progress.ts';
|
|
12
13
|
|
|
13
14
|
// (err path below converted to CliError; resolveBackend may also throw CliError)
|
|
14
15
|
|
|
@@ -45,6 +46,8 @@ export function registerContext(program: Command): void {
|
|
|
45
46
|
console.log(JSON.stringify(unavailableResult(), null, 2));
|
|
46
47
|
return;
|
|
47
48
|
}
|
|
49
|
+
// LLM 链路可能是秒级 — 进度提示防止看上去卡住(B1)
|
|
50
|
+
progressNote('context.retrieve', process.cwd());
|
|
48
51
|
const result = await runContextRetrieval({
|
|
49
52
|
config,
|
|
50
53
|
task: options.task ?? '',
|
package/src/commands/init.ts
CHANGED
|
@@ -1,11 +1,22 @@
|
|
|
1
1
|
import { mkdirSync } from 'node:fs';
|
|
2
2
|
import { resolve } from 'node:path';
|
|
3
3
|
|
|
4
|
-
import { runInit } from '@llman-sdd/core';
|
|
4
|
+
import { discoverRoots, refreshSubRootBlocks, runInit } from '@llman-sdd/core';
|
|
5
5
|
import type { Command } from 'commander';
|
|
6
6
|
|
|
7
7
|
import { CliError, templateIo, version } from '../cli-shared.ts';
|
|
8
|
-
import { makeIo } from '../io.ts';
|
|
8
|
+
import { makeCliGit, makeIo } from '../io.ts';
|
|
9
|
+
import { progressNote } from '../progress.ts';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* r93: the repo-root instance (git toplevel, or outside any repo) carries the
|
|
13
|
+
* agent skill surface; a sub-root init defaults to blocks-only. `--skills`
|
|
14
|
+
* opts a sub-root in.
|
|
15
|
+
*/
|
|
16
|
+
function isRepoRootInstance(root: string): boolean {
|
|
17
|
+
const toplevel = makeCliGit(root).runOpt(['rev-parse', '--show-toplevel']);
|
|
18
|
+
return toplevel === null || resolve(toplevel) === root;
|
|
19
|
+
}
|
|
9
20
|
|
|
10
21
|
export function registerInit(program: Command): void {
|
|
11
22
|
program
|
|
@@ -13,10 +24,17 @@ export function registerInit(program: Command): void {
|
|
|
13
24
|
.description('Initialize llmanspec in your project (--update to refresh existing)')
|
|
14
25
|
.argument('[path]', 'target directory (created when missing; defaults to cwd)')
|
|
15
26
|
.option('--update', 'refresh an existing installation')
|
|
27
|
+
.option(
|
|
28
|
+
'--skills',
|
|
29
|
+
'inject .agents/skills at a sub-root instance (repo-root instances always inject)',
|
|
30
|
+
)
|
|
16
31
|
.option('--locale <locale>', 'locale for generated templates (defaults to config or en)')
|
|
17
32
|
.option('--lang <locale>', 'alias of --locale')
|
|
18
33
|
.action(
|
|
19
|
-
(
|
|
34
|
+
(
|
|
35
|
+
path: string | undefined,
|
|
36
|
+
options: { update?: boolean; skills?: boolean; locale?: string; lang?: string },
|
|
37
|
+
) => {
|
|
20
38
|
if (options.locale !== undefined && options.lang !== undefined) {
|
|
21
39
|
throw new CliError('--locale and --lang are mutually exclusive (they are aliases)');
|
|
22
40
|
}
|
|
@@ -25,13 +43,30 @@ export function registerInit(program: Command): void {
|
|
|
25
43
|
root = resolve(path);
|
|
26
44
|
mkdirSync(root, { recursive: true });
|
|
27
45
|
}
|
|
46
|
+
const repoRootInstance = isRepoRootInstance(root);
|
|
47
|
+
// 模板生成/子根扫块可能耗时 — 进度提示防止看上去卡住(B1)
|
|
48
|
+
progressNote('init.generate', root);
|
|
28
49
|
const result = runInit(makeIo(root), templateIo, {
|
|
29
50
|
update: options.update ?? false,
|
|
30
51
|
locale: options.locale ?? options.lang,
|
|
31
52
|
version,
|
|
53
|
+
skills: options.skills === true || repoRootInstance,
|
|
32
54
|
});
|
|
55
|
+
let swept = '';
|
|
56
|
+
if ((options.update ?? false) && repoRootInstance) {
|
|
57
|
+
// r93 --update sweep: refresh managed blocks of every discovered
|
|
58
|
+
// sub-root (blocks only; skills stay a repo-root surface).
|
|
59
|
+
const subRoots = discoverRoots(root, makeIo(root)).filter((r) => r.rootDir !== root);
|
|
60
|
+
const refreshed: string[] = [];
|
|
61
|
+
for (const sub of subRoots) {
|
|
62
|
+
if (refreshSubRootBlocks(makeIo(sub.rootDir), templateIo, version)) {
|
|
63
|
+
refreshed.push(sub.rootDir);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (refreshed.length > 0) swept = `, blocks refreshed: ${refreshed.join(', ')}`;
|
|
67
|
+
}
|
|
33
68
|
const removed = result.removed.length > 0 ? `, removed: ${result.removed.join(', ')}` : '';
|
|
34
|
-
console.log(`initialized llmanspec (${result.skills.length} skills${removed})`);
|
|
69
|
+
console.log(`initialized llmanspec (${result.skills.length} skills${removed}${swept})`);
|
|
35
70
|
},
|
|
36
71
|
);
|
|
37
72
|
}
|
package/src/commands/spec.ts
CHANGED
|
@@ -20,9 +20,10 @@ import {
|
|
|
20
20
|
addReportOutputOptions,
|
|
21
21
|
CliError,
|
|
22
22
|
loadCliConfig,
|
|
23
|
-
|
|
23
|
+
loadRootSpecEntries,
|
|
24
24
|
newIo,
|
|
25
25
|
resolveOutMode,
|
|
26
|
+
resolveRootSpecsDir,
|
|
26
27
|
} from '../cli-shared.ts';
|
|
27
28
|
|
|
28
29
|
/** Walk paths collecting .feature files (directories recurse). */
|
|
@@ -61,13 +62,18 @@ export function registerSpec(program: Command): void {
|
|
|
61
62
|
.description('Generate a single-track spec skeleton for a capability')
|
|
62
63
|
.argument('<capability>')
|
|
63
64
|
.option('--force', 'overwrite an existing spec file')
|
|
64
|
-
.
|
|
65
|
+
.option(
|
|
66
|
+
'--directory <path>',
|
|
67
|
+
'instance root resolution start (default: nearest llmanspec/ at or above cwd)',
|
|
68
|
+
)
|
|
69
|
+
.action((capability: string, options: { force?: boolean; directory?: string }) => {
|
|
65
70
|
const locale = loadCliConfig()?.locale ?? 'en';
|
|
66
|
-
const
|
|
71
|
+
const specsDir = resolveRootSpecsDir(options.directory);
|
|
72
|
+
const path = join(specsDir, `${capability}.feature`);
|
|
67
73
|
if (!options.force && existsSync(path)) {
|
|
68
74
|
throw new CliError(`spec already exists: ${path} (use --force to overwrite)`);
|
|
69
75
|
}
|
|
70
|
-
const written = scaffoldSpec(newIo(),
|
|
76
|
+
const written = scaffoldSpec(newIo(), specsDir, capability, locale, {
|
|
71
77
|
force: options.force,
|
|
72
78
|
});
|
|
73
79
|
console.log(`wrote ${written}`);
|
|
@@ -75,10 +81,14 @@ export function registerSpec(program: Command): void {
|
|
|
75
81
|
|
|
76
82
|
spec
|
|
77
83
|
.command('next-req-id')
|
|
78
|
-
.description('Allocate the next
|
|
84
|
+
.description('Allocate the next global req id (max in use + 1, rN)')
|
|
79
85
|
.option('--json', 'emit {reqId}')
|
|
80
|
-
.
|
|
81
|
-
|
|
86
|
+
.option(
|
|
87
|
+
'--directory <path>',
|
|
88
|
+
'instance root resolution start (default: nearest llmanspec/ at or above cwd)',
|
|
89
|
+
)
|
|
90
|
+
.action((options: { json?: boolean; directory?: string }) => {
|
|
91
|
+
const reqId = nextReqId(newIo(), resolveRootSpecsDir(options.directory));
|
|
82
92
|
if (options.json) console.log(JSON.stringify({ reqId }, null, 2));
|
|
83
93
|
else console.log(reqId);
|
|
84
94
|
});
|
|
@@ -91,16 +101,31 @@ export function registerSpec(program: Command): void {
|
|
|
91
101
|
.argument('<req_id>')
|
|
92
102
|
.requiredOption('--title <title>', 'rule title')
|
|
93
103
|
.requiredOption('--statement <statement>', 'rule statement (free text; multiple lines via \\n)')
|
|
94
|
-
.
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
+
.option(
|
|
105
|
+
'--directory <path>',
|
|
106
|
+
'instance root resolution start (default: nearest llmanspec/ at or above cwd)',
|
|
107
|
+
)
|
|
108
|
+
.action(
|
|
109
|
+
(
|
|
110
|
+
capability: string,
|
|
111
|
+
reqId: string,
|
|
112
|
+
options: { title: string; statement: string; directory?: string },
|
|
113
|
+
) => {
|
|
114
|
+
const io = newIo();
|
|
115
|
+
const path = addReq(
|
|
116
|
+
io,
|
|
117
|
+
resolveRootSpecsDir(options.directory),
|
|
118
|
+
loadRootSpecEntries(options.directory),
|
|
119
|
+
{
|
|
120
|
+
capability,
|
|
121
|
+
reqId,
|
|
122
|
+
title: options.title,
|
|
123
|
+
statement: options.statement,
|
|
124
|
+
},
|
|
125
|
+
);
|
|
126
|
+
console.log(path);
|
|
127
|
+
},
|
|
128
|
+
);
|
|
104
129
|
|
|
105
130
|
spec
|
|
106
131
|
.command('add-scenario')
|
|
@@ -111,22 +136,31 @@ export function registerSpec(program: Command): void {
|
|
|
111
136
|
.option('--given <given>', 'Given step (optional)')
|
|
112
137
|
.requiredOption('--when <when>', 'When step')
|
|
113
138
|
.requiredOption('--then <then>', 'Then step')
|
|
139
|
+
.option(
|
|
140
|
+
'--directory <path>',
|
|
141
|
+
'instance root resolution start (default: nearest llmanspec/ at or above cwd)',
|
|
142
|
+
)
|
|
114
143
|
.action(
|
|
115
144
|
(
|
|
116
145
|
capability: string,
|
|
117
146
|
reqId: string,
|
|
118
147
|
scenarioId: string,
|
|
119
|
-
options: { given?: string; when: string; then: string },
|
|
148
|
+
options: { given?: string; when: string; then: string; directory?: string },
|
|
120
149
|
) => {
|
|
121
150
|
const io = newIo();
|
|
122
|
-
const path = addScenario(
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
151
|
+
const path = addScenario(
|
|
152
|
+
io,
|
|
153
|
+
resolveRootSpecsDir(options.directory),
|
|
154
|
+
loadRootSpecEntries(options.directory),
|
|
155
|
+
{
|
|
156
|
+
capability,
|
|
157
|
+
reqId,
|
|
158
|
+
scenarioId,
|
|
159
|
+
given: options.given,
|
|
160
|
+
when: options.when,
|
|
161
|
+
thenText: options.then,
|
|
162
|
+
},
|
|
163
|
+
);
|
|
130
164
|
console.log(path);
|
|
131
165
|
},
|
|
132
166
|
);
|
|
@@ -176,8 +210,12 @@ export function registerSpec(program: Command): void {
|
|
|
176
210
|
.command('resolve-req')
|
|
177
211
|
.description('Resolve an rN to its capability and statement')
|
|
178
212
|
.argument('<req_id>')
|
|
179
|
-
.
|
|
180
|
-
|
|
213
|
+
.option(
|
|
214
|
+
'--directory <path>',
|
|
215
|
+
'instance root resolution start (default: nearest llmanspec/ at or above cwd)',
|
|
216
|
+
)
|
|
217
|
+
.action((reqId: string, options: { directory?: string }) => {
|
|
218
|
+
const resolved = resolveReq(loadRootSpecEntries(options.directory), reqId);
|
|
181
219
|
if (resolved === null) {
|
|
182
220
|
throw new CliError(`req id not found: ${reqId}`);
|
|
183
221
|
}
|
|
@@ -201,7 +239,7 @@ export function registerSpec(program: Command): void {
|
|
|
201
239
|
throw new CliError(`invalid --limit: ${raw} (non-negative integer)`, 2);
|
|
202
240
|
}
|
|
203
241
|
const limit = Number(raw);
|
|
204
|
-
const feed: UnboundFeed = buildUnboundFeed(
|
|
242
|
+
const feed: UnboundFeed = buildUnboundFeed(loadRootSpecEntries(), limit);
|
|
205
243
|
const mode = resolveOutMode(options.output, options.json, options.compactJson);
|
|
206
244
|
if (mode !== 'human') {
|
|
207
245
|
console.log(renderMachine(feed, mode));
|
package/src/commands/validate.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { relative } from 'node:path';
|
|
2
|
+
|
|
1
3
|
import {
|
|
2
4
|
applyStrict,
|
|
3
5
|
buildDuplicatesFor,
|
|
@@ -5,11 +7,14 @@ import {
|
|
|
5
7
|
collectChanges,
|
|
6
8
|
currentBranch,
|
|
7
9
|
defaultBranch,
|
|
10
|
+
discoverRoots,
|
|
8
11
|
evaluateStaleness,
|
|
9
12
|
formatTotals,
|
|
10
13
|
notApplicableStaleness,
|
|
11
14
|
renderMachine,
|
|
15
|
+
resolveInstanceRoot,
|
|
12
16
|
runHarnessForSpecs,
|
|
17
|
+
scopeCrossings,
|
|
13
18
|
specRelFor,
|
|
14
19
|
specIdOf,
|
|
15
20
|
STAGE_ORDER,
|
|
@@ -17,6 +22,7 @@ import {
|
|
|
17
22
|
validateChange,
|
|
18
23
|
type ChangeIssue,
|
|
19
24
|
type HarnessGate,
|
|
25
|
+
type RootEntry,
|
|
20
26
|
type StalenessInfo,
|
|
21
27
|
} from '@llman-sdd/core';
|
|
22
28
|
import type { Command } from 'commander';
|
|
@@ -51,13 +57,34 @@ function compareItems(a: VItem, b: VItem): number {
|
|
|
51
57
|
return a.id < b.id ? -1 : a.id > b.id ? 1 : a.type.localeCompare(b.type);
|
|
52
58
|
}
|
|
53
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Multi-root context of the specs being validated (r91-r92): `repoRelPrefix`
|
|
62
|
+
* lifts instance-root-relative scopes into repo-relative paths for the
|
|
63
|
+
* staleness diff match ('' when the instance root is the git toplevel —
|
|
64
|
+
* byte-identical legacy behavior); `otherRoots` feeds the single-ownership
|
|
65
|
+
* crossings that surface as ERROR issues.
|
|
66
|
+
*/
|
|
67
|
+
export interface InstanceContext {
|
|
68
|
+
repoRelPrefix: string;
|
|
69
|
+
otherRoots: RootEntry[];
|
|
70
|
+
}
|
|
71
|
+
|
|
54
72
|
function specV1Items(
|
|
55
|
-
opts: {
|
|
73
|
+
opts: {
|
|
74
|
+
strict?: boolean;
|
|
75
|
+
harness?: HarnessGate;
|
|
76
|
+
harnessOnlyIds?: string[];
|
|
77
|
+
instance?: InstanceContext;
|
|
78
|
+
},
|
|
56
79
|
entries: ReturnType<typeof loadSpecEntries> = loadSpecEntries(),
|
|
57
80
|
): VItem[] {
|
|
58
81
|
const io = newIo();
|
|
59
82
|
const git = makeCliGit(process.cwd());
|
|
60
83
|
const duplicatesFor = buildDuplicatesFor(entries);
|
|
84
|
+
const liftScope = (scope: string): string =>
|
|
85
|
+
opts.instance !== undefined && opts.instance.repoRelPrefix !== ''
|
|
86
|
+
? `${opts.instance.repoRelPrefix}/${scope}`
|
|
87
|
+
: scope;
|
|
61
88
|
|
|
62
89
|
const items: VItem[] = [];
|
|
63
90
|
for (const entry of entries) {
|
|
@@ -65,12 +92,13 @@ function specV1Items(
|
|
|
65
92
|
const verdict = validateCapability(entry as never, duplicatesFor, io, {
|
|
66
93
|
strict: opts.strict === true,
|
|
67
94
|
});
|
|
95
|
+
const scopes = entry.doc.header.scope?.split(',').map((s) => s.trim()) ?? [];
|
|
68
96
|
const specRel = specRelFor(entry.fileName);
|
|
69
97
|
const staleness = evaluateStaleness({
|
|
70
98
|
git,
|
|
71
99
|
root: process.cwd(),
|
|
72
100
|
specRel,
|
|
73
|
-
scope:
|
|
101
|
+
scope: scopes.map(liftScope),
|
|
74
102
|
baseRefEnv: process.env.LLMANSPEC_BASE_REF,
|
|
75
103
|
});
|
|
76
104
|
let issues: ChangeIssue[] = verdict.items.map((i) => ({
|
|
@@ -78,6 +106,18 @@ function specV1Items(
|
|
|
78
106
|
path: i.id,
|
|
79
107
|
message: i.message,
|
|
80
108
|
}));
|
|
109
|
+
if (opts.instance !== undefined) {
|
|
110
|
+
for (const crossing of scopeCrossings(scopes, process.cwd(), opts.instance.otherRoots)) {
|
|
111
|
+
issues = [
|
|
112
|
+
...issues,
|
|
113
|
+
{
|
|
114
|
+
level: 'ERROR',
|
|
115
|
+
path: 'single-ownership',
|
|
116
|
+
message: `scope '${crossing.scope}' crosses sub-root '${crossing.rootDir}' — migrate these specs into that root or narrow the scope (one path, one root)`,
|
|
117
|
+
},
|
|
118
|
+
];
|
|
119
|
+
}
|
|
120
|
+
}
|
|
81
121
|
if (opts.strict === true) issues = [...issues, ...applyStrict(staleness.issues)];
|
|
82
122
|
else issues = [...issues, ...staleness.issues];
|
|
83
123
|
items.push({
|
|
@@ -201,9 +241,33 @@ function renderValidateText(items: VItem[]): void {
|
|
|
201
241
|
console.log(formatTotals(passed, failed, items.length));
|
|
202
242
|
}
|
|
203
243
|
|
|
204
|
-
function
|
|
244
|
+
function gitToplevelOrCwd(): string {
|
|
245
|
+
const toplevel = makeCliGit(process.cwd()).runOpt(['rev-parse', '--show-toplevel']);
|
|
246
|
+
return toplevel ?? process.cwd();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Multi-root context of the cwd instance (r91-r92): empty otherRoots + ''
|
|
251
|
+
* prefix when run at a lone root — legacy byte-identical behavior.
|
|
252
|
+
*/
|
|
253
|
+
function instanceContextFor(maxDepth: number): InstanceContext {
|
|
254
|
+
const toplevel = gitToplevelOrCwd();
|
|
255
|
+
const otherRoots = discoverRoots(toplevel, newIo(), { maxDepth }).filter(
|
|
256
|
+
(r) => r.rootDir !== process.cwd(),
|
|
257
|
+
);
|
|
258
|
+
const repoRelPrefix = toplevel === process.cwd() ? '' : relative(toplevel, process.cwd());
|
|
259
|
+
return { repoRelPrefix, otherRoots };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
interface RootReport {
|
|
263
|
+
root: string;
|
|
264
|
+
items: VItem[];
|
|
265
|
+
summary: ReturnType<typeof summarizeItems>;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function summarizeItems(items: VItem[]) {
|
|
205
269
|
const types = [...new Set(items.map((i) => i.type))] as string[];
|
|
206
|
-
|
|
270
|
+
return {
|
|
207
271
|
totals: {
|
|
208
272
|
items: items.length,
|
|
209
273
|
passed: items.filter((i) => i.valid).length,
|
|
@@ -222,7 +286,10 @@ function renderValidateReport(items: VItem[], mode: 'json' | 'compact-json' | 't
|
|
|
222
286
|
{},
|
|
223
287
|
),
|
|
224
288
|
};
|
|
225
|
-
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function renderValidateReport(items: VItem[], mode: 'json' | 'compact-json' | 'toon'): void {
|
|
292
|
+
console.log(renderMachine({ items, summary: summarizeItems(items), version: '1.0' }, mode));
|
|
226
293
|
}
|
|
227
294
|
|
|
228
295
|
const SPEC_NEXT_STEPS = [
|
|
@@ -252,24 +319,28 @@ function warnDirtySpecsOnDefaultBranch(): void {
|
|
|
252
319
|
let harnessBannerShown = false;
|
|
253
320
|
|
|
254
321
|
/**
|
|
255
|
-
* r13/r48 trigger state: the nested guard env and the check
|
|
322
|
+
* r13/r48 trigger state: the nested guard env and the check flag are read
|
|
256
323
|
* here (CLI boundary) and injected into core as parameters — core harness
|
|
257
|
-
* logic never touches process.env. Commander
|
|
258
|
-
* --check → check=true,
|
|
324
|
+
* logic never touches process.env. Commander binary state:
|
|
325
|
+
* --check → check=true, absent → undefined.
|
|
259
326
|
*/
|
|
260
327
|
function makeHarnessGate(options: { check?: boolean }): HarnessGate {
|
|
261
328
|
const runCommand = loadCliConfig()?.specs?.check_command ?? null;
|
|
262
329
|
const configured = runCommand !== null && runCommand !== '';
|
|
263
330
|
return {
|
|
264
331
|
nested: process.env.LLMAN_SDD_HARNESS_ACTIVE === '1',
|
|
265
|
-
|
|
332
|
+
// Opt-in harness (2026-10 decision): absent → 'off' — structure/state gate
|
|
333
|
+
// only; --check → 'on' (explicit full harness). The CLI never sends
|
|
334
|
+
// 'default'; core still treats a received 'default' as
|
|
335
|
+
// run-if-configured (its own contract).
|
|
336
|
+
check: options.check === true ? 'on' : 'off',
|
|
266
337
|
runner: configured ? makeCliHarnessRunner() : undefined,
|
|
267
338
|
runCommand,
|
|
268
339
|
cwd: process.cwd(),
|
|
269
340
|
onBeforeFirstRun: (expanded: string): void => {
|
|
270
341
|
if (harnessBannerShown) return;
|
|
271
342
|
harnessBannerShown = true;
|
|
272
|
-
console.error(`running spec check: ${expanded} (
|
|
343
|
+
console.error(`running spec check: ${expanded} (explicit --check harness run)`);
|
|
273
344
|
},
|
|
274
345
|
};
|
|
275
346
|
}
|
|
@@ -285,9 +356,16 @@ export function registerValidate(program: Command): void {
|
|
|
285
356
|
.option('--type <type>', 'force disambiguation: change | spec')
|
|
286
357
|
.option('--stage <stage>', 'change stage gate: draft | designed | planned | full')
|
|
287
358
|
.option('--strict', 'warnings also make the exit code non-zero')
|
|
359
|
+
.option(
|
|
360
|
+
'--directory <path>',
|
|
361
|
+
'instance root resolution start (default: nearest llmanspec/ at or above cwd)',
|
|
362
|
+
)
|
|
363
|
+
.option(
|
|
364
|
+
'--all-roots',
|
|
365
|
+
'aggregate: validate every discovered llmanspec root (specs scope, any root red → non-zero exit)',
|
|
366
|
+
)
|
|
288
367
|
.option('--include-info', 'keep INFO-level issues (default: WARNING and above)')
|
|
289
|
-
.option('--
|
|
290
|
-
.option('--check', 'run the spec check (default when specs.check_command is configured)');
|
|
368
|
+
.option('--check', 'run the spec check (full harness) explicitly — opt-in, default skips');
|
|
291
369
|
addReportOutputOptions(validate);
|
|
292
370
|
validate.action(
|
|
293
371
|
(
|
|
@@ -304,6 +382,8 @@ export function registerValidate(program: Command): void {
|
|
|
304
382
|
includeInfo?: boolean;
|
|
305
383
|
output?: string;
|
|
306
384
|
check?: boolean;
|
|
385
|
+
directory?: string;
|
|
386
|
+
allRoots?: boolean;
|
|
307
387
|
},
|
|
308
388
|
) => {
|
|
309
389
|
if (!assertCompactJsonPairing(options)) return;
|
|
@@ -323,6 +403,77 @@ export function registerValidate(program: Command): void {
|
|
|
323
403
|
) {
|
|
324
404
|
throw new CliError(`invalid --stage: ${options.stage}`);
|
|
325
405
|
}
|
|
406
|
+
const maxDepth = cliMaxScanDepth(program);
|
|
407
|
+
|
|
408
|
+
// ---- aggregate over every discovered root (r91) ----
|
|
409
|
+
if (options.allRoots === true) {
|
|
410
|
+
if (item !== undefined) {
|
|
411
|
+
throw new CliError('--all-roots validates every root; drop the <item> argument');
|
|
412
|
+
}
|
|
413
|
+
const start = options.directory ?? gitToplevelOrCwd();
|
|
414
|
+
const roots = discoverRoots(start, newIo(), { maxDepth });
|
|
415
|
+
if (roots.length === 0) {
|
|
416
|
+
throw new CliError(`no llmanspec roots discovered under: ${start}`);
|
|
417
|
+
}
|
|
418
|
+
const originalCwd = process.cwd();
|
|
419
|
+
const reports: RootReport[] = [];
|
|
420
|
+
let anyFail = false;
|
|
421
|
+
try {
|
|
422
|
+
for (const root of roots) {
|
|
423
|
+
process.chdir(root.rootDir);
|
|
424
|
+
const items = specV1Items({
|
|
425
|
+
strict: options.strict,
|
|
426
|
+
harness: makeHarnessGate(options),
|
|
427
|
+
instance: instanceContextFor(maxDepth),
|
|
428
|
+
});
|
|
429
|
+
const visible = keepInfo ? items : items.map(stripInfo);
|
|
430
|
+
if (visible.some((i) => !i.valid)) anyFail = true;
|
|
431
|
+
reports.push({
|
|
432
|
+
root: relative(start, root.rootDir) || '.',
|
|
433
|
+
items: visible,
|
|
434
|
+
summary: summarizeItems(visible),
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
} finally {
|
|
438
|
+
process.chdir(originalCwd);
|
|
439
|
+
}
|
|
440
|
+
if (outMode === 'human') {
|
|
441
|
+
for (const report of reports) {
|
|
442
|
+
console.log(`root ${report.root}`);
|
|
443
|
+
renderValidateText(report.items);
|
|
444
|
+
}
|
|
445
|
+
} else {
|
|
446
|
+
const totals = reports.reduce(
|
|
447
|
+
(acc, r) => ({
|
|
448
|
+
items: acc.items + r.summary.totals.items,
|
|
449
|
+
passed: acc.passed + r.summary.totals.passed,
|
|
450
|
+
failed: acc.failed + r.summary.totals.failed,
|
|
451
|
+
}),
|
|
452
|
+
{ items: 0, passed: 0, failed: 0 },
|
|
453
|
+
);
|
|
454
|
+
console.log(
|
|
455
|
+
renderMachine({ roots: reports, summary: { totals }, version: '1.0' }, outMode),
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
if (anyFail) console.error('Error: validation failed');
|
|
459
|
+
exitWith(anyFail ? 1 : 0);
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// ---- instance root resolution: --directory start, else nearest
|
|
464
|
+
// ancestor llmanspec/ ("cd into the subpackage and run", r94). A no-op
|
|
465
|
+
// when cwd already sits at a root (byte-identical legacy path). ----
|
|
466
|
+
if (options.directory !== undefined) {
|
|
467
|
+
const root = resolveInstanceRoot(options.directory, newIo());
|
|
468
|
+
if (root === null) {
|
|
469
|
+
throw new CliError(`no llmanspec root at or above: ${options.directory}`);
|
|
470
|
+
}
|
|
471
|
+
process.chdir(root);
|
|
472
|
+
} else {
|
|
473
|
+
const root = resolveInstanceRoot(process.cwd(), newIo());
|
|
474
|
+
if (root !== null && root !== process.cwd()) process.chdir(root);
|
|
475
|
+
}
|
|
476
|
+
|
|
326
477
|
const harness = makeHarnessGate(options);
|
|
327
478
|
|
|
328
479
|
// ---- single item (auto-disambiguate: spec first, then change) ----
|
|
@@ -332,7 +483,12 @@ export function registerValidate(program: Command): void {
|
|
|
332
483
|
options.type === 'change' ? undefined : entries.find((e) => specIdOf(e) === item);
|
|
333
484
|
if (specEntry !== undefined) {
|
|
334
485
|
const items = specV1Items(
|
|
335
|
-
{
|
|
486
|
+
{
|
|
487
|
+
strict: options.strict,
|
|
488
|
+
harness,
|
|
489
|
+
harnessOnlyIds: [item],
|
|
490
|
+
instance: instanceContextFor(maxDepth),
|
|
491
|
+
},
|
|
336
492
|
entries,
|
|
337
493
|
);
|
|
338
494
|
const mine = items.find((i) => i.id === item);
|
|
@@ -424,10 +580,18 @@ export function registerValidate(program: Command): void {
|
|
|
424
580
|
const effectiveChanges = defaultSpecsOnly ? false : changeScope;
|
|
425
581
|
|
|
426
582
|
let items: VItem[] = [];
|
|
427
|
-
if (effectiveSpecs)
|
|
583
|
+
if (effectiveSpecs) {
|
|
584
|
+
items = items.concat(
|
|
585
|
+
specV1Items({
|
|
586
|
+
strict: options.strict,
|
|
587
|
+
harness,
|
|
588
|
+
instance: instanceContextFor(maxDepth),
|
|
589
|
+
}),
|
|
590
|
+
);
|
|
591
|
+
}
|
|
428
592
|
if (effectiveChanges) {
|
|
429
593
|
const names = collectChanges(newIo(), process.cwd(), new Date(), {
|
|
430
|
-
maxScanDepth:
|
|
594
|
+
maxScanDepth: maxDepth,
|
|
431
595
|
}).map((c) => c.name);
|
|
432
596
|
items = items.concat(
|
|
433
597
|
changeV1Items(names, { stage: options.stage, strict: options.strict }),
|
package/src/io.ts
CHANGED
|
@@ -14,10 +14,20 @@ import {
|
|
|
14
14
|
statSync,
|
|
15
15
|
writeFileSync,
|
|
16
16
|
} from 'node:fs';
|
|
17
|
-
import { isAbsolute, join } from 'node:path';
|
|
17
|
+
import { dirname, isAbsolute, join } from 'node:path';
|
|
18
18
|
|
|
19
19
|
import { makeSpawnGit, type GitLike } from '@llman-sdd/core';
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Platform-aware parent directory (node:path.dirname, never hand-rolled
|
|
23
|
+
* '/' slicing): on win32, join() yields backslashes and a lastIndexOf('/')
|
|
24
|
+
* fallback would resolve to '.', so mkdir of a nested new dir would fail with
|
|
25
|
+
* ENOENT. Exported for unit-test guards (A2 platform compat).
|
|
26
|
+
*/
|
|
27
|
+
export function parentOf(p: string): string {
|
|
28
|
+
return dirname(p);
|
|
29
|
+
}
|
|
30
|
+
|
|
21
31
|
export interface CliIo {
|
|
22
32
|
exists(path: string): boolean;
|
|
23
33
|
readText(path: string): string;
|
|
@@ -37,7 +47,6 @@ export interface CliIo {
|
|
|
37
47
|
|
|
38
48
|
export function makeIo(root: string): CliIo {
|
|
39
49
|
const full = (p: string): string => (isAbsolute(p) ? p : join(root, p));
|
|
40
|
-
const parentOf = (p: string): string => p.slice(0, p.lastIndexOf('/')) || '.';
|
|
41
50
|
return {
|
|
42
51
|
exists: (p) => existsSync(full(p)),
|
|
43
52
|
readText: (p) => readFileSync(full(p), 'utf8'),
|
package/src/progress.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Localized stage-progress notes (B1): genuinely long-running operations print
|
|
3
|
+
* a short one-line stderr note in the project locale (zh → zh-Hans text,
|
|
4
|
+
* everything else English) so neither humans nor agents mistake progress for a
|
|
5
|
+
* hang. Progress ALWAYS goes to stderr (capture contract: results on stdout,
|
|
6
|
+
* progress on stderr); each key fires at most once per process; notes are only
|
|
7
|
+
* emitted before the long op itself, never ahead of domain errors.
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync } from 'node:fs';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
|
|
12
|
+
type Locale = 'zh' | 'en';
|
|
13
|
+
|
|
14
|
+
const NOTES: Record<string, Record<Locale, string>> = {
|
|
15
|
+
'context.retrieve': {
|
|
16
|
+
zh: '正在检索 specs 上下文(LLM)...',
|
|
17
|
+
en: 'Retrieving spec context (LLM)...',
|
|
18
|
+
},
|
|
19
|
+
'init.generate': {
|
|
20
|
+
zh: '正在初始化 llmanspec/ 与 skills...',
|
|
21
|
+
en: 'Initializing llmanspec/ and skills...',
|
|
22
|
+
},
|
|
23
|
+
'freeze.pack': {
|
|
24
|
+
zh: '正在压缩冻结归档(7z)...',
|
|
25
|
+
en: 'Packing freeze archive (7z)...',
|
|
26
|
+
},
|
|
27
|
+
'thaw.unpack': {
|
|
28
|
+
zh: '正在解包冻结归档(7z)...',
|
|
29
|
+
en: 'Unpacking freeze archive (7z)...',
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** Resolve the note text for a locale (pure; exported for unit tests). */
|
|
34
|
+
export function resolveNote(key: string, locale: Locale): string | undefined {
|
|
35
|
+
return NOTES[key]?.[locale];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Detect the project locale from llmanspec/config.yaml (zh-* → zh, else en). */
|
|
39
|
+
export function detectLocale(cwd: string): Locale {
|
|
40
|
+
try {
|
|
41
|
+
const cfg = readFileSync(join(cwd, 'llmanspec', 'config.yaml'), 'utf8');
|
|
42
|
+
const m = cfg.match(/^locale:\s*([A-Za-z-]+)/mu);
|
|
43
|
+
if (m?.[1]?.startsWith('zh') ?? false) return 'zh';
|
|
44
|
+
} catch {
|
|
45
|
+
// No config at cwd — English fallback.
|
|
46
|
+
}
|
|
47
|
+
return 'en';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const shown = new Set<string>();
|
|
51
|
+
|
|
52
|
+
/** Print a localized progress note to stderr once per key per process. */
|
|
53
|
+
export function progressNote(key: string, cwd: string = process.cwd()): void {
|
|
54
|
+
if (shown.has(key)) return;
|
|
55
|
+
const text = resolveNote(key, detectLocale(cwd));
|
|
56
|
+
if (text === undefined) return;
|
|
57
|
+
shown.add(key);
|
|
58
|
+
process.stderr.write(`${text}\n`);
|
|
59
|
+
}
|