@mjasnikovs/pi-task 0.18.49 → 0.18.51

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.
@@ -67,7 +67,9 @@ export function registerRemote(pi) {
67
67
  bridge.currentCtx = makeShimmedCtx(ctx);
68
68
  }
69
69
  if (getConfig().remote) {
70
- void ensureServer().catch(err => ctx.ui.notify(`Failed to start remote: ${err.message}`, 'error'));
70
+ // Optional feature: a bind failure must never take pi down. Degrade
71
+ // to a one-line warning and keep the agent running without remote.
72
+ void ensureServer().catch(err => ctx.ui.notify(`Remote UI unavailable: ${err.message}`, 'warning'));
71
73
  }
72
74
  });
73
75
  pi.on('session_shutdown', (event, _ctx) => {
@@ -158,7 +160,7 @@ export function registerRemote(pi) {
158
160
  ctx.ui.notify(`Remote running at ${primaryUrl}`, 'info');
159
161
  }
160
162
  catch (err) {
161
- ctx.ui.notify(`Failed to start remote: ${err.message}`, 'error');
163
+ ctx.ui.notify(`Remote UI unavailable: ${err.message}`, 'error');
162
164
  }
163
165
  }
164
166
  });
@@ -28,5 +28,19 @@ export declare function getLocalIPs(nets?: NodeJS.Dict<import("node:os").Network
28
28
  * Tailscale line uses the MagicDNS host when known (resolves to the same node,
29
29
  * but is what SSH and webpush certs need), falling back to the raw IP. */
30
30
  export declare function formatAddresses(ips: LocalIPs, port: number, tsHost?: string): AddressLine[];
31
+ /** Bind the REAL `server` to the first free port at or above `start`, trying up
32
+ * to `max` consecutive ports. On EADDRINUSE we bump the port and re-listen; any
33
+ * other error (e.g. EACCES), or exhausting the range, REJECTS the promise —
34
+ * never throws uncaught.
35
+ *
36
+ * Binding the real server directly (rather than probing a throwaway socket with
37
+ * createServer()/listen()/close() first, then binding the real one) removes a
38
+ * TOCTOU race: between "probe says port free" and "real listen", the port can be
39
+ * taken by someone else, and on Windows/Bun the PROBE socket's own port isn't
40
+ * fully released before the real listen runs — so the real bind hits EADDRINUSE
41
+ * on the very port that just tested free, and (with no 'error' listener on the
42
+ * real server) escapes as an uncaughtException that crashes pi (issue #7).
43
+ * Retrying the real bind has no probe and no window. */
44
+ export declare function listenWithRetry(server: import('node:http').Server, start: number, max: number): Promise<number>;
31
45
  export declare function startServer(onMessage: MessageCallback, getHtml: (wsUrl: string) => string, onInterrupt?: () => void): Promise<ServerHandle>;
32
46
  export {};
@@ -48,27 +48,60 @@ export function formatAddresses(ips, port, tsHost) {
48
48
  out.push({ label: '', url: `http://${ips.primary}:${port}` });
49
49
  return out;
50
50
  }
51
- async function tryBind(port) {
52
- return new Promise(resolve => {
53
- const s = createServer();
54
- s.listen(port, '0.0.0.0', () => {
55
- s.close(() => resolve(true));
56
- });
57
- s.on('error', () => resolve(false));
51
+ /** Bind the REAL `server` to the first free port at or above `start`, trying up
52
+ * to `max` consecutive ports. On EADDRINUSE we bump the port and re-listen; any
53
+ * other error (e.g. EACCES), or exhausting the range, REJECTS the promise —
54
+ * never throws uncaught.
55
+ *
56
+ * Binding the real server directly (rather than probing a throwaway socket with
57
+ * createServer()/listen()/close() first, then binding the real one) removes a
58
+ * TOCTOU race: between "probe says port free" and "real listen", the port can be
59
+ * taken by someone else, and on Windows/Bun the PROBE socket's own port isn't
60
+ * fully released before the real listen runs — so the real bind hits EADDRINUSE
61
+ * on the very port that just tested free, and (with no 'error' listener on the
62
+ * real server) escapes as an uncaughtException that crashes pi (issue #7).
63
+ * Retrying the real bind has no probe and no window. */
64
+ export function listenWithRetry(server, start, max) {
65
+ return new Promise((resolve, reject) => {
66
+ let port = start;
67
+ // Persistent 'listening'/'error' listeners (not one-shot listen(cb)):
68
+ // under Bun, a listen(port, host, cb) callback from a FAILED first bind
69
+ // is NOT carried over to a later listen() retry, so it never fires — the
70
+ // retry silently hangs. Registering both via .on() and re-calling
71
+ // listen(port) with no callback routes each attempt's outcome correctly
72
+ // on both Bun and Node.
73
+ const cleanup = () => {
74
+ server.removeListener('error', onError);
75
+ server.removeListener('listening', onListening);
76
+ };
77
+ const onListening = () => {
78
+ cleanup();
79
+ resolve(port);
80
+ };
81
+ const onError = (err) => {
82
+ if (err.code === 'EADDRINUSE' && port < start + max - 1) {
83
+ port++;
84
+ server.listen(port, '0.0.0.0');
85
+ return;
86
+ }
87
+ cleanup();
88
+ reject(err.code === 'EADDRINUSE' ?
89
+ new Error(`No free port found in range ${start}–${start + max - 1}`)
90
+ : err);
91
+ };
92
+ server.on('error', onError);
93
+ server.on('listening', onListening);
94
+ server.listen(port, '0.0.0.0');
58
95
  });
59
96
  }
60
- async function findPort(start, max) {
61
- for (let p = start; p < start + max; p++) {
62
- if (await tryBind(p))
63
- return p;
64
- }
65
- throw new Error(`No free port found in range ${start}–${start + max - 1}`);
66
- }
67
97
  export async function startServer(onMessage, getHtml, onInterrupt) {
68
- const port = await findPort(8800, 100);
69
98
  const ips = getLocalIPs();
70
99
  const ip = ips.primary;
71
- const wsUrl = `ws://${ip}:${port}/ws`;
100
+ // The bound port isn't known until listenWithRetry succeeds, and wsUrl
101
+ // depends on it. The request handler only ever runs once the server is
102
+ // listening (real client I/O, long after we set wsUrl below), so reading it
103
+ // lazily from this closure variable is safe.
104
+ let wsUrl = '';
72
105
  const httpServer = createServer((req, res) => {
73
106
  if (req.method === 'GET' && (req.url === '/' || req.url === '')) {
74
107
  const body = getHtml(wsUrl);
@@ -108,7 +141,6 @@ export async function startServer(onMessage, getHtml, onInterrupt) {
108
141
  res.end('Not found');
109
142
  }
110
143
  });
111
- const wss = new WebSocketServer({ server: httpServer, path: '/ws' });
112
144
  // Track every accepted TCP socket so stop() can forcibly destroy lingering
113
145
  // keep-alive / WebSocket connections. Without this, httpServer.close() only
114
146
  // stops accepting new connections and waits for existing ones to drain — an
@@ -124,6 +156,18 @@ export async function startServer(onMessage, getHtml, onInterrupt) {
124
156
  sockets.add(s);
125
157
  s.on('close', () => sockets.delete(s));
126
158
  });
159
+ // Bind the real server now, retrying past any in-use ports. A bind failure
160
+ // REJECTS (see listenWithRetry) — register.ts's callers catch it and let pi
161
+ // continue without the remote UI; the remote server is optional.
162
+ const port = await listenWithRetry(httpServer, 8800, 100);
163
+ wsUrl = `ws://${ip}:${port}/ws`;
164
+ // Attach the WebSocket server only AFTER the http server is bound. ws adds an
165
+ // 'error' listener to the http server that re-emits on the WebSocketServer
166
+ // (which has no error listener) — so if it were attached during the bind, an
167
+ // EADDRINUSE on the first port would be forwarded to wss and thrown as an
168
+ // uncaughtException, crashing pi even though listenWithRetry handled it. ws
169
+ // works fine on an already-listening server.
170
+ const wss = new WebSocketServer({ server: httpServer, path: '/ws' });
127
171
  const handle = {
128
172
  port,
129
173
  ip,
@@ -177,6 +221,5 @@ export async function startServer(onMessage, getHtml, onInterrupt) {
177
221
  removeClient(ws);
178
222
  });
179
223
  });
180
- await new Promise(resolve => httpServer.listen(port, '0.0.0.0', resolve));
181
224
  return handle;
182
225
  }
@@ -1,10 +1,3 @@
1
- /**
2
- * Shared utilities for parsing and formatting child pi output.
3
- *
4
- * Used by both fetch-core (web page extraction) and docs-core (npm package
5
- * docs extraction). The child pi outputs <answer> and <excerpt> XML tags;
6
- * these functions parse, verify, and format the result.
7
- */
8
1
  export declare function parseChildOutput(stdout: string): {
9
2
  answer: string;
10
3
  excerpt?: string;
@@ -13,6 +6,27 @@ export declare function normaliseWhitespace(s: string): string;
13
6
  /** Check whether an excerpt appears verbatim in the source content
14
7
  * (whitespace-normalised). Returns false for empty excerpts. */
15
8
  export declare function isExcerptInContent(excerpt: string, content: string): boolean;
9
+ /**
10
+ * The same verdict as {@link isExcerptInContent}, PLUS a retained record of what was
11
+ * actually checked — the whitespace-normalised excerpt and a hash+length of the normalised
12
+ * content it was searched in. This is PROMPT-3 item 4: make an `excerptVerified === false`
13
+ * DIAGNOSABLE after the fact, so it can be attributed to fabrication (the excerpt is nowhere
14
+ * near the content) versus a normaliser gap (it is a markdown-escape or entity variant of
15
+ * text that IS present) WITHOUT re-fetching. It deliberately does NOT loosen the verifier:
16
+ * `.verified` is identical to `isExcerptInContent`. F-3(f) — whether the normaliser needs
17
+ * markdown-escape handling — is left unproven on purpose; you decide that from the retained
18
+ * evidence, not by weakening the one working hallucination detector first.
19
+ */
20
+ export interface ExcerptVerification {
21
+ verified: boolean;
22
+ /** sha256 of the whitespace-normalised content the excerpt was checked against. */
23
+ contentSha256: string;
24
+ /** Length of that normalised content, so a short/empty page is visible at a glance. */
25
+ contentLength: number;
26
+ /** The whitespace-normalised excerpt that was searched for. */
27
+ normalisedExcerpt: string;
28
+ }
29
+ export declare function verifyExcerpt(excerpt: string, content: string): ExcerptVerification;
16
30
  /** Format the child's parsed output with a header and optional excerpt block.
17
31
  * When `verified === false` a warning is prepended. */
18
32
  export declare function formatResultText(header: string, parsed: {
@@ -5,6 +5,7 @@
5
5
  * docs extraction). The child pi outputs <answer> and <excerpt> XML tags;
6
6
  * these functions parse, verify, and format the result.
7
7
  */
8
+ import { createHash } from 'node:crypto';
8
9
  export function parseChildOutput(stdout) {
9
10
  const trimmed = stdout.trim();
10
11
  const answerMatch = /<answer>([\s\S]*?)<\/answer>/i.exec(trimmed);
@@ -26,6 +27,16 @@ export function isExcerptInContent(excerpt, content) {
26
27
  return false;
27
28
  return normaliseWhitespace(content).includes(normaliseWhitespace(excerpt));
28
29
  }
30
+ export function verifyExcerpt(excerpt, content) {
31
+ const nc = normaliseWhitespace(content);
32
+ const ne = normaliseWhitespace(excerpt);
33
+ return {
34
+ verified: ne.length > 0 && nc.includes(ne),
35
+ contentSha256: createHash('sha256').update(nc).digest('hex'),
36
+ contentLength: nc.length,
37
+ normalisedExcerpt: ne
38
+ };
39
+ }
29
40
  /** Format the child's parsed output with a header and optional excerpt block.
30
41
  * When `verified === false` a warning is prepended. */
31
42
  export function formatResultText(header, parsed, verified) {
@@ -0,0 +1,67 @@
1
+ /**
2
+ * STAGE 2 LEVER — the APIS OUTPUT CONTRACT.
3
+ *
4
+ * ── WHAT STAGE 1 MEASURED, AND WHY THIS IS THE ONLY VARIABLE LEFT ─────────────────────────
5
+ *
6
+ * worker:apis terminates when every entry on its output list has a SIGNATURE. That is not an
7
+ * inference; it is what 16 live reps say (commit 807ffad, raw data ~/tmp/apis-stopping-point):
8
+ * - it does not stop at a budget — docs calls/rep mean 8.5, sd 3.8, cv 0.45, range 3-14;
9
+ * - it does not stop because it is circling — near-repeats 2/45 = 4.4% over the last three
10
+ * calls, LOWER than the 10.2% whole-trajectory rate;
11
+ * - it does not stop because nothing new is arriving — the FINAL answer of a rep still
12
+ * returns 39 symbols no earlier answer in that rep carried;
13
+ * - it does not stop with holes in its own output — ungrounded symbols 2/588 = 0.3%,
14
+ * strict open-gap entries 2/277 = 0.7%.
15
+ * What it does instead: 95.3% of its lookups reach the emitted section and 72.2% of emitted
16
+ * entries were themselves asked about. The trajectory and the output are the SAME LIST. And
17
+ * the list's format is `<name> <one-line signature or use>` — so of 61 package queries across
18
+ * 15 reps, 52 (85.2%) are signature questions and THREE are behaviour questions.
19
+ *
20
+ * The worker stops because the artifact it was asked for is complete by the standard its
21
+ * format sets, and a signature satisfies that standard. "What does this parameter MEAN" is not
22
+ * a field of the thing it is building, so nothing in its output is ever left unfilled by not
23
+ * asking it. That is why it never escalates: escalation answers a question it has no reason to
24
+ * ask.
25
+ *
26
+ * ── WHY THIS BLOCK AND NOT A THIRD INSTRUCTION ────────────────────────────────────────────
27
+ *
28
+ * Two levers have already failed against this seam, and they failed for the same reason:
29
+ * PROMPT 2 conditioned on RECOGNISING an answer as inadequate (type-only). Reach 9/1680 =
30
+ * 0.54% of answers. But a type signature is not an inadequate answer to the
31
+ * question actually being asked — it is exactly the requested field.
32
+ * PROMPT 4 conditioned on nothing at all, and pointed the worker at the exact page that
33
+ * would have prevented the fatal bug. 2/20 vs 3/20, Fisher p = 0.50, with delivery
34
+ * of the block into the assembled prompt PROVEN separately. A pointer only helps a
35
+ * worker that has an unmet slot to fill.
36
+ * Both acted on the ANSWER side or the TARGET side. This one adds a FIELD A SIGNATURE CANNOT
37
+ * FILL, which is the variable that is not flat.
38
+ *
39
+ * ── STEP 3 OF THE FALLBACK IS LOAD-BEARING. DO NOT "CLOSE" IT ─────────────────────────────
40
+ *
41
+ * The `UNVERIFIED:` escape is mandatory and is not a loophole. Forbidding abstention is
42
+ * precisely how F-1 manufactured the confident wrong claim that killed run 15: worker:context,
43
+ * holding one true citable fact (the pinned hono version), fused it with an uncheckable one
44
+ * (what `hc`'s base URL means) under a single attribution — `hc<AppType>('/api')` — and every
45
+ * request in the shipped product went to /api/api/… and 404'd. A lever that buys behaviour
46
+ * questions with fabrication is a FAIL, not a win. The A/B asserts it: excerptVerified===false
47
+ * and the ungrounded-symbol rate must not rise.
48
+ *
49
+ * Exported unwired first, wired into RESEARCH_APIS_PROMPT in the same series; the STEP A
50
+ * feasibility probe splices this exact text into a patched dist so the probe and the shipped
51
+ * lever can never drift apart.
52
+ */
53
+ /**
54
+ * The extra output-contract clause for RESEARCH_APIS_PROMPT.
55
+ *
56
+ * Wording notes, because each of these is answering something measured:
57
+ * - "NOT DONE WHEN THEY HAVE A SIGNATURE" is the whole lever. Stage 1's mechanism is
58
+ * completion-by-format, so the change has to move the completion bar, not add advice.
59
+ * - the worked example is the run-15 fatal case verbatim (`hc(baseUrl: Prefix, …)`), because
60
+ * a rule without an instance of what does NOT satisfy it reads as satisfied by anything.
61
+ * - step 2 says escalation is EXPECTED rather than permitted: bundled .d.ts files genuinely
62
+ * do not carry semantics, and a worker that reads "you may escalate" has been told nothing
63
+ * it did not already have (PROMPT 4 measured what permission alone achieves: nothing).
64
+ * - step 3 is stated as CORRECT and REQUIRED, in those words, so the field cannot be closed
65
+ * by guessing. See the header.
66
+ */
67
+ export declare const APIS_SEMANTICS_CONTRACT = "THIRD-PARTY PACKAGE ENTRIES ARE NOT DONE WHEN THEY HAVE A SIGNATURE. For every entry whose symbol comes from a third-party npm package \u2014 not this project's own source, not a runtime builtin \u2014 the line carries a SECOND field saying what the thing MEANS in use: what one of its arguments stands for, what it defaults to, what a path/URL/prefix it is handed is relative to, or what its return value actually is. Format:\n <name> <one-line signature or use> \u2014 SEMANTICS: <what it means in use>\n\nA TYPE SIGNATURE IS NOT A SEMANTICS CLAUSE, and restating one in prose does not make it one. `hc(baseUrl: Prefix, options?: ClientRequestOptions)` names the argument and says nothing about whether that argument is an origin, or a mount prefix, or how it is joined to each route path \u2014 which is the fact the implementing agent actually needs, and the one it will otherwise guess wrong. An entry whose SEMANTICS field is missing is UNFINISHED, and your section is not ready to emit while any package entry is unfinished.\n\nHOW TO FILL THAT FIELD \u2014 in this order. Do not skip a step, and do not stop after step 1 because you already hold the declaration:\n 1. ASK `pi-worker-docs` A BEHAVIOUR QUESTION about that package. NOT \"what is X's signature\", NOT \"what types does X export\" \u2014 those return the declaration you already have. Ask what an argument MEANS, what it DEFAULTS to, what it is RELATIVE to, what HAPPENS when it is given a particular value. For example: `pi-worker-docs(\"hono/client\", \"what does the baseUrl argument to hc MEAN \u2014 an origin or a mount prefix \u2014 and how is it joined to each route path?\")`.\n 2. IF THE PACKAGE TEXT DOES NOT ANSWER IT, ESCALATE. Expect this: bundled `.d.ts` declarations frequently carry no semantics at all, because the semantics live in the package's documentation. Call `pi-worker-search` with the question, or `pi-worker-fetch` on a documentation URL \u2014 including any `@see {@link https://\u2026}` link that appeared in the text `pi-worker-docs` just returned to you.\n 3. ONLY IF BOTH FAIL, WRITE THE OPEN QUESTION DOWN, in this exact form:\n <name> <signature> \u2014 SEMANTICS: UNVERIFIED: <the exact question you could not answer>\n THIS IS A CORRECT AND REQUIRED OUTCOME, not a failure. A named open question is worth far more to the implementing agent than a confident guess, and it is the only acceptable way to finish an entry you could not verify. NEVER fill this field from memory, from what the symbol is named, or from what the API \"obviously\" does: a plausible wrong semantics clause is the single most damaging thing this section can carry.";
@@ -0,0 +1,77 @@
1
+ /**
2
+ * STAGE 2 LEVER — the APIS OUTPUT CONTRACT.
3
+ *
4
+ * ── WHAT STAGE 1 MEASURED, AND WHY THIS IS THE ONLY VARIABLE LEFT ─────────────────────────
5
+ *
6
+ * worker:apis terminates when every entry on its output list has a SIGNATURE. That is not an
7
+ * inference; it is what 16 live reps say (commit 807ffad, raw data ~/tmp/apis-stopping-point):
8
+ * - it does not stop at a budget — docs calls/rep mean 8.5, sd 3.8, cv 0.45, range 3-14;
9
+ * - it does not stop because it is circling — near-repeats 2/45 = 4.4% over the last three
10
+ * calls, LOWER than the 10.2% whole-trajectory rate;
11
+ * - it does not stop because nothing new is arriving — the FINAL answer of a rep still
12
+ * returns 39 symbols no earlier answer in that rep carried;
13
+ * - it does not stop with holes in its own output — ungrounded symbols 2/588 = 0.3%,
14
+ * strict open-gap entries 2/277 = 0.7%.
15
+ * What it does instead: 95.3% of its lookups reach the emitted section and 72.2% of emitted
16
+ * entries were themselves asked about. The trajectory and the output are the SAME LIST. And
17
+ * the list's format is `<name> <one-line signature or use>` — so of 61 package queries across
18
+ * 15 reps, 52 (85.2%) are signature questions and THREE are behaviour questions.
19
+ *
20
+ * The worker stops because the artifact it was asked for is complete by the standard its
21
+ * format sets, and a signature satisfies that standard. "What does this parameter MEAN" is not
22
+ * a field of the thing it is building, so nothing in its output is ever left unfilled by not
23
+ * asking it. That is why it never escalates: escalation answers a question it has no reason to
24
+ * ask.
25
+ *
26
+ * ── WHY THIS BLOCK AND NOT A THIRD INSTRUCTION ────────────────────────────────────────────
27
+ *
28
+ * Two levers have already failed against this seam, and they failed for the same reason:
29
+ * PROMPT 2 conditioned on RECOGNISING an answer as inadequate (type-only). Reach 9/1680 =
30
+ * 0.54% of answers. But a type signature is not an inadequate answer to the
31
+ * question actually being asked — it is exactly the requested field.
32
+ * PROMPT 4 conditioned on nothing at all, and pointed the worker at the exact page that
33
+ * would have prevented the fatal bug. 2/20 vs 3/20, Fisher p = 0.50, with delivery
34
+ * of the block into the assembled prompt PROVEN separately. A pointer only helps a
35
+ * worker that has an unmet slot to fill.
36
+ * Both acted on the ANSWER side or the TARGET side. This one adds a FIELD A SIGNATURE CANNOT
37
+ * FILL, which is the variable that is not flat.
38
+ *
39
+ * ── STEP 3 OF THE FALLBACK IS LOAD-BEARING. DO NOT "CLOSE" IT ─────────────────────────────
40
+ *
41
+ * The `UNVERIFIED:` escape is mandatory and is not a loophole. Forbidding abstention is
42
+ * precisely how F-1 manufactured the confident wrong claim that killed run 15: worker:context,
43
+ * holding one true citable fact (the pinned hono version), fused it with an uncheckable one
44
+ * (what `hc`'s base URL means) under a single attribution — `hc<AppType>('/api')` — and every
45
+ * request in the shipped product went to /api/api/… and 404'd. A lever that buys behaviour
46
+ * questions with fabrication is a FAIL, not a win. The A/B asserts it: excerptVerified===false
47
+ * and the ungrounded-symbol rate must not rise.
48
+ *
49
+ * Exported unwired first, wired into RESEARCH_APIS_PROMPT in the same series; the STEP A
50
+ * feasibility probe splices this exact text into a patched dist so the probe and the shipped
51
+ * lever can never drift apart.
52
+ */
53
+ /**
54
+ * The extra output-contract clause for RESEARCH_APIS_PROMPT.
55
+ *
56
+ * Wording notes, because each of these is answering something measured:
57
+ * - "NOT DONE WHEN THEY HAVE A SIGNATURE" is the whole lever. Stage 1's mechanism is
58
+ * completion-by-format, so the change has to move the completion bar, not add advice.
59
+ * - the worked example is the run-15 fatal case verbatim (`hc(baseUrl: Prefix, …)`), because
60
+ * a rule without an instance of what does NOT satisfy it reads as satisfied by anything.
61
+ * - step 2 says escalation is EXPECTED rather than permitted: bundled .d.ts files genuinely
62
+ * do not carry semantics, and a worker that reads "you may escalate" has been told nothing
63
+ * it did not already have (PROMPT 4 measured what permission alone achieves: nothing).
64
+ * - step 3 is stated as CORRECT and REQUIRED, in those words, so the field cannot be closed
65
+ * by guessing. See the header.
66
+ */
67
+ export const APIS_SEMANTICS_CONTRACT = `THIRD-PARTY PACKAGE ENTRIES ARE NOT DONE WHEN THEY HAVE A SIGNATURE. For every entry whose symbol comes from a third-party npm package — not this project's own source, not a runtime builtin — the line carries a SECOND field saying what the thing MEANS in use: what one of its arguments stands for, what it defaults to, what a path/URL/prefix it is handed is relative to, or what its return value actually is. Format:
68
+ <name> <one-line signature or use> — SEMANTICS: <what it means in use>
69
+
70
+ A TYPE SIGNATURE IS NOT A SEMANTICS CLAUSE, and restating one in prose does not make it one. \`hc(baseUrl: Prefix, options?: ClientRequestOptions)\` names the argument and says nothing about whether that argument is an origin, or a mount prefix, or how it is joined to each route path — which is the fact the implementing agent actually needs, and the one it will otherwise guess wrong. An entry whose SEMANTICS field is missing is UNFINISHED, and your section is not ready to emit while any package entry is unfinished.
71
+
72
+ HOW TO FILL THAT FIELD — in this order. Do not skip a step, and do not stop after step 1 because you already hold the declaration:
73
+ 1. ASK \`pi-worker-docs\` A BEHAVIOUR QUESTION about that package. NOT "what is X's signature", NOT "what types does X export" — those return the declaration you already have. Ask what an argument MEANS, what it DEFAULTS to, what it is RELATIVE to, what HAPPENS when it is given a particular value. For example: \`pi-worker-docs("hono/client", "what does the baseUrl argument to hc MEAN — an origin or a mount prefix — and how is it joined to each route path?")\`.
74
+ 2. IF THE PACKAGE TEXT DOES NOT ANSWER IT, ESCALATE. Expect this: bundled \`.d.ts\` declarations frequently carry no semantics at all, because the semantics live in the package's documentation. Call \`pi-worker-search\` with the question, or \`pi-worker-fetch\` on a documentation URL — including any \`@see {@link https://…}\` link that appeared in the text \`pi-worker-docs\` just returned to you.
75
+ 3. ONLY IF BOTH FAIL, WRITE THE OPEN QUESTION DOWN, in this exact form:
76
+ <name> <signature> — SEMANTICS: UNVERIFIED: <the exact question you could not answer>
77
+ THIS IS A CORRECT AND REQUIRED OUTCOME, not a failure. A named open question is worth far more to the implementing agent than a confident guess, and it is the only acceptable way to finish an entry you could not verify. NEVER fill this field from memory, from what the symbol is named, or from what the API "obviously" does: a plausible wrong semantics clause is the single most damaging thing this section can carry.`;
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Deterministic detector for the F-1 laundering shape: a CONTEXT bullet that asserts
3
+ * external API USAGE SEMANTICS under an attribution cue that no EXTERNAL CONTEXT block
4
+ * can actually support.
5
+ *
6
+ * THE SHAPE, from mx5 run 15 (TASK_0027.md, verbatim):
7
+ *
8
+ * - The `hono` dependency is pinned at `^4.12.31` in package.json, and the external
9
+ * context confirms `hc<AppType>` pattern with base URL `/api` for same-origin
10
+ * relative paths works correctly (per Hono RPC docs LIVE data).
11
+ *
12
+ * worker:context has tools `read,grep` only (phases.ts:623) — it cannot consult any
13
+ * documentation — so the base-URL claim was necessarily from model memory. It shipped
14
+ * into TASK_0027's CONSTRAINTS and ACCEPTANCE, the implementation obeyed it exactly
15
+ * (`hc<AppType>('/api')` plus `api.api.auth.login.$post()`), and every request went to
16
+ * `/api/api/...` ⇒ 404 ⇒ the product's entire API surface was dead.
17
+ *
18
+ * WHY THE OBVIOUS TEST DOES NOT WORK. "Flag a bullet whose package has no EXTERNAL
19
+ * CONTEXT block" misses this case: EXTERNAL CONTEXT *did* carry a `### npm: hono` block.
20
+ * That block contains version numbers and nothing else, so it cannot support a claim
21
+ * about what a base URL MEANS — yet it lends the sentence an air of having been checked.
22
+ * That is the whole mechanism (F-1e): one citable fact, the pinned version, fused in a
23
+ * single sentence with an uncitable one under a shared attribution. The true half
24
+ * launders the false half.
25
+ *
26
+ * So the rule keys on what a block CAN support, mirroring the LIVE-DATA RULE's own
27
+ * taxonomy (RESEARCH_CONTEXT_PROMPT in prompts.ts):
28
+ * ### npm: version numbers only -> cannot source a semantics claim
29
+ * ### docs: retrieved package doc/.d.ts -> CAN source a semantics claim
30
+ * ### url: fetched page content -> CAN source a semantics claim
31
+ * ### service: 3 search-result SNIPPETS -> cannot source a semantics claim
32
+ *
33
+ * The `service` exclusion is not a judgement call — it is the LIVE-DATA RULE's own scope.
34
+ * That rule makes a service block authoritative for "current API surface, deprecation
35
+ * status, and replacement systems", i.e. versions/status/names. A service block is a
36
+ * title + URL + one-line description per result (service-blocks.ts:10); it cannot carry
37
+ * what a parameter MEANS. This matters concretely: TASK_0027's enrichment produced
38
+ * exactly one service block, `### service: Hono RPC client` (extractEnrichTargets on the
39
+ * verbatim refined task yields services=[Hono RPC client], urls=[], packages=[any,api,hc]).
40
+ * Were `service` treated as source-capable for semantics, the subject string "Hono RPC
41
+ * client" would match the package `hono` and the fatal bullet would pass unflagged — the
42
+ * detector would be unable to catch the very defect it exists for.
43
+ *
44
+ * A bullet is FLAGGED iff all three hold:
45
+ * 1. it carries an attribution cue ("per ... LIVE data", "the external context
46
+ * confirms", "docs confirm", "per the official docs", ...);
47
+ * 2. it asserts API usage semantics — how something is called, what a parameter means,
48
+ * what a default is, what behaviour results — as opposed to a version or a status;
49
+ * 3. no `### url:` or `### docs:` block exists for any package the bullet names.
50
+ *
51
+ * Pure and side-effect free; unit-tested in context-attribution.test.ts against the real
52
+ * run-15 bullets, including the three legitimate attributed bullets (TASK_0007, _0012,
53
+ * _0031) that must NOT be flagged.
54
+ */
55
+ /** A block that actually appears in an EXTERNAL CONTEXT header. */
56
+ export interface ContextBlock {
57
+ kind: 'npm' | 'docs' | 'url' | 'service' | 'freshness-skipped';
58
+ /** The block's subject: a package name, a URL, or a service name. */
59
+ subject: string;
60
+ }
61
+ export interface AttributionFinding {
62
+ bullet: string;
63
+ /** The attribution cue that made this bullet a claim of provenance. */
64
+ cue: string;
65
+ /** The semantics marker that made it an API-behaviour claim rather than a version. */
66
+ semantics: string;
67
+ /** Packages the bullet names that have no source-capable block. */
68
+ unsourced: string[];
69
+ }
70
+ /** One bullet plus the line range it occupies, so a rewrite can be surgical. */
71
+ export interface BulletSpan {
72
+ /** The bullet's text, continuation lines folded in, marker stripped. */
73
+ text: string;
74
+ /** Index of the line carrying the `-`/`*` marker. */
75
+ startLine: number;
76
+ /** Index of the last line belonging to this bullet (inclusive). */
77
+ endLine: number;
78
+ /** Leading whitespace of the marker line, preserved on rewrite. */
79
+ indent: string;
80
+ }
81
+ /**
82
+ * Split a CONTEXT section into bullets WITH their line ranges. Continuation lines are
83
+ * folded into the bullet above so a hard-wrapped claim is judged as one sentence — which
84
+ * is exactly how the fatal run-15 bullet was written.
85
+ */
86
+ export declare function splitBulletSpans(context: string): BulletSpan[];
87
+ /** Split a CONTEXT section into its bullets, joining hard-wrapped continuation lines. */
88
+ export declare function splitBullets(context: string): string[];
89
+ /** Parse the `### npm:` / `### docs:` / `### url:` / `### service:` blocks out of an EXTERNAL CONTEXT header. */
90
+ export declare function parseContextBlocks(externalContext: string): ContextBlock[];
91
+ /**
92
+ * Find bullets that assert external API semantics under an attribution no available
93
+ * block can support.
94
+ *
95
+ * @param context the emitted CONTEXT section text
96
+ * @param externalContext the EXTERNAL CONTEXT header actually passed to that worker
97
+ * @param packages dependency names to look for in a bullet (from package.json)
98
+ */
99
+ export declare function findUnsourcedAttributions(context: string, externalContext: string, packages: string[]): AttributionFinding[];
100
+ /** The result of demoting the flagged bullets out of a CONTEXT section. */
101
+ export interface DemotedContext {
102
+ /** The CONTEXT text with every flagged bullet rewritten as an open question. */
103
+ text: string;
104
+ /** What was demoted, in emission order. Empty means the section was untouched. */
105
+ demoted: AttributionFinding[];
106
+ }
107
+ /**
108
+ * Rewrite every bullet that findUnsourcedAttributions flags into an OPEN QUESTION, in
109
+ * place, leaving every other byte of the section alone.
110
+ *
111
+ * DEMOTE, DO NOT DELETE. PROMPT 1 allows either, and its invariant is that neither the
112
+ * bullet count nor the count of legitimately-sourced bullets may collapse — "a worker
113
+ * silenced into saying nothing is a regression, not a fix". Demotion satisfies that
114
+ * mechanically: one flagged bullet becomes exactly one bullet, so the count is invariant,
115
+ * and the observation survives for the grill to ask about instead of reaching compose as
116
+ * fact. The attribution cue is removed, which is what makes the claim stop reading as
117
+ * sourced — and it also makes the rewrite idempotent, since the cue was condition (i).
118
+ *
119
+ * @param context the emitted CONTEXT section text
120
+ * @param externalContext the EXTERNAL CONTEXT header actually passed to that worker
121
+ * @param packages dependency names to look for in a bullet (from package.json)
122
+ */
123
+ export declare function demoteUnsourcedAttributions(context: string, externalContext: string, packages: string[]): DemotedContext;