@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.
Files changed (38) hide show
  1. package/dist/shared/child-output.d.ts +19 -3
  2. package/dist/shared/child-output.js +21 -5
  3. package/dist/shared/git-runner.d.ts +39 -0
  4. package/dist/shared/git-runner.js +38 -0
  5. package/dist/task/accept-debt.d.ts +27 -58
  6. package/dist/task/accept-debt.js +60 -130
  7. package/dist/task/auto-orchestrator.d.ts +7 -57
  8. package/dist/task/auto-orchestrator.js +25 -499
  9. package/dist/task/child-runner.d.ts +2 -0
  10. package/dist/task/child-runner.js +74 -70
  11. package/dist/task/enforce-guidelines.d.ts +1 -1
  12. package/dist/task/enforce-guidelines.js +2 -2
  13. package/dist/task/external-context.d.ts +85 -7
  14. package/dist/task/external-context.js +100 -63
  15. package/dist/task/file-inventory.js +22 -41
  16. package/dist/task/final-gate.d.ts +80 -0
  17. package/dist/task/final-gate.js +102 -49
  18. package/dist/task/gate-deps.js +6 -23
  19. package/dist/task/git-state-guard.d.ts +1 -1
  20. package/dist/task/git-state-guard.js +1 -7
  21. package/dist/task/phases.js +40 -83
  22. package/dist/task/run-final-gate.d.ts +127 -0
  23. package/dist/task/run-final-gate.js +492 -0
  24. package/dist/task/task-gates.d.ts +20 -57
  25. package/dist/task/task-gates.js +11 -11
  26. package/dist/task/verify-work.d.ts +40 -32
  27. package/dist/task/verify-work.js +301 -241
  28. package/dist/workers/docs-core.d.ts +14 -0
  29. package/dist/workers/docs-core.js +28 -16
  30. package/dist/workers/fetch-core.d.ts +6 -1
  31. package/dist/workers/fetch-core.js +26 -33
  32. package/dist/workers/focused-extractor.d.ts +73 -0
  33. package/dist/workers/focused-extractor.js +72 -0
  34. package/dist/workers/pi-worker-docs.d.ts +1 -1
  35. package/dist/workers/pi-worker-docs.js +48 -42
  36. package/dist/workers/pi-worker-fetch.js +6 -8
  37. package/dist/workers/typeonly-log.d.ts +13 -0
  38. 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 a durable ACCEPT-despite-verify-FAIL debt (task id + FAIL reason) to the
129
- * run-level ledger (`.pi-tasks/accept-debt.md`, see accept-debt.ts). Called only on
130
- * the picker's ACCEPT branch the human blessed a failing artifact as-is, so the
131
- * defect is real and recorded; the final integration gate re-checks it at run end
132
- * and surfaces it if still open. Best-effort; absent in tests no ledger written.
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
- recordAcceptDebt?: (cwd: string, taskId: string, reason: string) => Promise<void>;
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 (bare `/task`, tests) → detection still records
189
- * the debt, nothing is scheduled.
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
  /**