@mjasnikovs/pi-task 0.37.7 → 0.38.0
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/dist/shared/child-output.d.ts +19 -3
- package/dist/shared/child-output.js +21 -5
- package/dist/shared/git-runner.d.ts +39 -0
- package/dist/shared/git-runner.js +38 -0
- package/dist/task/accept-debt.d.ts +27 -58
- package/dist/task/accept-debt.js +60 -130
- package/dist/task/auto-orchestrator.d.ts +7 -57
- package/dist/task/auto-orchestrator.js +25 -499
- package/dist/task/child-runner.d.ts +2 -0
- package/dist/task/child-runner.js +74 -70
- package/dist/task/enforce-guidelines.d.ts +1 -1
- package/dist/task/enforce-guidelines.js +2 -2
- package/dist/task/external-context.d.ts +85 -7
- package/dist/task/external-context.js +100 -63
- package/dist/task/file-inventory.js +22 -41
- package/dist/task/final-gate.d.ts +80 -0
- package/dist/task/final-gate.js +102 -49
- package/dist/task/gate-deps.js +6 -23
- package/dist/task/git-state-guard.d.ts +1 -1
- package/dist/task/git-state-guard.js +1 -7
- package/dist/task/phases.js +40 -83
- package/dist/task/run-final-gate.d.ts +127 -0
- package/dist/task/run-final-gate.js +492 -0
- package/dist/task/task-gates.d.ts +20 -57
- package/dist/task/task-gates.js +11 -11
- package/dist/task/verify-work.d.ts +40 -32
- package/dist/task/verify-work.js +301 -241
- package/dist/workers/docs-core.d.ts +14 -0
- package/dist/workers/docs-core.js +28 -16
- package/dist/workers/fetch-core.d.ts +6 -1
- package/dist/workers/fetch-core.js +26 -33
- package/dist/workers/focused-extractor.d.ts +73 -0
- package/dist/workers/focused-extractor.js +72 -0
- package/dist/workers/pi-worker-docs.d.ts +1 -1
- package/dist/workers/pi-worker-docs.js +48 -42
- package/dist/workers/pi-worker-fetch.js +6 -8
- package/dist/workers/typeonly-log.d.ts +13 -0
- package/package.json +1 -1
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
import { describeDebt, recordDebt } from './accept-debt.js';
|
|
2
|
+
import { cancelCheckpoint } from './cancel-points.js';
|
|
3
|
+
import { SessionUI } from '../remote/bridge.js';
|
|
4
|
+
import { isYoloMode, yoloFinalGateChoice, YOLO_STAMP } from './yolo.js';
|
|
5
|
+
import { ignoredWriteTrailLine, ignoredWriteDebtReason } from './write-guard.js';
|
|
6
|
+
import { readOwnedRequirements } from './requirements.js';
|
|
7
|
+
import { unclaimedPendingRequirements } from './owned-freeze-reassign.js';
|
|
8
|
+
import { applyDemotions, isNonProgress, normalizeFailureDetail, rankedFirstFailure, unobservedDebtReason } from './final-gate-progress.js';
|
|
9
|
+
import { classifyFinalGateAnswer, MAX_FINAL_GATE_AUTOFIX, FINAL_LEAVE_LABEL, FINAL_LEAVE_VALUE, FINAL_ACCEPT_LABEL, FINAL_ACCEPT_VALUE, FINAL_AUTOFIX_LABEL, FINAL_AUTOFIX_VALUE, STRANDED_FIX_COMMIT, strandedFixNote } from './final-gate-fix.js';
|
|
10
|
+
/**
|
|
11
|
+
* Show the run-end picker and return the raw answer. Card ORDER is fixed —
|
|
12
|
+
* Leave-failed, then Autofix while the bound still allows it, then Accept — and
|
|
13
|
+
* Leave-failed is the recommendation on every branch: a run that could not green the
|
|
14
|
+
* whole-repo gate has not produced a working project. Same SessionUI.ask the
|
|
15
|
+
* clarify/grill/verify dialogs use, so a remote device answers it too.
|
|
16
|
+
*/
|
|
17
|
+
async function askFinalGateResolution(ctx, question, canAutofix) {
|
|
18
|
+
return new SessionUI(ctx).ask({
|
|
19
|
+
localTitle: 'Final integration gate failed — how should pi proceed?',
|
|
20
|
+
displayQuestion: question,
|
|
21
|
+
question,
|
|
22
|
+
recommended: FINAL_LEAVE_LABEL,
|
|
23
|
+
recommended2: canAutofix ? FINAL_AUTOFIX_LABEL : FINAL_ACCEPT_LABEL,
|
|
24
|
+
allowSkip: false,
|
|
25
|
+
options: [
|
|
26
|
+
{ label: FINAL_LEAVE_LABEL, value: FINAL_LEAVE_VALUE },
|
|
27
|
+
...(canAutofix ? [{ label: FINAL_AUTOFIX_LABEL, value: FINAL_AUTOFIX_VALUE }] : []),
|
|
28
|
+
{ label: FINAL_ACCEPT_LABEL, value: FINAL_ACCEPT_VALUE }
|
|
29
|
+
]
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
/** The run-completion announcement: a run that completed on statics alone must not
|
|
33
|
+
* read like one whose product was actually exercised. */
|
|
34
|
+
function completedResult(id, taskCount, unobservedNote) {
|
|
35
|
+
return {
|
|
36
|
+
kind: 'completed',
|
|
37
|
+
message: `${id} complete — all ${taskCount} tasks done.`
|
|
38
|
+
+ (unobservedNote ?
|
|
39
|
+
' WARNING: the final integration gate was UNOBSERVED — it ran no '
|
|
40
|
+
+ 'dynamic check at all, so "complete" here means the statics '
|
|
41
|
+
+ 'passed and nothing more. Carried as debt for the next run.'
|
|
42
|
+
: ''),
|
|
43
|
+
level: unobservedNote ? 'warning' : 'info'
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Run the whole-repo gate and resolve its verdict with the user.
|
|
48
|
+
*
|
|
49
|
+
* Lifted verbatim out of /task-auto's run loop, which it never shared state with:
|
|
50
|
+
* the stage reads no per-task variable and writes none. Never throws for a gate
|
|
51
|
+
* outcome — only a user cancel inside a gate child propagates (the caller's
|
|
52
|
+
* USER_CANCELLED path handles it).
|
|
53
|
+
*/
|
|
54
|
+
export async function runFinalGateStage(active, deps, p) {
|
|
55
|
+
const { cwd, runId: id, planText, taskCount } = p;
|
|
56
|
+
// SAFE CHECKPOINT (pre-final-gate): every task is checked off and committed and
|
|
57
|
+
// the whole-repo gate has not started. A resume re-enters this same stage and
|
|
58
|
+
// runs the gate then, so the run is left exactly where it was — not silently
|
|
59
|
+
// declared complete.
|
|
60
|
+
if (cancelCheckpoint('pre-final-gate')) {
|
|
61
|
+
return {
|
|
62
|
+
kind: 'cancelled',
|
|
63
|
+
message: `${id} cancelled before the final integration gate — resume with /task-auto-resume.`
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
if (!deps.finalGate)
|
|
67
|
+
return completedResult(id, taskCount, null);
|
|
68
|
+
/**
|
|
69
|
+
* The ONE place this stage reaches the durable debt ledger. Four distinct findings
|
|
70
|
+
* feed it — an UNOBSERVED gate, an ignored-path write the gate depends on, a
|
|
71
|
+
* converged autofix that observed nothing, and a check DEMOTED as unfalsifiable —
|
|
72
|
+
* and they stay four calls because they are four different facts about the run.
|
|
73
|
+
* What they no longer each restate is WHERE the debt goes: the run's own id, under
|
|
74
|
+
* origin 'final-gate', which is what the next run's gate re-checks.
|
|
75
|
+
*/
|
|
76
|
+
const carryDebt = (reason) => recordDebt(cwd, id, reason, 'final-gate');
|
|
77
|
+
// Set when the gate finished having observed NOTHING dynamic. Declared out here so
|
|
78
|
+
// the run-completion announcement can say so.
|
|
79
|
+
let unobservedNote = null;
|
|
80
|
+
active.ui.notify(`${id}: running final integration gate…`, 'info');
|
|
81
|
+
// Run-level gate trail on the parent task file — same durable auditability
|
|
82
|
+
// contract as the per-task `## gates` records.
|
|
83
|
+
const recGate = async (line) => {
|
|
84
|
+
try {
|
|
85
|
+
await deps.record?.(cwd, id, line);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
// recording must never break the gate
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
// Trail EVERY aggregated failure entry (mx5 run 13): the gate runs all sections
|
|
92
|
+
// and ranks the list; a single sliced reason line would re-hide everything past
|
|
93
|
+
// the first entry.
|
|
94
|
+
const trailGateFail = async (f) => {
|
|
95
|
+
const list = f.failures ?? [f.reason];
|
|
96
|
+
if (list.length <= 1) {
|
|
97
|
+
await recGate(`final-gate: FAIL — ${f.reason.slice(0, 300)}`);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
await recGate(`final-gate: FAIL — ${list.length} failures (ranked, most load-bearing first)`);
|
|
101
|
+
for (const [i, entry] of list.entries()) {
|
|
102
|
+
await recGate(`final-gate FAIL ${i + 1}/${list.length}: ${entry.slice(0, 300)}`);
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
let fin = await deps.finalGate(cwd, planText);
|
|
106
|
+
// Record the outcome symmetrically (mx5 run 10 item 7): only FAIL was ever
|
|
107
|
+
// trailed, so a PASSing gate was indistinguishable from a gate that never ran. The
|
|
108
|
+
// PASS reason names the commands that were run. THREE verdicts, not two
|
|
109
|
+
// (final-gate.ts unobservedVerdict): a gate that observed nothing dynamic is
|
|
110
|
+
// UNOBSERVED, never PASS — IAR1 shipped `PASS — no integration command found`
|
|
111
|
+
// twice while carrying open verify-FAIL debt. It does not block (justified there),
|
|
112
|
+
// but it is labelled here, warned about, and recorded as durable debt so the next
|
|
113
|
+
// run's gate re-surfaces it.
|
|
114
|
+
if (fin.ok && fin.unobserved) {
|
|
115
|
+
unobservedNote = fin.unobserved;
|
|
116
|
+
await recGate(`final-gate: UNOBSERVED — ${fin.reason.slice(0, 300)}`);
|
|
117
|
+
await carryDebt(fin.unobserved);
|
|
118
|
+
active.ui.notify(`${id}: the final integration gate observed NOTHING dynamic — `
|
|
119
|
+
+ 'the run completed on static checks alone. Nothing verified '
|
|
120
|
+
+ 'that the assembled product builds, boots or works.', 'warning');
|
|
121
|
+
}
|
|
122
|
+
else if (fin.ok) {
|
|
123
|
+
await recGate(`final-gate: PASS — ${fin.reason.slice(0, 300)}`);
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
await trailGateFail(fin);
|
|
127
|
+
}
|
|
128
|
+
// ACCEPT-debt re-check surfacing (mx5 run 4 B3 / run 8 TASK_0012): tasks the user
|
|
129
|
+
// accepted despite a verify-FAIL that the gate could not prove resolved against the
|
|
130
|
+
// current tree. Surface them at the gate moment — on PASS or FAIL — so a run never
|
|
131
|
+
// completes silently carrying an accepted defect. Informational: the per-task
|
|
132
|
+
// ACCEPT was already a human decision, so this reports, it does not re-fail.
|
|
133
|
+
const debtKey = (d) => `${d.taskId}\t${d.reason}`;
|
|
134
|
+
const surfaceOpenDebts = async (debts) => {
|
|
135
|
+
if (debts.length === 0)
|
|
136
|
+
return;
|
|
137
|
+
for (const d of debts) {
|
|
138
|
+
await recGate(`defect STILL OPEN — ${d.taskId || '(unknown task)'}: ${describeDebt(d)}: ${d.reason.slice(0, 240)}${d.conflict ? ` [CONFLICTING CLAIM — ${d.conflict}]` : ''}`);
|
|
139
|
+
}
|
|
140
|
+
active.ui.notify(`${id}: ${debts.length} recorded verify-FAIL defect(s) are STILL unresolved at run end — see the gate trail.`, 'warning');
|
|
141
|
+
};
|
|
142
|
+
// What was REPORTED, so a post-autofix re-derivation can be compared against it
|
|
143
|
+
// rather than blindly re-printed.
|
|
144
|
+
let reportedDebts = fin.openDebts ?? [];
|
|
145
|
+
await surfaceOpenDebts(reportedDebts);
|
|
146
|
+
// An owned obligation a task DETACHED (its own spec froze the only file that could
|
|
147
|
+
// satisfy it, nexttask 2) and no later task claimed. Detach never deletes the
|
|
148
|
+
// quote, so the run ends holding it — say so, or the resolution would be a quieter
|
|
149
|
+
// version of the deletion it exists to prevent.
|
|
150
|
+
const unclaimed = unclaimedPendingRequirements(await readOwnedRequirements(cwd).catch(() => []));
|
|
151
|
+
for (const o of unclaimed) {
|
|
152
|
+
await recGate(`owned requirement UNCLAIMED — "${o.quote.slice(0, 200)}"`
|
|
153
|
+
+ ` [frozen in "${o.title.slice(0, 60)}"; no task claimed`
|
|
154
|
+
+ ` ${(o.pending ?? []).join(', ')}]`);
|
|
155
|
+
}
|
|
156
|
+
if (unclaimed.length > 0) {
|
|
157
|
+
active.ui.notify(`${id}: ${unclaimed.length} authoritative design requirement(s) ended the run`
|
|
158
|
+
+ ' owned by NO task — the task they were mapped to could not touch the'
|
|
159
|
+
+ ' file, and nothing else claimed it. See the gate trail.', 'warning');
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* nexttask 6 (mx5 run 18). The lines above are emitted from the FIRST gate result;
|
|
163
|
+
* the converged-autofix paths below used to rebuild `fin` as `{ok, reason}`, so
|
|
164
|
+
* `openDebts` did not survive the fix pass — the run's last word on its own defects
|
|
165
|
+
* was a snapshot of a tree that no longer existed, and no code path could clear,
|
|
166
|
+
* re-check or act on it. Re-derive here, against the tree the run actually ends
|
|
167
|
+
* with, and correct the record.
|
|
168
|
+
*
|
|
169
|
+
* FP-safe by inheritance: `deriveOpenDebts` auto-closes only what a deterministic
|
|
170
|
+
* check can stand behind (a static-class debt when the statics provably pass, a
|
|
171
|
+
* cross-task-deletion whose file is back). Anything model-judged or behavioral
|
|
172
|
+
* STAYS OPEN — `inv-no-false-clear`. `staticOk` is therefore only ever passed true
|
|
173
|
+
* where the gate itself just passed the statics.
|
|
174
|
+
*/
|
|
175
|
+
const reconcileDebts = async (staticOk) => {
|
|
176
|
+
if (!deps.recheckOpenDebts)
|
|
177
|
+
return;
|
|
178
|
+
let fresh;
|
|
179
|
+
try {
|
|
180
|
+
fresh = await deps.recheckOpenDebts(cwd, staticOk);
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
// A ledger read fault is inconclusive: say nothing rather than imply the
|
|
184
|
+
// defects cleared.
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
fin = {
|
|
188
|
+
...fin,
|
|
189
|
+
openDebts: fresh.openDebts,
|
|
190
|
+
...(fresh.debtNote ? { debtNote: fresh.debtNote } : {})
|
|
191
|
+
};
|
|
192
|
+
// Per-debt evidence from the VERIFY-COMMAND re-check (nexttask 5): which
|
|
193
|
+
// command was re-run and what it did. A close that cannot be read back from
|
|
194
|
+
// the trail is a close nobody can audit, and an INCONCLUSIVE re-run is worth
|
|
195
|
+
// saying out loud — it is the difference between "still broken" and "nothing
|
|
196
|
+
// could observe it".
|
|
197
|
+
for (const line of fresh.trail ?? []) {
|
|
198
|
+
await recGate(`defect re-check: ${line}`);
|
|
199
|
+
}
|
|
200
|
+
// Identity is (task, origin, reason), but a RESOLUTION claim needs more than a
|
|
201
|
+
// key miss: a ledger entry whose TEXT changed is the same defect re-recorded,
|
|
202
|
+
// never a fix. So a debt counts as closed only when nothing for that (task,
|
|
203
|
+
// origin) survives.
|
|
204
|
+
const slot = (d) => `${d.taskId}\t${d.origin ?? ''}`;
|
|
205
|
+
const before = new Set(reportedDebts.map(debtKey));
|
|
206
|
+
const after = new Set(fresh.openDebts.map(debtKey));
|
|
207
|
+
const beforeSlots = new Set(reportedDebts.map(slot));
|
|
208
|
+
const afterSlots = new Set(fresh.openDebts.map(slot));
|
|
209
|
+
const closed = reportedDebts.filter(d => !after.has(debtKey(d)) && !afterSlots.has(slot(d)));
|
|
210
|
+
const added = fresh.openDebts.filter(d => !before.has(debtKey(d)) && !beforeSlots.has(slot(d)));
|
|
211
|
+
if (closed.length === 0 && added.length === 0) {
|
|
212
|
+
if (reportedDebts.length > 0) {
|
|
213
|
+
await recGate(`defect re-check after autofix: all ${reportedDebts.length} defect(s) `
|
|
214
|
+
+ 'above re-derived against the FINAL tree and still open');
|
|
215
|
+
}
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
for (const d of closed) {
|
|
219
|
+
await recGate(`defect RESOLVED — ${d.taskId || '(unknown task)'}: ` + `${d.reason.slice(0, 240)}`);
|
|
220
|
+
}
|
|
221
|
+
await surfaceOpenDebts(added);
|
|
222
|
+
reportedDebts = fresh.openDebts;
|
|
223
|
+
await recGate(`defect re-check after autofix: ${closed.length} resolved, `
|
|
224
|
+
+ `${fresh.openDebts.length} still open (re-derived against the FINAL tree)`);
|
|
225
|
+
};
|
|
226
|
+
// Resolution loop: Leave-failed (recommended) / Autofix (bounded, model-driven fix
|
|
227
|
+
// pass + gate re-run — run 7's gap: the picker had NO automated fix path) /
|
|
228
|
+
// Accept. The user always decides; after MAX_FINAL_GATE_AUTOFIX attempts that
|
|
229
|
+
// still FAIL the autofix card is withdrawn so the loop cannot run unbounded.
|
|
230
|
+
let fixAttempts = 0;
|
|
231
|
+
// Gitignored paths the fix passes have written so far in this resolution loop (mx5
|
|
232
|
+
// run 19). Accumulated across attempts: a `.env` written by a failed attempt is
|
|
233
|
+
// still on disk for the next one, and that attempt's own before/after diff cannot
|
|
234
|
+
// see it.
|
|
235
|
+
let ignoredWritten = [];
|
|
236
|
+
// Sub-fixes a non-converging autofix attempt left uncommitted. Refreshed after
|
|
237
|
+
// every attempt; drives the picker note and the terminal commit (mx5 run 13 PROMPT
|
|
238
|
+
// 4 item 3, run 14 item 2b).
|
|
239
|
+
let stranded = [];
|
|
240
|
+
const refreshStranded = async () => {
|
|
241
|
+
if (!deps.pendingChanges)
|
|
242
|
+
return;
|
|
243
|
+
try {
|
|
244
|
+
stranded = await deps.pendingChanges(cwd);
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
// Inconclusive: say nothing rather than claim a clean tree.
|
|
248
|
+
stranded = [];
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
// NON-PROGRESS / UNFALSIFIABLE-CHECK state (mx5 run 14 item 2a). `prevFailSig` is
|
|
252
|
+
// the previous attempt's normalized ranked-first failure; `demoted` holds the
|
|
253
|
+
// signatures already carried as debt, so a re-run that still reports them does not
|
|
254
|
+
// re-fail the gate.
|
|
255
|
+
let prevFailSig = null;
|
|
256
|
+
const demoted = new Set();
|
|
257
|
+
// Set when a write-guard rejected an attempt whose edits could NOT be discarded:
|
|
258
|
+
// REJECTED edits are then sitting in the tree and must never be committed by the
|
|
259
|
+
// terminal paths below.
|
|
260
|
+
let rejectedEditsInTree = false;
|
|
261
|
+
// Commit whatever guard-clean repairs the fix passes left, on ANY terminal
|
|
262
|
+
// non-converged outcome. Run 14 ended on LEAVE with 13 real repairs dirty in the
|
|
263
|
+
// tree after an unattended run — the next checkout would have destroyed them
|
|
264
|
+
// silently.
|
|
265
|
+
const commitStranded = async (outcome) => {
|
|
266
|
+
if (stranded.length === 0)
|
|
267
|
+
return;
|
|
268
|
+
if (rejectedEditsInTree) {
|
|
269
|
+
await recGate(`final-gate: NOT committing ${stranded.length} working-tree change(s) — a `
|
|
270
|
+
+ `write-guard rejected an attempt and its edits could not be discarded, `
|
|
271
|
+
+ `so the tree holds REJECTED edits: ${stranded.slice(0, 8).join(', ')}`);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
// REPORT WHAT ACTUALLY HAPPENED (mx5 run 20). This bound the CommitResult to
|
|
275
|
+
// `sha` and interpolated it, so the trail read "committed 5 stranded fix-pass
|
|
276
|
+
// change(s) as [object Object]". Worse than cosmetic: `commit` returns
|
|
277
|
+
// {committed, reason?, note?} and NEVER a sha, the `committed` field was never
|
|
278
|
+
// read, and gitCommitAll returns {committed:false} WITHOUT throwing on an
|
|
279
|
+
// unmerged index — so on that path the catch below never fires and the trail
|
|
280
|
+
// claimed a commit over changes that were still sitting in the working tree.
|
|
281
|
+
const notCommitted = async (why) => {
|
|
282
|
+
await recGate(`final-gate: could NOT commit ${stranded.length} stranded fix-pass `
|
|
283
|
+
+ `change(s) (${why}) — they remain UNCOMMITTED in the working `
|
|
284
|
+
+ `tree: ${stranded.slice(0, 8).join(', ')}`);
|
|
285
|
+
};
|
|
286
|
+
try {
|
|
287
|
+
const res = await deps.commit(cwd, STRANDED_FIX_COMMIT(id, outcome));
|
|
288
|
+
if (res.committed) {
|
|
289
|
+
await recGate(`final-gate: committed ${stranded.length} stranded fix-pass change(s)`
|
|
290
|
+
+ `${res.note ? ` (${res.note})` : ''} — ${stranded.slice(0, 8).join(', ')}`);
|
|
291
|
+
}
|
|
292
|
+
else {
|
|
293
|
+
await notCommitted(res.reason ?? 'unknown');
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
catch (err) {
|
|
297
|
+
// Never break the terminal path over this — but say so, so the changes are
|
|
298
|
+
// not silently lost.
|
|
299
|
+
await notCommitted(err instanceof Error ? err.message : String(err));
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
while (!fin.ok) {
|
|
303
|
+
const canAutofix = deps.finalGateFix !== undefined && fixAttempts < MAX_FINAL_GATE_AUTOFIX;
|
|
304
|
+
// The picker question shows the debts (the HUMAN weighs them); the autofix seed
|
|
305
|
+
// below deliberately does not — mx5 run 11's fix child executed a debt claim as
|
|
306
|
+
// an `rm` instruction.
|
|
307
|
+
const question = `Final integration gate FAILED for ${id}.\n\n${fin.reason}${fin.debtNote ?? ''}\n\n`
|
|
308
|
+
+ 'All tasks are checked off — this is the whole-repo check '
|
|
309
|
+
+ '(the project’s own test/build/static commands, run unaided).'
|
|
310
|
+
+ (fixAttempts > 0 ?
|
|
311
|
+
`\n\nAutofix attempts so far: ${fixAttempts}/${MAX_FINAL_GATE_AUTOFIX}.`
|
|
312
|
+
: '')
|
|
313
|
+
// Never let a partial repair be invisible at the moment the human decides
|
|
314
|
+
// (run 13: a bunfig fix that made `bun run test` pass 116/116 was stranded
|
|
315
|
+
// by an ACCEPT).
|
|
316
|
+
+ strandedFixNote(stranded);
|
|
317
|
+
// YOLO: keep autofixing WHILE the card is still offered — the loop withdraws it
|
|
318
|
+
// after MAX_FINAL_GATE_AUTOFIX, so the cap that bounds a non-converging fix pass
|
|
319
|
+
// still bounds this — then LEAVE the run failed. Never 'accept': an unattended
|
|
320
|
+
// run that could not green the whole-repo gate has not produced a working
|
|
321
|
+
// project, and mx5 run 13 shows what an accepted FAIL looks like afterwards (a
|
|
322
|
+
// shipped app that 404s at `/`).
|
|
323
|
+
const yoloFinal = yoloFinalGateChoice(isYoloMode(), canAutofix);
|
|
324
|
+
if (yoloFinal !== null) {
|
|
325
|
+
await recGate(`final-gate: auto-chose ${yoloFinal.action.toUpperCase()} ${YOLO_STAMP}`);
|
|
326
|
+
}
|
|
327
|
+
const answer = yoloFinal !== null ?
|
|
328
|
+
yoloFinal.action === 'autofix' ?
|
|
329
|
+
FINAL_AUTOFIX_VALUE
|
|
330
|
+
: FINAL_LEAVE_VALUE
|
|
331
|
+
: await askFinalGateResolution(active, question, canAutofix);
|
|
332
|
+
const choice = classifyFinalGateAnswer(answer);
|
|
333
|
+
if (choice.action === 'accept') {
|
|
334
|
+
await recGate('final-gate: FAIL accepted by user');
|
|
335
|
+
// STRANDED SUB-FIXES: the run completes here, so anything the fix pass
|
|
336
|
+
// repaired but never committed would be lost to the next `git checkout`
|
|
337
|
+
// while HEAD keeps the defect it fixed. Commit it as its own, named
|
|
338
|
+
// commit — the ACCEPT is a decision about the FAILING gate, never an
|
|
339
|
+
// instruction to throw away work (mx5 run 13 item 3).
|
|
340
|
+
await commitStranded('accepted');
|
|
341
|
+
active.ui.notify(`${id}: final integration gate FAIL accepted by user — completing.`
|
|
342
|
+
+ (stranded.length > 0 ?
|
|
343
|
+
` ${stranded.length} uncommitted fix-pass change(s) committed separately.`
|
|
344
|
+
: ''), 'warning');
|
|
345
|
+
break;
|
|
346
|
+
}
|
|
347
|
+
if (choice.action === 'autofix' && canAutofix) {
|
|
348
|
+
fixAttempts += 1;
|
|
349
|
+
await recGate(`final-gate: user chose AUTOFIX (attempt ${fixAttempts}/${MAX_FINAL_GATE_AUTOFIX})`);
|
|
350
|
+
active.ui.notify(`${id}: final-gate autofix (${fixAttempts}/${MAX_FINAL_GATE_AUTOFIX}) — bounded fix pass, then the gate re-runs…`, 'info');
|
|
351
|
+
const seed = choice.guidance ? `${fin.reason}\n\nUser guidance: ${choice.guidance}` : fin.reason;
|
|
352
|
+
const fix = await deps.finalGateFix(active, cwd, seed, ignoredWritten);
|
|
353
|
+
// IGNORED-PATH WRITES (mx5 run 19). The pass wrote file(s) git ignores, so
|
|
354
|
+
// they are not in the commit and a fresh clone does not have them. Trailed
|
|
355
|
+
// on EVERY outcome — a rejected attempt's tracked edits are discarded while
|
|
356
|
+
// its ignored writes survive on disk — and carried forward, so a later
|
|
357
|
+
// attempt's PASS is judged against everything this loop wrote, not just its
|
|
358
|
+
// own attempt. PATH NAMES ONLY: an ignored file's contents (`.env` is the
|
|
359
|
+
// canonical case) never enter a log, a debt or a child prompt.
|
|
360
|
+
if (fix.ignoredWrites && fix.ignoredWrites.length > 0) {
|
|
361
|
+
ignoredWritten = [...new Set([...ignoredWritten, ...fix.ignoredWrites])].sort();
|
|
362
|
+
await recGate(ignoredWriteTrailLine(fix.ignoredWrites));
|
|
363
|
+
// Debt only where a verdict can rest on the file: the probe proved the
|
|
364
|
+
// gate needs it, or the question stayed open. A write the gate
|
|
365
|
+
// demonstrably does NOT need is trailed and nothing more — a ledger
|
|
366
|
+
// full of scratch files is a ledger nobody reads.
|
|
367
|
+
if (fix.ignoredDependent !== false) {
|
|
368
|
+
await carryDebt(ignoredWriteDebtReason(fix.ignoredWrites, fix.ignoredDependent));
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
if (fix.ok) {
|
|
372
|
+
await deps.commit(cwd, `FINAL GATE AUTOFIX (${id})`);
|
|
373
|
+
// A converged re-run that observed nothing dynamic is UNOBSERVED on
|
|
374
|
+
// this door too — never announce it as a PASS just because it arrived
|
|
375
|
+
// via autofix.
|
|
376
|
+
if (fix.unobserved) {
|
|
377
|
+
unobservedNote = fix.unobserved;
|
|
378
|
+
await carryDebt(fix.unobserved);
|
|
379
|
+
}
|
|
380
|
+
await recGate(`final-gate: autofix ${fix.unobserved ? 'ended UNOBSERVED' : 'converged'} — ${fix.reason.slice(0, 200)}`);
|
|
381
|
+
active.ui.notify(`${id}: final integration gate ${fix.unobserved ? 'is UNOBSERVED' : 'PASSES'} after autofix — ${fix.reason.slice(0, 140)}`, fix.unobserved ? 'warning' : 'info');
|
|
382
|
+
fin = { ok: true, reason: fix.reason };
|
|
383
|
+
// The gate itself just passed, statics included, so `staticOk` here is
|
|
384
|
+
// proof rather than assumption.
|
|
385
|
+
await reconcileDebts(true);
|
|
386
|
+
break;
|
|
387
|
+
}
|
|
388
|
+
await recGate(`final-gate: autofix attempt ${fixAttempts} failed — ${fix.reason.slice(0, 200)}`);
|
|
389
|
+
// A guard that rejected an attempt WITHOUT discarding leaves rejected edits
|
|
390
|
+
// behind: the terminal paths must not commit the tree after that (the cheat
|
|
391
|
+
// guard stays intact).
|
|
392
|
+
if (fix.guardTripped === true && fix.editsDiscarded !== true) {
|
|
393
|
+
rejectedEditsInTree = true;
|
|
394
|
+
}
|
|
395
|
+
// The attempt's edits survive a non-convergence (only a guard trip
|
|
396
|
+
// discards). Find out what they are NOW, so the next picker shows them and
|
|
397
|
+
// a terminal outcome commits them.
|
|
398
|
+
await refreshStranded();
|
|
399
|
+
if (stranded.length > 0) {
|
|
400
|
+
await recGate(`final-gate: autofix attempt ${fixAttempts} left ${stranded.length} `
|
|
401
|
+
+ `uncommitted change(s) — ${stranded.slice(0, 8).join(', ')}`);
|
|
402
|
+
}
|
|
403
|
+
active.ui.notify(`${id}: final-gate autofix did not converge — ${fix.reason.slice(0, 140)}`, 'warning');
|
|
404
|
+
// NON-PROGRESS CLASSIFIER (mx5 run 14 item 2a). An attempt that changed the
|
|
405
|
+
// tree, re-ran the gate, and got back the SAME ranked-first failure as the
|
|
406
|
+
// previous such attempt is evidence about the CHECK, not the fix: run 14
|
|
407
|
+
// burned all three attempts on a boot probe that could not observe a
|
|
408
|
+
// listener in that sandbox at all. Demote that one check to
|
|
409
|
+
// UNOBSERVED-with-debt and let the REMAINING checks decide.
|
|
410
|
+
const detail = rankedFirstFailure({
|
|
411
|
+
reason: fix.gateReason,
|
|
412
|
+
failures: fix.gateFailures
|
|
413
|
+
});
|
|
414
|
+
const edited = fix.gateReason !== undefined && stranded.length > 0;
|
|
415
|
+
if (detail !== null
|
|
416
|
+
&& isNonProgress({ previousSignature: prevFailSig, currentDetail: detail, edited })) {
|
|
417
|
+
demoted.add(normalizeFailureDetail(detail));
|
|
418
|
+
prevFailSig = null;
|
|
419
|
+
await carryDebt(unobservedDebtReason(detail));
|
|
420
|
+
await recGate(`final-gate: check DEMOTED to UNOBSERVED after ${fixAttempts} tree-changing `
|
|
421
|
+
+ `attempts returned an identical failure — carried as debt (origin final-gate) `
|
|
422
|
+
+ `and re-checked by the next run's gate: ${detail.slice(0, 240)}`);
|
|
423
|
+
active.ui.notify(`${id}: final-gate check is unfalsifiable in this environment — carried as debt; `
|
|
424
|
+
+ 'the remaining checks decide convergence.', 'warning');
|
|
425
|
+
}
|
|
426
|
+
else {
|
|
427
|
+
prevFailSig = detail !== null ? normalizeFailureDetail(detail) : null;
|
|
428
|
+
}
|
|
429
|
+
// Convergence on the REMAINING checks: a demoted signature no longer counts
|
|
430
|
+
// against the gate. Nothing left ⇒ the run converges carrying the demotion
|
|
431
|
+
// as debt, and the fix passes' repairs are committed rather than stranded.
|
|
432
|
+
if (demoted.size > 0 && fix.gateReason !== undefined) {
|
|
433
|
+
const remaining = applyDemotions(fix.gateFailures ?? [fix.gateReason], demoted);
|
|
434
|
+
if (remaining.length === 0) {
|
|
435
|
+
await deps.commit(cwd, `FINAL GATE AUTOFIX (${id})`);
|
|
436
|
+
const converged = `converged on all remaining checks; ${demoted.size} check(s) `
|
|
437
|
+
+ 'carried as UNOBSERVED debt (unfalsifiable in this environment)';
|
|
438
|
+
await recGate(`final-gate: ${converged}`);
|
|
439
|
+
active.ui.notify(`${id}: final integration gate converged — ${converged}.`, 'warning');
|
|
440
|
+
fin = { ok: true, reason: converged };
|
|
441
|
+
// Converged on the REMAINING checks only: one or more were DEMOTED
|
|
442
|
+
// as unfalsifiable here, and the statics may be among them. No
|
|
443
|
+
// proof ⇒ pass false, so nothing static-class can auto-close on
|
|
444
|
+
// this door (inv-no-false-clear).
|
|
445
|
+
await reconcileDebts(false);
|
|
446
|
+
break;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
// Work from the FRESH gate failure when the fix pass got as far as
|
|
450
|
+
// re-running the gate; otherwise keep the last. The full ranked list rides
|
|
451
|
+
// along (and is re-trailed when fresh) so the next picker and the next fix
|
|
452
|
+
// seed still carry every entry, not just the first. The debt note is
|
|
453
|
+
// carried so the next picker still shows the open claims (the seed never
|
|
454
|
+
// includes it). Demoted checks are stripped from what rides forward, so the
|
|
455
|
+
// next picker and the next fix seed target only what is still falsifiable —
|
|
456
|
+
// never re-aiming the child at the check the classifier just proved it
|
|
457
|
+
// cannot move.
|
|
458
|
+
const freshFailures = fix.gateReason !== undefined ? fix.gateFailures : fin.failures;
|
|
459
|
+
const carried = freshFailures !== undefined ? applyDemotions(freshFailures, demoted) : undefined;
|
|
460
|
+
fin = {
|
|
461
|
+
ok: false,
|
|
462
|
+
reason: demoted.size > 0 && carried !== undefined && carried.length > 0 ?
|
|
463
|
+
carried[0]
|
|
464
|
+
: (fix.gateReason ?? fin.reason),
|
|
465
|
+
failures: carried,
|
|
466
|
+
debtNote: fin.debtNote
|
|
467
|
+
};
|
|
468
|
+
if (fix.gateReason !== undefined && (fix.gateFailures?.length ?? 0) > 1) {
|
|
469
|
+
await trailGateFail(fin);
|
|
470
|
+
}
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
// Leave failed — the dismissal default, unchanged from the two-option picker
|
|
474
|
+
// (an unavailable autofix demotes here too).
|
|
475
|
+
await recGate(yoloFinal !== null ?
|
|
476
|
+
`final-gate: left failed — autofix budget spent, nobody to ask ${YOLO_STAMP}`
|
|
477
|
+
: 'final-gate: left failed (user)');
|
|
478
|
+
// Leaving the run failed is TERMINAL for an unattended run, so the fix passes'
|
|
479
|
+
// guard-clean repairs are committed here too — run 14 left 13 of them dirty for
|
|
480
|
+
// a `git checkout` to destroy (mx5 run 13 item 3, run 14 item 2b). The user
|
|
481
|
+
// still owns the outcome; they own it with the work in HEAD, named in the trail.
|
|
482
|
+
await commitStranded('left-failed');
|
|
483
|
+
return {
|
|
484
|
+
kind: 'failed',
|
|
485
|
+
message: `${id} finished all tasks but FAILED the final integration gate — ${fin.reason.slice(0, 200)} — fix and /task-auto-resume (the gate re-runs).`
|
|
486
|
+
+ (stranded.length > 0 ?
|
|
487
|
+
` NOTE: ${stranded.length} fix-pass change(s) were committed separately (${stranded.slice(0, 4).join(', ')}).`
|
|
488
|
+
: '')
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
return completedResult(id, taskCount, unobservedNote);
|
|
492
|
+
}
|
|
@@ -35,6 +35,7 @@ import type { VerifyOutcome } from './verify-work.js';
|
|
|
35
35
|
import type { EnforceOutcome } from './enforce-guidelines.js';
|
|
36
36
|
import { type ResolutionOutcome, type ResolutionChoice } from './verify-resolution.js';
|
|
37
37
|
import { type RepairCandidate } from './root-cause-repair.js';
|
|
38
|
+
import { type DebtOrigin } from './accept-debt.js';
|
|
38
39
|
/**
|
|
39
40
|
* The deps the gate sequence drives. A superset of these is built once per command
|
|
40
41
|
* by buildGateDeps; AutoDeps extends this with the planning-only `runChild`. Every
|
|
@@ -125,68 +126,30 @@ export interface GateDeps {
|
|
|
125
126
|
*/
|
|
126
127
|
record?: (cwd: string, taskId: string, line: string) => Promise<void>;
|
|
127
128
|
/**
|
|
128
|
-
* Record
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
129
|
+
* Record ONE durable defect to the run-level ledger (`.pi-tasks/accept-debt.md`,
|
|
130
|
+
* see accept-debt.ts), stamped with the DebtOrigin that says how it was reached.
|
|
131
|
+
* The final integration gate re-checks every recorded debt at run end and surfaces
|
|
132
|
+
* the ones still open, so a defect the gate found is never lost by whatever the
|
|
133
|
+
* loop then did with the WORK — accepted by a human ('accepted'), auto-picked
|
|
134
|
+
* unattended by yolo mode ('yolo-accepted'), reverted with the enforce commit
|
|
135
|
+
* ('enforce-revert'), kept because the enforce diff could not have caused it
|
|
136
|
+
* ('enforce-kept'), blocked by a spec-frozen path ('frozen-blocked'), a sibling's
|
|
137
|
+
* deliverable deleted and accepted ('cross-task-deletion'), or another task's
|
|
138
|
+
* pre-existing bug this one merely tripped over ('root-cause').
|
|
139
|
+
*
|
|
140
|
+
* The ORIGIN is load-bearing, not a label: the final gate reports by class, and
|
|
141
|
+
* an unattended auto-pick may never be recorded as the 'accepted' class, which
|
|
142
|
+
* asserts a human weighed the failing artifact. Best-effort; absent in tests →
|
|
143
|
+
* no ledger written.
|
|
133
144
|
*/
|
|
134
|
-
|
|
135
|
-
/**
|
|
136
|
-
* Record a durable YOLO-ACCEPTED debt: the same ACCEPT branch, but reached by an
|
|
137
|
-
* unattended auto-pick (yolo mode) rather than a human. Separate dep — and
|
|
138
|
-
* separate ledger origin — because collapsing the two would let an auto-pick
|
|
139
|
-
* read as "a human blessed this" in the final gate's run-end report. Best-effort;
|
|
140
|
-
* absent in tests → no ledger written.
|
|
141
|
-
*/
|
|
142
|
-
recordYoloAcceptDebt?: (cwd: string, taskId: string, reason: string) => Promise<void>;
|
|
143
|
-
/**
|
|
144
|
-
* Record a durable ENFORCE-REVERT debt (mx5 run 10 item 3): the enforce re-verify
|
|
145
|
-
* FAILED and the enforce edits were reverted, but the FAIL indicts the ORIGINAL
|
|
146
|
-
* work (run 10 TASK_0004: "Missing server entry point … the Hono server cannot be
|
|
147
|
-
* started"). Without this the diagnosis dies with the revert; recorded, the final
|
|
148
|
-
* gate re-checks and surfaces it like an accept-debt. Best-effort; absent in tests.
|
|
149
|
-
*/
|
|
150
|
-
recordEnforceRevertDebt?: (cwd: string, taskId: string, reason: string) => Promise<void>;
|
|
151
|
-
/**
|
|
152
|
-
* Record a durable FROZEN-BLOCKED debt (mx5 run 12 / PROMPT 1 layer B): a
|
|
153
|
-
* repo-health FAIL whose only fix is an edit to a path THIS task's spec froze —
|
|
154
|
-
* a cross-task contradiction. Recorded when the loop routes such a FAIL to the
|
|
155
|
-
* picker (whatever the human then picks, the defect is real and no task may fix
|
|
156
|
-
* it), so the final gate re-checks it at run end. Best-effort; absent in tests.
|
|
157
|
-
*/
|
|
158
|
-
recordFrozenBlockedDebt?: (cwd: string, taskId: string, reason: string) => Promise<void>;
|
|
159
|
-
/**
|
|
160
|
-
* Record a durable CROSS-TASK DELETION debt (mx5 run 12 PROMPT 2): the task's
|
|
161
|
-
* work deleted a file a DIFFERENT task's commit introduced, verify FAILed with
|
|
162
|
-
* the deterministic finding attached, and the user ACCEPTed anyway — the
|
|
163
|
-
* deletion ships in the next commit, so the final gate must re-check it
|
|
164
|
-
* (resolved iff the file is back in the tree). Best-effort; absent in tests.
|
|
165
|
-
*/
|
|
166
|
-
recordCrossTaskDeletionDebt?: (cwd: string, taskId: string, deletion: {
|
|
167
|
-
path: string;
|
|
168
|
-
owner: string;
|
|
169
|
-
}) => Promise<void>;
|
|
170
|
-
/**
|
|
171
|
-
* Record a durable ROOT-CAUSE debt (mx5 run 14 item 5): this task's verify
|
|
172
|
-
* FAILed on a pre-existing defect in a file ANOTHER task created and this task
|
|
173
|
-
* never touched. Its work is kept (it is not at fault) but the defect is real,
|
|
174
|
-
* so the final gate must re-check and surface it. Best-effort; absent in tests.
|
|
175
|
-
*/
|
|
176
|
-
recordRootCauseDebt?: (cwd: string, taskId: string, reason: string) => Promise<void>;
|
|
177
|
-
/**
|
|
178
|
-
* Record a durable ENFORCE-KEPT debt (mx5 run 18 / nexttask 4): the enforce
|
|
179
|
-
* re-verify FAILED but the failing check names only files the ENFORCE COMMIT
|
|
180
|
-
* does not touch, so the edits were KEPT. The defect is still real and still in
|
|
181
|
-
* the shipped tree — keeping the work must not lose the finding.
|
|
182
|
-
*/
|
|
183
|
-
recordEnforceKeptDebt?: (cwd: string, taskId: string, reason: string) => Promise<void>;
|
|
145
|
+
recordDebt?: (cwd: string, taskId: string, reason: string, origin: DebtOrigin) => Promise<void>;
|
|
184
146
|
/**
|
|
185
147
|
* Queue a scoped repair task for a root-caused defect. The gate DETECTS the
|
|
186
148
|
* cause; only the /task-auto loop may mutate the plan, so the two are decoupled
|
|
187
149
|
* through the durable `.pi-tasks/repair-queue.md` ledger this writes (see
|
|
188
|
-
* root-cause-repair.ts). Absent (
|
|
189
|
-
*
|
|
150
|
+
* root-cause-repair.ts). Absent (tests) → detection still records the debt,
|
|
151
|
+
* nothing is scheduled. buildGateDeps always supplies it, so a bare `/task`
|
|
152
|
+
* queues repairs exactly like /task-auto — only the plan mutation is the loop's.
|
|
190
153
|
*/
|
|
191
154
|
recordRepairCandidate?: (cwd: string, candidate: RepairCandidate) => Promise<void>;
|
|
192
155
|
/**
|