@mjasnikovs/pi-task 0.18.22 → 0.18.24
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 +31 -4
- package/dist/task/accept-debt.js +50 -4
- package/dist/task/auto-orchestrator.d.ts +1 -0
- package/dist/task/auto-orchestrator.js +30 -5
- package/dist/task/final-gate-fix.d.ts +11 -2
- package/dist/task/final-gate-fix.js +14 -8
- package/dist/task/final-gate.d.ts +26 -12
- package/dist/task/final-gate.js +64 -44
- package/dist/task/gate-deps.d.ts +8 -0
- package/dist/task/gate-deps.js +35 -3
- package/dist/task/lint-fix.d.ts +11 -0
- package/dist/task/lint-fix.js +62 -0
- package/dist/task/task-gates.d.ts +11 -0
- package/dist/task/task-gates.js +12 -0
- package/dist/task/task-provenance.d.ts +45 -0
- package/dist/task/task-provenance.js +98 -0
- package/dist/task/verify-work.d.ts +17 -1
- package/dist/task/verify-work.js +55 -4
- package/dist/task/write-guard.d.ts +10 -0
- package/dist/task/write-guard.js +40 -0
- package/package.json +1 -1
|
@@ -11,8 +11,14 @@
|
|
|
11
11
|
* created files need a tsconfig registration every spec forbids). Cross-task
|
|
12
12
|
* contradiction: no unattended re-run can converge, so the gate loop records the
|
|
13
13
|
* defect and routes to the human picker instead of burning AUTOFIX rounds.
|
|
14
|
+
* - 'cross-task-deletion' — the task's work DELETED a sibling task's committed
|
|
15
|
+
* deliverable (mx5 run 12 PROMPT 2: a fix child deleted TASK_0020's playwright ct
|
|
16
|
+
* files to green a lint) and the user ACCEPTed the verify-FAIL anyway, so the
|
|
17
|
+
* deletion ships in the next commit. Recorded so the final gate re-checks it:
|
|
18
|
+
* resolved iff the named file is back in the tree (a later task restored it),
|
|
19
|
+
* otherwise surfaced.
|
|
14
20
|
*/
|
|
15
|
-
export type DebtOrigin = 'accepted' | 'enforce-revert' | 'frozen-blocked';
|
|
21
|
+
export type DebtOrigin = 'accepted' | 'enforce-revert' | 'frozen-blocked' | 'cross-task-deletion';
|
|
16
22
|
/** One recorded defect: the task, why its VERIFY failed, and how it was recorded. */
|
|
17
23
|
export interface AcceptDebt {
|
|
18
24
|
taskId: string;
|
|
@@ -60,6 +66,24 @@ export declare function recordEnforceRevertDebt(cwd: string, taskId: string, rea
|
|
|
60
66
|
* it auto-closes iff the final gate's own static check passes.
|
|
61
67
|
*/
|
|
62
68
|
export declare function recordFrozenBlockedDebt(cwd: string, taskId: string, reason: string): Promise<void>;
|
|
69
|
+
/**
|
|
70
|
+
* Record a CROSS-TASK DELETION debt (mx5 run 12 PROMPT 2): the task's work deleted a
|
|
71
|
+
* file a DIFFERENT task's commit introduced, verify FAILed, and the user ACCEPTed —
|
|
72
|
+
* so the deletion survives into the next commit. The reason is a fixed machine-
|
|
73
|
+
* parseable shape (`deleted \`<path>\` …`) so the final gate's re-check can extract
|
|
74
|
+
* the path and prove the debt resolved iff the file is back in the tree.
|
|
75
|
+
*/
|
|
76
|
+
export declare function recordCrossTaskDeletionDebt(cwd: string, taskId: string, deletion: {
|
|
77
|
+
path: string;
|
|
78
|
+
owner: string;
|
|
79
|
+
}): Promise<void>;
|
|
80
|
+
/**
|
|
81
|
+
* The deleted path a cross-task-deletion debt names (the fixed shape
|
|
82
|
+
* recordCrossTaskDeletionDebt writes). Null on any other reason text — an
|
|
83
|
+
* unextractable path means the re-check cannot prove anything, so the debt
|
|
84
|
+
* stays open (surface, never re-hide).
|
|
85
|
+
*/
|
|
86
|
+
export declare function extractDeletedDebtPath(reason: string): string | null;
|
|
63
87
|
/** Overwrite the ledger with exactly these records (used to prune resolved debts). */
|
|
64
88
|
export declare function writeAcceptDebts(cwd: string, debts: AcceptDebt[]): Promise<void>;
|
|
65
89
|
/**
|
|
@@ -73,12 +97,15 @@ export declare function writeAcceptDebts(cwd: string, debts: AcceptDebt[]): Prom
|
|
|
73
97
|
export declare function isStaticClassDebt(reason: string): boolean;
|
|
74
98
|
/**
|
|
75
99
|
* Re-check the ledger against the current run state. A static-class debt is RESOLVED
|
|
76
|
-
* iff the final gate's own static check now passes (`staticOk`);
|
|
77
|
-
*
|
|
78
|
-
*
|
|
100
|
+
* iff the final gate's own static check now passes (`staticOk`); a cross-task-deletion
|
|
101
|
+
* debt is RESOLVED iff the file it names is back in the tree (`fileExists` — a later
|
|
102
|
+
* task or a human restored it, so the deletion no longer holds); every other debt
|
|
103
|
+
* stays OPEN (unprovable ⇒ surface, never re-hide). FP-safe: the only auto-closes are
|
|
104
|
+
* ones a deterministic check can stand behind.
|
|
79
105
|
*/
|
|
80
106
|
export declare function recheckAcceptDebts(debts: AcceptDebt[], opts: {
|
|
81
107
|
staticOk: boolean;
|
|
108
|
+
fileExists?: (rel: string) => boolean;
|
|
82
109
|
}): {
|
|
83
110
|
open: AcceptDebt[];
|
|
84
111
|
resolved: AcceptDebt[];
|
package/dist/task/accept-debt.js
CHANGED
|
@@ -74,7 +74,9 @@ export function parseAcceptDebts(raw) {
|
|
|
74
74
|
out.push({
|
|
75
75
|
taskId: parts[0].trim(),
|
|
76
76
|
reason: parts[1].trim(),
|
|
77
|
-
...(origin === 'enforce-revert'
|
|
77
|
+
...((origin === 'enforce-revert'
|
|
78
|
+
|| origin === 'frozen-blocked'
|
|
79
|
+
|| origin === 'cross-task-deletion') ?
|
|
78
80
|
{ origin: origin }
|
|
79
81
|
: {})
|
|
80
82
|
});
|
|
@@ -157,6 +159,30 @@ export async function recordFrozenBlockedDebt(cwd, taskId, reason) {
|
|
|
157
159
|
origin: 'frozen-blocked'
|
|
158
160
|
});
|
|
159
161
|
}
|
|
162
|
+
/**
|
|
163
|
+
* Record a CROSS-TASK DELETION debt (mx5 run 12 PROMPT 2): the task's work deleted a
|
|
164
|
+
* file a DIFFERENT task's commit introduced, verify FAILed, and the user ACCEPTed —
|
|
165
|
+
* so the deletion survives into the next commit. The reason is a fixed machine-
|
|
166
|
+
* parseable shape (`deleted \`<path>\` …`) so the final gate's re-check can extract
|
|
167
|
+
* the path and prove the debt resolved iff the file is back in the tree.
|
|
168
|
+
*/
|
|
169
|
+
export async function recordCrossTaskDeletionDebt(cwd, taskId, deletion) {
|
|
170
|
+
await appendDebt(cwd, {
|
|
171
|
+
taskId: taskId.trim(),
|
|
172
|
+
reason: normaliseReason(`deleted \`${deletion.path}\` — ${deletion.owner}'s committed deliverable, removed by this task's work`),
|
|
173
|
+
origin: 'cross-task-deletion'
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* The deleted path a cross-task-deletion debt names (the fixed shape
|
|
178
|
+
* recordCrossTaskDeletionDebt writes). Null on any other reason text — an
|
|
179
|
+
* unextractable path means the re-check cannot prove anything, so the debt
|
|
180
|
+
* stays open (surface, never re-hide).
|
|
181
|
+
*/
|
|
182
|
+
export function extractDeletedDebtPath(reason) {
|
|
183
|
+
const m = /^deleted `([^`]+)`/.exec(reason.trim());
|
|
184
|
+
return m ? m[1] : null;
|
|
185
|
+
}
|
|
160
186
|
/** Overwrite the ledger with exactly these records (used to prune resolved debts). */
|
|
161
187
|
export async function writeAcceptDebts(cwd, debts) {
|
|
162
188
|
try {
|
|
@@ -184,14 +210,31 @@ export function isStaticClassDebt(reason) {
|
|
|
184
210
|
}
|
|
185
211
|
/**
|
|
186
212
|
* Re-check the ledger against the current run state. A static-class debt is RESOLVED
|
|
187
|
-
* iff the final gate's own static check now passes (`staticOk`);
|
|
188
|
-
*
|
|
189
|
-
*
|
|
213
|
+
* iff the final gate's own static check now passes (`staticOk`); a cross-task-deletion
|
|
214
|
+
* debt is RESOLVED iff the file it names is back in the tree (`fileExists` — a later
|
|
215
|
+
* task or a human restored it, so the deletion no longer holds); every other debt
|
|
216
|
+
* stays OPEN (unprovable ⇒ surface, never re-hide). FP-safe: the only auto-closes are
|
|
217
|
+
* ones a deterministic check can stand behind.
|
|
190
218
|
*/
|
|
191
219
|
export function recheckAcceptDebts(debts, opts) {
|
|
192
220
|
const open = [];
|
|
193
221
|
const resolved = [];
|
|
194
222
|
for (const d of debts) {
|
|
223
|
+
if (d.origin === 'cross-task-deletion') {
|
|
224
|
+
const p = extractDeletedDebtPath(d.reason);
|
|
225
|
+
let restored;
|
|
226
|
+
try {
|
|
227
|
+
restored = p !== null && opts.fileExists?.(p) === true;
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
restored = false; // an existence-check fault is inconclusive, not proof
|
|
231
|
+
}
|
|
232
|
+
if (restored)
|
|
233
|
+
resolved.push(d);
|
|
234
|
+
else
|
|
235
|
+
open.push(d);
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
195
238
|
if (opts.staticOk && isStaticClassDebt(d.reason))
|
|
196
239
|
resolved.push(d);
|
|
197
240
|
else
|
|
@@ -278,5 +321,8 @@ export function describeDebt(d) {
|
|
|
278
321
|
if (d.origin === 'frozen-blocked') {
|
|
279
322
|
return 'repo health blocked by a spec-frozen path (cross-task contradiction — no task may perform the fixing edit)';
|
|
280
323
|
}
|
|
324
|
+
if (d.origin === 'cross-task-deletion') {
|
|
325
|
+
return "a sibling task's committed deliverable was DELETED by this task's work and the deletion was accepted (still missing from the tree)";
|
|
326
|
+
}
|
|
281
327
|
return 'accepted despite verify-FAIL';
|
|
282
328
|
}
|
|
@@ -913,13 +913,30 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
|
|
|
913
913
|
// Hand the parent plan (the task list) to the gate so it can tell a
|
|
914
914
|
// served app from a CLI: the boot check requires a listener only for
|
|
915
915
|
// the former (mx5 run 10 — a CSS watcher satisfied "still alive").
|
|
916
|
+
// Trail EVERY aggregated failure entry (mx5 run 13): the gate now
|
|
917
|
+
// runs all sections and ranks the list; a single sliced reason
|
|
918
|
+
// line would re-hide everything past the first entry.
|
|
919
|
+
const trailGateFail = async (f) => {
|
|
920
|
+
const list = f.failures ?? [f.reason];
|
|
921
|
+
if (list.length <= 1) {
|
|
922
|
+
await recGate(`final-gate: FAIL — ${f.reason.slice(0, 300)}`);
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
await recGate(`final-gate: FAIL — ${list.length} failures (ranked, most load-bearing first)`);
|
|
926
|
+
for (const [i, entry] of list.entries()) {
|
|
927
|
+
await recGate(`final-gate FAIL ${i + 1}/${list.length}: ${entry.slice(0, 300)}`);
|
|
928
|
+
}
|
|
929
|
+
};
|
|
916
930
|
let fin = await deps.finalGate(cwd, body);
|
|
917
931
|
// Record the outcome symmetrically (mx5 run 10 item 7): only FAIL was
|
|
918
932
|
// ever trailed, so a PASSing gate was indistinguishable from a gate
|
|
919
933
|
// that never ran. The PASS reason names the commands that were run.
|
|
920
|
-
|
|
921
|
-
`final-gate: PASS — ${fin.reason.slice(0, 300)}`
|
|
922
|
-
|
|
934
|
+
if (fin.ok) {
|
|
935
|
+
await recGate(`final-gate: PASS — ${fin.reason.slice(0, 300)}`);
|
|
936
|
+
}
|
|
937
|
+
else {
|
|
938
|
+
await trailGateFail(fin);
|
|
939
|
+
}
|
|
923
940
|
// ACCEPT-debt re-check surfacing (mx5 run 4 B3 / run 8 TASK_0012):
|
|
924
941
|
// tasks the user accepted despite a verify-FAIL that the gate could
|
|
925
942
|
// not prove resolved against the current tree. Surface them at the
|
|
@@ -989,13 +1006,21 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
|
|
|
989
1006
|
active.ui.notify(`${id}: final-gate autofix did not converge — ${fix.reason.slice(0, 140)}`, 'warning');
|
|
990
1007
|
// Work from the FRESH gate failure when the fix pass got
|
|
991
1008
|
// as far as re-running the gate; otherwise keep the last.
|
|
992
|
-
// The
|
|
993
|
-
// the
|
|
1009
|
+
// The full ranked list rides along (and is re-trailed
|
|
1010
|
+
// when fresh) so the next picker and the next fix seed
|
|
1011
|
+
// still carry every entry, not just the first. The debt
|
|
1012
|
+
// note is carried so the next picker still shows the
|
|
1013
|
+
// open claims (the seed never includes it).
|
|
994
1014
|
fin = {
|
|
995
1015
|
ok: false,
|
|
996
1016
|
reason: fix.gateReason ?? fin.reason,
|
|
1017
|
+
failures: fix.gateReason !== undefined ? fix.gateFailures : fin.failures,
|
|
997
1018
|
debtNote: fin.debtNote
|
|
998
1019
|
};
|
|
1020
|
+
if (fix.gateReason !== undefined
|
|
1021
|
+
&& (fix.gateFailures?.length ?? 0) > 1) {
|
|
1022
|
+
await trailGateFail(fin);
|
|
1023
|
+
}
|
|
999
1024
|
continue;
|
|
1000
1025
|
}
|
|
1001
1026
|
// Leave failed — the dismissal default, unchanged from the
|
|
@@ -42,7 +42,10 @@ export declare function extractFailingCommand(reason: string): string | null;
|
|
|
42
42
|
/**
|
|
43
43
|
* Build the fix child's prompt. Generic by construction: the only project facts
|
|
44
44
|
* in it are the gate's own failure text — the command comes from the project's
|
|
45
|
-
* discovered manifest, never from a hardcoded ecosystem.
|
|
45
|
+
* discovered manifest, never from a hardcoded ecosystem. The seed may carry
|
|
46
|
+
* SEVERAL failures (the gate aggregates every section since mx5 run 13, ranked
|
|
47
|
+
* most load-bearing first); convergence means the WHOLE list is empty, so the
|
|
48
|
+
* child is told to fix all of them.
|
|
46
49
|
*/
|
|
47
50
|
export declare function buildFinalFixPrompt(failReason: string): string;
|
|
48
51
|
/**
|
|
@@ -63,6 +66,9 @@ export interface FinalFixResult {
|
|
|
63
66
|
/** On a did-not-converge outcome: the FRESH gate failure, so the caller's next
|
|
64
67
|
* picker (and next fix attempt) works from the current state, not the stale one. */
|
|
65
68
|
gateReason?: string;
|
|
69
|
+
/** The fresh gate's individual ranked failures (see FinalGateOutcome.failures),
|
|
70
|
+
* so the caller can trail each entry — never just the first. */
|
|
71
|
+
gateFailures?: string[];
|
|
66
72
|
}
|
|
67
73
|
export interface FinalFixDeps {
|
|
68
74
|
cwd: string;
|
|
@@ -72,10 +78,13 @@ export interface FinalFixDeps {
|
|
|
72
78
|
failReason: string;
|
|
73
79
|
/** Run the fix child; same closure shape the other gate children use. */
|
|
74
80
|
runChild: (tools: string, prompt: string, signal?: AbortSignal) => Promise<string>;
|
|
75
|
-
/** Re-run the final integration gate — the only arbiter of convergence.
|
|
81
|
+
/** Re-run the final integration gate — the only arbiter of convergence.
|
|
82
|
+
* Converges only when the gate's FULL aggregated failure list is empty
|
|
83
|
+
* (ok=true); `failures` rides through so the caller sees every entry. */
|
|
76
84
|
gate: (cwd: string) => Promise<{
|
|
77
85
|
ok: boolean;
|
|
78
86
|
reason: string;
|
|
87
|
+
failures?: string[];
|
|
79
88
|
}>;
|
|
80
89
|
/** Labels of every currently-discoverable gate command (static + integration),
|
|
81
90
|
* for the shrink guard. Pure discovery — nothing is executed. */
|
|
@@ -96,19 +96,24 @@ export function extractFailingCommand(reason) {
|
|
|
96
96
|
/**
|
|
97
97
|
* Build the fix child's prompt. Generic by construction: the only project facts
|
|
98
98
|
* in it are the gate's own failure text — the command comes from the project's
|
|
99
|
-
* discovered manifest, never from a hardcoded ecosystem.
|
|
99
|
+
* discovered manifest, never from a hardcoded ecosystem. The seed may carry
|
|
100
|
+
* SEVERAL failures (the gate aggregates every section since mx5 run 13, ranked
|
|
101
|
+
* most load-bearing first); convergence means the WHOLE list is empty, so the
|
|
102
|
+
* child is told to fix all of them.
|
|
100
103
|
*/
|
|
101
104
|
export function buildFinalFixPrompt(failReason) {
|
|
102
105
|
return [
|
|
103
106
|
'You are a bounded fix pass for a FAILED whole-repo integration gate.',
|
|
104
107
|
'Every task in this run is complete and committed; then the project’s own',
|
|
105
|
-
'integration
|
|
108
|
+
'integration commands were run against the assembled repository and failed:',
|
|
106
109
|
'',
|
|
107
110
|
failReason.trim(),
|
|
108
111
|
'',
|
|
109
|
-
'Your ONLY job is to
|
|
112
|
+
'Your ONLY job is to fix the DEFECT(s) those failures reveal. When several',
|
|
113
|
+
'failures are listed they are ranked most load-bearing first — fix ALL of',
|
|
114
|
+
'them; the gate only converges when every one passes.',
|
|
110
115
|
'',
|
|
111
|
-
'1. Re-run
|
|
116
|
+
'1. Re-run each exact failing command first and read its full output.',
|
|
112
117
|
'2. Diagnose the root cause, then fix it with the smallest correct change.',
|
|
113
118
|
' The project’s own manifests, configs and conventions define what',
|
|
114
119
|
' correct means — follow them, do not invent new structure.',
|
|
@@ -129,9 +134,9 @@ export function buildFinalFixPrompt(failReason) {
|
|
|
129
134
|
' revert, stash, clean). The work in this repository is finished and',
|
|
130
135
|
' committed — reverting it is destroying the run, not fixing it.',
|
|
131
136
|
'',
|
|
132
|
-
'4. Re-run the failing command after your fix and confirm
|
|
133
|
-
' gate is re-run mechanically after you finish — your claim is not
|
|
134
|
-
' verdict, the real exit
|
|
137
|
+
'4. Re-run the failing command(s) after your fix and confirm they exit 0.',
|
|
138
|
+
' The gate is re-run mechanically after you finish — your claim is not',
|
|
139
|
+
' the verdict, the real exit codes are.',
|
|
135
140
|
'',
|
|
136
141
|
'End with exactly one line:',
|
|
137
142
|
' FINAL-GATE-FIX: DONE',
|
|
@@ -240,7 +245,8 @@ export async function runFinalGateAutofix(deps) {
|
|
|
240
245
|
return {
|
|
241
246
|
ok: false,
|
|
242
247
|
reason: `did not converge: ${fin.reason}`,
|
|
243
|
-
gateReason: fin.reason
|
|
248
|
+
gateReason: fin.reason,
|
|
249
|
+
gateFailures: fin.failures
|
|
244
250
|
};
|
|
245
251
|
}
|
|
246
252
|
return { ok: true, reason: fin.reason };
|
|
@@ -1,18 +1,30 @@
|
|
|
1
1
|
import { type HealthCommand } from './repo-health-check.js';
|
|
2
2
|
import { type AcceptDebt } from './accept-debt.js';
|
|
3
3
|
import { type RenderOutcome } from './render-check.js';
|
|
4
|
+
import { taskThatIntroduced } from './task-provenance.js';
|
|
4
5
|
export interface FinalGateOutcome {
|
|
5
6
|
/** true → statics and every runnable integration command passed (or nothing to run). */
|
|
6
7
|
ok: boolean;
|
|
7
8
|
/**
|
|
8
|
-
* On a fail: the exact command,
|
|
9
|
-
* MECHANICAL
|
|
9
|
+
* On a fail: the exact command(s), exit code(s), and output tail(s) — the
|
|
10
|
+
* MECHANICAL failures only. The accept-debt note is deliberately NOT folded in
|
|
10
11
|
* here (mx5 run 11): this string seeds the final-gate AUTOFIX child's prompt,
|
|
11
12
|
* and a debt included there is read as an instruction — the run-11 fix child
|
|
12
13
|
* `rm`'d a sibling task's verified deliverable to satisfy a recorded claim. The
|
|
13
14
|
* child cannot act on text it never receives; debts travel in `debtNote`.
|
|
15
|
+
* With multiple failures this is the numbered, ranked list (see `failures`).
|
|
14
16
|
*/
|
|
15
17
|
reason: string;
|
|
18
|
+
/**
|
|
19
|
+
* On a fail: EVERY section failure individually, ranked most load-bearing
|
|
20
|
+
* first — boot/render ("the app does not serve/render") outranks any single
|
|
21
|
+
* test failure. The gate runs every section and aggregates rather than
|
|
22
|
+
* early-returning (mx5 run 13: a bun-test glob failure shadowed the boot +
|
|
23
|
+
* render probe, so the user accepted the FAIL having only ever seen 1 failing
|
|
24
|
+
* CT test while the shipped app 404'd on every non-API GET). Callers trail
|
|
25
|
+
* each entry and show the full list wherever an ACCEPT decision is made.
|
|
26
|
+
*/
|
|
27
|
+
failures?: string[];
|
|
16
28
|
/**
|
|
17
29
|
* Human-facing suffix listing the still-open accepted-defect claims (see
|
|
18
30
|
* buildAcceptDebtNote) — for the picker question and the trail, NEVER for the
|
|
@@ -140,19 +152,21 @@ export declare function discoverGateCommandLabels(cwd: string): string[];
|
|
|
140
152
|
* this box; the same wording in a `test` run is a real failure the suite must own).
|
|
141
153
|
*/
|
|
142
154
|
export declare const INFRA_GAP_OUTPUT_RE: RegExp;
|
|
143
|
-
|
|
144
|
-
* The task whose commit INTRODUCED `rel` (oldest `--diff-filter=A` commit whose
|
|
145
|
-
* subject carries the pi-task `(TASK_nnnn)` suffix — both the task snapshot and the
|
|
146
|
-
* ENFORCE commit shapes match). Null when the file predates the run, was never
|
|
147
|
-
* committed, git is unavailable, or the adding commit is not a task commit — every
|
|
148
|
-
* unknown degrades to "no conflict claim".
|
|
149
|
-
*/
|
|
150
|
-
export declare function taskThatIntroduced(cwd: string, rel: string): string | null;
|
|
155
|
+
export { taskThatIntroduced };
|
|
151
156
|
/**
|
|
152
157
|
* Run the final gate: static analysis first, then the lockfile consistency
|
|
153
158
|
* checks, then the discovered integration commands, then one boot exercise of
|
|
154
159
|
* the start command — whole-repo, verbatim, unaided. Deterministic (no model).
|
|
155
|
-
*
|
|
160
|
+
*
|
|
161
|
+
* EVERY section runs and failures AGGREGATE (mx5 run 13): the gate used to
|
|
162
|
+
* early-return on the first failing section, and the boot + render probe — built
|
|
163
|
+
* after run 11 exactly for "app serves blank/nothing" — was ordered last, so any
|
|
164
|
+
* earlier failure shadowed the most load-bearing signal. Run 13's user accepted
|
|
165
|
+
* the FAIL having seen only 1 failing CT test while the app 404'd on every
|
|
166
|
+
* non-API GET; boot/render never executed in any attempt. Now the outcome
|
|
167
|
+
* carries the full ranked failure list (boot/render first — "the app does not
|
|
168
|
+
* serve/render" outranks any single test), the ACCEPT decision is made on all of
|
|
169
|
+
* it, and autofix converges only when the whole list is empty. Per-section
|
|
170
|
+
* env-gap/INFRA_GAP skip semantics and orphan-port recovery are unchanged.
|
|
156
171
|
*/
|
|
157
172
|
export declare function runFinalIntegrationGate(cwd: string, timeoutMs?: number, bootGraceMs?: number, bootDeps?: BootDeps, planText?: string): Promise<FinalGateOutcome>;
|
|
158
|
-
export {};
|
package/dist/task/final-gate.js
CHANGED
|
@@ -46,6 +46,7 @@ import { readAcceptDebts, recheckAcceptDebts, writeAcceptDebts, buildAcceptDebtN
|
|
|
46
46
|
import { readDeclaredScripts, missingDeclaredScripts, runnableDeclaredScripts } from './launch-contract.js';
|
|
47
47
|
import { readEnvNotes, parseEnvNotes, isExcuseNote } from './env-notes.js';
|
|
48
48
|
import { runRenderCheck } from './render-check.js';
|
|
49
|
+
import { taskThatIntroduced } from './task-provenance.js';
|
|
49
50
|
function packageScripts(cwd) {
|
|
50
51
|
try {
|
|
51
52
|
const j = JSON.parse(readFileSync(path.join(cwd, 'package.json'), 'utf8'));
|
|
@@ -586,32 +587,25 @@ async function recoverOrphanPort(cwd, boot, first, bootGraceMs, deps, expectServ
|
|
|
586
587
|
await new Promise(r => setTimeout(r, 1_500));
|
|
587
588
|
return runBootCheck(cwd, boot, bootGraceMs, { expectServer, deps });
|
|
588
589
|
}
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
* committed, git is unavailable, or the adding commit is not a task commit — every
|
|
594
|
-
* unknown degrades to "no conflict claim".
|
|
595
|
-
*/
|
|
596
|
-
export function taskThatIntroduced(cwd, rel) {
|
|
597
|
-
const r = spawnSync('git', ['log', '--diff-filter=A', '--format=%s', '--', rel], {
|
|
598
|
-
cwd,
|
|
599
|
-
encoding: 'utf8'
|
|
600
|
-
});
|
|
601
|
-
if (r.error || r.status !== 0 || !r.stdout)
|
|
602
|
-
return null;
|
|
603
|
-
const subjects = r.stdout.trim().split('\n').filter(Boolean);
|
|
604
|
-
// Newest-first output; the LAST line is the original introduction (a
|
|
605
|
-
// delete-and-re-add later in history must not reattribute the file).
|
|
606
|
-
const first = subjects[subjects.length - 1] ?? '';
|
|
607
|
-
const m = /\((TASK_\d+)\)\s*$/.exec(first);
|
|
608
|
-
return m ? m[1] : null;
|
|
609
|
-
}
|
|
590
|
+
// File → introducing-task provenance moved to task-provenance.ts (mx5 run-12
|
|
591
|
+
// PROMPT 2 extracted it for the cross-task deletion guards); re-exported so
|
|
592
|
+
// existing importers keep working.
|
|
593
|
+
export { taskThatIntroduced };
|
|
610
594
|
/**
|
|
611
595
|
* Run the final gate: static analysis first, then the lockfile consistency
|
|
612
596
|
* checks, then the discovered integration commands, then one boot exercise of
|
|
613
597
|
* the start command — whole-repo, verbatim, unaided. Deterministic (no model).
|
|
614
|
-
*
|
|
598
|
+
*
|
|
599
|
+
* EVERY section runs and failures AGGREGATE (mx5 run 13): the gate used to
|
|
600
|
+
* early-return on the first failing section, and the boot + render probe — built
|
|
601
|
+
* after run 11 exactly for "app serves blank/nothing" — was ordered last, so any
|
|
602
|
+
* earlier failure shadowed the most load-bearing signal. Run 13's user accepted
|
|
603
|
+
* the FAIL having seen only 1 failing CT test while the app 404'd on every
|
|
604
|
+
* non-API GET; boot/render never executed in any attempt. Now the outcome
|
|
605
|
+
* carries the full ranked failure list (boot/render first — "the app does not
|
|
606
|
+
* serve/render" outranks any single test), the ACCEPT decision is made on all of
|
|
607
|
+
* it, and autofix converges only when the whole list is empty. Per-section
|
|
608
|
+
* env-gap/INFRA_GAP skip semantics and orphan-port recovery are unchanged.
|
|
615
609
|
*/
|
|
616
610
|
export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGraceMs = 10_000, bootDeps = {}, planText) {
|
|
617
611
|
const stat = runRepoHealthCheck(cwd);
|
|
@@ -624,7 +618,11 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
|
|
|
624
618
|
// construction (see accept-debt.ts). Best-effort: a ledger read/write failure
|
|
625
619
|
// must never break the gate.
|
|
626
620
|
const { open: openRaw, resolved } = recheckAcceptDebts(await readAcceptDebts(cwd), {
|
|
627
|
-
staticOk: stat.ok
|
|
621
|
+
staticOk: stat.ok,
|
|
622
|
+
// Cross-task-deletion debts auto-close iff the deleted file is back in the
|
|
623
|
+
// tree — a deterministic existence check, corroborating the per-file
|
|
624
|
+
// provenance the record already carries.
|
|
625
|
+
fileExists: rel => existsSync(path.join(cwd, rel))
|
|
628
626
|
});
|
|
629
627
|
if (resolved.length > 0)
|
|
630
628
|
await writeAcceptDebts(cwd, openRaw);
|
|
@@ -642,8 +640,15 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
|
|
|
642
640
|
...(debtNote ? { debtNote } : {}),
|
|
643
641
|
openDebts
|
|
644
642
|
});
|
|
643
|
+
// Aggregated failures across ALL sections (mx5 run 13 — see the function doc).
|
|
644
|
+
// rank 0 = boot/render ("does not serve/render" is the most load-bearing
|
|
645
|
+
// signal); rank 1 = everything else, kept in execution order by stable sort.
|
|
646
|
+
const failures = [];
|
|
647
|
+
const fail = (text, rank = 1) => {
|
|
648
|
+
failures.push({ rank, text });
|
|
649
|
+
};
|
|
645
650
|
if (!stat.ok)
|
|
646
|
-
|
|
651
|
+
fail(`static checks: ${stat.reason}`);
|
|
647
652
|
// Launch-contract diff (mx5 run 10 item 4): the design declared `migrate`/`seed`
|
|
648
653
|
// scripts that fell through decompose and shipped missing, unchecked. Diff the
|
|
649
654
|
// plan-time-extracted declared scripts against the manifest; a missing one is a
|
|
@@ -652,16 +657,13 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
|
|
|
652
657
|
if (declared.length > 0) {
|
|
653
658
|
const missing = missingDeclaredScripts(declared, Object.keys(packageScripts(cwd)));
|
|
654
659
|
if (missing.length > 0) {
|
|
655
|
-
|
|
656
|
-
ok: false,
|
|
657
|
-
reason: `launch contract: the design declares script(s) the shipped package.json does not expose: ${missing.join(', ')} (declared: ${declared.join(', ')})`
|
|
658
|
-
});
|
|
660
|
+
fail(`launch contract: the design declares script(s) the shipped package.json does not expose: ${missing.join(', ')} (declared: ${declared.join(', ')})`);
|
|
659
661
|
}
|
|
660
662
|
}
|
|
661
663
|
const lockCmds = discoverLockfileChecks(cwd);
|
|
662
664
|
const { cmds } = discoverIntegrationCommands(cwd);
|
|
663
665
|
const boot = discoverBootCommand(cwd);
|
|
664
|
-
if (lockCmds.length === 0 && cmds.length === 0 && !boot) {
|
|
666
|
+
if (lockCmds.length === 0 && cmds.length === 0 && !boot && failures.length === 0) {
|
|
665
667
|
return withDebts({ ok: true, reason: 'no integration command found (statics passed)' });
|
|
666
668
|
}
|
|
667
669
|
const ran = [];
|
|
@@ -675,10 +677,8 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
|
|
|
675
677
|
if (r.outcome === 'skip')
|
|
676
678
|
continue;
|
|
677
679
|
if (r.outcome === 'fail') {
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
reason: `${prefix}\`${label}\` exited ${r.status}${r.tail ? ` — ${r.tail}` : ''}`
|
|
681
|
-
});
|
|
680
|
+
fail(`${prefix}\`${label}\` exited ${r.status}${r.tail ? ` — ${r.tail}` : ''}`);
|
|
681
|
+
continue;
|
|
682
682
|
}
|
|
683
683
|
ran.push(label);
|
|
684
684
|
}
|
|
@@ -697,7 +697,13 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
|
|
|
697
697
|
if (declared.length > 0) {
|
|
698
698
|
const covered = cmds.flatMap(([bin, args]) => (bin === 'bun' || bin === 'npm') && args[0] === 'run' && args[1] ? [args[1]] : []);
|
|
699
699
|
const skippedLaunch = [];
|
|
700
|
+
// A declared script the manifest doesn't expose is already a launch-contract
|
|
701
|
+
// failure above; executing it too would double-report (pre-aggregation the
|
|
702
|
+
// contract diff early-returned, so this loop could assume presence).
|
|
703
|
+
const present = new Set(Object.keys(packageScripts(cwd)).map(s => s.toLowerCase()));
|
|
700
704
|
for (const name of runnableDeclaredScripts(declared, covered)) {
|
|
705
|
+
if (!present.has(name.toLowerCase()))
|
|
706
|
+
continue;
|
|
701
707
|
const cmd = ['bun', ['run', name]];
|
|
702
708
|
const label = `${cmd[0]} ${cmd[1].join(' ')}`;
|
|
703
709
|
const r = runGateCommand(cwd, cmd, Math.min(timeoutMs, 180_000), INFRA_GAP_OUTPUT_RE);
|
|
@@ -706,10 +712,8 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
|
|
|
706
712
|
continue;
|
|
707
713
|
}
|
|
708
714
|
if (r.outcome === 'fail') {
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
reason: `launch script: \`${label}\` exited ${r.status}${r.tail ? ` — ${r.tail}` : ''}`
|
|
712
|
-
});
|
|
715
|
+
fail(`launch script: \`${label}\` exited ${r.status}${r.tail ? ` — ${r.tail}` : ''}`);
|
|
716
|
+
continue;
|
|
713
717
|
}
|
|
714
718
|
ran.push(label);
|
|
715
719
|
}
|
|
@@ -726,6 +730,9 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
|
|
|
726
730
|
}
|
|
727
731
|
}
|
|
728
732
|
}
|
|
733
|
+
// Boot + render ALWAYS runs (mx5 run 13): it is independent of test results by
|
|
734
|
+
// construction, and it carries the run's most load-bearing signal — earlier
|
|
735
|
+
// failures no longer shadow it. Its failures rank FIRST in the aggregate.
|
|
729
736
|
if (boot) {
|
|
730
737
|
const label = `${boot[0]} ${boot[1].join(' ')}`;
|
|
731
738
|
const expectServer = detectsServedApp(cwd, planText);
|
|
@@ -746,21 +753,18 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
|
|
|
746
753
|
b = await recoverOrphanPort(cwd, boot, b, bootGraceMs, bootDepsWithRender, expectServer);
|
|
747
754
|
}
|
|
748
755
|
if (b.outcome === 'fail') {
|
|
749
|
-
|
|
756
|
+
fail(`boot check: \`${label}\` ${b.detail}`, 0);
|
|
750
757
|
}
|
|
751
|
-
if (b.outcome === 'orphan-port') {
|
|
758
|
+
else if (b.outcome === 'orphan-port') {
|
|
752
759
|
// Could not clear the port. Distinct HARNESS diagnosis, never a bare app
|
|
753
760
|
// FAIL: name the port and (when known) the process squatting on it.
|
|
754
761
|
const holder = b.port !== null ? (bootDeps.findPortHolder ?? defaultFindPortHolder)(b.port) : null;
|
|
755
762
|
const who = holder ? ` — held by an orphaned process (pid ${holder.pid}: ${holder.command})`
|
|
756
763
|
: b.port !== null ? ` — port ${b.port} is held by another process`
|
|
757
764
|
: '';
|
|
758
|
-
|
|
759
|
-
ok: false,
|
|
760
|
-
reason: `boot check: \`${label}\` could not bind: orphaned process / port already in use${who} (harness condition, not an app fault)`
|
|
761
|
-
});
|
|
765
|
+
fail(`boot check: \`${label}\` could not bind: orphaned process / port already in use${who} (harness condition, not an app fault)`, 0);
|
|
762
766
|
}
|
|
763
|
-
if (b.outcome === 'pass') {
|
|
767
|
+
else if (b.outcome === 'pass') {
|
|
764
768
|
ran.push(label);
|
|
765
769
|
// A listener that served, but whose page could not be OBSERVED to render
|
|
766
770
|
// (no browser, undeterminable port) → UNOBSERVED warning, not a silent pass.
|
|
@@ -768,6 +772,22 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
|
|
|
768
772
|
warnings.push(b.renderNote);
|
|
769
773
|
}
|
|
770
774
|
}
|
|
775
|
+
if (failures.length > 0) {
|
|
776
|
+
// Stable sort: boot/render (rank 0) leads, everything else keeps execution
|
|
777
|
+
// order. One failure keeps the exact single-failure wording; several become
|
|
778
|
+
// a numbered list so the trail, the ACCEPT picker, and the autofix seed all
|
|
779
|
+
// carry the complete ranked picture.
|
|
780
|
+
const texts = [...failures].sort((a, b) => a.rank - b.rank).map(f => f.text);
|
|
781
|
+
return withDebts({
|
|
782
|
+
ok: false,
|
|
783
|
+
reason: texts.length === 1 ?
|
|
784
|
+
texts[0]
|
|
785
|
+
: `${texts.length} failures (ranked, most load-bearing first):\n${texts
|
|
786
|
+
.map((t, i) => `${i + 1}. ${t}`)
|
|
787
|
+
.join('\n')}`,
|
|
788
|
+
failures: texts
|
|
789
|
+
});
|
|
790
|
+
}
|
|
771
791
|
const warningNote = warnings.length > 0 ? ` — WARNING: ${warnings.join('; WARNING: ')}` : '';
|
|
772
792
|
return withDebts({
|
|
773
793
|
ok: true,
|
package/dist/task/gate-deps.d.ts
CHANGED
|
@@ -40,6 +40,14 @@ export declare function collectAddedLines(cwd: string, signal?: AbortSignal): Pr
|
|
|
40
40
|
* Failures degrade to an empty summary — the guard then has nothing to reject.
|
|
41
41
|
*/
|
|
42
42
|
export declare function collectTreeChanges(cwd: string, signal?: AbortSignal): Promise<TreeChangeSummary>;
|
|
43
|
+
/**
|
|
44
|
+
* The task's changes for the cross-task deletion probe: the working tree's status
|
|
45
|
+
* when the work is uncommitted (pre-commit verify), else the LAST COMMIT's
|
|
46
|
+
* name-status diff (the post-enforce re-verify runs on a clean tree, where that
|
|
47
|
+
* commit IS the task's work). Failures degrade to an empty summary — the probe is
|
|
48
|
+
* a sharpener, never a blocker. Same fallback discipline as collectChangedFiles.
|
|
49
|
+
*/
|
|
50
|
+
export declare function collectTaskTreeChanges(cwd: string, signal?: AbortSignal): Promise<TreeChangeSummary>;
|
|
43
51
|
/**
|
|
44
52
|
* Build the gate deps for one command run. `runTask` is the orchestrator's
|
|
45
53
|
* implementation re-runner, injected by the caller. The returned object also drives
|
package/dist/task/gate-deps.js
CHANGED
|
@@ -21,7 +21,7 @@ import { runGuidelineEnforcement, classifyEnforceChildFailure } from './enforce-
|
|
|
21
21
|
import { runWorkVerification, extractSpecForVerification } from './verify-work.js';
|
|
22
22
|
import { readEnvNotes, appendEnvNotes } from './env-notes.js';
|
|
23
23
|
import { readContracts } from './contracts.js';
|
|
24
|
-
import { recordAcceptDebt, recordEnforceRevertDebt, recordFrozenBlockedDebt } from './accept-debt.js';
|
|
24
|
+
import { recordAcceptDebt, recordEnforceRevertDebt, recordFrozenBlockedDebt, recordCrossTaskDeletionDebt } from './accept-debt.js';
|
|
25
25
|
import { runRepoHealthCheck } from './repo-health-check.js';
|
|
26
26
|
import { runFinalIntegrationGate, discoverGateCommandLabels } from './final-gate.js';
|
|
27
27
|
import { runFinalGateAutofix } from './final-gate-fix.js';
|
|
@@ -30,7 +30,8 @@ import { extractProhibitions, findProhibitionViolations } from './prohibition-pr
|
|
|
30
30
|
import { frozenPathsFromSpec, revertFrozenPaths } from './frozen-path-guard.js';
|
|
31
31
|
import { findProbeGaming, parseAddedLines } from './probe-gaming.js';
|
|
32
32
|
import { findSubstitutionSuspects, isTestFile } from './substitution-probe.js';
|
|
33
|
-
import { parseTreeChanges, formatTreeChanges } from './write-guard.js';
|
|
33
|
+
import { parseTreeChanges, parseNameStatusChanges, formatTreeChanges } from './write-guard.js';
|
|
34
|
+
import { taskThatIntroduced, findCrossTaskDeletions } from './task-provenance.js';
|
|
34
35
|
import { findTestRebuiltAssemblies, testAssemblyVerifyFindings } from './test-assembly.js';
|
|
35
36
|
import { runBoundedLintFix } from './lint-fix.js';
|
|
36
37
|
import { captureGitState, reconcileGitState } from './git-state-guard.js';
|
|
@@ -131,6 +132,20 @@ export async function collectTreeChanges(cwd, signal) {
|
|
|
131
132
|
const r = await git(cwd, ['status', '--porcelain', '--', '.', EXCLUDE_TASKS_DIR], signal);
|
|
132
133
|
return r.exitCode === 0 ? parseTreeChanges(r.stdout) : { modified: [], deleted: [], added: [] };
|
|
133
134
|
}
|
|
135
|
+
/**
|
|
136
|
+
* The task's changes for the cross-task deletion probe: the working tree's status
|
|
137
|
+
* when the work is uncommitted (pre-commit verify), else the LAST COMMIT's
|
|
138
|
+
* name-status diff (the post-enforce re-verify runs on a clean tree, where that
|
|
139
|
+
* commit IS the task's work). Failures degrade to an empty summary — the probe is
|
|
140
|
+
* a sharpener, never a blocker. Same fallback discipline as collectChangedFiles.
|
|
141
|
+
*/
|
|
142
|
+
export async function collectTaskTreeChanges(cwd, signal) {
|
|
143
|
+
const now = await collectTreeChanges(cwd, signal);
|
|
144
|
+
if (now.modified.length + now.deleted.length + now.added.length > 0)
|
|
145
|
+
return now;
|
|
146
|
+
const last = await git(cwd, ['diff', '--name-status', 'HEAD~1..HEAD', '--', '.', EXCLUDE_TASKS_DIR], signal);
|
|
147
|
+
return last.exitCode === 0 ? parseNameStatusChanges(last.stdout) : now;
|
|
148
|
+
}
|
|
134
149
|
/** Source extensions whose relative imports the test-assembly probe reasons over. */
|
|
135
150
|
const SOURCE_EXT_RE = /\.(?:[cm]?[jt]sx?)$/;
|
|
136
151
|
/** Bounds so the probe stays cheap on large repos (it reads file text). */
|
|
@@ -318,6 +333,10 @@ export function buildGateDeps(params) {
|
|
|
318
333
|
// FAIL whose only fix is an edit to a path this task's spec froze — recorded
|
|
319
334
|
// when the gate loop routes it to the picker, re-checked by the final gate.
|
|
320
335
|
recordFrozenBlockedDebt: (cwd2, taskId, reason) => recordFrozenBlockedDebt(cwd2, taskId, reason),
|
|
336
|
+
// Durable cross-task-deletion ledger (PROMPT 2): a sibling's committed
|
|
337
|
+
// deliverable this task's diff deletes, ACCEPTed into a commit anyway —
|
|
338
|
+
// the final gate re-checks it (resolved iff the file is back in the tree).
|
|
339
|
+
recordCrossTaskDeletionDebt: (cwd2, taskId, deletion) => recordCrossTaskDeletionDebt(cwd2, taskId, deletion),
|
|
321
340
|
// Frozen-path write-deny (see frozen-path-guard.ts): the concrete paths this
|
|
322
341
|
// task's spec forbids modifying, so the gate sequence can UNDO any edit the
|
|
323
342
|
// enforce EDIT pass makes to them before those edits are committed. Reads the
|
|
@@ -457,6 +476,12 @@ export function buildGateDeps(params) {
|
|
|
457
476
|
// ("return 401 so the verification test passes") become rule-4c
|
|
458
477
|
// findings so the child verifies the real requirement, not the check.
|
|
459
478
|
probeGamingProbe: () => collectAddedLines(cwd2, signal).then(findProbeGaming),
|
|
479
|
+
// Deterministic cross-task deletion probe (mx5 run 12 PROMPT 2):
|
|
480
|
+
// tracked files this task's diff DELETES whose introducing commit
|
|
481
|
+
// belongs to a DIFFERENT task — a sibling's committed deliverable
|
|
482
|
+
// destroyed (typically to green a check). Injected under rule 4d and
|
|
483
|
+
// carried on a FAIL so an ACCEPT records durable debts.
|
|
484
|
+
crossTaskDeletionProbe: () => collectTaskTreeChanges(cwd2, signal).then(changes => findCrossTaskDeletions(changes, taskId, rel => taskThatIntroduced(cwd2, rel))),
|
|
460
485
|
// Deterministic prohibition probe: paths the spec forbids modifying
|
|
461
486
|
// that the task's diff modified anyway become prompt-level findings
|
|
462
487
|
// under the no-waiver rule — the child otherwise rarely runs `git
|
|
@@ -520,7 +545,14 @@ export function buildGateDeps(params) {
|
|
|
520
545
|
const r = await git(cwd2, args, signal);
|
|
521
546
|
return { exitCode: r.exitCode, stdout: r.stdout };
|
|
522
547
|
},
|
|
523
|
-
frozenPaths
|
|
548
|
+
frozenPaths,
|
|
549
|
+
// Cross-task deletion guard (mx5 run 12 PROMPT 2): the revert-guard
|
|
550
|
+
// is blind to a clean tracked sibling deliverable, so deleting one
|
|
551
|
+
// greens the lint and the pass returns ok. Provenance is the
|
|
552
|
+
// discriminator — the guard restores a sibling's deleted file and
|
|
553
|
+
// reports not-applied naming the owner.
|
|
554
|
+
currentTaskId: taskId,
|
|
555
|
+
introducedBy: rel => Promise.resolve(taskThatIntroduced(cwd2, rel))
|
|
524
556
|
});
|
|
525
557
|
},
|
|
526
558
|
// Deterministic static check + tree helpers for the enforce pre-commit gate.
|
package/dist/task/lint-fix.d.ts
CHANGED
|
@@ -31,6 +31,17 @@ export interface LintFixDeps {
|
|
|
31
31
|
* and the fix reported not-applied. Absent/empty → no-op, prior behavior.
|
|
32
32
|
*/
|
|
33
33
|
frozenPaths?: string[];
|
|
34
|
+
/**
|
|
35
|
+
* The CURRENT task's id, for the cross-task deletion guard's provenance
|
|
36
|
+
* discriminator. Absent → the guard is disarmed (prior behavior).
|
|
37
|
+
*/
|
|
38
|
+
currentTaskId?: string;
|
|
39
|
+
/**
|
|
40
|
+
* file → introducing task id (git provenance, see task-provenance.ts); null on
|
|
41
|
+
* any unknown — inconclusive is never evidence. Absent → the cross-task
|
|
42
|
+
* deletion guard is disarmed (prior behavior).
|
|
43
|
+
*/
|
|
44
|
+
introducedBy?: (rel: string) => Promise<string | null>;
|
|
34
45
|
}
|
|
35
46
|
/** The fix child edits and runs the checker; bash exists to RUN the check, not git. */
|
|
36
47
|
export declare const LINT_FIX_TOOLS = "read,edit,bash";
|
package/dist/task/lint-fix.js
CHANGED
|
@@ -43,8 +43,23 @@
|
|
|
43
43
|
* that were CLEAN before the child ran are reverted — a frozen path already
|
|
44
44
|
* dirty with (possibly task) work is left alone, in the guard's safe direction:
|
|
45
45
|
* cost time, never work.
|
|
46
|
+
*
|
|
47
|
+
* CROSS-TASK DELETION GUARD (mx5 run 12 PROMPT 2, verify-debug.log 17:53:44): the
|
|
48
|
+
* revert-guard above is outcome-based but blind to a CLEAN tracked file — a
|
|
49
|
+
* sibling task's committed deliverable is neither pre-dirty nor pre-untracked, so
|
|
50
|
+
* DELETING it slips both checks, and if the lint converges the pass returns ok
|
|
51
|
+
* (confirmed live: the child deleted TASK_0020's playwright ct files to green a
|
|
52
|
+
* typed-lint it was frozen out of fixing properly). The discriminator is
|
|
53
|
+
* PROVENANCE, not deletion per se: a tracked file the CHILD deleted whose
|
|
54
|
+
* introducing task (per `introducedBy`, git history) differs from the CURRENT
|
|
55
|
+
* task is restored from HEAD and the fix reported not-applied naming the owner.
|
|
56
|
+
* Same-task deletions, unknown provenance, relocations, and any git error all
|
|
57
|
+
* step aside — the guard may only cost time, never work. Armed only when both
|
|
58
|
+
* `currentTaskId` and `introducedBy` are wired.
|
|
46
59
|
*/
|
|
47
60
|
import { parseChangedFrozenFiles, pathNamedIn, revertFrozenPaths } from './frozen-path-guard.js';
|
|
61
|
+
import { parseTreeChanges } from './write-guard.js';
|
|
62
|
+
import { findCrossTaskDeletions } from './task-provenance.js';
|
|
48
63
|
/** The fix child edits and runs the checker; bash exists to RUN the check, not git. */
|
|
49
64
|
export const LINT_FIX_TOOLS = 'read,edit,bash';
|
|
50
65
|
/**
|
|
@@ -121,6 +136,17 @@ async function dirtyFiles(deps) {
|
|
|
121
136
|
.map(l => l.trim())
|
|
122
137
|
.filter(l => l.length > 0);
|
|
123
138
|
}
|
|
139
|
+
/**
|
|
140
|
+
* The whole tree's current changes (`git status --porcelain` shape), or null when
|
|
141
|
+
* git itself failed — inconclusive, not evidence (same discipline as dirtyFiles).
|
|
142
|
+
* Feeds the cross-task deletion guard's pre/post comparison.
|
|
143
|
+
*/
|
|
144
|
+
async function treeChanges(deps) {
|
|
145
|
+
const r = await deps.git(['status', '--porcelain', '--', '.', EXCLUDE_TASKS_DIR]);
|
|
146
|
+
if (r.exitCode !== 0)
|
|
147
|
+
return null;
|
|
148
|
+
return parseTreeChanges(r.stdout);
|
|
149
|
+
}
|
|
124
150
|
/**
|
|
125
151
|
* Which spec-frozen paths currently show a change in `git status`? Null when git
|
|
126
152
|
* itself failed — inconclusive, not evidence (same discipline as dirtyFiles).
|
|
@@ -172,6 +198,12 @@ export async function runBoundedLintFix(deps) {
|
|
|
172
198
|
// here disables the guard for this run (inconclusive ≠ license to revert).
|
|
173
199
|
const frozen = deps.frozenPaths ?? [];
|
|
174
200
|
const preFrozenDirty = await frozenDirtySet(deps, frozen);
|
|
201
|
+
// CROSS-TASK DELETION baseline: deletions already in the tree pre-child are
|
|
202
|
+
// the task's own (uncommitted) work, not the child's — only deletions the
|
|
203
|
+
// CHILD introduces on top are candidates. A git failure here disarms the
|
|
204
|
+
// guard for this run (inconclusive ≠ evidence).
|
|
205
|
+
const deletionGuardArmed = Boolean(deps.currentTaskId && deps.introducedBy);
|
|
206
|
+
const preChanges = deletionGuardArmed ? await treeChanges(deps) : null;
|
|
175
207
|
try {
|
|
176
208
|
await deps.runChild(LINT_FIX_TOOLS, buildLintFixPrompt(deps.failReason, frozen), deps.signal);
|
|
177
209
|
}
|
|
@@ -224,6 +256,36 @@ export async function runBoundedLintFix(deps) {
|
|
|
224
256
|
+ `${violations.length > 3 ? ', …' : ''}) — fix ${snapshot ? 'rolled back' : 'REJECTED but no snapshot to restore'}`
|
|
225
257
|
};
|
|
226
258
|
}
|
|
259
|
+
// CROSS-TASK DELETION GUARD: a tracked file the CHILD deleted whose
|
|
260
|
+
// introducing task differs from the current task is a sibling's committed
|
|
261
|
+
// deliverable destroyed to go green (mx5 run 12: the child deleted TASK_0020's
|
|
262
|
+
// playwright ct files and the pass returned ok — the revert-guard above only
|
|
263
|
+
// watches pre-dirty and pre-untracked files, and a clean tracked sibling file
|
|
264
|
+
// is neither). Restore JUST those paths from HEAD (they were clean pre-child,
|
|
265
|
+
// so nothing of the task's work can be lost) and report not-applied naming the
|
|
266
|
+
// owner. Inconclusive on any side — git error, unknown provenance, same-task
|
|
267
|
+
// deletion, relocation — steps aside.
|
|
268
|
+
if (deletionGuardArmed && preChanges !== null) {
|
|
269
|
+
const postChanges = await treeChanges(deps);
|
|
270
|
+
if (postChanges !== null) {
|
|
271
|
+
const preDeleted = new Set(preChanges.deleted);
|
|
272
|
+
const crossDeletions = await findCrossTaskDeletions({
|
|
273
|
+
modified: postChanges.modified,
|
|
274
|
+
added: postChanges.added,
|
|
275
|
+
deleted: postChanges.deleted.filter(p => !preDeleted.has(p))
|
|
276
|
+
}, deps.currentTaskId, deps.introducedBy);
|
|
277
|
+
if (crossDeletions.length > 0) {
|
|
278
|
+
await deps.git(['checkout', '-f', 'HEAD', '--', ...crossDeletions.map(d => d.path)]);
|
|
279
|
+
const named = crossDeletions.map(d => `${d.path} — ${d.owner}'s deliverable`);
|
|
280
|
+
return {
|
|
281
|
+
ok: false,
|
|
282
|
+
reason: `cross-task-deletion: fix child DELETED sibling task deliverable(s) `
|
|
283
|
+
+ `(${named.slice(0, 3).join('; ')}${crossDeletions.length > 3 ? '; …' : ''}) `
|
|
284
|
+
+ `— restored from HEAD; deleting another task's committed work is not a fix`
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
227
289
|
// FROZEN-PATH GUARD: a frozen path that was clean pre-child and is changed
|
|
228
290
|
// now is the child's edit — the exact write verify's rule-4b prohibition
|
|
229
291
|
// probe is guaranteed to fail the TASK for (mx5 run 12: ESLint's own error
|
|
@@ -143,6 +143,17 @@ export interface GateDeps {
|
|
|
143
143
|
* it), so the final gate re-checks it at run end. Best-effort; absent in tests.
|
|
144
144
|
*/
|
|
145
145
|
recordFrozenBlockedDebt?: (cwd: string, taskId: string, reason: string) => Promise<void>;
|
|
146
|
+
/**
|
|
147
|
+
* Record a durable CROSS-TASK DELETION debt (mx5 run 12 PROMPT 2): the task's
|
|
148
|
+
* work deleted a file a DIFFERENT task's commit introduced, verify FAILed with
|
|
149
|
+
* the deterministic finding attached, and the user ACCEPTed anyway — the
|
|
150
|
+
* deletion ships in the next commit, so the final gate must re-check it
|
|
151
|
+
* (resolved iff the file is back in the tree). Best-effort; absent in tests.
|
|
152
|
+
*/
|
|
153
|
+
recordCrossTaskDeletionDebt?: (cwd: string, taskId: string, deletion: {
|
|
154
|
+
path: string;
|
|
155
|
+
owner: string;
|
|
156
|
+
}) => Promise<void>;
|
|
146
157
|
/**
|
|
147
158
|
* The concrete paths this task's spec forbids modifying (its `Do NOT modify`
|
|
148
159
|
* CONSTRAINTS — see frozen-path-guard.ts / prohibition-probe.ts). Used to
|
package/dist/task/task-gates.js
CHANGED
|
@@ -208,6 +208,18 @@ export async function runGatesForTask(ctxIn, deps, p) {
|
|
|
208
208
|
// recording must never break the gate sequence
|
|
209
209
|
}
|
|
210
210
|
}
|
|
211
|
+
// Cross-task deletions the verify probe detected ship in the next
|
|
212
|
+
// commit with this ACCEPT — record each as its own durable debt so
|
|
213
|
+
// the final gate re-checks them (mx5 run 12 PROMPT 2). Best-effort.
|
|
214
|
+
for (const del of verified.crossTaskDeletions ?? []) {
|
|
215
|
+
try {
|
|
216
|
+
await deps.recordCrossTaskDeletionDebt?.(p.cwd, p.taskId, del);
|
|
217
|
+
await rec(`accept-debt: cross-task deletion recorded — ${del.path} (${del.owner}'s deliverable)`);
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
// recording must never break the gate sequence
|
|
221
|
+
}
|
|
222
|
+
}
|
|
211
223
|
active.ui.notify(`${p.tag}: accepted "${p.title}" despite verify FAIL (${failReason.slice(0, 120)}) — proceeding.`, 'warning');
|
|
212
224
|
break;
|
|
213
225
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { type TreeChangeSummary } from './write-guard.js';
|
|
2
|
+
/** Run a git subcommand; only stdout + exit code are read (injectable for tests). */
|
|
3
|
+
export type ProvenanceGit = (args: string[]) => Promise<{
|
|
4
|
+
stdout: string;
|
|
5
|
+
exitCode: number;
|
|
6
|
+
}>;
|
|
7
|
+
/**
|
|
8
|
+
* The task id carried by the OLDEST commit subject in `git log --diff-filter=A
|
|
9
|
+
* --format=%s` output (newest-first, so the LAST line is the original
|
|
10
|
+
* introduction — a delete-and-re-add later in history must not reattribute the
|
|
11
|
+
* file). Both the task snapshot and the ENFORCE commit shapes match the
|
|
12
|
+
* `(TASK_nnnn)` suffix. Null when the output is empty or the adding commit is
|
|
13
|
+
* not a task commit.
|
|
14
|
+
*/
|
|
15
|
+
export declare function parseIntroducingTask(logSubjects: string): string | null;
|
|
16
|
+
/**
|
|
17
|
+
* The task whose commit INTRODUCED `rel`. Null when the file predates the run,
|
|
18
|
+
* was never committed, git is unavailable, or the adding commit is not a task
|
|
19
|
+
* commit — every unknown degrades to "no provenance claim".
|
|
20
|
+
*/
|
|
21
|
+
export declare function taskThatIntroduced(cwd: string, rel: string): string | null;
|
|
22
|
+
/** Same lookup through an injected async git (the lint-fix deps shape). */
|
|
23
|
+
export declare function taskThatIntroducedVia(git: ProvenanceGit, rel: string): Promise<string | null>;
|
|
24
|
+
/** One detected cross-task deletion: the file and the task whose commit introduced it. */
|
|
25
|
+
export interface CrossTaskDeletion {
|
|
26
|
+
path: string;
|
|
27
|
+
owner: string;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Deterministic cross-task deletion detection: a tracked file DELETED in the
|
|
31
|
+
* given change summary whose introducing task differs from the CURRENT task is a
|
|
32
|
+
* finding. Everything else steps aside by construction — same-task deletions and
|
|
33
|
+
* unknown provenance are never findings (a task refactoring its own work must not
|
|
34
|
+
* be blocked), an unknown CURRENT task id disables the check ("differs" cannot be
|
|
35
|
+
* established), and a relocation (the same file name reappears among the added
|
|
36
|
+
* files — write-guard's allowance) is not a deletion. `introducedBy` may be sync
|
|
37
|
+
* or async and may throw; a throw reads as unknown provenance.
|
|
38
|
+
*/
|
|
39
|
+
export declare function findCrossTaskDeletions(changes: TreeChangeSummary, currentTaskId: string, introducedBy: (rel: string) => string | null | Promise<string | null>): Promise<CrossTaskDeletion[]>;
|
|
40
|
+
/**
|
|
41
|
+
* Finding lines for the verify prompt (the substitution-probe injection pattern:
|
|
42
|
+
* the rule alone is A/B-proven ignored, the rule plus a concrete finding naming
|
|
43
|
+
* the file is what lands).
|
|
44
|
+
*/
|
|
45
|
+
export declare function crossTaskDeletionVerifyFindings(found: CrossTaskDeletion[]): string[];
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* task-provenance — file → introducing-task lookup, and the cross-task deletion
|
|
3
|
+
* detection built on it (mx5 run-12 PROMPT 2).
|
|
4
|
+
*
|
|
5
|
+
* The failure class: write-enabled gate/fix children can DELETE a sibling task's
|
|
6
|
+
* committed deliverable to turn a red check green. Live (mx5 2026-07-16,
|
|
7
|
+
* verify-debug.log 17:53:44): `bun run lint` was permanently red because
|
|
8
|
+
* committed playwright ct files were not registered in a spec-frozen tsconfig —
|
|
9
|
+
* so a lint-fix child deleted TASK_0020's verified deliverables and the pass
|
|
10
|
+
* returned ok. Every existing guard missed the class:
|
|
11
|
+
*
|
|
12
|
+
* - the lint-fix revert-guard checks only pre-DIRTY files reverted and
|
|
13
|
+
* pre-UNTRACKED files gone — a CLEAN tracked sibling file is in neither set;
|
|
14
|
+
* - the frozen-path guard covers only paths THIS task's spec froze;
|
|
15
|
+
* - the run-11 deletion guard covers only the final-fix child;
|
|
16
|
+
* - the impl turn legitimately deletes files (its own refactors), so a blanket
|
|
17
|
+
* ban is wrong — the discriminator must be PROVENANCE: who introduced the file.
|
|
18
|
+
*
|
|
19
|
+
* The provenance primitive (extracted from final-gate.ts, where it fed the
|
|
20
|
+
* conflicting-claim annotation) resolves a file to the task whose commit ADDED it
|
|
21
|
+
* (`git log --diff-filter=A` → oldest task-subject commit). Every unknown — a file
|
|
22
|
+
* predating the run, a non-task commit, any git error — degrades to null:
|
|
23
|
+
* inconclusive is never evidence, so the guards built on this may only cost time,
|
|
24
|
+
* never work.
|
|
25
|
+
*/
|
|
26
|
+
import { spawnSync } from 'node:child_process';
|
|
27
|
+
import { findForbiddenDeletions } from './write-guard.js';
|
|
28
|
+
/**
|
|
29
|
+
* The task id carried by the OLDEST commit subject in `git log --diff-filter=A
|
|
30
|
+
* --format=%s` output (newest-first, so the LAST line is the original
|
|
31
|
+
* introduction — a delete-and-re-add later in history must not reattribute the
|
|
32
|
+
* file). Both the task snapshot and the ENFORCE commit shapes match the
|
|
33
|
+
* `(TASK_nnnn)` suffix. Null when the output is empty or the adding commit is
|
|
34
|
+
* not a task commit.
|
|
35
|
+
*/
|
|
36
|
+
export function parseIntroducingTask(logSubjects) {
|
|
37
|
+
const subjects = logSubjects.trim().split('\n').filter(Boolean);
|
|
38
|
+
const first = subjects[subjects.length - 1] ?? '';
|
|
39
|
+
const m = /\((TASK_\d+)\)\s*$/.exec(first);
|
|
40
|
+
return m ? m[1] : null;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The task whose commit INTRODUCED `rel`. Null when the file predates the run,
|
|
44
|
+
* was never committed, git is unavailable, or the adding commit is not a task
|
|
45
|
+
* commit — every unknown degrades to "no provenance claim".
|
|
46
|
+
*/
|
|
47
|
+
export function taskThatIntroduced(cwd, rel) {
|
|
48
|
+
const r = spawnSync('git', ['log', '--diff-filter=A', '--format=%s', '--', rel], {
|
|
49
|
+
cwd,
|
|
50
|
+
encoding: 'utf8'
|
|
51
|
+
});
|
|
52
|
+
if (r.error || r.status !== 0 || !r.stdout)
|
|
53
|
+
return null;
|
|
54
|
+
return parseIntroducingTask(r.stdout);
|
|
55
|
+
}
|
|
56
|
+
/** Same lookup through an injected async git (the lint-fix deps shape). */
|
|
57
|
+
export async function taskThatIntroducedVia(git, rel) {
|
|
58
|
+
const r = await git(['log', '--diff-filter=A', '--format=%s', '--', rel]);
|
|
59
|
+
if (r.exitCode !== 0 || !r.stdout)
|
|
60
|
+
return null;
|
|
61
|
+
return parseIntroducingTask(r.stdout);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Deterministic cross-task deletion detection: a tracked file DELETED in the
|
|
65
|
+
* given change summary whose introducing task differs from the CURRENT task is a
|
|
66
|
+
* finding. Everything else steps aside by construction — same-task deletions and
|
|
67
|
+
* unknown provenance are never findings (a task refactoring its own work must not
|
|
68
|
+
* be blocked), an unknown CURRENT task id disables the check ("differs" cannot be
|
|
69
|
+
* established), and a relocation (the same file name reappears among the added
|
|
70
|
+
* files — write-guard's allowance) is not a deletion. `introducedBy` may be sync
|
|
71
|
+
* or async and may throw; a throw reads as unknown provenance.
|
|
72
|
+
*/
|
|
73
|
+
export async function findCrossTaskDeletions(changes, currentTaskId, introducedBy) {
|
|
74
|
+
const current = currentTaskId.trim();
|
|
75
|
+
if (current.length === 0)
|
|
76
|
+
return [];
|
|
77
|
+
const out = [];
|
|
78
|
+
for (const p of findForbiddenDeletions(changes)) {
|
|
79
|
+
let owner;
|
|
80
|
+
try {
|
|
81
|
+
owner = await introducedBy(p);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
owner = null;
|
|
85
|
+
}
|
|
86
|
+
if (owner && owner !== current)
|
|
87
|
+
out.push({ path: p, owner });
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Finding lines for the verify prompt (the substitution-probe injection pattern:
|
|
93
|
+
* the rule alone is A/B-proven ignored, the rule plus a concrete finding naming
|
|
94
|
+
* the file is what lands).
|
|
95
|
+
*/
|
|
96
|
+
export function crossTaskDeletionVerifyFindings(found) {
|
|
97
|
+
return found.map(f => `\`${f.path}\` — introduced and committed by ${f.owner}, DELETED by this task's work`);
|
|
98
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type CrossTaskDeletion } from './task-provenance.js';
|
|
1
2
|
/**
|
|
2
3
|
* The verification child gets exactly two tools: `read` and `bash`.
|
|
3
4
|
*
|
|
@@ -27,6 +28,12 @@ export interface VerifyOutcome {
|
|
|
27
28
|
* AUTOFIX re-run, which cannot provision a missing tool. Only meaningful when
|
|
28
29
|
* ok === false. */
|
|
29
30
|
unobserved?: boolean;
|
|
31
|
+
/** The deterministic cross-task deletion findings (see task-provenance.ts) that
|
|
32
|
+
* were live when this verdict was produced: sibling tasks' committed
|
|
33
|
+
* deliverables this task's diff deletes. Carried on a FAIL so the gate loop
|
|
34
|
+
* can record each as a durable debt if the user ACCEPTs anyway — the deletion
|
|
35
|
+
* then ships in the next commit and the final gate must re-check it. */
|
|
36
|
+
crossTaskDeletions?: CrossTaskDeletion[];
|
|
30
37
|
}
|
|
31
38
|
/**
|
|
32
39
|
* Slice the delivered spec (GOAL / CONSTRAINTS / ACCEPTANCE / VERIFY) out of a
|
|
@@ -61,7 +68,7 @@ export declare function extractSpecForVerification(taskBody: string): string | n
|
|
|
61
68
|
* Guard: honest-clean fixture (prohibition in spec, probe silent) 5/5 PASS — no
|
|
62
69
|
* paranoia. Reverted-violation ≡ clean at the diff level (no entry → no finding).
|
|
63
70
|
*/
|
|
64
|
-
export declare function buildVerifyPrompt(spec: string, probeFindings?: string[], envNotes?: string, prohibitionFindings?: string[], skipEscapeFindings?: string[], contracts?: string, testAssemblyFindings?: string[], probeGamingFindings?: string[]): string;
|
|
71
|
+
export declare function buildVerifyPrompt(spec: string, probeFindings?: string[], envNotes?: string, prohibitionFindings?: string[], skipEscapeFindings?: string[], contracts?: string, testAssemblyFindings?: string[], probeGamingFindings?: string[], crossTaskDeletionFindings?: string[]): string;
|
|
65
72
|
/**
|
|
66
73
|
* Parse the child's verdict. Scans for the LAST `WORK-VERIFIED: PASS|FAIL|UNOBSERVED`
|
|
67
74
|
* marker (the model discusses before concluding, and bash output may echo the word
|
|
@@ -130,6 +137,15 @@ export interface VerificationDeps {
|
|
|
130
137
|
* underlying requirement is genuinely met rather than trusting the green check.
|
|
131
138
|
* Pure diff-text analysis; ABSENT or empty → no probe block. */
|
|
132
139
|
probeGamingProbe?: () => Promise<string[]>;
|
|
140
|
+
/**
|
|
141
|
+
* DETERMINISTIC cross-task deletion probe (see task-provenance.ts, mx5 run 12
|
|
142
|
+
* PROMPT 2): tracked files the task's diff DELETES whose introducing task (git
|
|
143
|
+
* provenance) differs from the current task — a sibling's committed deliverable
|
|
144
|
+
* destroyed, typically to green a check. Injected as prompt findings under rule
|
|
145
|
+
* 4d (MANDATORY + verdict-gating, the A/B-proven shape — buried rules score
|
|
146
|
+
* 0/5), and carried structurally on a FAIL outcome so an ACCEPT records each as
|
|
147
|
+
* a durable debt. ABSENT or empty → no block. */
|
|
148
|
+
crossTaskDeletionProbe?: () => Promise<CrossTaskDeletion[]>;
|
|
133
149
|
/**
|
|
134
150
|
* Result of the git-state guard for the MOST RECENT runChild call (see
|
|
135
151
|
* git-state-guard.ts): did the child mutate repo state (stash/checkout/file
|
package/dist/task/verify-work.js
CHANGED
|
@@ -71,6 +71,7 @@ import { USER_CANCELLED } from './child-runner.js';
|
|
|
71
71
|
import { buildEnvNotesBlock, ENV_NOTE_EMIT_INSTRUCTION, extractEnvNotes } from './env-notes.js';
|
|
72
72
|
import { buildContractsVerifyBlock } from './contracts.js';
|
|
73
73
|
import { findSkipEscapes, skipEscapeVerifyFindings } from './skip-escape.js';
|
|
74
|
+
import { crossTaskDeletionVerifyFindings } from './task-provenance.js';
|
|
74
75
|
/**
|
|
75
76
|
* The verification child gets exactly two tools: `read` and `bash`.
|
|
76
77
|
*
|
|
@@ -139,7 +140,7 @@ export function extractSpecForVerification(taskBody) {
|
|
|
139
140
|
* Guard: honest-clean fixture (prohibition in spec, probe silent) 5/5 PASS — no
|
|
140
141
|
* paranoia. Reverted-violation ≡ clean at the diff level (no entry → no finding).
|
|
141
142
|
*/
|
|
142
|
-
export function buildVerifyPrompt(spec, probeFindings, envNotes, prohibitionFindings, skipEscapeFindings, contracts, testAssemblyFindings, probeGamingFindings) {
|
|
143
|
+
export function buildVerifyPrompt(spec, probeFindings, envNotes, prohibitionFindings, skipEscapeFindings, contracts, testAssemblyFindings, probeGamingFindings, crossTaskDeletionFindings) {
|
|
143
144
|
const probeBlock = probeFindings && probeFindings.length > 0 ?
|
|
144
145
|
[
|
|
145
146
|
'SELF-VERIFICATION NOTICE (deterministic, computed by the orchestrator from the diff):',
|
|
@@ -166,6 +167,22 @@ export function buildVerifyPrompt(spec, probeFindings, envNotes, prohibitionFind
|
|
|
166
167
|
''
|
|
167
168
|
]
|
|
168
169
|
: [];
|
|
170
|
+
const crossTaskDeletionBlock = crossTaskDeletionFindings && crossTaskDeletionFindings.length > 0 ?
|
|
171
|
+
[
|
|
172
|
+
'CROSS-TASK DELETION NOTICE (deterministic, computed by the orchestrator from',
|
|
173
|
+
"git provenance over the task's diff): this task's work DELETED committed",
|
|
174
|
+
'deliverable(s) that a DIFFERENT, already-completed task introduced:',
|
|
175
|
+
...crossTaskDeletionFindings.map(f => `- ${f}`),
|
|
176
|
+
"A sibling task's verified, committed deliverable is not this task's to remove.",
|
|
177
|
+
'Deleting the file a checker complains about is the cheapest way to turn a red',
|
|
178
|
+
'check green — the finding vanishes with the file — and it destroys finished',
|
|
179
|
+
'work (rule 4d). A green check suite on the shrunken tree is NOT a waiver.',
|
|
180
|
+
"Unless THIS task's spec explicitly REQUIRES removing exactly that file, the",
|
|
181
|
+
'verdict is FAIL naming the deleted file and its owning task — even if every',
|
|
182
|
+
'check passes and the deletion made them pass.',
|
|
183
|
+
''
|
|
184
|
+
]
|
|
185
|
+
: [];
|
|
169
186
|
const probeGamingBlock = probeGamingFindings && probeGamingFindings.length > 0 ?
|
|
170
187
|
[
|
|
171
188
|
'CHECK-GAMING NOTICE (deterministic, computed by the orchestrator from the',
|
|
@@ -235,6 +252,7 @@ export function buildVerifyPrompt(spec, probeFindings, envNotes, prohibitionFind
|
|
|
235
252
|
...contractsBlock,
|
|
236
253
|
...probeBlock,
|
|
237
254
|
...prohibitionBlock,
|
|
255
|
+
...crossTaskDeletionBlock,
|
|
238
256
|
...probeGamingBlock,
|
|
239
257
|
...skipEscapeBlock,
|
|
240
258
|
...testAssemblyBlock,
|
|
@@ -359,6 +377,17 @@ export function buildVerifyPrompt(spec, probeFindings, envNotes, prohibitionFind
|
|
|
359
377
|
' check, not implementing the behavior). If the requirement is genuinely unmet while',
|
|
360
378
|
' the check passes, the verdict is FAIL naming the gamed check and the real gap.',
|
|
361
379
|
'',
|
|
380
|
+
"4d. A SIBLING TASK'S COMMITTED DELIVERABLE IS NOT THIS TASK'S TO DELETE — check the",
|
|
381
|
+
" task's own diff (git) for DELETED tracked files. A file that a DIFFERENT completed",
|
|
382
|
+
" task introduced and committed, deleted by this task's work, is finished work",
|
|
383
|
+
' destroyed — usually to make a red check go green (the file the checker complains',
|
|
384
|
+
' about simply disappears; that is quieting the messenger, rule 4c, not fixing the',
|
|
385
|
+
' defect). A passing check suite on the shrunken tree is not a waiver. Only two',
|
|
386
|
+
" outcomes are not a FAIL: THIS task's spec explicitly requires that removal, or",
|
|
387
|
+
' the deletion is a genuine relocation (the same file lives on elsewhere in the',
|
|
388
|
+
' tree). Otherwise the verdict is FAIL naming the deleted file and the task that',
|
|
389
|
+
' owns it.',
|
|
390
|
+
'',
|
|
362
391
|
'5. The ONLY thing you may assume is already provided is a genuinely EXTERNAL running',
|
|
363
392
|
' service or network resource (a database server, an API host) that the project',
|
|
364
393
|
' documents as a prerequisite. Before you rely on that assumption, PROBE for the',
|
|
@@ -531,6 +560,18 @@ export async function runWorkVerification(deps) {
|
|
|
531
560
|
probeGaming = [];
|
|
532
561
|
}
|
|
533
562
|
}
|
|
563
|
+
// Cross-task deletion findings feed the prompt (rule 4d) AND ride on a FAIL
|
|
564
|
+
// outcome (structured) so an ACCEPT can record them as durable debts. A probe
|
|
565
|
+
// failure must never block verification.
|
|
566
|
+
let crossDeletions = [];
|
|
567
|
+
if (deps.crossTaskDeletionProbe) {
|
|
568
|
+
try {
|
|
569
|
+
crossDeletions = await deps.crossTaskDeletionProbe();
|
|
570
|
+
}
|
|
571
|
+
catch {
|
|
572
|
+
crossDeletions = [];
|
|
573
|
+
}
|
|
574
|
+
}
|
|
534
575
|
// Environment facts from earlier gate children (best-effort; a cache failure
|
|
535
576
|
// must never block verification).
|
|
536
577
|
let envNotes = '';
|
|
@@ -566,7 +607,7 @@ export async function runWorkVerification(deps) {
|
|
|
566
607
|
for (let attempt = 1;; attempt++) {
|
|
567
608
|
let text;
|
|
568
609
|
try {
|
|
569
|
-
text = await deps.runChild(VERIFY_TOOLS, buildVerifyPrompt(deps.spec, findings, envNotes, prohibitions, skipEscapes, contracts, testAssembly, probeGaming), deps.signal);
|
|
610
|
+
text = await deps.runChild(VERIFY_TOOLS, buildVerifyPrompt(deps.spec, findings, envNotes, prohibitions, skipEscapes, contracts, testAssembly, probeGaming, crossTaskDeletionVerifyFindings(crossDeletions)), deps.signal);
|
|
570
611
|
}
|
|
571
612
|
catch (err) {
|
|
572
613
|
if (err instanceof Error && err.message === USER_CANCELLED)
|
|
@@ -603,15 +644,25 @@ export async function runWorkVerification(deps) {
|
|
|
603
644
|
return { ok: true };
|
|
604
645
|
if (verdict.detail === 'no verdict emitted' && attempt === 1)
|
|
605
646
|
continue;
|
|
647
|
+
// Structured cross-task deletion findings ride on every FAIL outcome: if the
|
|
648
|
+
// human ACCEPTs the failing artifact, the deletions ship in the next commit
|
|
649
|
+
// and the gate loop must record each as a durable debt (see task-gates.ts).
|
|
650
|
+
const deletions = crossDeletions.length > 0 ? { crossTaskDeletions: crossDeletions } : {};
|
|
606
651
|
// UNOBSERVED (rule 5c): a spec-required behavioral check could not run because
|
|
607
652
|
// its tooling is absent. Block like any FAIL, but flag it so the gate hands it
|
|
608
653
|
// straight to the human — re-running the impl turn cannot install a missing tool.
|
|
609
654
|
if (verdict.unobserved) {
|
|
610
|
-
return {
|
|
655
|
+
return {
|
|
656
|
+
ok: false,
|
|
657
|
+
unobserved: true,
|
|
658
|
+
reason: `work unobserved: ${verdict.detail}`,
|
|
659
|
+
...deletions
|
|
660
|
+
};
|
|
611
661
|
}
|
|
612
662
|
return {
|
|
613
663
|
ok: false,
|
|
614
|
-
reason: `work did not verify: ${verdict.detail}${verdict.detail === 'no verdict emitted' ? ' (after verify retry)' : ''}
|
|
664
|
+
reason: `work did not verify: ${verdict.detail}${verdict.detail === 'no verdict emitted' ? ' (after verify retry)' : ''}`,
|
|
665
|
+
...deletions
|
|
615
666
|
};
|
|
616
667
|
}
|
|
617
668
|
}
|
|
@@ -36,6 +36,16 @@ export interface TreeChangeSummary {
|
|
|
36
36
|
* without a repo.
|
|
37
37
|
*/
|
|
38
38
|
export declare function parseTreeChanges(porcelain: string): TreeChangeSummary;
|
|
39
|
+
/**
|
|
40
|
+
* Parse `git diff --name-status` output (tab-separated: `M\tpath`, `A\tpath`,
|
|
41
|
+
* `D\tpath`, `R100\told\tnew`, `C75\told\tnew`) into the same change summary. Used
|
|
42
|
+
* where the working tree is already clean and the last COMMIT's diff is the record
|
|
43
|
+
* of the task's changes (the post-enforce re-verify fallback). A rename entry
|
|
44
|
+
* contributes its source to `deleted` and its target to `added`, matching
|
|
45
|
+
* parseTreeChanges so the relocation allowance below reads both shapes identically;
|
|
46
|
+
* a copy's source still exists, so only its target counts (as added).
|
|
47
|
+
*/
|
|
48
|
+
export declare function parseNameStatusChanges(nameStatus: string): TreeChangeSummary;
|
|
39
49
|
/**
|
|
40
50
|
* The deletions a fix pass may NOT make: every deleted tracked file whose name does
|
|
41
51
|
* not reappear among the added files (a relocation keeps the file, under the same
|
package/dist/task/write-guard.js
CHANGED
|
@@ -75,6 +75,46 @@ export function parseTreeChanges(porcelain) {
|
|
|
75
75
|
}
|
|
76
76
|
return { modified: [...modified], deleted: [...deleted], added: [...added] };
|
|
77
77
|
}
|
|
78
|
+
/**
|
|
79
|
+
* Parse `git diff --name-status` output (tab-separated: `M\tpath`, `A\tpath`,
|
|
80
|
+
* `D\tpath`, `R100\told\tnew`, `C75\told\tnew`) into the same change summary. Used
|
|
81
|
+
* where the working tree is already clean and the last COMMIT's diff is the record
|
|
82
|
+
* of the task's changes (the post-enforce re-verify fallback). A rename entry
|
|
83
|
+
* contributes its source to `deleted` and its target to `added`, matching
|
|
84
|
+
* parseTreeChanges so the relocation allowance below reads both shapes identically;
|
|
85
|
+
* a copy's source still exists, so only its target counts (as added).
|
|
86
|
+
*/
|
|
87
|
+
export function parseNameStatusChanges(nameStatus) {
|
|
88
|
+
const modified = new Set();
|
|
89
|
+
const deleted = new Set();
|
|
90
|
+
const added = new Set();
|
|
91
|
+
for (const raw of nameStatus.split('\n')) {
|
|
92
|
+
const parts = raw.split('\t').map(s => s.trim());
|
|
93
|
+
const code = parts[0] ?? '';
|
|
94
|
+
if (code.length === 0 || !parts[1])
|
|
95
|
+
continue;
|
|
96
|
+
if (code.startsWith('R')) {
|
|
97
|
+
deleted.add(parts[1]);
|
|
98
|
+
if (parts[2])
|
|
99
|
+
added.add(parts[2]);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (code.startsWith('C')) {
|
|
103
|
+
added.add(parts[2] ?? parts[1]);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (code.startsWith('D')) {
|
|
107
|
+
deleted.add(parts[1]);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (code.startsWith('A')) {
|
|
111
|
+
added.add(parts[1]);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
modified.add(parts[1]);
|
|
115
|
+
}
|
|
116
|
+
return { modified: [...modified], deleted: [...deleted], added: [...added] };
|
|
117
|
+
}
|
|
78
118
|
const basename = (p) => {
|
|
79
119
|
const i = p.lastIndexOf('/');
|
|
80
120
|
return i === -1 ? p : p.slice(i + 1);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mjasnikovs/pi-task",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.24",
|
|
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",
|