@mjasnikovs/pi-task 0.28.1 → 0.28.3

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.
@@ -55,6 +55,25 @@ export interface AutoDeps extends GateDeps {
55
55
  * handling is skipped entirely (prior behavior).
56
56
  */
57
57
  pendingChanges?: (cwd: string) => Promise<string[]>;
58
+ /**
59
+ * Re-derive the still-open ACCEPT-debt ledger against the tree AS IT IS NOW
60
+ * (final-gate.ts `deriveOpenDebts`). Needed because the run's "N recorded
61
+ * verify-FAIL defect(s) are STILL unresolved" report used to be built from the
62
+ * FIRST gate result and the converged-autofix path then rebuilt the gate
63
+ * outcome as a bare `{ok, reason}` — so `openDebts` was not merely un-actioned,
64
+ * it was GONE from the value, and no code path could ever clear, re-check or
65
+ * act on it (mx5 run 18: four defects reported STILL OPEN at 14:58, one of them
66
+ * fixed by the autofix that converged at 15:03, and the report never moved).
67
+ *
68
+ * `staticOk` is the caller's PROOF about the current statics, never a guess:
69
+ * pass true only where the gate itself just passed them. Absent (tests) → the
70
+ * post-autofix re-check is skipped and the pre-autofix report stands, exactly
71
+ * the prior behavior.
72
+ */
73
+ recheckOpenDebts?: (cwd: string, staticOk: boolean) => Promise<{
74
+ openDebts: AcceptDebt[];
75
+ debtNote?: string;
76
+ }>;
58
77
  }
59
78
  /**
60
79
  * Expand any @file references in the feature text by appending each referenced
@@ -33,7 +33,7 @@ import { getParentContextWindow, resolveContextUsage } from './context-usage.js'
33
33
  import { buildGateDeps, collectTreeChanges } from './gate-deps.js';
34
34
  import { runGatesForTask } from './task-gates.js';
35
35
  import { gitUnmergedPaths, gitStashRef } from './auto-commit.js';
36
- import { runFinalIntegrationGate } from './final-gate.js';
36
+ import { runFinalIntegrationGate, deriveOpenDebts } from './final-gate.js';
37
37
  import { describeDebt, recordFinalGateUnobservedDebt } from './accept-debt.js';
38
38
  import { applyDemotions, isNonProgress, normalizeFailureDetail, rankedFirstFailure, unobservedDebtReason } from './final-gate-progress.js';
39
39
  import { classifyFinalGateAnswer, MAX_FINAL_GATE_AUTOFIX, FINAL_LEAVE_LABEL, FINAL_LEAVE_VALUE, FINAL_ACCEPT_LABEL, FINAL_ACCEPT_VALUE, FINAL_AUTOFIX_LABEL, FINAL_AUTOFIX_VALUE, STRANDED_FIX_COMMIT, strandedFixNote } from './final-gate-fix.js';
@@ -1103,7 +1103,11 @@ function defaultDeps(ctx, cwd, signal, title) {
1103
1103
  pendingChanges: async (cwd2) => {
1104
1104
  const changes = await collectTreeChanges(cwd2, signal);
1105
1105
  return [...changes.modified, ...changes.added, ...changes.deleted].sort();
1106
- }
1106
+ },
1107
+ // Re-derive the debt ledger against the FINAL tree after a converged
1108
+ // autofix (nexttask 6). Only ever reached from inside the gate's own
1109
+ // resolution loop, so it needs no `verify work` switch of its own.
1110
+ recheckOpenDebts: (cwd2, staticOk) => deriveOpenDebts(cwd2, staticOk)
1107
1111
  };
1108
1112
  }
1109
1113
  // ─── Loop ────────────────────────────────────────────────────────────────────
@@ -1222,12 +1226,79 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
1222
1226
  // gate moment — on PASS or FAIL — so a run never completes silently
1223
1227
  // carrying an accepted defect. Informational: the per-task ACCEPT was
1224
1228
  // already a human decision, so this reports, it does not re-fail.
1225
- if (fin.openDebts && fin.openDebts.length > 0) {
1226
- for (const d of fin.openDebts) {
1229
+ const debtKey = (d) => `${d.taskId}\t${d.reason}`;
1230
+ const surfaceOpenDebts = async (debts) => {
1231
+ if (debts.length === 0)
1232
+ return;
1233
+ for (const d of debts) {
1227
1234
  await recGate(`defect STILL OPEN — ${d.taskId || '(unknown task)'}: ${describeDebt(d)}: ${d.reason.slice(0, 240)}${d.conflict ? ` [CONFLICTING CLAIM — ${d.conflict}]` : ''}`);
1228
1235
  }
1229
- active.ui.notify(`${id}: ${fin.openDebts.length} recorded verify-FAIL defect(s) are STILL unresolved at run end — see the gate trail.`, 'warning');
1230
- }
1236
+ active.ui.notify(`${id}: ${debts.length} recorded verify-FAIL defect(s) are STILL unresolved at run end — see the gate trail.`, 'warning');
1237
+ };
1238
+ // What was REPORTED, so a post-autofix re-derivation can be
1239
+ // compared against it rather than blindly re-printed.
1240
+ let reportedDebts = fin.openDebts ?? [];
1241
+ await surfaceOpenDebts(reportedDebts);
1242
+ /**
1243
+ * nexttask 6 (mx5 run 18). The lines above are emitted from the
1244
+ * FIRST gate result; the converged-autofix paths below used to
1245
+ * rebuild `fin` as `{ok, reason}`, so `openDebts` did not survive
1246
+ * the fix pass — the run's last word on its own defects was a
1247
+ * snapshot of a tree that no longer existed, and no code path
1248
+ * could clear, re-check or act on it. Re-derive here, against the
1249
+ * tree the run actually ends with, and correct the record.
1250
+ *
1251
+ * FP-safe by inheritance: `deriveOpenDebts` auto-closes only what
1252
+ * a deterministic check can stand behind (a static-class debt when
1253
+ * the statics provably pass, a cross-task-deletion whose file is
1254
+ * back). Anything model-judged or behavioral STAYS OPEN —
1255
+ * `inv-no-false-clear`. `staticOk` is therefore only ever passed
1256
+ * true where the gate itself just passed the statics.
1257
+ */
1258
+ const reconcileDebts = async (staticOk) => {
1259
+ if (!deps.recheckOpenDebts)
1260
+ return;
1261
+ let fresh;
1262
+ try {
1263
+ fresh = await deps.recheckOpenDebts(cwd, staticOk);
1264
+ }
1265
+ catch {
1266
+ // A ledger read fault is inconclusive: say nothing rather
1267
+ // than imply the defects cleared.
1268
+ return;
1269
+ }
1270
+ fin = {
1271
+ ...fin,
1272
+ openDebts: fresh.openDebts,
1273
+ ...(fresh.debtNote ? { debtNote: fresh.debtNote } : {})
1274
+ };
1275
+ // Identity is (task, origin, reason), but a RESOLUTION claim
1276
+ // needs more than a key miss: a ledger entry whose TEXT changed
1277
+ // is the same defect re-recorded, never a fix. So a debt counts
1278
+ // as closed only when nothing for that (task, origin) survives.
1279
+ const slot = (d) => `${d.taskId}\t${d.origin ?? ''}`;
1280
+ const before = new Set(reportedDebts.map(debtKey));
1281
+ const after = new Set(fresh.openDebts.map(debtKey));
1282
+ const beforeSlots = new Set(reportedDebts.map(slot));
1283
+ const afterSlots = new Set(fresh.openDebts.map(slot));
1284
+ const closed = reportedDebts.filter(d => !after.has(debtKey(d)) && !afterSlots.has(slot(d)));
1285
+ const added = fresh.openDebts.filter(d => !before.has(debtKey(d)) && !beforeSlots.has(slot(d)));
1286
+ if (closed.length === 0 && added.length === 0) {
1287
+ if (reportedDebts.length > 0) {
1288
+ await recGate(`defect re-check after autofix: all ${reportedDebts.length} defect(s) `
1289
+ + 'above re-derived against the FINAL tree and still open');
1290
+ }
1291
+ return;
1292
+ }
1293
+ for (const d of closed) {
1294
+ await recGate(`defect RESOLVED by the final-gate autofix — ${d.taskId || '(unknown task)'}: `
1295
+ + `${d.reason.slice(0, 240)}`);
1296
+ }
1297
+ await surfaceOpenDebts(added);
1298
+ reportedDebts = fresh.openDebts;
1299
+ await recGate(`defect re-check after autofix: ${closed.length} resolved, `
1300
+ + `${fresh.openDebts.length} still open (re-derived against the FINAL tree)`);
1301
+ };
1231
1302
  // Resolution loop: Leave-failed (recommended) / Autofix (bounded,
