@mjasnikovs/pi-task 0.38.11 → 0.38.13

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 (59) hide show
  1. package/README.md +8 -5
  2. package/dist/config/config.d.ts +0 -1
  3. package/dist/config/config.js +0 -1
  4. package/dist/config/register.js +0 -2
  5. package/dist/index.js +0 -2
  6. package/dist/shared/child-process.d.ts +8 -0
  7. package/dist/shared/command-watchdog.d.ts +1 -1
  8. package/dist/shared/command-watchdog.js +1 -1
  9. package/dist/task/accept-debt.d.ts +47 -0
  10. package/dist/task/accept-debt.js +127 -28
  11. package/dist/task/auto-orchestrator.js +91 -114
  12. package/dist/task/child-runner.d.ts +39 -25
  13. package/dist/task/child-runner.js +59 -31
  14. package/dist/task/child-status.d.ts +95 -0
  15. package/dist/task/child-status.js +99 -0
  16. package/dist/task/command-run.d.ts +36 -0
  17. package/dist/task/command-run.js +48 -1
  18. package/dist/task/command-watchdog.js +1 -1
  19. package/dist/task/context-usage.d.ts +4 -3
  20. package/dist/task/context-usage.js +4 -3
  21. package/dist/task/contracts.js +18 -35
  22. package/dist/task/deep-render-check.d.ts +47 -0
  23. package/dist/task/deep-render-check.js +110 -65
  24. package/dist/task/env-notes.d.ts +3 -3
  25. package/dist/task/env-notes.js +24 -35
  26. package/dist/task/final-gate-fix.d.ts +1 -1
  27. package/dist/task/final-gate-fix.js +1 -1
  28. package/dist/task/final-gate.d.ts +5 -151
  29. package/dist/task/final-gate.js +81 -379
  30. package/dist/task/gate-child.d.ts +8 -10
  31. package/dist/task/gate-child.js +15 -19
  32. package/dist/task/gate-deps.d.ts +29 -0
  33. package/dist/task/gate-deps.js +192 -206
  34. package/dist/task/gate-tally.d.ts +189 -0
  35. package/dist/task/gate-tally.js +249 -0
  36. package/dist/task/implementation-turn.d.ts +201 -0
  37. package/dist/task/implementation-turn.js +263 -0
  38. package/dist/task/launch-contract.js +27 -43
  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 +67 -294
  45. package/dist/task/plan-orchestrator.js +34 -33
  46. package/dist/task/requirements.d.ts +1 -1
  47. package/dist/task/requirements.js +50 -66
  48. package/dist/task/root-cause-repair.js +20 -32
  49. package/dist/task/run-bracket.d.ts +75 -0
  50. package/dist/task/run-bracket.js +41 -0
  51. package/dist/task/stall-detector.d.ts +110 -0
  52. package/dist/task/stall-detector.js +159 -0
  53. package/dist/task/verify-work.d.ts +53 -67
  54. package/dist/task/verify-work.js +15 -11
  55. package/dist/workers/single-read-extension.d.ts +1 -1
  56. package/dist/workers/single-read-extension.js +5 -4
  57. package/dist/workers/single-read-guard.d.ts +32 -10
  58. package/dist/workers/single-read-guard.js +67 -16
  59. package/package.json +1 -1
@@ -48,7 +48,7 @@
48
48
  import { existsSync, readFileSync } from 'node:fs';
49
49
  import * as path from 'node:path';
50
50
  import { runRepoHealthCheck, discoverHealthCommands } from './repo-health-check.js';
51
- import { readAcceptDebts, recheckAcceptDebts, writeAcceptDebts, buildAcceptDebtNote, annotateDebtConflicts } from './accept-debt.js';
51
+ import { deriveOpenDebts, rerunDebtVerifyCommand } from './accept-debt.js';
52
52
  import { readDeclaredScripts, missingDeclaredScripts, runnableDeclaredScripts } from './launch-contract.js';
53
53
  import { readLaunchManifest, inertLaunchContractNote, packageScripts, makeHasTarget } from './launch-manifest.js';
54
54
  import { discoverBootCommand, detectsServedApp, runBootCheck, bootSkipVerdict, nonLaunchScriptReason, rejectedLaunchScript, parseSsListeners, parseNetstatListeners, parseLsofListeners, pickFreePort, preferredDeclaredPort, canEnumerateListeners, recoverOrphanPort, defaultFindPortHolder } from './boot-probe.js';
@@ -63,6 +63,7 @@ import { findDanglingArtifacts, danglingGateFailureText } from './artifact-closu
63
63
  import { findMissingEnvDeclarations, envGateFailureText, scanEnvTemplateClosure, inertClosure, trackedFiles } from './env-template-closure.js';
