@mjasnikovs/pi-task 0.18.14 → 0.18.16

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.
Files changed (55) hide show
  1. package/README.md +2 -2
  2. package/dist/task/accept-debt.d.ts +28 -1
  3. package/dist/task/accept-debt.js +61 -3
  4. package/dist/task/auto-io.d.ts +4 -2
  5. package/dist/task/auto-io.js +6 -3
  6. package/dist/task/auto-orchestrator.d.ts +1 -0
  7. package/dist/task/auto-orchestrator.js +135 -19
  8. package/dist/task/auto-prompts.d.ts +5 -5
  9. package/dist/task/auto-prompts.js +9 -2
  10. package/dist/task/contracts.d.ts +8 -0
  11. package/dist/task/contracts.js +4 -2
  12. package/dist/task/decompose-fidelity.d.ts +47 -0
  13. package/dist/task/decompose-fidelity.js +132 -0
  14. package/dist/task/final-gate-fix.d.ts +22 -3
  15. package/dist/task/final-gate-fix.js +72 -7
  16. package/dist/task/final-gate.d.ts +48 -1
  17. package/dist/task/final-gate.js +182 -34
  18. package/dist/task/gate-deps.d.ts +7 -0
  19. package/dist/task/gate-deps.js +37 -1
  20. package/dist/task/launch-contract.d.ts +36 -1
  21. package/dist/task/launch-contract.js +80 -2
  22. package/dist/task/phases.d.ts +13 -1
  23. package/dist/task/phases.js +50 -11
  24. package/dist/task/prompts.js +2 -0
  25. package/dist/task/render-check.d.ts +32 -0
  26. package/dist/task/render-check.js +186 -0
  27. package/dist/task/requirements.d.ts +88 -0
  28. package/dist/task/requirements.js +331 -0
  29. package/dist/task/verify-reconcile.d.ts +36 -0
  30. package/dist/task/verify-reconcile.js +203 -0
  31. package/dist/task/write-guard.d.ts +52 -0
  32. package/dist/task/write-guard.js +112 -0
  33. package/package.json +1 -1
  34. package/dist/task/_ab.d.ts +0 -1
  35. package/dist/task/_ab.js +0 -68
  36. package/dist/task/task-file.d.ts +0 -14
  37. package/dist/task/task-file.js +0 -15
  38. package/dist/think-test/cli.d.ts +0 -1
  39. package/dist/think-test/cli.js +0 -98
  40. package/dist/think-test/client.d.ts +0 -26
  41. package/dist/think-test/client.js +0 -37
  42. package/dist/think-test/compressor.d.ts +0 -5
  43. package/dist/think-test/compressor.js +0 -25
  44. package/dist/think-test/judge.d.ts +0 -4
  45. package/dist/think-test/judge.js +0 -11
  46. package/dist/think-test/score.d.ts +0 -8
  47. package/dist/think-test/score.js +0 -22
  48. package/dist/think-test/serialize.d.ts +0 -19
  49. package/dist/think-test/serialize.js +0 -41
  50. package/dist/think-test/transcript.d.ts +0 -7
  51. package/dist/think-test/transcript.js +0 -41
  52. package/dist/think-test/transform.d.ts +0 -6
  53. package/dist/think-test/transform.js +0 -24
  54. package/dist/think-test/types.d.ts +0 -45
  55. package/dist/think-test/types.js +0 -1
@@ -42,8 +42,10 @@ import { spawn, spawnSync } from 'node:child_process';
42
42
  import { existsSync, readFileSync } from 'node:fs';
43
43
  import * as path from 'node:path';
44
44
  import { runRepoHealthCheck, discoverHealthCommands } from './repo-health-check.js';
