@mjasnikovs/pi-task 0.42.2 → 0.42.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 [];
@@ -15,11 +15,12 @@ import { RUN_END_POLICY, runSucceeded } from './run-end.js';
15
15
  import { parseAutoAnswer, autoAnswerHasTag, deriveTitle } from './parsers.js';
16
16
  import { renderInlineMarkdown, stripInlineMarkdown } from './inline-markdown.js';
17
17
  import { AUTO_CLARIFY_PROMPT, AUTO_DECOMPOSE_PROMPT, DECOMPOSE_COVERAGE_PROMPT } from './auto-prompts.js';
18
+ import { defersBreakage } from './deferred-breakage.js';
18
19
  import { GRILL_AUTO_ANSWER_PROMPT, GRILL_AUTO_FORMAT_HINT } from './prompts.js';
19
20
  import { allocateAutoId, buildAutoBody, parseDecomposeList, parseCoverageVerdict, UNNAMED_COVERAGE_GAP, parseTaskList, planKeyAt, checkOffTask, stampTaskInProgress, beginTaskAttempt, recordTaskEnd, insertTaskAfter, insertTaskBefore, findResumableAutoDetailed } from './auto-io.js';
20
21
  import { decideResume, UNATTENDED_STATES } from './resume-gap.js';
21
22
  import { ENTRY_ATTEMPT_BUDGET } from './gate-resolution.js';
22
- import { recordDebt } from './accept-debt.js';
23
+ import { readOpenAcceptDebts, recordDebt } from './accept-debt.js';
23
24
  import { drainRepairQueue, mergeRepairCandidates, planHasRepairFor, parseRepairTitleFile, buildRepairTitle, buildRepairScopeFence, extractFailingCommand } from './root-cause-repair.js';
24
25
  import { writeTaskFile, readTaskFile, readSection, updateTaskFrontMatter, taskFilePath } from './task-io.js';
25
26
  // Re-exported as well as used: the @-mention helpers moved to their own module so
@@ -37,7 +38,7 @@ import { getParentContextWindow } from './context-usage.js';
37
38
  import { ChildStatus, runPlanningChild, statusCallbacks } from './child-status.js';
38
39
  import { buildGateDeps, collectTreeChanges } from './gate-deps.js';
39
40
  import { runGatesForTask } from './task-gates.js';
40
- import { buildHealthRepairFence, buildHealthRepairTitle, healthRedSubject, parseHealthRepairTitle, planCoversHealthRed } from './health-repair.js';
41
+ import { buildHealthRepairFence, buildHealthRepairTitle, healthRedSubject, suiteRegressionOwed, parseHealthRepairTitle, planCoversHealthRed } from './health-repair.js';
41
42
  import { HEALTH_BASELINE_SECTION, parseHealthBaseline } from './health-baseline.js';
42
43
  import { runFinalGateStage } from './run-final-gate.js';
43
44
  import { gitUnmergedPaths, gitStashRef } from './auto-commit.js';
@@ -220,6 +221,10 @@ async function triageClarifyQuestion(deps, cwd, featureForModel, existingFilesBl
220
221
  text = await deps.runChild('clarify-triage', 'read', prependHint(GRILL_AUTO_FORMAT_HINT, basePrompt));
221
222
  }
222
223
  const parsed = parseAutoAnswer(text);
