@mjasnikovs/pi-task 0.37.3 → 0.37.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.
@@ -5,6 +5,11 @@ export interface CommitResult {
5
5
  reason?: string;
6
6
  /** Set when the commit needed a fallback (e.g. self-supplied identity). */
7
7
  note?: string;
8
+ /** UNTRACKED regenerable test-runner output this commit deliberately left out
9
+ * of the index (see `stagePathspec`). Empty/absent when nothing was excluded.
10
+ * Present so the caller can TRAIL it: a silent exclusion is the same failure
11
+ * class as the silent ignored-path write nexttask 4 closed. */
12
+ excluded?: string[];
8
13
  }
9
14
  /**
10
15
  * Does this git stderr describe a missing author identity? Seen live (mx5 run 4):
@@ -21,6 +26,31 @@ export declare function git(cwd: string, args: string[], signal: AbortSignal | u
21
26
  exitCode: number;
22
27
  aborted: boolean;
23
28
  }>;
29
+ /**
30
+ * The UNTRACKED files a per-task snapshot must not sweep into the index: Playwright
31
+ * `test-results/`, `playwright-report/`, `coverage/`, `.nyc_output/`,
32
+ * `.last-run.json`, `*.tsbuildinfo`.
33
+ *
34
+ * This is the other half of mx5 run 20. TASK_0027's snapshot ran a bare
35
+ * `git add -A` over a tree the test run had just littered with three Playwright
36
+ * FAILURE screenshots (`*-actual.png` — written only when a screenshot assertion
37
+ * fails), committed them, and thereby made them tracked deliverables. Two whole
38
+ * final-gate fix attempts were then rejected for deleting them. The deletion guard
39
+ * fix (write-guard.ts) stops the rejection; this stops the tracking.
40
+ *
41
+ * ONLY UNTRACKED PATHS ARE EXCLUDED, and that is load-bearing rather than tidy.
42
+ * `git ls-files --others` lists untracked, non-ignored files and nothing else, so a
43
+ * path git ALREADY tracks can never appear here — meaning a project that
44
+ * deliberately commits, say, a `coverage/` badge keeps having its edits to it
45
+ * committed. Excluding by directory pathspec instead (`:(exclude)coverage/`) would
46
+ * silently stop committing those.
47
+ *
48
+ * Best-effort: any git failure yields an empty list, i.e. today's `git add -A`.
49
+ */
50
+ export declare function untrackedArtifacts(cwd: string, signal?: AbortSignal, spawnFn?: SpawnFn): Promise<string[]>;
51
+ /** `:(exclude)` pathspecs for `git add -A`; empty when nothing is excluded, so the
52
+ * common case is byte-identical to the previous bare `git add -A`. */
53
+ export declare function stagePathspec(excluded: readonly string[]): string[];
24
54
  /**
25
55
  * Paths with unmerged index entries (an in-progress merge conflict), deduped.
26
56
  * Empty outside a git repo or on any git error — this is a GUARD input, and a
@@ -9,6 +9,7 @@
9
9
  import * as fsp from 'node:fs/promises';
10
10
  import * as path from 'node:path';
11
11
  import { runChildDefault } from '../shared/child-process.js';
12
+ import { isDeletionExemptArtifact } from './regenerable-artifacts.js';
12
13
  /** The gate machinery's own state/forensic dir — the trail, debug logs, and per-run
13
14
  * ledgers. Preserved verbatim across a revert (see gitDropLastCommit). */
14
15
  const TRAIL_DIR = '.pi-tasks';
@@ -36,6 +37,44 @@ export async function git(cwd, args, signal, spawnFn) {
36
37
  const r = await runChildDefault({ command: 'git', args }, cwd, signal, { mode: 'text' }, spawnFn);
37
38
  return { stdout: r.stdout, stderr: r.stderr, exitCode: r.exitCode, aborted: r.aborted };
38
39
  }
