@mjasnikovs/pi-task 0.42.3 → 0.42.5

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.
@@ -16,8 +16,13 @@
16
16
  * the dedup ledger — a title covering the same command, or any of the same files,
17
17
  * means no second entry, checked-off ones included, which is what stops a repair
18
18
  * that failed from being re-spawned.
19
+ *
20
+ * A red TEST command is repaired only when a task's regression of it is on the
21
+ * debt ledger. A suite can also be red because a database is not up here, or
22
+ * because its script is a placeholder `exit 1`, and no repair task can fix either.
19
23
  */
20
24
  import type { HealthSignal } from './health-baseline.js';
25
+ import type { HealthCommandResult } from './repo-health-check.js';
21
26
  /** The failing check, and what its output named. */
22
27
  export interface HealthRed {
23
28
  command: string;
@@ -31,13 +36,23 @@ export interface HealthRedOwners {
31
36
  owners: string[];
32
37
  }
33
38
  /**
34
- * What a red health result is about. Null when the result records no failing
35
- * command (a legacy baseline, or a signal with no per-command detail) — there is
36
- * nothing a repair could be pinned to.
39
+ * What a red health result is about: its first failing command that `mayRepair`
40
+ * admits. Null when there is none — a legacy baseline, a signal with no
41
+ * per-command detail, or only reds no repair can fix so nothing to pin to.
37
42
  */
38
43
  export declare function healthRedSubject(health: HealthSignal & {
39
44
  output?: string;
40
- }, cwd: string, tracked: readonly string[] | null): HealthRed | null;
45
+ }, cwd: string, tracked: readonly string[] | null, mayRepair?: (c: HealthCommandResult) => boolean): HealthRed | null;
46
+ /**
47
+ * Is a red TEST command owed? True when an open debt records a task's regression
48
+ * of it — an accepted `test suite:` FAIL naming the command. An inherited-health
49
+ * debt does not count: every task in a run whose suite needs a missing database
50
+ * records one.
51
+ */
52
+ export declare function suiteRegressionOwed(cmd: string, openDebts: readonly {
53
+ reason: string;
54
+ origin?: string;
55
+ }[]): boolean;
41
56
  /**
42
57
  * The plan title, in one of two fixed shapes the parser below recovers:
43
58
  * `repair src/a.ts, src/b.ts: \`bun run lint\` exits 1 (introduced by TASK_0033)`
@@ -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:
@@ -673,6 +673,55 @@ export async function phaseResearch(deps, refined, rawPrompt = '') {
673
673
  }
674
674
  return sections.map(({ name, text }) => `${name}\n${text}`).join('\n\n');
675
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
+ }
676
725
  export async function phaseAutoAnswer(deps, refined, research, question) {
677
726
  const docsFocusedFn = deps.docsFocused ?? docsFocused;
678
727
  const fetchFocusedFn = deps.fetchFocused ?? fetchFocused;
@@ -724,84 +773,35 @@ export async function phaseAutoAnswer(deps, refined, research, question) {
724
773
  // otherwise a preamble line leaks out as the recommended answer.
725
774
  text = await runPhaseChild(deps, 'grill-auto', 'read', prependHint(GRILL_AUTO_FORMAT_HINT, basePrompt));
726
775
  }
727
- let parsed = parseAutoAnswer(text);
728
- // Anti-synthesis guard: the auto-answer invented
729
- // `Bun.mkdirSync` while research's APIS section carried the correct list,
730
- // and the invention was promoted into requirements + VERIFY. Deterministic
731
- // verbatim-substring check: an API-shaped identifier in the answer that is
732
- // absent from the research AND the question, in a namespace the research
733
- // claims to cover, triggers ONE re-ask with the verified research lines
734
- // injected. Still synthesizing after the re-ask ⇒ surface to the user as a
735
- // recommendation instead of silently promoting it (costs time, never work).
736
- if (parsed.kind === 'answered') {
737
- const synth = findSynthesizedApis(parsed.text, question, research);
738
- if (synth.length > 0) {
739
- deps.logDebug?.('grill-auto: unverified API identifier(s) in answer — '
740
- + synth.map(f => f.identifier).join(', ')
741
- + ' — re-asking with the research API list injected');
742
- let reasked = null;
743
- try {
744
- const text2 = await runPhaseChild(deps, 'grill-auto', 'read', prependHint(synthesizedApiReaskHint(synth, research), basePrompt));
745
- if (autoAnswerHasTag(text2))
746
- reasked = parseAutoAnswer(text2);
747
- }
748
- catch (e) {
749
- if (isFatalChildCause(e))
750
- throw e;
751
- reasked = null;
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);
752
791
  }
753
- if (reasked === null
754
- || (reasked.kind === 'answered'
755
- && findSynthesizedApis(reasked.text, question, research).length > 0)) {
756
- const still = reasked ?? parsed;
757
- const suggested = still.kind === 'answered' ? still.text : parsed.text;
758
- deps.logDebug?.('grill-auto: answer still carries an unverified API — surfacing to user');
759
- parsed = {
760
- kind: 'unknown',
761
- suggested,
762
- raw: still.raw,
763
- // Tagged so a call site can tell this producer from the other
764
- // two: the suggestion is PROVEN to name an unverified API, so
765
- // it may only be judged by a human (yolo.ts must not take it).
766
- reason: 'api-synthesis'
767
- };
768
- }
769
- else {
770
- 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);
771
802
  }
772
803
  }
773
- }
774
- // Deterministic backstop behind the prompt's GREEN-SUITE CHECK: an answer
775
- // that defers a breakage to "the test owner" gets ONE re-ask, and a second
776
- // deferral is surfaced as an unsafe unknown — yolo.ts skips it, a human
777
- // sees it. Promoting it is how mx5-n TASK_0004 turned the suite red for
778
- // the rest of the run.
779
- if (parsed.kind === 'answered' && defersBreakage(parsed.text)) {
780
- deps.logDebug?.('grill-auto: answer defers a breakage to a nonexistent owner — re-asking once');
781
- let reasked = null;
782
- try {
783
- const text2 = await runPhaseChild(deps, 'grill-auto', 'read', prependHint(deferredBreakageReaskHint(parsed.text), basePrompt));
784
- if (autoAnswerHasTag(text2))
785
- reasked = parseAutoAnswer(text2);
786
- }
787
- catch (e) {
788
- if (isFatalChildCause(e))
789
- throw e;
790
- reasked = null;
791
- }
792
- if (reasked !== null && reasked.kind === 'answered' && !defersBreakage(reasked.text)) {
793
- parsed = reasked;
794
- }
795
- else {
796
- deps.logDebug?.('grill-auto: answer still defers the breakage — surfacing to user');
797
- parsed = {
798
- kind: 'unknown',
799
- suggested: reasked?.kind === 'answered' ? reasked.text : parsed.text,
800
- raw: (reasked ?? parsed).raw,
801
- reason: 'deferred-breakage'
802
- };
803
- }
804
- }
804
+ ]);
805
805
  // Surviving-unknown routing: an integration / build-wiring unknown whose
806
806
  // wrong guess is a structural landmine must NOT be silently auto-answered.
807
807
  // We first try to ground it from fetched docs (the enrichment fan-out
@@ -1,4 +1,4 @@
1
- import { type CommandRunner } from './command-run.js';
1
+ import { type CommandGapId, type CommandRunner } from './command-run.js';
2
2
  /**
3
3
  * What ONE discovered command did. `outcome` is `classifyCommandRun`'s verdict, so
4
4
  * a tool that could not run at all is `skip` rather than a zero-exit pass.
@@ -15,20 +15,29 @@ 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
+ /** Why nothing was observed, on a `skip`. The differential reads it: a runner
22
+ * that found no tests is a gap alone and a regression against a suite. */
23
+ gap?: CommandGapId;
24
+ /** This command's own captured output, on a `fail` only. */
25
+ output?: string;
18
26
  }
