@mjasnikovs/pi-task 0.13.16 → 0.13.18

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.
@@ -26,6 +26,30 @@ export interface AutoDeps {
26
26
  * is returned verbatim when nothing readable is referenced.
27
27
  */
28
28
  export declare function expandFeatureMentions(cwd: string, feature: string): Promise<string>;
29
+ /**
30
+ * The @file references in the feature that point at a readable file on disk —
31
+ * the bare path tokens, deduped, in first-seen order. Unreadable mentions
32
+ * (typos, non-file @tokens) are dropped so we never advertise a missing file as
33
+ * an authoritative spec.
34
+ */
35
+ export declare function readableMentions(cwd: string, feature: string): Promise<string[]>;
36
+ /**
37
+ * Thread the feature's spec references AND any per-task decisions into every
38
+ * decomposed task title. A title is ALL a per-task pipeline ever sees, so both
39
+ * the design doc the feature pointed at and the user's clarification choices have
40
+ * to ride along or they're invisible downstream — this is how an "Implement
41
+ * @design.md" run built a generic `posts` table the spec never mentioned, and how
42
+ * a "do not use vite" clarification got silently overridden by the doc's own
43
+ * vite.config.ts.
44
+ *
45
+ * Precedence is the crux: a clarification is a CORRECTION to a (possibly stale)
46
+ * spec doc, so the decisions clause is marked as overriding the doc, while the doc
47
+ * stays authoritative for everything the decisions don't touch. Decompose scopes
48
+ * each decision to the task(s) it governs, so most titles carry none. No readable
49
+ * refs and no decisions → title unchanged, so a doc-less /task-auto behaves
50
+ * exactly as before.
51
+ */
52
+ export declare function attachSpecRefs(titles: string[], refs: string[]): string[];
29
53
  /** Plan phase: clarify → decompose → write AUTO file. Returns the new id, or null. */
30
54
  export declare function planAuto(ctx: ExtensionCommandContext, cwd: string, feature: string, deps: AutoDeps): Promise<string | null>;
31
55
  export declare function requestAutoCancel(): void;
@@ -51,6 +51,66 @@ export async function expandFeatureMentions(cwd, feature) {
51
51
  }
52
52
  return blocks.length === 0 ? feature : `${feature.trim()}\n\n${blocks.join('\n\n')}`;
53
53
  }
54
+ /**
55
+ * The @file references in the feature that point at a readable file on disk —
56
+ * the bare path tokens, deduped, in first-seen order. Unreadable mentions
57
+ * (typos, non-file @tokens) are dropped so we never advertise a missing file as
58
+ * an authoritative spec.
59
+ */
60
+ export async function readableMentions(cwd, feature) {
61
+ const out = [];
62
+ const seen = new Set();
63
+ for (const m of feature.matchAll(MENTION_RE)) {
64
+ const rel = m[1];
65
+ if (seen.has(rel))
66
+ continue;
67
+ seen.add(rel);
68
+ try {
69
+ await fsp.access(path.resolve(cwd, rel));
70
+ out.push(rel);
71
+ }
72
+ catch {
73
+ // not a readable file — don't thread it into task titles
74
+ }
75
+ }
76
+ return out;
77
+ }
78
+ /** A trailing "[decisions: …]" clause decompose may attach to a task line. */
79
+ const DECISIONS_RE = /\s*\[decisions:\s*(.+?)\]\s*$/i;
80
+ /**
81
+ * Thread the feature's spec references AND any per-task decisions into every
82
+ * decomposed task title. A title is ALL a per-task pipeline ever sees, so both
83
+ * the design doc the feature pointed at and the user's clarification choices have
84
+ * to ride along or they're invisible downstream — this is how an "Implement
85
+ * @design.md" run built a generic `posts` table the spec never mentioned, and how
86
+ * a "do not use vite" clarification got silently overridden by the doc's own
87
+ * vite.config.ts.
88
+ *
89
+ * Precedence is the crux: a clarification is a CORRECTION to a (possibly stale)
90
+ * spec doc, so the decisions clause is marked as overriding the doc, while the doc
91
+ * stays authoritative for everything the decisions don't touch. Decompose scopes
92
+ * each decision to the task(s) it governs, so most titles carry none. No readable
93
+ * refs and no decisions → title unchanged, so a doc-less /task-auto behaves
94
+ * exactly as before.
95
+ */
96
+ export function attachSpecRefs(titles, refs) {
97
+ const list = refs.map(r => '@' + r).join(' ');
98
+ return titles.map(t => {
99
+ if (t.includes('| spec:') || t.includes('| decisions'))
100
+ return t; // already threaded
101
+ const dm = DECISIONS_RE.exec(t);
102
+ const base = dm ? t.slice(0, dm.index).trimEnd() : t;
103
+ const decisions = dm ? dm[1].trim() : '';
104
+ let out = base;
105
+ if (decisions) {
106
+ out += ` | decisions (explicit user choices — these OVERRIDE the spec doc wherever they conflict; follow them exactly): ${decisions}`;
107
+ }
108
+ if (refs.length > 0) {
109
+ out += ` | spec: ${list} — otherwise authoritative; read it and follow it over this title wherever they differ`;
110
+ }
111
+ return out;
112
+ });
113
+ }
54
114
  /** Plan phase: clarify → decompose → write AUTO file. Returns the new id, or null. */
