@mjasnikovs/pi-task 0.13.6 → 0.13.7

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.
@@ -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
  /**
@@ -136,7 +137,7 @@ function defaultDeps(ctx, cwd, signal, title) {
136
137
  // output line and context usage, exactly like the single-task phase widget.
137
138
  let lastLine;
138
139
  let contextUsage;
139
- const parentContextWindow = ctx.model?.contextWindow ?? 0;
140
+ const parentContextWindow = getParentContextWindow(ctx);
140
141
  const phaseDeps = {
141
142
  cwd,
142
143
  taskId: '',
@@ -145,11 +146,7 @@ function defaultDeps(ctx, cwd, signal, title) {
145
146
  lastLine = line;
146
147
  },
147
148
  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 };
149
+ contextUsage = resolveContextUsage(snapshot, contextUsage, parentContextWindow);
153
150
  }
154
151
  };
155
152
  return {
@@ -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 {};
@@ -0,0 +1,93 @@
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 { formatNpmVersionSection } from '../workers/npm-version.js';
14
+ import { search as defaultSearch } from '../workers/search-core.js';
15
+ import { extractEnrichTargets } from './enrichment.js';
16
+ import { formatServiceBlock, formatFreshnessSkippedBlock } from './service-blocks.js';
17
+ /**
18
+ * Returns the `EXTERNAL CONTEXT\n…\n\n` block for the refined spec, or `''` when
19
+ * there is nothing to enrich (no targets, or every lookup failed).
20
+ */
21
+ export async function gatherExternalContext(refined, deps, researchDeps = {}) {
22
+ const docsRawFn = researchDeps.docsRaw ?? docsRaw;
23
+ const fetchRawFn = researchDeps.fetchRaw ?? fetchRaw;
24
+ const searchFn = researchDeps.searchFn ?? defaultSearch;
25
+ const enrichTargets = extractEnrichTargets(refined);
26
+ if (enrichTargets.packages.length === 0
27
+ && enrichTargets.urls.length === 0
28
+ && enrichTargets.services.length === 0) {
29
+ return '';
30
+ }
31
+ const enrichSections = [];
32
+ const tEnrichStart = Date.now();
33
+ const [docsResults, fetchResults, serviceResults] = await Promise.all([
34
+ Promise.all(enrichTargets.packages.map(pkg => docsRawFn({
35
+ pkg,
36
+ query: refined.split('\n').find(l => l.trim()) ?? refined,
37
+ cwd: deps.cwd,
38
+ signal: deps.signal
39
+ }).catch(() => null))),
40
+ Promise.all(enrichTargets.urls.map(url => fetchRawFn({ url, signal: deps.signal }).catch(() => null))),
41
+ Promise.all(enrichTargets.services.map(s => searchFn({
42
+ query: `${s.name} ${s.query}`,
43
+ count: 3,
44
+ signal: deps.signal
45
+ }).catch(() => null)))
46
+ ]);
47
+ // npm version blocks come from docsRaw's bundled lookup and lead the
48
+ // section so the model anchors on live version data before reading
49
+ // the docs body.
50
+ for (let i = 0; i < enrichTargets.packages.length; i++) {
51
+ const v = docsResults[i]?.npmVersion;
52
+ if (v)
53
+ enrichSections.push(formatNpmVersionSection(v));
54
+ }
55
+ for (let i = 0; i < enrichTargets.packages.length; i++) {
56
+ const r = docsResults[i];
57
+ if (r?.kind === 'ok' && r.chunks.length > 0) {
58
+ const body = r.chunks
59
+ .map(c => c.content)
60
+ .join('\n\n')
61
+ .slice(0, 4000);
62
+ enrichSections.push(`### docs: ${enrichTargets.packages[i]}\n${body}`);
63
+ }
64
+ }
65
+ for (let i = 0; i < enrichTargets.urls.length; i++) {
66
+ const r = fetchResults[i];
67
+ if (r) {
68
+ enrichSections.push(`### url: ${enrichTargets.urls[i]}\n${r.markdown.slice(0, 4000)}`);
69
+ }
70
+ }
71
+ const skipped = [];
72
+ for (let i = 0; i < enrichTargets.services.length; i++) {
73
+ const s = enrichTargets.services[i];
74
+ const r = serviceResults[i];
75
+ if (r === null)
76
+ continue;
77
+ if (r.kind === 'no_key') {
78
+ skipped.push(s.name);
79
+ continue;
80
+ }
81
+ if (r.kind === 'error')
82
+ continue;
83
+ // kind === 'ok'
84
+ enrichSections.push(formatServiceBlock(s.name, `${s.name} ${s.query}`, r.results));
85
+ }
86
+ if (skipped.length > 0) {
87
+ enrichSections.push(formatFreshnessSkippedBlock(skipped));
88
+ }
89
+ deps.recordSubStep?.('enrichment', Date.now() - tEnrichStart);
90
+ if (enrichSections.length === 0)
91
+ return '';
92
+ return `EXTERNAL CONTEXT\n${enrichSections.join('\n\n')}\n\n`;
93
+ }
@@ -2,7 +2,7 @@
2
2
  * Failure classification — map runtime errors to task state transitions,
3
3
  * widget flash messages, and user notifications.
4
4
  */
