@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,470 @@
|
|
|
1
|
+
// tier2-protocol.mjs — the contract between the skill's CLI and the
|
|
2
|
+
// wiki-runner sub-agent that answers Tier 2 requests.
|
|
3
|
+
//
|
|
4
|
+
// Design A: exit-7 handshake. The CLI runs under Node and cannot
|
|
5
|
+
// call Claude Code's Agent tool directly. So when a convergence
|
|
6
|
+
// phase accumulates Tier 2 requests we:
|
|
7
|
+
//
|
|
8
|
+
// 1. Write a pending-batch file listing all open requests.
|
|
9
|
+
// 2. Exit with code 7 (NEEDS_TIER2).
|
|
10
|
+
// 3. The wiki-runner spawns one sub-agent per request, collects
|
|
11
|
+
// structured responses, writes a sibling response file.
|
|
12
|
+
// 4. The wiki-runner re-invokes the CLI with the same op-id.
|
|
13
|
+
// 5. On resume the CLI reads the response files, feeds the
|
|
14
|
+
// answers into the tiered decision cache, and continues.
|
|
15
|
+
//
|
|
16
|
+
// This module owns:
|
|
17
|
+
//
|
|
18
|
+
// - Batch path helpers (`pending` + `responses`)
|
|
19
|
+
// - Request builders + response validators for each `kind`
|
|
20
|
+
// - Batch read / write / merge helpers
|
|
21
|
+
// - Pollution-key defence for JSON parse
|
|
22
|
+
//
|
|
23
|
+
// Request shape (JSON):
|
|
24
|
+
// {
|
|
25
|
+
// request_id: string, unique per batch
|
|
26
|
+
// kind: "merge_decision" | "nest_decision" | "cluster_name"
|
|
27
|
+
// | "propose_structure"
|
|
28
|
+
// | "draft_frontmatter" | "rebuild_plan_review"
|
|
29
|
+
// | "human_fix_item"
|
|
30
|
+
// prompt: natural-language question the sub-agent answers
|
|
31
|
+
// inputs: minimal per-kind inputs (frontmatter blobs, etc.)
|
|
32
|
+
// response_schema: JSON shape the sub-agent must return
|
|
33
|
+
// model_hint: string, picked from guide/tiered-ai.md matrix
|
|
34
|
+
// effort_hint: string, picked from guide/tiered-ai.md matrix
|
|
35
|
+
// }
|
|
36
|
+
//
|
|
37
|
+
// Response shape (JSON):
|
|
38
|
+
// {
|
|
39
|
+
// request_id: string (matches request.request_id)
|
|
40
|
+
// response: matches request.response_schema
|
|
41
|
+
// }
|
|
42
|
+
//
|
|
43
|
+
// A batch lives at `<wiki>/.work/tier2/pending-<batch-id>.json`
|
|
44
|
+
// and its responses at `<wiki>/.work/tier2/responses-<batch-id>.json`.
|
|
45
|
+
// Batches are uniquely tagged by batch-id (op-id + phase + iteration).
|
|
46
|
+
|
|
47
|
+
import { createHash } from "node:crypto";
|
|
48
|
+
import {
|
|
49
|
+
existsSync,
|
|
50
|
+
mkdirSync,
|
|
51
|
+
readFileSync,
|
|
52
|
+
readdirSync,
|
|
53
|
+
renameSync,
|
|
54
|
+
writeFileSync,
|
|
55
|
+
} from "node:fs";
|
|
56
|
+
import { dirname, join } from "node:path";
|
|
57
|
+
|
|
58
|
+
export const TIER2_EXIT_CODE = 7;
|
|
59
|
+
|
|
60
|
+
// The default model + effort matrix from guide/tiered-ai.md. Each
|
|
61
|
+
// request kind maps to a model hint and an effort hint the wiki-
|
|
62
|
+
// runner uses when spawning the sub-agent. These are hints, not
|
|
63
|
+
// mandates — the wiki-runner may override per-session.
|
|
64
|
+
export const TIER2_DEFAULTS = Object.freeze({
|
|
65
|
+
merge_decision: {
|
|
66
|
+
model_hint: "sonnet",
|
|
67
|
+
effort_hint: "low",
|
|
68
|
+
response_schema: {
|
|
69
|
+
decision: "same|different|undecidable",
|
|
70
|
+
reason: "string",
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
nest_decision: {
|
|
74
|
+
model_hint: "sonnet",
|
|
75
|
+
effort_hint: "medium",
|
|
76
|
+
response_schema: {
|
|
77
|
+
decision: "nest|keep_flat|undecidable",
|
|
78
|
+
reason: "string",
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
cluster_name: {
|
|
82
|
+
model_hint: "sonnet",
|
|
83
|
+
effort_hint: "low",
|
|
84
|
+
response_schema: {
|
|
85
|
+
slug: "kebab-case-slug",
|
|
86
|
+
purpose: "string",
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
// propose_structure — whole-directory structural optimiser. Given
|
|
90
|
+
// N leaves under one parent, ask Tier 2 to propose the optimal
|
|
91
|
+
// nested partition: subcategories (with slug + purpose + member
|
|
92
|
+
// ids) plus the leaves that should remain as siblings. This is
|
|
93
|
+
// the "Tier 2 gets first dibs" escalation and fires BEFORE the
|
|
94
|
+
// math-based cluster detector on every non-already-nested
|
|
95
|
+
// directory. Opus + medium effort because the task is a
|
|
96
|
+
// structural judgment call over many inputs that benefits from
|
|
97
|
+
// the strongest reasoning model.
|
|
98
|
+
propose_structure: {
|
|
99
|
+
model_hint: "opus",
|
|
100
|
+
effort_hint: "medium",
|
|
101
|
+
response_schema: {
|
|
102
|
+
subcategories: "array of { slug, purpose, members[] }",
|
|
103
|
+
siblings: "array of leaf ids",
|
|
104
|
+
notes: "string",
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
draft_frontmatter: {
|
|
108
|
+
model_hint: "sonnet",
|
|
109
|
+
effort_hint: "medium",
|
|
110
|
+
response_schema: {
|
|
111
|
+
focus: "string",
|
|
112
|
+
covers: "array of strings",
|
|
113
|
+
tags: "array of strings",
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
rebuild_plan_review: {
|
|
117
|
+
model_hint: "opus",
|
|
118
|
+
effort_hint: "high",
|
|
119
|
+
response_schema: {
|
|
120
|
+
approve: "boolean",
|
|
121
|
+
drop: "array of iteration ids",
|
|
122
|
+
notes: "string",
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
human_fix_item: {
|
|
126
|
+
model_hint: "sonnet",
|
|
127
|
+
effort_hint: "low",
|
|
128
|
+
response_schema: {
|
|
129
|
+
action: "string",
|
|
130
|
+
rationale: "string",
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
export const TIER2_KINDS = Object.freeze(Object.keys(TIER2_DEFAULTS));
|
|
136
|
+
|
|
137
|
+
// Pollution keys that would leak onto Object.prototype if we
|
|
138
|
+
// blindly merged parsed JSON. We refuse requests/responses that
|
|
139
|
+
// contain them at the top level.
|
|
140
|
+
const POLLUTION_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
141
|
+
|
|
142
|
+
function hasPollution(obj) {
|
|
143
|
+
if (!obj || typeof obj !== "object") return false;
|
|
144
|
+
for (const k of Object.keys(obj)) {
|
|
145
|
+
if (POLLUTION_KEYS.has(k)) return true;
|
|
146
|
+
}
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── Paths ────────────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
export function tier2Dir(wikiRoot) {
|
|
153
|
+
return join(wikiRoot, ".work", "tier2");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function pendingPath(wikiRoot, batchId) {
|
|
157
|
+
return join(tier2Dir(wikiRoot), `pending-${batchId}.json`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function responsesPath(wikiRoot, batchId) {
|
|
161
|
+
return join(tier2Dir(wikiRoot), `responses-${batchId}.json`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// List all (batchId, pending path, response path) triples under a
|
|
165
|
+
// wiki's tier2 dir. Used during resume to discover what's waiting
|
|
166
|
+
// and what's been answered.
|
|
167
|
+
export function listBatches(wikiRoot) {
|
|
168
|
+
const dir = tier2Dir(wikiRoot);
|
|
169
|
+
if (!existsSync(dir)) return [];
|
|
170
|
+
const out = [];
|
|
171
|
+
for (const name of readdirSync(dir)) {
|
|
172
|
+
const m = /^pending-(.+)\.json$/.exec(name);
|
|
173
|
+
if (!m) continue;
|
|
174
|
+
const batchId = m[1];
|
|
175
|
+
out.push({
|
|
176
|
+
batchId,
|
|
177
|
+
pending: join(dir, name),
|
|
178
|
+
responses: responsesPath(wikiRoot, batchId),
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
return out.sort((a, b) => a.batchId.localeCompare(b.batchId));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ── Request builders ────────────────────────────────────────────────
|
|
185
|
+
//
|
|
186
|
+
// Callers construct a Tier 2 request via `makeRequest(kind, {...})`.
|
|
187
|
+
// The builder fills in defaults from TIER2_DEFAULTS and validates
|
|
188
|
+
// the shape. `inputs` is kind-specific and kept small (a few
|
|
189
|
+
// frontmatter blobs at most) so batches stay under a few KB each.
|
|
190
|
+
|
|
191
|
+
export function makeRequest(kind, { prompt, inputs, model_hint, effort_hint, request_id } = {}) {
|
|
192
|
+
if (!TIER2_KINDS.includes(kind)) {
|
|
193
|
+
throw new Error(`tier2-protocol: unknown kind "${kind}" (valid: ${TIER2_KINDS.join(", ")})`);
|
|
194
|
+
}
|
|
195
|
+
if (typeof prompt !== "string" || prompt.length === 0) {
|
|
196
|
+
throw new Error("tier2-protocol: prompt must be a non-empty string");
|
|
197
|
+
}
|
|
198
|
+
if (inputs === undefined || inputs === null) {
|
|
199
|
+
throw new Error("tier2-protocol: inputs is required");
|
|
200
|
+
}
|
|
201
|
+
if (hasPollution(inputs)) {
|
|
202
|
+
throw new Error("tier2-protocol: inputs contains a forbidden key");
|
|
203
|
+
}
|
|
204
|
+
const defaults = TIER2_DEFAULTS[kind];
|
|
205
|
+
const rid = request_id ?? deriveRequestId(kind, inputs);
|
|
206
|
+
return {
|
|
207
|
+
request_id: rid,
|
|
208
|
+
kind,
|
|
209
|
+
prompt,
|
|
210
|
+
inputs,
|
|
211
|
+
response_schema: defaults.response_schema,
|
|
212
|
+
model_hint: model_hint ?? defaults.model_hint,
|
|
213
|
+
effort_hint: effort_hint ?? defaults.effort_hint,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Deterministic request id: sha256(kind + canonical-JSON(inputs))
|
|
218
|
+
// truncated to 16 hex chars. Stable across runs, so the same
|
|
219
|
+
// cluster re-asked produces the same request id.
|
|
220
|
+
//
|
|
221
|
+
// NOTE: JSON.stringify's replacer-array argument is a property-
|
|
222
|
+
// name FILTER at every nesting level, not a sorter. Using it
|
|
223
|
+
// accidentally erased every nested property and collapsed
|
|
224
|
+
// distinct inputs to the same hash. Use a manual canonical
|
|
225
|
+
// serializer instead.
|
|
226
|
+
function deriveRequestId(kind, inputs) {
|
|
227
|
+
const text = kind + "\0" + canonicalJson(inputs);
|
|
228
|
+
return createHash("sha256").update(text).digest("hex").slice(0, 16);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Canonical JSON: sort object keys at every level, serialize
|
|
232
|
+
// arrays and primitives normally. Produces a byte-identical
|
|
233
|
+
// string for any two semantically-equal inputs.
|
|
234
|
+
function canonicalJson(value) {
|
|
235
|
+
if (value === null || typeof value !== "object") {
|
|
236
|
+
return JSON.stringify(value);
|
|
237
|
+
}
|
|
238
|
+
if (Array.isArray(value)) {
|
|
239
|
+
return "[" + value.map((v) => canonicalJson(v)).join(",") + "]";
|
|
240
|
+
}
|
|
241
|
+
const keys = Object.keys(value).sort();
|
|
242
|
+
const parts = [];
|
|
243
|
+
for (const k of keys) {
|
|
244
|
+
parts.push(JSON.stringify(k) + ":" + canonicalJson(value[k]));
|
|
245
|
+
}
|
|
246
|
+
return "{" + parts.join(",") + "}";
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ── Request validation ─────────────────────────────────────────────
|
|
250
|
+
|
|
251
|
+
export function validateRequest(req) {
|
|
252
|
+
if (!req || typeof req !== "object") {
|
|
253
|
+
throw new Error("tier2-protocol: request must be an object");
|
|
254
|
+
}
|
|
255
|
+
if (hasPollution(req)) {
|
|
256
|
+
throw new Error("tier2-protocol: request contains a forbidden key");
|
|
257
|
+
}
|
|
258
|
+
if (typeof req.request_id !== "string" || req.request_id.length === 0) {
|
|
259
|
+
throw new Error("tier2-protocol: request.request_id must be a non-empty string");
|
|
260
|
+
}
|
|
261
|
+
if (!TIER2_KINDS.includes(req.kind)) {
|
|
262
|
+
throw new Error(`tier2-protocol: request.kind "${req.kind}" is not recognised`);
|
|
263
|
+
}
|
|
264
|
+
if (typeof req.prompt !== "string" || req.prompt.length === 0) {
|
|
265
|
+
throw new Error("tier2-protocol: request.prompt must be a non-empty string");
|
|
266
|
+
}
|
|
267
|
+
if (req.inputs === undefined || req.inputs === null) {
|
|
268
|
+
throw new Error("tier2-protocol: request.inputs is required");
|
|
269
|
+
}
|
|
270
|
+
return true;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ── Response validation ────────────────────────────────────────────
|
|
274
|
+
|
|
275
|
+
export function validateResponse(res) {
|
|
276
|
+
if (!res || typeof res !== "object") {
|
|
277
|
+
throw new Error("tier2-protocol: response must be an object");
|
|
278
|
+
}
|
|
279
|
+
if (hasPollution(res)) {
|
|
280
|
+
throw new Error("tier2-protocol: response contains a forbidden key");
|
|
281
|
+
}
|
|
282
|
+
if (typeof res.request_id !== "string" || res.request_id.length === 0) {
|
|
283
|
+
throw new Error("tier2-protocol: response.request_id must be a non-empty string");
|
|
284
|
+
}
|
|
285
|
+
if (res.response === undefined || res.response === null) {
|
|
286
|
+
throw new Error("tier2-protocol: response.response is required");
|
|
287
|
+
}
|
|
288
|
+
if (hasPollution(res.response)) {
|
|
289
|
+
throw new Error("tier2-protocol: response.response contains a forbidden key");
|
|
290
|
+
}
|
|
291
|
+
return true;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ── Batch file I/O ─────────────────────────────────────────────────
|
|
295
|
+
|
|
296
|
+
export function writePending(wikiRoot, batchId, requests) {
|
|
297
|
+
if (!Array.isArray(requests) || requests.length === 0) {
|
|
298
|
+
throw new Error("tier2-protocol: writePending requires at least one request");
|
|
299
|
+
}
|
|
300
|
+
for (const r of requests) validateRequest(r);
|
|
301
|
+
const path = pendingPath(wikiRoot, batchId);
|
|
302
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
303
|
+
const payload = JSON.stringify(
|
|
304
|
+
{
|
|
305
|
+
batch_id: batchId,
|
|
306
|
+
created_at: new Date().toISOString(),
|
|
307
|
+
requests,
|
|
308
|
+
},
|
|
309
|
+
null,
|
|
310
|
+
2,
|
|
311
|
+
);
|
|
312
|
+
const tmp = `${path}.tmp.${process.pid}.${Date.now()}`;
|
|
313
|
+
writeFileSync(tmp, payload, "utf8");
|
|
314
|
+
renameSync(tmp, path);
|
|
315
|
+
return path;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export function readPending(wikiRoot, batchId) {
|
|
319
|
+
const path = pendingPath(wikiRoot, batchId);
|
|
320
|
+
if (!existsSync(path)) return null;
|
|
321
|
+
const raw = readFileSync(path, "utf8");
|
|
322
|
+
const parsed = safeJsonParse(raw);
|
|
323
|
+
if (!parsed || !Array.isArray(parsed.requests)) {
|
|
324
|
+
throw new Error(`tier2-protocol: pending file ${path} malformed`);
|
|
325
|
+
}
|
|
326
|
+
for (const r of parsed.requests) validateRequest(r);
|
|
327
|
+
return parsed;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export function writeResponses(wikiRoot, batchId, responses) {
|
|
331
|
+
if (!Array.isArray(responses)) {
|
|
332
|
+
throw new Error("tier2-protocol: writeResponses requires an array");
|
|
333
|
+
}
|
|
334
|
+
for (const r of responses) validateResponse(r);
|
|
335
|
+
const path = responsesPath(wikiRoot, batchId);
|
|
336
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
337
|
+
const payload = JSON.stringify(
|
|
338
|
+
{
|
|
339
|
+
batch_id: batchId,
|
|
340
|
+
completed_at: new Date().toISOString(),
|
|
341
|
+
responses,
|
|
342
|
+
},
|
|
343
|
+
null,
|
|
344
|
+
2,
|
|
345
|
+
);
|
|
346
|
+
const tmp = `${path}.tmp.${process.pid}.${Date.now()}`;
|
|
347
|
+
writeFileSync(tmp, payload, "utf8");
|
|
348
|
+
renameSync(tmp, path);
|
|
349
|
+
return path;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export function readResponses(wikiRoot, batchId) {
|
|
353
|
+
const path = responsesPath(wikiRoot, batchId);
|
|
354
|
+
if (!existsSync(path)) return null;
|
|
355
|
+
const raw = readFileSync(path, "utf8");
|
|
356
|
+
const parsed = safeJsonParse(raw);
|
|
357
|
+
if (!parsed || !Array.isArray(parsed.responses)) {
|
|
358
|
+
throw new Error(`tier2-protocol: response file ${path} malformed`);
|
|
359
|
+
}
|
|
360
|
+
for (const r of parsed.responses) validateResponse(r);
|
|
361
|
+
return parsed;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Read all responses for a wiki, merging by request_id into a map.
|
|
365
|
+
// Used during resume to populate the decision cache.
|
|
366
|
+
export function readAllResponses(wikiRoot) {
|
|
367
|
+
const out = new Map();
|
|
368
|
+
const batches = listBatches(wikiRoot);
|
|
369
|
+
for (const b of batches) {
|
|
370
|
+
if (!existsSync(b.responses)) continue;
|
|
371
|
+
const parsed = readResponses(wikiRoot, b.batchId);
|
|
372
|
+
if (!parsed) continue;
|
|
373
|
+
for (const r of parsed.responses) {
|
|
374
|
+
out.set(r.request_id, r.response);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
return out;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// ── Fixture support (LLM_WIKI_TIER2_FIXTURE) ───────────────────────
|
|
381
|
+
//
|
|
382
|
+
// When the env var is set, tests can provide a single JSON file
|
|
383
|
+
// containing either an array of {request_id, response} pairs OR a
|
|
384
|
+
// map of { request_id → response }. The CLI path uses
|
|
385
|
+
// `loadFixture` to resolve Tier 2 requests inline instead of
|
|
386
|
+
// exiting with code 7. This is the ONLY way to run Tier 2 paths
|
|
387
|
+
// hermetically — the production path always emits exit 7.
|
|
388
|
+
|
|
389
|
+
export function fixturePath() {
|
|
390
|
+
return process.env.LLM_WIKI_TIER2_FIXTURE || null;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
export function loadFixture() {
|
|
394
|
+
const path = fixturePath();
|
|
395
|
+
if (!path) return null;
|
|
396
|
+
if (!existsSync(path)) {
|
|
397
|
+
throw new Error(`tier2-protocol: LLM_WIKI_TIER2_FIXTURE points at missing file ${path}`);
|
|
398
|
+
}
|
|
399
|
+
const raw = readFileSync(path, "utf8");
|
|
400
|
+
const parsed = safeJsonParse(raw);
|
|
401
|
+
const map = new Map();
|
|
402
|
+
if (Array.isArray(parsed)) {
|
|
403
|
+
for (const item of parsed) {
|
|
404
|
+
if (hasPollution(item)) {
|
|
405
|
+
throw new Error("tier2-protocol: fixture item contains a forbidden key");
|
|
406
|
+
}
|
|
407
|
+
if (!item || typeof item.request_id !== "string") {
|
|
408
|
+
throw new Error("tier2-protocol: fixture array item missing request_id");
|
|
409
|
+
}
|
|
410
|
+
map.set(item.request_id, item.response);
|
|
411
|
+
}
|
|
412
|
+
return map;
|
|
413
|
+
}
|
|
414
|
+
if (parsed && typeof parsed === "object") {
|
|
415
|
+
if (hasPollution(parsed)) {
|
|
416
|
+
throw new Error("tier2-protocol: fixture object contains a forbidden key");
|
|
417
|
+
}
|
|
418
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
419
|
+
map.set(k, v);
|
|
420
|
+
}
|
|
421
|
+
return map;
|
|
422
|
+
}
|
|
423
|
+
throw new Error(`tier2-protocol: fixture at ${path} is neither array nor object`);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Resolve a single request against the fixture map. Returns the
|
|
427
|
+
// response value (the inner `response` object) or null if the
|
|
428
|
+
// fixture doesn't carry this request id — in which case the caller
|
|
429
|
+
// can decide whether to fall through to exit-7 or to a sensible
|
|
430
|
+
// default.
|
|
431
|
+
//
|
|
432
|
+
// Wildcard fallback: a fixture may carry a special key
|
|
433
|
+
// `__kind__<kind>` whose value is the default response for any
|
|
434
|
+
// request of that kind that is not matched by a specific
|
|
435
|
+
// request_id. This exists so tests (and long-running convergence
|
|
436
|
+
// runs) can answer propose_structure / nest_decision / cluster_name
|
|
437
|
+
// with a uniform default response without pre-computing every
|
|
438
|
+
// possible request_id across every iteration.
|
|
439
|
+
export function resolveFromFixture(fixtureMap, request) {
|
|
440
|
+
if (!fixtureMap) return null;
|
|
441
|
+
if (!request || typeof request.request_id !== "string") return null;
|
|
442
|
+
const specific = fixtureMap.get(request.request_id);
|
|
443
|
+
if (specific !== undefined) return specific;
|
|
444
|
+
if (typeof request.kind === "string") {
|
|
445
|
+
const wildcard = fixtureMap.get(`__kind__${request.kind}`);
|
|
446
|
+
if (wildcard !== undefined) return wildcard;
|
|
447
|
+
}
|
|
448
|
+
return null;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// ── Safe JSON parse (rejects pollution keys) ───────────────────────
|
|
452
|
+
|
|
453
|
+
function safeJsonParse(raw) {
|
|
454
|
+
const parsed = JSON.parse(raw);
|
|
455
|
+
if (hasPollution(parsed)) {
|
|
456
|
+
throw new Error("tier2-protocol: parsed JSON contains a forbidden top-level key");
|
|
457
|
+
}
|
|
458
|
+
return parsed;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// ── Batch id derivation ────────────────────────────────────────────
|
|
462
|
+
//
|
|
463
|
+
// A batch id is a short deterministic string built from the op-id,
|
|
464
|
+
// phase name, and iteration number. Deterministic so rerunning the
|
|
465
|
+
// same op produces the same batch id and the wiki-runner can
|
|
466
|
+
// correlate pending ↔ responses unambiguously.
|
|
467
|
+
export function deriveBatchId(opId, phase, iteration) {
|
|
468
|
+
const text = `${opId}\0${phase}\0${iteration}`;
|
|
469
|
+
return createHash("sha256").update(text).digest("hex").slice(0, 12);
|
|
470
|
+
}
|