@mjasnikovs/pi-task 0.36.0 → 0.37.1

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.
@@ -49,14 +49,40 @@ export declare function findLoginCredentials(vars: Record<string, string>): Logi
49
49
  * FREE — the caller checks freeness and falls back to a reserved port otherwise.
50
50
  */
51
51
  export declare function pinnedLocalPort(vars: Record<string, string>): number | null;
52
+ /**
53
+ * One same-origin request the session issued, as the wire saw it. This is the whole
54
+ * evidence base: `authRequest`, `postAuthDataAttempted` and `postAuthData2xx` below
55
+ * are DERIVED from this list (deriveLegacyFacts), never recorded separately.
56
+ *
57
+ * `mimeType` is here because status alone cannot see a misrouted GET: an SPA
58
+ * catch-all answers every unmatched GET with index.html at 200, so a dead API call
59
+ * looks healthy to any status rule and only the content type gives it away.
60
+ */
61
+ export interface SessionRequest {
62
+ method: string;
63
+ path: string;
64
+ status: number | null;
65
+ mimeType: string | null;
66
+ failed: boolean;
67
+ /** CDP resource type, collapsed: what ISSUED this request. */
68
+ initiator: 'xhr' | 'document' | 'other';
69
+ /** Relative to the sign-in request: before it, it, or after it. */
70
+ phase: 'pre' | 'auth' | 'post';
71
+ }
52
72
  export interface DeepSessionFacts {
73
+ /** Every same-origin request of the whole session, in order. Optional only so
74
+ * that a hand-written or pre-existing recorded session stays valid: absent
75
+ * means "not recorded", and the request-log rules simply do not fire. The live
76
+ * driver always populates it. */
77
+ sessionRequests?: SessionRequest[];
53
78
  /** The landing page presented a sign-in wall (a visible password input). */
54
79
  landingHadAuthWall: boolean;
55
80
  /** A credential pair was declared by the project. */
56
81
  credentialsFound: boolean;
57
82
  /** The form could be filled and submitted. */
58
83
  submitted: boolean;
59
- /** The sign-in request the SUBMIT issued, when one was issued at all. */
84
+ /** The sign-in request the SUBMIT issued, when one was issued at all.
85
+ * Derived: the `sessionRequests` entry in phase 'auth'. */
60
86
  authRequest: {
61
87
  method: string;
62
88
  path: string;
@@ -64,7 +90,7 @@ export interface DeepSessionFacts {
64
90
  failed: boolean;
65
91
  } | null;
66
92
  /** Same-origin XHR/fetch requests issued AFTER the sign-in response, excluding
67
- * the sign-in request itself. */
93
+ * the sign-in request itself. Derived: `sessionRequests` in phase 'post'. */
68
94
  postAuthDataAttempted: number;
69
95
  postAuthData2xx: number;
70
96
  /** Origins the client called that are not the app's own, whose requests failed
@@ -78,6 +104,14 @@ export interface DeepSessionFacts {
78
104
  postAuthDomOk: boolean;
79
105
  postAuthDomDetail: string;
80
106
  }
107
+ /**
108
+ * The three request-shaped facts, computed from the log and from nothing else. The
109
+ * driver records `sessionRequests` and calls this; the values are exactly what the
110
+ * pre-log driver computed by filtering the same map (the sign-in request is the
111
+ * first same-origin non-GET after submit; the data requests are the same-origin
112
+ * XHR/fetch issued at or after it, itself excluded).
113
+ */
114
+ export declare function deriveLegacyFacts(log: SessionRequest[]): Pick<DeepSessionFacts, 'authRequest' | 'postAuthDataAttempted' | 'postAuthData2xx'>;
81
115
  /**
82
116
  * Judge a recorded session. The ONE thing that may FAIL is a session the SERVER
83
117
  * authenticated (2xx on the sign-in request) whose client then could not use it:
@@ -109,4 +143,7 @@ export declare function runDeepRenderCheck(url: string, cwd: string, opts?: {
109
143
  credentials?: LoginCredentials | null;
110
144
  timeoutMs?: number;
111
145
  env?: NodeJS.ProcessEnv;
146
+ /** Recorder hook: receives the facts the verdict was made on. Used by the
147
+ * corpus builder; the gate itself never passes it. */
148
+ onFacts?: (f: DeepSessionFacts) => void;
112
149
  }): Promise<DeepRenderOutcome>;
@@ -194,6 +194,27 @@ export function pinnedLocalPort(vars) {
194
194
  }
195
195
  return ports.length > 0 ? Math.min(...ports) : null;
196
196
  }
197
+ /**
198
+ * The three request-shaped facts, computed from the log and from nothing else. The
199
+ * driver records `sessionRequests` and calls this; the values are exactly what the
200
+ * pre-log driver computed by filtering the same map (the sign-in request is the
201
+ * first same-origin non-GET after submit; the data requests are the same-origin
202
+ * XHR/fetch issued at or after it, itself excluded).
203
+ */
204
+ export function deriveLegacyFacts(log) {
205
+ const auth = log.find(r => r.phase === 'auth') ?? null;
206
+ const data = log.filter(r => r.phase === 'post' && r.initiator === 'xhr');
207
+ return {
208
+ authRequest: auth === null ? null : ({ method: auth.method, path: auth.path, status: auth.status, failed: auth.failed }),
209
+ postAuthDataAttempted: data.length,
210
+ postAuthData2xx: data.filter(r => r.status !== null && r.status >= 200 && r.status < 300)
211
+ .length
212
+ };
213
+ }
214
+ /** Statuses that mean "no handler is mounted here", as opposed to a handler that
215
+ * answered No. 501 is included because a server that routes but implements
216
+ * nothing is the same dead call from the client's side. */
217
+ const MISSING_ROUTE_STATUS = new Set([404, 405, 501]);
197
218
  /**
198
219
  * Judge a recorded session. The ONE thing that may FAIL is a session the SERVER
199
220
  * authenticated (2xx on the sign-in request) whose client then could not use it:
@@ -241,6 +262,36 @@ export function judgeDeepSession(f) {
241
262
  note: `submitting the sign-in form issued no request to the app's own origin${pinned} — the authenticated half of the app was NOT observed`
242
263
  };
243
264
  }
265
+ // Rule A — a route the server does not have. The client addressed a path
266
+ // nothing is mounted on, so the request was never evaluated by any handler.
267
+ // Deliberately narrow: 400/401/403/419/422 are a handler ANSWERING (a rejected
268
+ // password, a missing permission — the app working), 5xx is the server failing,
269
+ // and both keep their existing behaviour. Only "no such route" is here.
270
+ const missingRoute = (f.sessionRequests ?? []).find(r => r.initiator === 'xhr' && r.status !== null && MISSING_ROUTE_STATUS.has(r.status));
271
+ if (missingRoute) {
272
+ return {
273
+ outcome: 'fail',
274
+ detail: `\`${missingRoute.method} ${missingRoute.path}\` → ${String(missingRoute.status)}: `
275
+ + 'the client sent this to a path the server does not route. The credentials were '
276
+ + 'never evaluated. This is a dead client call — a base URL joined twice, a renamed '
277
+ + 'route, a wrong method. No type or mock can produce a route that is not mounted.'
278
+ };
279
+ }
280
+ // Rule B — the SPA catch-all answering an API call. Any server with a
281
+ // `GET /*` → index.html fallback returns 200 for a route it does not have, so
282
+ // the status is healthy and the body is the app shell. A document navigation
283
+ // answered with HTML is normal; an XHR asking for data and getting HTML is a
284
+ // call that reached nothing.
285
+ const swallowed = (f.sessionRequests ?? []).find(r => r.initiator === 'xhr' && (r.mimeType ?? '').startsWith('text/html'));
286
+ if (swallowed) {
287
+ return {
288
+ outcome: 'fail',
289
+ detail: `\`${swallowed.method} ${swallowed.path}\` → ${String(swallowed.status)} `
290
+ + `${swallowed.mimeType ?? ''}: an XHR asked this server for data and got the SPA `
291
+ + 'shell. The route is not mounted and the catch-all answered instead, so the client '
292
+ + 'sees a 200 it cannot parse. No status check can see this.'
293
+ };
294
+ }
244
295
  const { method, path: p, status, failed } = f.authRequest;
245
296
  if (failed || status === null || status < 200 || status >= 300) {
246
297
  return {
@@ -293,6 +344,9 @@ const QUIET_MS = 1_200;
293
344
  /** Caps for the two settle windows (initial load, post-submit). */
