@mjasnikovs/pi-task 0.38.10 → 0.38.12

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 (78) hide show
  1. package/README.md +7 -3
  2. package/dist/shared/child-process.d.ts +8 -0
  3. package/dist/shared/command-watchdog.d.ts +1 -1
  4. package/dist/shared/command-watchdog.js +1 -1
  5. package/dist/task/accept-debt.d.ts +47 -0
  6. package/dist/task/accept-debt.js +127 -28
  7. package/dist/task/auto-orchestrator.js +93 -116
  8. package/dist/task/boot-probe.d.ts +298 -0
  9. package/dist/task/boot-probe.js +806 -0
  10. package/dist/task/child-runner.d.ts +56 -25
  11. package/dist/task/child-runner.js +65 -31
  12. package/dist/task/child-status.d.ts +95 -0
  13. package/dist/task/child-status.js +99 -0
  14. package/dist/task/command-run.d.ts +36 -0
  15. package/dist/task/command-run.js +48 -1
  16. package/dist/task/command-watchdog.js +1 -1
  17. package/dist/task/context-usage.d.ts +4 -3
  18. package/dist/task/context-usage.js +4 -3
  19. package/dist/task/contracts.js +18 -35
  20. package/dist/task/deep-render-check.d.ts +47 -0
  21. package/dist/task/deep-render-check.js +110 -65
  22. package/dist/task/env-notes.d.ts +3 -3
  23. package/dist/task/env-notes.js +24 -35
  24. package/dist/task/final-gate-fix.d.ts +1 -1
  25. package/dist/task/final-gate-fix.js +1 -1
  26. package/dist/task/final-gate.d.ts +38 -432
  27. package/dist/task/final-gate.js +105 -1213
  28. package/dist/task/gate-child.d.ts +8 -10
  29. package/dist/task/gate-child.js +15 -19
  30. package/dist/task/gate-deps.d.ts +29 -0
  31. package/dist/task/gate-deps.js +192 -206
  32. package/dist/task/gate-tally.d.ts +189 -0
  33. package/dist/task/gate-tally.js +249 -0
  34. package/dist/task/implementation-turn.d.ts +201 -0
  35. package/dist/task/implementation-turn.js +263 -0
  36. package/dist/task/launch-contract.js +27 -43
  37. package/dist/task/launch-manifest.d.ts +5 -0
  38. package/dist/task/launch-manifest.js +21 -0
  39. package/dist/task/ledger.d.ts +38 -0
  40. package/dist/task/ledger.js +83 -0
  41. package/dist/task/loop-detector.d.ts +14 -8
  42. package/dist/task/loop-detector.js +36 -12
  43. package/dist/task/orchestrator.d.ts +61 -126
  44. package/dist/task/orchestrator.js +70 -297
  45. package/dist/task/phases.d.ts +18 -0
  46. package/dist/task/phases.js +4 -3
  47. package/dist/task/plan-orchestrator.js +34 -33
  48. package/dist/task/requirements.d.ts +1 -1
  49. package/dist/task/requirements.js +50 -66
  50. package/dist/task/root-cause-repair.js +20 -32
  51. package/dist/task/run-bracket.d.ts +75 -0
  52. package/dist/task/run-bracket.js +41 -0
  53. package/dist/task/stall-detector.d.ts +110 -0
  54. package/dist/task/stall-detector.js +159 -0
  55. package/dist/task/task-gates.d.ts +69 -0
  56. package/dist/task/task-gates.js +114 -90
  57. package/dist/task/verify-work.d.ts +53 -67
  58. package/dist/task/verify-work.js +15 -11
  59. package/dist/workers/docs-core.d.ts +0 -4
  60. package/dist/workers/docs-core.js +10 -34
  61. package/dist/workers/docs-project.js +3 -3
  62. package/dist/workers/docs-resolve.d.ts +18 -0
  63. package/dist/workers/docs-resolve.js +39 -0
  64. package/dist/workers/docs-retrieve.d.ts +13 -0
  65. package/dist/workers/docs-retrieve.js +17 -2
  66. package/dist/workers/fetch-core.d.ts +0 -4
  67. package/dist/workers/fetch-core.js +2 -5
  68. package/dist/workers/phantom-imports.d.ts +3 -3
  69. package/dist/workers/phantom-imports.js +16 -29
  70. package/dist/workers/pi-worker-docs.d.ts +49 -0
  71. package/dist/workers/pi-worker-docs.js +33 -9
  72. package/dist/workers/pi-worker-fetch.d.ts +18 -0
  73. package/dist/workers/pi-worker-fetch.js +19 -4
  74. package/dist/workers/single-read-extension.d.ts +1 -1
  75. package/dist/workers/single-read-extension.js +5 -4
  76. package/dist/workers/single-read-guard.d.ts +32 -10
  77. package/dist/workers/single-read-guard.js +67 -16
  78. package/package.json +1 -1
