@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.
- package/CHANGELOG.md +134 -0
- package/LICENSE +21 -0
- package/README.md +484 -0
- package/SKILL.md +252 -0
- package/guide/basics/concepts.md +74 -0
- package/guide/basics/index.md +45 -0
- package/guide/basics/schema.md +140 -0
- package/guide/cli.md +256 -0
- package/guide/correctness/index.md +45 -0
- package/guide/correctness/invariants.md +89 -0
- package/guide/correctness/safety.md +96 -0
- package/guide/history/diff.md +110 -0
- package/guide/history/hidden-git.md +130 -0
- package/guide/history/index.md +52 -0
- package/guide/history/remote-sync.md +113 -0
- package/guide/index.md +134 -0
- package/guide/isolation/coexistence.md +134 -0
- package/guide/isolation/index.md +44 -0
- package/guide/isolation/scale.md +251 -0
- package/guide/layout/in-place-mode.md +97 -0
- package/guide/layout/index.md +53 -0
- package/guide/layout/layout-contract.md +131 -0
- package/guide/layout/layout-modes.md +115 -0
- package/guide/operations/index.md +76 -0
- package/guide/operations/ingest/build.md +75 -0
- package/guide/operations/ingest/extend.md +61 -0
- package/guide/operations/ingest/index.md +54 -0
- package/guide/operations/ingest/join.md +65 -0
- package/guide/operations/maintain/fix.md +66 -0
- package/guide/operations/maintain/index.md +47 -0
- package/guide/operations/maintain/rebuild.md +86 -0
- package/guide/operations/validate.md +48 -0
- package/guide/substrate/index.md +47 -0
- package/guide/substrate/operators.md +96 -0
- package/guide/substrate/tiered-ai.md +363 -0
- package/guide/ux/index.md +44 -0
- package/guide/ux/preflight.md +150 -0
- package/guide/ux/user-intent.md +135 -0
- package/package.json +55 -0
- package/scripts/cli.mjs +893 -0
- package/scripts/commands/remote.mjs +93 -0
- package/scripts/commands/review.mjs +253 -0
- package/scripts/commands/sync.mjs +84 -0
- package/scripts/lib/chunk.mjs +421 -0
- package/scripts/lib/cluster-detect.mjs +516 -0
- package/scripts/lib/decision-log.mjs +343 -0
- package/scripts/lib/draft.mjs +158 -0
- package/scripts/lib/embeddings.mjs +366 -0
- package/scripts/lib/frontmatter.mjs +497 -0
- package/scripts/lib/git-commands.mjs +155 -0
- package/scripts/lib/git.mjs +486 -0
- package/scripts/lib/gitignore.mjs +62 -0
- package/scripts/lib/history.mjs +331 -0
- package/scripts/lib/indices.mjs +510 -0
- package/scripts/lib/ingest.mjs +258 -0
- package/scripts/lib/intent.mjs +713 -0
- package/scripts/lib/interactive.mjs +99 -0
- package/scripts/lib/migrate.mjs +126 -0
- package/scripts/lib/nest-applier.mjs +260 -0
- package/scripts/lib/operators.mjs +1365 -0
- package/scripts/lib/orchestrator.mjs +718 -0
- package/scripts/lib/paths.mjs +197 -0
- package/scripts/lib/preflight.mjs +213 -0
- package/scripts/lib/provenance.mjs +672 -0
- package/scripts/lib/quality-metric.mjs +269 -0
- package/scripts/lib/query-fixture.mjs +71 -0
- package/scripts/lib/rollback.mjs +95 -0
- package/scripts/lib/shape-check.mjs +172 -0
- package/scripts/lib/similarity-cache.mjs +126 -0
- package/scripts/lib/similarity.mjs +230 -0
- package/scripts/lib/snapshot.mjs +54 -0
- package/scripts/lib/source-frontmatter.mjs +85 -0
- package/scripts/lib/tier2-protocol.mjs +470 -0
- package/scripts/lib/tiered.mjs +453 -0
- 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
|
+
}
|