@mjasnikovs/pi-task 0.38.15 → 0.38.17

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 (86) hide show
  1. package/dist/config/config.d.ts +26 -0
  2. package/dist/config/config.js +68 -17
  3. package/dist/shared/child-process.js +9 -16
  4. package/dist/task/accept-debt.d.ts +7 -5
  5. package/dist/task/accept-debt.js +18 -14
  6. package/dist/task/artifact-closure.js +18 -63
  7. package/dist/task/auto-orchestrator.js +211 -218
  8. package/dist/task/autofix-ledger.d.ts +113 -0
  9. package/dist/task/autofix-ledger.js +152 -0
  10. package/dist/task/boot-probe.d.ts +109 -1
  11. package/dist/task/boot-probe.js +139 -23
  12. package/dist/task/child-runner.d.ts +50 -6
  13. package/dist/task/child-runner.js +48 -69
  14. package/dist/task/command-run.d.ts +49 -6
  15. package/dist/task/command-run.js +154 -18
  16. package/dist/task/coverage-loop.d.ts +11 -0
  17. package/dist/task/coverage-loop.js +16 -0
  18. package/dist/task/external-context.d.ts +9 -12
  19. package/dist/task/external-context.js +5 -5
  20. package/dist/task/failure-classifier.d.ts +9 -1
  21. package/dist/task/failure-classifier.js +9 -0
  22. package/dist/task/final-gate-fix.d.ts +22 -26
  23. package/dist/task/final-gate-fix.js +16 -31
  24. package/dist/task/final-gate.d.ts +10 -2
  25. package/dist/task/final-gate.js +55 -89
  26. package/dist/task/fix-child.d.ts +64 -0
  27. package/dist/task/fix-child.js +66 -0
  28. package/dist/task/gate-deps.js +20 -13
  29. package/dist/task/lint-fix.d.ts +7 -0
  30. package/dist/task/lint-fix.js +45 -9
  31. package/dist/task/orchestrator.d.ts +33 -24
  32. package/dist/task/orchestrator.js +75 -46
  33. package/dist/task/phases.d.ts +120 -34
  34. package/dist/task/phases.js +221 -134
  35. package/dist/task/plan-orchestrator.js +2 -2
  36. package/dist/task/plan-rounds.d.ts +86 -0
  37. package/dist/task/plan-rounds.js +105 -0
  38. package/dist/task/plan-session.d.ts +31 -21
  39. package/dist/task/plan-session.js +97 -120
  40. package/dist/task/qa-transcript.d.ts +100 -0
  41. package/dist/task/qa-transcript.js +99 -0
  42. package/dist/task/question-source.d.ts +117 -0
  43. package/dist/task/question-source.js +174 -0
  44. package/dist/task/repo-health-check.d.ts +21 -21
  45. package/dist/task/repo-health-check.js +43 -112
  46. package/dist/task/run-end.d.ts +77 -0
  47. package/dist/task/run-end.js +37 -0
  48. package/dist/task/run-final-gate.js +71 -79
  49. package/dist/task/serve-entry.js +6 -57
  50. package/dist/task/shipped-source.d.ts +67 -0
  51. package/dist/task/shipped-source.js +144 -0
  52. package/dist/task/task-gates.d.ts +9 -1
  53. package/dist/task/task-gates.js +27 -6
  54. package/dist/task/terminal-outcome.d.ts +1 -1
  55. package/dist/task/terminal-outcome.js +12 -0
  56. package/dist/task/verify-work.d.ts +46 -0
  57. package/dist/task/verify-work.js +51 -3
  58. package/dist/workers/brave-search.d.ts +7 -0
  59. package/dist/workers/brave-search.js +36 -55
  60. package/dist/workers/ddg-search.d.ts +1 -1
  61. package/dist/workers/ddg-search.js +27 -47
  62. package/dist/workers/docs-core.d.ts +71 -1
  63. package/dist/workers/docs-core.js +131 -71
  64. package/dist/workers/exa-search.d.ts +2 -2
  65. package/dist/workers/exa-search.js +53 -68
  66. package/dist/workers/html-clean.js +67 -88
  67. package/dist/workers/http-request.d.ts +74 -0
  68. package/dist/workers/http-request.js +103 -0
  69. package/dist/workers/npm-version.js +37 -42
  70. package/dist/workers/pi-worker-core.d.ts +13 -2
  71. package/dist/workers/pi-worker-core.js +35 -25
  72. package/dist/workers/pi-worker-docs.d.ts +1 -1
  73. package/dist/workers/pi-worker-docs.js +49 -68
  74. package/dist/workers/pi-worker-fetch.d.ts +1 -1
  75. package/dist/workers/pi-worker-fetch.js +20 -21
  76. package/dist/workers/pi-worker-search.js +6 -4
  77. package/dist/workers/pi-worker.js +5 -4
  78. package/dist/workers/search-core.d.ts +1 -1
  79. package/dist/workers/search-core.js +36 -42
  80. package/dist/workers/search-types.d.ts +13 -0
  81. package/dist/workers/search-types.js +27 -0
  82. package/dist/workers/shared.d.ts +51 -11
  83. package/dist/workers/shared.js +0 -0
  84. package/dist/workers/worker-channels.d.ts +60 -0
  85. package/dist/workers/worker-channels.js +98 -0
  86. package/package.json +1 -1
