@yemi33/minions 0.1.2381 → 0.1.2383

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/bin/minions.js +42 -2
  2. package/dashboard/js/refresh.js +8 -2
  3. package/dashboard/js/render-other.js +38 -0
  4. package/dashboard/js/render-work-items.js +61 -20
  5. package/dashboard/js/settings.js +7 -8
  6. package/dashboard/pages/engine.html +6 -0
  7. package/dashboard.js +110 -27
  8. package/docs/README.md +1 -0
  9. package/docs/claude-md-propagation.md +118 -0
  10. package/docs/completion-reports.md +20 -1
  11. package/docs/engine-restart.md +10 -0
  12. package/docs/runtime-adapters.md +54 -22
  13. package/docs/skills.md +2 -0
  14. package/docs/workspace-manifests.md +1 -1
  15. package/docs/worktree-lifecycle.md +23 -0
  16. package/engine/acp-transport.js +63 -35
  17. package/engine/ado-comment.js +3 -1
  18. package/engine/agent-worker-pool.js +11 -4
  19. package/engine/cc-worker-pool.js +4 -2
  20. package/engine/claude-md-context.js +195 -0
  21. package/engine/cli.js +9 -1
  22. package/engine/comment-format.js +51 -14
  23. package/engine/consolidation.js +47 -3
  24. package/engine/db/migrations/026-pre-dispatch-rejections.js +55 -0
  25. package/engine/dispatch.js +80 -23
  26. package/engine/execution-model.js +68 -0
  27. package/engine/gh-comment.js +6 -3
  28. package/engine/github.js +6 -7
  29. package/engine/lifecycle.js +195 -56
  30. package/engine/llm.js +59 -83
  31. package/engine/memory-store.js +3 -1
  32. package/engine/pipeline.js +147 -20
  33. package/engine/playbook.js +39 -6
  34. package/engine/pooled-agent-process.js +28 -26
  35. package/engine/prd-store.js +14 -6
  36. package/engine/quarantine-refs.js +33 -0
  37. package/engine/queries.js +8 -6
  38. package/engine/restart-health.js +69 -4
  39. package/engine/runtimes/claude.js +100 -25
  40. package/engine/runtimes/codex.js +19 -0
  41. package/engine/runtimes/contract.js +239 -0
  42. package/engine/runtimes/copilot.js +85 -8
  43. package/engine/runtimes/index.js +37 -9
  44. package/engine/shared.js +157 -64
  45. package/engine/spawn-agent.js +79 -113
  46. package/engine/spawn-phase-watchdog.js +8 -37
  47. package/engine/supervisor.js +72 -16
  48. package/engine/timeout.js +87 -16
  49. package/engine/untrusted-fence.js +2 -1
  50. package/engine.js +434 -125
  51. package/package.json +1 -1
  52. package/playbooks/fix.md +20 -0
  53. package/playbooks/review.md +2 -0
  54. package/skills/check-self-authored-review-comment/SKILL.md +34 -0
