@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.
Files changed (77) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +125 -0
  3. package/dist/index.d.ts +2 -0
  4. package/dist/index.js +6 -0
  5. package/dist/shared/child-output.d.ts +21 -0
  6. package/dist/shared/child-output.js +40 -0
  7. package/dist/shared/child-process.d.ts +71 -0
  8. package/dist/shared/child-process.js +190 -0
  9. package/dist/shared/pi-invocation.d.ts +7 -0
  10. package/dist/shared/pi-invocation.js +24 -0
  11. package/dist/task/child-runner.d.ts +66 -0
  12. package/dist/task/child-runner.js +157 -0
  13. package/dist/task/enrichment.d.ts +12 -0
  14. package/dist/task/enrichment.js +82 -0
  15. package/dist/task/failure-classifier.d.ts +15 -0
  16. package/dist/task/failure-classifier.js +63 -0
  17. package/dist/task/file-inventory.d.ts +9 -0
  18. package/dist/task/file-inventory.js +44 -0
  19. package/dist/task/loop-detector.d.ts +32 -0
  20. package/dist/task/loop-detector.js +46 -0
  21. package/dist/task/orchestrator.d.ts +54 -0
  22. package/dist/task/orchestrator.js +387 -0
  23. package/dist/task/parsers.d.ts +32 -0
  24. package/dist/task/parsers.js +172 -0
  25. package/dist/task/phases.d.ts +56 -0
  26. package/dist/task/phases.js +477 -0
  27. package/dist/task/prompts.d.ts +21 -0
  28. package/dist/task/prompts.js +346 -0
  29. package/dist/task/service-blocks.d.ts +3 -0
  30. package/dist/task/service-blocks.js +10 -0
  31. package/dist/task/task-file.d.ts +14 -0
  32. package/dist/task/task-file.js +15 -0
  33. package/dist/task/task-io.d.ts +19 -0
  34. package/dist/task/task-io.js +78 -0
  35. package/dist/task/task-parsers.d.ts +12 -0
  36. package/dist/task/task-parsers.js +75 -0
  37. package/dist/task/task-types.d.ts +21 -0
  38. package/dist/task/task-types.js +18 -0
  39. package/dist/task/timings.d.ts +18 -0
  40. package/dist/task/timings.js +36 -0
  41. package/dist/task/widget.d.ts +39 -0
  42. package/dist/task/widget.js +122 -0
  43. package/dist/workers/brave-search.d.ts +17 -0
  44. package/dist/workers/brave-search.js +77 -0
  45. package/dist/workers/docs-cache.d.ts +16 -0
  46. package/dist/workers/docs-cache.js +66 -0
  47. package/dist/workers/docs-core.d.ts +86 -0
  48. package/dist/workers/docs-core.js +329 -0
  49. package/dist/workers/docs-index.d.ts +9 -0
  50. package/dist/workers/docs-index.js +200 -0
  51. package/dist/workers/docs-resolve.d.ts +12 -0
  52. package/dist/workers/docs-resolve.js +126 -0
  53. package/dist/workers/docs-retrieve.d.ts +15 -0
  54. package/dist/workers/docs-retrieve.js +91 -0
  55. package/dist/workers/fetch-core.d.ts +35 -0
  56. package/dist/workers/fetch-core.js +91 -0
  57. package/dist/workers/html-clean.d.ts +17 -0
  58. package/dist/workers/html-clean.js +142 -0
  59. package/dist/workers/index.d.ts +2 -0
  60. package/dist/workers/index.js +10 -0
  61. package/dist/workers/npm-version.d.ts +32 -0
  62. package/dist/workers/npm-version.js +102 -0
  63. package/dist/workers/pi-worker-core.d.ts +28 -0
  64. package/dist/workers/pi-worker-core.js +29 -0
  65. package/dist/workers/pi-worker-docs.d.ts +16 -0
  66. package/dist/workers/pi-worker-docs.js +143 -0
  67. package/dist/workers/pi-worker-fetch.d.ts +20 -0
  68. package/dist/workers/pi-worker-fetch.js +72 -0
  69. package/dist/workers/pi-worker-search.d.ts +7 -0
  70. package/dist/workers/pi-worker-search.js +55 -0
  71. package/dist/workers/pi-worker.d.ts +10 -0
  72. package/dist/workers/pi-worker.js +61 -0
  73. package/dist/workers/search-core.d.ts +19 -0
  74. package/dist/workers/search-core.js +35 -0
  75. package/dist/workers/shared.d.ts +3 -0
  76. package/dist/workers/shared.js +4 -0
  77. 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 };