@mjasnikovs/pi-task 0.18.2 → 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.
@@ -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');
@@ -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 };
@@ -61,7 +61,7 @@ ${title}`;
61
61
  * "Scaffold …" title re-expands the entire design into one task (validated: a real
62
62
  * /task-auto run implemented all 24 steps under step 1).
63
63
  */
64
- const REFINE_PROMPT = (raw, planContext, existingFiles) => `${planContext ? planContext + '\n\n---\n\n' : ''}You receive a user's task description for an AI coding agent. Rewrite it to be unambiguous and actionable.
64
+ const REFINE_PROMPT = (raw, planContext, existingFiles, contracts) => `${planContext ? planContext + '\n\n---\n\n' : ''}You receive a user's task description for an AI coding agent. Rewrite it to be unambiguous and actionable.
65
65
 
66
66
  Output structure (four sections, exact headings, in this order):
67
67
 
@@ -86,8 +86,9 @@ Rules:
86
86
  - Preserve every concrete identifier verbatim (paths, function names, ports, env vars, file:line refs).
87
87
  - Do not invent requirements not implied by the input.
88
88
  - If the task references a design/spec document (an @-path or a named spec file), READ it and treat it as authoritative. Carry its concrete schema verbatim into GOAL/CONSTRAINTS — table and column names, types, endpoint methods and paths, enum values. The task title is only a pointer into that spec: where the title and the spec disagree, follow the spec, and never introduce a table, column, endpoint, or dependency the spec does not define.
89
+ - CITE interface WIRING, do NOT synthesize it. A wiring specific — how modules/endpoints/files connect (a mount prefix, a route/mount table, a module→path mapping, an exported function/type signature, a file or module layout) — must be citable from the design or the CROSS-SLICE CONTRACTS. The design often pins the interface FACTS (the exact endpoint paths, exported names, layouts) WITHOUT stating the wiring that produces them; when it does, any wiring you write MUST reproduce those pinned facts EXACTLY. Do NOT infer a "uniform" or "tidy" pattern from them — e.g. do not assume one module maps to one mount prefix when the design's pinned facts for that module do not all sit under a single prefix (that exact inference is a seam bug: the consumers follow the pinned facts, the assembly follows your invented pattern, and the seam ships broken). If the design pins neither the fact nor the wiring, leave the detail unspecified rather than inventing a specific.
89
90
  - Do not output any preamble, commentary, or markdown headings beyond the four sections above.
90
- ${existingFiles && existingFiles.trim() ? `\n${existingFiles.trim()}\n` : ''}
91
+ ${contracts && contracts.trim() ? `\n${contracts.trim()}\n` : ''}${existingFiles && existingFiles.trim() ? `\n${existingFiles.trim()}\n` : ''}
91
92
  Task: ${raw}`;
92
93
  // ─── Research fan-out prompts ─────────────────────────────────────────────────
93
94
  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.`;
@@ -288,7 +289,7 @@ function composeRetryEmphasis(problem) {
288
289
  }
289
290
  return `\nPREVIOUS ATTEMPT was invalid (${problem}). Ensure all four sections are present and the output starts with the literal word GOAL.\n`;
290
291
  }
291
- const COMPOSE_PROMPT = (refined, research, qa, retryProblem) => `You are composing the final implementation spec for an AI coding agent. Combine the refined task, the research, and the user's Q&A answers into one spec.
292
+ const COMPOSE_PROMPT = (refined, research, qa, retryProblem, contracts) => `You are composing the final implementation spec for an AI coding agent. Combine the refined task, the research, and the user's Q&A answers into one spec.
292
293
 
293
294
  CRITICAL FORMAT RULES (read first):
294
295
  - Output the spec as plain markdown text. Do NOT wrap your entire output in a code block, shell fence, or heredoc. Do NOT prefix with \`\`\`sh / \`\`\`bash. Do NOT use \`cat << EOF > file\` patterns. Your response begins literally with "GOAL" on the first line.
@@ -334,14 +335,15 @@ Research:
334
335
  ${research}
335
336
 
336
337
  User Q&A:
337
- ${qa}`;
338
+ ${qa}
339
+ ${contracts && contracts.trim() ? `\n${contracts.trim()}\n` : ''}`;
338
340
  // Fast triage pass run before the (expensive) full rewrite. It produces either
339
341
  // the single token CLEAN — meaning the compose draft needs no rewrite — or a
340
342
  // short defect list. When CLEAN, the orchestrator returns the draft unchanged
341
343
  // and skips the rewrite entirely; otherwise the defects are fed into
342
344
  // CRITIQUE_PROMPT as a focus list so the rewrite targets real problems instead
343
345
  // of re-deriving them from scratch.
344
- const CRITIQUE_TRIAGE_PROMPT = (spec, refined, qa) => `You are triaging an implementation spec for an AI coding agent. Decide whether it needs a rewrite. Do NOT rewrite it — only judge it.
346
+ const CRITIQUE_TRIAGE_PROMPT = (spec, refined, qa, contracts) => `You are triaging an implementation spec for an AI coding agent. Decide whether it needs a rewrite. Do NOT rewrite it — only judge it.
345
347
 
346
348
  The refined task and the user's Q&A below are GROUND TRUTH. Judge the spec against them. Look for SUBSTANTIVE defects only:
347
349
  - ambiguity that would let the agent build the wrong thing
@@ -349,7 +351,8 @@ The refined task and the user's Q&A below are GROUND TRUTH. Judge the spec again
349
351
  - a VERIFY block that is missing, unrunnable, full of placeholders, or does not exercise the surface the task touches
350
352
  - scope drift: requirements, files, or deliverables not implied by the refined task or Q&A
351
353
  - a dropped or weakened CONSTRAINT from the refined task
352
-
354
+ - a synthesized interface WIRING specific — a mount/route table, a module→path mapping, an exported signature, a file layout — that the design does not pin AND that does not reproduce the design's pinned interface facts. A "uniform" pattern (one module → one mount prefix, etc.) applied to an interface whose pinned facts are NOT uniform is a SEAM BUG: flag it naming the pinned fact it contradicts.
355
+ ${contracts && contracts.trim() ? `\n${contracts.trim()}\n` : ''}
353
356
  Do NOT flag cosmetic wording, style, or anything you would change only to "polish" prose. The bar is: would this defect change what the agent builds or whether the work can be verified?
354
357
 
355
358
  Output format — read carefully:
@@ -364,7 +367,7 @@ ${qa}
364
367
 
365
368
  Spec to triage:
366
369
  ${spec}`;