64
64
  import { findMissingServeEntry, serveEntryGateFailureText } from './serve-entry.js';
65
65
  import { makefileRecipe } from './command-shrink.js';
66
+ import { GateTally, observabilityGapFailure, unobservedVerdict } from './gate-tally.js';
66
67
  /**
67
68
  * The project's OWN whole-repo integration commands (test, then build — test
68
69
  * first because it is the richer signal and the more common script). First
@@ -272,127 +273,13 @@ run = spawnCommand) {
272
273
  }
273
274
  return verdict;
274
275
  }
275
- /** The command word of a shell line, past any leading `VAR=value` assignments. */
276
- function leadingBin(line) {
277
- for (const tok of line.trim().split(/\s+/)) {
278
- if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(tok))
279
- continue;
280
- return tok;
281
- }
282
- return null;
283
- }
284
- /**
285
- * Re-run one VERIFY-block command line (nexttask 5) under the gate's existing
286
- * env-gap contract, so a debt whose reason NAMES that command can be closed by the
287
- * command itself rather than by a judgement about it.
288
- *
289
- * Runs through `sh -c` because a VERIFY line is a shell line, not an argv: run 19's
290
- * is `AGENT=1 bun test test/listings.test.ts`, and env prefixes, `&&` and redirects
291
- * are all ordinary there. The leading command word is still resolved through
292
- * runner-resolve so a login-shell-stripped PATH cannot make every re-run look like a
293
- * gap (mx5 run 16's blindness, one level down).
294
- *
295
- * The asymmetry is the point: only exit 0 is conclusive. Every other ending — real
296
- * failure, missing tool, unreachable database, timeout, no POSIX shell — leaves the
297
- * debt exactly as open as it was.
298
- */
299
- export function runVerifyCommandLine(cwd, line, timeoutMs, extraGapRe,
300
- /** The spawner. Injected so a re-run's outcome can be tested without one. */
301
- run = spawnCommand) {
302
- const bin = leadingBin(line);
303
- const runner = bin === null ? null : resolveRunner(bin);
304
- // A VERIFY line is a SHELL line, not an argv — env prefixes, `&&` and
305
- // redirects are all ordinary there — so the runner spawns `sh -c`.
306
- const verdict = classifyCommandRun(run({
307
- cwd,
308
- bin: 'sh',
309
- args: ['-c', line],
310
- timeoutMs,
311
- env: runner ? runnerEnv(runner) : { ...process.env }
312
- }),
313
- // Infrastructure counts as a gap on EVERY debt re-run, not only on
314
- // request: an unreachable database cannot tell us whether the code is
315
- // fixed, and the asymmetry below means an inconclusive re-run simply
316
- // leaves the debt as open as it was.
317
- extraGapRe ? [INFRA_GAP_OUTPUT_RE, extraGapRe] : [INFRA_GAP_OUTPUT_RE]);
318
- if (verdict.outcome === 'gap')
319
- return { outcome: 'gap', detail: verdict.detail };
320
- return verdict;
321
- }
322
- /**
323
- * The full-skip blindness guard (mx5 run 16, validated): dynamic commands were
324
- * DISCOVERED but every single one skipped as an environment gap, so the gate
325
- * decided on statics alone and stamped a permanently blank app green. Per-command
326
- * env-gap skips stay legitimate (a missing browser must not fail a suite); what
327
- * may never happen again is ALL of them skipping while the gate still reports
328
- * PASS — a gate that observed nothing dynamic has no basis to vouch for the
329
- * assembled app. Pure so the semantics are unit-tested; the caller feeds it the
330
- * attempt/observation counters and runner resolvability.
331
- */
332
- export function observabilityGapFailure(args) {
333
- if (args.attempted === 0 || args.observed > 0)
334
- return null;
335
- if (args.spawnFailures < args.attempted)
336
- return null;
337
- const unresolvable = args.runnerBins.filter(b => !args.runnerResolvable(b));
338
- const runnerNote = unresolvable.length > 0 ?
339
- ` — the project's own runner ${unresolvable
340
- .map(b => `\`${b}\``)
341
- .join(', ')} is not spawnable here (not on PATH nor any known install location)`
342
- : '';
343
- return (`observability gap: ${args.attempted} integration/boot command(s) exist but NONE `
344
- + `could even spawn in this environment${runnerNote}; `
345
- + `the gate observed nothing dynamic and cannot vouch for the assembled app`);
346
- }
347
- /**
348
- * The THIRD verdict. observabilityGapFailure above covers "commands were DISCOVERED
349
- * but every one failed to spawn" — a rank-0 FAIL. It deliberately returns null for
350
- * `attempted === 0`, and until now that silence fell straight through to
351
- * `PASS — no integration command found (statics passed)`: the run-16 blindness class
352
- * entering through a different door, where "we never checked" reads exactly like "we
353
- * checked and it was fine". Measured 2026-07-27: IAR1 (C++/CMake, no package.json)
354
- * shipped that verdict TWICE while carrying 2 and 3 open verify-FAIL debts, and
355
- * godot-engine (package.json whose only script is `verify`) reproduces it live today.
356
- *
357
- * So: observed anything dynamic ⇒ PASS; discovered-but-all-spawn-failed ⇒ the
358
- * existing FAIL; observed NOTHING ⇒ this note, carried on an `ok: true` outcome.
359
- *
360
- * WHY NON-BLOCKING (decided, not deferred — the evidence cuts both ways and this is
361
- * the resolution):
362
- * - Blocking's case: both real occurrences also carried open verify-FAIL debt, so
363
- * the runs with no dynamic evidence were exactly the runs already known to be
364
- * carrying defects.
365
- * - Against, and decisive: (1) that debt is ALREADY surfaced unconditionally at the
366
- * gate moment, on PASS as on FAIL — the IAR1 records literally read "PASS — no
367
- * integration command found … UNRESOLVED VERIFY-FAIL DEBT still open (2)". The
368
- * missing signal was never the debt, it was the word PASS endorsing the run, and
369
- * that is what this fixes. (2) `ok: false` routes into the autofix picker, whose
370
- * seed is `reason`; "no integration command is discoverable" is not fixable by
371
- * editing code, so the highest-probability child response is to FABRICATE a
372
- * runnable command to satisfy the gate — the same fabrication class that refuted
373
- * the `## verified tooling` harvest (see discoverIntegrationCommands) and that had
374
- * run 11's fix child `rm` a sibling's deliverable. (3) That harvest being refuted
375
- * means IAR1 and godot-engine can NEVER discover a command, so blocking would end
376
- * every non-npm run in `failed` permanently, with no remedy — the task's own I3
377
- * ("show blocking does not block IAR1/godot post-Task-1") is unsatisfiable, and
378
- * its stated consequence is to downgrade to a warning and say so. This is that.
379
- * The teeth are elsewhere and are real: the verdict word changes, the gate trail says
380
- * UNOBSERVED, and the caller records a durable final-gate debt that the NEXT run's
381
- * gate re-surfaces (it can never auto-close — it is not static-class).
382
- */
383
- export function unobservedVerdict(args) {
384
- if (args.observed > 0)
385
- return null;
386
- // Kept short ON PURPOSE: the run-level trail line slices the reason at 300 chars,
387
- // and the whole point of this verdict is that the durable record carries it.
388
- const why = args.discovered === 0 ?
389
- 'no integration, lockfile or boot command was discoverable here, so the gate ran '
390
- + 'nothing at all'
391
- : `all ${args.discovered} discovered command(s) skipped as environment gaps, so the `
392
- + 'gate ran nothing observable';
393
- return (`UNOBSERVED — NOT a pass: ${why}; statics passed, but this run produced NO evidence `
394
- + 'that the assembled product builds, boots or works.');
395
- }
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 };
396
283
  // File → introducing-task provenance moved to task-provenance.ts (mx5 run-12
