@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.
- package/dist/config/config.d.ts +7 -0
- package/dist/config/config.js +10 -4
- package/dist/config/register.d.ts +37 -0
- package/dist/config/register.js +89 -114
- package/dist/remote/events.js +0 -3
- package/dist/remote/register.js +12 -3
- package/dist/task/auto-orchestrator.js +119 -94
- package/dist/task/command-run.d.ts +104 -0
- package/dist/task/command-run.js +138 -0
- package/dist/task/coverage-loop.d.ts +45 -0
- package/dist/task/critique-probes.d.ts +82 -0
- package/dist/task/critique-probes.js +156 -0
- package/dist/task/enforce-guidelines.d.ts +14 -17
- package/dist/task/enforce-guidelines.js +44 -31
- package/dist/task/final-gate.d.ts +8 -10
- package/dist/task/final-gate.js +36 -74
- package/dist/task/gate-child.d.ts +104 -0
- package/dist/task/gate-child.js +177 -0
- package/dist/task/gate-deps.js +57 -205
- package/dist/task/orchestrator.js +13 -22
- package/dist/task/phases.js +109 -182
- package/dist/task/plan-session.d.ts +4 -22
- package/dist/task/plan-session.js +4 -33
- package/dist/task/question-dialog.d.ts +71 -0
- package/dist/task/question-dialog.js +89 -0
- package/dist/task/terminal-outcome.d.ts +67 -0
- package/dist/task/terminal-outcome.js +76 -0
- package/dist/task/type-only-answer.js +2 -3
- package/dist/workers/abstention.d.ts +71 -0
- package/dist/workers/abstention.js +108 -0
- package/dist/workers/docs-chunk.d.ts +74 -0
- package/dist/workers/docs-chunk.js +143 -0
- package/dist/workers/docs-core.d.ts +10 -1
- package/dist/workers/docs-core.js +22 -19
- package/dist/workers/docs-index.js +2 -69
- package/dist/workers/docs-project.d.ts +15 -1
- package/dist/workers/docs-project.js +27 -66
- package/dist/workers/fetch-core.d.ts +1 -1
- package/dist/workers/fetch-core.js +2 -1
- package/dist/workers/pi-worker-core.js +157 -86
- package/dist/workers/pi-worker-docs.js +5 -10
- package/dist/workers/pi-worker-fetch.js +8 -1
- package/dist/workers/typeonly-log.js +2 -10
- package/dist/workers/worker-failure.d.ts +91 -0
- package/dist/workers/worker-failure.js +82 -0
- 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
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
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
|
|
603
|
-
//
|
|
604
|
-
//
|
|
605
|
-
//
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
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
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
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
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
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
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
+
|
|
1029
|
-
|
|
1030
|
-
...
|
|
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
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
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
|
+
}
|