@mjasnikovs/pi-task 0.42.3 → 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.
@@ -155,11 +155,12 @@ export declare function writeAcceptDebts(cwd: string, debts: AcceptDebt[]): Prom
155
155
  /** The debts nothing has closed yet — the only ones a re-check may read. */
156
156
  export declare function readOpenAcceptDebts(cwd: string): Promise<AcceptDebt[]>;
157
157
  /**
158
- * Close every open static-class debt that names `command`, stamping the task
158
+ * Close every open health-class debt that names `command`, stamping the task
159
159
  * whose verified work made that check pass again. Returns the debts closed.
160
160
  * A reason that quotes the command is the whole match: the health-check reason
161
161
  * (`repo health: \`bun run lint\` exited 1`) and its inherited form both do,
162
- * and nothing else in the ledger quotes a health command. Best-effort.
162
+ * and nothing else in the ledger quotes a health command. A suite debt closes
163
+ * here too: the repair verified clean, and that check runs the suite. Best-effort.
163
164
  */
164
165
  export declare function closeHealthDebts(cwd: string, command: string, resolvedBy: string): Promise<AcceptDebt[]>;
165
166
  /**
@@ -35,7 +35,7 @@ import { existsSync } from 'node:fs';
35
35
  import * as path from 'node:path';
36
36
  import * as fsp from 'node:fs/promises';
37
37
  import { runVerifyCommandLine, spawnCommand } from './command-run.js';
38
- import { failClassOfReason, isStaticClass } from './verify-work.js';
38
+ import { failClassOfReason, isHealthClass, isStaticClass } from './verify-work.js';
39
39
  import { taskThatIntroduced } from './task-provenance.js';
40
40
  import { makeLedger } from './ledger.js';
41
41
  import { parseVerifyBlockStrict } from './spec-validation.js';
@@ -251,18 +251,19 @@ export async function readOpenAcceptDebts(cwd) {
251
251
  return (await readAcceptDebts(cwd)).filter(d => d.resolvedBy === undefined);
252
252
  }
253
253
  /**
254
- * Close every open static-class debt that names `command`, stamping the task
254
+ * Close every open health-class debt that names `command`, stamping the task
255
255
  * whose verified work made that check pass again. Returns the debts closed.
256
256
  * A reason that quotes the command is the whole match: the health-check reason
257
257
  * (`repo health: \`bun run lint\` exited 1`) and its inherited form both do,
258
- * and nothing else in the ledger quotes a health command. Best-effort.
258
+ * and nothing else in the ledger quotes a health command. A suite debt closes
259
+ * here too: the repair verified clean, and that check runs the suite. Best-effort.
259
260
  */