19
27
  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. */
28
+ /** true → every discovered check passed or could not run, or there was nothing
29
+ * to run. false → a discovered command actually ran and exited non-zero. */
22
30
  ok: boolean;
23
- /** Human-readable reason. On a fail, names the exact command and exit code. */
31
+ /** Human-readable reason. On a fail, names every failing command and its exit code. */
24
32
  reason: string;
25
33
  /** Which manifest drove discovery, or null when none was found. */
26
34
  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. */
35
+ /** Every discovered command, in run order. A red one does not stop the run: a
36
+ * command it skipped would be absent from both sides of the differential, which
37
+ * then cannot see that command break. */
29
38
  commands: HealthCommandResult[];
30
39
  /**
31
- * First lines of the failing command's combined stderr+stdout — captured so a
40
+ * First lines of the first failing command's combined stderr+stdout — captured so a
32
41
  * FAIL is explainable from artifacts alone. The exit code alone does not say
33
42
  * what happened: eslint exits 1 for findings and 2 when it could not run at
34
43
  * all (a missing config, say), so "`bun run lint` exited 2" is unreproducible
@@ -65,7 +74,8 @@ export declare function discoverHealthCommands(cwd: string): {
65
74
  * Every test-shaped script, not just the one literally named `test`: a project's
66
75
  * only browser-executing suite is often `test:ct`, and looking for `test` alone
67
76
  * never runs it. Plain `test` leads, then every `test:`/`test_`/`test-` name in
68
- * declaration order (Array#sort is stable).
77
+ * declaration order (Array#sort is stable). A watch-mode script is left out: it
78
+ * never exits, so all it can add is a timeout.
69
79
  */
