@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,477 @@
|
|
|
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 { docsRaw, docsFocused } from '../workers/docs-core.js';
|
|
6
|
+
import { fetchRaw, fetchFocused } from '../workers/fetch-core.js';
|
|
7
|
+
import { formatNpmVersionSection } from '../workers/npm-version.js';
|
|
8
|
+
import { runWorker } from '../workers/pi-worker-core.js';
|
|
9
|
+
import { search as defaultSearch } from '../workers/search-core.js';
|
|
10
|
+
import { extractEnrichTargets } from './enrichment.js';
|
|
11
|
+
import { getFileInventory } from './file-inventory.js';
|
|
12
|
+
import { formatServiceBlock, formatFreshnessSkippedBlock } from './service-blocks.js';
|
|
13
|
+
import { REFINE_PROMPT, RESEARCH_FILES_PROMPT, RESEARCH_APIS_PROMPT, RESEARCH_CONTEXT_PROMPT, RESEARCH_TOOLING_PROMPT, GRILL_GEN_PROMPT, GRILL_AUTO_ANSWER_PROMPT, COMPOSE_PROMPT, CRITIQUE_PROMPT, CRITIQUE_TRIAGE_PROMPT, VERIFY_TOOLING_PROMPT } from './prompts.js';
|
|
14
|
+
import { setTaskSection, updateTaskFrontMatter } from './task-file.js';
|
|
15
|
+
import { parseVerifyBlock, parseGrillQuestions, parseAutoAnswer, parseVerifyToolingOutput, validateSpecShape, deriveTitle, isCritiqueClean } from './parsers.js';
|
|
16
|
+
import { runPhaseChild, runPhaseWithLoopGuard, runWithEmphasisRetry, prependHint, USER_CANCELLED } from './child-runner.js';
|
|
17
|
+
// ─── Re-export constants from their home modules ────────────────────────────
|
|
18
|
+
export { MAX_GRILL_QUESTIONS } from './prompts.js';
|
|
19
|
+
// ─── Tooling helpers ─────────────────────────────────────────────────────────
|
|
20
|
+
/** Extract the TOOLING section commands from a research output string. */
|
|
21
|
+
export function extractToolingCommands(research) {
|
|
22
|
+
const toolingMatch = /^TOOLING\s*\n([\s\S]*?)(?=^[A-Z][A-Z-]+\s*$|(?![\s\S]))/m.exec(research);
|
|
23
|
+
if (!toolingMatch)
|
|
24
|
+
return null;
|
|
25
|
+
const block = toolingMatch[1];
|
|
26
|
+
const commands = [];
|
|
27
|
+
for (const raw of block.split('\n')) {
|
|
28
|
+
const line = raw.trim();
|
|
29
|
+
if (!line)
|
|
30
|
+
continue;
|
|
31
|
+
const match = line.match(/^\S.*?\s{2,}(.+)$/);
|
|
32
|
+
if (match) {
|
|
33
|
+
commands.push(match[1].trim());
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
commands.push(line);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return commands.length > 0 ? commands : null;
|
|
40
|
+
}
|
|
41
|
+
/** Replace the TOOLING section in a research string with a VERIFIED-TOOLING section. */
|
|
42
|
+
export function replaceToolingWithVerified(research, verifiedCommands) {
|
|
43
|
+
const verifiedBlock = verifiedCommands.length > 0 ?
|
|
44
|
+
verifiedCommands.map(cmd => ` ${cmd}`).join('\n')
|
|
45
|
+
: ' (none verified)';
|
|
46
|
+
const replacement = `VERIFIED-TOOLING\n${verifiedBlock}`;
|
|
47
|
+
const replaced = research.replace(/^TOOLING\s*\n([\s\S]*?)(?=^[A-Z][A-Z-]+\s*$|$(?![\s\S]))/m, replacement + '\n\n');
|
|
48
|
+
if (replaced === research) {
|
|
49
|
+
return research + `\n\n${replacement}`;
|
|
50
|
+
}
|
|
51
|
+
return replaced;
|
|
52
|
+
}
|
|
53
|
+
// ─── Phase functions ─────────────────────────────────────────────────────────
|
|
54
|
+
export const phaseRefine = (deps, raw) => runPhaseWithLoopGuard(deps, 'refine', 'read', hint => prependHint(hint, REFINE_PROMPT(raw)));
|
|
55
|
+
export async function phaseVerifyTooling(deps, research) {
|
|
56
|
+
const commands = extractToolingCommands(research);
|
|
57
|
+
if (!commands || commands.length === 0) {
|
|
58
|
+
return replaceToolingWithVerified(research, []);
|
|
59
|
+
}
|
|
60
|
+
const toolingList = commands.join('\n');
|
|
61
|
+
let verifyOutput;
|
|
62
|
+
try {
|
|
63
|
+
verifyOutput = await runPhaseChild(deps, 'verify-tooling', 'read,bash', VERIFY_TOOLING_PROMPT(toolingList));
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return replaceToolingWithVerified(research, commands);
|
|
67
|
+
}
|
|
68
|
+
const parsed = parseVerifyToolingOutput(verifyOutput);
|
|
69
|
+
const verifiedSection = parsed.verified.length > 0 ? parsed.verified.join('\n')
|
|
70
|
+
: parsed.rejected.length > 0 ? '(none verified)'
|
|
71
|
+
: '(verification inconclusive)';
|
|
72
|
+
await setTaskSection(deps.cwd, deps.taskId, 'verified tooling', verifiedSection);
|
|
73
|
+
return replaceToolingWithVerified(research, parsed.verified);
|
|
74
|
+
}
|
|
75
|
+
export async function phaseResearch(deps, refined, researchDeps = {}) {
|
|
76
|
+
const docsRawFn = researchDeps.docsRaw ?? docsRaw;
|
|
77
|
+
const fetchRawFn = researchDeps.fetchRaw ?? fetchRaw;
|
|
78
|
+
const fileInventoryFn = researchDeps.getFileInventory ?? getFileInventory;
|
|
79
|
+
const searchFn = researchDeps.searchFn ?? defaultSearch;
|
|
80
|
+
const enrichTargets = extractEnrichTargets(refined);
|
|
81
|
+
const enrichSections = [];
|
|
82
|
+
if (enrichTargets.packages.length > 0
|
|
83
|
+
|| enrichTargets.urls.length > 0
|
|
84
|
+
|| enrichTargets.services.length > 0) {
|
|
85
|
+
const tEnrichStart = Date.now();
|
|
86
|
+
const [docsResults, fetchResults, serviceResults] = await Promise.all([
|
|
87
|
+
Promise.all(enrichTargets.packages.map(pkg => docsRawFn({
|
|
88
|
+
pkg,
|
|
89
|
+
query: refined.split('\n').find(l => l.trim()) ?? refined,
|
|
90
|
+
cwd: deps.cwd,
|
|
91
|
+
signal: deps.signal
|
|
92
|
+
}).catch(() => null))),
|
|
93
|
+
Promise.all(enrichTargets.urls.map(url => fetchRawFn({ url, signal: deps.signal }).catch(() => null))),
|
|
94
|
+
Promise.all(enrichTargets.services.map(s => searchFn({
|
|
95
|
+
query: `${s.name} ${s.query}`,
|
|
96
|
+
count: 3,
|
|
97
|
+
signal: deps.signal
|
|
98
|
+
}).catch(() => null)))
|
|
99
|
+
]);
|
|
100
|
+
// npm version blocks come from docsRaw's bundled lookup and lead the
|
|
101
|
+
// section so the model anchors on live version data before reading
|
|
102
|
+
// the docs body.
|
|
103
|
+
for (let i = 0; i < enrichTargets.packages.length; i++) {
|
|
104
|
+
const v = docsResults[i]?.npmVersion;
|
|
105
|
+
if (v)
|
|
106
|
+
enrichSections.push(formatNpmVersionSection(v));
|
|
107
|
+
}
|
|
108
|
+
for (let i = 0; i < enrichTargets.packages.length; i++) {
|
|
109
|
+
const r = docsResults[i];
|
|
110
|
+
if (r?.kind === 'ok' && r.chunks.length > 0) {
|
|
111
|
+
const body = r.chunks
|
|
112
|
+
.map(c => c.content)
|
|
113
|
+
.join('\n\n')
|
|
114
|
+
.slice(0, 4000);
|
|
115
|
+
enrichSections.push(`### docs: ${enrichTargets.packages[i]}\n${body}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
for (let i = 0; i < enrichTargets.urls.length; i++) {
|
|
119
|
+
const r = fetchResults[i];
|
|
120
|
+
if (r) {
|
|
121
|
+
enrichSections.push(`### url: ${enrichTargets.urls[i]}\n${r.markdown.slice(0, 4000)}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const skipped = [];
|
|
125
|
+
for (let i = 0; i < enrichTargets.services.length; i++) {
|
|
126
|
+
const s = enrichTargets.services[i];
|
|
127
|
+
const r = serviceResults[i];
|
|
128
|
+
if (r === null)
|
|
129
|
+
continue;
|
|
130
|
+
if (r.kind === 'no_key') {
|
|
131
|
+
skipped.push(s.name);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (r.kind === 'error')
|
|
135
|
+
continue;
|
|
136
|
+
// kind === 'ok'
|
|
137
|
+
enrichSections.push(formatServiceBlock(s.name, `${s.name} ${s.query}`, r.results));
|
|
138
|
+
}
|
|
139
|
+
if (skipped.length > 0) {
|
|
140
|
+
enrichSections.push(formatFreshnessSkippedBlock(skipped));
|
|
141
|
+
}
|
|
142
|
+
deps.recordSubStep?.('enrichment', Date.now() - tEnrichStart);
|
|
143
|
+
}
|
|
144
|
+
const externalContext = enrichSections.length > 0 ? `EXTERNAL CONTEXT\n${enrichSections.join('\n\n')}\n\n` : '';
|
|
145
|
+
// Pre-compute the project file inventory once and hand it to every worker.
|
|
146
|
+
// Workers can then jump straight to targeted read/grep on known paths
|
|
147
|
+
// instead of each spawning its own discovery loop (find/ls). A '' result
|
|
148
|
+
// (non-git repo, git missing, abort) silently falls back to the original
|
|
149
|
+
// behavior.
|
|
150
|
+
const inventoryRaw = await fileInventoryFn(deps.cwd, deps.signal).catch(() => '');
|
|
151
|
+
const inventoryHeader = inventoryRaw.length > 0 ? `PROJECT FILE INVENTORY\n${inventoryRaw}\n\n` : '';
|
|
152
|
+
const promptHeader = externalContext + inventoryHeader;
|
|
153
|
+
let doneCount = 0;
|
|
154
|
+
const updateProgress = () => {
|
|
155
|
+
doneCount++;
|
|
156
|
+
if (deps.onChildOutput)
|
|
157
|
+
deps.onChildOutput(`research (${doneCount}/4 workers done)`);
|
|
158
|
+
};
|
|
159
|
+
// Per-worker timing split into wait (spawn → first byte) and work (first
|
|
160
|
+
// byte → exit). When workers fan out concurrently and the upstream model
|
|
161
|
+
// API caps concurrency, the queued workers spend most of their elapsed
|
|
162
|
+
// time waiting for a slot — the wait/work split makes that visible
|
|
163
|
+
// instead of the previous Promise.all-relative wall-clock that conflated
|
|
164
|
+
// the two.
|
|
165
|
+
const recordWorker = (label, p) => p.then(r => {
|
|
166
|
+
deps.recordSubStep?.(`${label} wait`, r.waitMs);
|
|
167
|
+
deps.recordSubStep?.(`${label} work`, r.workMs);
|
|
168
|
+
return r;
|
|
169
|
+
});
|
|
170
|
+
const [files, apis, context, tooling] = await Promise.all([
|
|
171
|
+
recordWorker('worker:files', runWorker({
|
|
172
|
+
prompt: promptHeader + RESEARCH_FILES_PROMPT(refined),
|
|
173
|
+
cwd: deps.cwd,
|
|
174
|
+
signal: deps.signal,
|
|
175
|
+
spawn: deps.spawn
|
|
176
|
+
}).then(r => {
|
|
177
|
+
updateProgress();
|
|
178
|
+
return r;
|
|
179
|
+
})),
|
|
180
|
+
recordWorker('worker:apis', runWorker({
|
|
181
|
+
prompt: promptHeader + RESEARCH_APIS_PROMPT(refined),
|
|
182
|
+
cwd: deps.cwd,
|
|
183
|
+
signal: deps.signal,
|
|
184
|
+
spawn: deps.spawn
|
|
185
|
+
}).then(r => {
|
|
186
|
+
updateProgress();
|
|
187
|
+
return r;
|
|
188
|
+
})),
|
|
189
|
+
recordWorker('worker:context', runWorker({
|
|
190
|
+
prompt: promptHeader + RESEARCH_CONTEXT_PROMPT(refined),
|
|
191
|
+
cwd: deps.cwd,
|
|
192
|
+
signal: deps.signal,
|
|
193
|
+
spawn: deps.spawn,
|
|
194
|
+
// Context owns architectural understanding, not path discovery
|
|
195
|
+
// — FILES handles that. Dropping `find`/`ls` keeps the worker
|
|
196
|
+
// from spawning long enumeration loops whose output then
|
|
197
|
+
// inflates prefill on every subsequent round.
|
|
198
|
+
tools: 'read,grep'
|
|
199
|
+
}).then(r => {
|
|
200
|
+
updateProgress();
|
|
201
|
+
return r;
|
|
202
|
+
})),
|
|
203
|
+
recordWorker('worker:tooling', runWorker({
|
|
204
|
+
prompt: promptHeader + RESEARCH_TOOLING_PROMPT(refined),
|
|
205
|
+
cwd: deps.cwd,
|
|
206
|
+
signal: deps.signal,
|
|
207
|
+
spawn: deps.spawn
|
|
208
|
+
}).then(r => {
|
|
209
|
+
updateProgress();
|
|
210
|
+
return r;
|
|
211
|
+
}))
|
|
212
|
+
]);
|
|
213
|
+
const sections = [
|
|
214
|
+
{ name: 'FILES', result: files },
|
|
215
|
+
{ name: 'APIS', result: apis },
|
|
216
|
+
{ name: 'CONTEXT', result: context },
|
|
217
|
+
{ name: 'TOOLING', result: tooling }
|
|
218
|
+
];
|
|
219
|
+
for (const { name, result } of sections) {
|
|
220
|
+
if (result.exitCode !== 0) {
|
|
221
|
+
throw new Error(`Research ${name} worker failed (exit ${result.exitCode}): ${result.stderr.slice(-500)}`);
|
|
222
|
+
}
|
|
223
|
+
if (result.text.trim().length === 0) {
|
|
224
|
+
throw new Error(`Research ${name} worker produced no output`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return `FILES\n${files.text}\n\nAPIS\n${apis.text}\n\nCONTEXT\n${context.text}\n\nTOOLING\n${tooling.text}`;
|
|
228
|
+
}
|
|
229
|
+
export async function phaseAutoAnswer(deps, refined, research, question, autoDeps = {}) {
|
|
230
|
+
const docsFocusedFn = autoDeps.docsFocused ?? docsFocused;
|
|
231
|
+
const fetchFocusedFn = autoDeps.fetchFocused ?? fetchFocused;
|
|
232
|
+
try {
|
|
233
|
+
const enrichTargets = extractEnrichTargets(question);
|
|
234
|
+
const allTargets = [
|
|
235
|
+
...enrichTargets.packages.slice(0, 2).map(pkg => ({ kind: 'pkg', pkg })),
|
|
236
|
+
...enrichTargets.urls
|
|
237
|
+
.slice(0, 2 - Math.min(enrichTargets.packages.length, 2))
|
|
238
|
+
.map(url => ({ kind: 'url', url }))
|
|
239
|
+
];
|
|
240
|
+
const cappedTargets = allTargets.slice(0, 2);
|
|
241
|
+
const npmSections = [];
|
|
242
|
+
const docSections = [];
|
|
243
|
+
const searchFn = autoDeps.searchFn ?? defaultSearch;
|
|
244
|
+
const cappedServices = enrichTargets.services.slice(0, 2);
|
|
245
|
+
// Fan out doc/url focused workers and service searches in parallel —
|
|
246
|
+
// otherwise the user waits for max(docs, fetch) + search instead of
|
|
247
|
+
// max(docs, fetch, search) on every grill auto-answer with at least
|
|
248
|
+
// one service plus a package or url. Mirrors phaseResearch's pattern.
|
|
249
|
+
const [, serviceResults] = await Promise.all([
|
|
250
|
+
Promise.all(cappedTargets.map(async (t, idx) => {
|
|
251
|
+
if (t.kind === 'pkg') {
|
|
252
|
+
const r = await docsFocusedFn({
|
|
253
|
+
pkg: t.pkg,
|
|
254
|
+
query: question,
|
|
255
|
+
cwd: deps.cwd,
|
|
256
|
+
signal: deps.signal
|
|
257
|
+
}).catch(() => null);
|
|
258
|
+
if (r?.npmVersion) {
|
|
259
|
+
npmSections[idx] = formatNpmVersionSection(r.npmVersion);
|
|
260
|
+
}
|
|
261
|
+
if (r?.answer) {
|
|
262
|
+
docSections[idx] = `### docs: ${t.pkg}\n${r.answer}`;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
else {
|
|
266
|
+
const r = await fetchFocusedFn({
|
|
267
|
+
url: t.url,
|
|
268
|
+
query: question,
|
|
269
|
+
cwd: deps.cwd,
|
|
270
|
+
signal: deps.signal
|
|
271
|
+
}).catch(() => null);
|
|
272
|
+
if (r?.answer) {
|
|
273
|
+
docSections[idx] = `### url: ${t.url}\n${r.answer}`;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
})),
|
|
277
|
+
Promise.all(cappedServices.map(s => searchFn({
|
|
278
|
+
query: `${s.name} ${s.query}`,
|
|
279
|
+
count: 3,
|
|
280
|
+
signal: deps.signal
|
|
281
|
+
}).catch(() => null)))
|
|
282
|
+
]);
|
|
283
|
+
const serviceSections = [];
|
|
284
|
+
const skipped = [];
|
|
285
|
+
for (let i = 0; i < cappedServices.length; i++) {
|
|
286
|
+
const s = cappedServices[i];
|
|
287
|
+
const r = serviceResults[i];
|
|
288
|
+
if (r === null)
|
|
289
|
+
continue;
|
|
290
|
+
if (r.kind === 'no_key') {
|
|
291
|
+
skipped.push(s.name);
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
if (r.kind === 'error')
|
|
295
|
+
continue;
|
|
296
|
+
serviceSections.push(formatServiceBlock(s.name, `${s.name} ${s.query}`, r.results));
|
|
297
|
+
}
|
|
298
|
+
if (skipped.length > 0) {
|
|
299
|
+
serviceSections.push(formatFreshnessSkippedBlock(skipped));
|
|
300
|
+
}
|
|
301
|
+
// npm blocks lead so the model anchors on live version data first.
|
|
302
|
+
const contextSections = [
|
|
303
|
+
...npmSections.filter(Boolean),
|
|
304
|
+
...docSections.filter(Boolean),
|
|
305
|
+
...serviceSections
|
|
306
|
+
];
|
|
307
|
+
const externalContext = contextSections.length > 0 ?
|
|
308
|
+
`EXTERNAL CONTEXT\n${contextSections.join('\n\n')}\n\n`
|
|
309
|
+
: '';
|
|
310
|
+
const text = await runPhaseChild(deps, 'grill-auto', 'read', externalContext + GRILL_AUTO_ANSWER_PROMPT(refined, research, question));
|
|
311
|
+
return parseAutoAnswer(text);
|
|
312
|
+
}
|
|
313
|
+
catch (err) {
|
|
314
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
315
|
+
return { kind: 'unknown', raw: `(threw: ${msg})` };
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
export async function phaseGrill(deps, ctx, widgetState, refined, research) {
|
|
319
|
+
const tGenStart = Date.now();
|
|
320
|
+
const raw = await runPhaseWithLoopGuard(deps, 'grill-gen', 'read', hint => prependHint(hint, GRILL_GEN_PROMPT(refined, research)));
|
|
321
|
+
deps.recordSubStep?.('gen', Date.now() - tGenStart);
|
|
322
|
+
const questions = parseGrillQuestions(raw);
|
|
323
|
+
if (questions.length === 0)
|
|
324
|
+
return '(no questions produced)';
|
|
325
|
+
// Auto-answers are independent — generate them concurrently before the UI
|
|
326
|
+
// loop. The user-input loop below still runs sequentially (the user can
|
|
327
|
+
// only answer one prompt at a time), but the LLM-spawning work no longer
|
|
328
|
+
// blocks each iteration. For N questions this turns ~N × cold-start time
|
|
329
|
+
// into ~1 × cold-start time.
|
|
330
|
+
const tAutoStart = Date.now();
|
|
331
|
+
let doneCount = 0;
|
|
332
|
+
widgetState.lastLine = `auto-answering 0/${questions.length} done…`;
|
|
333
|
+
const autos = await Promise.all(questions.map((q, i) => phaseAutoAnswer(deps, refined, research, q).then(r => {
|
|
334
|
+
doneCount++;
|
|
335
|
+
widgetState.lastLine = `auto-answering ${doneCount}/${questions.length} done (Q${i + 1})`;
|
|
336
|
+
return r;
|
|
337
|
+
})));
|
|
338
|
+
deps.recordSubStep?.('auto-answers', Date.now() - tAutoStart);
|
|
339
|
+
const theme = ctx.ui.theme;
|
|
340
|
+
const tInputStart = Date.now();
|
|
341
|
+
const out = [];
|
|
342
|
+
for (let i = 0; i < questions.length; i++) {
|
|
343
|
+
const q = questions[i];
|
|
344
|
+
const auto = autos[i];
|
|
345
|
+
out.push(`Q${i + 1}: ${q}`);
|
|
346
|
+
const rawTrim = auto.raw.trim();
|
|
347
|
+
out.push(` (auto-worker raw: ${rawTrim.length === 0 ? '(empty)' : rawTrim.replace(/\n/g, ' ⏎ ')})`);
|
|
348
|
+
if (auto.kind === 'answered') {
|
|
349
|
+
out.push(`A${i + 1}: ${auto.text} (auto)`);
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
const title = auto.suggested ?
|
|
353
|
+
`${q}\n${theme.fg('muted', 'Recommended:')}\n\n${theme.fg('text', auto.suggested)}\n\n${theme.fg('muted', 'press Enter to accept')}`
|
|
354
|
+
: `${q}\n${theme.fg('muted', '(no recommendation — please answer)')}`;
|
|
355
|
+
widgetState.lastLine = `awaiting Q${i + 1}`;
|
|
356
|
+
const a = await ctx.ui.input(title, auto.suggested);
|
|
357
|
+
if (a === undefined)
|
|
358
|
+
throw new Error(USER_CANCELLED);
|
|
359
|
+
const typed = a.trim();
|
|
360
|
+
if (typed.length === 0 && auto.suggested) {
|
|
361
|
+
out.push(`A${i + 1}: ${auto.suggested} (accepted recommendation)`);
|
|
362
|
+
}
|
|
363
|
+
else if (typed.length === 0) {
|
|
364
|
+
out.push(`A${i + 1}: (skipped)`);
|
|
365
|
+
}
|
|
366
|
+
else {
|
|
367
|
+
out.push(`A${i + 1}: ${typed}`);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
deps.recordSubStep?.('user input', Date.now() - tInputStart);
|
|
371
|
+
return out.join('\n');
|
|
372
|
+
}
|
|
373
|
+
export async function phaseCompose(deps, refined, research, qa) {
|
|
374
|
+
return runWithEmphasisRetry(deps, 'compose', 'read', problem => COMPOSE_PROMPT(refined, research, qa, problem), text => {
|
|
375
|
+
const problem = validateSpecShape(text);
|
|
376
|
+
return problem ? { ok: false, problem } : { ok: true, value: text };
|
|
377
|
+
}, problem => new Error(`compose_invalid: ${problem}`));
|
|
378
|
+
}
|
|
379
|
+
export async function phaseCritique(deps, spec, refined, qa) {
|
|
380
|
+
// Fast triage before the expensive full rewrite. The rewrite regenerates
|
|
381
|
+
// the entire spec from scratch and is the costliest tail of the pipeline
|
|
382
|
+
// (observed up to ~240s). Most compose drafts are already good, so we first
|
|
383
|
+
// ask a cheap, short-output triage pass whether a rewrite is even needed.
|
|
384
|
+
//
|
|
385
|
+
// We only short-circuit when the draft already has a runnable VERIFY block
|
|
386
|
+
// (parseVerifyBlock !== null): the final handoff gate rejects specs without
|
|
387
|
+
// one, so returning a structurally-incomplete draft would just fail later.
|
|
388
|
+
// When the draft is structurally sound and triage says CLEAN, return it as
|
|
389
|
+
// is. Otherwise fall through to the rewrite, feeding the triage defects in
|
|
390
|
+
// as a focus list. Triage failures are non-fatal — we just do the rewrite.
|
|
391
|
+
let triageDefects = null;
|
|
392
|
+
if (parseVerifyBlock(spec) !== null) {
|
|
393
|
+
const tTriage = Date.now();
|
|
394
|
+
let verdict;
|
|
395
|
+
try {
|
|
396
|
+
// No tools: triage judges only the spec/refined/qa text it is given.
|
|
397
|
+
// Granting `read` here let it wander the repo to "verify" findings,
|
|
398
|
+
// which made the supposedly-cheap pass cost as much as a rewrite
|
|
399
|
+
// (observed ~133s). The judgement needs no file access.
|
|
400
|
+
verdict = await runPhaseChild(deps, 'critique-triage', '', CRITIQUE_TRIAGE_PROMPT(spec, refined, qa));
|
|
401
|
+
}
|
|
402
|
+
catch {
|
|
403
|
+
verdict = null;
|
|
404
|
+
}
|
|
405
|
+
deps.recordSubStep?.('triage', Date.now() - tTriage);
|
|
406
|
+
if (verdict !== null) {
|
|
407
|
+
if (isCritiqueClean(verdict))
|
|
408
|
+
return spec;
|
|
409
|
+
triageDefects = verdict.trim();
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
const tRewrite = Date.now();
|
|
413
|
+
try {
|
|
414
|
+
return await runWithEmphasisRetry(deps, 'critique', 'read', problem => CRITIQUE_PROMPT(spec, refined, qa, problem !== null, triageDefects), text => parseVerifyBlock(text) ?
|
|
415
|
+
{ ok: true, value: text }
|
|
416
|
+
: { ok: false, problem: 'no_verify_block' }, () => new Error('no_verify_block'));
|
|
417
|
+
}
|
|
418
|
+
finally {
|
|
419
|
+
deps.recordSubStep?.('rewrite', Date.now() - tRewrite);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
// ─── Critique with fallback ──────────────────────────────────────────────────
|
|
423
|
+
export async function critiqueWithFallback(d, p) {
|
|
424
|
+
try {
|
|
425
|
+
return await phaseCritique(d, p.spec, p.refined, p.qa);
|
|
426
|
+
}
|
|
427
|
+
catch (err) {
|
|
428
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
429
|
+
if (msg !== 'no_verify_block')
|
|
430
|
+
throw err;
|
|
431
|
+
p.ctx.ui.notify('Critique couldn\'t produce a VERIFY block — using compose draft. Edit the spec manually if needed.', 'warning');
|
|
432
|
+
return p.spec;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
// ─── Phase config table ──────────────────────────────────────────────────────
|
|
436
|
+
export const PHASES = [
|
|
437
|
+
{
|
|
438
|
+
name: 'refine',
|
|
439
|
+
section: 'refined prompt',
|
|
440
|
+
field: 'refined',
|
|
441
|
+
run: (d, p) => phaseRefine(d, p.rawPrompt)
|
|
442
|
+
},
|
|
443
|
+
{
|
|
444
|
+
name: 'research',
|
|
445
|
+
section: 'research',
|
|
446
|
+
field: 'research',
|
|
447
|
+
run: async (d, p) => {
|
|
448
|
+
const tResearch = Date.now();
|
|
449
|
+
const rawResearch = await phaseResearch(d, p.refined);
|
|
450
|
+
d.recordSubStep?.('workers', Date.now() - tResearch);
|
|
451
|
+
const tVerify = Date.now();
|
|
452
|
+
const out = await phaseVerifyTooling(d, rawResearch);
|
|
453
|
+
d.recordSubStep?.('verify-tooling', Date.now() - tVerify);
|
|
454
|
+
return out;
|
|
455
|
+
}
|
|
456
|
+
},
|
|
457
|
+
{
|
|
458
|
+
name: 'grill',
|
|
459
|
+
section: 'grill Q&A',
|
|
460
|
+
field: 'qa',
|
|
461
|
+
run: (d, p) => phaseGrill(d, p.ctx, p.widgetState, p.refined, p.research)
|
|
462
|
+
},
|
|
463
|
+
{
|
|
464
|
+
name: 'compose',
|
|
465
|
+
section: 'spec',
|
|
466
|
+
field: 'spec',
|
|
467
|
+
run: (d, p) => phaseCompose(d, p.refined, p.research, p.qa)
|
|
468
|
+
},
|
|
469
|
+
{ name: 'critique', section: 'spec', field: 'spec', run: critiqueWithFallback }
|
|
470
|
+
];
|
|
471
|
+
export async function postCommitPhase(phase, pc, out) {
|
|
472
|
+
if (phase.name !== 'refine')
|
|
473
|
+
return;
|
|
474
|
+
const title = deriveTitle(out);
|
|
475
|
+
pc.widgetState.title = title;
|
|
476
|
+
await updateTaskFrontMatter(pc.cwd, pc.id, { title });
|
|
477
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prompt templates for every phase of the pi-task pipeline.
|
|
3
|
+
*
|
|
4
|
+
* Each template is a pure function: inputs → prompt string. No I/O, no side
|
|
5
|
+
* effects, trivially testable.
|
|
6
|
+
*/
|
|
7
|
+
export declare const MAX_GRILL_QUESTIONS = 10;
|
|
8
|
+
declare const REFINE_PROMPT: (raw: string) => string;
|
|
9
|
+
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.";
|
|
10
|
+
declare const RESEARCH_FILES_PROMPT: (refined: string) => string;
|
|
11
|
+
declare const RESEARCH_APIS_PROMPT: (refined: string) => string;
|
|
12
|
+
declare const RESEARCH_CONTEXT_PROMPT: (refined: string) => string;
|
|
13
|
+
declare const RESEARCH_TOOLING_PROMPT: (refined: string) => string;
|
|
14
|
+
declare const GRILL_GEN_PROMPT: (refined: string, research: string) => string;
|
|
15
|
+
declare const GRILL_AUTO_ANSWER_PROMPT: (refined: string, research: string, question: string) => string;
|
|
16
|
+
declare function composeRetryEmphasis(problem: string): string;
|
|
17
|
+
declare const COMPOSE_PROMPT: (refined: string, research: string, qa: string, retryProblem: string | null) => string;
|
|
18
|
+
declare const CRITIQUE_TRIAGE_PROMPT: (spec: string, refined: string, qa: string) => string;
|
|
19
|
+
declare const CRITIQUE_PROMPT: (spec: string, refined: string, qa: string, addVerifyEmphasis: boolean, triageDefects?: string | null) => string;
|
|
20
|
+
declare const VERIFY_TOOLING_PROMPT: (tooling: string) => string;
|
|
21
|
+
export { REFINE_PROMPT, RESEARCH_FILES_PROMPT, RESEARCH_APIS_PROMPT, RESEARCH_CONTEXT_PROMPT, RESEARCH_TOOLING_PROMPT, RESEARCH_READ_ONLY_CONSTRAINT, GRILL_GEN_PROMPT, GRILL_AUTO_ANSWER_PROMPT, COMPOSE_PROMPT, CRITIQUE_PROMPT, CRITIQUE_TRIAGE_PROMPT, VERIFY_TOOLING_PROMPT, composeRetryEmphasis };
|