@dzhechkov/harness-core 0.5.4 → 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.
@@ -1009,9 +1009,13 @@ export function codexDispatchMode(stage) {
1009
1009
  * 56-line adversarial code review answered in 14s. The stalls are INTERMITTENT latency — the same
1010
1010
  * input hung at 60s and answered at 14s minutes apart. Size is not the variable.
1011
1011
  *
1012
- * So the real guard is the bounded timeout plus the CODEX_UNAVAILABLE sentinel: a slow exec becomes
1013
- * an explicit "unavailable", never a passed review. This constant only stops us from shipping an
1014
- * absurdly large prompt.
1012
+ * DEMOTED 2026-08-21 (feature qe-scoped-review, ADR 001). This constant used to be described as the
1013
+ * stall guard. That is now REFUTED by measurement: a 19 038-char QE prompt sat under this 24 000
1014
+ * ceiling with ~5 000 chars of headroom and still spent 280 s / exit 124 producing no verdict, twice.
1015
+ * The variable is not SIZE, it is whether the model is allowed to roam the tree. The defence is
1016
+ * SCOPE — `codexReviewCommand` (the diff defines it) and `scopedQePrompt` ("read ONLY these files").
1017
+ * This constant is retained as a sanity bound on an absurd payload, and is no longer claimed as the
1018
+ * thing that prevents a stall.
1015
1019
  */
1016
1020
  export const CODEX_EXEC_PROMPT_CEILING_CHARS = 24_000;
1017
1021
  /** The sentinel an exec agent returns when the command failed, timed out, or Codex refused. */
@@ -1024,6 +1028,16 @@ export function codexExecPlan(input) {
1024
1028
  if (!input.probedId) {
1025
1029
  return { mode: 'claude', reason: 'no codex model id answered the probe' };
1026
1030
  }
1031
+ // The `qe` stage is the one whose deliverable is a VERDICT, and the one measured to time out
1032
+ // unscoped. An unscoped exec here is not merely slow — it returns null, the belt runs a Claude
1033
+ // reviewer, and cross-family QE is lost silently. So it must not be dispatchable at all.
1034
+ if (input.stage === 'qe' && input.scoped !== true) {
1035
+ return {
1036
+ mode: 'claude',
1037
+ reason: 'qe prompt is not SCOPED — an unscoped codex exec QE buys reconnaissance, not review ' +
1038
+ '(MEASURED 2026-08-21: 19038 chars, 280s, exit 124, no verdict)',
1039
+ };
1040
+ }
1027
1041
  if (input.promptChars > CODEX_EXEC_PROMPT_CEILING_CHARS) {
1028
1042
  return {
1029
1043
  mode: 'claude',
@@ -1088,6 +1102,388 @@ export function parseCodexExecResult(text) {
1088
1102
  }
1089
1103
  return { ok: true, text: t, reason: 'codex answered' };
1090
1104
  }
1105
+ // ── SCOPED CODEX QE (feature qe-scoped-review, ADR 001) ─────────────────────
1106
+ //
1107
+ // MEASURED 2026-08-21, one question, one model (`gpt-5.6-sol`, effort `high`), three dispatches:
1108
+ // 1. `codex exec`, UNSCOPED, 19 038-char prompt → 280 s, exit 124, 416 KB / 4 583 lines, NO verdict
1109
+ // (retried at a 1500 s ceiling: still no verdict).
1110
+ // 2. `codex exec`, SCOPED ("read ONLY these two files"), 1 461-char prompt → 41 s, exit 0, `Grade: B`.
1111
+ // 3. `codex review --commit <SHA>` → 146 s, exit 0, verdict + findings, scope derived from the diff.
1112
+ //
1113
+ // The budget went on RECONNAISSANCE of the tree, not on reasoning about the change. So raising the
1114
+ // timeout buys more reconnaissance, and the old working hypothesis — that
1115
+ // `CODEX_EXEC_PROMPT_CEILING_CHARS` was the binding constraint — is REFUTED: 19 038 sat under the
1116
+ // 24 000 ceiling with ~5 000 chars to spare. The defence is SCOPE. The ceiling is now a sanity bound.
1117
+ //
1118
+ // Why this matters beyond wall-clock: on timeout the dispatch returns null, the belt runs a Claude
1119
+ // reviewer, and the cross-family QE property is lost SILENTLY on exactly the large features that need
1120
+ // it most. Everything below exists so that loss is (a) rarer and (b) always attributable.
1121
+ /** Mode-A wall-clock bound. Run 3 measured 146 s; 600 s is ~4× headroom and still bounded. */
1122
+ export const CODEX_REVIEW_TIMEOUT_SECONDS = 600;
1123
+ /** MEASURED (probe 0.3): `codex review` accepts and echoes `reasoning effort: high`. */
1124
+ export const CODEX_REVIEW_DEFAULT_EFFORT = 'high';
1125
+ /** The sentinel a wrapper returns when the command hit its `timeout` — distinct from CODEX_UNAVAILABLE. */
1126
+ export const CODEX_TIMEOUT = 'CODEX_TIMEOUT';
1127
+ /** Machine sentinel appended by the dispatch command itself (grammar of the Step-7.5 landing signal). */
1128
+ export const CODEX_QE_SIGNAL_PREFIX = 'CODEX-QE-SIGNAL';
1129
+ /** Mode-B bounds, set FROM the measurement above (run 2 = 2 files / 1 461 chars), not from the ceiling. */
1130
+ export const SCOPED_QE_MAX_FILES = 3;
1131
+ export const SCOPED_QE_MAX_QUESTIONS = 4;
1132
+ export const SCOPED_QE_MAX_PATH_CHARS = 200;
1133
+ export const SCOPED_QE_MAX_QUESTION_CHARS = 200;
1134
+ export const SCOPED_QE_PROMPT_MAX_CHARS = 2000;
1135
+ /**
1136
+ * A git ref reaches a shell command, exactly like a model id does. Same discipline as
1137
+ * {@link isSafeCodexId}: plain refs only, and single-quoted at the call site anyway.
1138
+ *
1139
+ * Deliberately STRICTER than git: `HEAD~1`, `a..b` with `~`/`^`, and any leading `-` (which the CLI
1140
+ * would read as a flag) are rejected. A rejected ref returns `{cmd: null}` → mode A is skipped and
1141
+ * mode B / the Claude belt runs. Refusing to build is always cheaper than building something odd.
1142
+ */
1143
+ export function isSafeCodexRef(ref) {
1144
+ return /^[A-Za-z0-9][A-Za-z0-9._\/-]{0,199}$/.test(String(ref));
1145
+ }
1146
+ /**
1147
+ * Build the mode-A command. The diff defines the scope, so Codex computes for free the thing we were
1148
+ * paying a model to do badly.
1149
+ *
1150
+ * Two refusals are load-bearing, both measured (exit 2 = a silent review failure, since the pipeline
1151
+ * would read "no output" as "codex unavailable"):
1152
+ * • never emit `-m` — `codex review` rejects it; the model goes through `-c model=`;
1153
+ * • never append a positional prompt — no scope flag accepts one.
1154
+ *
1155
+ * Default scope is `uncommitted` (probe 0.4b: it and `--base HEAD` reviewed the identical uncommitted
1156
+ * diff, and `uncommitted` needs no ref, so it has no ref-injection surface at all).
1157
+ */
1158
+ export function codexReviewCommand(input) {
1159
+ const o = input || {};
1160
+ const scope = o.scope === undefined || o.scope === null || o.scope === '' ? 'uncommitted' : String(o.scope);
1161
+ if (scope !== 'commit' && scope !== 'base' && scope !== 'uncommitted') {
1162
+ return { cmd: null, carriesPrompt: false, scope: scope, reason: 'unknown review scope ' + scope };
1163
+ }
1164
+ const modelId = String(o.modelId === undefined || o.modelId === null ? '' : o.modelId);
1165
+ if (!isSafeCodexId(modelId)) {
1166
+ return { cmd: null, carriesPrompt: false, scope: scope, reason: 'unsafe id or ref' };
1167
+ }
1168
+ const effort = o.reasoning === undefined || o.reasoning === null || o.reasoning === '' ? CODEX_REVIEW_DEFAULT_EFFORT : String(o.reasoning);
1169
+ if (!VALID_REASONING[effort]) {
1170
+ return { cmd: null, carriesPrompt: false, scope: scope, reason: 'unknown reasoning effort ' + effort };
1171
+ }
1172
+ const ref = String(o.ref === undefined || o.ref === null ? '' : o.ref);
1173
+ if (scope !== 'uncommitted' && !isSafeCodexRef(ref)) {
1174
+ return { cmd: null, carriesPrompt: false, scope: scope, reason: 'unsafe id or ref' };
1175
+ }
1176
+ const raw = Number(o.timeoutSeconds);
1177
+ const seconds = raw === raw && raw !== Infinity && raw > 0 ? Math.floor(raw) : CODEX_REVIEW_TIMEOUT_SECONDS;
1178
+ let cmd = 'timeout ' + seconds + " codex review -c model='" + modelId + "' -c model_reasoning_effort='" + effort + "'";
1179
+ if (scope === 'commit')
1180
+ cmd += " --commit '" + ref + "'";
1181
+ else if (scope === 'base')
1182
+ cmd += " --base '" + ref + "'";
1183
+ else
1184
+ cmd += ' --uncommitted';
1185
+ cmd += ' < /dev/null';
1186
+ return { cmd: cmd, carriesPrompt: false, scope: scope, reason: null };
1187
+ }
1188
+ /**
1189
+ * Build the mode-B narrow prompt. The "do NOT open any other file" clause is LOAD-BEARING TEXT: it is
1190
+ * the difference between the 41 s graded run and the 280 s ungraded one, at comparable model effort.
1191
+ *
1192
+ * An empty file list returns `''` — the caller treats that as "do not dispatch". An unscoped mode-B
1193
+ * exec is precisely the failure this feature removes, so it must not be CONSTRUCTIBLE.
1194
+ */
1195
+ export function scopedQePrompt(input) {
1196
+ const o = input || {};
1197
+ const rawFiles = Array.isArray(o.files) ? o.files : [];
1198
+ const files = [];
1199
+ for (const f of rawFiles) {
1200
+ const s = String(f === undefined || f === null ? '' : f).trim();
1201
+ if (s === '')
1202
+ continue;
1203
+ if (files.indexOf(s) !== -1)
1204
+ continue;
1205
+ files.push(s.slice(0, SCOPED_QE_MAX_PATH_CHARS));
1206
+ if (files.length >= SCOPED_QE_MAX_FILES)
1207
+ break;
1208
+ }
1209
+ if (files.length === 0)
1210
+ return '';
1211
+ const rawQuestions = Array.isArray(o.questions) ? o.questions : [];
1212
+ const questions = [];
1213
+ for (const q of rawQuestions) {
1214
+ const s = String(q === undefined || q === null ? '' : q).trim().replace(/\s+/g, ' ');
1215
+ if (s === '')
1216
+ continue;
1217
+ questions.push(s.slice(0, SCOPED_QE_MAX_QUESTION_CHARS));
1218
+ if (questions.length >= SCOPED_QE_MAX_QUESTIONS)
1219
+ break;
1220
+ }
1221
+ if (questions.length === 0) {
1222
+ questions.push('Is this change correct, and does the test named by its ADR actually DISCRIMINATE (would it fail if the protection were deleted)?');
1223
+ }
1224
+ const slug = String(o.slug === undefined || o.slug === null ? '' : o.slug).trim().slice(0, 60);
1225
+ let out = 'Read ONLY these files: ' + files.join(', ') + '. Do NOT open any other file and do NOT explore the repository.';
1226
+ if (slug !== '')
1227
+ out += ' They are the changed files of feature ' + slug + '.';
1228
+ out += '\n\nAnswer these ' + questions.length + ' questions about them:\n';
1229
+ for (let i = 0; i < questions.length; i++)
1230
+ out += i + 1 + '. ' + questions[i] + '\n';
1231
+ out += '\nFinish with a single final line: Grade: <A|B|C|D>';
1232
+ return out;
1233
+ }
1234
+ /**
1235
+ * Wrap a command so its EXIT CODE survives the shell agent that runs it.
1236
+ *
1237
+ * The old wrapper collapsed every failure to `CODEX_UNAVAILABLE`, so a timeout (narrow the scope) and
1238
+ * a broken invocation (fix the command) arrived indistinguishable — and `codex review` output is
1239
+ * prose, not JSON, so the exit code is the only reliable discriminator there is. The emitted line is
1240
+ * the grammar {@link parseCodexReviewSignal} reads back, and the two are round-trip tested against a
1241
+ * REAL shell rather than against each other's regexes.
1242
+ *
1243
+ * The SUBSHELL around `inner` is load-bearing, and the round-trip test is what earned it: without it
1244
+ * the `> "$o" 2>&1` redirect binds only to the last command of a compound `inner`, and an `exit`
1245
+ * inside `inner` terminates the wrapper BEFORE the sentinel is echoed — producing exactly the
1246
+ * "signal missing" state that must mean "the command did not demonstrably run".
1247
+ */
1248
+ export function codexQeSignalCommand(inner, outPath) {
1249
+ const raw = String(outPath === undefined || outPath === null ? '' : outPath);
1250
+ // `JSON.stringify` is JSON quoting, not SHELL quoting — inside the double quotes it emits, `$`,
1251
+ // backtick and `\` all keep their shell meaning, so a path carrying one would break out of the
1252
+ // assignment. Found by the first live mode-A review of this feature (P1, 2026-08-21). The path is
1253
+ // ours to construct, so the fix is an allowlist plus single-quoting, not an escaper: anything that
1254
+ // is not a plain POSIX path falls back to the default rather than being cleverly escaped.
1255
+ const o = /^\/[A-Za-z0-9._\/-]{1,200}$/.test(raw) ? raw : '/tmp/dz-codex-qe.out';
1256
+ 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 \" \")"';
1257
+ }
1258
+ /**
1259
+ * Split the machine sentinel from the reviewer's own words.
1260
+ *
1261
+ * `codex review` output is prose, not JSON, so the EXIT CODE is the only reliable discriminator — and
1262
+ * today the shell-agent wrapper throws it away. The dispatch command now appends
1263
+ * `CODEX-QE-SIGNAL exit=<n> elapsed=<n>s bytes=<n>`, the same grammar as the Step-7.5 landing signal.
1264
+ *
1265
+ * A MISSING sentinel yields `exit: null` and `signalPresent: false` — never a defaulted 0. Defaulting
1266
+ * to 0 would let "the wrapper never ran the command" read as "the command succeeded".
1267
+ */
1268
+ export function parseCodexReviewSignal(text) {
1269
+ const t = typeof text === 'string' ? text : '';
1270
+ const re = /^CODEX-QE-SIGNAL exit=(-?\d+) elapsed=(\d+)s bytes=(\d+)[ \t]*$/gm;
1271
+ let m = null;
1272
+ let last = null;
1273
+ while ((m = re.exec(t)) !== null)
1274
+ last = m;
1275
+ if (last === null) {
1276
+ return { exit: null, elapsedSeconds: null, bytes: null, body: t.trim(), signalPresent: false };
1277
+ }
1278
+ const body = (t.slice(0, last.index) + t.slice(last.index + last[0].length)).trim();
1279
+ return { exit: Number(last[1]), elapsedSeconds: Number(last[2]), bytes: Number(last[3]), body: body, signalPresent: true };
1280
+ }
1281
+ /**
1282
+ * Parse mode-A findings out of non-JSON review output, driven by a REAL saved capture
1283
+ * (`test/fixtures/codex-review-2026-08-21.txt`), never by invented text.
1284
+ *
1285
+ * Observed shape: `- [P1] <title> — <abs-path>:<line>-<line>`, and Codex prints the whole finding
1286
+ * block TWICE (once as the summary, once as the final message), hence the dedup.
1287
+ *
1288
+ * Zero parsed findings is a DATA POINT (`[]`), never "clean" — that judgment belongs to
1289
+ * {@link gradeFromReviewFindings}, which refuses to make it.
1290
+ */
1291
+ export function parseCodexReviewFindings(body) {
1292
+ const t = String(body === undefined || body === null ? '' : body);
1293
+ const out = [];
1294
+ const seen = new Set();
1295
+ for (const rawLine of t.split('\n')) {
1296
+ const line = rawLine.trim();
1297
+ const m = /^[-*]\s*\[(P[0-4])\]\s*(.+)$/.exec(line);
1298
+ if (!m)
1299
+ continue;
1300
+ const severity = String(m[1]);
1301
+ let title = String(m[2]).trim();
1302
+ let location = '';
1303
+ const sep = title.lastIndexOf(' — ');
1304
+ if (sep !== -1) {
1305
+ const cand = title.slice(sep + 3).trim();
1306
+ if (/:\d/.test(cand) || cand.indexOf('/') !== -1) {
1307
+ location = cand;
1308
+ title = title.slice(0, sep).trim();
1309
+ }
1310
+ }
1311
+ if (title === '')
1312
+ continue;
1313
+ const key = severity + '|' + title + '|' + location;
1314
+ if (seen.has(key))
1315
+ continue;
1316
+ seen.add(key);
1317
+ out.push({ severity: severity, title: title, location: location });
1318
+ }
1319
+ return out;
1320
+ }
1321
+ /**
1322
+ * Derive a grade from what the reviewer actually found — used ONLY on the mode-A path, where the CLI
1323
+ * structurally forbids asking for a letter (measured: every scope flag rejects `[PROMPT]`).
1324
+ *
1325
+ * Two honesty rules, and they are the highest-severity lines in this feature:
1326
+ * 1. an empty or unparseable finding set returns `null`, NEVER a default letter. MEASURED
1327
+ * (probe 0.6): `codex review --uncommitted` on a CLEAN tree exits 0 with a polite, well-formed,
1328
+ * completely empty review. Mapping that to `'A'` would turn a review of NOTHING into a clean
1329
+ * bill of health — the exact `{grade:'codex-review', gaps: []}` fabrication ADR-001 deleted once;
1330
+ * 2. `'A'` is UNREACHABLE by derivation for every input. "Nothing was found" is not evidence of
1331
+ * quality when the reviewer's own findings are the only evidence we have; only a reviewer that
1332
+ * STATES `Grade: A` (mode B, where we can ask) may produce one.
1333
+ */
1334
+ export function gradeFromReviewFindings(findings) {
1335
+ const list = Array.isArray(findings) ? findings : [];
1336
+ let worst = null;
1337
+ for (const f of list) {
1338
+ const s = String(f && f.severity ? f.severity : '').toUpperCase();
1339
+ if (!/^P[0-4]$/.test(s))
1340
+ continue;
1341
+ const n = Number(s.slice(1));
1342
+ if (worst === null || n < worst)
1343
+ worst = n;
1344
+ }
1345
+ if (worst === null)
1346
+ return null;
1347
+ if (worst === 0)
1348
+ return 'D';
1349
+ if (worst === 1)
1350
+ return 'C';
1351
+ return 'B';
1352
+ }
1353
+ /**
1354
+ * The LOCKED decline taxonomy. A `kind` outside this set is a bug, not a new case — which is why
1355
+ * {@link codexQeDeclineReason} throws on one rather than rendering something plausible.
1356
+ */
1357
+ export const CODEX_QE_DECLINE_KINDS = ['timeout', 'no-verdict', 'tool-error', 'unusable-output', 'unavailable', 'over-ceiling'];
1358
+ /**
1359
+ * Classify one Codex QE dispatch. The property this whole feature exists to protect lives here:
1360
+ * a TIMEOUT and an UNUSABLE OUTPUT must never collapse into the same kind, because the operator's
1361
+ * next move differs — `timeout` ⇒ narrow the scope; `tool-error` ⇒ fix the command; `unavailable` ⇒
1362
+ * fix the account/model.
1363
+ *
1364
+ * Order is deliberate. `exit === 124` wins over EVERY content rule, because the measured timeout body
1365
+ * was 416 KB of exploration — very much non-empty, and an empty-body rule would have mislabelled it
1366
+ * `unusable-output` and told the operator to fix a tool that is working fine.
1367
+ *
1368
+ * `exit !== 0` is `tool-error` — but only for exits outside {0, 124}. MEASURED (probe 0.2): a review
1369
+ * that finds a real P1 blocker still exits 0. A BAD review is a SUCCESSFUL cross-family review.
1370
+ */
1371
+ export function classifyCodexQeOutcome(input) {
1372
+ const o = input || {};
1373
+ const body = String(o.body === undefined || o.body === null ? '' : o.body);
1374
+ const exit = o.exit === undefined || o.exit === null ? null : Number(o.exit);
1375
+ const grade = o.grade === undefined || o.grade === null || o.grade === '' ? null : String(o.grade);
1376
+ const signalExpected = o.signalExpected === undefined ? true : !!o.signalExpected;
1377
+ if (exit === 124)
1378
+ return { kind: 'timeout' };
1379
+ if (body.trim() === '')
1380
+ return { kind: 'unusable-output' };
1381
+ // The TEXT sentinels are evidence only when there is NO machine signal. FOUND BY THE FIRST LIVE
1382
+ // MODE-A RUN (2026-08-21): `codex review --uncommitted` over this very feature's diff exited 0 in
1383
+ // 482 s with six real findings, and was classified `timeout` — because the DIFF ITSELF contains the
1384
+ // line `CODEX_TIMEOUT = 'CODEX_TIMEOUT'`. A reviewer quoting the code under review is the normal
1385
+ // case for a diff-scoped review, so a content sentinel that outranks the exit code turns any review
1386
+ // of this file into a fake timeout. When the exit code is known it is authoritative.
1387
+ if (exit === null) {
1388
+ if (body.indexOf(CODEX_TIMEOUT) !== -1)
1389
+ return { kind: 'timeout' };
1390
+ if (body.indexOf(CODEX_UNAVAILABLE) !== -1)
1391
+ return { kind: 'unavailable' };
1392
+ if (signalExpected)
1393
+ return { kind: 'tool-error' };
1394
+ }
1395
+ else if (exit !== 0) {
1396
+ return { kind: 'tool-error' };
1397
+ }
1398
+ if (grade !== null)
1399
+ return { kind: 'verdict' };
1400
+ return { kind: 'no-verdict' };
1401
+ }
1402
+ /**
1403
+ * Render the operator-facing decline reason — the string the workflow assigns to `lastCodexDecline`
1404
+ * and that `crossFamilyQe` prints inside `opus (cross-family QE DID NOT happen — …)`.
1405
+ *
1406
+ * Before this feature every decline rendered as `codex exec unusable — codex exec returned no text`,
1407
+ * so a timeout (narrow the scope) and a broken invocation (fix the command) produced an identical,
1408
+ * unactionable alarm. The two `unusable-output` / `unavailable` strings are preserved VERBATIM from
1409
+ * the two pre-existing call sites so the change adds precision without rewriting history.
1410
+ *
1411
+ * `'empty'` and `'ceiling'` are accepted as aliases (the ADR's vocabulary) of `'unusable-output'` and
1412
+ * `'over-ceiling'` (the taxonomy's). Anything else THROWS: a kind outside the locked set is a bug,
1413
+ * and rendering a plausible sentence for it would hide that bug behind a readable label.
1414
+ */
1415
+ export function codexQeDeclineReason(kind, detail) {
1416
+ const d = detail || {};
1417
+ const k = String(kind === undefined || kind === null ? '' : kind);
1418
+ const canonical = k === 'empty' ? 'unusable-output' : k === 'ceiling' ? 'over-ceiling' : k;
1419
+ const secs = d.elapsedSeconds === undefined || d.elapsedSeconds === null ? d.seconds : d.elapsedSeconds;
1420
+ const elapsed = secs === undefined || secs === null ? '?' : String(secs);
1421
+ const ref = d.ref === undefined || d.ref === null || String(d.ref) === '' ? 'unknown' : String(d.ref);
1422
+ const files = d.files === undefined || d.files === null ? '?' : String(Array.isArray(d.files) ? d.files.length : d.files);
1423
+ const exit = d.exit === undefined || d.exit === null ? '?' : String(d.exit);
1424
+ const chars = d.chars === undefined || d.chars === null ? '?' : String(d.chars);
1425
+ const extra = d.detail === undefined || d.detail === null || String(d.detail) === '' ? 'no detail' : String(d.detail);
1426
+ if (canonical === 'timeout') {
1427
+ return 'codex review timed out after ' + elapsed + 's on scope ' + ref + ' (' + files + ' files) — NARROW the scope (this is reconnaissance cost, not thinking time)';
1428
+ }
1429
+ if (canonical === 'no-verdict') {
1430
+ return 'codex answered in ' + elapsed + 's but named no grade — not a verdict';
1431
+ }
1432
+ if (canonical === 'tool-error') {
1433
+ return 'codex review exited ' + exit + ' — FIX the invocation (' + extra + ')';
1434
+ }
1435
+ if (canonical === 'unusable-output') {
1436
+ return 'codex exec unusable — ' + (d.reason === undefined || d.reason === null || String(d.reason) === '' ? 'codex exec returned no text' : String(d.reason));
1437
+ }
1438
+ if (canonical === 'unavailable') {
1439
+ return 'codex not used — ' + (d.reason === undefined || d.reason === null || String(d.reason) === '' ? 'codex exec reported it could not run' : String(d.reason));
1440
+ }
1441
+ if (canonical === 'over-ceiling') {
1442
+ return 'prompt is ' + chars + ' chars / unscoped — refused before dispatch';
1443
+ }
1444
+ throw new Error('codexQeDeclineReason: unknown kind ' + k);
1445
+ }
1446
+ /**
1447
+ * The ADR's spelling of {@link codexQeDeclineReason}. Accepts BOTH call shapes — the ADR's
1448
+ * `({kind, seconds, detail})` object and the taxonomy's positional `(kind, detail)` — so both cited
1449
+ * call sites resolve to one implementation instead of two that can drift.
1450
+ */
1451
+ export function codexDeclineReason(a, b) {
1452
+ if (a && typeof a === 'object') {
1453
+ const o = a;
1454
+ return codexQeDeclineReason(o.kind, o);
1455
+ }
1456
+ return codexQeDeclineReason(a, b);
1457
+ }
1458
+ /**
1459
+ * The ADR's one-call parser over RAW reviewer text: signal-split → findings → grade → classify.
1460
+ * Pure composition; every rule it applies belongs to one of the four helpers above.
1461
+ *
1462
+ * `signalExpected` is passed as `sig.signalPresent` ON PURPOSE, and it is the one line here worth
1463
+ * reading twice. This function's input is text that may never have been wrapped (a saved fixture, a
1464
+ * report on disk), so a missing sentinel means "no machine signal exists", not "the tool failed".
1465
+ * The PIPELINE must not use this leniency: the workflow dispatches through the sentinel-emitting
1466
+ * wrapper and calls {@link classifyCodexQeOutcome} directly with `signalExpected: true`, so a
1467
+ * swallowed sentinel there is a `tool-error` and never a pass. A wiring test pins that.
1468
+ */
1469
+ export function parseCodexReviewResult(text) {
1470
+ const sig = parseCodexReviewSignal(text);
1471
+ const findings = parseCodexReviewFindings(sig.body);
1472
+ const stated = parseCodexGrade(sig.body);
1473
+ const grade = stated !== null ? stated : gradeFromReviewFindings(findings);
1474
+ const outcome = classifyCodexQeOutcome({ exit: sig.exit, body: sig.body, grade: grade, findings: findings, signalExpected: sig.signalPresent });
1475
+ const kind = outcome.kind === 'unusable-output' ? 'empty' : outcome.kind;
1476
+ const ok = kind === 'verdict';
1477
+ const reason = ok ? null : codexQeDeclineReason(kind, { elapsedSeconds: sig.elapsedSeconds, exit: sig.exit, chars: sig.body.length });
1478
+ return {
1479
+ ok: ok,
1480
+ grade: ok ? grade : null,
1481
+ kind: kind,
1482
+ gradeSource: ok ? (stated !== null ? 'stated' : 'derived-from-findings') : null,
1483
+ findings: findings,
1484
+ reason: reason,
1485
+ };
1486
+ }
1091
1487
  /** CX-3: a workflow that names an agent type the harness does not have must fall back, not die. */
1092
1488
  export function isAgentTypeMissingError(err) {
1093
1489
  const msg = err instanceof Error ? err.message : String(err ?? '');
@@ -1206,15 +1602,60 @@ export function checkArtifactRoot(root) {
1206
1602
  // copies (drift-guarded), because the workflow sandbox cannot import.
1207
1603
  /** The one path the pipeline calls. Repo-relative — the command `cd`s into the repo root first. */
1208
1604
  export const PLAN_GATE_SCRIPT = '.claude/skills/feature-adr/scripts/check-plan-completeness.mjs';
1605
+ /** Both interpolated knobs are shell-spliced, so both get the same build-time shape check. */
1606
+ function assertAbsoluteNoTraversal(value, knob) {
1607
+ if (typeof value !== 'string' || value === '')
1608
+ throw new Error('planCompletenessGateCmd: opts.' + knob + ' must be a non-empty absolute path');
1609
+ if (value.charAt(0) !== '/')
1610
+ throw new Error('planCompletenessGateCmd: opts.' + knob + ' must be an ABSOLUTE path, got ' + JSON.stringify(value));
1611
+ if (/(^|\/)\.\.(\/|$)/.test(value))
1612
+ throw new Error("planCompletenessGateCmd: opts." + knob + " must not contain a '..' segment, got " + JSON.stringify(value));
1613
+ return value;
1614
+ }
1209
1615
  /**
1210
1616
  * The EXACT command the gate agent runs. `2>&1` folds stderr in (a crash must be visible, not
1211
1617
  * silently empty) and the `K2_EXIT=` trailer carries the exit code back through an agent that can
1212
1618
  * only return text.
1619
+ *
1620
+ * P16/D2 — WHERE the script is looked up. The skill is installed in the WORKSPACE; the command
1621
+ * `cd`s into the TARGET repo, so a repo-relative `node .claude/skills/…` resolved against the target
1622
+ * and died with `Cannot find module` on every repo that is not itself a feature-adr install (field
1623
+ * report P16: K2_EXIT=1, verdict not-established, reason no-verdict-line). The fix is an ordered
1624
+ * candidate chain, WORKSPACE BEFORE REPO on purpose: the verdict contract is defined by the PARSER
1625
+ * inside the running workflow, so only the copy from that same installation is known to speak it —
1626
+ * a target repo may carry an older copy that prints `K2: NOT-ESTABLISHED — …`, a prefix this
1627
+ * parser does not match (a live example lives at
1628
+ * features/wave1-instrument-repair/check-plan-completeness.mjs). Nothing found ⇒ a LOUD
1629
+ * `tooling-missing` refusal with every tried path echoed — never a skip, never a pass.
1630
+ *
1631
+ * Called with three arguments the emitted string is byte-identical to the pre-P16 command; the
1632
+ * search chain appears only when `opts` is supplied. That 3-arg form exists so the pre-existing
1633
+ * byte-pin can keep asserting the OLD shape (a test-fixture role, not an API promise — the shipped
1634
+ * caller always passes `opts`).
1213
1635
  */
1214
- export function planCompletenessGateCmd(repo, featureDir, tier) {
1636
+ export function planCompletenessGateCmd(repo, featureDir, tier, opts) {
1215
1637
  const q = (s) => "'" + String(s).replace(/'/g, "'\\''") + "'";
1216
1638
  const t = (typeof tier === 'string' && tier !== '') ? ' --tier=' + q(tier) : '';
1217
- return 'cd ' + q(repo) + ' && node ' + q(PLAN_GATE_SCRIPT) + ' ' + q(featureDir) + t + ' 2>&1; echo K2_EXIT=$?';
1639
+ if (opts === undefined || opts === null) {
1640
+ return 'cd ' + q(repo) + ' && node ' + q(PLAN_GATE_SCRIPT) + ' ' + q(featureDir) + t + ' 2>&1; echo K2_EXIT=$?';
1641
+ }
1642
+ const explicit = (opts.gateScript === undefined || opts.gateScript === null) ? null : assertAbsoluteNoTraversal(opts.gateScript, 'gateScript');
1643
+ const ws = (opts.workspace === undefined || opts.workspace === null) ? null : assertAbsoluteNoTraversal(opts.workspace, 'workspace');
1644
+ // WS is captured BEFORE the cd — that ordering is the whole point; a 'pwd -P' after the cd would
1645
+ // report the target repo and the chain would collapse back into the defect it fixes.
1646
+ const wsAssign = ws === null ? 'WS=$(pwd -P)' : 'WS=' + q(ws);
1647
+ return [
1648
+ wsAssign + "; GS=''",
1649
+ 'C1=' + (explicit === null ? "''" : q(explicit)) + '; C2="$WS/' + PLAN_GATE_SCRIPT + '"; C3=' + q(repo + '/' + PLAN_GATE_SCRIPT),
1650
+ 'for c in "$C1" "$C2" "$C3"; do [ -n "$c" ] && [ -f "$c" ] && { GS="$c"; break; }; done',
1651
+ // Audit lines, printed ALWAYS and BEFORE any verdict line: which copy ran, and what was tried.
1652
+ // They are deliberately not verdict-shaped, so the parser's last-match anchoring is untouched,
1653
+ // and the tried paths live OUTSIDE the verdict line so no path can smuggle a second verdict word
1654
+ // into it.
1655
+ 'echo "K2_GATE_SCRIPT=${GS:-none}"',
1656
+ 'echo "K2_GATE_TRIED=${C1:-(none)} | $C2 | $C3"',
1657
+ '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',
1658
+ ].join('\n');
1218
1659
  }
1219
1660
  /**
1220
1661
  * PARSE-NEVER-SYNTHESIZE. An empty reply, a reply with no verdict line, a missing/unknown exit code,
@@ -1243,6 +1684,278 @@ export function parsePlanGateVerdict(raw) {
1243
1684
  return { verdict: 'not-established', exit: exitCode, reason: 'unknown-exit-code', output: output };
1244
1685
  if (byExit !== byName)
1245
1686
  return { verdict: 'not-established', exit: exitCode, reason: 'verdict-exit-mismatch', output: output };
1246
- return { verdict: byName, exit: exitCode, reason: 'script-verdict', output: output };
1687
+ // P16/D2: a REFINEMENT of the reason, not a new verdict. The verdict vocabulary stays three values,
1688
+ // so every banner and exit-code table pinned by tests keeps meaning what it meant. A forged
1689
+ // K2_EXIT=0 under a NOT-ESTABLISHED line still returns verdict-exit-mismatch above — the new
1690
+ // reason is read only after both halves already agree.
1691
+ const lastAt = text.lastIndexOf(lastVerdict);
1692
+ const nl = text.indexOf('\n', lastAt);
1693
+ const lastLine = nl < 0 ? text.slice(lastAt) : text.slice(lastAt, nl);
1694
+ const reason = (byName === 'not-established' && /tooling-missing:/.test(lastLine)) ? 'tooling-missing' : 'script-verdict';
1695
+ return { verdict: byName, exit: exitCode, reason: reason, output: output };
1696
+ }
1697
+ /**
1698
+ * The operator note a refused plan gate carries — ONE reason→text table, so the workflow's inline
1699
+ * copy cannot drift into telling an operator to fix a plan that is not broken.
1700
+ *
1701
+ * AM-2/AM-7: before P16 this text was inline and UNCONDITIONAL ("exit 1 ⇒ fix the FAIL lines"),
1702
+ * which is actively wrong for a gate that never RAN. Existence of a branch is not proof it fires,
1703
+ * so the table is exported and unit-tested on both reasons.
1704
+ */
1705
+ export function refusalNoteFor(planGate, slug) {
1706
+ const exitTxt = planGate.exit === null ? 'unknown' : String(planGate.exit);
1707
+ 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. ';
1708
+ if (planGate.reason === 'tooling-missing') {
1709
+ 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;
1710
+ }
1711
+ 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;
1712
+ }
1713
+ /**
1714
+ * ADR-003 — pin a RELATIVE `args.dzBin` to the workspace root once, at the point of definition.
1715
+ *
1716
+ * `DZ` is spliced into commands that first `cd` into the target repo, into the brain, or into
1717
+ * nothing at all (the usage probe), so a relative binary path resolves against three different
1718
+ * bases and silently returns nothing on at least two of them — and a null usage probe is read
1719
+ * upstream as "the limit was hit", which fail-safe-switches a healthy run to Codex.
1720
+ * A bare `dz` (no slash) keeps PATH resolution; an already-absolute value is returned untouched.
1721
+ */
1722
+ export function normalizeDzBin(raw, ws) {
1723
+ const r = (typeof raw === 'string' && raw.length > 0) ? raw : 'dz';
1724
+ if (r.indexOf('/') < 0)
1725
+ return r;
1726
+ if (r.charAt(0) === '/')
1727
+ return r;
1728
+ const base = (typeof ws === 'string' && ws.length > 0) ? ws.replace(/\/+$/, '') : '';
1729
+ return base === '' ? r : base + '/' + r;
1730
+ }
1731
+ /**
1732
+ * Report — and LABEL — whether cross-family QE actually happened.
1733
+ *
1734
+ * The 2026-08-20 P16 run is the reason this exists. Routing correctly resolved QE to
1735
+ * `codex:gpt-5.6-sol:high` (MEASURED: `resolveQeSpec` returns exactly that for a Claude coder), the
1736
+ * codex dispatch returned null, the cross-model belt correctly fell back to a Claude reviewer so the
1737
+ * run would not block — and the result reported `modelsUsed.qe = "opus"` with nothing anywhere saying
1738
+ * the independent review had not taken place. The belt worked; its FAILURE was invisible. The only
1739
+ * reason anyone noticed is that the Claude reviewer volunteered it in prose.
1740
+ *
1741
+ * A safety property that silently degrades to its own absence is worse than one that is absent, because
1742
+ * the absence is believed to be presence. So the label carries the degradation and the caller returns
1743
+ * the report — self-review must never be able to pass for independent review by omission.
1744
+ *
1745
+ * HONEST SCOPE — what this does NOT do, named because a reader would otherwise assume it does (the
1746
+ * cross-family reviewer that graded the first version B asked for exactly this paragraph):
1747
+ * • it does not verify the declared reviewer ACTUALLY RAN — only what the caller declares;
1748
+ * • it does not establish genuine independence: two Claude models are one family here, and a
1749
+ * caller free to mislabel a family can still misreport;
1750
+ * • it does not validate the decline reason's provenance — any non-blank caller text is taken
1751
+ * verbatim, so a stale reason from an earlier dispatch would be reported as this one's (the
1752
+ * workflow clears `lastCodexDecline` per dispatch for exactly this reason; that discipline
1753
+ * lives at the call site, not here);
1754
+ * • it does not force a caller to surface the report at all.
1755
+ * It normalises family names and refuses to call an unnameable side cross-family. Beyond that it is
1756
+ * honest only when wired with truthful inputs — a REPORTING helper, not an authentication mechanism.
1757
+ */
1758
+ export function crossFamilyQe(opts) {
1759
+ // NORMALISE before comparing. A cross-family reviewer graded the first version B and named this:
1760
+ // families were compared as RAW strings, so `reviewerFamily: 'Claude'` against
1761
+ // `coderFamily: 'claude'` reported happened:true with a clean label — the same loss this helper
1762
+ // exists to expose, walking back in through letter case.
1763
+ const norm = (f) => String(f ?? '').trim().toLowerCase();
1764
+ const coderFamily = norm(opts.coderFamily);
1765
+ const reviewerFamily = norm(opts.reviewerFamily);
1766
+ // An empty family is not a family. Calling '' different from 'claude' would report a cross-family
1767
+ // review nobody can name, so an unnameable side counts as NOT cross-family.
1768
+ const nameable = coderFamily !== '' && reviewerFamily !== '';
1769
+ const happened = nameable && coderFamily !== reviewerFamily;
1770
+ const stated = opts.declineReason !== null && opts.declineReason !== undefined && String(opts.declineReason).trim() !== ''
1771
+ ? String(opts.declineReason).trim()
1772
+ : null;
1773
+ const reason = happened
1774
+ ? null
1775
+ : (stated ?? (nameable ? 'reviewer family equals coder family' : 'reviewer or coder family not named'));
1776
+ const report = {
1777
+ requested: opts.requestedSpec ?? null,
1778
+ actual: opts.actualLabel,
1779
+ coderFamily,
1780
+ reviewerFamily,
1781
+ happened,
1782
+ reason,
1783
+ };
1784
+ // The label is what a human skims. It must not read clean when the property was lost.
1785
+ const label = happened
1786
+ ? opts.actualLabel
1787
+ : `${opts.actualLabel} (cross-family QE DID NOT happen — ${reason})`;
1788
+ return { report, label };
1789
+ }
1790
+ /**
1791
+ * Decide the scope of a scoped cross-family QE review — or refuse it.
1792
+ *
1793
+ * The defect this closes (cross-family review of `qe-scoped-review`, 2026-08-21, its own first P1):
1794
+ * mode B built its file list from the PLANNED targets and never once consulted what actually changed.
1795
+ * On a run where Step 7 produced nothing, mode A reviews an empty diff and declines, mode B then
1796
+ * points the reviewer at unchanged pre-feature files and asks for a closing letter — and mode B is
1797
+ * the ONLY path that may return a STATED grade, the only one on which `A` is reachable at all. The
1798
+ * reviewer's headline stands quoted because it is exact: *"the patch can record a successful QE
1799
+ * verdict without reviewing the actual change."*
1800
+ *
1801
+ * So scope is derived from the MEASURED change set intersected with what the plan declared, and two
1802
+ * states refuse outright rather than review something else:
1803
+ * • `genuinely-not-landed` — the landing barrier already established the code is not there;
1804
+ * • an unknown change set — the probe could not measure. Inconclusive is never a pass, and here the
1805
+ * honest consequence is that cross-family QE does not happen and says so, which `crossFamilyQe`
1806
+ * now surfaces, rather than a confident grade over the wrong files.
1807
+ *
1808
+ * `dropped` is the planned-but-unchanged remainder. Callers must NOT report it as reviewed: recording
1809
+ * files the reviewer never saw is the same lie one level down (that is the report's MEDIUM-1).
1810
+ */
1811
+ export function decideModeBScope(opts) {
1812
+ if (opts.landingStatus === 'genuinely-not-landed') {
1813
+ return { ok: false, reason: 'the landing barrier established the code did not land — there is nothing to review' };
1814
+ }
1815
+ if (opts.changed === null || opts.changed === undefined) {
1816
+ return { ok: false, reason: 'the change set could not be measured — scope is NOT ESTABLISHED, which is never a pass' };
1817
+ }
1818
+ const planned = opts.planned.map((p) => String(p)).filter((p) => p !== '');
1819
+ if (planned.length === 0) {
1820
+ return { ok: false, reason: 'no declared targets — a scoped review needs a declared scope' };
1821
+ }
1822
+ const changedSet = new Set(opts.changed.map((c) => String(c)));
1823
+ const files = planned.filter((p) => changedSet.has(p));
1824
+ const dropped = planned.filter((p) => !changedSet.has(p));
1825
+ if (files.length === 0) {
1826
+ return { ok: false, reason: 'none of the ' + planned.length + ' declared target(s) actually changed — the review would be of unchanged code' };
1827
+ }
1828
+ return { ok: true, files, dropped };
1829
+ }
1830
+ /**
1831
+ * Separate a mode-A review's findings into this feature's and the rest of the dirty worktree's.
1832
+ *
1833
+ * `codex review --uncommitted` reviews EVERY staged, unstaged and untracked change, and
1834
+ * `gradeFromReviewFindings` takes the worst severity over all of them. So an unrelated P0 sitting
1835
+ * dirty in the tree grades this feature D — indistinguishable from a D it earned.
1836
+ *
1837
+ * Not theoretical. In the saved live self-review fixture
1838
+ * (`test/fixtures/codex-review-selfreview-slices-2026-08-21.txt`), ONE of the six P1s is
1839
+ * `features/talk-ai-assistants/demo-site/site/dist/index.html:57-58` — unrelated work that merely
1840
+ * happened to be uncommitted at the time.
1841
+ *
1842
+ * Out-of-scope findings are NOT discarded: they are real, and dropping them silently would be its own
1843
+ * dishonesty. They are returned separately so the caller can report them as what they are.
1844
+ *
1845
+ * @param inScopePaths the MEASURED change set for this feature (repo-relative). Empty ⇒ `unscoped`,
1846
+ * and the caller must not attribute anything — inconclusive is never a pass, and it is not a
1847
+ * fail either.
1848
+ */
1849
+ export function partitionReviewFindings(findings, inScopePaths) {
1850
+ const list = Array.isArray(findings) ? findings : [];
1851
+ const paths = (inScopePaths ?? []).map((p) => String(p)).filter((p) => p !== '');
1852
+ if (paths.length === 0)
1853
+ return { inScope: [], outOfScope: [], unlocatable: list, unscoped: true };
1854
+ const belongs = (loc) => {
1855
+ const l = String(loc ?? '');
1856
+ // A location is `<path>:<line>` or `<abs-path>:<line>-<line>`; match on the path SEGMENT so an
1857
+ // absolute location still matches its repo-relative target, and a mere substring cannot.
1858
+ if (paths.some((p) => l === p || l.startsWith(p + ':') || l.includes('/' + p + ':') || l.endsWith('/' + p)))
1859
+ return true;
1860
+ // A location whose SHAPE we can parse (`<path>:<line>`) has already had its say above — a loose
1861
+ // substring must not override it, or `other/src/a.ts.bak:3` would be claimed by `src/a.ts`.
1862
+ // But a shape we CANNOT parse yet which names an in-scope file is ours (`src/a.ts line 10`):
1863
+ // reading "I could not parse this" as "it belongs to someone else" is how a finding about our own
1864
+ // file would quietly leave the grade.
1865
+ if (/[\w./-]+:\d/.test(l))
1866
+ return false;
1867
+ return paths.some((p) => l.includes(p));
1868
+ };
1869
+ const inScope = [];
1870
+ const outOfScope = [];
1871
+ const unlocatable = [];
1872
+ // A location we can PARSE and that names another file is out of scope. A location we cannot parse
1873
+ // proves nothing, so it goes to `unlocatable` and the caller must not rescore without it.
1874
+ // Purely about SHAPE now: does the location name some file at some line? If it does and it is not
1875
+ // ours, it is another file's. If it does not, we know nothing.
1876
+ const parseable = (loc) => /[\w./-]+:\d/.test(String(loc ?? ''));
1877
+ for (const f of list) {
1878
+ const loc = f && f.location ? f.location : '';
1879
+ if (belongs(loc))
1880
+ inScope.push(f);
1881
+ else if (parseable(loc))
1882
+ outOfScope.push(f);
1883
+ else
1884
+ unlocatable.push(f);
1885
+ }
1886
+ return { inScope, outOfScope, unlocatable, unscoped: false };
1887
+ }
1888
+ /**
1889
+ * Parse a `sha256sum` probe into a snapshot, tolerating the "no such file" lines it prints to stderr.
1890
+ *
1891
+ * Presence is not the question — CONTENT is. A file that was already dirty before Step 7 and then
1892
+ * edited by Step 7 must count as changed, and `git status` cannot tell those apart: it reports the
1893
+ * file as dirty in both cases. That is one half of why the old probe measured the wrong thing.
1894
+ */
1895
+ export function parseHashProbe(text, declared) {
1896
+ const out = new Map();
1897
+ for (const p of declared)
1898
+ out.set(String(p), null);
1899
+ for (const raw of String(text ?? '').split('\n')) {
1900
+ const line = raw.trim();
1901
+ if (line === '')
1902
+ continue;
1903
+ const m = /^([0-9a-f]{64})\s+(.+)$/.exec(line);
1904
+ if (m === null || m[1] === undefined || m[2] === undefined)
1905
+ continue;
1906
+ const path = m[2].trim().replace(/^\.\//, '');
1907
+ if (out.has(path))
1908
+ out.set(path, m[1]);
1909
+ }
1910
+ return out;
1911
+ }
1912
+ /**
1913
+ * Which declared targets actually changed between two snapshots.
1914
+ *
1915
+ * Returns `null` when either snapshot is missing — an unmeasured delta is NOT an empty delta, and the
1916
+ * callers treat null as "scope not established", which is never a pass.
1917
+ */
1918
+ export function changedFromHashes(before, after) {
1919
+ if (before === null || before === undefined || after === null || after === undefined)
1920
+ return null;
1921
+ const changed = [];
1922
+ for (const [path, afterHash] of after) {
1923
+ const beforeHash = before.has(path) ? before.get(path) ?? null : null;
1924
+ if (beforeHash !== (afterHash ?? null))
1925
+ changed.push(path);
1926
+ }
1927
+ return changed.sort();
1928
+ }
1929
+ /**
1930
+ * Build the probe that measures a change set, MATCHED to the review scope.
1931
+ *
1932
+ * The old code ran one `git status --porcelain` regardless of scope, which is wrong in both
1933
+ * directions (cross-family review of the 2026-08-21 wave, P1):
1934
+ * • `uncommitted` — a target already dirty BEFORE Step 7 is reported as this run's change, so mode B
1935
+ * could certify work the coder never touched. Hence the baseline pair rather than a single look.
1936
+ * • `commit` / `base` — real COMMITTED changes produce no status entry at all, so the set came back
1937
+ * empty and the scoped review was disabled on a run whose code demonstrably exists.
1938
+ *
1939
+ * @returns the shell command, or `null` when the scope needs a ref it was not given — the caller must
1940
+ * then treat the scope as unmeasured rather than substituting a different question.
1941
+ */
1942
+ export function changeSetProbeCmd(opts) {
1943
+ const paths = opts.paths.map((p) => String(p)).filter((p) => p !== '');
1944
+ if (paths.length === 0)
1945
+ return null;
1946
+ const quoted = paths.map(opts.quote).join(' ');
1947
+ const ref = String(opts.ref ?? '').trim();
1948
+ if (opts.scope === 'commit') {
1949
+ if (ref === '')
1950
+ return null;
1951
+ return 'git diff --name-only ' + opts.quote(ref) + '~1 ' + opts.quote(ref) + ' -- ' + quoted;
1952
+ }
1953
+ if (opts.scope === 'base') {
1954
+ if (ref === '')
1955
+ return null;
1956
+ return 'git diff --name-only ' + opts.quote(ref) + '...HEAD -- ' + quoted;
1957
+ }
1958
+ // uncommitted: hash the declared targets; the caller pairs this with a pre-code baseline.
1959
+ return 'sha256sum -- ' + quoted + ' 2>/dev/null || true';
1247
1960
  }
1248
1961
  //# sourceMappingURL=feature-adr-routing.js.map