@mjasnikovs/pi-task 0.38.14 → 0.38.16

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 (62) hide show
  1. package/dist/shared/child-process.js +9 -16
  2. package/dist/task/accept-debt.d.ts +7 -5
  3. package/dist/task/accept-debt.js +16 -13
  4. package/dist/task/auto-orchestrator.js +38 -36
  5. package/dist/task/autofix-ledger.d.ts +113 -0
  6. package/dist/task/autofix-ledger.js +152 -0
  7. package/dist/task/boot-probe.d.ts +63 -1
  8. package/dist/task/boot-probe.js +98 -2
  9. package/dist/task/child-runner.d.ts +50 -6
  10. package/dist/task/child-runner.js +48 -69
  11. package/dist/task/command-run.d.ts +49 -6
  12. package/dist/task/command-run.js +154 -18
  13. package/dist/task/external-context.d.ts +9 -12
  14. package/dist/task/external-context.js +5 -5
  15. package/dist/task/failure-classifier.d.ts +9 -1
  16. package/dist/task/failure-classifier.js +9 -0
  17. package/dist/task/final-gate-fix.d.ts +22 -26
  18. package/dist/task/final-gate-fix.js +2 -7
  19. package/dist/task/final-gate.d.ts +10 -2
  20. package/dist/task/final-gate.js +49 -88
  21. package/dist/task/gate-deps.js +20 -13
  22. package/dist/task/orchestrator.d.ts +33 -24
  23. package/dist/task/orchestrator.js +66 -44
  24. package/dist/task/phases.d.ts +58 -34
  25. package/dist/task/phases.js +140 -113
  26. package/dist/task/plan-orchestrator.js +2 -2
  27. package/dist/task/repo-health-check.d.ts +21 -21
  28. package/dist/task/repo-health-check.js +43 -112
  29. package/dist/task/run-end.d.ts +77 -0
  30. package/dist/task/run-end.js +37 -0
  31. package/dist/task/run-final-gate.js +71 -79
  32. package/dist/task/task-gates.d.ts +8 -0
  33. package/dist/task/task-gates.js +23 -4
  34. package/dist/task/terminal-outcome.d.ts +1 -1
  35. package/dist/task/terminal-outcome.js +12 -0
  36. package/dist/workers/brave-search.d.ts +7 -0
  37. package/dist/workers/brave-search.js +36 -55
  38. package/dist/workers/ddg-search.d.ts +1 -1
  39. package/dist/workers/ddg-search.js +27 -47
  40. package/dist/workers/exa-search.d.ts +2 -2
  41. package/dist/workers/exa-search.js +53 -68
  42. package/dist/workers/html-clean.js +67 -88
  43. package/dist/workers/http-request.d.ts +74 -0
  44. package/dist/workers/http-request.js +103 -0
  45. package/dist/workers/npm-version.js +37 -42
  46. package/dist/workers/pi-worker-core.d.ts +13 -2
  47. package/dist/workers/pi-worker-core.js +12 -17
  48. package/dist/workers/pi-worker-docs.d.ts +1 -1
  49. package/dist/workers/pi-worker-docs.js +49 -68
  50. package/dist/workers/pi-worker-fetch.d.ts +1 -1
  51. package/dist/workers/pi-worker-fetch.js +20 -21
  52. package/dist/workers/pi-worker-search.js +6 -4
  53. package/dist/workers/pi-worker.js +5 -4
  54. package/dist/workers/search-core.d.ts +1 -1
  55. package/dist/workers/search-core.js +36 -42
  56. package/dist/workers/search-types.d.ts +13 -0
  57. package/dist/workers/search-types.js +27 -0
  58. package/dist/workers/shared.d.ts +51 -11
  59. package/dist/workers/shared.js +0 -0
  60. package/dist/workers/worker-channels.d.ts +60 -0
  61. package/dist/workers/worker-channels.js +98 -0
  62. package/package.json +1 -1
