@mjasnikovs/pi-task 0.18.2 → 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.
@@ -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
@@ -20,12 +20,15 @@ import { gitCommitAll, gitDropLastCommit, git } from './auto-commit.js';
20
20
  import { runGuidelineEnforcement, classifyEnforceChildFailure } from './enforce-guidelines.js';
21
21
  import { runWorkVerification, extractSpecForVerification } from './verify-work.js';
22
22
  import { readEnvNotes, appendEnvNotes } from './env-notes.js';
23
+ import { readContracts } from './contracts.js';
23
24
  import { runRepoHealthCheck } from './repo-health-check.js';
24
25
  import { runFinalIntegrationGate, discoverGateCommandLabels } from './final-gate.js';
25
26
  import { runFinalGateAutofix } from './final-gate-fix.js';
26
27
  import { researchResolution } from './verify-resolution.js';
27
28
  import { extractProhibitions, findProhibitionViolations } from './prohibition-probe.js';
28
- 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';
29
32
  import { runBoundedLintFix } from './lint-fix.js';
30
33
  import { captureGitState, reconcileGitState } from './git-state-guard.js';
31
34
  import { runWorker } from '../workers/pi-worker-core.js';
@@ -75,6 +78,85 @@ export async function collectChangedFiles(cwd, signal) {
75
78
  }
76
79
  return files;
77
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
+ }
78
160
  /**
79
161
  * Build the gate deps for one command run. `runTask` is the orchestrator's
80
162
  * implementation re-runner, injected by the caller. The returned object also drives
@@ -291,6 +373,16 @@ export function buildGateDeps(params) {
291
373
  // authored/changed become prompt-level findings mandating the child
292
374
  // to drive the real artifact before trusting their green result.
293
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),
294
386
  // Deterministic prohibition probe: paths the spec forbids modifying
295
387
  // that the task's diff modified anyway become prompt-level findings
296
388
  // under the no-waiver rule — the child otherwise rarely runs `git
@@ -310,11 +402,20 @@ export function buildGateDeps(params) {
310
402
  // Per-run environment-facts cache under .pi-tasks/ (survives
311
403
  // discardEdits): earlier children's discoveries save this child
312
404
  // the re-archaeology; its own ENV-NOTE lines are stored for the
313
- // 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.
314
409
  envNotes: {
315
410
  read: () => readEnvNotes(cwd2),
316
- append: notes => appendEnvNotes(cwd2, notes)
317
- }
411
+ append: notes => appendEnvNotes(cwd2, notes, taskId)
412
+ },
413
+ // Per-run cross-slice contract registry under .pi-tasks/ (F3): the
414
+ // verbatim interface facts the design pins that multiple slices
415
+ // share, so the verify child checks this slice's boundary against
416
+ // them. Empty on single-`/task` runs or a design with no shared
417
+ // boundary → no block.
418
+ contracts: () => readContracts(cwd2)
318
419
  });
319
420
  },
320
421
  lintFix: (fixCtx, cwd2, taskTitle, failReason) => runBoundedLintFix({
@@ -50,6 +50,14 @@ export declare function replaceToolingWithVerified(research: string, verifiedCom
50
50
  * a genuinely greenfield task is still free to create.
51
51
  */
52
52
  export declare function refineExistingFilesBlock(deps: PhaseDeps): Promise<string>;
53
+ /**
54
+ * The read-only cross-slice contract block (see contracts.ts) for a phase that
55
+ * generates spec text — the verbatim interface facts the SOURCE design pins that
56
+ * more than one slice touches. Empty when the registry is absent/empty (single
57
+ * `/task` runs, or a design that pins no shared boundary), so this degrades to a
58
+ * no-op. Best-effort: a read fault yields '' rather than blocking the phase.
59
+ */
60
+ export declare function phaseContractsBlock(deps: PhaseDeps): Promise<string>;
53
61
  export declare const phaseRefine: (deps: PhaseDeps, raw: string, planContext?: string) => Promise<string>;
54
62
  export declare function phaseVerifyTooling(deps: PhaseDeps, research: string): Promise<string>;
