@mjasnikovs/pi-task 0.26.0 → 0.28.0

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.
@@ -6,6 +6,12 @@
6
6
  * server entry point … the Hono server cannot be started"), so the terminal defect
7
7
  * was FOUND and then erased by the very mechanism that found it. Persisted here so
8
8
  * the final gate re-checks and surfaces it instead of letting it die with the revert.
9
+ * - 'enforce-kept' — the same enforce re-verify FAIL, but the failing check named
10
+ * only files the ENFORCE COMMIT does not touch, so reverting that commit could not
11
+ * possibly repair it (mx5 run 18 TASK_0024: a one-line paren removal in `Admin.tsx`
12
+ * was reverted over a `MyListings.spec.tsx` CT failure it cannot reach, and the
13
+ * final gate re-made the identical change 5 minutes later). The edits are KEPT and
14
+ * the defect is recorded here — keeping the work must never mean losing the finding.
9
15
  * - 'frozen-blocked' — a repo-health verify-FAIL whose only fix is an edit to a path
10
16
  * THIS task's spec froze (mx5 run 12: `bun run lint` permanently red because the
11
17
  * created files need a tsconfig registration every spec forbids). Cross-task
@@ -43,7 +49,7 @@
43
49
  * (root-cause-repair.ts). Before this class existed the ledger recorded the same
44
50
  * root cause twice and nothing ever scheduled a fix, so it survived ~24h.
45
51
  */
46
- export type DebtOrigin = 'accepted' | 'enforce-revert' | 'frozen-blocked' | 'cross-task-deletion' | 'yolo-accepted' | 'final-gate' | 'root-cause';
52
+ export type DebtOrigin = 'accepted' | 'enforce-revert' | 'enforce-kept' | 'frozen-blocked' | 'cross-task-deletion' | 'yolo-accepted' | 'final-gate' | 'root-cause';
47
53
  /** One recorded defect: the task, why its VERIFY failed, and how it was recorded. */
