@ctxr/skill-llm-wiki 1.0.1 → 1.1.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.
Files changed (50) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/README.md +2 -2
  3. package/SKILL.md +7 -0
  4. package/guide/cli.md +6 -4
  5. package/guide/consumers/index.md +106 -0
  6. package/guide/consumers/quickstart.md +96 -0
  7. package/guide/consumers/recipes/ci-gate.md +125 -0
  8. package/guide/consumers/recipes/dated-wiki.md +131 -0
  9. package/guide/consumers/recipes/format-gate.md +126 -0
  10. package/guide/consumers/recipes/post-write-heal.md +125 -0
  11. package/guide/consumers/recipes/skill-absent.md +111 -0
  12. package/guide/consumers/recipes/subject-wiki.md +110 -0
  13. package/guide/consumers/recipes/testing.md +149 -0
  14. package/guide/index.md +9 -0
  15. package/guide/substrate/operators.md +1 -1
  16. package/guide/substrate/tiered-ai.md +6 -5
  17. package/guide/ux/user-intent.md +6 -5
  18. package/package.json +9 -3
  19. package/scripts/cli.mjs +565 -15
  20. package/scripts/lib/balance.mjs +579 -0
  21. package/scripts/lib/cluster-detect.mjs +482 -4
  22. package/scripts/lib/contract.mjs +257 -0
  23. package/scripts/lib/decision-log.mjs +121 -15
  24. package/scripts/lib/heal.mjs +167 -0
  25. package/scripts/lib/init.mjs +210 -0
  26. package/scripts/lib/intent.mjs +370 -4
  27. package/scripts/lib/join-constants.mjs +22 -0
  28. package/scripts/lib/join.mjs +917 -0
  29. package/scripts/lib/json-envelope.mjs +190 -0
  30. package/scripts/lib/nest-applier.mjs +395 -32
  31. package/scripts/lib/operators.mjs +472 -38
  32. package/scripts/lib/orchestrator.mjs +419 -12
  33. package/scripts/lib/root-containment.mjs +351 -0
  34. package/scripts/lib/similarity-cache.mjs +115 -20
  35. package/scripts/lib/similarity.mjs +11 -0
  36. package/scripts/lib/soft-dag.mjs +726 -0
  37. package/scripts/lib/templates.mjs +78 -0
  38. package/scripts/lib/tiered.mjs +42 -18
  39. package/scripts/lib/validate.mjs +22 -0
  40. package/scripts/lib/where.mjs +71 -0
  41. package/scripts/testkit/assert-frontmatter.mjs +171 -0
  42. package/scripts/testkit/cli-run.mjs +95 -0
  43. package/scripts/testkit/make-wiki-fixture.mjs +301 -0
  44. package/scripts/testkit/stub-skill.mjs +107 -0
  45. package/templates/adrs.llmwiki.layout.yaml +33 -0
  46. package/templates/plans.llmwiki.layout.yaml +34 -0
  47. package/templates/regressions.llmwiki.layout.yaml +34 -0
  48. package/templates/reports.llmwiki.layout.yaml +33 -0
  49. package/templates/runbooks.llmwiki.layout.yaml +33 -0
  50. package/templates/sessions.llmwiki.layout.yaml +34 -0
@@ -25,6 +25,7 @@ import {
25
25
  mkdirSync,
26
26
  readFileSync,
27
27
  readdirSync,
28
+ rmSync,
28
29
  writeFileSync,
29
30
  } from "node:fs";
30
31
  import { basename, dirname, join, relative } from "node:path";
@@ -49,8 +50,17 @@ import {
49
50
  recordSource,
50
51
  startCorpus,
51
52
  } from "./provenance.mjs";
52
- import { rmSync } from "node:fs";
53
+ import { MAX_BALANCE_ITERATIONS, runBalance } from "./balance.mjs";
54
+ import {
55
+ FANOUT_TARGET_MAX,
56
+ FANOUT_TARGET_MIN,
57
+ MAX_DEPTH_MAX,
58
+ MAX_DEPTH_MIN,
59
+ } from "./intent.mjs";
60
+ import { applySoftParentEntries, runSoftDagParents } from "./soft-dag.mjs";
53
61
  import { runConvergence } from "./operators.mjs";
