@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.
@@ -20,18 +20,34 @@
20
20
  // - Batch read / write / merge helpers
21
21
  // - Pollution-key defence for JSON parse
22
22
  //
23
- // Request shape (JSON):
23
+ // Request shape (JSON, conforms to the open subagent.dispatch.v1 envelope
24
+ // with skill-specific extensions). What makeRequest() emits today:
24
25
  // {
26
+ // kind: "subagent.dispatch.v1" (the wire-format literal)
25
27
  // 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"
28
+ // role: "wiki-tier2-<tier2_kind>" (host maps to its native
29
+ // sub-agent type)
30
30
  // prompt: natural-language question the sub-agent answers
31
31
  // inputs: minimal per-kind inputs (frontmatter blobs, etc.)
32
+ // effort: "heavy" | "balanced" | "light" (provider-neutral hint)
32
33
  // 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
34
+ // tier2_kind: "merge_decision" | "nest_decision" | "cluster_name"
35
+ // | "propose_structure" | "draft_frontmatter"
36
+ // | "rebuild_plan_review" | "human_fix_item"
37
+ // (skill extension: the per-Tier-2-request kind, which
38
+ // the wiki-runner routes on)
39
+ // model: optional explicit model override; host prefers this
40
+ // when set, else maps `effort` to its own lineup
41
+ //
42
+ // Deprecated aliases kept for one release (emitted with the exact pre-v1
43
+ // per-kind values so existing wiki-runner consumers stay byte-compatible):
44
+ // model_hint → preserved per-kind legacy model hint
45
+ // effort_hint → preserved per-kind legacy effort hint
46
+ //
47
+ // Legacy envelopes written by a PREVIOUS release (where top-level `kind`
48
+ // WAS the Tier 2 kind and there was no `tier2_kind`/`role`) are still
49
+ // accepted on read: validateRequest and tier2KindOf both tolerate that
50
+ // shape so in-flight pending files resume across the upgrade.
35
51
  // }
36
52
  //
37
53
  // Response shape (JSON):
@@ -57,30 +73,39 @@ import { dirname, join } from "node:path";
57
73
 
58
74
  export const TIER2_EXIT_CODE = 7;
