@mjasnikovs/pi-task 0.31.0 → 0.33.0

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/README.md CHANGED
@@ -111,7 +111,9 @@ It works like the clarify step you already know — **one question at a time, ea
111
111
 
112
112
  When the model runs out of questions it says so and offers the same three moves rather than proceeding behind your back. Answering something new re-opens it: a decision you volunteer can make a fresh question worth asking.
113
113
 
114
- Everything lands in `.pi-tasks/TASK_PLAN_NNNN.md` the task prompt, the `## decisions` transcript, and a separate `## notes` section for what you asked. Only the decisions are handed to `/task`, as an authoritative block ahead of your original prompt; the notes stay behind, because an answer you read is not a decision you made. From there it is an ordinary `/task` run same pipeline, same gates.
114
+ **Planning is read-only.** Nothing in your project is created, edited, or deleted while you plan — the planning model runs with a one-tool allowlist (`read`), which also excludes write tools contributed by any extension you have whitelisted for helper sessions. That is verified as well as prevented: the working tree is compared before and after every step, and if anything outside `.pi-tasks/` ever changes, the run says so loudly and records it in the plan file rather than carrying on quietly. Read-only ends the moment you choose **Proceed to execution** — from there it is a normal `/task` run and it writes code.
115
+
116
+ The one thing written during planning is the plan file itself, `.pi-tasks/TASK_PLAN_NNNN.md` — the task prompt, the `## decisions` transcript, and a separate `## notes` section for what you asked (a session you abandon before deciding anything deletes its own file, so an aborted plan leaves nothing at all). Only the decisions are handed to `/task`, as an authoritative block ahead of your original prompt; the notes stay behind, because an answer you read is not a decision you made. From there it is an ordinary `/task` run — same pipeline, same gates.
115
117
 
116
118
  It works from the browser too (see [Remote](#remote--drive-a-task-from-your-phone)): the prompt card grows an **Ask the model** and a **Proceed to execution** button alongside the usual Accept / Manual answer.
117
119
 
@@ -48,5 +48,20 @@ export interface PlanCommandDeps {
48
48
  /** Hand the composed prompt to /task. Returns the produced task id, if any. */
49
49
  handoff(ctx: ExtensionCommandContext, cwd: string, prompt: string): Promise<string | undefined>;
50
50
  }
51
+ /**
52
+ * Remove the plan file (and its debug log) when the session ended with nothing
53
+ * in it — the user opened /task-plan and backed out at the first question.
54
+ *
55
+ * The file is allocated up front so a crash mid-session still leaves the record,
56
+ * which means an abandoned session would otherwise leave a stub whose whole
57
+ * content is "(none yet)". That is exactly the artifact this command is not
58
+ * supposed to produce.
59
+ *
60
+ * It refuses to delete anything that carries a read-only violation: that section
61
+ * is the report of a file we could not account for, and it must outlive the
62
+ * session that found it. Best-effort — a failure here leaves the stub, which is
63
+ * harmless.
64
+ */
65
+ export declare function discardEmptyPlanFile(cwd: string, planId: string): Promise<void>;
51
66
  export declare function handleTaskPlan(args: string, ctx: ExtensionCommandContext, commandDeps?: PlanCommandDeps): Promise<void>;
52
67
  export declare function registerTaskPlan(pi: ExtensionAPI): void;
@@ -24,7 +24,10 @@ import { runPhaseChild, prependHint, USER_CANCELLED } from './child-runner.js';
24
24
  import { PLAN_QUESTION_PROMPT, PLAN_ANSWER_PROMPT } from './plan-prompts.js';
25
25
  import { runPlanSession, ASK_TITLE } from './plan-session.js';
26
26
  import { allocatePlanId, buildPlanBody, buildHandoffPrompt, formatPlanDecisions } from './plan-io.js';
27
- import { writeTaskFile, setTaskSection, updateTaskFrontMatter, tasksDir } from './task-io.js';
27
+ import { writeTaskFile, readTaskFile, setTaskSection, readSection, updateTaskFrontMatter, taskFilePath, tasksDir } from './task-io.js';
28
+ import { extractSection } from './task-parsers.js';
29
+ import { collectTreeChanges } from './gate-deps.js';
30
+ import { PLAN_TOOLS, newTreeChanges, isEmptyChange, formatReadOnlyViolation } from './plan-readonly.js';
28
31
  import { deriveTitle } from './parsers.js';
29
32
  import { renderInlineMarkdown } from './inline-markdown.js';
30
33
  import { expandFeatureMentions } from './auto-orchestrator.js';
@@ -70,7 +73,14 @@ export function buildPlanDeps(ctx, cwd, planId, task, signal) {
70
73
  },
71
74
  ...(logDebug && { logDebug })
72
75
  };
73
- /** Run a planning child under the shared /task-auto-style loader. */
76
+ /**
77
+ * Run a planning child under the shared /task-auto-style loader, with the
78
+ * read-only contract enforced around it: the child gets exactly
79
+ * {@link PLAN_TOOLS}, and the working tree is compared before and after so a
80
+ * hole in that prevention is reported instead of shipped silently. The
81
+ * comparison excludes `.pi-tasks/` (collectTreeChanges does), which is where
82
+ * the plan file itself is written.
83
+ */
74
84
  const child = async (name, prompt) => {
75
85
  lastLine = undefined;
76
86
  contextUsage = undefined;
@@ -85,11 +95,21 @@ export function buildPlanDeps(ctx, cwd, planId, task, signal) {
85
95
  lastLine,
86
96
  contextUsage
87
97
  }));