package/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
  [![npm](https://img.shields.io/npm/v/@mjasnikovs/pi-task?color=cb3837&logo=npm)](https://www.npmjs.com/package/@mjasnikovs/pi-task)
10
10
  [![license](https://img.shields.io/badge/license-AGPL--3.0-blue.svg)](./LICENSE)
11
11
  [![pi extension](https://img.shields.io/badge/pi-extension-7c3aed)](https://www.npmjs.com/package/@earendil-works/pi-coding-agent)
12
- [![tests](https://img.shields.io/badge/tests-3025%20passing-3fb950)](#development)
12
+ [![tests](https://img.shields.io/badge/tests-3797%20passing-3fb950)](#development)
13
13
  [![types](https://img.shields.io/badge/TypeScript-strict-3178c6?logo=typescript&logoColor=white)](./tsconfig.json)
14
14
 
15
15
  </div>
@@ -233,13 +233,17 @@ Run `/task-config` to toggle pi-task's behavior in an editor dialog. Settings pe
233
233
  | `CHROME_BIN` | verify-work render check | Explicit headless Chrome-family binary. Tried before the Playwright cache and a browser on `PATH`. No browser found ⇒ the render check SKIPs; it never installs one. |
234
234
  | `PLAYWRIGHT_BROWSERS_PATH` | verify-work render check | Where to look for a cached Playwright Chromium (defaults to `~/.cache/ms-playwright`, or `~/Library/Caches/ms-playwright` on macOS). |
235
235
 
236
- Tasks are persisted to `<cwd>/.pi-tasks/TASK_NNNN.md`. Add `.pi-tasks/` to your `.gitignore` if you don't want them checked in.
236
+ Tasks are persisted to `<cwd>/.pi-tasks/TASK_NNNN.md`. A run also keeps small
237
+ line-oriented ledgers beside them — contracts, launch contract, environment
238
+ notes, accepted debt, repair queue, requirements — plus `*-debug.log` when
239
+ **debug logs** is on. Add `.pi-tasks/` to your `.gitignore` if you don't want
240
+ them checked in.
237
241
 
238
242
  ## Development
239
243
 
240
244
  ```sh
241
245
  bun install
242
- bun run test # 3028 tests across 173 files
246
+ bun run test # 3800 tests across 214 files
243
247
  bun run lint # prettier + eslint + tsc --noEmit
244
248
  bun run build # tsc → dist/
245
249
  ```
@@ -81,6 +81,14 @@ export interface LoopHit {
81
81
  call: ToolCall;
82
82
  count: number;
83
83
  windowSize: number;
84
+ /**
85
+ * Set when the kill came from the whole-run StallDetector rather than the
86
+ * short-window LoopDetector, naming which of its two rules tripped
87
+ * (task/stall-detector.ts). Absent for an ordinary loop hit. Carried here so
88
+ * a stall rides the kill/restart plumbing the loop hit already has instead of
89
+ * needing a second channel.
90
+ */
91
+ stall?: 'no-new-ground' | 'context-churn';
84
92
  }
85
93
  export interface ContextSnapshot {
86
94
  tokens: number;
@@ -54,7 +54,7 @@ export interface WatchdogDeps {
54
54
  }
55
55
  /**
56
56
  * Stable substring of {@link reminderMessage}, used by the steer loop
57
- * (orchestrator steerUntilDone) to recognise the watchdog's follow-up turn in
57
+ * (implementation-turn steerUntilDone) to recognise the watchdog's follow-up turn in
58
58
  * the session entries — the artifact that distinguishes a watchdog abort from a
59
59
  * human ESC. Interpolated into the message so the detector and the text cannot
60
60
  * drift apart.
@@ -53,7 +53,7 @@ function correction() {
53
53
  }
54
54
  /**
55
55
  * Stable substring of {@link reminderMessage}, used by the steer loop
56
- * (orchestrator steerUntilDone) to recognise the watchdog's follow-up turn in
56
+ * (implementation-turn steerUntilDone) to recognise the watchdog's follow-up turn in
57
57
  * the session entries — the artifact that distinguishes a watchdog abort from a
58
58
  * human ESC. Interpolated into the message so the detector and the text cannot
59
59
  * drift apart.
@@ -1,3 +1,4 @@
1
+ import { type CommandRunner } from './command-run.js';
1
2
  /**
2
3
  * Provenance of a recorded defect:
3
4
  * - 'accepted' — the user chose ACCEPT despite a verify-FAIL (the original class).
@@ -226,3 +227,49 @@ export declare function buildAcceptDebtNote(open: AcceptDebt[]): string;
226
227
  * origin falls back to the 'accepted' class exactly as the branch chain did.
227
228
  */
228
229
  export declare function describeDebt(d: AcceptDebt): string;
230
+ /**
231
+ * ACCEPT-debt re-check (mx5 run 4 B3 / run 8 TASK_0012): read the ledger of tasks
232
+ * the user accepted despite a verify-FAIL and re-check each against the CURRENT
233
+ * tree. A static-class debt whose statics now pass is provably RESOLVED (a later
234
+ * task fixed it) and pruned from the ledger; every other debt cannot be proven
235
+ * resolved deterministically, so it stays OPEN and is surfaced — a run may not
236
+ * complete silently carrying an accepted defect. FP-safe by construction (see
237
+ * accept-debt.ts). Best-effort: a ledger read/write failure must never break the
238
+ * caller.
239
+ *
240
+ * FACTORED OUT of runFinalIntegrationGate (nexttask 6): the derivation has to be
241
+ * runnable at a SECOND moment — after a converged final-gate autofix, where the
242
+ * orchestrator used to rebuild its gate outcome as a bare `{ok, reason}` and drop
243
+ * `openDebts` entirely. The report a run ends on has to be derived from the tree
244
+ * the run ends with, not from the tree as it was before the fix pass.
245
+ *
246
+ * `staticOk` is the caller's claim about the CURRENT statics, and it is the only
247
+ * thing that can auto-close a static-class debt — so a caller that does not know
248
+ * must pass `false` (unprovable ⇒ stays open), never a guess.
249
+ */
250
+ export declare function deriveOpenDebts(cwd: string, staticOk: boolean): Promise<{
251
+ openDebts: AcceptDebt[];
252
+ debtNote?: string;
253
+ trail?: string[];
254
+ }>;
255
+ /**
256
+ * Re-run ONE debt's stored VERIFY command for the re-check, with the no-write guard
257
+ * (`inv-no-write`) wrapped around it.
258
+ *
259
+ * A VERIFY command is the project's own command and may legitimately write (a build
260
+ * emits `dist/`, a suite writes a snapshot). What it may NOT do is turn the tree into
261
+ * a passing tree and have that count as the debt being fixed — the run would then be
262
+ * certifying its own side effect. So tracked state is captured before and after, and
263
+ * a pass that came with a tracked change is downgraded to INCONCLUSIVE with the
264
+ * change named. Untracked output is left alone: it is what a build legitimately
265
+ * produces, and `git status --porcelain` in a repo with the usual ignores does not
266
+ * see it.
267
+ *
268
+ * A repository the guard cannot read (no git, git absent) is not a licence to skip
269
+ * the guard: the re-run is INCONCLUSIVE there, because "nothing changed" would be an
270
+ * assumption rather than an observation.
271
+ */
272
+ export declare function rerunDebtVerifyCommand(cwd: string, command: string,
273
+ /** The spawner, for BOTH the command and the tracked-state reads. Injected so
274
+ * the guard's four outcomes are testable without a repo or a real command. */
275
+ run?: CommandRunner): VerifyRerunResult;
@@ -25,10 +25,14 @@
25
25
  * model, so it is SURFACED, never auto-closed — biasing hard toward informing the
26
26
  * user rather than re-hiding a live defect. "Nothing still open = clean."
27
27
  */
28
- import * as fsp from 'node:fs/promises';
28
+ import { existsSync } from 'node:fs';
29
29
  import * as path from 'node:path';
30
+ import * as fsp from 'node:fs/promises';
31
+ import { runVerifyCommandLine, spawnCommand } from './command-run.js';
32
+ import { taskThatIntroduced } from './task-provenance.js';
33
+ import { makeLedger } from './ledger.js';
30
34
  import { parseVerifyBlockStrict } from './spec-validation.js';
31
- import { taskFilePath, tasksDir } from './task-io.js';
35
+ import { taskFilePath } from './task-io.js';
32
36
  import { isUnfailableCommand } from './unfailable-command.js';
33
37
  const ACCEPT_DEBT_FILE = 'accept-debt.md';
34
38
  /** Cap kept records so a run that accepts many FAILs cannot grow the report unboundedly. */
@@ -69,16 +73,11 @@ function isKnownOrigin(origin) {
69
73
  return origin !== undefined && Object.hasOwn(DEBT_LABELS, origin);
70
74
  }
71
75
  export function acceptDebtFile(cwd) {
72
- return path.join(tasksDir(cwd), ACCEPT_DEBT_FILE);
76
+ return ledger.path(cwd);
73
77
  }
74
78
  /** The raw stored ledger ('' when none recorded yet). Parse with parseAcceptDebts. */
75
79
  export async function readAcceptDebtsRaw(cwd) {
76
- try {
77
- return (await fsp.readFile(acceptDebtFile(cwd), 'utf8')).trim();
78
- }
79
- catch {
80
- return '';
81
- }
80
+ return ledger.readRaw(cwd);
82
81
  }
83
82
  /**
84
83
  * Parse the stored ledger into records. Fields are tab-separated: `id`, `reason`, and
@@ -117,7 +116,7 @@ export function parseAcceptDebts(raw) {
117
116
  }
118
117
  /** Read + parse in one step. */
119
118
  export async function readAcceptDebts(cwd) {
120
- return parseAcceptDebts(await readAcceptDebtsRaw(cwd));
119
+ return ledger.read(cwd);
121
120
  }
122
121
  function normaliseReason(reason) {
123
122
  return reason
@@ -142,6 +141,18 @@ function serialize(d) {
142
141
  function debtKey(d) {
143
142
  return `${d.origin ?? 'accepted'} ${d.taskId.toLowerCase()} ${d.reason.toLowerCase()}`;
144
143
  }
144
+ /**
145
+ * The debt ledger. `onNoop: 'skip'` — a duplicate is a return, not a rewrite: the
146
+ * file is touched only when a NEW debt enters it.
147
+ */
148
+ const ledger = makeLedger({
149
+ file: ACCEPT_DEBT_FILE,
150
+ max: MAX_DEBTS,
151
+ key: debtKey,
152
+ serialize,
153
+ parse: parseAcceptDebts,
154
+ onNoop: 'skip'
155
+ });
145
156
  /**
146
157
  * Append one accepted-despite-FAIL record, deduplicated against what is already
147
158
  * stored (case-insensitive on task id + reason), keeping the newest MAX_DEBTS.
@@ -158,13 +169,7 @@ async function appendDebt(cwd, entry) {
158
169
  const verifyCommand = entry.verifyCommand ?? (await classifyVerifyCommand(cwd, entry.taskId, entry.reason));
159
170
  if (verifyCommand !== null && verifyCommand !== undefined)
160
171
  entry = { ...entry, verifyCommand };
161
- const existing = parseAcceptDebts(await readAcceptDebtsRaw(cwd));
162
- const seen = new Set(existing.map(debtKey));
163
- if (seen.has(debtKey(entry)))
164
- return;
165
- const kept = [...existing, entry].slice(-MAX_DEBTS);
166
- await fsp.mkdir(tasksDir(cwd), { recursive: true });
167
- await fsp.writeFile(acceptDebtFile(cwd), kept.map(serialize).join('\n') + '\n', 'utf8');
172
+ await ledger.append(cwd, [entry]);
168
173
  }
169
174
  catch {
170
175
  // best-effort ledger
@@ -217,17 +222,7 @@ export function extractDeletedDebtPath(reason) {
217
222
  }
218
223
  /** Overwrite the ledger with exactly these records (used to prune resolved debts). */
219
224
  export async function writeAcceptDebts(cwd, debts) {
220
- try {
221
- await fsp.mkdir(tasksDir(cwd), { recursive: true });
222
- if (debts.length === 0) {
223
- await fsp.writeFile(acceptDebtFile(cwd), '', 'utf8');
224
- return;
225
- }
226
- await fsp.writeFile(acceptDebtFile(cwd), debts.map(serialize).join('\n') + '\n', 'utf8');
227
- }
228
- catch {
229
- // best-effort ledger
230
- }
225
+ await ledger.write(cwd, debts);
231
226
  }
232
227
  /**
233
228
  * STATIC-CLASS debt: one whose accepted FAIL was the deterministic whole-repo static
@@ -484,3 +479,107 @@ export function buildAcceptDebtNote(open) {
484
479
  export function describeDebt(d) {
485
480
  return isKnownOrigin(d.origin) ? DEBT_LABELS[d.origin] : DEBT_LABELS.accepted;
486
481
  }
482
+ /**
483
+ * ACCEPT-debt re-check (mx5 run 4 B3 / run 8 TASK_0012): read the ledger of tasks
484
+ * the user accepted despite a verify-FAIL and re-check each against the CURRENT
485
+ * tree. A static-class debt whose statics now pass is provably RESOLVED (a later
486
+ * task fixed it) and pruned from the ledger; every other debt cannot be proven
487
+ * resolved deterministically, so it stays OPEN and is surfaced — a run may not
488
+ * complete silently carrying an accepted defect. FP-safe by construction (see
489
+ * accept-debt.ts). Best-effort: a ledger read/write failure must never break the
490
+ * caller.
491
+ *
492
+ * FACTORED OUT of runFinalIntegrationGate (nexttask 6): the derivation has to be
493
+ * runnable at a SECOND moment — after a converged final-gate autofix, where the
494
+ * orchestrator used to rebuild its gate outcome as a bare `{ok, reason}` and drop
495
+ * `openDebts` entirely. The report a run ends on has to be derived from the tree
496
+ * the run ends with, not from the tree as it was before the fix pass.
497
+ *
498
+ * `staticOk` is the caller's claim about the CURRENT statics, and it is the only
499
+ * thing that can auto-close a static-class debt — so a caller that does not know
500
+ * must pass `false` (unprovable ⇒ stays open), never a guess.
501
+ */
502
+ export async function deriveOpenDebts(cwd, staticOk) {
503
+ const { open: openRaw, resolved, trail } = recheckAcceptDebts(await readAcceptDebts(cwd), {
504
+ staticOk,
505
+ // Cross-task-deletion debts auto-close iff the deleted file is back in the
506
+ // tree — a deterministic existence check, corroborating the per-file
507
+ // provenance the record already carries.
508
+ fileExists: rel => existsSync(path.join(cwd, rel)),
509
+ // VERIFY-COMMAND class (nexttask 5): a debt that NAMES a command is settled
510
+ // by running that command, under the gate's own env-gap contract and behind
511
+ // the no-write guard below.
512
+ rerunVerify: cmd => rerunDebtVerifyCommand(cwd, cmd)
513
+ });
514
+ if (resolved.length > 0)
515
+ await writeAcceptDebts(cwd, openRaw);
516
+ // Conflicting-claim annotation (mx5 run 11): an existence-as-failure debt whose
517
+ // named file is another task's committed deliverable is a plan defect — surface
518
+ // the contradiction with the debt so nobody (human or child) treats the claim as
519
+ // a deletion instruction. Pure git-history lookup; degrades to no annotation.
520
+ const openDebts = annotateDebtConflicts(openRaw, p => taskThatIntroduced(cwd, p));
521
+ const debtNote = buildAcceptDebtNote(openDebts);
522
+ return { openDebts, ...(debtNote ? { debtNote } : {}), ...(trail.length > 0 ? { trail } : {}) };
523
+ }
524
+ /** Per-command ceiling for a debt re-run (`inv-bounded`). */
525
+ const DEBT_RERUN_TIMEOUT_MS = 300_000;
526
+ /**
527
+ * Extra infrastructure-gap shapes recognised ONLY when re-running a debt's command,
528
+ * never in the gate's own verdicts. A driver that reports its connection simply
529
+ * closed (`ERR_POSTGRES_CONNECTION_CLOSED` — what bun's SQL client says when the
530
+ * database is not there at all, as on this box with the mx5 container stopped) is an
531
+ * absent dependency, and calling that "the defect is still present" would be a
532
+ * finding the environment invented. Kept out of INFRA_GAP_OUTPUT_RE on purpose: in a
533
+ * gate verdict the same wording can be a real fault the suite must own, and only the
534
+ * debt re-check needs the conservative reading — where it costs nothing, because gap
535
+ * and fail both leave the debt open.
536
+ */
537
+ const DEBT_INFRA_GAP_RE = /ERR_POSTGRES_CONNECTION_CLOSED|ERR_MYSQL_CONNECTION|ECONNRESET/i;
538
+ /**
539
+ * Re-run ONE debt's stored VERIFY command for the re-check, with the no-write guard
540
+ * (`inv-no-write`) wrapped around it.
541
+ *
542
+ * A VERIFY command is the project's own command and may legitimately write (a build
543
+ * emits `dist/`, a suite writes a snapshot). What it may NOT do is turn the tree into
544
+ * a passing tree and have that count as the debt being fixed — the run would then be
545
+ * certifying its own side effect. So tracked state is captured before and after, and
546
+ * a pass that came with a tracked change is downgraded to INCONCLUSIVE with the
547
+ * change named. Untracked output is left alone: it is what a build legitimately
548
+ * produces, and `git status --porcelain` in a repo with the usual ignores does not
549
+ * see it.
550
+ *
551
+ * A repository the guard cannot read (no git, git absent) is not a licence to skip
552
+ * the guard: the re-run is INCONCLUSIVE there, because "nothing changed" would be an
553
+ * assumption rather than an observation.
554
+ */
555
+ export function rerunDebtVerifyCommand(cwd, command,
556
+ /** The spawner, for BOTH the command and the tracked-state reads. Injected so
557
+ * the guard's four outcomes are testable without a repo or a real command. */
558
+ run = spawnCommand) {
559
+ const tracked = () => {
560
+ const r = run({
561
+ cwd,
562
+ bin: 'git',
563
+ args: ['status', '--porcelain', '--untracked-files=no'],
564
+ timeoutMs: 60_000
565
+ });
566
+ return r.failedToStart || r.status !== 0 ? null : r.stdout;
567
+ };
568
+ const before = tracked();
569
+ const r = runVerifyCommandLine(cwd, command, DEBT_RERUN_TIMEOUT_MS, DEBT_INFRA_GAP_RE, run);
570
+ if (r.outcome === 'fail')
571
+ return { outcome: 'fail', detail: `exit ${r.status} — ${r.tail}` };
572
+ if (r.outcome === 'gap')
573
+ return { outcome: 'gap', detail: r.detail };
574
+ const after = tracked();
575
+ if (before === null || after === null) {
576
+ return { outcome: 'gap', detail: 'tracked-state guard could not read git status' };
577
+ }
578
+ if (before !== after) {
579
+ return {
580
+ outcome: 'gap',
581
+ detail: 'the re-run itself CHANGED tracked files — a command that edits the tree into a pass proves nothing'
582
+ };
583
+ }
584
+ return { outcome: 'pass' };
585
+ }
@@ -20,16 +20,13 @@ import { drainRepairQueue, mergeRepairCandidates, planHasRepairFor, parseRepairT
20
20
  import { writeTaskFile, readTaskFile, updateTaskFrontMatter, taskFilePath, tasksDir } from './task-io.js';
21
21
  import { readTextFile } from '../shared/fs-text.js';
22
22
  import { findPhantomImports, rewritePhantomSpecifiers } from '../workers/phantom-imports.js';
23
- import { runPhaseChild, prependHint, USER_CANCELLED } from './child-runner.js';
23
+ import { prependHint, USER_CANCELLED } from './child-runner.js';
24
24
  import { requestCancel, resetCancel, isCancelRequested, cancelCheckpoint } from './cancel-points.js';
25
- import { armCancelListener, disarmCancelListener } from './cancel-input.js';
26
- import { beginRun, endRun } from './mid-run-input.js';
27
- import { reportDroppedInput } from './dropped-input.js';
25
+ import { withRun, announceTerminal } from './run-bracket.js';
28
26
  import { refineExistingFilesBlock, SINGLE_READ_EXTENSION_PATH } from './phases.js';
29
27
  import { SessionUI, registerBridgeCommand, publishLifecycleNotice } from '../remote/bridge.js';
30
- import { pushNotify } from '../remote/push.js';
31
- import { startAutoLoader } from './widget.js';
32
- import { getParentContextWindow, resolveContextUsage } from './context-usage.js';
28
+ import { getParentContextWindow } from './context-usage.js';
29
+ import { ChildStatus, runPlanningChild, statusCallbacks } from './child-status.js';
33
30
  import { buildGateDeps, collectTreeChanges } from './gate-deps.js';
34
31
  import { runGatesForTask } from './task-gates.js';
35
32
  import { runFinalGateStage } from './run-final-gate.js';
@@ -428,7 +425,7 @@ export async function orientFeature(cwd, feature, deps) {
428
425
  // same deterministic, no-LLM strike at the single point that feeds both planning
429
426
  // children. Silent + no-op when nothing is flagged or the runtime's types aren't
430
427
  // installed.
431
- const planPhantoms = findPhantomImports(rawFeatureForModel, cwd);
428
+ const planPhantoms = await findPhantomImports(rawFeatureForModel, cwd);
432
429
  const featureForModel = planPhantoms.length === 0 ?
433
430
  rawFeatureForModel
434
431
  : rewritePhantomSpecifiers(rawFeatureForModel, planPhantoms);
@@ -1126,12 +1123,11 @@ const AUTO_PLAN_STEPS = {
1126
1123
  };
1127
1124
  const AUTO_PLAN_STEP_TOTAL = 2;
1128
1125
  function defaultDeps(ctx, cwd, signal, title) {
1129
- // Captured by the planning loader's getState so the widget mirrors the child's
1130
- // latest output line and context usage, exactly like the single-task phase
1131
- // widget. (The gate children manage their own loaders inside buildGateDeps.)
1132
- let lastLine;
1133
- let contextUsage;
1126
+ // The planning loader mirrors the child's latest output line and context
1127
+ // usage, exactly like the single-task phase widget. (The gate children have
1128
+ // their own ChildStatus inside buildGateDeps.)
1134
1129
  const parentContextWindow = getParentContextWindow(ctx);
1130
+ const status = new ChildStatus({ parentContextWindow });
1135
1131
  const phaseDeps = {
1136
1132
  cwd,
1137
1133
  taskId: '',
@@ -1143,41 +1139,31 @@ function defaultDeps(ctx, cwd, signal, title) {
1143
1139
  // it has already opened can only be thrash — which makes the read-once
1144
1140
  // block safe here in a way it is not for a phase that must explore.
1145
1141
  childExtensions: [SINGLE_READ_EXTENSION_PATH],
1146
- onChildOutput: (line) => {
1147
- lastLine = line;
1148
- },
1149
- onContextUsage: snapshot => {
1150
- contextUsage = resolveContextUsage(snapshot, contextUsage, parentContextWindow);
1151
- }
1142
+ ...statusCallbacks(status)
1152
1143
  };
1153
1144
  return {
1154
1145
  // Planning-only seam. The shared gate surface (runTask/commit/verify/
1155
1146
  // enforce/recommend/revert) comes from buildGateDeps below — identical to
1156
1147
  // what /task builds, so both commands gate the same way.
1157
- runChild: async (name, tools, prompt) => {
1158
- // Planning children are slow LLM calls with no UI of their own; show
1159
- // the same status block as /task so this never goes silent until the
1160
- // drill dialog.
1161
- lastLine = undefined;
1162
- contextUsage = undefined;
1163
- const startedAt = Date.now();
1164
- const { step, stepNum } = AUTO_PLAN_STEPS[name] ?? { step: name, stepNum: 1 };
1165
- const stopLoader = startAutoLoader(ctx, () => ({
1148
+ //
1149
+ // Planning children are slow LLM calls with no UI of their own; the
1150
+ // shared loader shows the same status block as /task so this never goes
1151
+ // silent until the drill dialog.
1152
+ runChild: (name, tools, prompt) => runPlanningChild({
1153
+ ctx,
1154
+ status,
1155
+ phaseDeps,
1156
+ name,
1157
+ tools,
1158
+ prompt,
1159
+ loader: {
1166
1160
  title,
1167
- step,
1168
- stepNum,
1169
- stepTotal: AUTO_PLAN_STEP_TOTAL,
1170
- startedAt,
1171
- lastLine,
1172
- contextUsage
1173
- }));
1174
- try {
1175
- return await runPhaseChild(phaseDeps, name, tools, prompt);
1161
+ step: n => ({
1162
+ ...(AUTO_PLAN_STEPS[n] ?? { step: n, stepNum: 1 }),
1163
+ stepTotal: AUTO_PLAN_STEP_TOTAL
1164
+ })
1176
1165
  }
1177
- finally {
1178
- stopLoader();
1179
- }
1180
- },
1166
+ }),
1181
1167
  ...buildGateDeps({ signal, parentContextWindow, runTask: gateRunTask }),
1182
1168
  // Loop-level repo integrity + run-end gate glue (see AutoDeps docs).
1183
1169
  unmergedPaths: cwd2 => gitUnmergedPaths(cwd2, signal),
@@ -1185,7 +1171,7 @@ function defaultDeps(ctx, cwd, signal, title) {
1185
1171
  // The final integration gate follows the `verify work` switch: it is the
1186
1172
  // run-level half of the same verification story.
1187
1173
  finalGate: (cwd2, planText) => getConfig().verifyWork ?
1188
- runFinalIntegrationGate(cwd2, undefined, undefined, undefined, planText)
1174
+ runFinalIntegrationGate(cwd2, { planText })
1189
1175
  : Promise.resolve({ ok: true, reason: 'disabled' }),
1190
1176
  // Uncommitted paths, for the stranded-sub-fix handling around the final-gate
1191
1177
  // picker (mx5 run 13 PROMPT 4 item 3). Every task is committed by the time
@@ -1213,12 +1199,7 @@ export function requestAutoCancel() {
1213
1199
  * runSingleTask without notifyFinish, so they stay silent).
1214
1200
  */
1215
1201
  function announceDone(ctx, msg, level) {
1216
- ctx.ui.notify(msg, level);
1217
- // ctx.ui.notify is terminal-only and pushNotify is a backgrounded-device web
1218
- // push — neither shows up in a remote viewer that's watching live. Mirror it
1219
- // into the session view too (errors become a persistent red bubble).
1220
- publishLifecycleNotice(msg, level);
1221
- void pushNotify('Task finished', msg, 'pi-end').catch(() => { });
1202
+ announceTerminal(ctx, msg, level);
1222
1203
  }
1223
1204
  export async function runAutoLoop(ctx, cwd, id, deps) {
1224
1205
  resetCancel();
@@ -1461,46 +1442,45 @@ async function handleTaskAuto(args, ctx) {
1461
1442
  return;
1462
1443
  }
1463
1444
  autoRunning = true;
1464
- beginRun(); // the whole loop owns the session, not just the task inside it
1465
- // Take delivery of a typed /task-auto-cancel for the WHOLE run, planning
1466
- // included planning is children too, so the host is not streaming and the
1467
- // ordinary command path cannot reach us.
1468
- armTerminalCancel(ctx);
1445
+ // The whole loop owns the session, not just the task inside it — and the
1446
+ // bracket takes delivery of a typed /task-auto-cancel for the WHOLE run,
1447
+ // planning included: planning is children too, so the host is not streaming
1448
+ // and the ordinary command path cannot reach us.
1469
1449
  try {
1470
- // Stamp a fresh per-run research-cache id (F10) BEFORE planning so enrichment and
1471
- // every task's research phase share one run's cache; disabled clears any token a
1472
- // prior run left, so nothing is cached.
1473
- configureResearchRun(getConfig().researchCache);
1474
- const abort = new AbortController();
1475
- const deps = defaultDeps(ctx, cwd, abort.signal, deriveTitle(raw));
1476
- let id;
1477
- try {
1478
- id = await planAuto(ctx, cwd, raw, deps);
1479
- }
1480
- catch (err) {
1481
- const msg = err instanceof Error ? err.message : String(err);
1482
- if (msg === USER_CANCELLED) {
1450
+ await withRun(ctx, { onCancel: terminalCancel }, async () => {
1451
+ // Stamp a fresh per-run research-cache id (F10) BEFORE planning so enrichment and
1452
+ // every task's research phase share one run's cache; disabled clears any token a
1453
+ // prior run left, so nothing is cached.
1454
+ configureResearchRun(getConfig().researchCache);
1455
+ const abort = new AbortController();
1456
+ const deps = defaultDeps(ctx, cwd, abort.signal, deriveTitle(raw));
1457
+ let id;
1458
+ try {
1459
+ id = await planAuto(ctx, cwd, raw, deps);
1460
+ }
1461
+ catch (err) {
1462
+ const msg = err instanceof Error ? err.message : String(err);
1463
+ if (msg === USER_CANCELLED) {
1464
+ announceDone(ctx, '/task-auto cancelled.', 'warning');
1465
+ return;
1466
+ }
1467
+ announceDone(ctx, `/task-auto planning failed: ${msg}`, 'error');
1468
+ return;
1469
+ }
1470
+ if (!id)
1471
+ return;
1472
+ // Check for a cancel that was requested during the planning phase before the
1473
+ // loop resets the flag.
1474
+ if (isCancelRequested()) {
1475
+ resetCancel();
1483
1476
  announceDone(ctx, '/task-auto cancelled.', 'warning');
1484
1477
  return;
1485
1478
  }
1486
- announceDone(ctx, `/task-auto planning failed: ${msg}`, 'error');
1487
- return;
1488
- }
1489
- if (!id)
1490
- return;
1491
- // Check for a cancel that was requested during the planning phase before the
1492
- // loop resets the flag.
1493
- if (isCancelRequested()) {
1494
- resetCancel();
1495
- announceDone(ctx, '/task-auto cancelled.', 'warning');
1496
- return;
1497
- }
1498
- await runAutoLoop(ctx, cwd, id, deps);
1479
+ await runAutoLoop(ctx, cwd, id, deps);
1480
+ });
1499
1481
  }
1500
1482
  finally {
1501
1483
  autoRunning = false;
1502
- reportDroppedInput(endRun(), ctx);
1503
- disarmCancelListener();
1504
1484
  }
1505
1485
  }
1506
1486
  async function handleTaskAutoResume(args, ctx) {
@@ -1529,28 +1509,27 @@ async function handleTaskAutoResume(args, ctx) {
1529
1509
  const id = candidate.id;
1530
1510
  await updateTaskFrontMatter(cwd, id, { state: 'in_progress' });
1531
1511
  autoRunning = true;
1532
- beginRun(); // the whole loop owns the session, not just the task inside it
1533
- armTerminalCancel(ctx);
1512
+ // The whole loop owns the session, not just the task inside it.
1534
1513
  try {
1535
- // Reuse the interrupted run's research-cache id, dropping only the entries whose
1536
- // own package moved version (F10). mx5 run 13 resumed three times and each
1537
- // resume's fresh id discarded a working 201-entry cache; run 14 then showed a
1538
- // whole-file freshness gate can never hold on a greenfield run that installs
1539
- // packages as it goes, so invalidation is per entry. See resumeResearchRun.
1540
- const research = await resumeResearchRun(cwd, getConfig().researchCache);
1541
- if (research.reused) {
1542
- logPlanDebug(cwd, `research cache: resume reused ${research.entries} entr(ies), `
1543
- + `dropped ${research.dropped} stale`);
1544
- }
1545
- const abort = new AbortController();
1546
- // Resume only runs the loop (runTask); no planning children, so the loader
1547
- // title is unused here pass the id for clarity if that ever changes.
1548
- await runAutoLoop(ctx, cwd, id, defaultDeps(ctx, cwd, abort.signal, id));
1514
+ await withRun(ctx, { onCancel: terminalCancel }, async () => {
1515
+ // Reuse the interrupted run's research-cache id, dropping only the entries whose
1516
+ // own package moved version (F10). mx5 run 13 resumed three times and each
1517
+ // resume's fresh id discarded a working 201-entry cache; run 14 then showed a
1518
+ // whole-file freshness gate can never hold on a greenfield run that installs
1519
+ // packages as it goes, so invalidation is per entry. See resumeResearchRun.
1520
+ const research = await resumeResearchRun(cwd, getConfig().researchCache);
1521
+ if (research.reused) {
1522
+ logPlanDebug(cwd, `research cache: resume reused ${research.entries} entr(ies), `
1523
+ + `dropped ${research.dropped} stale`);
1524
+ }
1525
+ const abort = new AbortController();
1526
+ // Resume only runs the loop (runTask); no planning children, so the loader
1527
+ // title is unused here — pass the id for clarity if that ever changes.
1528
+ await runAutoLoop(ctx, cwd, id, defaultDeps(ctx, cwd, abort.signal, id));
1529
+ });
1549
1530
  }
1550
1531
  finally {
1551
1532
  autoRunning = false;
1552
- reportDroppedInput(endRun(), ctx);
1553
- disarmCancelListener();
1554
1533
  }
1555
1534
  }
1556
1535
  // eslint-disable-next-line @typescript-eslint/require-await
@@ -1571,24 +1550,22 @@ async function handleTaskAutoCancel(_args, ctx) {
1571
1550
  const CANCEL_ACK = 'Stopping /task-auto at the next safe checkpoint…';
1572
1551
  /**
1573
1552
  * Deliver a /task-auto-cancel typed in the terminal while a run owns the main
1574
- * loop. `armCancelListener` watches raw stdin, so it works during the spec
1575
- * phases and the gates — the windows where pi would otherwise queue the line
1576
- * until after the run (see cancel-input.ts). The remote path is unaffected:
1577
- * dispatchRemoteLine invokes the handler directly.
1553
+ * loop — the run bracket's `onCancel`. The armed listener watches raw stdin, so
1554
+ * it works during the spec phases and the gates — the windows where pi would
1555
+ * otherwise queue the line until after the run (see cancel-input.ts). The
1556
+ * remote path is unaffected: dispatchRemoteLine invokes the handler directly.
1578
1557
  */
1579
- function armTerminalCancel(ctx) {
1580
- armCancelListener(ctx, live => {
1581
- requestAutoCancel();
1582
- // `live` is the ctx the listener is currently installed on — the captured
1583
- // one is stale the moment a task replaces the session.
1584
- try {
1585
- live.ui.notify(CANCEL_ACK, 'warning');
1586
- }
1587
- catch {
1588
- /* the acknowledgement must never break the cancel itself */
1589
- }
1590
- publishLifecycleNotice(CANCEL_ACK, 'warning');
1591
- });
1558
+ function terminalCancel(live) {
1559
+ requestAutoCancel();
1560
+ // `live` is the ctx the listener is currently installed on — the captured
1561
+ // one is stale the moment a task replaces the session.
1562
+ try {
1563
+ live.ui.notify(CANCEL_ACK, 'warning');
1564
+ }
1565
+ catch {
1566
+ /* the acknowledgement must never break the cancel itself */
1567
+ }
1568
+ publishLifecycleNotice(CANCEL_ACK, 'warning');
1592
1569
  }
1593
1570
  // ─── Registration ────────────────────────────────────────────────────────────
1594
1571
  export function registerTaskAuto(pi) {