@mjasnikovs/pi-task 0.38.2 → 0.38.4

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 (49) hide show
  1. package/dist/config/config.d.ts +7 -0
  2. package/dist/config/config.js +10 -4
  3. package/dist/config/register.d.ts +37 -0
  4. package/dist/config/register.js +89 -114
  5. package/dist/remote/events.js +0 -3
  6. package/dist/remote/register.js +12 -3
  7. package/dist/task/auto-orchestrator.js +119 -94
  8. package/dist/task/command-run.d.ts +104 -0
  9. package/dist/task/command-run.js +138 -0
  10. package/dist/task/coverage-loop.d.ts +45 -0
  11. package/dist/task/critique-probes.d.ts +82 -0
  12. package/dist/task/critique-probes.js +156 -0
  13. package/dist/task/deep-render-check.d.ts +30 -0
  14. package/dist/task/deep-render-check.js +19 -11
  15. package/dist/task/enforce-guidelines.d.ts +14 -17
  16. package/dist/task/enforce-guidelines.js +44 -31
  17. package/dist/task/final-gate.d.ts +8 -10
  18. package/dist/task/final-gate.js +36 -74
  19. package/dist/task/gate-child.d.ts +104 -0
  20. package/dist/task/gate-child.js +177 -0
  21. package/dist/task/gate-deps.d.ts +13 -0
  22. package/dist/task/gate-deps.js +72 -208
  23. package/dist/task/orchestrator.js +13 -22
  24. package/dist/task/phases.js +109 -182
  25. package/dist/task/plan-session.d.ts +4 -22
  26. package/dist/task/plan-session.js +4 -33
  27. package/dist/task/question-dialog.d.ts +71 -0
  28. package/dist/task/question-dialog.js +89 -0
  29. package/dist/task/terminal-outcome.d.ts +67 -0
  30. package/dist/task/terminal-outcome.js +76 -0
  31. package/dist/task/type-only-answer.js +2 -3
  32. package/dist/workers/abstention.d.ts +71 -0
  33. package/dist/workers/abstention.js +108 -0
  34. package/dist/workers/docs-chunk.d.ts +74 -0
  35. package/dist/workers/docs-chunk.js +143 -0
  36. package/dist/workers/docs-core.d.ts +10 -1
  37. package/dist/workers/docs-core.js +22 -19
  38. package/dist/workers/docs-index.js +2 -69
  39. package/dist/workers/docs-project.d.ts +15 -1
  40. package/dist/workers/docs-project.js +27 -66
  41. package/dist/workers/fetch-core.d.ts +1 -1
  42. package/dist/workers/fetch-core.js +2 -1
  43. package/dist/workers/pi-worker-core.js +157 -86
  44. package/dist/workers/pi-worker-docs.js +5 -10
  45. package/dist/workers/pi-worker-fetch.js +8 -1
  46. package/dist/workers/typeonly-log.js +2 -10
  47. package/dist/workers/worker-failure.d.ts +91 -0
  48. package/dist/workers/worker-failure.js +82 -0
  49. package/package.json +1 -1