397
284
  // PROMPT 2 extracted it for the cross-task deletion guards); re-exported so
398
285
  // existing importers keep working.
@@ -402,110 +289,10 @@ export { taskThatIntroduced };
402
289
  // always imported exactly this surface and nothing else from the gate — keep
403
290
  // working unchanged. Same pattern as taskThatIntroduced above.
404
291
  export { discoverBootCommand, detectsServedApp, runBootCheck, bootSkipVerdict, nonLaunchScriptReason, rejectedLaunchScript, parseSsListeners, parseNetstatListeners, parseLsofListeners, pickFreePort, preferredDeclaredPort, canEnumerateListeners };
405
- /**
406
- * ACCEPT-debt re-check (mx5 run 4 B3 / run 8 TASK_0012): read the ledger of tasks
407
- * the user accepted despite a verify-FAIL and re-check each against the CURRENT
408
- * tree. A static-class debt whose statics now pass is provably RESOLVED (a later
409
- * task fixed it) and pruned from the ledger; every other debt cannot be proven
410
- * resolved deterministically, so it stays OPEN and is surfaced — a run may not
411
- * complete silently carrying an accepted defect. FP-safe by construction (see
412
- * accept-debt.ts). Best-effort: a ledger read/write failure must never break the
413
- * caller.
414
- *
415
- * FACTORED OUT of runFinalIntegrationGate (nexttask 6): the derivation has to be
416
- * runnable at a SECOND moment — after a converged final-gate autofix, where the
417
- * orchestrator used to rebuild its gate outcome as a bare `{ok, reason}` and drop
418
- * `openDebts` entirely. The report a run ends on has to be derived from the tree
419
- * the run ends with, not from the tree as it was before the fix pass.
420
- *
421
- * `staticOk` is the caller's claim about the CURRENT statics, and it is the only
422
- * thing that can auto-close a static-class debt — so a caller that does not know
423
- * must pass `false` (unprovable ⇒ stays open), never a guess.
424
- */
425
- export async function deriveOpenDebts(cwd, staticOk) {
426
- const { open: openRaw, resolved, trail } = recheckAcceptDebts(await readAcceptDebts(cwd), {
427
- staticOk,
428
- // Cross-task-deletion debts auto-close iff the deleted file is back in the
429
- // tree — a deterministic existence check, corroborating the per-file
430
- // provenance the record already carries.
431
- fileExists: rel => existsSync(path.join(cwd, rel)),
432
- // VERIFY-COMMAND class (nexttask 5): a debt that NAMES a command is settled
433
- // by running that command, under the gate's own env-gap contract and behind
434
- // the no-write guard below.
435
- rerunVerify: cmd => rerunDebtVerifyCommand(cwd, cmd)
436
- });
437
- if (resolved.length > 0)
438
- await writeAcceptDebts(cwd, openRaw);
439
- // Conflicting-claim annotation (mx5 run 11): an existence-as-failure debt whose
440
- // named file is another task's committed deliverable is a plan defect — surface
441
- // the contradiction with the debt so nobody (human or child) treats the claim as
442
- // a deletion instruction. Pure git-history lookup; degrades to no annotation.
443
- const openDebts = annotateDebtConflicts(openRaw, p => taskThatIntroduced(cwd, p));
444
- const debtNote = buildAcceptDebtNote(openDebts);
445
- return { openDebts, ...(debtNote ? { debtNote } : {}), ...(trail.length > 0 ? { trail } : {}) };
446
- }
447
- /** Per-command ceiling for a debt re-run (`inv-bounded`). */
448
- const DEBT_RERUN_TIMEOUT_MS = 300_000;
449
- /**
450
- * Extra infrastructure-gap shapes recognised ONLY when re-running a debt's command,
451
- * never in the gate's own verdicts. A driver that reports its connection simply
452
- * closed (`ERR_POSTGRES_CONNECTION_CLOSED` — what bun's SQL client says when the
453
- * database is not there at all, as on this box with the mx5 container stopped) is an
454
- * absent dependency, and calling that "the defect is still present" would be a
455
- * finding the environment invented. Kept out of INFRA_GAP_OUTPUT_RE on purpose: in a
456
- * gate verdict the same wording can be a real fault the suite must own, and only the
457
- * debt re-check needs the conservative reading — where it costs nothing, because gap
458
- * and fail both leave the debt open.
459
- */
460
- const DEBT_INFRA_GAP_RE = /ERR_POSTGRES_CONNECTION_CLOSED|ERR_MYSQL_CONNECTION|ECONNRESET/i;
461
- /**
462
- * Re-run ONE debt's stored VERIFY command for the re-check, with the no-write guard
463
- * (`inv-no-write`) wrapped around it.
464
- *
465
- * A VERIFY command is the project's own command and may legitimately write (a build
466
- * emits `dist/`, a suite writes a snapshot). What it may NOT do is turn the tree into
467
- * a passing tree and have that count as the debt being fixed — the run would then be
468
- * certifying its own side effect. So tracked state is captured before and after, and
469
- * a pass that came with a tracked change is downgraded to INCONCLUSIVE with the
470
- * change named. Untracked output is left alone: it is what a build legitimately
471
- * produces, and `git status --porcelain` in a repo with the usual ignores does not
472
- * see it.
473
- *
474
- * A repository the guard cannot read (no git, git absent) is not a licence to skip
475
- * the guard: the re-run is INCONCLUSIVE there, because "nothing changed" would be an
476
- * assumption rather than an observation.
477
- */
478
- export function rerunDebtVerifyCommand(cwd, command,
479
- /** The spawner, for BOTH the command and the tracked-state reads. Injected so
480
- * the guard's four outcomes are testable without a repo or a real command. */
481
- run = spawnCommand) {
482
- const tracked = () => {
483
- const r = run({
484
- cwd,
485
- bin: 'git',
486
- args: ['status', '--porcelain', '--untracked-files=no'],
487
- timeoutMs: 60_000
488
- });
489
- return r.failedToStart || r.status !== 0 ? null : r.stdout;
490
- };
491
- const before = tracked();
492
- const r = runVerifyCommandLine(cwd, command, DEBT_RERUN_TIMEOUT_MS, DEBT_INFRA_GAP_RE, run);
493
- if (r.outcome === 'fail')
494
- return { outcome: 'fail', detail: `exit ${r.status} — ${r.tail}` };
495
- if (r.outcome === 'gap')
496
- return { outcome: 'gap', detail: r.detail };
497
- const after = tracked();
498
- if (before === null || after === null) {
499
- return { outcome: 'gap', detail: 'tracked-state guard could not read git status' };
500
- }
501
- if (before !== after) {
502
- return {
503
- outcome: 'gap',
504
- detail: 'the re-run itself CHANGED tracked files — a command that edits the tree into a pass proves nothing'
505
- };
506
- }
507
- return { outcome: 'pass' };
508
- }
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 };
509
296
  /**
510
297
  * The run-level closure scans, in emission order within their stage.
511
298
  *
@@ -608,33 +395,17 @@ export async function runFinalIntegrationGate(cwd, opts = {}) {
608
395
  }
609
396
  }, trackedFiles: trackedFilesFn = trackedFiles } = opts;
610
397
  const stat = runRepoHealthCheck(cwd);
611
- const { openDebts, debtNote } = await deriveOpenDebts(cwd, stat.ok);
612
- // The debt note rides in its OWN field: `reason` stays the mechanical failure
613
- // because it seeds the autofix child's prompt (see FinalGateOutcome.reason
614
- // run 11's fix child executed a recorded claim as an instruction).
615
- const withDebts = (o) => ({
616
- ...o,
617
- ...(debtNote ? { debtNote } : {}),
618
- openDebts
619
- });
620
- // Aggregated failures across ALL sections (mx5 run 13 — see the function doc).
621
- // rank 0 = boot/render ("does not serve/render" is the most load-bearing
622
- // signal); rank 1 = everything else, kept in execution order by stable sort.
623
- const failures = [];
624
- const fail = (text, rank = 1) => {
625
- failures.push({ rank, text });
626
- };
627
- /**
628
- * A failure a PROBE returned after observing (nexttask 19A — see
629
- * FinalGateOutcome.observedFailures). Used by exactly one caller: the boot
630
- * section, whose `fail` outcome can only arise from a probe that looked. Every
631
- * other `fail()` keeps today's class, so nothing else changes.
632
- */
633
- const failObserved = (text, rank = 1) => {
634
- failures.push({ rank, text, observed: true });
635
- };
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();
636
407
  if (!stat.ok)
