diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 383402a..e73f7ff 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -19,7 +19,7 @@ jobs:
- name: Bash syntax + shellcheck
run: |
sudo apt-get install -y -qq shellcheck >/dev/null
- files="skills/delegate-kit/hooks/*.sh skills/delegate-kit/scripts/agent-wt bench/seeded-review/run.sh"
+ files="skills/delegate-kit/hooks/*.sh skills/delegate-kit/scripts/agent-wt tests/*.sh"
for f in $files; do bash -n "$f"; done
shellcheck -S warning $files
- name: Node syntax
@@ -27,19 +27,29 @@ jobs:
node --check skills/delegate-kit/scripts/agent-run
for f in skills/delegate-kit/scripts/*.mjs skills/delegate-kit/hooks/*.mjs; do node --check "$f"; done
- name: Writer caps and detach refusal
- run: bash skills/delegate-kit/tests/caps.sh
+ run: bash tests/caps.sh
- name: Routing policy
- run: bash skills/delegate-kit/tests/route.sh
+ run: bash tests/route.sh
- name: Completion delivery
- run: bash skills/delegate-kit/tests/delivery.sh
+ run: bash tests/delivery.sh
- name: Safety gate
- run: bash skills/delegate-kit/tests/gate.sh
+ run: bash tests/gate.sh
- name: Worktree inspect for reruns
- run: bash skills/delegate-kit/tests/inspect.sh
+ run: bash tests/inspect.sh
- name: SKILL.md stays a policy, not a manual
run: |
size=$(wc -c < skills/delegate-kit/SKILL.md)
echo "SKILL.md: $size bytes"
test "$size" -le 8000
- - name: Validate result schema JSON
- run: python3 -m json.tool skills/delegate-kit/references/result-schema.json >/dev/null
+ - name: Validate packaged schemas and example JSON
+ run: |
+ python3 -m json.tool skills/delegate-kit/references/result-schema.json >/dev/null
+ python3 -m json.tool skills/delegate-kit/assets/preset.schema.json >/dev/null
+ python3 -m json.tool skills/delegate-kit/examples/config.json >/dev/null
+ node skills/delegate-kit/scripts/dk.mjs presets validate --file skills/delegate-kit/examples/main.json
+ - name: Runtime help from a relocated package
+ run: |
+ stage=$(mktemp -d)
+ cp -R skills/delegate-kit "$stage/installed skill"
+ cd "$stage"
+ node "$stage/installed skill/scripts/dk.mjs" help
diff --git a/README.md b/README.md
index 3027fd8..bee8046 100644
--- a/README.md
+++ b/README.md
@@ -1,260 +1,177 @@
-
-
# Delegate Kit
-**Your agent coordinates. Your chosen models do the work.**
+Delegate Kit is an agent skill that lets your coding assistant delegate work to a team you choose. Save each specialist's model, tools and responsibilities in a named preset. Your current chat coordinates the work, collects results and checks them before accepting changes.
+
+Use it when you want a researcher to investigate a bug, a separate worker to implement a fix, or a fresh reviewer to check it. Teams can use one model family or combine Codex, Claude Code and other supported tools. Small, understood tasks can stay in the current chat.
-[](https://github.com/tomastaker/delegate-kit/releases/latest)
[](https://github.com/tomastaker/delegate-kit/actions/workflows/ci.yml)
[](LICENSE)
-**Model families**
-[](skills/delegate-kit/examples/config.json)
-[](skills/delegate-kit/examples/config.json)
-[](skills/delegate-kit/references/routing.md)
-[](skills/delegate-kit/examples/config.json)
-[](#add-your-own-team-glm-with-claude-and-gpt)
+

-**Execution tools**
-[](skills/delegate-kit/references/providers.md#gpt-through-codex-cli)
-[](skills/delegate-kit/references/providers.md#claude-through-claude-code)
-[](skills/delegate-kit/references/providers.md#gemini-through-gemini-cli)
-[](skills/delegate-kit/references/providers.md#kimi-and-glm-through-opencode)
+## Install
-

+```bash
+npx skills add tomastaker/delegate-kit
+```
-
+Select the coding assistant where you want to use the skill. You need Node.js 20+ and the tools chosen for your team. Writers use Git worktrees, which also require Bash and jq. Sign in to each chosen tool separately. Delegate Kit does not include accounts or credentials.
-Delegate Kit is a skill for coding agents that turns a substantial task into scoped work, assigns it to the models you choose, and checks the result. Your current chat stays in charge: it decides what to delegate, accepts the work and integrates the changes.
+For SSH work, install the skill and execution tools on the machine that runs the workers. Your local login does not authorize a remote machine.
-- **Use your own team.** Assign a model and reasoning level to each role, with stronger options when needed.
-- **Mix native and external workers.** GPT can implement natively while Claude plans and reviews through its CLI. Use OpenCode for configured Kimi or GLM models.
-- **Make useful work parallel.** Independent tasks run together; coupled changes stay with one owner. The coordinator chooses the number of workers within your limits and the host's capacity.
-- **Check the result independently.** Writers use separate git worktrees. Reviewers start with a fresh context and the specification.
-- **Keep overhead proportionate.** Small work stays in the chat. Clarify a weak brief or strengthen the model when the evidence calls for it.
+## Start in your chat
-## Get started
+Ask your assistant:
-```bash
-npx skills add tomastaker/delegate-kit
-```
+> Use Delegate Kit start. Help me configure my default team, starting from the main example.
-Then ask your coding agent:
+Setup happens in conversation. The assistant checks installed tools, asks which models and reasoning levels to use, and helps describe when each specialist should be called. You can supply the whole team at once or answer a few questions at a time, in your own language.
-> Use Delegate Kit to implement this feature. Delegate useful independent work and verify the result.
+The assistant shows the proposed configuration before saving it. The included [`main` preset](skills/delegate-kit/examples/main.json) is the default starting example. You can adopt it, change it or build a smaller team. Installation does not activate it automatically, overwrite existing presets or run paid model tests.
-No configuration is required: workers inherit the current model where the host supports native agents. To use other models or CLIs, choose them explicitly in your request or save the profiles below. Installing a CLI alone does not authorize its use.
+Once configured:
-Native delegation requires the host's subagent tools. External execution requires Node 20+, bash, git, jq and an authenticated supported CLI. [Execution paths and restrictions](skills/delegate-kit/references/providers.md).
+> Use Delegate Kit main to investigate this bug, implement the fix and review the result.
-
-Native role installation and upgrading
+These are requests to the skill in your chat, not global terminal commands. The exact skill picker or slash-command syntax depends on your assistant.
-After installing the skill, run its `hooks/install.sh --dry-run`, then `hooks/install.sh` to install native role definitions and the optional shell gate. From a repository checkout:
+## Requests you can make
-```bash
-skills/delegate-kit/hooks/install.sh --dry-run
-skills/delegate-kit/hooks/install.sh
-```
+| Request | What it does |
+|---|---|
+| `Delegate Kit start` | Walk through team setup |
+| `Use Delegate Kit main` | Select `main` for this chat |
+| `Use frontend only for this task` | Use another preset without changing the chat selection |
+| `Create a preset called backend` | Build a separate team |
+| `Copy main to frontend` | Create an independent copy |
+| `Change frontend's UI implementer` | Edit one profile in that team |
+| `Make main the default` | Choose the team for new chats |
+| `Show my Delegate Kit presets` | List saved teams |
-Use `--agents-only`, `--hooks-only`, `--claude` or `--codex` to select what is installed. Upgrading an older installation removes managed model pins so role profiles can choose the model. Unrelated configuration is preserved and backups are made. Restart affected sessions.
+An explicit preset takes precedence over the chat selection, which takes precedence over the saved default. Switching teams never changes your chat's model. Existing workers keep the configuration they started with.
-To uninstall the managed hooks and roles, run `hooks/uninstall.sh` from the installed skill, then remove your skill links. Run records remain available.
+## How work gets assigned
-
+A **preset** is a complete team. A **profile** describes one specialist, including its role, model and when to use it. A **worker** is a running instance of a profile. The same profile can handle several independent tasks, and one role can have several profiles.
-## Four roles, one coordinator
+The coordinator reads the profiles' descriptions and chooses the ones relevant to the task. You can also name a profile yourself. There is no fixed pipeline that runs every specialist:
-| Role | What it returns | When it helps |
-|---|---|---|
-| **Researcher** (`researcher`) | Facts, relevant code and source evidence | A bounded search can be done independently |
-| **Planner** (`planner`) | Tasks, dependencies, ownership and acceptance checks | The task needs meaningful decomposition or clarification |
-| **Implementer** (`implementer`) | Scoped changes and executed checks | A complete outcome deserves its own worker |
-| **Reviewer** (`reviewer`) | Findings against the diff and specification | The result needs an independent check |
+- A clear fix can go straight to an implementer, followed by review.
+- A bug with an unknown cause can start with research.
+- A change with unresolved design choices can need a planner before implementation.
+- Independent changes can run in parallel with separate ownership and worktrees.
-The coordinator selects ready tasks, writes briefs, handles blockers and accepts results. It can work directly when delegation would cost more than it helps. A separate planner is optional. Reviewers see the specification and frozen diff without the author's conversation or each other's findings.
+Results return to the coordinator, which decides what happens next. Researchers and implementers do not start their own teams. Reviewers receive a fresh context with the specification and a fixed version of the changes.
-An occasional **finding verifier** resolves disputed findings; a **review lead** helps organize a substantial review. They reuse the reviewer and planner model ladders unless you configure them separately.
+There is no default one-worker limit. The coordinator chooses parallelism from the available independent work, subject to your configured limits and the host's capacity. Waiting does not launch another worker. Local health checks use no model calls, though processing status results still consumes coordinator tokens.
-## Choose your models in one file
+## The main preset
-Edit **`~/.delegate-kit/config.json`** (or `$DELEGATE_KIT_HOME/config.json` if you override the state directory). This one file holds all your profiles under `profiles`. Each profile describes a team of workers; the coordinator is the model already running in your chat.
+[`examples/main.json`](skills/delegate-kit/examples/main.json) is the English version of the maintainer's working team. All seven profiles use CLI execution.
-**GPT, Claude and Kimi are example teams. You can add your own profiles.** To get started:
+| Profile | Model and reasoning | Assignment |
+|---|---|---|
+| `researcher` | Codex, GPT-5.6 Luna, medium | Bounded code and documentation lookup |
+| `researcher-hard` | Codex, GPT-5.6 Sol, medium | Uncertain causes, interacting failures and complex investigations |
+| `planner` | Codex, GPT-6 Astra, low | Approach, dependencies, ownership, risks and acceptance checks |
+| `implementer` | Codex, GPT-5.6 Sol, medium | Features, fixes, tests and documentation |
+| `implementer-ui` | OMP/OpenRouter, Qwen 3.8 Max, medium | Interfaces, components, interactions and responsive styling |
+| `reviewer` | Codex, GPT-5.6 Sol, medium | Independent review of ordinary changes |
+| `reviewer-hard` | Codex, GPT-6 Astra, high | Review of changes with a high cost of failure |
-1. Open the [example config.json](skills/delegate-kit/examples/config.json). It contains all three teams in one file.
-2. For a first configuration, save a copy at the path above. If you already have a configuration, merge the profiles you want into its existing `profiles` object, preserving your other settings. Profiles are entries inside this file; they do not need separate files or renaming.
-3. Keep the teams you need and replace their model identifiers and reasoning settings with choices supported by your host or CLI. Add another named entry under `profiles` for each additional team.
+The two researchers are alternatives. So are the two reviewers. A difficult investigation does not have to pass through the basic researcher first. For example, the coordinator might select `researcher-hard` immediately for duplicate payment requests and `reviewer-hard` for the resulting fix.
-By default, the coordinator family selects the matching profile: `--parent codex` selects `profiles.gpt`, `--parent claude` selects `profiles.claude`, and `--parent kimi`, `glm` or `gemini` selects the corresponding name. Codex and Claude sessions can be detected from their environment; specify `--parent` for other hosts. An explicit `--profile NAME` selects a particular team. Neither option starts or changes the chat model.
+Model availability and reasoning options depend on your account and execution tool. Setup checks those choices before adopting the example. You can use a Codex-only team, a Claude-only team, or a mixed team with independently chosen tools and models. There is no requirement to match the coordinator's model family.
-Each role is an ordered list. **The first entry is the usual choice; later entries are available strengthening steps.** These are alternatives, not agents all launched together.
+The UI profile needs OMP and an authorized OpenRouter connection. Its current adapter restricts worker tools and does not supply browser automation or shell execution. The coordinator must perform the running-UI checks when the worker cannot. See [OMP setup](skills/delegate-kit/references/omp-setup.md) and the [compatibility table](skills/delegate-kit/references/compatibility.md).
-Here is a GPT-led team with native GPT implementers and external Claude planning and review:
+## Make it yours
-```json
-{
- "profiles": {
- "gpt": {
- "roles": {
- "researcher": [
- { "model": "gpt-5.6-luna", "effort": "high" },
- { "model": "gpt-6-astra", "effort": "low" }
- ],
- "planner": [
- { "family": "claude", "runner": "claude", "model": "fable", "effort": "high" }
- ],
- "implementer": [
- { "model": "gpt-6-astra", "effort": "low" },
- { "model": "gpt-6-astra", "effort": "high" }
- ],
- "reviewer": [
- { "family": "claude", "runner": "claude", "model": "opus", "effort": "high" }
- ]
- }
- }
- }
-}
-```
-
-These model names illustrate a personal setup, not required dependencies or a quality ranking. Replace them with identifiers available in your host or configured CLI. Only use effort values that the chosen model and execution path support; omit `effort` when it cannot be set.
+You do not need to edit JSON. For example:
-[Open the complete editable example](skills/delegate-kit/examples/config.json), which includes all three profiles:
+> Copy main to backend. Remove the UI specialist. Use my configured Claude Code model for implementation. Keep the Codex reviewers. Ask me for any missing model or reasoning choices before saving.
-| Coordinator | Research | Planning | Implementation | Review |
-|---|---|---|---|---|
-| GPT | Luna → Astra, native | Fable through Claude CLI | Astra low → high, native | Opus → Fable through Claude CLI |
-| Claude | Sonnet → Opus, native | Fable, native | Opus medium → high, native | Astra through Codex CLI |
-| Kimi | Current Kimi model, native | Astra through Codex CLI | Current Kimi model, native | GLM through OpenCode |
+Or describe a new specialist:
-The Kimi example inherits your current native model instead of guessing its identifier. It requires a host with native worker support. Replace `YOUR_PROVIDER/YOUR_GLM_MODEL` with the exact identifier shown by `opencode models`. Configuration never installs a model or changes provider credentials.
+> Add a second researcher for database problems. Use it for query plans, transaction boundaries and migration investigations. Keep the ordinary researcher for other lookups.
-### Add your own team: GLM with Claude and GPT
+Be specific about the work that distinguishes profiles. "Investigate intermittent failures across services" gives the coordinator more to work with than "use for hard tasks."
-Suppose your chat already runs GLM 5.3. This configuration assigns native GLM research and implementation, Claude planning, and GPT review:
+If you prefer editing files, each preset is one JSON document. A small team can look like this:
```json
{
- "profiles": {
- "glm": {
- "roles": {
- "researcher": [
- { "runner": "native" }
- ],
- "planner": [
- { "family": "claude", "runner": "claude", "model": "opus" }
- ],
- "implementer": [
- { "runner": "native" }
- ],
- "reviewer": [
- { "family": "gpt", "runner": "codex", "model": "YOUR_GPT_MODEL" }
- ]
+ "schema_version": 2,
+ "id": "research",
+ "defaults": { "researcher": "researcher" },
+ "agents": {
+ "researcher": {
+ "role": "researcher",
+ "when": "Find relevant code, tests and documentation for a bounded question.",
+ "instructions": "Return evidence and unresolved questions. Keep files unchanged.",
+ "executor": {
+ "harness": "codex",
+ "model": "gpt-5.6-luna",
+ "reasoning": "medium",
+ "transport": "cli"
}
}
}
}
```
-Add the `glm` entry beside your other profiles. Replace `YOUR_GPT_MODEL` with the exact model identifier accepted by your Codex CLI; use an available Claude model in place of `opus` if needed. Omitted native model settings inherit from the current session. Native execution requires real subagent tools in that host. If those tools are unavailable, replace each native GLM assignment with an explicit OpenCode assignment from the table below.
-
-From a checkout, inspect the selected routes without calling a model:
-
-```bash
-skills/delegate-kit/scripts/agent-run route --parent glm --role implementer
-skills/delegate-kit/scripts/agent-run route --parent glm --role reviewer --author-backend self
-```
-
-The first route selects a native GLM implementer; the second selects an external GPT reviewer through Codex. GLM remains the coordinator for both.
+`when` guides the coordinator's selection. `instructions` go to the worker with its task. `defaults` identifies the usual profile for a role. Optional `coordination` text describes team-wide choices. A required additional reviewer can be configured through `review.also_run`. See the [schema](skills/delegate-kit/assets/preset.schema.json) and [setup reference](skills/delegate-kit/references/setup.md).
-To keep different teams for two GLM versions or hosts, name them, for example, `glm-5-3` and `glm-in-opencode`, and explicitly select the desired profile:
+Saved configuration lives outside the installed skill:
-```bash
-skills/delegate-kit/scripts/agent-run route --parent glm --profile glm-5-3 --role implementer
+```text
+~/.delegate-kit/
+ settings.json
+ presets/main.json
+ presets/backend.json
```
-Create that named entry under `profiles` before using the command. Profile names start with a lowercase letter and use lowercase letters, digits, hyphens or underscores. Automatic selection uses the family; it does not distinguish model versions or hosts. You can also tell the coordinator: “Use Delegate Kit with parent glm and profile glm-5-3.” The coordinator carries those choices into its routing calls.
-
-Profiles configure teams; support for a new execution environment depends on its tools. Native workers use the current host's actual agent tools. External workers currently use the `codex`, `claude`, `gemini` or `opencode` adapters. Models served through OpenRouter or another provider can use that provider's OpenCode configuration. A new external CLI requires a code adapter; adding a profile does not create one. Direct external Kimi CLI execution is not implemented. [Adapter contracts and provider setup](skills/delegate-kit/references/providers.md).
-
-### Native or external?
+Run state and session selections live under the same root. Set `DELEGATE_KIT_HOME` to use another directory. Skill updates leave your presets intact. Preset IDs are case-sensitive; names that differ only by case cannot coexist. Copies are independent, and edits check the previous revision to prevent overwriting another change.
-| Entry | Meaning |
-|---|---|
-| `{ "model": "gpt-6-astra", "effort": "low" }` in the GPT profile | Prefer a native worker in the current family |
-| `{ "runner": "native" }` | Require a native worker and inherit its model; report unavailable native support |
-| `{ "family": "claude", "runner": "claude", "model": "opus" }` | Start an external Claude Code process |
-| `{ "family": "gpt", "runner": "codex", "model": "gpt-6-astra" }` | Start an external Codex CLI process, even from a GPT chat |
-| `{ "family": "glm", "runner": "opencode", "model": "YOUR_PROVIDER/YOUR_GLM_MODEL" }` | Start the configured GLM model through OpenCode |
-
-`family` describes the model; `runner` describes how it executes. Omitted `family` means the coordinator's family. Omitted `runner` means `auto`: use native support when the host has it, otherwise the family's supported CLI. The coordinator checks actual capabilities; same family alone does not prove that a host can spawn workers or set their reasoning effort.
-
-One profile can use any number of families. A request such as “GPT only for this task” restricts the saved team for that task; unavailable or excluded assignments are surfaced for deliberate selection, never silently replaced. [Configuration precedence, capabilities and migration](skills/delegate-kit/references/routing.md).
+## Supported tools and limits
-## When a worker needs help
+CLI adapters are implemented for Codex, Claude Code, Gemini, OpenCode, Pi and OMP. Native Codex/Claude and Paseo use host bridges that require compatible tools in the current environment. The [compatibility table](skills/delegate-kit/references/compatibility.md) separates implemented routes from fixture tests, local protocol checks and real model runs. Support does not mean every route has been tested against a live account.
-The coordinator inspects the result before deciding what to do next:
+The runtime preserves the selected tool, provider, model and reasoning settings. Unsupported combinations fail explicitly. It can resume the same worker session for a specific correction; independent review starts a new session. A timeout alone does not trigger a replacement or change models.
-- **The brief was incomplete or the omission is small:** clarify and continue with the same worker.
-- **The reasoning was insufficient:** choose a stronger configured level and hand a fresh worker the current state, useful changes and remaining checks.
-- **Tools or access are missing:** resolve the environment problem.
+Writers use isolated worktrees, but worktrees are not a security sandbox. Tool restrictions depend on the execution environment. The coordinator checks results before accepting them, and cancelled work keeps its partial changes available for inspection.
-A difficult task may start at a stronger level immediately. Changing the model or effort starts a fresh worker; a resume preserves its original executor. The previous writer must stop and release ownership before a replacement continues. Every repair attempt needs a reason; the ladder is never an automatic retry loop.
+## Reference
-## Parallelism and limits
+- [Setup and preset management](skills/delegate-kit/references/setup.md)
+- [Runtime commands, limits and recovery](skills/delegate-kit/references/routing.md)
+- [CLI execution](skills/delegate-kit/references/external.md)
+- [Native agents and Paseo](skills/delegate-kit/references/hosts.md)
+- [Providers and tool restrictions](skills/delegate-kit/references/providers.md)
+- [Independent review](skills/delegate-kit/references/review.md)
-The coordinator weighs independent work, context transfer, expected quality and checking effort. It uses task evidence and observed progress, without looking up API prices or estimating a token invoice. More workers are useful only while they improve the expected result.
-
-There is no fixed kit-wide worker count. To set your own hard limits, add this optional section beside `profiles`:
-
-```json
-{
- "limits": {
- "max_workers": 6,
- "max_writers": 3,
- "max_runs": 20,
- "max_retries": 2
- }
-}
-```
-
-These numbers are examples, not defaults. `max_workers` and `max_writers` cap known concurrent workers; `max_runs` counts starts and resumes per task; `max_retries` counts repair attempts per ticket. The host's own limits still apply. The coordinator records native starts and resumes in the same lightweight counter that external runs use automatically. Native read-only concurrency remains supervised by the host/coordinator, since it has no worktree lock.
-
-Without configured limits the coordinator still tracks progress and reassesses repeated attempts. The counter records calls, not a monetary budget. Full native token usage may be unavailable. [Counter commands and exact limits](skills/delegate-kit/references/routing.md#task-counters-and-limits).
-
-## Inspect a route without calling a model
-
-From a checkout:
-
-```bash
-skills/delegate-kit/scripts/agent-run doctor
-skills/delegate-kit/scripts/agent-run route --parent codex --role implementer
-skills/delegate-kit/scripts/agent-run route --parent claude --role implementer
-skills/delegate-kit/scripts/agent-run route --parent kimi --role reviewer --author-backend self
-skills/delegate-kit/scripts/agent-run route --parent codex --role implementer --level 2
-```
+For terminal use, invoke `node /path/to/delegate-kit/scripts/dk.mjs help` using the installed skill path. There is no globally installed `dk` command.
-The output identifies the profile, role level, requested model, reasoning and native or external route. `doctor` detects installed CLIs; it does not test authentication or model access. Use `agent-run --help` for run, resume, budget and status commands, and `agent-wt --help` for worktrees and ownership.
+## Upgrading from v1
-## Guarantees and limits
+Ask your assistant to run the migration dry run first. It reports choices that need your input before converting the old configuration to complete presets. Applying the migration creates backups and preserves edited v2 presets. [Migration instructions](skills/delegate-kit/references/migration.md).
-Worktrees prevent competing writers from owning the same checkout; they are not security sandboxes. Adapter permissions differ. Some read-only workers cannot execute shell checks, so the coordinator runs those checks and reports what remains unverified.
+The old execution helpers and optional hooks remain for compatibility. New v2 CLI use does not require installing global hooks or static native roles.
-A fresh reviewer and a different model family can provide useful checks; neither guarantees correctness. The coordinator verifies findings and acceptance criteria. Live Gemini, Kimi and GLM execution has not been validated for this release; adapter tests use fixtures. The historical [seeded review experiment](bench/seeded-review/README.md) is evidence from one setup, not a universal model ranking.
+## Development
-
-Local checks — no model calls
+The installable skill is in `skills/delegate-kit`. Tests live outside it in [`tests`](tests), so they do not travel with the installed skill. Run the local checks without calling a model:
```bash
-bash skills/delegate-kit/tests/route.sh
-bash skills/delegate-kit/tests/caps.sh
-bash skills/delegate-kit/tests/gate.sh
-bash skills/delegate-kit/tests/inspect.sh
-bash skills/delegate-kit/tests/delivery.sh
+bash tests/route.sh
+bash tests/caps.sh
+bash tests/gate.sh
+bash tests/inspect.sh
+bash tests/delivery.sh
```
-
+[Behavioral checks and the opt-in live test](tests/behavioral.md) cover what deterministic tests cannot establish. Live tests require account authorization and are not part of CI.
-## License and acknowledgments
+## License and credits
-MIT. Review lenses and the standards baseline draw from [mattpocock/skills](https://github.com/mattpocock/skills) and [addyosmani/agent-skills](https://github.com/addyosmani/agent-skills). Host dispatch and git coordination were informed by [Hyperskills](https://github.com/hyperb1iss/hyperskills); scoped ownership by [Superpowers](https://github.com/obra/superpowers). The original artwork was inspired by [ponytail](https://github.com/DietrichGebert/ponytail).
+[MIT](LICENSE). Review practices draw from [mattpocock/skills](https://github.com/mattpocock/skills) and [addyosmani/agent-skills](https://github.com/addyosmani/agent-skills). Git coordination was informed by [Hyperskills](https://github.com/hyperb1iss/hyperskills) and [Superpowers](https://github.com/obra/superpowers). Artwork was inspired by [ponytail](https://github.com/DietrichGebert/ponytail).
diff --git a/bench/seeded-review/PLANTED.md b/bench/seeded-review/PLANTED.md
deleted file mode 100644
index 584e42a..0000000
--- a/bench/seeded-review/PLANTED.md
+++ /dev/null
@@ -1,14 +0,0 @@
-# Planted defects
-
-Two per diff. A reviewer "catches" one when a finding names the line and the mechanism.
-
-| Diff | # | Where | Defect | Kind |
-|---|---|---|---|---|
-| 01-pagination | 1 | `totalPages` | `floor(n/size)+1` instead of `ceil`: one page too many when `n` is a multiple of `size` | off-by-one |
-| 01-pagination | 2 | cursor decode | a decodable cursor with a negative, fractional or out-of-range `offset` is accepted; spec demands `InvalidCursor` | missing validation |
-| 02-ratelimit | 3 | `key.toLowerCase()` | keys are opaque and case-sensitive by spec; distinct keys share one counter | spec violation |
-| 02-ratelimit | 4 | `retryAfterMs` | constant `windowMs` instead of time until the oldest hit leaves the window | spec violation |
-| 03-money | 5 | `allocate` | independent `Math.round` per party: parts do not sum to `total` | correctness |
-| 03-money | 6 | `add` | floats and strings accepted; spec demands `TypeError` on non-integers | missing validation |
-| 04-authz | 7 | `isExpired` | `exp` in seconds compared with `Date.now()` in milliseconds | units |
-| 04-authz | 8 | `can` | substring match on the joined roles: `"superadmin"` grants `admin`, `"editor"` grants `edit` | substring match |
diff --git a/bench/seeded-review/README.md b/bench/seeded-review/README.md
deleted file mode 100644
index 80d1f22..0000000
--- a/bench/seeded-review/README.md
+++ /dev/null
@@ -1,20 +0,0 @@
-# Seeded-review bench
-
-Four small diffs, two planted defects each, reviewed by a Claude reviewer and a Codex reviewer through `agent-run`. The question is whether a second family adds findings, and at what cost.
-
-| File | Role |
-|---|---|
-| `diffs/NN-.diff` | the change under review: source, spec, passing tests |
-| `PLANTED.md` | the eight defects and where they sit |
-| `brief.md` | the reviewer brief; `run.sh` fills the paths |
-| `base-*` | the three files of the base commit the diffs apply to |
-| `run.sh` | builds a throwaway repo, launches the eight reviews, collects `result.json` per run |
-| `results//` | raw results and a hand-scored `SCORE.md` |
-
-Run it:
-
-```bash
-bench/seeded-review/run.sh
-```
-
-Eight headless runs of two to three minutes each, in parallel, on your own subscriptions. Both CLIs must be installed and logged in. Score by hand against `PLANTED.md`; test-coverage remarks do not count, since every diff ships with tests that miss its defects by construction.
diff --git a/bench/seeded-review/base-README.md b/bench/seeded-review/base-README.md
deleted file mode 100644
index c271dd2..0000000
--- a/bench/seeded-review/base-README.md
+++ /dev/null
@@ -1,3 +0,0 @@
-# shoplib
-Small shared library for the shop backend. Plain ESM, no dependencies. `npm test` runs node:test.
-Amounts are integers in minor units (cents). Keys and ids are opaque strings.
diff --git a/bench/seeded-review/base-errors.js b/bench/seeded-review/base-errors.js
deleted file mode 100644
index cae2822..0000000
--- a/bench/seeded-review/base-errors.js
+++ /dev/null
@@ -1 +0,0 @@
-export class ShopError extends Error { constructor(code, msg) { super(msg); this.code = code; } }
diff --git a/bench/seeded-review/base-package.json b/bench/seeded-review/base-package.json
deleted file mode 100644
index 6c3b0c9..0000000
--- a/bench/seeded-review/base-package.json
+++ /dev/null
@@ -1 +0,0 @@
-{ "name": "shoplib", "version": "0.1.0", "type": "module", "scripts": { "test": "node --test" } }
diff --git a/bench/seeded-review/brief.md b/bench/seeded-review/brief.md
deleted file mode 100644
index 8d82127..0000000
--- a/bench/seeded-review/brief.md
+++ /dev/null
@@ -1,17 +0,0 @@
-# Task: review the __NAME__ change
-
-## Goal
-Find every defect in this change before it merges: behaviour that contradicts the spec, incorrect logic, and anything that would break a caller.
-
-## Spec
-__WT__/spec/__NAME__.md
-
-## Diff
-__DIFF__ — the complete change (source, spec, tests). The worktree at __WT__ has it checked out; `npm test` runs its tests.
-
-## Constraints
-- Read-only: do not modify files.
-- Scope is the diff. Do not restate it.
-
-## Return
-The delegate-kit result JSON. Return `findings` with severity (`high` | `medium` | `low`), `file`, `line`, `claim`, `evidence`, `suggested_fix`, and `kind` (`spec` | `correctness` | `standards` | `nit`).
diff --git a/bench/seeded-review/diffs/01-pagination.diff b/bench/seeded-review/diffs/01-pagination.diff
deleted file mode 100644
index 4610228..0000000
--- a/bench/seeded-review/diffs/01-pagination.diff
+++ /dev/null
@@ -1,49 +0,0 @@
-diff --git a/spec/pagination.md b/spec/pagination.md
-new file mode 100644
-index 0000000..7e84526
---- /dev/null
-+++ b/spec/pagination.md
-@@ -0,0 +1,7 @@
-+# Cursor pagination
-+- `page(items, {size, cursor})` returns `{items, nextCursor, totalPages}`.
-+- `size` is an integer 1..100; anything else throws ShopError("InvalidSize").
-+- `totalPages` = number of pages needed for `items.length` at `size` (0 items → 0 pages).
-+- `cursor` is base64url of JSON `{"offset": n}`; absent cursor means offset 0.
-+- A cursor that does not decode to an integer offset within `0..items.length` throws ShopError("InvalidCursor").
-+- `nextCursor` is null on the last page.
-diff --git a/src/pagination.js b/src/pagination.js
-new file mode 100644
-index 0000000..a108a1c
---- /dev/null
-+++ b/src/pagination.js
-@@ -0,0 +1,19 @@
-+import { ShopError } from "./errors.js";
-+
-+const enc = (o) => Buffer.from(JSON.stringify(o)).toString("base64url");
-+const dec = (c) => JSON.parse(Buffer.from(c, "base64url").toString("utf8"));
-+
-+export function page(items, { size, cursor } = {}) {
-+ if (!Number.isInteger(size) || size < 1 || size > 100) throw new ShopError("InvalidSize", `size ${size}`);
-+ let offset = 0;
-+ if (cursor != null) {
-+ try { offset = dec(cursor).offset; } catch { throw new ShopError("InvalidCursor", "undecodable"); }
-+ }
-+ const slice = items.slice(offset, offset + size);
-+ const end = offset + size;
-+ return {
-+ items: slice,
-+ nextCursor: end < items.length ? enc({ offset: end }) : null,
-+ totalPages: items.length === 0 ? 0 : Math.floor(items.length / size) + 1,
-+ };
-+}
-diff --git a/test/pagination.test.js b/test/pagination.test.js
-new file mode 100644
-index 0000000..67c8c56
---- /dev/null
-+++ b/test/pagination.test.js
-@@ -0,0 +1,5 @@
-+import test from "node:test"; import assert from "node:assert/strict";
-+import { page } from "../src/pagination.js";
-+test("first page", () => { const r = page([1,2,3,4,5], { size: 2 }); assert.deepEqual(r.items, [1,2]); assert.ok(r.nextCursor); });
-+test("follows cursor", () => { const a = page([1,2,3], { size: 2 }); const b = page([1,2,3], { size: 2, cursor: a.nextCursor }); assert.deepEqual(b.items, [3]); assert.equal(b.nextCursor, null); });
-+test("bad size", () => { assert.throws(() => page([], { size: 0 })); });
diff --git a/bench/seeded-review/diffs/02-ratelimit.diff b/bench/seeded-review/diffs/02-ratelimit.diff
deleted file mode 100644
index 2e891cd..0000000
--- a/bench/seeded-review/diffs/02-ratelimit.diff
+++ /dev/null
@@ -1,97 +0,0 @@
-diff --git a/review/01-pagination.diff b/review/01-pagination.diff
-new file mode 100644
-index 0000000..4610228
---- /dev/null
-+++ b/review/01-pagination.diff
-@@ -0,0 +1,49 @@
-+diff --git a/spec/pagination.md b/spec/pagination.md
-+new file mode 100644
-+index 0000000..7e84526
-+--- /dev/null
-++++ b/spec/pagination.md
-+@@ -0,0 +1,7 @@
-++# Cursor pagination
-++- `page(items, {size, cursor})` returns `{items, nextCursor, totalPages}`.
-++- `size` is an integer 1..100; anything else throws ShopError("InvalidSize").
-++- `totalPages` = number of pages needed for `items.length` at `size` (0 items → 0 pages).
-++- `cursor` is base64url of JSON `{"offset": n}`; absent cursor means offset 0.
-++- A cursor that does not decode to an integer offset within `0..items.length` throws ShopError("InvalidCursor").
-++- `nextCursor` is null on the last page.
-+diff --git a/src/pagination.js b/src/pagination.js
-+new file mode 100644
-+index 0000000..a108a1c
-+--- /dev/null
-++++ b/src/pagination.js
-+@@ -0,0 +1,19 @@
-++import { ShopError } from "./errors.js";
-++
-++const enc = (o) => Buffer.from(JSON.stringify(o)).toString("base64url");
-++const dec = (c) => JSON.parse(Buffer.from(c, "base64url").toString("utf8"));
-++
-++export function page(items, { size, cursor } = {}) {
-++ if (!Number.isInteger(size) || size < 1 || size > 100) throw new ShopError("InvalidSize", `size ${size}`);
-++ let offset = 0;
-++ if (cursor != null) {
-++ try { offset = dec(cursor).offset; } catch { throw new ShopError("InvalidCursor", "undecodable"); }
-++ }
-++ const slice = items.slice(offset, offset + size);
-++ const end = offset + size;
-++ return {
-++ items: slice,
-++ nextCursor: end < items.length ? enc({ offset: end }) : null,
-++ totalPages: items.length === 0 ? 0 : Math.floor(items.length / size) + 1,
-++ };
-++}
-+diff --git a/test/pagination.test.js b/test/pagination.test.js
-+new file mode 100644
-+index 0000000..67c8c56
-+--- /dev/null
-++++ b/test/pagination.test.js
-+@@ -0,0 +1,5 @@
-++import test from "node:test"; import assert from "node:assert/strict";
-++import { page } from "../src/pagination.js";
-++test("first page", () => { const r = page([1,2,3,4,5], { size: 2 }); assert.deepEqual(r.items, [1,2]); assert.ok(r.nextCursor); });
-++test("follows cursor", () => { const a = page([1,2,3], { size: 2 }); const b = page([1,2,3], { size: 2, cursor: a.nextCursor }); assert.deepEqual(b.items, [3]); assert.equal(b.nextCursor, null); });
-++test("bad size", () => { assert.throws(() => page([], { size: 0 })); });
-diff --git a/spec/ratelimit.md b/spec/ratelimit.md
-new file mode 100644
-index 0000000..dc2ae7f
---- /dev/null
-+++ b/spec/ratelimit.md
-@@ -0,0 +1,6 @@
-+# Sliding-window rate limiter
-+- `createLimiter({limit, windowMs})` returns `hit(key, now = Date.now())`.
-+- Keys are opaque, case-sensitive strings; two keys that differ in any character are different keys.
-+- `hit` returns `{allowed: true}` while fewer than `limit` hits fall inside the last `windowMs`.
-+- Otherwise `{allowed: false, retryAfterMs}` where `retryAfterMs` is the time until the OLDEST hit in the window leaves it (so the caller waits the minimum, not the whole window).
-+- Hits outside the window are dropped from memory so state does not grow with time.
-diff --git a/src/ratelimit.js b/src/ratelimit.js
-new file mode 100644
-index 0000000..12a9bfd
---- /dev/null
-+++ b/src/ratelimit.js
-@@ -0,0 +1,14 @@
-+export function createLimiter({ limit, windowMs }) {
-+ const hits = new Map();
-+ return function hit(key, now = Date.now()) {
-+ const k = key.toLowerCase();
-+ const list = (hits.get(k) ?? []).filter((t) => now - t < windowMs);
-+ if (list.length >= limit) {
-+ hits.set(k, list);
-+ return { allowed: false, retryAfterMs: windowMs };
-+ }
-+ list.push(now);
-+ hits.set(k, list);
-+ return { allowed: true };
-+ };
-+}
-diff --git a/test/ratelimit.test.js b/test/ratelimit.test.js
-new file mode 100644
-index 0000000..79f2801
---- /dev/null
-+++ b/test/ratelimit.test.js
-@@ -0,0 +1,4 @@
-+import test from "node:test"; import assert from "node:assert/strict";
-+import { createLimiter } from "../src/ratelimit.js";
-+test("allows up to limit", () => { const h = createLimiter({ limit: 2, windowMs: 1000 }); assert.ok(h("a", 0).allowed); assert.ok(h("a", 1).allowed); assert.equal(h("a", 2).allowed, false); });
-+test("window slides", () => { const h = createLimiter({ limit: 1, windowMs: 1000 }); h("a", 0); assert.ok(h("a", 1000).allowed); });
diff --git a/bench/seeded-review/diffs/03-money.diff b/bench/seeded-review/diffs/03-money.diff
deleted file mode 100644
index d262fb9..0000000
--- a/bench/seeded-review/diffs/03-money.diff
+++ /dev/null
@@ -1,142 +0,0 @@
-diff --git a/review/02-ratelimit.diff b/review/02-ratelimit.diff
-new file mode 100644
-index 0000000..2e891cd
---- /dev/null
-+++ b/review/02-ratelimit.diff
-@@ -0,0 +1,97 @@
-+diff --git a/review/01-pagination.diff b/review/01-pagination.diff
-+new file mode 100644
-+index 0000000..4610228
-+--- /dev/null
-++++ b/review/01-pagination.diff
-+@@ -0,0 +1,49 @@
-++diff --git a/spec/pagination.md b/spec/pagination.md
-++new file mode 100644
-++index 0000000..7e84526
-++--- /dev/null
-+++++ b/spec/pagination.md
-++@@ -0,0 +1,7 @@
-+++# Cursor pagination
-+++- `page(items, {size, cursor})` returns `{items, nextCursor, totalPages}`.
-+++- `size` is an integer 1..100; anything else throws ShopError("InvalidSize").
-+++- `totalPages` = number of pages needed for `items.length` at `size` (0 items → 0 pages).
-+++- `cursor` is base64url of JSON `{"offset": n}`; absent cursor means offset 0.
-+++- A cursor that does not decode to an integer offset within `0..items.length` throws ShopError("InvalidCursor").
-+++- `nextCursor` is null on the last page.
-++diff --git a/src/pagination.js b/src/pagination.js
-++new file mode 100644
-++index 0000000..a108a1c
-++--- /dev/null
-+++++ b/src/pagination.js
-++@@ -0,0 +1,19 @@
-+++import { ShopError } from "./errors.js";
-+++
-+++const enc = (o) => Buffer.from(JSON.stringify(o)).toString("base64url");
-+++const dec = (c) => JSON.parse(Buffer.from(c, "base64url").toString("utf8"));
-+++
-+++export function page(items, { size, cursor } = {}) {
-+++ if (!Number.isInteger(size) || size < 1 || size > 100) throw new ShopError("InvalidSize", `size ${size}`);
-+++ let offset = 0;
-+++ if (cursor != null) {
-+++ try { offset = dec(cursor).offset; } catch { throw new ShopError("InvalidCursor", "undecodable"); }
-+++ }
-+++ const slice = items.slice(offset, offset + size);
-+++ const end = offset + size;
-+++ return {
-+++ items: slice,
-+++ nextCursor: end < items.length ? enc({ offset: end }) : null,
-+++ totalPages: items.length === 0 ? 0 : Math.floor(items.length / size) + 1,
-+++ };
-+++}
-++diff --git a/test/pagination.test.js b/test/pagination.test.js
-++new file mode 100644
-++index 0000000..67c8c56
-++--- /dev/null
-+++++ b/test/pagination.test.js
-++@@ -0,0 +1,5 @@
-+++import test from "node:test"; import assert from "node:assert/strict";
-+++import { page } from "../src/pagination.js";
-+++test("first page", () => { const r = page([1,2,3,4,5], { size: 2 }); assert.deepEqual(r.items, [1,2]); assert.ok(r.nextCursor); });
-+++test("follows cursor", () => { const a = page([1,2,3], { size: 2 }); const b = page([1,2,3], { size: 2, cursor: a.nextCursor }); assert.deepEqual(b.items, [3]); assert.equal(b.nextCursor, null); });
-+++test("bad size", () => { assert.throws(() => page([], { size: 0 })); });
-+diff --git a/spec/ratelimit.md b/spec/ratelimit.md
-+new file mode 100644
-+index 0000000..dc2ae7f
-+--- /dev/null
-++++ b/spec/ratelimit.md
-+@@ -0,0 +1,6 @@
-++# Sliding-window rate limiter
-++- `createLimiter({limit, windowMs})` returns `hit(key, now = Date.now())`.
-++- Keys are opaque, case-sensitive strings; two keys that differ in any character are different keys.
-++- `hit` returns `{allowed: true}` while fewer than `limit` hits fall inside the last `windowMs`.
-++- Otherwise `{allowed: false, retryAfterMs}` where `retryAfterMs` is the time until the OLDEST hit in the window leaves it (so the caller waits the minimum, not the whole window).
-++- Hits outside the window are dropped from memory so state does not grow with time.
-+diff --git a/src/ratelimit.js b/src/ratelimit.js
-+new file mode 100644
-+index 0000000..12a9bfd
-+--- /dev/null
-++++ b/src/ratelimit.js
-+@@ -0,0 +1,14 @@
-++export function createLimiter({ limit, windowMs }) {
-++ const hits = new Map();
-++ return function hit(key, now = Date.now()) {
-++ const k = key.toLowerCase();
-++ const list = (hits.get(k) ?? []).filter((t) => now - t < windowMs);
-++ if (list.length >= limit) {
-++ hits.set(k, list);
-++ return { allowed: false, retryAfterMs: windowMs };
-++ }
-++ list.push(now);
-++ hits.set(k, list);
-++ return { allowed: true };
-++ };
-++}
-+diff --git a/test/ratelimit.test.js b/test/ratelimit.test.js
-+new file mode 100644
-+index 0000000..79f2801
-+--- /dev/null
-++++ b/test/ratelimit.test.js
-+@@ -0,0 +1,4 @@
-++import test from "node:test"; import assert from "node:assert/strict";
-++import { createLimiter } from "../src/ratelimit.js";
-++test("allows up to limit", () => { const h = createLimiter({ limit: 2, windowMs: 1000 }); assert.ok(h("a", 0).allowed); assert.ok(h("a", 1).allowed); assert.equal(h("a", 2).allowed, false); });
-++test("window slides", () => { const h = createLimiter({ limit: 1, windowMs: 1000 }); h("a", 0); assert.ok(h("a", 1000).allowed); });
-diff --git a/spec/money.md b/spec/money.md
-new file mode 100644
-index 0000000..d25dd39
---- /dev/null
-+++ b/spec/money.md
-@@ -0,0 +1,4 @@
-+# Money helpers
-+- All amounts are integers in minor units. `add(a, b)` and `allocate` throw TypeError on any non-integer input (floats, NaN, strings).
-+- `allocate(total, ratios)` splits `total` proportionally to `ratios` (positive integers). The parts MUST sum exactly to `total`: distribute the remainder one unit at a time to the parties in order, never lose or invent a unit.
-+- `allocate` throws RangeError when `ratios` is empty or contains a non-positive value.
-diff --git a/src/money.js b/src/money.js
-new file mode 100644
-index 0000000..69f6427
---- /dev/null
-+++ b/src/money.js
-@@ -0,0 +1,12 @@
-+export function add(a, b) {
-+ if (Number.isNaN(a) || Number.isNaN(b)) throw new TypeError("NaN amount");
-+ return a + b;
-+}
-+
-+export function allocate(total, ratios) {
-+ if (!Number.isInteger(total)) throw new TypeError("total must be an integer");
-+ if (!Array.isArray(ratios) || ratios.length === 0) throw new RangeError("ratios empty");
-+ if (ratios.some((r) => !Number.isInteger(r) || r <= 0)) throw new RangeError("ratio must be a positive integer");
-+ const sum = ratios.reduce((s, r) => s + r, 0);
-+ return ratios.map((r) => Math.round((total * r) / sum));
-+}
-diff --git a/test/money.test.js b/test/money.test.js
-new file mode 100644
-index 0000000..dc55ed2
---- /dev/null
-+++ b/test/money.test.js
-@@ -0,0 +1,5 @@
-+import test from "node:test"; import assert from "node:assert/strict";
-+import { add, allocate } from "../src/money.js";
-+test("add", () => assert.equal(add(150, 250), 400));
-+test("allocate even", () => assert.deepEqual(allocate(100, [1, 1]), [50, 50]));
-+test("allocate rejects empty", () => assert.throws(() => allocate(100, [])));
diff --git a/bench/seeded-review/diffs/04-authz.diff b/bench/seeded-review/diffs/04-authz.diff
deleted file mode 100644
index 7c0c479..0000000
--- a/bench/seeded-review/diffs/04-authz.diff
+++ /dev/null
@@ -1,193 +0,0 @@
-diff --git a/review/03-money.diff b/review/03-money.diff
-new file mode 100644
-index 0000000..d262fb9
---- /dev/null
-+++ b/review/03-money.diff
-@@ -0,0 +1,142 @@
-+diff --git a/review/02-ratelimit.diff b/review/02-ratelimit.diff
-+new file mode 100644
-+index 0000000..2e891cd
-+--- /dev/null
-++++ b/review/02-ratelimit.diff
-+@@ -0,0 +1,97 @@
-++diff --git a/review/01-pagination.diff b/review/01-pagination.diff
-++new file mode 100644
-++index 0000000..4610228
-++--- /dev/null
-+++++ b/review/01-pagination.diff
-++@@ -0,0 +1,49 @@
-+++diff --git a/spec/pagination.md b/spec/pagination.md
-+++new file mode 100644
-+++index 0000000..7e84526
-+++--- /dev/null
-++++++ b/spec/pagination.md
-+++@@ -0,0 +1,7 @@
-++++# Cursor pagination
-++++- `page(items, {size, cursor})` returns `{items, nextCursor, totalPages}`.
-++++- `size` is an integer 1..100; anything else throws ShopError("InvalidSize").
-++++- `totalPages` = number of pages needed for `items.length` at `size` (0 items → 0 pages).
-++++- `cursor` is base64url of JSON `{"offset": n}`; absent cursor means offset 0.
-++++- A cursor that does not decode to an integer offset within `0..items.length` throws ShopError("InvalidCursor").
-++++- `nextCursor` is null on the last page.
-+++diff --git a/src/pagination.js b/src/pagination.js
-+++new file mode 100644
-+++index 0000000..a108a1c
-+++--- /dev/null
-++++++ b/src/pagination.js
-+++@@ -0,0 +1,19 @@
-++++import { ShopError } from "./errors.js";
-++++
-++++const enc = (o) => Buffer.from(JSON.stringify(o)).toString("base64url");
-++++const dec = (c) => JSON.parse(Buffer.from(c, "base64url").toString("utf8"));
-++++
-++++export function page(items, { size, cursor } = {}) {
-++++ if (!Number.isInteger(size) || size < 1 || size > 100) throw new ShopError("InvalidSize", `size ${size}`);
-++++ let offset = 0;
-++++ if (cursor != null) {
-++++ try { offset = dec(cursor).offset; } catch { throw new ShopError("InvalidCursor", "undecodable"); }
-++++ }
-++++ const slice = items.slice(offset, offset + size);
-++++ const end = offset + size;
-++++ return {
-++++ items: slice,
-++++ nextCursor: end < items.length ? enc({ offset: end }) : null,
-++++ totalPages: items.length === 0 ? 0 : Math.floor(items.length / size) + 1,
-++++ };
-++++}
-+++diff --git a/test/pagination.test.js b/test/pagination.test.js
-+++new file mode 100644
-+++index 0000000..67c8c56
-+++--- /dev/null
-++++++ b/test/pagination.test.js
-+++@@ -0,0 +1,5 @@
-++++import test from "node:test"; import assert from "node:assert/strict";
-++++import { page } from "../src/pagination.js";
-++++test("first page", () => { const r = page([1,2,3,4,5], { size: 2 }); assert.deepEqual(r.items, [1,2]); assert.ok(r.nextCursor); });
-++++test("follows cursor", () => { const a = page([1,2,3], { size: 2 }); const b = page([1,2,3], { size: 2, cursor: a.nextCursor }); assert.deepEqual(b.items, [3]); assert.equal(b.nextCursor, null); });
-++++test("bad size", () => { assert.throws(() => page([], { size: 0 })); });
-++diff --git a/spec/ratelimit.md b/spec/ratelimit.md
-++new file mode 100644
-++index 0000000..dc2ae7f
-++--- /dev/null
-+++++ b/spec/ratelimit.md
-++@@ -0,0 +1,6 @@
-+++# Sliding-window rate limiter
-+++- `createLimiter({limit, windowMs})` returns `hit(key, now = Date.now())`.
-+++- Keys are opaque, case-sensitive strings; two keys that differ in any character are different keys.
-+++- `hit` returns `{allowed: true}` while fewer than `limit` hits fall inside the last `windowMs`.
-+++- Otherwise `{allowed: false, retryAfterMs}` where `retryAfterMs` is the time until the OLDEST hit in the window leaves it (so the caller waits the minimum, not the whole window).
-+++- Hits outside the window are dropped from memory so state does not grow with time.
-++diff --git a/src/ratelimit.js b/src/ratelimit.js
-++new file mode 100644
-++index 0000000..12a9bfd
-++--- /dev/null
-+++++ b/src/ratelimit.js
-++@@ -0,0 +1,14 @@
-+++export function createLimiter({ limit, windowMs }) {
-+++ const hits = new Map();
-+++ return function hit(key, now = Date.now()) {
-+++ const k = key.toLowerCase();
-+++ const list = (hits.get(k) ?? []).filter((t) => now - t < windowMs);
-+++ if (list.length >= limit) {
-+++ hits.set(k, list);
-+++ return { allowed: false, retryAfterMs: windowMs };
-+++ }
-+++ list.push(now);
-+++ hits.set(k, list);
-+++ return { allowed: true };
-+++ };
-+++}
-++diff --git a/test/ratelimit.test.js b/test/ratelimit.test.js
-++new file mode 100644
-++index 0000000..79f2801
-++--- /dev/null
-+++++ b/test/ratelimit.test.js
-++@@ -0,0 +1,4 @@
-+++import test from "node:test"; import assert from "node:assert/strict";
-+++import { createLimiter } from "../src/ratelimit.js";
-+++test("allows up to limit", () => { const h = createLimiter({ limit: 2, windowMs: 1000 }); assert.ok(h("a", 0).allowed); assert.ok(h("a", 1).allowed); assert.equal(h("a", 2).allowed, false); });
-+++test("window slides", () => { const h = createLimiter({ limit: 1, windowMs: 1000 }); h("a", 0); assert.ok(h("a", 1000).allowed); });
-+diff --git a/spec/money.md b/spec/money.md
-+new file mode 100644
-+index 0000000..d25dd39
-+--- /dev/null
-++++ b/spec/money.md
-+@@ -0,0 +1,4 @@
-++# Money helpers
-++- All amounts are integers in minor units. `add(a, b)` and `allocate` throw TypeError on any non-integer input (floats, NaN, strings).
-++- `allocate(total, ratios)` splits `total` proportionally to `ratios` (positive integers). The parts MUST sum exactly to `total`: distribute the remainder one unit at a time to the parties in order, never lose or invent a unit.
-++- `allocate` throws RangeError when `ratios` is empty or contains a non-positive value.
-+diff --git a/src/money.js b/src/money.js
-+new file mode 100644
-+index 0000000..69f6427
-+--- /dev/null
-++++ b/src/money.js
-+@@ -0,0 +1,12 @@
-++export function add(a, b) {
-++ if (Number.isNaN(a) || Number.isNaN(b)) throw new TypeError("NaN amount");
-++ return a + b;
-++}
-++
-++export function allocate(total, ratios) {
-++ if (!Number.isInteger(total)) throw new TypeError("total must be an integer");
-++ if (!Array.isArray(ratios) || ratios.length === 0) throw new RangeError("ratios empty");
-++ if (ratios.some((r) => !Number.isInteger(r) || r <= 0)) throw new RangeError("ratio must be a positive integer");
-++ const sum = ratios.reduce((s, r) => s + r, 0);
-++ return ratios.map((r) => Math.round((total * r) / sum));
-++}
-+diff --git a/test/money.test.js b/test/money.test.js
-+new file mode 100644
-+index 0000000..dc55ed2
-+--- /dev/null
-++++ b/test/money.test.js
-+@@ -0,0 +1,5 @@
-++import test from "node:test"; import assert from "node:assert/strict";
-++import { add, allocate } from "../src/money.js";
-++test("add", () => assert.equal(add(150, 250), 400));
-++test("allocate even", () => assert.deepEqual(allocate(100, [1, 1]), [50, 50]));
-++test("allocate rejects empty", () => assert.throws(() => allocate(100, [])));
-diff --git a/spec/authz.md b/spec/authz.md
-new file mode 100644
-index 0000000..26a01ad
---- /dev/null
-+++ b/spec/authz.md
-@@ -0,0 +1,5 @@
-+# Authorization helpers
-+- A token is `{sub: string, exp: number, roles: string[]}`. `exp` is a UNIX timestamp in SECONDS (as in JWT).
-+- `isExpired(token, nowMs = Date.now())` is true once `exp` has passed.
-+- `can(token, role)` is true only when `roles` contains exactly `role`, or contains `"admin"` (admin implies every role).
-+- Both helpers throw TypeError on a malformed token (missing fields, roles not an array).
-diff --git a/src/authz.js b/src/authz.js
-new file mode 100644
-index 0000000..03464bf
---- /dev/null
-+++ b/src/authz.js
-@@ -0,0 +1,15 @@
-+function check(token) {
-+ if (!token || typeof token.sub !== "string" || typeof token.exp !== "number" || !Array.isArray(token.roles))
-+ throw new TypeError("malformed token");
-+}
-+
-+export function isExpired(token, nowMs = Date.now()) {
-+ check(token);
-+ return token.exp < nowMs;
-+}
-+
-+export function can(token, role) {
-+ check(token);
-+ const roles = token.roles.join(",");
-+ return roles.includes("admin") || roles.includes(role);
-+}
-diff --git a/test/authz.test.js b/test/authz.test.js
-new file mode 100644
-index 0000000..9e94445
---- /dev/null
-+++ b/test/authz.test.js
-@@ -0,0 +1,7 @@
-+import test from "node:test"; import assert from "node:assert/strict";
-+import { isExpired, can } from "../src/authz.js";
-+const t = { sub: "u1", exp: 4102444800, roles: ["editor"] };
-+test("editor can edit", () => assert.ok(can(t, "editor")));
-+test("editor cannot delete", () => assert.equal(can(t, "delete"), false));
-+test("admin implies", () => assert.ok(can({ ...t, roles: ["admin"] }, "delete")));
-+test("malformed", () => assert.throws(() => can({}, "x")));
diff --git a/bench/seeded-review/results/2026-09-02/01-claude.json b/bench/seeded-review/results/2026-09-02/01-claude.json
deleted file mode 100644
index 15b347b..0000000
--- a/bench/seeded-review/results/2026-09-02/01-claude.json
+++ /dev/null
@@ -1,60 +0,0 @@
-{
- "status": "done",
- "summary": "Reviewed src/pagination.js and test/pagination.test.js against spec/pagination.md. Two spec-contradicting defects: totalPages uses Math.floor(len/size)+1 instead of ceil, so it over-reports by one whenever items.length is an exact multiple of size (including len==size); and the decoded cursor offset is never validated, so the InvalidCursor rule for out-of-range or non-integer offsets is unimplemented and a crafted cursor produces empty or misaligned pages — with offset -2 the returned nextCursor points back at offset 0, restarting pagination. Tests pass (3/3) but cover neither totalPages nor cursor validation, which is why both defects ship green. All findings were reproduced by executing the module.",
- "changes": [],
- "checks_run": [
- "npm test: 3 passed, 0 failed",
- "node -e (direct calls to page): totalPages off-by-one reproduced for len=4/size=2 (reported 3, actual pages walked 2) and len=2/size=2 (reported 2)",
- "node -e (crafted cursors): offset=-2 -> items [] + nextCursor offset 0 (restarts); offset=99 -> items [] no error; offset=\"1\" and offset=1.5 -> items [2,3] no error; {x:1} missing offset -> items [] no error; garbage cursor -> ShopError InvalidCursor (only case handled)"
- ],
- "not_verified": [
- "Whether any caller outside this diff consumes page() — the diff adds a new module and no other source file in the worktree references it.",
- "Intended behaviour when items is not an array; the spec does not state it and the diff does not validate it."
- ],
- "plan": [],
- "findings": [
- {
- "severity": "high",
- "kind": "spec",
- "lens": "spec",
- "file": "src/pagination.js",
- "line": 17,
- "claim": "totalPages is computed as Math.floor(items.length / size) + 1, which over-reports by one whenever items.length is an exact multiple of size.",
- "evidence": "Spec line 4: \"totalPages = number of pages needed for items.length at size\". Executed: page([1,2,3,4], {size:2}).totalPages === 3, but walking nextCursor yields only 2 pages before nextCursor is null. page([1,2], {size:2}).totalPages === 2 for a single full page. The value also contradicts nextCursor inside the same return object.",
- "suggested_fix": "totalPages: Math.ceil(items.length / size) — this also yields 0 for an empty list, making the items.length === 0 special case unnecessary.",
- "verdict": "confirmed",
- "raised_by": null
- },
- {
- "severity": "high",
- "kind": "spec",
- "lens": "spec",
- "file": "src/pagination.js",
- "line": 10,
- "claim": "The decoded cursor offset is never validated, so the spec's InvalidCursor rule is enforced only for undecodable base64/JSON; a well-formed cursor carrying a negative, out-of-range, fractional, string, or missing offset is accepted and silently produces a wrong page.",
- "evidence": "Spec line 6 requires ShopError(\"InvalidCursor\") for any cursor that does not decode to an integer offset within 0..items.length. The try/catch guards only the decode. Executed against [1,2,3,4,5] with size 2: cursor {offset:-2} returns items [] and nextCursor encoding offset 0, so the caller's next call restarts at [1,2] — an endless page loop instead of an error. Against [1,2,3]: {offset:99} returns items [] with nextCursor null; {offset:\"1\"} and {offset:1.5} both return items [2,3]; {x:1} (offset undefined) returns items [] because slice(undefined, NaN) is empty.",
- "suggested_fix": "Validate the payload before use: const o = dec(cursor).offset; throw new ShopError(\"InvalidCursor\", ...) unless Number.isInteger(o) && o >= 0 && o <= items.length; only then assign offset.",
- "verdict": "confirmed",
- "raised_by": null
- },
- {
- "severity": "medium",
- "kind": "standards",
- "lens": "standards",
- "file": "test/pagination.test.js",
- "line": 47,
- "claim": "The tests assert no totalPages value and no cursor-validation behaviour, so both defects above pass the suite; the single error test also does not assert the error type or code.",
- "evidence": "npm test passes 3/3 with the defects present. The three cases cover first-page items, cursor following on lists whose length is not an exact multiple of size (the arithmetic that happens to be right), and `assert.throws(() => page([], {size: 0}))` with no matcher, which would also pass on an unrelated TypeError.",
- "suggested_fix": "Add totalPages cases for an exact multiple (page([1,2,3,4], {size:2}).totalPages === 2) and empty input (0); add ShopError code \"InvalidCursor\" cases for offsets -1, items.length + 1, and a non-integer offset; pass a matcher to assert.throws, e.g. assert.throws(fn, (e) => e.code === \"InvalidSize\").",
- "verdict": "confirmed",
- "raised_by": null
- }
- ],
- "questions": [],
- "sources": [],
- "next_steps": [
- "Fix totalPages to Math.ceil(items.length / size).",
- "Add explicit offset validation after cursor decode and throw ShopError(\"InvalidCursor\") for anything outside integer 0..items.length.",
- "Extend test/pagination.test.js with the totalPages boundary and InvalidCursor cases so the fixes are locked in."
- ]
-}
diff --git a/bench/seeded-review/results/2026-09-02/01-codex.json b/bench/seeded-review/results/2026-09-02/01-codex.json
deleted file mode 100644
index 947c4c0..0000000
--- a/bench/seeded-review/results/2026-09-02/01-codex.json
+++ /dev/null
@@ -1,53 +0,0 @@
-{
- "status": "done",
- "summary": "Found three specification defects: incorrect page counts for exact multiples, acceptance of invalid decoded offsets, and permissive acceptance of non-base64url cursor strings. The existing test suite passes because it does not cover these boundaries.",
- "changes": [],
- "checks_run": [
- "npm test: 3 tests passed",
- "Targeted Node execution: reproduced totalPages=3 for 4 items at size 2 and acceptance of negative, out-of-range, fractional, and missing offsets",
- "Targeted Node execution: confirmed cursors containing spaces or trailing \"!!!\" are accepted"
- ],
- "not_verified": [],
- "plan": [],
- "findings": [
- {
- "severity": "medium",
- "kind": "spec",
- "lens": "spec",
- "file": "src/pagination.js",
- "line": 10,
- "claim": "Decoded cursor offsets are used without verifying that they are integers within 0..items.length.",
- "evidence": "The spec requires InvalidCursor for any offset outside that range or not an integer. Executed examples showed offsets -1, 4 for a three-item array, 1.5, and a missing offset all returning results instead of throwing.",
- "suggested_fix": "After decoding, require an object with Number.isInteger(decoded.offset) and 0 <= decoded.offset <= items.length; otherwise throw ShopError(\"InvalidCursor\"). Add regression tests for each invalid category and both valid boundaries.",
- "verdict": "confirmed",
- "raised_by": null
- },
- {
- "severity": "medium",
- "kind": "spec",
- "lens": "spec",
- "file": "src/pagination.js",
- "line": 17,
- "claim": "totalPages is one too high whenever items.length is an exact positive multiple of size.",
- "evidence": "The formula floor(items.length / size) + 1 returns 3 for four items at size 2, while the specification requires 2 pages.",
- "suggested_fix": "Use Math.ceil(items.length / size), which also returns 0 for an empty array, and test exact multiples.",
- "verdict": "confirmed",
- "raised_by": null
- },
- {
- "severity": "low",
- "kind": "spec",
- "lens": "spec",
- "file": "src/pagination.js",
- "line": 4,
- "claim": "The decoder accepts strings that are not valid base64url cursors.",
- "evidence": "Buffer.from is permissive: a valid encoded cursor with trailing \"!!!\" or leading whitespace was accepted and produced the first page, despite those characters being outside the base64url alphabet required by the spec.",
- "suggested_fix": "Validate that cursor is a string containing only the accepted base64url syntax before decoding, and throw ShopError(\"InvalidCursor\") otherwise. Add malformed-alphabet tests.",
- "verdict": "confirmed",
- "raised_by": null
- }
- ],
- "questions": [],
- "sources": [],
- "next_steps": []
-}
diff --git a/bench/seeded-review/results/2026-09-02/02-claude.json b/bench/seeded-review/results/2026-09-02/02-claude.json
deleted file mode 100644
index e69ce1a..0000000
--- a/bench/seeded-review/results/2026-09-02/02-claude.json
+++ /dev/null
@@ -1,71 +0,0 @@
-{
- "status": "done",
- "summary": "Reviewed src/ratelimit.js and test/ratelimit.test.js against spec/ratelimit.md. Two high-severity spec violations: keys are lowercased (spec requires case-sensitive keys) and retryAfterMs is always the full windowMs (spec requires the time until the oldest in-window hit expires). One medium memory issue: per-key Map entries are never evicted, so state grows with the number of distinct keys ever seen and never shrinks. Tests pass (2/2) but cover neither broken behaviour, which is why CI is green. The diff file also contains an unrelated pagination change; that was out of scope and not reviewed.",
- "changes": [],
- "checks_run": [
- "npm test: 2 passed, 0 failed",
- "node -e repro of case-folding: h(\"A\",0) and h(\"a\",0) share a bucket, third call denied at limit 2",
- "node -e repro of retryAfterMs: limit 1, windowMs 1000, hit at t=0, denied at t=900 returns retryAfterMs 1000 (expected 100)"
- ],
- "not_verified": [
- "Behaviour when `now` is passed non-monotonically across calls for the same key (the list is assumed sorted ascending; a fix using list[0] as the oldest hit inherits that assumption).",
- "Behaviour for non-positive `limit` or `windowMs` — the spec does not define it and the code does not validate it."
- ],
- "plan": [],
- "findings": [
- {
- "severity": "high",
- "kind": "spec",
- "lens": "spec",
- "file": "src/ratelimit.js",
- "line": 4,
- "claim": "Keys are lowercased, so keys that differ only in case collide and share one counter, contradicting the spec's case-sensitivity requirement.",
- "evidence": "Spec line 3: \"Keys are opaque, case-sensitive strings; two keys that differ in any character are different keys.\" Code: `const k = key.toLowerCase();`. Repro with limit 2, windowMs 1000: h(\"A\",0)={allowed:true}, h(\"a\",0)={allowed:true}, h(\"A\",0)={allowed:false} — two distinct keys consumed one budget. Any caller keying on a case-sensitive identifier (API token, user id, base64 value) has its quota merged with other identifiers, and `key.toLowerCase()` also throws TypeError for a non-string key.",
- "suggested_fix": "Drop the normalisation: use `key` directly as the Map key (`const list = (hits.get(key) ?? []).filter(...)`, `hits.set(key, list)`).",
- "verdict": "confirmed",
- "raised_by": null
- },
- {
- "severity": "high",
- "kind": "spec",
- "lens": "spec",
- "file": "src/ratelimit.js",
- "line": 8,
- "claim": "On denial, retryAfterMs is always the full windowMs instead of the time until the oldest in-window hit expires, so callers wait far longer than necessary.",
- "evidence": "Spec line 5: \"`retryAfterMs` is the time until the OLDEST hit in the window leaves it (so the caller waits the minimum, not the whole window).\" Code returns `{ allowed: false, retryAfterMs: windowMs }`. Repro with limit 1, windowMs 1000: hit at t=0, then hit at t=900 returns retryAfterMs 1000; the oldest hit leaves the window at t=1000, so the correct value is 100. The error is unbounded up to windowMs and is worst exactly when the caller is closest to being allowed.",
- "suggested_fix": "Compute from the oldest retained hit: `return { allowed: false, retryAfterMs: windowMs - (now - list[0]) };` — list is already filtered to in-window hits, so list[0] is the oldest.",
- "verdict": "confirmed",
- "raised_by": null
- },
- {
- "severity": "medium",
- "kind": "correctness",
- "lens": "correctness",
- "file": "src/ratelimit.js",
- "line": 5,
- "claim": "Map entries for idle keys are never removed, so memory grows without bound with the number of distinct keys ever seen and never shrinks.",
- "evidence": "Spec line 6: \"Hits outside the window are dropped from memory so state does not grow with time.\" Expired hits are pruned only on the next call for that same key (the `.filter` inside `hit`), and `hits.set(k, list)` runs unconditionally. A key hit once and never again keeps its array in the Map forever; with per-IP or per-token keys this is a monotonically growing map in a long-running process, which is the growth that bullet exists to prevent.",
- "suggested_fix": "Delete the bucket when nothing remains in the window (`if (list.length === 0) hits.delete(k); else hits.set(k, list);` on the deny/prune path) and/or sweep empty buckets periodically.",
- "verdict": "confirmed",
- "raised_by": null
- },
- {
- "severity": "low",
- "kind": "standards",
- "lens": "standards",
- "file": "test/ratelimit.test.js",
- "line": 3,
- "claim": "Tests assert only the allowed/denied flag; neither retryAfterMs nor key case-sensitivity is covered, which is why both spec violations pass CI.",
- "evidence": "The two tests check `.allowed` for a single key \"a\". `npm test` reports 2 passed with both high-severity defects present. Spec bullets 3 and 5 have no corresponding assertion.",
- "suggested_fix": "Add a test asserting `{limit:1, windowMs:1000}` with hits at 0 and 900 yields `retryAfterMs === 100`, and a test asserting `h(\"A\",0)` and `h(\"a\",0)` do not share a budget.",
- "verdict": "confirmed",
- "raised_by": null
- }
- ],
- "questions": [],
- "sources": [],
- "next_steps": [
- "Fix the two high-severity spec violations (case-folding and retryAfterMs) and add the regression tests named above before merge.",
- "Decide whether idle-key eviction belongs in this change or a follow-up; if deferred, document the unbounded key-count growth in the module."
- ]
-}
diff --git a/bench/seeded-review/results/2026-09-02/02-codex.json b/bench/seeded-review/results/2026-09-02/02-codex.json
deleted file mode 100644
index 5a042ca..0000000
--- a/bench/seeded-review/results/2026-09-02/02-codex.json
+++ /dev/null
@@ -1,55 +0,0 @@
-{
- "status": "done",
- "summary": "Found three confirmed spec defects. Key normalization merges distinct callers, retry timing is overstated, and expired state can remain indefinitely. The existing test suite passes but does not cover these requirements.",
- "changes": [],
- "checks_run": [
- "npm test: 2 tests passed",
- "Case-sensitivity probe: \"A\" was allowed, then distinct key \"a\" was incorrectly denied",
- "Retry probe: returned 1000ms where the oldest hit required 800ms",
- "Stale-state probe: expired key \"stale\" and timestamp 0 remained after a hit at time 1001",
- "git status --short && git diff --check: exited 0 with no worktree changes or diff errors; emitted sandbox-related xcrun cache warnings"
- ],
- "not_verified": [],
- "plan": [],
- "findings": [
- {
- "severity": "high",
- "kind": "spec",
- "lens": "spec",
- "file": "src/ratelimit.js",
- "line": 4,
- "claim": "Lowercasing keys violates the required opaque, case-sensitive key identity and lets traffic for one key consume another key's quota.",
- "evidence": "The spec says keys differing in any character are distinct. A focused probe with limit 1 allowed \"A\" at time 0 but denied \"a\" at time 1 because both mapped to \"a\".",
- "suggested_fix": "Use the original key as the Map key without normalization, and add a regression test proving \"A\" and \"a\" have independent limits.",
- "verdict": "confirmed",
- "raised_by": null
- },
- {
- "severity": "medium",
- "kind": "spec",
- "lens": "spec",
- "file": "src/ratelimit.js",
- "line": 8,
- "claim": "A rejected hit always receives the full window duration instead of the minimum delay until the oldest active hit expires.",
- "evidence": "With windowMs 1000, accepted hits at 0 and 100, and a rejected hit at 200, the oldest hit expires after 800ms; the implementation returned retryAfterMs 1000.",
- "suggested_fix": "Return `list[0] + windowMs - now` and add a regression test using staggered timestamps.",
- "verdict": "confirmed",
- "raised_by": null
- },
- {
- "severity": "medium",
- "kind": "spec",
- "lens": "spec",
- "file": "src/ratelimit.js",
- "line": 5,
- "claim": "Expired hits are pruned only when their own key is used again, so one-time keys and their timestamp arrays remain in the Map indefinitely and state grows with unique keys.",
- "evidence": "All reads and writes target only `k`. After recording \"stale\" at time 0 and hitting \"fresh\" at time 1001 with a 1000ms window, instrumentation showed both keys remained and \"stale\" still stored timestamp 0.",
- "suggested_fix": "Expire entries across stored keys as time advances and delete keys whose active-hit list becomes empty; add a regression test that inspects retained state through an appropriate test seam.",
- "verdict": "confirmed",
- "raised_by": null
- }
- ],
- "questions": [],
- "sources": [],
- "next_steps": []
-}
diff --git a/bench/seeded-review/results/2026-09-02/03-claude.json b/bench/seeded-review/results/2026-09-02/03-claude.json
deleted file mode 100644
index 8e47b4b..0000000
--- a/bench/seeded-review/results/2026-09-02/03-claude.json
+++ /dev/null
@@ -1,84 +0,0 @@
-{
- "status": "done",
- "summary": "Reviewed src/money.js, test/money.test.js and spec/money.md from the frozen diff. Both exported functions violate the spec: allocate uses independent Math.round per party, so the returned parts do not sum to total (loses a unit on allocate(100,[1,1,1]) = 99, invents two on allocate(10,[1,1,1,1]) = 12), and add validates only NaN, so floats and strings pass through (add(1.5,2)=3.5, add(\"1\",\"2\")=\"12\"). Two lower findings cover the wrong error class for non-integer ratios and test coverage that never exercises the remainder invariant. All three shipped tests pass, which is the problem: they only test the even-split case.",
- "changes": [],
- "checks_run": [
- "npm test: 3 passed, 0 failed (does not exercise any defect found)",
- "node -e ad-hoc harness against src/money.js: reproduced allocate sum drift, allocate over-allocation, add float/string pass-through, RangeError on non-integer ratio"
- ],
- "not_verified": [
- "Whether any caller outside this diff already depends on the current (incorrect) allocate rounding — src/ contains only errors.js and money.js, so there is no in-repo caller to check.",
- "Intended behaviour for negative total (spec does not state it); allocate(-100,[1,1,1]) returns [-33,-33,-33], sum -99, but that is a consequence of finding 1, not an independent rule.",
- "Intended behaviour above Number.MAX_SAFE_INTEGER; allocate(1e18,[1,1,1]) returns parts summing to 999999999999999900."
- ],
- "plan": [],
- "findings": [
- {
- "severity": "high",
- "kind": "spec",
- "lens": "spec",
- "file": "src/money.js",
- "line": 11,
- "claim": "allocate rounds each part independently, so the returned parts do not sum to total — it both loses and invents units, which the spec forbids explicitly.",
- "evidence": "spec/money.md:3 requires \"The parts MUST sum exactly to `total`: distribute the remainder one unit at a time to the parties in order, never lose or invent a unit.\" Line 11 is `ratios.map((r) => Math.round((total * r) / sum))` with no remainder pass. Verified by running the shipped module: allocate(100,[1,1,1]) → [33,33,33], sum 99 (one minor unit lost); allocate(10,[1,1,1,1]) → [3,3,3,3], sum 12 (two minor units invented); allocate(5,[1,1,1,1,1,1]) → [1,1,1,1,1,1], sum 6.",
- "suggested_fix": "Floor each share, then hand out the leftover `total - sum(floors)` one unit at a time to the parties in index order: `const shares = ratios.map(r => Math.floor(total * r / sum)); let rem = total - shares.reduce((s,x)=>s+x,0); for (let i = 0; rem > 0; i = (i+1) % shares.length, rem--) shares[i]++; return shares;`",
- "verdict": "confirmed",
- "raised_by": null
- },
- {
- "severity": "high",
- "kind": "spec",
- "lens": "spec",
- "file": "src/money.js",
- "line": 2,
- "claim": "add accepts floats, strings and null instead of throwing TypeError, so non-integer money silently enters the system and string arguments are concatenated rather than added.",
- "evidence": "spec/money.md:2 requires \"`add(a, b)` and `allocate` throw TypeError on any non-integer input (floats, NaN, strings)\". Line 2 checks only `Number.isNaN`, which does not coerce and is false for strings. Verified: add(1.5, 2) → 3.5; add(\"1\",\"2\") → \"12\" (string concatenation, not addition); add(null, 5) → 5. Note allocate at line 7 does apply the correct `Number.isInteger` check on `total`, so the two exported functions disagree on validation.",
- "suggested_fix": "Replace the NaN check with the same guard allocate uses: `if (!Number.isInteger(a) || !Number.isInteger(b)) throw new TypeError(\"amounts must be integers\");` — Number.isInteger already rejects NaN, floats and strings.",
- "verdict": "confirmed",
- "raised_by": null
- },
- {
- "severity": "medium",
- "kind": "spec",
- "lens": "spec",
- "file": "src/money.js",
- "line": 9,
- "claim": "A non-integer ratio throws RangeError where the spec calls for TypeError, so a caller catching TypeError for bad-type input misses it.",
- "evidence": "spec/money.md:2 assigns non-integer input to TypeError; spec/money.md:4 assigns RangeError only to \"`ratios` is empty or contains a non-positive value\". Line 9 collapses both conditions into one RangeError. Verified: allocate(100,[1.5,1]) throws RangeError(\"ratio must be a positive integer\"), not TypeError.",
- "suggested_fix": "Split the predicate: throw TypeError when `!Number.isInteger(r)`, and RangeError only when `r <= 0`.",
- "verdict": "confirmed",
- "raised_by": null
- },
- {
- "severity": "medium",
- "kind": "correctness",
- "lens": "correctness",
- "file": "test/money.test.js",
- "line": 4,
- "claim": "The tests pass while both spec violations are present: no case exercises the remainder path or any type rejection, so the suite gives no signal on the module's two central rules.",
- "evidence": "npm test reports 3 passed. The only allocate success case is allocate(100,[1,1]) → [50,50], which divides evenly and never reaches the remainder branch; the only throw case is the empty array. No test asserts that parts sum to total, and no test calls add with a float, string or NaN. Both high findings above are invisible to this suite.",
- "suggested_fix": "Add an uneven split asserting the exact array and the sum (allocate(100,[1,1,1]) → [34,33,33]), an over-allocation guard (allocate(10,[1,1,1,1]) → sum 10), a ratio-order case with unequal ratios, and throw cases for add(1.5,2), add(\"1\",\"2\") and add(NaN,1) asserting TypeError.",
- "verdict": "confirmed",
- "raised_by": null
- },
- {
- "severity": "low",
- "kind": "standards",
- "lens": "standards",
- "file": "src/money.js",
- "line": 8,
- "claim": "A non-array `ratios` is reported as RangeError(\"ratios empty\"), which is both the wrong error class for a type problem and a message that misdescribes the input.",
- "evidence": "Verified: allocate(100, \"abc\") throws RangeError with message \"ratios empty\" even though the argument is a non-empty string. The spec reserves RangeError for empty or non-positive ratios.",
- "suggested_fix": "Separate the guards: `if (!Array.isArray(ratios)) throw new TypeError(\"ratios must be an array\");` before the `length === 0` RangeError.",
- "verdict": "confirmed",
- "raised_by": null
- }
- ],
- "questions": [],
- "sources": [],
- "next_steps": [
- "Fix the two high findings before merge: they are the module's stated invariants and money is lost or created on every uneven split.",
- "Decide the intended error class for a non-integer ratio (spec bullets 2 and 4 overlap) and align spec/money.md with the implementation once chosen.",
- "Extend test/money.test.js so the remainder invariant and the type rejections are covered; the current suite would not have caught either high finding."
- ]
-}
diff --git a/bench/seeded-review/results/2026-09-02/03-codex.json b/bench/seeded-review/results/2026-09-02/03-codex.json
deleted file mode 100644
index 0f49ed3..0000000
--- a/bench/seeded-review/results/2026-09-02/03-codex.json
+++ /dev/null
@@ -1,70 +0,0 @@
-{
- "status": "done",
- "summary": "Found four confirmed defects: allocation can lose or invent money, invalid add operands are accepted, non-integer ratios receive the wrong error type, and sparse ratio arrays bypass validation. The existing tests pass but do not exercise these contract requirements.",
- "changes": [],
- "checks_run": [
- "npm test: 3 tests passed.",
- "Focused node probes: confirmed add accepts floats, strings, and Infinity; non-integer ratios throw RangeError; uneven allocations do not preserve totals.",
- "Sparse-array probe: allocate(10, Array(1)) returned a one-element sparse result whose sum is 0.",
- "git status --short: could not complete because the read-only sandbox prevented xcrun from creating a temporary cache."
- ],
- "not_verified": [
- "Working-tree cleanliness was not confirmed; the review used the supplied frozen diff and checked-out files."
- ],
- "plan": [],
- "findings": [
- {
- "severity": "high",
- "kind": "spec",
- "lens": null,
- "file": "src/money.js",
- "line": 11,
- "claim": "Rounding every proportional share independently violates the mandatory exact-total and ordered-remainder behavior.",
- "evidence": "The spec requires parts to sum exactly to total and assigns remainder units in party order. allocate(1, [1, 1]) returns [1, 1], summing to 2; allocate(10, [1, 1, 1]) returns [3, 3, 3], summing to 9 instead of the ordered result [4, 3, 3].",
- "suggested_fix": "Calculate base shares, determine the signed difference from total, and adjust parties in order one unit at a time until the parts sum exactly to total. Add regression tests for both lost and invented units, including negative totals if supported.",
- "verdict": "confirmed",
- "raised_by": null
- },
- {
- "severity": "medium",
- "kind": "spec",
- "lens": null,
- "file": "src/money.js",
- "line": 2,
- "claim": "add does not throw TypeError for most non-integer operands.",
- "evidence": "The spec explicitly covers floats, NaN, and strings, but the guard only rejects NaN. add(1.5, 2) returns 3.5, add(\"1\", 2) returns \"12\", and add(Infinity, 2) returns Infinity.",
- "suggested_fix": "Validate both operands with Number.isInteger before performing addition, and add tests for floats, strings, NaN, Infinity, and missing operands.",
- "verdict": "confirmed",
- "raised_by": null
- },
- {
- "severity": "medium",
- "kind": "spec",
- "lens": null,
- "file": "src/money.js",
- "line": 9,
- "claim": "allocate throws RangeError rather than TypeError for non-integer ratio values.",
- "evidence": "The spec requires TypeError for any non-integer input and reserves RangeError for empty ratios or non-positive values. allocate(10, [1.5, 1]) and allocate(10, [\"1\", 1]) both throw RangeError.",
- "suggested_fix": "Separate ratio validation: throw TypeError when an entry is not an integer, then throw RangeError when an integer entry is non-positive. Test the exact error classes.",
- "verdict": "confirmed",
- "raised_by": null
- },
- {
- "severity": "low",
- "kind": "correctness",
- "lens": null,
- "file": "src/money.js",
- "line": 9,
- "claim": "Sparse ratio arrays bypass validation and produce invalid sparse allocations.",
- "evidence": "Array.prototype.some, reduce, and map skip holes. allocate(10, Array(1)) returns a sparse array of length 1 whose numeric sum is 0, even though every ratio must be a positive integer and the result must sum to 10.",
- "suggested_fix": "Validate every numeric index explicitly, treating missing entries as invalid non-integer ratios, and add a sparse-array regression test.",
- "verdict": "confirmed",
- "raised_by": null
- }
- ],
- "questions": [],
- "sources": [],
- "next_steps": [
- "Fix the four confirmed defects and add regression tests for each contract boundary before merge."
- ]
-}
diff --git a/bench/seeded-review/results/2026-09-02/04-claude.json b/bench/seeded-review/results/2026-09-02/04-claude.json
deleted file mode 100644
index a540a89..0000000
--- a/bench/seeded-review/results/2026-09-02/04-claude.json
+++ /dev/null
@@ -1,96 +0,0 @@
-{
- "status": "done",
- "summary": "Reviewed src/authz.js, spec/authz.md and test/authz.test.js against spec/authz.md. Two high-severity defects: isExpired compares an exp in seconds against a now in milliseconds, so every realistic token reports as expired; and can() matches roles by substring on a joined string, which both grants unrelated roles (role \"editor\" satisfies can(t,\"edit\")) and treats any role containing \"admin\" (e.g. \"nonadmin\", \"superadmin\") as full admin. Three lower findings cover NaN exp passing the malformed check, the \">= vs >\" expiry boundary, and the test file having zero coverage of isExpired, which is why the unit bug ships green. All four existing tests pass (npm test), so the suite does not catch any of this.",
- "changes": [],
- "checks_run": [
- "npm test: 4 passed, 0 failed (suite is green despite the defects)",
- "node -e repro of isExpired({exp: 4102444800}) -> true (token valid until year 2100 reported expired)",
- "node -e repro of can({roles:['editor']}, 'edit') -> true, can({roles:['nonadmin']}, 'delete') -> true, can({roles:['editor']}, '') -> true"
- ],
- "not_verified": [
- "Whether any caller outside this diff already compensates for the seconds/milliseconds mismatch by passing nowMs in seconds — no callers of isExpired exist in the repo yet.",
- "Intended boundary semantics at exactly exp (spec says 'once exp has passed' without defining the instant)."
- ],
- "plan": [],
- "findings": [
- {
- "severity": "high",
- "kind": "spec",
- "lens": "correctness",
- "file": "src/authz.js",
- "line": 8,
- "claim": "isExpired compares exp (UNIX seconds) directly to nowMs (milliseconds), so every token with a realistic exp is reported expired.",
- "evidence": "Spec: \"exp is a UNIX timestamp in SECONDS\" and the parameter is named nowMs. Line 8 is `return token.exp < nowMs;`. Verified: isExpired({sub:'u',exp:4102444800,roles:[]}) -> true with Date.now() = 1788305052784, i.e. a token valid until the year 2100 is treated as expired. The condition only becomes correct around the year 2286. No test exercises isExpired, so npm test stays green.",
- "suggested_fix": "Convert before comparing: `return token.exp * 1000 <= nowMs;` and add tests for a future exp (false), a past exp (true) and the boundary.",
- "verdict": "confirmed",
- "raised_by": null
- },
- {
- "severity": "high",
- "kind": "correctness",
- "lens": "correctness",
- "file": "src/authz.js",
- "line": 14,
- "claim": "can() tests role membership with String.includes on the comma-joined roles, so a substring of a granted role authorises an ungranted role.",
- "evidence": "Spec: \"can(token, role) is true only when roles contains exactly role\". Lines 13-14 join roles into a string and call `roles.includes(role)`. Verified: can({roles:['editor']}, 'edit') -> true, and can({roles:['editor']}, '') -> true. A role like 'r,s' also matches a token holding ['r','s'] because the separator is part of the searched string.",
- "suggested_fix": "Match array elements, not substrings: `return token.roles.includes('admin') || token.roles.includes(role);` and drop the join.",
- "verdict": "confirmed",
- "raised_by": null
- },
- {
- "severity": "high",
- "kind": "correctness",
- "lens": "correctness",
- "file": "src/authz.js",
- "line": 14,
- "claim": "The admin check is also a substring test, so any role whose name merely contains \"admin\" grants every permission — privilege escalation from a deliberately non-privileged role name.",
- "evidence": "Spec: admin implies every role only when roles \"contains 'admin'\". Line 14 is `roles.includes(\"admin\") || ...` against the joined string. Verified: can({roles:['nonadmin']}, 'delete') -> true and can({roles:['superadmin']}, 'delete') -> true. A role named 'nonadmin' or 'admin-readonly' therefore confers full admin.",
- "suggested_fix": "Use exact array membership for the admin bypass: `token.roles.includes('admin')`.",
- "verdict": "confirmed",
- "raised_by": null
- },
- {
- "severity": "medium",
- "kind": "spec",
- "lens": "correctness",
- "file": "src/authz.js",
- "line": 2,
- "claim": "check() accepts NaN as exp because typeof NaN === 'number', so a malformed token passes validation instead of throwing TypeError.",
- "evidence": "Spec: \"Both helpers throw TypeError on a malformed token\". Verified: can({sub:'a',exp:NaN,roles:[]},'x') returns false rather than throwing; isExpired on the same token would return false (NaN comparison), silently treating an unusable token as live.",
- "suggested_fix": "Require a finite number: `!Number.isFinite(token.exp)` in place of the `typeof token.exp !== \"number\"` check.",
- "verdict": "confirmed",
- "raised_by": null
- },
- {
- "severity": "low",
- "kind": "spec",
- "lens": "correctness",
- "file": "src/authz.js",
- "line": 8,
- "claim": "Expiry boundary uses strict `<`, so a token is still valid at exactly exp; the spec's \"true once exp has passed\" is ambiguous at that instant and should be pinned down.",
- "evidence": "Line 8: `token.exp < nowMs`. JWT convention (RFC 7519 exp: \"MUST NOT be accepted on or after\") treats exp itself as expired, which argues for `<=` after the unit fix.",
- "suggested_fix": "Adopt the JWT convention (`token.exp * 1000 <= nowMs`), state it in spec/authz.md, and cover it with a test at exactly exp.",
- "verdict": "confirmed",
- "raised_by": null
- },
- {
- "severity": "medium",
- "kind": "standards",
- "lens": "standards",
- "file": "test/authz.test.js",
- "line": 2,
- "claim": "The test file imports isExpired but never tests it, so the seconds/milliseconds defect ships with a green suite.",
- "evidence": "test/authz.test.js line 2 imports { isExpired, can }; the four tests (lines 4-7) only call can(). npm test reports 4 pass / 0 fail while isExpired is wrong for every realistic input. The can() tests also miss the substring cases ('edit' vs 'editor', 'nonadmin').",
- "suggested_fix": "Add isExpired tests (future exp false, past exp true, boundary) and negative can() tests for a role that is a prefix of a granted role and for a role name containing 'admin'.",
- "verdict": "confirmed",
- "raised_by": null
- }
- ],
- "questions": [],
- "sources": [],
- "next_steps": [
- "Fix the unit conversion in isExpired and replace the joined-string matching in can() with array membership.",
- "Decide and document the boundary semantics at exactly exp in spec/authz.md.",
- "Extend test/authz.test.js with the isExpired cases and the substring/admin-lookalike negative cases before re-running npm test."
- ]
-}
diff --git a/bench/seeded-review/results/2026-09-02/04-codex.json b/bench/seeded-review/results/2026-09-02/04-codex.json
deleted file mode 100644
index 027aad4..0000000
--- a/bench/seeded-review/results/2026-09-02/04-codex.json
+++ /dev/null
@@ -1,55 +0,0 @@
-{
- "status": "done",
- "summary": "Found three merge-blocking authz defects: timestamp units are mixed, role checks use substring matching, and malformed token contents bypass validation. The existing test suite passes all 4 tests but does not exercise these cases.",
- "changes": [],
- "checks_run": [
- "npm test: 4 passed, 0 failed.",
- "Read-only Node probe: reproduced futureExpired=true, substringRole=true, superadminAll=true, and acceptance of non-string roles and NaN expiration.",
- "Inspected the frozen 193-line diff, authz spec, implementation, tests, package configuration, and repository file list."
- ],
- "not_verified": [],
- "plan": [],
- "findings": [
- {
- "severity": "high",
- "kind": "spec",
- "lens": "spec",
- "file": "src/authz.js",
- "line": 8,
- "claim": "isExpired compares a seconds-based expiration directly with a millisecond timestamp, causing valid JWT-style tokens to be reported as expired.",
- "evidence": "The spec defines exp in seconds and nowMs in milliseconds. With exp=4102444800 and nowMs=1700000000000, isExpired returns true even though the token expires in 2100.",
- "suggested_fix": "Compare nowMs against token.exp * 1000 and expire at the boundary, for example `return nowMs >= token.exp * 1000`; add tests before, at, and after expiration.",
- "verdict": "confirmed",
- "raised_by": null
- },
- {
- "severity": "high",
- "kind": "spec",
- "lens": "spec",
- "file": "src/authz.js",
- "line": 14,
- "claim": "can performs substring matching instead of exact role membership, allowing unauthorized roles to satisfy requested permissions and allowing names containing \"admin\" to imply every role.",
- "evidence": "Joining roles and calling String.includes makes roles=[\"editorial\"] authorize \"editor\" and roles=[\"superadmin\"] authorize \"delete\". Both were reproduced by the read-only probe and contradict the exact-match requirement.",
- "suggested_fix": "Use `token.roles.includes(\"admin\") || token.roles.includes(role)` directly and add regression tests for partial role names, admin-containing names, empty roles, and exact admin.",
- "verdict": "confirmed",
- "raised_by": null
- },
- {
- "severity": "medium",
- "kind": "spec",
- "lens": "spec",
- "file": "src/authz.js",
- "line": 2,
- "claim": "Token validation accepts malformed role elements and non-finite expiration values, so both helpers can return normally instead of throwing TypeError for values that do not satisfy the declared token shape.",
- "evidence": "The validator only checks Array.isArray and `typeof exp === \"number\"`. A token with roles=[42] was accepted by both helpers, and exp=NaN was accepted by isExpired without throwing; neither value is a valid roles:string[] or UNIX timestamp.",
- "suggested_fix": "Require a finite numeric expiration and validate every array entry as a string, including sparse entries, before either helper proceeds; add malformed-token tests for both exported helpers.",
- "verdict": "confirmed",
- "raised_by": null
- }
- ],
- "questions": [],
- "sources": [],
- "next_steps": [
- "Correct all three findings and add focused regression tests before merge."
- ]
-}
diff --git a/bench/seeded-review/results/2026-09-02/SCORE.md b/bench/seeded-review/results/2026-09-02/SCORE.md
deleted file mode 100644
index 81607a4..0000000
--- a/bench/seeded-review/results/2026-09-02/SCORE.md
+++ /dev/null
@@ -1,54 +0,0 @@
-# Score — 2026-09-02
-
-Reviewers: Claude `opus` effort `high` via `claude -p`; Codex `gpt-5.6-sol` effort `high` via `codex exec`. Same brief and spec, no hints about the planted list. Raw output per run is the `NN-.json` beside this file. Scored by hand.
-
-## Planted defects (see `../../PLANTED.md`)
-
-| # | Defect | Claude | Codex |
-|---|---|---|---|
-| 1 | `totalPages` off by one on exact multiples | ✓ high | ✓ medium |
-| 2 | decoded cursor offset not validated | ✓ high | ✓ medium |
-| 3 | keys lowercased, spec says case-sensitive | ✓ high | ✓ high |
-| 4 | `retryAfterMs` constant instead of until-oldest-expires | ✓ high | ✓ medium |
-| 5 | `allocate` parts do not sum to `total` | ✓ high | ✓ high |
-| 6 | `add` accepts non-integers | ✓ high | ✓ medium |
-| 7 | `exp` seconds vs `Date.now()` milliseconds | ✓ high | ✓ high |
-| 8 | role check by substring | ✓ high (two findings) | ✓ high |
-| | **caught** | **8 / 8** | **8 / 8** |
-
-## Findings beyond the planted list
-
-Test-coverage remarks ("the suite does not exercise this") are excluded; they are true of every diff by construction.
-
-| Diff | Finding | Claude | Codex |
-|---|---|---|---|
-| 01 | decoder accepts strings that are not valid base64url | | ✓ low |
-| 02 | idle keys are never pruned from the Map, memory grows with distinct keys | ✓ medium | ✓ medium |
-| 03 | non-integer ratio throws `RangeError`, spec says `TypeError` | ✓ medium | ✓ medium |
-| 03 | non-array `ratios` reported as "ratios empty" | ✓ low | |
-| 03 | sparse ratio arrays bypass validation | | ✓ low |
-| 04 | `NaN` / non-finite `exp` passes the shape check | ✓ medium | ✓ medium |
-| 04 | expiry boundary at exactly `exp` is ambiguous in the spec | ✓ low | |
-| | **distinct** | **5** | **5** |
-| | **union** | | **7** |
-
-## Wall-clock
-
-| Run | Backend | Started (UTC) | Finished | Duration |
-|---|---|---|---|---|
-| 01 | claude | 23:23:18 | 23:24:51 | 1 m 33 s |
-| 01 | codex | 23:23:36 | 23:25:41 | 2 m 05 s |
-| 02 | claude | 23:23:37 | 23:25:02 | 1 m 25 s |
-| 02 | codex | 23:23:37 | 23:26:09 | 2 m 32 s |
-| 03 | claude | 23:23:37 | 23:25:14 | 1 m 37 s |
-| 03 | codex | 23:23:37 | 23:26:01 | 2 m 24 s |
-| 04 | claude | 23:23:38 | 23:24:51 | 1 m 13 s |
-| 04 | codex | 23:23:59 | 23:26:32 | 2 m 33 s |
-
-All eight ran concurrently. One machine-wide cap refusal happened on launch because a ninth reviewer from another session was already running; `DELEGATE_KIT_MAX_WORKERS=9` admitted the last one.
-
-## What this does and does not show
-
-- Both families catch a planted set of eight textbook defects in small, well-specified diffs. The experiment does not demonstrate family-specific blind spots; a larger and subtler corpus would be needed for that.
-- The families diverge in what they find beyond the list. The union is larger than either side, which is the practical argument for a second reviewer of the other family on a diff that matters.
-- n = 4 diffs, 8 runs, one day. Treat the numbers as a bound, not a benchmark.
diff --git a/bench/seeded-review/run.sh b/bench/seeded-review/run.sh
deleted file mode 100755
index 4c748ed..0000000
--- a/bench/seeded-review/run.sh
+++ /dev/null
@@ -1,39 +0,0 @@
-#!/bin/bash
-# Seeded-review bench: four small diffs, two planted defects each, reviewed by a
-# Claude reviewer and a Codex reviewer through agent-run. Eight headless runs,
-# roughly two to three minutes each, on your own subscriptions.
-#
-# bench/seeded-review/run.sh [OUT_DIR] default OUT_DIR = results/
-#
-# Scoring is by hand: compare each result.json against PLANTED.md.
-set -euo pipefail
-HERE="$(cd "$(dirname "$0")" && pwd)"
-AR="$HERE/../../skills/delegate-kit/scripts/agent-run"
-OUT="${1:-$HERE/results/$(date +%F)}"
-WORK="${TMPDIR:-/tmp}/dk-seeded-review.$$"
-mkdir -p "$OUT" "$WORK/repo"
-cd "$WORK/repo"
-git init -q -b main
-mkdir -p src
-cp "$HERE/base-package.json" package.json; cp "$HERE/base-README.md" README.md; cp "$HERE/base-errors.js" src/errors.js
-git add -A && git -c user.name=bench -c user.email=bench@local commit -qm base
-export DELEGATE_KIT_MAX_WORKERS="${DELEGATE_KIT_MAX_WORKERS:-8}"
-ids=()
-for diff in "$HERE"/diffs/*.diff; do
- n="$(basename "$diff" .diff)"; name="${n#*-}"
- git checkout -q main && git checkout -qb "$n" && git apply "$diff" && git add -A && git -c user.name=bench -c user.email=bench@local commit -qm "$n"
- git checkout -q main
- wt="$WORK/wt/$n"; git worktree add -q "$wt" "$n"
- sed -e "s#__NAME__#$name#g" -e "s#__WT__#$wt#g" -e "s#__DIFF__#$diff#g" "$HERE/brief.md" > "$WORK/$n.brief.md"
- for backend in claude codex; do
- id="$(node "$AR" run --role reviewer --backend "$backend" --cwd "$wt" --brief "$WORK/$n.brief.md" --detach --timeout 25 s+=d).on("end",()=>console.log(JSON.parse(s).id))')"
- ids+=("$n $backend $id"); echo "started $n $backend $id"
- done
-done
-for entry in "${ids[@]}"; do
- set -- $entry
- node "$AR" wait "$3" --timeout 30 /dev/null || true
- cp "$HOME/.delegate-kit/runs/$3/result.json" "$OUT/$1-$2.json" 2>/dev/null || echo "no result for $1 $2 ($3)"
- echo "$1 $2: $(node -e 'const r=require(process.argv[1]);console.log(r.findings.length+" findings")' "$OUT/$1-$2.json" 2>/dev/null || echo failed)"
-done
-echo "results in $OUT"
diff --git a/skills/delegate-kit/SKILL.md b/skills/delegate-kit/SKILL.md
index 0d23d2e..d65fb4d 100644
--- a/skills/delegate-kit/SKILL.md
+++ b/skills/delegate-kit/SKILL.md
@@ -1,75 +1,51 @@
---
name: delegate-kit
-description: Coordinate repository work through scoped researchers, planners, implementers and fresh reviewers. Use for features, refactors, implementation plans, delegation and second opinions. Select role ladders for the current coordinator and mix supported native and external workers.
+description: Coordinate repository work with a user-selected team preset, scoped specialists and independent review. Use when asked to use Delegate Kit, configure or copy a team, delegate substantial work, or obtain a second opinion. Small understood tasks can stay in the current chat.
license: MIT
---
-# delegate-kit
+# Delegate Kit
-The current chat is the coordinator. Own user intent, decomposition, briefs, acceptance, integration and reporting. Delegate bounded outcomes to workers; delegation depth is one.
+The current chat remains the coordinator. A preset assigns helpers and describes when to use them; it never selects or changes the chat model. Apply the same coordination policy with every model and host. Managed delegation depth is one.
-## 1. Decide what benefits from delegation
+## Establish the team
-Compare a worker's useful independent work and potential parallel progress with briefing, repeated context, verification and likely repairs. Use task knowledge and observed results; pricing searches and hypothetical token bills are unnecessary. File count alone does not determine task size.
+For `start`, first setup, creating/copying/editing a preset or targeted help, read [setup.md](references/setup.md). Conversation is the user interface: offer the bundled `main` example for first setup, gather the missing choices, show the proposed team/change, then save within the user's authorization. A valid saved preset does not require another interview.
-| Shape | Use when |
-|---|---|
-| DIRECT | Completing the work in existing context costs less than briefing and checking a worker; or the action must stay with the coordinator under the user's permissions |
-| SCOUT | Locating facts, relevant code or primary sources is a bounded independent outcome |
-| PLAN | A separate planner can resolve meaningful ambiguity, interacting constraints or decomposition; skip a redundant plan |
-| SINGLE | One substantial, specified outcome has one writer |
-| PARALLEL | Ready tasks have independent outcomes, disjoint write scopes and stable interfaces |
-| SEQUENTIAL | One result changes the next task's assumptions |
+Find `scripts/dk.mjs` relative to this installed skill; invoke it with Node. `delegate-kit Y2` is a skill request, not a promised global shell command. For runtime commands and precedence, read [routing.md](references/routing.md).
-State the shape and reason for substantial work. Choose worker count from ready outcomes and integration capacity, within actual host limits and explicit user limits. Reassess after results; neither a fixed number of workers nor maximum fan-out is a target. Coupled edits have one owner.
+Open a context using a reliable namespaced host chat ID, or retain the generated session handle. Explicit preset wins, then the saved chat selection, then the default for a new chat. An explicit selection persists in this chat; “only this task” uses `--task-only` on its prepare calls. Unknown IDs fail visibly. Preserve session/run handles across compaction and handoff. Never infer a team from the coordinator's family, cwd or another chat. Without configuration, offer setup before delegating; a trivial direct task can proceed.
-## 2. Select the coordinator's role profile
+Read only the active catalog. Honor an explicit profile; otherwise choose by the meaning of its `when`, then an applicable role default. Consider direct work or missing configuration when no profile fits. Additional researchers, UI specialists and reviewers are ordinary named profiles in the same preset. Descriptions guide judgment; keywords, diff size, model rankings and price algorithms do not select profiles.
-Before dispatch, read `references/routing.md`. Personal assignments live in `~/.delegate-kit/config.json`: `profiles.gpt`, `profiles.claude`, `profiles.kimi`, or another declared coordinator family. The profile selects workers; it never replaces the chat's model. An explicit session instruction overrides saved choices.
+## Choose useful work
-Each role is an ordered ladder: level 1 is the usual choice; later levels are permitted alternatives for harder work. Use the appropriate level immediately when risk or ambiguity justifies it. Choose models from the user's configured ladder and actual host/provider capabilities, not vendor rankings. With no assignment, inherit the current model and disclose unavailable choices.
+Clarify the requested result, constraints and acceptance checks. Consider substantial independent outcomes and useful specialists explicitly. Choose direct work, one worker, parallel independent outcomes or dependent steps. A separate planner is useful for consequential uncertainty; it is optional. State the shape briefly for substantial work.
-Resolve each selected role and level:
+Parallel writers need disjoint ownership and stable interfaces. Coupled changes have one owner or proceed sequentially. Choose worker count from ready outcomes and integration capacity; there is no default cap of one. Honor explicit user and host limits. Do independent work while workers run; avoid duplicating their investigation.
-```
-agent-run route --parent codex --role implementer --level 1
-agent-run route --parent codex --role reviewer --level 2 --author-backend self
-```
+## Prepare and dispatch
-Native means the host can launch that worker. External means a supported CLI executes it. Check the actual tool schema, model identifiers and supported effort. `doctor` only detects installed CLIs. A configured target that is unavailable stays visible; choose another authorized candidate deliberately. Native dispatch: `references/hosts.md`. Before a new external CLI/model combination, read `references/providers.md`, installed help and `references/external.md`.
+Use [brief-template.md](references/brief-template.md): outcome, necessary facts, scope, workspace, constraints, checks and authorized finishing actions. Workers should begin from the brief and repository without the parent's full conversation. `when` and coordination instructions belong to you; profile `instructions` and the brief go to the worker.
-## 3. Brief and dispatch
+For CLI execution read [external.md](references/external.md); for native or Paseo bridges read [hosts.md](references/hosts.md). Check [providers.md](references/providers.md) when choosing a new executor/version or after a capability error. Installed binaries do not establish authorization. Preserve harness, provider, model, reasoning and access exactly, or surface a specific refusal. A CLI running a similar model is not permission to replace the harness.
-For work spanning several outcomes, record scope, dependencies, status and acceptance checks in existing tickets or `.scratch//`. Ready tasks have accepted dependencies. Preserve unfinished plans. A planner returns decomposition; the coordinator accepts or revises it before assigning work.
+Prepare with the saved session, stable task ID, selected profile and brief. The runtime snapshots configuration, reserves limits and returns run IDs. An `also_run` review set is reserved together: dispatch every returned run with fresh independent context. Each local writer needs a linked Git worktree; `agent-wt create` supplies it and prepare acquires ownership. Paseo workspaces remain owned by Paseo. Preserve relevant uncommitted work before branching.
-Use `references/brief-template.md`: outcome, constraints, ownership and acceptance commands. Give enough detail to remove consequential ambiguity while leaving local implementation choices to the worker. A stranger with only the brief and repository must be able to begin.
+`run` starts a CLI supervisor or returns the verified native/Paseo invocation. A bridge invocation is preparation, not an agent: call the actual host tool once and attach its returned ID. If dispatch outcome is uncertain, reconcile with the host before another call. Record correlated completion/permission events. Unique Claude native definitions must be discovered by that host; otherwise use CLI. Never rewrite a shared role when switching presets.
-Every writer gets an isolated worktree and one owner. `agent-wt create ` branches from HEAD; account for relevant uncommitted changes first. Native writers need `agent-wt lock ` and the absolute path. External writers take the lock through `--cwd`. Preserve other people's edits.
+## Await and verify results
-Set a shared task ID for native and external runs. Track starts and retries with the lightweight budget counter described in `references/routing.md`; include each native dispatch and resume. External `run`/`resume` records its own start. Review total starts, retries and useful progress before another wave. Explicit user limits are hard; otherwise the coordinator decides whether the next call remains worthwhile.
+Use host completion notifications or runtime `wait`. Read the compact result by default; full private logs are diagnostic artifacts. No log-summarizer model or periodic LLM heartbeat is needed. After each bounded wait, check runtime health or query the saved host agent without sending a prompt. A wait timeout does not stop the worker or authorize a duplicate. An attention alert requires diagnosis of process/turn progress; continue waiting only with a concrete reason, or stop/recover a confirmed stall. See [lifecycle and recovery](references/routing.md#lifecycle-and-recovery). Intervene for a blocker, permission request, user correction, explicit failure, breached limit or data risk.
-## 4. Accept, clarify or strengthen
+Transport acknowledgement, terminal turn, valid result and coordinator acceptance are separate. Compare evidence against acceptance checks, run relevant checks the adapter could not perform, and accept only completed work. Explain unverified claims. Runtime `accept` refuses incomplete required review sets; it records your judgment, not proof that tests passed.
-Inspect the returned evidence and run the relevant acceptance checks. A worker's done status does not establish completion. Workers return `references/result-schema.json`; report checks excluded by their adapter and run them in an authorized workspace.
+For a bounded omission, resume the saved run and launch that new attempt: the same exact executor session and snapshot remain. Changing profile/model/harness or seeking independent judgment requires a fresh session. Before replacing a writer, stop the previous one, inspect partial changes and transfer ownership. A failed attempt never triggers hidden model/provider fallback. Limits count reservations and continuations, not status/wait calls.
-- Missing context, an imprecise brief or a bounded oversight: clarify and resume the same worker when its context remains useful.
-- Insufficient reasoning or repeated substantive mistakes: choose a stronger configured level and start a fresh worker with the task, prior result, current diff and remaining checks.
-- Missing tools, access or environment: address that obstacle; a stronger model does not supply access.
+## Review, integrate and report
-A ladder is not an automatic retry loop. Reassess expected benefit before every retry; stop or report a blocker when another attempt is unlikely to help. Before replacing a writer, inspect partial work and ensure the previous writer has stopped and released ownership. Resume preserves model/effort; a changed choice is a fresh run.
+Use a fresh read-only configured reviewer for substantial delegated implementation and risky changes. Trivial direct actions do not require a review ceremony. Select coverage by actual contracts and failure modes; [review.md](references/review.md) explains lenses and reconciliation. Two reviewers receive the same frozen spec/diff and no initial findings from each other. Family diversity is optional and does not prove correctness.
-## 5. Review and integrate
+Reproduce disputed findings with commands. Resume for a specific correction; use another configured specialist only for a substantive reason. Integrate and perform finishing actions within user authorization. Keep worktrees and logs until useful changes are preserved; cancellation never deletes partial work.
-Review delegated implementation, risk-zone changes and substantial coordinator-written changes with a fresh read-only agent given the frozen diff and spec. Fresh context is required in every family. Family diversity is a separate choice; configured reviewer assignments govern it.
-
-Choose review reasoning for contract complexity and risk, not only diff length. One reviewer is sufficient when it covers the risk; complementary reviewers may run independently when useful within user limits. `references/review.md` defines lenses, proposals and reconciliation. Reviewers do not receive each other's findings before reporting.
-
-Reproduce disputed findings with commands first. Use a verifier for unresolved judgement. Clarify or strengthen the fix worker as in step 4. After behavior-changing fixes, rerun affected checks and resume the reviewer with new hunks and finding dispositions; use a fresh brief if resume is unavailable.
-
-Integrate and publish within user authorization and repository conventions. Mark tickets accepted only after checks pass. Release/remove worktrees after preserving accepted changes.
-
-## 6. Report and hand off
-
-Report changed behavior, actual checks, remaining limits, selected profile/role levels and native versus external execution. Confirm actual model only from runtime evidence; otherwise distinguish requested model from unknown identity. Report fresh context separately from family diversity. For tickets, close with completed X of Y and the next unfinished item.
-
-For ownership transfer, write `.scratch/handoff/-.md` with state, decisions, blockers and pointers to specs, tickets and diffs. Include task counter ID and remaining user limits. No secrets.
+Report changed behavior, actual checks, remaining gaps, selected preset/profiles and transports. Distinguish requested model from runtime-confirmed identity, and fixture tests from live execution. On handoff retain session/run IDs, workspaces, task/remaining limits, spec and result pointers. For old configuration/runs read [migration.md](references/migration.md); new dispatch never uses family-selected v1 routing.
diff --git a/skills/delegate-kit/assets/preset.schema.json b/skills/delegate-kit/assets/preset.schema.json
new file mode 100644
index 0000000..98f871e
--- /dev/null
+++ b/skills/delegate-kit/assets/preset.schema.json
@@ -0,0 +1,202 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "title": "Delegate Kit v2 complete team preset",
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "schema_version": {
+ "const": 2
+ },
+ "id": {
+ "type": "string",
+ "pattern": "^[A-Za-z0-9][A-Za-z0-9_-]{0,79}$"
+ },
+ "name": {
+ "type": "string",
+ "minLength": 1
+ },
+ "description": {
+ "type": "string",
+ "minLength": 1
+ },
+ "coordination": {
+ "type": "string",
+ "minLength": 1
+ },
+ "defaults": {
+ "type": "object",
+ "propertyNames": {
+ "type": "string",
+ "pattern": "^[A-Za-z0-9][A-Za-z0-9_-]{0,79}$"
+ },
+ "additionalProperties": {
+ "type": "string",
+ "pattern": "^[A-Za-z0-9][A-Za-z0-9_-]{0,79}$"
+ }
+ },
+ "agents": {
+ "type": "object",
+ "minProperties": 1,
+ "propertyNames": {
+ "type": "string",
+ "pattern": "^[A-Za-z0-9][A-Za-z0-9_-]{0,79}$"
+ },
+ "additionalProperties": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "role": {
+ "type": "string",
+ "pattern": "^[A-Za-z0-9][A-Za-z0-9_-]{0,79}$"
+ },
+ "when": {
+ "type": "string",
+ "minLength": 1
+ },
+ "instructions": {
+ "type": "string",
+ "minLength": 1
+ },
+ "executor": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "harness": {
+ "enum": [
+ "codex",
+ "claude",
+ "gemini",
+ "opencode",
+ "pi",
+ "omp"
+ ]
+ },
+ "model": {
+ "type": "string",
+ "minLength": 1
+ },
+ "provider": {
+ "type": "string",
+ "minLength": 1
+ },
+ "reasoning": {
+ "type": "string",
+ "minLength": 1
+ },
+ "transport": {
+ "enum": [
+ "auto",
+ "native",
+ "cli",
+ "paseo"
+ ]
+ },
+ "inherit_model": {
+ "const": true
+ }
+ },
+ "required": [
+ "harness"
+ ],
+ "oneOf": [
+ {
+ "required": [
+ "model"
+ ],
+ "not": {
+ "required": [
+ "inherit_model"
+ ]
+ }
+ },
+ {
+ "required": [
+ "inherit_model",
+ "transport"
+ ],
+ "properties": {
+ "transport": {
+ "const": "native"
+ }
+ },
+ "not": {
+ "anyOf": [
+ {
+ "required": [
+ "model"
+ ]
+ },
+ {
+ "required": [
+ "provider"
+ ]
+ }
+ ]
+ }
+ }
+ ]
+ },
+ "access": {
+ "enum": [
+ "read-only",
+ "workspace-write"
+ ]
+ },
+ "review": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "also_run": {
+ "type": "array",
+ "minItems": 1,
+ "uniqueItems": true,
+ "items": {
+ "type": "string",
+ "pattern": "^[A-Za-z0-9][A-Za-z0-9_-]{0,79}$"
+ }
+ },
+ "independent": {
+ "const": true
+ }
+ },
+ "required": [
+ "also_run"
+ ]
+ }
+ },
+ "required": [
+ "role",
+ "when",
+ "executor"
+ ]
+ }
+ },
+ "limits": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "max_runs": {
+ "type": "integer",
+ "minimum": 1
+ },
+ "max_retries": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "max_workers": {
+ "type": "integer",
+ "minimum": 1
+ },
+ "max_writers": {
+ "type": "integer",
+ "minimum": 1
+ }
+ }
+ }
+ },
+ "required": [
+ "schema_version",
+ "id",
+ "agents"
+ ]
+}
diff --git a/skills/delegate-kit/examples/config.json b/skills/delegate-kit/examples/config.json
index 685409b..6fc9e07 100644
--- a/skills/delegate-kit/examples/config.json
+++ b/skills/delegate-kit/examples/config.json
@@ -1,56 +1,43 @@
{
- "profiles": {
- "gpt": {
- "roles": {
- "researcher": [
- { "model": "gpt-5.6-luna", "effort": "high" },
- { "model": "gpt-6-astra", "effort": "low" }
- ],
- "planner": [
- { "family": "claude", "runner": "claude", "model": "fable", "effort": "high" }
- ],
- "implementer": [
- { "model": "gpt-6-astra", "effort": "low" },
- { "model": "gpt-6-astra", "effort": "high" }
- ],
- "reviewer": [
- { "family": "claude", "runner": "claude", "model": "opus", "effort": "high" },
- { "family": "claude", "runner": "claude", "model": "fable", "effort": "high" }
- ]
+ "schema_version": 2,
+ "id": "X1",
+ "name": "My team",
+ "defaults": {
+ "researcher": "research-general",
+ "implementer": "build-general",
+ "reviewer": "review-general"
+ },
+ "agents": {
+ "research-general": {
+ "role": "researcher",
+ "when": "Bounded repository and primary-source research.",
+ "executor": {
+ "harness": "codex",
+ "model": "REPLACE_WITH_VERIFIED_MODEL_ID"
}
},
- "claude": {
- "roles": {
- "researcher": [
- { "model": "sonnet", "effort": "medium" },
- { "model": "opus", "effort": "high" }
- ],
- "planner": [
- { "model": "fable", "effort": "high" }
- ],
- "implementer": [
- { "model": "opus", "effort": "medium" },
- { "model": "opus", "effort": "high" }
- ],
- "reviewer": [
- { "family": "gpt", "runner": "codex", "model": "gpt-6-astra", "effort": "high" }
- ]
+ "build-general": {
+ "role": "implementer",
+ "when": "Ordinary technical implementation with understood requirements.",
+ "executor": {
+ "harness": "codex",
+ "model": "REPLACE_WITH_VERIFIED_MODEL_ID"
}
},
- "kimi": {
- "roles": {
- "researcher": [
- { "runner": "native" }
- ],
- "planner": [
- { "family": "gpt", "runner": "codex", "model": "gpt-6-astra", "effort": "high" }
- ],
- "implementer": [
- { "runner": "native" }
- ],
- "reviewer": [
- { "family": "glm", "runner": "opencode", "model": "YOUR_PROVIDER/YOUR_GLM_MODEL" }
- ]
+ "build-ui": {
+ "role": "implementer",
+ "when": "Components, layout, styling and visual behavior.",
+ "executor": {
+ "harness": "claude",
+ "model": "REPLACE_WITH_VERIFIED_MODEL_ID"
+ }
+ },
+ "review-general": {
+ "role": "reviewer",
+ "when": "Independent checking against the frozen specification and changes.",
+ "executor": {
+ "harness": "codex",
+ "model": "REPLACE_WITH_VERIFIED_MODEL_ID"
}
}
}
diff --git a/skills/delegate-kit/examples/legacy-config.json b/skills/delegate-kit/examples/legacy-config.json
new file mode 100644
index 0000000..685409b
--- /dev/null
+++ b/skills/delegate-kit/examples/legacy-config.json
@@ -0,0 +1,57 @@
+{
+ "profiles": {
+ "gpt": {
+ "roles": {
+ "researcher": [
+ { "model": "gpt-5.6-luna", "effort": "high" },
+ { "model": "gpt-6-astra", "effort": "low" }
+ ],
+ "planner": [
+ { "family": "claude", "runner": "claude", "model": "fable", "effort": "high" }
+ ],
+ "implementer": [
+ { "model": "gpt-6-astra", "effort": "low" },
+ { "model": "gpt-6-astra", "effort": "high" }
+ ],
+ "reviewer": [
+ { "family": "claude", "runner": "claude", "model": "opus", "effort": "high" },
+ { "family": "claude", "runner": "claude", "model": "fable", "effort": "high" }
+ ]
+ }
+ },
+ "claude": {
+ "roles": {
+ "researcher": [
+ { "model": "sonnet", "effort": "medium" },
+ { "model": "opus", "effort": "high" }
+ ],
+ "planner": [
+ { "model": "fable", "effort": "high" }
+ ],
+ "implementer": [
+ { "model": "opus", "effort": "medium" },
+ { "model": "opus", "effort": "high" }
+ ],
+ "reviewer": [
+ { "family": "gpt", "runner": "codex", "model": "gpt-6-astra", "effort": "high" }
+ ]
+ }
+ },
+ "kimi": {
+ "roles": {
+ "researcher": [
+ { "runner": "native" }
+ ],
+ "planner": [
+ { "family": "gpt", "runner": "codex", "model": "gpt-6-astra", "effort": "high" }
+ ],
+ "implementer": [
+ { "runner": "native" }
+ ],
+ "reviewer": [
+ { "family": "glm", "runner": "opencode", "model": "YOUR_PROVIDER/YOUR_GLM_MODEL" }
+ ]
+ }
+ }
+ }
+}
diff --git a/skills/delegate-kit/examples/main.json b/skills/delegate-kit/examples/main.json
new file mode 100644
index 0000000..c808819
--- /dev/null
+++ b/skills/delegate-kit/examples/main.json
@@ -0,0 +1,100 @@
+{
+ "schema_version": 2,
+ "id": "main",
+ "name": "Main team",
+ "description": "Research, planning, implementation and independent review, with specialists for complex investigations and UI work.",
+ "coordination": "Use researcher for bounded lookups and researcher-hard for investigations with uncertain causes or interacting failures. They are alternatives, not mandatory consecutive steps. Use planner when the approach, dependencies, risks or acceptance checks need to be worked out before implementation. Assign ordinary implementation to implementer and substantial interface work to implementer-ui. Choose reviewer for ordinary changes and reviewer-hard when the cost of failure or impact is high. Reviewers receive the specification and frozen diff without the author conclusions. The coordinator chooses the next step from the evidence; not every task needs every role.",
+ "defaults": {
+ "researcher": "researcher",
+ "planner": "planner",
+ "implementer": "implementer",
+ "reviewer": "reviewer"
+ },
+ "agents": {
+ "researcher": {
+ "role": "researcher",
+ "when": "A bounded lookup: find relevant code, trace a value, identify inputs, existing tests or relevant documentation.",
+ "instructions": "Investigate the assigned question. Return concrete files, symbols, data flows and existing tests with evidence. Keep files unchanged and stay within scope.",
+ "executor": {
+ "harness": "codex",
+ "model": "gpt-5.6-luna",
+ "reasoning": "medium",
+ "transport": "cli"
+ },
+ "access": "read-only"
+ },
+ "researcher-hard": {
+ "role": "researcher",
+ "when": "An investigation across code and external sources: intermittent failures, duplicate charges, repeated requests, idempotency, races, integration failures or error handling.",
+ "instructions": "Build a testable causal explanation. Inspect code, tests, documentation and primary external sources when needed. Check concurrency, retries, transaction boundaries, idempotency and failure paths where relevant. Keep files unchanged. Separate confirmed facts from hypotheses and explain how to resolve remaining uncertainty.",
+ "executor": {
+ "harness": "codex",
+ "model": "gpt-5.6-sol",
+ "reasoning": "medium",
+ "transport": "cli"
+ },
+ "access": "read-only"
+ },
+ "planner": {
+ "role": "planner",
+ "when": "The approach, sequence, ownership, dependencies, risks or acceptance checks need to be decided before implementation.",
+ "instructions": "Create an actionable plan grounded in the repository. Specify ordered steps, ownership, dependencies, risks, acceptance criteria and concrete checks. Separate blocking questions from optional choices. Keep files unchanged.",
+ "executor": {
+ "harness": "codex",
+ "model": "gpt-6-astra",
+ "reasoning": "low",
+ "transport": "cli"
+ },
+ "access": "read-only"
+ },
+ "implementer": {
+ "role": "implementer",
+ "when": "Ordinary features, bug fixes, tests and documentation with a clear outcome and scope.",
+ "instructions": "Implement the requested outcome with the smallest complete change in the assigned worktree. Follow project patterns and preserve others' changes. Add a regression test for a fixed bug and run relevant checks.",
+ "executor": {
+ "harness": "codex",
+ "model": "gpt-5.6-sol",
+ "reasoning": "medium",
+ "transport": "cli"
+ },
+ "access": "workspace-write"
+ },
+ "implementer-ui": {
+ "role": "implementer",
+ "when": "Design or substantially change an interface, components, interactions, responsive behavior or visual styling.",
+ "instructions": "Implement the interface in the assigned worktree using the project components and design system. Verify the affected user flow in the running UI and inspect the rendered result for overlap, clipping and responsive issues. If the executor lacks browser or shell tools, report the exact checks for the coordinator to perform before acceptance. Preserve others' changes.",
+ "executor": {
+ "harness": "omp",
+ "provider": "openrouter",
+ "model": "qwen/qwen3.8-max",
+ "reasoning": "medium",
+ "transport": "cli"
+ },
+ "access": "workspace-write"
+ },
+ "reviewer": {
+ "role": "reviewer",
+ "when": "Independent review of an ordinary change at completion or a checkpoint: requirements, correctness and regression risk.",
+ "instructions": "Review the frozen diff in a fresh context against the specification and repository standards. Treat the author conclusions as claims, not evidence. Report concrete, reproducible problems with location, scenario and impact. Keep files unchanged.",
+ "executor": {
+ "harness": "codex",
+ "model": "gpt-5.6-sol",
+ "reasoning": "medium",
+ "transport": "cli"
+ },
+ "access": "read-only"
+ },
+ "reviewer-hard": {
+ "role": "reviewer",
+ "when": "Changes with a high cost of failure or broad consequences: payments, authorization, concurrency, migrations, public contracts, security or critical integrations.",
+ "instructions": "Review the frozen diff independently in a fresh context. Check requirements, invariants, negative and concurrent scenarios, compatibility, error recovery and test coverage. Give concrete evidence and impact for each finding. Keep files unchanged.",
+ "executor": {
+ "harness": "codex",
+ "model": "gpt-6-astra",
+ "reasoning": "high",
+ "transport": "cli"
+ },
+ "access": "read-only"
+ }
+ }
+}
diff --git a/skills/delegate-kit/hooks/gate.sh b/skills/delegate-kit/hooks/gate.sh
index eefd600..f0af772 100755
--- a/skills/delegate-kit/hooks/gate.sh
+++ b/skills/delegate-kit/hooks/gate.sh
@@ -21,7 +21,7 @@ CMD=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null ||
# a worktree lock from there is denied outright — no confirmation prefix reopens it, so this
# check sits before the confirmation bypass below.
AGENT=$(printf '%s' "$INPUT" | jq -r '.agent_type // empty' 2>/dev/null || true)
-if [ -n "$AGENT" ] && printf '%s' "$CMD" | grep -Eq '(^|[;&|(`[:space:]/"'"'"'])(agent-run["'"'"']?[[:space:]]+(run|resume)|agent-wt["'"'"']?[[:space:]]+lock)([[:space:]]|$)'; then
+if [ -n "$AGENT" ] && printf '%s' "$CMD" | grep -Eq '(^|[;&|(`[:space:]/"'"'"'])(agent-run["'"'"']?[[:space:]]+(run|resume)|dk\.mjs["'"'"']?[[:space:]]+(prepare|run|resume)|agent-wt["'"'"']?[[:space:]]+lock)([[:space:]]|$)'; then
msg="delegate-kit gate: delegation depth is 1 — a worker ($AGENT) does not start workers or take worktree locks. Return what you have; the coordinator dispatches."
jq -cn --arg m "$msg" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:$m}}'
exit 0
diff --git a/skills/delegate-kit/hooks/install.sh b/skills/delegate-kit/hooks/install.sh
index 0a7a673..e95765e 100755
--- a/skills/delegate-kit/hooks/install.sh
+++ b/skills/delegate-kit/hooks/install.sh
@@ -57,8 +57,10 @@ link_claude_agents() { # symlink the role definitions so edits in the repo take
local name; name=$(basename "$src"); local dst="$dir/$name"
if [ -L "$dst" ] && [ "$(readlink "$dst")" = "$src" ]; then echo "$dst: already linked"; continue; fi
if [ -e "$dst" ] && [ ! -L "$dst" ]; then
- echo "$dst exists and is not our symlink"
- [ $DRY -eq 1 ] || { cp "$dst" "$dst.bak-delegate-kit-$TS"; echo " backed up to $dst.bak-delegate-kit-$TS"; }
+ echo "refusing to replace unmanaged $dst" >&2; return 1
+ fi
+ if [ -L "$dst" ]; then
+ case "$(readlink "$dst")" in */delegate-kit/agents/dk-*.md) ;; *) echo "refusing to replace unmanaged symlink $dst" >&2; return 1;; esac
fi
echo "link $dst -> $src"
[ $DRY -eq 1 ] || ln -sfn "$src" "$dst"
diff --git a/skills/delegate-kit/references/brief-template.md b/skills/delegate-kit/references/brief-template.md
index 551de20..ed082c5 100644
--- a/skills/delegate-kit/references/brief-template.md
+++ b/skills/delegate-kit/references/brief-template.md
@@ -1,59 +1,23 @@
-# Brief template
+# Self-contained briefs
-Include the shared task ID and ticket ID for start/retry accounting. Keep it under ~40 lines. The worker reads the code itself; your job is to remove ambiguity, not to narrate the repository.
+Give a worker the intended outcome, necessary facts/files, constraints, workspace ownership, acceptance checks and authorized finishing actions. Keep unrelated transcripts and logs out. The runtime adds profile instructions and the canonical result contract; `when` is only for the coordinator.
-The same brief serves both dispatch paths: `--brief` for an external `agent-run` worker, or the prompt body for a native subagent. Do not restate the role in it — the role preamble comes from `agent-run` or from the installed `dk-*` definition. Stage spec/diff files within the permitted directory, or include their contents in the brief, when the adapter denies external-directory reads. Shell checks excluded by the adapter belong to the coordinator.
-
-A native writer needs one extra line the external one gets from `--cwd`: **the absolute worktree path it may touch.**
-
-```markdown
-# Task:
-
-## Goal
-
-
-## Spec
-/spec.md, or 3-8 bullet requirements>
-
-## Ticket
-/issues/NN-slug.md when the slice has one — the brief points at it, it does not restate it>
-
-## Acceptance criteria
--
--
-
-## Where to look
-- —
-- —
-
-## Constraints
-- Follow the repo's AGENTS.md/CLAUDE.md. Do not touch: .
-- No new dependencies without stating why.
--
-
-## Worktree (native writers only)
-Work only inside ``. Run git as `git -C ...`.
-
-## Return
-The delegate-kit result JSON. If anything is ambiguous, return `status: blocked` with precise `questions` instead of guessing.
+```
+Task:
+Goal:
+Specification:
+Relevant context:
+Workspace:
+Ownership:
+Constraints:
+Acceptance:
+Finishing actions:
```
-## Role-specific additions
-
-**planner**: "Do not change files. Return `plan` as ordered steps with the files each step touches, `questions` split into blocking and non-blocking, and the checks that prove completion."
-
-**reviewer**: "Read-only. The diff is at ``; the spec at ``. Return `findings` with severity (`high` | `medium` | `low`), `file`, `line`, `claim`, `evidence`, `suggested_fix`, and `kind` (`spec` | `correctness` | `standards` | `nit`). Findings only — the diff is already known, and scope is the diff."
-
-**reviewer on a panel** (add to the above): "Your lens is `` — its definition is in `references/review.md`; for `standards`, the smell baseline there applies under the repo's own rules. The lens is your priority, not your boundary: report a high-severity problem outside it too. Concentrate on ``; skip ``. Set `lens` on every finding. You are one of `` reviewers; you do not see the others' findings." Externally the lens also goes on the command: `--lens --panel `.
-
-**fix worker** (a fresh implementer after review): "Ticket at ``; frozen diff at ``; findings at ``. The previous worker reported: ``. Change only what the findings name; run the checks the ticket names; return the same result JSON."
-
-**re-review** (the same reviewer, resumed): "Findings 1 and 3 are fixed — diff of the fixes at ``. Finding 2 is refuted: ``. Confirm or reject each, review the new hunks; the rest of the diff stands as reviewed." A fresh reviewer, when resume is impossible, gets the same text plus the original findings and the full diff.
-
-**review-lead**, call 1: "Spec at ``. Diff stat: ``. Depth: `led`. Return `plan`: one step per reviewer with lens, files to concentrate on, exclusions, and the brief text." Call 2: "Reviewer results: ``. Return one merged `findings` list with `raised_by`; disputes as `verdict: needs-human`."
+A writer needs an isolated linked worktree or an owned Paseo workspace. Make referenced artifacts readable within its permissions. Some read-only paths exclude shell; ask the coordinator to run command checks instead of changing access. Worktree isolation does not itself sandbox tools.
-**verifier**: "Finding: . Counter-argument: . Return `findings[0].verdict` as `confirmed`, `refuted` or `needs-human` with evidence."
+For a researcher, request primary sources/code evidence and explicit uncertainty. For a planner, request dependencies and criteria, not implementation. For a reviewer, provide the frozen diff and spec without author reasoning or other reviewers' findings. A lens (spec, correctness, standards) can prioritize attention while still allowing material findings outside it.
-**researcher**: "Primary sources only. Every claim with URL and date. Mark anything you could not open as UNVERIFIED. Return `summary` and `sources`."
+Continuation example: “Check reconnect behavior and update the result; preserve the accepted investigation.” `resume` retains the exact agent and snapshot. Re-review example: “Findings 1 and 3 are fixed in this diff; finding 2 is refuted by this test. Check the new hunks and dispositions.”
-**Strengthened replacement:** give the accepted task, current worktree/diff, previous result, concrete failure and remaining checks. Name the selected profile/role level and why it changed. Confirm the old writer has stopped and ownership is released. Count this start as a retry of the same ticket.
+A fresh replacement gets the current worktree/diff, accepted results, the concrete remaining failure and unfinished checks. Verify the prior writer stopped and ownership was released. Independent review always starts with fresh context, even when it uses the same model.
diff --git a/skills/delegate-kit/references/compatibility.md b/skills/delegate-kit/references/compatibility.md
new file mode 100644
index 0000000..28bc09e
--- /dev/null
+++ b/skills/delegate-kit/references/compatibility.md
@@ -0,0 +1,21 @@
+# Compatibility and tested scope
+
+| Path | Implemented | Automated evidence | Local evidence | Live model call |
+|---|---|---|---|---|
+| Codex CLI | fresh, result, exact resume, cancellation | legacy + v2 fake CLI tests | 0.153.4 | 2026-09-16: Sol fix + exact resume, Luna independent review + cancellation; isolated fixture, low reasoning |
+| Claude CLI | fresh, result, exact resume | adapter + supervisor fixtures | help/version 2.1.268 | not run |
+| Gemini CLI | fresh, result, exact resume | adapter + supervisor fixtures | help/version 0.36.0 | not run |
+| OpenCode | permissions, provider/model, exact resume | legacy permission/adapter fixtures | help/version 1.18.23 | not run |
+| Pi | official RPC via installed SDK, isolated settings, exact resume | SDK/protocol + fake process tests | not installed | not run |
+| OMP | RPC v2 negotiation/chunks, terminal result, resume | protocol + fake CLI tests | 18.1.17 ready/state handshake, no prompt | not run |
+| Native Codex/Claude | prepare, exact invocation, unique definitions, attach/events | bridge fixtures | tool/schema-dependent; no agent dispatched | not run |
+| Paseo | materialized create/follow-up settings, daemon/workspace lease | bridge fixtures | no daemon/tools available | not run |
+
+Native writers require verified host enforcement of the reserved worktree binding. Hosts that cannot establish it must use an explicitly selected CLI route.
+
+Native Pi/OMP and a dedicated T3 bridge are not implemented. A direct CLI does not become a Paseo UI agent. Desktop/cloud chats need actual shell and host tools. [Adapter contracts, official sources and limitations](providers.md).
+
+Worktrees coordinate writers, not all filesystem permissions. Read-only tool controls differ by executor. Pi/OMP writers deliberately exclude shell and internal delegation; the coordinator performs command checks and authorized commits. Runtime usage is null when unavailable, not zero. The runtime counts its own reservations/continuations and known legacy work; it cannot account for arbitrary agents launched outside it. No savings or model-quality percentage is promised.
+
+
+The Codex smoke used three completed prompts and one cancelled dispatch. Both completed sessions recorded the requested model in Codex turn metadata. This proves the tested CLI lifecycle, not general coordinator routing quality or other providers. CLI-reported completed-turn usage: 165,014 input tokens (112,128 cached), 1,097 output tokens. Currency cost and quota percentage were not provided.
diff --git a/skills/delegate-kit/references/external.md b/skills/delegate-kit/references/external.md
index 05ab26e..ac878c9 100644
--- a/skills/delegate-kit/references/external.md
+++ b/skills/delegate-kit/references/external.md
@@ -1,36 +1,13 @@
-# External workers
+# CLI dispatch
-An external worker is a CLI process launched once from a complete brief. It may belong to the same family as the coordinator. Native versus external describes transport, not model lineage.
+Use `scripts/dk.mjs prepare` then `run` as described in [routing.md](routing.md). Only the configured harness is eligible; available unrelated CLIs are not fallbacks. The supervisor runs argv arrays without shell interpolation and keeps full logs out of the coordinator's default result.
-Resolve the active coordinator profile and role level per `routing.md`. Read the relevant adapter in `providers.md` before first use. Confirm the CLI and configured model are available; `doctor` only detects executable presence. Model names, authentication and reasoning capabilities come from the host/provider. Do not read credential values into prompts or reports.
+Before the first executor/version combination, consult [providers.md](providers.md), its installed help, and the configured model picker. Check exact provider/model/reasoning. Keep credentials in that executor; installation alone does not authorize a model call. Cache the discovery evidence for the current version/configuration.
-```
-agent-run run --role implementer --backend codex --cwd ../repo.worktrees/task --brief brief.md --detach
-agent-run status RUN_ID
-agent-run wait RUN_ID
-agent-run resume RUN_ID --brief fixes.md
-```
+A writer requires a linked worktree created with `scripts/agent-wt create TASK`; prepare acquires its lease. Do not also acquire a manual native lock for this run. Pass an absolute `--cwd`. A read-only worker may use the source repository. Briefs should include relevant facts/files and authorized finishing actions, not the parent transcript. The runtime adds profile instructions and the result contract.
-The route returns external arguments as an array. Pass them as arguments; do not interpolate untrusted model identifiers into shell strings.
+`run` returns promptly with a saved ID and detached supervisor. Use `wait` to collect the terminal result; the runtime polls without model calls. Parent wait timeout is not process death. Inspect the returned health before another bounded wait; attention requires diagnosis. [Lifecycle and recovery](routing.md#lifecycle-and-recovery) describes heartbeat checks and configurable no-progress alerts. The record retains requested settings, actual identity when evidenced, session ID, usage (null when unknown), result and diagnostics. Completion is separately validated and accepted.
-A blocking shell call may yield a live process ID before completion. Continue collecting that process; do not repeat run, which starts another worker. With nothing independent to do, use blocking run; with ongoing coordinator work, use --detach and status or an authorized --on-finish command. Delivery hooks are external actions and need the user's applicable authorization.
+`resume OLD_ID --brief FILE` prepares a new attempt using the old snapshot and exact transport session. Run that returned ID. It refuses active sessions and never uses “last session”. Changed model/harness is a fresh explicitly selected profile, after stopping and inspecting any prior writer.
-## Results and lifecycle
-
-The adapter extracts the final object and validates the canonical schema, including nested findings. Missing/malformed output fails even if the CLI exits zero. The report retains requested model, source of selection, actual model when available, session ID and check results. CLI configuration may be mutable; an unconfirmed model is never presented as verified.
-
-Resume uses the saved adapter, family, model and effort. It never changes model automatically or resumes an unrelated latest session. If the CLI did not provide a session ID, start a fresh worker with the prior summary, worktree state and findings. A CLI-default model remains dependent on CLI configuration across resume; pin a known model for reproducibility.
-
-Timeout/orphaned workers may leave commits or uncommitted files. Run `agent-run inspect WORKTREE` before a replacement. Quota failures do not trigger hidden retries on another provider. Choose an authorized replacement explicitly and include the prior state.
-
-## Isolation and limits
-
-Every writer has one worktree and one lock. `agent-run --cwd` locks it automatically and refuses a native writer's lock. Native dispatch requires `agent-wt lock`. Writers commit only their own files; integration and push belong to the coordinator.
-
-Read-only capability depends on the adapter. Some omit shell tools completely; the coordinator performs acceptance commands in its own authorized environment. See providers.md for the exact boundary. A worktree is write coordination, not a security sandbox.
-
-Concurrency is bounded by explicit user limits and actual host capacity. The run counts external processes machine-wide plus native writer locks in its repository. Concurrent starts reserve slots under a mutex. Supply one --task ID and --ticket ID across related attempts; run/resume records each start in the task counter. Mark repairs with --retry. Limits and native accounting are defined in routing.md; a refusal before or during detached startup is reported.
-
-Delegation depth is 1. Native definitions carry no delegation tool; external adapters disable it through host controls where available, and the brief forbids further delegation. Worker shell subprocesses carry DELEGATE_KIT_DEPTH, so nested agent-run calls fail.
-
-Run metadata and ledger live under ~/.delegate-kit, overridable with DELEGATE_KIT_HOME. Native runs are not automatically in this ledger; record their actual dispatch in the coordinator's report. Do not treat an external-only ledger as a complete cost comparison.
+Tools are narrowed per adapter. Pi/OMP writers have read/edit/write tools but no shell, delegation or optional extension tools; the coordinator performs command checks and authorized commits. Restrictions are not a universal filesystem or secret sandbox. Unsupported permission controls stop dispatch instead of enabling bypass.
diff --git a/skills/delegate-kit/references/hosts.md b/skills/delegate-kit/references/hosts.md
index 6876fd8..b490e53 100644
--- a/skills/delegate-kit/references/hosts.md
+++ b/skills/delegate-kit/references/hosts.md
@@ -1,18 +1,47 @@
-# Native dispatch
+# Native and Paseo host bridges
-Native means the current host can launch and supervise a separate agent. It does not imply a particular family. Use the actual tool schema exposed in the session.
+A Node script cannot invoke hidden parent-chat tools. A bridge prepares/reserves one dispatch, the coordinator calls the actual tool, and the runtime attaches and validates correlated results. Prepared or accepted-by-transport is not completed.
-| Host | Dispatch | Role source |
-|---|---|---|
-| Codex | spawn_agent with agent_type when required by the tool | ~/.codex/agents/dk-*.toml, generated by install.sh |
-| Claude Code | Agent with subagent_type: dk-ROLE | ~/.claude/agents/dk-*.md |
-| Other hosts | Their actual dispatch tool, if available | Brief carries the role instructions |
-| No native fan-out | Supported external CLI, or coordinator executes serially | No simulated subagent or fresh-context claim |
+## Capability evidence
-Select the coordinator profile and role level per `routing.md`; a native worker may use any model the host actually supports. Record each start/resume with `agent-run budget --task ID --record --ticket ID`, adding `--retry` for repair attempts. Omit model and effort to inherit. If the host permits explicit choices, use the verified model catalog and supported reasoning values. A pinned custom role can override a spawn choice; install updated role definitions before relying on inheritance. Native interfaces can differ from CLI configuration interfaces; if a setting cannot be applied natively, use a supported external adapter or retain the host setting and disclose the limit.
+Read the actual tool schema and model/provider discovery, then write a temporary JSON array passed to `prepare --capabilities FILE`. It is per-run evidence, not another user team configuration. Each entry contains:
-Review starts in a fresh context, not a fork containing the author's entire reasoning. Provide spec, frozen diff, relevant files and acceptance criteria. A same-family reviewer is a full review with no family diversity; both properties are reported separately.
+```
+{
+ "verified": true,
+ "host": "codex",
+ "version": "observed host/tool schema version",
+ "harness": "codex",
+ "transport": "native",
+ "models": [{ "id": "verified-model", "reasoning": ["verified-option"] }],
+ "resume": true, "result": true, "cancel": true,
+ "access": ["read-only"]
+}
+```
-Every native writer needs an isolated worktree, a lock and its absolute path in the brief. One owner per write scope. Commit only assigned paths; preserve other changes. If git reports an index lock, identify its live owner before removing anything. Push remains the coordinator's responsibility.
+Include only capabilities actually established in this host. A prompt saying read-only is not enforcement. Codex native inherits the host's access boundary; if that boundary cannot enforce the profile's access, use CLI. Explicit inheritance also needs `current_model`. Auto tries compatible Paseo, compatible native, then the same harness CLI. Explicit transports fail if evidence is insufficient. Native Pi/OMP/T3 bridges are not implemented; use their CLI if locally available. T3 is a host, never a model provider.
-Native tools may not provide schema enforcement, stable run IDs or a ledger. Validate the returned result, track lifecycle through host notifications, and report unknown model identity honestly. Read-only instructions are not an OS sandbox; use available host restrictions for risk-sensitive review.
+For a native writer, also provide `workspace_binding: { "cwd": "/absolute/linked/worktree", "enforced": true }`. Set this only after verifying that the host places the child in that directory and enforces its write boundary there. The coordinator's cwd, a prompt instruction or a list of accessible directories is not sufficient evidence. The runtime compares canonical paths with the leased worktree before admission and again before dispatch or continuation. This applies to both Codex and Claude native writers. Without that host guarantee, select CLI explicitly; its process starts in the assigned worktree. Read-only native tasks keep their existing access checks. Paseo uses its explicit workspace ID binding.
+
+## Dispatch and events
+
+1. `prepare` snapshots the team and reserves its required profile set. Local writers use linked worktrees.
+2. `run ID` returns `invoke` and a unique `dispatch_token`, and marks starting. Call that real host tool once with the exact settings. Do not re-dispatch on an uncertain response: inspect host state and attach the existing agent. If the host positively confirms that no agent was created, use `dispatch-failed ID --dispatch-token TOKEN --confirmed-not-started --evidence FILE`, with a text record of that evidence. A timeout or missing notification does not establish this fact.
+3. `attach ID --host-agent HOST_ID` records the real session. Reattaching the same ID is idempotent; another ID is rejected. A late ID can be attached during cancellation without reopening the run.
+4. Await completion notifications or the host's bounded wait. All events require `--dispatch-token TOKEN` from this dispatch. For a permission event use `event ID --host-agent HOST_ID --dispatch-token TOKEN --event permission`. The dispatched prompt requests `{ "dispatch_token": "TOKEN", "result": }`; preserve this envelope exactly, without replacing an old token. A terminal host response is saved as JSON and registered with `event ... --event complete --file RESULT --stopped`. This explicit assertion means the host confirmed the turn stopped; never assert it while a writer can still modify files. Invalid output fails validation. Error/cancellation use `--event failed|cancelled --stopped`.
+5. After every bounded host wait without completion, query its read-only status/progress tool. Record a verified observation with `event ID --host-agent HOST_ID --dispatch-token TOKEN --event running [--progress OBSERVED_CURSOR]`. The cursor must come from host events/artifacts, not a fabricated timestamp. Polling does not send a new prompt. See [watchdog behavior](routing.md#lifecycle-and-recovery).
+6. `resume ID --brief FILE`, then `run NEW_ID`, returns a follow-up invocation for the saved host session. Attach that same ID to the new attempt, then ingest its correlated result. Fresh review starts a new prepared run.
+
+Codex uses `spawn_agent` with fresh context/model/reasoning, then `followup_task`. Tool availability varies: an unsupported schema is not fixed by copying these names. Claude uses an isolated `dk-RUN_ID` definition. `materialize ID --directory VERIFIED_AGENT_DIR` writes only that unique file and refuses conflicts. Mark capability `dynamic_roles: true` only if the running host discovers it without restart. Remove that unchanged managed file after completion; otherwise prefer CLI. Neither route repins a shared role when X1/Y2 switches.
+
+`cancel` for a bridge requests host interruption and retains capacity/ownership until a correlated stopped event. An uncertain, unattached native dispatch cannot be automatically recovered; identify its real agent through the host first. Never associate a result with a new attempt solely because the host session ID matches: continuations reuse that ID but get a new dispatch token. Legacy in-flight records without an echoed token must be reconciled against their original host turn rather than relabelled. Agents launched outside the bridge are outside runtime accounting. Host interfaces may still expose progress messages to the parent; the skill cannot erase them.
+
+## Paseo
+
+Use actual `list_providers`, `list_models`, `inspect_provider` results, not Paseo profiles. The active Delegate Kit JSON is the assignment source. A compatible evidence entry sets `host: "paseo"`, `transport: "paseo"`, the unchanged `harness`, a stable `daemon`, and verified `mode_ids` mapping `read-only`/`workspace-write` to exact Paseo modes. Include provider when the profile pins one and only if discovery proves that same connection is used.
+
+Create/select the workspace through Paseo first. Pass `--workspace FILE` containing `owner: "paseo"`, its `id`, `daemon`, and optional `remote: true`; `--cwd` is a daemon path and is not checked locally. The runtime materializes `create_agent` with `provider: harness/model`, workspaceId, settings.modeId and optional thinkingOptionId, and notifyOnFinish. It never requires a Paseo profile. Invoke in the parent agent context on the saved daemon so Paseo owns the parent-child relationship.
+
+Attach with both `--host-agent` and `--workspace-id`. Follow-up uses `send_agent_prompt` with the saved agentId and daemon. Notifications trigger result ingestion. Workspace creation/archive stay with Paseo; preserve changes and references before archiving. A direct Pi/OMP CLI is not automatically visible in Paseo UI. No live Paseo daemon or tool schema was available for this implementation; the bridge is contract-tested against the official documented tool surface and requires current-host verification before use.
+
+Sources checked 2026-09-16: [Paseo skill and tool contract](https://github.com/getpaseo/paseo/blob/main/skills/paseo/SKILL.md), [Paseo orchestration](https://paseo.sh/docs/orchestration). Local Desktop/cloud chats without shell and corresponding host tools cannot execute this bridge.
diff --git a/skills/delegate-kit/references/migration.md b/skills/delegate-kit/references/migration.md
new file mode 100644
index 0000000..2f8f102
--- /dev/null
+++ b/skills/delegate-kit/references/migration.md
@@ -0,0 +1,24 @@
+# Migration from v1
+
+V2 uses one complete team per `presets/ID.json`, independent of the coordinator's family. The legacy parser lives in `scripts/legacy-routing.mjs`; `routing.mjs` is a compatibility export. Old `agent-run` commands warn when creating new v1 work. Use `dk.mjs` for new dispatches.
+
+Start with `node /scripts/dk.mjs migrate --dry-run`. It reads `config.json`, materializes shared roles into each team, turns ladder candidates into separately named profiles, and shows proposed files and unresolved decisions. No model calls or writes occur. [legacy-config.json](../examples/legacy-config.json) illustrates the old format; do not use it for new setup.
+
+Explicit backend/runner/model/effort and limits carry over. Generic descriptions preserve ordinary versus alternative assignments without guessing UI specialization or price. Dynamic inheritance, ambiguous parent routes, out-of-pool assignments, legacy preferences and solo/duo restrictions require a decision; none is silently relaxed. Unsupported values and placeholders are reported before applying.
+
+For assignments that intentionally depended on the parent, supply a decisions file, for example:
+
+```json
+{
+ "parents": { "gpt": "codex", "claude": "claude" },
+ "default_preset": "gpt"
+}
+```
+
+For auto/native family assignments, a matching explicit parent determines the original harness. Multiple harnesses in one family require that choice rather than selecting the first backend. These mappings are explicit user decisions; team names themselves have no routing meaning. A missing default remains unset. If other ambiguities remain, use conversational setup to construct and validate the intended complete v2 JSON from the dry-run output. Keep the original legacy file unchanged as evidence; the automatic converter deliberately refuses to invent missing models or translate preferences into specialties.
+
+After user authorization, `migrate --apply --decisions FILE` backs up the exact old config, saves complete presets atomically per file, and records `migration-v2.json`. It refuses existing destinations/settings instead of overwriting them. A completed migration is idempotent and preserves later v2 edits. If a process crashes during a multi-file apply before the journal is saved, rerun dry-run and inspect the backup/proposed files; an existing destination is a recovery diagnostic, never permission to overwrite.
+
+Existing v1 run directories remain readable/continuable with `agent-run status ID`, `agent-run wait ID`, and `agent-run resume ID --brief FILE`. They keep their original adapter/model/session. Passing them to `dk.mjs` gives a specific legacy-command diagnostic. Do not migrate an active model session to a new executor.
+
+Optional old hooks and static native roles can remain for legacy runs. They do not select v2 teams. `hooks/install.sh --dry-run` and `hooks/uninstall.sh` manage only known package components; v2 does not require global role installation. V2 Claude native definitions are per-run and must not replace a shared user role.
diff --git a/skills/delegate-kit/references/omp-setup.md b/skills/delegate-kit/references/omp-setup.md
new file mode 100644
index 0000000..0a88bc8
--- /dev/null
+++ b/skills/delegate-kit/references/omp-setup.md
@@ -0,0 +1,27 @@
+# OMP setup for Delegate Kit
+
+Use this only when the user asks to prepare OMP. OMP settings describe the host tool; Delegate Kit presets assign specialists. Avoid maintaining a second team in OMP modelRoles. Keep credentials and session databases local to each host.
+
+## Align the hosts
+
+Compare `omp --version`, `bun --version` and the effective `omp config path`. The tested pair is OMP 18.1.17 and Bun 1.4.0; OMP declares Bun >=1.3.14. Matching existing versions need no reinstall. For a new host, use the official OMP installation instructions and pin the chosen version. Recheck RPC compatibility before upgrading it.
+
+Use one non-secret baseline on both machines, backing up each existing config first. A simple interactive setup can keep one explicitly selected default model, fixed reasoning, the same theme/composer, advisor/prewalk disabled, model/usage fallback disabled, and `tools.approvalMode: write` (workspace edits allowed; execution asks). Local credentials, MCP definitions, caches and sessions are not configuration-sync targets. A shared default model still needs authorization on each machine; copying a model ID does not copy sign-in.
+
+The Delegate Kit OMP adapter supplies per-run overrides, exact provider/model/reasoning, restricted tools, disabled internal delegation/extensions, and disabled retries/compaction/fallback. It does not depend on OMP specialist roles. These worker settings differ intentionally from an interactive coordinator's normal configuration.
+
+For SSH automation, use a login shell, for example `ssh HOST 'bash -lc "omp --version"'`. Ensure the user's Bun bin directory is exposed before an early non-interactive return in shell initialization. A bare SSH command may receive only the sshd PATH. Do not change system sshd settings or copy credentials to solve a PATH problem.
+
+## Connect OpenRouter after setup
+
+On each host, run `omp auth-broker login openrouter` interactively, or open OMP and use `/login openrouter`. The installed provider registry must list OpenRouter. Complete the credential prompt locally; credentials do not belong in the team JSON or repository. An existing `OPENROUTER_API_KEY` is another supported connection source, but a GUI/SSH process must actually inherit it.
+
+After sign-in, use `omp models openrouter --json` or the model picker to choose exact IDs and supported reasoning. A catalog listing alone does not establish successful authentication. Add an agent with `executor.harness: omp`, `provider: openrouter`, the chosen model ID and `transport: cli` to the active team; validate and save using the current preset revision. Model calls for a connection test need user authorization.
+
+## Validate without a model call
+
+Check effective config on both hosts and compare only non-secret managed settings. Launch RPC in an isolated temporary cwd with the same worker overrides; negotiate protocol v2, get_state, select the exact already configured model, disable retry/compaction and get_state again, then close stdin. Do not send prompt. This checks startup and the protocol, not provider authentication or generated results.
+
+Local CLI status checks and supervisor heartbeats call no model. The coordinator does spend tokens when it receives a tool result: prefer a bounded long CLI wait (for example 300000 ms when the host tool supports it) over one-minute status conversations. Internal health checks still return early for completion or required attention. Host tool execution limits may shorten a wait; native/Paseo state checks require the host bridge and may wake the coordinator. This setup does not claim zero total coordination overhead.
+
+Sources: [official installation](https://github.com/can1357/oh-my-pi#install), installed 18.1.17 `--help`, `auth-broker list --json`, `config` help, and [RPC contract](https://github.com/can1357/oh-my-pi/blob/main/docs/rpc.md).
diff --git a/skills/delegate-kit/references/providers.md b/skills/delegate-kit/references/providers.md
index 7a2a919..90ad44a 100644
--- a/skills/delegate-kit/references/providers.md
+++ b/skills/delegate-kit/references/providers.md
@@ -1,19 +1,19 @@
# Launch adapters
-Official contracts checked 2026-09-07. Local help inspected: Codex CLI 0.153.4, Claude Code 2.1.263, Gemini CLI 0.36.0 and OpenCode. No live model executions were performed for this integration. Recheck installed help before dispatch; configured model access is account-specific.
+Official contracts checked 2026-09-07. Local help inspected: Codex CLI 0.153.4, Claude Code 2.1.263, Gemini CLI 0.36.0 and OpenCode. See the packaged [compatibility matrix](compatibility.md) for current live-test evidence. Recheck installed help before dispatch; configured model access is account-specific.
## GPT through Codex CLI
Fresh: `codex exec --json -m MODEL -c 'model_reasoning_effort="high"' --output-schema SCHEMA -o OUTPUT PROMPT`.
Resume: `codex exec resume ... SESSION_ID PROMPT`. The adapter sets `sandbox_mode` through `-c` on both paths and disables agents with `agents.enabled=false`. It never enables bypass mode. Model and effort flags are omitted when unspecified.
-Native roles are standalone TOML files under `~/.codex/agents/`. The installer generates them without model/effort settings. Custom agent model settings can override spawn choices, so existing installed pins must be removed. Actual tool schemas differ by host: use agent_type when the spawn tool requires it; do not copy another harness's argument names.
+V2 native dispatch uses the verified host bridge described in [hosts.md](hosts.md), with explicit per-run model/reasoning. Static TOML roles installed under `~/.codex/agents/` are legacy components; never rewrite user roles or rely on their pins for v2. Verify the actual host schema and effective access before dispatch.
Sources: [subagents and precedence](https://learn.chatgpt.com/docs/agent-configuration/subagents), [CLI reference](https://developers.openai.com/codex/cli/reference), and installed `codex exec --help` / `codex exec resume --help`.
## Claude through Claude Code
-`claude -p --output-format json --model MODEL --effort high --json-schema SCHEMA PROMPT`; resume adds `--resume SESSION_ID`. Native Markdown roles use `model: inherit` and omit effort. If the native tool cannot express the chosen effort, use the supported role configuration or external adapter; do not invent a spawn argument.
+`claude -p --output-format json --model MODEL --effort high --json-schema SCHEMA PROMPT`; resume adds `--resume SESSION_ID`. V2 native Markdown definitions are unique per run and carry the resolved model and optional effort. Static legacy roles using `model: inherit` do not select v2 profiles. If the native tool cannot express the chosen effort, use the supported role configuration or external adapter; do not invent a spawn argument.
The adapter disables Agent/Task. Writers use acceptEdits and retain command approval requirements. Read-only calls use plan mode plus a read/search/web tool allowlist and an empty strict MCP configuration. Shell checks are run by the coordinator; plan mode alone is not an OS sandbox.
@@ -39,6 +39,38 @@ A diagnostic `opencode debug agent RUN_AGENT --pure` reads effective permissions
Sources: [OpenCode CLI](https://opencode.ai/docs/cli/), [permissions](https://opencode.ai/docs/permissions/), [agents](https://opencode.ai/docs/agents/), [Moonshot and Z.AI providers](https://opencode.ai/docs/providers/).
-GLM can also be configured behind Claude Code using its Anthropic-compatible endpoint. In that case define a backend with family glm and adapter claude, and use the already configured CLI environment. Do not label its review cross-family just because the executable says claude. [Z.AI's Claude Code integration](https://docs.z.ai/devpack/tool/claude).
+GLM can also be configured behind Claude Code using its Anthropic-compatible endpoint. In v2 define an agent with `executor.harness: "claude"` and the exact configured GLM model ID, using the already configured Claude CLI connection. A different executable does not establish independent model identity. Legacy `family`/`adapter` backend fields belong only to v1 migration. [Z.AI's Claude Code integration](https://docs.z.ai/devpack/tool/claude).
Direct Kimi CLI execution is intentionally not an external adapter in this release. Current documentation says -p uses auto permission mode and cannot combine with --plan; older kimi-cli documentation describes a different --print interface. OpenCode provides a documented per-agent permission contract for the initial Kimi integration. Native Kimi coordination can still follow the policy through its actual tools. [Kimi command and flag conflicts](https://www.kimi.com/code/docs/en/kimi-code-cli/reference/kimi-command.html).
+
+## V2 adapter additions and capability evidence (2026-09-16)
+
+V2 always specifies a model; only an explicit native `inherit_model` may use verified parent identity. `provider` is supported for Codex via its configured `model_provider`, OpenCode via an exact provider/model selector, and Pi/OMP via RPC `set_model`. A provider override on Claude/Gemini is rejected because their existing connection is not selected by such a flag. OpenCode reasoning uses its model-specific variant; unknown variants must be checked in discovery. The runtime never translates effort names. Codex/Claude known effort tokens are checked before launch, while account/model compatibility must also be established in setup; provider rejection remains a failed run without fallback.
+
+Local help/version inspected: Codex 0.153.4, Claude Code 2.1.268, Gemini 0.36.0, OpenCode 1.18.23, OMP 18.1.17. Pi and Paseo CLI are absent here. These are compatibility observations, not minimum supported-version promises. An authorized Codex CLI smoke with Sol/Luna passed on 2026-09-16; other live routes remain unverified. Version discovery does not read credentials or claim authentication.
+
+### Pi
+
+Pi runs its official RPC mode in a Node subprocess through the SDK shipped with the already installed `@earendil-works/pi-coding-agent` package. `pi-worker.mjs` locates that package from the Pi executable; it downloads no package. This small SDK bootstrap is necessary because Pi RPC setters for retry/compaction persist global settings by default. The wrapper supplies `SettingsManager.inMemory`, so those setters cannot alter the user's CLI preferences. It preserves global connection/transport preferences, disables packages/extensions/skills/prompts/themes for this worker, and leaves auth.json/models.json at their original Pi paths without copying credentials. Project settings are not promoted into global configuration.
+
+The adapter requires the documented `createAgentSessionServices`, `createAgentSessionFromServices`, `createAgentSessionRuntime` and `runRpcMode` exports (source contract inspected at package version 0.85.1); missing exports fail explicitly before prompt. Resume uses `SessionManager.open` on the exact saved session file, never a partial UUID or “last session”. Read-only tools are read/grep/find/ls; writers add edit/write. Shell checks stay with the coordinator. Extension discovery and internal delegation tools are excluded.
+
+The RPC client correlates responses by ID and command. Before prompt it calls exact `set_model`, disables auto-retry and auto-compaction, sets optional reasoning, and verifies state without accepting clamped reasoning. Prompt acceptance is separate from terminal `agent_end`. Assistant message metadata can establish actual provider/model; otherwise identity is unknown. Session ID/file are saved before and after the turn. After the completed turn the idle Pi process is terminated; later continuation reopens its exact transcript. LF alone separates JSONL, preserving U+2028/U+2029 and split UTF-8 sequences.
+
+Sources: [Pi RPC](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/rpc.md), [CLI options and tools](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/README.md), [SDK settings/runtime](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/sdk.md). Covered by deterministic protocol, SDK-bootstrap fixtures and fake-process tests, including unchanged global settings; Pi is not installed for a local handshake/live run.
+
+### OMP
+
+OMP uses its own RPC adapter, not Pi framing assumptions. Require a ready frame advertising v2, negotiate v2, then validate chunk IDs, order, counts, strict base64, byte lengths and UTF-8. Interrupted/interleaved/incomplete sequences fail visibly. Physical and reassembled limits are bounded by the advertised ceilings. `agent_end` with `isTerminal: false` is not completion. A late error after prompt acknowledgement fails the turn.
+
+CLI: `omp --mode rpc --provider PROVIDER --model MODEL --no-extensions --no-skills --no-title --no-prewalk --no-lsp --no-pty --config RUN_CONFIG --approval-mode write --tools ... --session-dir DIR`. Read-only tools are read/grep/glob; writers add edit/write. Resume uses `--resume EXACT_SESSION_FILE`. The generated run-only configuration disables advisor, retry/model fallback, compaction, memory/autolearn, recap, planning and task isolation. RPC also disables retry/compaction and verifies exact model/reasoning before prompt. Tool restrictions are not an OS sandbox. Existing provider authentication remains in OMP; the adapter never enables yolo/auto-approve or copies secrets.
+
+Display-only extension UI notifications are ignored. A blocking dialog or host-owned tool request cannot be silently approved; it stops the run for explicit resolution. Unexpected subagent/fallback/retry events fail rather than hiding extra work. These controls cover the documented execution surface, not arbitrary host/provider internals or external account activity.
+
+Sources: [OMP RPC framing and lifecycle](https://github.com/can1357/oh-my-pi/blob/main/docs/rpc.md), [OMP settings schema](https://github.com/can1357/oh-my-pi/blob/main/packages/coding-agent/src/config/settings-schema.ts), installed 18.1.17 help. Local startup, v2 negotiation, get_state, exact set_model, disabling retry/compaction and EOF passed with no prompt/model call. Completion, continuation and failures use deterministic fixtures; live provider calls remain unverified.
+
+### Host bridges
+
+See [hosts.md](hosts.md) for actual host-call boundaries and [compatibility matrix](compatibility.md). Native access must be established by the host, not the profile text. Paseo maps a Delegate Kit profile into create_agent settings without a second team configuration; it preserves daemon and workspace ownership. Both bridge paths are fixture-tested, not live-certified. No cloud shell or native OMP/Pi/T3 integration is implied.
+
+OpenCode variants require a verified `transport: "cli"` capability entry with its harness, observed version, exact combined model ID and supported `reasoning` list. Pass this ephemeral discovery evidence with `prepare --capabilities`; an unknown variant is refused before launch. For auto routing with an incompatible host entry, CLI fallback additionally needs explicit `cli_equivalent: true` evidence for the same account/provider, or an explicit user-authorized `transport: "cli"` selection. Capability evidence cannot authorize another provider.
diff --git a/skills/delegate-kit/references/review.md b/skills/delegate-kit/references/review.md
index 1d582dc..17afb13 100644
--- a/skills/delegate-kit/references/review.md
+++ b/skills/delegate-kit/references/review.md
@@ -1,30 +1,10 @@
-# Review: depth, lenses, composition
+# Independent review
-How many reviewers a diff deserves, which angle each one takes, and how their findings become one list. `agent-run route --role reviewer --diff --author-backend ` applies all of it and prints the result; this file is the reasoning behind that output.
+Review substantial delegated implementation and changes whose failure modes justify an independent check. Select profiles from the active team's descriptions and explicit defaults. Trivial direct work does not need an agent ceremony. Scope coverage to actual contracts, ambiguity and failure impact; keywords, line counts and family rankings do not pick reviewers.
-## Fresh context and family diversity
+Every initial reviewer gets a fresh read-only context, the same frozen specification and diff, and no other reviewer's findings. Freshness and family diversity are different properties. Two agents of the same model can provide independent analysis; agreement alone is not proof.
-Every review uses a fresh read-only agent and a frozen diff/spec. This applies equally in solo and duo. Family diversity is a separate property, not a binary label for whether a review counts.
-
-When several families are allowed, consider another capable family when otherwise comparable. User assignments and known task suitability can select the author's family. A second slot should add a complementary lens and independently gathered evidence, rather than merely endorsing the first reviewer. It can belong to the same family in solo.
-
-Slots use correctness, spec and standards priorities. The coordinator can choose a different composition when justified by the task; state the reason. A hard reviewer backend assignment applies to every slot. See routing.md for mode boundaries and preferences.
-
-## Depth
-
-| Depth | Reviewers | When |
-|---|---|---|
-| `single` | A | the default: under ~400 changed lines, ≤ 10 files, one module, no risk zone |
-| `panel` | A + B, parallel and blind to each other | above any of those, or any risk zone touched |
-| `led` | lead → A + B + C → lead | ~1200+ lines, 25+ files, 3+ modules, or a risk zone with a large diff |
-
-A **mechanical** diff (formatting, lockfile bump, generated client) is always `single`; size means nothing there. Pass `--kind mechanical`.
-
-The thresholds are starting points. The ledger records `lens` and `panel` per run: after a few panels, look at how many findings slot B raised that A did not and how many of those survived verification. If B keeps returning one low-severity nit per panel, raise the thresholds.
-
-The coordinator chooses useful coverage within the user's limits and the host's capacity. Thresholds propose a review shape, not mandatory fan-out. `review.allow_multiple: false` is an explicit user restriction; request an exception or use one reviewer. An explicit user-selected `--depth` can record that exception. Count every review start/resume in the task budget.
-
-The reviewer model ladder and review depth are separate: a stronger single reviewer may be appropriate for a small risky change, while a large mechanical diff need not use the highest level. Configured reviewer assignments apply to all generated slots. For deliberate cross-family slots, resolve each authorized candidate separately and provide independent briefs.
+A profile's `review.also_run` is a required set, validated and reserved by `prepare`. Dispatch all returned runs and keep their initial results independent. A missing/unavailable mandatory reviewer makes the set incomplete. Optional extra coverage is a coordinator decision within user limits. A review lead can help resolve difficult decomposition; it is never an automatic prelude.
## Lenses
@@ -62,15 +42,6 @@ Four structural checks sit alongside the smells, adapted from addyosmani/agent-s
- **Unnamed Remedy** — a finding that says "too complex" without a move → name the restructuring: a typed model or dispatcher for a conditional chain, orchestration split from business logic, a pass-through wrapper deleted, a helper extracted.
- **Bulk Dependency Bump** — several packages upgraded in one change, changelog unread, lockfile diff unreviewed → one dependency per change, changelog read for behaviour changes, lockfile diff in the review, green suite before and after.
-## The lead
-
-At `led` depth the **review lead** (`dk-review-lead`; planner ladder unless separately configured) is called twice, and both calls are short because it reads *around* the diff, not through it:
-
-1. **Before** — spec plus diff stat in, `plan` out: one reviewer per step with lens, files to concentrate on, exclusions, and the brief text. The brief is the most consequential artifact of the whole review, so choose the planning level for the ambiguity and risk.
-2. **After** — the reviewers' result JSONs in, one merged `findings` list out, by the rules below.
-
-At `panel` depth the parent does both jobs itself with the same rules; the lead exists for the size at which the parent would otherwise be reading three reports into its own context.
-
## Merge rules
Reviewers run **in parallel and blind to each other**. A reviewer that reads another's findings anchors on them and the second opinion collapses into agreement; the merge is a separate step.
@@ -81,6 +52,5 @@ Reviewers run **in parallel and blind to each other**. A reviewer that reads ano
- Dedupe by meaning; `file:line` catches only the trivial duplicates.
- Rank by severity, then by how many slots raised it. Drop nothing silently.
-## What it costs
-`panel` is two review sessions instead of one. `led` is three plus two short lead calls, plus a verifier per real dispute — five to seven read-only sessions on one review, which is often more than the implementation cost. Choose that structure only when the added coverage justifies the extra calls; the proposal carries the counts so the coordinator can judge it.
+After a concrete fix, repeat the affected checks and resume the relevant reviewer with the new hunks and finding dispositions. For disputed claims use reproducible commands first, then a configured verifier if meaningful uncertainty remains. Avoid an unbounded argument to consensus. Accept only after checking the evidence and every required reviewer result.
diff --git a/skills/delegate-kit/references/roles.md b/skills/delegate-kit/references/roles.md
index 393a44d..db0952b 100644
--- a/skills/delegate-kit/references/roles.md
+++ b/skills/delegate-kit/references/roles.md
@@ -1,30 +1,18 @@
# Roles and useful outcomes
-Users assign model ladders in `config.json`; see `routing.md`. The current chat remains the coordinator in every profile. Role names describe work rather than price or vendor.
+A role describes the result. A named profile contains a user-selected executor and `when` description. Several profiles can share a role; adding a specialist requires only editing its complete team JSON.
-| Key | User-facing name | Required outcome |
-|---|---|---|
-| researcher | Researcher | Bounded facts, source evidence and uncertainty |
-| planner | Planner | Ordered tasks, dependencies, ownership, assumptions and acceptance checks |
-| implementer | Implementer | One completed outcome, isolated changes and executed checks |
-| reviewer | Reviewer | Findings against the frozen diff and spec, in a fresh context |
-| verifier | Finding verifier | Evidence that confirms or refutes a disputed claim |
-| review-lead | Review lead | Reviewer briefs and a consolidated report for a substantial review |
+| Role | Useful outcome |
+|---|---|
+| researcher | Bounded facts, code/source evidence and uncertainty |
+| planner | Dependencies, ownership, assumptions and acceptance checks |
+| implementer | Scoped changes and executed checks |
+| reviewer | Findings against a frozen spec/diff in fresh context |
+| verifier | Evidence confirming or refuting a disputed finding |
+| review-lead | Independent reviewer briefs and consolidated findings |
-Verifier uses the reviewer ladder and review lead uses the planner ladder unless separately configured. These are occasional roles, not mandatory stages.
+The planner, verifier and review lead are optional. No model ladder or implicit role inheritance applies. `defaults.` references an explicit profile in this preset. Unknown role names are allowed and default to read-only; writing needs explicit access. Only implementer defaults to workspace-write.
-## Choosing a level
+Choose by the described task, not role name alone. A UI implementer and a general implementer have different `when` descriptions. A small text correction on an authentication page is not automatically a complex backend task. A consequential distributed invariant may merit the complex specialist without a sensitive keyword.
-The first candidate is the usual assignment; later candidates are available strengthening steps, not backup providers to try automatically. The coordinator may start at a stronger level for high risk or interacting constraints. It preserves explicit user assignments and reports its choice.
-
-A cheaper researcher can locate a documented flag or extract relevant code without making an architectural verdict. A capable implementer at lower effort can execute a clear brief. Whether either pays off depends on context transfer, evidence quality and repair work, not its model name. A researcher may identify uncertainty and pass the judgement to the coordinator/planner.
-
-## Returning unsatisfactory work
-
-Clarify an incomplete brief or a small oversight. Strengthen the level for insufficient reasoning or repeated substantive failures. Resolve tool/access failures as environment problems. Keep the current worker for a useful clarification; use a fresh worker when changing model/effort or when old context obscures the task.
-
-The coordinator inspects partial results, preserves useful changes and transfers writer ownership before replacement. Count clarification resumes and fresh replacements as starts; count repair attempts against the same ticket. Avoid repeating the same ineffective attempt.
-
-## Briefs
-
-The planner should identify dependencies and independently testable outcomes, not expand every implementation detail. The implementer owns local syntax and implementation choices within the accepted contracts. The reviewer needs the specification and frozen diff, not the author's reasoning. Use `brief-template.md` for dispatch and continuation examples.
+Clarify a bounded omission in the same executor session. Select a fresh authorized profile for changed responsibilities, model/harness, unsuitable context or independent judgment. Tool/access failures are environment problems. Stop and inspect a partial writer before transferring ownership. Use [brief-template.md](brief-template.md) for briefs and continuation.
diff --git a/skills/delegate-kit/references/routing.md b/skills/delegate-kit/references/routing.md
index 2951cc2..6608ddf 100644
--- a/skills/delegate-kit/references/routing.md
+++ b/skills/delegate-kit/references/routing.md
@@ -1,112 +1,49 @@
-# Role profiles and execution
+# Preset resolution and execution
-## One configuration file
+The coordinator selects a profile semantically from the active catalog. Code validates exact executor settings and references; it never classifies tasks by keywords or model family.
-Edit `~/.delegate-kit/config.json`, or `$DELEGATE_KIT_HOME/config.json` when the state directory is overridden. `examples/config.json` contains complete GPT, Claude and Kimi teams as examples, not an exhaustive list. All profiles live inside the same file. For a first configuration, copy the example there and customize it; for an existing configuration, merge the desired entries into `profiles`. Keep credentials in the provider's configuration. [Setup and a custom GLM team](../../../README.md#add-your-own-team-glm-with-claude-and-gpt).
+`` below means `node /scripts/dk.mjs`.
-`profiles..roles` assigns workers for a coordinator. The family declared by `--parent` selects the profile automatically: built-in parent `codex` has family `gpt`, while `claude`, `kimi`, `glm` and `gemini` have their respective families. `--profile NAME` selects a named profile explicitly. `--parent-model` describes the current model; it does not switch the chat or select a profile by model-name guesswork. Distinct teams for two models in the same family can use named profiles and `--profile`.
-
-Names start with a lowercase letter and contain lowercase letters, digits, hyphens or underscores, such as `glm-5-3` or `glm-in-opencode`. There is no automatic profile selection by model version or host within one family. Parent detection uses the Codex/Claude environment; other hosts specify `--parent` or `DELEGATE_KIT_PARENT`. A custom parent backend must declare its family and one of the supported adapters in `backends`; it does not add a new CLI adapter or native tool capability.
-
-Top-level `roles` supplies shared defaults. The selected profile replaces each role it defines completely; roles absent from that profile use shared assignments. An absent verifier uses the reviewer ladder; an absent review-lead uses the planner ladder. With no assignment, the current model is inherited on a native route.
-
-### Role ladders
-
-A role is a nonempty ordered array:
-
-```json
-{
- "profiles": {
- "gpt": {
- "roles": {
- "researcher": [
- { "model": "gpt-5.6-luna", "effort": "high" },
- { "model": "gpt-6-astra", "effort": "low" }
- ],
- "reviewer": [
- { "family": "claude", "runner": "claude", "model": "opus", "effort": "high" }
- ]
- }
- }
- }
-}
+```
+ context open --session codex:CHAT_ID --preset X1
+ catalog --session codex:CHAT_ID
+ prepare --session codex:CHAT_ID --task feature-a --agent research-general --brief /abs/brief.md --cwd /abs/repository
+ run RUN_ID
+ wait RUN_ID --timeout-ms 60000
+ result RUN_ID
+ resume RUN_ID --brief /abs/follow-up.md
+ run NEW_ATTEMPT_ID
+ accept NEW_ATTEMPT_ID
```
-`--level 1` selects the usual candidate. `--level 2` selects the next permitted step; an out-of-range level fails. The coordinator chooses a level for risk, ambiguity and observed mistakes. No automatic escalation or cross-provider retry occurs. A difficult task can start above level 1.
-
-| Candidate field | Meaning |
-|---|---|
-| `model` | Exact host/CLI identifier; omit to inherit native or CLI configuration |
-| `effort` | Supported reasoning setting; omit when unsupported or to retain defaults |
-| `family` | Model lineage, such as gpt, claude, kimi or glm; defaults to the parent family |
-| `runner` | `auto` (default), `native`, or external `codex`, `claude`, `gemini`, `opencode` |
-| `backend` | Optional advanced named executor from `backends`; useful for custom endpoints |
-| `efforts` | Optional declared supported effort values for this exact configured candidate |
-
-A candidate that names an unavailable executor remains selected and reports that limitation. The coordinator must deliberately select another authorized candidate or address the missing capability. It must not silently downgrade or spend on an unconfigured family merely because its CLI exists.
-
-## Native and external are capabilities
-
-A native worker is launched and supervised by the current host's tools. Its family need not match the host if the host really supports other families. The native invocation uses the parent's tool contract, not the external adapter of the target model.
-
-`runner: auto` prefers native execution for the supported families. The default route assumes the parent family can run natively; verify this against the actual tools before dispatch. Use `--no-native` for a host without fan-out, or supply the known supported families with `--native-families gpt,claude`. These flags describe observed capabilities, not a way to grant them. Check the specific model and effort too. A separate backend profile in the same family can still be native.
-
-`runner: native` requires that capability and refuses an external `run`. An explicit CLI runner always chooses external execution, including a same-family worker. `--external` requests an external route unless it conflicts with an explicit native requirement. `agent-run run` executes only CLI workers; use the host's tool for a native route.
-
-For external OpenCode, use a `provider/model` returned by `opencode models` for your configured provider. This establishes the identifier, not account access. For a native Kimi worker, use the identifier exposed by that host or omit it to inherit; an OpenCode identifier is not automatically a native identifier.
-
-Omitted external model/effort uses that CLI's own configuration, not the coordinator's model. Confirm those settings before relying on them. A model/effort override must be supported by the exact host and model; `recommended_reasoning` is guidance, not a provider parameter. Adapters: `providers.md`. Native details: `hosts.md`.
-
-## Selection and compatibility
-
-The active profile's candidates and shared role assignments form the allowed family pool, together with the parent. Inactive profiles do not authorize their models in this task. `auto` supports any number of configured families. The route reports `solo`, `duo` or `mixed` to describe the pool; these labels do not determine worker count or review quality.
+Use a reliable namespaced host chat ID. Without one, omit `--session` on context open once and preserve the generated handle. Cwd and transient shell PIDs do not identify a chat. Prepare and catalog require the retained handle.
-Existing `mode: solo|duo`, `families` (legacy backend IDs), `backends`, object role assignments and preferences remain accepted. Explicit solo restricts the task to the parent family; an excluded role assignment fails visibly. Explicit duo requires two families. Use the role profiles for new setups. Invalid configuration, including an inactive profile, fails visibly rather than restoring defaults.
+Precedence: explicit preset, saved session choice, default for a new session, setup. Switching presets affects new dispatches. `prepare --preset Y2 --task-only` uses Y2 without changing the chat; pass the override to each preparation belonging to that task. `context open --preset Y2` persists it. Unknown explicit IDs do not fall back. Resume always uses the saved snapshot, including limits and executor session, even if the preset has since changed or been removed.
-Precedence: explicit session choice carried in CLI arguments → selected profile role → shared role → backend defaults → current native model or CLI defaults. `--backend`, `--family`, `--runner`, `--model`, `--effort` and `--level` express deliberate per-call choices. An explicit model without a family/backend belongs to the current family; family is never inferred from its spelling. To select a foreign model explicitly, include `--family` or its configured `--backend`.
+A role default is used only when `--agent` is omitted and `--role` is given. Missing defaults fail; they do not inherit the chat model. `inherit_model: true` is supported only with explicit native transport and verified current-model evidence, and excludes model/provider fields. CLI defaults never mean chat inheritance.
-Legacy mode selection is per-call → `DELEGATE_KIT_MODE` → JSON → auto. Legacy main-codex/main-claude presets remain backend preferences. `roles..: [model, effort]` is retained for migration. Custom `backends.` contains a declared family, supported adapter, optional model and supported efforts. A GLM endpoint behind Claude Code must declare family glm; the executable name does not establish diversity.
+## Limits and required review sets
-Review output keeps `fresh_context_required` and `cross_family` separate. `--author-backend self` means the current parent. A configured reviewer ladder applies to each generated reviewer slot; choose a different authorized candidate explicitly for another slot when useful. Review depth chooses coverage, while role level chooses the model/effort. They are independent. An explicit `review.allow_multiple: false` restricts multiple reviewers unless the user grants an exception.
+Optional preset `limits` accepts positive `max_workers`, `max_writers`, `max_runs`, and nonnegative `max_retries`. No implicit worker count applies. `max_runs` counts reservations, including continuations, within a namespaced chat/task budget. `max_retries` counts continuations per profile in that task. Fresh runs and continuations are marked separately in metadata. Failed or cancelled reservations do not refund attempt limits. This conservative rule prevents a crash from granting an uncounted model call.
-## Task counters and limits
+Prepare/resume reserve concurrency before dispatch, including native read-only agents. Release unused reservations with `cancel`. Global known worker counts include v1 external runs and native worktree locks; the host's own capacity also applies. Explicit CLI/environment limits follow the legacy precedence (call > environment > preset); do not raise them without user authorization. Status/wait/result do not consume attempts.
-For delegated work, choose one stable task ID and ticket IDs. The counter tracks starts and resumes, not tokens or money. Keep the same task/ticket when continuing or repairing work. Configuration can set `limits.max_workers`, `max_writers`, `max_runs` and `max_retries`; omitted fields impose no kit limit. Other than retries (which may be zero), limits are positive integers.
+`review.also_run` recursively expands a required set. References must be unique, acyclic and read-only reviewers. Prepare validates all routes and reserves the whole set or refuses it before dispatch. Run each returned ID independently. `accept` requires a valid done result for every required profile in the group. It does not mean the runtime ran acceptance tests: the coordinator verifies evidence before calling it.
-- `max_workers` / `max_writers`: concurrent known runs. External runners count active external processes machine-wide and native writer locks in the current repository. Native read-only workers remain supervised by the host/coordinator. Actual host capacity is always binding.
-- `max_runs`: total starts and resumes for a task, including failed attempts after launch admission.
-- `max_retries`: repair attempts per ticket, recorded with `--retry` for both clarification resumes and fresh replacements.
+## Lifecycle and recovery
-CLI `--max-*` overrides the corresponding `DELEGATE_KIT_MAX_*` environment value, then JSON. Treat saved user limits as hard unless the user's session instruction changes them; flags are not autonomous permission to increase a limit. CLI/environment limits apply to that invocation/session; persistent user limits belong in JSON. Task files store usage, not a copy of configuration limits.
+States: prepared, starting, running, permission, cancelling, orphaned, finished, blocked, failed, cancelled, timeout. Finished means a terminal turn with valid result; `accepted` records the separate coordinator decision. Provider errors or invalid JSON fail even with exit code zero. Full logs stay in the private run folder, separate from the compact result.
-Inspect usage without modifying it:
+A timeout of `wait` returns `wait_timed_out: true` with current state and leaves the worker alive. An optional `prepare --timeout-ms N` is a process deadline; there is no automatic idle kill. Repeated `run` on an already dispatched reservation refuses a duplicate. Native dispatch with an unknown outcome must be reconciled at the host before attach or recovery.
-```
-agent-run budget --task feature-name
-```
-
-Before a native start or resume, reserve its count once:
-
-```
-agent-run budget --task feature-name --record --ticket find-docs
-agent-run budget --task feature-name --record --ticket find-docs --retry
-```
-
-Then dispatch through the host. A recorded reservation is an attempt even if the host subsequently refuses it; state that outcome rather than launching another worker without accounting. Use host notifications for completion. This is bookkeeping for the coordinator, not an automatic interceptor of arbitrary native tools.
-
-External starts and resumes record themselves once, including detached launches:
-
-```
-agent-run run --parent codex --role planner --task feature-name --ticket plan --brief brief.md
-agent-run resume RUN_ID --brief clarification.md --retry
-```
+Each CLI supervisor writes a local heartbeat every five seconds. Status checks verify process identity; a missing supervisor becomes orphaned, and a heartbeat older than 30 seconds requests diagnosis even if the PID still exists. Five minutes without output/progress also sets `health.attention_required`; adjust that diagnostic interval per run with `prepare --stall-ms N` for known long operations. This threshold requests investigation; it does not kill a process or release its lease. Host routes require a real status observation at least once per minute while waiting, and repeated unchanged observations do not reset the progress timer.
-Resume inherits the saved task and ticket. Pass `--retry` when repairing an unsatisfactory result; a normal continuation still counts as a run. A run/retry limit without a task ID is rejected. Exhausted budgets reject admission before launching another external process. Counters update atomically in `tasks/.json` under the state directory.
+`wait` returns early for required attention. On an ordinary wait timeout, inspect returned health and the current host/process state before another bounded wait. On a no-progress alert, inspect bounded logs and the current operation; either document why more time is warranted or interrupt/recover a confirmed stall. Repeating an unchanged wait indefinitely is not a recovery strategy. Status polling and heartbeat files perform no model calls. The coordinator must remain active to perform host probes and decide recovery; an exited parent chat cannot be awakened by this local library.
-Before each wave or repair, inspect progress and count. Continue when the likely useful outcome justifies context transfer and checking; stop repetitive ineffective attempts. User limits bound this judgement; there are no default three-writer/eight-writer thresholds and no pricing lookup requirement.
+Native dispatch failures and late IDs are handled through [host reconciliation](hosts.md#dispatch-and-events); uncertain outcomes retain ownership. A confirmed not-started dispatch can release capacity without inventing an agent ID.
-## Failure and continuation
+`cancel` preserves work and retains ownership until the process group stops. If a supervisor disappears, status becomes orphaned; inspect logs and run `cancel` or `recover` when process identity/termination is established. PID birth checks prevent signalling an unrelated reused PID. Uncertain live descendants retain the lease for manual diagnosis. A stale operation mutex is a visible diagnostic; confirm the owning operation stopped before removing it.
-Missing context or a bounded oversight calls for clarification. Insufficient reasoning calls for a stronger permitted level and a fresh worker. Tool/access failures call for an environment fix. Before replacement, inspect partial work, stop the old writer and transfer ownership.
+Local writers require a linked worktree and use the existing `delegate-kit.lock`. The v2 runtime owns its lease; `agent-wt release/remove` cannot clear an active v2 lease. A Paseo workspace has a daemon-scoped lease and remains owned by Paseo; local cleanup never removes it.
-Resume keeps the saved adapter, family, model and effort, even when profile configuration changes. A CLI-default model remains dependent on that CLI's configuration; pin a known model for reproducibility. A route exposes requested choices; actual runtime identity remains unknown unless confirmed by metadata. Invalid result JSON is a failed worker contract even when the CLI exits successfully.
+Legacy commands are documented in [migration.md](migration.md). Do not use `agent-run route` to resolve a v2 preset.
diff --git a/skills/delegate-kit/references/setup.md b/skills/delegate-kit/references/setup.md
new file mode 100644
index 0000000..18d6d6e
--- /dev/null
+++ b/skills/delegate-kit/references/setup.md
@@ -0,0 +1,40 @@
+# Conversational setup and help
+
+Read this for start/help/create/copy/edit, not every delegation. The current chat stays coordinator. A team may contain one specialist; neither all roles nor multiple providers are required.
+
+1. Run `node /scripts/dk.mjs doctor`. It checks versions without model calls. Describe installed, authorized, supported and live-tested separately. Discover exact models and reasoning through the configured executor or actual host model tools. Do not read secrets into the conversation.
+2. Ask for the missing preset ID and authorized execution routes. Offer a small team and discuss specialists' general purposes. Ask a small group of questions at a time. Avoid ranking models or treating an installed CLI as consent to use an account.
+3. For each profile capture role, `when`, exact executor and optional persistent instructions. Explain access/tool limitations. Additional specialists use different IDs inside the same JSON. An optional planner is for independent analysis, not the host's plan mode. For a required second reviewer add `review.also_run` references within this preset.
+4. Show the resulting team or a meaningful edit diff. Once confirmed or already explicitly requested, write a temporary complete JSON, validate, then save with the runtime. Ask about default only if its change was not part of the request. Do not require the human to edit JSON.
+5. Return the saved ID and a natural next request, e.g. “Use Delegate Kit with Y2 for this task.” Run a live model smoke test only if authorized.
+
+The user can say “Create X1”, “Copy X1 to Y2”, “Use Y2”, “Make X1 default”, or “Replace Y2's researcher”. These are semantic requests for the skill. Host invocation syntax differs; a global shell alias is optional and is not installed.
+
+[main.json](../examples/main.json) is the default starting example, based on the maintainer's working team. It includes two researchers, a planner, two implementers and two reviewers, using Codex and OMP/OpenRouter. Offer `main` as the new preset ID unless the user chooses another name. Discuss the example before adopting it: preserve supplied choices, verify exact model/reasoning support and authorization, and adapt unavailable routes with the user's input. Setup must not silently substitute models or overwrite an existing preset. When the user asks to adopt this default, save the agreed preset and select it as the default for new chats. Installation alone does not activate accounts or copy the example into user state.
+
+Profiles sharing a role are alternatives selected by their `when` descriptions, unless a required review set is explicitly configured. The coordinator decides the sequence; a researcher does not launch another worker. Users can describe profiles and conditions in their own language. For an explicitly requested OMP installation/setup, read [omp-setup.md](omp-setup.md).
+
+## Operations for the coordinator
+
+Use an absolute installed skill path; examples below use `` for `node /scripts/dk.mjs`.
+
+```
+ presets list
+ presets show X1
+ presets validate --file /tmp/team.json
+ presets save --file /tmp/team.json
+ presets copy X1 Y2
+ presets set-default X1
+```
+
+For editing, `show` returns `preset` and `revision`. Edit a complete copy and use `save --file ... --revision `. If another edit won, reload and reconcile; never silently overwrite. Copy refuses an existing destination. Uppercase IDs are supported; IDs are case-sensitive and X1/x1 collisions are refused on every filesystem. Display names can be Unicode. Secrets, arbitrary executables and callbacks are not configuration fields.
+
+[examples/config.json](../examples/config.json) is a template with intentionally rejected model placeholders. Replace them with verified user choices. [preset.schema.json](../assets/preset.schema.json) describes the file shape; runtime validation also checks references, cycles, collisions and placeholders. Unknown roles default to read-only; explicit `access: "workspace-write"` requires an isolated workspace.
+
+## Files and installation
+
+`DELEGATE_KIT_HOME` overrides `~/.delegate-kit`. `settings.json` holds `schema_version: 2` and optional `default_preset`. Each `presets/ID.json` contains its whole team. Hashed `sessions/` folders and `runs/` snapshots are runtime state, not more user configuration. Updates to the installed skill do not overwrite them.
+
+Install the `skills/delegate-kit` directory as one Agent Skill. Node 20+ runs v2 without package dependencies. Git is needed for worktrees; the retained `agent-wt` helper also requires Bash and jq. Install/authorize only the chosen executors on the machine that will run them. The skill does not install CLIs, sync credentials, create remote infrastructure, or provide shell access to cloud-only chats.
+
+The repository supports `npx skills add tomastaker/delegate-kit`; installations from the default branch use the latest merged version. To test unpublished changes, copy this entire skill directory to an isolated host skill location. Preserve the existing installation before replacing it. Existing optional hooks/roles use `hooks/install.sh --dry-run` and the corresponding uninstall script; v2 CLI needs neither. Codex bridge uses actual tool model arguments. Claude native needs a unique managed role discovered by the host, described in [hosts.md](hosts.md).
diff --git a/skills/delegate-kit/scripts/adapters.mjs b/skills/delegate-kit/scripts/adapters.mjs
index 4eba22d..7fcb744 100644
--- a/skills/delegate-kit/scripts/adapters.mjs
+++ b/skills/delegate-kit/scripts/adapters.mjs
@@ -3,12 +3,13 @@ import fs from 'node:fs';
import path from 'node:path';
import { narrowPermissions } from './opencode-permissions.mjs';
const present = value => value !== null && value !== undefined;
-export function buildCommand({ adapter, model, effort, prompt, write, resumeId, skillDir, agentName = 'delegate-kit', permissionRules }) {
+export function buildCommand({ adapter, model, effort, provider, prompt, write, resumeId, skillDir, agentName = 'delegate-kit', permissionRules }) {
const schema = path.join(skillDir, 'references', 'result-schema.json');
if (adapter === 'codex') {
const args = ['exec', ...(resumeId ? ['resume'] : []), '--json', '--skip-git-repo-check', '-c', 'agents.enabled=false'];
args.push('-c', `sandbox_mode="${write ? 'workspace-write' : 'read-only'}"`);
if (present(model)) args.push('-m', model);
+ if (present(provider)) args.push('-c', `model_provider=${JSON.stringify(provider)}`);
if (present(effort)) args.push('-c', `model_reasoning_effort=${JSON.stringify(effort)}`);
args.push('--output-schema', schema, '-o', '__OUT__');
if (resumeId) args.push(resumeId);
diff --git a/skills/delegate-kit/scripts/agent-run b/skills/delegate-kit/scripts/agent-run
index f9c7c2c..85906aa 100755
--- a/skills/delegate-kit/scripts/agent-run
+++ b/skills/delegate-kit/scripts/agent-run
@@ -91,6 +91,7 @@ const alive = (pid) => { try { process.kill(pid, 0); return true; } catch { retu
* Preserve an existing killReason instead of replacing it with orphaned.
*/
function reconcile(m) {
+ if (m?.schema_version === 2) return m; // v2 owns descendant/process identity and its leases.
if (!m || m.status !== "running" || alive(m.pid)) return m;
m.status = m.killReason || "orphaned";
m.finished = m.finished || nowIso();
@@ -133,7 +134,9 @@ function lockPath(cwd) { const g = gitDirOf(cwd); return g ? path.join(g, "deleg
function activeRuns() {
if (!fs.existsSync(RUNS_DIR)) return [];
- return fs.readdirSync(RUNS_DIR).map(metaOf).filter(Boolean).filter((m) => m.status === "running" && alive(m.pid));
+ return fs.readdirSync(RUNS_DIR).map(metaOf).filter(Boolean).filter((m) => m.schema_version === 2
+ ? ["prepared", "starting", "running", "permission", "cancelling", "orphaned"].includes(m.status)
+ : m.status === "running" && alive(m.pid));
}
// Why the worktree cannot take this writer, or null. Checked in the parent before the
@@ -142,6 +145,7 @@ function writeLockConflict(cwd, id) {
const lp = lockPath(cwd);
if (!lp) return `--cwd ${cwd} is not inside a git repository; writers must run in a git worktree`;
const existing = readJson(lp);
+ if (existing?.kind === "v2" && existing.id !== id) return `worktree is owned by v2 run ${existing.id}; use dk.mjs cancel/recover after inspecting partial work`;
if (existing && existing.kind === "native" && existing.id !== id) {
return `worktree is locked for a native subagent (${existing.label || existing.id}, since ${existing.since}). Release it with \`agent-wt release \` when that subagent is done.`;
}
@@ -754,6 +758,8 @@ function cmdLog(id, which = "stdout") { const p = path.join(runDir(id), which ==
async function main() {
const argv = parseArgs(process.argv.slice(2));
const sub = argv._[0];
+if (["status", "wait", "kill", "resume", "log"].includes(sub) && argv._[1] && metaOf(argv._[1])?.schema_version === 2) die("v2 run: use scripts/dk.mjs; legacy lifecycle operations cannot release its ownership");
+if (["route", "run", "preset", "doctor"].includes(sub)) process.stderr.write("agent-run: legacy v1 command; use scripts/dk.mjs for v2 presets. Saved v1 runs remain supported.\n");
fs.mkdirSync(RUNS_DIR, { recursive: true });
switch (sub) {
case "run": await cmdRun(argv); break;
diff --git a/skills/delegate-kit/scripts/agent-wt b/skills/delegate-kit/scripts/agent-wt
index 0e95b5a..98cf5b6 100755
--- a/skills/delegate-kit/scripts/agent-wt
+++ b/skills/delegate-kit/scripts/agent-wt
@@ -53,7 +53,7 @@ lock_state() { # state of one lock file: unlocked | locked(...) | stale-lock(...
local kind pid; kind=$(jq -r '.kind // "process"' "$lf"); pid=$(jq -r '.pid // empty' "$lf")
# A native lock has no process behind it: the parent holds it on behalf of its own
# subagent and releases it by hand. It is live until someone releases it.
- if [ "$kind" = "native" ]; then echo "locked(native, label=$(jq -r '.label // "-"' "$lf"), since=$(jq -r '.since // "?"' "$lf"))"; return; fi
+ if [ "$kind" = "native" ] || [ "$kind" = "v2" ]; then echo "locked($kind, label=$(jq -r '.label // "-"' "$lf"), since=$(jq -r '.since // "?"' "$lf"))"; return; fi
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then echo "locked(run=$(jq -r .id "$lf"), pid=$pid)"; else echo "stale-lock(run=$(jq -r .id "$lf"))"; fi
}
@@ -131,11 +131,11 @@ const [stateDir, common] = process.argv.slice(2);
const alive = pid => { if (!Number.isInteger(pid) || pid < 1) return false; try { process.kill(pid, 0); return true; } catch (error) { return error.code === 'EPERM'; } };
const entries = dir => { try { return fs.readdirSync(dir); } catch (error) { if (error.code === 'ENOENT') return []; throw error; } };
const read = file => { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch (error) { if (error.code === 'ENOENT') return null; throw error; } };
-const runs = entries(path.join(stateDir, 'runs')).map(id => read(path.join(stateDir, 'runs', id, 'meta.json'))).filter(run => run && run.status === 'running' && alive(run.pid));
+const runs = entries(path.join(stateDir, 'runs')).map(id => read(path.join(stateDir, 'runs', id, 'meta.json'))).filter(run => run && (run.schema_version === 2 ? ['prepared', 'starting', 'running', 'permission', 'cancelling', 'orphaned'].includes(run.status) : run.status === 'running' && alive(run.pid)));
const counts = { workers: runs.length, writers: runs.filter(run => run.write).length };
for (const name of entries(path.join(common, 'worktrees'))) {
const lock = read(path.join(common, 'worktrees', name, 'delegate-kit.lock'));
- if (lock?.kind === 'native' || (lock && alive(lock.pid) && !runs.some(run => run.id === lock.id || (run.write && run.cwd === lock.cwd)))) {
+ if (lock && !runs.some(run => run.id === lock.id || (run.write && run.cwd === lock.cwd)) && (['native', 'v2'].includes(lock.kind) || alive(lock.pid))) {
counts.workers += 1;
counts.writers += 1;
}
@@ -196,6 +196,7 @@ cmd_release() {
local force=0; [ "${1:-}" = "--force" ] && force=1
local lf; lf=$(lock_file "$name") || die "no such worktree: $name"
[ -f "$lf" ] || { echo "already unlocked"; return; }
+ [ "$(jq -r '.kind // empty' "$lf")" != "v2" ] || die "v2 run owns this worktree; use dk.mjs cancel/recover before release"
local pid; pid=$(jq -r '.pid // empty' "$lf")
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null && [ $force -eq 0 ]; then die "lock held by live process $pid; use --force after stopping the worker (agent-run kill )"; fi
# native locks have no process: releasing one is the parent saying its subagent is done
@@ -207,6 +208,7 @@ cmd_remove() {
local force=0; [ "${1:-}" = "--force" ] && force=1
local p; p=$(wt_path "$name"); [ -d "$p" ] || die "no such worktree: $name"
local lock; lock=$(lock_info "$name")
+ [[ "$lock" != locked\(v2,* ]] || die "v2 run owns this worktree; use dk.mjs cancel/recover before removal"
[[ "$lock" == locked* ]] && [ $force -eq 0 ] && die "worktree is $lock; stop the worker first"
local dirty; dirty=$(cd "$p" && git status --porcelain | wc -l | tr -d ' ')
[ "$dirty" != "0" ] && [ $force -eq 0 ] && die "worktree has $dirty uncommitted changes; commit/stash or use --force"
diff --git a/skills/delegate-kit/scripts/dk.mjs b/skills/delegate-kit/scripts/dk.mjs
new file mode 100644
index 0000000..dbb934c
--- /dev/null
+++ b/skills/delegate-kit/scripts/dk.mjs
@@ -0,0 +1,95 @@
+#!/usr/bin/env node
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { home, check, readJSON, validatePreset, presetFiles, loadPreset, savePreset, copyPreset, setDefault, context, catalog } from './presets.mjs';
+import { discover } from './executors.mjs';
+import { migrate } from './migrate.mjs';
+import { prepare, launch, attach, ingest, resume, status, wait, cancel, recover, accept, supervise, getRun, dispatchFailed } from './runtime.mjs';
+
+function args(input) {
+ const o = { _: [] };
+ const booleans = ['task-only', 'dry-run', 'apply', 'stopped', 'confirmed-not-started'];
+ for (let i = 0; i < input.length; i++) {
+ if (!input[i].startsWith('--')) { o._.push(input[i]); continue; }
+ const key = input[i].slice(2); check(!Object.hasOwn(o, key), `Duplicate --${key}`);
+ if (booleans.includes(key)) o[key] = true;
+ else { check(input[i + 1] !== undefined && !input[i + 1].startsWith('--'), `--${key} requires a value`); o[key] = input[++i]; }
+ }
+ return o;
+}
+const optKeys = {
+ context: ['session', 'preset', 'task-only'], catalog: ['session', 'preset', 'task-only', 'role'],
+ presets: ['file', 'revision'], prepare: ['session', 'preset', 'task-only', 'task', 'agent', 'role', 'brief', 'cwd', 'capabilities', 'workspace', 'timeout-ms', 'stall-ms', 'max-workers', 'max-writers', 'max-runs', 'max-retries'],
+ run: [], attach: ['host-agent', 'workspace-id'], status: [], result: [], wait: ['timeout-ms'], resume: ['brief'], cancel: [], recover: [], accept: [],
+ 'dispatch-failed': ['dispatch-token', 'confirmed-not-started', 'evidence'],
+ event: ['host-agent', 'event', 'file', 'stopped', 'dispatch-token', 'progress'], doctor: [], migrate: ['decisions', 'dry-run', 'apply'],
+ materialize: ['directory'], _supervise: ['claim'], help: [], start: [],
+};
+export async function main(input = process.argv.slice(2)) {
+ process.umask(0o077);
+ const o = args(input), [command = 'help', action, target] = o._;
+ check(Object.hasOwn(optKeys, command), `Unknown command ${command}; use help`);
+ for (const key of Object.keys(o)) check(key === '_' || optKeys[command].includes(key), `Unknown ${command} option --${key}`);
+ const ms = o['timeout-ms'] === undefined ? undefined : Number(o['timeout-ms']);
+ if (ms !== undefined) check(Number.isSafeInteger(ms) && ms > 0, '--timeout-ms must be a positive integer');
+ const selection = { session: o.session, preset: o.preset, taskOnly: o['task-only'] === true };
+ switch (command) {
+ case 'context': {
+ check(action === 'open', 'Use context open'); const c = context(selection);
+ return { session: c.session, preset: c.preset.id, revision: c.revision, task_only: c.task_only };
+ }
+ case 'catalog': { check(o.session, 'catalog requires --session'); const c = context(selection); return { session: c.session, revision: c.revision, ...catalog(c.preset, o.role) }; }
+ case 'presets': {
+ if (action === 'list' || action === 'audit') return presetFiles().map(file => {
+ try { const p = loadPreset(file.slice(0, -5)); return { id: p.preset.id, revision: p.revision, valid: true }; }
+ catch (e) { return { file, valid: false, error: e.message }; }
+ });
+ if (action === 'show') return loadPreset(target);
+ if (action === 'validate') { const p = validatePreset(readJSON(o.file)); return { id: p.id, valid: true }; }
+ if (action === 'save') return savePreset(readJSON(o.file), o.revision ?? null);
+ if (action === 'copy') return copyPreset(target, o._[3]);
+ if (action === 'set-default') return setDefault(target);
+ throw new Error('Use presets list|audit|show ID|validate --file FILE|save --file FILE [--revision HASH]|copy X1 Y2|set-default ID');
+ }
+ case 'prepare': return prepare({ ...selection, task: o.task, agent: o.agent, role: o.role, brief: o.brief, cwd: o.cwd, timeoutMs: ms, stallMs: o['stall-ms'] === undefined ? undefined : Number(o['stall-ms']),
+ capabilities: o.capabilities ? readJSON(o.capabilities) : [], workspace: o.workspace ? readJSON(o.workspace) : null,
+ limits: Object.fromEntries(Object.entries(o).filter(([k]) => k.startsWith('max-'))) });
+ case 'run': return launch(action);
+ case 'attach': return attach(action, o['host-agent'], o['workspace-id']);
+ case 'dispatch-failed': return dispatchFailed(action, { dispatchToken: o['dispatch-token'], confirmedNotStarted: o['confirmed-not-started'], evidence: o.evidence ? fs.readFileSync(o.evidence, 'utf8') : undefined });
+ case 'event': return ingest(action, { hostAgent: o['host-agent'], event: o.event, result: o.file ? readJSON(o.file) : undefined, stopped: o.stopped, dispatchToken: o['dispatch-token'], progress: o.progress });
+ case 'status': case 'result': return status(action);
+ case 'wait': return wait(action, ms);
+ case 'resume': return resume(action, o.brief);
+ case 'cancel': return cancel(action);
+ case 'recover': return recover(action);
+ case 'accept': return accept(action);
+ case 'doctor': return { executors: discover(), root: home(), note: 'No model calls made. Installed/version is not proof of authorization. See references/providers.md.' };
+ case 'migrate': check(!(o.apply && o['dry-run']), 'Choose --apply or --dry-run'); return migrate(o.decisions ? readJSON(o.decisions) : {}, o.apply === true);
+ case 'materialize': {
+ const m = getRun(action), definition = m.invoke?.definition;
+ check(m.executor.transport === 'native' && definition && m.status === 'starting', 'Run must return a native definition before materialization');
+ check(o.directory, '--directory must be the verified host agent directory');
+ const file = path.join(path.resolve(o.directory), `${definition.name}.md`);
+ const text = `---\nname: ${definition.name}\ndescription: Isolated Delegate Kit run ${m.id}\nmodel: ${JSON.stringify(definition.model)}\n${definition.effort ? `effort: ${JSON.stringify(definition.effort)}\n` : ''}tools: ${definition.tools.join(', ')}\ndisallowedTools: ${definition.disallowedTools.join(', ')}\n---\n${definition.instructions}\n`;
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
+ if (fs.existsSync(file)) check(fs.readFileSync(file, 'utf8') === text, 'Managed role path conflicts with an existing file');
+ else fs.writeFileSync(file, text, { flag: 'wx', mode: 0o600 });
+ return { file, name: definition.name, cleanup: 'Remove only this unchanged per-run file after completion; never shared user roles' };
+ }
+ case '_supervise': await supervise(action, o.claim); return;
+ default: return { setup: 'The current chat remains coordinator. Use references/setup.md to create a complete preset; do not infer models or launch paid smoke tests.',
+ commands: ['doctor', 'presets list|audit|show ID', 'presets validate|save --file FILE [--revision HASH]', 'presets copy X1 Y2', 'presets set-default X1',
+ 'context open [--session HOST:ID] [--preset X1] [--task-only]', 'catalog --session HOST:ID [--role ROLE]',
+ 'prepare --session HOST:ID --task ID --agent PROFILE --brief FILE [--cwd WORKTREE] [--capabilities FILE] [--workspace FILE]',
+ 'run ID', 'attach ID --host-agent ID [--workspace-id ID]', 'event ID --host-agent ID --dispatch-token TOKEN --event complete --file RESULT --stopped',
+ 'dispatch-failed ID --dispatch-token TOKEN --confirmed-not-started --evidence FILE', 'status|result|wait|cancel|recover|accept ID', 'resume ID --brief FILE', 'migrate --dry-run [--decisions FILE]'],
+ root: home(), note: 'Invoke this script by its installed path. No global dk command is installed. Legacy runs use agent-run.' };
+ }
+}
+if (process.argv[1] && fs.existsSync(process.argv[1]) && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
+ main().then(value => { if (value !== undefined) console.log(JSON.stringify(value, null, 2)); }).catch(error => {
+ console.error(JSON.stringify({ error: error.message })); process.exitCode = 1;
+ });
+}
diff --git a/skills/delegate-kit/scripts/executors.mjs b/skills/delegate-kit/scripts/executors.mjs
new file mode 100644
index 0000000..485fe7a
--- /dev/null
+++ b/skills/delegate-kit/scripts/executors.mjs
@@ -0,0 +1,105 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import { spawnSync } from 'node:child_process';
+import { check, accessOf, harnesses } from './presets.mjs';
+
+const efforts = {
+ codex: ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'],
+ claude: ['low', 'medium', 'high', 'xhigh', 'max'],
+ pi: ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'],
+ omp: ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'],
+};
+export function discover() {
+ return harnesses.map(harness => {
+ const r = spawnSync(harness, ['--version'], { encoding: 'utf8', timeout: 5000 });
+ // Version only: never print tool stderr, auth configuration or environment.
+ const version = r.status === 0 ? (r.stdout || '').match(/\b\d+\.\d+\.\d+(?:[-+][\w.-]+)?/)?.[0] : null;
+ return { harness, installed: !r.error || r.error.code !== 'ENOENT', version: version || 'unknown',
+ authentication: 'unknown', live_tested: false, models: 'unknown; use the configured executor model picker',
+ cli: { fresh: 'supported', resume: 'supported', cancel: 'supported', wait: 'supported',
+ access: 'tool restrictions; not a universal OS sandbox', actual_model: ['pi', 'omp'].includes(harness) ? 'runtime message metadata' : 'unknown' } };
+ });
+}
+
+// Host evidence is supplied by the coordinator after reading the actual tool schema
+// and provider discovery. It is a per-run technical attestation, not another team config.
+function compatible(e, access, c) {
+ if (!c || c.harness !== e.harness || c.verified !== true || !['native', 'paseo'].includes(c.transport)) return false;
+ if (!c.version || !c.host || !c.resume || !c.result || !c.cancel || !c.access?.includes(access)) return false;
+ if (e.provider !== undefined && e.provider !== c.provider) return false;
+ const model = e.inherit_model ? c.current_model : e.model;
+ const entry = c.models?.find(m => m.id === model);
+ return Boolean(entry && (e.reasoning === undefined || entry.reasoning?.includes(e.reasoning)));
+}
+export function resolveExecutor(agent, capabilities = [], available = () => true) {
+ check(Array.isArray(capabilities), 'Capability evidence must be an array');
+ const e = agent.executor, access = accessOf(agent), desired = e.transport || 'auto';
+ const host = ['paseo', 'native'].flatMap(t => capabilities.filter(c => c.transport === t)).find(c =>
+ (desired === 'auto' || desired === c.transport) && compatible(e, access, c));
+ if (host) {
+ check(host.transport !== 'native' || (['codex', 'claude'].includes(host.host) && host.host === e.harness), `No native bridge preserving ${e.harness} on ${host.host}; use an explicit CLI route`);
+ check(host.transport !== 'native' || host.host !== 'claude' || host.dynamic_roles === true, 'Claude native requires verified discovery of per-run role definitions; use CLI if a restart is required');
+ check(host.launch_provider === undefined || host.launch_provider === e.harness, 'Paseo launch provider cannot replace the selected harness');
+ check(host.transport !== 'paseo' || host.daemon, 'Paseo capability evidence needs a stable daemon identifier');
+ return { ...e, model: e.inherit_model ? host.current_model : e.model, transport: host.transport, access, capability: host, actual_model: null };
+ }
+ check(desired === 'auto' || desired === 'cli', `${desired} cannot preserve ${e.harness} model/provider/reasoning/access; supply verified host capabilities or choose CLI explicitly`);
+ if (desired === 'auto') check(!capabilities.some(c => c.harness === e.harness && ['native', 'paseo'].includes(c.transport) && c.cli_equivalent !== true), 'Host route is incompatible and CLI equivalence is unverified; select transport cli explicitly after checking its provider/account');
+ check(!e.inherit_model, 'CLI defaults cannot inherit the parent chat model');
+ if (e.provider !== undefined) check(['codex', 'opencode', 'pi', 'omp'].includes(e.harness), `${e.harness}: provider selection is unsupported; use its configured connection without a provider override`);
+ if (e.reasoning !== undefined) {
+ check(e.harness !== 'gemini', 'Gemini CLI has no reasoning flag');
+ check(e.harness === 'opencode' || efforts[e.harness]?.includes(e.reasoning), `${e.harness}: unsupported reasoning ${e.reasoning}`);
+ }
+ let model = e.model;
+ if (e.harness === 'opencode') {
+ if (e.provider) { check(!model.includes('/') || model.startsWith(`${e.provider}/`), 'OpenCode model conflicts with provider'); model = model.includes('/') ? model : `${e.provider}/${model}`; }
+ check(model.includes('/'), 'OpenCode needs an exact provider/model identifier');
+ }
+ const cliCapability = capabilities.find(c => c.transport === 'cli' && c.harness === e.harness && c.verified === true && c.version &&
+ (e.provider === undefined || c.provider === e.provider) && c.models?.some(m => m.id === model && m.reasoning?.includes(e.reasoning)));
+ if (e.harness === 'opencode' && e.reasoning !== undefined) check(cliCapability, 'OpenCode reasoning variant needs verified per-model CLI capability evidence; unknown variants must not be silently ignored');
+ if (['pi', 'omp'].includes(e.harness)) check(e.provider, `${e.harness}: specify provider for exact RPC model selection`);
+ check(available(e.harness), `${e.harness} CLI is unavailable; the selected profile has not been replaced`);
+ return { ...e, model, transport: 'cli', access, ...(cliCapability ? { capability: cliCapability } : {}), actual_model: null };
+}
+
+// Native agents inherit placement from the host. A path in the prompt is not a binding.
+export function assertWorkspaceBinding(executor, cwd) {
+ if (executor.transport !== 'native' || executor.access !== 'workspace-write') return;
+ const c = executor.capability, binding = c?.workspace_binding;
+ let matches = false;
+ if (c?.verified === true && binding?.enforced === true && typeof binding.cwd === 'string' && path.isAbsolute(binding.cwd) && typeof cwd === 'string' && path.isAbsolute(cwd)) {
+ try { matches = fs.realpathSync(binding.cwd) === fs.realpathSync(cwd); } catch { /* Missing workspace evidence fails closed. */ }
+ }
+ check(matches, 'Native writer requires a verified, enforced host binding to the reserved worktree; choose an explicit CLI route when the host cannot provide it');
+}
+
+export function bridgeInvocation(meta, prompt) {
+ assertWorkspaceBinding(meta.executor, meta.cwd);
+ const e = meta.executor, c = e.capability;
+ if (e.transport === 'paseo') {
+ const modeId = c.mode_ids?.[e.access];
+ check(modeId, `Paseo: discover a mode enforcing ${e.access} for ${e.harness}`);
+ check(meta.workspace?.owner === 'paseo' && meta.workspace.id, 'Paseo requires an existing Paseo workspace handle');
+ return { daemon: c.daemon, tool: meta.resume_of ? 'send_agent_prompt' : 'create_agent',
+ arguments: meta.resume_of ? { agentId: meta.transport_session_id, prompt, background: true, notifyOnFinish: true } : {
+ title: `Delegate Kit ${meta.profile}`, provider: `${c.launch_provider || e.harness}/${e.model}`, initialPrompt: prompt,
+ workspaceId: meta.workspace.id, notifyOnFinish: true,
+ settings: { modeId, ...(e.reasoning === undefined ? {} : { thinkingOptionId: e.reasoning }) },
+ }, parent_session: meta.parent_session, note: 'Call on the saved daemon in the parent agent context. Attach the returned agentId/workspaceId; wait for completion notification.' };
+ }
+ if (c.host === 'codex') return {
+ tool: meta.resume_of ? 'followup_task' : 'spawn_agent',
+ arguments: meta.resume_of ? { target: meta.transport_session_id, message: prompt } : {
+ task_name: `dk_${meta.id.replaceAll('-', '_')}`, message: prompt, fork_turns: 'none', model: e.model,
+ ...(e.reasoning === undefined ? {} : { reasoning_effort: e.reasoning }),
+ }, note: 'Use only the verified host schema. Access is inherited from the host; the brief is not a sandbox. Attach the returned agent ID; ingest the final result.' };
+ check(c.host === 'claude', 'Unsupported native bridge');
+ return { tool: 'Agent', arguments: meta.resume_of ? { resume: meta.transport_session_id, prompt } : {
+ subagent_type: `dk-${meta.id}`, prompt,
+ }, definition: { name: `dk-${meta.id}`, model: e.model, ...(e.reasoning === undefined ? {} : { effort: e.reasoning }),
+ tools: e.access === 'read-only' ? ['Read', 'Glob', 'Grep', 'WebFetch', 'WebSearch'] : ['Read', 'Glob', 'Grep', 'Edit', 'Write'],
+ disallowedTools: ['Agent', 'Task'], instructions: 'Complete only the supplied brief; delegation depth is one.' },
+ note: 'Materialize this unique managed definition in the verified Claude agent directory before dispatch. Never overwrite a shared role or a user file.' };
+}
diff --git a/skills/delegate-kit/scripts/legacy-routing.mjs b/skills/delegate-kit/scripts/legacy-routing.mjs
new file mode 100644
index 0000000..a41296d
--- /dev/null
+++ b/skills/delegate-kit/scripts/legacy-routing.mjs
@@ -0,0 +1,241 @@
+// Model identifiers belong to the host or user configuration, never to role policy.
+import fs from 'node:fs';
+import { spawnSync } from 'node:child_process';
+import { validateLimits } from './limits.mjs';
+
+export const roles = Object.fromEntries(['planner', 'implementer', 'reviewer', 'verifier', 'researcher', 'review-lead'].map(role => [role, { write: role === 'implementer' }]));
+export const defaults = {
+ codex: { family: 'gpt', adapter: 'codex' },
+ claude: { family: 'claude', adapter: 'claude' },
+ gemini: { family: 'gemini', adapter: 'gemini' },
+ kimi: { family: 'kimi', adapter: 'opencode' },
+ glm: { family: 'glm', adapter: 'opencode' },
+};
+const adapters = ['codex', 'claude', 'gemini', 'opencode'];
+const runners = ['auto', 'native', ...adapters];
+const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
+const check = (condition, message) => { if (!condition) throw new Error(message); };
+const identifier = value => typeof value === 'string' && /^[a-z][a-z0-9_-]*$/.test(value);
+const nonempty = value => typeof value === 'string' && value.trim().length > 0;
+const candidateKeys = ['model', 'effort', 'family', 'runner', 'backend', 'efforts'];
+const isCandidate = value => candidateKeys.some(key => Object.hasOwn(value, key));
+const defaultEfforts = (adapter, family) => adapter === 'codex' ? ['low', 'medium', 'high', 'xhigh', 'max', 'ultra']
+ : adapter === 'claude' && family === 'claude' ? ['low', 'medium', 'high', 'xhigh', 'max'] : [];
+export function readConfig(file) {
+ let text;
+ try { text = fs.readFileSync(file, 'utf8'); } catch (error) { if (error.code === 'ENOENT') return {}; throw error; }
+ let config;
+ try { config = JSON.parse(text); } catch { throw new Error(`Invalid JSON in ${file}; fix it before routing`); }
+ validateConfig(config);
+ return config;
+}
+export function backendTable(config) {
+ check(config.backends === undefined || object(config.backends), 'backends must be an object');
+ const table = structuredClone(defaults);
+ for (const [name, entry] of Object.entries(config.backends || {})) {
+ check(identifier(name) && object(entry), `Invalid backend ${name}`);
+ for (const key of Object.keys(entry)) check(['family', 'adapter', 'model', 'efforts'].includes(key), `Unknown backends.${name}.${key}`);
+ const merged = { ...table[name], ...entry };
+ check(identifier(merged.family) && adapters.includes(merged.adapter), `Backend ${name} needs a family and a supported adapter`);
+ check(merged.model === undefined || (typeof merged.model === 'string' && merged.model.trim() !== ''), `Invalid model for ${name}`);
+ check(merged.efforts === undefined || (Array.isArray(merged.efforts) && merged.efforts.every(e => typeof e === 'string' && e.length)), `Invalid efforts for ${name}`);
+ table[name] = merged;
+ }
+ return table;
+}
+function validateCandidate(candidate, location, table) {
+ check(object(candidate), `${location} must be an object`);
+ for (const key of Object.keys(candidate)) check(candidateKeys.includes(key), `Unknown ${location}.${key}`);
+ for (const key of ['model', 'effort']) check(candidate[key] === undefined || nonempty(candidate[key]), `Invalid ${location}.${key}`);
+ check(candidate.family === undefined || identifier(candidate.family), `Invalid ${location}.family`);
+ check(candidate.runner === undefined || runners.includes(candidate.runner), `Invalid ${location}.runner`);
+ check(candidate.backend === undefined || Object.hasOwn(table, candidate.backend), `Unknown ${location}.backend`);
+ check(candidate.efforts === undefined || (Array.isArray(candidate.efforts) && candidate.efforts.every(nonempty)), `Invalid ${location}.efforts`);
+ if (candidate.backend && candidate.family) check(table[candidate.backend].family === candidate.family, `${location}.family conflicts with backend`);
+ if (candidate.efforts && candidate.effort) check(candidate.efforts.includes(candidate.effort), `${location} does not declare effort ${candidate.effort}`);
+ // Explicit external transports have static restrictions, even in inactive profiles.
+ if (adapters.includes(candidate.runner)) {
+ const entry = table[candidate.backend];
+ const family = candidate.family ?? entry?.family;
+ const model = candidate.model ?? entry?.model;
+ if (candidate.runner === 'opencode' && model) check(model.includes('/'), `${location}: OpenCode requires provider/model`);
+ if (candidate.effort) {
+ check(candidate.runner !== 'gemini', `${location}: Gemini CLI has no --effort flag`);
+ const permitted = candidate.efforts ?? entry?.efforts ?? defaultEfforts(candidate.runner, family);
+ // An omitted family depends on the current parent; resolve checks it later.
+ if (family || candidate.runner !== 'claude' || candidate.efforts || entry?.efforts) check(permitted.includes(candidate.effort), `${location} does not declare effort ${candidate.effort}`);
+ }
+ }
+}
+function validateRoles(assignments, location, table) {
+ check(assignments === undefined || object(assignments), `${location} must be an object`);
+ for (const [name, assignment] of Object.entries(assignments || {})) {
+ check(Object.hasOwn(roles, name), `Unknown role: ${name}`);
+ const at = `${location}.${name}`;
+ if (Array.isArray(assignment)) {
+ check(assignment.length > 0, `${at} must be a nonempty candidate list`);
+ assignment.forEach((candidate, index) => validateCandidate(candidate, `${at}[${index}]`, table));
+ } else {
+ check(object(assignment), `${at} must be an object or nonempty candidate list`);
+ if (isCandidate(assignment)) validateCandidate(assignment, at, table);
+ else for (const [backend, pair] of Object.entries(assignment)) {
+ check(Object.hasOwn(table, backend) && Array.isArray(pair) && pair.length === 2 && pair.every(value => typeof value === 'string'), `Invalid legacy ${at}.${backend}`);
+ }
+ }
+ }
+}
+function validateConfig(config) {
+ check(object(config), 'config.json must contain an object');
+ for (const key of Object.keys(config)) check(['mode', 'backends', 'families', 'roles', 'profiles', 'preferences', 'review', 'preset', 'limits'].includes(key), `Unknown config key: ${key}`);
+ const table = backendTable(config);
+ check(config.mode === undefined || ['auto', 'solo', 'duo'].includes(config.mode), 'mode must be auto, solo or duo');
+ check(config.preset === undefined || ['auto', 'main-claude', 'main-codex', 'main-gpt'].includes(config.preset), 'Unknown preset');
+ check(config.families === undefined || (Array.isArray(config.families) && config.families.length > 0 && config.families.every(b => typeof b === 'string' && Object.hasOwn(table, b))), 'families must list backend IDs');
+ validateRoles(config.roles, 'roles', table);
+ check(config.profiles === undefined || object(config.profiles), 'profiles must be an object');
+ for (const [name, profile] of Object.entries(config.profiles || {})) {
+ check(identifier(name) && object(profile), `Invalid profile ${name}`);
+ for (const key of Object.keys(profile)) check(key === 'roles', `Unknown profiles.${name}.${key}`);
+ validateRoles(profile.roles, `profiles.${name}.roles`, table);
+ }
+ check(config.preferences === undefined || object(config.preferences), 'preferences must be an object');
+ for (const [key, pref] of Object.entries(config.preferences || {})) {
+ check(Object.hasOwn(roles, key) || key === 'ui', `Unknown preference: ${key}`);
+ check(Array.isArray(pref) && pref.every(b => typeof b === 'string' && Object.hasOwn(table, b)), `Invalid preferences.${key}`);
+ }
+ if (config.review !== undefined) {
+ check(object(config.review), 'review must be an object');
+ for (const key of Object.keys(config.review)) check(key === 'allow_multiple', `Unknown review.${key}`);
+ check(config.review.allow_multiple === undefined || typeof config.review.allow_multiple === 'boolean', 'review.allow_multiple must be boolean');
+ }
+ validateLimits(config.limits);
+ return table;
+}
+export function installed(adapter) {
+ // Adapter is validated against a fixed list; no model/config text reaches a shell.
+ return spawnSync('/bin/sh', ['-c', `command -v ${adapter}`], { stdio: 'ignore' }).status === 0;
+}
+export function parentOf(opts, env = process.env) {
+ return opts.parent || env.DELEGATE_KIT_PARENT ||
+ (env.CLAUDECODE || env.CLAUDE_CODE_ENTRYPOINT ? 'claude' :
+ env.CODEX_SANDBOX || env.CODEX_NON_INTERACTIVE || env.CODEX_HOME ? 'codex' : null);
+}
+export function resolve(opts, config = {}, env = process.env, available = installed) {
+ const table = validateConfig(config);
+ const role = opts.role;
+ check(Object.hasOwn(roles, role), `--role must be one of ${Object.keys(roles).join('|')}`);
+ const parent = parentOf(opts, env);
+ check(!parent || Object.hasOwn(table, parent), `Unknown parent backend: ${parent}`);
+ const parentFamily = table[parent]?.family;
+ const profile = opts.profile ?? (Object.hasOwn(config.profiles || {}, parentFamily) ? parentFamily : null);
+ check(profile === null || (typeof profile === 'string' && Object.hasOwn(config.profiles || {}, profile)), `Unknown profile: ${profile}`);
+ const assignments = { ...config.roles, ...config.profiles?.[profile]?.roles };
+ const fallbackRole = { verifier: 'reviewer', 'review-lead': 'planner' }[role];
+ const assignment = assignments[role] ?? assignments[fallbackRole] ?? {};
+ const ladder = Array.isArray(assignment) ? assignment : [assignment];
+ const level = opts.level === undefined ? 1 : Number(opts.level);
+ check((typeof opts.level === 'string' || typeof opts.level === 'number' || opts.level === undefined) && Number.isSafeInteger(level) && level >= 1 && level <= ladder.length, `--level must be between 1 and ${ladder.length}`);
+ let candidate = isCandidate(ladder[level - 1]) ? ladder[level - 1] : {};
+ const mode = opts.mode || env.DELEGATE_KIT_MODE || config.mode || 'auto';
+ check(['auto', 'solo', 'duo'].includes(mode), '--mode must be auto, solo or duo');
+ const preset = opts.preset || env.DELEGATE_KIT_PRESET || config.preset || 'auto';
+ const legacy = { 'main-claude': 'claude', 'main-codex': 'codex', 'main-gpt': 'codex' };
+ check(preset === 'auto' || legacy[preset], 'Unknown preset; use auto, main-claude or main-codex');
+ check(opts.backend === undefined || Object.hasOwn(table, opts.backend), `Unknown backend ${opts.backend}`);
+ check(opts.family === undefined || identifier(opts.family), '--family requires a family identifier');
+ check(opts.runner === undefined || runners.includes(opts.runner), '--runner must be auto, native, codex, claude, gemini or opencode');
+ const candidateFamily = value => value.family ?? table[value.backend]?.family ?? parentFamily;
+ // A call selecting another executor must not carry model pins from the old one.
+ if ((opts.backend && ((candidate.backend && candidate.backend !== opts.backend) || candidateFamily(candidate) !== table[opts.backend].family)) ||
+ (opts.family && candidateFamily(candidate) !== opts.family) ||
+ (opts.model && !opts.family && !opts.backend && candidateFamily(candidate) !== parentFamily)) candidate = {};
+ let family = opts.family ?? table[opts.backend]?.family ?? candidateFamily(candidate);
+ let backend = opts.backend ?? candidate.backend;
+ const explicitModel = opts.model !== undefined;
+ if (explicitModel && !opts.family && !opts.backend) family = parentFamily;
+ if (opts.backend && opts.family) check(table[opts.backend].family === opts.family, '--family conflicts with --backend');
+ const runner = opts.runner ?? candidate.runner ?? 'auto';
+ check(!(runner === 'native' && opts.external === true), 'runner native requires the host native tool; agent-run run cannot execute it externally');
+ let nativeFamilies = opts['native-families'] === undefined ? [parentFamily].filter(Boolean) : String(opts['native-families']).split(',');
+ check(nativeFamilies.every(identifier), '--native-families must list family identifiers');
+ if (opts['no-native'] === true) nativeFamilies = [];
+ const canNative = f => Boolean(parent && nativeFamilies.includes(f));
+ const forceExternal = opts.external === true || adapters.includes(runner);
+ let pool = opts.families === undefined ? config.families : String(opts.families).split(',');
+ const explicitPool = pool !== undefined;
+ if (explicitPool) check(Array.isArray(pool) && pool.length > 0 && pool.every(b => typeof b === 'string' && Object.hasOwn(table, b)), 'families must list backend IDs');
+ const modern = profile !== null || Object.values(assignments).some(value => Array.isArray(value) || value.family !== undefined || value.runner !== undefined) || opts.family !== undefined || opts.runner !== undefined;
+ let allowedFamilies;
+ if (explicitPool) allowedFamilies = pool.map(b => table[b].family);
+ else if (modern) {
+ allowedFamilies = [parentFamily];
+ if (mode !== 'solo') {
+ for (const value of Object.values(assignments)) {
+ for (const item of Array.isArray(value) ? value : [value]) {
+ if (isCandidate(item)) allowedFamilies.push(candidateFamily(item));
+ }
+ }
+ }
+ if (mode === 'auto') allowedFamilies.push(family);
+ pool = Object.keys(table).filter(b => allowedFamilies.includes(table[b].family));
+ } else {
+ pool = [(mode === 'auto' ? backend : null) || legacy[preset] || parent].filter(Boolean);
+ allowedFamilies = pool.map(b => table[b].family);
+ }
+ allowedFamilies = [...new Set(allowedFamilies.filter(Boolean))];
+ pool = [...new Set(pool)];
+ check(allowedFamilies.length > 0, 'No current host detected; pass --parent or --backend (no JSON needed)');
+ const count = allowedFamilies.length;
+ check(mode !== 'solo' || count === 1, 'solo requires exactly one family');
+ check(mode !== 'duo' || count === 2, 'duo requires two families; pass --families backend-a,backend-b');
+ const effectiveMode = count === 1 ? 'solo' : count === 2 ? 'duo' : 'mixed';
+ const why = [`${effectiveMode}: ${allowedFamilies.join(', ')}`];
+ const needsAuthor = ['reviewer', 'verifier'].includes(role);
+ let author = opts['author-backend'];
+ if (author === 'self') { check(parent, 'self needs --parent'); author = parent; }
+ check(!author || Object.hasOwn(table, author), 'Unknown --author-backend');
+ check(!needsAuthor || author || (opts._run && opts.backend), '--author-backend is required for reviewer and verifier');
+ const pinned = backend || Array.isArray(assignment) || isCandidate(candidate) || opts.family || opts.runner || explicitModel;
+ const reachable = b => (!forceExternal && canNative(table[b].family)) || available(table[b].adapter);
+ if (!backend && !pinned) {
+ const pref = config.preferences?.[opts.kind === 'ui' && role === 'implementer' ? 'ui' : role];
+ backend = pref?.find(b => pool.includes(b) && reachable(b));
+ if (backend) why.push('selected a reachable user preference');
+ if (!backend && role === 'reviewer' && author) backend = pool.find(b => table[b].family !== table[author].family && reachable(b));
+ const primary = legacy[preset] || (pool.includes(parent) ? parent : pool[0]);
+ backend ||= pool.includes(primary) && reachable(primary) ? primary : pool.find(reachable);
+ check(backend, 'No allowed executor is available');
+ family = table[backend].family;
+ }
+ backend ||= Object.keys(table).find(b => table[b].family === family && (!adapters.includes(runner) || table[b].adapter === runner)) ||
+ Object.keys(table).find(b => table[b].family === family) || family;
+ check(allowedFamilies.includes(family) && (!explicitPool || pool.includes(backend)), `${backend} is outside the allowed families; update --families or the configured pool`);
+ const entry = table[backend] || {};
+ const native = !forceExternal && canNative(family);
+ check(runner !== 'native' || native, `Native execution is unavailable for family ${family}; declare host support with --native-families`);
+ const externalAdapter = adapters.includes(runner) ? runner : entry.adapter;
+ const adapter = native ? table[parent].adapter : externalAdapter;
+ check(adapter, `No external runner configured for family ${family}; set runner or backend`);
+ const ready = native || available(adapter);
+ if (!ready) why.push(`${adapter} CLI is not installed; pinned target retained`);
+ const old = !Array.isArray(assignment) && !isCandidate(assignment) ? assignment[backend] : undefined;
+ const configuredModel = candidate.model ?? old?.[0] ?? entry.model;
+ const configuredEffort = candidate.effort ?? old?.[1];
+ const model = opts.model ?? configuredModel ?? (native && family === parentFamily ? opts['parent-model'] : undefined) ?? null;
+ const effort = opts.effort ?? configuredEffort ?? null;
+ check(model === null || nonempty(model), '--model requires a nonempty identifier');
+ check(effort === null || nonempty(effort), '--effort requires a value');
+ if (effort !== null) {
+ const permitted = candidate.efforts || entry.efforts || defaultEfforts(adapter, family);
+ check(permitted.includes(effort), `${backend} does not declare effort ${effort}; verify model support and configure candidate.efforts or backends.${backend}.efforts`);
+ check(native || adapter !== 'gemini', 'Gemini CLI has no --effort flag; configure model settings in Gemini');
+ }
+ if (!native && adapter === 'opencode') check(model && model.includes('/'), 'OpenCode requires an explicit provider/model ID for Kimi/GLM; use --model or a backend model');
+ why.push(explicitModel ? 'model selected by explicit call' : configuredModel ? 'model selected by user configuration' : model ? 'model supplied by current session' : native ? 'model and unspecified effort inherit from the current session' : 'model and unspecified effort use CLI configuration; actual model is not yet confirmed');
+ return { role, preset, mode: effectiveMode, requested_mode: mode, parent, author: author || null, backend, family, adapter, external_adapter: externalAdapter ?? null,
+ profile, level, levels: ladder.length, candidates: ladder, runner, native_required: runner === 'native',
+ model, effort, write: roles[role].write, dispatch: native ? 'native' : 'external', available: ready,
+ allowed_backends: pool, allowed_families: allowedFamilies, cross_family: author ? family !== table[author].family : null, fresh_context_required: needsAuthor,
+ model_source: explicitModel ? 'call' : configuredModel ? 'config' : model ? 'parent' : native ? 'inherit' : 'cli-default',
+ actual_model: null, recommended_reasoning: ['planner', 'verifier', 'review-lead'].includes(role) ? 'deep' : role === 'researcher' ? 'normal' : 'careful', why };
+}
diff --git a/skills/delegate-kit/scripts/limits.mjs b/skills/delegate-kit/scripts/limits.mjs
index f172fb9..2c580af 100644
--- a/skills/delegate-kit/scripts/limits.mjs
+++ b/skills/delegate-kit/scripts/limits.mjs
@@ -1,4 +1,4 @@
-import path from 'node:path';
+import fs from 'node:fs';
import { fileURLToPath } from 'node:url';
const names = ['writers', 'workers', 'runs', 'retries'];
@@ -31,7 +31,7 @@ export function resolveLimits(opts = {}, config = {}, env = process.env) {
}
// Small argv-only bridge for agent-wt; never interpolate configuration into shell code.
-if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+if (process.argv[1] && fs.existsSync(process.argv[1]) && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
const fail = error => {
process.stderr.write(`delegate-kit: ${error.message}\n`);
process.exitCode = 1;
diff --git a/skills/delegate-kit/scripts/migrate.mjs b/skills/delegate-kit/scripts/migrate.mjs
new file mode 100644
index 0000000..69eecd5
--- /dev/null
+++ b/skills/delegate-kit/scripts/migrate.mjs
@@ -0,0 +1,76 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import { readConfig, backendTable } from './routing.mjs';
+import { home, hash, check, locked, atomicJSON, readJSON, validatePreset, presetFiles } from './presets.mjs';
+
+// Parent-dependent assignments are resolved only from an explicit migration
+// decision. A preset called gpt or claude does not itself supply that decision.
+export function migrationPlan(decisions = {}, root = home()) {
+ const file = path.join(root, 'config.json');
+ check(fs.existsSync(file), 'No legacy config.json found');
+ const legacy = readConfig(file), table = backendTable(legacy), issues = [], presets = [];
+ const teams = Object.keys(legacy.profiles || {}).length ? legacy.profiles : { legacy: { roles: {} } };
+ for (const [id, profile] of Object.entries(teams)) {
+ const parent = decisions.parents?.[id];
+ if (parent && !table[parent]) { issues.push(`${id}: unknown parent backend ${parent}`); continue; }
+ const p = { schema_version: 2, id, defaults: {}, agents: {}, ...(legacy.limits ? { limits: legacy.limits } : {}) };
+ const assignments = { ...legacy.roles, ...profile.roles };
+ for (const [role, assignment] of Object.entries(assignments)) {
+ const candidateKeys = ['model', 'effort', 'family', 'runner', 'backend', 'efforts'];
+ const candidates = Array.isArray(assignment) ? assignment : candidateKeys.some(key => Object.hasOwn(assignment, key))
+ ? [assignment] : Object.entries(assignment).map(([backend, pair]) => ({ backend, model: pair[0], effort: pair[1] || undefined }));
+ candidates.forEach((c, i) => {
+ const at = `${id}.${role}[${i + 1}]`;
+ const backend = c.backend || (c.family ? Object.keys(table).find(b => table[b].family === c.family && (!c.runner || ['auto', 'native'].includes(c.runner) || table[b].adapter === c.runner)) : parent);
+ if (!backend) { issues.push(`${at}: declare parents.${id} or an explicit backend; model does not identify its harness`); return; }
+ const entry = table[backend];
+ if (legacy.families && !legacy.families.includes(backend)) { issues.push(`${at}: ${backend} is outside the legacy allowed pool`); return; }
+ if (legacy.mode === 'solo' && (!parent || table[parent].family !== entry.family)) { issues.push(`${at}: solo restriction needs an explicit compatible parent`); return; }
+ if (legacy.mode === 'duo' && !legacy.families) { issues.push(`${at}: duo has no fixed allowed pool; choose it before migration`); return; }
+ const usesParent = !c.runner || ['auto', 'native'].includes(c.runner);
+ if (usesParent && !parent && (c.runner === 'native' || new Set(Object.values(table).filter(b => b.family === entry.family).map(b => b.adapter)).size > 1)) { issues.push(`${at}: declare parents.${id}; this family has parent-dependent harness selection`); return; }
+ if (c.runner === 'native' && table[parent]?.family !== entry.family) { issues.push(`${at}: native runner needs a matching parent family`); return; }
+ const harness = usesParent && table[parent]?.family === entry.family ? table[parent].adapter : c.runner && !['native', 'auto'].includes(c.runner) ? c.runner : entry.adapter;
+ const model = c.model || entry.model;
+ if (!model) { issues.push(`${at}: omitted model/inheritance requires an explicit decision in legacy configuration`); return; }
+ const aid = `${role}-${i + 1}`;
+ p.agents[aid] = { role, when: i === 0 ? `General ${role} work; original usual assignment.` : `Permitted alternative ${role} for work needing a different approach or deeper analysis; choose deliberately.`,
+ executor: { harness, model, ...(c.effort ? { reasoning: c.effort } : {}), ...(c.runner === 'native' ? { transport: 'native' } : c.runner && c.runner !== 'auto' ? { transport: 'cli' } : {}) } };
+ p.defaults[role] ||= aid;
+ });
+ }
+ if (legacy.preferences && Object.keys(legacy.preferences).length) issues.push(`${id}: preferences need explicit profile descriptions/defaults; migration will not guess specialization`);
+ if (legacy.review?.allow_multiple === false) p.coordination = 'Use one reviewer. Multiple reviewers require an explicit user exception.';
+ if (legacy.preset && legacy.preset !== 'auto') issues.push(`${id}: legacy preset ${legacy.preset} depends on coordinator routing; confirm explicit assignments before migration`);
+ try { validatePreset(p); presets.push(p); } catch (e) { issues.push(`${id}: ${e.message}`); }
+ }
+ if (decisions.default_preset && !presets.some(p => p.id === decisions.default_preset)) issues.push(`Unknown chosen default ${decisions.default_preset}`);
+ return { source: file, source_hash: hash(legacy), presets, default_preset: decisions.default_preset || null,
+ files: presets.map(p => path.join(root, 'presets', `${p.id}.json`)), issues,
+ note: 'Dry-run does not call models or modify files. No default is inferred from a family name.' };
+}
+export function migrate(decisions = {}, apply = false, root = home()) {
+ const plan = migrationPlan(decisions, root);
+ if (!apply) return plan;
+ check(!plan.issues.length, `Migration requires decisions: ${plan.issues.join('; ')}`);
+ return locked(path.join(root, 'config.lock'), () => {
+ const journal = readJSON(path.join(root, 'migration-v2.json'), null);
+ if (journal) {
+ check(journal.source_hash === plan.source_hash, 'Legacy config changed since migration; reconcile it explicitly');
+ return { ...journal, already_migrated: true, note: 'Existing v2 edits preserved' };
+ }
+ const existing = presetFiles(root).map(f => f.toLowerCase());
+ for (const p of plan.presets) check(!existing.includes(`${p.id}.json`.toLowerCase()), `Migration would overwrite ${p.id}; choose a different destination`);
+ const legacy = readConfig(plan.source); check(hash(legacy) === plan.source_hash, 'Legacy config changed during migration');
+ if (plan.default_preset) check(!fs.existsSync(path.join(root, 'settings.json')), 'Settings already exist; set the default explicitly after migration');
+ const backup = path.join(root, `config.v1-${plan.source_hash.slice(0, 12)}.json`);
+ if (!fs.existsSync(backup)) atomicJSON(backup, legacy);
+ const created = [];
+ try {
+ for (const p of plan.presets) { const file = path.join(root, 'presets', `${p.id}.json`); atomicJSON(file, p); created.push(file); }
+ if (plan.default_preset) { const file = path.join(root, 'settings.json'); atomicJSON(file, { schema_version: 2, default_preset: plan.default_preset }); created.push(file); }
+ const result = { source_hash: plan.source_hash, files: plan.files, backup, default_preset: plan.default_preset };
+ atomicJSON(path.join(root, 'migration-v2.json'), result); return result;
+ } catch (error) { for (const file of created) fs.rmSync(file, { force: true }); throw error; }
+ });
+}
diff --git a/skills/delegate-kit/scripts/pi-worker.mjs b/skills/delegate-kit/scripts/pi-worker.mjs
new file mode 100644
index 0000000..207ec42
--- /dev/null
+++ b/skills/delegate-kit/scripts/pi-worker.mjs
@@ -0,0 +1,57 @@
+// Use the SDK shipped with the selected Pi installation solely to provide an
+// in-memory settings store to its official RPC mode. Credentials/models stay at
+// their original Pi paths; no package is downloaded and no auth data is copied.
+import fs from 'node:fs';
+import path from 'node:path';
+import { pathToFileURL, fileURLToPath } from 'node:url';
+import { spawnSync } from 'node:child_process';
+import { check, readJSON } from './presets.mjs';
+
+export function installedPiSDK() {
+ const located = spawnSync('which', ['pi'], { encoding: 'utf8' });
+ check(located.status === 0, 'Pi CLI is not installed');
+ let dir = path.dirname(fs.realpathSync(located.stdout.trim()));
+ for (;;) {
+ const pkg = readJSON(path.join(dir, 'package.json'), null);
+ if (pkg?.name === '@earendil-works/pi-coding-agent') {
+ const main = pkg.exports?.['.']?.import || pkg.main;
+ check(typeof main === 'string' && fs.existsSync(path.resolve(dir, main)), 'Installed Pi package has no supported SDK entry');
+ return path.resolve(dir, main);
+ }
+ const parent = path.dirname(dir); if (parent === dir) break; dir = parent;
+ }
+ throw new Error('Cannot locate the SDK shipped with Pi; supported package is @earendil-works/pi-coding-agent. No model was called.');
+}
+
+export async function piWorker(args) {
+ const value = key => { const index = args.indexOf(key); return index < 0 ? undefined : args[index + 1]; };
+ const sdk = await import(pathToFileURL(value('--sdk')).href);
+ for (const name of ['SettingsManager', 'SessionManager', 'getAgentDir', 'createAgentSessionServices', 'createAgentSessionFromServices', 'createAgentSessionRuntime', 'runRpcMode']) check(sdk[name], `Installed Pi SDK lacks ${name}; verify its version before dispatch`);
+ const agentDir = sdk.getAgentDir();
+ const saved = readJSON(path.join(agentDir, 'settings.json'), {});
+ const settingsManager = sdk.SettingsManager.inMemory({ ...saved,
+ packages: [], extensions: [], skills: [], prompts: [], themes: [], defaultProjectTrust: 'never',
+ retry: { ...saved.retry, enabled: false }, compaction: { ...saved.compaction, enabled: false },
+ });
+ const cwd = process.cwd(), sessionDir = value('--session-dir'), resumeFile = value('--session');
+ const sessionManager = resumeFile ? sdk.SessionManager.open(resumeFile) : sdk.SessionManager.create(cwd, sessionDir);
+ const factory = async ({ cwd: currentCwd, sessionManager: currentSession, sessionStartEvent }) => {
+ const services = await sdk.createAgentSessionServices({ cwd: currentCwd, agentDir, settingsManager,
+ resourceLoaderOptions: { noExtensions: true, noSkills: true, noPromptTemplates: true, noThemes: true } });
+ const model = services.modelRuntime.getAvailableSnapshot().find(m => m.id === value('--model') && m.provider === value('--provider'));
+ check(model, 'Pi configured catalog does not contain the exact requested provider/model');
+ const result = await sdk.createAgentSessionFromServices({ services, sessionManager: currentSession, sessionStartEvent,
+ model, ...(value('--thinking') === undefined ? {} : { thinkingLevel: value('--thinking') }), tools: value('--tools').split(',') });
+ check(!result.modelFallbackMessage, 'Pi attempted an initial model fallback');
+ return { ...result, services, diagnostics: services.diagnostics };
+ };
+ const runtime = await sdk.createAgentSessionRuntime(factory, { cwd, agentDir, sessionManager });
+ await sdk.runRpcMode(runtime);
+}
+if (process.argv[1] && fs.existsSync(process.argv[1]) && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
+ piWorker(process.argv.slice(2)).catch(error => {
+ console.error(/^(Installed Pi SDK lacks |Pi configured catalog |Pi attempted an initial model fallback)/.test(error.message)
+ ? error.message : 'Pi SDK startup failed; verify installed SDK capabilities, model access and private configuration.');
+ process.exitCode = 1;
+ });
+}
diff --git a/skills/delegate-kit/scripts/presets.mjs b/skills/delegate-kit/scripts/presets.mjs
new file mode 100644
index 0000000..44550d5
--- /dev/null
+++ b/skills/delegate-kit/scripts/presets.mjs
@@ -0,0 +1,165 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import os from 'node:os';
+import { createHash, randomUUID } from 'node:crypto';
+import { validateLimits } from './limits.mjs';
+
+export const home = () => path.resolve(process.env.DELEGATE_KIT_HOME || path.join(os.homedir(), '.delegate-kit'));
+export const hash = value => createHash('sha256').update(typeof value === 'string' ? value : JSON.stringify(value)).digest('hex');
+export const check = (ok, message) => { if (!ok) throw new Error(message); };
+export const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
+export const nonempty = value => typeof value === 'string' && value.trim().length > 0;
+export function identifier(value, at = 'id') {
+ check(typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9_-]{0,79}$/.test(value), `${at}: use 1–80 letters, digits, _ or -`);
+ return value;
+}
+export function keys(value, allowed, at) {
+ check(object(value), `${at}: expected object`);
+ for (const key of Object.keys(value)) check(allowed.includes(key), `${at}.${key}: unknown field`);
+}
+export function readJSON(file, fallback) {
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); }
+ catch (error) {
+ if (error.code === 'ENOENT' && arguments.length > 1) return fallback;
+ throw new Error(`${file}: ${error instanceof SyntaxError ? 'invalid JSON' : error.message}`);
+ }
+}
+export function atomicJSON(file, value) {
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
+ const temp = `${file}.${randomUUID()}.tmp`;
+ try {
+ fs.writeFileSync(temp, JSON.stringify(value, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
+ fs.renameSync(temp, file);
+ } finally { fs.rmSync(temp, { force: true }); }
+}
+// Fail closed on abandoned locks. Never steal an operation based on PID reuse or age.
+export function locked(file, fn, timeout = 5000) {
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
+ const deadline = Date.now() + timeout;
+ for (;;) {
+ try { fs.mkdirSync(file, { mode: 0o700 }); break; }
+ catch (error) {
+ if (error.code !== 'EEXIST') throw error;
+ check(Date.now() < deadline, `Operation locked: ${file}; verify the owner has stopped before recovery`);
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20);
+ }
+ }
+ try { return fn(); } finally { fs.rmdirSync(file); }
+}
+
+export const harnesses = ['codex', 'claude', 'gemini', 'opencode', 'pi', 'omp'];
+export const accessOf = agent => agent.access || (agent.role === 'implementer' ? 'workspace-write' : 'read-only');
+export function validatePreset(preset) {
+ keys(preset, ['schema_version', 'id', 'name', 'description', 'defaults', 'agents', 'limits', 'coordination'], 'preset');
+ check(preset.schema_version === 2, 'preset.schema_version: expected 2');
+ identifier(preset.id, 'preset.id');
+ for (const key of ['name', 'description', 'coordination']) if (preset[key] !== undefined) check(nonempty(preset[key]), `preset.${key}: expected nonempty text`);
+ validateLimits(preset.limits);
+ check(object(preset.agents) && Object.keys(preset.agents).length > 0, 'preset.agents: at least one profile is required');
+ const seen = new Set();
+ for (const [id, agent] of Object.entries(preset.agents)) {
+ identifier(id, 'agent id');
+ check(!seen.has(id.toLowerCase()), `agents.${id}: case collision`); seen.add(id.toLowerCase());
+ const at = `agents.${id}`;
+ keys(agent, ['role', 'when', 'instructions', 'executor', 'access', 'review'], at);
+ identifier(agent.role, `${at}.role`);
+ check(nonempty(agent.when), `${at}.when: describe when to use this agent`);
+ if (agent.instructions !== undefined) check(nonempty(agent.instructions), `${at}.instructions: expected text`);
+ check(agent.access === undefined || ['read-only', 'workspace-write'].includes(agent.access), `${at}.access: unsupported access`);
+ const e = agent.executor;
+ keys(e, ['harness', 'provider', 'model', 'reasoning', 'transport', 'inherit_model'], `${at}.executor`);
+ check(harnesses.includes(e.harness), `${at}.executor.harness: unsupported harness`);
+ check(e.transport === undefined || ['auto', 'cli', 'native', 'paseo'].includes(e.transport), `${at}.executor.transport: unsupported transport`);
+ check(e.inherit_model === undefined || e.inherit_model === true, `${at}.executor.inherit_model: omit or set true`);
+ check(e.inherit_model ? e.model === undefined && e.provider === undefined && e.transport === 'native' : nonempty(e.model), `${at}.executor: exact model required, or explicit native inherit_model without model/provider`);
+ for (const key of ['model', 'provider', 'reasoning']) if (e[key] !== undefined) {
+ check(nonempty(e[key]) && !/[\x00-\x1f]/.test(e[key]), `${at}.executor.${key}: invalid value`);
+ check(!/REPLACE_WITH|YOUR_|<[^>]+>/i.test(e[key]), `${at}.executor.${key}: replace placeholder before saving`);
+ }
+ if (agent.review !== undefined) {
+ keys(agent.review, ['also_run', 'independent'], `${at}.review`);
+ check(agent.review.independent === undefined || agent.review.independent === true, `${at}.review.independent must be true`);
+ check(Array.isArray(agent.review.also_run) && agent.review.also_run.length > 0, `${at}.review.also_run must list profiles`);
+ check(new Set(agent.review.also_run).size === agent.review.also_run.length, `${at}.review.also_run: duplicate`);
+ for (const target of agent.review.also_run) {
+ check(target !== id && Object.hasOwn(preset.agents, target), `${at}.review: invalid reference ${target}`);
+ check(preset.agents[target].role === 'reviewer' && accessOf(preset.agents[target]) === 'read-only', `${at}.review: ${target} must be a read-only reviewer`);
+ }
+ }
+ }
+ if (preset.defaults !== undefined) {
+ check(object(preset.defaults), 'preset.defaults: expected object');
+ for (const [role, id] of Object.entries(preset.defaults)) {
+ check(Object.hasOwn(preset.agents, id) && preset.agents[id].role === role, `defaults.${role}: invalid reference ${id}`);
+ }
+ }
+ for (const id of Object.keys(preset.agents)) reviewSet(preset, id);
+ return preset;
+}
+export function reviewSet(preset, id, chain = []) {
+ check(!chain.includes(id), `review.also_run cycle: ${[...chain, id].join(' -> ')}`);
+ const ids = [id, ...(preset.agents[id].review?.also_run || []).flatMap(target => reviewSet(preset, target, [...chain, id]))];
+ check(new Set(ids).size === ids.length, `review.also_run repeats a profile in ${id}'s required set`);
+ return ids;
+}
+export function presetFiles(root = home()) {
+ const dir = path.join(root, 'presets');
+ return fs.existsSync(dir) ? fs.readdirSync(dir).filter(name => name.endsWith('.json')).sort() : [];
+}
+export function loadPreset(id, root = home()) {
+ identifier(id, 'preset');
+ const matches = presetFiles(root).filter(name => name.toLowerCase() === `${id}.json`.toLowerCase());
+ check(matches.length === 1, matches.length ? `Preset case collision: ${matches.join(', ')}` : `Unknown preset ${id}; available: ${presetFiles(root).join(', ') || '(none; run setup)'}`);
+ check(matches[0] === `${id}.json`, `Preset IDs are case-sensitive; use ${matches[0].slice(0, -5)}`);
+ const value = validatePreset(readJSON(path.join(root, 'presets', matches[0])));
+ check(value.id === id, `Preset filename/id mismatch: ${id}`);
+ return { preset: value, revision: hash(value) };
+}
+export function savePreset(preset, expected = null, root = home()) {
+ validatePreset(preset);
+ return locked(path.join(root, 'config.lock'), () => {
+ const collisions = presetFiles(root).filter(name => name.toLowerCase() === `${preset.id}.json`.toLowerCase());
+ if (collisions.length) {
+ check(expected !== null, `Preset ${preset.id} already exists; editing requires its revision`);
+ check(loadPreset(preset.id, root).revision === expected, `Preset ${preset.id} changed; reload before editing`);
+ } else check(expected === null, `Preset ${preset.id} disappeared; reload before editing`);
+ atomicJSON(path.join(root, 'presets', `${preset.id}.json`), preset);
+ return { id: preset.id, revision: hash(preset) };
+ });
+}
+export function copyPreset(from, to, root = home()) {
+ const { preset } = loadPreset(from, root);
+ return savePreset({ ...preset, id: identifier(to) }, null, root);
+}
+export function settings(root = home()) {
+ const value = readJSON(path.join(root, 'settings.json'), { schema_version: 2 });
+ keys(value, ['schema_version', 'default_preset'], 'settings');
+ check(value.schema_version === 2, 'settings.schema_version: expected 2');
+ if (value.default_preset !== undefined) identifier(value.default_preset, 'default_preset');
+ return value;
+}
+export function setDefault(id, root = home()) {
+ return locked(path.join(root, 'config.lock'), () => {
+ loadPreset(id, root);
+ const value = { ...settings(root), default_preset: id };
+ atomicJSON(path.join(root, 'settings.json'), value); return value;
+ });
+}
+export function context({ session, preset, taskOnly = false }, root = home()) {
+ session ||= `dk:${randomUUID()}`;
+ check(nonempty(session) && session.length <= 256, 'session: expected stable namespaced host ID or generated handle');
+ const file = path.join(root, 'sessions', hash(session), 'session.json');
+ return locked(`${file}.lock`, () => {
+ const prior = readJSON(file, null);
+ check(!prior || prior.session === session, 'Session handle mismatch');
+ const selected = preset ?? prior?.preset ?? settings(root).default_preset;
+ check(selected, 'No preset selected; run Delegate Kit start before delegation');
+ const loaded = loadPreset(selected, root);
+ if (!taskOnly) atomicJSON(file, { schema_version: 2, session, preset: selected });
+ return { session, preset: selected, task_only: taskOnly, ...loaded };
+ });
+}
+export function catalog(preset, role) {
+ return { id: preset.id, defaults: preset.defaults || {}, coordination: preset.coordination || null,
+ agents: Object.entries(preset.agents).filter(([, a]) => !role || a.role === role).map(([id, a]) => ({ id, role: a.role, when: a.when, executor: a.executor, access: accessOf(a), review: a.review || null })) };
+}
diff --git a/skills/delegate-kit/scripts/routing.mjs b/skills/delegate-kit/scripts/routing.mjs
index a41296d..4992c10 100644
--- a/skills/delegate-kit/scripts/routing.mjs
+++ b/skills/delegate-kit/scripts/routing.mjs
@@ -1,241 +1,3 @@
-// Model identifiers belong to the host or user configuration, never to role policy.
-import fs from 'node:fs';
-import { spawnSync } from 'node:child_process';
-import { validateLimits } from './limits.mjs';
-
-export const roles = Object.fromEntries(['planner', 'implementer', 'reviewer', 'verifier', 'researcher', 'review-lead'].map(role => [role, { write: role === 'implementer' }]));
-export const defaults = {
- codex: { family: 'gpt', adapter: 'codex' },
- claude: { family: 'claude', adapter: 'claude' },
- gemini: { family: 'gemini', adapter: 'gemini' },
- kimi: { family: 'kimi', adapter: 'opencode' },
- glm: { family: 'glm', adapter: 'opencode' },
-};
-const adapters = ['codex', 'claude', 'gemini', 'opencode'];
-const runners = ['auto', 'native', ...adapters];
-const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
-const check = (condition, message) => { if (!condition) throw new Error(message); };
-const identifier = value => typeof value === 'string' && /^[a-z][a-z0-9_-]*$/.test(value);
-const nonempty = value => typeof value === 'string' && value.trim().length > 0;
-const candidateKeys = ['model', 'effort', 'family', 'runner', 'backend', 'efforts'];
-const isCandidate = value => candidateKeys.some(key => Object.hasOwn(value, key));
-const defaultEfforts = (adapter, family) => adapter === 'codex' ? ['low', 'medium', 'high', 'xhigh', 'max', 'ultra']
- : adapter === 'claude' && family === 'claude' ? ['low', 'medium', 'high', 'xhigh', 'max'] : [];
-export function readConfig(file) {
- let text;
- try { text = fs.readFileSync(file, 'utf8'); } catch (error) { if (error.code === 'ENOENT') return {}; throw error; }
- let config;
- try { config = JSON.parse(text); } catch { throw new Error(`Invalid JSON in ${file}; fix it before routing`); }
- validateConfig(config);
- return config;
-}
-export function backendTable(config) {
- check(config.backends === undefined || object(config.backends), 'backends must be an object');
- const table = structuredClone(defaults);
- for (const [name, entry] of Object.entries(config.backends || {})) {
- check(identifier(name) && object(entry), `Invalid backend ${name}`);
- for (const key of Object.keys(entry)) check(['family', 'adapter', 'model', 'efforts'].includes(key), `Unknown backends.${name}.${key}`);
- const merged = { ...table[name], ...entry };
- check(identifier(merged.family) && adapters.includes(merged.adapter), `Backend ${name} needs a family and a supported adapter`);
- check(merged.model === undefined || (typeof merged.model === 'string' && merged.model.trim() !== ''), `Invalid model for ${name}`);
- check(merged.efforts === undefined || (Array.isArray(merged.efforts) && merged.efforts.every(e => typeof e === 'string' && e.length)), `Invalid efforts for ${name}`);
- table[name] = merged;
- }
- return table;
-}
-function validateCandidate(candidate, location, table) {
- check(object(candidate), `${location} must be an object`);
- for (const key of Object.keys(candidate)) check(candidateKeys.includes(key), `Unknown ${location}.${key}`);
- for (const key of ['model', 'effort']) check(candidate[key] === undefined || nonempty(candidate[key]), `Invalid ${location}.${key}`);
- check(candidate.family === undefined || identifier(candidate.family), `Invalid ${location}.family`);
- check(candidate.runner === undefined || runners.includes(candidate.runner), `Invalid ${location}.runner`);
- check(candidate.backend === undefined || Object.hasOwn(table, candidate.backend), `Unknown ${location}.backend`);
- check(candidate.efforts === undefined || (Array.isArray(candidate.efforts) && candidate.efforts.every(nonempty)), `Invalid ${location}.efforts`);
- if (candidate.backend && candidate.family) check(table[candidate.backend].family === candidate.family, `${location}.family conflicts with backend`);
- if (candidate.efforts && candidate.effort) check(candidate.efforts.includes(candidate.effort), `${location} does not declare effort ${candidate.effort}`);
- // Explicit external transports have static restrictions, even in inactive profiles.
- if (adapters.includes(candidate.runner)) {
- const entry = table[candidate.backend];
- const family = candidate.family ?? entry?.family;
- const model = candidate.model ?? entry?.model;
- if (candidate.runner === 'opencode' && model) check(model.includes('/'), `${location}: OpenCode requires provider/model`);
- if (candidate.effort) {
- check(candidate.runner !== 'gemini', `${location}: Gemini CLI has no --effort flag`);
- const permitted = candidate.efforts ?? entry?.efforts ?? defaultEfforts(candidate.runner, family);
- // An omitted family depends on the current parent; resolve checks it later.
- if (family || candidate.runner !== 'claude' || candidate.efforts || entry?.efforts) check(permitted.includes(candidate.effort), `${location} does not declare effort ${candidate.effort}`);
- }
- }
-}
-function validateRoles(assignments, location, table) {
- check(assignments === undefined || object(assignments), `${location} must be an object`);
- for (const [name, assignment] of Object.entries(assignments || {})) {
- check(Object.hasOwn(roles, name), `Unknown role: ${name}`);
- const at = `${location}.${name}`;
- if (Array.isArray(assignment)) {
- check(assignment.length > 0, `${at} must be a nonempty candidate list`);
- assignment.forEach((candidate, index) => validateCandidate(candidate, `${at}[${index}]`, table));
- } else {
- check(object(assignment), `${at} must be an object or nonempty candidate list`);
- if (isCandidate(assignment)) validateCandidate(assignment, at, table);
- else for (const [backend, pair] of Object.entries(assignment)) {
- check(Object.hasOwn(table, backend) && Array.isArray(pair) && pair.length === 2 && pair.every(value => typeof value === 'string'), `Invalid legacy ${at}.${backend}`);
- }
- }
- }
-}
-function validateConfig(config) {
- check(object(config), 'config.json must contain an object');
- for (const key of Object.keys(config)) check(['mode', 'backends', 'families', 'roles', 'profiles', 'preferences', 'review', 'preset', 'limits'].includes(key), `Unknown config key: ${key}`);
- const table = backendTable(config);
- check(config.mode === undefined || ['auto', 'solo', 'duo'].includes(config.mode), 'mode must be auto, solo or duo');
- check(config.preset === undefined || ['auto', 'main-claude', 'main-codex', 'main-gpt'].includes(config.preset), 'Unknown preset');
- check(config.families === undefined || (Array.isArray(config.families) && config.families.length > 0 && config.families.every(b => typeof b === 'string' && Object.hasOwn(table, b))), 'families must list backend IDs');
- validateRoles(config.roles, 'roles', table);
- check(config.profiles === undefined || object(config.profiles), 'profiles must be an object');
- for (const [name, profile] of Object.entries(config.profiles || {})) {
- check(identifier(name) && object(profile), `Invalid profile ${name}`);
- for (const key of Object.keys(profile)) check(key === 'roles', `Unknown profiles.${name}.${key}`);
- validateRoles(profile.roles, `profiles.${name}.roles`, table);
- }
- check(config.preferences === undefined || object(config.preferences), 'preferences must be an object');
- for (const [key, pref] of Object.entries(config.preferences || {})) {
- check(Object.hasOwn(roles, key) || key === 'ui', `Unknown preference: ${key}`);
- check(Array.isArray(pref) && pref.every(b => typeof b === 'string' && Object.hasOwn(table, b)), `Invalid preferences.${key}`);
- }
- if (config.review !== undefined) {
- check(object(config.review), 'review must be an object');
- for (const key of Object.keys(config.review)) check(key === 'allow_multiple', `Unknown review.${key}`);
- check(config.review.allow_multiple === undefined || typeof config.review.allow_multiple === 'boolean', 'review.allow_multiple must be boolean');
- }
- validateLimits(config.limits);
- return table;
-}
-export function installed(adapter) {
- // Adapter is validated against a fixed list; no model/config text reaches a shell.
- return spawnSync('/bin/sh', ['-c', `command -v ${adapter}`], { stdio: 'ignore' }).status === 0;
-}
-export function parentOf(opts, env = process.env) {
- return opts.parent || env.DELEGATE_KIT_PARENT ||
- (env.CLAUDECODE || env.CLAUDE_CODE_ENTRYPOINT ? 'claude' :
- env.CODEX_SANDBOX || env.CODEX_NON_INTERACTIVE || env.CODEX_HOME ? 'codex' : null);
-}
-export function resolve(opts, config = {}, env = process.env, available = installed) {
- const table = validateConfig(config);
- const role = opts.role;
- check(Object.hasOwn(roles, role), `--role must be one of ${Object.keys(roles).join('|')}`);
- const parent = parentOf(opts, env);
- check(!parent || Object.hasOwn(table, parent), `Unknown parent backend: ${parent}`);
- const parentFamily = table[parent]?.family;
- const profile = opts.profile ?? (Object.hasOwn(config.profiles || {}, parentFamily) ? parentFamily : null);
- check(profile === null || (typeof profile === 'string' && Object.hasOwn(config.profiles || {}, profile)), `Unknown profile: ${profile}`);
- const assignments = { ...config.roles, ...config.profiles?.[profile]?.roles };
- const fallbackRole = { verifier: 'reviewer', 'review-lead': 'planner' }[role];
- const assignment = assignments[role] ?? assignments[fallbackRole] ?? {};
- const ladder = Array.isArray(assignment) ? assignment : [assignment];
- const level = opts.level === undefined ? 1 : Number(opts.level);
- check((typeof opts.level === 'string' || typeof opts.level === 'number' || opts.level === undefined) && Number.isSafeInteger(level) && level >= 1 && level <= ladder.length, `--level must be between 1 and ${ladder.length}`);
- let candidate = isCandidate(ladder[level - 1]) ? ladder[level - 1] : {};
- const mode = opts.mode || env.DELEGATE_KIT_MODE || config.mode || 'auto';
- check(['auto', 'solo', 'duo'].includes(mode), '--mode must be auto, solo or duo');
- const preset = opts.preset || env.DELEGATE_KIT_PRESET || config.preset || 'auto';
- const legacy = { 'main-claude': 'claude', 'main-codex': 'codex', 'main-gpt': 'codex' };
- check(preset === 'auto' || legacy[preset], 'Unknown preset; use auto, main-claude or main-codex');
- check(opts.backend === undefined || Object.hasOwn(table, opts.backend), `Unknown backend ${opts.backend}`);
- check(opts.family === undefined || identifier(opts.family), '--family requires a family identifier');
- check(opts.runner === undefined || runners.includes(opts.runner), '--runner must be auto, native, codex, claude, gemini or opencode');
- const candidateFamily = value => value.family ?? table[value.backend]?.family ?? parentFamily;
- // A call selecting another executor must not carry model pins from the old one.
- if ((opts.backend && ((candidate.backend && candidate.backend !== opts.backend) || candidateFamily(candidate) !== table[opts.backend].family)) ||
- (opts.family && candidateFamily(candidate) !== opts.family) ||
- (opts.model && !opts.family && !opts.backend && candidateFamily(candidate) !== parentFamily)) candidate = {};
- let family = opts.family ?? table[opts.backend]?.family ?? candidateFamily(candidate);
- let backend = opts.backend ?? candidate.backend;
- const explicitModel = opts.model !== undefined;
- if (explicitModel && !opts.family && !opts.backend) family = parentFamily;
- if (opts.backend && opts.family) check(table[opts.backend].family === opts.family, '--family conflicts with --backend');
- const runner = opts.runner ?? candidate.runner ?? 'auto';
- check(!(runner === 'native' && opts.external === true), 'runner native requires the host native tool; agent-run run cannot execute it externally');
- let nativeFamilies = opts['native-families'] === undefined ? [parentFamily].filter(Boolean) : String(opts['native-families']).split(',');
- check(nativeFamilies.every(identifier), '--native-families must list family identifiers');
- if (opts['no-native'] === true) nativeFamilies = [];
- const canNative = f => Boolean(parent && nativeFamilies.includes(f));
- const forceExternal = opts.external === true || adapters.includes(runner);
- let pool = opts.families === undefined ? config.families : String(opts.families).split(',');
- const explicitPool = pool !== undefined;
- if (explicitPool) check(Array.isArray(pool) && pool.length > 0 && pool.every(b => typeof b === 'string' && Object.hasOwn(table, b)), 'families must list backend IDs');
- const modern = profile !== null || Object.values(assignments).some(value => Array.isArray(value) || value.family !== undefined || value.runner !== undefined) || opts.family !== undefined || opts.runner !== undefined;
- let allowedFamilies;
- if (explicitPool) allowedFamilies = pool.map(b => table[b].family);
- else if (modern) {
- allowedFamilies = [parentFamily];
- if (mode !== 'solo') {
- for (const value of Object.values(assignments)) {
- for (const item of Array.isArray(value) ? value : [value]) {
- if (isCandidate(item)) allowedFamilies.push(candidateFamily(item));
- }
- }
- }
- if (mode === 'auto') allowedFamilies.push(family);
- pool = Object.keys(table).filter(b => allowedFamilies.includes(table[b].family));
- } else {
- pool = [(mode === 'auto' ? backend : null) || legacy[preset] || parent].filter(Boolean);
- allowedFamilies = pool.map(b => table[b].family);
- }
- allowedFamilies = [...new Set(allowedFamilies.filter(Boolean))];
- pool = [...new Set(pool)];
- check(allowedFamilies.length > 0, 'No current host detected; pass --parent or --backend (no JSON needed)');
- const count = allowedFamilies.length;
- check(mode !== 'solo' || count === 1, 'solo requires exactly one family');
- check(mode !== 'duo' || count === 2, 'duo requires two families; pass --families backend-a,backend-b');
- const effectiveMode = count === 1 ? 'solo' : count === 2 ? 'duo' : 'mixed';
- const why = [`${effectiveMode}: ${allowedFamilies.join(', ')}`];
- const needsAuthor = ['reviewer', 'verifier'].includes(role);
- let author = opts['author-backend'];
- if (author === 'self') { check(parent, 'self needs --parent'); author = parent; }
- check(!author || Object.hasOwn(table, author), 'Unknown --author-backend');
- check(!needsAuthor || author || (opts._run && opts.backend), '--author-backend is required for reviewer and verifier');
- const pinned = backend || Array.isArray(assignment) || isCandidate(candidate) || opts.family || opts.runner || explicitModel;
- const reachable = b => (!forceExternal && canNative(table[b].family)) || available(table[b].adapter);
- if (!backend && !pinned) {
- const pref = config.preferences?.[opts.kind === 'ui' && role === 'implementer' ? 'ui' : role];
- backend = pref?.find(b => pool.includes(b) && reachable(b));
- if (backend) why.push('selected a reachable user preference');
- if (!backend && role === 'reviewer' && author) backend = pool.find(b => table[b].family !== table[author].family && reachable(b));
- const primary = legacy[preset] || (pool.includes(parent) ? parent : pool[0]);
- backend ||= pool.includes(primary) && reachable(primary) ? primary : pool.find(reachable);
- check(backend, 'No allowed executor is available');
- family = table[backend].family;
- }
- backend ||= Object.keys(table).find(b => table[b].family === family && (!adapters.includes(runner) || table[b].adapter === runner)) ||
- Object.keys(table).find(b => table[b].family === family) || family;
- check(allowedFamilies.includes(family) && (!explicitPool || pool.includes(backend)), `${backend} is outside the allowed families; update --families or the configured pool`);
- const entry = table[backend] || {};
- const native = !forceExternal && canNative(family);
- check(runner !== 'native' || native, `Native execution is unavailable for family ${family}; declare host support with --native-families`);
- const externalAdapter = adapters.includes(runner) ? runner : entry.adapter;
- const adapter = native ? table[parent].adapter : externalAdapter;
- check(adapter, `No external runner configured for family ${family}; set runner or backend`);
- const ready = native || available(adapter);
- if (!ready) why.push(`${adapter} CLI is not installed; pinned target retained`);
- const old = !Array.isArray(assignment) && !isCandidate(assignment) ? assignment[backend] : undefined;
- const configuredModel = candidate.model ?? old?.[0] ?? entry.model;
- const configuredEffort = candidate.effort ?? old?.[1];
- const model = opts.model ?? configuredModel ?? (native && family === parentFamily ? opts['parent-model'] : undefined) ?? null;
- const effort = opts.effort ?? configuredEffort ?? null;
- check(model === null || nonempty(model), '--model requires a nonempty identifier');
- check(effort === null || nonempty(effort), '--effort requires a value');
- if (effort !== null) {
- const permitted = candidate.efforts || entry.efforts || defaultEfforts(adapter, family);
- check(permitted.includes(effort), `${backend} does not declare effort ${effort}; verify model support and configure candidate.efforts or backends.${backend}.efforts`);
- check(native || adapter !== 'gemini', 'Gemini CLI has no --effort flag; configure model settings in Gemini');
- }
- if (!native && adapter === 'opencode') check(model && model.includes('/'), 'OpenCode requires an explicit provider/model ID for Kimi/GLM; use --model or a backend model');
- why.push(explicitModel ? 'model selected by explicit call' : configuredModel ? 'model selected by user configuration' : model ? 'model supplied by current session' : native ? 'model and unspecified effort inherit from the current session' : 'model and unspecified effort use CLI configuration; actual model is not yet confirmed');
- return { role, preset, mode: effectiveMode, requested_mode: mode, parent, author: author || null, backend, family, adapter, external_adapter: externalAdapter ?? null,
- profile, level, levels: ladder.length, candidates: ladder, runner, native_required: runner === 'native',
- model, effort, write: roles[role].write, dispatch: native ? 'native' : 'external', available: ready,
- allowed_backends: pool, allowed_families: allowedFamilies, cross_family: author ? family !== table[author].family : null, fresh_context_required: needsAuthor,
- model_source: explicitModel ? 'call' : configuredModel ? 'config' : model ? 'parent' : native ? 'inherit' : 'cli-default',
- actual_model: null, recommended_reasoning: ['planner', 'verifier', 'review-lead'].includes(role) ? 'deep' : role === 'researcher' ? 'normal' : 'careful', why };
-}
+// Compatibility API for v1 commands and saved runs. New work uses dk.mjs and
+// executors.mjs; no coordinator-family selection occurs on the v2 path.
+export * from './legacy-routing.mjs';
diff --git a/skills/delegate-kit/scripts/rpc.mjs b/skills/delegate-kit/scripts/rpc.mjs
new file mode 100644
index 0000000..fb3e313
--- /dev/null
+++ b/skills/delegate-kit/scripts/rpc.mjs
@@ -0,0 +1,172 @@
+import { TextDecoder } from 'node:util';
+import { fileURLToPath } from 'node:url';
+import { check } from './presets.mjs';
+import { installedPiSDK } from './pi-worker.mjs';
+
+// LF alone delimits records, including when a chunk splits a UTF-8 character.
+export class FrameDecoder {
+ constructor(kind, emit) { this.kind = kind; this.emit = emit; this.buffer = Buffer.alloc(0); this.chunk = null; this.maxFrame = kind === 'omp' ? 1048576 : 67108864; this.maxTotal = 67108864; this.v2 = false; }
+ push(bytes) {
+ this.buffer = Buffer.concat([this.buffer, bytes]);
+ let end;
+ while ((end = this.buffer.indexOf(10)) !== -1) {
+ check(end <= this.maxFrame, 'RPC physical frame exceeds limit');
+ const line = this.buffer.subarray(0, end); this.buffer = this.buffer.subarray(end + 1);
+ if (!line.length) continue;
+ this.frame(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(line)));
+ }
+ check(this.buffer.length <= this.maxFrame, 'RPC unterminated frame exceeds limit');
+ }
+ frame(frame) {
+ check(frame && typeof frame.type === 'string', 'RPC frame needs a type');
+ if (frame.type !== 'rpc_chunk') {
+ check(!this.chunk, 'RPC chunk sequence interrupted');
+ this.emit(frame); return;
+ }
+ check(this.kind === 'omp' && this.v2, 'RPC chunks require negotiated OMP v2');
+ const { chunkId, index, count, byteLength, data } = frame;
+ check(typeof chunkId === 'string' && chunkId.length && Number.isSafeInteger(index) && index >= 0 && Number.isSafeInteger(count) && count > 0 && count <= this.maxTotal && index < count && Number.isSafeInteger(byteLength) && byteLength > 0 && byteLength <= this.maxTotal, 'Invalid RPC chunk metadata');
+ check(typeof data === 'string' && data.length > 0 && /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(data), 'Invalid RPC base64');
+ this.chunk ||= { chunkId, count, byteLength, next: 0, bytes: 0, parts: [] };
+ const c = this.chunk;
+ check(c.chunkId === chunkId && c.count === count && c.byteLength === byteLength && c.next === index, 'RPC chunks out of order or interleaved');
+ const part = Buffer.from(data, 'base64'); c.parts.push(part); c.bytes += part.length; c.next++;
+ check(c.bytes <= byteLength, 'RPC chunk byte length exceeded');
+ if (c.next === count) {
+ check(c.bytes === byteLength, 'RPC chunk byte length mismatch');
+ const complete = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(c.parts)));
+ check(complete.type !== 'rpc_chunk', 'Nested RPC chunks are unsupported');
+ this.chunk = null; this.frame(complete);
+ }
+ }
+ end() { check(this.buffer.length === 0 && !this.chunk, 'Incomplete RPC frame at EOF'); }
+}
+
+export function rpcCommand(executor, dir, resumeFile) {
+ const omp = executor.harness === 'omp';
+ const args = ['--mode', 'rpc', '--provider', executor.provider, '--model', executor.model,
+ '--no-extensions', '--no-skills', '--tools', executor.access === 'workspace-write'
+ ? (omp ? 'read,grep,glob,edit,write' : 'read,grep,find,ls,edit,write')
+ : (omp ? 'read,grep,glob' : 'read,grep,find,ls'), '--session-dir', dir];
+ if (executor.reasoning !== undefined) args.push('--thinking', executor.reasoning);
+ if (resumeFile) args.push(omp ? '--resume' : '--session', resumeFile);
+ if (omp) args.push('--no-title', '--no-prewalk', '--no-lsp', '--no-pty', '--config', `${dir}/runtime-config.json`, '--approval-mode', 'write');
+ return omp ? { cmd: 'omp', args } : { cmd: process.execPath, args: [fileURLToPath(new URL('./pi-worker.mjs', import.meta.url)), '--sdk', installedPiSDK(), ...args] };
+}
+export const ompConfig = {
+ advisor: { enabled: false }, prewalk: { enabled: false },
+ retry: { enabled: false, modelFallback: false, usageAwareFallback: false },
+ providers: { anthropic: { serverSideFallback: false } },
+ compaction: { enabled: false, asyncEnabled: false, idleEnabled: false },
+ memory: { backend: 'off' }, autolearn: { enabled: false, autoContinue: false }, recap: { enabled: false }, contextPromotion: { enabled: false },
+ plan: { enabled: false, defaultOnStartup: false }, todo: { enabled: false },
+ task: { isolation: { enabled: false }, eager: false },
+};
+
+// Own one prompt per RPC process. Responses are correlation-checked; prompt ACK
+// does not settle the turn. The caller owns process termination and durable state.
+export function rpcTurn(child, executor, prompt, onState, onLog, timeoutMs = 30000) {
+ const messages = [];
+ let turnMessages = null;
+ const pending = new Map(); let serial = 0, settled = false, prompted = false, last = null, failure = null;
+ let resolveDone, rejectDone, readyResolve;
+ const done = new Promise((resolve, reject) => { resolveDone = resolve; rejectDone = reject; });
+ const ready = new Promise(resolve => { readyResolve = resolve; });
+ function fail(error) {
+ if (failure) return;
+ failure = error;
+ for (const req of pending.values()) { clearTimeout(req.timer); req.reject(error); }
+ pending.clear();
+ if (!settled) { settled = true; rejectDone(error); }
+ }
+ function send(type, data = {}) {
+ return new Promise((resolve, reject) => {
+ if (failure) { reject(failure); return; }
+ const id = `dk-${++serial}`;
+ const timer = setTimeout(() => { pending.delete(id); reject(new Error(`RPC ${type} acknowledgement timed out`)); }, timeoutMs);
+ pending.set(id, { type, resolve, reject, timer });
+ child.stdin.write(JSON.stringify({ id, type, ...data }) + '\n', error => { if (error) fail(error); });
+ });
+ }
+ const decoder = new FrameDecoder(executor.harness, frame => {
+ if (frame.type === 'ready') {
+ check(executor.harness === 'omp' && frame.protocolVersion === 1 && frame.supportedProtocolVersions?.includes(2), 'OMP requires protocol v2 support');
+ check(Number.isSafeInteger(frame.maxFrameBytes) && frame.maxFrameBytes > 0 && Number.isSafeInteger(frame.maxReassembledFrameBytes) && frame.maxReassembledFrameBytes > 0, 'Invalid OMP ready limits');
+ decoder.maxFrame = Math.min(frame.maxFrameBytes, 1048576); decoder.maxTotal = Math.min(frame.maxReassembledFrameBytes, 67108864);
+ readyResolve(); return;
+ }
+ if (frame.type === 'response') {
+ const req = pending.get(frame.id);
+ if (!req) { if (frame.success === false) throw new Error(`RPC ${frame.command || 'parse'} failed`); return; }
+ check(frame.command === req.type, 'RPC response command does not match request');
+ clearTimeout(req.timer); pending.delete(frame.id);
+ if (frame.success !== true) { req.reject(new Error(`RPC ${req.type} failed; inspect the private log`)); return; }
+ if (req.type === 'negotiate_protocol') decoder.v2 = true;
+ req.resolve(frame.data); return;
+ }
+ if (['extension_error', 'error', 'retry_fallback_applied', 'auto_retry_start', 'auto_compaction_start', 'subagent_lifecycle', 'host_tool_call', 'host_uri_request'].includes(frame.type)) throw new Error(`Unexpected RPC event ${frame.type}; execution stopped without fallback`);
+ if (frame.type === 'extension_ui_request' && !['notify', 'setStatus', 'setWidget', 'setTitle', 'set_editor_text'].includes(frame.method)) throw new Error('RPC needs interactive permission; resume after resolving it explicitly');
+ if (frame.type === 'message_end' && frame.message?.role === 'assistant') { last = frame.message; messages.push(last); }
+ if (frame.type === 'prompt_result' && frame.agentInvoked === false) throw new Error('RPC prompt did not invoke an agent');
+ if (frame.type === 'agent_end' && frame.isTerminal !== false && prompted && !settled) {
+ turnMessages = frame.messages?.filter(m => m.role === 'assistant');
+ if (!turnMessages?.length) turnMessages = messages;
+ last = turnMessages.at(-1) || last;
+ settled = true;
+ resolveDone(last);
+ }
+ });
+ child.stdout.on('data', bytes => { onLog(bytes); try { decoder.push(bytes); } catch (error) { fail(error); } });
+ child.stdin.on('error', fail);
+ child.on('error', fail);
+ child.on('close', () => { try { decoder.end(); } catch (e) { fail(e); } if (!settled || pending.size) fail(new Error('RPC closed before completing the turn and pending responses')); });
+ // Attach a rejection handler immediately while the handshake awaits responses.
+ done.catch(() => {});
+ return (async () => {
+ try {
+ if (executor.harness === 'omp') {
+ let timer;
+ try { await Promise.race([ready, done, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('OMP ready timed out')), timeoutMs); })]); }
+ finally { clearTimeout(timer); }
+ await send('negotiate_protocol', { protocolVersion: 2 });
+ }
+ const selected = await send('set_model', { provider: executor.provider, modelId: executor.model });
+ check(selected?.id === executor.model && selected?.provider === executor.provider, 'RPC did not select the exact model/provider');
+ await send('set_auto_retry', { enabled: false });
+ await send('set_auto_compaction', { enabled: false });
+ if (executor.reasoning !== undefined) await send('set_thinking_level', { level: executor.reasoning });
+ const state = await send('get_state');
+ check(state?.model?.id === executor.model && state?.model?.provider === executor.provider, 'RPC state model/provider mismatch');
+ check(executor.reasoning === undefined || state.thinkingLevel === executor.reasoning, 'RPC reasoning was clamped or ignored');
+ onState(state);
+ prompted = true;
+ const accepted = await send('prompt', { message: prompt });
+ check(accepted?.agentInvoked !== false, 'RPC prompt did not invoke an agent');
+ const message = await done;
+ check(message && !['error', 'aborted'].includes(message.stopReason), 'RPC assistant failed or aborted');
+ const finalState = await send('get_state'); onState(finalState);
+ if (failure) throw failure;
+ check(finalState.sessionId && finalState.sessionFile, 'RPC did not return a resumable session');
+ check(finalState.model?.id === executor.model && finalState.model?.provider === executor.provider, 'RPC changed the selected executor during the turn');
+ check(!message.model || message.model === executor.model, 'RPC message came from a different model');
+ check(!message.provider || message.provider === executor.provider, 'RPC message came from a different provider');
+ return { text: (message.content || []).filter(p => p.type === 'text').map(p => p.text).join(''),
+ usage: sumUsage(turnMessages), actual_model: message.model || null, actual_provider: message.provider || null,
+ assertHealthy: () => { if (failure) throw failure; } };
+ } finally { for (const p of pending.values()) clearTimeout(p.timer); pending.clear(); }
+ })();
+}
+
+// agent_end contains the current turn's messages, also emitted by message_end.
+// Aggregate one source only; any missing component stays unknown.
+export function sumUsage(messages) {
+ if (!messages?.length || messages.some(m => !m.usage)) return null;
+ const sum = values => {
+ if (values.every(v => typeof v === 'number' && Number.isFinite(v) && v >= 0)) return values.reduce((a, b) => a + b, 0);
+ if (values.every(v => v && typeof v === 'object' && !Array.isArray(v))) {
+ return Object.fromEntries([...new Set(values.flatMap(v => Object.keys(v)))].map(k => [k, sum(values.map(v => v[k]))]));
+ }
+ return null;
+ };
+ return sum(messages.map(m => m.usage));
+}
diff --git a/skills/delegate-kit/scripts/runtime.mjs b/skills/delegate-kit/scripts/runtime.mjs
new file mode 100644
index 0000000..be1f2f5
--- /dev/null
+++ b/skills/delegate-kit/scripts/runtime.mjs
@@ -0,0 +1,470 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import { spawn, spawnSync } from 'node:child_process';
+import { randomUUID } from 'node:crypto';
+import { fileURLToPath } from 'node:url';
+import { home, check, identifier, hash, readJSON, atomicJSON, context, reviewSet, accessOf } from './presets.mjs';
+import { resolveLimits } from './limits.mjs';
+import { budget } from './budget.mjs';
+import { resolveExecutor, bridgeInvocation, assertWorkspaceBinding } from './executors.mjs';
+import { buildCommand, extractResult, validate } from './adapters.mjs';
+import { inspectPermissions, mergeInline } from './opencode-permissions.mjs';
+import { rpcCommand, rpcTurn, ompConfig } from './rpc.mjs';
+
+const skill = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const schema = readJSON(path.join(skill, 'references/result-schema.json'));
+const active = ['prepared', 'starting', 'running', 'permission', 'cancelling', 'orphaned'];
+const terminal = ['finished', 'failed', 'cancelled', 'timeout', 'blocked'];
+const now = () => new Date().toISOString();
+function diagnostic(message) {
+ let text = String(message);
+ for (const [key, value] of Object.entries(process.env)) {
+ if (/(?:TOKEN|SECRET|PASSWORD|API_KEY)$/i.test(key) && value?.length >= 8) text = text.split(value).join('[redacted]');
+ }
+ return text.replace(/Bearer\s+[A-Za-z0-9._~+\/-]+/gi, 'Bearer [redacted]');
+}
+const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
+const git = (cwd, args) => spawnSync('git', args, { cwd, encoding: 'utf8' });
+const dir = id => path.join(home(), 'runs', identifier(id, 'run'));
+const metaFile = id => path.join(dir(id), 'meta.json');
+export const getRun = id => { const m = readJSON(metaFile(id)); check(m.schema_version === 2, `Legacy run ${id}: use agent-run status/resume to preserve its executor`); return m; };
+const save = m => atomicJSON(metaFile(m.id), m);
+function allRuns() {
+ const root = path.join(home(), 'runs');
+ return fs.existsSync(root) ? fs.readdirSync(root).flatMap(id => { const f = path.join(root, id, 'meta.json'); return fs.existsSync(f) ? [readJSON(f)] : []; }) : [];
+}
+function alive(pid) { if (!Number.isSafeInteger(pid) || pid < 1) return false; try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; } }
+export function fingerprint(pid) {
+ if (!alive(pid)) return null;
+ const r = spawnSync('ps', ['-p', String(pid), '-o', 'lstart='], { encoding: 'utf8' });
+ return r.status === 0 && r.stdout.trim() ? r.stdout.trim() : null;
+}
+function sameProcess(pid, stamp) { return Boolean(stamp && fingerprint(pid) === stamp); }
+function groupAlive(pid) {
+ if (!pid) return false;
+ const r = spawnSync('ps', ['-eo', 'pid=,pgid=,stat='], { encoding: 'utf8' });
+ check(r.status === 0, 'Cannot verify process group termination; ownership retained');
+ return r.stdout.split('\n').some(line => { const [, group, state] = line.trim().split(/\s+/); return Number(group) === pid && state && !state.startsWith('Z'); });
+}
+function signal(m, value) {
+ if (sameProcess(m.child_pid, m.child_fingerprint)) {
+ try { process.kill(-m.child_pid, value); } catch (e) { if (e.code !== 'ESRCH') throw e; }
+ } else check(!groupAlive(m.child_pid), 'Process identity cannot be verified; ownership retained for manual recovery');
+}
+
+// Share admission with legacy agent-run and agent-wt. Fully publish the PID
+// before taking the lock so another process never sees an empty owner.
+function admission(fn) {
+ fs.mkdirSync(home(), { recursive: true, mode: 0o700 });
+ const mutex = path.join(home(), 'caps.lock'), temp = `${mutex}.${randomUUID()}`;
+ fs.writeFileSync(temp, String(process.pid), { mode: 0o600 });
+ const deadline = Date.now() + 15000;
+ try {
+ for (;;) {
+ try { fs.linkSync(temp, mutex); break; }
+ catch (error) {
+ if (error.code !== 'EEXIST') throw error;
+ let pid;
+ try { pid = Number(fs.readFileSync(mutex, 'utf8')); } catch (e) { if (e.code === 'ENOENT') continue; throw e; }
+ if (pid && !alive(pid)) { fs.rmSync(mutex, { force: true }); continue; }
+ check(Date.now() < deadline, 'Admission lock held; wait or verify its owner before recovery');
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
+ }
+ }
+ try { return fn(); } finally { fs.rmSync(mutex, { force: true }); }
+ } finally { fs.rmSync(temp, { force: true }); }
+}
+function workspace(cwd, write, external) {
+ if (external) {
+ check(external.owner === 'paseo' && external.id && external.daemon, 'Paseo workspace requires id and daemon');
+ return { ...external, path: cwd || null };
+ }
+ cwd = fs.realpathSync(cwd || process.cwd());
+ if (!write) return { owner: 'delegate-kit', path: cwd };
+ const r = git(cwd, ['rev-parse', '--absolute-git-dir']);
+ const common = git(cwd, ['rev-parse', '--git-common-dir']);
+ check(r.status === 0 && common.status === 0, 'Writer needs an isolated Git worktree');
+ const gitDir = r.stdout.trim();
+ check(fs.realpathSync(gitDir) !== fs.realpathSync(path.resolve(cwd, common.stdout.trim())), 'Writer must use a linked worktree; create it with agent-wt');
+ return { owner: 'delegate-kit', path: cwd, lock: path.join(gitDir, 'delegate-kit.lock') };
+}
+function reserveWriter(m) {
+ if (!m.write) return;
+ if (m.workspace.owner === 'paseo') {
+ check(!allRuns().some(r => active.includes(r.status) && r.write && r.workspace?.owner === 'paseo' && r.workspace.id === m.workspace.id && r.workspace.daemon === m.workspace.daemon), 'Paseo workspace already has a writer lease');
+ return;
+ }
+ const file = m.workspace.lock, existing = readJSON(file, null);
+ // An unknown owner is never stolen. Release through the original runtime.
+ check(!existing, `Worktree already owned by ${existing?.id}; inspect and release the previous owner first`);
+ atomicJSON(file, { id: m.id, kind: 'v2', cwd: m.cwd, role: m.role, since: now() });
+}
+function releaseWriter(m) {
+ if (m.workspace?.lock && readJSON(m.workspace.lock, null)?.id === m.id) fs.rmSync(m.workspace.lock);
+}
+function counts(cwd) {
+ const runs = allRuns().filter(r => r.schema_version === 2 ? active.includes(r.status) : r.status === 'running' && alive(r.pid));
+ const result = { workers: runs.length, writers: runs.filter(r => r.write).length };
+ if (cwd) {
+ const common = git(cwd, ['rev-parse', '--git-common-dir']);
+ if (common.status === 0) {
+ const trees = path.join(path.resolve(cwd, common.stdout.trim()), 'worktrees');
+ for (const name of fs.existsSync(trees) ? fs.readdirSync(trees) : []) {
+ const lock = readJSON(path.join(trees, name, 'delegate-kit.lock'), null);
+ if (lock && !runs.some(r => r.id === lock.id) && (['native', 'v2'].includes(lock.kind) || alive(lock.pid))) { result.workers++; result.writers++; }
+ }
+ }
+ }
+ return result;
+}
+function enforceCaps(caps, existing, additions) {
+ for (const name of ['workers', 'writers']) check(caps[name] === null || existing[name] + additions[name] <= caps[name], `Explicit max ${caps[name]} ${name} reached; required set was not partially admitted`);
+}
+function promptFor(m, brief) {
+ const instruction = m.agent.instructions || '';
+ return `You own one bounded task as ${m.role}. Delegation depth is one. Access: ${m.executor.access}. Workspace: ${m.cwd || m.workspace.id}. Preserve other people's changes. Perform only authorized finishing actions.\n${instruction}\n\n${brief}\n\nReturn one JSON object matching this schema. Include evidence and distinguish unverified work. Do not claim acceptance on behalf of the coordinator.\n${JSON.stringify(schema)}`;
+}
+export function prepare(options) {
+ check(!process.env.DELEGATE_KIT_DEPTH, 'Worker cannot delegate (depth is one)');
+ check(options.session, 'prepare requires a saved --session handle');
+ identifier(options.task, 'task');
+ const selected = context({ session: options.session, preset: options.preset, taskOnly: options.taskOnly });
+ const p = selected.preset;
+ const id = options.agent || p.defaults?.[options.role];
+ check(id && Object.hasOwn(p.agents, id), 'Select an explicit profile or a configured role default from the active catalog');
+ const ids = reviewSet(p, id);
+ const brief = fs.readFileSync(options.brief, 'utf8'); check(brief.trim(), 'Brief is empty');
+ const caps = resolveLimits(options.limits || {}, p);
+ check(options.stallMs === undefined || Number.isSafeInteger(options.stallMs) && options.stallMs > 0, 'stallMs must be a positive integer');
+ const group = randomUUID();
+ const rows = ids.map(profile => {
+ const agent = p.agents[profile];
+ const executor = resolveExecutor(agent, options.capabilities || [], h => spawnSync('which', [h], { stdio: 'ignore' }).status === 0);
+ const ws = workspace(options.cwd, accessOf(agent) === 'workspace-write', executor.transport === 'paseo' ? options.workspace : null);
+ assertWorkspaceBinding(executor, ws.path);
+ check(executor.transport !== 'paseo' || ws.daemon === executor.capability.daemon, 'Workspace daemon differs from selected Paseo daemon');
+ const run = randomUUID();
+ return { schema_version: 2, id: run, parent_session: options.session, task: options.task,
+ budget_task: hash(`${options.session}\0${options.task}`), preset: p.id, preset_hash: selected.revision,
+ profile, agent, executor, workspace: ws, cwd: ws.path, write: accessOf(agent) === 'workspace-write', role: agent.role,
+ limits: caps, timeout_ms: options.timeoutMs || null, stall_ms: options.stallMs ?? 300000, group, required_profiles: ids, status: 'prepared', created: now(), resume_of: null,
+ host_attached: false, transport_session_id: null, transport_session_file: null, actual_model: null, usage: null, cost_usd: null,
+ result_validated: false, accepted: false, attempt_kind: 'fresh', pid: null };
+ });
+ return admission(() => {
+ enforceCaps(caps, counts(rows[0].workspace.owner === 'delegate-kit' ? rows[0].cwd : null), { workers: rows.length, writers: rows.filter(m => m.write).length });
+ const before = budget({ stateDir: home(), task: rows[0].budget_task, limits: caps });
+ check(caps.runs === null || before.runs + rows.length <= caps.runs, `Required review set exceeds max ${caps.runs} runs`);
+ const reserved = [];
+ try {
+ for (const m of rows) {
+ reserveWriter(m); reserved.push(m);
+ atomicJSON(path.join(dir(m.id), 'preset.snapshot.json'), p);
+ fs.writeFileSync(path.join(dir(m.id), 'prompt.md'), promptFor(m, brief), { mode: 0o600 });
+ save(m);
+ }
+ // Reservations count once, including failed dispatch. Cancel releases capacity,
+ // but not the attempt limit; a crash can never create a free retry.
+ for (const m of rows) {
+ m.budget = budget({ stateDir: home(), task: m.budget_task, record: true, limits: caps }); save(m);
+ }
+ } catch (error) {
+ for (const m of reserved) { m.status = 'failed'; m.error = error.message; save(m); releaseWriter(m); }
+ throw error;
+ }
+ return { group, runs: rows.map(compact) };
+ });
+}
+export function compact(m) {
+ const { capability, ...executor } = m.executor;
+ return { id: m.id, parent_session: m.parent_session, task: m.task, preset: m.preset, profile: m.profile,
+ executor: { ...executor, ...(capability ? { host: capability.host, version: capability.version } : {}) }, status: m.status, attempt_kind: m.attempt_kind, resume_of: m.resume_of,
+ dispatch_token: m.claim || null, transport_session_id: m.transport_session_id, workspace: m.workspace, actual_model: m.actual_model,
+ result_validated: m.result_validated, accepted: m.accepted, result: m.result || null, error: m.error || null,
+ group: m.group, required_profiles: m.required_profiles, logs: dir(m.id), usage: m.usage, cost_usd: m.cost_usd };
+}
+function health(m) {
+ if (!active.includes(m.status) || m.status === 'prepared') return { state: 'inactive', attention_required: false };
+ const age = value => Math.max(0, Date.now() - Date.parse(value || m.started || m.created));
+ const stale = m.stall_ms ?? 300000;
+ if (m.status === 'orphaned') return { state: 'orphaned', attention_required: true, action_required: 'Inspect processes and partial work; recover only after termination is verified.' };
+ if (m.executor.transport !== 'cli') {
+ if (age(m.progress_at) >= stale) return { state: 'no_progress', attention_required: true, action_required: 'Inspect the saved host agent and its turn/progress cursor. Diagnose or interrupt a confirmed stall; do not repeat an unchanged wait or start a duplicate.' };
+ if (age(m.host_checked_at) >= 60000) return { state: 'check_host', attention_required: true, action_required: 'Query the saved host agent with its read-only status/wait tool, then record a correlated observation or stopped result.' };
+ return { state: 'awaiting_host', attention_required: false };
+ }
+ const beat = readJSON(path.join(dir(m.id), 'heartbeat.json'), null);
+ const verifiedBeat = beat?.claim === m.claim && beat.pid === m.pid ? beat.at : null;
+ if (age(verifiedBeat) >= 30000) return { state: 'supervisor_unresponsive', attention_required: true, action_required: 'Supervisor heartbeat stopped. Inspect its process and child group; retain ownership until stopped.' };
+ let last = Date.parse(m.started || m.created);
+ for (const name of ['stdout.log', 'stderr.log']) {
+ try { const stat = fs.statSync(path.join(dir(m.id), name)); if (stat.size) last = Math.max(last, stat.mtimeMs); }
+ catch (e) { if (e.code !== 'ENOENT') throw e; }
+ }
+ const idle = Math.max(0, Date.now() - last);
+ return { state: idle >= stale ? 'no_progress' : 'running', idle_ms: idle, attention_required: idle >= stale,
+ ...(idle >= stale ? { action_required: 'Process is alive but output has not advanced. Inspect logs/process activity; choose a justified observation interval or cancel after diagnosis. Do not blindly repeat wait or duplicate the worker.' } : {}) };
+}
+export function status(id) {
+ return admission(() => {
+ const m = getRun(id);
+ if (m.executor.transport === 'cli' && ['running', 'starting', 'cancelling'].includes(m.status) && m.pid && !sameProcess(m.pid, m.pid_fingerprint)) {
+ m.status = 'orphaned'; m.error = 'Supervisor identity is gone; inspect logs and use recover after verifying process termination'; save(m);
+ }
+ return { ...compact(m), health: health(m) };
+ });
+}
+export async function wait(id, milliseconds = 60000) {
+ check(Number.isFinite(milliseconds) && milliseconds > 0, 'wait requires a positive timeout');
+ const deadline = Date.now() + milliseconds;
+ for (;;) {
+ const m = status(id);
+ if (terminal.includes(m.status) || ['permission', 'orphaned'].includes(m.status) || m.health.attention_required) return m;
+ if (Date.now() >= deadline) return { ...m, wait_timed_out: true, action_required: m.executor.transport === 'cli' ? 'Check health and progress before the next bounded wait; investigate repeated unchanged waits.' : 'Query the saved host agent before waiting again; record its current turn state.' };
+ await sleep(Math.min(500, deadline - Date.now()));
+ }
+}
+export function launch(id) {
+ check(!process.env.DELEGATE_KIT_DEPTH, 'Worker cannot launch another worker');
+ return admission(() => {
+ const m = getRun(id);
+ check(m.status === 'prepared', `Run ${id} is ${m.status}; attach/recover it, do not dispatch twice`);
+ m.status = 'starting'; m.started = now(); m.claim = randomUUID();
+ if (m.executor.transport !== 'cli') {
+ const prompt = fs.readFileSync(path.join(dir(id), 'prompt.md'), 'utf8');
+ const invoke = bridgeInvocation(m, `${prompt}\nFor this host turn, wrap that result as {"dispatch_token":"${m.claim}","result":}. Echo this exact token; earlier turn tokens are obsolete.`);
+ m.invoke = invoke; save(m); return { ...compact(m), invoke };
+ }
+ save(m);
+ const fd = fs.openSync(path.join(dir(id), 'supervisor.log'), 'a', 0o600);
+ const child = spawn(process.execPath, [path.join(skill, 'scripts/dk.mjs'), '_supervise', id, '--claim', m.claim], { detached: true, stdio: ['ignore', fd, fd] });
+ fs.closeSync(fd);
+ child.on('error', () => { admission(() => { const latest = getRun(id); latest.status = 'failed'; latest.error = 'Unable to spawn supervisor'; save(latest); releaseWriter(latest); }); });
+ m.pid = child.pid || null; m.pid_fingerprint = child.pid ? fingerprint(child.pid) : null; save(m);
+ child.unref(); return compact(m);
+ });
+}
+export function attach(id, transportId, workspaceId) {
+ check(typeof transportId === 'string' && transportId.length > 0 && transportId.length <= 512, 'Attach requires a concrete host agent ID');
+ return admission(() => {
+ const m = getRun(id); check(m.executor.transport !== 'cli', 'CLI attaches internally');
+ if (m.status === 'running') { check(m.transport_session_id === transportId, 'Already attached to another agent'); return compact(m); }
+ check(['starting', 'cancelling'].includes(m.status), 'Run must be dispatched before attach');
+ check(!m.transport_session_id || m.transport_session_id === transportId, 'Resume must attach the original agent');
+ if (m.executor.transport === 'paseo') check(workspaceId === m.workspace.id, 'Paseo returned a different workspace; reconcile before proceeding');
+ check(!allRuns().some(r => r.id !== id && active.includes(r.status) && r.executor?.transport === m.executor.transport && r.executor?.capability?.daemon === m.executor.capability?.daemon && r.transport_session_id === transportId), 'Host agent already belongs to another active attempt');
+ m.transport_session_id = transportId; m.host_attached = true; if (m.status !== 'cancelling') m.status = 'running'; m.host_checked_at = now(); m.progress_at ||= m.started; save(m); return compact(m);
+ });
+}
+function resultFrom(text) {
+ let result; try { result = JSON.parse(text.trim().replace(/^```(?:json)?\s*\n([\s\S]*?)\n```$/, '$1')); } catch { throw new Error('Worker result is not JSON'); }
+ const error = validate(result, schema); check(!error, `Invalid worker result: ${error}`); return result;
+}
+function finish(m, result, error, reason) {
+ m.finished = now(); m.result = result || null; m.result_validated = Boolean(result);
+ m.status = reason || (error || result?.status === 'failed' ? 'failed' : result?.status === 'blocked' ? 'blocked' : 'finished');
+ if (error) m.error = diagnostic(error);
+ atomicJSON(path.join(dir(m.id), 'result.json'), { status: m.status, validated: m.result_validated, accepted: false, result: m.result, error: m.error || null });
+ save(m); releaseWriter(m);
+ fs.appendFileSync(path.join(home(), 'ledger.jsonl'), JSON.stringify({ schema_version: 2, id: m.id, task: m.task, parent_session: m.parent_session, preset: m.preset, profile: m.profile, status: m.status, resume_of: m.resume_of, transport_session_id: m.transport_session_id, usage: m.usage, cost_usd: m.cost_usd }) + '\n', { mode: 0o600 });
+}
+export function ingest(id, { hostAgent, event, result, stopped = false, dispatchToken, progress }) {
+ return admission(() => {
+ const m = getRun(id); check(m.executor.transport !== 'cli', 'CLI completion is collected by its supervisor');
+ check(hostAgent === m.transport_session_id && hostAgent, 'Event is not correlated to the attached agent');
+ check(dispatchToken && dispatchToken === m.claim, 'Event dispatch token does not match this attempt');
+ if (event === 'complete') {
+ check(result?.dispatch_token === m.claim, 'Result dispatch token does not match this attempt');
+ result = result.result;
+ }
+ if (terminal.includes(m.status)) {
+ check(event === 'complete' && JSON.stringify(result) === JSON.stringify(m.result), 'Conflicting terminal event'); return compact(m);
+ }
+ check(['running', 'permission', 'cancelling'].includes(m.status), 'Run is not attached');
+ if (event === 'permission') { if (m.status !== 'cancelling') m.status = 'permission'; save(m); return compact(m); }
+ if (event === 'running') {
+ check(progress === undefined || typeof progress === 'string' && progress.length <= 512, 'Progress must be an observed host cursor');
+ m.host_checked_at = now();
+ if (progress !== undefined && progress !== m.progress_cursor) { m.progress_cursor = progress; m.progress_at = now(); }
+ if (m.status !== 'cancelling') m.status = 'running'; save(m); return compact(m);
+ }
+ check(['complete', 'failed', 'cancelled'].includes(event) && stopped, 'Completion must confirm the host turn/process stopped before releasing ownership');
+ let valid = null, error = null;
+ if (event === 'complete') { try { valid = resultFrom(JSON.stringify(result)); } catch (e) { error = e.message; } }
+ finish(m, valid, error || (event === 'failed' ? 'Host reported failure' : null), event === 'cancelled' ? 'cancelled' : null);
+ return compact(m);
+ });
+}
+export function dispatchFailed(id, { dispatchToken, confirmedNotStarted, evidence }) {
+ return admission(() => {
+ const m = getRun(id);
+ check(m.executor.transport !== 'cli' && ['starting', 'cancelling'].includes(m.status) && !m.host_attached, 'Only an unattached host dispatch can be reconciled as not started');
+ check(dispatchToken && dispatchToken === m.claim, 'Dispatch token mismatch');
+ check(confirmedNotStarted === true && typeof evidence === 'string' && evidence.trim(), 'Require host evidence confirming no agent/turn was started; a timeout is not proof');
+ m.dispatch_failure_evidence = diagnostic(evidence);
+ finish(m, null, 'Host confirmed dispatch did not start', m.status === 'cancelling' ? 'cancelled' : 'failed');
+ return compact(m);
+ });
+}
+export function resume(id, briefFile) {
+ check(!process.env.DELEGATE_KIT_DEPTH, 'Worker cannot resume another worker');
+ const brief = fs.readFileSync(briefFile, 'utf8'); check(brief.trim(), 'Brief is empty');
+ return admission(() => {
+ const prev = getRun(id);
+ check(terminal.includes(prev.status) && prev.transport_session_id, 'Resume requires a stopped run with an exact saved session');
+ check(!allRuns().some(r => active.includes(r.status) && r.parent_session === prev.parent_session && r.transport_session_id === prev.transport_session_id), 'An attempt already owns this executor session');
+ if (prev.executor.transport === 'cli' && ['pi', 'omp'].includes(prev.executor.harness)) check(prev.transport_session_file && fs.existsSync(prev.transport_session_file), 'Saved RPC session file is unavailable; do not resume a prefix or last session');
+ assertWorkspaceBinding(prev.executor, prev.cwd);
+ enforceCaps(prev.limits, counts(prev.workspace.owner === 'delegate-kit' ? prev.cwd : null), { workers: 1, writers: prev.write ? 1 : 0 });
+ const m = { ...prev, id: randomUUID(), status: 'prepared', created: now(), started: null, finished: null, error: null,
+ resume_of: id, result: null, result_validated: false, accepted: false, attempt_kind: 'continuation', pid: null,
+ pid_fingerprint: null, child_pid: null, child_fingerprint: null, usage: null, cost_usd: null, claim: null, invoke: null, host_attached: false, host_checked_at: null, progress_at: null, progress_cursor: null };
+ reserveWriter(m);
+ try {
+ m.budget = budget({ stateDir: home(), task: m.budget_task, ticket: m.profile, retry: true, record: true, limits: m.limits });
+ atomicJSON(path.join(dir(m.id), 'preset.snapshot.json'), readJSON(path.join(dir(id), 'preset.snapshot.json')));
+ fs.writeFileSync(path.join(dir(m.id), 'prompt.md'), promptFor(m, brief), { mode: 0o600 }); save(m);
+ } catch (e) { releaseWriter(m); throw e; }
+ return compact(m);
+ });
+}
+export async function cancel(id) {
+ let m = admission(() => {
+ const value = getRun(id);
+ if (terminal.includes(value.status)) return value;
+ if (value.status === 'prepared') { finish(value, null, null, 'cancelled'); return value; }
+ value.status = 'cancelling'; save(value); return value;
+ });
+ if (terminal.includes(m.status)) return compact(m);
+ if (m.executor.transport !== 'cli') return { ...compact(m), action_required: 'Interrupt the saved host agent; ingest cancelled with stopped=true only after the host confirms it stopped. Unattached dispatch must be reconciled with the host first.' };
+ // A starting supervisor may not have registered a child yet. Its first action
+ // observes cancelling under admission and exits without launching the model.
+ if (sameProcess(m.pid, m.pid_fingerprint)) {
+ process.kill(m.pid, 'SIGTERM'); // supervisor asks RPC abort before escalating
+ } else if (m.child_pid) {
+ signal(m, 'SIGTERM'); await sleep(250);
+ if (groupAlive(m.child_pid)) signal(m, 'SIGKILL');
+ }
+ for (let i = 0; i < 50; i++) {
+ m = getRun(id); if (terminal.includes(m.status)) return compact(m);
+ if (!sameProcess(m.pid, m.pid_fingerprint) && !groupAlive(m.child_pid)) return recover(id);
+ await sleep(100);
+ }
+ return { ...compact(m), action_required: 'Waiting for process termination; ownership is retained' };
+}
+export function recover(id) {
+ return admission(() => {
+ const m = getRun(id);
+ check(m.executor.transport === 'cli', 'Native recovery requires a correlated host completion event');
+ check(!sameProcess(m.pid, m.pid_fingerprint) && !groupAlive(m.child_pid), 'Supervisor or child group may still be writing; ownership retained');
+ if (!terminal.includes(m.status)) finish(m, null, 'Recovered stopped run; inspect partial changes before continuation', m.status === 'cancelling' ? 'cancelled' : 'failed');
+ return compact(m);
+ });
+}
+export function accept(id) {
+ return admission(() => {
+ const m = getRun(id); check(m.status === 'finished' && m.result_validated && m.result.status === 'done', 'Only a validated done result can be accepted');
+ const group = allRuns().filter(r => r.group === m.group);
+ for (const profile of m.required_profiles) {
+ const attempts = group.filter(r => r.profile === profile).sort((a, b) => a.created.localeCompare(b.created));
+ const latest = attempts.at(-1);
+ check(latest?.status === 'finished' && latest.result_validated && latest.result?.status === 'done' && !attempts.some(r => active.includes(r.status)), `Required profile ${profile} has not completed its latest attempt validly`);
+ if (profile === m.profile) check(latest.id === m.id, 'Accept the latest attempt, not an earlier result');
+ }
+ m.accepted = true; save(m); return compact(m);
+ });
+}
+
+export async function supervise(id, claim) {
+ let m = admission(() => {
+ const value = getRun(id); check(value.claim === claim, 'Supervisor claim mismatch');
+ if (value.status === 'cancelling') { finish(value, null, null, 'cancelled'); return value; }
+ check(value.status === 'starting', 'Run has already been supervised');
+ value.pid = process.pid; value.pid_fingerprint = fingerprint(process.pid); value.status = 'running'; save(value); return value;
+ });
+ if (m.status !== 'running') return;
+ const beat = () => atomicJSON(path.join(dir(id), 'heartbeat.json'), { pid: process.pid, claim, at: now() });
+ beat();
+ const heartbeatTimer = setInterval(beat, 5000); heartbeatTimer.unref();
+ let child, timer, gracefulTimer, forceTimer, result = null, failure = null, requestedReason = null;
+ const e = m.executor, isRPC = ['pi', 'omp'].includes(e.harness);
+ const stdoutFile = path.join(dir(id), 'stdout.log'), stderrFile = path.join(dir(id), 'stderr.log');
+ const logOut = fs.openSync(stdoutFile, 'a', 0o600), logErr = fs.openSync(stderrFile, 'a', 0o600);
+ const stop = reason => {
+ requestedReason ||= reason;
+ if (!child?.pid) return;
+ if (isRPC && child.stdin.writable) child.stdin.write(JSON.stringify({ id: 'dk-cancel', type: 'abort' }) + '\n', () => {});
+ else { try { signal(getRun(id), 'SIGTERM'); } catch {} }
+ gracefulTimer ||= setTimeout(() => { try { signal(getRun(id), 'SIGTERM'); } catch {} }, 300);
+ forceTimer ||= setTimeout(() => { try { signal(getRun(id), 'SIGKILL'); } catch {} }, 1500);
+ };
+ const onSignal = () => stop('cancelled');
+ process.on('SIGTERM', onSignal); process.on('SIGINT', onSignal);
+ try {
+ const prompt = fs.readFileSync(path.join(dir(id), 'prompt.md'), 'utf8');
+ let built;
+ if (isRPC) { atomicJSON(path.join(dir(id), 'runtime-config.json'), ompConfig); built = rpcCommand(e, dir(id), m.transport_session_file); }
+ else {
+ const permissions = e.harness === 'opencode' ? inspectPermissions({ cwd: m.cwd, agentName: `dk-${id}`, model: e.model }) : undefined;
+ built = buildCommand({ adapter: e.harness, model: e.model, effort: e.reasoning, provider: e.provider,
+ prompt, write: m.write, resumeId: m.transport_session_id, skillDir: skill, agentName: `dk-${id}`, permissionRules: permissions });
+ built.args = built.args.map(a => a === '__OUT__' ? path.join(dir(id), 'last-message.txt') : a);
+ }
+ // Cancellation and child registration share the admission lock.
+ admission(() => {
+ m = getRun(id); check(m.status === 'running', 'Run cancelled before model launch');
+ child = spawn(built.cmd, built.args, { cwd: m.cwd, detached: true, stdio: ['pipe', 'pipe', 'pipe'],
+ env: { ...process.env, DELEGATE_KIT_DEPTH: '1', ...(built.config ? { OPENCODE_CONFIG_CONTENT: mergeInline(built.config), OPENCODE_AUTO_SHARE: 'false' } : {}) } });
+ m.child_pid = child.pid || null; m.child_fingerprint = child.pid ? fingerprint(child.pid) : null; save(m);
+ });
+ const closed = new Promise((resolve, reject) => { child.once('error', reject); child.once('close', (code, sig) => resolve({ code, sig })); });
+ closed.catch(() => {});
+ child.stderr.on('data', bytes => fs.writeSync(logErr, bytes));
+ // No idle timeout. Only an explicitly supplied run deadline may stop reasoning.
+ if (m.timeout_ms) timer = setTimeout(() => stop('timeout'), m.timeout_ms);
+ if (isRPC) {
+ const output = await rpcTurn(child, e, prompt, state => admission(() => {
+ const value = getRun(id);
+ check(!value.transport_session_id || !state.sessionId || value.transport_session_id === state.sessionId, 'RPC resumed another session');
+ value.transport_session_id = state.sessionId || value.transport_session_id;
+ value.transport_session_file = state.sessionFile || value.transport_session_file; save(value);
+ }), bytes => fs.writeSync(logOut, bytes));
+ result = resultFrom(output.text);
+ admission(() => { const value = getRun(id); value.usage = output.usage; value.cost_usd = output.usage?.cost?.total ?? null; value.actual_model = output.actual_model; value.actual_provider = output.actual_provider; save(value); });
+ child.stdin.end();
+ // Pi has no documented EOF disposal guarantee. Its model turn is already
+ // terminal, so stop the idle transport; OMP drains on stdin EOF.
+ if (e.harness === 'pi') signal(getRun(id), 'SIGTERM');
+ const exit = await closed;
+ output.assertHealthy();
+ check(e.harness === 'pi' || exit.code === 0, 'OMP exited with a transport error');
+ } else {
+ child.stdout.on('data', bytes => fs.writeSync(logOut, bytes)); child.stdin.end();
+ const exit = await closed;
+ const extracted = extractResult(e.harness, fs.readFileSync(stdoutFile, 'utf8'), path.join(dir(id), 'last-message.txt'), schema);
+ admission(() => { const value = getRun(id); value.transport_session_id = extracted.sessionId || value.transport_session_id;
+ value.actual_model = extracted.actualModel; value.usage = extracted.usage; value.cost_usd = extracted.cost_usd; save(value); });
+ if (!requestedReason) {
+ check(exit.code === 0 && !extracted.error, extracted.error || `CLI exited ${exit.code ?? exit.sig}`);
+ result = extracted.result;
+ }
+ }
+ } catch (error) { failure = error.message; }
+ finally {
+ clearInterval(heartbeatTimer); clearTimeout(timer); clearTimeout(gracefulTimer); clearTimeout(forceTimer); process.off('SIGTERM', onSignal); process.off('SIGINT', onSignal);
+ // Keep the lease until all descendants have stopped, even after a leader exits.
+ m = getRun(id);
+ if (m.child_pid && groupAlive(m.child_pid)) {
+ // This supervisor created and continuously owns this process group.
+ try { process.kill(-m.child_pid, 'SIGKILL'); } catch (error) { if (error.code !== 'ESRCH') failure ||= error.message; }
+ for (let i = 0; i < 50 && groupAlive(m.child_pid); i++) await sleep(100);
+ }
+ child?.stdout?.removeAllListeners('data'); child?.stderr?.removeAllListeners('data');
+ fs.closeSync(logOut); fs.closeSync(logErr);
+ admission(() => {
+ const value = getRun(id);
+ if (groupAlive(value.child_pid)) { value.status = 'orphaned'; value.error = failure || 'Child group still active; ownership retained'; save(value); }
+ else finish(value, result, failure, requestedReason || (value.status === 'cancelling' ? 'cancelled' : null));
+ });
+ }
+}
diff --git a/skills/delegate-kit/tests/delivery.sh b/skills/delegate-kit/tests/delivery.sh
deleted file mode 100755
index e03ceac..0000000
--- a/skills/delegate-kit/tests/delivery.sh
+++ /dev/null
@@ -1,129 +0,0 @@
-#!/bin/bash
-# Стенд механики доставки завершений (`--on-finish`, `notify`, lifecycle).
-#
-# Прогоны синтетические: meta.json пишется руками, модели не вызываются, квота
-# не тратится. Настоящего воркера здесь нет намеренно — проверяется доставка,
-# а не бэкенд.
-#
-# ./delivery.sh функциональные проверки
-# ./delivery.sh --race N N раундов конкуренции (по умолчанию 20 × 8 процессов)
-#
-# Гонку стоит гонять отдельно и подолгу: оба дефекта, найденные при внедрении
-# (пустая заявка в окне создания и EPIPE у хука, не читающего stdin),
-# проявлялись лишь под нагрузкой и в одиночном прогоне выглядели как флак.
-set -u
-AR="$(cd "$(dirname "$0")/../scripts" && pwd)/agent-run"
-BASE="${TMPDIR:-/tmp}/dk-delivery-test.$$"
-trap 'rm -rf "$BASE"' EXIT
-export DELEGATE_KIT_HOME="$BASE/state"
-PASS=0; FAIL=0
-ok(){ if [ "$2" = "$3" ]; then echo " ✔ $1"; PASS=$((PASS+1)); else echo " ✘ $1: ожидалось [$3], получено [$2]"; FAIL=$((FAIL+1)); fi; }
-
-mkrun(){ # id status pid sessionId onFinish
- local d="$DELEGATE_KIT_HOME/runs/$1"; mkdir -p "$d"
- node -e '
- const fs=require("fs");const[,d,id,status,pid,sess,hook]=process.argv;
- const m={id,role:"researcher",backend:"codex",model:"gpt-5.6-terra",effort:"medium",
- cwd:"/tmp",write:false,status,pid:Number(pid),started:new Date(Date.now()-6e4).toISOString(),
- finished:status==="running"?null:new Date().toISOString(),
- sessionId:sess==="null"?null:sess,workerStatus:status==="finished"?"done":null,
- onFinish:hook==="null"?null:hook,
- delivery:hook==="null"?null:{state:"pending",attempts:0,nextAttemptAt:null,lastError:null,deliveredAt:null},
- result:{status:"done",summary:"синтетический прогон "+id,changes:[],checks_run:[],not_verified:[],findings:[],plan:[],questions:[],sources:[],next_steps:[]}};
- fs.writeFileSync(d+"/meta.json",JSON.stringify(m,null,2));
- fs.writeFileSync(d+"/result.json",JSON.stringify(m.result,null,2));
- ' "$d" "$1" "$2" "$3" "$4" "$5"
-}
-field(){ node -e 'const fs=require("fs");const m=JSON.parse(fs.readFileSync(process.argv[1]));const p=process.argv[2].split(".");let v=m;for(const k of p)v=v?.[k];console.log(v===undefined?"undefined":v===null?"null":v)' "$DELEGATE_KIT_HOME/runs/$1/meta.json" "$2"; }
-out(){ node "$AR" status "$1" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const o=JSON.parse(s);console.log(process.argv[1].split(".").reduce((a,k)=>a?.[k],o))})' "$2"; }
-rewind(){ node -e 'const fs=require("fs"),p=process.argv[1];const m=JSON.parse(fs.readFileSync(p));m.delivery.nextAttemptAt=new Date(Date.now()-1000).toISOString();fs.writeFileSync(p,JSON.stringify(m,null,2))' "$DELEGATE_KIT_HOME/runs/$1/meta.json"; }
-
-race(){
- local rounds="${1:-20}" procs=8 bad=0 lost=0
- for r in $(seq 1 "$rounds"); do
- rm -rf "$DELEGATE_KIT_HOME"; mkdir -p "$DELEGATE_KIT_HOME/runs"
- local log="$BASE/race.log"; : > "$log"
- mkrun rr finished 999999 s "echo hit >> $log"
- for i in $(seq 1 $procs); do node "$AR" notify rr >/dev/null 2>&1 & done; wait
- local n s; n=$(grep -c hit "$log"); s=$(field rr delivery.state)
- [ "$n" = "1" ] || { echo " раунд $r: срабатываний $n"; bad=$((bad+1)); }
- [ "$s" = "delivered" ] || { echo " раунд $r: состояние $s"; lost=$((lost+1)); }
- done
- echo "раундов: $rounds × $procs процессов, дублей: $bad, недоставлено: $lost"
- [ $((bad+lost)) -eq 0 ]
-}
-
-rm -rf "$DELEGATE_KIT_HOME"; mkdir -p "$DELEGATE_KIT_HOME/runs"
-if [ "${1:-}" = "--race" ]; then race "${2:-20}"; exit $?; fi
-
-HOOKLOG="$BASE/hook.log"; : > "$HOOKLOG"
-HOOK="echo \"fired \$DK_RUN_ID \$DK_STATUS \$DK_LIFECYCLE\" >> $HOOKLOG"
-
-echo "── доставка завершённого прогона"
-mkrun r-done finished 999999 sess-abc "$HOOK"
-node "$AR" notify >/dev/null
-ok "хук сработал" "$(grep -c 'fired r-done' "$HOOKLOG")" "1"
-ok "состояние доставки" "$(field r-done delivery.state)" "delivered"
-ok "lifecycle=parked при живой сессии" "$(out r-done lifecycle)" "parked"
-
-echo "── ровно однажды"
-node "$AR" notify >/dev/null; node "$AR" list >/dev/null; node "$AR" status r-done >/dev/null
-ok "срабатываний по-прежнему одно" "$(grep -c 'fired r-done' "$HOOKLOG")" "1"
-
-echo "── живой прогон не доставляется"
-mkrun r-live running $$ null "$HOOK"
-node "$AR" notify >/dev/null
-ok "хук не сработал" "$(grep -c 'fired r-live' "$HOOKLOG")" "0"
-
-echo "── смерть супервизора: сверка плюс доставка"
-mkrun r-orph running 999998 null "$HOOK"
-node "$AR" list >/dev/null
-ok "статус переведён в orphaned" "$(field r-orph status)" "orphaned"
-ok "хук сработал по факту смерти" "$(grep -c 'fired r-orph' "$HOOKLOG")" "1"
-ok "lifecycle=done без сессии" "$(out r-orph lifecycle)" "done"
-
-echo "── провал по квоте не доставляется (доставит перезапуск)"
-mkrun r-quota failed-quota 999997 null "$HOOK"
-node "$AR" notify >/dev/null
-ok "хук не сработал" "$(grep -c 'fired r-quota' "$HOOKLOG")" "0"
-
-echo "── ретраи и backoff"
-mkrun r-bad finished 999996 null "exit 3"
-node "$AR" notify >/dev/null
-ok "попытка учтена" "$(field r-bad delivery.attempts)" "1"
-ok "срок следующей назначен" "$([ "$(field r-bad delivery.nextAttemptAt)" = null ] && echo нет || echo есть)" "есть"
-ok "код возврата в ошибке" "$(field r-bad delivery.lastError | grep -c 'exited 3')" "1"
-node "$AR" notify >/dev/null
-ok "до срока повтора нет" "$(field r-bad delivery.attempts)" "1"
-for i in 2 3 4 5; do rewind r-bad; node "$AR" notify >/dev/null; done
-ok "попытки исчерпаны" "$(field r-bad delivery.attempts)" "5"
-ok "доставка помечена провалившейся" "$(field r-bad delivery.state)" "failed"
-node "$AR" notify >/dev/null
-ok "сама не ретраится" "$(field r-bad delivery.attempts)" "5"
-node "$AR" notify r-bad --force >/dev/null
-ok "--force поднимает" "$(field r-bad delivery.attempts)" "6"
-
-echo "── конкуренция"
-mkrun r-race finished 999995 s "$HOOK"
-for i in $(seq 1 8); do node "$AR" notify r-race >/dev/null 2>&1 & done; wait
-ok "сработало ровно однажды" "$(grep -c 'fired r-race' "$HOOKLOG")" "1"
-ok "заявка снята" "$([ -e "$DELEGATE_KIT_HOME/runs/r-race/delivery.lock" ] && echo есть || echo нет)" "нет"
-
-echo "── хук, не читающий stdin, не провал (регрессия EPIPE)"
-mkrun r-epipe finished 999993 null "$HOOK"
-# Payload раздут за буфер трубы (64 КиБ): иначе разрыв ловится лишь иногда.
-node -e 'const f=process.argv[1];const m=JSON.parse(require("fs").readFileSync(f));m.result.summary="д".repeat(200000);require("fs").writeFileSync(f,JSON.stringify(m))' "$DELEGATE_KIT_HOME/runs/r-epipe/meta.json"
-node "$AR" notify >/dev/null
-ok "хук сработал" "$(grep -c 'fired r-epipe' "$HOOKLOG")" "1"
-ok "доставка засчитана" "$(field r-epipe delivery.state)" "delivered"
-ok "ошибка не записана" "$(field r-epipe delivery.lastError)" "null"
-ok "payload доступен файлом" "$([ -s "$DELEGATE_KIT_HOME/runs/r-epipe/delivery-payload.json" ] && echo есть || echo нет)" "есть"
-
-echo "── прогон без хука не ломает поллинг"
-mkrun r-nohook finished 999994 null null
-node "$AR" list >/dev/null 2>&1
-ok "list отработал" "$?" "0"
-ok "delivery отсутствует" "$(field r-nohook delivery)" "null"
-
-echo; echo "Пройдено: $PASS, провалено: $FAIL"
-exit $((FAIL > 0))
diff --git a/skills/delegate-kit/tests/gate.sh b/skills/delegate-kit/tests/gate.sh
deleted file mode 100755
index 65697fc..0000000
--- a/skills/delegate-kit/tests/gate.sh
+++ /dev/null
@@ -1,55 +0,0 @@
-#!/bin/bash
-# Стенд hooks/gate.sh: что просит подтверждения, что пропускает, и правило
-# глубины делегирования — субагент (agent_type во входе хука) не запускает
-# воркеров и не берёт lock на worktree, префикс подтверждения этого не открывает.
-#
-# ./gate.sh
-set -u
-GATE="$(cd "$(dirname "$0")/../hooks" && pwd)/gate.sh"
-LAST="${TMPDIR:-/tmp}/dk-gate-test.$$.json"; trap 'rm -f "$LAST"' EXIT
-PASS=0; FAIL=0
-ok(){ if [ "$2" = "$3" ]; then echo " ✔ $1"; PASS=$((PASS+1)); else echo " ✘ $1: ожидалось [$3], получено [$2]"; FAIL=$((FAIL+1)); fi; }
-# gate HARNESS AGENT_TYPE COMMAND → decision (allow | ask | deny); сырой ответ в $LAST для reason
-gate(){
- jq -cn --arg c "$3" --arg a "$2" '{hook_event_name:"PreToolUse",tool_name:"Bash",tool_input:{command:$c}} + (if $a == "" then {} else {agent_type:$a} end)' \
- | DELEGATE_KIT_CONFIRMED= bash "$GATE" --harness "$1" > "$LAST"
- [ -s "$LAST" ] || echo '{}' > "$LAST"
- jq -r '.hookSpecificOutput.permissionDecision // "allow"' "$LAST"
-}
-reason(){ jq -r '.hookSpecificOutput.permissionDecisionReason // ""' "$LAST"; }
-
-echo "── опасные команды: ask на Claude, deny с инструкцией на Codex"
-ok "rm -rf → ask" "$(gate claude "" 'rm -rf build')" "ask"
-ok "причина названа" "$(reason | grep -c 'rm -rf')" "1"
-ok "sudo → ask" "$(gate claude "" 'sudo systemctl restart nginx')" "ask"
-ok "git push --force → ask" "$(gate claude "" 'git push --force origin main')" "ask"
-ok "DROP TABLE → ask" "$(gate claude "" 'psql -c "DROP TABLE users"')" "ask"
-ok "codex: deny с префиксом подтверждения" "$(gate codex "" 'rm -rf build')" "deny"
-ok "codex: инструкция про DELEGATE_KIT_CONFIRMED" "$(reason | grep -c 'DELEGATE_KIT_CONFIRMED=1')" "1"
-ok "префикс подтверждения пропускает" "$(gate codex "" 'DELEGATE_KIT_CONFIRMED=1 rm -rf build')" "allow"
-
-echo "── обычные команды проходят"
-ok "ls" "$(gate claude "" 'ls -la')" "allow"
-ok "git push без force" "$(gate claude "" 'git push origin feature')" "allow"
-ok "rm одного файла" "$(gate claude "" 'rm build/out.txt')" "allow"
-ok "не Bash-вход (нет command)" "$(printf '{"tool_name":"Read","tool_input":{"file_path":"x"}}' | bash "$GATE" --harness claude; echo "exit $?")" "exit 0"
-
-echo "── глубина делегирования: субагент не запускает воркеров"
-ok "координатор: agent-run run проходит" "$(gate claude "" 'agent-run run --role reviewer --backend codex --brief b.md')" "allow"
-ok "субагент: agent-run run → deny" "$(gate claude "dk-implementer" 'agent-run run --role reviewer --backend codex --brief b.md')" "deny"
-ok "причина — depth 1 с именем агента" "$(reason | grep -c 'depth is 1.*dk-implementer')" "1"
-ok "субагент: agent-run resume → deny" "$(gate claude "dk-planner" 'agent-run resume 2026-x --brief n.md')" "deny"
-ok "субагент: agent-wt lock → deny" "$(gate claude "dk-implementer" 'agent-wt lock slice-a')" "deny"
-ok "субагент: путь к скрипту тоже" "$(gate claude "dk-implementer" '~/.claude/skills/delegate-kit/scripts/agent-run run --role planner --brief b.md')" "deny"
-ok "субагент: исполняемый файл в кавычках" "$(gate claude "dk-implementer" '"agent-run" run --role planner --brief b.md')" "deny"
-ok "субагент: путь в кавычках" "$(gate claude "dk-implementer" "'/home/me/bin/agent-wt' lock task")" "deny"
-ok "субагент: через env/node" "$(gate claude "dk-implementer" 'env DELEGATE_KIT_PRESET=auto node /x/agent-run run --role planner --brief b.md')" "deny"
-ok "субагент: в цепочке команд" "$(gate claude "dk-implementer" 'cd /repo && agent-run run --role planner --brief b.md')" "deny"
-ok "субагент: префикс подтверждения не открывает" "$(gate claude "dk-implementer" 'DELEGATE_KIT_CONFIRMED=1 agent-run run --role planner --brief b.md')" "deny"
-ok "субагент на Codex тоже deny" "$(gate codex "dk-reviewer" 'agent-run run --role verifier --brief b.md')" "deny"
-ok "субагент: status/list/wait проходят" "$(gate claude "dk-reviewer" 'agent-run status 2026-x; agent-run list; agent-wt status s')" "allow"
-ok "субагент: agent-wt diff проходит" "$(gate claude "dk-reviewer" 'agent-wt diff slice-a > review.diff')" "allow"
-ok "субагент: обычные команды проходят" "$(gate claude "dk-implementer" 'npm test')" "allow"
-
-echo; echo "Пройдено: $PASS, провалено: $FAIL"
-exit $((FAIL > 0))
diff --git a/skills/delegate-kit/tests/inspect.sh b/skills/delegate-kit/tests/inspect.sh
deleted file mode 100755
index 6e12e43..0000000
--- a/skills/delegate-kit/tests/inspect.sh
+++ /dev/null
@@ -1,53 +0,0 @@
-#!/bin/bash
-# Стенд `agent-run inspect`: что прерванный писатель оставил в worktree и какую
-# заметку получит его fallback-перезапуск. Та же функция кормит quota fallback,
-# поэтому проверяется здесь, без запуска моделей.
-#
-# ./inspect.sh
-set -u
-SCRIPTS="$(cd "$(dirname "$0")/../scripts" && pwd)"
-AR="$SCRIPTS/agent-run"; WT="$SCRIPTS/agent-wt"
-BASE="${TMPDIR:-/tmp}/dk-inspect-test.$$"
-trap 'rm -rf "$BASE"' EXIT
-export DELEGATE_KIT_HOME="$BASE/state"
-PASS=0; FAIL=0
-ok(){ if [ "$2" = "$3" ]; then echo " ✔ $1"; PASS=$((PASS+1)); else echo " ✘ $1: ожидалось [$3], получено [$2]"; FAIL=$((FAIL+1)); fi; }
-G="git -c user.email=t@t -c user.name=t"
-
-mkdir -p "$BASE/repo"; cd "$BASE/repo" || exit 1
-git init -q -b main . && $G commit -q --allow-empty -m init
-"$WT" create slice >/dev/null 2>&1
-W="$BASE/repo.worktrees/slice"
-
-echo "── чистый worktree"
-ok "git=true, partial=false" "$(node "$AR" inspect "$W" | jq -r '[.git,.partial,(.commits|length),(.dirty|length)]|join(" ")')" "true false 0 0"
-ok "база записана agent-wt create" "$(node "$AR" inspect "$W" | jq -r '.base == "'"$(git rev-parse HEAD)"'"')" "true"
-ok "note пустой" "$(node "$AR" inspect "$W" | jq -r '.note')" "null"
-
-echo "── коммит и грязный файл после базы"
-echo a > "$W/a.txt"; $G -C "$W" add a.txt; $G -C "$W" commit -q -m "add a"
-echo b > "$W/b.txt"; echo a2 > "$W/a.txt"
-OUT=$(node "$AR" inspect "$W")
-ok "partial=true" "$(jq -r '.partial' <<<"$OUT")" "true"
-ok "один коммит с темой" "$(jq -r '.commits|length|tostring' <<<"$OUT") $(jq -r '.commits[0].subject' <<<"$OUT")" "1 add a"
-ok "грязные: изменённый и новый" "$(jq -r '[.dirty[]|.status+":"+.path]|sort|join(" ")' <<<"$OUT")" "??:b.txt M:a.txt"
-ok "note называет коммит" "$(jq -r '.note' <<<"$OUT" | grep -c 'add a')" "1"
-ok "note называет грязные файлы" "$(jq -r '.note' <<<"$OUT" | grep -c 'M a.txt, ?? b.txt')" "1"
-ok "note велит читать и продолжать" "$(jq -r '.note' <<<"$OUT" | grep -c 'continue from it')" "1"
-
-echo "── файлы в новом каталоге перечислены поимённо"
-mkdir -p "$W/src/new"; echo c > "$W/src/new/c.txt"; echo d > "$W/src/new/d.txt"
-ok "два файла, не один каталог" "$(node "$AR" inspect "$W" | jq -r '[.dirty[]|select(.path|startswith("src/new/"))|.path]|sort|join(" ")')" "src/new/c.txt src/new/d.txt"
-rm -rf "$W/src"
-
-echo "── без базы считаются только грязные файлы"
-rm "$BASE/repo/.git/worktrees/slice/delegate-kit.base"
-ok "base=null, коммиты не считаются, грязные видны" "$(node "$AR" inspect "$W" | jq -r '[(.base|tostring),(.commits|length),(.dirty|length),.partial]|join(" ")')" "null 0 2 true"
-
-echo "── не git"
-mkdir -p "$BASE/plain"
-ok "git=false, partial=false" "$(node "$AR" inspect "$BASE/plain" | jq -r '[.git,.partial]|join(" ")')" "false false"
-ok "нет каталога — отказ" "$(node "$AR" inspect "$BASE/nope" 2>&1 | grep -c 'no such directory')" "1"
-
-echo; echo "Пройдено: $PASS, провалено: $FAIL"
-exit $((FAIL > 0))
diff --git a/skills/delegate-kit/tests/adapters.test.mjs b/tests/adapters.test.mjs
similarity index 97%
rename from skills/delegate-kit/tests/adapters.test.mjs
rename to tests/adapters.test.mjs
index d7370a3..4f300d6 100644
--- a/skills/delegate-kit/tests/adapters.test.mjs
+++ b/tests/adapters.test.mjs
@@ -5,9 +5,9 @@ import { fileURLToPath } from 'node:url';
import os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
-import { inspectPermissions, narrowPermissions, mergeInline } from '../scripts/opencode-permissions.mjs';
-import { buildCommand, extractResult } from '../scripts/adapters.mjs';
-const skillDir = fileURLToPath(new URL('..', import.meta.url));
+import { inspectPermissions, narrowPermissions, mergeInline } from '../skills/delegate-kit/scripts/opencode-permissions.mjs';
+import { buildCommand, extractResult } from '../skills/delegate-kit/scripts/adapters.mjs';
+const skillDir = fileURLToPath(new URL('../skills/delegate-kit/', import.meta.url));
const schema = JSON.parse(fs.readFileSync(path.join(skillDir, 'references/result-schema.json')));
const done = { status: 'done', summary: 'Checked', changes: [], checks_run: [], not_verified: [], plan: [], findings: [], questions: [], sources: [], next_steps: [] };
const build = (adapter, extra = {}) => buildCommand({ adapter, model: null, effort: null, prompt: 'task', write: false, skillDir, ...extra });
diff --git a/tests/behavioral.md b/tests/behavioral.md
new file mode 100644
index 0000000..30883b0
--- /dev/null
+++ b/tests/behavioral.md
@@ -0,0 +1,27 @@
+# Behavioral acceptance protocol
+
+These cases are prepared for manual or explicitly authorized model evaluation. They are not claimed as executed model tests. Use the same frozen repository, task, model settings and authorization for baseline-without-skill, v1 and v2. Configure actual permitted models in place of the symbolic executor IDs; no commercial model name is a test condition.
+
+Use one complete preset with descriptions for general research, alternative research, ordinary implementation, UI implementation, complex contract work, independent review and an optional planner. Copy it to Y2 and change a chosen executor explicitly. Fix the repository revision and retain session/run handles, briefs, accepted results, checks and usage artifacts.
+
+| Case | Request | Observable outcome |
+|---|---|---|
+| Direct | Correct one understood typo already visible in context | Direct edit is valid; no mandatory planner/worker |
+| Research | Find the exact reconnect contract in source/docs | Configured researcher gets a bounded brief; no automatic parent model inheritance |
+| UI | Fix responsive menu layout | Chosen specialist's description fits UI; real browser verification follows project rules |
+| Auth text | Change the registration-page label only | Complexity is judged from the change, not the word auth |
+| Complex | Resolve conflicting persistence/retry invariants | Planner/complex specialist may be useful, with a stated reason |
+| Parallel | Two independent owned modules with stable interfaces | Independent briefs/workspaces; no duplicate coordinator implementation |
+| Waiting | Worker runs longer than parent wait | No duplicate launch or repeated LLM polling; compact eventual result |
+| Repair | Initial result omitted one boundary check | Same executor session receives a concrete continuation |
+| Independence | Ask for a second review | Fresh session; neither reviewer sees the other's initial conclusions |
+| Session | X1 in chat A, Y2 in chat B, same cwd | Independent bindings; neither chat model changes |
+| Policy | Repeat cases with two different coordinators | Same policy and preset contract; no coordinator-intelligence branches |
+
+Record accepted behavior, actual checks, rework, starts versus continuations, elapsed time, user interventions, coordinator context/messages and available usage. Missing usage stays unknown. More delegation is not success; no universal savings or quality percentage follows from this evaluation.
+
+## Opt-in live Codex smoke
+
+Run `node tests/live-codex.mjs --execute --writer-model gpt-5.6-sol --reviewer-model gpt-5.6-luna` only with authorization for those model calls. Without `--execute` it prints help. It uses the existing Codex connection, low reasoning, at most four dispatches, and an isolated temporary repository/worktree/state. It verifies a real fix against tests, exact-session continuation with a remembered random marker, independent review, bounded wait without duplicate launch, and cancellation. Artifacts and report.json remain in the printed temporary directory. On failure it attempts to stop its own runs. CI never invokes this live script.
+
+The first authorized run on 2026-09-16 passed all four lifecycle cases with Sol/Luna. It did not evaluate the full semantic selection table above. Technical fixtures in v2.test.mjs cover failed native dispatch reconciliation, stale continuation events, watchdog alerts, migration and RPC usage without model calls.
diff --git a/skills/delegate-kit/tests/budget.test.mjs b/tests/budget.test.mjs
similarity index 96%
rename from skills/delegate-kit/tests/budget.test.mjs
rename to tests/budget.test.mjs
index 87ebcbd..c924abd 100644
--- a/skills/delegate-kit/tests/budget.test.mjs
+++ b/tests/budget.test.mjs
@@ -4,7 +4,7 @@ import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawn } from 'node:child_process';
-import { budget } from '../scripts/budget.mjs';
+import { budget } from '../skills/delegate-kit/scripts/budget.mjs';
function fixture(t) {
const stateDir = fs.mkdtempSync(fileURLToPath(new URL('../.budget-test-', import.meta.url)));
@@ -59,7 +59,7 @@ test('malformed state fails without resetting usage', t => {
});
test('concurrent processes cannot overspend a shared run or ticket retry allowance', async t => {
const opts = fixture(t);
- const moduleURL = new URL('../scripts/budget.mjs', import.meta.url).href;
+ const moduleURL = new URL('../skills/delegate-kit/scripts/budget.mjs', import.meta.url).href;
const attempt = options => new Promise((resolve, reject) => {
const child = spawn(process.execPath, ['--input-type=module', '-e', `import {budget} from ${JSON.stringify(moduleURL)}; try {budget(JSON.parse(process.argv[1]));} catch {process.exitCode=2;}`, JSON.stringify(options)], { stdio: 'ignore' });
child.on('error', reject); child.on('exit', resolve);
diff --git a/skills/delegate-kit/tests/caps.sh b/tests/caps.sh
similarity index 99%
rename from skills/delegate-kit/tests/caps.sh
rename to tests/caps.sh
index 7598d1a..d05d360 100755
--- a/skills/delegate-kit/tests/caps.sh
+++ b/tests/caps.sh
@@ -9,7 +9,7 @@
#
# ./caps.sh
set -u
-SCRIPTS="$(cd "$(dirname "$0")/../scripts" && pwd)"
+SCRIPTS="$(cd "$(dirname "$0")/../skills/delegate-kit/scripts" && pwd)"
AR="$SCRIPTS/agent-run"; WT="$SCRIPTS/agent-wt"
BASE="${TMPDIR:-/tmp}/dk-caps-test.$$"
trap 'rm -rf "$BASE"' EXIT
diff --git a/tests/delivery.sh b/tests/delivery.sh
new file mode 100755
index 0000000..8512e52
--- /dev/null
+++ b/tests/delivery.sh
@@ -0,0 +1,129 @@
+#!/bin/bash
+# Completion delivery contracts for --on-finish, notify and lifecycle.
+#
+# Synthetic metadata fixtures; no worker or model calls.
+# Backend execution is tested separately.
+# Run ./delivery.sh for functional checks.
+#
+# Run ./delivery.sh --race N for N concurrency rounds.
+# The default race uses 20 rounds of eight processes.
+#
+# Stress the creation window and hooks that close stdin early.
+# These regressions require concurrency to reproduce consistently.
+# A payload larger than the pipe buffer exercises the EPIPE path.
+set -u
+AR="$(cd "$(dirname "$0")/../skills/delegate-kit/scripts" && pwd)/agent-run"
+BASE="${TMPDIR:-/tmp}/dk-delivery-test.$$"
+trap 'rm -rf "$BASE"' EXIT
+export DELEGATE_KIT_HOME="$BASE/state"
+PASS=0; FAIL=0
+ok(){ if [ "$2" = "$3" ]; then echo " ✔ $1"; PASS=$((PASS+1)); else echo " ✘ $1: expected [$3], got [$2]"; FAIL=$((FAIL+1)); fi; }
+
+mkrun(){ # id status pid sessionId onFinish
+ local d="$DELEGATE_KIT_HOME/runs/$1"; mkdir -p "$d"
+ node -e '
+ const fs=require("fs");const[,d,id,status,pid,sess,hook]=process.argv;
+ const m={id,role:"researcher",backend:"codex",model:"gpt-5.6-terra",effort:"medium",
+ cwd:"/tmp",write:false,status,pid:Number(pid),started:new Date(Date.now()-6e4).toISOString(),
+ finished:status==="running"?null:new Date().toISOString(),
+ sessionId:sess==="null"?null:sess,workerStatus:status==="finished"?"done":null,
+ onFinish:hook==="null"?null:hook,
+ delivery:hook==="null"?null:{state:"pending",attempts:0,nextAttemptAt:null,lastError:null,deliveredAt:null},
+ result:{status:"done",summary:"synthetic run "+id,changes:[],checks_run:[],not_verified:[],findings:[],plan:[],questions:[],sources:[],next_steps:[]}};
+ fs.writeFileSync(d+"/meta.json",JSON.stringify(m,null,2));
+ fs.writeFileSync(d+"/result.json",JSON.stringify(m.result,null,2));
+ ' "$d" "$1" "$2" "$3" "$4" "$5"
+}
+field(){ node -e 'const fs=require("fs");const m=JSON.parse(fs.readFileSync(process.argv[1]));const p=process.argv[2].split(".");let v=m;for(const k of p)v=v?.[k];console.log(v===undefined?"undefined":v===null?"null":v)' "$DELEGATE_KIT_HOME/runs/$1/meta.json" "$2"; }
+out(){ node "$AR" status "$1" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const o=JSON.parse(s);console.log(process.argv[1].split(".").reduce((a,k)=>a?.[k],o))})' "$2"; }
+rewind(){ node -e 'const fs=require("fs"),p=process.argv[1];const m=JSON.parse(fs.readFileSync(p));m.delivery.nextAttemptAt=new Date(Date.now()-1000).toISOString();fs.writeFileSync(p,JSON.stringify(m,null,2))' "$DELEGATE_KIT_HOME/runs/$1/meta.json"; }
+
+race(){
+ local rounds="${1:-20}" procs=8 bad=0 lost=0
+ for r in $(seq 1 "$rounds"); do
+ rm -rf "$DELEGATE_KIT_HOME"; mkdir -p "$DELEGATE_KIT_HOME/runs"
+ local log="$BASE/race.log"; : > "$log"
+ mkrun rr finished 999999 s "echo hit >> $log"
+ for _i in $(seq 1 $procs); do node "$AR" notify rr >/dev/null 2>&1 & done; wait
+ local n s; n=$(grep -c hit "$log"); s=$(field rr delivery.state)
+ [ "$n" = "1" ] || { echo " round $r: deliveries $n"; bad=$((bad+1)); }
+ [ "$s" = "delivered" ] || { echo " round $r: state $s"; lost=$((lost+1)); }
+ done
+ echo "rounds: $rounds × $procs processes, duplicates: $bad, undelivered: $lost"
+ [ $((bad+lost)) -eq 0 ]
+}
+
+rm -rf "$DELEGATE_KIT_HOME"; mkdir -p "$DELEGATE_KIT_HOME/runs"
+if [ "${1:-}" = "--race" ]; then race "${2:-20}"; exit $?; fi
+
+HOOKLOG="$BASE/hook.log"; : > "$HOOKLOG"
+HOOK="echo \"fired \$DK_RUN_ID \$DK_STATUS \$DK_LIFECYCLE\" >> $HOOKLOG"
+
+echo "-- Completed run delivery"
+mkrun r-done finished 999999 sess-abc "$HOOK"
+node "$AR" notify >/dev/null
+ok "Completion hook fired" "$(grep -c 'fired r-done' "$HOOKLOG")" "1"
+ok "Delivery state recorded" "$(field r-done delivery.state)" "delivered"
+ok "Live session is parked" "$(out r-done lifecycle)" "parked"
+
+echo "-- Exactly once"
+node "$AR" notify >/dev/null; node "$AR" list >/dev/null; node "$AR" status r-done >/dev/null
+ok "Hook still fired only once" "$(grep -c 'fired r-done' "$HOOKLOG")" "1"
+
+echo "-- Running tasks are not delivered"
+mkrun r-live running $$ null "$HOOK"
+node "$AR" notify >/dev/null
+ok "Hook did not fire" "$(grep -c 'fired r-live' "$HOOKLOG")" "0"
+
+echo "-- Supervisor death triggers reconciliation and delivery"
+mkrun r-orph running 999998 null "$HOOK"
+node "$AR" list >/dev/null
+ok "Dead supervisor becomes orphaned" "$(field r-orph status)" "orphaned"
+ok "Hook fires after supervisor death" "$(grep -c 'fired r-orph' "$HOOKLOG")" "1"
+ok "No session means done" "$(out r-orph lifecycle)" "done"
+
+echo "-- Quota failure defers delivery to the next attempt"
+mkrun r-quota failed-quota 999997 null "$HOOK"
+node "$AR" notify >/dev/null
+ok "Quota fallback defers delivery" "$(grep -c 'fired r-quota' "$HOOKLOG")" "0"
+
+echo "-- Retries and backoff"
+mkrun r-bad finished 999996 null "exit 3"
+node "$AR" notify >/dev/null
+ok "Attempt counted" "$(field r-bad delivery.attempts)" "1"
+ok "Next retry scheduled" "$([ "$(field r-bad delivery.nextAttemptAt)" = null ] && echo absent || echo present)" "present"
+ok "Exit status included in error" "$(field r-bad delivery.lastError | grep -c 'exited 3')" "1"
+node "$AR" notify >/dev/null
+ok "No retry before deadline" "$(field r-bad delivery.attempts)" "1"
+for _i in 2 3 4 5; do rewind r-bad; node "$AR" notify >/dev/null; done
+ok "Attempts exhausted" "$(field r-bad delivery.attempts)" "5"
+ok "Delivery marked failed" "$(field r-bad delivery.state)" "failed"
+node "$AR" notify >/dev/null
+ok "No automatic retry after failure" "$(field r-bad delivery.attempts)" "5"
+node "$AR" notify r-bad --force >/dev/null
+ok "Force retries delivery" "$(field r-bad delivery.attempts)" "6"
+
+echo "-- Concurrent delivery"
+mkrun r-race finished 999995 s "$HOOK"
+for _i in $(seq 1 8); do node "$AR" notify r-race >/dev/null 2>&1 & done; wait
+ok "Concurrent delivery fires once" "$(grep -c 'fired r-race' "$HOOKLOG")" "1"
+ok "Delivery claim released" "$([ -e "$DELEGATE_KIT_HOME/runs/r-race/delivery.lock" ] && echo present || echo absent)" "absent"
+
+echo "-- Hook that does not read stdin avoids EPIPE"
+mkrun r-epipe finished 999993 null "$HOOK"
+# Delivery regression coverage.
+node -e 'const f=process.argv[1];const m=JSON.parse(require("fs").readFileSync(f));m.result.summary="x".repeat(200000);require("fs").writeFileSync(f,JSON.stringify(m))' "$DELEGATE_KIT_HOME/runs/r-epipe/meta.json"
+node "$AR" notify >/dev/null
+ok "Hook fired" "$(grep -c 'fired r-epipe' "$HOOKLOG")" "1"
+ok "Delivery counted" "$(field r-epipe delivery.state)" "delivered"
+ok "No error recorded" "$(field r-epipe delivery.lastError)" "null"
+ok "Payload file is available" "$([ -s "$DELEGATE_KIT_HOME/runs/r-epipe/delivery-payload.json" ] && echo present || echo absent)" "present"
+
+echo "-- Polling without a hook"
+mkrun r-nohook finished 999994 null null
+node "$AR" list >/dev/null 2>&1
+ok "List succeeds" "$?" "0"
+ok "No delivery record without a hook" "$(field r-nohook delivery)" "null"
+
+echo; echo "Passed: $PASS, failed: $FAIL"
+exit $((FAIL > 0))
diff --git a/tests/gate.sh b/tests/gate.sh
new file mode 100755
index 0000000..dbf4135
--- /dev/null
+++ b/tests/gate.sh
@@ -0,0 +1,61 @@
+#!/bin/bash
+# Gate contracts: dangerous commands, explicit confirmation and delegation depth.
+# A worker cannot launch another worker or acquire a writer lock.
+# Confirmation does not bypass the depth restriction.
+#
+# ./gate.sh
+set -u
+GATE="$(cd "$(dirname "$0")/../skills/delegate-kit/hooks" && pwd)/gate.sh"
+LAST="${TMPDIR:-/tmp}/dk-gate-test.$$.json"; trap 'rm -f "$LAST"' EXIT
+PASS=0; FAIL=0
+ok(){ if [ "$2" = "$3" ]; then echo " ✔ $1"; PASS=$((PASS+1)); else echo " ✘ $1: expected [$3], got [$2]"; FAIL=$((FAIL+1)); fi; }
+# gate HARNESS AGENT_TYPE COMMAND returns allow, ask or deny; LAST retains the reason.
+gate(){
+ jq -cn --arg c "$3" --arg a "$2" '{hook_event_name:"PreToolUse",tool_name:"Bash",tool_input:{command:$c}} + (if $a == "" then {} else {agent_type:$a} end)' \
+ | DELEGATE_KIT_CONFIRMED='' bash "$GATE" --harness "$1" > "$LAST"
+ [ -s "$LAST" ] || echo '{}' > "$LAST"
+ jq -r '.hookSpecificOutput.permissionDecision // "allow"' "$LAST"
+}
+reason(){ jq -r '.hookSpecificOutput.permissionDecisionReason // ""' "$LAST"; }
+
+echo "-- Dangerous commands ask on Claude and deny with guidance on Codex"
+ok "rm -rf → ask" "$(gate claude "" 'rm -rf build')" "ask"
+ok "The reason identifies the command" "$(reason | grep -c 'rm -rf')" "1"
+ok "sudo → ask" "$(gate claude "" 'sudo systemctl restart nginx')" "ask"
+ok "git push --force → ask" "$(gate claude "" 'git push --force origin main')" "ask"
+ok "DROP TABLE → ask" "$(gate claude "" 'psql -c "DROP TABLE users"')" "ask"
+ok "Codex denies with confirmation guidance" "$(gate codex "" 'rm -rf build')" "deny"
+ok "Codex names the confirmation environment variable" "$(reason | grep -c 'DELEGATE_KIT_CONFIRMED=1')" "1"
+ok "Explicit confirmation allows the command" "$(gate codex "" 'DELEGATE_KIT_CONFIRMED=1 rm -rf build')" "allow"
+
+echo "-- Ordinary commands are allowed"
+ok "ls" "$(gate claude "" 'ls -la')" "allow"
+ok "Non-force git push" "$(gate claude "" 'git push origin feature')" "allow"
+ok "Remove one file" "$(gate claude "" 'rm build/out.txt')" "allow"
+ok "Non-shell input without command" "$(printf '{"tool_name":"Read","tool_input":{"file_path":"x"}}' | bash "$GATE" --harness claude; echo "exit $?")" "exit 0"
+
+echo "-- Delegation depth prevents nested workers"
+ok "Coordinator can launch a worker" "$(gate claude "" 'agent-run run --role reviewer --backend codex --brief b.md')" "allow"
+ok "Worker cannot launch another worker" "$(gate claude "dk-implementer" 'agent-run run --role reviewer --backend codex --brief b.md')" "deny"
+ok "Depth reason names the worker" "$(reason | grep -c 'depth is 1.*dk-implementer')" "1"
+ok "Worker cannot resume another worker" "$(gate claude "dk-planner" 'agent-run resume 2026-x --brief n.md')" "deny"
+ok "Worker cannot lock a worktree" "$(gate claude "dk-implementer" 'agent-wt lock slice-a')" "deny"
+# The command is input to the gate parser, so preserve its literal tilde.
+# shellcheck disable=SC2088
+ok "Tilde helper path is covered" "$(gate claude "dk-implementer" '~/.claude/skills/delegate-kit/scripts/agent-run run --role planner --brief b.md')" "deny"
+ok "Quoted executable is covered" "$(gate claude "dk-implementer" '"agent-run" run --role planner --brief b.md')" "deny"
+ok "Quoted helper path is covered" "$(gate claude "dk-implementer" "'/home/me/bin/agent-wt' lock task")" "deny"
+ok "env and node wrappers are covered" "$(gate claude "dk-implementer" 'env DELEGATE_KIT_PRESET=auto node /x/agent-run run --role planner --brief b.md')" "deny"
+ok "Command chains are covered" "$(gate claude "dk-implementer" 'cd /repo && agent-run run --role planner --brief b.md')" "deny"
+ok "Confirmation cannot bypass delegation depth" "$(gate claude "dk-implementer" 'DELEGATE_KIT_CONFIRMED=1 agent-run run --role planner --brief b.md')" "deny"
+ok "Codex worker is also denied" "$(gate codex "dk-reviewer" 'agent-run run --role verifier --brief b.md')" "deny"
+ok "Worker can inspect status and wait" "$(gate claude "dk-reviewer" 'agent-run status 2026-x; agent-run list; agent-wt status s')" "allow"
+ok "Worker can inspect worktree diff" "$(gate claude "dk-reviewer" 'agent-wt diff slice-a > review.diff')" "allow"
+ok "Worker can run ordinary commands" "$(gate claude "dk-implementer" 'npm test')" "allow"
+
+ok "Worker cannot prepare a v2 run" "$(gate claude "dk-researcher" 'node /installed/delegate-kit/scripts/dk.mjs prepare --session x --task t --agent a --brief b')" "deny"
+ok "Worker cannot resume a v2 run" "$(gate claude "dk-researcher" 'node /installed/delegate-kit/scripts/dk.mjs resume run --brief b')" "deny"
+ok "Worker can read a v2 result" "$(gate claude "dk-researcher" 'node /installed/delegate-kit/scripts/dk.mjs result run')" "allow"
+
+echo; echo "Passed: $PASS, failed: $FAIL"
+exit $((FAIL > 0))
diff --git a/tests/inspect.sh b/tests/inspect.sh
new file mode 100755
index 0000000..2681649
--- /dev/null
+++ b/tests/inspect.sh
@@ -0,0 +1,53 @@
+#!/bin/bash
+# Inspect partial work left by a stopped legacy writer.
+# The same inspection supplies context to legacy fallback attempts.
+# These checks do not call models.
+#
+# ./inspect.sh
+set -u
+SCRIPTS="$(cd "$(dirname "$0")/../skills/delegate-kit/scripts" && pwd)"
+AR="$SCRIPTS/agent-run"; WT="$SCRIPTS/agent-wt"
+BASE="${TMPDIR:-/tmp}/dk-inspect-test.$$"
+trap 'rm -rf "$BASE"' EXIT
+export DELEGATE_KIT_HOME="$BASE/state"
+PASS=0; FAIL=0
+ok(){ if [ "$2" = "$3" ]; then echo " ✔ $1"; PASS=$((PASS+1)); else echo " ✘ $1: expected [$3], got [$2]"; FAIL=$((FAIL+1)); fi; }
+G="git -c user.email=t@t -c user.name=t"
+
+mkdir -p "$BASE/repo"; cd "$BASE/repo" || exit 1
+git init -q -b main . && $G commit -q --allow-empty -m init
+"$WT" create slice >/dev/null 2>&1
+W="$BASE/repo.worktrees/slice"
+
+echo "-- Clean worktree"
+ok "git=true, partial=false" "$(node "$AR" inspect "$W" | jq -r '[.git,.partial,(.commits|length),(.dirty|length)]|join(" ")')" "true false 0 0"
+ok "Worktree creation records the base" "$(node "$AR" inspect "$W" | jq -r '.base == "'"$(git rev-parse HEAD)"'"')" "true"
+ok "Clean worktree has no note" "$(node "$AR" inspect "$W" | jq -r '.note')" "null"
+
+echo "-- Committed and uncommitted changes after base"
+echo a > "$W/a.txt"; $G -C "$W" add a.txt; $G -C "$W" commit -q -m "add a"
+echo b > "$W/b.txt"; echo a2 > "$W/a.txt"
+OUT=$(node "$AR" inspect "$W")
+ok "partial=true" "$(jq -r '.partial' <<<"$OUT")" "true"
+ok "One commit with its subject" "$(jq -r '.commits|length|tostring' <<<"$OUT") $(jq -r '.commits[0].subject' <<<"$OUT")" "1 add a"
+ok "Modified and untracked files are listed" "$(jq -r '[.dirty[]|.status+":"+.path]|sort|join(" ")' <<<"$OUT")" "??:b.txt M:a.txt"
+ok "Note includes the commit" "$(jq -r '.note' <<<"$OUT" | grep -c 'add a')" "1"
+ok "Note includes dirty files" "$(jq -r '.note' <<<"$OUT" | grep -c 'M a.txt, ?? b.txt')" "1"
+ok "Note instructs continuation" "$(jq -r '.note' <<<"$OUT" | grep -c 'continue from it')" "1"
+
+echo "-- Files inside new directories are listed individually"
+mkdir -p "$W/src/new"; echo c > "$W/src/new/c.txt"; echo d > "$W/src/new/d.txt"
+ok "Lists individual files rather than the directory" "$(node "$AR" inspect "$W" | jq -r '[.dirty[]|select(.path|startswith("src/new/"))|.path]|sort|join(" ")')" "src/new/c.txt src/new/d.txt"
+rm -rf "$W/src"
+
+echo "-- Missing base counts only dirty files"
+rm "$BASE/repo/.git/worktrees/slice/delegate-kit.base"
+ok "Missing base reports dirty files without counting commits" "$(node "$AR" inspect "$W" | jq -r '[(.base|tostring),(.commits|length),(.dirty|length),.partial]|join(" ")')" "null 0 2 true"
+
+echo "-- Non-Git directory"
+mkdir -p "$BASE/plain"
+ok "git=false, partial=false" "$(node "$AR" inspect "$BASE/plain" | jq -r '[.git,.partial]|join(" ")')" "false false"
+ok "Missing directory is rejected" "$(node "$AR" inspect "$BASE/nope" 2>&1 | grep -c 'no such directory')" "1"
+
+echo; echo "Passed: $PASS, failed: $FAIL"
+exit $((FAIL > 0))
diff --git a/skills/delegate-kit/tests/install.test.mjs b/tests/install.test.mjs
similarity index 77%
rename from skills/delegate-kit/tests/install.test.mjs
rename to tests/install.test.mjs
index b9e3f7e..6a1009a 100644
--- a/skills/delegate-kit/tests/install.test.mjs
+++ b/tests/install.test.mjs
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'node:url';
import os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
-const skill = fileURLToPath(new URL('..', import.meta.url));
+const skill = fileURLToPath(new URL('../skills/delegate-kit/', import.meta.url));
const installer = path.join(skill, 'hooks/native-agents.mjs');
const invoke = (action, home, dry = '0') => spawnSync(process.execPath, [installer, action, skill, home, dry], { encoding: 'utf8' });
test('native install removes legacy pins, preserves user config, is idempotent and reversible', () => {
@@ -38,3 +38,15 @@ test('unmanaged native role is never overwritten', () => {
assert.equal(fs.readFileSync(file, 'utf8'), 'user-owned'); assert.equal(fs.readdirSync(path.dirname(file)).length, 1);
} finally { fs.rmSync(home, { recursive: true }); }
});
+test('Claude installation refuses unmanaged roles instead of replacing them', () => {
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'dk-claude-install-'));
+ try {
+ const dir = path.join(home, 'agents'); fs.mkdirSync(dir);
+ const file = path.join(dir, 'dk-planner.md'); fs.writeFileSync(file, 'my custom planner');
+ const r = spawnSync('bash', [path.join(skill, 'hooks/install.sh'), '--claude', '--agents-only'], {
+ encoding: 'utf8', env: { ...process.env, CLAUDE_CONFIG_DIR: home },
+ });
+ assert.notEqual(r.status, 0); assert.match(r.stderr, /unmanaged/);
+ assert.equal(fs.readFileSync(file, 'utf8'), 'my custom planner');
+ } finally { fs.rmSync(home, { recursive: true, force: true }); }
+});
diff --git a/skills/delegate-kit/tests/limits.test.mjs b/tests/limits.test.mjs
similarity index 94%
rename from skills/delegate-kit/tests/limits.test.mjs
rename to tests/limits.test.mjs
index f2ff17e..be7488b 100644
--- a/skills/delegate-kit/tests/limits.test.mjs
+++ b/tests/limits.test.mjs
@@ -1,6 +1,6 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
-import { resolveLimits, validateLimits } from '../scripts/limits.mjs';
+import { resolveLimits, validateLimits } from '../skills/delegate-kit/scripts/limits.mjs';
test('no configured limits means unbounded without a derived worker cap', () => {
assert.deepEqual(resolveLimits({}, {}, {}), { writers: null, workers: null, runs: null, retries: null });
diff --git a/tests/live-codex.mjs b/tests/live-codex.mjs
new file mode 100644
index 0000000..2a3c6bc
--- /dev/null
+++ b/tests/live-codex.mjs
@@ -0,0 +1,40 @@
+import { randomUUID } from 'node:crypto';
+import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { spawnSync } from 'node:child_process'; import assert from 'node:assert/strict';
+import {savePreset,setDefault,context} from '../skills/delegate-kit/scripts/presets.mjs';
+import {prepare,launch,wait,resume,cancel,getRun,accept} from '../skills/delegate-kit/scripts/runtime.mjs';
+// Explicit opt-in: this script calls the user's configured Codex provider.
+const options = process.argv.slice(2);
+if (!options.includes('--execute')) {
+ console.log('Live Codex smoke (up to four dispatches): --execute --writer-model MODEL --reviewer-model MODEL. Uses low reasoning and a temporary repository; preserves artifacts. Not part of CI.');
+ process.exit(0);
+}
+function option(name) { const i = options.indexOf(name); assert.ok(i >= 0 && options[i + 1] && !options[i + 1].startsWith('--'), `Missing ${name}`); return options[i + 1]; }
+const writerModel = option('--writer-model'), reviewerModel = option('--reviewer-model');
+const marker = `DK-MEMORY-${randomUUID()}`;
+const root=fs.mkdtempSync(path.join(os.tmpdir(),'dk-live-sol-luna-')); process.env.DELEGATE_KIT_HOME=path.join(root,'state');
+const repo=path.join(root,'repo'),wt=path.join(root,'worker'); fs.mkdirSync(repo);
+const git=(args)=>{const r=spawnSync('git',args,{cwd:repo,encoding:'utf8'});assert.equal(r.status,0,r.stderr);return r.stdout};
+git(['init','-q']); fs.writeFileSync(path.join(repo,'take.mjs'),'export function takeFirst(items, count) { return items.slice(0, Math.max(0, count) + 1); }\n');
+fs.writeFileSync(path.join(repo,'take.test.mjs'),"import {test} from 'node:test'; import assert from 'node:assert/strict'; import {takeFirst} from './take.mjs'; test('two',()=>assert.deepEqual(takeFirst([1,2,3],2),[1,2])); test('zero',()=>assert.deepEqual(takeFirst([1,2,3],0),[]));\n");
+git(['add','.']);git(['-c','user.name=DK Test','-c','user.email=test@example.invalid','commit','-qm','Initial test fixture']);git(['worktree','add','-qb','codex/live-worker',wt]);
+const models={sol:writerModel,luna:reviewerModel};
+savePreset({schema_version:2,id:'LIVE',limits:{max_runs:4,max_retries:1},agents:{sol:{role:'implementer',when:'Fix the small fixture',executor:{harness:'codex',model:models.sol,reasoning:'low',transport:'cli'}},luna:{role:'reviewer',when:'Independent review',executor:{harness:'codex',model:models.luna,reasoning:'low',transport:'cli'}}}});setDefault('LIVE');context({session:'codex:live-smoke'});
+const started=[];
+const reports=[]; const file=(name,body)=>{const f=path.join(root,name);fs.writeFileSync(f,body);return f};
+const brief=file('fix.md',`This is an isolated smoke test. Fix the off-by-one in take.mjs: takeFirst(items,count) must return the first max(0,count) items. Read only these two small files, edit only take.mjs, run node --test take.test.mjs, no commits, network or delegation. Keep response short. Remember the marker ${marker} for a later continuation.`);
+console.log(JSON.stringify({root,models}));
+async function collect(id){for(let n=0;n<8;n++){const r=await wait(id,30000);if(['finished','failed','cancelled','timeout','blocked'].includes(r.status))return r;if(r.health?.attention_required)throw new Error(JSON.stringify(r.health));}throw new Error('Smoke observation deadline');}
+try {
+ const first=prepare({session:'codex:live-smoke',task:'smoke',agent:'sol',brief,cwd:wt,timeoutMs:240000}).runs[0];started.push(first.id);launch(first.id);const short=await wait(first.id,10);assert.equal(short.wait_timed_out,true);
+ const done=await collect(first.id);reports.push({case:'sol-fix',...done});console.log(JSON.stringify({case:'sol-fix',status:done.status,error:done.error,id:done.id,usage:done.usage}));assert.equal(done.status,'finished',done.error);
+ const checked=spawnSync(process.execPath,['--test','take.test.mjs'],{cwd:wt,encoding:'utf8'});assert.equal(checked.status,0,checked.stdout+checked.stderr);assert.equal(accept(first.id).accepted,true);
+ const n=resume(first.id,file('resume.md','Do not use tools or edit files. In summary briefly state what you changed previously and echo the exact remembered marker from the original request. Return the required result object.'));started.push(n.id);launch(n.id);const followed=await collect(n.id);reports.push({case:'sol-resume',...followed});console.log(JSON.stringify({case:'sol-resume',status:followed.status,id:followed.id,usage:followed.usage}));assert.equal(followed.status,'finished',followed.error);assert.equal(followed.transport_session_id,done.transport_session_id);assert.ok(followed.result.summary.includes(marker), 'Continuation lost the original marker');
+ const beforeReview = fs.readFileSync(path.join(wt, 'take.mjs'), 'utf8');
+ const review=prepare({session:'codex:live-smoke',task:'smoke',agent:'luna',brief:file('review.md','Independent read-only review of take.mjs and take.test.mjs in this isolated fixture. Contract: takeFirst(items,count) returns the first max(0,count) items for integer count. Read the files and verify tests with node --test take.test.mjs. Report only concrete defects; if none, say so. Do not edit files, use network, delegate or inspect unrelated files. Keep response short.'),cwd:wt,timeoutMs:240000}).runs[0];started.push(review.id);launch(review.id);const reviewed=await collect(review.id);reports.push({case:'luna-review',...reviewed});console.log(JSON.stringify({case:'luna-review',status:reviewed.status,id:reviewed.id,usage:reviewed.usage}));assert.equal(reviewed.status,'finished',reviewed.error);assert.notEqual(reviewed.transport_session_id, followed.transport_session_id);assert.equal(reviewed.result.findings.length,0);assert.equal(fs.readFileSync(path.join(wt,'take.mjs'),'utf8'),beforeReview);
+ const abort=prepare({session:'codex:live-smoke',task:'smoke',agent:'luna',brief:file('cancel.md','Cancellation smoke test. Run sleep 30, then return a short done result. No edits, network, delegation or other work.'),cwd:wt,timeoutMs:60000}).runs[0];started.push(abort.id);launch(abort.id);
+ for(let i=0;i<100 && !getRun(abort.id).child_pid;i++)await new Promise(r=>setTimeout(r,50));
+ assert.ok(getRun(abort.id).child_pid);const stopped=await cancel(abort.id);reports.push({case:'luna-cancel',...stopped});assert.equal(stopped.status,'cancelled');console.log(JSON.stringify({case:'luna-cancel',status:stopped.status,id:stopped.id}));
+ fs.writeFileSync(path.join(root,'report.json'),JSON.stringify({root,success:true,reports},null,2));
+} catch(error){
+ for (const id of started) { try { await cancel(id); } catch {} }
+fs.writeFileSync(path.join(root,'report.json'),JSON.stringify({root,success:false,error:error.message,reports},null,2)); console.error(error);process.exitCode=1;}
diff --git a/skills/delegate-kit/tests/profiles.test.mjs b/tests/profiles.test.mjs
similarity index 93%
rename from skills/delegate-kit/tests/profiles.test.mjs
rename to tests/profiles.test.mjs
index ccb64de..168ea5e 100644
--- a/skills/delegate-kit/tests/profiles.test.mjs
+++ b/tests/profiles.test.mjs
@@ -5,14 +5,15 @@ import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
-import { resolve } from '../scripts/routing.mjs';
+import { resolve } from '../skills/delegate-kit/scripts/routing.mjs';
+import { validatePreset } from '../skills/delegate-kit/scripts/presets.mjs';
-const skill = fileURLToPath(new URL('..', import.meta.url));
+const skill = fileURLToPath(new URL('../skills/delegate-kit/', import.meta.url));
const repo = path.resolve(skill, '../..');
-const example = JSON.parse(fs.readFileSync(path.join(skill, 'examples/config.json'), 'utf8'));
+const example = JSON.parse(fs.readFileSync(path.join(skill, 'examples/legacy-config.json'), 'utf8'));
const choose = (parent, role, level = 1) => resolve({ parent, role, level, 'author-backend': 'self' }, example, {}, () => true);
-test('editable example switches the team with its coordinator and strengthens only the chosen role', () => {
+test('legacy example remains readable with its original family and ladder semantics', () => {
const gpt = choose('codex', 'implementer');
assert.equal(gpt.profile, 'gpt'); assert.equal(gpt.model, 'gpt-6-astra');
assert.equal(gpt.dispatch, 'native'); assert.equal(gpt.effort, 'low');
@@ -34,7 +35,7 @@ test('README JSON examples are valid configuration fragments and local assets/li
const readme = fs.readFileSync(path.join(repo, 'README.md'), 'utf8');
for (const [, json] of readme.matchAll(/```json\n([\s\S]*?)\n```/g)) {
const cfg = JSON.parse(json);
- resolve({ parent: 'codex', role: 'implementer' }, cfg, {}, () => true);
+ validatePreset(cfg);
}
const links = [...readme.matchAll(/\]\(([^)]+)\)/g)].map(m => m[1]);
const images = [...readme.matchAll(/src="([^"]+)"/g)].map(m => m[1]);
diff --git a/skills/delegate-kit/tests/route.sh b/tests/route.sh
similarity index 100%
rename from skills/delegate-kit/tests/route.sh
rename to tests/route.sh
diff --git a/skills/delegate-kit/tests/routing.test.mjs b/tests/routing.test.mjs
similarity index 97%
rename from skills/delegate-kit/tests/routing.test.mjs
rename to tests/routing.test.mjs
index 527dff3..0fe2ca5 100644
--- a/skills/delegate-kit/tests/routing.test.mjs
+++ b/tests/routing.test.mjs
@@ -1,6 +1,6 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
-import { resolve, roles, readConfig } from '../scripts/routing.mjs';
+import { resolve, roles, readConfig } from '../skills/delegate-kit/scripts/routing.mjs';
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
@@ -68,7 +68,7 @@ test('panel modes, hard assignments and user approval survive actual route comma
fs.writeFileSync(path.join(dir, 'config.json'), JSON.stringify(cfg));
const diff = path.join(dir, 'review.diff');
fs.writeFileSync(diff, 'diff --git a/src/a.js b/src/a.js\n--- a/src/a.js\n+++ b/src/a.js\n@@ -1 +1 @@\n' + '+x\n'.repeat(400));
- const result = spawnSync(process.execPath, [fileURLToPath(new URL('../scripts/agent-run', import.meta.url)), 'route', '--role', 'reviewer', '--parent', 'codex', '--author-backend', 'self', '--diff', diff], { env: { ...process.env, DELEGATE_KIT_HOME: dir, DELEGATE_KIT_PRESET: 'auto' }, encoding: 'utf8' });
+ const result = spawnSync(process.execPath, [fileURLToPath(new URL('../skills/delegate-kit/scripts/agent-run', import.meta.url)), 'route', '--role', 'reviewer', '--parent', 'codex', '--author-backend', 'self', '--diff', diff], { env: { ...process.env, DELEGATE_KIT_HOME: dir, DELEGATE_KIT_PRESET: 'auto' }, encoding: 'utf8' });
assert.equal(result.status, 0, result.stderr); const r = JSON.parse(result.stdout);
assert.equal(r.depth, 'panel'); assert.equal(r.ask_user, undefined); assert.equal(r.reviewers.length, 2);
for (const reviewer of r.reviewers) { assert.equal(reviewer.backend, 'codex'); assert.equal(reviewer.cross_family, false); }
diff --git a/skills/delegate-kit/tests/run-budget.test.mjs b/tests/run-budget.test.mjs
similarity index 98%
rename from skills/delegate-kit/tests/run-budget.test.mjs
rename to tests/run-budget.test.mjs
index af88035..2813ac5 100644
--- a/skills/delegate-kit/tests/run-budget.test.mjs
+++ b/tests/run-budget.test.mjs
@@ -5,7 +5,7 @@ import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
-const runner = fileURLToPath(new URL('../scripts/agent-run', import.meta.url));
+const runner = fileURLToPath(new URL('../skills/delegate-kit/scripts/agent-run', import.meta.url));
const done = { status: 'done', summary: 'Fixture completed', changes: [], checks_run: [], not_verified: [], plan: [], findings: [], questions: [], sources: [], next_steps: [] };
function fixture(fn) {
diff --git a/tests/v2.test.mjs b/tests/v2.test.mjs
new file mode 100644
index 0000000..510c0ae
--- /dev/null
+++ b/tests/v2.test.mjs
@@ -0,0 +1,486 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { spawn, spawnSync } from 'node:child_process';
+import { fileURLToPath } from 'node:url';
+import { savePreset, loadPreset, copyPreset, context, setDefault, validatePreset, hash, readJSON } from '../skills/delegate-kit/scripts/presets.mjs';
+import { resolveExecutor, bridgeInvocation } from '../skills/delegate-kit/scripts/executors.mjs';
+import { FrameDecoder, sumUsage } from '../skills/delegate-kit/scripts/rpc.mjs';
+import { migrate } from '../skills/delegate-kit/scripts/migrate.mjs';
+import { prepare, launch, wait, resume, attach, ingest as ingestRaw, dispatchFailed, accept, cancel, recover, status, getRun } from '../skills/delegate-kit/scripts/runtime.mjs';
+
+// Host fixtures echo the per-attempt token supplied in the dispatched prompt.
+function ingest(id, event) {
+ const token = getRun(id).claim;
+ return ingestRaw(id, { ...event, dispatchToken: token,
+ ...(event.event === 'complete' ? { result: { dispatch_token: token, result: event.result } } : {}) });
+}
+const dk = fileURLToPath(new URL('../skills/delegate-kit/scripts/dk.mjs', import.meta.url));
+const done = { status: 'done', summary: 'verified result', changes: [], checks_run: ['fixture'], not_verified: [], plan: [], findings: [], questions: [], sources: [], next_steps: [] };
+const agent = (harness = 'codex', extra = {}) => ({ role: 'researcher', when: 'Bounded investigation', executor: { harness, model: 'test-model', ...(harness === 'pi' || harness === 'omp' ? { provider: 'test-provider' } : {}), ...extra } });
+const preset = (id = 'X1', a = agent()) => ({ schema_version: 2, id, defaults: { [a.role]: 'general' }, agents: { general: a } });
+const cap = (transport = 'native', harness = 'codex') => ({ verified: true, host: transport === 'native' ? 'codex' : 'paseo', version: 'fixture-v1', harness, transport,
+ resume: true, result: true, cancel: true, access: ['read-only', 'workspace-write'], models: [{ id: 'test-model', reasoning: ['high'] }],
+ ...(transport === 'paseo' ? { daemon: 'fixture-daemon', mode_ids: { 'read-only': 'read-only', 'workspace-write': 'workspace-write' } } : {}) });
+
+async function sandbox(fn) {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'dk-v2-'));
+ const old = process.env.DELEGATE_KIT_HOME, oldPath = process.env.PATH;
+ process.env.DELEGATE_KIT_HOME = path.join(root, 'state');
+ const bin = path.join(root, 'bin'); fs.mkdirSync(bin);
+ const fake = `#!${process.execPath}
+const fs=require('node:fs'),path=require('node:path');
+const args=process.argv.slice(2),kind=path.basename(process.argv[1])==='pi-worker.mjs'?'pi':path.basename(process.argv[1]);
+const value=f=>args[args.indexOf(f)+1];
+const result=${JSON.stringify(done)};
+if(args.includes('--version')){console.log('fixture 1.0.0');process.exit(0)}
+fs.appendFileSync(${JSON.stringify(path.join(root, 'calls.jsonl'))},JSON.stringify({kind,args})+'\\n');
+if(kind==='pi'||kind==='omp') {
+ const sessionDir=value('--session-dir');
+ const sessionFile=args.includes('--resume')?value('--resume'):args.includes('--session')?value('--session'):path.join(sessionDir,'session.jsonl');
+ const sessionId=fs.existsSync(sessionFile)?fs.readFileSync(sessionFile,'utf8'):'session-'+require('node:crypto').randomUUID();
+ fs.writeFileSync(sessionFile,sessionId);
+ let model={id:value('--model'),provider:value('--provider')},thinkingLevel=value('--thinking'),buffer='';
+ const send=o=>process.stdout.write(JSON.stringify(o)+'\\n');
+ if(kind==='omp')send({type:'ready',protocolVersion:1,supportedProtocolVersions:process.env.DK_FAKE_RPC_CASE==='protocol'?[1]:[1,2],maxFrameBytes:1048576,maxReassembledFrameBytes:67108864});
+ send({type:'extension_ui_request',method:'setStatus',id:'display'});
+ process.stdin.on('data',bytes=>{buffer+=bytes;let end;while((end=buffer.indexOf('\\n'))!==-1){const q=JSON.parse(buffer.slice(0,end));buffer=buffer.slice(end+1);let data;
+ if(q.type==='set_model'){model={id:q.modelId,provider:q.provider};data=model}
+ if(q.type==='set_thinking_level')thinkingLevel=q.level;
+ if(q.type==='get_state')data={sessionId,sessionFile,model,thinkingLevel:process.env.DK_FAKE_RPC_CASE==='clamp'?'low':thinkingLevel};
+ send({id:q.id,type:'response',command:q.type,success:true,...(data?{data}:{})});
+ if(q.type==='prompt') {
+ if(process.env.DK_FAKE_RPC_CASE==='late-error'){setTimeout(()=>send({id:q.id,type:'response',command:'prompt',success:false,error:'late scheduling failure'}),20);continue;}
+ if(process.env.DK_FAKE_RPC_CASE==='nonterminal')send({type:'agent_end',isTerminal:false,messages:[]});
+ setTimeout(()=>{send({type:'agent_start'});const last={role:'assistant',model:model.id,provider:model.provider,stopReason:'stop',content:[{type:'text',text:JSON.stringify(result)}]};const messages=[last];
+ if(process.env.DK_FAKE_RPC_CASE==='multi-usage'){last.usage={input:200,output:20,totalTokens:220,cost:{total:2}};messages.unshift({role:'assistant',model:model.id,provider:model.provider,stopReason:'toolUse',content:[],usage:{input:100,output:10,totalTokens:110,cost:{total:1}}});for(const message of messages)send({type:'message_end',message});}
+ send({type:'agent_end',messages})},Number(process.env.DK_FAKE_DELAY||200));}
+ }});process.stdin.on('end',()=>{if(process.env.DK_FAKE_RPC_CASE==='eof')process.stdout.write('{');process.exit(0)});
+} else {
+ const id=args.includes('resume')?args[args.length-2]:'cli-session-'+require('node:crypto').randomUUID();
+ setTimeout(()=>{
+ if(process.env.DK_FAKE_BAD) { console.log('{}'); process.exit(0); }
+ if(kind==='codex'){fs.writeFileSync(value('-o'),JSON.stringify(result));console.log(JSON.stringify({type:'thread.started',thread_id:id}));console.log(JSON.stringify({type:'turn.completed',usage:{input_tokens:3}}));}
+ if(kind==='claude')console.log(JSON.stringify({type:'result',session_id:args.includes('--resume')?value('--resume'):id,structured_output:result}));
+ if(kind==='gemini'){console.log(JSON.stringify({type:'init',session_id:args.includes('--resume')?value('--resume'):id}));console.log(JSON.stringify({type:'message',role:'assistant',content:JSON.stringify(result)}));console.log(JSON.stringify({type:'result',status:'success'}));}
+ },Number(process.env.DK_FAKE_DELAY||10));
+}
+`;
+ for (const name of ['codex', 'claude', 'gemini', 'pi', 'omp']) fs.writeFileSync(path.join(bin, name), fake, { mode: 0o755 });
+ // A fake installed Pi package exercises the SDK bootstrap as well as RPC.
+ const piPackage = path.join(root, 'pi-package'), piUser = path.join(root, 'pi-user');
+ fs.mkdirSync(piPackage); fs.mkdirSync(piUser);
+ fs.writeFileSync(path.join(piUser, 'settings.json'), JSON.stringify({ retry: { enabled: true }, compaction: { enabled: true }, transport: 'sse' }));
+ fs.writeFileSync(path.join(piPackage, 'package.json'), JSON.stringify({ name: '@earendil-works/pi-coding-agent', type: 'module', main: './index.mjs' }));
+ fs.writeFileSync(path.join(piPackage, 'cli.cjs'), fake, { mode: 0o755 });
+ fs.rmSync(path.join(bin, 'pi')); fs.symlinkSync(path.join(piPackage, 'cli.cjs'), path.join(bin, 'pi'));
+ fs.writeFileSync(path.join(piPackage, 'index.mjs'), `
+ import assert from 'node:assert/strict';
+ const args=process.argv.slice(2),value=k=>args[args.indexOf(k)+1];
+ export const SettingsManager={inMemory(settings){assert.equal(settings.retry.enabled,false);assert.equal(settings.compaction.enabled,false);assert.equal(settings.transport,'sse');return settings;}};
+ export const SessionManager={create(cwd,dir){return {cwd,dir}},open(file){return {file}}};
+ export const getAgentDir=()=>${JSON.stringify(piUser)};
+ export async function createAgentSessionServices(o){assert.equal(o.resourceLoaderOptions.noExtensions,true);assert.equal(o.resourceLoaderOptions.noSkills,true);return {modelRuntime:{getAvailableSnapshot:()=>[{id:value('--model'),provider:value('--provider')}]},diagnostics:[]};}
+ export async function createAgentSessionFromServices(o){assert.ok(!o.tools.includes('bash'));assert.ok(o.tools.includes('read'));return {session:{}};}
+ export async function createAgentSessionRuntime(factory,o){return factory(o);}
+ export async function runRpcMode(){await import('./cli.cjs');}
+ `);
+ process.env.PATH = `${bin}${path.delimiter}${oldPath}`;
+ const brief = path.join(root, 'brief.md'); fs.writeFileSync(brief, 'Return evidence for a bounded test task.');
+ try { await fn({ root, brief, state: process.env.DELEGATE_KIT_HOME }); }
+ finally {
+ delete process.env.DK_FAKE_DELAY; delete process.env.DK_FAKE_BAD; delete process.env.DK_FAKE_RPC_CASE;
+ process.env.PATH = oldPath;
+ if (old === undefined) delete process.env.DELEGATE_KIT_HOME; else process.env.DELEGATE_KIT_HOME = old;
+ fs.rmSync(root, { recursive: true, force: true });
+ }
+}
+const invoke = args => spawnSync(process.execPath, [dk, ...args], { encoding: 'utf8', env: process.env });
+function parallel(args) {
+ return new Promise(resolve => { const c = spawn(process.execPath, [dk, ...args], { env: process.env }); let out = '', err = ''; c.stdout.on('data', b => out += b); c.stderr.on('data', b => err += b); c.on('close', code => resolve({ code, out, err })); });
+}
+function setup() { savePreset(preset()); setDefault('X1'); return context({ session: 'test:chat' }); }
+function prep(brief, extra = {}) { return prepare({ session: 'test:chat', task: 'task', agent: 'general', brief, ...extra }).runs[0]; }
+
+test('P01–P08: session selection, task overrides, copy and broken inactive preset isolation', () => sandbox(async ({ state }) => {
+ savePreset(preset()); copyPreset('X1', 'Y2'); setDefault('X1');
+ assert.equal(context({ session: 'codex:A' }).preset.id, 'X1');
+ assert.equal(context({ session: 'codex:B', preset: 'Y2' }).preset.id, 'Y2');
+ setDefault('Y2'); assert.equal(context({ session: 'codex:A' }).preset.id, 'X1');
+ assert.equal(context({ session: 'codex:A', preset: 'Y2', taskOnly: true }).preset.id, 'Y2');
+ assert.equal(context({ session: 'codex:A' }).preset.id, 'X1');
+ assert.throws(() => context({ session: 'codex:A', preset: 'Missing' }), /Unknown preset/);
+ fs.writeFileSync(path.join(state, 'presets/broken.json'), '{');
+ assert.equal(context({ session: 'codex:A' }).preset.id, 'X1');
+ const audit = JSON.parse(invoke(['presets', 'audit']).stdout); assert.equal(audit.find(p => p.file === 'broken.json').valid, false);
+ const y = loadPreset('Y2'); y.preset.agents.general.executor.model = 'different'; savePreset(y.preset, y.revision);
+ assert.equal(loadPreset('X1').preset.agents.general.executor.model, 'test-model');
+ assert.throws(() => copyPreset('X1', 'Y2'), /already exists/);
+}));
+test('P09: competing revision edits and parallel chat bindings cannot lose updates', () => sandbox(async ({ root }) => {
+ setup(); copyPreset('X1', 'Y2'); const p = loadPreset('X1');
+ const files = ['A', 'B'].map(name => { const file = path.join(root, name + '.json'); fs.writeFileSync(file, JSON.stringify({ ...p.preset, name })); return file; });
+ const edits = await Promise.all(files.map(file => parallel(['presets', 'save', '--file', file, '--revision', p.revision])));
+ assert.equal(edits.filter(e => e.code === 0).length, 1); assert.equal(edits.filter(e => e.code !== 0).length, 1);
+ const bindings = await Promise.all(['X1', 'Y2'].map(id => parallel(['context', 'open', '--session', `host:${id}`, '--preset', id])));
+ assert.deepEqual(bindings.map(b => JSON.parse(b.out).preset), ['X1', 'Y2']);
+}));
+test('P10/R02/R05/R06: strict schema, case collisions, arbitrary specialists and review references', () => sandbox(async () => {
+ setup(); assert.throws(() => savePreset(preset('x1')), /already exists/);
+ assert.throws(() => loadPreset('../X1'), /letters/);
+ for (const change of [p => p.schema_version = 99, p => p.extra = 1, p => delete p.agents.general.executor.model,
+ p => p.agents.general.executor.model = 'REPLACE_WITH_MODEL', p => p.defaults.reviewer = 'absent']) {
+ const p = preset(); change(p); assert.throws(() => validatePreset(p));
+ }
+ const p = preset(); p.agents.docs = { ...agent(), role: 'docs-specialist' }; validatePreset(p);
+ assert.equal(resolveExecutor(p.agents.docs).access, 'read-only');
+ p.agents.general.review = { also_run: ['general'] }; assert.throws(() => validatePreset(p), /reference/);
+ p.agents.second = { ...agent(), role: 'reviewer' }; p.agents.general.review.also_run = ['second', 'second']; assert.throws(() => validatePreset(p), /duplicate/);
+ p.agents.general.role = 'reviewer'; p.defaults = {}; p.agents.general.review.also_run = ['second']; p.agents.second.review = { also_run: ['general'] }; assert.throws(() => validatePreset(p), /cycle/);
+}));
+test('R03/R04/S02: incompatible provider, reasoning, harness and native access fail explicitly', () => {
+ assert.throws(() => resolveExecutor(agent('gemini', { reasoning: 'high' })), /reasoning/);
+ assert.throws(() => resolveExecutor(agent('claude', { provider: 'another' })), /provider/);
+ assert.throws(() => resolveExecutor(agent('codex', { reasoning: 'fantasy' })), /reasoning/);
+ assert.throws(() => resolveExecutor(agent('codex', { transport: 'native' }), [cap('native', 'omp')]), /cannot preserve/);
+ assert.throws(() => resolveExecutor(agent('codex', { transport: 'native' }), [{ ...cap(), access: [] }]), /cannot preserve/);
+ const a = agent('codex', { transport: 'native', inherit_model: true }); delete a.executor.model;
+ assert.throws(() => resolveExecutor(a, [cap()]), /cannot preserve/);
+ assert.equal(resolveExecutor(a, [{ ...cap(), current_model: 'test-model' }]).model, 'test-model');
+});
+test('R01/L02/L03/S01: actual launch argv and resume preserve immutable preset settings', () => sandbox(async ({ brief, state, root }) => {
+ setup(); const p = loadPreset('X1'); p.preset.agents.general.executor.model = 'test-$(touch SHOULD_NOT_EXIST);`echo bad`'; savePreset(p.preset, p.revision);
+ const r = prep(brief, { cwd: root }); launch(r.id); const first = await wait(r.id, 5000); assert.equal(first.status, 'finished', first.error);
+ const edited = loadPreset('X1'); edited.preset.agents.general.executor.model = 'new-model'; savePreset(edited.preset, edited.revision);
+ const continuation = resume(r.id, brief); launch(continuation.id); const second = await wait(continuation.id, 5000);
+ assert.equal(second.status, 'finished', second.error); assert.equal(second.transport_session_id, first.transport_session_id);
+ assert.equal(second.executor.model, first.executor.model); assert.equal(second.attempt_kind, 'continuation');
+ const calls = fs.readFileSync(path.join(root, 'calls.jsonl'), 'utf8').trim().split('\n').map(JSON.parse);
+ assert.ok(calls.every(c => c.args.includes(first.executor.model))); assert.equal(calls.length, 2);
+ assert.equal(fs.existsSync(path.join(root, 'SHOULD_NOT_EXIST')), false);
+ assert.equal(readJSON(path.join(state, 'runs', second.id, 'preset.snapshot.json')).agents.general.executor.model, first.executor.model);
+ assert.equal(accept(second.id).accepted, true);
+}));
+test('L01/L04/L07: waiting and repeated dispatch never restart; exit zero cannot validate bad output', () => sandbox(async ({ brief, root }) => {
+ setup(); process.env.DK_FAKE_DELAY = '500'; const r = prep(brief); launch(r.id);
+ assert.throws(() => launch(r.id), /do not dispatch twice/);
+ const pending = await wait(r.id, 40); assert.equal(pending.wait_timed_out, true); assert.ok(['starting', 'running'].includes(pending.status));
+ assert.equal((await wait(r.id, 5000)).status, 'finished');
+ process.env.DK_FAKE_BAD = '1'; const bad = prep(brief); launch(bad.id);
+ const failed = await wait(bad.id, 5000); assert.equal(failed.status, 'failed'); assert.equal(failed.result_validated, false);
+ assert.equal(fs.readFileSync(path.join(root, 'calls.jsonl'), 'utf8').trim().split('\n').length, 2);
+}));
+test('Pi and OMP: acknowledged prompt stays running; exact RPC session is continued', () => sandbox(async ({ brief, root }) => {
+ for (const harness of ['pi', 'omp']) {
+ savePreset(preset(harness, agent(harness, { reasoning: 'high' }))); context({ session: 'test:chat', preset: harness });
+ process.env.DK_FAKE_DELAY = '350'; const r = prep(brief, { task: harness }); launch(r.id);
+ assert.equal((await wait(r.id, 100)).wait_timed_out, true);
+ const first = await wait(r.id, 5000); assert.equal(first.status, 'finished', first.error); assert.equal(first.actual_model, 'test-model');
+ const next = resume(r.id, brief); launch(next.id); const second = await wait(next.id, 5000);
+ assert.equal(second.status, 'finished', second.error); assert.equal(second.transport_session_id, first.transport_session_id);
+ assert.equal(getRun(next.id).transport_session_file, getRun(r.id).transport_session_file);
+ assert.deepEqual(readJSON(path.join(root,'pi-user/settings.json')), {retry:{enabled:true},compaction:{enabled:true},transport:'sse'});
+ }
+}));
+test('L09: LF, UTF8 partial data and strict OMP v2 reassembly', () => {
+ const events = [], text = JSON.stringify({ type: 'event', text: '\u0441\u0442\u0440\u043e\u043a\u0430\u2028not a new frame\u2029' });
+ const d = new FrameDecoder('pi', e => events.push(e)); const bytes = Buffer.from(text + '\n');
+ for (const b of bytes) d.push(Buffer.from([b])); d.end(); assert.equal(events.length, 1);
+ const decoded = [], omp = new FrameDecoder('omp', e => decoded.push(e)); omp.v2 = true;
+ const buffer = Buffer.from(text), chunks = [buffer.subarray(0, 11), buffer.subarray(11)];
+ chunks.forEach((part, index) => omp.push(Buffer.from(JSON.stringify({ type: 'rpc_chunk', chunkId: 'x', index, count: 2, byteLength: buffer.length, data: part.toString('base64') }) + '\n')));
+ assert.equal(decoded[0].text, events[0].text); omp.end();
+ const invalid = new FrameDecoder('omp', () => {}); invalid.v2 = true;
+ const frame = { type: 'rpc_chunk', chunkId: 'x', index: 1, count: 2, byteLength: 6, data: 'e30=' };
+ assert.throws(() => invalid.frame(frame), /out of order/);
+ const partial = new FrameDecoder('pi', () => {}); partial.push(Buffer.from('{')); assert.throws(() => partial.end(), /Incomplete/);
+ assert.throws(() => new FrameDecoder('pi', () => {}).frame({ ...frame, index: 0 }), /require negotiated/);
+});
+test('L10/R06: required review set reserves all slots; parallel admission obeys caps', () => sandbox(async ({ brief }) => {
+ const p = preset(); p.agents.general.role = 'reviewer'; p.defaults = {}; p.agents.general.review = { also_run: ['second'] };
+ p.agents.second = { ...agent(), role: 'reviewer' }; p.limits = { max_workers: 1 }; savePreset(p); setDefault('X1'); context({ session: 'test:chat' });
+ assert.throws(() => prep(brief), /not partially admitted/);
+ const current = loadPreset('X1'); delete current.preset.agents.general.review; savePreset(current.preset, current.revision);
+ const calls = await Promise.all(Array.from({ length: 5 }, () => parallel(['prepare', '--session', 'test:chat', '--task', 'race', '--agent', 'general', '--brief', brief])));
+ assert.equal(calls.filter(c => c.code === 0).length, 1); assert.equal(calls.filter(c => c.code !== 0).length, 4);
+}));
+test('native bridge: preparation, unique sessions, attach idempotence, correlated completion and same-agent follow-up', () => sandbox(async ({ brief }) => {
+ const p = preset('X1', agent('codex', { transport: 'native' })); savePreset(p); setDefault('X1'); context({ session: 'test:chat' });
+ const r = prep(brief, { capabilities: [cap()] }); assert.equal(r.status, 'prepared');
+ const call = launch(r.id); assert.equal(call.status, 'starting'); assert.equal(call.invoke.arguments.model, 'test-model'); assert.equal(call.invoke.arguments.fork_turns, 'none');
+ attach(r.id, 'host-agent'); attach(r.id, 'host-agent'); assert.throws(() => attach(r.id, 'another-agent'), /Already attached/);
+ assert.throws(() => ingest(r.id, { hostAgent: 'wrong', event: 'complete', result: done, stopped: true }), /correlated/);
+ ingest(r.id, { hostAgent: 'host-agent', event: 'complete', result: done, stopped: true });
+ const next = resume(r.id, brief); assert.equal(launch(next.id).invoke.arguments.target, 'host-agent');
+ attach(next.id, 'host-agent'); ingest(next.id, { hostAgent: 'host-agent', event: 'complete', result: done, stopped: true });
+ assert.equal(accept(next.id).accepted, true);
+}));
+test('PA01–PA04: Paseo materializes own settings, preserves daemon/workspace and rejects unsupported harness', () => sandbox(async ({ brief }) => {
+ savePreset(preset('X1', agent('codex', { transport: 'paseo', reasoning: 'high' }))); setDefault('X1'); context({ session: 'test:chat' });
+ const r = prep(brief, { capabilities: [cap('paseo')], workspace: { owner: 'paseo', id: 'ws', daemon: 'fixture-daemon', remote: true }, cwd: '/remote/not-local' });
+ const call = launch(r.id); assert.equal(call.invoke.arguments.provider, 'codex/test-model'); assert.equal(call.invoke.arguments.settings.thinkingOptionId, 'high');
+ assert.equal(call.invoke.daemon, 'fixture-daemon'); assert.ok(!('profile' in call.invoke.arguments));
+ assert.throws(() => attach(r.id, 'a1', 'wrong'), /different workspace/); attach(r.id, 'a1', 'ws');
+ ingest(r.id, { hostAgent: 'a1', event: 'complete', result: done, stopped: true });
+ const next = resume(r.id, brief); const follow = launch(next.id); assert.equal(follow.invoke.tool, 'send_agent_prompt'); assert.equal(follow.invoke.arguments.agentId, 'a1');
+ attach(next.id, 'a1', 'ws'); ingest(next.id, { hostAgent: 'a1', event: 'complete', result: done, stopped: true });
+ assert.throws(() => resolveExecutor(agent('omp', { transport: 'paseo' }), [cap('paseo')]), /cannot preserve/);
+}));
+test('L05: writer cancellation preserves partial edits and releases only after process termination', () => sandbox(async ({ root, brief }) => {
+ const repo = path.join(root, 'repo'), wt = path.join(root, 'wt'); fs.mkdirSync(repo);
+ const git = a => { const r = spawnSync('git', a, { cwd: repo, encoding: 'utf8' }); assert.equal(r.status, 0, r.stderr); };
+ git(['init', '-q']); git(['-c', 'user.name=Test', '-c', 'user.email=test@example.com', 'commit', '--allow-empty', '-qm', 'fixture']); git(['worktree', 'add', '-qb', 'test-writer', wt]);
+ const a = agent(); a.role = 'implementer'; savePreset(preset('X1', a)); setDefault('X1'); context({ session: 'test:chat' });
+ process.env.DK_FAKE_DELAY = '10000'; const r = prep(brief, { cwd: wt }); fs.writeFileSync(path.join(wt, 'partial.txt'), 'keep me'); launch(r.id);
+ await wait(r.id, 300); const stopped = await cancel(r.id); assert.equal(stopped.status, 'cancelled'); assert.equal(stopped.error, null);
+ assert.equal(fs.readFileSync(path.join(wt, 'partial.txt'), 'utf8'), 'keep me'); assert.equal(fs.existsSync(getRun(r.id).workspace.lock), false);
+}));
+test('M01/M02: migration materializes shared roles/ladders, rejects ambiguity and is idempotent', () => sandbox(async ({ state }) => {
+ fs.mkdirSync(state, { recursive: true }); const config = { roles: { researcher: { backend: 'codex', model: 'research' } }, profiles: { x1: { roles: { implementer: [{ backend: 'claude', model: 'builder' }, { backend: 'codex', model: 'complex', effort: 'high' }] } } }, limits: { max_runs: 4 } };
+ fs.writeFileSync(path.join(state, 'config.json'), JSON.stringify(config));
+ const plan = migrate(); assert.equal(plan.issues.length, 0); assert.equal(Object.keys(plan.presets[0].agents).length, 3); assert.equal(fs.existsSync(path.join(state, 'presets')), false);
+ migrate({ default_preset: 'x1' }, true); const p = loadPreset('x1'); p.preset.name = 'edited'; savePreset(p.preset, p.revision);
+ assert.equal(migrate({ default_preset: 'x1' }, true).already_migrated, true); assert.equal(loadPreset('x1').preset.name, 'edited');
+ config.profiles.x1.roles.implementer = [{ model: 'ambiguous' }]; fs.writeFileSync(path.join(state, 'config.json'), JSON.stringify(config));
+ assert.ok(migrate().issues.some(i => i.includes('declare parents'))); assert.throws(() => migrate({}, true), /requires decisions/);
+}));
+test('U01/U03/M03: absent setup, relocated package and concrete legacy-run diagnostics', () => sandbox(async ({ state, root }) => {
+ assert.throws(() => context({ session: 'new' }), /No preset/);
+ const moved = path.join(root, 'installed skill'); fs.cpSync(path.dirname(path.dirname(dk)), moved, { recursive: true });
+ const help = spawnSync(process.execPath, [path.join(moved, 'scripts/dk.mjs'), 'help'], { cwd: root, env: process.env, encoding: 'utf8' }); assert.equal(help.status, 0, help.stderr);
+ fs.mkdirSync(path.join(state, 'runs', 'old'), { recursive: true }); fs.writeFileSync(path.join(state, 'runs', 'old/meta.json'), JSON.stringify({ id: 'old', model: 'saved-model', status: 'running' }));
+ assert.throws(() => status('old'), /Legacy run.*agent-run/);
+}));
+
+
+test('RPC lifecycle: nonterminal agent_end, late errors, clamped reasoning and unsupported version', () => sandbox(async ({ brief }) => {
+ savePreset(preset('X1', agent('omp', { reasoning: 'high' }))); setDefault('X1'); context({ session: 'test:chat' });
+ process.env.DK_FAKE_RPC_CASE = 'nonterminal'; process.env.DK_FAKE_DELAY = '400';
+ const valid = prep(brief); launch(valid.id); assert.equal((await wait(valid.id, 100)).wait_timed_out, true);
+ assert.equal((await wait(valid.id, 5000)).status, 'finished');
+ for (const scenario of ['late-error', 'clamp', 'protocol', 'eof']) {
+ process.env.DK_FAKE_RPC_CASE = scenario;
+ const r = prep(brief); launch(r.id); const result = await wait(r.id, 5000);
+ assert.equal(result.status, 'failed', `${scenario}: ${result.error}`); assert.equal(result.accepted, false); if(scenario !== 'eof') assert.equal(result.result_validated, false);
+ }
+}));
+
+test('orphaned supervisor retains capacity while its child lives; cancellation recovers it', () => sandbox(async ({ brief }) => {
+ setup(); process.env.DK_FAKE_DELAY = '10000';
+ const r = prep(brief); launch(r.id);
+ let m;
+ for (let i = 0; i < 50; i++) { m = getRun(r.id); if (m.child_pid) break; await new Promise(resolve => setTimeout(resolve, 50)); }
+ assert.ok(m.child_pid); process.kill(m.pid, 'SIGKILL');
+ await new Promise(resolve => setTimeout(resolve, 100));
+ assert.equal(status(r.id).status, 'orphaned');
+ assert.throws(() => recover(r.id), /may still be writing/);
+ assert.equal((await cancel(r.id)).status, 'cancelled');
+}));
+
+test('mandatory reviewer cannot be skipped at acceptance and counters include continuations once', () => sandbox(async ({ brief, state }) => {
+ const p = preset(); p.agents.general.role = 'reviewer'; p.defaults = {}; p.agents.general.review = { also_run: ['second'] };
+ p.agents.second = { ...agent(), role: 'reviewer' }; p.limits = { max_runs: 3, max_retries: 1 };
+ savePreset(p); setDefault('X1'); context({ session: 'test:chat' });
+ const group = prepare({ session: 'test:chat', task: 'reviews', agent: 'general', brief });
+ launch(group.runs[0].id); await wait(group.runs[0].id, 5000);
+ assert.throws(() => accept(group.runs[0].id), /second/);
+ launch(group.runs[1].id); await wait(group.runs[1].id, 5000); assert.equal(accept(group.runs[0].id).accepted, true);
+ const attempt = resume(group.runs[0].id, brief); assert.throws(() => accept(group.runs[1].id), /latest attempt/); launch(attempt.id); await wait(attempt.id, 5000);
+ assert.throws(() => resume(attempt.id, brief), /max 3 runs|retries/);
+ const m = getRun(attempt.id); const count = readJSON(path.join(state, 'tasks', `${m.budget_task}.json`));
+ assert.equal(count.runs, 3); assert.equal(count.retries.general, 1);
+}));
+
+test('Claude and Gemini retained adapters work through the v2 snapshot runner', () => sandbox(async ({ brief }) => {
+ for (const harness of ['claude', 'gemini']) {
+ savePreset(preset(harness, agent(harness))); context({ session: 'test:chat', preset: harness });
+ const r = prep(brief, { task: harness }); launch(r.id); const first = await wait(r.id, 5000); assert.equal(first.status, 'finished', first.error);
+ const n = resume(r.id, brief); launch(n.id); const next = await wait(n.id, 5000);
+ assert.equal(next.status, 'finished', next.error); assert.equal(next.transport_session_id, first.transport_session_id);
+ }
+}));
+
+test('P03: simultaneous X1/Y2 native definitions never repin a shared role', () => sandbox(async ({ brief, root }) => {
+ const p = preset('X1', agent('claude', { transport: 'native' })); savePreset(p); copyPreset('X1', 'Y2');
+ const y = loadPreset('Y2'); y.preset.agents.general.executor.model = 'other-model'; savePreset(y.preset, y.revision);
+ setDefault('X1'); context({ session: 'host:X1' }); context({ session: 'host:Y2', preset: 'Y2' });
+ const capabilities = [{ ...cap('native', 'claude'), host: 'claude', dynamic_roles: true, models: [{ id: 'test-model' }, { id: 'other-model' }] }];
+ const ids = ['X1', 'Y2'].map(team => prepare({ session: `host:${team}`, task: 'task', agent: 'general', brief, capabilities }).runs[0].id);
+ const calls = ids.map(launch); assert.notEqual(calls[0].invoke.definition.name, calls[1].invoke.definition.name);
+ const directory = path.join(root, 'native agents'); fs.mkdirSync(directory); fs.writeFileSync(path.join(directory, 'user-role.md'), 'user definition');
+ for (const id of ids) { const r = invoke(['materialize', id, '--directory', directory]); assert.equal(r.status, 0, r.stderr); }
+ assert.equal(fs.readFileSync(path.join(directory, `${calls[0].invoke.definition.name}.md`), 'utf8').includes('test-model'), true);
+ assert.equal(fs.readFileSync(path.join(directory, `${calls[1].invoke.definition.name}.md`), 'utf8').includes('other-model'), true);
+ assert.equal(fs.readFileSync(path.join(directory, 'user-role.md'), 'utf8'), 'user definition');
+}));
+
+test('U03: shipped v2 example requires replacing placeholders; relocated skill references resolve', t => {
+ const source = path.dirname(path.dirname(dk)), relocated = fs.mkdtempSync(path.join(os.tmpdir(), 'dk-package-'));
+ t.after(() => fs.rmSync(relocated, { recursive: true, force: true }));
+ const skill = path.join(relocated, 'skill'); fs.cpSync(source, skill, { recursive: true });
+ const p = readJSON(path.join(skill, 'examples/config.json'));
+ assert.throws(() => validatePreset(p), /placeholder/);
+ for (const a of Object.values(p.agents)) a.executor.model = 'verified-model';
+ validatePreset(p);
+ const files = [path.join(skill, 'SKILL.md'), ...fs.readdirSync(path.join(skill, 'references')).filter(f => f.endsWith('.md')).map(f => path.join(skill, 'references', f))];
+ for (const file of files) for (const [, link] of fs.readFileSync(file, 'utf8').matchAll(/\]\(([^)]+)\)/g)) {
+ if (/^(?:https?:|#)/.test(link)) continue;
+ assert.ok(fs.existsSync(path.resolve(path.dirname(file), link.split('#')[0])), `${file}: missing ${link}`);
+ }
+});
+
+
+test('Native failed dispatch releases its reservation only with confirmed host evidence', () => sandbox(async ({ brief }) => {
+ const p = preset('X1', agent('codex', { transport: 'native' })); p.limits = { max_workers: 1 };
+ savePreset(p); setDefault('X1'); context({ session: 'test:chat' });
+ const r = prep(brief, { capabilities: [cap()] }); const call = launch(r.id);
+ await cancel(r.id);
+ assert.throws(() => dispatchFailed(r.id, { dispatchToken: call.dispatch_token, evidence: 'timed out' }), /confirming/);
+ assert.throws(() => prep(brief, { capabilities: [cap()] }), /max 1/);
+ assert.equal(dispatchFailed(r.id, { dispatchToken: call.dispatch_token, confirmedNotStarted: true, evidence: 'Host rejected request before creating an agent' }).status, 'cancelled');
+ const next = prep(brief, { capabilities: [cap()] }); launch(next.id); await cancel(next.id);
+ attach(next.id, 'late-host-id'); assert.equal(status(next.id).status, 'cancelling');
+ ingest(next.id, { hostAgent: 'late-host-id', event: 'running' }); assert.equal(status(next.id).status, 'cancelling');
+ ingest(next.id, { hostAgent: 'late-host-id', event: 'permission' }); assert.equal(status(next.id).status, 'cancelling');
+ assert.throws(() => dispatchFailed(next.id, { dispatchToken: getRun(next.id).claim, confirmedNotStarted: true, evidence: 'no' }), /unattached/);
+ assert.equal(ingest(next.id, { hostAgent: 'late-host-id', event: 'cancelled', stopped: true }).status, 'cancelled');
+}));
+
+test('Native continuation rejects a previous turn result even if caller labels it with the new token', () => sandbox(async ({ brief }) => {
+ savePreset(preset('X1', agent('codex', { transport: 'native' }))); setDefault('X1'); context({ session: 'test:chat' });
+ const r = prep(brief, { capabilities: [cap()] }); const first = launch(r.id); attach(r.id, 'host');
+ const old = { dispatch_token: first.dispatch_token, result: done };
+ ingest(r.id, { hostAgent: 'host', event: 'complete', result: done, stopped: true });
+ const next = resume(r.id, brief); const second = launch(next.id); attach(next.id, 'host');
+ assert.notEqual(first.dispatch_token, second.dispatch_token);
+ assert.ok(second.invoke.arguments.message.includes(second.dispatch_token));
+ assert.throws(() => ingestRaw(next.id, { hostAgent: 'host', dispatchToken: first.dispatch_token, event: 'complete', result: old, stopped: true }), /token/);
+ assert.throws(() => ingestRaw(next.id, { hostAgent: 'host', dispatchToken: second.dispatch_token, event: 'complete', result: old, stopped: true }), /token/);
+ assert.equal(status(next.id).status, 'running');
+ ingest(next.id, { hostAgent: 'host', event: 'complete', result: done, stopped: true }); assert.equal(accept(next.id).accepted, true);
+ const rejected = resume(next.id, brief), call = launch(rejected.id);
+ assert.equal(dispatchFailed(rejected.id, { dispatchToken: call.dispatch_token, confirmedNotStarted: true, evidence: 'Host rejected follow-up before starting a new turn in existing session' }).status, 'failed');
+ assert.equal(getRun(rejected.id).transport_session_id, 'host');
+ const retry = resume(rejected.id, brief); assert.equal(retry.transport_session_id, 'host'); await cancel(retry.id);
+}));
+
+test('No implicit worker cap; coordinator can admit independent workers', () => sandbox(async ({ brief }) => {
+ setup(); const runs = Array.from({ length: 3 }, () => prep(brief));
+ assert.equal(runs.length, 3); assert.equal(getRun(runs[0].id).limits.workers, null);
+ for (const r of runs) await cancel(r.id);
+}));
+
+test('Watchdog reports an alive but quiet CLI without killing it or losing ownership', () => sandbox(async ({ brief }) => {
+ setup(); process.env.DK_FAKE_DELAY = '10000'; const r = prep(brief, { stallMs: 150 }); launch(r.id);
+ const observed = await wait(r.id, 2000);
+ assert.equal(observed.status, 'running'); assert.equal(observed.health.state, 'no_progress');
+ assert.equal(observed.health.attention_required, true); assert.equal(getRun(r.id).status, 'running');
+ await cancel(r.id);
+}));
+
+test('Watchdog requests native status and preserves progress age across unchanged probes', () => sandbox(async ({ brief, state }) => {
+ savePreset(preset('X1', agent('codex', { transport: 'native' }))); setDefault('X1'); context({ session: 'test:chat' });
+ const r = prep(brief, { capabilities: [cap()] }); launch(r.id); attach(r.id, 'host');
+ const file = path.join(state, 'runs', r.id, 'meta.json'), m = getRun(r.id);
+ m.host_checked_at = new Date(Date.now() - 61000).toISOString(); fs.writeFileSync(file, JSON.stringify(m));
+ assert.equal((await wait(r.id, 1000)).health.state, 'check_host');
+ ingest(r.id, { hostAgent: 'host', event: 'running', progress: 'cursor1' });
+ const stale = getRun(r.id); stale.progress_at = new Date(Date.now() - 301000).toISOString(); fs.writeFileSync(file, JSON.stringify(stale));
+ ingest(r.id, { hostAgent: 'host', event: 'running', progress: 'cursor1' });
+ assert.equal((await wait(r.id, 1000)).health.state, 'no_progress');
+ ingest(r.id, { hostAgent: 'host', event: 'running', progress: 'cursor2' });
+ assert.equal(status(r.id).health.attention_required, false);
+}));
+
+test('Watchdog detects frozen supervisor heartbeats while the process remains alive', () => sandbox(async ({ brief, state }) => {
+ setup(); process.env.DK_FAKE_DELAY = '10000'; const r = prep(brief); launch(r.id); await wait(r.id, 300);
+ const m = getRun(r.id); process.kill(m.pid, 'SIGSTOP');
+ try {
+ fs.writeFileSync(path.join(state, 'runs', r.id, 'heartbeat.json'), JSON.stringify({ pid: m.pid, claim: m.claim, at: new Date(Date.now() - 31000).toISOString() }));
+ assert.equal(status(r.id).health.state, 'supervisor_unresponsive');
+ } finally { process.kill(m.pid, 'SIGCONT'); await cancel(r.id); }
+}));
+
+test('Migration retains an explicit parent harness and refuses ambiguous family routing', () => sandbox(async ({ state }) => {
+ fs.mkdirSync(state, { recursive: true });
+ const config = { backends: { custom: { family: 'gpt', adapter: 'opencode' } }, profiles: { x1: { roles: { researcher: { family: 'gpt', runner: 'auto', model: 'provider/model' } } } } };
+ fs.writeFileSync(path.join(state, 'config.json'), JSON.stringify(config));
+ assert.ok(migrate().issues.some(i => i.includes('parent-dependent')));
+ const plan = migrate({ parents: { x1: 'custom' } }); assert.deepEqual(plan.issues, []);
+ assert.equal(plan.presets[0].agents['researcher-1'].executor.harness, 'opencode');
+}));
+
+test('RPC usage includes tool-call turns once and keeps absent measurements unknown', () => {
+ const first = { usage: { input: 100, output: 10, totalTokens: 110, cost: { total: 1 } } };
+ const last = { usage: { input: 200, output: 20, totalTokens: 220, cost: { total: 2 } } };
+ assert.deepEqual(sumUsage([first, last]), { input: 300, output: 30, totalTokens: 330, cost: { total: 3 } });
+ assert.equal(sumUsage([first, {}]), null);
+ assert.equal(sumUsage([first, { usage: { input: 5 } }]).cost, null);
+});
+
+
+test('RPC supervisor records complete current-turn usage without double-counting message_end', () => sandbox(async ({ brief }) => {
+ for (const harness of ['pi', 'omp']) {
+ savePreset(preset(harness, agent(harness))); setDefault(harness); context({ session: 'test:chat', preset: harness });
+ process.env.DK_FAKE_RPC_CASE = 'multi-usage'; const r = prep(brief); launch(r.id);
+ const result = await wait(r.id, 5000); assert.equal(result.status, 'finished', result.error);
+ assert.equal(result.usage.totalTokens, 330); assert.equal(result.cost_usd, 3);
+ const n = resume(r.id, brief); launch(n.id); const next = await wait(n.id, 5000);
+ assert.equal(next.usage.totalTokens, 330); assert.equal(next.cost_usd, 3);
+ }
+}));
+
+
+test('Live smoke requires opt-in and never calls models when showing help', () => sandbox(async ({ root, state }) => {
+ const file = fileURLToPath(new URL('./live-codex.mjs', import.meta.url));
+ const result = spawnSync(process.execPath, [file], { cwd: root, env: process.env, encoding: 'utf8' });
+ assert.equal(result.status, 0, result.stderr); assert.match(result.stdout, /--execute/);
+ assert.equal(fs.existsSync(path.join(state, 'runs')), false);
+}));
+
+test('Installed CLI runs through a skill-directory symlink', t => {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'dk-symlink-'));
+ t.after(() => fs.rmSync(root, { recursive: true, force: true }));
+ const source = path.dirname(path.dirname(dk)), installed = path.join(root, 'installed'), linked = path.join(root, 'skill-link');
+ fs.cpSync(source, installed, { recursive: true }); fs.symlinkSync(installed, linked, 'dir');
+ const result = spawnSync(process.execPath, [path.join(linked, 'scripts/dk.mjs'), 'help'], { cwd: root, encoding: 'utf8' });
+ assert.equal(result.status, 0, result.stderr);
+ assert.ok(JSON.parse(result.stdout).commands.includes('run ID'));
+ validatePreset(readJSON(path.join(installed, 'examples/main.json')));
+ const limits = spawnSync(process.execPath, [path.join(linked, 'scripts/limits.mjs'), '--max-workers', '2'], { encoding: 'utf8' });
+ assert.equal(limits.status, 0, limits.stderr); assert.equal(JSON.parse(limits.stdout).workers, 2);
+ const pi = spawnSync(process.execPath, [path.join(linked, 'scripts/pi-worker.mjs'), '--sdk', path.join(root, 'absent-sdk.mjs')], { encoding: 'utf8' });
+ assert.equal(pi.status, 1); assert.match(pi.stderr, /Pi SDK startup failed/);
+});
+
+
+test('Native writers require an enforced binding to the leased worktree before admission', () => sandbox(async ({ root, brief, state }) => {
+ const repo = path.join(root, 'repo'), wt = path.join(root, 'writer'), alias = path.join(root, 'writer-link');
+ fs.mkdirSync(repo);
+ const git = args => { const r = spawnSync('git', args, { cwd: repo, encoding: 'utf8' }); assert.equal(r.status, 0, r.stderr); return r.stdout.trim(); };
+ git(['init', '-q']); git(['-c', 'user.name=Test', '-c', 'user.email=test@example.invalid', 'commit', '--allow-empty', '-qm', 'Fixture']);
+ git(['worktree', 'add', '-qb', 'writer', wt]); fs.symlinkSync(wt, alias);
+ const lock = path.join(spawnSync('git', ['rev-parse', '--absolute-git-dir'], { cwd: wt, encoding: 'utf8' }).stdout.trim(), 'delegate-kit.lock');
+ for (const harness of ['codex', 'claude']) {
+ savePreset(preset(harness, { ...agent(harness, { transport: 'native' }), role: 'implementer' }));
+ context({ session: 'test:chat', preset: harness });
+ const host = { ...cap('native', harness), host: harness, dynamic_roles: true };
+ for (const binding of [undefined, { cwd: repo, enforced: true }, { cwd: wt, enforced: false }, { cwd: 'writer', enforced: true }]) {
+ assert.throws(() => prep(brief, { cwd: wt, capabilities: [{ ...host, workspace_binding: binding }] }), /Native writer requires.*worktree/);
+ assert.equal(fs.existsSync(lock), false);
+ assert.equal(fs.existsSync(path.join(state, 'runs')) && fs.readdirSync(path.join(state, 'runs')).length > 0, false);
+ }
+ }
+ const host = { ...cap(), workspace_binding: { cwd: alias, enforced: true } };
+ context({ session: 'test:chat', preset: 'codex' });
+ const r = prep(brief, { cwd: wt, capabilities: [host] });
+ assert.equal(fs.existsSync(lock), true);
+ assert.equal(bridgeInvocation(getRun(r.id), 'task').tool, 'spawn_agent');
+ const legacy = getRun(r.id); delete legacy.executor.capability.workspace_binding;
+ assert.throws(() => bridgeInvocation(legacy, 'task'), /Native writer requires/);
+ legacy.resume_of = 'previous'; legacy.transport_session_id = 'host-agent';
+ assert.throws(() => bridgeInvocation(legacy, 'continue'), /Native writer requires/);
+ await cancel(r.id);
+}));