@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,510 @@
1
+ // index.md generation and parsing.
2
+ //
3
+ // For every directory in a wiki that contains entries, a single `index.md`
4
+ // holds:
5
+ // - frontmatter with machine routing metadata (derived + authored fields)
6
+ // - body with auto-generated navigation + preserved authored orientation
7
+ //
8
+ // The hook rebuilds indices by: reading the existing index.md to preserve
9
+ // authored fields, aggregating children's frontmatter to recompute derived
10
+ // fields, rendering a deterministic body, writing back atomically.
11
+
12
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
13
+ import { basename, dirname, join, relative } from "node:path";
14
+ import { parseFrontmatter, renderFrontmatter } from "./frontmatter.mjs";
15
+ import { WIKI_GENERATOR_MARKER } from "./paths.mjs";
16
+ import { readFrontmatterStreaming } from "./chunk.mjs";
17
+
18
+ const AUTO_BEGIN = "<!-- BEGIN AUTO-GENERATED NAVIGATION -->";
19
+ const AUTO_END = "<!-- END AUTO-GENERATED NAVIGATION -->";
20
+ const AUTHORED_BEGIN = "<!-- BEGIN AUTHORED ORIENTATION -->";
21
+ const AUTHORED_END = "<!-- END AUTHORED ORIENTATION -->";
22
+
23
+ // Fields the user or init routine authored that must survive rebuilds.
24
+ const AUTHORED_FIELDS = [
25
+ "id",
26
+ "type",
27
+ "depth_role",
28
+ "focus",
29
+ "parents",
30
+ "activation_defaults",
31
+ "orientation",
32
+ "rebuild_needed",
33
+ "rebuild_reasons",
34
+ "rebuild_command",
35
+ "sources",
36
+ "source_wikis",
37
+ "tags",
38
+ "domains",
39
+ "generator",
40
+ // Hosted-mode markers — set on the root index when the wiki is governed
41
+ // by a layout contract. Must survive rebuilds so `isWikiRoot` and the
42
+ // hosted-mode operation paths keep recognising the target after every
43
+ // regeneration.
44
+ "mode",
45
+ "layout_contract_path",
46
+ ];
47
+
48
+ export function readIndex(dirPath) {
49
+ const p = join(dirPath, "index.md");
50
+ if (!existsSync(p)) return null;
51
+ const raw = readFileSync(p, "utf8");
52
+ return parseFrontmatter(raw, p);
53
+ }
54
+
55
+ // Walk a directory and return a list of child entries (leaves) and child
56
+ // index directories (subcategories). Leaves are any .md file that is not
57
+ // the directory's own index.md and has frontmatter.
58
+ //
59
+ // Scale note: this function reads ONLY each leaf's frontmatter bytes via
60
+ // `readFrontmatterStreaming`. It never pulls the body into memory, so a
61
+ // directory with 10,000 × 50 KB leaves costs ~40 MB of frontmatter (at
62
+ // the 4 KB-per-leaf typical case) instead of 500 MB of full files. This
63
+ // is what makes `rebuildAllIndices` scalable at Phase 5 targets.
64
+ export function listChildren(dirPath) {
65
+ const out = { leaves: [], subdirs: [] };
66
+ if (!existsSync(dirPath)) return out;
67
+ const entries = readdirSync(dirPath, { withFileTypes: true });
68
+ entries.sort((a, b) => a.name.localeCompare(b.name));
69
+ for (const e of entries) {
70
+ if (e.name.startsWith(".")) continue;
71
+ const full = join(dirPath, e.name);
72
+ if (e.isDirectory()) {
73
+ if (existsSync(join(full, "index.md"))) out.subdirs.push(full);
74
+ continue;
75
+ }
76
+ if (!e.isFile()) continue;
77
+ if (!e.name.endsWith(".md")) continue;
78
+ if (e.name === "index.md") continue;
79
+ try {
80
+ const captured = readFrontmatterStreaming(full);
81
+ if (captured === null) continue; // no frontmatter — skip silently
82
+ const { data } = parseFrontmatter(captured.frontmatterText, full);
83
+ if (data && typeof data === "object" && data.id) {
84
+ out.leaves.push({ path: full, data });
85
+ }
86
+ } catch {
87
+ // Skip malformed — `runShapeCheck` / `rebuildIndex` both tolerate
88
+ // leaves whose frontmatter fails to parse. The strict validator
89
+ // catches them separately.
90
+ }
91
+ }
92
+ return out;
93
+ }
94
+
95
+ // Rebuild the index.md for a single directory. Idempotent. Never modifies
96
+ // children. Preserves authored content in the existing index.md.
97
+ //
98
+ // depth is computed from the directory's position relative to the wiki
99
+ // root. If `preloadedChildren` is provided it is used instead of calling
100
+ // `listChildren` again — `rebuildAllIndices` takes advantage of this to
101
+ // avoid reading every leaf's frontmatter twice per rebuild (once during
102
+ // the walk that discovers directories, once during per-directory index
103
+ // regeneration). At 10k leaves the savings are meaningful.
104
+ //
105
+ // `options.indexInput`, when provided, carries the AUTHORED-index
106
+ // hints (shared_covers / orientation / focus) that the ingest phase
107
+ // recovered from a source file named `index.md` or carrying
108
+ // `type: index`. Those fields are forwarded into the synthesised
109
+ // target index verbatim — they take priority over the heuristic
110
+ // fallbacks, so a hand-tuned guide's routing metadata survives a
111
+ // rebuild cleanly.
112
+ //
113
+ // NOTE on `activation_defaults`: this field is no longer
114
+ // auto-aggregated upward from members (that was the old literal-
115
+ // routing substrate). The field is still recognised in
116
+ // AUTHORED_FIELDS so a hand-authored source index that carries an
117
+ // `activation_defaults` block round-trips without data loss, but
118
+ // the rebuild pass does NOT synthesise one from child signals.
119
+ // Routing is semantic — see SKILL.md "Routing into guide.wiki/".
120
+ export function rebuildIndex(
121
+ dirPath,
122
+ wikiRoot,
123
+ preloadedChildren = null,
124
+ options = {},
125
+ ) {
126
+ const { indexInput = null } = options;
127
+ const p = join(dirPath, "index.md");
128
+ const existing = existsSync(p) ? parseFrontmatter(readFileSync(p, "utf8"), p) : null;
129
+ const { leaves, subdirs } = preloadedChildren ?? listChildren(dirPath);
130
+
131
+ const depth = computeDepth(dirPath, wikiRoot);
132
+ const isRoot = dirPath === wikiRoot;
133
+
134
+ // Start with existing authored fields (survive rebuild).
135
+ const data = {};
136
+ if (existing?.data) {
137
+ for (const k of AUTHORED_FIELDS) {
138
+ if (existing.data[k] !== undefined) data[k] = existing.data[k];
139
+ }
140
+ }
141
+
142
+ // Forward hints from an authored source `index.md` (if the build
143
+ // pipeline stashed one for this directory). These take priority
144
+ // over any stub values planted by `bootstrapIndexStubs`, EXCEPT
145
+ // for identity fields whose correct value is structurally
146
+ // determined by the target-tree position (`id`, `type`,
147
+ // `depth_role`, `depth`, `parents`). Those get re-derived below.
148
+ const authoredIndex = indexInput?.authored_frontmatter || null;
149
+ const structuralFields = new Set([
150
+ "id",
151
+ "type",
152
+ "depth_role",
153
+ "depth",
154
+ "parents",
155
+ "generator",
156
+ "mode",
157
+ "layout_contract_path",
158
+ // Rebuild-status fields are managed by the orchestrator / rebuild
159
+ // path. Forwarding them from a source index would leak absolute
160
+ // paths into the target and defeat build determinism.
161
+ "rebuild_needed",
162
+ "rebuild_reasons",
163
+ "rebuild_command",
164
+ ]);
165
+ if (authoredIndex) {
166
+ for (const k of AUTHORED_FIELDS) {
167
+ if (structuralFields.has(k)) continue;
168
+ if (authoredIndex[k] !== undefined) data[k] = authoredIndex[k];
169
+ }
170
+ }
171
+
172
+ // Ensure required identity fields.
173
+ data.id = data.id ?? (isRoot ? basename(wikiRoot) : basename(dirPath));
174
+ data.type = "index";
175
+ // Depth-role mapping per schema: root is "category", everything deeper is
176
+ // "subcategory". (Early drafts mislabeled depth-1 as "category"; fixed.)
177
+ data.depth_role = depth === 0 ? "category" : "subcategory";
178
+ if (isRoot) data.depth_role = "category";
179
+ data.depth = depth;
180
+
181
+ if (!data.focus) {
182
+ data.focus = `subtree under ${data.id}`;
183
+ }
184
+
185
+ if (!data.parents) {
186
+ if (isRoot) {
187
+ data.parents = [];
188
+ } else {
189
+ data.parents = [relative(dirPath, dirname(dirPath)) + "/index.md"];
190
+ }
191
+ }
192
+
193
+ // Derived: entries (aggregate child frontmatter).
194
+ //
195
+ // Each entry carries the minimum a semantic router needs to decide
196
+ // whether to descend into or load the child: `id`, `file`, `type`,
197
+ // `focus`, and any authored `tags`. Claude reads the parent's
198
+ // `entries[]`, matches on `focus` (and the parent's authored
199
+ // `shared_covers`), and loads only the matches. It does NOT match
200
+ // on literal keyword/tag lists lifted from the child — that was
201
+ // the old deterministic-router substrate and is gone. Per-leaf
202
+ // `activation` blocks are still preserved IN the leaf file as
203
+ // optional semantic hints the router may consult AFTER opening
204
+ // the leaf; they are not copied up into the parent entries[]
205
+ // record.
206
+ const entries = [];
207
+ for (const leaf of leaves) {
208
+ const record = {
209
+ id: leaf.data.id,
210
+ file: relative(dirPath, leaf.path),
211
+ type: leaf.data.type ?? "primary",
212
+ focus: leaf.data.focus ?? "",
213
+ };
214
+ if (leaf.data.tags) record.tags = leaf.data.tags;
215
+ if (leaf.data.overlay_targets) record.overlay_targets = leaf.data.overlay_targets;
216
+ entries.push(record);
217
+ }
218
+ for (const sub of subdirs) {
219
+ const subIndex = readIndex(sub);
220
+ if (!subIndex) continue;
221
+ const record = {
222
+ id: subIndex.data.id,
223
+ file: relative(dirPath, join(sub, "index.md")),
224
+ type: "index",
225
+ focus: subIndex.data.focus ?? "",
226
+ };
227
+ if (subIndex.data.tags) record.tags = subIndex.data.tags;
228
+ entries.push(record);
229
+ }
230
+ data.entries = entries;
231
+
232
+ // Semantic-routing substrate: `activation_defaults` is NOT
233
+ // auto-aggregated anymore. Claude decides descent from `focus`
234
+ // and `shared_covers` semantically. If the user hand-authored an
235
+ // `activation_defaults` block (forwarded via AUTHORED_FIELDS or
236
+ // via indexInput), it survives here as a free-form authored hint
237
+ // but we no longer synthesise or merge one from child signals.
238
+ // See the doc comment on `rebuildIndex` above.
239
+
240
+ // Derived: children (subdirectory index pointers)
241
+ data.children = subdirs.map((s) => relative(dirPath, join(s, "index.md")));
242
+
243
+ // Derived: shared_covers — intersection of leaf covers when present.
244
+ // Also unioned with any authored shared_covers the user put in the
245
+ // existing index.md AND any shared_covers forwarded from an
246
+ // authored source index input. (Subcategory intersections are
247
+ // handled when their own indices rebuild.)
248
+ const computedShared = intersectCovers(leaves.map((l) => l.data.covers ?? []));
249
+ const authoredShared = existing?.data?.shared_covers ?? [];
250
+ const sourceShared =
251
+ authoredIndex && Array.isArray(authoredIndex.shared_covers)
252
+ ? authoredIndex.shared_covers
253
+ : [];
254
+ data.shared_covers = uniqueJoin(
255
+ uniqueJoin(computedShared, authoredShared),
256
+ sourceShared,
257
+ );
258
+
259
+ // Root gets the rebuild-surfacing fields and the generator marker.
260
+ // The marker is what the hook uses to positively identify this folder
261
+ // as a skill-llm-wiki-managed wiki (see paths.mjs::isWikiRoot). Without
262
+ // the marker, the hook treats the folder as unrelated and stays silent.
263
+ if (isRoot) {
264
+ if (data.rebuild_needed === undefined) data.rebuild_needed = false;
265
+ if (!data.rebuild_reasons) data.rebuild_reasons = [];
266
+ // The rebuild_command field uses a placeholder path instead of
267
+ // the absolute wikiRoot so that byte-identical wiki content
268
+ // produces a byte-identical tracked file across machines and
269
+ // install locations. The user substitutes the placeholder with
270
+ // their actual wiki path when they run the command. This is the
271
+ // determinism fix from the Phase 8 sweep finding that two
272
+ // identical builds into different tmp dirs were producing
273
+ // different HEAD tree SHAs.
274
+ if (!data.rebuild_command) {
275
+ data.rebuild_command = "skill-llm-wiki rebuild <wiki> --plan";
276
+ }
277
+ data.generator = WIKI_GENERATOR_MARKER;
278
+ }
279
+
280
+ // Pull an authored orientation block out of the source index body,
281
+ // if one was forwarded. The source may carry either literal
282
+ // `<!-- BEGIN AUTHORED ORIENTATION -->` markers (e.g. when re-
283
+ // building an already-built wiki) or a plain prose preface. We
284
+ // only lift the marker-delimited block here — the plain-prose case
285
+ // is covered by the `orientation:` YAML field, which we already
286
+ // forwarded into `data` via AUTHORED_FIELDS.
287
+ let sourceAuthoredOrientation = null;
288
+ if (indexInput?.body) {
289
+ sourceAuthoredOrientation = extractAuthoredBlock(indexInput.body);
290
+ }
291
+
292
+ // Deterministic key order
293
+ const ordered = orderKeys(data, isRoot);
294
+ const body = renderBody(
295
+ ordered,
296
+ existing,
297
+ sourceAuthoredOrientation,
298
+ );
299
+ atomicWriteFile(p, renderFrontmatter(ordered, body));
300
+ return { path: p, entries: entries.length, children: subdirs.length };
301
+ }
302
+
303
+ export function rebuildAllIndices(wikiRoot, options = {}) {
304
+ // Rebuild bottom-up so parent `shared_covers[]` computations see fresh
305
+ // child frontmatter. The wiki root is ALWAYS included even when it
306
+ // has no leaves of its own, so `isWikiRoot` can find the generator
307
+ // marker in its regenerated frontmatter.
308
+ //
309
+ // Scale: each directory's `listChildren` result is cached during the
310
+ // walk and threaded into `rebuildIndex` so every leaf's frontmatter is
311
+ // read exactly once per rebuild. The naive implementation walked twice
312
+ // (once to collect directories, once during per-directory aggregation),
313
+ // which doubled I/O for no reason.
314
+ //
315
+ // `options.indexInputs`: optional map { dirRelPath → authoredIndex }
316
+ // produced by the orchestrator's ingest phase when the source tree
317
+ // carried authored `index.md` files. Each entry forwards its
318
+ // frontmatter (orientation / shared_covers / activation_defaults /
319
+ // focus / tags / domains …) into the corresponding target index.
320
+ // Keys are POSIX-normalised relative paths from the wiki root
321
+ // (`""` for the root, `"operations"` for `operations/index.md`).
322
+ const { indexInputs = {} } = options;
323
+ const cache = new Map(); // dirPath → { leaves, subdirs }
324
+ const rootChildren = listChildren(wikiRoot);
325
+ cache.set(wikiRoot, rootChildren);
326
+ const dirs = [wikiRoot];
327
+ collectDirs(wikiRoot, wikiRoot, dirs, cache);
328
+ // Sort by depth descending so deepest directories rebuild first.
329
+ dirs.sort((a, b) => depthOf(b, wikiRoot) - depthOf(a, wikiRoot));
330
+ const out = [];
331
+ for (const d of dirs) {
332
+ const rel = d === wikiRoot ? "" : relative(wikiRoot, d).split("\\").join("/");
333
+ const indexInput = indexInputs[rel] || null;
334
+ out.push(
335
+ rebuildIndex(d, wikiRoot, cache.get(d) ?? null, { indexInput }),
336
+ );
337
+ }
338
+ return out;
339
+ }
340
+
341
+ function collectDirs(dirPath, wikiRoot, acc, cache) {
342
+ if (!existsSync(dirPath)) return;
343
+ try {
344
+ // Reuse the cached result when the caller (rebuildAllIndices)
345
+ // has already paid for it; otherwise compute and stash it so
346
+ // the rebuild pass can reuse.
347
+ let children = cache.get(dirPath);
348
+ if (!children) {
349
+ children = listChildren(dirPath);
350
+ cache.set(dirPath, children);
351
+ }
352
+ const { leaves, subdirs } = children;
353
+ // Include every non-root directory that carries at least one leaf
354
+ // or indexed subdir. The wiki root was already added by the
355
+ // caller; we skip adding it again to avoid duplicates.
356
+ if (dirPath !== wikiRoot && (leaves.length > 0 || subdirs.length > 0)) {
357
+ acc.push(dirPath);
358
+ }
359
+ for (const s of subdirs) collectDirs(s, wikiRoot, acc, cache);
360
+ } catch {
361
+ /* skip */
362
+ }
363
+ }
364
+
365
+ function depthOf(dirPath, wikiRoot) {
366
+ if (dirPath === wikiRoot) return 0;
367
+ return relative(wikiRoot, dirPath).split("/").filter(Boolean).length;
368
+ }
369
+
370
+ function computeDepth(dirPath, wikiRoot) {
371
+ return depthOf(dirPath, wikiRoot);
372
+ }
373
+
374
+ function intersectCovers(lists) {
375
+ if (lists.length === 0) return [];
376
+ if (lists.length === 1) return [];
377
+ const out = [];
378
+ for (const item of lists[0]) {
379
+ if (lists.every((l) => l.includes(item))) out.push(item);
380
+ }
381
+ return out;
382
+ }
383
+
384
+ function uniqueJoin(a, b) {
385
+ const seen = new Set();
386
+ const out = [];
387
+ for (const item of [...a, ...b]) {
388
+ if (!seen.has(item)) {
389
+ seen.add(item);
390
+ out.push(item);
391
+ }
392
+ }
393
+ return out;
394
+ }
395
+
396
+ function orderKeys(data, isRoot) {
397
+ // Canonical ordering for deterministic output.
398
+ const baseOrder = [
399
+ "id",
400
+ "type",
401
+ "depth_role",
402
+ "depth",
403
+ "focus",
404
+ "parents",
405
+ "tags",
406
+ "domains",
407
+ "activation_defaults",
408
+ "shared_covers",
409
+ "sources",
410
+ "source_wikis",
411
+ "orientation",
412
+ "generator",
413
+ "mode",
414
+ "layout_contract_path",
415
+ "rebuild_needed",
416
+ "rebuild_reasons",
417
+ "rebuild_command",
418
+ "entries",
419
+ "children",
420
+ ];
421
+ const out = {};
422
+ for (const k of baseOrder) {
423
+ if (data[k] !== undefined) out[k] = data[k];
424
+ }
425
+ // Any extra keys appended at the end preserve author additions.
426
+ for (const k of Object.keys(data)) {
427
+ if (!(k in out)) out[k] = data[k];
428
+ }
429
+ if (!isRoot) {
430
+ delete out.rebuild_needed;
431
+ delete out.rebuild_reasons;
432
+ delete out.rebuild_command;
433
+ }
434
+ return out;
435
+ }
436
+
437
+ function renderBody(data, existing, sourceAuthoredOrientation) {
438
+ const lines = [];
439
+ lines.push("");
440
+ lines.push(AUTO_BEGIN);
441
+ lines.push("");
442
+ lines.push(`# ${titleize(data.id)}`);
443
+ lines.push("");
444
+ if (data.focus) {
445
+ lines.push(`**Focus:** ${data.focus}`);
446
+ lines.push("");
447
+ }
448
+ if (data.shared_covers && data.shared_covers.length > 0) {
449
+ lines.push("**Shared across all children:**");
450
+ lines.push("");
451
+ for (const c of data.shared_covers) lines.push(`- ${c}`);
452
+ lines.push("");
453
+ }
454
+ if (data.entries && data.entries.length > 0) {
455
+ lines.push("## Children");
456
+ lines.push("");
457
+ lines.push("| File | Type | Focus |");
458
+ lines.push("|------|------|-------|");
459
+ for (const e of data.entries) {
460
+ const typeTag = e.type === "index" ? "📁 index" : e.type === "overlay" ? "🔗 overlay" : "📄 primary";
461
+ lines.push(`| [${e.file}](${e.file}) | ${typeTag} | ${e.focus || ""} |`);
462
+ }
463
+ lines.push("");
464
+ } else {
465
+ lines.push("_No children yet._");
466
+ lines.push("");
467
+ }
468
+ lines.push(AUTO_END);
469
+ lines.push("");
470
+
471
+ // Preserve authored orientation block. Priority:
472
+ // 1. existing target index.md body (`<!-- BEGIN AUTHORED ORIENTATION -->`)
473
+ // 2. authored source index body block (forwarded via indexInput)
474
+ // 3. YAML `orientation:` field from the rebuilt frontmatter
475
+ const authored = extractAuthoredBlock(existing?.body ?? "");
476
+ const sourceAuthored = sourceAuthoredOrientation || null;
477
+ lines.push(AUTHORED_BEGIN);
478
+ if (authored) {
479
+ lines.push(authored);
480
+ } else if (sourceAuthored) {
481
+ lines.push(sourceAuthored);
482
+ } else if (data.orientation) {
483
+ lines.push(data.orientation);
484
+ }
485
+ lines.push(AUTHORED_END);
486
+ lines.push("");
487
+
488
+ return lines.join("\n");
489
+ }
490
+
491
+ function extractAuthoredBlock(body) {
492
+ const start = body.indexOf(AUTHORED_BEGIN);
493
+ const end = body.indexOf(AUTHORED_END);
494
+ if (start === -1 || end === -1 || end <= start) return null;
495
+ return body.slice(start + AUTHORED_BEGIN.length, end).trim();
496
+ }
497
+
498
+ function titleize(id) {
499
+ return id
500
+ .split("-")
501
+ .map((p) => p.charAt(0).toUpperCase() + p.slice(1))
502
+ .join(" ");
503
+ }
504
+
505
+ function atomicWriteFile(targetPath, content) {
506
+ mkdirSync(dirname(targetPath), { recursive: true });
507
+ const tmp = targetPath + ".tmp";
508
+ writeFileSync(tmp, content, "utf8");
509
+ renameSync(tmp, targetPath);
510
+ }