@mjasnikovs/pi-task 0.18.26 → 0.18.28

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.
@@ -1,68 +1,33 @@
1
1
  import { getConfig } from '../config/config.js';
2
+ import { CommandWatchdog, realTimerDeps, reminderMessage } from '../shared/command-watchdog.js';
2
3
  /**
3
- * The reminder delivered to the model after its command is cancelled. Kept pure
4
- * and exported so a test can assert its shape without driving the whole session.
4
+ * MAIN-SESSION adapter for the command watchdog.
5
+ *
6
+ * WHY: a local model in the MAIN session routinely runs a command that never
7
+ * returns — `godot --headless --check-only` with no timeout, a dev server, a
8
+ * hung test — and the run wedges until the user manually aborts and tells the
9
+ * model to add a timeout. pi's bash tool takes an OPTIONAL `timeout` with NO
10
+ * default (see pi-coding-agent tools/bash.js), so any command the model didn't
11
+ * bound runs forever. This supplies the missing default from the host side.
12
+ *
13
+ * HOW: arm a wall-clock timer on `tool_execution_start`, disarm it on
14
+ * `tool_execution_end`. If it elapses, `ctx.abort()` cancels the in-flight
15
+ * operation — which fires the tool's AbortSignal, and pi's bash executor kills
16
+ * the whole process tree on abort — then a follow-up user turn tells the model
17
+ * what happened so it retries with a timeout instead of hanging again.
18
+ *
19
+ * Tool-agnostic: it arms on ANY tool, honouring "any command can run forever",
20
+ * though in practice only bash runs long enough to trip it.
21
+ *
22
+ * SCOPE — this covers the main session ONLY, which is where the implementation
23
+ * turn runs (orchestrator hands the spec off via sendUserMessage). Gate
24
+ * children are spawned `--no-extensions`, so no host extension exists inside
25
+ * them; their equivalent guard lives in runWorker (workers/pi-worker-core.ts)
26
+ * and shares the same machine from shared/command-watchdog.ts.
5
27
  */
