@mjasnikovs/pi-task 0.38.15 → 0.38.16

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 (62) hide show
  1. package/dist/shared/child-process.js +9 -16
  2. package/dist/task/accept-debt.d.ts +7 -5
  3. package/dist/task/accept-debt.js +16 -13
  4. package/dist/task/auto-orchestrator.js +38 -36
  5. package/dist/task/autofix-ledger.d.ts +113 -0
  6. package/dist/task/autofix-ledger.js +152 -0
  7. package/dist/task/boot-probe.d.ts +63 -1
  8. package/dist/task/boot-probe.js +98 -2
  9. package/dist/task/child-runner.d.ts +50 -6
  10. package/dist/task/child-runner.js +48 -69
  11. package/dist/task/command-run.d.ts +49 -6
  12. package/dist/task/command-run.js +154 -18
  13. package/dist/task/external-context.d.ts +9 -12
  14. package/dist/task/external-context.js +5 -5
  15. package/dist/task/failure-classifier.d.ts +9 -1
  16. package/dist/task/failure-classifier.js +9 -0
  17. package/dist/task/final-gate-fix.d.ts +22 -26
  18. package/dist/task/final-gate-fix.js +2 -7
  19. package/dist/task/final-gate.d.ts +10 -2
  20. package/dist/task/final-gate.js +49 -88
  21. package/dist/task/gate-deps.js +20 -13
  22. package/dist/task/orchestrator.d.ts +33 -24
  23. package/dist/task/orchestrator.js +66 -44
  24. package/dist/task/phases.d.ts +58 -34
  25. package/dist/task/phases.js +140 -113
  26. package/dist/task/plan-orchestrator.js +2 -2
  27. package/dist/task/repo-health-check.d.ts +21 -21
  28. package/dist/task/repo-health-check.js +43 -112
  29. package/dist/task/run-end.d.ts +77 -0
  30. package/dist/task/run-end.js +37 -0
  31. package/dist/task/run-final-gate.js +71 -79
  32. package/dist/task/task-gates.d.ts +8 -0
  33. package/dist/task/task-gates.js +23 -4
  34. package/dist/task/terminal-outcome.d.ts +1 -1
  35. package/dist/task/terminal-outcome.js +12 -0
  36. package/dist/workers/brave-search.d.ts +7 -0
  37. package/dist/workers/brave-search.js +36 -55
  38. package/dist/workers/ddg-search.d.ts +1 -1
  39. package/dist/workers/ddg-search.js +27 -47
  40. package/dist/workers/exa-search.d.ts +2 -2
  41. package/dist/workers/exa-search.js +53 -68
  42. package/dist/workers/html-clean.js +67 -88
  43. package/dist/workers/http-request.d.ts +74 -0
  44. package/dist/workers/http-request.js +103 -0
  45. package/dist/workers/npm-version.js +37 -42
  46. package/dist/workers/pi-worker-core.d.ts +13 -2
  47. package/dist/workers/pi-worker-core.js +12 -17
  48. package/dist/workers/pi-worker-docs.d.ts +1 -1
  49. package/dist/workers/pi-worker-docs.js +49 -68
  50. package/dist/workers/pi-worker-fetch.d.ts +1 -1
  51. package/dist/workers/pi-worker-fetch.js +20 -21
  52. package/dist/workers/pi-worker-search.js +6 -4
  53. package/dist/workers/pi-worker.js +5 -4
  54. package/dist/workers/search-core.d.ts +1 -1
  55. package/dist/workers/search-core.js +36 -42
  56. package/dist/workers/search-types.d.ts +13 -0
  57. package/dist/workers/search-types.js +27 -0
  58. package/dist/workers/shared.d.ts +51 -11
  59. package/dist/workers/shared.js +0 -0
  60. package/dist/workers/worker-channels.d.ts +60 -0
  61. package/dist/workers/worker-channels.js +98 -0
  62. package/package.json +1 -1
@@ -29,10 +29,10 @@
29
29
  * environment gap, not a code fault, so that command is SKIPPED — only a command that
30
30
  * actually ran and returned non-zero fails the check.
31
31
  */
32
- import { spawn, spawnSync } from 'node:child_process';
33
32
  import { existsSync, readFileSync } from 'node:fs';
34
33
  import * as path from 'node:path';
35
- import { resolveRunner, runnerEnv, isCommandNotFound } from './runner-resolve.js';
34
+ import { resolveRunner, runnerEnv } from './runner-resolve.js';
35
+ import { classifyCommandRun, spawnCommand } from './command-run.js';
36
36
  /** How much of a failing command's output to keep — bounded so a wedged tool that
37
37
  * spews megabytes cannot bloat the trail. stderr leads (a crash trace lives there). */
