@mjasnikovs/pi-task 0.38.11 → 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 (55) 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 +91 -114
  8. package/dist/task/child-runner.d.ts +39 -25
  9. package/dist/task/child-runner.js +59 -31
  10. package/dist/task/child-status.d.ts +95 -0
  11. package/dist/task/child-status.js +99 -0
  12. package/dist/task/command-run.d.ts +36 -0
  13. package/dist/task/command-run.js +48 -1
  14. package/dist/task/command-watchdog.js +1 -1
  15. package/dist/task/context-usage.d.ts +4 -3
  16. package/dist/task/context-usage.js +4 -3
  17. package/dist/task/contracts.js +18 -35
  18. package/dist/task/deep-render-check.d.ts +47 -0
  19. package/dist/task/deep-render-check.js +110 -65
  20. package/dist/task/env-notes.d.ts +3 -3
  21. package/dist/task/env-notes.js +24 -35
  22. package/dist/task/final-gate-fix.d.ts +1 -1
  23. package/dist/task/final-gate-fix.js +1 -1
  24. package/dist/task/final-gate.d.ts +5 -151
  25. package/dist/task/final-gate.js +81 -379
  26. package/dist/task/gate-child.d.ts +8 -10
  27. package/dist/task/gate-child.js +15 -19
  28. package/dist/task/gate-deps.d.ts +29 -0
  29. package/dist/task/gate-deps.js +192 -206
  30. package/dist/task/gate-tally.d.ts +189 -0
  31. package/dist/task/gate-tally.js +249 -0
  32. package/dist/task/implementation-turn.d.ts +201 -0
  33. package/dist/task/implementation-turn.js +263 -0
  34. package/dist/task/launch-contract.js +27 -43
  35. package/dist/task/ledger.d.ts +38 -0
  36. package/dist/task/ledger.js +83 -0
  37. package/dist/task/loop-detector.d.ts +14 -8
  38. package/dist/task/loop-detector.js +36 -12
  39. package/dist/task/orchestrator.d.ts +61 -126
  40. package/dist/task/orchestrator.js +67 -294
  41. package/dist/task/plan-orchestrator.js +34 -33
  42. package/dist/task/requirements.d.ts +1 -1
  43. package/dist/task/requirements.js +50 -66
  44. package/dist/task/root-cause-repair.js +20 -32
  45. package/dist/task/run-bracket.d.ts +75 -0
  46. package/dist/task/run-bracket.js +41 -0
  47. package/dist/task/stall-detector.d.ts +110 -0
  48. package/dist/task/stall-detector.js +159 -0
  49. package/dist/task/verify-work.d.ts +53 -67
  50. package/dist/task/verify-work.js +15 -11
  51. package/dist/workers/single-read-extension.d.ts +1 -1
  52. package/dist/workers/single-read-extension.js +5 -4
  53. package/dist/workers/single-read-guard.d.ts +32 -10
  54. package/dist/workers/single-read-guard.js +67 -16
  55. package/package.json +1 -1
