@mjasnikovs/pi-task 0.18.49 → 0.18.50

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.
@@ -3,11 +3,15 @@ import { fetchAndClean as defaultFetchAndClean } from './html-clean.js';
3
3
  import { getPiInvocation } from '../shared/pi-invocation.js';
4
4
  import { runChild } from '../shared/child-process.js';
5
5
  import { childBaseArgs } from '../shared/child-extensions.js';
6
- import { parseChildOutput, isExcerptInContent, formatResultText as formatResultTextShared } from '../shared/child-output.js';
6
+ import { parseChildOutput, verifyExcerpt, formatResultText as formatResultTextShared } from '../shared/child-output.js';
7
7
  const CONTENT_BUDGET = 30_000;
8
8
  const HEAD_CHARS = 25_000;
9
9
  const TAIL_CHARS = 5_000;
10
10
  const TRUNCATION_MARKER = '\n\n[...page continues, truncated...]\n\n';
11
+ /** The exact non-answers the child is instructed to emit, matched at the tool layer. */
12
+ export const UNCLEAR_ANSWER = 'unclear from this page';
13
+ export const NOT_COVERED_ANSWER = 'not covered by this page';
14
+ const NOT_COVERED_RE = /not covered by this page/i;
11
15
  const childArgs = () => [...childBaseArgs(), '--no-tools'];
