@ctxr/skill-llm-wiki 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/CHANGELOG.md +134 -0
  2. package/LICENSE +21 -0
  3. package/README.md +484 -0
  4. package/SKILL.md +252 -0
  5. package/guide/basics/concepts.md +74 -0
  6. package/guide/basics/index.md +45 -0
  7. package/guide/basics/schema.md +140 -0
  8. package/guide/cli.md +256 -0
  9. package/guide/correctness/index.md +45 -0
  10. package/guide/correctness/invariants.md +89 -0
  11. package/guide/correctness/safety.md +96 -0
  12. package/guide/history/diff.md +110 -0
  13. package/guide/history/hidden-git.md +130 -0
  14. package/guide/history/index.md +52 -0
  15. package/guide/history/remote-sync.md +113 -0
  16. package/guide/index.md +134 -0
  17. package/guide/isolation/coexistence.md +134 -0
  18. package/guide/isolation/index.md +44 -0
  19. package/guide/isolation/scale.md +251 -0
  20. package/guide/layout/in-place-mode.md +97 -0
  21. package/guide/layout/index.md +53 -0
  22. package/guide/layout/layout-contract.md +131 -0
  23. package/guide/layout/layout-modes.md +115 -0
  24. package/guide/operations/index.md +76 -0
  25. package/guide/operations/ingest/build.md +75 -0
  26. package/guide/operations/ingest/extend.md +61 -0
  27. package/guide/operations/ingest/index.md +54 -0
  28. package/guide/operations/ingest/join.md +65 -0
  29. package/guide/operations/maintain/fix.md +66 -0
  30. package/guide/operations/maintain/index.md +47 -0
  31. package/guide/operations/maintain/rebuild.md +86 -0
  32. package/guide/operations/validate.md +48 -0
  33. package/guide/substrate/index.md +47 -0
  34. package/guide/substrate/operators.md +96 -0
  35. package/guide/substrate/tiered-ai.md +363 -0
  36. package/guide/ux/index.md +44 -0
  37. package/guide/ux/preflight.md +150 -0
  38. package/guide/ux/user-intent.md +135 -0
  39. package/package.json +55 -0
  40. package/scripts/cli.mjs +893 -0
  41. package/scripts/commands/remote.mjs +93 -0
  42. package/scripts/commands/review.mjs +253 -0
  43. package/scripts/commands/sync.mjs +84 -0
  44. package/scripts/lib/chunk.mjs +421 -0
  45. package/scripts/lib/cluster-detect.mjs +516 -0
  46. package/scripts/lib/decision-log.mjs +343 -0
  47. package/scripts/lib/draft.mjs +158 -0
  48. package/scripts/lib/embeddings.mjs +366 -0
  49. package/scripts/lib/frontmatter.mjs +497 -0
  50. package/scripts/lib/git-commands.mjs +155 -0
  51. package/scripts/lib/git.mjs +486 -0
  52. package/scripts/lib/gitignore.mjs +62 -0
  53. package/scripts/lib/history.mjs +331 -0
  54. package/scripts/lib/indices.mjs +510 -0
  55. package/scripts/lib/ingest.mjs +258 -0
  56. package/scripts/lib/intent.mjs +713 -0
  57. package/scripts/lib/interactive.mjs +99 -0
  58. package/scripts/lib/migrate.mjs +126 -0
  59. package/scripts/lib/nest-applier.mjs +260 -0
  60. package/scripts/lib/operators.mjs +1365 -0
  61. package/scripts/lib/orchestrator.mjs +718 -0
  62. package/scripts/lib/paths.mjs +197 -0
  63. package/scripts/lib/preflight.mjs +213 -0
  64. package/scripts/lib/provenance.mjs +672 -0
  65. package/scripts/lib/quality-metric.mjs +269 -0
  66. package/scripts/lib/query-fixture.mjs +71 -0
  67. package/scripts/lib/rollback.mjs +95 -0
  68. package/scripts/lib/shape-check.mjs +172 -0
  69. package/scripts/lib/similarity-cache.mjs +126 -0
  70. package/scripts/lib/similarity.mjs +230 -0
  71. package/scripts/lib/snapshot.mjs +54 -0
  72. package/scripts/lib/source-frontmatter.mjs +85 -0
  73. package/scripts/lib/tier2-protocol.mjs +470 -0
  74. package/scripts/lib/tiered.mjs +453 -0
  75. package/scripts/lib/validate.mjs +362 -0
@@ -0,0 +1,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
+ }