@mjasnikovs/pi-task 0.37.7 → 0.38.1

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 (38) hide show
  1. package/dist/shared/child-output.d.ts +19 -3
  2. package/dist/shared/child-output.js +21 -5
  3. package/dist/shared/git-runner.d.ts +39 -0
  4. package/dist/shared/git-runner.js +38 -0
  5. package/dist/task/accept-debt.d.ts +37 -61
  6. package/dist/task/accept-debt.js +67 -130
  7. package/dist/task/auto-orchestrator.d.ts +7 -57
  8. package/dist/task/auto-orchestrator.js +25 -499
  9. package/dist/task/child-runner.d.ts +2 -0
  10. package/dist/task/child-runner.js +74 -70
  11. package/dist/task/enforce-guidelines.d.ts +1 -1
  12. package/dist/task/enforce-guidelines.js +2 -2
  13. package/dist/task/external-context.d.ts +85 -7
  14. package/dist/task/external-context.js +100 -63
  15. package/dist/task/file-inventory.js +22 -41
  16. package/dist/task/final-gate.d.ts +80 -0
  17. package/dist/task/final-gate.js +102 -49
  18. package/dist/task/gate-deps.js +6 -23
  19. package/dist/task/git-state-guard.d.ts +1 -1
  20. package/dist/task/git-state-guard.js +1 -7
  21. package/dist/task/phases.js +40 -83
  22. package/dist/task/run-final-gate.d.ts +127 -0
  23. package/dist/task/run-final-gate.js +492 -0
  24. package/dist/task/task-gates.d.ts +20 -57
  25. package/dist/task/task-gates.js +11 -11
  26. package/dist/task/verify-work.d.ts +40 -32
  27. package/dist/task/verify-work.js +301 -241
  28. package/dist/workers/docs-core.d.ts +14 -0
  29. package/dist/workers/docs-core.js +28 -16
  30. package/dist/workers/fetch-core.d.ts +6 -1
  31. package/dist/workers/fetch-core.js +26 -33
  32. package/dist/workers/focused-extractor.d.ts +73 -0
  33. package/dist/workers/focused-extractor.js +72 -0
  34. package/dist/workers/pi-worker-docs.d.ts +1 -1
  35. package/dist/workers/pi-worker-docs.js +48 -42
  36. package/dist/workers/pi-worker-fetch.js +6 -8
  37. package/dist/workers/typeonly-log.d.ts +13 -0
  38. package/package.json +1 -1
@@ -124,52 +124,80 @@ export async function runChild(cwd, tools, prompt, signal, onLine, onContextUsag
124
124
  leakedToolCall: loopHit ? undefined : (detectLeakedToolCall(text) ?? undefined)
125
125
  };
126
126
  }