294
345
  const SETTLE_CAP_MS = 8_000;
295
346
  const POST_SUBMIT_CAP_MS = 12_000;
347
+ /** Cap for the one authenticated re-entry into the landing URL (see `drive`). Kept
348
+ * small so the three settle windows together stay inside DEEP_RENDER_TIMEOUT_MS. */
349
+ const RE_NAV_CAP_MS = 6_000;
296
350
  /** Minimal DevTools-protocol client: request/response ids over one socket, plus
297
351
  * event fan-out. Everything the driver needs and nothing more. */
298
352
  class Cdp {
@@ -460,7 +514,7 @@ export async function runDeepRenderCheck(url, cwd, opts = {}) {
460
514
  child = c;
461
515
  }, s => {
462
516
  socket = s;
463
- }), budget);
517
+ }, opts.onFacts), budget);
464
518
  }
465
519
  catch (e) {
466
520
  const why = e instanceof Error ? e.message : String(e);
@@ -486,8 +540,14 @@ function withTimeout(p, ms) {
486
540
  });
487
541
  });
488
542
  }
489
- async function drive(url, bin, userDataDir, credentials, holdChild, holdSocket) {
543
+ async function drive(url, bin, userDataDir, credentials, holdChild, holdSocket, onFacts) {
490
544
  const origin = new URL(url).origin;
545
+ /** Every verdict goes through here, so a recorder sees the same facts the judge
546
+ * does — the corpus is what the gate itself read, not a reconstruction. */
547
+ const judge = (f) => {
548
+ onFacts?.(f);
549
+ return judgeDeepSession(f);
550
+ };
491
551
  const child = spawn(bin, [
492
552
  '--headless',
493
553
  '--disable-gpu',
@@ -531,6 +591,7 @@ async function drive(url, bin, userDataDir, credentials, holdChild, holdSocket)
531
591
  method: String(req?.method ?? 'GET'),
532
592
  type: String(p.type ?? ''),
533
593
  status: null,
594
+ mimeType: null,
534
595
  failed: false,
535
596
  at: Date.now()
536
597
  });
@@ -541,6 +602,9 @@ async function drive(url, bin, userDataDir, credentials, holdChild, holdSocket)
541
602
  const res = p.response;
542
603
  if (r) {
543
604
  r.status = typeof res?.status === 'number' ? res.status : r.status;
605
+ if (typeof res?.mimeType === 'string' && res.mimeType.length > 0) {
606
+ r.mimeType = res.mimeType;
607
+ }
544
608
  if (p.type)
545
609
  r.type = String(p.type);
546
610
  }
@@ -582,13 +646,36 @@ async function drive(url, bin, userDataDir, credentials, holdChild, holdSocket)
582
646
  }
583
647
  return [...out];
584
648
  };