@@ -31,8 +31,7 @@
31
31
  import type { ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
32
32
  import type { RunWorkerInput, RunWorkerResult } from '../workers/pi-worker-core.js';
33
33
  import type { GitStateSnapshot, ReconcileResult } from './git-state-guard.js';
34
- import type { ContextSnapshot } from '../shared/child-process.js';
35
- import type { AutoLoaderState } from './widget.js';
34
+ import type { ChildStatus } from './child-status.js';
36
35
  /** Which gate child this is. */
37
36
  export type GateChildKind = 'verify' | 'recommend' | 'lint-fix' | 'final-fix' | 'enforce';
38
37
  export interface GateChildRow {
@@ -76,21 +75,20 @@ export interface GateChildDeps {
76
75
  commandTimeoutMs: number;
77
76
  /** Hung-stream bound; the probe-based stall guard cannot supply it. */
78
77
  streamInactivityMs: number;
79
- parentContextWindow: number;
78
+ /**
79
+ * The live widget state this child feeds and its loader reads. SHARED with
80
+ * the caller — the verify gate's own loader reads the same status while this
81
+ * child runs with `loader: false` — so it is the caller's object, not a copy.
82
+ * It also owns the loader ritual and the context-usage resolution.
83
+ */
84
+ status: ChildStatus;
80
85
  runWorker: (input: RunWorkerInput) => Promise<RunWorkerResult>;
81
86
  makeDebugAppender: (path: string) => (line: string, level?: 'event' | 'stream') => void;
82
- startAutoLoader: (ctx: ExtensionCommandContext, getState: () => AutoLoaderState | null) => () => void;
83
87
  captureGitState: (cwd: string, signal?: AbortSignal) => Promise<GitStateSnapshot>;
84
88
  reconcileGitState: (cwd: string, snapshot: GitStateSnapshot, signal?: AbortSignal) => Promise<ReconcileResult>;
85
89
  /** Tree changes for a WRITE-capable child, already formatted. */
86
90
  describeTreeChanges: (cwd: string, signal?: AbortSignal) => Promise<string>;
87
- resolveContextUsage: (snapshot: ContextSnapshot, prev: ContextSnapshot | undefined, parentContextWindow: number) => ContextSnapshot;
88
91
  truncateToolResult: (text: string) => string;
89
- /** Where the live widget reads `lastLine` / `contextUsage` from. */
90
- widget: {
91
- lastLine?: string;
92
- contextUsage?: ContextSnapshot;
93
- };
94
92
  /** Set to the last reconcile so the caller can discard a tainted verdict. */
95
93
  onReconcile?: (rec: ReconcileResult) => void;
96
94
  }
@@ -57,8 +57,11 @@ export const GATE_CHILD_KINDS = {
57
57
  export function makeGateChild(deps) {
58
58
  const row = GATE_CHILD_KINDS[deps.kind];
59
59
  return async (tools, prompt, sig) => {
60
- deps.widget.lastLine = undefined;
61
- deps.widget.contextUsage = undefined;
60
+ // Reset FIRST — before the start marker and the guard snapshot, not just
61
+ // inside `track` — so a previous child's trailer never shows under this
62
+ // one while the git snapshot is still being taken (the verify gate's own
63
+ // loader is already reading this status by then).
64
+ deps.status.reset();
62
65
  const startedAt = Date.now();
63
66
  // Every marker below (start/end, the guard's restore, the loop warning, a
64
67
  // write-capable child's tree changes) is a guard record that survives at
@@ -67,19 +70,17 @@ export function makeGateChild(deps) {
67
70
  const log = deps.makeDebugAppender(deps.logPath);
68
71
  log(`=== ${deps.kind} start: ${deps.taskTitle} ===`);
69
72
  const guardSnapshot = row.guarded ? await deps.captureGitState(deps.cwd, sig) : null;
70
- const stopLoader = deps.loader === false ?
71
- () => { }
72
- : deps.startAutoLoader(deps.ctx, () => ({
73
+ const frame = deps.loader === false ?
74
+ null
75
+ : () => ({
73
76
  title: deps.taskTitle,
74
77
  kind: deps.kind,
75
78
  step: row.step,
76
79
  stepNum: 1,
77
80
  stepTotal: 1,
78
- startedAt,
79
- lastLine: deps.widget.lastLine,
80
- contextUsage: deps.widget.contextUsage
81
- }));
82
- try {
81
+ startedAt
82
+ });
83
+ return deps.status.track(deps.ctx, frame, async () => {
83
84
  let r;
84
85
  try {
85
86
  r = await deps.runWorker({
@@ -106,10 +107,10 @@ export function makeGateChild(deps) {
106
107
  + (rs.detail ? ` — ${rs.detail}` : '')
107
108
  + ' ==='),
108
109
  onLine: line => {
109
- // `lastLine` feeds the LIVE widget and is not logging — it
110
+ // The status feeds the LIVE widget and is not logging — it
110
111
  // stays outside the gate, or a quiet trail would also blank
111
112
  // the progress display.
112
- deps.widget.lastLine = line;
113
+ deps.status.onLine(line);
113
114
  log(line, 'stream');
114
115
  },
115
116
  ...(row.logToolResults ?
@@ -118,9 +119,7 @@ export function makeGateChild(deps) {
118
119
  + deps.truncateToolResult(text), 'stream')
119
120
  }
120
121
  : {}),
121
- onContextUsage: snapshot => {
122
- deps.widget.contextUsage = deps.resolveContextUsage(snapshot, deps.widget.contextUsage, deps.parentContextWindow);
123
- }
122
+ onContextUsage: snapshot => deps.status.onContextUsage(snapshot)
124
123
  });
125
124
  }
126
125
  finally {
@@ -169,9 +168,6 @@ export function makeGateChild(deps) {
169
168
  + `${await deps.describeTreeChanges(deps.cwd, sig)} ===`);
170
169
  }
171
170
  return r.text;
172
- }
173
- finally {
174
- stopLoader();
175
- }
171
+ });
176
172
  };
177
173
  }
@@ -1,5 +1,6 @@
1
1
  import type { ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
2
2
  import type { GateDeps } from './task-gates.js';
3
+ import { type VerifyProbes } from './verify-work.js';
3
4
  import { type FinalFixResult } from './final-gate-fix.js';
4
5
  import { type AddedLine } from './probe-gaming.js';
5
6
  import { type ChangedFile } from './substitution-probe.js';
@@ -135,6 +136,34 @@ export declare function collectTaskTreeChanges(cwd: string, signal?: AbortSignal
135
136
  * costs one extra tracked-file listing, not a second diff.
136
137
  */
137
138
  export declare function collectTestAssemblyFindings(cwd: string, changed: ChangedFile[], signal?: AbortSignal): Promise<string[]>;
139
+ /**
140
+ * The composed spec a gate judges against: the `## spec` section of the task file
141
+ * (see extractSpecForVerification). A task that never reached compose has no spec
142
+ * section, and an unreadable task file is the same case — both are null, and every
143
+ * caller already treats null as "nothing to hold the work to" (verify no-ops,
144
+ * frozen paths are empty, recommend falls back to the title). Never throws. This
145
+ * used to be the same four-line try/catch at four gate sites.
146
+ */
147
+ export declare function readSpecForVerification(cwd: string, taskId: string): Promise<string | null>;
148
+ /**
149
+ * Bind the deterministic verify probes — THE one place the collectors above meet
150
+ * the probe table in verify-work.ts. One entry per `BoundProbeKey`; the table row
151
+ * with that key reads it, and the table's loop owns the ritual (skip when absent,
152
+ * degrade to the row's empty value when the thunk throws), so nothing here needs a
153
+ * try/catch and a fault in one probe cannot reach another. Each thunk is LAZY:
154
+ * building the record runs no git.
155
+ *
156
+ * `spec` is the composed spec (see readSpecForVerification): the prohibition probe
157
+ * reads it and skips the git call when the spec forbids nothing. `log` is the
158
+ * verify debug appender for the one probe that repairs as well as reports.
159
+ */
160
+ export declare function buildVerifyProbes(params: {
161
+ cwd: string;
162
+ signal?: AbortSignal;
163
+ taskId: string;
164
+ spec: string | null;
165
+ log?: (msg: string) => void;
166
+ }): VerifyProbes;
138
167
  /**
139
168
  * Build the gate deps for one command run. `runTask` is the orchestrator's
140
169
  * implementation re-runner, injected by the caller. The returned object also drives
@@ -44,7 +44,7 @@ import { runWorker } from '../workers/pi-worker-core.js';
44
44
  import { getConfig } from '../config/config.js';
45
45
  import { makeDebugAppender } from './debug-log.js';
46
46
  import { startAutoLoader } from './widget.js';
47
- import { resolveContextUsage } from './context-usage.js';
47
+ import { ChildStatus } from './child-status.js';
48
48
  import { makeGateChild } from './gate-child.js';
49
49
  /** Max chars of a tool result kept in the gate debug log (mx5 run 10 item 6). */
50
50
  const TOOL_RESULT_LOG_LIMIT = 300;
@@ -457,6 +457,90 @@ export async function collectTestAssemblyFindings(cwd, changed, signal) {
457
457
  }
458
458
  return testAssemblyVerifyFindings(findTestRebuiltAssemblies(testFiles, production));
459
459
  }
460
+ /**
461
+ * The composed spec a gate judges against: the `## spec` section of the task file
462
+ * (see extractSpecForVerification). A task that never reached compose has no spec
463
+ * section, and an unreadable task file is the same case — both are null, and every
464
+ * caller already treats null as "nothing to hold the work to" (verify no-ops,
465
+ * frozen paths are empty, recommend falls back to the title). Never throws. This
466
+ * used to be the same four-line try/catch at four gate sites.
467
+ */
468
+ export async function readSpecForVerification(cwd, taskId) {
469
+ try {
470
+ const { body } = await readTaskFile(cwd, taskId);
471
+ return extractSpecForVerification(body);
472
+ }
473
+ catch {
474
+ return null;
475
+ }
476
+ }
477
+ /**
478
+ * Bind the deterministic verify probes — THE one place the collectors above meet
479
+ * the probe table in verify-work.ts. One entry per `BoundProbeKey`; the table row
480
+ * with that key reads it, and the table's loop owns the ritual (skip when absent,
481
+ * degrade to the row's empty value when the thunk throws), so nothing here needs a
482
+ * try/catch and a fault in one probe cannot reach another. Each thunk is LAZY:
483
+ * building the record runs no git.
484
+ *
485
+ * `spec` is the composed spec (see readSpecForVerification): the prohibition probe
486
+ * reads it and skips the git call when the spec forbids nothing. `log` is the
487
+ * verify debug appender for the one probe that repairs as well as reports.
488
+ */
489
+ export function buildVerifyProbes(params) {
490
+ const { cwd, signal, taskId, spec, log } = params;
491
+ return {
492
+ // Deterministic self-verification probe: test files the task itself
493
+ // authored/changed become prompt-level findings mandating the child
494
+ // to drive the real artifact before trusting their green result.
495
+ substitution: () => collectChangedFiles(cwd, signal).then(findSubstitutionSuspects),
496
+ // Deterministic prohibition probe: paths the spec forbids modifying
497
+ // that the task's diff modified anyway become prompt-level findings
498
+ // under the no-waiver rule — the child otherwise rarely runs `git
499
+ // diff` and cannot even see the violation.
500
+ prohibition: () => {
501
+ const banned = spec ? extractProhibitions(spec) : [];
502
+ if (banned.length === 0)
503
+ return Promise.resolve([]);
504
+ return collectChangedFiles(cwd, signal).then(files => findProhibitionViolations(banned, files));
505
+ },
506
+ // Deterministic cross-task deletion probe (mx5 run 12 PROMPT 2):
507
+ // tracked files this task's diff DELETES whose introducing commit
508
+ // belongs to a DIFFERENT task — a sibling's committed deliverable
509
+ // destroyed (typically to green a check). Injected under rule 4d and
510
+ // carried on a FAIL so an ACCEPT records durable debts.
511
+ crossTaskDeletion: () => collectTaskTreeChanges(cwd, signal).then(changes => findCrossTaskDeletions(changes, taskId, rel => taskThatIntroduced(cwd, rel))),
512
+ // Deterministic probe-gaming probe (F6): added lines whose stated
513
+ // purpose is to make a check pass rather than meet the requirement
514
+ // ("return 401 so the verification test passes") become rule-4c
515
+ // findings so the child verifies the real requirement, not the check.
516
+ probeGaming: () => collectAddedLines(cwd, signal).then(findProbeGaming),
517
+ // Deterministic sandbox-path-leak probe (mx5 run 13 PROMPT 4 item
518
+ // 1): absolute paths committed from the authoring child's own
519
+ // environment (`/workspace/src/shared`) that resolve nowhere here.
520
+ // Repaired deterministically where the relative form provably
521
+ // resolves; the remainder is injected under rule 4e, whose point is
522
+ // that such a path breaks the BUILD — so the checks that would have
523
+ // caught it report nothing rather than failing.
524
+ foreignPath: () => collectForeignPathFindings(cwd, signal, log),
525
+ // Deterministic neutered-check-script probe (mx5 run 13 PROMPT 4
526
+ // item 4): a check script this task authored that cannot fail
527
+ // (`… || true`, an inverted-grep launder). Injected under rule 4f,
528
+ // because the child provably cannot find this by running the
529
+ // script — it passes, which IS the defect.
530
+ scriptEscape: () => collectScriptEscapeFindings(cwd, signal),
531
+ // Deterministic runner glob-collision probe (mx5 runs 7 AND 13,
532
+ // PROMPT 4 item 2): both `bun test` and `playwright test` declared
533
+ // with no proof their file sets are disjoint. Injected under rule
534
+ // 4g — the collision kills the suite during COLLECTION, which does
535
+ // not look like a test failure.
536
+ runnerGlob: () => collectRunnerGlobFindings(cwd),
537
+ // Deterministic test-assembly probe (F4): authored test files that
538
+ // rebuild production wiring — importing the leaf modules the shipped
539
+ // entry composes and assembling their own copy — become rule-3f
540
+ // findings so the child drives the REAL assembly, not the copy.
541
+ testAssembly: () => collectChangedFiles(cwd, signal).then(changed => collectTestAssemblyFindings(cwd, changed, signal))
542
+ };
543
+ }
460
544
  /**
461
545
  * Build the gate deps for one command run. `runTask` is the orchestrator's
462
546
  * implementation re-runner, injected by the caller. The returned object also drives
@@ -471,10 +555,11 @@ export function buildGateDeps(params) {
471
555
  // loader across the deterministic stage — so the dead air can be measured in the
472
556
  // SAME binary rather than against a remembered baseline. Unset in every real run.
473
557
  const deadAirBaseline = process.env.DEADAIR_AB_ARM === 'baseline';
474
- // Captured by each gate child's loader so the widget mirrors the child's latest
475
- // output line and context usage, exactly like the single-task phase widget.
476
- let lastLine;
477
- let contextUsage;
558
+ // ONE live status for every gate child, so the widget mirrors the running
559
+ // child's latest output line and context usage exactly like the single-task
560
+ // phase widget — and so the verify gate's own loader (below) reads what its
561
+ // loader-less child feeds.
562
+ const status = new ChildStatus({ parentContextWindow });
478
563
  // What the git-state guard had to restore after the MOST RECENT verify/recommend
479
564
  // child run (see git-state-guard.ts). runWorkVerification reads this through its
480
565
  // mutationCheck dep to discard a verdict computed on a mutated tree.
@@ -488,50 +573,28 @@ export function buildGateDeps(params) {
488
573
  };
489
574
  // Adapter onto the shared gate-child runner (gate-child.ts). What survives
490
575
  // here is WIRING — which context, which log file, which config knobs, and
491
- // where the live-widget state lives; the ritual and the per-kind policy are
492
- // the table's. The enforce child below goes through the same call.
493
- const gateChild = (gateCtx, cwd2, taskTitle, kind, logFile, opts = {}) => {
494
- // An accessor box, not a copy: the runner writes these fields and the
495
- // loader snapshot reads them on every tick, so both must see the same
496
- // closure state the rest of buildGateDeps already shares.
497
- const widget = {
498
- get lastLine() {
499
- return lastLine;
500
- },
501
- set lastLine(v) {
502
- lastLine = v;
503
- },
504
- get contextUsage() {
505
- return contextUsage;
506
- },
507
- set contextUsage(v) {
508
- contextUsage = v;
509
- }
510
- };
511
- return makeGateChild({
512
- ctx: gateCtx,
513
- cwd: cwd2,
514
- taskTitle,
515
- kind,
516
- logPath: path.join(tasksDir(cwd2), logFile),
517
- ...(opts.loader === undefined ? {} : { loader: opts.loader }),
518
- commandTimeoutMs: getConfig().requestTimeoutMs,
519
- streamInactivityMs: getConfig().streamInactivityMs,
520
- parentContextWindow,
521
- runWorker,
522
- makeDebugAppender,
523
- startAutoLoader,
524
- captureGitState,
525
- reconcileGitState,
526
- describeTreeChanges: async (c, sig) => formatTreeChanges(await collectTreeChanges(c, sig)),
527
- resolveContextUsage,
528
- truncateToolResult,
529
- widget,
530
- onReconcile: rec => {
531
- lastGuardReconcile = rec;
532
- }
533
- });
534
- };
576
+ // the shared live status; the ritual and the per-kind policy are the
577
+ // table's. The enforce child below goes through the same call.
578
+ const gateChild = (gateCtx, cwd2, taskTitle, kind, logFile, opts = {}) => makeGateChild({
579
+ ctx: gateCtx,
580
+ cwd: cwd2,
581
+ taskTitle,
582
+ kind,
583
+ logPath: path.join(tasksDir(cwd2), logFile),
584
+ ...(opts.loader === undefined ? {} : { loader: opts.loader }),
585
+ commandTimeoutMs: getConfig().requestTimeoutMs,
586
+ streamInactivityMs: getConfig().streamInactivityMs,
587
+ status,
588
+ runWorker,
589
+ makeDebugAppender,
590
+ captureGitState,
591
+ reconcileGitState,
592
+ describeTreeChanges: async (c, sig) => formatTreeChanges(await collectTreeChanges(c, sig)),
593
+ truncateToolResult,
594
+ onReconcile: rec => {
595
+ lastGuardReconcile = rec;
596
+ }
597
+ });
535
598
  return {
536
599
  runTask,
537
600
  // Durable per-task gate trail: every verdict/decision lands in the task
@@ -604,15 +667,7 @@ export function buildGateDeps(params) {
604
667
  // enforce EDIT pass makes to them before those edits are committed. Reads the
605
668
  // same composed spec + extractProhibitions the verify probe consumes; empty on
606
669
  // a spec that froze nothing → the guard is a no-op.
607
- frozenPaths: async (cwd2, taskId) => {
608
- try {
609
- const { body } = await readTaskFile(cwd2, taskId);
610
- return frozenPathsFromSpec(extractSpecForVerification(body));
611
- }
612
- catch {
613
- return [];
614
- }
615
- },
670
+ frozenPaths: async (cwd2, taskId) => frozenPathsFromSpec(await readSpecForVerification(cwd2, taskId)),
616
671
  // Restore those frozen paths to HEAD, discarding a gate child's edits to them;
617
672
  // returns the files actually reverted (for the trail). Best-effort git shape.
618
673
  revertFrozenPaths: (cwd2, paths) => revertFrozenPaths(paths, async (args) => {
@@ -654,14 +709,7 @@ export function buildGateDeps(params) {
654
709
  // The spec to verify against is the composed spec committed in the task
655
710
  // file. A task that never reached compose has no spec section —
656
711
  // runWorkVerification treats a null spec as a no-op pass.
657
- let spec;
658
- try {
659
- const { body } = await readTaskFile(cwd2, taskId);
660
- spec = extractSpecForVerification(body);
661
- }
662
- catch {
663
- spec = null;
664
- }
712
+ const spec = await readSpecForVerification(cwd2, taskId);
665
713
  // DEAD AIR (the reason this loader exists). The gate's DETERMINISTIC
666
714
  // stage — repo health plus ten probes — runs before the verify child,
667
715
  // and the child's own loader only starts once the child does. The impl
@@ -674,133 +722,84 @@ export function buildGateDeps(params) {
674
722
  // deterministic stage and the child — so the run is never silent.
675
723
  const gateStartedAt = Date.now();
676
724
  let stageLine;
677
- // Clear the PREVIOUS child's trailer before the loader goes up: the
678
- // deterministic stage has no child of its own, so a stale `↳` line from
679
- // the last task's enforce pass would otherwise sit under the new
680
- // status block as if it were live.
681
- lastLine = undefined;
682
- contextUsage = undefined;
683
- const stopGateLoader = deadAirBaseline ?
684
- () => { }
685
- : startAutoLoader(verifyCtx, () => ({
686
- title: taskTitle,
687
- kind: 'verify',
688
- step: 'verify',
689
- stepNum: 1,
690
- stepTotal: 1,
691
- startedAt: gateStartedAt,
692
- lastLine: lastLine ?? stageLine,
693
- contextUsage
694
- }));
695
- try {
696
- return await runWorkVerification({
725
+ // `track` clears the PREVIOUS child's trailer before the loader goes
726
+ // up: the deterministic stage has no child of its own, so a stale `↳`
727
+ // line from the last task's enforce pass would otherwise sit under the
728
+ // new status block as if it were live. The frame's `lastLine` wins
729
+ // over the status's own, which is how the stage label shows until
730
+ // the child has a line.
731
+ const gateFrame = deadAirBaseline ? null : (() => ({
732
+ title: taskTitle,
733
+ kind: 'verify',
734
+ step: 'verify',
735
+ stepNum: 1,
736
+ stepTotal: 1,
737
+ startedAt: gateStartedAt,
738
+ lastLine: status.snapshot().lastLine ?? stageLine
739
+ }));
740
+ return status.track(verifyCtx, gateFrame, () => runWorkVerification({
741
+ cwd: cwd2,
742
+ signal,
743
+ spec,
744
+ // The child renders no loader of its own: the gate-wide one above is
745
+ // already live and reads the same status the child feeds, so a
746
+ // second widget on the same key would only fight it.
747
+ runChild: gateChild(verifyCtx, cwd2, taskTitle, 'verify', 'verify-debug.log', {
748
+ loader: deadAirBaseline
749
+ }),
750
+ // Names the deterministic step in the live status line.
751
+ onStage: label => {
752
+ stageLine = label;
753
+ },
754
+ // Deterministic whole-repo static-analysis gate — runs the project's
755
+ // own lint/typecheck and fails on a real non-zero exit, independent of
756
+ // the model-authored VERIFY block (which may not lint at all). ASYNC:
757
+ // the sync runner froze the event loop for the whole lint (see above).
758
+ repoHealth: () => deadAirBaseline ?
759
+ Promise.resolve(runRepoHealthCheck(cwd2))
760
+ : runRepoHealthCheckAsync(cwd2, {
761
+ signal,
762
+ onCommand: c => {
763
+ stageLine = `repo health · ${c}`;
764
+ }
765
+ }),
766
+ // The deterministic probes, bound in one place (buildVerifyProbes
767
+ // above); the PROBE_ADAPTERS table in verify-work.ts runs them.
768
+ probes: buildVerifyProbes({
697
769
  cwd: cwd2,
698
770
  signal,
771
+ taskId,
699
772
  spec,
700
- // The child renders no loader of its own: the gate-wide one above is
701
- // already live and reads the same `lastLine`/`contextUsage` the child
702
- // feeds, so a second widget on the same key would only fight it.
703
- runChild: gateChild(verifyCtx, cwd2, taskTitle, 'verify', 'verify-debug.log', {
704
- loader: deadAirBaseline
705
- }),
706
- // Names the deterministic step in the live status line.
707
- onStage: label => {
708
- stageLine = label;
709
- },
710
- // Deterministic whole-repo static-analysis gate runs the project's
711
- // own lint/typecheck and fails on a real non-zero exit, independent of
712
- // the model-authored VERIFY block (which may not lint at all). ASYNC:
713
- // the sync runner froze the event loop for the whole lint (see above).
714
- repoHealth: () => deadAirBaseline ?
715
- Promise.resolve(runRepoHealthCheck(cwd2))
716
- : runRepoHealthCheckAsync(cwd2, {
717
- signal,
718
- onCommand: c => {
719
- stageLine = `repo health · ${c}`;
720
- }
721
- }),
722
- // Deterministic self-verification probe: test files the task itself
723
- // authored/changed become prompt-level findings mandating the child
724
- // to drive the real artifact before trusting their green result.
725
- probe: () => collectChangedFiles(cwd2, signal).then(findSubstitutionSuspects),
726
- // Deterministic test-assembly probe (F4): authored test files that
727
- // rebuild production wiring — importing the leaf modules the shipped
728
- // entry composes and assembling their own copy — become rule-3f
729
- // findings so the child drives the REAL assembly, not the copy.
730
- testAssemblyProbe: () => collectChangedFiles(cwd2, signal).then(changed => collectTestAssemblyFindings(cwd2, changed, signal)),
731
- // Deterministic probe-gaming probe (F6): added lines whose stated
732
- // purpose is to make a check pass rather than meet the requirement
733
- // ("return 401 so the verification test passes") become rule-4c
734
- // findings so the child verifies the real requirement, not the check.
735
- probeGamingProbe: () => collectAddedLines(cwd2, signal).then(findProbeGaming),
736
- // Deterministic cross-task deletion probe (mx5 run 12 PROMPT 2):
737
- // tracked files this task's diff DELETES whose introducing commit
738
- // belongs to a DIFFERENT task — a sibling's committed deliverable
739
- // destroyed (typically to green a check). Injected under rule 4d and
740
- // carried on a FAIL so an ACCEPT records durable debts.
741
- crossTaskDeletionProbe: () => collectTaskTreeChanges(cwd2, signal).then(changes => findCrossTaskDeletions(changes, taskId, rel => taskThatIntroduced(cwd2, rel))),
742
- // Deterministic sandbox-path-leak probe (mx5 run 13 PROMPT 4 item
743
- // 1): absolute paths committed from the authoring child's own
744
- // environment (`/workspace/src/shared`) that resolve nowhere here.
745
- // Repaired deterministically where the relative form provably
746
- // resolves; the remainder is injected under rule 4e, whose point is
747
- // that such a path breaks the BUILD — so the checks that would have
748
- // caught it report nothing rather than failing.
749
- foreignPathProbe: () => collectForeignPathFindings(cwd2, signal, makeDebugAppender(path.join(tasksDir(cwd2), 'verify-debug.log'))),
750
- // Deterministic neutered-check-script probe (mx5 run 13 PROMPT 4
751
- // item 4): a check script this task authored that cannot fail
752
- // (`… || true`, an inverted-grep launder). Injected under rule 4f,
753
- // because the child provably cannot find this by running the
754
- // script — it passes, which IS the defect.
755
- scriptEscapeProbe: () => collectScriptEscapeFindings(cwd2, signal),
756
- // Deterministic runner glob-collision probe (mx5 runs 7 AND 13,
757
- // PROMPT 4 item 2): both `bun test` and `playwright test` declared
758
- // with no proof their file sets are disjoint. Injected under rule
759
- // 4g — the collision kills the suite during COLLECTION, which does
760
- // not look like a test failure.
761
- runnerGlobProbe: () => collectRunnerGlobFindings(cwd2),
762
- // Deterministic prohibition probe: paths the spec forbids modifying
763
- // that the task's diff modified anyway become prompt-level findings
764
- // under the no-waiver rule — the child otherwise rarely runs `git
765
- // diff` and cannot even see the violation.
766
- prohibitionProbe: () => {
767
- const banned = spec ? extractProhibitions(spec) : [];
768
- if (banned.length === 0)
769
- return Promise.resolve([]);
770
- return collectChangedFiles(cwd2, signal).then(files => findProhibitionViolations(banned, files));
771
- },
772
- // Git-state guard result of the most recent child run: a verdict
773
- // computed on a tree the child itself mutated is discarded — but ONLY
774
- // when the mutation touched graded state (verdictTainted). A child
775
- // that merely left test-runner output behind (test-results/,
776
- // playwright-report/ …) judged an equivalent tree; its verdict stands
777
- // and the artifacts were still cleaned (mx5 run 9 lost 7 verdicts this
778
- // way — see git-state-guard.ts).
779
- mutationCheck: () => lastGuardReconcile?.verdictTainted ?
780
- { mutated: true, detail: lastGuardReconcile.actions.join('; ') }
781
- : { mutated: false, detail: '' },
782
- // Per-run environment-facts cache under .pi-tasks/ (survives
783
- // discardEdits): earlier children's discoveries save this child
784
- // the re-archaeology; its own ENV-NOTE lines are stored for the
785
- // next one, stamped with this task's id as their origin so a
786
- // later child sees a cited fact is second-hand and must
787
- // re-validate before excusing a failure (F7). Facts only —
788
- // verdict rules unaffected.
789
- envNotes: {
790
- read: () => readEnvNotes(cwd2),
791
- append: notes => appendEnvNotes(cwd2, notes, taskId)
792
- },
793
- // Per-run cross-slice contract registry under .pi-tasks/ (F3): the
794
- // verbatim interface facts the design pins that multiple slices
795
- // share, so the verify child checks this slice's boundary against
796
- // them. Empty on single-`/task` runs or a design with no shared
797
- // boundary → no block.
798
- contracts: () => readContracts(cwd2)
799
- });
800
- }
801
- finally {
802
- stopGateLoader();
803
- }
773
+ log: makeDebugAppender(path.join(tasksDir(cwd2), 'verify-debug.log'))
774
+ }),
775
+ // Git-state guard result of the most recent child run: a verdict
776
+ // computed on a tree the child itself mutated is discarded — but ONLY
777
+ // when the mutation touched graded state (verdictTainted). A child
778
+ // that merely left test-runner output behind (test-results/,
779
+ // playwright-report/ …) judged an equivalent tree; its verdict stands
780
+ // and the artifacts were still cleaned (mx5 run 9 lost 7 verdicts this
781
+ // way — see git-state-guard.ts).
782
+ mutationCheck: () => lastGuardReconcile?.verdictTainted ?
783
+ { mutated: true, detail: lastGuardReconcile.actions.join('; ') }
784
+ : { mutated: false, detail: '' },
785
+ // Per-run environment-facts cache under .pi-tasks/ (survives
786
+ // discardEdits): earlier children's discoveries save this child
787
+ // the re-archaeology; its own ENV-NOTE lines are stored for the
788
+ // next one, stamped with this task's id as their origin so a
789
+ // later child sees a cited fact is second-hand and must
790
+ // re-validate before excusing a failure (F7). Facts only —
791
+ // verdict rules unaffected.
792
+ envNotes: {
793
+ read: () => readEnvNotes(cwd2),
794
+ append: notes => appendEnvNotes(cwd2, notes, taskId)
795
+ },
796
+ // Per-run cross-slice contract registry under .pi-tasks/ (F3): the
797
+ // verbatim interface facts the design pins that multiple slices
798
+ // share, so the verify child checks this slice's boundary against
799
+ // them. Empty on single-`/task` runs or a design with no shared
800
+ // boundary no block.
801
+ contracts: () => readContracts(cwd2)
802
+ }));
804
803
  },
805
804
  lintFix: async (fixCtx, cwd2, taskTitle, taskId, failReason) => {
806
805
  // Same frozen extraction the enforce guard and the verify rule-4b
@@ -808,14 +807,8 @@ export function buildGateDeps(params) {
808
807
  // tsconfig.json because ESLint's own error text instructed it, then
809
808
  // verify failed the TASK for that edit — the child must know the
810
809
  // spec's do-not-touch list AND be mechanically denied it).
811
- let frozenPaths = [];
812
- try {
813
- const { body } = await readTaskFile(cwd2, taskId);
814
- frozenPaths = frozenPathsFromSpec(extractSpecForVerification(body));
815
- }
816
- catch {
817
- // spec unreadable → no frozen paths; the guard degrades to a no-op
818
- }
810
+ // (spec unreadable → no frozen paths; the guard degrades to a no-op)
811
+ const frozenPaths = frozenPathsFromSpec(await readSpecForVerification(cwd2, taskId));
819
812
  return runBoundedLintFix({
820
813
  cwd: cwd2,
821
814
  signal,
@@ -906,14 +899,7 @@ export function buildGateDeps(params) {
906
899
  recommend: async (recCtx, cwd2, taskTitle, taskId, failReason) => {
907
900
  // Read the same composed spec the verify gate judged against, so the
908
901
  // recommendation reasons over the real contract (degrade to the bare title).
909
- let spec;
910
- try {
911
- const { body } = await readTaskFile(cwd2, taskId);
912
- spec = extractSpecForVerification(body) ?? taskTitle;
913
- }
914
- catch {
915
- spec = taskTitle;
916
- }
902
+ const spec = (await readSpecForVerification(cwd2, taskId)) ?? taskTitle;
917
903
  return researchResolution({
918
904
  cwd: cwd2,
919
905
  signal,