@mjasnikovs/pi-task 0.18.28 → 0.18.30

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.
@@ -61,6 +61,21 @@ export interface PiTaskConfig {
61
61
  * a true hang doesn't cost half an hour of dead time.
62
62
  */
63
63
  requestTimeoutMs: number;
64
+ /**
65
+ * UNATTENDED AUTO-PICK (see task/yolo.ts): wherever pi-task would stop and ask,
66
+ * take the option it already marks RECOMMENDED, stamp the artifact `(YOLO)` so
67
+ * an audit can tell a machine decided, and never notify. Lets a local model run
68
+ * a throwaway/test project end to end with nobody watching.
69
+ *
70
+ * Decided PER SITE, before the prompt is built — so the lone prompt notification
71
+ * (SessionUI.ask) is suppressed structurally, and the existing unattended budgets
72
+ * (MAX_AUTO_AUTOFIX, MAX_FINAL_GATE_AUTOFIX) still bound the loops they were
73
+ * written to bound. An auto-pick may cost time, never work: a question with no
74
+ * recommendation, or one the anti-synthesis guard demoted, is SKIPPED, not
75
+ * invented.
76
+ * DEFAULT OFF — this is never the behaviour of a normal, watched run.
77
+ */
78
+ yoloMode: boolean;
64
79
  }
65
80
  /**
66
81
  * The command-watchdog timeout choices offered by /task-config, newest-first in
@@ -38,7 +38,9 @@ const DEFAULTS = {
38
38
  researchCache: true,
39
39
  searchProvider: 'exa',
40
40
  extensionWhitelist: [],
41
- requestTimeoutMs: DEFAULT_REQUEST_TIMEOUT_MS
41
+ requestTimeoutMs: DEFAULT_REQUEST_TIMEOUT_MS,
42
+ // OFF: auto-answering is for unattended throwaway runs only.
43
+ yoloMode: false
42
44
  };
43
45
  /**
44
46
  * A hand-edited config can hold anything; keep only string entries so a stray
@@ -67,6 +69,11 @@ if (!G.loaded) {
67
69
  delete parsed.searchProvider;
68
70
  parsed.extensionWhitelist = sanitizeExtensionWhitelist(parsed.extensionWhitelist);
69
71
  parsed.requestTimeoutMs = sanitizeRequestTimeoutMs(parsed.requestTimeoutMs);
72
+ // A hand-edited `"yoloMode": "false"` is a truthy string — it must not
73
+ // silently switch a watched run into unattended auto-pick. Only a real
74
+ // boolean counts; anything else falls back to the OFF default.
75
+ if (typeof parsed.yoloMode !== 'boolean')
76
+ delete parsed.yoloMode;
70
77
  G.config = { ...DEFAULTS, ...parsed };
71
78
  }
72
79
  catch {
@@ -107,6 +107,16 @@ const ITEMS = [
107
107
  + 'unbounded',
108
108
  // Display human labels; the stored config value stays the ms number.
109
109
  values: COMMAND_TIMEOUT_OPTIONS.map(o => o.label)
110
+ },
111
+ {
112
+ id: 'yoloMode',
113
+ label: 'yolo mode',
114
+ description: 'UNATTENDED: auto-answer every question with the option pi already recommends, '
115
+ + 'and show no prompts at all — clarify/grill answers, the verify-FAIL picker '
116
+ + '(auto-ACCEPT, recorded as a yolo debt), and the final-gate picker (autofix '
117
+ + 'while the budget lasts, then leave the run FAILED). Every auto-pick is stamped '
118
+ + '(YOLO) in the task file and debt ledger. For THROWAWAY/TEST projects you are '
119
+ + 'not watching — a real run should decide these itself'
110
120
  }
111
121
  ];
112
122
  /** Human label for the stored command-timeout ms (falls back to the raw ms). */
@@ -43,6 +43,14 @@ export interface WatchdogDeps {
43
43
  /** Invoked when a command overruns: each adapter aborts/kills here. */
44
44
  onFire: (toolCallId: string, toolName: string, timeoutMs: number) => void;
45
45
  }
46
+ /**
47
+ * Stable substring of {@link reminderMessage}, used by the steer loop
48
+ * (orchestrator steerUntilDone) to recognise the watchdog's follow-up turn in
49
+ * the session entries — the artifact that distinguishes a watchdog abort from a
50
+ * human ESC. Interpolated into the message so the detector and the text cannot
51
+ * drift apart.
52
+ */
53
+ export declare const WATCHDOG_CANCEL_MARKER = "was automatically cancelled \u2014 it looked stuck.";
46
54
  /**
47
55
  * MAIN-SESSION reminder, delivered as a follow-up turn after ctx.abort() has
48
56
  * cancelled the offending tool call. The session is still alive and remembers
@@ -44,6 +44,14 @@ function correction() {
44
44
  + `timeout — set the bash tool's \`timeout\` parameter (in seconds) so it cannot hang `
45
45
  + `again — or break it into smaller steps. Do NOT simply retry the same unbounded command.`);
46
46
  }
47
+ /**
48
+ * Stable substring of {@link reminderMessage}, used by the steer loop
49
+ * (orchestrator steerUntilDone) to recognise the watchdog's follow-up turn in
50
+ * the session entries — the artifact that distinguishes a watchdog abort from a
51
+ * human ESC. Interpolated into the message so the detector and the text cannot
52
+ * drift apart.
53
+ */
54
+ export const WATCHDOG_CANCEL_MARKER = 'was automatically cancelled — it looked stuck.';
47
55
  /**
48
56
  * MAIN-SESSION reminder, delivered as a follow-up turn after ctx.abort() has
49
57
  * cancelled the offending tool call. The session is still alive and remembers
@@ -51,7 +59,7 @@ function correction() {
51
59
  */
52
60
  export function reminderMessage(toolName, timeoutMs) {
53
61
  return (`[SYSTEM] Your \`${toolName}\` call ran longer than ${minutes(timeoutMs)} `
54
- + `and was automatically cancelled — it looked stuck. `
62
+ + `and ${WATCHDOG_CANCEL_MARKER} `
55
63
  + correction());
56
64
  }
57
65
  /**
@@ -11,6 +11,11 @@
11
11
  * created files need a tsconfig registration every spec forbids). Cross-task
12
12
  * contradiction: no unattended re-run can converge, so the gate loop records the
13
13
  * defect and routes to the human picker instead of burning AUTOFIX rounds.
14
+ * - 'yolo-accepted' — YOLO MODE (unattended auto-pick, see yolo.ts) took the
15
+ * verify-FAIL picker's terminal option with nobody watching. It is NOT the
16
+ * 'accepted' class and must never collapse into it: 'accepted' asserts a HUMAN
17
+ * weighed this failing artifact and blessed it, which is exactly the assurance
18
+ * an auto-pick cannot give. Same re-check treatment, honest provenance.
14
19
  * - 'cross-task-deletion' — the task's work DELETED a sibling task's committed
15
20
  * deliverable (mx5 run 12 PROMPT 2: a fix child deleted TASK_0020's playwright ct
16
21
  * files to green a lint) and the user ACCEPTed the verify-FAIL anyway, so the
@@ -18,7 +23,7 @@
18
23
  * resolved iff the named file is back in the tree (a later task restored it),
19
24
  * otherwise surfaced.
20
25
  */
