project-autopilot 12.0.0 → 12.0.1

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 (40) hide show
  1. package/README.md +10 -10
  2. package/SKILL.md +1 -3
  3. package/adapters/agents.json +11 -1
  4. package/config.example.json +5 -3
  5. package/config.schema.json +28 -9
  6. package/docs/configuration.md +5 -3
  7. package/package.json +1 -1
  8. package/scripts/adapter-drivers.mjs +209 -22
  9. package/scripts/agent-contract.mjs +24 -2
  10. package/scripts/agent-invocation.mjs +32 -5
  11. package/scripts/autopilot.mjs +787 -222
  12. package/scripts/config-layout.mjs +6 -0
  13. package/scripts/config-migration.mjs +3 -3
  14. package/scripts/config-validation.mjs +12 -4
  15. package/scripts/e2e-detect.mjs +30 -0
  16. package/scripts/escalation-naming.mjs +19 -0
  17. package/scripts/failure-contract.mjs +25 -9
  18. package/scripts/gen-config-schema.mjs +7 -0
  19. package/scripts/idle-hang-watchdog.mjs +34 -7
  20. package/scripts/implementation-baseline.mjs +95 -4
  21. package/scripts/model-discovery.mjs +176 -38
  22. package/scripts/opencode-generation.mjs +116 -0
  23. package/scripts/per-task-budget.mjs +11 -7
  24. package/scripts/plan-review-rounds.mjs +164 -9
  25. package/scripts/planning-boundary.mjs +16 -6
  26. package/scripts/planning-continuation.mjs +43 -23
  27. package/scripts/planning-review-decision.mjs +3 -0
  28. package/scripts/planning-review-wiring.mjs +12 -8
  29. package/scripts/relay-guards.mjs +133 -11
  30. package/scripts/review-canary.mjs +5 -2
  31. package/scripts/role-fallback.mjs +23 -8
  32. package/scripts/run-history.mjs +32 -1
  33. package/scripts/run-observability.mjs +46 -2
  34. package/scripts/setup-discovery.mjs +2 -1
  35. package/scripts/setup.mjs +183 -81
  36. package/scripts/skill-copy.mjs +49 -0
  37. package/scripts/task-requirements.mjs +24 -0
  38. package/scripts/watch-report.mjs +146 -21
  39. package/scripts/watch-summary.mjs +26 -3
  40. package/scripts/plan-mode-model-note.mjs +0 -27
package/README.md CHANGED
@@ -5,10 +5,15 @@
5
5
 
6
6
  <p align="center">
7
7
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT license"/></a>
8
- <img src="https://img.shields.io/badge/version-12.0.0-informational" alt="Version 12.0.0"/>
8
+ <img src="https://img.shields.io/badge/version-12.0.1-informational" alt="Version 12.0.1"/>
9
9
  <img src="https://img.shields.io/badge/node-%3E%3D18-brightgreen" alt="Node 18 or newer"/>
10
10
  </p>
11
11
 
12
+ <p align="center">
13
+ <img src="docs/media/install.gif" alt="npm install -g project-autopilot, then ap setup in a project: it finds the agent CLIs and saves the settings" width="900"/>
14
+ <br/><sub>Install and set up a project in about a minute (Enter takes the default answers). Then run <code>ap run "your request"</code>.</sub>
15
+ </p>
16
+
12
17
  <p align="center">
13
18
  <img src="docs/media/demo.gif" alt="ap run on a small Node project: the planner, coder, reviewers and tests run in turn until the change passes" width="900"/>
14
19
  <br/><sub>A real 14-minute run, sped up. Left: <code>ap run</code>; right: the live watch report.</sub>
