@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 +3 -1
- package/dist/task/plan-orchestrator.d.ts +15 -0
- package/dist/task/plan-orchestrator.js +84 -5
- package/dist/task/plan-readonly.d.ts +47 -0
- package/dist/task/plan-readonly.js +67 -0
- package/dist/task/task-gates.d.ts +23 -0
- package/dist/task/task-gates.js +74 -12
- package/package.json +1 -1
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
|
-
|
|
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
|
-
/**
|
|
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,
|
|
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
|
-
//
|
|
208
|
-
// a
|
|
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
|
package/dist/task/task-gates.js
CHANGED
|
@@ -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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
232
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|