@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llman-sdd/core",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Pure domain logic for llman-sdd (config, gherkin specs, validation, templates)",
5
5
  "license": "MIT",
6
6
  "repository": {
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
- * predecessor parity (`req_registry.rs::next_req_id_from_index`): smallest free
43
- * rN over the requirement handles (`@req` on `规则:` headers) only. The id set
44
- * comes from the global req registry fed with the parsed specs.
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 = new Set(
49
- [...buildReqRegistry(entries).byId.keys()].map((reqId) =>
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
- let n = 1;
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
+ }