21
- export type DebtOrigin = 'accepted' | 'enforce-revert' | 'frozen-blocked' | 'cross-task-deletion';
26
+ export type DebtOrigin = 'accepted' | 'enforce-revert' | 'frozen-blocked' | 'cross-task-deletion' | 'yolo-accepted';
22
27
  /** One recorded defect: the task, why its VERIFY failed, and how it was recorded. */
23
28
  export interface AcceptDebt {
24
29
  taskId: string;
@@ -77,6 +82,13 @@ export declare function recordCrossTaskDeletionDebt(cwd: string, taskId: string,
77
82
  path: string;
78
83
  owner: string;
79
84
  }): Promise<void>;
85
+ /**
86
+ * Record a YOLO-ACCEPTED debt: unattended auto-pick took the verify-FAIL picker's
87
+ * ACCEPT branch because there was nobody to ask (yolo.ts). Its own origin — and
88
+ * therefore its own line in the final gate's surfaced report — so a later audit
89
+ * reading only the artifacts can never read it as "a human decided this".
90
+ */
91
+ export declare function recordYoloAcceptDebt(cwd: string, taskId: string, reason: string): Promise<void>;
80
92
  /**
81
93
  * The deleted path a cross-task-deletion debt names (the fixed shape
82
94
  * recordCrossTaskDeletionDebt writes). Null on any other reason text — an
@@ -76,7 +76,8 @@ export function parseAcceptDebts(raw) {
76
76
  reason: parts[1].trim(),
77
77
  ...((origin === 'enforce-revert'
78
78
  || origin === 'frozen-blocked'
79
- || origin === 'cross-task-deletion') ?
79
+ || origin === 'cross-task-deletion'
80
+ || origin === 'yolo-accepted') ?
80
81
  { origin: origin }
81
82
  : {})
82
83
  });
@@ -173,6 +174,19 @@ export async function recordCrossTaskDeletionDebt(cwd, taskId, deletion) {
173
174
  origin: 'cross-task-deletion'
174
175
  });
175
176
  }
177
+ /**
178
+ * Record a YOLO-ACCEPTED debt: unattended auto-pick took the verify-FAIL picker's
179
+ * ACCEPT branch because there was nobody to ask (yolo.ts). Its own origin — and
180
+ * therefore its own line in the final gate's surfaced report — so a later audit
181
+ * reading only the artifacts can never read it as "a human decided this".
182
+ */
183
+ export async function recordYoloAcceptDebt(cwd, taskId, reason) {
184
+ await appendDebt(cwd, {
185
+ taskId: taskId.trim(),
186
+ reason: normaliseReason(reason),
187
+ origin: 'yolo-accepted'
188
+ });
189
+ }
176
190
  /**
177
191
  * The deleted path a cross-task-deletion debt names (the fixed shape
178
192
  * recordCrossTaskDeletionDebt writes). Null on any other reason text — an
@@ -324,5 +338,8 @@ export function describeDebt(d) {
324
338
  if (d.origin === 'cross-task-deletion') {
325
339
  return "a sibling task's committed deliverable was DELETED by this task's work and the deletion was accepted (still missing from the tree)";
326
340
  }
341
+ if (d.origin === 'yolo-accepted') {
342
+ return 'auto-ACCEPTED by YOLO mode despite verify-FAIL (unattended — no human weighed this)';
343
+ }
327
344
  return 'accepted despite verify-FAIL';
328
345
  }
@@ -31,6 +31,7 @@ import { runFinalIntegrationGate } from './final-gate.js';
31
31
  import { describeDebt } from './accept-debt.js';
32
32
  import { classifyFinalGateAnswer, MAX_FINAL_GATE_AUTOFIX, FINAL_LEAVE_LABEL, FINAL_LEAVE_VALUE, FINAL_ACCEPT_LABEL, FINAL_ACCEPT_VALUE, FINAL_AUTOFIX_LABEL, FINAL_AUTOFIX_VALUE } from './final-gate-fix.js';
33
33
  import { getConfig } from '../config/config.js';
34
+ import { isYoloMode, yoloPickAnswer, yoloFinalGateChoice, YOLO_STAMP } from './yolo.js';
34
35
  import { configureResearchRun } from '../workers/research-cache.js';
35
36
  import { CONTRACT_EXTRACT_PROMPT, parseContractLines, keepGroundedContracts, appendContracts } from './contracts.js';
36
37
  import { reconcileTitleSources } from './decompose-fidelity.js';
@@ -409,6 +410,20 @@ export async function planAuto(ctx, cwd, feature, deps) {
409
410
  // the recommended one tinted green; an open question shows the bare text
410
411
  // prompt. No verbose "Recommended:" / "press Enter to accept" scaffolding.
411
412
  const twoOption = plainSuggested !== undefined && plainAlt !== undefined;
413
+ // YOLO: take the recommended option (index 0 / the green card) without ever
414
+ // building the prompt. Clarify has no anti-synthesis channel — it runs before
415
+ // any research — so the only step-aside here is a question that carries no
416
+ // recommendation to take; that one is skipped rather than guessed.
417
+ const yolo = yoloPickAnswer(isYoloMode(), {
418
+ ...(plainSuggested !== undefined && { suggested: plainSuggested }),
419
+ ...(plainAlt !== undefined && { alt: plainAlt })
420
+ });
421
+ if (yolo !== null) {
422
+ const auto = yolo.kind === 'answer' ? yolo.answer : `(skipped — ${yolo.note})`;
423
+ answers.push(`Q${answers.length + 1}: ${plainQ}\n`
424
+ + `A${answers.length + 1}: ${auto} ${YOLO_STAMP}`);
425
+ continue;
426
+ }
412
427
  const options = twoOption ?
413
428
  [
414
429
  {
@@ -1005,21 +1020,41 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
1005
1020
  + (fixAttempts > 0 ?
1006
1021
  `\n\nAutofix attempts so far: ${fixAttempts}/${MAX_FINAL_GATE_AUTOFIX}.`
1007
1022
  : '');
1008
- const answer = await new SessionUI(active).ask({
1009
- localTitle: 'Final integration gate failed — how should pi proceed?',
1010
- displayQuestion: question,
1011
- question,
1012
- recommended: FINAL_LEAVE_LABEL,
1013
- recommended2: canAutofix ? FINAL_AUTOFIX_LABEL : FINAL_ACCEPT_LABEL,
1014
- allowSkip: false,
1015
- options: [
1016
- { label: FINAL_LEAVE_LABEL, value: FINAL_LEAVE_VALUE },
1017
- ...(canAutofix ?
1018
- [{ label: FINAL_AUTOFIX_LABEL, value: FINAL_AUTOFIX_VALUE }]
1019
- : []),
1020
- { label: FINAL_ACCEPT_LABEL, value: FINAL_ACCEPT_VALUE }
1021
- ]
1022
- });
1023
+ // YOLO: keep autofixing WHILE the card is still offered — the
1024
+ // loop withdraws it after MAX_FINAL_GATE_AUTOFIX, so the cap
1025
+ // that bounds a non-converging fix pass still bounds this —
1026
+ // then LEAVE the run failed. Never 'accept': an unattended run
1027
+ // that could not green the whole-repo gate has not produced a
1028
+ // working project, and mx5 run 13 shows what an accepted FAIL
1029
+ // looks like afterwards (a shipped app that 404s at `/`).
1030
+ const yoloFinal = yoloFinalGateChoice(isYoloMode(), canAutofix);
1031
+ if (yoloFinal !== null) {
1032
+ await recGate(`final-gate: auto-chose ${yoloFinal.action.toUpperCase()} ${YOLO_STAMP}`);
1033
+ }
1034
+ const answer = yoloFinal !== null ?
1035
+ yoloFinal.action === 'autofix' ?
1036
+ FINAL_AUTOFIX_VALUE
1037
+ : FINAL_LEAVE_VALUE
1038
+ : await new SessionUI(active).ask({
1039
+ localTitle: 'Final integration gate failed — how should pi proceed?',
1040
+ displayQuestion: question,
1041
+ question,
1042
+ recommended: FINAL_LEAVE_LABEL,
1043
+ recommended2: canAutofix ? FINAL_AUTOFIX_LABEL : FINAL_ACCEPT_LABEL,
1044
+ allowSkip: false,
1045
+ options: [
1046
+ { label: FINAL_LEAVE_LABEL, value: FINAL_LEAVE_VALUE },
1047
+ ...(canAutofix ?
1048
+ [
1049
+ {
1050
+ label: FINAL_AUTOFIX_LABEL,
1051
+ value: FINAL_AUTOFIX_VALUE
1052
+ }
1053
+ ]
1054
+ : []),
1055
+ { label: FINAL_ACCEPT_LABEL, value: FINAL_ACCEPT_VALUE }
1056
+ ]
1057
+ });
1023
1058
  const choice = classifyFinalGateAnswer(answer);
1024
1059
  if (choice.action === 'accept') {
1025
1060
  await recGate('final-gate: FAIL accepted by user');
@@ -1064,7 +1099,9 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
1064
1099
  }
1065
1100
  // Leave failed — the dismissal default, unchanged from the
1066
1101
  // two-option picker (an unavailable autofix demotes here too).
1067
- await recGate('final-gate: left failed (user)');
1102
+ await recGate(yoloFinal !== null ?
1103
+ `final-gate: left failed — autofix budget spent, nobody to ask ${YOLO_STAMP}`
1104
+ : 'final-gate: left failed (user)');
1068
1105
  await updateTaskFrontMatter(cwd, id, { state: 'failed' });
1069
1106
  announceDone(active, `${id} finished all tasks but FAILED the final integration gate — ${fin.reason.slice(0, 200)} — fix and /task-auto-resume (the gate re-runs).`, 'error');
1070
1107
  return;
@@ -24,7 +24,11 @@ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
24
24
  * them; their equivalent guard lives in runWorker (workers/pi-worker-core.ts)
25
25
  * and shares the same machine from shared/command-watchdog.ts.
26
26
  */
