@mjasnikovs/pi-task 0.18.21 → 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.
@@ -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 } 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). */
@@ -314,6 +329,14 @@ export function buildGateDeps(params) {
314
329
  // discardEdits): the final integration gate re-checks each debt at run end.
315
330
  recordAcceptDebt: (cwd2, taskId, reason) => recordAcceptDebt(cwd2, taskId, reason),
316
331
  recordEnforceRevertDebt: (cwd2, taskId, reason) => recordEnforceRevertDebt(cwd2, taskId, reason),
332
+ // Durable cross-task-contradiction ledger (PROMPT 1 layer B): a repo-health
333
+ // FAIL whose only fix is an edit to a path this task's spec froze — recorded
334
+ // when the gate loop routes it to the picker, re-checked by the final gate.
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),
317
340
  // Frozen-path write-deny (see frozen-path-guard.ts): the concrete paths this
318
341
  // task's spec forbids modifying, so the gate sequence can UNDO any edit the
319
342
  // enforce EDIT pass makes to them before those edits are committed. Reads the
@@ -453,6 +476,12 @@ export function buildGateDeps(params) {
453
476
  // ("return 401 so the verification test passes") become rule-4c
454
477
  // findings so the child verifies the real requirement, not the check.
455
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))),
456
485
  // Deterministic prohibition probe: paths the spec forbids modifying
457
486
  // that the task's diff modified anyway become prompt-level findings
458
487
  // under the no-waiver rule — the child otherwise rarely runs `git
@@ -516,7 +545,14 @@ export function buildGateDeps(params) {
516
545
  const r = await git(cwd2, args, signal);
517
546
  return { exitCode: r.exitCode, stdout: r.stdout };
518
547
  },
519
- 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))
520
556
  });
521
557
  },
522
558
  // Deterministic static check + tree helpers for the enforce pre-commit gate.
