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
@@ -7,16 +7,37 @@ import { normalizeFindings } from "./review-feedback.mjs";
7
7
  import { normalizePreviousStatuses } from "./code-review-rounds.mjs";
8
8
 
9
9
  const STATE_KEY = "planReviewHistory";
10
+ const NOTES_KEY = "planReviewNotes";
10
11
  const MAX_CUMULATIVE_CHARS = 16000;
11
12
 
13
+ // The operator's severity split (backlog #8): only a problem that would make
14
+ // the plan wrong or unexecutable may FAIL a review. Everything cosmetic or
15
+ // optional is recorded as a note, so a fresh full read cannot keep looping on
16
+ // line anchors and wording nits. Shared by every plan-review brief below.
17
+ export const PLAN_REVIEW_SEVERITY_LINE =
18
+ 'Classify every problem by severity. BLOCKING = wrong behaviour or logic, a missing or contradictory requirement, a wrong API or data contract, an unsafe data or permission path, a requirement or task that cannot be tested or executed, or a test the change will break that the plan does not list. MINOR = line-number anchors, wording or precision, cross-reference nits, style, and nice-to-have extra tests or coverage. Put only BLOCKING problems in "findings"; return FAIL only when there is at least one. List MINOR ones in a separate "notes" array of strings in the final sentinel — they do not fail the review.';
19
+
12
20
  export const PLAN_REVIEW_EXHAUSTIVE_LINE =
13
- "This is review round 1 — make it EXHAUSTIVE: report every problem you can find in this first review, across all categories you check, not one batch at a time. Later rounds will only verify the fixes and the changes made, so anything you leave out now will not be looked for again.";
21
+ `This is review round 1 — make it EXHAUSTIVE: report every problem you can find in this first review, across all categories you check, not one batch at a time. Later rounds will only verify the fixes and the changes made, so anything you leave out now will not be looked for again. ${PLAN_REVIEW_SEVERITY_LINE}`;
14
22
 
15
23
  export const PLAN_REVIEW_VERIFY_LINE =
16
- 'This is a later review round. First verify each previous finding listed below was fixed (go through them one by one) and report the outcome in the final sentinel as "previous":[{"finding":"<the previous finding text, shortened>","status":"fixed"|"not_fixed"}] (one entry per previous finding; keep a not_fixed one in "findings" too). Raise NEW findings only if this revision introduced them, or if they would actually break execution; do not raise new style, preference or nice-to-have findings about content that was already reviewed.';
24
+ `This is a later review round. First verify each previous finding listed below was fixed (go through them one by one) and report the outcome in the final sentinel as "previous":[{"finding":"<the previous finding text, shortened>","status":"fixed"|"not_fixed"}] (one entry per previous finding; keep a not_fixed one in "findings" too). Raise NEW findings only if this revision introduced them, or if they would actually break execution; do not raise new style, preference or nice-to-have findings about content that was already reviewed. ${PLAN_REVIEW_SEVERITY_LINE}`;
17
25
 
18
26
  export const PLAN_REVIEW_FULL_LINE =
19
- "Review the whole plan again from scratch. Report every problem you can find in the current revision, across all categories you check, not just whether the previously reported ones were fixed. Earlier fixes stay in scope: a regression or a newly visible problem anywhere in the plan is a valid finding.";
27
+ `Review the whole plan again from scratch. Report every problem you can find in the current revision, across all categories you check, not just whether the previously reported ones were fixed. Earlier fixes stay in scope: a regression or a newly visible problem anywhere in the plan is a valid finding. ${PLAN_REVIEW_SEVERITY_LINE}`;
28
+
29
+ // A scoped PASS only verified the earlier findings and the changes made since.
30
+ // Before it is accepted, one fresh FULL review must run with no previous
31
+ // findings in its brief — marked so logs/run notes can identify it as the
32
+ // confirmation round. It reviews everything again, but only BLOCKING problems
33
+ // may fail it; minor points are notes, so the confirmation cannot loop on
34
+ // line anchors or wording nits.
35
+ export const PLAN_REVIEW_CONFIRMATION_LINE =
36
+ `This is a confirmation full review after a scoped re-review PASS: no previous findings are listed, so review the whole plan again from scratch, across all categories you check, not one batch at a time. ${PLAN_REVIEW_SEVERITY_LINE}`;
37
+
38
+ const CONFIRMATION_KEY = "planReviewConfirmation";
39
+ const REVISIONS_KEY = "planReviewRevisions";
40
+ const REVIEWER_ROUNDS_KEY = "planReviewReviewerRounds";
20
41
 
