@mjasnikovs/pi-task 0.13.6 → 0.13.8

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 (43) hide show
  1. package/dist/config/register.js +1 -1
  2. package/dist/remote/push.d.ts +12 -3
  3. package/dist/remote/push.js +63 -9
  4. package/dist/remote/register.js +7 -3
  5. package/dist/remote/server.d.ts +4 -2
  6. package/dist/remote/server.js +7 -3
  7. package/dist/remote/tailscale.d.ts +8 -2
  8. package/dist/remote/tailscale.js +13 -6
  9. package/dist/remote/ui-script.d.ts +3 -0
  10. package/dist/remote/ui-script.js +804 -0
  11. package/dist/remote/ui-styles.d.ts +1 -0
  12. package/dist/remote/ui-styles.js +202 -0
  13. package/dist/remote/ui.js +4 -1000
  14. package/dist/shared/child-process.d.ts +27 -0
  15. package/dist/shared/child-process.js +151 -139
  16. package/dist/task/auto-orchestrator.js +43 -13
  17. package/dist/task/auto-prompts.d.ts +4 -3
  18. package/dist/task/auto-prompts.js +9 -6
  19. package/dist/task/child-runner.js +1 -1
  20. package/dist/task/context-usage.d.ts +16 -0
  21. package/dist/task/context-usage.js +22 -0
  22. package/dist/task/external-context.d.ts +27 -0
  23. package/dist/task/external-context.js +93 -0
  24. package/dist/task/failure-classifier.js +1 -1
  25. package/dist/task/orchestrator.js +7 -13
  26. package/dist/task/parsers.d.ts +4 -15
  27. package/dist/task/parsers.js +48 -87
  28. package/dist/task/phases.d.ts +5 -7
  29. package/dist/task/phases.js +29 -84
  30. package/dist/task/prompts.d.ts +1 -0
  31. package/dist/task/prompts.js +9 -0
  32. package/dist/task/spec-validation.d.ts +23 -0
  33. package/dist/task/spec-validation.js +90 -0
  34. package/dist/task/widget.d.ts +1 -1
  35. package/dist/task/widget.js +1 -1
  36. package/dist/workers/html-clean.js +7 -4
  37. package/dist/workers/pi-worker-docs.js +69 -58
  38. package/dist/workers/pi-worker-fetch.js +25 -21
  39. package/dist/workers/pi-worker-search.js +7 -13
  40. package/dist/workers/pi-worker.js +8 -14
  41. package/dist/workers/shared.d.ts +40 -0
  42. package/dist/workers/shared.js +31 -0
  43. package/package.json +1 -1
@@ -60,6 +60,33 @@ export interface RunChildJsonEventsOptions {
60
60
  onFirstByte?: () => void;
61
61
  }
62
62
  export type RunChildOptions = RunChildTextOptions | RunChildJsonEventsOptions;
