@mjasnikovs/pi-task 0.26.0 → 0.27.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.
- package/dist/task/accept-debt.d.ts +15 -1
- package/dist/task/accept-debt.js +18 -0
- package/dist/task/enforce-attribution.d.ts +128 -0
- package/dist/task/enforce-attribution.js +213 -0
- package/dist/task/final-gate.d.ts +3 -1
- package/dist/task/final-gate.js +12 -4
- package/dist/task/gate-deps.js +34 -2
- package/dist/task/repo-health-check.js +7 -5
- package/dist/task/runner-resolve.d.ts +25 -0
- package/dist/task/runner-resolve.js +31 -0
- package/dist/task/task-gates.d.ts +22 -5
- package/dist/task/task-gates.js +73 -6
- package/package.json +1 -1
|
@@ -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 —
|
package/dist/task/accept-debt.js
CHANGED
|
@@ -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
|
|
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;
|
package/dist/task/final-gate.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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))
|
package/dist/task/gate-deps.js
CHANGED
|
@@ -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';
|
|
@@ -469,6 +469,10 @@ export function buildGateDeps(params) {
|
|
|
469
469
|
recordAcceptDebt: (cwd2, taskId, reason) => recordAcceptDebt(cwd2, taskId, reason),
|
|
470
470
|
recordYoloAcceptDebt: (cwd2, taskId, reason) => recordYoloAcceptDebt(cwd2, taskId, reason),
|
|
471
471
|
recordEnforceRevertDebt: (cwd2, taskId, reason) => recordEnforceRevertDebt(cwd2, taskId, reason),
|
|
472
|
+
// Same ledger, the KEPT disposition (mx5 run 18 / nexttask 4): the enforce
|
|
473
|
+
// re-verify FAILed on a check the enforce diff cannot reach, so the edits
|
|
474
|
+
// stayed and only the defect was recorded.
|
|
475
|
+
recordEnforceKeptDebt: (cwd2, taskId, reason) => recordEnforceKeptDebt(cwd2, taskId, reason),
|
|
472
476
|
// Durable cross-task-contradiction ledger (PROMPT 1 layer B): a repo-health
|
|
473
477
|
// FAIL whose only fix is an edit to a path this task's spec froze — recorded
|
|
474
478
|
// when the gate loop routes it to the picker, re-checked by the final gate.
|
|
@@ -485,6 +489,23 @@ export function buildGateDeps(params) {
|
|
|
485
489
|
recordRepairCandidate: (cwd2, candidate) => recordRepairCandidate(cwd2, candidate),
|
|
486
490
|
// file → introducing task, the provenance half of the discriminator.
|
|
487
491
|
introducedBy: (cwd2, rel) => Promise.resolve(taskThatIntroduced(cwd2, rel)),
|
|
492
|
+
// Tracked paths, used only to resolve a bare file name a FAIL text names
|
|
493
|
+
// (`MyListings.spec.tsx:186`) to its repo path. A git fault returns null,
|
|
494
|
+
// which costs resolution and nothing else.
|
|
495
|
+
repoFiles: async (cwd2) => {
|
|
496
|
+
try {
|
|
497
|
+
const r = await git(cwd2, ['ls-files'], signal);
|
|
498
|
+
if (r.exitCode !== 0)
|
|
499
|
+
return null;
|
|
500
|
+
return r.stdout
|
|
501
|
+
.split('\n')
|
|
502
|
+
.map(l => l.trim())
|
|
503
|
+
.filter(l => l.length > 0);
|
|
504
|
+
}
|
|
505
|
+
catch {
|
|
506
|
+
return null;
|
|
507
|
+
}
|
|
508
|
+
},
|
|
488
509
|
// The authorship half: which files THIS task's work touched. `worktree` is
|
|
489
510
|
// the pre-commit verify site (uncommitted changes); `committed` is the
|
|
490
511
|
// post-commit enforce site, where the task snapshot and the ENFORCE commit
|
|
@@ -499,7 +520,18 @@ export function buildGateDeps(params) {
|
|
|
499
520
|
const c = parseTreeChanges(r.stdout);
|
|
500
521
|
return [...c.modified, ...c.added, ...c.deleted];
|
|
501
522
|
}
|
|
502
|
-
|
|
523
|
+
// `enforce-commit` is HEAD ALONE — at the enforce differential HEAD is
|
|
524
|
+
// the ENFORCE commit, and that commit's own diff is the only file set
|
|
525
|
+
// that can answer "could reverting this repair the failure?" (mx5 run
|
|
526
|
+
// 18 / nexttask 4).
|
|
527
|
+
const r = await git(cwd2, [
|
|
528
|
+
'log',
|
|
529
|
+
'-n',
|
|
530
|
+
scope === 'enforce-commit' ? '1' : '2',
|
|
531
|
+
'--name-only',
|
|
532
|
+
'--format=',
|
|
533
|
+
'HEAD'
|
|
534
|
+
], signal);
|
|
503
535
|
if (r.exitCode !== 0)
|
|
504
536
|
return null;
|
|
505
537
|
return r.stdout
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
import { spawnSync } from 'node:child_process';
|
|
33
33
|
import { existsSync, readFileSync } from 'node:fs';
|
|
34
34
|
import * as path from 'node:path';
|
|
35
|
-
import { resolveRunner, runnerEnv } from './runner-resolve.js';
|
|
35
|
+
import { resolveRunner, runnerEnv, isCommandNotFound } from './runner-resolve.js';
|
|
36
36
|
/** How much of a failing command's output to keep — bounded so a wedged tool that
|
|
37
37
|
* spews megabytes cannot bloat the trail. stderr leads (a crash trace lives there). */
|
|
38
38
|
const HEALTH_OUTPUT_MAX_LINES = 40;
|
|
@@ -142,10 +142,12 @@ export function runRepoHealthCheck(cwd, timeoutMs = 600_000) {
|
|
|
142
142
|
// Tool missing (ENOENT) or killed by timeout → cannot conclude; skip it.
|
|
143
143
|
if (r.error || r.status === null)
|
|
144
144
|
continue;
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
|
|
145
|
+
// "Command not found" INSIDE the script chain (e.g. `bun run lint` before
|
|
146
|
+
// node_modules exists — seen live failing TASK_0001's first verify). Same
|
|
147
|
+
// environment gap as ENOENT, just surfaced through the runner's shell —
|
|
148
|
+
// as exit 127 where a posix shell ran it, else by the runner's own wording
|
|
149
|
+
// (Windows bun reports the miss itself and exits 1).
|
|
150
|
+
if (isCommandNotFound(r.status, `${r.stdout ?? ''}\n${r.stderr ?? ''}`))
|
|
149
151
|
continue;
|
|
150
152
|
if (r.status !== 0) {
|
|
151
153
|
return {
|
|
@@ -20,6 +20,31 @@ export declare function resolveRunner(bin: string, opts?: {
|
|
|
20
20
|
probe?: (bin: string) => boolean;
|
|
21
21
|
env?: NodeJS.ProcessEnv;
|
|
22
22
|
}): ResolvedRunner;
|
|
23
|
+
/**
|
|
24
|
+
* Output shapes a RUNNER emits when the command inside a script chain does not
|
|
25
|
+
* exist, on platforms where that is not reported as exit 127.
|
|
26
|
+
*
|
|
27
|
+
* 127 is a POSIX-SHELL convention: on Linux/macOS bun hands the script to
|
|
28
|
+
* /bin/sh, the shell prints `…: command not found` and exits 127, and the whole
|
|
29
|
+
* env-gap contract keys off that number. On Windows there is no such shell —
|
|
30
|
+
* bun runs the script in its own built-in shell, which reports the miss itself
|
|
31
|
+
* (`bun: command not found: X`) and exits **1**, indistinguishable by status
|
|
32
|
+
* alone from a real code fault. cmd.exe (9009) and PowerShell have their own
|
|
33
|
+
* wording. Recognising the shape restores one env-gap contract on all three.
|
|
34
|
+
*
|
|
35
|
+
* Deliberately narrow: only wordings a RUNNER/SHELL produces, never the bare
|
|
36
|
+
* phrase. A suite that prints "command not found" inside a failing assertion is
|
|
37
|
+
* a real FAIL and must stay one — the posix shape already travels as 127.
|
|
38
|
+
*/
|
|
39
|
+
export declare const COMMAND_NOT_FOUND_OUTPUT_RE: RegExp;
|
|
40
|
+
/**
|
|
41
|
+
* Did this command fail because the thing it tried to run does not exist here,
|
|
42
|
+
* rather than because the code is wrong? Exit 127 (POSIX shell) or 9009
|
|
43
|
+
* (cmd.exe) say so outright; anything else needs the runner's own wording (see
|
|
44
|
+
* COMMAND_NOT_FOUND_OUTPUT_RE) — a Windows `bun run dev` on a missing binary
|
|
45
|
+
* exits 1. Callers treat a true here as an environment gap → skip, never FAIL.
|
|
46
|
+
*/
|
|
47
|
+
export declare function isCommandNotFound(status: number | null, output?: string): boolean;
|
|
23
48
|
/**
|
|
24
49
|
* The env a spawn site should pass so the resolved runner's script chain can
|
|
25
50
|
* re-invoke it: base env with the runner's directory prepended to PATH. With no
|
|
@@ -83,6 +83,37 @@ export function resolveRunner(bin, opts = {}) {
|
|
|
83
83
|
cache.set(bin, resolved);
|
|
84
84
|
return resolved;
|
|
85
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* Output shapes a RUNNER emits when the command inside a script chain does not
|
|
88
|
+
* exist, on platforms where that is not reported as exit 127.
|
|
89
|
+
*
|
|
90
|
+
* 127 is a POSIX-SHELL convention: on Linux/macOS bun hands the script to
|
|
91
|
+
* /bin/sh, the shell prints `…: command not found` and exits 127, and the whole
|
|
92
|
+
* env-gap contract keys off that number. On Windows there is no such shell —
|
|
93
|
+
* bun runs the script in its own built-in shell, which reports the miss itself
|
|
94
|
+
* (`bun: command not found: X`) and exits **1**, indistinguishable by status
|
|
95
|
+
* alone from a real code fault. cmd.exe (9009) and PowerShell have their own
|
|
96
|
+
* wording. Recognising the shape restores one env-gap contract on all three.
|
|
97
|
+
*
|
|
98
|
+
* Deliberately narrow: only wordings a RUNNER/SHELL produces, never the bare
|
|
99
|
+
* phrase. A suite that prints "command not found" inside a failing assertion is
|
|
100
|
+
* a real FAIL and must stay one — the posix shape already travels as 127.
|
|
101
|
+
*/
|
|
102
|
+
export const COMMAND_NOT_FOUND_OUTPUT_RE = /\b(?:bun|npm|pnpm|yarn|node|deno): command not found:|is not recognized as an internal or external command|is not recognized as the name of a cmdlet/i;
|
|
103
|
+
/**
|
|
104
|
+
* Did this command fail because the thing it tried to run does not exist here,
|
|
105
|
+
* rather than because the code is wrong? Exit 127 (POSIX shell) or 9009
|
|
106
|
+
* (cmd.exe) say so outright; anything else needs the runner's own wording (see
|
|
107
|
+
* COMMAND_NOT_FOUND_OUTPUT_RE) — a Windows `bun run dev` on a missing binary
|
|
108
|
+
* exits 1. Callers treat a true here as an environment gap → skip, never FAIL.
|
|
109
|
+
*/
|
|
110
|
+
export function isCommandNotFound(status, output = '') {
|
|
111
|
+
if (status === 127 || status === 9009)
|
|
112
|
+
return true;
|
|
113
|
+
if (status === null || status === 0)
|
|
114
|
+
return false;
|
|
115
|
+
return COMMAND_NOT_FOUND_OUTPUT_RE.test(output);
|
|
116
|
+
}
|
|
86
117
|
/**
|
|
87
118
|
* The env a spawn site should pass so the resolved runner's script chain can
|
|
88
119
|
* re-invoke it: base env with the runner's directory prepended to PATH. With no
|
|
@@ -170,6 +170,13 @@ export interface GateDeps {
|
|
|
170
170
|
* so the final gate must re-check and surface it. Best-effort; absent in tests.
|
|
171
171
|
*/
|
|
172
172
|
recordRootCauseDebt?: (cwd: string, taskId: string, reason: string) => Promise<void>;
|
|
173
|
+
/**
|
|
174
|
+
* Record a durable ENFORCE-KEPT debt (mx5 run 18 / nexttask 4): the enforce
|
|
175
|
+
* re-verify FAILED but the failing check names only files the ENFORCE COMMIT
|
|
176
|
+
* does not touch, so the edits were KEPT. The defect is still real and still in
|
|
177
|
+
* the shipped tree — keeping the work must not lose the finding.
|
|
178
|
+
*/
|
|
179
|
+
recordEnforceKeptDebt?: (cwd: string, taskId: string, reason: string) => Promise<void>;
|
|
173
180
|
/**
|
|
174
181
|
* Queue a scoped repair task for a root-caused defect. The gate DETECTS the
|
|
175
182
|
* cause; only the /task-auto loop may mutate the plan, so the two are decoupled
|
|
@@ -184,12 +191,22 @@ export interface GateDeps {
|
|
|
184
191
|
* this task's own fault, and only a file it never touched can be somebody
|
|
185
192
|
* else's pre-existing bug. `worktree` = uncommitted changes (the pre-commit
|
|
186
193
|
* verify site); `committed` = the files the task snapshot + the ENFORCE commit
|
|
187
|
-
* changed (the post-commit enforce site)
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
194
|
+
* changed (the post-commit enforce site); `enforce-commit` = the ENFORCE COMMIT
|
|
195
|
+
* ALONE, which is the only correct authorship question at the enforce
|
|
196
|
+
* differential (mx5 run 18 / nexttask 4 — that differential decides whether to
|
|
197
|
+
* discard the enforce commit, so what the TASK touched is irrelevant to it).
|
|
198
|
+
* `null` means UNKNOWN (git unavailable) and stands the whole channel down —
|
|
199
|
+
* inconclusive is never evidence, so an unreadable tree can only cost a repair
|
|
200
|
+
* task, never spawn a wrong one or wrongly keep a regression.
|
|
201
|
+
*/
|
|
202
|
+
touchedFiles?: (cwd: string, scope: 'worktree' | 'committed' | 'enforce-commit') => Promise<string[] | null>;
|
|
203
|
+
/**
|
|
204
|
+
* Every path git tracks in the repo — used ONLY to resolve a bare file name a
|
|
205
|
+
* FAIL text names (`MyListings.spec.tsx:186`) to its repo path, so the defect
|
|
206
|
+
* can be attributed and a repair queued for it. Absent/null costs resolution,
|
|
207
|
+
* never changes a keep/revert verdict.
|
|
191
208
|
*/
|
|
192
|
-
|
|
209
|
+
repoFiles?: (cwd: string) => Promise<string[] | null>;
|
|
193
210
|
/** The task whose commit INTRODUCED a file (task-provenance.ts). Null for a
|
|
194
211
|
* file predating the run or any git error → unknown provenance. */
|
|
195
212
|
introducedBy?: (cwd: string, rel: string) => Promise<string | null>;
|
package/dist/task/task-gates.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { resolutionOptions, classifyResolutionAnswer } from './verify-resolution.js';
|
|
2
2
|
import { SessionUI } from '../remote/bridge.js';
|
|
3
3
|
import { isYoloMode, yoloVerifyResolution, YOLO_STAMP } from './yolo.js';
|
|
4
|
-
import { findRepairCandidate } from './root-cause-repair.js';
|
|
4
|
+
import { extractFailingCommand, findRepairCandidate, summariseDefect } from './root-cause-repair.js';
|
|
5
|
+
import { attributeEnforceFailure } from './enforce-attribution.js';
|
|
5
6
|
/**
|
|
6
7
|
* How many times a verify FAIL may be auto-fixed UNATTENDED (the research
|
|
7
8
|
* recommended AUTOFIX, so pi re-runs the impl turn without prompting) before the
|
|
@@ -455,8 +456,8 @@ export async function runGatesForTask(ctxIn, deps, p) {
|
|
|
455
456
|
const afterReason = after.reason ?? 'enforce re-verify failed';
|
|
456
457
|
// PRE-EXISTING-CAUSE KEEP PATH (mx5 run 14 item 5b). Both of run
|
|
457
458
|
// 14's enforce-reverts were this shape: the re-verify FAILed on
|
|
458
|
-
// TASK_0007's `test/teardown.ts` TRUNCATE bug — a file
|
|
459
|
-
//
|
|
459
|
+
// TASK_0007's `test/teardown.ts` TRUNCATE bug — a file the enforce
|
|
460
|
+
// pass never touched — and the differential
|
|
460
461
|
// reverted enforce's edits anyway, destroying good work over a fault
|
|
461
462
|
// it did not cause AND leaving the actual cause unscheduled. When the
|
|
462
463
|
// FAIL is attributed to another task's untouched file, KEEP the edits
|
|
@@ -464,16 +465,82 @@ export async function runGatesForTask(ctxIn, deps, p) {
|
|
|
464
465
|
// unknown (git unavailable, no provenance, this task touched the file,
|
|
465
466
|
// an environment-blamed FAIL) falls through to the revert below —
|
|
466
467
|
// the conservative pre-existing behavior.
|
|
467
|
-
|
|
468
|
+
//
|
|
469
|
+
// The scope is `enforce-commit`, NOT the task's own commit (mx5 run
|
|
470
|
+
// 18 / nexttask 4). This differential decides whether to discard the
|
|
471
|
+
// ENFORCE COMMIT, so the causal question is "could the enforce diff
|
|
472
|
+
// have caused this?" — asking what the TASK touched answers a
|
|
473
|
+
// question nobody at this seam is asking, and in run 18 it answered
|
|
474
|
+
// it in a way that destroyed a correct one-line change.
|
|
475
|
+
const rootCause = after.ok ? null : await routeRootCause(afterReason, '', 'enforce-commit');
|
|
476
|
+
// ATTRIBUTION PRE-FILTER (mx5 run 18 / nexttask 4). The root-cause
|
|
477
|
+
// channel above needs a blame CUE, a path-separator token and known
|
|
478
|
+
// provenance; run 18's FAIL text carried none of the three (it named
|
|
479
|
+
// a bare `MyListings.spec.tsx:186`), so it fell straight through to
|
|
480
|
+
// the revert. This filter asks only the mechanical question: does the
|
|
481
|
+
// failing check name any file the ENFORCE COMMIT touched? Disjoint =>
|
|
482
|
+
// the revert cannot repair the failure, so keep the edits and route
|
|
483
|
+
// the defect. Unknown diff, or a FAIL naming no file at all, still
|
|
484
|
+
// reverts — never keep on ignorance.
|
|
485
|
+
const attribution = !after.ok && !rootCause ?
|
|
486
|
+
attributeEnforceFailure({
|
|
487
|
+
failReason: afterReason,
|
|
488
|
+
enforceTouched: (await deps.touchedFiles?.(p.cwd, 'enforce-commit')) ?? null,
|
|
489
|
+
repoFiles: (await deps.repoFiles?.(p.cwd)) ?? null
|
|
490
|
+
})
|
|
491
|
+
: null;
|
|
468
492
|
if (!after.ok && rootCause) {
|
|
469
493
|
await rec(`enforce: re-verify FAILED (${afterReason.slice(0, 200)}) but the failure is attributed to a PRE-EXISTING defect in \`${rootCause.file}\` `
|
|
470
|
-
+ `(${rootCause.owner}'s file, untouched by
|
|
494
|
+
+ `(${rootCause.owner}'s file, untouched by the ENFORCE COMMIT whose fate this differential decides) — edits KEPT, not reverted; repair task queued`);
|
|
471
495
|
active.ui.notify(`${p.tag}: guideline fixes on "${p.title}" re-verified red on a pre-existing defect in ${rootCause.file} (${rootCause.owner}'s file) — keeping the fixes, queued a repair task.`, 'warning');
|
|
472
496
|
}
|
|
497
|
+
else if (!after.ok && attribution?.verdict === 'keep') {
|
|
498
|
+
// KEEP, mechanically justified: every file the failing check named
|
|
499
|
+
// is outside the enforce diff (and outside its companions — a
|
|
500
|
+
// touched file's own spec/story counts as inside). Discarding the
|
|
501
|
+
// enforce commit could not repair this, and in run 18 doing so
|
|
502
|
+
// cost a correct change that the final gate then re-made.
|
|
503
|
+
await rec(`enforce: re-verify FAILED (${afterReason.slice(0, 200)}) but the failing check names only \`${attribution.named.join(', ')}\`, `
|
|
504
|
+
+ `which the ENFORCE COMMIT does not touch (its diff: ${attribution.enforceDiff.join(', ') || '—'}) — `
|
|
505
|
+
+ 'reverting it could not repair this, so the edits are KEPT and the defect is recorded as durable debt');
|
|
506
|
+
// Keeping the edits must NOT lose the finding — that was mx5 run
|
|
507
|
+
// 5's mistake. Same durability as the revert path; only the
|
|
508
|
+
// disposition of the edits differs.
|
|
509
|
+
await deps.recordEnforceKeptDebt?.(p.cwd, p.taskId, afterReason);
|
|
510
|
+
// …and, when the named file is somebody else's committed work,
|
|
511
|
+
// queue the scoped repair so something actually FIXES it.
|
|
512
|
+
if (attribution.file && deps.introducedBy && deps.recordRepairCandidate) {
|
|
513
|
+
try {
|
|
514
|
+
const owner = await deps.introducedBy(p.cwd, attribution.file);
|
|
515
|
+
const verifyCommand = extractFailingCommand(afterReason);
|
|
516
|
+
if (owner && owner !== p.taskId) {
|
|
517
|
+
await deps.recordRepairCandidate(p.cwd, {
|
|
518
|
+
file: attribution.file,
|
|
519
|
+
owner,
|
|
520
|
+
defect: summariseDefect(afterReason, attribution.file),
|
|
521
|
+
blamedTask: p.taskId,
|
|
522
|
+
...(verifyCommand ? { verifyCommand } : {})
|
|
523
|
+
});
|
|
524
|
+
await rec(`root-cause: \`${attribution.file}\` is ${owner}'s file — scoped repair task queued`);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
catch {
|
|
528
|
+
// queueing a repair must never break the gate sequence
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
active.ui.notify(`${p.tag}: guideline fixes on "${p.title}" re-verified red on ${attribution.file ?? 'a file'} — outside the enforce diff, so keeping the fixes and recording the defect.`, 'warning');
|
|
532
|
+
}
|
|
473
533
|
else if (!after.ok) {
|
|
474
534
|
if (deps.revert)
|
|
475
535
|
await deps.revert(p.cwd);
|
|
476
|
-
await rec(`enforce: fixes committed but re-verify FAILED (${(after.reason ?? 'now fails').slice(0, 200)}) — ${deps.revert ? 'REVERTED' : 'left in place (no revert available)'}`
|
|
536
|
+
await rec(`enforce: fixes committed but re-verify FAILED (${(after.reason ?? 'now fails').slice(0, 200)}) — ${deps.revert ? 'REVERTED' : 'left in place (no revert available)'}`
|
|
537
|
+
// Why the attribution filter did NOT save the edits, so a
|
|
538
|
+
// revert is explainable from the trail alone.
|
|
539
|
+
+ (attribution ?
|
|
540
|
+
` [attribution: ${attribution.why}${attribution.overlap ?
|
|
541
|
+
` — the check names \`${attribution.overlap.named}\`, the enforce diff touches \`${attribution.overlap.enforce}\``
|
|
542
|
+
: ''}]`
|
|
543
|
+
: ''));
|
|
477
544
|
// Persist the FAIL as a durable defect (mx5 run 10 item 3). The
|
|
478
545
|
// revert restores the tree the ORIGINAL verify already blessed, so
|
|
479
546
|
// this re-verify caught a defect that verify's earlier PASS missed —
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mjasnikovs/pi-task",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.0",
|
|
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",
|