6
- export function reminderMessage(toolName, timeoutMs) {
7
- const mins = Math.max(1, Math.round(timeoutMs / 60_000));
8
- return (`[SYSTEM] Your \`${toolName}\` call ran longer than ${mins} minute`
9
- + `${mins === 1 ? '' : 's'} and was automatically cancelled — it looked stuck. `
10
- // Anti-fabrication: a live run showed the model react to the cancel by
11
- // reporting the killed command as succeeded ("the server is now running").
12
- // State plainly that it produced nothing so the model can't claim success.
13
- + `The command was killed before it finished and produced NO result, so do not `
14
- + `report it as completed or successful, and do not claim that anything it would `
15
- + `have started (a server, build, or process) is now running. `
16
- + `If it was a genuinely long-running command, you MUST re-run it with an explicit `
17
- + `timeout — set the bash tool's \`timeout\` parameter (in seconds) so it cannot hang `
18
- + `again — or break it into smaller steps. Do NOT simply retry the same unbounded command.`);
19
- }
20
- export class CommandWatchdog {
21
- deps;
22
- /** Armed timers, keyed by the tool call they guard. Tool executions are
23
- * sequential, so this holds at most one entry in normal operation, but the
24
- * map keeps it correct even if pi ever overlaps two calls. */
25
- active = new Map();
26
- constructor(deps) {
27
- this.deps = deps;
28
- }
29
- /** Arm a timer for a starting tool. No-op when the watchdog is off. */
30
- onStart(toolCallId, toolName) {
31
- const ms = this.deps.getTimeoutMs();
32
- if (!(ms > 0))
33
- return;
34
- // A duplicate start for the same id must not leak the previous timer.
35
- this.disarm(toolCallId);
36
- const handle = this.deps.schedule(() => this.fire(toolCallId, toolName, ms), ms);
37
- this.active.set(toolCallId, handle);
38
- }
39
- /** Disarm the timer for a finished tool. */
40
- onEnd(toolCallId) {
41
- this.disarm(toolCallId);
42
- }
43
- /** Cancel every armed timer — a turn-end / session-shutdown safety net so no
44
- * stray timer can fire into a later, unrelated command. */
45
- clearAll() {
46
- for (const handle of this.active.values())
47
- this.deps.cancel(handle);
48
- this.active.clear();
49
- }
50
- disarm(toolCallId) {
51
- const handle = this.active.get(toolCallId);
52
- if (handle !== undefined) {
53
- this.deps.cancel(handle);
54
- this.active.delete(toolCallId);
55
- }
56
- }
57
- fire(toolCallId, toolName, ms) {
58
- // If the tool ended in the same tick the timer fired, its entry is gone
59
- // already — never abort a command that has just finished cleanly.
60
- if (!this.active.has(toolCallId))
61
- return;
62
- this.active.delete(toolCallId);
63
- this.deps.onFire(toolCallId, toolName, ms);
64
- }
65
- }
28
+ // Re-exported so existing importers (and the machine's own tests) keep their
29
+ // entry point while the implementation lives in shared/.
30
+ export { CommandWatchdog, commandTimeoutHint, realTimerDeps, reminderMessage } from '../shared/command-watchdog.js';
66
31
  /**
67
32
  * Wire the watchdog into the main session. Only ever active in the host session
68
33
  * (children run `--no-extensions`), which is exactly where the observed hangs
@@ -75,16 +40,7 @@ export function registerCommandWatchdog(pi) {
75
40
  const ctxByCall = new Map();
76
41
  const watchdog = new CommandWatchdog({
77
42
  getTimeoutMs: () => getConfig().requestTimeoutMs,
78
- schedule: (fn, ms) => {
79
- const handle = setTimeout(fn, ms);
80
- // Don't let a pending watchdog timer keep the process alive on exit.
81
- if (typeof handle.unref === 'function') {
82
- ;
83
- handle.unref();
84
- }
85
- return handle;
86
- },
87
- cancel: handle => clearTimeout(handle),
43
+ ...realTimerDeps,
88
44
  onFire: (toolCallId, toolName, timeoutMs) => {
89
45
  const ctx = ctxByCall.get(toolCallId);
90
46
  ctxByCall.delete(toolCallId);
@@ -90,6 +90,10 @@ export interface EnforceChildResult {
90
90
  loopHit?: unknown;
91
91
  leakedToolCall?: unknown;
92
92
  stalled?: boolean;
93
+ commandTimedOut?: {
94
+ toolName: string;
95
+ timeoutMs: number;
96
+ };
93
97
  }
94
98
  /**
95
99
  * Map the enforcement child's runWorker result to a fatal error message, or null
@@ -224,6 +224,15 @@ export function classifyEnforceChildFailure(r) {
224
224
  if (r.stalled) {
225
225
  return 'model server unreachable — the child produced no output and the model endpoint did not respond';
226
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`);
235
+ }
227
236
  if (r.timedOut)
228
237
  return 'enforcement child timed out';
229
238
  if (r.loopHit)
@@ -263,6 +263,15 @@ export function buildGateDeps(params) {
263
263
  signal: sig,
264
264
  tools,
265
265
  timeoutMs: 0,
266
+ // The gate child runs to completion (timeoutMs 0), but a
267
+ // single command inside it must still be bounded: pi's bash
268
+ // tool has no default timeout, so a `bun run dev` / hung
269
+ // check the model forgot to bound wedges the gate forever.
270
+ // The stall guard cannot see it — a reachable model endpoint
271
+ // reads as proof of life while the command blocks. Same
272
+ // ceiling the main session uses, so one /task-config knob
273
+ // covers implementation and gates alike.
274
+ commandTimeoutMs: getConfig().requestTimeoutMs,
266
275
  loop: { pathThreshold: Number.POSITIVE_INFINITY },
267
276
  onLine: line => {
268
277
  lastLine = line;
@@ -404,6 +413,11 @@ export function buildGateDeps(params) {
404
413
  signal: sig,
405
414
  tools,
406
415
  timeoutMs: 0, // no wall-clock timeout — run to completion
416
+ // …but still bound any SINGLE command (see makeGateChild).
417
+ // enforce is read,edit today, so nothing here can hang on
418
+ // bash — wired anyway so a future tool grant can't quietly
419
+ // re-open the hole.
420
+ commandTimeoutMs: getConfig().requestTimeoutMs,
407
421
  // Exact-match loop guard only: pathThreshold Infinity
408
422
  // disables the path-revisit heuristic, so revisiting one
409
423
  // file (which IS this pass's job) never trips — only a
@@ -30,6 +30,8 @@ import { findSkipEscapes, skipEscapeDefectText } from './skip-escape.js';
30
30
  import { findSynthesizedWiring, wiringProbeText, readReferencedDocs } from './wiring-claims.js';
31
31
  import { findAbsenceConflicts, absenceProbeText, siblingTitlesFromPlanContext } from './verify-reconcile.js';
32
32
  import { findFrozenPathConflicts, frozenConflictProbeText } from './frozen-conflict.js';
33
+ import { findSynthesizedApis, synthesizedApiReaskHint } from './api-synthesis.js';
34
+ import { findGrepOnlyVerify, grepOnlyVerifyDefectText, GREP_THEATER_RETRY_HINT } from './verify-quality.js';
33
35
  import { existsSync } from 'node:fs';
34
36
  import { readContracts, buildContractsBlock, buildContractsVerifyBlock } from './contracts.js';
35
37
  import { readRequirements, buildRequirementsBlock } from './requirements.js';
@@ -633,7 +635,43 @@ export async function phaseAutoAnswer(deps, refined, research, question, autoDep
633
635
  // otherwise a preamble line leaks out as the recommended answer.
634
636
  text = await runPhaseChild(deps, 'grill-auto', 'read', prependHint(GRILL_AUTO_FORMAT_HINT, basePrompt));
635
637
  }
636
- const parsed = parseAutoAnswer(text);
638
+ let parsed = parseAutoAnswer(text);
639
+ // Anti-synthesis guard (mx5 run 13, Bug A): the auto-answer invented
640
+ // `Bun.mkdirSync` while research's APIS section carried the correct list,
641
+ // and the invention was promoted into requirements + VERIFY. Deterministic
642
+ // verbatim-substring check: an API-shaped identifier in the answer that is
643
+ // absent from the research AND the question, in a namespace the research
644
+ // claims to cover, triggers ONE re-ask with the verified research lines
645
+ // injected. Still synthesizing after the re-ask ⇒ surface to the user as a
646
+ // recommendation instead of silently promoting it (costs time, never work).
647
+ if (parsed.kind === 'answered') {
648
+ const synth = findSynthesizedApis(parsed.text, question, research);
649
+ if (synth.length > 0) {
650
+ deps.logDebug?.('grill-auto: unverified API identifier(s) in answer — '
651
+ + synth.map(f => f.identifier).join(', ')
652
+ + ' — re-asking with the research API list injected');
653
+ let reasked = null;
654
+ try {
655
+ const text2 = await runPhaseChild(deps, 'grill-auto', 'read', prependHint(synthesizedApiReaskHint(synth, research), basePrompt));
656
+ if (autoAnswerHasTag(text2))
657
+ reasked = parseAutoAnswer(text2);
658
+ }
659
+ catch {
660
+ reasked = null;
661
+ }
662
+ if (reasked === null
663
+ || (reasked.kind === 'answered'
664
+ && findSynthesizedApis(reasked.text, question, research).length > 0)) {
665
+ const still = reasked ?? parsed;
666
+ const suggested = still.kind === 'answered' ? still.text : parsed.text;
667
+ deps.logDebug?.('grill-auto: answer still carries an unverified API — surfacing to user');
668
+ parsed = { kind: 'unknown', suggested, raw: still.raw };
669
+ }
670
+ else {
671
+ parsed = reasked;
672
+ }
673
+ }
674
+ }
637
675
  // Surviving-unknown routing: an integration / build-wiring unknown whose
638
676
  // wrong guess is a structural landmine must NOT be silently auto-answered.
639
677
  // We first try to ground it from fetched docs (the enrichment fan-out
@@ -866,6 +904,17 @@ export async function phaseCritique(deps, spec, refined, qa, planContext, resear
866
904
  deps.logDebug?.('unsatisfiable freeze/requires-edit pair flagged in spec: '
867
905
  + frozenConflicts.map(c => c.path).join(' | '));
868
906
  }
907
+ // DETERMINISTIC grep-theater probe (mx5 run 13, Bug B): a VERIFY block that
908
+ // grep-asserts the SOURCE of a runnable deliverable while every command in
909
+ // the block is static inspection — the build script "verified" by three
910
+ // greps that was never run, shipping broken for 14 tasks. Forced into the
911
+ // rewrite like the skip-escape finding: VERIFY must EXECUTE the artifact
912
+ // and assert an observable outcome of that run.
913
+ const grepOnly = findGrepOnlyVerify(spec);
914
+ const grepOnlyProbe = grepOnly.length > 0 ? grepOnlyVerifyDefectText(grepOnly) : null;
915
+ if (grepOnlyProbe) {
916
+ deps.logDebug?.('grep-theater VERIFY flagged in spec: ' + grepOnly.map(f => f.target).join(' | '));
917
+ }
869
918
  let triageDefects = null;
870
919
  if (parseVerifyBlock(spec) !== null) {
871
920
  const tTriage = Date.now();
@@ -883,14 +932,15 @@ export async function phaseCritique(deps, spec, refined, qa, planContext, resear
883
932
  deps.recordSubStep?.('triage', Date.now() - tTriage);
884
933
  if (verdict !== null) {
885
934
  // A deterministic skip-escape, synthesized-wiring, plan-contradiction,
886
- // or unsatisfiable-pair finding overrides a CLEAN triage: the draft must
935
+ // unsatisfiable-pair, or grep-theater finding overrides a CLEAN triage: the draft must
887
936
  // be rewritten to resolve it even if the model judged the rest clean
888
937
  // (the model does not self-discover any of them reliably).
889
938
  if (isCritiqueClean(verdict)) {
890
939
  if (skipDefects === null
891
940
  && wiringProbe === null
892
941
  && absenceProbe === null
893
- && frozenProbe === null) {
942
+ && frozenProbe === null
943
+ && grepOnlyProbe === null) {
894
944
  return spec;
895
945
  }
896
946
  }
@@ -900,22 +950,39 @@ export async function phaseCritique(deps, spec, refined, qa, planContext, resear
900
950
  }
901
951
  }
902
952
  // Merge the deterministic skip-escape + synthesized-wiring + plan-contradiction
903
- // + unsatisfiable-pair defects with any triage defects for the rewrite (all are
904
- // forced FOCUS items).
905
- const rewriteDefects = [skipDefects, wiringProbe, absenceProbe, frozenProbe, triageDefects]
953
+ // + unsatisfiable-pair + grep-theater defects with any triage defects for the
954
+ // rewrite (all are forced FOCUS items).
955
+ const rewriteDefects = [skipDefects, wiringProbe, absenceProbe, frozenProbe, grepOnlyProbe, triageDefects]
906
956
  .filter(Boolean)
907
957
  .join('\n\n') || null;
908
958
  const tRewrite = Date.now();
909
959
  try {
910
- return await runWithEmphasisRetry(deps, 'critique', 'read', problem => CRITIQUE_PROMPT(spec, refined, qa, problem !== null, rewriteDefects, contractsBlock), text => {
960
+ return await runWithEmphasisRetry(deps, 'critique', 'read', problem => {
961
+ const base = CRITIQUE_PROMPT(spec, refined, qa, problem === 'no_verify_block', rewriteDefects, contractsBlock);
962
+ // Theater retry gets a targeted hint (the generic emphasis line
963
+ // says "previous attempt had no VERIFY block", which is wrong
964
+ // here — it had one, it just never ran the deliverable).
965
+ return problem === 'verify_grep_theater' ?
966
+ prependHint(GREP_THEATER_RETRY_HINT, base)
967
+ : base;
968
+ }, text => {
911
969
  // The rewrite (thinking on) sometimes prepends narration before
912
970
  // GOAL; the prompt forbids it but this validator only checks for
913
971
  // a VERIFY block. Strip it so the delivered spec starts at GOAL.
914
972
  const stripped = stripSpecPreamble(text);
915
- return parseVerifyBlock(stripped) ?
916
- { ok: true, value: stripped }
917
- : { ok: false, problem: 'no_verify_block' };
918
- }, () => new Error('no_verify_block'));
973
+ if (parseVerifyBlock(stripped) === null) {
974
+ return { ok: false, problem: 'no_verify_block' };
975
+ }
976
+ // Detector-backed closure on the grep-theater defect: when the
977
+ // draft was flagged, the rewrite must actually resolve it (live
978
+ // A/B: 1/5 rewrites ignored the injected defect and re-shipped
979
+ // the grep-only block). One emphasis retry with a targeted hint;
980
+ // a second miss falls back to the draft in critiqueWithFallback.
981
+ if (grepOnlyProbe !== null && findGrepOnlyVerify(stripped).length > 0) {
982
+ return { ok: false, problem: 'verify_grep_theater' };
983
+ }
984
+ return { ok: true, value: stripped };
985
+ }, problem => new Error(problem));
919
986
  }
920
987
  finally {
921
988
  deps.recordSubStep?.('rewrite', Date.now() - tRewrite);
@@ -928,7 +995,7 @@ export async function critiqueWithFallback(d, p) {
928
995
  }
929
996
  catch (err) {
930
997
  const msg = err instanceof Error ? err.message : String(err);
931
- if (msg !== 'no_verify_block')
998
+ if (msg !== 'no_verify_block' && msg !== 'verify_grep_theater')
932
999
  throw err;
933
1000
  // Fall back to the compose draft — but only if it actually carries a
934
1001
  // runnable VERIFY block. Critique reaches its rewrite path precisely
@@ -937,9 +1004,14 @@ export async function critiqueWithFallback(d, p) {
937
1004
  // handoff gate rejects and resume can't heal. Compose now enforces a
938
1005
  // parseable VERIFY, so this should hold; keep the guard so a regression
939
1006
  // fails the run cleanly instead of shipping a broken spec.
1007
+ // (verify_grep_theater: both rewrite attempts kept a grep-only VERIFY;
1008
+ // the draft carries the same defect but is the validated-shape fallback
1009
+ // — deliver it rather than fail the run. The guard costs time, never work.)
940
1010
  if (parseVerifyBlock(p.spec) === null)
941
1011
  throw err;
942
- p.ctx.ui.notify("Critique couldn't produce a VERIFY block — using compose draft. Edit the spec manually if needed.", 'warning');
1012
+ p.ctx.ui.notify(msg === 'verify_grep_theater' ?
1013
+ 'Critique rewrite kept a grep-only VERIFY — using compose draft. Consider adding a command that RUNS the deliverable.'
1014
+ : "Critique couldn't produce a VERIFY block — using compose draft. Edit the spec manually if needed.", 'warning');
943
1015
  return p.spec;
944
1016
  }
945
1017
  }
@@ -231,6 +231,8 @@ LIVE-DATA RULE:
231
231
  - No npm block + question is about latest/current version → tag UNKNOWN (training data goes stale).
232
232
  - VERSION-PIN questions ("pin to X.y vs latest", "which major version") are costly-to-reverse build-shaping choices: unless the spec or an "### npm:" block already settles it (then ANSWER that value), tag UNKNOWN and surface it. NEVER auto-answer a downgrade to an OLDER major "to avoid breaking changes" from memory — that reasoning is exactly the stale-training-data trap. If an "### npm:" block shows a newer major than your instinct, that block is the live latest; do not silently pin an older major the live data and spec never asked for.
233
233
 
234
+ API-GROUNDING RULE: never name a concrete API (\`Namespace.member\`, an imported function, a runtime builtin) that appears in neither the research notes nor the question. The research APIS list was verified against the installed types; an API you remember but the research does not list may simply not exist, and an invented one poisons the whole task downstream. If the behavior you recommend needs an API the research does not list, describe the behavior without naming an API, or tag UNKNOWN.
235
+
234
236
  TRIAGE — run these checks IN ORDER first. The REVERSIBILITY TEST below applies ONLY to a question that survives all checks as a genuine preference.
235
237
 
236
238
  1. ALREADY-DECIDED CHECK — scan the refined task and research for a value, shape, response body, schema, route, or requirement that ALREADY determines the answer. If one does, this is a fact, not a preference. Emit "ANSWER: <value taken from that source>". If your instinct or a "nicer" alternative contradicts that source, the SOURCE WINS — never override a stated contract with a preferred default. (E.g. a stated response shape { items, total, page, pageSize } already answers a pagination question — page/offset — you may NOT answer "cursor".)
@@ -323,6 +325,7 @@ VERIFY must exercise the surface area the task actually touches. Draw VERIFY com
323
325
  - TypeScript / JavaScript source changes → MUST include the project's typecheck, lint, and test commands when those scripts exist in TOOLING. Include build only if the change could affect the build output.
324
326
  - Python / Go / Rust / other source changes → MUST include the language's standard verification from TOOLING (e.g. \`pytest\`, \`go test ./...\`, \`cargo test\`) plus lint/typecheck if configured.
325
327
  - Config / infra-only changes with no executable verification → state that explicitly with a single command that re-reads or validates the config (e.g. \`docker compose config\`, \`nginx -t\`, \`yamllint file.yml\`). Never leave VERIFY with only \`true\` or \`echo ok\`.
328
+ - Runnable deliverables (a build script, server, CLI, seed/migrate script) → VERIFY must EXECUTE the artifact and assert an observable outcome of that run (exit code, a file the run produces, a served response). A grep on the artifact's SOURCE proves nothing about behavior and is never sufficient on its own.
326
329
 
327
330
  When this task is one step of a larger plan: sibling steps' deliverables may already exist in the tree and more will land after this task. NEVER write a VERIFY check that fails because sibling work exists (e.g. "file X must not exist" when another step owns X). The plan context forbids you from BUILDING other steps' work — it does not make their work absent. Verify what THIS task adds or changes.
328
331
 
@@ -0,0 +1,26 @@
1
+ export interface GrepOnlyVerifyFinding {
2
+ /** The runnable source file being grep-asserted, e.g. "build.ts". */
3
+ target: string;
4
+ /** The VERIFY lines that inspect it, verbatim. */
5
+ lines: string[];
6
+ }
7
+ /**
8
+ * Scan a composed spec's VERIFY block: all-static block that grep/cat-asserts
9
+ * runnable source ⇒ one finding per inspected file. Empty when the block
10
+ * contains any execution command, has no VERIFY block, or inspects no runnable
11
+ * source (doc/config-only tasks).
12
+ */
13
+ export declare function findGrepOnlyVerify(spec: string): GrepOnlyVerifyFinding[];
14
+ /**
15
+ * Retry hint when the critique rewrite KEPT the grep-theater block it was told
16
+ * to fix (live A/B: 1/5 rewrites ignored the injected defect). Prepended to the
17
+ * second rewrite attempt; the defect block naming the exact files is still in
18
+ * the prompt body.
19
+ */
20
+ export declare const GREP_THEATER_RETRY_HINT: string;
21
+ /**
22
+ * Render the findings as a defect block for the critique rewrite: VERIFY must
23
+ * EXECUTE the runnable deliverable and assert an observable outcome of THAT
24
+ * run, not grep its source.
25
+ */
26
+ export declare function grepOnlyVerifyDefectText(findings: GrepOnlyVerifyFinding[]): string;
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Grep-theater VERIFY detector (mx5 run-13 Bug B), compose-critique side.
3
+ *
4
+ * TASK_0018's VERIFY block "verified" a build script with tsc + three greps on
5
+ * build.ts's SOURCE — it never ran `bun build.ts`. The greps asserted the
6
+ * hallucinated `Bun.mkdirSync` line was present, so a broken build shipped
7
+ * green and stayed broken for 14 tasks. Grep-on-source is not verification of
8
+ * a runnable deliverable; only executing the artifact is.
9
+ *
10
+ * Deterministic shape (findSkipEscapes → critique-rewrite pattern): a finding
11
+ * fires when the VERIFY block (a) grep/cat-asserts the SOURCE of a runnable
12
+ * file (.ts/.js/.sh — a build script, server, CLI entry) and (b) contains NO
13
+ * execution command at all — every command is static inspection (grep, test,
14
+ * ls, cat, tsc --noEmit, eslint, prettier). Any real execution anywhere in the
15
+ * block (bun/node/npm run/test, curl, ./script) means the deliverable-runs
16
+ * question is at worst partially covered, and we step aside — the guard may
17
+ * only cost time, never work, so recall is floored at the unambiguous
18
+ * all-static case rather than chasing which command exercises which file.
19
+ */
20
+ import { parseVerifyBlock } from './spec-validation.js';
21
+ /** Commands that only inspect — they never execute the shipped artifact. */
22
+ const STATIC_HEADS = new Set([
23
+ 'grep',
24
+ 'rg',
25
+ 'cat',
26
+ 'ls',
27
+ 'test',
28
+ '[',
29
+ '[[',
30
+ 'find',
31
+ 'wc',
32
+ 'head',
33
+ 'tail',
34
+ 'diff',
35
+ 'stat',
36
+ 'echo',
37
+ 'printf',
38
+ 'true',
39
+ 'false',
40
+ 'cd',
41
+ 'pwd',
42
+ 'which',
43
+ 'command',
44
+ 'file',
45
+ 'jq',
46
+ 'sed',
47
+ 'awk',
48
+ 'sort',
49
+ 'uniq',
50
+ 'cut',
51
+ 'tr',
52
+ 'sleep',
53
+ 'exit',
54
+ // static-analysis tools: they read source, they don't run the deliverable
55
+ 'tsc',
56
+ 'eslint',
57
+ 'prettier',
58
+ 'biome'
59
+ ]);
60
+ /** A bare (unquoted) path token ending in a runnable-source extension. */
61
+ const RUNNABLE_SRC_RE = /^[\w@./-]+\.(?:ts|tsx|js|jsx|mjs|cjs|mts|cts|sh)$/;
62
+ /** Heads whose file arguments count as "inspecting the source of". */
63
+ const INSPECT_HEADS = new Set(['grep', 'rg', 'cat', 'head', 'tail', 'wc']);
64
+ /**
65
+ * Shell-control noise that precedes (or IS) a segment without being a command:
66
+ * `if grep -q x f; then` splits into an `if`-prefixed segment plus bare `then`;
67
+ * `… || { echo FAIL; exit 1; }` yields `{ echo …` and `}` segments. Treating
68
+ * these as unknown heads would count them as execution and silently blind the
69
+ * detector on exactly the incident shape (run-13's VERIFY used all of them).
70
+ */
71
+ const CONTROL_PREFIX = new Set(['if', 'elif', 'while', 'until', 'then', 'else', 'do', '!']);
72
+ const CONTROL_ONLY = new Set(['}', ')', 'fi', 'done', 'esac']);
73
+ /**
74
+ * The effective head of one pipeline segment: shell-control prefixes, leading
75
+ * `(`/`{`, VAR=val prefixes and `timeout N` are skipped; `bunx`/`npx` resolve
76
+ * to the tool they invoke (so `bunx tsc --noEmit` is static).
77
+ * `bun`/`npm`/`yarn`/`pnpm`/`node` stay as themselves — whatever they run (a
78
+ * script, a test suite, a file) is execution.
79
+ */
80
+ function segmentHead(segment) {
81
+ const tokens = segment.split(/\s+/).filter(t => t.length > 0);
82
+ let i = 0;
83
+ while (i < tokens.length) {
84
+ const t = tokens[i].replace(/^[({!]+/, '');
85
+ if (t.length === 0 || CONTROL_PREFIX.has(t)) {
86
+ i++;
87
+ continue;
88
+ }
89
+ tokens[i] = t;
90
+ break;
91
+ }
92
+ if (i >= tokens.length || CONTROL_ONLY.has(tokens[i]))
93
+ return null;
94
+ while (i < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i]))
95
+ i++;
96
+ if (i < tokens.length && tokens[i] === 'timeout') {
97
+ i++;
98
+ if (i < tokens.length && /^\d/.test(tokens[i]))
99
+ i++;
100
+ }
101
+ if (i >= tokens.length)
102
+ return null;
103
+ let head = tokens[i];
104
+ if (head === 'bunx' || head === 'npx') {
105
+ i++;
106
+ while (i < tokens.length && tokens[i].startsWith('-'))
107
+ i++;
108
+ if (i >= tokens.length)
109
+ return null;
110
+ head = tokens[i];
111
+ }
112
+ return { head, args: tokens.slice(i + 1) };
113
+ }
114
+ /**
115
+ * Scan a composed spec's VERIFY block: all-static block that grep/cat-asserts
116
+ * runnable source ⇒ one finding per inspected file. Empty when the block
117
+ * contains any execution command, has no VERIFY block, or inspects no runnable
118
+ * source (doc/config-only tasks).
119
+ */
120
+ export function findGrepOnlyVerify(spec) {
121
+ const cmds = parseVerifyBlock(spec);
122
+ if (!cmds)
123
+ return [];
124
+ const inspected = new Map();
125
+ for (const { raw } of cmds) {
126
+ // Split into pipeline segments; quotes are rare in VERIFY one-liners and
127
+ // a mis-split only risks a MISSED finding (a quoted `&&` making a fake
128
+ // segment whose head is unknown ⇒ counted as execution ⇒ step aside).
129
+ for (const segment of raw.split(/&&|\|\||;|\|/)) {
130
+ const s = segmentHead(segment);
131
+ if (s === null)
132
+ continue;
133
+ if (!STATIC_HEADS.has(s.head))
134
+ return []; // real execution — step aside
135
+ if (!INSPECT_HEADS.has(s.head))
136
+ continue;
137
+ for (const arg of s.args) {
138
+ if (arg.startsWith('-') || arg.startsWith("'") || arg.startsWith('"'))
139
+ continue;
140
+ if (!RUNNABLE_SRC_RE.test(arg))
141
+ continue;
142
+ const lines = inspected.get(arg) ?? [];
143
+ if (!lines.includes(raw))
144
+ lines.push(raw);
145
+ inspected.set(arg, lines);
146
+ }
147
+ }
148
+ }
149
+ return [...inspected.entries()].map(([target, lines]) => ({ target, lines }));
150
+ }
151
+ /**
152
+ * Retry hint when the critique rewrite KEPT the grep-theater block it was told
153
+ * to fix (live A/B: 1/5 rewrites ignored the injected defect). Prepended to the
154
+ * second rewrite attempt; the defect block naming the exact files is still in
155
+ * the prompt body.
156
+ */
157
+ export const GREP_THEATER_RETRY_HINT = '[SYSTEM NOTE: Your previous rewrite still shipped a VERIFY block whose only signal '
158
+ + 'on the runnable deliverable is grep-on-source — every command is static inspection '
159
+ + '(grep/cat/test/tsc) and the artifact is never run. This exact shape shipped a broken '
160
+ + 'build that stayed broken for 14 tasks. The rewritten VERIFY MUST execute the '
161
+ + 'deliverable (e.g. `bun <script>.ts`, `bun run <script>`, start it and curl it) and '
162
+ + 'assert an observable outcome of that run (exit code, a file the run produces, a '
163
+ + 'served response). Keep greps only as additions to the run, never as the only signal.]';
164
+ /**
165
+ * Render the findings as a defect block for the critique rewrite: VERIFY must
166
+ * EXECUTE the runnable deliverable and assert an observable outcome of THAT
167
+ * run, not grep its source.
168
+ */
169
+ export function grepOnlyVerifyDefectText(findings) {
170
+ return [
171
+ 'GREP-THEATER VERIFY — every command in the VERIFY block is static inspection',
172
+ '(grep/cat/test/tsc), yet the deliverable includes runnable source. Grep-asserting',
173
+ 'that a source file CONTAINS some text proves nothing about behavior (run-13: a',
174
+ 'build script "verified" by greps shipped broken and stayed broken for 14 tasks',
175
+ 'because `bun build.ts` was never run). Rewrite the VERIFY block so it EXECUTES the',
176
+ 'runnable deliverable and asserts an OBSERVABLE OUTCOME of that run — exit code,',
177
+ 'a produced file (`rm -rf dist && bun run build && test -f dist/…`), a served',
178
+ 'response (`curl -sf http://…`). Keep static checks only as ADDITIONS to the run,',
179
+ 'never as the sole signal. Runnable files currently only grep/cat-inspected:',
180
+ ...findings.map((f, i) => ` ${i + 1}. ${f.target} — via: ${f.lines.join(' ; ')}`)
181
+ ].join('\n');
182
+ }
@@ -17,6 +17,7 @@ export interface RunWorkerInput {
17
17
  name: string;
18
18
  isError: boolean;
19
19
  text: string;
20
+ toolCallId?: string;
20
21
  }) => void;
