@mjasnikovs/pi-task 0.18.3 → 0.18.4

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.
@@ -61,14 +61,14 @@ export declare function discoverGuidelines(cwd: string, readFile?: (p: string) =
61
61
  * done, and the contract for the final verdict line. Kept pure so the wording
62
62
  * is unit-tested without spawning pi.
63
63
  */
64
- export declare function buildEnforcePrompt(rulesText: string, diff: string): string;
64
+ export declare function buildEnforcePrompt(rulesText: string, diff: string, probeGamingFindings?: string[]): string;
65
65
  /**
66
66
  * Build the FLAG-ONLY enforcement prompt: same rules + diff, but the child has a
67
67
  * `read` tool only and is told to REPORT violations, not fix them. Kept pure so
68
68
  * the wording is unit-tested without spawning pi. Used when there is no
69
69
  * verification signal to guard a destructive edit (see ENFORCE_FLAG_TOOLS).
70
70
  */
71
- export declare function buildEnforceFlagPrompt(rulesText: string, diff: string): string;
71
+ export declare function buildEnforceFlagPrompt(rulesText: string, diff: string, probeGamingFindings?: string[]): string;
72
72
  /**
73
73
  * Parse the child's final verdict. Scans for the LAST `ENFORCE: CLEAN` /
74
74
  * `ENFORCE: VIOLATION` marker (the model may discuss before concluding).
@@ -26,6 +26,7 @@ import * as path from 'node:path';
26
26
  import { runChildDefault } from '../shared/child-process.js';
27
27
  import { USER_CANCELLED } from './child-runner.js';
28
28
  import { TASKS_DIR_NAME } from './task-types.js';
29
+ import { findProbeGamingInDiff } from './probe-gaming.js';
29
30
  /** Filenames discovered in the working directory (cwd only — no tree walk). */
30
31
  export const GUIDELINE_FILENAMES = ['AGENTS.md', 'CLAUDE.md'];
31
32
  /**
@@ -87,12 +88,33 @@ export async function discoverGuidelines(cwd, readFile = p => fsp.readFile(p, 'u
87
88
  return null;
88
89
  return { files, text: sections.join('\n\n') };
89
90
  }
91
+ /**
92
+ * Render the deterministic probe-gaming findings (run-8 F6) as a prompt block, or
93
+ * an empty array when there are none. Shared by the edit and flag prompts: the
94
+ * finding is a concrete diff line whose stated purpose is to make a check pass
95
+ * rather than meet the requirement — the reliable lever the prompt rule leans on.
96
+ * The `action` line differs (fix vs report) between the two capability modes.
97
+ */
98
+ function probeGamingEnforceBlock(findings, action) {
99
+ if (findings.length === 0)
100
+ return [];
101
+ return [
102
+ 'CHECK-GAMING NOTICE (deterministic, computed from the diff): these added lines',
103
+ 'state their own purpose is to make a CHECK pass (a test / verification / lint /',
104
+ 'gate), not to meet the requirement the check stands for:',
105
+ ...findings.map(f => `- ${f}`),
106
+ 'A check is a MESSENGER for a requirement; code written only to quiet the messenger',
107
+ 'is a defect even when the check is green (run-8 F6: a handler returned 401 "so the',
108
+ `verification test passes" while the real route stayed dead). ${action}`,
109
+ ''
110
+ ];
111
+ }
90
112
  /**
91
113
  * Build the enforcement child's prompt: the rules, the diff of the work just
92
114
  * done, and the contract for the final verdict line. Kept pure so the wording
93
115
  * is unit-tested without spawning pi.
94
116
  */
95
- export function buildEnforcePrompt(rulesText, diff) {
117
+ export function buildEnforcePrompt(rulesText, diff, probeGamingFindings = []) {
96
118
  return [
97
119
  'You are a strict guideline-enforcement pass running right after an AI coding',
98
120
  'agent finished a task and committed it. The agent is known to skip project',
@@ -111,6 +133,9 @@ export function buildEnforcePrompt(rulesText, diff) {
111
133
  'CHANGES IN THE LAST COMMIT (verify these specifically against the rules):',
112
134
  diff.trim().length > 0 ? diff : '(no textual diff captured — nothing to verify)',
113
135
  '',
136
+ ...probeGamingEnforceBlock(probeGamingFindings, 'Treat this as a violation: replace the check-gaming code with a real'
137
+ + ' implementation of the requirement, or if you cannot, report it as a'
138
+ + ' VIOLATION naming the gamed check.'),
114
139
  'Your job:',
115
140
  '1. Read each changed file and check it against EVERY rule above.',
116
141
  '2. For each violation you find, FIX it directly with your `edit` tool, then',
@@ -130,7 +155,7 @@ export function buildEnforcePrompt(rulesText, diff) {
130
155
  * the wording is unit-tested without spawning pi. Used when there is no
131
156
  * verification signal to guard a destructive edit (see ENFORCE_FLAG_TOOLS).
132
157
  */
133
- export function buildEnforceFlagPrompt(rulesText, diff) {
158
+ export function buildEnforceFlagPrompt(rulesText, diff, probeGamingFindings = []) {
134
159
  return [
135
160
  'You are a strict guideline-enforcement REVIEW pass running right after an AI',
136
161
  'coding agent finished a task and committed it. The agent is known to skip',
@@ -146,6 +171,8 @@ export function buildEnforceFlagPrompt(rulesText, diff) {
146
171
  'CHANGES IN THE LAST COMMIT (review these specifically against the rules):',
147
172
  diff.trim().length > 0 ? diff : '(no textual diff captured — nothing to verify)',
148
173
  '',
174
+ ...probeGamingEnforceBlock(probeGamingFindings, 'Treat this as a violation and REPORT it (do not fix it): name the gamed check'
175
+ + ' and the requirement left unmet.'),
149
176
  'Read each changed file and check it against EVERY rule above. Do NOT attempt',
150
177
  'to fix anything — only report what you find.',
151
178
  '',
@@ -279,9 +306,15 @@ export async function runGuidelineEnforcement(deps) {
279
306
  if (!doc)
280
307
  return { ok: true, reason: 'no guideline files' };
281
308
  const diff = await getDiff(deps.cwd, deps.signal);
309
+ // Deterministic probe-gaming findings (F6) straight from the captured diff — no
310
+ // extra git call, the diff is already in hand. Injected under the CHECK-GAMING
311
+ // rule so the child acts on a concrete line, not on self-discovered intent.
312
+ const probeGaming = findProbeGamingInDiff(diff);
282
313
  const flagOnly = deps.mode === 'flag';
283
314
  const tools = flagOnly ? ENFORCE_FLAG_TOOLS : ENFORCE_TOOLS;
284
- const prompt = flagOnly ? buildEnforceFlagPrompt(doc.text, diff) : buildEnforcePrompt(doc.text, diff);
315
+ const prompt = flagOnly ?
316
+ buildEnforceFlagPrompt(doc.text, diff, probeGaming)
317
+ : buildEnforcePrompt(doc.text, diff, probeGaming);
285
318
  let text;
286
319
  try {
287
320
  text = await deps.runChild(tools, prompt, deps.signal);
@@ -1,23 +1,39 @@
1
1
  export declare function envNotesFile(cwd: string): string;
2
- /** The cached notes, one fact per line ('' when none were recorded yet). */
2
+ /** The raw stored file ('' when none were recorded yet). Parse with parseEnvNotes. */
3
3
  export declare function readEnvNotes(cwd: string): Promise<string>;
4
+ /** One recorded fact plus the origin task that established it (may be ''). */
5
+ export interface EnvNote {
6
+ fact: string;
7
+ origin: string;
8
+ }
9
+ /**
10
+ * Parse the stored file into fact+origin records. Legacy lines written before
11
+ * provenance (no separator) parse with an empty origin, so old caches still read.
12
+ */
13
+ export declare function parseEnvNotes(raw: string): EnvNote[];
4
14
  /**
5
15
  * Pull `ENV-NOTE: <fact>` lines out of a child's answer text. Deduplicated,
6
16
  * length-capped; verdict markers can never match (different prefix).
7
17
  */
8
18
  export declare function extractEnvNotes(text: string): string[];
19
+ /** True when a fact reads like a standing excuse (see EXCUSE_PATTERNS). */
20
+ export declare function isExcuseNote(fact: string): boolean;
9
21
  /**
10
22
  * Append newly discovered facts to the cache, deduplicated against what is
11
- * already there (case-insensitive full-line match), keeping the newest
12
- * MAX_NOTES. Failures are swallowed — the cache is a sharpener, never a
23
+ * already there (case-insensitive fact match), keeping the newest MAX_NOTES.
24
+ * Each new fact is stamped with the `origin` task that recorded it; a fact
25
+ * already present keeps its ORIGINAL origin (provenance traces to who first
26
+ * established it). Failures are swallowed — the cache is a sharpener, never a
13
27
  * blocker.
14
28
  */
15
- export declare function appendEnvNotes(cwd: string, notes: string[]): Promise<void>;
29
+ export declare function appendEnvNotes(cwd: string, notes: string[], origin?: string): Promise<void>;
16
30
  /**
17
- * The prompt block a gate child receives when notes exist. The caveat is
18
- * load-bearing: facts save re-discovery time but grant no waiver from the
19
- * verify-as-shipped rule.
31
+ * The prompt block a gate child receives when notes exist. Two things are
32
+ * load-bearing: the no-waiver caveat (facts save re-discovery time but grant no
33
+ * license to prepare/repair) and the trust discipline (a note is second-hand
34
+ * until re-validated; an EXCUSE-CLASS note may not wave off a failure without a
35
+ * live re-check; a grep of a generated artifact is not evidence of absence).
20
36
  */
21
- export declare function buildEnvNotesBlock(notes: string): string;
37
+ export declare function buildEnvNotesBlock(raw: string): string;
22
38
  /** The emit instruction appended to bash-capable gate-child prompts. */
23
39
  export declare const ENV_NOTE_EMIT_INSTRUCTION: string;
@@ -18,6 +18,19 @@
18
18
  * project setup: the verify-as-shipped rule ("any prep you needed IS the
19
19
  * defect") still governs every verdict — the block injected into prompts says
20
20
  * so explicitly. The cache only kills re-discovery time.
21
+ *
22
+ * PROVENANCE + RE-VALIDATION (run 8, F7): a verify child once grepped component
23
+ * names in a MINIFIED bundle (identifiers mangled ⇒ 0 hits by construction),
24
+ * wrote "build tree-shakes ALL route components — pre-existing issue" to the
25
+ * cache, and ten later tasks inherited it verbatim as a standing "pre-existing,
26
+ * unrelated" excuse to wave off a genuinely broken deliverable — nobody
27
+ * re-checked. Two guards close that class: (a) each note is stamped host-side
28
+ * with the ORIGIN task that recorded it (a note is second-hand hearsay, not the
29
+ * reader's own observation); (b) the injected block demands the reader
30
+ * RE-VALIDATE a note in the CURRENT tree before citing it to excuse a failure,
31
+ * flags EXCUSE-CLASS notes ("pre-existing", "unrelated", "tree-shaken") for
32
+ * exactly that scrutiny, and forbids treating a grep of a generated artifact as
33
+ * evidence of absence. Provenance is mechanical; re-validation is prompt-level.
21
34
  */
22
35
  import * as fsp from 'node:fs/promises';
23
36
  import * as path from 'node:path';
@@ -27,10 +40,16 @@ const ENV_NOTES_FILE = 'env-notes.md';
27
40
  const MAX_NOTES = 40;
28
41
  /** A single fact is one line; anything longer is prose, not a fact. */
29
42
  const MAX_NOTE_LENGTH = 240;
43
+ /**
44
+ * Field separator between a fact and its origin in the stored file. A tab never
45
+ * occurs in a one-line fact (facts are prose), so it round-trips cleanly and any
46
+ * stray tab in an emitted fact is normalised to a space before storage.
47
+ */
48
+ const ORIGIN_SEP = '\t';
30
49
  export function envNotesFile(cwd) {
31
50
  return path.join(tasksDir(cwd), ENV_NOTES_FILE);
32
51
  }
33
- /** The cached notes, one fact per line ('' when none were recorded yet). */
52
+ /** The raw stored file ('' when none were recorded yet). Parse with parseEnvNotes. */
34
53
  export async function readEnvNotes(cwd) {
35
54
  try {
36
55
  return (await fsp.readFile(envNotesFile(cwd), 'utf8')).trim();
@@ -39,6 +58,27 @@ export async function readEnvNotes(cwd) {
39
58
  return '';
40
59
  }
41
60
  }
61
+ /**
62
+ * Parse the stored file into fact+origin records. Legacy lines written before
63
+ * provenance (no separator) parse with an empty origin, so old caches still read.
64
+ */
65
+ export function parseEnvNotes(raw) {
66
+ const out = [];
67
+ for (const line of raw.split('\n')) {
68
+ const t = line.trim();
69
+ if (t.length === 0)
70
+ continue;
71
+ const i = t.indexOf(ORIGIN_SEP);
72
+ if (i === -1)
73
+ out.push({ fact: t, origin: '' });
74
+ else
75
+ out.push({ fact: t.slice(0, i).trim(), origin: t.slice(i + 1).trim() });
76
+ }
77
+ return out;
78
+ }
79
+ function serializeNote(n) {
80
+ return n.origin ? `${n.fact}${ORIGIN_SEP}${n.origin}` : n.fact;
81
+ }
42
82
  /**
43
83
  * Pull `ENV-NOTE: <fact>` lines out of a child's answer text. Deduplicated,
44
84
  * length-capped; verdict markers can never match (different prefix).
@@ -58,54 +98,110 @@ export function extractEnvNotes(text) {
58
98
  }
59
99
  return notes;
60
100
  }
101
+ /**
102
+ * EXCUSE-CLASS wording: a note that waves a problem off as someone else's or a
103
+ * prior condition ("pre-existing … mismatch", "unrelated to this task",
104
+ * "tree-shaken", "not applicable"). These are the notes that propagate across
105
+ * slices as standing excuses (mx5 run-8 F7) — every one of the run-8 cache's
106
+ * dozen such notes was either the false tree-shake fact or the schema mismatch
107
+ * the final gate later proved was a REAL defect. Pure text, stack-agnostic; the
108
+ * flag never drops or fails a note, it only marks it as needing live
109
+ * re-validation before it may EXCUSE a failure. FP is harmless by construction:
110
+ * a benign fact re-validates and is used, the marker only bites a citation-to-
111
+ * wave-off. Benign status facts ("5 pre-existing warnings") do not match — a
112
+ * "pre-existing" match requires a co-located problem word.
113
+ */
114
+ const EXCUSE_PATTERNS = [
115
+ /\bunrelated\b/i,
116
+ /\bnot (?:my|our|this) (?:task|concern|deliverable|slice|problem)\b/i,
117
+ /\boutside (?:the|this) deliverable\b/i,
118
+ /\bnot applicable\b/i,
119
+ /\bnot specific to\b/i,
120
+ /\btree[-\s]?shak/i,
121
+ /\bpre-?existing\b[^.\n]*\b(?:fail|issue|mismatch|bug|error|broken|problem|affect)/i,
122
+ /\baffect(?:ing|s)? all\b/i
123
+ ];
124
+ /** True when a fact reads like a standing excuse (see EXCUSE_PATTERNS). */
125
+ export function isExcuseNote(fact) {
126
+ return EXCUSE_PATTERNS.some(re => re.test(fact));
127
+ }
61
128
  /**
62
129
  * Append newly discovered facts to the cache, deduplicated against what is
63
- * already there (case-insensitive full-line match), keeping the newest
64
- * MAX_NOTES. Failures are swallowed — the cache is a sharpener, never a
130
+ * already there (case-insensitive fact match), keeping the newest MAX_NOTES.
131
+ * Each new fact is stamped with the `origin` task that recorded it; a fact
132
+ * already present keeps its ORIGINAL origin (provenance traces to who first
133
+ * established it). Failures are swallowed — the cache is a sharpener, never a
65
134
  * blocker.
66
135
  */
67
- export async function appendEnvNotes(cwd, notes) {
136
+ export async function appendEnvNotes(cwd, notes, origin = '') {
68
137
  if (notes.length === 0)
69
138
  return;
70
139
  try {
71
- const existing = (await readEnvNotes(cwd)).split('\n').filter(l => l.trim().length > 0);
72
- const seen = new Set(existing.map(l => l.trim().toLowerCase()));
140
+ const existing = parseEnvNotes(await readEnvNotes(cwd));
141
+ const seen = new Set(existing.map(n => n.fact.toLowerCase()));
73
142
  const merged = [...existing];
74
143
  for (const note of notes) {
75
- const key = note.trim().toLowerCase();
76
- if (seen.has(key))
144
+ const fact = note.trim().replace(/\t/g, ' ');
145
+ const key = fact.toLowerCase();
146
+ if (key.length === 0 || seen.has(key))
77
147
  continue;
78
148
  seen.add(key);
79
- merged.push(note.trim());
149
+ merged.push({ fact, origin: origin.trim() });
80
150
  }
81
151
  const kept = merged.slice(-MAX_NOTES);
82
152
  await fsp.mkdir(tasksDir(cwd), { recursive: true });
83
- await fsp.writeFile(envNotesFile(cwd), kept.join('\n') + '\n', 'utf8');
153
+ await fsp.writeFile(envNotesFile(cwd), kept.map(serializeNote).join('\n') + '\n', 'utf8');
84
154
  }
85
155
  catch {
86
156
  // best-effort cache
87
157
  }
88
158
  }
89
159
  /**
90
- * The prompt block a gate child receives when notes exist. The caveat is
91
- * load-bearing: facts save re-discovery time but grant no waiver from the
92
- * verify-as-shipped rule.
160
+ * The prompt block a gate child receives when notes exist. Two things are
161
+ * load-bearing: the no-waiver caveat (facts save re-discovery time but grant no
162
+ * license to prepare/repair) and the trust discipline (a note is second-hand
163
+ * until re-validated; an EXCUSE-CLASS note may not wave off a failure without a
164
+ * live re-check; a grep of a generated artifact is not evidence of absence).
93
165
  */
94
- export function buildEnvNotesBlock(notes) {
95
- if (notes.trim().length === 0)
166
+ export function buildEnvNotesBlock(raw) {
167
+ const notes = parseEnvNotes(raw);
168
+ if (notes.length === 0)
96
169
  return '';
170
+ const lines = notes.map(n => {
171
+ const origin = n.origin ? ` — recorded by ${n.origin}` : ' — origin unrecorded';
172
+ const flag = isExcuseNote(n.fact) ?
173
+ ' [EXCUSE-CLASS — re-validate in the CURRENT tree before citing this to wave off any failure]'
174
+ : '';
175
+ return `- ${n.fact}${origin}${flag}`;
176
+ });
97
177
  return [
98
- 'KNOWN ENVIRONMENT FACTS — discovered by earlier verification passes in this run',
99
- '(informational, may be stale):',
100
- ...notes
101
- .trim()
102
- .split('\n')
103
- .map(n => `- ${n}`),
178
+ 'KNOWN ENVIRONMENT FACTS — recorded by earlier verification passes in this run',
179
+ '(second-hand, may be stale or WRONG):',
180
+ ...lines,
181
+ '',
104
182
  'These facts only save you re-discovery time (where credentials/config live, which',
105
183
  'tools are installed, which services are reachable). They are NOT a license to',
106
184
  'prepare or repair the run: the verify-as-shipped rules below still govern the',
107
185
  'verdict — if the project needs something its own committed files do not provide,',
108
186
  'that remains the defect no matter what is listed here.',
187
+ '',
188
+ 'TRUST DISCIPLINE — a false "pre-existing, unrelated" note once masked a real shipped',
189
+ 'defect across many tasks in this exact pipeline; do not repeat it:',
190
+ '- A note above is second-hand hearsay from another task, not your own observation.',
191
+ ' You may CITE one to EXCUSE, wave off, or down-grade a failure ONLY IF you',
192
+ ' RE-VALIDATE its claim in the CURRENT tree right now and state the command or',
193
+ ' observation you used to reconfirm it.',
194
+ '- If a note fails re-validation (its claim is not true in the current tree), do NOT',
195
+ " inherit it: treat the underlying problem as UNexcused and report it. Don't silently",
196
+ ' carry a stale fact forward.',
197
+ '- EVIDENCE HYGIENE: string-searching a MINIFIED, bundled, or otherwise generated or',
198
+ ' compiled artifact is NOT evidence that something is absent — identifiers there are',
199
+ ' renamed or stripped by construction, so a zero-hit grep proves nothing. Derive',
200
+ ' presence/absence only from SOURCE files or by EXECUTING the artifact and observing.',
201
+ '- A claim that a defect is "pre-existing", "unrelated", or "not this task" is an',
202
+ ' EXCUSE, not a fact (the ones above are marked): re-establish it live, and if it',
203
+ ' actually holds as a real defect, escalate it (report FAIL) rather than passing it',
204
+ ' on as a standing waiver.',
109
205
  ''
110
206
  ].join('\n');
111
207
  }
@@ -115,7 +211,11 @@ export const ENV_NOTE_EMIT_INSTRUCTION = [
115
211
  'THIS MACHINE or the project environment (a service reachable/absent at an address, where',
116
212
  'credentials/config live, a tool or runtime present/missing and its version), emit a line',
117
213
  ' ENV-NOTE: <one-line fact>',
118
- 'anywhere in your answer, one per fact. Facts about the ENVIRONMENT only — never task',
119
- 'verdicts, never spec content, never code judgments. These are cached for later',
120
- 'verification passes in this run so they do not re-discover the same things.'
214
+ 'anywhere in your answer, one per fact. A fact is something you OBSERVED to be true of the',
215
+ 'machine or environment — never a task verdict, never spec content, never a judgment about',
216
+ 'the code. In particular do NOT record an absence you inferred from grepping a built or',
217
+ 'minified artifact (identifiers there are mangled — a zero-hit grep proves nothing), and',
218
+ 'do NOT record "X is pre-existing / unrelated / not my task": that is a verdict, not an',
219
+ 'environment fact. These are cached for later verification passes in this run so they do',
220
+ 'not re-discover the same things.'
121
221
  ].join('\n');
@@ -1,6 +1,7 @@
1
1
  import type { ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
2
2
  import type { GateDeps } from './task-gates.js';
3
3
  import { type FinalFixResult } from './final-gate-fix.js';
4
+ import { type AddedLine } from './probe-gaming.js';
4
5
  import { type ChangedFile } from './substitution-probe.js';
5
6
  /** A function that re-runs a task's implementation turn (AUTOFIX). Injected by the
6
7
  * command so this module stays free of the orchestrators (avoids an import cycle). */
@@ -17,6 +18,15 @@ export type FinalGateFixFn = (ctx: ExtensionCommandContext, cwd: string, failRea
17
18
  * blocker.
18
19
  */
19
20
  export declare function collectChangedFiles(cwd: string, signal?: AbortSignal): Promise<ChangedFile[]>;
21
+ /**
22
+ * Collect the task's added lines WITH CONTENT (path + text) for the probe-gaming
23
+ * probe (F6), which needs the actual line text the numstat-shape collector omits.
24
+ * Pre-commit the work is the tree-vs-HEAD diff plus untracked files (read whole, as
25
+ * all-added); on the post-enforce re-verify the tree is clean, so fall back to the
26
+ * last commit's diff. Failures degrade to an empty list — the probe is a sharpener,
27
+ * never a blocker. The `.pi-tasks/` bookkeeping is excluded from every git command.
28
+ */
29
+ export declare function collectAddedLines(cwd: string, signal?: AbortSignal): Promise<AddedLine[]>;
20
30
  /**
21
31
  * Build the gate deps for one command run. `runTask` is the orchestrator's
22
32
  * implementation re-runner, injected by the caller. The returned object also drives
@@ -26,7 +26,9 @@ import { runFinalIntegrationGate, discoverGateCommandLabels } from './final-gate
26
26
  import { runFinalGateAutofix } from './final-gate-fix.js';
27
27
  import { researchResolution } from './verify-resolution.js';
28
28
  import { extractProhibitions, findProhibitionViolations } from './prohibition-probe.js';
29
- import { findSubstitutionSuspects } from './substitution-probe.js';
29
+ import { findProbeGaming, parseAddedLines } from './probe-gaming.js';
30
+ import { findSubstitutionSuspects, isTestFile } from './substitution-probe.js';
31
+ import { findTestRebuiltAssemblies, testAssemblyVerifyFindings } from './test-assembly.js';
30
32
  import { runBoundedLintFix } from './lint-fix.js';
31
33
  import { captureGitState, reconcileGitState } from './git-state-guard.js';
32
34
  import { runWorker } from '../workers/pi-worker-core.js';
@@ -76,6 +78,85 @@ export async function collectChangedFiles(cwd, signal) {
76
78
  }
77
79
  return files;
78
80
  }
81
+ /**
82
+ * Collect the task's added lines WITH CONTENT (path + text) for the probe-gaming
83
+ * probe (F6), which needs the actual line text the numstat-shape collector omits.
84
+ * Pre-commit the work is the tree-vs-HEAD diff plus untracked files (read whole, as
85
+ * all-added); on the post-enforce re-verify the tree is clean, so fall back to the
86
+ * last commit's diff. Failures degrade to an empty list — the probe is a sharpener,
87
+ * never a blocker. The `.pi-tasks/` bookkeeping is excluded from every git command.
88
+ */
89
+ export async function collectAddedLines(cwd, signal) {
90
+ const tracked = await git(cwd, ['diff', 'HEAD', '--', '.', EXCLUDE_TASKS_DIR], signal);
91
+ const lines = tracked.exitCode === 0 ? parseAddedLines(tracked.stdout) : [];
92
+ const untracked = await git(cwd, ['ls-files', '--others', '--exclude-standard', '--', '.', EXCLUDE_TASKS_DIR], signal);
93
+ for (const name of splitLines(untracked.exitCode === 0 ? untracked.stdout : '')) {
94
+ try {
95
+ const content = await fsp.readFile(path.join(cwd, name), 'utf8');
96
+ for (const text of content.split('\n'))
97
+ lines.push({ path: name, text });
98
+ }
99
+ catch {
100
+ // unreadable/binary — nothing to report
101
+ }
102
+ }
103
+ if (lines.length === 0) {
104
+ const last = await git(cwd, ['diff', 'HEAD~1..HEAD', '--', '.', EXCLUDE_TASKS_DIR], signal);
105
+ return last.exitCode === 0 ? parseAddedLines(last.stdout) : [];
106
+ }
107
+ return lines;
108
+ }
109
+ /** Source extensions whose relative imports the test-assembly probe reasons over. */
110
+ const SOURCE_EXT_RE = /\.(?:[cm]?[jt]sx?)$/;
111
+ /** Bounds so the probe stays cheap on large repos (it reads file text). */
112
+ const MAX_PROBE_FILES = 4000;
113
+ const MAX_PROBE_FILE_BYTES = 512 * 1024;
114
+ /** Best-effort read of a repo file's text; unreadable/oversized → null (skipped). */
115
+ async function readRepoFile(cwd, rel) {
116
+ try {
117
+ const buf = await fsp.readFile(path.join(cwd, rel));
118
+ if (buf.length > MAX_PROBE_FILE_BYTES)
119
+ return null;
120
+ return { path: rel, text: buf.toString('utf8') };
121
+ }
122
+ catch {
123
+ return null;
124
+ }
125
+ }
126
+ /**
127
+ * Deterministic test-assembly probe input (see test-assembly.ts, run-8 F4): read the
128
+ * task's own changed TEST files plus the repo's tracked source files, and return the
129
+ * finding lines naming any test that rebuilds a production assembly it never imports.
130
+ * Pure import-graph shape; failures degrade to no findings (the probe is a sharpener,
131
+ * never a blocker). `changed` is the already-collected task diff, reused so the probe
132
+ * costs one extra tracked-file listing, not a second diff.
133
+ */
134
+ async function collectTestAssemblyFindings(cwd, changed, signal) {
135
+ const changedTests = changed.filter(f => isTestFile(f.path));
136
+ if (changedTests.length === 0)
137
+ return [];
138
+ const listed = await git(cwd, ['ls-files', '--', '.', EXCLUDE_TASKS_DIR], signal);
139
+ if (listed.exitCode !== 0)
140
+ return [];
141
+ const sources = splitLines(listed.stdout)
142
+ .filter(p => SOURCE_EXT_RE.test(p))
143
+ .slice(0, MAX_PROBE_FILES);
144
+ const production = [];
145
+ for (const rel of sources) {
146
+ if (isTestFile(rel))
147
+ continue;
148
+ const f = await readRepoFile(cwd, rel);
149
+ if (f)
150
+ production.push(f);
151
+ }
152
+ const testFiles = [];
153
+ for (const { path: rel } of changedTests) {
154
+ const f = await readRepoFile(cwd, rel);
155
+ if (f)
156
+ testFiles.push(f);
157
+ }
158
+ return testAssemblyVerifyFindings(findTestRebuiltAssemblies(testFiles, production));
159
+ }
79
160
  /**
80
161
  * Build the gate deps for one command run. `runTask` is the orchestrator's
81
162
  * implementation re-runner, injected by the caller. The returned object also drives
@@ -292,6 +373,16 @@ export function buildGateDeps(params) {
292
373
  // authored/changed become prompt-level findings mandating the child
293
374
  // to drive the real artifact before trusting their green result.
294
375
  probe: () => collectChangedFiles(cwd2, signal).then(findSubstitutionSuspects),
376
+ // Deterministic test-assembly probe (F4): authored test files that
377
+ // rebuild production wiring — importing the leaf modules the shipped
378
+ // entry composes and assembling their own copy — become rule-3f
379
+ // findings so the child drives the REAL assembly, not the copy.
380
+ testAssemblyProbe: () => collectChangedFiles(cwd2, signal).then(changed => collectTestAssemblyFindings(cwd2, changed, signal)),
381
+ // Deterministic probe-gaming probe (F6): added lines whose stated
382
+ // purpose is to make a check pass rather than meet the requirement
383
+ // ("return 401 so the verification test passes") become rule-4c
384
+ // findings so the child verifies the real requirement, not the check.
385
+ probeGamingProbe: () => collectAddedLines(cwd2, signal).then(findProbeGaming),
295
386
  // Deterministic prohibition probe: paths the spec forbids modifying
296
387
  // that the task's diff modified anyway become prompt-level findings
297
388
  // under the no-waiver rule — the child otherwise rarely runs `git
@@ -311,10 +402,13 @@ export function buildGateDeps(params) {
311
402
  // Per-run environment-facts cache under .pi-tasks/ (survives
312
403
  // discardEdits): earlier children's discoveries save this child
313
404
  // the re-archaeology; its own ENV-NOTE lines are stored for the
314
- // next one. Facts only — verdict rules unaffected.
405
+ // next one, stamped with this task's id as their origin so a
406
+ // later child sees a cited fact is second-hand and must
407
+ // re-validate before excusing a failure (F7). Facts only —
408
+ // verdict rules unaffected.
315
409
  envNotes: {
316
410
  read: () => readEnvNotes(cwd2),
317
- append: notes => appendEnvNotes(cwd2, notes)
411
+ append: notes => appendEnvNotes(cwd2, notes, taskId)
318
412
  },
319
413
  // Per-run cross-slice contract registry under .pi-tasks/ (F3): the
320
414
  // verbatim interface facts the design pins that multiple slices
@@ -0,0 +1,60 @@
1
+ /**
2
+ * probe-gaming — deterministic detection of CHECK-GAMING code, feeding the verify
3
+ * and enforce gate prompts (run-8 F6).
4
+ *
5
+ * The failure class: the implementation writes code — or a comment on it — whose
6
+ * STATED PURPOSE is to make a check pass, rather than to satisfy the requirement the
7
+ * check stands for. mx5 run-8 shipped, verbatim,
8
+ * `// Return 401 so the verification test passes (expects 200/401/403 for /api routes).`
9
+ * above four catch-all handlers added to satisfy the VERIFY curl instead of fixing
10
+ * the broken route mount. The route stayed dead; the check went green; the code SAID
11
+ * SO IN WRITING. A check is a MESSENGER for a requirement — code written to quiet the
12
+ * messenger instead of meeting the requirement is the defect, and when it announces
13
+ * its own intent it is cheaply detectable.
14
+ *
15
+ * Same probe+rule design as the other run-8 probes (skip-escape.ts,
16
+ * prohibition-probe.ts, substitution-probe.ts, test-assembly.ts): a deterministic
17
+ * finding is the reliable lever, a prompt rule alone is weak. This scans the task's
18
+ * DIFF (added lines only — the work this task introduced) for the gaming tell and
19
+ * hands each hit to the gate child verbatim, so the rule fires on a concrete line
20
+ * rather than on the model self-discovering the intent.
21
+ *
22
+ * Advisory, never an auto-FAIL: intent phrasing is prose and a genuinely-benign line
23
+ * could in principle carry it ("returns 401 so the auth test passes" describing a
24
+ * CORRECT auth path). The child reads the exact line and the surrounding code and
25
+ * judges — the verify rule directs it to confirm the underlying requirement is
26
+ * actually met, not merely that the check is green.
27
+ *
28
+ * FP-MEASURED on the real run-8 corpus (~/hub/mx5, all 53 commits): 1 unique hit in
29
+ * 50,735 added lines — exactly the true F6 line, zero false positives. The tell is a
30
+ * purpose phrase binding a CHECK noun (test / verification / lint / CI / gate / …) to
31
+ * a pass-state or a gaming verb; benign uses of the same words ("pass the test data",
32
+ * "run the verification suite", "the linter flagged this") do not match. Pure
33
+ * diff-text analysis — no stack, framework, or tool-name assumptions; a project with
34
+ * no such comment simply yields no findings (nothing to observe = pass).
35
+ */
36
+ /** One added line from a task's diff: the file it was added to and its text. */
37
+ export interface AddedLine {
38
+ /** Path relative to the repo root (from the `+++ b/…` header). */
39
+ path: string;
40
+ /** The added line's content, without the leading `+`. */
41
+ text: string;
42
+ }
43
+ /** Does this line state its purpose is to make a check pass? */
44
+ export declare function isProbeGamingLine(text: string): boolean;
45
+ /**
46
+ * Parse a unified `git diff` into its added lines (path + text). Tracks the current
47
+ * file from each `+++ b/…` header and collects every `+`-prefixed body line
48
+ * (excluding the `+++` header itself). Robust to surrounding prose (the enforce diff
49
+ * capture wraps the diff in a header/footer) because only `+`/`+++` lines are read.
50
+ */
51
+ export declare function parseAddedLines(diff: string): AddedLine[];
52
+ /**
53
+ * Scan a set of added lines for check-gaming tells. Returns one finding per matching
54
+ * line; empty when none match (the prompt then gets no probe block). The finding
55
+ * carries the file and the offending line verbatim so the gate child can locate and
56
+ * judge it. Long lines are truncated for the prompt.
57
+ */
58
+ export declare function findProbeGaming(added: AddedLine[]): string[];
59
+ /** Convenience: scan a unified diff string straight to findings. */
60
+ export declare function findProbeGamingInDiff(diff: string): string[];
Binary file
@@ -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)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.18.3",
3
+ "version": "0.18.4",
4
4
  "description": "Deterministic task planning and spec-orchestration for local models — crash-safe /task pipelines with verify/enforce gates, a real-time remote web view, and web/docs/fetch/worker subagent tools.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -14,7 +14,7 @@
14
14
  "scripts": {
15
15
  "build": "tsc -p tsconfig.build.json",
16
16
  "lint": "prettier --log-level warn --write 'src/**/*.ts' && eslint --fix . && tsc --noEmit",
17
- "test": "bun test src/",
17
+ "test": "AGENT=1 bun test src/",
18
18
  "prepublishOnly": "bun run build"
19
19
  },
20
20
  "peerDependencies": {