38
38
  const HEALTH_OUTPUT_MAX_LINES = 40;
@@ -106,141 +106,72 @@ export function discoverHealthCommands(cwd) {
106
106
  }
107
107
  return { ecosystem: null, cmds: [] };
108
108
  }
109
- /**
110
- * Verdict for ONE finished command: 'skip' (environment gap — cannot conclude),
111
- * 'pass', or the FAIL outcome. Shared by the sync and async runners so their
112
- * semantics cannot drift apart — the async runner exists only to stop blocking the
113
- * event loop, and a behaviour difference between the two would be a silent gate
114
- * change rather than a UI fix.
115
- */
116
- function classifyHealthRun(bin, args, ecosystem, r) {
117
- // Tool missing (ENOENT) or killed by timeout → cannot conclude; skip it.
118
- if (r.failedToStart || r.status === null)
119
- return 'skip';
120
- // "Command not found" INSIDE the script chain (e.g. `bun run lint` before
121
- // node_modules exists — seen live failing TASK_0001's first verify). Same
122
- // environment gap as ENOENT, just surfaced through the runner's shell —
123
- // as exit 127 where a posix shell ran it, else by the runner's own wording
124
- // (Windows bun reports the miss itself and exits 1).
125
- if (isCommandNotFound(r.status, `${r.stdout ?? ''}\n${r.stderr ?? ''}`))
126
- return 'skip';
127
- if (r.status !== 0) {
128
- return {
129
- ok: false,
130
- reason: `\`${bin} ${args.join(' ')}\` exited ${r.status}`,
131
- ecosystem,
132
- output: captureHealthOutput(r.stdout, r.stderr)
133
- };
134
- }
135
- return 'pass';
136
- }
137
109
  /** The nothing-to-run outcome, shared by both runners. */
138
110
  function noCommandOutcome(ecosystem) {
139
111
  return { ok: true, reason: 'no repo-wide static-analysis command found', ecosystem, output: '' };
140
112
  }
141
113
  /**
142
114
  * Run the discovered static checks whole-repo and let the real exit codes decide.
143
- * Deterministic and synchronous under the hood (a wrapper keeps the caller async).
144
115
  *
145
116
  * - No manifest / no static command → ok (nothing can regress).
146
- * - A command that CANNOT run (ENOENT / null exit tool not installed) → skipped,
117
+ * - A command that CANNOT run (ENOENT / null exit / 127 inside the chain) → skipped,
147
118
  * treated as an environment gap, not a fault.
148
119
  * - A command that ran and exited non-zero → the first such failure is returned.
149
120
  *
150
- * A generous per-command timeout guards against a wedged tool; a timeout is treated
151
- * as an inconclusive skip, not a fault (it is an environment problem, not the code's).
121
+ * This module owns DISCOVERY and its own output policy; running a command and
122
+ * deciding what its ending MEANS is `command-run.ts`'s. It used to own those too
123
+ * `HealthRun`, `classifyHealthRun` and `spawnHealthCommand` were a second statement
124
+ * of the gap ladder, with no injectable runner, so every classification case in the
125
+ * suite spawned a real shell. `command-run.ts`'s own header notes that this module
126
+ * "had solved exactly this shape years earlier" and the gate never adopted it; this
127
+ * is the adoption, in the other direction.
152
128
  *
153
- * SYNCHRONOUS it blocks the event loop for as long as the project's own lint takes
154
- * (MEASURED: 15s on mx5, 69s on aiz-client), so nothing can render or animate while
155
- * it runs. Gate callers must use {@link runRepoHealthCheckAsync} instead; this stays
156
- * for callers that genuinely have no async seam.
129
+ * `captureHealthOutput` stays this module's own: 40 lines of a linter's report is a
130
+ * real difference from `outputTail`'s 400 characters, and that is a parameter, not a
131
+ * thing to unify.
132
+ *
133
+ * `onCommand` lets the caller name the running command in a live status line — the
134
+ * gate runs this immediately after the implementation turn ends, when the impl
135
+ * widget has just been cleared.
157
136
  */