21
22
  /**
22
23
  * Called for each context_usage snapshot the child emits (same `--mode json`
@@ -31,6 +32,26 @@ export interface RunWorkerInput {
31
32
  * own) — for a pass that must be allowed to finish however long it takes.
32
33
  */
33
34
  timeoutMs?: number;
35
+ /**
36
+ * PER-TOOL-CALL wall-clock ceiling in ms — the child-side half of the command
37
+ * watchdog (see shared/command-watchdog.ts). Arms on each tool_execution_start
38
+ * and disarms on the matching end; on overrun the child is killed and, within
39
+ * the shared restart budget, re-spawned with commandTimeoutHint.
40
+ *
41
+ * WHY SEPARATE FROM `timeoutMs`: that one bounds the whole worker and is
42
+ * deliberately 0 (unbounded) for gate children, which must run to completion.
43
+ * Neither it nor the stall guard can catch a hung command — the stall guard
44
+ * treats a reachable model endpoint as proof of life, which it is, even while
45
+ * a `bun run dev` the model forgot to bound blocks the child forever.
46
+ *
47
+ * This is the ceiling for the FIRST attempt; each HANG-caused restart halves
48
+ * it (see commandCeilingForAttempt — loop-caused restarts don't count), so a
49
+ * model that ignores the hint cannot spend the full ceiling again on every
50
+ * retry.
51
+ *
52
+ * 0 / omitted = off, so every existing caller is unchanged.
53
+ */
54
+ commandTimeoutMs?: number;
34
55
  /**
35
56
  * Per-worker loop-detector tuning. Defaults to the read-only research/impl
36
57
  * guard (LOOP_WINDOW / LOOP_THRESHOLD, path threshold = exact threshold). An
@@ -96,5 +117,39 @@ export interface RunWorkerResult {
96
117
  * aborted too, and mislabeling this as a user cancel hides a dead backend.
97
118
  */
