@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,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Child process runner for the pi-task orchestrator.
|
|
3
|
+
*
|
|
4
|
+
* Thin wrapper layer over the unified `runChild` in `shared/child-process.ts`.
|
|
5
|
+
* Provides JSON event-stream parsing, loop detection, and context-usage tracking
|
|
6
|
+
* for phase-level child pi invocations.
|
|
7
|
+
*/
|
|
8
|
+
import { spawn } from 'node:child_process';
|
|
9
|
+
import { getPiInvocation } from '../shared/pi-invocation.js';
|
|
10
|
+
import { runChild as runChildUnified, CHILD_BASE_ARGS } from '../shared/child-process.js';
|
|
11
|
+
import { LoopDetector } from './loop-detector.js';
|
|
12
|
+
import { readSection, setTaskSection } from './task-file.js';
|
|
13
|
+
// ─── Loop detection constants ────────────────────────────────────────────────
|
|
14
|
+
// Defined here (not in phases.ts) to avoid a circular dependency:
|
|
15
|
+
// phases.ts → child-runner.ts → phases.ts
|
|
16
|
+
export const LOOP_WINDOW = 20;
|
|
17
|
+
export const LOOP_THRESHOLD = 5;
|
|
18
|
+
export const MAX_LOOP_RESTARTS = 2; // 3 strikes total (initial attempt + 2 restarts)
|
|
19
|
+
// ─── Spawn helpers ───────────────────────────────────────────────────────────
|
|
20
|
+
export function childArgs(tools, prompt) {
|
|
21
|
+
// `--mode json` puts the child into the structured event stream the
|
|
22
|
+
// unified runner parses in `mode: 'json-events'`. Without it the child
|
|
23
|
+
// emits plain text, every line fails JSON.parse, finalText stays empty,
|
|
24
|
+
// and every phase fails with "X child produced no output". Was silently
|
|
25
|
+
// dropped in the 4e34f96 split-refactor; do not remove again.
|
|
26
|
+
//
|
|
27
|
+
// An empty `tools` string means "no tools at all" — emit `--no-tools`
|
|
28
|
+
// instead of `--tools ''` (which pi would reject). Used by pure-judgment
|
|
29
|
+
// phases like critique-triage that should reason only over the text we
|
|
30
|
+
// hand them, never spend time reading the repo.
|
|
31
|
+
const toolFlags = tools === '' ? ['--no-tools'] : ['--tools', tools];
|
|
32
|
+
return [...CHILD_BASE_ARGS, '--mode', 'json', ...toolFlags, prompt];
|
|
33
|
+
}
|
|
34
|
+
// Sentinel error thrown when the user dismisses a grill-me dialog.
|
|
35
|
+
// Defined here (not in failure-classifier.ts) to avoid circular dependency.
|
|
36
|
+
export const USER_CANCELLED = '__user_cancelled__';
|
|
37
|
+
// ─── Core child runner (JSON event-stream mode) ─────────────────────────────
|
|
38
|
+
/**
|
|
39
|
+
* Run a child pi process with JSON event-stream output, loop detection, and
|
|
40
|
+
* context-usage tracking. This is the typed convenience wrapper used by
|
|
41
|
+
* phase-level code.
|
|
42
|
+
*/
|
|
43
|
+
export async function runChild(cwd, tools, prompt, signal, onLine, onContextUsage, onToolCall, spawnFn) {
|
|
44
|
+
const invocation = getPiInvocation(childArgs(tools, prompt));
|
|
45
|
+
let loopHit;
|
|
46
|
+
const result = await runChildUnified(spawnFn ?? spawn, invocation, cwd, signal, {
|
|
47
|
+
mode: 'json-events',
|
|
48
|
+
onLine,
|
|
49
|
+
onContextUsage,
|
|
50
|
+
onToolCall: call => {
|
|
51
|
+
if (!onToolCall)
|
|
52
|
+
return null;
|
|
53
|
+
const hit = onToolCall(call);
|
|
54
|
+
if (hit && !loopHit) {
|
|
55
|
+
loopHit = hit;
|
|
56
|
+
}
|
|
57
|
+
return hit; // propagate to unified runner so it can kill
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
return {
|
|
61
|
+
text: result.text ?? result.stdout.trim(),
|
|
62
|
+
exitCode: result.exitCode,
|
|
63
|
+
stderr: result.stderr.trim(),
|
|
64
|
+
loopHit
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/** Run a child pi and return its assistant text. Throws if exit code != 0. */
|
|
68
|
+
export async function runPhaseChild(deps, name, tools, prompt) {
|
|
69
|
+
const r = await runChild(deps.cwd, tools, prompt, deps.signal, deps.onChildOutput, deps.onContextUsage, undefined, deps.spawn);
|
|
70
|
+
if (r.exitCode !== 0) {
|
|
71
|
+
throw new Error(`${name} child failed: ${r.stderr || '(no stderr)'}`);
|
|
72
|
+
}
|
|
73
|
+
if (r.text.trim().length === 0) {
|
|
74
|
+
throw new Error(`${name} child produced no output`);
|
|
75
|
+
}
|
|
76
|
+
return r.text;
|
|
77
|
+
}
|
|
78
|
+
function formatLoopHint(hit) {
|
|
79
|
+
const argsStr = JSON.stringify(hit.call.args);
|
|
80
|
+
return (`[SYSTEM NOTE: Your prior attempt called ${hit.call.name}(${argsStr}) `
|
|
81
|
+
+ `${hit.count} times in the last ${hit.windowSize} tool calls — you appeared to be `
|
|
82
|
+
+ `stuck in a loop. Avoid repeating that exact call; if you've already seen its result, `
|
|
83
|
+
+ `work from memory or pick a different angle.]`);
|
|
84
|
+
}
|
|
85
|
+
export function prependHint(hint, prompt) {
|
|
86
|
+
return hint === null ? prompt : `${hint}\n\n${prompt}`;
|
|
87
|
+
}
|
|
88
|
+
async function appendLoopEvent(cwd, taskId, phase, hit, strike, outcome) {
|
|
89
|
+
const ts = new Date().toISOString();
|
|
90
|
+
const argsStr = JSON.stringify(hit.call.args);
|
|
91
|
+
const line = `- ${ts} ${phase} strike ${strike}/${MAX_LOOP_RESTARTS + 1} `
|
|
92
|
+
+ `${hit.call.name}(${argsStr}) ×${hit.count} in last ${hit.windowSize} calls → ${outcome}`;
|
|
93
|
+
const existing = (await readSection(cwd, taskId, 'loop events')) ?? '';
|
|
94
|
+
const next = existing ? `${existing}\n${line}` : line;
|
|
95
|
+
await setTaskSection(cwd, taskId, 'loop events', next);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Run a phase child with loop detection. On a detected loop, kill and re-spawn
|
|
99
|
+
* with a hint that names the offending call. Cap at MAX_LOOP_RESTARTS restarts;
|
|
100
|
+
* the (MAX_LOOP_RESTARTS+1)th loop throws LoopExhaustedError.
|
|
101
|
+
*/
|
|
102
|
+
export async function runPhaseWithLoopGuard(deps, name, tools, buildPrompt) {
|
|
103
|
+
const loopHistory = [];
|
|
104
|
+
for (let strike = 0; strike <= MAX_LOOP_RESTARTS; strike++) {
|
|
105
|
+
if (deps.signal.aborted)
|
|
106
|
+
throw new Error(USER_CANCELLED);
|
|
107
|
+
const detector = new LoopDetector(LOOP_WINDOW, LOOP_THRESHOLD);
|
|
108
|
+
const hint = strike === 0 ? null : formatLoopHint(loopHistory[strike - 1]);
|
|
109
|
+
const prompt = buildPrompt(hint);
|
|
110
|
+
const r = await runChild(deps.cwd, tools, prompt, deps.signal, deps.onChildOutput, deps.onContextUsage, call => detector.record(call), deps.spawn);
|
|
111
|
+
if (deps.signal.aborted)
|
|
112
|
+
throw new Error(USER_CANCELLED);
|
|
113
|
+
if (r.loopHit) {
|
|
114
|
+
const isLastStrike = strike === MAX_LOOP_RESTARTS;
|
|
115
|
+
loopHistory.push(r.loopHit);
|
|
116
|
+
await appendLoopEvent(deps.cwd, deps.taskId, name, r.loopHit, strike + 1, isLastStrike ? 'phase failed' : 'restarted with hint');
|
|
117
|
+
if (isLastStrike)
|
|
118
|
+
throw new LoopExhaustedError(name, loopHistory);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (r.exitCode !== 0) {
|
|
122
|
+
throw new Error(`${name} child failed: ${r.stderr || '(no stderr)'}`);
|
|
123
|
+
}
|
|
124
|
+
if (r.text.trim().length === 0) {
|
|
125
|
+
throw new Error(`${name} child produced no output`);
|
|
126
|
+
}
|
|
127
|
+
return r.text;
|
|
128
|
+
}
|
|
129
|
+
throw new LoopExhaustedError(name, loopHistory);
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Run a child up to twice; the second attempt gets `emphasized=true` to escalate
|
|
133
|
+
* the prompt. On success, return the validator's value; on two failures, throw
|
|
134
|
+
* the caller-supplied error built from the last problem string.
|
|
135
|
+
*/
|
|
136
|
+
export async function runWithEmphasisRetry(deps, name, tools, build, validate, onFail) {
|
|
137
|
+
let lastProblem = 'unknown';
|
|
138
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
139
|
+
const text = await runPhaseChild(deps, name, tools, build(attempt === 0 ? null : lastProblem));
|
|
140
|
+
const result = validate(text);
|
|
141
|
+
if (result.ok)
|
|
142
|
+
return result.value;
|
|
143
|
+
lastProblem = result.problem;
|
|
144
|
+
}
|
|
145
|
+
throw onFail(lastProblem);
|
|
146
|
+
}
|
|
147
|
+
// ─── LoopExhaustedError ──────────────────────────────────────────────────────
|
|
148
|
+
export class LoopExhaustedError extends Error {
|
|
149
|
+
phase;
|
|
150
|
+
history;
|
|
151
|
+
constructor(phase, history) {
|
|
152
|
+
super(`loop detected ${history.length} times in ${phase}`);
|
|
153
|
+
this.phase = phase;
|
|
154
|
+
this.history = history;
|
|
155
|
+
this.name = 'LoopExhaustedError';
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Research enrichment — extract package names and URLs from text so the
|
|
3
|
+
* orchestrator can fan out docs/URL lookups before the research phase.
|
|
4
|
+
*/
|
|
5
|
+
export declare function extractEnrichTargets(text: string): {
|
|
6
|
+
packages: string[];
|
|
7
|
+
urls: string[];
|
|
8
|
+
services: Array<{
|
|
9
|
+
name: string;
|
|
10
|
+
query: string;
|
|
11
|
+
}>;
|
|
12
|
+
};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Research enrichment — extract package names and URLs from text so the
|
|
3
|
+
* orchestrator can fan out docs/URL lookups before the research phase.
|
|
4
|
+
*/
|
|
5
|
+
const ENRICH_PKG_RE = /`((?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*)`/g;
|
|
6
|
+
const ENRICH_URL_RE = /https?:\/\/[^\s)`>]+/g;
|
|
7
|
+
const ENRICH_DENYLIST = new Set([
|
|
8
|
+
'bun',
|
|
9
|
+
'node',
|
|
10
|
+
'npm',
|
|
11
|
+
'pnpm',
|
|
12
|
+
'yarn',
|
|
13
|
+
'git',
|
|
14
|
+
'sh',
|
|
15
|
+
'bash',
|
|
16
|
+
'cd',
|
|
17
|
+
'ls',
|
|
18
|
+
'cat',
|
|
19
|
+
'grep',
|
|
20
|
+
'find',
|
|
21
|
+
'rm'
|
|
22
|
+
]);
|
|
23
|
+
const ENRICH_CAP = 3;
|
|
24
|
+
const ENRICH_SERVICE_HEADER = 'EXTERNAL-DEPENDENCIES';
|
|
25
|
+
const ENRICH_HEADER_LINE_RE = /^[A-Z][A-Z0-9 -]+$/;
|
|
26
|
+
const ENRICH_SERVICE_BULLET_RE = /^\s*-\s+(.+?)\s{2,}(.+?)\s*$/;
|
|
27
|
+
function parseServices(text) {
|
|
28
|
+
const lines = text.split('\n');
|
|
29
|
+
const startIdx = lines.findIndex(l => l.trim() === ENRICH_SERVICE_HEADER);
|
|
30
|
+
if (startIdx === -1)
|
|
31
|
+
return [];
|
|
32
|
+
const out = [];
|
|
33
|
+
const seen = new Set();
|
|
34
|
+
for (let i = startIdx + 1; i < lines.length; i++) {
|
|
35
|
+
const line = lines[i];
|
|
36
|
+
const trimmed = line.trim();
|
|
37
|
+
if (trimmed === '')
|
|
38
|
+
break;
|
|
39
|
+
// The refine model sometimes emits the section header twice in a row.
|
|
40
|
+
// A repeated EXTERNAL-DEPENDENCIES line is not a section terminator —
|
|
41
|
+
// skip it and keep reading bullets. Any *other* all-caps header still
|
|
42
|
+
// ends the section.
|
|
43
|
+
if (trimmed === ENRICH_SERVICE_HEADER)
|
|
44
|
+
continue;
|
|
45
|
+
if (ENRICH_HEADER_LINE_RE.test(trimmed))
|
|
46
|
+
break;
|
|
47
|
+
const m = line.match(ENRICH_SERVICE_BULLET_RE);
|
|
48
|
+
if (!m)
|
|
49
|
+
continue;
|
|
50
|
+
const name = m[1].trim();
|
|
51
|
+
// Dedupe by name (case-insensitive); the model also duplicates bullets.
|
|
52
|
+
// Keep the first occurrence's query and count uniques against the cap.
|
|
53
|
+
const key = name.toLowerCase();
|
|
54
|
+
if (seen.has(key))
|
|
55
|
+
continue;
|
|
56
|
+
seen.add(key);
|
|
57
|
+
out.push({ name, query: m[2].trim() });
|
|
58
|
+
if (out.length >= ENRICH_CAP)
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
export function extractEnrichTargets(text) {
|
|
64
|
+
const pkgs = new Set();
|
|
65
|
+
for (const m of text.matchAll(ENRICH_PKG_RE)) {
|
|
66
|
+
const t = m[1];
|
|
67
|
+
if (ENRICH_DENYLIST.has(t))
|
|
68
|
+
continue;
|
|
69
|
+
pkgs.add(t);
|
|
70
|
+
if (pkgs.size >= ENRICH_CAP)
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
const urls = new Set();
|
|
74
|
+
for (const m of text.matchAll(ENRICH_URL_RE)) {
|
|
75
|
+
const u = m[0].replace(/[.,;:!?]+$/, '');
|
|
76
|
+
urls.add(u);
|
|
77
|
+
if (urls.size >= ENRICH_CAP)
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
const services = parseServices(text);
|
|
81
|
+
return { packages: [...pkgs], urls: [...urls], services };
|
|
82
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Failure classification — map runtime errors to task state transitions,
|
|
3
|
+
* widget flash messages, and user notifications.
|
|
4
|
+
*/
|
|
5
|
+
import type { ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
|
|
6
|
+
export type NotifyLevel = 'info' | 'warning' | 'error';
|
|
7
|
+
export interface FailureClass {
|
|
8
|
+
state: 'failed' | 'cancelled';
|
|
9
|
+
reason?: string;
|
|
10
|
+
flash?: string;
|
|
11
|
+
notify: string;
|
|
12
|
+
level: NotifyLevel;
|
|
13
|
+
}
|
|
14
|
+
export declare function classifyFailure(err: unknown, aborted: boolean): FailureClass;
|
|
15
|
+
export declare function handleFailure(err: unknown, ctx: ExtensionCommandContext, cwd: string, id: string, aborted: boolean): Promise<void>;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Failure classification — map runtime errors to task state transitions,
|
|
3
|
+
* widget flash messages, and user notifications.
|
|
4
|
+
*/
|
|
5
|
+
import { updateTaskFrontMatter } from './task-file.js';
|
|
6
|
+
import { flashTerminalWidget } from './widget.js';
|
|
7
|
+
import { LoopExhaustedError, USER_CANCELLED } from './child-runner.js';
|
|
8
|
+
// ─── Classifier ──────────────────────────────────────────────────────────────
|
|
9
|
+
export function classifyFailure(err, aborted) {
|
|
10
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
11
|
+
if (aborted || msg === USER_CANCELLED) {
|
|
12
|
+
return { state: 'cancelled', notify: 'cancelled.', level: 'warning' };
|
|
13
|
+
}
|
|
14
|
+
if (err instanceof LoopExhaustedError) {
|
|
15
|
+
return {
|
|
16
|
+
state: 'failed',
|
|
17
|
+
reason: `loop detected ${err.history.length}× in ${err.phase}`,
|
|
18
|
+
flash: 'loop_detected',
|
|
19
|
+
notify: `failed: ${err.phase} loop detected ${err.history.length}×. Resume to retry.`,
|
|
20
|
+
level: 'error'
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
if (msg === 'no_verify_block') {
|
|
24
|
+
return {
|
|
25
|
+
state: 'failed',
|
|
26
|
+
reason: 'no_verify_block',
|
|
27
|
+
flash: 'no_verify_block',
|
|
28
|
+
notify: 'failed: spec has no VERIFY block. Resume to edit and try again.',
|
|
29
|
+
level: 'error'
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
if (msg.startsWith('compose_invalid')) {
|
|
33
|
+
return {
|
|
34
|
+
state: 'failed',
|
|
35
|
+
reason: msg.slice(0, 200),
|
|
36
|
+
flash: 'compose_invalid',
|
|
37
|
+
notify: `failed: compose produced malformed spec (${msg.replace(/^compose_invalid:\s*/, '')}). Resume to retry.`,
|
|
38
|
+
level: 'error'
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
if (/ECONNREFUSED|fetch failed|connect/i.test(msg)) {
|
|
42
|
+
return {
|
|
43
|
+
state: 'failed',
|
|
44
|
+
reason: `model_unreachable: ${msg.slice(0, 120)}`,
|
|
45
|
+
flash: 'model_unreachable',
|
|
46
|
+
notify: 'failed: model unreachable.',
|
|
47
|
+
level: 'error'
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
state: 'failed',
|
|
52
|
+
reason: msg.slice(0, 200),
|
|
53
|
+
flash: msg.slice(0, 80),
|
|
54
|
+
notify: `failed: ${msg.slice(0, 120)}`,
|
|
55
|
+
level: 'error'
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
export async function handleFailure(err, ctx, cwd, id, aborted) {
|
|
59
|
+
const c = classifyFailure(err, aborted);
|
|
60
|
+
await updateTaskFrontMatter(cwd, id, { state: c.state, reason: c.reason });
|
|
61
|
+
flashTerminalWidget(ctx, c.state, id, c.flash);
|
|
62
|
+
ctx.ui.notify(`${id} ${c.notify}`, c.level);
|
|
63
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project file inventory — runs `git ls-files` once per /task run so research
|
|
3
|
+
* workers can skip their own discovery loops and jump straight to targeted
|
|
4
|
+
* read/grep on known paths. Returns '' on failure (non-git repo, git missing,
|
|
5
|
+
* timeout) so callers can fall back to the pre-inventory behavior.
|
|
6
|
+
*/
|
|
7
|
+
/** Cap output to maxLines real (non-blank) paths; tag truncation when cut. */
|
|
8
|
+
export declare function capInventory(raw: string, maxLines?: number): string;
|
|
9
|
+
export declare function getFileInventory(cwd: string, signal?: AbortSignal, maxLines?: number): Promise<string>;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project file inventory — runs `git ls-files` once per /task run so research
|
|
3
|
+
* workers can skip their own discovery loops and jump straight to targeted
|
|
4
|
+
* read/grep on known paths. Returns '' on failure (non-git repo, git missing,
|
|
5
|
+
* timeout) so callers can fall back to the pre-inventory behavior.
|
|
6
|
+
*/
|
|
7
|
+
import { spawn } from 'node:child_process';
|
|
8
|
+
const DEFAULT_MAX_LINES = 2000;
|
|
9
|
+
function runGitLsFiles(cwd, signal) {
|
|
10
|
+
return new Promise(resolve => {
|
|
11
|
+
let stdout = '';
|
|
12
|
+
let proc;
|
|
13
|
+
try {
|
|
14
|
+
proc = spawn('git', ['ls-files'], { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
resolve('');
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
proc.stdout?.on('data', (d) => {
|
|
21
|
+
stdout += d.toString();
|
|
22
|
+
});
|
|
23
|
+
proc.on('error', () => resolve(''));
|
|
24
|
+
proc.on('close', code => resolve(code === 0 ? stdout : ''));
|
|
25
|
+
signal?.addEventListener('abort', () => {
|
|
26
|
+
if (!proc.killed)
|
|
27
|
+
proc.kill('SIGTERM');
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
/** Cap output to maxLines real (non-blank) paths; tag truncation when cut. */
|
|
32
|
+
export function capInventory(raw, maxLines = DEFAULT_MAX_LINES) {
|
|
33
|
+
const lines = raw.split('\n').filter(l => l.trim().length > 0);
|
|
34
|
+
if (lines.length <= maxLines)
|
|
35
|
+
return lines.join('\n');
|
|
36
|
+
const shown = lines.slice(0, maxLines);
|
|
37
|
+
return `${shown.join('\n')}\n(truncated: ${lines.length - maxLines} more files)`;
|
|
38
|
+
}
|
|
39
|
+
export async function getFileInventory(cwd, signal, maxLines = DEFAULT_MAX_LINES) {
|
|
40
|
+
const raw = await runGitLsFiles(cwd, signal);
|
|
41
|
+
if (!raw)
|
|
42
|
+
return '';
|
|
43
|
+
return capInventory(raw, maxLines);
|
|
44
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure-logic loop detector for pi-task sub-agent phases.
|
|
3
|
+
*
|
|
4
|
+
* Watches a ring buffer of recent tool-call keys. When the same
|
|
5
|
+
* `(toolName, stable-stringified args)` key appears `threshold` times
|
|
6
|
+
* within the last `window` events, returns a LoopHit so the caller can
|
|
7
|
+
* kill the child and re-spawn with a hint.
|
|
8
|
+
*
|
|
9
|
+
* No I/O. No imports from index.ts. Trivially unit-testable.
|
|
10
|
+
*/
|
|
11
|
+
export interface ToolCall {
|
|
12
|
+
name: string;
|
|
13
|
+
args: unknown;
|
|
14
|
+
}
|
|
15
|
+
export interface LoopHit {
|
|
16
|
+
call: ToolCall;
|
|
17
|
+
count: number;
|
|
18
|
+
windowSize: number;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* JSON.stringify with sorted object keys so {a:1,b:2} and {b:2,a:1} hash equal.
|
|
22
|
+
* Arrays preserve their order (positional). undefined / primitives passthrough.
|
|
23
|
+
*/
|
|
24
|
+
export declare function stableStringify(value: unknown): string;
|
|
25
|
+
export declare class LoopDetector {
|
|
26
|
+
private readonly window;
|
|
27
|
+
private readonly threshold;
|
|
28
|
+
private readonly buf;
|
|
29
|
+
constructor(window?: number, threshold?: number);
|
|
30
|
+
/** Record a tool call. Returns LoopHit if the threshold is breached, else null. */
|
|
31
|
+
record(call: ToolCall): LoopHit | null;
|
|
32
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure-logic loop detector for pi-task sub-agent phases.
|
|
3
|
+
*
|
|
4
|
+
* Watches a ring buffer of recent tool-call keys. When the same
|
|
5
|
+
* `(toolName, stable-stringified args)` key appears `threshold` times
|
|
6
|
+
* within the last `window` events, returns a LoopHit so the caller can
|
|
7
|
+
* kill the child and re-spawn with a hint.
|
|
8
|
+
*
|
|
9
|
+
* No I/O. No imports from index.ts. Trivially unit-testable.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* JSON.stringify with sorted object keys so {a:1,b:2} and {b:2,a:1} hash equal.
|
|
13
|
+
* Arrays preserve their order (positional). undefined / primitives passthrough.
|
|
14
|
+
*/
|
|
15
|
+
export function stableStringify(value) {
|
|
16
|
+
return JSON.stringify(value, (_key, v) => {
|
|
17
|
+
if (v === null || typeof v !== 'object' || Array.isArray(v))
|
|
18
|
+
return v;
|
|
19
|
+
const sorted = {};
|
|
20
|
+
for (const k of Object.keys(v).sort()) {
|
|
21
|
+
sorted[k] = v[k];
|
|
22
|
+
}
|
|
23
|
+
return sorted;
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
export class LoopDetector {
|
|
27
|
+
window;
|
|
28
|
+
threshold;
|
|
29
|
+
buf = [];
|
|
30
|
+
constructor(window = 20, threshold = 5) {
|
|
31
|
+
this.window = window;
|
|
32
|
+
this.threshold = threshold;
|
|
33
|
+
}
|
|
34
|
+
/** Record a tool call. Returns LoopHit if the threshold is breached, else null. */
|
|
35
|
+
record(call) {
|
|
36
|
+
const key = `${call.name}\x00${stableStringify(call.args)}`;
|
|
37
|
+
this.buf.push(key);
|
|
38
|
+
if (this.buf.length > this.window)
|
|
39
|
+
this.buf.shift();
|
|
40
|
+
let count = 0;
|
|
41
|
+
for (const k of this.buf)
|
|
42
|
+
if (k === key)
|
|
43
|
+
count++;
|
|
44
|
+
return count >= this.threshold ? { call, count, windowSize: this.buf.length } : null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
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 type { ExtensionAPI, ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
|
|
19
|
+
import { type WidgetState } from './widget.js';
|
|
20
|
+
import type { SpawnFn } from '../shared/child-process.js';
|
|
21
|
+
/** Encapsulates the full lifecycle of a single pi-task run. */
|
|
22
|
+
export declare class TaskRunner {
|
|
23
|
+
private readonly _ctx;
|
|
24
|
+
private readonly _cwd;
|
|
25
|
+
private readonly _rawPrompt;
|
|
26
|
+
private readonly _resumeId;
|
|
27
|
+
private readonly _sendSpec;
|
|
28
|
+
private readonly _abort;
|
|
29
|
+
private readonly _startedAt;
|
|
30
|
+
private readonly _widgetState;
|
|
31
|
+
private _stopWidget;
|
|
32
|
+
private readonly _deps;
|
|
33
|
+
private readonly _pc;
|
|
34
|
+
/**
|
|
35
|
+
* Per-phase wall-clock durations collected during the run. Written to the
|
|
36
|
+
* `## phase timings` section on successful completion so we can spot
|
|
37
|
+
* regressions and target future speed work. Each top-level entry is a
|
|
38
|
+
* phase (refine/research/grill/compose/critique); children are optional
|
|
39
|
+
* sub-step splits the phase chose to record via deps.recordSubStep.
|
|
40
|
+
*/
|
|
41
|
+
private readonly _timings;
|
|
42
|
+
private _currentPhaseChildren;
|
|
43
|
+
constructor(ctx: ExtensionCommandContext, cwd: string, rawPrompt: string, resumeId?: string, sendSpec?: (spec: string) => Promise<void>, spawnFn?: SpawnFn);
|
|
44
|
+
get taskId(): string;
|
|
45
|
+
get signal(): AbortSignal;
|
|
46
|
+
/** Return the current widget state, or null if not started. */
|
|
47
|
+
status(): WidgetState | null;
|
|
48
|
+
/** Cancel the running task by aborting the signal. */
|
|
49
|
+
cancel(): void;
|
|
50
|
+
/** Execute the full task lifecycle. */
|
|
51
|
+
run(): Promise<void>;
|
|
52
|
+
private _deliverSpec;
|
|
53
|
+
}
|
|
54
|
+
export declare function registerTask(pi: ExtensionAPI): void;
|