70
80
  export declare function discoverTestCommands(cwd: string): {
71
81
  ecosystem: string | null;
@@ -80,7 +90,7 @@ export type HealthProgress = (command: string) => void;
80
90
  * - No manifest / no static command → ok (nothing can regress).
81
91
  * - A command that CANNOT run (ENOENT / null exit / 127 inside the chain) → skipped,
82
92
  * treated as an environment gap, not a fault.
83
- * - A command that ran and exited non-zero → the first such failure is returned.
93
+ * - A command that ran and exited non-zero → red. Every command still runs.
84
94
  *
85
95
  * This module owns DISCOVERY and its own output policy. Running a command and
86
96
  * deciding what its ending MEANS is `command-run.ts`'s — one statement of the
@@ -105,3 +115,5 @@ export declare function runRepoHealthCheck(cwd: string, opts?: {
105
115
  * that will judge the result DIFFERENTIALLY may set this — see the header. */
106
116
  withTests?: boolean;
107
117
  }): Promise<HealthOutcome>;
118
+ /** "`bun run lint` exited 1; `bun run test` exited 1" — every failing command. */
119
+ export declare function describeHealthFailures(commands: readonly HealthCommandResult[]): string;
@@ -125,6 +125,17 @@ export function discoverHealthCommands(cwd) {
125
125
  }
126
126
  return { ecosystem: null, cmds: [] };
127
127
  }
128
+ /**
129
+ * `test:watch`, `jest --watchAll`, `vitest watch`, `bun test --watch`.
130
+ *
131
+ * The flag is read by its VALUE, not its presence: `--watchAll=false` is how a CI
132
+ * script turns watch off, and excluding it drops the only `test` script such a
133
+ * repo has.
134
+ */
135
+ function isWatchScript(name, body) {
136
+ return (/watch/i.test(name)
137
+ || /(?:^|\s)--watch(?:All)?(?:=(?:true|1))?(?=\s|$)|(?:^|\s)watch(?=\s|$)/.test(body));
138
+ }
128
139
  /**
129
140
  * The project's OWN test commands, in the order the run-end gate runs them. One
130
141
  * statement for both gates: final-gate.ts appends `build` to this list for the
@@ -134,12 +145,13 @@ export function discoverHealthCommands(cwd) {
134
145
  * Every test-shaped script, not just the one literally named `test`: a project's
135
146
  * only browser-executing suite is often `test:ct`, and looking for `test` alone
136
147
  * never runs it. Plain `test` leads, then every `test:`/`test_`/`test-` name in
137
- * declaration order (Array#sort is stable).
148
+ * declaration order (Array#sort is stable). A watch-mode script is left out: it
149
+ * never exits, so all it can add is a timeout.
138
150
  */