1232
1303
  // model-driven fix pass + gate re-run — run 7's gap: the picker
1233
1304
  // had NO automated fix path) / Accept. The user always decides;
@@ -1371,6 +1442,9 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
1371
1442
  await recGate(`final-gate: autofix ${fix.unobserved ? 'ended UNOBSERVED' : 'converged'} — ${fix.reason.slice(0, 200)}`);
1372
1443
  active.ui.notify(`${id}: final integration gate ${fix.unobserved ? 'is UNOBSERVED' : 'PASSES'} after autofix — ${fix.reason.slice(0, 140)}`, fix.unobserved ? 'warning' : 'info');
1373
1444
  fin = { ok: true, reason: fix.reason };
1445
+ // The gate itself just passed, statics included, so
1446
+ // `staticOk` here is proof rather than assumption.
1447
+ await reconcileDebts(true);
1374
1448
  break;
1375
1449
  }
1376
1450
  await recGate(`final-gate: autofix attempt ${fixAttempts} failed — ${fix.reason.slice(0, 200)}`);
@@ -1434,6 +1508,12 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
1434
1508
  await recGate(`final-gate: ${converged}`);
1435
1509
  active.ui.notify(`${id}: final integration gate converged — ${converged}.`, 'warning');
1436
1510
  fin = { ok: true, reason: converged };
1511
+ // Converged on the REMAINING checks only: one or
1512
+ // more were DEMOTED as unfalsifiable here, and the
1513
+ // statics may be among them. No proof ⇒ pass false,
1514
+ // so nothing static-class can auto-close on this
1515
+ // door (inv-no-false-clear).
1516
+ await reconcileDebts(false);
1437
1517
  break;
1438
1518
  }
1439
1519
  }
@@ -446,6 +446,30 @@ export declare function bootSkipVerdict(args: {
446
446
  expectServer: boolean;
447
447
  }): string | null;
448
448
  export { taskThatIntroduced };
