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.
- package/README.md +10 -10
- package/SKILL.md +1 -3
- package/adapters/agents.json +11 -1
- package/config.example.json +5 -3
- package/config.schema.json +28 -9
- package/docs/configuration.md +5 -3
- package/package.json +1 -1
- package/scripts/adapter-drivers.mjs +209 -22
- package/scripts/agent-contract.mjs +24 -2
- package/scripts/agent-invocation.mjs +32 -5
- package/scripts/autopilot.mjs +787 -222
- package/scripts/config-layout.mjs +6 -0
- package/scripts/config-migration.mjs +3 -3
- package/scripts/config-validation.mjs +12 -4
- package/scripts/e2e-detect.mjs +30 -0
- package/scripts/escalation-naming.mjs +19 -0
- package/scripts/failure-contract.mjs +25 -9
- package/scripts/gen-config-schema.mjs +7 -0
- package/scripts/idle-hang-watchdog.mjs +34 -7
- package/scripts/implementation-baseline.mjs +95 -4
- package/scripts/model-discovery.mjs +176 -38
- package/scripts/opencode-generation.mjs +116 -0
- package/scripts/per-task-budget.mjs +11 -7
- package/scripts/plan-review-rounds.mjs +164 -9
- package/scripts/planning-boundary.mjs +16 -6
- package/scripts/planning-continuation.mjs +43 -23
- package/scripts/planning-review-decision.mjs +3 -0
- package/scripts/planning-review-wiring.mjs +12 -8
- package/scripts/relay-guards.mjs +133 -11
- package/scripts/review-canary.mjs +5 -2
- package/scripts/role-fallback.mjs +23 -8
- package/scripts/run-history.mjs +32 -1
- package/scripts/run-observability.mjs +46 -2
- package/scripts/setup-discovery.mjs +2 -1
- package/scripts/setup.mjs +183 -81
- package/scripts/skill-copy.mjs +49 -0
- package/scripts/task-requirements.mjs +24 -0
- package/scripts/watch-report.mjs +146 -21
- package/scripts/watch-summary.mjs +26 -3
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
145
|
-
// Returns false when the result is
|
|
146
|
-
// once through the shared helper above and never
|
|
147
|
-
// fatal throws).
|
|
148
|
-
//
|
|
149
|
-
//
|
|
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
|
-
|
|
152
|
-
|
|
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}`});
|
package/scripts/relay-guards.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
399
|
-
|
|
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
|
|
452
|
-
// .
|
|
453
|
-
//
|
|
454
|
-
//
|
|
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(
|
|
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:
|
|
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", "
|
|
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
|