@mjasnikovs/pi-task 0.18.2 → 0.18.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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;
@@ -0,0 +1,87 @@
1
+ /**
2
+ * test-assembly — deterministic detection of TEST-REBUILT PRODUCTION WIRING, feeding
3
+ * the verify gate's prompt (run-8 F4; third recurrence of the test-the-copy class,
4
+ * runs 3, 4, 8).
5
+ *
6
+ * The failure class: a test file re-constructs wiring that ALSO exists in production
7
+ * — it builds its own app assembly / its own entry point out of the same leaf modules
8
+ * the production entry composes, then tests THAT private copy. The copy can be wired
9
+ * differently from production and stay green while the shipped wiring is broken. Run-8
10
+ * fixture: `test/photos.test.ts` imports the real `authRoutes` + `photosRoutes` leaves,
11
+ * mounts them into its OWN app at a DIFFERENT prefix than the production entry, and
12
+ * runs 102/102 green — while the shipped upload path is dead because production mounts
13
+ * the same leaf at the wrong prefix. The verify child, judging "do the tests pass",
14
+ * saw green and counted the photos area verified. The seam the test was supposed to
15
+ * cover is exactly the seam it re-implemented away.
16
+ *
17
+ * This is the VERIFY-SIDE complement of the generation-side wiring probe
18
+ * (wiring-claims.ts, item #5) and shares the load-bearing lesson of
19
+ * substitution-probe / skip-escape / wiring-claims: a deterministic finding that NAMES
20
+ * the suspect file is the reliable lever; a bare prompt rule is weak. rule 3b already
21
+ * tells the child to spot-check that self-authored tests exercise the real artifact,
22
+ * but F4 slips through because these tests DO import the real leaf modules — they just
23
+ * bypass the real ASSEMBLY, which rule 3b's "did it import and call the module" check
24
+ * does not catch. This probe supplies the missing concrete fact.
25
+ *
26
+ * THE SIGNAL is pure import-graph SHAPE, zero stack/framework assumptions (no "app",
27
+ * no "route", no "mount", no language runtime): a test file T is flagged when there is
28
+ * a production file E (the assembly/entry) such that
29
+ * - T does NOT import E (it bypasses the shipped assembly), AND
30
+ * - T and E both import ≥2 of the SAME leaf modules, where each such leaf is
31
+ * imported by E and by NO OTHER production file (E is the leaf's SOLE production
32
+ * composition site).
33
+ * The "E-exclusive leaf" condition is the crisp discriminator that keeps ordinary
34
+ * shared-utility imports out: a test importing an api client + a schema module that
35
+ * every page also imports is NOT re-assembly (those utilities have many production
36
+ * importers); a test importing two route modules that only the server entry composes
37
+ * IS re-assembly. Measured on the run-8 fixture tree: flags exactly the four backend
38
+ * tests that rebuild the server entry's route composition (including the real
39
+ * photos seam bug) and leaves clean the single-leaf direct test, the utility-sharing
40
+ * page test, and the source-grepping test — 0 false positives.
41
+ *
42
+ * Findings are ADVISORY (probe+rule): they mandate the child to exercise the REAL
43
+ * shipped assembly directly before counting the area verified; they never auto-FAIL.
44
+ * A test that re-composes wiring which happens to match production survives once the
45
+ * child drives the real entry; only one whose real assembly is broken gets named.
46
+ */
47
+ /** A source file the probe reasons over: repo-relative path + its full text. */
48
+ export interface RepoFile {
49
+ /** Path relative to the repo root (used verbatim in the finding text). */
50
+ path: string;
51
+ /** Full file contents (import statements are parsed out of it). */
52
+ text: string;
53
+ }
54
+ /** One test file that rebuilds a production assembly instead of importing it. */
55
+ export interface TestAssemblyFinding {
56
+ /** The test file re-constructing the wiring. */
57
+ testFile: string;
58
+ /** The production assembly / entry it bypasses (sole composer of the leaves). */
59
+ assemblyFile: string;
60
+ /** The leaf modules (repo-relative, extensionless) the test re-composes. */
61
+ leaves: string[];
62
+ }
63
+ /** The importing file's OWN module id (path minus extension / `/index`). */
64
+ export declare function moduleIdOf(filePath: string): string;
65
+ /**
66
+ * The set of repo-relative, extensionless module ids that `text` imports via RELATIVE
67
+ * specifiers (a specifier starting with `.`). Bare/external specifiers (`hono`,
68
+ * `bun:sql`, `node:fs`) are ignored — they never name a repo file, so they cannot be
69
+ * a re-composed production leaf. Resolution is pure path arithmetic against the
70
+ * importer's directory; the filesystem is never touched.
71
+ */
72
+ export declare function relativeImports(filePath: string, text: string): string[];
73
+ /**
74
+ * Find test files that rebuild a production assembly. `changedTestFiles` are the
75
+ * task's own authored/changed test files (path + text); `productionFiles` are the
76
+ * repo's non-test source files (path + text) used to build the import graph and the
77
+ * per-leaf production in-degree. Returns one finding per re-assembling test, sorted
78
+ * for determinism. Empty when no test re-composes an E-exclusive leaf set.
79
+ */
80
+ export declare function findTestRebuiltAssemblies(changedTestFiles: RepoFile[], productionFiles: RepoFile[]): TestAssemblyFinding[];
81
+ /**
82
+ * Render findings as verify-child prompt lines (probe+rule pattern — the concrete
83
+ * finding that makes the rule fire reliably). One line per re-assembling test naming
84
+ * the test, the shipped assembly it bypasses, and the re-composed leaves. Empty
85
+ * findings → empty array (caller emits no block).
86
+ */
87
+ export declare function testAssemblyVerifyFindings(findings: TestAssemblyFinding[]): string[];
@@ -0,0 +1,163 @@
1
+ /**
2
+ * test-assembly — deterministic detection of TEST-REBUILT PRODUCTION WIRING, feeding
3
+ * the verify gate's prompt (run-8 F4; third recurrence of the test-the-copy class,
4
+ * runs 3, 4, 8).
5
+ *
6
+ * The failure class: a test file re-constructs wiring that ALSO exists in production
7
+ * — it builds its own app assembly / its own entry point out of the same leaf modules
8
+ * the production entry composes, then tests THAT private copy. The copy can be wired
9
+ * differently from production and stay green while the shipped wiring is broken. Run-8
10
+ * fixture: `test/photos.test.ts` imports the real `authRoutes` + `photosRoutes` leaves,
11
+ * mounts them into its OWN app at a DIFFERENT prefix than the production entry, and
12
+ * runs 102/102 green — while the shipped upload path is dead because production mounts
13
+ * the same leaf at the wrong prefix. The verify child, judging "do the tests pass",
14
+ * saw green and counted the photos area verified. The seam the test was supposed to
15
+ * cover is exactly the seam it re-implemented away.
16
+ *
17
+ * This is the VERIFY-SIDE complement of the generation-side wiring probe
18
+ * (wiring-claims.ts, item #5) and shares the load-bearing lesson of
19
+ * substitution-probe / skip-escape / wiring-claims: a deterministic finding that NAMES
20
+ * the suspect file is the reliable lever; a bare prompt rule is weak. rule 3b already
21
+ * tells the child to spot-check that self-authored tests exercise the real artifact,
22
+ * but F4 slips through because these tests DO import the real leaf modules — they just
23
+ * bypass the real ASSEMBLY, which rule 3b's "did it import and call the module" check
24
+ * does not catch. This probe supplies the missing concrete fact.
25
+ *
26
+ * THE SIGNAL is pure import-graph SHAPE, zero stack/framework assumptions (no "app",
27
+ * no "route", no "mount", no language runtime): a test file T is flagged when there is
28
+ * a production file E (the assembly/entry) such that
29
+ * - T does NOT import E (it bypasses the shipped assembly), AND
30
+ * - T and E both import ≥2 of the SAME leaf modules, where each such leaf is
31
+ * imported by E and by NO OTHER production file (E is the leaf's SOLE production
32
+ * composition site).
33
+ * The "E-exclusive leaf" condition is the crisp discriminator that keeps ordinary
34
+ * shared-utility imports out: a test importing an api client + a schema module that
35
+ * every page also imports is NOT re-assembly (those utilities have many production
36
+ * importers); a test importing two route modules that only the server entry composes
37
+ * IS re-assembly. Measured on the run-8 fixture tree: flags exactly the four backend
38
+ * tests that rebuild the server entry's route composition (including the real
39
+ * photos seam bug) and leaves clean the single-leaf direct test, the utility-sharing
40
+ * page test, and the source-grepping test — 0 false positives.
41
+ *
42
+ * Findings are ADVISORY (probe+rule): they mandate the child to exercise the REAL
43
+ * shipped assembly directly before counting the area verified; they never auto-FAIL.
44
+ * A test that re-composes wiring which happens to match production survives once the
45
+ * child drives the real entry; only one whose real assembly is broken gets named.
46
+ */
47
+ import { isTestFile } from './substitution-probe.js';
48
+ /** Code file extensions whose relative imports we resolve. Not a stack assumption —
49
+ * purely which quoted specifiers name a repo file; other languages simply produce
50
+ * no matches and the whole probe degrades to nothing. */
51
+ const CODE_EXT_RE = /\.(?:[cm]?[jt]sx?)$/;
52
+ /**
53
+ * Static import/re-export declarations: `import … from 'x'`, `import 'x'`,
54
+ * `export … from 'x'`. Anchored to line start (after optional whitespace) so an
55
+ * import-shaped STRING inside an assertion (`expect(src).toContain("import x from
56
+ * '../y'")`, a source-grepping test) is NOT mistaken for a real import — that string
57
+ * is indented behind `expect(`, never at line start.
58
+ */
59
+ const STATIC_IMPORT_RE = /^[ \t]*(?:import|export)\s+(?:[^'"\n]*\sfrom\s+)?['"]([^'"]+)['"]/gm;
60
+ /** Dynamic `import('x')` / `require('x')` calls (the call-paren form is unlikely to
61
+ * appear inside an assertion string, so matching anywhere is safe enough). */
62
+ const CALL_IMPORT_RE = /(?:require|import)\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
63
+ /** Strip a code extension and a trailing `/index` so `./a`, `./a.ts`, and
64
+ * `./a/index.ts` all collapse to the same module id. */
65
+ function stripToModuleId(p) {
66
+ return p.replace(CODE_EXT_RE, '').replace(/\/index$/, '');
67
+ }
68
+ /** Normalise a POSIX-style relative path (resolve `.`/`..` segments) without touching
69
+ * the filesystem — the analysis is pure text shape. */
70
+ function normalisePosix(p) {
71
+ const segments = [];
72
+ for (const seg of p.split('/')) {
73
+ if (seg === '' || seg === '.')
74
+ continue;
75
+ if (seg === '..')
76
+ segments.pop();
77
+ else
78
+ segments.push(seg);
79
+ }
80
+ return segments.join('/');
81
+ }
82
+ /** The importing file's OWN module id (path minus extension / `/index`). */
83
+ export function moduleIdOf(filePath) {
84
+ return stripToModuleId(filePath);
85
+ }
86
+ /**
87
+ * The set of repo-relative, extensionless module ids that `text` imports via RELATIVE
88
+ * specifiers (a specifier starting with `.`). Bare/external specifiers (`hono`,
89
+ * `bun:sql`, `node:fs`) are ignored — they never name a repo file, so they cannot be
90
+ * a re-composed production leaf. Resolution is pure path arithmetic against the
91
+ * importer's directory; the filesystem is never touched.
92
+ */
93
+ export function relativeImports(filePath, text) {
94
+ const dir = filePath.includes('/') ? filePath.slice(0, filePath.lastIndexOf('/')) : '';
95
+ const ids = new Set();
96
+ for (const re of [STATIC_IMPORT_RE, CALL_IMPORT_RE]) {
97
+ re.lastIndex = 0;
98
+ for (let m = re.exec(text); m !== null; m = re.exec(text)) {
99
+ const spec = m[1];
100
+ if (!spec.startsWith('.'))
101
+ continue;
102
+ ids.add(stripToModuleId(normalisePosix(`${dir}/${spec}`)));
103
+ }
104
+ }
105
+ return [...ids];
106
+ }
107
+ /**
108
+ * Find test files that rebuild a production assembly. `changedTestFiles` are the
109
+ * task's own authored/changed test files (path + text); `productionFiles` are the
110
+ * repo's non-test source files (path + text) used to build the import graph and the
111
+ * per-leaf production in-degree. Returns one finding per re-assembling test, sorted
112
+ * for determinism. Empty when no test re-composes an E-exclusive leaf set.
113
+ */
114
+ export function findTestRebuiltAssemblies(changedTestFiles, productionFiles) {
115
+ // Only genuine production (non-test) files can be the bypassed assembly.
116
+ const prod = productionFiles.filter(f => !isTestFile(f.path));
117
+ const prodImports = new Map();
118
+ const inDegree = new Map();
119
+ for (const f of prod) {
120
+ const imps = relativeImports(f.path, f.text);
121
+ prodImports.set(f.path, new Set(imps));
122
+ for (const m of imps)
123
+ inDegree.set(m, (inDegree.get(m) ?? 0) + 1);
124
+ }
125
+ const findings = [];
126
+ for (const t of [...changedTestFiles].sort((a, b) => a.path.localeCompare(b.path))) {
127
+ if (!isTestFile(t.path))
128
+ continue;
129
+ const tImports = new Set(relativeImports(t.path, t.text));
130
+ if (tImports.size < 2)
131
+ continue;
132
+ let best = null;
133
+ for (const e of [...prod].sort((a, b) => a.path.localeCompare(b.path))) {
134
+ if (e.path === t.path)
135
+ continue;
136
+ // The test imports the real assembly → it is exercising the shipped wiring,
137
+ // not a copy. Good citizen, never flagged.
138
+ if (tImports.has(moduleIdOf(e.path)))
139
+ continue;
140
+ const eImports = prodImports.get(e.path);
141
+ // Leaves E is the SOLE production composer of, that this test re-imports.
142
+ const leaves = [...tImports].filter(m => eImports.has(m) && inDegree.get(m) === 1);
143
+ if (leaves.length >= 2 && (best === null || leaves.length > best.leaves.length)) {
144
+ best = { testFile: t.path, assemblyFile: e.path, leaves: leaves.sort() };
145
+ }
146
+ }
147
+ if (best)
148
+ findings.push(best);
149
+ }
150
+ return findings;
151
+ }
152
+ /**
153
+ * Render findings as verify-child prompt lines (probe+rule pattern — the concrete
154
+ * finding that makes the rule fire reliably). One line per re-assembling test naming
155
+ * the test, the shipped assembly it bypasses, and the re-composed leaves. Empty
156
+ * findings → empty array (caller emits no block).
157
+ */
158
+ export function testAssemblyVerifyFindings(findings) {
159
+ return findings.map(f => `${f.testFile} imports and re-composes ${f.leaves.length} leaf module(s) `
160
+ + `(${f.leaves.join(', ')}) that ${f.assemblyFile} is the ONLY production file to `
161
+ + `compose, yet it never imports ${f.assemblyFile} — it builds its OWN assembly of `
162
+ + `those leaves instead of exercising the shipped one`);
163
+ }
@@ -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, testAssemblyFindings?: string[], probeGamingFindings?: 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 {
@@ -101,6 +114,22 @@ export interface VerificationDeps {
101
114
  * no-waiver rule (4b). Advisory, never auto-FAIL — real prohibitions can be
102
115
  * conditional prose. ABSENT or empty → no prohibition block. */
103
116
  prohibitionProbe?: () => Promise<string[]>;
117
+ /**
118
+ * DETERMINISTIC test-assembly probe (see test-assembly.ts): authored test files
119
+ * that rebuild production WIRING — importing the leaf modules the shipped entry
120
+ * composes and assembling their own copy instead of the real assembly — become
121
+ * prompt findings under rule 3f (F4 test-the-copy, 3rd recurrence). Pure import-
122
+ * graph shape; the child then drives the real assembly before trusting the copy.
123
+ * ABSENT or empty → no test-assembly block. */
124
+ testAssemblyProbe?: () => Promise<string[]>;
125
+ /**
126
+ * DETERMINISTIC probe-gaming probe (see probe-gaming.ts, run-8 F6): added lines
127
+ * in the task's diff whose stated purpose is to make a CHECK pass instead of
128
+ * meeting the requirement it stands for ("return 401 so the verification test
129
+ * passes"). Injected as findings under rule 4c so the child confirms the
130
+ * underlying requirement is genuinely met rather than trusting the green check.
131
+ * Pure diff-text analysis; ABSENT or empty → no probe block. */
132
+ probeGamingProbe?: () => Promise<string[]>;
104
133
  /**
105
134
  * Result of the git-state guard for the MOST RECENT runChild call (see
106
135
  * git-state-guard.ts): did the child mutate repo state (stash/checkout/file
@@ -124,6 +153,14 @@ export interface VerificationDeps {
124
153
  read: () => Promise<string>;
125
154
  append: (notes: string[]) => Promise<void>;
126
155
  };
156
+ /**
157
+ * Per-run cross-slice contract registry (see contracts.ts): `read` supplies the
158
+ * verbatim interface facts the SOURCE design pins that more than one slice
159
+ * touches, injected so the verify child checks THIS slice's boundary against
160
+ * them (F3 seam bugs are locally right but globally wrong). ABSENT/empty → no
161
+ * block (single `/task` runs, or a design pinning no shared boundary), unchanged.
162
+ */
163
+ contracts?: () => Promise<string>;
127
164
  }
128
165
  /**
129
166
  * Run the verification pass for one task. A missing spec is a pass. Otherwise run