127
+ /**
128
+ * The error-triage ladder both phase wrappers run over a finished child, in
129
+ * this fixed order: non-zero exit → model error → empty completion → leaked
130
+ * tool call. Callers own the loop, the prompt and the hint; this owns the
131
+ * verdict, so a fix to any rung lands in every caller at once.
132
+ *
133
+ * `attempt` is the caller's 0-based counter (its attempt/strike), `budget` the
134
+ * matching restart allowance (MAX_LEAK_RETRIES for runPhaseChild's leak budget,
135
+ * MAX_LOOP_RESTARTS for runPhaseWithLoopGuard's strike budget) — so both run
136
+ * `budget + 1` attempts in total before a rung gives up and throws.
137
+ *
138
+ * `verb` names the caller's restart in the debug log ("retry" for runPhaseChild,
139
+ * "restart" for runPhaseWithLoopGuard). It is the only externally visible thing
140
+ * that differs between the two, and the only way to tell from a debug log which
141
+ * wrapper produced a given line — so it is passed in rather than hardcoded.
142
+ *
143
+ * A loop kill (`r.loopHit`) is NOT handled here: only runPhaseWithLoopGuard
144
+ * detects loops, and it must consume the hit before calling this.
145
+ */
146
+ async function triageChildResult(deps, name, r, attempt, budget, verb) {
147
+ if (r.exitCode !== 0) {
148
+ throw new Error(`${name} child failed: ${r.stderr || '(no stderr)'}`);
149
+ }
150
+ if (r.modelError) {
151
+ // The model/provider failed (pi exited 0 with a stopReason "error"
152
+ // turn). A connection-class cause is transient — re-spawn within the
153
+ // caller's budget after a backoff; anything else fails fast (pi already
154
+ // retried, and re-spawning won't fix a real fault).
155
+ if (isConnectionError(r.modelError) && attempt < budget) {
156
+ deps.logDebug?.(`${name}: connection error "${r.modelError}" — ${verb} `
157
+ + `${attempt + 1}/${budget}`);
158
+ await (deps.sleepFor ?? defaultSleep)(connectionRetryBackoffMs(attempt));
159
+ return { done: false };
160
+ }
161
+ throw new ModelError(name, r.modelError);
162
+ }
163
+ if (r.text.trim().length === 0) {
164
+ // An empty completion (exit 0, no assistant text, no stderr) is almost
165
+ // always transient — a model/API error swallowed inside --mode json,
166
+ // not a repeatable mistake — so re-spawn rather than fail the phase.
167
+ // There's nothing to correct, so we carry no hint (and leave any hint
168
+ // the caller already has alone). Shares the caller's budget: budget+1
169
+ // attempts, then surface the error.
170
+ if (attempt === budget) {
171
+ throw new Error(`${name} child produced no output${r.stderr ? ' — stderr: ' + r.stderr : ''}`);
172
+ }
173
+ return { done: false };
174
+ }
175
+ if (r.leakedToolCall) {
176
+ if (attempt === budget) {
177
+ throw new LeakedToolCallError(name, r.leakedToolCall);
178
+ }
179
+ return { done: false, hint: leakedToolCallHint(r.leakedToolCall) };
180
+ }
181
+ return { done: true, text: r.text };
182
+ }
127
183
  /**
128
184
  * Run a child pi and return its assistant text. Throws if exit code != 0.
129
185
  *
130
186
  * If the child leaks a tool call as plain text (wrong dialect — never executed),
131
187
  * re-prompt with a correction hint up to MAX_LEAK_RETRIES times; if it keeps
132
188
  * leaking, throw LeakedToolCallError rather than returning the unexecuted call.
189
+ * Empty completions and connection-class model errors share that same budget —
190
+ * see triageChildResult, which decides every one of those cases.
133
191
  */
