@mjasnikovs/pi-task 0.42.4 → 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.
@@ -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 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.
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,12 @@ 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
+ { re: /\bownership\s+(?:belongs|lies|rests)\s+(?:to|with)\b/i, needs: 'alone' },
46
58
  { re: /\bwhoever\b/i, needs: 'check' },
47
59
  { 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
60
  { re: /\bleft\s+for\b/i, needs: 'check' },
50
61
  { re: /\bknown[- ]issues?\b/i, needs: 'check' },
51
62
  { re: /\bfollow[- ]?ups?\b/i, needs: 'check' },
@@ -88,7 +99,10 @@ export function defersBreakage(answer) {
88
99
  const hit = re.exec(clause);
89
100
  if (!hit)
90
101
  continue;
91
- if (NOT_A_DECISION.test(clause.slice(0, hit.index)))
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))
92
106
  continue;
93
107
  if (needs === 'check' && !aboutACheck(clause))
94
108
  continue;
@@ -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,16 @@ 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 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[];
41
51
  /** The failing commands the baseline did not have failing the same way — what a
42
52
  * `regressed` verdict is about. Every failing command when there is no baseline. */
43
53
  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,29 @@ 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 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
+ }
58
75
  /** The failing commands the baseline did not have failing the same way — what a
59
76
  * `regressed` verdict is about. Every failing command when there is no baseline. */
60
77
  export function regressedCommands(baseline, after) {
61
78
  const wasFailing = new Set(baseline ? failures(baseline).map(failureKey) : []);
62
- return failures(after).filter(c => !wasFailing.has(failureKey(c)));
79
+ return [
80
+ ...failures(after).filter(c => !wasFailing.has(failureKey(c))),
81
+ ...vanishedSuites(baseline, after)
82
+ ];
63
83
  }
64
84
  /**
65
85
  * The failing commands, as prompt/trail lines naming the exit code. A test
@@ -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
  }
@@ -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
  }
@@ -275,7 +283,9 @@ export async function runRepoHealthCheck(cwd, opts = {}) {
275
283
  /** "`bun run lint` exited 1; `bun run test` exited 1" — every failing command. */
276
284
  export function describeHealthFailures(commands) {
277
285
  return commands
278
- .filter(c => c.outcome === 'fail')
279
- .map(c => `\`${c.cmd}\` exited ${c.exitCode}`)
286
+ .filter(c => c.outcome === 'fail' || c.gap === 'empty-suite')
287
+ .map(c => c.outcome === 'fail' ?
288
+ `\`${c.cmd}\` exited ${c.exitCode}`
289
+ : `\`${c.cmd}\` found no tests to run`)
280
290
  .join('; ');
281
291
  }
@@ -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') {
@@ -938,15 +943,22 @@ export async function runWorkVerification(deps) {
938
943
  ok: false,
939
944
  failClass,
940
945
  reason: `${VERIFY_FAIL_PREFIX[failClass]} ${describeHealthFailures(regressed)}`,
941
- health: { ...h, commands: regressed, output: regressed[0].output ?? h.output }
946
+ health: {
947
+ ...h,
948
+ ok: false,
949
+ commands: regressed,
950
+ output: regressed[0].output ?? h.output
951
+ }
942
952
  };
943
953
  }
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`;
954
+ if (!h.ok) {
955
+ pre.repoHealth = inheritedHealthFindings(h);
956
+ const failing = h.commands?.filter(c => c.outcome === 'fail') ?? [];
957
+ inheritedHealth =
958
+ failing.length > 0 ?
959
+ `${VERIFY_FAIL_PREFIX[healthFailClass(failing)]} ${describeHealthFailures(failing)} — already failing before this task`
960
+ : `repo health: ${h.reason} — already failing before this task`;
961
+ }
950
962
  }
951
963
  }
952
964
  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.5",
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",