637
- fail(`static checks: ${stat.reason}`);
408
+ tally.fail(`static checks: ${stat.reason}`);
638
409
  // Launch-contract diff (mx5 run 10 item 4): the design declared `migrate`/`seed`
639
410
  // scripts that fell through decompose and shipped missing, unchecked. Diff the
640
411
  // plan-time-extracted declared scripts against the manifest; a missing one is a
@@ -650,73 +421,43 @@ export async function runFinalIntegrationGate(cwd, opts = {}) {
650
421
  // readLaunchManifest resolves package.json, else a Makefile's targets, else
651
422
  // nothing — and nothing means no failure plus a note, never a silent pass.
652
423
  const declared = await readDeclaredScripts(cwd);
653
- const contractNotes = [];
654
424
  if (declared.length > 0) {
655
425
  const manifest = readLaunchManifest(cwd);
656
426
  if (manifest.kind === 'none') {
657
- contractNotes.push(inertLaunchContractNote(declared, manifest));
427
+ tally.contractNote(inertLaunchContractNote(declared, manifest));
658
428
  }
659
429
  else {
660
430
  const missing = missingDeclaredScripts(declared, manifest.names);
661
431
  if (missing.length > 0) {
662
- 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(', ')})`);
663
433
  }
664
434
  }
665
435
  }
666
436
  // Run-level closure scans that must be decided BEFORE the zero-discovery early
667
437
  // return below — a static check needs no runner (CLOSURE_SCANS: 'pre-discovery').
668
- runClosureScans('pre-discovery', { cwd, planText }, fail);
438
+ runClosureScans('pre-discovery', { cwd, planText }, (t, r) => tally.fail(t, r));
669
439
  const lockCmds = discoverLockfileChecks(cwd);
670
440
  const { cmds } = discoverIntegrationCommands(cwd);
671
441
  const boot = discoverBootCommand(cwd);
672
- // ZERO DISCOVERY IS UNOBSERVED, NEVER A PASS (see unobservedVerdict). Nothing was
673
- // discovered, so nothing ran, so observabilityGapFailure (attempted === 0 → null) does
674
- // not fire — and this outcome used to be reported as `PASS — no integration command
675
- // found (statics passed)`, i.e. "we never checked" reading identically to "we checked
676
- // and it was fine". IAR1 shipped that verdict TWICE while carrying open verify-FAIL
677
- // debt (its .pi-tasks/TASK_AUTO_0001.md:31 and TASK_AUTO_0002.md:37). The outcome stays
678
- // `ok: true` (non-blocking, justified at unobservedVerdict) but is now labelled, trailed
679
- // and carried as debt by the caller. It needs no new command source, so unlike the
680
- // harvest lever refuted at discoverIntegrationCommands it cannot inject a fabricated
681
- // failure.
682
- if (lockCmds.length === 0 && cmds.length === 0 && !boot && failures.length === 0) {
683
- // The inert-contract note rides here too: a non-npm project carrying a launch
684
- // contract usually discovers no command either, and that is exactly the run
685
- // whose silence must not read as "the contract was checked and was fine".
686
- const note = [unobservedVerdict({ discovered: 0, observed: 0 }) ?? '', ...contractNotes]
687
- .filter(n => n !== '')
688
- .join(' ');
689
- return withDebts({ ok: true, unobserved: note, reason: note });
690
- }
691
- const ran = [];
692
- // Full-skip blindness counters (mx5 run 16): every dynamic spawn counts an
693
- // attempt; a real pass OR a real fail counts an observation; skips observe
694
- // nothing. If everything discovered ends up skipped, observabilityGapFailure
695
- // turns the silence into a rank-0 failure instead of a static-only PASS.
696
- let dynAttempted = 0;
697
- let dynObserved = 0;
698
- let dynSpawnFailures = 0;
699
- const dynBins = new Set();
700
442
  for (const { prefix, list } of [
701
443
  { prefix: 'lockfile check: ', list: lockCmds },
702
444
  { prefix: '', list: cmds }
703
445
  ]) {
704
446
  for (const cmd of list) {
705
447
  const label = `${cmd[0]} ${cmd[1].join(' ')}`;
706
- dynAttempted += 1;
707
- dynBins.add(cmd[0]);
448
+ tally.attempted(cmd[0]);
708
449
  const r = runGateCommand(cwd, cmd, timeoutMs, undefined, undefined, runCmd);
709
450
  if (r.outcome === 'skip') {
710
451
  if (r.spawnFailed)
711
- dynSpawnFailures += 1;
452
+ tally.spawnFailure(cmd[0]);
712
453
  continue;
713
454
  }
714
- dynObserved += 1;
455
+ tally.observed();
715
456
  if (r.outcome === 'fail') {
716
- fail(`${prefix}\`${label}\` exited ${r.status}${r.tail ? ` — ${r.tail}` : ''}`);
457
+ tally.fail(`${prefix}\`${label}\` exited ${r.status}${r.tail ? ` — ${r.tail}` : ''}`);
717
458
  continue;
718
459
  }
719
- ran.push(label);
460
+ tally.ran(label);
720
461
  }
721
462
  }
722
463
  // EXECUTE the launch contract (mx5 run 11): every declared script that is
@@ -729,12 +470,6 @@ export async function runFinalIntegrationGate(cwd, opts = {}) {
729
470
  // note (F7) is surfaced as an UNOBSERVED warning — the note may be covering a
730
471
  // real defect the gate could not reach here (run 11's "pre-existing .rows
731
472
  // bug" note excused the exact scripts that shipped broken).
732
- const warnings = [];
733
- /** UNOBSERVED notes for launch scripts reclassified as CONFIG GAPS (run 20).
734
- * They ride in `unobserved`, not `warnings`, so the caller's existing
735
- * `recordDebt(cwd, id, fin.unobserved, 'final-gate')` writes the debt —
736
- * never a PASS. */
737
- const configGapNotes = [];
738
473
  if (declared.length > 0) {
739
474
  const covered = cmds.flatMap(([bin, args]) => (bin === 'bun' || bin === 'npm') && args[0] === 'run' && args[1] ? [args[1]] : []);
740
475
  const skippedLaunch = [];
@@ -760,16 +495,15 @@ export async function runFinalIntegrationGate(cwd, opts = {}) {
760
495
  continue;
761
496
  const cmd = ['bun', ['run', name]];
762
497
  const label = `${cmd[0]} ${cmd[1].join(' ')}`;
763
- dynAttempted += 1;
764
- dynBins.add(cmd[0]);
498
+ tally.attempted(cmd[0]);
765
499
  const r = runGateCommand(cwd, cmd, launchTimeout, INFRA_GAP_OUTPUT_RE, undefined, runCmd);
766
500
  if (r.outcome === 'skip') {
767
501
  if (r.spawnFailed)
768
- dynSpawnFailures += 1;
502
+ tally.spawnFailure(cmd[0]);
769
503
  skippedLaunch.push(name);
770
504
  continue;
771
505
  }
772
- dynObserved += 1;
506
+ tally.observed();
773
507
  if (r.outcome === 'fail') {
774
508
  // A CONFIG GAP IS NOT A CODE FAULT (mx5 run 20). The run died on
775
509
  // `bun run seed` exiting 1 because ADMIN_PHONE — which the project's
@@ -796,16 +530,16 @@ export async function runFinalIntegrationGate(cwd, opts = {}) {
796
530
  // Nothing about this script was OBSERVED: the real run could
797
531
  // not reach it and the probe run is a diagnostic, never an
798
532
  // observation. So it un-counts, exactly like a skip.
799
- dynObserved -= 1;
533
+ tally.unobserve();
800
534
  skippedLaunch.push(name);
801
- configGapNotes.push(configGapUnobservedNote(gap));
535
+ tally.configGap(configGapUnobservedNote(gap));
802
536
  continue;
803
537
  }
804
538
  }
805
- fail(`launch script: \`${label}\` exited ${r.status}${r.tail ? ` — ${r.tail}` : ''}`);
539
+ tally.fail(`launch script: \`${label}\` exited ${r.status}${r.tail ? ` — ${r.tail}` : ''}`);
806
540
  continue;
807
541
  }
808
- ran.push(label);
542
+ tally.ran(label);
809
543
  }
810
544
  if (skippedLaunch.length > 0) {
811
545
  const notes = parseEnvNotes(await readEnvNotes(cwd)).filter(n => isExcuseNote(n.fact));
@@ -813,7 +547,7 @@ export async function runFinalIntegrationGate(cwd, opts = {}) {
813
547
  const re = new RegExp(`\\b${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i');
814
548
  const excuse = notes.find(n => re.test(n.fact));
815
549
  if (excuse) {
816
- 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 `
817
551
  + `standing excuse note covers it ("${excuse.fact.slice(0, 160)}") — `
818
552
  + `UNOBSERVED: verify it by hand before trusting the launch surface`);
819
553
  }
@@ -824,13 +558,36 @@ export async function runFinalIntegrationGate(cwd, opts = {}) {
824
558
  // construction, and it carries the run's most load-bearing signal — earlier
825
559
  // failures no longer shadow it. Its failures rank FIRST in the aggregate.
826
560
  // A boot that never RAN is its own verdict (mx5 run 18 — see bootSkipVerdict);
827
- // 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
828
562
  // commands that did run cannot cancel it.
829
- 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);
830
588
  if (boot) {
831
589
  const label = `${boot[0]} ${boot[1].join(' ')}`;
832
- dynAttempted += 1;
833
- dynBins.add(boot[0]);
590
+ tally.attempted(boot[0]);
834
591
  const expectServer = detectsServedApp(cwd, planText);
835
592
  // Render check (mx5 runs 8/11): for a served app, load the live page in a
836
593
  // headless browser and judge the RENDERED DOM — curl can't run JS, so a
@@ -859,14 +616,14 @@ export async function runFinalIntegrationGate(cwd, opts = {}) {
859
616
  b = await recoverOrphanPort(cwd, boot, b, bootGraceMs, bootDepsWithRender, expectServer);
860
617
  }
861
618
  if (b.outcome !== 'skip')
862
- dynObserved += 1;
619
+ tally.observed();
863
620
  else if (b.spawnFailed)
864
- dynSpawnFailures += 1;
865
- bootUnobserved = bootSkipVerdict({
621
+ tally.spawnFailure(boot[0]);
622
+ tally.bootUnobserved(bootSkipVerdict({
866
623
  label,
867
624
  skipped: b.outcome === 'skip',
868
625
  expectServer
869
- });
626
+ }));
870
627
  if (b.outcome === 'fail') {
871
628
  // OBSERVED (nexttask 19A). Every path that produces `fail` here is a
872
629
  // probe that looked: the render judge saw an empty body, the deep
@@ -875,7 +632,7 @@ export async function runFinalIntegrationGate(cwd, opts = {}) {
875
632
  // condition that means "we could not look" — no ss/netstat/lsof, mx5
876
633
  // run 14 — returns PASS stamped UNOBSERVED and never reaches here
877
634
  // (`b0f90a7`, final-gate.ts `if (!canEnumerate) return passAndKill(…)`).
878
- failObserved(`boot check: \`${label}\` ${b.detail}`, 0);
635
+ tally.failObserved(`boot check: \`${label}\` ${b.detail}`, 0);
879
636
  }
880
637
  else if (b.outcome === 'orphan-port') {
881
638
  // Could not clear the port. Distinct HARNESS diagnosis, never a bare app
@@ -884,14 +641,14 @@ export async function runFinalIntegrationGate(cwd, opts = {}) {
884
641
  const who = holder ? ` — held by an orphaned process (pid ${holder.pid}: ${holder.command})`
885
642
  : b.port !== null ? ` — port ${b.port} is held by another process`
886
643
  : '';
887
- 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);
888
645
  }
889
646
  else if (b.outcome === 'pass') {
890
- ran.push(label);
647
+ tally.ran(label);
891
648
  // A listener that served, but whose page could not be OBSERVED to render
892
649
  // (no browser, undeterminable port) → UNOBSERVED warning, not a silent pass.
893
650
  if (b.renderNote)
894
- warnings.push(b.renderNote);
651
+ tally.warn(b.renderNote);
895
652
  }
896
653
  }
897
654
  else {
@@ -900,77 +657,22 @@ export async function runFinalIntegrationGate(cwd, opts = {}) {
900
657
  // project with no launch surface, and it must not degrade into silence.
901
658
  const rejected = rejectedLaunchScript(cwd);
902
659
  if (rejected && detectsServedApp(cwd, planText)) {
903
- bootUnobserved =
904
- `boot check: this project's only launch script (\`${rejected.name}\`) is not a `
905
- + `launch ${rejected.reason} — so nothing was started and the app was never `
906
- + '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.');
907
663
  }
908
664
  }
909
665
  // Full-skip blindness guard (mx5 run 16): commands were discovered but every
910
666
  // one skipped → rank-0 failure, never a static-only PASS. Runner resolvability
911
667
  // is checked through resolveRunner so the failure text can name the missing
912
668
  // runner when that is the cause (the run-16 shape: login-shell PATH lost bun).
913
- const gap = observabilityGapFailure({
914
- attempted: dynAttempted,
915
- observed: dynObserved,
916
- spawnFailures: dynSpawnFailures,
917
- runnerBins: [...dynBins],
918
- runnerResolvable: b => resolveRunner(b).ok
919
- });
669
+ const gap = tally.blindness(b => resolveRunner(b).ok);
920
670
  if (gap)
921
- fail(gap, 0);
671
+ tally.fail(gap, 0);
922
672
  // The remaining run-level closure scans — "the shipped tree references or
923
673
  // requires something it does not contain" — after every dynamic section, so
924
674
  // their failures keep their historical place in the aggregate (CLOSURE_SCANS:
925
675
  // 'post-boot').
926
- runClosureScans('post-boot', { cwd, planText }, fail);
927
- if (failures.length > 0) {
928
- // Stable sort: boot/render (rank 0) leads, everything else keeps execution
929
- // order. One failure keeps the exact single-failure wording; several become
930
- // a numbered list so the trail, the ACCEPT picker, and the autofix seed all
931
- // carry the complete ranked picture.
932
- const ranked = [...failures].sort((a, b) => a.rank - b.rank);
933
- const texts = ranked.map(f => f.text);
934
- // The observed subset rides along by exact text identity (19A) — the demote
935
- // decision downstream reads THIS, instead of re-deriving observability from
936
- // the failure string.
937
- const observed = ranked.filter(f => f.observed === true).map(f => f.text);
938
- return withDebts({
939
- ok: false,
940
- reason: texts.length === 1 ?
941
- texts[0]
942
- : `${texts.length} failures (ranked, most load-bearing first):\n${texts
943
- .map((t, i) => `${i + 1}. ${t}`)
944
- .join('\n')}`,
945
- failures: texts,
946
- ...(observed.length > 0 ? { observedFailures: observed } : {})
947
- });
948
- }
949
- const warningNote = warnings.length > 0 ? ` — WARNING: ${warnings.join('; WARNING: ')}` : '';
950
- // The same three-way verdict at the other zero-observation door: commands WERE
951
- // discovered, none spawn-failed (so the run-16 guard correctly stayed silent — every
952
- // skip was a tool-level env gap), and yet nothing ran. That was `statics passed
953
- // (integration commands not runnable here)`, which is the identical "we never checked"
954
- // silence wearing different words. Unchanged when anything at all was observed, so a
955
- // project with runnable commands is byte-for-byte unaffected.
956
- // Two independent UNOBSERVED notes, either or both of which may apply: the boot
957
- // never ran (run 18), and/or NOTHING dynamic ran at all. The boot note leads
958
- // because it names a concrete command and the trail line is sliced at 300 chars.
959
- const unobserved = [
960
- bootUnobserved,
961
- unobservedVerdict({ discovered: dynAttempted, observed: dynObserved }),
962
- ...configGapNotes,
963
- ...contractNotes
964
- ]
965
- .filter(n => n !== null)
966
- .join(' ');
967
- return withDebts({
968
- ok: true,
969
- ...(unobserved ? { unobserved } : {}),
970
- reason: (unobserved ? `${unobserved} — ` : '')
971
- + (ran.length > 0 ?
972
- `statics + ${ran.map(c => `\`${c}\``).join(', ')} passed`
973
- : 'statics passed (integration commands not runnable here)')
974
- + warningNote
975
- });
676
+ runClosureScans('post-boot', { cwd, planText }, (t, r) => tally.fail(t, r));
677
+ return tally.verdict(debts);
976
678
  }