55
115
  export async function planAuto(ctx, cwd, feature, deps) {
56
116
  // clarify — sequential & adaptive: ask one question at a time, feeding every
@@ -138,7 +198,11 @@ export async function planAuto(ctx, cwd, feature, deps) {
138
198
  const clarifications = answers.join('\n');
139
199
  // decompose
140
200
  const listRaw = await deps.runChild('auto-decompose', 'read', AUTO_DECOMPOSE_PROMPT(featureForModel, clarifications));
141
- const titles = parseDecomposeList(listRaw);
201
+ // Thread the feature's spec doc(s) into every title so each per-task
202
+ // pipeline — which only ever sees its title — reads the real spec instead of
203
+ // a lossy one-line paraphrase of it.
204
+ const refs = await readableMentions(cwd, feature);
205
+ const titles = attachSpecRefs(parseDecomposeList(listRaw), refs);
142
206
  if (titles.length === 0) {
143
207
  announceDone(ctx, '/task-auto: no tasks produced from the feature.', 'warning');
144
208
  return null;
@@ -33,6 +33,7 @@ fork the breakdown). Account for the answers so far:
33
33
  time (file/blob storage, client/rendering strategy, auth and session model,
34
34
  real-time vs polling transport, search, deployment).
35
35
  - Skip anything /task will naturally resolve per-task during its own research.
36
+ - Stay grounded in the referenced spec. If a design/spec doc is included above, do NOT propose a new subsystem, dependency, or requirement it does not call for, and do NOT re-ask a choice the spec already settles. Ask only about genuine forks the spec leaves open.
36
37
 
37
38
  YOU MUST propose a default answer for the question — every question you emit
38
39
  carries exactly one SUGGESTED line. Never omit it, never leave it blank, never
@@ -81,4 +82,10 @@ RULES:
81
82
  - Order tasks so earlier ones unblock later ones (foundations first).
82
83
  - Each task should be independently implementable as a single /task run.
83
84
  - Prefer a handful of substantial tasks over many trivial ones.
85
+ - When a CLARIFICATION decision governs a task, append it to that task's line as
86
+ "[decisions: <directive>]" — include ONLY the decisions that bear on that task,
87
+ and attach a cross-cutting decision to EACH task it governs. Most tasks carry
88
+ none. These are explicit user choices that may contradict the referenced spec
89
+ doc; phrase them as imperative directives (e.g. "use Bun's built-in bundler, do
90
+ not add vite"). Do NOT invent decisions — only restate ones from CLARIFICATIONS.
84
91
  - Output the checkbox list and NOTHING else (no preamble, no numbering).`;
@@ -52,6 +52,7 @@ export type { PhaseDeps };
52
52
  * leaking, throw LeakedToolCallError rather than returning the unexecuted call.
53
53
  */
54
54
  export declare function runPhaseChild(deps: PhaseDeps, name: string, tools: string, prompt: string): Promise<string>;
55
+ export declare function formatLoopHint(hit: LoopHit): string;
55
56
  export declare function prependHint(hint: string | null, prompt: string): string;
56
57
  /**
57
58
  * Run a phase child with loop detection. On a detected loop, kill and re-spawn
@@ -119,7 +119,7 @@ export async function runPhaseChild(deps, name, tools, prompt) {
119
119
  // Unreachable: the loop returns clean text or throws on the final leak.
120
120
  throw new LeakedToolCallError(name, '(unknown)');
121
121
  }
122
- function formatLoopHint(hit) {
122
+ export function formatLoopHint(hit) {
123
123
  const argsStr = JSON.stringify(hit.call.args);
124
124
  return (`[SYSTEM NOTE: Your prior attempt called ${hit.call.name}(${argsStr}) `
125
125
  + `${hit.count} times in the last ${hit.windowSize} tool calls — you appeared to be `
@@ -179,6 +179,20 @@ export async function phaseResearch(deps, refined, researchDeps = {}) {
179
179
  // mirrors workerSpecs order — the loop above pushes in sequence).
180
180
  const sections = workerSpecs.map((spec, i) => ({ name: spec.section, result: workerResults[i] }));
181
181
  for (const { name, result } of sections) {
182
+ // Loop/timeout exhaustion is checked before the generic exit-code branch:
183
+ // a loop-kill arrives as a SIGTERM (exit 143) AND sometimes as a clean
184
+ // exit 0 with partial text, so keying off exitCode alone would either give
185
+ // a useless "exit 143" message or silently accept truncated output. The
186
+ // worker already burned its MAX_LOOP_RESTARTS restarts before setting these.
187
+ if (result.loopHit) {
188
+ const argsStr = JSON.stringify(result.loopHit.call.args);
189
+ throw new Error(`Research ${name} worker stuck in a loop — called `
190
+ + `${result.loopHit.call.name}(${argsStr}) ×${result.loopHit.count} in the last `
191
+ + `${result.loopHit.windowSize} calls and still looped after restarts`);
192
+ }
193
+ if (result.timedOut) {
194
+ throw new Error(`Research ${name} worker timed out after restarts`);
195
+ }
182
196
  if (result.exitCode !== 0) {
183
197
  throw new Error(`Research ${name} worker failed (exit ${result.exitCode}): ${result.stderr.slice(-500)}`);
184
198
  }
@@ -54,6 +54,7 @@ Rules:
54
54
  - Fix spelling and grammar; output in English regardless of input language.
55
55
  - Preserve every concrete identifier verbatim (paths, function names, ports, env vars, file:line refs).
56
56
  - Do not invent requirements not implied by the input.
57
+ - If the task references a design/spec document (an @-path or a named spec file), READ it and treat it as authoritative. Carry its concrete schema verbatim into GOAL/CONSTRAINTS — table and column names, types, endpoint methods and paths, enum values. The task title is only a pointer into that spec: where the title and the spec disagree, follow the spec, and never introduce a table, column, endpoint, or dependency the spec does not define.
57
58
  - Do not output any preamble, commentary, or markdown headings beyond the four sections above.
58
59
 
59
60
  Task: ${raw}`;
@@ -73,6 +74,8 @@ const RESEARCH_FILES_PROMPT = (refined) => `You are doing targeted research for
73
74
 
74
75
  FILES owns paths. APIS owns symbols. Do not omit a path because it "feels like config" — if the agent will touch or read it, list it here.
75
76
 
77
+ If the task references a design/spec document (an @-path or a named spec file), OPEN it first and list it — it is the contract for this task, and the schema, names, and endpoints it defines are authoritative over any paraphrase in the task title.
78
+
76
79
  When a task operates across a whole directory tree (e.g. lint, typecheck, build, format, test-all), list the root directory entry (\`src/ one-line purpose\`) instead of enumerating every file under it. Enumerate individual files only when they need to be singled out — modified specifically, called out by name in the task, or distinct from their siblings in some material way.
77
80
 
78
81
  RELEVANCE — read carefully: list ONLY paths this specific task touches — the files the agent will read, edit, or must be aware of to complete THIS task. Do NOT inventory the whole repo or list files just because they exist. A file the agent will never open does not belong here. Aim for the smallest sufficient set: include every path the task genuinely reaches and nothing more. There is no fixed limit — a broad task may need many entries, a narrow one only a few. Right-size to the task, not to a number, and collapse directories to their root entry where the task spans a whole tree.
@@ -1,4 +1,5 @@
1
1
  import { type SpawnFn } from '../shared/child-process.js';
2
+ import { type LoopHit } from '../task/loop-detector.js';
2
3
  export interface RunWorkerInput {
3
4
  prompt: string;
4
5
  cwd: string;
@@ -10,6 +11,8 @@ export interface RunWorkerInput {
10
11
  extensions?: string[];
11
12
  /** Called for each tool execution start and text-writing event inside the worker. */
12
13
  onLine?: (line: string) => void;
14
+ /** Per-worker wall-clock timeout in ms. Defaults to RESEARCH_WORKER_TIMEOUT_MS. */
15
+ timeoutMs?: number;
13
16
  }
14
17
  export interface RunWorkerResult {
15
18
  text: string;
@@ -34,5 +37,18 @@ export interface RunWorkerResult {
34
37
  * failure rather than trusting the returned text.
35
38
  */
36
39
  leakedToolCall?: string;
40
+ /**
41
+ * Set when the worker was killed for looping (the same tool call repeated
42
+ * past threshold) and still looped after exhausting MAX_LOOP_RESTARTS
43
+ * restarts. The returned text is truncated mid-stream — the caller must treat
44
+ * this as a failure, not trust it.
45
+ */
46
+ loopHit?: LoopHit;
47
+ /**
48
+ * Set when the worker's final attempt hit the per-worker wall-clock timeout
49
+ * after exhausting its restart budget. Like loopHit, the text is partial and
50
+ * the caller must treat it as a failure.
51
+ */
52
+ timedOut?: boolean;
37
53
  }
38
54
  export declare function runWorker(input: RunWorkerInput): Promise<RunWorkerResult>;
@@ -1,6 +1,7 @@
1
1
  import { getPiInvocation } from '../shared/pi-invocation.js';
2
2
  import { CHILD_BASE_ARGS, runChildDefault } from '../shared/child-process.js';
3
3
  import { LoopDetector } from '../task/loop-detector.js';
4
+ import { LOOP_WINDOW, LOOP_THRESHOLD, MAX_LOOP_RESTARTS, formatLoopHint } from '../task/child-runner.js';
4
5
  import { detectLeakedToolCall, leakedToolCallHint, MAX_LEAK_RETRIES } from '../shared/leaked-tool-call.js';
5
6
  // `--mode json` makes pi emit structured events as they happen instead of
6
7
  // buffering the assistant text and flushing on exit. That matters for the
@@ -10,33 +11,118 @@ import { detectLeakedToolCall, leakedToolCallHint, MAX_LEAK_RETRIES } from '../s
10
11
  // model starts producing — making waitMs the real queue/cold-start cost and
11
12
  // workMs the real generation+tool-call cost.
12
13
  const DEFAULT_TOOLS = 'read,grep,find,ls';
14
+ /**
15
+ * Hard wall-clock bound on a single research worker run (one spawn). The
16
+ * exact-match LoopDetector only catches *identical* repeated tool calls; a model
17
+ * that thrashes with slightly-varied calls (different grep patterns each time)
18
+ * slips past it and would otherwise run unbounded. This is the backstop for that
19
+ * case: after this long with no clean exit, abort and restart with a hint. Sized
20
+ * well above a healthy worker's observed runtime (~25-130s on the local backend)
21
+ * so it never trips a legitimately slow run.
22
+ */
23
+ const RESEARCH_WORKER_TIMEOUT_MS = 240_000;
24
+ /** Restart hint after a wall-clock timeout — distinct from the loop hint. */
25
+ const WORKER_TIMEOUT_HINT = '[SYSTEM NOTE: Your previous attempt ran out of time before answering — you '
26
+ + 'were exploring too long. Be decisive: do the minimum reads/greps needed, '
27
+ + 'then write your answer now. Do not re-explore ground you have already covered.]';
28
+ /**
29
+ * Combine an external abort signal with an internal wall-clock timeout into one
30
+ * signal, while keeping the two causes distinguishable: `timedOut()` is true
31
+ * only when the timer fired (not when the external signal aborted), so the caller
32
+ * can restart on a timeout but not on a user cancel.
33
+ */
34
+ function workerTimeout(external, ms) {
35
+ const ctrl = new AbortController();
36
+ let timedOut = false;
37
+ const timer = setTimeout(() => {
38
+ timedOut = true;
39
+ ctrl.abort();
40
+ }, ms);
41
+ const onExternal = () => ctrl.abort();
42
+ if (external) {
43
+ if (external.aborted)
44
+ ctrl.abort();
45
+ else
46
+ external.addEventListener('abort', onExternal, { once: true });
47
+ }
48
+ return {
49
+ signal: ctrl.signal,
50
+ timedOut: () => timedOut,
51
+ cleanup: () => {
52
+ clearTimeout(timer);
53
+ external?.removeEventListener('abort', onExternal);
54
+ }
55
+ };
56
+ }
13
57
  export async function runWorker(input) {
14
58
  const tools = input.tools ?? DEFAULT_TOOLS;
15
59
  const extensionArgs = (input.extensions ?? []).flatMap(e => ['-e', e]);
16
60
  const baseArgs = [...extensionArgs, ...CHILD_BASE_ARGS, '--mode', 'json', '--tools', tools];
61
+ const timeoutMs = input.timeoutMs ?? RESEARCH_WORKER_TIMEOUT_MS;
17
62
  let hint = null;
18
- for (let attempt = 0;; attempt++) {
63
+ // Loop-kill and timeout share one restart budget, mirroring
64
+ // runPhaseWithLoopGuard: a runaway worker gets re-spawned with a corrective
65
+ // hint up to MAX_LOOP_RESTARTS times before we give up. Leaked tool calls
66
+ // keep their own MAX_LEAK_RETRIES budget below — a different failure mode.
67
+ let restarts = 0;
68
+ let leakRetries = 0;
69
+ for (;;) {
19
70
  const prompt = hint === null ? input.prompt : `${hint}\n\n${input.prompt}`;
20
71
  const invocation = getPiInvocation([...baseArgs, prompt]);
21
72
  const tStart = Date.now();
22
73
  let tFirstByte = null;
23
- const loopDetector = new LoopDetector(20, 5);
24
- const result = await runChildDefault(invocation, input.cwd, input.signal, {
25
- mode: 'json-events',
26
- onFirstByte: () => (tFirstByte = Date.now()),
27
- onToolCall: call => loopDetector.record(call),
28
- onLine: input.onLine
29
- }, input.spawn);
74
+ const loopDetector = new LoopDetector(LOOP_WINDOW, LOOP_THRESHOLD);
75
+ // Capture the hit the detector reports (it also returns it to the unified
76
+ // runner, which kills the child on a hit). Without capturing it here the
77
+ // SIGTERM that kill produces would surface as a bare non-zero exit the
78
+ // caller couldn't distinguish from a crash.
79
+ let loopHit;
80
+ const timeout = workerTimeout(input.signal, timeoutMs);
81
+ let result;
82
+ try {
83
+ result = await runChildDefault(invocation, input.cwd, timeout.signal, {
84
+ mode: 'json-events',
85
+ onFirstByte: () => (tFirstByte = Date.now()),
86
+ onToolCall: call => {
87
+ const hit = loopDetector.record(call);
88
+ if (hit && !loopHit)
89
+ loopHit = hit;
90
+ return hit;
91
+ },
92
+ onLine: input.onLine
93
+ }, input.spawn);
94
+ }
95
+ finally {
96
+ timeout.cleanup();
97
+ }
30
98
  const tEnd = Date.now();
31
99
  const waitMs = tFirstByte === null ? tEnd - tStart : tFirstByte - tStart;
32
100
  const workMs = tFirstByte === null ? 0 : tEnd - tFirstByte;
33
101
  const text = result.text ?? '';
102
+ const timedOut = timeout.timedOut();
103
+ // A loop-kill gets the same restart-with-hint treatment every other phase
104
+ // already gets (runPhaseWithLoopGuard) — name the offending call so the
105
+ // re-spawn avoids it. Bounded by the shared restart budget.
106
+ if (loopHit && restarts < MAX_LOOP_RESTARTS) {
107
+ hint = formatLoopHint(loopHit);
108
+ restarts++;
109
+ continue;
110
+ }
111
+ // A wall-clock timeout (the backstop for varied thrash the exact-match
112
+ // detector misses) is also restartable, sharing the same budget. Skip when
113
+ // a loop also tripped — the loop hint above is more specific.
114
+ if (timedOut && !loopHit && restarts < MAX_LOOP_RESTARTS) {
115
+ hint = WORKER_TIMEOUT_HINT;
116
+ restarts++;
117
+ continue;
118
+ }
34
119
  // Only treat output as a leak on a clean, complete run — a non-zero exit
35
120
  // or abort yields partial text the caller already handles, and detecting
36
121
  // there would just mislabel the real failure.
37
122
  const leaked = result.exitCode === 0 && !result.aborted ? detectLeakedToolCall(text) : null;
38
- if (leaked && attempt < MAX_LEAK_RETRIES) {
123
+ if (leaked && leakRetries < MAX_LEAK_RETRIES) {
39
124
  hint = leakedToolCallHint(leaked);
125
+ leakRetries++;
40
126
  continue;
41
127
  }
42
128
  return {
@@ -46,7 +132,9 @@ export async function runWorker(input) {
46
132
  aborted: result.aborted,
47
133
  waitMs,
48
134
  workMs,
49
- ...(leaked ? { leakedToolCall: leaked } : {})
135
+ ...(leaked ? { leakedToolCall: leaked } : {}),
136
+ ...(loopHit ? { loopHit } : {}),
137
+ ...(timedOut ? { timedOut: true } : {})
50
138
  };
51
139
  }
52
140
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.13.16",
3
+ "version": "0.13.18",
4
4
  "description": "Deterministic spec-orchestration for local models, with a bundled real-time remote web view and web/docs/fetch/worker subagent tools.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",