@@ -11,10 +11,13 @@ export interface LintFixDeps {
11
11
  failReason: string;
12
12
  /** Run the fix child; same closure shape the other gate children use. */
13
13
  runChild: (tools: string, prompt: string, signal?: AbortSignal) => Promise<string>;
14
- /** The deterministic whole-repo static check to converge against. */
14
+ /** The deterministic whole-repo static check to converge against. `output`
15
+ * (the failing command's captured text, when the impl provides it) lets the
16
+ * non-convergence path trace the findings to a spec-frozen path. */
15
17
  repoHealth: () => Promise<{
16
18
  ok: boolean;
17
19
  reason: string;
20
+ output?: string;
18
21
  }>;
19
22
  /** Run git in cwd; injected so the guard logic is unit-testable. */
20
23
  git: (args: string[]) => Promise<{
@@ -28,6 +31,17 @@ export interface LintFixDeps {
28
31
  * and the fix reported not-applied. Absent/empty → no-op, prior behavior.
29
32
  */
30
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>;
31
45
  }
32
46
  /** The fix child edits and runs the checker; bash exists to RUN the check, not git. */
33
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
- import { parseChangedFrozenFiles, revertFrozenPaths } from './frozen-path-guard.js';
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
@@ -251,6 +313,25 @@ export async function runBoundedLintFix(deps) {
251
313
  }
252
314
  const health = await deps.repoHealth();
253
315
  if (!health.ok) {
316
+ // FROZEN-PATH TRACE on non-convergence (PROMPT 1 layer B): when the child
317
+ // was honest — it did NOT touch the frozen path, so the guard above never
318
+ // tripped — but the check is still red and its own output NAMES a frozen
319
+ // path (typed ESLint: "playwright/index.ts was not found by the project …
320
+ // consider including it in the tsconfig.json"), the findings can only be
321
+ // fixed by an edit this task's spec forbids. Report it under the same
322
+ // `frozen-path:` prefix as the guard trip, so the gate loop can route
323
+ // straight to the human picker instead of burning unattended AUTOFIX
324
+ // rounds an impl re-run under the same freeze cannot converge out of.
325
+ const implicated = frozen.filter(p => pathNamedIn(`${health.reason}\n${health.output ?? ''}`, p));
326
+ if (implicated.length > 0) {
327
+ return {
328
+ ok: false,
329
+ reason: `frozen-path: static findings implicate spec-frozen path(s) `
330
+ + `(${implicated.slice(0, 3).join(', ')}`
331
+ + `${implicated.length > 3 ? ', …' : ''}) — did not converge `
332
+ + `(${health.reason}); a fix under this task's constraints cannot converge`
333
+ };
334
+ }
254
335
  return { ok: false, reason: `did not converge: ${health.reason}` };
255
336
  }
256
337
  return { ok: true, reason: guardNote };
@@ -117,7 +117,7 @@ export interface PhaseAutoAnswerDeps {
117
117
  export declare function phaseAutoAnswer(deps: PhaseDeps, refined: string, research: string, question: string, autoDeps?: PhaseAutoAnswerDeps): Promise<AutoAnswer>;
118
118
  export declare function phaseGrill(deps: PhaseDeps, ctx: ExtensionCommandContext, widgetState: WidgetState, refined: string, research: string): Promise<string>;
119
119
  export declare function phaseCompose(deps: PhaseDeps, refined: string, research: string, qa: string): Promise<string>;
120
- export declare function phaseCritique(deps: PhaseDeps, spec: string, refined: string, qa: string, planContext?: string): Promise<string>;
120
+ export declare function phaseCritique(deps: PhaseDeps, spec: string, refined: string, qa: string, planContext?: string, research?: string): Promise<string>;
121
121
  export declare function critiqueWithFallback(d: PhaseDeps, p: PhaseContext): Promise<string>;
122
122
  export declare const PHASES: PhaseConfig[];
123
123
  export declare function postCommitPhase(phase: PhaseConfig, deps: PhaseDeps, pc: PhaseContext, out: string): Promise<void>;
@@ -29,6 +29,7 @@ import { parseVerifyBlock, validateSpecShape, stripSpecPreamble, isCritiqueClean
29
29
  import { findSkipEscapes, skipEscapeDefectText } from './skip-escape.js';
30
30
  import { findSynthesizedWiring, wiringProbeText, readReferencedDocs } from './wiring-claims.js';
31
31
  import { findAbsenceConflicts, absenceProbeText, siblingTitlesFromPlanContext } from './verify-reconcile.js';
32
+ import { findFrozenPathConflicts, frozenConflictProbeText } from './frozen-conflict.js';
32
33
  import { existsSync } from 'node:fs';
33
34
  import { readContracts, buildContractsBlock, buildContractsVerifyBlock } from './contracts.js';
34
35
  import { readRequirements, buildRequirementsBlock } from './requirements.js';
@@ -791,7 +792,7 @@ export async function phaseCompose(deps, refined, research, qa) {
791
792
  return { ok: true, value: stripped };
792
793
  }, problem => new Error(`compose_invalid: ${problem}`));
793
794
  }
794
- export async function phaseCritique(deps, spec, refined, qa, planContext) {
795
+ export async function phaseCritique(deps, spec, refined, qa, planContext, research) {
795
796
  // Fast triage before the expensive full rewrite. The rewrite regenerates
796
797
  // the entire spec from scratch and is the costliest tail of the pipeline
797
798
  // (observed up to ~240s). Most compose drafts are already good, so we first
@@ -850,6 +851,21 @@ export async function phaseCritique(deps, spec, refined, qa, planContext) {
850
851
  deps.logDebug?.('plan-contradiction flagged in VERIFY: '
851
852
  + absenceConflicts.map(c => `${c.assertion.target} (${c.against})`).join(' | '));
852
853
  }
854
+ // DETERMINISTIC unsatisfiable-pair probe (mx5 run 12 root cause): a blanket
855
+ // frozen path ("Do NOT modify `tsconfig.json` … handled in steps 1–2") whose
856
+ // registration edit the spec's OWN body — or the task's RESEARCH the spec was
857
+ // composed from (live drafts sometimes drop the nuance while shipping the
858
+ // freeze and the creation) — says the deliverable requires ("must also be
859
+ // included …"). Shipped as-is, the created files turn the repo-wide static
860
+ // check permanently red and no task is allowed to fix it — every later task
861
+ // burns its AUTOFIX rounds on it. Forced into the rewrite like the other
862
+ // probes: the rewrite must grant scoped ownership or drop the creation.
863
+ const frozenConflicts = findFrozenPathConflicts(spec, research);
864
+ const frozenProbe = frozenConflicts.length > 0 ? frozenConflictProbeText(frozenConflicts) : null;
865
+ if (frozenProbe) {
866
+ deps.logDebug?.('unsatisfiable freeze/requires-edit pair flagged in spec: '
867
+ + frozenConflicts.map(c => c.path).join(' | '));
868
+ }
853
869
  let triageDefects = null;
854
870
  if (parseVerifyBlock(spec) !== null) {
855
871
  const tTriage = Date.now();
@@ -866,12 +882,15 @@ export async function phaseCritique(deps, spec, refined, qa, planContext) {
866
882
  }
867
883
  deps.recordSubStep?.('triage', Date.now() - tTriage);
868
884
  if (verdict !== null) {
869
- // A deterministic skip-escape, synthesized-wiring, or plan-contradiction
870
- // finding overrides a CLEAN triage: the draft must be rewritten to resolve
871
- // it even if the model judged the rest clean (the model does not
872
- // self-discover any of them reliably).
885
+ // A deterministic skip-escape, synthesized-wiring, plan-contradiction,
886
+ // or unsatisfiable-pair finding overrides a CLEAN triage: the draft must
887
+ // be rewritten to resolve it even if the model judged the rest clean
888
+ // (the model does not self-discover any of them reliably).
873
889
  if (isCritiqueClean(verdict)) {
874
- if (skipDefects === null && wiringProbe === null && absenceProbe === null) {
890
+ if (skipDefects === null
891
+ && wiringProbe === null
892
+ && absenceProbe === null
893
+ && frozenProbe === null) {
875
894
  return spec;
876
895
  }
877
896
  }
@@ -881,8 +900,11 @@ export async function phaseCritique(deps, spec, refined, qa, planContext) {
881
900
  }
882
901
  }
883
902
  // Merge the deterministic skip-escape + synthesized-wiring + plan-contradiction
884
- // defects with any triage defects for the rewrite (all are forced FOCUS items).
885
- const rewriteDefects = [skipDefects, wiringProbe, absenceProbe, triageDefects].filter(Boolean).join('\n\n') || null;
903
+ // + unsatisfiable-pair defects with any triage defects for the rewrite (all are
904
+ // forced FOCUS items).
905
+ const rewriteDefects = [skipDefects, wiringProbe, absenceProbe, frozenProbe, triageDefects]
906
+ .filter(Boolean)
907
+ .join('\n\n') || null;
886
908
  const tRewrite = Date.now();
887
909
  try {
888
910
  return await runWithEmphasisRetry(deps, 'critique', 'read', problem => CRITIQUE_PROMPT(spec, refined, qa, problem !== null, rewriteDefects, contractsBlock), text => {
@@ -902,7 +924,7 @@ export async function phaseCritique(deps, spec, refined, qa, planContext) {
902
924
  // ─── Critique with fallback ──────────────────────────────────────────────────
903
925
  export async function critiqueWithFallback(d, p) {
904
926
  try {
905
- return await phaseCritique(d, p.spec, p.refined, p.qa, p.planContext);
927
+ return await phaseCritique(d, p.spec, p.refined, p.qa, p.planContext, p.research);
906
928
  }
907
929
  catch (err) {
908
930
  const msg = err instanceof Error ? err.message : String(err);
@@ -36,6 +36,13 @@ export interface Prohibition {
36
36
  * against the EXACT wording — including any exception clause it states. */
37
37
  constraint: string;
38
38
  }
39
+ /**
40
+ * Does this line express a modification ban? Matches the active forms ("do not
41
+ * modify", "must not touch", "never edit", "don't change") and the passive form
42
+ * ("must not be modified"). Deliberately verb-scoped to modification — a "do not
43
+ * add a dependency" style rule names no path and is the prompt rule's job.
44
+ */
45
+ export declare const PROHIBITION_RE: RegExp;
39
46
  /**
40
47
  * Extract the concrete paths the spec explicitly forbids modifying: every
41
48
  * backtick-quoted path-like token on a line that expresses a modification ban.
@@ -4,7 +4,7 @@
4
4
  * ("must not be modified"). Deliberately verb-scoped to modification — a "do not
5
5
  * add a dependency" style rule names no path and is the prompt rule's job.
6
6
  */
7
- const PROHIBITION_RE = /\b(?:do\s+not|don'?t|must\s+not|never)\s+(?:be\s+)?(?:modify|modified|touch|touched|edit|edited|change|changed|alter|altered|rewrite|rewritten|overwrite|overwritten|delete|deleted|remove|removed)\b/i;
7
+ export const PROHIBITION_RE = /\b(?:do\s+not|don'?t|must\s+not|never)\s+(?:be\s+)?(?:modify|modified|touch|touched|edit|edited|change|changed|alter|altered|rewrite|rewritten|overwrite|overwritten|delete|deleted|remove|removed)\b/i;
8
8
  /**
9
9
  * A backtick token counts as a path only when it is whitespace-free, uses path
10
10
  * characters, and either contains a directory separator, has a file extension,
@@ -135,6 +135,25 @@ export interface GateDeps {
135
135
  * gate re-checks and surfaces it like an accept-debt. Best-effort; absent in tests.
136
136
  */
137
137
  recordEnforceRevertDebt?: (cwd: string, taskId: string, reason: string) => Promise<void>;
138
+ /**
139
+ * Record a durable FROZEN-BLOCKED debt (mx5 run 12 / PROMPT 1 layer B): a
140
+ * repo-health FAIL whose only fix is an edit to a path THIS task's spec froze —
141
+ * a cross-task contradiction. Recorded when the loop routes such a FAIL to the
142
+ * picker (whatever the human then picks, the defect is real and no task may fix
143
+ * it), so the final gate re-checks it at run end. Best-effort; absent in tests.
144
+ */
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>;
138
157
  /**
139
158
  * The concrete paths this task's spec forbids modifying (its `Do NOT modify`
140
159
  * CONSTRAINTS — see frozen-path-guard.ts / prohibition-probe.ts). Used to
@@ -89,6 +89,15 @@ export async function runGatesForTask(ctxIn, deps, p) {
89
89
  // auto attempts still FAIL, the picker returns so a person can break the loop.
90
90
  let lintFixAttempted = false;
91
91
  let autoFixCount = 0;
92
+ // Set when the bounded lint-fix reports the `frozen-path:` rejection: the
93
+ // repo-health FAIL can only be fixed by editing a path THIS task's spec
94
+ // froze (mx5 run 12's unsatisfiable registration pair). An impl re-run is
95
+ // under the same freeze — rule 4b fails the task if it complies with the
96
+ // linter — so unattended AUTOFIX rounds CANNOT converge and are skipped;
97
+ // the picker is forced with the cross-task contradiction named, and the
98
+ // defect is recorded as a durable debt for the final gate.
99
+ let frozenContradiction = null;
100
+ let frozenDebtRecorded = false;
92
101
  while (!verified.ok) {
93
102
  const failReason = verified.reason ?? 'did not verify';
94
103
  // GRADUATED resolution: a repo-health FAIL (pure static findings) gets ONE
@@ -107,6 +116,9 @@ export async function runGatesForTask(ctxIn, deps, p) {
107
116
  await rec(verdictLine(verified));
108
117
  continue;
109
118
  }
119
+ if ((fix.reason ?? '').startsWith('frozen-path:')) {
120
+ frozenContradiction = fix.reason ?? null;
121
+ }
110
122
  }
111
123
  // UNOBSERVED (rule 5c): a spec-required behavioral check could not run because
112
124
  // its observation tooling is absent. An unattended AUTOFIX re-run cannot install
@@ -114,14 +126,48 @@ export async function runGatesForTask(ctxIn, deps, p) {
114
126
  // decision (provision the tool, or accept the unproven behavior) is the human's.
115
127
  // Skip the (moot) recommendation research and force the picker.
116
128
  const isUnobserved = verified.unobserved === true;
129
+ // FROZEN-BLOCKED (cross-task contradiction): skip the recommendation
130
+ // research too — it would only re-derive what the deterministic lint-fix
131
+ // rejection already proved. The picker shows the contradiction; ACCEPT
132
+ // records the (already-recorded) defect as the human's call. Applies
133
+ // only while the FAIL is still the repo-health one the contradiction
134
+ // explains — a later, different FAIL gets the ordinary resolution path.
135
+ const isFrozenBlocked = frozenContradiction !== null && failReason.startsWith('repo health:');
117
136
  const recOutcome = isUnobserved ? { recommend: 'autofix', rationale: failReason }
118
- : deps.recommend ?
119
- await deps.recommend(active, p.cwd, p.title, p.taskId, failReason)
120
- : { recommend: 'autofix', rationale: failReason };
137
+ : isFrozenBlocked ?
138
+ {
139
+ recommend: 'accept',
140
+ rationale: `${frozenContradiction} — the failing static check can only be fixed by `
141
+ + `editing a path this task's spec freezes (a cross-task contradiction: `
142
+ + `the spec forbids the very edit the repo needs; the "owning" earlier `
143
+ + `step already completed). An implementation re-run under the same `
144
+ + `freeze cannot converge. ACCEPT records it as durable debt the final `
145
+ + `gate re-checks; fixing it needs a plan-level change, not a re-run.`
146
+ }
147
+ : deps.recommend ?
148
+ await deps.recommend(active, p.cwd, p.title, p.taskId, failReason)
149
+ : { recommend: 'autofix', rationale: failReason };
121
150
  await rec(isUnobserved ?
122
151
  'resolution: verify UNOBSERVED — spec-required check could not run (tooling absent); '
123
152
  + 'forcing the human picker, an unattended re-run cannot provision it'
124
- : `resolution: recommended ${recOutcome.recommend.toUpperCase()}`);
153
+ : isFrozenBlocked ?
154
+ 'resolution: repo-health FAIL is blocked by spec-frozen path(s) — cross-task '
155
+ + 'contradiction; unattended AUTOFIX skipped (an impl re-run under the same '
156
+ + 'freeze cannot converge), forcing the human picker'
157
+ : `resolution: recommended ${recOutcome.recommend.toUpperCase()}`);
158
+ if (isFrozenBlocked && !frozenDebtRecorded) {
159
+ frozenDebtRecorded = true;
160
+ // Durable regardless of what the human picks next: the contradiction
161
+ // is real, cross-task, and outside this task's power to fix — the
162
+ // final gate must surface it at run end (static-class: it auto-closes
163
+ // iff the run-end static check passes).
164
+ try {
165
+ await deps.recordFrozenBlockedDebt?.(p.cwd, p.taskId, `${failReason} — ${frozenContradiction}`);
166
+ }
167
+ catch {
168
+ // recording must never break the gate sequence
169
+ }
170
+ }
125
171
  // AUTO-RESOLVE the AUTOFIX path: when the research says the work is
126
172
  // genuinely wrong, re-run the fix WITHOUT prompting the user. The picker is
127
173
  // reserved for the ACCEPT recommendation (the human decides whether to bless
@@ -129,6 +175,7 @@ export async function runGatesForTask(ctxIn, deps, p) {
129
175
  // MAX_AUTO_AUTOFIX consecutive unattended attempts that still FAIL, hand
130
176
  // control back so a person can break a non-converging loop.
131
177
  const autoFixNow = !isUnobserved
178
+ && !isFrozenBlocked
132
179
  && recOutcome.recommend === 'autofix'
133
180
  && autoFixCount < MAX_AUTO_AUTOFIX;
134
181
  let choice;
@@ -151,11 +198,27 @@ export async function runGatesForTask(ctxIn, deps, p) {
151
198
  // defect ships and nothing else revisits it (mx5 run 4 B3 / run 8
152
199
  // TASK_0012). Record it to the run ledger; the final integration gate
153
200
  // re-checks it at run end and surfaces it if still open. Best-effort.
154
- try {
155
- await deps.recordAcceptDebt?.(p.cwd, p.taskId, failReason);
201
+ // The frozen-blocked routing above already recorded this defect (with
202
+ // the contradiction named) — don't double-enter it in the ledger.
203
+ if (!frozenDebtRecorded) {
204
+ try {
205
+ await deps.recordAcceptDebt?.(p.cwd, p.taskId, failReason);
206
+ }
207
+ catch {
208
+ // recording must never break the gate sequence
209
+ }
156
210
  }
157
- catch {
158
- // recording must never break the gate sequence
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
+ }
159
222
  }
160
223
  active.ui.notify(`${p.tag}: accepted "${p.title}" despite verify FAIL (${failReason.slice(0, 120)}) — proceeding.`, 'warning');
161
224
  break;
@@ -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
+ }