@ctxr/skill-llm-wiki 1.0.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.
Files changed (75) hide show
  1. package/CHANGELOG.md +134 -0
  2. package/LICENSE +21 -0
  3. package/README.md +484 -0
  4. package/SKILL.md +252 -0
  5. package/guide/basics/concepts.md +74 -0
  6. package/guide/basics/index.md +45 -0
  7. package/guide/basics/schema.md +140 -0
  8. package/guide/cli.md +256 -0
  9. package/guide/correctness/index.md +45 -0
  10. package/guide/correctness/invariants.md +89 -0
  11. package/guide/correctness/safety.md +96 -0
  12. package/guide/history/diff.md +110 -0
  13. package/guide/history/hidden-git.md +130 -0
  14. package/guide/history/index.md +52 -0
  15. package/guide/history/remote-sync.md +113 -0
  16. package/guide/index.md +134 -0
  17. package/guide/isolation/coexistence.md +134 -0
  18. package/guide/isolation/index.md +44 -0
  19. package/guide/isolation/scale.md +251 -0
  20. package/guide/layout/in-place-mode.md +97 -0
  21. package/guide/layout/index.md +53 -0
  22. package/guide/layout/layout-contract.md +131 -0
  23. package/guide/layout/layout-modes.md +115 -0
  24. package/guide/operations/index.md +76 -0
  25. package/guide/operations/ingest/build.md +75 -0
  26. package/guide/operations/ingest/extend.md +61 -0
  27. package/guide/operations/ingest/index.md +54 -0
  28. package/guide/operations/ingest/join.md +65 -0
  29. package/guide/operations/maintain/fix.md +66 -0
  30. package/guide/operations/maintain/index.md +47 -0
  31. package/guide/operations/maintain/rebuild.md +86 -0
  32. package/guide/operations/validate.md +48 -0
  33. package/guide/substrate/index.md +47 -0
  34. package/guide/substrate/operators.md +96 -0
  35. package/guide/substrate/tiered-ai.md +363 -0
  36. package/guide/ux/index.md +44 -0
  37. package/guide/ux/preflight.md +150 -0
  38. package/guide/ux/user-intent.md +135 -0
  39. package/package.json +55 -0
  40. package/scripts/cli.mjs +893 -0
  41. package/scripts/commands/remote.mjs +93 -0
  42. package/scripts/commands/review.mjs +253 -0
  43. package/scripts/commands/sync.mjs +84 -0
  44. package/scripts/lib/chunk.mjs +421 -0
  45. package/scripts/lib/cluster-detect.mjs +516 -0
  46. package/scripts/lib/decision-log.mjs +343 -0
  47. package/scripts/lib/draft.mjs +158 -0
  48. package/scripts/lib/embeddings.mjs +366 -0
  49. package/scripts/lib/frontmatter.mjs +497 -0
  50. package/scripts/lib/git-commands.mjs +155 -0
  51. package/scripts/lib/git.mjs +486 -0
  52. package/scripts/lib/gitignore.mjs +62 -0
  53. package/scripts/lib/history.mjs +331 -0
  54. package/scripts/lib/indices.mjs +510 -0
  55. package/scripts/lib/ingest.mjs +258 -0
  56. package/scripts/lib/intent.mjs +713 -0
  57. package/scripts/lib/interactive.mjs +99 -0
  58. package/scripts/lib/migrate.mjs +126 -0
  59. package/scripts/lib/nest-applier.mjs +260 -0
  60. package/scripts/lib/operators.mjs +1365 -0
  61. package/scripts/lib/orchestrator.mjs +718 -0
  62. package/scripts/lib/paths.mjs +197 -0
  63. package/scripts/lib/preflight.mjs +213 -0
  64. package/scripts/lib/provenance.mjs +672 -0
  65. package/scripts/lib/quality-metric.mjs +269 -0
  66. package/scripts/lib/query-fixture.mjs +71 -0
  67. package/scripts/lib/rollback.mjs +95 -0
  68. package/scripts/lib/shape-check.mjs +172 -0
  69. package/scripts/lib/similarity-cache.mjs +126 -0
  70. package/scripts/lib/similarity.mjs +230 -0
  71. package/scripts/lib/snapshot.mjs +54 -0
  72. package/scripts/lib/source-frontmatter.mjs +85 -0
  73. package/scripts/lib/tier2-protocol.mjs +470 -0
  74. package/scripts/lib/tiered.mjs +453 -0
  75. package/scripts/lib/validate.mjs +362 -0
