@mjasnikovs/pi-task 0.42.1 → 0.42.3

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.
package/dist/index.js CHANGED
@@ -7,6 +7,7 @@ import { registerRemote } from './remote/register.js';
7
7
  import { registerCommandWatchdog } from './task/command-watchdog.js';
8
8
  import { registerStreamWatchdog } from './task/stream-watchdog.js';
9
9
  import { registerImplementationGuards } from './task/implementation-guards.js';
10
+ import { registerTaskDirCustody } from './task/task-dir-custody.js';
10
11
  import { registerModelHoldRestore } from './task/model-hold-stash.js';
11
12
  export default function (pi) {
12
13
  registerConfig(pi);
@@ -18,5 +19,6 @@ export default function (pi) {
18
19
  registerCommandWatchdog(pi);
19
20
  registerStreamWatchdog(pi);
20
21
  registerImplementationGuards(pi);
22
+ registerTaskDirCustody(pi);
21
23
  registerModelHoldRestore(pi);
22
24
  }
@@ -15,6 +15,7 @@ import { RUN_END_POLICY, runSucceeded } from './run-end.js';
15
15
  import { parseAutoAnswer, autoAnswerHasTag, deriveTitle } from './parsers.js';
16
16
  import { renderInlineMarkdown, stripInlineMarkdown } from './inline-markdown.js';
17
17
  import { AUTO_CLARIFY_PROMPT, AUTO_DECOMPOSE_PROMPT, DECOMPOSE_COVERAGE_PROMPT } from './auto-prompts.js';
18
+ import { defersBreakage } from './deferred-breakage.js';
18
19
  import { GRILL_AUTO_ANSWER_PROMPT, GRILL_AUTO_FORMAT_HINT } from './prompts.js';
19
20
  import { allocateAutoId, buildAutoBody, parseDecomposeList, parseCoverageVerdict, UNNAMED_COVERAGE_GAP, parseTaskList, planKeyAt, checkOffTask, stampTaskInProgress, beginTaskAttempt, recordTaskEnd, insertTaskAfter, insertTaskBefore, findResumableAutoDetailed } from './auto-io.js';
20
21
  import { decideResume, UNATTENDED_STATES } from './resume-gap.js';
@@ -220,6 +221,10 @@ async function triageClarifyQuestion(deps, cwd, featureForModel, existingFilesBl
220
221
  text = await deps.runChild('clarify-triage', 'read', prependHint(GRILL_AUTO_FORMAT_HINT, basePrompt));
221
222
  }
222
223
  const parsed = parseAutoAnswer(text);
224
+ if (parsed.kind === 'answered' && defersBreakage(parsed.text)) {
225
+ logPlanDebug(cwd, `clarify-triage answer defers a breakage to a nonexistent owner — surfacing: ${parsed.text.replace(/\s+/g, ' ').slice(0, 100)}`);
226
+ return null;
227
+ }
223
228
  if (parsed.kind === 'answered') {
224
229
  logPlanDebug(cwd, `clarify-triage auto-resolved (spec-settled): ${question.replace(/\s+/g, ' ').slice(0, 100)}`
225
230
  + ` → ${parsed.text.replace(/\s+/g, ' ').slice(0, 100)}`);
@@ -0,0 +1,3 @@
1
+ export declare function defersBreakage(answer: string): boolean;
2
+ /** The one re-ask a deferring answer gets before it is surfaced instead of promoted. */
3
+ export declare function deferredBreakageReaskHint(answer: string): string;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Does an auto-answer hand a breakage to someone who does not exist?
3
+ *
4
+ * MEASURED (mx5-n TASK_0004, 2026-09-17): "flag the test/migrate.test.ts breakage
5
+ * as a known issue for the test owner". Nothing in a /task-auto run owns a test:
6
+ * the answer was stamped `(auto)`, verify passed the task with the suite red, and
7
+ * four tasks later an unsatisfiable spec looped until the runaway guard fired.
8
+ *
9
+ * This is the deterministic backstop behind the prompt's GREEN-SUITE CHECK: a
10
+ * model that ignores the rule still cannot promote a deferral into a decision.
11
+ * The phrases are the ones a model reaches for when it wants to defer, not the
12
+ * word "test" — "add a test later" is a plan, not a deferral.
13
+ */
14
+ const DEFERRAL_PHRASES = [
15
+ /\bknown[- ]issue\b/i,
16
+ /\b(?:test|suite|file|module)[- ]owner\b/i,
17
+ /\bwhoever\s+(?:owns|revisits|maintains|touches)\b/i,
18
+ /\bowned by\s+(?:whoever|the\s+\w+\s+owner|a\s+later\s+(?:step|task))/i,
19
+ /\b(?:a|the)\s+later\s+(?:step|task)\s+(?:will|should|can|to)\s+(?:fix|revisit|update|repair|address)/i,
20
+ /\bleave\s+(?:the\s+)?(?:test|tests|suite|failure|breakage)\s+(?:failing|red|broken|as[- ]is)\b/i,
21
+ /\baccept(?:ing)?\s+(?:that\s+)?.{0,60}?\b(?:test|tests|suite|assertions?|lint|build)\b.{0,80}?\b(?:fail|failing|red|broken)\b/i,
22
+ /\bflag(?:ged|ging)?\s+(?:it\s+|this\s+|the\s+\S+\s+)?(?:as\s+)?(?:a\s+)?(?:known|for\s+(?:the|a|whoever))/i,
23
+ /\b(?:owned|as the owned|as a)\s+follow-?up\b/i,
24
+ /\bleft\s+for\s+(?:whoever|the\s+\w+\s+owner)\b/i,
25
+ /\bownership\s+belongs\s+to\b/i
26
+ ];
27
+ /** "do NOT defer", "not a deferral to a test owner", "rather than flag it" — the
28
+ * phrase is named to reject it. MEASURED: a treatment answer did exactly that. */
29
+ const NEGATION_BEFORE = /\b(?:not|never|no|don't|do not|rather than|instead of|isn't|is not|without)\b[^.;]{0,40}$/i;
30
+ export function defersBreakage(answer) {
31
+ return DEFERRAL_PHRASES.some(re => {
32
+ const m = new RegExp(re.source, re.flags + (re.flags.includes('g') ? '' : 'g'));
33
+ for (const hit of answer.matchAll(m)) {
34
+ const before = answer.slice(Math.max(0, hit.index - 60), hit.index);
35
+ if (!NEGATION_BEFORE.test(before))
36
+ return true;
37
+ }
38
+ return false;
39
+ });
40
+ }
41
+ /** The one re-ask a deferring answer gets before it is surfaced instead of promoted. */
42
+ export function deferredBreakageReaskHint(answer) {
43
+ return ('[SYSTEM NOTE: Your previous answer deferred a breakage to someone who does not '
44
+ + `exist — "${answer.replace(/\s+/g, ' ').slice(0, 160)}". No later step owns a `
45
+ + 'failing test, lint, or build; each one inherits it and is told not to touch it. '
46
+ + 'Re-run the GREEN-SUITE CHECK: answer with the option that keeps the suite green, '
47
+ + 'naming the test file this task must also update. Output ONLY the tagged lines.]');
48
+ }
@@ -46,7 +46,7 @@
46
46
  */
47
47
  import { existsSync, readFileSync } from 'node:fs';
48
48
  import * as path from 'node:path';
49
- import { runRepoHealthCheck, discoverHealthCommands } from './repo-health-check.js';
49
+ import { runRepoHealthCheck, discoverHealthCommands, discoverTestCommands } from './repo-health-check.js';
50
50
  import { deriveOpenDebts, rerunDebtVerifyCommand } from './accept-debt.js';
51
51
  import { readDeclaredScripts, missingDeclaredScripts, runnableDeclaredScripts } from './launch-contract.js';
52
52
  import { readLaunchManifest, inertLaunchContractNote, packageScripts, makeHasTarget } from './launch-manifest.js';
@@ -83,58 +83,21 @@ import { VERIFY_FAIL_PREFIX } from './verify-work.js';
83
83
  * tool-by-tool is not the fix either.
84
84
  */
85
85
  export function discoverIntegrationCommands(cwd) {
86
- if (existsSync(path.join(cwd, 'package.json'))) {
87
- const s = packageScripts(cwd);
88
- const cmds = [];
89
- // Every test-shaped script, not just the one literally named `test`: a
90
- // project's only browser-executing suite is often `test:ct` or similar, and
91
- // looking for `test` alone never runs it. Plain `test` leads, then every
92
- // `test:`/`test_`/`test-` prefixed name in declaration order (Array#sort is
93
- // stable), then `build`. Measured on a manifest declaring test:ct, build,
94
- // test_unit, test-e2e, test, testing and pretest, the result is exactly
95
- // test, test:ct, test_unit, test-e2e, build — `testing` and `pretest` do
96
- // not match. Env-gap SKIP still applies per command: a suite whose browser
97
- // or runtime is absent skips rather than fails (see runGateCommand).
98
- const testNames = Object.keys(s).filter(n => n === 'test' || /^test[:_-]/.test(n));
99
- testNames.sort((a, b) => a === 'test' ? -1
100
- : b === 'test' ? 1
101
- : 0);
102
- for (const name of testNames)
103
- cmds.push(['bun', ['run', name]]);
104
- if (s.build)
105
- cmds.push(['bun', ['run', 'build']]);
106
- return { ecosystem: 'package.json', cmds };
107
- }
108
- if (existsSync(path.join(cwd, 'Makefile'))) {
109
- const cmds = [];
110
- for (const target of ['test', 'build']) {
111
- if (makeHasTarget(cwd, target))
112
- cmds.push(['make', [target]]);
86
+ const tests = discoverTestCommands(cwd);
87
+ const build = (() => {
88
+ if (existsSync(path.join(cwd, 'package.json'))) {
89
+ return packageScripts(cwd).build ? ['bun', ['run', 'build']] : null;
113
90
  }
114
- return { ecosystem: 'Makefile', cmds };
115
- }
116
- if (existsSync(path.join(cwd, 'Cargo.toml'))) {
117
- return {
118
- ecosystem: 'Cargo.toml',
119
- cmds: [
120
- ['cargo', ['test', '--quiet']],
121
- ['cargo', ['build', '--quiet']]
122
- ]
123
- };
124
- }
125
- if (existsSync(path.join(cwd, 'go.mod'))) {
126
- return {
127
- ecosystem: 'go.mod',
128
- cmds: [
129
- ['go', ['test', './...']],
130
- ['go', ['build', './...']]
131
- ]
132
- };
133
- }
134
- if (existsSync(path.join(cwd, 'pyproject.toml'))) {
135
- return { ecosystem: 'pyproject.toml', cmds: [['pytest', ['-q']]] };
136
- }
137
- return { ecosystem: null, cmds: [] };
91
+ if (existsSync(path.join(cwd, 'Makefile'))) {
92
+ return makeHasTarget(cwd, 'build') ? ['make', ['build']] : null;
93
+ }
94
+ if (existsSync(path.join(cwd, 'Cargo.toml')))
95
+ return ['cargo', ['build', '--quiet']];
96
+ if (existsSync(path.join(cwd, 'go.mod')))
97
+ return ['go', ['build', './...']];
98
+ return null;
99
+ })();
100
+ return { ecosystem: tests.ecosystem, cmds: build ? [...tests.cmds, build] : tests.cmds };
138
101
  }
139
102
  /**
140
103
  * Per-ecosystem lockfile↔manifest consistency checks. A check applies only when
@@ -27,6 +27,7 @@
27
27
  import { formatLoopHint } from './loop-detector.js';
28
28
  import { classifyEnforceChildFailure } from './enforce-guidelines.js';
29
29
  import { notifyRun } from '../remote/bridge.js';
30
+ import { restoreTaskDir, snapshotTaskDir, taskDirRestoredNotice } from './task-dir-custody.js';
30
31
  /**
31
32
  * What each kind may do. Adding a child is a row; it cannot be added without
32
33
  * deciding all four questions, which is the point.
@@ -69,6 +70,9 @@ export function makeGateChild(deps) {
69
70
  const log = deps.makeDebugAppender(deps.logPath);
70
71
  log(`=== ${deps.kind} start: ${deps.taskTitle} ===`);
71
72
  const guardSnapshot = row.guarded ? await deps.captureGitState(deps.cwd, sig) : null;
73
+ // Every kind, whatever its tools: the git-state guard and the discard both
74
+ // leave `.pi-tasks` alone.
75
+ const taskDir = await snapshotTaskDir(deps.cwd);
72
76
  const frame = deps.loader === false ?
73
77
  null
74
78
  : () => ({
@@ -132,6 +136,12 @@ export function makeGateChild(deps) {
132
136
  finally {
133
137
  // Restore whatever the child moved BEFORE any verdict or failure is
134
138
  // acted on — a crashed child must not skip the restore either.
139
+ const restored = await restoreTaskDir(deps.cwd, taskDir);
140
+ if (restored.length > 0) {
141
+ log(`=== ${deps.kind} TASK-DIR CUSTODY — restored: ${restored.join(', ')} ===`);
142
+ notifyRun(deps.ctx, `${deps.taskTitle}: `
143
+ + taskDirRestoredNotice(`the ${deps.kind} child`, restored), 'warning');
144
+ }
135
145
  if (guardSnapshot) {
136
146
  const rec = await deps.reconcileGitState(deps.cwd, guardSnapshot, sig);
137
147
  deps.onReconcile?.(rec);
@@ -537,7 +537,7 @@ export async function healthBaselineFor(cwd, taskId, signal) {
537
537
  return stored;
538
538
  const fresh = await lazyHealthBaseline({
539
539
  git: makeGit(cwd, signal),
540
- runHealthIn: dir => runRepoHealthCheck(dir, { signal })
540
+ runHealthIn: dir => runRepoHealthCheck(dir, { signal, withTests: true })
541
541
  });
542
542
  if (fresh) {
543
543
  await setTaskSection(cwd, taskId, HEALTH_BASELINE_SECTION, formatHealthBaseline(fresh)).catch(() => { });
@@ -739,6 +739,7 @@ export function buildGateDeps(params) {
739
739
  }));
740
740
  return runRepoHealthCheck(cwd2, {
741
741
  signal,
742
+ withTests: true,
742
743
  onCommand: c => {
743
744
  running = c;
744
745
  }
@@ -934,9 +935,11 @@ export function buildGateDeps(params) {
934
935
  onStage: label => {
935
936
  stageLine = label;
936
937
  },
937
- // Deterministic whole-repo static-analysis gate — runs the project's
938
- // own lint/typecheck and fails on a real non-zero exit, independent of
939
- // the model-authored VERIFY block, which may not lint at all. ASYNC,
938
+ // Deterministic whole-repo gate — runs the project's own
939
+ // lint/typecheck AND its test suite, independent of the
940
+ // model-authored VERIFY block, which may not lint at all. The suite
941
+ // is judged only against the baseline below (a task that turned a
942
+ // green suite red is this task's FAIL, whatever its spec says). ASYNC,
940
943
  // so the lint does not starve pi-tui's nextTick-scheduled renders.
941
944
  //
942
945
  // ONE call, both arms. A baseline arm that dropped the signal or the
@@ -945,6 +948,7 @@ export function buildGateDeps(params) {
945
948
  // the LOADER, above.
946
949
  repoHealth: () => runRepoHealthCheck(cwd2, {
947
950
  signal,
951
+ withTests: true,
948
952
  onCommand: c => {
949
953
  stageLine = `repo health · ${c}`;
950
954
  }
@@ -16,10 +16,6 @@ export declare function implementationGuardArmed(): boolean;
16
16
  * one altered byte is a new key and a clean slate on both counters.
17
17
  */
18
18
  export declare function blockedCallReason(toolName: string, count: number): string;
19
- /** Every file under the task dir is host-written. MEASURED: an mx5 implementer
20
- * wrote its report over TASK_AUTO_0001.md, and the run died on its front matter. */
21
- export declare function targetsTaskDir(toolName: string, input: unknown): boolean;
22
- export declare function taskDirWriteReason(): string;
23
19
  /** The reason on the final block, which also ends the turn. */
24
20
  export declare function terminalCallReason(): string;
25
21
  export declare function consumeGuardTermination(): boolean;
@@ -1,5 +1,4 @@
1
1
  import { LoopDetector, loopKey, LOOP_THRESHOLD, LOOP_WINDOW, MAX_LOOP_RESTARTS } from './loop-detector.js';
2
- import { TASKS_DIR_NAME } from './task-types.js';
3
2
  /**
4
3
  * Runaway guard for the IMPLEMENTATION TURN — the one model surface with none.
5
4
  *
@@ -82,18 +81,6 @@ export function blockedCallReason(toolName, count) {
82
81
  return (`Blocked: this is the ${count}th identical ${toolName} call in this turn. `
83
82
  + `Use what you already have, or do something different, then continue the task.`);
84
83
  }
85
- /** Every file under the task dir is host-written. MEASURED: an mx5 implementer
86
- * wrote its report over TASK_AUTO_0001.md, and the run died on its front matter. */
87
- export function targetsTaskDir(toolName, input) {
88
- if (!MUTATING_TOOLS.has(toolName))
89
- return false;
90
- const target = input?.path;
91
- return typeof target === 'string' && target.split(/[\\/]/).includes(TASKS_DIR_NAME);
92
- }
93
- export function taskDirWriteReason() {
94
- return (`Blocked: ${TASKS_DIR_NAME}/ belongs to pi-task and is written only by the host. `
95
- + `Put your report in your reply, not in a file, then continue the task.`);
96
- }
97
84
  /** The reason on the final block, which also ends the turn. */
98
85
  export function terminalCallReason() {
99
86
  return (`Blocked: this turn repeated one call past every warning, so it is being stopped `
@@ -133,9 +120,6 @@ export function registerImplementationGuards(pi) {
133
120
  if (state.terminating) {
134
121
  return { block: true, terminate: true, reason: terminalCallReason() };
135
122
  }
136
- if (targetsTaskDir(event.toolName, event.input)) {
137
- return { block: true, reason: taskDirWriteReason() };
138
- }
139
123
  const call = { name: event.toolName, args: event.input };
140
124
  const mutating = MUTATING_TOOLS.has(event.toolName);
141
125
  const hit = mutating ? state.edits.record(call) : state.loop.record(call);
@@ -1,8 +1,8 @@
1
1
  /**
2
- * The implementation-turn bracket: one entry arms both the status widget and the
3
- * runaway guard, one `leave` disarms both.
2
+ * The implementation-turn bracket: one entry arms the status widget and the
3
+ * runaway guard and takes custody of the task dir, one `leave` ends all three.
4
4
  *
5
- * The two modules keep their own lifecycle handlers, and they draw the turn
5
+ * Each module keeps its own lifecycle handlers, and the first two draw the turn
6
6
  * boundary differently on purpose: the widget hides on `agent_end` (a sub-turn is
7
7
  * over, the screen should say so), the guard survives until `agent_settled`
8
8
  * (compactions and retries fire `agent_end` INSIDE a turn — see its header). What
@@ -13,12 +13,14 @@
13
13
  import { type ImplWidgetMeta } from './impl-widget.js';
14
14
  /**
15
15
  * `oneShot` true (fire-and-forget /task) lets each module's own settle handler
16
- * disarm after the single turn; false (awaited /task-auto) keeps both armed
16
+ * end it after the single turn; false (awaited /task-auto) keeps all three
17
17
  * across resume and steer turns until `leave` is called.
18
18
  *
19
19
  * `leave` is idempotent: a second call is a no-op, so a caller can put it in a
20
- * `finally` and a `catch` without disarming a bracket entered since.
20
+ * `finally` and a `catch` without ending a bracket entered since. It resolves to
21
+ * the task files it restored.
21
22
  */
22
23
  export declare function enterImplementationTurn(meta: ImplWidgetMeta, opts: {
23
24
  oneShot: boolean;
24
- }): () => void;
25
+ cwd: string;
26
+ }): Promise<() => Promise<string[]>>;
@@ -1,8 +1,8 @@
1
1
  /**
2
- * The implementation-turn bracket: one entry arms both the status widget and the
3
- * runaway guard, one `leave` disarms both.
2
+ * The implementation-turn bracket: one entry arms the status widget and the
3
+ * runaway guard and takes custody of the task dir, one `leave` ends all three.
4
4
  *
5
- * The two modules keep their own lifecycle handlers, and they draw the turn
5
+ * Each module keeps its own lifecycle handlers, and the first two draw the turn
6
6
  * boundary differently on purpose: the widget hides on `agent_end` (a sub-turn is
7
7
  * over, the screen should say so), the guard survives until `agent_settled`
8
8
  * (compactions and retries fire `agent_end` INSIDE a turn — see its header). What
@@ -12,23 +12,28 @@
12
12
  */
13
13
  import { armImplWidget, disarmImplWidget } from './impl-widget.js';
14
14
  import { armImplementationGuard, disarmImplementationGuard } from './implementation-guards.js';
15
+ import { takeTaskDirCustody } from './task-dir-custody.js';
15
16
  /**
16
17
  * `oneShot` true (fire-and-forget /task) lets each module's own settle handler
17
- * disarm after the single turn; false (awaited /task-auto) keeps both armed
18
+ * end it after the single turn; false (awaited /task-auto) keeps all three
18
19
  * across resume and steer turns until `leave` is called.
19
20
  *
20
21
  * `leave` is idempotent: a second call is a no-op, so a caller can put it in a
21
- * `finally` and a `catch` without disarming a bracket entered since.
22
+ * `finally` and a `catch` without ending a bracket entered since. It resolves to
23
+ * the task files it restored.
22
24
  */
23
- export function enterImplementationTurn(meta, opts) {
24
- armImplWidget(meta, opts);
25
- armImplementationGuard(opts);
25
+ export async function enterImplementationTurn(meta, opts) {
26
+ const { oneShot } = opts;
27
+ const releaseTaskDir = await takeTaskDirCustody(opts.cwd, { oneShot });
28
+ armImplWidget(meta, { oneShot });
29
+ armImplementationGuard({ oneShot });
26
30
  let left = false;
27
- return () => {
31
+ return async () => {
28
32
  if (left)
29
- return;
33
+ return [];
30
34
  left = true;
31
35
  disarmImplWidget();
32
36
  disarmImplementationGuard();
37
+ return releaseTaskDir();
33
38
  };
34
39
  }
@@ -178,6 +178,7 @@ export declare class TaskRunner {
178
178
  */
179
179
  private _recordHandoff;
180
180
  private _deliverSpec;
181
+ private _reportRestored;
181
182
  /**
182
183
  * The spec as the implementer should receive it (Layer B). Layer A strips phantom
183
184
  * specifiers from the upstream pipeline text, but a residual affirmative can survive
@@ -27,7 +27,8 @@ import { allocateTaskId, ensureTasksDir, mergeTaskSection, readSection, readTask
27
27
  import { startWidget } from './widget.js';
28
28
  import { setupImplWidget } from './impl-widget.js';
29
29
  import { enterImplementationTurn } from './implementation-scope.js';
30
- import { SessionUI, publishNotify, registerBridgeCommand, getBridge, notifyBoth, isRemoteOrigin } from '../remote/bridge.js';
30
+ import { taskDirRestoredNotice } from './task-dir-custody.js';
31
+ import { SessionUI, publishNotify, registerBridgeCommand, getBridge, notifyBoth, notifyRun, isRemoteOrigin } from '../remote/bridge.js';
31
32
  import { pushNotify } from '../remote/push.js';
32
33
  import { getConfig } from '../config/config.js';
33
34
  import { appendDebugLine, gateDebugWriter } from './debug-log.js';
@@ -473,7 +474,10 @@ export class TaskRunner {
473
474
  label: this._widgetState.label
474
475
  };
475
476
  if (this._sendSpec) {
476
- const leave = enterImplementationTurn(meta, { oneShot: !this._implAwaited });
477
+ const leave = await enterImplementationTurn(meta, {
478
+ oneShot: !this._implAwaited,
479
+ cwd: this._cwd
480
+ });
477
481
  let delivered = false;
478
482
  try {
479
483
  await this._sendSpec(spec);
@@ -486,14 +490,14 @@ export class TaskRunner {
486
490
  // missing model, or a failed auth. The next unrelated turn would
487
491
  // inherit it, and this guard can end a turn outright.
488
492
  if (this._implAwaited || !delivered)
489
- leave();
493
+ this._reportRestored(await leave());
490
494
  }
491
495
  return;
492
496
  }
493
497
  if (!piApi) {
494
498
  throw new Error('extension not initialised (no ExtensionAPI captured)');
495
499
  }
496
- const leave = enterImplementationTurn(meta, { oneShot: true });
500
+ const leave = await enterImplementationTurn(meta, { oneShot: true, cwd: this._cwd });
497
501
  // Same reason as the awaited path's `delivered` flag: this send can throw
498
502
  // SYNCHRONOUSLY — the loader gates every ExtensionAPI action behind
499
503
  // `assertActive()` — and a guard left armed over a turn that never starts
@@ -507,10 +511,17 @@ export class TaskRunner {
507
511
  piApi.sendUserMessage(spec, { deliverAs: 'followUp' });
508
512
  }
509
513
  catch (e) {
510
- leave();
514
+ await leave();
511
515
  throw e;
512
516
  }
513
517
  }
518
+ _reportRestored(names) {
519
+ if (names.length === 0)
520
+ return;
521
+ const notice = taskDirRestoredNotice('The implementation turn', names);
522
+ this._deps.logDebug?.(notice);
523
+ notifyRun(this._ctx, notice, 'warning');
524
+ }
514
525
  /**
515
526
  * The spec as the implementer should receive it (Layer B). Layer A strips phantom
516
527
  * specifiers from the upstream pipeline text, but a residual affirmative can survive
@@ -22,7 +22,7 @@ import type { ToolingClass } from './run-context.js';
22
22
  * a recommendation instead of being taken silently.
23
23
  * - 'threw' — the child failed; there is no recommendation at all.
24
24
  */
25
- export type AutoAnswerUnknownReason = 'model-unknown' | 'api-synthesis' | 'integration' | 'threw';
25
+ export type AutoAnswerUnknownReason = 'model-unknown' | 'api-synthesis' | 'integration' | 'threw' | 'deferred-breakage';
26
26
  export type AutoAnswer = {
27
27
  kind: 'answered';
28
28
  text: string;
@@ -18,6 +18,7 @@ import { getConfig } from '../config/config.js';
18
18
  import { buildExternalContext, gatherExternalContext } from './external-context.js';
19
19
  import { currentRunContext } from './run-context.js';
20
20
  import { readableMentions } from './mentions.js';
21
+ import { defersBreakage, deferredBreakageReaskHint } from './deferred-breakage.js';
21
22
  import { REFINE_PROMPT, RESEARCH_FILES_PROMPT, RESEARCH_APIS_PROMPT, RESEARCH_CONTEXT_PROMPT, RESEARCH_TOOLING_PROMPT, GRILL_GEN_PROMPT, GRILL_AUTO_ANSWER_PROMPT, GRILL_AUTO_FORMAT_HINT, COMPOSE_PROMPT, CRITIQUE_PROMPT, CRITIQUE_TRIAGE_PROMPT, VERIFY_TOOLING_PROMPT, MAX_GRILL_QUESTIONS } from './prompts.js';
22
23
  import { appendGateRecord, readSection, readTaskFile, removeTaskSection, setTaskSection, updateTaskFrontMatter } from './task-io.js';
23
24
  import { extractSection } from './task-parsers.js';
@@ -770,6 +771,37 @@ export async function phaseAutoAnswer(deps, refined, research, question) {
770
771
  }
771
772
  }
772
773
  }
774
+ // Deterministic backstop behind the prompt's GREEN-SUITE CHECK: an answer
775
+ // that defers a breakage to "the test owner" gets ONE re-ask, and a second
776
+ // deferral is surfaced as an unsafe unknown — yolo.ts skips it, a human
777
+ // sees it. Promoting it is how mx5-n TASK_0004 turned the suite red for
778
+ // the rest of the run.
779
+ if (parsed.kind === 'answered' && defersBreakage(parsed.text)) {
780
+ deps.logDebug?.('grill-auto: answer defers a breakage to a nonexistent owner — re-asking once');
781
+ let reasked = null;
782
+ try {
783
+ const text2 = await runPhaseChild(deps, 'grill-auto', 'read', prependHint(deferredBreakageReaskHint(parsed.text), basePrompt));
784
+ if (autoAnswerHasTag(text2))
785
+ reasked = parseAutoAnswer(text2);
786
+ }
787
+ catch (e) {
788
+ if (isFatalChildCause(e))
789
+ throw e;
790
+ reasked = null;
791
+ }
792
+ if (reasked !== null && reasked.kind === 'answered' && !defersBreakage(reasked.text)) {
793
+ parsed = reasked;
794
+ }
795
+ else {
796
+ deps.logDebug?.('grill-auto: answer still defers the breakage — surfacing to user');
797
+ parsed = {
798
+ kind: 'unknown',
799
+ suggested: reasked?.kind === 'answered' ? reasked.text : parsed.text,
800
+ raw: (reasked ?? parsed).raw,
801
+ reason: 'deferred-breakage'
802
+ };
803
+ }
804
+ }
773
805
  // Surviving-unknown routing: an integration / build-wiring unknown whose
774
806
  // wrong guess is a structural landmine must NOT be silently auto-answered.
775
807
  // We first try to ground it from fetched docs (the enrichment fan-out
@@ -36,6 +36,15 @@ declare const RESEARCH_APIS_PROMPT: (refined: string, filesMap: string | undefin
36
36
  declare const RESEARCH_CONTEXT_PROMPT: (refined: string) => string;
37
37
  declare const RESEARCH_TOOLING_PROMPT: (refined: string) => string;
38
38
  declare const GRILL_GEN_PROMPT: (refined: string, research: string, priorQA: string) => string;
39
+ /**
40
+ * The first triage check. MEASURED (mx5-n TASK_0004, 2026-09-17): the auto-answer
41
+ * chose "flag the test breakage as a known issue for the test owner". No task owns
42
+ * a test, so the suite stayed red through four tasks, and the fifth was specced
43
+ * as "add three files, change nothing else, make the whole suite pass" — an
44
+ * unsatisfiable spec whose implementer looped until the runaway guard fired.
45
+ * Exported on its own so a harness can measure the prompt with and without it.
46
+ */
47
+ export declare const GRILL_GREEN_SUITE_CHECK = "1. GREEN-SUITE CHECK \u2014 if an option would leave an existing test, lint, or build failing after this task (\"known issue\", \"for the test owner\", \"whoever revisits it\", \"a later step will fix it\"), that option is NOT available. Nobody downstream owns a breakage: every later task inherits a red suite it is told not to touch, and the run stalls there. A scope rule such as \"only create file X\" does NOT settle this \u2014 a test that asserts on X's output is part of X's change, so updating that test is in scope. If one option keeps the suite green, emit \"ANSWER: <that option>, and update <the test file> so the suite stays green\". If no offered option does, emit \"ANSWER: <the smallest change that keeps the suite green, naming the test file to update>\". Never answer with a deferral.";
39
48
  declare const GRILL_AUTO_ANSWER_PROMPT: (refined: string, research: string, question: string) => string;
40
49
  export declare const GRILL_AUTO_FORMAT_HINT: string;
41
50
  declare function composeRetryEmphasis(problem: string): string;
@@ -208,6 +208,15 @@ ${research}
208
208
 
209
209
  Answers so far:
210
210
  ${priorQA.trim() || '(none yet)'}`;
211
+ /**
212
+ * The first triage check. MEASURED (mx5-n TASK_0004, 2026-09-17): the auto-answer
213
+ * chose "flag the test breakage as a known issue for the test owner". No task owns
214
+ * a test, so the suite stayed red through four tasks, and the fifth was specced
215
+ * as "add three files, change nothing else, make the whole suite pass" — an
216
+ * unsatisfiable spec whose implementer looped until the runaway guard fired.
217
+ * Exported on its own so a harness can measure the prompt with and without it.
218
+ */
219
+ export const GRILL_GREEN_SUITE_CHECK = `1. GREEN-SUITE CHECK — if an option would leave an existing test, lint, or build failing after this task ("known issue", "for the test owner", "whoever revisits it", "a later step will fix it"), that option is NOT available. Nobody downstream owns a breakage: every later task inherits a red suite it is told not to touch, and the run stalls there. A scope rule such as "only create file X" does NOT settle this — a test that asserts on X's output is part of X's change, so updating that test is in scope. If one option keeps the suite green, emit "ANSWER: <that option>, and update <the test file> so the suite stays green". If no offered option does, emit "ANSWER: <the smallest change that keeps the suite green, naming the test file to update>". Never answer with a deferral.`;
211
220
  const GRILL_AUTO_ANSWER_PROMPT = (refined, research, question) => `You are pre-answering a clarifying question for an AI coding task. You have the refined task and the research notes. You may use the read tool on files mentioned in the research (e.g. package.json) if it helps.
212
221
 
213
222
  Your job is to produce a recommended default. If the default is one the user would almost certainly accept without thinking, tag it ANSWER and skip the user. Otherwise tag it UNKNOWN. YOU MUST PROPOSE A DEFAULT — never refuse, never leave it empty.
@@ -223,11 +232,13 @@ API-GROUNDING RULE: never name a concrete API (\`Namespace.member\`, an imported
223
232
 
224
233
  TRIAGE — run these checks IN ORDER first. The REVERSIBILITY TEST below applies ONLY to a question that survives all checks as a genuine preference.
225
234
 
226
- 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".)
235
+ ${GRILL_GREEN_SUITE_CHECK}
236
+
237
+ 2. 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".)
227
238
 
228
- 2. FUNCTIONAL-REQUIREMENT CHECK — if the question is whether to include or defer a package, config file, or setup that something THIS task configures needs in order to FUNCTION (a build plugin's engine or required peer dependency, an entry file the build reads, a runtime module an import resolves to), then a "minimize / keep it minimal / defer to the step that uses it" preference does NOT override that functional requirement. A tool you wire up this step must have its required pieces present this step or the build/step is broken. Emit "ANSWER: <include it now, because configuring X requires it>". Do not defer something the step's own configuration depends on.
239
+ 3. FUNCTIONAL-REQUIREMENT CHECK — if the question is whether to include or defer a package, config file, or setup that something THIS task configures needs in order to FUNCTION (a build plugin's engine or required peer dependency, an entry file the build reads, a runtime module an import resolves to), then a "minimize / keep it minimal / defer to the step that uses it" preference does NOT override that functional requirement. A tool you wire up this step must have its required pieces present this step or the build/step is broken. Emit "ANSWER: <include it now, because configuring X requires it>". Do not defer something the step's own configuration depends on.
229
240
 
230
- 3. PREFERENCE — only if neither check fires (a genuine choice the sources do not determine), apply the REVERSIBILITY TEST.
241
+ 4. PREFERENCE — only if no check fires (a genuine choice the sources do not determine), apply the REVERSIBILITY TEST.
231
242
 
232
243
  REVERSIBILITY TEST:
233
244
  ANSWER: cheap to undo (output style, policy, report format, obvious scope, standard convention).
@@ -56,6 +56,21 @@ export declare function discoverHealthCommands(cwd: string): {
56
56
  ecosystem: string | null;
57
57
  cmds: HealthCommand[];
58
58
  };
59
+ /**
60
+ * The project's OWN test commands, in the order the run-end gate runs them. One
61
+ * statement for both gates: final-gate.ts appends `build` to this list for the
62
+ * integration half, and `runRepoHealthCheck` runs it under `withTests` for the
63
+ * per-task differential, so the two cannot drift apart.
64
+ *
65
+ * Every test-shaped script, not just the one literally named `test`: a project's
66
+ * only browser-executing suite is often `test:ct`, and looking for `test` alone
67
+ * never runs it. Plain `test` leads, then every `test:`/`test_`/`test-` name in
68
+ * declaration order (Array#sort is stable).
69
+ */
70
+ export declare function discoverTestCommands(cwd: string): {
71
+ ecosystem: string | null;
72
+ cmds: HealthCommand[];
73
+ };
59
74
  /** Progress hook: called with each command's label as it STARTS, so a caller can
60
75
  * keep a live status line naming what is currently running. */
61
76
  export type HealthProgress = (command: string) => void;
@@ -86,4 +101,7 @@ export declare function runRepoHealthCheck(cwd: string, opts?: {
86
101
  onCommand?: HealthProgress;
87
102
  /** The spawner. Injected so a verdict is testable without a real shell. */
88
103
  run?: CommandRunner;
104
+ /** Also run the project's test commands, after the statics. Only a caller
105
+ * that will judge the result DIFFERENTIALLY may set this — see the header. */
106
+ withTests?: boolean;
89
107
  }): Promise<HealthOutcome>;
@@ -14,11 +14,18 @@
14
14
  * verify gate's `repo-health` FAIL (verify-work.ts), which reaches the
15
15
  * AUTOFIX / ACCEPT picker in verify-resolution.ts.
16
16
  *
17
- * Scope is deliberately STATIC ANALYSIS ONLY (lint / typecheck / clippy / vet), never
18
- * `test`, `build`, `run`, or anything that boots a server or needs a database. Those
19
- * depend on external services the verify prompt already carves out as an environment
20
- * gap, so running them here would blame code for a missing database. Static analysis
21
- * is hermetic: it needs no network, no service and no fixtures.
17
+ * Scope is STATIC ANALYSIS (lint / typecheck / clippy / vet) by default, never
18
+ * `build`, `run`, or anything that boots a server. Static analysis is hermetic: it
19
+ * needs no network, no service and no fixtures, so its absolute exit code decides.
20
+ *
21
+ * The TEST suite joins only under `withTests`, and only for the DIFFERENTIAL
22
+ * (health-baseline.ts). MEASURED (mx5-n TASK_0004, 2026-09-17): a task turned a
23
+ * green suite red, its spec called that a "known issue for the test owner", the
24
+ * model gate passed it, and four tasks later the run stalled on an unsatisfiable
25
+ * spec. A suite that needs a database fails the same way before and after the
26
+ * task, which the differential reads as pre-existing — so running it here does
27
+ * not blame code for a missing database, and DOES blame the task that broke a
28
+ * suite the baseline saw green.
22
29
  *
23
30
  * Absence is a PASS, two ways: (1) no recognised manifest at all (a pure-docs or
24
31
  * config-only repo has nothing that can regress); (2) a manifest with no static-check
@@ -118,6 +125,43 @@ export function discoverHealthCommands(cwd) {
118
125
  }
119
126
  return { ecosystem: null, cmds: [] };
120
127
  }
128
+ /**
129
+ * The project's OWN test commands, in the order the run-end gate runs them. One
130
+ * statement for both gates: final-gate.ts appends `build` to this list for the
131
+ * integration half, and `runRepoHealthCheck` runs it under `withTests` for the
132
+ * per-task differential, so the two cannot drift apart.
133
+ *
134
+ * Every test-shaped script, not just the one literally named `test`: a project's
135
+ * only browser-executing suite is often `test:ct`, and looking for `test` alone
136
+ * never runs it. Plain `test` leads, then every `test:`/`test_`/`test-` name in
137
+ * declaration order (Array#sort is stable).
138
+ */
139
+ export function discoverTestCommands(cwd) {
140
+ if (existsSync(path.join(cwd, 'package.json'))) {
141
+ const s = packageScripts(cwd);
142
+ const names = Object.keys(s).filter(n => n === 'test' || /^test[:_-]/.test(n));
143
+ names.sort((a, b) => a === 'test' ? -1
144
+ : b === 'test' ? 1
145
+ : 0);
146
+ return { ecosystem: 'package.json', cmds: names.map(n => ['bun', ['run', n]]) };
147
+ }
148
+ if (existsSync(path.join(cwd, 'Makefile'))) {
149
+ return {
150
+ ecosystem: 'Makefile',
151
+ cmds: makeHasTarget(cwd, 'test') ? [['make', ['test']]] : []
152
+ };
153
+ }
154
+ if (existsSync(path.join(cwd, 'Cargo.toml'))) {
155
+ return { ecosystem: 'Cargo.toml', cmds: [['cargo', ['test', '--quiet']]] };
156
+ }
157
+ if (existsSync(path.join(cwd, 'go.mod'))) {
158
+ return { ecosystem: 'go.mod', cmds: [['go', ['test', './...']]] };
159
+ }
160
+ if (existsSync(path.join(cwd, 'pyproject.toml'))) {
161
+ return { ecosystem: 'pyproject.toml', cmds: [['pytest', ['-q']]] };
162
+ }
163
+ return { ecosystem: null, cmds: [] };
164
+ }
121
165
  /** The nothing-to-run outcome, shared by both runners. */
122
166
  function noCommandOutcome(ecosystem) {
123
167
  return {
@@ -150,12 +194,18 @@ function noCommandOutcome(ecosystem) {
150
194
  * widget has just been cleared.
151
195
  */
152
196
  export async function runRepoHealthCheck(cwd, opts = {}) {
153
- const { ecosystem, cmds } = discoverHealthCommands(cwd);
197
+ const statics = discoverHealthCommands(cwd);
198
+ const tests = opts.withTests ? discoverTestCommands(cwd) : { ecosystem: null, cmds: [] };
199
+ const ecosystem = statics.ecosystem ?? tests.ecosystem;
200
+ const cmds = [
201
+ ...statics.cmds.map(([bin, args]) => ({ bin, args, test: false })),
202
+ ...tests.cmds.map(([bin, args]) => ({ bin, args, test: true }))
203
+ ];
154
204
  if (!ecosystem || cmds.length === 0)
155
205
  return noCommandOutcome(ecosystem);
156
206
  const run = opts.run ?? spawnCommand;
157
207
  const commands = [];
158
- for (const [bin, args] of cmds) {
208
+ for (const { bin, args, test } of cmds) {
159
209
  const cmd = `${bin} ${args.join(' ')}`;
160
210
  opts.onCommand?.(cmd);
161
211
  // Runner resolution: a PATH-stripped environment must not
@@ -175,12 +225,11 @@ export async function runRepoHealthCheck(cwd, opts = {}) {
175
225
  // ladder's `tail` keeps 400 characters, and that difference is real — a
176
226
  // truncated lint report is unactionable. So the run is classified, not
177
227
  // consumed: the verdict decides, the raw streams are what we show.
178
- // `runtimeGap: false` this ladder is NARROWER than the gate's. The
179
- // browser/runtime row was written for the gate's TEST commands; here the
180
- // commands are lint and typecheck, and its pattern matches ordinary
181
- // English, so a genuine report quoting "browsers are not installed" would
182
- // skip the static check and certify the repo healthy.
183
- const verdict = classifyCommandRun(r, [], { runtimeGap: false });
228
+ // `runtimeGap` only for a TEST command. The browser/runtime row was
229
+ // written for the gate's test commands and its pattern matches ordinary
230
+ // English, so on lint and typecheck a genuine report quoting "browsers are
231
+ // not installed" would skip the static check and certify the repo healthy.
232
+ const verdict = classifyCommandRun(r, [], { runtimeGap: test });
184
233
  if (verdict.outcome !== 'fail') {
185
234
  const passed = verdict.outcome === 'pass';
186
235
  commands.push({ cmd, outcome: passed ? 'pass' : 'skip', exitCode: passed ? 0 : null });
@@ -197,7 +246,7 @@ export async function runRepoHealthCheck(cwd, opts = {}) {
197
246
  }
198
247
  return {
199
248
  ok: true,
200
- reason: `${ecosystem}: static checks passed`,
249
+ reason: `${ecosystem}: static checks${tests.cmds.length > 0 ? ' and tests' : ''} passed`,
201
250
  ecosystem,
202
251
  commands,
203
252
  output: ''
@@ -0,0 +1,24 @@
1
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
2
+ /** File name → bytes, for the held files only. */
3
+ export type TaskDirSnapshot = ReadonlyMap<string, Buffer>;
4
+ export declare function snapshotTaskDir(cwd: string): Promise<TaskDirSnapshot>;
5
+ /** Rewrite every snapshotted file that changed or is gone. Never throws.
6
+ * Returns the names it put back, sorted. */
7
+ export declare function restoreTaskDir(cwd: string, snapshot: TaskDirSnapshot): Promise<string[]>;
8
+ export declare function taskDirRestoredNotice(who: string, names: readonly string[]): string;
9
+ /**
10
+ * Snapshot before the turn can start, replacing any custody still held.
11
+ * `oneShot` means the settle ends it; otherwise the returned release does, and
12
+ * it never releases a custody taken since.
13
+ */
14
+ export declare function takeTaskDirCustody(cwd: string, opts: {
15
+ oneShot: boolean;
16
+ }): Promise<() => Promise<string[]>>;
17
+ export declare function releaseTaskDirCustody(): Promise<string[]>;
18
+ /** @internal Test seam: is a turn's task dir held? */
19
+ export declare function taskDirCustodyHeld(): boolean;
20
+ /**
21
+ * pi awaits these handlers before it wakes a command waiting for idle, so a
22
+ * one-shot restore finishes before the next run writes anything.
23
+ */
24
+ export declare function registerTaskDirCustody(pi: ExtensionAPI): void;
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Custody of the task dir while a model can write to it.
3
+ *
4
+ * Every markdown file in `.pi-tasks/` is host state. A model reaches it through
5
+ * edit, write or bash, and pi rewrites a path before it writes (`@x`, `file://`),
6
+ * so a rule on the tool call sees only some of the writers. The host snapshots
7
+ * the files before the model runs and puts back whatever changed. MEASURED: an
8
+ * mx5 implementer wrote its report over TASK_AUTO_0001.md, and the run died on
9
+ * its front matter.
10
+ *
11
+ * Only markdown is held: pi-task's own tools write research-cache.json mid-turn.
12
+ * A file created in the window is kept, because a run started meanwhile
13
+ * allocates its own.
14
+ */
15
+ import * as fsp from 'node:fs/promises';
16
+ import * as path from 'node:path';
17
+ import { notifyRun } from '../remote/bridge.js';
18
+ import { tasksDir } from './task-io.js';
19
+ import { TASKS_DIR_NAME } from './task-types.js';
20
+ export async function snapshotTaskDir(cwd) {
21
+ const dir = tasksDir(cwd);
22
+ const out = new Map();
23
+ const entries = await fsp.readdir(dir, { withFileTypes: true }).catch(() => []);
24
+ for (const e of entries) {
25
+ if (!e.isFile() || !e.name.endsWith('.md'))
26
+ continue;
27
+ const bytes = await fsp.readFile(path.join(dir, e.name)).catch(() => null);
28
+ if (bytes)
29
+ out.set(e.name, bytes);
30
+ }
31
+ return out;
32
+ }
33
+ /** Rewrite every snapshotted file that changed or is gone. Never throws.
34
+ * Returns the names it put back, sorted. */
35
+ export async function restoreTaskDir(cwd, snapshot) {
36
+ const dir = tasksDir(cwd);
37
+ const restored = [];
38
+ for (const [name, bytes] of snapshot) {
39
+ const file = path.join(dir, name);
40
+ const now = await fsp.readFile(file).catch(() => null);
41
+ if (now?.equals(bytes))
42
+ continue;
43
+ try {
44
+ await fsp.mkdir(dir, { recursive: true });
45
+ await fsp.writeFile(file, bytes);
46
+ restored.push(name);
47
+ }
48
+ catch {
49
+ // The host's next read of this file reports what is still wrong.
50
+ }
51
+ }
52
+ return restored.sort();
53
+ }
54
+ export function taskDirRestoredNotice(who, names) {
55
+ const list = names.map(n => `${TASKS_DIR_NAME}/${n}`).join(', ');
56
+ return `${who} changed ${list}, which only pi-task writes. Restored.`;
57
+ }
58
+ /** The implementation turn's custody. One slot: one task runs at a time. */
59
+ let held = null;
60
+ async function release(custody) {
61
+ if (held !== custody)
62
+ return [];
63
+ held = null;
64
+ return restoreTaskDir(custody.cwd, custody.snapshot);
65
+ }
66
+ /**
67
+ * Snapshot before the turn can start, replacing any custody still held.
68
+ * `oneShot` means the settle ends it; otherwise the returned release does, and
69
+ * it never releases a custody taken since.
70
+ */
71
+ export async function takeTaskDirCustody(cwd, opts) {
72
+ const custody = { cwd, snapshot: await snapshotTaskDir(cwd), oneShot: opts.oneShot };
73
+ held = custody;
74
+ return () => release(custody);
75
+ }
76
+ export function releaseTaskDirCustody() {
77
+ return held ? release(held) : Promise.resolve([]);
78
+ }
79
+ /** @internal Test seam: is a turn's task dir held? */
80
+ export function taskDirCustodyHeld() {
81
+ return held !== null;
82
+ }
83
+ /**
84
+ * pi awaits these handlers before it wakes a command waiting for idle, so a
85
+ * one-shot restore finishes before the next run writes anything.
86
+ */
87
+ export function registerTaskDirCustody(pi) {
88
+ pi.on('agent_settled', async (_event, ctx) => {
89
+ if (!held?.oneShot)
90
+ return;
91
+ const restored = await releaseTaskDirCustody();
92
+ if (restored.length === 0)
93
+ return;
94
+ notifyRun(ctx, taskDirRestoredNotice('The implementation turn', restored), 'warning');
95
+ });
96
+ // Dropped, not restored: a snapshot this old would revert the next run's writes.
97
+ pi.on('session_shutdown', () => {
98
+ held = null;
99
+ });
100
+ }
@@ -44,11 +44,12 @@ export declare function yoloPickAnswer(enabled: boolean, opts: {
44
44
  }): YoloPick;
45
45
  /**
46
46
  * The same policy expressed over an {@link AutoAnswer}, for the grill site. Of the
47
- * four `reason` tags an unknown can carry — `api-synthesis`, `integration`,
48
- * `threw`, `model-unknown` — only ANTI-SYNTHESIS is unsafe. The other three carry
49
- * an ordinary best-effort recommendation, which is precisely what a human would be
50
- * shown as the green card. The variants are told apart by that tag, never by
51
- * pattern-matching the answer text.
47
+ * five `reason` tags an unknown can carry — `api-synthesis`, `deferred-breakage`,
48
+ * `integration`, `threw`, `model-unknown` — the first two are unsafe: one names an
49
+ * API nobody verified, the other hands a red suite to an owner nobody is. The other
50
+ * three carry an ordinary best-effort recommendation, which is precisely what a
51
+ * human would be shown as the green card. The variants are told apart by that tag,
52
+ * never by pattern-matching the answer text.
52
53
  */
53
54
  export declare function yoloPickAutoAnswer(enabled: boolean, auto: AutoAnswer): YoloPick;
54
55
  /**
package/dist/task/yolo.js CHANGED
@@ -68,11 +68,12 @@ export function yoloPickAnswer(enabled, opts) {
68
68
  }
69
69
  /**
70
70
  * The same policy expressed over an {@link AutoAnswer}, for the grill site. Of the
71
- * four `reason` tags an unknown can carry — `api-synthesis`, `integration`,
72
- * `threw`, `model-unknown` — only ANTI-SYNTHESIS is unsafe. The other three carry
73
- * an ordinary best-effort recommendation, which is precisely what a human would be
74
- * shown as the green card. The variants are told apart by that tag, never by
75
- * pattern-matching the answer text.
71
+ * five `reason` tags an unknown can carry — `api-synthesis`, `deferred-breakage`,
72
+ * `integration`, `threw`, `model-unknown` — the first two are unsafe: one names an
73
+ * API nobody verified, the other hands a red suite to an owner nobody is. The other
74
+ * three carry an ordinary best-effort recommendation, which is precisely what a
75
+ * human would be shown as the green card. The variants are told apart by that tag,
76
+ * never by pattern-matching the answer text.
76
77
  */
77
78
  export function yoloPickAutoAnswer(enabled, auto) {
78
79
  if (!enabled)
@@ -84,6 +85,9 @@ export function yoloPickAutoAnswer(enabled, auto) {
84
85
  ...(auto.alt !== undefined && { alt: auto.alt }),
85
86
  ...(auto.reason === 'api-synthesis' && {
86
87
  unsafe: 'the suggested answer names an unverified API identifier — needs a human'
88
+ }),
89
+ ...(auto.reason === 'deferred-breakage' && {
90
+ unsafe: 'the suggested answer leaves a test or build failing for an owner that does not exist — needs a human'
87
91
  })
88
92
  });
89
93
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.42.1",
3
+ "version": "0.42.3",
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",