98
119
  stalled?: boolean;
120
+ /**
121
+ * Set when the command watchdog killed the worker's FINAL attempt: one tool
122
+ * call outran `commandTimeoutMs` (a command the model never bounded). Like
123
+ * loopHit/timedOut the text is partial — treat as a failure. Names the tool
124
+ * so the caller's trail says which call hung rather than just "aborted".
125
+ *
126
+ * Check BEFORE `aborted`, same reasoning as `stalled`: the kill aborts too.
127
+ */
128
+ commandTimedOut?: {
129
+ toolName: string;
130
+ timeoutMs: number;
131
+ };
99
132
  }
133
+ /**
134
+ * The per-command ceiling for attempt N, halving each time a hang recurs.
135
+ *
136
+ * The first attempt gets the full configured ceiling — a genuinely slow build or
137
+ * test suite deserves it. But every hang-caused restart carries
138
+ * commandTimeoutHint, which tells the model in as many words to bound its
139
+ * command; a SECOND hang means it ignored an explicit instruction, and a third
140
+ * means it ignored it twice. Giving a non-complying child the full ceiling again
141
+ * would put the worst case at 3 × 15 min = 45 minutes of dead time, resting
142
+ * entirely on the model obeying prose. Halving bounds it at ~26 min while
143
+ * costing a complying child nothing.
144
+ *
145
+ * `priorHangs` counts watchdog kills specifically, NOT total restarts — the
146
+ * restart budget is shared with loop kills, and a child restarted for LOOPING
147
+ * never received the bound-your-command hint, so its first hang still deserves
148
+ * the full ceiling. Only a hang after a hang is defiance.
149
+ *
150
+ * Floored at 30s so repeated halving cannot shrink the ceiling to something no
151
+ * real command could finish inside — but never ABOVE the configured ceiling
152
+ * itself, or a caller asking for 10s would silently get 30.
153
+ */
154
+ export declare function commandCeilingForAttempt(baseMs: number, priorHangs: number): number;
100
155
  export declare function runWorker(input: RunWorkerInput): Promise<RunWorkerResult>;