59
75
 
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.
76
+ // The default effort matrix from guide/tiered-ai.md. Each request
77
+ // kind maps to an effort hint that the host harness translates to a
78
+ // model from its own lineup. These are hints, not mandates — the
79
+ // wiki-runner may override per-session by setting an explicit
80
+ // `model` on the request.
81
+ //
82
+ // Effort enum (provider-neutral):
83
+ // "heavy" — prior `opus` + high; deepest reasoning task
84
+ // "balanced" — prior `sonnet`/`opus` + medium; structural judgement
85
+ // "light" — prior `sonnet`/`haiku` + low; quick decisions
64
86
  export const TIER2_DEFAULTS = Object.freeze({
65
87
  merge_decision: {
66
- model_hint: "sonnet",
67
- effort_hint: "low",
88
+ effort: "light",
89
+ legacy_model_hint: "sonnet",
90
+ legacy_effort_hint: "low",
68
91
  response_schema: {
69
92
  decision: "same|different|undecidable",
70
93
  reason: "string",
71
94
  },
72
95
  },
73
96
  nest_decision: {
74
- model_hint: "sonnet",
75
- effort_hint: "medium",
97
+ effort: "balanced",
98
+ legacy_model_hint: "sonnet",
99
+ legacy_effort_hint: "medium",
76
100
  response_schema: {
77
101
  decision: "nest|keep_flat|undecidable",
78
102
  reason: "string",
79
103
  },
80
104
  },
81
105
  cluster_name: {
82
- model_hint: "sonnet",
83
- effort_hint: "low",
106
+ effort: "light",
107
+ legacy_model_hint: "sonnet",
108
+ legacy_effort_hint: "low",
84
109
  response_schema: {
85
110
  slug: "kebab-case-slug",
86
111
  purpose: "string",
@@ -92,12 +117,13 @@ export const TIER2_DEFAULTS = Object.freeze({
92
117
  // ids) plus the leaves that should remain as siblings. This is
93
118
  // the "Tier 2 gets first dibs" escalation and fires BEFORE the
94
119
  // 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.
120
+ // directory. balanced effort because the task is a structural
121
+ // judgement call over many inputs that benefits from a strong
122
+ // reasoning model.
98
123
  propose_structure: {
99
- model_hint: "opus",
100
- effort_hint: "medium",
124
+ effort: "balanced",
125
+ legacy_model_hint: "opus",
126
+ legacy_effort_hint: "medium",
101
127
  response_schema: {
102
128
  subcategories: "array of { slug, purpose, members[] }",
103
129
  siblings: "array of leaf ids",
@@ -105,8 +131,9 @@ export const TIER2_DEFAULTS = Object.freeze({
105
131
  },
106
132
  },
107
133
  draft_frontmatter: {
108
- model_hint: "sonnet",
109
- effort_hint: "medium",
134
+ effort: "balanced",
135
+ legacy_model_hint: "sonnet",
136
+ legacy_effort_hint: "medium",
110
137
  response_schema: {
111
138
  focus: "string",
112
139
  covers: "array of strings",
@@ -114,8 +141,9 @@ export const TIER2_DEFAULTS = Object.freeze({
114
141
  },
115
142
  },
116
143
  rebuild_plan_review: {
117
- model_hint: "opus",
118
- effort_hint: "high",
144
+ effort: "heavy",
145
+ legacy_model_hint: "opus",
146
+ legacy_effort_hint: "high",
119
147
  response_schema: {
120
148
  approve: "boolean",
121
149
  drop: "array of iteration ids",
@@ -123,8 +151,9 @@ export const TIER2_DEFAULTS = Object.freeze({
123
151
  },
124
152
  },
125
153
  human_fix_item: {
126
- model_hint: "sonnet",
127
- effort_hint: "low",
154
+ effort: "light",
155
+ legacy_model_hint: "sonnet",
156
+ legacy_effort_hint: "low",
128
157
  response_schema: {
129
158
  action: "string",
130
159
  rationale: "string",
@@ -132,6 +161,20 @@ export const TIER2_DEFAULTS = Object.freeze({
132
161
  },
133
162
  });
134
163
 
164
+ // The three valid provider-neutral effort values. Any other value
165
+ // is rejected by makeRequest rather than silently falling back to a
166
+ // legacy alias.
167
+ const VALID_EFFORTS = new Set(["heavy", "balanced", "light"]);
168
+
169
+ let deprecationWarned = false;
170
+ function warnDeprecatedAliasOnce() {
171
+ if (deprecationWarned) return;
172
+ deprecationWarned = true;
173
+ process.stderr.write(
174
+ "[skill-llm-wiki] tier2-protocol: `model_hint` and `effort_hint` are deprecated; pass `effort` and (optional) `model` instead.\n",
175
+ );
176
+ }
177
+
135
178
  export const TIER2_KINDS = Object.freeze(Object.keys(TIER2_DEFAULTS));
136
179
 
137
180
  // Pollution keys that would leak onto Object.prototype if we
@@ -187,8 +230,26 @@ export function listBatches(wikiRoot) {
187
230
  // The builder fills in defaults from TIER2_DEFAULTS and validates
188
231
  // the shape. `inputs` is kind-specific and kept small (a few
189
232
  // frontmatter blobs at most) so batches stay under a few KB each.
233
+ //
234
+ // Wire shape: emitted envelopes conform to the open `subagent.dispatch.v1`
235
+ // envelope (see https://github.com/ctxr-dev/kit/blob/main/docs/subagent-dispatch-v1.md) so any Agent
236
+ // Skills harness can validate them. The Tier 2 per-request kind
237
+ // (`merge_decision`, `propose_structure`, …) lives on `tier2_kind`, NOT on
238
+ // the envelope's top-level `kind` field — `kind` MUST be the literal
239
+ // `"subagent.dispatch.v1"`. The skill-side `role` is derived from the Tier 2
240
+ // kind so the harness can map to its native sub-agent type.
241
+ //
242
+ // Legacy aliases `model_hint` / `effort_hint` are emitted alongside the
243
+ // canonical `effort` field for one release so wiki-runners that read the
244
+ // old names keep working. The schema's `additionalProperties: true` allows
245
+ // these as a documented extension profile.
246
+
247
+ const ROLE_PREFIX = "wiki-tier2-";
190
248
 
191
- export function makeRequest(kind, { prompt, inputs, model_hint, effort_hint, request_id } = {}) {
249
+ export function makeRequest(
250
+ kind,
251
+ { prompt, inputs, effort, model, model_hint, effort_hint, request_id } = {},
252
+ ) {
192
253
  if (!TIER2_KINDS.includes(kind)) {
193
254
  throw new Error(`tier2-protocol: unknown kind "${kind}" (valid: ${TIER2_KINDS.join(", ")})`);
194
255
  }
@@ -203,15 +264,44 @@ export function makeRequest(kind, { prompt, inputs, model_hint, effort_hint, req
203
264
  }
204
265
  const defaults = TIER2_DEFAULTS[kind];
205
266
  const rid = request_id ?? deriveRequestId(kind, inputs);
206
- return {
267
+
268
+ // Accept deprecated aliases (with a one-shot stderr warning) but
269
+ // prefer the new names when both are set.
270
+ if ((model_hint !== undefined || effort_hint !== undefined) && effort === undefined && model === undefined) {
271
+ warnDeprecatedAliasOnce();
272
+ }
273
+ const resolvedEffort = effort ?? defaults.effort;
274
+ if (!VALID_EFFORTS.has(resolvedEffort)) {
275
+ throw new Error(
276
+ `tier2-protocol: invalid effort "${resolvedEffort}" (allowed: ${[...VALID_EFFORTS].join(", ")})`,
277
+ );
278
+ }
279
+ // Required v1 fields first (`kind`, `request_id`, `role`, `prompt`,
280
+ // `inputs`, `effort`); skill-specific extensions follow.
281
+ const out = {
282
+ kind: "subagent.dispatch.v1",
207
283
  request_id: rid,
208
- kind,
284
+ role: ROLE_PREFIX + kind,
209
285
  prompt,
210
286
  inputs,
287
+ effort: resolvedEffort,
211
288
  response_schema: defaults.response_schema,
212
- model_hint: model_hint ?? defaults.model_hint,
213
- effort_hint: effort_hint ?? defaults.effort_hint,
289
+ // Skill-specific extension: the per-Tier-2-request kind, used by the
290
+ // wiki-runner to route to the right inline handler / prompt template.
291
+ tier2_kind: kind,
292
+ // Deprecated aliases retained for one release; readers should migrate to
293
+ // `effort` (and optional `model`). These emit the EXACT pre-v1 per-kind
294
+ // `model_hint`/`effort_hint` values (stored on TIER2_DEFAULTS) so the
295
+ // deprecation window is byte-compatible — they are NOT derived from
296
+ // `effort` (which would change e.g. propose_structure's model_hint).
297
+ // A caller-supplied alias still wins.
298
+ model_hint: model_hint ?? defaults.legacy_model_hint,
299
+ effort_hint: effort_hint ?? defaults.legacy_effort_hint,
214
300
  };
301
+ if (typeof model === "string" && model.length > 0) {
302
+ out.model = model;
303
+ }
304
+ return out;
215
305
  }
216
306
 
217
307
  // Deterministic request id: sha256(kind + canonical-JSON(inputs))
@@ -248,6 +338,28 @@ function canonicalJson(value) {
248
338
 
249
339
  // ── Request validation ─────────────────────────────────────────────
250
340
 
341
+ /**
342
+ * Pull the per-request Tier 2 kind off an envelope.
343
+ *
344
+ * New v1-conformant envelopes carry it on `tier2_kind` (the wire `kind` is
345
+ * the literal `"subagent.dispatch.v1"`). Legacy envelopes (pre-v1
346
+ * conformance) put it on `kind`. This helper accepts either so on-disk
347
+ * envelopes from a previous release continue to resolve correctly.
348
+ */
349
+ export function tier2KindOf(req) {
350
+ if (!req || typeof req !== "object") return null;
351
+ // Only accept a `tier2_kind` that names a recognised kind; an unknown or
352
+ // malformed value must NOT be treated as valid downstream.
353
+ if (typeof req.tier2_kind === "string" && TIER2_KINDS.includes(req.tier2_kind)) {
354
+ return req.tier2_kind;
355
+ }
356
+ // Legacy fallback: `kind` was the per-request tier-2 kind before v1 conformance.
357
+ if (typeof req.kind === "string" && TIER2_KINDS.includes(req.kind)) {
358
+ return req.kind;
359
+ }
360
+ return null;
361
+ }
362
+
251
363
  export function validateRequest(req) {
252
364
  if (!req || typeof req !== "object") {
253
365
  throw new Error("tier2-protocol: request must be an object");
@@ -258,8 +370,24 @@ export function validateRequest(req) {
258
370
  if (typeof req.request_id !== "string" || req.request_id.length === 0) {
259
371
  throw new Error("tier2-protocol: request.request_id must be a non-empty string");
260
372
  }
261
- if (!TIER2_KINDS.includes(req.kind)) {
262
- throw new Error(`tier2-protocol: request.kind "${req.kind}" is not recognised`);
373
+ // Envelope `kind` is REQUIRED. It must be the v1 wire constant
374
+ // "subagent.dispatch.v1" OR — for legacy pre-v1 envelopes itself one of
375
+ // the Tier 2 kinds. Omitting it (even when a valid `tier2_kind` is present)
376
+ // is a malformed envelope: an envelope with no `kind` is neither v1 nor a
377
+ // recognised legacy shape, and must not be writable to a pending file.
378
+ if (typeof req.kind !== "string" || req.kind.length === 0) {
379
+ throw new Error(
380
+ `tier2-protocol: request.kind is required and must be "subagent.dispatch.v1" or a legacy tier-2 kind (${TIER2_KINDS.join(", ")})`,
381
+ );
382
+ }
383
+ if (req.kind !== "subagent.dispatch.v1" && !TIER2_KINDS.includes(req.kind)) {
384
+ throw new Error(
385
+ `tier2-protocol: request.kind must be "subagent.dispatch.v1" or a legacy tier-2 kind (${TIER2_KINDS.join(", ")}), got "${req.kind}"`,
386
+ );
387
+ }
388
+ const t2 = tier2KindOf(req);
389
+ if (!t2) {
390
+ throw new Error(`tier2-protocol: request must declare tier2_kind (or legacy kind) from: ${TIER2_KINDS.join(", ")}`);
263
391
  }
264
392
  if (typeof req.prompt !== "string" || req.prompt.length === 0) {
265
393
  throw new Error("tier2-protocol: request.prompt must be a non-empty string");
@@ -441,8 +569,12 @@ export function resolveFromFixture(fixtureMap, request) {
441
569
  if (!request || typeof request.request_id !== "string") return null;
442
570
  const specific = fixtureMap.get(request.request_id);
443
571
  if (specific !== undefined) return specific;
444
- if (typeof request.kind === "string") {
445
- const wildcard = fixtureMap.get(`__kind__${request.kind}`);
572
+ // Wildcard lookups key on the per-Tier-2-request kind, not the v1
573
+ // envelope `kind` literal. Resolve via `tier2KindOf` so both new and
574
+ // legacy envelope shapes route correctly.
575
+ const t2 = tier2KindOf(request);
576
+ if (t2) {
577
+ const wildcard = fixtureMap.get(`__kind__${t2}`);
446
578
  if (wildcard !== undefined) return wildcard;
447
579
  }
448
580
  return null;
@@ -10,7 +10,7 @@
10
10
  // circuits the whole ladder.
11
11
  //
12
12
  // Three quality modes, selected via --quality-mode or the
13
- // LLM_WIKI_QUALITY_MODE env var:
13
+ // LLM_WIKI_QUALITY_MODE env var (see resolveQualityMode):
14
14
  //
15
15
  // tiered-fast (default):
16
16
  // Tier 0 → Tier 1 → Tier 2, the full ladder. Mid-band Tier 0
@@ -21,9 +21,16 @@
21
21
  // obvious decisions) but anything in the Tier 0 mid-band goes
22
22
  // straight to Tier 2, skipping Tier 1.
23
23
  //
24
- // tier0-only:
25
- // Tier 0 decisions only. Mid-band becomes an explicit
26
- // "undecidable" marker that the caller must resolve manually.
24
+ // deterministic:
25
+ // Tier 0 + Tier 1 ladder, but the ladder terminates at Tier 1:
26
+ // mid-band Tier 0 escalates to Tier 1 (as in tiered-fast), but
27
+ // mid-band Tier 1 is resolved by a deterministic threshold
28
+ // (`TIER1_DETERMINISTIC_THRESHOLD`) instead of escalating to
29
+ // Tier 2. No LLM/sub-agent is ever consulted — every decision
30
+ // is produced from TF-IDF + MiniLM cosine alone, so repeated
31
+ // runs on the same inputs are byte-reproducible. This is the
32
+ // mode the clustering pipeline pairs with algorithmic HAC +
33
+ // auto-slug to produce deterministic wiki builds end-to-end.
27
34
  //
28
35
  // Tier 2 escalation contract: the skill's CLI runs under Node with
29
36
  // no access to Claude Code's `Agent` tool, so it cannot spawn
@@ -64,11 +71,27 @@ import {
64
71
  export const QUALITY_MODES = Object.freeze([
65
72
  "tiered-fast",
66
73
  "claude-first",
67
- "tier0-only",
74
+ "deterministic",
68
75
  ]);
69
76
 
70
77
  export const DEFAULT_QUALITY_MODE = "tiered-fast";
71
78
 
79
+ // Deterministic-mode split point for resolving mid-band Tier 1
80
+ // similarities. Derived as the midpoint of the Tier 1 mid-band so
81
+ // future tuning of the decisive-same / decisive-different thresholds
82
+ // propagates here without a separate code-change — no drift between
83
+ // "where the ladder says 'escalate'" and "where deterministic mode
84
+ // says 'same vs different'". Any pair whose Tier 1 cosine sits
85
+ // strictly above this is routed to "same"; anything at-or-below is
86
+ // routed to "different". In this mode there is no mid-band
87
+ // "undecidable" / pending-Tier-2 outcome — Tier 1 always produces a
88
+ // concrete branch without an LLM in the loop. (Note: Tier 0 can still
89
+ // produce an "undecidable" result on insufficient-text inputs — two
90
+ // empty frontmatters — independent of quality mode; that predates
91
+ // deterministic mode and is by design.)
92
+ export const TIER1_DETERMINISTIC_THRESHOLD =
93
+ (TIER1_DECISIVE_SAME + TIER1_DECISIVE_DIFFERENT) / 2;
94
+
72
95
  export function resolveQualityMode(flags = {}) {
73
96
  const fromFlag = flags.quality_mode;
74
97
  const fromEnv = process.env.LLM_WIKI_QUALITY_MODE;
@@ -244,18 +267,6 @@ export async function decide(
244
267
  }
245
268
 
246
269
  // Mid-band Tier 0 → escalate. Behaviour depends on quality mode.
247
- if (qualityMode === "tier0-only") {
248
- const result = {
249
- tier: 0,
250
- similarity: t0.similarity,
251
- decision: "undecidable",
252
- confidence_band: t0.confidence_band,
253
- reason: "tier0-only quality mode — mid-band left unresolved",
254
- };
255
- finaliseDecision(result, { a, b, hashA, hashB, wikiRoot, opId, operator, writeLog, writeCache });
256
- return result;
257
- }
258
-
259
270
  if (qualityMode === "claude-first") {
260
271
  // Skip Tier 1 entirely, go straight to Tier 2.
261
272
  return await escalateToTier2(
@@ -308,7 +319,20 @@ export async function decide(
308
319
  finaliseDecision(result, { a, b, hashA, hashB, wikiRoot, opId, operator, writeLog, writeCache });
309
320
  return result;
310
321
  }
311
- // Mid-band Tier 1 Tier 2.
322
+ // Mid-band Tier 1. Branch on quality mode: deterministic resolves
323
+ // algorithmically, tiered-fast escalates to Tier 2.
324
+ if (qualityMode === "deterministic") {
325
+ const decision = sim > TIER1_DETERMINISTIC_THRESHOLD ? "same" : "different";
326
+ const result = {
327
+ tier: 1,
328
+ similarity: sim,
329
+ decision,
330
+ confidence_band: "deterministic-mid-band",
331
+ reason: `deterministic mode: sim ${sim.toFixed(3)} ${decision === "same" ? ">" : "≤"} ${TIER1_DETERMINISTIC_THRESHOLD}`,
332
+ };
333
+ finaliseDecision(result, { a, b, hashA, hashB, wikiRoot, opId, operator, writeLog, writeCache });
334
+ return result;
335
+ }
312
336
  return await escalateToTier2(
313
337
  a, b, hashA, hashB, wikiRoot, opId, operator,
314
338
  sim, "tier1 mid-band", writeLog, writeCache,
@@ -133,6 +133,28 @@ export function validateWiki(wikiRoot) {
133
133
  }
134
134
  }
135
135
  }
136
+
137
+ // LEAF-AT-WIKI-ROOT — the wiki root must hold only `index.md`
138
+ // plus subdirectories. Any `.md` file at the wiki root other
139
+ // than `index.md` itself violates the invariant, regardless of
140
+ // what its frontmatter `type:` claims. Keying off path rather
141
+ // than `data.type` catches the edge case of a hand-authored
142
+ // `foo.md` at root declared as `type: index` — it's still a
143
+ // loose root file the navigational model forbids. The rule is
144
+ // navigational: Claude reading `<root>/index.md` and following
145
+ // its `entries[]` should reach every leaf via a
146
+ // semantically-named category; loose root files bypass that
147
+ // mental model and bloat the top-level index.
148
+ const absDir = dirname(e.absolute);
149
+ const absName = basename(e.absolute);
150
+ if (absDir === wikiRoot && absName !== "index.md") {
151
+ push(
152
+ "error",
153
+ "LEAF-AT-WIKI-ROOT",
154
+ e.absolute,
155
+ `non-index markdown file at wiki root — must live in a subcategory (run 'fix' to contain)`,
156
+ );
157
+ }
136
158
  }
137
159
 
138
160
  return findings;