585
- const facts = (over) => ({
649
+ const initiatorOf = (r) => isData(r) ? 'xhr'
650
+ : r.type === 'Document' ? 'document'
651
+ : 'other';
652
+ /** The same-origin request log, phased against the sign-in request. `authAt` is
653
+ * Infinity before the submit, so every request so far is 'pre'. */
654
+ const sessionLog = (authId, authAt) => {
655
+ const out = [];
656
+ for (const [id, r] of requests) {
657
+ if (!sameOrigin(r))
658
+ continue;
659
+ out.push({
660
+ method: r.method,
661
+ path: pathOf(r.url),
662
+ status: r.status,
663
+ mimeType: r.mimeType,
664
+ failed: r.failed,
665
+ initiator: initiatorOf(r),
666
+ phase: id === authId ? 'auth'
667
+ : r.at >= authAt ? 'post'
668
+ : 'pre'
669
+ });
670
+ }
671
+ return out;
672
+ };
673
+ const facts = (log, over) => ({
674
+ sessionRequests: log,
675
+ ...deriveLegacyFacts(log),
586
676
  landingHadAuthWall: before.hasPassword,
587
677
  credentialsFound: credentials !== null,
588
678
  submitted: false,
589
- authRequest: null,
590
- postAuthDataAttempted: 0,
591
- postAuthData2xx: 0,
592
679
  foreignOriginFailures: foreignOriginFailures(),
593
680
  leftAuthWall: false,
594
681
  urlBefore: before.url,
@@ -597,18 +684,19 @@ async function drive(url, bin, userDataDir, credentials, holdChild, holdSocket)
597
684
  postAuthDomDetail: '',
598
685
  ...over
599
686
  });
687
+ const unsubmitted = (over) => facts(sessionLog(null, Number.POSITIVE_INFINITY), over);
600
688
  if (!before.hasPassword || credentials === null)
601
- return judgeDeepSession(facts({}));
689
+ return judge(unsubmitted({}));
602
690
  const submitMark = Date.now();
603
691
  const filled = await evaluate(fillExpr(credentials.identifier, credentials.password));
604
692
  if (!filled?.ok)
605
- return judgeDeepSession(facts({ submitted: false }));
693
+ return judge(unsubmitted({ submitted: false }));
606
694
  // Separate turn: the fill's input events schedule framework state updates that
607
695
  // the submit handler must already see.
608
696
  await sleep(300);
609
697
  const submitted = await evaluate(SUBMIT_EXPR);
610
698
  if (!submitted?.ok)
611
- return judgeDeepSession(facts({ submitted: false }));
699
+ return judge(unsubmitted({ submitted: false }));
612
700
  lastActivity = Date.now();
613
701
  await settle(() => lastActivity, POST_SUBMIT_CAP_MS);
614
702
  // The sign-in request: the first same-origin non-GET issued by the submit. Its
@@ -625,25 +713,30 @@ async function drive(url, bin, userDataDir, credentials, holdChild, holdSocket)
625
713
  }
626
714
  const authReq = authId !== null ? after.get(authId) : null;
627
715
  const authAt = authReq?.at ?? submitMark;
628
- const postAuthData = [...after]
629
- .filter(([id, r]) => id !== authId && sameOrigin(r) && isData(r) && r.at >= authAt)
630
- .map(([, r]) => r);
631
716
  const now = await evaluate(INSPECT_EXPR);
632
717
  const domJudgment = judgeRenderedDom(now?.html ?? '');
633
- return judgeDeepSession(facts({
718
+ const leftAuthWall = !(now?.hasPassword ?? false) || (now?.pathname ?? '') !== before.pathname;
719
+ // Exercise the authenticated app once. A sign-in page that ends on a success
720
+ // card — mx5's does — issues NOTHING after the login POST, so the authenticated
721
+ // data path is never observed at all and every request-shaped fact below is a
722
+ // fact about the login form. Re-entering the landing URL with the session cookie
723
+ // is the cheapest way to make the app fetch its own data. Deliberately gated on
724
+ // an accepted sign-in that left the wall: every session that SKIPs or FAILs
725
+ // without it takes exactly the path it took before, request logs included.
726
+ const authAccepted = authReq !== null
727
+ && !authReq.failed
728
+ && authReq.status !== null
729
+ && authReq.status >= 200
730
+ && authReq.status < 300;
731
+ if (authAccepted && leftAuthWall) {
732
+ await cdp.send('Page.navigate', { url }, sessionId);
733
+ lastActivity = Date.now();
734
+ await settle(() => lastActivity, RE_NAV_CAP_MS);
735
+ }
736
+ return judge(facts(sessionLog(authId, authAt), {
634
737
  submitted: true,
635
- authRequest: authReq ?
636
- {
637
- method: authReq.method,
638
- path: pathOf(authReq.url),
639
- status: authReq.status,
640
- failed: authReq.failed
641
- }
642
- : null,
643
- postAuthDataAttempted: postAuthData.length,
644
- postAuthData2xx: postAuthData.filter(r => r.status !== null && r.status >= 200 && r.status < 300).length,
645
738
  foreignOriginFailures: foreignOriginFailures(),
646
- leftAuthWall: !(now?.hasPassword ?? false) || (now?.pathname ?? '') !== before.pathname,
739
+ leftAuthWall,
647
740
  urlAfter: now?.url ?? before.url,
648
741
  postAuthDomOk: domJudgment.ok,
649
742
  postAuthDomDetail: domJudgment.detail
@@ -38,6 +38,32 @@ export declare function collectChangedFiles(cwd: string, signal?: AbortSignal):
38
38
  * never a blocker. The `.pi-tasks/` bookkeeping is excluded from every git command.
39
39
  */
40
40
  export declare function collectAddedLines(cwd: string, signal?: AbortSignal): Promise<AddedLine[]>;
41
+ /**
42
+ * Deterministic neutered-check-script pass (see script-escape.ts, mx5 run 13 PROMPT
43
+ * 4 item 4): check-class scripts that cannot report failure, in a manifest THIS
44
+ * task changed.
45
+ *
46
+ * Scoped to manifests the task touched, so the finding lands on the task that
47
+ * authored the script rather than being re-served to every later task. A script
48
+ * neutered by an earlier task is the whole-repo final gate's business, which
49
+ * re-checks the shipped manifest at run end regardless of who wrote it.
50
+ *
51
+ * Failures degrade to no findings — a sharpener, never a blocker.
52
+ */
53
+ export declare function collectScriptEscapeFindings(cwd: string, signal?: AbortSignal): Promise<string[]>;
54
+ /**
55
+ * Deterministic test-runner glob-collision pass (see runner-globs.ts, mx5 runs 7 AND
56
+ * 13, PROMPT 4 item 2): the manifest declares both `bun test` and `playwright test`
57
+ * without a provably disjoint file set, so `bun test` imports the playwright specs
58
+ * and dies during collection.
59
+ *
60
+ * Whole-repo rather than diff-scoped, unlike the neutered-script probe: a collision
61
+ * is a property of the PAIR of declarations, and the task that completes the pair is
62
+ * rarely the one that will be blamed by a diff. It is cheap (two small file reads)
63
+ * and silent unless both runners are actually declared. Failures degrade to no
64
+ * findings — a sharpener, never a blocker.
65
+ */
66
+ export declare function collectRunnerGlobFindings(cwd: string): Promise<string[]>;
41
67
  /**
42
68
  * The working tree's current changes as a summary (write-guard shape): what a
43
69
  * write-capable gate child changed, given the tree was clean when it started.
@@ -87,6 +113,15 @@ export declare function gatePassesWithoutIgnored(cwd: string, paths: string[], r
87
113
  * a sharpener, never a blocker. Same fallback discipline as collectChangedFiles.
88
114
  */
89
115
  export declare function collectTaskTreeChanges(cwd: string, signal?: AbortSignal): Promise<TreeChangeSummary>;
116
+ /**
117
+ * Deterministic test-assembly probe input (see test-assembly.ts, run-8 F4): read the
118
+ * task's own changed TEST files plus the repo's tracked source files, and return the
119
+ * finding lines naming any test that rebuilds a production assembly it never imports.
120
+ * Pure import-graph shape; failures degrade to no findings (the probe is a sharpener,
121
+ * never a blocker). `changed` is the already-collected task diff, reused so the probe
122
+ * costs one extra tracked-file listing, not a second diff.
123
+ */
124
+ export declare function collectTestAssemblyFindings(cwd: string, changed: ChangedFile[], signal?: AbortSignal): Promise<string[]>;
90
125
  /**
91
126
  * Build the gate deps for one command run. `runTask` is the orchestrator's
92
127
  * implementation re-runner, injected by the caller. The returned object also drives
@@ -24,7 +24,7 @@ import { readEnvNotes, appendEnvNotes } from './env-notes.js';
24
24
  import { readContracts } from './contracts.js';
25
25
  import { recordAcceptDebt, recordEnforceKeptDebt, recordEnforceRevertDebt, recordFrozenBlockedDebt, recordCrossTaskDeletionDebt, recordYoloAcceptDebt, recordRootCauseDebt } from './accept-debt.js';
26
26
  import { recordRepairCandidate } from './root-cause-repair.js';
27
- import { runRepoHealthCheck } from './repo-health-check.js';
27
+ import { runRepoHealthCheck, runRepoHealthCheckAsync } from './repo-health-check.js';
28
28
  import { runFinalIntegrationGate, discoverGateCommandLabels, discoverGateCommandBodies } from './final-gate.js';
29
29
  import { runFinalGateAutofix } from './final-gate-fix.js';
30
30
  import { researchResolution } from './verify-resolution.js';
@@ -174,7 +174,7 @@ const MANIFEST_RE = /(^|\/)package\.json$/;
174
174
  *
175
175
  * Failures degrade to no findings — a sharpener, never a blocker.
176
176
  */
177
- async function collectScriptEscapeFindings(cwd, signal) {
177
+ export async function collectScriptEscapeFindings(cwd, signal) {
178
178
  const changed = await collectChangedFiles(cwd, signal);
179
179
  const manifests = changed.map(f => f.path).filter(p => MANIFEST_RE.test(p));
180
180
  const findings = [];
@@ -217,7 +217,7 @@ async function readOrNull(cwd, rel) {
217
217
  * and silent unless both runners are actually declared. Failures degrade to no
218
218
  * findings — a sharpener, never a blocker.
219
219
  */
220
- async function collectRunnerGlobFindings(cwd) {
220
+ export async function collectRunnerGlobFindings(cwd) {
221
221
  const manifestText = await readOrNull(cwd, 'package.json');
222
222
  if (manifestText === null)
223
223
  return [];
@@ -419,7 +419,7 @@ async function readRepoFile(cwd, rel) {
419
419
  * never a blocker). `changed` is the already-collected task diff, reused so the probe
420
420
  * costs one extra tracked-file listing, not a second diff.
421
421
  */
422
- async function collectTestAssemblyFindings(cwd, changed, signal) {
422
+ export async function collectTestAssemblyFindings(cwd, changed, signal) {
423
423
  const changedTests = changed.filter(f => isTestFile(f.path));
424
424
  if (changedTests.length === 0)
425
425
  return [];
@@ -454,6 +454,11 @@ async function collectTestAssemblyFindings(cwd, changed, signal) {
454
454
  */
455
455
  export function buildGateDeps(params) {
456
456
  const { signal, parentContextWindow, runTask } = params;
457
+ // A/B seam (scripts/verify-deadair-ab.ts), same shape as CANCEL_AB_ARM in
458
+ // cancel-points.ts: reproduce the pre-fix gate — blocking sync repo health, no
459
+ // loader across the deterministic stage — so the dead air can be measured in the
460
+ // SAME binary rather than against a remembered baseline. Unset in every real run.
461
+ const deadAirBaseline = process.env.DEADAIR_AB_ARM === 'baseline';
457
462
  // Captured by each gate child's loader so the widget mirrors the child's latest
458
463
  // output line and context usage, exactly like the single-task phase widget.
459
464
  let lastLine;
@@ -475,7 +480,11 @@ export function buildGateDeps(params) {
475
480
  // disabled because re-running the same check is the job), with a status widget
476
481
  // and a per-gate debug log. Returns the closure runWorkVerification /
477
482
  // researchResolution expect as `runChild`.
478
- const makeGateChild = (gateCtx, cwd2, taskTitle, kind, logFile) => async (tools, prompt, sig) => {
483
+ const makeGateChild = (gateCtx, cwd2, taskTitle, kind, logFile,
484
+ /** `loader: false` when the CALLER already renders a loader that spans
485
+ * this child (the verify gate does — see its dead-air note). Two
486
+ * loaders on one widget key only fight each other. */
487
+ opts = {}) => async (tools, prompt, sig) => {
479
488
  lastLine = undefined;
480
489
  contextUsage = undefined;
481
490
  lastGuardReconcile = null;
@@ -493,16 +502,18 @@ export function buildGateDeps(params) {
493
502
  // Snapshot before, deterministically restore after; lint-fix is excluded
494
503
  // because editing is its job (it carries its own revert guard).
495
504
  const guardSnapshot = kind === 'verify' || kind === 'recommend' ? await captureGitState(cwd2, sig) : null;
496
- const stopLoader = startAutoLoader(gateCtx, () => ({
497
- title: taskTitle,
498
- kind,
499
- step: kind,
500
- stepNum: 1,
501
- stepTotal: 1,
502
- startedAt,
503
- lastLine,
504
- contextUsage
505
- }));
505
+ const stopLoader = opts.loader === false ?
506
+ () => { }
507
+ : startAutoLoader(gateCtx, () => ({
508
+ title: taskTitle,
509
+ kind,
510
+ step: kind,
511
+ stepNum: 1,
512
+ stepTotal: 1,
513
+ startedAt,
514
+ lastLine,
515
+ contextUsage
516
+ }));
506
517
  try {
507
518
  let r;
508
519
  try {
@@ -804,93 +815,145 @@ export function buildGateDeps(params) {
804
815
  catch {
805
816
  spec = null;
806
817
  }
807
- return runWorkVerification({
808
- cwd: cwd2,
809
- signal,
810
- spec,
811
- runChild: makeGateChild(verifyCtx, cwd2, taskTitle, 'verify', 'verify-debug.log'),
812
- // Deterministic whole-repo static-analysis gate runs the project's
813
- // own lint/typecheck and fails on a real non-zero exit, independent of
814
- // the model-authored VERIFY block (which may not lint at all).
815
- repoHealth: () => Promise.resolve(runRepoHealthCheck(cwd2)),
816
- // Deterministic self-verification probe: test files the task itself
817
- // authored/changed become prompt-level findings mandating the child
818
- // to drive the real artifact before trusting their green result.
819
- probe: () => collectChangedFiles(cwd2, signal).then(findSubstitutionSuspects),
820
- // Deterministic test-assembly probe (F4): authored test files that
821
- // rebuild production wiring importing the leaf modules the shipped
822
- // entry composes and assembling their own copy — become rule-3f
823
- // findings so the child drives the REAL assembly, not the copy.
824
- testAssemblyProbe: () => collectChangedFiles(cwd2, signal).then(changed => collectTestAssemblyFindings(cwd2, changed, signal)),
825
- // Deterministic probe-gaming probe (F6): added lines whose stated
826
- // purpose is to make a check pass rather than meet the requirement
827
- // ("return 401 so the verification test passes") become rule-4c
828
- // findings so the child verifies the real requirement, not the check.
829
- probeGamingProbe: () => collectAddedLines(cwd2, signal).then(findProbeGaming),
830
- // Deterministic cross-task deletion probe (mx5 run 12 PROMPT 2):
831
- // tracked files this task's diff DELETES whose introducing commit
832
- // belongs to a DIFFERENT task — a sibling's committed deliverable
833
- // destroyed (typically to green a check). Injected under rule 4d and
834
- // carried on a FAIL so an ACCEPT records durable debts.
835
- crossTaskDeletionProbe: () => collectTaskTreeChanges(cwd2, signal).then(changes => findCrossTaskDeletions(changes, taskId, rel => taskThatIntroduced(cwd2, rel))),
836
- // Deterministic sandbox-path-leak probe (mx5 run 13 PROMPT 4 item
837
- // 1): absolute paths committed from the authoring child's own
838
- // environment (`/workspace/src/shared`) that resolve nowhere here.
839
- // Repaired deterministically where the relative form provably
840
- // resolves; the remainder is injected under rule 4e, whose point is
841
- // that such a path breaks the BUILD — so the checks that would have
842
- // caught it report nothing rather than failing.
843
- foreignPathProbe: () => collectForeignPathFindings(cwd2, signal, makeDebugAppender(path.join(tasksDir(cwd2), 'verify-debug.log'))),
844
- // Deterministic neutered-check-script probe (mx5 run 13 PROMPT 4
845
- // item 4): a check script this task authored that cannot fail
846
- // (`… || true`, an inverted-grep launder). Injected under rule 4f,
847
- // because the child provably cannot find this by running the
848
- // script it passes, which IS the defect.
849
- scriptEscapeProbe: () => collectScriptEscapeFindings(cwd2, signal),
850
- // Deterministic runner glob-collision probe (mx5 runs 7 AND 13,
851
- // PROMPT 4 item 2): both `bun test` and `playwright test` declared
852
- // with no proof their file sets are disjoint. Injected under rule
853
- // 4g the collision kills the suite during COLLECTION, which does
854
- // not look like a test failure.
855
- runnerGlobProbe: () => collectRunnerGlobFindings(cwd2),
856
- // Deterministic prohibition probe: paths the spec forbids modifying
857
- // that the task's diff modified anyway become prompt-level findings
858
- // under the no-waiver rule — the child otherwise rarely runs `git
859
- // diff` and cannot even see the violation.
860
- prohibitionProbe: () => {
861
- const banned = spec ? extractProhibitions(spec) : [];
862
- if (banned.length === 0)
863
- return Promise.resolve([]);
864
- return collectChangedFiles(cwd2, signal).then(files => findProhibitionViolations(banned, files));
865
- },
866
- // Git-state guard result of the most recent child run: a verdict
867
- // computed on a tree the child itself mutated is discarded — but ONLY
868
- // when the mutation touched graded state (verdictTainted). A child
869
- // that merely left test-runner output behind (test-results/,
870
- // playwright-report/ …) judged an equivalent tree; its verdict stands
871
- // and the artifacts were still cleaned (mx5 run 9 lost 7 verdicts this
872
- // way see git-state-guard.ts).
873
- mutationCheck: () => lastGuardReconcile?.verdictTainted ?
874
- { mutated: true, detail: lastGuardReconcile.actions.join('; ') }
875
- : { mutated: false, detail: '' },
876
- // Per-run environment-facts cache under .pi-tasks/ (survives
877
- // discardEdits): earlier children's discoveries save this child
878
- // the re-archaeology; its own ENV-NOTE lines are stored for the
879
- // next one, stamped with this task's id as their origin so a
880
- // later child sees a cited fact is second-hand and must
881
- // re-validate before excusing a failure (F7). Facts only
882
- // verdict rules unaffected.
883
- envNotes: {
884
- read: () => readEnvNotes(cwd2),
885
- append: notes => appendEnvNotes(cwd2, notes, taskId)
886
- },
887
- // Per-run cross-slice contract registry under .pi-tasks/ (F3): the
888
- // verbatim interface facts the design pins that multiple slices
889
- // share, so the verify child checks this slice's boundary against
890
- // them. Empty on single-`/task` runs or a design with no shared
891
- // boundary no block.
892
- contracts: () => readContracts(cwd2)
893
- });
818
+ // DEAD AIR (the reason this loader exists). The gate's DETERMINISTIC
819
+ // stage — repo health plus ten probes — runs before the verify child,
820
+ // and the child's own loader only starts once the child does. The impl
821
+ // widget was cleared at `agent_end`, so until now the screen simply
822
+ // stopped: no spinner, no clock, no line (the `verifying…` notify cannot
823
+ // even paint, since pi-tui schedules renders on process.nextTick and the
824
+ // health check used to block the loop outright). MEASURED on real repos:
825
+ // 15s (mx5) to 69s (aiz-client) per health run, 0 of 686 expected 100ms
826
+ // timer ticks delivered. One loader now spans the WHOLE gate — the
827
+ // deterministic stage and the child — so the run is never silent.
828
+ const gateStartedAt = Date.now();
829
+ let stageLine;
830
+ // Clear the PREVIOUS child's trailer before the loader goes up: the
831
+ // deterministic stage has no child of its own, so a stale `↳` line from
832
+ // the last task's enforce pass would otherwise sit under the new
833
+ // status block as if it were live.
834
+ lastLine = undefined;
835
+ contextUsage = undefined;
836
+ const stopGateLoader = deadAirBaseline ?
837
+ () => { }
838
+ : startAutoLoader(verifyCtx, () => ({
839
+ title: taskTitle,
840
+ kind: 'verify',
841
+ step: 'verify',
842
+ stepNum: 1,
843
+ stepTotal: 1,
844
+ startedAt: gateStartedAt,
845
+ lastLine: lastLine ?? stageLine,
846
+ contextUsage
847
+ }));
848
+ try {
849
+ return await runWorkVerification({
850
+ cwd: cwd2,
851
+ signal,
852
+ spec,
853
+ // The child renders no loader of its own: the gate-wide one above is
854
+ // already live and reads the same `lastLine`/`contextUsage` the child
855
+ // feeds, so a second widget on the same key would only fight it.
856
+ runChild: makeGateChild(verifyCtx, cwd2, taskTitle, 'verify', 'verify-debug.log', {
857
+ loader: deadAirBaseline
858
+ }),
859
+ // Names the deterministic step in the live status line.
860
+ onStage: label => {
861
+ stageLine = label;
862
+ },
863
+ // Deterministic whole-repo static-analysis gate runs the project's
864
+ // own lint/typecheck and fails on a real non-zero exit, independent of
865
+ // the model-authored VERIFY block (which may not lint at all). ASYNC:
866
+ // the sync runner froze the event loop for the whole lint (see above).
867
+ repoHealth: () => deadAirBaseline ?
868
+ Promise.resolve(runRepoHealthCheck(cwd2))
869
+ : runRepoHealthCheckAsync(cwd2, {
870
+ signal,
871
+ onCommand: c => {
872
+ stageLine = `repo health · ${c}`;
873
+ }
874
+ }),
875
+ // Deterministic self-verification probe: test files the task itself
876
+ // authored/changed become prompt-level findings mandating the child
877
+ // to drive the real artifact before trusting their green result.
878
+ probe: () => collectChangedFiles(cwd2, signal).then(findSubstitutionSuspects),
879
+ // Deterministic test-assembly probe (F4): authored test files that
880
+ // rebuild production wiring importing the leaf modules the shipped
881
+ // entry composes and assembling their own copy become rule-3f
882
+ // findings so the child drives the REAL assembly, not the copy.
883
+ testAssemblyProbe: () => collectChangedFiles(cwd2, signal).then(changed => collectTestAssemblyFindings(cwd2, changed, signal)),
884
+ // Deterministic probe-gaming probe (F6): added lines whose stated
885
+ // purpose is to make a check pass rather than meet the requirement
886
+ // ("return 401 so the verification test passes") become rule-4c
887
+ // findings so the child verifies the real requirement, not the check.
888
+ probeGamingProbe: () => collectAddedLines(cwd2, signal).then(findProbeGaming),
889
+ // Deterministic cross-task deletion probe (mx5 run 12 PROMPT 2):
890
+ // tracked files this task's diff DELETES whose introducing commit
891
+ // belongs to a DIFFERENT task a sibling's committed deliverable
892
+ // destroyed (typically to green a check). Injected under rule 4d and
893
+ // carried on a FAIL so an ACCEPT records durable debts.
894
+ crossTaskDeletionProbe: () => collectTaskTreeChanges(cwd2, signal).then(changes => findCrossTaskDeletions(changes, taskId, rel => taskThatIntroduced(cwd2, rel))),
895
+ // Deterministic sandbox-path-leak probe (mx5 run 13 PROMPT 4 item
896
+ // 1): absolute paths committed from the authoring child's own
897
+ // environment (`/workspace/src/shared`) that resolve nowhere here.
898
+ // Repaired deterministically where the relative form provably
899
+ // resolves; the remainder is injected under rule 4e, whose point is
900
+ // that such a path breaks the BUILD so the checks that would have
901
+ // caught it report nothing rather than failing.
902
+ foreignPathProbe: () => collectForeignPathFindings(cwd2, signal, makeDebugAppender(path.join(tasksDir(cwd2), 'verify-debug.log'))),
903
+ // Deterministic neutered-check-script probe (mx5 run 13 PROMPT 4
904
+ // item 4): a check script this task authored that cannot fail
905
+ // (`… || true`, an inverted-grep launder). Injected under rule 4f,
906
+ // because the child provably cannot find this by running the
907
+ // script — it passes, which IS the defect.
908
+ scriptEscapeProbe: () => collectScriptEscapeFindings(cwd2, signal),
909
+ // Deterministic runner glob-collision probe (mx5 runs 7 AND 13,
910
+ // PROMPT 4 item 2): both `bun test` and `playwright test` declared
911
+ // with no proof their file sets are disjoint. Injected under rule
912
+ // 4g — the collision kills the suite during COLLECTION, which does
913
+ // not look like a test failure.
914
+ runnerGlobProbe: () => collectRunnerGlobFindings(cwd2),
915
+ // Deterministic prohibition probe: paths the spec forbids modifying
916
+ // that the task's diff modified anyway become prompt-level findings
917
+ // under the no-waiver rule — the child otherwise rarely runs `git
918
+ // diff` and cannot even see the violation.
919
+ prohibitionProbe: () => {
920
+ const banned = spec ? extractProhibitions(spec) : [];
921
+ if (banned.length === 0)
922
+ return Promise.resolve([]);
923
+ return collectChangedFiles(cwd2, signal).then(files => findProhibitionViolations(banned, files));
924
+ },
925
+ // Git-state guard result of the most recent child run: a verdict
926
+ // computed on a tree the child itself mutated is discarded — but ONLY
927
+ // when the mutation touched graded state (verdictTainted). A child
928
+ // that merely left test-runner output behind (test-results/,
929
+ // playwright-report/ …) judged an equivalent tree; its verdict stands
930
+ // and the artifacts were still cleaned (mx5 run 9 lost 7 verdicts this
931
+ // way — see git-state-guard.ts).
932
+ mutationCheck: () => lastGuardReconcile?.verdictTainted ?
933
+ { mutated: true, detail: lastGuardReconcile.actions.join('; ') }
934
+ : { mutated: false, detail: '' },
935
+ // Per-run environment-facts cache under .pi-tasks/ (survives
936
+ // discardEdits): earlier children's discoveries save this child
937
+ // the re-archaeology; its own ENV-NOTE lines are stored for the
938
+ // next one, stamped with this task's id as their origin so a
939
+ // later child sees a cited fact is second-hand and must
940
+ // re-validate before excusing a failure (F7). Facts only —
941
+ // verdict rules unaffected.
942
+ envNotes: {
943
+ read: () => readEnvNotes(cwd2),
944
+ append: notes => appendEnvNotes(cwd2, notes, taskId)
945
+ },
946
+ // Per-run cross-slice contract registry under .pi-tasks/ (F3): the
947
+ // verbatim interface facts the design pins that multiple slices
948
+ // share, so the verify child checks this slice's boundary against
949
+ // them. Empty on single-`/task` runs or a design with no shared
950
+ // boundary → no block.
951
+ contracts: () => readContracts(cwd2)
952
+ });
953
+ }
954
+ finally {
955
+ stopGateLoader();
956
+ }
894
957
  },
895
958
  lintFix: async (fixCtx, cwd2, taskTitle, taskId, failReason) => {
896
959
  // Same frozen extraction the enforce guard and the verify rule-4b
@@ -911,7 +974,7 @@ export function buildGateDeps(params) {
911
974
  signal,
912
975
  failReason,
913
976
  runChild: makeGateChild(fixCtx, cwd2, taskTitle, 'lint-fix', 'verify-debug.log'),
914
- repoHealth: () => Promise.resolve(runRepoHealthCheck(cwd2)),
977
+ repoHealth: () => runRepoHealthCheckAsync(cwd2, { signal }),
915
978
  git: async (args) => {
916
979
  const r = await git(cwd2, args, signal);
917
980
  return { exitCode: r.exitCode, stdout: r.stdout };
@@ -927,7 +990,29 @@ export function buildGateDeps(params) {
927
990
  });
928
991
  },
929
992
  // Deterministic static check + tree helpers for the enforce pre-commit gate.
930
- repoHealth: cwd2 => Promise.resolve(runRepoHealthCheck(cwd2)),
993
+ // Runs TWICE per task there (a baseline before the edit pass, a differential
994
+ // check after it), each one as long as the project's own lint — so it gets
995
+ // the same treatment as the verify-side run: async, and under a live loader
996
+ // naming the command, instead of a frozen screen.
997
+ repoHealth: (healthCtx, cwd2, label) => {
998
+ const startedAt = Date.now();
999
+ let running;
1000
+ const stop = startAutoLoader(healthCtx, () => ({
1001
+ title: label,
1002
+ kind: 'enforce',
1003
+ step: 'repo health',
1004
+ stepNum: 1,
1005
+ stepTotal: 1,
1006
+ startedAt,
1007
+ lastLine: running ? `repo health · ${running}` : 'repo health'
1008
+ }));
1009
+ return runRepoHealthCheckAsync(cwd2, {
1010
+ signal,
1011
+ onCommand: c => {
1012
+ running = c;
1013
+ }
1014
+ }).finally(stop);
1015
+ },
931
1016
  dirty: async (cwd2) => {
932
1017
  const r = await git(cwd2, ['status', '--porcelain', '--', '.', EXCLUDE_TASKS_DIR], signal);
933
1018
  return r.exitCode === 0 && r.stdout.trim().length > 0;
@@ -40,5 +40,27 @@ export declare function discoverHealthCommands(cwd: string): {
40
40
  *
41
41
  * A generous per-command timeout guards against a wedged tool; a timeout is treated
42
42
  * as an inconclusive skip, not a fault (it is an environment problem, not the code's).
43
+ *
44
+ * SYNCHRONOUS — it blocks the event loop for as long as the project's own lint takes
45
+ * (MEASURED: 15s on mx5, 69s on aiz-client), so nothing can render or animate while
46
+ * it runs. Gate callers must use {@link runRepoHealthCheckAsync} instead; this stays
47
+ * for callers that genuinely have no async seam.
43
48
  */
44
49
  export declare function runRepoHealthCheck(cwd: string, timeoutMs?: number): HealthOutcome;
50
+ /** Progress hook: called with each command's label as it STARTS, so a caller can
51
+ * keep a live status line naming what is currently running. */
52
+ export type HealthProgress = (command: string) => void;
53
+ /**
54
+ * Same check, same verdicts, without blocking the event loop.
55
+ *
56
+ * The gate runs this immediately after the implementation turn ends, when the impl
57
+ * widget has just been cleared — the sync version froze the whole TUI there for the
58
+ * duration of the project's lint (MEASURED: 0 of 686 expected 100ms timer ticks
59
+ * fired during a 69s aiz-client run), so no spinner, clock or queued notify could
60
+ * paint. `onCommand` lets the caller name the running command in a live status line.
61
+ */
62
+ export declare function runRepoHealthCheckAsync(cwd: string, opts?: {
63
+ timeoutMs?: number;
64
+ signal?: AbortSignal;
65
+ onCommand?: HealthProgress;
66
+ }): Promise<HealthOutcome>;
@@ -29,7 +29,7 @@
29
29
  * environment gap, not a code fault, so that command is SKIPPED — only a command that
30
30
  * actually ran and returned non-zero fails the check.
31
31
  */
32
- import { spawnSync } from 'node:child_process';
32
+ import { spawn, spawnSync } from 'node:child_process';
33
33
  import { existsSync, readFileSync } from 'node:fs';
34
34
  import * as path from 'node:path';
35
35
  import { resolveRunner, runnerEnv, isCommandNotFound } from './runner-resolve.js';
@@ -106,6 +106,38 @@ export function discoverHealthCommands(cwd) {
106
106
  }
107
107
  return { ecosystem: null, cmds: [] };
108
108
  }
109
+ /**
110
+ * Verdict for ONE finished command: 'skip' (environment gap — cannot conclude),
111
+ * 'pass', or the FAIL outcome. Shared by the sync and async runners so their
112
+ * semantics cannot drift apart — the async runner exists only to stop blocking the
113
+ * event loop, and a behaviour difference between the two would be a silent gate
114
+ * change rather than a UI fix.
115
+ */
116
+ function classifyHealthRun(bin, args, ecosystem, r) {
117
+ // Tool missing (ENOENT) or killed by timeout → cannot conclude; skip it.
118
+ if (r.failedToStart || r.status === null)
119
+ return 'skip';
120
+ // "Command not found" INSIDE the script chain (e.g. `bun run lint` before
121
+ // node_modules exists — seen live failing TASK_0001's first verify). Same
122
+ // environment gap as ENOENT, just surfaced through the runner's shell —
123
+ // as exit 127 where a posix shell ran it, else by the runner's own wording
124
+ // (Windows bun reports the miss itself and exits 1).
125
+ if (isCommandNotFound(r.status, `${r.stdout ?? ''}\n${r.stderr ?? ''}`))
126
+ return 'skip';
127
+ if (r.status !== 0) {
128
+ return {
129
+ ok: false,
130
+ reason: `\`${bin} ${args.join(' ')}\` exited ${r.status}`,
131
+ ecosystem,
132
+ output: captureHealthOutput(r.stdout, r.stderr)
133
+ };
134
+ }
135
+ return 'pass';
136
+ }
137
+ /** The nothing-to-run outcome, shared by both runners. */
138
+ function noCommandOutcome(ecosystem) {
139
+ return { ok: true, reason: 'no repo-wide static-analysis command found', ecosystem, output: '' };
140
+ }
109
141
  /**
110
142
  * Run the discovered static checks whole-repo and let the real exit codes decide.
111
143
  * Deterministic and synchronous under the hood (a wrapper keeps the caller async).
@@ -117,17 +149,16 @@ export function discoverHealthCommands(cwd) {
117
149
  *
118
150
  * A generous per-command timeout guards against a wedged tool; a timeout is treated
119
151
  * as an inconclusive skip, not a fault (it is an environment problem, not the code's).
152
+ *
153
+ * SYNCHRONOUS — it blocks the event loop for as long as the project's own lint takes
154
+ * (MEASURED: 15s on mx5, 69s on aiz-client), so nothing can render or animate while
155
+ * it runs. Gate callers must use {@link runRepoHealthCheckAsync} instead; this stays
156
+ * for callers that genuinely have no async seam.
120
157
  */
121
158
  export function runRepoHealthCheck(cwd, timeoutMs = 600_000) {
122
159
  const { ecosystem, cmds } = discoverHealthCommands(cwd);
123
- if (!ecosystem || cmds.length === 0) {
124
- return {
125
- ok: true,
126
- reason: 'no repo-wide static-analysis command found',
127
- ecosystem,
128
- output: ''
129
- };
130
- }
160
+ if (!ecosystem || cmds.length === 0)
161
+ return noCommandOutcome(ecosystem);
131
162
  for (const [bin, args] of cmds) {
132
163
  // Runner resolution (mx5 run 16): a PATH-stripped environment must not
133
164
  // silently skip the statics when the runner sits at a known install
@@ -139,24 +170,77 @@ export function runRepoHealthCheck(cwd, timeoutMs = 600_000) {
139
170
  timeout: timeoutMs,
140
171
  env: runnerEnv(runner)
141
172
  });
142
- // Tool missing (ENOENT) or killed by timeout → cannot conclude; skip it.
143
- if (r.error || r.status === null)
173
+ const verdict = classifyHealthRun(bin, args, ecosystem, {
174
+ failedToStart: r.error !== undefined,
175
+ status: r.status,
176
+ stdout: r.stdout ?? '',
177
+ stderr: r.stderr ?? ''
178
+ });
179
+ if (verdict === 'skip' || verdict === 'pass')
144
180
  continue;
145
- // "Command not found" INSIDE the script chain (e.g. `bun run lint` before
146
- // node_modules exists — seen live failing TASK_0001's first verify). Same
147
- // environment gap as ENOENT, just surfaced through the runner's shell —
148
- // as exit 127 where a posix shell ran it, else by the runner's own wording
149
- // (Windows bun reports the miss itself and exits 1).
150
- if (isCommandNotFound(r.status, `${r.stdout ?? ''}\n${r.stderr ?? ''}`))
181
+ return verdict;
182
+ }
183
+ return { ok: true, reason: `${ecosystem}: static checks passed`, ecosystem, output: '' };
184
+ }
185
+ /** Spawn one health command without blocking the event loop. Mirrors spawnSync's
186
+ * result shape (status null when killed, failedToStart on ENOENT). */
187
+ function spawnHealthCommand(bin, args, cwd, timeoutMs, signal) {
188
+ return new Promise(resolve => {
189
+ const runner = resolveRunner(bin);
190
+ let stdout = '';
191
+ let stderr = '';
192
+ let settled = false;
193
+ const child = spawn(runner.bin, args, { cwd, env: runnerEnv(runner) });
194
+ const done = (r) => {
195
+ if (settled)
196
+ return;
197
+ settled = true;
198
+ clearTimeout(timer);
199
+ signal?.removeEventListener('abort', onAbort);
200
+ resolve(r);
201
+ };
202
+ const kill = () => {
203
+ try {
204
+ child.kill('SIGKILL');
205
+ }
206
+ catch {
207
+ /* already gone */
208
+ }
209
+ };
210
+ const timer = setTimeout(kill, timeoutMs);
211
+ timer.unref?.();
212
+ const onAbort = () => kill();
213
+ signal?.addEventListener('abort', onAbort, { once: true });
214
+ child.stdout?.on('data', (d) => {
215
+ stdout += d.toString();
216
+ });
217
+ child.stderr?.on('data', (d) => {
218
+ stderr += d.toString();
219
+ });
220
+ child.on('error', () => done({ failedToStart: true, status: null, stdout, stderr }));
221
+ child.on('close', (code) => done({ failedToStart: false, status: code, stdout, stderr }));
222
+ });
223
+ }
224
+ /**
225
+ * Same check, same verdicts, without blocking the event loop.
226
+ *
227
+ * The gate runs this immediately after the implementation turn ends, when the impl
228
+ * widget has just been cleared — the sync version froze the whole TUI there for the
229
+ * duration of the project's lint (MEASURED: 0 of 686 expected 100ms timer ticks
230
+ * fired during a 69s aiz-client run), so no spinner, clock or queued notify could
231
+ * paint. `onCommand` lets the caller name the running command in a live status line.
232
+ */
233
+ export async function runRepoHealthCheckAsync(cwd, opts = {}) {
234
+ const { ecosystem, cmds } = discoverHealthCommands(cwd);
235
+ if (!ecosystem || cmds.length === 0)
236
+ return noCommandOutcome(ecosystem);
237
+ for (const [bin, args] of cmds) {
238
+ opts.onCommand?.(`${bin} ${args.join(' ')}`);
239
+ const r = await spawnHealthCommand(bin, args, cwd, opts.timeoutMs ?? 600_000, opts.signal);
240
+ const verdict = classifyHealthRun(bin, args, ecosystem, r);
241
+ if (verdict === 'skip' || verdict === 'pass')
151
242
  continue;
152
- if (r.status !== 0) {
153
- return {
154
- ok: false,
155
- reason: `\`${bin} ${args.join(' ')}\` exited ${r.status}`,
156
- ecosystem,
157
- output: captureHealthOutput(r.stdout, r.stderr)
158
- };
159
- }
243
+ return verdict;
160
244
  }
161
245
  return { ok: true, reason: `${ecosystem}: static checks passed`, ecosystem, output: '' };
162
246
  }
@@ -99,7 +99,11 @@ export interface GateDeps {
99
99
  * revert cycle. Checking before committing skips that cycle. Absent → the old
100
100
  * commit-then-differential path runs unchanged.
101
101
  */
102
- repoHealth?: (cwd: string) => Promise<{
102
+ /** Deterministic whole-repo static check for the enforce pre-commit gate. Takes
103
+ * the live ctx and a label so the implementation can render a status line while
104
+ * it runs — it is as slow as the project's own lint (15–69s measured), and a
105
+ * gate step that long with no widget is indistinguishable from a hang. */
106
+ repoHealth?: (ctx: ExtensionCommandContext, cwd: string, label: string) => Promise<{
103
107
  ok: boolean;
104
108
  reason: string;
105
109
  output?: string;
@@ -419,7 +419,9 @@ export async function runGatesForTask(ctxIn, deps, p) {
419
419
  // good work for a fault it did not cause). Only meaningful in edit mode (flag
420
420
  // makes no edits); the task's work is already committed so this reflects the
421
421
  // committed state the pass is about to build on.
422
- const healthBefore = mode === 'edit' && deps.repoHealth ? await deps.repoHealth(p.cwd) : undefined;
422
+ const healthBefore = mode === 'edit' && deps.repoHealth ?
423
+ await deps.repoHealth(active, p.cwd, p.title)
424
+ : undefined;
423
425
  const verdict = await deps.enforce(active, p.cwd, p.title, mode);
424
426
  // FROZEN-PATH WRITE-DENY (mechanical, not prompt — the "MUST NOT edit"
425
427
  // instruction is A/B-proven ~0–1/5 reliable on the weak model): the enforce
@@ -466,7 +468,7 @@ export async function runGatesForTask(ctxIn, deps, p) {
466
468
  // unreproducible precisely because only the exit code was recorded.
467
469
  let enforceEditsBlocked = false;
468
470
  if (mode === 'edit' && deps.repoHealth && editsMade !== false) {
469
- const after = await deps.repoHealth(p.cwd);
471
+ const after = await deps.repoHealth(active, p.cwd, p.title);
470
472
  // A regression needs a clean (or unknown) baseline turning to a fail. If
471
473
  // healthBefore is undefined (repoHealth was absent at baseline time) treat
472
474
  // the baseline as clean — the conservative absolute behavior.
@@ -122,6 +122,14 @@ export interface VerificationDeps {
122
122
  ok: boolean;
123
123
  reason: string;
124
124
  }>;
125
+ /**
126
+ * Progress hook for the DETERMINISTIC stage — the repo-health run plus the ten
127
+ * probes below, all of which run BEFORE the child (and therefore before the
128
+ * child's own status widget exists). Called with a short label as each step
129
+ * starts, so the caller can keep a live line on screen through what was
130
+ * otherwise the run's longest stretch of dead air (MEASURED at 15–69s per
131
+ * repo-health run). ABSENT → no progress reporting, same behaviour as before. */
132
+ onStage?: (stage: string) => void;
125
133
  /**
126
134
  * DETERMINISTIC substitution probe (see substitution-probe.ts): scans the task's
127
135
  * changed test files for test-the-copy shapes and returns finding lines to inject
@@ -597,7 +597,16 @@ export async function runWorkVerification(deps) {
597
597
  // 5/5 false-PASS live) — because it does not depend on that block. A fail is the
598
598
  // ordinary verify-FAIL outcome, so it flows into the existing resolution picker.
599
599
  // Absent dep, or a no-op result (no tooling to run), falls through to the model.
600
+ const stage = (label) => {
601
+ try {
602
+ deps.onStage?.(label);
603
+ }
604
+ catch {
605
+ // progress reporting must never break the gate
606
+ }
607
+ };
600
608
  if (deps.repoHealth) {
609
+ stage('repo health');
601
610
  const h = await deps.repoHealth();
602
611
  if (!h.ok)
603
612
  return { ok: false, reason: `repo health: ${h.reason}` };
@@ -609,6 +618,7 @@ export async function runWorkVerification(deps) {
609
618
  // verification (it is an optional sharpener, the gate still runs without it).
610
619
  let findings = [];
611
620
  if (deps.probe) {
621
+ stage('substitution probe');
612
622
  try {
613
623
  findings = await deps.probe();
614
624
  }
@@ -618,6 +628,7 @@ export async function runWorkVerification(deps) {
618
628
  }
619
629
  let prohibitions = [];
620
630
  if (deps.prohibitionProbe) {
631
+ stage('prohibition probe');
621
632
  try {
622
633
  prohibitions = await deps.prohibitionProbe();
623
634
  }
@@ -629,6 +640,7 @@ export async function runWorkVerification(deps) {
629
640
  // block verification — it is an optional sharpener like the substitution probe.
630
641
  let testAssembly = [];
631
642
  if (deps.testAssemblyProbe) {
643
+ stage('test-assembly probe');
632
644
  try {
633
645
  testAssembly = await deps.testAssemblyProbe();
634
646
  }
@@ -640,6 +652,7 @@ export async function runWorkVerification(deps) {
640
652
  // block verification — an optional sharpener like the other diff-shape probes.
641
653
  let probeGaming = [];
642
654
  if (deps.probeGamingProbe) {
655
+ stage('probe-gaming probe');
643
656
  try {
644
657
  probeGaming = await deps.probeGamingProbe();
645
658
  }
@@ -652,6 +665,7 @@ export async function runWorkVerification(deps) {
652
665
  // failure must never block verification.
653
666
  let crossDeletions = [];
654
667
  if (deps.crossTaskDeletionProbe) {
668
+ stage('cross-task deletion probe');
655
669
  try {
656
670
  crossDeletions = await deps.crossTaskDeletionProbe();
657
671
  }
@@ -663,6 +677,7 @@ export async function runWorkVerification(deps) {
663
677
  // under rule 4e. A probe failure must never block verification.
664
678
  let foreignPaths = [];
665
679
  if (deps.foreignPathProbe) {
680
+ stage('foreign-path probe');
666
681
  try {
667
682
  foreignPaths = await deps.foreignPathProbe();
668
683
  }
@@ -674,6 +689,7 @@ export async function runWorkVerification(deps) {
674
689
  // 4f. A probe failure must never block verification.
675
690
  let scriptEscapes = [];
676
691
  if (deps.scriptEscapeProbe) {
692
+ stage('script-escape probe');
677
693
  try {
678
694
  scriptEscapes = await deps.scriptEscapeProbe();
679
695
  }
@@ -685,6 +701,7 @@ export async function runWorkVerification(deps) {
685
701
  // never block verification.
686
702
  let runnerGlobs = [];
687
703
  if (deps.runnerGlobProbe) {
704
+ stage('runner-glob probe');
688
705
  try {
689
706
  runnerGlobs = await deps.runnerGlobProbe();
690
707
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.36.0",
3
+ "version": "0.37.1",
4
4
  "description": "Deterministic task planning and spec-orchestration for local models — crash-safe /task pipelines with verify/enforce gates, a real-time remote web view, and web/docs/fetch/worker subagent tools.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",