@dzhechkov/harness-core 0.5.3 → 0.6.0

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.
@@ -1243,9 +1243,13 @@ export function codexDispatchMode(stage: string): CodexDispatch {
1243
1243
  * 56-line adversarial code review answered in 14s. The stalls are INTERMITTENT latency — the same
1244
1244
  * input hung at 60s and answered at 14s minutes apart. Size is not the variable.
1245
1245
  *
1246
- * So the real guard is the bounded timeout plus the CODEX_UNAVAILABLE sentinel: a slow exec becomes
1247
- * an explicit "unavailable", never a passed review. This constant only stops us from shipping an
1248
- * absurdly large prompt.
1246
+ * DEMOTED 2026-08-21 (feature qe-scoped-review, ADR 001). This constant used to be described as the
1247
+ * stall guard. That is now REFUTED by measurement: a 19 038-char QE prompt sat under this 24 000
1248
+ * ceiling with ~5 000 chars of headroom and still spent 280 s / exit 124 producing no verdict, twice.
1249
+ * The variable is not SIZE, it is whether the model is allowed to roam the tree. The defence is
1250
+ * SCOPE — `codexReviewCommand` (the diff defines it) and `scopedQePrompt` ("read ONLY these files").
1251
+ * This constant is retained as a sanity bound on an absurd payload, and is no longer claimed as the
1252
+ * thing that prevents a stall.
1249
1253
  */
1250
1254
  export const CODEX_EXEC_PROMPT_CEILING_CHARS = 24_000;
1251
1255
 
@@ -1256,6 +1260,15 @@ export interface CodexExecPlanInput {
1256
1260
  readonly stage: string;
1257
1261
  readonly promptChars: number;
1258
1262
  readonly probedId: string | null;
1263
+ /**
1264
+ * TRUE only when the prompt was built by `scopedQePrompt` — i.e. it NAMES the files to read and
1265
+ * forbids exploring. Absent/false on the `qe` stage routes to Claude instead of `exec`.
1266
+ *
1267
+ * MEASURED 2026-08-21: an unscoped 19 038-char QE prompt spent 280 s and exit 124 on
1268
+ * reconnaissance and returned no verdict, TWICE (the retry ran at a 1500 s ceiling). The ceiling
1269
+ * did not stop it — 19 038 sat under 24 000. Scope is the guard; this input is where it binds.
1270
+ */
1271
+ readonly scoped?: boolean;
1259
1272
  }
1260
1273
 
1261
1274
  export interface CodexExecPlanResult {
@@ -1271,6 +1284,17 @@ export function codexExecPlan(input: CodexExecPlanInput): CodexExecPlanResult {
1271
1284
  if (!input.probedId) {
1272
1285
  return { mode: 'claude', reason: 'no codex model id answered the probe' };
1273
1286
  }
1287
+ // The `qe` stage is the one whose deliverable is a VERDICT, and the one measured to time out
1288
+ // unscoped. An unscoped exec here is not merely slow — it returns null, the belt runs a Claude
1289
+ // reviewer, and cross-family QE is lost silently. So it must not be dispatchable at all.
1290
+ if (input.stage === 'qe' && input.scoped !== true) {
1291
+ return {
1292
+ mode: 'claude',
1293
+ reason:
1294
+ 'qe prompt is not SCOPED — an unscoped codex exec QE buys reconnaissance, not review ' +
1295
+ '(MEASURED 2026-08-21: 19038 chars, 280s, exit 124, no verdict)',
1296
+ };
1297
+ }
1274
1298
  if (input.promptChars > CODEX_EXEC_PROMPT_CEILING_CHARS) {
1275
1299
  return {
1276
1300
  mode: 'claude',
@@ -1353,6 +1377,452 @@ export function parseCodexExecResult(text: string | null | undefined): CodexExec
1353
1377
  return { ok: true, text: t, reason: 'codex answered' };
1354
1378
  }
1355
1379
 
1380
+ // ── SCOPED CODEX QE (feature qe-scoped-review, ADR 001) ─────────────────────
1381
+ //
1382
+ // MEASURED 2026-08-21, one question, one model (`gpt-5.6-sol`, effort `high`), three dispatches:
1383
+ // 1. `codex exec`, UNSCOPED, 19 038-char prompt → 280 s, exit 124, 416 KB / 4 583 lines, NO verdict
1384
+ // (retried at a 1500 s ceiling: still no verdict).
1385
+ // 2. `codex exec`, SCOPED ("read ONLY these two files"), 1 461-char prompt → 41 s, exit 0, `Grade: B`.
1386
+ // 3. `codex review --commit <SHA>` → 146 s, exit 0, verdict + findings, scope derived from the diff.
1387
+ //
1388
+ // The budget went on RECONNAISSANCE of the tree, not on reasoning about the change. So raising the
1389
+ // timeout buys more reconnaissance, and the old working hypothesis — that
1390
+ // `CODEX_EXEC_PROMPT_CEILING_CHARS` was the binding constraint — is REFUTED: 19 038 sat under the
1391
+ // 24 000 ceiling with ~5 000 chars to spare. The defence is SCOPE. The ceiling is now a sanity bound.
1392
+ //
1393
+ // Why this matters beyond wall-clock: on timeout the dispatch returns null, the belt runs a Claude
1394
+ // reviewer, and the cross-family QE property is lost SILENTLY on exactly the large features that need
1395
+ // it most. Everything below exists so that loss is (a) rarer and (b) always attributable.
1396
+
1397
+ /** Mode-A wall-clock bound. Run 3 measured 146 s; 600 s is ~4× headroom and still bounded. */
1398
+ export const CODEX_REVIEW_TIMEOUT_SECONDS = 600;
1399
+ /** MEASURED (probe 0.3): `codex review` accepts and echoes `reasoning effort: high`. */
1400
+ export const CODEX_REVIEW_DEFAULT_EFFORT = 'high';
1401
+ /** The sentinel a wrapper returns when the command hit its `timeout` — distinct from CODEX_UNAVAILABLE. */
1402
+ export const CODEX_TIMEOUT = 'CODEX_TIMEOUT';
1403
+ /** Machine sentinel appended by the dispatch command itself (grammar of the Step-7.5 landing signal). */
1404
+ export const CODEX_QE_SIGNAL_PREFIX = 'CODEX-QE-SIGNAL';
1405
+ /** Mode-B bounds, set FROM the measurement above (run 2 = 2 files / 1 461 chars), not from the ceiling. */
1406
+ export const SCOPED_QE_MAX_FILES = 3;
1407
+ export const SCOPED_QE_MAX_QUESTIONS = 4;
1408
+ export const SCOPED_QE_MAX_PATH_CHARS = 200;
1409
+ export const SCOPED_QE_MAX_QUESTION_CHARS = 200;
1410
+ export const SCOPED_QE_PROMPT_MAX_CHARS = 2000;
1411
+
1412
+ /**
1413
+ * A git ref reaches a shell command, exactly like a model id does. Same discipline as
1414
+ * {@link isSafeCodexId}: plain refs only, and single-quoted at the call site anyway.
1415
+ *
1416
+ * Deliberately STRICTER than git: `HEAD~1`, `a..b` with `~`/`^`, and any leading `-` (which the CLI
1417
+ * would read as a flag) are rejected. A rejected ref returns `{cmd: null}` → mode A is skipped and
1418
+ * mode B / the Claude belt runs. Refusing to build is always cheaper than building something odd.
1419
+ */
1420
+ export function isSafeCodexRef(ref: string): boolean {
1421
+ return /^[A-Za-z0-9][A-Za-z0-9._\/-]{0,199}$/.test(String(ref));
1422
+ }
1423
+
1424
+ export interface CodexReviewCommandInput {
1425
+ /** `'commit' | 'base' | 'uncommitted'`. Default `'uncommitted'` — see the scope note below. */
1426
+ readonly scope?: string | null;
1427
+ readonly ref?: string | null;
1428
+ readonly modelId?: string | null;
1429
+ readonly reasoning?: string | null;
1430
+ /**
1431
+ * Accepted and DELIBERATELY IGNORED. MEASURED 2026-08-21: every scope flag refuses a positional
1432
+ * prompt — `--commit`, `--base <BRANCH>` and `--uncommitted` each exit 2 with
1433
+ * "the argument '<flag>' cannot be used with '[PROMPT]'". Silently appending one would present as
1434
+ * a review that never happened. Our own questions go to mode B ({@link scopedQePrompt}).
1435
+ */
1436
+ readonly prompt?: string | null;
1437
+ readonly timeoutSeconds?: number | null;
1438
+ }
1439
+
1440
+ export interface CodexReviewCommandResult {
1441
+ readonly cmd: string | null;
1442
+ /** Always `false` today, and KEPT as a field: it is the one boolean a future CLI would flip. */
1443
+ readonly carriesPrompt: boolean;
1444
+ readonly scope: string;
1445
+ readonly reason: string | null;
1446
+ }
1447
+
1448
+ /**
1449
+ * Build the mode-A command. The diff defines the scope, so Codex computes for free the thing we were
1450
+ * paying a model to do badly.
1451
+ *
1452
+ * Two refusals are load-bearing, both measured (exit 2 = a silent review failure, since the pipeline
1453
+ * would read "no output" as "codex unavailable"):
1454
+ * • never emit `-m` — `codex review` rejects it; the model goes through `-c model=`;
1455
+ * • never append a positional prompt — no scope flag accepts one.
1456
+ *
1457
+ * Default scope is `uncommitted` (probe 0.4b: it and `--base HEAD` reviewed the identical uncommitted
1458
+ * diff, and `uncommitted` needs no ref, so it has no ref-injection surface at all).
1459
+ */
1460
+ export function codexReviewCommand(input: CodexReviewCommandInput): CodexReviewCommandResult {
1461
+ const o = input || ({} as CodexReviewCommandInput);
1462
+ const scope = o.scope === undefined || o.scope === null || o.scope === '' ? 'uncommitted' : String(o.scope);
1463
+ if (scope !== 'commit' && scope !== 'base' && scope !== 'uncommitted') {
1464
+ return { cmd: null, carriesPrompt: false, scope: scope, reason: 'unknown review scope ' + scope };
1465
+ }
1466
+ const modelId = String(o.modelId === undefined || o.modelId === null ? '' : o.modelId);
1467
+ if (!isSafeCodexId(modelId)) {
1468
+ return { cmd: null, carriesPrompt: false, scope: scope, reason: 'unsafe id or ref' };
1469
+ }
1470
+ const effort = o.reasoning === undefined || o.reasoning === null || o.reasoning === '' ? CODEX_REVIEW_DEFAULT_EFFORT : String(o.reasoning);
1471
+ if (!VALID_REASONING[effort]) {
1472
+ return { cmd: null, carriesPrompt: false, scope: scope, reason: 'unknown reasoning effort ' + effort };
1473
+ }
1474
+ const ref = String(o.ref === undefined || o.ref === null ? '' : o.ref);
1475
+ if (scope !== 'uncommitted' && !isSafeCodexRef(ref)) {
1476
+ return { cmd: null, carriesPrompt: false, scope: scope, reason: 'unsafe id or ref' };
1477
+ }
1478
+ const raw = Number(o.timeoutSeconds);
1479
+ const seconds = raw === raw && raw !== Infinity && raw > 0 ? Math.floor(raw) : CODEX_REVIEW_TIMEOUT_SECONDS;
1480
+ let cmd = 'timeout ' + seconds + " codex review -c model='" + modelId + "' -c model_reasoning_effort='" + effort + "'";
1481
+ if (scope === 'commit') cmd += " --commit '" + ref + "'";
1482
+ else if (scope === 'base') cmd += " --base '" + ref + "'";
1483
+ else cmd += ' --uncommitted';
1484
+ cmd += ' < /dev/null';
1485
+ return { cmd: cmd, carriesPrompt: false, scope: scope, reason: null };
1486
+ }
1487
+
1488
+ export interface ScopedQePromptInput {
1489
+ readonly files?: readonly string[] | null;
1490
+ readonly questions?: readonly string[] | null;
1491
+ readonly slug?: string | null;
1492
+ }
1493
+
1494
+ /**
1495
+ * Build the mode-B narrow prompt. The "do NOT open any other file" clause is LOAD-BEARING TEXT: it is
1496
+ * the difference between the 41 s graded run and the 280 s ungraded one, at comparable model effort.
1497
+ *
1498
+ * An empty file list returns `''` — the caller treats that as "do not dispatch". An unscoped mode-B
1499
+ * exec is precisely the failure this feature removes, so it must not be CONSTRUCTIBLE.
1500
+ */
1501
+ export function scopedQePrompt(input: ScopedQePromptInput): string {
1502
+ const o = input || ({} as ScopedQePromptInput);
1503
+ const rawFiles = Array.isArray(o.files) ? o.files : [];
1504
+ const files: string[] = [];
1505
+ for (const f of rawFiles) {
1506
+ const s = String(f === undefined || f === null ? '' : f).trim();
1507
+ if (s === '') continue;
1508
+ if (files.indexOf(s) !== -1) continue;
1509
+ files.push(s.slice(0, SCOPED_QE_MAX_PATH_CHARS));
1510
+ if (files.length >= SCOPED_QE_MAX_FILES) break;
1511
+ }
1512
+ if (files.length === 0) return '';
1513
+ const rawQuestions = Array.isArray(o.questions) ? o.questions : [];
1514
+ const questions: string[] = [];
1515
+ for (const q of rawQuestions) {
1516
+ const s = String(q === undefined || q === null ? '' : q).trim().replace(/\s+/g, ' ');
1517
+ if (s === '') continue;
1518
+ questions.push(s.slice(0, SCOPED_QE_MAX_QUESTION_CHARS));
1519
+ if (questions.length >= SCOPED_QE_MAX_QUESTIONS) break;
1520
+ }
1521
+ if (questions.length === 0) {
1522
+ questions.push('Is this change correct, and does the test named by its ADR actually DISCRIMINATE (would it fail if the protection were deleted)?');
1523
+ }
1524
+ const slug = String(o.slug === undefined || o.slug === null ? '' : o.slug).trim().slice(0, 60);
1525
+ let out = 'Read ONLY these files: ' + files.join(', ') + '. Do NOT open any other file and do NOT explore the repository.';
1526
+ if (slug !== '') out += ' They are the changed files of feature ' + slug + '.';
1527
+ out += '\n\nAnswer these ' + questions.length + ' questions about them:\n';
1528
+ for (let i = 0; i < questions.length; i++) out += i + 1 + '. ' + questions[i] + '\n';
1529
+ out += '\nFinish with a single final line: Grade: <A|B|C|D>';
1530
+ return out;
1531
+ }
1532
+
1533
+ /**
1534
+ * Wrap a command so its EXIT CODE survives the shell agent that runs it.
1535
+ *
1536
+ * The old wrapper collapsed every failure to `CODEX_UNAVAILABLE`, so a timeout (narrow the scope) and
1537
+ * a broken invocation (fix the command) arrived indistinguishable — and `codex review` output is
1538
+ * prose, not JSON, so the exit code is the only reliable discriminator there is. The emitted line is
1539
+ * the grammar {@link parseCodexReviewSignal} reads back, and the two are round-trip tested against a
1540
+ * REAL shell rather than against each other's regexes.
1541
+ *
1542
+ * The SUBSHELL around `inner` is load-bearing, and the round-trip test is what earned it: without it
1543
+ * the `> "$o" 2>&1` redirect binds only to the last command of a compound `inner`, and an `exit`
1544
+ * inside `inner` terminates the wrapper BEFORE the sentinel is echoed — producing exactly the
1545
+ * "signal missing" state that must mean "the command did not demonstrably run".
1546
+ */
1547
+ export function codexQeSignalCommand(inner: string, outPath?: string | null): string {
1548
+ const raw = String(outPath === undefined || outPath === null ? '' : outPath);
1549
+ // `JSON.stringify` is JSON quoting, not SHELL quoting — inside the double quotes it emits, `$`,
1550
+ // backtick and `\` all keep their shell meaning, so a path carrying one would break out of the
1551
+ // assignment. Found by the first live mode-A review of this feature (P1, 2026-08-21). The path is
1552
+ // ours to construct, so the fix is an allowlist plus single-quoting, not an escaper: anything that
1553
+ // is not a plain POSIX path falls back to the default rather than being cleverly escaped.
1554
+ const o = /^\/[A-Za-z0-9._\/-]{1,200}$/.test(raw) ? raw : '/tmp/dz-codex-qe.out';
1555
+ return "o='" + o + "'; start=$(date +%s); ( " + String(inner) + ' ) > "$o" 2>&1; rc=$?; cat "$o"; echo; echo "' + CODEX_QE_SIGNAL_PREFIX + ' exit=$rc elapsed=$(( $(date +%s) - start ))s bytes=$(wc -c < \"$o\" | tr -d \" \")"';
1556
+ }
1557
+
1558
+ export interface ParsedCodexQeSignal {
1559
+ readonly exit: number | null;
1560
+ readonly elapsedSeconds: number | null;
1561
+ readonly bytes: number | null;
1562
+ readonly body: string;
1563
+ readonly signalPresent: boolean;
1564
+ }
1565
+
1566
+ /**
1567
+ * Split the machine sentinel from the reviewer's own words.
1568
+ *
1569
+ * `codex review` output is prose, not JSON, so the EXIT CODE is the only reliable discriminator — and
1570
+ * today the shell-agent wrapper throws it away. The dispatch command now appends
1571
+ * `CODEX-QE-SIGNAL exit=<n> elapsed=<n>s bytes=<n>`, the same grammar as the Step-7.5 landing signal.
1572
+ *
1573
+ * A MISSING sentinel yields `exit: null` and `signalPresent: false` — never a defaulted 0. Defaulting
1574
+ * to 0 would let "the wrapper never ran the command" read as "the command succeeded".
1575
+ */
1576
+ export function parseCodexReviewSignal(text: string | null | undefined): ParsedCodexQeSignal {
1577
+ const t = typeof text === 'string' ? text : '';
1578
+ const re = /^CODEX-QE-SIGNAL exit=(-?\d+) elapsed=(\d+)s bytes=(\d+)[ \t]*$/gm;
1579
+ let m: RegExpExecArray | null = null;
1580
+ let last: RegExpExecArray | null = null;
1581
+ while ((m = re.exec(t)) !== null) last = m;
1582
+ if (last === null) {
1583
+ return { exit: null, elapsedSeconds: null, bytes: null, body: t.trim(), signalPresent: false };
1584
+ }
1585
+ const body = (t.slice(0, last.index) + t.slice(last.index + last[0].length)).trim();
1586
+ return { exit: Number(last[1]), elapsedSeconds: Number(last[2]), bytes: Number(last[3]), body: body, signalPresent: true };
1587
+ }
1588
+
1589
+ export interface CodexQeFinding {
1590
+ readonly severity: string;
1591
+ readonly title: string;
1592
+ readonly location: string;
1593
+ }
1594
+
1595
+ /**
1596
+ * Parse mode-A findings out of non-JSON review output, driven by a REAL saved capture
1597
+ * (`test/fixtures/codex-review-2026-08-21.txt`), never by invented text.
1598
+ *
1599
+ * Observed shape: `- [P1] <title> — <abs-path>:<line>-<line>`, and Codex prints the whole finding
1600
+ * block TWICE (once as the summary, once as the final message), hence the dedup.
1601
+ *
1602
+ * Zero parsed findings is a DATA POINT (`[]`), never "clean" — that judgment belongs to
1603
+ * {@link gradeFromReviewFindings}, which refuses to make it.
1604
+ */
1605
+ export function parseCodexReviewFindings(body: string | null | undefined): CodexQeFinding[] {
1606
+ const t = String(body === undefined || body === null ? '' : body);
1607
+ const out: CodexQeFinding[] = [];
1608
+ const seen = new Set<string>();
1609
+ for (const rawLine of t.split('\n')) {
1610
+ const line = rawLine.trim();
1611
+ const m = /^[-*]\s*\[(P[0-4])\]\s*(.+)$/.exec(line);
1612
+ if (!m) continue;
1613
+ const severity = String(m[1]);
1614
+ let title = String(m[2]).trim();
1615
+ let location = '';
1616
+ const sep = title.lastIndexOf(' — ');
1617
+ if (sep !== -1) {
1618
+ const cand = title.slice(sep + 3).trim();
1619
+ if (/:\d/.test(cand) || cand.indexOf('/') !== -1) {
1620
+ location = cand;
1621
+ title = title.slice(0, sep).trim();
1622
+ }
1623
+ }
1624
+ if (title === '') continue;
1625
+ const key = severity + '|' + title + '|' + location;
1626
+ if (seen.has(key)) continue;
1627
+ seen.add(key);
1628
+ out.push({ severity: severity, title: title, location: location });
1629
+ }
1630
+ return out;
1631
+ }
1632
+
1633
+ /**
1634
+ * Derive a grade from what the reviewer actually found — used ONLY on the mode-A path, where the CLI
1635
+ * structurally forbids asking for a letter (measured: every scope flag rejects `[PROMPT]`).
1636
+ *
1637
+ * Two honesty rules, and they are the highest-severity lines in this feature:
1638
+ * 1. an empty or unparseable finding set returns `null`, NEVER a default letter. MEASURED
1639
+ * (probe 0.6): `codex review --uncommitted` on a CLEAN tree exits 0 with a polite, well-formed,
1640
+ * completely empty review. Mapping that to `'A'` would turn a review of NOTHING into a clean
1641
+ * bill of health — the exact `{grade:'codex-review', gaps: []}` fabrication ADR-001 deleted once;
1642
+ * 2. `'A'` is UNREACHABLE by derivation for every input. "Nothing was found" is not evidence of
1643
+ * quality when the reviewer's own findings are the only evidence we have; only a reviewer that
1644
+ * STATES `Grade: A` (mode B, where we can ask) may produce one.
1645
+ */
1646
+ export function gradeFromReviewFindings(findings: readonly CodexQeFinding[] | null | undefined): string | null {
1647
+ const list = Array.isArray(findings) ? findings : [];
1648
+ let worst: number | null = null;
1649
+ for (const f of list) {
1650
+ const s = String(f && f.severity ? f.severity : '').toUpperCase();
1651
+ if (!/^P[0-4]$/.test(s)) continue;
1652
+ const n = Number(s.slice(1));
1653
+ if (worst === null || n < worst) worst = n;
1654
+ }
1655
+ if (worst === null) return null;
1656
+ if (worst === 0) return 'D';
1657
+ if (worst === 1) return 'C';
1658
+ return 'B';
1659
+ }
1660
+
1661
+ /**
1662
+ * The LOCKED decline taxonomy. A `kind` outside this set is a bug, not a new case — which is why
1663
+ * {@link codexQeDeclineReason} throws on one rather than rendering something plausible.
1664
+ */
1665
+ export const CODEX_QE_DECLINE_KINDS: readonly string[] = ['timeout', 'no-verdict', 'tool-error', 'unusable-output', 'unavailable', 'over-ceiling'];
1666
+
1667
+ export interface ClassifyCodexQeInput {
1668
+ readonly exit?: number | null;
1669
+ readonly body?: string | null;
1670
+ readonly grade?: string | null;
1671
+ readonly findings?: readonly CodexQeFinding[] | null;
1672
+ /**
1673
+ * TRUE (the default) when the caller dispatched through the sentinel-emitting wrapper, so a missing
1674
+ * sentinel means the command never ran → `tool-error`. FALSE only when parsing RAW reviewer text
1675
+ * that was never wrapped (a saved fixture, a file on disk), where content is all there is.
1676
+ */
1677
+ readonly signalExpected?: boolean;
1678
+ }
1679
+
1680
+ /**
1681
+ * Classify one Codex QE dispatch. The property this whole feature exists to protect lives here:
1682
+ * a TIMEOUT and an UNUSABLE OUTPUT must never collapse into the same kind, because the operator's
1683
+ * next move differs — `timeout` ⇒ narrow the scope; `tool-error` ⇒ fix the command; `unavailable` ⇒
1684
+ * fix the account/model.
1685
+ *
1686
+ * Order is deliberate. `exit === 124` wins over EVERY content rule, because the measured timeout body
1687
+ * was 416 KB of exploration — very much non-empty, and an empty-body rule would have mislabelled it
1688
+ * `unusable-output` and told the operator to fix a tool that is working fine.
1689
+ *
1690
+ * `exit !== 0` is `tool-error` — but only for exits outside {0, 124}. MEASURED (probe 0.2): a review
1691
+ * that finds a real P1 blocker still exits 0. A BAD review is a SUCCESSFUL cross-family review.
1692
+ */
1693
+ export function classifyCodexQeOutcome(input: ClassifyCodexQeInput): { kind: string } {
1694
+ const o = input || ({} as ClassifyCodexQeInput);
1695
+ const body = String(o.body === undefined || o.body === null ? '' : o.body);
1696
+ const exit = o.exit === undefined || o.exit === null ? null : Number(o.exit);
1697
+ const grade = o.grade === undefined || o.grade === null || o.grade === '' ? null : String(o.grade);
1698
+ const signalExpected = o.signalExpected === undefined ? true : !!o.signalExpected;
1699
+ if (exit === 124) return { kind: 'timeout' };
1700
+ if (body.trim() === '') return { kind: 'unusable-output' };
1701
+ // The TEXT sentinels are evidence only when there is NO machine signal. FOUND BY THE FIRST LIVE
1702
+ // MODE-A RUN (2026-08-21): `codex review --uncommitted` over this very feature's diff exited 0 in
1703
+ // 482 s with six real findings, and was classified `timeout` — because the DIFF ITSELF contains the
1704
+ // line `CODEX_TIMEOUT = 'CODEX_TIMEOUT'`. A reviewer quoting the code under review is the normal
1705
+ // case for a diff-scoped review, so a content sentinel that outranks the exit code turns any review
1706
+ // of this file into a fake timeout. When the exit code is known it is authoritative.
1707
+ if (exit === null) {
1708
+ if (body.indexOf(CODEX_TIMEOUT) !== -1) return { kind: 'timeout' };
1709
+ if (body.indexOf(CODEX_UNAVAILABLE) !== -1) return { kind: 'unavailable' };
1710
+ if (signalExpected) return { kind: 'tool-error' };
1711
+ } else if (exit !== 0) {
1712
+ return { kind: 'tool-error' };
1713
+ }
1714
+ if (grade !== null) return { kind: 'verdict' };
1715
+ return { kind: 'no-verdict' };
1716
+ }
1717
+
1718
+ export interface CodexQeDeclineDetail {
1719
+ readonly elapsedSeconds?: number | string | null;
1720
+ readonly seconds?: number | string | null;
1721
+ readonly ref?: string | null;
1722
+ readonly files?: readonly unknown[] | number | null;
1723
+ readonly exit?: number | null;
1724
+ readonly chars?: number | null;
1725
+ readonly detail?: string | null;
1726
+ readonly reason?: string | null;
1727
+ }
1728
+
1729
+ /**
1730
+ * Render the operator-facing decline reason — the string the workflow assigns to `lastCodexDecline`
1731
+ * and that `crossFamilyQe` prints inside `opus (cross-family QE DID NOT happen — …)`.
1732
+ *
1733
+ * Before this feature every decline rendered as `codex exec unusable — codex exec returned no text`,
1734
+ * so a timeout (narrow the scope) and a broken invocation (fix the command) produced an identical,
1735
+ * unactionable alarm. The two `unusable-output` / `unavailable` strings are preserved VERBATIM from
1736
+ * the two pre-existing call sites so the change adds precision without rewriting history.
1737
+ *
1738
+ * `'empty'` and `'ceiling'` are accepted as aliases (the ADR's vocabulary) of `'unusable-output'` and
1739
+ * `'over-ceiling'` (the taxonomy's). Anything else THROWS: a kind outside the locked set is a bug,
1740
+ * and rendering a plausible sentence for it would hide that bug behind a readable label.
1741
+ */
1742
+ export function codexQeDeclineReason(kind: string | null | undefined, detail?: CodexQeDeclineDetail | null): string {
1743
+ const d = detail || ({} as CodexQeDeclineDetail);
1744
+ const k = String(kind === undefined || kind === null ? '' : kind);
1745
+ const canonical = k === 'empty' ? 'unusable-output' : k === 'ceiling' ? 'over-ceiling' : k;
1746
+ const secs = d.elapsedSeconds === undefined || d.elapsedSeconds === null ? d.seconds : d.elapsedSeconds;
1747
+ const elapsed = secs === undefined || secs === null ? '?' : String(secs);
1748
+ const ref = d.ref === undefined || d.ref === null || String(d.ref) === '' ? 'unknown' : String(d.ref);
1749
+ const files = d.files === undefined || d.files === null ? '?' : String(Array.isArray(d.files) ? d.files.length : d.files);
1750
+ const exit = d.exit === undefined || d.exit === null ? '?' : String(d.exit);
1751
+ const chars = d.chars === undefined || d.chars === null ? '?' : String(d.chars);
1752
+ const extra = d.detail === undefined || d.detail === null || String(d.detail) === '' ? 'no detail' : String(d.detail);
1753
+ if (canonical === 'timeout') {
1754
+ return 'codex review timed out after ' + elapsed + 's on scope ' + ref + ' (' + files + ' files) — NARROW the scope (this is reconnaissance cost, not thinking time)';
1755
+ }
1756
+ if (canonical === 'no-verdict') {
1757
+ return 'codex answered in ' + elapsed + 's but named no grade — not a verdict';
1758
+ }
1759
+ if (canonical === 'tool-error') {
1760
+ return 'codex review exited ' + exit + ' — FIX the invocation (' + extra + ')';
1761
+ }
1762
+ if (canonical === 'unusable-output') {
1763
+ return 'codex exec unusable — ' + (d.reason === undefined || d.reason === null || String(d.reason) === '' ? 'codex exec returned no text' : String(d.reason));
1764
+ }
1765
+ if (canonical === 'unavailable') {
1766
+ return 'codex not used — ' + (d.reason === undefined || d.reason === null || String(d.reason) === '' ? 'codex exec reported it could not run' : String(d.reason));
1767
+ }
1768
+ if (canonical === 'over-ceiling') {
1769
+ return 'prompt is ' + chars + ' chars / unscoped — refused before dispatch';
1770
+ }
1771
+ throw new Error('codexQeDeclineReason: unknown kind ' + k);
1772
+ }
1773
+
1774
+ /**
1775
+ * The ADR's spelling of {@link codexQeDeclineReason}. Accepts BOTH call shapes — the ADR's
1776
+ * `({kind, seconds, detail})` object and the taxonomy's positional `(kind, detail)` — so both cited
1777
+ * call sites resolve to one implementation instead of two that can drift.
1778
+ */
1779
+ export function codexDeclineReason(a: unknown, b?: CodexQeDeclineDetail | null): string {
1780
+ if (a && typeof a === 'object') {
1781
+ const o = a as CodexQeDeclineDetail & { kind?: string | null };
1782
+ return codexQeDeclineReason(o.kind, o);
1783
+ }
1784
+ return codexQeDeclineReason(a as string, b);
1785
+ }
1786
+
1787
+ export interface CodexReviewResult {
1788
+ readonly ok: boolean;
1789
+ readonly grade: string | null;
1790
+ readonly kind: string;
1791
+ readonly gradeSource: string | null;
1792
+ readonly findings: CodexQeFinding[];
1793
+ readonly reason: string | null;
1794
+ }
1795
+
1796
+ /**
1797
+ * The ADR's one-call parser over RAW reviewer text: signal-split → findings → grade → classify.
1798
+ * Pure composition; every rule it applies belongs to one of the four helpers above.
1799
+ *
1800
+ * `signalExpected` is passed as `sig.signalPresent` ON PURPOSE, and it is the one line here worth
1801
+ * reading twice. This function's input is text that may never have been wrapped (a saved fixture, a
1802
+ * report on disk), so a missing sentinel means "no machine signal exists", not "the tool failed".
1803
+ * The PIPELINE must not use this leniency: the workflow dispatches through the sentinel-emitting
1804
+ * wrapper and calls {@link classifyCodexQeOutcome} directly with `signalExpected: true`, so a
1805
+ * swallowed sentinel there is a `tool-error` and never a pass. A wiring test pins that.
1806
+ */
1807
+ export function parseCodexReviewResult(text: string | null | undefined): CodexReviewResult {
1808
+ const sig = parseCodexReviewSignal(text);
1809
+ const findings = parseCodexReviewFindings(sig.body);
1810
+ const stated = parseCodexGrade(sig.body);
1811
+ const grade = stated !== null ? stated : gradeFromReviewFindings(findings);
1812
+ const outcome = classifyCodexQeOutcome({ exit: sig.exit, body: sig.body, grade: grade, findings: findings, signalExpected: sig.signalPresent });
1813
+ const kind = outcome.kind === 'unusable-output' ? 'empty' : outcome.kind;
1814
+ const ok = kind === 'verdict';
1815
+ const reason = ok ? null : codexQeDeclineReason(kind, { elapsedSeconds: sig.elapsedSeconds, exit: sig.exit, chars: sig.body.length });
1816
+ return {
1817
+ ok: ok,
1818
+ grade: ok ? grade : null,
1819
+ kind: kind,
1820
+ gradeSource: ok ? (stated !== null ? 'stated' : 'derived-from-findings') : null,
1821
+ findings: findings,
1822
+ reason: reason,
1823
+ };
1824
+ }
1825
+
1356
1826
  /** CX-3: a workflow that names an agent type the harness does not have must fall back, not die. */
1357
1827
  export function isAgentTypeMissingError(err: unknown): boolean {
1358
1828
  const msg = err instanceof Error ? err.message : String(err ?? '');
@@ -1503,10 +1973,65 @@ export interface PlanGateVerdict {
1503
1973
  * silently empty) and the `K2_EXIT=` trailer carries the exit code back through an agent that can
1504
1974
  * only return text.
1505
1975
  */
1506
- export function planCompletenessGateCmd(repo: string, featureDir: string, tier?: string | null): string {
1976
+ export interface PlanGateCmdOpts {
1977
+ /** An explicit ABSOLUTE path to the gate script. Validated at build time; never silently demoted. */
1978
+ gateScript?: string
1979
+ /** Pin the workspace root instead of resolving it live with `pwd -P` (tests; deterministic pins). */
1980
+ workspace?: string
1981
+ }
1982
+
1983
+ /** Both interpolated knobs are shell-spliced, so both get the same build-time shape check. */
1984
+ function assertAbsoluteNoTraversal(value: unknown, knob: string): string {
1985
+ if (typeof value !== 'string' || value === '') throw new Error('planCompletenessGateCmd: opts.' + knob + ' must be a non-empty absolute path')
1986
+ if (value.charAt(0) !== '/') throw new Error('planCompletenessGateCmd: opts.' + knob + ' must be an ABSOLUTE path, got ' + JSON.stringify(value))
1987
+ if (/(^|\/)\.\.(\/|$)/.test(value)) throw new Error("planCompletenessGateCmd: opts." + knob + " must not contain a '..' segment, got " + JSON.stringify(value))
1988
+ return value
1989
+ }
1990
+
1991
+ /**
1992
+ * The EXACT command the gate agent runs. `2>&1` folds stderr in (a crash must be visible, not
1993
+ * silently empty) and the `K2_EXIT=` trailer carries the exit code back through an agent that can
1994
+ * only return text.
1995
+ *
1996
+ * P16/D2 — WHERE the script is looked up. The skill is installed in the WORKSPACE; the command
1997
+ * `cd`s into the TARGET repo, so a repo-relative `node .claude/skills/…` resolved against the target
1998
+ * and died with `Cannot find module` on every repo that is not itself a feature-adr install (field
1999
+ * report P16: K2_EXIT=1, verdict not-established, reason no-verdict-line). The fix is an ordered
2000
+ * candidate chain, WORKSPACE BEFORE REPO on purpose: the verdict contract is defined by the PARSER
2001
+ * inside the running workflow, so only the copy from that same installation is known to speak it —
2002
+ * a target repo may carry an older copy that prints `K2: NOT-ESTABLISHED — …`, a prefix this
2003
+ * parser does not match (a live example lives at
2004
+ * features/wave1-instrument-repair/check-plan-completeness.mjs). Nothing found ⇒ a LOUD
2005
+ * `tooling-missing` refusal with every tried path echoed — never a skip, never a pass.
2006
+ *
2007
+ * Called with three arguments the emitted string is byte-identical to the pre-P16 command; the
2008
+ * search chain appears only when `opts` is supplied. That 3-arg form exists so the pre-existing
2009
+ * byte-pin can keep asserting the OLD shape (a test-fixture role, not an API promise — the shipped
2010
+ * caller always passes `opts`).
2011
+ */
2012
+ export function planCompletenessGateCmd(repo: string, featureDir: string, tier?: string | null, opts?: PlanGateCmdOpts): string {
1507
2013
  const q = (s: string) => "'" + String(s).replace(/'/g, "'\\''") + "'"
1508
2014
  const t = (typeof tier === 'string' && tier !== '') ? ' --tier=' + q(tier) : ''
1509
- return 'cd ' + q(repo) + ' && node ' + q(PLAN_GATE_SCRIPT) + ' ' + q(featureDir) + t + ' 2>&1; echo K2_EXIT=$?'
2015
+ if (opts === undefined || opts === null) {
2016
+ return 'cd ' + q(repo) + ' && node ' + q(PLAN_GATE_SCRIPT) + ' ' + q(featureDir) + t + ' 2>&1; echo K2_EXIT=$?'
2017
+ }
2018
+ const explicit = (opts.gateScript === undefined || opts.gateScript === null) ? null : assertAbsoluteNoTraversal(opts.gateScript, 'gateScript')
2019
+ const ws = (opts.workspace === undefined || opts.workspace === null) ? null : assertAbsoluteNoTraversal(opts.workspace, 'workspace')
2020
+ // WS is captured BEFORE the cd — that ordering is the whole point; a 'pwd -P' after the cd would
2021
+ // report the target repo and the chain would collapse back into the defect it fixes.
2022
+ const wsAssign = ws === null ? 'WS=$(pwd -P)' : 'WS=' + q(ws)
2023
+ return [
2024
+ wsAssign + "; GS=''",
2025
+ 'C1=' + (explicit === null ? "''" : q(explicit)) + '; C2="$WS/' + PLAN_GATE_SCRIPT + '"; C3=' + q(repo + '/' + PLAN_GATE_SCRIPT),
2026
+ 'for c in "$C1" "$C2" "$C3"; do [ -n "$c" ] && [ -f "$c" ] && { GS="$c"; break; }; done',
2027
+ // Audit lines, printed ALWAYS and BEFORE any verdict line: which copy ran, and what was tried.
2028
+ // They are deliberately not verdict-shaped, so the parser's last-match anchoring is untouched,
2029
+ // and the tried paths live OUTSIDE the verdict line so no path can smuggle a second verdict word
2030
+ // into it.
2031
+ 'echo "K2_GATE_SCRIPT=${GS:-none}"',
2032
+ 'echo "K2_GATE_TRIED=${C1:-(none)} | $C2 | $C3"',
2033
+ 'if [ -z "$GS" ]; then echo "K2 plan-completeness: NOT-ESTABLISHED — tooling-missing: no gate script at any candidate on the K2_GATE_TRIED line above"; echo "K2_EXIT=3"; else cd ' + q(repo) + ' && node "$GS" ' + q(featureDir) + t + ' 2>&1; echo "K2_EXIT=$?"; fi',
2034
+ ].join('\n')
1510
2035
  }
1511
2036
 
1512
2037
  /**
@@ -1532,5 +2057,325 @@ export function parsePlanGateVerdict(raw: string | null | undefined): PlanGateVe
1532
2057
  const byExit = exitCode === 0 ? 'pass' : (exitCode === 1 ? 'fail' : (exitCode === 3 ? 'not-established' : null))
1533
2058
  if (byExit === null) return { verdict: 'not-established', exit: exitCode, reason: 'unknown-exit-code', output: output }
1534
2059
  if (byExit !== byName) return { verdict: 'not-established', exit: exitCode, reason: 'verdict-exit-mismatch', output: output }
1535
- return { verdict: byName, exit: exitCode, reason: 'script-verdict', output: output }
2060
+ // P16/D2: a REFINEMENT of the reason, not a new verdict. The verdict vocabulary stays three values,
2061
+ // so every banner and exit-code table pinned by tests keeps meaning what it meant. A forged
2062
+ // K2_EXIT=0 under a NOT-ESTABLISHED line still returns verdict-exit-mismatch above — the new
2063
+ // reason is read only after both halves already agree.
2064
+ const lastAt = text.lastIndexOf(lastVerdict)
2065
+ const nl = text.indexOf('\n', lastAt)
2066
+ const lastLine = nl < 0 ? text.slice(lastAt) : text.slice(lastAt, nl)
2067
+ const reason = (byName === 'not-established' && /tooling-missing:/.test(lastLine)) ? 'tooling-missing' : 'script-verdict'
2068
+ return { verdict: byName, exit: exitCode, reason: reason, output: output }
2069
+ }
2070
+
2071
+ /**
2072
+ * The operator note a refused plan gate carries — ONE reason→text table, so the workflow's inline
2073
+ * copy cannot drift into telling an operator to fix a plan that is not broken.
2074
+ *
2075
+ * AM-2/AM-7: before P16 this text was inline and UNCONDITIONAL ("exit 1 ⇒ fix the FAIL lines"),
2076
+ * which is actively wrong for a gate that never RAN. Existence of a branch is not proof it fires,
2077
+ * so the table is exported and unit-tested on both reasons.
2078
+ */
2079
+ export function refusalNoteFor(planGate: PlanGateVerdict, slug: string): string {
2080
+ const exitTxt = planGate.exit === null ? 'unknown' : String(planGate.exit)
2081
+ const head = 'REFUSED at the Step-6/7 boundary: the K2 plan-completeness gate returned ' + String(planGate.verdict).toUpperCase() + ' (exit=' + exitTxt + ', reason=' + planGate.reason + '). Step 7 was NOT dispatched. '
2082
+ if (planGate.reason === 'tooling-missing') {
2083
+ return head + 'The gate could not be RUN. This is NOT a plan defect — do NOT edit the plan. Reinstall the feature-adr skill into the workspace this run started in, or re-invoke with args.gateScript=<absolute path to check-plan-completeness.mjs>. Every path that was tried is on the K2_GATE_TRIED line of the gate output below. The plan stage is checkpointed, so once the gate is reachable a bare re-invoke resumes it and nothing is re-planned. Gate output:\n' + planGate.output
2084
+ }
2085
+ return head + 'exit 1 ⇒ fix the plan per the FAIL lines below and re-invoke; exit 3 / not-established ⇒ INCONCLUSIVE, the gate could not read its inputs (fix them and rerun) — it is never a pass. HOW TO REPAIR (the plan stage is checkpointed, so a bare re-invoke RESUMES this same failing plan): edit features/' + slug + "/06_implementation_plan.md to fix the FAIL lines and re-invoke — the plan checkpoint is keyed on run INPUTS, not on the file, so your edit is NOT re-planned away; to force a fresh plan instead, re-invoke with args.resume='never' (or delete features/" + slug + '/.fa-state/). Gate output:\n' + planGate.output
2086
+ }
2087
+
2088
+ /**
2089
+ * ADR-003 — pin a RELATIVE `args.dzBin` to the workspace root once, at the point of definition.
2090
+ *
2091
+ * `DZ` is spliced into commands that first `cd` into the target repo, into the brain, or into
2092
+ * nothing at all (the usage probe), so a relative binary path resolves against three different
2093
+ * bases and silently returns nothing on at least two of them — and a null usage probe is read
2094
+ * upstream as "the limit was hit", which fail-safe-switches a healthy run to Codex.
2095
+ * A bare `dz` (no slash) keeps PATH resolution; an already-absolute value is returned untouched.
2096
+ */
2097
+ export function normalizeDzBin(raw: string | null | undefined, ws: string | null | undefined): string {
2098
+ const r = (typeof raw === 'string' && raw.length > 0) ? raw : 'dz'
2099
+ if (r.indexOf('/') < 0) return r
2100
+ if (r.charAt(0) === '/') return r
2101
+ const base = (typeof ws === 'string' && ws.length > 0) ? ws.replace(/\/+$/, '') : ''
2102
+ return base === '' ? r : base + '/' + r
2103
+ }
2104
+
2105
+ /** What cross-family QE was asked for, what actually reviewed, and — when they differ — why. */
2106
+ export interface CrossFamilyQeReport {
2107
+ /** The QE spec routing resolved, e.g. `codex:gpt-5.6-sol:high`. */
2108
+ readonly requested: string | null;
2109
+ /** The model label that actually produced the review, e.g. `opus`. */
2110
+ readonly actual: string;
2111
+ readonly coderFamily: string;
2112
+ readonly reviewerFamily: string;
2113
+ /** TRUE only when the reviewer's family differs from the coder's. */
2114
+ readonly happened: boolean;
2115
+ /** Why cross-family QE did not happen. `null` when it did. */
2116
+ readonly reason: string | null;
2117
+ }
2118
+
2119
+ /**
2120
+ * Report — and LABEL — whether cross-family QE actually happened.
2121
+ *
2122
+ * The 2026-08-20 P16 run is the reason this exists. Routing correctly resolved QE to
2123
+ * `codex:gpt-5.6-sol:high` (MEASURED: `resolveQeSpec` returns exactly that for a Claude coder), the
2124
+ * codex dispatch returned null, the cross-model belt correctly fell back to a Claude reviewer so the
2125
+ * run would not block — and the result reported `modelsUsed.qe = "opus"` with nothing anywhere saying
2126
+ * the independent review had not taken place. The belt worked; its FAILURE was invisible. The only
2127
+ * reason anyone noticed is that the Claude reviewer volunteered it in prose.
2128
+ *
2129
+ * A safety property that silently degrades to its own absence is worse than one that is absent, because
2130
+ * the absence is believed to be presence. So the label carries the degradation and the caller returns
2131
+ * the report — self-review must never be able to pass for independent review by omission.
2132
+ *
2133
+ * HONEST SCOPE — what this does NOT do, named because a reader would otherwise assume it does (the
2134
+ * cross-family reviewer that graded the first version B asked for exactly this paragraph):
2135
+ * • it does not verify the declared reviewer ACTUALLY RAN — only what the caller declares;
2136
+ * • it does not establish genuine independence: two Claude models are one family here, and a
2137
+ * caller free to mislabel a family can still misreport;
2138
+ * • it does not validate the decline reason's provenance — any non-blank caller text is taken
2139
+ * verbatim, so a stale reason from an earlier dispatch would be reported as this one's (the
2140
+ * workflow clears `lastCodexDecline` per dispatch for exactly this reason; that discipline
2141
+ * lives at the call site, not here);
2142
+ * • it does not force a caller to surface the report at all.
2143
+ * It normalises family names and refuses to call an unnameable side cross-family. Beyond that it is
2144
+ * honest only when wired with truthful inputs — a REPORTING helper, not an authentication mechanism.
2145
+ */
2146
+ export function crossFamilyQe(opts: {
2147
+ requestedSpec: string | null;
2148
+ actualLabel: string;
2149
+ coderFamily: string;
2150
+ reviewerFamily: string;
2151
+ declineReason?: string | null;
2152
+ }): { report: CrossFamilyQeReport; label: string } {
2153
+ // NORMALISE before comparing. A cross-family reviewer graded the first version B and named this:
2154
+ // families were compared as RAW strings, so `reviewerFamily: 'Claude'` against
2155
+ // `coderFamily: 'claude'` reported happened:true with a clean label — the same loss this helper
2156
+ // exists to expose, walking back in through letter case.
2157
+ const norm = (f: string): string => String(f ?? '').trim().toLowerCase();
2158
+ const coderFamily = norm(opts.coderFamily);
2159
+ const reviewerFamily = norm(opts.reviewerFamily);
2160
+ // An empty family is not a family. Calling '' different from 'claude' would report a cross-family
2161
+ // review nobody can name, so an unnameable side counts as NOT cross-family.
2162
+ const nameable = coderFamily !== '' && reviewerFamily !== '';
2163
+ const happened = nameable && coderFamily !== reviewerFamily;
2164
+ const stated = opts.declineReason !== null && opts.declineReason !== undefined && String(opts.declineReason).trim() !== ''
2165
+ ? String(opts.declineReason).trim()
2166
+ : null;
2167
+ const reason = happened
2168
+ ? null
2169
+ : (stated ?? (nameable ? 'reviewer family equals coder family' : 'reviewer or coder family not named'));
2170
+ const report: CrossFamilyQeReport = {
2171
+ requested: opts.requestedSpec ?? null,
2172
+ actual: opts.actualLabel,
2173
+ coderFamily,
2174
+ reviewerFamily,
2175
+ happened,
2176
+ reason,
2177
+ };
2178
+ // The label is what a human skims. It must not read clean when the property was lost.
2179
+ const label = happened
2180
+ ? opts.actualLabel
2181
+ : `${opts.actualLabel} (cross-family QE DID NOT happen — ${reason})`;
2182
+ return { report, label };
2183
+ }
2184
+
2185
+ /** Whether a scoped (mode-B) cross-family review may run, and over WHICH files. */
2186
+ export type ModeBScopeVerdict =
2187
+ | { readonly ok: true; readonly files: readonly string[]; readonly dropped: readonly string[] }
2188
+ | { readonly ok: false; readonly reason: string };
2189
+
2190
+ /**
2191
+ * Decide the scope of a scoped cross-family QE review — or refuse it.
2192
+ *
2193
+ * The defect this closes (cross-family review of `qe-scoped-review`, 2026-08-21, its own first P1):
2194
+ * mode B built its file list from the PLANNED targets and never once consulted what actually changed.
2195
+ * On a run where Step 7 produced nothing, mode A reviews an empty diff and declines, mode B then
2196
+ * points the reviewer at unchanged pre-feature files and asks for a closing letter — and mode B is
2197
+ * the ONLY path that may return a STATED grade, the only one on which `A` is reachable at all. The
2198
+ * reviewer's headline stands quoted because it is exact: *"the patch can record a successful QE
2199
+ * verdict without reviewing the actual change."*
2200
+ *
2201
+ * So scope is derived from the MEASURED change set intersected with what the plan declared, and two
2202
+ * states refuse outright rather than review something else:
2203
+ * • `genuinely-not-landed` — the landing barrier already established the code is not there;
2204
+ * • an unknown change set — the probe could not measure. Inconclusive is never a pass, and here the
2205
+ * honest consequence is that cross-family QE does not happen and says so, which `crossFamilyQe`
2206
+ * now surfaces, rather than a confident grade over the wrong files.
2207
+ *
2208
+ * `dropped` is the planned-but-unchanged remainder. Callers must NOT report it as reviewed: recording
2209
+ * files the reviewer never saw is the same lie one level down (that is the report's MEDIUM-1).
2210
+ */
2211
+ export function decideModeBScope(opts: {
2212
+ planned: readonly string[];
2213
+ changed: readonly string[] | null;
2214
+ landingStatus?: string | null;
2215
+ }): ModeBScopeVerdict {
2216
+ if (opts.landingStatus === 'genuinely-not-landed') {
2217
+ return { ok: false, reason: 'the landing barrier established the code did not land — there is nothing to review' };
2218
+ }
2219
+ if (opts.changed === null || opts.changed === undefined) {
2220
+ return { ok: false, reason: 'the change set could not be measured — scope is NOT ESTABLISHED, which is never a pass' };
2221
+ }
2222
+ const planned = opts.planned.map((p) => String(p)).filter((p) => p !== '');
2223
+ if (planned.length === 0) {
2224
+ return { ok: false, reason: 'no declared targets — a scoped review needs a declared scope' };
2225
+ }
2226
+ const changedSet = new Set(opts.changed.map((c) => String(c)));
2227
+ const files = planned.filter((p) => changedSet.has(p));
2228
+ const dropped = planned.filter((p) => !changedSet.has(p));
2229
+ if (files.length === 0) {
2230
+ return { ok: false, reason: 'none of the ' + planned.length + ' declared target(s) actually changed — the review would be of unchanged code' };
2231
+ }
2232
+ return { ok: true, files, dropped };
2233
+ }
2234
+
2235
+ /** Findings split by whether they belong to the feature under review. */
2236
+ export interface PartitionedFindings {
2237
+ readonly inScope: readonly CodexQeFinding[];
2238
+ /** Real findings about OTHER work that happened to be dirty. Reported, never graded. */
2239
+ readonly outOfScope: readonly CodexQeFinding[];
2240
+ /**
2241
+ * Findings whose location could not be MATCHED either way — no location at all, or a shape the
2242
+ * matcher does not parse (`src/a.ts line 10`). They are NOT out-of-scope: nothing proves they
2243
+ * concern another file. Suppressing them would drop a possibly-blocking finding from the grade on
2244
+ * the strength of a parsing failure, which is the "unknown counted as clean" mistake this whole
2245
+ * wave exists to remove — and which I reproduced here until cross-family review caught it.
2246
+ */
2247
+ readonly unlocatable: readonly CodexQeFinding[];
2248
+ /** True when scope could not be established, so nothing may be attributed either way. */
2249
+ readonly unscoped: boolean;
2250
+ }
2251
+
2252
+ /**
2253
+ * Separate a mode-A review's findings into this feature's and the rest of the dirty worktree's.
2254
+ *
2255
+ * `codex review --uncommitted` reviews EVERY staged, unstaged and untracked change, and
2256
+ * `gradeFromReviewFindings` takes the worst severity over all of them. So an unrelated P0 sitting
2257
+ * dirty in the tree grades this feature D — indistinguishable from a D it earned.
2258
+ *
2259
+ * Not theoretical. In the saved live self-review fixture
2260
+ * (`test/fixtures/codex-review-selfreview-slices-2026-08-21.txt`), ONE of the six P1s is
2261
+ * `features/talk-ai-assistants/demo-site/site/dist/index.html:57-58` — unrelated work that merely
2262
+ * happened to be uncommitted at the time.
2263
+ *
2264
+ * Out-of-scope findings are NOT discarded: they are real, and dropping them silently would be its own
2265
+ * dishonesty. They are returned separately so the caller can report them as what they are.
2266
+ *
2267
+ * @param inScopePaths the MEASURED change set for this feature (repo-relative). Empty ⇒ `unscoped`,
2268
+ * and the caller must not attribute anything — inconclusive is never a pass, and it is not a
2269
+ * fail either.
2270
+ */
2271
+ export function partitionReviewFindings(
2272
+ findings: readonly CodexQeFinding[] | null | undefined,
2273
+ inScopePaths: readonly string[] | null | undefined,
2274
+ ): PartitionedFindings {
2275
+ const list = Array.isArray(findings) ? findings : [];
2276
+ const paths = (inScopePaths ?? []).map((p) => String(p)).filter((p) => p !== '');
2277
+ if (paths.length === 0) return { inScope: [], outOfScope: [], unlocatable: list, unscoped: true };
2278
+ const belongs = (loc: string): boolean => {
2279
+ const l = String(loc ?? '');
2280
+ // A location is `<path>:<line>` or `<abs-path>:<line>-<line>`; match on the path SEGMENT so an
2281
+ // absolute location still matches its repo-relative target, and a mere substring cannot.
2282
+ if (paths.some((p) => l === p || l.startsWith(p + ':') || l.includes('/' + p + ':') || l.endsWith('/' + p))) return true;
2283
+ // A location whose SHAPE we can parse (`<path>:<line>`) has already had its say above — a loose
2284
+ // substring must not override it, or `other/src/a.ts.bak:3` would be claimed by `src/a.ts`.
2285
+ // But a shape we CANNOT parse yet which names an in-scope file is ours (`src/a.ts line 10`):
2286
+ // reading "I could not parse this" as "it belongs to someone else" is how a finding about our own
2287
+ // file would quietly leave the grade.
2288
+ if (/[\w./-]+:\d/.test(l)) return false;
2289
+ return paths.some((p) => l.includes(p));
2290
+ };
2291
+ const inScope: CodexQeFinding[] = [];
2292
+ const outOfScope: CodexQeFinding[] = [];
2293
+ const unlocatable: CodexQeFinding[] = [];
2294
+ // A location we can PARSE and that names another file is out of scope. A location we cannot parse
2295
+ // proves nothing, so it goes to `unlocatable` and the caller must not rescore without it.
2296
+ // Purely about SHAPE now: does the location name some file at some line? If it does and it is not
2297
+ // ours, it is another file's. If it does not, we know nothing.
2298
+ const parseable = (loc: string): boolean => /[\w./-]+:\d/.test(String(loc ?? ''));
2299
+ for (const f of list) {
2300
+ const loc = f && f.location ? f.location : '';
2301
+ if (belongs(loc)) inScope.push(f);
2302
+ else if (parseable(loc)) outOfScope.push(f);
2303
+ else unlocatable.push(f);
2304
+ }
2305
+ return { inScope, outOfScope, unlocatable, unscoped: false };
2306
+ }
2307
+
2308
+ /** A path → content-hash snapshot; `null` means the file was ABSENT when the snapshot was taken. */
2309
+ export type FileHashSnapshot = ReadonlyMap<string, string | null>;
2310
+
2311
+ /**
2312
+ * Parse a `sha256sum` probe into a snapshot, tolerating the "no such file" lines it prints to stderr.
2313
+ *
2314
+ * Presence is not the question — CONTENT is. A file that was already dirty before Step 7 and then
2315
+ * edited by Step 7 must count as changed, and `git status` cannot tell those apart: it reports the
2316
+ * file as dirty in both cases. That is one half of why the old probe measured the wrong thing.
2317
+ */
2318
+ export function parseHashProbe(text: string | null | undefined, declared: readonly string[]): FileHashSnapshot {
2319
+ const out = new Map<string, string | null>();
2320
+ for (const p of declared) out.set(String(p), null);
2321
+ for (const raw of String(text ?? '').split('\n')) {
2322
+ const line = raw.trim();
2323
+ if (line === '') continue;
2324
+ const m = /^([0-9a-f]{64})\s+(.+)$/.exec(line);
2325
+ if (m === null || m[1] === undefined || m[2] === undefined) continue;
2326
+ const path = m[2].trim().replace(/^\.\//, '');
2327
+ if (out.has(path)) out.set(path, m[1]);
2328
+ }
2329
+ return out;
2330
+ }
2331
+
2332
+ /**
2333
+ * Which declared targets actually changed between two snapshots.
2334
+ *
2335
+ * Returns `null` when either snapshot is missing — an unmeasured delta is NOT an empty delta, and the
2336
+ * callers treat null as "scope not established", which is never a pass.
2337
+ */
2338
+ export function changedFromHashes(before: FileHashSnapshot | null, after: FileHashSnapshot | null): string[] | null {
2339
+ if (before === null || before === undefined || after === null || after === undefined) return null;
2340
+ const changed: string[] = [];
2341
+ for (const [path, afterHash] of after) {
2342
+ const beforeHash = before.has(path) ? before.get(path) ?? null : null;
2343
+ if (beforeHash !== (afterHash ?? null)) changed.push(path);
2344
+ }
2345
+ return changed.sort();
2346
+ }
2347
+
2348
+ /**
2349
+ * Build the probe that measures a change set, MATCHED to the review scope.
2350
+ *
2351
+ * The old code ran one `git status --porcelain` regardless of scope, which is wrong in both
2352
+ * directions (cross-family review of the 2026-08-21 wave, P1):
2353
+ * • `uncommitted` — a target already dirty BEFORE Step 7 is reported as this run's change, so mode B
2354
+ * could certify work the coder never touched. Hence the baseline pair rather than a single look.
2355
+ * • `commit` / `base` — real COMMITTED changes produce no status entry at all, so the set came back
2356
+ * empty and the scoped review was disabled on a run whose code demonstrably exists.
2357
+ *
2358
+ * @returns the shell command, or `null` when the scope needs a ref it was not given — the caller must
2359
+ * then treat the scope as unmeasured rather than substituting a different question.
2360
+ */
2361
+ export function changeSetProbeCmd(opts: {
2362
+ scope: string;
2363
+ ref?: string | null;
2364
+ paths: readonly string[];
2365
+ quote: (s: string) => string;
2366
+ }): string | null {
2367
+ const paths = opts.paths.map((p) => String(p)).filter((p) => p !== '');
2368
+ if (paths.length === 0) return null;
2369
+ const quoted = paths.map(opts.quote).join(' ');
2370
+ const ref = String(opts.ref ?? '').trim();
2371
+ if (opts.scope === 'commit') {
2372
+ if (ref === '') return null;
2373
+ return 'git diff --name-only ' + opts.quote(ref) + '~1 ' + opts.quote(ref) + ' -- ' + quoted;
2374
+ }
2375
+ if (opts.scope === 'base') {
2376
+ if (ref === '') return null;
2377
+ return 'git diff --name-only ' + opts.quote(ref) + '...HEAD -- ' + quoted;
2378
+ }
2379
+ // uncommitted: hash the declared targets; the caller pairs this with a pre-code baseline.
2380
+ return 'sha256sum -- ' + quoted + ' 2>/dev/null || true';
1536
2381
  }