367
- const CRITIQUE_PROMPT = (spec, refined, qa, addVerifyEmphasis, triageDefects = null) => `You are reviewing the implementation spec below for ambiguity, weak acceptance criteria, and missing or unrunnable VERIFY commands.
370
+ const CRITIQUE_PROMPT = (spec, refined, qa, addVerifyEmphasis, triageDefects = null, contracts) => `You are reviewing the implementation spec below for ambiguity, weak acceptance criteria, and missing or unrunnable VERIFY commands.
368
371
 
369
372
  CRITICAL FORMAT RULES (read first):
370
373
  - Output the rewritten spec as plain markdown. Do NOT wrap your entire output in a code block, shell fence, or heredoc. Do NOT prefix with \`\`\`sh / \`\`\`bash. Do NOT use \`cat << EOF > file\` patterns. Your response begins literally with "GOAL" on the first line.
@@ -378,6 +381,7 @@ SCOPE RULES (equally critical — do not break these):
378
381
  - CONSTRAINTS from the refined task MUST be preserved in spirit. Do not silently drop or weaken them.
379
382
  - If the spec below is malformed, empty, or wrapped in a heredoc, reconstruct it from the refined task and Q&A — not from your own invention.
380
383
  - Your job is to tighten language, sharpen acceptance criteria, and ensure VERIFY is runnable. Not to redesign the task.
384
+ - WIRING vs pinned facts: if the spec states interface wiring (a mount/route table, a module→path mapping, an exported signature, a file layout), reconcile EACH wiring specific against the design's pinned interface facts (the CROSS-SLICE CONTRACTS below, if present, are those facts quoted verbatim). Keep every wiring specific that reproduces the pinned facts exactly; CORRECT any that do not; and do NOT invent wiring the design leaves unspecified. Watch specifically for a "uniform" pattern (one module → one mount prefix, one naming scheme) applied to an interface whose pinned facts are NOT uniform — that is a seam bug, fix only the entry that breaks, and leave the conforming entries unchanged.
381
385
 
382
386
  Rewrite the spec in the same four-section format (GOAL, CONSTRAINTS, ACCEPTANCE, VERIFY). Fix any issues you find within the scope rules above.
383
387
 
@@ -390,6 +394,7 @@ VERIFY QUALITY CHECK (apply during the rewrite):
390
394
  - Never accept \`true\`, \`echo ok\`, or other no-op commands as VERIFY content.
391
395
 
392
396
  ${addVerifyEmphasis ? 'REQUIRED: The output MUST include a VERIFY: section followed by a ```sh fenced block of runnable shell commands. The previous attempt was missing this.' : ''}
397
+ ${contracts && contracts.trim() ? `\n${contracts.trim()}\n` : ''}
393
398
  ${triageDefects ?
394
399
  `FOCUS — a triage pass already found these specific defects. Fix every one of them in your rewrite (without breaking the scope rules above):\n${triageDefects}\n`
395
400
  : ''}
