@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 +10 -0
- package/README.md +9 -6
- package/SKILL.md +11 -11
- package/guide/correctness/safety.md +2 -2
- package/guide/layout/in-place-mode.md +1 -1
- package/package.json +10 -3
- package/scripts/lib/contract.mjs +22 -1
- package/scripts/lib/draft.mjs +127 -20
- package/scripts/lib/frontmatter.mjs +45 -9
- package/scripts/lib/tier2-protocol.mjs +169 -37
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
|
[](https://www.npmjs.com/package/@ctxr/skill-llm-wiki)
|
|
4
4
|
[](LICENSE)
|
|
5
5
|
[](.github/workflows/ci.yml)
|
|
6
|
+
[](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
|
|
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
|
|
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 `.
|
|
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 .
|
|
184
|
-
cp -r /tmp/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
|
-
.
|
|
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
|
|
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
|
|
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
|
|
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 `
|
|
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
|
|
160
|
-
- **Tier 2 draft-frontmatter sub-agent
|
|
161
|
-
- **Tier 2 operator-convergence sub-agent
|
|
162
|
-
- **Tier 2 rebuild plan review sub-agent
|
|
163
|
-
- **HUMAN-class Fix sub-agent
|
|
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"`, `"
|
|
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.
|
|
4
|
-
"description": "Claude Code
|
|
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",
|
package/scripts/lib/contract.mjs
CHANGED
|
@@ -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: {
|
|
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"],
|
package/scripts/lib/draft.mjs
CHANGED
|
@@ -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
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
|
|
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
|
-
"
|
|
35
|
-
|
|
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
|
|
75
|
-
//
|
|
76
|
-
//
|
|
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
|
|
79
|
-
if (field
|
|
80
|
-
if (
|
|
81
|
-
|
|
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
|
-
|
|
130
|
-
|
|
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
|
-
|
|
193
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
27
|
-
//
|
|
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
|
-
//
|
|
34
|
-
//
|
|
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
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
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
|
-
|
|
67
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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
|
-
|
|
83
|
-
|
|
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.
|
|
96
|
-
//
|
|
97
|
-
//
|
|
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
|
-
|
|
100
|
-
|
|
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
|
-
|
|
109
|
-
|
|
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
|
-
|
|
118
|
-
|
|
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
|
-
|
|
127
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
213
|
-
|
|
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
|
-
|
|
262
|
-
|
|
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
|
-
|
|
445
|
-
|
|
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;
|