@mjasnikovs/pi-task 0.38.15 → 0.38.17

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 (86) hide show
  1. package/dist/config/config.d.ts +26 -0
  2. package/dist/config/config.js +68 -17
  3. package/dist/shared/child-process.js +9 -16
  4. package/dist/task/accept-debt.d.ts +7 -5
  5. package/dist/task/accept-debt.js +18 -14
  6. package/dist/task/artifact-closure.js +18 -63
  7. package/dist/task/auto-orchestrator.js +211 -218
  8. package/dist/task/autofix-ledger.d.ts +113 -0
  9. package/dist/task/autofix-ledger.js +152 -0
  10. package/dist/task/boot-probe.d.ts +109 -1
  11. package/dist/task/boot-probe.js +139 -23
  12. package/dist/task/child-runner.d.ts +50 -6
  13. package/dist/task/child-runner.js +48 -69
  14. package/dist/task/command-run.d.ts +49 -6
  15. package/dist/task/command-run.js +154 -18
  16. package/dist/task/coverage-loop.d.ts +11 -0
  17. package/dist/task/coverage-loop.js +16 -0
  18. package/dist/task/external-context.d.ts +9 -12
  19. package/dist/task/external-context.js +5 -5
  20. package/dist/task/failure-classifier.d.ts +9 -1
  21. package/dist/task/failure-classifier.js +9 -0
  22. package/dist/task/final-gate-fix.d.ts +22 -26
  23. package/dist/task/final-gate-fix.js +16 -31
  24. package/dist/task/final-gate.d.ts +10 -2
  25. package/dist/task/final-gate.js +55 -89
  26. package/dist/task/fix-child.d.ts +64 -0
  27. package/dist/task/fix-child.js +66 -0
  28. package/dist/task/gate-deps.js +20 -13
  29. package/dist/task/lint-fix.d.ts +7 -0
  30. package/dist/task/lint-fix.js +45 -9
  31. package/dist/task/orchestrator.d.ts +33 -24
  32. package/dist/task/orchestrator.js +75 -46
  33. package/dist/task/phases.d.ts +120 -34
  34. package/dist/task/phases.js +221 -134
  35. package/dist/task/plan-orchestrator.js +2 -2
  36. package/dist/task/plan-rounds.d.ts +86 -0
  37. package/dist/task/plan-rounds.js +105 -0
  38. package/dist/task/plan-session.d.ts +31 -21
  39. package/dist/task/plan-session.js +97 -120
  40. package/dist/task/qa-transcript.d.ts +100 -0
  41. package/dist/task/qa-transcript.js +99 -0
  42. package/dist/task/question-source.d.ts +117 -0
  43. package/dist/task/question-source.js +174 -0
  44. package/dist/task/repo-health-check.d.ts +21 -21
  45. package/dist/task/repo-health-check.js +43 -112
  46. package/dist/task/run-end.d.ts +77 -0
  47. package/dist/task/run-end.js +37 -0
  48. package/dist/task/run-final-gate.js +71 -79
  49. package/dist/task/serve-entry.js +6 -57
  50. package/dist/task/shipped-source.d.ts +67 -0
  51. package/dist/task/shipped-source.js +144 -0
  52. package/dist/task/task-gates.d.ts +9 -1
  53. package/dist/task/task-gates.js +27 -6
  54. package/dist/task/terminal-outcome.d.ts +1 -1
  55. package/dist/task/terminal-outcome.js +12 -0
  56. package/dist/task/verify-work.d.ts +46 -0
  57. package/dist/task/verify-work.js +51 -3
  58. package/dist/workers/brave-search.d.ts +7 -0
  59. package/dist/workers/brave-search.js +36 -55
  60. package/dist/workers/ddg-search.d.ts +1 -1
  61. package/dist/workers/ddg-search.js +27 -47
  62. package/dist/workers/docs-core.d.ts +71 -1
  63. package/dist/workers/docs-core.js +131 -71
  64. package/dist/workers/exa-search.d.ts +2 -2
  65. package/dist/workers/exa-search.js +53 -68
  66. package/dist/workers/html-clean.js +67 -88
  67. package/dist/workers/http-request.d.ts +74 -0
  68. package/dist/workers/http-request.js +103 -0
  69. package/dist/workers/npm-version.js +37 -42
  70. package/dist/workers/pi-worker-core.d.ts +13 -2
  71. package/dist/workers/pi-worker-core.js +35 -25
  72. package/dist/workers/pi-worker-docs.d.ts +1 -1
  73. package/dist/workers/pi-worker-docs.js +49 -68
  74. package/dist/workers/pi-worker-fetch.d.ts +1 -1
  75. package/dist/workers/pi-worker-fetch.js +20 -21
  76. package/dist/workers/pi-worker-search.js +6 -4
  77. package/dist/workers/pi-worker.js +5 -4
  78. package/dist/workers/search-core.d.ts +1 -1
  79. package/dist/workers/search-core.js +36 -42
  80. package/dist/workers/search-types.d.ts +13 -0
  81. package/dist/workers/search-types.js +27 -0
  82. package/dist/workers/shared.d.ts +51 -11
  83. package/dist/workers/shared.js +0 -0
  84. package/dist/workers/worker-channels.d.ts +60 -0
  85. package/dist/workers/worker-channels.js +98 -0
  86. package/package.json +1 -1