@@ -0,0 +1,82 @@
1
+ /**
2
+ * The critique phase's deterministic Probes, as a table.
3
+ *
4
+ * Each row is a defect a model does not self-discover reliably, found by a pure
5
+ * scanner over the composed spec, and forced into the rewrite. Together they are
6
+ * the deterministic half of critique: the triage child judges taste, these decide
7
+ * facts.
8
+ *
9
+ * Why a table. Every probe used to be written out three times inside
10
+ * `phaseCritique` — once as a four-line detect/format/log ritual, once as a term
11
+ * in the seven-way conjunction that lets a CLEAN triage short-circuit, and once
12
+ * as an element of the array merged into the rewrite. Adding a probe meant three
13
+ * coordinated edits, and forgetting the SECOND one is silent and severe: a CLEAN
14
+ * triage would then ship a spec carrying a defect the scanner had already found —
15
+ * which is precisely the failure each probe exists to prevent. No test would
16
+ * notice, because the wiring, unlike the scanners, was almost entirely untested
17
+ * (1 of 6).
18
+ *
19
+ * With a table the override and the merge are DERIVED from the rows, so "every
20
+ * probe blocks a CLEAN short-circuit" is structurally true rather than something
21
+ * a reviewer must check. This is the same move `PROBE_ADAPTERS` (verify-work.ts)
22
+ * and `CLOSURE_SCANS` (final-gate.ts) already made; critique is the one place
23
+ * that never got it.
24
+ */
25
+ /** Everything the probes read. Assembled once by the critique phase. */
26
+ export interface CritiqueProbeContext {
27
+ /** The composed draft under critique. */
28
+ spec: string;
29
+ /** The refined feature description the draft was composed from. */
30
+ refined: string;
31
+ /** The task's RESEARCH section, when it has one. */
32
+ research?: string;
33
+ cwd: string;
34
+ /** The cross-slice contract registry, verbatim. Empty for a single /task. */
35
+ registryRaw: string;
36
+ planContext?: string;
37
+ }
38
+ interface CritiqueProbe<F> {
39
+ id: string;
40
+ /** Findings, or empty when the probe does not fire. Pure and total. */
41
+ detect: (ctx: CritiqueProbeContext) => F[];
42
+ /** The defect block fed to the rewrite as a forced FOCUS item. */
43
+ text: (findings: F[], ctx: CritiqueProbeContext) => string;
44
+ /** One debug line naming what was flagged. */
45
+ log: (findings: F[]) => string;
46
+ /**
47
+ * A rewrite that was HANDED this defect and shipped it anyway is a failed
48
+ * rewrite, not a finished one. Rows that can check their own closure name the
49
+ * retry problem here; the emphasis retry then gets a targeted hint.
50
+ */
51
+ unresolvedProblem?: {
52
+ name: string;
53
+ stillPresent: (rewritten: string) => boolean;
54
+ };
55
+ }
56
+ /**
57
+ * The probes, in the order their defects reach the rewrite. Order is presentation
58
+ * only — every row that fires blocks the CLEAN short-circuit equally.
59
+ */
60
+ export declare const CRITIQUE_PROBES: ReadonlyArray<CritiqueProbe<unknown>>;
61
+ /** What the deterministic half of critique found. */
62
+ export interface CritiqueDefects {
63
+ /** One defect block per probe that fired, in table order. */
64
+ blocks: string[];
65
+ /**
66
+ * True when any probe fired. A CLEAN triage verdict may NOT short-circuit the
67
+ * rewrite while this holds — the whole reason the override exists.
68
+ */
69
+ forced: boolean;
70
+ /**
71
+ * The retry problem for a rewrite that shipped a flagged defect anyway, or
72
+ * null. Only probes that were actually FIRED are re-checked: a defect the
73
+ * draft never had is not the rewrite's to resolve.
74
+ */
75
+ unresolvedIn: (rewritten: string) => string | null;
76
+ }
77
+ /**
78
+ * Run every probe over one draft. The loop owns the ritual — skip-when-empty,
79
+ * log, order — so a row cannot be added that silently skips any of it.
80
+ */
81
+ export declare function collectCritiqueDefects(ctx: CritiqueProbeContext, logDebug?: (line: string) => void): CritiqueDefects;
82
+ export {};
@@ -0,0 +1,156 @@
1
+ /**
2
+ * The critique phase's deterministic Probes, as a table.
3
+ *
4
+ * Each row is a defect a model does not self-discover reliably, found by a pure
5
+ * scanner over the composed spec, and forced into the rewrite. Together they are
6
+ * the deterministic half of critique: the triage child judges taste, these decide
7
+ * facts.
8
+ *
9
+ * Why a table. Every probe used to be written out three times inside
10
+ * `phaseCritique` — once as a four-line detect/format/log ritual, once as a term
11
+ * in the seven-way conjunction that lets a CLEAN triage short-circuit, and once
12
+ * as an element of the array merged into the rewrite. Adding a probe meant three
13
+ * coordinated edits, and forgetting the SECOND one is silent and severe: a CLEAN
14
+ * triage would then ship a spec carrying a defect the scanner had already found —
15
+ * which is precisely the failure each probe exists to prevent. No test would
16
+ * notice, because the wiring, unlike the scanners, was almost entirely untested
17
+ * (1 of 6).
18
+ *
19
+ * With a table the override and the merge are DERIVED from the rows, so "every
20
+ * probe blocks a CLEAN short-circuit" is structurally true rather than something
21
+ * a reviewer must check. This is the same move `PROBE_ADAPTERS` (verify-work.ts)
22
+ * and `CLOSURE_SCANS` (final-gate.ts) already made; critique is the one place
23
+ * that never got it.
24
+ */
25
+ import { existsSync } from 'node:fs';
26
+ import { resolve } from 'node:path';
27
+ import { findSkipEscapes, skipEscapeDefectText } from './skip-escape.js';
28
+ import { findSynthesizedWiring, wiringProbeText, readReferencedDocs } from './wiring-claims.js';
29
+ import { findAbsenceConflicts, absenceProbeText, siblingTitlesFromPlanContext } from './verify-reconcile.js';
30
+ import { findFrozenPathConflicts, frozenConflictProbeText } from './frozen-conflict.js';
31
+ import { findGrepOnlyVerify, grepOnlyVerifyDefectText } from './verify-quality.js';
32
+ import { findScriptEscapesInText, scriptEscapeDefectText } from './script-escape.js';
33
+ /** Narrow helper so each row keeps its own finding type without a cast. */
34
+ function probe(row) {
35
+ return row;
36
+ }
37
+ /**
38
+ * The probes, in the order their defects reach the rewrite. Order is presentation
39
+ * only — every row that fires blocks the CLEAN short-circuit equally.
40
+ */
41
+ export const CRITIQUE_PROBES = [
42
+ probe({
43
+ // run-8 F2: a required VERIFY check wrapped in a skip-announcing `||`
44
+ // fallback (`… || echo "skipping (tool absent)"`) lets the check pass
45
+ // while never running. FP-measured 0/20 on the historical specs.
46
+ id: 'skip-escape',
47
+ detect: ctx => findSkipEscapes(ctx.spec),
48
+ text: f => skipEscapeDefectText(f),
49
+ log: f => `skip-escape flagged in VERIFY: ${f.length} line(s)`
50
+ }),
51
+ probe({
52
+ // run-8 F3, generation side. The registry alone is a WEAK catcher (live
53
+ // A/B: prompt+registry ~1/8) — the model's attention goes to the obvious
54
+ // VERIFY weakness and it rarely does the path-composition reasoning. The
55
+ // scanner NAMES the inferred mount mappings and juxtaposes the verbatim
56
+ // pinned facts, forcing focused reconciliation. FP-clean (1/18 files on
57
+ // the run-8 trees). Grounding = the registry ∪ any design doc the
58
+ // spec/refined @-reference. No registry ⇒ nothing to contradict.
59
+ id: 'synthesized-wiring',
60
+ detect: ctx => ctx.registryRaw.trim().length > 0 ?
61
+ findSynthesizedWiring(ctx.spec, ctx.registryRaw + '\n' + readReferencedDocs(ctx.cwd, ctx.refined, ctx.spec), ctx.registryRaw)
62
+ : [],
63
+ text: (f, ctx) => wiringProbeText(f, ctx.registryRaw),
64
+ log: f => `synthesized wiring flagged in spec: ${f.map(w => w.line).join(' | ')}`
65
+ }),
66
+ probe({
67
+ // mx5 run 11, goal D: a VERIFY line asserting the ABSENCE of an artifact
68
+ // the plan pins elsewhere — a path a prior task already shipped, a sibling
69
+ // title's deliverable, a contract-pinned boundary. Run 11: the scope fence
70
+ // leaked into TASK_0009's verify as "the admin page must NOT exist"
71
+ // (TASK_0008's deliverable); the guaranteed FAIL became an accepted debt
72
+ // that the final-gate autofix then "fixed" by deleting the sibling's work.
73
+ // It must die here, at spec time. Delete-tasks keep their check by
74
+ // declaring the delete.
75
+ id: 'plan-contradiction',
76
+ detect: ctx => findAbsenceConflicts(ctx.spec, {
77
+ fileExists: p => existsSync(resolve(ctx.cwd, p)),
78
+ siblingTitles: siblingTitlesFromPlanContext(ctx.planContext),
79
+ contracts: ctx.registryRaw
80
+ }),
81
+ text: f => absenceProbeText(f),
82
+ log: f => 'plan-contradiction flagged in VERIFY: '
83
+ + f.map(c => `${c.assertion.target} (${c.against})`).join(' | ')
84
+ }),
85
+ probe({
86
+ // mx5 run 12 root cause: a blanket frozen path ("Do NOT modify
87
+ // `tsconfig.json` … handled in steps 1–2") whose registration edit the
88
+ // spec's OWN body — or the task's RESEARCH the spec was composed from
89
+ // (live drafts sometimes drop the nuance while shipping the freeze and the
90
+ // creation) — says the deliverable requires. Shipped as-is, the created
91
+ // files turn the repo-wide static check permanently red and no task is
92
+ // allowed to fix it: every later task burns its AUTOFIX rounds on it. The
93
+ // rewrite must grant scoped ownership or drop the creation.
94
+ id: 'frozen-conflict',
95
+ detect: ctx => findFrozenPathConflicts(ctx.spec, ctx.research),
96
+ text: f => frozenConflictProbeText(f),
97
+ log: f => 'unsatisfiable freeze/requires-edit pair flagged in spec: '
98
+ + f.map(c => c.path).join(' | ')
99
+ }),
100
+ probe({
101
+ // mx5 run 13, Bug B: a VERIFY block that grep-asserts the SOURCE of a
102
+ // runnable deliverable while every command in the block is static
103
+ // inspection — the build script "verified" by three greps that was never
104
+ // run, shipping broken for 14 tasks. VERIFY must EXECUTE the artifact and
105
+ // assert an observable outcome of that run.
106
+ id: 'grep-theater',
107
+ detect: ctx => findGrepOnlyVerify(ctx.spec),
108
+ text: f => grepOnlyVerifyDefectText(f),
109
+ log: f => `grep-theater VERIFY flagged in spec: ${f.map(x => x.target).join(' | ')}`,
110
+ // Detector-backed closure: live A/B, 1/5 rewrites ignored the injected
111
+ // defect and re-shipped the grep-only block.
112
+ unresolvedProblem: {
113
+ name: 'verify_grep_theater',
114
+ stillPresent: rewritten => findGrepOnlyVerify(rewritten).length > 0
115
+ }
116
+ }),
117
+ probe({
118
+ // mx5 run 13, PROMPT 4 item 4: a spec that DICTATES a check script which
119
+ // cannot fail — `"lint": "… || true"`, or a checker laundered through an
120
+ // inverted grep. Whatever task implements that spec writes the disarmed
121
+ // script into package.json, and from then on every gate that runs it
122
+ // reads a constant. Cheapest to kill here, before it is authored.
123
+ id: 'script-escape',
124
+ detect: ctx => findScriptEscapesInText(ctx.spec),
125
+ text: f => scriptEscapeDefectText(f),
126
+ log: f => `neutered check script dictated by spec: ${f.map(x => x.name).join(' | ')}`
127
+ })
128
+ ];
129
+ /**
130
+ * Run every probe over one draft. The loop owns the ritual — skip-when-empty,
131
+ * log, order — so a row cannot be added that silently skips any of it.
132
+ */
133
+ export function collectCritiqueDefects(ctx, logDebug) {
134
+ const blocks = [];
135
+ const fired = [];
136
+ for (const row of CRITIQUE_PROBES) {
137
+ const findings = row.detect(ctx);
138
+ if (findings.length === 0)
139
+ continue;
140
+ fired.push(row);
141
+ blocks.push(row.text(findings, ctx));
142
+ logDebug?.(row.log(findings));
143
+ }
144
+ return {
145
+ blocks,
146
+ forced: blocks.length > 0,
147
+ unresolvedIn: rewritten => {
148
+ for (const row of fired) {
149
+ if (row.unresolvedProblem?.stillPresent(rewritten) === true) {
150
+ return row.unresolvedProblem.name;
151
+ }
152
+ }
153
+ return null;
154
+ }
155
+ };
156
+ }
@@ -1,3 +1,4 @@
1
+ import WebSocket from 'ws';
1
2
  export type DeepRenderOutcome = {
2
3
  outcome: 'pass';
3
4
  detail: string;
@@ -132,6 +133,31 @@ export declare function deriveLegacyFacts(log: SessionRequest[]): Pick<DeepSessi
132
133
  export declare function judgeDeepSession(f: DeepSessionFacts): DeepRenderOutcome;
133
134
  /** Whole-session wall-clock cap, including browser launch (I4). */
134
135
  export declare const DEEP_RENDER_TIMEOUT_MS = 45000;
136
+ /** Minimal DevTools-protocol client: request/response ids over one socket, plus
137
+ * event fan-out. Everything the driver needs and nothing more.
138
+ *
139
+ * @internal Exported for its own tests: the id/response matching and the
140
+ * send-failure path are the parts of the driver that need no browser. */
141
+ export declare class Cdp {
142
+ private readonly ws;
143
+ private nextId;
144
+ private readonly pending;
145
+ private readonly handlers;
146
+ constructor(ws: WebSocket);
147
+ on(method: string, cb: (params: Record<string, unknown>) => void): void;
148
+ send(method: string, params?: Record<string, unknown>, sessionId?: string): Promise<Record<string, unknown>>;
149
+ }
150
+ /** Page-side fill: native value setters + input/change events, so a controlled
151
+ * React/Vue/Svelte input actually updates its state (a bare `el.value = …` does
152
+ * not, and the form then submits empty).
153
+ *
154
+ * @internal Exported so the credential ESCAPING is testable: a password with a
155
+ * quote or a backslash in it must not break out of the injected expression. */
156
+ export declare function fillExpr(identifier: string, password: string): string;
157
+ /** Wait until `ms` of silence pass with no new request, or `cap` elapses.
158
+ * @internal Exported for its own tests — the quiet/cap arithmetic decides
159
+ * whether a slow page is judged half-loaded. */
160
+ export declare function settle(lastActivity: () => number, cap: number, quiet?: number): Promise<void>;
135
161
  /**
136
162
  * Drive one authenticated session against `url` and judge it. `cwd` is the project
137
163
  * whose dotenv declares the account. Every failure mode of the DRIVER itself
@@ -146,4 +172,8 @@ export declare function runDeepRenderCheck(url: string, cwd: string, opts?: {
146
172
  /** Recorder hook: receives the facts the verdict was made on. Used by the
147
173
  * corpus builder; the gate itself never passes it. */
148
174
  onFacts?: (f: DeepSessionFacts) => void;
175
+ /** @internal Settle quiet window, for tests only. The three settle windows
176
+ * cost QUIET_MS each against a fake browser that answers instantly, which
177
+ * is the whole runtime of a driver test. The gate never passes it. */
178
+ quietMs?: number;
149
179
  }): Promise<DeepRenderOutcome>;