@@ -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
  }
@@ -38,8 +38,9 @@
38
38
  *
39
39
  * Ground truth is the file tree only. No model, no network.
40
40
  */
41
- import { readdirSync, readFileSync, statSync } from 'node:fs';
41
+ import { readFileSync } from 'node:fs';
42
42
  import * as path from 'node:path';
43
+ import { shippedSources, stripCommentLines, SOURCE_JS_RE } from './shipped-source.js';
43
44
  /** A server-app construction: framework, and the regex that recognises it. */
44
45
  const CONSTRUCT_PATTERNS = [
45
46
  { re: /\bnew\s+Hono\s*[<(]/, construct: 'new Hono()' },
@@ -180,62 +181,10 @@ export function opaqueLauncher(cwd) {
180
181
  }
181
182
  return null;
182
183
  }
183
- // Directories never scanned: VCS/dep trees, build output (bundled copies of the
184
- // same sources), and test/fixture/example/doc trees a test that stands up a
185
- // throwaway listener is not the app's launch, and a doc snippet is not code.
186
- const SKIP_DIR_RE = /^(?:\.git|node_modules|\.pi-tasks|dist|build|out|coverage|target|vendor|__pycache__|\.venv|venv|tmp|test|tests|__tests__|__mocks__|__fixtures__|fixtures|e2e|examples|example|docs|doc|bench|benchmarks)$/;
187
- const SKIP_FILE_RE = /\.(?:test|spec|stories|bench)\.[a-z]+$|\.d\.[mc]?ts$/i;
188
- const SCAN_RE = /\.(?:ts|tsx|js|jsx|mjs|cjs|mts|cts)$/i;
189
- const MAX_SCAN_FILES = 3000;
190
- const MAX_FILE_BYTES = 400_000;
191
- /** Authored sources, bounded and in deterministic order. */
192
- function scanCandidates(cwd) {
193
- const out = [];
194
- const walk = (rel) => {
195
- if (out.length >= MAX_SCAN_FILES)
196
- return;
197
- let entries;
198
- try {
199
- entries = readdirSync(path.join(cwd, rel)).sort();
200
- }
201
- catch {
202
- return;
203
- }
204
- for (const name of entries) {
205
- if (out.length >= MAX_SCAN_FILES)
206
- return;
207
- const relPath = rel === '' ? name : `${rel}/${name}`;
208
- let st;
209
- try {
210
- st = statSync(path.join(cwd, relPath));
211
- }
212
- catch {
213
- continue;
214
- }
215
- if (st.isDirectory()) {
216
- if (name.startsWith('.') || SKIP_DIR_RE.test(name))
217
- continue;
218
- walk(relPath);
219
- }
220
- else if (st.isFile() && st.size <= MAX_FILE_BYTES) {
221
- if (SKIP_FILE_RE.test(name))
222
- continue;
223
- if (SCAN_RE.test(name))
224
- out.push(relPath);
225
- }
226
- }
227
- };
228
- walk('');
229
- return out;
230
- }
231
- /** Strip comment-only lines — a `Bun.serve` quoted in a comment is not a bind, and
232
- * a commented-out catch-all is not a route. Inline comments are left alone. */
233
- function stripCommentLines(src) {
234
- return src
235
- .split('\n')
236
- .filter(l => !/^\s*(?:\/\/|\*|\/\*)/.test(l))
237
- .join('\n');
238
- }
184
+ // The tree walk, the caps, the skip sets and the comment strip live in
185
+ // task/shipped-source.ts this scan and artifact-closure's were the same walker
186
+ // written twice, and their skip sets had drifted apart on `bench`/`benchmarks`.
187
+ const scanCandidates = (cwd) => shippedSources(cwd, { ext: SOURCE_JS_RE });
239
188
  /** A `/…/flags` literal whose body carries a regex METACHARACTER — `[`, `\`, `+`,
240
189
  * `?`, `|`, a group. A path string like `'/api/admin'` has none, so it survives. */
241
190
  const REGEX_LITERAL_RE = /\/(?![*/])((?:\\.|\[(?:\\.|[^\]\\])*\]|[^/\\\n])+)\/[gimsuyd]*/g;
@@ -0,0 +1,67 @@
1
+ /**
2
+ * What counts as SHIPPED SOURCE — the input every run-level closure scan reads.
3
+ *
4
+ * `CLOSURE_SCANS` (final-gate.ts) deepened the DRIVER of these scans: the fault
5
+ * isolation, the rank, the stage. It did not unify their INPUT, and the copies
6
+ * had drifted.
7
+ *
8
+ * - `scanCandidates` existed twice, near-byte-identical — `serve-entry.ts` and
9
+ * `artifact-closure.ts` — same `readdirSync().sort()` → `statSync` → recurse,
10
+ * same 3000-file cap, same 400 KB per-file cap, same dot-dir rule.
11
+ * - The skip sets had diverged: serve-entry carried `bench|benchmarks` and
12
+ * `*.bench.*`; artifact-closure did not, so a dangling artifact reference in a
13
+ * benchmark file was a run-level finding while the same file was invisible to
14
+ * the sibling scan. Nothing in either file acknowledged the other.
15
+ * - The same extension regex was declared twice under two names (`SCAN_RE`,
16
+ * `SCAN_JS_RE`), and `stripCommentLines` was byte-identical in both.
17
+ * - `.pi-tasks` was hardcoded into both skip sets rather than derived from
18
+ * `TASKS_DIR_NAME`.
19
+ *
20
+ * The locality proof is in the suites: `artifact-closure.test.ts` has 28
21
+ * references to the pure extractors and 5 calls to the driver, and NO test in the
22
+ * cluster asserted a skip set at all. That is the shape recorded under
23
+ * `resolveTypeSource` — pure functions extracted for testability while the real
24
+ * logic stayed in how they are CALLED.
25
+ *
26
+ * NOT unified here: `env-template-closure.ts`. It asks a different question —
27
+ * which TRACKED files could read an env var, including `.py`/`.go`, including
28
+ * tests — and answering it over this walk would silently change which env
29
+ * findings a run produces. Its comment rule is not this one either: it is a
30
+ * per-line predicate that also treats `#` as a comment opener, which it must,
31
+ * and which would be wrong for JS/TS. Recorded as a real divergence rather than
32
+ * harmonised; changing it is an env-policy change with its own A/B.
33
+ */
34
+ /** Authored JS/TS. The one declaration — it was two constants under two names. */
35
+ export declare const SOURCE_JS_RE: RegExp;
36
+ /** Markup a produced artifact can be referenced from. */
37
+ export declare const SOURCE_HTML_RE: RegExp;
38
+ /** Bounds. A scan is a gate step, not a search: it must terminate on any tree. */
39
+ export declare const MAX_SCAN_FILES = 3000;
40
+ export declare const MAX_FILE_BYTES = 400000;
41
+ /** Is this directory NAME (not path) one no closure scan descends into? */
42
+ export declare function isSkippedDir(name: string): boolean;
43
+ /** Is this file NAME one no closure scan reads? */
44
+ export declare function isSkippedFile(name: string): boolean;
45
+ export interface ShippedSourceOptions {
46
+ /** Which extensions this scan reads. */
47
+ ext: RegExp;
48
+ /**
49
+ * Extra ROOT-LEVEL directory names to exclude. artifact-closure's own: a
50
+ * produced output tree re-referencing its own chunks is noise, and which dirs
51
+ * are produced is discovered per run, so it cannot live in the static set.
52
+ */
53
+ excludeRoots?: ReadonlySet<string>;
54
+ }
55
+ /**
56
+ * Walk `cwd` for shipped sources — bounded, deterministic order, never throws.
57
+ *
58
+ * Unreadable directories and unstattable entries are skipped rather than fatal:
59
+ * a closure scan runs against whatever tree the implementation left behind.
60
+ */
61
+ export declare function shippedSources(cwd: string, opts: ShippedSourceOptions): string[];
62
+ /**
63
+ * Strip comment-only lines. A `Bun.serve` quoted in a comment is not a bind, a
64
+ * commented-out catch-all is not a route, and a path in a comment is not a
65
+ * runtime read. Inline comments are left alone — strings may contain `//`.
66
+ */
67
+ export declare function stripCommentLines(src: string): string;
@@ -0,0 +1,144 @@
1
+ /**
2
+ * What counts as SHIPPED SOURCE — the input every run-level closure scan reads.
3
+ *
4
+ * `CLOSURE_SCANS` (final-gate.ts) deepened the DRIVER of these scans: the fault
5
+ * isolation, the rank, the stage. It did not unify their INPUT, and the copies
6
+ * had drifted.
7
+ *
8
+ * - `scanCandidates` existed twice, near-byte-identical — `serve-entry.ts` and
9
+ * `artifact-closure.ts` — same `readdirSync().sort()` → `statSync` → recurse,
10
+ * same 3000-file cap, same 400 KB per-file cap, same dot-dir rule.
11
+ * - The skip sets had diverged: serve-entry carried `bench|benchmarks` and
12
+ * `*.bench.*`; artifact-closure did not, so a dangling artifact reference in a
13
+ * benchmark file was a run-level finding while the same file was invisible to
14
+ * the sibling scan. Nothing in either file acknowledged the other.
15
+ * - The same extension regex was declared twice under two names (`SCAN_RE`,
16
+ * `SCAN_JS_RE`), and `stripCommentLines` was byte-identical in both.
17
+ * - `.pi-tasks` was hardcoded into both skip sets rather than derived from
18
+ * `TASKS_DIR_NAME`.
19
+ *
20
+ * The locality proof is in the suites: `artifact-closure.test.ts` has 28
21
+ * references to the pure extractors and 5 calls to the driver, and NO test in the
22
+ * cluster asserted a skip set at all. That is the shape recorded under
23
+ * `resolveTypeSource` — pure functions extracted for testability while the real
24
+ * logic stayed in how they are CALLED.
25
+ *
26
+ * NOT unified here: `env-template-closure.ts`. It asks a different question —
27
+ * which TRACKED files could read an env var, including `.py`/`.go`, including
28
+ * tests — and answering it over this walk would silently change which env
29
+ * findings a run produces. Its comment rule is not this one either: it is a
30
+ * per-line predicate that also treats `#` as a comment opener, which it must,
31
+ * and which would be wrong for JS/TS. Recorded as a real divergence rather than
32
+ * harmonised; changing it is an env-policy change with its own A/B.
33
+ */
34
+ import { readdirSync, statSync } from 'node:fs';
35
+ import * as path from 'node:path';
36
+ import { TASKS_DIR_NAME } from './task-types.js';
37
+ /**
38
+ * Directories never scanned: VCS/dep trees, build output (bundled copies of the
39
+ * same sources), and test/fixture/example/doc/bench trees — a test that stands up
40
+ * a throwaway listener is not the app's launch, a doc snippet is not code, and a
41
+ * benchmark references fixture paths freely.
42
+ */
43
+ const SKIP_DIRS = new Set([
44
+ '.git',
45
+ 'node_modules',
46
+ TASKS_DIR_NAME,
47
+ 'dist',
48
+ 'build',
49
+ 'out',
50
+ 'coverage',
51
+ 'target',
52
+ 'vendor',
53
+ '__pycache__',
54
+ '.venv',
55
+ 'venv',
56
+ 'tmp',
57
+ 'test',
58
+ 'tests',
59
+ '__tests__',
60
+ '__mocks__',
61
+ '__fixtures__',
62
+ 'fixtures',
63
+ 'e2e',
64
+ 'examples',
65
+ 'example',
66
+ 'docs',
67
+ 'doc',
68
+ 'bench',
69
+ 'benchmarks'
70
+ ]);
71
+ const SKIP_FILE_RE = /\.(?:test|spec|stories|bench)\.[a-z]+$|\.d\.[mc]?ts$/i;
72
+ /** Authored JS/TS. The one declaration — it was two constants under two names. */
73
+ export const SOURCE_JS_RE = /\.(?:ts|tsx|js|jsx|mjs|cjs|mts|cts)$/i;
74
+ /** Markup a produced artifact can be referenced from. */
75
+ export const SOURCE_HTML_RE = /\.html?$/i;
76
+ /** Bounds. A scan is a gate step, not a search: it must terminate on any tree. */
77
+ export const MAX_SCAN_FILES = 3000;
78
+ export const MAX_FILE_BYTES = 400_000;
79
+ /** Is this directory NAME (not path) one no closure scan descends into? */
80
+ export function isSkippedDir(name) {
81
+ return name.startsWith('.') || SKIP_DIRS.has(name);
82
+ }
83
+ /** Is this file NAME one no closure scan reads? */
84
+ export function isSkippedFile(name) {
85
+ return SKIP_FILE_RE.test(name);
86
+ }
87
+ /**
88
+ * Walk `cwd` for shipped sources — bounded, deterministic order, never throws.
89
+ *
90
+ * Unreadable directories and unstattable entries are skipped rather than fatal:
91
+ * a closure scan runs against whatever tree the implementation left behind.
92
+ */
93
+ export function shippedSources(cwd, opts) {
94
+ const out = [];
95
+ const walk = (rel) => {
96
+ if (out.length >= MAX_SCAN_FILES)
97
+ return;
98
+ let entries;
99
+ try {
100
+ entries = readdirSync(path.join(cwd, rel)).sort();
101
+ }
102
+ catch {
103
+ return;
104
+ }
105
+ for (const name of entries) {
106
+ if (out.length >= MAX_SCAN_FILES)
107
+ return;
108
+ const relPath = rel === '' ? name : `${rel}/${name}`;
109
+ let st;
110
+ try {
111
+ st = statSync(path.join(cwd, relPath));
112
+ }
113
+ catch {
114
+ continue;
115
+ }
116
+ if (st.isDirectory()) {
117
+ if (isSkippedDir(name))
118
+ continue;
119
+ if (rel === '' && opts.excludeRoots?.has(name))
120
+ continue;
121
+ walk(relPath);
122
+ }
123
+ else if (st.isFile() && st.size <= MAX_FILE_BYTES) {
124
+ if (isSkippedFile(name))
125
+ continue;
126
+ if (opts.ext.test(name))
127
+ out.push(relPath);
128
+ }
129
+ }
130
+ };
131
+ walk('');
132
+ return out;
133
+ }
134
+ /**
135
+ * Strip comment-only lines. A `Bun.serve` quoted in a comment is not a bind, a
136
+ * commented-out catch-all is not a route, and a path in a comment is not a
137
+ * runtime read. Inline comments are left alone — strings may contain `//`.
138
+ */
139
+ export function stripCommentLines(src) {
140
+ return src
141
+ .split('\n')
142
+ .filter(l => !/^\s*(?:\/\/|\*|\/\*)/.test(l))
143
+ .join('\n');
144
+ }
@@ -31,7 +31,7 @@
31
31
  import type { ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
32
32
  import type { RunSingleTaskResult } from './orchestrator.js';
33
33
  import type { CommitResult } from './auto-commit.js';
34
- import type { VerifyOutcome } from './verify-work.js';
34
+ 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';
@@ -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';