@mjasnikovs/pi-task 0.18.1 → 0.18.3

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.
@@ -45,6 +46,32 @@ function coverageRepromptHint(missing) {
45
46
  + 'task your previous list already had (reworded freely) PLUS tasks covering '
46
47
  + 'the areas above. Output every task, one "- [ ] " line each, nothing else.]');
47
48
  }
49
+ // Deterministic distrust floor for the coverage gate. The gate's judge is the
50
+ // same stochastic model as the decompose call it guards, and live (mx5 2026-07-08,
51
+ // A/B N=10) it rubber-stamps a 1-task plan for an 18KB spec 3/10 times — always
52
+ // as the bare "COVERAGE: COMPLETE" line, which is byte-identical to a legitimate
53
+ // verdict, so the rubber-stamp is NOT detectable from the judge's output. The
54
+ // distrust signal must come from the input: a plan this small for a spec this
55
+ // large is near-certainly the known degenerate-decompose flake (healthy runs on
56
+ // the same inputs produce 10–30 titles). The floor only ever forces a REGENERATION
57
+ // — it never rejects a plan on count alone (the v0.13.34 objection), so a model
58
+ // that insists twice still ships its small plan, with a warning.
59
+ const SUSPECT_PLAN_MAX_TITLES = 2;
60
+ const SUSPECT_PLAN_MIN_SPEC_CHARS = 4000;
61
+ function isSuspectPlan(titles, featureForModel) {
62
+ return (titles.length > 0
63
+ && titles.length <= SUSPECT_PLAN_MAX_TITLES
64
+ && featureForModel.length >= SUSPECT_PLAN_MIN_SPEC_CHARS);
65
+ }
66
+ /** Reprompt prefix for a suspect (degenerate-count) list; unlike
67
+ * coverageRepromptHint there is no judge verdict yet, so no missing areas. */
68
+ function suspectPlanHint(count) {
69
+ return (`[SYSTEM NOTE: Your previous answer contained only ${count} task(s), which `
70
+ + 'cannot decompose a feature specification of this size — it was almost '
71
+ + 'certainly an incomplete generation. Regenerate the FULL ordered checkbox '
72
+ + 'list for the ENTIRE feature, covering every part of the spec end to end. '
73
+ + 'Output every task, one "- [ ] " line each, nothing else.]');
74
+ }
48
75
  // Matches pi's @-file completion token (a path after @, until whitespace).
49
76
  const MENTION_RE = /(?:^|\s)@([^\s]+)/g;
50
77
  // Trailing punctuation a user naturally types AFTER an @-mention when it sits in
@@ -411,6 +438,20 @@ export async function planAuto(ctx, cwd, feature, deps) {
411
438
  const listRaw = await deps.runChild('auto-decompose', 'read', decomposePrompt);
412
439
  let planTitles = parseDecomposeList(listRaw);
413
440
  logPlanDebug(cwd, `decompose produced ${planTitles.length} title(s)`);
441
+ // Distrust floor (see isSuspectPlan): a ≤2-title plan for a multi-KB spec is
442
+ // regenerated once BEFORE the judge runs — the judge cannot be trusted to
443
+ // catch it (3/10 live false-pass) and a hinted retry heals it reliably
444
+ // (5/5 live). Longer list wins; a still-suspect plan falls through to the
445
+ // judge loop as before, so this never blocks planning.
446
+ if (isSuspectPlan(planTitles, featureForModel)) {
447
+ logPlanDebug(cwd, `decompose suspect (${planTitles.length} title(s) for a ${featureForModel.length}-char spec)`
448
+ + ` — raw output: ${listRaw.trim().slice(0, 300)}`);
449
+ const retryRaw = await deps.runChild('auto-decompose', 'read', prependHint(suspectPlanHint(planTitles.length), decomposePrompt));
450
+ const retryTitles = parseDecomposeList(retryRaw);
451
+ logPlanDebug(cwd, `decompose suspect-retry produced ${retryTitles.length} title(s)`);
452
+ if (retryTitles.length > planTitles.length)
453
+ planTitles = retryTitles;
454
+ }
414
455
  // Coverage gate: a stochastic degenerate completion (live mx5: ONE task +
415
456
  // natural EOS for an 18KB design doc) is nonempty, so the length guard below
416
457
  // never fires and the whole run "completes" after one task. Judge the list
@@ -442,6 +483,14 @@ export async function planAuto(ctx, cwd, feature, deps) {
442
483
  unresolvedMissing = null;
443
484
  logPlanDebug(cwd, `decompose-coverage round ${round + 1}: `
444
485
  + (verdict === null ? 'no verdict — accepting list' : 'COMPLETE'));
486
+ // A COMPLETE on a still-suspect plan is the judge's known live
487
+ // false-pass mode (bare verdict, indistinguishable from a real one).
488
+ // The plan still ships — the floor never rejects on count — but
489
+ // never silently: the user decides whether to trust it.
490
+ if (isSuspectPlan(planTitles, featureForModel)) {
491
+ ctx.ui.notify(`/task-auto: only ${planTitles.length} task(s) planned for a large spec`
492
+ + ' and the regeneration did not grow the list — review the plan before running.', 'warning');
493
+ }
445
494
  break;
446
495
  }
447
496
  unresolvedMissing = verdict.missing;
@@ -465,6 +514,25 @@ export async function planAuto(ctx, cwd, feature, deps) {
465
514
  + unresolvedMissing.join('; ').slice(0, 300));
466
515
  ctx.ui.notify(`/task-auto: plan may be missing coverage — ${unresolvedMissing.join('; ').slice(0, 200)} — review the plan before running.`, 'warning');
467
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
+ }
468
536
  // Thread the feature's spec doc(s) into every title so each per-task
469
537
  // pipeline — which only ever sees its title — reads the real spec instead of
470
538
  // a lossy one-line paraphrase of it.
@@ -492,7 +560,8 @@ export async function planAuto(ctx, cwd, feature, deps) {
492
560
  const AUTO_PLAN_STEPS = {
493
561
  'auto-clarify': { step: 'clarify', stepNum: 1 },
494
562
  'auto-decompose': { step: 'decompose', stepNum: 2 },
495
- 'decompose-coverage': { step: 'coverage', stepNum: 2 }
563
+ 'decompose-coverage': { step: 'coverage', stepNum: 2 },
564
+ 'contract-extract': { step: 'contracts', stepNum: 2 }
496
565
  };
497
566
  const AUTO_PLAN_STEP_TOTAL = 2;
498
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');
@@ -20,6 +20,7 @@ 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';
@@ -314,7 +315,13 @@ export function buildGateDeps(params) {
314
315
  envNotes: {
315
316
  read: () => readEnvNotes(cwd2),
316
317
  append: notes => appendEnvNotes(cwd2, notes)
317
- }
318
+ },
319
+ // Per-run cross-slice contract registry under .pi-tasks/ (F3): the
320
+ // verbatim interface facts the design pins that multiple slices
321
+ // share, so the verify child checks this slice's boundary against
322
+ // them. Empty on single-`/task` runs or a design with no shared
323
+ // boundary → no block.
324
+ contracts: () => readContracts(cwd2)
318
325
  });
319
326
  },
320
327
  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.
@@ -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 };