@mjasnikovs/pi-task 0.18.22 → 0.18.23

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
  }
@@ -1,6 +1,7 @@
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;
@@ -140,14 +141,7 @@ export declare function discoverGateCommandLabels(cwd: string): string[];
140
141
  * this box; the same wording in a `test` run is a real failure the suite must own).
141
142
  */
142
143
  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;
144
+ export { taskThatIntroduced };
151
145
  /**
152
146
  * Run the final gate: static analysis first, then the lockfile consistency
153
147
  * checks, then the discovered integration commands, then one boot exercise of
@@ -155,4 +149,3 @@ export declare function taskThatIntroduced(cwd: string, rel: string): string | n
155
149
  * First real failure wins.
156
150
  */
157
151
  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,27 +587,10 @@ 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
@@ -624,7 +608,11 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
624
608
  // construction (see accept-debt.ts). Best-effort: a ledger read/write failure
625
609
  // must never break the gate.
626
610
  const { open: openRaw, resolved } = recheckAcceptDebts(await readAcceptDebts(cwd), {
627
- staticOk: stat.ok
611
+ staticOk: stat.ok,
612
+ // Cross-task-deletion debts auto-close iff the deleted file is back in the
613
+ // tree — a deterministic existence check, corroborating the per-file
614
+ // provenance the record already carries.
615
+ fileExists: rel => existsSync(path.join(cwd, rel))
628
616
  });
629
617
  if (resolved.length > 0)
630
618
  await writeAcceptDebts(cwd, openRaw);
@@ -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.23",
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",