@basou/core 0.35.0 → 0.36.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.
package/dist/index.js CHANGED
@@ -701,6 +701,32 @@ function codexRolloutToImportPayload(records, options) {
701
701
  continue;
702
702
  }
703
703
  if (readString4(record.type) !== "response_item") continue;
704
+ if (readString4(payload2.type) === "custom_tool_call") {
705
+ if (readString4(payload2.name) !== SCRIPT_TOOL_NAME) continue;
706
+ const scan = scanScript(readString4(payload2.input));
707
+ if (scan.commands.length === 0) continue;
708
+ const output2 = readCallId(payload2.call_id, outputsByCallId);
709
+ const durationMs = scan.toolCallCount === 1 ? parseWallTimeMs(output2) : 0;
710
+ const scriptTsMs = Date.parse(ts);
711
+ if (Number.isFinite(scriptTsMs)) engagementTsMs.push(scriptTsMs);
712
+ for (const command2 of scan.commands) {
713
+ derived.push(
714
+ // The per-command exit code is absent from this format (the script
715
+ // would have to print it), so it stays null — "unknown", not "0".
716
+ commandExecutedEvent2(
717
+ ts,
718
+ placeholderSessionId,
719
+ command2.cmd,
720
+ command2.workdir ?? workingDir ?? ".",
721
+ {
722
+ exitCode: null,
723
+ durationMs
724
+ }
725
+ )
726
+ );
727
+ }
728
+ continue;
729
+ }
704
730
  if (readString4(payload2.type) !== "function_call") continue;
705
731
  if (readString4(payload2.name) !== "exec_command") continue;
706
732
  const command = readExecCommand(payload2.arguments);
@@ -840,6 +866,250 @@ function readCallId(value, outputs) {
840
866
  const callId = readString4(value);
841
867
  return callId !== void 0 ? outputs.get(callId) : void 0;
842
868
  }