@@ -0,0 +1,74 @@
1
+ /**
2
+ * http-request — the one bounded HTTP request in this codebase.
3
+ *
4
+ * Five modules used to hand-roll the same ~12 lines: an internal
5
+ * `AbortController`, a `setTimeout` that aborts it, a `userAborted` flag set
6
+ * from the caller's signal, and a `finally` that clears the timer and removes
7
+ * the listener. Five copies of a rule is five chances to drift, and it HAD
8
+ * drifted — `npm-version.ts` never grew the `userAborted` flag, so a user cancel
9
+ * came back as `null`, indistinguishable from a registry that is down.
10
+ *
11
+ * What is shared is the BOUNDING, not the interpretation. Each caller still owns
12
+ * its own status-code policy (DDG treats 429/403 as rate-limiting, Brave splits
13
+ * auth from rate-limit, npm treats every non-OK as "no answer") and its own error
14
+ * type, because those genuinely differ. What they cannot differ on is whether the
15
+ * request was cancelled by the user, killed by the clock, or refused by the
16
+ * network — so that verdict is made once, here.
17
+ *
18
+ * The handler runs INSIDE the timeout. `fetch` resolves as soon as the headers
19
+ * arrive, so a seam that returned the `Response` and cleared its own timer would
20
+ * leave the body read unbounded — a hung stream would hang forever. Passing a
21
+ * handler keeps the clock over the whole operation, which is what every copy of
22
+ * the ritual already did.
23
+ */
24
+ /** Injectable fetch — the narrow signature keeps test fakes free of Bun's extras. */
25
+ export type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
26
+ /**
27
+ * Why the request never produced a response. Deliberately only two kinds: a
28
+ * status code is not a failure of the REQUEST, and what a given status means is
29
+ * the caller's policy.
30
+ */
31
+ export declare class HttpRequestError extends Error {
32
+ readonly kind: 'aborted' | 'network';
33
+ /** The underlying cause, already rendered — callers put it in their own message. */
34
+ readonly detail: string;
35
+ readonly cause?: unknown | undefined;
36
+ constructor(kind: 'aborted' | 'network',
37
+ /** The underlying cause, already rendered — callers put it in their own message. */
38
+ detail: string, cause?: unknown | undefined);
39
+ }
40
+ export interface HttpRequestOpts {
41
+ method?: string;
42
+ headers?: Record<string, string>;
43
+ body?: string;
44
+ redirect?: 'follow' | 'error' | 'manual';
45
+ /** Wall clock over the request AND the handler. Required — no silent default. */
46
+ timeoutMs: number;
47
+ /** The caller's cancel. Its firing is what makes `userAborted()` true. */
48
+ signal?: AbortSignal;
49
+ fetchImpl?: FetchLike;
50
+ }
51
+ /** What a handler can ask about, and do to, the request it is reading. */
52
+ export interface HttpRequestControl {
53
+ /** The request's own signal, so a handler can pass it further down. */
54
+ readonly signal: AbortSignal;
55
+ /**
56
+ * Abort the in-flight request from inside the handler — a size cap hit, an
57
+ * early stop. Distinct from both a user cancel and the timeout, so a handler
58
+ * that calls this can tell its own abort apart from the other two.
59
+ */
60
+ abort(): void;
61
+ /** The CALLER cancelled. Not the timeout, not `abort()`. */
62
+ userAborted(): boolean;
63
+ /** The wall clock fired. */
64
+ timedOut(): boolean;
65
+ }
66
+ /**
67
+ * Run one bounded HTTP request and hand the response to `handle`.
68
+ *
69
+ * Throws {@link HttpRequestError} when the request itself failed. Anything
70
+ * `handle` throws propagates untouched — that is the caller's own policy talking.
71
+ */
72
+ export declare function httpRequest<T>(url: string, opts: HttpRequestOpts, handle: (response: Response, ctl: HttpRequestControl) => Promise<T>): Promise<T>;
73
+ /** A readable one-liner for an unknown thrown value. Four byte-identical copies. */
74
+ export declare function describeError(err: unknown): string;
@@ -0,0 +1,103 @@
1
+ /**
2
+ * http-request — the one bounded HTTP request in this codebase.
3
+ *
4
+ * Five modules used to hand-roll the same ~12 lines: an internal
5
+ * `AbortController`, a `setTimeout` that aborts it, a `userAborted` flag set
6
+ * from the caller's signal, and a `finally` that clears the timer and removes
7
+ * the listener. Five copies of a rule is five chances to drift, and it HAD
8
+ * drifted — `npm-version.ts` never grew the `userAborted` flag, so a user cancel
9
+ * came back as `null`, indistinguishable from a registry that is down.
10
+ *
11
+ * What is shared is the BOUNDING, not the interpretation. Each caller still owns
12
+ * its own status-code policy (DDG treats 429/403 as rate-limiting, Brave splits
13
+ * auth from rate-limit, npm treats every non-OK as "no answer") and its own error
14
+ * type, because those genuinely differ. What they cannot differ on is whether the
15
+ * request was cancelled by the user, killed by the clock, or refused by the
16
+ * network — so that verdict is made once, here.
17
+ *
18
+ * The handler runs INSIDE the timeout. `fetch` resolves as soon as the headers
19
+ * arrive, so a seam that returned the `Response` and cleared its own timer would
20
+ * leave the body read unbounded — a hung stream would hang forever. Passing a
21
+ * handler keeps the clock over the whole operation, which is what every copy of
22
+ * the ritual already did.
23
+ */
24
+ /**
25
+ * Why the request never produced a response. Deliberately only two kinds: a
26
+ * status code is not a failure of the REQUEST, and what a given status means is
27
+ * the caller's policy.
28
+ */
29
+ export class HttpRequestError extends Error {
30
+ kind;
31
+ detail;
32
+ cause;
33
+ constructor(kind,
34
+ /** The underlying cause, already rendered — callers put it in their own message. */
35
+ detail, cause) {
36
+ super(detail);
37
+ this.kind = kind;
38
+ this.detail = detail;
39
+ this.cause = cause;
40
+ this.name = 'HttpRequestError';
41
+ }
42
+ }
43
+ /**
44
+ * Run one bounded HTTP request and hand the response to `handle`.
45
+ *
46
+ * Throws {@link HttpRequestError} when the request itself failed. Anything
47
+ * `handle` throws propagates untouched — that is the caller's own policy talking.
48
+ */
49
+ export async function httpRequest(url, opts, handle) {
50
+ const fetchImpl = opts.fetchImpl ?? fetch;
51
+ const controller = new AbortController();
52
+ let userAborted = false;
53
+ let timedOut = false;
54
+ const timer = setTimeout(() => {
55
+ timedOut = true;
56
+ controller.abort();
57
+ }, opts.timeoutMs);
58
+ const onUserAbort = () => {
59
+ userAborted = true;
60
+ controller.abort();
61
+ };
62
+ if (opts.signal) {
63
+ if (opts.signal.aborted)
64
+ onUserAbort();
65
+ else
66
+ opts.signal.addEventListener('abort', onUserAbort, { once: true });
67
+ }
68
+ const ctl = {
69
+ signal: controller.signal,
70
+ abort: () => controller.abort(),
71
+ userAborted: () => userAborted,
72
+ timedOut: () => timedOut
73
+ };
74
+ try {
75
+ let response;
76
+ try {
77
+ response = await fetchImpl(url, {
78
+ ...(opts.method === undefined ? {} : { method: opts.method }),
79
+ ...(opts.headers === undefined ? {} : { headers: opts.headers }),
80
+ ...(opts.body === undefined ? {} : { body: opts.body }),
81
+ ...(opts.redirect === undefined ? {} : { redirect: opts.redirect }),
82
+ signal: controller.signal
83
+ });
84
+ }
85
+ catch (err) {
86
+ if (userAborted)
87
+ throw new HttpRequestError('aborted', 'Request aborted.', err);
88
+ throw new HttpRequestError('network', describeError(err), err);
89
+ }
90
+ return await handle(response, ctl);
91
+ }
92
+ finally {
93
+ clearTimeout(timer);
94
+ if (opts.signal)
95
+ opts.signal.removeEventListener('abort', onUserAbort);
96
+ }
97
+ }
98
+ /** A readable one-liner for an unknown thrown value. Four byte-identical copies. */
99
+ export function describeError(err) {
100
+ if (err instanceof Error)
101
+ return err.message;
102
+ return String(err);
103
+ }
@@ -10,6 +10,7 @@
10
10
  * enough to anchor the worker in current reality: the dist-tag 'latest', a
