@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.
@@ -0,0 +1,285 @@
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
+ /** Block kinds whose body is retrieved prose/declarations, so they can carry semantics. */
56
+ const SOURCE_CAPABLE_KINDS = new Set(['docs', 'url']);
57
+ /**
58
+ * Phrases by which a bullet claims its content came from somewhere authoritative.
59
+ * Deliberately narrow: each must assert PROVENANCE. "is pinned at" is not a cue — it is
60
+ * an ordinary statement of fact the worker can verify by reading package.json.
61
+ */
62
+ const ATTRIBUTION_CUES = [
63
+ /per\s+[^.,;]{0,60}\bLIVE\s+data\b/i,
64
+ /\bLIVE\s+data\b/i,
65
+ /\bthe\s+external\s+context\s+confirms\b/i,
66
+ /\bexternal\s+context\s+confirms\b/i,
67
+ /\bper\s+the\s+EXTERNAL\s+CONTEXT\b/i,
68
+ /\bthe\s+EXTERNAL\s+CONTEXT\s+(block|documentation)\b/i,
69
+ /\bdocs?\s+confirms?\b/i,
70
+ /\bper\s+the\s+official\s+docs?\b/i,
71
+ /\baccording\s+to\s+(the\s+)?(official\s+)?docs?\b/i,
72
+ /\bthe\s+docs?\s+says?\b/i,
73
+ /\bper\s+[A-Z][\w./-]*\s+docs\b/
74
+ ];
75
+ /**
76
+ * Markers that a bullet is asserting how an API BEHAVES, rather than what version it is.
77
+ * A version/status claim under an attribution is legitimate — that is exactly what the
78
+ * npm block is for — so those must not trip the detector.
79
+ */
80
+ const SEMANTICS_MARKERS = [
81
+ /\bbase\s*URL\b/i,
82
+ /\bdefaults?\s+to\b/i,
83
+ /\bby\s+default\b/i,
84
+ /\bmust\s+be\s+(a\s+)?(relative|absolute)\b/i,
85
+ /\bworks?\s+correctly\b/i,
86
+ /\bbehaves?\b/i,
87
+ /\breturns?\b/i,
88
+ /\baccepts?\b/i,
89
+ /\btakes?\s+(a|an|two|three|the)\b/i,
90
+ /\bis\s+called\s+with\b/i,
91
+ /\bpass(es|ed|ing)?\s+(a|an|the|it)\b/i,
92
+ /\bparameter\b/i,
93
+ /\bargument\b/i,
94
+ /\bsignature\b/i,
95
+ /\bautomatically\b/i,
96
+ /\bsupports?\b/i,
97
+ /\brequires?\b/i,
98
+ /\bresolves?\b/i
99
+ ];
100
+ /** A claim that is ONLY about a version or release status — never a semantics claim. */
101
+ const VERSION_ONLY = [
102
+ /\blatest\s+is\b/i,
103
+ /\bis\s+the\s+latest\b/i,
104
+ /\blive\s+registry\s+shows\b/i,
105
+ /\bnpm\s+latest\b/i,
106
+ /\bversion\s+is\b/i,
107
+ /\bis\s+at\s+version\b/i,
108
+ /\bpinned\s+at\b/i
109
+ ];
110
+ function firstMatch(text, patterns) {
111
+ for (const p of patterns) {
112
+ const m = p.exec(text);
113
+ if (m)
114
+ return m[0];
115
+ }
116
+ return null;
117
+ }
118
+ /**
119
+ * Split a CONTEXT section into bullets WITH their line ranges. Continuation lines are
120
+ * folded into the bullet above so a hard-wrapped claim is judged as one sentence — which
121
+ * is exactly how the fatal run-15 bullet was written.
122
+ */
123
+ export function splitBulletSpans(context) {
124
+ const spans = [];
125
+ const lines = context.split('\n');
126
+ for (let i = 0; i < lines.length; i++) {
127
+ const line = lines[i].trimEnd();
128
+ const marker = /^(\s*)[-*]\s+/.exec(line);
129
+ if (marker) {
130
+ spans.push({
131
+ text: line.slice(marker[0].length).trim(),
132
+ startLine: i,
133
+ endLine: i,
134
+ indent: marker[1]
135
+ });
136
+ }
137
+ else if (spans.length > 0 && line.trim().length > 0 && !/^#/.test(line)) {
138
+ const last = spans[spans.length - 1];
139
+ last.text += ' ' + line.trim();
140
+ last.endLine = i;
141
+ }
142
+ }
143
+ return spans.filter(s => s.text.length > 0);
144
+ }
145
+ /** Split a CONTEXT section into its bullets, joining hard-wrapped continuation lines. */
146
+ export function splitBullets(context) {
147
+ return splitBulletSpans(context).map(s => s.text);
148
+ }
149
+ /** Parse the `### npm:` / `### docs:` / `### url:` / `### service:` blocks out of an EXTERNAL CONTEXT header. */
150
+ export function parseContextBlocks(externalContext) {
151
+ const out = [];
152
+ const re = /^###\s+(npm|docs|url|service|freshness-check)\s*:?\s*(.*)$/gim;
153
+ let m;
154
+ while ((m = re.exec(externalContext)) !== null) {
155
+ const kind = m[1].toLowerCase();
156
+ out.push({
157
+ kind: kind === 'freshness-check' ? 'freshness-skipped' : kind,
158
+ subject: m[2].trim()
159
+ });
160
+ }
161
+ return out;
162
+ }
163
+ /**
164
+ * Does any block exist that COULD source a semantics claim about `pkg`? Only retrieved
165
+ * documentation text can: a `### docs:` chunk or a `### url:` page body. An npm block
166
+ * carries versions and nothing else, and a service block carries search-result snippets
167
+ * (see the header note). A url block matches on hostname/path containing the package
168
+ * name, which is how the design's own reference list reads (hono.dev/docs/guides/rpc
169
+ * for `hono`).
170
+ */
171
+ function hasSourceCapableBlock(pkg, blocks) {
172
+ const base = pkg
173
+ .replace(/^@[^/]+\//, '')
174
+ .replace(/^@/, '')
175
+ .toLowerCase();
176
+ return blocks.some(b => {
177
+ if (!SOURCE_CAPABLE_KINDS.has(b.kind))
178
+ return false;
179
+ const s = b.subject.toLowerCase();
180
+ return s.includes(base) || s.includes(pkg.toLowerCase());
181
+ });
182
+ }
183
+ /**
184
+ * Find bullets that assert external API semantics under an attribution no available
185
+ * block can support.
186
+ *
187
+ * @param context the emitted CONTEXT section text
188
+ * @param externalContext the EXTERNAL CONTEXT header actually passed to that worker
189
+ * @param packages dependency names to look for in a bullet (from package.json)
190
+ */
191
+ export function findUnsourcedAttributions(context, externalContext, packages) {
192
+ const blocks = parseContextBlocks(externalContext);
193
+ const findings = [];
194
+ for (const bullet of splitBullets(context)) {
195
+ const cue = firstMatch(bullet, ATTRIBUTION_CUES);
196
+ if (cue === null)
197
+ continue;
198
+ const semantics = firstMatch(bullet, SEMANTICS_MARKERS);
199
+ if (semantics === null)
200
+ continue;
201
+ // A bullet that ONLY talks about versions is legitimately sourced by an npm
202
+ // block, even though "supports"/"is" may look like a semantics marker.
203
+ const versionOnly = firstMatch(bullet, VERSION_ONLY) !== null;
204
+ const semanticsBeyondVersion = SEMANTICS_MARKERS.filter(p => p.test(bullet)).length;
205
+ if (versionOnly && semanticsBeyondVersion <= 1)
206
+ continue;
207
+ const named = packages.filter(p => {
208
+ const base = p.replace(/^@[^/]+\//, '');
209
+ return (new RegExp(`\\b${escapeRegExp(p)}\\b`, 'i').test(bullet)
210
+ || new RegExp(`\\b${escapeRegExp(base)}\\b`, 'i').test(bullet));
211
+ });
212
+ if (named.length === 0)
213
+ continue;
214
+ const unsourced = named.filter(p => !hasSourceCapableBlock(p, blocks));
215
+ if (unsourced.length === 0)
216
+ continue;
217
+ findings.push({ bullet, cue, semantics, unsourced });
218
+ }
219
+ return findings;
220
+ }
221
+ function escapeRegExp(s) {
222
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
223
+ }
224
+ /**
225
+ * Strip every attribution cue from a bullet. Applied repeatedly because one sentence can
226
+ * carry two overlapping cues (the fatal bullet carried "the external context confirms"
227
+ * AND "per Hono RPC docs LIVE data"), and because removing one can expose another.
228
+ */
229
+ function stripAttributionCues(bullet) {
230
+ let out = bullet;
231
+ for (let pass = 0; pass < 4 && firstMatch(out, ATTRIBUTION_CUES) !== null; pass++) {
232
+ for (const p of ATTRIBUTION_CUES) {
233
+ out = out.replace(new RegExp(p.source, p.flags.includes('g') ? p.flags : p.flags + 'g'), '');
234
+ }
235
+ }
236
+ return out
237
+ .replace(/\(\s*[),.;]?\s*\)/g, '')
238
+ .replace(/\s+([.,;:)])/g, '$1')
239
+ .replace(/,\s*([.;])/g, '$1')
240
+ .replace(/\s{2,}/g, ' ')
241
+ .trim()
242
+ .replace(/^[,;.\s]+/, '');
243
+ }
244
+ /**
245
+ * Rewrite every bullet that findUnsourcedAttributions flags into an OPEN QUESTION, in
246
+ * place, leaving every other byte of the section alone.
247
+ *
248
+ * DEMOTE, DO NOT DELETE. PROMPT 1 allows either, and its invariant is that neither the
249
+ * bullet count nor the count of legitimately-sourced bullets may collapse — "a worker
250
+ * silenced into saying nothing is a regression, not a fix". Demotion satisfies that
251
+ * mechanically: one flagged bullet becomes exactly one bullet, so the count is invariant,
252
+ * and the observation survives for the grill to ask about instead of reaching compose as
253
+ * fact. The attribution cue is removed, which is what makes the claim stop reading as
254
+ * sourced — and it also makes the rewrite idempotent, since the cue was condition (i).
255
+ *
256
+ * @param context the emitted CONTEXT section text
257
+ * @param externalContext the EXTERNAL CONTEXT header actually passed to that worker
258
+ * @param packages dependency names to look for in a bullet (from package.json)
259
+ */
260
+ export function demoteUnsourcedAttributions(context, externalContext, packages) {
261
+ const demoted = findUnsourcedAttributions(context, externalContext, packages);
262
+ if (demoted.length === 0)
263
+ return { text: context, demoted: [] };
264
+ const byText = new Map(demoted.map(f => [f.bullet, f]));
265
+ const lines = context.split('\n');
266
+ const out = [];
267
+ let cursor = 0;
268
+ for (const span of splitBulletSpans(context)) {
269
+ const finding = byText.get(span.text);
270
+ if (!finding)
271
+ continue;
272
+ out.push(...lines.slice(cursor, span.startLine));
273
+ const stripped = stripAttributionCues(span.text);
274
+ // If a cue somehow survives four stripping passes the claim is dropped outright
275
+ // rather than re-emitted still wearing its attribution.
276
+ const body = firstMatch(stripped, ATTRIBUTION_CUES) === null ? stripped : '';
277
+ out.push(`${span.indent}- OPEN QUESTION (unsourced API-semantics claim about `
278
+ + `${finding.unsourced.join(', ')} — worker:context has no documentation tool `
279
+ + `and no retrieved block supports it; do NOT treat as fact)`
280
+ + (body.length > 0 ? `: ${body}` : ''));
281
+ cursor = span.endLine + 1;
282
+ }
283
+ out.push(...lines.slice(cursor));
284
+ return { text: out.join('\n'), demoted };
285
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Deterministic classifier for a SILENT worker:context — a rep whose CONTEXT section
3
+ * carries zero parseable bullets. The open question this answers (STEP 0 of the
4
+ * worker:context ZERO-BULLETS thread) is whether a silent rep is a genuine LOSS (the
5
+ * architectural context was there to surface and the worker dropped all of it) or a
6
+ * LEGITIMATE empty answer (the task truly had nothing worth a bullet). Only the former
7
+ * is a defect worth a lever.
8
+ *
9
+ * WHAT THE RECORDED EVIDENCE SHOWED (48 reps, three runs, 2026-07-21/22). Every silent
10
+ * rep — 5/48 — fell into one of two failure shapes, NONE legitimately empty:
11
+ *
12
+ * LOOP_DEGRADE (3/5): the worker thrashed the SAME grep 5× until the loop-killer fired
13
+ * (exit 143), leaving only the degrade banner in place of a section. Verbatim:
14
+ * "(degraded: research CONTEXT worker stuck in a loop — called grep(...) ×5 ...)"
15
+ *
16
+ * GENERATION_GARBAGE (2/5): the worker exited 0 in ~3.6s and emitted a hallucinated
17
+ * non-bullet fragment instead of context. Verbatim: "Users deleted" and
18
+ * "[SYSTEM NOTE This message received a positive feedback reward/+20 ...]"
19
+ *
20
+ * The fixture (mx5 pre-TASK_0027 tree) is IDENTICAL across all reps and non-silent reps
21
+ * reliably emit 11–21 architectural bullets from it, so the input-empty rival is refuted:
22
+ * the content was always there; a silent rep dropped it. Both shapes are therefore genuine
23
+ * loss. This classifier keys on those shapes so the same verdict is reproducible and so a
24
+ * PHASE-1 gate can reuse it to decide when a retry is warranted.
25
+ */
26
+ /** Why a CONTEXT section came out with no bullets — or that it did not (productive). */
27
+ export type SilenceCause = 'productive' | 'loop-degrade' | 'generation-garbage' | 'legitimately-empty';
28
+ export interface SilenceVerdict {
29
+ bulletCount: number;
30
+ silent: boolean;
31
+ cause: SilenceCause;
32
+ /** A silent rep that dropped context that was there to surface (loop / garbage). */
33
+ genuineLoss: boolean;
34
+ /** The exact substring the verdict keyed on — hand-verifiable in a report. */
35
+ evidence: string;
36
+ }
37
+ /** Bullet lines in an emitted CONTEXT section: leading `-` or `*` markers. */
38
+ export declare function countBullets(contextText: string): number;
39
+ /**
40
+ * Classify one worker:context output. `workerLog` is optional and only consulted for the
41
+ * loop banner, which the degrade machinery writes to the debug log even on the reps where
42
+ * it never reached the persisted section (e.g. a mid-loop SIGTERM before any write).
43
+ */
44
+ export declare function classifyContextSilence(contextText: string, workerLog?: string): SilenceVerdict;
45
+ /**
46
+ * Wilson score interval for a binomial proportion — the CI STEP 0 reports on the silent
47
+ * and genuine-loss rates. Normal-approximation (Wald) intervals are badly wrong at the
48
+ * small counts and near-boundary rates this measurement lives at; Wilson is not.
49
+ */
50
+ export declare function wilsonInterval(successes: number, n: number, z?: number): {
51
+ lo: number;
52
+ hi: number;
53
+ };
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Deterministic classifier for a SILENT worker:context — a rep whose CONTEXT section
3
+ * carries zero parseable bullets. The open question this answers (STEP 0 of the
4
+ * worker:context ZERO-BULLETS thread) is whether a silent rep is a genuine LOSS (the
5
+ * architectural context was there to surface and the worker dropped all of it) or a
6
+ * LEGITIMATE empty answer (the task truly had nothing worth a bullet). Only the former
7
+ * is a defect worth a lever.
8
+ *
9
+ * WHAT THE RECORDED EVIDENCE SHOWED (48 reps, three runs, 2026-07-21/22). Every silent
10
+ * rep — 5/48 — fell into one of two failure shapes, NONE legitimately empty:
11
+ *
12
+ * LOOP_DEGRADE (3/5): the worker thrashed the SAME grep 5× until the loop-killer fired
13
+ * (exit 143), leaving only the degrade banner in place of a section. Verbatim:
14
+ * "(degraded: research CONTEXT worker stuck in a loop — called grep(...) ×5 ...)"
15
+ *
16
+ * GENERATION_GARBAGE (2/5): the worker exited 0 in ~3.6s and emitted a hallucinated
17
+ * non-bullet fragment instead of context. Verbatim: "Users deleted" and
18
+ * "[SYSTEM NOTE This message received a positive feedback reward/+20 ...]"
19
+ *
20
+ * The fixture (mx5 pre-TASK_0027 tree) is IDENTICAL across all reps and non-silent reps
21
+ * reliably emit 11–21 architectural bullets from it, so the input-empty rival is refuted:
22
+ * the content was always there; a silent rep dropped it. Both shapes are therefore genuine
23
+ * loss. This classifier keys on those shapes so the same verdict is reproducible and so a
24
+ * PHASE-1 gate can reuse it to decide when a retry is warranted.
25
+ */
26
+ /** Bullet lines in an emitted CONTEXT section: leading `-` or `*` markers. */
27
+ export function countBullets(contextText) {
28
+ return contextText.split('\n').filter(l => /^\s*[-*]\s+/.test(l)).length;
29
+ }
30
+ const LOOP_BANNER = /stuck in a loop/i;
31
+ /** Honest "nothing to surface" — the ONLY non-loss silent shape. */
32
+ const EMPTY_DECLARATION = /^\s*(none|n\/a|no relevant (context|architectural)|nothing\b)/i;
33
+ /**
34
+ * Classify one worker:context output. `workerLog` is optional and only consulted for the
35
+ * loop banner, which the degrade machinery writes to the debug log even on the reps where
36
+ * it never reached the persisted section (e.g. a mid-loop SIGTERM before any write).
37
+ */
38
+ export function classifyContextSilence(contextText, workerLog = '') {
39
+ const bulletCount = countBullets(contextText);
40
+ if (bulletCount >= 1) {
41
+ return { bulletCount, silent: false, cause: 'productive', genuineLoss: false, evidence: '' };
42
+ }
43
+ const haystack = `${contextText}\n${workerLog}`;
44
+ const loop = LOOP_BANNER.exec(haystack);
45
+ if (loop) {
46
+ // Quote the banner line itself, not just the two matched words.
47
+ const line = haystack
48
+ .split('\n')
49
+ .find(l => LOOP_BANNER.test(l))
50
+ ?.trim() ?? loop[0];
51
+ return {
52
+ bulletCount: 0,
53
+ silent: true,
54
+ cause: 'loop-degrade',
55
+ genuineLoss: true,
56
+ evidence: line.slice(0, 240)
57
+ };
58
+ }
59
+ const trimmed = contextText.trim();
60
+ if (trimmed.length === 0 || EMPTY_DECLARATION.test(trimmed)) {
61
+ return {
62
+ bulletCount: 0,
63
+ silent: true,
64
+ cause: 'legitimately-empty',
65
+ genuineLoss: false,
66
+ evidence: trimmed.slice(0, 240)
67
+ };
68
+ }
69
+ // Non-empty, non-bullet, non-banner, non-declaration → a hallucinated fragment.
70
+ return {
71
+ bulletCount: 0,
72
+ silent: true,
73
+ cause: 'generation-garbage',
74
+ genuineLoss: true,
75
+ evidence: trimmed.slice(0, 240)
76
+ };
77
+ }
78
+ /**
79
+ * Wilson score interval for a binomial proportion — the CI STEP 0 reports on the silent
80
+ * and genuine-loss rates. Normal-approximation (Wald) intervals are badly wrong at the
81
+ * small counts and near-boundary rates this measurement lives at; Wilson is not.
82
+ */
83
+ export function wilsonInterval(successes, n, z = 1.959963984540054 // 95%
84
+ ) {
85
+ if (n === 0)
86
+ return { lo: 0, hi: 0 };
87
+ const p = successes / n;
88
+ const z2 = z * z;
89
+ const denom = 1 + z2 / n;
90
+ const centre = p + z2 / (2 * n);
91
+ const half = z * Math.sqrt((p * (1 - p) + z2 / (4 * n)) / n);
92
+ return { lo: Math.max(0, (centre - half) / denom), hi: Math.min(1, (centre + half) / denom) };
93
+ }