@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.
- 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 +48 -2
- package/dist/task/phases.js +62 -1
- package/dist/task/repo-health-check.js +7 -5
- package/dist/task/research-fanout-budget.d.ts +121 -0
- package/dist/task/research-fanout-budget.js +148 -0
- 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/dist/workers/pi-worker-core.d.ts +136 -2
- package/dist/workers/pi-worker-core.js +274 -34
- package/dist/workers/pi-worker-docs.js +18 -0
- 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';
|
|
@@ -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
|
-
|
|
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;
|