224
+ if (parsed.kind === 'answered' && defersBreakage(parsed.text)) {
225
+ logPlanDebug(cwd, `clarify-triage answer defers a breakage to a nonexistent owner — surfacing: ${parsed.text.replace(/\s+/g, ' ').slice(0, 100)}`);
226
+ return null;
227
+ }
223
228
  if (parsed.kind === 'answered') {
224
229
  logPlanDebug(cwd, `clarify-triage auto-resolved (spec-settled): ${question.replace(/\s+/g, ' ').slice(0, 100)}`
225
230
  + ` → ${parsed.text.replace(/\s+/g, ' ').slice(0, 100)}`);
@@ -387,7 +392,8 @@ async function schedulePendingRepairs(cwd, id, afterIndex, ctx, deps) {
387
392
  */
388
393
  async function spliceHealthRepair(cwd, id, next, entries, health, ctx, deps) {
389
394
  try {
390
- 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));
391
397
  if (!red)
392
398
  return false;
393
399
  if (planCoversHealthRed(entries.map(e => e.title), red))
@@ -604,8 +610,9 @@ export async function elicitClarifications(ctx, cwd, deps, oriented) {
604
610
  }
605
611
  // YOLO: take the recommended option (index 0 / the green card) without ever
606
612
  // building the prompt. Clarify has no anti-synthesis channel — it runs before
607
- // any research — so the only step-aside here is a question that carries no
608
- // 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.
609
616
  const outcome = await settleQuestion({
610
617
  ui,
611
618
  transcript,
@@ -1361,7 +1368,7 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
1361
1368
  notifyRun(active, `${id}: checkpointed uncommitted work before "${next.title}".`, 'info');
1362
1369
  }
1363
1370
  // REPO-HEALTH BASELINE, taken here because the checkpoint above just made
1364
- // 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
1365
1372
  // INHERITED, and the verify gate attributes a red check against it instead
1366
1373
  // of failing the task for a sibling's defect (health-baseline.ts). The
1367
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
  }
@@ -0,0 +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
+ */
21
+ export declare function defersBreakage(answer: string): boolean;
22
+ /** The one re-ask a deferring answer gets before it is surfaced instead of promoted. */
23
+ export declare function deferredBreakageReaskHint(answer: string): string;
@@ -0,0 +1,110 @@
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
+ */
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
+ }
62
+ ];
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
+ }
83
+ export function defersBreakage(answer) {
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;
97
+ return true;
98
+ }
99
+ }
100
+ }
101
+ return false;
102
+ }
103
+ /** The one re-ask a deferring answer gets before it is surfaced instead of promoted. */
104
+ export function deferredBreakageReaskHint(answer) {
105
+ return ('[SYSTEM NOTE: Your previous answer deferred a breakage to someone who does not '
106
+ + `exist — "${answer.replace(/\s+/g, ' ').slice(0, 160)}". No later step owns a `
107
+ + 'failing test, lint, or build; each one inherits it and is told not to touch it. '
108
+ + 'Re-run the GREEN-SUITE CHECK: answer with the option that keeps the suite green, '
109
+ + 'naming the test file this task must also update. Output ONLY the tagged lines.]');
110
+ }
@@ -46,7 +46,7 @@
46
46
  */
47
47
  import { existsSync, readFileSync } from 'node:fs';
48
48
  import * as path from 'node:path';
49
- import { runRepoHealthCheck, discoverHealthCommands } from './repo-health-check.js';
49
+ import { runRepoHealthCheck, discoverHealthCommands, discoverTestCommands } from './repo-health-check.js';
50
50
  import { deriveOpenDebts, rerunDebtVerifyCommand } from './accept-debt.js';
51
51
  import { readDeclaredScripts, missingDeclaredScripts, runnableDeclaredScripts } from './launch-contract.js';
52
52
  import { readLaunchManifest, inertLaunchContractNote, packageScripts, makeHasTarget } from './launch-manifest.js';
@@ -83,58 +83,21 @@ import { VERIFY_FAIL_PREFIX } from './verify-work.js';
83
83
  * tool-by-tool is not the fix either.
84
84
  */
