@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.
@@ -6,8 +6,19 @@
6
6
  * server entry point … the Hono server cannot be started"), so the terminal defect
7
7
  * was FOUND and then erased by the very mechanism that found it. Persisted here so
8
8
  * the final gate re-checks and surfaces it instead of letting it die with the revert.
9
+ * - 'frozen-blocked' — a repo-health verify-FAIL whose only fix is an edit to a path
10
+ * THIS task's spec froze (mx5 run 12: `bun run lint` permanently red because the
11
+ * created files need a tsconfig registration every spec forbids). Cross-task
12
+ * contradiction: no unattended re-run can converge, so the gate loop records the
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.
9
20
  */
10
- export type DebtOrigin = 'accepted' | 'enforce-revert';
21
+ export type DebtOrigin = 'accepted' | 'enforce-revert' | 'frozen-blocked' | 'cross-task-deletion';
11
22
  /** One recorded defect: the task, why its VERIFY failed, and how it was recorded. */
12
23
  export interface AcceptDebt {
13
24
  taskId: string;
@@ -46,6 +57,33 @@ export declare function recordAcceptDebt(cwd: string, taskId: string, reason: st
46
57
  * rather than letting it die with the revert.
47
58
  */
48
59
  export declare function recordEnforceRevertDebt(cwd: string, taskId: string, reason: string): Promise<void>;
60
+ /**
61
+ * Record a FROZEN-BLOCKED debt (mx5 run 12 / PROMPT 1 layer B): a repo-health FAIL
62
+ * whose static findings can only be fixed by editing a path this task's spec froze —
63
+ * a cross-task contradiction no unattended re-run may resolve. Recorded when the gate
64
+ * loop routes to the picker (regardless of what the human then picks), so the final
65
+ * gate re-checks it at run end. Static-class by reason prefix (`repo health: …`), so
66
+ * it auto-closes iff the final gate's own static check passes.
67
+ */
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;
49
87
  /** Overwrite the ledger with exactly these records (used to prune resolved debts). */
50
88
  export declare function writeAcceptDebts(cwd: string, debts: AcceptDebt[]): Promise<void>;
51
89
  /**
@@ -59,12 +97,15 @@ export declare function writeAcceptDebts(cwd: string, debts: AcceptDebt[]): Prom
59
97
  export declare function isStaticClassDebt(reason: string): boolean;
60
98
  /**
61
99
  * Re-check the ledger against the current run state. A static-class debt is RESOLVED
62
- * iff the final gate's own static check now passes (`staticOk`); every other debt
63
- * stays OPEN (unprovable ⇒ surface, never re-hide). FP-safe: the only auto-close is
64
- * 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.
65
105
  */
66
106
  export declare function recheckAcceptDebts(debts: AcceptDebt[], opts: {
67
107
  staticOk: boolean;
108
+ fileExists?: (rel: string) => boolean;
68
109
  }): {
69
110
  open: AcceptDebt[];
70
111
  resolved: AcceptDebt[];
@@ -74,7 +74,11 @@ 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: 'enforce-revert' } : {})
77
+ ...((origin === 'enforce-revert'
78
+ || origin === 'frozen-blocked'
79
+ || origin === 'cross-task-deletion') ?
80
+ { origin: origin }
81
+ : {})
78
82
  });
79
83
  }
80
84
  return out;
@@ -92,8 +96,8 @@ function normaliseReason(reason) {
92
96
  }
93
97
  function serialize(d) {
94
98
  // Legacy 2-field shape for 'accepted' (backward compatible); a 3rd origin field
95
- // only for the enforce-revert class, so old readers/files round-trip unchanged.
96
- return d.origin === 'enforce-revert' ?
99
+ // only for the non-accepted classes, so old readers/files round-trip unchanged.
100
+ return d.origin && d.origin !== 'accepted' ?
97
101
  `${d.taskId}${FIELD_SEP}${d.reason}${FIELD_SEP}${d.origin}`
98
102
  : `${d.taskId}${FIELD_SEP}${d.reason}`;
99
103
  }
@@ -140,6 +144,45 @@ export async function recordEnforceRevertDebt(cwd, taskId, reason) {
140
144
  origin: 'enforce-revert'
141
145
  });
142
146
  }
