@mjasnikovs/pi-task 0.38.2 → 0.38.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/dist/config/config.d.ts +7 -0
  2. package/dist/config/config.js +10 -4
  3. package/dist/config/register.d.ts +37 -0
  4. package/dist/config/register.js +89 -114
  5. package/dist/remote/events.js +0 -3
  6. package/dist/remote/register.js +12 -3
  7. package/dist/task/auto-orchestrator.js +119 -94
  8. package/dist/task/command-run.d.ts +104 -0
  9. package/dist/task/command-run.js +138 -0
  10. package/dist/task/coverage-loop.d.ts +45 -0
  11. package/dist/task/critique-probes.d.ts +82 -0
  12. package/dist/task/critique-probes.js +156 -0
  13. package/dist/task/enforce-guidelines.d.ts +14 -17
  14. package/dist/task/enforce-guidelines.js +44 -31
  15. package/dist/task/final-gate.d.ts +8 -10
  16. package/dist/task/final-gate.js +36 -74
  17. package/dist/task/gate-child.d.ts +104 -0
  18. package/dist/task/gate-child.js +177 -0
  19. package/dist/task/gate-deps.js +57 -205
  20. package/dist/task/orchestrator.js +13 -22
  21. package/dist/task/phases.js +109 -182
  22. package/dist/task/plan-session.d.ts +4 -22
  23. package/dist/task/plan-session.js +4 -33
  24. package/dist/task/question-dialog.d.ts +71 -0
  25. package/dist/task/question-dialog.js +89 -0
  26. package/dist/task/terminal-outcome.d.ts +67 -0
  27. package/dist/task/terminal-outcome.js +76 -0
  28. package/dist/task/type-only-answer.js +2 -3
  29. package/dist/workers/abstention.d.ts +71 -0
  30. package/dist/workers/abstention.js +108 -0
  31. package/dist/workers/docs-chunk.d.ts +74 -0
  32. package/dist/workers/docs-chunk.js +143 -0
  33. package/dist/workers/docs-core.d.ts +10 -1
  34. package/dist/workers/docs-core.js +22 -19
  35. package/dist/workers/docs-index.js +2 -69
  36. package/dist/workers/docs-project.d.ts +15 -1
  37. package/dist/workers/docs-project.js +27 -66
  38. package/dist/workers/fetch-core.d.ts +1 -1
  39. package/dist/workers/fetch-core.js +2 -1
  40. package/dist/workers/pi-worker-core.js +157 -86
  41. package/dist/workers/pi-worker-docs.js +5 -10
  42. package/dist/workers/pi-worker-fetch.js +8 -1
  43. package/dist/workers/typeonly-log.js +2 -10
  44. package/dist/workers/worker-failure.d.ts +91 -0
  45. package/dist/workers/worker-failure.js +82 -0
  46. package/package.json +1 -1
@@ -45,6 +45,8 @@ import { granularityFloor, granularitySplitHint, isPlanShapeQuestion, isTooCoars
45
45
  import { mandatesTestsInSameChange, rewriteBatchTestPlan } from './batch-test-task.js';
46
46
  import { REQUIREMENT_EXTRACT_PROMPT, COVERAGE_MAP_PROMPT, parseRequirementLines, keepGroundedRequirements, capRequirements, writeOwnedRequirements, enumerateObligationPassages, uncoveredPassages, extractionRetryHint, parseCoverageMap, accountCoverage, isCrossCuttingRequirement, appendCarriedRequirements, buildRequirementsLedger } from './requirements.js';
47
47
  import { decideAdoption, groundedCoverage } from './coverage-loop.js';
48
+ import { buildOptionCards, resolveAnswer } from './question-dialog.js';
49
+ import { TERMINAL_OUTCOMES, formatAt, formatWhy } from './terminal-outcome.js';
48
50
  import { findSpecDanglingArtifacts, titlesCoverArtifact, danglingMissingText, danglingCarryText } from './artifact-closure.js';
49
51
  import { LAUNCH_EXTRACT_PROMPT, enumerateScriptCandidates, parseScriptLines, keepGroundedScripts, appendDeclaredScripts } from './launch-contract.js';
50
52
  // Hard ceiling on clarify questions per feature. The loop is open-ended (it stops
@@ -556,11 +558,6 @@ export async function planAuto(ctx, cwd, feature, deps) {
556
558
  }
557
559
  const plainSuggested = suggested === undefined ? undefined : stripInlineMarkdown(suggested);
558
560
  const plainAlt = alt === undefined ? undefined : stripInlineMarkdown(alt);
559
- // Identical to /task's grill dialog: a recommendation (or A/B fork)
560
- // becomes the boxed picker locally — each answer in its own bounding box,
561
- // the recommended one tinted green; an open question shows the bare text
562
- // prompt. No verbose "Recommended:" / "press Enter to accept" scaffolding.
563
- const twoOption = plainSuggested !== undefined && plainAlt !== undefined;
564
561
  // YOLO: take the recommended option (index 0 / the green card) without ever
565
562
  // building the prompt. Clarify has no anti-synthesis channel — it runs before
566
563
  // any research — so the only step-aside here is a question that carries no
@@ -575,17 +572,22 @@ export async function planAuto(ctx, cwd, feature, deps) {
575
572
  + `A${answers.length + 1}: ${auto} ${YOLO_STAMP}`);
576
573
  continue;
577
574
  }
