@mjasnikovs/pi-task 0.37.7 → 0.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/shared/child-output.d.ts +19 -3
  2. package/dist/shared/child-output.js +21 -5
  3. package/dist/shared/git-runner.d.ts +39 -0
  4. package/dist/shared/git-runner.js +38 -0
  5. package/dist/task/accept-debt.d.ts +27 -58
  6. package/dist/task/accept-debt.js +60 -130
  7. package/dist/task/auto-orchestrator.d.ts +7 -57
  8. package/dist/task/auto-orchestrator.js +25 -499
  9. package/dist/task/child-runner.d.ts +2 -0
  10. package/dist/task/child-runner.js +74 -70
  11. package/dist/task/enforce-guidelines.d.ts +1 -1
  12. package/dist/task/enforce-guidelines.js +2 -2
  13. package/dist/task/external-context.d.ts +85 -7
  14. package/dist/task/external-context.js +100 -63
  15. package/dist/task/file-inventory.js +22 -41
  16. package/dist/task/final-gate.d.ts +80 -0
  17. package/dist/task/final-gate.js +102 -49
  18. package/dist/task/gate-deps.js +6 -23
  19. package/dist/task/git-state-guard.d.ts +1 -1
  20. package/dist/task/git-state-guard.js +1 -7
  21. package/dist/task/phases.js +40 -83
  22. package/dist/task/run-final-gate.d.ts +127 -0
  23. package/dist/task/run-final-gate.js +492 -0
  24. package/dist/task/task-gates.d.ts +20 -57
  25. package/dist/task/task-gates.js +11 -11
  26. package/dist/task/verify-work.d.ts +40 -32
  27. package/dist/task/verify-work.js +301 -241
  28. package/dist/workers/docs-core.d.ts +14 -0
  29. package/dist/workers/docs-core.js +28 -16
  30. package/dist/workers/fetch-core.d.ts +6 -1
  31. package/dist/workers/fetch-core.js +26 -33
  32. package/dist/workers/focused-extractor.d.ts +73 -0
  33. package/dist/workers/focused-extractor.js +72 -0
  34. package/dist/workers/pi-worker-docs.d.ts +1 -1
  35. package/dist/workers/pi-worker-docs.js +48 -42
  36. package/dist/workers/pi-worker-fetch.js +6 -8
  37. package/dist/workers/typeonly-log.d.ts +13 -0
  38. package/package.json +1 -1
@@ -4,6 +4,7 @@ import { resolvePackage as defaultResolvePackage, type ResolvedPackage } from '.
4
4
  import { retrieveChunks as defaultRetrieveChunks, type RetrievedChunk } from './docs-retrieve.js';
5
5
  import { npmVersionLookup as defaultNpmVersionLookup, type NpmVersionInfo } from './npm-version.js';
6
6
  import { type SpawnFn } from '../shared/child-process.js';
7
+ import { type ExcerptVerification } from '../shared/child-output.js';
7
8
  /**
8
9
  * Provenance of an auto-installed package's version, so the answer can state
9
10
  * what the version is grounded in instead of leaving it buried in tool details.
@@ -74,9 +75,22 @@ export interface DocsRawInput {
74
75
  signal?: AbortSignal;
75
76
  }
76
77
  export interface DocsFocusedResult {
78
+ /**
79
+ * The child's answer — EMPTY when `failure` is set. A failed child's stdout is never
80
+ * parsed as an answer (it used to be: `parseChildOutput` returns the whole trimmed
81
+ * stdout when there is no `<answer>` tag, so a crashed child's error dump was handed
82
+ * to phaseAutoAnswer as if it were package documentation).
83
+ */
77
84
  answer: string;
78
85
  excerpt?: string;
79
86
  excerptVerified?: boolean;
87
+ /** Retained evidence for a false `excerptVerified`, diagnosable without re-running. */
88
+ excerptCheck?: ExcerptVerification;
89
+ /**
90
+ * Set exactly when the child failed (aborted, or non-zero exit): the standard
91
+ * child-failure message. Callers must treat `answer` as absent when this is present.
92
+ */
93
+ failure?: string;
80
94
  pkg: ResolvedPackage;
81
95
  version: string;
82
96
  exitCode: number;
@@ -7,17 +7,15 @@ import { ensureIndexed as defaultEnsureIndexed } from './docs-index.js';
7
7
  import { resolvePackage as defaultResolvePackage, ResolveError, detectTypesRedirect, typesPackageName, hasTypeFiles, isDtsFile, splitRuntimeNamespace } from './docs-resolve.js';