98
+ const before = await collectTreeChanges(cwd, signal).catch(() => null);
88
99
  try {
89
- return await runPhaseChild(phaseDeps, name, 'read', prompt);
100
+ return await runPhaseChild(phaseDeps, name, PLAN_TOOLS, prompt);
90
101
  }
91
102
  finally {
92
103
  stopLoader();
104
+ // Outside a git repo `before` is null and there is nothing to compare
105
+ // against — the same degrade every other tree-reading guard here takes.
106
+ if (before) {
107
+ const after = await collectTreeChanges(cwd, signal).catch(() => null);
108
+ const touched = after ? newTreeChanges(before, after) : null;
109
+ if (touched && !isEmptyChange(touched)) {
110
+ await reportReadOnlyViolation(ctx, cwd, planId, name, touched, logDebug);
111
+ }
112
+ }
93
113
  }
94
114
  };
95
115
  return {
@@ -120,6 +140,32 @@ export function buildPlanDeps(ctx, cwd, planId, task, signal) {
120
140
  ...(logDebug && { logDebug })
121
141
  };
122
142
  }
143
+ /**
144
+ * A planning step touched the project: say so on every surface and write it into
145
+ * the plan file, which is the one record that outlives the session.
146
+ *
147
+ * Deliberately NOT a rollback. If this ever fires, something wrote a file we
148
+ * cannot account for; deleting it unseen would turn a reporting bug into data
149
+ * loss. The user gets the path and decides.
150
+ */
151
+ async function reportReadOnlyViolation(ctx, cwd, planId, step, touched, logDebug) {
152
+ const line = formatReadOnlyViolation(step, touched);
153
+ logDebug?.(line);
154
+ try {
155
+ ctx.ui.notify(line, 'error');
156
+ }
157
+ catch {
158
+ /* stale ctx — the record below is what matters */
159
+ }
160
+ publishLifecycleNotice(line, 'error');
161
+ try {
162
+ const existing = (await readSection(cwd, planId, 'read-only violations')) ?? '';
163
+ await setTaskSection(cwd, planId, 'read-only violations', existing ? `${existing}\n- ${line}` : `- ${line}`);
164
+ }
165
+ catch {
166
+ /* best-effort */
167
+ }
168
+ }
123
169
  /**
124
170
  * Write the transcript into the plan file. The decisions and the model's answers
125
171
  * to the user's questions live in separate sections: only the decisions are
@@ -161,6 +207,37 @@ const DEFAULT_COMMAND_DEPS = {
161
207
  run: runPlanSession,
162
208
  handoff: defaultHandoff
163
209
  };
210
+ /**
211
+ * Remove the plan file (and its debug log) when the session ended with nothing
212
+ * in it — the user opened /task-plan and backed out at the first question.
213
+ *
214
+ * The file is allocated up front so a crash mid-session still leaves the record,
215
+ * which means an abandoned session would otherwise leave a stub whose whole
216
+ * content is "(none yet)". That is exactly the artifact this command is not
217
+ * supposed to produce.
218
+ *
219
+ * It refuses to delete anything that carries a read-only violation: that section
220
+ * is the report of a file we could not account for, and it must outlive the
221
+ * session that found it. Best-effort — a failure here leaves the stub, which is
222
+ * harmless.
223
+ */
224
+ export async function discardEmptyPlanFile(cwd, planId) {
225
+ try {
226
+ const { body } = await readTaskFile(cwd, planId);
227
+ if (extractSection(body, 'read-only violations') !== null)
228
+ return;
229
+ const decisions = extractSection(body, 'decisions');
230
+ if (decisions !== null && decisions.trim() !== '(none yet)')
231
+ return;
232
+ if (extractSection(body, 'notes') !== null)
233
+ return;
234
+ await fsp.rm(taskFilePath(cwd, planId), { force: true });
235
+ await fsp.rm(path.join(tasksDir(cwd), `${planId}-debug.log`), { force: true });
236
+ }
237
+ catch {
238
+ /* best-effort: an unreadable file is left exactly where it is */
239
+ }
240
+ }
164
241
  export async function handleTaskPlan(args, ctx, commandDeps = DEFAULT_COMMAND_DEPS) {
165
242
  await ctx.waitForIdle();
166
243
  const cwd = ctx.cwd;
@@ -204,9 +281,11 @@ export async function handleTaskPlan(args, ctx, commandDeps = DEFAULT_COMMAND_DE
204
281
  return;
205
282
  }
206
283
  if (outcome.kind === 'cancelled') {
207
- // Nothing is handed to /task, but whatever WAS decided stays on disk
208
- // a cancelled plan is a record, not a rollback.
284
+ // Whatever WAS decided stays on disk a cancelled plan is a record, not
285
+ // a rollback. A session that decided NOTHING leaves nothing at all.
209
286
  await updateTaskFrontMatter(cwd, planId, { state: 'cancelled' }).catch(() => { });
287
+ if (outcome.entries.length === 0)
288
+ await discardEmptyPlanFile(cwd, planId);
210
289
  const line = outcome.entries.length === 0 ?
211
290
  `${planId} cancelled — nothing planned.`
212
291
  : `${planId} cancelled — ${outcome.entries.length} entr${outcome.entries.length === 1 ? 'y' : 'ies'} kept in .pi-tasks/${planId}.md`;
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The read-only contract for /task-plan.
3
+ *
4
+ * Planning happens BEFORE the work is agreed, so a plan session must leave the
5
+ * project exactly as it found it: no file created, edited, or deleted. Nothing is
6
+ * built yet, so any artifact a plan produced would be an artifact nobody approved
7
+ * — and it would sit in the tree looking like part of the change the user is
8
+ * still deciding whether to make.
9
+ *
10
+ * Two layers, because one of them is somebody else's flag:
11
+ *
12
+ * PREVENTION — the planning children run with {@link PLAN_TOOLS}, an allowlist
13
+ * of exactly one tool. pi applies `--tools` to built-in, extension AND custom
14
+ * tools, so a write tool contributed by a whitelisted extension is excluded too
15
+ * (proven live — scripts/live-task-plan-readonly.ts). This is what actually
16
+ * makes the session read-only.
17
+ *
18
+ * VERIFICATION — after every child, the working tree is compared against the
19
+ * snapshot taken before it. `.pi-tasks/` is excluded (that is where the plan
20
+ * file itself lives — see collectTreeChanges), so what remains is precisely
21
+ * "did planning touch the project". If it ever fires, prevention has a hole:
22
+ * it is reported loudly and recorded in the plan file rather than silently
23
+ * tolerated. It never deletes anything — an unexplained file is a thing to show
24
+ * the user, not a thing to quietly destroy.
25
+ */
26
+ import type { TreeChangeSummary } from './write-guard.js';
27
+ /**
28
+ * The tool allowlist every /task-plan child runs under. One tool: `read`.
29
+ *
30
+ * Deliberately a named constant with a test pinning it (plan-readonly.test.ts):
31
+ * widening this string is the single edit that would end the read-only guarantee,
32
+ * and it should never happen by accident. The same value grill and clarify use
33
+ * for their generation children — planning has never needed more.
34
+ */
35
+ export declare const PLAN_TOOLS = "read";
36
+ /**
37
+ * What appeared in `after` that was not already in `before`.
38
+ *
39
+ * A set difference, not an emptiness check: /task-plan runs against whatever the
40
+ * user already has in progress, and a tree that was dirty when planning started
41
+ * is normal — only what planning ADDED is a violation.
42
+ */
43
+ export declare function newTreeChanges(before: TreeChangeSummary, after: TreeChangeSummary): TreeChangeSummary;
44
+ /** True when the summary names nothing at all. */
45
+ export declare function isEmptyChange(c: TreeChangeSummary): boolean;
46
+ /** One line naming what a planning step touched, for the notify and the record. */
47
+ export declare function formatReadOnlyViolation(step: string, c: TreeChangeSummary): string;
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The read-only contract for /task-plan.
3
+ *
4
+ * Planning happens BEFORE the work is agreed, so a plan session must leave the
5
+ * project exactly as it found it: no file created, edited, or deleted. Nothing is
6
+ * built yet, so any artifact a plan produced would be an artifact nobody approved
7
+ * — and it would sit in the tree looking like part of the change the user is
8
+ * still deciding whether to make.
9
+ *
10
+ * Two layers, because one of them is somebody else's flag:
11
+ *
12
+ * PREVENTION — the planning children run with {@link PLAN_TOOLS}, an allowlist
13
+ * of exactly one tool. pi applies `--tools` to built-in, extension AND custom
14
+ * tools, so a write tool contributed by a whitelisted extension is excluded too
15
+ * (proven live — scripts/live-task-plan-readonly.ts). This is what actually
16
+ * makes the session read-only.
17
+ *
18
+ * VERIFICATION — after every child, the working tree is compared against the
19
+ * snapshot taken before it. `.pi-tasks/` is excluded (that is where the plan
20
+ * file itself lives — see collectTreeChanges), so what remains is precisely
21
+ * "did planning touch the project". If it ever fires, prevention has a hole:
22
+ * it is reported loudly and recorded in the plan file rather than silently
23
+ * tolerated. It never deletes anything — an unexplained file is a thing to show
24
+ * the user, not a thing to quietly destroy.
25
+ */
26
+ /**
27
+ * The tool allowlist every /task-plan child runs under. One tool: `read`.
28
+ *
29
+ * Deliberately a named constant with a test pinning it (plan-readonly.test.ts):
30
+ * widening this string is the single edit that would end the read-only guarantee,
31
+ * and it should never happen by accident. The same value grill and clarify use
32
+ * for their generation children — planning has never needed more.
33
+ */
34
+ export const PLAN_TOOLS = 'read';
35
+ /**
36
+ * What appeared in `after` that was not already in `before`.
37
+ *
38
+ * A set difference, not an emptiness check: /task-plan runs against whatever the
39
+ * user already has in progress, and a tree that was dirty when planning started
40
+ * is normal — only what planning ADDED is a violation.
41
+ */
42
+ export function newTreeChanges(before, after) {
43
+ const diff = (a, b) => {
44
+ const seen = new Set(b);
45
+ return a.filter(p => !seen.has(p));
46
+ };
47
+ return {
48
+ modified: diff(after.modified, before.modified),
49
+ added: diff(after.added, before.added),
50
+ deleted: diff(after.deleted, before.deleted)
51
+ };
52
+ }
53
+ /** True when the summary names nothing at all. */
54
+ export function isEmptyChange(c) {
55
+ return c.modified.length + c.added.length + c.deleted.length === 0;
56
+ }
57
+ /** One line naming what a planning step touched, for the notify and the record. */
58
+ export function formatReadOnlyViolation(step, c) {
59
+ const parts = [];
60
+ if (c.added.length > 0)
61
+ parts.push(`created ${c.added.join(', ')}`);
62
+ if (c.modified.length > 0)
63
+ parts.push(`modified ${c.modified.join(', ')}`);
64
+ if (c.deleted.length > 0)
65
+ parts.push(`deleted ${c.deleted.join(', ')}`);
66
+ return `/task-plan is read-only, but the ${step} step ${parts.join('; ')}`;
67
+ }
@@ -285,6 +285,29 @@ export type GateResult = {
285
285
  * regardless of this count (blessing an artifact as-is is a human's call).
286
286
  */
287
287
  export declare const MAX_AUTO_AUTOFIX = 3;
288
+ /** Which of the four disjoint branches sent a FAIL to the terminal YOLO ACCEPT. */
289
+ export interface YoloAcceptContext {
290
+ /** Rule 5c: the spec-required check could not run (tooling absent). */
291
+ isUnobserved: boolean;
292
+ /** Cross-task contradiction: the repo-health fix needs a spec-frozen path. */
293
+ isFrozenBlocked: boolean;
294
+ /** What the resolution research recommended, when it was consulted at all. */
295
+ recommend: ResolutionOutcome['recommend'];
296
+ /** Unattended AUTOFIX attempts already spent on this task. */
297
+ autoFixCount: number;
298
+ }
299
+ /**
300
+ * The reason an auto-ACCEPT is being written — NAMED, not assumed.
301
+ *
302
+ * The line this replaces asserted "autofix budget spent" on every branch. It was
303
+ * measured false in 2709 of 2709 recorded accepts (scripts/yolo-accept-baserate.ts):
304
+ * the budget has never once reached MAX_AUTO_AUTOFIX anywhere in the corpus — 30%
305
+ * of accepts are UNOBSERVED (the research is never even consulted) and 70% are an
306
+ * ACCEPT recommendation with the budget fully untouched. A durable trail that
307
+ * misstates why a defect shipped is worse than no trail: mx5 run 19's TASK_0009
308
+ * reads as an exhausted fixer when nothing was ever attempted.
309
+ */
310
+ export declare function yoloAcceptReason(c: YoloAcceptContext): string;
288
311
  /**
289
312
  * Show the boxed two-choice picker after a verify FAIL and return what the user
290
313
  * decided. The model-recommended card is placed first so the renderer tints it
@@ -13,6 +13,29 @@ import { attributeEnforceFailure } from './enforce-attribution.js';
13
13
  * regardless of this count (blessing an artifact as-is is a human's call).
14
14
  */
15
15
  export const MAX_AUTO_AUTOFIX = 3;
16
+ /**
17
+ * The reason an auto-ACCEPT is being written — NAMED, not assumed.
18
+ *
19
+ * The line this replaces asserted "autofix budget spent" on every branch. It was
20
+ * measured false in 2709 of 2709 recorded accepts (scripts/yolo-accept-baserate.ts):
21
+ * the budget has never once reached MAX_AUTO_AUTOFIX anywhere in the corpus — 30%
22
+ * of accepts are UNOBSERVED (the research is never even consulted) and 70% are an
23
+ * ACCEPT recommendation with the budget fully untouched. A durable trail that
24
+ * misstates why a defect shipped is worse than no trail: mx5 run 19's TASK_0009
25
+ * reads as an exhausted fixer when nothing was ever attempted.
26
+ */
27
+ export function yoloAcceptReason(c) {
28
+ if (c.isUnobserved)
29
+ return 'verify UNOBSERVED — tooling absent, an unattended re-run cannot provision it';
30
+ if (c.isFrozenBlocked)
31
+ return 'repo-health blocked by a spec-frozen path — an impl re-run under the same freeze cannot converge';
32
+ if (c.recommend === 'autofix') {
33
+ return `autofix budget spent (${c.autoFixCount}/${MAX_AUTO_AUTOFIX})`;
34
+ }
35
+ return c.autoFixCount === 0 ?
36
+ `judge recommended ACCEPT (autofix budget 0/${MAX_AUTO_AUTOFIX} unused)`
37
+ : `judge recommended ACCEPT (autofix budget ${c.autoFixCount}/${MAX_AUTO_AUTOFIX} already spent)`;
38
+ }
16
39
  /**
17
40
  * Bound a captured health-check output before it is embedded in a gate-trail line.
18
41
  * appendGateRecord flattens newlines to spaces, so the trail stays one line per
@@ -134,6 +157,8 @@ export async function runGatesForTask(ctxIn, deps, p) {
134
157
  // defect is recorded as a durable debt for the final gate.
135
158
  let frozenContradiction = null;
136
159
  let frozenDebtRecorded = false;
160
+ // YOLO only: has the one-attempt rescue below already been spent on this task?
161
+ let yoloRescueUsed = false;
137
162
  while (!verified.ok) {
138
163
  const failReason = verified.reason ?? 'did not verify';
139
164
  // GRADUATED resolution: a repo-health FAIL (pure static findings) gets ONE
@@ -213,23 +238,54 @@ export async function runGatesForTask(ctxIn, deps, p) {
213
238
  const autoFixNow = !isUnobserved
214
239
  && !isFrozenBlocked
215
240
  && recOutcome.recommend === 'autofix'
216
- && autoFixCount < MAX_AUTO_AUTOFIX;
217
- // YOLO: the picker is unreachable with nobody watching, and by the time
218
- // we are here the unattended AUTOFIX budget is ALREADY spent (autoFixNow
219
- // is false) so the only option left that terminates is ACCEPT, recorded
220
- // as its own 'yolo-accepted' debt. Deliberately NOT a re-entry into
221
- // autofix: MAX_AUTO_AUTOFIX exists to break a non-converging loop, and an
241
+ && autoFixCount < MAX_AUTO_AUTOFIX
242
+ // A rescue attempt that still FAILed is terminal under YOLO: the
243
+ // recommendation that got us here was ACCEPT, so a later flip to
244
+ // AUTOFIX must not bootstrap the full budget from it.
245
+ && !yoloRescueUsed;
246
+ // YOLO, THE RESCUE BRANCH: the recommendation is ACCEPT, nobody can be
247
+ // asked, and the unattended budget is UNTOUCHED. Accepting here ships a
248
+ // defect having attempted nothing — mx5 run 19 did exactly that twice,
249
+ // and the final-gate autofix later fixed one of the two in a single pass
250
+ // (`0 pass 130 fail` → `121 pass 0 fail`). So spend ONE attempt first.
251
+ // Bounded by construction: one, not MAX_AUTO_AUTOFIX, so an ACCEPT
252
+ // recommendation can never restart a full loop; if it still FAILs the
253
+ // next turn falls through to the same auto-ACCEPT and the same debt.
254
+ const yoloRescueNow = isYoloMode()
255
+ && !yoloRescueUsed
256
+ && !isUnobserved
257
+ && !isFrozenBlocked
258
+ && recOutcome.recommend === 'accept'
259
+ && autoFixCount === 0;
260
+ // YOLO: the picker is unreachable with nobody watching, and every
261
+ // unattended attempt this task may make has been made — so the only
262
+ // option left that terminates is ACCEPT, recorded as its own
263
+ // 'yolo-accepted' debt. Deliberately NOT a re-entry into autofix:
264
+ // MAX_AUTO_AUTOFIX exists to break a non-converging loop, and an
222
265
  // auto-pick here would restart the budget from the site that proves it ran out.
223
- const yoloChoice = autoFixNow ? null : yoloVerifyResolution(isYoloMode());
266
+ const yoloChoice = autoFixNow || yoloRescueNow ? null : yoloVerifyResolution(isYoloMode());
224
267
  let choice;
225
268
  if (yoloChoice !== null) {
226
269
  choice = yoloChoice;
227
- await rec(`resolution: auto-ACCEPTED despite verify FAIL autofix budget spent, nobody to ask ${YOLO_STAMP}`);
270
+ // NAME the branch. This line is the durable record of why a defect
271
+ // shipped; asserting a spent budget on all four branches made run
272
+ // 19's zero-attempt accepts read as an exhausted fixer.
273
+ await rec(`resolution: auto-ACCEPTED despite verify FAIL — ${yoloAcceptReason({
274
+ isUnobserved,
275
+ isFrozenBlocked,
276
+ recommend: recOutcome.recommend,
277
+ autoFixCount
278
+ })}, nobody to ask ${YOLO_STAMP}`);
228
279
  }
229
- else if (autoFixNow) {
280
+ else if (autoFixNow || yoloRescueNow) {
230
281
  autoFixCount += 1;
231
- await rec(`resolution: auto-AUTOFIX (recommended, unattended ${autoFixCount}/${MAX_AUTO_AUTOFIX})`);
232
- active.ui.notify(`${p.tag}: verify FAIL on "${p.title}" — auto-fixing (recommended, ${autoFixCount}/${MAX_AUTO_AUTOFIX})…`, 'info');
282
+ if (yoloRescueNow)
283
+ yoloRescueUsed = true;
284
+ await rec(yoloRescueNow ?
285
+ `resolution: auto-AUTOFIX (${YOLO_STAMP} rescue — judge recommended ACCEPT with the unattended `
286
+ + `budget unspent; one attempt, ${autoFixCount}/${MAX_AUTO_AUTOFIX})`
287
+ : `resolution: auto-AUTOFIX (recommended, unattended ${autoFixCount}/${MAX_AUTO_AUTOFIX})`);
288
+ active.ui.notify(`${p.tag}: verify FAIL on "${p.title}" — auto-fixing (${yoloRescueNow ? `${YOLO_STAMP} one attempt before accepting` : 'recommended'}, ${autoFixCount}/${MAX_AUTO_AUTOFIX})…`, 'info');
233
289
  choice = { action: 'autofix' };
234
290
  }
235
291
  else {
@@ -292,7 +348,13 @@ export async function runGatesForTask(ctxIn, deps, p) {
292
348
  // hand that diagnosis to the re-run so it fixes the located cause
293
349
  // instead of re-deriving it from the bare FAIL line. Skipped when there
294
350
  // is no researched rationale beyond the failure text itself.
295
- await rec('resolution: user chose AUTOFIX re-running the implementation turn');
351
+ // Only the picker branch may claim a person chose this: the two
352
+ // unattended branches already recorded themselves one line above, and a
353
+ // trail that says "user chose" when nobody was asked is the same lie the
354
+ // accept line used to tell.
355
+ if (!autoFixNow && !yoloRescueNow) {
356
+ await rec('resolution: user chose AUTOFIX — re-running the implementation turn');
357
+ }
296
358
  active.ui.notify(`${p.tag}: autofixing "${p.title}"…`, 'info');
297
359
  const diagnosis = (recOutcome.recommend === 'autofix'
298
360
  && recOutcome.rationale.length > 0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.31.0",
3
+ "version": "0.33.0",
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",