@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.
- package/dist/config/config.d.ts +11 -0
- package/dist/config/config.js +4 -1
- package/dist/config/register.js +5 -0
- package/dist/task/accept-debt.d.ts +52 -0
- package/dist/task/accept-debt.js +0 -0
- package/dist/task/auto-orchestrator.d.ts +2 -0
- package/dist/task/auto-orchestrator.js +20 -0
- package/dist/task/enforce-guidelines.d.ts +2 -2
- package/dist/task/enforce-guidelines.js +36 -3
- package/dist/task/env-notes.d.ts +24 -8
- package/dist/task/env-notes.js +124 -24
- package/dist/task/final-gate.d.ts +8 -0
- package/dist/task/final-gate.js +27 -7
- package/dist/task/frozen-path-guard.d.ts +39 -0
- package/dist/task/frozen-path-guard.js +116 -0
- package/dist/task/gate-deps.d.ts +10 -0
- package/dist/task/gate-deps.js +122 -3
- package/dist/task/probe-gaming.d.ts +60 -0
- package/dist/task/probe-gaming.js +0 -0
- package/dist/task/repo-health-check.d.ts +11 -0
- package/dist/task/repo-health-check.js +26 -3
- package/dist/task/task-gates.d.ts +24 -0
- package/dist/task/task-gates.js +78 -8
- package/dist/task/test-assembly.d.ts +87 -0
- package/dist/task/test-assembly.js +163 -0
- package/dist/task/verify-work.d.ts +17 -1
- package/dist/task/verify-work.js +87 -2
- package/dist/workers/pi-worker-docs.js +13 -1
- package/dist/workers/pi-worker-fetch.js +10 -1
- package/dist/workers/pi-worker-search.js +9 -1
- package/dist/workers/research-cache.d.ts +39 -0
- package/dist/workers/research-cache.js +140 -0
- package/dist/workers/shared.d.ts +17 -0
- package/dist/workers/shared.js +0 -0
- 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
|
package/dist/task/verify-work.js
CHANGED
|
@@ -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>;
|