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
@@ -749,6 +749,10 @@ export function toRuntimeConfig(layout = {}) {
749
749
  else if (has(legacyCapabilities.verification || {}, "browser")) verification.browser = legacyCapabilities.verification.browser;
750
750
  if (has(tests, "require_browser")) verification.require_browser = tests.require_browser;
751
751
  else if (has(legacyCapabilities.verification || {}, "require_browser")) verification.require_browser = legacyCapabilities.verification.require_browser;
752
+ if (has(tests, "e2e")) verification.e2e = tests.e2e;
753
+ else if (has(legacyCapabilities.verification || {}, "e2e")) verification.e2e = legacyCapabilities.verification.e2e;
754
+ if (has(tests, "e2e_setup_pending")) verification.e2e_setup_pending = tests.e2e_setup_pending;
755
+ else if (has(legacyCapabilities.verification || {}, "e2e_setup_pending")) verification.e2e_setup_pending = legacyCapabilities.verification.e2e_setup_pending;
752
756
  if (Object.keys(verification).length) capabilities.verification = verification;
753
757
  if (Object.keys(capabilities).length) out.capabilities = capabilities;
754
758
 
@@ -885,6 +889,8 @@ export function toNewLayoutConfig(runtimeConfig = {}, schemaRef = "./config.sche
885
889
  const verification = isObject(capabilities.verification) ? capabilities.verification : {};
886
890
  if (has(verification, "browser")) layout.tests.browser = verification.browser;
887
891
  if (has(verification, "require_browser")) layout.tests.require_browser = verification.require_browser;
892
+ if (has(verification, "e2e")) layout.tests.e2e = verification.e2e;
893
+ if (has(verification, "e2e_setup_pending")) layout.tests.e2e_setup_pending = verification.e2e_setup_pending;
888
894
  if (has(planning, "verify_denylist")) layout.tests.verify_denylist = planning.verify_denylist;
889
895
 
890
896
  // safety
@@ -226,8 +226,8 @@ function migrateLegacyRuntime(input, existingMeta = {}) {
226
226
  // the policy invalid — a wrap-up at/above its cap, or a non-planner role cap
227
227
  // at/above the global ceiling. Degrade the offending value to its default so
228
228
  // migrateConfig never returns a config validatePolicyConfig rejects (the
229
- // comparisons mirror validatePolicyConfig exactly, planner exempt from the
230
- // global-ceiling check like it is there).
229
+ // comparisons mirror validatePolicyConfig exactly, planner and plan_reviewer
230
+ // exempt from the global-ceiling check like they are there).
231
231
  const effectiveCeiling = Number.isInteger(out.policy.per_task_max_tokens) && out.policy.per_task_max_tokens > 0
232
232
  ? out.policy.per_task_max_tokens
233
233
  : PER_TASK_MAX_TOKENS_DEFAULT;
@@ -239,7 +239,7 @@ function migrateLegacyRuntime(input, existingMeta = {}) {
239
239
  for (const role of reviewRoles) {
240
240
  const roleCaps = out.policy.caps?.[role];
241
241
  if (!roleCaps || typeof roleCaps !== "object" || Array.isArray(roleCaps)) continue;
242
- if (role !== "planner") {
242
+ if (role !== "planner" && role !== "plan_reviewer") {
243
243
  const capTokens = Number(roleCaps.max_tokens);
244
244
  if (Number.isInteger(capTokens) && capTokens > 0 && capTokens >= effectiveCeiling) {
245
245
  roleCaps.max_tokens = PER_ROLE_CAPS_DEFAULT[role].maxTokens;
@@ -260,7 +260,8 @@ export function validatePolicyConfig(policy = {}) {
260
260
  // Mission 031 Phase 1 task 1.4: validate policy.caps per-role entries.
261
261
  // Review roles: planner, plan_reviewer, code_reviewer, verifier.
262
262
  // Each entry requires positive integer max_turns, max_tokens, extra_sessions;
263
- // max_tokens < effective token ceiling; extra_sessions >= 0;
263
+ // max_tokens < effective token ceiling (planner and plan_reviewer exempt);
264
+ // extra_sessions >= 0;
264
265
  // wrap_up_tokens_threshold optional, if present must be < max_tokens for review roles.
265
266
  const caps = policy.caps;
266
267
  if (caps !== undefined) {
@@ -288,12 +289,14 @@ export function validatePolicyConfig(policy = {}) {
288
289
  const maxTokens = roleCaps.max_tokens;
289
290
  if (maxTokens == null || !Number.isInteger(maxTokens) || maxTokens <= 0) {
290
291
  errors.push(`policy.caps.${roleName}.max_tokens must be a positive integer.`);
291
- } else if (maxTokens >= globalCeiling && roleName !== "planner") {
292
+ } else if (maxTokens >= globalCeiling && roleName !== "planner" && roleName !== "plan_reviewer") {
292
293
  // Mission 039 task 1.4: planner is exempt from the sub-ceiling
293
294
  // check. Its code default (200K per-turn) intentionally exceeds the
294
295
  // ~180K implementer-oriented global default, and the relay enforces
295
296
  // planner caps via resolvePerRoleLimits, so the planner cap fires
296
- // at its own max regardless of the global ceiling.
297
+ // at its own max regardless of the global ceiling. Backlog #8:
298
+ // plan_reviewer is exempt for the same reason — its code default is
299
+ // now 250K so a full plan re-check no longer splits at ~135K.
297
300
  errors.push(`policy.caps.${roleName}.max_tokens (${maxTokens}) must be strictly below the effective token ceiling (${globalCeiling}); otherwise the cap can never fire.`);
298
301
  }
299
302
  const extraSessions = roleCaps.extra_sessions;
@@ -564,7 +567,7 @@ const LEGACY_KEY_TARGETS = {
564
567
  role_requirements: "advanced.capabilities.role_requirements",
565
568
  safety_denied: "advanced.capabilities.safety_denied",
566
569
  legacy_text_inference: "advanced.capabilities.legacy_text_inference",
567
- verification: "tests.browser, tests.require_browser",
570
+ verification: "tests.browser, tests.require_browser, tests.e2e",
568
571
  },
569
572
  };
570
573
 
@@ -640,6 +643,8 @@ const TESTS_KEYS = new Set([
640
643
  "require_reviewer",
641
644
  "browser",
642
645
  "require_browser",
646
+ "e2e",
647
+ "e2e_setup_pending",
643
648
  "verify_denylist",
644
649
  ]);
645
650
 
@@ -696,6 +701,7 @@ const FREE_MODELS_KEYS = new Set(["auto_fallback", "max_turns"]);
696
701
  // shipped config.schema.json cannot drift.
697
702
  const BROWSER_VALUES = new Set(SCHEMA_ENUMS["tests.browser"]);
698
703
  const REQUIRE_BROWSER_VALUES = new Set(SCHEMA_ENUMS["tests.require_browser"]);
704
+ const E2E_VALUES = new Set(SCHEMA_ENUMS["tests.e2e"]);
699
705
  const PLANNING_FRAMEWORK_VALUES = SCHEMA_ENUMS["project.planning_framework"];
700
706
 
701
707
  function isObject(value) {
@@ -1002,6 +1008,8 @@ function validateTestsConfig(tests, path, errors, warnings) {
1002
1008
  if (tests.require_reviewer !== undefined) validateType(tests.require_reviewer, "boolean", `${path}.require_reviewer`, errors);
1003
1009
  if (tests.browser !== undefined) validateType(tests.browser, "string", `${path}.browser`, errors, Array.from(BROWSER_VALUES));
1004
1010
  if (tests.require_browser !== undefined) validateType(tests.require_browser, "string", `${path}.require_browser`, errors, Array.from(REQUIRE_BROWSER_VALUES));
1011
+ if (tests.e2e !== undefined && tests.e2e !== null) validateType(tests.e2e, "string", `${path}.e2e`, errors, Array.from(E2E_VALUES));
1012
+ if (tests.e2e_setup_pending !== undefined) validateType(tests.e2e_setup_pending, "boolean", `${path}.e2e_setup_pending`, errors);
1005
1013
  if (tests.verify_denylist !== undefined) {
1006
1014
  if (!Array.isArray(tests.verify_denylist)) {
1007
1015
  errors.push(`${path}.verify_denylist: must be an array.`);
@@ -0,0 +1,30 @@
1
+ // Detects whether a repo is a UI project and which e2e tool it already uses.
2
+ // Reads BOTH dependencies and devDependencies of package.json.
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+
6
+ const UI_PACKAGES = ["react", "next", "vue", "svelte", "angular", "@angular/core"];
7
+ const E2E_TOOLS = [
8
+ ["@playwright/test", "playwright"],
9
+ ["cypress", "cypress"],
10
+ ];
11
+
12
+ function readDeps(repo) {
13
+ try {
14
+ const pkg = JSON.parse(fs.readFileSync(path.join(repo, "package.json"), "utf8"));
15
+ return { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
16
+ } catch {
17
+ return {};
18
+ }
19
+ }
20
+
21
+ export function detectUiProject(repo) {
22
+ const deps = readDeps(repo);
23
+ return UI_PACKAGES.some((name) => Object.hasOwn(deps, name));
24
+ }
25
+
26
+ export function detectE2eTool(repo) {
27
+ const deps = readDeps(repo);
28
+ for (const [name, tool] of E2E_TOOLS) if (Object.hasOwn(deps, name)) return tool;
29
+ return null;
30
+ }
@@ -50,6 +50,25 @@ export function decideEscalationName({failureClass, retryable, used, limit, budg
50
50
  : "RATE_LIMITED is not retryable; resume with `ap run` after the limit lifts";
51
51
  return {step: "rate_limited_escalation", detail};
52
52
  }
53
+ // The CLI's own credentials are unusable. Same-adapter retry (and any
54
+ // same-adapter fallback) cannot fix it; the operator must log the CLI in
55
+ // again. The adapter/binary and the CLI's own reason travel on the auth
56
+ // observation's evidence.
57
+ if (failureClass === "AUTH_REQUIRED") {
58
+ let adapter = null;
59
+ let reason = null;
60
+ if (Array.isArray(evidence)) {
61
+ for (const entry of evidence) {
62
+ if (!entry || typeof entry !== "object") continue;
63
+ if (adapter == null && typeof entry.adapter === "string" && entry.adapter !== "") adapter = entry.adapter;
64
+ if (reason == null && typeof entry.reason === "string" && entry.reason !== "") reason = entry.reason;
65
+ if (adapter != null && reason != null) break;
66
+ }
67
+ }
68
+ const cli = adapter || "the CLI";
69
+ const why = reason ? ` (${reason})` : "";
70
+ return {step: "auth_required_escalation", detail: `${cli} is logged out${why} — run \`${cli}\` and log in, then \`ap run\``};
71
+ }
53
72
  return {step: "non_retryable_failure", detail: `${failureClass} is not retryable`};
54
73
  }
55
74
  if (budget == null) {
@@ -1,4 +1,4 @@
1
- import {renderFinding} from "./review-feedback.mjs";
1
+ import {normalizeFindings, renderFinding} from "./review-feedback.mjs";
2
2
 
3
3
  export const FAILURE_SCHEMA = "autopilot-failure.v1";
4
4
 
@@ -11,7 +11,7 @@ export const FAILURE_CLASSES = Object.freeze([
11
11
  "PERMISSION_DENIED", "PATH_UNSUPPORTED", "INVOCATION_MISMATCH",
12
12
  "STRUCTURED_OUTPUT_MISSING", "STRUCTURED_OUTPUT_INVALID",
13
13
  "READ_ONLY_VIOLATION", "GIT_MUTATION_BLOCKED", "GIT_COMMIT_DETECTED",
14
- "COMMAND_FAILED", "SERVER_UNAVAILABLE", "RATE_LIMITED", "PROVIDER_ERROR", "IDLE_HANG", "IMPLEMENTATION_REJECTED",
14
+ "COMMAND_FAILED", "SERVER_UNAVAILABLE", "RATE_LIMITED", "AUTH_REQUIRED", "PROVIDER_ERROR", "IDLE_HANG", "IMPLEMENTATION_REJECTED",
15
15
  "VERIFICATION_REJECTED", "PLANNING_REJECTED", "INTERNAL_CONTRACT_ERROR"
16
16
  ]);
17
17
 
@@ -107,6 +107,9 @@ export function parseReviewContract(output) {
107
107
  // Backlog #118: optional per-previous-finding fixed/not_fixed record
108
108
  // (code-review rounds 2+). Tolerant: absent/malformed -> [].
109
109
  previous: normalizePreviousStatuses(review.previous),
110
+ // Backlog #8: optional minor notes from a plan review. They never
111
+ // fail the review; strings only, bounded like findings.
112
+ notes: normalizeFindings(Array.isArray(review.notes) ? review.notes.filter(note => typeof note === "string") : []),
110
113
  },
111
114
  failure: null
112
115
  };
@@ -211,9 +214,20 @@ export function classifyObservedFailure(observation) {
211
214
  // 4. Structured terminal rate limit — a CLI-protocol status field, not
212
215
  // text scraped from prose, so it is authoritative regardless of exit code.
213
216
  if (eventTypes.has("rate_limited")) return failure("RATE_LIMITED", "infrastructure", false, evidence);
214
- // 4b. Backlog #106: structured provider-side error (opencode UnknownError).
215
- // Retryable (one same-model retry may be spent) but also a role-fallback
216
- // trigger like RATE_LIMITED, so a configured fallback is tried first.
217
+ // 4a. The CLI itself is logged out (claude 401 / revoked or expired OAuth).
218
+ // Non-retryable and adapter-domain: retrying the same CLI cannot restore
219
+ // credentials. Placed beside RATE_LIMITED, before every generic adapter
220
+ // error, so it can never degrade to a retryable PROCESS_EXIT_NONZERO.
221
+ if (eventTypes.has("auth_required")) return failure("AUTH_REQUIRED", "adapter", false, evidence);
222
+ // 4b. OpenCode 2.x without --standalone cannot resolve the config-defined
223
+ // agent ("Agent not found: ..."). That is an invocation/config failure, not
224
+ // a provider limit and not an idle hang: name it explicitly and do not
225
+ // retry the same invocation.
226
+ if (eventTypes.has("agent_not_found")) return failure("INVOCATION_MISMATCH", "adapter", false, evidence);
227
+ // 4c. Backlog #106: structured provider-side error (opencode UnknownError,
228
+ // and 2.x provider.no-route / "Model unavailable"). Retryable (one
229
+ // same-model retry may be spent) but also a role-fallback trigger like
230
+ // RATE_LIMITED, so a configured fallback is tried first.
217
231
  if (eventTypes.has("provider_error")) return failure("PROVIDER_ERROR", "infrastructure", true, evidence);
218
232
  // 5. Structured failed permission/path/server evidence — trusted because
219
233
  // it comes from the adapter's own canonicalized tool-call outcome
@@ -259,7 +273,7 @@ export function classifyObservedFailure(observation) {
259
273
  // error line (which stays PROCESS_EXIT_NONZERO); only silence or explicit
260
274
  // limit text is reclassified, so the normal fallback path runs instead of
261
275
  // burning the adapter retry budget and stopping.
262
- const OPENCODE_LIMIT_TEXT = /(?:\b429\b|rate[_\s-]?limit|too many requests|insufficient[_\s-]?(?:quota|funds)|quota[_\s-]?(?:exceeded|exhausted))/i;
276
+ const OPENCODE_LIMIT_TEXT = /(?:\b429\b|rate[_\s-]?limit|too many requests|insufficient[_\s-]?(?:quota|funds)|quota[_\s-]?(?:exceeded|exhausted)|free usage limit reached|usage limit reached|subscribe to [^\n]{0,80}to keep going)/i;
263
277
  export function reclassifySilentOpenCodeFailure(observed, { adapter, output, stderr } = {}) {
264
278
  if (!observed || observed.class !== "PROCESS_EXIT_NONZERO" || adapter !== "opencode") return observed;
265
279
  const answer = String(output || "").trim();
@@ -318,7 +332,7 @@ export function verdictFailure(roleName, verdict) {
318
332
  // deliberately absent here — so a warning-only observation set on a clean
319
333
  // exit classifies to null below and the relay mapping falls through to the
320
334
  // missing-sentinel STRUCTURED_OUTPUT_MISSING path.
321
- const TRUSTED_FAILURE_EVENT_TYPES = new Set(["rate_limited", "provider_error", "permission_denied", "path_unsupported", "server_unavailable"]);
335
+ const TRUSTED_FAILURE_EVENT_TYPES = new Set(["rate_limited", "auth_required", "agent_not_found", "provider_error", "permission_denied", "path_unsupported", "server_unavailable"]);
322
336
  // Reviewer FAIL verdicts live in the failure class (their findings are plain
323
337
  // strings, not typed events), so they are exempt from the evidence check.
324
338
  const VERDICT_FAILURE_CLASSES = new Set(["IMPLEMENTATION_REJECTED", "VERIFICATION_REJECTED", "PLANNING_REJECTED"]);
@@ -630,8 +644,10 @@ export function recordFailureBudget(state, policy, observedFailure, scope) {
630
644
  const limit = positiveInteger(policy?.failure_budgets?.[name], defaultBudget(name));
631
645
  // RATE_LIMITED escalates immediately without consuming the budget: the
632
646
  // counter is never incremented, so the key stays unset and every such
633
- // failure reports allowed:false.
634
- if (observedFailure?.class === "RATE_LIMITED") return {allowed: false, budget: name, used: 0, limit, scope};
647
+ // failure reports allowed:false. AUTH_REQUIRED is the same shape — logging
648
+ // in again is an operator action, not a retry — so it must not burn the
649
+ // adapter budget either.
650
+ if (observedFailure?.class === "RATE_LIMITED" || observedFailure?.class === "AUTH_REQUIRED") return {allowed: false, budget: name, used: 0, limit, scope};
635
651
  const key = `${name}:${scope}`;
636
652
  state.failureBudgets = state.failureBudgets || {};
637
653
  state.failureBudgets[key] = (state.failureBudgets[key] || 0) + 1;
@@ -132,6 +132,8 @@ const DESCRIPTIONS = {
132
132
  "tests.require_reviewer": "Intended to require a reviewer verdict before a phase passes. Fixed in this version; changing it has no effect.",
133
133
  "tests.browser": "Browser capability used for verification: native (the agent's own browser), playwright or both.",
134
134
  "tests.require_browser": "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.",
135
+ "tests.e2e": "End-to-end test tool for UI projects: playwright, cypress or none. Unset (null) = setup asks once; treated as none until answered.",
136
+ "tests.e2e_setup_pending": "True while an end-to-end tool setup is still pending for the project; unset or false means nothing is pending.",
135
137
  "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.",
136
138
 
137
139
  safety: "What roles may do and which safety checks are enforced.",
@@ -227,6 +229,7 @@ export const ENUMS = {
227
229
  "runtime.mode": ["auto", "linux-strong", "wsl2-strong", "windows-compatibility"],
228
230
  "tests.browser": ["native", "playwright", "both"],
229
231
  "tests.require_browser": ["always", "when-task-requires", "never"],
232
+ "tests.e2e": ["playwright", "cypress", "none"],
230
233
  "roles.*.cli": CLI_VALUES,
231
234
  "roles.*.reasoning": REASONING_VALUES,
232
235
  "roles.*.fallbacks[].cli": CLI_VALUES,
@@ -426,6 +429,10 @@ export function buildConfigSchema() {
426
429
  for (const role of ESCALATION_MODEL_ROLES) {
427
430
  properties.roles.properties[role].properties.escalation_model = escalationModelSchema();
428
431
  }
432
+ // Schema-only keys: not in DEFAULTS, so they stay out of config.example.json.
433
+ // Unset (null) means never asked; setup/config asks the operator once.
434
+ properties.tests.properties.e2e = {type: ["string", "null"], description: descriptionFor("tests.e2e"), default: null, enum: [...enumFor("tests.e2e"), null]};
435
+ properties.tests.properties.e2e_setup_pending = {type: "boolean", description: descriptionFor("tests.e2e_setup_pending"), default: false};
429
436
  return {
430
437
  $schema: "https://json-schema.org/draft/2020-12/schema",
431
438
  $id: "https://project-autopilot.dev/config.schema.json",
@@ -76,6 +76,10 @@ export const RATE_LIMIT_CONFIRM_MATCHES = 1;
76
76
  export const RATE_LIMIT_CONFIRM_GAP_MS = 60_000;
77
77
  // Bounded log reads: only appended bytes are ever scanned, capped.
78
78
  export const RATE_LIMIT_LOG_TAIL_BYTES = 256 * 1024;
79
+ // The reason value a free-tier quota/billing rejection records. Exported so
80
+ // call sites (autopilot.mjs, adapter-drivers.mjs) never hand-build the
81
+ // literal; it is a label on the unchanged RATE_LIMITED path, not a new class.
82
+ export const FREE_QUOTA_REASON = "free_quota_exhausted";
79
83
 
80
84
  // opencode's OWN log (logfmt), not model prose: `AI_APICallError` and
81
85
  // `AI_RetryError` are engine-level error values. Observed live in
@@ -85,8 +89,26 @@ export const RATE_LIMIT_LOG_TAIL_BYTES = 256 * 1024;
85
89
  // exceeded. Please retry after a brief wait."
86
90
  // Bare AI_RetryError ALSO fires for non-rate-limit errors ("Cannot connect
87
91
  // to API"), so the rate-limit marker is required in both branches.
88
- const RATE_LIMIT_MARKER = /rate[_\s]limit[_\s]exceeded|endpoint is unavailable|insufficient account funds|status_?code[=:"\s]+429\b/i;
92
+ // Backlog #14: a free-tier quota rejection has no status code at all:
93
+ // error.error="AI_APICallError: Free usage limit reached. Free models work
94
+ // in OpenCode (https://opencode.ai); subscribe to OpenCode Go to keep
95
+ // going: https://opencode.ai/go"
96
+ // It is the same operator-visible state as a 429 (stop, switch model), so
97
+ // the marker covers quota/billing exhaustion too. The source check below
98
+ // still requires an engine error token, so model prose never matches.
99
+ const FREE_QUOTA_MARKER = /free usage limit reached|usage limit reached|insufficient[_\s-]?quota|quota[_\s-]?(?:exceeded|exhausted)|subscribe to [^\n]{0,80}to keep going/i;
100
+ const RATE_LIMIT_MARKER = new RegExp(`rate[_\\s]limit[_\\s]exceeded|endpoint is unavailable|insufficient account funds|status_?code[=:"\\s]+429\\b|${FREE_QUOTA_MARKER.source}`, "i");
101
+ // Backlog #107: the small title-generation model's stream errors
102
+ // (small=true agent=title) say nothing about the role's own model. Shared
103
+ // with adapter-drivers.mjs so both stderr scanners ignore the same lines.
104
+ export const OPENCODE_TITLE_MODEL_LINE_RE = /\bsmall=true\b|\bagent=title\b/;
89
105
  const RATE_LIMIT_SOURCE = /AI_APICallError|AI_RetryError/;
106
+ // 2.x logfmt ERROR lines do not name the 1.x AI SDK error class; they carry
107
+ // server-role/cause structure instead (real 2.2.0 capture in
108
+ // scripts/test-fixtures/opencode-2x-run-bad-model.stderr.txt). Accept those
109
+ // too, but only as a logfmt line — the JSON-stdout and no-source guards in
110
+ // rateLimitLineInfo() below still keep model prose/tool text out.
111
+ const OPENCODE_2X_ENGINE_ERROR_LINE = /\blevel=ERROR\b[^\n]*(?:\brole=server\b|\bcause=)/;
90
112
  const PROVIDER_ID_RE = /providerID=([^\s]+)/;
91
113
  const MODEL_ID_RE = /modelID=([^\s]+)/;
92
114
  const LOG_TIMESTAMP_RE = /timestamp=([0-9T:.\-+Z]+)/;
@@ -131,11 +153,15 @@ export function rateLimitLineInfo(line) {
131
153
  const text = String(line ?? "");
132
154
  // Backlog #107: the small title-generation model's stream errors (small=true
133
155
  // agent=title) say nothing about the role's own model.
134
- if (/\bsmall=true\b|\bagent=title\b/.test(text)) return null;
135
- if (!RATE_LIMIT_SOURCE.test(text) || !RATE_LIMIT_MARKER.test(text)) return null;
156
+ if (OPENCODE_TITLE_MODEL_LINE_RE.test(text)) return null;
157
+ // Only opencode's own logfmt ERROR lines count. A JSON event is the model's
158
+ // stdout channel (tool text can quote a log line verbatim), never the
159
+ // engine log, so it must not be mistaken for a provider rejection.
160
+ if (/^\s*\{/.test(text)) return null;
161
+ if (!(RATE_LIMIT_SOURCE.test(text) || OPENCODE_2X_ENGINE_ERROR_LINE.test(text)) || !RATE_LIMIT_MARKER.test(text)) return null;
136
162
  const provider = text.match(PROVIDER_ID_RE)?.[1] ?? null;
137
163
  const model = text.match(MODEL_ID_RE)?.[1] ?? null;
138
- return {provider, model};
164
+ return {provider, model, reason: FREE_QUOTA_MARKER.test(text) ? FREE_QUOTA_REASON : "rate_limit"};
139
165
  }
140
166
 
141
167
  // Newest *.log under <baseDir>/log/ (opencode rotates there). Null when the
@@ -590,11 +616,12 @@ export function formatIdleHangMessage({role, adapter, model, idleMs, maxSilence
590
616
  return `IDLE_HANG: ${who} produced no output, ran no command and wrote no file for ${formatIdleMinutes(idleMs)}`;
591
617
  }
592
618
 
593
- export function formatRateLimitedMessage({role, adapter, model, provider}) {
619
+ export function formatRateLimitedMessage({role, adapter, model, provider, freeQuota = false}) {
594
620
  const modelLabel = model || adapter;
595
621
  const providerLabel = provider && provider !== adapter ? `${provider}/${modelLabel}` : modelLabel;
622
+ const quotaLabel = freeQuota ? " — free quota used up" : "";
596
623
  return (
597
- `RATE_LIMITED: ${role} (${adapter}, ${model || "unknown model"}) hit a provider rate limit (${providerLabel}); ` +
624
+ `RATE_LIMITED: ${role} (${adapter}, ${model || "unknown model"}) hit a provider rate limit${quotaLabel} (${providerLabel}); ` +
598
625
  `opencode retried silently in its own log. The operator may need to wait or re-login ` +
599
626
  `(\`opencode auth login\` inside WSL — the WSL and Windows opencode accounts are separate).`
600
627
  );
@@ -854,7 +881,7 @@ export function evaluateIdleWatch(watch, {now = Date.now(), platform = process.p
854
881
  /* log tailing is best-effort; the idle watchdog still applies */
855
882
  }
856
883
  if (rateLimitConfirmed(watch, now)) {
857
- return {status: "rate_limited", provider: watch.rateLimitDetail?.provider ?? null, model: watch.rateLimitDetail?.model ?? null};
884
+ return {status: "rate_limited", provider: watch.rateLimitDetail?.provider ?? null, model: watch.rateLimitDetail?.model ?? null, reason: watch.rateLimitDetail?.reason ?? null};
858
885
  }
859
886
  }
860
887
  const quietFor = now - watch.lastActivityAt;
@@ -223,6 +223,60 @@ function manifestPath(repo, key) {
223
223
  return path.join(baselineDir(repo, key), "manifest.json");
224
224
  }
225
225
 
226
+ // The repo-relative path of the NEAREST ancestor of `rel` that is a symlink
227
+ // in the working tree, or null. lstat only, so links are never followed.
228
+ // A path under a symlink is not an independent working-tree entry: the
229
+ // symlink itself is the only real thing on disk, and Git can still report
230
+ // tracked paths beneath it (e.g. committed on Windows with
231
+ // core.symlinks=false) as deleted/untracked. Capture and diff must therefore
232
+ // represent the symlink once and skip its descendants, never read a file
233
+ // "through" the link.
234
+ function symlinkAncestor(repo, rel) {
235
+ const segments = String(rel).split("/").filter(Boolean);
236
+ let cur = repo;
237
+ let curRel = "";
238
+ for (let i = 0; i < segments.length - 1; i++) {
239
+ curRel = curRel ? `${curRel}/${segments[i]}` : segments[i];
240
+ cur = path.join(cur, segments[i]);
241
+ let st;
242
+ try {
243
+ st = fs.lstatSync(cur);
244
+ } catch (e) {
245
+ if (e.code === "ENOENT" || e.code === "ENOTDIR") return null;
246
+ throw new Error(`implementation-baseline: failed to inspect '${cur}': ${e.message}`);
247
+ }
248
+ if (st.isSymbolicLink()) return curRel;
249
+ }
250
+ return null;
251
+ }
252
+
253
+ // The kind of whatever already occupies a baseline snapshot path, or null if
254
+ // nothing does. Defensive: a previous interrupted capture can leave stale
255
+ // snapshot entries behind (the manifest is written last), and recreating a
256
+ // symlink on top of an existing directory otherwise fails with a raw EEXIST.
257
+ function existingSnapshotKind(snapshotPath) {
258
+ let st;
259
+ try {
260
+ st = fs.lstatSync(snapshotPath);
261
+ } catch (e) {
262
+ if (e.code === "ENOENT" || e.code === "ENOTDIR") return null;
263
+ throw new Error(`implementation-baseline: failed to inspect baseline snapshot '${snapshotPath}': ${e.message}`);
264
+ }
265
+ if (st.isSymbolicLink()) return "symlink";
266
+ if (st.isDirectory()) return "directory";
267
+ if (st.isFile()) return "file";
268
+ return "other";
269
+ }
270
+
271
+ function snapshotConflictError(rel, snapshotPath, existingKind) {
272
+ return new Error(
273
+ `implementation-baseline: refusing to overwrite existing baseline snapshot '${snapshotPath}' for repository path '${rel}' (found ${existingKind}). ` +
274
+ `This usually means the working tree has a symlink at or above '${rel}' while Git tracks regular file(s) beneath that path ` +
275
+ `(for example commits made on Windows with core.symlinks=false); the symlink must be recorded once as a symlink and its descendants skipped, ` +
276
+ `never captured as separate files through the link.`
277
+ );
278
+ }
279
+
226
280
  // Symlink-safe classification of a filesystem path: NEVER follows a
227
281
  // symlink to inspect what it points to. Returns one of:
228
282
  // {kind:"absent"} -- nothing at this path
@@ -247,8 +301,11 @@ function classifyEntry(abs) {
247
301
  // Captures the CURRENT dirty/untracked state as the baseline for `key`, but
248
302
  // only the first time it is called for that key — every later call
249
303
  // (a retry of the same phase, or a resumed process) is a no-op, so a
250
- // failed attempt's own changes are never folded into the baseline. Never
251
- // deletes or recreates an existing baseline.
304
+ // failed attempt's own changes are never folded into the baseline. A
305
+ // baseline directory WITHOUT manifest.json is an incomplete capture (the
306
+ // manifest is written last), so its stale snapshot copies are discarded and
307
+ // recaptured rather than layered on top of; a complete baseline is never
308
+ // deleted or recreated.
252
309
  export function ensureBaseline(repo, key, opts={}) {
253
310
  const mission = opts.mission ?? null;
254
311
  const rawDeclared = Array.isArray(opts.declaredPaths) ? opts.declaredPaths : [];
@@ -267,6 +324,11 @@ export function ensureBaseline(repo, key, opts={}) {
267
324
  }
268
325
  const dir = baselineDir(repo, key);
269
326
  const filesDir = path.join(dir, "files");
327
+ // No manifest means an interrupted capture (manifest.json is written
328
+ // LAST). Its snapshot copies may include stale REAL directories where a
329
+ // symlink now belongs, which would otherwise collide on resume. Discard
330
+ // the incomplete snapshot and capture cleanly from the working tree.
331
+ fs.rmSync(filesDir, {recursive: true, force: true});
270
332
  const entries = {};
271
333
  for (const entry of statusEntries(repo)) {
272
334
  recordBaselineEntry(repo, filesDir, entries, entry.path);
@@ -398,6 +460,14 @@ function readManifest(file){
398
460
 
399
461
  function recordBaselineEntry(repo, filesDir, manifest, rel) {
400
462
  if (manifest[rel]) return;
463
+ // Deterministic regardless of the order paths arrive in: any path under a
464
+ // working-tree symlink collapses to that symlink (captured once); the
465
+ // descendant itself is never recorded as a separate file through the link.
466
+ const ancestor = symlinkAncestor(repo, rel);
467
+ if (ancestor) {
468
+ recordBaselineEntry(repo, filesDir, manifest, ancestor);
469
+ return;
470
+ }
401
471
  const abs = path.join(repo, rel);
402
472
  const classified = classifyEntry(abs);
403
473
  if (classified.kind === "absent") {
@@ -405,8 +475,12 @@ function recordBaselineEntry(repo, filesDir, manifest, rel) {
405
475
  return;
406
476
  }
407
477
  if (classified.kind === "file") {
408
- manifest[rel] = {type: "file"};
409
478
  const snapshotPath = path.join(filesDir, rel);
479
+ const existing = existingSnapshotKind(snapshotPath);
480
+ if (existing && existing !== "file") {
481
+ throw snapshotConflictError(rel, snapshotPath, existing);
482
+ }
483
+ manifest[rel] = {type: "file"};
410
484
  fs.mkdirSync(path.dirname(snapshotPath), {recursive: true});
411
485
  fs.copyFileSync(abs, snapshotPath);
412
486
  return;
@@ -419,8 +493,18 @@ function recordBaselineEntry(repo, filesDir, manifest, rel) {
419
493
  // handing this snapshot symlink straight to `git diff --no-index`
420
494
  // later compares target strings safely, with no code in this module
421
495
  // ever opening the pointed-to file on either side.
422
- manifest[rel] = {type: "symlink", target: classified.target};
423
496
  const snapshotPath = path.join(filesDir, rel);
497
+ const existing = existingSnapshotKind(snapshotPath);
498
+ if (existing) {
499
+ let existingTarget = null;
500
+ try { existingTarget = fs.readlinkSync(snapshotPath); } catch { /* not a link */ }
501
+ if (existing === "symlink" && existingTarget === classified.target) {
502
+ manifest[rel] = {type: "symlink", target: classified.target};
503
+ return;
504
+ }
505
+ throw snapshotConflictError(rel, snapshotPath, existing);
506
+ }
507
+ manifest[rel] = {type: "symlink", target: classified.target};
424
508
  fs.mkdirSync(path.dirname(snapshotPath), {recursive: true});
425
509
  fs.symlinkSync(classified.target, snapshotPath);
426
510
  return;
@@ -521,6 +605,13 @@ export function scopedDeltaChunks(repo, key, opts={}) {
521
605
  const allPaths = new Set([...Object.keys(manifestEntries), ...entries.map(e => e.path), ...declaredPaths]);
522
606
  const chunks = [];
523
607
  for (const rel of allPaths) {
608
+ // A path under a working-tree symlink is represented by that symlink
609
+ // alone (see recordBaselineEntry). Git status still lists the tracked
610
+ // files beneath such a path as deleted/untracked, and diffing them
611
+ // "through" the link would invent changes that are really just the
612
+ // pre-existing symlink-vs-tracked-files shape. The symlink's own entry
613
+ // (if any) is processed normally below.
614
+ if (symlinkAncestor(repo, rel)) continue;
524
615
  const baseEntry = manifestEntries[rel] || null;
525
616
  const currentAbs = path.join(repo, rel);
526
617