134
192
  export async function runPhaseChild(deps, name, tools, prompt) {
135
193
  let hint = null;
136
194
  for (let attempt = 0; attempt <= MAX_LEAK_RETRIES; attempt++) {
137
195
  const r = await runChild(deps.cwd, tools, prependHint(hint, prompt), deps.signal, deps.onChildOutput, deps.onContextUsage, undefined, deps.spawn);
138
- if (r.exitCode !== 0) {
139
- throw new Error(`${name} child failed: ${r.stderr || '(no stderr)'}`);
140
- }
141
- if (r.modelError) {
142
- // The model/provider failed (pi exited 0 with a stopReason "error"
143
- // turn). A connection-class cause is transient — retry within the
144
- // leak budget after a backoff; anything else fails fast (pi already
145
- // retried, and re-spawning won't fix a real fault).
146
- if (isConnectionError(r.modelError) && attempt < MAX_LEAK_RETRIES) {
147
- deps.logDebug?.(`${name}: connection error "${r.modelError}" — retry `
148
- + `${attempt + 1}/${MAX_LEAK_RETRIES}`);
149
- await (deps.sleepFor ?? defaultSleep)(connectionRetryBackoffMs(attempt));
150
- continue;
151
- }
152
- throw new ModelError(name, r.modelError);
153
- }
154
- if (r.text.trim().length === 0) {
155
- // An empty completion (exit 0, no assistant text, no stderr) is almost
156
- // always transient — a model/API error swallowed inside --mode json,
157
- // not a repeatable mistake — so re-spawn rather than fail the phase.
158
- // There's nothing to correct, so we carry no hint. Reuses the leak
159
- // retry budget: MAX_LEAK_RETRIES+1 attempts, then surface the error.
160
- if (attempt === MAX_LEAK_RETRIES) {
161
- throw new Error(`${name} child produced no output${r.stderr ? ' — stderr: ' + r.stderr : ''}`);
162
- }
163
- continue;
164
- }
165
- if (r.leakedToolCall) {
166
- if (attempt === MAX_LEAK_RETRIES) {
167
- throw new LeakedToolCallError(name, r.leakedToolCall);
168
- }
169
- hint = leakedToolCallHint(r.leakedToolCall);
170
- continue;
171
- }
172
- return r.text;
196
+ const step = await triageChildResult(deps, name, r, attempt, MAX_LEAK_RETRIES, 'retry');
197
+ if (step.done)
198
+ return step.text;
199
+ if (step.hint !== undefined)
200
+ hint = step.hint;
173
201
  }
174
202
  // Unreachable: the loop returns clean text or throws on the final leak.
175
203
  throw new LeakedToolCallError(name, '(unknown)');
@@ -235,41 +263,17 @@ export async function runPhaseWithLoopGuard(deps, name, tools, buildPrompt, opts
235
263
  nextHint = formatLoopHint(r.loopHit);
236
264
  continue;
237
265
  }
238
- if (r.exitCode !== 0) {
239
- throw new Error(`${name} child failed: ${r.stderr || '(no stderr)'}`);
240
- }
241
- if (r.modelError) {
242
- // The model/provider failed (pi exited 0 with a stopReason "error"
243
- // turn). A connection-class cause is transient — restart within the
244
- // strike budget after a backoff; anything else fails fast (pi already
245
- // retried, and re-spawning won't fix a real fault).
246
- if (isConnectionError(r.modelError) && strike < MAX_LOOP_RESTARTS) {
247
- deps.logDebug?.(`${name}: connection error "${r.modelError}" restart `
248
- + `${strike + 1}/${MAX_LOOP_RESTARTS}`);
249
- await (deps.sleepFor ?? defaultSleep)(connectionRetryBackoffMs(strike));
250
- continue;
251
- }
252
- throw new ModelError(name, r.modelError);
253
- }
254
- if (r.text.trim().length === 0) {
255
- // An empty completion (exit 0, no assistant text, no stderr) is almost
256
- // always transient — a model/API error swallowed inside --mode json,
257
- // not a repeatable mistake — so re-spawn rather than fail the phase.
258
- // Nothing to correct, so leave nextHint as-is. Reuses the strike
259
- // budget shared with loop/leak restarts: MAX_LOOP_RESTARTS+1 attempts.
260
- if (strike === MAX_LOOP_RESTARTS) {
261
- throw new Error(`${name} child produced no output${r.stderr ? ' — stderr: ' + r.stderr : ''}`);
262
- }
263
- continue;
264
- }
265
- if (r.leakedToolCall) {
266
- if (strike === MAX_LOOP_RESTARTS) {
267
- throw new LeakedToolCallError(name, r.leakedToolCall);
268
- }
269
- nextHint = leakedToolCallHint(r.leakedToolCall);
270
- continue;
271
- }
272
- return r.text;
266
+ // Everything past the loop kill is the shared ladder: exit code, model
267
+ // error (connection-class restarts within the strike budget), empty
268
+ // completion, leaked tool call. The strike budget is shared with the
269
+ // loop restarts above — MAX_LOOP_RESTARTS+1 attempts across all causes.
270
+ const step = await triageChildResult(deps, name, r, strike, MAX_LOOP_RESTARTS, 'restart');
271
+ if (step.done)
272
+ return step.text;
273
+ // Only a leak produces a new correction hint; the other rungs have
274
+ // nothing to correct and leave any loop hint already in flight alone.
275
+ if (step.hint !== undefined)
276
+ nextHint = step.hint;
273
277
  }
274
278
  throw new LoopExhaustedError(name, loopHistory);
275
279
  }
@@ -1,4 +1,4 @@
1
- import { type SpawnFn } from '../shared/child-process.js';
1
+ import type { SpawnFn } from '../shared/child-process.js';
2
2
  /** Filenames discovered in the working directory (cwd only — no tree walk). */
3
3
  export declare const GUIDELINE_FILENAMES: readonly ["AGENTS.md", "CLAUDE.md"];
4
4
  /**
@@ -23,7 +23,7 @@
23
23
  */
24
24
  import * as fsp from 'node:fs/promises';
25
25
  import * as path from 'node:path';
26
- import { runChildDefault } from '../shared/child-process.js';
26
+ import { makeGit } from '../shared/git-runner.js';
27
27
  import { USER_CANCELLED } from './child-runner.js';
28
28
  import { TASKS_DIR_NAME } from './task-types.js';
29
29
  import { findProbeGamingInDiff } from './probe-gaming.js';