12
16
  export async function fetchRaw(input) {
13
17
  const fetchAndCleanFn = input.fetchAndClean ?? defaultFetchAndClean;
@@ -17,52 +21,135 @@ export async function fetchRaw(input) {
17
21
  export async function fetchFocused(input) {
18
22
  const fetchAndCleanFn = input.fetchAndClean ?? defaultFetchAndClean;
19
23
  const spawnFn = input.spawn ?? defaultSpawn;
24
+ const strategy = input.strategy ?? shippedStrategy;
20
25
  const cleaned = await fetchAndCleanFn(input.url, { signal: input.signal });
21
- const truncated = truncate(cleaned.markdown);
22
- const prompt = buildPrompt({
26
+ // The #fragment is a client-side concern the server never sees, so anchor from the
27
+ // ORIGINALLY REQUESTED url, not the post-redirect finalUrl (which will have dropped it).
28
+ const selected = strategy.selectContent(cleaned.markdown, input.url);
29
+ const prompt = strategy.buildPrompt({
23
30
  query: input.query,
24
31
  url: cleaned.finalUrl,
25
32
  title: cleaned.title,
26
- content: truncated
33
+ content: selected.content,
34
+ section: selected.section
27
35
  });
28
36
  const invocation = getPiInvocation(childArgs(), prompt);
29
37
  const childResult = await runChild(spawnFn, invocation, input.cwd, input.signal);
38
+ const base = {
39
+ anchoredSection: selected.section,
40
+ assembledPrompt: prompt,
41
+ stderr: childResult.stderr,
42
+ stdout: childResult.stdout
43
+ };
30
44
  if (childResult.aborted) {
31
45
  return {
32
46
  answer: '',
47
+ coverageMiss: false,
33
48
  childExitCode: childResult.exitCode,
34
49
  aborted: true,
35
- stderr: childResult.stderr,
36
- stdout: childResult.stdout
50
+ ...base
37
51
  };
38
52
  }
39
53
  if (childResult.exitCode !== 0) {
40
54
  return {
41
55
  answer: '',
56
+ coverageMiss: false,
42
57
  childExitCode: childResult.exitCode,
43
58
  aborted: false,
44
- stderr: childResult.stderr,
45
- stdout: childResult.stdout
59
+ ...base
46
60
  };
47
61
  }
48
62
  const parsed = parseChildOutput(childResult.stdout);
49
- const excerptVerified = parsed.excerpt ? isExcerptInContent(parsed.excerpt, cleaned.markdown) : undefined;
63
+ // Verify against the FULL page, not the anchored slice: the slice is a substring of it,
64
+ // so a genuine excerpt still verifies, and an excerpt the child pulled from memory still
65
+ // fails — the detector's discrimination is unchanged by fragment anchoring.
66
+ const check = parsed.excerpt ? verifyExcerpt(parsed.excerpt, cleaned.markdown) : undefined;
50
67
  return {
51
68
  answer: parsed.answer,
52
69
  excerpt: parsed.excerpt,
53
- excerptVerified,
70
+ excerptVerified: check?.verified,
71
+ excerptCheck: check,
72
+ coverageMiss: NOT_COVERED_RE.test(parsed.answer),
54
73
  childExitCode: 0,
55
74
  aborted: false,
56
- stderr: childResult.stderr,
57
- stdout: childResult.stdout
75
+ ...base
58
76
  };
59
77
  }
78
+ /** Read the #fragment from a URL. Empty string when there is none. */
79
+ function fragmentOf(url) {
80
+ const h = url.indexOf('#');
81
+ return h === -1 ? '' : url.slice(h + 1).trim();
82
+ }
83
+ /**
84
+ * Slice a markdown page down to the section its heading-anchor #fragment names — the deep
85
+ * link the caller actually asked for — instead of head-truncating the whole page and losing
86
+ * a section that sits past the head window (PROMPT-3 item 2). Falls back to {@link truncate}
87
+ * when the URL has no fragment, or the fragment names no heading in THIS content (e.g. the
88
+ * page changed since the link was captured), so a fragment we cannot honour never degrades a
89
+ * page we could otherwise read.
90
+ */
91
+ export function selectContent(markdown, requestedUrl) {
92
+ const frag = fragmentOf(requestedUrl);
93
+ if (frag) {
94
+ const section = sliceSection(markdown, frag);
95
+ if (section)
96
+ return { content: truncate(section), section: frag };
97
+ }
98
+ return { content: truncate(markdown) };
99
+ }
100
+ function escapeRegExp(s) {
101
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
102
+ }
103
+ /** Find the heading whose anchor is `#<frag>` and return that heading's section: from the
104
+ * heading line up to the next heading of the same or higher level. Anchors are matched on
105
+ * the heading line in both the Docusaurus (`### Title[](#slug "…")`) and GitHub
106
+ * (`## [Title](#slug)`) shapes; `#foo` must not match `#foobar`. */
107
+ function sliceSection(markdown, frag) {
108
+ const lines = markdown.split('\n');
109
+ const anchor = new RegExp('#' + escapeRegExp(frag) + '(?![a-z0-9-])', 'i');
110
+ let start = -1;
111
+ let level = 0;
112
+ for (let i = 0; i < lines.length; i++) {
113
+ const h = /^(#{1,6})\s/.exec(lines[i]);
114
+ if (h && anchor.test(lines[i])) {
115
+ start = i;
116
+ level = h[1].length;
117
+ break;
118
+ }
119
+ }
120
+ if (start === -1)
121
+ return undefined;
122
+ let end = lines.length;
123
+ for (let i = start + 1; i < lines.length; i++) {
124
+ const h = /^(#{1,6})\s/.exec(lines[i]);
125
+ if (h && h[1].length <= level) {
126
+ end = i;
127
+ break;
128
+ }
129
+ }
130
+ const section = lines.slice(start, end).join('\n').trim();
131
+ return section.length > 0 ? section : undefined;
132
+ }
60
133
  function truncate(md) {
61
134
  if (md.length <= CONTENT_BUDGET)
62
135
  return md;
63
136
  return md.slice(0, HEAD_CHARS) + TRUNCATION_MARKER + md.slice(md.length - TAIL_CHARS);
64
137
  }
138
+ /**
139
+ * The shipped extraction prompt. Rule 5 was recalibrated for PROMPT 3: the pre-change rule
140
+ * sent the child to `unclear from this page` on ANY ambiguity, which threw away answers that
141
+ * were on the page (measured: 5 of fetch's 10 failures were deterministic false-"unclear").
142
+ * It now (5) answers from the content whenever the content supports one, even partially;
143
+ * (6) reports a page-coverage MISS distinctly, so it is not conflated with ambiguity; and
144
+ * (7) reserves `unclear` for genuinely-ambiguous content — never for absence, and never as
145
+ * licence to invent. The abstention was relaxed WITHOUT relaxing rules 1-2 (verbatim
146
+ * excerpt) — the fabrication guard the A/B scores is untouched.
147
+ */
65
148
  function buildPrompt(args) {
149
+ const sectionNote = args.section ?
150
+ `<page-section>The content below has been narrowed to the "#${args.section}" section of\n`
151
+ + `the page — the section the URL points to. Treat it as the relevant part of the page.</page-section>\n`
152
+ : '';
66
153
  return (`You extract a single piece of information from a web page to answer one question.\n`
67
154
  + `\n`
68
155
  + `Rules:\n`
@@ -76,16 +163,31 @@ function buildPrompt(args) {
76
163
  + ` specifically about page UI.\n`
77
164
  + `4. If the page is not in English, write the <answer> in English (translate key\n`
78
165
  + ` non-English terms) and keep the original-language text in <excerpt>.\n`
79
- + `5. If the answer is unclear, ambiguous, or absent from <page-content>, write\n`
80
- + ` exactly: <answer>unclear from this page</answer> and put the closest related\n`
81
- + ` text in <excerpt>. Do not guess.\n`
82
- + `6. Be terse. One short paragraph in <answer> max.\n`
166
+ + `5. Answer from <page-content> whenever it supports an answer — INCLUDING a partial\n`
167
+ + ` one. If the content states only part of what is asked, give the part that IS\n`
168
+ + ` present and note what is missing; do NOT fall back to "unclear" merely because\n`
169
+ + ` the coverage is incomplete. When <page-content> plainly contains the asked-for\n`
170
+ + ` thing (e.g. it lists the methods, fields, or signature asked about), you MUST\n`
171
+ + ` answer it — "unclear" is the wrong response in that case.\n`
172
+ + `6. If <page-content> is about a DIFFERENT version, topic, or page than the question\n`
173
+ + ` asks about — the asked-about thing is simply not on this page — write exactly:\n`
174
+ + ` <answer>${NOT_COVERED_ANSWER}</answer> and quote in <excerpt> the text that\n`
175
+ + ` shows what this page IS about. Use this, not "unclear", so the caller can try a\n`
176
+ + ` different page.\n`
177
+ + `7. Only when the answer is genuinely present but ambiguous or self-contradictory,\n`
178
+ + ` write exactly: <answer>${UNCLEAR_ANSWER}</answer> with the closest text in\n`
179
+ + ` <excerpt>. Never invent an answer or state anything not supported by\n`
180
+ + ` <page-content>.\n`
181
+ + `8. Be terse. One short paragraph in <answer> max.\n`
83
182
  + `\n`
84
183
  + `<question>${args.query}</question>\n`
85
184
  + `<url>${args.url}</url>\n`
86
185
  + `<page-title>${args.title}</page-title>\n`
186
+ + sectionNote
87
187
  + `<page-content>\n${args.content}\n</page-content>\n`);
88
188
  }
189
+ /** The shipped strategy: fragment-aware selection + the recalibrated prompt. */
190
+ export const shippedStrategy = { selectContent, buildPrompt };
89
191
  // ─── Thin wrapper: fetch-core formatResultText (no header) ───────────────────
90
192
  export function formatResultText(parsed, verified) {
91
193
  return formatResultTextShared('', parsed, verified);
@@ -1,5 +1,7 @@
1
1
  import { type ContextSnapshot, type SpawnFn } from '../shared/child-process.js';
2
2
  import { type LoopHit } from '../task/loop-detector.js';
3
+ /** True when a tool call retrieves content an APIS entry could be grounded in. */
4
+ export declare function isGroundingRetrieval(toolName: string): boolean;
3
5
  export interface RunWorkerInput {
4
6
  prompt: string;
5
7
  cwd: string;
@@ -102,6 +104,15 @@ export interface RunWorkerResult {
102
104
  * elapsed when the child never produced output.
103
105
  */
104
106
  workMs: number;
107
+ /**
108
+ * How many GROUNDING retrieval tool calls the FINAL attempt made — the calls
109
+ * that returned content an APIS entry could be cited from (see
110
+ * isGroundingRetrieval; `ls`/`find` excluded on purpose). Counted over the
111
+ * attempt that produced `text`, not summed across restarts (a restarted
112
+ * attempt discards its predecessor's calls along with its text). Zero here on
113
+ * a non-empty section means every symbol in it came from parametric memory.
114
+ */
115
+ groundingRetrievalCount: number;
105
116
  /**
106
117
  * Set when the worker exhausted its re-prompts still leaking a tool call as
107
118
  * text (wrong dialect, never executed). The caller must treat this as a
@@ -15,6 +15,29 @@ import { streamStallHint } from '../shared/stream-watchdog.js';
15
15
  // model starts producing — making waitMs the real queue/cold-start cost and
16
16
  // workMs the real generation+tool-call cost.
17
17
  const DEFAULT_TOOLS = 'read,grep,find,ls';
18
+ /**
19
+ * Tool calls that can GROUND an APIS claim — i.e. return content a signature or
20
+ * command could be cited from. `pi-worker-docs` (the primary), `read` and `grep`
21
+ * (project source), and the web escalations `pi-worker-search`/`pi-worker-fetch`.
22
+ *
23
+ * `ls` and `find` are deliberately EXCLUDED: they return file/directory NAMES,
24
+ * and APIS owns symbols by name only, never paths (RESEARCH_APIS_PROMPT). Bare
25
+ * enumeration cannot verify a signature, so a worker that fabricates its section
26
+ * from memory does not launder itself grounded by calling `ls` once. That
27
+ * exclusion is the anti-gaming property of any gate built on this count: "one
28
+ * trivial `ls` then fabricate the rest" leaves groundingRetrievalCount at 0.
29
+ */
30
+ const GROUNDING_RETRIEVAL_TOOLS = new Set([
31
+ 'pi-worker-docs',
32
+ 'read',
33
+ 'grep',
34
+ 'pi-worker-search',
35
+ 'pi-worker-fetch'
36
+ ]);
37
+ /** True when a tool call retrieves content an APIS entry could be grounded in. */
38
+ export function isGroundingRetrieval(toolName) {
39
+ return GROUNDING_RETRIEVAL_TOOLS.has(toolName);
40
+ }
18
41
  /**
19
42
  * Hard wall-clock bound on a single research worker run (one spawn). The
20
43
  * exact-match LoopDetector only catches *identical* repeated tool calls; a model
@@ -190,6 +213,10 @@ export async function runWorker(input) {
190
213
  // SIGTERM that kill produces would surface as a bare non-zero exit the
191
214
  // caller couldn't distinguish from a crash.
192
215
  let loopHit;
216
+ // Reset EACH attempt: on a restart the previous attempt's calls are
217
+ // discarded with its text, so the count must describe only the attempt
218
+ // whose text this call returns.
219
+ let groundingRetrievalCount = 0;
193
220
  const timeout = workerTimeout(input.signal, timeoutMs);
194
221
  // Per-tool-call watchdog for this attempt (null when off). Its abort is
195
222
  // OR'd with the worker timeout / external cancel into the child's signal.
@@ -214,6 +241,8 @@ export async function runWorker(input) {
214
241
  onFirstByte: () => (tFirstByte = Date.now()),
215
242
  onToolCall: call => {
216
243
  cmdWatch?.onStart(call);
244
+ if (isGroundingRetrieval(call.name))
245
+ groundingRetrievalCount++;
217
246
  if (!loopDetector)
218
247
  return null;
219
248
  const hit = loopDetector.record(call);
@@ -302,6 +331,7 @@ export async function runWorker(input) {
302
331
  aborted: result.aborted,
303
332
  waitMs,
304
333
  workMs,
334
+ groundingRetrievalCount,
305
335
  ...(leaked ? { leakedToolCall: leaked } : {}),
306
336
  ...(loopHit ? { loopHit } : {}),
307
337
  ...(timedOut ? { timedOut: true } : {}),
@@ -5,6 +5,15 @@ 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
7
  import { type SpawnFn } from '../shared/child-process.js';
8
+ /**
9
+ * Pull `@see {@link https://…}` pointers out of retrieved .d.ts/README text.
10
+ *
11
+ * F-2(d): the answer to a type-only lookup usually is not in the package at all — it lives
12
+ * at the `@see` URL that the very excerpt being returned already carries. In run 15,
13
+ * hono.dev appeared in cache values ONLY inside these JSDoc links, and was never fetched.
14
+ * Surfacing the link is therefore free: the pointer is already in hand.
15
+ */
16
+ export declare function extractSeeUrls(content: string): string[];
8
17
  /**
9
18
  * The package NAME a module specifier belongs to — `hono/client` → `hono`,
10
19
  * `@scope/name/sub` → `@scope/name`. The cache stores this (not the raw specifier) as an
@@ -1,3 +1,4 @@
1
+ import { spawn as defaultSpawn } from 'node:child_process';
1
2
  import { Type } from '@sinclair/typebox';
2
3
  import { Text } from '@earendil-works/pi-tui';
3
4
  import { openCache as defaultOpenCache } from './docs-cache.js';
@@ -9,6 +10,8 @@ import { childBaseArgs } from '../shared/child-extensions.js';
9
10
  import { parseChildOutput, isExcerptInContent } from '../shared/child-output.js';
10
11
  import { getPiInvocation } from '../shared/pi-invocation.js';
11
12
  import { formatChildFailure, makeWorkerTool } from './shared.js';
13
+ import { isTypeOnlyAnswer } from '../task/type-only-answer.js';
14
+ import { logDocsAnswer } from './typeonly-log.js';
12
15
  import { normalizeQuery } from './research-cache.js';
13
16
  import { projectDocsRaw, buildProjectPrompt } from './docs-project.js';
14
17
  const childArgs = () => [...childBaseArgs(), '--no-tools'];
@@ -21,6 +24,25 @@ const Params = Type.Object({
21
24
  description: 'What to extract from the docs. The child pi reads ranked chunks and returns ONLY content answering this.'
22
25
  })
23
26
  });
27
+ /**
28
+ * Pull `@see {@link https://…}` pointers out of retrieved .d.ts/README text.
29
+ *
30
+ * F-2(d): the answer to a type-only lookup usually is not in the package at all — it lives
31
+ * at the `@see` URL that the very excerpt being returned already carries. In run 15,
32
+ * hono.dev appeared in cache values ONLY inside these JSDoc links, and was never fetched.
33
+ * Surfacing the link is therefore free: the pointer is already in hand.
34
+ */
35
+ export function extractSeeUrls(content) {
36
+ const out = [];
37
+ const re = /@see\s*\{?\s*@?link\s+(https?:\/\/[^}\s)]+)/gi;
38
+ let m;
39
+ while ((m = re.exec(content)) !== null) {
40
+ const url = m[1].replace(/[.,;]+$/, '');
41
+ if (!out.includes(url))
42
+ out.push(url);
43
+ }
44
+ return out;
45
+ }
24
46
  /**
25
47
  * The package NAME a module specifier belongs to — `hono/client` → `hono`,
26
48
  * `@scope/name/sub` → `@scope/name`. The cache stores this (not the raw specifier) as an
@@ -72,10 +94,13 @@ export function registerPiWorkerDocs(pi, internals = {}) {
72
94
  + '- You need docs for a specific newer version than what is installed — use pi-worker-fetch on the upstream docs site',
73
95
  parameters: Params,
74
96
  async run(params, signal, ctx) {
75
- const spawn = internals.spawn
76
- ?? (globalThis.Bun !== undefined ?
77
- globalThis.Bun.spawn
78
- : (await import('node:child_process')).spawn);
97
+ // Always node:child_process spawn (matching fetch-core and every other
98
+ // worker). The former globalThis.Bun branch called Bun.spawn — whose signature
99
+ // is Bun.spawn([cmd, ...args], opts), NOT the node (cmd, args, opts) that
100
+ // runChild/SpawnFn require — so it threw "cmd must be an array" whenever it ran.
101
+ // It was DEAD in production (pi runs under node) and BYPASSED under bun test
102
+ // (internals.spawn is always injected), i.e. untested, unreachable, and wrong.
103
+ const spawn = internals.spawn ?? defaultSpawn;
79
104
  // ── Project source lookup ───────────────────────────────────────
80
105
  if (params.module === '.') {
81
106
  const openCache = internals.openCache ?? defaultOpenCache;
@@ -138,6 +163,27 @@ export function registerPiWorkerDocs(pi, internals = {}) {
138
163
  entryDts: null,
139
164
  readme: null
140
165
  }, parsed, verified);
166
+ // SAME instrumentation channel as the package path below, extended to the
167
+ // project-source branch because that branch is the MAJORITY of what
168
+ // worker:apis asks — 13 of 17 docs calls in run 15's fatal task, 7 of 12 in
169
+ // the first live diagnostic rep. With only the package path recorded, "the
170
+ // last docs answer before the worker stopped" was unanswerable: the sink's
171
+ // last row was routinely not the worker's last answer.
172
+ //
173
+ // `typeOnly` is recorded FALSE with an explicit reason rather than by running
174
+ // the detector: this path never applies it, and the record must say what the
175
+ // shipped tool decided, not what it would have decided. Inventing a verdict
176
+ // here would let a firing-rate computed off this sink count answers the lever
177
+ // does not reach.
178
+ logDocsAnswer({
179
+ module: params.module,
180
+ query: params.query,
181
+ answer: parsed.answer,
182
+ typeOnly: false,
183
+ reason: 'project-source lookup — the type-only detector is not applied here',
184
+ excerptVerified: verified,
185
+ toolText: text
186
+ });
141
187
  return {
142
188
  text,
143
189
  details: {
@@ -233,13 +279,66 @@ export function registerPiWorkerDocs(pi, internals = {}) {
233
279
  }
234
280
  const parsed = parseChildOutput(child.stdout);
235
281
  const verified = parsed.excerpt ? isExcerptInContent(parsed.excerpt, concatenated) : undefined;
236
- const text = versionBanner + npmHeader + formatResultText(pkg, parsed, verified);
282
+ const body = formatResultText(pkg, parsed, verified);
283
+ // F-2: a TYPE-ONLY answer is the dangerous failure. "unclear from this package"
284
+ // is honest and already escalates; a signature is a well-formed, confident,
285
+ // on-topic answer that names the very parameter asked about, so the worker
286
+ // stops — and worker:context then fills the semantic gap from memory (F-1).
287
+ // Measured: 14 of 17 live reps terminated on exactly this shape.
288
+ //
289
+ // The retrieved type is KEPT (it is real and useful) and an UNANSWERED banner
290
+ // is prepended, naming the gap and — when the excerpt carries one — the `@see`
291
+ // URL that actually documents the semantics. That pointer is free: F-2(d) found
292
+ // hono.dev present in run-15 cache values ONLY inside these JSDoc links, never
293
+ // fetched. Prompting the escalation beats performing it here: this tool runs in
294
+ // parallel execution mode and cannot cleanly spawn a fetch of its own.
295
+ const typeOnly = isTypeOnlyAnswer(parsed.answer, params.query);
296
+ let text = versionBanner + npmHeader + body;
297
+ if (typeOnly.typeOnly) {
298
+ const seeUrls = extractSeeUrls(concatenated);
299
+ text =
300
+ versionBanner
301
+ + npmHeader
302
+ + 'UNANSWERED — TYPE-ONLY: the package gave a declaration, not the '
303
+ + 'usage semantics this question needs. A signature says what the '
304
+ + 'parameter IS, not what it MEANS. Do NOT answer from memory and do '
305
+ + 'NOT treat the type below as the answer.\n'
306
+ + (seeUrls.length > 0 ?
307
+ `NEXT STEP: the retrieved excerpt itself cites documentation — `
308
+ + `fetch ${seeUrls[0]} (pi-worker-fetch) and re-ask this same `
309
+ + `question.\n`
310
+ : 'NEXT STEP: use pi-worker-search / pi-worker-fetch for the official '
311
+ + 'documentation of this API, then re-ask this same question.\n')
312
+ + '\nThe declaration that WAS retrieved (context only, not the answer):\n'
313
+ + body;
314
+ }
315
+ // STAGE 1 INSTRUMENTATION — off unless PI_TASK_TYPEONLY_LOG names a sink, and
316
+ // side-effect only: nothing below reads it, and every failure inside is
317
+ // swallowed. It records EVERY answer, flagged or not, because the open question
318
+ // is a RATE — how often this fires — and a log of firings alone has no
319
+ // denominator. See typeonly-log.ts.
320
+ //
321
+ // It sits AFTER `text` is final (it used to sit above `text`'s first assignment)
322
+ // so the record carries what the worker was actually handed, banner and cited
323
+ // excerpt included, not just the child's prose. Purely a move: logDocsAnswer
324
+ // returns nothing and nothing between the two positions reads it, so the tool's
325
+ // behaviour and its return value are unchanged.
326
+ logDocsAnswer({
327
+ module: params.module,
328
+ query: params.query,
329
+ answer: parsed.answer,
330
+ typeOnly: typeOnly.typeOnly,
331
+ reason: typeOnly.reason,
332
+ excerptVerified: verified,
333
+ toolText: text
334
+ });
237
335
  return {
238
336
  text,
239
337
  details: {
240
338
  ...baseDetails,
241
339
  childExitCode: 0,
242
- excerptVerified: verified
340
+ excerptVerified: verified,
341
+ ...(typeOnly.typeOnly ? { typeOnly: true } : {})
243
342
  }
244
343
  };
245
344
  },
@@ -268,6 +367,19 @@ export function registerPiWorkerDocs(pi, internals = {}) {
268
367
  // Only a completed lookup (child exited 0) is a real answer; not-installed,
269
368
  // no-chunks, resolve/cache errors, and aborts omit childExitCode:0 and fall
270
369
  // through to a live retry next time.
271
- cacheable: d => d.childExitCode === 0
370
+ //
371
+ // F-2(e): process health is NOT answer quality. A child that ran fine and answered
372
+ // "unclear from this package" exits 0, so the NON-ANSWER was memoised and re-served
373
+ // as a cache hit to every later sibling task — 52 of run 15's cached entries were
374
+ // "unclear" with hitCache true. One dead end, paid for many times, and escalation
375
+ // could never re-fire because the miss never recurred. So a non-answer is now never
376
+ // stored: the next task that asks pays for a real lookup and can escalate.
377
+ //
378
+ // `text` is supplied by makeWorkerTool (shared.ts) alongside details, so the
379
+ // content check needs no new plumbing.
380
+ cacheable: (d, text) => d.childExitCode === 0
381
+ && d.typeOnly !== true
382
+ && d.excerptVerified !== false
383
+ && !/unclear from this package/i.test(text)
272
384
  });
273
385
  }
@@ -59,7 +59,9 @@ export function registerPiWorkerFetch(pi, internals = {}) {
59
59
  childExitCode: 0,
60
60
  answer: result.answer,
61
61
  excerpt: result.excerpt,
62
- excerptVerified: result.excerptVerified
62
+ excerptVerified: result.excerptVerified,
63
+ coverageMiss: result.coverageMiss,
64
+ anchoredSection: result.anchoredSection
63
65
  }
64
66
  };
65
67
  }
@@ -0,0 +1,45 @@
1
+ /** Env var naming the JSONL sink. Unset (or empty) ⇒ instrumentation is entirely off. */
2
+ export declare const TYPEONLY_LOG_ENV = "PI_TASK_TYPEONLY_LOG";
3
+ /** One pi-worker-docs answer, as observed at the tool layer. */
4
+ export interface TypeOnlyLogRecord {
5
+ /** ISO timestamp — lets records be attributed to a rep/phase window after the fact. */
6
+ at: string;
7
+ /** The `module` param: a package specifier, or "." for project source. */
8
+ module: string;
9
+ /** The query verbatim as the worker asked it (NOT the lowercased cache key). */
10
+ query: string;
11
+ /** The child's `<answer>` prose — retained so every counter can be re-scored offline. */
12
+ answer: string;
13
+ /** The shipped detector's verdict for (answer, query). */
14
+ typeOnly: boolean;
15
+ /** The detector's reason string — names which gate decided, for auditing precision. */
16
+ reason: string;
17
+ /** True when the answer is the explicit "unclear from this package" non-answer. */
18
+ unclear: boolean;
19
+ /** child-output's excerpt check; undefined when the child cited no excerpt. */
20
+ excerptVerified?: boolean;
21
+ /**
22
+ * The tool's ENTIRE return text — version banner, npm header, the answer prose, the cited
23
+ * excerpt, and (when it fires) the type-only banner. Optional so logs written before this
24
+ * field existed still parse.
25
+ *
26
+ * WHY IT IS NOT REDUNDANT WITH `answer`. `answer` is the child's prose only. The worker
27
+ * receives strictly more than that, and the extra part is where the SYMBOL NAMES live: the
28
+ * cited `.d.ts` excerpt. Asking "did the worker write an APIS entry it had actually looked
29
+ * up, or one it produced from memory" is a substring question against what the worker was
30
+ * GIVEN, and answering it off `answer` alone would score a symbol that appeared verbatim in
31
+ * the retrieved declaration as ungrounded. That would inflate the very counter it is meant
32
+ * to measure. Recording the full text makes the grounding test conservative in the
33
+ * direction that cannot manufacture a finding.
34
+ */
35
+ toolText?: string;
36
+ }
37
+ /**
38
+ * Append one record to the JSONL sink named by `PI_TASK_TYPEONLY_LOG`, if set.
39
+ *
40
+ * @param rec everything but `at` and `unclear`, which are derived here so every call site
41
+ * stamps them identically.
42
+ */
43
+ export declare function logDocsAnswer(rec: Omit<TypeOnlyLogRecord, 'at' | 'unclear'>, getEnv?: (k: string) => string | undefined): void;
44
+ /** Parse a sink written by {@link logDocsAnswer}; malformed lines are skipped, not thrown. */
45
+ export declare function readTypeOnlyLog(text: string): TypeOnlyLogRecord[];
@@ -0,0 +1,88 @@
1
+ /**
2
+ * STAGE 1 INSTRUMENTATION for F-2 / PROMPT 2 — firing-rate observability, no behaviour.
3
+ *
4
+ * WHY THIS EXISTS. PROMPT 2's live A/B measured 82% baseline vs 91% treatment, p = 0.88,
5
+ * and was written off as "the lever does not work". It was a broken EXPERIMENT, and it was
6
+ * broken for a reason this module fixes: the metric counted ALL research terminations while
7
+ * the lever touches only TYPE-ONLY answers, and nothing anywhere recorded how often a
8
+ * type-only answer actually occurs. The causal claim "workers stop BECAUSE of type-only
9
+ * answers" was asserted from one static corpus (1 flag in 150 recorded answers, 0.7%) and
10
+ * never measured live. You cannot size an arm, pick a metric, or decide whether the lever is
11
+ * a population fix or a single-case guard without that firing rate.
12
+ *
13
+ * WHAT IT DOES. When `PI_TASK_TYPEONLY_LOG` names a file, every pi-worker-docs answer — not
14
+ * only the flagged ones — appends one JSON line there. Denominator and numerator both, from
15
+ * the same channel, so a rate is computable rather than inferable. With the variable unset
16
+ * this is a no-op and nothing is written.
17
+ *
18
+ * MECHANICAL, NOT SELF-REPORT. The record is written at the TOOL layer from the verdict the
19
+ * shipped detector just returned, next to the same `details` the caller receives. No model is
20
+ * asked whether it thought the answer was a type signature.
21
+ *
22
+ * BEHAVIOUR-NEUTRALITY IS THE WHOLE POINT — this lands in a stage that forbids src/ behaviour
23
+ * change, so:
24
+ * - it is called for its side effect only; nothing reads its return value;
25
+ * - every failure is swallowed (a full disk or an unwritable path must not turn a working
26
+ * docs lookup into an error — instrumentation that can break the thing it measures is
27
+ * worse than no instrumentation);
28
+ * - it appends synchronously, because the process that writes it (a pi child running the
29
+ * docs extension) can exit immediately after the tool returns and a queued async write
30
+ * would be lost.
31
+ *
32
+ * THE FULL ANSWER TEXT IS RETAINED, deliberately. Run 15's audit could not decide F-3(f) —
33
+ * whether an `excerptVerified === false` was fabrication or a normaliser gap — because the
34
+ * text it judged was kept nowhere. The same hole would make every stability question here
35
+ * unanswerable: whether a question is type-only in EVERY rep or churns between identical
36
+ * reps can only be settled by re-scoring the recorded answers. Cheap to keep, impossible to
37
+ * reconstruct later.
38
+ */
39
+ import * as fs from 'node:fs';
40
+ /** Env var naming the JSONL sink. Unset (or empty) ⇒ instrumentation is entirely off. */
41
+ export const TYPEONLY_LOG_ENV = 'PI_TASK_TYPEONLY_LOG';
42
+ /**
43
+ * The honest non-answer, in BOTH wordings the tool can emit. A package lookup is told to
44
+ * write "unclear from this package" (docs-core.ts:622); a project-source lookup is told
45
+ * "unclear from this project" (docs-project.ts:310). Matching only the first silently scored
46
+ * every project-source abstention as a valid answer — and project-source is the MAJORITY of
47
+ * what worker:apis asks (13 of 17 calls in run 15's fatal task), so that one missing word
48
+ * would have put the wrong denominator under the whole termination diagnostic.
49
+ */
50
+ const UNCLEAR = /unclear from this (package|project)/i;
51
+ /**
52
+ * Append one record to the JSONL sink named by `PI_TASK_TYPEONLY_LOG`, if set.
53
+ *
54
+ * @param rec everything but `at` and `unclear`, which are derived here so every call site
55
+ * stamps them identically.
56
+ */
57
+ export function logDocsAnswer(rec, getEnv = k => process.env[k]) {
58
+ const sink = getEnv(TYPEONLY_LOG_ENV);
59
+ if (!sink || sink.trim().length === 0)
60
+ return;
61
+ const full = {
62
+ at: new Date().toISOString(),
63
+ ...rec,
64
+ unclear: UNCLEAR.test(rec.answer)
65
+ };
66
+ try {
67
+ fs.appendFileSync(sink, `${JSON.stringify(full)}\n`, 'utf8');
68
+ }
69
+ catch {
70
+ // Deliberately silent. This is a measurement side-channel; a docs lookup that
71
+ // succeeded must not be reported as failed because the sink was unwritable.
72
+ }
73
+ }
74
+ /** Parse a sink written by {@link logDocsAnswer}; malformed lines are skipped, not thrown. */
75
+ export function readTypeOnlyLog(text) {
76
+ const out = [];
77
+ for (const line of text.split('\n')) {
78
+ if (line.trim().length === 0)
79
+ continue;
80
+ try {
81
+ out.push(JSON.parse(line));
82
+ }
83
+ catch {
84
+ // A truncated final line is normal when a process is killed mid-append.
85
+ }
86
+ }
87
+ return out;
88
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.18.49",
3
+ "version": "0.18.50",
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",