40
+ /**
41
+ * The UNTRACKED files a per-task snapshot must not sweep into the index: Playwright
42
+ * `test-results/`, `playwright-report/`, `coverage/`, `.nyc_output/`,
43
+ * `.last-run.json`, `*.tsbuildinfo`.
44
+ *
45
+ * This is the other half of mx5 run 20. TASK_0027's snapshot ran a bare
46
+ * `git add -A` over a tree the test run had just littered with three Playwright
47
+ * FAILURE screenshots (`*-actual.png` — written only when a screenshot assertion
48
+ * fails), committed them, and thereby made them tracked deliverables. Two whole
49
+ * final-gate fix attempts were then rejected for deleting them. The deletion guard
50
+ * fix (write-guard.ts) stops the rejection; this stops the tracking.
51
+ *
52
+ * ONLY UNTRACKED PATHS ARE EXCLUDED, and that is load-bearing rather than tidy.
53
+ * `git ls-files --others` lists untracked, non-ignored files and nothing else, so a
54
+ * path git ALREADY tracks can never appear here — meaning a project that
55
+ * deliberately commits, say, a `coverage/` badge keeps having its edits to it
56
+ * committed. Excluding by directory pathspec instead (`:(exclude)coverage/`) would
57
+ * silently stop committing those.
58
+ *
59
+ * Best-effort: any git failure yields an empty list, i.e. today's `git add -A`.
60
+ */
61
+ export async function untrackedArtifacts(cwd, signal, spawnFn) {
62
+ const r = await git(cwd, ['ls-files', '--others', '--exclude-standard', '-z'], signal, spawnFn);
63
+ if (r.aborted || r.exitCode !== 0)
64
+ return [];
65
+ return r.stdout
66
+ .split('\0')
67
+ .map(p => p.trim())
68
+ .filter(p => p.length > 0 && isDeletionExemptArtifact(p))
69
+ .sort();
70
+ }
71
+ /** `:(exclude)` pathspecs for `git add -A`; empty when nothing is excluded, so the
72
+ * common case is byte-identical to the previous bare `git add -A`. */
73
+ export function stagePathspec(excluded) {
74
+ if (excluded.length === 0)
75
+ return [];
76
+ return ['--', '.', ...excluded.map(p => `:(exclude)${p}`)];
77
+ }
39
78
  /**
40
79
  * Paths with unmerged index entries (an in-progress merge conflict), deduped.
41
80
  * Empty outside a git repo or on any git error — this is a GUARD input, and a
@@ -93,8 +132,10 @@ export async function gitCommitAll(cwd, message, signal, spawnFn) {
93
132
  reason: `git commit blocked: unresolved merge conflict (${unmerged.slice(0, 3).join(', ')}${unmerged.length > 3 ? `, +${unmerged.length - 3} more` : ''})`
94
133
  };
95
134
  }
96
- // 3. Stage all working-tree changes (new, modified, deleted).
97
- const add = await git(cwd, ['add', '-A'], signal, spawnFn);
135
+ // 3. Stage all working-tree changes (new, modified, deleted) EXCEPT untracked
136
+ // regenerable test-runner output see stagePathspec.
137
+ const excluded = await untrackedArtifacts(cwd, signal, spawnFn);
138
+ const add = await git(cwd, ['add', '-A', ...stagePathspec(excluded)], signal, spawnFn);
98
139
  if (add.aborted)
99
140
  return { committed: false, reason: 'cancelled' };
100
141
  if (add.exitCode !== 0) {
@@ -119,7 +160,11 @@ export async function gitCommitAll(cwd, message, signal, spawnFn) {
119
160
  if (retry.aborted)
120
161
  return { committed: false, reason: 'cancelled' };
121
162
  if (retry.exitCode === 0) {
122
- return { committed: true, note: 'no git identity configured — used pi-task fallback' };
163
+ return {
164
+ committed: true,
165
+ note: 'no git identity configured — used pi-task fallback',
166
+ ...(excluded.length > 0 ? { excluded } : {})
167
+ };
123
168
  }
124
169
  return {
125
170
  committed: false,
@@ -131,7 +176,7 @@ export async function gitCommitAll(cwd, message, signal, spawnFn) {
131
176
  reason: `git commit failed: ${firstLine(commit.stderr || commit.stdout)}`
132
177
  };
133
178
  }
134
- return { committed: true };
179
+ return { committed: true, ...(excluded.length > 0 ? { excluded } : {}) };
135
180
  }
136
181
  /**
137
182
  * Drop the last commit, restoring the tree to its parent — the differential
@@ -1375,17 +1375,35 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
1375
1375
  + `so the tree holds REJECTED edits: ${stranded.slice(0, 8).join(', ')}`);
1376
1376
  return;
1377
1377
  }
1378
+ // REPORT WHAT ACTUALLY HAPPENED (mx5 run 20). This bound the
1379
+ // CommitResult to `sha` and interpolated it, so the trail
1380
+ // read "committed 5 stranded fix-pass change(s) as
1381
+ // [object Object]". Worse than cosmetic: `commit` returns
1382
+ // {committed, reason?, note?} and NEVER a sha, the
1383
+ // `committed` field was never read, and gitCommitAll
1384
+ // returns {committed:false} WITHOUT throwing on an unmerged
1385
+ // index — so on that path the catch below never fires and
1386
+ // the trail claimed a commit over changes that were still
1387
+ // sitting in the working tree.
1388
+ const notCommitted = async (why) => {
1389
+ await recGate(`final-gate: could NOT commit ${stranded.length} stranded fix-pass `
1390
+ + `change(s) (${why}) — they remain UNCOMMITTED in the working `
1391
+ + `tree: ${stranded.slice(0, 8).join(', ')}`);
1392
+ };
1378
1393
  try {
1379
- const sha = await deps.commit(cwd, STRANDED_FIX_COMMIT(id, outcome));
1380
- await recGate(`final-gate: committed ${stranded.length} stranded fix-pass change(s)`
1381
- + `${sha ? ` as ${sha}` : ''} — ${stranded.slice(0, 8).join(', ')}`);
1394
+ const res = await deps.commit(cwd, STRANDED_FIX_COMMIT(id, outcome));
1395
+ if (res.committed) {
1396
+ await recGate(`final-gate: committed ${stranded.length} stranded fix-pass change(s)`
1397
+ + `${res.note ? ` (${res.note})` : ''} — ${stranded.slice(0, 8).join(', ')}`);
1398
+ }
1399
+ else {
1400
+ await notCommitted(res.reason ?? 'unknown');
1401
+ }
1382
1402
  }
1383
1403
  catch (err) {
1384
1404
  // Never break the terminal path over this — but say so, so
1385
1405
  // the changes are not silently lost.
1386
- await recGate(`final-gate: could NOT commit ${stranded.length} stranded fix-pass `
1387
- + `change(s) (${err instanceof Error ? err.message : String(err)}) — `
1388
- + `they remain UNCOMMITTED in the working tree: ${stranded.slice(0, 8).join(', ')}`);
1406
+ await notCommitted(err instanceof Error ? err.message : String(err));
1389
1407
  }
1390
1408
  };
1391
1409
  while (!fin.ok) {
@@ -56,9 +56,10 @@ import { readEnvNotes, parseEnvNotes, isExcuseNote } from './env-notes.js';
56
56
  import { runRenderCheck } from './render-check.js';
57
57
  import { collectProjectEnv, pinnedLocalPort, runDeepRenderCheck } from './deep-render-check.js';
58
58
  import { resolveRunner, runnerEnv, isCommandNotFound } from './runner-resolve.js';
59
+ import { findLaunchConfigGap, probeEnv, configGapUnobservedNote } from './launch-config-gap.js';
59
60
  import { taskThatIntroduced } from './task-provenance.js';
60
61
  import { findDanglingArtifacts, danglingGateFailureText } from './artifact-closure.js';
61
- import { findMissingEnvDeclarations, envGateFailureText } from './env-template-closure.js';
62
+ import { findMissingEnvDeclarations, envGateFailureText, scanEnvTemplateClosure, inertClosure, trackedFiles } from './env-template-closure.js';
62
63
  import { findMissingServeEntry, serveEntryGateFailureText } from './serve-entry.js';
63
64
  import { makefileRecipe } from './command-shrink.js';
64
65
  function packageScripts(cwd) {
@@ -1008,7 +1009,10 @@ export const INFRA_GAP_OUTPUT_RE = /ECONNREFUSED|connection refused|ENOTFOUND|EA
1008
1009
  * passes `extraGapRe` (launch scripts), missing external infrastructure. Only a
1009
1010
  * command that actually ran and exited non-zero for a real reason fails.
1010
1011
  */