869
+ var SCRIPT_TOOL_NAME = "exec";
870
+ var TOOL_CALL_PREFIX = "tools.";
871
+ var EXEC_TOOL = "exec_command";
872
+ function scanScript(script) {
873
+ const scan = { commands: [], toolCallCount: 0 };
874
+ if (script === void 0) return scan;
875
+ try {
876
+ collectScriptCalls(script, scan);
877
+ } catch {
878
+ }
879
+ return scan;
880
+ }
881
+ function collectScriptCalls(script, scan) {
882
+ let i = 0;
883
+ while (i < script.length) {
884
+ const skipped = skipNonCode(script, i);
885
+ if (skipped !== i) {
886
+ if (skipped === -1) return;
887
+ i = skipped;
888
+ continue;
889
+ }
890
+ if (!script.startsWith(TOOL_CALL_PREFIX, i) || isIdentifierChar(script[i - 1])) {
891
+ i++;
892
+ continue;
893
+ }
894
+ const nameStart = i + TOOL_CALL_PREFIX.length;
895
+ const nameEnd = readIdentifierEnd(script, nameStart);
896
+ const open = skipWhitespaceAndComments(script, nameEnd);
897
+ if (nameEnd === nameStart || open === -1 || script[open] !== "(") {
898
+ i = nameStart;
899
+ continue;
900
+ }
901
+ scan.toolCallCount++;
902
+ const argsStart = skipWhitespaceAndComments(script, open + 1);
903
+ i = open + 1;
904
+ if (argsStart === -1 || script[argsStart] !== "{") continue;
905
+ const argsEnd = findObjectEnd(script, argsStart);
906
+ if (argsEnd === -1) return;
907
+ i = argsStart + 1;
908
+ if (script.slice(nameStart, nameEnd) !== EXEC_TOOL) continue;
909
+ const command = readExecArguments(script.slice(argsStart, argsEnd + 1));
910
+ if (command !== void 0) scan.commands.push(command);
911
+ }
912
+ }
913
+ function isIdentifierChar(ch) {
914
+ return ch !== void 0 && /[A-Za-z0-9_$]/.test(ch);
915
+ }
916
+ function skipNonCode(script, at) {
917
+ const ch = script[at];
918
+ if (ch === '"' || ch === "'" || ch === "`") return skipStringLiteral(script, at);
919
+ if (ch !== "/") return at;
920
+ const next = script[at + 1];
921
+ if (next === "/") {
922
+ const eol = script.indexOf("\n", at + 2);
923
+ return eol === -1 ? script.length : eol + 1;
924
+ }
925
+ if (next === "*") {
926
+ const end = script.indexOf("*/", at + 2);
927
+ return end === -1 ? -1 : end + 2;
928
+ }
929
+ return at;
930
+ }
931
+ function skipStringLiteral(script, at) {
932
+ const quote = script[at];
933
+ for (let i = at + 1; i < script.length; i++) {
934
+ const ch = script[i];
935
+ if (ch === "\\") {
936
+ i++;
937
+ continue;
938
+ }
939
+ if (ch === quote) return i + 1;
940
+ if (quote === "`" && ch === "$" && script[i + 1] === "{") {
941
+ const end = findObjectEnd(script, i + 1);
942
+ if (end === -1) return -1;
943
+ i = end;
944
+ }
945
+ }
946
+ return -1;
947
+ }
948
+ function skipWhitespaceAndComments(script, at) {
949
+ let i = at;
950
+ while (i < script.length) {
951
+ if (/\s/.test(script[i] ?? "")) {
952
+ i++;
953
+ continue;
954
+ }
955
+ if (script[i] !== "/") return i;
956
+ const skipped = skipNonCode(script, i);
957
+ if (skipped === -1) return -1;
958
+ if (skipped === i) return i;
959
+ i = skipped;
960
+ }
961
+ return i;
962
+ }
963
+ function readIdentifierEnd(script, start) {
964
+ let i = start;
965
+ while (i < script.length && /[A-Za-z0-9_$]/.test(script[i] ?? "")) i++;
966
+ return i;
967
+ }
968
+ function findObjectEnd(script, start) {
969
+ const closers = { "{": "}", "(": ")", "[": "]" };
970
+ const stack = [];
971
+ let i = start;
972
+ while (i < script.length) {
973
+ const skipped = skipNonCode(script, i);
974
+ if (skipped === -1) return -1;
975
+ if (skipped !== i) {
976
+ i = skipped;
977
+ continue;
978
+ }
979
+ const ch = script[i] ?? "";
980
+ const closer = closers[ch];
981
+ if (closer !== void 0) {
982
+ stack.push(closer);
983
+ } else if (ch === "}" || ch === ")" || ch === "]") {
984
+ if (stack.pop() !== ch) return -1;
985
+ if (stack.length === 0) return i;
986
+ }
987
+ i++;
988
+ }
989
+ return -1;
990
+ }
991
+ function readExecArguments(literal) {
992
+ const values = /* @__PURE__ */ new Map();
993
+ let i = 1;
994
+ while (i < literal.length) {
995
+ const at = skipWhitespaceAndComments(literal, i);
996
+ if (at === -1) return void 0;
997
+ const ch = literal[at];
998
+ if (ch === "}" || ch === void 0) break;
999
+ if (ch === ",") {
1000
+ i = at + 1;
1001
+ continue;
1002
+ }
1003
+ if (literal.startsWith("...", at)) return void 0;
1004
+ let key;
1005
+ let afterKey;
1006
+ if (ch === '"' || ch === "'") {
1007
+ const end = skipStringLiteral(literal, at);
1008
+ if (end === -1) return void 0;
1009
+ key = literal.slice(at + 1, end - 1);
1010
+ afterKey = end;
1011
+ } else {
1012
+ afterKey = readIdentifierEnd(literal, at);
1013
+ if (afterKey === at) return void 0;
1014
+ key = literal.slice(at, afterKey);
1015
+ }
1016
+ const colon = skipWhitespaceAndComments(literal, afterKey);
1017
+ if (colon === -1) return void 0;
1018
+ if (literal[colon] !== ":") {
1019
+ if (key === "cmd" || key === "workdir") return void 0;
1020
+ i = colon;
1021
+ const next2 = skipToPropertyEnd(literal, colon);
1022
+ if (next2 === -1) return void 0;
1023
+ i = next2;
1024
+ continue;
1025
+ }
1026
+ const valueAt = skipWhitespaceAndComments(literal, colon + 1);
1027
+ if (valueAt === -1) return void 0;
1028
+ const valueChar = literal[valueAt];
1029
+ let value;
1030
+ let afterValue = valueAt;
1031
+ if (valueChar === '"' || valueChar === "'" || valueChar === "`") {
1032
+ const end = skipStringLiteral(literal, valueAt);
1033
+ if (end === -1) return void 0;
1034
+ const decoded = unescapeScriptString(literal.slice(valueAt, end));
1035
+ afterValue = end;
1036
+ const after = skipWhitespaceAndComments(literal, end);
1037
+ if (after === -1) return void 0;
1038
+ const terminator = literal[after];
1039
+ if (terminator === "," || terminator === "}") {
1040
+ value = decoded.length > 0 ? decoded : void 0;
1041
+ afterValue = after;
1042
+ } else if (key === "cmd" || key === "workdir") {
1043
+ return void 0;
1044
+ }
1045
+ } else if (key === "cmd" || key === "workdir") {
1046
+ return void 0;
1047
+ }
1048
+ if (key === "cmd" || key === "workdir") {
1049
+ if (values.has(key)) return void 0;
1050
+ values.set(key, value);
1051
+ }
1052
+ const next = skipToPropertyEnd(literal, afterValue);
1053
+ if (next === -1) return void 0;
1054
+ i = next;
1055
+ }
1056
+ const cmd = values.get("cmd");
1057
+ if (cmd === void 0) return void 0;
1058
+ return { cmd, workdir: values.get("workdir") };
1059
+ }
1060
+ function skipToPropertyEnd(literal, at) {
1061
+ let i = at;
1062
+ while (i < literal.length) {
1063
+ const skipped = skipNonCode(literal, i);
1064
+ if (skipped === -1) return -1;
1065
+ if (skipped !== i) {
1066
+ i = skipped;
1067
+ continue;
1068
+ }
1069
+ const ch = literal[i];
1070
+ if (ch === "," || ch === "}") return i;
1071
+ if (ch === "{" || ch === "(" || ch === "[") {
1072
+ const end = findObjectEnd(literal, i);
1073
+ if (end === -1) return -1;
1074
+ i = end + 1;
1075
+ continue;
1076
+ }
1077
+ i++;
1078
+ }
1079
+ return -1;
1080
+ }
1081
+ var SCRIPT_STRING_ESCAPES = {
1082
+ n: "\n",
1083
+ r: "\r",
1084
+ t: " ",
1085
+ b: "\b",
1086
+ f: "\f",
1087
+ v: "\v",
1088
+ "0": "\0"
1089
+ };
1090
+ function unescapeScriptString(quoted) {
1091
+ return quoted.slice(1, -1).replace(
1092
+ /\\(u\{[0-9a-fA-F]{1,6}\}|u[0-9a-fA-F]{4}|x[0-9a-fA-F]{2}|\r\n|[\s\S])/g,
1093
+ (match, sequence) => {
1094
+ if (sequence.length <= 2) {
1095
+ if (sequence === "\n" || sequence === "\r" || sequence === "\r\n") return "";
1096
+ if (sequence === "u" || sequence === "x") return match;
1097
+ return SCRIPT_STRING_ESCAPES[sequence] ?? sequence;
1098
+ }
1099
+ const hex = sequence.startsWith("u{") ? sequence.slice(2, -1) : sequence.slice(1);
1100
+ return codePointOrLiteral(hex, match);
1101
+ }
1102
+ );
1103
+ }
1104
+ function codePointOrLiteral(hex, literal) {
1105
+ const code = Number.parseInt(hex, 16);
1106
+ if (!Number.isInteger(code) || code < 0 || code > 1114111) return literal;
1107
+ try {
1108
+ return String.fromCodePoint(code);
1109
+ } catch {
1110
+ return literal;
1111
+ }
1112
+ }
843
1113
  function turnIntervalFromComplete(endTs, payload, startMsByTurnId) {
844
1114
  const endMs = Date.parse(endTs);
845
1115
  if (!Number.isFinite(endMs)) return void 0;
@@ -871,7 +1141,7 @@ function parseExitCode(output) {
871
1141
  }
872
1142
  function parseWallTimeMs(output) {
873
1143
  if (output === void 0) return 0;
874
- const match = output.match(/Wall time:\s*([\d.]+)\s*seconds/);
1144
+ const match = output.match(/Wall time:?\s*([\d.]+)\s*seconds/);
875
1145
  if (match?.[1] === void 0) return 0;
876
1146
  const seconds = Number.parseFloat(match[1]);
877
1147
  return Number.isFinite(seconds) ? Math.round(seconds * 1e3) : 0;
@@ -882,13 +1152,20 @@ function indexOutputs(records) {
882
1152
  if (readString4(record.type) !== "response_item") continue;
883
1153
  const payload = isObject4(record.payload) ? record.payload : void 0;
884
1154
  if (payload === void 0) continue;
885
- if (readString4(payload.type) !== "function_call_output") continue;
1155
+ const payloadType = readString4(payload.type);
1156
+ const output = payloadType === "function_call_output" ? readString4(payload.output) : payloadType === "custom_tool_call_output" ? readOutputParts(payload.output) : void 0;
886
1157
  const callId = readString4(payload.call_id);
887
- const output = readString4(payload.output);
888
1158
  if (callId !== void 0 && output !== void 0) byId.set(callId, output);
889
1159
  }
890
1160
  return byId;
891
1161
  }
1162
+ function readOutputParts(value) {
1163
+ const asString = readString4(value);
1164
+ if (asString !== void 0) return asString;
1165
+ if (!Array.isArray(value)) return void 0;
1166
+ const texts = value.map((part) => isObject4(part) ? readString4(part.text) : void 0).filter((text) => text !== void 0);
1167
+ return texts.length > 0 ? texts.join("\n") : void 0;
1168
+ }
892
1169
 
893
1170
  // src/approval/approval-store.ts
894
1171
  import { readdir } from "fs/promises";
@@ -1257,6 +1534,9 @@ var ReviewRecordedEventSchema = BaseEventSchema.extend({
1257
1534
  type: z3.literal("review_recorded"),
1258
1535
  reviewer: z3.string().min(1),
1259
1536
  target: z3.string().min(1),
1537
+ repos: z3.array(z3.string().min(1)).optional(),
1538
+ repos_resolved: z3.array(z3.string().min(1)).optional(),
1539
+ commits: z3.array(z3.string().min(1)).optional(),
1260
1540
  verdict: z3.enum(["pass", "needs-attention", "fail"]).optional(),
1261
1541
  findings: z3.array(ReviewFindingSchema).optional(),
1262
1542
  blocked: z3.array(ReviewBlockedSchema).optional()
@@ -7262,6 +7542,29 @@ function normalizeRepoPath(p) {
7262
7542
  if (/-workspace$/.test(seg) || seg.includes("$")) return null;
7263
7543
  return s;
7264
7544
  }
7545
+ function recordRepoKey(p) {
7546
+ return resolveRepoRoot(p);
7547
+ }
7548
+ function resolveRepoRoot(p) {
7549
+ return classifyRepoPath(p).resolved;
7550
+ }
7551
+ function classifyRepoPath(p) {
7552
+ let s = stripQuotes((p ?? "").trim()).replace(/\/+$/, "");
7553
+ if (s.startsWith("~/")) s = homedir2() + s.slice(1);
7554
+ if (s.length === 0 || !isAbsolute2(s)) return { resolved: null, problem: "relative" };
7555
+ const real = resolveRealpath(s);
7556
+ if (real === null) return { resolved: null, problem: "absent" };
7557
+ if (!isRepoRoot(real)) return { resolved: null, problem: "not_a_repo_root" };
7558
+ return { resolved: real, problem: null };
7559
+ }
7560
+ function findUnbindableRepos(repos) {
7561
+ const out = [];
7562
+ repos.forEach((repo, index) => {
7563
+ const { problem } = classifyRepoPath(repo);
7564
+ if (problem !== null) out.push({ repo, index, problem });
7565
+ });
7566
+ return out;
7567
+ }
7265
7568
  function normalizeRepoKey(p) {
7266
7569
  const full = normalizeRepoPath(p);
7267
7570
  return full === null ? null : basename3(full);
@@ -7283,10 +7586,16 @@ function inspectCommand(args) {
7283
7586
  }
7284
7587
  return { files: [...files], examinedDiff };
7285
7588
  }
7286
- function commandRepo(args, cwd) {
7589
+ function commandRepoWithProvenance(args, cwd) {
7590
+ const raw = commandRepoPath(args, cwd);
7591
+ return { key: normalizeRepoPath(raw), resolved: resolveRepoRoot(raw) !== null };
7592
+ }
7593
+ function commandRepoPath(args, cwd) {
7287
7594
  const cd = args.join(" ").match(/\bcd\s+("[^"]+"|'[^']+'|[^\s&]+)\s*&&/);
7288
- if (cd) return normalizeRepoPath(cd[1]);
7289
- return normalizeRepoPath(cwd);
7595
+ return cd?.[1] ?? cwd;
7596
+ }
7597
+ function commandRepo(args, cwd) {
7598
+ return normalizeRepoPath(commandRepoPath(args, cwd));
7290
7599
  }
7291
7600
  function commandFailed(exitCode) {
7292
7601
  return exitCode !== null && exitCode !== 0;
@@ -7308,6 +7617,9 @@ async function findReviewGaps(input) {
7308
7617
  if (input.onWarning !== void 0) loadOpts.onWarning = input.onWarning;
7309
7618
  const entries = await loadSessionEntries(input.paths, loadOpts);
7310
7619
  const reviews = [];
7620
+ const selfReports = [];
7621
+ let noRepos = 0;
7622
+ let unresolvableRepo = 0;
7311
7623
  const workUnits = /* @__PURE__ */ new Map();
7312
7624
  const unknownCommits = /* @__PURE__ */ new Map();
7313
7625
  for (const entry of entries) {
@@ -7319,6 +7631,28 @@ async function findReviewGaps(input) {
7319
7631
  for await (const ev of replayEvents(sessionDir, {
7320
7632
  onWarning: (w) => input.onWarning?.(w, entry.sessionId)
7321
7633
  })) {
7634
+ if (ev.type === "review_recorded") {
7635
+ const recordedAt = Date.parse(ev.occurred_at);
7636
+ const named = ev.repos_resolved !== void 0 && ev.repos_resolved.length > 0 ? ev.repos_resolved : ev.repos ?? [];
7637
+ const keys = named.map((r) => recordRepoKey(r));
7638
+ const repos2 = new Set(keys.filter((r) => r !== null));
7639
+ if (keys.some((k) => k === null) || repos2.size === 0 || Number.isNaN(recordedAt)) {
7640
+ if (named.length === 0) noRepos++;
7641
+ else unresolvableRepo++;
7642
+ continue;
7643
+ }
7644
+ selfReports.push({
7645
+ sessionId: entry.sessionId,
7646
+ eventId: ev.id,
7647
+ reviewer: ev.reviewer,
7648
+ target: ev.target,
7649
+ recordedAt: ev.occurred_at,
7650
+ commits: ev.commits ?? [],
7651
+ at: recordedAt,
7652
+ repos: repos2
7653
+ });
7654
+ continue;
7655
+ }
7322
7656
  if (ev.type !== "command_executed") continue;
7323
7657
  if (commandFailed(ev.exit_code)) continue;
7324
7658
  const at = Date.parse(ev.occurred_at);
@@ -7334,7 +7668,7 @@ async function findReviewGaps(input) {
7334
7668
  continue;
7335
7669
  }
7336
7670
  if (!ev.args.join(" ").includes("git commit")) continue;
7337
- const repo = commandRepo(ev.args, ev.cwd);
7671
+ const { key: repo, resolved: keyResolved } = commandRepoWithProvenance(ev.args, ev.cwd);
7338
7672
  if (repo === null || Number.isNaN(at)) {
7339
7673
  const list2 = unknownCommits.get(entry.sessionId) ?? [];
7340
7674
  list2.push(Number.isNaN(at) ? null : at);
@@ -7343,7 +7677,7 @@ async function findReviewGaps(input) {
7343
7677
  }
7344
7678
  const byRepo = workUnits.get(entry.sessionId) ?? /* @__PURE__ */ new Map();
7345
7679
  const list = byRepo.get(repo) ?? [];
7346
- list.push({ repo, at, files: commitFiles(ev.args) });
7680
+ list.push({ repo, at, files: commitFiles(ev.args), keyResolved });
7347
7681
  byRepo.set(repo, list);
7348
7682
  workUnits.set(entry.sessionId, byRepo);
7349
7683
  }
@@ -7358,19 +7692,33 @@ async function findReviewGaps(input) {
7358
7692
  const windowMs = windowHours * 3600 * 1e3;
7359
7693
  const units = [];
7360
7694
  let newestCommit = null;
7695
+ const attachedSelfReports = /* @__PURE__ */ new Set();
7696
+ const refusedForUnit = /* @__PURE__ */ new Set();
7697
+ let refusedPairings = 0;
7361
7698
  for (const [sessionId, byRepo] of workUnits) {
7362
7699
  for (const [repoPath, commits] of byRepo) {
7363
7700
  const label = basename3(repoPath);
7364
- if (scope !== null && !scope.includes(label)) continue;
7365
7701
  const times = commits.map((c) => c.at).sort((a, b) => a - b);
7366
7702
  const first = times[0] ?? null;
7367
7703
  const last = times[times.length - 1] ?? null;
7704
+ const earliest = first ?? last ?? 0;
7705
+ const latest = last ?? first ?? 0;
7706
+ const unitRepoIsHere = commits.every((c) => c.keyResolved);
7707
+ const inWindow = selfReports.filter(
7708
+ (r) => r.repos.has(repoPath) && r.at >= earliest - windowMs && r.at <= latest + windowMs
7709
+ );
7710
+ const selfBound = unitRepoIsHere ? inWindow : [];
7711
+ if (!unitRepoIsHere) {
7712
+ refusedPairings += inWindow.length;
7713
+ for (const r of inWindow) refusedForUnit.add(r.eventId);
7714
+ }
7715
+ for (const r of selfBound) attachedSelfReports.add(r.eventId);
7716
+ if (scope !== null && !scope.includes(label)) continue;
7368
7717
  if (last !== null) newestCommit = newestCommit === null ? last : Math.max(newestCommit, last);
7369
7718
  const changedFiles = new Set(commits.flatMap((c) => c.files));
7370
- const before = first ?? last ?? 0;
7371
7719
  const nearby = reviews.filter((r) => {
7372
7720
  if (!r.repos.has(repoPath) || r.endedAt === null) return false;
7373
- return r.endedAt <= before && r.endedAt >= before - windowMs;
7721
+ return r.endedAt <= earliest && r.endedAt >= earliest - windowMs;
7374
7722
  });
7375
7723
  const bound = nearby.filter((r) => {
7376
7724
  const touched = r.repos.get(repoPath);
@@ -7388,6 +7736,9 @@ async function findReviewGaps(input) {
7388
7736
  firstCommitAt: first === null ? null : new Date(first).toISOString(),
7389
7737
  lastCommitAt: last === null ? null : new Date(last).toISOString(),
7390
7738
  verdict,
7739
+ // Attached after the verdict is computed, and deliberately not an input
7740
+ // to it: a record must never move a unit out of `gaps`.
7741
+ selfReports: selfBound.map((r) => toSelfReportedReview(r, r.at > earliest)),
7391
7742
  reviews: cited.map((r) => ({
7392
7743
  sessionId: r.sessionId,
7393
7744
  examinedDiff: r.repos.get(repoPath)?.examinedDiff ?? false,
@@ -7397,34 +7748,41 @@ async function findReviewGaps(input) {
7397
7748
  });
7398
7749
  }
7399
7750
  }
7400
- if (scope === null) {
7401
- for (const [sessionId, times] of unknownCommits) {
7402
- const valid = times.filter((t) => t !== null).sort((a, b) => a - b);
7403
- const first = valid[0] ?? null;
7404
- const last = valid[valid.length - 1] ?? null;
7405
- if (last !== null) newestCommit = newestCommit === null ? last : Math.max(newestCommit, last);
7406
- units.push({
7407
- repo: "(unknown)",
7408
- sessionId,
7409
- commitCount: times.length,
7410
- firstCommitAt: first === null ? null : new Date(first).toISOString(),
7411
- lastCommitAt: last === null ? null : new Date(last).toISOString(),
7412
- verdict: "unknown",
7413
- reviews: []
7414
- });
7751
+ for (const [sessionId, times] of unknownCommits) {
7752
+ const valid = times.filter((t) => t !== null).sort((a, b) => a - b);
7753
+ const first = valid[0] ?? null;
7754
+ const last = valid[valid.length - 1] ?? null;
7755
+ if (last !== null && scope === null) {
7756
+ newestCommit = newestCommit === null ? last : Math.max(newestCommit, last);
7415
7757
  }
7758
+ units.push({
7759
+ repo: "(unknown)",
7760
+ sessionId,
7761
+ commitCount: times.length,
7762
+ firstCommitAt: first === null ? null : new Date(first).toISOString(),
7763
+ lastCommitAt: last === null ? null : new Date(last).toISOString(),
7764
+ verdict: "unknown",
7765
+ reviews: [],
7766
+ // No repo key, so nothing a record's `repos` could bind to.
7767
+ selfReports: []
7768
+ });
7416
7769
  }
7770
+ const missed = selfReports.filter((r) => !attachedSelfReports.has(r.eventId));
7771
+ const unverifiableUnit = missed.filter((r) => refusedForUnit.has(r.eventId)).length;
7772
+ const noMatchingUnit = missed.length - unverifiableUnit;
7417
7773
  const recentFirst = (a, b) => (Date.parse(b.lastCommitAt ?? "") || 0) - (Date.parse(a.lastCommitAt ?? "") || 0);
7418
- const repoKeys = [...new Set(units.map((u) => u.repo))].sort();
7774
+ const talliedUnits = scope === null ? units : units.filter((u) => u.verdict !== "unknown");
7775
+ const repoKeys = [...new Set(talliedUnits.map((u) => u.repo))].sort();
7419
7776
  const repos = repoKeys.map((repo) => {
7420
- const us = units.filter((u) => u.repo === repo);
7777
+ const us = talliedUnits.filter((u) => u.repo === repo);
7421
7778
  return {
7422
7779
  repo,
7423
7780
  units: us.length,
7424
7781
  omissionUnits: us.filter((u) => u.verdict === "omission").length,
7425
7782
  nearUnboundUnits: us.filter((u) => u.verdict === "near_unbound").length,
7426
7783
  candidateUnits: us.filter((u) => u.verdict === "candidate").length,
7427
- unknownUnits: us.filter((u) => u.verdict === "unknown").length
7784
+ unknownUnits: us.filter((u) => u.verdict === "unknown").length,
7785
+ selfReportedGapUnits: us.filter((u) => isGap(u) && u.selfReports.length > 0).length
7428
7786
  };
7429
7787
  });
7430
7788
  return {
@@ -7432,12 +7790,34 @@ async function findReviewGaps(input) {
7432
7790
  windowHours,
7433
7791
  scope,
7434
7792
  repos,
7435
- gaps: units.filter((u) => u.verdict === "omission" || u.verdict === "near_unbound").sort(recentFirst),
7793
+ gaps: units.filter(isGap).sort(recentFirst),
7436
7794
  candidates: units.filter((u) => u.verdict === "candidate").sort(recentFirst),
7437
7795
  unknowns: units.filter((u) => u.verdict === "unknown").sort(recentFirst),
7796
+ unattachedSelfReports: {
7797
+ total: noRepos + unresolvableRepo + noMatchingUnit + unverifiableUnit,
7798
+ noRepos,
7799
+ unresolvableRepo,
7800
+ noMatchingUnit,
7801
+ unverifiableUnit
7802
+ },
7803
+ refusedPairings,
7438
7804
  newestCommitAt: newestCommit === null ? null : new Date(newestCommit).toISOString()
7439
7805
  };
7440
7806
  }
7807
+ function isGap(u) {
7808
+ return u.verdict === "omission" || u.verdict === "near_unbound";
7809
+ }
7810
+ function toSelfReportedReview(r, recordedAfterCommit) {
7811
+ return {
7812
+ sessionId: r.sessionId,
7813
+ eventId: r.eventId,
7814
+ reviewer: r.reviewer,
7815
+ target: r.target,
7816
+ recordedAt: r.recordedAt,
7817
+ commits: r.commits,
7818
+ recordedAfterCommit
7819
+ };
7820
+ }
7441
7821
 
7442
7822
  // src/review/review-record.ts
7443
7823
  var VALID_VERDICTS = /* @__PURE__ */ new Set(["pass", "needs-attention", "fail"]);
@@ -7446,6 +7826,8 @@ var VALID_BLOCK_REASONS = /* @__PURE__ */ new Set(["spec-deviation", "design-rev
7446
7826
  var ALLOWED_KEYS = /* @__PURE__ */ new Set([
7447
7827
  "reviewer",
7448
7828
  "target",
7829
+ "repos",
7830
+ "commits",
7449
7831
  "verdict",
7450
7832
  "findings",
7451
7833
  "blocked"
@@ -7476,13 +7858,19 @@ function parseReviewRecordInput(raw) {
7476
7858
  for (const key of Object.keys(obj)) {
7477
7859
  if (!ALLOWED_KEYS.has(key)) {
7478
7860
  throw new Error(
7479
- `Unknown field '${key}'. Allowed: reviewer, target, verdict, findings, blocked.`
7861
+ `Unknown field '${key}'. Allowed: reviewer, target, repos, commits, verdict, findings, blocked.`
7480
7862
  );
7481
7863
  }
7482
7864
  }
7483
7865
  const reviewer = requireNonEmptyString(obj.reviewer, "reviewer");
7484
7866
  const target = requireNonEmptyString(obj.target, "target");
7485
7867
  const out = { reviewer, target };
7868
+ if (obj.repos !== void 0) {
7869
+ out.repos = parseStringArray(obj.repos, "repos");
7870
+ }
7871
+ if (obj.commits !== void 0) {
7872
+ out.commits = parseStringArray(obj.commits, "commits");
7873
+ }
7486
7874
  if (obj.verdict !== void 0) {
7487
7875
  if (typeof obj.verdict !== "string" || !VALID_VERDICTS.has(obj.verdict)) {
7488
7876
  throw new Error(`verdict must be one of pass, needs-attention, fail, got '${obj.verdict}'.`);
@@ -7497,6 +7885,12 @@ function parseReviewRecordInput(raw) {
7497
7885
  }
7498
7886
  return out;
7499
7887
  }
7888
+ function parseStringArray(value, field) {
7889
+ if (!Array.isArray(value)) {
7890
+ throw new Error(`${field} must be an array of strings.`);
7891
+ }
7892
+ return value.map((item, i) => requireNonEmptyString(item, `${field}[${i}]`));
7893
+ }
7500
7894
  function parseFindings(value) {
7501
7895
  if (!Array.isArray(value)) {
7502
7896
  throw new Error("findings must be an array of objects.");
@@ -7579,6 +7973,9 @@ function buildReviewRecordedEvent(input) {
7579
7973
  type: "review_recorded",
7580
7974
  reviewer: review.reviewer,
7581
7975
  target: review.target,
7976
+ ...review.repos !== void 0 ? { repos: review.repos } : {},
7977
+ ...input.reposResolved !== void 0 && input.reposResolved.length > 0 ? { repos_resolved: input.reposResolved } : {},
7978
+ ...review.commits !== void 0 ? { commits: review.commits } : {},
7582
7979
  ...review.verdict !== void 0 ? { verdict: review.verdict } : {},
7583
7980
  ...review.findings !== void 0 ? { findings: review.findings } : {},
7584
7981
  ...review.blocked !== void 0 ? { blocked: review.blocked } : {}
@@ -8589,6 +8986,7 @@ export {
8589
8986
  findBasouStopHookCommand,
8590
8987
  findErrorCode,
8591
8988
  findReviewGaps,
8989
+ findUnbindableRepos,
8592
8990
  formatDurationMs,
8593
8991
  genesisHash,
8594
8992
  getDiff,
@@ -8652,6 +9050,7 @@ export {
8652
9050
  resolveClaudeCodeCommand,
8653
9051
  resolveCodexCommand,
8654
9052
  resolveRepoContentLanguage,
9053
+ resolveRepoRoot,
8655
9054
  resolveRepositoryRoot,
8656
9055
  resolveSessionId,
8657
9056
  resolveTaskId,