@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.
- package/dist/task/auto-orchestrator.js +22 -1
- package/dist/task/contracts.d.ts +54 -0
- package/dist/task/contracts.js +226 -0
- package/dist/task/enforce-guidelines.d.ts +2 -2
- package/dist/task/enforce-guidelines.js +36 -3
- package/dist/task/env-notes.d.ts +24 -8
- package/dist/task/env-notes.js +124 -24
- package/dist/task/gate-deps.d.ts +10 -0
- package/dist/task/gate-deps.js +105 -4
- package/dist/task/phases.d.ts +8 -0
- package/dist/task/phases.js +62 -7
- package/dist/task/probe-gaming.d.ts +60 -0
- package/dist/task/probe-gaming.js +0 -0
- package/dist/task/prompts.d.ts +4 -4
- package/dist/task/prompts.js +12 -7
- package/dist/task/skip-escape.d.ts +25 -0
- package/dist/task/skip-escape.js +80 -0
- package/dist/task/task-gates.js +17 -5
- package/dist/task/test-assembly.d.ts +87 -0
- package/dist/task/test-assembly.js +163 -0
- package/dist/task/verify-work.d.ts +40 -3
- package/dist/task/verify-work.js +204 -9
- package/dist/task/wiring-claims.d.ts +64 -0
- package/dist/task/wiring-claims.js +149 -0
- package/package.json +2 -2
|
@@ -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 ?
|
|
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);
|
package/dist/task/env-notes.d.ts
CHANGED
|
@@ -1,23 +1,39 @@
|
|
|
1
1
|
export declare function envNotesFile(cwd: string): string;
|
|
2
|
-
/** The
|
|
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
|
|
12
|
-
*
|
|
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.
|
|
18
|
-
* load-bearing: facts save re-discovery time but grant no
|
|
19
|
-
*
|
|
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(
|
|
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;
|