5
- import { updateTaskFrontMatter } from './task-file.js';
5
+ import { updateTaskFrontMatter } from './task-io.js';
6
6
  import { flashTerminalWidget } from './widget.js';
7
7
  import { LoopExhaustedError, LeakedToolCallError, USER_CANCELLED } from './child-runner.js';
8
8
  // ─── Classifier ──────────────────────────────────────────────────────────────
@@ -19,11 +19,14 @@ import * as fsp from 'node:fs/promises';
19
19
  import * as path from 'node:path';
20
20
  import { PHASES, postCommitPhase } from './phases.js';
21
21
  import { handleFailure } from './failure-classifier.js';
22
- import { PHASE_INDEX, PHASE_ORDER, allocateTaskId, ensureTasksDir, normaliseTaskId, parseFrontMatter, readSection, readTaskFile, setTaskSection, taskFilePath, tasksDir, updateTaskFrontMatter, writeTaskFile, extractSection, RESUMABLE_STATES } from './task-file.js';
22
+ import { PHASE_INDEX, PHASE_ORDER, RESUMABLE_STATES } from './task-types.js';
23
+ import { normaliseTaskId, parseFrontMatter, extractSection } from './task-parsers.js';
24
+ import { allocateTaskId, ensureTasksDir, readSection, readTaskFile, setTaskSection, taskFilePath, tasksDir, updateTaskFrontMatter, writeTaskFile } from './task-io.js';
23
25
  import { startWidget } from './widget.js';
24
26
  import { publishViewer, publishNotify, registerBridgeCommand, getBridge } from '../remote/bridge.js';
25
- import { parseVerifyBlock } from './parsers.js';
27
+ import { parseVerifyBlock } from './spec-validation.js';
26
28
  import { formatTimings } from './timings.js';
29
+ import { getParentContextWindow, resolveContextUsage } from './context-usage.js';
27
30
  // ─── Module-level state ──────────────────────────────────────────────────────
28
31
  let activeTask = null;
29
32
  /** Set the module-level active task (avoids `this` aliasing in TaskRunner.run). */
@@ -77,7 +80,7 @@ export class TaskRunner {
77
80
  phase: 'refine',
78
81
  startedAt: this._startedAt
79
82
  };
80
- const parentContextWindow = ctx.model?.contextWindow ?? 0;
83
+ const parentContextWindow = getParentContextWindow(ctx);
81
84
  this._deps = {
82
85
  cwd,
83
86
  taskId: '',
@@ -87,16 +90,7 @@ export class TaskRunner {
87
90
  this._widgetState.lastLine = line;
88
91
  },
89
92
  onContextUsage: snapshot => {
90
- const prev = this._widgetState.contextUsage;
91
- const cw = snapshot.contextWindow > 0 ?
92
- snapshot.contextWindow
93
- : prev?.contextWindow || parentContextWindow;
94
- const percent = cw > 0 ? Math.min(100, (snapshot.tokens / cw) * 100) : snapshot.percent;
95
- this._widgetState.contextUsage = {
96
- tokens: snapshot.tokens,
97
- contextWindow: cw,
98
- percent
99
- };
93
+ this._widgetState.contextUsage = resolveContextUsage(snapshot, this._widgetState.contextUsage, parentContextWindow);
100
94
  },
101
95
  recordSubStep: (label, ms) => {
102
96
  if (this._currentPhaseChildren) {
@@ -3,9 +3,6 @@
3
3
  *
4
4
  * Pure functions that parse raw model output into structured data.
5
5
  */
6
- export interface VerifyCommand {
7
- raw: string;
8
- }
9
6
  export type AutoAnswer = {
10
7
  kind: 'answered';
11
8
  text: string;
@@ -23,9 +20,9 @@ export interface ClarifyQuestion {
23
20
  }
24
21
  export declare const GRILL_LINE_RE: RegExp;
25
22
  export declare const SUGGESTED_LINE_RE: RegExp;
26
- export declare function parseVerifyBlock(spec: string): VerifyCommand[] | null;
27
23
  export declare function parseGrillQuestions(raw: string): string[];
28
24
  export declare function parseClarifyList(raw: string): ClarifyQuestion[];
25
+ export declare function autoAnswerHasTag(raw: string): boolean;
29
26
  export declare function parseAutoAnswer(raw: string): AutoAnswer;
30
27
  export declare function parseVerifyToolingOutput(output: string): {
31
28
  verified: string[];
@@ -34,15 +31,4 @@ export declare function parseVerifyToolingOutput(output: string): {
34
31
  reason: string;
35
32
  }>;
36
33
  };
37
- export declare function isCritiqueClean(text: string): boolean;
38
- /**
39
- * Drop any preamble the model emitted before the spec's GOAL header. The
40
- * thinking model sometimes narrates ("Now I have all the context. Here's the
41
- * rewritten spec:") before GOAL — the prompts forbid it, but the critique
42
- * validator only checks for a VERIFY block, so it leaked into the delivered
43
- * spec. We slice from the first line that begins a GOAL section so the spec
44
- * starts at GOAL. No GOAL line → returned unchanged (validation then flags it).
45
- */
46
- export declare function stripSpecPreamble(spec: string): string;
47
- export declare function validateSpecShape(spec: string): string | null;
48
34
  export declare function deriveTitle(refined: string): string;