1011
- function runGateCommand(cwd, [bin, args], timeoutMs, extraGapRe) {
1012
+ function runGateCommand(cwd, [bin, args], timeoutMs, extraGapRe,
1013
+ /** Replaces the child's environment wholesale (config-gap probe re-run only —
1014
+ * see launch-config-gap.ts). Absent ⇒ `runnerEnv(runner)`, i.e. unchanged. */
1015
+ envOverride) {
1012
1016
  // Runner resolution (mx5 run 16): a login-shell-stripped PATH left `bun`
1013
1017
  // unspawnable, so every dynamic check skipped and the gate went blind. The
1014
1018
  // resolved binary is spawned, and its directory rides on the child's PATH so
@@ -1021,7 +1025,7 @@ function runGateCommand(cwd, [bin, args], timeoutMs, extraGapRe) {
1021
1025
  cwd,
1022
1026
  encoding: 'utf8',
1023
1027
  timeout: timeoutMs,
1024
- env: runnerEnv(runner)
1028
+ env: envOverride ?? runnerEnv(runner)
1025
1029
  });
1026
1030
  if (r.error)
1027
1031
  return { outcome: 'skip', spawnFailed: true };
@@ -1457,6 +1461,10 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
1457
1461
  // real defect the gate could not reach here (run 11's "pre-existing .rows
1458
1462
  // bug" note excused the exact scripts that shipped broken).
1459
1463
  const warnings = [];
1464
+ /** UNOBSERVED notes for launch scripts reclassified as CONFIG GAPS (run 20).
1465
+ * They ride in `unobserved`, not `warnings`, so the caller's existing
1466
+ * recordFinalGateUnobservedDebt writes the debt — never a PASS. */
1467
+ const configGapNotes = [];
1460
1468
  if (declared.length > 0) {
1461
1469
  const covered = cmds.flatMap(([bin, args]) => (bin === 'bun' || bin === 'npm') && args[0] === 'run' && args[1] ? [args[1]] : []);
1462
1470
  const skippedLaunch = [];
@@ -1464,6 +1472,21 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
1464
1472
  // failure above; executing it too would double-report (pre-aggregation the
1465
1473
  // contract diff early-returned, so this loop could assume presence).
1466
1474
  const present = new Set(Object.keys(packageScripts(cwd)).map(s => s.toLowerCase()));
1475
+ const scripts = packageScripts(cwd);
1476
+ // CONFIG-GAP INPUTS (mx5 run 20), read once: the tracked file list and the
1477
+ // union of every tracked env template's declared variables. Both empty on a
1478
+ // non-git tree or a tree with no template, which makes the whole check inert
1479
+ // — a project with no template gains no excuse. See launch-config-gap.ts.
1480
+ const closure = (() => {
1481
+ try {
1482
+ return scanEnvTemplateClosure(cwd);
1483
+ }
1484
+ catch {
1485
+ return inertClosure();
1486
+ }
1487
+ })();
1488
+ const trackedForGap = closure.templates.length > 0 ? (trackedFiles(cwd) ?? []) : [];
1489
+ const launchTimeout = Math.min(timeoutMs, 180_000);
1467
1490
  for (const name of runnableDeclaredScripts(declared, covered)) {
1468
1491
  if (!present.has(name.toLowerCase()))
1469
1492
  continue;
@@ -1471,7 +1494,7 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
1471
1494
  const label = `${cmd[0]} ${cmd[1].join(' ')}`;
1472
1495
  dynAttempted += 1;
1473
1496
  dynBins.add(cmd[0]);
1474
- const r = runGateCommand(cwd, cmd, Math.min(timeoutMs, 180_000), INFRA_GAP_OUTPUT_RE);
1497
+ const r = runGateCommand(cwd, cmd, launchTimeout, INFRA_GAP_OUTPUT_RE);
1475
1498
  if (r.outcome === 'skip') {
1476
1499
  if (r.spawnFailed)
1477
1500
  dynSpawnFailures += 1;
@@ -1480,6 +1503,37 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
1480
1503
  }
1481
1504
  dynObserved += 1;
1482
1505
  if (r.outcome === 'fail') {
1506
+ // A CONFIG GAP IS NOT A CODE FAULT (mx5 run 20). The run died on
1507
+ // `bun run seed` exiting 1 because ADMIN_PHONE — which the project's
1508
+ // own `.env.example` DECLARES — is absent from this box, and the only
1509
+ // way to supply it is a gitignored `.env` the commit cannot contain.
1510
+ // Four static conditions (findLaunchConfigGap) plus one dynamic one:
1511
+ // re-run with the variables supplied as synthetic placeholders, and
1512
+ // reclassify ONLY if that exits 0. A script that fails for its own
1513
+ // reasons fails again with the values present and stays a FAIL — an
1514
+ // absent variable is not a licence to ignore an exit code the code
1515
+ // caused. Nothing is parsed from the child's stderr: the wording is
1516
+ // the project's, not the harness's.
1517
+ const gap = findLaunchConfigGap({
1518
+ cwd,
1519
+ script: name,
1520
+ body: scripts[name] ?? null,
1521
+ tracked: trackedForGap,
1522
+ declared: closure.declared,
1523
+ env: process.env
1524
+ });
1525
+ if (gap) {
1526
+ const probe = runGateCommand(cwd, cmd, launchTimeout, INFRA_GAP_OUTPUT_RE, probeEnv(runnerEnv(resolveRunner(cmd[0])), gap));
1527
+ if (probe.outcome === 'pass') {
1528
+ // Nothing about this script was OBSERVED: the real run could
1529
+ // not reach it and the probe run is a diagnostic, never an
1530
+ // observation. So it un-counts, exactly like a skip.
1531
+ dynObserved -= 1;
1532
+ skippedLaunch.push(name);
1533
+ configGapNotes.push(configGapUnobservedNote(gap));
1534
+ continue;
1535
+ }
1536
+ }
1483
1537
  fail(`launch script: \`${label}\` exited ${r.status}${r.tail ? ` — ${r.tail}` : ''}`);
1484
1538
  continue;
1485
1539
  }
@@ -1649,7 +1703,8 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
1649
1703
  // because it names a concrete command and the trail line is sliced at 300 chars.
1650
1704
  const unobserved = [
1651
1705
  bootUnobserved,
1652
- unobservedVerdict({ discovered: dynAttempted, observed: dynObserved })
1706
+ unobservedVerdict({ discovered: dynAttempted, observed: dynObserved }),
1707
+ ...configGapNotes
1653
1708
  ]
1654
1709
  .filter(n => n !== null)
1655
1710
  .join(' ');
@@ -43,6 +43,7 @@ import * as fsp from 'node:fs/promises';
43
43
  import * as os from 'node:os';
44
44
  import * as path from 'node:path';
45
45
  import { runChildDefault } from '../shared/child-process.js';
46
+ import { isRegenerableArtifact } from './regenerable-artifacts.js';
46
47
  /** Keep the gate machinery's own artifacts out of the snapshot and the restore. */
47
48
  const EXCLUDE_TASKS_DIR = ':(exclude).pi-tasks';
48
49
  /**
@@ -53,16 +54,12 @@ const EXCLUDE_TASKS_DIR = ':(exclude).pi-tasks';
53
54
  * `test-results/` and `playwright-report/` above all, the exact churn that discarded
54
55
  * verify verdicts across mx5 run 9. Kept deliberately narrow: anything not matched
55
56
  * here that a child modifies/deletes is treated as graded state (verdict-tainting).
57
+ *
58
+ * The list itself now lives in `regenerable-artifacts.ts` — the deletion guard and
59
+ * the per-task commit need the same knowledge, and three private copies of it is
60
+ * how mx5 run 20 spent two thirds of its repair budget on three screenshots.
56
61
  */
57
- const ARTIFACT_PATTERNS = [
58
- /^(?:test-results|playwright-report|coverage|\.nyc_output|dist|build|\.next|\.turbo|\.svelte-kit)\//,
59
- /(?:^|\/)\.last-run\.json$/,
60
- /\.tsbuildinfo$/
61
- ];
62
- function isBenignArtifact(relPath) {
63
- const p = relPath.replace(/\\/g, '/');
64
- return ARTIFACT_PATTERNS.some(re => re.test(p));
65
- }
62
+ const isBenignArtifact = isRegenerableArtifact;
66
63
  /**
67
64
  * Regenerable machine state that is benign EVEN WHEN TRACKED — a project that
68
65
  * mistakenly commits it (mx5 run 10 does exactly this) must not have a gate child's
@@ -0,0 +1,53 @@
1
+ import { type EnvRead } from './env-template-closure.js';
2
+ /** What a launch script needs that this box does not have. */
3
+ export interface LaunchConfigGap {
4
+ /** Declared script name, e.g. `seed`. */
5
+ script: string;
6
+ /** The tracked source file the script body runs. */
7
+ file: string;
8
+ /** Variables it REQUIRES that the template declares and the env lacks. */
9
+ variables: string[];
10
+ /** First read site per variable, for the debt line. */
11
+ reads: EnvRead[];
12
+ }
13
+ /**
14
+ * The value the probe run supplies. Obviously synthetic and obviously ours, so a
15
+ * seeded row or a log line carrying it is recognisable as harness residue rather
16
+ * than mistaken for real configuration.
17
+ */
18
+ export declare const CONFIG_GAP_PROBE_VALUE = "pi-task-config-gap-probe";
19
+ /**
20
+ * The tracked source files a script body runs. Purely lexical and deliberately
21
+ * so: every whitespace/operator-separated token, normalised, that the tracked set
22
+ * contains. A token that is not a tracked file (a flag, the runner, a bare word)
23
+ * cannot match, and a body that names no tracked file yields nothing — which
24
+ * fails condition 1 and ends the check.
25
+ */
26
+ export declare function bodySourceFiles(body: string, tracked: ReadonlySet<string>): string[];
27
+ /**
28
+ * The static half: conditions 1–4. Returns null when ANY of them fails, which is
29
+ * the common case and means "behave exactly as before".
30
+ *
31
+ * `declared` is the union of every tracked template's variables. When it is empty
32
+ * the tree has no template, condition 3 can never hold, and this always returns
33
+ * null — a project with no template gains no excuse.
34
+ */
35
+ export declare function findLaunchConfigGap(args: {
36
+ cwd: string;
37
+ script: string;
38
+ /** Resolved script body (`packageScripts(cwd)[name]`), or null. */
39
+ body: string | null;
40
+ /** Tracked files, repo-relative (`trackedFiles(cwd)`). */
41
+ tracked: readonly string[];
42
+ /** Variables the tracked env template(s) declare. */
43
+ declared: ReadonlySet<string>;
44
+ /** The env the gate spawned (or would spawn) the child with. */
45
+ env: Record<string, string | undefined>;
46
+ }): LaunchConfigGap | null;
47
+ /** The env for the probe re-run: the gate's own env plus a synthetic value per
48
+ * gap variable. Nothing else changes, so a second non-zero exit is the script's
49
+ * own fault and not the harness's. */
50
+ export declare function probeEnv(base: Record<string, string | undefined>, gap: LaunchConfigGap): Record<string, string | undefined>;
51
+ /** The UNOBSERVED warning: names the script and every variable, so a human can
52
+ * supply them and re-run by hand. */
53
+ export declare function configGapUnobservedNote(gap: LaunchConfigGap): string;
@@ -0,0 +1,159 @@
1
+ /**
2
+ * launch-config-gap — a launch script that cannot run because a variable the
3
+ * project's own tracked template DECLARES is absent from this box is an
4
+ * ENVIRONMENT GAP, not a code fault (mx5 run 20).
5
+ *
6
+ * THE EPISODE. Attempt 3 of the final-gate autofix passed every guard, fixed the
7
+ * failing test, and still lost the run:
8
+ *
9
+ * autofix attempt 3 failed — did not converge: launch script: `bun run seed`
10
+ * exited 1 — $ bun run src/server/seed.ts Missing required environment
11
+ * variable: ADMIN_PHONE error: script "seed" exited with code 1
12
+ *
13
+ * Single-failure phrasing (final-gate.ts uses a numbered list for ≥2), so the
14
+ * suite was green and this was the only remaining failure. The run failed on
15
+ * exactly this, and it is self-inflicted at the last moment: pre-gate
16
+ * `package.json` had no `seed` script at all, so `if (!present.has(name)) continue`
17
+ * skipped it. Attempt 3 added the script to clear the launch-contract diff — and
18
+ * thereby armed the check that killed the run.
19
+ *
20
+ * The loop was closed by construction. The gate runs every declared non-boot
21
+ * script with `runnerEnv(runner)` = `process.env` plus a PATH prefix and nothing
22
+ * else; "Missing required environment variable" matches neither ENV_GAP_OUTPUT_RE
23
+ * nor INFRA_GAP_OUTPUT_RE, so it is a hard FAIL; and the only way to supply the
24
+ * value is a gitignored `.env`, whose writes nexttask 4 correctly refuses to
25
+ * credit. The static half of this already shipped and WORKED — `.env.example`
26
+ * declares ADMIN_PHONE/ADMIN_PASSWORD, so `findMissingEnvDeclarations` was
27
+ * correctly silent. This is the execution half.
28
+ *
29
+ * FOUR STATIC CONDITIONS, ALL REQUIRED. Deliberately over-constrained: the
30
+ * failure mode of getting this wrong is a gate that excuses real breakage.
31
+ *
32
+ * 1. the script's resolved body names a TRACKED SOURCE FILE
33
+ * (`bun run src/server/seed.ts` → `src/server/seed.ts`);
34
+ * 2. that file REQUIRES an env var `X` under `scanSource` — i.e. none of its
35
+ * step-asides (default / compared / assigned / ambient / …) applies;
36
+ * 3. `X` is DECLARED in the tracked template. If it is NOT,
37
+ * `findMissingEnvDeclarations` has already failed the gate statically and
38
+ * this path must not fire — otherwise the two checks would cancel out and a
39
+ * project with no template at all would gain a blanket excuse;
40
+ * 4. `X` is ABSENT from the env the gate spawned the child with.
41
+ *
42
+ * NOTHING IS PARSED FROM THE CHILD'S STDERR. `Missing required environment
43
+ * variable: ADMIN_PHONE` is a string the PROJECT authored; matching on it would
44
+ * be a rule about one project's phrasing, and every other project would phrase it
45
+ * differently or not at all.
46
+ *
47
+ * THE FIFTH CONDITION IS DYNAMIC, AND IT IS WHAT MAKES THE RULE HONEST. The four
48
+ * above cannot tell "exited BECAUSE the variable is absent" from "exited for its
49
+ * own reasons, and also happens to read an absent variable". A script that throws
50
+ * a TypeError on line 1 and also reads ADMIN_PHONE on line 3 satisfies all four.
51
+ * So the script is re-run once with the gap variables supplied as OBVIOUSLY
52
+ * SYNTHETIC placeholders, and the exit code decides:
53
+ *
54
+ * still non-zero → the absence did not cause it → FAIL, as today
55
+ * now zero → the absence did cause it → skip + UNOBSERVED + debt
56
+ *
57
+ * The probe run is a DIAGNOSTIC, never an observation. Its success is not
58
+ * reported, and the verdict it produces is UNOBSERVED with debt — never a PASS
59
+ * (memory/unobserved-gate-verdict-shipped.md). The placeholder is a fixed
60
+ * harness-authored string; `.env.example`'s own values are never injected,
61
+ * because they are placeholders too (`change-me`) and a green seed run against
62
+ * them would be a fabricated observation.
63
+ */
64
+ import { readFileSync } from 'node:fs';
65
+ import * as path from 'node:path';
66
+ import { scanSource } from './env-template-closure.js';
67
+ /**
68
+ * The value the probe run supplies. Obviously synthetic and obviously ours, so a
69
+ * seeded row or a log line carrying it is recognisable as harness residue rather
70
+ * than mistaken for real configuration.
71
+ */
72
+ export const CONFIG_GAP_PROBE_VALUE = 'pi-task-config-gap-probe';
73
+ /** Shell operators that separate commands inside a script body. */
74
+ const SEPARATORS = /[\s;|&()<>]+/;
75
+ /**
76
+ * The tracked source files a script body runs. Purely lexical and deliberately
77
+ * so: every whitespace/operator-separated token, normalised, that the tracked set
78
+ * contains. A token that is not a tracked file (a flag, the runner, a bare word)
79
+ * cannot match, and a body that names no tracked file yields nothing — which
80
+ * fails condition 1 and ends the check.
81
+ */
82
+ export function bodySourceFiles(body, tracked) {
83
+ const out = [];
84
+ for (const raw of body.split(SEPARATORS)) {
85
+ const tok = raw
86
+ .trim()
87
+ .replace(/^["']|["']$/g, '')
88
+ .replace(/^\.\//, '');
89
+ if (tok.length === 0)
90
+ continue;
91
+ if (tracked.has(tok) && !out.includes(tok))
92
+ out.push(tok);
93
+ }
94
+ return out;
95
+ }
96
+ /**
97
+ * The static half: conditions 1–4. Returns null when ANY of them fails, which is
98
+ * the common case and means "behave exactly as before".
99
+ *
100
+ * `declared` is the union of every tracked template's variables. When it is empty
101
+ * the tree has no template, condition 3 can never hold, and this always returns
102
+ * null — a project with no template gains no excuse.
103
+ */
104
+ export function findLaunchConfigGap(args) {
105
+ if (!args.body || args.declared.size === 0)
106
+ return null;
107
+ const trackedSet = new Set(args.tracked);
108
+ const files = bodySourceFiles(args.body, trackedSet);
109
+ if (files.length === 0)
110
+ return null; // condition 1
111
+ const variables = [];
112
+ const reads = [];
113
+ let file = null;
114
+ for (const f of files) {
115
+ let text;
116
+ try {
117
+ text = readFileSync(path.join(args.cwd, f), 'utf8');
118
+ }
119
+ catch {
120
+ continue;
121
+ }
122
+ for (const r of scanSource(f, text)) {
123
+ if (r.stepAside !== null)
124
+ continue; // condition 2: REQUIRED only
125
+ if (!args.declared.has(r.name))
126
+ continue; // condition 3: template declares it
127
+ if (args.env[r.name] !== undefined)
128
+ continue; // condition 4: absent here
129
+ if (variables.includes(r.name))
130
+ continue;
131
+ variables.push(r.name);
132
+ reads.push(r);
133
+ file ??= f;
134
+ }
135
+ }
136
+ if (variables.length === 0 || file === null)
137
+ return null;
138
+ return { script: args.script, file, variables, reads };
139
+ }
140
+ /** The env for the probe re-run: the gate's own env plus a synthetic value per
141
+ * gap variable. Nothing else changes, so a second non-zero exit is the script's
142
+ * own fault and not the harness's. */
143
+ export function probeEnv(base, gap) {
144
+ const out = { ...base };
145
+ for (const v of gap.variables)
146
+ out[v] = CONFIG_GAP_PROBE_VALUE;
147
+ return out;
148
+ }
149
+ /** The UNOBSERVED warning: names the script and every variable, so a human can
150
+ * supply them and re-run by hand. */
151
+ export function configGapUnobservedNote(gap) {
152
+ const site = gap.reads[0];
153
+ return (`launch script \`${gap.script}\` could not run here — ${gap.file}`
154
+ + `${site ? `:${site.line}` : ''} requires ${gap.variables.map(v => `\`${v}\``).join(', ')}, `
155
+ + `which the tracked env template DECLARES but this environment does not supply. `
156
+ + `Re-running it with the value(s) present exits 0, so the failure is a CONFIG GAP, not a `
157
+ + `code fault — UNOBSERVED: the launch surface was not exercised here. Supply `
158
+ + `${gap.variables.join(', ')} and run \`bun run ${gap.script}\` to observe it.`);
159
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * The one place that knows which repo paths are regenerable machine OUTPUT.
3
+ *
4
+ * This module exists because the knowledge was in exactly one module and two
5
+ * others needed it (mx5 run 20). `git-state-guard.ts` knew `test-results/` was
6
+ * regenerable and used it for VERDICT decisions only; `write-guard.ts`'s deletion
7
+ * guard did not, so it rejected two whole fix attempts over three Playwright
8
+ * failure screenshots — taking each attempt's real `src/client/api.test.tsx`
9
+ * repair with them — and `auto-commit.ts` did not, so its blind `git add -A` is
10
+ * how the screenshots became tracked in the first place. Three modules, one fact,
11
+ * shared with neither of the two that needed it. Import from here; do not copy.
12
+ *
13
+ * TWO LISTS, and the split between them is the whole point.
14
+ *
15
+ * `REGENERABLE_ARTIFACT_PATTERNS` is the VERDICT list: a gate child that rewrote
16
+ * one of these did not mutate the work under judgement, so its verdict stands.
17
+ * Being wrong here costs a discarded verify.
18
+ *
19
+ * `DELETION_EXEMPT_ARTIFACT_PATTERNS` is the strict SUBSET a fix pass may DELETE
20
+ * without its whole attempt being rejected. Being wrong here destroys a
21
+ * deliverable, so it is narrower, and `dist/`, `build/`, `.next/`, `.turbo/` and
22
+ * `.svelte-kit/` are deliberately NOT in it.
23
+ *
24
+ * MEASURED over 269 git work trees under ~/hub, ~/tmp, ~/.cache
25
+ * (`scripts/tracked-artifact-baserate.ts`, 2026-08-07):
26
+ *
27
+ * trees tracking any artifact path 33 (33 of 33 have .pi-tasks)
28
+ * test-results/ 33 trees / 33 files
29
+ * .last-run.json 33 trees / 33 files ← the SAME 33 files
30
+ * dist/, build/, .next/, .turbo/, .svelte-kit/, playwright-report/,
31
+ * coverage/, .nyc_output/, *.tsbuildinfo 0 trees
32
+ *
33
+ * So `test-results/` and `.last-run.json` are exempt on MEASUREMENT — they are the
34
+ * only artifact paths this corpus tracks at all, and the one real episode is
35
+ * exactly a tracked `test-results/` deletion. `playwright-report/`, `coverage/`,
36
+ * `.nyc_output/` and `*.tsbuildinfo` are exempt BY CONSTRUCTION with zero corpus
37
+ * evidence either way: none of the four has a hand-authored form, so there is no
38
+ * version of them that is a deliverable. `dist/` and its family have exactly the
39
+ * hand-authored form the other six lack — a published package's `dist/` IS the
40
+ * shipped artifact — and with 0 tracked instances here the exemption would never
41
+ * fire anyway, so it would buy nothing while carrying that risk.
42
+ *
43
+ * 32 of the 33 trees are one A/B harness's delivery trees built from a single mx5
44
+ * DESIGN/PROJECT.md: 32 independent RUNS of one project shape, which is evidence
45
+ * of reproducibility and not of breadth. The 33rd is mx5 itself.
46
+ */
47
+ /** Directory prefixes and file names that are regenerable test/build output. */
48
+ export declare const REGENERABLE_ARTIFACT_PATTERNS: readonly RegExp[];
49
+ /**
50
+ * The strict subset a fix pass may delete, and the pipeline must not newly track.
51
+ *
52
+ * Every entry here is output a test runner rewrites on its next run. Absent from
53
+ * this list, and staying absent: `dist/`, `build/`, `.next/`, `.turbo/`,
54
+ * `.svelte-kit/` — see the header.
55
+ */
56
+ export declare const DELETION_EXEMPT_ARTIFACT_PATTERNS: readonly RegExp[];
57
+ /** Human-readable form of the exempt list, for trail lines and pathspecs. */
58
+ export declare const DELETION_EXEMPT_ARTIFACT_GLOBS: readonly string[];
59
+ /** Is this path regenerable machine output, for VERDICT purposes? */
60
+ export declare function isRegenerableArtifact(relPath: string): boolean;
61
+ /**
62
+ * May a fix pass DELETE this path without its whole attempt being rejected, and
63
+ * may the pipeline leave it untracked? Narrower than `isRegenerableArtifact` on
64
+ * purpose: build output is excluded because a committed one can be the product.
65
+ */
66
+ export declare function isDeletionExemptArtifact(relPath: string): boolean;
@@ -0,0 +1,88 @@
1
+ /**
2
+ * The one place that knows which repo paths are regenerable machine OUTPUT.
3
+ *
4
+ * This module exists because the knowledge was in exactly one module and two
5
+ * others needed it (mx5 run 20). `git-state-guard.ts` knew `test-results/` was
6
+ * regenerable and used it for VERDICT decisions only; `write-guard.ts`'s deletion
7
+ * guard did not, so it rejected two whole fix attempts over three Playwright
8
+ * failure screenshots — taking each attempt's real `src/client/api.test.tsx`
9
+ * repair with them — and `auto-commit.ts` did not, so its blind `git add -A` is
10
+ * how the screenshots became tracked in the first place. Three modules, one fact,
11
+ * shared with neither of the two that needed it. Import from here; do not copy.
12
+ *
13
+ * TWO LISTS, and the split between them is the whole point.
14
+ *
15
+ * `REGENERABLE_ARTIFACT_PATTERNS` is the VERDICT list: a gate child that rewrote
16
+ * one of these did not mutate the work under judgement, so its verdict stands.
17
+ * Being wrong here costs a discarded verify.
18
+ *
19
+ * `DELETION_EXEMPT_ARTIFACT_PATTERNS` is the strict SUBSET a fix pass may DELETE
20
+ * without its whole attempt being rejected. Being wrong here destroys a
21
+ * deliverable, so it is narrower, and `dist/`, `build/`, `.next/`, `.turbo/` and
22
+ * `.svelte-kit/` are deliberately NOT in it.
23
+ *
24
+ * MEASURED over 269 git work trees under ~/hub, ~/tmp, ~/.cache
25
+ * (`scripts/tracked-artifact-baserate.ts`, 2026-08-07):
26
+ *
27
+ * trees tracking any artifact path 33 (33 of 33 have .pi-tasks)
28
+ * test-results/ 33 trees / 33 files
29
+ * .last-run.json 33 trees / 33 files ← the SAME 33 files
30
+ * dist/, build/, .next/, .turbo/, .svelte-kit/, playwright-report/,
31
+ * coverage/, .nyc_output/, *.tsbuildinfo 0 trees
32
+ *
33
+ * So `test-results/` and `.last-run.json` are exempt on MEASUREMENT — they are the
34
+ * only artifact paths this corpus tracks at all, and the one real episode is
35
+ * exactly a tracked `test-results/` deletion. `playwright-report/`, `coverage/`,
36
+ * `.nyc_output/` and `*.tsbuildinfo` are exempt BY CONSTRUCTION with zero corpus
37
+ * evidence either way: none of the four has a hand-authored form, so there is no
38
+ * version of them that is a deliverable. `dist/` and its family have exactly the
39
+ * hand-authored form the other six lack — a published package's `dist/` IS the
40
+ * shipped artifact — and with 0 tracked instances here the exemption would never
41
+ * fire anyway, so it would buy nothing while carrying that risk.
42
+ *
43
+ * 32 of the 33 trees are one A/B harness's delivery trees built from a single mx5
44
+ * DESIGN/PROJECT.md: 32 independent RUNS of one project shape, which is evidence
45
+ * of reproducibility and not of breadth. The 33rd is mx5 itself.
46
+ */
47
+ /** Directory prefixes and file names that are regenerable test/build output. */
48
+ export const REGENERABLE_ARTIFACT_PATTERNS = [
49
+ /^(?:test-results|playwright-report|coverage|\.nyc_output|dist|build|\.next|\.turbo|\.svelte-kit)\//,
50
+ /(?:^|\/)\.last-run\.json$/,
51
+ /\.tsbuildinfo$/
52
+ ];
53
+ /**
54
+ * The strict subset a fix pass may delete, and the pipeline must not newly track.
55
+ *
56
+ * Every entry here is output a test runner rewrites on its next run. Absent from
57
+ * this list, and staying absent: `dist/`, `build/`, `.next/`, `.turbo/`,
58
+ * `.svelte-kit/` — see the header.
59
+ */
60
+ export const DELETION_EXEMPT_ARTIFACT_PATTERNS = [
61
+ /^(?:test-results|playwright-report|coverage|\.nyc_output)\//,
62
+ /(?:^|\/)\.last-run\.json$/,
63
+ /\.tsbuildinfo$/
64
+ ];
65
+ /** Human-readable form of the exempt list, for trail lines and pathspecs. */
66
+ export const DELETION_EXEMPT_ARTIFACT_GLOBS = [
67
+ 'test-results/',
68
+ 'playwright-report/',
69
+ 'coverage/',
70
+ '.nyc_output/',
71
+ '.last-run.json',
72
+ '*.tsbuildinfo'
73
+ ];
74
+ const normalize = (relPath) => relPath.replace(/\\/g, '/');
75
+ /** Is this path regenerable machine output, for VERDICT purposes? */
76
+ export function isRegenerableArtifact(relPath) {
77
+ const p = normalize(relPath);
78
+ return REGENERABLE_ARTIFACT_PATTERNS.some(re => re.test(p));
79
+ }
80
+ /**
81
+ * May a fix pass DELETE this path without its whole attempt being rejected, and
82
+ * may the pipeline leave it untracked? Narrower than `isRegenerableArtifact` on
83
+ * purpose: build output is excluded because a committed one can be the product.
84
+ */
85
+ export function isDeletionExemptArtifact(relPath) {
86
+ const p = normalize(relPath);
87
+ return DELETION_EXEMPT_ARTIFACT_PATTERNS.some(re => re.test(p));
88
+ }
@@ -391,6 +391,17 @@ export async function runGatesForTask(ctxIn, deps, p) {
391
391
  const commit = await deps.commit(p.cwd, `task: ${p.title} (${p.taskId})`);
392
392
  if (commit.committed) {
393
393
  await rec(`commit: task snapshot committed${commit.note ? ` (${commit.note})` : ''}`);
394
+ // SAY WHAT WAS LEFT OUT. The stage skips untracked regenerable test-runner
395
+ // output (mx5 run 20: TASK_0027's `git add -A` swept in three Playwright
396
+ // failure screenshots and two later fix attempts were rejected for deleting
397
+ // them). A SILENT exclusion is the same failure class as the silent
398
+ // ignored-path write nexttask 4 closed, so it gets its own trail line.
399
+ if (commit.excluded && commit.excluded.length > 0) {
400
+ await rec(`commit: left ${commit.excluded.length} untracked test-runner artifact(s) out of the `
401
+ + `snapshot — regenerable output, not deliverables: `
402
+ + `${commit.excluded.slice(0, 8).join(', ')}`
403
+ + `${commit.excluded.length > 8 ? `, +${commit.excluded.length - 8} more` : ''}`);
404
+ }
394
405
  active.ui.notify(`${p.tag}: committed "${p.title}".`, 'info');
395
406
  }
396
407
  else {
@@ -1,24 +1,3 @@
1
- /**
2
- * write-guard — deterministic tree-change accounting for WRITE-CAPABLE gate
3
- * children (mx5 run 11).
4
- *
5
- * The failure class: the final-gate autofix child (read,edit,bash) was added after
6
- * the run-8 guard generation and inherited NONE of the guards the other
7
- * write-capable passes carry — no diff capture, no frozen-path deny, no probe
8
- * scans, free `rm`. Run 11 it deleted `src/client/pages/admin.tsx` (TASK_0008's
9
- * verified deliverable) to satisfy a recorded debt claim, and the deletion was
10
- * invisible: nothing even logged what the pass changed.
11
- *
12
- * This module is the pure half of the guard stack: parse `git status --porcelain`
13
- * into a change summary (the diff-capture log line every write-capable child now
14
- * gets at the gate-deps seam), and classify tracked-file DELETIONS. A fix pass
15
- * exists to repair the assembled repository, not to shrink it: every tracked file
16
- * is a committed task's deliverable, so deleting one is rejected outright — with
17
- * one allowance, a RELOCATION (the same file name reappears as an added file
18
- * elsewhere, e.g. moving a test the runner was never meant to pick up out of its
19
- * glob — the legitimate fix shape from run 7). Pure text/path analysis; no git
20
- * execution, no stack assumptions.
21
- */
22
1
  /** What a write-capable pass changed, from `git status --porcelain`. */
23
2
  export interface TreeChangeSummary {
24
3
  /** Tracked files modified in place (includes rename targets). */
@@ -52,6 +31,21 @@ export declare function parseNameStatusChanges(nameStatus: string): TreeChangeSu
52
31
  * name, somewhere in the tree). Anything returned here rejects the whole fix
53
32
  * attempt — run 11's `rm src/client/pages/admin.tsx` had no corresponding add and
54
33
  * destroyed a sibling task's verified deliverable.
34
+ *
35
+ * REGENERABLE TEST-RUNNER OUTPUT IS EXEMPT (mx5 run 20). The guard is pure git: no
36
+ * ecosystem, no exemption list, so three Playwright FAILURE screenshots that
37
+ * TASK_0027's own `git add -A` had swept into a commit read as deliverables. Two
38
+ * consecutive attempts were discarded whole over them — each taking a real
39
+ * `src/client/api.test.tsx` repair with it, which attempt 3 then re-did and kept.
40
+ * 6m14s of an 8m16s gate. And the pipeline committed those same three deletions at
41
+ * the end anyway, in the stranded-fix commit (mx5 5d4147e), so the guard did not
42
+ * even preserve what it rejected two attempts to protect.
43
+ *
44
+ * The exempt list is a strict SUBSET of the verdict-level artifact list and lives
45
+ * in `regenerable-artifacts.ts`. `dist/`, `build/`, `.next/`, `.turbo/` and
46
+ * `.svelte-kit/` are NOT in it: a committed build output can be the shipped
47
+ * artifact, so deleting one is destructive in a way deleting a failure screenshot
48
+ * is not.
55
49
  */
56
50
  export declare function findForbiddenDeletions(changes: TreeChangeSummary): string[];
57
51
  /** Why an ignored path is (or is not) something the gate may rule on. */
@@ -19,6 +19,7 @@
19
19
  * glob — the legitimate fix shape from run 7). Pure text/path analysis; no git
20
20
  * execution, no stack assumptions.
21
21
  */
22
+ import { isDeletionExemptArtifact } from './regenerable-artifacts.js';
22
23
  /** Porcelain v1 line: `XY <path>` or `XY <orig> -> <new>` (rename/copy). */
23
24
  function splitEntry(raw) {
24
25
  if (raw.length < 4)
@@ -125,12 +126,27 @@ const basename = (p) => {
125
126
  * name, somewhere in the tree). Anything returned here rejects the whole fix
126
127
  * attempt — run 11's `rm src/client/pages/admin.tsx` had no corresponding add and
127
128
  * destroyed a sibling task's verified deliverable.
129
+ *
130
+ * REGENERABLE TEST-RUNNER OUTPUT IS EXEMPT (mx5 run 20). The guard is pure git: no
131
+ * ecosystem, no exemption list, so three Playwright FAILURE screenshots that
132
+ * TASK_0027's own `git add -A` had swept into a commit read as deliverables. Two
133
+ * consecutive attempts were discarded whole over them — each taking a real
134
+ * `src/client/api.test.tsx` repair with it, which attempt 3 then re-did and kept.
135
+ * 6m14s of an 8m16s gate. And the pipeline committed those same three deletions at
136
+ * the end anyway, in the stranded-fix commit (mx5 5d4147e), so the guard did not
137
+ * even preserve what it rejected two attempts to protect.
138
+ *
139
+ * The exempt list is a strict SUBSET of the verdict-level artifact list and lives
140
+ * in `regenerable-artifacts.ts`. `dist/`, `build/`, `.next/`, `.turbo/` and
141
+ * `.svelte-kit/` are NOT in it: a committed build output can be the shipped
142
+ * artifact, so deleting one is destructive in a way deleting a failure screenshot
143
+ * is not.
128
144
  */
129
145
  export function findForbiddenDeletions(changes) {
130
146
  if (changes.deleted.length === 0)
131
147
  return [];
132
148
  const addedNames = new Set(changes.added.map(basename));
133
- return changes.deleted.filter(p => !addedNames.has(basename(p)));
149
+ return changes.deleted.filter(p => !addedNames.has(basename(p)) && !isDeletionExemptArtifact(p));
134
150
  }
135
151
  /**
136
152
  * Directory names that are build output or a dependency tree by convention. The
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.37.3",
3
+ "version": "0.37.4",
4
4
  "description": "Deterministic task planning and spec-orchestration for local models — crash-safe /task pipelines with verify/enforce gates, a real-time remote web view, and web/docs/fetch/worker subagent tools.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",