@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.
@@ -29,6 +29,7 @@ import { gitUnmergedPaths, gitStashRef } from './auto-commit.js';
29
29
  import { runFinalIntegrationGate } from './final-gate.js';
30
30
  import { classifyFinalGateAnswer, MAX_FINAL_GATE_AUTOFIX, FINAL_LEAVE_LABEL, FINAL_LEAVE_VALUE, FINAL_ACCEPT_LABEL, FINAL_ACCEPT_VALUE, FINAL_AUTOFIX_LABEL, FINAL_AUTOFIX_VALUE } from './final-gate-fix.js';
31
31
  import { getConfig } from '../config/config.js';
32
+ import { CONTRACT_EXTRACT_PROMPT, parseContractLines, keepGroundedContracts, appendContracts } from './contracts.js';
32
33
  // Hard ceiling on clarify questions per feature. The loop is open-ended (it stops
33
34
  // when the model emits NONE), but a model that never says NONE would otherwise
34
35
  // barrage the user — the real mx5 run asked 10, several of them redundant.
@@ -513,6 +514,25 @@ export async function planAuto(ctx, cwd, feature, deps) {
513
514
  + unresolvedMissing.join('; ').slice(0, 300));
514
515
  ctx.ui.notify(`/task-auto: plan may be missing coverage — ${unresolvedMissing.join('; ').slice(0, 200)} — review the plan before running.`, 'warning');
515
516
  }
517
+ // Cross-slice contract registry (mx5 run 8, F3): now that the plan is settled,
518
+ // extract the interface facts MORE THAN ONE slice must agree on — endpoint paths,
519
+ // exported signatures, file layouts, env var names the DESIGN pins — into a
520
+ // run-level artifact each downstream refine/compose/verify reads. The extraction
521
+ // child EMITs `CONTRACT:` lines, but every quote is re-grounded HOST-SIDE against
522
+ // the design (keepGroundedContracts): a fact the child paraphrased or invented is
523
+ // not a substring of the doc and is dropped, so a fabricated contract — exactly
524
+ // the F3 bug — can never enter the registry. Best-effort: any fault here is
525
+ // swallowed (the registry is a sharpener, never a planning blocker).
526
+ try {
527
+ const contractRaw = await deps.runChild('contract-extract', '', CONTRACT_EXTRACT_PROMPT(featureForModel, planTitles));
528
+ const grounded = keepGroundedContracts(parseContractLines(contractRaw), featureForModel);
529
+ logPlanDebug(cwd, `contract extraction: ${grounded.length} grounded contract(s) kept`
530
+ + ` from ${parseContractLines(contractRaw).length} emitted`);
531
+ await appendContracts(cwd, grounded);
532
+ }
533
+ catch {
534
+ // best-effort registry
535
+ }
516
536
  // Thread the feature's spec doc(s) into every title so each per-task
517
537
  // pipeline — which only ever sees its title — reads the real spec instead of
518
538
  // a lossy one-line paraphrase of it.
