@mjasnikovs/pi-task 0.37.2 → 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.
- package/dist/task/auto-commit.d.ts +30 -0
- package/dist/task/auto-commit.js +49 -4
- package/dist/task/auto-orchestrator.js +24 -6
- package/dist/task/final-gate.js +60 -5
- package/dist/task/git-state-guard.js +6 -9
- package/dist/task/launch-config-gap.d.ts +53 -0
- package/dist/task/launch-config-gap.js +159 -0
- package/dist/task/regenerable-artifacts.d.ts +66 -0
- package/dist/task/regenerable-artifacts.js +88 -0
- package/dist/task/task-gates.js +11 -0
- package/dist/task/write-guard.d.ts +15 -21
- package/dist/task/write-guard.js +17 -1
- package/dist/workers/fetch-core.d.ts +20 -0
- package/dist/workers/fetch-core.js +65 -4
- package/dist/workers/pi-worker-fetch.js +5 -1
- package/package.json +1 -1
|
@@ -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
|
package/dist/task/auto-commit.js
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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
|
|
1380
|
-
|
|
1381
|
-
|
|
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
|
|
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) {
|
package/dist/task/final-gate.js
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
+
}
|
package/dist/task/task-gates.js
CHANGED
|
@@ -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. */
|
package/dist/task/write-guard.js
CHANGED
|
@@ -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
|
|
@@ -4,6 +4,7 @@ import { type ExcerptVerification } from '../shared/child-output.js';
|
|
|
4
4
|
/** The exact non-answers the child is instructed to emit, matched at the tool layer. */
|
|
5
5
|
export declare const UNCLEAR_ANSWER = "unclear from this page";
|
|
6
6
|
export declare const NOT_COVERED_ANSWER = "not covered by this page";
|
|
7
|
+
export declare function normaliseSourceUrl(url: string): string;
|
|
7
8
|
export interface FetchRawInput {
|
|
8
9
|
url: string;
|
|
9
10
|
signal?: AbortSignal;
|
|
@@ -51,6 +52,15 @@ export interface FetchFocusedResult {
|
|
|
51
52
|
* "page has no answer" and "answer is ambiguous" as the same thing (PROMPT-3 item 3).
|
|
52
53
|
*/
|
|
53
54
|
coverageMiss: boolean;
|
|
55
|
+
/**
|
|
56
|
+
* What to do INSTEAD, present only on a coverage miss. `coverageMiss` was computed and
|
|
57
|
+
* stored and read nowhere, so the distinct channel it exists to provide never reached the
|
|
58
|
+
* worker: all it ever saw was the bare sentence "not covered by this page", which reads
|
|
59
|
+
* as "ask again, differently". It did — 9 of the 84 corpus fetches re-read a URL that had
|
|
60
|
+
* already returned a non-answer, one release-notes page three times with near-identical
|
|
61
|
+
* questions.
|
|
62
|
+
*/
|
|
63
|
+
nextStep?: string;
|
|
54
64
|
/** The #fragment slug that was anchored, when the URL carried one and it was located. */
|
|
55
65
|
anchoredSection?: string;
|
|
56
66
|
/** Retained evidence for a false `excerptVerified`, so it is diagnosable without re-fetch. */
|
|
@@ -63,6 +73,16 @@ export interface FetchFocusedResult {
|
|
|
63
73
|
stdout: string;
|
|
64
74
|
}
|
|
65
75
|
export declare function fetchFocused(input: FetchFocusedInput): Promise<FetchFocusedResult>;
|
|
76
|
+
/**
|
|
77
|
+
* The instruction that goes with a coverage miss. It says two things the bare sentinel does
|
|
78
|
+
* not: WHICH page was actually read (after the blob rewrite these differ, and a worker that
|
|
79
|
+
* cannot see that would "retry" the raw URL it already got), and that re-reading this page
|
|
80
|
+
* with a reworded question returns the same answer — so the next move is a different URL.
|
|
81
|
+
*
|
|
82
|
+
* Deliberately not a suggestion of WHICH other URL. Nothing at this layer knows one, and
|
|
83
|
+
* naming a guess is how a dead end becomes two dead ends.
|
|
84
|
+
*/
|
|
85
|
+
export declare function coverageMissNextStep(requestedUrl: string, fetchedUrl: string): string;
|
|
66
86
|
export interface SelectedContent {
|
|
67
87
|
content: string;
|
|
68
88
|
/** The #fragment slug that was anchored, if the URL carried one AND it was located. */
|
|
@@ -11,18 +11,58 @@ const TRUNCATION_MARKER = '\n\n[...page continues, truncated...]\n\n';
|
|
|
11
11
|
/** The exact non-answers the child is instructed to emit, matched at the tool layer. */
|
|
12
12
|
export const UNCLEAR_ANSWER = 'unclear from this page';
|
|
13
13
|
export const NOT_COVERED_ANSWER = 'not covered by this page';
|
|
14
|
-
|
|
14
|
+
/**
|
|
15
|
+
* Rule 6 asks the child to write the sentinel and NOTHING else, so the sentinel is the whole
|
|
16
|
+
* answer — anchored, not a substring search. A substring search misreads the opposite case:
|
|
17
|
+
* rule 5 tells the child to answer partially and say what is missing, and it says it in the
|
|
18
|
+
* prompt's own words ("… `obs_add_raw_audio_callback` and `obs_remove_raw_audio_callback`
|
|
19
|
+
* are not covered by this page"). That is a sourced answer, and the loose match filed it as
|
|
20
|
+
* a coverage miss. Observed twice in 5 reps of scripts/fetch-url-normalise-ab.ts once the
|
|
21
|
+
* rewrite started delivering pages that could half-answer; never in the 84 recorded corpus
|
|
22
|
+
* fetches, where 11 of 11 sentinel answers are the bare sentinel — so tightening it changes
|
|
23
|
+
* no recorded verdict.
|
|
24
|
+
*/
|
|
25
|
+
const NOT_COVERED_RE = /^not covered by this page[.\s]*$/i;
|
|
15
26
|
const childArgs = () => [...childBaseArgs(), '--no-tools'];
|
|
27
|
+
/**
|
|
28
|
+
* `github.com/{owner}/{repo}/blob/{ref}/{path}` renders the file through a client-side
|
|
29
|
+
* viewer, so the HTML we clean carries GitHub chrome ("Sign in", "Appearance settings")
|
|
30
|
+
* and none of the file. Every one of the 8 blob URLs in the three-project research-cache
|
|
31
|
+
* corpus came back a non-answer for that reason. `raw.githubusercontent.com` serves the
|
|
32
|
+
* same bytes as text/plain.
|
|
33
|
+
*
|
|
34
|
+
* Only the URL handed to the fetcher is rewritten — the caller's URL still keys the cache
|
|
35
|
+
* and still supplies the #fragment — so a worker that retries the raw URL by hand hits the
|
|
36
|
+
* cache, and a run's own recorded URLs stay what the run asked for.
|
|
37
|
+
*
|
|
38
|
+
* The query string is dropped: every blob query param (`?plain=1`, `?w=1`, …) is a viewer
|
|
39
|
+
* setting with no meaning on raw. The #fragment is dropped from the request too (it never
|
|
40
|
+
* reaches a server) but is preserved for {@link selectContent} via the original URL.
|
|
41
|
+
*/
|
|
42
|
+
const GH_BLOB_RE = /^https?:\/\/(?:www\.)?github\.com\/([^/?#]+)\/([^/?#]+)\/blob\/([^?#]+?)\/*(?:[?#].*)?$/i;
|
|
43
|
+
export function normaliseSourceUrl(url) {
|
|
44
|
+
const m = GH_BLOB_RE.exec(url.trim());
|
|
45
|
+
if (!m)
|
|
46
|
+
return url;
|
|
47
|
+
const [, owner, repo, refAndPath] = m;
|
|
48
|
+
// `/blob/{ref}/{path}` — a bare `/blob/{ref}` with no path names no file.
|
|
49
|
+
if (!refAndPath.includes('/'))
|
|
50
|
+
return url;
|
|
51
|
+
return `https://raw.githubusercontent.com/${owner}/${repo}/${refAndPath}`;
|
|
52
|
+
}
|
|
16
53
|
export async function fetchRaw(input) {
|
|
17
54
|
const fetchAndCleanFn = input.fetchAndClean ?? defaultFetchAndClean;
|
|
18
|
-
const cleaned = await fetchAndCleanFn(input.url, {
|
|
55
|
+
const cleaned = await fetchAndCleanFn(normaliseSourceUrl(input.url), {
|
|
56
|
+
signal: input.signal
|
|
57
|
+
});
|
|
19
58
|
return { markdown: cleaned.markdown, finalUrl: cleaned.finalUrl, title: cleaned.title };
|
|
20
59
|
}
|
|
21
60
|
export async function fetchFocused(input) {
|
|
22
61
|
const fetchAndCleanFn = input.fetchAndClean ?? defaultFetchAndClean;
|
|
23
62
|
const spawnFn = input.spawn ?? defaultSpawn;
|
|
24
63
|
const strategy = input.strategy ?? shippedStrategy;
|
|
25
|
-
const
|
|
64
|
+
const fetchedUrl = normaliseSourceUrl(input.url);
|
|
65
|
+
const cleaned = await fetchAndCleanFn(fetchedUrl, { signal: input.signal });
|
|
26
66
|
// The #fragment is a client-side concern the server never sees, so anchor from the
|
|
27
67
|
// ORIGINALLY REQUESTED url, not the post-redirect finalUrl (which will have dropped it).
|
|
28
68
|
const selected = strategy.selectContent(cleaned.markdown, input.url);
|
|
@@ -60,6 +100,7 @@ export async function fetchFocused(input) {
|
|
|
60
100
|
};
|
|
61
101
|
}
|
|
62
102
|
const parsed = parseChildOutput(childResult.stdout);
|
|
103
|
+
const coverageMiss = NOT_COVERED_RE.test(parsed.answer.trim());
|
|
63
104
|
// Verify against the FULL page, not the anchored slice: the slice is a substring of it,
|
|
64
105
|
// so a genuine excerpt still verifies, and an excerpt the child pulled from memory still
|
|
65
106
|
// fails — the detector's discrimination is unchanged by fragment anchoring.
|
|
@@ -69,12 +110,32 @@ export async function fetchFocused(input) {
|
|
|
69
110
|
excerpt: parsed.excerpt,
|
|
70
111
|
excerptVerified: check?.verified,
|
|
71
112
|
excerptCheck: check,
|
|
72
|
-
coverageMiss
|
|
113
|
+
coverageMiss,
|
|
114
|
+
nextStep: coverageMiss ? coverageMissNextStep(input.url, fetchedUrl) : undefined,
|
|
73
115
|
childExitCode: 0,
|
|
74
116
|
aborted: false,
|
|
75
117
|
...base
|
|
76
118
|
};
|
|
77
119
|
}
|
|
120
|
+
/**
|
|
121
|
+
* The instruction that goes with a coverage miss. It says two things the bare sentinel does
|
|
122
|
+
* not: WHICH page was actually read (after the blob rewrite these differ, and a worker that
|
|
123
|
+
* cannot see that would "retry" the raw URL it already got), and that re-reading this page
|
|
124
|
+
* with a reworded question returns the same answer — so the next move is a different URL.
|
|
125
|
+
*
|
|
126
|
+
* Deliberately not a suggestion of WHICH other URL. Nothing at this layer knows one, and
|
|
127
|
+
* naming a guess is how a dead end becomes two dead ends.
|
|
128
|
+
*/
|
|
129
|
+
export function coverageMissNextStep(requestedUrl, fetchedUrl) {
|
|
130
|
+
const rewritten = fetchedUrl !== requestedUrl ?
|
|
131
|
+
` ${requestedUrl} is a GitHub file viewer whose HTML does not carry the file, so the`
|
|
132
|
+
+ ` file itself was already read from ${fetchedUrl} — fetching that raw URL by hand`
|
|
133
|
+
+ ` returns exactly this.`
|
|
134
|
+
: '';
|
|
135
|
+
return (`NEXT STEP: this page does not contain the answer.${rewritten}`
|
|
136
|
+
+ ` Asking ${fetchedUrl} the same question a different way returns this same result —`
|
|
137
|
+
+ ` do not re-read it. Try a different URL, or search for one.`);
|
|
138
|
+
}
|
|
78
139
|
/** Read the #fragment from a URL. Empty string when there is none. */
|
|
79
140
|
function fragmentOf(url) {
|
|
80
141
|
const h = url.indexOf('#');
|
|
@@ -52,7 +52,11 @@ export function registerPiWorkerFetch(pi, internals = {}) {
|
|
|
52
52
|
if (failure !== null) {
|
|
53
53
|
return { text: failure, details: { childExitCode: result.childExitCode } };
|
|
54
54
|
}
|
|
55
|
-
const
|
|
55
|
+
const body = formatResultText({ answer: result.answer, excerpt: result.excerpt }, result.excerptVerified) || '(no output)';
|
|
56
|
+
// The coverage miss is the one outcome that carries an instruction. It goes
|
|
57
|
+
// in the TEXT, not only in details: details are for the harness, and the
|
|
58
|
+
// worker acts on what it reads.
|
|
59
|
+
const text = result.nextStep ? `${body}\n\n${result.nextStep}` : body;
|
|
56
60
|
return {
|
|
57
61
|
text,
|
|
58
62
|
details: {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mjasnikovs/pi-task",
|
|
3
|
-
"version": "0.37.
|
|
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",
|