21
42
  function slot(state, key) {
22
43
  const list = state?.[STATE_KEY]?.[key];
@@ -25,9 +46,35 @@ function slot(state, key) {
25
46
 
26
47
  export function planReviewRoundCount(state, key) { return slot(state, key).length; }
27
48
 
49
+ // Rounds the plan reviewer actually ran. A deterministic verify-denylist
50
+ // rejection records findings (the planner must fix them) but the reviewer
51
+ // never saw the plan, so that round is NOT a full review: the next reviewer
52
+ // call is effectively the first full review and its PASS needs no
53
+ // confirmation. Kept separate from the findings history so that history can
54
+ // still carry every earlier round to the planner/reviewer.
55
+ export function planReviewReviewerRoundCount(state, key) {
56
+ const used = state?.[REVIEWER_ROUNDS_KEY]?.[key];
57
+ return Number.isInteger(used) && used >= 0 ? used : 0;
58
+ }
59
+
60
+ function recordPlanReviewReviewerRound(state, key) {
61
+ if (!state || typeof state !== "object" || !key) return 0;
62
+ const used = planReviewReviewerRoundCount(state, key) + 1;
63
+ if (!state[REVIEWER_ROUNDS_KEY] || typeof state[REVIEWER_ROUNDS_KEY] !== "object") state[REVIEWER_ROUNDS_KEY] = {};
64
+ state[REVIEWER_ROUNDS_KEY][key] = used;
65
+ return used;
66
+ }
67
+
28
68
  // Append one failed round's findings (bounded, normalized). Empty -> no-op.
29
- export function recordPlanReviewRound(state, key, findings, previous = []) {
69
+ // `reviewer:false` records a deterministic rejection (the reviewer did not
70
+ // run), which must not count towards the confirmation decision nor turn the
71
+ // next reviewer round into a scoped re-review.
72
+ export function recordPlanReviewRound(state, key, findings, previous = [], {reviewer = true} = {}) {
30
73
  if (!state || typeof state !== "object" || !key) return 0;
74
+ // The reviewer ran even when it returned no findings: that still counts
75
+ // for the confirmation decision (a later scoped PASS follows a real full
76
+ // review). Only the findings history is a no-op when empty.
77
+ if (reviewer) recordPlanReviewReviewerRound(state, key);
31
78
  const normalized = normalizeFindings(findings);
32
79
  if (!normalized.length) return planReviewRoundCount(state, key);
33
80
  if (!state[STATE_KEY] || typeof state[STATE_KEY] !== "object") state[STATE_KEY] = {};
@@ -38,14 +85,110 @@ export function recordPlanReviewRound(state, key, findings, previous = []) {
38
85
  }
39
86
 
40
87
  // Resume from older state that has current findings but no history yet.
88
+ // Nothing to seed (no findings) must not look like a reviewer round.
41
89
  export function seedPlanReviewHistory(state, key, findings) {
42
- if (planReviewRoundCount(state, key) === 0) recordPlanReviewRound(state, key, findings);
90
+ if (planReviewRoundCount(state, key) === 0 && normalizeFindings(findings).length) recordPlanReviewRound(state, key, findings);
43
91
  }
44
92
 
45
93
  export function clearPlanReviewHistory(state, key) {
46
- if (!state?.[STATE_KEY] || typeof state[STATE_KEY] !== "object") return;
47
- if (key) delete state[STATE_KEY][key]; else delete state[STATE_KEY];
48
- if (state[STATE_KEY] && !Object.keys(state[STATE_KEY]).length) delete state[STATE_KEY];
94
+ if (state?.[STATE_KEY] && typeof state[STATE_KEY] === "object") {
95
+ if (key) delete state[STATE_KEY][key]; else delete state[STATE_KEY];
96
+ if (!Object.keys(state[STATE_KEY]).length) delete state[STATE_KEY];
97
+ }
98
+ if (state?.[REVIEWER_ROUNDS_KEY] && typeof state[REVIEWER_ROUNDS_KEY] === "object") {
99
+ if (key) delete state[REVIEWER_ROUNDS_KEY][key]; else delete state[REVIEWER_ROUNDS_KEY];
100
+ if (!Object.keys(state[REVIEWER_ROUNDS_KEY]).length) delete state[REVIEWER_ROUNDS_KEY];
101
+ }
102
+ clearPlanReviewNotes(state, key);
103
+ }
104
+
105
+ // Minor notes from the latest plan-review verdict (backlog #8). Bounded and
106
+ // fail-closed like findings: non-strings and unusable entries are dropped.
107
+ export function normalizePlanReviewNotes(raw) {
108
+ return normalizeFindings(Array.isArray(raw) ? raw.filter(note => typeof note === "string") : []);
109
+ }
110
+
111
+ export function planReviewNotes(state, key) {
112
+ const list = state?.[NOTES_KEY]?.[key];
113
+ return Array.isArray(list) ? list : [];
114
+ }
115
+
116
+ // A REVIEW that returned no notes clears the slot, so a later revision never
117
+ // inherits notes from an older, superseded review.
118
+ export function recordPlanReviewNotes(state, key, notes) {
119
+ if (!state || typeof state !== "object" || !key) return [];
120
+ const normalized = normalizePlanReviewNotes(notes);
121
+ if (!state[NOTES_KEY] || typeof state[NOTES_KEY] !== "object") state[NOTES_KEY] = {};
122
+ if (normalized.length) state[NOTES_KEY][key] = normalized;
123
+ else delete state[NOTES_KEY][key];
124
+ if (!Object.keys(state[NOTES_KEY]).length) delete state[NOTES_KEY];
125
+ return normalized;
126
+ }
127
+
128
+ export function planReviewNotesText(state, key) {
129
+ const notes = planReviewNotes(state, key);
130
+ if (!notes.length) return "";
131
+ return `Minor notes (optional, fix if cheap):\n${notes.map((note, i) => `${i + 1}. ${note}`).join("\n")}`;
132
+ }
133
+
134
+ export function clearPlanReviewNotes(state, key) {
135
+ if (!state?.[NOTES_KEY] || typeof state[NOTES_KEY] !== "object") return;
136
+ if (key) delete state[NOTES_KEY][key]; else delete state[NOTES_KEY];
137
+ if (state[NOTES_KEY] && !Object.keys(state[NOTES_KEY]).length) delete state[NOTES_KEY];
138
+ }
139
+
140
+ // A PASS that only came after earlier FULL reviewer rounds is a SCOPED
141
+ // re-review: it verified the fixes, it did not look at the rest of the plan
142
+ // again. Such a PASS needs the one full confirmation review below before it
143
+ // is accepted. A deterministic (denylist) rejection is not a full reviewer
144
+ // round — the reviewer never ran — so the next reviewer PASS is simply the
145
+ // first full review and is accepted without confirmation. With convergence
146
+ // disabled every round is already a full re-review, so no confirmation round
147
+ // is added there.
148
+ export function planReviewPassNeedsConfirmation(state, key, converge = true) {
149
+ return converge !== false && planReviewReviewerRoundCount(state, key) > 0;
150
+ }
151
+
152
+ // Persisted "the next review is the confirmation full review" marker. It must
153
+ // survive a kill during the confirmation call: on resume the loop reads it and
154
+ // re-runs the full confirmation review instead of accepting the scoped PASS.
155
+ export function planReviewConfirmationPending(state, key) {
156
+ return Boolean(state?.[CONFIRMATION_KEY]?.[key] === true);
157
+ }
158
+
159
+ export function markPlanReviewConfirmationPending(state, key) {
160
+ if (!state || typeof state !== "object" || !key) return;
161
+ if (!state[CONFIRMATION_KEY] || typeof state[CONFIRMATION_KEY] !== "object") state[CONFIRMATION_KEY] = {};
162
+ state[CONFIRMATION_KEY][key] = true;
163
+ }
164
+
165
+ export function clearPlanReviewConfirmation(state, key) {
166
+ if (!state?.[CONFIRMATION_KEY] || typeof state[CONFIRMATION_KEY] !== "object") return;
167
+ if (key) delete state[CONFIRMATION_KEY][key]; else delete state[CONFIRMATION_KEY];
168
+ if (state[CONFIRMATION_KEY] && !Object.keys(state[CONFIRMATION_KEY]).length) delete state[CONFIRMATION_KEY];
169
+ }
170
+
171
+ // Revision counter for the SpecKit gate loops (standalone keeps its own
172
+ // `standalonePlanReview.revision`). A review FAIL that actually leads to a
173
+ // Planner revision consumes one; a FAIL that escalates (no revision follows)
174
+ // consumes none, and a PASS never consumes one.
175
+ export function planReviewRevisionCount(state, key) {
176
+ const used = state?.[REVISIONS_KEY]?.[key];
177
+ return Number.isInteger(used) && used >= 0 ? used : 0;
178
+ }
179
+
180
+ export function recordPlanReviewRevision(state, key) {
181
+ const used = planReviewRevisionCount(state, key) + 1;
182
+ if (!state || typeof state !== "object" || !key) return used;
183
+ if (!state[REVISIONS_KEY] || typeof state[REVISIONS_KEY] !== "object") state[REVISIONS_KEY] = {};
184
+ state[REVISIONS_KEY][key] = used;
185
+ return used;
186
+ }
187
+
188
+ export function clearPlanReviewRevisions(state, key) {
189
+ if (!state?.[REVISIONS_KEY] || typeof state[REVISIONS_KEY] !== "object") return;
190
+ if (key) delete state[REVISIONS_KEY][key]; else delete state[REVISIONS_KEY];
191
+ if (state[REVISIONS_KEY] && !Object.keys(state[REVISIONS_KEY]).length) delete state[REVISIONS_KEY];
49
192
  }
50
193
 
51
194
  function render(rounds) {
@@ -59,11 +202,23 @@ function render(rounds) {
59
202
  // With convergence disabled every round is a full-plan re-review.
60
203
  export function planReviewerRoundLines(state, key, converge = true) {
61
204
  if (converge === false) return [PLAN_REVIEW_FULL_LINE];
205
+ // The next review is the confirmation full review: no previous findings.
206
+ if (planReviewConfirmationPending(state, key)) return planReviewerConfirmationLines();
62
207
  const rounds = slot(state, key);
63
- if (!rounds.length) return [PLAN_REVIEW_EXHAUSTIVE_LINE];
208
+ // History exists but the plan reviewer has never run (only deterministic
209
+ // denylist rejections): this next call IS the first full review, so give it
210
+ // the exhaustive first-round brief rather than a scoped verification brief.
211
+ if (!rounds.length || planReviewReviewerRoundCount(state, key) === 0) return [PLAN_REVIEW_EXHAUSTIVE_LINE];
64
212
  return [PLAN_REVIEW_VERIFY_LINE, `Previous findings from all earlier review rounds (verify each):\n${render(rounds)}`];
65
213
  }
66
214
 
215
+ // The confirmation round's reviewer brief: a fresh exhaustive full review with
216
+ // NO previous findings listed. Never call planReviewerRoundLines() for it, or
217
+ // it would become another scoped verification round.
218
+ export function planReviewerConfirmationLines() {
219
+ return [PLAN_REVIEW_CONFIRMATION_LINE];
220
+ }
221
+
67
222
  // Planner revision brief: findings of all rounds BEFORE the latest (the latest
68
223
  // is already quoted as the feedback that must be addressed).
69
224
  export function plannerEarlierRoundsText(state, key, converge = true) {
@@ -33,7 +33,7 @@ import path from "node:path";
33
33
  export const CLAUDE_MD_MARKER_BEGIN = "<!-- SPECKIT START -->";
34
34
  export const CLAUDE_MD_MARKER_END = "<!-- SPECKIT END -->";
35
35
 
36
- function normalizeRel(p) {
36
+ export function normalizeRel(p) {
37
37
  return String(p).replaceAll("\\", "/");
38
38
  }
39
39
 
@@ -65,17 +65,27 @@ function specifyAllowed(rel, featureDir) {
65
65
  if (rel.startsWith(`${base}checklists/`) && rel.length > `${base}checklists/`.length) return true;
66
66
  return false;
67
67
  }
68
- function planAllowed(rel, featureDir) {
68
+ function planAllowed(rel, featureDir, revision = false) {
69
69
  if (rel === "CLAUDE.md") return true;
70
70
  if (!featureDir) return false;
71
71
  const base = `specs/${featureDir}/`;
72
72
  if ([`${base}plan.md`, `${base}research.md`, `${base}data-model.md`, `${base}quickstart.md`].includes(rel)) return true;
73
73
  if (rel.startsWith(`${base}contracts/`) && rel.length > `${base}contracts/`.length) return true;
74
+ // A gate revision answering review findings may also repair an EARLIER
75
+ // stage's artifact that a finding cites (spec.md during plan). Same feature
76
+ // dir only; never a later stage's artifact (tasks.md) nor feature.json.
77
+ if (revision && rel === `${base}spec.md`) return true;
74
78
  return false;
75
79
  }
76
- function tasksAllowed(rel, featureDir) {
80
+ function tasksAllowed(rel, featureDir, revision = false) {
77
81
  if (!featureDir) return false;
78
- return rel === `specs/${featureDir}/tasks.md`;
82
+ const base = `specs/${featureDir}/`;
83
+ if (rel === `${base}tasks.md`) return true;
84
+ // A gate revision answering review findings may also repair earlier stages'
85
+ // artifacts that a finding cites (spec.md, plan.md during tasks). Same
86
+ // feature dir only; never feature.json nor CLAUDE.md.
87
+ if (revision && (rel === `${base}spec.md` || rel === `${base}plan.md`)) return true;
88
+ return false;
79
89
  }
80
90
  const STAGE_ALLOW = { specify: specifyAllowed, plan: planAllowed, tasks: tasksAllowed };
81
91
 
@@ -237,7 +247,7 @@ function reasonForRejection(rel, stage, featureDir, preexistingFeatureDirs) {
237
247
  // claudeMdBaseline: CLAUDE.md content captured at session start, or null.
238
248
  export function evaluatePlanningWriteBoundary({
239
249
  stage, touchedFiles = [], preexistingFeatureDirs = [], resolvedFeatureDir = null,
240
- readFile = () => null, claudeMdBaseline = null
250
+ readFile = () => null, claudeMdBaseline = null, revision = false
241
251
  } = {}) {
242
252
  const matcher = STAGE_ALLOW[stage];
243
253
  if (!matcher) throw new Error(`Unknown planning stage: ${stage}`);
@@ -276,7 +286,7 @@ export function evaluatePlanningWriteBoundary({
276
286
  violations.push({ path: rel, reason: "multiple_new_feature_directories" });
277
287
  continue;
278
288
  }
279
- if (!matcher(rel, featureDir)) {
289
+ if (!matcher(rel, featureDir, revision)) {
280
290
  violations.push({ path: rel, reason: reasonForRejection(rel, stage, featureDir, preexistingFeatureDirs) });
281
291
  continue;
282
292
  }
@@ -64,6 +64,10 @@ export { readRolloverDeadEnds };
64
64
  export const PLANNING_CONTINUATION_SCOPES = [
65
65
  "task:standalone:planning:planner",
66
66
  "task:standalone:planning:plan_reviewer",
67
+ "task:speckit:planning:planner",
68
+ "task:speckit:planning:plan_reviewer",
69
+ "task:gsd:planning:planner",
70
+ "task:gsd:planning:plan_reviewer",
67
71
  ];
68
72
 
69
73
  // Shared spent-continuation predicate: true when the scope is a planning
@@ -92,6 +96,44 @@ export function hasSpentPlanningContinuations(s) {
92
96
  return false;
93
97
  }
94
98
 
99
+ // Drop one planning role scope's continuation entries: the
100
+ // continuationBudgetKey(scope) entries from
101
+ // continuationBudgets/noProgressGenerations/rolloverDeadEnds, the bare-scope
102
+ // entry from planningProgressDigests, the backstopLedgerKey(scope) entry from
103
+ // backstopFirings (via the existing clearBackstopFirings), and
104
+ // lastContinuation when it names this scope. Implementer keys, failureBudgets
105
+ // and every OTHER planning scope survive. Returns the number of entries
106
+ // dropped. Called when that role finishes a run, so a new stage starts with a
107
+ // fresh continuation allowance instead of inheriting the previous stage's.
108
+ export function clearPlanningContinuationScope(s, scope) {
109
+ if (!s || typeof s !== "object" || !isPlanningContinuationScope(scope)) return 0;
110
+ let cleared = 0;
111
+ const budgetKey = continuationBudgetKey(scope);
112
+ if (s.continuationBudgets && typeof s.continuationBudgets === "object" && budgetKey in s.continuationBudgets) {
113
+ delete s.continuationBudgets[budgetKey];
114
+ cleared += 1;
115
+ }
116
+ if (s.noProgressGenerations && typeof s.noProgressGenerations === "object" && budgetKey in s.noProgressGenerations) {
117
+ delete s.noProgressGenerations[budgetKey];
118
+ cleared += 1;
119
+ }
120
+ if (s.rolloverDeadEnds && typeof s.rolloverDeadEnds === "object" && budgetKey in s.rolloverDeadEnds) {
121
+ delete s.rolloverDeadEnds[budgetKey];
122
+ cleared += 1;
123
+ }
124
+ if (s.planningProgressDigests && typeof s.planningProgressDigests === "object" && scope in s.planningProgressDigests) {
125
+ delete s.planningProgressDigests[scope];
126
+ cleared += 1;
127
+ }
128
+ if (clearBackstopFirings(s, scope)) cleared += 1;
129
+ if (s.lastContinuation && typeof s.lastContinuation === "object"
130
+ && s.lastContinuation.scope === scope) {
131
+ delete s.lastContinuation;
132
+ cleared += 1;
133
+ }
134
+ return cleared;
135
+ }
136
+
95
137
  // Drop every planning continuation entry: the continuationBudgetKey(scope)
96
138
  // entries from continuationBudgets/noProgressGenerations/rolloverDeadEnds,
97
139
  // the bare-scope entries from planningProgressDigests, the
@@ -103,29 +145,7 @@ export function clearPlanningContinuations(s) {
103
145
  if (!s || typeof s !== "object") return 0;
104
146
  let cleared = 0;
105
147
  for (const scope of PLANNING_CONTINUATION_SCOPES) {
106
- const budgetKey = continuationBudgetKey(scope);
107
- if (s.continuationBudgets && typeof s.continuationBudgets === "object" && budgetKey in s.continuationBudgets) {
108
- delete s.continuationBudgets[budgetKey];
109
- cleared += 1;
110
- }
111
- if (s.noProgressGenerations && typeof s.noProgressGenerations === "object" && budgetKey in s.noProgressGenerations) {
112
- delete s.noProgressGenerations[budgetKey];
113
- cleared += 1;
114
- }
115
- if (s.rolloverDeadEnds && typeof s.rolloverDeadEnds === "object" && budgetKey in s.rolloverDeadEnds) {
116
- delete s.rolloverDeadEnds[budgetKey];
117
- cleared += 1;
118
- }
119
- if (s.planningProgressDigests && typeof s.planningProgressDigests === "object" && scope in s.planningProgressDigests) {
120
- delete s.planningProgressDigests[scope];
121
- cleared += 1;
122
- }
123
- if (clearBackstopFirings(s, scope)) cleared += 1;
124
- }
125
- if (s.lastContinuation && typeof s.lastContinuation === "object"
126
- && isPlanningContinuationScope(s.lastContinuation.scope)) {
127
- delete s.lastContinuation;
128
- cleared += 1;
148
+ cleared += clearPlanningContinuationScope(s, scope);
129
149
  }
130
150
  return cleared;
131
151
  }
@@ -41,6 +41,9 @@
41
41
  const NON_RETRYABLE_ESCALATE_CLASSES = Object.freeze([
42
42
  "PATH_UNSUPPORTED",
43
43
  "PERMISSION_DENIED",
44
+ // The CLI is logged out: another reviewer attempt on the same adapter
45
+ // cannot succeed any more than another path/permission attempt can.
46
+ "AUTH_REQUIRED",
44
47
  ]);
45
48
 
46
49
  // Functional class that is never intercepted here: the caller runs its
@@ -141,15 +141,19 @@ function escalate(recorded,{state,saveState,fatal,escalatePrefix,cause}){
141
141
  fatal(`${escalatePrefix} failed: ${cause} (${recorded.scope}): ${naming.detail}; human intervention required.`);
142
142
  }
143
143
 
144
- // Caller-facing RATE_LIMITED guard (imported by scripts/autopilot.mjs).
145
- // Returns false when the result is not rate-limited; otherwise escalates at
146
- // once through the shared helper above and never returns (the injected
147
- // fatal throws). Forces retryable:false on the recorded-shaped object so the
148
- // shared naming takes its immediate-escalation step even if the relay marked
149
- // the failure retryable, and writes zero failure-budget state.
144
+ // Caller-facing immediate-escalation guard (imported by scripts/autopilot.mjs).
145
+ // Returns false when the result is neither RATE_LIMITED nor AUTH_REQUIRED;
146
+ // otherwise escalates at once through the shared helper above and never
147
+ // returns (the injected fatal throws). Both classes are non-retryable and
148
+ // spend zero failure budget: a provider limit lifts later, and a logged-out
149
+ // CLI can only be fixed by the operator. Forces retryable:false on the
150
+ // recorded-shaped object so the shared naming takes its immediate-escalation
151
+ // step even if the relay marked the failure retryable, and writes zero
152
+ // failure-budget state.
150
153
  export function escalateIfRateLimited(config,state,taskScope,result,{fatal,saveState,escalatePrefix}={}){
151
- if(result?.failure?.class!=="RATE_LIMITED") return false;
152
- const failureRecord={...result.failure,class:"RATE_LIMITED",retryable:false};
154
+ const failureClass=result?.failure?.class;
155
+ if(failureClass!=="RATE_LIMITED"&&failureClass!=="AUTH_REQUIRED") return false;
156
+ const failureRecord={...result.failure,class:failureClass,retryable:false};
153
157
  const scope=failureBudgetScope(failureRecord,taskScope,result);
154
158
  escalate({failure:failureRecord,budget:{allowed:false,budget:null,used:0,limit:0,scope},scope},
155
159
  {state,saveState:typeof saveState==="function"?saveState:()=>{},fatal:typeof fatal==="function"?fatal:(m)=>{throw new Error(String(m));},escalatePrefix,cause:`${failureRecord.class}/${failureRecord.domain}`});
@@ -148,6 +148,82 @@ export function keepEntryAfterEolFilter(entry,eolDirtySet){
148
148
  return entry.paths.some(p=>eolDirtySet.has(p));
149
149
  }
150
150
 
151
+ // Batch bounds for the limited diffs: keep every git invocation's argv well
152
+ // under the OS limit (~128 KB on Windows, more elsewhere) even with very long
153
+ // paths. Both are soft limits; a rename pair may push a batch one path over.
154
+ const SNAPSHOT_DIFF_PATHS_PER_BATCH=200;
155
+ const SNAPSHOT_DIFF_ARG_BYTES_PER_BATCH=30*1024;
156
+
157
+ function compareSnapshotPaths(a,b){
158
+ // Git emits changed paths in byte order, not UTF-16 code-unit order.
159
+ return Buffer.compare(Buffer.from(a,"utf8"),Buffer.from(b,"utf8"));
160
+ }
161
+
162
+ // Derive the tracked paths whose diff could contribute to the snapshot from
163
+ // `git status`: every entry that is not untracked (`??`) or ignored (`!!`),
164
+ // including both sides of a rename/copy. Root-level `.autopilot/` paths are
165
+ // excluded because the binary diff already excludes them via pathspec.
166
+ // A rename/copy pair must travel in one batch or the pair would degrade into
167
+ // separate delete+add hunks. Git sorts a pair at its NEW path, so the pair is
168
+ // anchored there (see snapshotDiffBatches) while its old path waits.
169
+ function snapshotDiffCandidates(entries){
170
+ const candidates=new Set();
171
+ const pairs=[];
172
+ for(const entry of entries){
173
+ if(entry.xy==="??"||entry.xy==="!!") continue;
174
+ if(entry.xy===null||entry.xy===undefined) continue;
175
+ const included=[];
176
+ for(const p of entry.paths){
177
+ if(String(p).startsWith(".autopilot/")) continue;
178
+ candidates.add(p);
179
+ included.push(p);
180
+ }
181
+ if(entry.fromPath!==null&&entry.fromPath!==undefined&&included.length===2){
182
+ pairs.push([entry.toPath,entry.fromPath]);
183
+ }
184
+ }
185
+ const pairFromByTo=new Map();
186
+ const renameSources=new Set();
187
+ for(const [to,from] of pairs){
188
+ pairFromByTo.set(to,from);
189
+ renameSources.add(from);
190
+ }
191
+ return {
192
+ candidatePaths:[...candidates].sort(compareSnapshotPaths),
193
+ pairFromByTo,
194
+ renameSources
195
+ };
196
+ }
197
+
198
+ function snapshotDiffBatches(sortedPaths,pairFromByTo,renameSources){
199
+ const batches=[];
200
+ const assigned=new Set();
201
+ let i=0;
202
+ while(i<sortedPaths.length){
203
+ const batch=[];
204
+ let bytes=0;
205
+ const add=(p)=>{
206
+ if(assigned.has(p)) return;
207
+ assigned.add(p);
208
+ batch.push(p);
209
+ // ":(literal)" prefix plus the separating space.
210
+ bytes+=Buffer.byteLength(p,"utf8")+11;
211
+ };
212
+ while(i<sortedPaths.length&&batch.length<SNAPSHOT_DIFF_PATHS_PER_BATCH&&(batch.length===0||bytes<SNAPSHOT_DIFF_ARG_BYTES_PER_BATCH)){
213
+ const p=sortedPaths[i++];
214
+ if(assigned.has(p)) continue;
215
+ // A rename source is added by its destination's batch, at the position
216
+ // Git would emit the pair.
217
+ if(renameSources.has(p)) continue;
218
+ add(p);
219
+ const from=pairFromByTo.get(p);
220
+ if(from!==undefined) add(from);
221
+ }
222
+ if(batch.length) batches.push(batch);
223
+ }
224
+ return batches;
225
+ }
226
+
151
227
  function eolNormalizedFileHash(file){
152
228
  try{
153
229
  const buf=fs.readFileSync(file);
@@ -374,11 +450,39 @@ export function gitSnapshot(startCwd){
374
450
  // Entries for tracked paths absent from this set are phantoms and are
375
451
  // dropped from the fingerprinted status. Untracked/ignored entries (`??`,
376
452
  // `!!`) are never in the diff-HEAD set and are always kept.
377
- const eolDirtySet=new Set(gitOut(cwd,["-c","core.autocrlf=input","diff","--ignore-cr-at-eol","--name-only","-z","HEAD"]).toString().split("\0").filter(Boolean));
453
+ //
454
+ // Status runs FIRST so its tracked entries can bound both diffs to the
455
+ // paths that actually changed. On a large checkout this turns two ~12 s
456
+ // full-tree diffs into ~2 s limited diffs while producing byte-identical
457
+ // output: a path not reported by status cannot contribute to `diff HEAD`.
458
+ // Batches are sorted in Git's byte order and concatenated in that order so
459
+ // the fingerprinted binary diff matches the full-tree run exactly.
378
460
  const rawStatus=gitOut(cwd,["-c","core.autocrlf=input","status","--porcelain=v1","-z","--untracked-files=all"]);
379
461
  const statusText=rawStatus.toString("utf8");
462
+ const statusEntries=parsePorcelainV1ZEntries(statusText);
463
+ const {candidatePaths,pairFromByTo,renameSources}=snapshotDiffCandidates(statusEntries);
464
+ const eolDirtySet=new Set();
465
+ const diffParts=[];
466
+ for(const batch of snapshotDiffBatches(candidatePaths,pairFromByTo,renameSources)){
467
+ // Literal pathspecs: a tracked filename containing glob metacharacters
468
+ // must not pull an unrelated path into this batch (which would duplicate
469
+ // that path's diff across batches).
470
+ const pathspecs=batch.map(p=>`:(literal)${p}`);
471
+ for(const rel of gitOut(cwd,["-c","core.autocrlf=input","diff","--ignore-cr-at-eol","--name-only","-z","HEAD","--",...pathspecs]).toString().split("\0")){
472
+ if(rel) eolDirtySet.add(rel);
473
+ }
474
+ diffParts.push(gitOut(cwd,["-c","core.autocrlf=input","diff","--ignore-cr-at-eol","--no-ext-diff","--binary","HEAD","--",...pathspecs,":(exclude).autopilot/**"]));
475
+ }
476
+ // Preserve gitOut's historical type: Buffer.alloc(0) when the full-tree
477
+ // command would have produced no bytes, otherwise its UTF-8 string. Parts
478
+ // are already decoded per batch; concatenating decoded file diffs is
479
+ // identical to decoding the same full output (no multi-byte sequence spans
480
+ // two files).
481
+ const diff=diffParts.some(part=>part.length>0)
482
+ ?diffParts.map(part=>part.toString()).join("")
483
+ :Buffer.alloc(0);
380
484
  const statusKept=[];
381
- for(const entry of parsePorcelainV1ZEntries(statusText)){
485
+ for(const entry of statusEntries){
382
486
  if(entry.paths.some(p=>String(p).includes(".autopilot/"))) continue;
383
487
  if(entry.xy===null){
384
488
  // Ungrouped continuation token (should not happen outside renames
@@ -392,11 +496,15 @@ export function gitSnapshot(startCwd){
392
496
  if(entry.fromPath!==null) statusKept.push(entry.fromPath);
393
497
  }
394
498
  const statusFiltered=Buffer.from(statusKept.join("\0")+(statusKept.length?"\0":""));
395
- const diff=gitOut(cwd,["-c","core.autocrlf=input","diff","--ignore-cr-at-eol","--no-ext-diff","--binary","HEAD","--", ".", ":(exclude).autopilot/**"]);
396
499
 
397
500
  const fileSet=new Set([...eolDirtySet].filter(rel=>!rel.startsWith(".autopilot/")));
398
- for(const rel of gitOut(cwd,["ls-files","--others","-z","--exclude-standard"]).toString().split("\0").filter(Boolean)){
399
- if(!rel.startsWith(".autopilot/"))fileSet.add(rel);
501
+ // Untracked files come from the status already parsed above (`??` entries
502
+ // under --untracked-files=all): byte-identical to `git ls-files --others
503
+ // --exclude-standard` (plain, nested, spaces/unicode/newline names, nested
504
+ // git repos reported as `sub/`, empty dirs omitted by both) without a
505
+ // second full-tree walk.
506
+ for(const entry of statusEntries){
507
+ if(entry.xy==="??"&&!entry.toPath.startsWith(".autopilot/")) fileSet.add(entry.toPath);
400
508
  }
401
509
  // Ignored files: --directory reports a wholly-ignored dir as ONE entry
402
510
  // (e.g. `apps/web/.next/`) without descending, so git never walks
@@ -448,13 +556,22 @@ export function gitSnapshot(startCwd){
448
556
 
449
557
  // Snapshot scope (backlog #9 / TASK-31). Single place defining which
450
558
  // wholly-ignored directory segments are EXCLUDED from the fingerprint.
451
- // Framework build caches only: node_modules, .next, .turbo, .nuxt,
452
- // .svelte-kit, .parcel-cache, .vite. Deliberately NOT excluded: dist/,
453
- // build/, coverage/ — they are build outputs a read-only stage must not
454
- // change, so a write there must still trip read_only_violation.
559
+ // Framework build caches (node_modules, .next, .turbo, .nuxt, .svelte-kit,
560
+ // .parcel-cache, .vite) and build/coverage OUTPUT dirs (dist, build,
561
+ // coverage, .nyc_output, out, .cache, __pycache__, .pytest_cache, target) are
562
+ // never expanded or hashed: on a real repo they were hundreds of files / MBs
563
+ // per snapshot. Playwright/browser artifact dirs (.playwright-mcp,
564
+ // playwright-report, test-results) are deliberately NOT listed: a verifier's
565
+ // screenshots/traces may hold sensitive data and must stay visible to
566
+ // snapshot diffing (test-security-hardening.mjs). Applied to
567
+ // IGNORED entries only — a TRACKED file under dist/ etc. is still covered by
568
+ // status/diff. Agent-config dirs (.opencode, .claude, ...) are deliberately
569
+ // NOT listed: they stay fingerprinted to catch writes by read-only roles.
455
570
  export const SNAPSHOT_EXCLUDED_DIR_SEGMENTS = Object.freeze([
456
571
  "node_modules", ".next", ".turbo", ".nuxt", ".svelte-kit",
457
- ".parcel-cache", ".vite"
572
+ ".parcel-cache", ".vite",
573
+ "dist", "build", "coverage", ".nyc_output", "out", ".cache", "__pycache__", ".pytest_cache",
574
+ "target"
458
575
  ]);
459
576
 
460
577
  function snapshotPathExcluded(rel){
@@ -1373,9 +1490,14 @@ export function createGitMutationGuard(runDir,repositoryRoot=null){
1373
1490
  // structurally exempted (see isSafeSymbolicRefQuery in the generated
1374
1491
  // wrapper). symbolic-ref STAYS in BLOCKED_SUBCOMMANDS — the exemption is a
1375
1492
  // narrow allow-after-parse, never a reclassification.
1493
+ // Backlog #24: a guard never logs into another guard's log. The shim writes
1494
+ // only to the blocked.log beside itself (resolved at run time, so it holds when
1495
+ // the guard dir is mounted elsewhere); an inherited AUTOPILOT_GIT_GUARD_LOG from
1496
+ // an outer session is ignored. The one exception is the null device, which
1497
+ // explicitWrapperSource() sets on purpose to drop the orchestrator's own entries.
1376
1498
  const wrapper=path.join(guardDir,"git-guard.mjs");
1377
1499
  const blockedList=JSON.stringify([...BLOCKED_SUBCOMMANDS]);
1378
- fs.writeFileSync(wrapper,`import fs from "node:fs"; import path from "node:path"; import {spawnSync} from "node:child_process"; import crypto from "node:crypto";\nconst SCOPE_FILE=new URL("./git-scope.conf",import.meta.url);const REAL_GIT=${JSON.stringify(realGit)};const GUARD_ENV_ALLOWLIST=${JSON.stringify(GUARD_ENV_ALLOWLIST)};if(process.argv[2]==="--autopilot-hook-scope-check"){let allow=false;try{allow=classifyGuardTarget(process.cwd(),SCOPE_FILE,process.env,REAL_GIT)==="outside";}catch{allow=false;}process.exit(allow?0:1);}\nif(process.argv[2]===\"--autopilot-hook-target\"){let t=\"unproven\";try{t=classifyGuardTarget(process.cwd(),SCOPE_FILE,process.env,REAL_GIT);}catch{}process.stdout.write(t);process.exit(0);}\nconst args=process.argv.slice(2);\nconst blocked=new Set(${blockedList});\nconst GLOBAL_VALUE_FLAGS=new Set(["-C","-c","--git-dir","--work-tree","--namespace","--super-prefix","--config-env"]);\nconst GLOBAL_VALUE_PREFIXES=[\"--git-dir=\",\"--work-tree=\",\"--namespace=\",\"--super-prefix=\",\"--config-env=\"];\nconst SAFE_SYMBOLIC_REF_OPTIONS=new Set([\"--quiet\",\"-q\",\"--short\",\"--no-recurse\"]);\nfunction isSafeSymbolicRefQuery(argumentList,commandIndex){\nlet operand=null;\nfor(let i=commandIndex+1;i<argumentList.length;i+=1){\nconst token=argumentList[i];\nif(token.startsWith(\"-\")){if(!SAFE_SYMBOLIC_REF_OPTIONS.has(token))return false;continue;}\nif(operand!==null)return false;\noperand=token;\n}\nreturn operand===\"HEAD\"||(typeof operand===\"string\"&&operand.startsWith(\"refs/\")&&operand.length>5);\n}\nconst MUTATING_BRANCH=new Set(["-d","-D","-m","-M","-c","-C","-f","--force","--delete","--move","--copy","--set-upstream-to","-u","--unset-upstream"]);\nconst READONLY_BRANCH_QUERY=new Set(["--list","-l","--show-current","--contains","--no-contains","--merged","--no-merged","--points-at","--format","-v","--verbose","-a","--all","-r","--remotes","--column","--sort"]);\nlet subcommandIndex=-1;\nfor(let index=0;index<args.length;index+=1){\n const argument=args[index];\n if(GLOBAL_VALUE_FLAGS.has(argument)){index+=1;continue;}\n if(GLOBAL_VALUE_PREFIXES.some(prefix=>argument.startsWith(prefix)))continue;\n if(argument.startsWith("-"))continue;\n subcommandIndex=index;\n break;\n}\nconst subcommand=subcommandIndex>=0?args[subcommandIndex]:null;\nlet readOnlyBranch=false;\nif(subcommand==="branch"){\n const rest=args.slice(subcommandIndex+1);\n readOnlyBranch=rest.some(a=>READONLY_BRANCH_QUERY.has(a.split("=")[0]))||(rest.every(a=>a.startsWith("-"))&&rest.every(a=>!MUTATING_BRANCH.has(a.split("=")[0])));\n}\nconst safeSymbolicRefQuery=subcommand===\"symbolic-ref\"&&subcommandIndex>=0&&isSafeSymbolicRefQuery(args,subcommandIndex);\nconst guardBlocks=!!(subcommand && blocked.has(subcommand) && !(subcommand===\"branch\"&&readOnlyBranch) && !safeSymbolicRefQuery);\nlet allowOutsideProtected=false;\nlet blockTarget="unproven";\nconst sanitizedArgs=stripOverridingConfigArgs(args,subcommandIndex);\nlet discardForm=null;\ntry{discardForm=classifyWorktreeDiscarding(args,subcommandIndex,function(operand){try{const allowNames={};for(let k=0;k<GUARD_ENV_ALLOWLIST.length;k+=1){allowNames[String(GUARD_ENV_ALLOWLIST[k]).toUpperCase()]=true;}const probeEnv={};const srcEnv=process.env||{};for(const name of Object.keys(srcEnv)){if(allowNames[String(name).toUpperCase()]){probeEnv[name]=srcEnv[name];}}const r=spawnSync(REAL_GIT,["rev-parse","--verify","--quiet",String(operand)+"^{commit}"],{stdio:["ignore","pipe","pipe"],timeout:5000,env:probeEnv,shell:false});return r.status===0;}catch(e){return false;}});}catch(e){discardForm=null;}\nif((guardBlocks||discardForm)&&SCOPE_FILE){\n let probeCwd=process.cwd();\n let gitDirOverride=null;\n let workTreeOverride=null;\n for(let index=0;index<args.length;index+=1){\n const argument=args[index];\n if(argument==="-C"){const target=args[index+1];if(typeof target==="string"&&target)probeCwd=path.resolve(probeCwd,target);index+=1;continue;}\n if(argument==="--git-dir"){gitDirOverride=args[index+1];index+=1;continue;}\n if(argument.startsWith("--git-dir=")){gitDirOverride=argument.slice("--git-dir=".length);continue;}\n if(argument==="--work-tree"){workTreeOverride=args[index+1];index+=1;continue;}\n if(argument.startsWith("--work-tree=")){workTreeOverride=argument.slice("--work-tree=".length);continue;}\n }\n const probeEnv={...process.env};\n if(typeof gitDirOverride==="string"&&gitDirOverride)probeEnv.GIT_DIR=gitDirOverride;\n if(typeof workTreeOverride==="string"&&workTreeOverride)probeEnv.GIT_WORK_TREE=workTreeOverride;\n try{const wrappedClassification=classifyGuardTarget(probeCwd,SCOPE_FILE,probeEnv,REAL_GIT);allowOutsideProtected=wrappedClassification==="outside";blockTarget=allowOutsideProtected?"unproven":wrappedClassification;}catch{allowOutsideProtected=false;blockTarget="unproven";}\n}\nlet snapshotAllowsRestore=false;\ntry{if(discardForm&&!guardBlocks&&!allowOutsideProtected){snapshotAllowsRestore=guardBeforeSnapshotAllows(args,subcommandIndex,discardForm,SCOPE_FILE.href,REAL_GIT,GUARD_ENV_ALLOWLIST)===true;}}catch{snapshotAllowsRestore=false;}\nif((guardBlocks||discardForm)&&!allowOutsideProtected&&!snapshotAllowsRestore){\n fs.appendFileSync(process.env.AUTOPILOT_GIT_GUARD_LOG||"${logFile.replace(/\\/g,"\\\\")}",JSON.stringify({time:new Date().toISOString(),args:args,layer:"path-shim",subcommand:subcommand,reason:discardForm?"worktree-restore":"history-ref-mutation",form:discardForm||subcommand,target:blockTarget})+"\\n");\n console.error("AUTOPILOT GIT GUARD: '"+subcommand+"' is forbidden ("+(discardForm?"worktree restore":"history/ref mutation")+").");\n process.exit(97);\n}\nconst env={...process.env};\nconst result=spawnSync(REAL_GIT,sanitizedArgs,{stdio:"inherit",shell:false,env});\nif(result.error){console.error(result.error.message);process.exit(127);}\nprocess.exit(result.status??1);\n`);
1500
+ fs.writeFileSync(wrapper,`import fs from "node:fs"; import path from "node:path"; import {spawnSync} from "node:child_process"; import crypto from "node:crypto"; import {fileURLToPath} from "node:url";\nconst SCOPE_FILE=new URL("./git-scope.conf",import.meta.url);const GUARD_LOG=(()=>{const inherited=process.env.AUTOPILOT_GIT_GUARD_LOG;if(typeof inherited===\"string\"&&(process.platform===\"win32\"?inherited.toUpperCase()===\"NUL\":inherited===\"/dev/null\"))return inherited;return fileURLToPath(new URL(\"./blocked.log\",import.meta.url));})();const REAL_GIT=${JSON.stringify(realGit)};const GUARD_ENV_ALLOWLIST=${JSON.stringify(GUARD_ENV_ALLOWLIST)};if(process.argv[2]==="--autopilot-hook-scope-check"){let allow=false;try{allow=classifyGuardTarget(process.cwd(),SCOPE_FILE,process.env,REAL_GIT)==="outside";}catch{allow=false;}process.exit(allow?0:1);}\nif(process.argv[2]===\"--autopilot-hook-target\"){let t=\"unproven\";try{t=classifyGuardTarget(process.cwd(),SCOPE_FILE,process.env,REAL_GIT);}catch{}process.stdout.write(t);process.exit(0);}\nconst args=process.argv.slice(2);\nconst blocked=new Set(${blockedList});\nconst GLOBAL_VALUE_FLAGS=new Set(["-C","-c","--git-dir","--work-tree","--namespace","--super-prefix","--config-env"]);\nconst GLOBAL_VALUE_PREFIXES=[\"--git-dir=\",\"--work-tree=\",\"--namespace=\",\"--super-prefix=\",\"--config-env=\"];\nconst SAFE_SYMBOLIC_REF_OPTIONS=new Set([\"--quiet\",\"-q\",\"--short\",\"--no-recurse\"]);\nfunction isSafeSymbolicRefQuery(argumentList,commandIndex){\nlet operand=null;\nfor(let i=commandIndex+1;i<argumentList.length;i+=1){\nconst token=argumentList[i];\nif(token.startsWith(\"-\")){if(!SAFE_SYMBOLIC_REF_OPTIONS.has(token))return false;continue;}\nif(operand!==null)return false;\noperand=token;\n}\nreturn operand===\"HEAD\"||(typeof operand===\"string\"&&operand.startsWith(\"refs/\")&&operand.length>5);\n}\nconst MUTATING_BRANCH=new Set(["-d","-D","-m","-M","-c","-C","-f","--force","--delete","--move","--copy","--set-upstream-to","-u","--unset-upstream"]);\nconst READONLY_BRANCH_QUERY=new Set(["--list","-l","--show-current","--contains","--no-contains","--merged","--no-merged","--points-at","--format","-v","--verbose","-a","--all","-r","--remotes","--column","--sort"]);\nlet subcommandIndex=-1;\nfor(let index=0;index<args.length;index+=1){\n const argument=args[index];\n if(GLOBAL_VALUE_FLAGS.has(argument)){index+=1;continue;}\n if(GLOBAL_VALUE_PREFIXES.some(prefix=>argument.startsWith(prefix)))continue;\n if(argument.startsWith("-"))continue;\n subcommandIndex=index;\n break;\n}\nconst subcommand=subcommandIndex>=0?args[subcommandIndex]:null;\nlet readOnlyBranch=false;\nif(subcommand==="branch"){\n const rest=args.slice(subcommandIndex+1);\n readOnlyBranch=rest.some(a=>READONLY_BRANCH_QUERY.has(a.split("=")[0]))||(rest.every(a=>a.startsWith("-"))&&rest.every(a=>!MUTATING_BRANCH.has(a.split("=")[0])));\n}\nconst safeSymbolicRefQuery=subcommand===\"symbolic-ref\"&&subcommandIndex>=0&&isSafeSymbolicRefQuery(args,subcommandIndex);\nconst guardBlocks=!!(subcommand && blocked.has(subcommand) && !(subcommand===\"branch\"&&readOnlyBranch) && !safeSymbolicRefQuery);\nlet allowOutsideProtected=false;\nlet blockTarget="unproven";\nconst sanitizedArgs=stripOverridingConfigArgs(args,subcommandIndex);\nlet discardForm=null;\ntry{discardForm=classifyWorktreeDiscarding(args,subcommandIndex,function(operand){try{const allowNames={};for(let k=0;k<GUARD_ENV_ALLOWLIST.length;k+=1){allowNames[String(GUARD_ENV_ALLOWLIST[k]).toUpperCase()]=true;}const probeEnv={};const srcEnv=process.env||{};for(const name of Object.keys(srcEnv)){if(allowNames[String(name).toUpperCase()]){probeEnv[name]=srcEnv[name];}}const r=spawnSync(REAL_GIT,["rev-parse","--verify","--quiet",String(operand)+"^{commit}"],{stdio:["ignore","pipe","pipe"],timeout:5000,env:probeEnv,shell:false});return r.status===0;}catch(e){return false;}});}catch(e){discardForm=null;}\nif((guardBlocks||discardForm)&&SCOPE_FILE){\n let probeCwd=process.cwd();\n let gitDirOverride=null;\n let workTreeOverride=null;\n for(let index=0;index<args.length;index+=1){\n const argument=args[index];\n if(argument==="-C"){const target=args[index+1];if(typeof target==="string"&&target)probeCwd=path.resolve(probeCwd,target);index+=1;continue;}\n if(argument==="--git-dir"){gitDirOverride=args[index+1];index+=1;continue;}\n if(argument.startsWith("--git-dir=")){gitDirOverride=argument.slice("--git-dir=".length);continue;}\n if(argument==="--work-tree"){workTreeOverride=args[index+1];index+=1;continue;}\n if(argument.startsWith("--work-tree=")){workTreeOverride=argument.slice("--work-tree=".length);continue;}\n }\n const probeEnv={...process.env};\n if(typeof gitDirOverride==="string"&&gitDirOverride)probeEnv.GIT_DIR=gitDirOverride;\n if(typeof workTreeOverride==="string"&&workTreeOverride)probeEnv.GIT_WORK_TREE=workTreeOverride;\n try{const wrappedClassification=classifyGuardTarget(probeCwd,SCOPE_FILE,probeEnv,REAL_GIT);allowOutsideProtected=wrappedClassification==="outside";blockTarget=allowOutsideProtected?"unproven":wrappedClassification;}catch{allowOutsideProtected=false;blockTarget="unproven";}\n}\nlet snapshotAllowsRestore=false;\ntry{if(discardForm&&!guardBlocks&&!allowOutsideProtected){snapshotAllowsRestore=guardBeforeSnapshotAllows(args,subcommandIndex,discardForm,SCOPE_FILE.href,REAL_GIT,GUARD_ENV_ALLOWLIST)===true;}}catch{snapshotAllowsRestore=false;}\nif((guardBlocks||discardForm)&&!allowOutsideProtected&&!snapshotAllowsRestore){\n fs.appendFileSync(GUARD_LOG,JSON.stringify({time:new Date().toISOString(),args:args,layer:"path-shim",subcommand:subcommand,reason:discardForm?"worktree-restore":"history-ref-mutation",form:discardForm||subcommand,target:blockTarget})+"\\n");\n console.error("AUTOPILOT GIT GUARD: '"+subcommand+"' is forbidden ("+(discardForm?"worktree restore":"history/ref mutation")+").");\n process.exit(97);\n}\nconst env={...process.env};\nconst result=spawnSync(REAL_GIT,sanitizedArgs,{stdio:"inherit",shell:false,env});\nif(result.error){console.error(result.error.message);process.exit(127);}\nprocess.exit(result.status??1);\n`);
1379
1501
  // TASK 6: embed the Layer-1 scoping helpers. Function declarations are
1380
1502
  // hoisted, so the wrapper body above can call them; all are closure-free by
1381
1503
  // design. The GUARD_ENV_ALLOWLIST they consult is emitted at the TOP of the
@@ -356,7 +356,10 @@ function runSlicedBriefProbe(repo, fileDiff) {
356
356
  // /workspace/repository envelope, so the review never happens and the
357
357
  // probe silently produces no AUTOPILOT_REVIEW sentinel (observed live:
358
358
  // "permission requested: external_directory (.../src/*); auto-rejecting").
359
- // - claude: its own --permission-mode plan.
359
+ // - claude: default mode + an explicit read-only allow list (same shape
360
+ // the production read-only roles launch with after backlog #2). No
361
+ // Write/Edit rule is granted: the canary probe has no scratch hand-off
362
+ // note to keep, so the probe stays read-only without plan mode.
360
363
  const PROBE_OPENCODE_AGENT = "review_canary_probe";
361
364
  const opencodeProbeConfig = JSON.stringify({
362
365
  "$schema": "https://opencode.ai/config.json",
@@ -369,7 +372,7 @@ function runSlicedBriefProbe(repo, fileDiff) {
369
372
  });
370
373
  const baseArgs = REVIEWER_ADAPTER === "opencode"
371
374
  ? ["run", "--format", "json", "--agent", PROBE_OPENCODE_AGENT]
372
- : ["-p", "--output-format", "stream-json", "--input-format", "text", "--verbose", "--permission-mode", "plan"];
375
+ : ["-p", "--output-format", "stream-json", "--input-format", "text", "--verbose", "--allowedTools", "Read", "Glob", "Grep", "Bash", "--disallowedTools", "Monitor", "TaskStop", "--settings", "{\"env\":{\"CLAUDE_CODE_DISABLE_BACKGROUND_TASKS\":\"1\"}}", "--permission-mode", "default"];
373
376
 
374
377
  // Every driver delivers the prompt on stdin (no driver declares a
375
378
  // transportPrompt prompt-as-CLI-argument path), so the prompt always goes