63
+ /**
64
+ * Parses a child's `--mode json` event stream into assistant text plus side
65
+ * effects (caller callbacks, loop-kill). It holds the cross-chunk line buffer
66
+ * and the text-assembly state, so event interpretation is independent of the
67
+ * spawn/kill machinery in runChild — and therefore unit-testable without a real
68
+ * child: construct one, `feed()` raw lines, assert on `text` / the callbacks /
69
+ * the onLoopKill signal.
70
+ */
71
+ export declare class JsonEventSink {
72
+ private readonly opts;
73
+ /** Invoked when onToolCall reports a loop hit — runChild kills the child. */
74
+ private readonly onLoopKill;
75
+ /** Final assistant text from the agent_end event, if one arrived. */
76
+ finalText: string;
77
+ private textDeltaAccum;
78
+ private buf;
79
+ constructor(opts: RunChildJsonEventsOptions,
80
+ /** Invoked when onToolCall reports a loop hit — runChild kills the child. */
81
+ onLoopKill: () => void);
82
+ /** Feed a raw stdout chunk: parse every complete line, buffer the partial tail. */
83
+ feed(chunk: string): void;
84
+ /** Flush a trailing event that wasn't newline-terminated (call on close). */
85
+ flush(): void;
86
+ /** Extracted assistant text: the agent_end text if present, else the deltas. */
87
+ get text(): string;
88
+ private handleEvent;
89
+ }
63
90
  export declare function runChild(spawn: SpawnFn, invocation: {
64
91
  command: string;
65
92
  args: ReadonlyArray<string>;
@@ -10,31 +10,162 @@ export const CHILD_BASE_ARGS = [
10
10
  '--no-context-files',
11
11
  '--no-session'
12
12
  ];
13
+ // ─── JSON event-stream sink ──────────────────────────────────────────────────
14
+ /**
15
+ * Parses a child's `--mode json` event stream into assistant text plus side
16
+ * effects (caller callbacks, loop-kill). It holds the cross-chunk line buffer
17
+ * and the text-assembly state, so event interpretation is independent of the
18
+ * spawn/kill machinery in runChild — and therefore unit-testable without a real
19
+ * child: construct one, `feed()` raw lines, assert on `text` / the callbacks /
20
+ * the onLoopKill signal.
21
+ */
22
+ export class JsonEventSink {
23
+ opts;
24
+ onLoopKill;
25
+ /** Final assistant text from the agent_end event, if one arrived. */
26
+ finalText = '';
27
+ textDeltaAccum = '';
28
+ // json-events lines can split across data chunks; this holds the trailing
29
+ // partial line between feeds so events spanning a boundary still parse. We
30
+ // deliberately do NOT accumulate the full raw stream: a long-running child
31
+ // emits hundreds of MB of events and buffering it would overflow V8's max
32
+ // string length (≈512MB). We keep only the parsed text.
33
+ buf = '';
34
+ constructor(opts,
35
+ /** Invoked when onToolCall reports a loop hit — runChild kills the child. */
36
+ onLoopKill) {
37
+ this.opts = opts;
38
+ this.onLoopKill = onLoopKill;
39
+ }
40
+ /** Feed a raw stdout chunk: parse every complete line, buffer the partial tail. */
41
+ feed(chunk) {
42
+ this.buf += chunk;
43
+ let nl;
44
+ while ((nl = this.buf.indexOf('\n')) !== -1) {
45
+ const line = this.buf.slice(0, nl).trim();
46
+ this.buf = this.buf.slice(nl + 1);
47
+ if (line.length === 0)
48
+ continue;
49
+ try {
50
+ const evt = JSON.parse(line);
51
+ if (evt && typeof evt === 'object')
52
+ this.handleEvent(evt);
53
+ }
54
+ catch {
55
+ // Non-JSON line (startup banner, etc.) — ignore.
56
+ }
57
+ }
58
+ }
59
+ /** Flush a trailing event that wasn't newline-terminated (call on close). */
60
+ flush() {
61
+ if (this.buf.trim().length > 0)
62
+ this.feed('\n');
63
+ this.buf = '';
64
+ }
65
+ /** Extracted assistant text: the agent_end text if present, else the deltas. */
66
+ get text() {
67
+ return (this.finalText || this.textDeltaAccum).trim();
68
+ }
69
+ handleEvent(evt) {
70
+ const opts = this.opts;
71
+ const t = typeof evt.type === 'string' ? evt.type : '';
72
+ if (t === 'context_usage' && opts.onContextUsage) {
73
+ const tokens = Number(evt.tokens ?? 0);
74
+ const contextWindow = Number(evt.contextWindow ?? 0);
75
+ const percent = Number(evt.percent ?? 0);
76
+ if (tokens > 0 || contextWindow > 0) {
77
+ opts.onContextUsage({ tokens, contextWindow, percent });
78
+ }
79
+ return;
80
+ }
81
+ if (t === 'message_end' && opts.onContextUsage) {
82
+ const msg = evt.message;
83
+ if (msg?.role === 'assistant') {
84
+ const usage = msg.usage;
85
+ if (usage) {
86
+ const tokens = Number(usage.input ?? 0)
87
+ + Number(usage.cacheRead ?? 0)
88
+ + Number(usage.cacheWrite ?? 0)
89
+ + Number(usage.output ?? 0);
90
+ if (tokens > 0) {
91
+ opts.onContextUsage({ tokens, contextWindow: 0, percent: 0 });
92
+ }
93
+ }
94
+ }
95
+ return;
96
+ }
97
+ if (t === 'agent_end' && Array.isArray(evt.messages)) {
98
+ for (let i = evt.messages.length - 1; i >= 0; i--) {
99
+ const m = evt.messages[i];
100
+ if (m && m.role === 'assistant' && Array.isArray(m.content)) {
101
+ const texts = [];
102
+ for (const c of m.content) {
103
+ if (c?.type === 'text' && typeof c.text === 'string') {
104
+ texts.push(c.text);
105
+ }
106
+ }
107
+ if (texts.length > 0) {
108
+ this.finalText = texts.join('');
109
+ break;
110
+ }
111
+ }
112
+ }
113
+ return;
114
+ }
115
+ if (t === 'message_update') {
116
+ const ame = evt.assistantMessageEvent;
117
+ const ameType = ame && typeof ame.type === 'string' ? ame.type : '';
118
+ if (ameType === 'text_start') {
119
+ this.textDeltaAccum = '';
120
+ if (opts.onLine)
121
+ opts.onLine('writing answer…');
122
+ }
123
+ else if (ameType === 'text_delta' && typeof ame.delta === 'string') {
124
+ this.textDeltaAccum += ame.delta;
125
+ }
126
+ else if (ameType === 'thinking_start' && opts.onLine) {
127
+ opts.onLine('thinking…');
128
+ }
129
+ return;
130
+ }
131
+ if (t === 'tool_execution_start') {
132
+ const tn = typeof evt.toolName === 'string' ? evt.toolName : 'tool';
133
+ if (opts.onLine) {
134
+ const detail = summarizeToolArgs(tn, evt.args);
135
+ opts.onLine(detail ? `${tn}: ${detail}` : tn);
136
+ }
137
+ if (opts.onToolCall) {
138
+ const hit = opts.onToolCall({ name: tn, args: evt.args });
139
+ if (hit)
140
+ this.onLoopKill();
141
+ }
142
+ }
143
+ }
144
+ }
13
145
  // ─── Unified runChild ────────────────────────────────────────────────────────
14
146
  export function runChild(spawn, invocation, cwd, signal, opts) {
15
147
  return new Promise(resolve => {
16
148
  let stdout = '';
17
149
  let stderr = '';
18
150
  let aborted = false;
19
- const isJsonEvents = opts?.mode === 'json-events';
20
151
  const discardStdout = opts?.mode === 'text' && opts.discardStdout === true;
21
- // State for json-events mode
22
- let finalText = '';
23
- let textDeltaAccum = '';
24
152
  const proc = spawn(invocation.command, invocation.args, {
25
153
  cwd,
26
154
  shell: false,
27
155
  stdio: ['ignore', 'pipe', 'pipe']
28
156
  });
157
+ // One kill path, shared by user-abort and loop-kill: SIGTERM, then
158
+ // SIGKILL after a grace period if the child ignored the term.
159
+ const killProc = () => {
160
+ aborted = true;
161
+ proc.kill('SIGTERM');
162
+ setTimeout(() => {
163
+ if (!proc.killed)
164
+ proc.kill('SIGKILL');
165
+ }, KILL_GRACE_MS);
166
+ };
167
+ const sink = opts?.mode === 'json-events' ? new JsonEventSink(opts, killProc) : null;
29
168
  let firstByteFired = false;
30
- // json-events lines can split across data chunks; this holds the trailing
31
- // partial line between chunks so events spanning a boundary still parse.
32
- // In json-events mode we deliberately do NOT accumulate the full raw
33
- // stream: a long-running child emits hundreds of MB of events and
34
- // `stdout += chunk` would overflow V8's max string length (≈512MB),
35
- // crashing the process. We only need the parsed text, so we drop the raw
36
- // bytes once drained.
37
- let lineBuffer = '';
38
169
  proc.stdout?.on('data', (d) => {
39
170
  if (!firstByteFired) {
40
171
  firstByteFired = true;
@@ -43,147 +174,28 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
43
174
  if (discardStdout)
44
175
  return;
45
176
  const chunk = d.toString();
46
- if (isJsonEvents) {
47
- lineBuffer = drainJsonEvents(lineBuffer + chunk, opts);
48
- }
49
- else {
177
+ if (sink)
178
+ sink.feed(chunk);
179
+ else
50
180
  stdout += chunk;
51
- }
52
181
  });
53
182
  proc.stderr?.on('data', (d) => {
54
183
  stderr += d.toString();
55
184
  });
56
185
  proc.on('close', (code) => {
57
- // Flush a final event that wasn't newline-terminated.
58
- if (isJsonEvents && lineBuffer.trim().length > 0) {
59
- drainJsonEvents(lineBuffer + '\n', opts);
60
- lineBuffer = '';
61
- }
62
- const text = isJsonEvents ? (finalText || textDeltaAccum).trim() : undefined;
186
+ if (sink)
187
+ sink.flush();
188
+ const text = sink ? sink.text : undefined;
63
189
  resolve({ stdout, stderr, exitCode: code ?? 0, aborted, text });
64
190
  });
65
191
  proc.on('error', () => {
66
192
  resolve({ stdout, stderr, exitCode: 1, aborted });
67
193
  });
68
194
  if (signal) {
69
- const kill = () => {
70
- aborted = true;
71
- proc.kill('SIGTERM');
72
- setTimeout(() => {
73
- if (!proc.killed)
74
- proc.kill('SIGKILL');
75
- }, KILL_GRACE_MS);
76
- };
77
195
  if (signal.aborted)
78
- kill();
196
+ killProc();
79
197
  else
80
- signal.addEventListener('abort', kill, { once: true });
81
- }
82
- // ─── JSON event-stream processing ──────────────────────────────────
83
- /**
84
- * Parse every complete (newline-terminated) JSON line in `buf` and
85
- * return the trailing partial line, which the caller carries into the
86
- * next chunk. This avoids both losing events that span a chunk boundary
87
- * and buffering the entire stream.
88
- */
89
- function drainJsonEvents(buf, jsonOpts) {
90
- let nl;
91
- while ((nl = buf.indexOf('\n')) !== -1) {
92
- const line = buf.slice(0, nl).trim();
93
- buf = buf.slice(nl + 1);
94
- if (line.length === 0)
95
- continue;
96
- try {
97
- const evt = JSON.parse(line);
98
- if (evt && typeof evt === 'object') {
99
- handleEvent(evt, jsonOpts);
100
- }
101
- }
102
- catch {
103
- // Non-JSON line (startup banner, etc.) — ignore.
104
- }
105
- }
106
- return buf;
107
- }
108
- function handleEvent(evt, jsonOpts) {
109
- const t = typeof evt.type === 'string' ? evt.type : '';
110
- if (t === 'context_usage' && jsonOpts.onContextUsage) {
111
- const tokens = Number(evt.tokens ?? 0);
112
- const contextWindow = Number(evt.contextWindow ?? 0);
113
- const percent = Number(evt.percent ?? 0);
114
- if (tokens > 0 || contextWindow > 0) {
115
- jsonOpts.onContextUsage({ tokens, contextWindow, percent });
116
- }
117
- return;
118
- }
119
- if (t === 'message_end' && jsonOpts.onContextUsage) {
120
- const msg = evt.message;
121
- if (msg?.role === 'assistant') {
122
- const usage = msg.usage;
123
- if (usage) {
124
- const tokens = Number(usage.input ?? 0)
125
- + Number(usage.cacheRead ?? 0)
126
- + Number(usage.cacheWrite ?? 0)
127
- + Number(usage.output ?? 0);
128
- if (tokens > 0) {
129
- jsonOpts.onContextUsage({ tokens, contextWindow: 0, percent: 0 });
130
- }
131
- }
132
- }
133
- return;
134
- }
135
- if (t === 'agent_end' && Array.isArray(evt.messages)) {
136
- for (let i = evt.messages.length - 1; i >= 0; i--) {
137
- const m = evt.messages[i];
138
- if (m && m.role === 'assistant' && Array.isArray(m.content)) {
139
- const texts = [];
140
- for (const c of m.content) {
141
- if (c?.type === 'text' && typeof c.text === 'string') {
142
- texts.push(c.text);
143
- }
144
- }
145
- if (texts.length > 0) {
146
- finalText = texts.join('');
147
- break;
148
- }
149
- }
150
- }
151
- return;
152
- }
153
- if (t === 'message_update') {
154
- const ame = evt.assistantMessageEvent;
155
- const ameType = ame && typeof ame.type === 'string' ? ame.type : '';
156
- if (ameType === 'text_start') {
157
- textDeltaAccum = '';
158
- if (jsonOpts.onLine)
159
- jsonOpts.onLine('writing answer…');
160
- }
161
- else if (ameType === 'text_delta' && typeof ame.delta === 'string') {
162
- textDeltaAccum += ame.delta;
163
- }
164
- else if (ameType === 'thinking_start' && jsonOpts.onLine) {
165
- jsonOpts.onLine('thinking…');
166
- }
167
- return;
168
- }
169
- if (t === 'tool_execution_start') {
170
- const tn = typeof evt.toolName === 'string' ? evt.toolName : 'tool';
171
- if (jsonOpts.onLine) {
172
- const detail = summarizeToolArgs(tn, evt.args);
173
- jsonOpts.onLine(detail ? `${tn}: ${detail}` : tn);
174
- }
175
- if (jsonOpts.onToolCall) {
176
- const hit = jsonOpts.onToolCall({ name: tn, args: evt.args });
177
- if (hit) {
178
- aborted = true;
179
- proc.kill('SIGTERM');
180
- setTimeout(() => {
181
- if (!proc.killed)
182
- proc.kill('SIGKILL');
183
- }, KILL_GRACE_MS);
184
- }
185
- }
186
- }
198
+ signal.addEventListener('abort', killProc, { once: true });
187
199
  }
188
200
  });
189
201
  }
@@ -18,6 +18,7 @@ import { runPhaseChild, USER_CANCELLED } from './child-runner.js';
18
18
  import { SessionUI, registerBridgeCommand } from '../remote/bridge.js';
19
19
  import { getConfig } from '../config/config.js';
20
20
  import { startAutoLoader } from './widget.js';
21
+ import { getParentContextWindow, resolveContextUsage } from './context-usage.js';
21
22
  // Matches pi's @-file completion token (a path after @, until whitespace).
22
23
  const MENTION_RE = /(?:^|\s)@([^\s]+)/g;
23
24
  /**
@@ -54,8 +55,12 @@ export async function planAuto(ctx, cwd, feature, deps) {
54
55
  // clarify — sequential & adaptive: ask one question at a time, feeding every
55
56
  // answer back into the next call so later questions react to earlier ones
56
57
  // (e.g. a framework choice reshapes what gets asked). Each question is shown
57
- // with the model's recommended default pre-filled (Enter to accept, type to
58
- // override); we never auto-answer. The model emits NONE when nothing remains.
58
+ // exactly like /task's grill dialog: a binary fork offers two options (A/B),
59
+ // otherwise the model's recommendation is shown as the input placeholder and
60
+ // in the title. Nothing is pre-filled into the editor — submitting an empty
61
+ // field is what accepts the recommendation (see the typed.length === 0 branch
62
+ // below); typing overrides it. We never auto-answer; the model emits NONE when
63
+ // nothing remains.
59
64
  const theme = ctx.ui.theme;
60
65
  const ui = new SessionUI(ctx);
61
66
  // Inline any @file spec the user referenced so clarify/decompose reason over
@@ -68,26 +73,38 @@ export async function planAuto(ctx, cwd, feature, deps) {
68
73
  const parsed = parseClarifyList(qRaw);
69
74
  if (parsed.length === 0)
70
75
  break; // NONE / nothing left to ask
71
- const { question, suggested } = parsed[0];
76
+ const { question, suggested, alt } = parsed[0];
72
77
  // Render markdown (bold/code) for the displayed prompt; keep plain text
73
78
  // for the editable default and the persisted file.
74
79
  const shownQ = renderInlineMarkdown(question, theme);
75
80
  const plainQ = stripInlineMarkdown(question);
76
81
  const plainSuggested = suggested === undefined ? undefined : stripInlineMarkdown(suggested);
77
- const title = suggested ?
78
- `${shownQ}\n${theme.fg('muted', 'Recommended:')}\n\n${renderInlineMarkdown(suggested, theme)}\n\n${theme.fg('muted', 'press Enter to accept')}`
79
- : `${shownQ}\n${theme.fg('muted', '(no recommendation please answer)')}`;
82
+ const plainAlt = alt === undefined ? undefined : stripInlineMarkdown(alt);
83
+ // Compact A/B presentation, identical to /task's grill dialog: a binary
84
+ // fork shows both options labelled A/B; a single recommendation shows just
85
+ // the default; an open question shows the bare prompt. No verbose
86
+ // "Recommended:" / "press Enter to accept" scaffolding.
87
+ const title = plainSuggested ?
88
+ plainAlt ?
89
+ `${shownQ}\nA: ${renderInlineMarkdown(suggested, theme)}\nB: ${renderInlineMarkdown(alt, theme)}`
90
+ : `${shownQ}\n${renderInlineMarkdown(suggested, theme)}`
91
+ : shownQ;
80
92
  const a = await ui.ask({
81
93
  localTitle: title,
82
94
  question: plainQ,
83
95
  recommended: plainSuggested,
84
- allowSkip: plainSuggested === undefined
96
+ ...(plainAlt !== undefined && { recommended2: plainAlt }),
97
+ allowSkip: plainSuggested === undefined && plainAlt === undefined
85
98
  });
86
99
  if (a === undefined) {
87
100
  ctx.ui.notify('/task-auto cancelled.', 'warning');
88
101
  return null;
89
102
  }
90
103
  const typed = a.trim();
104
+ // Two-option mode labels the choices "A:"/"B:", so a user (local TUI or
105
+ // remote "Manual answer") naturally types the bare letter to pick. Map it
106
+ // back to the option's full text. Mirrors phaseGrill's answer mapping.
107
+ const twoOption = plainSuggested !== undefined && plainAlt !== undefined;
91
108
  let answer;
92
109
  if (typed.length === 0 && plainSuggested) {
93
110
  answer = `${plainSuggested} (accepted recommendation)`;
@@ -95,6 +112,12 @@ export async function planAuto(ctx, cwd, feature, deps) {
95
112
  else if (typed.length === 0) {
96
113
  answer = '(skipped)';
97
114
  }
115
+ else if (twoOption && /^a[.)]?$/i.test(typed)) {
116
+ answer = plainSuggested;
117
+ }
118
+ else if (twoOption && /^b[.)]?$/i.test(typed)) {
119
+ answer = plainAlt;
120
+ }
98
121
  else {
99
122
  answer = typed;
100
123
  }
@@ -136,7 +159,7 @@ function defaultDeps(ctx, cwd, signal, title) {
136
159
  // output line and context usage, exactly like the single-task phase widget.
137
160
  let lastLine;
138
161
  let contextUsage;
139
- const parentContextWindow = ctx.model?.contextWindow ?? 0;
162
+ const parentContextWindow = getParentContextWindow(ctx);
140
163
  const phaseDeps = {
141
164
  cwd,
142
165
  taskId: '',
@@ -145,11 +168,7 @@ function defaultDeps(ctx, cwd, signal, title) {
145
168
  lastLine = line;
146
169
  },
147
170
  onContextUsage: snapshot => {
148
- const cw = snapshot.contextWindow > 0 ?
149
- snapshot.contextWindow
150
- : contextUsage?.contextWindow || parentContextWindow;
151
- const percent = cw > 0 ? Math.min(100, (snapshot.tokens / cw) * 100) : snapshot.percent;
152
- contextUsage = { tokens: snapshot.tokens, contextWindow: cw, percent };
171
+ contextUsage = resolveContextUsage(snapshot, contextUsage, parentContextWindow);
153
172
  }
154
173
  };
155
174
  return {
@@ -233,6 +252,17 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
233
252
  resumeId = undefined;
234
253
  }
235
254
  }
255
+ // Before starting, fold any uncommitted work into its own checkpoint
256
+ // commit so a dirty tree at the start of the run — or edits left behind
257
+ // by an interrupted/failed task — land separately instead of being swept
258
+ // into this task's snapshot. Best-effort and a no-op on a clean tree
259
+ // (gitCommitAll commits nothing), so it only ever produces a commit when
260
+ // there is stray work; the matching post-task commit below is the "after"
261
+ // half. Only the success path is announced to keep the common no-op quiet.
262
+ const checkpoint = await deps.commit(cwd, `chore: checkpoint before "${next.title}"`);
263
+ if (checkpoint.committed) {
264
+ active.ui.notify(`${id}: checkpointed uncommitted work before "${next.title}".`, 'info');
265
+ }
236
266
  const res = await deps.runTask(active, cwd, next.title, {
237
267
  resumeId,
238
268
  onStart: resumeId ? undefined : (innerId => stampTaskInProgress(cwd, id, next.index, innerId, next.title))
@@ -4,9 +4,10 @@
4
4
  */
5
5
  /**
6
6
  * Clarify: asks ONE question at a time. Output MUST match parseClarifyList — a
7
- * single numbered question followed by a "SUGGESTED: <default>" line, or the
8
- * literal token NONE when no clarification remains. priorQA carries the
9
- * questions already answered so each next question adapts to them.
7
+ * single numbered question followed by a "SUGGESTED: <default>" line, an optional
8
+ * "ALT: <alternative>" line for binary "A or B?" forks, or the literal token NONE
9
+ * when no clarification remains. priorQA carries the questions already answered so
10
+ * each next question adapts to them.
10
11
  */
11
12
  export declare const AUTO_CLARIFY_PROMPT: (feature: string, priorQA: string) => string;
12
13
  /**
@@ -4,9 +4,10 @@
4
4
  */
5
5
  /**
6
6
  * Clarify: asks ONE question at a time. Output MUST match parseClarifyList — a
7
- * single numbered question followed by a "SUGGESTED: <default>" line, or the
8
- * literal token NONE when no clarification remains. priorQA carries the
9
- * questions already answered so each next question adapts to them.
7
+ * single numbered question followed by a "SUGGESTED: <default>" line, an optional
8
+ * "ALT: <alternative>" line for binary "A or B?" forks, or the literal token NONE
9
+ * when no clarification remains. priorQA carries the questions already answered so
10
+ * each next question adapts to them.
10
11
  */
11
12
  export const AUTO_CLARIFY_PROMPT = (feature, priorQA) => `You are planning how to split a feature into separate implementation tasks, one clarifying question at a time.
12
13
 
@@ -33,14 +34,16 @@ fork the breakdown). Account for the answers so far:
33
34
  real-time vs polling transport, search, deployment).
34
35
  - Skip anything /task will naturally resolve per-task during its own research.
35
36
 
36
- Also propose the single most sensible default answer for this question, inferred
37
- from the repo, the referenced docs, and any stated philosophy or constraints —
37
+ Also propose the most sensible default answer for this question, inferred from
38
+ the repo, the referenced docs, and any stated philosophy or constraints —
38
39
  concrete and decisive, shown to the user as a recommendation they can accept or
39
- override.
40
+ override. When the question is a genuine binary "A or B?" fork, also give the
41
+ single best alternative as a second option; otherwise offer only the one default.
40
42
 
41
43
  OUTPUT FORMAT (exact):
42
44
  - One clarifying question as a single numbered line: "1. ...".
43
45
  - On the NEXT line (never inline), a line that begins with "SUGGESTED: <your recommended default>".
46
+ - ONLY for a binary "A or B?" fork, on the line after that, a line beginning with "ALT: <the alternative option>". Omit the ALT line entirely for open-ended questions.
44
47
  - Put the core question in **bold**, followed by a short one-line rationale in plain prose. Backticks around code/identifiers are fine. Avoid other markdown (headings, bullet lists, links).
45
48
  - Only when the spec already pins down every choice that would change the task breakdown — nothing decision-changing is left to ask — output exactly:
46
49
  NONE`;
@@ -10,7 +10,7 @@ import { getPiInvocation } from '../shared/pi-invocation.js';
10
10
  import { runChild as runChildUnified, CHILD_BASE_ARGS } from '../shared/child-process.js';
11
11
  import { LoopDetector } from './loop-detector.js';
12
12
  import { detectLeakedToolCall, leakedToolCallHint, MAX_LEAK_RETRIES } from '../shared/leaked-tool-call.js';
13
- import { readSection, setTaskSection } from './task-file.js';
13
+ import { readSection, setTaskSection } from './task-io.js';
14
14
  // ─── Loop detection constants ────────────────────────────────────────────────
15
15
  // Defined here (not in phases.ts) to avoid a circular dependency:
16
16
  // phases.ts → child-runner.ts → phases.ts
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Context-usage resolution — shared by the single-task widget (TaskRunner) and
3
+ * the /task-auto planning loader (defaultDeps), which both mirror a child's
4
+ * context_usage events into a display snapshot with identical math.
5
+ */
6
+ import type { ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
7
+ import type { ContextSnapshot } from '../shared/child-process.js';
8
+ /** The parent session's context window, or 0 when the model doesn't expose it. */
9
+ export declare function getParentContextWindow(ctx: ExtensionCommandContext): number;
10
+ /**
11
+ * Fold a raw context_usage snapshot into a display snapshot: prefer the child's
12
+ * own contextWindow, else the last known one, else the parent session's; then
13
+ * derive percent against it — falling back to the child's reported percent when
14
+ * no window is known at all.
15
+ */
16
+ export declare function resolveContextUsage(snapshot: ContextSnapshot, prev: ContextSnapshot | undefined, parentContextWindow: number): ContextSnapshot;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Context-usage resolution — shared by the single-task widget (TaskRunner) and
3
+ * the /task-auto planning loader (defaultDeps), which both mirror a child's
4
+ * context_usage events into a display snapshot with identical math.
5
+ */
6
+ /** The parent session's context window, or 0 when the model doesn't expose it. */
7
+ export function getParentContextWindow(ctx) {
8
+ return (ctx.model?.contextWindow ?? 0);
9
+ }
10
+ /**
11
+ * Fold a raw context_usage snapshot into a display snapshot: prefer the child's
12
+ * own contextWindow, else the last known one, else the parent session's; then
13
+ * derive percent against it — falling back to the child's reported percent when
14
+ * no window is known at all.
15
+ */
16
+ export function resolveContextUsage(snapshot, prev, parentContextWindow) {
17
+ const cw = snapshot.contextWindow > 0 ?
18
+ snapshot.contextWindow
19
+ : prev?.contextWindow || parentContextWindow;
20
+ const percent = cw > 0 ? Math.min(100, (snapshot.tokens / cw) * 100) : snapshot.percent;
21
+ return { tokens: snapshot.tokens, contextWindow: cw, percent };
22
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * External-context enrichment — extract packages / URLs / services from the
3
+ * refined spec, fan out to docs / fetch / search workers, and assemble the
4
+ * `EXTERNAL CONTEXT` block the research phase prepends to every worker prompt.
5
+ *
6
+ * Split out of phases.ts so the research phase reads as "gather context → run
7
+ * probes → assemble", and so this fan-out has its own test surface separate
8
+ * from the four research workers. `enrichment.ts` stays a pure parser; the I/O
9
+ * lives here.
10
+ */
11
+ import { docsRaw } from '../workers/docs-core.js';
12
+ import { fetchRaw } from '../workers/fetch-core.js';
13
+ import type { SearchCoreInput, SearchCoreResult } from '../workers/search-core.js';
14
+ import type { PhaseDeps } from './child-runner.js';
15
+ /** Injectable workers so enrichment is testable without spawning real lookups. */
16
+ export interface ExternalContextDeps {
17
+ docsRaw?: typeof docsRaw;
18
+ fetchRaw?: typeof fetchRaw;
19
+ searchFn?: (input: SearchCoreInput) => Promise<SearchCoreResult>;
20
+ }
21
+ type GatherDeps = Pick<PhaseDeps, 'cwd' | 'signal' | 'recordSubStep'>;
22
+ /**
23
+ * Returns the `EXTERNAL CONTEXT\n…\n\n` block for the refined spec, or `''` when
24
+ * there is nothing to enrich (no targets, or every lookup failed).
25
+ */
26
+ export declare function gatherExternalContext(refined: string, deps: GatherDeps, researchDeps?: ExternalContextDeps): Promise<string>;
27
+ export {};