139
151
  export function discoverTestCommands(cwd) {
140
152
  if (existsSync(path.join(cwd, 'package.json'))) {
141
153
  const s = packageScripts(cwd);
142
- const names = Object.keys(s).filter(n => n === 'test' || /^test[:_-]/.test(n));
154
+ const names = Object.keys(s).filter(n => (n === 'test' || /^test[:_-]/.test(n)) && !isWatchScript(n, s[n]));
143
155
  names.sort((a, b) => a === 'test' ? -1
144
156
  : b === 'test' ? 1
145
157
  : 0);
@@ -178,7 +190,7 @@ function noCommandOutcome(ecosystem) {
178
190
  * - No manifest / no static command → ok (nothing can regress).
179
191
  * - A command that CANNOT run (ENOENT / null exit / 127 inside the chain) → skipped,
180
192
  * treated as an environment gap, not a fault.
181
- * - A command that ran and exited non-zero → the first such failure is returned.
193
+ * - A command that ran and exited non-zero → red. Every command still runs.
182
194
  *
183
195
  * This module owns DISCOVERY and its own output policy. Running a command and
184
196
  * deciding what its ending MEANS is `command-run.ts`'s — one statement of the
@@ -225,23 +237,39 @@ export async function runRepoHealthCheck(cwd, opts = {}) {
225
237
  // ladder's `tail` keeps 400 characters, and that difference is real — a
226
238
  // truncated lint report is unactionable. So the run is classified, not
227
239
  // consumed: the verdict decides, the raw streams are what we show.
228
- // `runtimeGap` only for a TEST command. The browser/runtime row was
229
- // written for the gate's test commands and its pattern matches ordinary
230
- // English, so on lint and typecheck a genuine report quoting "browsers are
231
- // not installed" would skip the static check and certify the repo healthy.
232
- const verdict = classifyCommandRun(r, [], { runtimeGap: test });
240
+ // `runtimeGap` and `emptySuite` only for a TEST command. Both rows read the
241
+ // command's output, and on lint and typecheck a genuine report quoting
242
+ // "browsers are not installed" would skip the static check and certify
243
+ // the repo healthy.
244
+ const verdict = classifyCommandRun(r, [], { runtimeGap: test, emptySuite: test });
245
+ const kind = test ? 'test' : 'static';
233
246
  if (verdict.outcome !== 'fail') {
234
247
  const passed = verdict.outcome === 'pass';
235
- commands.push({ cmd, outcome: passed ? 'pass' : 'skip', exitCode: passed ? 0 : null });
248
+ commands.push({
249
+ cmd,
250
+ outcome: passed ? 'pass' : 'skip',
251
+ exitCode: passed ? 0 : null,
252
+ kind,
253
+ ...(verdict.outcome === 'gap' ? { gap: verdict.gap } : {})
254
+ });
236
255
  continue;
237
256
  }
238
- commands.push({ cmd, outcome: 'fail', exitCode: verdict.status });
257
+ commands.push({
258
+ cmd,
259
+ outcome: 'fail',
260
+ exitCode: verdict.status,
261
+ kind,
262
+ output: captureHealthOutput(r.stdout, r.stderr)
263
+ });
264
+ }
265
+ const firstFail = commands.find(c => c.outcome === 'fail');
266
+ if (firstFail) {
239
267
  return {
240
268
  ok: false,
241
- reason: `\`${cmd}\` exited ${verdict.status}`,
269
+ reason: describeHealthFailures(commands),
242
270
  ecosystem,
243
271
  commands,
244
- output: captureHealthOutput(r.stdout, r.stderr)
272
+ output: firstFail.output ?? ''
245
273
  };
246
274
  }
247
275
  return {
@@ -252,3 +280,12 @@ export async function runRepoHealthCheck(cwd, opts = {}) {
252
280
  output: ''
253
281
  };
254
282
  }
283
+ /** "`bun run lint` exited 1; `bun run test` exited 1" — every failing command. */
284
+ export function describeHealthFailures(commands) {
285
+ return commands
286
+ .filter(c => c.outcome === 'fail' || c.gap === 'empty-suite')
287
+ .map(c => c.outcome === 'fail' ?
288
+ `\`${c.cmd}\` exited ${c.exitCode}`
289
+ : `\`${c.cmd}\` found no tests to run`)
290
+ .join('; ');
291
+ }
@@ -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