11
11
  * short list of recent versions, and the publish date of latest.
12
12
  */
13
+ import { httpRequest, HttpRequestError } from './http-request.js';
13
14
  const REGISTRY_BASE = 'https://registry.npmjs.org';
14
15
  const DEFAULT_TIMEOUT_MS = 3000;
15
16
  const RECENT_VERSIONS_LIMIT = 10;
@@ -24,50 +25,44 @@ export async function npmVersionLookup(pkg, opts = {}) {
24
25
  return null;
25
26
  const base = opts.registry ?? REGISTRY_BASE;
26
27
  const url = `${base}/${encodePackageName(pkg)}`;
27
- const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
28
- const internalController = new AbortController();
29
- const timeoutHandle = setTimeout(() => internalController.abort(), timeoutMs);
30
- const onUserAbort = () => internalController.abort();
31
- if (opts.signal) {
32
- if (opts.signal.aborted)
33
- onUserAbort();
34
- else
35
- opts.signal.addEventListener('abort', onUserAbort, { once: true });
36
- }
37
28
  try {
38
- let response;
39
- try {
40
- response = await fetch(url, {
41
- method: 'GET',
42
- headers: { accept: 'application/vnd.npm.install-v1+json, application/json' },
43
- signal: internalController.signal
44
- });
45
- }
46
- catch {
47
- return null;
48
- }
49
- if (!response.ok)
50
- return null;
51
- let body;
52
- try {
53
- body = (await response.json());
54
- }
55
- catch {
56
- return null;
57
- }
58
- const latest = body['dist-tags']?.latest;
59
- if (typeof latest !== 'string' || latest.length === 0)
60
- return null;
61
- const allVersions = Object.keys(body.versions ?? {});
62
- const recent = allVersions.slice(-RECENT_VERSIONS_LIMIT).reverse();
63
- const publishedAtRaw = body.time?.[latest];
64
- const publishedAt = typeof publishedAtRaw === 'string' ? publishedAtRaw : undefined;
65
- return { pkg, latest, recent, publishedAt };
29
+ return await httpRequest(url, {
30
+ timeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
31
+ ...(opts.signal === undefined ? {} : { signal: opts.signal }),
32
+ method: 'GET',
33
+ headers: { accept: 'application/vnd.npm.install-v1+json, application/json' }
34
+ }, async (response) => {
35
+ // npm's own status policy: a version banner is a nicety, so every
36
+ // non-OK answer is simply "no version to report".
37
+ if (!response.ok)
38
+ return null;
39
+ let body;
40
+ try {
41
+ body = (await response.json());
42
+ }
43
+ catch {
44
+ return null;
45
+ }
46
+ const latest = body['dist-tags']?.latest;
47
+ if (typeof latest !== 'string' || latest.length === 0)
48
+ return null;
49
+ const allVersions = Object.keys(body.versions ?? {});
50
+ const recent = allVersions.slice(-RECENT_VERSIONS_LIMIT).reverse();
51
+ const publishedAtRaw = body.time?.[latest];
52
+ const publishedAt = typeof publishedAtRaw === 'string' ? publishedAtRaw : undefined;
53
+ return { pkg, latest, recent, publishedAt };
54
+ });
66
55
  }
67
- finally {
68
- clearTimeout(timeoutHandle);
69
- if (opts.signal)
70
- opts.signal.removeEventListener('abort', onUserAbort);
56
+ catch (err) {
57
+ // A user cancel is re-thrown, not swallowed. This module was the copy of the
58
+ // request ritual that never grew a `userAborted` flag, so a cancelled lookup
59
+ // returned `null` — indistinguishable from a registry that is down, and the
60
+ // caller went on assembling a block for a run the user had already stopped.
61
+ if (err instanceof HttpRequestError && err.kind === 'aborted')
62
+ throw err;
63
+ if (err instanceof HttpRequestError)
64
+ return null;
65
+ throw err;
71
66
  }
72
67
  }
73
68
  /** Format an NpmVersionInfo as a short Markdown block for EXTERNAL CONTEXT. */
@@ -1,7 +1,18 @@
1
1
  import { type ContextSnapshot, type SpawnFn } from '../shared/child-process.js';
2
2
  import { type LoopHit } from '../task/loop-detector.js';
3
- /** True when a tool call retrieves content an APIS entry could be grounded in. */
4
- export declare function isGroundingRetrieval(toolName: string): boolean;
3
+ /**
4
+ * Tool calls that can GROUND an APIS claim — i.e. return content a signature or
5
+ * command could be cited from. `pi-worker-docs` (the primary), `read` and `grep`
6
+ * (project source), and the web escalations `pi-worker-search`/`pi-worker-fetch`.
7
+ *
8
+ * `ls` and `find` are deliberately EXCLUDED: they return file/directory NAMES,
9
+ * and APIS owns symbols by name only, never paths (RESEARCH_APIS_PROMPT). Bare
10
+ * enumeration cannot verify a signature, so a worker that fabricates its section
11
+ * from memory does not launder itself grounded by calling `ls` once. That
12
+ * exclusion is the anti-gaming property of any gate built on this count: "one
13
+ * trivial `ls` then fabricate the rest" leaves groundingRetrievalCount at 0.
14
+ */
15
+ export { isGroundingRetrieval } from './worker-channels.js';
5
16
  /**
6
17
  * Does this partial output carry ANSWER CONTENT, or is it the model clearing its
7
18
  * throat?
@@ -1,6 +1,7 @@
1
1
  import { getPiInvocation } from '../shared/pi-invocation.js';
2
2
  import { runChildDefault } from '../shared/child-process.js';
3
3
  import { CommandWatchdog, commandTimeoutHint, realTimerDeps } from '../shared/command-watchdog.js';
4
+ import { isGroundingRetrieval as isGrounding, workerChannel } from './worker-channels.js';
4
5
  import { childBaseArgs } from '../shared/child-extensions.js';
5
6
  import { LoopDetector } from '../task/loop-detector.js';
6
7
  import { LOOP_WINDOW, LOOP_THRESHOLD, MAX_LOOP_RESTARTS, formatLoopHint, isConnectionError, connectionRetryBackoffMs } from '../task/child-runner.js';
@@ -27,17 +28,10 @@ const DEFAULT_TOOLS = 'read,grep,find,ls';
27
28
  * exclusion is the anti-gaming property of any gate built on this count: "one
28
29
  * trivial `ls` then fabricate the rest" leaves groundingRetrievalCount at 0.
29
30
  */
30
- const GROUNDING_RETRIEVAL_TOOLS = new Set([
31
- 'pi-worker-docs',
32
- 'read',
33
- 'grep',
34
- 'pi-worker-search',
35
- 'pi-worker-fetch'
36
- ]);
37
- /** True when a tool call retrieves content an APIS entry could be grounded in. */
38
- export function isGroundingRetrieval(toolName) {
39
- return GROUNDING_RETRIEVAL_TOOLS.has(toolName);
40
- }
31
+ // The grounding set is derived from WORKER_CHANNELS (worker-channels.ts), not
32
+ // hand-kept — this was a second copy of the four tool names. Re-exported because
33
+ // several call sites and tests import it from here.
34
+ export { isGroundingRetrieval } from './worker-channels.js';
41
35
  /**
42
36
  * Hard wall-clock bound on a single research worker run (one spawn). The
43
37
  * exact-match LoopDetector only catches *identical* repeated tool calls; a model
@@ -272,7 +266,7 @@ export function commandCeilingForAttempt(baseMs, priorHangs) {
272
266
  const RESTART_RULES = [
273
267
  {
274
268
  // A loop-kill gets the same restart-with-hint treatment every other phase
275
- // already gets (runPhaseWithLoopGuard) — name the offending call so the
269
+ // already gets (runPhaseChild) — name the offending call so the
276
270
  // re-spawn avoids it. Bounded by the shared restart budget.
277
271
  reason: 'loop',
278
272
  detect: s => s.loopHit && s.restartBudgetSpent < MAX_LOOP_RESTARTS ?
@@ -329,7 +323,7 @@ const RESTART_RULES = [
329
323
  },
330
324
  {
331
325
  // A connection-class model error is restartable on the same budget, exactly
332
- // as runPhaseWithLoopGuard already treats it — a research worker had no such
326
+ // as runPhaseChild already treats it — a research worker had no such
333
327
  // retry, so one dropped fetch failed the whole task at research while the
334
328
  // identical blip in refine/compose was absorbed.
335
329
  //
@@ -429,7 +423,7 @@ export async function runWorker(input) {
429
423
  const timeoutMs = input.timeoutMs ?? RESEARCH_WORKER_TIMEOUT_MS;
430
424
  let hint = null;
431
425
  // Loop-kill and timeout share one restart budget, mirroring
432
- // runPhaseWithLoopGuard: a runaway worker gets re-spawned with a corrective
426
+ // runPhaseChild: a runaway worker gets re-spawned with a corrective
433
427
  // hint up to MAX_LOOP_RESTARTS times before we give up. Leaked tool calls
434
428
  // keep their own MAX_LEAK_RETRIES budget below — a different failure mode.
435
429
  let restartBudgetSpent = 0;
@@ -522,12 +516,13 @@ export async function runWorker(input) {
522
516
  // A tool call is the worker working. Inert unless the
523
517
  // caller opted into a progress-based deadline.
524
518
  timeout.progress();
519
+ // The generic child runner used to name ONE tool and ONE of
520
+ // its parameters here. It asks the tool's own row now.
525
521
  if (input.fanoutTimeout
526
- && call.name === 'pi-worker-docs'
527
- && call.args?.module === '.') {
522
+ && workerChannel(call.name)?.isProjectSourceLookup?.(call.args ?? {}) === true) {
528
523
  timeout.extend(input.fanoutTimeout.perLookupMs, input.fanoutTimeout.ceilingMs);
529
524
  }
530
- if (isGroundingRetrieval(call.name))
525
+ if (isGrounding(call.name))
531
526
  groundingRetrievalCount++;
532
527
  if (!loopDetector)
533
528
  return null;
@@ -67,7 +67,7 @@ export declare function registerPiWorkerDocs(pi: ExtensionAPI, internals?: PiWor
67
67
  * exists to prevent: four regexes matching three phrasings, documented at length in
68
68
  * abstention.ts, which cost a real bug.
69
69
  */
70
- export declare function docsCacheable(d: Pick<DocsDetails, 'childExitCode' | 'typeOnly' | 'excerptVerified'>, text: string): boolean;
70
+ export declare function docsCacheable(d: Pick<DocsDetails, 'typeOnly' | 'excerptVerified'>, text: string): boolean;
71
71
  /** The docs cache key: a package's answer is per (module, question). A project-source
72
72
  * `.` lookup is never cached — the working tree mutates as tasks implement. */
73
73
  export declare function docsCacheKey(params: {
@@ -7,7 +7,7 @@ import { formatResultText } from '../shared/child-output.js';
7
7
  import { docsRaw, packageHeader, buildPrompt, buildVersionBanner } from './docs-core.js';
8
8
  import { formatNpmVersionSection } from './npm-version.js';
9
9
  import { runFocusedExtraction } from './focused-extractor.js';
10
- import { makeWorkerTool } from './shared.js';
10
+ import { childFailureReason, makeWorkerTool, workerAnswer, workerUnavailable } from './shared.js';
11
11
  import { isTypeOnlyAnswer } from '../task/type-only-answer.js';
12
12
  import { logDocsAnswer } from './typeonly-log.js';
13
13
  import { normalizeQuery } from './research-cache.js';
@@ -61,18 +61,18 @@ function pinDetails(pin) {
61
61
  * The paths differ only in `prefix`: the npm path leads every result, failures included, with
62
62
  * its version banner and npm-version header; the project path has neither.
63
63
  *
64
- * `childExitCode` is recorded but NOT as 0, which is what keeps a failure out of the research
65
- * cache (see `cacheable` below).
64
+ * It says UNAVAILABLE, so the cache cannot take it. It used to say so by writing a
65
+ * non-zero `childExitCode` and letting `docsCacheable` re-derive the verdict — a
66
+ * derivation that failed on the case it mattered most for: a signal-killed child
67
+ * reports exit code 0, so `"Docs lookup aborted."` was cached for the whole run.
68
+ * `aborted` was written here and read by nothing, which is what let that hide.
66
69
  */
67
70
  function docsFailureResult(extraction, baseDetails, prefix) {
68
- return {
69
- text: prefix + extraction.failure,
70
- details: {
71
- ...baseDetails,
72
- ...(extraction.aborted ? { aborted: true } : {}),
73
- childExitCode: extraction.exitCode
74
- }
75
- };
71
+ return workerUnavailable(prefix + extraction.failure, {
72
+ ...baseDetails,
73
+ ...(extraction.aborted ? { aborted: true } : {}),
74
+ childExitCode: extraction.exitCode
75
+ }, childFailureReason({ exitCode: extraction.exitCode, aborted: extraction.aborted }));
76
76
  }
77
77
  export function registerPiWorkerDocs(pi, internals = {}) {
78
78
  // CAP arm of nexttask 5B — OFF unless PI_TASK_PROJECT_DOCS_BUDGET is set, and
@@ -145,10 +145,7 @@ export function registerPiWorkerDocs(pi, internals = {}) {
145
145
  if (budget !== null && ++projectLookups > budget) {
146
146
  // Refused BEFORE any work: the point of the cap is the child
147
147
  // spawn and the model pass this branch would otherwise run.
148
- return {
149
- text: projectDocsBudgetExhausted(budget),
150
- details: { budgetSpent: true }
151
- };
148
+ return workerUnavailable(projectDocsBudgetExhausted(budget), { budgetSpent: true }, 'budget-spent');
152
149
  }
153
150
  const openCache = internals.openCache ?? defaultOpenCache;
154
151
  let cache;
@@ -160,24 +157,19 @@ export function registerPiWorkerDocs(pi, internals = {}) {
160
157
  cacheError = err instanceof Error ? err.message : String(err);
161
158
  }
162
159
  if (!cache) {
163
- return {
164
- text: `Project docs unavailable: cache open failed (${cacheError}).`,
165
- details: {}
166
- };
160
+ return workerUnavailable(`Project docs unavailable: cache open failed (${cacheError}).`, {}, 'cache-open-failed');
167
161
  }
168
162
  const retrieveChunks = internals.retrieveChunks ?? defaultRetrieveChunks;
169
163
  const projectResult = projectDocsRaw(cache, ctx.cwd, params.query, retrieveChunks);
170
164
  if (projectResult.kind === 'error') {
171
- return { text: `Project docs error: ${projectResult.message}`, details: {} };
165
+ return workerUnavailable(`Project docs error: ${projectResult.message}`, {}, 'project-docs-error');
172
166
  }
173
167
  if (projectResult.kind === 'no_chunks') {
174
- return {
175
- text: `Project "${projectResult.projectName}" has no .ts/.tsx files indexed.`,
176
- details: {
177
- hitCache: projectResult.hitCache,
178
- indexedFiles: projectResult.filesIngested
179
- }
180
- };
168
+ // The project IS indexed and has nothing — a real answer.
169
+ return workerAnswer(`Project "${projectResult.projectName}" has no .ts/.tsx files indexed.`, {
170
+ hitCache: projectResult.hitCache,
171
+ indexedFiles: projectResult.filesIngested
172
+ });
181
173
  }
182
174
  const { projectName, chunks, hitCache, filesIngested, indexingMs } = projectResult;
183
175
  const baseDetails = {
@@ -214,14 +206,10 @@ export function registerPiWorkerDocs(pi, internals = {}) {
214
206
  excerptCheck: extraction.excerptCheck,
215
207
  toolText: text
216
208
  });
217
- return {
218
- text,
219
- details: {
220
- ...baseDetails,
221
- childExitCode: 0,
222
- excerptVerified: verified
223
- }
224
- };
209
+ return workerAnswer(text, {
210
+ ...baseDetails,
211
+ excerptVerified: verified
212
+ });
225
213
  }
226
214
  // ── npm package lookup (existing path) ──────────────────────────
227
215
  const rawResult = await docsRaw({
@@ -253,31 +241,27 @@ export function registerPiWorkerDocs(pi, internals = {}) {
253
241
  autoInstalled: rawResult.autoInstalled,
254
242
  ...npmDetails
255
243
  };
256
- return { text: npmHeader + rawResult.message, details };
244
+ return workerUnavailable(npmHeader + rawResult.message, details, 'docs-error');
257
245
  }
258
246
  if (rawResult.kind === 'not_installed') {
259
- return {
260
- text: npmHeader
261
- + `Package "${rawResult.pkg}" is not installed and auto-install failed.`,
262
- details: { resolveError: 'not_installed', ...npmDetails }
263
- };
247
+ return workerUnavailable(npmHeader
248
+ + `Package "${rawResult.pkg}" is not installed and auto-install failed.`, { resolveError: 'not_installed', ...npmDetails }, 'not-installed');
264
249
  }
265
250
  if (rawResult.kind === 'no_chunks') {
266
251
  const banner = buildVersionBanner(rawResult.autoInstallPin, rawResult.pkg.name, rawResult.pkg.version, ctx.cwd);
267
- return {
268
- text: banner
269
- + npmHeader
270
- + `Package ${rawResult.pkg.name}@${rawResult.pkg.version} has no .d.ts files or README. Use pi-worker to read source directly.`,
271
- details: {
272
- version: rawResult.pkg.version,
273
- hitCache: rawResult.hitCache,
274
- indexedFiles: rawResult.indexedFiles ?? 0,
275
- cacheError: rawResult.cacheError,
276
- autoInstalled: rawResult.autoInstalled,
277
- ...pinDetails(rawResult.autoInstallPin),
278
- ...npmDetails
279
- }
280
- };
252
+ // The package resolved and genuinely ships nothing to read — an
253
+ // answer, and a stable one for this run.
254
+ return workerAnswer(banner
255
+ + npmHeader
256
+ + `Package ${rawResult.pkg.name}@${rawResult.pkg.version} has no .d.ts files or README. Use pi-worker to read source directly.`, {
257
+ version: rawResult.pkg.version,
258
+ hitCache: rawResult.hitCache,
259
+ indexedFiles: rawResult.indexedFiles ?? 0,
260
+ cacheError: rawResult.cacheError,
261
+ autoInstalled: rawResult.autoInstalled,
262
+ ...pinDetails(rawResult.autoInstallPin),
263
+ ...npmDetails
264
+ });
281
265
  }
282
266
  // kind === 'ok'
283
267
  const { pkg, chunks, hitCache, indexingMs, cacheError, autoInstalled } = rawResult;
@@ -352,15 +336,11 @@ export function registerPiWorkerDocs(pi, internals = {}) {
352
336
  excerptCheck: extraction.excerptCheck,
353
337
  toolText: text
354
338
  });
355
- return {
356
- text,
357
- details: {
358
- ...baseDetails,
359
- childExitCode: 0,
360
- excerptVerified: verified,
361
- ...(typeOnly.typeOnly ? { typeOnly: true } : {})
362
- }
363
- };
339
+ return workerAnswer(text, {
340
+ ...baseDetails,
341
+ excerptVerified: verified,
342
+ ...(typeOnly.typeOnly ? { typeOnly: true } : {})
343
+ });
364
344
  },
365
345
  renderCall(args, theme) {
366
346
  const query = args.query.replace(/\s+/g, ' ').trim();
@@ -410,10 +390,11 @@ export function registerPiWorkerDocs(pi, internals = {}) {
410
390
  * abstention.ts, which cost a real bug.
411
391
  */
412
392
  export function docsCacheable(d, text) {
413
- return (d.childExitCode === 0
414
- && d.typeOnly !== true
415
- && d.excerptVerified !== false
416
- && !isAbstention(text));
393
+ // Answer QUALITY only. Whether there IS an answer is `WorkerOutcome.kind`, and
394
+ // `makeWorkerTool` has already refused an `unavailable` before reaching here —
395
+ // this used to open with `childExitCode === 0`, which a signal-killed child
396
+ // satisfies, so an aborted lookup was memoised for the run.
397
+ return d.typeOnly !== true && d.excerptVerified !== false && !isAbstention(text);
417
398
  }
418
399
  /** The docs cache key: a package's answer is per (module, question). A project-source
419
400
  * `.` lookup is never cached — the working tree mutates as tasks implement. */
@@ -28,7 +28,7 @@ export declare function registerPiWorkerFetch(pi: ExtensionAPI, internals?: PiWo
28
28
  * `docsCacheable`: pi-worker-fetch.test.ts carried a hand-retyped copy driving four
29
29
  * tests, which a change to the shipped rule would leave green.
30
30
  */
31
- export declare function fetchCacheable(d: Pick<FetchDetails, 'childExitCode'>, text: string): boolean;
31
+ export declare function fetchCacheable(_d: Pick<FetchDetails, never>, text: string): boolean;
32
32
  /** The fetch cache key. URL verbatim (path case can matter), question normalised —
33
33
  * same page, different question is a different answer. */
34
34
  export declare function fetchCacheKey(params: {