@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.
@@ -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`); every other debt
77
- * stays OPEN (unprovable ⇒ surface, never re-hide). FP-safe: the only auto-close is
78
- * the one a deterministic check can stand behind.
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[];
@@ -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' || origin === 'frozen-blocked' ?
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`); every other debt
188
- * stays OPEN (unprovable ⇒ surface, never re-hide). FP-safe: the only auto-close is
189
- * the one a deterministic check can stand behind.
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
  }
@@ -33,6 +33,7 @@ export interface AutoDeps extends GateDeps {
33
33
  finalGate?: (cwd: string, planText?: string) => Promise<{
34
34
  ok: boolean;
35
35
  reason: string;
36
+ failures?: string[];
36
37
  debtNote?: string;
37
38
  openDebts?: AcceptDebt[];
38
39
  }>;
@@ -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
- await recGate(fin.ok ?
921
- `final-gate: PASS — ${fin.reason.slice(0, 300)}`
922
- : `final-gate: FAIL — ${fin.reason.slice(0, 300)}`);
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 debt note is carried so the next picker still shows
993
- // the open claims (the seed never includes it).
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 command was run against the assembled repository and failed:',
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 make that command pass by fixing the DEFECT it reveals.',
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 the exact failing command first and read its full output.',
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 it exits 0. The',
133
- ' gate is re-run mechanically after you finish — your claim is not the',
134
- ' verdict, the real exit code is.',
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, its exit code, and the tail of its output — the
9
- * MECHANICAL failure only. The accept-debt note is deliberately NOT folded in
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
- * First real failure wins.
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 {};
@@ -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
- * The task whose commit INTRODUCED `rel` (oldest `--diff-filter=A` commit whose
591
- * subject carries the pi-task `(TASK_nnnn)` suffix — both the task snapshot and the
592
- * ENFORCE commit shapes match). Null when the file predates the run, was never
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
- * First real failure wins.
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
- return withDebts({ ok: false, reason: `static checks: ${stat.reason}` });
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
- return withDebts({
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
- return withDebts({
679
- ok: false,
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
- return withDebts({
710
- ok: false,
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
- return withDebts({ ok: false, reason: `boot check: \`${label}\` ${b.detail}` });
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
- return withDebts({
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,
@@ -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
@@ -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.
@@ -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";
@@ -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
@@ -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
@@ -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 { ok: false, unobserved: true, reason: `work unobserved: ${verdict.detail}` };
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
@@ -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.22",
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",