@mjasnikovs/pi-task 0.38.10 → 0.38.12

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 (78) hide show
  1. package/README.md +7 -3
  2. package/dist/shared/child-process.d.ts +8 -0
  3. package/dist/shared/command-watchdog.d.ts +1 -1
  4. package/dist/shared/command-watchdog.js +1 -1
  5. package/dist/task/accept-debt.d.ts +47 -0
  6. package/dist/task/accept-debt.js +127 -28
  7. package/dist/task/auto-orchestrator.js +93 -116
  8. package/dist/task/boot-probe.d.ts +298 -0
  9. package/dist/task/boot-probe.js +806 -0
  10. package/dist/task/child-runner.d.ts +56 -25
  11. package/dist/task/child-runner.js +65 -31
  12. package/dist/task/child-status.d.ts +95 -0
  13. package/dist/task/child-status.js +99 -0
  14. package/dist/task/command-run.d.ts +36 -0
  15. package/dist/task/command-run.js +48 -1
  16. package/dist/task/command-watchdog.js +1 -1
  17. package/dist/task/context-usage.d.ts +4 -3
  18. package/dist/task/context-usage.js +4 -3
  19. package/dist/task/contracts.js +18 -35
  20. package/dist/task/deep-render-check.d.ts +47 -0
  21. package/dist/task/deep-render-check.js +110 -65
  22. package/dist/task/env-notes.d.ts +3 -3
  23. package/dist/task/env-notes.js +24 -35
  24. package/dist/task/final-gate-fix.d.ts +1 -1
  25. package/dist/task/final-gate-fix.js +1 -1
  26. package/dist/task/final-gate.d.ts +38 -432
  27. package/dist/task/final-gate.js +105 -1213
  28. package/dist/task/gate-child.d.ts +8 -10
  29. package/dist/task/gate-child.js +15 -19
  30. package/dist/task/gate-deps.d.ts +29 -0
  31. package/dist/task/gate-deps.js +192 -206
  32. package/dist/task/gate-tally.d.ts +189 -0
  33. package/dist/task/gate-tally.js +249 -0
  34. package/dist/task/implementation-turn.d.ts +201 -0
  35. package/dist/task/implementation-turn.js +263 -0
  36. package/dist/task/launch-contract.js +27 -43
  37. package/dist/task/launch-manifest.d.ts +5 -0
  38. package/dist/task/launch-manifest.js +21 -0
  39. package/dist/task/ledger.d.ts +38 -0
  40. package/dist/task/ledger.js +83 -0
  41. package/dist/task/loop-detector.d.ts +14 -8
  42. package/dist/task/loop-detector.js +36 -12
  43. package/dist/task/orchestrator.d.ts +61 -126
  44. package/dist/task/orchestrator.js +70 -297
  45. package/dist/task/phases.d.ts +18 -0
  46. package/dist/task/phases.js +4 -3
  47. package/dist/task/plan-orchestrator.js +34 -33
  48. package/dist/task/requirements.d.ts +1 -1
  49. package/dist/task/requirements.js +50 -66
  50. package/dist/task/root-cause-repair.js +20 -32
  51. package/dist/task/run-bracket.d.ts +75 -0
  52. package/dist/task/run-bracket.js +41 -0
  53. package/dist/task/stall-detector.d.ts +110 -0
  54. package/dist/task/stall-detector.js +159 -0
  55. package/dist/task/task-gates.d.ts +69 -0
  56. package/dist/task/task-gates.js +114 -90
  57. package/dist/task/verify-work.d.ts +53 -67
  58. package/dist/task/verify-work.js +15 -11
  59. package/dist/workers/docs-core.d.ts +0 -4
  60. package/dist/workers/docs-core.js +10 -34
  61. package/dist/workers/docs-project.js +3 -3
  62. package/dist/workers/docs-resolve.d.ts +18 -0
  63. package/dist/workers/docs-resolve.js +39 -0
  64. package/dist/workers/docs-retrieve.d.ts +13 -0
  65. package/dist/workers/docs-retrieve.js +17 -2
  66. package/dist/workers/fetch-core.d.ts +0 -4
  67. package/dist/workers/fetch-core.js +2 -5
  68. package/dist/workers/phantom-imports.d.ts +3 -3
  69. package/dist/workers/phantom-imports.js +16 -29
  70. package/dist/workers/pi-worker-docs.d.ts +49 -0
  71. package/dist/workers/pi-worker-docs.js +33 -9
  72. package/dist/workers/pi-worker-fetch.d.ts +18 -0
  73. package/dist/workers/pi-worker-fetch.js +19 -4
  74. package/dist/workers/single-read-extension.d.ts +1 -1
  75. package/dist/workers/single-read-extension.js +5 -4
  76. package/dist/workers/single-read-guard.d.ts +32 -10
  77. package/dist/workers/single-read-guard.js +67 -16
  78. package/package.json +1 -1
@@ -45,43 +45,25 @@
45
45
  * scripts/boot-skip-verdict-ab.ts two-armed deterministic A/B + invariants
46
46
  * scripts/boot-skip-fp-suite.ts zero-FP arms over every local repo
47
47
  */
48
- import { spawn, spawnSync } from 'node:child_process';
49
48
  import { existsSync, readFileSync } from 'node:fs';
50
- import * as net from 'node:net';
51
49
  import * as path from 'node:path';
52
50
  import { runRepoHealthCheck, discoverHealthCommands } from './repo-health-check.js';
53
- import { readAcceptDebts, recheckAcceptDebts, writeAcceptDebts, buildAcceptDebtNote, annotateDebtConflicts } from './accept-debt.js';
51
+ import { deriveOpenDebts, rerunDebtVerifyCommand } from './accept-debt.js';
54
52
  import { readDeclaredScripts, missingDeclaredScripts, runnableDeclaredScripts } from './launch-contract.js';
55
- import { readLaunchManifest, inertLaunchContractNote } from './launch-manifest.js';
53
+ import { readLaunchManifest, inertLaunchContractNote, packageScripts, makeHasTarget } from './launch-manifest.js';
54
+ import { discoverBootCommand, detectsServedApp, runBootCheck, bootSkipVerdict, nonLaunchScriptReason, rejectedLaunchScript, parseSsListeners, parseNetstatListeners, parseLsofListeners, pickFreePort, preferredDeclaredPort, canEnumerateListeners, recoverOrphanPort, defaultFindPortHolder } from './boot-probe.js';
56
55
  import { readEnvNotes, parseEnvNotes, isExcuseNote } from './env-notes.js';
57
56
  import { runRenderCheck } from './render-check.js';
58
- import { collectProjectEnv, pinnedLocalPort, runDeepRenderCheck } from './deep-render-check.js';
59
- import { resolveRunner, runnerEnv, isCommandNotFound } from './runner-resolve.js';
60
- import { classifyCommandRun, spawnCommand, outputTail, INFRA_GAP_OUTPUT_RE } from './command-run.js';
57
+ import { runDeepRenderCheck } from './deep-render-check.js';
58
+ import { resolveRunner, runnerEnv } from './runner-resolve.js';
59
+ import { classifyCommandRun, spawnCommand, INFRA_GAP_OUTPUT_RE } from './command-run.js';
61
60
  import { findLaunchConfigGap, probeEnv, configGapUnobservedNote } from './launch-config-gap.js';
62
61
  import { taskThatIntroduced } from './task-provenance.js';
63
62
  import { findDanglingArtifacts, danglingGateFailureText } from './artifact-closure.js';
64
63
  import { findMissingEnvDeclarations, envGateFailureText, scanEnvTemplateClosure, inertClosure, trackedFiles } from './env-template-closure.js';
65
64
  import { findMissingServeEntry, serveEntryGateFailureText } from './serve-entry.js';
66
65
  import { makefileRecipe } from './command-shrink.js';
