@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,718 @@
1
+ // orchestrator.mjs — glue a top-level operation (build / extend / rebuild
2
+ // / fix / join) to the phased execution model defined in methodology
3
+ // section 9.4. Every phase commits to the private git repo between phases,
4
+ // giving `git log pre-op/<id>..op/<id>` a granular view of what the
5
+ // operation did.
6
+ //
7
+ // Phase 3 ships with a minimum-viable set of phases that make `build`
8
+ // produce a real wiki from a source folder:
9
+ //
10
+ // preflight → pre-op snapshot → ingest → draft-frontmatter →
11
+ // index-generation → validation → commit-finalize
12
+ //
13
+ // Operator-convergence is a stub here — it lands properly in Phase 5
14
+ // (chunked iteration) + Phase 6 (tiered AI). For Build against a
15
+ // well-shaped source, the tree is usable without it; for Rebuild the
16
+ // stub is a no-op and a warning is printed so Claude surfaces it.
17
+ //
18
+ // On validation failure, the orchestrator runs `git reset --hard
19
+ // pre-op/<id> && git clean -fd`, restoring the working tree to its
20
+ // pre-op state byte-exact. Phase commits since the pre-tag remain in
21
+ // the reflog for post-mortem inspection.
22
+
23
+ import {
24
+ existsSync,
25
+ mkdirSync,
26
+ readFileSync,
27
+ readdirSync,
28
+ writeFileSync,
29
+ } from "node:fs";
30
+ import { basename, dirname, join, relative } from "node:path";
31
+ import { ingestSource } from "./ingest.mjs";
32
+ import { draftCategory, draftLeafFrontmatter } from "./draft.mjs";
33
+ import { rebuildAllIndices } from "./indices.mjs";
34
+ import { validateWiki, summariseFindings } from "./validate.mjs";
35
+ import {
36
+ gitClean,
37
+ gitCommit,
38
+ gitResetHard,
39
+ gitRunChecked,
40
+ gitTag,
41
+ gitHeadSha,
42
+ gitWorkingTreeClean,
43
+ } from "./git.mjs";
44
+ import { preOpSnapshot } from "./snapshot.mjs";
45
+ import { appendOpLog } from "./history.mjs";
46
+ import { parseFrontmatter, renderFrontmatter } from "./frontmatter.mjs";
47
+ import {
48
+ provenancePath,
49
+ recordSource,
50
+ startCorpus,
51
+ } from "./provenance.mjs";
52
+ import { rmSync } from "node:fs";
53
+ import { runConvergence } from "./operators.mjs";
54
+ import { runReviewCycle } from "../commands/review.mjs";
55
+ import {
56
+ deriveBatchId,
57
+ listBatches,
58
+ readAllResponses,
59
+ writePending,
60
+ } from "./tier2-protocol.mjs";
61
+ import {
62
+ clearTier2Responses,
63
+ seedTier2Responses,
64
+ takePendingRequests,
65
+ } from "./tiered.mjs";
66
+
67
+ // Public entry. `plan` comes from intent.mjs and carries
68
+ // { operation, layout_mode, source, target, is_new_wiki, flags }.
69
+ // Returns { op_id, final_sha, phases: [...] } on success; throws on
70
+ // validation failure (after rolling the working tree back to pre-op).
71
+ export async function runOperation(plan, { opId, source, startedIso } = {}) {
72
+ if (!plan || !plan.target) {
73
+ throw new Error("runOperation requires a resolved plan with a target");
74
+ }
75
+ if (!opId || typeof opId !== "string") {
76
+ throw new Error("runOperation requires an opId");
77
+ }
78
+ const wikiRoot = plan.target;
79
+ const workDir = join(wikiRoot, ".work", opId);
80
+ mkdirSync(workDir, { recursive: true });
81
+
82
+ const phases = [];
83
+ const record = (name, summary) => phases.push({ name, summary });
84
+
85
+ // Map of authored index hints keyed by POSIX-relative directory
86
+ // path from the wiki root. Populated in the ingest phase for Build
87
+ // and consumed by rebuildAllIndices in the index-generation phase,
88
+ // so fields like shared_covers / orientation / activation_defaults
89
+ // survive from the source `index.md` into the synthesised target.
90
+ const indexInputs = {};
91
+
92
+ // Phase 1 — pre-op snapshot (always, even on empty wikis).
93
+ const snap = preOpSnapshot(wikiRoot, opId);
94
+ record("snapshot", `tag ${snap.tag} sha=${(snap.sha ?? "n/a").slice(0, 12)}`);
95
+
96
+ try {
97
+ // Phase 2 — ingest + draft-frontmatter. Phase 3 only supports the
98
+ // BUILD path. Extend is structurally disabled here because the
99
+ // naive overwrite of authored leaves is destructive; Phase 4 lands
100
+ // a merge-preserving extend that respects user edits. Rebuild/Fix/
101
+ // Join read from the wiki's frontmatter rather than raw sources —
102
+ // also Phase 4+ scope.
103
+ if (plan.operation === "build") {
104
+ const sourcePath = plan.source;
105
+ if (!sourcePath) {
106
+ throw new Error("build requires a resolved source path");
107
+ }
108
+ const { leaves: candidates, indexSources } = ingestSource(sourcePath);
109
+ writeFileSync(
110
+ join(workDir, "candidates.json"),
111
+ JSON.stringify({ candidates, indexSources }, null, 2),
112
+ "utf8",
113
+ );
114
+ // Start the provenance manifest. `pre_commit` pins source sizes
115
+ // to the private git's pre-op snapshot — but for Build the
116
+ // private repo's working tree snapshot does NOT contain the
117
+ // user's source files (source lives outside the wiki). Source
118
+ // sizes are authoritative from `candidates[].size` at ingest
119
+ // time and we verify against that on LOSS-01.
120
+ startCorpus(wikiRoot, {
121
+ root: sourcePath,
122
+ root_hash: null,
123
+ pre_commit: snap.sha,
124
+ ingested_at: startedIso || new Date().toISOString(),
125
+ });
126
+ gitRunChecked(wikiRoot, ["add", "-A"]);
127
+ record("ingest", `${candidates.length} candidate(s) from ${sourcePath}`);
128
+
129
+ // Draft-frontmatter + layout. For each candidate, compute its
130
+ // category path and write a fresh leaf .md file.
131
+ //
132
+ // Resume-safe (idempotent) ingest: a build that exited 7 mid-way
133
+ // already wrote leaves — and operator-convergence may have moved
134
+ // them under subdirectories. Re-running the loop blindly would
135
+ // either overwrite authored frontmatter at the original path or
136
+ // duplicate the leaf at root. Instead we:
137
+ //
138
+ // 1. Walk the wiki once and build a map keyed by the
139
+ // `source.path` field carried in each existing leaf's
140
+ // frontmatter → `{ absLeafPath, hash, dataKeys }`.
141
+ // 2. For each candidate:
142
+ // a. If the source path is in the map AND the recorded
143
+ // hash matches the freshly-ingested hash, SKIP the
144
+ // write (the existing leaf is already correct, and any
145
+ // frontmatter authored by convergence is preserved).
146
+ // b. If the source path is in the map but the hash
147
+ // differs, REWRITE in place at the existing location
148
+ // (the source has changed and a re-draft is correct).
149
+ // c. If the source path is NOT in the map, write a fresh
150
+ // leaf at the computed category path. Initial-build
151
+ // runs hit this branch for every candidate.
152
+ //
153
+ // The first build still writes everything; resume runs skip the
154
+ // unchanged majority and never touch leaves moved by convergence.
155
+ const existingLeavesBySource = collectExistingLeavesBySource(wikiRoot);
156
+ let wrote = 0;
157
+ let skipped = 0;
158
+ let updated = 0;
159
+ for (const candidate of candidates) {
160
+ const existing = existingLeavesBySource.get(candidate.source_path);
161
+ if (existing) {
162
+ if (existing.hash === candidate.hash) {
163
+ // Byte-identical source → no-op. Provenance is still re-
164
+ // recorded so the manifest reflects this op-id's view of
165
+ // the world (startCorpus cleared the file at the top of
166
+ // this phase).
167
+ recordSource(wikiRoot, existing.targetRel, {
168
+ source_path: candidate.source_path,
169
+ source_pre_hash: candidate.hash,
170
+ source_size: candidate.size,
171
+ byte_range: [0, candidate.size],
172
+ disposition: "preserved",
173
+ });
174
+ skipped++;
175
+ continue;
176
+ }
177
+ // Hash mismatch: re-draft at the existing location so any
178
+ // post-convergence reshape is preserved.
179
+ const draft = draftLeafFrontmatter(candidate, {
180
+ categoryPath: existing.relCategory,
181
+ });
182
+ const body =
183
+ typeof candidate.body === "string"
184
+ ? candidate.body
185
+ : readFileSync(candidate.absolute_path, "utf8");
186
+ const rendered = renderFrontmatter(draft.data) + "\n" + body;
187
+ writeFileSync(existing.absLeafPath, rendered, "utf8");
188
+ recordSource(wikiRoot, existing.targetRel, {
189
+ source_path: candidate.source_path,
190
+ source_pre_hash: candidate.hash,
191
+ source_size: candidate.size,
192
+ byte_range: [0, candidate.size],
193
+ disposition: "preserved",
194
+ });
195
+ updated++;
196
+ continue;
197
+ }
198
+ // Fresh leaf: compute the draft category and write at
199
+ // <wiki>/<category>/<basename>.md.
200
+ const category = draftCategory(candidate);
201
+ const draft = draftLeafFrontmatter(candidate, {
202
+ categoryPath: category,
203
+ });
204
+ const categoryDir = category ? join(wikiRoot, category) : wikiRoot;
205
+ mkdirSync(categoryDir, { recursive: true });
206
+ // Leaf filename on disk = final path segment of the SOURCE
207
+ // (e.g. `operations/build.md` → `build.md`). The candidate
208
+ // `id` stays globally unique for routing — see
209
+ // `scripts/lib/ingest.mjs::deriveId` — but the awkward flat-
210
+ // slug filename (`operations-build.md`) is a routing
211
+ // distraction, so we store the plain name on disk.
212
+ const sourceSegments = candidate.source_path.split(/[\/\\]/).filter(Boolean);
213
+ const leafFilename = sourceSegments[sourceSegments.length - 1] || `${candidate.id}.md`;
214
+ const leafPath = join(categoryDir, leafFilename);
215
+ if (existsSync(leafPath)) {
216
+ // A leaf already lives at this path but it does not carry a
217
+ // matching `source.path`. This is the "stale collision"
218
+ // case: a previous candidate wrote to the same filename
219
+ // from a different source. Refuse loudly — the collision
220
+ // means the source layout changed in a way the orchestrator
221
+ // cannot reconcile without operator help.
222
+ throw new Error(
223
+ `build: leaf ${leafPath} exists but its frontmatter does ` +
224
+ `not reference ${candidate.source_path} — refusing to ` +
225
+ "clobber. Run `rebuild` to reconcile.",
226
+ );
227
+ }
228
+ // `candidate.body` carries the source content WITH its
229
+ // frontmatter fence already stripped by ingest.mjs (via
230
+ // gray-matter). Prefer it over re-reading the file so we do
231
+ // not double-stack fences in the leaf output.
232
+ const body =
233
+ typeof candidate.body === "string"
234
+ ? candidate.body
235
+ : readFileSync(candidate.absolute_path, "utf8");
236
+ const rendered = renderFrontmatter(draft.data) + "\n" + body;
237
+ writeFileSync(leafPath, rendered, "utf8");
238
+ // Record the whole source file as preserved into this leaf —
239
+ // Phase 3's draft-frontmatter does not yet split or discard
240
+ // any portion, so the byte range is [0, size] and disposition
241
+ // is `preserved`. Phase 6 operators will record split / merged
242
+ // / transformed dispositions when they start reshaping entries.
243
+ const targetRel = category
244
+ ? `${category}/${leafFilename}`
245
+ : leafFilename;
246
+ recordSource(wikiRoot, targetRel, {
247
+ source_path: candidate.source_path,
248
+ source_pre_hash: candidate.hash,
249
+ source_size: candidate.size,
250
+ byte_range: [0, candidate.size],
251
+ disposition: "preserved",
252
+ });
253
+ wrote++;
254
+ }
255
+
256
+ // Index-source inputs: source files named `index.md` (or
257
+ // carrying `type: index` in their frontmatter) are not leaves —
258
+ // they carry authored hints (shared_covers / orientation /
259
+ // activation_defaults) for the SYNTHESISED target index at the
260
+ // matching directory. Stash them under `.work/<opId>/` where the
261
+ // index-generation phase below can pick them up and forward
262
+ // their fields into the rebuilt `index.md` files.
263
+ //
264
+ // Note: index-source bodies are also provenance-recorded so
265
+ // LOSS-01 stays satisfied. The target they map to is the
266
+ // synthesised `<dir>/index.md` (or the root `index.md`).
267
+ if (indexSources.length > 0) {
268
+ const indexInputsPath = join(workDir, "index-inputs.json");
269
+ const serialisable = indexSources.map((ix) => ({
270
+ source_path: ix.source_path,
271
+ dir: ix.dir,
272
+ authored_frontmatter: ix.authored_frontmatter || {},
273
+ body: ix.body || "",
274
+ hash: ix.hash,
275
+ size: ix.size,
276
+ }));
277
+ for (const ix of serialisable) {
278
+ // Key by POSIX-normalised directory, "" for root. Matches
279
+ // the key space rebuildAllIndices expects.
280
+ indexInputs[ix.dir || ""] = ix;
281
+ }
282
+ writeFileSync(
283
+ indexInputsPath,
284
+ JSON.stringify({ indexSources: serialisable }, null, 2),
285
+ "utf8",
286
+ );
287
+ for (const ix of indexSources) {
288
+ const targetDir = ix.dir || "";
289
+ const targetRel = targetDir
290
+ ? `${targetDir}/index.md`
291
+ : "index.md";
292
+ recordSource(wikiRoot, targetRel, {
293
+ source_path: ix.source_path,
294
+ source_pre_hash: ix.hash,
295
+ source_size: ix.size,
296
+ byte_range: [0, ix.size],
297
+ disposition: "preserved",
298
+ });
299
+ }
300
+ }
301
+
302
+ gitRunChecked(wikiRoot, ["add", "-A"]);
303
+ if (!gitWorkingTreeClean(wikiRoot)) {
304
+ gitCommit(
305
+ wikiRoot,
306
+ `phase draft-frontmatter: wrote ${wrote}` +
307
+ (updated > 0 ? ` updated ${updated}` : "") +
308
+ (skipped > 0 ? ` skipped ${skipped}` : "") +
309
+ ` leaves` +
310
+ (indexSources.length > 0
311
+ ? ` (+${indexSources.length} index source(s))`
312
+ : ""),
313
+ );
314
+ }
315
+ record(
316
+ "draft-frontmatter",
317
+ `wrote ${wrote}` +
318
+ (updated > 0 ? `, updated ${updated}` : "") +
319
+ (skipped > 0 ? `, skipped ${skipped}` : "") +
320
+ " leaves" +
321
+ (indexSources.length > 0
322
+ ? ` (+${indexSources.length} index source(s))`
323
+ : ""),
324
+ );
325
+ } else if (plan.operation === "extend") {
326
+ throw new Error(
327
+ "extend: not yet implemented in Phase 3 — Phase 4 will add " +
328
+ "frontmatter-preserving merge. For now, rebuild the wiki from " +
329
+ "its source, or wait for Phase 4.",
330
+ );
331
+ } else {
332
+ record(
333
+ "ingest",
334
+ `skipped for ${plan.operation} (phase 4+ reads from frontmatter)`,
335
+ );
336
+ }
337
+
338
+ // Phase 4 — operator-convergence. Runs the tiered ladder
339
+ // (Tier 0 TF-IDF → Tier 1 MiniLM embeddings → Tier 2 sub-agent
340
+ // via exit-7 handshake) through the five operators from
341
+ // methodology §3.5 PLUS the cluster-based NEST applier from
342
+ // cluster-detect.mjs. Each applied proposal produces its own
343
+ // per-iteration commit so `git log` shows the convergence
344
+ // history at file-level granularity.
345
+ //
346
+ // On resume (after a previous exit-7 wrote responses), we
347
+ // seed tiered.mjs's runtime-resolved-response map with the
348
+ // answers collected by the wiki-runner so the next call to
349
+ // runConvergence finds them inline instead of re-enqueuing.
350
+ clearTier2Responses(wikiRoot);
351
+ const priorResponses = readAllResponses(wikiRoot);
352
+ if (priorResponses.size > 0) {
353
+ seedTier2Responses(wikiRoot, priorResponses);
354
+ }
355
+ const convergence = await runConvergence(wikiRoot, {
356
+ opId,
357
+ qualityMode: plan.flags?.quality_mode || "tiered-fast",
358
+ interactive: false, // orchestrator runs non-interactive
359
+ commitBetweenIterations: async ({ iteration, operator, summary }) => {
360
+ gitRunChecked(wikiRoot, ["add", "-A"]);
361
+ if (!gitWorkingTreeClean(wikiRoot)) {
362
+ gitCommit(
363
+ wikiRoot,
364
+ `phase operator-convergence: iteration ${iteration} ${operator} — ${summary}`,
365
+ );
366
+ }
367
+ },
368
+ });
369
+
370
+ // If convergence parked any Tier 2 requests, drain them into a
371
+ // pending batch and raise NeedsTier2 so the CLI exits with
372
+ // code 7. The wiki-runner will write responses and re-invoke.
373
+ if (convergence.needs_tier2) {
374
+ const requests = takePendingRequests(wikiRoot);
375
+ if (requests.length > 0) {
376
+ const batchId = deriveBatchId(opId, "convergence", convergence.iterations);
377
+ const path = writePending(wikiRoot, batchId, requests);
378
+ throw new NeedsTier2Error(
379
+ `operator-convergence parked ${requests.length} Tier 2 request(s) ` +
380
+ `(batch ${batchId}); wiki-runner must resolve and re-invoke`,
381
+ opId,
382
+ path,
383
+ );
384
+ }
385
+ }
386
+ record(
387
+ "operator-convergence",
388
+ `${convergence.applied.length} operator(s) applied across ` +
389
+ `${convergence.iterations} iteration(s); ` +
390
+ `${convergence.suggestions.length} suggestion(s) recorded`,
391
+ );
392
+
393
+ // Phase 4.5 — optional interactive review. Fires only when the
394
+ // user passed --review AND convergence actually produced at
395
+ // least one commit. The review flow prints a diff + commit
396
+ // list and lets the user approve, abort, or drop specific
397
+ // iterations before validation runs. Abort throws so the
398
+ // orchestrator's catch block handles the rollback uniformly
399
+ // with any other failure path.
400
+ if (plan.flags?.review && convergence.applied.length > 0) {
401
+ const reviewResult = await runReviewCycle(wikiRoot, opId, {
402
+ forceInteractive: plan.flags?.force_interactive === true,
403
+ });
404
+ if (reviewResult.outcome === "abort") {
405
+ throw new ReviewAbortedError(
406
+ `user aborted review for op ${opId} — working tree rolled back`,
407
+ opId,
408
+ );
409
+ }
410
+ // `applyDrop` uses `git revert --no-edit`, which produces its
411
+ // own inverse commit directly in history — so by the time we
412
+ // see `outcome: "approve"` (possibly with a non-empty
413
+ // `dropped[]`), there is nothing left to stage or commit here.
414
+ // We just surface the drop count in the phase summary so the
415
+ // op-log records that drops happened.
416
+ const dropCount = Array.isArray(reviewResult.dropped)
417
+ ? reviewResult.dropped.length
418
+ : 0;
419
+ record(
420
+ "review",
421
+ `outcome=${reviewResult.outcome}${dropCount ? ` (dropped ${dropCount})` : ""}`,
422
+ );
423
+ }
424
+
425
+ // Phase 5 — index-generation. `rebuildAllIndices` only visits
426
+ // directories that ALREADY contain an `index.md` (plus the wiki
427
+ // root once at least one child index exists). For a fresh Build,
428
+ // no such stubs exist yet — we create minimal ones bottom-up so
429
+ // the rebuild pass can fill them in with frontmatter.
430
+ bootstrapIndexStubs(wikiRoot);
431
+ const rebuilt = rebuildAllIndices(wikiRoot, { indexInputs });
432
+ gitRunChecked(wikiRoot, ["add", "-A"]);
433
+ if (!gitWorkingTreeClean(wikiRoot)) {
434
+ gitCommit(
435
+ wikiRoot,
436
+ `phase index-generation: rebuilt ${rebuilt.length} index.md files`,
437
+ );
438
+ }
439
+ record("index-generation", `rebuilt ${rebuilt.length} indices`);
440
+
441
+ // Phase 6 — validation. Any hard-invariant failure halts the pipeline
442
+ // and triggers the rollback below.
443
+ const findings = validateWiki(wikiRoot);
444
+ const summary = summariseFindings(findings);
445
+ writeFileSync(
446
+ join(workDir, "validation-report.json"),
447
+ JSON.stringify({ findings, summary }, null, 2),
448
+ "utf8",
449
+ );
450
+ if (summary.errors > 0) {
451
+ const preview = findings
452
+ .filter((f) => f.severity === "error")
453
+ .slice(0, 5)
454
+ .map((f) => ` ${f.code}: ${f.message} (${f.target})`)
455
+ .join("\n");
456
+ throw new ValidationError(
457
+ `validation failed with ${summary.errors} error(s) for op ${opId} ` +
458
+ `(rolled back to pre-op/${opId}):\n${preview}`,
459
+ opId,
460
+ );
461
+ }
462
+ record("validation", `${summary.errors} errors, ${summary.warnings} warnings`);
463
+
464
+ // Phase 7 — commit-finalize. Tag the final commit, append op-log.
465
+ // The tag + op-log + record() calls are the "finalise" atoms: once
466
+ // they have run, the op is considered complete and the
467
+ // failure-rollback path must not fire.
468
+ const finalSha = gitHeadSha(wikiRoot);
469
+ gitTag(wikiRoot, `op/${opId}`, "HEAD");
470
+ appendOpLog(wikiRoot, {
471
+ op_id: opId,
472
+ operation: plan.operation,
473
+ layout_mode: plan.layout_mode,
474
+ started: startedIso || new Date().toISOString(),
475
+ finished: new Date().toISOString(),
476
+ base_commit: snap.sha || "",
477
+ final_commit: finalSha || "",
478
+ summary:
479
+ `${plan.operation} target=${plan.target} ` +
480
+ `source=${plan.source ?? "n/a"} mode=${plan.layout_mode} ` +
481
+ `phases=${phases.length}`,
482
+ });
483
+ record("commit-finalize", `tagged op/${opId}`);
484
+
485
+ return {
486
+ op_id: opId,
487
+ final_sha: finalSha,
488
+ phases,
489
+ };
490
+ } catch (err) {
491
+ // NeedsTier2 is NOT a failure path — it's the suspend-and-
492
+ // resume signal the exit-7 handshake uses. The convergence
493
+ // phase committed its partial work; we leave the working tree
494
+ // as-is and let the CLI propagate the exit code. The op-log
495
+ // is not finalised because the op isn't done.
496
+ if (err instanceof NeedsTier2Error) {
497
+ throw err;
498
+ }
499
+ // Validation or any other phase failure: reset to pre-op.
500
+ //
501
+ // `.llmwiki/provenance.yaml` is wiped ONLY when the current op
502
+ // wrote it (`build`), because it lives outside the git working
503
+ // tree and `git reset --hard` cannot undo the write. For
504
+ // non-build operations (rebuild, fix, join) the provenance
505
+ // file is pre-existing from an earlier build; wiping it on
506
+ // review abort or validation failure would be unrecoverable
507
+ // data loss.
508
+ try {
509
+ gitResetHard(wikiRoot, snap.tag);
510
+ gitClean(wikiRoot);
511
+ } catch (resetErr) {
512
+ err.rollback_error = resetErr.message;
513
+ }
514
+ if (plan.operation === "build") {
515
+ try {
516
+ rmSync(provenancePath(wikiRoot), { force: true });
517
+ } catch {
518
+ /* best effort — the next operation's startCorpus will
519
+ overwrite it anyway */
520
+ }
521
+ }
522
+ throw err;
523
+ } finally {
524
+ // Housekeeping: run `git gc --auto` AFTER the try/catch so a gc
525
+ // failure cannot rollback a successful op. Best-effort; log and
526
+ // move on if gc fails.
527
+ try {
528
+ gitRunChecked(wikiRoot, ["gc", "--auto", "--quiet"]);
529
+ } catch (gcErr) {
530
+ process.stderr.write(
531
+ `skill-llm-wiki: git gc --auto failed (non-fatal): ${gcErr.message}\n`,
532
+ );
533
+ }
534
+ }
535
+ }
536
+
537
+ export class ValidationError extends Error {
538
+ constructor(msg, opId = null) {
539
+ super(msg);
540
+ this.name = "ValidationError";
541
+ this.opId = opId;
542
+ }
543
+ }
544
+
545
+ // Thrown when a phase has accumulated Tier 2 requests and needs
546
+ // the wiki-runner to resolve them before the operation can
547
+ // continue. The CLI catches this and exits with code 7
548
+ // (NEEDS_TIER2) — exit-7 is NOT a failure path; it's a normal
549
+ // suspend-and-resume signal. The orchestrator does NOT roll back
550
+ // to the pre-op snapshot; the partial-convergence commits remain
551
+ // in the private git and the wiki is left in an intermediate
552
+ // shape for the resume to pick up.
553
+ export class NeedsTier2Error extends Error {
554
+ constructor(msg, opId = null, pendingPath = null) {
555
+ super(msg);
556
+ this.name = "NeedsTier2Error";
557
+ this.opId = opId;
558
+ this.pendingPath = pendingPath;
559
+ }
560
+ }
561
+
562
+ // Thrown when the user aborts an interactive review. Signals the
563
+ // orchestrator's catch-block to roll back to pre-op. The caller in
564
+ // cli.mjs recognises this class and prints a friendly "review
565
+ // aborted" message instead of a generic stack trace. Carries the
566
+ // op-id so programmatic callers can correlate without regex-
567
+ // parsing the error message.
568
+ export class ReviewAbortedError extends Error {
569
+ constructor(msg, opId = null) {
570
+ super(msg);
571
+ this.name = "ReviewAbortedError";
572
+ this.opId = opId;
573
+ }
574
+ }
575
+
576
+ // Walk the wiki tree and ensure every directory containing `.md` leaf
577
+ // files (AND every ancestor of such a directory up to the root) has a
578
+ // minimal `index.md` stub. The stubs carry the `generator:
579
+ // skill-llm-wiki/v1` marker (required by `isWikiRoot`) and placeholder
580
+ // identity fields that `rebuildIndex` will overwrite with derived
581
+ // data. Idempotent: pre-existing indices are left alone.
582
+ //
583
+ // Stubbing ancestors is essential for depth ≥ 2: without it,
584
+ // `collectDirs` in indices.mjs cannot reach the deeper dirs because
585
+ // it walks via `listChildren.subdirs` which only counts dirs that
586
+ // already have `index.md`.
587
+ //
588
+ // This is the ONE place the orchestrator writes stub frontmatter
589
+ // directly, and only for indices. Leaves always carry their own
590
+ // drafted frontmatter from `draft-frontmatter`.
591
+ function bootstrapIndexStubs(wikiRoot) {
592
+ const dirs = new Set();
593
+ dirs.add(wikiRoot);
594
+ collectLeafBearingDirs(wikiRoot, wikiRoot, dirs);
595
+ for (const dir of dirs) {
596
+ const indexPath = join(dir, "index.md");
597
+ if (existsSync(indexPath)) continue;
598
+ const isRoot = dir === wikiRoot;
599
+ const id = isRoot ? basename(wikiRoot) : basename(dir);
600
+ // NOTE: we deliberately omit `parents:` from the stub. `rebuildIndex`
601
+ // knows how to derive the immediate-parent path from the directory
602
+ // position (see indices.mjs), and previous stub code got this wrong
603
+ // for depth ≥ 2 by pointing straight at the root. Leaving the field
604
+ // off lets `rebuildIndex` compute it correctly for every depth.
605
+ const stub =
606
+ "---\n" +
607
+ `id: ${id}\n` +
608
+ "type: index\n" +
609
+ (isRoot ? "depth_role: category\n" : "depth_role: subcategory\n") +
610
+ `focus: "subtree under ${id}"\n` +
611
+ "generator: skill-llm-wiki/v1\n" +
612
+ "---\n\n";
613
+ writeFileSync(indexPath, stub, "utf8");
614
+ }
615
+ }
616
+
617
+ // Walk the wiki tree once and build a map keyed by the
618
+ // `source.path` field carried in each existing leaf's frontmatter.
619
+ // Leaves without a `source.path` are skipped silently — they belong
620
+ // to other operations (rebuild/extend) which do not participate in
621
+ // build's resume protocol.
622
+ //
623
+ // Returned shape: Map<sourceRelPath, {
624
+ // absLeafPath: absolute path on disk
625
+ // targetRel: POSIX-relative path from wikiRoot (no leading "./")
626
+ // relCategory: POSIX-relative category dir from wikiRoot, "" for root
627
+ // hash: the source.hash recorded at the last write
628
+ // }>
629
+ //
630
+ // Used by the build phase to detect "this candidate was already
631
+ // drafted, possibly at a non-default location, and is byte-identical
632
+ // to the source on disk → skip the write" without losing authored
633
+ // frontmatter or doubling up after operator-convergence reshapes.
634
+ export function collectExistingLeavesBySource(wikiRoot) {
635
+ const map = new Map();
636
+ walkLeafFiles(wikiRoot, wikiRoot, (absPath) => {
637
+ let raw;
638
+ try {
639
+ raw = readFileSync(absPath, "utf8");
640
+ } catch {
641
+ return;
642
+ }
643
+ let parsed;
644
+ try {
645
+ parsed = parseFrontmatter(raw, absPath);
646
+ } catch {
647
+ return;
648
+ }
649
+ const data = parsed?.data;
650
+ if (!data || typeof data !== "object") return;
651
+ const src = data.source;
652
+ if (!src || typeof src !== "object") return;
653
+ const sourcePath = typeof src.path === "string" ? src.path : null;
654
+ if (!sourcePath) return;
655
+ const hash = typeof src.hash === "string" ? src.hash : null;
656
+ const rel = relative(wikiRoot, absPath).split(/[\\\/]/).join("/");
657
+ const dir = rel.includes("/") ? rel.slice(0, rel.lastIndexOf("/")) : "";
658
+ map.set(sourcePath, {
659
+ absLeafPath: absPath,
660
+ targetRel: rel,
661
+ relCategory: dir,
662
+ hash,
663
+ });
664
+ });
665
+ return map;
666
+ }
667
+
668
+ function walkLeafFiles(dir, wikiRoot, visit) {
669
+ let entries;
670
+ try {
671
+ entries = readdirSync(dir, { withFileTypes: true });
672
+ } catch {
673
+ return;
674
+ }
675
+ for (const e of entries) {
676
+ if (e.name.startsWith(".")) continue;
677
+ const full = join(dir, e.name);
678
+ if (e.isDirectory()) {
679
+ walkLeafFiles(full, wikiRoot, visit);
680
+ continue;
681
+ }
682
+ if (!e.isFile()) continue;
683
+ if (!e.name.endsWith(".md")) continue;
684
+ if (e.name === "index.md") continue;
685
+ visit(full);
686
+ }
687
+ }
688
+
689
+ // Walk the wiki tree (skipping dot-dirs like .llmwiki/.work/.shape)
690
+ // and add every directory that either (a) contains a leaf `.md` file
691
+ // or (b) contains a descendant directory that does. Adding
692
+ // intermediate ancestors is essential for depth ≥ 2 wikis: otherwise
693
+ // `collectDirs` in indices.mjs cannot reach them via its
694
+ // subdirs-with-index traversal, and the intermediate dirs never get
695
+ // an index.md at all.
696
+ function collectLeafBearingDirs(dir, wikiRoot, acc) {
697
+ let entries;
698
+ try {
699
+ entries = readdirSync(dir, { withFileTypes: true });
700
+ } catch {
701
+ return;
702
+ }
703
+ let hasLeaf = false;
704
+ let hasIndexedDescendant = false;
705
+ for (const e of entries) {
706
+ if (e.name.startsWith(".")) continue;
707
+ if (e.isFile() && e.name.endsWith(".md") && e.name !== "index.md") {
708
+ hasLeaf = true;
709
+ continue;
710
+ }
711
+ if (e.isDirectory()) {
712
+ const before = acc.size;
713
+ collectLeafBearingDirs(join(dir, e.name), wikiRoot, acc);
714
+ if (acc.size > before) hasIndexedDescendant = true;
715
+ }
716
+ }
717
+ if (hasLeaf || hasIndexedDescendant) acc.add(dir);
718
+ }