55
63
  export interface PhaseResearchDeps extends ExternalContextDeps {
@@ -24,6 +24,9 @@ import { isDuplicateQuestion, MAX_DUP_STRIKES, DUP_REPROMPT_HINT } from './quest
24
24
  import { parseGrillQuestions, parseAutoAnswer, autoAnswerHasTag, parseVerifyToolingOutput, deriveTitle } from './parsers.js';
25
25
  import { compressTitle } from './title-label.js';
26
26
  import { parseVerifyBlock, validateSpecShape, stripSpecPreamble, isCritiqueClean } from './spec-validation.js';
27
+ import { findSkipEscapes, skipEscapeDefectText } from './skip-escape.js';
28
+ import { findSynthesizedWiring, wiringProbeText, readReferencedDocs } from './wiring-claims.js';
29
+ import { readContracts, buildContractsBlock, buildContractsVerifyBlock } from './contracts.js';
27
30
  import { runPhaseChild, runPhaseWithLoopGuard, runWithEmphasisRetry, prependHint, USER_CANCELLED } from './child-runner.js';
28
31
  import { SessionUI } from '../remote/bridge.js';
29
32
  // ─── Re-export constants from their home modules ────────────────────────────
@@ -112,9 +115,21 @@ export async function refineExistingFilesBlock(deps) {
112
115
  });
113
116
  return block.trim().length === 0 ? '' : `${REFINE_PRESERVE_DIRECTIVE}\n\n${block.trim()}`;
114
117
  }
