@mjasnikovs/pi-task 0.18.3 → 0.18.5

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 (35) hide show
  1. package/dist/config/config.d.ts +11 -0
  2. package/dist/config/config.js +4 -1
  3. package/dist/config/register.js +5 -0
  4. package/dist/task/accept-debt.d.ts +52 -0
  5. package/dist/task/accept-debt.js +0 -0
  6. package/dist/task/auto-orchestrator.d.ts +2 -0
  7. package/dist/task/auto-orchestrator.js +20 -0
  8. package/dist/task/enforce-guidelines.d.ts +2 -2
  9. package/dist/task/enforce-guidelines.js +36 -3
  10. package/dist/task/env-notes.d.ts +24 -8
  11. package/dist/task/env-notes.js +124 -24
  12. package/dist/task/final-gate.d.ts +8 -0
  13. package/dist/task/final-gate.js +27 -7
  14. package/dist/task/frozen-path-guard.d.ts +39 -0
  15. package/dist/task/frozen-path-guard.js +116 -0
  16. package/dist/task/gate-deps.d.ts +10 -0
  17. package/dist/task/gate-deps.js +122 -3
  18. package/dist/task/probe-gaming.d.ts +60 -0
  19. package/dist/task/probe-gaming.js +0 -0
  20. package/dist/task/repo-health-check.d.ts +11 -0
  21. package/dist/task/repo-health-check.js +26 -3
  22. package/dist/task/task-gates.d.ts +24 -0
  23. package/dist/task/task-gates.js +78 -8
  24. package/dist/task/test-assembly.d.ts +87 -0
  25. package/dist/task/test-assembly.js +163 -0
  26. package/dist/task/verify-work.d.ts +17 -1
  27. package/dist/task/verify-work.js +87 -2
  28. package/dist/workers/pi-worker-docs.js +13 -1
  29. package/dist/workers/pi-worker-fetch.js +10 -1
  30. package/dist/workers/pi-worker-search.js +9 -1
  31. package/dist/workers/research-cache.d.ts +39 -0
  32. package/dist/workers/research-cache.js +140 -0
  33. package/dist/workers/shared.d.ts +17 -0
  34. package/dist/workers/shared.js +0 -0
  35. package/package.json +2 -2