@@ -0,0 +1,118 @@
1
+ # CLAUDE.md propagation to non-Claude runtimes
2
+
3
+ **Work item:** W-mrdon0pe000l045a
4
+ **Config flag:** `engine.propagateClaudeMdForNonClaudeRuntimes` (default `true`)
5
+ **Per-project override:** `project.propagateClaudeMdForNonClaudeRuntimes`
6
+ **Module:** `engine/claude-md-context.js` · **Injection:** `engine/playbook.js`
7
+
8
+ ## Problem
9
+
10
+ `CLAUDE.md` auto-discovery is a **Claude-Code-CLI-only** convention — the `claude`
11
+ binary reads repo-authored `CLAUDE.md` files itself at startup (unless `--bare`).
12
+ The **Copilot** and **Codex** runtimes have **no equivalent auto-load** for
13
+ `CLAUDE.md` at all. So repo-authored `CLAUDE.md` files — which frequently carry
14
+ load-bearing "must be kept in sync" instructions and name blessed tooling — are
15
+ structurally invisible to a Copilot/Codex-based fleet.
16
+
17
+ ### Motivating incident
18
+
19
+ `W-mrdkv6zc00071fef` (a `fluidclientframework.android` dependency bump in
20
+ `office/src`) missed a **third** sync-point file (`settings.gradle.kts:116`) that
21
+ `loop/app/android/android/CLAUDE.md` explicitly warns about, and that
22
+ `updateDeps.bat` (named in that same `CLAUDE.md`) would have caught via its build
23
+ verification step. Neither the implementer nor the reviewer ever saw that file's
24
+ content because Copilot has no `CLAUDE.md` auto-load mechanism. Propagating
25
+ `CLAUDE.md` content directly addresses recurrence of this bug class.
26
+
27
+ ## Relationship to Copilot's native `AGENTS.md` discovery (NOT the same gap)
28
+
29
+ Copilot has its **own native `AGENTS.md` auto-load**, which (post-#817, "Delegate
30
+ repository harness discovery to native runtimes") Minions leaves to the runtime
31
+ CLI itself rather than suppressing or synthesizing.
32
+
33
+ `CLAUDE.md` propagation is **independent and additive**:
34
+
35
+ - `CLAUDE.md` carries **zero split-brain risk** for Copilot/Codex, because those
36
+ runtimes **never read it themselves** — there is nothing to conflict with.
37
+ - Therefore propagation is **orthogonal** to Copilot's native `AGENTS.md`
38
+ discovery: whether or not the runtime loads its own `AGENTS.md` has **no
39
+ effect** on `CLAUDE.md` propagation.
40
+
41
+ | Concern | Owner | What governs it |
42
+ |---------|-------|-----------------|
43
+ | Copilot's OWN `AGENTS.md` auto-load | the Copilot CLI (native) | delegated to the runtime; Minions does not suppress or synthesize it |
44
+ | Repo `CLAUDE.md` invisible to non-Claude runtimes | `propagateClaudeMdForNonClaudeRuntimes` | injects a "Project instructions (CLAUDE.md)" prompt layer |
45
+
46
+ ## Capability flag
47
+
48
+ Each runtime adapter (`engine/runtimes/<name>.js`) declares
49
+ `capabilities.claudeMdNativeDiscovery`:
50
+
51
+ | Runtime | `claudeMdNativeDiscovery` | Propagation |
52
+ |---------|---------------------------|-------------|
53
+ | `claude` | `true` | **skipped** — the Claude CLI already reads CLAUDE.md; re-injecting would double it |
54
+ | `copilot` | `false` | **injected** |
55
+ | `codex` | `false` | **injected** |
56
+
57
+ Unknown runtimes are treated as **not** auto-discovering (propagate), since an
58
+ unrecognized runtime is far more likely to lack native CLAUDE.md loading than to
59
+ have it. Engine code gates on the capability flag, never on `runtime.name`.
60
+
61
+ ## Bounded discovery algorithm
62
+
63
+ `office/src` is a huge monorepo — a full tree walk collecting every `CLAUDE.md`
64
+ is not viable. Instead the module mirrors how a human/tool resolves "nearest
65
+ applicable instructions":
66
+
67
+ 1. **Collect path hints** for the dispatch (`engine.js#_deriveClaudeMdPathHints`):
68
+ `item.meta.workdir` and `item.references[*].path`.
69
+ 2. **Walk UP** from each hint directory toward the **project root**, `stat`-ing
70
+ `CLAUDE.md` at each ancestor level (`discoverClaudeMdFiles`).
71
+ 3. **Always include the project-root `CLAUDE.md`** as a fallback — if there are
72
+ no path hints, at minimum the project root is checked (discovery is never
73
+ skipped entirely).
74
+ 4. **Nearest-wins ordering:** deepest directory first, project root last, so the
75
+ most specific instructions lead.
76
+ 5. **Bounds (cheap by construction):**
77
+ - `MAX_WALK_DEPTH = 24` ancestor levels per hint (deep-path guard),
78
+ - `MAX_FILES = 8` total collected files,
79
+ - path-traversal guard — hints that escape the project root are dropped,
80
+ - the walk **never ascends above the project root**.
81
+
82
+ This only `stat`s `CLAUDE.md` at a small number of ancestor directories, so it
83
+ does not meaningfully slow dispatch even in a monorepo.
84
+
85
+ ## Injection & byte cap
86
+
87
+ `engine/playbook.js#renderPlaybook` adds `CLAUDE.md` propagation as a context
88
+ layer alongside the existing `pinned.md` → `notes.md` →
89
+ `knowledge/agents/<agentId>.md` layers:
90
+
91
+ - Header: `## Project instructions (CLAUDE.md)`, with a short provenance note so
92
+ the agent understands why it appears and that "keep in sync" rules are
93
+ authoritative.
94
+ - Content is wrapped in an `<UNTRUSTED-INPUT>` fence (repo-authored, semi-trusted).
95
+ - Each file is labeled by its repo-relative path (`### <relPath>`).
96
+ - The whole block is capped at `ENGINE_DEFAULTS.maxNotesPromptBytes` (the same
97
+ budget the sibling notes / agent-memory layers use), so it can never balloon a
98
+ prompt on a large monorepo. Individual files are truncated with a
99
+ "read the full file if needed" marker when they exceed the remaining budget.
100
+
101
+ Injection only happens when **all** hold:
102
+
103
+ 1. the resolved runtime's `claudeMdNativeDiscovery` is falsy (Copilot/Codex),
104
+ 2. `resolvePropagateClaudeMdForNonClaudeRuntimes(project, engine)` returns true,
105
+ 3. the project has a `localPath`, and
106
+ 4. at least one non-empty `CLAUDE.md` was discovered.
107
+
108
+ ## Config resolution
109
+
110
+ `shared.resolvePropagateClaudeMdForNonClaudeRuntimes(project, engine)`:
111
+
112
+ 1. per-project boolean `project.propagateClaudeMdForNonClaudeRuntimes`, else
113
+ 2. fleet-wide boolean `engine.propagateClaudeMdForNonClaudeRuntimes`, else
114
+ 3. hardcoded default `true`.
115
+
116
+ Only an explicit boolean counts as "set" at either level (undefined/null/string
117
+ fall through). Editable from **Settings → Copilot Tuning → "Propagate CLAUDE.md
118
+ to non-Claude runtimes"** or via `POST /api/settings`.
@@ -90,6 +90,7 @@ Do **not** invent, regenerate, or share the nonce across dispatches — each spa
90
90
  |---|---|---|
91
91
  | `noop` | boolean | Canonical no-op signal. See [No-op semantics](#no-op-semantics). |
92
92
  | `noopReason` | string | Human-readable rationale shown when `noop: true`. Falls back to `summary` if absent. |
93
+ | `reviewFindingResolution` | object | Required when a review-feedback fix leaves the PR branch unchanged. Shape: `{findingId, disposition, currentCodeEvidence}`; see [Review-fix no-op evidence](#review-fix-no-op-evidence). |
93
94
  | `files_changed` | string \| array | Comma-separated list (or array) of key files changed. |
94
95
  | `affected_files` | string[] | Optional array of relative file paths this dispatch touched or plans to touch. Used by the dispatcher to emit a conflict warning (`WI <new-id> may conflict with in-progress <existing-id> on files: [list]`) when a new WI's `affected_files` overlaps with an in-progress WI's `affected_files`. Logging-only — dispatch is never blocked. Stored back onto the work item after completion so re-dispatches can also participate in overlap detection. |
95
96
  | `tests` | string | `pass`, `fail`, `skipped`, `N/A`, or a free-form note like `skipped — relying on PR pipeline`. |
@@ -223,7 +224,7 @@ All three classes are **never retryable** — they signal a structural/environme
223
224
 
224
225
  ## No-op semantics
225
226
 
226
- A no-op completion declares that the agent correctly **declined** to do the work — the change was already shipped on master, the dispatch premise was wrong, the flagged review comment was an author-note, etc.
227
+ A no-op completion declares that the agent correctly **declined** to do the work — the change was already shipped on master, the dispatch premise was wrong, or an exact review finding was proven resolved/invalid with current-code evidence.
227
228
 
228
229
  To signal a no-op:
229
230
 
@@ -245,6 +246,24 @@ Engine behavior when `noop: true` (`engine/lifecycle.js` `parseCompletionNoop` +
245
246
 
246
247
  Without `noop: true`, an empty PR field will be flagged as a missing-PR-attachment failure and auto-retried up to `ENGINE_DEFAULTS.maxRetries` times.
247
248
 
249
+ ### Review-fix no-op evidence
250
+
251
+ Review-feedback fixes have a stricter no-op contract because all agents can share one platform credential. `viewerDidAuthor: true` does not identify the Minions agent that authored a finding, and reviewer/fixer agent equality is allowed.
252
+
253
+ If the live PR branch did not advance, the completion must set `noop: true` and include:
254
+
255
+ ```json
256
+ {
257
+ "reviewFindingResolution": {
258
+ "findingId": "review-0123456789abcdef",
259
+ "disposition": "already-resolved",
260
+ "currentCodeEvidence": "engine/lifecycle.js:4600 already enforces the condition; test/unit/example.test.js covers it."
261
+ }
262
+ }
263
+ ```
264
+
265
+ The `findingId` is injected into the fix playbook from trusted `minionsReview` provenance. `disposition` is `already-resolved` or `invalid`, and `currentCodeEvidence` must cite a current `file:line` or commit. An identity-only no-op is retried and does not increment `_noOpFixes`.
266
+
248
267
  ## PR-comment follow-ups
249
268
 
250
269
  Fix and review agents can spin off new Minions work items in response to PR
@@ -70,6 +70,16 @@ If an agent is killed as an orphan and the work item retries, cooldowns use expo
70
70
 
71
71
  The engine.js *process itself* (not an agent) can crash and get auto-respawned by three independent mechanisms: `engine/supervisor.js#checkEngine` (dead-PID), `engine/supervisor.js#checkEngineHung` (stale-heartbeat), and `engine/watchdog.js` (OS-scheduled external recovery); `dashboard.js`'s in-process 30s watchdog also auto-restarts on a dead PID (the user-triggered Restart Engine button is excluded). Each successful respawn calls `shared.recordEngineRespawn(source)`, which appends `{ts, source}` to `control.json`'s `engineRespawns[]` (rolling window, default `CRASH_LOOP_WINDOW_MS` = 10 min). Once respawns within the window reach `CRASH_LOOP_THRESHOLD` (default 3), `control.crashLoopAlert` is set (dashboard-visible via `/api/status`) and a deduped (one-per-day) `notes/inbox/` alert is written; the alert clears automatically once the window rolls past the incident. Override the window/threshold via `MINIONS_CRASH_LOOP_WINDOW_MS` / `MINIONS_CRASH_LOOP_THRESHOLD` env vars.
72
72
 
73
+ ### 7. Exclusive Restart Topology
74
+
75
+ Managed dashboard spawns must bind the requested port; automatic fallback remains available only for standalone `node dashboard.js` use. Before a supervisor respawn, every matching stale daemon is killed and confirmed dead. If any PID survives, the supervisor skips the replacement and retries on its next tick instead of creating a second engine or a dashboard on another port.
76
+
77
+ `minions restart` verifies ownership of the exact engine, dashboard, and supervisor PIDs it spawned and rejects duplicate daemon processes. A failed verification tears down the partial stack and leaves `stop-intent.json` set so watchdogs cannot race another replacement into the failed topology.
78
+
79
+ PID-bearing file locks are reaped immediately when their recorded owner is dead. The five-minute stale threshold remains for legacy locks without owner metadata and the longer safety window remains for live holders.
80
+
81
+ Dashboard hot polling avoids recurring event-loop stalls: freshness-directory walks are coalesced briefly, plan scans are cached for 30 seconds and processed in cooperative batches, and quarantine-ref Git reads run asynchronously in parallel.
82
+
73
83
  ## Safe Restart Pattern
74
84
 
75
85
  ```bash
@@ -8,7 +8,8 @@ behavior is hidden behind an adapter object resolved through `resolveRuntime()`.
8
8
 
9
9
  | File | Role |
10
10
  |------|------|
11
- | `engine/runtimes/index.js` | Adapter registry. `resolveRuntime(name)`, `listRuntimes()`, `registerRuntime()`. Engine code MUST go through these. |
11
+ | `engine/runtimes/contract.js` | Versioned adapter contract, capability gating, opaque invocation-option transport, and optional-hook facades. |
12
+ | `engine/runtimes/index.js` | Adapter registry and the only runtime facade imported by orchestration code. |
12
13
  | `engine/runtimes/claude.js` | Claude Code adapter. Owns binary probe, `--system-prompt-file`, JSONL parser, model shorthands, budget cap, bare mode. |
13
14
  | `engine/runtimes/copilot.js` | GitHub Copilot CLI adapter. Owns standalone-vs-`gh-copilot` resolution, stdin-only prompt delivery, `https://api.githubcopilot.com/models` discovery, effort `'max' → 'xhigh'` mapping. |
14
15
  | `engine/runtimes/codex.js` | OpenAI Codex CLI adapter. Owns `@openai/codex`/native binary resolution, `codex exec --json -` prompt delivery, `codex debug models --bundled` discovery, `.agents/skills` roots, and Codex JSONL parsing. |
@@ -30,6 +31,7 @@ methods that genuinely differ.
30
31
 
31
32
  | Member | Type / Returns | Purpose |
32
33
  |--------|----------------|---------|
34
+ | `apiVersion` | number | Contract version. Bundled adapters use `1`; incompatible versions fail at registration. |
33
35
  | `name` | string | Adapter identifier (matches the registry key). |
34
36
  | `capabilities` | flag object (see below) | Feature flags consumed by engine code. |
35
37
  | `resolveBinary({ env, config? })` | `{ bin, native, leadingArgs }` or null | Probe PATH, npm-globals, package overrides; cached to `engine/<name>-caps.json`. `leadingArgs` is `[]` for direct binaries and `['copilot']` for the `gh copilot` extension fallback. |
@@ -39,7 +41,8 @@ methods that genuinely differ.
39
41
  | `modelsCache` | absolute path | Cache file for `listModels()` results. |
40
42
  | `spawnScript` | absolute path | Path to the runtime-agnostic spawn wrapper (currently `engine/spawn-agent.js` for both runtimes). |
41
43
  | `buildArgs(opts)` | `string[]` | CLI args excluding the binary itself. |
42
- | `buildSpawnFlags(opts)` | `string[]` | Optional; engine-side flags consumed by `engine/spawn-agent.js` (overrides the default `_buildAgentSpawnFlags`). |
44
+ | `resolveInvocationOptions({ options, engineConfig, config, failureClass })` | object | Optional; maps harness-specific config and retry policy into semantic invocation options. Core code never reads harness-specific invocation keys. |
45
+ | `buildSpawnFlags(opts)` | `string[]` | Optional compatibility flags for older spawn wrappers. New fields travel in the opaque `--runtime-options` bag automatically. |
43
46
  | `buildPrompt(promptText, sysPromptText)` | string | Final prompt delivered to the CLI. Adapters that lack `--system-prompt-file` (Copilot) prepend a `<system>` block here. |
44
47
  | `getUserAssetDirs({ homeDir })` | `string[]` | Runtime-native global asset roots passed to spawn as `--add-dir` so worktrees still see them. |
45
48
  | `getSkillRoots({ homeDir, project? })` | `[{ scope, dir, projectName? }]` | Where `collectSkillFiles` looks for native + project skill markdown. |
@@ -52,11 +55,39 @@ methods that genuinely differ.
52
55
  | `parseStreamChunk(line)` | event object or null | Single JSONL line → typed event. |
53
56
  | `parseError(rawOutput)` | `{ message, code, retriable }` | Codes: `auth-failure`, `context-limit`, `budget-exceeded`, `model-unavailable` (retriable=true for upstream overload/503; retriable=false for invalid/typo'd model id — Copilot enriches the message via `_warmModelCache()` so it lists the available models), `crash`, null. |
54
57
  | `createStreamConsumer(ctx)` | consumer object | Stream accumulator used by `engine/llm.js`. |
55
- | `detectPermissionGate`, `getPromptDeliveryMode`, `usesSystemPromptFile`, `classifyFailure` | misc | Adapter-owned policy that engine code reads through accessors instead of branching on `runtime.name`. |
56
-
57
- The contract surface is documented inline at the top of `engine/runtimes/claude.js`
58
- (see the JSDoc block at the start of that file). Keep that block authoritative —
59
- this table is the human-readable mirror.
58
+ | `prepareWorkspace(ctx)` | result object | Optional synchronous hook for harness-owned workspace state, such as trust configuration. |
59
+ | `isSpawnStartupEvent(event)` | boolean | Optional startup-event classifier used by the spawn-phase watchdog. |
60
+ | `shouldSuppressPostMutationError(ctx)` | boolean | Optional harness quirk policy for an error received after a confirmed mutation. |
61
+ | `buildWorkerArgs(opts)` | string[] | Required with `acpWorkerPool`; owns persistent-worker CLI flags. |
62
+ | `encodePooledOutput(event)` | string | Required with `acpWorkerPool`; translates semantic pooled events into the adapter's normal parser format. |
63
+ | `detectPermissionGate`, `getPromptDeliveryMode`, `usesSystemPromptFile`, `classifyFailure` | misc | Adapter-owned policy that orchestration reads through the facade instead of branching on `runtime.name`. |
64
+
65
+ `engine/runtimes/contract.js` is authoritative. Registration normalizes legacy
66
+ partial test/tooling adapters, validates bundled adapters strictly, and rejects
67
+ an explicit unsupported `apiVersion`.
68
+
69
+ ### Stable invocation boundary
70
+
71
+ Minions passes semantic values such as `model`, `effort`, and `sessionId`; it
72
+ does not construct harness CLI argv. The selected adapter:
73
+
74
+ 1. maps its config through `resolveInvocationOptions()`;
75
+ 2. receives the full options object through an opaque base64url JSON
76
+ `--runtime-options` channel when the spawn wrapper is used; and
77
+ 3. converts those options to real CLI flags only in `buildArgs()`.
78
+
79
+ Legacy named wrapper flags remain for backward compatibility, but they are no
80
+ longer the extension point. A new harness option can be added entirely inside
81
+ its adapter without teaching `engine.js`, `engine/llm.js`, or
82
+ `engine/spawn-agent.js` another CLI flag. Large image payloads are written to a
83
+ protected per-call sidecar; only its path enters the options argument, and the
84
+ wrapper hydrates then deletes it before invoking the adapter.
85
+
86
+ Retirement gates for the temporary compatibility surfaces are tracked in
87
+ `docs/deprecated.json`: `legacy-runtime-named-spawn-flags`,
88
+ `versionless-runtime-adapter-compat`, and
89
+ `preapprove-workspace-mcps-wrapper`. Calendar dates never override their
90
+ external-usage gates.
60
91
 
61
92
  ## Capability Flags
62
93
 
@@ -81,7 +112,7 @@ real behavioral split appears between adapters.
81
112
  | `resumePromptCarryover` | ✗ | ✓ | ✓ | CC resume turns prepend recent visible Q&A in stdin when the runtime session store is opaque to Minions. |
82
113
  | `resumeBookkeepingTurn` | ✓ | — | — | Claude CLI injects a synthetic "Continue from where you left off." meta turn on `--resume`; CC prompts must tell the model not to treat it as user intent. |
83
114
  | `streamConsumer` | ✓ | ✓ | ✓ | Adapter implements `createStreamConsumer(ctx)` — required by `engine/llm.js` accumulator. |
84
- | `imageInput` | ✓ | ✓ | | Runtime accepts `images: [{mimeType, dataBase64}]` and delivers them to the CLI. Claude: Anthropic Messages base64 content-block envelope via `--input-format stream-json`. Copilot: base64 payloads materialized to tmp files and passed as `--attachment <path>` (W-mqv7324u0021db5d). When false, `_resolveImageOpts` returns a typed `model-unavailable` error. |
115
+ | `imageInput` | ✓ | ✓ | | Runtime accepts `images: [{mimeType, dataBase64}]` and delivers them to the CLI using adapter-owned transport. When false, `_resolveImageOpts` returns a typed `model-unavailable` error. |
85
116
  | `acpWorkerPool` | ✗ | ✓ | ✗ | Runtime supports `<bin> --acp` (Agent Client Protocol), so it can be routed through a persistent worker pool instead of cold-spawning a fresh CLI process per turn/dispatch. Two independent consumers gate on this flag: `engine/cc-worker-pool.js` (CC/doc-chat tabs, `resolveCcUseWorkerPool`) and `engine/agent-worker-pool.js` (fleet agent dispatch, `resolveAgentUseWorkerPool`). Both resolvers hard-return `false` whenever this flag isn't `true` on the resolved runtime, regardless of any operator override, so a future non-ACP runtime can never be silently pooled. See [ACP Worker Pool](#acp-worker-pool-fleet-agent-dispatch) below. |
86
117
 
87
118
  When a behavior is genuinely uniform across all current adapters, it still gets
@@ -197,9 +228,9 @@ agent fleet to Copilot, and vice versa. Both paths fall through to
197
228
  `engine.defaultCli` (the fleet-wide default), but they never see each other's
198
229
  overrides.
199
230
 
200
- Six helpers in `engine/shared.js` are the single source of truth for runtime/model
201
- resolution. Engine code MUST go through them instead of reading config keys
202
- directly.
231
+ The helpers in `engine/shared.js` are the single source of truth for
232
+ runtime/model and cross-runtime policy resolution. Engine code MUST go through
233
+ them instead of reading config keys directly.
203
234
 
204
235
  | Helper | Chain |
205
236
  |--------|-------|
@@ -207,6 +238,7 @@ directly.
207
238
  | `resolveCcCli(engine)` | `engine.ccCli` → `engine.defaultCli` → `'copilot'` |
208
239
  | `resolveAgentModel(agent, engine)` | `agent.model` → `engine.defaultModel` → undefined |
209
240
  | `resolveCcModel(engine)` | `engine.ccModel` → `engine.defaultModel` → undefined |
241
+ | `resolveAgentAllowedTools(config)` | Legacy `config.claude.allowedTools` fleet ceiling → undefined. Despite its legacy namespace, it applies to every selected runtime. |
210
242
  | `resolveAgentMaxBudget(agent, engine)` | `agent.maxBudgetUsd` → `engine.maxBudgetUsd`. Honors literal `0`. |
211
243
  | `resolveAgentBareMode(agent, engine)` | `agent.bareMode` → `engine.claudeBareMode` → false. Strict null check so per-agent `false` overrides engine `true`. |
212
244
 
@@ -285,8 +317,9 @@ codifies are documented in [`docs/harness-propagation.md`](./harness-propagation
285
317
 
286
318
  ## The "No Runtime-Name Branching" Rule
287
319
 
288
- The whole point of this layer: **engine code MUST gate behavior on
289
- `runtime.capabilities.*` flags, not on `runtime.name === 'claude'` comparisons.**
320
+ The whole point of this layer: **orchestration code MUST use the runtime facade,
321
+ capabilities, and adapter hooks rather than concrete imports, harness config
322
+ keys, or `runtime.name === 'claude'` comparisons.**
290
323
 
291
324
  If you find yourself wanting to write `if (runtime.name === 'copilot')` in
292
325
  engine code, the correct response is one of:
@@ -296,19 +329,18 @@ engine code, the correct response is one of:
296
329
  3. Move the special case into the adapter's `buildArgs` / `buildPrompt` /
297
330
  `parseOutput` where it belongs.
298
331
 
299
- Tests in `test/unit/runtime-adapters.test.js` and `test/unit/copilot-adapter.test.js`
300
- enforce the contract; the source-inspection tests in `test/unit.test.js` look
301
- for `resolveRuntime(...)` call sites and reject runtime-name comparisons in new
302
- code.
332
+ Tests in `test/unit/runtime-adapters.test.js` enforce the contract.
333
+ `test/unit/runtime-boundary.test.js` rejects concrete adapter imports,
334
+ runtime-name branches, and harness-specific invocation config in core paths.
303
335
 
304
336
  ## Adding a New Runtime
305
337
 
306
- 1. Create `engine/runtimes/<name>.js` implementing the full adapter contract.
307
- Copy `claude.js` as the template and override only the members that differ.
308
- Set a useful `installHint`.
338
+ 1. Create `engine/runtimes/<name>.js` implementing adapter API version `1`.
339
+ Keep all CLI flags, output schemas, workspace quirks, and worker behavior in
340
+ that file. Set a useful `installHint`.
309
341
  2. Register it in `engine/runtimes/index.js`:
310
342
  ```js
311
- registry.set('<name>', require('./<name>'));
343
+ registerRuntime('<name>', require('./<name>'), { strict: true });
312
344
  ```
313
345
  3. Add unit coverage modeled on `test/unit/runtime-adapters.test.js`.
314
346
 
@@ -324,6 +356,6 @@ flags — never special-case in engine code.
324
356
  - `docs/copilot-cli-schema.md` — empirical Copilot CLI behavior captured during
325
357
  the spike that produced the Copilot adapter; cite it when changing
326
358
  Copilot-specific capability flags.
327
- - `engine/runtimes/claude.js` JSDoc header — authoritative adapter contract.
359
+ - `engine/runtimes/contract.js` — authoritative adapter contract and facade.
328
360
  - `engine/shared.js` `resolveAgentCli` / `resolveCcCli` etc. — the only
329
361
  supported way to read runtime / model selection from config.
package/docs/skills.md CHANGED
@@ -4,6 +4,8 @@ Agents emit a fenced `skill` block when they discover a durable, reusable workfl
4
4
 
5
5
  The engine extracts valid blocks from agent stdout and from inbox findings (`notes/inbox/*.md`) and writes them into the selected runtime's native personal skills directory (e.g. `~/.claude/skills/<name>/SKILL.md` for Claude, the equivalent personal skills directory for Copilot). Project-scoped skills are landed via PR into the matching project's playbook tree.
6
6
 
7
+ Packaged `scope: minions` skills are synchronized into every registered runtime's personal skill directory during `minions init` and upgrades. A packaged skill replaces an existing same-name personal copy so contract fixes take effect immediately.
8
+
7
9
  ## Block format
8
10
 
9
11
  ````
@@ -54,7 +54,7 @@ A config that never mentions `workspace_manifest` produces the exact same dispat
54
54
  | Phase | Location | What it does |
55
55
  | ----- | -------- | ------------ |
56
56
  | **Dispatch — repo gate** | `engine.js spawnAgent()` (right after project resolution) | Calls `shared.agentCanUseRepo(agent, project)`. Mismatch → `completeDispatch(... FAILURE_CLASS.WORKSPACE_MANIFEST_REPO, agentRetryable: false)` and `cleanupTempAgent` runs. Non-retryable because the structural answer is "widen the manifest or pick a different agent". |
57
- | **Spawn — tool merge** | `engine.js spawnAgent()` → `_buildAgentSpawnFlags(..., allowedTools)` | `shared.mergeManifestAllowedTools(claudeConfig.allowedTools, manifest.allowed_tools)` produces the intersection of the runtime baseline and the manifest list. Result is passed as `--allowedTools <csv>` to Claude / Copilot / Codex, so the CLI itself enforces the narrowed surface. Empty manifest list (`[]`) = deny-all. Same merge runs on the steering-resume codepath. |
57
+ | **Spawn — tool merge** | `engine.js spawnAgent()` → `_buildAgentSpawnFlags(..., allowedTools)` | `shared.resolveAgentAllowedTools(config)` preserves the legacy fleet baseline for every selected runtime, then `shared.mergeManifestAllowedTools(baseline, manifest.allowed_tools)` produces the intersection. Result is passed through the Claude / Copilot / Codex adapter option path. Empty manifest list (`[]`) = deny-all. Same resolved option bag is reused on the steering-resume codepath. |
58
58
  | **Agent context** | Future work (playbook.js) | Manifest can be surfaced into the agent prompt so the agent sees its declared scope. Today, `shared.resolveAgentManifest(agent)` returns the resolved struct any caller can read. |
59
59
  | **URL fetch** | Advisory today | `allowed_external_urls` is validated at config load. No runtime helper function — a future runtime adapter hook can wire URL-fetch intercepts against the manifest. |
60
60
  | **Memory scope** | Advisory today | `memory_scope` field is stored in the manifest (`private` = agent only sees its own `knowledge/agents/<id>.md`; `shared` = full team knowledge (current default); `read-only-shared` = reads shared memory but should not write inbox/notes). No runtime enforcement helper — full enforcement at the consolidation layer is future work. |
@@ -47,6 +47,20 @@ recycle worktree dirs across branches.
47
47
  → `git checkout --detach origin/<main>` → mark IDLE.
48
48
  - **State** in SQLite (`worktree_pool`); git ops outside the transaction.
49
49
 
50
+ ## Fresh ordinary branch bases
51
+
52
+ When the pool is disabled or has no usable entry, a brand-new ordinary mutating
53
+ branch is created only after `git fetch origin <mainBranch>` succeeds, and its
54
+ base is always `origin/<mainBranch>`. A network or authentication failure stops
55
+ before `git worktree add` or agent spawn and leaves the work item retryable; the
56
+ engine never falls back to a potentially stale local `<mainBranch>`.
57
+
58
+ This fail-closed rule applies only when the requested branch is confirmed absent
59
+ from origin. PR-targeted / `useExistingBranch`, shared-branch, and retry paths
60
+ that intentionally preserve an existing branch continue using that branch
61
+ unchanged. Warm-pool borrowing keeps its existing fetch-and-checkout of
62
+ `origin/<mainBranch>`.
63
+
50
64
  ## Ownership marker is never "dirty" (#284)
51
65
 
52
66
  The reused-worktree preflight (`assertCleanSharedWorktree`) builds its
@@ -77,6 +91,15 @@ distinguishes the **original worktree-preflight issue** from a
77
91
 
78
92
  ## Quarantine path (dirty / divergent)
79
93
 
94
+ Before quarantine, a reused worktree that is filesystem-clean and strictly
95
+ ahead of `origin/<branch>` is pushed fast-forward to origin. Dispatch close
96
+ and stale-orphan cleanup run the same preservation check before pool return or
97
+ worktree removal. Dirty trees and branches confirmed behind origin retain the
98
+ quarantine fallback; transient probe, fetch, auth, and push failures leave clean
99
+ ahead commits in place for retry. Recoverable
100
+ `refs/minions/quarantine/*` and `refs/minions/quarantine-wip/*` refs are listed
101
+ on Dashboard → Engine under **Quarantined Work**.
102
+
80
103
  When `discoverFromWorkItems` finds a worktree in a `WORKTREE_DIRTY`,
81
104
  `WORKTREE_DIVERGENT`, or post-stuck state, `_quarantineDirtyWorktree`
82
105
  moves it to `<root>/.quarantined/<basename>-<utc>` and lets the next tick
@@ -2,12 +2,12 @@
2
2
  * engine/acp-transport.js — Shared ACP (Agent Client Protocol) transport layer
3
3
  * (P-4c6e8a72, extracted from engine/cc-worker-pool.js).
4
4
  *
5
- * `copilot --acp` is a long-lived JSON-RPC server over stdin/stdout. This
6
- * module owns the protocol-level plumbing that is identical regardless of
7
- * WHO is pooling the worker (a CC/doc-chat tab vs. a fleet agent dispatch):
5
+ * ACP-capable harnesses expose a long-lived JSON-RPC server over stdin/stdout.
6
+ * This module owns protocol plumbing that is identical regardless of which
7
+ * adapter or consumer is using the worker:
8
8
  *
9
- * - process spawn (`spawnAcp`) — resolves the `copilot` binary via the
10
- * runtime adapter and launches it with `--acp`.
9
+ * - process spawn (`spawnAcp`) — delegates binary and argv construction to
10
+ * the selected runtime adapter.
11
11
  * - newline-delimited JSON-RPC framing, request/response correlation, and
12
12
  * `session/update` notification dispatch (the `Worker` class).
13
13
  * - typed error envelope (`ERROR_CODES` / `typedError`).
@@ -45,6 +45,12 @@
45
45
 
46
46
  const { spawn } = require('child_process');
47
47
  const crypto = require('crypto');
48
+ const { normalizeExecutionModel } = require('./execution-model');
49
+ const {
50
+ resolveRuntime,
51
+ resolveRuntimeByCapability,
52
+ buildWorkerArgs,
53
+ } = require('./runtimes');
48
54
 
49
55
  // W-mpmwxni2000c25c7-c — typed error codes the transport emits through every
50
56
  // failure exit so a consumer (CC streaming handler / doc-chat pool wrapper /
@@ -56,8 +62,7 @@ const ERROR_CODES = Object.freeze({
56
62
  // because a transient PATH / fs glitch may recover.
57
63
  WORKER_SPAWN_FAILED: 'worker-spawn-failed',
58
64
  // The worker process exited DURING the ACP handshake (initialize or
59
- // session/new) usually `copilot login` is incomplete or the CLI
60
- // version is too old. Also fires when session/new returns no
65
+ // session/new), or session/new returned no
61
66
  // sessionId. Retriable: the caller swaps to a fallback model / a re-auth
62
67
  // may unblock the next attempt.
63
68
  ACP_HANDSHAKE_FAILED: 'acp-handshake-failed',
@@ -89,25 +94,29 @@ function typedError(message, code, retriable = true) {
89
94
  // same knob instead of hardcoding their own copy.
90
95
  const WARM_MAX_CONCURRENT = 3;
91
96
 
92
- // Real spawn implementation — resolves `copilot` through the runtime adapter
93
- // so every ACP transport consumer gets the SAME native/leadingArgs contract
94
- // as the only other spawn site (engine/spawn-agent.js ~line 595). A bare
95
- // `spawn('copilot', {shell:false})` cannot exec a `.cmd`/`.ps1`/extensionless
96
- // npm shim on Windows — it ENOENTs instantly (copilot issue #2370 /
97
- // W-mqid8uev). resolveBinary() resolves the real JS entry
98
- // (node_modules/@github/copilot/index.js) and reports whether to spawn it
99
- // directly (native) or under the current Node process (shim).
100
- function spawnAcp({ cwd, env } = {}) {
101
- const copilot = require('./runtimes/copilot');
97
+ function resolveWorkerRuntime(runtime) {
98
+ const adapter = typeof runtime === 'string'
99
+ ? resolveRuntime(runtime)
100
+ : (runtime || resolveRuntimeByCapability('acpWorkerPool'));
101
+ if (adapter?.capabilities?.acpWorkerPool !== true) {
102
+ throw new Error(`Runtime "${adapter?.name || '<unknown>'}" does not support ACP workers`);
103
+ }
104
+ return adapter;
105
+ }
106
+
107
+ // Real spawn implementation. Binary resolution and worker argv are both
108
+ // adapter-owned so harness CLI flag changes do not leak into the transport.
109
+ function spawnAcp({ runtime, cwd, env } = {}) {
110
+ const adapter = resolveWorkerRuntime(runtime);
102
111
  const workerEnv = env || process.env;
103
- const resolved = copilot.resolveBinary({ env: workerEnv });
112
+ const resolved = adapter.resolveBinary({ env: workerEnv });
104
113
  if (!resolved || !resolved.bin) {
105
114
  throw new Error(
106
- `copilot binary not resolvable -- ${copilot.installHint || 'install the GitHub Copilot CLI'}`
115
+ `${adapter.name} binary not resolvable -- ${adapter.installHint || `install the ${adapter.name} CLI`}`
107
116
  );
108
117
  }
109
118
  const { bin, native, leadingArgs = [] } = resolved;
110
- const acpArgs = [...leadingArgs, '--acp', '--allow-all', '--max-autopilot-continues', '1'];
119
+ const acpArgs = [...leadingArgs, ...buildWorkerArgs(adapter, { cwd, env: workerEnv })];
111
120
  // Mirror engine/spawn-agent.js: native binaries run directly; a non-native
112
121
  // (JS entry / npm shim) runs under process.execPath as `node <bin> ...`.
113
122
  const execBin = native ? bin : process.execPath;
@@ -181,6 +190,19 @@ function buildSessionNewParams({ cwd, mcpServers, model, effort }) {
181
190
  return params;
182
191
  }
183
192
 
193
+ function extractSessionModel(result) {
194
+ for (const value of [
195
+ result?.models?.currentModelId,
196
+ result?.currentModelId,
197
+ result?.modelId,
198
+ result?.model,
199
+ ]) {
200
+ const model = normalizeExecutionModel(value);
201
+ if (model) return model;
202
+ }
203
+ return null;
204
+ }
205
+
184
206
  function extractChunkText(content) {
185
207
  if (content == null) return '';
186
208
  if (typeof content === 'string') return content;
@@ -246,9 +268,11 @@ function mapAcpToolCallToToolUse(update) {
246
268
  */
247
269
  class Worker {
248
270
  constructor({
271
+ runtime,
249
272
  id, model, effort, mcpServers, mcpServersHash, processEnv, processContextHash,
250
273
  systemPromptHash, cwd, internals, trace,
251
274
  }) {
275
+ this.runtime = resolveWorkerRuntime(runtime);
252
276
  this.id = id;
253
277
  this.model = model;
254
278
  this.effort = effort;
@@ -283,13 +307,11 @@ class Worker {
283
307
  async _spawnAndInit() {
284
308
  let proc;
285
309
  try {
286
- proc = this._internals.spawnAcp({ cwd: this.cwd, env: this.processEnv });
310
+ proc = this._internals.spawnAcp({ runtime: this.runtime, cwd: this.cwd, env: this.processEnv });
287
311
  } catch (err) {
288
- // spawn() threw synchronously typically ENOENT (copilot binary not
289
- // on PATH) or EACCES. Surface as worker-spawn-failed so the consumer
290
- // can show "install the CLI / fix PATH" guidance.
312
+ const hint = this.runtime.workerErrorHint || `${this.runtime.name} persistent worker failed`;
291
313
  throw typedError(
292
- `copilot --acp failed -- ensure copilot CLI >=1.0.46 and copilot login is complete (${err.message})`,
314
+ `${hint} (${err.message})`,
293
315
  ERROR_CODES.WORKER_SPAWN_FAILED,
294
316
  true
295
317
  );
@@ -307,11 +329,9 @@ class Worker {
307
329
  const earlyExitPromise = new Promise((_, reject) => {
308
330
  earlyExitReject = (code) => {
309
331
  this.killed = true;
310
- // Early exit DURING the handshake = acp-handshake-failed (almost
311
- // always missing `copilot login`, stale CLI, or daemon crash on
312
- // boot). Retriable so re-auth or a CLI upgrade can recover.
332
+ const hint = this.runtime.workerErrorHint || `${this.runtime.name} persistent worker failed`;
313
333
  const err = typedError(
314
- `copilot --acp failed -- ensure copilot CLI >=1.0.46 and copilot login is complete (exit ${code})`,
334
+ `${hint} (exit ${code})`,
315
335
  ERROR_CODES.ACP_HANDSHAKE_FAILED,
316
336
  true
317
337
  );
@@ -327,8 +347,9 @@ class Worker {
327
347
  // proc 'error' event fires when the OS can't actually start the child
328
348
  // (ENOENT after a successful spawn() call, etc.). Treat as a spawn
329
349
  // failure even though we made it past the synchronous spawn() above.
350
+ const hint = this.runtime.workerErrorHint || `${this.runtime.name} persistent worker failed`;
330
351
  const wrapped = typedError(
331
- `copilot --acp failed -- ensure copilot CLI >=1.0.46 and copilot login is complete (${err.message})`,
352
+ `${hint} (${err.message})`,
332
353
  ERROR_CODES.WORKER_SPAWN_FAILED,
333
354
  true
334
355
  );
@@ -350,11 +371,13 @@ class Worker {
350
371
  earlyExitPromise,
351
372
  ]);
352
373
  this.sessionId = result && result.sessionId;
374
+ this.currentModel = extractSessionModel(result) || normalizeExecutionModel(this.model);
353
375
  if (!this.sessionId) {
354
376
  // Handshake completed without an error but the daemon didn't hand
355
377
  // back a sessionId — protocol violation or partial init failure.
378
+ const hint = this.runtime.workerErrorHint || `${this.runtime.name} persistent worker failed`;
356
379
  throw typedError(
357
- 'copilot --acp failed -- session/new returned no sessionId',
380
+ `${hint} (session/new returned no sessionId)`,
358
381
  ERROR_CODES.ACP_HANDSHAKE_FAILED,
359
382
  true
360
383
  );
@@ -369,7 +392,7 @@ class Worker {
369
392
  // Post-handshake exit = the daemon died mid-conversation. Retriable
370
393
  // because the next call will cold-spawn a fresh worker.
371
394
  const err = typedError(
372
- 'copilot --acp process exited',
395
+ `${this.runtime.name} persistent worker process exited`,
373
396
  ERROR_CODES.WORKER_DIED,
374
397
  true
375
398
  );
@@ -563,7 +586,8 @@ class Worker {
563
586
  this._notify('session/cancel', { sessionId: this.inflight.sessionId });
564
587
  }
565
588
 
566
- async newSession({ mcpServers, systemPromptHash, model, effort, cwd }) {
589
+ async newSession(options = {}) {
590
+ const { mcpServers, systemPromptHash, model, effort, cwd } = options;
567
591
  // Cancel any inflight before swapping the underlying session.
568
592
  if (this.inflight) {
569
593
  this.cancel();
@@ -582,8 +606,9 @@ class Worker {
582
606
  // Bug B (issue #2479): if the caller is rotating the session because the
583
607
  // system prompt changed, they may also be passing a fresh model/effort —
584
608
  // update bookkeeping BEFORE session/new so the new fields land on the
585
- // daemon. Falls through to whatever we already had when callers omit them.
586
- if (model !== undefined) this.model = model;
609
+ // daemon. An explicit `model: undefined` clears a prior pooled override and
610
+ // lets the new session select its own default.
611
+ if (Object.prototype.hasOwnProperty.call(options, 'model')) this.model = model;
587
612
  if (effort !== undefined) this.effort = effort;
588
613
  // agent-worker-pool.js reuses a warm worker across dispatches with
589
614
  // different cwds (unlike cc-worker-pool.js, whose tabs never change
@@ -593,6 +618,7 @@ class Worker {
593
618
  cwd: this.cwd, mcpServers: mcpServers || [], model: this.model, effort: this.effort,
594
619
  }));
595
620
  this.sessionId = result && result.sessionId;
621
+ this.currentModel = extractSessionModel(result) || normalizeExecutionModel(this.model);
596
622
  this.systemPromptHash = systemPromptHash;
597
623
  this.firstSystemPromptSent = false;
598
624
  }
@@ -623,11 +649,13 @@ module.exports = {
623
649
  ERROR_CODES,
624
650
  typedError,
625
651
  spawnAcp,
652
+ resolveWorkerRuntime,
626
653
  killImmediate,
627
654
  hashMcpServers,
628
655
  hashProcessContext,
629
656
  buildTrustedSessionContext,
630
657
  buildSessionNewParams,
658
+ extractSessionModel,
631
659
  extractChunkText,
632
660
  mapAcpToolCallToToolUse,
633
661
  Worker,
@@ -106,6 +106,7 @@ async function _defaultAcquireToken() {
106
106
  * @param {string} args.agentId minions agent id (marker)
107
107
  * @param {string} args.kind comment kind (marker)
108
108
  * @param {string} [args.workItemId] originating work-item id (marker)
109
+ * @param {string} [args.model] concrete runtime execution model
109
110
  * @param {number} [args.timeoutMs=30000]
110
111
  * @param {Function} [args.acquireToken] () => Promise<string> — injectable token source
111
112
  * @param {Function} [args.fetchImpl=fetch] injectable fetch
@@ -120,6 +121,7 @@ async function postAdoPrComment({
120
121
  agentId,
121
122
  kind,
122
123
  workItemId,
124
+ model,
123
125
  resolved = false,
124
126
  timeoutMs = 30000,
125
127
  acquireToken = _defaultAcquireToken,
@@ -131,7 +133,7 @@ async function postAdoPrComment({
131
133
  _validatePrNumber(prNumber);
132
134
 
133
135
  // buildMinionsCommentBody validates marker fields and throws on bad input.
134
- const finalBody = buildMinionsCommentBody({ agentId, kind, workItemId, body });
136
+ const finalBody = buildMinionsCommentBody({ agentId, kind, workItemId, model, body });
135
137
 
136
138
  const token = await acquireToken();
137
139
  if (!token || typeof token !== 'string') {