578
- const options = twoOption ?
579
- [
580
- {
581
- label: `A: ${renderInlineMarkdown(suggested, theme)}`,
582
- value: plainSuggested
583
- },
584
- { label: `B: ${renderInlineMarkdown(alt, theme)}`, value: plainAlt }
585
- ]
586
- : plainSuggested !== undefined ?
587
- [{ label: renderInlineMarkdown(suggested, theme), value: plainSuggested }]
588
- : undefined;
575
+ // The picker cards and the reply mapping are shared with /task's grill
576
+ // phase and the plan session (question-dialog.ts) — all three used to
577
+ // write them out, and had drifted.
578
+ const pending = {
579
+ plain: plainQ,
580
+ shown: shownQ,
581
+ ...(plainSuggested !== undefined && {
582
+ suggested: plainSuggested,
583
+ shownSuggested: renderInlineMarkdown(suggested, theme)
584
+ }),
585
+ ...(plainAlt !== undefined && {
586
+ alt: plainAlt,
587
+ shownAlt: renderInlineMarkdown(alt, theme)
588
+ })
589
+ };
590
+ const options = buildOptionCards(pending);
589
591
  const a = await ui.ask({
590
592
  localTitle: shownQ,
591
593
  displayQuestion: shownQ,
@@ -599,31 +601,15 @@ export async function planAuto(ctx, cwd, feature, deps) {
599
601
  announceDone(ctx, '/task-auto cancelled.', 'warning');
600
602
  return null;
601
603
  }
602
- const typed = a.trim();
603
- // The local picker resolves to the chosen option's full value, but a
604
- // remote user (or the picker's free-text fallback) may still type a bare
605
- // "A"/"B" map those back to the option's full text. Mirrors phaseGrill.
606
- let answer;
607
- if (typed.length === 0 && plainSuggested) {
608
- answer = `${plainSuggested} (accepted recommendation)`;
609
- }
610
- else if (typed.length === 0) {
611
- answer = '(skipped)';
612
- }
613
- else if (twoOption && /^a[.)]?$/i.test(typed)) {
614
- answer = plainSuggested;
615
- }
616
- else if (twoOption && /^b[.)]?$/i.test(typed)) {
617
- answer = plainAlt;
618
- }
619
- else if (!twoOption && plainSuggested !== undefined && typed === plainSuggested) {
620
- // Single recommendation accepted by picking its (green) card in the
621
- // boxed picker — same provenance as an empty-submit accept.
622
- answer = `${plainSuggested} (accepted recommendation)`;
623
- }
624
- else {
625
- answer = typed;
626
- }
604
+ const resolved = resolveAnswer(pending, a);
605
+ // Clarify's transcript records PROVENANCE; grill's deliberately does not,
606
+ // because grill's is fed back verbatim into the next grill-gen prompt. That
607
+ // is now the only difference between the two dialogs, and it is one line.
608
+ // An accept covers both routes to it: submitting empty, and pressing the
609
+ // single green card.
610
+ const answer = resolved.source === 'accepted' ?
611
+ `${resolved.answer} (accepted recommendation)`
612
+ : resolved.answer;
627
613
  answers.push(`Q${answers.length + 1}: ${plainQ}\nA${answers.length + 1}: ${answer}`);
628
614
  }