@@ -0,0 +1,87 @@
1
+ /**
2
+ * test-assembly — deterministic detection of TEST-REBUILT PRODUCTION WIRING, feeding
3
+ * the verify gate's prompt (run-8 F4; third recurrence of the test-the-copy class,
4
+ * runs 3, 4, 8).
5
+ *
6
+ * The failure class: a test file re-constructs wiring that ALSO exists in production
7
+ * — it builds its own app assembly / its own entry point out of the same leaf modules
8
+ * the production entry composes, then tests THAT private copy. The copy can be wired
9
+ * differently from production and stay green while the shipped wiring is broken. Run-8
10
+ * fixture: `test/photos.test.ts` imports the real `authRoutes` + `photosRoutes` leaves,
11
+ * mounts them into its OWN app at a DIFFERENT prefix than the production entry, and
12
+ * runs 102/102 green — while the shipped upload path is dead because production mounts
13
+ * the same leaf at the wrong prefix. The verify child, judging "do the tests pass",
14
+ * saw green and counted the photos area verified. The seam the test was supposed to
15
+ * cover is exactly the seam it re-implemented away.
16
+ *
17
+ * This is the VERIFY-SIDE complement of the generation-side wiring probe
18
+ * (wiring-claims.ts, item #5) and shares the load-bearing lesson of
19
+ * substitution-probe / skip-escape / wiring-claims: a deterministic finding that NAMES
20
+ * the suspect file is the reliable lever; a bare prompt rule is weak. rule 3b already
21
+ * tells the child to spot-check that self-authored tests exercise the real artifact,
22
+ * but F4 slips through because these tests DO import the real leaf modules — they just
23
+ * bypass the real ASSEMBLY, which rule 3b's "did it import and call the module" check
24
+ * does not catch. This probe supplies the missing concrete fact.
25
+ *
26
+ * THE SIGNAL is pure import-graph SHAPE, zero stack/framework assumptions (no "app",
27
+ * no "route", no "mount", no language runtime): a test file T is flagged when there is
28
+ * a production file E (the assembly/entry) such that
29
+ * - T does NOT import E (it bypasses the shipped assembly), AND
30
+ * - T and E both import ≥2 of the SAME leaf modules, where each such leaf is
31
+ * imported by E and by NO OTHER production file (E is the leaf's SOLE production
32
+ * composition site).
33
+ * The "E-exclusive leaf" condition is the crisp discriminator that keeps ordinary
34
+ * shared-utility imports out: a test importing an api client + a schema module that
35
+ * every page also imports is NOT re-assembly (those utilities have many production
36
+ * importers); a test importing two route modules that only the server entry composes
37
+ * IS re-assembly. Measured on the run-8 fixture tree: flags exactly the four backend
38
+ * tests that rebuild the server entry's route composition (including the real
39
+ * photos seam bug) and leaves clean the single-leaf direct test, the utility-sharing
40
+ * page test, and the source-grepping test — 0 false positives.
41
+ *
42
+ * Findings are ADVISORY (probe+rule): they mandate the child to exercise the REAL
43
+ * shipped assembly directly before counting the area verified; they never auto-FAIL.
44
+ * A test that re-composes wiring which happens to match production survives once the
45
+ * child drives the real entry; only one whose real assembly is broken gets named.
46
+ */
47
+ /** A source file the probe reasons over: repo-relative path + its full text. */
48
+ export interface RepoFile {
49
+ /** Path relative to the repo root (used verbatim in the finding text). */
50
+ path: string;
51
+ /** Full file contents (import statements are parsed out of it). */
52
+ text: string;
53
+ }
54
+ /** One test file that rebuilds a production assembly instead of importing it. */
55
+ export interface TestAssemblyFinding {
56
+ /** The test file re-constructing the wiring. */
57
+ testFile: string;
58
+ /** The production assembly / entry it bypasses (sole composer of the leaves). */
59
+ assemblyFile: string;
60
+ /** The leaf modules (repo-relative, extensionless) the test re-composes. */
61
+ leaves: string[];
62
+ }
63
+ /** The importing file's OWN module id (path minus extension / `/index`). */
64
+ export declare function moduleIdOf(filePath: string): string;
65
+ /**
66
+ * The set of repo-relative, extensionless module ids that `text` imports via RELATIVE
67
+ * specifiers (a specifier starting with `.`). Bare/external specifiers (`hono`,
68
+ * `bun:sql`, `node:fs`) are ignored — they never name a repo file, so they cannot be
69
+ * a re-composed production leaf. Resolution is pure path arithmetic against the
70
+ * importer's directory; the filesystem is never touched.
71
+ */
72
+ export declare function relativeImports(filePath: string, text: string): string[];
73
+ /**
74
+ * Find test files that rebuild a production assembly. `changedTestFiles` are the
75
+ * task's own authored/changed test files (path + text); `productionFiles` are the
76
+ * repo's non-test source files (path + text) used to build the import graph and the
77
+ * per-leaf production in-degree. Returns one finding per re-assembling test, sorted
78
+ * for determinism. Empty when no test re-composes an E-exclusive leaf set.
79
+ */
80
+ export declare function findTestRebuiltAssemblies(changedTestFiles: RepoFile[], productionFiles: RepoFile[]): TestAssemblyFinding[];
81
+ /**
82
+ * Render findings as verify-child prompt lines (probe+rule pattern — the concrete
83
+ * finding that makes the rule fire reliably). One line per re-assembling test naming
84
+ * the test, the shipped assembly it bypasses, and the re-composed leaves. Empty
85
+ * findings → empty array (caller emits no block).
86
+ */
87
+ export declare function testAssemblyVerifyFindings(findings: TestAssemblyFinding[]): string[];
@@ -0,0 +1,163 @@
1
+ /**
2
+ * test-assembly — deterministic detection of TEST-REBUILT PRODUCTION WIRING, feeding
3
+ * the verify gate's prompt (run-8 F4; third recurrence of the test-the-copy class,
4
+ * runs 3, 4, 8).
5
+ *
6
+ * The failure class: a test file re-constructs wiring that ALSO exists in production
7
+ * — it builds its own app assembly / its own entry point out of the same leaf modules
8
+ * the production entry composes, then tests THAT private copy. The copy can be wired
9
+ * differently from production and stay green while the shipped wiring is broken. Run-8
10
+ * fixture: `test/photos.test.ts` imports the real `authRoutes` + `photosRoutes` leaves,
11
+ * mounts them into its OWN app at a DIFFERENT prefix than the production entry, and
12
+ * runs 102/102 green — while the shipped upload path is dead because production mounts
13
+ * the same leaf at the wrong prefix. The verify child, judging "do the tests pass",
14
+ * saw green and counted the photos area verified. The seam the test was supposed to
15
+ * cover is exactly the seam it re-implemented away.
16
+ *
17
+ * This is the VERIFY-SIDE complement of the generation-side wiring probe
18
+ * (wiring-claims.ts, item #5) and shares the load-bearing lesson of
19
+ * substitution-probe / skip-escape / wiring-claims: a deterministic finding that NAMES
20
+ * the suspect file is the reliable lever; a bare prompt rule is weak. rule 3b already
21
+ * tells the child to spot-check that self-authored tests exercise the real artifact,
22
+ * but F4 slips through because these tests DO import the real leaf modules — they just
23
+ * bypass the real ASSEMBLY, which rule 3b's "did it import and call the module" check
24
+ * does not catch. This probe supplies the missing concrete fact.
25
+ *
26
+ * THE SIGNAL is pure import-graph SHAPE, zero stack/framework assumptions (no "app",
27
+ * no "route", no "mount", no language runtime): a test file T is flagged when there is
28
+ * a production file E (the assembly/entry) such that
29
+ * - T does NOT import E (it bypasses the shipped assembly), AND
30
+ * - T and E both import ≥2 of the SAME leaf modules, where each such leaf is
31
+ * imported by E and by NO OTHER production file (E is the leaf's SOLE production
32
+ * composition site).
33
+ * The "E-exclusive leaf" condition is the crisp discriminator that keeps ordinary
34
+ * shared-utility imports out: a test importing an api client + a schema module that
35
+ * every page also imports is NOT re-assembly (those utilities have many production
36
+ * importers); a test importing two route modules that only the server entry composes
37
+ * IS re-assembly. Measured on the run-8 fixture tree: flags exactly the four backend
38
+ * tests that rebuild the server entry's route composition (including the real
39
+ * photos seam bug) and leaves clean the single-leaf direct test, the utility-sharing
40
+ * page test, and the source-grepping test — 0 false positives.
41
+ *
42
+ * Findings are ADVISORY (probe+rule): they mandate the child to exercise the REAL
43
+ * shipped assembly directly before counting the area verified; they never auto-FAIL.
44
+ * A test that re-composes wiring which happens to match production survives once the
45
+ * child drives the real entry; only one whose real assembly is broken gets named.
46
+ */
47
+ import { isTestFile } from './substitution-probe.js';
48
+ /** Code file extensions whose relative imports we resolve. Not a stack assumption —
49
+ * purely which quoted specifiers name a repo file; other languages simply produce
50
+ * no matches and the whole probe degrades to nothing. */
51
+ const CODE_EXT_RE = /\.(?:[cm]?[jt]sx?)$/;
52
+ /**
53
+ * Static import/re-export declarations: `import … from 'x'`, `import 'x'`,
54
+ * `export … from 'x'`. Anchored to line start (after optional whitespace) so an
55
+ * import-shaped STRING inside an assertion (`expect(src).toContain("import x from
56
+ * '../y'")`, a source-grepping test) is NOT mistaken for a real import — that string
57
+ * is indented behind `expect(`, never at line start.
58
+ */
59
+ const STATIC_IMPORT_RE = /^[ \t]*(?:import|export)\s+(?:[^'"\n]*\sfrom\s+)?['"]([^'"]+)['"]/gm;
60
+ /** Dynamic `import('x')` / `require('x')` calls (the call-paren form is unlikely to
61
+ * appear inside an assertion string, so matching anywhere is safe enough). */
62
+ const CALL_IMPORT_RE = /(?:require|import)\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
63
+ /** Strip a code extension and a trailing `/index` so `./a`, `./a.ts`, and
64
+ * `./a/index.ts` all collapse to the same module id. */
65
+ function stripToModuleId(p) {
66
+ return p.replace(CODE_EXT_RE, '').replace(/\/index$/, '');
67
+ }
68
+ /** Normalise a POSIX-style relative path (resolve `.`/`..` segments) without touching
69
+ * the filesystem — the analysis is pure text shape. */
70
+ function normalisePosix(p) {
71
+ const segments = [];
72
+ for (const seg of p.split('/')) {
73
+ if (seg === '' || seg === '.')
74
+ continue;
75
+ if (seg === '..')
76
+ segments.pop();
77
+ else
78
+ segments.push(seg);
79
+ }
80
+ return segments.join('/');
81
+ }
82
+ /** The importing file's OWN module id (path minus extension / `/index`). */
83
+ export function moduleIdOf(filePath) {
84
+ return stripToModuleId(filePath);
85
+ }
86
+ /**
87
+ * The set of repo-relative, extensionless module ids that `text` imports via RELATIVE
88
+ * specifiers (a specifier starting with `.`). Bare/external specifiers (`hono`,
89
+ * `bun:sql`, `node:fs`) are ignored — they never name a repo file, so they cannot be
90
+ * a re-composed production leaf. Resolution is pure path arithmetic against the
91
+ * importer's directory; the filesystem is never touched.
92
+ */
93
+ export function relativeImports(filePath, text) {
94
+ const dir = filePath.includes('/') ? filePath.slice(0, filePath.lastIndexOf('/')) : '';
95
+ const ids = new Set();
96
+ for (const re of [STATIC_IMPORT_RE, CALL_IMPORT_RE]) {
97
+ re.lastIndex = 0;
98
+ for (let m = re.exec(text); m !== null; m = re.exec(text)) {
99
+ const spec = m[1];
100
+ if (!spec.startsWith('.'))
101
+ continue;
102
+ ids.add(stripToModuleId(normalisePosix(`${dir}/${spec}`)));
103
+ }
104
+ }
105
+ return [...ids];
106
+ }
107
+ /**
108
+ * Find test files that rebuild a production assembly. `changedTestFiles` are the
109
+ * task's own authored/changed test files (path + text); `productionFiles` are the
110
+ * repo's non-test source files (path + text) used to build the import graph and the
111
+ * per-leaf production in-degree. Returns one finding per re-assembling test, sorted
112
+ * for determinism. Empty when no test re-composes an E-exclusive leaf set.
113
+ */
114
+ export function findTestRebuiltAssemblies(changedTestFiles, productionFiles) {
115
+ // Only genuine production (non-test) files can be the bypassed assembly.
116
+ const prod = productionFiles.filter(f => !isTestFile(f.path));
117
+ const prodImports = new Map();
118
+ const inDegree = new Map();
119
+ for (const f of prod) {
120
+ const imps = relativeImports(f.path, f.text);
121
+ prodImports.set(f.path, new Set(imps));
122
+ for (const m of imps)
123
+ inDegree.set(m, (inDegree.get(m) ?? 0) + 1);
124
+ }
125
+ const findings = [];
126
+ for (const t of [...changedTestFiles].sort((a, b) => a.path.localeCompare(b.path))) {
127
+ if (!isTestFile(t.path))
128
+ continue;
129
+ const tImports = new Set(relativeImports(t.path, t.text));
130
+ if (tImports.size < 2)
131
+ continue;
132
+ let best = null;
133
+ for (const e of [...prod].sort((a, b) => a.path.localeCompare(b.path))) {
134
+ if (e.path === t.path)
135
+ continue;
136
+ // The test imports the real assembly → it is exercising the shipped wiring,
137
+ // not a copy. Good citizen, never flagged.
138
+ if (tImports.has(moduleIdOf(e.path)))
139
+ continue;
140
+ const eImports = prodImports.get(e.path);
141
+ // Leaves E is the SOLE production composer of, that this test re-imports.
142
+ const leaves = [...tImports].filter(m => eImports.has(m) && inDegree.get(m) === 1);
143
+ if (leaves.length >= 2 && (best === null || leaves.length > best.leaves.length)) {
144
+ best = { testFile: t.path, assemblyFile: e.path, leaves: leaves.sort() };
145
+ }
146
+ }
147
+ if (best)
148
+ findings.push(best);
149
+ }
150
+ return findings;
151
+ }
152
+ /**
153
+ * Render findings as verify-child prompt lines (probe+rule pattern — the concrete
154
+ * finding that makes the rule fire reliably). One line per re-assembling test naming
155
+ * the test, the shipped assembly it bypasses, and the re-composed leaves. Empty
156
+ * findings → empty array (caller emits no block).
157
+ */
158
+ export function testAssemblyVerifyFindings(findings) {
159
+ return findings.map(f => `${f.testFile} imports and re-composes ${f.leaves.length} leaf module(s) `
160
+ + `(${f.leaves.join(', ')}) that ${f.assemblyFile} is the ONLY production file to `
161
+ + `compose, yet it never imports ${f.assemblyFile} — it builds its OWN assembly of `
162
+ + `those leaves instead of exercising the shipped one`);
163
+ }
@@ -61,7 +61,7 @@ export declare function extractSpecForVerification(taskBody: string): string | n
61
61
  * Guard: honest-clean fixture (prohibition in spec, probe silent) 5/5 PASS — no
62
62
  * paranoia. Reverted-violation ≡ clean at the diff level (no entry → no finding).
63
63
  */
64
- export declare function buildVerifyPrompt(spec: string, probeFindings?: string[], envNotes?: string, prohibitionFindings?: string[], skipEscapeFindings?: string[], contracts?: string): string;
64
+ export declare function buildVerifyPrompt(spec: string, probeFindings?: string[], envNotes?: string, prohibitionFindings?: string[], skipEscapeFindings?: string[], contracts?: string, testAssemblyFindings?: string[], probeGamingFindings?: string[]): string;
65
65
  /**
66
66
  * Parse the child's verdict. Scans for the LAST `WORK-VERIFIED: PASS|FAIL|UNOBSERVED`
67
67
  * marker (the model discusses before concluding, and bash output may echo the word
@@ -114,6 +114,22 @@ export interface VerificationDeps {
114
114
  * no-waiver rule (4b). Advisory, never auto-FAIL — real prohibitions can be
115
115
  * conditional prose. ABSENT or empty → no prohibition block. */
116
116
  prohibitionProbe?: () => Promise<string[]>;
117
+ /**
118
+ * DETERMINISTIC test-assembly probe (see test-assembly.ts): authored test files
119
+ * that rebuild production WIRING — importing the leaf modules the shipped entry
120
+ * composes and assembling their own copy instead of the real assembly — become
121
+ * prompt findings under rule 3f (F4 test-the-copy, 3rd recurrence). Pure import-
122
+ * graph shape; the child then drives the real assembly before trusting the copy.
123
+ * ABSENT or empty → no test-assembly block. */
124
+ testAssemblyProbe?: () => Promise<string[]>;
125
+ /**
126
+ * DETERMINISTIC probe-gaming probe (see probe-gaming.ts, run-8 F6): added lines
127
+ * in the task's diff whose stated purpose is to make a CHECK pass instead of
128
+ * meeting the requirement it stands for ("return 401 so the verification test
129
+ * passes"). Injected as findings under rule 4c so the child confirms the
130
+ * underlying requirement is genuinely met rather than trusting the green check.
131
+ * Pure diff-text analysis; ABSENT or empty → no probe block. */
132
+ probeGamingProbe?: () => Promise<string[]>;
117
133
  /**
118
134
  * Result of the git-state guard for the MOST RECENT runChild call (see
119
135
  * git-state-guard.ts): did the child mutate repo state (stash/checkout/file
@@ -139,7 +139,7 @@ export function extractSpecForVerification(taskBody) {
139
139
  * Guard: honest-clean fixture (prohibition in spec, probe silent) 5/5 PASS — no
140
140
  * paranoia. Reverted-violation ≡ clean at the diff level (no entry → no finding).
141
141
  */
142
- export function buildVerifyPrompt(spec, probeFindings, envNotes, prohibitionFindings, skipEscapeFindings, contracts) {
142
+ export function buildVerifyPrompt(spec, probeFindings, envNotes, prohibitionFindings, skipEscapeFindings, contracts, testAssemblyFindings, probeGamingFindings) {
143
143
  const probeBlock = probeFindings && probeFindings.length > 0 ?
144
144
  [
145
145
  'SELF-VERIFICATION NOTICE (deterministic, computed by the orchestrator from the diff):',
@@ -166,6 +166,24 @@ export function buildVerifyPrompt(spec, probeFindings, envNotes, prohibitionFind
166
166
  ''
167
167
  ]
168
168
  : [];
169
+ const probeGamingBlock = probeGamingFindings && probeGamingFindings.length > 0 ?
170
+ [
171
+ 'CHECK-GAMING NOTICE (deterministic, computed by the orchestrator from the',
172
+ "task's diff): these added lines state their own purpose is to make a CHECK",
173
+ 'pass (a test / verification / lint / gate), not to meet the requirement the',
174
+ 'check stands for:',
175
+ ...probeGamingFindings.map(f => `- ${f}`),
176
+ 'A check is a MESSENGER for a requirement. Code written to quiet the messenger',
177
+ 'instead of meeting the requirement is a defect even when the check is green',
178
+ '(run-8 F6: a handler returned 401 "so the verification test passes" while the',
179
+ 'real route stayed dead). Do NOT accept the passing check as proof. Read each',
180
+ "line's surrounding code and confirm the UNDERLYING requirement is genuinely",
181
+ 'met — drive the real behavior directly (rule 4c). If the code only exists to',
182
+ 'satisfy the check while the requirement is unmet, that is a FAIL naming the',
183
+ 'gamed check and the unmet requirement.',
184
+ ''
185
+ ]
186
+ : [];
169
187
  const skipEscapeBlock = skipEscapeFindings && skipEscapeFindings.length > 0 ?
170
188
  [
171
189
  "SKIP-ESCAPE NOTICE (deterministic, computed by the orchestrator from the spec's",
@@ -181,6 +199,23 @@ export function buildVerifyPrompt(spec, probeFindings, envNotes, prohibitionFind
181
199
  ''
182
200
  ]
183
201
  : [];
202
+ const testAssemblyBlock = testAssemblyFindings && testAssemblyFindings.length > 0 ?
203
+ [
204
+ 'TEST-ASSEMBLY NOTICE (deterministic, computed by the orchestrator from pure',
205
+ 'import-graph shape): these test files rebuild production WIRING — they import the',
206
+ 'same leaf modules the shipped entry composes and assemble their OWN copy of it,',
207
+ 'instead of importing the production assembly:',
208
+ ...testAssemblyFindings.map(f => `- ${f}`),
209
+ 'A green result on such a test proves that PRIVATE re-assembly, NOT the shipped',
210
+ 'wiring — the copy can be wired differently (a different mount prefix, order, or',
211
+ 'middleware) and pass while production is broken exactly at the seam the test was',
212
+ 'meant to cover (rule 3f below). Before you count the covered area verified, drive',
213
+ 'the behavior against the REAL shipped assembly/entry named above (start or invoke',
214
+ "the production entry point, not the test's hand-built app). If the real assembly",
215
+ 'fails where the test passes, report FAIL and name the wiring seam.',
216
+ ''
217
+ ]
218
+ : [];
184
219
  const envBlock = envNotes && envNotes.trim().length > 0 ? [buildEnvNotesBlock(envNotes)] : [];
185
220
  const contractsBlock = contracts && contracts.trim().length > 0 ? [buildContractsVerifyBlock(contracts)] : [];
186
221
  return [
@@ -200,7 +235,9 @@ export function buildVerifyPrompt(spec, probeFindings, envNotes, prohibitionFind
200
235
  ...contractsBlock,
201
236
  ...probeBlock,
202
237
  ...prohibitionBlock,
238
+ ...probeGamingBlock,
203
239
  ...skipEscapeBlock,
240
+ ...testAssemblyBlock,
204
241
  'How to verify — verify the REAL, shipped deliverable exactly as an unaided fresh',
205
242
  'checkout (or CI run) would experience it:',
206
243
  '',
@@ -280,6 +317,20 @@ export function buildVerifyPrompt(spec, probeFindings, envNotes, prohibitionFind
280
317
  ' unverified is a FAIL, never a PASS. A wrong-input control exists for every',
281
318
  ' artifact — HTTP request, CLI invocation, library call, schema load, config parse.',
282
319
  '',
320
+ '3f. TEST-REBUILT ASSEMBLY: a test proves only the copy if it RE-CONSTRUCTS wiring that',
321
+ ' also exists in production — assembling its own app / entry point / config out of the',
322
+ ' same leaf modules the shipped entry composes, instead of importing and exercising the',
323
+ ' production assembly. Such a test can wire the leaves differently from production (a',
324
+ ' different prefix, order, adapter, or middleware) and pass green while the SHIPPED',
325
+ ' wiring is broken at exactly the seam the test was meant to cover — its green result',
326
+ ' never touched the production assembly at all. Whenever a spec-required behavior is',
327
+ ' covered ONLY by tests that build their own composition of the real modules, that',
328
+ ' behavior is UNVERIFIED off those tests: exercise the REAL shipped entry/assembly (run',
329
+ ' or invoke the production entry point, hit the real composed surface) and judge THAT.',
330
+ ' If the real assembly fails where the copy passes, report FAIL naming the wiring seam',
331
+ ' (e.g. "the entry mounts <module> at <X> but the test mounts it at <Y>, so the real',
332
+ ' path is dead while the test is green").',
333
+ '',
283
334
  '4. Treat the ACCEPTANCE criteria as the bar. If a command fails, or its real output',
284
335
  ' contradicts an ACCEPTANCE criterion, the work has NOT verified.',
285
336
  '',
@@ -296,6 +347,18 @@ export function buildVerifyPrompt(spec, probeFindings, envNotes, prohibitionFind
296
347
  ' for…") that covers the change — judged against that stated exception, not against',
297
348
  ' your view of harmlessness.',
298
349
  '',
350
+ '4c. THE CHECK IS THE MESSENGER, NOT THE REQUIREMENT — code (or a comment) whose',
351
+ ' stated purpose is to make a check PASS, rather than to satisfy the requirement the',
352
+ ' check stands for, is a defect even when the check is green. The tell is the intent',
353
+ ' written down: "return X so the test passes", "hardcode this to satisfy the linter",',
354
+ ' "stub it out to appease CI". When you see such a line — or the CHECK-GAMING NOTICE',
355
+ ' above names one — do NOT treat the passing check as proof the requirement is met.',
356
+ ' Find the actual requirement the check was meant to prove and verify THAT directly',
357
+ ' against the real artifact (rule 3e negative control is the sharpest tool: a handler',
358
+ ' that answers the check-shaped request the same way for a WRONG input is gaming the',
359
+ ' check, not implementing the behavior). If the requirement is genuinely unmet while',
360
+ ' the check passes, the verdict is FAIL naming the gamed check and the real gap.',
361
+ '',
299
362
  '5. The ONLY thing you may assume is already provided is a genuinely EXTERNAL running',
300
363
  ' service or network resource (a database server, an API host) that the project',
301
364
  ' documents as a prerequisite. Before you rely on that assumption, PROBE for the',
@@ -446,6 +509,28 @@ export async function runWorkVerification(deps) {
446
509
  prohibitions = [];
447
510
  }
448
511
  }
512
+ // Test-assembly findings feed the prompt (rule 3f); a probe failure must never
513
+ // block verification — it is an optional sharpener like the substitution probe.
514
+ let testAssembly = [];
515
+ if (deps.testAssemblyProbe) {
516
+ try {
517
+ testAssembly = await deps.testAssemblyProbe();
518
+ }
519
+ catch {
520
+ testAssembly = [];
521
+ }
522
+ }
523
+ // Probe-gaming findings feed the prompt (rule 4c, F6); a probe failure must never
524
+ // block verification — an optional sharpener like the other diff-shape probes.
525
+ let probeGaming = [];
526
+ if (deps.probeGamingProbe) {
527
+ try {
528
+ probeGaming = await deps.probeGamingProbe();
529
+ }
530
+ catch {
531
+ probeGaming = [];
532
+ }
533
+ }
449
534
  // Environment facts from earlier gate children (best-effort; a cache failure
450
535
  // must never block verification).
451
536
  let envNotes = '';
@@ -481,7 +566,7 @@ export async function runWorkVerification(deps) {
481
566
  for (let attempt = 1;; attempt++) {
482
567
  let text;
483
568
  try {
484
- text = await deps.runChild(VERIFY_TOOLS, buildVerifyPrompt(deps.spec, findings, envNotes, prohibitions, skipEscapes, contracts), deps.signal);
569
+ text = await deps.runChild(VERIFY_TOOLS, buildVerifyPrompt(deps.spec, findings, envNotes, prohibitions, skipEscapes, contracts, testAssembly, probeGaming), deps.signal);
485
570
  }
486
571
  catch (err) {
487
572
  if (err instanceof Error && err.message === USER_CANCELLED)
@@ -8,6 +8,7 @@ import { runChild, CHILD_BASE_ARGS } from '../shared/child-process.js';
8
8
  import { parseChildOutput, isExcerptInContent } from '../shared/child-output.js';
9
9
  import { getPiInvocation } from '../shared/pi-invocation.js';
10
10
  import { formatChildFailure, makeWorkerTool } from './shared.js';
11
+ import { normalizeQuery } from './research-cache.js';
11
12
  import { projectDocsRaw, buildProjectPrompt } from './docs-project.js';
12
13
  const CHILD_ARGS = [...CHILD_BASE_ARGS, '--no-tools'];
13
14
  const RENDER_QUERY_MAX = 100;
@@ -239,6 +240,17 @@ export function registerPiWorkerDocs(pi, internals = {}) {
239
240
  text += theme.fg('accent', label);
240
241
  text += `\n${theme.fg('dim', ` query: ${truncated}`)}`;
241
242
  return new Text(text, 0, 0);
242
- }
243
+ },
244
+ // Cache npm-package answers per run (a package's installed types/README + latest
245
+ // version do not change within a run). A project-source `.` lookup is NOT cached:
246
+ // the working tree mutates as tasks implement, so its answer can go stale mid-run
247
+ // (the docs SQLite index already keys those on file mtime).
248
+ cacheKey: params => params.module === '.' ?
249
+ null
250
+ : `${normalizeQuery(params.module)}::${normalizeQuery(params.query)}`,
251
+ // Only a completed lookup (child exited 0) is a real answer; not-installed,
252
+ // no-chunks, resolve/cache errors, and aborts omit childExitCode:0 and fall
253
+ // through to a live retry next time.
254
+ cacheable: d => d.childExitCode === 0
243
255
  });
244
256
  }
@@ -3,6 +3,7 @@ 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
5
  import { formatChildFailure, makeWorkerTool } from './shared.js';
6
+ import { normalizeQuery } from './research-cache.js';
6
7
  const RENDER_QUERY_MAX = 100;
7
8
  const Params = Type.Object({
8
9
  url: Type.String({ description: 'URL to fetch. Must be http or https.' }),
@@ -79,6 +80,14 @@ export function registerPiWorkerFetch(pi, internals = {}) {
79
80
  text += theme.fg('accent', args.url);
80
81
  text += `\n${theme.fg('dim', ` query: ${truncatedQuery}`)}`;
81
82
  return new Text(text, 0, 0);
82
- }
83
+ },
84
+ // Cache fetch answers per run (the same page re-fetched across sibling tasks
85
+ // otherwise). The URL is kept verbatim (path case can matter); the query is
86
+ // normalised. Both parts key the entry — same page, different question is a
87
+ // different answer.
88
+ cacheKey: params => `${params.url.trim()}::${normalizeQuery(params.query)}`,
89
+ // Only a completed fetch (child exited 0) is a real answer; invalid-URL,
90
+ // fetch failures, and aborts omit childExitCode:0 and fall through.
91
+ cacheable: d => d.childExitCode === 0
83
92
  });
84
93
  }
@@ -2,6 +2,7 @@ import { Type } from '@sinclair/typebox';
2
2
  import { Text } from '@earendil-works/pi-tui';
3
3
  import { search } from './search-core.js';
4
4
  import { makeWorkerTool } from './shared.js';
5
+ import { normalizeQuery } from './research-cache.js';
5
6
  const Params = Type.Object({
6
7
  query: Type.String({ description: 'Search query.' }),
7
8
  count: Type.Optional(Type.Integer({
@@ -49,6 +50,13 @@ export function registerPiWorkerSearch(pi, internals = {}) {
49
50
  text += theme.fg('dim', ` (count=${args.count})`);
50
51
  }
51
52
  return new Text(text, 0, 0);
52
- }
53
+ },
54
+ // Cache search results per run (the same query re-run across sibling tasks hits
55
+ // the live web anew otherwise). Count is part of the key — a larger request is a
56
+ // different result set.
57
+ cacheKey: params => `${normalizeQuery(params.query)}::${params.count ?? ''}`,
58
+ // Only a non-empty result set is worth caching; no-key, error, and empty results
59
+ // (resultCount 0) fall through so a later attempt can succeed.
60
+ cacheable: d => d.resultCount > 0
53
61
  });
54
62
  }
@@ -0,0 +1,39 @@
1
+ /** The env var the orchestrator stamps with the per-run id children inherit. */
2
+ export declare const RESEARCH_RUN_ID_ENV = "PI_TASK_RUN_ID";
3
+ export declare function researchCacheFile(cwd: string): string;
4
+ /**
5
+ * The current run's id, or undefined when caching is off (the orchestrator did not
6
+ * stamp one for this run). A worker treats undefined as "do not cache".
7
+ */
8
+ export declare function researchRunId(): string | undefined;
9
+ /** A fresh, per-invocation run token — stable within one run, unique across runs. */
10
+ export declare function newRunToken(): string;
11
+ /**
12
+ * Orchestrator hook: called once at the start of every /task-auto invocation. When
13
+ * caching is enabled it stamps a FRESH token (so a long-lived host never reuses a
14
+ * prior run's token, and planAuto + the task loop of THIS run share one id); when
15
+ * disabled it clears any token a prior run left, so the workers cache nothing.
16
+ */
17
+ export declare function configureResearchRun(enabled: boolean): string | undefined;
18
+ /**
19
+ * Normalise a query/module string for the cache KEY: collapse whitespace and
20
+ * lowercase, so trivially-varied phrasings of the same question share a digest. The
21
+ * stored value is the real answer, so a case/spacing collision only means two ways
22
+ * of asking the same thing resolve to the same (correct) result.
23
+ */
24
+ export declare function normalizeQuery(s: string): string;
25
+ /**
26
+ * Look up a cached result for `key` in the current run. Returns undefined on a miss,
27
+ * a stale-run file (different id ⇒ another run's digest, ignored), or any failure.
28
+ */
29
+ export declare function lookupResearch(cwd: string, runId: string, key: string): Promise<{
30
+ text: string;
31
+ details: unknown;
32
+ } | undefined>;
33
+ /**
34
+ * Store a successful result under `key` for the current run. A file written for a
35
+ * different run id is discarded and started fresh (first write of a new run drops the
36
+ * prior run's contents — self-healing per-run isolation without an explicit clear).
37
+ * Best-effort: any failure is swallowed, leaving the caller's live result untouched.
38
+ */
39
+ export declare function storeResearch(cwd: string, runId: string, key: string, text: string, details: unknown): Promise<void>;