@@ -53,7 +58,7 @@ Needs Node.js 18+, Git and at least one signed-in agent CLI
53
58
  ([details](#get-started)).
54
59
 
55
60
  ```bash
56
- npm install -g github:mmetwally-dev/project-autopilot
61
+ npm install -g project-autopilot
57
62
  cd your-project && ap setup
58
63
  ap run "your request"
59
64
  ```
@@ -113,7 +118,7 @@ On Linux and WSL2 you also need `bubblewrap` for the read-only reviewers
113
118
  **1. Install** — one command, then `ap` works in every terminal:
114
119
 
115
120
  ```bash
116
- npm install -g github:mmetwally-dev/project-autopilot
121
+ npm install -g project-autopilot
117
122
  ```
118
123
 
119
124
  **2. Set up a project** — from the project folder (a Git repository):
@@ -152,6 +157,7 @@ its output to the mission's `orchestrator.log`.
152
157
  - **Update:** run the same `npm install -g …` command again.
153
158
  - **Remove the CLI:** `npm uninstall -g project-autopilot`. To remove Autopilot from
154
159
  one project instead, run `ap uninstall` there (see [Commands](#commands)).
160
+ - **Latest code from GitHub:** `npm install -g github:mmetwally-dev/project-autopilot`.
155
161
  - **From a local folder:** `npm install -g <path-to-project-autopilot>`.
156
162
  - **Developing the skill itself:** run `npm link` once inside the checkout; `ap`
157
163
  then runs that folder, so every edit is live. `npm unlink -g project-autopilot` undoes it.
@@ -570,7 +576,7 @@ GATE (orchestrator) - T1.1 > T1.7 PASS 00:
570
576
  **CONTEXT** — the largest context the session reached. **TURNS** — model turns.
571
577
  `-` means the CLI reports no usage.
572
578
  - **NOTE** — the session number, the number of findings, and warnings such as
573
- `⚠ high usage` (over the role's cap) or `haiku→sonnet !` (see below).
579
+ `⚠ high usage` (over the role's cap).
574
580
 
575
581
  The same folder has `orchestrator.log` (the full log), `gates/` (each gate's
576
582
  output), `handoff/` (hand-off notes), `phase-briefs/` and `runs/` (each agent
@@ -622,12 +628,6 @@ documented fallback list). Before saving, each role is checked: a live model mus
622
628
  be in the live list and unsupported flags are refused. `node scripts/doctor.mjs`
623
629
  flags a documented list older than 180 days.
624
630
 
625
- **Haiku on read-only Claude roles runs as Sonnet.** The read-only roles start
626
- Claude with `--permission-mode plan`, and Claude Code runs plan mode on Sonnet
627
- when Haiku is selected. A reviewer set to `haiku` therefore really runs (and
628
- bills) Sonnet. Setup, `ap config` and `ap run` warn about it, and the report
629
- marks it `haiku→sonnet !`. Haiku as implementer runs as Haiku.
630
-
631
631
  **What really ran.** For every run Autopilot records the exact CLI command and
632
632
  checks before launch that the configured model, effort and variant are really in
633
633
  it. Each run writes a result file (`autopilot-relay.result.v3`) with the
package/SKILL.md CHANGED
@@ -36,7 +36,7 @@ Requires Node.js 18+, Git, one signed-in agent CLI, and `bubblewrap` on
36
36
  Linux/WSL2 for the read-only reviewers.
37
37
 
38
38
  ```bash
39
- npm install -g github:mmetwally-dev/project-autopilot # once; or: node <skill>/bin/ap.cjs …
39
+ npm install -g project-autopilot # once; or: node <skill>/bin/ap.cjs …
40
40
  ap setup # once per project (wizard)
41
41
  ap run "Add a dark-mode toggle to the settings page" # a full mission
42
42
  ```
@@ -253,8 +253,6 @@ here.` and exits 0, without starting the setup wizard.
253
253
  `tests.gate_network` is true.
254
254
  - Keep reviewers independent: never the same model for `code_reviewer` as
255
255
  `implementer`, or for `plan_reviewer` as `planner`, as primary or fallback.
256
- - Haiku on a read-only Claude role runs as Sonnet (plan mode); setup, config and
257
- run warn, and the report shows `haiku→sonnet !`.
258
256
 
259
257
  ### Known Git-guard limitation
260
258
 
@@ -32,8 +32,18 @@
32
32
  "--input-format",
33
33
  "text",
34
34
  "--verbose",
35
+ "--allowedTools",
36
+ "Read",
37
+ "Glob",
38
+ "Grep",
39
+ "Bash",
40
+ "--disallowedTools",
41
+ "Monitor",
42
+ "TaskStop",
43
+ "--settings",
44
+ "{\"env\":{\"CLAUDE_CODE_DISABLE_BACKGROUND_TASKS\":\"1\"}}",
35
45
  "--permission-mode",
36
- "plan"
46
+ "default"
37
47
  ],
38
48
  "model_flag": "--model",
39
49
  "effort_flag": "--effort",
@@ -61,9 +61,9 @@
61
61
  },
62
62
  "plan_reviewer": {
63
63
  "max_turns": 30,
64
- "max_tokens": 150000,
65
- "extra_sessions": 2,
66
- "wrap_up_at_tokens": 135000
64
+ "max_tokens": 250000,
65
+ "extra_sessions": 4,
66
+ "wrap_up_at_tokens": 225000
67
67
  },
68
68
  "implementer": {
69
69
  "max_turns": 40,
@@ -126,6 +126,8 @@
126
126
  "require_reviewer": true,
127
127
  "browser": "native",
128
128
  "require_browser": "when-task-requires",
129
+ "e2e": null,
130
+ "e2e_setup_pending": false,
129
131
  "verify_denylist": []
130
132
  },
131
133
  "safety": {
@@ -783,9 +783,9 @@
783
783
  },
784
784
  "plan_reviewer": {
785
785
  "max_turns": 30,
786
- "max_tokens": 150000,
787
- "extra_sessions": 2,
788
- "wrap_up_at_tokens": 135000
786
+ "max_tokens": 250000,
787
+ "extra_sessions": 4,
788
+ "wrap_up_at_tokens": 225000
789
789
  },
790
790
  "implementer": {
791
791
  "max_turns": 40,
@@ -859,9 +859,9 @@
859
859
  "description": "Caps for the plan reviewer role.",
860
860
  "default": {
861
861
  "max_turns": 30,
862
- "max_tokens": 150000,
863
- "extra_sessions": 2,
864
- "wrap_up_at_tokens": 135000
862
+ "max_tokens": 250000,
863
+ "extra_sessions": 4,
864
+ "wrap_up_at_tokens": 225000
865
865
  },
866
866
  "properties": {
867
867
  "max_turns": {
@@ -873,19 +873,19 @@
873
873
  "max_tokens": {
874
874
  "type": "number",
875
875
  "description": "Largest single-turn context (input plus cached input) this role may use; at the limit the role stops and hands off, then extra_sessions decides whether a new session may continue.",
876
- "default": 150000,
876
+ "default": 250000,
877
877
  "minimum": 1
878
878
  },
879
879
  "extra_sessions": {
880
880
  "type": "number",
881
881
  "description": "Extra sessions this role may start after hitting its turn or token cap; at the limit no further session starts and the mission stops or reports the cap.",
882
- "default": 2,
882
+ "default": 4,
883
883
  "minimum": 0
884
884
  },
885
885
  "wrap_up_at_tokens": {
886
886
  "type": "number",
887
887
  "description": "Token count at which this role is told to wrap up and hand off before max_tokens; at the limit the role writes its hand-off and stops.",
888
- "default": 135000,
888
+ "default": 225000,
889
889
  "minimum": 1
890
890
  }
891
891
  },
@@ -1364,6 +1364,25 @@
1364
1364
  "items": {
1365
1365
  "type": "string"
1366
1366
  }
1367
+ },
1368
+ "e2e": {
1369
+ "type": [
1370
+ "string",
1371
+ "null"
1372
+ ],
1373
+ "description": "End-to-end test tool for UI projects: playwright, cypress or none. Unset (null) = setup asks once; treated as none until answered.",
1374
+ "default": null,
1375
+ "enum": [
1376
+ "playwright",
1377
+ "cypress",
1378
+ "none",
1379
+ null
1380
+ ]
1381
+ },
1382
+ "e2e_setup_pending": {
1383
+ "type": "boolean",
1384
+ "description": "True while an end-to-end tool setup is still pending for the project; unset or false means nothing is pending.",
1385
+ "default": false
1367
1386
  }
1368
1387
  },
1369
1388
  "additionalProperties": false,
@@ -104,9 +104,9 @@ Per-role turn, token and session caps plus the mission token backstop.
104
104
  | `caps.planner.extra_sessions` | `2` | >= 0 | Extra sessions this role may start after hitting its turn or token cap; at the limit no further session starts and the mission stops or reports the cap. |
105
105
  | `caps.planner.wrap_up_at_tokens` | `180000` | >= 1 | Token count at which this role is told to wrap up and hand off before max_tokens; at the limit the role writes its hand-off and stops. |
106
106
  | `caps.plan_reviewer.max_turns` | `30` | >= 1 | Most model turns this role may use in one session; at the limit the role stops and hands off, then extra_sessions decides whether a new session may continue. |
107
- | `caps.plan_reviewer.max_tokens` | `150000` | >= 1 | Largest single-turn context (input plus cached input) this role may use; at the limit the role stops and hands off, then extra_sessions decides whether a new session may continue. |
108
- | `caps.plan_reviewer.extra_sessions` | `2` | >= 0 | Extra sessions this role may start after hitting its turn or token cap; at the limit no further session starts and the mission stops or reports the cap. |
109
- | `caps.plan_reviewer.wrap_up_at_tokens` | `135000` | >= 1 | Token count at which this role is told to wrap up and hand off before max_tokens; at the limit the role writes its hand-off and stops. |
107
+ | `caps.plan_reviewer.max_tokens` | `250000` | >= 1 | Largest single-turn context (input plus cached input) this role may use; at the limit the role stops and hands off, then extra_sessions decides whether a new session may continue. |
108
+ | `caps.plan_reviewer.extra_sessions` | `4` | >= 0 | Extra sessions this role may start after hitting its turn or token cap; at the limit no further session starts and the mission stops or reports the cap. |
109
+ | `caps.plan_reviewer.wrap_up_at_tokens` | `225000` | >= 1 | Token count at which this role is told to wrap up and hand off before max_tokens; at the limit the role writes its hand-off and stops. |
110
110
  | `caps.implementer.max_turns` | `40` | >= 1 | Most model turns this role may use in one session; at the limit the role stops and hands off, then extra_sessions decides whether a new session may continue. |
111
111
  | `caps.implementer.max_tokens` | `180000` | >= 1 | Largest single-turn context (input plus cached input) this role may use; at the limit the role stops and hands off, then extra_sessions decides whether a new session may continue. |
112
112
  | `caps.implementer.extra_sessions` | `5` | >= 0 | Extra sessions this role may start after hitting its turn or token cap; at the limit no further session starts and the mission stops or reports the cap. |
@@ -184,6 +184,8 @@ Gate commands, reviewer requirement and verification policy.
184
184
  | `tests.browser` | `"native"` | `"native"`, `"playwright"`, `"both"` | Browser capability used for verification: native (the agent's own browser), playwright or both. |
185
185
  | `tests.require_browser` | `"when-task-requires"` | `"always"`, `"when-task-requires"`, `"never"` | How browser capabilities are required for the verifier: always adds them to every call; when-task-requires and never add them only when the task itself needs a browser. |
186
186
  | `tests.verify_denylist` | `[]` | — | Commands or paths that a task or verification step may never run; empty adds no bans beyond the built-in full-suite ban. |
187
+ | `tests.e2e` | `null` | `"playwright"`, `"cypress"`, `"none"`, `null` | End-to-end test tool for UI projects: playwright, cypress or none. Unset (null) = setup asks once; treated as none until answered. |
188
+ | `tests.e2e_setup_pending` | `false` | — | True while an end-to-end tool setup is still pending for the project; unset or false means nothing is pending. |
187
189
 
188
190
  ### safety
189
191
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "project-autopilot",
3
- "version": "12.0.0",
3
+ "version": "12.0.1",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -9,6 +9,8 @@ import {equivalentEventPath, eventReferencesPath} from "./event-paths.mjs";
9
9
  import {decodeAgyEvents, observeAgyEvents, accumulateAgyUsage, agyRunModelPatch, writeAgyAllowRule} from "./agy-events.mjs";
10
10
  import {resolveRunModel} from "./model-extract.mjs";
11
11
  import {updateRunModel} from "./run-observability.mjs";
12
+ import {FREE_QUOTA_REASON, OPENCODE_TITLE_MODEL_LINE_RE} from "./idle-hang-watchdog.mjs";
13
+ import {detectOpenCodeGeneration, splitOpenCodeModelSuffix} from "./opencode-generation.mjs";
12
14
 
13
15
  const DRIVERS = Object.freeze({
14
16
  codex: codexDriver(),
@@ -76,6 +78,11 @@ function resolveOpenCodeBinary(binary) {
76
78
  function opencodeInvocation(request) {
77
79
  // --print-logs: the only place opencode reports the model it ran (#107).
78
80
  const args = ["run", "--print-logs", "--format", "json"];
81
+ // 2.x resolves config-defined agents (autopilot_readonly lives in
82
+ // OPENCODE_CONFIG_CONTENT) only in a private server, so every run role on
83
+ // that generation needs --standalone.
84
+ const generation = detectOpenCodeGeneration({binary: request.binary});
85
+ if (generation === "2") args.push("--standalone");
79
86
  if (["write", "scratch"].includes(request.mode)) args.push("--auto");
80
87
  // Not the native "plan" agent: OpenCode 1.18.29's built-in "plan" agent
81
88
  // carries an unconditional `edit "*": deny` in its own resolved permission
@@ -89,8 +96,14 @@ function opencodeInvocation(request) {
89
96
  // repository containment still comes from bubblewrap's read-only bind, not
90
97
  // from this permission block.
91
98
  else args.push("--agent", "autopilot_readonly");
92
- if (request.model) args.push("--model", request.model);
93
- if (request.reasoning) args.push("--variant", request.reasoning);
99
+ // 2.x carries the effort level as provider/model#level (there is no
100
+ // --variant); 1.x keeps the flag it was verified against.
101
+ const effort = generation === "2" && request.reasoning;
102
+ if (effort && !request.model) throw new Error("OpenCode 2.x reasoning/effort requires a selected model.");
103
+ if (request.model || effort) {
104
+ args.push("--model", effort ? `${request.model}#${request.reasoning}` : request.model);
105
+ }
106
+ if (request.reasoning && generation !== "2") args.push("--variant", request.reasoning);
94
107
  return {binary: request.binary, args};
95
108
  }
96
109
 
@@ -99,7 +112,12 @@ function opencodeAppliedSelections(invocation) {
99
112
  const index = invocation.args.indexOf(flag);
100
113
  return index >= 0 ? invocation.args[index + 1] : null;
101
114
  };
102
- return {model: pick("--model"), reasoning: pick("--variant")};
115
+ const model = pick("--model");
116
+ if (model && String(model).includes("#")) {
117
+ const split = splitOpenCodeModelSuffix(model);
118
+ return {model: split.model, reasoning: split.reasoning};
119
+ }
120
+ return {model, reasoning: pick("--variant")};
103
121
  }
104
122
 
105
123
  // Containment inside the trusted envelope comes from bubblewrap, not from any
@@ -158,7 +176,7 @@ const SENTINEL_TOKEN = "AUTOPILOT_REVIEW";
158
176
  // (file_read_*, write_*, outside_write_*, git_mutation_*, command_*,
159
177
  // timeout_child_command_started, permission_denied).
160
178
  function observeOpenCodeEvents(events, probe = {}) {
161
- const observations = [...detectOpenCodeRateLimited(events), ...detectOpenCodeProviderError(events)];
179
+ const observations = [...detectOpenCodeRateLimited(events), ...detectOpenCodeProviderError(events), ...detectOpenCodeAgentNotFound(events)];
162
180
  const emit = (type, event, details = {}) => observations.push({type, source: "cli_event", ...details, event});
163
181
  for (let index = 0; index < events.length; index += 1) {
164
182
  const event = events[index];
@@ -249,22 +267,59 @@ function observeOpenCodeEvents(events, probe = {}) {
249
267
  return observations;
250
268
  }
251
269
 
252
- // Structured opencode provider error events only (never prose): the run's
253
- // `{"type":"error","error":{"name":"APIError","data":{"message":...,
254
- // "statusCode":429}}}` event (2026-10-13 incident B). Rate limit, quota
255
- // ("Insufficient account funds") and the free tier's "Endpoint is
256
- // unavailable" are all the same operator-visible state: RATE_LIMITED.
257
- const OPENCODE_RATE_LIMIT_MESSAGE = /rate[_\s]limit[_\s]exceeded|endpoint is unavailable|insufficient account funds/i;
270
+ // opencode provider errors (never prose). Two channels carry them:
271
+ // - the structured stdout error event `{"type":"error","error":{"name":
272
+ // "APIError","data":{"message":...,"statusCode":429}}}` (2026-10-13
273
+ // incident B);
274
+ // - opencode's own logfmt ERROR line on stderr (mission 2026-10-28 backlog
275
+ // #14: `error.error="AI_APICallError: Free usage limit reached ..."`, no
276
+ // 429 status at all).
277
+ // Rate limit, quota ("Insufficient account funds") and the free tier's
278
+ // "Endpoint is unavailable"/"Free usage limit reached" are all the same
279
+ // operator-visible state: RATE_LIMITED. The 1.x stderr branch requires the
280
+ // engine error source token (`AI_APICallError`/`AI_RetryError`); 2.x logfmt
281
+ // ERROR lines instead carry `cause=`/`role=server` (real 2.2.0 shape, see
282
+ // scripts/test-fixtures/opencode-2x-run-bad-model.stderr.txt). Either way the
283
+ // line must come from stderr, so model prose or tool output containing the
284
+ // same words never matches.
285
+ const OPENCODE_FREE_QUOTA_MESSAGE = /free usage limit reached|usage limit reached|insufficient[_\s-]?quota|quota[_\s-]?(?:exceeded|exhausted)|subscribe to [^\n]{0,80}to keep going/i;
286
+ const OPENCODE_RATE_LIMIT_MESSAGE = new RegExp(`rate[_\\s]limit[_\\s]exceeded|endpoint is unavailable|insufficient account funds|${OPENCODE_FREE_QUOTA_MESSAGE.source}`, "i");
287
+ const OPENCODE_PROVIDER_ERROR_SOURCE = /AI_APICallError|AI_RetryError/;
288
+ // 2.x's own logfmt ERROR lines are engine-level diagnostics too, but they do
289
+ // not spell the 1.x AI SDK error class. The structural marker (server role or
290
+ // a stack-bearing cause=) keeps the existing 1.x negative control — a no-source
291
+ // line is still not a rate-limit hit — intact.
292
+ const OPENCODE_2X_ENGINE_ERROR_LINE = /\blevel=ERROR\b[^\n]*(?:\brole=server\b|\bcause=)/;
293
+ function openCodeEngineErrorLine(text) {
294
+ return OPENCODE_PROVIDER_ERROR_SOURCE.test(text) || OPENCODE_2X_ENGINE_ERROR_LINE.test(text);
295
+ }
258
296
  function detectOpenCodeRateLimited(events) {
259
297
  const observations = [];
260
298
  for (const event of events) {
261
- if (!event || typeof event !== "object" || event.type !== "error") continue;
299
+ if (!event || typeof event !== "object") continue;
300
+ if (event.type === "stderr") {
301
+ const text = String(event.text ?? "");
302
+ // Backlog #107: the small title-generation model's stream errors
303
+ // (small=true agent=title) say nothing about the role's own model, so a
304
+ // completed run must never be reclassified RATE_LIMITED from one.
305
+ if (OPENCODE_TITLE_MODEL_LINE_RE.test(text)) continue;
306
+ if (!openCodeEngineErrorLine(text) || !OPENCODE_RATE_LIMIT_MESSAGE.test(text)) continue;
307
+ observations.push(observation("rate_limited", event, {
308
+ reason: OPENCODE_FREE_QUOTA_MESSAGE.test(text) ? FREE_QUOTA_REASON : "opencode_stderr_rate_limit",
309
+ message: text.slice(0, 200),
310
+ }));
311
+ continue;
312
+ }
313
+ if (event.type !== "error") continue;
262
314
  const err = event.error && typeof event.error === "object" ? event.error : {};
263
315
  const data = err.data && typeof err.data === "object" ? err.data : {};
264
316
  const status = Number(data.statusCode ?? err.statusCode);
265
317
  const message = String(data.message ?? err.message ?? "");
266
318
  if (status === 429 || OPENCODE_RATE_LIMIT_MESSAGE.test(message)) {
267
- observations.push(observation("rate_limited", event, {reason: status === 429 ? "api_error_status_429" : "opencode_error_message"}));
319
+ const freeQuota = OPENCODE_FREE_QUOTA_MESSAGE.test(message);
320
+ observations.push(observation("rate_limited", event, {
321
+ reason: freeQuota ? FREE_QUOTA_REASON : status === 429 ? "api_error_status_429" : "opencode_error_message",
322
+ }));
268
323
  }
269
324
  }
270
325
  return observations;
@@ -275,24 +330,51 @@ function detectOpenCodeRateLimited(events) {
275
330
  // {"message":"Unexpected server error. ..."}}}` and the run did no tool work
276
331
  // (a failure tied to the brief/model, not to anything the agent did). Not a
277
332
  // rate limit (detectOpenCodeRateLimited keeps precedence in the classifier).
333
+ // 2.x reports an unavailable model as `{"type":"error","error":{"type":
334
+ // "provider.no-route","message":"Model unavailable: provider/model"}}`; that is
335
+ // the same provider/model-side failure class even when tools already ran.
278
336
  const OPENCODE_PROVIDER_ERROR_MESSAGE = /unexpected server error/i;
337
+ const OPENCODE_MODEL_UNAVAILABLE_MESSAGE = /model unavailable/i;
279
338
  function detectOpenCodeProviderError(events) {
280
339
  const hasToolUse = events.some(event => event && typeof event === "object"
281
340
  && (event.type === "tool_use" || event.type === "tool" || event.part?.type === "tool"));
282
- if (hasToolUse) return [];
283
341
  const observations = [];
284
342
  for (const event of events) {
285
343
  if (!event || typeof event !== "object" || event.type !== "error") continue;
286
344
  const err = event.error && typeof event.error === "object" ? event.error : {};
287
345
  const data = err.data && typeof err.data === "object" ? err.data : {};
288
346
  const message = String(data.message ?? err.message ?? "");
289
- if (err.name === "UnknownError" || OPENCODE_PROVIDER_ERROR_MESSAGE.test(message)) {
347
+ const errorType = String(err.type ?? "");
348
+ if (errorType === "provider.no-route" || OPENCODE_MODEL_UNAVAILABLE_MESSAGE.test(message)) {
349
+ observations.push(observation("provider_error", event, {reason: "opencode_model_unavailable", errorType: errorType || null, errorName: err.name ?? null, message: message.slice(0, 200)}));
350
+ continue;
351
+ }
352
+ if (!hasToolUse && (err.name === "UnknownError" || OPENCODE_PROVIDER_ERROR_MESSAGE.test(message))) {
290
353
  observations.push(observation("provider_error", event, {reason: "opencode_unknown_error", errorName: err.name ?? null, message: message.slice(0, 200), ref: data.ref ?? null}));
291
354
  }
292
355
  }
293
356
  return observations;
294
357
  }
295
358
 
359
+ // 2.x without --standalone cannot see an OPENCODE_CONFIG_CONTENT agent:
360
+ // `{"type":"error","error":{"type":"unknown","message":"Agent not found:
361
+ // \"autopilot_readonly\""}}`. That is an invocation/config failure, not an idle
362
+ // hang or a provider limit, so it gets its own structured observation.
363
+ const OPENCODE_AGENT_NOT_FOUND_MESSAGE = /agent not found/i;
364
+ function detectOpenCodeAgentNotFound(events) {
365
+ const observations = [];
366
+ for (const event of events) {
367
+ if (!event || typeof event !== "object" || event.type !== "error") continue;
368
+ const err = event.error && typeof event.error === "object" ? event.error : {};
369
+ const data = err.data && typeof err.data === "object" ? err.data : {};
370
+ const message = String(data.message ?? err.message ?? "");
371
+ if (OPENCODE_AGENT_NOT_FOUND_MESSAGE.test(message)) {
372
+ observations.push(observation("agent_not_found", event, {errorType: String(err.type ?? "") || null, message: message.slice(0, 200)}));
373
+ }
374
+ }
375
+ return observations;
376
+ }
377
+
296
378
  // Whether a later event is a successful tool call, derived from the CLI's own
297
379
  // status plus, where a shell exit was recorded, that exit code — the same
298
380
  // derivation the observer's own `failed` uses. Only successful calls suppress a
@@ -587,6 +669,37 @@ function joinMissingSyntheticExe(originalPath) {
587
669
  return path.join(dir, base + ".synthetic-missing-target.exe");
588
670
  }
589
671
 
672
+ // Claude Code permission-rule syntax is `Tool(//absolute/path/**)`; a single
673
+ // leading `/` is project-relative, so the scratch grant uses two. A read-only
674
+ // role may write ONLY its hand-off note tree. No scratch dir => no Write/Edit
675
+ // grant at all (fail closed), never an unrestricted tool.
676
+ export const CLAUDE_READ_ONLY_ALLOWED_TOOLS = Object.freeze(["Read", "Glob", "Grep", "Bash"]);
677
+
678
+ export function claudeScratchAllowRules(scratchDir) {
679
+ const normalized = String(scratchDir ?? "").trim().replace(/\\/g, "/").replace(/\/+$/, "");
680
+ if (!/^(?:[A-Za-z]:\/|\/)/.test(normalized)) return [];
681
+ if (normalized.split("/").includes("..")) return [];
682
+ const base = `//${normalized.replace(/^\/+/, "")}`;
683
+ return [`Write(${base}/**)`, `Edit(${base}/**)`];
684
+ }
685
+
686
+ export function applyClaudeReadOnlyScratchAllow(args, scratchDir) {
687
+ const out = Array.isArray(args) ? [...args] : [];
688
+ const rules = claudeScratchAllowRules(scratchDir);
689
+ if (!rules.length) return out;
690
+ const at = out.indexOf("--allowedTools");
691
+ if (at < 0) {
692
+ const before = out.indexOf("--permission-mode");
693
+ const insertAt = before >= 0 ? before : out.length;
694
+ out.splice(insertAt, 0, "--allowedTools", ...rules);
695
+ return out;
696
+ }
697
+ let end = at + 1;
698
+ while (end < out.length && !String(out[end]).startsWith("--")) end += 1;
699
+ out.splice(end, 0, ...rules);
700
+ return out;
701
+ }
702
+
590
703
  function claudeInvocation(request) {
591
704
  const args = ["-p"];
592
705
  args.push("--output-format", "stream-json");
@@ -601,7 +714,16 @@ function claudeInvocation(request) {
601
714
  throw new Error("Claude invocation must not carry a pre-existing --permission-mode flag (fail closed: mode decides).");
602
715
  }
603
716
  if (request.mode === "read_only") {
604
- args.push("--permission-mode", "plan");
717
+ // Plan mode refuses EVERY Write, including the sanctioned scratch note a
718
+ // capped reviewer must leave for the next session (backlog #2). Run in
719
+ // default mode with an explicit allow list instead: read tools + Bash,
720
+ // plus Write/Edit scoped to the scratch dir the brief names. The
721
+ // repository stays read-only through bubblewrap (Linux/WSL) or the
722
+ // disposable workspace (Windows), never through the permission mode.
723
+ args.push("--allowedTools", ...CLAUDE_READ_ONLY_ALLOWED_TOOLS, ...claudeScratchAllowRules(request.scratchDir));
724
+ args.push("--disallowedTools", "Monitor", "TaskStop");
725
+ args.push("--settings", CLAUDE_FOREGROUND_ONLY_SETTINGS);
726
+ args.push("--permission-mode", "default");
605
727
  } else if (request.mode === "write" || request.mode === "scratch") {
606
728
  // Least-privileged non-interactive write mode: auto-accept file edits
607
729
  // without the broad bypass of --dangerously-skip-permissions.
@@ -668,7 +790,49 @@ function claudeProviderProfile({binary}) {
668
790
  // for accounting. It does not introduce new containment, safety, or
669
791
  // isolation authority.
670
792
  function observeClaudeEvents(events, probe = {}) {
671
- return [...detectClaudeRateLimited(events), ...observeCodexEvents(events, probe)];
793
+ return [...detectClaudeRateLimited(events), ...detectClaudeAuthRequired(events), ...observeCodexEvents(events, probe)];
794
+ }
795
+
796
+ // Mission 002 runs 09-11 (2026-10-28, a real operator project): the claude CLI
797
+ // ended with a genuine `{"type":"result","is_error":true,...}` line whose
798
+ // `api_error_status` was 401 ("Failed to authenticate: OAuth token revoked.
799
+ // Please log in again…") or null with the same text naming an expired OAuth
800
+ // session. Nothing recognized it, so it fell through to a generic adapter
801
+ // error and was retried until adapterRetryBudget_exhausted — an escalation
802
+ // that never told the operator to log in. Auth is terminal for the CLI
803
+ // itself: no same-adapter retry can succeed.
804
+ //
805
+ // Text is trusted ONLY on the CLI's own terminal result record with
806
+ // `is_error:true` (or an explicit 401 status), never on assistant prose: a
807
+ // model answer that merely discusses "log in again" is not an auth failure.
808
+ // TODO: codex/opencode/agy have no captured auth-failure fixture yet; extend
809
+ // this mapping with their own terminal event shapes when one is observed.
810
+ const CLAUDE_AUTH_REQUIRED_TEXT = /failed to authenticate|oauth token revoked|oauth session expired|please log in again|invalid api key/i;
811
+ const CLAUDE_AUTH_REASON_PATTERNS = [
812
+ /oauth token revoked/i,
813
+ /oauth session expired[^.]*/i,
814
+ /invalid api key/i,
815
+ /please log in again/i,
816
+ /failed to authenticate/i,
817
+ ];
818
+ function detectClaudeAuthRequired(events) {
819
+ const observations = [];
820
+ for (const event of events) {
821
+ if (!event || typeof event !== "object") continue;
822
+ const status = event.api_error_status == null ? null : Number(event.api_error_status);
823
+ const resultFailed = event.type === "result" && event.is_error === true;
824
+ const text = typeof event.result === "string" ? event.result : "";
825
+ if (status !== 401 && !(resultFailed && CLAUDE_AUTH_REQUIRED_TEXT.test(text))) continue;
826
+ const reasonPattern = CLAUDE_AUTH_REASON_PATTERNS.find((pattern) => pattern.test(text));
827
+ const matched = reasonPattern ? text.match(reasonPattern) : null;
828
+ observations.push(observation("auth_required", event, {
829
+ adapter: "claude",
830
+ reason: status === 401 ? "api_error_status_401" : (matched ? matched[0] : "authentication failed"),
831
+ status: Number.isFinite(status) ? status : null,
832
+ message: text.slice(0, 200),
833
+ }));
834
+ }
835
+ return observations;
672
836
  }
673
837
 
674
838
  // Mission 001 runs 27-28: a real Anthropic rate-limit rejection (`{"type":
@@ -927,8 +1091,8 @@ function priceWeightedUsage({input, cacheCreation, cacheRead, output}) {
927
1091
  // together; the breakdown is the point of this function, not the total,
928
1092
  // because per-task dispatch (this task list's reason for existing) changes
929
1093
  // the MIX of these four, not just their sum.
930
- export function accumulateUsage(adapterId, events) {
931
- if (adapterId === "opencode") return accumulateOpenCodeUsage(events);
1094
+ export function accumulateUsage(adapterId, events, options = {}) {
1095
+ if (adapterId === "opencode") return accumulateOpenCodeUsage(events, options);
932
1096
  if (adapterId === "claude") return accumulateClaudeUsage(events);
933
1097
  if (adapterId === "antigravity") return accumulateAgyUsage(events);
934
1098
  return null;
@@ -974,16 +1138,38 @@ export function recordRunModel(dir, adapterId, events, configuredModel = null) {
974
1138
  // component. The return also carries `maxSingleTurnContext`: the largest
975
1139
  // single-step context footprint (`input + cache.read + cache.write`), i.e.
976
1140
  // the current context size rather than the cumulative session total.
977
- function accumulateOpenCodeUsage(events) {
1141
+ //
1142
+ // OpenCode 2.x omits step_finish for the FINAL step (a one-step reply has no
1143
+ // step_finish at all; a tool run's last step has text but no finish), so its
1144
+ // sum is inherently a lower bound. The caller passes the generation detected
1145
+ // at launch; on 2.x a text-bearing step after the last step_finish marks the
1146
+ // result `usage_partial:true` while still summing every step_finish that did
1147
+ // arrive. A 2.x stream with no step_finish at all returns only that flag —
1148
+ // never zeroed/invented token counts. 1.x (and callers with no generation,
1149
+ // e.g. the existing unit tests) keep the exact old null/throw-free behavior.
1150
+ function openCodeTextAfterLastFinish(events, lastFinishIndex) {
1151
+ for (let index = lastFinishIndex + 1; index < events.length; index += 1) {
1152
+ const event = events[index];
1153
+ const part = event?.part || {};
1154
+ const type = String(event?.type || part.type || "");
1155
+ if (type !== "text") continue;
1156
+ if (String(part.text ?? event?.text ?? "").trim() !== "") return true;
1157
+ }
1158
+ return false;
1159
+ }
1160
+ function accumulateOpenCodeUsage(events, {generation = null} = {}) {
978
1161
  let input = 0, output = 0, cacheCreation = 0, cacheRead = 0, turns = 0, seen = false;
979
1162
  let maxSingleTurnContext = 0;
980
- for (const event of events) {
1163
+ let lastFinishIndex = -1;
1164
+ for (let index = 0; index < events.length; index += 1) {
1165
+ const event = events[index];
981
1166
  const part = event?.part || {};
982
1167
  const type = String(event?.type || part.type || "");
983
1168
  if (type !== "step_finish" && type !== "step-finish") continue;
984
1169
  const tokens = part.tokens;
985
1170
  if (!tokens || typeof tokens !== "object") continue;
986
1171
  seen = true;
1172
+ lastFinishIndex = index;
987
1173
  turns += 1;
988
1174
  input += Number(tokens.input) || 0;
989
1175
  output += (Number(tokens.output) || 0) + (Number(tokens.reasoning) || 0);
@@ -994,9 +1180,10 @@ function accumulateOpenCodeUsage(events) {
994
1180
  + (Number(tokens.cache?.write) || 0);
995
1181
  if (turnContext > maxSingleTurnContext) maxSingleTurnContext = turnContext;
996
1182
  }
997
- if (!seen) return null;
1183
+ const partial = generation === "2" && openCodeTextAfterLastFinish(events, lastFinishIndex);
1184
+ if (!seen) return partial ? {usage_partial: true} : null;
998
1185
  const usage = {input, cacheCreation, cacheRead, output, turns, maxSingleTurnContext};
999
- return {...usage, priceWeighted: priceWeightedUsage(usage)};
1186
+ return {...usage, priceWeighted: priceWeightedUsage(usage), ...(partial ? {usage_partial: true} : {})};
1000
1187
  }
1001
1188
 
1002
1189
  // Claude Code's stream-json carries a per-Messages-API-shaped `usage` object
@@ -1,4 +1,10 @@
1
- import {discoverCapabilities, discoverModelCatalog, loadDocumentedCatalog} from "./model-discovery.mjs";
1
+ import {
2
+ discoverCapabilities,
3
+ discoverModelCatalog,
4
+ loadDocumentedCatalog,
5
+ modelDiscoveryRetryTimeoutMs,
6
+ modelDiscoveryTimeoutLabel
7
+ } from "./model-discovery.mjs";
2
8
  import {ADAPTER_CONTRACT_VERSION, validateCapabilityDeclaration} from "./capability-contract.mjs";
3
9
 
4
10
  export const ADAPTER_CONTRACT = "autopilot-adapter.v2";
@@ -26,15 +32,31 @@ export function validateAdapterContract(id, adapter) {
26
32
  return {ok: errors.length === 0, errors};
27
33
  }
28
34
 
35
+ // Backlog #5: a cold first start can exceed the normal model-list budget while
36
+ // the CLI is still working. Retry a timed-out list ONCE with double the
37
+ // effective budget before setup/config falls back to the free-text prompt.
38
+ // Unsupported/error results are never retried.
39
+ async function discoverCatalogWithTimeoutRetry(id, adapter) {
40
+ const first = await discoverModelCatalog(adapter);
41
+ if (first?.status !== "timeout") return first;
42
+ const retryMs = modelDiscoveryRetryTimeoutMs();
43
+ const binary = adapter.binary || id;
44
+ console.log(`${binary} is slow to answer (first start can take a while) — trying again for up to ${modelDiscoveryTimeoutLabel(retryMs)}…`);
45
+ return discoverModelCatalog(adapter, {timeoutMs: retryMs});
46
+ }
47
+
29
48
  export async function discoverAgentContract(id, adapter) {
30
49
  const validation = validateAdapterContract(id, adapter);
31
50
  if (!validation.ok) return {contract: ADAPTER_CONTRACT, adapter: id, valid: false, errors: validation.errors, capabilities: null, access: {method: "cli", requires_api_key: false}, models: {status: "failed", source: "contract", items: []}};
32
51
  const capabilities = await discoverCapabilities(adapter);
33
52
  try {
34
- const items = await discoverModelCatalog(adapter);
53
+ const items = await discoverCatalogWithTimeoutRetry(id, adapter);
35
54
  if (Array.isArray(items) && items.length) {
36
55
  return {contract: ADAPTER_CONTRACT, adapter: id, valid: true, errors: [], capabilities, access: {method: "cli", requires_api_key: false, note: "Uses the installed CLI/provider authentication context."}, models: {status: "reported", source: adapter.model_discovery || "cli", items}};
37
56
  }
57
+ if (items?.status === "timeout") {
58
+ return {contract: ADAPTER_CONTRACT, adapter: id, valid: true, errors: [], capabilities, access: {method: "cli", requires_api_key: false}, models: {status: "timeout", source: adapter.model_discovery || "cli", items: []}};
59
+ }
38
60
  } catch (error) {
39
61
  return {contract: ADAPTER_CONTRACT, adapter: id, valid: true, errors: [String(error?.message || error)], capabilities, access: {method: "cli", requires_api_key: false}, models: {status: "failed", source: adapter.model_discovery || "cli", items: []}};
40
62
  }