62
+ import { runJoin } from "./join.mjs";
63
+ import { runRootContainment } from "./root-containment.mjs";
54
64
  import { runReviewCycle } from "../commands/review.mjs";
55
65
  import {
56
66
  deriveBatchId,
@@ -60,6 +70,7 @@ import {
60
70
  } from "./tier2-protocol.mjs";
61
71
  import {
62
72
  clearTier2Responses,
73
+ resolveQualityMode,
63
74
  seedTier2Responses,
64
75
  takePendingRequests,
65
76
  } from "./tiered.mjs";
@@ -68,7 +79,22 @@ import {
68
79
  // { operation, layout_mode, source, target, is_new_wiki, flags }.
69
80
  // Returns { op_id, final_sha, phases: [...] } on success; throws on
70
81
  // validation failure (after rolling the working tree back to pre-op).
71
- export async function runOperation(plan, { opId, source, startedIso } = {}) {
82
+ export async function runOperation(plan, {
83
+ opId,
84
+ source,
85
+ startedIso,
86
+ // Optional per-phase progress hook. Invoked inside `record()`
87
+ // so the caller (typically CLI) can surface phase-by-phase
88
+ // progress to the user in real time — a 596-leaf deterministic
89
+ // build otherwise prints nothing for 2+ minutes. Shape:
90
+ // (phase: { name: string, summary: string, index: number }) => void
91
+ // The hook runs synchronously; any errors it throws are caught
92
+ // and swallowed inside `record()` so a misbehaving progress
93
+ // reporter can never halt the operation. When absent,
94
+ // `runOperation` is silent on phase progression (preserving the
95
+ // pre-X.9 behaviour tests may have relied on).
96
+ onProgress = null,
97
+ } = {}) {
72
98
  if (!plan || !plan.target) {
73
99
  throw new Error("runOperation requires a resolved plan with a target");
74
100
  }
@@ -80,7 +106,141 @@ export async function runOperation(plan, { opId, source, startedIso } = {}) {
80
106
  mkdirSync(workDir, { recursive: true });
81
107
 
82
108
  const phases = [];
83
- const record = (name, summary) => phases.push({ name, summary });
109
+ const record = (name, summary) => {
110
+ const phase = { name, summary };
111
+ phases.push(phase);
112
+ if (onProgress) {
113
+ // Two failure modes to cover: a synchronous throw, and a
114
+ // returned Promise that rejects (an `async` hook never
115
+ // throws synchronously — the rejection escapes as an
116
+ // unhandledRejection and would terminate the process under
117
+ // Node's default policy, violating the "progress-hook
118
+ // failures must never halt the op" contract). The try/catch
119
+ // handles the sync throw; `Promise.resolve(...).catch()`
120
+ // handles the async rejection. Both swallow silently — a
121
+ // user with a broken progress reporter should still get a
122
+ // successful op completion.
123
+ try {
124
+ const ret = onProgress({ name, summary, index: phases.length });
125
+ if (ret && typeof ret.then === "function") {
126
+ Promise.resolve(ret).catch(() => {
127
+ /* async progress-hook rejection silently swallowed */
128
+ });
129
+ }
130
+ } catch {
131
+ /* sync progress-hook throw silently swallowed */
132
+ }
133
+ }
134
+ };
135
+
136
+ // `join` is the one top-level operation whose pipeline shape does
137
+ // not match the build/rebuild/fix family. Instead of threading
138
+ // an "ingest from N wikis" path through the build-shaped phase
139
+ // cascade, we delegate to `scripts/lib/join.mjs::runJoin` which
140
+ // implements all 11 phases from `guide/operations/ingest/join.md`
141
+ // end-to-end on an already-prepared empty target. The caller
142
+ // (intent → CLI) is responsible for ensuring `plan.target` is a
143
+ // fresh empty directory before runOperation fires; runJoin writes
144
+ // the unified tree there and then runs the same
145
+ // convergence / indices / validation tail the build path would.
146
+ // Per-phase commits are routed through runJoin's onPhaseCommit
147
+ // callback so the private git log shows join progression at the
148
+ // same granularity a build does.
149
+ if (plan.operation === "join") {
150
+ if (!Array.isArray(plan.sources) || plan.sources.length < 2) {
151
+ throw new Error("join requires at least 2 resolved source wiki paths");
152
+ }
153
+ const snap = preOpSnapshot(wikiRoot, opId);
154
+ record(
155
+ "snapshot",
156
+ `tag ${snap.tag} sha=${(snap.sha ?? "n/a").slice(0, 12)}`,
157
+ );
158
+ try {
159
+ const result = await runJoin(plan.sources, wikiRoot, {
160
+ opId,
161
+ qualityMode: resolveQualityMode(plan.flags || {}),
162
+ idCollisionPolicy: plan.flags?.id_collision,
163
+ onPhaseCommit: async ({ phase, summary }) => {
164
+ gitRunChecked(wikiRoot, ["add", "-A"]);
165
+ if (!gitWorkingTreeClean(wikiRoot)) {
166
+ gitCommit(wikiRoot, `phase ${phase}: ${summary}`);
167
+ }
168
+ },
169
+ // Stream join's phases through the orchestrator's own
170
+ // `record()` the moment each one fires — that routes the
171
+ // phase into the outer `phases[]` AND invokes the CLI's
172
+ // `onProgress` breadcrumb callback in real time. Without
173
+ // this hook we'd only see join's phases relayed AFTER
174
+ // `runJoin` returns (the pre-X.9-followup post-call
175
+ // `for (const p of result.phases)` loop), which made the
176
+ // whole join look like one silent block in the breadcrumb
177
+ // stream. runJoin's `phaseLog` still accumulates for the
178
+ // returned-result shape, but the outer `phases[]` now
179
+ // gets populated live.
180
+ onPhase: ({ phase, summary }) => {
181
+ record(phase, summary);
182
+ },
183
+ });
184
+ // Tier 2 suspend: convergence parked decisions that can't be
185
+ // resolved without a sub-agent. Drain the pending queue,
186
+ // write the batch, and throw NeedsTier2Error — the caller
187
+ // catches it via the same path the build/rebuild uses, exits
188
+ // 7, and the wiki-runner spawns sub-agents, writes responses,
189
+ // and re-invokes the CLI. Must happen BEFORE the op tag so a
190
+ // resume picks up at the same pre-op/... anchor.
191
+ if (result.needs_tier2) {
192
+ const requests = takePendingRequests(wikiRoot);
193
+ if (requests.length > 0) {
194
+ const batchId = deriveBatchId(
195
+ opId,
196
+ "join-convergence",
197
+ result.convergence.iterations,
198
+ );
199
+ const path = writePending(wikiRoot, batchId, requests);
200
+ throw new NeedsTier2Error(
201
+ `join: operator-convergence parked ${requests.length} Tier 2 request(s) ` +
202
+ `(batch ${batchId}); wiki-runner must resolve and re-invoke`,
203
+ opId,
204
+ path,
205
+ );
206
+ }
207
+ }
208
+ // Finalise — tag the op and emit the op-log entry.
209
+ const finalTag = `op/${opId}`;
210
+ gitTag(wikiRoot, finalTag);
211
+ const finalSha = gitHeadSha(wikiRoot);
212
+ appendOpLog(wikiRoot, {
213
+ op_id: opId,
214
+ operation: "join",
215
+ layout_mode: plan.layout_mode,
216
+ started: startedIso || new Date().toISOString(),
217
+ finished: new Date().toISOString(),
218
+ base_commit: snap.sha || "",
219
+ final_commit: finalSha || "",
220
+ summary:
221
+ `join target=${plan.target} sources=${plan.sources.length} ` +
222
+ `mode=${plan.layout_mode} phases=${phases.length}`,
223
+ });
224
+ record("commit-finalize", `tagged ${finalTag}`);
225
+ return { op_id: opId, final_sha: finalSha, phases };
226
+ } catch (err) {
227
+ // NeedsTier2Error is NOT a failure — it's a suspend signal.
228
+ // Don't roll back the working tree; the wiki-runner will
229
+ // resume on re-invoke. Matches the build-path semantics.
230
+ if (err instanceof NeedsTier2Error) {
231
+ throw err;
232
+ }
233
+ // Roll working tree back to the pre-op snapshot, matching the
234
+ // build path's failure semantics.
235
+ try {
236
+ gitResetHard(wikiRoot, snap.tag);
237
+ gitClean(wikiRoot);
238
+ } catch {
239
+ /* best-effort rollback */
240
+ }
241
+ throw err;
242
+ }
243
+ }
84
244
 
85
245
  // Map of authored index hints keyed by POSIX-relative directory
86
246
  // path from the wiki root. Populated in the ingest phase for Build
@@ -354,7 +514,14 @@ export async function runOperation(plan, { opId, source, startedIso } = {}) {
354
514
  }
355
515
  const convergence = await runConvergence(wikiRoot, {
356
516
  opId,
357
- qualityMode: plan.flags?.quality_mode || "tiered-fast",
517
+ // Resolve through tiered.mjs::resolveQualityMode so the
518
+ // `LLM_WIKI_QUALITY_MODE` env var is actually consulted (the
519
+ // prior `plan.flags?.quality_mode || "tiered-fast"` shortcut
520
+ // silently dropped the env-var path documented in
521
+ // methodology.md). Intent-layer INT-13 already catches an
522
+ // invalid flag value before we reach here; an invalid env
523
+ // var surfaces from `resolveQualityMode` as a plain throw.
524
+ qualityMode: resolveQualityMode(plan.flags || {}),
358
525
  interactive: false, // orchestrator runs non-interactive
359
526
  commitBetweenIterations: async ({ iteration, operator, summary }) => {
360
527
  gitRunChecked(wikiRoot, ["add", "-A"]);
@@ -390,14 +557,235 @@ export async function runOperation(plan, { opId, source, startedIso } = {}) {
390
557
  `${convergence.suggestions.length} suggestion(s) recorded`,
391
558
  );
392
559
 
560
+ // Phase 4.3 — balance enforcement. Runs only when at least one
561
+ // of `--fanout-target` / `--max-depth` is set on the plan (both
562
+ // validated at intent time). No-op otherwise. Iterates until
563
+ // fixed point applying two transform classes:
564
+ // - Sub-cluster an overfull directory (movable leaf count >
565
+ // target × 1.5 — subdirs are structurally cemented and can't
566
+ // be carved by the math cluster detector, so a dir overfull
567
+ // *only* due to subdirs is un-actionable and is skipped) via
568
+ // the math cluster detector + deterministic naming, reusing
569
+ // the same helpers Phase X.3 built for the deterministic
570
+ // quality mode so two runs on the same tree produce identical
571
+ // sub-clusters.
572
+ // - Flatten an overdeep single-child passthrough by promoting
573
+ // its only subdir up one level. Descendants' `parents[]`
574
+ // paths are left unchanged — they are relative to the direct
575
+ // parent's `index.md`, so promoting an entire subtree up one
576
+ // level preserves every relative path by construction. Only
577
+ // pure passthroughs qualify; multi-child subcategories would
578
+ // lose structure.
579
+ // The phase has its own commit cadence (one commit per apply);
580
+ // the same git-add + git-commit callback convergence uses wires
581
+ // into the private-git machinery. A no-op run (no overfull /
582
+ // overdeep candidates) leaves the working tree byte-identical.
583
+ // Balance-enforcement is build/rebuild-only per contract.mjs and
584
+ // `--help`. Intent-resolution already rejects the flags on other
585
+ // subcommands (INT-14a / INT-15a) and rejects non-integer values
586
+ // (INT-14 / INT-15), but gate here as well in defense-in-depth —
587
+ // any future code path that constructs a plan with balance flags
588
+ // outside of build/rebuild (or with garbage values) would otherwise
589
+ // trigger structural mutations outside the documented surface.
590
+ // `Number.isFinite` rejects NaN from a non-numeric string: a NaN
591
+ // maxDepth would make `detectDepthOverage` treat every depth as
592
+ // over (NaN comparisons are false), potentially flattening the
593
+ // wiki root and moving directories outside the wiki.
594
+ // Single source of truth for the set of operations that can
595
+ // reach the post-convergence hooks (balance enforcement + soft-
596
+ // DAG synthesis). Named for the actual invariant (operation
597
+ // membership), not a specific feature, so adding a third
598
+ // post-convergence phase in the future doesn't demand another
599
+ // one-purpose constant.
600
+ const BUILD_REBUILD_OPS = new Set(["build", "rebuild"]);
601
+ // Strict parse: must be a canonical base-10 integer string (no
602
+ // leading `+`/`-`, no trailing garbage, no decimal parts, no
603
+ // scientific notation), and must fall inside the intent-validated
604
+ // range for its flag. `Number.parseInt` would accept `"3.5"` as
605
+ // `3` and `"5xyz"` as `5`, which an adversarial or faulty caller
606
+ // could use to bypass the range bounds. `/^\d+$/` + explicit
607
+ // bounds keep orchestrator behaviour aligned with what intent
608
+ // resolution already enforces (INT-14 / INT-15).
609
+ const parseBalanceInt = (raw, min, max) => {
610
+ if (raw == null) return null;
611
+ const s = String(raw);
612
+ if (!/^\d+$/.test(s)) return null;
613
+ const n = Number.parseInt(s, 10);
614
+ if (!Number.isFinite(n)) return null;
615
+ if (n < min || n > max) return null;
616
+ return n;
617
+ };
618
+ const opSupportsBalance = BUILD_REBUILD_OPS.has(plan.operation);
619
+ const fanoutTarget = opSupportsBalance
620
+ ? parseBalanceInt(plan.flags?.fanout_target, FANOUT_TARGET_MIN, FANOUT_TARGET_MAX)
621
+ : null;
622
+ const maxDepth = opSupportsBalance
623
+ ? parseBalanceInt(plan.flags?.max_depth, MAX_DEPTH_MIN, MAX_DEPTH_MAX)
624
+ : null;
625
+ let balance = null;
626
+ if (fanoutTarget != null || maxDepth != null) {
627
+ balance = await runBalance(wikiRoot, {
628
+ opId,
629
+ // Same resolveQualityMode indirection as the convergence
630
+ // phase above — keeps the env-var path working on builds
631
+ // that use the balance-enforcement flags.
632
+ qualityMode: resolveQualityMode(plan.flags || {}),
633
+ fanoutTarget,
634
+ maxDepth,
635
+ commitBetweenIterations: async ({ iteration, operator, summary }) => {
636
+ gitRunChecked(wikiRoot, ["add", "-A"]);
637
+ if (!gitWorkingTreeClean(wikiRoot)) {
638
+ gitCommit(
639
+ wikiRoot,
640
+ `phase balance-enforcement: iteration ${iteration} ${operator} — ${summary}`,
641
+ );
642
+ }
643
+ },
644
+ });
645
+ record(
646
+ "balance-enforcement",
647
+ `${balance.applied.length} operation(s) applied across ` +
648
+ `${balance.iterations} iteration(s); converged=${balance.converged}`,
649
+ );
650
+ if (!balance.converged) {
651
+ // Enforcement contract: a user who asked for a balanced tree
652
+ // expects the post-convergence shape to honour `--fanout-target`
653
+ // / `--max-depth`. Hitting the 20-iteration cap means the
654
+ // rebalance didn't reach a fixed point — any downstream
655
+ // assumption "the tree is now balanced" would silently be
656
+ // wrong. Fail loud here so the orchestrator's pre-op snapshot
657
+ // restores and the user sees the problem, instead of shipping
658
+ // a half-balanced wiki with no error.
659
+ throw new Error(
660
+ `balance enforcement did not converge after ${balance.iterations} ` +
661
+ `iteration(s) (cap=${MAX_BALANCE_ITERATIONS}); applied ` +
662
+ `${balance.applied.length} op(s). Inspect the private git ` +
663
+ `history via the skill's hidden-git passthrough (e.g. ` +
664
+ `\`node scripts/cli.mjs log ${wikiRoot}\`) — the private repo ` +
665
+ `lives under \`<wiki>/.llmwiki/git\` and requires the skill's ` +
666
+ `isolation env, which the passthrough wraps. Reduce ` +
667
+ `--fanout-target / --max-depth strictness, or file a ` +
668
+ `ping-pong repro.`,
669
+ );
670
+ }
671
+ }
672
+
673
+ // Phase 4.4 — optional soft-DAG parent synthesis. Runs only when
674
+ // the user passed `--soft-dag-parents` on build / rebuild (intent
675
+ // rejects the flag everywhere else via INT-16a). Writes soft
676
+ // parents into each routable leaf's `parents[]` frontmatter based
677
+ // on TF-IDF cosine similarity against candidate category
678
+ // directories. The downstream `applySoftParentEntries` pass, which
679
+ // runs in Phase 5 AFTER `rebuildAllIndices`, propagates each
680
+ // leaf's soft claims into the corresponding index `entries[]`
681
+ // arrays so a Claude navigator arriving at any claimed parent
682
+ // sees the leaf in that parent's entries.
683
+ //
684
+ // Fan-in control is via `SOFT_PARENT_MAX_PER_LEAF` (default 3) and
685
+ // `SOFT_PARENT_AFFINITY_THRESHOLD` (default 0.35). Both exported
686
+ // from `soft-dag.mjs` — tests that want boundary behaviour can
687
+ // pass overrides, but the CLI flag is a pure boolean.
688
+ const softDagRequested =
689
+ plan.flags?.soft_dag_parents === true && BUILD_REBUILD_OPS.has(plan.operation);
690
+ let softDag = null;
691
+ // Tracks whether the soft-DAG phase actually created a commit
692
+ // (not just whether it found soft parents). A run with zero
693
+ // softParentsAdded can still dirty the tree — e.g., a rerun
694
+ // that removes previously-synthesised soft parents now below
695
+ // threshold, or a canonical-order frontmatter rewrite that
696
+ // leaves no net soft-parent change but still alters bytes. The
697
+ // `--review` gate below consumes this flag to decide whether
698
+ // Phase 4.4 contributed to the diff.
699
+ let softDagDidCommit = false;
700
+ if (softDagRequested) {
701
+ softDag = await runSoftDagParents(wikiRoot);
702
+ record(
703
+ "soft-dag-parents",
704
+ `${softDag.leavesProcessed} leaf/leaves scanned; ` +
705
+ `${softDag.softParentsAdded} soft-parent pointer(s) selected`,
706
+ );
707
+ // Commit the frontmatter rewrites as a single iteration so the
708
+ // private-git history preserves the DAG synthesis as a
709
+ // distinguishable artefact. Review cycle picks up this commit
710
+ // via the anyMutation gate below.
711
+ gitRunChecked(wikiRoot, ["add", "-A"]);
712
+ if (!gitWorkingTreeClean(wikiRoot)) {
713
+ // Commit subject stays generic over the leaf count alone.
714
+ // A per-run delta (added / removed / reordered) against the
715
+ // pre-run parents[] would be the ideal audit trail, but the
716
+ // orchestrator doesn't snapshot pre-run frontmatter state
717
+ // and wiring that plumbing adds cost for a diagnostic
718
+ // message; the per-phase record() call above already surfaces
719
+ // `softParentsAdded` (the count of selected pointers this
720
+ // run, which equals the "pointers present after synthesis"
721
+ // since runSoftDagParents rewrites parents[] top-to-bottom
722
+ // from scratch each pass). The private-git diff on the
723
+ // commit itself is the byte-exact source of truth.
724
+ gitCommit(
725
+ wikiRoot,
726
+ `phase soft-dag-parents: parents[] synthesis across ` +
727
+ `${softDag.leavesProcessed} leaf/leaves`,
728
+ );
729
+ softDagDidCommit = true;
730
+ }
731
+ }
732
+
733
+ // Phase 4.4.5 — root-leaf containment (X.11). Invariant: the wiki
734
+ // root holds only `index.md` plus subdirectories — never a direct
735
+ // `.md` leaf. Any outlier leaf that survived convergence + balance
736
+ // (because its affinity to every other leaf fell below clustering
737
+ // thresholds) is moved into its own semantically-named
738
+ // subcategory derived from its own TF-IDF distinguishing tokens.
739
+ // Runs BEFORE Phase 4.5 review so the containment commit is part
740
+ // of the reviewable diff — users can drop/abort it like any
741
+ // other tree-mutating phase. Runs BEFORE Phase 5 so
742
+ // `rebuildAllIndices` sees the final tree shape (and the new
743
+ // `<slug>/index.md` stubs get their `entries[]` populated as part
744
+ // of the regular pass, not a separate write).
745
+ let containmentDidCommit = false;
746
+ const containment = await runRootContainment(wikiRoot);
747
+ if (containment.moved > 0) {
748
+ record(
749
+ "root-containment",
750
+ `moved ${containment.moved} outlier leaf/leaves into per-slug subcategories`,
751
+ );
752
+ gitRunChecked(wikiRoot, ["add", "-A"]);
753
+ if (!gitWorkingTreeClean(wikiRoot)) {
754
+ gitCommit(
755
+ wikiRoot,
756
+ `phase root-containment: moved ${containment.moved} outlier(s) into subcategories`,
757
+ );
758
+ containmentDidCommit = true;
759
+ }
760
+ }
761
+
393
762
  // 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) {
763
+ // user passed --review AND at least one tree-mutating phase
764
+ // actually produced commits. That's ANY of convergence, the
765
+ // balance-enforcement phase, the soft-DAG parent phase above, or
766
+ // the root-containment phase each commits separately via its
767
+ // own git callback. Pre-round-6 the gate only checked
768
+ // convergence, which meant a no-op-convergence + active-balance
769
+ // op (e.g. a hand-authored corpus that's already operator-clean
770
+ // but violates the fanout/depth targets) would silently skip
771
+ // review. The review flow prints a diff + commit list and lets
772
+ // the user approve, abort, or drop specific iterations before
773
+ // validation runs. Abort throws so the orchestrator's catch
774
+ // block handles the rollback uniformly with any other failure
775
+ // path.
776
+ const balanceApplied = balance?.applied?.length ?? 0;
777
+ // Use the commit-did-fire flag for soft-DAG and root-containment,
778
+ // not their planned-work counters: a rerun that removes
779
+ // previously-synthesised soft parents below threshold, or a
780
+ // canonical-order frontmatter rewrite that preserves logical
781
+ // content while altering bytes, produces the planned-work
782
+ // counter = 0 but still dirties the tree and commits.
783
+ const anyMutation =
784
+ convergence.applied.length > 0 ||
785
+ balanceApplied > 0 ||
786
+ softDagDidCommit ||
787
+ containmentDidCommit;
788
+ if (plan.flags?.review && anyMutation) {
401
789
  const reviewResult = await runReviewCycle(wikiRoot, opId, {
402
790
  forceInteractive: plan.flags?.force_interactive === true,
403
791
  });
@@ -429,11 +817,30 @@ export async function runOperation(plan, { opId, source, startedIso } = {}) {
429
817
  // the rebuild pass can fill them in with frontmatter.
430
818
  bootstrapIndexStubs(wikiRoot);
431
819
  const rebuilt = rebuildAllIndices(wikiRoot, { indexInputs });
820
+ // Phase 5.1 — soft-DAG propagation. When Phase 4.4 synthesised
821
+ // soft-parent pointers, `rebuildAllIndices` has just placed every
822
+ // leaf in its DIRECT parent's `entries[]` (the primary parent) —
823
+ // but the DAG view requires leaves to also show up under every
824
+ // parent they claim. `applySoftParentEntries` walks each leaf's
825
+ // `parents[]` and appends its record into each claimed non-primary
826
+ // parent's index. Idempotent: records are deduped by id, so
827
+ // re-running the pass produces byte-identical output.
828
+ let softDagAppends = null;
829
+ if (softDagRequested) {
830
+ softDagAppends = applySoftParentEntries(wikiRoot);
831
+ record(
832
+ "soft-dag-propagate",
833
+ `${softDagAppends.indicesTouched} index(es) updated; ` +
834
+ `${softDagAppends.softEntriesAdded} soft-entry record(s) added`,
835
+ );
836
+ }
432
837
  gitRunChecked(wikiRoot, ["add", "-A"]);
433
838
  if (!gitWorkingTreeClean(wikiRoot)) {
434
839
  gitCommit(
435
840
  wikiRoot,
436
- `phase index-generation: rebuilt ${rebuilt.length} index.md files`,
841
+ softDagRequested
842
+ ? `phase index-generation: rebuilt ${rebuilt.length} index.md files + soft-DAG propagation`
843
+ : `phase index-generation: rebuilt ${rebuilt.length} index.md files`,
437
844
  );
438
845
  }
439
846
  record("index-generation", `rebuilt ${rebuilt.length} indices`);