449
+ /**
450
+ * ACCEPT-debt re-check (mx5 run 4 B3 / run 8 TASK_0012): read the ledger of tasks
451
+ * the user accepted despite a verify-FAIL and re-check each against the CURRENT
452
+ * tree. A static-class debt whose statics now pass is provably RESOLVED (a later
453
+ * task fixed it) and pruned from the ledger; every other debt cannot be proven
454
+ * resolved deterministically, so it stays OPEN and is surfaced — a run may not
455
+ * complete silently carrying an accepted defect. FP-safe by construction (see
456
+ * accept-debt.ts). Best-effort: a ledger read/write failure must never break the
457
+ * caller.
458
+ *
459
+ * FACTORED OUT of runFinalIntegrationGate (nexttask 6): the derivation has to be
460
+ * runnable at a SECOND moment — after a converged final-gate autofix, where the
461
+ * orchestrator used to rebuild its gate outcome as a bare `{ok, reason}` and drop
462
+ * `openDebts` entirely. The report a run ends on has to be derived from the tree
463
+ * the run ends with, not from the tree as it was before the fix pass.
464
+ *
465
+ * `staticOk` is the caller's claim about the CURRENT statics, and it is the only
466
+ * thing that can auto-close a static-class debt — so a caller that does not know
467
+ * must pass `false` (unprovable ⇒ stays open), never a guess.
468
+ */
469
+ export declare function deriveOpenDebts(cwd: string, staticOk: boolean): Promise<{
470
+ openDebts: AcceptDebt[];
471
+ debtNote?: string;
472
+ }>;
449
473
  /**
450
474
  * Run the final gate: static analysis first, then the lockfile consistency
451
475
  * checks, then the discovered integration commands, then one boot exercise of
@@ -1132,6 +1132,44 @@ async function recoverOrphanPort(cwd, boot, first, bootGraceMs, deps, expectServ
1132
1132
  // PROMPT 2 extracted it for the cross-task deletion guards); re-exported so
1133
1133
  // existing importers keep working.
1134
1134
  export { taskThatIntroduced };
1135
+ /**
1136
+ * ACCEPT-debt re-check (mx5 run 4 B3 / run 8 TASK_0012): read the ledger of tasks
1137
+ * the user accepted despite a verify-FAIL and re-check each against the CURRENT
1138
+ * tree. A static-class debt whose statics now pass is provably RESOLVED (a later
1139
+ * task fixed it) and pruned from the ledger; every other debt cannot be proven
1140
+ * resolved deterministically, so it stays OPEN and is surfaced — a run may not
1141
+ * complete silently carrying an accepted defect. FP-safe by construction (see
1142
+ * accept-debt.ts). Best-effort: a ledger read/write failure must never break the
1143
+ * caller.
1144
+ *
1145
+ * FACTORED OUT of runFinalIntegrationGate (nexttask 6): the derivation has to be
1146
+ * runnable at a SECOND moment — after a converged final-gate autofix, where the
1147
+ * orchestrator used to rebuild its gate outcome as a bare `{ok, reason}` and drop
1148
+ * `openDebts` entirely. The report a run ends on has to be derived from the tree
1149
+ * the run ends with, not from the tree as it was before the fix pass.
1150
+ *
1151
+ * `staticOk` is the caller's claim about the CURRENT statics, and it is the only
1152
+ * thing that can auto-close a static-class debt — so a caller that does not know
1153
+ * must pass `false` (unprovable ⇒ stays open), never a guess.
1154
+ */
1155
+ export async function deriveOpenDebts(cwd, staticOk) {
1156
+ const { open: openRaw, resolved } = recheckAcceptDebts(await readAcceptDebts(cwd), {
1157
+ staticOk,
1158
+ // Cross-task-deletion debts auto-close iff the deleted file is back in the
1159
+ // tree — a deterministic existence check, corroborating the per-file
1160
+ // provenance the record already carries.
1161
+ fileExists: rel => existsSync(path.join(cwd, rel))
1162
+ });
1163
+ if (resolved.length > 0)
1164
+ await writeAcceptDebts(cwd, openRaw);
1165
+ // Conflicting-claim annotation (mx5 run 11): an existence-as-failure debt whose
1166
+ // named file is another task's committed deliverable is a plan defect — surface
1167
+ // the contradiction with the debt so nobody (human or child) treats the claim as
1168
+ // a deletion instruction. Pure git-history lookup; degrades to no annotation.
1169
+ const openDebts = annotateDebtConflicts(openRaw, p => taskThatIntroduced(cwd, p));
1170
+ const debtNote = buildAcceptDebtNote(openDebts);
1171
+ return { openDebts, ...(debtNote ? { debtNote } : {}) };
1172
+ }
1135
1173
  /**
1136
1174
  * Run the final gate: static analysis first, then the lockfile consistency
1137
1175
  * checks, then the discovered integration commands, then one boot exercise of
@@ -1150,29 +1188,7 @@ export { taskThatIntroduced };
1150
1188
  */