@@ -0,0 +1,25 @@
1
+ export interface SkipEscapeFinding {
2
+ /** The offending VERIFY command line, verbatim. */
3
+ line: string;
4
+ /** Why it is a skip-escape (human- and prompt-readable). */
5
+ reason: string;
6
+ }
7
+ /**
8
+ * Scan a composed spec's VERIFY block for skip-announcing escapes. Returns one
9
+ * finding per offending command line; empty when the spec has no VERIFY block or
10
+ * no skip-escapes. Comment/blank lines are already dropped by parseVerifyBlock.
11
+ */
12
+ export declare function findSkipEscapes(spec: string): SkipEscapeFinding[];
13
+ /**
14
+ * Render skip-escape findings as a defect block for the critique rewrite: a
15
+ * numbered instruction list the rewrite must resolve (remove the escape / run the
16
+ * check unconditionally, or drop the check if it is genuinely not required).
17
+ */
18
+ export declare function skipEscapeDefectText(findings: SkipEscapeFinding[]): string;
19
+ /**
20
+ * Render skip-escape findings as verify-child prompt lines (the deterministic
21
+ * finding that makes rule 5c fire reliably — the model does not self-discover a
22
+ * graceful skip-escape, but acts on a finding that names the exact line). Empty
23
+ * findings → empty array (caller emits no block).
24
+ */
25
+ export declare function skipEscapeVerifyFindings(findings: SkipEscapeFinding[]): string[];
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Deterministic skip-escape scanner for authored VERIFY blocks (run-8 F2).
3
+ *
4
+ * A skip-escape is a `||` fallback that lets a REQUIRED check pass SILENTLY when
5
+ * its tool is absent or it fails — e.g. `playwright test … || echo "skipping"`.
6
+ * mx5 run-8 shipped a blank/dead app partly because its only behavioral smoke
7
+ * tests were wrapped this way: the tool was absent, the check silently skipped,
8
+ * and the verify child blessed it as "correctly skipped".
9
+ *
10
+ * FP-MEASURED on the historical VERIFY blocks (~/hub/mx5/.pi-tasks, 22 tasks): a
11
+ * blanket `|| true` flag is ~90% false positives — teardown (`kill … || true`,
12
+ * `docker compose down … || true`), setup (`… install … || true`), and negative
13
+ * tests (`… && exit 1 || true`, where `|| true` catches an EXPECTED failure). Of
14
+ * 45 `||` uses, exactly one was the real F2 skip-escape, and it ANNOUNCED the skip
15
+ * ("Playwright not available — skipping browser smoke test"). So the crisp,
16
+ * ~zero-FP signal is a fallback whose text ADMITS it is dodging the check — that is
17
+ * the actionable finding here. Bare `|| true` is left to the verify child's runtime
18
+ * rule 5c, which can actually observe whether the check ran (a static scan cannot
19
+ * tell a required-check `|| true` from a teardown `|| true`).
20
+ *
21
+ * Pure shell-shape / text analysis; no stack or tool-name assumptions.
22
+ */
23
+ import { parseVerifyBlock } from './spec-validation.js';
24
+ /**
25
+ * A `||` (optionally after a `2>/dev/null`) whose fallback text ADMITS it is
26
+ * skipping / that a tool is unavailable. Anchored on the fallback wording, so it
27
+ * fires on `|| echo "skipping"`, `|| { echo "playwright not installed"; }`,
28
+ * `|| echo "runner unavailable — skipped"`, and misses benign teardown `|| true`.
29
+ */
30
+ const SKIP_ANNOUNCE_RE = /\|\|[^|]*\b(skip|skipping|skipped|not\s+installed|not\s+available|unavailable)\b/i;
31
+ /**
32
+ * Scan a composed spec's VERIFY block for skip-announcing escapes. Returns one
33
+ * finding per offending command line; empty when the spec has no VERIFY block or
34
+ * no skip-escapes. Comment/blank lines are already dropped by parseVerifyBlock.
35
+ */
36
+ export function findSkipEscapes(spec) {
37
+ const cmds = parseVerifyBlock(spec);
38
+ if (!cmds)
39
+ return [];
40
+ const found = [];
41
+ for (const { raw } of cmds) {
42
+ if (SKIP_ANNOUNCE_RE.test(raw)) {
43
+ found.push({
44
+ line: raw,
45
+ reason: 'its `||` fallback announces skipping the check when a tool is absent — a '
46
+ + 'required check must run unconditionally, not self-waive into a silent pass'
47
+ });
48
+ }
49
+ }
50
+ return found;
51
+ }
52
+ /**
53
+ * Render skip-escape findings as a defect block for the critique rewrite: a
54
+ * numbered instruction list the rewrite must resolve (remove the escape / run the
55
+ * check unconditionally, or drop the check if it is genuinely not required).
56
+ */
57
+ export function skipEscapeDefectText(findings) {
58
+ return [
59
+ 'SKIP-ESCAPE in the VERIFY block — a required check is wrapped so that a missing',
60
+ 'tool or a failure passes SILENTLY (run-8 F2: the only smoke tests shipped this',
61
+ 'way, skipped unnoticed, and a blank app was blessed). Rewrite the VERIFY block so',
62
+ 'each of these checks RUNS UNCONDITIONALLY and its failure fails the block — remove',
63
+ 'the `|| echo skipping`-style fallback. PREFER a check that needs no special tool at',
64
+ 'all (start the artifact and probe its real behavior directly). Do NOT merely reshape',
65
+ 'the escape into a `command -v X`/`if`-guard that still skips silently when the tool',
66
+ 'is absent — that is the same defect: if the check truly needs a tool, its absence',
67
+ 'must make the block EXIT NON-ZERO (surface it), never exit 0. If a check genuinely',
68
+ 'cannot be required here, remove it entirely rather than leaving a self-waiving stub:',
69
+ ...findings.map((f, i) => ` ${i + 1}. ${f.line}`)
70
+ ].join('\n');
71
+ }
72
+ /**
73
+ * Render skip-escape findings as verify-child prompt lines (the deterministic
74
+ * finding that makes rule 5c fire reliably — the model does not self-discover a
75
+ * graceful skip-escape, but acts on a finding that names the exact line). Empty
76
+ * findings → empty array (caller emits no block).
77
+ */
78
+ export function skipEscapeVerifyFindings(findings) {
79
+ return findings.map(f => `${f.line} — ${f.reason}`);
80
+ }
@@ -96,17 +96,29 @@ export async function runGatesForTask(ctxIn, deps, p) {
96
96
  continue;
97
97
  }
98
98
  }
99
- const recOutcome = deps.recommend ?
100
- await deps.recommend(active, p.cwd, p.title, p.taskId, failReason)
101
- : { recommend: 'autofix', rationale: failReason };
102
- await rec(`resolution: recommended ${recOutcome.recommend.toUpperCase()}`);
99
+ // UNOBSERVED (rule 5c): a spec-required behavioral check could not run because
100
+ // its observation tooling is absent. An unattended AUTOFIX re-run cannot install
101
+ // a missing tool, so it would only burn MAX_AUTO_AUTOFIX turns and re-FAIL — the
102
+ // decision (provision the tool, or accept the unproven behavior) is the human's.
103
+ // Skip the (moot) recommendation research and force the picker.
104
+ const isUnobserved = verified.unobserved === true;
105
+ const recOutcome = isUnobserved ? { recommend: 'autofix', rationale: failReason }
106
+ : deps.recommend ?
107
+ await deps.recommend(active, p.cwd, p.title, p.taskId, failReason)
108
+ : { recommend: 'autofix', rationale: failReason };
109
+ await rec(isUnobserved ?
110
+ 'resolution: verify UNOBSERVED — spec-required check could not run (tooling absent); '
111
+ + 'forcing the human picker, an unattended re-run cannot provision it'
112
+ : `resolution: recommended ${recOutcome.recommend.toUpperCase()}`);
103
113
  // AUTO-RESOLVE the AUTOFIX path: when the research says the work is
104
114
  // genuinely wrong, re-run the fix WITHOUT prompting the user. The picker is
105
115
  // reserved for the ACCEPT recommendation (the human decides whether to bless
106
116
  // an artifact the gate FAILed) and for the bounded fallback: after
107
117
  // MAX_AUTO_AUTOFIX consecutive unattended attempts that still FAIL, hand
108
118
  // control back so a person can break a non-converging loop.
109
- const autoFixNow = recOutcome.recommend === 'autofix' && autoFixCount < MAX_AUTO_AUTOFIX;
119
+ const autoFixNow = !isUnobserved
120
+ && recOutcome.recommend === 'autofix'
121
+ && autoFixCount < MAX_AUTO_AUTOFIX;
110
122
  let choice;
