# dsh-openclaude One DSH plugin that runs a task graph through the OpenClaude CLI and judges every attempt against the project's own quality commands. This replaces the four-package `dsh-openclaude-ecosystem` layout (`dsh-openclaude-launcher` + `dsh-task-orchestrator` + `dsh-supervision` + `dsh-openclaude-orchestrator`) with a single package containing three internal modules. The modules did not need to be four plugins: supervision reached into the orchestrator's private `store` field, the launcher had no consumer but the facade, and the facade was an empty shell without the other three. Splitting them cost four `package.json`s, four build steps, four `cordis.patch.yml` files, and the `depends` ordering that produced the `declares no dsh.bundle` failures. ## Layout ``` src/ index.ts the plugin: name / inject / Config / apply config.ts the whole config surface, one schemastery schema llm.ts the model seam (structural, no harness import) core/ types.ts the shared vocabulary; executor and supervision both read it task-graph.ts dependency graph, readiness, cycle detection task-store.ts append-only task journal (node:fs) context.ts real context collection (manifest, config, repo map) patch.ts supervisor patch application (node:fs) signals.ts abort plumbing that cannot produce an inert signal orchestrator.ts the engine executor/ cli.ts child-process lifecycle: framing, idle watchdog, SIGTERM→SIGKILL openclaude.ts the OpenClaude invocation and result parsing supervision/ auto-check.ts runs the project's own test/lint/typecheck/build supervisor.ts the review advisor subagent/ index.ts the `openclaude` subagent provider: delegation with stall self-healing ``` Two entry points, one bundle: `.` is the orchestrator plugin above, and `./subagent` is the separately-mounted delegation provider. They share the executor but are configured independently — the orchestrator runs a task graph in-process, the provider hands a single task to a child OpenClaude in its own process. ## Install The plugin is a normal DSH bundle: it declares `dsh.bundle.patch` in `package.json`, so a profile that lists it as a bundle picks the row up automatically. 1. Build it. ```sh cd packages/dsh-openclaude npm run build # or: node ../../node_modules/typescript/bin/tsc -p tsconfig.build.json ``` Building needs the monorepo root's dev dependencies. `lib/` is committed to the working tree and the package carries its own runtime dependencies under `node_modules/@deepseek-ai/`, so a *built* plugin keeps working even with the root `node_modules/` removed — but re-building needs `pnpm install` first. 2. Add it to the profile's manifest — `~/.dsh/profiles/web/package.json`: ```json { "dependencies": { "dsh-openclaude": "link:/packages/dsh-openclaude" }, "dsh": { "profile": { "bundles": ["...", "dsh-openclaude"] } } } ``` > `link:` takes an **absolute filesystem path** — npm does not expand `~`. > Replace `` with the real path, e.g. > `link:$HOME/dsh-openclaude-ecosystem/packages/dsh-openclaude`. A path > starting with `~` is written into the tree verbatim and the install fails. 3. Make it resolvable from the profile — either run the profile install, or create the link the install would create: ```sh ln -s ~/dsh-openclaude-ecosystem/packages/dsh-openclaude \ ~/.dsh/profiles/web/node_modules/dsh-openclaude ``` `./install-to-profile.sh` does steps 2 and 3 for the `web` profile, backing up the manifest first. > `~/.dsh/profiles/web/plugins.json` is **not** read by DeepSeek Harness. The > four old entries in it never loaded anything. A profile's bundles come from > `dsh.profile.bundles` in its `package.json`. ## Prerequisites: OpenClaude credentials The plugin only spawns `openclaude`; it does not hold credentials. Configure the provider **once, in OpenClaude's own global config** — `~/.openclaude.json` accepts an `env` block and merges it into its process environment at startup: ```json { "env": { "CLAUDE_CODE_USE_OPENAI": "1", "NVIDIA_API_KEY": "nvapi-...", "OPENAI_BASE_URL": "https://integrate.api.nvidia.com/v1", "OPENAI_MODEL": "nvidia/nemotron-3-ultra-550b-a55b" } } ``` `chmod 600 ~/.openclaude.json`. Keeping the secret here rather than in the plugin row means one source of truth: a bare `openclaude` in a terminal and a task spawned by the plugin use the same credentials and the same default model. That is also why the row's `provider`/`model` default to empty — "whatever OpenClaude itself is configured with". An override in the profile's `cordis.patch.yml` is only for making the plugin differ from the global default. Verify before blaming the plugin: ```sh # the config alone must be enough — hence env -u, or the test proves nothing env -u NVIDIA_API_KEY openclaude --print --yolo --max-turns 1 \ --output-format json "Reply with exactly: OK" ``` A `{"subtype":"success","is_error":false}` envelope naming your model in `modelUsage` means the whole chain works. `--provider nvidia-nim` is supported even though `--help` does not list it; it reads `NVIDIA_API_KEY`. Note that `--max-turns 1` is too small for real work — one tool call consumes a turn, and the envelope comes back as `error_max_turns`. ## Configuration Every key is validated before `apply` runs, so a typo is a load error rather than a surprise mid-run. ### Executor | key | default | meaning | |---|---|---| | `executable` | `openclaude` | A name resolved on PATH, or an absolute path. `~/.homebrew/bin` and the npm-global layout under a Homebrew node are also probed. | | `workdir` | `.` | The project tasks operate on. | | `provider` / `model` | `''` | Passed as `--provider` / `--model`. | | `maxTurns` | `50` | `--max-turns`. **Not** `--max-steps`; see below. | | `idleTimeoutMs` | `240000` | Kill the child when it makes no **progress** for this long. A `--heartbeat` line is liveness, not progress, so it does not reset this clock — see [The watchdog measures progress](#the-watchdog-measures-progress). | | `maxRuntimeMs` | `3600000` | Hard per-attempt wall clock, independent of output. `0` disables it. Bounds the one failure the progress clock cannot see: a livelock that keeps reporting activity. | | `killGraceMs` | `5000` | SIGTERM → SIGKILL grace period. | | `heartbeat` | `30s` | `--heartbeat` value; tells the watchdog the child is *alive*, which is what makes a stall visible as a stall instead of a timeout. | | `permissionMode` | `''` | Empty keeps `--yolo`, required for unattended runs. | | `extraArgs` | `[]` | Appended verbatim. | | `env` | `{}` | Extra environment for the child, e.g. provider API keys. | | `noSessionPersistence` | `true` | One session file per task is not wanted. | | `addDirs` | `[]` | `--add-dir` values. | ### Engine | key | default | meaning | |---|---|---| | `maxConcurrency` | `2` | Tasks allowed to run at once. | | `maxRetriesPerTask` | `3` | Total attempts per task, including the first. | | `maxSupervisionRounds` | `5` | Review cycles before a decision is forced to escalate. | | `storePath` | `.dsh/openclaude/tasks.jsonl` | Journal, relative to `workdir`. | | `persistence` | `true` | Write the journal. | | `contextStrategy` | `full` | `minimal` \| `full` \| `repo-map`. | | `contextMaxTokens` | `20000` | Soft cap on collected context. | | `patchMode` | `suggest` | `auto` applies supervisor patches, `suggest` only records them. | | `acceptUnverified` | `true` | Accept a successful attempt when no acceptance command is configured. | ### Acceptance | key | default | |---|---| | `test` / `lint` / `typecheck` / `build` | the package.json script name to run for each role | | `acceptanceTimeoutMs` | `600000` per command | | `packageManager` | `''` → detected from the lockfile | | `additionalCommands` | `[]` extra command lines run as-is | ### Supervisor | key | default | |---|---| | `supervisorEnabled` | `true` (skipped entirely when no `llm` service is loaded) | | `supervisorProvider` / `supervisorModel` | `''` → falls back to `provider` / `model` | | `supervisorMaxTokens` | `4096` | | `supervisorTemperature` | `0.2` | ## What it registers - Service `ctx.openclaude`: the engine, the executor, `loadGraphFile`, `runFile`, `runRequest`, `cancel`, `abort`, `close`. - Command `/openclaude ` — run a task graph file, or a single ad-hoc request. - Command `/openclaude-status` — every task with its status, attempt count and halt reason. - Command `/openclaude-abort` — abort the run and kill in-flight children. Separately, and only if the `subagent-openclaude` row is mounted, `./subagent` registers the `openclaude` subagent provider on `ctx.subagents` — see [Delegation](#delegation-the-openclaude-subagent-provider). The two rows are independent: `/openclaude` works without the provider, and the provider needs neither the engine nor the slash commands. `inject` is empty on purpose. `llm` is read with `ctx.get('llm')` and skipped when absent, so the plugin also runs in a headless composition whose only evidence is the acceptance commands. `commands` cannot be read that way: Cordis throws `cannot get property "commands" without inject` for any service read off the context without declaring it, and putting it in the top-level `inject` would hold the **whole** plugin pending wherever no command adapter is mounted. The injection therefore lives in a child fiber: ```ts ctx.inject(['commands'], (inner) => { inner.commands.register({ /* ... */ }) }) ``` The service, the executor and the engine mount either way; only the slash commands wait for a registry. ## Delegation: the `openclaude` subagent provider `./subagent` registers a `SubagentProvider` named `openclaude` on `ctx.subagents`, so a parent agent can hand a self-contained task to a child OpenClaude process through the harness's own `@deepseek-ai/dsh-tool-subagent`. It is a **provider**, not a tool: the bundle row registers the provider, and an agent preset binds a tool to it. ```yaml # cordis.patch.yml (host plane — this bundle) - id: subagent-openclaude name: dsh-openclaude/subagent config: { executable: openclaude, idleTimeoutMs: 900000, maxRuntimeMs: 3600000, heartbeat: 30s, maxStalledRestarts: 2 } ``` ```yaml # ~/.dsh/.agent-presets//agent.cordis.yml (agent plane) - id: tool-subagent-openclaude name: '@deepseek-ai/dsh-tool-subagent' config: provider: openclaude toolName: subagent_openclaude enableRunInBackground: true maxDepth: provider-managed # a number fails the mount — see below ``` ### What it adds over the shipped `claude-code` provider | Behaviour | `subagent-claude-code` | this provider | |---|---|---| | Idle / stall detection | ✗ — a hung child is invisible forever | ✅ no **progress** > `idleTimeoutMs`, plus a hard `maxRuntimeMs` cap per attempt | | Recovery | ✗ | ✅ kill, then `--resume ` the *same* CLI session, bounded by `maxStalledRestarts` | | Resume handle | ✗ | ✅ every run mints its own `--session-id` | | Failure signal | exit code | ✅ error code, attempt count, session id + `openclaude --resume `, last heartbeat, stderr tail | A child cannot report a stall — it is hung — and a black-box provider cannot recover from one, because the session handle lives inside the provider. That is the entire reason this module exists. ### The watchdog measures progress The distinction the whole watchdog rests on: a heartbeat proves the child is **alive**, not that it is **working**. The two stalls that matter — a socket that never delivers, and a child blocked on a permission prompt nobody will answer — both keep heartbeating happily. OpenClaude emits a heartbeat every `heartbeat` interval while output is quiet: ``` openclaude: heartbeat elapsed=900s quiet=900s state=running phase=in_turn ``` under the `--output-format json` this plugin uses it arrives on **stderr** (`stream-json` puts the SDK record on stdout instead). Both shapes are classified by `classifyOutput`, and a liveness line **does not reset** `idleTimeoutMs`. That is the point: the old rule was "any byte is progress", so a heartbeat every 30s reset the timer every 30s and a frozen run stayed frozen forever — the stall the watchdog exists to catch was the one case it could not see. Two further details are worth knowing: - **The child's own clock is used.** `quiet=` is the gap OpenClaude itself measured, and `since_last_activity_ms` is the same number in the JSON shape. When it already covers `idleTimeoutMs`, the watchdog fires from the child's accounting rather than waiting for a timer, which keeps unrelated stderr chatter from looking like work. - **`maxRuntimeMs` bounds what the progress clock cannot.** A livelock — a child that keeps marking activity while getting nowhere — is invisible to a no-progress rule, so one hard wall clock ends the attempt regardless. Both verdicts reward the same recovery, so `--resume` handles either. A stall report names the last heartbeat the child sent, because that is the one clue an error code cannot carry: `phase=in_turn` (waiting on the model) and `phase=waiting_for_permission` (nobody will ever answer) need completely different fixes. ### Capabilities, and the `maxDepth` trap `capabilities` is `NO_START_CAPABILITIES` (all four `false`) and `inheritsParentContext` is `false`. A child in another process cannot honour a parent-enforced `outputSchema` / `depthLimit` / `toolFilter` / `persona`, so such a request is rejected *before* `start` rather than silently ignored. The consequence: the tool row **must** say `maxDepth: provider-managed`. A numeric cap throws at mount — ``` tool-subagent: provider "openclaude" cannot enforce maxDepth (no depthLimit capability) ``` — which is deliberate: a cap the provider cannot enforce is a misconfiguration, and a mount-time failure is easier to find than a delegation that quietly exceeds its budget. Because `inheritsParentContext` is `false`, the tool's own description tells the parent model "it does not see this conversation" and to write a standalone prompt. Nothing in this module has to say it. ### Announcing the capability Registering a tool and *describing* a tool are two different acts, and the harness only makes the second one mandatory for the one shape it hard-codes: its `@deepseek-ai/dsh-tool-subagent` publishes a `tool:` prompt section when `backgroundMode` is `continuable`. A one-shot provider — this one, and the shipped `subagent-claude-code` provider alike — gets a schema and nothing else. A schema states parameters, not abilities. Nothing in `subagent_openclaude`'s schema says *you can hand engineering work to another process*, so the model is left to infer the ability from a tool name. It does not do so reliably: measured on this rig with 46 tools in context, a session that had the tool made zero delegation calls and never mentioned it. So `./subagent` registers its own section: ```ts ctx.systemPrompt.section({ name: `tool:${toolName}`, // tool:subagent_openclaude order: 116.6, // 116.5 is the harness's own delegation text text: context => tools.get(toolName, context.scope) === undefined ? '' : guidance, }) ``` Four properties are load-bearing: - **It states a capability, not a rulebook.** The routing criteria, the brief's required fields, the supervision protocol and the rework loop live in the agent preset's persona. `delegationGuidance()` names the capability, says it is the default for engineering-heavy work, points at the persona for the criteria, and restates exactly one prohibition — delegating judgement to a process that cannot see the conversation. One fact, one owner. - **It is gated per assembly.** The `text` is a provider evaluated at each assembly; `tools.get(name, context.scope)` decides visibility, and an empty string is dropped from the rendered prompt. An agent that never got the tool pays nothing, so no preset has to opt out. - **It reaches the optional services through a child fiber, never `inject`.** The row's `inject` is `['subagents']`. Adding `tools`/`systemPrompt` there would leave the row pending in any composition without them, `getProvider('openclaude')` would never resolve, and the delegation tool would vanish with no error. `ctx.inject(['tools', 'systemPrompt'], …)` keeps the provider mounting regardless. - **It never coexists with `continuable`.** That mode makes the harness register the same section name itself, and a duplicate within one layer throws. Registering is therefore wrapped: the duplicate is reported and skipped rather than allowed to fail the mount and take the tool with it. `delegationToolName` names the section. It must match the tool row's `toolName`; a mismatch is not an error, it just renders the section empty. ### Flags it passes `--print --output-format json`, `--session-id ` (or `--resume ` on a retry), `--max-turns` when set, `--heartbeat`, `--yolo` for `permissionMode: bypassPermissions` (its documented alias), and `--append-system-prompt` with the child contract. `--output-format json`, **not** `--json-schema`: the parent consumes prose, and wrapping the answer in a schema only double-encodes it. `readResultEnvelope` takes the last parseable envelope and ignores stray noise; real heartbeat lines never reach it at all, because the watchdog consumes them first. The child contract (`CHILD_SYSTEM_PROMPT`) mandates a trailing `## Report` block — summary, files changed, the verification command *and its result*, and what was left unfinished. That block is what lets the parent check a delegation without re-reading the diff. ### Config normalization `normalizeConfig` fills every omitted key from `CONFIG_DEFAULTS` in the constructor. Schemastery only applies defaults when the *loader* resolves the config, so a provider built directly — by a test, or by another plugin calling `apply()` with a partial object — would otherwise carry `undefined` for every omitted key, and `childSystemPrompt(undefined)` would throw on the first `.trim()`. This was a real failure against the live binary, not a hypothetical; `test/subagent.mjs` now asserts the literal and the schema cannot drift apart. ## Model Experience Written to the harness's own contract for a model-facing package (`.agents/notes/implemented/process/2026-07-12-package-model-experience-contract.md`). The harness enforces this section on its own `packages/*` and cannot reach a third-party plugin, so it is written here by hand. The note's reasoning for requiring even a zero effect applies: *unconstrained absence is ambiguous between an audited zero and forgotten documentation.* ### Child request #### What the model sees The OpenClaude child receives one standalone text task on stdin, plus `--append-system-prompt` carrying this module's own child contract (rules, and the mandated trailing `## Report` block). Its working directory is the delegating session's cwd, or the row's `cwd` override. Its model, tools, permissions and credentials come from OpenClaude's own global configuration — this plugin holds none of them. #### Token effect The child pays for a fully independent OpenClaude context, including that product's own tool catalog. No child token enters the parent's context. #### KV cache effect Independent of the parent request cache. Reuse depends only on OpenClaude's own model, instructions and tool catalog. ### Parent tool result, indirectly #### What the model sees Through `@deepseek-ai/dsh-tool-subagent`, the parent sees only the child's final message — or, when the run did not finish, that message plus this provider's diagnostics. Reasoning, tool activity, intermediate messages, stderr, usage and workspace diffs are not copied into the parent session. #### Token effect Parent input grows only by the retained tool result: normally the final message; on failure, the same message plus a diagnostics block (error code, attempt count, session id, a `--resume` command, and a stderr tail capped at 2,000 characters). This provider adds no tool schema of its own. #### KV cache effect Append-only: the new tool result follows the reusable parent request prefix. ### Delegation guidance, resident #### What the model sees `delegationGuidance()` as a `tool:subagent_openclaude` prompt section at order 116.6 — immediately after the harness's own delegation text at 116.5 — rendered only for an agent the tool is visible to. #### Token effect 116 characters of static text added to the parent's fixed per-turn overhead, and only in sessions that received the tool. Against the ~90k-token per-turn baseline measured on this rig that is well under 0.3%. Every other agent pays exactly zero: an empty section is dropped from the rendered prompt. #### KV cache effect Static text at a fixed order extends the cacheable prefix rather than breaking it. Its presence is scope-dependent, so a delegating agent and a non-delegating one have different prefixes — but they were never the same request to begin with, since their tool catalogs already differ. ## Known Limitations and Deferred Work - **Stall self-healing is this provider's, not upstream's.** A stalled run is killed and resumed with `--resume `, bounded by `maxStalledRestarts`. The shipped `claude-code` provider has no equivalent: a hung child is invisible to it forever, because the session handle lives inside the provider. - **No `continuable` background mode.** Deliberate — see the `maxDepth` trap above. `continuable` would also require `prepareContinuable`, which pulls the CLI child back into the DSH process as a child agent and discards the resume handle this module exists to keep. - **No optional shared capabilities.** `outputSchema`, `persona`, `toolFilter` and `depthLimit` are all rejected by the shared service for this provider, and by every other out-of-process provider. - **No progress stream.** A foreground delegation is a black box until it settles, so supervision is one brief at a time; the seam has no liveness channel and this provider does not invent one. - **No human interaction path.** The child runs `--yolo` unattended. Anything needing approval must be resolved by the parent before delegating. - **Final text plus diagnostics only.** Reasoning, tool traffic, usage and workspace diffs stay inside the child. - **The child's model is OpenClaude's own.** The parent cannot pin it per delegation; `appendSystemPrompt` is the only per-row influence. - **The heartbeat format is load-bearing, and unversioned.** `classifyOutput` recognises two exact shapes (the stderr text line and the `stream-json` SDK record). If a future OpenClaude changes either, heartbeats degrade to "progress" — which is the *original bug*, silently restored. Two things bound the damage: `maxRuntimeMs` is format-independent, so an attempt still cannot run forever; and `test/subagent.mjs` pins both shapes, so a format change that reaches this rig fails a test rather than a delegation. Verify a new format with `openclaude --print --output-format json --heartbeat 5s …` before trusting the progress clock on it. - **The guidance cannot be verified from the config dump.** `dsh check` composes the host plane; agent presets mount at session creation. The section's presence is asserted from a live session's `request/header` — see `../tools/dsh-acceptance-guidance.sh`. ## Running it The plugin is only exercised when the profile actually boots. Two things bite: 1. **Use a node whose arch matches the machine.** The harness tree's `esbuild`/`tsx` binaries are built for one arch; a mismatched node fails with *"You installed esbuild for another platform"*. Check `node -p process.arch` against `uname -m`. 2. **Run the launcher from the harness checkout**, not from this package. ```sh cd ~/deepseek-harness node apps/cli/lib/bin.js --profile web --dump-config # compose the tree, start nothing node apps/cli/lib/bin.js --profile web # serve on 127.0.0.1:3080 ``` `../start-dsh.sh` wraps both checks; `./start-dsh.sh --dump-config` is the pre-flight, `./start-dsh.sh` the real thing. A successful boot is itself evidence the plugin mounted: the launcher asserts that *every* loader entry activated, so a pending or throwing `apply` aborts it with `plugin tree failed to load`. ### Verifying that a config edit reached the plugin A boot states the policy it resolved — once, and only once: ``` [openclaude] subagent provider "openclaude" registered (watchdog stall 900000ms / cap 3600000ms / heartbeat 30s, 2 auto-resume(s)) ``` That line is the only artefact that answers *"did my edit land?"* for a running process. Node resolves this plugin at boot, so a config change needs a restart; and `--dump-config` on its own cannot prove the plumbing anyway — a value that happens to equal its default looks identical whether it was read from the row or supplied by `normalizeConfig`. The row in `cordis.patch.yml` currently sets `maxRuntimeMs: 3600000`, the default, so that value is exactly the ambiguous case. `../tools/dsh-live-config.mjs` settles the file-level half with no model call: it composes the tree, feeds the `subagent-openclaude` row through the built `normalizeConfig` + `apply()`, and prints the registration line above together with where each value came from. ```sh node ../tools/dsh-live-config.mjs ``` If the server was started with `dsh bg`, `dsh logs` holds that boot line. Started in the foreground (as `./start-dsh.sh` does), it only exists in that terminal — `dsh status` then gives just the pid, and a boot time *after* the edit is the second half of the proof. ### `dsh`, the short version `bin/dsh` is symlinked onto `PATH` as `dsh` (see `~/.local/bin/dsh`), so the archive-check above never has to be retyped: ```sh dsh # foreground; Ctrl-C stops it dsh bg # detached, log under logs/; waits for HTTP 200 dsh status # pid / port / health dsh stop # SIGTERM, then SIGKILL after 10s dsh restart dsh check # == start-dsh.sh --dump-config dsh logs [n] dsh where ``` Three operational facts it encodes: - **A killed dsh can leave `~/.dsh/settings.yaml.lock` behind**, recording a dead pid. `packages/util/atomic-write` deliberately never steals an existing lock — its comment says *"orphan recovery is an operator action"* — so the next writer blocks and dies with `timed out waiting for the writer lock`. `dsh` is that operator: it removes such a lock only when the pid inside is gone, and only touches locks under `~/.dsh`. - **Boot time is dominated by other bundles, not this one.** `--dump-config` composes in ~12s; mounting the full plugin tree has been measured from 5s to 167s on this machine, tracking the network. `dsh bg` therefore waits up to `DSH_START_TIMEOUT` (default 300) seconds and prints progress instead of declaring failure. - **`lsof -p -iTCP` ORs its selectors** unless `-a` is passed, so it happily reports a neighbouring daemon's port. `dsh` uses `lsof -a -p -iTCP -sTCP:LISTEN`, then confirms by requesting the port rather than trusting the first row. ## Task graph format An array, or `{ "tasks": [...] }`: ```json [ { "id": "types", "title": "Define the shared types", "description": "Add src/types.ts with ...", "acceptanceCriteria": ["tsc --noEmit passes"], "dependencies": [], "priority": 1, "assignee": "openclaude" }, { "id": "impl", "title": "Implement", "description": "...", "dependencies": ["types"] } ] ``` `assignee` names an executor. `openclaude` is registered out of the box; `engine.registerExecutor(...)` adds more, and anything implementing `Executor` works — that seam is why the engine is testable without a model. ## Testing ```sh node test/e2e.mjs # 25 assertions, no model, no OpenClaude install node test/subagent.mjs # 55 assertions, no model; spawns real children, not OpenClaude ``` `test/subagent.mjs` covers the delegation provider in seven blocks — the contract the seam reads, the argv it builds, the cwd rules it enforces, real spawned children (success, failed turn, non-zero exit, stall→resume, bounded recovery, abort, dispose), how attempts fold into the single result the parent receives, how the watchdog classifies output, and the prompt section it announces itself through — plus config normalization. Stall scenarios use a fake `openclaude` script that writes nothing and then exits, so the idle watchdog and the `--resume` loop are exercised for real without a network or a model. The watchdog block is the regression guard for the `--heartbeat` bug: one fake child heartbeats forever and *must still be killed as a stall*, and another reports its own quiet clock past the window and must be killed on that alone. Both fail against the pre-fix build — verified by reverting the classification in a scratch copy and re-running the suite, where exactly those two assertions fail and the other 53 pass. The suite is a defect ledger: each block corresponds to a specific bug in the code this replaces. Highlights: - a 5-task diamond graph completes in full (the old scheduler advanced past the first task and failed the rest); - a project with no test script reports `skipped`, not `passed`; - aborting a run reaches the executor's signal and kills the child process (the old signals were `AbortSignal.any([])` and could never fire); - the generated command line contains `--max-turns`; - `text-delta` chunks are understood; - a decision containing braces inside a string still parses; - a stalled delegation is resumed with `--resume `, and recovery stops after `maxStalledRestarts` instead of looping forever; - a child that only heartbeats is caught as a stall rather than being reported as a successful run that produced no output (`--heartbeat` used to disarm the watchdog by re-arming it on every line), and a run that keeps reporting activity is still ended by `maxRuntimeMs`; - the delegation guidance is registered once at order 116.6, is silent for an agent that does not have the tool, and *survives* a same-name collision by logging instead of throwing — a throw there would fail the row and take the tool with it; - a provider built with a partial config does not throw on the first `.trim()`. Two of the assertions exist because the first suite was too permissive. Its fake context handed `commands` over as an ordinary property, so it accepted code that a real boot rejects (`cannot get property "commands" without inject`). There is now a fixture that throws on an undeclared service read — the way Cordis actually behaves — plus one with no `inject` at all, which pins down that the plugin still provides its service in a composition with no command adapter. ## Bugs fixed relative to the four-package version | # | Defect | Consequence | |---|---|---| | P0-1 | `const completed = new Set()` was never advanced, while `getReady()` required membership | one task ran, then every remaining task failed as a "deadlock" | | P0-2 | `auto-check.ts` wrapped `Bun.file` / `Bun.spawn` calls in empty `catch {}`; `Bun` does not exist on Node | every probe threw, was swallowed, and the all-green initial value was returned — auto-acceptance was a constant and the supervisor never ran | | P1-3 | `AbortSignal.any([])` in three places | an empty composite is connected to nothing, so cancellation never fired and `cancel()` was a no-op stub | | P1-4 | `dsh-supervision/src/index.ts` mutated engine state through `ctx.orchestrator['store']` | supervision is now an advisor that returns a decision; the engine applies it | | P2-5 | `getProjectOverview()` returned `'Project overview not implemented yet.'`, `getRelevantFiles()` returned `[]`, `contextStrategy` was never read | every executor prompt claimed the project was unknown | | — | `--max-steps 50` on every invocation | commander rejects it (`error: unknown option '--max-steps'`) before the model is called; the real flag is `--max-turns` | | — | a non-zero exit was raised as a *stream* error | the caller never reached `exitCode()`, so the real exit code was replaced by an opaque stderr string | | — | `chunk.type === 'text'` in the facade's three LLM helpers | the real chunk type is `'text-delta'`, so `clarifyRequest`, `generateDesign` and `decomposeDesign` each returned `''` — an empty requirements document was passed down the pipeline | | — | `cordis.patch.yml` written as `name:` / `provides:` / `config.schema:` | not a loader patch list. The harness rejects it: *"must be a top-level YAML array of loader patch entries"* | | — | `/\\{[\s\S]*\\}/` for decision parsing | greedy and string-blind; a decision containing a code sample failed to parse and silently became an unvalidated `retry` | | — | `splitTask` left the parent `blocked` and did not rewire dependents | the parent could neither complete nor be scheduled, and tasks depending on it raced its children | | — | `parseArtifacts` scraped filenames out of prose | a model could report files it never wrote; artifacts are now verified against the filesystem | | — | the idle watchdog re-armed on **any** stdout/stderr byte, and `--heartbeat 30s` emits one every 30s | the timer was reset forever, so a frozen child was never killed and the `--resume` self-heal never fired. Observed live: a delegation sat frozen for over an hour, TCP to the provider static, reported as `running`. The watchdog now measures *progress* (`classifyOutput`) and a separate `maxRuntimeMs` cap bounds the attempt | | — | `const commands = ctx.commands` with `inject = []` | Cordis throws `cannot get property "commands" without inject`, so `apply` failed and the **entire profile** refused to boot. Fixed with a child fiber (`ctx.inject(['commands'], …)`), which keeps the plugin mountable where no command adapter exists. Only a real boot caught this — the test fixture had handed `commands` over as a plain property |