@ctxr/skill-llm-wiki 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ### Cross-harness support (Claude Code + OpenAI Codex CLI)
10
+
11
+ - **SKILL.md prose neutralised so the wiki-runner sub-agent dispatch works under both Claude Code (via the `Agent` tool) and Codex CLI (via its equivalent).** The Tier 2 envelope shape is now the open `subagent.dispatch.v1` contract: top-level `kind: "subagent.dispatch.v1"`, `role: "wiki-tier2-<kind>"`, the per-Tier-2-request kind moved to the `tier2_kind` extension field. The deprecated `model_hint` / `effort_hint` aliases continue to be emitted for one release so existing wiki-runner consumers keep working; new code should consume `effort` (and optional `model` override) instead. See `https://github.com/ctxr-dev/kit/blob/main/docs/subagent-dispatch-v1.md` for the full envelope spec.
12
+ - Replaced hardcoded Claude model names (`opus` / `sonnet` / `haiku`) in default-model documentation with provider-neutral effort hints (`heavy` / `balanced` / `light`). Each host harness maps `effort` to its own lineup; `model` overrides remain available for explicit pinning.
13
+ - Repositioned package.json description from "Claude Code skill" to "Agent Skills (Claude Code, Codex CLI)".
14
+ - Reordered package.json keywords to lead with `agent-skills`, `agents-md`, `codex`, `claude-code`.
15
+ - Updated git-submodule install path in README from `.claude/skills/` to `.agents/skills/` to match the canonical install topology used by `@ctxr/kit`.
16
+ - Fixed three broken cross-references in `guide/correctness/safety.md` and `guide/layout/in-place-mode.md` (relative paths that resolved outside their target subdir).
17
+ - Declared `publishConfig.access: "public"` for scoped npm publish; added `prepublishOnly` lint+test gate.
18
+
9
19
  ### Performance
10
20
 
11
21
  - **Large-corpus pairwise-sweep speedup (~50-100× on I/O-bound paths).** A 596-leaf deterministic build previously took 2h15m; this release targets the three I/O antipatterns responsible for most of that wall time. Surfaced during a live Phase X.6 rebuild attempt on `skill-code-review/reviewers.src`.