@@ -279,7 +279,7 @@ export async function captureCommitDiff(cwd, signal, spawnFn) {
279
279
  // `:(exclude)<dir>` is a git pathspec that drops everything under the tasks
280
280
  // directory from the result, leaving only real source changes to verify.
281
281
  const excludeTasks = `:(exclude)${TASKS_DIR_NAME}`;
282
- const run = (args) => runChildDefault({ command: 'git', args }, cwd, signal, { mode: 'text' }, spawnFn);
282
+ const run = makeGit(cwd, signal, spawnFn);
283
283
  // Diff the last commit against its parent. On a root commit there is no
284
284
  // HEAD~1 (rev-parse exits non-zero), so fall back to the empty tree.
285
285
  const parent = await run(['rev-parse', '--verify', '--quiet', 'HEAD~1']);
@@ -1,18 +1,94 @@
1
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.
2
+ * External-context enrichment — extract packages / URLs / services from a piece
3
+ * of text, fan out to docs / fetch / search workers, and assemble the
4
+ * `EXTERNAL CONTEXT` block that gets prepended to a worker prompt.
5
5
  *
6
6
  * Split out of phases.ts so the research phase reads as "gather context → run
7
7
  * probes → assemble", and so this fan-out has its own test surface separate
8
8
  * from the four research workers. `enrichment.ts` stays a pure parser; the I/O
9
9
  * lives here.
10
+ *
11
+ * There were TWO copies of this assembly: {@link gatherExternalContext} (the
12
+ * research phase, raw workers) and an 87-line inline block in `phaseAutoAnswer`
13
+ * (the grill auto-answer, focused workers). They agreed on everything that
14
+ * shows up in the output — the 8-step shape, the "npm blocks lead" ordering,
15
+ * the `### docs:` / `### url:` headings, the service loop's `no_key` / `error`
16
+ * handling, the block terminator — and disagreed only on POLICY. So the shape
17
+ * is {@link buildExternalContext} once, the disagreements are
18
+ * {@link ExternalContextPolicy}, and the worker variant is
19
+ * {@link ExternalContextLookups} — an adapter, expressible only since the
20
+ * focused-extractor seam landed.
10
21
  */
11
22
  import { docsRaw } from '../workers/docs-core.js';
12
23
  import { fetchRaw } from '../workers/fetch-core.js';
13
- import { npmVersionLookup } from '../workers/npm-version.js';
24
+ import { npmVersionLookup, type NpmVersionInfo } from '../workers/npm-version.js';
14
25
  import type { SearchCoreInput, SearchCoreResult } from '../workers/search-core.js';
15
26
  import type { PhaseDeps } from './child-runner.js';
27
+ type GatherDeps = Pick<PhaseDeps, 'cwd' | 'signal' | 'recordSubStep'>;
28
+ /** What a target lookup contributes to the block. */
29
+ export interface ExternalTargetResult {
30
+ /** Emitted as an `### npm:` block ahead of every body. Absent for url targets. */
31
+ npmVersion?: NpmVersionInfo | null;
32
+ /**
33
+ * The `### docs:`/`### url:` body. `undefined` means "this target contributes no
34
+ * body block" — and the two call paths draw that line differently ON PURPOSE:
35
+ * the raw lookups emit a (possibly empty) body whenever the docs call returned
36
+ * chunks, the focused ones emit one only when the child produced a non-empty
37
+ * answer. Both keep any `npmVersion` regardless.
38
+ */
39
+ body?: string;
40
+ }
41
+ /**
42
+ * The worker variant. This is the adapter the focused-extractor seam made
43
+ * expressible: `gatherExternalContext` binds the RAW workers (whole doc chunks /
44
+ * whole page markdown, truncated), `phaseAutoAnswer` binds the FOCUSED ones (a
45
+ * child's one-question answer). Everything else about the block is identical.
46
+ *
47
+ * A rejected lookup is caught by the builder and yields no blocks for that
48
+ * target, so adapters may throw freely.
49
+ */
50
+ export interface ExternalContextLookups {
51
+ docs(pkg: string): Promise<ExternalTargetResult | null>;
52
+ url(url: string): Promise<ExternalTargetResult | null>;
53
+ /** Defaults to the real search worker. */
54
+ search?: (input: SearchCoreInput) => Promise<SearchCoreResult>;
55
+ }
56
+ /**
57
+ * The six places the two call paths genuinely disagreed. Each is a knob rather
58
+ * than a default, because every one of them is a deliberate, measured choice on
59
+ * at least one path — the auto-answer block is capped and records nothing
60
+ * because it runs per grill question, in front of a waiting user.
61
+ */
62
+ export interface ExternalContextPolicy {
63
+ /**
64
+ * Max combined docs+url targets fanned out, packages first. Omit for uncapped
65
+ * (research); the auto-answer path caps at 2.
66
+ */
67
+ targetCap?: number;
68
+ /** Max services fanned out. Omit for uncapped; the auto-answer path caps at 2. */
69
+ serviceCap?: number;
70
+ /**
71
+ * A cheap live version lookup for every named dep that did NOT get a docs
72
+ * target, so a version block exists for ALL of them. Omit to disable, as the
73
+ * auto-answer path does. Without this on the research path, deps past the
74
+ * docs cap had no live version and a "which version?" question fell back to
75
+ * the model's stale training data — how tailwindcss got pinned to an old major.
76
+ */
77
+ versionLookup?: (pkg: string) => Promise<NpmVersionInfo | null>;
78
+ /** Sub-step label recorded via `deps.recordSubStep`. Omit to record nothing. */
79
+ subStepLabel?: string;
80
+ /**
81
+ * Return `''` before any fan-out when there is nothing to look up. Only the
82
+ * research path does this today; on the auto-answer path the empty fan-out is
83
+ * a no-op that produces the same `''`.
84
+ */
85
+ earlyReturnOnNoTargets?: boolean;
86
+ }
87
+ /**
88
+ * Assemble the `EXTERNAL CONTEXT\n…\n\n` block for `source`, or `''` when there
89
+ * is nothing to enrich (no targets, or every lookup failed).
90
+ */
91
+ export declare function buildExternalContext(source: string, deps: GatherDeps, lookups: ExternalContextLookups, policy?: ExternalContextPolicy): Promise<string>;
16
92
  /** Injectable workers so enrichment is testable without spawning real lookups. */
17
93
  export interface ExternalContextDeps {
18
94
  docsRaw?: typeof docsRaw;
@@ -20,10 +96,12 @@ export interface ExternalContextDeps {
20
96
  searchFn?: (input: SearchCoreInput) => Promise<SearchCoreResult>;
21
97
  npmVersionLookup?: typeof npmVersionLookup;
22
98
  }
23
- type GatherDeps = Pick<PhaseDeps, 'cwd' | 'signal' | 'recordSubStep'>;
24
99
  /**
25
- * Returns the `EXTERNAL CONTEXT\n…\n\n` block for the refined spec, or `''` when
26
- * there is nothing to enrich (no targets, or every lookup failed).
100
+ * The RESEARCH-phase binding: raw workers, no caps, live versions for every
101
+ * named dep, truncated bodies, timed, and short-circuited when there is nothing
102
+ * to look up.
103
+ *
104
+ * Returns the `EXTERNAL CONTEXT\n…\n\n` block for the refined spec, or `''`.
27
105
  */
28
106
  export declare function gatherExternalContext(refined: string, deps: GatherDeps, researchDeps?: ExternalContextDeps): Promise<string>;
29
107
  export {};
@@ -1,12 +1,23 @@
1
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.
2
+ * External-context enrichment — extract packages / URLs / services from a piece
3
+ * of text, fan out to docs / fetch / search workers, and assemble the
4
+ * `EXTERNAL CONTEXT` block that gets prepended to a worker prompt.
5
5
  *
6
6
  * Split out of phases.ts so the research phase reads as "gather context → run
7
7
  * probes → assemble", and so this fan-out has its own test surface separate
8
8
  * from the four research workers. `enrichment.ts` stays a pure parser; the I/O
9
9
  * lives here.
10
+ *
11
+ * There were TWO copies of this assembly: {@link gatherExternalContext} (the
12
+ * research phase, raw workers) and an 87-line inline block in `phaseAutoAnswer`
13
+ * (the grill auto-answer, focused workers). They agreed on everything that
14
+ * shows up in the output — the 8-step shape, the "npm blocks lead" ordering,
15
+ * the `### docs:` / `### url:` headings, the service loop's `no_key` / `error`
16
+ * handling, the block terminator — and disagreed only on POLICY. So the shape
17
+ * is {@link buildExternalContext} once, the disagreements are
18
+ * {@link ExternalContextPolicy}, and the worker variant is
19
+ * {@link ExternalContextLookups} — an adapter, expressible only since the
20
+ * focused-extractor seam landed.
10
21
  */
11
22
  import { docsRaw } from '../workers/docs-core.js';
12
23
  import { fetchRaw } from '../workers/fetch-core.js';
@@ -14,77 +25,61 @@ import { formatNpmVersionSection, npmVersionLookup } from '../workers/npm-versio
14
25
  import { search as defaultSearch } from '../workers/search-core.js';
15
26
  import { extractEnrichTargets } from './enrichment.js';
16
27
  import { formatServiceBlock, formatFreshnessSkippedBlock } from './service-blocks.js';
28
+ /** How much of a docs/url body survives into the block, for the raw-worker lookups. */
29
+ const RAW_BODY_LIMIT = 4000;
17
30
  /**
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).
31
+ * Assemble the `EXTERNAL CONTEXT\n…\n\n` block for `source`, or `''` when there
32
+ * is nothing to enrich (no targets, or every lookup failed).
20
33
  */
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 npmVersionFn = researchDeps.npmVersionLookup ?? npmVersionLookup;
26
- const enrichTargets = extractEnrichTargets(refined);
27
- // Every named dep past the heavy-docs cap still gets a cheap live version
28
- // lookup, so a version block exists for ALL of them (the docs-fetched ones
29
- // emit their version below from the bundled lookup). Without this, deps 4..N
30
- // had no live version and a "which version?" question fell back to the model's
31
- // stale training data — how tailwindcss got pinned to an old major.
32
- const docsPkgs = new Set(enrichTargets.packages);
33
- const extraVersionPkgs = enrichTargets.versionPackages.filter(p => !docsPkgs.has(p));
34
- if (enrichTargets.packages.length === 0
35
- && enrichTargets.urls.length === 0
36
- && enrichTargets.services.length === 0) {
34
+ export async function buildExternalContext(source, deps, lookups, policy = {}) {
35
+ const searchFn = lookups.search ?? defaultSearch;
36
+ const enrichTargets = extractEnrichTargets(source);
37
+ // Packages lead urls, then the combined cap applies — so a capped run spends
38
+ // its budget on named deps first. Uncapped, this is just "packages, then urls".
39
+ const targets = [
40
+ ...enrichTargets.packages.map(name => ({ kind: 'pkg', name })),
41
+ ...enrichTargets.urls.map(name => ({ kind: 'url', name }))
42
+ ].slice(0, policy.targetCap ?? Number.POSITIVE_INFINITY);
43
+ const services = enrichTargets.services.slice(0, policy.serviceCap ?? Number.POSITIVE_INFINITY);
44
+ const versionLookup = policy.versionLookup;
45
+ const docsTargets = new Set(targets.filter(t => t.kind === 'pkg').map(t => t.name));
46
+ const extraVersionPkgs = versionLookup ? enrichTargets.versionPackages.filter(p => !docsTargets.has(p)) : [];
47
+ if (policy.earlyReturnOnNoTargets && targets.length === 0 && services.length === 0)
37
48
  return '';
38
- }
39
- const enrichSections = [];
40
- const tEnrichStart = Date.now();
41
- const [docsResults, fetchResults, serviceResults, extraVersionResults] = await Promise.all([
42
- Promise.all(enrichTargets.packages.map(pkg => docsRawFn({
43
- pkg,
44
- query: refined.split('\n').find(l => l.trim()) ?? refined,
45
- cwd: deps.cwd,
46
- signal: deps.signal
47
- }).catch(() => null))),
48
- Promise.all(enrichTargets.urls.map(url => fetchRawFn({ url, signal: deps.signal }).catch(() => null))),
49
- Promise.all(enrichTargets.services.map(s => searchFn({
49
+ const startedAt = Date.now();
50
+ const [targetResults, serviceResults, extraVersionResults] = await Promise.all([
51
+ Promise.all(targets.map(t => (t.kind === 'pkg' ? lookups.docs(t.name) : lookups.url(t.name)).catch(() => null))),
52
+ Promise.all(services.map(s => searchFn({
50
53
  query: `${s.name} ${s.query}`,
51
54
  count: 3,
52
55
  signal: deps.signal
53
56
  }).catch(() => null))),
54
- Promise.all(extraVersionPkgs.map(pkg => npmVersionFn(pkg, { signal: deps.signal }).catch(() => null)))
57
+ Promise.all(versionLookup ? extraVersionPkgs.map(pkg => versionLookup(pkg).catch(() => null)) : [])
55
58
  ]);
59
+ const sections = [];
56
60
  // npm version blocks lead the section so the model anchors on live version
57
61
  // data before reading any docs body. The docs-fetched packages carry their
58
- // version in docsRaw's bundled lookup; the remaining named deps get theirs
59
- // from the cheap standalone lookup above. Together they cover EVERY named dep.
60
- for (let i = 0; i < enrichTargets.packages.length; i++) {
61
- const v = docsResults[i]?.npmVersion;
62
- if (v)
63
- enrichSections.push(formatNpmVersionSection(v));
62
+ // version in the lookup's own bundled result; the remaining named deps get
63
+ // theirs from the cheap standalone lookup above. Together they cover EVERY
64
+ // named dep.
65
+ for (const r of targetResults) {
66
+ if (r?.npmVersion)
67
+ sections.push(formatNpmVersionSection(r.npmVersion));
64
68
  }
65
69
  for (const v of extraVersionResults) {
66
70
  if (v)
67
- enrichSections.push(formatNpmVersionSection(v));
68
- }
69
- for (let i = 0; i < enrichTargets.packages.length; i++) {
70
- const r = docsResults[i];
71
- if (r?.kind === 'ok' && r.chunks.length > 0) {
72
- const body = r.chunks
73
- .map(c => c.content)
74
- .join('\n\n')
75
- .slice(0, 4000);
76
- enrichSections.push(`### docs: ${enrichTargets.packages[i]}\n${body}`);
77
- }
71
+ sections.push(formatNpmVersionSection(v));
78
72
  }
79
- for (let i = 0; i < enrichTargets.urls.length; i++) {
80
- const r = fetchResults[i];
81
- if (r) {
82
- enrichSections.push(`### url: ${enrichTargets.urls[i]}\n${r.markdown.slice(0, 4000)}`);
83
- }
73
+ for (let i = 0; i < targets.length; i++) {
74
+ const body = targetResults[i]?.body;
75
+ if (body === undefined)
76
+ continue;
77
+ const heading = targets[i].kind === 'pkg' ? 'docs' : 'url';
78
+ sections.push(`### ${heading}: ${targets[i].name}\n${body}`);
84
79
  }
85
80
  const skipped = [];
86
- for (let i = 0; i < enrichTargets.services.length; i++) {
87
- const s = enrichTargets.services[i];
81
+ for (let i = 0; i < services.length; i++) {
82
+ const s = services[i];
88
83
  const r = serviceResults[i];
89
84
  if (r === null)
90
85
  continue;
@@ -95,13 +90,55 @@ export async function gatherExternalContext(refined, deps, researchDeps = {}) {
95
90
  if (r.kind === 'error')
96
91
  continue;
97
92
  // kind === 'ok'
98
- enrichSections.push(formatServiceBlock(s.name, `${s.name} ${s.query}`, r.results));
93
+ sections.push(formatServiceBlock(s.name, `${s.name} ${s.query}`, r.results));
99
94
  }
100
95
  if (skipped.length > 0) {
101
- enrichSections.push(formatFreshnessSkippedBlock(skipped));
96
+ sections.push(formatFreshnessSkippedBlock(skipped));
102
97
  }
103
- deps.recordSubStep?.('enrichment', Date.now() - tEnrichStart);
104
- if (enrichSections.length === 0)
98
+ if (policy.subStepLabel)
99
+ deps.recordSubStep?.(policy.subStepLabel, Date.now() - startedAt);
100
+ if (sections.length === 0)
105
101
  return '';
106
- return `EXTERNAL CONTEXT\n${enrichSections.join('\n\n')}\n\n`;
102
+ return `EXTERNAL CONTEXT\n${sections.join('\n\n')}\n\n`;
103
+ }
104
+ /**
105
+ * The RESEARCH-phase binding: raw workers, no caps, live versions for every
106
+ * named dep, truncated bodies, timed, and short-circuited when there is nothing
107
+ * to look up.
108
+ *
109
+ * Returns the `EXTERNAL CONTEXT\n…\n\n` block for the refined spec, or `''`.
110
+ */
111
+ export async function gatherExternalContext(refined, deps, researchDeps = {}) {
112
+ const docsRawFn = researchDeps.docsRaw ?? docsRaw;
113
+ const fetchRawFn = researchDeps.fetchRaw ?? fetchRaw;
114
+ const npmVersionFn = researchDeps.npmVersionLookup ?? npmVersionLookup;
115
+ const docsQuery = refined.split('\n').find(l => l.trim()) ?? refined;
116
+ return buildExternalContext(refined, deps, {
117
+ docs: async (pkg) => {
118
+ const r = await docsRawFn({
119
+ pkg,
120
+ query: docsQuery,
121
+ cwd: deps.cwd,
122
+ signal: deps.signal
123
+ });
124
+ return {
125
+ npmVersion: r.npmVersion,
126
+ body: r.kind === 'ok' && r.chunks.length > 0 ?
127
+ r.chunks
128
+ .map(c => c.content)
129
+ .join('\n\n')
130
+ .slice(0, RAW_BODY_LIMIT)
131
+ : undefined
132
+ };
133
+ },
134
+ url: async (url) => {
135
+ const r = await fetchRawFn({ url, signal: deps.signal });
136
+ return { body: r.markdown.slice(0, RAW_BODY_LIMIT) };
137
+ },
138
+ search: researchDeps.searchFn
139
+ }, {
140
+ versionLookup: pkg => npmVersionFn(pkg, { signal: deps.signal }),
141
+ subStepLabel: 'enrichment',
142
+ earlyReturnOnNoTargets: true
143
+ });
107
144
  }
@@ -4,7 +4,7 @@
4
4
  * read/grep on known paths. Returns '' on failure (non-git repo, git missing,
5
5
  * timeout) so callers can fall back to the pre-inventory behavior.
6
6
  */
7
- import { spawn } from 'node:child_process';
7
+ import { makeGit } from '../shared/git-runner.js';
8
8
  import { TASKS_DIR_NAME } from './task-types.js';
9
9
  const DEFAULT_MAX_LINES = 2000;
10
10
  /**
@@ -23,46 +23,27 @@ export function stripTasksDir(raw) {
23
23
  .filter(l => !l.startsWith(prefix))
24
24
  .join('\n');
25
25
  }
26
- function runGitLsFiles(cwd, signal) {
27
- return new Promise(resolve => {
28
- let stdout = '';
29
- let proc;
30
- try {
31
- proc = spawn('git', ['ls-files'], { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
32
- }
33
- catch {
34
- resolve('');
35
- return;
36
- }
37
- proc.stdout?.on('data', (d) => {
38
- stdout += d.toString();
39
- });
40
- // Same detach discipline as runChild (GitHub issue #9): `signal` is the
41
- // run-long orchestrator signal, so a listener left behind by a normally
42
- // finished `git ls-files` would retain that child for the whole run.
43
- const onAbort = () => {
44
- if (!proc.killed)
45
- proc.kill('SIGTERM');
46
- };
47
- let settled = false;
48
- const settle = (value) => {
49
- signal?.removeEventListener('abort', onAbort);
50
- if (settled)
51
- return;
52
- settled = true;
53
- resolve(value);
54
- };
55
- proc.once('error', () => settle(''));
56
- proc.once('close', code => settle(code === 0 ? stdout : ''));
57
- if (signal) {
58
- // An already-aborted signal never emits 'abort', so without this check
59
- // a run cancelled before this point would let the child run to term.
60
- if (signal.aborted)
61
- onAbort();
62
- else
63
- signal.addEventListener('abort', onAbort, { once: true });
64
- }
65
- });
26
+ /**
27
+ * `git ls-files`, or '' on ANY failure — non-git tree, missing git, cancelled run.
28
+ * The empty string is the caller's fall-back-to-pre-inventory signal, so every
29
+ * unhappy path has to collapse to it.
30
+ *
31
+ * Runs on the shared GitRunner (`shared/git-runner.ts`), which brings the abort
32
+ * discipline this used to hand-roll: the listener is detached when the child
33
+ * settles normally, so a run-long orchestrator signal does not accumulate one
34
+ * retained child per invocation (GitHub issue #9).
35
+ *
36
+ * The `signal.aborted` check is what preserves this function's OWN contract on
37
+ * cancellation. A killed child closes with a null exit code, which the runner
38
+ * reports as 0 — so without it a cancelled run would hand back a truncated
39
+ * inventory as if it were complete, where the hand-rolled version returned ''.
40
+ */
41
+ async function runGitLsFiles(cwd, signal) {
42
+ const git = makeGit(cwd, signal);
43
+ const r = await git(['ls-files']);
44
+ if (r.exitCode !== 0 || signal?.aborted)
45
+ return '';
46
+ return r.stdout;
66
47
  }
67
48
  /** Cap output to maxLines real (non-blank) paths; tag truncation when cut. */
68
49
  export function capInventory(raw, maxLines = DEFAULT_MAX_LINES) {