67
- function packageScripts(cwd) {
68
- try {
69
- const j = JSON.parse(readFileSync(path.join(cwd, 'package.json'), 'utf8'));
70
- return j.scripts ?? {};
71
- }
72
- catch {
73
- return {};
74
- }
75
- }
76
- function makeHasTarget(cwd, target) {
77
- try {
78
- const mk = readFileSync(path.join(cwd, 'Makefile'), 'utf8');
79
- return new RegExp(`^${target}:`, 'm').test(mk);
80
- }
81
- catch {
82
- return false;
83
- }
84
- }
66
+ import { GateTally, observabilityGapFailure, unobservedVerdict } from './gate-tally.js';
85
67
  /**
86
68
  * The project's OWN whole-repo integration commands (test, then build — test
87
69
  * first because it is the richer signal and the more common script). First
@@ -198,720 +180,6 @@ export function discoverLockfileChecks(cwd) {
198
180
  }
199
181
  return cmds;
200
182
  }
201
- /** Leading `FOO=bar` env assignments and `sudo`/`exec` wrappers carry no verb. */
202
- function commandTokens(member) {
203
- const t = member.trim().split(/\s+/).filter(Boolean);
204
- while (t.length > 0
205
- && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(t[0]) || /^(?:sudo|exec|env)$/.test(t[0]))) {
206
- t.shift();
207
- }
208
- return t;
209
- }
210
- /** The chain members of a shell script body, in order (`&&`, `||`, `;`, `|`). */
211
- function chainMembers(body) {
212
- return body
213
- .split(/&&|\|\||;|\|/)
214
- .map(s => s.trim())
215
- .filter(s => s.length > 0);
216
- }
217
- /** Container/infra orchestration: `docker compose … up`, `docker-compose … up -d`,
218
- * `podman-compose … up`, `docker run …`. The verb must be a bare token, so a
219
- * filename like `docker-compose.dev.yml` never counts as one. */
220
- function isContainerOrchestration(member) {
221
- const t = commandTokens(member);
222
- if (t.length === 0)
223
- return false;
224
- const bin = path.posix.basename(t[0]);
225
- if (!/^(?:docker|podman|nerdctl)(?:-compose)?$/.test(bin))
226
- return false;
227
- const verbs = new Set(['up', 'start', 'run']);
228
- return t.slice(1).some(tok => verbs.has(tok));
229
- }
230
- const MULTIPLEXER_RE = /^(?:concurrently|npm-run-all|run-p|run-s|turbo)$/;
231
- /** A watcher that recompiles ASSETS and never listens: the tool is a
232
- * bundler/compiler/preprocessor AND it is in watch mode. `bun run --watch x.ts`
233
- * is deliberately NOT here — that re-executes an entrypoint, which may serve. */
234
- const ASSET_TOOL_RE = /(?:^|[\s/@])(?:tailwindcss|postcss|sass|node-sass|less|stylus|esbuild|rollup|webpack|parcel|swc|babel|tsc|tsup|chokidar)(?:$|[\s"'])/;
235
- const WATCH_FLAG_RE = /(?:^|\s)(?:--watch|-w|--watch=[^\s]*)(?:\s|$)/;
236
- /** The quoted commands a multiplexer runs, or its bare script-name arguments
237
- * resolved through the manifest (`run-p dev:css dev:js`). One level only. */
238
- function multiplexerChildren(member, scripts) {
239
- const quoted = [...member.matchAll(/"([^"]+)"|'([^']+)'/g)].map(m => m[1] ?? m[2]);
240
- if (quoted.length > 0)
241
- return quoted;
242
- const t = commandTokens(member)
243
- .slice(1)
244
- .filter(a => !a.startsWith('-'));
245
- return t.flatMap(name => (scripts[name] !== undefined ? [scripts[name]] : []));
246
- }
247
- /** Every member of the chain that could plausibly stay up and serve. Members that
248
- * are one-shot setup (`mkdir`, `sleep`, an `until … done` wait loop) are not
249
- * themselves launches, but they are not disqualifying either — only the two
250
- * shapes below are. */
251
- function isWatcherOnlyMultiplexer(member, scripts) {
252
- const t = commandTokens(member);
253
- if (t.length === 0)
254
- return false;
255
- const bin = path.posix.basename(t[0]);
256
- const runner = /^(?:npx|bunx|pnpm|yarn|npm)$/.test(bin);
257
- const head = runner ?
258
- (t.slice(1).find(a => !a.startsWith('-') && a !== 'exec' && a !== 'dlx' && a !== 'run')
259
- ?? '')
260
- : bin;
261
- if (!MULTIPLEXER_RE.test(path.posix.basename(head)))
262
- return false;
263
- const children = multiplexerChildren(member, scripts);
264
- if (children.length === 0)
265
- return false;
266
- // Every child is an ASSET watcher ⇒ nothing in here ever listens.
267
- return children.every(c => ASSET_TOOL_RE.test(c) && WATCH_FLAG_RE.test(c));
268
- }
269
- /**
270
- * Why this script is NOT a launch of the shipped app, or null when it plausibly
271
- * is one (mx5 run 18, validated).
272
- *
273
- * Run 18's boot command resolved to `bun run dev`, whose body is
274
- * `docker compose -f docker-compose.dev.yml up -d && until docker compose … pg_isready
275
- * … && concurrently "bun run dev:css" "bun run dev:js" "bun run --watch
276
- * src/server/index.ts"`. The gate sandbox has no docker, so the chain died at 127 and
277
- * the boot SKIPPED as an environment gap — while the shipped app had no HTTP listener
278
- * at all. A script whose first act is `docker compose up` cannot distinguish "the app
279
- * is broken" from "this box has no docker", so it is not evidence either way: better
280
- * to discover NO boot command — reported as "nothing to boot" — and let the static
281
- * serve-entry check (serve-entry.ts) carry the signal, than to spend the grace window
282
- * producing an unfalsifiable skip.
283
- *
284
- * CONSERVATIVE AND LEXICAL BY CONSTRUCTION. Only two shapes are rejected, both
285
- * decidable from the script text alone:
286
- * 1. the chain OPENS with container orchestration (docker/podman/nerdctl … up|start|run);
287
- * 2. the whole body is a multiplexer (concurrently/npm-run-all/run-p/run-s/turbo)
288
- * whose every child is an ASSET watcher in watch mode (tailwind/tsc/esbuild/…),
289
- * i.e. nothing in it can ever listen.
290
- * Anything else — `vite`, `next dev`, `node dist/index.js`, `nodemon`, `bun --watch
291
- * src/index.ts`, and any multiplexer with one non-asset child — is accepted
292
- * unchanged. Deciding whether a watcher actually SERVES is not attempted here; that
293
- * is exactly what the static serve-entry check is for.
294
- */
295
- export function nonLaunchScriptReason(body, scripts = {}) {
296
- const members = chainMembers(body);
297
- if (members.length === 0)
298
- return null;
299
- if (isContainerOrchestration(members[0])) {
300
- return 'it opens with container orchestration, which starts infrastructure rather than the app';
301
- }
302
- if (members.every(m => isWatcherOnlyMultiplexer(m, scripts))) {
303
- return 'its only long-running member multiplexes asset watchers, none of which serves';
304
- }
305
- return null;
306
- }
307
- /**
308
- * The project's OWN launch command, if it declares one (package.json `start`,
309
- * else `dev`; Makefile `run`). null means the project has nothing to boot —
310
- * the boot check degrades to nothing-to-run.
311
- *
312
- * A script that is not a LAUNCH at all (nonLaunchScriptReason — mx5 run 18's
313
- * `docker compose up` orchestrator) is rejected here and falls through to the
314
- * next candidate, then to null. Discovering nothing is strictly better than
315
- * discovering something unfalsifiable: an env-gap skip of an orchestration script
316
- * says nothing about the app, and null is reported as "nothing to boot".
317
- */
318
- export function discoverBootCommand(cwd) {
319
- if (existsSync(path.join(cwd, 'package.json'))) {
320
- const s = packageScripts(cwd);
321
- for (const name of ['start', 'dev']) {
322
- if (s[name] && nonLaunchScriptReason(s[name], s) === null)
323
- return ['bun', ['run', name]];
324
- }
325
- return null;
326
- }
327
- if (existsSync(path.join(cwd, 'Makefile')) && makeHasTarget(cwd, 'run')) {
328
- return ['make', ['run']];
329
- }
330
- return null;
331
- }
332
- /**
333
- * The launch script that EXISTS but was rejected as not-a-launch, if any. Without
334
- * this the rejection would trade run 18's unfalsifiable skip for pure silence: no
335
- * boot command means bootSkipVerdict has no label to name, and a project whose test
336
- * suite ran still reports `observed > 0`, so unobservedVerdict stays quiet too. A
337
- * served app whose only declared launch script cannot start it was not observed to
338
- * run, and must say so.
339
- */
340
- export function rejectedLaunchScript(cwd) {
341
- if (!existsSync(path.join(cwd, 'package.json')))
342
- return null;
343
- const s = packageScripts(cwd);
344
- for (const name of ['start', 'dev']) {
345
- if (!s[name])
346
- continue;
347
- const reason = nonLaunchScriptReason(s[name], s);
348
- if (reason === null)
349
- return null; // this one IS a launch — it was chosen
350
- return { name, reason };
351
- }
352
- return null;
353
- }
354
- /** Recognise an "address already in use" bind failure across runtimes (Node
355
- * EADDRINUSE, Bun "Is port N in use?", Go "address already in use", generic). */
356
- function isAddressInUse(text) {
357
- return /EADDRINUSE|address already in use|address in use|port \d+ (?:is |already )?in use/i.test(text);
358
- }
359
- /** Best-effort port number from a bind-failure message, for the diagnosis line. The
360
- * digit run ends on any non-digit (a `(?!\d)` lookahead, NOT `\b`): runtimes often
361
- * print ":3000" flush against the next token with no separating space/newline
362
- * ("…:3000error: script exited"), where a trailing `\b` would never match. */
363
- function extractPort(text) {
364
- const m = /(?:port|:)\s*(\d{2,5})(?!\d)/i.exec(text) ?? /\baddress[^0-9]*(\d{2,5})(?!\d)/i.exec(text);
365
- if (!m)
366
- return null;
367
- const n = Number(m[1]);
368
- return n > 0 && n < 65536 ? n : null;
369
- }
370
- /** Stamped on a PASS the boot check could not actually observe, so the trail says
371
- * so out loud instead of implying the listener requirement was met. */
372
- const UNOBSERVED_LISTENER_NOTE = 'listener check UNOBSERVED: no socket-enumeration tool (ss/netstat/lsof) in this '
373
- + 'environment and the app never answered on the port it was given — passed on the '
374
- + 'survival rule (the process stayed up), NOT on observed serving';
375
- /** Package deps that mean "this project stands up an HTTP server" — the deterministic
376
- * proxy for "the plan/spec promised a served app". Bare framework names plus the
377
- * scoped families whose presence implies a listener at runtime. */
378
- function isServerFrameworkDep(name) {
379
- return (/^(?:hono|express|fastify|koa|polka|restify|next|nuxt|http-server|serve|ws|socket\.io)$/.test(name) || /^@(?:hono|fastify|koa|nestjs|sveltejs|remix-run)\//.test(name));
380
- }
381
- /** Spec/plan phrasings that promise a listening server, for the text signal. */
382
- const SERVE_TEXT_RE = /\b(?:https?\s+server|web\s+server|serves?\b|listen(?:s|ing)?\b|Bun\.serve|app\.listen|createServer|serve\s+(?:static|the)|\/api\/|endpoints?\b)/i;
383
- /**
384
- * Does the finished run stand up a listening HTTP server? Deterministic, from the
385
- * built manifest (a server-framework dependency is the plan's own artifact) OR, when
386
- * available, the plan/spec text. Used to decide whether the boot check must observe a
387
- * LISTENER (served app) or may pass on mere survival / quick exit (CLI project).
388
- */
389
- export function detectsServedApp(cwd, planText) {
390
- try {
391
- const j = JSON.parse(readFileSync(path.join(cwd, 'package.json'), 'utf8'));
392
- const all = { ...(j.dependencies ?? {}), ...(j.devDependencies ?? {}) };
393
- if (Object.keys(all).some(isServerFrameworkDep))
394
- return true;
395
- }
396
- catch {
397
- // no/unreadable manifest → fall through to the text signal
398
- }
399
- return planText !== undefined && SERVE_TEXT_RE.test(planText);
400
- }
401
- /** `ss -tlnpH` rows → {pid, port}. Column 4 (0-based 3) is the local address; the
402
- * port is its last `:`-suffixed number ("0.0.0.0:3000", "[::]:3000"). */
403
- export function parseSsListeners(stdout) {
404
- const out = [];
405
- for (const line of stdout.split('\n')) {
406
- const pm = /pid=(\d+)/.exec(line);
407
- if (!pm)
408
- continue;
409
- const local = line.trim().split(/\s+/)[3] ?? '';
410
- const portm = /:(\d+)$/.exec(local);
411
- if (!portm)
412
- continue;
413
- out.push({ pid: Number(pm[1]), port: Number(portm[1]) });
414
- }
415
- return out;
416
- }
417
- /**
418
- * `netstat -tlnp` rows → {pid, port} (mx5 run 14, validated: the agent-sandbox
419
- * image ships NEITHER ss NOR lsof — only ps and netstat — so the served-app boot
420
- * check could never observe a listener and failed unfalsifiably). The pid rides
421
- * in the trailing "PID/Program name" column ("1234/bun"); rows the kernel will
422
- * not attribute to us print "-" there and are skipped.
423
- */
424
- export function parseNetstatListeners(stdout) {
425
- const out = [];
426
- for (const line of stdout.split('\n')) {
427
- if (!/^\s*tcp/i.test(line))
428
- continue;
429
- const cols = line.trim().split(/\s+/);
430
- const local = cols[3] ?? '';
431
- const portm = /:(\d+)$/.exec(local);
432
- if (!portm)
433
- continue;
434
- const pidm = /^(\d+)\//.exec(cols[cols.length - 1] ?? '');
435
- if (!pidm)
436
- continue;
437
- out.push({ pid: Number(pidm[1]), port: Number(portm[1]) });
438
- }
439
- return out;
440
- }
441
- /** `lsof -iTCP -sTCP:LISTEN -n -P` rows → {pid, port}. */
442
- export function parseLsofListeners(stdout) {
443
- const out = [];
444
- for (const line of stdout.split('\n').slice(1)) {
445
- const cols = line.trim().split(/\s+/);
446
- const pid = Number(cols[1]);
447
- const name = cols.find(c => /:\d+$/.test(c)) ?? '';
448
- const portm = /:(\d+)$/.exec(name);
449
- if (Number.isInteger(pid) && pid > 0 && portm) {
450
- out.push({ pid, port: Number(portm[1]) });
451
- }
452
- }
453
- return out;
454
- }
455
- /** The socket-enumeration tools we can attribute listeners with, in preference
456
- * order: ss (richest), netstat (present where ss is not), lsof (BSD/macOS). */
457
- const LISTENER_TOOLS = [
458
- { bin: 'ss', args: ['-tlnpH'], parse: parseSsListeners },
459
- { bin: 'netstat', args: ['-tlnp'], parse: parseNetstatListeners },
460
- { bin: 'lsof', args: ['-iTCP', '-sTCP:LISTEN', '-n', '-P'], parse: parseLsofListeners }
461
- ];
462
- /** Listening TCP sockets as {pid, port} pairs (best-effort; ss, then netstat, then
463
- * lsof). Empty on any failure — the caller then cannot attribute a listener to our
464
- * group and the served-app check degrades to survival (never a false FAIL). */
465
- function listeningSockets() {
466
- for (const { bin, args, parse } of LISTENER_TOOLS) {
467
- try {
468
- const t = spawnSync(bin, args, { encoding: 'utf8', timeout: 4000 });
469
- if (t.error || !t.stdout)
470
- continue;
471
- const rows = parse(t.stdout);
472
- if (rows.length > 0)
473
- return rows;
474
- }
475
- catch {
476
- // tool missing/unusable — try the next one
477
- }
478
- }
479
- return [];
480
- }
481
- /**
482
- * Can ANY socket-enumeration tool run here at all? (mx5 run 14: the sandbox had
483
- * none, so `groupHasListener` returned false forever and the boot check emitted
484
- * "never opened a listening socket" no matter what the app did — an unfalsifiable
485
- * FAIL that failed a run whose app demonstrably served.) This is a CAPABILITY
486
- * question, deliberately separate from "did we see a listener": a tool that ran
487
- * and found nothing is an observation; no tool at all is blindness, and blindness
488
- * must degrade to the survival rule exactly like win32 — never a false FAIL on a
489
- * platform we cannot probe.
490
- *
491
- * "Ran" = spawned without ENOENT and either exited 0 or printed something (lsof
492
- * exits 1 on an empty match set; a netstat that rejects `-p` prints nothing).
493
- * Memoised: the answer is a property of the box, not of the run.
494
- */
495
- let listenerToolCapability = null;
496
- export function canEnumerateListeners() {
497
- if (listenerToolCapability !== null)
498
- return listenerToolCapability;
499
- listenerToolCapability = LISTENER_TOOLS.some(({ bin, args }) => {
500
- try {
501
- const r = spawnSync(bin, args, { encoding: 'utf8', timeout: 4000 });
502
- if (r.error)
503
- return false;
504
- return r.status === 0 || (r.stdout ?? '').trim().length > 0;
505
- }
506
- catch {
507
- return false;
508
- }
509
- });
510
- return listenerToolCapability;
511
- }
512
- /** Test seam: forget the memoised capability answer. */
513
- export function resetListenerToolCapability() {
514
- listenerToolCapability = null;
515
- }
516
- /**
517
- * A free TCP port on the loopback interface, or null if one cannot be reserved.
518
- * The boot check hands this to the child as PORT so that a successful HTTP
519
- * request to it is OWNERSHIP evidence: nobody else knows the number (mx5 runs
520
- * 8/10/11 — orphaned servers from earlier checks answered curl on the
521
- * conventional :3000 and passed checks the app had not earned).
522
- */
523
- export function pickFreePort() {
524
- return new Promise(resolve => {
525
- try {
526
- const srv = net.createServer();
527
- srv.once('error', () => resolve(null));
528
- srv.listen(0, '127.0.0.1', () => {
529
- const a = srv.address();
530
- const port = typeof a === 'object' && a !== null ? a.port : null;
531
- srv.close(() => resolve(port));
532
- });
533
- }
534
- catch {
535
- resolve(null);
536
- }
537
- });
538
- }
539
- /** Can we bind 127.0.0.1:`port` right now? (Free ⇒ the boot child can have it.) */
540
- export function isPortFree(port) {
541
- return new Promise(resolve => {
542
- try {
543
- const srv = net.createServer();
544
- srv.once('error', () => resolve(false));
545
- srv.listen(port, '127.0.0.1', () => srv.close(() => resolve(true)));
546
- }
547
- catch {
548
- resolve(false);
549
- }
550
- });
551
- }
552
- /**
553
- * The project's own declared local port, but only if nothing is holding it — the
554
- * default `preferredPort` for the gate. A declared port that is BUSY falls back to
555
- * a reserved one rather than colliding: a stranger's server on :3000 must never be
556
- * mistaken for the app we just booted.
557
- */
558
- export async function preferredDeclaredPort(cwd) {
559
- const port = pinnedLocalPort(collectProjectEnv(cwd));
560
- if (port === null)
561
- return null;
562
- return (await isPortFree(port)) ? port : null;
563
- }
564
- /**
565
- * Does anything answer HTTP on 127.0.0.1:`port`? Any response at all (404, 500 —
566
- * a status is a listener) counts; only a connection error or timeout is a no.
567
- * Runs in a throwaway child of our own runtime so it needs no curl on PATH and
568
- * stays synchronous inside the boot poll.
569
- */
570
- function defaultHttpProbe(port) {
571
- const script = `fetch('http://127.0.0.1:${port}/').then(()=>process.exit(0),()=>process.exit(1));`
572
- + `setTimeout(()=>process.exit(1),2000)`;
573
- try {
574
- const r = spawnSync(process.execPath, ['-e', script], {
575
- encoding: 'utf8',
576
- timeout: 5000
577
- });
578
- return !r.error && r.status === 0;
579
- }
580
- catch {
581
- return false;
582
- }
583
- }
584
- /** Process-group id of `pid`, or null if it cannot be read. */
585
- function pgidOf(pid) {
586
- try {
587
- const r = spawnSync('ps', ['-o', 'pgid=', '-p', String(pid)], {
588
- encoding: 'utf8',
589
- timeout: 4000
590
- });
591
- const n = Number((r.stdout ?? '').trim());
592
- return Number.isInteger(n) && n > 0 ? n : null;
593
- }
594
- catch {
595
- return null;
596
- }
597
- }
598
- /** Default listener probe: any LISTENing socket owned by a pid in process group
599
- * `pgid` (the detached boot child IS its own group leader, so pgid === child.pid). */
600
- function defaultGroupHasListener(pgid) {
601
- for (const { pid } of listeningSockets()) {
602
- if (pgidOf(pid) === pgid)
603
- return true;
604
- }
605
- return false;
606
- }
607
- /** Default port lookup for the render check: the LOWEST port among the group's
608
- * listeners (a dev toolchain may open an HMR socket too; the app's own server
609
- * conventionally sits on the lower, configured port). Null when undeterminable. */
610
- function defaultGroupListeningPort(pgid) {
611
- const ports = listeningSockets()
612
- .filter(({ pid }) => pgidOf(pid) === pgid)
613
- .map(({ port }) => port);
614
- return ports.length > 0 ? Math.min(...ports) : null;
615
- }
616
- /** Default port-holder lookup: `lsof` first, then `ss`/`fuser`. Returns null on any
617
- * failure (the diagnosis then omits the pid — never blocks). */
618
- function defaultFindPortHolder(port) {
619
- try {
620
- const t = spawnSync('lsof', ['-i', `:${port}`, '-sTCP:LISTEN', '-t', '-P', '-n'], {
621
- encoding: 'utf8',
622
- timeout: 4000
623
- });
624
- const pid = Number((t.stdout ?? '').split('\n')[0]?.trim());
625
- if (!Number.isInteger(pid) || pid <= 0)
626
- return null;
627
- const ps = spawnSync('ps', ['-o', 'args=', '-p', String(pid)], {
628
- encoding: 'utf8',
629
- timeout: 4000
630
- });
631
- return { pid, command: (ps.stdout ?? '').trim() || `pid ${pid}` };
632
- }
633
- catch {
634
- return null;
635
- }
636
- }
637
- function defaultReap(pid) {
638
- try {
639
- process.kill(pid, 'SIGTERM');
640
- setTimeout(() => {
641
- try {
642
- process.kill(pid, 'SIGKILL');
643
- }
644
- catch {
645
- // already gone
646
- }
647
- }, 1_000).unref();
648
- return true;
649
- }
650
- catch {
651
- return false;
652
- }
653
- }
654
- /** Does the port holder look like one of OUR gate children (a `dev`/`start` run of
655
- * the discovered boot command)? Only then do we reap it — never a foreign process
656
- * the user happens to be running. */
657
- function holderIsOurs(command, boot) {
658
- const script = boot[1][boot[1].length - 1] ?? ''; // 'start' | 'dev' | 'run'
659
- const c = command.toLowerCase();
660
- return ((c.includes('bun') || c.includes('node') || c.includes('npm') || c.includes('make'))
661
- && (c.includes(` ${script}`) || c.endsWith(script)));
662
- }
663
- /**
664
- * Exercise the start command ONCE. For a CLI project (`expectServer` false) the
665
- * command's own fate within the grace window decides:
666
- *
667
- * - non-zero exit (or signal death) before the window closes → FAIL, output tail;
668
- * - exit 0 before the window closes → PASS (a CLI-style "run" that finished);
669
- * - still alive when the window closes → PASS, then the whole process group is
670
- * killed (detached spawn = own group; SIGTERM, escalating to SIGKILL).
671
- *
672
- * For a SERVED app (`expectServer` true — the spec/plan promised an HTTP server) mere
673
- * survival is not enough: a watcher (`dev` = tailwind/bundler --watch) stays alive
674
- * forever without ever listening, and a type-only entrypoint exits 0 in <1s having
675
- * served nothing (mx5 run 10 — both were blessed by the survival rule). The boot then
676
- * PASSes only once a LISTENing socket owned by our process group is observed; if the
677
- * command exits, or the grace window closes, with no listener ever seen → FAIL naming
678
- * that a listening server was expected.
679
- *
680
- * OBSERVABILITY is a precondition of that FAIL (mx5 run 14, validated). The listener
681
- * requirement needs pgid-attributed socket enumeration; win32 has none, and neither
682
- * does a Linux image shipping no ss/netstat/lsof — run 14's sandbox was exactly that,
683
- * so the check emitted "never opened a listening socket" against an app that
684
- * demonstrably served, three autofix passes could not falsify it, and the run was
685
- * recorded failed. Two defences, in order:
686
- *
687
- * - the child is spawned with a freshly reserved, otherwise-unused PORT, and an
688
- * HTTP answer on THAT port proves a listener regardless of tooling. The private
689
- * port is what makes the HTTP probe trustworthy: an orphaned server from an
690
- * earlier check answers on :3000, but nobody else knows this number.
691
- * - if nothing can enumerate listeners AND the assigned port never answered, the
692
- * served-app requirement is unobservable here, so `expectServer` collapses to
693
- * the survival rule and the PASS is stamped UNOBSERVED. An app that ignores PORT
694
- * is indistinguishable from one that never listened — an observer limitation,
695
- * not an app defect, and it may not be reported as one.
696
- *
697
- * A child that EXITS non-zero still FAILs in every environment: "the process died"
698
- * needs no socket probe, so run 14's original true positive (a `--hot` runtime
699
- * pinning a crashed app) stays reportable wherever the tooling exists.
700
- *
701
- * Env-gap contract as everywhere: spawn error (ENOENT) or a command-not-found
702
- * inside the chain (exit 127, or the runner's own wording where the platform
703
- * reports it that way — see isCommandNotFound) → skip.
704
- */
705
- export async function runBootCheck(cwd, [bin, args], graceMs = 10_000, opts = {}) {
706
- const expectServer = (opts.expectServer ?? false) && process.platform !== 'win32';
707
- const groupHasListener = opts.deps?.groupHasListener ?? defaultGroupHasListener;
708
- const httpProbe = opts.deps?.httpProbe ?? defaultHttpProbe;
709
- const canEnumerate = expectServer ? (opts.deps?.enumerationCapable ?? canEnumerateListeners)() : true;
710
- // Only served apps get an assigned port: a CLI project has nothing to bind, and
711
- // an unexpected PORT in its env is noise.
712
- // The app's OWN declared local port wins when it is free (see pinnedLocalPort):
713
- // a client whose base URL was baked in at build time calls that origin and no
714
- // other, so serving it anywhere else makes the whole authenticated half
715
- // unobservable. Anything else — no declaration, a port already held — falls back
716
- // to the freshly reserved private port that run 14's ownership evidence needs.
717
- const noPreference = () => Promise.resolve(null);
718
- const preferred = expectServer ? await (opts.deps?.preferredPort ?? noPreference)() : null;
719
- const assignedPort = preferred ?? (expectServer ? await (opts.deps?.pickPort ?? pickFreePort)() : null);
720
- // Runner resolution (mx5 run 16): same contract as runGateCommand — resolve
721
- // the runner and carry its directory on PATH so the boot script's own chain
722
- // can re-invoke it.
723
- const runner = resolveRunner(bin);
724
- return new Promise(resolve => {
725
- const child = spawn(runner.bin, args, {
726
- cwd,
727
- detached: true,
728
- stdio: ['ignore', 'pipe', 'pipe'],
729
- env: {
730
- ...runnerEnv(runner),
731
- ...(assignedPort !== null ? { PORT: String(assignedPort) } : {})
732
- }
733
- });
734
- // Best-effort cleanup only: killGroup below can silently fail to reap the
735
- // process (platform/sandbox-specific — observed on a GH Actions Linux
736
- // runner where the group-kill did not take, hanging the whole `bun test
737
- // --isolate` run on the leaked child's piped stdio). unref() so a child
738
- // we already tried to kill can never itself keep this process alive.
739
- child.unref();
740
- let out = '';
741
- let err = '';
742
- let listenerSeen = false;
743
- const cap = (s) => (s.length > 8000 ? s.slice(-8000) : s);
744
- child.stdout?.on('data', (d) => (out = cap(out + String(d))));
745
- child.stderr?.on('data', (d) => (err = cap(err + String(d))));
746
- let settled = false;
747
- const settle = (r) => {
748
- if (settled)
749
- return;
750
- settled = true;
751
- clearTimeout(timer);
752
- if (poll)
753
- clearInterval(poll);
754
- resolve(r);
755
- };
756
- const killGroup = (sig) => {
757
- try {
758
- if (!child.pid)
759
- return;
760
- if (process.platform === 'win32') {
761
- // Windows has no process groups / negative-pid kill. taskkill
762
- // /T tears down the whole tree (the detached child plus any
763
- // grandchildren it spawned); /F forces it, so the SIGTERM→
764
- // SIGKILL escalation collapses to one idempotent call.
765
- spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F']);
766
- }
767
- else {
768
- process.kill(-child.pid, sig);
769
- }
770
- }
771
- catch {
772
- // group already gone
773
- }
774
- };
775
- const passAndKill = (renderNote) => {
776
- settle(renderNote ? { outcome: 'pass', renderNote } : { outcome: 'pass' });
777
- killGroup('SIGTERM');
778
- setTimeout(() => killGroup('SIGKILL'), 2_000).unref();
779
- };
780
- const failAndKill = (detail) => {
781
- settle({ outcome: 'fail', detail });
782
- killGroup('SIGTERM');
783
- setTimeout(() => killGroup('SIGKILL'), 2_000).unref();
784
- };
785
- // Served apps only: poll for a listening socket owned by our process group.
786
- // As soon as one appears the boot has demonstrably served → run the render
787
- // check against the LIVE listener (mx5 runs 8/11: a listener that serves a
788
- // permanently blank page passed every curl-shaped check), then PASS/FAIL.
789
- // The probe is spawnSync, so the interval cannot re-enter mid-check.
790
- // The deep probe is asynchronous (it drives a browser session), so the
791
- // interval body must not re-enter while one is in flight — a second session
792
- // would race the first for the same still-booting child.
793
- let probing = false;
794
- const poll = expectServer ?
795
- setInterval(() => {
796
- if (settled || probing || !child.pid)
797
- return;
798
- // pgid attribution first (precise, cheap). If it saw nothing — or
799
- // cannot see anything here — fall back to the private assigned
800
- // port: an HTTP answer on a number only this child was told is
801
- // proof of OUR listener, not of some orphan on :3000.
802
- const byGroup = canEnumerate && groupHasListener(child.pid);
803
- const byPort = !byGroup && assignedPort !== null && httpProbe(assignedPort);
804
- if (!byGroup && !byPort)
805
- return;
806
- listenerSeen = true;
807
- const probe = opts.deps?.renderProbe;
808
- if (!probe)
809
- return passAndKill();
810
- const port = byGroup ?
811
- (opts.deps?.groupListeningPort ?? defaultGroupListeningPort)(child.pid)
812
- : assignedPort;
813
- if (port === null) {
814
- return passAndKill('render check UNOBSERVED: a listener was seen but its port could not be determined');
815
- }
816
- const url = `http://127.0.0.1:${port}/`;
817
- const rr = probe(url);
818
- if (rr.outcome === 'fail') {
819
- return failAndKill(`listens on :${port} but ${rr.detail}`);
820
- }
821
- const deep = opts.deps?.deepRenderProbe;
822
- if (rr.outcome !== 'pass' || !deep) {
823
- return passAndKill(rr.outcome === 'skip' ?
824
- `render check UNOBSERVED: ${rr.note}`
825
- : undefined);
826
- }
827
- // The page renders. Now sign in and prove the AUTHENTICATED half
828
- // is alive (mx5 run 17): the server accepted the login and the
829
- // client never used it. Async, so the interval is held off by
830
- // `probing` until this settles.
831
- probing = true;
832
- void Promise.resolve(deep(url)).then(dr => {
833
- if (settled)
834
- return;
835
- if (dr.outcome === 'fail') {
836
- return failAndKill(`listens on :${port} but ${dr.detail}`);
837
- }
838
- passAndKill(dr.outcome === 'skip' ?
839
- `authenticated render check UNOBSERVED: ${dr.note}`
840
- : undefined);
841
- }, () => {
842
- // The deep probe may never fail the gate on its own fault.
843
- if (!settled)
844
- passAndKill();
845
- });
846
- }, 500)
847
- : null;
848
- const onGrace = () => {
849
- // A browser session in flight outlives the grace window by design (it
850
- // signs in and waits for the app's data calls). Settling here would kill
851
- // the server under it and discard its verdict, so the window re-arms
852
- // until the probe resolves — which it always does, on its own hard
853
- // timeout (DEEP_RENDER_TIMEOUT_MS).
854
- if (probing) {
855
- timer = setTimeout(onGrace, 500);
856
- return;
857
- }
858
- if (expectServer && !listenerSeen) {
859
- // Blind here (no enumeration tool, and the assigned port never
860
- // answered) ⇒ we cannot tell "never listened" from "ignores PORT".
861
- // Survival rule, stamped UNOBSERVED — an observer limitation is not
862
- // an app defect (mx5 run 14).
863
- if (!canEnumerate)
864
- return passAndKill(UNOBSERVED_LISTENER_NOTE);
865
- settle({
866
- outcome: 'fail',
867
- detail: `still running after ${graceMs}ms but never opened a listening socket — the spec/dependencies promise an HTTP server`
868
- });
869
- killGroup('SIGTERM');
870
- setTimeout(() => killGroup('SIGKILL'), 2_000).unref();
871
- return;
872
- }
873
- passAndKill();
874
- };
875
- let timer = setTimeout(onGrace, graceMs);
876
- child.on('error', () => settle({ outcome: 'skip', spawnFailed: true }));
877
- child.on('exit', (status, signal) => {
878
- if (status === 0) {
879
- if (expectServer && !listenerSeen) {
880
- if (!canEnumerate) {
881
- return settle({ outcome: 'pass', renderNote: UNOBSERVED_LISTENER_NOTE });
882
- }
883
- return settle({
884
- outcome: 'fail',
885
- detail: 'exited 0 without ever opening a listening socket — the spec/dependencies '
886
- + 'promise an HTTP server, so a boot that serves nothing is not a launch'
887
- });
888
- }
889
- return settle({ outcome: 'pass' });
890
- }
891
- // Command-not-found inside the boot chain — 127 on a posix shell, or
892
- // the runner's own wording where it isn't (Windows bun exits 1). Either
893
- // way the boot never RAN, so it is an environment gap, not an app fault.
894
- if (isCommandNotFound(status, `${out}\n${err}`)
895
- || (status === null && signal === null)) {
896
- return settle({ outcome: 'skip' });
897
- }
898
- const what = status !== null ? `exited ${status}` : `was killed by ${signal}`;
899
- const tail = outputTail(out, err);
900
- // A bind collision is an environment condition, not an app defect — hand
901
- // it back distinctly so the gate can reap our own orphan and retry rather
902
- // than reporting the app "crashed" (mx5 run 9 item 3).
903
- if (isAddressInUse(`${out}\n${err}`)) {
904
- settle({
905
- outcome: 'orphan-port',
906
- port: extractPort(`${out}\n${err}`),
907
- detail: `${what}${tail ? ` — ${tail}` : ''}`
908
- });
909
- return;
910
- }
911
- settle({ outcome: 'fail', detail: `${what}${tail ? ` — ${tail}` : ''}` });
912
- });
913
- });
914
- }
915
183
  /**
916
184
  * Labels (`bin args…`) of every command the gate CAN currently discover — the
917
185
  * static half (repo-health) plus the integration half. Pure discovery, nothing
@@ -1005,302 +273,26 @@ run = spawnCommand) {
1005
273
  }
1006
274
  return verdict;
1007
275
  }
1008
- /** The command word of a shell line, past any leading `VAR=value` assignments. */
1009
- function leadingBin(line) {
1010
- for (const tok of line.trim().split(/\s+/)) {
1011
- if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(tok))
1012
- continue;
1013
- return tok;
1014
- }
1015
- return null;
1016
- }
1017
- /**
1018
- * Re-run one VERIFY-block command line (nexttask 5) under the gate's existing
1019
- * env-gap contract, so a debt whose reason NAMES that command can be closed by the
1020
- * command itself rather than by a judgement about it.
1021
- *
1022
- * Runs through `sh -c` because a VERIFY line is a shell line, not an argv: run 19's
1023
- * is `AGENT=1 bun test test/listings.test.ts`, and env prefixes, `&&` and redirects
1024
- * are all ordinary there. The leading command word is still resolved through
1025
- * runner-resolve so a login-shell-stripped PATH cannot make every re-run look like a
1026
- * gap (mx5 run 16's blindness, one level down).
1027
- *
1028
- * The asymmetry is the point: only exit 0 is conclusive. Every other ending — real
1029
- * failure, missing tool, unreachable database, timeout, no POSIX shell — leaves the
1030
- * debt exactly as open as it was.
1031
- */
1032
- export function runVerifyCommandLine(cwd, line, timeoutMs, extraGapRe,
1033
- /** The spawner. Injected so a re-run's outcome can be tested without one. */
1034
- run = spawnCommand) {
1035
- const bin = leadingBin(line);
1036
- const runner = bin === null ? null : resolveRunner(bin);
1037
- // A VERIFY line is a SHELL line, not an argv — env prefixes, `&&` and
1038
- // redirects are all ordinary there — so the runner spawns `sh -c`.
1039
- const verdict = classifyCommandRun(run({
1040
- cwd,
1041
- bin: 'sh',
1042
- args: ['-c', line],
1043
- timeoutMs,
1044
- env: runner ? runnerEnv(runner) : { ...process.env }
1045
- }),
1046
- // Infrastructure counts as a gap on EVERY debt re-run, not only on
1047
- // request: an unreachable database cannot tell us whether the code is
1048
- // fixed, and the asymmetry below means an inconclusive re-run simply
1049
- // leaves the debt as open as it was.
1050
- extraGapRe ? [INFRA_GAP_OUTPUT_RE, extraGapRe] : [INFRA_GAP_OUTPUT_RE]);
1051
- if (verdict.outcome === 'gap')
1052
- return { outcome: 'gap', detail: verdict.detail };
1053
- return verdict;
1054
- }
1055
- /**
1056
- * The full-skip blindness guard (mx5 run 16, validated): dynamic commands were
1057
- * DISCOVERED but every single one skipped as an environment gap, so the gate
1058
- * decided on statics alone and stamped a permanently blank app green. Per-command
1059
- * env-gap skips stay legitimate (a missing browser must not fail a suite); what
1060
- * may never happen again is ALL of them skipping while the gate still reports
1061
- * PASS — a gate that observed nothing dynamic has no basis to vouch for the
1062
- * assembled app. Pure so the semantics are unit-tested; the caller feeds it the
1063
- * attempt/observation counters and runner resolvability.
1064
- */
1065
- export function observabilityGapFailure(args) {
1066
- if (args.attempted === 0 || args.observed > 0)
1067
- return null;
1068
- if (args.spawnFailures < args.attempted)
1069
- return null;
1070
- const unresolvable = args.runnerBins.filter(b => !args.runnerResolvable(b));
1071
- const runnerNote = unresolvable.length > 0 ?
1072
- ` — the project's own runner ${unresolvable
1073
- .map(b => `\`${b}\``)
1074
- .join(', ')} is not spawnable here (not on PATH nor any known install location)`
1075
- : '';
1076
- return (`observability gap: ${args.attempted} integration/boot command(s) exist but NONE `
1077
- + `could even spawn in this environment${runnerNote}; `
1078
- + `the gate observed nothing dynamic and cannot vouch for the assembled app`);
1079
- }
1080
- /**
1081
- * The THIRD verdict. observabilityGapFailure above covers "commands were DISCOVERED
1082
- * but every one failed to spawn" — a rank-0 FAIL. It deliberately returns null for
1083
- * `attempted === 0`, and until now that silence fell straight through to
1084
- * `PASS — no integration command found (statics passed)`: the run-16 blindness class
1085
- * entering through a different door, where "we never checked" reads exactly like "we
1086
- * checked and it was fine". Measured 2026-07-27: IAR1 (C++/CMake, no package.json)
1087
- * shipped that verdict TWICE while carrying 2 and 3 open verify-FAIL debts, and
1088
- * godot-engine (package.json whose only script is `verify`) reproduces it live today.
1089
- *
1090
- * So: observed anything dynamic ⇒ PASS; discovered-but-all-spawn-failed ⇒ the
1091
- * existing FAIL; observed NOTHING ⇒ this note, carried on an `ok: true` outcome.
1092
- *
1093
- * WHY NON-BLOCKING (decided, not deferred — the evidence cuts both ways and this is
1094
- * the resolution):
1095
- * - Blocking's case: both real occurrences also carried open verify-FAIL debt, so
1096
- * the runs with no dynamic evidence were exactly the runs already known to be
1097
- * carrying defects.
1098
- * - Against, and decisive: (1) that debt is ALREADY surfaced unconditionally at the
1099
- * gate moment, on PASS as on FAIL — the IAR1 records literally read "PASS — no
1100
- * integration command found … UNRESOLVED VERIFY-FAIL DEBT still open (2)". The
1101
- * missing signal was never the debt, it was the word PASS endorsing the run, and
1102
- * that is what this fixes. (2) `ok: false` routes into the autofix picker, whose
1103
- * seed is `reason`; "no integration command is discoverable" is not fixable by
1104
- * editing code, so the highest-probability child response is to FABRICATE a
1105
- * runnable command to satisfy the gate — the same fabrication class that refuted
1106
- * the `## verified tooling` harvest (see discoverIntegrationCommands) and that had
1107
- * run 11's fix child `rm` a sibling's deliverable. (3) That harvest being refuted
1108
- * means IAR1 and godot-engine can NEVER discover a command, so blocking would end
1109
- * every non-npm run in `failed` permanently, with no remedy — the task's own I3
1110
- * ("show blocking does not block IAR1/godot post-Task-1") is unsatisfiable, and
1111
- * its stated consequence is to downgrade to a warning and say so. This is that.
1112
- * The teeth are elsewhere and are real: the verdict word changes, the gate trail says
1113
- * UNOBSERVED, and the caller records a durable final-gate debt that the NEXT run's
1114
- * gate re-surfaces (it can never auto-close — it is not static-class).
1115
- */
1116
- export function unobservedVerdict(args) {
1117
- if (args.observed > 0)
1118
- return null;
1119
- // Kept short ON PURPOSE: the run-level trail line slices the reason at 300 chars,
1120
- // and the whole point of this verdict is that the durable record carries it.
1121
- const why = args.discovered === 0 ?
1122
- 'no integration, lockfile or boot command was discoverable here, so the gate ran '
1123
- + 'nothing at all'
1124
- : `all ${args.discovered} discovered command(s) skipped as environment gaps, so the `
1125
- + 'gate ran nothing observable';
1126
- return (`UNOBSERVED — NOT a pass: ${why}; statics passed, but this run produced NO evidence `
1127
- + 'that the assembled product builds, boots or works.');
1128
- }
1129
- /**
1130
- * The SAME third verdict, at the door unobservedVerdict cannot reach: the boot
1131
- * check specifically (mx5 run 18, validated).
1132
- *
1133
- * Run 18 shipped an app with no HTTP server behind a converged final gate. Its
1134
- * `src/server/index.ts` ends at `export {app}` — no `Bun.serve`, no
1135
- * `export default app`, no `start` script — so `bun run src/server/index.ts` exits
1136
- * 0 immediately and the product cannot be started at all. The gate's boot command
1137
- * resolved to `bun run dev`, whose body begins `docker compose … up -d`; the gate
1138
- * sandbox had no docker, so the boot SKIPPED as an environment gap. Skips
1139
- * contribute nothing to `dynObserved`, and `bun run test`, `test:ct`, `build`,
1140
- * `lint`, `seed` and `migrate` all ran — so `dynObserved > 0`, the full-skip
1141
- * blindness guard (observabilityGapFailure) stayed correctly quiet, and the trail
1142
- * read `final-gate: autofix converged — statics + … passed` with 24/24 tasks green.
1143
- *
1144
- * The defect is that "the app was never observed to boot" and "the app booted
1145
- * fine" produced BYTE-IDENTICAL gate output. That is the class scripts/ab-verdict.ts
1146
- * exists to kill one layer up: absence of evidence rendered in the shape of
1147
- * evidence. So a discovered-but-skipped boot now names itself, and — unlike every
1148
- * other skip — it CANNOT be cancelled by observations from other commands.
1149
- * Component tests are the trap here, not the alibi: run 18 had 51 green Playwright
1150
- * CT tests, and CT mounts components in a browser without ever assembling or
1151
- * starting the server.
1152
- *
1153
- * DECIDED, do not silently re-open:
1154
- * - NOT a FAIL. A boot skip on a docker-less box is a genuine environment gap, and
1155
- * failing it re-creates run 16's unfalsifiable-FAIL mistake pointing the other
1156
- * way. UNOBSERVED blocks nothing while being loud and durable (the caller records
1157
- * it as final-gate debt the next run re-surfaces), and it keeps "boot never ran"
1158
- * out of the autofix child's seed — a child cannot fix a missing docker, so the
1159
- * highest-probability response would be to FABRICATE a bootable command, the
1160
- * class that refuted the `## verified tooling` harvest.
1161
- * - BOTH skip flavours count. Run 18's skip carried `spawnFailed: false` (127 inside
1162
- * the script chain, not an ENOENT on the runner), so keying off spawnFailed would
1163
- * have missed the actual defect.
1164
- * - SERVED APPS ONLY. `expectServer === false` (a CLI/library project) is fenced off
1165
- * deliberately: a CLI whose `dev` script needs an absent tool has no server to be
1166
- * unobserved, and widening the lever there buys warnings nobody can act on.
1167
- */
1168
- export function bootSkipVerdict(args) {
1169
- if (args.label === null || !args.skipped || !args.expectServer)
1170
- return null;
1171
- // Deliberately short: the run-level trail slices the reason at 300 chars and this
1172
- // note leads it, so the command name always survives.
1173
- return (`boot check: \`${args.label}\` NEVER RAN (environment gap) — the app was not observed `
1174
- + 'to start, and no test suite substitutes for that.');
1175
- }
1176
- /**
1177
- * Boot check hit an address-in-use bind failure. If the port is held by one of OUR
1178
- * own orphaned gate children (a `dev`/`start` run), reap it and retry the boot once
1179
- * so the app gets a fair launch; otherwise leave the (foreign) holder alone and let
1180
- * the caller emit the harness diagnosis. Never reaps a process we cannot attribute
1181
- * to ourselves.
1182
- */
1183
- async function recoverOrphanPort(cwd, boot, first, bootGraceMs, deps, expectServer) {
1184
- if (first.port === null)
1185
- return first;
1186
- const holder = (deps.findPortHolder ?? defaultFindPortHolder)(first.port);
1187
- if (!holder || !holderIsOurs(holder.command, boot))
1188
- return first;
1189
- const reaped = (deps.reap ?? defaultReap)(holder.pid);
1190
- if (!reaped)
1191
- return first;
1192
- // Give the OS a moment to release the socket, then re-run the boot once.
1193
- await new Promise(r => setTimeout(r, 1_500));
1194
- return runBootCheck(cwd, boot, bootGraceMs, { expectServer, deps });
1195
- }
276
+ // `runVerifyCommandLine` and its outcome type live in command-run.ts with the
277
+ // other command drivers; re-exported so existing importers keep working.
278
+ export { runVerifyCommandLine } from './command-run.js';
279
+ // The two verdict predicates — the run-16 full-blindness FAIL and the third,
280
+ // non-blocking UNOBSERVED verdict — live with the counters they read, in
281
+ // gate-tally.ts (GateTally). Re-exported so every existing importer keeps working.
282
+ export { observabilityGapFailure, unobservedVerdict };
1196
283
  // File → introducing-task provenance moved to task-provenance.ts (mx5 run-12
1197
284
  // PROMPT 2 extracted it for the cross-task deletion guards); re-exported so
1198
285
  // existing importers keep working.
1199
286
  export { taskThatIntroduced };
1200
- /**
1201
- * ACCEPT-debt re-check (mx5 run 4 B3 / run 8 TASK_0012): read the ledger of tasks
1202
- * the user accepted despite a verify-FAIL and re-check each against the CURRENT
1203
- * tree. A static-class debt whose statics now pass is provably RESOLVED (a later
1204
- * task fixed it) and pruned from the ledger; every other debt cannot be proven
1205
- * resolved deterministically, so it stays OPEN and is surfaced — a run may not
1206
- * complete silently carrying an accepted defect. FP-safe by construction (see
1207
- * accept-debt.ts). Best-effort: a ledger read/write failure must never break the
1208
- * caller.
1209
- *
1210
- * FACTORED OUT of runFinalIntegrationGate (nexttask 6): the derivation has to be
1211
- * runnable at a SECOND moment — after a converged final-gate autofix, where the
1212
- * orchestrator used to rebuild its gate outcome as a bare `{ok, reason}` and drop
1213
- * `openDebts` entirely. The report a run ends on has to be derived from the tree
1214
- * the run ends with, not from the tree as it was before the fix pass.
1215
- *
1216
- * `staticOk` is the caller's claim about the CURRENT statics, and it is the only
1217
- * thing that can auto-close a static-class debt — so a caller that does not know
1218
- * must pass `false` (unprovable ⇒ stays open), never a guess.
1219
- */
1220
- export async function deriveOpenDebts(cwd, staticOk) {
1221
- const { open: openRaw, resolved, trail } = recheckAcceptDebts(await readAcceptDebts(cwd), {
1222
- staticOk,
1223
- // Cross-task-deletion debts auto-close iff the deleted file is back in the
1224
- // tree — a deterministic existence check, corroborating the per-file
1225
- // provenance the record already carries.
1226
- fileExists: rel => existsSync(path.join(cwd, rel)),
1227
- // VERIFY-COMMAND class (nexttask 5): a debt that NAMES a command is settled
1228
- // by running that command, under the gate's own env-gap contract and behind
1229
- // the no-write guard below.
1230
- rerunVerify: cmd => rerunDebtVerifyCommand(cwd, cmd)
1231
- });
1232
- if (resolved.length > 0)
1233
- await writeAcceptDebts(cwd, openRaw);
1234
- // Conflicting-claim annotation (mx5 run 11): an existence-as-failure debt whose
1235
- // named file is another task's committed deliverable is a plan defect — surface
1236
- // the contradiction with the debt so nobody (human or child) treats the claim as
1237
- // a deletion instruction. Pure git-history lookup; degrades to no annotation.
1238
- const openDebts = annotateDebtConflicts(openRaw, p => taskThatIntroduced(cwd, p));
1239
- const debtNote = buildAcceptDebtNote(openDebts);
1240
- return { openDebts, ...(debtNote ? { debtNote } : {}), ...(trail.length > 0 ? { trail } : {}) };
1241
- }
1242
- /** Per-command ceiling for a debt re-run (`inv-bounded`). */
1243
- const DEBT_RERUN_TIMEOUT_MS = 300_000;
1244
- /**
1245
- * Extra infrastructure-gap shapes recognised ONLY when re-running a debt's command,
1246
- * never in the gate's own verdicts. A driver that reports its connection simply
1247
- * closed (`ERR_POSTGRES_CONNECTION_CLOSED` — what bun's SQL client says when the
1248
- * database is not there at all, as on this box with the mx5 container stopped) is an
1249
- * absent dependency, and calling that "the defect is still present" would be a
1250
- * finding the environment invented. Kept out of INFRA_GAP_OUTPUT_RE on purpose: in a
1251
- * gate verdict the same wording can be a real fault the suite must own, and only the
1252
- * debt re-check needs the conservative reading — where it costs nothing, because gap
1253
- * and fail both leave the debt open.
1254
- */
1255
- const DEBT_INFRA_GAP_RE = /ERR_POSTGRES_CONNECTION_CLOSED|ERR_MYSQL_CONNECTION|ECONNRESET/i;
1256
- /**
1257
- * Re-run ONE debt's stored VERIFY command for the re-check, with the no-write guard
1258
- * (`inv-no-write`) wrapped around it.
1259
- *
1260
- * A VERIFY command is the project's own command and may legitimately write (a build
1261
- * emits `dist/`, a suite writes a snapshot). What it may NOT do is turn the tree into
1262
- * a passing tree and have that count as the debt being fixed — the run would then be
1263
- * certifying its own side effect. So tracked state is captured before and after, and
1264
- * a pass that came with a tracked change is downgraded to INCONCLUSIVE with the
1265
- * change named. Untracked output is left alone: it is what a build legitimately
1266
- * produces, and `git status --porcelain` in a repo with the usual ignores does not
1267
- * see it.
1268
- *
1269
- * A repository the guard cannot read (no git, git absent) is not a licence to skip
1270
- * the guard: the re-run is INCONCLUSIVE there, because "nothing changed" would be an
1271
- * assumption rather than an observation.
1272
- */
1273
- export function rerunDebtVerifyCommand(cwd, command,
1274
- /** The spawner, for BOTH the command and the tracked-state reads. Injected so
1275
- * the guard's four outcomes are testable without a repo or a real command. */
1276
- run = spawnCommand) {
1277
- const tracked = () => {
1278
- const r = run({
1279
- cwd,
1280
- bin: 'git',
1281
- args: ['status', '--porcelain', '--untracked-files=no'],
1282
- timeoutMs: 60_000
1283
- });
1284
- return r.failedToStart || r.status !== 0 ? null : r.stdout;
1285
- };
1286
- const before = tracked();
1287
- const r = runVerifyCommandLine(cwd, command, DEBT_RERUN_TIMEOUT_MS, DEBT_INFRA_GAP_RE, run);
1288
- if (r.outcome === 'fail')
1289
- return { outcome: 'fail', detail: `exit ${r.status} — ${r.tail}` };
1290
- if (r.outcome === 'gap')
1291
- return { outcome: 'gap', detail: r.detail };
1292
- const after = tracked();
1293
- if (before === null || after === null) {
1294
- return { outcome: 'gap', detail: 'tracked-state guard could not read git status' };
1295
- }
1296
- if (before !== after) {
1297
- return {
1298
- outcome: 'gap',
1299
- detail: 'the re-run itself CHANGED tracked files — a command that edits the tree into a pass proves nothing'
1300
- };
1301
- }
1302
- return { outcome: 'pass' };
1303
- }
287
+ // The boot probe moved to boot-probe.ts (its own concern, 0 other importers inside
288
+ // src/). Re-exported so the seven validation harnesses under scripts/ which have
289
+ // always imported exactly this surface and nothing else from the gate — keep
290
+ // working unchanged. Same pattern as taskThatIntroduced above.
291
+ export { discoverBootCommand, detectsServedApp, runBootCheck, bootSkipVerdict, nonLaunchScriptReason, rejectedLaunchScript, parseSsListeners, parseNetstatListeners, parseLsofListeners, pickFreePort, preferredDeclaredPort, canEnumerateListeners };
292
+ // The ACCEPT-debt re-check (`deriveOpenDebts`, `rerunDebtVerifyCommand`) lives in
293
+ // accept-debt.ts with the ledger it reads and writes; re-exported so the
294
+ // orchestrator and the harnesses under scripts/ keep working unchanged.
295
+ export { deriveOpenDebts, rerunDebtVerifyCommand };
1304
296
  /**
1305
297
  * The run-level closure scans, in emission order within their stage.
1306
298
  *
@@ -1393,51 +385,27 @@ function runClosureScans(stage, input, fail, scans = CLOSURE_SCANS) {
1393
385
  }
1394
386
  }
1395
387
  export { CLOSURE_SCANS, runClosureScans };
1396
- /**
1397
- * Run the final gate: static analysis first, then the lockfile consistency
1398
- * checks, then the discovered integration commands, then one boot exercise of
1399
- * the start command — whole-repo, verbatim, unaided. Deterministic (no model).
1400
- *
1401
- * EVERY section runs and failures AGGREGATE (mx5 run 13): the gate used to
1402
- * early-return on the first failing section, and the boot + render probe — built
1403
- * after run 11 exactly for "app serves blank/nothing" — was ordered last, so any
1404
- * earlier failure shadowed the most load-bearing signal. Run 13's user accepted
1405
- * the FAIL having seen only 1 failing CT test while the app 404'd on every
1406
- * non-API GET; boot/render never executed in any attempt. Now the outcome
1407
- * carries the full ranked failure list (boot/render first — "the app does not
1408
- * serve/render" outranks any single test), the ACCEPT decision is made on all of
1409
- * it, and autofix converges only when the whole list is empty. Per-section
1410
- * env-gap/INFRA_GAP skip semantics and orphan-port recovery are unchanged.
1411
- */
1412
- export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGraceMs = 10_000, bootDeps = {}, planText) {
388
+ export async function runFinalIntegrationGate(cwd, opts = {}) {
389
+ const { timeoutMs = 900_000, bootGraceMs = 10_000, bootDeps = {}, planText, run: runCmd = spawnCommand, envClosure = (c) => {
390
+ try {
391
+ return scanEnvTemplateClosure(c);
392
+ }
393
+ catch {
394
+ return inertClosure();
395
+ }
396
+ }, trackedFiles: trackedFilesFn = trackedFiles } = opts;
1413
397
  const stat = runRepoHealthCheck(cwd);
1414
- const { openDebts, debtNote } = await deriveOpenDebts(cwd, stat.ok);
1415
- // The debt note rides in its OWN field: `reason` stays the mechanical failure
1416
- // because it seeds the autofix child's prompt (see FinalGateOutcome.reason
1417
- // run 11's fix child executed a recorded claim as an instruction).
1418
- const withDebts = (o) => ({
1419
- ...o,
1420
- ...(debtNote ? { debtNote } : {}),
1421
- openDebts
1422
- });
1423
- // Aggregated failures across ALL sections (mx5 run 13 — see the function doc).
1424
- // rank 0 = boot/render ("does not serve/render" is the most load-bearing
1425
- // signal); rank 1 = everything else, kept in execution order by stable sort.
1426
- const failures = [];
1427
- const fail = (text, rank = 1) => {
1428
- failures.push({ rank, text });
1429
- };
1430
- /**
1431
- * A failure a PROBE returned after observing (nexttask 19A — see
1432
- * FinalGateOutcome.observedFailures). Used by exactly one caller: the boot
1433
- * section, whose `fail` outcome can only arise from a probe that looked. Every
1434
- * other `fail()` keeps today's class, so nothing else changes.
1435
- */
1436
- const failObserved = (text, rank = 1) => {
1437
- failures.push({ rank, text, observed: true });
1438
- };
398
+ // Debts are derived once, before any section runs, and ride on every verdict
399
+ // shape (GateTally.verdict): `reason` stays the mechanical failure because it
400
+ // seeds the autofix child's prompt run 11's fix child executed a recorded
401
+ // claim as an instruction.
402
+ const debts = await deriveOpenDebts(cwd, stat.ok);
403
+ // Every section below RECORDS into the tally (failures ranked, the four
404
+ // dynamic counters, the notes) and the verdict is assembled ONCE at the end —
405
+ // see gate-tally.ts for what each method means.
406
+ const tally = new GateTally();
1439
407
  if (!stat.ok)
1440
- fail(`static checks: ${stat.reason}`);
408
+ tally.fail(`static checks: ${stat.reason}`);
1441
409
  // Launch-contract diff (mx5 run 10 item 4): the design declared `migrate`/`seed`
1442
410
  // scripts that fell through decompose and shipped missing, unchecked. Diff the
1443
411
  // plan-time-extracted declared scripts against the manifest; a missing one is a
@@ -1453,73 +421,43 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
1453
421
  // readLaunchManifest resolves package.json, else a Makefile's targets, else
1454
422
  // nothing — and nothing means no failure plus a note, never a silent pass.
1455
423
  const declared = await readDeclaredScripts(cwd);
1456
- const contractNotes = [];
1457
424
  if (declared.length > 0) {
1458
425
  const manifest = readLaunchManifest(cwd);
1459
426
  if (manifest.kind === 'none') {
1460
- contractNotes.push(inertLaunchContractNote(declared, manifest));
427
+ tally.contractNote(inertLaunchContractNote(declared, manifest));
1461
428
  }
1462
429
  else {
1463
430
  const missing = missingDeclaredScripts(declared, manifest.names);
1464
431
  if (missing.length > 0) {
1465
- fail(`launch contract: the design declares script(s) the shipped ${manifest.file} does not expose: ${missing.join(', ')} (declared: ${declared.join(', ')})`);
432
+ tally.fail(`launch contract: the design declares script(s) the shipped ${manifest.file} does not expose: ${missing.join(', ')} (declared: ${declared.join(', ')})`);
1466
433
  }
1467
434
  }
1468
435
  }
1469
436
  // Run-level closure scans that must be decided BEFORE the zero-discovery early
1470
437
  // return below — a static check needs no runner (CLOSURE_SCANS: 'pre-discovery').
1471
- runClosureScans('pre-discovery', { cwd, planText }, fail);
438
+ runClosureScans('pre-discovery', { cwd, planText }, (t, r) => tally.fail(t, r));
1472
439
  const lockCmds = discoverLockfileChecks(cwd);
1473
440
  const { cmds } = discoverIntegrationCommands(cwd);
1474
441
  const boot = discoverBootCommand(cwd);
1475
- // ZERO DISCOVERY IS UNOBSERVED, NEVER A PASS (see unobservedVerdict). Nothing was
1476
- // discovered, so nothing ran, so observabilityGapFailure (attempted === 0 → null) does
1477
- // not fire — and this outcome used to be reported as `PASS — no integration command
1478
- // found (statics passed)`, i.e. "we never checked" reading identically to "we checked
1479
- // and it was fine". IAR1 shipped that verdict TWICE while carrying open verify-FAIL
1480
- // debt (its .pi-tasks/TASK_AUTO_0001.md:31 and TASK_AUTO_0002.md:37). The outcome stays
1481
- // `ok: true` (non-blocking, justified at unobservedVerdict) but is now labelled, trailed
1482
- // and carried as debt by the caller. It needs no new command source, so unlike the
1483
- // harvest lever refuted at discoverIntegrationCommands it cannot inject a fabricated
1484
- // failure.
1485
- if (lockCmds.length === 0 && cmds.length === 0 && !boot && failures.length === 0) {
1486
- // The inert-contract note rides here too: a non-npm project carrying a launch
1487
- // contract usually discovers no command either, and that is exactly the run
1488
- // whose silence must not read as "the contract was checked and was fine".
1489
- const note = [unobservedVerdict({ discovered: 0, observed: 0 }) ?? '', ...contractNotes]
1490
- .filter(n => n !== '')
1491
- .join(' ');
1492
- return withDebts({ ok: true, unobserved: note, reason: note });
1493
- }
1494
- const ran = [];
1495
- // Full-skip blindness counters (mx5 run 16): every dynamic spawn counts an
1496
- // attempt; a real pass OR a real fail counts an observation; skips observe
1497
- // nothing. If everything discovered ends up skipped, observabilityGapFailure
1498
- // turns the silence into a rank-0 failure instead of a static-only PASS.
1499
- let dynAttempted = 0;
1500
- let dynObserved = 0;
1501
- let dynSpawnFailures = 0;
1502
- const dynBins = new Set();
1503
442
  for (const { prefix, list } of [
1504
443
  { prefix: 'lockfile check: ', list: lockCmds },
1505
444
  { prefix: '', list: cmds }
1506
445
  ]) {
1507
446
  for (const cmd of list) {
1508
447
  const label = `${cmd[0]} ${cmd[1].join(' ')}`;
1509
- dynAttempted += 1;
1510
- dynBins.add(cmd[0]);
1511
- const r = runGateCommand(cwd, cmd, timeoutMs);
448
+ tally.attempted(cmd[0]);
449
+ const r = runGateCommand(cwd, cmd, timeoutMs, undefined, undefined, runCmd);
1512
450
  if (r.outcome === 'skip') {
1513
451
  if (r.spawnFailed)
1514
- dynSpawnFailures += 1;
452
+ tally.spawnFailure(cmd[0]);
1515
453
  continue;
1516
454
  }
1517
- dynObserved += 1;
455
+ tally.observed();
1518
456
  if (r.outcome === 'fail') {
1519
- fail(`${prefix}\`${label}\` exited ${r.status}${r.tail ? ` — ${r.tail}` : ''}`);
457
+ tally.fail(`${prefix}\`${label}\` exited ${r.status}${r.tail ? ` — ${r.tail}` : ''}`);
1520
458
  continue;
1521
459
  }
1522
- ran.push(label);
460
+ tally.ran(label);
1523
461
  }
1524
462
  }
1525
463
  // EXECUTE the launch contract (mx5 run 11): every declared script that is
@@ -1532,12 +470,6 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
1532
470
  // note (F7) is surfaced as an UNOBSERVED warning — the note may be covering a
1533
471
  // real defect the gate could not reach here (run 11's "pre-existing .rows
1534
472
  // bug" note excused the exact scripts that shipped broken).
1535
- const warnings = [];
1536
- /** UNOBSERVED notes for launch scripts reclassified as CONFIG GAPS (run 20).
1537
- * They ride in `unobserved`, not `warnings`, so the caller's existing
1538
- * `recordDebt(cwd, id, fin.unobserved, 'final-gate')` writes the debt —
1539
- * never a PASS. */
1540
- const configGapNotes = [];
1541
473
  if (declared.length > 0) {
1542
474
  const covered = cmds.flatMap(([bin, args]) => (bin === 'bun' || bin === 'npm') && args[0] === 'run' && args[1] ? [args[1]] : []);
1543
475
  const skippedLaunch = [];
@@ -1555,31 +487,23 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
1555
487
  // union of every tracked env template's declared variables. Both empty on a
1556
488
  // non-git tree or a tree with no template, which makes the whole check inert
1557
489
  // — a project with no template gains no excuse. See launch-config-gap.ts.
1558
- const closure = (() => {
1559
- try {
1560
- return scanEnvTemplateClosure(cwd);
1561
- }
1562
- catch {
1563
- return inertClosure();
1564
- }
1565
- })();
1566
- const trackedForGap = closure.templates.length > 0 ? (trackedFiles(cwd) ?? []) : [];
490
+ const closure = envClosure(cwd);
491
+ const trackedForGap = closure.templates.length > 0 ? (trackedFilesFn(cwd) ?? []) : [];
1567
492
  const launchTimeout = Math.min(timeoutMs, 180_000);
1568
493
  for (const name of runnableDeclaredScripts(declared, covered)) {
1569
494
  if (!present.has(name.toLowerCase()))
1570
495
  continue;
1571
496
  const cmd = ['bun', ['run', name]];
1572
497
  const label = `${cmd[0]} ${cmd[1].join(' ')}`;
1573
- dynAttempted += 1;
1574
- dynBins.add(cmd[0]);
1575
- const r = runGateCommand(cwd, cmd, launchTimeout, INFRA_GAP_OUTPUT_RE);
498
+ tally.attempted(cmd[0]);
499
+ const r = runGateCommand(cwd, cmd, launchTimeout, INFRA_GAP_OUTPUT_RE, undefined, runCmd);
1576
500
  if (r.outcome === 'skip') {
1577
501
  if (r.spawnFailed)
1578
- dynSpawnFailures += 1;
502
+ tally.spawnFailure(cmd[0]);
1579
503
  skippedLaunch.push(name);
1580
504
  continue;
1581
505
  }
1582
- dynObserved += 1;
506
+ tally.observed();
1583
507
  if (r.outcome === 'fail') {
1584
508
  // A CONFIG GAP IS NOT A CODE FAULT (mx5 run 20). The run died on
1585
509
  // `bun run seed` exiting 1 because ADMIN_PHONE — which the project's
@@ -1601,21 +525,21 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
1601
525
  env: process.env
1602
526
  });
1603
527
  if (gap) {
1604
- const probe = runGateCommand(cwd, cmd, launchTimeout, INFRA_GAP_OUTPUT_RE, probeEnv(runnerEnv(resolveRunner(cmd[0])), gap));
528
+ const probe = runGateCommand(cwd, cmd, launchTimeout, INFRA_GAP_OUTPUT_RE, probeEnv(runnerEnv(resolveRunner(cmd[0])), gap), runCmd);
1605
529
  if (probe.outcome === 'pass') {
1606
530
  // Nothing about this script was OBSERVED: the real run could
1607
531
  // not reach it and the probe run is a diagnostic, never an
1608
532
  // observation. So it un-counts, exactly like a skip.
1609
- dynObserved -= 1;
533
+ tally.unobserve();
1610
534
  skippedLaunch.push(name);
1611
- configGapNotes.push(configGapUnobservedNote(gap));
535
+ tally.configGap(configGapUnobservedNote(gap));
1612
536
  continue;
1613
537
  }
1614
538
  }
1615
- fail(`launch script: \`${label}\` exited ${r.status}${r.tail ? ` — ${r.tail}` : ''}`);
539
+ tally.fail(`launch script: \`${label}\` exited ${r.status}${r.tail ? ` — ${r.tail}` : ''}`);
1616
540
  continue;
1617
541
  }
1618
- ran.push(label);
542
+ tally.ran(label);
1619
543
  }
1620
544
  if (skippedLaunch.length > 0) {
1621
545
  const notes = parseEnvNotes(await readEnvNotes(cwd)).filter(n => isExcuseNote(n.fact));
@@ -1623,7 +547,7 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
1623
547
  const re = new RegExp(`\\b${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i');
1624
548
  const excuse = notes.find(n => re.test(n.fact));
1625
549
  if (excuse) {
1626
- warnings.push(`launch script \`${name}\` could not run here (environment gap) and a `
550
+ tally.warn(`launch script \`${name}\` could not run here (environment gap) and a `
1627
551
  + `standing excuse note covers it ("${excuse.fact.slice(0, 160)}") — `
1628
552
  + `UNOBSERVED: verify it by hand before trusting the launch surface`);
1629
553
  }
@@ -1634,13 +558,36 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
1634
558
  // construction, and it carries the run's most load-bearing signal — earlier
1635
559
  // failures no longer shadow it. Its failures rank FIRST in the aggregate.
1636
560
  // A boot that never RAN is its own verdict (mx5 run 18 — see bootSkipVerdict);
1637
- // it lives outside the dynObserved counters on purpose, so the test/build
561
+ // it lives outside the tally's dynamic counters on purpose, so the test/build
1638
562
  // commands that did run cannot cancel it.
1639
- let bootUnobserved = null;
563
+ //
564
+ // ZERO DISCOVERY IS UNOBSERVED, NEVER A PASS (see unobservedVerdict, and the
565
+ // zero-attempts branch of GateTally.verdict). Nothing was discovered, so nothing
566
+ // ran, so the blindness guard below (attempted === 0 → null) does not fire — and
567
+ // this outcome used to be reported as `PASS — no integration command found
568
+ // (statics passed)`, i.e. "we never checked" reading identically to "we checked
569
+ // and it was fine". IAR1 shipped that verdict TWICE while carrying open
570
+ // verify-FAIL debt (its .pi-tasks/TASK_AUTO_0001.md:31 and TASK_AUTO_0002.md:37).
571
+ // The outcome stays `ok: true` (non-blocking, justified at unobservedVerdict) but
572
+ // is labelled, trailed and carried as debt by the caller. It needs no new command
573
+ // source, so unlike the harvest lever refuted at discoverIntegrationCommands it
574
+ // cannot inject a fabricated failure. The inert-contract note rides on it too: a
575
+ // non-npm project carrying a launch contract usually discovers no command either,
576
+ // and that is exactly the run whose silence must not read as "the contract was
577
+ // checked and was fine".
578
+ //
579
+ // This return sits AFTER the launch-script loop, not before it: it used to fire
580
+ // first, so a DECLARED launch script never ran on a tree with no discoverable
581
+ // integration command (found and left unfixed in f5d7110). "Nothing to observe"
582
+ // is a fact about the tally — no attempt, no failure — not about discovery, and
583
+ // asking the tally makes the two paths see the same state. It still returns
584
+ // before the boot `else` branch and the post-boot closure scans, whose stage is a
585
+ // statement about when they are meaningful.
586
+ if (!boot && tally.silent())
587
+ return tally.verdict(debts);
1640
588
  if (boot) {
1641
589
  const label = `${boot[0]} ${boot[1].join(' ')}`;
1642
- dynAttempted += 1;
1643
- dynBins.add(boot[0]);
590
+ tally.attempted(boot[0]);
1644
591
  const expectServer = detectsServedApp(cwd, planText);
1645
592
  // Render check (mx5 runs 8/11): for a served app, load the live page in a
1646
593
  // headless browser and judge the RENDERED DOM — curl can't run JS, so a
@@ -1669,14 +616,14 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
1669
616
  b = await recoverOrphanPort(cwd, boot, b, bootGraceMs, bootDepsWithRender, expectServer);
1670
617
  }
1671
618
  if (b.outcome !== 'skip')
1672
- dynObserved += 1;
619
+ tally.observed();
1673
620
  else if (b.spawnFailed)
1674
- dynSpawnFailures += 1;
1675
- bootUnobserved = bootSkipVerdict({
621
+ tally.spawnFailure(boot[0]);
622
+ tally.bootUnobserved(bootSkipVerdict({
1676
623
  label,
1677
624
  skipped: b.outcome === 'skip',
1678
625
  expectServer
1679
- });
626
+ }));
1680
627
  if (b.outcome === 'fail') {
1681
628
  // OBSERVED (nexttask 19A). Every path that produces `fail` here is a
1682
629
  // probe that looked: the render judge saw an empty body, the deep
@@ -1685,7 +632,7 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
1685
632
  // condition that means "we could not look" — no ss/netstat/lsof, mx5
1686
633
  // run 14 — returns PASS stamped UNOBSERVED and never reaches here
1687
634
  // (`b0f90a7`, final-gate.ts `if (!canEnumerate) return passAndKill(…)`).
1688
- failObserved(`boot check: \`${label}\` ${b.detail}`, 0);
635
+ tally.failObserved(`boot check: \`${label}\` ${b.detail}`, 0);
1689
636
  }
1690
637
  else if (b.outcome === 'orphan-port') {
1691
638
  // Could not clear the port. Distinct HARNESS diagnosis, never a bare app
@@ -1694,14 +641,14 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
1694
641
  const who = holder ? ` — held by an orphaned process (pid ${holder.pid}: ${holder.command})`
1695
642
  : b.port !== null ? ` — port ${b.port} is held by another process`
1696
643
  : '';
1697
- fail(`boot check: \`${label}\` could not bind: orphaned process / port already in use${who} (harness condition, not an app fault)`, 0);
644
+ tally.fail(`boot check: \`${label}\` could not bind: orphaned process / port already in use${who} (harness condition, not an app fault)`, 0);
1698
645
  }
1699
646
  else if (b.outcome === 'pass') {
1700
- ran.push(label);
647
+ tally.ran(label);
1701
648
  // A listener that served, but whose page could not be OBSERVED to render
1702
649
  // (no browser, undeterminable port) → UNOBSERVED warning, not a silent pass.
1703
650
  if (b.renderNote)
1704
- warnings.push(b.renderNote);
651
+ tally.warn(b.renderNote);
1705
652
  }
1706
653
  }
1707
654
  else {
@@ -1710,77 +657,22 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
1710
657
  // project with no launch surface, and it must not degrade into silence.
1711
658
  const rejected = rejectedLaunchScript(cwd);
1712
659
  if (rejected && detectsServedApp(cwd, planText)) {
1713
- bootUnobserved =
1714
- `boot check: this project's only launch script (\`${rejected.name}\`) is not a `
1715
- + `launch ${rejected.reason} — so nothing was started and the app was never `
1716
- + 'observed to run.';
660
+ tally.bootUnobserved(`boot check: this project's only launch script (\`${rejected.name}\`) is not a `
661
+ + `launch ${rejected.reason} so nothing was started and the app was never `
662
+ + 'observed to run.');
1717
663
  }
1718
664
  }
1719
665
  // Full-skip blindness guard (mx5 run 16): commands were discovered but every
1720
666
  // one skipped → rank-0 failure, never a static-only PASS. Runner resolvability
1721
667
  // is checked through resolveRunner so the failure text can name the missing
1722
668
  // runner when that is the cause (the run-16 shape: login-shell PATH lost bun).
1723
- const gap = observabilityGapFailure({
1724
- attempted: dynAttempted,
1725
- observed: dynObserved,
1726
- spawnFailures: dynSpawnFailures,
1727
- runnerBins: [...dynBins],
1728
- runnerResolvable: b => resolveRunner(b).ok
1729
- });
669
+ const gap = tally.blindness(b => resolveRunner(b).ok);
1730
670
  if (gap)
1731
- fail(gap, 0);
671
+ tally.fail(gap, 0);
1732
672
  // The remaining run-level closure scans — "the shipped tree references or
1733
673
  // requires something it does not contain" — after every dynamic section, so
1734
674
  // their failures keep their historical place in the aggregate (CLOSURE_SCANS:
1735
675
  // 'post-boot').
1736
- runClosureScans('post-boot', { cwd, planText }, fail);
1737
- if (failures.length > 0) {
1738
- // Stable sort: boot/render (rank 0) leads, everything else keeps execution
1739
- // order. One failure keeps the exact single-failure wording; several become
1740
- // a numbered list so the trail, the ACCEPT picker, and the autofix seed all
1741
- // carry the complete ranked picture.
1742
- const ranked = [...failures].sort((a, b) => a.rank - b.rank);
1743
- const texts = ranked.map(f => f.text);
1744
- // The observed subset rides along by exact text identity (19A) — the demote
1745
- // decision downstream reads THIS, instead of re-deriving observability from
1746
- // the failure string.
1747
- const observed = ranked.filter(f => f.observed === true).map(f => f.text);
1748
- return withDebts({
1749
- ok: false,
1750
- reason: texts.length === 1 ?
1751
- texts[0]
1752
- : `${texts.length} failures (ranked, most load-bearing first):\n${texts
1753
- .map((t, i) => `${i + 1}. ${t}`)
1754
- .join('\n')}`,
1755
- failures: texts,
1756
- ...(observed.length > 0 ? { observedFailures: observed } : {})
1757
- });
1758
- }
1759
- const warningNote = warnings.length > 0 ? ` — WARNING: ${warnings.join('; WARNING: ')}` : '';
1760
- // The same three-way verdict at the other zero-observation door: commands WERE
1761
- // discovered, none spawn-failed (so the run-16 guard correctly stayed silent — every
1762
- // skip was a tool-level env gap), and yet nothing ran. That was `statics passed
1763
- // (integration commands not runnable here)`, which is the identical "we never checked"
1764
- // silence wearing different words. Unchanged when anything at all was observed, so a
1765
- // project with runnable commands is byte-for-byte unaffected.
1766
- // Two independent UNOBSERVED notes, either or both of which may apply: the boot
1767
- // never ran (run 18), and/or NOTHING dynamic ran at all. The boot note leads
1768
- // because it names a concrete command and the trail line is sliced at 300 chars.
1769
- const unobserved = [
1770
- bootUnobserved,
1771
- unobservedVerdict({ discovered: dynAttempted, observed: dynObserved }),
1772
- ...configGapNotes,
1773
- ...contractNotes
1774
- ]
1775
- .filter(n => n !== null)
1776
- .join(' ');
1777
- return withDebts({
1778
- ok: true,
1779
- ...(unobserved ? { unobserved } : {}),
1780
- reason: (unobserved ? `${unobserved} — ` : '')
1781
- + (ran.length > 0 ?
1782
- `statics + ${ran.map(c => `\`${c}\``).join(', ')} passed`
1783
- : 'statics passed (integration commands not runnable here)')
1784
- + warningNote
1785
- });
676
+ runClosureScans('post-boot', { cwd, planText }, (t, r) => tally.fail(t, r));
677
+ return tally.verdict(debts);
1786
678
  }