158
- export function runRepoHealthCheck(cwd, timeoutMs = 600_000) {
137
+ export async function runRepoHealthCheck(cwd, opts = {}) {
159
138
  const { ecosystem, cmds } = discoverHealthCommands(cwd);
160
139
  if (!ecosystem || cmds.length === 0)
161
140
  return noCommandOutcome(ecosystem);
141
+ const run = opts.run ?? spawnCommand;
162
142
  for (const [bin, args] of cmds) {
143
+ opts.onCommand?.(`${bin} ${args.join(' ')}`);
163
144
  // Runner resolution (mx5 run 16): a PATH-stripped environment must not
164
145
  // silently skip the statics when the runner sits at a known install
165
146
  // location; the resolved dir also rides on PATH for the script chain.
166
147
  const runner = resolveRunner(bin);
167
- const r = spawnSync(runner.bin, args, {
148
+ const r = await run({
168
149
  cwd,
169
- encoding: 'utf8',
170
- timeout: timeoutMs,
171
- env: runnerEnv(runner)
150
+ bin: runner.bin,
151
+ args,
152
+ timeoutMs: opts.timeoutMs ?? 600_000,
153
+ env: runnerEnv(runner),
154
+ ...(opts.signal === undefined ? {} : { signal: opts.signal })
172
155
  });
173
- const verdict = classifyHealthRun(bin, args, ecosystem, {
174
- failedToStart: r.error !== undefined,
175
- status: r.status,
176
- stdout: r.stdout ?? '',
177
- stderr: r.stderr ?? ''
178
- });
179
- if (verdict === 'skip' || verdict === 'pass')
156
+ // The DECISION comes from the shared ladder; the OUTPUT is this module's own
157
+ // policy. `captureHealthOutput` keeps 40 lines of a linter's report where the
158
+ // ladder's `tail` keeps 400 characters, and that difference is real — a
159
+ // truncated lint report is unactionable. So the run is classified, not
160
+ // consumed: the verdict decides, the raw streams are what we show.
161
+ // `runtimeGap: false` — this ladder is NARROWER than the gate's. The
162
+ // browser/runtime row was written for the gate's TEST commands; here the
163
+ // commands are lint and typecheck, and its pattern matches ordinary
164
+ // English, so a genuine report quoting "browsers are not installed" would
165
+ // skip the static check and certify the repo healthy.
166
+ const verdict = classifyCommandRun(r, [], { runtimeGap: false });
167
+ if (verdict.outcome !== 'fail')
180
168
  continue;
181
- return verdict;
182
- }
183
- return { ok: true, reason: `${ecosystem}: static checks passed`, ecosystem, output: '' };
184
- }
185
- /** Spawn one health command without blocking the event loop. Mirrors spawnSync's
186
- * result shape (status null when killed, failedToStart on ENOENT). */
187
- function spawnHealthCommand(bin, args, cwd, timeoutMs, signal) {
188
- return new Promise(resolve => {
189
- const runner = resolveRunner(bin);
190
- let stdout = '';
191
- let stderr = '';
192
- let settled = false;
193
- const child = spawn(runner.bin, args, { cwd, env: runnerEnv(runner) });
194
- const done = (r) => {
195
- if (settled)
196
- return;
197
- settled = true;
198
- clearTimeout(timer);
199
- signal?.removeEventListener('abort', onAbort);
200
- resolve(r);
201
- };
202
- const kill = () => {
203
- try {
204
- child.kill('SIGKILL');
205
- }
206
- catch {
207
- /* already gone */
208
- }
169
+ return {
170
+ ok: false,
171
+ reason: `\`${bin} ${args.join(' ')}\` exited ${verdict.status}`,
172
+ ecosystem,
173
+ output: captureHealthOutput(r.stdout, r.stderr)
209
174
  };
210
- const timer = setTimeout(kill, timeoutMs);
211
- timer.unref?.();
212
- const onAbort = () => kill();
213
- signal?.addEventListener('abort', onAbort, { once: true });
214
- child.stdout?.on('data', (d) => {
215
- stdout += d.toString();
216
- });
217
- child.stderr?.on('data', (d) => {
218
- stderr += d.toString();
219
- });
220
- child.on('error', () => done({ failedToStart: true, status: null, stdout, stderr }));
221
- child.on('close', (code) => done({ failedToStart: false, status: code, stdout, stderr }));
222
- });
223
- }
224
- /**
225
- * Same check, same verdicts, without blocking the event loop.
226
- *
227
- * The gate runs this immediately after the implementation turn ends, when the impl
228
- * widget has just been cleared — the sync version froze the whole TUI there for the
229
- * duration of the project's lint (MEASURED: 0 of 686 expected 100ms timer ticks
230
- * fired during a 69s aiz-client run), so no spinner, clock or queued notify could
231
- * paint. `onCommand` lets the caller name the running command in a live status line.
232
- */
233
- export async function runRepoHealthCheckAsync(cwd, opts = {}) {
234
- const { ecosystem, cmds } = discoverHealthCommands(cwd);
235
- if (!ecosystem || cmds.length === 0)
236
- return noCommandOutcome(ecosystem);
237
- for (const [bin, args] of cmds) {
238
- opts.onCommand?.(`${bin} ${args.join(' ')}`);
239
- const r = await spawnHealthCommand(bin, args, cwd, opts.timeoutMs ?? 600_000, opts.signal);
240
- const verdict = classifyHealthRun(bin, args, ecosystem, r);
241
- if (verdict === 'skip' || verdict === 'pass')
242
- continue;
243
- return verdict;
244
175
  }
245
176
  return { ok: true, reason: `${ecosystem}: static checks passed`, ecosystem, output: '' };
246
177
  }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * run-end — how a single /task run ENDED, named once.
3
+ *
4
+ * `TaskRunner.run` returned `void` and never threw, so `runSingleTask` learned
5
+ * what it had just done by RE-READING the task file's front matter, narrowing
6
+ * that to `ok: boolean`, and smuggling the rest out of the `withSession` closure
7
+ * through three mutable captures. Both commands then re-derived a cause the
8
+ * runner already had: `classifyFailure` names the ending exactly, and
9
+ * `handleFailure` threw the name away.
10
+ *
11
+ * The live consequence was a wrong report. `/task-cancel` during a gated run
12
+ * writes `cancelled` to the file; `ok` is `state === 'completed'`, so it is
13
+ * false; `!res.ok` calls `markResumable`, which overwrites `cancelled` with
14
+ * `failed`, and announces a red *"stopped — fix and run /task-resume"*.
15
+ * `/task-auto` hits the same arm for the same input, because its cancel branch
16
+ * only consults a module global that `/task-cancel` never sets.
17
+ *
18
+ * The file is still written — it is what a RESUME reads. What changed is that it
19
+ * is no longer the channel this process uses to talk to itself.
20
+ */
21
+ /** Why a run stopped. Exactly one of these is true of any finished run. */
22
+ export type RunEnd =
23
+ /** Every phase ran and the spec was delivered. */
24
+ {
25
+ kind: 'completed';
26
+ }
27
+ /** The user cancelled — via /task-cancel, ESC, or an aborted signal. */
28
+ | {
29
+ kind: 'cancelled';
30
+ }
31
+ /** A phase threw. `reason` is `FailureClass.reason`, already trimmed. */
32
+ | {
33
+ kind: 'failed';
34
+ reason?: string;
35
+ }
36
+ /** The implementation turn was interrupted and left resumable. */
37
+ | {
38
+ kind: 'interrupted';
39
+ }
40
+ /** No fresh session could be started, so nothing ran at all. */
41
+ | {
42
+ kind: 'no-session';
43
+ };
44
+ export type RunEndKind = RunEnd['kind'];
45
+ /**
46
+ * What a command does about each ending.
47
+ *
48
+ * `resumable` and `announce` are the two facts the two hand-written ladders
49
+ * disagreed about, and the disagreement is where the cancel bug lived: a
50
+ * `cancelled` run was falling into the `failed` arm and being marked resumable.
51
+ * The WORDING stays per-command — `/task` says "resume with /task-resume" where
52
+ * `/task-auto` says "/task-auto-resume" — so only the policy is shared.
53
+ */
54
+ export interface RunEndPolicy {
55
+ /** Mark the task resumable (overwrites its state with `failed`). */
56
+ resumable: boolean;
57
+ /**
58
+ * Does this ending FAIL the plan that contains the task?
59
+ *
60
+ * Strictly narrower than `resumable`, and the distinction is load-bearing: a
61
+ * declined-steer interrupt leaves the inner task resumable but the PLAN in
62
+ * progress, so `/task-auto-resume` re-delivers that task's spec. A fault fails
63
+ * the plan too. Only `/task-auto` reads this — a bare `/task` has no plan.
64
+ */
65
+ failsRun: boolean;
66
+ /** The notify level for this ending. */
67
+ level: 'info' | 'warning' | 'error';
68
+ }
69
+ /**
70
+ * The one table. A run that the USER stopped is not resumable-as-failed: its
71
+ * file already says `cancelled`, and rewriting that to `failed` both lies in the
72
+ * ledger and turns a deliberate stop into a red error the user has to read as a
73
+ * fault.
74
+ */
75
+ export declare const RUN_END_POLICY: Record<RunEndKind, RunEndPolicy>;
76
+ /** Did the run deliver a spec? The single question the old `ok` boolean answered. */
77
+ export declare function runSucceeded(end: RunEnd): boolean;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * run-end — how a single /task run ENDED, named once.
3
+ *
4
+ * `TaskRunner.run` returned `void` and never threw, so `runSingleTask` learned
5
+ * what it had just done by RE-READING the task file's front matter, narrowing
6
+ * that to `ok: boolean`, and smuggling the rest out of the `withSession` closure
7
+ * through three mutable captures. Both commands then re-derived a cause the
8
+ * runner already had: `classifyFailure` names the ending exactly, and
9
+ * `handleFailure` threw the name away.
10
+ *
11
+ * The live consequence was a wrong report. `/task-cancel` during a gated run
12
+ * writes `cancelled` to the file; `ok` is `state === 'completed'`, so it is
13
+ * false; `!res.ok` calls `markResumable`, which overwrites `cancelled` with
14
+ * `failed`, and announces a red *"stopped — fix and run /task-resume"*.
15
+ * `/task-auto` hits the same arm for the same input, because its cancel branch
16
+ * only consults a module global that `/task-cancel` never sets.
17
+ *
18
+ * The file is still written — it is what a RESUME reads. What changed is that it
19
+ * is no longer the channel this process uses to talk to itself.
20
+ */
21
+ /**
22
+ * The one table. A run that the USER stopped is not resumable-as-failed: its
23
+ * file already says `cancelled`, and rewriting that to `failed` both lies in the
24
+ * ledger and turns a deliberate stop into a red error the user has to read as a
25
+ * fault.
26
+ */
27
+ export const RUN_END_POLICY = {
28
+ completed: { resumable: false, failsRun: false, level: 'info' },
29
+ cancelled: { resumable: false, failsRun: false, level: 'warning' },
30
+ interrupted: { resumable: true, failsRun: false, level: 'warning' },
31
+ failed: { resumable: true, failsRun: true, level: 'error' },
32
+ 'no-session': { resumable: false, failsRun: false, level: 'warning' }
33
+ };
34
+ /** Did the run deliver a spec? The single question the old `ok` boolean answered. */
35
+ export function runSucceeded(end) {
36
+ return end.kind === 'completed';
37
+ }
@@ -1,3 +1,4 @@
1
+ import { AutofixLedger } from './autofix-ledger.js';
1
2
  import { describeDebt, recordDebt } from './accept-debt.js';
2
3
  import { cancelCheckpoint } from './cancel-points.js';
3
4
  import { SessionUI } from '../remote/bridge.js';
@@ -5,7 +6,9 @@ import { isYoloMode, yoloFinalGateChoice, YOLO_STAMP } from './yolo.js';
5
6
  import { ignoredWriteTrailLine, ignoredWriteDebtReason } from './write-guard.js';
6
7
  import { readOwnedRequirements } from './requirements.js';
7
8
  import { unclaimedPendingRequirements } from './owned-freeze-reassign.js';
8
- import { applyDemotions, isNonProgress, normalizeFailureDetail, rankedFirstFailure, unobservedDebtReason } from './final-gate-progress.js';
9
+ // `final-gate-progress.ts`'s five pure functions are `AutofixLedger`'s internals
10
+ // now — each was called from exactly ONE site inside this loop, extracted for
11
+ // testability while the ordering and carry-forward decisions stayed out here.
9
12
  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
13
  /**
11
14
  * Show the run-end picker and return the raw answer. Card ORDER is fixed —
@@ -227,45 +230,34 @@ export async function runFinalGateStage(active, deps, p) {
227
230
  // pass + gate re-run — run 7's gap: the picker had NO automated fix path) /
228
231
  // Accept. The user always decides; after MAX_FINAL_GATE_AUTOFIX attempts that
229
232
  // 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 = [];
233
+ // What this loop RECORDS, and the decisions that record makes: the attempt
234
+ // count and its bound, the accumulated gitignored writes, the stranded
235
+ // sub-fixes, the previous failure signature, the demoted set and the
236
+ // rejected-edits flag. Six closure-threaded locals before, and the
237
+ // non-progress rule applied downstream from the evidence it judges — the shape
238
+ // `final-gate-progress.ts`'s own comment names as the run-21 defect.
239
+ // `GateTally`'s twin, one altitude up (autofix-ledger.ts).
240
+ const ledger = new AutofixLedger(MAX_FINAL_GATE_AUTOFIX);
240
241
  const refreshStranded = async () => {
241
242
  if (!deps.pendingChanges)
242
243
  return;
243
244
  try {
244
- stranded = await deps.pendingChanges(cwd);
245
+ ledger.setStranded(await deps.pendingChanges(cwd));
245
246
  }
246
247
  catch {
247
248
  // Inconclusive: say nothing rather than claim a clean tree.
248
- stranded = [];
249
+ ledger.setStranded([]);
249
250
  }
250
251
  };
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
252
  // Commit whatever guard-clean repairs the fix passes left, on ANY terminal
262
253
  // non-converged outcome. Run 14 ended on LEAVE with 13 real repairs dirty in the
263
254
  // tree after an unattended run — the next checkout would have destroyed them
264
255
  // silently.
265
256
  const commitStranded = async (outcome) => {
257
+ const stranded = ledger.stranded();
266
258
  if (stranded.length === 0)
267
259
  return;
268
- if (rejectedEditsInTree) {
260
+ if (!ledger.mayCommitTree()) {
269
261
  await recGate(`final-gate: NOT committing ${stranded.length} working-tree change(s) — a `
270
262
  + `write-guard rejected an attempt and its edits could not be discarded, `
271
263
  + `so the tree holds REJECTED edits: ${stranded.slice(0, 8).join(', ')}`);
@@ -300,20 +292,20 @@ export async function runFinalGateStage(active, deps, p) {
300
292
  }
301
293
  };
302
294
  while (!fin.ok) {
303
- const canAutofix = deps.finalGateFix !== undefined && fixAttempts < MAX_FINAL_GATE_AUTOFIX;
295
+ const canAutofix = deps.finalGateFix !== undefined && ledger.canAutofix();
304
296
  // The picker question shows the debts (the HUMAN weighs them); the autofix seed
305
297
  // below deliberately does not — mx5 run 11's fix child executed a debt claim as
306
298
  // an `rm` instruction.
307
299
  const question = `Final integration gate FAILED for ${id}.\n\n${fin.reason}${fin.debtNote ?? ''}\n\n`
308
300
  + 'All tasks are checked off — this is the whole-repo check '
309
301
  + '(the project’s own test/build/static commands, run unaided).'
310
- + (fixAttempts > 0 ?
311
- `\n\nAutofix attempts so far: ${fixAttempts}/${MAX_FINAL_GATE_AUTOFIX}.`
302
+ + (ledger.attempts() > 0 ?
303
+ `\n\nAutofix attempts so far: ${ledger.attempts()}/${MAX_FINAL_GATE_AUTOFIX}.`
312
304
  : '')
313
305
  // Never let a partial repair be invisible at the moment the human decides
314
306
  // (run 13: a bunfig fix that made `bun run test` pass 116/116 was stranded
315
307
  // by an ACCEPT).
316
- + strandedFixNote(stranded);
308
+ + strandedFixNote([...ledger.stranded()]);
317
309
  // YOLO: keep autofixing WHILE the card is still offered — the loop withdraws it
318
310
  // after MAX_FINAL_GATE_AUTOFIX, so the cap that bounds a non-converging fix pass
319
311
  // still bounds this — then LEAVE the run failed. Never 'accept': an unattended
@@ -339,17 +331,17 @@ export async function runFinalGateStage(active, deps, p) {
339
331
  // instruction to throw away work (mx5 run 13 item 3).
340
332
  await commitStranded('accepted');
341
333
  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.`
334
+ + (ledger.stranded().length > 0 ?
335
+ ` ${ledger.stranded().length} uncommitted fix-pass change(s) committed separately.`
344
336
  : ''), 'warning');
345
337
  break;
346
338
  }
347
339
  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');
340
+ const attempt = ledger.attempt();
341
+ await recGate(`final-gate: user chose AUTOFIX (attempt ${attempt}/${MAX_FINAL_GATE_AUTOFIX})`);
342
+ active.ui.notify(`${id}: final-gate autofix (${attempt}/${MAX_FINAL_GATE_AUTOFIX}) — bounded fix pass, then the gate re-runs…`, 'info');
351
343
  const seed = choice.guidance ? `${fin.reason}\n\nUser guidance: ${choice.guidance}` : fin.reason;
352
- const fix = await deps.finalGateFix(active, cwd, seed, ignoredWritten);
344
+ const fix = await deps.finalGateFix(active, cwd, seed, [...ledger.ignoredWrites()]);
353
345
  // IGNORED-PATH WRITES (mx5 run 19). The pass wrote file(s) git ignores, so
354
346
  // they are not in the commit and a fresh clone does not have them. Trailed
355
347
  // on EVERY outcome — a rejected attempt's tracked edits are discarded while
@@ -358,7 +350,7 @@ export async function runFinalGateStage(active, deps, p) {
358
350
  // own attempt. PATH NAMES ONLY: an ignored file's contents (`.env` is the
359
351
  // canonical case) never enter a log, a debt or a child prompt.
360
352
  if (fix.ignoredWrites && fix.ignoredWrites.length > 0) {
361
- ignoredWritten = [...new Set([...ignoredWritten, ...fix.ignoredWrites])].sort();
353
+ ledger.wroteIgnored(fix.ignoredWrites);
362
354
  await recGate(ignoredWriteTrailLine(fix.ignoredWrites));
363
355
  // Debt only where a verdict can rest on the file: the probe proved the
364
356
  // gate needs it, or the question stayed open. A write the gate
@@ -379,26 +371,30 @@ export async function runFinalGateStage(active, deps, p) {
379
371
  }
380
372
  await recGate(`final-gate: autofix ${fix.unobserved ? 'ended UNOBSERVED' : 'converged'} — ${fix.reason.slice(0, 200)}`);
381
373
  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 };
374
+ // The gate's own outcome, whole, with this door's reason on it. It
375
+ // used to be a two-key literal, so `openDebts` and `observedFailures`
376
+ // were dropped and `reconcileDebts` was the only thing putting one of
377
+ // them back — the recorded mx5 run-18 defect.
378
+ fin = { ...(fix.gate ?? fin), ok: true, reason: fix.reason };
383
379
  // The gate itself just passed, statics included, so `staticOk` here is
384
380
  // proof rather than assumption.
385
381
  await reconcileDebts(true);
386
382
  break;
387
383
  }
388
- await recGate(`final-gate: autofix attempt ${fixAttempts} failed — ${fix.reason.slice(0, 200)}`);
384
+ await recGate(`final-gate: autofix attempt ${attempt} failed — ${fix.reason.slice(0, 200)}`);
389
385
  // A guard that rejected an attempt WITHOUT discarding leaves rejected edits
390
386
  // behind: the terminal paths must not commit the tree after that (the cheat
391
387
  // guard stays intact).
392
388
  if (fix.guardTripped === true && fix.editsDiscarded !== true) {
393
- rejectedEditsInTree = true;
389
+ ledger.rejectedEditsRemain();
394
390
  }
395
391
  // The attempt's edits survive a non-convergence (only a guard trip
396
392
  // discards). Find out what they are NOW, so the next picker shows them and
397
393
  // a terminal outcome commits them.
398
394
  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(', ')}`);
395
+ if (ledger.stranded().length > 0) {
396
+ await recGate(`final-gate: autofix attempt ${attempt} left ${ledger.stranded().length} `
397
+ + `uncommitted change(s) — ${ledger.stranded().slice(0, 8).join(', ')}`);
402
398
  }
403
399
  active.ui.notify(`${id}: final-gate autofix did not converge — ${fix.reason.slice(0, 140)}`, 'warning');
404
400
  // NON-PROGRESS CLASSIFIER (mx5 run 14 item 2a). An attempt that changed the
@@ -407,46 +403,32 @@ export async function runFinalGateStage(active, deps, p) {
407
403
  // burned all three attempts on a boot probe that could not observe a
408
404
  // listener in that sandbox at all. Demote that one check to
409
405
  // 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
- // …and whether a PROBE OBSERVED this failure (nexttask 19A). Exact text
416
- // identity against the gate's own observed subset not a second string
417
- // pattern, which is the mistake `isNonProgress` already made once.
418
- const observed = fix.gateObservedFailures?.includes(detail ?? '') === true;
419
- if (detail !== null
420
- && isNonProgress({
421
- previousSignature: prevFailSig,
422
- currentDetail: detail,
423
- edited,
424
- observed
425
- })) {
426
- demoted.add(normalizeFailureDetail(detail));
427
- prevFailSig = null;
428
- await carryDebt(unobservedDebtReason(detail));
429
- await recGate(`final-gate: check DEMOTED to UNOBSERVED after ${fixAttempts} tree-changing `
406
+ //
407
+ // The judgement, the observed check and the signature carry-forward are
408
+ // the ledger's — made where the evidence is, not downstream from it.
409
+ const verdict = ledger.judge(fix.gate, fix.gate !== undefined && ledger.stranded().length > 0);
410
+ if (verdict.demoted) {
411
+ await carryDebt(verdict.debtReason);
412
+ await recGate(`final-gate: check DEMOTED to UNOBSERVED after ${attempt} tree-changing `
430
413
  + `attempts returned an identical failure — carried as debt (origin final-gate) `
431
- + `and re-checked by the next run's gate: ${detail.slice(0, 240)}`);
414
+ + `and re-checked by the next run's gate: ${verdict.detail.slice(0, 240)}`);
432
415
  active.ui.notify(`${id}: final-gate check is unfalsifiable in this environment — carried as debt; `
433
416
  + 'the remaining checks decide convergence.', 'warning');
434
417
  }
435
- else {
436
- prevFailSig = detail !== null ? normalizeFailureDetail(detail) : null;
437
- }
438
418
  // Convergence on the REMAINING checks: a demoted signature no longer counts
439
419
  // against the gate. Nothing left ⇒ the run converges carrying the demotion
440
420
  // as debt, and the fix passes' repairs are committed rather than stranded.
441
- if (demoted.size > 0 && fix.gateReason !== undefined) {
442
- const remaining = applyDemotions(fix.gateFailures ?? [fix.gateReason], demoted);
443
- if (remaining.length === 0) {
421
+ if (ledger.hasDemotions() && fix.gate !== undefined) {
422
+ const remaining = ledger.remaining(fix.gate);
423
+ if (remaining !== undefined && remaining.length === 0) {
444
424
  await deps.commit(cwd, `FINAL GATE AUTOFIX (${id})`);
445
- const converged = `converged on all remaining checks; ${demoted.size} check(s) `
425
+ const converged = `converged on all remaining checks; ${ledger.demotedCount()} check(s) `
446
426
  + 'carried as UNOBSERVED debt (unfalsifiable in this environment)';
447
427
  await recGate(`final-gate: ${converged}`);
448
428
  active.ui.notify(`${id}: final integration gate converged — ${converged}.`, 'warning');
449
- fin = { ok: true, reason: converged };
429
+ // The gate's own outcome, with this door's reason on it — the
430
+ // whole value, so `openDebts` survives the assignment.
431
+ fin = { ...fix.gate, ok: true, reason: converged };
450
432
  // Converged on the REMAINING checks only: one or more were DEMOTED
451
433
  // as unfalsifiable here, and the statics may be among them. No
452
434
  // proof ⇒ pass false, so nothing static-class can auto-close on
@@ -464,17 +446,27 @@ export async function runFinalGateStage(active, deps, p) {
464
446
  // next picker and the next fix seed target only what is still falsifiable —
465
447
  // never re-aiming the child at the check the classifier just proved it
466
448
  // cannot move.
467
- const freshFailures = fix.gateReason !== undefined ? fix.gateFailures : fin.failures;
468
- const carried = freshFailures !== undefined ? applyDemotions(freshFailures, demoted) : undefined;
449
+ // Outcome to outcome. The base is the FRESH gate outcome when the fix
450
+ // pass got as far as re-running it, otherwise the one we already hold —
451
+ // and either way it arrives whole, so nothing (`openDebts`,
452
+ // `observedFailures`) is dropped by the assignment. This used to be a
453
+ // literal with four keys, and the field it omitted is the recorded mx5
454
+ // run-18 defect.
455
+ const base = fix.gate ?? fin;
456
+ const carried = base.failures === undefined ? undefined : ledger.remaining(base);
469
457
  fin = {
458
+ ...base,
470
459
  ok: false,
471
- reason: demoted.size > 0 && carried !== undefined && carried.length > 0 ?
460
+ reason: ledger.hasDemotions() && carried !== undefined && carried.length > 0 ?
472
461
  carried[0]
473
- : (fix.gateReason ?? fin.reason),
474
- failures: carried,
475
- debtNote: fin.debtNote
462
+ : base.reason,
463
+ ...(carried === undefined ? {} : { failures: carried }),
464
+ // The debt NOTE is the caller's running one: the next picker must
465
+ // still show the open claims, and the fresh gate's own note is
466
+ // reconciled separately against the final tree.
467
+ ...(fin.debtNote === undefined ? {} : { debtNote: fin.debtNote })
476
468
  };
477
- if (fix.gateReason !== undefined && (fix.gateFailures?.length ?? 0) > 1) {
469
+ if (fix.gate !== undefined && (fix.gate.failures?.length ?? 0) > 1) {
478
470
  await trailGateFail(fin);
479
471
  }
480
472
  continue;
@@ -492,8 +484,8 @@ export async function runFinalGateStage(active, deps, p) {
492
484
  return {
493
485
  kind: 'failed',
494
486
  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).`
495
- + (stranded.length > 0 ?
496
- ` NOTE: ${stranded.length} fix-pass change(s) were committed separately (${stranded.slice(0, 4).join(', ')}).`
487
+ + (ledger.stranded().length > 0 ?
488
+ ` NOTE: ${ledger.stranded().length} fix-pass change(s) were committed separately (${ledger.stranded().slice(0, 4).join(', ')}).`
497
489
  : '')
498
490
  };
499
491
  }
@@ -248,6 +248,14 @@ export type GateResult = {
248
248
  kind: 'interrupted';
249
249
  ctx: ExtensionCommandContext;
250
250
  }
251
+ /**
252
+ * The USER cancelled an AUTOFIX re-run. Distinct from `interrupted`: the task
253
+ * file already says `cancelled` and must not be demoted to `failed` over it.
254
+ */
255
+ | {
256
+ kind: 'cancelled';
257
+ ctx: ExtensionCommandContext;
258
+ }
251
259
  /** An AUTOFIX re-run's implementation itself failed. */
252
260
  | {
253
261
  kind: 'failed';