@llman-sdd/core 0.6.0 → 0.7.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 +1 -1
- package/src/index.ts +11 -0
- package/src/init/init.ts +44 -15
- package/src/report/specHelpers.ts +9 -10
- package/src/validation/roots.ts +123 -0
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -107,6 +107,16 @@ export {
|
|
|
107
107
|
type StageGate,
|
|
108
108
|
} from './validation/changeCheck.ts';
|
|
109
109
|
export { discoverSpecs, type DiscoveryIo } from './validation/discover.ts';
|
|
110
|
+
export {
|
|
111
|
+
discoverRoots,
|
|
112
|
+
isValidRoot,
|
|
113
|
+
resolveInstanceRoot,
|
|
114
|
+
scopeCrossings,
|
|
115
|
+
ROOT_EXCLUDED_DIRS,
|
|
116
|
+
type RootEntry,
|
|
117
|
+
type RootsIo,
|
|
118
|
+
type ScopeCrossing,
|
|
119
|
+
} from './validation/roots.ts';
|
|
110
120
|
export {
|
|
111
121
|
expandRunCommand,
|
|
112
122
|
runHarnessForSpecs,
|
|
@@ -187,6 +197,7 @@ export {
|
|
|
187
197
|
} from './templates/embedded.ts';
|
|
188
198
|
export {
|
|
189
199
|
TEMPLATES_ROOT,
|
|
200
|
+
refreshSubRootBlocks,
|
|
190
201
|
runInit,
|
|
191
202
|
updateFileWithMarkers,
|
|
192
203
|
type InitIo,
|
package/src/init/init.ts
CHANGED
|
@@ -80,7 +80,7 @@ function writeDefaultConfig(io: InitIo, locale: string): void {
|
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
export interface InitResult {
|
|
83
|
-
/** Skill dirs written, in render order. */
|
|
83
|
+
/** Skill dirs written, in render order ([] when skills injection is off). */
|
|
84
84
|
skills: string[];
|
|
85
85
|
/** llman-sdd-* directories removed by --update namespace cleanup. */
|
|
86
86
|
removed: string[];
|
|
@@ -89,10 +89,31 @@ export interface InitResult {
|
|
|
89
89
|
|
|
90
90
|
const SKILLS_BASE = '.agents/skills';
|
|
91
91
|
|
|
92
|
+
/** Managed AGENTS.md marker blocks (root + llmanspec), content-preserving. */
|
|
93
|
+
function writeManagedBlocks(
|
|
94
|
+
io: InitIo,
|
|
95
|
+
templates: TemplateIo,
|
|
96
|
+
config: ReturnType<typeof loadConfig>,
|
|
97
|
+
version: string,
|
|
98
|
+
): void {
|
|
99
|
+
const vars = buildTemplateVars(config, version);
|
|
100
|
+
const locales = localeFallbacks(config.locale);
|
|
101
|
+
for (const [stubPath, agentsPath] of [
|
|
102
|
+
['agents-root-stub.md', 'AGENTS.md'],
|
|
103
|
+
['llmanspec-agents-stub.md', 'llmanspec/AGENTS.md'],
|
|
104
|
+
] as const) {
|
|
105
|
+
const stubRaw = loadLocaleResource(templates, TEMPLATES_ROOT, locales, stubPath);
|
|
106
|
+
if (stubRaw === null) continue;
|
|
107
|
+
const existing = io.exists(agentsPath) ? io.readText(agentsPath) : '';
|
|
108
|
+
const body = renderTemplate(stubRaw, new Map(), vars);
|
|
109
|
+
io.writeText(agentsPath, updateFileWithMarkers(existing, body));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
92
113
|
export function runInit(
|
|
93
114
|
io: InitIo,
|
|
94
115
|
templates: TemplateIo,
|
|
95
|
-
opts: { update: boolean; locale?: string; version: string },
|
|
116
|
+
opts: { update: boolean; locale?: string; version: string; skills: boolean },
|
|
96
117
|
): InitResult {
|
|
97
118
|
io.mkdirp('llmanspec');
|
|
98
119
|
|
|
@@ -110,26 +131,15 @@ export function runInit(
|
|
|
110
131
|
}
|
|
111
132
|
|
|
112
133
|
// 3) AGENTS.md managed blocks (root + llmanspec), preserving content.
|
|
134
|
+
writeManagedBlocks(io, templates, config, opts.version);
|
|
113
135
|
const vars = buildTemplateVars(config, opts.version);
|
|
114
|
-
const locales = localeFallbacks(config.locale);
|
|
115
136
|
const skillTemplates = loadSkillTemplates(templates, TEMPLATES_ROOT, config, vars);
|
|
116
137
|
enforceEthicsGovernance(skillTemplates);
|
|
117
138
|
|
|
118
|
-
for (const [stubPath, agentsPath] of [
|
|
119
|
-
['agents-root-stub.md', 'AGENTS.md'],
|
|
120
|
-
['llmanspec-agents-stub.md', 'llmanspec/AGENTS.md'],
|
|
121
|
-
] as const) {
|
|
122
|
-
const stubRaw = loadLocaleResource(templates, TEMPLATES_ROOT, locales, stubPath);
|
|
123
|
-
if (stubRaw === null) continue;
|
|
124
|
-
const existing = io.exists(agentsPath) ? io.readText(agentsPath) : '';
|
|
125
|
-
const body = renderTemplate(stubRaw, new Map(), vars);
|
|
126
|
-
io.writeText(agentsPath, updateFileWithMarkers(existing, body));
|
|
127
|
-
}
|
|
128
|
-
|
|
129
139
|
// 4) skills namespace cleanup (--update only): remove llman-sdd-* dirs
|
|
130
140
|
// outside the candidate set; un-prefixed custom skills stay untouched.
|
|
131
141
|
const removed: string[] = [];
|
|
132
|
-
if (opts.update && io.exists(SKILLS_BASE)) {
|
|
142
|
+
if (opts.skills && opts.update && io.exists(SKILLS_BASE)) {
|
|
133
143
|
const candidates = new Set(skillCandidates(config).map((f) => f.replace(/\.md$/u, '')));
|
|
134
144
|
for (const entry of io.listDir(SKILLS_BASE)) {
|
|
135
145
|
if (entry.startsWith('llman-sdd-') && !candidates.has(entry)) {
|
|
@@ -140,6 +150,12 @@ export function runInit(
|
|
|
140
150
|
}
|
|
141
151
|
|
|
142
152
|
// 5) write candidates: rendered product trimmed + single trailing newline.
|
|
153
|
+
// Sub-root instances default to no skills injection (r93): the agent skill
|
|
154
|
+
// surface stays at the repo root and sub-root navigation lives in the
|
|
155
|
+
// managed blocks; --skills opts a sub-root in.
|
|
156
|
+
if (!opts.skills) {
|
|
157
|
+
return { skills: [], removed, configPath };
|
|
158
|
+
}
|
|
143
159
|
for (const t of skillTemplates) {
|
|
144
160
|
const dirName = t.name.replace(/\.md$/u, '');
|
|
145
161
|
io.mkdirp(`${SKILLS_BASE}/${dirName}`);
|
|
@@ -148,3 +164,16 @@ export function runInit(
|
|
|
148
164
|
|
|
149
165
|
return { skills: skillTemplates.map((t) => t.name.replace(/\.md$/u, '')), removed, configPath };
|
|
150
166
|
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* r93 --update sweep: refresh the managed blocks of one discovered sub-root
|
|
170
|
+
* (blocks only — no scaffold, no config touch, no skills). Returns false for
|
|
171
|
+
* a missing config (discovery guarantees validity; a race is not fatal).
|
|
172
|
+
*/
|
|
173
|
+
export function refreshSubRootBlocks(io: InitIo, templates: TemplateIo, version: string): boolean {
|
|
174
|
+
const configPath = 'llmanspec/config.yaml';
|
|
175
|
+
if (!io.exists(configPath)) return false;
|
|
176
|
+
const config = loadConfig(io.readText(configPath));
|
|
177
|
+
writeManagedBlocks(io, templates, config, version);
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
@@ -39,20 +39,19 @@ function collectSpecEntries(io: SpecHelperIo, specsDir: string): ParsedEntry[] {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
/**
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
42
|
+
* Max+1 over the requirement handles (`@req` on `规则:` headers) only — the id
|
|
43
|
+
* set comes from the global req registry fed with the parsed specs. Deliberately
|
|
44
|
+
* divergent from the predecessor's smallest-free semantics
|
|
45
|
+
* (`req_registry.rs::next_req_id_from_index`): handing out freed ids aliases
|
|
46
|
+
* references archived in past changes (issue #5) — retired ranges are never
|
|
47
|
+
* reused. See change `align-next-req-id-max-plus-one` design for the trade-off.
|
|
45
48
|
*/
|
|
46
49
|
export function nextReqId(io: SpecHelperIo, specsDir: string): string {
|
|
47
50
|
const entries = collectSpecEntries(io, specsDir);
|
|
48
|
-
const used =
|
|
49
|
-
|
|
50
|
-
Math.trunc(Number(reqId.replace(/^r/u, ''))),
|
|
51
|
-
),
|
|
51
|
+
const used = [...buildReqRegistry(entries).byId.keys()].map((reqId) =>
|
|
52
|
+
Math.trunc(Number(reqId.replace(/^r/u, ''))),
|
|
52
53
|
);
|
|
53
|
-
|
|
54
|
-
while (used.has(n)) n += 1;
|
|
55
|
-
return `r${n}`;
|
|
54
|
+
return `r${Math.max(0, ...used) + 1}`;
|
|
56
55
|
}
|
|
57
56
|
|
|
58
57
|
export function skeletonContent(capability: string, reqId: string, locale: string): string {
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Instance-root discovery (subproject-llmanspec-discovery, r91-r92): the
|
|
3
|
+
* unified-root axiom — every llman-sdd operation targets one llmanspec/
|
|
4
|
+
* directory; the repo root is just the default instance and a workspace
|
|
5
|
+
* subpackage carrying its own llmanspec/ is another instance of the same
|
|
6
|
+
* shape. Pure: filesystem access only through the injected IO.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface RootsIo {
|
|
10
|
+
exists(path: string): boolean;
|
|
11
|
+
isDirectory(path: string): boolean;
|
|
12
|
+
listDir(path: string): string[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface RootEntry {
|
|
16
|
+
/** Directory containing the llmanspec/ instance (the instance root). */
|
|
17
|
+
rootDir: string;
|
|
18
|
+
/** The llmanspec directory itself. */
|
|
19
|
+
llmanspecDir: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Directory names never descended into during discovery. */
|
|
23
|
+
export const ROOT_EXCLUDED_DIRS: ReadonlySet<string> = new Set(['node_modules', 'target', '.git']);
|
|
24
|
+
|
|
25
|
+
const joinPath = (dir: string, name: string): string =>
|
|
26
|
+
dir.endsWith('/') ? `${dir}${name}` : `${dir}/${name}`;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A directory counts as an llmanspec root when it carries `config.yaml` or a
|
|
30
|
+
* `specs/` subdir — bare `llmanspec/` scaffolds in flight are not instances.
|
|
31
|
+
*/
|
|
32
|
+
export function isValidRoot(llmanspecDir: string, io: RootsIo): boolean {
|
|
33
|
+
if (!io.isDirectory(llmanspecDir)) return false;
|
|
34
|
+
return io.exists(`${llmanspecDir}/config.yaml`) || io.isDirectory(`${llmanspecDir}/specs`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Discover instance roots under `startDir` (convention scan). The start dir
|
|
39
|
+
* itself is checked first (depth 0 — the git-root instance), then subdirs
|
|
40
|
+
* depth-first in stable sort order; `llmanspec/` contents are never descended
|
|
41
|
+
* into and excluded names are pruned. `maxDepth` mirrors the global
|
|
42
|
+
* --max-scan-depth knob (default 8).
|
|
43
|
+
*/
|
|
44
|
+
export function discoverRoots(
|
|
45
|
+
startDir: string,
|
|
46
|
+
io: RootsIo,
|
|
47
|
+
opts: { maxDepth?: number } = {},
|
|
48
|
+
): RootEntry[] {
|
|
49
|
+
const maxDepth = opts.maxDepth ?? 8;
|
|
50
|
+
const roots: RootEntry[] = [];
|
|
51
|
+
const walk = (dir: string, depth: number): void => {
|
|
52
|
+
if (depth > maxDepth) return;
|
|
53
|
+
const llmanspecDir = joinPath(dir, 'llmanspec');
|
|
54
|
+
if (isValidRoot(llmanspecDir, io)) roots.push({ rootDir: dir, llmanspecDir });
|
|
55
|
+
let names: string[];
|
|
56
|
+
try {
|
|
57
|
+
names = io.listDir(dir);
|
|
58
|
+
} catch {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
for (const name of names.toSorted()) {
|
|
62
|
+
if (name.startsWith('.') || ROOT_EXCLUDED_DIRS.has(name)) continue;
|
|
63
|
+
const full = joinPath(dir, name);
|
|
64
|
+
if (!io.isDirectory(full)) continue;
|
|
65
|
+
if (name === 'llmanspec') continue;
|
|
66
|
+
walk(full, depth + 1);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
walk(startDir, 0);
|
|
70
|
+
return roots;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface ScopeCrossing {
|
|
74
|
+
scope: string;
|
|
75
|
+
rootDir: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Single-ownership rule (r92): a file path belongs to exactly one llmanspec
|
|
80
|
+
* root. Scope entries are resolved against the owning instance root; a scope
|
|
81
|
+
* that reaches into another root's instance directory crosses the boundary
|
|
82
|
+
* and is reported with the offending scope and the other root. Ancestor roots
|
|
83
|
+
* are exempt: a sub-root scoping its own subtree always resolves under the
|
|
84
|
+
* ancestor's directory, but ownership there belongs to the descendant.
|
|
85
|
+
*/
|
|
86
|
+
export function scopeCrossings(
|
|
87
|
+
scopePaths: readonly string[],
|
|
88
|
+
instanceRootDir: string,
|
|
89
|
+
otherRoots: readonly RootEntry[],
|
|
90
|
+
): ScopeCrossing[] {
|
|
91
|
+
const crossings: ScopeCrossing[] = [];
|
|
92
|
+
for (const raw of scopePaths) {
|
|
93
|
+
const scope = raw.trim().replace(/^\.\//u, '').replace(/\/+$/u, '');
|
|
94
|
+
if (scope === '') continue;
|
|
95
|
+
const abs = joinPath(instanceRootDir, scope);
|
|
96
|
+
for (const other of otherRoots) {
|
|
97
|
+
if (other.rootDir === instanceRootDir) continue;
|
|
98
|
+
// Ancestor exemption: our own subtree necessarily sits inside the
|
|
99
|
+
// ancestor's directory — the ancestor does not own it.
|
|
100
|
+
if (instanceRootDir.startsWith(`${other.rootDir}/`)) continue;
|
|
101
|
+
if (abs === other.rootDir || abs.startsWith(`${other.rootDir}/`)) {
|
|
102
|
+
crossings.push({ scope: raw.trim(), rootDir: other.rootDir });
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return crossings;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Nearest instance root at or above `startDir` (inclusive) — the cwd
|
|
112
|
+
* resolution behind "cd into the subpackage and run" (r94). Returns null when
|
|
113
|
+
* no ancestor carries a valid llmanspec root.
|
|
114
|
+
*/
|
|
115
|
+
export function resolveInstanceRoot(startDir: string, io: RootsIo): string | null {
|
|
116
|
+
let dir = startDir;
|
|
117
|
+
for (;;) {
|
|
118
|
+
if (isValidRoot(joinPath(dir, 'llmanspec'), io)) return dir;
|
|
119
|
+
const parent = dir.replace(/\/+$/u, '').replace(/\/[^/]+$/u, '');
|
|
120
|
+
if (parent === '' || parent === dir) return null;
|
|
121
|
+
dir = parent;
|
|
122
|
+
}
|
|
123
|
+
}
|