8
8
  import { retrieveChunks as defaultRetrieveChunks } from './docs-retrieve.js';
9
9
  import { npmVersionLookup as defaultNpmVersionLookup } from './npm-version.js';
10
- import { getPiInvocation } from '../shared/pi-invocation.js';
11
10
  import { runChild } from '../shared/child-process.js';
12
- import { childBaseArgs } from '../shared/child-extensions.js';
13
- import { parseChildOutput, isExcerptInContent, formatResultText as formatResultTextShared } from '../shared/child-output.js';
11
+ import { runFocusedExtraction } from './focused-extractor.js';
12
+ import { formatResultText as formatResultTextShared } from '../shared/child-output.js';
14
13
  const DEFAULT_LIMIT = 8;
15
14
  const DEFAULT_BUDGET = 24_000;
16
15
  const NO_CACHE_HEAD = 25_000;
17
16
  const NO_CACHE_TAIL = 5_000;
18
17
  const NO_CACHE_TOTAL = NO_CACHE_HEAD + NO_CACHE_TAIL;
19
18
  const NO_CACHE_MARKER = '\n\n[...content continues, truncated...]\n\n';
20
- const childArgs = () => [...childBaseArgs(), '--no-tools'];
21
19
  export function extractParentPackage(moduleName) {
22
20
  if (moduleName.startsWith('@')) {
23
21
  const parts = moduleName.split('/');
@@ -516,26 +514,40 @@ export async function docsFocused(input) {
516
514
  }
517
515
  const { pkg, chunks, hitCache, indexingMs } = rawResult;
518
516
  const concatenated = chunks.map(c => c.content).join('\n\n');
519
- const prompt = buildPrompt(pkg, input.query, concatenated);
520
- const invocation = getPiInvocation(childArgs(), prompt);
521
- const child = await runChild(spawn, invocation, input.cwd, input.signal);
522
- const parsed = parseChildOutput(child.stdout);
523
- const excerptVerified = parsed.excerpt ? isExcerptInContent(parsed.excerpt, concatenated) : undefined;
524
- return {
525
- answer: parsed.answer,
526
- excerpt: parsed.excerpt,
527
- excerptVerified,
517
+ const extraction = await runFocusedExtraction({
518
+ prompt: buildPrompt(pkg, input.query, concatenated),
519
+ // Exactly what went into the prompt — this path prompts with the whole concatenation,
520
+ // so the verify target and the prompt content are the same text.
521
+ verifyAgainst: concatenated,
522
+ cwd: input.cwd,
523
+ signal: input.signal,
524
+ spawn,
525
+ abortedMessage: 'Docs lookup aborted.'
526
+ });
527
+ const base = {
528
528
  pkg,
529
529
  version: pkg.version,
530
- exitCode: child.exitCode,
531
- aborted: child.aborted,
532
- stderr: child.stderr,
530
+ exitCode: extraction.exitCode,
531
+ aborted: extraction.aborted,
532
+ stderr: extraction.stderr,
533
533
  hitCache,
534
534
  indexingMs,
535
535
  chunksRetrieved: chunks.length,
536
536
  autoInstalled: rawResult.autoInstalled,
537
537
  npmVersion: rawResult.npmVersion
538
538
  };
539
+ // A failed child yields NO answer. The caller (phaseAutoAnswer) gates on `answer` being
540
+ // non-empty, so an empty one keeps a dead child's output out of the spec entirely;
541
+ // `failure` carries the reason for anyone who wants to report it.
542
+ if (!extraction.ok)
543
+ return { answer: '', failure: extraction.failure, ...base };
544
+ return {
545
+ answer: extraction.answer,
546
+ excerpt: extraction.excerpt,
547
+ excerptVerified: extraction.excerptVerified,
548
+ excerptCheck: extraction.excerptCheck,
549
+ ...base
550
+ };
539
551
  }
540
552
  export function buildPrompt(pkg, query, content) {
541
553
  return (`You answer one question about an npm package, using only the provided content.\n`
@@ -1,5 +1,5 @@
1
1
  import { fetchAndClean as defaultFetchAndClean } from './html-clean.js';
2
- import { type SpawnFn } from '../shared/child-process.js';
2
+ import type { SpawnFn } from '../shared/child-process.js';
3
3
  import { type ExcerptVerification } from '../shared/child-output.js';
4
4
  /** The exact non-answers the child is instructed to emit, matched at the tool layer. */
5
5
  export declare const UNCLEAR_ANSWER = "unclear from this page";
@@ -67,6 +67,11 @@ export interface FetchFocusedResult {
67
67
  excerptCheck?: ExcerptVerification;
68
68
  /** The prompt actually handed to the child — the A/B asserts surgery against this per rep. */
69
69
  assembledPrompt: string;
70
+ /**
71
+ * Set exactly when the child failed (aborted, or non-zero exit): the standard
72
+ * child-failure message, already formatted. Present ⇒ `answer` is empty and means nothing.
73
+ */
74
+ failure?: string;
70
75
  childExitCode: number;
71
76
  aborted: boolean;
72
77
  stderr: string;
@@ -1,9 +1,6 @@
1
- import { spawn as defaultSpawn } from 'node:child_process';
2
1
  import { fetchAndClean as defaultFetchAndClean } from './html-clean.js';
3
- import { getPiInvocation } from '../shared/pi-invocation.js';
4
- import { runChild } from '../shared/child-process.js';
5
- import { childBaseArgs } from '../shared/child-extensions.js';
6
- import { parseChildOutput, verifyExcerpt, formatResultText as formatResultTextShared } from '../shared/child-output.js';
2
+ import { runFocusedExtraction } from './focused-extractor.js';
3
+ import { formatResultText as formatResultTextShared } from '../shared/child-output.js';
7
4
  const CONTENT_BUDGET = 30_000;
8
5
  const HEAD_CHARS = 25_000;
9
6
  const TAIL_CHARS = 5_000;
@@ -23,7 +20,6 @@ export const NOT_COVERED_ANSWER = 'not covered by this page';
23
20
  * no recorded verdict.
24
21
  */
25
22
  const NOT_COVERED_RE = /^not covered by this page[.\s]*$/i;
26
- const childArgs = () => [...childBaseArgs(), '--no-tools'];
27
23
  /**
28
24
  * `github.com/{owner}/{repo}/blob/{ref}/{path}` renders the file through a client-side
29
25
  * viewer, so the HTML we clean carries GitHub chrome ("Sign in", "Appearance settings")
@@ -59,7 +55,6 @@ export async function fetchRaw(input) {
59
55
  }
60
56
  export async function fetchFocused(input) {
61
57
  const fetchAndCleanFn = input.fetchAndClean ?? defaultFetchAndClean;
62
- const spawnFn = input.spawn ?? defaultSpawn;
63
58
  const strategy = input.strategy ?? shippedStrategy;
64
59
  const fetchedUrl = normaliseSourceUrl(input.url);
65
60
  const cleaned = await fetchAndCleanFn(fetchedUrl, { signal: input.signal });
@@ -73,43 +68,41 @@ export async function fetchFocused(input) {
73
68
  content: selected.content,
74
69
  section: selected.section
75
70
  });
76
- const invocation = getPiInvocation(childArgs(), prompt);
77
- const childResult = await runChild(spawnFn, invocation, input.cwd, input.signal);
71
+ const extraction = await runFocusedExtraction({
72
+ prompt,
73
+ // Verify against the FULL page, not the anchored slice we prompted with: the slice is
74
+ // a substring of it, so a genuine excerpt still verifies, and an excerpt the child
75
+ // pulled from memory still fails — the detector's discrimination is unchanged by
76
+ // fragment anchoring. This is the one call site that verifies against a SUPERSET of
77
+ // the prompt content, which is why the extractor takes the target by name.
78
+ verifyAgainst: cleaned.markdown,
79
+ cwd: input.cwd,
80
+ signal: input.signal,
81
+ spawn: input.spawn,
82
+ abortedMessage: 'Fetch aborted.'
83
+ });
78
84
  const base = {
79
85
  anchoredSection: selected.section,
80
86
  assembledPrompt: prompt,
81
- stderr: childResult.stderr,
82
- stdout: childResult.stdout
87
+ stderr: extraction.stderr,
88
+ stdout: extraction.stdout
83
89
  };
84
- if (childResult.aborted) {
85
- return {
86
- answer: '',
87
- coverageMiss: false,
88
- childExitCode: childResult.exitCode,
89
- aborted: true,
90
- ...base
91
- };
92
- }
93
- if (childResult.exitCode !== 0) {
90
+ if (!extraction.ok) {
94
91
  return {
95
92
  answer: '',
96
93
  coverageMiss: false,
97
- childExitCode: childResult.exitCode,
98
- aborted: false,
94
+ failure: extraction.failure,
95
+ childExitCode: extraction.exitCode,
96
+ aborted: extraction.aborted,
99
97
  ...base
100
98
  };
101
99
  }
102
- const parsed = parseChildOutput(childResult.stdout);
103
- const coverageMiss = NOT_COVERED_RE.test(parsed.answer.trim());
104
- // Verify against the FULL page, not the anchored slice: the slice is a substring of it,
105
- // so a genuine excerpt still verifies, and an excerpt the child pulled from memory still
106
- // fails — the detector's discrimination is unchanged by fragment anchoring.
107
- const check = parsed.excerpt ? verifyExcerpt(parsed.excerpt, cleaned.markdown) : undefined;
100
+ const coverageMiss = NOT_COVERED_RE.test(extraction.answer.trim());
108
101
  return {
109
- answer: parsed.answer,
110
- excerpt: parsed.excerpt,
111
- excerptVerified: check?.verified,
112
- excerptCheck: check,
102
+ answer: extraction.answer,
103
+ excerpt: extraction.excerpt,
104
+ excerptVerified: extraction.excerptVerified,
105
+ excerptCheck: extraction.excerptCheck,
113
106
  coverageMiss,
114
107
  nextStep: coverageMiss ? coverageMissNextStep(input.url, fetchedUrl) : undefined,
115
108
  childExitCode: 0,
@@ -0,0 +1,73 @@
1
+ import { type SpawnFn } from '../shared/child-process.js';
2
+ import { type ExcerptVerification } from '../shared/child-output.js';
3
+ /**
4
+ * The argv every focused extraction child runs with: the shared child base (whitelisted
5
+ * extensions + `--print --no-session …`) plus `--no-tools`. This was three byte-identical
6
+ * one-liners in fetch-core, docs-core and pi-worker-docs.
7
+ *
8
+ * `--no-tools` is the contract, not a default: the child is given all the content it may use
9
+ * inside its prompt, so a tool call could only reach for something unsourced.
10
+ */
11
+ export declare const focusedChildArgs: () => string[];
12
+ export interface FocusedRequest {
13
+ /** The fully assembled prompt, including the content block. Delivered on stdin. */
14
+ prompt: string;
15
+ /**
16
+ * The text the cited excerpt is checked against — a NAMED knob, because the four call
17
+ * sites deliberately disagree and the disagreement used to be invisible.
18
+ *
19
+ * `pi-worker-docs` and `docsFocused` pass exactly the concatenated chunks that went into
20
+ * the prompt. `fetchFocused` passes the FULL cleaned page while prompting with only the
21
+ * anchored `#fragment` section: the slice is a substring of the page, so a genuine excerpt
22
+ * still verifies, and an excerpt pulled from the model's memory still fails — fragment
23
+ * anchoring therefore cannot change the hallucination detector's discrimination.
24
+ *
25
+ * Passing the prompt's own content is the safe default; passing a SUPERSET (as fetch does)
26
+ * loosens the check deliberately. Passing anything narrower would manufacture false
27
+ * fabrication verdicts.
28
+ */
29
+ verifyAgainst: string;
30
+ cwd: string;
31
+ signal?: AbortSignal;
32
+ /** Defaults to node's spawn; injected by tests and by the worker `internals` hooks. */
33
+ spawn?: SpawnFn;
34
+ /** The message reported when the child was aborted, e.g. `'Docs lookup aborted.'`. */
35
+ abortedMessage: string;
36
+ }
37
+ /** What every outcome carries, success or failure — the raw child evidence. */
38
+ interface FocusedChildEvidence {
39
+ exitCode: number;
40
+ aborted: boolean;
41
+ stderr: string;
42
+ /** The child's raw stdout, retained for A/B harnesses and failure diagnosis. */
43
+ stdout: string;
44
+ }
45
+ /** The child aborted or exited non-zero. There is deliberately no `answer` here. */
46
+ export interface FocusedFailure extends FocusedChildEvidence {
47
+ ok: false;
48
+ /** `formatChildFailure`'s message — the abort message, or `Worker exited N` + stderr tail. */
49
+ failure: string;
50
+ }
51
+ /** The child exited 0; its output has been parsed and its citation checked. */
52
+ export interface FocusedAnswer extends FocusedChildEvidence {
53
+ ok: true;
54
+ aborted: false;
55
+ /** The `<answer>` body, or the whole trimmed stdout when the child emitted no tags. */
56
+ answer: string;
57
+ /** The `<excerpt>` body; absent when the child cited nothing (or cited only whitespace). */
58
+ excerpt?: string;
59
+ /** The full verification record — absent exactly when `excerpt` is. */
60
+ excerptCheck?: ExcerptVerification;
61
+ /** `excerptCheck?.verified`, for the many callers that only report the verdict. */
62
+ excerptVerified?: boolean;
63
+ }
64
+ export type FocusedResult = FocusedFailure | FocusedAnswer;
65
+ /**
66
+ * Run one focused extraction: spawn the no-tools child on `prompt`, and on success parse its
67
+ * `<answer>`/`<excerpt>` and verify the excerpt against `verifyAgainst`.
68
+ *
69
+ * Never retries — a re-ask of a deterministic extraction over unchanged content is a second
70
+ * bill for the same answer, and all four call sites already had it that way.
71
+ */
72
+ export declare function runFocusedExtraction(req: FocusedRequest): Promise<FocusedResult>;
73
+ export {};
@@ -0,0 +1,72 @@
1
+ /**
2
+ * The **Focused extractor** — the one place a no-tools child pi is run to answer ONE question
3
+ * over content already in hand, cite a verbatim `<excerpt>`, and have that citation checked.
4
+ *
5
+ * It had four copies: `fetchFocused` (fetch-core), `docsFocused` (docs-core), and both paths
6
+ * of `pi-worker-docs` (project source, npm package) — the last two 17-of-20 lines identical
7
+ * and 150 lines apart inside one function. Everything the four did around the child was
8
+ * byte-identical: the `--no-tools` argv, `getPiInvocation`, `runChild`, `parseChildOutput`,
9
+ * and no retry. What genuinely differed was DATA, and the copies hid it:
10
+ *
11
+ * 1. the prompt body — so {@link FocusedRequest.prompt} is a string the caller assembles;
12
+ * 2. WHAT the excerpt is verified against — see {@link FocusedRequest.verifyAgainst};
13
+ * 3. what the answer MEANS (fetch's coverage-miss sentinel) — left to the caller, because
14
+ * classifying an answer is the caller's domain, not the child runner's.
15
+ *
16
+ * Two things the copies got wrong, fixed here once:
17
+ * - **Failure.** `docsFocused` never called `formatChildFailure`, so a child that exited
18
+ * non-zero had its raw stdout parsed as if it were an answer (`parseChildOutput` returns
19
+ * the whole trimmed stdout when there is no `<answer>` tag) and handed to the caller.
20
+ * The result type below makes that unrepresentable: there is no `answer` on a failure.
21
+ * - **Evidence.** Only fetch kept the rich {@link ExcerptVerification}; the other three
22
+ * reduced it to a bare boolean, which is exactly the diagnostic gap typeonly-log.ts
23
+ * names — an `excerptVerified === false` that cannot afterwards be attributed to
24
+ * fabrication vs a normaliser gap. Every caller now gets the struct.
25
+ */
26
+ import { spawn as defaultSpawn } from 'node:child_process';
27
+ import { getPiInvocation } from '../shared/pi-invocation.js';
28
+ import { runChild } from '../shared/child-process.js';
29
+ import { childBaseArgs } from '../shared/child-extensions.js';
30
+ import { parseChildOutput, verifyExcerpt } from '../shared/child-output.js';
31
+ import { formatChildFailure } from './shared.js';
32
+ /**
33
+ * The argv every focused extraction child runs with: the shared child base (whitelisted
34
+ * extensions + `--print --no-session …`) plus `--no-tools`. This was three byte-identical
35
+ * one-liners in fetch-core, docs-core and pi-worker-docs.
36
+ *
37
+ * `--no-tools` is the contract, not a default: the child is given all the content it may use
38
+ * inside its prompt, so a tool call could only reach for something unsourced.
39
+ */
40
+ export const focusedChildArgs = () => [...childBaseArgs(), '--no-tools'];
41
+ /**
42
+ * Run one focused extraction: spawn the no-tools child on `prompt`, and on success parse its
43
+ * `<answer>`/`<excerpt>` and verify the excerpt against `verifyAgainst`.
44
+ *
45
+ * Never retries — a re-ask of a deterministic extraction over unchanged content is a second
46
+ * bill for the same answer, and all four call sites already had it that way.
47
+ */
48
+ export async function runFocusedExtraction(req) {
49
+ const spawn = req.spawn ?? defaultSpawn;
50
+ const invocation = getPiInvocation(focusedChildArgs(), req.prompt);
51
+ const child = await runChild(spawn, invocation, req.cwd, req.signal);
52
+ const evidence = {
53
+ exitCode: child.exitCode,
54
+ aborted: child.aborted,
55
+ stderr: child.stderr,
56
+ stdout: child.stdout
57
+ };
58
+ const failure = formatChildFailure(child, req.abortedMessage);
59
+ if (failure !== null)
60
+ return { ok: false, failure, ...evidence };
61
+ const parsed = parseChildOutput(child.stdout);
62
+ const excerptCheck = parsed.excerpt ? verifyExcerpt(parsed.excerpt, req.verifyAgainst) : undefined;
63
+ return {
64
+ ok: true,
65
+ ...evidence,
66
+ aborted: false,
67
+ answer: parsed.answer,
68
+ excerpt: parsed.excerpt,
69
+ excerptCheck,
70
+ excerptVerified: excerptCheck?.verified
71
+ };
72
+ }
@@ -4,7 +4,7 @@ import { ensureIndexed as defaultEnsureIndexed } from './docs-index.js';
4
4
  import { resolvePackage as defaultResolvePackage } from './docs-resolve.js';
5
5
  import { retrieveChunks as defaultRetrieveChunks } from './docs-retrieve.js';
6
6
  import { npmVersionLookup as defaultNpmVersionLookup } from './npm-version.js';
7
- import { type SpawnFn } from '../shared/child-process.js';
7
+ import type { SpawnFn } from '../shared/child-process.js';
8
8
  /**
9
9
  * Pull `@see {@link https://…}` pointers out of retrieved .d.ts/README text.
10
10
  *
@@ -5,17 +5,13 @@ import { openCache as defaultOpenCache } from './docs-cache.js';
5
5
  import { retrieveChunks as defaultRetrieveChunks } from './docs-retrieve.js';
6
6
  import { docsRaw, formatResultText, buildPrompt, buildVersionBanner } from './docs-core.js';
7
7
  import { formatNpmVersionSection } from './npm-version.js';
8
- import { runChild } from '../shared/child-process.js';
9
- import { childBaseArgs } from '../shared/child-extensions.js';
10
- import { parseChildOutput, isExcerptInContent } from '../shared/child-output.js';
11
- import { getPiInvocation } from '../shared/pi-invocation.js';
12
- import { formatChildFailure, makeWorkerTool } from './shared.js';
8
+ import { runFocusedExtraction } from './focused-extractor.js';
9
+ import { makeWorkerTool } from './shared.js';
13
10
  import { isTypeOnlyAnswer } from '../task/type-only-answer.js';
14
11
  import { logDocsAnswer } from './typeonly-log.js';
15
12
  import { normalizeQuery } from './research-cache.js';
16
13
  import { projectDocsRaw, buildProjectPrompt } from './docs-project.js';
17
14
  import { projectDocsBudget, projectDocsBudgetExhausted } from '../task/research-fanout-budget.js';
18
- const childArgs = () => [...childBaseArgs(), '--no-tools'];
19
15
  const RENDER_QUERY_MAX = 100;
20
16
  const Params = Type.Object({
21
17
  module: Type.String({
@@ -57,6 +53,25 @@ export function packageRootOf(module) {
57
53
  function pinDetails(pin) {
58
54
  return pin ? { versionSource: pin.source, declaredRange: pin.range } : {};
59
55
  }
56
+ /**
57
+ * The tool result for a focused-extraction child that failed, shared by both docs paths —
58
+ * the last chunk they still held in common after the extractor seam took the child running.
59
+ * The paths differ only in `prefix`: the npm path leads every result, failures included, with
60
+ * its version banner and npm-version header; the project path has neither.
61
+ *
62
+ * `childExitCode` is recorded but NOT as 0, which is what keeps a failure out of the research
63
+ * cache (see `cacheable` below).
64
+ */
65
+ function docsFailureResult(extraction, baseDetails, prefix) {
66
+ return {
67
+ text: prefix + extraction.failure,
68
+ details: {
69
+ ...baseDetails,
70
+ ...(extraction.aborted ? { aborted: true } : {}),
71
+ childExitCode: extraction.exitCode
72
+ }
73
+ };
74
+ }
60
75
  export function registerPiWorkerDocs(pi, internals = {}) {
61
76
  // CAP arm of nexttask 5B — OFF unless PI_TASK_PROJECT_DOCS_BUDGET is set, and
62
77
  // then per-ATTEMPT by construction: the extension is loaded into a fresh pi
@@ -110,6 +125,18 @@ export function registerPiWorkerDocs(pi, internals = {}) {
110
125
  // It was DEAD in production (pi runs under node) and BYPASSED under bun test
111
126
  // (internals.spawn is always injected), i.e. untested, unreachable, and wrong.
112
127
  const spawn = internals.spawn ?? defaultSpawn;
128
+ // Both paths below run the SAME extraction against this call's cwd/signal/spawn
129
+ // and verify the citation against exactly the content they prompted with; only
130
+ // the prompt body and the abort wording differ. (fetch is the site that verifies
131
+ // against a superset — see FocusedRequest.verifyAgainst.)
132
+ const extract = (prompt, content, abortedMessage) => runFocusedExtraction({
133
+ prompt,
134
+ verifyAgainst: content,
135
+ cwd: ctx.cwd,
136
+ signal,
137
+ spawn,
138
+ abortedMessage
139
+ });
113
140
  // ── Project source lookup ───────────────────────────────────────
114
141
  if (params.module === '.') {
115
142
  const budget = projectDocsBudget();
@@ -158,29 +185,17 @@ export function registerPiWorkerDocs(pi, internals = {}) {
158
185
  indexingMs
159
186
  };
160
187
  const concatenated = chunks.map(c => c.content).join('\n\n');
161
- const prompt = buildProjectPrompt(projectName, params.query, concatenated);
162
- const invocation = getPiInvocation(childArgs(), prompt);
163
- const child = await runChild(spawn, invocation, ctx.cwd, signal);
164
- const failure = formatChildFailure(child, 'Project docs lookup aborted.');
165
- if (failure !== null) {
166
- return {
167
- text: failure,
168
- details: {
169
- ...baseDetails,
170
- ...(child.aborted ? { aborted: true } : {}),
171
- childExitCode: child.exitCode
172
- }
173
- };
174
- }
175
- const parsed = parseChildOutput(child.stdout);
176
- const verified = parsed.excerpt ? isExcerptInContent(parsed.excerpt, concatenated) : undefined;
188
+ const extraction = await extract(buildProjectPrompt(projectName, params.query, concatenated), concatenated, 'Project docs lookup aborted.');
189
+ if (!extraction.ok)
190
+ return docsFailureResult(extraction, baseDetails, '');
191
+ const verified = extraction.excerptVerified;
177
192
  const text = formatResultText({
178
193
  name: projectName,
179
194
  version: 'local',
180
195
  root: ctx.cwd,
181
196
  entryDts: null,
182
197
  readme: null
183
- }, parsed, verified);
198
+ }, extraction, verified);
184
199
  // SAME instrumentation channel as the package path below, extended to the
185
200
  // project-source branch because that branch is the MAJORITY of what
186
201
  // worker:apis asks — 13 of 17 docs calls in run 15's fatal task, 7 of 12 in
@@ -196,10 +211,11 @@ export function registerPiWorkerDocs(pi, internals = {}) {
196
211
  logDocsAnswer({
197
212
  module: params.module,
198
213
  query: params.query,
199
- answer: parsed.answer,
214
+ answer: extraction.answer,
200
215
  typeOnly: false,
201
216
  reason: 'project-source lookup — the type-only detector is not applied here',
202
217
  excerptVerified: verified,
218
+ excerptCheck: extraction.excerptCheck,
203
219
  toolText: text
204
220
  });
205
221
  return {
@@ -281,23 +297,12 @@ export function registerPiWorkerDocs(pi, internals = {}) {
281
297
  ...npmDetails
282
298
  };
283
299
  const concatenated = chunks.map(c => c.content).join('\n\n');
284
- const prompt = buildPrompt(pkg, params.query, concatenated);
285
- const invocation = getPiInvocation(childArgs(), prompt);
286
- const child = await runChild(spawn, invocation, ctx.cwd, signal);
287
- const failure = formatChildFailure(child, 'Docs lookup aborted.');
288
- if (failure !== null) {
289
- return {
290
- text: versionBanner + npmHeader + failure,
291
- details: {
292
- ...baseDetails,
293
- ...(child.aborted ? { aborted: true } : {}),
294
- childExitCode: child.exitCode
295
- }
296
- };
300
+ const extraction = await extract(buildPrompt(pkg, params.query, concatenated), concatenated, 'Docs lookup aborted.');
301
+ if (!extraction.ok) {
302
+ return docsFailureResult(extraction, baseDetails, versionBanner + npmHeader);
297
303
  }
298
- const parsed = parseChildOutput(child.stdout);
299
- const verified = parsed.excerpt ? isExcerptInContent(parsed.excerpt, concatenated) : undefined;
300
- const body = formatResultText(pkg, parsed, verified);
304
+ const verified = extraction.excerptVerified;
305
+ const body = formatResultText(pkg, extraction, verified);
301
306
  // F-2: a TYPE-ONLY answer is the dangerous failure. "unclear from this package"
302
307
  // is honest and already escalates; a signature is a well-formed, confident,
303
308
  // on-topic answer that names the very parameter asked about, so the worker
@@ -310,7 +315,7 @@ export function registerPiWorkerDocs(pi, internals = {}) {
310
315
  // hono.dev present in run-15 cache values ONLY inside these JSDoc links, never
311
316
  // fetched. Prompting the escalation beats performing it here: this tool runs in
312
317
  // parallel execution mode and cannot cleanly spawn a fetch of its own.
313
- const typeOnly = isTypeOnlyAnswer(parsed.answer, params.query);
318
+ const typeOnly = isTypeOnlyAnswer(extraction.answer, params.query);
314
319
  let text = versionBanner + npmHeader + body;
315
320
  if (typeOnly.typeOnly) {
316
321
  const seeUrls = extractSeeUrls(concatenated);
@@ -344,10 +349,11 @@ export function registerPiWorkerDocs(pi, internals = {}) {
344
349
  logDocsAnswer({
345
350
  module: params.module,
346
351
  query: params.query,
347
- answer: parsed.answer,
352
+ answer: extraction.answer,
348
353
  typeOnly: typeOnly.typeOnly,
349
354
  reason: typeOnly.reason,
350
355
  excerptVerified: verified,
356
+ excerptCheck: extraction.excerptCheck,
351
357
  toolText: text
352
358
  });
353
359
  return {
@@ -2,7 +2,7 @@ import { Type } from '@sinclair/typebox';
2
2
  import { Text } from '@earendil-works/pi-tui';
3
3
  import { FetchAndCleanError } from './html-clean.js';
4
4
  import { fetchFocused, formatResultText } from './fetch-core.js';
5
- import { formatChildFailure, makeWorkerTool } from './shared.js';
5
+ import { makeWorkerTool } from './shared.js';
6
6
  import { normalizeQuery } from './research-cache.js';
7
7
  const RENDER_QUERY_MAX = 100;
8
8
  const Params = Type.Object({
@@ -44,13 +44,11 @@ export function registerPiWorkerFetch(pi, internals = {}) {
44
44
  fetchAndClean: internals.fetchAndClean,
45
45
  spawn: internals.spawn
46
46
  });
47
- const failure = formatChildFailure({
48
- aborted: result.aborted,
49
- exitCode: result.childExitCode,
50
- stderr: result.stderr
51
- }, 'Fetch aborted.');
52
- if (failure !== null) {
53
- return { text: failure, details: { childExitCode: result.childExitCode } };
47
+ // Child failure is decided and formatted once, inside the focused extractor
48
+ // (workers/focused-extractor.ts) — this used to re-map the result back into a
49
+ // ChildOutcome just to ask formatChildFailure the same question.
50
+ if (result.failure !== undefined) {
51
+ return { text: result.failure, details: { childExitCode: result.childExitCode } };
54
52
  }
55
53
  const body = formatResultText({ answer: result.answer, excerpt: result.excerpt }, result.excerptVerified) || '(no output)';
56
54
  // The coverage miss is the one outcome that carries an instruction. It goes
@@ -1,3 +1,4 @@
1
+ import type { ExcerptVerification } from '../shared/child-output.js';
1
2
  /** Env var naming the JSONL sink. Unset (or empty) ⇒ instrumentation is entirely off. */
2
3
  export declare const TYPEONLY_LOG_ENV = "PI_TASK_TYPEONLY_LOG";
3
4
  /** One pi-worker-docs answer, as observed at the tool layer. */
@@ -18,6 +19,18 @@ export interface TypeOnlyLogRecord {
18
19
  unclear: boolean;
19
20
  /** child-output's excerpt check; undefined when the child cited no excerpt. */
20
21
  excerptVerified?: boolean;
22
+ /**
23
+ * The FULL verification record behind `excerptVerified` — the normalised excerpt that was
24
+ * searched for, plus a sha256 + length of the normalised content it was searched in.
25
+ *
26
+ * This is the other half of the F-3(f) hole described above: retaining the answer text
27
+ * says WHAT was claimed, and this says what it was checked against, so a false verdict can
28
+ * be attributed to fabrication (the excerpt is nowhere near the content) rather than a
29
+ * normaliser gap (a markdown-escape variant of text that IS present) without re-running
30
+ * the lookup. Only fetch's extractor used to keep it; all four focused-extractor call
31
+ * sites now can (workers/focused-extractor.ts). Optional — records predating it parse.
32
+ */
33
+ excerptCheck?: ExcerptVerification;
21
34
  /**
22
35
  * The tool's ENTIRE return text — version banner, npm header, the answer prose, the cited
23
36
  * excerpt, and (when it fires) the type-only banner. Optional so logs written before this
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.37.7",
3
+ "version": "0.38.0",
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",