48
54
  export interface AcceptDebt {
49
55
  taskId: string;
@@ -82,6 +88,14 @@ export declare function recordAcceptDebt(cwd: string, taskId: string, reason: st
82
88
  * rather than letting it die with the revert.
83
89
  */
84
90
  export declare function recordEnforceRevertDebt(cwd: string, taskId: string, reason: string): Promise<void>;
91
+ /**
92
+ * Record an ENFORCE-KEPT debt (mx5 run 18 / nexttask 4): an enforce re-verify FAILED
93
+ * on a check whose named files are DISJOINT from the enforce commit's own diff, so
94
+ * the edits were kept — discarding them could not have repaired a failure they cannot
95
+ * reach. The defect is real and still in the shipped tree, so it is recorded with the
96
+ * same durability as an enforce-revert; only the disposition of the edits differs.
97
+ */
98
+ export declare function recordEnforceKeptDebt(cwd: string, taskId: string, reason: string): Promise<void>;
85
99
  /**
86
100
  * Record a FROZEN-BLOCKED debt (mx5 run 12 / PROMPT 1 layer B): a repo-health FAIL
87
101
  * whose static findings can only be fixed by editing a path this task's spec froze —
@@ -75,6 +75,7 @@ export function parseAcceptDebts(raw) {
75
75
  taskId: parts[0].trim(),
76
76
  reason: parts[1].trim(),
77
77
  ...((origin === 'enforce-revert'
78
+ || origin === 'enforce-kept'
78
79
  || origin === 'frozen-blocked'
79
80
  || origin === 'cross-task-deletion'
80
81
  || origin === 'yolo-accepted'
@@ -147,6 +148,20 @@ export async function recordEnforceRevertDebt(cwd, taskId, reason) {
147
148
  origin: 'enforce-revert'
148
149
  });
149
150
  }
151
+ /**
152
+ * Record an ENFORCE-KEPT debt (mx5 run 18 / nexttask 4): an enforce re-verify FAILED
153
+ * on a check whose named files are DISJOINT from the enforce commit's own diff, so
154
+ * the edits were kept — discarding them could not have repaired a failure they cannot
155
+ * reach. The defect is real and still in the shipped tree, so it is recorded with the
156
+ * same durability as an enforce-revert; only the disposition of the edits differs.
157
+ */
158
+ export async function recordEnforceKeptDebt(cwd, taskId, reason) {
159
+ await appendDebt(cwd, {
160
+ taskId: taskId.trim(),
161
+ reason: normaliseReason(reason),
162
+ origin: 'enforce-kept'
163
+ });
164
+ }
150
165
  /**
151
166
  * Record a FROZEN-BLOCKED debt (mx5 run 12 / PROMPT 1 layer B): a repo-health FAIL
152
167
  * whose static findings can only be fixed by editing a path this task's spec froze —
@@ -364,6 +379,9 @@ export function describeDebt(d) {
364
379
  if (d.origin === 'enforce-revert') {
365
380
  return 'enforce re-verify FAILED then the edits were reverted (defect indicts the ORIGINAL work, still shipped)';
366
381
  }
382
+ if (d.origin === 'enforce-kept') {
383
+ return 'enforce re-verify FAILED on a check the enforce diff cannot reach — the guideline edits were KEPT (reverting them could not fix it) and the defect indicts the ORIGINAL work, still shipped';
384
+ }
367
385
  if (d.origin === 'frozen-blocked') {
368
386
  return 'repo health blocked by a spec-frozen path (cross-task contradiction — no task may perform the fixing edit)';
369
387
  }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * enforce-attribution — decide whether an enforce-pass re-verify FAIL can possibly
3
+ * be the ENFORCE COMMIT's fault (mx5 run 18, nexttask 4).
4
+ *
5
+ * The failure class, measured. Run 18's TASK_0024 enforce commit `ee65661` was, in
6
+ * full, one line:
7
+ *
8
+ * - setApiError((usersData).error ?? 'Failed to load users')
9
+ * + setApiError(usersData.error ?? 'Failed to load users')
10
+ *
11
+ * The differential re-verify then FAILed on `MyListings.spec.tsx:186`, a Playwright
12
+ * CT file `Admin.tsx` cannot reach (CT bundles per story; the import graph never
13
+ * touches it), and the differential reverted the enforce commit anyway. The defect
14
+ * was real and PRE-EXISTING — `getByText('SOLD')` matched two elements because
15
+ * MOCK_LISTINGS already carries a sold row — and the final gate fixed it five
16
+ * minutes later, in the same commit that RE-MADE the identical paren change. Cost:
17
+ * one correct change destroyed, one permanent false verify-FAIL debt written
18
+ * against work that was fine, and the change re-made anyway.
19
+ *
20
+ * The mechanism was not broken; it was asked the wrong question. The root-cause
21
+ * channel (root-cause-repair.ts) attributed against `scope: 'committed'` — the
22
+ * TASK's own commit, which touched `MyListings.tsx` — concluded "this task touched
23
+ * the file", and fell through to the conservative revert. But at THIS seam the
24
+ * differential is deciding whether to discard the ENFORCE COMMIT. The causal
25
+ * question is therefore *"could the enforce diff have caused this?"*, never *"could
26
+ * the task have?"*: reverting the enforce commit can only ever repair damage the
27
+ * enforce commit did.
28
+ *
29
+ * So the filter here is mechanical and one-sided:
30
+ *
31
+ * - the files the FAIL text NAMES are extracted (paths AND bare basenames — run
32
+ * 18's reason named `MyListings.spec.tsx:186`, which carries no separator and
33
+ * is invisible to root-cause-repair.ts's path-token regex, a second reason
34
+ * that incident could not be attributed);
35
+ * - each is compared against the enforce commit's own file set, by path suffix
36
+ * and by RELATED STEM (`Admin.tsx` ~ `Admin.spec.tsx` ~ `Admin.stories.tsx`),
37
+ * so a regression in a touched file's own test still counts as overlap;
38
+ * - DISJOINT ⇒ keep the edits and route the defect. Anything else — an unknown
39
+ * enforce diff, a FAIL text naming no file at all, any overlap — reverts,
40
+ * which is exactly the pre-existing behaviour. Never keep on ignorance.
41
+ *
42
+ * Keeping the edits must not mean losing the finding: the caller still records the
43
+ * debt and still queues the repair. Losing the finding was mx5 run 5's mistake.
44
+ *
45
+ * MEASURED COVERAGE, and it is the honest weakness of this filter. Live, 40 trials
46
+ * against the run-18 tree with a real verify child
47
+ * (`scripts/live-enforce-attribution-ab.ts`):
48
+ *
49
+ * reachable arm (the enforce edit itself breaks `Admin.tsx`, ground truth REVERT)
50
+ * 20/20 FAILs named a resolvable file, 20/20 decided correctly — tsc prints the
51
+ * path, so the regression case is fully covered.
52
+ * disjoint arm (a deterministic failure in `MyListings.spec.tsx`, ground truth KEEP)
53
+ * only 9/20 FAILs named a file at all. The other 11 named the UNIT — "The
54
+ * MyListings component test \"edit link navigates…\" fails … expects 4 Edit links"
55
+ * — with no path and no `.tsx` anywhere. Those 11 fall through to the revert.
56
+ *
57
+ * So this fires on roughly half of live disjoint failures and on the recorded run-18
58
+ * verdict (which did name `MyListings.spec.tsx:186`), and it was NEVER wrong: 29 of
59
+ * 29 extractable decisions matched ground truth across both arms. The arm-level
60
+ * verdict is still FAIL against the pre-registered ≥80%-extractable bar, and the
61
+ * shipped rule is deliberately the conservative half of that trade: a miss costs
62
+ * exactly the behaviour that shipped before, while a false KEEP would ship a real
63
+ * regression. A stem-widened extractor (accept a bare identifier matching a tracked
64
+ * file's stem) reaches 40/40 with 0 errors in the same data and is NOT wired — see
65
+ * VALIDATION-DEBT.md, it resolves prose nouns to files and its FP mode was never
66
+ * exercised.
67
+ */
68
+ /**
69
+ * The comparable stem of a file name: the base name with its extension and any
70
+ * companion suffix removed, lowercased. `src/client/pages/Admin.tsx`,
71
+ * `Admin.spec.tsx` and `Admin.stories.tsx` all reduce to `admin`, which is what
72
+ * makes "the enforce diff broke that file's own test" count as an overlap rather
73
+ * than a disjoint (and therefore keepable) failure.
74
+ */
75
+ export declare function fileStem(p: string): string;
76
+ /**
77
+ * Every file a FAIL text names, de-duplicated, in first-seen order. Both shapes
78
+ * count: `src/server/routes/photos.ts` (path) and `MyListings.spec.tsx:186` (bare
79
+ * name plus a line number). An empty result is the signal that NOTHING can be
80
+ * attributed — the caller must then revert, never keep.
81
+ */
82
+ export declare function extractFailingFiles(text: string): string[];
83
+ /**
84
+ * Resolve a named file to a tracked repo path when the repo file list makes it
85
+ * unambiguous. `MyListings.spec.tsx` → `src/client/pages/MyListings.spec.tsx`, so
86
+ * the KEEP path can ask provenance about it and queue a real repair task. An
87
+ * ambiguous name (two tracked files share it) resolves to null: guessing which one
88
+ * the failure meant would put a wrong file in a repair task's title.
89
+ */
90
+ export declare function resolveNamedFile(named: string, repoFiles: string[] | null): string | null;
91
+ export interface EnforceAttributionInput {
92
+ /** The differential re-verify's FAIL reason, verbatim. */
93
+ failReason: string;
94
+ /**
95
+ * Repo-relative paths the ENFORCE COMMIT changed. `null` means unknown (git
96
+ * unavailable) and forces the pre-existing revert — inconclusive is never
97
+ * evidence for keeping a possible regression.
98
+ */
99
+ enforceTouched: string[] | null;
100
+ /** Tracked repo paths, for resolving bare file names. Optional; absent only
101
+ * costs resolution, never changes the keep/revert verdict. */
102
+ repoFiles?: string[] | null;
103
+ }
104
+ /** Why the differential decided as it did — verbatim into the gate trail. */
105
+ export type EnforceAttributionWhy = 'enforce-diff-unknown' | 'no-file-named' | 'named-file-in-enforce-diff' | 'disjoint-from-enforce-diff';
106
+ export interface EnforceAttribution {
107
+ /** `keep` only on positive mechanical evidence that enforce cannot be at fault. */
108
+ verdict: 'keep' | 'revert';
109
+ why: EnforceAttributionWhy;
110
+ /** Files the FAIL text named (resolved to repo paths where unambiguous). */
111
+ named: string[];
112
+ /** The enforce commit's own file set, as compared against (for the trail). */
113
+ enforceDiff: string[];
114
+ /** On `revert`: the named file that overlaps the enforce diff. */
115
+ overlap?: {
116
+ named: string;
117
+ enforce: string;
118
+ };
119
+ /** On `keep`: the file to blame the defect on (the first named file). */
120
+ file?: string;
121
+ }
122
+ /**
123
+ * Could the ENFORCE COMMIT have caused this FAIL? `keep` iff the failure names at
124
+ * least one file and NO named file is the enforce diff's, or a companion of one.
125
+ * Every unknown returns `revert`, i.e. exactly the behaviour that shipped before
126
+ * this filter existed.
127
+ */
128
+ export declare function attributeEnforceFailure(input: EnforceAttributionInput): EnforceAttribution;
@@ -0,0 +1,213 @@
1
+ /**
2
+ * enforce-attribution — decide whether an enforce-pass re-verify FAIL can possibly
3
+ * be the ENFORCE COMMIT's fault (mx5 run 18, nexttask 4).
4
+ *
5
+ * The failure class, measured. Run 18's TASK_0024 enforce commit `ee65661` was, in
6
+ * full, one line:
7
+ *
8
+ * - setApiError((usersData).error ?? 'Failed to load users')
9
+ * + setApiError(usersData.error ?? 'Failed to load users')
10
+ *
11
+ * The differential re-verify then FAILed on `MyListings.spec.tsx:186`, a Playwright
12
+ * CT file `Admin.tsx` cannot reach (CT bundles per story; the import graph never
13
+ * touches it), and the differential reverted the enforce commit anyway. The defect
14
+ * was real and PRE-EXISTING — `getByText('SOLD')` matched two elements because
15
+ * MOCK_LISTINGS already carries a sold row — and the final gate fixed it five
16
+ * minutes later, in the same commit that RE-MADE the identical paren change. Cost:
17
+ * one correct change destroyed, one permanent false verify-FAIL debt written
18
+ * against work that was fine, and the change re-made anyway.
19
+ *
20
+ * The mechanism was not broken; it was asked the wrong question. The root-cause
21
+ * channel (root-cause-repair.ts) attributed against `scope: 'committed'` — the
22
+ * TASK's own commit, which touched `MyListings.tsx` — concluded "this task touched
23
+ * the file", and fell through to the conservative revert. But at THIS seam the
24
+ * differential is deciding whether to discard the ENFORCE COMMIT. The causal
25
+ * question is therefore *"could the enforce diff have caused this?"*, never *"could
26
+ * the task have?"*: reverting the enforce commit can only ever repair damage the
27
+ * enforce commit did.
28
+ *
29
+ * So the filter here is mechanical and one-sided:
30
+ *
31
+ * - the files the FAIL text NAMES are extracted (paths AND bare basenames — run
32
+ * 18's reason named `MyListings.spec.tsx:186`, which carries no separator and
33
+ * is invisible to root-cause-repair.ts's path-token regex, a second reason
34
+ * that incident could not be attributed);
35
+ * - each is compared against the enforce commit's own file set, by path suffix
36
+ * and by RELATED STEM (`Admin.tsx` ~ `Admin.spec.tsx` ~ `Admin.stories.tsx`),
37
+ * so a regression in a touched file's own test still counts as overlap;
38
+ * - DISJOINT ⇒ keep the edits and route the defect. Anything else — an unknown
39
+ * enforce diff, a FAIL text naming no file at all, any overlap — reverts,
40
+ * which is exactly the pre-existing behaviour. Never keep on ignorance.
41
+ *
42
+ * Keeping the edits must not mean losing the finding: the caller still records the
43
+ * debt and still queues the repair. Losing the finding was mx5 run 5's mistake.
44
+ *
45
+ * MEASURED COVERAGE, and it is the honest weakness of this filter. Live, 40 trials
46
+ * against the run-18 tree with a real verify child
47
+ * (`scripts/live-enforce-attribution-ab.ts`):
48
+ *
49
+ * reachable arm (the enforce edit itself breaks `Admin.tsx`, ground truth REVERT)
50
+ * 20/20 FAILs named a resolvable file, 20/20 decided correctly — tsc prints the
51
+ * path, so the regression case is fully covered.
52
+ * disjoint arm (a deterministic failure in `MyListings.spec.tsx`, ground truth KEEP)
53
+ * only 9/20 FAILs named a file at all. The other 11 named the UNIT — "The
54
+ * MyListings component test \"edit link navigates…\" fails … expects 4 Edit links"
55
+ * — with no path and no `.tsx` anywhere. Those 11 fall through to the revert.
56
+ *
57
+ * So this fires on roughly half of live disjoint failures and on the recorded run-18
58
+ * verdict (which did name `MyListings.spec.tsx:186`), and it was NEVER wrong: 29 of
59
+ * 29 extractable decisions matched ground truth across both arms. The arm-level
60
+ * verdict is still FAIL against the pre-registered ≥80%-extractable bar, and the
61
+ * shipped rule is deliberately the conservative half of that trade: a miss costs
62
+ * exactly the behaviour that shipped before, while a false KEEP would ship a real
63
+ * regression. A stem-widened extractor (accept a bare identifier matching a tracked
64
+ * file's stem) reaches 40/40 with 0 errors in the same data and is NOT wired — see
65
+ * VALIDATION-DEBT.md, it resolves prose nouns to files and its FP mode was never
66
+ * exercised.
67
+ */
68
+ /** A path-like token: at least one separator, ending in a file name. */
69
+ const PATH_TOKEN_RE = /(?:[\w.@~-]+\/)+[\w.@-]+\.\w+/g;
70
+ /**
71
+ * A bare file name with a code-ish extension. Deliberately extension-gated: an
72
+ * ungated `\w+\.\w+` matches prose ("e.g.", "v1.2"), object access (`usersData.error`
73
+ * — which run 18's own enforce diff contains) and assertion chains
74
+ * (`toBeVisible()`), and every one of those would be a phantom "named file".
75
+ */
76
+ const BARE_FILE_RE = /\b[\w@-]+(?:\.[\w@-]+)*\.(?:tsx?|jsx?|mtsx?|ctsx?|mjs|cjs|vue|svelte|astro|py|go|rs|rb|java|kt|kts|cs|php|swift|scala|c|h|cc|cpp|hpp|css|scss|sass|less|html?|json|jsonc|ya?ml|toml|sql|sh|bash|zsh|md|mdx|prisma|graphql|gql|proto|lock)\b/g;
77
+ /** Line/column suffixes and decoration a reason wraps a file name in. */
78
+ const TRAILING_POSITION_RE = /:(\d+)(?::(\d+))?$/;
79
+ /** Suffixes that make a file a sibling of another (test/story/type companions). */
80
+ const COMPANION_SUFFIXES = ['.spec', '.test', '.stories', '.story', '.d', '.min'];
81
+ /** A pathological reason cannot make this scan unbounded. */
82
+ const MAX_NAMED = 24;
83
+ /** Strip `./`, leading slashes and surrounding whitespace. */
84
+ function normalisePath(p) {
85
+ return p.replace(/^\.\//, '').replace(/^\/+/, '').trim();
86
+ }
87
+ /** The last path segment of a repo-relative path. */
88
+ function basename(p) {
89
+ const i = p.lastIndexOf('/');
90
+ return i < 0 ? p : p.slice(i + 1);
91
+ }
92
+ /**
93
+ * The comparable stem of a file name: the base name with its extension and any
94
+ * companion suffix removed, lowercased. `src/client/pages/Admin.tsx`,
95
+ * `Admin.spec.tsx` and `Admin.stories.tsx` all reduce to `admin`, which is what
96
+ * makes "the enforce diff broke that file's own test" count as an overlap rather
97
+ * than a disjoint (and therefore keepable) failure.
98
+ */
99
+ export function fileStem(p) {
100
+ let name = basename(normalisePath(p));
101
+ const dot = name.lastIndexOf('.');
102
+ if (dot > 0)
103
+ name = name.slice(0, dot);
104
+ for (const suffix of COMPANION_SUFFIXES) {
105
+ if (name.toLowerCase().endsWith(suffix)) {
106
+ name = name.slice(0, name.length - suffix.length);
107
+ break;
108
+ }
109
+ }
110
+ return name.toLowerCase();
111
+ }
112
+ /**
113
+ * Every file a FAIL text names, de-duplicated, in first-seen order. Both shapes
114
+ * count: `src/server/routes/photos.ts` (path) and `MyListings.spec.tsx:186` (bare
115
+ * name plus a line number). An empty result is the signal that NOTHING can be
116
+ * attributed — the caller must then revert, never keep.
117
+ */
118
+ export function extractFailingFiles(text) {
119
+ if (text.trim().length === 0)
120
+ return [];
121
+ const out = [];
122
+ const seen = new Set();
123
+ const add = (raw) => {
124
+ const cleaned = normalisePath(raw.replace(TRAILING_POSITION_RE, ''));
125
+ if (cleaned.length === 0)
126
+ return;
127
+ const key = cleaned.toLowerCase();
128
+ if (seen.has(key))
129
+ return;
130
+ seen.add(key);
131
+ if (out.length < MAX_NAMED)
132
+ out.push(cleaned);
133
+ };
134
+ // Paths first so `src/a/B.ts` is recorded as the path, not as the bare `B.ts`
135
+ // its own tail also matches.
136
+ const covered = [];
137
+ for (const m of text.matchAll(PATH_TOKEN_RE)) {
138
+ covered.push([m.index, m.index + m[0].length]);
139
+ add(withPosition(text, m.index + m[0].length, m[0]));
140
+ }
141
+ for (const m of text.matchAll(BARE_FILE_RE)) {
142
+ const start = m.index;
143
+ if (covered.some(([s, e]) => start >= s && start < e))
144
+ continue;
145
+ add(withPosition(text, start + m[0].length, m[0]));
146
+ }
147
+ return out;
148
+ }
149
+ /** Re-attach a `:line[:col]` suffix that follows a match, so it can be stripped
150
+ * uniformly (and so `a.ts:1` and `a.ts` collapse to one named file). */
151
+ function withPosition(text, end, token) {
152
+ const m = /^:\d+(?::\d+)?/.exec(text.slice(end));
153
+ return m ? token + m[0] : token;
154
+ }
155
+ /** Two repo-relative paths that denote the same file (suffix-compared). */
156
+ function samePath(a, b) {
157
+ const x = normalisePath(a).toLowerCase();
158
+ const y = normalisePath(b).toLowerCase();
159
+ return x === y || x.endsWith(`/${y}`) || y.endsWith(`/${x}`);
160
+ }
161
+ /**
162
+ * Resolve a named file to a tracked repo path when the repo file list makes it
163
+ * unambiguous. `MyListings.spec.tsx` → `src/client/pages/MyListings.spec.tsx`, so
164
+ * the KEEP path can ask provenance about it and queue a real repair task. An
165
+ * ambiguous name (two tracked files share it) resolves to null: guessing which one
166
+ * the failure meant would put a wrong file in a repair task's title.
167
+ */
168
+ export function resolveNamedFile(named, repoFiles) {
169
+ if (!repoFiles || repoFiles.length === 0)
170
+ return null;
171
+ const matches = repoFiles.filter(f => samePath(f, named));
172
+ if (matches.length === 1)
173
+ return normalisePath(matches[0]);
174
+ // Exact-path hit wins over the suffix fan-out (a repo may track both
175
+ // `a/x.ts` and `b/a/x.ts`).
176
+ const exact = repoFiles.filter(f => normalisePath(f).toLowerCase() === normalisePath(named).toLowerCase());
177
+ return exact.length === 1 ? normalisePath(exact[0]) : null;
178
+ }
179
+ /**
180
+ * Could the ENFORCE COMMIT have caused this FAIL? `keep` iff the failure names at
181
+ * least one file and NO named file is the enforce diff's, or a companion of one.
182
+ * Every unknown returns `revert`, i.e. exactly the behaviour that shipped before
183
+ * this filter existed.
184
+ */
185
+ export function attributeEnforceFailure(input) {
186
+ const enforce = (input.enforceTouched ?? []).map(normalisePath).filter(f => f.length > 0);
187
+ if (input.enforceTouched === null || enforce.length === 0) {
188
+ return { verdict: 'revert', why: 'enforce-diff-unknown', named: [], enforceDiff: enforce };
189
+ }
190
+ const raw = extractFailingFiles(input.failReason);
191
+ const named = raw.map(n => resolveNamedFile(n, input.repoFiles ?? null) ?? n);
192
+ if (named.length === 0)
193
+ return { verdict: 'revert', why: 'no-file-named', named, enforceDiff: enforce };
194
+ for (const n of named) {
195
+ const hit = enforce.find(e => samePath(e, n) || fileStem(e) === fileStem(n));
196
+ if (hit) {
197
+ return {
198
+ verdict: 'revert',
199
+ why: 'named-file-in-enforce-diff',
200
+ named,
201
+ enforceDiff: enforce,
202
+ overlap: { named: n, enforce: hit }
203
+ };
204
+ }
205
+ }
206
+ return {
207
+ verdict: 'keep',
208
+ why: 'disjoint-from-enforce-diff',
209
+ named,
210
+ enforceDiff: enforce,
211
+ file: named[0]
212
+ };
213
+ }
@@ -305,7 +305,9 @@ export declare function preferredDeclaredPort(cwd: string): Promise<number | nul
305
305
  * needs no socket probe, so run 14's original true positive (a `--hot` runtime
306
306
  * pinning a crashed app) stays reportable wherever the tooling exists.
307
307
  *
308
- * Env-gap contract as everywhere: spawn error (ENOENT) or exit 127 → skip.
308
+ * Env-gap contract as everywhere: spawn error (ENOENT) or a command-not-found
309
+ * inside the chain (exit 127, or the runner's own wording where the platform
310
+ * reports it that way — see isCommandNotFound) → skip.
309
311
  */
310
312
  export declare function runBootCheck(cwd: string, [bin, args]: HealthCommand, graceMs?: number, opts?: {
311
313
  expectServer?: boolean;
@@ -55,7 +55,7 @@ import { readDeclaredScripts, missingDeclaredScripts, runnableDeclaredScripts }
55
55
  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
- import { resolveRunner, runnerEnv } from './runner-resolve.js';
58
+ import { resolveRunner, runnerEnv, isCommandNotFound } from './runner-resolve.js';
59
59
  import { taskThatIntroduced } from './task-provenance.js';
60
60
  import { findDanglingArtifacts, danglingGateFailureText } from './artifact-closure.js';
61
61
  import { findMissingServeEntry, serveEntryGateFailureText } from './serve-entry.js';
@@ -693,7 +693,9 @@ function holderIsOurs(command, boot) {
693
693
  * needs no socket probe, so run 14's original true positive (a `--hot` runtime
694
694
  * pinning a crashed app) stays reportable wherever the tooling exists.
695
695
  *
696
- * Env-gap contract as everywhere: spawn error (ENOENT) or exit 127 → skip.
696
+ * Env-gap contract as everywhere: spawn error (ENOENT) or a command-not-found
697
+ * inside the chain (exit 127, or the runner's own wording where the platform
698
+ * reports it that way — see isCommandNotFound) → skip.
697
699
  */
698
700
  export async function runBootCheck(cwd, [bin, args], graceMs = 10_000, opts = {}) {
699
701
  const expectServer = (opts.expectServer ?? false) && process.platform !== 'win32';
@@ -881,7 +883,11 @@ export async function runBootCheck(cwd, [bin, args], graceMs = 10_000, opts = {}
881
883
  }
882
884
  return settle({ outcome: 'pass' });
883
885
  }
884
- if (status === 127 || (status === null && signal === null)) {
886
+ // Command-not-found inside the boot chain 127 on a posix shell, or
887
+ // the runner's own wording where it isn't (Windows bun exits 1). Either
888
+ // way the boot never RAN, so it is an environment gap, not an app fault.
889
+ if (isCommandNotFound(status, `${out}\n${err}`)
890
+ || (status === null && signal === null)) {
885
891
  return settle({ outcome: 'skip' });
886
892
  }
887
893
  const what = status !== null ? `exited ${status}` : `was killed by ${signal}`;
@@ -967,10 +973,12 @@ function runGateCommand(cwd, [bin, args], timeoutMs, extraGapRe) {
967
973
  });
968
974
  if (r.error)
969
975
  return { outcome: 'skip', spawnFailed: true };
970
- if (r.status === null || r.status === 127)
976
+ if (r.status === null)
971
977
  return { outcome: 'skip', spawnFailed: false };
972
978
  if (r.status !== 0) {
973
979
  const output = `${r.stdout ?? ''}\n${r.stderr ?? ''}`;
980
+ if (isCommandNotFound(r.status, output))
981
+ return { outcome: 'skip', spawnFailed: false };
974
982
  if (ENV_GAP_OUTPUT_RE.test(output))
975
983
  return { outcome: 'skip', spawnFailed: false };
976
984
  if (extraGapRe?.test(output))
@@ -22,7 +22,7 @@ import { runGuidelineEnforcement, classifyEnforceChildFailure } from './enforce-
22
22
  import { runWorkVerification, extractSpecForVerification } from './verify-work.js';
23
23
  import { readEnvNotes, appendEnvNotes } from './env-notes.js';
24
24
  import { readContracts } from './contracts.js';
25
- import { recordAcceptDebt, recordEnforceRevertDebt, recordFrozenBlockedDebt, recordCrossTaskDeletionDebt, recordYoloAcceptDebt, recordRootCauseDebt } from './accept-debt.js';
25
+ import { recordAcceptDebt, recordEnforceKeptDebt, recordEnforceRevertDebt, recordFrozenBlockedDebt, recordCrossTaskDeletionDebt, recordYoloAcceptDebt, recordRootCauseDebt } from './accept-debt.js';
26
26
  import { recordRepairCandidate } from './root-cause-repair.js';
27
27
  import { runRepoHealthCheck } from './repo-health-check.js';
28
28
  import { runFinalIntegrationGate, discoverGateCommandLabels } from './final-gate.js';
@@ -400,6 +400,14 @@ export function buildGateDeps(params) {
400
400
  // (a healthy endpoint reads as proof of life).
401
401
  streamInactivityMs: getConfig().streamInactivityMs,
402
402
  loop: { pathThreshold: Number.POSITIVE_INFINITY },
403
+ // A discarded attempt is otherwise invisible here too: the
404
+ // returned exitCode/text describe the FINAL attempt, so a
405
+ // gate child that burned two attempts and its wall clock
406
+ // reads exactly like one that ran clean.
407
+ onRestart: rs => log(`=== ${kind} RESTART (attempt ${rs.attempt} discarded)`
408
+ + ` reason=${rs.reason} wall=${rs.wallMs}ms`
409
+ + (rs.detail ? ` — ${rs.detail}` : '')
410
+ + ' ==='),
403
411
  onLine: line => {
404
412
  // `lastLine` feeds the LIVE status widget and is not
405
413
  // logging — it stays outside the gate, or a quiet
@@ -469,6 +477,10 @@ export function buildGateDeps(params) {
469
477
  recordAcceptDebt: (cwd2, taskId, reason) => recordAcceptDebt(cwd2, taskId, reason),
470
478
  recordYoloAcceptDebt: (cwd2, taskId, reason) => recordYoloAcceptDebt(cwd2, taskId, reason),
471
479
  recordEnforceRevertDebt: (cwd2, taskId, reason) => recordEnforceRevertDebt(cwd2, taskId, reason),
480
+ // Same ledger, the KEPT disposition (mx5 run 18 / nexttask 4): the enforce
481
+ // re-verify FAILed on a check the enforce diff cannot reach, so the edits
482
+ // stayed and only the defect was recorded.
483
+ recordEnforceKeptDebt: (cwd2, taskId, reason) => recordEnforceKeptDebt(cwd2, taskId, reason),
472
484
  // Durable cross-task-contradiction ledger (PROMPT 1 layer B): a repo-health
473
485
  // FAIL whose only fix is an edit to a path this task's spec froze — recorded
474
486
  // when the gate loop routes it to the picker, re-checked by the final gate.
@@ -485,6 +497,23 @@ export function buildGateDeps(params) {
485
497
  recordRepairCandidate: (cwd2, candidate) => recordRepairCandidate(cwd2, candidate),
486
498
  // file → introducing task, the provenance half of the discriminator.
487
499
  introducedBy: (cwd2, rel) => Promise.resolve(taskThatIntroduced(cwd2, rel)),
500
+ // Tracked paths, used only to resolve a bare file name a FAIL text names
501
+ // (`MyListings.spec.tsx:186`) to its repo path. A git fault returns null,
502
+ // which costs resolution and nothing else.
503
+ repoFiles: async (cwd2) => {
504
+ try {
505
+ const r = await git(cwd2, ['ls-files'], signal);
506
+ if (r.exitCode !== 0)
507
+ return null;
508
+ return r.stdout
509
+ .split('\n')
510
+ .map(l => l.trim())
511
+ .filter(l => l.length > 0);
512
+ }
513
+ catch {
514
+ return null;
515
+ }
516
+ },
488
517
  // The authorship half: which files THIS task's work touched. `worktree` is
489
518
  // the pre-commit verify site (uncommitted changes); `committed` is the
490
519
  // post-commit enforce site, where the task snapshot and the ENFORCE commit
@@ -499,7 +528,18 @@ export function buildGateDeps(params) {
499
528
  const c = parseTreeChanges(r.stdout);
500
529
  return [...c.modified, ...c.added, ...c.deleted];
501
530
  }
502
- const r = await git(cwd2, ['log', '-n', '2', '--name-only', '--format=', 'HEAD'], signal);
531
+ // `enforce-commit` is HEAD ALONE at the enforce differential HEAD is
532
+ // the ENFORCE commit, and that commit's own diff is the only file set
533
+ // that can answer "could reverting this repair the failure?" (mx5 run
534
+ // 18 / nexttask 4).
535
+ const r = await git(cwd2, [
536
+ 'log',
537
+ '-n',
538
+ scope === 'enforce-commit' ? '1' : '2',
539
+ '--name-only',
540
+ '--format=',
541
+ 'HEAD'
542
+ ], signal);
503
543
  if (r.exitCode !== 0)
504
544
  return null;
505
545
  return r.stdout
@@ -586,6 +626,12 @@ export function buildGateDeps(params) {
586
626
  // file (which IS this pass's job) never trips — only a
587
627
  // literally-identical call repeated past threshold does.
588
628
  loop: { pathThreshold: Number.POSITIVE_INFINITY },
629
+ // Same reasoning as the gate child: without this a
630
+ // discarded attempt leaves no trace anywhere.
631
+ onRestart: rs => logEnforce(`=== enforce RESTART (attempt ${rs.attempt} discarded)`
632
+ + ` reason=${rs.reason} wall=${rs.wallMs}ms`
633
+ + (rs.detail ? ` — ${rs.detail}` : '')
634
+ + ' ==='),
589
635
  onLine: line => {
590
636
  // `lastLine` drives the live widget, not the trail.
591
637
  lastLine = line;