85
85
  export function discoverIntegrationCommands(cwd) {
86
- if (existsSync(path.join(cwd, 'package.json'))) {
87
- const s = packageScripts(cwd);
88
- const cmds = [];
89
- // Every test-shaped script, not just the one literally named `test`: a
90
- // project's only browser-executing suite is often `test:ct` or similar, and
91
- // looking for `test` alone never runs it. Plain `test` leads, then every
92
- // `test:`/`test_`/`test-` prefixed name in declaration order (Array#sort is
93
- // stable), then `build`. Measured on a manifest declaring test:ct, build,
94
- // test_unit, test-e2e, test, testing and pretest, the result is exactly
95
- // test, test:ct, test_unit, test-e2e, build — `testing` and `pretest` do
96
- // not match. Env-gap SKIP still applies per command: a suite whose browser
97
- // or runtime is absent skips rather than fails (see runGateCommand).
98
- const testNames = Object.keys(s).filter(n => n === 'test' || /^test[:_-]/.test(n));
99
- testNames.sort((a, b) => a === 'test' ? -1
100
- : b === 'test' ? 1
101
- : 0);
102
- for (const name of testNames)
103
- cmds.push(['bun', ['run', name]]);
104
- if (s.build)
105
- cmds.push(['bun', ['run', 'build']]);
106
- return { ecosystem: 'package.json', cmds };
107
- }
108
- if (existsSync(path.join(cwd, 'Makefile'))) {
109
- const cmds = [];
110
- for (const target of ['test', 'build']) {
111
- if (makeHasTarget(cwd, target))
112
- cmds.push(['make', [target]]);
86
+ const tests = discoverTestCommands(cwd);
87
+ const build = (() => {
88
+ if (existsSync(path.join(cwd, 'package.json'))) {
89
+ return packageScripts(cwd).build ? ['bun', ['run', 'build']] : null;
113
90
  }
114
- return { ecosystem: 'Makefile', cmds };
115
- }
116
- if (existsSync(path.join(cwd, 'Cargo.toml'))) {
117
- return {
118
- ecosystem: 'Cargo.toml',
119
- cmds: [
120
- ['cargo', ['test', '--quiet']],
121
- ['cargo', ['build', '--quiet']]
122
- ]
123
- };
124
- }
125
- if (existsSync(path.join(cwd, 'go.mod'))) {
126
- return {
127
- ecosystem: 'go.mod',
128
- cmds: [
129
- ['go', ['test', './...']],
130
- ['go', ['build', './...']]
131
- ]
132
- };
133
- }
134
- if (existsSync(path.join(cwd, 'pyproject.toml'))) {
135
- return { ecosystem: 'pyproject.toml', cmds: [['pytest', ['-q']]] };
136
- }
137
- return { ecosystem: null, cmds: [] };
91
+ if (existsSync(path.join(cwd, 'Makefile'))) {
92
+ return makeHasTarget(cwd, 'build') ? ['make', ['build']] : null;
93
+ }
94
+ if (existsSync(path.join(cwd, 'Cargo.toml')))
95
+ return ['cargo', ['build', '--quiet']];
96
+ if (existsSync(path.join(cwd, 'go.mod')))
97
+ return ['go', ['build', './...']];
98
+ return null;
99
+ })();
100
+ return { ecosystem: tests.ecosystem, cmds: build ? [...tests.cmds, build] : tests.cmds };
138
101
  }
139
102
  /**
140
103
  * Per-ecosystem lockfile↔manifest consistency checks. A check applies only when
@@ -537,6 +537,9 @@ export async function healthBaselineFor(cwd, taskId, signal) {
537
537
  return stored;
538
538
  const fresh = await lazyHealthBaseline({
539
539
  git: makeGit(cwd, signal),
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.
540
543
  runHealthIn: dir => runRepoHealthCheck(dir, { signal })
541
544
  });
542
545
  if (fresh) {
@@ -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,11 +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
- onCommand: c => {
743
- running = c;
744
- }
767
+ return gateHealth(cwd2, c => {
768
+ running = c;
745
769
  }).finally(stop);
746
770
  };
747
771
  // Adapter onto the shared gate-child runner (gate-child.ts). What survives
@@ -934,20 +958,19 @@ export function buildGateDeps(params) {
934
958
  onStage: label => {
935
959
  stageLine = label;
936
960
  },
937
- // Deterministic whole-repo static-analysis gate — runs the project's
938
- // own lint/typecheck and fails on a real non-zero exit, independent of
939
- // the model-authored VERIFY block, which may not lint at all. ASYNC,
961
+ // Deterministic whole-repo gate — runs the project's own
962
+ // lint/typecheck AND its test suite, independent of the
963
+ // model-authored VERIFY block, which may not lint at all. The suite
964
+ // is judged only against the baseline below (a task that turned a
965
+ // green suite red is this task's FAIL, whatever its spec says). ASYNC,
940
966
  // so the lint does not starve pi-tui's nextTick-scheduled renders.
941
967
  //
942
968
  // ONE call, both arms. A baseline arm that dropped the signal or the
943
969
  // progress hook here would be uncancellable and mute for reasons
944
970
  // unrelated to the thing under test. The arm's only difference is
945
971
  // the LOADER, above.
946
- repoHealth: () => runRepoHealthCheck(cwd2, {
947
- signal,
948
- onCommand: c => {
949
- stageLine = `repo health · ${c}`;
950
- }
972
+ repoHealth: () => gateHealth(cwd2, c => {
973
+ stageLine = `repo health · ${c}`;
951
974
  }),
952
975
  // What those checks said before the task started, so a red one
953
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)`