@mjasnikovs/pi-task 0.16.5 → 0.17.1
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 +48 -396
- package/dist/task/auto-prompts.d.ts +1 -1
- package/dist/task/auto-prompts.js +3 -6
- package/dist/task/file-inventory.d.ts +0 -16
- package/dist/task/file-inventory.js +0 -37
- 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/phases.d.ts +8 -0
- package/dist/task/phases.js +62 -10
- package/dist/task/prompts.d.ts +1 -1
- package/dist/task/prompts.js +2 -2
- package/dist/task/task-gates.d.ts +148 -0
- package/dist/task/task-gates.js +135 -0
- package/package.json +1 -1
|
@@ -9,19 +9,16 @@
|
|
|
9
9
|
* when no clarification remains. priorQA carries the questions already answered so
|
|
10
10
|
* each next question adapts to them.
|
|
11
11
|
*/
|
|
12
|
-
export const AUTO_CLARIFY_PROMPT = (feature, priorQA
|
|
12
|
+
export const AUTO_CLARIFY_PROMPT = (feature, priorQA) => `You are planning how to split a feature into separate implementation tasks, one clarifying question at a time.
|
|
13
13
|
|
|
14
14
|
FEATURE REQUEST:
|
|
15
15
|
${feature.trim()}
|
|
16
16
|
|
|
17
17
|
ANSWERS SO FAR:
|
|
18
18
|
${priorQA.trim() || '(none yet)'}
|
|
19
|
-
|
|
19
|
+
|
|
20
20
|
You may use the read tool to inspect the repo and any referenced docs so your
|
|
21
|
-
question and recommendation are grounded in what already exists.
|
|
22
|
-
STATE snapshot is given above, trust it: never assume an empty or greenfield
|
|
23
|
-
project, never invent directory paths, and never recommend creating files it
|
|
24
|
-
already lists — at most propose updating them.
|
|
21
|
+
question and recommendation are grounded in what already exists.
|
|
25
22
|
|
|
26
23
|
Output the SINGLE most important clarifying question that REMAINS — the one whose
|
|
27
24
|
answer would most change HOW this feature is split into tasks (scope boundaries,
|
|
@@ -17,19 +17,3 @@ export declare function stripTasksDir(raw: string): string;
|
|
|
17
17
|
/** Cap output to maxLines real (non-blank) paths; tag truncation when cut. */
|
|
18
18
|
export declare function capInventory(raw: string, maxLines?: number): string;
|
|
19
19
|
export declare function getFileInventory(cwd: string, signal?: AbortSignal, maxLines?: number): Promise<string>;
|
|
20
|
-
/**
|
|
21
|
-
* Derive a compact PROJECT STATE block (root-level files + top-level directories)
|
|
22
|
-
* from a raw `git ls-files` listing. Pure/testable; `getProjectSnapshot` wires it
|
|
23
|
-
* to the live repo. Returns '' when there is nothing tracked so the caller omits
|
|
24
|
-
* the block entirely (and the model is then correctly free to treat the target as
|
|
25
|
-
* an empty/greenfield project).
|
|
26
|
-
*/
|
|
27
|
-
export declare function formatProjectSnapshot(raw: string): string;
|
|
28
|
-
/**
|
|
29
|
-
* Live PROJECT STATE snapshot for the planning phase. Clarify otherwise only
|
|
30
|
-
* "may" read the repo; a weak model that skips the read then assumes a greenfield
|
|
31
|
-
* project and recommends creating package.json/tsconfig from scratch — or invents
|
|
32
|
-
* a path like /workspace — even when those configs already exist on disk. Handing
|
|
33
|
-
* it the facts up front removes the guess. Empty (non-git / nothing tracked) ⇒ ''.
|
|
34
|
-
*/
|
|
35
|
-
export declare function getProjectSnapshot(cwd: string, signal?: AbortSignal): Promise<string>;
|
|
@@ -59,40 +59,3 @@ export async function getFileInventory(cwd, signal, maxLines = DEFAULT_MAX_LINES
|
|
|
59
59
|
return '';
|
|
60
60
|
return capInventory(stripTasksDir(raw), maxLines);
|
|
61
61
|
}
|
|
62
|
-
/**
|
|
63
|
-
* Derive a compact PROJECT STATE block (root-level files + top-level directories)
|
|
64
|
-
* from a raw `git ls-files` listing. Pure/testable; `getProjectSnapshot` wires it
|
|
65
|
-
* to the live repo. Returns '' when there is nothing tracked so the caller omits
|
|
66
|
-
* the block entirely (and the model is then correctly free to treat the target as
|
|
67
|
-
* an empty/greenfield project).
|
|
68
|
-
*/
|
|
69
|
-
export function formatProjectSnapshot(raw) {
|
|
70
|
-
const lines = stripTasksDir(raw)
|
|
71
|
-
.split('\n')
|
|
72
|
-
.map(l => l.trim())
|
|
73
|
-
.filter(l => l.length > 0);
|
|
74
|
-
const rootFiles = [...new Set(lines.filter(l => !l.includes('/')))].sort();
|
|
75
|
-
const dirs = [...new Set(lines.filter(l => l.includes('/')).map(l => l.split('/')[0]))].sort();
|
|
76
|
-
if (rootFiles.length === 0 && dirs.length === 0)
|
|
77
|
-
return '';
|
|
78
|
-
return ('EXISTING PROJECT STATE (a factual snapshot of the target directory — trust it over '
|
|
79
|
-
+ 'assumptions: do NOT assume an empty or greenfield project, do NOT invent directory '
|
|
80
|
-
+ 'paths, and do NOT propose creating files that already exist. Treat the config/manifest '
|
|
81
|
-
+ "files listed here as the project's established setup unless the request explicitly says "
|
|
82
|
-
+ 'to replace them):\n'
|
|
83
|
-
+ `Root files: ${rootFiles.join(', ') || '(none)'}\n`
|
|
84
|
-
+ `Top-level directories: ${dirs.join(', ') || '(none)'}`);
|
|
85
|
-
}
|
|
86
|
-
/**
|
|
87
|
-
* Live PROJECT STATE snapshot for the planning phase. Clarify otherwise only
|
|
88
|
-
* "may" read the repo; a weak model that skips the read then assumes a greenfield
|
|
89
|
-
* project and recommends creating package.json/tsconfig from scratch — or invents
|
|
90
|
-
* a path like /workspace — even when those configs already exist on disk. Handing
|
|
91
|
-
* it the facts up front removes the guess. Empty (non-git / nothing tracked) ⇒ ''.
|
|
92
|
-
*/
|
|
93
|
-
export async function getProjectSnapshot(cwd, signal) {
|
|
94
|
-
const raw = await runGitLsFiles(cwd, signal);
|
|
95
|
-
if (!raw)
|
|
96
|
-
return '';
|
|
97
|
-
return formatProjectSnapshot(raw);
|
|
98
|
-
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { GateDeps } from './task-gates.js';
|
|
2
|
+
/** A function that re-runs a task's implementation turn (AUTOFIX). Injected by the
|
|
3
|
+
* command so this module stays free of the orchestrators (avoids an import cycle). */
|
|
4
|
+
export type RunTaskFn = GateDeps['runTask'];
|
|
5
|
+
/**
|
|
6
|
+
* Build the gate deps for one command run. `runTask` is the orchestrator's
|
|
7
|
+
* implementation re-runner, injected by the caller. The returned object also drives
|
|
8
|
+
* a shared status widget: `getLastLine`/`getContextUsage` expose the children's
|
|
9
|
+
* latest stream line and context usage so the caller can mirror them in its own
|
|
10
|
+
* loader if it wants (the gate closures already render their own loaders).
|
|
11
|
+
*/
|
|
12
|
+
export declare function buildGateDeps(params: {
|
|
13
|
+
signal: AbortSignal;
|
|
14
|
+
parentContextWindow: number;
|
|
15
|
+
runTask: RunTaskFn;
|
|
16
|
+
}): GateDeps;
|
|
@@ -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');
|
package/dist/task/phases.d.ts
CHANGED
|
@@ -42,6 +42,14 @@ export interface PhaseConfig {
|
|
|
42
42
|
export declare function extractToolingCommands(research: string): string[] | null;
|
|
43
43
|
/** Replace the TOOLING section in a research string with a VERIFIED-TOOLING section. */
|
|
44
44
|
export declare function replaceToolingWithVerified(research: string, verifiedCommands: string[]): string;
|
|
45
|
+
/**
|
|
46
|
+
* Manifest + config content (orientation tiers 0–1) for refine, with the preserve
|
|
47
|
+
* directive. Reuses the SAME orientation machinery research feeds its workers — not
|
|
48
|
+
* a parallel reader — scoped to the "accumulative" files a from-scratch rewrite
|
|
49
|
+
* silently destroys. '' (non-git/empty/orientation-off) leaves refine unchanged, so
|
|
50
|
+
* a genuinely greenfield task is still free to create.
|
|
51
|
+
*/
|
|
52
|
+
export declare function refineExistingFilesBlock(deps: PhaseDeps): Promise<string>;
|
|
45
53
|
export declare const phaseRefine: (deps: PhaseDeps, raw: string, planContext?: string) => Promise<string>;
|
|
46
54
|
export declare function phaseVerifyTooling(deps: PhaseDeps, research: string): Promise<string>;
|
|
47
55
|
export interface PhaseResearchDeps extends ExternalContextDeps {
|
package/dist/task/phases.js
CHANGED
|
@@ -11,7 +11,7 @@ import { search as defaultSearch } from '../workers/search-core.js';
|
|
|
11
11
|
import { extractEnrichTargets } from './enrichment.js';
|
|
12
12
|
import { isIntegrationUnknown } from './unknown-routing.js';
|
|
13
13
|
import { getFileInventory } from './file-inventory.js';
|
|
14
|
-
import { buildOrientation } from './orientation.js';
|
|
14
|
+
import { buildOrientation, orientationTier } from './orientation.js';
|
|
15
15
|
import { getConfig } from '../config/config.js';
|
|
16
16
|
import { readFile } from 'node:fs/promises';
|
|
17
17
|
import { resolve } from 'node:path';
|
|
@@ -63,15 +63,67 @@ export function replaceToolingWithVerified(research, verifiedCommands) {
|
|
|
63
63
|
return replaced;
|
|
64
64
|
}
|
|
65
65
|
// ─── Phase functions ─────────────────────────────────────────────────────────
|
|
66
|
-
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
// the
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
|
|
66
|
+
// Authoritative directive that travels with the refine orientation block. Refine
|
|
67
|
+
// runs BEFORE research, so on a "Scaffold project with package.json…" title it has
|
|
68
|
+
// no signal the file already exists and authors greenfield strip-constraints
|
|
69
|
+
// ("bun-plugin-tailwind the only dependency", "exactly N scripts") that compose
|
|
70
|
+
// then obeys over the research facts — the implementer executes a wholesale
|
|
71
|
+
// rewrite that drops every existing dependency and script (mx5 TASK_0001 emptied
|
|
72
|
+
// package.json; the REAL pipeline reproduced it 3/3 even with research surfacing
|
|
73
|
+
// the deps). Handing refine the manifest/config CONTENT up front, with this
|
|
74
|
+
// reframe, fixes it at the origin (A/B: 1/5 → 5/5 preserve, 5/5 end-to-end).
|
|
75
|
+
const REFINE_PRESERVE_DIRECTIVE = 'EXISTING FILES ON DISK — AUTHORITATIVE (overrides any "scaffold / create / set '
|
|
76
|
+
+ 'up / initialize / from scratch / only / exactly / minimal" wording in the task '
|
|
77
|
+
+ 'below): the files shown in the PROJECT ORIENTATION block ALREADY EXIST. When '
|
|
78
|
+
+ 'the task says to scaffold, create, set up, initialize, or configure a file that '
|
|
79
|
+
+ 'already exists, your GOAL and CONSTRAINTS MUST frame it as an in-place UPDATE '
|
|
80
|
+
+ 'that PRESERVES every existing dependency, devDependency, script, field, and '
|
|
81
|
+
+ "compiler option — adding or changing only the task's explicit delta. Do NOT "
|
|
82
|
+
+ 'author any constraint that empties, reduces to a fixed/minimal set, renames, '
|
|
83
|
+
+ 'recreates, or drops existing entries (no "X is the only dependency", no '
|
|
84
|
+
+ '"exactly N scripts", no "produce exactly N files from scratch"). An existing '
|
|
85
|
+
+ 'entry is NEVER scope drift and is never removed because it "belongs to a later step".';
|
|
86
|
+
/**
|
|
87
|
+
* Manifest + config content (orientation tiers 0–1) for refine, with the preserve
|
|
88
|
+
* directive. Reuses the SAME orientation machinery research feeds its workers — not
|
|
89
|
+
* a parallel reader — scoped to the "accumulative" files a from-scratch rewrite
|
|
90
|
+
* silently destroys. '' (non-git/empty/orientation-off) leaves refine unchanged, so
|
|
91
|
+
* a genuinely greenfield task is still free to create.
|
|
92
|
+
*/
|
|
93
|
+
export async function refineExistingFilesBlock(deps) {
|
|
94
|
+
if (!getConfig().orientation)
|
|
95
|
+
return '';
|
|
96
|
+
const inventoryRaw = await getFileInventory(deps.cwd, deps.signal).catch(() => '');
|
|
97
|
+
if (inventoryRaw.length === 0)
|
|
98
|
+
return '';
|
|
99
|
+
const paths = inventoryRaw
|
|
100
|
+
.split('\n')
|
|
101
|
+
.map(l => l.trim())
|
|
102
|
+
.filter(l => l.length > 0 && (orientationTier(l) === 0 || orientationTier(l) === 1));
|
|
103
|
+
if (paths.length === 0)
|
|
104
|
+
return '';
|
|
105
|
+
const { block } = await buildOrientation(paths, async (p) => {
|
|
106
|
+
try {
|
|
107
|
+
return await readFile(resolve(deps.cwd, p), 'utf8');
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
return block.trim().length === 0 ? '' : `${REFINE_PRESERVE_DIRECTIVE}\n\n${block.trim()}`;
|
|
114
|
+
}
|
|
115
|
+
export const phaseRefine = async (deps, raw, planContext) => {
|
|
116
|
+
const existingFiles = await refineExistingFilesBlock(deps).catch(() => '');
|
|
117
|
+
return runPhaseWithLoopGuard(deps, 'refine', 'read', hint => prependHint(hint, appendNoThink(REFINE_PROMPT(raw, planContext, existingFiles))),
|
|
118
|
+
// refine's deliverable is a 4-section text rewrite that never strictly
|
|
119
|
+
// needs a successful read — on a test-writing task against a large
|
|
120
|
+
// existing codebase the model over-explores (re-reads source hunting for
|
|
121
|
+
// the impl) and burns the loop budget. Degrade to a no-tools final
|
|
122
|
+
// attempt instead of hard-failing the whole run. See TASK_0016 (mx5):
|
|
123
|
+
// refine looped 3×/resume forever; the deliverable was always producible
|
|
124
|
+
// from the title + design doc alone.
|
|
125
|
+
{ degradeOnExhaustion: true });
|
|
126
|
+
};
|
|
75
127
|
export async function phaseVerifyTooling(deps, research) {
|
|
76
128
|
const commands = extractToolingCommands(research);
|
|
77
129
|
if (!commands || commands.length === 0) {
|
package/dist/task/prompts.d.ts
CHANGED
|
@@ -47,7 +47,7 @@ export declare const COMPRESS_LABEL_PROMPT: (title: string, maxChars: number) =>
|
|
|
47
47
|
* "Scaffold …" title re-expands the entire design into one task (validated: a real
|
|
48
48
|
* /task-auto run implemented all 24 steps under step 1).
|
|
49
49
|
*/
|
|
50
|
-
declare const REFINE_PROMPT: (raw: string, planContext?: string) => string;
|
|
50
|
+
declare const REFINE_PROMPT: (raw: string, planContext?: string, existingFiles?: string) => string;
|
|
51
51
|
declare const RESEARCH_READ_ONLY_CONSTRAINT = "IMPORTANT: You are ONLY allowed to READ. Do NOT create, modify, or delete any files. Use the read, grep, find, and ls tools to inspect the repo.";
|
|
52
52
|
declare const RESEARCH_FILES_PROMPT: (refined: string) => string;
|
|
53
53
|
declare const RESEARCH_APIS_PROMPT: (refined: string) => string;
|
package/dist/task/prompts.js
CHANGED
|
@@ -61,7 +61,7 @@ ${title}`;
|
|
|
61
61
|
* "Scaffold …" title re-expands the entire design into one task (validated: a real
|
|
62
62
|
* /task-auto run implemented all 24 steps under step 1).
|
|
63
63
|
*/
|
|
64
|
-
const REFINE_PROMPT = (raw, planContext) => `${planContext ? planContext + '\n\n---\n\n' : ''}You receive a user's task description for an AI coding agent. Rewrite it to be unambiguous and actionable.
|
|
64
|
+
const REFINE_PROMPT = (raw, planContext, existingFiles) => `${planContext ? planContext + '\n\n---\n\n' : ''}You receive a user's task description for an AI coding agent. Rewrite it to be unambiguous and actionable.
|
|
65
65
|
|
|
66
66
|
Output structure (four sections, exact headings, in this order):
|
|
67
67
|
|
|
@@ -87,7 +87,7 @@ Rules:
|
|
|
87
87
|
- Do not invent requirements not implied by the input.
|
|
88
88
|
- If the task references a design/spec document (an @-path or a named spec file), READ it and treat it as authoritative. Carry its concrete schema verbatim into GOAL/CONSTRAINTS — table and column names, types, endpoint methods and paths, enum values. The task title is only a pointer into that spec: where the title and the spec disagree, follow the spec, and never introduce a table, column, endpoint, or dependency the spec does not define.
|
|
89
89
|
- Do not output any preamble, commentary, or markdown headings beyond the four sections above.
|
|
90
|
-
|
|
90
|
+
${existingFiles && existingFiles.trim() ? `\n${existingFiles.trim()}\n` : ''}
|
|
91
91
|
Task: ${raw}`;
|
|
92
92
|
// ─── Research fan-out prompts ─────────────────────────────────────────────────
|
|
93
93
|
const RESEARCH_READ_ONLY_CONSTRAINT = `IMPORTANT: You are ONLY allowed to READ. Do NOT create, modify, or delete any files. Use the read, grep, find, and ls tools to inspect the repo.`;
|