@mjasnikovs/pi-task 0.17.0 → 0.17.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/config/register.js +2 -2
- package/dist/task/auto-orchestrator.d.ts +5 -66
- package/dist/task/auto-orchestrator.js +47 -387
- package/dist/task/gate-deps.d.ts +16 -0
- package/dist/task/gate-deps.js +221 -0
- package/dist/task/orchestrator.d.ts +21 -0
- package/dist/task/orchestrator.js +134 -1
- package/dist/task/task-gates.d.ts +148 -0
- package/dist/task/task-gates.js +135 -0
- package/dist/task/verify-work.js +61 -39
- package/package.json +1 -1
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* gate-deps — builds the concrete {@link GateDeps} (verify / enforce / recommend /
|
|
3
|
+
* commit / revert) that the post-implementation gate sequence drives.
|
|
4
|
+
*
|
|
5
|
+
* Lifted out of /task-auto's `defaultDeps` so both /task-auto and the single /task
|
|
6
|
+
* command construct the gate children identically. `runTask` (the AUTOFIX re-run) is
|
|
7
|
+
* INJECTED rather than imported, so this module never imports the orchestrators —
|
|
8
|
+
* keeping the dependency graph acyclic (the orchestrators import this).
|
|
9
|
+
*
|
|
10
|
+
* The gate children — verify, the post-FAIL recommend, and enforce — are read-only
|
|
11
|
+
* (or read,edit for enforce) passes of the same local model that must run to
|
|
12
|
+
* completion: unguarded (no wall-clock timeout, exact-match loop guard only,
|
|
13
|
+
* path-revisit disabled because re-running the same check IS the job), each with a
|
|
14
|
+
* status widget and a per-gate debug log under .pi-tasks/.
|
|
15
|
+
*/
|
|
16
|
+
import * as fsp from 'node:fs/promises';
|
|
17
|
+
import * as path from 'node:path';
|
|
18
|
+
import { tasksDir, readTaskFile } from './task-io.js';
|
|
19
|
+
import { gitCommitAll, gitDropLastCommit } from './auto-commit.js';
|
|
20
|
+
import { runGuidelineEnforcement, classifyEnforceChildFailure } from './enforce-guidelines.js';
|
|
21
|
+
import { runWorkVerification, extractSpecForVerification } from './verify-work.js';
|
|
22
|
+
import { researchResolution } from './verify-resolution.js';
|
|
23
|
+
import { runWorker } from '../workers/pi-worker-core.js';
|
|
24
|
+
import { formatLoopHint } from './child-runner.js';
|
|
25
|
+
import { getConfig } from '../config/config.js';
|
|
26
|
+
import { startAutoLoader } from './widget.js';
|
|
27
|
+
import { resolveContextUsage } from './context-usage.js';
|
|
28
|
+
/**
|
|
29
|
+
* Build the gate deps for one command run. `runTask` is the orchestrator's
|
|
30
|
+
* implementation re-runner, injected by the caller. The returned object also drives
|
|
31
|
+
* a shared status widget: `getLastLine`/`getContextUsage` expose the children's
|
|
32
|
+
* latest stream line and context usage so the caller can mirror them in its own
|
|
33
|
+
* loader if it wants (the gate closures already render their own loaders).
|
|
34
|
+
*/
|
|
35
|
+
export function buildGateDeps(params) {
|
|
36
|
+
const { signal, parentContextWindow, runTask } = params;
|
|
37
|
+
// Captured by each gate child's loader so the widget mirrors the child's latest
|
|
38
|
+
// output line and context usage, exactly like the single-task phase widget.
|
|
39
|
+
let lastLine;
|
|
40
|
+
let contextUsage;
|
|
41
|
+
// Shared runner for the per-task GATE children (verify + post-FAIL recommend).
|
|
42
|
+
// Both are read-only passes of the same local model that must run to completion:
|
|
43
|
+
// unguarded (no wall-clock timeout, exact-match loop guard only, path-revisit
|
|
44
|
+
// disabled because re-running the same check is the job), with a status widget
|
|
45
|
+
// and a per-gate debug log. Returns the closure runWorkVerification /
|
|
46
|
+
// researchResolution expect as `runChild`.
|
|
47
|
+
const makeGateChild = (gateCtx, cwd2, taskTitle, kind, logFile) => async (tools, prompt, sig) => {
|
|
48
|
+
lastLine = undefined;
|
|
49
|
+
contextUsage = undefined;
|
|
50
|
+
const startedAt = Date.now();
|
|
51
|
+
const logPath = path.join(tasksDir(cwd2), logFile);
|
|
52
|
+
const log = (msg) => {
|
|
53
|
+
void fsp.appendFile(logPath, `${new Date().toISOString()} ${msg}\n`).catch(() => { });
|
|
54
|
+
};
|
|
55
|
+
log(`=== ${kind} start: ${taskTitle} ===`);
|
|
56
|
+
const stopLoader = startAutoLoader(gateCtx, () => ({
|
|
57
|
+
title: taskTitle,
|
|
58
|
+
kind,
|
|
59
|
+
step: kind,
|
|
60
|
+
stepNum: 1,
|
|
61
|
+
stepTotal: 1,
|
|
62
|
+
startedAt,
|
|
63
|
+
lastLine,
|
|
64
|
+
contextUsage
|
|
65
|
+
}));
|
|
66
|
+
try {
|
|
67
|
+
const r = await runWorker({
|
|
68
|
+
prompt,
|
|
69
|
+
cwd: cwd2,
|
|
70
|
+
signal: sig,
|
|
71
|
+
tools,
|
|
72
|
+
timeoutMs: 0,
|
|
73
|
+
loop: { pathThreshold: Number.POSITIVE_INFINITY },
|
|
74
|
+
onLine: line => {
|
|
75
|
+
lastLine = line;
|
|
76
|
+
log(line);
|
|
77
|
+
},
|
|
78
|
+
onContextUsage: snapshot => {
|
|
79
|
+
contextUsage = resolveContextUsage(snapshot, contextUsage, parentContextWindow);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
if (r.loopHit) {
|
|
83
|
+
log(`=== ${kind} LOOP WARNING — ${formatLoopHint(r.loopHit)} ===`);
|
|
84
|
+
gateCtx.ui.notify(`${taskTitle}: ${kind} worker looped past the nudges — continuing (not blocked).`, 'warning');
|
|
85
|
+
}
|
|
86
|
+
const failure = classifyEnforceChildFailure(r);
|
|
87
|
+
log(failure ? `=== ${kind} end: FAIL — ${failure} ===` : `=== ${kind} end: ok ===`);
|
|
88
|
+
if (failure)
|
|
89
|
+
throw new Error(failure);
|
|
90
|
+
return r.text;
|
|
91
|
+
}
|
|
92
|
+
finally {
|
|
93
|
+
stopLoader();
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
return {
|
|
97
|
+
runTask,
|
|
98
|
+
commit: (cwd2, message) => getConfig().autoCommit ?
|
|
99
|
+
gitCommitAll(cwd2, message, signal)
|
|
100
|
+
: Promise.resolve({ committed: false, reason: 'auto-commit disabled' }),
|
|
101
|
+
revert: cwd2 => gitDropLastCommit(cwd2, signal),
|
|
102
|
+
enforce: (enforceCtx, cwd2, taskTitle, mode) => {
|
|
103
|
+
if (!getConfig().enforceGuidelines) {
|
|
104
|
+
return Promise.resolve({ ok: true, reason: 'disabled' });
|
|
105
|
+
}
|
|
106
|
+
return runGuidelineEnforcement({
|
|
107
|
+
cwd: cwd2,
|
|
108
|
+
signal,
|
|
109
|
+
mode,
|
|
110
|
+
// Run the worker child UNGUARDED: no loop detector, no wall-clock
|
|
111
|
+
// timeout. This pass reworks files in place until every violation is
|
|
112
|
+
// fixed and legitimately reads/edits the same file many times — the
|
|
113
|
+
// research-worker guards mislabel that as a runaway and kill good work
|
|
114
|
+
// (proven on mx5 TASK_0002). classifyEnforceChildFailure still blocks
|
|
115
|
+
// on a real failure (non-zero exit, leaked tool call) or a user cancel.
|
|
116
|
+
runChild: async (tools, prompt, sig) => {
|
|
117
|
+
lastLine = undefined;
|
|
118
|
+
contextUsage = undefined;
|
|
119
|
+
const startedAt = Date.now();
|
|
120
|
+
// Per-pass debug log; the enforce child is otherwise unobservable.
|
|
121
|
+
const enforceLogPath = path.join(tasksDir(cwd2), 'enforce-debug.log');
|
|
122
|
+
const logEnforce = (msg) => {
|
|
123
|
+
void fsp
|
|
124
|
+
.appendFile(enforceLogPath, `${new Date().toISOString()} ${msg}\n`)
|
|
125
|
+
.catch(() => { });
|
|
126
|
+
};
|
|
127
|
+
logEnforce(`=== enforce start: ${taskTitle} ===`);
|
|
128
|
+
const stopLoader = startAutoLoader(enforceCtx, () => ({
|
|
129
|
+
title: taskTitle,
|
|
130
|
+
kind: 'enforce',
|
|
131
|
+
step: 'guidelines',
|
|
132
|
+
stepNum: 1,
|
|
133
|
+
stepTotal: 1,
|
|
134
|
+
startedAt,
|
|
135
|
+
lastLine,
|
|
136
|
+
contextUsage
|
|
137
|
+
}));
|
|
138
|
+
try {
|
|
139
|
+
const r = await runWorker({
|
|
140
|
+
prompt,
|
|
141
|
+
cwd: cwd2,
|
|
142
|
+
signal: sig,
|
|
143
|
+
tools,
|
|
144
|
+
timeoutMs: 0, // no wall-clock timeout — run to completion
|
|
145
|
+
// Exact-match loop guard only: pathThreshold Infinity
|
|
146
|
+
// disables the path-revisit heuristic, so revisiting one
|
|
147
|
+
// file (which IS this pass's job) never trips — only a
|
|
148
|
+
// literally-identical call repeated past threshold does.
|
|
149
|
+
loop: { pathThreshold: Number.POSITIVE_INFINITY },
|
|
150
|
+
onLine: line => {
|
|
151
|
+
lastLine = line;
|
|
152
|
+
logEnforce(line);
|
|
153
|
+
},
|
|
154
|
+
onContextUsage: snapshot => {
|
|
155
|
+
contextUsage = resolveContextUsage(snapshot, contextUsage, parentContextWindow);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
// A loop that survived the restart-with-hint nudges is a
|
|
159
|
+
// warning, not a failure: log it and tell the user, but let the
|
|
160
|
+
// verdict gate be the only thing that can block.
|
|
161
|
+
if (r.loopHit) {
|
|
162
|
+
logEnforce(`=== enforce LOOP WARNING — ${formatLoopHint(r.loopHit)} ===`);
|
|
163
|
+
enforceCtx.ui.notify(`${taskTitle}: enforce worker looped past the nudges — continuing (not blocked).`, 'warning');
|
|
164
|
+
}
|
|
165
|
+
const failure = classifyEnforceChildFailure(r);
|
|
166
|
+
logEnforce(failure ?
|
|
167
|
+
`=== enforce end: FAIL — ${failure} ===`
|
|
168
|
+
: '=== enforce end: verdict captured ===');
|
|
169
|
+
if (failure)
|
|
170
|
+
throw new Error(failure);
|
|
171
|
+
return r.text;
|
|
172
|
+
}
|
|
173
|
+
finally {
|
|
174
|
+
stopLoader();
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
},
|
|
179
|
+
verify: async (verifyCtx, cwd2, taskTitle, taskId) => {
|
|
180
|
+
if (!getConfig().verifyWork) {
|
|
181
|
+
return { ok: true, reason: 'disabled' };
|
|
182
|
+
}
|
|
183
|
+
// The spec to verify against is the composed spec committed in the task
|
|
184
|
+
// file. A task that never reached compose has no spec section —
|
|
185
|
+
// runWorkVerification treats a null spec as a no-op pass.
|
|
186
|
+
let spec;
|
|
187
|
+
try {
|
|
188
|
+
const { body } = await readTaskFile(cwd2, taskId);
|
|
189
|
+
spec = extractSpecForVerification(body);
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
spec = null;
|
|
193
|
+
}
|
|
194
|
+
return runWorkVerification({
|
|
195
|
+
cwd: cwd2,
|
|
196
|
+
signal,
|
|
197
|
+
spec,
|
|
198
|
+
runChild: makeGateChild(verifyCtx, cwd2, taskTitle, 'verify', 'verify-debug.log')
|
|
199
|
+
});
|
|
200
|
+
},
|
|
201
|
+
recommend: async (recCtx, cwd2, taskTitle, taskId, failReason) => {
|
|
202
|
+
// Read the same composed spec the verify gate judged against, so the
|
|
203
|
+
// recommendation reasons over the real contract (degrade to the bare title).
|
|
204
|
+
let spec;
|
|
205
|
+
try {
|
|
206
|
+
const { body } = await readTaskFile(cwd2, taskId);
|
|
207
|
+
spec = extractSpecForVerification(body) ?? taskTitle;
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
spec = taskTitle;
|
|
211
|
+
}
|
|
212
|
+
return researchResolution({
|
|
213
|
+
cwd: cwd2,
|
|
214
|
+
signal,
|
|
215
|
+
spec,
|
|
216
|
+
failReason,
|
|
217
|
+
runChild: makeGateChild(recCtx, cwd2, taskTitle, 'recommend', 'verify-debug.log')
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
}
|
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import type { ExtensionAPI, ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
|
|
19
19
|
import { type WidgetState } from './widget.js';
|
|
20
|
+
import { type RunTaskFn } from './gate-deps.js';
|
|
21
|
+
import { type GateDeps } from './task-gates.js';
|
|
20
22
|
import type { SpawnFn } from '../shared/child-process.js';
|
|
21
23
|
/** Encapsulates the full lifecycle of a single pi-task run. */
|
|
22
24
|
export declare class TaskRunner {
|
|
@@ -216,4 +218,23 @@ export declare function resumeAcrossCompactions(ctx: SteerCtx): Promise<number>;
|
|
|
216
218
|
* front-matter state (TaskRunner.run never throws).
|
|
217
219
|
*/
|
|
218
220
|
export declare function runSingleTask(ctx: ExtensionCommandContext, cwd: string, rawPrompt: string, opts?: RunSingleTaskOptions): Promise<RunSingleTaskResult>;
|
|
221
|
+
/**
|
|
222
|
+
* The AUTOFIX re-runner injected into the gate deps: re-run a task's
|
|
223
|
+
* implementation turn, blocking until it finishes (steering across interrupts and
|
|
224
|
+
* resuming across compactions). Shared by /task and /task-auto so both gate the
|
|
225
|
+
* same way; lives here because it wraps runSingleTask (gate-deps must not import the
|
|
226
|
+
* orchestrators, to keep the dependency graph acyclic).
|
|
227
|
+
*/
|
|
228
|
+
export declare const gateRunTask: RunTaskFn;
|
|
229
|
+
/**
|
|
230
|
+
* Run a single /task through implementation AND the shared verify + enforce gates,
|
|
231
|
+
* blocking until both finish. Used instead of the fire-and-forget handoff whenever
|
|
232
|
+
* `verify work` or `enforce guidelines` is enabled — so /task gates exactly like a
|
|
233
|
+
* /task-auto sub-task does. A terminal stop (the implementation died, or a gate
|
|
234
|
+
* paused/failed) leaves the task resumable and tells the user to /task-resume.
|
|
235
|
+
*/
|
|
236
|
+
export declare function runGatedTask(ctx: ExtensionCommandContext, cwd: string, raw: string, opts?: {
|
|
237
|
+
resumeId?: string;
|
|
238
|
+
deps?: GateDeps;
|
|
239
|
+
}): Promise<void>;
|
|
219
240
|
export declare function registerTask(pi: ExtensionAPI): void;
|
|
@@ -24,8 +24,11 @@ import { normaliseTaskId, parseFrontMatter, extractSection } from './task-parser
|
|
|
24
24
|
import { allocateTaskId, ensureTasksDir, readSection, readTaskFile, setTaskSection, taskFilePath, tasksDir, updateTaskFrontMatter, writeTaskFile } from './task-io.js';
|
|
25
25
|
import { startWidget } from './widget.js';
|
|
26
26
|
import { armImplWidget, disarmImplWidget, setupImplWidget } from './impl-widget.js';
|
|
27
|
-
import { publishViewer, publishNotify, registerBridgeCommand, getBridge } from '../remote/bridge.js';
|
|
27
|
+
import { publishViewer, publishNotify, publishLifecycleNotice, registerBridgeCommand, getBridge } from '../remote/bridge.js';
|
|
28
28
|
import { pushNotify } from '../remote/push.js';
|
|
29
|
+
import { getConfig } from '../config/config.js';
|
|
30
|
+
import { buildGateDeps } from './gate-deps.js';
|
|
31
|
+
import { runGatesForTask } from './task-gates.js';
|
|
29
32
|
import { parseVerifyBlock } from './spec-validation.js';
|
|
30
33
|
import { findDeliveryPhantoms, formatApiOverrideBanner } from '../workers/phantom-imports.js';
|
|
31
34
|
import { titleForDisplay } from './parsers.js';
|
|
@@ -555,6 +558,121 @@ export async function runSingleTask(ctx, cwd, rawPrompt, opts = {}) {
|
|
|
555
558
|
}
|
|
556
559
|
return { taskId, ok, sessionCancelled: false, ctx: freshCtx, interrupted, reason: implError };
|
|
557
560
|
}
|
|
561
|
+
// ─── Gated single-task flow ────────────────────────────────────────────────────
|
|
562
|
+
/**
|
|
563
|
+
* The AUTOFIX re-runner injected into the gate deps: re-run a task's
|
|
564
|
+
* implementation turn, blocking until it finishes (steering across interrupts and
|
|
565
|
+
* resuming across compactions). Shared by /task and /task-auto so both gate the
|
|
566
|
+
* same way; lives here because it wraps runSingleTask (gate-deps must not import the
|
|
567
|
+
* orchestrators, to keep the dependency graph acyclic).
|
|
568
|
+
*/
|
|
569
|
+
export const gateRunTask = (c, cwd, t, opts) => runSingleTask(c, cwd, t, {
|
|
570
|
+
waitForImplementation: true,
|
|
571
|
+
resumeId: opts?.resumeId,
|
|
572
|
+
onStart: opts?.onStart,
|
|
573
|
+
planContext: opts?.planContext,
|
|
574
|
+
fixInstruction: opts?.fixInstruction
|
|
575
|
+
});
|
|
576
|
+
/**
|
|
577
|
+
* Demote a task file to a resumable state after a gate (or its implementation)
|
|
578
|
+
* stopped short. The file is marked `completed` at spec-handoff — before the work
|
|
579
|
+
* is even verified — so a gate FAIL would otherwise leave it un-resumable
|
|
580
|
+
* (`completed` is not in RESUMABLE_STATES). Best-effort; a missing/empty id is a
|
|
581
|
+
* no-op. Mirrors how /task-auto marks the parent run `failed` so resume re-runs it.
|
|
582
|
+
*/
|
|
583
|
+
async function markResumable(cwd, taskId) {
|
|
584
|
+
if (!taskId)
|
|
585
|
+
return;
|
|
586
|
+
try {
|
|
587
|
+
await updateTaskFrontMatter(cwd, taskId, { state: 'failed' });
|
|
588
|
+
}
|
|
589
|
+
catch {
|
|
590
|
+
/* best-effort */
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* Run a single /task through implementation AND the shared verify + enforce gates,
|
|
595
|
+
* blocking until both finish. Used instead of the fire-and-forget handoff whenever
|
|
596
|
+
* `verify work` or `enforce guidelines` is enabled — so /task gates exactly like a
|
|
597
|
+
* /task-auto sub-task does. A terminal stop (the implementation died, or a gate
|
|
598
|
+
* paused/failed) leaves the task resumable and tells the user to /task-resume.
|
|
599
|
+
*/
|
|
600
|
+
export async function runGatedTask(ctx, cwd, raw, opts = {}) {
|
|
601
|
+
const abort = new AbortController();
|
|
602
|
+
const deps = opts.deps
|
|
603
|
+
?? buildGateDeps({
|
|
604
|
+
signal: abort.signal,
|
|
605
|
+
parentContextWindow: getParentContextWindow(ctx),
|
|
606
|
+
runTask: gateRunTask
|
|
607
|
+
});
|
|
608
|
+
let active = ctx;
|
|
609
|
+
// One push + remote bubble on the terminal outcome (parity with the
|
|
610
|
+
// notifyFinish push the fire-and-forget path emits via runSingleTask).
|
|
611
|
+
const announce = (msg, level) => {
|
|
612
|
+
active.ui.notify(msg, level);
|
|
613
|
+
publishLifecycleNotice(msg, level);
|
|
614
|
+
void pushNotify('Task finished', msg, 'pi-end').catch(() => { });
|
|
615
|
+
};
|
|
616
|
+
// First implementation run (blocking).
|
|
617
|
+
const res = await deps.runTask(active, cwd, raw, { resumeId: opts.resumeId });
|
|
618
|
+
active = res.ctx ?? active;
|
|
619
|
+
const tag = res.taskId || 'Task';
|
|
620
|
+
if (res.sessionCancelled) {
|
|
621
|
+
announce(`${tag} — could not start a fresh session for /task.`, 'warning');
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
if (res.interrupted) {
|
|
625
|
+
await markResumable(cwd, res.taskId);
|
|
626
|
+
announce(`${tag} paused — resume with /task-resume.`, 'warning');
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
if (!res.ok) {
|
|
630
|
+
await markResumable(cwd, res.taskId);
|
|
631
|
+
const why = res.reason ? ` — ${res.reason.slice(0, 160)}` : '';
|
|
632
|
+
announce(`${tag} stopped${why} — fix and run /task-resume.`, 'error');
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
// The composed task's own front-matter title — used in commit messages and
|
|
636
|
+
// notifies (the gate sequence has no plan title to borrow). Degrade to the id.
|
|
637
|
+
let title = tag;
|
|
638
|
+
try {
|
|
639
|
+
const { frontMatter } = await readTaskFile(cwd, res.taskId);
|
|
640
|
+
title = frontMatter.title || tag;
|
|
641
|
+
}
|
|
642
|
+
catch {
|
|
643
|
+
/* keep the id as the title */
|
|
644
|
+
}
|
|
645
|
+
const gate = await runGatesForTask(active, deps, {
|
|
646
|
+
cwd,
|
|
647
|
+
taskId: res.taskId,
|
|
648
|
+
title,
|
|
649
|
+
tag
|
|
650
|
+
// No sibling plan → no scope fence; no parent list → no check-off.
|
|
651
|
+
});
|
|
652
|
+
active = gate.ctx;
|
|
653
|
+
switch (gate.kind) {
|
|
654
|
+
case 'paused':
|
|
655
|
+
await markResumable(cwd, res.taskId);
|
|
656
|
+
announce(`${tag} paused — verification failed and you dismissed the choice; resume with /task-resume.`, 'warning');
|
|
657
|
+
return;
|
|
658
|
+
case 'session-cancelled':
|
|
659
|
+
announce(`${tag} paused — could not start a session for autofix. Run /task-resume to retry.`, 'warning');
|
|
660
|
+
return;
|
|
661
|
+
case 'interrupted':
|
|
662
|
+
await markResumable(cwd, res.taskId);
|
|
663
|
+
announce(`${tag} paused — resume with /task-resume.`, 'warning');
|
|
664
|
+
return;
|
|
665
|
+
case 'failed': {
|
|
666
|
+
await markResumable(cwd, res.taskId);
|
|
667
|
+
const why = gate.reason ? ` — ${gate.reason.slice(0, 160)}` : '';
|
|
668
|
+
announce(`${tag} stopped${why} — fix and run /task-resume.`, 'error');
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
case 'done':
|
|
672
|
+
announce(`${tag} complete — verified.`, 'info');
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
558
676
|
// ─── Command handlers ────────────────────────────────────────────────────────
|
|
559
677
|
async function handleTask(args, ctx) {
|
|
560
678
|
await ctx.waitForIdle();
|
|
@@ -565,6 +683,15 @@ async function handleTask(args, ctx) {
|
|
|
565
683
|
ctx.ui.notify('Type your prompt after /task (use @ for file completion).', 'info');
|
|
566
684
|
return;
|
|
567
685
|
}
|
|
686
|
+
// When a gate is enabled, /task awaits the implementation and runs the same
|
|
687
|
+
// verify + enforce gates a /task-auto sub-task does. With both gates off
|
|
688
|
+
// (the default), /task stays fire-and-forget: hand the spec to the main
|
|
689
|
+
// conversation and return immediately, exactly as before.
|
|
690
|
+
const cfg = getConfig();
|
|
691
|
+
if (cfg.verifyWork || cfg.enforceGuidelines) {
|
|
692
|
+
await runGatedTask(ctx, cwd, raw);
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
568
695
|
const { sessionCancelled } = await runSingleTask(ctx, cwd, raw, { notifyFinish: true });
|
|
569
696
|
if (sessionCancelled) {
|
|
570
697
|
ctx.ui.notify('Could not start a fresh session for /task.', 'warning');
|
|
@@ -648,6 +775,12 @@ async function handleTaskResume(args, ctx) {
|
|
|
648
775
|
}
|
|
649
776
|
id = candidates[0].id;
|
|
650
777
|
}
|
|
778
|
+
// Match /task: resume through the gates when one is enabled, else fire-and-forget.
|
|
779
|
+
const cfg = getConfig();
|
|
780
|
+
if (cfg.verifyWork || cfg.enforceGuidelines) {
|
|
781
|
+
await runGatedTask(ctx, cwd, '', { resumeId: id });
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
651
784
|
const { sessionCancelled } = await runSingleTask(ctx, cwd, '', { resumeId: id, notifyFinish: true });
|
|
652
785
|
if (sessionCancelled) {
|
|
653
786
|
ctx.ui.notify('Could not start a fresh session for /task-resume.', 'warning');
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* task-gates — the post-implementation GATE sequence shared by /task-auto's per-task
|
|
3
|
+
* loop and the single /task command.
|
|
4
|
+
*
|
|
5
|
+
* After a task's implementation turn settles, the same two gates run against the
|
|
6
|
+
* just-finished work:
|
|
7
|
+
*
|
|
8
|
+
* 1. VERIFY — RUN the composed spec's VERIFY block in the real workspace and
|
|
9
|
+
* judge a PASS/FAIL. A FAIL offers the user a boxed AUTOFIX / ACCEPT / dismiss
|
|
10
|
+
* picker (the user always decides; AUTOFIX re-runs the implementation turn and
|
|
11
|
+
* loops back to the gate uncapped).
|
|
12
|
+
* 2. ENFORCE — hold the committed work to the project's AGENTS.md / CLAUDE.md
|
|
13
|
+
* rules. Runs in `edit` mode (fix in place) only when the verify gate produced
|
|
14
|
+
* a genuine clean pass to guard the edits against; otherwise `flag` mode
|
|
15
|
+
* (read-only, report don't fix). An edit pass that regresses the verify signal
|
|
16
|
+
* is reverted.
|
|
17
|
+
*
|
|
18
|
+
* Both gates are no-ops when their config flag is off (the injected deps return a
|
|
19
|
+
* disabled pass), so wiring this into a command is inert until the user enables
|
|
20
|
+
* `verify work` / `enforce guidelines` in /task-config.
|
|
21
|
+
*
|
|
22
|
+
* The sequence is parameterised so each caller supplies the parent-specific glue
|
|
23
|
+
* (notify prefix, the autofix scope fence, and the "mark verified" step — a parent
|
|
24
|
+
* task-list check-off for /task-auto, a no-op for /task). Terminal outcomes are
|
|
25
|
+
* returned as a discriminated GateResult; the caller turns them into its own
|
|
26
|
+
* announce + state changes (the resume command differs: /task-auto-resume vs
|
|
27
|
+
* /task-resume).
|
|
28
|
+
*/
|
|
29
|
+
import type { ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
|
|
30
|
+
import type { RunSingleTaskResult } from './orchestrator.js';
|
|
31
|
+
import type { CommitResult } from './auto-commit.js';
|
|
32
|
+
import type { VerifyOutcome } from './verify-work.js';
|
|
33
|
+
import type { EnforceOutcome } from './enforce-guidelines.js';
|
|
34
|
+
import { type ResolutionOutcome, type ResolutionChoice } from './verify-resolution.js';
|
|
35
|
+
/**
|
|
36
|
+
* The deps the gate sequence drives. A superset of these is built once per command
|
|
37
|
+
* by buildGateDeps; AutoDeps extends this with the planning-only `runChild`. Every
|
|
38
|
+
* gate dep is injected so the sequence is testable without spawning pi, and so the
|
|
39
|
+
* optional gates (verify/enforce/recommend/revert) can be absent in tests or when
|
|
40
|
+
* their config flag is off — the sequence then treats them as a pass / no-op.
|
|
41
|
+
*/
|
|
42
|
+
export interface GateDeps {
|
|
43
|
+
/**
|
|
44
|
+
* Re-run a task's implementation turn (AUTOFIX after a verify FAIL). Resumes
|
|
45
|
+
* the same inner task id, optionally fenced by a plan scope and led by a
|
|
46
|
+
* RE-ATTEMPT banner naming the verification failure.
|
|
47
|
+
*/
|
|
48
|
+
runTask: (ctx: ExtensionCommandContext, cwd: string, title: string, opts?: {
|
|
49
|
+
resumeId?: string;
|
|
50
|
+
onStart?: (taskId: string) => void | Promise<void>;
|
|
51
|
+
planContext?: string;
|
|
52
|
+
fixInstruction?: string;
|
|
53
|
+
}) => Promise<RunSingleTaskResult>;
|
|
54
|
+
/** Snapshot the working tree into one commit after a task passes. */
|
|
55
|
+
commit: (cwd: string, message: string) => Promise<CommitResult>;
|
|
56
|
+
/**
|
|
57
|
+
* Verify the just-finished task's work by RUNNING its composed spec's VERIFY
|
|
58
|
+
* block in the real workspace, then reporting a PASS/FAIL verdict. Absent in
|
|
59
|
+
* tests or when `verify work` is off → the sequence treats it as a pass.
|
|
60
|
+
*/
|
|
61
|
+
verify?: (ctx: ExtensionCommandContext, cwd: string, taskTitle: string, taskId: string) => Promise<VerifyOutcome>;
|
|
62
|
+
/**
|
|
63
|
+
* Hold the committed work to AGENTS.md / CLAUDE.md. `edit` (fix in place) only
|
|
64
|
+
* with a clean verify signal to guard against; otherwise `flag` (report only).
|
|
65
|
+
* Absent in tests or when `enforce guidelines` is off → skipped.
|
|
66
|
+
*/
|
|
67
|
+
enforce?: (ctx: ExtensionCommandContext, cwd: string, taskTitle: string, mode: 'edit' | 'flag') => Promise<EnforceOutcome>;
|
|
68
|
+
/**
|
|
69
|
+
* After a verify FAIL, research whether to recommend AUTOFIX or ACCEPT — only
|
|
70
|
+
* sets which card the picker tints RECOMMENDED; the user always decides. Absent
|
|
71
|
+
* → the picker defaults the recommendation to AUTOFIX.
|
|
72
|
+
*/
|
|
73
|
+
recommend?: (ctx: ExtensionCommandContext, cwd: string, taskTitle: string, taskId: string, failReason: string) => Promise<ResolutionOutcome>;
|
|
74
|
+
/**
|
|
75
|
+
* Discard the working-tree edits an `edit` enforcement pass made, restoring the
|
|
76
|
+
* verified task commit (the differential guard's revert). Absent → the guard
|
|
77
|
+
* skips the revert and warns.
|
|
78
|
+
*/
|
|
79
|
+
revert?: (cwd: string) => Promise<void>;
|
|
80
|
+
}
|
|
81
|
+
/** Inputs the sequence needs that vary per caller. */
|
|
82
|
+
export interface GateParams {
|
|
83
|
+
cwd: string;
|
|
84
|
+
/** The inner task id whose spec is verified and whose work is committed. */
|
|
85
|
+
taskId: string;
|
|
86
|
+
/** The task title — used in commit messages and user-facing notifies. */
|
|
87
|
+
title: string;
|
|
88
|
+
/** Prefix for in-progress notifies (the parent /task-auto id, or the /task id). */
|
|
89
|
+
tag: string;
|
|
90
|
+
/** Scope fence forwarded to an AUTOFIX re-run's refine (siblings for /task-auto;
|
|
91
|
+
* undefined for a bare /task). */
|
|
92
|
+
planContext?: string;
|
|
93
|
+
/** Runs after the verify gate passes/accepts and BEFORE the work is committed —
|
|
94
|
+
* the parent task-list check-off for /task-auto, omitted for /task. */
|
|
95
|
+
onVerified?: () => void | Promise<void>;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Why the gate sequence stopped. `done` means the work verified (or was accepted),
|
|
99
|
+
* was checked off + committed, and enforcement ran — the caller proceeds. The other
|
|
100
|
+
* kinds are terminal: the caller announces them (with its own resume command) and
|
|
101
|
+
* stops. `ctx` is always the live (possibly session-replaced) context the caller
|
|
102
|
+
* must adopt.
|
|
103
|
+
*/
|
|
104
|
+
export type GateResult = {
|
|
105
|
+
kind: 'done';
|
|
106
|
+
ctx: ExtensionCommandContext;
|
|
107
|
+
}
|
|
108
|
+
/** User dismissed the verify-FAIL picker — pause, leave the work unblessed. */
|
|
109
|
+
| {
|
|
110
|
+
kind: 'paused';
|
|
111
|
+
ctx: ExtensionCommandContext;
|
|
112
|
+
reason: string;
|
|
113
|
+
}
|
|
114
|
+
/** An AUTOFIX re-run could not start a fresh session. */
|
|
115
|
+
| {
|
|
116
|
+
kind: 'session-cancelled';
|
|
117
|
+
ctx: ExtensionCommandContext;
|
|
118
|
+
}
|
|
119
|
+
/** An AUTOFIX re-run was interrupted (ESC) and the user declined to steer. */
|
|
120
|
+
| {
|
|
121
|
+
kind: 'interrupted';
|
|
122
|
+
ctx: ExtensionCommandContext;
|
|
123
|
+
}
|
|
124
|
+
/** An AUTOFIX re-run's implementation itself failed. */
|
|
125
|
+
| {
|
|
126
|
+
kind: 'failed';
|
|
127
|
+
ctx: ExtensionCommandContext;
|
|
128
|
+
reason?: string;
|
|
129
|
+
};
|
|
130
|
+
/**
|
|
131
|
+
* Show the boxed two-choice picker after a verify FAIL and return what the user
|
|
132
|
+
* decided. The model-recommended card is placed first so the renderer tints it
|
|
133
|
+
* green; the user ALWAYS makes the final call (there is no auto-pick). Mirrors the
|
|
134
|
+
* clarify/grill dialog: the same SessionUI.ask races the local boxed picker against
|
|
135
|
+
* a remote answer, with the two actions also surfaced as remote buttons.
|
|
136
|
+
*/
|
|
137
|
+
export declare function askVerifyResolution(ctx: ExtensionCommandContext, title: string, failReason: string, rec: ResolutionOutcome): Promise<ResolutionChoice>;
|
|
138
|
+
/**
|
|
139
|
+
* Run the verify + enforce gates against a task's just-finished implementation.
|
|
140
|
+
*
|
|
141
|
+
* Lifted verbatim from /task-auto's per-task loop so the two commands gate
|
|
142
|
+
* identically. Returns a GateResult; `done` means the caller should proceed (the
|
|
143
|
+
* work is verified-or-accepted, checked off, committed, and enforced), every other
|
|
144
|
+
* kind is a terminal stop the caller announces. Never throws for a gate outcome —
|
|
145
|
+
* only a user cancel inside a gate child propagates (handled by the caller's
|
|
146
|
+
* USER_CANCELLED path).
|
|
147
|
+
*/
|
|
148
|
+
export declare function runGatesForTask(ctxIn: ExtensionCommandContext, deps: GateDeps, p: GateParams): Promise<GateResult>;
|