@mjasnikovs/pi-task 0.42.3 → 0.42.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,8 @@ 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
+ import { discoverTestCommands } from './repo-health-check.js';
39
40
  import { taskThatIntroduced } from './task-provenance.js';
40
41
  import { makeLedger } from './ledger.js';
41
42
  import { parseVerifyBlockStrict } from './spec-validation.js';
@@ -251,18 +252,19 @@ export async function readOpenAcceptDebts(cwd) {
251
252
  return (await readAcceptDebts(cwd)).filter(d => d.resolvedBy === undefined);
252
253
  }
253
254
  /**
254
- * Close every open static-class debt that names `command`, stamping the task
255
+ * Close every open health-class debt that names `command`, stamping the task
255
256
  * whose verified work made that check pass again. Returns the debts closed.
256
257
  * A reason that quotes the command is the whole match: the health-check reason
257
258
  * (`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.
259
+ * and nothing else in the ledger quotes a health command. A suite debt closes
260
+ * here too: the repair verified clean, and that check runs the suite. Best-effort.
259
261
  */
260
262
  export async function closeHealthDebts(cwd, command, resolvedBy) {
261
263
  try {
262
264
  const all = await readAcceptDebts(cwd);
263
265
  const quoted = `\`${command}\``;
264
266
  const closing = all.filter(d => d.resolvedBy === undefined
265
- && isStaticClassDebt(d.reason)
267
+ && isHealthClass(failClassOfReason(d.reason))
266
268
  && d.reason.includes(quoted));
267
269
  if (closing.length === 0)
268
270
  return [];
@@ -340,6 +342,9 @@ function isStorableCommand(cmd) {
340
342
  * command with fabricated provenance, which is the one thing this class may not do.
341
343
  */
342
344
  export async function classifyVerifyCommand(cwd, taskId, reason) {
345
+ const suite = suiteCommandFromReason(cwd, reason);
346
+ if (suite !== null)
347
+ return suite;
343
348
  if (taskId.trim().length === 0)
344
349
  return null;
345
350
  try {
@@ -354,6 +359,23 @@ export async function classifyVerifyCommand(cwd, taskId, reason) {
354
359
  return null;
355
360
  }
356
361
  }
362
+ /**
363
+ * The command a `test suite:` reason names, when the repo's own check is what
364
+ * discovers it. The suite is not a task's VERIFY line — it belongs to the repo —
365
+ * so without this the class has NO closure path at all: a passing lint may not
366
+ * close it (that is why the class exists), the run-end static check does not run
367
+ * the suite, and a repair splices only for a task's own regression. An inherited
368
+ * red suite was then reported open for the rest of the run after it went green.
369
+ *
370
+ * Provenance is the manifest: the span must equal a command `discoverTestCommands`
371
+ * produced, exactly as the VERIFY-block match must equal a parsed line.
372
+ */
373
+ function suiteCommandFromReason(cwd, reason) {
374
+ if (failClassOfReason(reason) !== 'test-suite')
375
+ return null;
376
+ const hit = verifyCommandFromReason(reason, discoverTestCommands(cwd).cmds.map(([bin, args]) => `${bin} ${args.join(' ')}`));
377
+ return hit !== null && isStorableCommand(hit) ? hit : null;
378
+ }
357
379
  export function verifyCommandFromReason(reason, verifyCommands) {
358
380
  const byText = new Map();
359
381
  for (const c of verifyCommands) {
@@ -394,6 +416,19 @@ export async function recheckAcceptDebts(debts, opts) {
394
416
  const resolved = [];
395
417
  const trail = [];
396
418
  let rerunsLeft = MAX_VERIFY_RERUNS;
419
+ const ran = new Map();
420
+ const settle = (d, cmd, r) => {
421
+ if (r.outcome === 'pass') {
422
+ resolved.push(d);
423
+ trail.push(`${d.taskId}: RESOLVED — re-ran \`${cmd}\` and it exited 0`);
424
+ return;
425
+ }
426
+ trail.push(`${d.taskId}: still open — re-ran \`${cmd}\`: `
427
+ + (r.outcome === 'fail' ?
428
+ `it FAILED${r.detail ? ` (${r.detail})` : ''}`
429
+ : `INCONCLUSIVE${r.detail ? ` (${r.detail})` : ''}, nothing was observed`));
430
+ open.push(d);
431
+ };
397
432
  for (const d of debts) {
398
433
  if (d.origin === 'cross-task-deletion') {
399
434
  const p = extractDeletedDebtPath(d.reason);
@@ -422,6 +457,15 @@ export async function recheckAcceptDebts(debts, opts) {
422
457
  open.push(d);
423
458
  continue;
424
459
  }
460
+ // One command, one run. A run that inherits a red suite records the same
461
+ // `bun run test` against every task in it, and re-running it once per debt
462
+ // would spend the whole budget proving the same thing and leave the rest
463
+ // open. The budget counts commands, which is what it was for.
464
+ const already = ran.get(cmd);
465
+ if (already !== undefined) {
466
+ settle(d, cmd, already);
467
+ continue;
468
+ }
425
469
  if (rerunsLeft <= 0) {
426
470
  trail.push(`${d.taskId}: NOT re-checked — the per-run re-run budget `
427
471
  + `(${MAX_VERIFY_RERUNS}) is spent; the debt stays open`);
@@ -437,16 +481,8 @@ export async function recheckAcceptDebts(debts, opts) {
437
481
  // A harness fault observes nothing, so it proves nothing.
438
482
  r = { outcome: 'gap', detail: 're-run harness fault' };
439
483
  }
440
- if (r.outcome === 'pass') {
441
- resolved.push(d);
442
- trail.push(`${d.taskId}: RESOLVED — re-ran \`${cmd}\` and it exited 0`);
443
- continue;
444
- }
445
- trail.push(`${d.taskId}: still open — re-ran \`${cmd}\`: `
446
- + (r.outcome === 'fail' ?
447
- `it FAILED${r.detail ? ` (${r.detail})` : ''}`
448
- : `INCONCLUSIVE${r.detail ? ` (${r.detail})` : ''}, nothing was observed`));
449
- open.push(d);
484
+ ran.set(cmd, r);
485
+ settle(d, cmd, r);
450
486
  }
451
487
  return { open, resolved, trail };
452
488
  }
@@ -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,22 @@
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 cancels a phrase only
15
+ * inside the phrase's own clause: "rather than flag it as a known issue" rejects
16
+ * the phrase, and "without touching the test file, accepting that it fails" does
17
+ * not. "Known issue", "follow-up" and "a later step" also name legitimate plans —
18
+ * an upstream bug, a scope cut — so they count only in a clause about a check.
19
+ */
1
20
  export declare function defersBreakage(answer: string): boolean;
2
21
  /** The one re-ask a deferring answer gets before it is surfaced instead of promoted. */
3
22
  export declare function deferredBreakageReaskHint(answer: string): string;
@@ -10,33 +10,109 @@
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 cancels a phrase only
15
+ * inside the phrase's own clause: "rather than flag it as a known issue" rejects
16
+ * the phrase, and "without touching the test file, accepting that it fails" does
17
+ * not. "Known issue", "follow-up" and "a later step" also name legitimate plans —
18
+ * an upstream bug, a scope cut — so they count only in a clause about a check.
19
+ */
20
+ /** A test, or a static check that the same clause calls broken. */
21
+ const TEST_NOUN = /\b(?:tests?|suites?|assertions?)\b/i;
22
+ const BUILD_NOUN = /\b(?:lint|linter|typecheck|build|ci)\b/i;
23
+ const FAILURE = /\b(?:fail\w*|red|broken|breaks?|breakage|errors?)\b/i;
24
+ /** Before a phrase in its clause: the phrase is rejected. */
25
+ 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)\b/i;
26
+ /**
27
+ * A modal cancels a phrase only where the sentence poses an option for it to
28
+ * weigh: "IF NOT EXISTS would still leave the test failing" describes what a
29
+ * rejected option does. A bare hedge does not — "I would flag it as a known
30
+ * issue" is the decision, and treating every modal as hypothetical let the guard
31
+ * be rephrased away.
13
32
  */
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
33
+ const MODAL = /\b(?:would|could|might)\b/i;
34
+ const HYPOTHETICAL = /\b(?:if|unless|either|whether|option|alternative|otherwise)\b/i;
35
+ /** Where one clause ends and the next begins. A semicolon joins clauses of ONE
36
+ * thought, so the breakage a clause defers may sit in the other half. */
37
+ const CLAUSE_BOUNDARY = /[,:;()]|\s[—–-]\s|\b(?:and|but|so|then|while|whereas|although|though|because|since|however)\b/gi;
38
+ const SENTENCE_BOUNDARY = /[.!?](?=\s|$)|\n/;
39
+ const PHRASES = [
40
+ { re: /\b(?:test|suite)[- ]owners?\b/i, needs: 'alone' },
41
+ {
42
+ 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,
43
+ needs: 'alone'
44
+ },
45
+ {
46
+ re: /\bleav(?:e|es|ing)\b.*?\b(?:tests?|suites?|assertions?|lint|build|checks?|ci)\b.*?\b(?:failing|red|broken|as[- ]is)\b/i,
47
+ needs: 'alone'
48
+ },
49
+ {
50
+ re: /\bskip(?:s|ping)?\s+(?:updating|fixing|adjusting|changing|touching)\b.*?\b(?:tests?|suites?|assertions?)\b/i,
51
+ needs: 'alone'
52
+ },
53
+ { re: /\baccept(?:s|ed|ing)?\b.*?\b(?:fail\w*|red|broken)\b/i, needs: 'check' },
54
+ // Handing the work to an unnamed someone is the deferral itself, whatever the
55
+ // clause is about; bare `whoever` below still needs a check to be one.
56
+ { re: /\bwhoever\s+(?:owns|revisits|maintains|touches)\b/i, needs: 'alone' },
57
+ { re: /\bownership\s+(?:belongs|lies|rests)\s+(?:to|with)\b/i, needs: 'alone' },
58
+ { re: /\bwhoever\b/i, needs: 'check' },
59
+ { re: /\bowned\s+(?:by|follow[- ]?up)\b/i, needs: 'check' },
60
+ { re: /\bleft\s+for\b/i, needs: 'check' },
61
+ { re: /\bknown[- ]issues?\b/i, needs: 'check' },
62
+ { re: /\bfollow[- ]?ups?\b/i, needs: 'check' },
63
+ {
64
+ re: /\b(?:a|the|another|some)\s+(?:later|future|subsequent|separate)\s+(?:step|task|change|pr)\b/i,
65
+ needs: 'check'
66
+ },
67
+ { re: /\bdefer(?:s|red|ring)?\b/i, needs: 'check' },
68
+ { re: /\bout\s+of\s+scope\b/i, needs: 'check' },
69
+ {
70
+ re: /\b(?:that|this|which|it|they|those)\s+(?:is|are|remains?)\s+out\s+of\s+scope\b/i,
71
+ needs: 'breakage'
72
+ }
26
73
  ];
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;
74
+ function aboutACheck(text) {
75
+ return TEST_NOUN.test(text) || (BUILD_NOUN.test(text) && FAILURE.test(text));
76
+ }
77
+ /**
78
+ * Parenthetical asides go, and a code span keeps its words but loses the
79
+ * punctuation that would split a clause in two: `toEqual([{filename: X}])` is
80
+ * one token of the sentence around it, not three clauses.
81
+ */
82
+ function prose(answer) {
83
+ let text = answer.replace(/`([^`]*)`/g, (_m, code) => code.replace(/[,;:()[\]{}]/g, ' '));
84
+ let before;
85
+ do {
86
+ before = text;
87
+ text = text.replace(/\([^()]*\)/g, ' ');
88
+ } while (text !== before);
89
+ return text;
90
+ }
91
+ function clauses(sentence) {
92
+ return sentence.split(CLAUSE_BOUNDARY).filter(c => c.trim().length > 0);
93
+ }
30
94
  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))
95
+ for (const sentence of prose(answer).split(SENTENCE_BOUNDARY)) {
96
+ const sentenceBreaks = aboutACheck(sentence) && FAILURE.test(sentence);
97
+ for (const clause of clauses(sentence)) {
98
+ for (const { re, needs } of PHRASES) {
99
+ const hit = re.exec(clause);
100
+ if (!hit)
101
+ continue;
102
+ const before = clause.slice(0, hit.index);
103
+ if (NOT_A_DECISION.test(before))
104
+ continue;
105
+ if (MODAL.test(before) && HYPOTHETICAL.test(sentence))
106
+ continue;
107
+ if (needs === 'check' && !aboutACheck(clause))
108
+ continue;
109
+ if (needs === 'breakage' && !sentenceBreaks)
110
+ continue;
36
111
  return true;
112
+ }
37
113
  }
38
- return false;
39
- });
114
+ }
115
+ return false;
40
116
  }
41
117
  /** The one re-ask a deferring answer gets before it is surfaced instead of promoted. */
42
118
  export function deferredBreakageReaskHint(answer) {
@@ -27,6 +27,17 @@ export type FinalGateFixFn = (ctx: ExtensionCommandContext, cwd: string, failRea
27
27
  * FinalFixDeps.ignoredKnown) — a failed attempt's ignored writes survive its
28
28
  * discard and can green a later attempt. */
29
29
  ignoredKnown?: string[]) => Promise<FinalFixResult>;
30
+ /**
31
+ * A file the suite wrote as the repo's OWN record, not as a report about the run.
32
+ * A test added this task and never run locally generates its snapshot on the
33
+ * gate's run; deleting it commits a snapshot test with no snapshot, and the next
34
+ * suite — or CI — fails on a file the task was supposed to carry.
35
+ *
36
+ * A named set, not a shape test: the alternative is an allowlist of throwaway
37
+ * artefacts, and anything it misses rides into the commit, which is the problem
38
+ * the cleanup exists for. A stray snapshot is the smaller error.
39
+ */
40
+ export declare function isSuiteRecord(rel: string): boolean;
30
41
  /**
31
42
  * Collect the task's changed files as pure GIT SHAPE — path + added-line count,
32
43
  * no content, no language parsing — for the self-verification probe. Before the
@@ -82,6 +82,21 @@ export function truncateToolResult(text, limit = TOOL_RESULT_LOG_LIMIT) {
82
82
  }
83
83
  /** Keep the gate machinery's own artifacts out of every git pathspec below. */
84
84
  const EXCLUDE_TASKS_DIR = ':(exclude).pi-tasks';
85
+ /**
86
+ * A file the suite wrote as the repo's OWN record, not as a report about the run.
87
+ * A test added this task and never run locally generates its snapshot on the
88
+ * gate's run; deleting it commits a snapshot test with no snapshot, and the next
89
+ * suite — or CI — fails on a file the task was supposed to carry.
90
+ *
91
+ * A named set, not a shape test: the alternative is an allowlist of throwaway
92
+ * artefacts, and anything it misses rides into the commit, which is the problem
93
+ * the cleanup exists for. A stray snapshot is the smaller error.
94
+ */
95
+ export function isSuiteRecord(rel) {
96
+ return (/(?:^|[\\/])__(?:snapshots|image_snapshots)__[\\/]/.test(rel)
97
+ || /\.snap$/.test(rel)
98
+ || /\.approved\.[^.\\/]+$/.test(rel));
99
+ }
85
100
  /**
86
101
  * Pin the diff header prefixes on any command whose output we PARSE for paths.
87
102
  *
@@ -537,7 +552,10 @@ export async function healthBaselineFor(cwd, taskId, signal) {
537
552
  return stored;
538
553
  const fresh = await lazyHealthBaseline({
539
554
  git: makeGit(cwd, signal),
540
- runHealthIn: dir => runRepoHealthCheck(dir, { signal, withTests: true })
555
+ // Statics only. The worktree has none of the tree's ignored files (the
556
+ // installed dependencies, a `.env`), so its suite fails for that, and a
557
+ // red recorded here would excuse the real regression it matches.
558
+ runHealthIn: dir => runRepoHealthCheck(dir, { signal })
541
559
  });
542
560
  if (fresh) {
543
561
  await setTaskSection(cwd, taskId, HEALTH_BASELINE_SECTION, formatHealthBaseline(fresh)).catch(() => { });
@@ -720,7 +738,32 @@ export function buildGateDeps(params) {
720
738
  await git(cwd2, ['checkout', '--', '.', EXCLUDE_TASKS_DIR], signal);
721
739
  await git(cwd2, ['clean', '-fd', '-e', '.pi-tasks'], signal);
722
740
  };
723
- // The project's own statics, under a live loader naming the running command.
741
+ const untrackedFiles = async (cwd2) => {
742
+ const r = await git(cwd2, ['ls-files', '--others', '--exclude-standard', '-z', '--', '.', EXCLUDE_TASKS_DIR], signal);
743
+ return r.exitCode === 0 ? new Set(r.stdout.split('\u0000').filter(f => f.length > 0)) : null;
744
+ };
745
+ // The project's own checks, suite included, once per tree for the run. A suite
746
+ // writes coverage, reports and databases into the tree; left there, they ride
747
+ // into the task's commit and read as enforce edits, so what the check created
748
+ // is removed before the tree is hashed again — except what it wrote as the
749
+ // repo's own record (see isSuiteRecord).
750
+ const gateHealth = (cwd2, onCommand) => currentRunContext(cwd2).healthFor(async () => {
751
+ const before = await untrackedFiles(cwd2);
752
+ try {
753
+ return await runRepoHealthCheck(cwd2, { signal, withTests: true, onCommand });
754
+ }
755
+ finally {
756
+ const after = before ? await untrackedFiles(cwd2) : null;
757
+ for (const rel of after ?? []) {
758
+ if (!before?.has(rel) && !isSuiteRecord(rel)) {
759
+ await fsp
760
+ .rm(path.join(cwd2, rel), { recursive: true, force: true })
761
+ .catch(() => { });
762
+ }
763
+ }
764
+ }
765
+ });
766
+ // The project's own checks, under a live loader naming the running command.
724
767
  // Each run is as long as that command, and a gate step that long with no widget
725
768
  // is indistinguishable from a hang. Shared by the enforce pre-commit gate (a
726
769
  // baseline before the edit pass, a differential after it) and by the
@@ -737,12 +780,8 @@ export function buildGateDeps(params) {
737
780
  startedAt,
738
781
  lastLine: running ? `repo health · ${running}` : 'repo health'
739
782
  }));
740
- return runRepoHealthCheck(cwd2, {
741
- signal,
742
- withTests: true,
743
- onCommand: c => {
744
- running = c;
745
- }
783
+ return gateHealth(cwd2, c => {
784
+ running = c;
746
785
  }).finally(stop);
747
786
  };
748
787
  // Adapter onto the shared gate-child runner (gate-child.ts). What survives
@@ -946,12 +985,8 @@ export function buildGateDeps(params) {
946
985
  // progress hook here would be uncancellable and mute for reasons
947
986
  // unrelated to the thing under test. The arm's only difference is
948
987
  // the LOADER, above.
949
- repoHealth: () => runRepoHealthCheck(cwd2, {
950
- signal,
951
- withTests: true,
952
- onCommand: c => {
953
- stageLine = `repo health · ${c}`;
954
- }
988
+ repoHealth: () => gateHealth(cwd2, c => {
989
+ stageLine = `repo health · ${c}`;
955
990
  }),
956
991
  // What those checks said before the task started, so a red one
957
992
  // 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,24 @@ 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
+ /**
42
+ * Test commands the baseline saw PASS that now find no tests to run.
43
+ *
44
+ * A runner that found nothing observed nothing, which is a gap — in isolation. A
45
+ * task that deleted the test directory, renamed it, or broke the config's glob
46
+ * leaves the same gap, and the check reports the repo healthy because a gap never
47
+ * fails. Against a baseline that ran the suite, the suite is gone: this task's
48
+ * regression, and the largest one it can hide behind a green.
49
+ */
50
+ export declare function vanishedSuites(baseline: HealthSignal | null, after: HealthSignal): HealthCommandResult[];
51
+ /** The failing commands the baseline did not have failing the same way — what a
52
+ * `regressed` verdict is about. Every failing command when there is no baseline. */
53
+ export declare function regressedCommands(baseline: HealthSignal | null, after: HealthSignal): HealthCommandResult[];
54
+ /**
55
+ * The failing commands, as prompt/trail lines naming the exit code. A test
56
+ * runner exits 1 for one failing test or for fifty, so for a suite the line
57
+ * claims only the exit code: which tests fail was not compared.
58
+ */
42
59
  export declare function inheritedHealthFindings(after: HealthSignal): string[];
43
60
  export declare const HEALTH_BASELINE_SECTION = "health baseline";
44
61
  /**
@@ -46,9 +63,9 @@ export declare const HEALTH_BASELINE_SECTION = "health baseline";
46
63
  * grammar: this round-trips through a committed file that a later run parses, and
47
64
  * a second grammar is a second thing to drift.
48
65
  *
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.
66
+ * The captured output is dropped, the outcome's and each command's up to 40
67
+ * lines of a linter's report, in a file committed with every task, for a field the
68
+ * differential never reads. The live run's own trail already carries it.
52
69
  */
53
70
  export declare function formatHealthBaseline(b: HealthBaseline): string;
54
71
  /** Parse a `## health baseline` section back. Null on anything unreadable — an
@@ -41,6 +41,8 @@ function failures(signal) {
41
41
  * default the enforce site used to carry.
42
42
  */
43
43
  export function classifyHealthDelta(baseline, after) {
44
+ if (vanishedSuites(baseline, after).length > 0)
45
+ return 'regressed';
44
46
  if (after.ok)
45
47
  return 'clean';
46
48
  if (!baseline)
@@ -52,13 +54,42 @@ export function classifyHealthDelta(baseline, after) {
52
54
  const detailed = now.length > 0 && (baseline.ok || before.length > 0);
53
55
  if (!detailed)
54
56
  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';
57
+ return regressedCommands(baseline, after).length > 0 ? 'regressed' : 'pre-existing';
58
58
  }
59
- /** The failing commands, as prompt/trail lines naming the exit code. */
59
+ const failureKey = (c) => JSON.stringify([c.cmd, c.exitCode]);
60
+ /**
61
+ * Test commands the baseline saw PASS that now find no tests to run.
62
+ *
63
+ * A runner that found nothing observed nothing, which is a gap — in isolation. A
64
+ * task that deleted the test directory, renamed it, or broke the config's glob
65
+ * leaves the same gap, and the check reports the repo healthy because a gap never
66
+ * fails. Against a baseline that ran the suite, the suite is gone: this task's
67
+ * regression, and the largest one it can hide behind a green.
68
+ */
69
+ export function vanishedSuites(baseline, after) {
70
+ if (!baseline)
71
+ return [];
72
+ const passed = new Set((baseline.commands ?? []).filter(c => c.outcome === 'pass').map(c => c.cmd));
73
+ return (after.commands ?? []).filter(c => c.outcome === 'skip' && c.gap === 'empty-suite' && passed.has(c.cmd));
74
+ }
75
+ /** The failing commands the baseline did not have failing the same way — what a
76
+ * `regressed` verdict is about. Every failing command when there is no baseline. */
77
+ export function regressedCommands(baseline, after) {
78
+ const wasFailing = new Set(baseline ? failures(baseline).map(failureKey) : []);
79
+ return [
80
+ ...failures(after).filter(c => !wasFailing.has(failureKey(c))),
81
+ ...vanishedSuites(baseline, after)
82
+ ];
83
+ }
84
+ /**
85
+ * The failing commands, as prompt/trail lines naming the exit code. A test
86
+ * runner exits 1 for one failing test or for fifty, so for a suite the line
87
+ * claims only the exit code: which tests fail was not compared.
88
+ */
60
89
  export function inheritedHealthFindings(after) {
61
- return failures(after).map(c => `\`${c.cmd}\` exits ${c.exitCode} (and did before this task)`);
90
+ return failures(after).map(c => c.kind === 'test' ?
91
+ `\`${c.cmd}\` exits ${c.exitCode}, as it did before this task — the same exit code, not proof the same tests fail`
92
+ : `\`${c.cmd}\` exits ${c.exitCode} (and did before this task)`);
62
93
  }
63
94
  // ─── The task-file section ───────────────────────────────────────────────────
64
95
  export const HEALTH_BASELINE_SECTION = 'health baseline';
@@ -67,12 +98,13 @@ export const HEALTH_BASELINE_SECTION = 'health baseline';
67
98
  * grammar: this round-trips through a committed file that a later run parses, and
68
99
  * a second grammar is a second thing to drift.
69
100
  *
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.
101
+ * The captured output is dropped, the outcome's and each command's up to 40
102
+ * lines of a linter's report, in a file committed with every task, for a field the
103
+ * differential never reads. The live run's own trail already carries it.
73
104
  */
74
105
  export function formatHealthBaseline(b) {
75
- const { output: _output, ...outcome } = b.outcome;
106
+ const { output: _output, commands, ...rest } = b.outcome;
107
+ const outcome = { ...rest, commands: commands.map(({ output: _o, ...c }) => c) };
76
108
  return ['```json', JSON.stringify({ ...b, outcome }, null, 2), '```'].join('\n');
77
109
  }
78
110
  /** Parse a `## health baseline` section back. Null on anything unreadable — an