@mjasnikovs/pi-task 0.2.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/LICENSE +21 -0
- package/README.md +125 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +6 -0
- package/dist/shared/child-output.d.ts +21 -0
- package/dist/shared/child-output.js +40 -0
- package/dist/shared/child-process.d.ts +71 -0
- package/dist/shared/child-process.js +190 -0
- package/dist/shared/pi-invocation.d.ts +7 -0
- package/dist/shared/pi-invocation.js +24 -0
- package/dist/task/child-runner.d.ts +66 -0
- package/dist/task/child-runner.js +157 -0
- package/dist/task/enrichment.d.ts +12 -0
- package/dist/task/enrichment.js +82 -0
- package/dist/task/failure-classifier.d.ts +15 -0
- package/dist/task/failure-classifier.js +63 -0
- package/dist/task/file-inventory.d.ts +9 -0
- package/dist/task/file-inventory.js +44 -0
- package/dist/task/loop-detector.d.ts +32 -0
- package/dist/task/loop-detector.js +46 -0
- package/dist/task/orchestrator.d.ts +54 -0
- package/dist/task/orchestrator.js +387 -0
- package/dist/task/parsers.d.ts +32 -0
- package/dist/task/parsers.js +172 -0
- package/dist/task/phases.d.ts +56 -0
- package/dist/task/phases.js +477 -0
- package/dist/task/prompts.d.ts +21 -0
- package/dist/task/prompts.js +346 -0
- package/dist/task/service-blocks.d.ts +3 -0
- package/dist/task/service-blocks.js +10 -0
- package/dist/task/task-file.d.ts +14 -0
- package/dist/task/task-file.js +15 -0
- package/dist/task/task-io.d.ts +19 -0
- package/dist/task/task-io.js +78 -0
- package/dist/task/task-parsers.d.ts +12 -0
- package/dist/task/task-parsers.js +75 -0
- package/dist/task/task-types.d.ts +21 -0
- package/dist/task/task-types.js +18 -0
- package/dist/task/timings.d.ts +18 -0
- package/dist/task/timings.js +36 -0
- package/dist/task/widget.d.ts +39 -0
- package/dist/task/widget.js +122 -0
- package/dist/workers/brave-search.d.ts +17 -0
- package/dist/workers/brave-search.js +77 -0
- package/dist/workers/docs-cache.d.ts +16 -0
- package/dist/workers/docs-cache.js +66 -0
- package/dist/workers/docs-core.d.ts +86 -0
- package/dist/workers/docs-core.js +329 -0
- package/dist/workers/docs-index.d.ts +9 -0
- package/dist/workers/docs-index.js +200 -0
- package/dist/workers/docs-resolve.d.ts +12 -0
- package/dist/workers/docs-resolve.js +126 -0
- package/dist/workers/docs-retrieve.d.ts +15 -0
- package/dist/workers/docs-retrieve.js +91 -0
- package/dist/workers/fetch-core.d.ts +35 -0
- package/dist/workers/fetch-core.js +91 -0
- package/dist/workers/html-clean.d.ts +17 -0
- package/dist/workers/html-clean.js +142 -0
- package/dist/workers/index.d.ts +2 -0
- package/dist/workers/index.js +10 -0
- package/dist/workers/npm-version.d.ts +32 -0
- package/dist/workers/npm-version.js +102 -0
- package/dist/workers/pi-worker-core.d.ts +28 -0
- package/dist/workers/pi-worker-core.js +29 -0
- package/dist/workers/pi-worker-docs.d.ts +16 -0
- package/dist/workers/pi-worker-docs.js +143 -0
- package/dist/workers/pi-worker-fetch.d.ts +20 -0
- package/dist/workers/pi-worker-fetch.js +72 -0
- package/dist/workers/pi-worker-search.d.ts +7 -0
- package/dist/workers/pi-worker-search.js +55 -0
- package/dist/workers/pi-worker.d.ts +10 -0
- package/dist/workers/pi-worker.js +61 -0
- package/dist/workers/search-core.d.ts +19 -0
- package/dist/workers/search-core.js +35 -0
- package/dist/workers/shared.d.ts +3 -0
- package/dist/workers/shared.js +4 -0
- package/package.json +50 -0
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-task — deterministic spec orchestrator for local models.
|
|
3
|
+
*
|
|
4
|
+
* Drives the prompt through five phases — refine → research → grill → compose →
|
|
5
|
+
* critique — then hands the final spec to the main pi thread via
|
|
6
|
+
* pi.sendUserMessage so the user can keep working in the main conversation.
|
|
7
|
+
*
|
|
8
|
+
* Slash commands:
|
|
9
|
+
* /task <prompt> start a new task
|
|
10
|
+
* /task-list open the task list in an editor dialog
|
|
11
|
+
* /task-resume [id] resume the most recent (or named) non-completed task
|
|
12
|
+
* /task-cancel cancel the running task (soft-terminal — still resumable)
|
|
13
|
+
*
|
|
14
|
+
* The orchestrator persists after every phase boundary to
|
|
15
|
+
* <cwd>/.pi-tasks/TASK_NNNN.md. All user interaction during phases runs through
|
|
16
|
+
* ctx.ui dialogs; the main pi chat only receives the final spec.
|
|
17
|
+
*/
|
|
18
|
+
import * as fsp from 'node:fs/promises';
|
|
19
|
+
import * as path from 'node:path';
|
|
20
|
+
import { PHASES, postCommitPhase } from './phases.js';
|
|
21
|
+
import { handleFailure } from './failure-classifier.js';
|
|
22
|
+
import { PHASE_INDEX, PHASE_ORDER, allocateTaskId, ensureTasksDir, normaliseTaskId, parseFrontMatter, readSection, readTaskFile, setTaskSection, taskFilePath, tasksDir, updateTaskFrontMatter, writeTaskFile, extractSection, RESUMABLE_STATES } from './task-file.js';
|
|
23
|
+
import { startWidget, WIDGET_KEY } from './widget.js';
|
|
24
|
+
import { parseVerifyBlock } from './parsers.js';
|
|
25
|
+
import { formatTimings } from './timings.js';
|
|
26
|
+
// ─── Module-level state ──────────────────────────────────────────────────────
|
|
27
|
+
let activeTask = null;
|
|
28
|
+
/** Set the module-level active task (avoids `this` aliasing in TaskRunner.run). */
|
|
29
|
+
function setActiveTask(runner) {
|
|
30
|
+
activeTask = runner;
|
|
31
|
+
}
|
|
32
|
+
function clearActiveTask(runner) {
|
|
33
|
+
if (activeTask === runner) {
|
|
34
|
+
activeTask = null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
// Captured from the factory so command handlers can call pi.sendUserMessage.
|
|
38
|
+
let piApi = null;
|
|
39
|
+
// ─── TaskRunner class ────────────────────────────────────────────────────────
|
|
40
|
+
/** Encapsulates the full lifecycle of a single pi-task run. */
|
|
41
|
+
export class TaskRunner {
|
|
42
|
+
_ctx;
|
|
43
|
+
_cwd;
|
|
44
|
+
_rawPrompt;
|
|
45
|
+
_resumeId;
|
|
46
|
+
_sendSpec;
|
|
47
|
+
_abort = new AbortController();
|
|
48
|
+
_startedAt;
|
|
49
|
+
_widgetState;
|
|
50
|
+
_stopWidget = null;
|
|
51
|
+
_deps;
|
|
52
|
+
_pc;
|
|
53
|
+
/**
|
|
54
|
+
* Per-phase wall-clock durations collected during the run. Written to the
|
|
55
|
+
* `## phase timings` section on successful completion so we can spot
|
|
56
|
+
* regressions and target future speed work. Each top-level entry is a
|
|
57
|
+
* phase (refine/research/grill/compose/critique); children are optional
|
|
58
|
+
* sub-step splits the phase chose to record via deps.recordSubStep.
|
|
59
|
+
*/
|
|
60
|
+
_timings = [];
|
|
61
|
+
_currentPhaseChildren = null;
|
|
62
|
+
constructor(ctx, cwd, rawPrompt, resumeId, sendSpec, spawnFn) {
|
|
63
|
+
this._ctx = ctx;
|
|
64
|
+
this._cwd = cwd;
|
|
65
|
+
this._rawPrompt = rawPrompt;
|
|
66
|
+
this._resumeId = resumeId;
|
|
67
|
+
this._sendSpec = sendSpec;
|
|
68
|
+
this._startedAt = Date.now();
|
|
69
|
+
// We'll populate id/title/phase lazily in run().
|
|
70
|
+
// Placeholder — real values set in run().
|
|
71
|
+
this._widgetState = {
|
|
72
|
+
taskId: '',
|
|
73
|
+
title: '',
|
|
74
|
+
phase: 'refine',
|
|
75
|
+
startedAt: this._startedAt
|
|
76
|
+
};
|
|
77
|
+
const parentContextWindow = ctx.model?.contextWindow ?? 0;
|
|
78
|
+
this._deps = {
|
|
79
|
+
cwd,
|
|
80
|
+
taskId: '',
|
|
81
|
+
signal: this._abort.signal,
|
|
82
|
+
spawn: spawnFn,
|
|
83
|
+
onChildOutput: (line) => {
|
|
84
|
+
this._widgetState.lastLine = line;
|
|
85
|
+
},
|
|
86
|
+
onContextUsage: snapshot => {
|
|
87
|
+
const prev = this._widgetState.contextUsage;
|
|
88
|
+
const cw = snapshot.contextWindow > 0 ?
|
|
89
|
+
snapshot.contextWindow
|
|
90
|
+
: prev?.contextWindow || parentContextWindow;
|
|
91
|
+
const percent = cw > 0 ? Math.min(100, (snapshot.tokens / cw) * 100) : snapshot.percent;
|
|
92
|
+
this._widgetState.contextUsage = {
|
|
93
|
+
tokens: snapshot.tokens,
|
|
94
|
+
contextWindow: cw,
|
|
95
|
+
percent
|
|
96
|
+
};
|
|
97
|
+
},
|
|
98
|
+
recordSubStep: (label, ms) => {
|
|
99
|
+
if (this._currentPhaseChildren) {
|
|
100
|
+
this._currentPhaseChildren.push({ label, ms, children: [] });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
this._pc = {
|
|
105
|
+
cwd,
|
|
106
|
+
id: '',
|
|
107
|
+
ctx,
|
|
108
|
+
widgetState: this._widgetState,
|
|
109
|
+
rawPrompt,
|
|
110
|
+
refined: '',
|
|
111
|
+
research: '',
|
|
112
|
+
qa: '',
|
|
113
|
+
spec: ''
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
get taskId() {
|
|
117
|
+
return this._widgetState.taskId;
|
|
118
|
+
}
|
|
119
|
+
get signal() {
|
|
120
|
+
return this._abort.signal;
|
|
121
|
+
}
|
|
122
|
+
/** Return the current widget state, or null if not started. */
|
|
123
|
+
status() {
|
|
124
|
+
return this._widgetState.taskId ? this._widgetState : null;
|
|
125
|
+
}
|
|
126
|
+
/** Cancel the running task by aborting the signal. */
|
|
127
|
+
cancel() {
|
|
128
|
+
this._abort.abort();
|
|
129
|
+
}
|
|
130
|
+
/** Execute the full task lifecycle. */
|
|
131
|
+
async run() {
|
|
132
|
+
const cwd = this._cwd;
|
|
133
|
+
const ctx = this._ctx;
|
|
134
|
+
// Initialise or resume the TASK file.
|
|
135
|
+
let id;
|
|
136
|
+
let title;
|
|
137
|
+
let resumePhase = 'refine';
|
|
138
|
+
if (this._resumeId) {
|
|
139
|
+
id = this._resumeId;
|
|
140
|
+
const { frontMatter } = await readTaskFile(cwd, id);
|
|
141
|
+
title = frontMatter.title;
|
|
142
|
+
resumePhase = frontMatter.phase;
|
|
143
|
+
await updateTaskFrontMatter(cwd, id, { state: 'in_progress' });
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
id = await allocateTaskId(cwd);
|
|
147
|
+
title = '(refining…)';
|
|
148
|
+
const now = new Date().toISOString();
|
|
149
|
+
const fm = {
|
|
150
|
+
id,
|
|
151
|
+
state: 'in_progress',
|
|
152
|
+
phase: 'refine',
|
|
153
|
+
created_at: now,
|
|
154
|
+
updated_at: now,
|
|
155
|
+
title
|
|
156
|
+
};
|
|
157
|
+
await writeTaskFile(cwd, fm, `\n## raw prompt\n\n${this._rawPrompt.trim() || '(none)'}\n`);
|
|
158
|
+
}
|
|
159
|
+
// Register as active.
|
|
160
|
+
this._widgetState.taskId = id;
|
|
161
|
+
this._widgetState.title = title;
|
|
162
|
+
this._widgetState.phase = resumePhase;
|
|
163
|
+
this._widgetState.startedAt = this._startedAt;
|
|
164
|
+
this._deps.taskId = id;
|
|
165
|
+
this._pc.id = id;
|
|
166
|
+
setActiveTask(this);
|
|
167
|
+
this._stopWidget = startWidget(ctx, () => this.status());
|
|
168
|
+
const advance = async (phase) => {
|
|
169
|
+
this._widgetState.phase = phase;
|
|
170
|
+
this._widgetState.lastLine = undefined;
|
|
171
|
+
this._widgetState.contextUsage = undefined;
|
|
172
|
+
await updateTaskFrontMatter(cwd, id, { phase });
|
|
173
|
+
};
|
|
174
|
+
try {
|
|
175
|
+
const resumeIdx = PHASE_INDEX[resumePhase];
|
|
176
|
+
if (resumeIdx === 0) {
|
|
177
|
+
const { body } = await readTaskFile(cwd, id);
|
|
178
|
+
const onDisk = extractSection(body, 'raw prompt');
|
|
179
|
+
if (onDisk)
|
|
180
|
+
this._pc.rawPrompt = onDisk;
|
|
181
|
+
}
|
|
182
|
+
for (const phase of PHASES) {
|
|
183
|
+
const idx = PHASE_INDEX[phase.name];
|
|
184
|
+
if (idx < resumeIdx) {
|
|
185
|
+
this._pc[phase.field] = (await readSection(cwd, id, phase.section)) ?? '';
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
await advance(phase.name);
|
|
189
|
+
const children = [];
|
|
190
|
+
this._currentPhaseChildren = children;
|
|
191
|
+
const phaseStart = Date.now();
|
|
192
|
+
let out;
|
|
193
|
+
try {
|
|
194
|
+
out = await phase.run(this._deps, this._pc);
|
|
195
|
+
}
|
|
196
|
+
finally {
|
|
197
|
+
this._timings.push({
|
|
198
|
+
label: phase.name,
|
|
199
|
+
ms: Date.now() - phaseStart,
|
|
200
|
+
children
|
|
201
|
+
});
|
|
202
|
+
this._currentPhaseChildren = null;
|
|
203
|
+
}
|
|
204
|
+
await setTaskSection(cwd, id, phase.section, out);
|
|
205
|
+
this._pc[phase.field] = out;
|
|
206
|
+
await postCommitPhase(phase, this._pc, out);
|
|
207
|
+
}
|
|
208
|
+
// All phases done — hand off the spec.
|
|
209
|
+
await advance('done');
|
|
210
|
+
if (parseVerifyBlock(this._pc.spec) === null)
|
|
211
|
+
throw new Error('no_verify_block');
|
|
212
|
+
await updateTaskFrontMatter(cwd, id, { state: 'completed', phase: 'done' });
|
|
213
|
+
this._stopWidget?.();
|
|
214
|
+
try {
|
|
215
|
+
ctx.ui.setWidget(WIDGET_KEY, undefined);
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
/* stale ctx */
|
|
219
|
+
}
|
|
220
|
+
await setTaskSection(cwd, id, 'phase timings', formatTimings(this._timings));
|
|
221
|
+
await setTaskSection(cwd, id, 'handoff', `handoff_at: ${new Date().toISOString()}`);
|
|
222
|
+
await this._deliverSpec(ctx);
|
|
223
|
+
}
|
|
224
|
+
catch (err) {
|
|
225
|
+
this._stopWidget?.();
|
|
226
|
+
// Persist whatever timings we collected so failed runs are still
|
|
227
|
+
// useful for analysis. Best-effort — never mask the original error.
|
|
228
|
+
if (this._timings.length > 0) {
|
|
229
|
+
try {
|
|
230
|
+
await setTaskSection(cwd, id, 'phase timings', formatTimings(this._timings));
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
/* ignore — preserve original failure */
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
await handleFailure(err, ctx, cwd, id, this._abort.signal.aborted);
|
|
237
|
+
}
|
|
238
|
+
finally {
|
|
239
|
+
this._stopWidget?.();
|
|
240
|
+
clearActiveTask(this);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
async _deliverSpec(ctx) {
|
|
244
|
+
if (this._sendSpec) {
|
|
245
|
+
await this._sendSpec(this._pc.spec);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (!piApi) {
|
|
249
|
+
throw new Error('extension not initialised (no ExtensionAPI captured)');
|
|
250
|
+
}
|
|
251
|
+
if (ctx.isIdle()) {
|
|
252
|
+
piApi.sendUserMessage(this._pc.spec);
|
|
253
|
+
}
|
|
254
|
+
else {
|
|
255
|
+
piApi.sendUserMessage(this._pc.spec, { deliverAs: 'followUp' });
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
// ─── Command handlers ────────────────────────────────────────────────────────
|
|
260
|
+
async function handleTask(args, ctx) {
|
|
261
|
+
await ctx.waitForIdle();
|
|
262
|
+
const cwd = ctx.cwd;
|
|
263
|
+
const raw = args.trim();
|
|
264
|
+
if (raw.length === 0) {
|
|
265
|
+
ctx.ui.setEditorText('/task ');
|
|
266
|
+
ctx.ui.notify('Type your prompt after /task (use @ for file completion).', 'info');
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
const result = await ctx.newSession({
|
|
270
|
+
withSession: async (newCtx) => {
|
|
271
|
+
const runner = new TaskRunner(newCtx, cwd, raw, undefined, async (spec) => {
|
|
272
|
+
await newCtx.sendUserMessage(spec);
|
|
273
|
+
});
|
|
274
|
+
await runner.run();
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
if (result.cancelled) {
|
|
278
|
+
ctx.ui.notify('Could not start a fresh session for /task.', 'warning');
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
async function handleTaskList(_args, ctx) {
|
|
282
|
+
const cwd = ctx.cwd;
|
|
283
|
+
await ensureTasksDir(cwd);
|
|
284
|
+
const entries = await fsp.readdir(tasksDir(cwd));
|
|
285
|
+
const taskFiles = entries.filter((e) => /^TASK_\d+\.md$/.test(e));
|
|
286
|
+
const rows = [];
|
|
287
|
+
for (const f of taskFiles) {
|
|
288
|
+
try {
|
|
289
|
+
const raw = await fsp.readFile(path.join(tasksDir(cwd), f), 'utf8');
|
|
290
|
+
const fm = parseFrontMatter(raw);
|
|
291
|
+
if (!fm)
|
|
292
|
+
continue;
|
|
293
|
+
const st = await fsp.stat(path.join(tasksDir(cwd), f));
|
|
294
|
+
rows.push({ fm, mtime: st.mtimeMs });
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
/* skip unreadable */
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
rows.sort((a, b) => b.mtime - a.mtime);
|
|
301
|
+
const lines = [];
|
|
302
|
+
for (const { fm } of rows) {
|
|
303
|
+
const idx = PHASE_INDEX[fm.phase];
|
|
304
|
+
const phasePart = `phase ${Math.min(idx + 1, PHASE_ORDER.length)}/${PHASE_ORDER.length} ${fm.phase}`;
|
|
305
|
+
const date = fm.updated_at.replace('T', ' ').slice(0, 16);
|
|
306
|
+
lines.push(`${fm.id} ${fm.state.padEnd(12)} ${phasePart.padEnd(24)} ${date} "${fm.title}"`);
|
|
307
|
+
}
|
|
308
|
+
if (lines.length === 0)
|
|
309
|
+
lines.push('(no tasks in .pi-tasks/)');
|
|
310
|
+
lines.push('', 'resume: /task-resume <id> (eligible: in_progress, pending, cancelled, failed)');
|
|
311
|
+
await ctx.ui.editor('Tasks', lines.join('\n'));
|
|
312
|
+
}
|
|
313
|
+
async function handleTaskResume(args, ctx) {
|
|
314
|
+
await ctx.waitForIdle();
|
|
315
|
+
const cwd = ctx.cwd;
|
|
316
|
+
let id;
|
|
317
|
+
if (args.trim().length > 0) {
|
|
318
|
+
id = normaliseTaskId(args);
|
|
319
|
+
try {
|
|
320
|
+
await fsp.access(taskFilePath(cwd, id));
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
ctx.ui.notify(`${id} not found in .pi-tasks/`, 'error');
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
else {
|
|
328
|
+
await ensureTasksDir(cwd);
|
|
329
|
+
const entries = await fsp.readdir(tasksDir(cwd));
|
|
330
|
+
const candidates = [];
|
|
331
|
+
for (const f of entries) {
|
|
332
|
+
const m = /^(TASK_\d+)\.md$/.exec(f);
|
|
333
|
+
if (!m)
|
|
334
|
+
continue;
|
|
335
|
+
try {
|
|
336
|
+
const raw = await fsp.readFile(path.join(tasksDir(cwd), f), 'utf8');
|
|
337
|
+
const fm = parseFrontMatter(raw);
|
|
338
|
+
if (!fm)
|
|
339
|
+
continue;
|
|
340
|
+
if (!RESUMABLE_STATES.includes(fm.state))
|
|
341
|
+
continue;
|
|
342
|
+
const st = await fsp.stat(path.join(tasksDir(cwd), f));
|
|
343
|
+
candidates.push({ id: m[1], mtime: st.mtimeMs });
|
|
344
|
+
}
|
|
345
|
+
catch {
|
|
346
|
+
/* skip */
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
candidates.sort((a, b) => b.mtime - a.mtime);
|
|
350
|
+
if (candidates.length === 0) {
|
|
351
|
+
ctx.ui.notify('No resumable tasks.', 'info');
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
id = candidates[0].id;
|
|
355
|
+
}
|
|
356
|
+
const runner = new TaskRunner(ctx, cwd, '', id);
|
|
357
|
+
await runner.run();
|
|
358
|
+
}
|
|
359
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
360
|
+
async function handleTaskCancel(_args, ctx) {
|
|
361
|
+
if (!activeTask) {
|
|
362
|
+
ctx.ui.notify('No task is running.', 'info');
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
activeTask.cancel();
|
|
366
|
+
ctx.ui.notify(`Cancelling ${activeTask.taskId}…`, 'warning');
|
|
367
|
+
}
|
|
368
|
+
// ─── Entry point ─────────────────────────────────────────────────────────────
|
|
369
|
+
export function registerTask(pi) {
|
|
370
|
+
piApi = pi;
|
|
371
|
+
pi.registerCommand('task', {
|
|
372
|
+
description: 'Start a new task. Usage: /task <prompt>',
|
|
373
|
+
handler: handleTask
|
|
374
|
+
});
|
|
375
|
+
pi.registerCommand('task-list', {
|
|
376
|
+
description: 'List tasks in this project.',
|
|
377
|
+
handler: handleTaskList
|
|
378
|
+
});
|
|
379
|
+
pi.registerCommand('task-resume', {
|
|
380
|
+
description: 'Resume a task. Usage: /task-resume [id]',
|
|
381
|
+
handler: handleTaskResume
|
|
382
|
+
});
|
|
383
|
+
pi.registerCommand('task-cancel', {
|
|
384
|
+
description: 'Cancel the currently running task.',
|
|
385
|
+
handler: handleTaskCancel
|
|
386
|
+
});
|
|
387
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output parsers for the pi-task pipeline.
|
|
3
|
+
*
|
|
4
|
+
* Pure functions that parse raw model output into structured data.
|
|
5
|
+
*/
|
|
6
|
+
export interface VerifyCommand {
|
|
7
|
+
raw: string;
|
|
8
|
+
}
|
|
9
|
+
export type AutoAnswer = {
|
|
10
|
+
kind: 'answered';
|
|
11
|
+
text: string;
|
|
12
|
+
raw: string;
|
|
13
|
+
} | {
|
|
14
|
+
kind: 'unknown';
|
|
15
|
+
suggested?: string;
|
|
16
|
+
raw: string;
|
|
17
|
+
};
|
|
18
|
+
export declare const GRILL_LINE_RE: RegExp;
|
|
19
|
+
export declare const TITLE_MAX_CHARS = 120;
|
|
20
|
+
export declare function parseVerifyBlock(spec: string): VerifyCommand[] | null;
|
|
21
|
+
export declare function parseGrillQuestions(raw: string): string[];
|
|
22
|
+
export declare function parseAutoAnswer(raw: string): AutoAnswer;
|
|
23
|
+
export declare function parseVerifyToolingOutput(output: string): {
|
|
24
|
+
verified: string[];
|
|
25
|
+
rejected: Array<{
|
|
26
|
+
cmd: string;
|
|
27
|
+
reason: string;
|
|
28
|
+
}>;
|
|
29
|
+
};
|
|
30
|
+
export declare function isCritiqueClean(text: string): boolean;
|
|
31
|
+
export declare function validateSpecShape(spec: string): string | null;
|
|
32
|
+
export declare function deriveTitle(refined: string): string;
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output parsers for the pi-task pipeline.
|
|
3
|
+
*
|
|
4
|
+
* Pure functions that parse raw model output into structured data.
|
|
5
|
+
*/
|
|
6
|
+
import { MAX_GRILL_QUESTIONS } from './phases.js';
|
|
7
|
+
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
8
|
+
export const GRILL_LINE_RE = /^\s*\d+[.)]\s+(.+)$/;
|
|
9
|
+
export const TITLE_MAX_CHARS = 120;
|
|
10
|
+
// ─── Verify block parser ─────────────────────────────────────────────────────
|
|
11
|
+
export function parseVerifyBlock(spec) {
|
|
12
|
+
const lines = spec.split('\n');
|
|
13
|
+
let i = 0;
|
|
14
|
+
while (i < lines.length && !/^VERIFY:\s*$/.test(lines[i]))
|
|
15
|
+
i++;
|
|
16
|
+
if (i >= lines.length)
|
|
17
|
+
return null;
|
|
18
|
+
i++;
|
|
19
|
+
while (i < lines.length && lines[i].trim() === '')
|
|
20
|
+
i++;
|
|
21
|
+
if (i >= lines.length)
|
|
22
|
+
return null;
|
|
23
|
+
if (!/^```(sh|bash)?\s*$/.test(lines[i]))
|
|
24
|
+
return null;
|
|
25
|
+
i++;
|
|
26
|
+
const cmds = [];
|
|
27
|
+
while (i < lines.length && !/^```\s*$/.test(lines[i])) {
|
|
28
|
+
const line = lines[i].trim();
|
|
29
|
+
if (line.length > 0 && !line.startsWith('#'))
|
|
30
|
+
cmds.push({ raw: line });
|
|
31
|
+
i++;
|
|
32
|
+
}
|
|
33
|
+
return cmds;
|
|
34
|
+
}
|
|
35
|
+
// ─── Grill questions parser ──────────────────────────────────────────────────
|
|
36
|
+
// The grill-gen prompt instructs the worker to emit the literal token `NONE`
|
|
37
|
+
// when it has zero questions, so the runner's empty-output guard can still
|
|
38
|
+
// distinguish "intentional silence" from a silent child crash.
|
|
39
|
+
export function parseGrillQuestions(raw) {
|
|
40
|
+
if (/^\s*NONE\s*$/m.test(raw))
|
|
41
|
+
return [];
|
|
42
|
+
const out = [];
|
|
43
|
+
for (const line of raw.split('\n')) {
|
|
44
|
+
const m = GRILL_LINE_RE.exec(line);
|
|
45
|
+
if (m)
|
|
46
|
+
out.push(m[1].trim());
|
|
47
|
+
if (out.length >= MAX_GRILL_QUESTIONS)
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
// ─── Auto-answer parser ──────────────────────────────────────────────────────
|
|
53
|
+
export function parseAutoAnswer(raw) {
|
|
54
|
+
const lines = raw
|
|
55
|
+
.split('\n')
|
|
56
|
+
.map(l => l.trim())
|
|
57
|
+
.filter(l => l.length > 0);
|
|
58
|
+
for (let i = 0; i < lines.length; i++) {
|
|
59
|
+
const t = lines[i];
|
|
60
|
+
const a = /^AN[SW]{1,3}E?R:\s*(.+)$/i.exec(t);
|
|
61
|
+
if (a)
|
|
62
|
+
return { kind: 'answered', text: a[1].trim(), raw };
|
|
63
|
+
const u = /^UNKNOWN:\s*(.*)$/i.exec(t);
|
|
64
|
+
if (u) {
|
|
65
|
+
const inline = u[1].trim();
|
|
66
|
+
if (inline.length > 0)
|
|
67
|
+
return { kind: 'unknown', suggested: inline, raw };
|
|
68
|
+
const next = lines[i + 1];
|
|
69
|
+
if (next && next.length > 0)
|
|
70
|
+
return { kind: 'unknown', suggested: next, raw };
|
|
71
|
+
return { kind: 'unknown', raw };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (lines.length > 0)
|
|
75
|
+
return { kind: 'unknown', suggested: lines[0], raw };
|
|
76
|
+
return { kind: 'unknown', raw };
|
|
77
|
+
}
|
|
78
|
+
// ─── Verify tooling output parser ────────────────────────────────────────────
|
|
79
|
+
export function parseVerifyToolingOutput(output) {
|
|
80
|
+
const verified = [];
|
|
81
|
+
const rejected = [];
|
|
82
|
+
let section = null;
|
|
83
|
+
for (const raw of output.split('\n')) {
|
|
84
|
+
const line = raw.trim();
|
|
85
|
+
if (line === 'VERIFIED') {
|
|
86
|
+
section = 'verified';
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (line === 'REJECTED') {
|
|
90
|
+
section = 'rejected';
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (!line)
|
|
94
|
+
continue;
|
|
95
|
+
// Lines look like: " <cmd> <evidence/reason>"
|
|
96
|
+
const match = line.match(/^(\S.*?)\s{2,}(.+)$/);
|
|
97
|
+
if (!match)
|
|
98
|
+
continue;
|
|
99
|
+
const [, cmd, detail] = match;
|
|
100
|
+
if (section === 'verified')
|
|
101
|
+
verified.push(cmd.trim());
|
|
102
|
+
else if (section === 'rejected')
|
|
103
|
+
rejected.push({ cmd: cmd.trim(), reason: detail.trim() });
|
|
104
|
+
}
|
|
105
|
+
return { verified, rejected };
|
|
106
|
+
}
|
|
107
|
+
// ─── Critique triage parser ──────────────────────────────────────────────────
|
|
108
|
+
// The critique-triage prompt instructs the worker to emit the literal token
|
|
109
|
+
// `CLEAN` on its own line when the compose draft has no substantive defects, so
|
|
110
|
+
// we can skip the expensive full-rewrite pass. Anything else is treated as a
|
|
111
|
+
// defect list that gets fed into the rewrite. Empty output is NOT clean — that
|
|
112
|
+
// would be a silent crash, and treating it as clean would skip review entirely.
|
|
113
|
+
export function isCritiqueClean(text) {
|
|
114
|
+
const firstLine = text
|
|
115
|
+
.split('\n')
|
|
116
|
+
.map(l => l.trim())
|
|
117
|
+
.find(l => l.length > 0);
|
|
118
|
+
if (!firstLine)
|
|
119
|
+
return false;
|
|
120
|
+
return /^CLEAN[.!]?$/i.test(firstLine);
|
|
121
|
+
}
|
|
122
|
+
// ─── Spec shape validator ────────────────────────────────────────────────────
|
|
123
|
+
export function validateSpecShape(spec) {
|
|
124
|
+
const trimmed = spec.trim();
|
|
125
|
+
if (trimmed.length === 0)
|
|
126
|
+
return 'spec is empty';
|
|
127
|
+
const firstLine = trimmed.split('\n', 1)[0];
|
|
128
|
+
if (/^\s*```/.test(firstLine))
|
|
129
|
+
return 'spec starts with a markdown fence';
|
|
130
|
+
if (/^\s*cat\s*<<\s*['"]?[A-Za-z_][A-Za-z0-9_]*['"]?/.test(firstLine)) {
|
|
131
|
+
return 'spec is wrapped in a cat heredoc';
|
|
132
|
+
}
|
|
133
|
+
if (!/^GOAL\b/i.test(trimmed))
|
|
134
|
+
return 'spec does not start with GOAL';
|
|
135
|
+
for (const section of ['CONSTRAINTS', 'ACCEPTANCE', 'VERIFY']) {
|
|
136
|
+
if (!new RegExp(`^\\s*${section}\\b`, 'm').test(trimmed)) {
|
|
137
|
+
return `spec missing required section: ${section}`;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
// ─── Title derivation ────────────────────────────────────────────────────────
|
|
143
|
+
export function deriveTitle(refined) {
|
|
144
|
+
const lines = refined.split('\n');
|
|
145
|
+
for (let i = 0; i < lines.length; i++) {
|
|
146
|
+
const stripped = lines[i].trim().replace(/^#+\s+/, '');
|
|
147
|
+
if (/^GOAL\s*:?\s*$/i.test(stripped)) {
|
|
148
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
149
|
+
const line = lines[j].trim();
|
|
150
|
+
if (line.length === 0)
|
|
151
|
+
continue;
|
|
152
|
+
const headerCheck = line.replace(/^#+\s+/, '');
|
|
153
|
+
if (/^(CONSTRAINTS|KNOWN-UNKNOWNS)\s*:?\s*$/i.test(headerCheck))
|
|
154
|
+
break;
|
|
155
|
+
return line.length > TITLE_MAX_CHARS ?
|
|
156
|
+
line.slice(0, TITLE_MAX_CHARS - 1) + '…'
|
|
157
|
+
: line;
|
|
158
|
+
}
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
for (const raw of lines) {
|
|
163
|
+
let line = raw.trim();
|
|
164
|
+
if (line.length === 0)
|
|
165
|
+
continue;
|
|
166
|
+
line = line.replace(/^#+\s+/, '').replace(/^GOAL\s*:?\s*/i, '');
|
|
167
|
+
if (line.length === 0)
|
|
168
|
+
continue;
|
|
169
|
+
return line.length > TITLE_MAX_CHARS ? line.slice(0, TITLE_MAX_CHARS - 1) + '…' : line;
|
|
170
|
+
}
|
|
171
|
+
return '(untitled)';
|
|
172
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase pipeline — the five phase functions (refine, research, grill, compose,
|
|
3
|
+
* critique) plus the config table that drives the orchestrator loop.
|
|
4
|
+
*/
|
|
5
|
+
import type { ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
|
|
6
|
+
import { docsRaw, docsFocused } from '../workers/docs-core.js';
|
|
7
|
+
import { fetchRaw, fetchFocused } from '../workers/fetch-core.js';
|
|
8
|
+
import type { SearchCoreInput, SearchCoreResult } from '../workers/search-core.js';
|
|
9
|
+
import { type PhaseName } from './task-file.js';
|
|
10
|
+
import { type WidgetState } from './widget.js';
|
|
11
|
+
import { type AutoAnswer } from './parsers.js';
|
|
12
|
+
import { type PhaseDeps } from './child-runner.js';
|
|
13
|
+
export { MAX_GRILL_QUESTIONS } from './prompts.js';
|
|
14
|
+
export interface PhaseContext {
|
|
15
|
+
cwd: string;
|
|
16
|
+
id: string;
|
|
17
|
+
ctx: ExtensionCommandContext;
|
|
18
|
+
widgetState: WidgetState;
|
|
19
|
+
rawPrompt: string;
|
|
20
|
+
refined: string;
|
|
21
|
+
research: string;
|
|
22
|
+
qa: string;
|
|
23
|
+
spec: string;
|
|
24
|
+
}
|
|
25
|
+
export type OutputField = 'refined' | 'research' | 'qa' | 'spec';
|
|
26
|
+
export interface PhaseConfig {
|
|
27
|
+
name: PhaseName;
|
|
28
|
+
section: string;
|
|
29
|
+
field: OutputField;
|
|
30
|
+
run: (deps: PhaseDeps, pc: PhaseContext) => Promise<string>;
|
|
31
|
+
}
|
|
32
|
+
/** Extract the TOOLING section commands from a research output string. */
|
|
33
|
+
export declare function extractToolingCommands(research: string): string[] | null;
|
|
34
|
+
/** Replace the TOOLING section in a research string with a VERIFIED-TOOLING section. */
|
|
35
|
+
export declare function replaceToolingWithVerified(research: string, verifiedCommands: string[]): string;
|
|
36
|
+
export declare const phaseRefine: (deps: PhaseDeps, raw: string) => Promise<string>;
|
|
37
|
+
export declare function phaseVerifyTooling(deps: PhaseDeps, research: string): Promise<string>;
|
|
38
|
+
export interface PhaseResearchDeps {
|
|
39
|
+
docsRaw?: typeof docsRaw;
|
|
40
|
+
fetchRaw?: typeof fetchRaw;
|
|
41
|
+
getFileInventory?: (cwd: string, signal?: AbortSignal) => Promise<string>;
|
|
42
|
+
searchFn?: (input: SearchCoreInput) => Promise<SearchCoreResult>;
|
|
43
|
+
}
|
|
44
|
+
export declare function phaseResearch(deps: PhaseDeps, refined: string, researchDeps?: PhaseResearchDeps): Promise<string>;
|
|
45
|
+
export interface PhaseAutoAnswerDeps {
|
|
46
|
+
docsFocused?: typeof docsFocused;
|
|
47
|
+
fetchFocused?: typeof fetchFocused;
|
|
48
|
+
searchFn?: (input: SearchCoreInput) => Promise<SearchCoreResult>;
|
|
49
|
+
}
|
|
50
|
+
export declare function phaseAutoAnswer(deps: PhaseDeps, refined: string, research: string, question: string, autoDeps?: PhaseAutoAnswerDeps): Promise<AutoAnswer>;
|
|
51
|
+
export declare function phaseGrill(deps: PhaseDeps, ctx: ExtensionCommandContext, widgetState: WidgetState, refined: string, research: string): Promise<string>;
|
|
52
|
+
export declare function phaseCompose(deps: PhaseDeps, refined: string, research: string, qa: string): Promise<string>;
|
|
53
|
+
export declare function phaseCritique(deps: PhaseDeps, spec: string, refined: string, qa: string): Promise<string>;
|
|
54
|
+
export declare function critiqueWithFallback(d: PhaseDeps, p: PhaseContext): Promise<string>;
|
|
55
|
+
export declare const PHASES: PhaseConfig[];
|
|
56
|
+
export declare function postCommitPhase(phase: PhaseConfig, pc: PhaseContext, out: string): Promise<void>;
|