260
261
  export async function closeHealthDebts(cwd, command, resolvedBy) {
261
262
  try {
262
263
  const all = await readAcceptDebts(cwd);
263
264
  const quoted = `\`${command}\``;
264
265
  const closing = all.filter(d => d.resolvedBy === undefined
265
- && isStaticClassDebt(d.reason)
266
+ && isHealthClass(failClassOfReason(d.reason))
266
267
  && d.reason.includes(quoted));
267
268
  if (closing.length === 0)
268
269
  return [];
@@ -20,7 +20,7 @@ import { GRILL_AUTO_ANSWER_PROMPT, GRILL_AUTO_FORMAT_HINT } from './prompts.js';
20
20
  import { allocateAutoId, buildAutoBody, parseDecomposeList, parseCoverageVerdict, UNNAMED_COVERAGE_GAP, parseTaskList, planKeyAt, checkOffTask, stampTaskInProgress, beginTaskAttempt, recordTaskEnd, insertTaskAfter, insertTaskBefore, findResumableAutoDetailed } from './auto-io.js';
21
21
  import { decideResume, UNATTENDED_STATES } from './resume-gap.js';
22
22
  import { ENTRY_ATTEMPT_BUDGET } from './gate-resolution.js';
23
- import { recordDebt } from './accept-debt.js';
23
+ import { readOpenAcceptDebts, recordDebt } from './accept-debt.js';
24
24
  import { drainRepairQueue, mergeRepairCandidates, planHasRepairFor, parseRepairTitleFile, buildRepairTitle, buildRepairScopeFence, extractFailingCommand } from './root-cause-repair.js';
25
25
  import { writeTaskFile, readTaskFile, readSection, updateTaskFrontMatter, taskFilePath } from './task-io.js';
26
26
  // Re-exported as well as used: the @-mention helpers moved to their own module so
@@ -38,7 +38,7 @@ import { getParentContextWindow } from './context-usage.js';
38
38
  import { ChildStatus, runPlanningChild, statusCallbacks } from './child-status.js';
39
39
  import { buildGateDeps, collectTreeChanges } from './gate-deps.js';
40
40
  import { runGatesForTask } from './task-gates.js';
41
- import { buildHealthRepairFence, buildHealthRepairTitle, healthRedSubject, parseHealthRepairTitle, planCoversHealthRed } from './health-repair.js';
41
+ import { buildHealthRepairFence, buildHealthRepairTitle, healthRedSubject, suiteRegressionOwed, parseHealthRepairTitle, planCoversHealthRed } from './health-repair.js';
42
42
  import { HEALTH_BASELINE_SECTION, parseHealthBaseline } from './health-baseline.js';
43
43
  import { runFinalGateStage } from './run-final-gate.js';
44
44
  import { gitUnmergedPaths, gitStashRef } from './auto-commit.js';
@@ -392,7 +392,8 @@ async function schedulePendingRepairs(cwd, id, afterIndex, ctx, deps) {
392
392
  */
393
393
  async function spliceHealthRepair(cwd, id, next, entries, health, ctx, deps) {
394
394
  try {
395
- const red = healthRedSubject(health, cwd, (await deps.repoFiles?.(cwd)) ?? null);
395
+ const debts = await readOpenAcceptDebts(cwd);
396
+ const red = healthRedSubject(health, cwd, (await deps.repoFiles?.(cwd)) ?? null, c => c.kind !== 'test' || suiteRegressionOwed(c.cmd, debts));
396
397
  if (!red)
397
398
  return false;
398
399
  if (planCoversHealthRed(entries.map(e => e.title), red))
@@ -609,8 +610,9 @@ export async function elicitClarifications(ctx, cwd, deps, oriented) {
609
610
  }
610
611
  // YOLO: take the recommended option (index 0 / the green card) without ever
611
612
  // building the prompt. Clarify has no anti-synthesis channel — it runs before
612
- // any research — so the only step-aside here is a question that carries no
613
- // recommendation to take; that one is skipped rather than guessed.
613
+ // any research — so the step-asides here are a question with no
614
+ // recommendation to take, and one whose every option defers a breakage the
615
+ // triage just refused; each is skipped rather than guessed.
614
616
  const outcome = await settleQuestion({
615
617
  ui,
616
618
  transcript,
@@ -1366,7 +1368,7 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
1366
1368
  notifyRun(active, `${id}: checkpointed uncommitted work before "${next.title}".`, 'info');
1367
1369
  }
1368
1370
  // REPO-HEALTH BASELINE, taken here because the checkpoint above just made
1369
- // the tree clean: what the project's own statics say now is what this task
1371
+ // the tree clean: what the project's own checks say now is what this task
1370
1372
  // INHERITED, and the verify gate attributes a red check against it instead
1371
1373
  // of failing the task for a sibling's defect (health-baseline.ts). The
1372
1374
  // inner task file does not exist yet, so the result is handed to the
@@ -109,8 +109,14 @@ export declare const ENV_GAP_OUTPUT_RE: RegExp;
109
109
  * `gapPatterns`, which is why that parameter exists rather than a boolean.
110
110
  */
111
111
  export declare const INFRA_GAP_OUTPUT_RE: RegExp;
112
+ /**
113
+ * A test runner that found no tests to run: bun, jest, vitest, mocha and pytest
114
+ * each exit non-zero on it. A suite that ran nothing observed nothing, which is the
115
+ * contract's definition of a gap. Test commands only (see `emptySuite`).
116
+ */
117
+ export declare const EMPTY_SUITE_OUTPUT_RE: RegExp;
112
118
  /** Which way a command failed to tell us anything. */
113
- export type CommandGapId = 'spawn-failed' | 'killed' | 'command-not-found' | 'missing-runtime' | 'infrastructure';
119
+ export type CommandGapId = 'spawn-failed' | 'killed' | 'command-not-found' | 'missing-runtime' | 'infrastructure' | 'empty-suite';
114
120
  export type CommandVerdict =
115
121
  /** Nothing was observed. Never fails a gate, never closes a debt. */
116
122
  {
@@ -145,6 +151,11 @@ export interface ClassifyOptions {
145
151
  * and tell the gate the repo is healthy.
146
152
  */
147
153
  runtimeGap?: boolean;
154
+ /**
155
+ * May this command's output claim it found NO TESTS? False by default: the
156
+ * wording is only a gap when a test runner printed it.
157
+ */
158
+ emptySuite?: boolean;
148
159
  }
149
160
  /**
150
161
  * Decide what one finished command proved. Pure — no spawning, no filesystem, no
@@ -233,6 +233,12 @@ export const ENV_GAP_OUTPUT_RE = /Executable doesn't exist|playwright install|br
233
233
  * `gapPatterns`, which is why that parameter exists rather than a boolean.
234
234
  */
235
235
  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;
236
+ /**
237
+ * A test runner that found no tests to run: bun, jest, vitest, mocha and pytest
238
+ * each exit non-zero on it. A suite that ran nothing observed nothing, which is the
239
+ * contract's definition of a gap. Test commands only (see `emptySuite`).
240
+ */
241
+ export const EMPTY_SUITE_OUTPUT_RE = /\b0 test files matching\b|\bNo tests found\b|\bNo test files found\b|\bno tests ran\b|\bcollected 0 items\b/i;
236
242
  /**
237
243
  * The gap ladder, in order. FIRST MATCH WINS.
238
244
  *
@@ -268,6 +274,11 @@ const GAP_RULES = [
268
274
  id: 'infrastructure',
269
275
  detail: () => 'external infrastructure unreachable',
270
276
  applies: (_run, output, gapPatterns) => gapPatterns.some(re => re.test(output))
277
+ },
278
+ {
279
+ id: 'empty-suite',
280
+ detail: () => 'no tests found',
281
+ applies: (_run, output) => EMPTY_SUITE_OUTPUT_RE.test(output)
271
282
  }
272
283
  ];
273
284
  /** Last ~`limit` chars of the command's combined output, one line, for the reason. */
@@ -296,6 +307,8 @@ export function classifyCommandRun(run, gapPatterns = [], opts = {}) {
296
307
  for (const rule of GAP_RULES) {
297
308
  if (rule.id === 'missing-runtime' && !runtimeGap)
298
309
  continue;
310
+ if (rule.id === 'empty-suite' && opts.emptySuite !== true)
311
+ continue;
299
312
  if (rule.applies(run, output, gapPatterns)) {
300
313
  return { outcome: 'gap', gap: rule.id, detail: rule.detail(run) };
301
314
  }
@@ -1,3 +1,23 @@
1
+ /**
2
+ * Does an auto-answer hand a breakage to someone who does not exist?
3
+ *
4
+ * MEASURED (mx5-n TASK_0004, 2026-09-17): "flag the test/migrate.test.ts breakage
5
+ * as a known issue for the test owner". Nothing in a /task-auto run owns a test:
6
+ * the answer was stamped `(auto)`, verify passed the task with the suite red, and
7
+ * four tasks later an unsatisfiable spec looped until the runaway guard fired.
8
+ *
9
+ * This is the deterministic backstop behind the prompt's GREEN-SUITE CHECK: a
10
+ * model that ignores the rule still cannot promote a deferral into a decision.
11
+ * The phrases are the ones a model reaches for when it wants to defer, not the
12
+ * word "test" — "add a test later" is a plan, not a deferral.
13
+ *
14
+ * SCOPE IS GRAMMATICAL, never a character count. A negation or a conditional
15
+ * cancels a phrase only inside the phrase's own clause: "rather than flag it as a
16
+ * known issue" rejects the phrase, and "without touching the test file, accepting
17
+ * that it fails" does not. "Known issue", "follow-up" and "a later step" also name
18
+ * legitimate plans — an upstream bug, a scope cut — so they count only in a clause
19
+ * about a check.
20
+ */
1
21
  export declare function defersBreakage(answer: string): boolean;
2
22
  /** The one re-ask a deferring answer gets before it is surfaced instead of promoted. */
3
23
  export declare function deferredBreakageReaskHint(answer: string): string;
@@ -10,33 +10,95 @@
10
10
  * model that ignores the rule still cannot promote a deferral into a decision.
11
11
  * The phrases are the ones a model reaches for when it wants to defer, not the
12
12
  * word "test" — "add a test later" is a plan, not a deferral.
13
+ *
14
+ * SCOPE IS GRAMMATICAL, never a character count. A negation or a conditional
15
+ * cancels a phrase only inside the phrase's own clause: "rather than flag it as a
16
+ * known issue" rejects the phrase, and "without touching the test file, accepting
17
+ * that it fails" does not. "Known issue", "follow-up" and "a later step" also name
18
+ * legitimate plans — an upstream bug, a scope cut — so they count only in a clause
19
+ * about a check.
13
20
  */
14
- const DEFERRAL_PHRASES = [
15
- /\bknown[- ]issue\b/i,
16
- /\b(?:test|suite|file|module)[- ]owner\b/i,
17
- /\bwhoever\s+(?:owns|revisits|maintains|touches)\b/i,
18
- /\bowned by\s+(?:whoever|the\s+\w+\s+owner|a\s+later\s+(?:step|task))/i,
19
- /\b(?:a|the)\s+later\s+(?:step|task)\s+(?:will|should|can|to)\s+(?:fix|revisit|update|repair|address)/i,
20
- /\bleave\s+(?:the\s+)?(?:test|tests|suite|failure|breakage)\s+(?:failing|red|broken|as[- ]is)\b/i,
21
- /\baccept(?:ing)?\s+(?:that\s+)?.{0,60}?\b(?:test|tests|suite|assertions?|lint|build)\b.{0,80}?\b(?:fail|failing|red|broken)\b/i,
22
- /\bflag(?:ged|ging)?\s+(?:it\s+|this\s+|the\s+\S+\s+)?(?:as\s+)?(?:a\s+)?(?:known|for\s+(?:the|a|whoever))/i,
23
- /\b(?:owned|as the owned|as a)\s+follow-?up\b/i,
24
- /\bleft\s+for\s+(?:whoever|the\s+\w+\s+owner)\b/i,
25
- /\bownership\s+belongs\s+to\b/i
21
+ /** A test, or a static check that the same clause calls broken. */
22
+ const TEST_NOUN = /\b(?:tests?|suites?|assertions?)\b/i;
23
+ const BUILD_NOUN = /\b(?:lint|linter|typecheck|build|ci)\b/i;
24
+ const FAILURE = /\b(?:fail\w*|red|broken|breaks?|breakage|errors?)\b/i;
25
+ /** Before a phrase in its clause: the phrase is rejected, or it is what an option
26
+ * WOULD do — "IF NOT EXISTS would still leave the test failing" weighs an option. */
27
+ const NOT_A_DECISION = /\b(?:not|never|no|don't|do not|doesn't|does not|rather than|instead of|isn't|is not|without|avoid|avoiding|would|could|might)\b/i;
28
+ /** Where one clause ends and the next begins. */
29
+ const CLAUSE_BOUNDARY = /[,:()]|\s[—–-]\s|\b(?:and|but|so|then|while|whereas|although|though|because|since|however)\b/gi;
30
+ const SENTENCE_BOUNDARY = /[.!?](?=\s|$)|;|\n/;
31
+ const PHRASES = [
32
+ { re: /\b(?:test|suite)[- ]owners?\b/i, needs: 'alone' },
33
+ {
34
+ re: /\bflag(?:s|ged|ging)?\b.*?\b(?:as\s+(?:an?\s+|the\s+)?(?:known|owned)\b|for\s+(?:whoever|later|a\s+later)\b)/i,
35
+ needs: 'alone'
36
+ },
37
+ {
38
+ re: /\bleav(?:e|es|ing)\b.*?\b(?:tests?|suites?|assertions?|lint|build|checks?|ci)\b.*?\b(?:failing|red|broken|as[- ]is)\b/i,
39
+ needs: 'alone'
40
+ },
41
+ {
42
+ re: /\bskip(?:s|ping)?\s+(?:updating|fixing|adjusting|changing|touching)\b.*?\b(?:tests?|suites?|assertions?)\b/i,
43
+ needs: 'alone'
44
+ },
45
+ { re: /\baccept(?:s|ed|ing)?\b.*?\b(?:fail\w*|red|broken)\b/i, needs: 'check' },
46
+ { re: /\bwhoever\b/i, needs: 'check' },
47
+ { re: /\bowned\s+(?:by|follow[- ]?up)\b/i, needs: 'check' },
48
+ { re: /\bownership\s+(?:belongs|lies|rests)\s+(?:to|with)\b/i, needs: 'check' },
49
+ { re: /\bleft\s+for\b/i, needs: 'check' },
50
+ { re: /\bknown[- ]issues?\b/i, needs: 'check' },
51
+ { re: /\bfollow[- ]?ups?\b/i, needs: 'check' },
52
+ {
53
+ re: /\b(?:a|the|another|some)\s+(?:later|future|subsequent|separate)\s+(?:step|task|change|pr)\b/i,
54
+ needs: 'check'
55
+ },
56
+ { re: /\bdefer(?:s|red|ring)?\b/i, needs: 'check' },
57
+ { re: /\bout\s+of\s+scope\b/i, needs: 'check' },
58
+ {
59
+ re: /\b(?:that|this|which|it|they|those)\s+(?:is|are|remains?)\s+out\s+of\s+scope\b/i,
60
+ needs: 'breakage'
61
+ }
26
62
  ];
27
- /** "do NOT defer", "not a deferral to a test owner", "rather than flag it" — the
28
- * phrase is named to reject it. MEASURED: a treatment answer did exactly that. */
29
- const NEGATION_BEFORE = /\b(?:not|never|no|don't|do not|rather than|instead of|isn't|is not|without)\b[^.;]{0,40}$/i;
63
+ function aboutACheck(text) {
64
+ return TEST_NOUN.test(text) || (BUILD_NOUN.test(text) && FAILURE.test(text));
65
+ }
66
+ /**
67
+ * Parenthetical asides go, and a code span keeps its words but loses the
68
+ * punctuation that would split a clause in two: `toEqual([{filename: X}])` is
69
+ * one token of the sentence around it, not three clauses.
70
+ */
71
+ function prose(answer) {
72
+ let text = answer.replace(/`([^`]*)`/g, (_m, code) => code.replace(/[,;:()[\]{}]/g, ' '));
73
+ let before;
74
+ do {
75
+ before = text;
76
+ text = text.replace(/\([^()]*\)/g, ' ');
77
+ } while (text !== before);
78
+ return text;
79
+ }
80
+ function clauses(sentence) {
81
+ return sentence.split(CLAUSE_BOUNDARY).filter(c => c.trim().length > 0);
82
+ }
30
83
  export function defersBreakage(answer) {
31
- return DEFERRAL_PHRASES.some(re => {
32
- const m = new RegExp(re.source, re.flags + (re.flags.includes('g') ? '' : 'g'));
33
- for (const hit of answer.matchAll(m)) {
34
- const before = answer.slice(Math.max(0, hit.index - 60), hit.index);
35
- if (!NEGATION_BEFORE.test(before))
84
+ for (const sentence of prose(answer).split(SENTENCE_BOUNDARY)) {
85
+ const sentenceBreaks = aboutACheck(sentence) && FAILURE.test(sentence);
86
+ for (const clause of clauses(sentence)) {
87
+ for (const { re, needs } of PHRASES) {
88
+ const hit = re.exec(clause);
89
+ if (!hit)
90
+ continue;
91
+ if (NOT_A_DECISION.test(clause.slice(0, hit.index)))
92
+ continue;
93
+ if (needs === 'check' && !aboutACheck(clause))
94
+ continue;
95
+ if (needs === 'breakage' && !sentenceBreaks)
96
+ continue;
36
97
  return true;
98
+ }
37
99
  }
38
- return false;
39
- });
100
+ }
101
+ return false;
40
102
  }
41
103
  /** The one re-ask a deferring answer gets before it is surfaced instead of promoted. */
42
104
  export function deferredBreakageReaskHint(answer) {
@@ -537,7 +537,10 @@ export async function healthBaselineFor(cwd, taskId, signal) {
537
537
  return stored;
538
538
  const fresh = await lazyHealthBaseline({
539
539
  git: makeGit(cwd, signal),
540
- runHealthIn: dir => runRepoHealthCheck(dir, { signal, withTests: true })
540
+ // Statics only. The worktree has none of the tree's ignored files (the
541
+ // installed dependencies, a `.env`), so its suite fails for that, and a
542
+ // red recorded here would excuse the real regression it matches.
543
+ runHealthIn: dir => runRepoHealthCheck(dir, { signal })
541
544
  });
542
545
  if (fresh) {
543
546
  await setTaskSection(cwd, taskId, HEALTH_BASELINE_SECTION, formatHealthBaseline(fresh)).catch(() => { });
@@ -720,7 +723,31 @@ export function buildGateDeps(params) {
720
723
  await git(cwd2, ['checkout', '--', '.', EXCLUDE_TASKS_DIR], signal);
721
724
  await git(cwd2, ['clean', '-fd', '-e', '.pi-tasks'], signal);
722
725
  };
723
- // The project's own statics, under a live loader naming the running command.
726
+ const untrackedFiles = async (cwd2) => {
727
+ const r = await git(cwd2, ['ls-files', '--others', '--exclude-standard', '-z', '--', '.', EXCLUDE_TASKS_DIR], signal);
728
+ return r.exitCode === 0 ? new Set(r.stdout.split('\u0000').filter(f => f.length > 0)) : null;
729
+ };
730
+ // The project's own checks, suite included, once per tree for the run. A suite
731
+ // writes coverage, reports and databases into the tree; left there, they ride
732
+ // into the task's commit and read as enforce edits, so what the check created
733
+ // is removed before the tree is hashed again.
734
+ const gateHealth = (cwd2, onCommand) => currentRunContext(cwd2).healthFor(async () => {
735
+ const before = await untrackedFiles(cwd2);
736
+ try {
737
+ return await runRepoHealthCheck(cwd2, { signal, withTests: true, onCommand });
738
+ }
739
+ finally {
740
+ const after = before ? await untrackedFiles(cwd2) : null;
741
+ for (const rel of after ?? []) {
742
+ if (!before?.has(rel)) {
743
+ await fsp
744
+ .rm(path.join(cwd2, rel), { recursive: true, force: true })
745
+ .catch(() => { });
746
+ }
747
+ }
748
+ }
749
+ });
750
+ // The project's own checks, under a live loader naming the running command.
724
751
  // Each run is as long as that command, and a gate step that long with no widget
725
752
  // is indistinguishable from a hang. Shared by the enforce pre-commit gate (a
726
753
  // baseline before the edit pass, a differential after it) and by the
@@ -737,12 +764,8 @@ export function buildGateDeps(params) {
737
764
  startedAt,
738
765
  lastLine: running ? `repo health · ${running}` : 'repo health'
739
766
  }));
740
- return runRepoHealthCheck(cwd2, {
741
- signal,
742
- withTests: true,
743
- onCommand: c => {
744
- running = c;
745
- }
767
+ return gateHealth(cwd2, c => {
768
+ running = c;
746
769
  }).finally(stop);
747
770
  };
748
771
  // Adapter onto the shared gate-child runner (gate-child.ts). What survives
@@ -946,12 +969,8 @@ export function buildGateDeps(params) {
946
969
  // progress hook here would be uncancellable and mute for reasons
947
970
  // unrelated to the thing under test. The arm's only difference is
948
971
  // the LOADER, above.
949
- repoHealth: () => runRepoHealthCheck(cwd2, {
950
- signal,
951
- withTests: true,
952
- onCommand: c => {
953
- stageLine = `repo health · ${c}`;
954
- }
972
+ repoHealth: () => gateHealth(cwd2, c => {
973
+ stageLine = `repo health · ${c}`;
955
974
  }),
956
975
  // What those checks said before the task started, so a red one
957
976
  // is attributed rather than absolutely failed. Read only when
@@ -11,6 +11,7 @@ const REATTEMPTABLE_BUDGET = 3;
11
11
  export const AUTOFIX_BUDGET = {
12
12
  'repo-health': REATTEMPTABLE_BUDGET,
13
13
  'static-checks': REATTEMPTABLE_BUDGET,
14
+ 'test-suite': REATTEMPTABLE_BUDGET,
14
15
  'model-verdict': REATTEMPTABLE_BUDGET,
15
16
  unobserved: 0,
16
17
  'harness-fault': 0
@@ -38,7 +38,14 @@ export type HealthDelta = 'clean' | 'regressed' | 'pre-existing';
38
38
  * default the enforce site used to carry.
39
39
  */
40
40
  export declare function classifyHealthDelta(baseline: HealthSignal | null, after: HealthSignal): HealthDelta;
41
- /** The failing commands, as prompt/trail lines naming the exit code. */
41
+ /** The failing commands the baseline did not have failing the same way — what a
42
+ * `regressed` verdict is about. Every failing command when there is no baseline. */
43
+ export declare function regressedCommands(baseline: HealthSignal | null, after: HealthSignal): HealthCommandResult[];
44
+ /**
45
+ * The failing commands, as prompt/trail lines naming the exit code. A test
46
+ * runner exits 1 for one failing test or for fifty, so for a suite the line
47
+ * claims only the exit code: which tests fail was not compared.
48
+ */
42
49
  export declare function inheritedHealthFindings(after: HealthSignal): string[];
43
50
  export declare const HEALTH_BASELINE_SECTION = "health baseline";
44
51
  /**
@@ -46,9 +53,9 @@ export declare const HEALTH_BASELINE_SECTION = "health baseline";
46
53
  * grammar: this round-trips through a committed file that a later run parses, and
47
54
  * a second grammar is a second thing to drift.
48
55
  *
49
- * The captured `output` is dropped up to 40 lines of a linter's report, in a
50
- * file committed with every task, for a field the differential never reads. The
51
- * live run's own trail already carries it.
56
+ * The captured output is dropped, the outcome's and each command's up to 40
57
+ * lines of a linter's report, in a file committed with every task, for a field the
58
+ * differential never reads. The live run's own trail already carries it.
52
59
  */
53
60
  export declare function formatHealthBaseline(b: HealthBaseline): string;
54
61
  /** Parse a `## health baseline` section back. Null on anything unreadable — an
@@ -52,13 +52,24 @@ export function classifyHealthDelta(baseline, after) {
52
52
  const detailed = now.length > 0 && (baseline.ok || before.length > 0);
53
53
  if (!detailed)
54
54
  return baseline.ok ? 'regressed' : 'pre-existing';
55
- const key = (c) => JSON.stringify([c.cmd, c.exitCode]);
56
- const wasFailing = new Set(before.map(key));
57
- return now.every(c => wasFailing.has(key(c))) ? 'pre-existing' : 'regressed';
55
+ return regressedCommands(baseline, after).length > 0 ? 'regressed' : 'pre-existing';
58
56
  }
59
- /** The failing commands, as prompt/trail lines naming the exit code. */
57
+ const failureKey = (c) => JSON.stringify([c.cmd, c.exitCode]);
58
+ /** The failing commands the baseline did not have failing the same way — what a
59
+ * `regressed` verdict is about. Every failing command when there is no baseline. */
60
+ export function regressedCommands(baseline, after) {
61
+ const wasFailing = new Set(baseline ? failures(baseline).map(failureKey) : []);
62
+ return failures(after).filter(c => !wasFailing.has(failureKey(c)));
63
+ }
64
+ /**
65
+ * The failing commands, as prompt/trail lines naming the exit code. A test
66
+ * runner exits 1 for one failing test or for fifty, so for a suite the line
67
+ * claims only the exit code: which tests fail was not compared.
68
+ */
60
69
  export function inheritedHealthFindings(after) {
61
- return failures(after).map(c => `\`${c.cmd}\` exits ${c.exitCode} (and did before this task)`);
70
+ return failures(after).map(c => c.kind === 'test' ?
71
+ `\`${c.cmd}\` exits ${c.exitCode}, as it did before this task — the same exit code, not proof the same tests fail`
72
+ : `\`${c.cmd}\` exits ${c.exitCode} (and did before this task)`);
62
73
  }
63
74
  // ─── The task-file section ───────────────────────────────────────────────────
64
75
  export const HEALTH_BASELINE_SECTION = 'health baseline';
@@ -67,12 +78,13 @@ export const HEALTH_BASELINE_SECTION = 'health baseline';
67
78
  * grammar: this round-trips through a committed file that a later run parses, and
68
79
  * a second grammar is a second thing to drift.
69
80
  *
70
- * The captured `output` is dropped up to 40 lines of a linter's report, in a
71
- * file committed with every task, for a field the differential never reads. The
72
- * live run's own trail already carries it.
81
+ * The captured output is dropped, the outcome's and each command's up to 40
82
+ * lines of a linter's report, in a file committed with every task, for a field the
83
+ * differential never reads. The live run's own trail already carries it.
73
84
  */
74
85
  export function formatHealthBaseline(b) {
75
- const { output: _output, ...outcome } = b.outcome;
86
+ const { output: _output, commands, ...rest } = b.outcome;
87
+ const outcome = { ...rest, commands: commands.map(({ output: _o, ...c }) => c) };
76
88
  return ['```json', JSON.stringify({ ...b, outcome }, null, 2), '```'].join('\n');
77
89
  }
78
90
  /** Parse a `## health baseline` section back. Null on anything unreadable — an
@@ -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
@@ -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
@@ -65,7 +71,8 @@ export declare function discoverHealthCommands(cwd: string): {
65
71
  * Every test-shaped script, not just the one literally named `test`: a project's
66
72
  * only browser-executing suite is often `test:ct`, and looking for `test` alone
67
73
  * never runs it. Plain `test` leads, then every `test:`/`test_`/`test-` name in
68
- * declaration order (Array#sort is stable).
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.
69
76
  */
70
77
  export declare function discoverTestCommands(cwd: string): {
71
78
  ecosystem: string | null;
@@ -80,7 +87,7 @@ export type HealthProgress = (command: string) => void;
80
87
  * - No manifest / no static command → ok (nothing can regress).
81
88
  * - A command that CANNOT run (ENOENT / null exit / 127 inside the chain) → skipped,
82
89
  * treated as an environment gap, not a fault.
83
- * - 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.
84
91
  *
85
92
  * This module owns DISCOVERY and its own output policy. Running a command and
86
93
  * deciding what its ending MEANS is `command-run.ts`'s — one statement of the
@@ -105,3 +112,5 @@ export declare function runRepoHealthCheck(cwd: string, opts?: {
105
112
  * that will judge the result DIFFERENTIALLY may set this — see the header. */
106
113
  withTests?: boolean;
107
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;
@@ -125,6 +125,10 @@ export function discoverHealthCommands(cwd) {
125
125
  }
126
126
  return { ecosystem: null, cmds: [] };
127
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
+ }
128
132
  /**
129
133
  * The project's OWN test commands, in the order the run-end gate runs them. One
130
134
  * statement for both gates: final-gate.ts appends `build` to this list for the
@@ -134,12 +138,13 @@ export function discoverHealthCommands(cwd) {
134
138
  * Every test-shaped script, not just the one literally named `test`: a project's
135
139
  * only browser-executing suite is often `test:ct`, and looking for `test` alone
136
140
  * never runs it. Plain `test` leads, then every `test:`/`test_`/`test-` name in
137
- * declaration order (Array#sort is stable).
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.
138
143
  */
139
144
  export function discoverTestCommands(cwd) {
140
145
  if (existsSync(path.join(cwd, 'package.json'))) {
141
146
  const s = packageScripts(cwd);
142
- const names = Object.keys(s).filter(n => n === 'test' || /^test[:_-]/.test(n));
147
+ const names = Object.keys(s).filter(n => (n === 'test' || /^test[:_-]/.test(n)) && !isWatchScript(n, s[n]));
143
148
  names.sort((a, b) => a === 'test' ? -1
144
149
  : b === 'test' ? 1
145
150
  : 0);
@@ -178,7 +183,7 @@ function noCommandOutcome(ecosystem) {
178
183
  * - No manifest / no static command → ok (nothing can regress).
179
184
  * - A command that CANNOT run (ENOENT / null exit / 127 inside the chain) → skipped,
180
185
  * treated as an environment gap, not a fault.
181
- * - 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.
182
187
  *
183
188
  * This module owns DISCOVERY and its own output policy. Running a command and
184
189
  * deciding what its ending MEANS is `command-run.ts`'s — one statement of the
@@ -225,23 +230,38 @@ export async function runRepoHealthCheck(cwd, opts = {}) {
225
230
  // ladder's `tail` keeps 400 characters, and that difference is real — a
226
231
  // truncated lint report is unactionable. So the run is classified, not
227
232
  // 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 });
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';
233
239
  if (verdict.outcome !== 'fail') {
234
240
  const passed = verdict.outcome === 'pass';
235
- 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
+ });
236
247
  continue;
237
248
  }
238
- 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) {
239
259
  return {
240
260
  ok: false,
241
- reason: `\`${cmd}\` exited ${verdict.status}`,
261
+ reason: describeHealthFailures(commands),
242
262
  ecosystem,
243
263
  commands,
244
- output: captureHealthOutput(r.stdout, r.stderr)
264
+ output: firstFail.output ?? ''
245
265
  };
246
266
  }
247
267
  return {
@@ -252,3 +272,10 @@ export async function runRepoHealthCheck(cwd, opts = {}) {
252
272
  output: ''
253
273
  };
254
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
@@ -42,7 +42,8 @@ import { buildEnvNotesBlock, ENV_NOTE_EMIT_INSTRUCTION, extractEnvNotes } from '
42
42
  import { buildContractsVerifyBlock } from './contracts.js';
43
43
  import { findSkipEscapes, skipEscapeVerifyFindings } from './skip-escape.js';
44
44
  import { crossTaskDeletionVerifyFindings } from './task-provenance.js';
45
- import { classifyHealthDelta, inheritedHealthFindings } from './health-baseline.js';
45
+ import { classifyHealthDelta, inheritedHealthFindings, regressedCommands } from './health-baseline.js';
46
+ import { describeHealthFailures } from './repo-health-check.js';
46
47
  import { parseSpec, sliceSpecSection } from './spec-model.js';
47
48
  import { qaKindsFromRecord } from './qa-transcript.js';
48
49
  import { annotateConstraints, anyBinding, renderConstraintPolicy } from './constraint-policy.js';
@@ -72,6 +73,7 @@ const VERIFY_TOOLS = 'read,bash';
72
73
  export const VERIFY_FAIL_PREFIX = {
73
74
  'repo-health': 'repo health:',
74
75
  'static-checks': 'static checks:',
76
+ 'test-suite': 'test suite:',
75
77
  unobserved: 'work unobserved:',
76
78
  'model-verdict': 'work did not verify:',
77
79
  'harness-fault': 'verification pass could not run:'
@@ -103,6 +105,14 @@ export function failClassOfReason(reason) {
103
105
  export function isStaticClass(cls) {
104
106
  return cls === 'repo-health' || cls === 'static-checks';
105
107
  }
108
+ /** The class a red health result is minted under: the suite's when any test is red. */
109
+ function healthFailClass(failing) {
110
+ return failing.some(c => c.kind === 'test') ? 'test-suite' : 'repo-health';
111
+ }
112
+ /** Does this class name the deterministic whole-repo check, suite included? */
113
+ export function isHealthClass(cls) {
114
+ return isStaticClass(cls) || cls === 'test-suite';
115
+ }
106
116
  /**
107
117
  * The delivered spec's TEXT, for the children that must read its prose verbatim.
108
118
  * The slicing itself lives in spec-model.ts beside the parser, so "the spec
@@ -182,8 +192,8 @@ const PROBE_ADAPTERS = [
182
192
  ]
183
193
  }),
184
194
  /**
185
- * PRE-EXISTING repo health (see health-baseline.ts): static checks that were
186
- * ALREADY failing, the same way, before this task started. They used to be an
195
+ * PRE-EXISTING repo health (see health-baseline.ts): checks that were ALREADY
196
+ * failing, with the same exit code, before this task started. They used to be an
187
197
  * absolute FAIL that short-circuited the whole pass, so the task answered for
188
198
  * a sibling's defect and its own probe findings were never computed. As a row
189
199
  * they are stated to the child instead: judge this task's work, and do not
@@ -197,7 +207,7 @@ const PROBE_ADAPTERS = [
197
207
  ruleId: '4h',
198
208
  block: findings => [
199
209
  'INHERITED REPO-HEALTH NOTICE (deterministic, computed by the orchestrator by',
200
- "re-running the project's own static checks and comparing them against the",
210
+ "re-running the project's own checks and comparing their exit codes against the",
201
211
  'baseline taken before this task started): these checks were ALREADY failing,',
202
212
  'with the same exit code, before any of this work existed:',
203
213
  ...findings.map(f => `- ${f}`),
@@ -208,13 +218,16 @@ const PROBE_ADAPTERS = [
208
218
  ],
209
219
  rule: [
210
220
  "4h. AN INHERITED RED CHECK IS NOT THIS TASK'S FAIL, AND NOT ITS PROOF — when the",
211
- ' INHERITED REPO-HEALTH NOTICE above names a check, that check failed identically',
221
+ ' INHERITED REPO-HEALTH NOTICE above names a check, that check exited the same way',
212
222
  ' before this task ran. Do NOT fail this work for it: the defect belongs to',
213
223
  ' whatever put it there, it is recorded as durable debt, and the run-end gate',
214
224
  ' re-checks it. Do NOT lean on it either — a command that was already exiting',
215
225
  " non-zero tells you nothing about this task's behavior, so verify that behavior",
216
226
  ' another way. A check that is failing DIFFERENTLY, or one absent from the notice,',
217
- " is this task's to answer for in the ordinary way."
227
+ " is this task's to answer for in the ordinary way. A TEST command is the",
228
+ ' exception to the exit code: it exits the same way for one failing test or',
229
+ " fifty. Run it, and a test that fails because of THIS work is this task's FAIL,",
230
+ ' whatever the spec says about it.'
218
231
  ]
219
232
  }),
220
233
  /**
@@ -907,16 +920,33 @@ export async function runWorkVerification(deps) {
907
920
  const h = await deps.repoHealth();
908
921
  if (!h.ok) {
909
922
  const baseline = deps.healthBaseline ? await deps.healthBaseline() : null;
910
- if (classifyHealthDelta(baseline?.outcome ?? null, h) === 'regressed') {
923
+ const before = baseline?.outcome ?? null;
924
+ if (classifyHealthDelta(before, h) === 'regressed') {
925
+ // Named after what REGRESSED, not after whatever failed first: a lint
926
+ // red on arrival would otherwise stand in for the suite this task broke.
927
+ const regressed = regressedCommands(before, h);
928
+ if (regressed.length === 0) {
929
+ return {
930
+ ok: false,
931
+ failClass: 'repo-health',
932
+ reason: `repo health: ${h.reason}`,
933
+ health: h
934
+ };
935
+ }
936
+ const failClass = healthFailClass(regressed);
911
937
  return {
912
938
  ok: false,
913
- failClass: 'repo-health',
914
- reason: `repo health: ${h.reason}`,
915
- health: h
939
+ failClass,
940
+ reason: `${VERIFY_FAIL_PREFIX[failClass]} ${describeHealthFailures(regressed)}`,
941
+ health: { ...h, commands: regressed, output: regressed[0].output ?? h.output }
916
942
  };
917
943
  }
918
944
  pre.repoHealth = inheritedHealthFindings(h);
919
- inheritedHealth = `repo health: ${h.reason} already failing before this task`;
945
+ const failing = h.commands?.filter(c => c.outcome === 'fail') ?? [];
946
+ inheritedHealth =
947
+ failing.length > 0 ?
948
+ `${VERIFY_FAIL_PREFIX[healthFailClass(failing)]} ${describeHealthFailures(failing)} — already failing before this task`
949
+ : `repo health: ${h.reason} — already failing before this task`;
920
950
  }
921
951
  }
922
952
  const inherited = inheritedHealth === undefined ? {} : { inheritedHealth };
@@ -36,6 +36,10 @@ export type YoloPick = {
36
36
  * demotion — an answer proven to name an unverified API identifier. Auto-accepting
37
37
  * that would re-promote exactly the invention the demotion exists to stop, so a
38
38
  * machine may never take it; a human still can.
39
+ *
40
+ * An option that defers a breakage to an owner nobody is, is passed over here
41
+ * rather than at each caller: the clarify generator, the plan review and an
42
+ * UNKNOWN's own suggestion reach a machine with no guard in front of them.
39
43
  */
40
44
  export declare function yoloPickAnswer(enabled: boolean, opts: {
41
45
  suggested?: string;
@@ -45,11 +49,11 @@ export declare function yoloPickAnswer(enabled: boolean, opts: {
45
49
  /**
46
50
  * The same policy expressed over an {@link AutoAnswer}, for the grill site. Of the
47
51
  * five `reason` tags an unknown can carry — `api-synthesis`, `deferred-breakage`,
48
- * `integration`, `threw`, `model-unknown` — the first two are unsafe: one names an
49
- * API nobody verified, the other hands a red suite to an owner nobody is. The other
52
+ * `integration`, `threw`, `model-unknown` — `api-synthesis` is unsafe by its tag:
53
+ * the answer names an API nobody verified. A `deferred-breakage` suggestion is the
54
+ * deferral itself, which `yoloPickAnswer` passes over for every site. The other
50
55
  * three carry an ordinary best-effort recommendation, which is precisely what a
51
- * human would be shown as the green card. The variants are told apart by that tag,
52
- * never by pattern-matching the answer text.
56
+ * human would be shown as the green card.
53
57
  */
54
58
  export declare function yoloPickAutoAnswer(enabled: boolean, auto: AutoAnswer): YoloPick;
55
59
  /**
package/dist/task/yolo.js CHANGED
@@ -29,6 +29,7 @@
29
29
  * the config.
30
30
  */
31
31
  import { getConfig } from '../config/config.js';
32
+ import { defersBreakage } from './deferred-breakage.js';
32
33
  /**
33
34
  * Visible provenance marker on the artifacts an auto-pick writes: gate trail lines
34
35
  * and the task file's Q&A record (qa-transcript.ts `QA_PROVENANCE`). A later audit
@@ -53,6 +54,10 @@ export function isYoloMode() {
53
54
  * demotion — an answer proven to name an unverified API identifier. Auto-accepting
54
55
  * that would re-promote exactly the invention the demotion exists to stop, so a
55
56
  * machine may never take it; a human still can.
57
+ *
58
+ * An option that defers a breakage to an owner nobody is, is passed over here
59
+ * rather than at each caller: the clarify generator, the plan review and an
60
+ * UNKNOWN's own suggestion reach a machine with no guard in front of them.
56
61
  */
57
62
  export function yoloPickAnswer(enabled, opts) {
58
63
  if (!enabled)
@@ -60,20 +65,26 @@ export function yoloPickAnswer(enabled, opts) {
60
65
  if (opts.unsafe !== undefined && opts.unsafe.length > 0) {
61
66
  return { kind: 'skip', note: opts.unsafe };
62
67
  }
63
- const pick = opts.suggested ?? opts.alt;
64
- if (pick === undefined || pick.trim().length === 0) {
68
+ const offered = [opts.suggested, opts.alt].filter((o) => o !== undefined && o.trim().length > 0);
69
+ if (offered.length === 0)
65
70
  return { kind: 'skip', note: 'no recommended option to take' };
71
+ const pick = offered.find(o => !defersBreakage(o));
72
+ if (pick === undefined) {
73
+ return {
74
+ kind: 'skip',
75
+ note: 'the recommended answer leaves a test or build failing for an owner that does not exist — needs a human'
76
+ };
66
77
  }
67
78
  return { kind: 'answer', answer: pick };
68
79
  }
69
80
  /**
70
81
  * The same policy expressed over an {@link AutoAnswer}, for the grill site. Of the
71
82
  * five `reason` tags an unknown can carry — `api-synthesis`, `deferred-breakage`,
72
- * `integration`, `threw`, `model-unknown` — the first two are unsafe: one names an
73
- * API nobody verified, the other hands a red suite to an owner nobody is. The other
83
+ * `integration`, `threw`, `model-unknown` — `api-synthesis` is unsafe by its tag:
84
+ * the answer names an API nobody verified. A `deferred-breakage` suggestion is the
85
+ * deferral itself, which `yoloPickAnswer` passes over for every site. The other
74
86
  * three carry an ordinary best-effort recommendation, which is precisely what a
75
- * human would be shown as the green card. The variants are told apart by that tag,
76
- * never by pattern-matching the answer text.
87
+ * human would be shown as the green card.
77
88
  */
78
89
  export function yoloPickAutoAnswer(enabled, auto) {
79
90
  if (!enabled)
@@ -85,9 +96,6 @@ export function yoloPickAutoAnswer(enabled, auto) {
85
96
  ...(auto.alt !== undefined && { alt: auto.alt }),
86
97
  ...(auto.reason === 'api-synthesis' && {
87
98
  unsafe: 'the suggested answer names an unverified API identifier — needs a human'
88
- }),
89
- ...(auto.reason === 'deferred-breakage' && {
90
- unsafe: 'the suggested answer leaves a test or build failing for an owner that does not exist — needs a human'
91
99
  })
92
100
  });
93
101
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.42.3",
3
+ "version": "0.42.4",
4
4
  "description": "Deterministic task planning and spec-orchestration for local models — crash-safe /task pipelines with verify/enforce gates, a real-time remote web view, and web/docs/fetch/worker subagent tools.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",