@@ -348,8 +348,11 @@ const POST_SUBMIT_CAP_MS = 12_000;
348
348
  * small so the three settle windows together stay inside DEEP_RENDER_TIMEOUT_MS. */
349
349
  const RE_NAV_CAP_MS = 6_000;
350
350
  /** Minimal DevTools-protocol client: request/response ids over one socket, plus
351
- * event fan-out. Everything the driver needs and nothing more. */
352
- class Cdp {
351
+ * event fan-out. Everything the driver needs and nothing more.
352
+ *
353
+ * @internal Exported for its own tests: the id/response matching and the
354
+ * send-failure path are the parts of the driver that need no browser. */
355
+ export class Cdp {
353
356
  ws;
354
357
  nextId = 1;
355
358
  pending = new Map();
@@ -418,8 +421,11 @@ const INSPECT_EXPR = `(() => {
418
421
  })()`;
419
422
  /** Page-side fill: native value setters + input/change events, so a controlled
420
423
  * React/Vue/Svelte input actually updates its state (a bare `el.value = …` does
421
- * not, and the form then submits empty). */
422
- function fillExpr(identifier, password) {
424
+ * not, and the form then submits empty).
425
+ *
426
+ * @internal Exported so the credential ESCAPING is testable: a password with a
427
+ * quote or a backslash in it must not break out of the injected expression. */
428
+ export function fillExpr(identifier, password) {
423
429
  return `(() => {
424
430
  const setValue = (el, v) => {
425
431
  const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype
@@ -457,8 +463,10 @@ const SUBMIT_EXPR = `(() => {
457
463
  if (submit) { submit.click(); return {ok: true, how: 'click'} }
458
464
  return {ok: false, reason: 'no submit control'}
459
465
  })()`;
460
- /** Wait until `ms` of silence pass with no new request, or `cap` elapses. */
461
- async function settle(lastActivity, cap, quiet = QUIET_MS) {
466
+ /** Wait until `ms` of silence pass with no new request, or `cap` elapses.
467
+ * @internal Exported for its own tests — the quiet/cap arithmetic decides
468
+ * whether a slow page is judged half-loaded. */
469
+ export async function settle(lastActivity, cap, quiet = QUIET_MS) {
462
470
  const deadline = Date.now() + cap;
463
471
  for (;;) {
464
472
  const idleFor = Date.now() - lastActivity();
@@ -514,7 +522,7 @@ export async function runDeepRenderCheck(url, cwd, opts = {}) {
514
522
  child = c;
515
523
  }, s => {
516
524
  socket = s;
517
- }, opts.onFacts), budget);
525
+ }, opts.onFacts, opts.quietMs), budget);
518
526
  }
519
527
  catch (e) {
520
528
  const why = e instanceof Error ? e.message : String(e);
@@ -540,7 +548,7 @@ function withTimeout(p, ms) {
540
548
  });
541
549
  });
542
550
  }
543
- async function drive(url, bin, userDataDir, credentials, holdChild, holdSocket, onFacts) {
551
+ async function drive(url, bin, userDataDir, credentials, holdChild, holdSocket, onFacts, quietMs) {
544
552
  const origin = new URL(url).origin;
545
553
  /** Every verdict goes through here, so a recorder sees the same facts the judge
546
554
  * does — the corpus is what the gate itself read, not a reconstruction. */
@@ -626,7 +634,7 @@ async function drive(url, bin, userDataDir, credentials, holdChild, holdSocket,
626
634
  return r.result?.value;
627
635
  };
628
636
  await cdp.send('Page.navigate', { url }, sessionId);
629
- await settle(() => lastActivity, SETTLE_CAP_MS);
637
+ await settle(() => lastActivity, SETTLE_CAP_MS, quietMs);
630
638
  const before = await evaluate(INSPECT_EXPR);
631
639
  if (!before)
632
640
  throw new Error('the page could not be inspected');
@@ -698,7 +706,7 @@ async function drive(url, bin, userDataDir, credentials, holdChild, holdSocket,
698
706
  if (!submitted?.ok)
699
707
  return judge(unsubmitted({ submitted: false }));
700
708
  lastActivity = Date.now();
701
- await settle(() => lastActivity, POST_SUBMIT_CAP_MS);
709
+ await settle(() => lastActivity, POST_SUBMIT_CAP_MS, quietMs);
702
710
  // The sign-in request: the first same-origin non-GET issued by the submit. Its
703
711
  // own 2xx is the precondition for judging anything, and it is excluded from the
704
712
  // data evidence (STEP 0: the broken build satisfies "≥1 same-origin 2xx" with
@@ -731,7 +739,7 @@ async function drive(url, bin, userDataDir, credentials, holdChild, holdSocket,
731
739
  if (authAccepted && leftAuthWall) {
732
740
  await cdp.send('Page.navigate', { url }, sessionId);
733
741
  lastActivity = Date.now();
734
- await settle(() => lastActivity, RE_NAV_CAP_MS);
742
+ await settle(() => lastActivity, RE_NAV_CAP_MS, quietMs);
735
743
  }
736
744
  return judge(facts(sessionLog(authId, authAt), {
737
745
  submitted: true,
@@ -1,4 +1,5 @@
1
1
  import type { SpawnFn } from '../shared/child-process.js';
2
+ import { type WorkerFailureInput } from '../workers/worker-failure.js';
2
3
  /** Filenames discovered in the working directory (cwd only — no tree walk). */
3
4
  export declare const GUIDELINE_FILENAMES: readonly ["AGENTS.md", "CLAUDE.md"];
4
5
  /**
@@ -82,29 +83,25 @@ export declare function parseEnforceVerdict(text: string): {
82
83
  detail: string;
83
84
  };
84
85
  /** The subset of a runWorker result the enforcement-child mapping reads. */
85
- export interface EnforceChildResult {
86
+ export interface EnforceChildResult extends WorkerFailureInput {
86
87
  text: string;
87
- exitCode: number;
88
- aborted: boolean;
89
- timedOut?: boolean;
90
- loopHit?: unknown;
91
- leakedToolCall?: unknown;
92
- stalled?: boolean;
93
- commandTimedOut?: {
94
- toolName: string;
95
- timeoutMs: number;
96
- };
97
88
  }
98
89
  /**
99
90
  * Map the enforcement child's runWorker result to a fatal error message, or null
100
91
  * when it finished cleanly enough to parse a verdict from its text.
101
92
  *
102
- * The order is load-bearing. A loop-kill AND a wall-clock timeout BOTH also set
103
- * `aborted` (and a non-zero exit) killProc flips it on every kill path — so the
104
- * specific causes (timeout, loop, leaked tool call) must be handled BEFORE the
105
- * generic `aborted user-cancel` and `exitCode` mappings. Checking `aborted`
106
- * first (as the original inline code did) mislabels a loop-killed enforcement
107
- * child as a user cancel.
93
+ * The ORDER is load-bearing and no longer lives here: every kill path also sets
94
+ * `aborted` and a non-zero exit, so the specific causes must be matched before
95
+ * the generic ones, and that precedence is stated once in `classifyWorkerFailure`
96
+ * (workers/worker-failure.ts). This function is now only the enforce-specific
97
+ * half what each cause MEANS to guideline enforcement.
98
+ *
99
+ * Writing the ladder out by hand here is exactly what cost us the stream-stall
100
+ * bug: `streamStalled` was added to the worker result and to `finalAttemptFailed`
101
+ * but never to this ladder, so a child killed for a dead model stream fell
102
+ * through to `aborted → USER_CANCELLED` and a hung backend was reported to the
103
+ * user as their own cancel. The switch below is exhaustive, so the next cause
104
+ * added to the union is a compile error here rather than a silent mislabel.
108
105
  *
109
106
  * A loop is NOT fatal: enforce attaches the detector in nudge-then-warn mode, so a
110
107
  * loop that survived its restart-with-hint nudges returns null here (the caller
@@ -25,6 +25,7 @@ import * as fsp from 'node:fs/promises';
25
25
  import * as path from 'node:path';
26
26
  import { makeGit } from '../shared/git-runner.js';
27
27
  import { USER_CANCELLED } from './child-runner.js';
28
+ import { classifyWorkerFailure } from '../workers/worker-failure.js';
28
29
  import { TASKS_DIR_NAME } from './task-types.js';
29
30
  import { findProbeGamingInDiff } from './probe-gaming.js';
30
31
  /** Filenames discovered in the working directory (cwd only — no tree walk). */
@@ -204,12 +205,18 @@ export function parseEnforceVerdict(text) {
204
205
  * Map the enforcement child's runWorker result to a fatal error message, or null
205
206
  * when it finished cleanly enough to parse a verdict from its text.
206
207
  *
207
- * The order is load-bearing. A loop-kill AND a wall-clock timeout BOTH also set
208
- * `aborted` (and a non-zero exit) killProc flips it on every kill path — so the
209
- * specific causes (timeout, loop, leaked tool call) must be handled BEFORE the
210
- * generic `aborted user-cancel` and `exitCode` mappings. Checking `aborted`
211
- * first (as the original inline code did) mislabels a loop-killed enforcement
212
- * child as a user cancel.
208
+ * The ORDER is load-bearing and no longer lives here: every kill path also sets
209
+ * `aborted` and a non-zero exit, so the specific causes must be matched before
210
+ * the generic ones, and that precedence is stated once in `classifyWorkerFailure`
211
+ * (workers/worker-failure.ts). This function is now only the enforce-specific
212
+ * half what each cause MEANS to guideline enforcement.
213
+ *
214
+ * Writing the ladder out by hand here is exactly what cost us the stream-stall
215
+ * bug: `streamStalled` was added to the worker result and to `finalAttemptFailed`
216
+ * but never to this ladder, so a child killed for a dead model stream fell
217
+ * through to `aborted → USER_CANCELLED` and a hung backend was reported to the
218
+ * user as their own cancel. The switch below is exhaustive, so the next cause
219
+ * added to the union is a compile error here rather than a silent mislabel.
213
220
  *
214
221
  * A loop is NOT fatal: enforce attaches the detector in nudge-then-warn mode, so a
215
222
  * loop that survived its restart-with-hint nudges returns null here (the caller
@@ -218,32 +225,38 @@ export function parseEnforceVerdict(text) {
218
225
  * effects don't get re-classified as a user cancel or a crash.
219
226
  */
220
227
  export function classifyEnforceChildFailure(r) {
221
- // Stall-kill must be matched BEFORE `aborted`: the kill sets aborted too,
222
- // and mislabeling a dead model backend as a user cancel hides the cause
223
- // (mx5 run 7: 64 minutes of silence).
224
- if (r.stalled) {
225
- return 'model server unreachable — the child produced no output and the model endpoint did not respond';
226
- }
227
- // Same rule, same reason: the command watchdog's kill sets `aborted` too, so
228
- // a child killed for a command that never returned would otherwise report as
229
- // a user cancel. Its text is truncated mid-run — the verdict in it is partial
230
- // and must never be parsed as a real one.
231
- if (r.commandTimedOut) {
232
- const mins = Math.max(1, Math.round(r.commandTimedOut.timeoutMs / 60_000));
233
- return (`child ran a \`${r.commandTimedOut.toolName}\` command that had not returned after `
234
- + `${mins} minute${mins === 1 ? '' : 's'} and was killed — it never bounded the command`);
228
+ const failure = classifyWorkerFailure(r);
229
+ if (!failure)
230
+ return null;
231
+ switch (failure.kind) {
232
+ case 'stalled':
233
+ // mx5 run 7: 64 minutes of silence reported as a user cancel.
234
+ return 'model server unreachable the child produced no output and the model endpoint did not respond';
235
+ case 'command-timeout': {
236
+ // Its text is truncated mid-run — the verdict in it is partial and
237
+ // must never be parsed as a real one.
238
+ const mins = Math.max(1, Math.round(failure.timeoutMs / 60_000));
239
+ return (`child ran a \`${failure.toolName}\` command that had not returned after `
240
+ + `${mins} minute${mins === 1 ? '' : 's'} and was killed it never bounded the command`);
241
+ }
242
+ case 'stream-stall': {
243
+ // The arm this ladder was missing. Same class as the two above: a
244
+ // watchdog, not the model, ended the run, and the text is partial.
245
+ const secs = Math.max(1, Math.round(failure.idleMs / 1000));
246
+ return (`model stream went silent for ${secs}s with no tool running and the child was `
247
+ + 'killed — the backend stopped producing, the child did not stop working');
248
+ }
249
+ case 'worker-timeout':
250
+ return 'enforcement child timed out';
251
+ case 'loop':
252
+ return null; // looped past the nudges → warning, handled by caller
253
+ case 'leaked-tool-call':
254
+ return 'enforcement child leaked a tool call';
255
+ case 'aborted':
256
+ return USER_CANCELLED;
257
+ case 'exit':
258
+ return `enforcement child exited ${failure.code}`;
235
259
  }
236
- if (r.timedOut)
237
- return 'enforcement child timed out';
238
- if (r.loopHit)
239
- return null; // looped past the nudges → warning, handled by caller
240
- if (r.leakedToolCall)
241
- return 'enforcement child leaked a tool call';
242
- if (r.aborted)
243
- return USER_CANCELLED;
244
- if (r.exitCode !== 0)
245
- return `enforcement child exited ${r.exitCode}`;
246
- return null;
247
260
  }
248
261
  /**
249
262
  * The git "empty tree" object — the canonical base for diffing a ROOT commit
@@ -2,6 +2,7 @@ import { type HealthCommand } from './repo-health-check.js';
2
2
  import { type AcceptDebt, type VerifyRerunResult } from './accept-debt.js';
3
3
  import { type RenderOutcome } from './render-check.js';
4
4
  import { type DeepRenderOutcome } from './deep-render-check.js';
5
+ import { type CommandRunner } from './command-run.js';
5
6
  import { taskThatIntroduced } from './task-provenance.js';
6
7
  export interface FinalGateOutcome {
7
8
  /** true → statics and every runnable integration command passed (or nothing to run). */
@@ -336,14 +337,6 @@ export declare function discoverGateCommandLabels(cwd: string): string[];
336
337
  * guard already owns.
337
338
  */
338
339
  export declare function discoverGateCommandBodies(cwd: string): Record<string, string>;
339
- /**
340
- * A non-zero exit whose output shows the EXTERNAL INFRASTRUCTURE a launch script
341
- * talks to is absent HERE — a database/daemon that is not running or not
342
- * installed — rather than a fault in the script itself. Applied ONLY to
343
- * launch-contract scripts (a migrate/seed against no DB is an environment gap on
344
- * this box; the same wording in a `test` run is a real failure the suite must own).
345
- */
346
- export declare const INFRA_GAP_OUTPUT_RE: RegExp;
347
340
  /**
348
341
  * How a re-run of ONE recorded VERIFY command line ended.
349
342
  * pass — it ran and exited 0. The ONLY outcome that may close a debt.
@@ -377,7 +370,9 @@ export type VerifyRerunOutcome = {
377
370
  * failure, missing tool, unreachable database, timeout, no POSIX shell — leaves the
378
371
  * debt exactly as open as it was.
379
372
  */
380
- export declare function runVerifyCommandLine(cwd: string, line: string, timeoutMs: number, extraGapRe?: RegExp): VerifyRerunOutcome;
373
+ export declare function runVerifyCommandLine(cwd: string, line: string, timeoutMs: number, extraGapRe?: RegExp,
374
+ /** The spawner. Injected so a re-run's outcome can be tested without one. */
375
+ run?: CommandRunner): VerifyRerunOutcome;
381
376
  /**
382
377
  * The full-skip blindness guard (mx5 run 16, validated): dynamic commands were
383
378
  * DISCOVERED but every single one skipped as an environment gap, so the gate
@@ -537,7 +532,10 @@ export declare function deriveOpenDebts(cwd: string, staticOk: boolean): Promise
537
532
  * the guard: the re-run is INCONCLUSIVE there, because "nothing changed" would be an
538
533
  * assumption rather than an observation.
539
534
  */
540
- export declare function rerunDebtVerifyCommand(cwd: string, command: string): VerifyRerunResult;
535
+ export declare function rerunDebtVerifyCommand(cwd: string, command: string,
536
+ /** The spawner, for BOTH the command and the tracked-state reads. Injected so
537
+ * the guard's four outcomes are testable without a repo or a real command. */
538
+ run?: CommandRunner): VerifyRerunResult;
541
539
  /**
542
540
  * Where in the gate a closure scan runs. The two stages are NOT interchangeable
543
541
  * and neither is a scheduling preference: