@mjasnikovs/pi-task 0.42.2 → 0.42.4

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.
@@ -1,4 +1,5 @@
1
1
  import { parseRepairTitleFile } from './root-cause-repair.js';
2
+ import { failClassOfReason } from './verify-work.js';
2
3
  /** A path-like token: at least one directory separator, ending in a file name. */
3
4
  const PATH_TOKEN_RE = /(?:[\w.@-]+[\\/])+[\w.@-]+\.\w+/g;
4
5
  function normalisePath(p) {
@@ -22,17 +23,17 @@ function resolveTracked(token, cwd, tracked) {
22
23
  return bySuffix.length === 1 ? bySuffix[0] : null;
23
24
  }
24
25
  /**
25
- * What a red health result is about. Null when the result records no failing
26
- * command (a legacy baseline, or a signal with no per-command detail) — there is
27
- * nothing a repair could be pinned to.
26
+ * What a red health result is about: its first failing command that `mayRepair`
27
+ * admits. Null when there is none — a legacy baseline, a signal with no
28
+ * per-command detail, or only reds no repair can fix so nothing to pin to.
28
29
  */
29
- export function healthRedSubject(health, cwd, tracked) {
30
- const failing = (health.commands ?? []).find(c => c.outcome === 'fail');
30
+ export function healthRedSubject(health, cwd, tracked, mayRepair = () => true) {
31
+ const failing = (health.commands ?? []).find(c => c.outcome === 'fail' && mayRepair(c));
31
32
  if (!failing)
32
33
  return null;
33
34
  const files = [];
34
35
  if (tracked) {
35
- for (const m of (health.output ?? '').matchAll(PATH_TOKEN_RE)) {
36
+ for (const m of (failing.output ?? health.output ?? '').matchAll(PATH_TOKEN_RE)) {
36
37
  const rel = resolveTracked(m[0], cwd, tracked);
37
38
  if (rel !== null && !files.includes(rel))
38
39
  files.push(rel);
@@ -40,6 +41,17 @@ export function healthRedSubject(health, cwd, tracked) {
40
41
  }
41
42
  return { command: failing.cmd, exitCode: failing.exitCode, files };
42
43
  }
44
+ /**
45
+ * Is a red TEST command owed? True when an open debt records a task's regression
46
+ * of it — an accepted `test suite:` FAIL naming the command. An inherited-health
47
+ * debt does not count: every task in a run whose suite needs a missing database
48
+ * records one.
49
+ */
50
+ export function suiteRegressionOwed(cmd, openDebts) {
51
+ return openDebts.some(d => d.origin !== 'inherited-health'
52
+ && failClassOfReason(d.reason) === 'test-suite'
53
+ && d.reason.includes(`\`${cmd}\``));
54
+ }
43
55
  // ─── Plan entry ──────────────────────────────────────────────────────────────
44
56
  /**
45
57
  * The plan title, in one of two fixed shapes the parser below recovers:
@@ -22,7 +22,7 @@ import type { ToolingClass } from './run-context.js';
22
22
  * a recommendation instead of being taken silently.
23
23
  * - 'threw' — the child failed; there is no recommendation at all.
24
24
  */
25
- export type AutoAnswerUnknownReason = 'model-unknown' | 'api-synthesis' | 'integration' | 'threw';
25
+ export type AutoAnswerUnknownReason = 'model-unknown' | 'api-synthesis' | 'integration' | 'threw' | 'deferred-breakage';
26
26
  export type AutoAnswer = {
27
27
  kind: 'answered';
28
28
  text: string;
@@ -18,6 +18,7 @@ import { getConfig } from '../config/config.js';
18
18
  import { buildExternalContext, gatherExternalContext } from './external-context.js';
19
19
  import { currentRunContext } from './run-context.js';
20
20
  import { readableMentions } from './mentions.js';
21
+ import { defersBreakage, deferredBreakageReaskHint } from './deferred-breakage.js';
21
22
  import { REFINE_PROMPT, RESEARCH_FILES_PROMPT, RESEARCH_APIS_PROMPT, RESEARCH_CONTEXT_PROMPT, RESEARCH_TOOLING_PROMPT, GRILL_GEN_PROMPT, GRILL_AUTO_ANSWER_PROMPT, GRILL_AUTO_FORMAT_HINT, COMPOSE_PROMPT, CRITIQUE_PROMPT, CRITIQUE_TRIAGE_PROMPT, VERIFY_TOOLING_PROMPT, MAX_GRILL_QUESTIONS } from './prompts.js';
22
23
  import { appendGateRecord, readSection, readTaskFile, removeTaskSection, setTaskSection, updateTaskFrontMatter } from './task-io.js';
23
24
  import { extractSection } from './task-parsers.js';
@@ -672,6 +673,55 @@ export async function phaseResearch(deps, refined, rawPrompt = '') {
672
673
  }
673
674
  return sections.map(({ name, text }) => `${name}\n${text}`).join('\n\n');
674
675
  }
676
+ /**
677
+ * Hold an answer to every guard. Each guard re-asks ONCE, and the answer that
678
+ * comes back faces every guard again: one that fixed a deferral by inventing an
679
+ * API is still caught. An answer that trips a guard it was already re-asked for,
680
+ * or a re-ask that produced no tagged answer, is surfaced as an unknown carrying
681
+ * that guard's reason. yolo.ts skips those, and a human sees them.
682
+ */
683
+ async function guardAutoAnswer(deps, first, guards) {
684
+ const reasked = new Set();
685
+ let parsed = first;
686
+ while (parsed.kind === 'answered') {
687
+ const answer = parsed.text;
688
+ let tripped;
689
+ for (const g of guards) {
690
+ const prompt = g.reask(answer);
691
+ if (prompt !== null) {
692
+ tripped = { reason: g.reason, prompt };
693
+ break;
694
+ }
695
+ }
696
+ if (!tripped)
697
+ return parsed;
698
+ const surfaced = {
699
+ kind: 'unknown',
700
+ suggested: answer,
701
+ raw: parsed.raw,
702
+ reason: tripped.reason
703
+ };
704
+ if (reasked.has(tripped.reason)) {
705
+ deps.logDebug?.(`grill-auto: ${tripped.reason} survived its re-ask — surfacing to user`);
706
+ return surfaced;
707
+ }
708
+ reasked.add(tripped.reason);
709
+ let again = null;
710
+ try {
711
+ const text = await runPhaseChild(deps, 'grill-auto', 'read', tripped.prompt);
712
+ if (autoAnswerHasTag(text))
713
+ again = parseAutoAnswer(text);
714
+ }
715
+ catch (e) {
716
+ if (isFatalChildCause(e))
717
+ throw e;
718
+ }
719
+ if (again === null)
720
+ return surfaced;
721
+ parsed = again;
722
+ }
723
+ return parsed;
724
+ }
675
725
  export async function phaseAutoAnswer(deps, refined, research, question) {
676
726
  const docsFocusedFn = deps.docsFocused ?? docsFocused;
677
727
  const fetchFocusedFn = deps.fetchFocused ?? fetchFocused;
@@ -723,53 +773,35 @@ export async function phaseAutoAnswer(deps, refined, research, question) {
723
773
  // otherwise a preamble line leaks out as the recommended answer.
724
774
  text = await runPhaseChild(deps, 'grill-auto', 'read', prependHint(GRILL_AUTO_FORMAT_HINT, basePrompt));
725
775
  }
726
- let parsed = parseAutoAnswer(text);
727
- // Anti-synthesis guard: the auto-answer invented
728
- // `Bun.mkdirSync` while research's APIS section carried the correct list,
729
- // and the invention was promoted into requirements + VERIFY. Deterministic
730
- // verbatim-substring check: an API-shaped identifier in the answer that is
731
- // absent from the research AND the question, in a namespace the research
732
- // claims to cover, triggers ONE re-ask with the verified research lines
733
- // injected. Still synthesizing after the re-ask ⇒ surface to the user as a
734
- // recommendation instead of silently promoting it (costs time, never work).
735
- if (parsed.kind === 'answered') {
736
- const synth = findSynthesizedApis(parsed.text, question, research);
737
- if (synth.length > 0) {
738
- deps.logDebug?.('grill-auto: unverified API identifier(s) in answer — '
739
- + synth.map(f => f.identifier).join(', ')
740
- + ' — re-asking with the research API list injected');
741
- let reasked = null;
742
- try {
743
- const text2 = await runPhaseChild(deps, 'grill-auto', 'read', prependHint(synthesizedApiReaskHint(synth, research), basePrompt));
744
- if (autoAnswerHasTag(text2))
745
- reasked = parseAutoAnswer(text2);
776
+ const parsed = await guardAutoAnswer(deps, parseAutoAnswer(text), [
777
+ // Anti-synthesis: the auto-answer invented `Bun.mkdirSync` while
778
+ // research's APIS section carried the correct list, and the invention
779
+ // was promoted into requirements + VERIFY. An API-shaped identifier
780
+ // absent from the research AND the question, in a namespace the
781
+ // research claims to cover, is re-asked with the verified lines injected.
782
+ {
783
+ reason: 'api-synthesis',
784
+ reask: answer => {
785
+ const synth = findSynthesizedApis(answer, question, research);
786
+ if (synth.length === 0)
787
+ return null;
788
+ deps.logDebug?.('grill-auto: unverified API identifier(s) in answer — '
789
+ + synth.map(f => f.identifier).join(', '));
790
+ return prependHint(synthesizedApiReaskHint(synth, research), basePrompt);
746
791
  }
747
- catch (e) {
748
- if (isFatalChildCause(e))
749
- throw e;
750
- reasked = null;
751
- }
752
- if (reasked === null
753
- || (reasked.kind === 'answered'
754
- && findSynthesizedApis(reasked.text, question, research).length > 0)) {
755
- const still = reasked ?? parsed;
756
- const suggested = still.kind === 'answered' ? still.text : parsed.text;
757
- deps.logDebug?.('grill-auto: answer still carries an unverified API — surfacing to user');
758
- parsed = {
759
- kind: 'unknown',
760
- suggested,
761
- raw: still.raw,
762
- // Tagged so a call site can tell this producer from the other
763
- // two: the suggestion is PROVEN to name an unverified API, so
764
- // it may only be judged by a human (yolo.ts must not take it).
765
- reason: 'api-synthesis'
766
- };
767
- }
768
- else {
769
- parsed = reasked;
792
+ },
793
+ // Behind the prompt's GREEN-SUITE CHECK: promoting "flag it for the test
794
+ // owner" is how mx5-n TASK_0004 turned the suite red for the rest of the run.
795
+ {
796
+ reason: 'deferred-breakage',
797
+ reask: answer => {
798
+ if (!defersBreakage(answer))
799
+ return null;
800
+ deps.logDebug?.('grill-auto: answer defers a breakage to a nonexistent owner');
801
+ return prependHint(deferredBreakageReaskHint(answer), basePrompt);
770
802
  }
771
803
  }
772
- }
804
+ ]);
773
805
  // Surviving-unknown routing: an integration / build-wiring unknown whose
774
806
  // wrong guess is a structural landmine must NOT be silently auto-answered.
775
807
  // We first try to ground it from fetched docs (the enrichment fan-out
@@ -36,6 +36,15 @@ declare const RESEARCH_APIS_PROMPT: (refined: string, filesMap: string | undefin
36
36
  declare const RESEARCH_CONTEXT_PROMPT: (refined: string) => string;
37
37
  declare const RESEARCH_TOOLING_PROMPT: (refined: string) => string;
38
38
  declare const GRILL_GEN_PROMPT: (refined: string, research: string, priorQA: string) => string;
39
+ /**
40
+ * The first triage check. MEASURED (mx5-n TASK_0004, 2026-09-17): the auto-answer
41
+ * chose "flag the test breakage as a known issue for the test owner". No task owns
42
+ * a test, so the suite stayed red through four tasks, and the fifth was specced
43
+ * as "add three files, change nothing else, make the whole suite pass" — an
44
+ * unsatisfiable spec whose implementer looped until the runaway guard fired.
45
+ * Exported on its own so a harness can measure the prompt with and without it.
46
+ */
47
+ export declare const GRILL_GREEN_SUITE_CHECK = "1. GREEN-SUITE CHECK \u2014 if an option would leave an existing test, lint, or build failing after this task (\"known issue\", \"for the test owner\", \"whoever revisits it\", \"a later step will fix it\"), that option is NOT available. Nobody downstream owns a breakage: every later task inherits a red suite it is told not to touch, and the run stalls there. A scope rule such as \"only create file X\" does NOT settle this \u2014 a test that asserts on X's output is part of X's change, so updating that test is in scope. If one option keeps the suite green, emit \"ANSWER: <that option>, and update <the test file> so the suite stays green\". If no offered option does, emit \"ANSWER: <the smallest change that keeps the suite green, naming the test file to update>\". Never answer with a deferral.";
39
48
  declare const GRILL_AUTO_ANSWER_PROMPT: (refined: string, research: string, question: string) => string;
40
49
  export declare const GRILL_AUTO_FORMAT_HINT: string;
41
50
  declare function composeRetryEmphasis(problem: string): string;
@@ -208,6 +208,15 @@ ${research}
208
208
 
209
209
  Answers so far:
210
210
  ${priorQA.trim() || '(none yet)'}`;
211
+ /**
212
+ * The first triage check. MEASURED (mx5-n TASK_0004, 2026-09-17): the auto-answer
213
+ * chose "flag the test breakage as a known issue for the test owner". No task owns
214
+ * a test, so the suite stayed red through four tasks, and the fifth was specced
215
+ * as "add three files, change nothing else, make the whole suite pass" — an
216
+ * unsatisfiable spec whose implementer looped until the runaway guard fired.
217
+ * Exported on its own so a harness can measure the prompt with and without it.
218
+ */
219
+ export const GRILL_GREEN_SUITE_CHECK = `1. GREEN-SUITE CHECK — if an option would leave an existing test, lint, or build failing after this task ("known issue", "for the test owner", "whoever revisits it", "a later step will fix it"), that option is NOT available. Nobody downstream owns a breakage: every later task inherits a red suite it is told not to touch, and the run stalls there. A scope rule such as "only create file X" does NOT settle this — a test that asserts on X's output is part of X's change, so updating that test is in scope. If one option keeps the suite green, emit "ANSWER: <that option>, and update <the test file> so the suite stays green". If no offered option does, emit "ANSWER: <the smallest change that keeps the suite green, naming the test file to update>". Never answer with a deferral.`;
211
220
  const GRILL_AUTO_ANSWER_PROMPT = (refined, research, question) => `You are pre-answering a clarifying question for an AI coding task. You have the refined task and the research notes. You may use the read tool on files mentioned in the research (e.g. package.json) if it helps.
212
221
 
213
222
  Your job is to produce a recommended default. If the default is one the user would almost certainly accept without thinking, tag it ANSWER and skip the user. Otherwise tag it UNKNOWN. YOU MUST PROPOSE A DEFAULT — never refuse, never leave it empty.
@@ -223,11 +232,13 @@ API-GROUNDING RULE: never name a concrete API (\`Namespace.member\`, an imported
223
232
 
224
233
  TRIAGE — run these checks IN ORDER first. The REVERSIBILITY TEST below applies ONLY to a question that survives all checks as a genuine preference.
225
234
 
226
- 1. ALREADY-DECIDED CHECK — scan the refined task and research for a value, shape, response body, schema, route, or requirement that ALREADY determines the answer. If one does, this is a fact, not a preference. Emit "ANSWER: <value taken from that source>". If your instinct or a "nicer" alternative contradicts that source, the SOURCE WINS — never override a stated contract with a preferred default. (E.g. a stated response shape { items, total, page, pageSize } already answers a pagination question — page/offset — you may NOT answer "cursor".)
235
+ ${GRILL_GREEN_SUITE_CHECK}
236
+
237
+ 2. ALREADY-DECIDED CHECK — scan the refined task and research for a value, shape, response body, schema, route, or requirement that ALREADY determines the answer. If one does, this is a fact, not a preference. Emit "ANSWER: <value taken from that source>". If your instinct or a "nicer" alternative contradicts that source, the SOURCE WINS — never override a stated contract with a preferred default. (E.g. a stated response shape { items, total, page, pageSize } already answers a pagination question — page/offset — you may NOT answer "cursor".)
227
238
 
228
- 2. FUNCTIONAL-REQUIREMENT CHECK — if the question is whether to include or defer a package, config file, or setup that something THIS task configures needs in order to FUNCTION (a build plugin's engine or required peer dependency, an entry file the build reads, a runtime module an import resolves to), then a "minimize / keep it minimal / defer to the step that uses it" preference does NOT override that functional requirement. A tool you wire up this step must have its required pieces present this step or the build/step is broken. Emit "ANSWER: <include it now, because configuring X requires it>". Do not defer something the step's own configuration depends on.
239
+ 3. FUNCTIONAL-REQUIREMENT CHECK — if the question is whether to include or defer a package, config file, or setup that something THIS task configures needs in order to FUNCTION (a build plugin's engine or required peer dependency, an entry file the build reads, a runtime module an import resolves to), then a "minimize / keep it minimal / defer to the step that uses it" preference does NOT override that functional requirement. A tool you wire up this step must have its required pieces present this step or the build/step is broken. Emit "ANSWER: <include it now, because configuring X requires it>". Do not defer something the step's own configuration depends on.
229
240
 
230
- 3. PREFERENCE — only if neither check fires (a genuine choice the sources do not determine), apply the REVERSIBILITY TEST.
241
+ 4. PREFERENCE — only if no check fires (a genuine choice the sources do not determine), apply the REVERSIBILITY TEST.
231
242
 
232
243
  REVERSIBILITY TEST:
233
244
  ANSWER: cheap to undo (output style, policy, report format, obvious scope, standard convention).
@@ -15,20 +15,26 @@ export interface HealthCommandResult {
15
15
  outcome: 'pass' | 'fail' | 'skip';
16
16
  /** Real exit status on a `fail`; null when nothing conclusive ran. */
17
17
  exitCode: number | null;
18
+ /** Absent on a record written before the suite joined the check, which ran
19
+ * statics only. A test red is judged, owed and repaired differently. */
20
+ kind?: 'static' | 'test';
21
+ /** This command's own captured output, on a `fail` only. */
22
+ output?: string;
18
23
  }
19
24
  export interface HealthOutcome {
20
- /** true → every discovered static check passed, or there was nothing to run.
21
- * false → a discovered command actually ran and exited non-zero. */
25
+ /** true → every discovered check passed or could not run, or there was nothing
26
+ * to run. false → a discovered command actually ran and exited non-zero. */
22
27
  ok: boolean;
23
- /** Human-readable reason. On a fail, names the exact command and exit code. */
28
+ /** Human-readable reason. On a fail, names every failing command and its exit code. */
24
29
  reason: string;
25
30
  /** Which manifest drove discovery, or null when none was found. */
26
31
  ecosystem: string | null;
27
- /** Every command that was REACHED, in run order. The run short-circuits on the
28
- * first failure, so commands after it are absent rather than passing. */
32
+ /** Every discovered command, in run order. A red one does not stop the run: a
33
+ * command it skipped would be absent from both sides of the differential, which
34
+ * then cannot see that command break. */
29
35
  commands: HealthCommandResult[];
30
36
  /**
31
- * First lines of the failing command's combined stderr+stdout — captured so a
37
+ * First lines of the first failing command's combined stderr+stdout — captured so a
32
38
  * FAIL is explainable from artifacts alone. The exit code alone does not say
33
39
  * what happened: eslint exits 1 for findings and 2 when it could not run at
34
40
  * all (a missing config, say), so "`bun run lint` exited 2" is unreproducible
@@ -56,6 +62,22 @@ export declare function discoverHealthCommands(cwd: string): {
56
62
  ecosystem: string | null;
57
63
  cmds: HealthCommand[];
58
64
  };
65
+ /**
66
+ * The project's OWN test commands, in the order the run-end gate runs them. One
67
+ * statement for both gates: final-gate.ts appends `build` to this list for the
68
+ * integration half, and `runRepoHealthCheck` runs it under `withTests` for the
69
+ * per-task differential, so the two cannot drift apart.
70
+ *
71
+ * Every test-shaped script, not just the one literally named `test`: a project's
72
+ * only browser-executing suite is often `test:ct`, and looking for `test` alone
73
+ * never runs it. Plain `test` leads, then every `test:`/`test_`/`test-` name in
74
+ * declaration order (Array#sort is stable). A watch-mode script is left out: it
75
+ * never exits, so all it can add is a timeout.
76
+ */
77
+ export declare function discoverTestCommands(cwd: string): {
78
+ ecosystem: string | null;
79
+ cmds: HealthCommand[];
80
+ };
59
81
  /** Progress hook: called with each command's label as it STARTS, so a caller can
60
82
  * keep a live status line naming what is currently running. */
61
83
  export type HealthProgress = (command: string) => void;
@@ -65,7 +87,7 @@ export type HealthProgress = (command: string) => void;
65
87
  * - No manifest / no static command → ok (nothing can regress).
66
88
  * - A command that CANNOT run (ENOENT / null exit / 127 inside the chain) → skipped,
67
89
  * treated as an environment gap, not a fault.
68
- * - A command that ran and exited non-zero → the first such failure is returned.
90
+ * - A command that ran and exited non-zero → red. Every command still runs.
69
91
  *
70
92
  * This module owns DISCOVERY and its own output policy. Running a command and
71
93
  * deciding what its ending MEANS is `command-run.ts`'s — one statement of the
@@ -86,4 +108,9 @@ export declare function runRepoHealthCheck(cwd: string, opts?: {
86
108
  onCommand?: HealthProgress;
87
109
  /** The spawner. Injected so a verdict is testable without a real shell. */
88
110
  run?: CommandRunner;
111
+ /** Also run the project's test commands, after the statics. Only a caller
112
+ * that will judge the result DIFFERENTIALLY may set this — see the header. */
113
+ withTests?: boolean;
89
114
  }): Promise<HealthOutcome>;
115
+ /** "`bun run lint` exited 1; `bun run test` exited 1" — every failing command. */
116
+ export declare function describeHealthFailures(commands: readonly HealthCommandResult[]): string;
@@ -14,11 +14,18 @@
14
14
  * verify gate's `repo-health` FAIL (verify-work.ts), which reaches the
15
15
  * AUTOFIX / ACCEPT picker in verify-resolution.ts.
16
16
  *
17
- * Scope is deliberately STATIC ANALYSIS ONLY (lint / typecheck / clippy / vet), never
18
- * `test`, `build`, `run`, or anything that boots a server or needs a database. Those
19
- * depend on external services the verify prompt already carves out as an environment
20
- * gap, so running them here would blame code for a missing database. Static analysis
21
- * is hermetic: it needs no network, no service and no fixtures.
17
+ * Scope is STATIC ANALYSIS (lint / typecheck / clippy / vet) by default, never
18
+ * `build`, `run`, or anything that boots a server. Static analysis is hermetic: it
19
+ * needs no network, no service and no fixtures, so its absolute exit code decides.
20
+ *
21
+ * The TEST suite joins only under `withTests`, and only for the DIFFERENTIAL
22
+ * (health-baseline.ts). MEASURED (mx5-n TASK_0004, 2026-09-17): a task turned a
23
+ * green suite red, its spec called that a "known issue for the test owner", the
24
+ * model gate passed it, and four tasks later the run stalled on an unsatisfiable
25
+ * spec. A suite that needs a database fails the same way before and after the
26
+ * task, which the differential reads as pre-existing — so running it here does
27
+ * not blame code for a missing database, and DOES blame the task that broke a
28
+ * suite the baseline saw green.
22
29
  *
23
30
  * Absence is a PASS, two ways: (1) no recognised manifest at all (a pure-docs or
24
31
  * config-only repo has nothing that can regress); (2) a manifest with no static-check
@@ -118,6 +125,48 @@ export function discoverHealthCommands(cwd) {
118
125
  }
119
126
  return { ecosystem: null, cmds: [] };
120
127
  }
128
+ /** `test:watch`, `jest --watchAll`, `vitest watch`, `bun test --watch`. */
129
+ function isWatchScript(name, body) {
130
+ return (/watch/i.test(name) || /(?:^|\s)--watch(?:All)?(?=[\s=]|$)|(?:^|\s)watch(?=\s|$)/.test(body));
131
+ }
132
+ /**
133
+ * The project's OWN test commands, in the order the run-end gate runs them. One
134
+ * statement for both gates: final-gate.ts appends `build` to this list for the
135
+ * integration half, and `runRepoHealthCheck` runs it under `withTests` for the
136
+ * per-task differential, so the two cannot drift apart.
137
+ *
138
+ * Every test-shaped script, not just the one literally named `test`: a project's
139
+ * only browser-executing suite is often `test:ct`, and looking for `test` alone
140
+ * never runs it. Plain `test` leads, then every `test:`/`test_`/`test-` name in
141
+ * declaration order (Array#sort is stable). A watch-mode script is left out: it
142
+ * never exits, so all it can add is a timeout.
143
+ */
144
+ export function discoverTestCommands(cwd) {
145
+ if (existsSync(path.join(cwd, 'package.json'))) {
146
+ const s = packageScripts(cwd);
147
+ const names = Object.keys(s).filter(n => (n === 'test' || /^test[:_-]/.test(n)) && !isWatchScript(n, s[n]));
148
+ names.sort((a, b) => a === 'test' ? -1
149
+ : b === 'test' ? 1
150
+ : 0);
151
+ return { ecosystem: 'package.json', cmds: names.map(n => ['bun', ['run', n]]) };
152
+ }
153
+ if (existsSync(path.join(cwd, 'Makefile'))) {
154
+ return {
155
+ ecosystem: 'Makefile',
156
+ cmds: makeHasTarget(cwd, 'test') ? [['make', ['test']]] : []
157
+ };
158
+ }
159
+ if (existsSync(path.join(cwd, 'Cargo.toml'))) {
160
+ return { ecosystem: 'Cargo.toml', cmds: [['cargo', ['test', '--quiet']]] };
161
+ }
162
+ if (existsSync(path.join(cwd, 'go.mod'))) {
163
+ return { ecosystem: 'go.mod', cmds: [['go', ['test', './...']]] };
164
+ }
165
+ if (existsSync(path.join(cwd, 'pyproject.toml'))) {
166
+ return { ecosystem: 'pyproject.toml', cmds: [['pytest', ['-q']]] };
167
+ }
168
+ return { ecosystem: null, cmds: [] };
169
+ }
121
170
  /** The nothing-to-run outcome, shared by both runners. */
122
171
  function noCommandOutcome(ecosystem) {
123
172
  return {
@@ -134,7 +183,7 @@ function noCommandOutcome(ecosystem) {
134
183
  * - No manifest / no static command → ok (nothing can regress).
135
184
  * - A command that CANNOT run (ENOENT / null exit / 127 inside the chain) → skipped,
136
185
  * treated as an environment gap, not a fault.
137
- * - A command that ran and exited non-zero → the first such failure is returned.
186
+ * - A command that ran and exited non-zero → red. Every command still runs.
138
187
  *
139
188
  * This module owns DISCOVERY and its own output policy. Running a command and
140
189
  * deciding what its ending MEANS is `command-run.ts`'s — one statement of the
@@ -150,12 +199,18 @@ function noCommandOutcome(ecosystem) {
150
199
  * widget has just been cleared.
151
200
  */
152
201
  export async function runRepoHealthCheck(cwd, opts = {}) {
153
- const { ecosystem, cmds } = discoverHealthCommands(cwd);
202
+ const statics = discoverHealthCommands(cwd);
203
+ const tests = opts.withTests ? discoverTestCommands(cwd) : { ecosystem: null, cmds: [] };
204
+ const ecosystem = statics.ecosystem ?? tests.ecosystem;
205
+ const cmds = [
206
+ ...statics.cmds.map(([bin, args]) => ({ bin, args, test: false })),
207
+ ...tests.cmds.map(([bin, args]) => ({ bin, args, test: true }))
208
+ ];
154
209
  if (!ecosystem || cmds.length === 0)
155
210
  return noCommandOutcome(ecosystem);
156
211
  const run = opts.run ?? spawnCommand;
157
212
  const commands = [];
158
- for (const [bin, args] of cmds) {
213
+ for (const { bin, args, test } of cmds) {
159
214
  const cmd = `${bin} ${args.join(' ')}`;
160
215
  opts.onCommand?.(cmd);
161
216
  // Runner resolution: a PATH-stripped environment must not
@@ -175,31 +230,52 @@ export async function runRepoHealthCheck(cwd, opts = {}) {
175
230
  // ladder's `tail` keeps 400 characters, and that difference is real — a
176
231
  // truncated lint report is unactionable. So the run is classified, not
177
232
  // consumed: the verdict decides, the raw streams are what we show.
178
- // `runtimeGap: false` this ladder is NARROWER than the gate's. The
179
- // browser/runtime row was written for the gate's TEST commands; here the
180
- // commands are lint and typecheck, and its pattern matches ordinary
181
- // English, so a genuine report quoting "browsers are not installed" would
182
- // skip the static check and certify the repo healthy.
183
- const verdict = classifyCommandRun(r, [], { runtimeGap: false });
233
+ // `runtimeGap` and `emptySuite` only for a TEST command. Both rows read the
234
+ // command's output, and on lint and typecheck a genuine report quoting
235
+ // "browsers are not installed" would skip the static check and certify
236
+ // the repo healthy.
237
+ const verdict = classifyCommandRun(r, [], { runtimeGap: test, emptySuite: test });
238
+ const kind = test ? 'test' : 'static';
184
239
  if (verdict.outcome !== 'fail') {
185
240
  const passed = verdict.outcome === 'pass';
186
- commands.push({ cmd, outcome: passed ? 'pass' : 'skip', exitCode: passed ? 0 : null });
241
+ commands.push({
242
+ cmd,
243
+ outcome: passed ? 'pass' : 'skip',
244
+ exitCode: passed ? 0 : null,
245
+ kind
246
+ });
187
247
  continue;
188
248
  }
189
- commands.push({ cmd, outcome: 'fail', exitCode: verdict.status });
249
+ commands.push({
250
+ cmd,
251
+ outcome: 'fail',
252
+ exitCode: verdict.status,
253
+ kind,
254
+ output: captureHealthOutput(r.stdout, r.stderr)
255
+ });
256
+ }
257
+ const firstFail = commands.find(c => c.outcome === 'fail');
258
+ if (firstFail) {
190
259
  return {
191
260
  ok: false,
192
- reason: `\`${cmd}\` exited ${verdict.status}`,
261
+ reason: describeHealthFailures(commands),
193
262
  ecosystem,
194
263
  commands,
195
- output: captureHealthOutput(r.stdout, r.stderr)
264
+ output: firstFail.output ?? ''
196
265
  };
197
266
  }
198
267
  return {
199
268
  ok: true,
200
- reason: `${ecosystem}: static checks passed`,
269
+ reason: `${ecosystem}: static checks${tests.cmds.length > 0 ? ' and tests' : ''} passed`,
201
270
  ecosystem,
202
271
  commands,
203
272
  output: ''
204
273
  };
205
274
  }
275
+ /** "`bun run lint` exited 1; `bun run test` exited 1" — every failing command. */
276
+ export function describeHealthFailures(commands) {
277
+ return commands
278
+ .filter(c => c.outcome === 'fail')
279
+ .map(c => `\`${c.cmd}\` exited ${c.exitCode}`)
280
+ .join('; ');
281
+ }
@@ -2,6 +2,7 @@ import type { SpawnFn } from '../shared/child-process.js';
2
2
  import { type EcosystemId } from '../workers/docs-ecosystems.js';
3
3
  import type { GateEvidence } from './gate-evidence.js';
4
4
  import { type OrientationResult } from './orientation.js';
5
+ import { type HealthOutcome } from './repo-health-check.js';
5
6
  /**
6
7
  * What a verified command is FOR, and the only column that decides whether the
7
8
  * gate-evidence runner may execute it: `check` and `build` terminate on their own,
@@ -79,6 +80,8 @@ export declare class RunContext {
79
80
  private _toolingHash;
80
81
  private _evidence;
81
82
  private _evidenceQueue;
83
+ private _health;
84
+ private _healthQueue;
82
85
  constructor(opts: RunContextOptions);
83
86
  /** `git ls-files` for this run; '' outside a git tree (see file-inventory.ts). */
84
87
  inventory(): Promise<string>;
@@ -134,6 +137,17 @@ export declare class RunContext {
134
137
  */
135
138
  gateEvidenceFor(produce: EvidenceRunner): Promise<GateEvidence>;
136
139
  private freshEvidence;
140
+ /**
141
+ * The repo-health check with its suite, at most once per tree, on the same terms
142
+ * as {@link gateEvidenceFor}. A verify, the enforce baseline on the commit it
143
+ * just judged and the next task's checkpoint all measure one tree; each running
144
+ * the whole suite again is the cost this removes.
145
+ *
146
+ * Stored under the tree the check LEFT, not the one it found: a `--fix` lint
147
+ * moves the tree, and the result describes the fixed one.
148
+ */
149
+ healthFor(produce: () => Promise<HealthOutcome>): Promise<HealthOutcome>;
150
+ private freshHealth;
137
151
  }
138
152
  /** Open a run: every task inside it now shares one context. Nests — an inner
139
153
  * bracket returns the outer run's context untouched. */
@@ -19,8 +19,8 @@
19
19
  * run found it and is not re-read: a mid-run inventory refresh would hand two
20
20
  * tasks different orientation cores for the same question.
21
21
  *
22
- * TREE HASH is the other half, used by the gate-evidence cache; its one
23
- * implementation lives in tree-hash.ts.
22
+ * TREE HASH is the other half, used by the gate-evidence and repo-health caches;
23
+ * its one implementation lives in tree-hash.ts.
24
24
  */
25
25
  import { createHash } from 'node:crypto';
26
26
  import * as fsp from 'node:fs/promises';
@@ -90,6 +90,8 @@ export class RunContext {
90
90
  _toolingHash;
91
91
  _evidence;
92
92
  _evidenceQueue = Promise.resolve();
93
+ _health;
94
+ _healthQueue = Promise.resolve();
93
95
  constructor(opts) {
94
96
  this.cwd = opts.cwd;
95
97
  this.runId = opts.runId ?? newRunToken();
@@ -238,6 +240,31 @@ export class RunContext {
238
240
  this._evidence = { hash, value };
239
241
  return value;
240
242
  }
243
+ /**
244
+ * The repo-health check with its suite, at most once per tree, on the same terms
245
+ * as {@link gateEvidenceFor}. A verify, the enforce baseline on the commit it
246
+ * just judged and the next task's checkpoint all measure one tree; each running
247
+ * the whole suite again is the cost this removes.
248
+ *
249
+ * Stored under the tree the check LEFT, not the one it found: a `--fix` lint
250
+ * moves the tree, and the result describes the fixed one.
251
+ */
252
+ healthFor(produce) {
253
+ const next = this._healthQueue.then(() => this.freshHealth(produce));
254
+ this._healthQueue = next.catch(() => { });
255
+ return next;
256
+ }
257
+ async freshHealth(produce) {
258
+ const opts = this._signal ? { signal: this._signal } : {};
259
+ const found = await treeHash(this.cwd, opts);
260
+ if (found !== null && this._health?.hash === found)
261
+ return this._health.value;
262
+ const value = await produce();
263
+ const left = await treeHash(this.cwd, opts);
264
+ if (left !== null)
265
+ this._health = { hash: left, value };
266
+ return value;
267
+ }
241
268
  }
242
269
  /**
243
270
  * The context of the run that owns the session right now, set by the run bracket.
@@ -90,8 +90,12 @@ export type VerifyOutcome = VerifyPass | VerifyFail;
90
90
  * `static-checks` is the RUN-level twin of `repo-health`: final-gate.ts mints
91
91
  * `VERIFY_FAIL_PREFIX['static-checks']` for the same concept at the other
92
92
  * altitude, and `isStaticClass` answers true for both.
93
+ *
94
+ * `test-suite` is the same deterministic check when a TEST command regressed. It
95
+ * is its own class because a passing lint proves nothing about a suite: a static
96
+ * debt closes when the statics pass, and a lint fix cannot green a test.
93
97
  */
94
- export type VerifyFailClass = 'repo-health' | 'static-checks' | 'unobserved' | 'model-verdict' | 'harness-fault';
98
+ export type VerifyFailClass = 'repo-health' | 'static-checks' | 'test-suite' | 'unobserved' | 'model-verdict' | 'harness-fault';
95
99
  /**
96
100
  * The prefix each class MINTS, stated once.
97
101
  *
@@ -117,6 +121,8 @@ export declare function verifyFailClass(o: {
117
121
  export declare function failClassOfReason(reason: string): VerifyFailClass | undefined;
118
122
  /** Does this class name a deterministic whole-repo static check, at either altitude? */
119
123
  export declare function isStaticClass(cls: VerifyFailClass | undefined): boolean;
124
+ /** Does this class name the deterministic whole-repo check, suite included? */
125
+ export declare function isHealthClass(cls: VerifyFailClass | undefined): boolean;
120
126
  /**
121
127
  * The delivered spec's TEXT, for the children that must read its prose verbatim.
122
128
  * The slicing itself lives in spec-model.ts beside the parser, so "the spec