45
- import { readAcceptDebts, recheckAcceptDebts, writeAcceptDebts, buildAcceptDebtNote } from './accept-debt.js';
46
- import { readDeclaredScripts, missingDeclaredScripts } from './launch-contract.js';
45
+ import { readAcceptDebts, recheckAcceptDebts, writeAcceptDebts, buildAcceptDebtNote, annotateDebtConflicts } from './accept-debt.js';
46
+ import { readDeclaredScripts, missingDeclaredScripts, runnableDeclaredScripts } from './launch-contract.js';
47
+ import { readEnvNotes, parseEnvNotes, isExcuseNote } from './env-notes.js';
48
+ import { runRenderCheck } from './render-check.js';
47
49
  function packageScripts(cwd) {
48
50
  try {
49
51
  const j = JSON.parse(readFileSync(path.join(cwd, 'package.json'), 'utf8'));
@@ -79,7 +81,9 @@ export function discoverIntegrationCommands(cwd) {
79
81
  // order, then `build`. Env-gap SKIP still applies per command (a suite whose
80
82
  // browser/runtime is absent skips, it does not fail — see runGateCommand).
81
83
  const testNames = Object.keys(s).filter(n => n === 'test' || /^test[:_-]/.test(n));
82
- testNames.sort((a, b) => (a === 'test' ? -1 : b === 'test' ? 1 : 0));
84
+ testNames.sort((a, b) => a === 'test' ? -1
85
+ : b === 'test' ? 1
86
+ : 0);
83
87
  for (const name of testNames)
84
88
  cmds.push(['bun', ['run', name]]);
85
89
  if (s.build)
@@ -218,32 +222,46 @@ export function detectsServedApp(cwd, planText) {
218
222
  }
219
223
  return planText !== undefined && SERVE_TEXT_RE.test(planText);
220
224
  }
221
- /** Pids currently owning a LISTENing TCP socket (best-effort; ss first, then lsof).
225
+ /** Listening TCP sockets as {pid, port} pairs (best-effort; ss first, then lsof).
222
226
  * Empty on any failure — the caller then cannot attribute a listener to our group
223
227
  * and the served-app check degrades to survival (never a false FAIL). */
224
- function listeningSocketPids() {
225
- const pids = new Set();
228
+ function listeningSockets() {
229
+ const out = [];
226
230
  try {
227
231
  const t = spawnSync('ss', ['-tlnpH'], { encoding: 'utf8', timeout: 4000 });
228
232
  if (!t.error && t.stdout) {
229
- for (const m of t.stdout.matchAll(/pid=(\d+)/g))
230
- pids.add(Number(m[1]));
233
+ for (const line of t.stdout.split('\n')) {
234
+ const pm = /pid=(\d+)/.exec(line);
235
+ if (!pm)
236
+ continue;
237
+ // Column 4 (0-based 3) is the local address; the port is its last
238
+ // `:`-suffixed number ("0.0.0.0:3000", "[::]:3000").
239
+ const local = line.trim().split(/\s+/)[3] ?? '';
240
+ const portm = /:(\d+)$/.exec(local);
241
+ if (!portm)
242
+ continue;
243
+ out.push({ pid: Number(pm[1]), port: Number(portm[1]) });
244
+ }
231
245
  }
232
246
  }
233
247
  catch {
234
248
  // ss missing — try lsof
235
249
  }
236
- if (pids.size === 0) {
250
+ if (out.length === 0) {
237
251
  try {
238
- const t = spawnSync('lsof', ['-iTCP', '-sTCP:LISTEN', '-t', '-n', '-P'], {
252
+ const t = spawnSync('lsof', ['-iTCP', '-sTCP:LISTEN', '-n', '-P'], {
239
253
  encoding: 'utf8',
240
254
  timeout: 4000
241
255
  });
242
256
  if (!t.error && t.stdout) {
243
- for (const line of t.stdout.split('\n')) {
244
- const n = Number(line.trim());
245
- if (Number.isInteger(n) && n > 0)
246
- pids.add(n);
257
+ for (const line of t.stdout.split('\n').slice(1)) {
258
+ const cols = line.trim().split(/\s+/);
259
+ const pid = Number(cols[1]);
260
+ const name = cols.find(c => /:\d+$/.test(c)) ?? '';
261
+ const portm = /:(\d+)$/.exec(name);
262
+ if (Number.isInteger(pid) && pid > 0 && portm) {
263
+ out.push({ pid, port: Number(portm[1]) });
264
+ }
247
265
  }
248
266
  }
249
267
  }
@@ -251,7 +269,7 @@ function listeningSocketPids() {
251
269
  // neither tool available
252
270
  }
253
271
  }
254
- return [...pids];
272
+ return out;
255
273
  }
256
274
  /** Process-group id of `pid`, or null if it cannot be read. */
257
275
  function pgidOf(pid) {
@@ -270,12 +288,21 @@ function pgidOf(pid) {
270
288
  /** Default listener probe: any LISTENing socket owned by a pid in process group
271
289
  * `pgid` (the detached boot child IS its own group leader, so pgid === child.pid). */
272
290
  function defaultGroupHasListener(pgid) {
273
- for (const pid of listeningSocketPids()) {
291
+ for (const { pid } of listeningSockets()) {
274
292
  if (pgidOf(pid) === pgid)
275
293
  return true;
276
294
  }
277
295
  return false;
278
296
  }
297
+ /** Default port lookup for the render check: the LOWEST port among the group's
298
+ * listeners (a dev toolchain may open an HMR socket too; the app's own server
299
+ * conventionally sits on the lower, configured port). Null when undeterminable. */
300
+ function defaultGroupListeningPort(pgid) {
301
+ const ports = listeningSockets()
302
+ .filter(({ pid }) => pgidOf(pid) === pgid)
303
+ .map(({ port }) => port);
304
+ return ports.length > 0 ? Math.min(...ports) : null;
305
+ }
279
306
  /** Default port-holder lookup: `lsof` first, then `ss`/`fuser`. Returns null on any
280
307
  * failure (the diagnosis then omits the pid — never blocks). */
281
308
  function defaultFindPortHolder(port) {
@@ -389,21 +416,40 @@ export function runBootCheck(cwd, [bin, args], graceMs = 10_000, opts = {}) {
389
416
  // group already gone
390
417
  }
391
418
  };
392
- const passAndKill = () => {
393
- settle({ outcome: 'pass' });
419
+ const passAndKill = (renderNote) => {
420
+ settle(renderNote ? { outcome: 'pass', renderNote } : { outcome: 'pass' });
421
+ killGroup('SIGTERM');
422
+ setTimeout(() => killGroup('SIGKILL'), 2_000).unref();
423
+ };
424
+ const failAndKill = (detail) => {
425
+ settle({ outcome: 'fail', detail });
394
426
  killGroup('SIGTERM');
395
427
  setTimeout(() => killGroup('SIGKILL'), 2_000).unref();
396
428
  };
397
429
  // Served apps only: poll for a listening socket owned by our process group.
398
- // As soon as one appears the boot has demonstrably served → PASS early.
430
+ // As soon as one appears the boot has demonstrably served → run the render
431
+ // check against the LIVE listener (mx5 runs 8/11: a listener that serves a
432
+ // permanently blank page passed every curl-shaped check), then PASS/FAIL.
433
+ // The probe is spawnSync, so the interval cannot re-enter mid-check.
399
434
  const poll = expectServer ?
400
435
  setInterval(() => {
401
436
  if (settled || !child.pid)
402
437
  return;
403
- if (groupHasListener(child.pid)) {
404
- listenerSeen = true;
405
- passAndKill();
438
+ if (!groupHasListener(child.pid))
439
+ return;
440
+ listenerSeen = true;
441
+ const probe = opts.deps?.renderProbe;
442
+ if (!probe)
443
+ return passAndKill();
444
+ const port = (opts.deps?.groupListeningPort ?? defaultGroupListeningPort)(child.pid);
445
+ if (port === null) {
446
+ return passAndKill('render check UNOBSERVED: a listener was seen but its port could not be determined');
447
+ }
448
+ const rr = probe(`http://127.0.0.1:${port}/`);
449
+ if (rr.outcome === 'fail') {
450
+ return failAndKill(`listens on :${port} but ${rr.detail}`);
406
451
  }
452
+ passAndKill(rr.outcome === 'skip' ? `render check UNOBSERVED: ${rr.note}` : undefined);
407
453
  }, 500)
408
454
  : null;
409
455
  const timer = setTimeout(() => {
@@ -483,14 +529,23 @@ function outputTail(stdout, stderr, limit = 400) {
483
529
  * These exit non-zero (not 127), so they need output-shape recognition to skip.
484
530
  */
485
531
  const ENV_GAP_OUTPUT_RE = /Executable doesn't exist|playwright install|browserType\.\w+: Executable|(?:wasn't|weren't) installed|Host system is missing dependencies|No usable sandbox|Cypress verification|Cypress executable (?:not found|was not found)|browser(?:s)? (?:is|are)? ?not installed/i;
532
+ /**
533
+ * A non-zero exit whose output shows the EXTERNAL INFRASTRUCTURE a launch script
534
+ * talks to is absent HERE — a database/daemon that is not running or not
535
+ * installed — rather than a fault in the script itself. Applied ONLY to
536
+ * launch-contract scripts (a migrate/seed against no DB is an environment gap on
537
+ * this box; the same wording in a `test` run is a real failure the suite must own).
538
+ */
539
+ export const INFRA_GAP_OUTPUT_RE = /ECONNREFUSED|connection refused|ENOTFOUND|EAI_AGAIN|is the server running|could not connect|cannot connect to the docker daemon|connect: connection|no such host/i;
486
540
  /**
487
541
  * Run one gate command with the env-gap contract: tool missing, timeout, or
488
542
  * command-not-found inside the script chain (127) → environment gap, not a code
489
543
  * fault → skipped (same contract as repo-health). Also skips a non-zero exit whose
490
- * output shows a missing browser/runtime (ENV_GAP_OUTPUT_RE). Only a command that
491
- * actually ran and exited non-zero for a real reason fails.
544
+ * output shows a missing browser/runtime (ENV_GAP_OUTPUT_RE) — or, when the caller
545
+ * passes `extraGapRe` (launch scripts), missing external infrastructure. Only a
546
+ * command that actually ran and exited non-zero for a real reason fails.
492
547
  */
493
- function runGateCommand(cwd, [bin, args], timeoutMs) {
548
+ function runGateCommand(cwd, [bin, args], timeoutMs, extraGapRe) {
494
549
  // env passed explicitly: bun's spawnSync resolves the binary against a
495
550
  // startup snapshot of the environment, not the live process.env.
496
551
  const r = spawnSync(bin, args, {
@@ -502,7 +557,10 @@ function runGateCommand(cwd, [bin, args], timeoutMs) {
502
557
  if (r.error || r.status === null || r.status === 127)
503
558
  return { outcome: 'skip' };
504
559
  if (r.status !== 0) {
505
- if (ENV_GAP_OUTPUT_RE.test(`${r.stdout ?? ''}\n${r.stderr ?? ''}`))
560
+ const output = `${r.stdout ?? ''}\n${r.stderr ?? ''}`;
561
+ if (ENV_GAP_OUTPUT_RE.test(output))
562
+ return { outcome: 'skip' };
563
+ if (extraGapRe?.test(output))
506
564
  return { outcome: 'skip' };
507
565
  return { outcome: 'fail', status: r.status, tail: outputTail(r.stdout ?? '', r.stderr ?? '') };
508
566
  }
@@ -528,6 +586,27 @@ async function recoverOrphanPort(cwd, boot, first, bootGraceMs, deps, expectServ
528
586
  await new Promise(r => setTimeout(r, 1_500));
529
587
  return runBootCheck(cwd, boot, bootGraceMs, { expectServer, deps });
530
588
  }
589
+ /**
590
+ * The task whose commit INTRODUCED `rel` (oldest `--diff-filter=A` commit whose
591
+ * subject carries the pi-task `(TASK_nnnn)` suffix — both the task snapshot and the
592
+ * ENFORCE commit shapes match). Null when the file predates the run, was never
593
+ * committed, git is unavailable, or the adding commit is not a task commit — every
594
+ * unknown degrades to "no conflict claim".
595
+ */
596
+ export function taskThatIntroduced(cwd, rel) {
597
+ const r = spawnSync('git', ['log', '--diff-filter=A', '--format=%s', '--', rel], {
598
+ cwd,
599
+ encoding: 'utf8'
600
+ });
601
+ if (r.error || r.status !== 0 || !r.stdout)
602
+ return null;
603
+ const subjects = r.stdout.trim().split('\n').filter(Boolean);
604
+ // Newest-first output; the LAST line is the original introduction (a
605
+ // delete-and-re-add later in history must not reattribute the file).
606
+ const first = subjects[subjects.length - 1] ?? '';
607
+ const m = /\((TASK_\d+)\)\s*$/.exec(first);
608
+ return m ? m[1] : null;
609
+ }
531
610
  /**
532
611
  * Run the final gate: static analysis first, then the lockfile consistency
533
612
  * checks, then the discovered integration commands, then one boot exercise of
@@ -544,15 +623,23 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
544
623
  // run may not complete silently carrying an accepted defect. FP-safe by
545
624
  // construction (see accept-debt.ts). Best-effort: a ledger read/write failure
546
625
  // must never break the gate.
547
- const { open: openDebts, resolved } = recheckAcceptDebts(await readAcceptDebts(cwd), {
626
+ const { open: openRaw, resolved } = recheckAcceptDebts(await readAcceptDebts(cwd), {
548
627
  staticOk: stat.ok
549
628
  });
550
629
  if (resolved.length > 0)
551
- await writeAcceptDebts(cwd, openDebts);
630
+ await writeAcceptDebts(cwd, openRaw);
631
+ // Conflicting-claim annotation (mx5 run 11): an existence-as-failure debt whose
632
+ // named file is another task's committed deliverable is a plan defect — surface
633
+ // the contradiction with the debt so nobody (human or child) treats the claim as
634
+ // a deletion instruction. Pure git-history lookup; degrades to no annotation.
635
+ const openDebts = annotateDebtConflicts(openRaw, p => taskThatIntroduced(cwd, p));
552
636
  const debtNote = buildAcceptDebtNote(openDebts);
637
+ // The debt note rides in its OWN field: `reason` stays the mechanical failure
638
+ // because it seeds the autofix child's prompt (see FinalGateOutcome.reason —
639
+ // run 11's fix child executed a recorded claim as an instruction).
553
640
  const withDebts = (o) => ({
554
641
  ...o,
555
- reason: `${o.reason}${debtNote}`,
642
+ ...(debtNote ? { debtNote } : {}),
556
643
  openDebts
557
644
  });
558
645
  if (!stat.ok)
@@ -596,12 +683,67 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
596
683
  ran.push(label);
597
684
  }
598
685
  }
686
+ // EXECUTE the launch contract (mx5 run 11): every declared script that is
687
+ // neither boot-class (the boot check below owns those) nor already covered by
688
+ // the integration commands above RUNS as a one-shot, in declared order —
689
+ // existence is not launchability (`migrate`/`seed` shipped as first-call
690
+ // TypeErrors while the gate checked only that they exist). The env-gap
691
+ // contract extends to missing external INFRASTRUCTURE (no DB/daemon on this
692
+ // box → skip, not fail); a skip whose script also carries a standing EXCUSE
693
+ // note (F7) is surfaced as an UNOBSERVED warning — the note may be covering a
694
+ // real defect the gate could not reach here (run 11's "pre-existing .rows
695
+ // bug" note excused the exact scripts that shipped broken).
696
+ const warnings = [];
697
+ if (declared.length > 0) {
698
+ const covered = cmds.flatMap(([bin, args]) => (bin === 'bun' || bin === 'npm') && args[0] === 'run' && args[1] ? [args[1]] : []);
699
+ const skippedLaunch = [];
700
+ for (const name of runnableDeclaredScripts(declared, covered)) {
701
+ const cmd = ['bun', ['run', name]];
702
+ const label = `${cmd[0]} ${cmd[1].join(' ')}`;
703
+ const r = runGateCommand(cwd, cmd, Math.min(timeoutMs, 180_000), INFRA_GAP_OUTPUT_RE);
704
+ if (r.outcome === 'skip') {
705
+ skippedLaunch.push(name);
706
+ continue;
707
+ }
708
+ if (r.outcome === 'fail') {
709
+ return withDebts({
710
+ ok: false,
711
+ reason: `launch script: \`${label}\` exited ${r.status}${r.tail ? ` — ${r.tail}` : ''}`
712
+ });
713
+ }
714
+ ran.push(label);
715
+ }
716
+ if (skippedLaunch.length > 0) {
717
+ const notes = parseEnvNotes(await readEnvNotes(cwd)).filter(n => isExcuseNote(n.fact));
718
+ for (const name of skippedLaunch) {
719
+ const re = new RegExp(`\\b${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i');
720
+ const excuse = notes.find(n => re.test(n.fact));
721
+ if (excuse) {
722
+ warnings.push(`launch script \`${name}\` could not run here (environment gap) and a `
723
+ + `standing excuse note covers it ("${excuse.fact.slice(0, 160)}") — `
724
+ + `UNOBSERVED: verify it by hand before trusting the launch surface`);
725
+ }
726
+ }
727
+ }
728
+ }
599
729
  if (boot) {
600
730
  const label = `${boot[0]} ${boot[1].join(' ')}`;
601
731
  const expectServer = detectsServedApp(cwd, planText);
602
- let b = await runBootCheck(cwd, boot, bootGraceMs, { expectServer, deps: bootDeps });
732
+ // Render check (mx5 runs 8/11): for a served app, load the live page in a
733
+ // headless browser and judge the RENDERED DOM — curl can't run JS, so a
734
+ // blank-mount app passed every prior "renders" check. Default to the real
735
+ // probe; tests inject their own. runRenderCheck env-gap-SKIPs when no
736
+ // browser exists, so a box without one never gets a false FAIL.
737
+ const bootDepsWithRender = {
738
+ ...bootDeps,
739
+ renderProbe: bootDeps.renderProbe ?? runRenderCheck
740
+ };
741
+ let b = await runBootCheck(cwd, boot, bootGraceMs, {
742
+ expectServer,
743
+ deps: bootDepsWithRender
744
+ });
603
745
  if (b.outcome === 'orphan-port') {
604
- b = await recoverOrphanPort(cwd, boot, b, bootGraceMs, bootDeps, expectServer);
746
+ b = await recoverOrphanPort(cwd, boot, b, bootGraceMs, bootDepsWithRender, expectServer);
605
747
  }
606
748
  if (b.outcome === 'fail') {
607
749
  return withDebts({ ok: false, reason: `boot check: \`${label}\` ${b.detail}` });
@@ -618,13 +760,19 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
618
760
  reason: `boot check: \`${label}\` could not bind: orphaned process / port already in use${who} (harness condition, not an app fault)`
619
761
  });
620
762
  }
621
- if (b.outcome === 'pass')
763
+ if (b.outcome === 'pass') {
622
764
  ran.push(label);
765
+ // A listener that served, but whose page could not be OBSERVED to render
766
+ // (no browser, undeterminable port) → UNOBSERVED warning, not a silent pass.
767
+ if (b.renderNote)
768
+ warnings.push(b.renderNote);
769
+ }
623
770
  }
771
+ const warningNote = warnings.length > 0 ? ` — WARNING: ${warnings.join('; WARNING: ')}` : '';
624
772
  return withDebts({
625
773
  ok: true,
626
- reason: ran.length > 0 ?
774
+ reason: (ran.length > 0 ?
627
775
  `statics + ${ran.map(c => `\`${c}\``).join(', ')} passed`
628
- : 'statics passed (integration commands not runnable here)'
776
+ : 'statics passed (integration commands not runnable here)') + warningNote
629
777
  });
630
778
  }
@@ -3,6 +3,7 @@ import type { GateDeps } from './task-gates.js';
3
3
  import { type FinalFixResult } from './final-gate-fix.js';
4
4
  import { type AddedLine } from './probe-gaming.js';
5
5
  import { type ChangedFile } from './substitution-probe.js';
6
+ import { type TreeChangeSummary } from './write-guard.js';
6
7
  /** A function that re-runs a task's implementation turn (AUTOFIX). Injected by the
7
8
  * command so this module stays free of the orchestrators (avoids an import cycle). */
8
9
  export type RunTaskFn = GateDeps['runTask'];
@@ -33,6 +34,12 @@ export declare function collectChangedFiles(cwd: string, signal?: AbortSignal):
33
34
  * never a blocker. The `.pi-tasks/` bookkeeping is excluded from every git command.
34
35
  */
35
36
  export declare function collectAddedLines(cwd: string, signal?: AbortSignal): Promise<AddedLine[]>;
37
+ /**
38
+ * The working tree's current changes as a summary (write-guard shape): what a
39
+ * write-capable gate child changed, given the tree was clean when it started.
40
+ * Failures degrade to an empty summary — the guard then has nothing to reject.
41
+ */
42
+ export declare function collectTreeChanges(cwd: string, signal?: AbortSignal): Promise<TreeChangeSummary>;
36
43
  /**
37
44
  * Build the gate deps for one command run. `runTask` is the orchestrator's
38
45
  * implementation re-runner, injected by the caller. The returned object also drives
@@ -30,6 +30,7 @@ import { extractProhibitions, findProhibitionViolations } from './prohibition-pr
30
30
  import { frozenPathsFromSpec, revertFrozenPaths } from './frozen-path-guard.js';
31
31
  import { findProbeGaming, parseAddedLines } from './probe-gaming.js';
32
32
  import { findSubstitutionSuspects, isTestFile } from './substitution-probe.js';
33
+ import { parseTreeChanges, formatTreeChanges } from './write-guard.js';
33
34
  import { findTestRebuiltAssemblies, testAssemblyVerifyFindings } from './test-assembly.js';
34
35
  import { runBoundedLintFix } from './lint-fix.js';
35
36
  import { captureGitState, reconcileGitState } from './git-state-guard.js';
@@ -121,6 +122,15 @@ export async function collectAddedLines(cwd, signal) {
121
122
  }
122
123
  return lines;
123
124
  }
125
+ /**
126
+ * The working tree's current changes as a summary (write-guard shape): what a
127
+ * write-capable gate child changed, given the tree was clean when it started.
128
+ * Failures degrade to an empty summary — the guard then has nothing to reject.
129
+ */
130
+ export async function collectTreeChanges(cwd, signal) {
131
+ const r = await git(cwd, ['status', '--porcelain', '--', '.', EXCLUDE_TASKS_DIR], signal);
132
+ return r.exitCode === 0 ? parseTreeChanges(r.stdout) : { modified: [], deleted: [], added: [] };
133
+ }
124
134
  /** Source extensions whose relative imports the test-assembly probe reasons over. */
125
135
  const SOURCE_EXT_RE = /\.(?:[cm]?[jt]sx?)$/;
126
136
  /** Bounds so the probe stays cheap on large repos (it reads file text). */
@@ -282,6 +292,13 @@ export function buildGateDeps(params) {
282
292
  log(failure ? `=== ${kind} end: FAIL — ${failure} ===` : `=== ${kind} end: ok ===`);
283
293
  if (failure)
284
294
  throw new Error(failure);
295
+ // CAPABILITY-LEVEL diff capture (mx5 run 11): any WRITE-capable
296
+ // child — decided by its tools, not by which phase spawned it —
297
+ // gets its tree changes logged, so a future write-capable kind
298
+ // cannot run invisibly the way the final-fix child's `rm` did.
299
+ if (/\b(?:edit|bash|write)\b/.test(tools)) {
300
+ log(`=== ${kind} tree changes: ${formatTreeChanges(await collectTreeChanges(cwd2, sig))} ===`);
301
+ }
285
302
  return r.text;
286
303
  }
287
304
  finally {
@@ -502,7 +519,26 @@ export function buildGateDeps(params) {
502
519
  // shrink guard's discovery is the gate's own (see final-gate.ts).
503
520
  gate: c => runFinalIntegrationGate(c),
504
521
  discoverLabels: discoverGateCommandLabels,
505
- discard: discardTreeEdits
522
+ discard: discardTreeEdits,
523
+ // WRITE-GUARD STACK (mx5 run 11: this child ran with free bash and
524
+ // none of the run-8 guards — it rm'd a sibling task's verified
525
+ // deliverable and hand-copied a contract to green the lint). Diff
526
+ // capture happens at the makeGateChild seam; deletion guard +
527
+ // probe scan reject-and-discard here. The frozen-path deny
528
+ // (FinalFixDeps.frozenPaths/revertFrozen) is deliberately NOT
529
+ // wired: per-task fences are task-SCOPED ("this task must not
530
+ // touch a sibling's territory"), and the measured union over the
531
+ // run-11 specs would have reverted the one legitimate fix that
532
+ // run needed (migrate.ts, frozen by its own producing task). Wire
533
+ // it only when a run-GLOBAL freeze source exists (a design-level
534
+ // preserve registry), never a per-task union.
535
+ treeChanges: () => collectTreeChanges(cwd2, signal),
536
+ probeScan: () => collectAddedLines(cwd2, signal).then(findProbeGaming),
537
+ log: msg => {
538
+ void fsp
539
+ .appendFile(path.join(tasksDir(cwd2), 'final-gate-debug.log'), `${new Date().toISOString()} ${msg}\n`)
540
+ .catch(() => { });
541
+ }
506
542
  }),
507
543
  recommend: async (recCtx, cwd2, taskTitle, taskId, failReason) => {
508
544
  // Read the same composed spec the verify gate judged against, so the
@@ -11,12 +11,42 @@ export declare function parseScriptLines(text: string): string[];
11
11
  * false-flag on it. Deduplicated, case-insensitive.
12
12
  */
13
13
  export declare function keepGroundedScripts(names: string[], sourceDoc: string): string[];
14
+ /**
15
+ * DETERMINISTIC RECALL (mx5 run 11): enumerate every backticked, script-name-shaped
16
+ * token in a paragraph that mentions the word "script", as extraction CANDIDATES.
17
+ *
18
+ * The run-11 failure this closes: `test:ct` is backticked in the design's §2 tooling
19
+ * paragraph, so the grounding guard would have KEPT it — but the extraction child
20
+ * anchored on §9's one-line summary (`dev`,`build`,`migrate`,`seed`,`test`) and never
21
+ * emitted it. Grounding can only DROP a candidate, never add one, so recall was
22
+ * entirely the model's, over a 20KB doc. This makes recall mechanical: the host
23
+ * enumerates candidates and hands them to the child as an explicit checklist; the
24
+ * model's job flips from recall (weak) to per-candidate classification (strong).
25
+ *
26
+ * The paragraph gate (`\bscripts?\b`, word-bounded so "TypeScript"/"JavaScript"
27
+ * don't match) is a grounded-context filter, not a tuned knob: a design declares a
28
+ * script by calling it one. It keeps package-name paragraphs (`hono`, `react`) out
29
+ * of the checklist so a weak model isn't invited to keep junk the grounding guard
30
+ * would then bless (every package name is backticked somewhere). A design with no
31
+ * such paragraph yields no candidates and the prompt is unchanged.
32
+ */
33
+ export declare function enumerateScriptCandidates(sourceDoc: string): string[];
14
34
  /** The stored declared-script list ('' when none recorded). */
15
35
  export declare function readLaunchContractRaw(cwd: string): Promise<string>;
16
36
  /** The declared script names recorded for this run (deduped, order preserved). */
17
37
  export declare function readDeclaredScripts(cwd: string): Promise<string[]>;
18
38
  /** Append grounded script names, deduped against what is stored, keeping newest MAX. */
19
39
  export declare function appendDeclaredScripts(cwd: string, names: string[]): Promise<void>;
40
+ /**
41
+ * The declared scripts the final gate must EXECUTE as one-shot commands (mx5 run
42
+ * 11): everything the launch contract declares that is neither boot-class (the
43
+ * boot check exercises those) nor already covered by the gate's integration
44
+ * commands (`covered`, case-insensitive — the test/build-shaped scripts that ran).
45
+ * Run 11 shipped `migrate` and `seed` broken (`.rows` on a Bun sql array —
46
+ * TypeError on first call) while the gate checked only that the scripts EXIST;
47
+ * existence is not launchability.
48
+ */
49
+ export declare function runnableDeclaredScripts(declared: string[], covered: string[]): string[];
20
50
  /**
21
51
  * Declared scripts the manifest does NOT expose (case-insensitive). Empty when every
22
52
  * declared script is present, or when nothing was declared (no check). This is the
@@ -27,5 +57,10 @@ export declare function missingDeclaredScripts(declared: string[], manifestScrip
27
57
  * The plan-time extraction prompt: the design in hand, emit the scripts it declares.
28
58
  * Runs with --no-tools (pure extraction). Every emitted name is re-grounded HOST-SIDE
29
59
  * (keepGroundedScripts), so a hallucinated script cannot reach the diff.
60
+ *
61
+ * `candidates` is enumerateScriptCandidates' mechanical checklist. It exists so the
62
+ * model cannot MISS a declared script buried far from the design's summary list (the
63
+ * run-11 `test:ct` hole); the model still classifies each candidate against the
64
+ * design, and the host grounding still applies. Empty ⇒ the prompt is unchanged.
30
65
  */
31
- export declare const LAUNCH_EXTRACT_PROMPT: (feature: string) => string;
66
+ export declare const LAUNCH_EXTRACT_PROMPT: (feature: string, candidates?: string[]) => string;
@@ -42,7 +42,10 @@ export function parseScriptLines(text) {
42
42
  const out = [];
43
43
  for (const m of text.matchAll(/^[ \t]*SCRIPT:[ \t]*(.+)$/gim)) {
44
44
  // Take the first whitespace/comma-delimited token, stripping backticks/quotes.
45
- const raw = m[1].trim().split(/[\s,]+/)[0]?.replace(/[`'"]/g, '') ?? '';
45
+ const raw = m[1]
46
+ .trim()
47
+ .split(/[\s,]+/)[0]
48
+ ?.replace(/[`'"]/g, '') ?? '';
46
49
  if (SCRIPT_NAME_RE.test(raw))
47
50
  out.push(raw);
48
51
  }
@@ -69,6 +72,44 @@ export function keepGroundedScripts(names, sourceDoc) {
69
72
  }
70
73
  return kept;
71
74
  }
75
+ /**
76
+ * DETERMINISTIC RECALL (mx5 run 11): enumerate every backticked, script-name-shaped
77
+ * token in a paragraph that mentions the word "script", as extraction CANDIDATES.
78
+ *
79
+ * The run-11 failure this closes: `test:ct` is backticked in the design's §2 tooling
80
+ * paragraph, so the grounding guard would have KEPT it — but the extraction child
81
+ * anchored on §9's one-line summary (`dev`,`build`,`migrate`,`seed`,`test`) and never
82
+ * emitted it. Grounding can only DROP a candidate, never add one, so recall was
83
+ * entirely the model's, over a 20KB doc. This makes recall mechanical: the host
84
+ * enumerates candidates and hands them to the child as an explicit checklist; the
85
+ * model's job flips from recall (weak) to per-candidate classification (strong).
86
+ *
87
+ * The paragraph gate (`\bscripts?\b`, word-bounded so "TypeScript"/"JavaScript"
88
+ * don't match) is a grounded-context filter, not a tuned knob: a design declares a
89
+ * script by calling it one. It keeps package-name paragraphs (`hono`, `react`) out
90
+ * of the checklist so a weak model isn't invited to keep junk the grounding guard
91
+ * would then bless (every package name is backticked somewhere). A design with no
92
+ * such paragraph yields no candidates and the prompt is unchanged.
93
+ */
94
+ export function enumerateScriptCandidates(sourceDoc) {
95
+ const out = [];
96
+ const seen = new Set();
97
+ for (const para of sourceDoc.split(/\n[ \t]*\n/)) {
98
+ if (!/\bscripts?\b/i.test(para))
99
+ continue;
100
+ for (const m of para.matchAll(/`([^`\n]+)`/g)) {
101
+ const tok = m[1].trim();
102
+ if (!SCRIPT_NAME_RE.test(tok))
103
+ continue;
104
+ const key = tok.toLowerCase();
105
+ if (seen.has(key))
106
+ continue;
107
+ seen.add(key);
108
+ out.push(tok);
109
+ }
110
+ }
111
+ return out.slice(0, MAX_SCRIPTS);
112
+ }
72
113
  /** The stored declared-script list ('' when none recorded). */
73
114
  export async function readLaunchContractRaw(cwd) {
74
115
  try {
@@ -117,6 +158,26 @@ export async function appendDeclaredScripts(cwd, names) {
117
158
  // best-effort artifact
118
159
  }
119
160
  }
161
+ /**
162
+ * Boot-class script names: long-running serve/watch shapes the gate's BOOT check
163
+ * owns (it needs a listener, a grace window, and a group kill). These are never run
164
+ * as one-shot gate commands — a `dev` server run synchronously would only burn the
165
+ * timeout. Suffixed variants (`dev:client`, `start-prod`) are boot-class too.
166
+ */
167
+ const BOOT_CLASS_RE = /^(?:dev|start|serve|preview|watch)(?:[:_-].*)?$/i;
168
+ /**
169
+ * The declared scripts the final gate must EXECUTE as one-shot commands (mx5 run
170
+ * 11): everything the launch contract declares that is neither boot-class (the
171
+ * boot check exercises those) nor already covered by the gate's integration
172
+ * commands (`covered`, case-insensitive — the test/build-shaped scripts that ran).
173
+ * Run 11 shipped `migrate` and `seed` broken (`.rows` on a Bun sql array —
174
+ * TypeError on first call) while the gate checked only that the scripts EXIST;
175
+ * existence is not launchability.
176
+ */
177
+ export function runnableDeclaredScripts(declared, covered) {
178
+ const have = new Set(covered.map(s => s.toLowerCase()));
179
+ return declared.filter(n => !BOOT_CLASS_RE.test(n) && !have.has(n.toLowerCase()));
180
+ }
120
181
  /**
121
182
  * Declared scripts the manifest does NOT expose (case-insensitive). Empty when every
122
183
  * declared script is present, or when nothing was declared (no check). This is the
@@ -139,8 +200,13 @@ export function missingDeclaredScripts(declared, manifestScripts) {
139
200
  * The plan-time extraction prompt: the design in hand, emit the scripts it declares.
140
201
  * Runs with --no-tools (pure extraction). Every emitted name is re-grounded HOST-SIDE
141
202
  * (keepGroundedScripts), so a hallucinated script cannot reach the diff.
203
+ *
204
+ * `candidates` is enumerateScriptCandidates' mechanical checklist. It exists so the
205
+ * model cannot MISS a declared script buried far from the design's summary list (the
206
+ * run-11 `test:ct` hole); the model still classifies each candidate against the
207
+ * design, and the host grounding still applies. Empty ⇒ the prompt is unchanged.
142
208
  */
143
- export const LAUNCH_EXTRACT_PROMPT = (feature) => [
209
+ export const LAUNCH_EXTRACT_PROMPT = (feature, candidates = []) => [
144
210
  'You are recording the PACKAGE/BUILD SCRIPTS the design below says the finished',
145
211
  'project MUST expose (the `scripts` a package.json / Makefile / task runner must',
146
212
  'declare — e.g. build, test, a migration runner, a seed step, a start/serve command).',
@@ -150,6 +216,18 @@ export const LAUNCH_EXTRACT_PROMPT = (feature) => [
150
216
  'DESIGN (the ONLY source — name only scripts the design itself declares):',
151
217
  feature.trim(),
152
218
  '',
219
+ ...(candidates.length > 0 ?
220
+ [
221
+ 'CANDIDATE TOKENS — found mechanically in the design near the word "script".',
222
+ 'This checklist exists ONLY so you do not MISS a declared script; many of these',
223
+ 'tokens are NOT scripts (config option names, tool names). For EACH candidate,',
224
+ 'decide from the design whether it is a script the finished project must expose,',
225
+ 'and emit it only if so. A declared script MISSING from this checklist must still',
226
+ 'be emitted — the checklist is a floor, not a ceiling.',
227
+ candidates.map(c => ` - ${c}`).join('\n'),
228
+ ''
229
+ ]
230
+ : []),
153
231
  'For each script the design declares by name, emit exactly:',
154
232
  ' SCRIPT: <name>',
155
233
  'one per line, the bare script name only (e.g. `SCRIPT: migrate`). RULES: (1) name',
@@ -59,6 +59,18 @@ export declare function refineExistingFilesBlock(deps: PhaseDeps): Promise<strin
59
59
  * no-op. Best-effort: a read fault yields '' rather than blocking the phase.
60
60
  */
61
61
  export declare function phaseContractsBlock(deps: PhaseDeps): Promise<string>;
62
+ /**
63
+ * The carried-context blocks a GENERATIVE phase (refine, compose) receives: the
64
+ * cross-slice contracts plus the carried cross-cutting requirements (mx5 run 11,
65
+ * goals A/C — `.pi-tasks/requirements.md`, written at plan time). The verbatim
66
+ * requirement quotes travel INTO every task's spec generation, so a mandated
67
+ * methodology ("a test lands in the same change as each new route") reaches the
68
+ * task's GOAL/CONSTRAINTS and its VERIFY — a pointer back to the spec doc
69
+ * recovered the dropped §10 in only 1 of ~6 applicable run-11 tasks; content
70
+ * travels, pointers don't. Both blocks are '' outside their runs, so a bare
71
+ * /task is byte-identical to before.
72
+ */
73
+ export declare function phaseCarriedBlocks(deps: PhaseDeps): Promise<string>;
62
74
  export declare const phaseRefine: (deps: PhaseDeps, raw: string, planContext?: string) => Promise<string>;
63
75
  export declare function phaseVerifyTooling(deps: PhaseDeps, research: string): Promise<string>;
64
76
  export interface PhaseResearchDeps extends ExternalContextDeps {
@@ -105,7 +117,7 @@ export interface PhaseAutoAnswerDeps {
105
117
  export declare function phaseAutoAnswer(deps: PhaseDeps, refined: string, research: string, question: string, autoDeps?: PhaseAutoAnswerDeps): Promise<AutoAnswer>;
106
118
  export declare function phaseGrill(deps: PhaseDeps, ctx: ExtensionCommandContext, widgetState: WidgetState, refined: string, research: string): Promise<string>;
107
119
  export declare function phaseCompose(deps: PhaseDeps, refined: string, research: string, qa: string): Promise<string>;
108
- export declare function phaseCritique(deps: PhaseDeps, spec: string, refined: string, qa: string): Promise<string>;
120
+ export declare function phaseCritique(deps: PhaseDeps, spec: string, refined: string, qa: string, planContext?: string): Promise<string>;
109
121
  export declare function critiqueWithFallback(d: PhaseDeps, p: PhaseContext): Promise<string>;
110
122
  export declare const PHASES: PhaseConfig[];
111
123
  export declare function postCommitPhase(phase: PhaseConfig, deps: PhaseDeps, pc: PhaseContext, out: string): Promise<void>;