27
- export { CommandWatchdog, commandTimeoutHint, realTimerDeps, reminderMessage, type TimerHandle, type WatchdogDeps } from '../shared/command-watchdog.js';
27
+ export { CommandWatchdog, commandTimeoutHint, realTimerDeps, reminderMessage, WATCHDOG_CANCEL_MARKER, type TimerHandle, type WatchdogDeps } from '../shared/command-watchdog.js';
28
+ /** @internal Set by onFire when it aborts a turn. Exported for the adapter and tests. */
29
+ export declare function noteWatchdogAbort(): void;
30
+ /** True exactly once per watchdog abort; clears the flag. */
31
+ export declare function consumeWatchdogAbort(): boolean;
28
32
  /**
29
33
  * Wire the watchdog into the main session. Only ever active in the host session
30
34
  * (children run `--no-extensions`), which is exactly where the observed hangs
@@ -27,7 +27,31 @@ import { CommandWatchdog, realTimerDeps, reminderMessage } from '../shared/comma
27
27
  */
28
28
  // Re-exported so existing importers (and the machine's own tests) keep their
29
29
  // entry point while the implementation lives in shared/.
30
- export { CommandWatchdog, commandTimeoutHint, realTimerDeps, reminderMessage } from '../shared/command-watchdog.js';
30
+ export { CommandWatchdog, commandTimeoutHint, realTimerDeps, reminderMessage, WATCHDOG_CANCEL_MARKER } from '../shared/command-watchdog.js';
31
+ /**
32
+ * One-shot marker: the most recent turn abort was issued BY THE WATCHDOG, not by
33
+ * a human ESC. Both end the assistant turn with stopReason 'aborted' — the only
34
+ * signal steerUntilDone's wasInterrupted() can read — so without this flag the
35
+ * steer loop can win the race against the watchdog's queued follow-up turn and
36
+ * show a steering prompt to an empty room (wedging an unattended run).
37
+ *
38
+ * Set synchronously in onFire BEFORE ctx.abort(), so it is observable by the
39
+ * time any waitForIdle resolves; consumed (cleared) by the first reader. A stale
40
+ * flag (the aborted turn wasn't one the steer loop was watching) only costs the
41
+ * consumer a bounded wait before it falls back to prompting — it can never
42
+ * permanently suppress a human's steer prompt.
43
+ */
44
+ let watchdogAbortPending = false;
45
+ /** @internal Set by onFire when it aborts a turn. Exported for the adapter and tests. */
46
+ export function noteWatchdogAbort() {
47
+ watchdogAbortPending = true;
48
+ }
49
+ /** True exactly once per watchdog abort; clears the flag. */
50
+ export function consumeWatchdogAbort() {
51
+ const was = watchdogAbortPending;
52
+ watchdogAbortPending = false;
53
+ return was;
54
+ }
31
55
  /**
32
56
  * Wire the watchdog into the main session. Only ever active in the host session
33
57
  * (children run `--no-extensions`), which is exactly where the observed hangs
@@ -46,8 +70,12 @@ export function registerCommandWatchdog(pi) {
46
70
  ctxByCall.delete(toolCallId);
47
71
  // Cancel the stuck command (kills the tool's whole process tree via
48
72
  // the turn's AbortSignal), then start a fresh turn telling the model
49
- // to bound its next attempt.
50
- ctx?.abort();
73
+ // to bound its next attempt. The flag must precede the abort so the
74
+ // steer loop can never observe the 'aborted' turn before the flag.
75
+ if (ctx) {
76
+ noteWatchdogAbort();
77
+ ctx.abort();
78
+ }
51
79
  pi.sendUserMessage(reminderMessage(toolName, timeoutMs), { deliverAs: 'followUp' });
52
80
  }
53
81
  });
@@ -21,7 +21,7 @@ import { runGuidelineEnforcement, classifyEnforceChildFailure } from './enforce-
21
21
  import { runWorkVerification, extractSpecForVerification } from './verify-work.js';
22
22
  import { readEnvNotes, appendEnvNotes } from './env-notes.js';
23
23
  import { readContracts } from './contracts.js';
24
- import { recordAcceptDebt, recordEnforceRevertDebt, recordFrozenBlockedDebt, recordCrossTaskDeletionDebt } from './accept-debt.js';
24
+ import { recordAcceptDebt, recordEnforceRevertDebt, recordFrozenBlockedDebt, recordCrossTaskDeletionDebt, recordYoloAcceptDebt } from './accept-debt.js';
25
25
  import { runRepoHealthCheck } from './repo-health-check.js';
26
26
  import { runFinalIntegrationGate, discoverGateCommandLabels } from './final-gate.js';
27
27
  import { runFinalGateAutofix } from './final-gate-fix.js';
@@ -337,6 +337,7 @@ export function buildGateDeps(params) {
337
337
  // Durable ACCEPT-despite-verify-FAIL ledger under .pi-tasks/ (survives
338
338
  // discardEdits): the final integration gate re-checks each debt at run end.
339
339
  recordAcceptDebt: (cwd2, taskId, reason) => recordAcceptDebt(cwd2, taskId, reason),
340
+ recordYoloAcceptDebt: (cwd2, taskId, reason) => recordYoloAcceptDebt(cwd2, taskId, reason),
340
341
  recordEnforceRevertDebt: (cwd2, taskId, reason) => recordEnforceRevertDebt(cwd2, taskId, reason),
341
342
  // Durable cross-task-contradiction ledger (PROMPT 1 layer B): a repo-health
342
343
  // FAIL whose only fix is an edit to a path this task's spec froze — recorded
@@ -212,6 +212,41 @@ export declare const MAX_COMPACTION_RESUMES = 20;
212
212
  * of resumes performed (0 when the turn did not end on a compaction).
213
213
  */