629
615
  if (answers.length === 0) {
@@ -821,9 +807,11 @@ export async function planAuto(ctx, cwd, feature, deps) {
821
807
  const hasRequirements = reqEntries.length > 0;
822
808
  // `best` is both the plan the next round reprompts FROM and the plan that
823
809
  // ships — kept identical because adoption is monotone (see coverage-loop.ts).
810
+ //
811
+ // It is also the ONLY handle on the accounting. There used to be a second,
812
+ // `let accounting`, carried alongside — see the ScoredPlan doc comment for the
813
+ // requirement-to-wrong-task bug that cost us.
824
814
  let best = await scorePlan(planTitles);
825
- // The carried accounting (cross-cutting + unowned) for the plan that ships.
826
- let accounting = best.accounting;
827
815
  let round = 0;
828
816
  // #2: the round cap can be lifted ONCE. An adoption is a fresh whole-plan roll,
829
817
  // so the plan that gets adopted can expose an uncovered area the pre-adoption
@@ -873,8 +861,12 @@ export async function planAuto(ctx, cwd, feature, deps) {
873
861
  // every round, so there is no trustworthy "grew"/"new" signal to gate on.
874
862
  const priorCovered = best.plan.covered.size;
875
863
  const priorMissing = new Set(best.plan.missing.map(normMissingArea));
864
+ // The WHOLE scored plan is adopted, titles and accounting together.
865
+ // This used to be two assignments, and the second one kept the OLD
866
+ // plan's accounting whenever the new plan's coverage-map child faulted
867
+ // (`cand.accounting ?? accounting`) — binding requirements to titles
868
+ // they were never mapped against. See the ScoredPlan doc comment.
876
869
  best = cand;
877
- accounting = cand.accounting ?? accounting;
878
870
  logPlanDebug(cwd, `decompose retry ADOPTED — ${decision.reason}`);
879
871
  if (!bonusRoundUsed
880
872
  && round >= roundCap
@@ -926,8 +918,8 @@ export async function planAuto(ctx, cwd, feature, deps) {
926
918
  // exactly the class that, having no carrier, was warned-about then dropped (mx5
927
919
  // 2026-07-16, §10 test-infra). Carried independent of `accounting` so a mapping
928
920
  // fault (accounting === null) can't strand them either.
929
- const carriedCrossCutting = accounting?.crossCutting ?? [];
930
- const carriedUnmapped = accounting?.unmapped ?? [];
921
+ const carriedCrossCutting = best.accounting?.crossCutting ?? [];
922
+ const carriedUnmapped = best.accounting?.unmapped ?? [];
931
923
  const carriedJudge = best.judgeMissing;
932
924
  // Dangling artifacts still unclaimed by any title of the SHIPPING plan are a
933
925
  // fourth channel: the producing obligation travels verbatim into every task
@@ -959,16 +951,17 @@ export async function planAuto(ctx, cwd, feature, deps) {
959
951
  // not a substring of the doc and is dropped, so a fabricated contract — exactly
960
952
  // the F3 bug — can never enter the registry. Best-effort: any fault here is
961
953
  // swallowed (the registry is a sharpener, never a planning blocker).
962
- try {
963
- const contractRaw = await deps.runChild('contract-extract', '', CONTRACT_EXTRACT_PROMPT(featureForModel, planTitles));
964
- const grounded = keepGroundedContracts(parseContractLines(contractRaw), featureForModel);
965
- logPlanDebug(cwd, `contract extraction: ${grounded.length} grounded contract(s) kept`
966
- + ` from ${parseContractLines(contractRaw).length} emitted`);
967
- await appendContracts(cwd, grounded);
968
- }
969
- catch {
970
- // best-effort registry
971
- }
954
+ await runGroundedExtraction({
955
+ cwd,
956
+ runChild: deps.runChild,
957
+ child: 'contract-extract',
958
+ noun: 'contract',
959
+ label: 'contract extraction',
960
+ prompt: CONTRACT_EXTRACT_PROMPT(featureForModel, planTitles),
961
+ parse: parseContractLines,
962
+ ground: emitted => keepGroundedContracts(emitted, featureForModel),
963
+ append: appendContracts
964
+ });
972
965
  // Launch contract (mx5 run 10 item 4): extract the package/build SCRIPTS the design
973
966
  // declares the project must expose (`migrate`/`seed` fell through decompose and
974
967
  // shipped missing, unchecked). Each emitted name is re-grounded against the design
@@ -979,16 +972,17 @@ export async function planAuto(ctx, cwd, feature, deps) {
979
972
  // far from the design's summary list (`test:ct` in §2 vs §9's five) can't be
980
973
  // missed by a weak model's recall — the child classifies, it no longer recalls.
981
974
  // Best-effort.
982
- try {
983
- const scriptRaw = await deps.runChild('launch-extract', '', LAUNCH_EXTRACT_PROMPT(featureForModel, enumerateScriptCandidates(featureForModel)));
984
- const grounded = keepGroundedScripts(parseScriptLines(scriptRaw), featureForModel);
985
- logPlanDebug(cwd, `launch-contract extraction: ${grounded.length} grounded script(s) kept`
986
- + ` from ${parseScriptLines(scriptRaw).length} emitted`);
987
- await appendDeclaredScripts(cwd, grounded);
988
- }
989
- catch {
990
- // best-effort artifact
991
- }
975
+ await runGroundedExtraction({
976
+ cwd,
977
+ runChild: deps.runChild,
978
+ child: 'launch-extract',
979
+ noun: 'script',
980
+ label: 'launch-contract extraction',
981
+ prompt: LAUNCH_EXTRACT_PROMPT(featureForModel, enumerateScriptCandidates(featureForModel)),
982
+ parse: parseScriptLines,
983
+ ground: emitted => keepGroundedScripts(emitted, featureForModel),
984
+ append: appendDeclaredScripts
985
+ });
992
986
  // Thread the feature's spec doc(s) into every title so each per-task
993
987
  // pipeline — which only ever sees its title — reads the real spec instead of
994
988
  // a lossy one-line paraphrase of it.
@@ -1004,8 +998,8 @@ export async function planAuto(ctx, cwd, feature, deps) {
1004
998
  // §9's "serves `/api` + static `dist/`" out of its spec with nothing to stop
1005
999
  // it). Inert until the owned-requirements injection is wired into the phase
1006
1000
  // prompts; recorded regardless so the plan's mapping is auditable per run.
1007
- if (accounting && accounting.mapped.length > 0) {
1008
- await writeOwnedRequirements(cwd, accounting.mapped
1001
+ if (best.accounting && best.accounting.mapped.length > 0) {
1002
+ await writeOwnedRequirements(cwd, best.accounting.mapped
1009
1003
  .filter(m => m.task >= 1 && m.task <= titles.length)
1010
1004
  .map(m => ({ quote: m.req.quote, anchor: m.req.anchor, title: titles[m.task - 1] })));
1011
1005
  }
@@ -1022,16 +1016,50 @@ export async function planAuto(ctx, cwd, feature, deps) {
1022
1016
  };
1023
1017
  // Durable, user-visible coverage record (goal A(c)): what was carried and what
1024
1018
  // stayed unowned lives in the plan file itself, not only in a transient toast.
1025
- const coverageNote = accounting === null ? '' : ([
1026
- `${reqEntries.length} grounded requirement(s): ${accounting.mapped.length} task-mapped, `
1027
- + `${accounting.crossCutting.length} cross-cutting (carried into every task via `
1028
- + `.pi-tasks/requirements.md), ${accounting.unmapped.length} unowned`,
1029
- ...accounting.crossCutting.map(e => `- carried: "${e.quote}"`),
1030
- ...accounting.unmapped.map(e => `- UNOWNED (no task covers this): "${e.quote}"`)
1019
+ const shipped = best.accounting;
1020
+ const coverageNote = shipped === null ? '' : ([
1021
+ `${reqEntries.length} grounded requirement(s): ${shipped.mapped.length} task-mapped, `
1022
+ + `${shipped.crossCutting.length} cross-cutting (carried into every task via `
1023
+ + `.pi-tasks/requirements.md), ${shipped.unmapped.length} unowned`,
1024
+ ...shipped.crossCutting.map(e => `- carried: "${e.quote}"`),
1025
+ ...shipped.unmapped.map(e => `- UNOWNED (no task covers this): "${e.quote}"`)
1031
1026
  ].join('\n'));
1032
1027
  await writeTaskFile(cwd, fm, buildAutoBody(feature, clarifications, titles, coverageNote));
1033
1028
  return id;
1034
1029
  }
1030
+ /**
1031
+ * One best-effort, HOST-GROUNDED extraction: ask a child to emit lines, drop
1032
+ * every line the design does not literally contain, log the kept/emitted split,
1033
+ * and append what survives to a run-level artifact.
1034
+ *
1035
+ * The grounding step is the reason this shape exists rather than a plain child
1036
+ * call. A child asked for interface facts will paraphrase and occasionally invent
1037
+ * them (mx5 run 8, F3), and an invented fact in a run-level registry is read as
1038
+ * authoritative by every downstream refine/compose/verify. So nothing the child
1039
+ * says is trusted: `ground` re-checks each emitted line against the design text
1040
+ * host-side, and only substrings survive.
1041
+ *
1042
+ * Best-effort by contract. These artifacts SHARPEN planning; none of them gates
1043
+ * it, so a fault here is swallowed rather than failing a run that is otherwise
1044
+ * fine — which is why the whole body sits in one `catch {}`.
1045
+ *
1046
+ * The two call sites (contracts, launch scripts) were byte-identical apart from
1047
+ * the four values this row carries, and the contracts copy parsed its child's
1048
+ * output twice — once for the artifact and once for the log count — because the
1049
+ * duplication made the second parse easy to miss.
1050
+ */
1051
+ async function runGroundedExtraction(row) {
1052
+ try {
1053
+ const emitted = row.parse(await row.runChild(row.child, '', row.prompt));
1054
+ const grounded = row.ground(emitted);
1055
+ logPlanDebug(row.cwd, `${row.label}: ${grounded.length} grounded ${row.noun}(s) kept`
1056
+ + ` from ${emitted.length} emitted`);
1057
+ await row.append(row.cwd, grounded);
1058
+ }
1059
+ catch {
1060
+ // best-effort artifact — never a planning blocker
1061
+ }
1062
+ }
1035
1063
  /** The two feature-level planning children, shown as steps in the loader. */
1036
1064
  const AUTO_PLAN_STEPS = {
1037
1065
  'auto-clarify': { step: 'clarify', stepNum: 1 },
@@ -1303,26 +1331,23 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
1303
1331
  onVerified: () => checkOffTask(cwd, id, next.index, res.taskId, next.title)
1304
1332
  });
1305
1333
  active = gate.ctx;
1306
- if (gate.kind === 'paused') {
1307
- await markResumable(cwd, res.taskId);
1308
- await updateTaskFrontMatter(cwd, id, { state: 'failed' });
1309
- announceDone(active, `${id} paused at "${next.title}" — verification failed and you dismissed the choice; resume with /task-auto-resume.`, 'warning');
1310
- return;
1311
- }
1312
- if (gate.kind === 'session-cancelled') {
1313
- announceDone(active, `${id} paused — could not start a session for autofix. Run /task-auto-resume to retry.`, 'warning');
1314
- return;
1315
- }
1316
- if (gate.kind === 'interrupted') {
1317
- await markResumable(cwd, res.taskId);
1318
- announceDone(active, `${id} paused at "${next.title}" — resume with /task-auto-resume.`, 'warning');
1319
- return;
1320
- }
1321
- if (gate.kind === 'failed') {
1322
- await markResumable(cwd, res.taskId);
1323
- await updateTaskFrontMatter(cwd, id, { state: 'failed' });
1324
- const why = gate.reason ? ` — ${gate.reason.slice(0, 160)}` : '';
1325
- announceDone(active, `${id} stopped at "${next.title}"${why} — fix and run /task-auto-resume.`, 'error');
1334
+ // Every terminal gate outcome what to demote, what to fail, what to
1335
+ // say — comes from TERMINAL_OUTCOMES, shared verbatim with /task's
1336
+ // loop. `done` alone is not terminal here: it falls through to the
1337
+ // next task.
1338
+ if (gate.kind !== 'done') {
1339
+ const outcome = TERMINAL_OUTCOMES[gate.kind];
1340
+ if (outcome.markResumable)
1341
+ await markResumable(cwd, res.taskId);
1342
+ if (outcome.failParent) {
1343
+ await updateTaskFrontMatter(cwd, id, { state: 'failed' });
1344
+ }
1345
+ announceDone(active, outcome.message({
1346
+ tag: id,
1347
+ at: formatAt(next.title),
1348
+ why: formatWhy(gate.kind === 'failed' ? gate.reason : undefined),
1349
+ resumeCmd: '/task-auto-resume'
1350
+ }), outcome.level);
1326
1351
  return;
1327
1352
  }
1328
1353
  // ROOT-CAUSE REPAIR (mx5 run 14 item 5): the gate may have attributed a
@@ -0,0 +1,104 @@
1
+ /**
2
+ * command-run — running a project command under the gate's ENV-GAP CONTRACT, and
3
+ * the single statement of what each way of ending means.
4
+ *
5
+ * The contract: a command that never ran tells you nothing about the code. A
6
+ * missing runner, a 127 inside the script chain, a browser binary that was never
7
+ * installed, a database that is not up on this box, a timeout — all of these are
8
+ * facts about the ENVIRONMENT, and none of them may fail a gate or close a debt.
9
+ * Only a command that demonstrably ran and exited non-zero for a reason of its
10
+ * own is a failure, and only exit 0 is a pass.
11
+ *
12
+ * Why a module. That ladder was written three times inside final-gate.ts —
13
+ * `runGateCommand`, `runVerifyCommandLine`, `rerunDebtVerifyCommand` — in the
14
+ * same order with different labels, and the copies had already drifted (the
15
+ * infrastructure pattern applied unconditionally in one and only on request in
16
+ * another). repo-health-check.ts had solved exactly this shape years earlier:
17
+ * `classifyHealthRun` is pure over a value, extracted so its sync and async
18
+ * runners "cannot drift apart". The gate never adopted it.
19
+ *
20
+ * The second half is the seam. Classification is now pure over a `CommandRun`
21
+ * value, and SPAWNING is a `CommandRunner` the caller injects. That is what lets
22
+ * the gate's tests state a case as `{status: 1, stdout: "…"}` instead of writing
23
+ * a real `node -e` child, creating a temp directory for it, and — for the three
24
+ * cases only reachable that way — shadowing a binary on `process.env.PATH` and
25
+ * resetting a module-level cache, which made those tests order-sensitive and
26
+ * needed a Windows carve-out. Note the asymmetry this closes: `BootDeps` already
27
+ * carried nine injectable probes for the gate's boot half while its command half
28
+ * had none.
29
+ */
30
+ /** What one finished command looks like, stripped of how it was spawned. */
31
+ export interface CommandRun {
32
+ /** The runner binary itself never started (ENOENT, no POSIX shell). */
33
+ failedToStart: boolean;
34
+ /** Why it could not start — only meaningful with `failedToStart`. */
35
+ failureMessage?: string;
36
+ /** Exit status, or null when the child was killed (timeout or signal). */
37
+ status: number | null;
38
+ stdout: string;
39
+ stderr: string;
40
+ }
41
+ /** Everything a runner needs to spawn one command. */
42
+ export interface CommandSpec {
43
+ cwd: string;
44
+ bin: string;
45
+ args: string[];
46
+ timeoutMs: number;
47
+ /**
48
+ * Replaces the child's environment wholesale. Passed explicitly because bun's
49
+ * spawnSync resolves the binary against a startup snapshot of the environment
50
+ * rather than the live `process.env`.
51
+ */
52
+ env?: Record<string, string | undefined>;
53
+ }
54
+ /**
55
+ * The injectable half. The gate takes one of these so its tests can script
56
+ * verdicts instead of paying process-spawn cost for every classification case.
57
+ */
58
+ export type CommandRunner = (spec: CommandSpec) => CommandRun;
59
+ /** The real runner. */
60
+ export declare const spawnCommand: CommandRunner;
61
+ /**
62
+ * A non-zero exit whose output shows an EXTERNAL runtime dependency is missing, not
63
+ * a code fault: a browser suite (Playwright/Cypress) whose browser binaries or system
64
+ * libraries were never installed here (mx5 run 10 item 2: `test:ct` must run in the
65
+ * gate, but on a box with no Playwright browsers it is an environment gap, not a FAIL).
66
+ * These exit non-zero (not 127), so they need output-shape recognition to skip.
67
+ */
68
+ export declare const ENV_GAP_OUTPUT_RE: RegExp;
69
+ /**
70
+ * A non-zero exit whose output shows the EXTERNAL INFRASTRUCTURE a launch script
71
+ * talks to is absent HERE — a database/daemon that is not running or not
72
+ * installed — rather than a fault in the script itself.
73
+ *
74
+ * NOT applied by default, and that is deliberate: a migrate/seed against no DB is
75
+ * an environment gap on this box, but the same wording out of a `test` run is a
76
+ * real failure the suite must own. Callers opt in per command by passing it in
77
+ * `gapPatterns`, which is why that parameter exists rather than a boolean.
78
+ */
79
+ export declare const INFRA_GAP_OUTPUT_RE: RegExp;
80
+ /** Which way a command failed to tell us anything. */
81
+ export type CommandGapId = 'spawn-failed' | 'killed' | 'command-not-found' | 'missing-runtime' | 'infrastructure';
82
+ export type CommandVerdict =
83
+ /** Nothing was observed. Never fails a gate, never closes a debt. */
84
+ {
85
+ outcome: 'gap';
86
+ gap: CommandGapId;
87
+ detail: string;
88
+ } | {
89
+ outcome: 'pass';
90
+ } | {
91
+ outcome: 'fail';
92
+ status: number;
93
+ tail: string;
94
+ };
95
+ /** Last ~`limit` chars of the command's combined output, one line, for the reason. */
96
+ export declare function outputTail(stdout: string, stderr: string, limit?: number): string;
97
+ /**
98
+ * Decide what one finished command proved. Pure — no spawning, no filesystem, no
99
+ * clock — so every case is stateable as a literal.
100
+ *
101
+ * `gapPatterns` are the EXTRA output shapes this particular command may treat as
102
+ * an environment gap (see INFRA_GAP_OUTPUT_RE). Empty for an ordinary check.
103
+ */
104
+ export declare function classifyCommandRun(run: CommandRun, gapPatterns?: readonly RegExp[]): CommandVerdict;
@@ -0,0 +1,138 @@
1
+ /**
2
+ * command-run — running a project command under the gate's ENV-GAP CONTRACT, and
3
+ * the single statement of what each way of ending means.
4
+ *
5
+ * The contract: a command that never ran tells you nothing about the code. A
6
+ * missing runner, a 127 inside the script chain, a browser binary that was never
7
+ * installed, a database that is not up on this box, a timeout — all of these are
8
+ * facts about the ENVIRONMENT, and none of them may fail a gate or close a debt.
9
+ * Only a command that demonstrably ran and exited non-zero for a reason of its
10
+ * own is a failure, and only exit 0 is a pass.
11
+ *
12
+ * Why a module. That ladder was written three times inside final-gate.ts —
13
+ * `runGateCommand`, `runVerifyCommandLine`, `rerunDebtVerifyCommand` — in the
14
+ * same order with different labels, and the copies had already drifted (the
15
+ * infrastructure pattern applied unconditionally in one and only on request in
16
+ * another). repo-health-check.ts had solved exactly this shape years earlier:
17
+ * `classifyHealthRun` is pure over a value, extracted so its sync and async
18
+ * runners "cannot drift apart". The gate never adopted it.
19
+ *
20
+ * The second half is the seam. Classification is now pure over a `CommandRun`
21
+ * value, and SPAWNING is a `CommandRunner` the caller injects. That is what lets
22
+ * the gate's tests state a case as `{status: 1, stdout: "…"}` instead of writing
23
+ * a real `node -e` child, creating a temp directory for it, and — for the three
24
+ * cases only reachable that way — shadowing a binary on `process.env.PATH` and
25
+ * resetting a module-level cache, which made those tests order-sensitive and
26
+ * needed a Windows carve-out. Note the asymmetry this closes: `BootDeps` already
27
+ * carried nine injectable probes for the gate's boot half while its command half
28
+ * had none.
29
+ */
30
+ import { spawnSync } from 'node:child_process';
31
+ import { isCommandNotFound } from './runner-resolve.js';
32
+ /** The real runner. */
33
+ export const spawnCommand = spec => {
34
+ const r = spawnSync(spec.bin, spec.args, {
35
+ cwd: spec.cwd,
36
+ encoding: 'utf8',
37
+ timeout: spec.timeoutMs,
38
+ ...(spec.env ? { env: spec.env } : {})
39
+ });
40
+ return {
41
+ failedToStart: r.error !== undefined && r.error !== null,
42
+ ...(r.error ? { failureMessage: r.error.message } : {}),
43
+ status: r.status,
44
+ stdout: r.stdout ?? '',
45
+ stderr: r.stderr ?? ''
46
+ };
47
+ };
48
+ /**
49
+ * A non-zero exit whose output shows an EXTERNAL runtime dependency is missing, not
50
+ * a code fault: a browser suite (Playwright/Cypress) whose browser binaries or system
51
+ * libraries were never installed here (mx5 run 10 item 2: `test:ct` must run in the
52
+ * gate, but on a box with no Playwright browsers it is an environment gap, not a FAIL).
53
+ * These exit non-zero (not 127), so they need output-shape recognition to skip.
54
+ */
55
+ export const ENV_GAP_OUTPUT_RE = /Executable doesn't exist|playwright install|browserType\.\w+: Executable|(?:wasn't|weren't) installed|Host system is missing dependencies|No usable sandbox|Cypress verification|Cypress executable (?:not found|was not found)|browser(?:s)? (?:is|are)? ?not installed/i;
56
+ /**
57
+ * A non-zero exit whose output shows the EXTERNAL INFRASTRUCTURE a launch script
58
+ * talks to is absent HERE — a database/daemon that is not running or not
59
+ * installed — rather than a fault in the script itself.
60
+ *
61
+ * NOT applied by default, and that is deliberate: a migrate/seed against no DB is
62
+ * an environment gap on this box, but the same wording out of a `test` run is a
63
+ * real failure the suite must own. Callers opt in per command by passing it in
64
+ * `gapPatterns`, which is why that parameter exists rather than a boolean.
65
+ */
66
+ export const INFRA_GAP_OUTPUT_RE = /ECONNREFUSED|connection refused|ENOTFOUND|EAI_AGAIN|is the server running|could not connect|cannot connect to the docker daemon|connect: connection|no such host/i;
67
+ /**
68
+ * The gap ladder, in order. FIRST MATCH WINS.
69
+ *
70
+ * `spawn-failed` is first and is kept distinguishable from every other row by its
71
+ * id: a tool-level gap (127 inside the chain, a missing browser, a timeout) means
72
+ * the runner demonstrably RAN, and only genuine spawn failures feed the gate's
73
+ * full-blindness guard (mx5 run 16 — see observabilityGapFailure).
74
+ */
75
+ const GAP_RULES = [
76
+ {
77
+ id: 'spawn-failed',
78
+ detail: run => run.failureMessage ?
79
+ `runner did not spawn (${run.failureMessage})`
80
+ : 'runner did not spawn',
81
+ applies: run => run.failedToStart
82
+ },
83
+ {
84
+ id: 'killed',
85
+ detail: () => 'killed (timeout or signal)',
86
+ applies: run => run.status === null
87
+ },
88
+ {
89
+ id: 'command-not-found',
90
+ detail: () => 'command not found (127)',
91
+ applies: (run, output) => isCommandNotFound(run.status, output)
92
+ },
93
+ {
94
+ id: 'missing-runtime',
95
+ detail: () => 'missing browser/runtime',
96
+ applies: (_run, output) => ENV_GAP_OUTPUT_RE.test(output)
97
+ },
98
+ {
99
+ id: 'infrastructure',
100
+ detail: () => 'external infrastructure unreachable',
101
+ applies: (_run, output, gapPatterns) => gapPatterns.some(re => re.test(output))
102
+ }
103
+ ];
104
+ /** Last ~`limit` chars of the command's combined output, one line, for the reason. */
105
+ export function outputTail(stdout, stderr, limit = 400) {
106
+ const combined = `${stdout}\n${stderr}`.trim();
107
+ if (combined.length === 0)
108
+ return '';
109
+ const tail = combined.slice(-limit).replace(/\s+/g, ' ').trim();
110
+ return combined.length > limit ? `…${tail}` : tail;
111
+ }
112
+ /**
113
+ * Decide what one finished command proved. Pure — no spawning, no filesystem, no
114
+ * clock — so every case is stateable as a literal.
115
+ *
116
+ * `gapPatterns` are the EXTRA output shapes this particular command may treat as
117
+ * an environment gap (see INFRA_GAP_OUTPUT_RE). Empty for an ordinary check.
118
+ */
119
+ export function classifyCommandRun(run, gapPatterns = []) {
120
+ // A clean exit is a pass before any gap shape is consulted: gap patterns
121
+ // describe output, and passing output can legitimately mention a database or
122
+ // a browser.
123
+ if (!run.failedToStart && run.status === 0)
124
+ return { outcome: 'pass' };
125
+ const output = `${run.stdout}\n${run.stderr}`;
126
+ for (const rule of GAP_RULES) {
127
+ if (rule.applies(run, output, gapPatterns)) {
128
+ return { outcome: 'gap', gap: rule.id, detail: rule.detail(run) };
129
+ }
130
+ }
131
+ // Only reachable with a real non-zero status: `failedToStart` and a null
132
+ // status are both gap rows above.
133
+ return {
134
+ outcome: 'fail',
135
+ status: run.status ?? -1,
136
+ tail: outputTail(run.stdout, run.stderr)
137
+ };
138
+ }
@@ -25,6 +25,7 @@
25
25
  * docs task all flow through the same integers.
26
26
  */
27
27
  /** A scored plan candidate — the minimum the adoption rule needs. */
28
+ import type { CoverageAccounting } from './requirements.js';
28
29
  export interface CoveragePlan {
29
30
  titles: string[];
30
31
  /**
@@ -94,3 +95,47 @@ export interface AdoptionDecision {
94
95
  * the no-requirements path also ships the best, not the last.
95
96
  */
96
97
  export declare function decideAdoption(current: CoveragePlan, retry: CoveragePlan, hasRequirements: boolean): AdoptionDecision;
98
+ /**
99
+ * One scored plan: its titles, and every judgement made ABOUT those titles.
100
+ *
101
+ * The whole point of this type is that it is indivisible. `accounting.mapped[i].task`
102
+ * is a 1-BASED INDEX INTO `plan.titles` — it means nothing except against the exact
103
+ * title list the coverage-map child was prompted with. Hold the two apart and the
104
+ * index silently addresses the wrong plan.
105
+ *
106
+ * That is not hypothetical. The coverage loop used to keep `best` (the scored plan)
107
+ * and a separate `let accounting`, updated on adoption as
108
+ * `accounting = cand.accounting ?? accounting`. `cand.accounting` is null whenever
109
+ * the coverage-map child throws or its output fails to parse — a fault that is
110
+ * SWALLOWED, while `decideAdoption` still adopts, because the monotonic guard runs
111
+ * on the grounded covered-set and not on the accounting. So: round 1 maps
112
+ * requirement #3 to task 2 of plan A; round 2 is adopted but its coverage-map child
113
+ * returns nothing; `planTitles` becomes plan B's while `accounting` stays plan A's;
114
+ * and `writeOwnedRequirements` binds requirement #3 to plan B's title #2 — a
115
+ * different task. The bounds filter at the write site only checks `1..titles.length`,
116
+ * so nothing catches it. Downstream, `ownedForTitle` force-appends that obligation
117
+ * into the wrong task's CONSTRAINTS and the final gate reports it unclaimed.
118
+ *
119
+ * Keeping accounting INSIDE the scored plan makes that state unrepresentable rather
120
+ * than merely fixed: adopting a plan is one assignment, so titles and accounting
121
+ * cannot come from different rounds. If you find yourself lifting `accounting` back
122
+ * out into its own variable, this is the bug you are re-introducing.
123
+ */
124
+ export interface ScoredPlan {
125
+ plan: CoveragePlan;
126
+ /**
127
+ * The per-requirement map for `plan.titles`, or null when the mapping child
128
+ * faulted. Null means "no accounting for THIS plan" — never "reuse the last
129
+ * plan's". A round that loses its accounting loses it; the judge-missing
130
+ * channel below is carried independently so nothing is stranded.
131
+ */
132
+ accounting: CoverageAccounting | null;
133
+ suspect: boolean;
134
+ /**
135
+ * The holistic-judge missing areas alone (NOT the quoted unmapped entries,
136
+ * which the grounded accounting already carries). This is the belt-only
137
+ * channel — areas requirement-extraction never captured, so nothing durable
138
+ * sees them unless carried explicitly at exhaustion.
139
+ */
140
+ judgeMissing: string[];
141
+ }