1151
1189
  export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGraceMs = 10_000, bootDeps = {}, planText) {
1152
1190
  const stat = runRepoHealthCheck(cwd);
1153
- // ACCEPT-debt re-check (mx5 run 4 B3 / run 8 TASK_0012): read the ledger of tasks
1154
- // the user accepted despite a verify-FAIL and re-check each against the current
1155
- // tree. A static-class debt whose statics now pass is provably RESOLVED (a later
1156
- // task fixed it) and pruned; every other debt cannot be proven resolved
1157
- // deterministically, so it stays OPEN and is surfaced in this gate's report — a
1158
- // run may not complete silently carrying an accepted defect. FP-safe by
1159
- // construction (see accept-debt.ts). Best-effort: a ledger read/write failure
1160
- // must never break the gate.
1161
- const { open: openRaw, resolved } = recheckAcceptDebts(await readAcceptDebts(cwd), {
1162
- staticOk: stat.ok,
1163
- // Cross-task-deletion debts auto-close iff the deleted file is back in the
1164
- // tree — a deterministic existence check, corroborating the per-file
1165
- // provenance the record already carries.
1166
- fileExists: rel => existsSync(path.join(cwd, rel))
1167
- });
1168
- if (resolved.length > 0)
1169
- await writeAcceptDebts(cwd, openRaw);
1170
- // Conflicting-claim annotation (mx5 run 11): an existence-as-failure debt whose
1171
- // named file is another task's committed deliverable is a plan defect — surface
1172
- // the contradiction with the debt so nobody (human or child) treats the claim as
1173
- // a deletion instruction. Pure git-history lookup; degrades to no annotation.
1174
- const openDebts = annotateDebtConflicts(openRaw, p => taskThatIntroduced(cwd, p));
1175
- const debtNote = buildAcceptDebtNote(openDebts);
1191
+ const { openDebts, debtNote } = await deriveOpenDebts(cwd, stat.ok);
1176
1192
  // The debt note rides in its OWN field: `reason` stays the mechanical failure
1177
1193
  // because it seeds the autofix child's prompt (see FinalGateOutcome.reason —
1178
1194
  // run 11's fix child executed a recorded claim as an instruction).
@@ -0,0 +1,95 @@
1
+ import type { OwnedRequirement } from './requirements.js';
2
+ /** A freeze whose scope is a CATEGORY of files, with the paths it carves out. */
3
+ export interface CategoryFreeze {
4
+ /** The freeze line, verbatim. */
5
+ constraint: string;
6
+ /** The paths the category exempts — everything else is frozen. */
7
+ exempt: string[];
8
+ }
9
+ /**
10
+ * One unsatisfiable pair, keyed by the REQUIREMENT rather than by the path.
11
+ *
12
+ * Grouping matters for the resolution's size. mx5 run 18's TASK_0023 carries
13
+ * two owned build-contract clauses that between them name three frozen paths;
14
+ * per-path findings would demand three separate ownership grants (and the same
15
+ * pair twice over, because that spec states its freeze in CONSTRAINTS and again
16
+ * in ACCEPTANCE). Per requirement, the rewrite is told which files that one
17
+ * obligation names and grants only what it needs — which is what keeps
18
+ * `inv-no-spec-inflation` satisfiable at all.
19
+ */
20
+ export interface OwnedFreezeConflict {
21
+ /** The owned requirement line, verbatim. */
22
+ requirement: string;
23
+ /** The files it names that this freeze covers. */
24
+ paths: string[];
25
+ /** The category freeze line, verbatim. */
26
+ constraint: string;
27
+ /** The paths that freeze exempts (the resolution has to widen this, or
28
+ * move the requirement to a task whose scope already includes the file). */
29
+ exempt: string[];
30
+ }
31
+ /**
32
+ * Backtick-quoted path-shaped tokens in a text, INCLUDING the ones embedded in
33
+ * a backticked command (`bun run --watch src/server/index.ts` is one backtick
34
+ * span; the path inside it is what the requirement is about). Route literals
35
+ * (`/api`) and bare directories survive this filter — the caller decides what
36
+ * to do with them; `findOwnedFreezeConflicts` keeps only files.
37
+ */
38
+ export declare function pathTokensIn(text: string): string[];
39
+ /**
40
+ * Every CATEGORY freeze in the spec: a modification ban (or its passive/only-
41
+ * form equivalent) scoped to a class of files rather than to named paths. A
42
+ * freeze that only names paths is `frozen-conflict.ts`'s job, not this one.
43
+ */
44
+ export declare function findCategoryFreezes(spec: string | null | undefined): CategoryFreeze[];
45
+ /**
46
+ * The spec lines carrying an AUTHORITATIVE owned requirement: the machine-
47
+ * stamped ones, plus — when the run's ledger is supplied — any line carrying an
48
+ * owned quote verbatim (compose folding the quote in itself leaves no stamp).
49
+ */
50
+ export declare function ownedRequirementLines(spec: string | null | undefined, owned?: OwnedRequirement[]): string[];
51
+ export interface OwnedFreezeOptions {
52
+ /** The run's owned-requirement ledger, so belt-folded (unstamped) quotes
53
+ * count too. Omitted → stamped lines only. */
54
+ owned?: OwnedRequirement[];
55
+ /**
56
+ * Is this repo-relative token an existing SOURCE file of the tree the spec
57
+ * will run against? Supplied by the caller (compose knows its cwd; the
58
+ * measured implementation is "tracked by git" — see `trackedSourceOracle`).
59
+ *
60
+ * Two false-positive classes die here, both observed at STEP 0 on the real
61
+ * TASK_0023 spec: route literals and build outputs (`/api`, `dist/`,
62
+ * `dist/app.css` — named by the very clause that is the true positive, but
63
+ * not files anyone can edit), and files a task is about to CREATE, which a
64
+ * freeze on the existing tree does not block. Omitted → every path-shaped
65
+ * token counts (the broad reading, reported alongside at STEP 0).
66
+ */
67
+ isSource?: (p: string) => boolean;
68
+ }
69
+ /**
70
+ * Every unsatisfiable pair in the composed spec: an AUTHORITATIVE owned
71
+ * requirement naming a file that a CATEGORY freeze in the same spec forbids
72
+ * touching. Deterministic, pure text (plus the caller's existence oracle).
73
+ * Empty when the spec froze no category or carries no owned requirement — the
74
+ * ordinary single-`/task` case degrades to a no-op.
75
+ */
76
+ export declare function findOwnedFreezeConflicts(spec: string | null | undefined, opts?: OwnedFreezeOptions): OwnedFreezeConflict[];
77
+ /**
78
+ * The measured `isSource` oracle: a token counts only when git tracks it in
79
+ * `cwd`. Tracked ⇒ it exists, it is not a build output, and it is not ignored —
80
+ * exactly the "source file" the category freezes talk about. `git` missing or
81
+ * the tree not a repo ⇒ every token counts (the broad reading), so the detector
82
+ * degrades toward firing rather than toward silent blindness.
83
+ */
84
+ export declare function trackedSourceOracle(lsFiles: (p: string) => {
85
+ stdout: string;
86
+ exitCode: number;
87
+ }): (p: string) => boolean;
88
+ /**
89
+ * The forced critique-rewrite defect text, in the shape the existing four
90
+ * families use: MANDATORY, self-contained, naming the exact resolutions. Prose
91
+ * surrender and silently dropping the requirement are called out as
92
+ * non-resolutions because narrowing the clause is precisely what run 16 did and
93
+ * freezing its file is precisely what run 18 did.
94
+ */
95
+ export declare function ownedFreezeConflictProbeText(conflicts: OwnedFreezeConflict[]): string;
@@ -0,0 +1,359 @@
1
+ /**
2
+ * owned-freeze-conflict — the FIFTH unsatisfiable-pair family (nexttask 7):
3
+ * an AUTHORITATIVE owned requirement whose file falls inside a CATEGORY freeze
4
+ * written by the same spec.
5
+ *
6
+ * WHY frozen-conflict.ts does not see it (mx5 run 18, TASK_0023). The owned
7
+ * channel worked: `.pi-tasks/requirements-owned.md` carried the design clause
8
+ * "**Server:** `bun run --watch src/server/index.ts` — serves `/api` + static
9
+ * `dist/`." and the composed spec carried it verbatim under CONSTRAINTS, marked
10
+ * AUTHORITATIVE. The same CONSTRAINTS block then said "Do not modify
11
+ * `docker-compose.dev.yml`, …, or any source files outside of `package.json`",
12
+ * and the spec's ACCEPTANCE/VERIFY converted the behavioural half of the clause
13
+ * ("serves `/api` + static `dist/`") into a string-match on `package.json`. The
14
+ * only file that could implement it — `src/server/index.ts` — was frozen. The
15
+ * requirement was structurally unsatisfiable inside its owning task, VERIFY
16
+ * PASSed honestly, and the app shipped with no static route.
17
+ *
18
+ * The existing detector misses this shape twice over (both visible in its own
19
+ * header): its freeze side needs a NAMED path (`pathNamedIn`), and run 18's
20
+ * freeze names a CATEGORY ("any source files outside of `package.json`"); its
21
+ * statement side must match one of four measured phrasing families, and a plain
22
+ * behavioural claim matches none of them.
23
+ *
24
+ * HIGH-PRECISION BY CONSTRUCTION — the statement side needs no NLP:
25
+ * - owned requirement lines are MACHINE-MARKED. `appendOwnedConstraints`
26
+ * stamps every one it appends with "owned requirement from the source
27
+ * design (AUTHORITATIVE; …)"; when compose folded the quote in by itself
28
+ * the marker is absent, so the caller may also pass the run's ledger
29
+ * entries and the quote is matched verbatim against the spec text.
30
+ * - the requirement names its path literally, so the intersection is lexical.
31
+ * - category freezes are a small closed lexical set ("any source files
32
+ * outside of X", "any files other than X", "only X may be modified",
33
+ * "no files outside X").
34
+ *
35
+ * ── STATUS: NOT WIRED. The critique seam FAILED its A/B, 2026-08-04. ─────────
36
+ *
37
+ * The detector is precise — 1 finding over 58 real composed specs, and it is the
38
+ * true positive (scripts/owned-freeze-conflict-fp-suite.ts, PASS; STEP 0 in
39
+ * scripts/owned-vs-freeze-baserate.ts). What failed is the LEVER built on it,
40
+ * for two independent reasons, both measured:
41
+ *
42
+ * 1. THE SEAM IS BLIND IN PRODUCTION. `appendOwnedConstraints` — the BRACES that
43
+ * stamp the machine-marked owned bullet this detector keys on — runs AFTER
44
+ * `critiqueWithFallback`, inside the `critique` step (phases.ts). When
45
+ * critique runs, the stamped line does not exist yet; compose's own folding
46
+ * is a PARAPHRASE ("The server watch command must match the contract exactly:
47
+ * `…` — serves `/api` + static `dist/`"), so neither the stamp nor the
48
+ * verbatim quote is there to match. Live: 0/40 compose drafts carried a
49
+ * detectable pair while 11/40 carried the clause semantically. A critique-
50
+ * time probe cannot see the shape it was designed for.
51
+ * 2. THE REWRITE RESOLVES IT BY DELETING THE REQUIREMENT. Forced through the
52
+ * controlled critique seam on run 18's real TASK_0023 draft (n=20/arm):
53
+ * pair-present 8/20 → 0/20, but the resolution was scoped ownership in only
54
+ * 9/20 — the other 11/20 removed the AUTHORITATIVE clause outright and
55
+ * rationalised it ("this references an existing file; no edits are required
56
+ * or permitted"), with 0 of the 11 reassigning it to the task that owns the
57
+ * file. VERIFY behaviour-observation was 6/20 in BOTH arms: the delivered
58
+ * spec still verifies the requirement by grepping `package.json`.
59
+ *
60
+ * Removal of the pair is not satisfaction of the requirement — the same lesson
61
+ * as the run-16 lever's delivery metric, one level down. Anything built here
62
+ * next has to act AFTER the braces, where the pair actually exists, and cannot
63
+ * be a model rewrite: the braces are the last spec-producing step.
64
+ *
65
+ * It is a pure text function so the base rate, the FP suite and the live A/B all
66
+ * measure the same object.
67
+ */
68
+ import { PROHIBITION_RE } from './prohibition-probe.js';
69
+ import { pathNamedIn } from './frozen-path-guard.js';
70
+ /**
71
+ * The machine stamp `appendOwnedConstraints` writes on every owned requirement
72
+ * it appends to CONSTRAINTS. Matching the stamp — not the prose — is what keeps
73
+ * the statement side free of NLP.
74
+ */
75
+ const OWNED_MARKER_RE = /owned\s+requirement\s+from\s+the\s+source\s+design/i;
76
+ /**
77
+ * Modification verbs, shared by the active and passive freeze families. Scoped
78
+ * to modification exactly as `PROHIBITION_RE` is: a "do not CREATE any files
79
+ * other than X" line is a creation ban and freezes no existing file, so it must
80
+ * never be read as a category freeze (mx5 run 18 TASK_0009 ships that line).
81
+ */
82
+ const MOD_VERB = 'modif|touch|edit|chang|alter|rewrit|overwrit';
83
+ /**
84
+ * A category noun phrase with an exception: "any source files outside of X",
85
+ * "any existing file other than X", "no files except X". `\w+` slots absorb the
86
+ * qualifiers seen in the corpora (source/existing/other), bounded so the phrase
87
+ * cannot span a whole paragraph.
88
+ */
89
+ const CATEGORY_NOUN = String.raw `(?:any|no)\s+(?:\w+\s+){0,3}?files?\b`;
90
+ const EXCEPT_KEYWORD = String.raw `(?:outside(?:\s+of)?|other\s+than|except(?:\s+for)?|besides|apart\s+from|beyond)`;
91
+ /**
92
+ * Active family: a modification ban whose object is the category phrase. The
93
+ * tempered gap forbids crossing a creation/addition verb, so the compound line
94
+ * "Do not create any files other than `X` and do not modify `Y`" — where the
95
+ * category belongs to the CREATE half — cannot be mis-read as a category freeze.
96
+ */
97
+ const ACTIVE_CATEGORY_RE = new RegExp(String.raw `\b(?:do\s+not|do\s+NOT|don'?t|must\s+not|never)\s+(?:${MOD_VERB})\w*\b`
98
+ + String.raw `(?:(?!\b(?:creat|add|introduc|generat)\w*\b)[\s\S]){0,200}?`
99
+ + String.raw `\b${CATEGORY_NOUN}[^\n]{0,40}?\b${EXCEPT_KEYWORD}\b`, 'i');
100
+ /**
101
+ * Passive family: "No files other than `package.json` are modified." — run 18's
102
+ * ACCEPTANCE line, identical in force to the CONSTRAINTS freeze above it.
103
+ */
104
+ const PASSIVE_CATEGORY_RE = new RegExp(String.raw `\b${CATEGORY_NOUN}[^\n]{0,40}?\b${EXCEPT_KEYWORD}\b`
105
+ + String.raw `[^\n]{0,120}?\b(?:are|is|may|must|should|can|will)\s+(?:be\s+|been\s+)?(?:${MOD_VERB})\w*`, 'i');
106
+ /**
107
+ * A SCOPED-OWNERSHIP grant — "You MAY edit `X` ONLY to …/ONLY as far as …" —
108
+ * which is the resolution this detector demands. Whatever else the spec says,
109
+ * the paths named on such a line are editable, so they can never be the frozen
110
+ * side of a pair. Without this the rewrite that grants ownership on a NEW line
111
+ * while leaving the category freeze in place would re-fire forever.
112
+ */
113
+ const SCOPED_GRANT_RE = new RegExp(String.raw `\b(?:may|can|are\s+allowed\s+to|is\s+allowed\s+to)\s+(?:${MOD_VERB})\w*\b[^\n]{0,80}?\bonly\b`, 'i');
114
+ /** "Only `X` may be modified" / "Only `X` is edited". */
115
+ const ONLY_CATEGORY_RE = new RegExp(String.raw `\bonly\b[^\n]{0,60}?\b(?:may|must|should|can|will|is|are)\s+(?:be\s+)?(?:${MOD_VERB})\w*`, 'i');
116
+ /**
117
+ * Backtick-quoted path-shaped tokens in a text, INCLUDING the ones embedded in
118
+ * a backticked command (`bun run --watch src/server/index.ts` is one backtick
119
+ * span; the path inside it is what the requirement is about). Route literals
120
+ * (`/api`) and bare directories survive this filter — the caller decides what
121
+ * to do with them; `findOwnedFreezeConflicts` keeps only files.
122
+ */
123
+ export function pathTokensIn(text) {
124
+ return tokensWithOrigin(text).map(t => t.path);
125
+ }
126
+ function tokensWithOrigin(text) {
127
+ const out = [];
128
+ const seen = new Set();
129
+ for (const m of text.matchAll(/`([^`]+)`/g)) {
130
+ const span = m[1];
131
+ const fromCommand = /\s/.test(span.trim());
132
+ for (const raw of span.split(/[\s,;()"']+/)) {
133
+ const token = raw.trim().replace(/[.,;:]+$/, '');
134
+ if (token.length === 0 || !/^[\w.@~/-]+$/.test(token))
135
+ continue;
136
+ if (!(token.includes('/') || /\.[A-Za-z0-9]+$/.test(token) || token.startsWith('.'))) {
137
+ continue;
138
+ }
139
+ // A leading `/` is a route literal or an absolute path — never a
140
+ // repo-relative source file, and `/api` is named by the very clause
141
+ // that is the true positive, so this one is not hypothetical.
142
+ if (token.startsWith('/'))
143
+ continue;
144
+ const n = token.replace(/^\.\//, '').replace(/\/+$/, '');
145
+ if (n.length === 0 || seen.has(n))
146
+ continue;
147
+ seen.add(n);
148
+ out.push({ path: n, fromCommand });
149
+ }
150
+ }
151
+ return out;
152
+ }
153
+ /**
154
+ * Does the requirement claim anything about the files it names BEYOND quoting a
155
+ * command that mentions them?
156
+ *
157
+ * This is what separates the two build-contract clauses of mx5 run 18's
158
+ * TASK_0023, which are otherwise the same shape:
159
+ *
160
+ * "**Client CSS:** `bunx @tailwindcss/cli -i src/client/index.css -o dist/app.css`"
161
+ * "**Server:** `bun run --watch src/server/index.ts` — serves `/api` + static `dist/`."
162
+ *
163
+ * The first is satisfied by putting that command in `package.json`; nothing
164
+ * about `src/client/index.css` has to change, so the freeze does not make it
165
+ * impossible. The second attaches a BEHAVIOURAL claim to the quoted file, and
166
+ * that claim can only be satisfied inside the file. Prose outside the backtick
167
+ * spans — minus the label, the anchor tag and the machine marker — is the
168
+ * signal; two words of it are enough.
169
+ */
170
+ function hasClaimOutsideCommand(requirement) {
171
+ const prose = requirement
172
+ .replace(/`[^`]*`/g, ' ')
173
+ .replace(/—\s*owned\s+requirement\s+from\s+the\s+source\s+design[\s\S]*$/i, ' ')
174
+ .replace(/\[[^\]]*\]/g, ' ')
175
+ .replace(/\*\*[^*]*\*\*/g, ' ')
176
+ .replace(/^[\s\-"']+/, ' ');
177
+ const words = prose.match(/\b[A-Za-z][A-Za-z-]{1,}\b/g) ?? [];
178
+ return words.length >= 2;
179
+ }
180
+ /**
181
+ * The clause the exception keyword governs: from the keyword to the first
182
+ * clause break (an em dash, a semicolon, or a sentence end). Everything else on
183
+ * the line — notably the "— all engine modules (`document.ts`, …) must remain
184
+ * untouched" tail of gofer-pixel's freeze — is NOT an exemption.
185
+ */
186
+ function exemptClause(line) {
187
+ const m = new RegExp(String.raw `\b${EXCEPT_KEYWORD}\b`, 'i').exec(line);
188
+ if (!m)
189
+ return null;
190
+ const rest = line.slice(m.index + m[0].length);
191
+ const brk = /\s+[—–]\s+|;|\.\s+[A-Z]|\.$/.exec(rest);
192
+ return brk ? rest.slice(0, brk.index) : rest;
193
+ }
194
+ /**
195
+ * Every CATEGORY freeze in the spec: a modification ban (or its passive/only-
196
+ * form equivalent) scoped to a class of files rather than to named paths. A
197
+ * freeze that only names paths is `frozen-conflict.ts`'s job, not this one.
198
+ */
199
+ export function findCategoryFreezes(spec) {
200
+ if (!spec)
201
+ return [];
202
+ const out = [];
203
+ const seen = new Set();
204
+ for (const raw of spec.split('\n')) {
205
+ const line = raw.trim();
206
+ if (line.length === 0 || seen.has(line))
207
+ continue;
208
+ const isCategory = ACTIVE_CATEGORY_RE.test(line)
209
+ || PASSIVE_CATEGORY_RE.test(line)
210
+ || (ONLY_CATEGORY_RE.test(line) && PROHIBITION_RE.test(line) === false);
211
+ if (!isCategory || SCOPED_GRANT_RE.test(line))
212
+ continue;
213
+ const clause = ONLY_CATEGORY_RE.test(line) && exemptClause(line) === null ? line : exemptClause(line);
214
+ seen.add(line);
215
+ out.push({ constraint: line, exempt: clause === null ? [] : pathTokensIn(clause) });
216
+ }
217
+ return out;
218
+ }
219
+ const basename = (p) => p.slice(p.lastIndexOf('/') + 1);
220
+ /**
221
+ * Is `p` inside the freeze — i.e. NOT one of the exempted paths, a file under
222
+ * an exempted directory, or the same file spelled shorter?
223
+ *
224
+ * The last clause is load-bearing. gofer-pixel TASK_0011 exempts
225
+ * `src/components/Canvas.tsx` while its owned requirement quotes the design's
226
+ * table cell, which says just `Canvas.tsx` — the same file, and the spec is
227
+ * correctly formed. A bare basename is matched against the exempted paths'
228
+ * basenames; a path WITH a directory must match exactly or by prefix, so
229
+ * `src/a/config.ts` never counts as exempted by `src/b/config.ts`.
230
+ */
231
+ function insideFreeze(p, exempt) {
232
+ return !exempt.some(e => e === p
233
+ || p.startsWith(`${e}/`)
234
+ || pathNamedIn(p, e)
235
+ || (!p.includes('/') && basename(e) === p));
236
+ }
237
+ /**
238
+ * The spec lines carrying an AUTHORITATIVE owned requirement: the machine-
239
+ * stamped ones, plus — when the run's ledger is supplied — any line carrying an
240
+ * owned quote verbatim (compose folding the quote in itself leaves no stamp).
241
+ */
242
+ export function ownedRequirementLines(spec, owned = []) {
243
+ if (!spec)
244
+ return [];
245
+ const quotes = owned.map(o => o.quote.trim()).filter(q => q.length > 0);
246
+ const out = [];
247
+ const seen = new Set();
248
+ for (const raw of spec.split('\n')) {
249
+ const line = raw.trim();
250
+ if (line.length === 0 || seen.has(line))
251
+ continue;
252
+ if (!OWNED_MARKER_RE.test(line) && !quotes.some(q => line.includes(q)))
253
+ continue;
254
+ seen.add(line);
255
+ out.push(line);
256
+ }
257
+ return out;
258
+ }
259
+ /**
260
+ * Every unsatisfiable pair in the composed spec: an AUTHORITATIVE owned
261
+ * requirement naming a file that a CATEGORY freeze in the same spec forbids
262
+ * touching. Deterministic, pure text (plus the caller's existence oracle).
263
+ * Empty when the spec froze no category or carries no owned requirement — the
264
+ * ordinary single-`/task` case degrades to a no-op.
265
+ */
266
+ export function findOwnedFreezeConflicts(spec, opts = {}) {
267
+ if (!spec)
268
+ return [];
269
+ const freezes = findCategoryFreezes(spec);
270
+ if (freezes.length === 0)
271
+ return [];
272
+ // Scoped ownership granted ANYWHERE in the spec settles the file, even when
273
+ // the grant is a line the rewrite added next to an untouched category
274
+ // freeze — otherwise a correctly resolved spec re-fires forever.
275
+ const granted = spec
276
+ .split('\n')
277
+ .filter(l => SCOPED_GRANT_RE.test(l))
278
+ .flatMap(l => pathTokensIn(l));
279
+ const out = [];
280
+ for (const requirement of ownedRequirementLines(spec, opts.owned)) {
281
+ // An owned requirement that is itself prohibition-shaped restates the
282
+ // freeze side; it can never be the thing the freeze makes impossible.
283
+ if (PROHIBITION_RE.test(requirement))
284
+ continue;
285
+ const claim = hasClaimOutsideCommand(requirement);
286
+ const named = tokensWithOrigin(requirement)
287
+ .filter(t => claim || !t.fromCommand)
288
+ .map(t => t.path)
289
+ .filter(p => !opts.isSource || opts.isSource(p));
290
+ if (named.length === 0)
291
+ continue;
292
+ // One finding per requirement: the FIRST freeze that covers any of its
293
+ // files. A spec that restates the same freeze under ACCEPTANCE (run 18
294
+ // does) must not double the rewrite's work.
295
+ for (const f of freezes) {
296
+ const paths = named.filter(p => insideFreeze(p, [...f.exempt, ...granted]));
297
+ if (paths.length === 0)
298
+ continue;
299
+ out.push({ requirement, paths, constraint: f.constraint, exempt: f.exempt });
300
+ break;
301
+ }
302
+ }
303
+ return out;
304
+ }
305
+ /**
306
+ * The measured `isSource` oracle: a token counts only when git tracks it in
307
+ * `cwd`. Tracked ⇒ it exists, it is not a build output, and it is not ignored —
308
+ * exactly the "source file" the category freezes talk about. `git` missing or
309
+ * the tree not a repo ⇒ every token counts (the broad reading), so the detector
310
+ * degrades toward firing rather than toward silent blindness.
311
+ */
312
+ export function trackedSourceOracle(lsFiles) {
313
+ const cache = new Map();
314
+ return p => {
315
+ const hit = cache.get(p);
316
+ if (hit !== undefined)
317
+ return hit;
318
+ const r = lsFiles(p);
319
+ const ok = r.exitCode !== 0 ? true : r.stdout.trim().length > 0;
320
+ cache.set(p, ok);
321
+ return ok;
322
+ };
323
+ }
324
+ /**
325
+ * The forced critique-rewrite defect text, in the shape the existing four
326
+ * families use: MANDATORY, self-contained, naming the exact resolutions. Prose
327
+ * surrender and silently dropping the requirement are called out as
328
+ * non-resolutions because narrowing the clause is precisely what run 16 did and
329
+ * freezing its file is precisely what run 18 did.
330
+ */
331
+ export function ownedFreezeConflictProbeText(conflicts) {
332
+ const items = conflicts.map(c => `- the spec carries the AUTHORITATIVE owned requirement `
333
+ + `"${c.requirement.slice(0, 200)}", which names ${c.paths.map(p => `\`${p}\``).join(', ')} — `
334
+ + `and the spec FREEZES ${c.paths.length > 1 ? 'those files' : 'that file'} with a CATEGORY freeze `
335
+ + `("${c.constraint.slice(0, 160)}"`
336
+ + (c.exempt.length > 0 ?
337
+ `, which exempts only ${c.exempt.map(e => `\`${e}\``).join(', ')}`
338
+ : '')
339
+ + ')');
340
+ return [
341
+ 'UNSATISFIABLE-CONSTRAINT FINDING (deterministic; MUST be resolved, it overrides a CLEAN triage):',
342
+ ...items,
343
+ 'An owned requirement is AUTHORITATIVE: it comes from the source design and this task',
344
+ 'owns it. A category freeze that covers the only file which could satisfy it makes the',
345
+ 'requirement structurally impossible INSIDE THIS TASK, and the task will still pass its',
346
+ 'own VERIFY — because VERIFY can then only assert strings in the files it is allowed to',
347
+ 'touch. Narrowing the requirement to what the unfrozen files can express, or dropping it,',
348
+ 'is NOT a resolution.',
349
+ 'REWRITE the spec to resolve it in exactly ONE of these two ways:',
350
+ ' (a) SCOPED OWNERSHIP — widen the category freeze for that file only:',
351
+ ' "You MAY edit `<path>` ONLY as far as the owned requirement requires; any other',
352
+ ' change to `<path>` is forbidden." Then make ACCEPTANCE state the requirement\'s',
353
+ ' BEHAVIOUR and make VERIFY exercise that behaviour, not the presence of a string.',
354
+ ' (b) REASSIGN — state that the owned requirement is not satisfiable in this task and',
355
+ " belongs to the task that owns `<path>`, and remove it from this spec's",
356
+ ' CONSTRAINTS/ACCEPTANCE so it is not falsely claimed as met here.',
357
+ 'Never ship both the category freeze and the owned requirement it makes impossible.'
358
+ ].join('\n');
359
+ }
@@ -132,7 +132,18 @@ export interface PhaseAutoAnswerDeps {
132
132
  export declare function phaseAutoAnswer(deps: PhaseDeps, refined: string, research: string, question: string, autoDeps?: PhaseAutoAnswerDeps): Promise<AutoAnswer>;
133
133
  export declare function phaseGrill(deps: PhaseDeps, ctx: ExtensionCommandContext, widgetState: WidgetState, refined: string, research: string): Promise<string>;
134
134
  export declare function phaseCompose(deps: PhaseDeps, refined: string, research: string, qa: string): Promise<string>;
135
- export declare function phaseCritique(deps: PhaseDeps, spec: string, refined: string, qa: string, planContext?: string, research?: string): Promise<string>;
135
+ export declare function phaseCritique(deps: PhaseDeps, spec: string, refined: string, qa: string, planContext?: string, research?: string,
136
+ /**
137
+ * An additional deterministic defect block, forced into the rewrite exactly
138
+ * like the probes below and overriding a CLEAN triage the same way.
139
+ *
140
+ * This is the A/B seam for a probe that is not wired yet: the discipline
141
+ * here is "wire only on PASS" (memory/prompt4-spec-urls-failed.md), so a
142
+ * candidate probe has to be measurable through the SHIPPED critique path
143
+ * rather than through a hand-copied replica of it, or the two arms differ by
144
+ * more than the probe. Undefined in production.
145
+ */
146
+ extraDefects?: string | null): Promise<string>;
136
147
  export declare function critiqueWithFallback(d: PhaseDeps, p: PhaseContext): Promise<string>;
137
148
  export declare const PHASES: PhaseConfig[];
138
149
  export declare function postCommitPhase(phase: PhaseConfig, deps: PhaseDeps, pc: PhaseContext, out: string): Promise<void>;
@@ -1269,7 +1269,18 @@ export async function phaseCompose(deps, refined, research, qa) {
1269
1269
  return { ok: true, value: stripped };
1270
1270
  }, problem => new Error(`compose_invalid: ${problem}`));
1271
1271
  }
1272
- export async function phaseCritique(deps, spec, refined, qa, planContext, research) {
1272
+ export async function phaseCritique(deps, spec, refined, qa, planContext, research,
1273
+ /**
1274
+ * An additional deterministic defect block, forced into the rewrite exactly
1275
+ * like the probes below and overriding a CLEAN triage the same way.
1276
+ *
1277
+ * This is the A/B seam for a probe that is not wired yet: the discipline
1278
+ * here is "wire only on PASS" (memory/prompt4-spec-urls-failed.md), so a
1279
+ * candidate probe has to be measurable through the SHIPPED critique path
1280
+ * rather than through a hand-copied replica of it, or the two arms differ by
1281
+ * more than the probe. Undefined in production.
1282
+ */
1283
+ extraDefects) {
1273
1284
  // Fast triage before the expensive full rewrite. The rewrite regenerates
1274
1285
  // the entire spec from scratch and is the costliest tail of the pipeline
1275
1286
  // (observed up to ~240s). Most compose drafts are already good, so we first
@@ -1392,7 +1403,8 @@ export async function phaseCritique(deps, spec, refined, qa, planContext, resear
1392
1403
  && absenceProbe === null
1393
1404
  && frozenProbe === null
1394
1405
  && grepOnlyProbe === null
1395
- && scriptProbe === null) {
1406
+ && scriptProbe === null
1407
+ && (extraDefects ?? null) === null) {
1396
1408
  return spec;
1397
1409
  }
1398
1410
  }
@@ -1411,6 +1423,7 @@ export async function phaseCritique(deps, spec, refined, qa, planContext, resear
1411
1423
  frozenProbe,
1412
1424
  grepOnlyProbe,
1413
1425
  scriptProbe,
1426
+ extraDefects ?? null,
1414
1427
  triageDefects
1415
1428
  ]
1416
1429
  .filter(Boolean)
@@ -11,6 +11,29 @@ export interface GrepOnlyVerifyFinding {
11
11
  * source (doc/config-only tasks).
12
12
  */
13
13
  export declare function findGrepOnlyVerify(spec: string): GrepOnlyVerifyFinding[];
14
+ /** One VERIFY command, classified by what it can observe. */
15
+ export interface VerifyCommandClass {
16
+ /** The command line, verbatim. */
17
+ raw: string;
18
+ /** Every pipeline segment is a STATIC head (grep/test/tsc/…) — it inspects
19
+ * files and can never observe the deliverable's behaviour. */
20
+ staticOnly: boolean;
21
+ /**
22
+ * The command observes RUNTIME behaviour: an HTTP request, a port probe, a
23
+ * process it starts and watches. This is the distinction nexttask 7's M3
24
+ * turns on — mx5 run 18's TASK_0023 VERIFY is all `node -e "…package.json…"`,
25
+ * which EXECUTES node yet can only assert that a string is present in a
26
+ * config file, and the behavioural half of the owned requirement ("serves
27
+ * `/api` + static `dist/`") is exactly what it cannot see.
28
+ */
29
+ observesBehaviour: boolean;
30
+ }
31
+ /**
32
+ * Classify each command of a spec's VERIFY block. Shares `segmentHead` with the
33
+ * grep-theater detector, so "static" means one thing across the two measures.
34
+ * Empty when the spec has no runnable VERIFY block.
35
+ */
36
+ export declare function classifyVerifyCommands(spec: string): VerifyCommandClass[];
14
37
  /**
15
38
  * Retry hint when the critique rewrite KEPT the grep-theater block it was told
16
39
  * to fix (live A/B: 1/5 rewrites ignored the injected defect). Prepended to the
@@ -148,6 +148,28 @@ export function findGrepOnlyVerify(spec) {
148
148
  }
149
149
  return [...inspected.entries()].map(([target, lines]) => ({ target, lines }));
150
150
  }
151
+ const BEHAVIOUR_RE = /\bcurl\b|\bwget\b|\bhttpie?\b|https?:\/\/|\bnc\s+-z\b|\bss\s+-|\blsof\b|127\.0\.0\.1|localhost|\bplaywright\b|\bfetch\(/i;
152
+ /**
153
+ * Classify each command of a spec's VERIFY block. Shares `segmentHead` with the
154
+ * grep-theater detector, so "static" means one thing across the two measures.
155
+ * Empty when the spec has no runnable VERIFY block.
156
+ */
157
+ export function classifyVerifyCommands(spec) {
158
+ const cmds = parseVerifyBlock(spec);
159
+ if (!cmds)
160
+ return [];
161
+ return cmds.map(({ raw }) => {
162
+ let staticOnly = true;
163
+ for (const segment of raw.split(/&&|\|\||;|\|/)) {
164
+ const s = segmentHead(segment);
165
+ if (s === null)
166
+ continue;
167
+ if (!STATIC_HEADS.has(s.head))
168
+ staticOnly = false;
169
+ }
170
+ return { raw, staticOnly, observesBehaviour: BEHAVIOUR_RE.test(raw) };
171
+ });
172
+ }
151
173
  /**
152
174
  * Retry hint when the critique rewrite KEPT the grep-theater block it was told
153
175
  * to fix (live A/B: 1/5 rewrites ignored the injected defect). Prepended to the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.28.1",
3
+ "version": "0.28.3",
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",