@mjasnikovs/pi-task 0.42.4 → 0.42.6

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.
@@ -36,6 +36,7 @@ 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
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';
@@ -341,6 +342,9 @@ function isStorableCommand(cmd) {
341
342
  * command with fabricated provenance, which is the one thing this class may not do.
342
343
  */
343
344
  export async function classifyVerifyCommand(cwd, taskId, reason) {
345
+ const suite = suiteCommandFromReason(cwd, reason);
346
+ if (suite !== null)
347
+ return suite;
344
348
  if (taskId.trim().length === 0)
345
349
  return null;
346
350
  try {
@@ -355,6 +359,23 @@ export async function classifyVerifyCommand(cwd, taskId, reason) {
355
359
  return null;
356
360
  }
357
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
+ }
358
379
  export function verifyCommandFromReason(reason, verifyCommands) {
359
380
  const byText = new Map();
360
381
  for (const c of verifyCommands) {
@@ -395,6 +416,19 @@ export async function recheckAcceptDebts(debts, opts) {
395
416
  const resolved = [];
396
417
  const trail = [];
397
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
+ };
398
432
  for (const d of debts) {
399
433
  if (d.origin === 'cross-task-deletion') {
400
434
  const p = extractDeletedDebtPath(d.reason);
@@ -423,6 +457,15 @@ export async function recheckAcceptDebts(debts, opts) {
423
457
  open.push(d);
424
458
  continue;
425
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
+ }
426
469
  if (rerunsLeft <= 0) {
427
470
  trail.push(`${d.taskId}: NOT re-checked — the per-run re-run budget `
428
471
  + `(${MAX_VERIFY_RERUNS}) is spent; the debt stays open`);
@@ -438,16 +481,8 @@ export async function recheckAcceptDebts(debts, opts) {
438
481
  // A harness fault observes nothing, so it proves nothing.
439
482
  r = { outcome: 'gap', detail: 're-run harness fault' };
440
483
  }
441
- if (r.outcome === 'pass') {
442
- resolved.push(d);
443
- trail.push(`${d.taskId}: RESOLVED — re-ran \`${cmd}\` and it exited 0`);
444
- continue;
445
- }
446
- trail.push(`${d.taskId}: still open — re-ran \`${cmd}\`: `
447
- + (r.outcome === 'fail' ?
448
- `it FAILED${r.detail ? ` (${r.detail})` : ''}`
449
- : `INCONCLUSIVE${r.detail ? ` (${r.detail})` : ''}, nothing was observed`));
450
- open.push(d);
484
+ ran.set(cmd, r);
485
+ settle(d, cmd, r);
451
486
  }
452
487
  return { open, resolved, trail };
453
488
  }
@@ -11,12 +11,11 @@
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
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.
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.
20
19
  */
21
20
  export declare function defersBreakage(answer: string): boolean;
22
21
  /** The one re-ask a deferring answer gets before it is surfaced instead of promoted. */
@@ -11,23 +11,31 @@
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
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.
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.
20
19
  */
21
20
  /** A test, or a static check that the same clause calls broken. */
22
21
  const TEST_NOUN = /\b(?:tests?|suites?|assertions?)\b/i;
23
22
  const BUILD_NOUN = /\b(?:lint|linter|typecheck|build|ci)\b/i;
24
23
  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/;
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 condition's own half of the sentence
28
+ * poses an option for it to weigh: "IF NOT EXISTS would still leave the test
29
+ * failing" describes what a rejected option does. A bare hedge does not — "I
30
+ * would flag it as a known issue" is the decision, and treating every modal as
31
+ * hypothetical let the guard be rephrased away.
32
+ */
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/;
31
39
  const PHRASES = [
32
40
  { re: /\b(?:test|suite)[- ]owners?\b/i, needs: 'alone' },
33
41
  {
@@ -43,9 +51,18 @@ const PHRASES = [
43
51
  needs: 'alone'
44
52
  },
45
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
+ // Ownership handed to a ROLE is handed to nobody. Handed to a named team it is
58
+ // handed to someone, so it counts only where the clause is about a check.
59
+ {
60
+ re: /\bownership\s+(?:belongs|lies|rests)\s+(?:to|with)\s+(?:whoever|someone|somebody|another\b|a\s+later\b|the\s+(?:\w+\s+)?(?:owners?|maintainers?)\b)/i,
61
+ needs: 'alone'
62
+ },
63
+ { re: /\bownership\s+(?:belongs|lies|rests)\s+(?:to|with)\b/i, needs: 'check' },
46
64
  { re: /\bwhoever\b/i, needs: 'check' },
47
65
  { 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
66
  { re: /\bleft\s+for\b/i, needs: 'check' },
50
67
  { re: /\bknown[- ]issues?\b/i, needs: 'check' },
51
68
  { re: /\bfollow[- ]?ups?\b/i, needs: 'check' },
@@ -83,18 +100,27 @@ function clauses(sentence) {
83
100
  export function defersBreakage(answer) {
84
101
  for (const sentence of prose(answer).split(SENTENCE_BOUNDARY)) {
85
102
  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;
103
+ // A condition governs its own half of a semicolon. It joins two independent
104
+ // clauses, so "the test fails only if X; I would flag it as known" states a
105
+ // condition and then decides — the decision is not one of X's options.
106
+ for (const half of sentence.split(';')) {
107
+ const weighsOptions = HYPOTHETICAL.test(half);
108
+ for (const clause of clauses(half)) {
109
+ for (const { re, needs } of PHRASES) {
110
+ const hit = re.exec(clause);
111
+ if (!hit)
112
+ continue;
113
+ const before = clause.slice(0, hit.index);
114
+ if (NOT_A_DECISION.test(before))
115
+ continue;
116
+ if (MODAL.test(before) && weighsOptions)
117
+ continue;
118
+ if (needs === 'check' && !aboutACheck(clause))
119
+ continue;
120
+ if (needs === 'breakage' && !sentenceBreaks)
121
+ continue;
122
+ return true;
123
+ }
98
124
  }
99
125
  }
100
126
  }
@@ -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
  *
@@ -730,7 +745,8 @@ export function buildGateDeps(params) {
730
745
  // The project's own checks, suite included, once per tree for the run. A suite
731
746
  // writes coverage, reports and databases into the tree; left there, they ride
732
747
  // into the task's commit and read as enforce edits, so what the check created
733
- // is removed before the tree is hashed again.
748
+ // is removed before the tree is hashed again — except what it wrote as the
749
+ // repo's own record (see isSuiteRecord).
734
750
  const gateHealth = (cwd2, onCommand) => currentRunContext(cwd2).healthFor(async () => {
735
751
  const before = await untrackedFiles(cwd2);
736
752
  try {
@@ -739,7 +755,7 @@ export function buildGateDeps(params) {
739
755
  finally {
740
756
  const after = before ? await untrackedFiles(cwd2) : null;
741
757
  for (const rel of after ?? []) {
742
- if (!before?.has(rel)) {
758
+ if (!before?.has(rel) && !isSuiteRecord(rel)) {
743
759
  await fsp
744
760
  .rm(path.join(cwd2, rel), { recursive: true, force: true })
745
761
  .catch(() => { });
@@ -38,6 +38,19 @@ 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
+ /**
42
+ * Test commands the baseline RAN 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
+ * Ran, not passed. A baseline that ran the suite RED ran it, and deleting a red
51
+ * suite is the same move with a larger payoff: the whole check turns green.
52
+ */
53
+ export declare function vanishedSuites(baseline: HealthSignal | null, after: HealthSignal): HealthCommandResult[];
41
54
  /** The failing commands the baseline did not have failing the same way — what a
42
55
  * `regressed` verdict is about. Every failing command when there is no baseline. */
43
56
  export declare function regressedCommands(baseline: HealthSignal | null, after: HealthSignal): HealthCommandResult[];
@@ -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)
@@ -55,11 +57,32 @@ export function classifyHealthDelta(baseline, after) {
55
57
  return regressedCommands(baseline, after).length > 0 ? 'regressed' : 'pre-existing';
56
58
  }
57
59
  const failureKey = (c) => JSON.stringify([c.cmd, c.exitCode]);
60
+ /**
61
+ * Test commands the baseline RAN 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
+ * Ran, not passed. A baseline that ran the suite RED ran it, and deleting a red
70
+ * suite is the same move with a larger payoff: the whole check turns green.
71
+ */
72
+ export function vanishedSuites(baseline, after) {
73
+ if (!baseline)
74
+ return [];
75
+ const ran = new Set((baseline.commands ?? []).filter(c => c.outcome !== 'skip').map(c => c.cmd));
76
+ return (after.commands ?? []).filter(c => c.outcome === 'skip' && c.gap === 'empty-suite' && ran.has(c.cmd));
77
+ }
58
78
  /** The failing commands the baseline did not have failing the same way — what a
59
79
  * `regressed` verdict is about. Every failing command when there is no baseline. */
60
80
  export function regressedCommands(baseline, after) {
61
81
  const wasFailing = new Set(baseline ? failures(baseline).map(failureKey) : []);
62
- return failures(after).filter(c => !wasFailing.has(failureKey(c)));
82
+ return [
83
+ ...failures(after).filter(c => !wasFailing.has(failureKey(c))),
84
+ ...vanishedSuites(baseline, after)
85
+ ];
63
86
  }
64
87
  /**
65
88
  * The failing commands, as prompt/trail lines naming the exit code. A test
@@ -22,7 +22,7 @@
22
22
  * because its script is a placeholder `exit 1`, and no repair task can fix either.
23
23
  */
24
24
  import type { HealthSignal } from './health-baseline.js';
25
- import type { HealthCommandResult } from './repo-health-check.js';
25
+ import { type HealthCommandResult } from './repo-health-check.js';
26
26
  /** The failing check, and what its output named. */
27
27
  export interface HealthRed {
28
28
  command: string;
@@ -36,9 +36,13 @@ export interface HealthRedOwners {
36
36
  owners: string[];
37
37
  }
38
38
  /**
39
- * What a red health result is about: its first failing command that `mayRepair`
39
+ * What a red health result is about: its first red command that `mayRepair`
40
40
  * admits. Null when there is none — a legacy baseline, a signal with no
41
41
  * per-command detail, or only reds no repair can fix — so nothing to pin to.
42
+ *
43
+ * A vanished suite is red here though it failed nothing and left `ok` true. It
44
+ * is the regression the differential exists to catch, and without a subject
45
+ * ACCEPT queued no repair for it and the checkpoint spliced none.
42
46
  */
43
47
  export declare function healthRedSubject(health: HealthSignal & {
44
48
  output?: string;
@@ -1,3 +1,4 @@
1
+ import { isHealthRed } from './repo-health-check.js';
1
2
  import { parseRepairTitleFile } from './root-cause-repair.js';
2
3
  import { failClassOfReason } from './verify-work.js';
3
4
  /** A path-like token: at least one directory separator, ending in a file name. */
@@ -23,12 +24,16 @@ function resolveTracked(token, cwd, tracked) {
23
24
  return bySuffix.length === 1 ? bySuffix[0] : null;
24
25
  }
25
26
  /**
26
- * What a red health result is about: its first failing command that `mayRepair`
27
+ * What a red health result is about: its first red command that `mayRepair`
27
28
  * admits. Null when there is none — a legacy baseline, a signal with no
28
29
  * per-command detail, or only reds no repair can fix — so nothing to pin to.
30
+ *
31
+ * A vanished suite is red here though it failed nothing and left `ok` true. It
32
+ * is the regression the differential exists to catch, and without a subject
33
+ * ACCEPT queued no repair for it and the checkpoint spliced none.
29
34
  */
30
35
  export function healthRedSubject(health, cwd, tracked, mayRepair = () => true) {
31
- const failing = (health.commands ?? []).find(c => c.outcome === 'fail' && mayRepair(c));
36
+ const failing = (health.commands ?? []).find(c => isHealthRed(c) && mayRepair(c));
32
37
  if (!failing)
33
38
  return null;
34
39
  const files = [];
@@ -1,4 +1,4 @@
1
- import { type CommandRunner } from './command-run.js';
1
+ import { type CommandGapId, type CommandRunner } from './command-run.js';
2
2
  /**
3
3
  * What ONE discovered command did. `outcome` is `classifyCommandRun`'s verdict, so
4
4
  * a tool that could not run at all is `skip` rather than a zero-exit pass.
@@ -18,6 +18,9 @@ export interface HealthCommandResult {
18
18
  /** Absent on a record written before the suite joined the check, which ran
19
19
  * statics only. A test red is judged, owed and repaired differently. */
20
20
  kind?: 'static' | 'test';
21
+ /** Why nothing was observed, on a `skip`. The differential reads it: a runner
22
+ * that found no tests is a gap alone and a regression against a suite. */
23
+ gap?: CommandGapId;
21
24
  /** This command's own captured output, on a `fail` only. */
22
25
  output?: string;
23
26
  }
@@ -112,5 +115,7 @@ export declare function runRepoHealthCheck(cwd: string, opts?: {
112
115
  * that will judge the result DIFFERENTIALLY may set this — see the header. */
113
116
  withTests?: boolean;
114
117
  }): Promise<HealthOutcome>;
118
+ /** A command the repo owes an answer for: it failed, or its suite went missing. */
119
+ export declare function isHealthRed(c: HealthCommandResult): boolean;
115
120
  /** "`bun run lint` exited 1; `bun run test` exited 1" — every failing command. */
116
121
  export declare function describeHealthFailures(commands: readonly HealthCommandResult[]): string;
@@ -125,9 +125,16 @@ export function discoverHealthCommands(cwd) {
125
125
  }
126
126
  return { ecosystem: null, cmds: [] };
127
127
  }
128
- /** `test:watch`, `jest --watchAll`, `vitest watch`, `bun test --watch`. */
128
+ /**
129
+ * `test:watch`, `jest --watchAll`, `vitest watch`, `bun test --watch`.
130
+ *
131
+ * The flag is read by its VALUE, not its presence: `--watchAll=false` is how a CI
132
+ * script turns watch off, and excluding it drops the only `test` script such a
133
+ * repo has.
134
+ */
129
135
  function isWatchScript(name, body) {
130
- return (/watch/i.test(name) || /(?:^|\s)--watch(?:All)?(?=[\s=]|$)|(?:^|\s)watch(?=\s|$)/.test(body));
136
+ return (/watch/i.test(name)
137
+ || /(?:^|\s)--watch(?:All)?(?:=(?:true|1))?(?=\s|$)|(?:^|\s)watch(?=\s|$)/.test(body));
131
138
  }
132
139
  /**
133
140
  * The project's OWN test commands, in the order the run-end gate runs them. One
@@ -242,7 +249,8 @@ export async function runRepoHealthCheck(cwd, opts = {}) {
242
249
  cmd,
243
250
  outcome: passed ? 'pass' : 'skip',
244
251
  exitCode: passed ? 0 : null,
245
- kind
252
+ kind,
253
+ ...(verdict.outcome === 'gap' ? { gap: verdict.gap } : {})
246
254
  });
247
255
  continue;
248
256
  }
@@ -272,10 +280,16 @@ export async function runRepoHealthCheck(cwd, opts = {}) {
272
280
  output: ''
273
281
  };
274
282
  }
283
+ /** A command the repo owes an answer for: it failed, or its suite went missing. */
284
+ export function isHealthRed(c) {
285
+ return c.outcome === 'fail' || c.gap === 'empty-suite';
286
+ }
275
287
  /** "`bun run lint` exited 1; `bun run test` exited 1" — every failing command. */
276
288
  export function describeHealthFailures(commands) {
277
289
  return commands
278
- .filter(c => c.outcome === 'fail')
279
- .map(c => `\`${c.cmd}\` exited ${c.exitCode}`)
290
+ .filter(isHealthRed)
291
+ .map(c => c.outcome === 'fail' ?
292
+ `\`${c.cmd}\` exited ${c.exitCode}`
293
+ : `\`${c.cmd}\` found no tests to run`)
280
294
  .join('; ');
281
295
  }
@@ -1,11 +1,12 @@
1
1
  import { verifyFailClass } from './verify-work.js';
2
2
  import { resolutionOptions, classifyResolutionAnswer } from './verify-resolution.js';
3
3
  import { resolveDisposition } from './gate-resolution.js';
4
- import { classifyHealthDelta } from './health-baseline.js';
4
+ import { classifyHealthDelta, regressedCommands } from './health-baseline.js';
5
5
  import { SessionUI, notifyBoth, notifyRun } from '../remote/bridge.js';
6
6
  import { isYoloMode, YOLO_STAMP } from './yolo.js';
7
7
  import { extractFailingCommand, findRepairCandidate, summariseDefect } from './root-cause-repair.js';
8
8
  import { attributeEnforceFailure } from './enforce-attribution.js';
9
+ import { describeHealthFailures } from './repo-health-check.js';
9
10
  import { healthRedSubject, parseHealthRepairTitle } from './health-repair.js';
10
11
  // The debt ledger is reached through the injected `recordDebt` dep (so it stays
11
12
  // absent-in-tests); only the origin TYPE and the cross-task-deletion reason SHAPE
@@ -417,15 +418,19 @@ export async function runEnforcePass(active, deps, p, rec, routeRootCause, args)
417
418
  const delta = classifyHealthDelta(healthBefore ?? null, after);
418
419
  if (delta === 'regressed') {
419
420
  enforceEditsBlocked = true;
421
+ // What REGRESSED, not the check's own verdict: a vanished suite fails
422
+ // nothing, so `after.reason` reads "tests passed" under this discard.
423
+ const regressed = regressedCommands(healthBefore ?? null, after);
424
+ const why = regressed.length > 0 ? describeHealthFailures(regressed) : after.reason;
420
425
  const outputTail = after.output ? ` — output:\n${clampOutput(after.output)}` : '';
421
426
  if (deps.discardEdits) {
422
427
  await deps.discardEdits(p.cwd);
423
- await rec(`enforce: edits discarded pre-commit — REGRESSED repo health (${after.reason})${outputTail}`);
428
+ await rec(`enforce: edits discarded pre-commit — REGRESSED repo health (${why})${outputTail}`);
424
429
  }
425
430
  else {
426
- await rec(`enforce: edits REGRESSED repo health pre-commit (${after.reason}) — no discard available, left uncommitted${outputTail}`);
431
+ await rec(`enforce: edits REGRESSED repo health pre-commit (${why}) — no discard available, left uncommitted${outputTail}`);
427
432
  }
428
- notifyRun(active, `${p.tag}: guideline edits on "${p.title}" regressed repo health (${after.reason.slice(0, 120)}) — discarded before commit.`, 'warning');
433
+ notifyRun(active, `${p.tag}: guideline edits on "${p.title}" regressed repo health (${why.slice(0, 120)}) — discarded before commit.`, 'warning');
429
434
  }
430
435
  else if (delta === 'pre-existing') {
431
436
  // Failing both before and after → not enforce's fault. Keep the edits;
@@ -65,9 +65,12 @@ export interface VerifyFail {
65
65
  crossTaskDeletions?: CrossTaskDeletion[];
66
66
  inheritedHealth?: string;
67
67
  /** The health result behind a `repo-health` FAIL: which command, and what its
68
- * output named. What an ACCEPT of this FAIL hands to the repair channel. */
68
+ * output named. What an ACCEPT of this FAIL hands to the repair channel.
69
+ * `reason` describes what REGRESSED, which is not what the check itself said:
70
+ * a vanished suite fails nothing, so the check's verdict reads "tests passed". */
69
71
  health?: HealthSignal & {
70
72
  output?: string;
73
+ reason?: string;
71
74
  };
72
75
  /** What the deterministic probes found for THIS verdict, carried out so an
73
76
  * AUTOFIX re-run is told what the gate already knows (see fix-context.ts)
@@ -918,7 +918,12 @@ export async function runWorkVerification(deps) {
918
918
  if (deps.repoHealth) {
919
919
  stage('repo health');
920
920
  const h = await deps.repoHealth();
921
- if (!h.ok) {
921
+ // `ok` is not the whole verdict: a suite this tree no longer finds observes
922
+ // nothing, so nothing fails, and only the differential sees it went away.
923
+ // Establishing a baseline can cost a worktree health run, so it is asked for
924
+ // only in the two shapes it can speak to.
925
+ const suiteGone = (h.commands ?? []).some(c => c.gap === 'empty-suite');
926
+ if (!h.ok || suiteGone) {
922
927
  const baseline = deps.healthBaseline ? await deps.healthBaseline() : null;
923
928
  const before = baseline?.outcome ?? null;
924
929
  if (classifyHealthDelta(before, h) === 'regressed') {
@@ -934,19 +939,30 @@ export async function runWorkVerification(deps) {
934
939
  };
935
940
  }
936
941
  const failClass = healthFailClass(regressed);
942
+ // Re-minted, never `h.reason`: a vanished suite fails nothing, so the
943
+ // check's own reason reads "tests passed" under a REGRESSED verdict.
944
+ const why = describeHealthFailures(regressed);
937
945
  return {
938
946
  ok: false,
939
947
  failClass,
940
- reason: `${VERIFY_FAIL_PREFIX[failClass]} ${describeHealthFailures(regressed)}`,
941
- health: { ...h, commands: regressed, output: regressed[0].output ?? h.output }
948
+ reason: `${VERIFY_FAIL_PREFIX[failClass]} ${why}`,
949
+ health: {
950
+ ...h,
951
+ ok: false,
952
+ reason: why,
953
+ commands: regressed,
954
+ output: regressed[0].output ?? h.output
955
+ }
942
956
  };
943
957
  }
944
- pre.repoHealth = inheritedHealthFindings(h);
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`;
958
+ if (!h.ok) {
959
+ pre.repoHealth = inheritedHealthFindings(h);
960
+ const failing = h.commands?.filter(c => c.outcome === 'fail') ?? [];
961
+ inheritedHealth =
962
+ failing.length > 0 ?
963
+ `${VERIFY_FAIL_PREFIX[healthFailClass(failing)]} ${describeHealthFailures(failing)} — already failing before this task`
964
+ : `repo health: ${h.reason} — already failing before this task`;
965
+ }
950
966
  }
951
967
  }
952
968
  const inherited = inheritedHealth === undefined ? {} : { inheritedHealth };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.42.4",
3
+ "version": "0.42.6",
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",