package/README.md CHANGED
@@ -3,9 +3,12 @@
3
3
  [![npm](https://img.shields.io/npm/v/@ctxr/skill-llm-wiki)](https://www.npmjs.com/package/@ctxr/skill-llm-wiki)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
5
5
  [![CI](https://img.shields.io/badge/CI-Ubuntu%20%2B%20Windows-green)](.github/workflows/ci.yml)
6
+ [![Agent Skills](https://img.shields.io/badge/Agent%20Skills-Claude%20Code%20%7C%20Codex%20CLI-blue)](https://agentskills.io)
6
7
 
7
8
  > **Turn any folder of markdown, docs, or source into a deterministic, token-efficient knowledge base your AI agent reads the way you'd want it to — once, and only the parts it needs.**
8
9
 
10
+ Supports Claude Code and OpenAI Codex CLI via the open Agent Skills standard. Tier 2 sub-agent dispatch follows the [`subagent-dispatch-v1`](https://github.com/ctxr-dev/kit/blob/main/docs/subagent-dispatch-v1.md) envelope.
11
+
9
12
  ## The problem every AI-heavy workflow eventually hits
10
13
 
11
14
  You want your AI pair — Claude, Cursor, an agent loop, whatever — to *know things*. Architecture decisions. Runbooks. API contracts. Prior postmortems. Team conventions. The messy folder of `.md` notes you've been keeping for eighteen months.
@@ -91,8 +94,8 @@ Merge my docs and runbooks wikis into a handbook
91
94
 
92
95
  This skill has two hard requirements. If either is missing, the skill will refuse to run and print a clear message explaining why and how to fix it.
93
96
 
94
- 1. **[Claude Code](https://claude.ai/code) CLI or IDE extension.**
95
- 2. **Node.js ≥ 18.0.0.** The skill's deterministic CLI (`scripts/cli.mjs`) is a Node.js program, so Node must be available in the shell Claude Code uses to run Bash commands. If Node.js is missing or below the minimum version, Claude will stop the operation before making any changes and relay platform-specific install instructions.
97
+ 1. **An Agent Skills-compatible harness** ([Claude Code](https://claude.ai/code) CLI/IDE, OpenAI Codex CLI, or another harness implementing the [open Agent Skills standard](https://agentskills.io)).
98
+ 2. **Node.js ≥ 18.0.0.** The skill's deterministic CLI (`scripts/cli.mjs`) is a Node.js program, so Node must be available in the shell the host harness uses to run Bash commands. If Node.js is missing or below the minimum version, the harness will stop the operation before making any changes and relay platform-specific install instructions.
96
99
 
97
100
  ### Verify your environment before invoking the skill
98
101
 
@@ -172,7 +175,7 @@ npx @ctxr/kit install @ctxr/skill-llm-wiki # project-local
172
175
  npx @ctxr/kit install @ctxr/skill-llm-wiki --user # user-global
173
176
  ```
174
177
 
175
- Installs to `.claude/skills/ctxr-skill-llm-wiki/` (or `~/.claude/skills/…` with `--user`). No post-install wiring, no automatic hooks, no filesystem watchers the skill is pure standby until you explicitly ask Claude to run an operation against a specific directory.
178
+ Installs canonically to `.agents/skills/ctxr-skill-llm-wiki/` (or `~/.agents/skills/…` with `--user`); `@ctxr/kit` auto-creates discovery-mirror symlinks at `.claude/skills/` (and `~/.codex/skills/` for user-scope) so Claude Code, Codex CLI, and other Agent Skills harnesses all find the artefact. No post-install wiring, no automatic hooks, no filesystem watchers; the skill is pure standby until you explicitly ask the host harness to run an operation against a specific directory.
176
179
 
177
180
  The installed package contains `SKILL.md` (the routing entry point Claude reads at activation), `LICENSE`, `README.md`, `scripts/` (invoked via `node scripts/cli.mjs <subcommand>`, never read as source), and `guide/` (context-specific routing leaves loaded on keyword activation — `hidden-git.md` when the user asks about history or diff, `user-intent.md` when the request is ambiguous, `tiered-ai.md` when the user asks about quality modes, etc.). The internal design doc `methodology.md` is deliberately excluded from the installed package (`files[]` in `package.json` does not list it) so it is never copied into any user environment and never loaded during a session.
178
181
 
@@ -180,15 +183,15 @@ The installed package contains `SKILL.md` (the routing entry point Claude reads
180
183
 
181
184
  ```bash
182
185
  git clone https://github.com/ctxr-dev/skill-llm-wiki.git /tmp/skill-llm-wiki
183
- mkdir -p .claude/skills
184
- cp -r /tmp/skill-llm-wiki .claude/skills/skill-llm-wiki
186
+ mkdir -p .agents/skills
187
+ cp -r /tmp/skill-llm-wiki .agents/skills/skill-llm-wiki
185
188
  ```
186
189
 
187
190
  ### Git Submodule
188
191
 
189
192
  ```bash
190
193
  git submodule add https://github.com/ctxr-dev/skill-llm-wiki.git \
191
- .claude/skills/skill-llm-wiki
194
+ .agents/skills/skill-llm-wiki
192
195
  ```
193
196
 
194
197
  ## Usage
package/SKILL.md CHANGED
@@ -62,18 +62,18 @@ When the user asks for any of the six operations (Build, Extend, Validate, Rebui
62
62
 
63
63
  1. **Resolve the ask** — pin down the operation, source, target, layout mode, and any constraints. Prompt the user to disambiguate where needed (see `guide/ux/user-intent.md`).
64
64
  2. **Run the Node.js preflight** in the main session — this is cheap, produces a tiny output, and must happen before any agent is spawned so the user sees the detailed install/upgrade message on failure. Preflight failures stop the operation; do not spawn an agent.
65
- 3. **Spawn a dedicated "wiki-runner" sub-agent** via the `Agent` tool with a self-contained prompt describing: the operation, the resolved CLI invocation, the activated `guide/` leaves by filename, any quality-mode / layout-mode flags, and the completion criterion. The sub-agent runs the CLI, handles Tier 2 sub-delegations, manages its own context, and reports back a summary when done.
65
+ 3. **Spawn a dedicated "wiki-runner" sub-agent** via the host harness's sub-agent dispatch primitive (Claude Code: `Agent` tool; Codex CLI: equivalent; see the [`subagent-dispatch-v1`](https://github.com/ctxr-dev/kit/blob/main/docs/subagent-dispatch-v1.md) spec) with a self-contained prompt describing: the operation, the resolved CLI invocation, the activated `guide/` leaves by filename, any quality-mode / layout-mode flags, and the completion criterion. The sub-agent runs the CLI, handles Tier 2 sub-delegations, manages its own context, and reports back a summary when done.
66
66
  4. **Relay the sub-agent's summary** to the user. The main session never loads the wiki's content into its own context window.
67
67
 
68
68
  ### Why
69
69
 
70
- Wikis can be any size a 10-entry notes folder or a 10,000-entry knowledge base. A Build that drafts frontmatter for a prose-heavy 10k corpus can run Claude against thousands of entries in Tier 2. Running all of that inline in the main session would consume the user's context budget on content they never asked to see, and would leave no room for continued conversation. The wiki-runner sub-agent has its own context window; the main session's budget stays lean for the user's ongoing chat.
70
+ Wikis can be any size: a 10-entry notes folder or a 10,000-entry knowledge base. A Build that drafts frontmatter for a prose-heavy 10k corpus can run the host LLM against thousands of entries in Tier 2. Running all of that inline in the main session would consume the user's context budget on content they never asked to see, and would leave no room for continued conversation. The wiki-runner sub-agent has its own context window; the main session's budget stays lean for the user's ongoing chat.
71
71
 
72
72
  ### What the wiki-runner sub-agent is responsible for
73
73
 
74
74
  - **Executing the CLI subcommand** and streaming progress back periodically (don't spam every phase — one line per phase is plenty).
75
75
  - **Its own context-window hygiene.** The sub-agent monitors its remaining budget and auto-compacts when it approaches the limit. See `guide/isolation/scale.md` "Context-window management in the wiki-runner" for the protocol — the short version is: phase commits in the private git are the durable checkpoint, so the sub-agent can safely drop its conversation history of prior phases and re-read only what the next phase needs.
76
- - **Handling the Tier 2 exit-7 handshake.** The skill's CLI runs under Node and cannot spawn sub-agents directly. When the operator-convergence phase accumulates Tier 2 requests (cluster naming, mid-band merge decisions, `propose_structure` whole-directory asks, `nest_decision` gate decisions, …) the CLI writes them to `<wiki>/.work/tier2/pending-<batch-id>.json` and exits with code **7** (`NEEDS_TIER2`). Exit 7 is **not a failure** it is the suspend-and-resume signal. The wiki-runner must:
76
+ - **Handling the Tier 2 exit-7 handshake.** The skill's CLI runs under Node and cannot spawn sub-agents directly. When the operator-convergence phase accumulates Tier 2 requests (cluster naming, mid-band merge decisions, `propose_structure` whole-directory asks, `nest_decision` gate decisions, …) the CLI writes them to `<wiki>/.work/tier2/pending-<batch-id>.json` and exits with code **7** (`NEEDS_TIER2`). Each pending request follows the [`subagent.dispatch.v1`](https://github.com/ctxr-dev/kit/blob/main/docs/subagent-dispatch-v1.md) shape (`prompt`, `inputs`, `effort`, optional `model`, `response_schema`, `outputs_path`) so any Agent Skills harness can service it. Exit 7 is **not a failure**, it is the suspend-and-resume signal. The wiki-runner must:
77
77
  1. Detect exit 7 from the CLI.
78
78
  2. Read every `pending-*.json` file under `<wiki>/.work/tier2/`.
79
79
  3. Service each request (see "Inline servicing vs fan-out" below).
@@ -86,9 +86,9 @@ Wikis can be any size — a 10-entry notes folder or a 10,000-entry knowledge ba
86
86
 
87
87
  The wiki-runner chooses **inline** or **fan-out** servicing based on the batch size. The skill CLI's wire protocol is identical either way — pending files in, response files out, exit 7 between — so the choice is entirely a context-budget and throughput call that the wiki-runner makes at runtime:
88
88
 
89
- - **Inline (≤ ~50 requests per batch).** The wiki-runner answers every request directly, reasoning as the Tier 2 worker itself. No child `Agent` spawn per request. Each request's `prompt`, `inputs`, `response_schema`, `model_hint`, and `effort_hint` are visible to the wiki-runner, which writes the JSON response inline. This is the right choice for a typical `build`/`rebuild` against a ~10-50 leaf corpus: batch sizes stay small, fan-out overhead would dwarf the work, and the wiki-runner's own context is plenty for a few dozen frontmatter-blob comparisons. Environment constraint: general-purpose sub-agents in the current Claude Code harness cannot spawn further `Agent`s themselves inline servicing is actually the *only* option when the wiki-runner is itself a nested sub-agent, so the skill's design must not require fan-out.
89
+ - **Inline (≤ ~50 requests per batch).** The wiki-runner answers every request directly, reasoning as the Tier 2 worker itself. No child sub-agent dispatch per request. Each request's `prompt`, `inputs`, `response_schema`, `effort` (with optional `model` override) are visible to the wiki-runner, which writes the JSON response inline. This is the right choice for a typical `build`/`rebuild` against a ~10-50 leaf corpus: batch sizes stay small, fan-out overhead would dwarf the work, and the wiki-runner's own context is plenty for a few dozen frontmatter-blob comparisons. Environment constraint: general-purpose sub-agents in current Agent Skills harnesses (Claude Code, Codex CLI) cannot spawn further sub-agents themselves, so inline servicing is actually the *only* option when the wiki-runner is itself a nested sub-agent, and the skill's design must not require fan-out.
90
90
 
91
- - **Fan-out (> ~50 requests per batch, or mixed `model_hint`s).** The wiki-runner reports the batch size back to the main session, which spawns one narrowly-scoped `Agent` per request (or per small group of homogeneous requests) with the request's `prompt`, `inputs`, `response_schema`, `model_hint`, and `effort_hint`. The sub-agent sees ONLY those inputs two frontmatter blobs for a `merge_decision`, a cluster's leaf metadata for a `cluster_name`, a directory's whole leaf list for a `propose_structure`, and so on. Never pass the whole wiki. Fan-out is the right choice for large corpora (thousands of leaves → thousands of draft-frontmatter and merge-decision requests) where inline would burn through the wiki-runner's context.
91
+ - **Fan-out (> ~50 requests per batch, or mixed `effort`/`model` per request).** The wiki-runner reports the batch size back to the main session, which dispatches one narrowly-scoped sub-agent per request (or per small group of homogeneous requests) via the host harness's sub-agent primitive, passing the request's `prompt`, `inputs`, `response_schema`, `effort`, and optional `model`. The sub-agent sees ONLY those inputs (two frontmatter blobs for a `merge_decision`, a cluster's leaf metadata for a `cluster_name`, a directory's whole leaf list for a `propose_structure`, and so on). Never pass the whole wiki. Fan-out is the right choice for large corpora (thousands of leaves → thousands of draft-frontmatter and merge-decision requests) where inline would burn through the wiki-runner's context.
92
92
 
93
93
  Either way the skill CLI doesn't change — it always emits pending files and exits 7, and the wiki-runner is free to decide how the actual reasoning happens before the response files appear.
94
94
 
@@ -156,13 +156,13 @@ Response: { "slug": "<kebab-case>", "purpose": "<one line>" } or { "decision": "
156
156
 
157
157
  Unless the user specifies otherwise, the wiki-runner and its Tier 2 fan-outs pick the **most suitable model for the task size** at their default effort level. Concretely:
158
158
 
159
- - **Wiki-runner** spawned at the subagent type that can orchestrate CLI subprocesses and hold the whole operation in its context. For very large corpora (>1k entries or >10 MB) prefer a 1M-context Claude variant.
160
- - **Tier 2 draft-frontmatter sub-agent** picks whatever model is cost-effective for writing a ~200-word `focus` + `covers[]` pair from a single source file. Effort: minimal.
161
- - **Tier 2 operator-convergence sub-agent** picks whatever model is strong at structural judgment on frontmatter pairs. Effort: minimal-to-medium depending on pair ambiguity.
162
- - **Tier 2 rebuild plan review sub-agent** picks a strong reasoning model because this is the "deep understanding" case. Effort: medium.
163
- - **HUMAN-class Fix sub-agent** — picks a strong reasoning model; effort medium because the decision needs justification.
159
+ - **Wiki-runner**: spawned at the sub-agent type that can orchestrate CLI subprocesses and hold the whole operation in its context. For very large corpora (>1k entries or >10 MB) prefer a 1M-context model with high effort (`effort: "heavy"`).
160
+ - **Tier 2 draft-frontmatter sub-agent**: picks whatever model the host maps to `effort: "light"`, cost-effective for writing a ~200-word `focus` + `covers[]` pair from a single source file.
161
+ - **Tier 2 operator-convergence sub-agent**: `effort: "light"` to `"balanced"` depending on pair ambiguity. The host's mapped model should be strong at structural judgment on frontmatter pairs.
162
+ - **Tier 2 rebuild plan review sub-agent**: `effort: "heavy"` because this is the "deep understanding" case.
163
+ - **HUMAN-class Fix sub-agent**: `effort: "balanced"` because the decision needs justification.
164
164
 
165
- **User overrides.** If the user specifies a model (`"use sonnet"`, `"run it on haiku"`, `"use opus 1M for the whole thing"`) or an effort level (`"minimal effort"`, `"maximum quality"`), honour the override on every sub-agent the operation spawns, not just the wiki-runner. Pass the override through to the Tier 2 prompts as an explicit instruction. If the user specifies conflicting overrides (e.g., a model that doesn't support the requested effort level), ask before proceeding.
165
+ **User overrides.** If the user specifies a model (`"use sonnet"`, `"use gpt-5-codex"`, `"use opus 1M for the whole thing"`) or an effort level (`"light effort"`, `"maximum quality"`), pass it as the dispatch envelope's optional `model` field and the required `effort` field. The host harness honours the explicit `model` when set; otherwise it maps `effort` to its own model lineup. Honour the override on every sub-agent the operation spawns, not just the wiki-runner. If the user specifies conflicting overrides (e.g., a model that doesn't support the requested effort level), ask before proceeding.
166
166
 
167
167
  ### Inline execution is the escape hatch, not the norm
168
168
 
@@ -69,8 +69,8 @@ The `.work/` directory is scratch space used by phases that need to stage interm
69
69
  folders. History is tracked by the private git repo at
70
70
  `<source>.wiki/.llmwiki/git/`, and rollback is `skill-llm-wiki rollback
71
71
  <source>.wiki --to pre-<op-id>` (byte-exact via `git reset --hard`).
72
- - See [guide/layout-modes.md](layout-modes.md) for the full mode matrix and
73
- [guide/in-place-mode.md](in-place-mode.md) for the in-place variant. Legacy
72
+ - See [guide/layout/layout-modes.md](../layout/layout-modes.md) for the full mode matrix and
73
+ [guide/layout/in-place-mode.md](../layout/in-place-mode.md) for the in-place variant. Legacy
74
74
  `<source>.llmwiki.v<N>/` wikis are detected via **INT-04** and must be
75
75
  migrated explicitly with `skill-llm-wiki migrate <legacy-path>` before any
76
76
  other operation will run.
@@ -93,5 +93,5 @@ If the user's source folder is already tracked by their own git repo, the
93
93
  first in-place operation writes `.gitignore` with `.llmwiki/`, `.work/`,
94
94
  `.shape/history/*/work/`. The user's git sees those paths as ignored. Our
95
95
  private repo's operations never touch the user's `.git/` — see
96
- [guide/coexistence.md](coexistence.md) for the full coexistence story and
96
+ [guide/isolation/coexistence.md](../isolation/coexistence.md) for the full coexistence story and
97
97
  proof-of-isolation tests.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ctxr/skill-llm-wiki",
3
- "version": "1.1.0",
4
- "description": "Claude Code skill build, extend, validate, rebuild, fix, and join LLM wikis from any knowledge corpus. Token-efficient retrieval via hierarchical indices, DAG parents, and deterministic rewrite operators.",
3
+ "version": "1.2.0",
4
+ "description": "Agent Skills (Claude Code, Codex CLI): build, extend, validate, rebuild, fix, and join LLM wikis from any knowledge corpus. Token-efficient retrieval via hierarchical indices, DAG parents, and deterministic rewrite operators.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "repository": {
@@ -16,6 +16,9 @@
16
16
  "node": ">=18.0.0"
17
17
  },
18
18
  "keywords": [
19
+ "agent-skills",
20
+ "agents-md",
21
+ "codex",
19
22
  "claude-code",
20
23
  "skill",
21
24
  "llm-wiki",
@@ -38,6 +41,9 @@
38
41
  "type": "skill",
39
42
  "target": "folder"
40
43
  },
44
+ "publishConfig": {
45
+ "access": "public"
46
+ },
41
47
  "bin": {
42
48
  "skill-llm-wiki": "scripts/cli.mjs"
43
49
  },
@@ -46,7 +52,8 @@
46
52
  "validate": "node scripts/cli.mjs --version",
47
53
  "lint": "markdownlint-cli2 '**/*.md' '#node_modules'",
48
54
  "lint:fix": "markdownlint-cli2 --fix '**/*.md' '#node_modules'",
49
- "prepare": "husky || true"
55
+ "prepare": "husky || true",
56
+ "prepublishOnly": "npm run lint && npm test"
50
57
  },
51
58
  "devDependencies": {
52
59
  "husky": "^9.1.0",
@@ -65,8 +65,29 @@ const FRONTMATTER_SCHEMA = {
65
65
  shared_covers: { kind: "string[]" },
66
66
  // Only present (and required) when type === "overlay".
67
67
  overlay_targets: { kind: "string[]" },
68
- links: { kind: "string[]" },
68
+ links: {
69
+ kind: "object[]",
70
+ description: "Cross-leaf references; each entry carries an `id` (and optional metadata) — see scripts/lib/join.mjs for how runtime code reads link.id.",
71
+ },
72
+ // Consumer-defined fields (e.g. skill-code-review's
73
+ // `dimensions`, `audit_surface`, `languages`, `tools`) are
74
+ // carried through rebuilds via the deny-list forwarding in
75
+ // draft.mjs; their VALUES are preserved (not dropped). Exact
76
+ // bytes can change because the renderer applies canonical
77
+ // top-level key ordering and YAML formatting. The contract
78
+ // here describes only the fields the wiki framework itself
79
+ // reads / writes; consumers ship their own schemas alongside.
69
80
  },
81
+ // Reserved fields that the rebuild ALWAYS re-derives from the
82
+ // target-tree position, regardless of what the author wrote.
83
+ // `parents` is NOT in this set — it's hand-authored when the
84
+ // soft-DAG layout requires it (the drafter picks the authored
85
+ // value over the heuristic).
86
+ reserved: ["id", "type", "depth_role", "source"],
87
+ // Deny-list semantics for everything else: any authored field not
88
+ // in `reserved` flows through verbatim. This keeps the wiki
89
+ // framework agnostic to consumer-specific schemas.
90
+ pass_through_authored: true,
70
91
  },
71
92
  index: {
72
93
  required: ["id", "type", "depth_role", "focus"],
@@ -23,21 +23,52 @@
23
23
  // `needs_ai` flag on the returned draft tells the caller which entries
24
24
  // need AI review.
25
25
 
26
- // Fields we copy straight from the source frontmatter when the author
27
- // supplied them. Fields NOT in this list (id / type / depth_role /
28
- // parents / source) are always re-derived because their authoritative
29
- // source is the target-tree position, not the original source file.
30
- const AUTHORED_LEAF_FIELDS = [
26
+ // Prototype-pollution deny-list. Mirrors POLLUTION_KEYS in
27
+ // scripts/lib/frontmatter.mjs the parser refuses these at parse
28
+ // time, but the new pass-through path in draftLeafFrontmatter could
29
+ // still surface them if a crafted candidate JSON (e.g. from
30
+ // `scripts/cli.mjs draft-leaf` invoked with adversarial input)
31
+ // shipped them via authored_frontmatter. Refusing here keeps the
32
+ // invariant local to the assignment site.
33
+ const POLLUTION_KEYS = new Set(["__proto__", "constructor", "prototype"]);
34
+
35
+ // Fields whose authoritative source is the target-tree position (not
36
+ // the original source file). These are ALWAYS re-derived during a
37
+ // rebuild regardless of what the author wrote: `id` comes from the
38
+ // filename / target slot, `type` defaults to "primary" (overlays must
39
+ // be re-asserted explicitly via the rebuild's overlay path),
40
+ // `depth_role` is always "leaf" for non-index leaves, and `source` is
41
+ // recomputed from the build invocation.
42
+ //
43
+ // `parents` is NOT in this set — it's a hand-authored field (the
44
+ // comment in the data object below describes the convention) and the
45
+ // drafter pickAuthored()s it. Including it here would silently drop
46
+ // authored parents and break the soft-DAG.
47
+ //
48
+ // EVERY OTHER authored field flows through verbatim. This is a
49
+ // deny-list, not an allow-list (issue #26): consumers ship their own
50
+ // schemas (e.g. skill-code-review's `dimensions`, `audit_surface`,
51
+ // `languages`, `tools`) and a generic wiki framework should preserve
52
+ // what the author wrote rather than enumerating per-consumer fields.
53
+ const RESERVED_LEAF_FIELDS = new Set([
54
+ "id",
55
+ "type",
56
+ "depth_role",
57
+ "source",
58
+ ]);
59
+
60
+ // Fields the drafter computes a heuristic baseline for and writes
61
+ // explicitly in the canonical data object below. Authored values for
62
+ // these win over the heuristic via pickAuthored(); they're listed here
63
+ // only so the pass-through loop knows to skip them (they're already in
64
+ // the data object — re-forwarding would be a no-op but with the wrong
65
+ // authored-vs-heuristic precedence).
66
+ const EXPLICITLY_HANDLED_LEAF_FIELDS = new Set([
31
67
  "focus",
32
68
  "covers",
33
69
  "tags",
34
- "domains",
35
- "aliases",
36
- "activation",
37
- "shared_covers",
38
- "overlay_targets",
39
- "links",
40
- ];
70
+ "parents",
71
+ ]);
41
72
 
42
73
  export function draftLeafFrontmatter(candidate, { categoryPath } = {}) {
43
74
  const authored = candidate.authored_frontmatter || {};
@@ -71,15 +102,39 @@ export function draftLeafFrontmatter(candidate, { categoryPath } = {}) {
71
102
  },
72
103
  };
73
104
 
74
- // Forward the remaining AUTHORED_LEAF_FIELDS verbatim. These have no
75
- // heuristic analogue when the author supplied them, we keep them;
76
- // otherwise we omit the field entirely so the output stays compact.
105
+ // Forward EVERY authored field that isn't reserved (re-derived from
106
+ // target-tree position) or explicitly handled above (focus / covers
107
+ // / tags / parents, where authored-wins-over-drafted is enforced via
108
+ // pickAuthored). Issue #26: the previous allow-list dropped any
109
+ // consumer-specific v2 field (dimensions, audit_surface, languages,
110
+ // tools, …) authored at the source; the deny-list now preserves
111
+ // arbitrary author-shipped frontmatter VALUES (the downstream
112
+ // renderer applies canonical top-level key ordering and YAML
113
+ // formatting, so the rebuilt bytes need not match the source bytes).
77
114
  if (hasAuthored) {
78
- for (const field of AUTHORED_LEAF_FIELDS) {
79
- if (field === "focus" || field === "covers" || field === "tags") continue;
80
- if (authored[field] !== undefined && authored[field] !== null) {
81
- data[field] = authored[field];
82
- }
115
+ for (const [field, value] of Object.entries(authored)) {
116
+ if (RESERVED_LEAF_FIELDS.has(field)) continue;
117
+ if (EXPLICITLY_HANDLED_LEAF_FIELDS.has(field)) continue;
118
+ // Refuse prototype-pollution keys before any assignment touches
119
+ // the prototype chain. Mirrors frontmatter.mjs's safeAssign.
120
+ if (POLLUTION_KEYS.has(field)) continue;
121
+ if (value === undefined || value === null) continue;
122
+ const sanitised = sanitiseAuthoredValue(value);
123
+ if (sanitised === undefined) continue;
124
+ // Empty arrays / empty strings DO get forwarded — distinguishing
125
+ // "author wrote []" from "author omitted" matters for some
126
+ // consumer schemas (e.g. an explicit empty file_globs[] means
127
+ // "this leaf opts out of glob-based activation"). Only the
128
+ // null/undefined case is treated as "author omitted".
129
+ // Use defineProperty (configurable, enumerable, writable) so the
130
+ // assignment never invokes a setter on Object.prototype if the
131
+ // POLLUTION_KEYS guard above is ever bypassed.
132
+ Object.defineProperty(data, field, {
133
+ value: sanitised,
134
+ configurable: true,
135
+ enumerable: true,
136
+ writable: true,
137
+ });
83
138
  }
84
139
  }
85
140
 
@@ -87,6 +142,58 @@ export function draftLeafFrontmatter(candidate, { categoryPath } = {}) {
87
142
  return { data, confidence, needs_ai: confidence < 0.6 };
88
143
  }
89
144
 
145
+ // Sanitise a value pulled from authored frontmatter for assignment
146
+ // into `data` (which is later passed to renderFrontmatter). The
147
+ // renderer at scripts/lib/frontmatter.mjs handles plain objects,
148
+ // arrays, and scalar primitives (string / number / boolean / null) but
149
+ // not richer JS types — gray-matter / js-yaml can return:
150
+ // - Date (from YAML timestamps like `created_at: 2026-04-30`):
151
+ // converted to ISO string. Otherwise renderScalar(date) calls
152
+ // String(date) which produces the verbose JS Date toString form.
153
+ // - functions / symbols / class instances: rejected (return
154
+ // undefined so the pass-through loop skips the field).
155
+ // Plain objects and arrays recurse so a Date nested inside an
156
+ // authored object still gets normalised.
157
+ function sanitiseAuthoredValue(value) {
158
+ if (value === null) return null;
159
+ if (value === undefined) return undefined;
160
+ const t = typeof value;
161
+ if (t === "string" || t === "number" || t === "boolean") return value;
162
+ if (t === "function" || t === "symbol" || t === "bigint") return undefined;
163
+ if (value instanceof Date) {
164
+ // YAML timestamps come back as Date; canonicalise to ISO string so
165
+ // a downstream rebuild round-trips the same string back into the
166
+ // YAML stream.
167
+ return value.toISOString();
168
+ }
169
+ if (Array.isArray(value)) {
170
+ return value.map(sanitiseAuthoredValue).filter((v) => v !== undefined);
171
+ }
172
+ if (t === "object") {
173
+ // Plain-object check: only recurse into objects whose prototype
174
+ // is Object.prototype or null. Class instances (URL, Buffer, …)
175
+ // are rejected — their `Object.entries` shape is rarely what a
176
+ // YAML frontmatter consumer wants.
177
+ const proto = Object.getPrototypeOf(value);
178
+ if (proto !== null && proto !== Object.prototype) return undefined;
179
+ // Use a null-prototype object as the accumulator so neither the
180
+ // POLLUTION_KEYS guard nor a setter on Object.prototype can be
181
+ // triggered by an `out[__proto__] = ...` assignment with a crafted
182
+ // key. (defineProperty would also work; null-proto is one allocation.)
183
+ const out = Object.create(null);
184
+ for (const [k, v] of Object.entries(value)) {
185
+ if (POLLUTION_KEYS.has(k)) continue;
186
+ const s = sanitiseAuthoredValue(v);
187
+ if (s === undefined) continue;
188
+ out[k] = s;
189
+ }
190
+ // Re-parent to Object.prototype before returning so downstream
191
+ // consumers that do `value.hasOwnProperty(...)` etc. keep working.
192
+ return Object.assign({}, out);
193
+ }
194
+ return undefined;
195
+ }
196
+
90
197
  function pickAuthored(authoredVal, fallback) {
91
198
  if (authoredVal === undefined || authoredVal === null) return fallback;
92
199
  if (Array.isArray(authoredVal)) {
@@ -126,8 +126,9 @@ function parseMap(p, baseIndent) {
126
126
  const rest = text.slice(colon + 1).trim();
127
127
  p.advance();
128
128
 
129
- if (rest === "|" || rest === ">") {
130
- safeAssign(out, key, parseBlockScalar(p, baseIndent, rest === "|"), p, tok);
129
+ const blockHeader = blockScalarHeader(rest);
130
+ if (blockHeader) {
131
+ safeAssign(out, key, parseBlockScalar(p, baseIndent, blockHeader), p, tok);
131
132
  continue;
132
133
  }
133
134
  if (rest !== "") {
@@ -178,6 +179,12 @@ function parseSeq(p, baseIndent) {
178
179
  continue;
179
180
  }
180
181
 
182
+ const itemBlockHeader = blockScalarHeader(afterDash);
183
+ if (itemBlockHeader) {
184
+ out.push(parseBlockScalar(p, baseIndent, itemBlockHeader));
185
+ continue;
186
+ }
187
+
181
188
  const colon = findKeyColon(afterDash);
182
189
  if (colon === -1) {
183
190
  out.push(parseScalarInline(afterDash));
@@ -189,8 +196,9 @@ function parseSeq(p, baseIndent) {
189
196
  const firstRest = afterDash.slice(colon + 1).trim();
190
197
  const item = {};
191
198
 
192
- if (firstRest === "|" || firstRest === ">") {
193
- item[firstKey] = parseBlockScalar(p, baseIndent + 2, firstRest === "|");
199
+ const firstBlockHeader = blockScalarHeader(firstRest);
200
+ if (firstBlockHeader) {
201
+ item[firstKey] = parseBlockScalar(p, baseIndent + 2, firstBlockHeader);
194
202
  } else if (firstRest !== "") {
195
203
  item[firstKey] = parseScalarInline(firstRest);
196
204
  } else {
@@ -237,10 +245,13 @@ function parseSeq(p, baseIndent) {
237
245
  } else {
238
246
  item[subKey] = null;
239
247
  }
240
- } else if (subRest === "|" || subRest === ">") {
241
- item[subKey] = parseBlockScalar(p, baseIndent + 2, subRest === "|");
242
248
  } else {
243
- item[subKey] = parseScalarInline(subRest);
249
+ const subBlockHeader = blockScalarHeader(subRest);
250
+ if (subBlockHeader) {
251
+ item[subKey] = parseBlockScalar(p, baseIndent + 2, subBlockHeader);
252
+ } else {
253
+ item[subKey] = parseScalarInline(subRest);
254
+ }
244
255
  }
245
256
  }
246
257
 
@@ -248,8 +259,29 @@ function parseSeq(p, baseIndent) {
248
259
  }
249
260
  }
250
261
 
251
- function parseBlockScalar(p, baseIndent, literal) {
262
+ // Recognise a YAML block scalar header: `|` (literal) or `>` (folded),
263
+ // each optionally carrying a chomping indicator (`+`/`-`) and/or an explicit
264
+ // indentation indicator (a single digit 1-9), in either order (YAML 1.2
265
+ // §8.1.1). Returns { literal } or null. Chomping/indent indicators affect
266
+ // only trailing-newline and indent-detection nuances that do not change the
267
+ // value of the single-line/wrapped scalars our frontmatter uses, so we read
268
+ // them for tolerance but act only on the literal-vs-folded distinction. This
269
+ // is why a serializer-folded `id: >-` (js-yaml's default line wrap) parses
270
+ // instead of tripping "unexpected indent".
271
+ function blockScalarHeader(rest) {
272
+ const m = /^([|>])(?:(?:([+-])([1-9])?)|(?:([1-9])([+-])?))?$/.exec(rest);
273
+ return m
274
+ ? {
275
+ literal: m[1] === "|",
276
+ indent: Number(m[3] ?? m[4] ?? 0),
277
+ }
278
+ : null;
279
+ }
280
+
281
+ function parseBlockScalar(p, baseIndent, header) {
282
+ const { literal, indent } = header;
252
283
  const collected = [];
284
+ let contentIndent = indent > 0 ? baseIndent + indent : null;
253
285
  while (p.pos < p.lines.length) {
254
286
  const raw = p.lines[p.pos];
255
287
  if (raw.trim() === "") {
@@ -259,7 +291,11 @@ function parseBlockScalar(p, baseIndent, literal) {
259
291
  }
260
292
  const indent = raw.length - raw.trimStart().length;
261
293
  if (indent <= baseIndent) break;
262
- collected.push(raw.slice(baseIndent + 2));
294
+ if (contentIndent == null) {
295
+ contentIndent = indent;
296
+ }
297
+ if (indent < contentIndent) break;
298
+ collected.push(raw.slice(contentIndent));
263
299
  p.pos++;
264
300
  }
265
301
  // Trim trailing empty lines
@@ -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;