@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.
- package/dist/shared/child-output.d.ts +19 -3
- package/dist/shared/child-output.js +21 -5
- package/dist/shared/git-runner.d.ts +39 -0
- package/dist/shared/git-runner.js +38 -0
- package/dist/task/accept-debt.d.ts +37 -61
- package/dist/task/accept-debt.js +67 -130
- package/dist/task/auto-orchestrator.d.ts +7 -57
- package/dist/task/auto-orchestrator.js +25 -499
- package/dist/task/child-runner.d.ts +2 -0
- package/dist/task/child-runner.js +74 -70
- package/dist/task/enforce-guidelines.d.ts +1 -1
- package/dist/task/enforce-guidelines.js +2 -2
- package/dist/task/external-context.d.ts +85 -7
- package/dist/task/external-context.js +100 -63
- package/dist/task/file-inventory.js +22 -41
- package/dist/task/final-gate.d.ts +80 -0
- package/dist/task/final-gate.js +102 -49
- package/dist/task/gate-deps.js +6 -23
- package/dist/task/git-state-guard.d.ts +1 -1
- package/dist/task/git-state-guard.js +1 -7
- package/dist/task/phases.js +40 -83
- package/dist/task/run-final-gate.d.ts +127 -0
- package/dist/task/run-final-gate.js +492 -0
- package/dist/task/task-gates.d.ts +20 -57
- package/dist/task/task-gates.js +11 -11
- package/dist/task/verify-work.d.ts +40 -32
- package/dist/task/verify-work.js +301 -241
- package/dist/workers/docs-core.d.ts +14 -0
- package/dist/workers/docs-core.js +28 -16
- package/dist/workers/fetch-core.d.ts +6 -1
- package/dist/workers/fetch-core.js +26 -33
- package/dist/workers/focused-extractor.d.ts +73 -0
- package/dist/workers/focused-extractor.js +72 -0
- package/dist/workers/pi-worker-docs.d.ts +1 -1
- package/dist/workers/pi-worker-docs.js +48 -42
- package/dist/workers/pi-worker-fetch.js +6 -8
- package/dist/workers/typeonly-log.d.ts +13 -0
- 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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
if (
|
|
142
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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 {
|
|
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 {
|
|
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 = (
|
|
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
|
|
3
|
-
*
|
|
4
|
-
* `EXTERNAL CONTEXT` block
|
|
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
|
-
*
|
|
26
|
-
*
|
|
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
|
|
3
|
-
*
|
|
4
|
-
* `EXTERNAL CONTEXT` block
|
|
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
|
-
*
|
|
19
|
-
*
|
|
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
|
|
22
|
-
const
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
const extraVersionPkgs = enrichTargets.versionPackages.filter(p => !
|
|
34
|
-
if (
|
|
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
|
|
40
|
-
|
|
41
|
-
|
|
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 =>
|
|
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
|
|
59
|
-
// from the cheap standalone lookup above. Together they cover EVERY
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
if (
|
|
63
|
-
|
|
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
|
-
|
|
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 <
|
|
80
|
-
const
|
|
81
|
-
if (
|
|
82
|
-
|
|
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 <
|
|
87
|
-
const s =
|
|
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
|
-
|
|
93
|
+
sections.push(formatServiceBlock(s.name, `${s.name} ${s.query}`, r.results));
|
|
99
94
|
}
|
|
100
95
|
if (skipped.length > 0) {
|
|
101
|
-
|
|
96
|
+
sections.push(formatFreshnessSkippedBlock(skipped));
|
|
102
97
|
}
|
|
103
|
-
|
|
104
|
-
|
|
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${
|
|
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 {
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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) {
|