@@ -0,0 +1,99 @@
1
+ // interactive.mjs — TTY-gated prompt helpers.
2
+ //
3
+ // The "ask, don't guess" rule from methodology section 9.4.3 has two
4
+ // enforcement layers: intent.mjs refuses ambiguous CLI invocations; this
5
+ // module handles interactive prompts for things that genuinely need a
6
+ // runtime yes/no (migration confirmation, Tier 1 install, --review
7
+ // checkpoints in Phase 7).
8
+ //
9
+ // Non-interactive mode is the default for CI, hooks, and any pipeline
10
+ // where stdin is not a TTY. Detection prefers explicit flags over
11
+ // heuristics, in this order:
12
+ //
13
+ // 1. `LLM_WIKI_NO_PROMPT=1` (env var) → never prompt
14
+ // 2. `--no-prompt` (CLI flag, surfaced via opts) → never prompt
15
+ // 3. `process.stdin.isTTY === false` → never prompt
16
+ // 4. otherwise → prompt
17
+ //
18
+ // When prompting is disabled, every prompt helper throws a
19
+ // `NonInteractiveError` that the caller is expected to surface as a
20
+ // structured CLI error. The caller chooses whether to exit, fall through,
21
+ // or translate into a different behaviour (Tier 1 install's silent
22
+ // fallthrough is the canonical example).
23
+
24
+ import { createInterface } from "node:readline";
25
+
26
+ export class NonInteractiveError extends Error {
27
+ constructor(question) {
28
+ super(
29
+ `skill-llm-wiki: cannot prompt "${question}" in non-interactive mode`,
30
+ );
31
+ this.name = "NonInteractiveError";
32
+ this.question = question;
33
+ }
34
+ }
35
+
36
+ export function isInteractive(opts = {}) {
37
+ if (process.env.LLM_WIKI_NO_PROMPT === "1") return false;
38
+ if (opts.noPrompt) return false;
39
+ if (opts.forceInteractive) return true; // tests
40
+ // `process.stdin.isTTY` is `true` on a TTY, `undefined` on a pipe.
41
+ // Boolean() handles both: pipes become false, TTYs become true.
42
+ return Boolean(process.stdin && process.stdin.isTTY);
43
+ }
44
+
45
+ // Basic y/n prompt with a configurable default. Returns a boolean.
46
+ // Throws NonInteractiveError when prompts are disabled.
47
+ export async function confirm(question, opts = {}) {
48
+ if (!isInteractive(opts)) {
49
+ throw new NonInteractiveError(question);
50
+ }
51
+ const def = opts.default === undefined ? true : Boolean(opts.default);
52
+ const suffix = def ? " [Y/n] " : " [y/N] ";
53
+ const answer = await readLine(question + suffix);
54
+ if (answer === "") return def;
55
+ return /^y(es)?$/i.test(answer.trim());
56
+ }
57
+
58
+ // Free-form text prompt with an optional default value.
59
+ export async function ask(question, opts = {}) {
60
+ if (!isInteractive(opts)) {
61
+ throw new NonInteractiveError(question);
62
+ }
63
+ const suffix = opts.default ? ` [${opts.default}] ` : " ";
64
+ const answer = await readLine(question + suffix);
65
+ if (answer === "" && opts.default !== undefined) return opts.default;
66
+ return answer.trim();
67
+ }
68
+
69
+ // Multiple-choice prompt. Returns the chosen option's `value`.
70
+ export async function choose(question, options, opts = {}) {
71
+ if (!isInteractive(opts)) {
72
+ throw new NonInteractiveError(question);
73
+ }
74
+ const lines = [question];
75
+ for (let i = 0; i < options.length; i++) {
76
+ lines.push(` ${i + 1}. ${options[i].label}`);
77
+ }
78
+ lines.push(`Choose 1-${options.length}: `);
79
+ while (true) {
80
+ const answer = await readLine(lines.join("\n"));
81
+ const idx = Number.parseInt(answer.trim(), 10);
82
+ if (Number.isInteger(idx) && idx >= 1 && idx <= options.length) {
83
+ return options[idx - 1].value;
84
+ }
85
+ process.stderr.write(
86
+ `skill-llm-wiki: please enter a number between 1 and ${options.length}\n`,
87
+ );
88
+ }
89
+ }
90
+
91
+ function readLine(prompt) {
92
+ return new Promise((resolvePromise) => {
93
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
94
+ rl.question(prompt, (answer) => {
95
+ rl.close();
96
+ resolvePromise(answer);
97
+ });
98
+ });
99
+ }
@@ -0,0 +1,126 @@
1
+ // migrate.mjs — move a legacy `<source>.llmwiki.v<N>/` wiki into the new
2
+ // `<source>.wiki/` sibling layout with a private git repo and an op-log
3
+ // entry recording the migration lineage.
4
+ //
5
+ // Atomicity guarantees:
6
+ // 1. The legacy folder is read-only to this module — never mutated,
7
+ // byte-identical before and after (success OR failure).
8
+ // 2. The destination is cleaned up on ANY exception between mkdirSync
9
+ // and the final commit. The user never has to manually rm a
10
+ // half-built sibling before retrying.
11
+ // 3. If the destination existed before this call (the caller should
12
+ // have refused via intent.mjs's INT-01 check, but defence in depth),
13
+ // we never touch it on failure — we only nuke directories this
14
+ // call created.
15
+
16
+ import { cpSync, existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
17
+ import { basename, dirname, join } from "node:path";
18
+ import { appendOpLog } from "./history.mjs";
19
+ import { ensureWikiGitignore } from "./gitignore.mjs";
20
+ import {
21
+ gitCommit,
22
+ gitHeadSha,
23
+ gitInit,
24
+ gitRunChecked,
25
+ gitTag,
26
+ } from "./git.mjs";
27
+
28
+ // Match legacy folder names of the form `<anything>.llmwiki.v<digits>`
29
+ // and extract the version integer. Returns null for non-matching inputs.
30
+ function parseLegacyVersion(legacyPath) {
31
+ const m = /\.llmwiki\.v(\d+)$/.exec(basename(legacyPath));
32
+ return m ? Number(m[1]) : null;
33
+ }
34
+
35
+ // Build the default migration destination for a legacy wiki.
36
+ // `<parent>/<base>.llmwiki.v3` → `<parent>/<base>.wiki`.
37
+ export function defaultMigrationTarget(legacyPath) {
38
+ const parent = dirname(legacyPath);
39
+ const name = basename(legacyPath);
40
+ const cleanName = name.replace(/\.llmwiki\.v\d+$/, "");
41
+ return join(parent, `${cleanName}.wiki`);
42
+ }
43
+
44
+ // Migrate a legacy wiki. Parameters:
45
+ // legacyPath absolute path to `<source>.llmwiki.v<N>/`
46
+ // newWikiPath absolute path to the new sibling destination
47
+ // opts.opId op-id string for the migration (caller-supplied so the
48
+ // surrounding CLI can correlate with its op-log record)
49
+ //
50
+ // Returns { opId, version, sha } on success.
51
+ // Throws if legacyPath is not a legacy wiki, or if newWikiPath already
52
+ // exists (collision resolution is the caller's job).
53
+ export function migrateLegacyWiki(legacyPath, newWikiPath, opts = {}) {
54
+ const version = parseLegacyVersion(legacyPath);
55
+ if (version === null) {
56
+ throw new Error(
57
+ `migrate: ${legacyPath} does not match the legacy .llmwiki.v<N> naming convention`,
58
+ );
59
+ }
60
+ if (!existsSync(legacyPath)) {
61
+ throw new Error(`migrate: legacy wiki ${legacyPath} does not exist`);
62
+ }
63
+ if (existsSync(newWikiPath)) {
64
+ throw new Error(
65
+ `migrate: destination ${newWikiPath} already exists; pick a new target or remove it first`,
66
+ );
67
+ }
68
+ if (!opts.opId || typeof opts.opId !== "string") {
69
+ throw new Error("migrate: opts.opId is required");
70
+ }
71
+ // The destination must not pre-exist. We check and throw here — AFTER
72
+ // this point, any directory at `newWikiPath` was created by this
73
+ // specific call, so failure-cleanup can rmSync it unconditionally
74
+ // without fear of destroying an unrelated pre-existing directory.
75
+ if (existsSync(newWikiPath)) {
76
+ throw new Error(
77
+ `migrate: destination ${newWikiPath} already exists; pick a new target or remove it first`,
78
+ );
79
+ }
80
+ try {
81
+ mkdirSync(newWikiPath, { recursive: true });
82
+ // Copy everything except the legacy sibling's own cruft. The legacy
83
+ // layout never had a `.llmwiki/` subdir of its own, so a full recursive
84
+ // copy is safe. `errorOnExist: false` is the default; explicit for clarity.
85
+ for (const entry of readdirSync(legacyPath, { withFileTypes: true })) {
86
+ cpSync(join(legacyPath, entry.name), join(newWikiPath, entry.name), {
87
+ recursive: true,
88
+ errorOnExist: false,
89
+ force: false,
90
+ preserveTimestamps: true,
91
+ });
92
+ }
93
+ // Initialise the private git repo, stage everything, commit.
94
+ gitInit(newWikiPath);
95
+ ensureWikiGitignore(newWikiPath);
96
+ gitRunChecked(newWikiPath, ["add", "-A"]);
97
+ gitCommit(newWikiPath, `migrate from legacy .llmwiki.v${version}`);
98
+ const tagName = `op/${opts.opId}`;
99
+ gitTag(newWikiPath, tagName, "HEAD");
100
+ const sha = gitHeadSha(newWikiPath);
101
+ appendOpLog(newWikiPath, {
102
+ op_id: opts.opId,
103
+ operation: "migrate",
104
+ layout_mode: "sibling",
105
+ started: new Date().toISOString(),
106
+ finished: new Date().toISOString(),
107
+ base_commit: "legacy",
108
+ final_commit: sha || "",
109
+ summary: `migrated from ${legacyPath} (v${version})`,
110
+ });
111
+ return { opId: opts.opId, version, sha };
112
+ } catch (err) {
113
+ // Atomic-rollback: remove the half-built destination so the user
114
+ // can retry cleanly. Safe to do unconditionally because the
115
+ // pre-existence check above guarantees this call is the one that
116
+ // created `newWikiPath`.
117
+ try {
118
+ rmSync(newWikiPath, { recursive: true, force: true });
119
+ } catch {
120
+ /* best effort — surface the original error anyway */
121
+ }
122
+ throw err;
123
+ }
124
+ }
125
+
126
+ export { parseLegacyVersion };
@@ -0,0 +1,260 @@
1
+ // nest-applier.mjs — the applying NEST operator.
2
+ //
3
+ // Given a cluster proposal (set of sibling leaves all under the
4
+ // same parent directory) and a resolved slug (from Tier 2), move
5
+ // the leaves into a new subdirectory `<parent>/<slug>/`, rewrite
6
+ // each moved leaf's `parents[]` to point at the new parent, and
7
+ // bootstrap a minimal `index.md` stub in the new subdirectory.
8
+ // The parent index.md is NOT touched here — the caller re-runs
9
+ // `rebuildAllIndices` in its phase so the parent's entries[] is
10
+ // regenerated with the new subcategory replacing the moved leaves.
11
+ //
12
+ // Preconditions enforced here:
13
+ //
14
+ // 1. The resolved slug is a valid kebab-case directory name.
15
+ // 2. All leaves share the same parent directory.
16
+ // 3. The new subdirectory does not already exist.
17
+ // 4. None of the leaf target paths collide with existing files.
18
+ //
19
+ // On any precondition failure the function throws BEFORE touching
20
+ // the filesystem, so the phase's rollback guarantees (pre-op
21
+ // snapshot → reset on exception) remain byte-exact.
22
+ //
23
+ // We do NOT touch the private git here. The caller's phase
24
+ // pipeline `git add -A && git commit` after the applier runs is
25
+ // what records the change.
26
+
27
+ import {
28
+ existsSync,
29
+ mkdirSync,
30
+ readdirSync,
31
+ readFileSync,
32
+ rmSync,
33
+ writeFileSync,
34
+ } from "node:fs";
35
+ import { basename, dirname, join } from "node:path";
36
+ import { parseFrontmatter, renderFrontmatter } from "./frontmatter.mjs";
37
+
38
+ const SLUG_RE = /^[a-z][a-z0-9-]{0,63}$/;
39
+
40
+ export function validateSlug(slug) {
41
+ if (typeof slug !== "string") return false;
42
+ return SLUG_RE.test(slug);
43
+ }
44
+
45
+ // Resolve a slug that won't collide with a member leaf's id or with a
46
+ // non-member sibling in the same parent directory. The observed
47
+ // collision case (v0.4.1 novel-corpus run): Tier 2's propose_structure
48
+ // response picked slug="security" for a cluster whose members included
49
+ // a leaf with id="security", so after apply both the new subcategory's
50
+ // stub index.md AND the moved leaf carried id="security" — DUP-ID at
51
+ // validate time, forcing a full pipeline rollback. Pre-resolving here
52
+ // auto-suffixes the slug (deterministically: `-group`, then `-group-N`)
53
+ // until it's non-colliding, letting the NEST land on the first try.
54
+ // Non-collision slugs are returned unchanged; invalid slugs are left
55
+ // alone so applyNest's own validation can reject them with its usual
56
+ // error message.
57
+ export function resolveNestSlug(slug, proposal) {
58
+ if (!validateSlug(slug)) return slug;
59
+ if (
60
+ !proposal ||
61
+ !Array.isArray(proposal.leaves) ||
62
+ proposal.leaves.length === 0
63
+ ) {
64
+ return slug;
65
+ }
66
+ const forbidden = collectForbiddenIds(proposal);
67
+ if (!forbidden.has(slug)) return slug;
68
+ // Try "-group" first (the natural human reading: "the group of X
69
+ // leaves"); fall back to numeric suffixes starting at -group-2
70
+ // because "-group" itself already occupies the slot that would
71
+ // otherwise be "-group-1". If the base slug is so long that
72
+ // "${slug}-group" overflows the 64-char SLUG_RE cap, short-circuit:
73
+ // all numeric candidates share the same prefix and will fail
74
+ // validation identically, so there's no point spinning the loop.
75
+ // Returning the original (colliding) slug propagates the failure
76
+ // to applyNest, which throws a clear "target subcategory already
77
+ // exists" error — strictly better than a silent spin.
78
+ const primary = `${slug}-group`;
79
+ if (!validateSlug(primary)) return slug;
80
+ if (!forbidden.has(primary)) return primary;
81
+ for (let i = 2; i < 100; i++) {
82
+ const candidate = `${slug}-group-${i}`;
83
+ if (!forbidden.has(candidate)) return candidate;
84
+ }
85
+ return slug;
86
+ }
87
+
88
+ function collectForbiddenIds(proposal) {
89
+ const forbidden = new Set();
90
+ for (const leaf of proposal.leaves) {
91
+ if (leaf?.data?.id) forbidden.add(leaf.data.id);
92
+ }
93
+ const parentDir = dirname(proposal.leaves[0].path);
94
+ const memberPaths = new Set(proposal.leaves.map((l) => l.path));
95
+ let entries;
96
+ try {
97
+ entries = readdirSync(parentDir, { withFileTypes: true });
98
+ } catch {
99
+ return forbidden;
100
+ }
101
+ for (const entry of entries) {
102
+ // Skip the parent's own index.md: its id is the parent's basename
103
+ // (i.e., the parent directory name), not something the new
104
+ // subcategory could collide with. Parent-name collisions — where
105
+ // the slug equals the parent dir's name — are a separate case that
106
+ // applyNest itself rejects via its existsSync(targetDir) check.
107
+ if (entry.name === "index.md") continue;
108
+ const entryPath = join(parentDir, entry.name);
109
+ if (memberPaths.has(entryPath)) continue;
110
+ if (entry.isDirectory()) {
111
+ forbidden.add(entry.name);
112
+ continue;
113
+ }
114
+ if (!entry.name.endsWith(".md")) continue;
115
+ try {
116
+ const raw = readFileSync(entryPath, "utf8");
117
+ const { data } = parseFrontmatter(raw, entryPath);
118
+ if (data?.id) forbidden.add(data.id);
119
+ } catch {
120
+ /* skip unreadable siblings */
121
+ }
122
+ }
123
+ return forbidden;
124
+ }
125
+
126
+ export function applyNest(wikiRoot, proposal, slug, opts = {}) {
127
+ void wikiRoot;
128
+ void opts;
129
+ if (!proposal || !Array.isArray(proposal.leaves) || proposal.leaves.length < 2) {
130
+ throw new Error("nest-applier: proposal must carry at least 2 leaves");
131
+ }
132
+ if (!validateSlug(slug)) {
133
+ throw new Error(
134
+ `nest-applier: invalid slug "${slug}" (must match /^[a-z][a-z0-9-]{0,63}$/)`,
135
+ );
136
+ }
137
+ const parentDirs = new Set(proposal.leaves.map((l) => dirname(l.path)));
138
+ if (parentDirs.size !== 1) {
139
+ throw new Error(
140
+ `nest-applier: leaves belong to ${parentDirs.size} different parent dirs — cannot NEST across parents in one step`,
141
+ );
142
+ }
143
+ const parentDir = parentDirs.values().next().value;
144
+ const targetDir = join(parentDir, slug);
145
+ if (existsSync(targetDir)) {
146
+ throw new Error(
147
+ `nest-applier: target subcategory ${targetDir} already exists`,
148
+ );
149
+ }
150
+
151
+ // Precompute target paths and check for collisions BEFORE any
152
+ // filesystem mutation.
153
+ const moves = [];
154
+ for (const leaf of proposal.leaves) {
155
+ const targetPath = join(targetDir, basename(leaf.path));
156
+ if (existsSync(targetPath)) {
157
+ throw new Error(
158
+ `nest-applier: target leaf path ${targetPath} already exists`,
159
+ );
160
+ }
161
+ moves.push({ from: leaf.path, to: targetPath, leaf });
162
+ }
163
+
164
+ // Create the target directory and move each leaf into it,
165
+ // rewriting the parents[] field as we go. We use `renameSync`
166
+ // rather than a raw move so the private git sees the rename
167
+ // as a proper content-preserving move on the next `git add`.
168
+ mkdirSync(targetDir, { recursive: true });
169
+ for (const move of moves) {
170
+ const raw = readFileSync(move.from, "utf8");
171
+ const { data, body } = parseFrontmatter(raw, move.from);
172
+ // Rewrite parents[] to point at the new subcategory's
173
+ // index.md. The methodology says parents[] uses POSIX-relative
174
+ // paths; for a leaf at `<parent>/<slug>/foo.md`, the direct
175
+ // parent index.md lives at `<parent>/<slug>/index.md`, which
176
+ // is `index.md` relative to the leaf itself.
177
+ data.parents = ["index.md"];
178
+ writeFileSync(move.to, renderFrontmatter(data, body), "utf8");
179
+ // Unlink the old location. The new file has already been
180
+ // written, so this is a destructive move but at the same
181
+ // level as the rollback guarantee (which resets the working
182
+ // tree to the pre-op snapshot on any exception upstream).
183
+ rmSync(move.from, { force: true });
184
+ }
185
+
186
+ // Bootstrap an index.md stub in the new subcategory. The stub is
187
+ // minimal: `id`, `type`, `depth_role`, `focus` (from the Tier 2
188
+ // cluster purpose, with a placeholder fallback), any shared tags
189
+ // and shared_covers that the cluster members have in common, and
190
+ // the `parents[]` / `generator` marker that the subsequent
191
+ // `rebuildAllIndices` pass fills in. No `activation_defaults`
192
+ // aggregation: routing is semantic now — Claude decides descent
193
+ // from `focus` and `shared_covers` rather than from a literal
194
+ // keyword/tag union. Per-leaf `activation` blocks stay on the
195
+ // leaves as optional semantic hints (see SKILL.md "Routing into
196
+ // guide.wiki/").
197
+ const sharedTags = intersectTags(proposal.leaves.map((l) => l.data.tags || []));
198
+ const sharedCovers = intersectCovers(proposal.leaves.map((l) => l.data.covers || []));
199
+ const purpose = typeof proposal.purpose === "string" ? proposal.purpose.trim() : "";
200
+ const stubData = {
201
+ id: slug,
202
+ type: "index",
203
+ depth_role: "subcategory",
204
+ focus: purpose || `subtree under ${slug}`,
205
+ };
206
+ if (sharedTags.length > 0) stubData.tags = sharedTags;
207
+ if (sharedCovers.length > 0) {
208
+ stubData.shared_covers = sharedCovers;
209
+ }
210
+ const stubBody =
211
+ "\n<!-- BEGIN AUTO-GENERATED NAVIGATION -->\n\n" +
212
+ "<!-- END AUTO-GENERATED NAVIGATION -->\n\n" +
213
+ "<!-- BEGIN AUTHORED ORIENTATION -->\n\n" +
214
+ "<!-- END AUTHORED ORIENTATION -->\n";
215
+ const stubPath = join(targetDir, "index.md");
216
+ writeFileSync(stubPath, renderFrontmatter(stubData, stubBody), "utf8");
217
+
218
+ return {
219
+ target_dir: targetDir,
220
+ moved: moves.map((m) => ({ from: m.from, to: m.to })),
221
+ stub: stubPath,
222
+ shared_tags: sharedTags,
223
+ shared_covers: sharedCovers,
224
+ };
225
+ }
226
+
227
+ function intersectTags(lists) {
228
+ if (lists.length === 0) return [];
229
+ const first = new Set(lists[0]);
230
+ const out = [];
231
+ for (const t of first) {
232
+ if (lists.every((l) => l.includes(t))) out.push(t);
233
+ }
234
+ return out.sort();
235
+ }
236
+
237
+ // Deterministic intersection of cover strings across cluster
238
+ // members. Case-sensitive string equality. Result is sorted so
239
+ // stub bodies are byte-deterministic across rebuilds.
240
+ function intersectCovers(lists) {
241
+ if (lists.length === 0) return [];
242
+ if (lists.length === 1) return [];
243
+ const first = new Set(lists[0]);
244
+ const out = [];
245
+ for (const item of first) {
246
+ if (lists.every((l) => l.includes(item))) out.push(item);
247
+ }
248
+ return out.sort();
249
+ }
250
+
251
+ // Historical note: an `aggregateActivation(leaves)` helper used to
252
+ // live here. It unioned `activation.keyword_matches`,
253
+ // `activation.tag_matches`, `tags[]`, and `activation.escalation_from`
254
+ // across cluster members into a single `activation_defaults` block
255
+ // for the new subcategory stub. That block was the old literal-
256
+ // routing substrate — the router's deterministic descent rule was an
257
+ // AND-filter on `activation_defaults.tag_matches ∩ profile.tags`.
258
+ // The rule has been removed in favour of semantic routing (Claude
259
+ // matches on the stub's `focus` + `shared_covers`), so the helper
260
+ // has no callers and was deleted.