214
214
  export declare function resumeAcrossCompactions(ctx: SteerCtx): Promise<number>;
215
+ /**
216
+ * Timing knobs for the watchdog-abort guard in {@link steerUntilDone}, injectable
217
+ * so tests exercise the grace expiry without a 10-second wait. `graceMs` bounds
218
+ * how long the loop waits for the watchdog's follow-up to be DELIVERED (not to
219
+ * finish — its turn may legitimately run for minutes afterwards); delivery is
220
+ * normally near-instant, so the grace only expires on a stale flag.
221
+ */
222
+ export interface SteerWatchdogDeps {
223
+ consume: () => boolean;
224
+ graceMs: number;
225
+ pollMs: number;
226
+ }
227
+ /**
228
+ * After the implementation turn settles, honour a user ESC by letting them steer.
229
+ *
230
+ * `waitForIdle` resolves both on natural completion AND on an ESC (which aborts
231
+ * the turn → idle). When the last turn was aborted, the host's main input loop is
232
+ * blocked inside our command handler, so a message typed in the editor would only
233
+ * queue, never run (interactive-mode routes idle input through onInputCallback,
234
+ * which is unset while we hold the loop). We therefore solicit the steering text
235
+ * ourselves and feed it back as another turn via sendUserMessage — which runs to
236
+ * completion when the session is idle. Repeat until a turn finishes uninterrupted.
237
+ *
238
+ * A WATCHDOG abort also ends the turn with stopReason 'aborted' — indistinguishable
239
+ * from a human ESC by the session entries alone at that instant. The watchdog
240
+ * queues its own recovery follow-up, so prompting there would show a steering
241
+ * dialog to an empty room and wedge an unattended run on the race. The one-shot
242
+ * flag (set synchronously before the abort) routes that case to
243
+ * {@link awaitWatchdogFollowUp} instead; a stale flag degrades to a bounded wait
244
+ * followed by the ordinary prompt, never to a suppressed one.
245
+ *
246
+ * Returns true when the user declined to steer (empty/cancelled) and the run
247
+ * should pause; false when the implementation completed (steered or not).
248
+ */
249
+ export declare function steerUntilDone(ctx: SteerCtx, promptSteer?: (ctx: ExtensionCommandContext) => Promise<string | undefined>, watchdog?: Partial<SteerWatchdogDeps>): Promise<boolean>;
215
250
  /**
216
251
  * Run one prompt through the full single-task pipeline in a fresh session and
217
252
  * deliver its spec. With waitForImplementation, block until the agent finishes
@@ -28,6 +28,7 @@ import { armImplWidget, disarmImplWidget, setupImplWidget } from './impl-widget.
28
28
  import { publishViewer, publishNotify, publishLifecycleNotice, registerBridgeCommand, getBridge, SessionUI } from '../remote/bridge.js';
29
29
  import { pushNotify } from '../remote/push.js';
30
30
  import { getConfig } from '../config/config.js';
31
+ import { consumeWatchdogAbort, WATCHDOG_CANCEL_MARKER } from './command-watchdog.js';
31
32
  import { buildGateDeps } from './gate-deps.js';
32
33
  import { runGatesForTask } from './task-gates.js';
33
34
  import { parseVerifyBlock } from './spec-validation.js';
@@ -462,6 +463,67 @@ export async function resumeAcrossCompactions(ctx) {
462
463
  }
463
464
  return resumes;
464
465
  }
466
+ /**
467
+ * True when the watchdog's reminder follow-up has been DELIVERED into the session
468
+ * after the aborted assistant turn but its own turn has not finished yet — the
469
+ * artifact that confirms a pending watchdog recovery. Scoped after the LAST
470
+ * assistant entry so an earlier fire's reminder (already answered by its own
471
+ * turn) never matches.
472
+ */
473
+ function watchdogReminderDelivered(ctx) {
474
+ const entries = ctx.sessionManager.getEntries();
475
+ let lastAssistant = -1;
476
+ for (let i = 0; i < entries.length; i++) {
477
+ const e = entries[i];
478
+ if ('message' in e && 'role' in e.message && e.message.role === 'assistant') {
479
+ lastAssistant = i;
480
+ }
481
+ }
482
+ for (let i = lastAssistant + 1; i < entries.length; i++) {
483
+ const e = entries[i];
484
+ if (!('message' in e) || !('role' in e.message) || e.message.role !== 'user')
485
+ continue;
486
+ const content = e.message.content;
487
+ const text = typeof content === 'string' ? content
488
+ : Array.isArray(content) ?
489
+ content
490
+ .map(b => b !== null && typeof b === 'object' && 'text' in b ?
491
+ String(b.text)
492
+ : '')
493
+ .join(' ')
494
+ : '';
495
+ if (text.includes(WATCHDOG_CANCEL_MARKER))
496
+ return true;
497
+ }
498
+ return false;
499
+ }
500
+ const STEER_WATCHDOG_DEFAULTS = {
501
+ consume: consumeWatchdogAbort,
502
+ graceMs: 10_000,
503
+ pollMs: 100
504
+ };
505
+ /**
506
+ * Wait for a watchdog abort's queued follow-up turn instead of prompting. The
507
+ * abort and the reminder follow-up are two separate steps in the watchdog's
508
+ * onFire, so the steer loop can observe the aborted turn before the reminder is
509
+ * delivered — poll (bounded) until it lands or the follow-up turn has already
510
+ * completed. True = recovery observed, re-check the loop; false = grace expired
511
+ * with no reminder (stale flag) — fall back to the human prompt.
512
+ */
513
+ async function awaitWatchdogFollowUp(ctx, wd) {
514
+ const deadline = Date.now() + wd.graceMs;
515
+ for (;;) {
516
+ if (!wasInterrupted(ctx))
517
+ return true; // follow-up turn already completed
518
+ if (watchdogReminderDelivered(ctx)) {
519
+ await ctx.waitForIdle(); // let the follow-up turn run to completion
520
+ return true;
521
+ }
522
+ if (Date.now() >= deadline)
523
+ return false;
524
+ await new Promise(r => setTimeout(r, wd.pollMs));
525
+ }
526
+ }
465
527
  /**
466
528
  * After the implementation turn settles, honour a user ESC by letting them steer.
467
529
  *
@@ -473,10 +535,19 @@ export async function resumeAcrossCompactions(ctx) {
473
535
  * ourselves and feed it back as another turn via sendUserMessage — which runs to
474
536
  * completion when the session is idle. Repeat until a turn finishes uninterrupted.
475
537
  *
538
+ * A WATCHDOG abort also ends the turn with stopReason 'aborted' — indistinguishable
539
+ * from a human ESC by the session entries alone at that instant. The watchdog
540
+ * queues its own recovery follow-up, so prompting there would show a steering
541
+ * dialog to an empty room and wedge an unattended run on the race. The one-shot
542
+ * flag (set synchronously before the abort) routes that case to
543
+ * {@link awaitWatchdogFollowUp} instead; a stale flag degrades to a bounded wait
544
+ * followed by the ordinary prompt, never to a suppressed one.
545
+ *
476
546
  * Returns true when the user declined to steer (empty/cancelled) and the run
477
547
  * should pause; false when the implementation completed (steered or not).
478
548
  */