147
+ /**
148
+ * Record a FROZEN-BLOCKED debt (mx5 run 12 / PROMPT 1 layer B): a repo-health FAIL
149
+ * whose static findings can only be fixed by editing a path this task's spec froze —
150
+ * a cross-task contradiction no unattended re-run may resolve. Recorded when the gate
151
+ * loop routes to the picker (regardless of what the human then picks), so the final
152
+ * gate re-checks it at run end. Static-class by reason prefix (`repo health: …`), so
153
+ * it auto-closes iff the final gate's own static check passes.
154
+ */
155
+ export async function recordFrozenBlockedDebt(cwd, taskId, reason) {
156
+ await appendDebt(cwd, {
157
+ taskId: taskId.trim(),
158
+ reason: normaliseReason(reason),
159
+ origin: 'frozen-blocked'
160
+ });
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
+ }
143
186
  /** Overwrite the ledger with exactly these records (used to prune resolved debts). */
144
187
  export async function writeAcceptDebts(cwd, debts) {
145
188
  try {
@@ -167,14 +210,31 @@ export function isStaticClassDebt(reason) {
167
210
  }
168
211
  /**
169
212
  * Re-check the ledger against the current run state. A static-class debt is RESOLVED
170
- * iff the final gate's own static check now passes (`staticOk`); every other debt
171
- * stays OPEN (unprovable ⇒ surface, never re-hide). FP-safe: the only auto-close is
172
- * 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.
173
218
  */
174
219
  export function recheckAcceptDebts(debts, opts) {
175
220
  const open = [];
176
221
  const resolved = [];
177
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
+ }
178
238
  if (opts.staticOk && isStaticClassDebt(d.reason))
179
239
  resolved.push(d);
180
240
  else
@@ -255,7 +315,14 @@ export function buildAcceptDebtNote(open) {
255
315
  }
256
316
  /** One-line provenance label for a debt, for the surfaced report. */
257
317
  export function describeDebt(d) {
258
- return d.origin === 'enforce-revert' ?
259
- 'enforce re-verify FAILED then the edits were reverted (defect indicts the ORIGINAL work, still shipped)'
260
- : 'accepted despite verify-FAIL';
318
+ if (d.origin === 'enforce-revert') {
319
+ return 'enforce re-verify FAILED then the edits were reverted (defect indicts the ORIGINAL work, still shipped)';
320
+ }
321
+ if (d.origin === 'frozen-blocked') {
322
+ return 'repo health blocked by a spec-frozen path (cross-task contradiction — no task may perform the fixing edit)';
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
+ }
327
+ return 'accepted despite verify-FAIL';
261
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);
@@ -0,0 +1,36 @@
1
+ /** One unsatisfiable freeze/requires-edit pair found in a composed spec. */
2
+ export interface FrozenPathConflict {
3
+ /** The frozen path (normalized: no leading ./, no trailing /). */
4
+ path: string;
5
+ /** The freeze line, verbatim (the `Do NOT modify` constraint). */
6
+ constraint: string;
7
+ /** The sentence stating the deliverable requires changing that path
8
+ * (or surrendering to the consequences of not being allowed to). */
9
+ statement: string;
10
+ /** Where the statement was found: the spec's own body, or the task's
11
+ * research (the compose INPUT — live drafts sometimes drop the research
12
+ * nuance from the spec text while the contradiction remains real). */
13
+ source: 'spec' | 'research';
14
+ }
15
+ /**
16
+ * Find every unsatisfiable pair in the composed spec: a BLANKET frozen path
17
+ * whose registration/edit is declared necessary by the spec's own body (or
18
+ * explicitly surrendered to), or - when `research` is given - by the task's
19
+ * research the spec was composed FROM. The frozen paths always come from the
20
+ * SPEC alone; research contributes only the statement side (live compose
21
+ * sometimes drops the research's "must also be included" nuance from the spec
22
+ * text while shipping the freeze and the file creation - the contradiction is
23
+ * then visible only across the compose boundary). Deterministic, pure text -
24
+ * the same extraction the live A/B grounds its measurements in, so the model
25
+ * cannot self-report its way past it. Empty on a null spec or one that froze
26
+ * nothing.
27
+ */
28
+ export declare function findFrozenPathConflicts(spec: string | null | undefined, research?: string | null): FrozenPathConflict[];
29
+ /**
30
+ * The forced critique-rewrite defect text (skip-escape pattern: MANDATORY,
31
+ * self-contained, names the exact resolution options). The two permitted
32
+ * resolutions come straight from the incident analysis: scoped ownership or
33
+ * dropping the creation — prose acknowledging the gap is called out as a
34
+ * non-resolution because that is precisely what the live model shipped.
35
+ */
36
+ export declare function frozenConflictProbeText(conflicts: FrozenPathConflict[]): string;
@@ -0,0 +1,206 @@
1
+ /**
2
+ * frozen-conflict — deterministic detection of an UNSATISFIABLE spec pair at
3
+ * compose time (mx5 run 12 root cause, PROMPT 1 layer A).
4
+ *
5
+ * The failure this closes: compose authored TASK_0020 with a blanket freeze
6
+ * ("Do not modify `tsconfig.json`, `eslint.config.js`, or `.prettierrc.cjs`;
7
+ * those are handled in steps 1–2") while the deliverable — new
8
+ * `playwright-ct.config.ts` + `playwright/index.ts` — needs exactly that
9
+ * registration edit, and the spec "resolved" the contradiction with prose
10
+ * surrender ("Accept that `playwright-ct.config.ts` will not be covered by
11
+ * `tsc --noEmit` since `tsconfig.json` is not modified in this step"). Typed
12
+ * ESLint does not "accept" anything: the moment the created files land,
13
+ * `bun run lint` hard-errors REPO-WIDE and permanently — and the "owning"
14
+ * steps 1–2 completed long ago, so NO task may ever perform the edit (every
15
+ * spec carries the same freeze). Every subsequent task then burned its
16
+ * unattended AUTOFIX rounds on a repo-health FAIL none of them was allowed to
17
+ * fix, and the eventual "escape" was a child DELETING the deliverables.
18
+ *
19
+ * The contradiction is visible IN THE COMPOSED TEXT ITSELF, so it must die at
20
+ * spec time. Like the skip-escape / synthesized-wiring / plan-contradiction
21
+ * probes (prompt-only rules are A/B-proven ~0–1/5 on the weak model), the
22
+ * detector is deterministic and its finding is FORCED into the critique
23
+ * rewrite: the rewrite must either grant scoped ownership ("MAY edit `X` ONLY
24
+ * to register the files this task creates") or drop the file creation.
25
+ *
26
+ * High-precision by construction (FP-swept over the 26 real mx5 run-12 specs):
27
+ * - the freeze side must be BLANKET — a `Do NOT modify` line with no
28
+ * exception clause; a scoped freeze ("MAY edit … ONLY to register",
29
+ * "Only edit `X` …", "… except to add …") is already the resolution shape
30
+ * and must never re-fire;
31
+ * - the statement side is judged per SENTENCE, not per line (real GOALs are
32
+ * one giant line — line scoping false-fired on `must include` in a
33
+ * response-shape sentence three paths away from the frozen one);
34
+ * - a sentence that is itself prohibition-shaped ("Preserve … do not
35
+ * remove or modify …") is the freeze side restated, never a statement;
36
+ * - the sentence must NAME the frozen path (pathNamedIn) AND match one of
37
+ * the measured phrasing families: passive registration ("must also be
38
+ * included"), unless-added ("won't be type-checked unless added"),
39
+ * directional active ("requires adding … to" / "must add … to"), or the
40
+ * prose SURRENDER itself ("will not be covered … since `X` is not
41
+ * modified" — the exact line the live TASK_0020 spec shipped).
42
+ * Mere co-mention never fires.
43
+ */
44
+ import { extractProhibitions, PROHIBITION_RE } from './prohibition-probe.js';
45
+ import { pathNamedIn } from './frozen-path-guard.js';
46
+ /**
47
+ * A freeze that carves out its own exception is SCOPED, not blanket — it is
48
+ * exactly the resolution shape the probe demands ("MAY edit `X` ONLY to
49
+ * register…", "Only edit `X` (add the search route)…", "Do not modify `X`
50
+ * except to add…"), so it never counts as the freeze side of a conflict, or
51
+ * the recomposed spec would re-fire forever.
52
+ */
53
+ const EXCEPTION_RE = /\b(?:except|unless|beyond|other\s+than|apart\s+from|only\s+(?:to|for|if|when|where|edit|modify|change|touch|update)|may\s+(?:edit|modify|change|update|add))\b/i;
54
+ /** Passive registration: "must (also) be included/added/registered/…". */
55
+ const PASSIVE_REG_RE = /\b(?:must|needs?\s+to|has\s+to|should)\s+(?:also\s+)?be\s+(?:includ|add|regist|list|referenc|declar|updat)\w*/i;
56
+ /** "unless (it is) added/included/registered/updated". */
57
+ const UNLESS_ADDED_RE = /\bunless\s+(?:it\s+is\s+|it'?s\s+|they\s+are\s+|first\s+)?(?:add|includ|regist|updat)\w*/i;
58
+ /** "won't be type-checked/compiled/linted/covered … unless …". */
59
+ const NOT_CHECKED_UNLESS_RE = /\b(?:won'?t|will\s+not|cannot|can'?t|does\s+not|doesn'?t|is\s+not|isn'?t)\s+(?:be\s+)?[\w\s-]{0,40}?(?:type-?check|compil|lint|cover|resolv|recogni[sz]|includ|pick)\w*[^;]{0,80}?\bunless\b/i;
60
+ /** Directional active: "requires adding … to …" / "must add … to/into/in …".
61
+ * The preposition requirement is what keeps a response-shape "must include
62
+ * `field`" sentence from counting as a registration edit. */
63
+ const REQUIRES_DIRECTIONAL_RE = /\brequires?\s+(?:add|includ|regist|updat|edit|modify|chang)\w*[^;]{0,80}?\b(?:to|into|in)\b/i;
64
+ const MUST_ADD_DIRECTIONAL_RE = /\bmust\s+(?:also\s+)?(?:add|includ|regist|updat)\w*[^;]{0,80}?\b(?:to|into|in)\b/i;
65
+ /** The prose-surrender pair: an artifact "will not be covered/type-checked/…"
66
+ * BECAUSE the (frozen) path "is not modified". Both halves must be present —
67
+ * this is the exact contradiction shape the live TASK_0020 spec shipped. */
68
+ const NOT_COVERED_RE = /\b(?:will\s+not|won'?t|cannot|can'?t|is\s+not|isn'?t)\s+(?:be\s+)?(?:covered|type-?checked|checked|compiled|linted|validated|included)\b/i;
69
+ const BECAUSE_NOT_MODIFIED_RE = /\b(?:since|because|as)\b[^;]{0,80}?\b(?:is\s+|are\s+|was\s+|were\s+)?not\s+(?:be(?:ing)?\s+)?(?:modif|edit|chang|updat|touch)\w*/i;
70
+ function isRequiresEditStatement(sentence) {
71
+ return (PASSIVE_REG_RE.test(sentence)
72
+ || UNLESS_ADDED_RE.test(sentence)
73
+ || NOT_CHECKED_UNLESS_RE.test(sentence)
74
+ || REQUIRES_DIRECTIONAL_RE.test(sentence)
75
+ || MUST_ADD_DIRECTIONAL_RE.test(sentence)
76
+ || (NOT_COVERED_RE.test(sentence) && BECAUSE_NOT_MODIFIED_RE.test(sentence)));
77
+ }
78
+ /**
79
+ * Split a spec line into sentences: a `.` or `;` followed by whitespace and a
80
+ * capital/backtick/bracket opener ends a sentence. The opener requirement
81
+ * keeps `e.g. foo` and mid-path dots intact; backtick paths carry no `. ` so
82
+ * they never split. Judging per sentence is what makes one-giant-line GOALs
83
+ * scannable without cross-sentence false fires.
84
+ */
85
+ function splitSentences(line) {
86
+ return line.split(/[.;]\s+(?=[A-Z`([-])/);
87
+ }
88
+ /** Backtick-quoted path-shaped tokens in a sentence (the same shape rule the
89
+ * prohibition extractor applies), minus surrounding quotes. */
90
+ function pathTokensIn(sentence) {
91
+ const out = [];
92
+ for (const m of sentence.matchAll(/`([^`]+)`/g)) {
93
+ const token = m[1].trim().replace(/^["']|["']$/g, '');
94
+ if (!/^[\w.@~/-]+$/.test(token))
95
+ continue;
96
+ if (token.includes('/') || /\.[A-Za-z0-9]+$/.test(token) || token.startsWith('.')) {
97
+ out.push(token);
98
+ }
99
+ }
100
+ return out;
101
+ }
102
+ /** Scan one text's sentences for requires-edit statements naming a blanket
103
+ * frozen path, appending each new pair to `out`.
104
+ *
105
+ * `anchorSpec` (research scans only): a research statement counts ONLY when,
106
+ * besides the frozen path, it names at least one other path that still
107
+ * appears in the SPEC. That anchor is what makes resolution (b) terminal:
108
+ * when the rewrite DROPS the file creation, the created file vanishes from
109
+ * the spec, the research statement about it loses its anchor, and the
110
+ * detector goes quiet instead of re-firing forever on stale research. */
111
+ function scanForStatements(text, blanket, source, out, seen, anchorSpec) {
112
+ for (const raw of text.split('\n')) {
113
+ for (const fragment of splitSentences(raw)) {
114
+ const sentence = fragment.trim();
115
+ if (sentence.length === 0)
116
+ continue;
117
+ // A prohibition-shaped sentence IS the freeze side (the constraint
118
+ // itself, or a "Preserve/do not remove or modify" restatement) -
119
+ // never the requires-edit side.
120
+ if (PROHIBITION_RE.test(sentence))
121
+ continue;
122
+ if (!isRequiresEditStatement(sentence))
123
+ continue;
124
+ for (const p of blanket) {
125
+ if (!pathNamedIn(sentence, p.path))
126
+ continue;
127
+ if (anchorSpec !== undefined) {
128
+ const anchors = pathTokensIn(sentence).filter(t => t !== p.path && !pathNamedIn(p.path, t));
129
+ if (!anchors.some(a => pathNamedIn(anchorSpec, a)))
130
+ continue;
131
+ }
132
+ const key = `${p.path} ${sentence}`;
133
+ if (seen.has(key))
134
+ continue;
135
+ seen.add(key);
136
+ out.push({ path: p.path, constraint: p.constraint, statement: sentence, source });
137
+ }
138
+ }
139
+ }
140
+ }
141
+ /**
142
+ * Find every unsatisfiable pair in the composed spec: a BLANKET frozen path
143
+ * whose registration/edit is declared necessary by the spec's own body (or
144
+ * explicitly surrendered to), or - when `research` is given - by the task's
145
+ * research the spec was composed FROM. The frozen paths always come from the
146
+ * SPEC alone; research contributes only the statement side (live compose
147
+ * sometimes drops the research's "must also be included" nuance from the spec
148
+ * text while shipping the freeze and the file creation - the contradiction is
149
+ * then visible only across the compose boundary). Deterministic, pure text -
150
+ * the same extraction the live A/B grounds its measurements in, so the model
151
+ * cannot self-report its way past it. Empty on a null spec or one that froze
152
+ * nothing.
153
+ */
154
+ export function findFrozenPathConflicts(spec, research) {
155
+ if (!spec)
156
+ return [];
157
+ const blanket = extractProhibitions(spec)
158
+ .filter(p => !EXCEPTION_RE.test(p.constraint))
159
+ .map(p => ({
160
+ path: p.path.replace(/^\.\//, '').replace(/\/+$/, ''),
161
+ constraint: p.constraint
162
+ }))
163
+ .filter(p => p.path.length > 0);
164
+ if (blanket.length === 0)
165
+ return [];
166
+ const out = [];
167
+ const seen = new Set();
168
+ scanForStatements(spec, blanket, 'spec', out, seen);
169
+ if (research)
170
+ scanForStatements(research, blanket, 'research', out, seen, spec);
171
+ return out;
172
+ }
173
+ /**
174
+ * The forced critique-rewrite defect text (skip-escape pattern: MANDATORY,
175
+ * self-contained, names the exact resolution options). The two permitted
176
+ * resolutions come straight from the incident analysis: scoped ownership or
177
+ * dropping the creation — prose acknowledging the gap is called out as a
178
+ * non-resolution because that is precisely what the live model shipped.
179
+ */
180
+ export function frozenConflictProbeText(conflicts) {
181
+ const items = conflicts.map(c => `- the spec FREEZES \`${c.path}\` ("${c.constraint.slice(0, 160)}") yet `
182
+ + (c.source === 'research' ?
183
+ `the task's own RESEARCH (the input this spec was composed from) states the `
184
+ + `deliverable REQUIRES changing it ("${c.statement.slice(0, 160)}") — the spec `
185
+ + `omitting this fact does not remove the requirement`
186
+ : `its own body states the deliverable REQUIRES changing it `
187
+ + `("${c.statement.slice(0, 160)}")`));
188
+ return [
189
+ 'UNSATISFIABLE-CONSTRAINT FINDING (deterministic; MUST be resolved, it overrides a CLEAN triage):',
190
+ ...items,
191
+ 'This pair is self-contradictory: the files this task creates need a registration edit',
192
+ 'that the spec itself forbids, and the step that "owns" the frozen file has already',
193
+ 'completed — no task will ever be allowed to perform the edit. The moment the created',
194
+ 'files land, the repo-wide static check fails PERMANENTLY and every later task burns',
195
+ 'its autofix rounds on a defect none of them may touch. Prose acknowledging the gap',
196
+ '("accept that it will not be covered…") is NOT a resolution.',
197
+ 'REWRITE the spec to resolve it in exactly ONE of these two ways:',
198
+ ' (a) SCOPED OWNERSHIP — replace the blanket freeze on the conflicting path with:',
199
+ ' "You MAY edit `<path>` ONLY to register the files this task creates (e.g. add',
200
+ ' them to its include list); any other change to `<path>` is forbidden." Keep the',
201
+ ' blanket freeze for the other frozen paths.',
202
+ ' (b) DROP the creation of the files that would require the frozen edit (and remove',
203
+ ' the acceptance/verify steps that depend on them), stating why.',
204
+ 'Never ship both the blanket freeze and the requires-edit statement.'
205
+ ].join('\n');
206
+ }
@@ -11,6 +11,15 @@ export type FrozenGit = (args: string[]) => Promise<{
11
11
  * no-op by construction.
12
12
  */
13
13
  export declare function frozenPathsFromSpec(spec: string | null | undefined): string[];
14
+ /**
15
+ * Does this prose/tool-output text NAME the given path? Word-bounded on both
16
+ * sides so `tsconfig.json` matches `` `tsconfig.json` ``, `(tsconfig.json:18)`
17
+ * and a bare mention, but never `foo.tsconfig.json`, `config/tsconfig.json`
18
+ * (a different file) or `tsconfig.json5`/`tsconfig.json.bak`. Shared by the
19
+ * compose-time unsatisfiable-pair detector (frozen-conflict.ts) and lint-fix's
20
+ * non-convergence trace, so "the text names a frozen path" means one thing.
21
+ */
22
+ export declare function pathNamedIn(text: string, path: string): boolean;
14
23
  /**
15
24
  * Parse `git status --porcelain` output (already scoped to the frozen pathspec)
16
25
  * into the list of changed files, for the gate-trail record and to decide whether
@@ -48,6 +48,21 @@ export function frozenPathsFromSpec(spec) {
48
48
  }
49
49
  return [...seen];
50
50
  }
51
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
52
+ /**
53
+ * Does this prose/tool-output text NAME the given path? Word-bounded on both
54
+ * sides so `tsconfig.json` matches `` `tsconfig.json` ``, `(tsconfig.json:18)`
55
+ * and a bare mention, but never `foo.tsconfig.json`, `config/tsconfig.json`
56
+ * (a different file) or `tsconfig.json5`/`tsconfig.json.bak`. Shared by the
57
+ * compose-time unsatisfiable-pair detector (frozen-conflict.ts) and lint-fix's
58
+ * non-convergence trace, so "the text names a frozen path" means one thing.
59
+ */
60
+ export function pathNamedIn(text, path) {
61
+ const p = path.replace(/^\.\//, '').replace(/\/+$/, '');
62
+ if (p.length === 0)
63
+ return false;
64
+ return new RegExp(`(?:^|[^\\w./-])${escapeRe(p)}(?!\\.?[\\w-])`, 'im').test(text);
65
+ }
51
66
  /**
52
67
  * Parse `git status --porcelain` output (already scoped to the frozen pathspec)
53
68
  * into the list of changed files, for the gate-trail record and to decide whether
@@ -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