@@ -540,7 +560,8 @@ export async function planAuto(ctx, cwd, feature, deps) {
540
560
  const AUTO_PLAN_STEPS = {
541
561
  'auto-clarify': { step: 'clarify', stepNum: 1 },
542
562
  'auto-decompose': { step: 'decompose', stepNum: 2 },
543
- 'decompose-coverage': { step: 'coverage', stepNum: 2 }
563
+ 'decompose-coverage': { step: 'coverage', stepNum: 2 },
564
+ 'contract-extract': { step: 'contracts', stepNum: 2 }
544
565
  };
545
566
  const AUTO_PLAN_STEP_TOTAL = 2;
546
567
  function defaultDeps(ctx, cwd, signal, title) {
@@ -0,0 +1,54 @@
1
+ export interface ContractEntry {
2
+ /** The verbatim quote from the source doc — the pinned interface fact. */
3
+ quote: string;
4
+ /** Where in the source it came from (a header, a section name) — free text. */
5
+ anchor: string;
6
+ }
7
+ export declare function contractsFile(cwd: string): string;
8
+ /** The stored registry text ('' when none recorded yet). */
9
+ export declare function readContracts(cwd: string): Promise<string>;
10
+ /**
11
+ * Parse `CONTRACT:` lines out of a child's answer text into entries. The line shape
12
+ * the extraction prompt asks for is:
13
+ * CONTRACT: "<verbatim quote>" [anchor: <where>]
14
+ * The quote (between the first pair of double quotes) is the pinned fact; the
15
+ * optional `[anchor: …]` trailer records provenance. A line without a quoted span
16
+ * is skipped — an unquoted "contract" is a summary, which this registry rejects.
17
+ */
18
+ export declare function parseContractLines(text: string): ContractEntry[];
19
+ /**
20
+ * THE ANTI-SYNTHESIS GUARD (the F3 defense): keep only entries whose quote actually
21
+ * appears in the source document. A paraphrase or fabrication is not a substring of
22
+ * the doc, so it is dropped — the registry cannot be poisoned with a synthesized
23
+ * contract. Matching is whitespace-insensitive and case-insensitive so a faithful
24
+ * quote across a line wrap still counts. Deduplicated (case-insensitive on the quote).
25
+ */
26
+ export declare function keepGroundedContracts(entries: ContractEntry[], sourceDoc: string): ContractEntry[];
27
+ /**
28
+ * Append grounded entries to the registry, deduplicated (case-insensitive on the
29
+ * quote) against what is already stored, keeping the newest MAX_CONTRACTS. Failures
30
+ * are swallowed — the registry is a sharpener, never a blocker.
31
+ */
32
+ export declare function appendContracts(cwd: string, entries: ContractEntry[]): Promise<void>;
33
+ /**
34
+ * The read-only prompt block a downstream slice (refine/compose) receives when the
35
+ * registry is non-empty. It is authoritative: these are the pinned cross-slice
36
+ * contracts from the SOURCE doc, and a slice must not restate them differently.
37
+ */
38
+ export declare function buildContractsBlock(contracts: string): string;
39
+ /**
40
+ * The block the VERIFY child receives: the same registry, plus the mandate to check
41
+ * the slice's actual boundary against it (F3 is a seam bug — locally right, globally
42
+ * wrong — so per-slice verification must look at the boundary, not just the interior).
43
+ */
44
+ export declare function buildContractsVerifyBlock(contracts: string): string;
45
+ /**
46
+ * The full decompose-time extraction prompt: the design and the just-decomposed
47
+ * task titles (so the child can judge which facts a boundary is SHARED on), plus
48
+ * the emit instruction. Runs with --no-tools — pure extraction over text in hand.
49
+ * The host re-grounds every emitted quote against the design (keepGroundedContracts),
50
+ * so a hallucinated contract cannot survive even if the child fabricates one here.
51
+ */
52
+ export declare const CONTRACT_EXTRACT_PROMPT: (feature: string, titles: string[]) => string;
53
+ /** The emit instruction for the decompose-time extraction child. */
54
+ export declare const CONTRACT_EMIT_INSTRUCTION: string;
@@ -0,0 +1,226 @@
1
+ /**
2
+ * contracts — a per-run registry of CROSS-SLICE INTERFACE FACTS, recorded once at
3
+ * decompose time and read (never written) by every downstream slice.
4
+ *
5
+ * The failure this serves (mx5 run 8, F3 — the dominant shipped-defect class): the
6
+ * SOURCE design pins an interface contract (a SPLIT route table: `POST
7
+ * /api/listings/:id/photos` but `GET/DELETE /api/photos/:id`). One slice's refine
8
+ * FABRICATED a uniform mount table ("/api/photos → photosRoutes") with no anchor in
9
+ * the doc; the consumer slices followed the spec, the assembly slice followed the
10
+ * fabrication, and the seam between them shipped broken while each slice was locally
11
+ * "right". Class: refine synthesizes plausible interface specifics instead of citing.
12
+ *
13
+ * Mechanism (mirrors env-notes.ts): interface facts that MORE THAN ONE task will
14
+ * touch — endpoint paths, exported signatures, file layouts, env var names, whatever
15
+ * the SOURCE DOC pins — are extracted ONCE (a decompose-time child EMITs `CONTRACT:`
16
+ * lines) and appended HOST-SIDE to `.pi-tasks/contracts.md`. Children never write the
17
+ * file (no artifact corruption). It lives under `.pi-tasks/`, surviving discardEdits
18
+ * and the git-state guard.
19
+ *
20
+ * HARD DESIGN RULE (this is exactly how F3 happened): a registry entry is a VERBATIM
21
+ * QUOTE from the source doc plus a source anchor — NEVER a model-synthesized summary.
22
+ * The anti-synthesis guard is deterministic: a candidate quote is kept only if it is
23
+ * an actual substring of the source document (normalised for whitespace). A quote the
24
+ * model paraphrased or invented is not in the doc, so it is dropped — a fabricated
25
+ * contract can never enter the registry.
26
+ *
27
+ * Stack-agnostic: an "interface fact" is any pinned boundary string; the guard is
28
+ * pure substring matching over the source text, with no assumption about its shape.
29
+ */
30
+ import * as fsp from 'node:fs/promises';
31
+ import * as path from 'node:path';
32
+ import { tasksDir } from './task-io.js';
33
+ const CONTRACTS_FILE = 'contracts.md';
34
+ /** Cap kept entries so the injected block stays bounded on a large design. */
35
+ const MAX_CONTRACTS = 40;
36
+ /** A single contract entry is one line; longer is prose, not a pinned fact. */
37
+ const MAX_CONTRACT_LENGTH = 300;
38
+ /** A quote shorter than this is too generic to anchor a contract (and to match). */
39
+ const MIN_QUOTE_LENGTH = 6;
40
+ export function contractsFile(cwd) {
41
+ return path.join(tasksDir(cwd), CONTRACTS_FILE);
42
+ }
43
+ /** The stored registry text ('' when none recorded yet). */
44
+ export async function readContracts(cwd) {
45
+ try {
46
+ return (await fsp.readFile(contractsFile(cwd), 'utf8')).trim();
47
+ }
48
+ catch {
49
+ return '';
50
+ }
51
+ }
52
+ /**
53
+ * Normalise for substring matching: collapse all whitespace runs to one space and
54
+ * lowercase. Quoting across a line wrap or with reflowed spacing still matches the
55
+ * source; casing differences do not defeat the anti-synthesis guard.
56
+ */
57
+ function normalise(s) {
58
+ return s.replace(/\s+/g, ' ').trim().toLowerCase();
59
+ }
60
+ /**
61
+ * Parse `CONTRACT:` lines out of a child's answer text into entries. The line shape
62
+ * the extraction prompt asks for is:
63
+ * CONTRACT: "<verbatim quote>" [anchor: <where>]
64
+ * The quote (between the first pair of double quotes) is the pinned fact; the
65
+ * optional `[anchor: …]` trailer records provenance. A line without a quoted span
66
+ * is skipped — an unquoted "contract" is a summary, which this registry rejects.
67
+ */
68
+ export function parseContractLines(text) {
69
+ const entries = [];
70
+ for (const m of text.matchAll(/^[ \t]*CONTRACT:[ \t]*(.+)$/gim)) {
71
+ const body = m[1].trim();
72
+ const q = /"([^"]+)"/.exec(body);
73
+ if (!q)
74
+ continue;
75
+ const quote = q[1].trim();
76
+ if (quote.length < MIN_QUOTE_LENGTH || quote.length > MAX_CONTRACT_LENGTH)
77
+ continue;
78
+ const a = /\[anchor:\s*([^\]]+)\]/i.exec(body);
79
+ entries.push({ quote, anchor: a ? a[1].trim() : '' });
80
+ }
81
+ return entries;
82
+ }
83
+ /**
84
+ * THE ANTI-SYNTHESIS GUARD (the F3 defense): keep only entries whose quote actually
85
+ * appears in the source document. A paraphrase or fabrication is not a substring of
86
+ * the doc, so it is dropped — the registry cannot be poisoned with a synthesized
87
+ * contract. Matching is whitespace-insensitive and case-insensitive so a faithful
88
+ * quote across a line wrap still counts. Deduplicated (case-insensitive on the quote).
89
+ */
90
+ export function keepGroundedContracts(entries, sourceDoc) {
91
+ const haystack = normalise(sourceDoc);
92
+ const seen = new Set();
93
+ const kept = [];
94
+ for (const e of entries) {
95
+ const key = normalise(e.quote);
96
+ if (key.length === 0 || seen.has(key))
97
+ continue;
98
+ if (!haystack.includes(key))
99
+ continue; // fabricated / paraphrased ⇒ reject
100
+ seen.add(key);
101
+ kept.push(e);
102
+ }
103
+ return kept;
104
+ }
105
+ /** Render one entry as a stored/displayed line. */
106
+ function formatEntry(e) {
107
+ return e.anchor ? `"${e.quote}" [anchor: ${e.anchor}]` : `"${e.quote}"`;
108
+ }
109
+ /**
110
+ * Append grounded entries to the registry, deduplicated (case-insensitive on the
111
+ * quote) against what is already stored, keeping the newest MAX_CONTRACTS. Failures
112
+ * are swallowed — the registry is a sharpener, never a blocker.
113
+ */
114
+ export async function appendContracts(cwd, entries) {
115
+ if (entries.length === 0)
116
+ return;
117
+ try {
118
+ const existingLines = (await readContracts(cwd))
119
+ .split('\n')
120
+ .filter(l => l.trim().length > 0);
121
+ const seen = new Set(existingLines.map(l => {
122
+ const q = /"([^"]+)"/.exec(l);
123
+ return normalise(q ? q[1] : l);
124
+ }));
125
+ const merged = [...existingLines];
126
+ for (const e of entries) {
127
+ const key = normalise(e.quote);
128
+ if (seen.has(key))
129
+ continue;
130
+ seen.add(key);
131
+ merged.push(formatEntry(e));
132
+ }
133
+ const kept = merged.slice(-MAX_CONTRACTS);
134
+ await fsp.mkdir(tasksDir(cwd), { recursive: true });
135
+ await fsp.writeFile(contractsFile(cwd), kept.join('\n') + '\n', 'utf8');
136
+ }
137
+ catch {
138
+ // best-effort registry
139
+ }
140
+ }
141
+ /**
142
+ * The read-only prompt block a downstream slice (refine/compose) receives when the
143
+ * registry is non-empty. It is authoritative: these are the pinned cross-slice
144
+ * contracts from the SOURCE doc, and a slice must not restate them differently.
145
+ */
146
+ export function buildContractsBlock(contracts) {
147
+ if (contracts.trim().length === 0)
148
+ return '';
149
+ return [
150
+ 'CROSS-SLICE CONTRACTS — interface facts the SOURCE design pins and that more than',
151
+ 'one task touches (endpoint paths, exported signatures, file layouts, env var names).',
152
+ 'Each is a VERBATIM quote from the design; treat them as AUTHORITATIVE and BINDING:',
153
+ ...contracts
154
+ .trim()
155
+ .split('\n')
156
+ .map(l => `- ${l}`),
157
+ 'Your slice MUST match these exactly at its boundary. Do NOT invent, rename, reshape,',
158
+ 'or "tidy" a path/signature/layout/name that appears here — a difference between what',
159
+ 'your slice emits and a contract above is a SEAM BUG, even if your slice is internally',
160
+ 'consistent. If the design is silent on a boundary detail, leave it unspecified rather',
161
+ 'than fabricating a specific — do not add contracts of your own.',
162
+ ''
163
+ ].join('\n');
164
+ }
165
+ /**
166
+ * The block the VERIFY child receives: the same registry, plus the mandate to check
167
+ * the slice's actual boundary against it (F3 is a seam bug — locally right, globally
168
+ * wrong — so per-slice verification must look at the boundary, not just the interior).
169
+ */
170
+ export function buildContractsVerifyBlock(contracts) {
171
+ if (contracts.trim().length === 0)
172
+ return '';
173
+ return [
174
+ 'CROSS-SLICE CONTRACTS (verbatim from the source design; authoritative):',
175
+ ...contracts
176
+ .trim()
177
+ .split('\n')
178
+ .map(l => `- ${l}`),
179
+ 'These are boundaries MULTIPLE slices share. Check the shipped work at its boundary',
180
+ 'against them: does the code this task produced use each path/signature/layout/name',
181
+ 'EXACTLY as quoted? A slice that is internally consistent but wires a boundary',
182
+ 'differently from a contract above is a SEAM BUG — report FAIL naming the mismatch',
183
+ '(what the code uses vs what the contract pins). Matching every contract it touches',
184
+ 'is part of the bar, not an extra.',
185
+ ''
186
+ ].join('\n');
187
+ }
188
+ /**
189
+ * The full decompose-time extraction prompt: the design and the just-decomposed
190
+ * task titles (so the child can judge which facts a boundary is SHARED on), plus
191
+ * the emit instruction. Runs with --no-tools — pure extraction over text in hand.
192
+ * The host re-grounds every emitted quote against the design (keepGroundedContracts),
193
+ * so a hallucinated contract cannot survive even if the child fabricates one here.
194
+ */
195
+ export const CONTRACT_EXTRACT_PROMPT = (feature, titles) => [
196
+ 'You are recording the CROSS-SLICE INTERFACE CONTRACTS for a feature that has just',
197
+ 'been split into the task list below. Each task will later be implemented by a',
198
+ 'SEPARATE pipeline that sees only its own title — so any interface fact TWO OR MORE',
199
+ 'of these tasks must agree on (a shared endpoint path, an exported signature, a file',
200
+ 'or module layout, an env var name, a wire/disk format) has to be pinned ONCE here,',
201
+ 'exactly as the design states it, or the slices will drift apart at the seam.',
202
+ '',
203
+ 'TASK LIST (the slices that will share these boundaries):',
204
+ ...titles.map((t, i) => `${i + 1}. ${t}`),
205
+ '',
206
+ 'DESIGN (the ONLY source of truth — quote from it, never from your own knowledge):',
207
+ feature.trim(),
208
+ '',
209
+ CONTRACT_EMIT_INSTRUCTION,
210
+ '',
211
+ 'Output the CONTRACT: lines and nothing else. If the design pins no shared boundary,',
212
+ 'output nothing.'
213
+ ].join('\n');
214
+ /** The emit instruction for the decompose-time extraction child. */
215
+ export const CONTRACT_EMIT_INSTRUCTION = [
216
+ 'CROSS-SLICE CONTRACTS — extract the interface facts that MORE THAN ONE task above will',
217
+ 'touch and that the design PINS: endpoint paths and methods, exported function/type',
218
+ 'signatures, file/module layout, env var names, on-disk or wire formats. For each, emit',
219
+ ' CONTRACT: "<verbatim quote copied EXACTLY from the design>" [anchor: <section/header>]',
220
+ 'one per line. RULES: (1) the quoted text MUST be copied verbatim from the design — do',
221
+ 'NOT paraphrase, summarise, normalise, or complete it; a quote that is not a literal',
222
+ 'substring of the design is DISCARDED. (2) Only facts a boundary is shared on — skip',
223
+ 'anything a single task fully owns. (3) If the design does not pin a detail, do NOT invent',
224
+ 'one. Quotes only; never your own synthesis — a fabricated contract is exactly the bug',
225
+ 'this registry exists to prevent.'
226
+ ].join('\n');
@@ -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;