118
+ /**
119
+ * The read-only cross-slice contract block (see contracts.ts) for a phase that
120
+ * generates spec text — the verbatim interface facts the SOURCE design pins that
121
+ * more than one slice touches. Empty when the registry is absent/empty (single
122
+ * `/task` runs, or a design that pins no shared boundary), so this degrades to a
123
+ * no-op. Best-effort: a read fault yields '' rather than blocking the phase.
124
+ */
125
+ export async function phaseContractsBlock(deps) {
126
+ const contracts = await readContracts(deps.cwd).catch(() => '');
127
+ return buildContractsBlock(contracts);
128
+ }
115
129
  export const phaseRefine = async (deps, raw, planContext) => {
116
130
  const existingFiles = await refineExistingFilesBlock(deps).catch(() => '');
117
- return runPhaseWithLoopGuard(deps, 'refine', 'read', hint => prependHint(hint, appendNoThink(REFINE_PROMPT(raw, planContext, existingFiles))),
131
+ const contracts = await phaseContractsBlock(deps);
132
+ return runPhaseWithLoopGuard(deps, 'refine', 'read', hint => prependHint(hint, appendNoThink(REFINE_PROMPT(raw, planContext, existingFiles, contracts))),
118
133
  // refine's deliverable is a 4-section text rewrite that never strictly
119
134
  // needs a successful read — on a test-writing task against a large
120
135
  // existing codebase the model over-explores (re-reads source hunting for
@@ -714,7 +729,8 @@ export async function phaseGrill(deps, ctx, widgetState, refined, research) {
714
729
  return out.join('\n');
715
730
  }
716
731
  export async function phaseCompose(deps, refined, research, qa) {
717
- return runWithEmphasisRetry(deps, 'compose', 'read', problem => COMPOSE_PROMPT(refined, research, qa, problem), text => {
732
+ const contracts = await phaseContractsBlock(deps);
733
+ return runWithEmphasisRetry(deps, 'compose', 'read', problem => COMPOSE_PROMPT(refined, research, qa, problem, contracts), text => {
718
734
  // Trim any "here's the spec:" preamble before validating, so a
719
735
  // strippable lead-in doesn't burn a full retry — and the stored
720
736
  // value starts at GOAL.
@@ -747,6 +763,35 @@ export async function phaseCritique(deps, spec, refined, qa) {
747
763
  // When the draft is structurally sound and triage says CLEAN, return it as
748
764
  // is. Otherwise fall through to the rewrite, feeding the triage defects in
749
765
  // as a focus list. Triage failures are non-fatal — we just do the rewrite.
766
+ // DETERMINISTIC skip-escape gate (run-8 F2): a required VERIFY check wrapped in a
767
+ // skip-announcing `||` fallback (`… || echo "skipping (tool absent)"`) lets the
768
+ // check pass while never running. FP-measured 0/20 on the historical specs. When
769
+ // present, force the rewrite to strip it — never let the triage CLEAN short-circuit
770
+ // ship a self-waiving VERIFY block — and feed the offending lines in as defects.
771
+ const skipEscapes = findSkipEscapes(spec);
772
+ const skipDefects = skipEscapes.length > 0 ? skipEscapeDefectText(skipEscapes) : null;
773
+ // The cross-slice contract registry (run-8 F3): the design's pinned interface
774
+ // facts, quoted verbatim, that more than one slice touches. Threading them into
775
+ // critique lets the triage/rewrite RECONCILE a synthesized wiring specific (a
776
+ // fabricated uniform mount table) against the facts it must reproduce — the
777
+ // generation-side complement of the verify-side boundary check. Empty (single
778
+ // /task, or a design that pins no shared boundary) ⇒ no-op.
779
+ const registryRaw = await readContracts(deps.cwd).catch(() => '');
780
+ const contractsBlock = buildContractsVerifyBlock(registryRaw);
781
+ // DETERMINISTIC synthesized-wiring probe (run-8 F3, generation side). The registry
782
+ // alone is a WEAK catcher (live A/B: prompt+registry ~1/8) — the model's attention
783
+ // goes to the obvious VERIFY weakness and it rarely does the path-composition
784
+ // reasoning. The scanner NAMES the inferred mount mappings and juxtaposes the
785
+ // verbatim pinned facts, forcing focused reconciliation (probe+rule pattern, same
786
+ // lever as skip-escape / substitution-probe). FP-clean (1/18 files on the run-8
787
+ // trees). Grounding = the registry ∪ any design doc the spec/refined @-reference.
788
+ const wiring = registryRaw.trim().length > 0 ?
789
+ findSynthesizedWiring(spec, registryRaw + '\n' + readReferencedDocs(deps.cwd, refined, spec), registryRaw)
790
+ : [];
791
+ const wiringProbe = wiring.length > 0 ? wiringProbeText(wiring, registryRaw) : null;
792
+ if (wiringProbe) {
793
+ deps.logDebug?.(`synthesized wiring flagged in spec: ${wiring.map(w => w.line).join(' | ')}`);
794
+ }
750
795
  let triageDefects = null;
751
796
  if (parseVerifyBlock(spec) !== null) {
752
797
  const tTriage = Date.now();
@@ -756,21 +801,31 @@ export async function phaseCritique(deps, spec, refined, qa) {
756
801
  // Granting `read` here let it wander the repo to "verify" findings,
757
802
  // which made the supposedly-cheap pass cost as much as a rewrite
758
803
  // (observed ~133s). The judgement needs no file access.
759
- verdict = await runPhaseChild(deps, 'critique-triage', '', appendNoThink(CRITIQUE_TRIAGE_PROMPT(spec, refined, qa)));
804
+ verdict = await runPhaseChild(deps, 'critique-triage', '', appendNoThink(CRITIQUE_TRIAGE_PROMPT(spec, refined, qa, contractsBlock)));
760
805
  }
761
806
  catch {
762
807
  verdict = null;
763
808
  }
764
809
  deps.recordSubStep?.('triage', Date.now() - tTriage);
765
810
  if (verdict !== null) {
766
- if (isCritiqueClean(verdict))
767
- return spec;
768
- triageDefects = verdict.trim();
811
+ // A deterministic skip-escape OR synthesized-wiring finding overrides a CLEAN
812
+ // triage: the draft must be rewritten to resolve it even if the model judged
813
+ // the rest clean (the model does not self-discover either reliably).
814
+ if (isCritiqueClean(verdict)) {
815
+ if (skipDefects === null && wiringProbe === null)
816
+ return spec;
817
+ }
818
+ else {
819
+ triageDefects = verdict.trim();
820
+ }
769
821
  }
770
822
  }
823
+ // Merge the deterministic skip-escape + synthesized-wiring defects with any triage
824
+ // defects for the rewrite (both are forced FOCUS items).
825
+ const rewriteDefects = [skipDefects, wiringProbe, triageDefects].filter(Boolean).join('\n\n') || null;
771
826
  const tRewrite = Date.now();
772
827
  try {
773
- return await runWithEmphasisRetry(deps, 'critique', 'read', problem => CRITIQUE_PROMPT(spec, refined, qa, problem !== null, triageDefects), text => {
828
+ return await runWithEmphasisRetry(deps, 'critique', 'read', problem => CRITIQUE_PROMPT(spec, refined, qa, problem !== null, rewriteDefects, contractsBlock), text => {
774
829
  // The rewrite (thinking on) sometimes prepends narration before
775
830
  // GOAL; the prompt forbids it but this validator only checks for
776
831
  // a VERIFY block. Strip it so the delivered spec starts at GOAL.
@@ -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
@@ -47,7 +47,7 @@ export declare const COMPRESS_LABEL_PROMPT: (title: string, maxChars: number) =>
47
47
  * "Scaffold …" title re-expands the entire design into one task (validated: a real
48
48
  * /task-auto run implemented all 24 steps under step 1).
49
49
  */
50
- declare const REFINE_PROMPT: (raw: string, planContext?: string, existingFiles?: string) => string;
50
+ declare const REFINE_PROMPT: (raw: string, planContext?: string, existingFiles?: string, contracts?: string) => string;
51
51
  declare const RESEARCH_READ_ONLY_CONSTRAINT = "IMPORTANT: You are ONLY allowed to READ. Do NOT create, modify, or delete any files. Use the read, grep, find, and ls tools to inspect the repo.";
52
52
  declare const RESEARCH_FILES_PROMPT: (refined: string) => string;
53
53
  declare const RESEARCH_APIS_PROMPT: (refined: string, filesMap?: string) => string;
@@ -57,8 +57,8 @@ declare const GRILL_GEN_PROMPT: (refined: string, research: string, priorQA: str
57
57
  declare const GRILL_AUTO_ANSWER_PROMPT: (refined: string, research: string, question: string) => string;
58
58
  export declare const GRILL_AUTO_FORMAT_HINT: string;
59
59
  declare function composeRetryEmphasis(problem: string): string;
60
- declare const COMPOSE_PROMPT: (refined: string, research: string, qa: string, retryProblem: string | null) => string;
61
- declare const CRITIQUE_TRIAGE_PROMPT: (spec: string, refined: string, qa: string) => string;
62
- declare const CRITIQUE_PROMPT: (spec: string, refined: string, qa: string, addVerifyEmphasis: boolean, triageDefects?: string | null) => string;
60
+ declare const COMPOSE_PROMPT: (refined: string, research: string, qa: string, retryProblem: string | null, contracts?: string) => string;
61
+ declare const CRITIQUE_TRIAGE_PROMPT: (spec: string, refined: string, qa: string, contracts?: string) => string;
62
+ declare const CRITIQUE_PROMPT: (spec: string, refined: string, qa: string, addVerifyEmphasis: boolean, triageDefects?: string | null, contracts?: string) => string;
63
63
  declare const VERIFY_TOOLING_PROMPT: (tooling: string) => string;
64
64
  export { REFINE_PROMPT, RESEARCH_FILES_PROMPT, RESEARCH_APIS_PROMPT, RESEARCH_CONTEXT_PROMPT, RESEARCH_TOOLING_PROMPT, RESEARCH_READ_ONLY_CONSTRAINT, GRILL_GEN_PROMPT, GRILL_AUTO_ANSWER_PROMPT, COMPOSE_PROMPT, CRITIQUE_PROMPT, CRITIQUE_TRIAGE_PROMPT, VERIFY_TOOLING_PROMPT, composeRetryEmphasis };