479
- async function steerUntilDone(ctx, promptSteer) {
549
+ export async function steerUntilDone(ctx, promptSteer, watchdog) {
550
+ const wd = { ...STEER_WATCHDOG_DEFAULTS, ...watchdog };
480
551
  // Fan the prompt out through the bridge (local TUI input + remote browser
481
552
  // card, first answer wins) instead of a raw ctx.ui.input: an interrupt can
482
553
  // come from the remote Stop button just as well as a terminal ESC, and a
@@ -491,6 +562,8 @@ async function steerUntilDone(ctx, promptSteer) {
491
562
  allowSkip: true
492
563
  }));
493
564
  while (wasInterrupted(ctx)) {
565
+ if (wd.consume() && (await awaitWatchdogFollowUp(ctx, wd)))
566
+ continue;
494
567
  const steer = await ask(ctx);
495
568
  if (steer === undefined || steer.trim().length === 0)
496
569
  return true; // pause
@@ -3,6 +3,23 @@
3
3
  *
4
4
  * Pure functions that parse raw model output into structured data.
5
5
  */
6
+ /**
7
+ * WHY an auto-answer came back `unknown` — the producers are otherwise
8
+ * indistinguishable at the call site, and they are NOT equivalent: only
9
+ * 'api-synthesis' marks a recommendation a machine must never take (see
10
+ * task/yolo.ts). Mechanical tag, never a text pattern-match:
11
+ * - 'model-unknown' — the child itself emitted UNKNOWN (or the parser salvaged a
12
+ * bare recommendation): a genuine open fork carrying a best-effort suggestion.
13
+ * - 'api-synthesis' — the answer was ANSWERED, then DEMOTED because it names an
14
+ * API identifier absent from the research and the question, in a namespace the
15
+ * research covers (mx5 run 13: an invented `Bun.mkdirSync` reached requirements
16
+ * AND the VERIFY block). The suggestion rides along for a HUMAN to judge.
17
+ * - 'integration' — an integration/build-wiring unknown no fetched doc grounded;
18
+ * a wrong guess is a structural landmine, so the model's answer is offered as
19
+ * a recommendation instead of being taken silently.
20
+ * - 'threw' — the child failed; there is no recommendation at all.
21
+ */
22
+ export type AutoAnswerUnknownReason = 'model-unknown' | 'api-synthesis' | 'integration' | 'threw';
6
23
  export type AutoAnswer = {
7
24
  kind: 'answered';
8
25
  text: string;
@@ -12,6 +29,7 @@ export type AutoAnswer = {
12
29
  suggested?: string;
13
30
  alt?: string;
14
31
  raw: string;
32
+ reason?: AutoAnswerUnknownReason;
15
33
  };
16
34
  /** One /task-auto clarify question with its model-recommended default answer. */
17
35
  export interface ClarifyQuestion {
@@ -148,7 +148,8 @@ export function parseAutoAnswer(raw) {
148
148
  kind: 'unknown',
149
149
  ...(suggested !== undefined && { suggested }),
150
150
  ...(alt !== undefined && { alt }),
151
- raw
151
+ raw,
152
+ reason: 'model-unknown'
152
153
  };
153
154
  }
154
155
  // Last-resort salvage: the model emitted no tag at all. Take the first line
@@ -159,8 +160,8 @@ export function parseAutoAnswer(raw) {
159
160
  // answer than to pre-fill a meaningless preamble.
160
161
  const salvaged = lines.find(l => !l.endsWith(':'));
161
162
  if (salvaged)
162
- return { kind: 'unknown', suggested: salvaged, raw };
163
- return { kind: 'unknown', raw };
163
+ return { kind: 'unknown', suggested: salvaged, raw, reason: 'model-unknown' };
164
+ return { kind: 'unknown', raw, reason: 'model-unknown' };
164
165
  }
165
166
  // ─── Verify tooling output parser ────────────────────────────────────────────
166
167
  export function parseVerifyToolingOutput(output) {
@@ -37,6 +37,7 @@ import { readContracts, buildContractsBlock, buildContractsVerifyBlock } from '.
37
37
  import { readRequirements, buildRequirementsBlock } from './requirements.js';
38
38
  import { runPhaseChild, runPhaseWithLoopGuard, runWithEmphasisRetry, prependHint, USER_CANCELLED } from './child-runner.js';
39
39
  import { SessionUI } from '../remote/bridge.js';
40
+ import { isYoloMode, yoloPickAutoAnswer, YOLO_STAMP } from './yolo.js';
40
41
  // ─── Re-export constants from their home modules ────────────────────────────
41
42
  export { MAX_GRILL_QUESTIONS };
42
43
  // ─── Tooling helpers ─────────────────────────────────────────────────────────
@@ -665,7 +666,15 @@ export async function phaseAutoAnswer(deps, refined, research, question, autoDep
665
666
  const still = reasked ?? parsed;
666
667
  const suggested = still.kind === 'answered' ? still.text : parsed.text;
667
668
  deps.logDebug?.('grill-auto: answer still carries an unverified API — surfacing to user');
668
- parsed = { kind: 'unknown', suggested, raw: still.raw };
669
+ parsed = {
670
+ kind: 'unknown',
671
+ suggested,
672
+ raw: still.raw,
673
+ // Tagged so a call site can tell this producer from the other
674
+ // two: the suggestion is PROVEN to name an unverified API, so
675
+ // it may only be judged by a human (yolo.ts must not take it).
676
+ reason: 'api-synthesis'
677
+ };
669
678
  }
670
679
  else {
671
680
  parsed = reasked;
@@ -683,13 +692,13 @@ export async function phaseAutoAnswer(deps, refined, research, question, autoDep
683
692
  if (parsed.kind === 'answered' && !docResolved && isIntegrationUnknown(question)) {
684
693
  deps.logDebug?.(`grill-auto: integration unknown unresolved by fetch — surfacing to user `
685
694
  + `instead of auto-answering: ${question.replace(/\s+/g, ' ').slice(0, 120)}`);
686
- return { kind: 'unknown', suggested: parsed.text, raw: parsed.raw };
695
+ return { kind: 'unknown', suggested: parsed.text, raw: parsed.raw, reason: 'integration' };
687
696
  }
688
697
  return parsed;
689
698
  }
690
699
  catch (err) {
691
700
  const msg = err instanceof Error ? err.message : String(err);
692
- return { kind: 'unknown', raw: `(threw: ${msg})` };
701
+ return { kind: 'unknown', raw: `(threw: ${msg})`, reason: 'threw' };
693
702
  }
694
703
  }
695
704
  export async function phaseGrill(deps, ctx, widgetState, refined, research) {
@@ -755,6 +764,21 @@ export async function phaseGrill(deps, ctx, widgetState, refined, research) {
755
764
  // locally — each answer in its own bounding box, the recommended one
756
765
  // tinted green; an open question shows the bare text prompt.
757
766
  const twoOption = plainSuggested !== undefined && plainAlt !== undefined;
767
+ // YOLO: take the recommended option and never build the prompt (which
768
+ // is also what suppresses its notification — see yolo.ts). An answer the
769
+ // anti-synthesis guard demoted, or a question with no recommendation at
770
+ // all, is SKIPPED instead: costing the spec one unanswered fork is the
771
+ // guard direction, promoting a hallucination is not.
772
+ const yolo = yoloPickAutoAnswer(isYoloMode(), auto);
773
+ if (yolo !== null) {
774
+ answer =
775
+ yolo.kind === 'answer' ?
776
+ stripInlineMarkdown(yolo.answer)
777
+ : `(skipped — ${yolo.note})`;
778
+ out.push(`A${n + 1}: ${answer} ${YOLO_STAMP}`);
779
+ qa.push(`Q${n + 1}: ${plainQ}\nA${n + 1}: ${answer} ${YOLO_STAMP}`);
780
+ continue;
781
+ }
758
782
  const options = twoOption ?
759
783
  [
760
784
  {
@@ -127,6 +127,14 @@ export interface GateDeps {
127
127
  * and surfaces it if still open. Best-effort; absent in tests → no ledger written.
128
128
  */
129
129
  recordAcceptDebt?: (cwd: string, taskId: string, reason: string) => Promise<void>;
130
+ /**
131
+ * Record a durable YOLO-ACCEPTED debt: the same ACCEPT branch, but reached by an
132
+ * unattended auto-pick (yolo mode) rather than a human. Separate dep — and
133
+ * separate ledger origin — because collapsing the two would let an auto-pick
134
+ * read as "a human blessed this" in the final gate's run-end report. Best-effort;
135
+ * absent in tests → no ledger written.
136
+ */
137
+ recordYoloAcceptDebt?: (cwd: string, taskId: string, reason: string) => Promise<void>;
130
138
  /**
131
139
  * Record a durable ENFORCE-REVERT debt (mx5 run 10 item 3): the enforce re-verify
132
140
  * FAILED and the enforce edits were reverted, but the FAIL indicts the ORIGINAL
@@ -1,5 +1,6 @@
1
1
  import { resolutionOptions, classifyResolutionAnswer } from './verify-resolution.js';
2
2
  import { SessionUI } from '../remote/bridge.js';
3
+ import { isYoloMode, yoloVerifyResolution, YOLO_STAMP } from './yolo.js';
3
4
  /**
4
5
  * How many times a verify FAIL may be auto-fixed UNATTENDED (the research
5
6
  * recommended AUTOFIX, so pi re-runs the impl turn without prompting) before the
@@ -178,8 +179,19 @@ export async function runGatesForTask(ctxIn, deps, p) {
178
179
  && !isFrozenBlocked
179
180
  && recOutcome.recommend === 'autofix'
180
181
  && autoFixCount < MAX_AUTO_AUTOFIX;
182
+ // YOLO: the picker is unreachable with nobody watching, and by the time
183
+ // we are here the unattended AUTOFIX budget is ALREADY spent (autoFixNow
184
+ // is false) — so the only option left that terminates is ACCEPT, recorded
185
+ // as its own 'yolo-accepted' debt. Deliberately NOT a re-entry into
186
+ // autofix: MAX_AUTO_AUTOFIX exists to break a non-converging loop, and an
187
+ // auto-pick here would restart the budget from the site that proves it ran out.
188
+ const yoloChoice = autoFixNow ? null : yoloVerifyResolution(isYoloMode());
181
189
  let choice;
182
- if (autoFixNow) {
190
+ if (yoloChoice !== null) {
191
+ choice = yoloChoice;
192
+ await rec(`resolution: auto-ACCEPTED despite verify FAIL — autofix budget spent, nobody to ask ${YOLO_STAMP}`);
193
+ }
194
+ else if (autoFixNow) {
183
195
  autoFixCount += 1;
184
196
  await rec(`resolution: auto-AUTOFIX (recommended, unattended ${autoFixCount}/${MAX_AUTO_AUTOFIX})`);
185
197
  active.ui.notify(`${p.tag}: verify FAIL on "${p.title}" — auto-fixing (recommended, ${autoFixCount}/${MAX_AUTO_AUTOFIX})…`, 'info');
@@ -193,7 +205,9 @@ export async function runGatesForTask(ctxIn, deps, p) {
193
205
  return { kind: 'paused', ctx: active, reason: failReason };
194
206
  }
195
207
  if (choice.action === 'accept') {
196
- await rec('resolution: user ACCEPTED the work despite verify FAIL');
208
+ const byYolo = yoloChoice !== null;
209
+ if (!byYolo)
210
+ await rec('resolution: user ACCEPTED the work despite verify FAIL');
197
211
  // Durable debt: the human blessed a FAILing artifact as-is, so the
198
212
  // defect ships and nothing else revisits it (mx5 run 4 B3 / run 8
199
213
  // TASK_0012). Record it to the run ledger; the final integration gate
@@ -202,7 +216,15 @@ export async function runGatesForTask(ctxIn, deps, p) {
202
216
  // the contradiction named) — don't double-enter it in the ledger.
203
217
  if (!frozenDebtRecorded) {
204
218
  try {
205
- await deps.recordAcceptDebt?.(p.cwd, p.taskId, failReason);
219
+ // Provenance splits here, mandatorily: an auto-pick writes the
220
+ // 'yolo-accepted' origin, never the plain 'accepted' one that
221
+ // asserts a human weighed the failing artifact.
222
+ if (byYolo) {
223
+ await deps.recordYoloAcceptDebt?.(p.cwd, p.taskId, failReason);
224
+ }
225
+ else {
226
+ await deps.recordAcceptDebt?.(p.cwd, p.taskId, failReason);
227
+ }
206
228
  }
207
229
  catch {
208
230
  // recording must never break the gate sequence
@@ -220,7 +242,7 @@ export async function runGatesForTask(ctxIn, deps, p) {
220
242
  // recording must never break the gate sequence
221
243
  }
222
244
  }
223
- active.ui.notify(`${p.tag}: accepted "${p.title}" despite verify FAIL (${failReason.slice(0, 120)}) — proceeding.`, 'warning');
245
+ active.ui.notify(`${p.tag}: accepted "${p.title}" despite verify FAIL (${failReason.slice(0, 120)}) — proceeding.${byYolo ? ` ${YOLO_STAMP}` : ''}`, 'warning');
224
246
  break;
225
247
  }
226
248
  // AUTOFIX: re-run the implementation turn with the failure (and any typed
@@ -0,0 +1,74 @@
1
+ import type { AutoAnswer } from './parsers.js';
2
+ import type { ResolutionChoice } from './verify-resolution.js';
3
+ import type { FinalGateChoice } from './final-gate-fix.js';
4
+ /**
5
+ * Visible provenance marker on EVERY artifact an auto-pick writes (gate trails,
6
+ * task-file Q&A, the debt ledger's reason text). A later audit reading only the
7
+ * artifacts must never mistake an auto-pick for a human decision — that is the
8
+ * same confusion the accept-debt origins were introduced to prevent.
9
+ */
10
+ export declare const YOLO_STAMP = "(YOLO)";
11
+ /** Is unattended auto-pick on for this run? The ONLY config read in this module. */
12
+ export declare function isYoloMode(): boolean;
13
+ /**
14
+ * What YOLO does at a question site:
15
+ * - 'answer' — take this (the recommended option).
16
+ * - 'skip' — YOLO is on but there is nothing safe to take; leave the question
17
+ * unanswered and move on, with `note` recorded as the reason.
18
+ * - null — YOLO is off: ask the human exactly as before.
19
+ */
20
+ export type YoloPick = {
21
+ kind: 'answer';
22
+ answer: string;
23
+ } | {
24
+ kind: 'skip';
25
+ note: string;
26
+ } | null;
27
+ /**
28
+ * The clarify/grill policy: take the RECOMMENDED option — which is positional,
29
+ * index 0 of the card list (question-box.ts tints it green), i.e. `suggested`,
30
+ * falling back to the B-side `alt` when a fork offers only that.
31
+ *
32
+ * `unsafe` is the step-aside channel: a caller that KNOWS the recommendation must
33
+ * not be auto-accepted passes why, and the question is skipped instead. Its one
34
+ * producer today is the anti-synthesis demotion — an answer proven to name a
35
+ * hallucinated API identifier. Auto-accepting that would re-promote exactly the
36
+ * invention the demotion was built to stop (it reached requirements AND the VERIFY
37
+ * block in mx5 run 13), so a machine may never take it; a human still can.
38
+ */
39
+ export declare function yoloPickAnswer(enabled: boolean, opts: {
40
+ suggested?: string;
41
+ alt?: string;
42
+ unsafe?: string;
43
+ }): YoloPick;
44
+ /**
45
+ * The same policy expressed over an {@link AutoAnswer}, for the grill site. Only
46
+ * the ANTI-SYNTHESIS unknown is unsafe: the other two producers (an integration
47
+ * unknown research could not ground, a child that threw) carry an ordinary
48
+ * best-effort recommendation, which is precisely what a human would be shown as
49
+ * the green card. The variants are told apart by the union's `reason` tag — never
50
+ * by pattern-matching the answer text.
51
+ */
52
+ export declare function yoloPickAutoAnswer(enabled: boolean, auto: AutoAnswer): YoloPick;
53
+ /**
54
+ * The verify-FAIL picker policy: ACCEPT (and write a debt), never AUTOFIX.
55
+ *
56
+ * Not a preference — a bound. Every branch that REACHES this picker has already
57
+ * spent its unattended budget: the loop auto-runs AUTOFIX while the research
58
+ * recommends it, up to MAX_AUTO_AUTOFIX consecutive failures, and only then hands
59
+ * over. Answering AUTOFIX here would restart that budget from a site whose whole
60
+ * purpose is that the budget ran out. So YOLO takes the terminal option and
61
+ * records the defect ('yolo-accepted' — an origin a human never produces).
62
+ */
63
+ export declare function yoloVerifyResolution(enabled: boolean): ResolutionChoice | null;
64
+ /**
65
+ * The final-integration-gate policy: keep autofixing WHILE the picker still
66
+ * offers that card (the loop withdraws it after MAX_FINAL_GATE_AUTOFIX attempts),
67
+ * then leave the run FAILED.
68
+ *
69
+ * 'leave', not 'accept': an unattended run that cannot fix the whole-repo gate has
70
+ * not produced a working project, and the honest terminal state is a failed run a
71
+ * resume can re-enter — mx5 run 13 ended "FAIL accepted by user" on an app that
72
+ * 404'd at `/`, and that acceptance is what made the failure look like a success.
73
+ */
74
+ export declare function yoloFinalGateChoice(enabled: boolean, canAutofix: boolean): FinalGateChoice | null;
@@ -0,0 +1,112 @@
1
+ /**
2
+ * yolo — unattended auto-pick of the option pi-task already marks RECOMMENDED.
3
+ *
4
+ * GOAL: let a local model take a throwaway/test project end to end with nobody
5
+ * watching. Wherever pi-task would stop and ask, it takes the recommended option,
6
+ * STAMPS the artifact so a later audit can see a machine decided, and never
7
+ * notifies. OFF by default; never the behaviour of a normal run.
8
+ *
9
+ * WHY PER-SITE, NOT ONE HOOK (the trap this module exists to avoid): the single
10
+ * interactive choke point is SessionUI.ask() (remote/bridge.ts) — auto-picking
11
+ * "index 0" inside it would be one tiny patch, and it would be wrong. The
12
+ * verify-FAIL picker is reached ONLY AFTER MAX_AUTO_AUTOFIX unattended autofix
13
+ * attempts have already failed, yet it STILL tints AUTOFIX as recommended: a
14
+ * central hook would pick AUTOFIX forever and defeat the exact cap that exists to
15
+ * break a non-converging loop. So every site decides for itself, BEFORE ask() is
16
+ * called — which also means the prompt notification (the lone pushNotify in
17
+ * ask()) is suppressed structurally, with zero suppression code.
18
+ *
19
+ * Guard direction (repo constraint): an auto-pick may cost time, never work.
20
+ * Anything this module cannot stand behind — no recommendation to take, an answer
21
+ * the anti-synthesis guard proved unverifiable — steps ASIDE ('skip') rather than
22
+ * inventing a decision.
23
+ *
24
+ * The policy functions are PURE and take `enabled` explicitly, so each site's
25
+ * behaviour is unit-tested with the flag both ways; only {@link isYoloMode} reads
26
+ * the config.
27
+ */
28
+ import { getConfig } from '../config/config.js';
29
+ /**
30
+ * Visible provenance marker on EVERY artifact an auto-pick writes (gate trails,
31
+ * task-file Q&A, the debt ledger's reason text). A later audit reading only the
32
+ * artifacts must never mistake an auto-pick for a human decision — that is the
33
+ * same confusion the accept-debt origins were introduced to prevent.
34
+ */
35
+ export const YOLO_STAMP = '(YOLO)';
36
+ /** Is unattended auto-pick on for this run? The ONLY config read in this module. */
37
+ export function isYoloMode() {
38
+ return getConfig().yoloMode;
39
+ }
40
+ /**
41
+ * The clarify/grill policy: take the RECOMMENDED option — which is positional,
42
+ * index 0 of the card list (question-box.ts tints it green), i.e. `suggested`,
43
+ * falling back to the B-side `alt` when a fork offers only that.
44
+ *
45
+ * `unsafe` is the step-aside channel: a caller that KNOWS the recommendation must
46
+ * not be auto-accepted passes why, and the question is skipped instead. Its one
47
+ * producer today is the anti-synthesis demotion — an answer proven to name a
48
+ * hallucinated API identifier. Auto-accepting that would re-promote exactly the
49
+ * invention the demotion was built to stop (it reached requirements AND the VERIFY
50
+ * block in mx5 run 13), so a machine may never take it; a human still can.
51
+ */
52
+ export function yoloPickAnswer(enabled, opts) {
53
+ if (!enabled)
54
+ return null;
55
+ if (opts.unsafe !== undefined && opts.unsafe.length > 0) {
56
+ return { kind: 'skip', note: opts.unsafe };
57
+ }
58
+ const pick = opts.suggested ?? opts.alt;
59
+ if (pick === undefined || pick.trim().length === 0) {
60
+ return { kind: 'skip', note: 'no recommended option to take' };
61
+ }
62
+ return { kind: 'answer', answer: pick };
63
+ }
64
+ /**
65
+ * The same policy expressed over an {@link AutoAnswer}, for the grill site. Only
66
+ * the ANTI-SYNTHESIS unknown is unsafe: the other two producers (an integration
67
+ * unknown research could not ground, a child that threw) carry an ordinary
68
+ * best-effort recommendation, which is precisely what a human would be shown as
69
+ * the green card. The variants are told apart by the union's `reason` tag — never
70
+ * by pattern-matching the answer text.
71
+ */
72
+ export function yoloPickAutoAnswer(enabled, auto) {
73
+ if (!enabled)
74
+ return null;
75
+ if (auto.kind === 'answered')
76
+ return { kind: 'answer', answer: auto.text };
77
+ return yoloPickAnswer(enabled, {
78
+ ...(auto.suggested !== undefined && { suggested: auto.suggested }),
79
+ ...(auto.alt !== undefined && { alt: auto.alt }),
80
+ ...(auto.reason === 'api-synthesis' && {
81
+ unsafe: 'the suggested answer names an unverified API identifier — needs a human'
82
+ })
83
+ });
84
+ }
85
+ /**
86
+ * The verify-FAIL picker policy: ACCEPT (and write a debt), never AUTOFIX.
87
+ *
88
+ * Not a preference — a bound. Every branch that REACHES this picker has already
89
+ * spent its unattended budget: the loop auto-runs AUTOFIX while the research
90
+ * recommends it, up to MAX_AUTO_AUTOFIX consecutive failures, and only then hands
91
+ * over. Answering AUTOFIX here would restart that budget from a site whose whole
92
+ * purpose is that the budget ran out. So YOLO takes the terminal option and
93
+ * records the defect ('yolo-accepted' — an origin a human never produces).
94
+ */
95
+ export function yoloVerifyResolution(enabled) {
96
+ return enabled ? { action: 'accept' } : null;
97
+ }
98
+ /**
99
+ * The final-integration-gate policy: keep autofixing WHILE the picker still
100
+ * offers that card (the loop withdraws it after MAX_FINAL_GATE_AUTOFIX attempts),
101
+ * then leave the run FAILED.
102
+ *
103
+ * 'leave', not 'accept': an unattended run that cannot fix the whole-repo gate has
104
+ * not produced a working project, and the honest terminal state is a failed run a
105
+ * resume can re-enter — mx5 run 13 ended "FAIL accepted by user" on an app that
106
+ * 404'd at `/`, and that acceptance is what made the failure look like a success.
107
+ */
108
+ export function yoloFinalGateChoice(enabled, canAutofix) {
109
+ if (!enabled)
110
+ return null;
111
+ return canAutofix ? { action: 'autofix' } : { action: 'leave' };
112
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.18.28",
3
+ "version": "0.18.30",
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",