111
123
  if (autoFixNow) {
112
124
  autoFixCount += 1;
@@ -21,6 +21,12 @@ export interface VerifyOutcome {
21
21
  /** Short, human-readable reason. Always set when ok === false; on the pass
22
22
  * path set to the no-op cause ('disabled', 'no spec to verify'). */
23
23
  reason?: string;
24
+ /** True when the FAIL is specifically an UNOBSERVED outcome (rule 5c): a
25
+ * spec-required behavioral check could not run because its tooling is absent.
26
+ * The gate routes this straight to the human picker instead of an unattended
27
+ * AUTOFIX re-run, which cannot provision a missing tool. Only meaningful when
28
+ * ok === false. */
29
+ unobserved?: boolean;
24
30
  }
25
31
  /**
26
32
  * Slice the delivered spec (GOAL / CONSTRAINTS / ACCEPTANCE / VERIFY) out of a
@@ -55,17 +61,24 @@ export declare function extractSpecForVerification(taskBody: string): string | n
55
61
  * Guard: honest-clean fixture (prohibition in spec, probe silent) 5/5 PASS — no
56
62
  * paranoia. Reverted-violation ≡ clean at the diff level (no entry → no finding).
57
63
  */
58
- export declare function buildVerifyPrompt(spec: string, probeFindings?: string[], envNotes?: string, prohibitionFindings?: string[]): string;
64
+ export declare function buildVerifyPrompt(spec: string, probeFindings?: string[], envNotes?: string, prohibitionFindings?: string[], skipEscapeFindings?: string[], contracts?: string): string;
59
65
  /**
60
- * Parse the child's verdict. Scans for the LAST `WORK-VERIFIED: PASS|FAIL` marker
61
- * (the model discusses before concluding, and bash output may echo the word
66
+ * Parse the child's verdict. Scans for the LAST `WORK-VERIFIED: PASS|FAIL|UNOBSERVED`
67
+ * marker (the model discusses before concluding, and bash output may echo the word
62
68
  * "VERIFY", so a distinct token and last-match win matter).
63
69
  *
70
+ * UNOBSERVED (rule 5c) is a distinct third outcome: a spec-required behavioral check
71
+ * could not run because its observation tooling is absent, so the behavior is neither
72
+ * proven nor shown broken. It is NOT a pass (`pass: false`) but carries `unobserved`
73
+ * so the gate can route it straight to the human — an unattended AUTOFIX re-run cannot
74
+ * provision a missing tool, so it must never auto-loop on this.
75
+ *
64
76
  * No marker at all is NOT a pass: a verification that cannot state a verdict is a
65
77
  * gray area, and the contract is that unverified work is reported as such.
66
78
  */
67
79
  export declare function parseVerifyVerdict(text: string): {
68
80
  pass: boolean;
81
+ unobserved?: boolean;
69
82
  detail: string;
70
83
  };
71
84
  export interface VerificationDeps {
@@ -124,6 +137,14 @@ export interface VerificationDeps {
124
137
  read: () => Promise<string>;
125
138
  append: (notes: string[]) => Promise<void>;
126
139
  };
140
+ /**
141
+ * Per-run cross-slice contract registry (see contracts.ts): `read` supplies the
142
+ * verbatim interface facts the SOURCE design pins that more than one slice
143
+ * touches, injected so the verify child checks THIS slice's boundary against
144
+ * them (F3 seam bugs are locally right but globally wrong). ABSENT/empty → no
145
+ * block (single `/task` runs, or a design pinning no shared boundary), unchanged.
146
+ */
147
+ contracts?: () => Promise<string>;
127
148
  }
128
149
  /**
129
150
  * Run the verification pass for one task. A missing spec is a pass. Otherwise run
@@ -69,6 +69,8 @@
69
69
  */
70
70
  import { USER_CANCELLED } from './child-runner.js';
71
71
  import { buildEnvNotesBlock, ENV_NOTE_EMIT_INSTRUCTION, extractEnvNotes } from './env-notes.js';
72
+ import { buildContractsVerifyBlock } from './contracts.js';
73
+ import { findSkipEscapes, skipEscapeVerifyFindings } from './skip-escape.js';
72
74
  /**
73
75
  * The verification child gets exactly two tools: `read` and `bash`.
74
76
  *
@@ -137,7 +139,7 @@ export function extractSpecForVerification(taskBody) {
137
139
  * Guard: honest-clean fixture (prohibition in spec, probe silent) 5/5 PASS — no
138
140
  * paranoia. Reverted-violation ≡ clean at the diff level (no entry → no finding).
139
141
  */
140
- export function buildVerifyPrompt(spec, probeFindings, envNotes, prohibitionFindings) {
142
+ export function buildVerifyPrompt(spec, probeFindings, envNotes, prohibitionFindings, skipEscapeFindings, contracts) {
141
143
  const probeBlock = probeFindings && probeFindings.length > 0 ?
142
144
  [
143
145
  'SELF-VERIFICATION NOTICE (deterministic, computed by the orchestrator from the diff):',
@@ -164,7 +166,23 @@ export function buildVerifyPrompt(spec, probeFindings, envNotes, prohibitionFind
164
166
  ''
165
167
  ]
166
168
  : [];
169
+ const skipEscapeBlock = skipEscapeFindings && skipEscapeFindings.length > 0 ?
170
+ [
171
+ "SKIP-ESCAPE NOTICE (deterministic, computed by the orchestrator from the spec's",
172
+ 'OWN VERIFY block): these VERIFY commands wrap a required check in a fallback that',
173
+ 'ANNOUNCES skipping it when a tool is absent — so the check can "pass" while never',
174
+ 'actually running:',
175
+ ...skipEscapeFindings.map(f => `- ${f}`),
176
+ 'Do NOT accept a skipped check as a passed check. For each, determine whether it',
177
+ 'ACTUALLY ran and observed the real behavior. If its tool is absent so the required',
178
+ 'behavior was never observed, that area is UNOBSERVED (rule 5c) — verdict UNOBSERVED,',
179
+ 'not PASS. Only if you observe the required behavior another way (running the real',
180
+ 'artifact directly) may it count as verified.',
181
+ ''
182
+ ]
183
+ : [];
167
184
  const envBlock = envNotes && envNotes.trim().length > 0 ? [buildEnvNotesBlock(envNotes)] : [];
185
+ const contractsBlock = contracts && contracts.trim().length > 0 ? [buildContractsVerifyBlock(contracts)] : [];
168
186
  return [
169
187
  'You are a strict verification pass running right after an AI coding agent',
170
188
  'finished a task and committed it. The agent is known to mark work "done"',
@@ -179,8 +197,10 @@ export function buildVerifyPrompt(spec, probeFindings, envNotes, prohibitionFind
179
197
  spec.trim(),
180
198
  '',
181
199
  ...envBlock,
200
+ ...contractsBlock,
182
201
  ...probeBlock,
183
202
  ...prohibitionBlock,
203
+ ...skipEscapeBlock,
184
204
  'How to verify — verify the REAL, shipped deliverable exactly as an unaided fresh',
185
205
  'checkout (or CI run) would experience it:',
186
206
  '',
@@ -242,6 +262,24 @@ export function buildVerifyPrompt(spec, probeFindings, envNotes, prohibitionFind
242
262
  ' server never starts, no entrypoint exists — that inability IS the defect:',
243
263
  ' report FAIL naming it. Do not stand up a substitute to get to green.',
244
264
  '',
265
+ '3e. NEGATIVE CONTROL IS MANDATORY — a success you cannot make fail is not evidence,',
266
+ ' and a check that only ever ran on the RIGHT input has not been shown to',
267
+ ' discriminate working from broken. For EACH behavioral check whose passing outcome',
268
+ ' you rely on (an endpoint returned success, a command exited 0, a call returned the',
269
+ ' expected value) you MUST also run that SAME check with one input deliberately',
270
+ ' wrong — a nonsense path, a bogus flag or subcommand, a malformed or absent',
271
+ ' argument, a target that does not exist — recreating whatever setup the check needs',
272
+ ' to do so (restart the server if a prior step killed it, re-invoke the command).',
273
+ ' Require a DIFFERENT, failing outcome from the wrong input. If the wrong input',
274
+ ' yields the SAME success (same status code, same body, same exit 0), the check',
275
+ ' cannot tell working from broken: it is VOID and the behavior it was meant to prove',
276
+ ' is UNVERIFIED — a FAIL naming the indiscriminate check (e.g. "a POST to a nonsense',
277
+ ' path returns the same 200 and body as the real endpoint, so the route is unproven',
278
+ ' — a catch-all fallback is masking it"). Skipping the control is not neutral: a',
279
+ ' required behavior left without a discriminating check is UNVERIFIED, and',
280
+ ' unverified is a FAIL, never a PASS. A wrong-input control exists for every',
281
+ ' artifact — HTTP request, CLI invocation, library call, schema load, config parse.',
282
+ '',
245
283
  '4. Treat the ACCEPTANCE criteria as the bar. If a command fails, or its real output',
246
284
  ' contradicts an ACCEPTANCE criterion, the work has NOT verified.',
247
285
  '',
@@ -282,8 +320,36 @@ export function buildVerifyPrompt(spec, probeFindings, envNotes, prohibitionFind
282
320
  ' that is a FAIL naming the gap. Any schema surgery you performed to reach green IS the',
283
321
  ' defect.',
284
322
  '',
285
- '6. If the spec legitimately has no runnable verification (a pure docs / config change',
286
- ' with nothing to build or run), validating it cleanly is a PASS.',
323
+ '5c. A SPEC-REQUIRED CHECK THAT DID NOT ACTUALLY RUN IS NOT VERIFIED. The env-gap',
324
+ ' exception (rule 5) covers ONLY a genuinely EXTERNAL service the finished product',
325
+ " connects to (a database, an API host). It does NOT cover a check the SPEC's own",
326
+ " VERIFY block authored to OBSERVE the deliverable's required behavior. If such a",
327
+ ' check did not actually execute — the tool or runner it needs is not installed, or',
328
+ ' the VERIFY line short-circuits ITSELF with a skip-escape (`|| true`, `|| echo',
329
+ ' skipping`, `2>/dev/null || exit 0`, `command -v X ||` …) so a missing tool passes',
330
+ ' silently — then that behavior was NEVER OBSERVED. A skipped check is not a passed',
331
+ ' check: you may not count the area as verified, and "correctly skipped" is NOT a',
332
+ " PASS. Do not let the work's own skip-escape waive the work's own gate. When a",
333
+ ' REQUIRED behavior could not be observed because its observation tooling is absent,',
334
+ ' the verdict is UNOBSERVED — report exactly what could not be observed and which',
335
+ ' tool was missing, so a human decides whether to provision the tool or accept the',
336
+ ' unproven behavior.',
337
+ ' DISCRIMINATOR (rule 5 vs rule 5c) — when something needed is absent, ask which it',
338
+ ' is: (rule 5, env-gap, do NOT fail the code) a SERVICE the FINISHED PRODUCT itself',
339
+ ' connects to at runtime to FUNCTION — a database, an API host, a message broker —',
340
+ ' whose absence is an environment gap; versus (rule 5c, UNOBSERVED) a HARNESS that',
341
+ ' exists only to OBSERVE or DRIVE the product during a check — a browser driver, a',
342
+ " UI/terminal smoke, a snapshot or fuzz tool — whose absence leaves the product's own",
343
+ ' behavior unproven. The product NEEDS the former to work at all; it needs the latter',
344
+ ' only to be CHECKED. A command that fails because such a runtime SERVICE is absent',
345
+ ' stays a rule-5 env-gap and is NOT UNOBSERVED; a required behavior you could not',
346
+ ' OBSERVE because its test harness is absent IS UNOBSERVED.',
347
+ '',
348
+ '6. If the spec legitimately has no runnable verification at all (a pure docs / config',
349
+ ' change with nothing to build or run), validating it cleanly is a PASS. This is NOT',
350
+ ' the same as a required behavioral check that self-skipped or whose tool is absent',
351
+ ' (that is UNOBSERVED, rule 5c) — "nothing to verify" means the spec never demanded',
352
+ ' an observation, not that an observation was demanded and then dodged.',
287
353
  '',
288
354
  '7. Do NOT edit anything to make a check pass. Report what you actually saw.',
289
355
  '',
@@ -291,32 +357,53 @@ export function buildVerifyPrompt(spec, probeFindings, envNotes, prohibitionFind
291
357
  'acceptance criterion is unmet — a required function absent, required data not persisted,',
292
358
  'a required behavior missing — the verdict is FAIL, even if typecheck and lint are green',
293
359
  'and even if the gap seems minor. Never downgrade an unmet criterion to a warning note.',
360
+ 'Before concluding PASS, confirm every behavioral check you relied on has its paired',
361
+ 'negative control (rule 3e) showing it CAN fail; a required behavior with no',
362
+ 'discriminating check is UNVERIFIED, and the verdict is FAIL.',
294
363
  '',
295
364
  ENV_NOTE_EMIT_INSTRUCTION,
296
365
  '',
297
366
  'When you are done, output EXACTLY ONE of these as the final line:',
298
367
  " WORK-VERIFIED: PASS (the project's own command, run unaided, met the spec)",
299
368
  ' WORK-VERIFIED: FAIL <text> (the shipped command failed or did not meet the spec; say what failed)',
369
+ ' WORK-VERIFIED: UNOBSERVED <text> (a spec-required behavioral check could not run because',
370
+ ' its observation tooling is absent — the behavior is',
371
+ ' unproven, not passed and not a code failure; rule 5c)',
300
372
  'Output the verdict line verbatim — it is parsed mechanically.'
301
373
  ].join('\n');
302
374
  }
303
375
  /**
304
- * Parse the child's verdict. Scans for the LAST `WORK-VERIFIED: PASS|FAIL` marker
305
- * (the model discusses before concluding, and bash output may echo the word
376
+ * Parse the child's verdict. Scans for the LAST `WORK-VERIFIED: PASS|FAIL|UNOBSERVED`
377
+ * marker (the model discusses before concluding, and bash output may echo the word
306
378
  * "VERIFY", so a distinct token and last-match win matter).
307
379
  *
380
+ * UNOBSERVED (rule 5c) is a distinct third outcome: a spec-required behavioral check
381
+ * could not run because its observation tooling is absent, so the behavior is neither
382
+ * proven nor shown broken. It is NOT a pass (`pass: false`) but carries `unobserved`
383
+ * so the gate can route it straight to the human — an unattended AUTOFIX re-run cannot
384
+ * provision a missing tool, so it must never auto-loop on this.
385
+ *
308
386
  * No marker at all is NOT a pass: a verification that cannot state a verdict is a
309
387
  * gray area, and the contract is that unverified work is reported as such.
310
388
  */
311
389
  export function parseVerifyVerdict(text) {
312
- const re = /WORK-VERIFIED:\s*(PASS|FAIL)\b[ \t]*(.*)/gi;
390
+ const re = /WORK-VERIFIED:\s*(PASS|FAIL|UNOBSERVED)\b[ \t]*(.*)/gi;
313
391
  let last = null;
314
392
  for (let m = re.exec(text); m !== null; m = re.exec(text))
315
393
  last = m;
316
394
  if (!last)
317
395
  return { pass: false, detail: 'no verdict emitted' };
318
- const pass = last[1].toUpperCase() === 'PASS';
319
- return { pass, detail: pass ? '' : last[2].trim() || 'unspecified failure' };
396
+ const kind = last[1].toUpperCase();
397
+ if (kind === 'PASS')
398
+ return { pass: true, detail: '' };
399
+ if (kind === 'UNOBSERVED') {
400
+ return {
401
+ pass: false,
402
+ unobserved: true,
403
+ detail: last[2].trim() || 'a required behavior could not be observed'
404
+ };
405
+ }
406
+ return { pass: false, detail: last[2].trim() || 'unspecified failure' };
320
407
  }
321
408
  /**
322
409
  * Run the verification pass for one task. A missing spec is a pass. Otherwise run
@@ -370,6 +457,23 @@ export async function runWorkVerification(deps) {
370
457
  envNotes = '';
371
458
  }
372
459
  }
460
+ // Cross-slice contracts from decompose-time extraction (best-effort; a read
461
+ // fault must never block verification).
462
+ let contracts = '';
463
+ if (deps.contracts) {
464
+ try {
465
+ contracts = await deps.contracts();
466
+ }
467
+ catch {
468
+ contracts = '';
469
+ }
470
+ }
471
+ // DETERMINISTIC skip-escape finding, computed purely from the spec's own VERIFY
472
+ // block (see skip-escape.ts): a required check wrapped in a skip-announcing `||`
473
+ // fallback. Injected so rule 5c fires reliably — the model does not self-discover a
474
+ // graceful skip-escape (A/B: rule alone ~1-3/5), but acts on a finding naming the
475
+ // exact line, per the proven probe+rule pattern. Pure text analysis, no dep needed.
476
+ const skipEscapes = skipEscapeVerifyFindings(findSkipEscapes(deps.spec));
373
477
  // A child that emits NO verdict never judged the work (budget/context death mid-
374
478
  // investigation — seen live: an 11-minute verify wandered, died verdict-less, and
375
479
  // the resulting FAIL burned a full implementation re-run on an unjudged artifact).
@@ -377,7 +481,7 @@ export async function runWorkVerification(deps) {
377
481
  for (let attempt = 1;; attempt++) {
378
482
  let text;
379
483
  try {
380
- text = await deps.runChild(VERIFY_TOOLS, buildVerifyPrompt(deps.spec, findings, envNotes, prohibitions), deps.signal);
484
+ text = await deps.runChild(VERIFY_TOOLS, buildVerifyPrompt(deps.spec, findings, envNotes, prohibitions, skipEscapes, contracts), deps.signal);
381
485
  }
382
486
  catch (err) {
383
487
  if (err instanceof Error && err.message === USER_CANCELLED)
@@ -414,6 +518,12 @@ export async function runWorkVerification(deps) {
414
518
  return { ok: true };
415
519
  if (verdict.detail === 'no verdict emitted' && attempt === 1)
416
520
  continue;
521
+ // UNOBSERVED (rule 5c): a spec-required behavioral check could not run because
522
+ // its tooling is absent. Block like any FAIL, but flag it so the gate hands it
523
+ // straight to the human — re-running the impl turn cannot install a missing tool.
524
+ if (verdict.unobserved) {
525
+ return { ok: false, unobserved: true, reason: `work unobserved: ${verdict.detail}` };
526
+ }
417
527
  return {
418
528
  ok: false,
419
529
  reason: `work did not verify: ${verdict.detail}${verdict.detail === 'no verdict emitted' ? ' (after verify retry)' : ''}`
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Deterministic synthesized-wiring scanner for a composed spec (run-8 F3, gen side).
3
+ *
4
+ * F3 (the dominant run-8 shipped defect): refine/compose invent a "uniform" wiring
5
+ * table — one module → one mount prefix, `/api/<x>` → `<x>Routes` for every module —
6
+ * though the design pins ENDPOINTS, not mounts, and one module's pinned endpoints do
7
+ * NOT all sit under a single prefix (photos: `POST /api/listings/:id/photos` AND
8
+ * `GET/DELETE /api/photos/:id`). Mounting that module at `/api/photos` double-prefixes
9
+ * the upload; consumers follow the pinned paths, assembly follows the invented table,
10
+ * the seam ships broken. See [[contract-registry-f3]] (#4, the verify-side + registry
11
+ * lever) — this is its GENERATION-side complement.
12
+ *
13
+ * A/B-measured on the live 27B (F3 critique trap): the CROSS-SLICE CONTRACTS registry
14
+ * is NECESSARY but the prompt+registry alone is a WEAK catcher (A/B arms 0/8, +registry
15
+ * only 1/8) — the model's attention goes to the obvious VERIFY weakness and it rarely
16
+ * does the path-composition reasoning even with the facts in front of it. The reliable
17
+ * lever is the SAME probe+rule pattern as [[skip-escape-scanner-f2]] / [[verify-
18
+ * substitution-ab]]: a deterministic finding that NAMES the exact synthesized mappings
19
+ * and juxtaposes the verbatim pinned facts, forcing focused reconciliation.
20
+ *
21
+ * This scanner does NOT decide which mapping is wrong — that needs routing-composition
22
+ * knowledge (a forbidden stack assumption). It surfaces every mapping that (a) is not a
23
+ * verbatim substring of the design/registry (so it is INFERRED, not cited) AND (b) touches
24
+ * a pinned cross-slice boundary (an operand appears in the registry) — i.e. it reshapes a
25
+ * shared contract. The 4 coincidentally-correct mappings are surfaced too, but framed as
26
+ * "reconcile each; keep the conforming ones" — the LLM decides, informed. Pure text/
27
+ * substring analysis; no stack, framework, or routing assumptions. Empty registry (single
28
+ * `/task`, or no shared boundary) ⇒ no-op.
29
+ */
30
+ export interface WiringClaim {
31
+ /** The offending mapping line, verbatim (trimmed). */
32
+ line: string;
33
+ /** Left operand (the mapped-from token, e.g. a mount prefix). */
34
+ from: string;
35
+ /** Right operand (the mapped-to token, e.g. a module name). */
36
+ to: string;
37
+ }
38
+ /**
39
+ * Find synthesized wiring mappings in `spec`: `A <arrow> B` lines whose whole mapping
40
+ * is NOT a verbatim substring of `grounding` (design ∪ registry) yet an operand appears
41
+ * in `registry` (so it reshapes a pinned cross-slice boundary). Returns [] when the
42
+ * registry is empty (no shared contracts to reshape) — the whole check is a no-op then.
43
+ */
44
+ export declare function findSynthesizedWiring(spec: string, grounding: string, registry: string): WiringClaim[];
45
+ /**
46
+ * Render findings as the critique probe (probe+rule pattern): NAME the inferred
47
+ * mappings and juxtapose the verbatim pinned facts, then instruct focused
48
+ * reconciliation. Deliberately does NOT accuse a specific mapping — the model decides
49
+ * which (if any) fails to reproduce a pinned fact. Empty findings ⇒ '' (no block).
50
+ */
51
+ export declare function wiringProbeText(findings: WiringClaim[], registry: string): string;
52
+ /**
53
+ * Render findings as a critique-rewrite defect block (fed into the FOCUS list): the
54
+ * rewrite must reconcile each mapping against the pinned facts and correct only the
55
+ * one(s) that break. Mirrors skipEscapeDefectText's shape.
56
+ */
57
+ export declare function wiringDefectText(findings: WiringClaim[], registry: string): string;
58
+ /**
59
+ * Concatenate the design/spec docs the given texts @-reference (best-effort, readable
60
+ * files only) as extra grounding for findSynthesizedWiring — so a mapping the design
61
+ * states verbatim is treated as CITED, not synthesized. Unreadable/absent mentions are
62
+ * skipped; returns '' when nothing resolves.
63
+ */
64
+ export declare function readReferencedDocs(cwd: string, ...texts: string[]): string;
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Deterministic synthesized-wiring scanner for a composed spec (run-8 F3, gen side).
3
+ *
4
+ * F3 (the dominant run-8 shipped defect): refine/compose invent a "uniform" wiring
5
+ * table — one module → one mount prefix, `/api/<x>` → `<x>Routes` for every module —
6
+ * though the design pins ENDPOINTS, not mounts, and one module's pinned endpoints do
7
+ * NOT all sit under a single prefix (photos: `POST /api/listings/:id/photos` AND
8
+ * `GET/DELETE /api/photos/:id`). Mounting that module at `/api/photos` double-prefixes
9
+ * the upload; consumers follow the pinned paths, assembly follows the invented table,
10
+ * the seam ships broken. See [[contract-registry-f3]] (#4, the verify-side + registry
11
+ * lever) — this is its GENERATION-side complement.
12
+ *
13
+ * A/B-measured on the live 27B (F3 critique trap): the CROSS-SLICE CONTRACTS registry
14
+ * is NECESSARY but the prompt+registry alone is a WEAK catcher (A/B arms 0/8, +registry
15
+ * only 1/8) — the model's attention goes to the obvious VERIFY weakness and it rarely
16
+ * does the path-composition reasoning even with the facts in front of it. The reliable
17
+ * lever is the SAME probe+rule pattern as [[skip-escape-scanner-f2]] / [[verify-
18
+ * substitution-ab]]: a deterministic finding that NAMES the exact synthesized mappings
19
+ * and juxtaposes the verbatim pinned facts, forcing focused reconciliation.
20
+ *
21
+ * This scanner does NOT decide which mapping is wrong — that needs routing-composition
22
+ * knowledge (a forbidden stack assumption). It surfaces every mapping that (a) is not a
23
+ * verbatim substring of the design/registry (so it is INFERRED, not cited) AND (b) touches
24
+ * a pinned cross-slice boundary (an operand appears in the registry) — i.e. it reshapes a
25
+ * shared contract. The 4 coincidentally-correct mappings are surfaced too, but framed as
26
+ * "reconcile each; keep the conforming ones" — the LLM decides, informed. Pure text/
27
+ * substring analysis; no stack, framework, or routing assumptions. Empty registry (single
28
+ * `/task`, or no shared boundary) ⇒ no-op.
29
+ */
30
+ import * as fs from 'node:fs';
31
+ import * as path from 'node:path';
32
+ /** Mapping arrows a wiring/mount table uses across notations. A colon is deliberately
33
+ * NOT an arrow — it matches endpoint params (`/photos/:id`), section headers, and prose,
34
+ * which would drown the signal. */
35
+ const ARROW = '(?:→|->|=>|⇒|↦)';
36
+ /** A `left <arrow> right` mapping on one line, tolerating a leading list bullet. */
37
+ const MAPPING_LINE_RE = new RegExp(`^[ \\t]*[-*]?[ \\t]*(.+?)[ \\t]*${ARROW}[ \\t]*(.+?)[ \\t]*$`);
38
+ /** An operand shorter than this is too generic to anchor a boundary match. */
39
+ const MIN_OPERAND_LENGTH = 4;
40
+ /** Collapse whitespace + lowercase; drop markdown backticks so quoting formatting
41
+ * differences don't defeat the substring match. Mirrors contracts.ts's normalise. */
42
+ function normalise(s) {
43
+ return s.replace(/`/g, '').replace(/\s+/g, ' ').trim().toLowerCase();
44
+ }
45
+ /**
46
+ * Find synthesized wiring mappings in `spec`: `A <arrow> B` lines whose whole mapping
47
+ * is NOT a verbatim substring of `grounding` (design ∪ registry) yet an operand appears
48
+ * in `registry` (so it reshapes a pinned cross-slice boundary). Returns [] when the
49
+ * registry is empty (no shared contracts to reshape) — the whole check is a no-op then.
50
+ */
51
+ export function findSynthesizedWiring(spec, grounding, registry) {
52
+ if (registry.trim().length === 0)
53
+ return [];
54
+ const groundHay = normalise(grounding + '\n' + registry);
55
+ const regHay = normalise(registry);
56
+ const found = [];
57
+ const seen = new Set();
58
+ for (const rawLine of spec.split('\n')) {
59
+ const m = MAPPING_LINE_RE.exec(rawLine);
60
+ if (!m)
61
+ continue;
62
+ const from = m[1].trim();
63
+ const to = m[2].trim();
64
+ const line = rawLine.replace(/^[ \t]*[-*][ \t]*/, '').trim();
65
+ const key = normalise(line);
66
+ if (key.length === 0 || seen.has(key))
67
+ continue;
68
+ // Cited, not synthesized: the whole mapping appears verbatim in the source.
69
+ if (groundHay.includes(key))
70
+ continue;
71
+ // Only a mapping that touches a PINNED shared boundary is an F3 suspect — this
72
+ // is the crisp discriminator that keeps internal-flow prose ("input → output")
73
+ // out. An operand (long enough to be specific) must appear in the registry.
74
+ const touchesBoundary = [from, to].some(op => {
75
+ const n = normalise(op);
76
+ return n.length >= MIN_OPERAND_LENGTH && regHay.includes(n);
77
+ });
78
+ if (!touchesBoundary)
79
+ continue;
80
+ seen.add(key);
81
+ found.push({ line, from: from.replace(/`/g, ''), to: to.replace(/`/g, '') });
82
+ }
83
+ return found;
84
+ }
85
+ /**
86
+ * Render findings as the critique probe (probe+rule pattern): NAME the inferred
87
+ * mappings and juxtapose the verbatim pinned facts, then instruct focused
88
+ * reconciliation. Deliberately does NOT accuse a specific mapping — the model decides
89
+ * which (if any) fails to reproduce a pinned fact. Empty findings ⇒ '' (no block).
90
+ */
91
+ export function wiringProbeText(findings, registry) {
92
+ if (findings.length === 0)
93
+ return '';
94
+ return [
95
+ 'SYNTHESIZED WIRING (deterministic finding) — the spec states these connect-the-',
96
+ 'modules mappings that are NOT quoted verbatim from the design (they are INFERRED,',
97
+ 'not cited) and that touch a pinned cross-slice boundary:',
98
+ ...findings.map((f, i) => ` ${i + 1}. ${f.line}`),
99
+ 'The design pins these interface FACTS instead (verbatim, authoritative):',
100
+ ...registry
101
+ .trim()
102
+ .split('\n')
103
+ .filter(l => l.trim().length > 0)
104
+ .map(l => ` - ${l.trim()}`),
105
+ 'For EACH mapping above, confirm it REPRODUCES the pinned facts EXACTLY. A module',
106
+ 'whose pinned facts do NOT all sit under the single prefix it is mapped to CANNOT be',
107
+ 'wired that way without breaking a path — that is a SEAM BUG: name the mapping and the',
108
+ 'pinned fact it fails to produce. KEEP every mapping that does reproduce its facts; do',
109
+ 'NOT alter a conforming one. If a boundary detail is genuinely unpinned, leave it',
110
+ 'unspecified rather than inventing a mapping.'
111
+ ].join('\n');
112
+ }
113
+ /**
114
+ * Render findings as a critique-rewrite defect block (fed into the FOCUS list): the
115
+ * rewrite must reconcile each mapping against the pinned facts and correct only the
116
+ * one(s) that break. Mirrors skipEscapeDefectText's shape.
117
+ */
118
+ export function wiringDefectText(findings, registry) {
119
+ return wiringProbeText(findings, registry);
120
+ }
121
+ // An @-file mention in a spec ("@DESIGN/foo.md"), minus trailing prose punctuation.
122
+ // Mirrors phantom-imports' mention rules so grounding sees the same source docs.
123
+ const MENTION_RE = /(?:^|\s)@([^\s]+)/g;
124
+ const MENTION_TRAILING_PUNCT = /[.,;:!?)\]}>"']+$/;
125
+ /**
126
+ * Concatenate the design/spec docs the given texts @-reference (best-effort, readable
127
+ * files only) as extra grounding for findSynthesizedWiring — so a mapping the design
128
+ * states verbatim is treated as CITED, not synthesized. Unreadable/absent mentions are
129
+ * skipped; returns '' when nothing resolves.
130
+ */
131
+ export function readReferencedDocs(cwd, ...texts) {
132
+ const seen = new Set();
133
+ const parts = [];
134
+ for (const text of texts) {
135
+ for (const m of text.matchAll(MENTION_RE)) {
136
+ const rel = m[1].replace(MENTION_TRAILING_PUNCT, '');
137
+ if (rel === '' || seen.has(rel))
138
+ continue;
139
+ seen.add(rel);
140
+ try {
141
+ parts.push(fs.readFileSync(path.resolve(cwd, rel), 'utf8'));
142
+ }
143
+ catch {
144
+ // not a readable file — skip
145
+ }
146
+ }
147
+ }
148
+ return parts.join('\n');
149
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.18.2",
3
+ "version": "0.18.3",
4
4
  "description": "Deterministic task planning and spec-orchestration for local models — crash-safe /task pipelines with verify/enforce gates, a real-time remote web view, and web/docs/fetch/worker subagent tools.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",