@@ -1,3 +1,4 @@
1
+ import { verifyFailClass } from './verify-work.js';
1
2
  import { resolutionOptions, classifyResolutionAnswer } from './verify-resolution.js';
2
3
  import { SessionUI } from '../remote/bridge.js';
3
4
  import { isYoloMode, yoloVerifyResolution, YOLO_STAMP } from './yolo.js';
@@ -121,7 +122,8 @@ export async function resolveVerifyGate(ctxIn, deps, p, rec, routeRootCause) {
121
122
  // bounded fix attempt before the picker — smallest tool first. Applied →
122
123
  // re-verify and re-enter the loop on the fresh verdict; not applied (guard
123
124
  // trip, no convergence) → fall through to the ordinary picker unchanged.
124
- if (!lintFixAttempted && deps.lintFix && failReason.startsWith('repo health:')) {
125
+ const failClass = verifyFailClass(verified);
126
+ if (!lintFixAttempted && deps.lintFix && failClass === 'repo-health') {
125
127
  lintFixAttempted = true;
126
128
  active.ui.notify(`${p.tag}: static findings on "${p.title}" — attempting bounded lint fix…`, 'info');
127
129
  const fix = await deps.lintFix(active, p.cwd, p.title, p.taskId, failReason);
@@ -149,7 +151,7 @@ export async function resolveVerifyGate(ctxIn, deps, p, rec, routeRootCause) {
149
151
  // records the (already-recorded) defect as the human's call. Applies
150
152
  // only while the FAIL is still the repo-health one the contradiction
151
153
  // explains — a later, different FAIL gets the ordinary resolution path.
152
- const isFrozenBlocked = frozenContradiction !== null && failReason.startsWith('repo health:');
154
+ const isFrozenBlocked = frozenContradiction !== null && failClass === 'repo-health';
153
155
  const recOutcome = isUnobserved ? { recommend: 'autofix', rationale: failReason }
154
156
  : isFrozenBlocked ?
155
157
  {
@@ -319,12 +321,31 @@ export async function resolveVerifyGate(ctxIn, deps, p, rec, routeRootCause) {
319
321
  fixInstruction
320
322
  });
321
323
  active = fixRes.ctx ?? active;
322
- if (fixRes.sessionCancelled)
324
+ // The re-run's ending, mapped to this loop's own terminal kinds. A
325
+ // CANCEL lands on `interrupted` — the user stopped it, so the task is
326
+ // left resumable rather than reported as a fault.
327
+ if (fixRes.end.kind === 'no-session') {
323
328
  return { stop: { kind: 'session-cancelled', ctx: active } };
324
- if (fixRes.interrupted)
329
+ }
330
+ // A CANCEL and an ESC-interrupt are NOT the same ending. Folding them
331
+ // together sent a cancelled re-run down the `interrupted` row, which
332
+ // demotes the task file — writing `failed` over the `cancelled` the
333
+ // cancel itself wrote.
334
+ if (fixRes.end.kind === 'cancelled') {
335
+ return { stop: { kind: 'cancelled', ctx: active } };
336
+ }
337
+ if (fixRes.end.kind === 'interrupted') {
325
338
  return { stop: { kind: 'interrupted', ctx: active } };
326
- if (!fixRes.ok)
327
- return { stop: { kind: 'failed', ctx: active, reason: fixRes.reason } };
339
+ }
340
+ if (fixRes.end.kind === 'failed') {
341
+ return {
342
+ stop: {
343
+ kind: 'failed',
344
+ ctx: active,
345
+ ...(fixRes.end.reason === undefined ? {} : { reason: fixRes.end.reason })
346
+ }
347
+ };
348
+ }
328
349
  // Resume reuses the same inner task id, so p.taskId is stable.
329
350
  verified = await deps.verify(active, p.cwd, p.title, p.taskId);
330
351
  await rec(verdictLine(verified));
@@ -25,7 +25,7 @@
25
25
  * overrides, which is not a simplification.
26
26
  */
27
27
  /** The gate outcomes a command has to act on. Mirrors runGatesForTask's union. */
28
- export type TerminalOutcomeKind = 'done' | 'paused' | 'session-cancelled' | 'interrupted' | 'failed';
28
+ export type TerminalOutcomeKind = 'done' | 'paused' | 'session-cancelled' | 'cancelled' | 'interrupted' | 'failed';
29
29
  /** What the message needs to name. */
30
30
  export interface TerminalMessageContext {
31
31
  /** The task or run id shown to the user (`TASK_0007`, `AUTO_0002`, `Task`). */
@@ -59,6 +59,18 @@ export const TERMINAL_OUTCOMES = {
59
59
  message: c => `${c.tag} paused — could not start a session for autofix. `
60
60
  + `Run ${c.resumeCmd} to retry.`
61
61
  },
62
+ cancelled: {
63
+ // The USER stopped the re-run. The task file already says `cancelled`, and
64
+ // `markResumable` writes `failed` — that both lies in the ledger and turns
65
+ // a deliberate stop into a red error. RUN_END_POLICY states this for the
66
+ // first implementation run; this row states the same thing for a re-run.
67
+ markResumable: false,
68
+ failParent: false,
69
+ level: 'warning',
70
+ // `cancelled` is already in RESUMABLE_STATES, so the file needs no demotion
71
+ // AND the resume works — naming it costs nothing and is true.
72
+ message: c => `${c.tag} cancelled${c.at} — resume with ${c.resumeCmd}.`
73
+ },
62
74
  interrupted: {
63
75
  markResumable: true,
64
76
  // NOT a failure: the user stopped it. The parent run stays in_progress so
@@ -34,7 +34,53 @@ export interface VerifyOutcome {
34
34
  * can record each as a durable debt if the user ACCEPTs anyway — the deletion
35
35
  * then ships in the next commit and the final gate must re-check it. */
36
36
  crossTaskDeletions?: CrossTaskDeletion[];
37
+ /**
38
+ * WHICH KIND of FAIL this is, as data. Only meaningful when ok === false.
39
+ *
40
+ * `unobserved` was already carried as a typed field and read as one. Its
41
+ * siblings were not: the repo-health class travelled only as the `repo health:`
42
+ * PREFIX of `reason`, and three independent production sites recovered it by
43
+ * re-typing that literal with two different matchers — the graduated lint-fix
44
+ * gate, the frozen-blocked contradiction test, and the ONE auto-closing debt
45
+ * class. A reword of the mint disabled all three, with no compile error and a
46
+ * green suite. This is the `observedFailures` finding one altitude down: the
47
+ * outcome CLASS never travelled with the failure TEXT, so every classifier
48
+ * downstream had to guess.
49
+ */
50
+ failClass?: VerifyFailClass;
37
51
  }
52
+ /**
53
+ * The kinds of verify FAIL. A new member is a compile error until it declares a
54
+ * display prefix below.
55
+ *
56
+ * `static-checks` is the RUN-level twin of `repo-health`: `final-gate.ts` mints
57
+ * `static checks: …` for the same concept at the other altitude, which is why
58
+ * `isStaticClassDebt` was structurally blind to every run-level static failure
59
+ * that reached the ledger.
60
+ */
61
+ export type VerifyFailClass = 'repo-health' | 'static-checks' | 'unobserved' | 'model-verdict' | 'harness-fault';
62
+ /**
63
+ * The prefix each class MINTS, stated once.
64
+ *
65
+ * These strings are byte-frozen: the debt ledger stores `reason` verbatim, so a
66
+ * reword would orphan every debt already on disk. The registry exists so minting
67
+ * and matching cannot drift apart, not to make the wording editable.
68
+ */
69
+ export declare const VERIFY_FAIL_PREFIX: Record<VerifyFailClass, string>;
70
+ /**
71
+ * The class of a FAIL — from the typed field when it is there, else from the
72
+ * prefix the registry above owns.
73
+ *
74
+ * The prefix test survives in exactly ONE place instead of three. It has to
75
+ * survive somewhere: `GateDeps.verify` is a seam, a debt read back off disk is a
76
+ * bare string with no outcome attached, and the run-level gate mints its own
77
+ * `static checks:` line through a different path entirely.
78
+ */
79
+ export declare function verifyFailClass(o: Pick<VerifyOutcome, 'failClass' | 'reason'>): VerifyFailClass | undefined;
80
+ /** The class a recorded reason STRING belongs to, by its minted prefix. */
81
+ export declare function failClassOfReason(reason: string): VerifyFailClass | undefined;
82
+ /** Does this class name a deterministic whole-repo static check, at either altitude? */
83
+ export declare function isStaticClass(cls: VerifyFailClass | undefined): boolean;
38
84
  /**
39
85
  * Slice the delivered spec (GOAL / CONSTRAINTS / ACCEPTANCE / VERIFY) out of a
40
86
  * task file body. The composed spec lives under a `## spec` header and runs until
@@ -87,6 +87,47 @@ import { crossTaskDeletionVerifyFindings } from './task-provenance.js';
87
87
  * enforce pass had to drop `write` to stop.
88
88
  */
89
89
  const VERIFY_TOOLS = 'read,bash';
90
+ /**
91
+ * The prefix each class MINTS, stated once.
92
+ *
93
+ * These strings are byte-frozen: the debt ledger stores `reason` verbatim, so a
94
+ * reword would orphan every debt already on disk. The registry exists so minting
95
+ * and matching cannot drift apart, not to make the wording editable.
96
+ */
97
+ export const VERIFY_FAIL_PREFIX = {
98
+ 'repo-health': 'repo health:',
99
+ 'static-checks': 'static checks:',
100
+ unobserved: 'work unobserved:',
101
+ 'model-verdict': 'work did not verify:',
102
+ 'harness-fault': 'verification pass could not run:'
103
+ };
104
+ /**
105
+ * The class of a FAIL — from the typed field when it is there, else from the
106
+ * prefix the registry above owns.
107
+ *
108
+ * The prefix test survives in exactly ONE place instead of three. It has to
109
+ * survive somewhere: `GateDeps.verify` is a seam, a debt read back off disk is a
110
+ * bare string with no outcome attached, and the run-level gate mints its own
111
+ * `static checks:` line through a different path entirely.
112
+ */
113
+ export function verifyFailClass(o) {
114
+ if (o.failClass)
115
+ return o.failClass;
116
+ return failClassOfReason(o.reason ?? '');
117
+ }
118
+ /** The class a recorded reason STRING belongs to, by its minted prefix. */
119
+ export function failClassOfReason(reason) {
120
+ const head = reason.trimStart().toLowerCase();
121
+ for (const [cls, prefix] of Object.entries(VERIFY_FAIL_PREFIX)) {
122
+ if (head.startsWith(prefix.toLowerCase()))
123
+ return cls;
124
+ }
125
+ return undefined;
126
+ }
127
+ /** Does this class name a deterministic whole-repo static check, at either altitude? */
128
+ export function isStaticClass(cls) {
129
+ return cls === 'repo-health' || cls === 'static-checks';
130
+ }
90
131
  /**
91
132
  * Slice the delivered spec (GOAL / CONSTRAINTS / ACCEPTANCE / VERIFY) out of a
92
133
  * task file body. The composed spec lives under a `## spec` header and runs until
@@ -762,8 +803,9 @@ export async function runWorkVerification(deps) {
762
803
  if (deps.repoHealth) {
763
804
  stage('repo health');
764
805
  const h = await deps.repoHealth();
765
- if (!h.ok)
766
- return { ok: false, reason: `repo health: ${h.reason}` };
806
+ if (!h.ok) {
807
+ return { ok: false, failClass: 'repo-health', reason: `repo health: ${h.reason}` };
808
+ }
767
809
  }
768
810
  if (!deps.spec || deps.spec.trim().length === 0) {
769
811
  return { ok: true, reason: 'no spec to verify' };
@@ -818,7 +860,11 @@ export async function runWorkVerification(deps) {
818
860
  if (err instanceof Error && err.message === USER_CANCELLED)
819
861
  throw err;
820
862
  const msg = err instanceof Error ? err.message : String(err);
821
- return { ok: false, reason: `verification pass could not run: ${msg}` };
863
+ return {
864
+ ok: false,
865
+ failClass: 'harness-fault',
866
+ reason: `${VERIFY_FAIL_PREFIX['harness-fault']} ${msg}`
867
+ };
822
868
  }
823
869
  // Capture the environment facts the child shared — regardless of verdict
824
870
  // (a FAIL run's discoveries are just as reusable).
@@ -860,12 +906,14 @@ export async function runWorkVerification(deps) {
860
906
  return {
861
907
  ok: false,
862
908
  unobserved: true,
909
+ failClass: 'unobserved',
863
910
  reason: `work unobserved: ${verdict.detail}`,
864
911
  ...deletions
865
912
  };
866
913
  }
867
914
  return {
868
915
  ok: false,
916
+ failClass: 'model-verdict',
869
917
  reason: `work did not verify: ${verdict.detail}${verdict.detail === 'no verdict emitted' ? ' (after verify retry)' : ''}`,
870
918
  ...deletions
871
919
  };
@@ -1,3 +1,4 @@
1
+ import { type FetchLike } from './http-request.js';
1
2
  import type { SearchResult } from './search-types.js';
2
3
  export type BraveResult = SearchResult;
3
4
  export interface BraveSearchOpts {
@@ -5,6 +6,12 @@ export interface BraveSearchOpts {
5
6
  count?: number;
6
7
  timeoutMs?: number;
7
8
  signal?: AbortSignal;
9
+ /**
10
+ * Injectable fetch. Brave was the ONE provider without this: exa and ddg both
11
+ * took a `fetchImpl`, so brave's status ladder — the widest of the three — was
12
+ * the only one no test could drive at the request level.
13
+ */
14
+ fetchImpl?: FetchLike;
8
15
  }
9
16
  export declare class BraveSearchError extends Error {
10
17
  readonly kind: 'auth' | 'rate-limit' | 'http' | 'network' | 'aborted';
@@ -1,3 +1,4 @@
1
+ import { httpRequest, HttpRequestError } from './http-request.js';
1
2
  const BRAVE_ENDPOINT = 'https://api.search.brave.com/res/v1/web/search';
2
3
  const DEFAULT_COUNT = 10;
3
4
  const MAX_COUNT = 20;
@@ -14,64 +15,44 @@ export class BraveSearchError extends Error {
14
15
  }
15
16
  export async function braveSearch(query, opts) {
16
17
  const count = Math.max(1, Math.min(MAX_COUNT, opts.count ?? DEFAULT_COUNT));
17
- const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
18
18
  const url = `${BRAVE_ENDPOINT}?q=${encodeURIComponent(query)}&count=${count}`;
19
- const internalController = new AbortController();
20
- let userAborted = false;
21
- const timeoutHandle = setTimeout(() => internalController.abort(), timeoutMs);
22
- const onUserAbort = () => {
23
- userAborted = true;
24
- internalController.abort();
25
- };
26
- if (opts.signal) {
27
- if (opts.signal.aborted)
28
- onUserAbort();
29
- else
30
- opts.signal.addEventListener('abort', onUserAbort, { once: true });
31
- }
32
19
  try {
33
- let response;
34
- try {
35
- response = await fetch(url, {
36
- method: 'GET',
37
- headers: {
38
- accept: 'application/json',
39
- 'x-subscription-token': opts.apiKey
40
- },
41
- signal: internalController.signal
42
- });
43
- }
44
- catch (err) {
45
- if (userAborted) {
46
- throw new BraveSearchError('Search aborted.', 'aborted');
20
+ return await httpRequest(url, {
21
+ timeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
22
+ ...(opts.signal === undefined ? {} : { signal: opts.signal }),
23
+ ...(opts.fetchImpl === undefined ? {} : { fetchImpl: opts.fetchImpl }),
24
+ method: 'GET',
25
+ headers: {
26
+ accept: 'application/json',
27
+ 'x-subscription-token': opts.apiKey
47
28
  }
48
- throw new BraveSearchError(`Brave Search request failed: ${describeError(err)}`, 'network');
49
- }
50
- if (response.status === 401 || response.status === 403) {
51
- throw new BraveSearchError(`Brave Search rejected the key (HTTP ${response.status}). Check BRAVE_SEARCH_API_KEY.`, 'auth', response.status);
52
- }
53
- if (response.status === 429) {
54
- throw new BraveSearchError('Brave Search rate limit hit (HTTP 429). Try again in a moment.', 'rate-limit', 429);
55
- }
56
- if (!response.ok) {
57
- throw new BraveSearchError(`Brave Search HTTP ${response.status} ${response.statusText}`, 'http', response.status);
58
- }
59
- const body = (await response.json());
60
- const rawResults = body.web?.results ?? [];
61
- return rawResults
62
- .filter((r) => typeof r.title === 'string'
63
- && typeof r.url === 'string'
64
- && typeof r.description === 'string')
65
- .map(r => ({ title: r.title, url: r.url, description: r.description }));
29
+ }, async (response) => {
30
+ // Brave's own status policy: a rejected key and a rate limit are
31
+ // different problems for the user, and neither is a plain HTTP fault.
32
+ if (response.status === 401 || response.status === 403) {
33
+ throw new BraveSearchError(`Brave Search rejected the key (HTTP ${response.status}). Check BRAVE_SEARCH_API_KEY.`, 'auth', response.status);
34
+ }
35
+ if (response.status === 429) {
36
+ throw new BraveSearchError('Brave Search rate limit hit (HTTP 429). Try again in a moment.', 'rate-limit', 429);
37
+ }
38
+ if (!response.ok) {
39
+ throw new BraveSearchError(`Brave Search HTTP ${response.status} ${response.statusText}`, 'http', response.status);
40
+ }
41
+ const body = (await response.json());
42
+ const rawResults = body.web?.results ?? [];
43
+ return rawResults
44
+ .filter((r) => typeof r.title === 'string'
45
+ && typeof r.url === 'string'
46
+ && typeof r.description === 'string')
47
+ .map(r => ({ title: r.title, url: r.url, description: r.description }));
48
+ });
66
49
  }
67
- finally {
68
- clearTimeout(timeoutHandle);
69
- if (opts.signal)
70
- opts.signal.removeEventListener('abort', onUserAbort);
50
+ catch (err) {
51
+ if (err instanceof HttpRequestError) {
52
+ throw err.kind === 'aborted' ?
53
+ new BraveSearchError('Search aborted.', 'aborted')
54
+ : new BraveSearchError(`Brave Search request failed: ${err.detail}`, 'network');
55
+ }
56
+ throw err;
71
57
  }
72
58
  }
73
- function describeError(err) {
74
- if (err instanceof Error)
75
- return err.message;
76
- return String(err);
77
- }
@@ -7,7 +7,7 @@
7
7
  * get the destination URL. Ad rows redirect through duckduckgo.com itself and
8
8
  * are dropped.
9
9
  */
10
- import type { FetchLike } from './exa-search.js';
10
+ import { type FetchLike } from './http-request.js';
11
11
  import type { SearchResult } from './search-types.js';
12
12
  export interface DdgSearchOpts {
13
13
  count?: number;
@@ -8,6 +8,7 @@
8
8
  * are dropped.
9
9
  */
10
10
  import { parseHTML } from 'linkedom';
11
+ import { httpRequest, HttpRequestError } from './http-request.js';
11
12
  const DDG_ENDPOINT = 'https://html.duckduckgo.com/html/';
12
13
  const DEFAULT_COUNT = 10;
13
14
  const MAX_COUNT = 20;
@@ -26,51 +27,35 @@ export class DdgSearchError extends Error {
26
27
  }
27
28
  export async function ddgSearch(query, opts = {}) {
28
29
  const count = Math.max(1, Math.min(MAX_COUNT, opts.count ?? DEFAULT_COUNT));
29
- const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
30
- const fetchImpl = opts.fetchImpl ?? fetch;
31
30
  const url = `${DDG_ENDPOINT}?q=${encodeURIComponent(query)}`;
32
- const internalController = new AbortController();
33
- let userAborted = false;
34
- const timeoutHandle = setTimeout(() => internalController.abort(), timeoutMs);
35
- const onUserAbort = () => {
36
- userAborted = true;
37
- internalController.abort();
38
- };
39
- if (opts.signal) {
40
- if (opts.signal.aborted)
41
- onUserAbort();
42
- else
43
- opts.signal.addEventListener('abort', onUserAbort, { once: true });
44
- }
45
31
  try {
46
- let response;
47
- try {
48
- response = await fetchImpl(url, {
49
- method: 'GET',
50
- headers: {
51
- 'user-agent': USER_AGENT,
52
- accept: 'text/html'
53
- },
54
- signal: internalController.signal
55
- });
56
- }
57
- catch (err) {
58
- if (userAborted)
59
- throw new DdgSearchError('Search aborted.', 'aborted');
60
- throw new DdgSearchError(`DuckDuckGo request failed: ${describeError(err)}`, 'network');
61
- }
62
- if (response.status === 429 || response.status === 403) {
63
- throw new DdgSearchError(`DuckDuckGo is rate-limiting this client (HTTP ${response.status}). Try again in a moment.`, 'rate-limit', response.status);
64
- }
65
- if (!response.ok) {
66
- throw new DdgSearchError(`DuckDuckGo HTTP ${response.status} ${response.statusText}`, 'http', response.status);
67
- }
68
- return parseDdgHtml(await response.text()).slice(0, count);
32
+ return await httpRequest(url, {
33
+ timeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
34
+ ...(opts.signal === undefined ? {} : { signal: opts.signal }),
35
+ ...(opts.fetchImpl === undefined ? {} : { fetchImpl: opts.fetchImpl }),
36
+ method: 'GET',
37
+ headers: {
38
+ 'user-agent': USER_AGENT,
39
+ accept: 'text/html'
40
+ }
41
+ }, async (response) => {
42
+ // DDG's own status policy: 429/403 is throttling, not a plain HTTP fault.
43
+ if (response.status === 429 || response.status === 403) {
44
+ throw new DdgSearchError(`DuckDuckGo is rate-limiting this client (HTTP ${response.status}). Try again in a moment.`, 'rate-limit', response.status);
45
+ }
46
+ if (!response.ok) {
47
+ throw new DdgSearchError(`DuckDuckGo HTTP ${response.status} ${response.statusText}`, 'http', response.status);
48
+ }
49
+ return parseDdgHtml(await response.text()).slice(0, count);
50
+ });
69
51
  }
70
- finally {
71
- clearTimeout(timeoutHandle);
72
- if (opts.signal)
73
- opts.signal.removeEventListener('abort', onUserAbort);
52
+ catch (err) {
53
+ if (err instanceof HttpRequestError) {
54
+ throw err.kind === 'aborted' ?
55
+ new DdgSearchError('Search aborted.', 'aborted')
56
+ : new DdgSearchError(`DuckDuckGo request failed: ${err.detail}`, 'network');
57
+ }
58
+ throw err;
74
59
  }
75
60
  }
76
61
  export function parseDdgHtml(html) {
@@ -123,8 +108,3 @@ function unwrapDdgRedirect(href) {
123
108
  function collapse(text) {
124
109
  return text.replace(/\s+/g, ' ').trim();
125
110
  }
126
- function describeError(err) {
127
- if (err instanceof Error)
128
- return err.message;
129
- return String(err);
130
- }
@@ -167,11 +167,81 @@ export declare function findDeclaredRange(parentPkg: string, cwd: string): strin
167
167
  export declare function buildVersionBanner(pin: AutoInstallPin | undefined, resolved: string, version: string, cwd: string): string;
168
168
  export declare function getDocsModulesDir(): string;
169
169
  export declare function ensureDocsModulesDir(dir: string): void;
170
- export declare function runAutoInstall(spawn: SpawnFn, packageName: string, signal: AbortSignal | undefined, versionRange?: string): Promise<{
170
+ /**
171
+ * Options rather than a positional tail: `signal` and `versionRange` sat adjacent,
172
+ * and reaching the range meant writing `undefined` into the signal slot — which is
173
+ * exactly how the primary acquisition path lost its abort signal while the hop path
174
+ * kept it.
175
+ */
176
+ export interface AutoInstallOptions {
177
+ signal?: AbortSignal | undefined;
178
+ versionRange?: string | undefined;
179
+ }
180
+ export declare function runAutoInstall(spawn: SpawnFn, packageName: string, opts?: AutoInstallOptions): Promise<{
171
181
  success: boolean;
172
182
  installDir: string;
173
183
  stderr: string;
174
184
  }>;
185
+ /** What acquiring one package produced, or the stage at which it failed. */
186
+ export type AcquireOutcome = {
187
+ ok: true;
188
+ pkg: ResolvedPackage;
189
+ autoInstalled: boolean;
190
+ pin?: AutoInstallPin;
191
+ } | {
192
+ ok: false;
193
+ stage: 'resolve';
194
+ err: unknown;
195
+ } | {
196
+ ok: false;
197
+ stage: 'install';
198
+ stderr: string;
199
+ pin: AutoInstallPin;
200
+ asked: string;
201
+ } | {
202
+ ok: false;
203
+ stage: 'reresolve';
204
+ err: unknown;
205
+ pin: AutoInstallPin;
206
+ };
207
+ export interface AcquireInput {
208
+ /** The specifier to resolve. May be a subpath (`pkg/sub`) — the INSTALL always
209
+ * targets its parent package. */
210
+ name: string;
211
+ cwd: string;
212
+ spawn: SpawnFn;
213
+ resolvePackage: typeof defaultResolvePackage;
214
+ signal: AbortSignal | undefined;
215
+ }
216
+ /**
217
+ * Get a package onto disk and resolved: resolve from `cwd`, and on
218
+ * `not_installed` install it — at the range the PROJECT declares when it declares
219
+ * one — then resolve again from the install dir.
220
+ *
221
+ * One statement of the ladder, for both callers. It was written twice: inline in
222
+ * `docsRaw` for the requested package, and in `tryResolveOrInstall` for each hop
223
+ * of the type-redirect chain — and the copies had drifted on all three things
224
+ * that matter.
225
+ *
226
+ * - The abort signal. The hop copy passed it; the primary copy passed a bare
227
+ * `undefined` placeholder to reach the fourth positional, while
228
+ * `DocsRawInput.signal` was honoured on either side of that call. So a user
229
+ * cancel during the MAIN `npm install` of a model-chosen package was not
230
+ * delivered. `runAutoInstall` takes an options object now, so a hole like that
231
+ * cannot be typed.
232
+ * - The version pin. `findDeclaredRange` had exactly one call site — the primary
233
+ * copy. The hop copy installed `latest` unconditionally, on the hop most likely
234
+ * to be the declared one: `declarationChain` exists precisely because "a
235
+ * project that uses Bun declares `@types/bun`, not `bun`".
236
+ * - The provenance. `autoInstalled`/`autoInstallPin` were locals of `docsRaw`, so
237
+ * a package acquired only through a hop reported neither and got no version
238
+ * banner.
239
+ *
240
+ * CONTEXT.md records the redirect WALK as unified (`resolveTypeSource`) and its
241
+ * `resolveHop` seam as "the one thing its two call sites disagree about". They
242
+ * should disagree only about WHETHER to install, never about HOW.
243
+ */
244
+ export declare function acquirePackage(input: AcquireInput): Promise<AcquireOutcome>;
175
245
  export declare function docsRaw(input: DocsRawInput): Promise<DocsRawResult>;
176
246
  export declare function docsFocused(input: DocsFocusedInput): Promise<DocsFocusedResult>;
177
247
  export declare function buildPrompt(pkg: ResolvedPackage, query: string, content: string): string;