@mjasnikovs/pi-task 0.38.15 → 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
@@ -3,7 +3,7 @@ import { Text } from '@earendil-works/pi-tui';
3
3
  import { FetchAndCleanError } from './html-clean.js';
4
4
  import { fetchFocused } from './fetch-core.js';
5
5
  import { formatResultText } from '../shared/child-output.js';
6
- import { makeWorkerTool } from './shared.js';
6
+ import { childFailureReason, makeWorkerTool, workerAnswer, workerUnavailable } from './shared.js';
7
7
  import { normalizeQuery } from './research-cache.js';
8
8
  import { isAbstention } from './abstention.js';
9
9
  const RENDER_QUERY_MAX = 100;
@@ -35,7 +35,7 @@ export function registerPiWorkerFetch(pi, internals = {}) {
35
35
  new URL(params.url);
36
36
  }
37
37
  catch {
38
- return { text: `Invalid URL: ${params.url}`, details: {} };
38
+ return workerUnavailable(`Invalid URL: ${params.url}`, {}, 'bad-url');
39
39
  }
40
40
  try {
41
41
  const result = await fetchFocused({
@@ -50,7 +50,10 @@ export function registerPiWorkerFetch(pi, internals = {}) {
50
50
  // (workers/focused-extractor.ts) — this used to re-map the result back into a
51
51
  // ChildOutcome just to ask formatChildFailure the same question.
52
52
  if (result.failure !== undefined) {
53
- return { text: result.failure, details: { childExitCode: result.childExitCode } };
53
+ return workerUnavailable(result.failure, { childExitCode: result.childExitCode }, childFailureReason({
54
+ exitCode: result.childExitCode,
55
+ aborted: result.aborted
56
+ }));
54
57
  }
55
58
  const body = formatResultText('', // a fetched page answer carries no package header
56
59
  { answer: result.answer, excerpt: result.excerpt }, result.excerptVerified) || '(no output)';
@@ -58,26 +61,20 @@ export function registerPiWorkerFetch(pi, internals = {}) {
58
61
  // in the TEXT, not only in details: details are for the harness, and the
59
62
  // worker acts on what it reads.
60
63
  const text = result.nextStep ? `${body}\n\n${result.nextStep}` : body;
61
- return {
62
- text,
63
- details: {
64
- childExitCode: 0,
65
- answer: result.answer,
66
- excerpt: result.excerpt,
67
- excerptVerified: result.excerptVerified,
68
- coverageMiss: result.coverageMiss,
69
- anchoredSection: result.anchoredSection
70
- }
71
- };
64
+ return workerAnswer(text, {
65
+ childExitCode: 0,
66
+ answer: result.answer,
67
+ excerpt: result.excerpt,
68
+ excerptVerified: result.excerptVerified,
69
+ coverageMiss: result.coverageMiss,
70
+ anchoredSection: result.anchoredSection
71
+ });
72
72
  }
73
73
  catch (err) {
74
74
  if (err instanceof FetchAndCleanError) {
75
- return { text: err.message, details: {} };
75
+ return workerUnavailable(err.message, {}, 'fetch-failed');
76
76
  }
77
- return {
78
- text: `Could not fetch ${params.url}: ${err instanceof Error ? err.message : String(err)}`,
79
- details: {}
80
- };
77
+ return workerUnavailable(`Could not fetch ${params.url}: ${err instanceof Error ? err.message : String(err)}`, {}, 'fetch-failed');
81
78
  }
82
79
  },
83
80
  renderCall(args, theme) {
@@ -109,8 +106,10 @@ export function registerPiWorkerFetch(pi, internals = {}) {
109
106
  * `docsCacheable`: pi-worker-fetch.test.ts carried a hand-retyped copy driving four
110
107
  * tests, which a change to the shipped rule would leave green.
111
108
  */
112
- export function fetchCacheable(d, text) {
113
- return d.childExitCode === 0 && !isAbstention(text);
109
+ export function fetchCacheable(_d, text) {
110
+ // Answer QUALITY only — see docsCacheable. `childExitCode === 0` used to lead
111
+ // this rule and was true of an aborted child, so `"Fetch aborted."` cached.
112
+ return !isAbstention(text);
114
113
  }
115
114
  /** The fetch cache key. URL verbatim (path case can matter), question normalised —
116
115
  * same page, different question is a different answer. */
@@ -2,7 +2,7 @@ import { Type } from '@sinclair/typebox';
2
2
  import { Text } from '@earendil-works/pi-tui';
3
3
  import { getConfig } from '../config/config.js';
4
4
  import { search } from './search-core.js';
5
- import { makeWorkerTool } from './shared.js';
5
+ import { makeWorkerTool, workerAnswer, workerUnavailable } from './shared.js';
6
6
  import { normalizeQuery } from './research-cache.js';
7
7
  const Params = Type.Object({
8
8
  query: Type.String({ description: 'Search query.' }),
@@ -38,14 +38,16 @@ export function registerPiWorkerSearch(pi, internals = {}) {
38
38
  ddgSearch: internals.ddgSearch
39
39
  });
40
40
  if (result.kind === 'no_key' || result.kind === 'error') {
41
- return { text: result.message, details: { resultCount: 0 } };
41
+ return workerUnavailable(result.message, { resultCount: 0 }, result.kind);
42
42
  }
43
43
  const { results } = result;
44
+ // Zero results IS an answer: the search ran and the web has nothing.
45
+ // Only a search that could not run is unavailable.
44
46
  if (results.length === 0) {
45
- return { text: `No results for: ${params.query}`, details: { resultCount: 0 } };
47
+ return workerAnswer(`No results for: ${params.query}`, { resultCount: 0 });
46
48
  }
47
49
  const lines = results.map((r, i) => `${i + 1}. [${r.title}](${r.url}) — ${r.description}`);
48
- return { text: lines.join('\n'), details: { resultCount: results.length } };
50
+ return workerAnswer(lines.join('\n'), { resultCount: results.length });
49
51
  },
50
52
  renderCall(args, theme) {
51
53
  let text = theme.fg('toolTitle', theme.bold('pi-worker-search '));
@@ -9,7 +9,7 @@
9
9
  import { Text } from '@earendil-works/pi-tui';
10
10
  import { Type } from '@sinclair/typebox';
11
11
  import { runWorker } from './pi-worker-core.js';
12
- import { formatChildFailure, makeWorkerTool } from './shared.js';
12
+ import { childFailureReason, formatChildFailure, makeWorkerTool, workerAnswer, workerUnavailable } from './shared.js';
13
13
  const RENDER_PROMPT_MAX = 120;
14
14
  const WorkerParams = Type.Object({
15
15
  prompt: Type.String({ description: 'Task for the worker to perform.' })
@@ -41,9 +41,10 @@ export function registerPiWorker(pi) {
41
41
  const result = await runWorker({ prompt: params.prompt, cwd: ctx.cwd, signal });
42
42
  const details = { exitCode: result.exitCode };
43
43
  const failure = formatChildFailure(result, 'Worker aborted.');
44
- if (failure !== null)
45
- return { text: failure, details };
46
- return { text: result.text || '(no output)', details };
44
+ if (failure !== null) {
45
+ return workerUnavailable(failure, details, childFailureReason(result));
46
+ }
47
+ return workerAnswer(result.text || '(no output)', details);
47
48
  },
48
49
  renderCall(args, theme) {
49
50
  const prompt = args.prompt.replace(/\s+/g, ' ').trim();
@@ -1,7 +1,7 @@
1
1
  import { braveSearch as defaultBraveSearch } from './brave-search.js';
2
2
  import { ddgSearch as defaultDdgSearch } from './ddg-search.js';
3
3
  import { exaSearch as defaultExaSearch } from './exa-search.js';
4
- import type { SearchProvider, SearchResult } from './search-types.js';
4
+ import { type SearchProvider, type SearchResult } from './search-types.js';
5
5
  export interface SearchCoreInput {
6
6
  query: string;
7
7
  count?: number;
@@ -1,12 +1,24 @@
1
1
  import { getConfig } from '../config/config.js';
2
- import { braveSearch as defaultBraveSearch, BraveSearchError } from './brave-search.js';
2
+ import { braveSearch as defaultBraveSearch } from './brave-search.js';
3
3
  import { ddgSearch as defaultDdgSearch } from './ddg-search.js';
4
4
  import { exaSearch as defaultExaSearch } from './exa-search.js';
5
- function isLikeBraveSearchError(err) {
6
- return (typeof err === 'object'
7
- && err !== null
8
- && err.name === 'BraveSearchError');
9
- }
5
+ import { searchProviderKey, SEARCH_PROVIDER_LABELS } from './search-types.js';
6
+ const SEARCH_ADAPTERS = {
7
+ exa: {
8
+ run: (input, _key, opts) => (input.exaSearch ?? defaultExaSearch)(input.query, opts)
9
+ },
10
+ ddg: {
11
+ run: (input, _key, opts) => (input.ddgSearch ?? defaultDdgSearch)(input.query, opts)
12
+ },
13
+ brave: {
14
+ run: (input, key, opts) => (input.braveSearch ?? defaultBraveSearch)(input.query, { ...opts, apiKey: key }),
15
+ missingKeyMessage: 'Brave Search not configured. Set BRAVE_SEARCH_API_KEY env var '
16
+ + '(get a key at https://api.search.brave.com/app/keys) or switch '
17
+ + 'the search provider in /task-config.',
18
+ errorName: 'BraveSearchError',
19
+ wrapUnknownError: m => `Brave Search request failed: ${m}`
20
+ }
21
+ };
10
22
  /**
11
23
  * Provider selection is STRICT: the configured (or overridden) engine is the
12
24
  * only one tried — a failure reports as an error rather than silently switching
@@ -14,54 +26,36 @@ function isLikeBraveSearchError(err) {
14
26
  */
15
27
  export async function search(input) {
16
28
  const provider = input.provider ?? getConfig().searchProvider;
17
- if (provider === 'brave')
18
- return braveSearchCore(input);
19
- const run = provider === 'exa' ?
20
- () => (input.exaSearch ?? defaultExaSearch)(input.query, {
21
- count: input.count,
22
- signal: input.signal
23
- })
24
- : () => (input.ddgSearch ?? defaultDdgSearch)(input.query, {
25
- count: input.count,
26
- signal: input.signal
27
- });
28
- try {
29
- return { kind: 'ok', results: await run() };
30
- }
31
- catch (err) {
32
- return {
33
- kind: 'error',
34
- message: err instanceof Error ? err.message : String(err)
35
- };
36
- }
37
- }
38
- async function braveSearchCore(input) {
29
+ const adapter = SEARCH_ADAPTERS[provider];
39
30
  const getEnv = input.getEnv ?? ((k) => process.env[k]);
40
- const braveSearch = input.braveSearch ?? defaultBraveSearch;
41
- const apiKey = getEnv('BRAVE_SEARCH_API_KEY') ?? getEnv('BRAVE_API_KEY');
42
- if (!apiKey) {
31
+ const key = searchProviderKey(provider, getEnv);
32
+ if (key === null) {
43
33
  return {
44
34
  kind: 'no_key',
45
- message: 'Brave Search not configured. Set BRAVE_SEARCH_API_KEY env var '
46
- + '(get a key at https://api.search.brave.com/app/keys) or switch '
47
- + 'the search provider in /task-config.'
35
+ message: adapter.missingKeyMessage
36
+ ?? `${SEARCH_PROVIDER_LABELS[provider]} search is not configured.`
48
37
  };
49
38
  }
50
39
  try {
51
- const results = await braveSearch(input.query, {
52
- apiKey,
53
- count: input.count,
54
- signal: input.signal
40
+ const results = await adapter.run(input, key, {
41
+ ...(input.count === undefined ? {} : { count: input.count }),
42
+ ...(input.signal === undefined ? {} : { signal: input.signal })
55
43
  });
56
44
  return { kind: 'ok', results };
57
45
  }
58
46
  catch (err) {
59
- if (err instanceof BraveSearchError || isLikeBraveSearchError(err)) {
60
- return { kind: 'error', message: err.message };
61
- }
47
+ // Which throws already carry a finished user-facing message is the engine's
48
+ // own fact, so it is a row. This used to be an `err.name ===
49
+ // 'BraveSearchError'` STRING match in a brave-only branch — the check
50
+ // existed only because brave was reached down a different path from the
51
+ // other two, and it is the reason a subclass rename would have gone
52
+ // unnoticed.
53
+ const message = err instanceof Error ? err.message : String(err);
54
+ const known = adapter.errorName === undefined
55
+ || (err instanceof Error && err.name === adapter.errorName);
62
56
  return {
63
57
  kind: 'error',
64
- message: `Brave Search request failed: ${err instanceof Error ? err.message : String(err)}`
58
+ message: known ? message : (adapter.wrapUnknownError?.(message) ?? message)
65
59
  };
66
60
  }
67
61
  }
@@ -12,6 +12,19 @@ export interface SearchResult {
12
12
  */
13
13
  export type SearchProvider = 'exa' | 'ddg' | 'brave';
14
14
  export declare const SEARCH_PROVIDERS: readonly SearchProvider[];
15
+ /**
16
+ * The API-key env vars an engine needs, in lookup order. Empty = keyless.
17
+ *
18
+ * Declared beside the engine ids rather than inside `search()`, because two
19
+ * places ask the question: the search itself, and `searchConfigured` in
20
+ * phases.ts, which decides whether the APIS research worker is even given the
21
+ * search tool. That second copy was a hand-written re-statement of brave's env
22
+ * pair with a comment saying it "mirrors search-core's lookup" — the shape a
23
+ * table exists to make impossible.
24
+ */
25
+ export declare const SEARCH_PROVIDER_KEY_ENV: Record<SearchProvider, readonly string[]>;
26
+ /** The engine's key, or `null` when it needs one and none is set. `''` = keyless. */
27
+ export declare function searchProviderKey(provider: SearchProvider, getEnv: (k: string) => string | undefined): string | null;
15
28
  /**
16
29
  * Human-readable engine names for the config UI. The short ids stay the stored
17
30
  * value (config-file compat); only the display layer uses these.
@@ -1,4 +1,31 @@
1
1
  export const SEARCH_PROVIDERS = ['exa', 'ddg', 'brave'];
2
+ /**
3
+ * The API-key env vars an engine needs, in lookup order. Empty = keyless.
4
+ *
5
+ * Declared beside the engine ids rather than inside `search()`, because two
6
+ * places ask the question: the search itself, and `searchConfigured` in
7
+ * phases.ts, which decides whether the APIS research worker is even given the
8
+ * search tool. That second copy was a hand-written re-statement of brave's env
9
+ * pair with a comment saying it "mirrors search-core's lookup" — the shape a
10
+ * table exists to make impossible.
11
+ */
12
+ export const SEARCH_PROVIDER_KEY_ENV = {
13
+ exa: [],
14
+ ddg: [],
15
+ brave: ['BRAVE_SEARCH_API_KEY', 'BRAVE_API_KEY']
16
+ };
17
+ /** The engine's key, or `null` when it needs one and none is set. `''` = keyless. */
18
+ export function searchProviderKey(provider, getEnv) {
19
+ const vars = SEARCH_PROVIDER_KEY_ENV[provider];
20
+ if (vars.length === 0)
21
+ return '';
22
+ for (const v of vars) {
23
+ const value = getEnv(v);
24
+ if (value)
25
+ return value;
26
+ }
27
+ return null;
28
+ }
2
29
  /**
3
30
  * Human-readable engine names for the config UI. The short ids stay the stored
4
31
  * value (config-file compat); only the display layer uses these.
@@ -2,6 +2,7 @@ import type { Static, TSchema } from '@sinclair/typebox';
2
2
  import type { AgentToolResult } from '@earendil-works/pi-agent-core';
3
3
  import type { ExtensionAPI, ExtensionContext, Theme } from '@earendil-works/pi-coding-agent';
4
4
  import type { Text } from '@earendil-works/pi-tui';
5
+ import { type WorkerFailureInput } from './worker-failure.js';
5
6
  /** Build a plain-text AgentToolResult. */
6
7
  export declare function textResult<T>(text: string, details: T): AgentToolResult<T>;
7
8
  /**
@@ -22,21 +23,60 @@ export interface ChildOutcome {
22
23
  * (`pi-worker` skipped the `.trim()` the others applied).
23
24
  */
24
25
  export declare function formatChildFailure(child: ChildOutcome, abortedMessage: string): string | null;
26
+ /**
27
+ * What a worker tool PRODUCED — an answer, or a statement that it has none.
28
+ *
29
+ * This used to be a bare `{text, details}` bag, and "did it succeed" was
30
+ * re-derived downstream from `details.childExitCode === 0`. That derivation was
31
+ * wrong in the one case it most needed to be right: a signal-killed child reports
32
+ * `code ?? 0` = 0 (shared/child-process.ts), so an aborted lookup arrived with
33
+ * exit code 0, `docsCacheable`/`fetchCacheable` said yes, and `"Docs lookup
34
+ * aborted."` was memoised for the whole run and re-served to every later sibling —
35
+ * exactly the failure `abstention.ts` exists to stop, with escalation unable to
36
+ * re-fire. `docsFailureResult`'s own contract said it recorded the code "NOT as
37
+ * 0"; the value it copied was 0.
38
+ *
39
+ * Stating the outcome makes that unrepresentable: `makeWorkerTool` stores only an
40
+ * `answer`, so no cache rule has to know anything about process health, and the
41
+ * `cacheable` predicates shrink to what they are actually about — answer quality.
42
+ */
43
+ export type WorkerOutcome<TDetails> = {
44
+ kind: 'answer';
45
+ text: string;
46
+ details: TDetails;
47
+ } | {
48
+ kind: 'unavailable';
49
+ text: string;
50
+ details: TDetails;
51
+ /**
52
+ * Why there is no answer, for the debug trail. A `WorkerFailureKind`
53
+ * where a child died (classifyWorkerFailure owns that precedence), or a
54
+ * short tag for the lookups that never got as far as a child.
55
+ */
56
+ reason: string;
57
+ };
58
+ /** This call produced an answer. Cacheable, subject to the tool's own rule. */
59
+ export declare function workerAnswer<T>(text: string, details: T): WorkerOutcome<T>;
60
+ /**
61
+ * The `reason` tag for a child that died, read off the ONE ladder that owns the
62
+ * precedence (`classifyWorkerFailure`). `'no-answer'` when nothing killed it and
63
+ * the caller still has no answer to give.
64
+ */
65
+ export declare function childFailureReason(child: WorkerFailureInput): string;
66
+ /** This call has no answer. NEVER cached, whatever the tool's rule says. */
67
+ export declare function workerUnavailable<T>(text: string, details: T, reason: string): WorkerOutcome<T>;
25
68
  /**
26
69
  * What a worker tool is, minus the registration ritual: a name/label/schema,
27
- * a `run` that produces the focused text + structured details, and a `renderCall`
28
- * for the TUI. `makeWorkerTool` owns `registerTool`, the parallel execution mode,
29
- * and wrapping the result in `textResult`.
70
+ * a `run` that produces the outcome, and a `renderCall` for the TUI.
71
+ * `makeWorkerTool` owns `registerTool`, the parallel execution mode, and wrapping
72
+ * the result in `textResult`.
30
73
  */
31
74
  export interface WorkerToolSpec<TParams extends TSchema, TDetails> {
32
75
  name: string;
33
76
  label: string;
34
77
  description: string;
35
78
  parameters: TParams;
36
- run(params: Static<TParams>, signal: AbortSignal | undefined, ctx: ExtensionContext): Promise<{
37
- text: string;
38
- details: TDetails;
39
- }>;
79
+ run(params: Static<TParams>, signal: AbortSignal | undefined, ctx: ExtensionContext): Promise<WorkerOutcome<TDetails>>;
40
80
  renderCall(args: Static<TParams>, theme: Theme): Text;
41
81
  /**
42
82
  * Per-run research-cache policy (F10). Return a stable cache key for this call —
@@ -57,10 +97,10 @@ export interface WorkerToolSpec<TParams extends TSchema, TDetails> {
57
97
  */
58
98
  cachePkg?(params: Static<TParams>): string | undefined;
59
99
  /**
60
- * Whether a produced result is safe to cache. Only a SUCCESS is memoised an
61
- * error, empty, or aborted result must fall through so a transient failure never
62
- * poisons the run. Defaults to always-cacheable when omitted (but a tool with a
63
- * cacheKey should always supply this).
100
+ * Whether an ANSWER is safe to cache a question about the answer's QUALITY
101
+ * (type-only, an abstention, an unverified excerpt), never about process
102
+ * health. An `unavailable` outcome never reaches this: `makeWorkerTool` has
103
+ * already refused it. Defaults to always-cacheable when omitted.
64
104
  */
65
105
  cacheable?(details: TDetails, text: string): boolean;
66
106
  }
Binary file
@@ -0,0 +1,60 @@
1
+ /**
2
+ * worker-channels — what a worker TOOL is, as data.
3
+ *
4
+ * `makeWorkerTool` already gives every worker tool one registration adapter, but
5
+ * `spec.name` never left the registration closure. So the same four name strings
6
+ * were re-typed as literals in three directories and had to agree by hand:
7
+ *
8
+ * • `phases.ts` paired `'…,pi-worker-docs'` with `DOCS_EXTENSION_PATH`, and
9
+ * `',pi-worker-search,pi-worker-fetch'` with `SEARCH_EXTENSION_PATH` — a tools
10
+ * string and an `-e` path list that mean the same thing, written twice and
11
+ * kept in step by eye.
12
+ * • `GROUNDING_RETRIEVAL_TOOLS` was a second copy of the names.
13
+ * • `summarizeToolArgs` was a third, and also re-stated each tool's parameter
14
+ * shape (`module`/`query`, `query`, `url`).
15
+ * • Worst for locality: `runWorker` — the GENERIC child runner — hardcoded one
16
+ * tool's identity AND its parameter, `call.name === 'pi-worker-docs' &&
17
+ * args.module === '.'`, to decide a fan-out deadline extension.
18
+ *
19
+ * A rename or a new tool was five edits in three directories with no compile
20
+ * error linking them. It is one row here now.
21
+ *
22
+ * What is NOT in a row: the tools string's non-worker members (`read`, `grep`,
23
+ * `find`, `ls`) are pi's own built-ins, not channels — they appear in
24
+ * {@link GROUNDING_RETRIEVAL_TOOLS} because grounding is about RETRIEVAL, not
25
+ * about which extension supplies it.
26
+ */
27
+ /** One worker tool, and everything the rest of the codebase knows about it. */
28
+ export interface WorkerChannel {
29
+ /** The tool name the model calls. The same string `WorkerToolSpec.name` declares. */
30
+ name: string;
31
+ /** The `-e` entry that registers it into a child pi. */
32
+ entryPath: string;
33
+ /** Does a call to this tool retrieve content a claim could be grounded in? */
34
+ grounding: boolean;
35
+ /** One line naming WHAT this call was about, for the debug log. */
36
+ summarize: (args: Record<string, unknown>) => string;
37
+ /**
38
+ * A call this tool's own consumers branch on. Only the docs channel has one:
39
+ * a `.` module is a PROJECT-SOURCE lookup, which reads the working tree
40
+ * rather than a package, and is what the fan-out deadline extends for.
41
+ *
42
+ * A property, not a method: it is read off the row and called on its own, so
43
+ * a method signature would invite an unbound `this`.
44
+ */
45
+ isProjectSourceLookup?: (args: Record<string, unknown>) => boolean;
46
+ }
47
+ export declare const WORKER_CHANNELS: readonly WorkerChannel[];
48
+ /** The row for a tool call, or `undefined` when the tool is not a worker channel. */
49
+ export declare function workerChannel(toolName: string): WorkerChannel | undefined;
50
+ /**
51
+ * The tools string and the `-e` paths for a set of channels, together — they are
52
+ * one fact and used to be two literals. Entry paths are de-duplicated: search and
53
+ * fetch ship in one extension file.
54
+ */
55
+ export declare function channelSet(names: readonly string[]): {
56
+ tools: string;
57
+ extensions: string[];
58
+ };
59
+ /** True when a tool call retrieves content an APIS entry could be grounded in. */
60
+ export declare function isGroundingRetrieval(toolName: string): boolean;
@@ -0,0 +1,98 @@
1
+ /**
2
+ * worker-channels — what a worker TOOL is, as data.
3
+ *
4
+ * `makeWorkerTool` already gives every worker tool one registration adapter, but
5
+ * `spec.name` never left the registration closure. So the same four name strings
6
+ * were re-typed as literals in three directories and had to agree by hand:
7
+ *
8
+ * • `phases.ts` paired `'…,pi-worker-docs'` with `DOCS_EXTENSION_PATH`, and
9
+ * `',pi-worker-search,pi-worker-fetch'` with `SEARCH_EXTENSION_PATH` — a tools
10
+ * string and an `-e` path list that mean the same thing, written twice and
11
+ * kept in step by eye.
12
+ * • `GROUNDING_RETRIEVAL_TOOLS` was a second copy of the names.
13
+ * • `summarizeToolArgs` was a third, and also re-stated each tool's parameter
14
+ * shape (`module`/`query`, `query`, `url`).
15
+ * • Worst for locality: `runWorker` — the GENERIC child runner — hardcoded one
16
+ * tool's identity AND its parameter, `call.name === 'pi-worker-docs' &&
17
+ * args.module === '.'`, to decide a fan-out deadline extension.
18
+ *
19
+ * A rename or a new tool was five edits in three directories with no compile
20
+ * error linking them. It is one row here now.
21
+ *
22
+ * What is NOT in a row: the tools string's non-worker members (`read`, `grep`,
23
+ * `find`, `ls`) are pi's own built-ins, not channels — they appear in
24
+ * {@link GROUNDING_RETRIEVAL_TOOLS} because grounding is about RETRIEVAL, not
25
+ * about which extension supplies it.
26
+ */
27
+ import { fileURLToPath } from 'node:url';
28
+ const DOCS_ENTRY = fileURLToPath(new URL('./docs-extension.js', import.meta.url));
29
+ const SEARCH_ENTRY = fileURLToPath(new URL('./search-extension.js', import.meta.url));
30
+ /** Clip an argument to one readable line. Shared by every row's `summarize`. */
31
+ function clip(s) {
32
+ const one = s.replace(/\s+/g, ' ').trim();
33
+ return one.length > 60 ? one.slice(0, 59) + '…' : one;
34
+ }
35
+ export const WORKER_CHANNELS = [
36
+ {
37
+ name: 'pi-worker-docs',
38
+ entryPath: DOCS_ENTRY,
39
+ grounding: true,
40
+ summarize: a => typeof a.module === 'string' && typeof a.query === 'string' ?
41
+ `${a.module} "${clip(a.query)}"`
42
+ : '',
43
+ isProjectSourceLookup: a => a.module === '.'
44
+ },
45
+ {
46
+ name: 'pi-worker-search',
47
+ entryPath: SEARCH_ENTRY,
48
+ grounding: true,
49
+ // Without this the debug log shows a bare tool name and a run audit
50
+ // cannot tell WHAT was searched.
51
+ summarize: a => (typeof a.query === 'string' ? `"${clip(a.query)}"` : '')
52
+ },
53
+ {
54
+ name: 'pi-worker-fetch',
55
+ entryPath: SEARCH_ENTRY,
56
+ grounding: true,
57
+ summarize: a => (typeof a.url === 'string' ? clip(a.url) : '')
58
+ }
59
+ ];
60
+ const BY_NAME = new Map(WORKER_CHANNELS.map(c => [c.name, c]));
61
+ /** The row for a tool call, or `undefined` when the tool is not a worker channel. */
62
+ export function workerChannel(toolName) {
63
+ return BY_NAME.get(toolName);
64
+ }
65
+ /**
66
+ * The tools string and the `-e` paths for a set of channels, together — they are
67
+ * one fact and used to be two literals. Entry paths are de-duplicated: search and
68
+ * fetch ship in one extension file.
69
+ */
70
+ export function channelSet(names) {
71
+ // REFUSED, not dropped. Silently skipping an unrecognised name is the exact
72
+ // failure this table was built to eliminate: a typo or a half-finished rename
73
+ // yields a shorter tools string AND a shorter `-e` list, with no compile error
74
+ // and no runtime error, and the child just quietly loses a tool. At every call
75
+ // site that is indistinguishable from asking for fewer channels on purpose.
76
+ const unknown = names.filter(n => !BY_NAME.has(n));
77
+ if (unknown.length > 0) {
78
+ throw new Error(`channelSet: unknown worker channel(s): ${unknown.join(', ')}`);
79
+ }
80
+ const rows = names.map(n => BY_NAME.get(n)).filter((c) => c !== undefined);
81
+ return {
82
+ tools: rows.map(c => c.name).join(','),
83
+ extensions: [...new Set(rows.map(c => c.entryPath))]
84
+ };
85
+ }
86
+ /**
87
+ * Tool calls that retrieve content an APIS entry could be grounded in — the
88
+ * worker channels that say so, plus pi's own read/grep.
89
+ */
90
+ const GROUNDING_RETRIEVAL_TOOLS = new Set([
91
+ 'read',
92
+ 'grep',
93
+ ...WORKER_CHANNELS.filter(c => c.grounding).map(c => c.name)
94
+ ]);
95
+ /** True when a tool call retrieves content an APIS entry could be grounded in. */
96
+ export function isGroundingRetrieval(toolName) {
97
+ return GROUNDING_RETRIEVAL_TOOLS.has(toolName);
98
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.38.15",
3
+ "version": "0.38.16",
4
4
  "description": "Deterministic task planning and spec-orchestration for local models — crash-safe /task pipelines with verify/enforce gates, a real-time remote web view, and web/docs/fetch/worker subagent tools.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",