@ctxr/skill-llm-wiki 1.0.2 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +128 -0
- package/README.md +11 -8
- package/SKILL.md +11 -11
- package/guide/cli.md +3 -2
- package/guide/correctness/safety.md +2 -2
- package/guide/layout/in-place-mode.md +1 -1
- package/guide/substrate/operators.md +1 -1
- package/guide/substrate/tiered-ai.md +6 -5
- package/guide/ux/user-intent.md +1 -1
- package/package.json +13 -4
- package/scripts/cli.mjs +92 -2
- package/scripts/lib/balance.mjs +579 -0
- package/scripts/lib/cluster-detect.mjs +482 -4
- package/scripts/lib/contract.mjs +53 -4
- package/scripts/lib/decision-log.mjs +121 -15
- package/scripts/lib/draft.mjs +127 -20
- package/scripts/lib/frontmatter.mjs +45 -9
- package/scripts/lib/heal.mjs +5 -0
- package/scripts/lib/intent.mjs +370 -4
- package/scripts/lib/join-constants.mjs +22 -0
- package/scripts/lib/join.mjs +917 -0
- package/scripts/lib/nest-applier.mjs +395 -32
- package/scripts/lib/operators.mjs +472 -38
- package/scripts/lib/orchestrator.mjs +419 -12
- package/scripts/lib/root-containment.mjs +351 -0
- package/scripts/lib/similarity-cache.mjs +115 -20
- package/scripts/lib/similarity.mjs +11 -0
- package/scripts/lib/soft-dag.mjs +726 -0
- package/scripts/lib/tier2-protocol.mjs +169 -37
- package/scripts/lib/tiered.mjs +42 -18
- package/scripts/lib/validate.mjs +22 -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 {
|
|
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, {
|
|
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) =>
|
|
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
|
-
|
|
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
|
|
395
|
-
//
|
|
396
|
-
//
|
|
397
|
-
//
|
|
398
|
-
//
|
|
399
|
-
//
|
|
400
|
-
|
|
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
|
-
|
|
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`);
|