trantor 0.18.81 → 0.18.85

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.
@@ -1,3 +1,4 @@
1
+ import { checkoutKey, collisionIdentity, levelFor } from "../../lib/overseer.mjs";
1
2
  /* oxlint-disable anti-slop/no-runtime-typeof -- SAFETY: These checks preserve the established request and provider-response compatibility boundary during a route-only extraction. */
2
3
  import { readFileSync } from "node:fs";
3
4
  import { homedir } from "node:os";
@@ -7,6 +8,19 @@ import { join } from "node:path";
7
8
  // consume a claim's first-touch (or vice versa), and /claims must never list reads. #7972.
8
9
  const fileReads = new Map();
9
10
 
11
+ function remoteEvidence(value, file) {
12
+ if (!Array.isArray(value)) return [];
13
+ return value.slice(0, 25).filter(row =>
14
+ row && typeof row.repo === "string" && /^[\w.-]+\/[\w.-]+$/.test(row.repo) && row.repo.length <= 200 &&
15
+ Number.isSafeInteger(row.number) && row.number > 0 &&
16
+ typeof row.author === "string" && row.author.length > 0 && row.author.length <= 120 &&
17
+ typeof row.url === "string" && row.url.length <= 2000 &&
18
+ (row.url === "" || /^https:\/\/[^\s]+$/.test(row.url)) &&
19
+ Array.isArray(row.files) && row.files.includes(file)
20
+ ).map(row => ({ repo: row.repo.toLowerCase(), number: row.number, author: row.author,
21
+ url: row.url, files: [file] }));
22
+ }
23
+
10
24
  export async function routeAdmin({ req, res, q, P, auth, ctx }) {
11
25
  const {
12
26
  state, body, json, crossProjectGuard, touch, canon, filterReadable,
@@ -23,6 +37,13 @@ export async function routeAdmin({ req, res, q, P, auth, ctx }) {
23
37
  // WHO is this, really: the LLM brand + the exact model currently loaded. In-memory like the
24
38
  // rest of presence — the next heartbeat re-supplies it after a restart.
25
39
  const pr = state.peers[b.session];
40
+ if (pr && b.gitRoot) {
41
+ pr.gitRoot = String(b.gitRoot).slice(0, 2000);
42
+ state.overseerState ??= {};
43
+ state.overseerState.roots ??= {};
44
+ state.overseerState.roots[b.session] = pr.gitRoot;
45
+ markDirty();
46
+ }
26
47
  // #6148: WHAT a session is rides its peer row (kind "genesis" = the CLI's brief-poster,
27
48
  // "agent" = a crew seat) — /peers hands it to the app so the seat strip can tell them apart.
28
49
  if (pr) { if (b.model) pr.model = String(b.model).slice(0, 80); if (b.llm) pr.llm = String(b.llm).slice(0, 40); if (b.kind) pr.kind = String(b.kind).slice(0, 40); }
@@ -194,7 +215,7 @@ export async function routeAdmin({ req, res, q, P, auth, ctx }) {
194
215
  const proj = canon(String(q.project || "").slice(0, 80));
195
216
  if (!proj) return json(res, 400, { error: "project required" });
196
217
  const pol = overseerPolicy();
197
- const level = overseer.engine?.levelFor ? overseer.engine.levelFor(proj, pol.autonomy) : (pol.autonomy[proj] ?? pol.autonomy["*"] ?? 1);
218
+ const level = overseer.engine?.levelFor ? overseer.engine.levelFor(proj, pol.autonomy) : (pol.autonomy[proj] ?? pol.autonomy["*"] ?? 2);
198
219
  const links = pol.links.filter(l => (l.projects || []).includes(proj));
199
220
  const linked = new Set(links.flatMap(l => l.projects).filter(x => x !== proj));
200
221
  const cutoff = now() - ONLINE_MS;
@@ -207,7 +228,7 @@ export async function routeAdmin({ req, res, q, P, auth, ctx }) {
207
228
  let warnings = [];
208
229
  try {
209
230
  warnings = (overseer.engine?.detectCollisions ? overseer.engine.detectCollisions(overseerInputs()) : [])
210
- .filter(c => c.project === proj || linked.has(c.project));
231
+ .filter(c => c.project === proj || c.projects?.includes(proj) || linked.has(c.project));
211
232
  // #5760: a declared crew is the NORMAL state of a project, not a collision — the tick
212
233
  // loop drops crew-only sets before they ever become episodes, and this live view must
213
234
  // agree with it: the SessionStart hook narrates exactly these lines, so a crew-only leak
@@ -215,14 +236,14 @@ export async function routeAdmin({ req, res, q, P, auth, ctx }) {
215
236
  warnings = warnings.filter(c => !(c.kind === "same-project-sessions" && overseer.sameProject?.sameProjectDecision &&
216
237
  overseer.sameProject.sameProjectDecision({
217
238
  current: c.sessions,
218
- declaredCrew: declaredCrewFor(c.project),
239
+ declaredCrew: declaredCrewFor(c.project, c.gitRoot),
219
240
  now: now(),
220
241
  }).reason === "crew-only"));
221
242
  // The record line reports DURATION ("standing for 6h"), never a count of warnings. Episode
222
243
  // identity is project+kind+files, exactly as the tick loop keys it, so this view says the
223
244
  // same thing the warning did  for every kind, not just same-project (#7029).
224
245
  for (const c of warnings) {
225
- const ep = overseer.active.get(`${c.project} ${c.kind}${c.kind === "same-project-sessions" ? "" : ` ${(c.files || []).join(",")}`}`);
246
+ const ep = overseer.active.get(c.kind === "same-project-sessions" ? `${checkoutKey(c)} ${c.kind}` : collisionIdentity(c));
226
247
  if (!ep) continue;
227
248
  c.since = ep.since;
228
249
  const label = c.kind === "same-project-sessions" ? "same-project" : "standing";
@@ -269,25 +290,50 @@ export async function routeAdmin({ req, res, q, P, auth, ctx }) {
269
290
  markDirty();
270
291
  return json(res, 200, { ok: true });
271
292
  }
272
- if (req.method === "POST" && P === "/claim") {
293
+ if (req.method === "GET" && P === "/holds") {
294
+ const rows = overseer.listHolds().filter(h => (!q.project || h.project === canon(q.project)) && (!q.status || h.status === q.status));
295
+ return json(res, 200, { holds: filterReadable(auth, rows, h => h.project) });
296
+ }
297
+ if (req.method === "POST" && P === "/hold/decide") {
298
+ const b = await body(req);
299
+ const hold = overseer.listHolds().find(h => h.id === Number(b.id));
300
+ if (!hold) return json(res, 404, { error: "hold expired or not found" });
301
+ if (AUTH_MODE !== "off" && !scopeAllows(auth?.identity, hold.project, "owner")) return json(res, 403, { error: "operator permission required" });
302
+ if (b.status !== "go" && b.status !== "nogo") return json(res, 400, { error: "status must be go or nogo" });
303
+ const decided = overseer.decideHold(hold.id, b.status, auth?.identity?.name || "operator");
304
+ return decided ? json(res, 200, { ok: true, hold: decided }) : json(res, 409, { error: "hold already decided" });
305
+ }
306
+ if (req.method === "POST" && (P === "/claim" || P === "/hold/check")) {
273
307
  const b = await body(req);
274
308
  const proj = canon(String(b.project || "").slice(0, 80));
275
309
  const file = String(b.file || "").slice(0, 400);
276
310
  const session = String(b.session || "").slice(0, 120);
277
311
  if (!proj || !file || !session) return json(res, 400, { error: "project, file and session required" });
312
+ if (auth?.identity && session !== auth.identity.name) return json(res, 403, { error: "session must match signer" });
313
+ const gitRoot = String(b.gitRoot || state.peers[session]?.gitRoot || state.overseerState?.roots?.[session] || "").slice(0, 2000);
278
314
  pruneClaims();
279
- const key = `${proj}${file}${session}`;
315
+ const key = `${checkoutKey({ project: proj, gitRoot })}\u0000${file}\u0000${session}`;
280
316
  const mine = fileClaims.get(key);
281
317
  const conflicts = [...fileClaims.values()]
282
- .filter(c => c.project === proj && c.file === file && c.session !== session)
318
+ .filter(c => checkoutKey(c) === checkoutKey({ project: proj, gitRoot }) && c.file === file && c.session !== session)
283
319
  .map(c => ({ session: c.session, ts: c.ts, agoSec: Math.round((now() - c.ts) / 1000) }));
284
- fileClaims.set(key, { project: proj, file, session, ts: now() });
320
+ const remote = P === "/hold/check" && b.remote === undefined
321
+ ? (mine?.remote || []) : remoteEvidence(b.remote, file);
322
+ fileClaims.set(key, { project: proj, file, session, gitRoot, ts: now(), remote });
285
323
  touch(session, undefined, undefined, undefined, auth);
286
- // Feed events, throttled by design: the FIRST touch inside a TTL window says "claimed";
287
- // a collision says so every time — that is the one worth seeing on the FEED.
324
+ if (gitRoot) {
325
+ state.peers[session].gitRoot = gitRoot;
326
+ state.overseerState ??= {};
327
+ state.overseerState.roots ??= {};
328
+ state.overseerState.roots[session] = gitRoot;
329
+ }
330
+ // Repeated checks refresh the claim without repeating its conflict event.
288
331
  if (!mine) appendEvent("file.claim", proj, session, { file });
289
- if (conflicts.length) appendEvent("file.conflict", proj, session, { file, with: conflicts.map(c => c.session) });
290
- return json(res, 200, { ok: true, conflicts, ttlMs: CLAIM_TTL_MS });
332
+ if (!mine && conflicts.length) appendEvent("file.conflict", proj, session, { file, with: conflicts.map(c => c.session) });
333
+ overseer.overseerTick();
334
+ const hold = overseer.holdFor(proj, file, session, gitRoot);
335
+ const level = levelFor(proj, overseer.overseerPolicy().autonomy);
336
+ return json(res, 200, { ok: true, conflicts: level >= 2 ? conflicts : [], level, hold, ttlMs: CLAIM_TTL_MS });
291
337
  }
292
338
  if (req.method === "GET" && P === "/claims") {
293
339
  pruneClaims();
package/hub/store.mjs CHANGED
@@ -6,7 +6,7 @@ import { DEFAULT_ORG } from "../lib/store-contract.mjs";
6
6
 
7
7
  export async function createStoreRuntime({ STORE_KIND, PG_URL, ORG_ID, DATA }) {
8
8
  function emptyState() {
9
- return { messages: [], peers: {}, seq: 0, tasks: [], taskSeq: 0, projectMeta: {}, lessons: [], events: [], cardEventsBackfilled: false, aliases: {}, phaseMeta: {}, verifyGates: [], verifyGateSeq: 0, proposals: [], proposalSeq: 0, balances: { ts: 0, by: "", entries: [] }, subagentCostReset: false, handoffLog: [], identities: {}, inviteTokens: {}, focus: {}, orgPolicy: {}, instances: {}, dutySession: "", contractReap: {}, eventSeq: 0 };
9
+ return { messages: [], peers: {}, seq: 0, tasks: [], taskSeq: 0, projectMeta: {}, lessons: [], events: [], cardEventsBackfilled: false, aliases: {}, phaseMeta: {}, verifyGates: [], verifyGateSeq: 0, proposals: [], proposalSeq: 0, balances: { ts: 0, by: "", entries: [] }, subagentCostReset: false, handoffLog: [], identities: {}, inviteTokens: {}, focus: {}, orgPolicy: {}, overseerState: {}, instances: {}, dutySession: "", contractReap: {}, eventSeq: 0 };
10
10
  }
11
11
 
12
12
  const CARD_LOG_MAX = 40;
@@ -110,6 +110,7 @@ function normalizeState(loaded = {}) {
110
110
  s.instances = loaded.instances && typeof loaded.instances === "object" ? loaded.instances : {};
111
111
  s.focus = loaded.focus && typeof loaded.focus === "object" ? loaded.focus : {};
112
112
  s.orgPolicy = loaded.orgPolicy && typeof loaded.orgPolicy === "object" ? loaded.orgPolicy : {};
113
+ s.overseerState = loaded.overseerState || {};
113
114
  s.dutySession = String(loaded.dutySession || "");
114
115
  s.contractReap = loaded.contractReap && typeof loaded.contractReap === "object" ? loaded.contractReap : {};
115
116
  // The event id high-water mark. Seeded from the store's own MAX(id) where it supplied one, and
@@ -127,7 +128,7 @@ function normalizeState(loaded = {}) {
127
128
  // #6170: `kind` rides the load. This normalizer rebuilds every peer from an explicit field
128
129
  // list, so a field missing here is dropped however faithfully the store returned it (the kinds
129
130
  // came back empty on the first live restart). llm/model stay out: they are in-memory presence.
130
- : { lastSeen: v.lastSeen || 0, status: v.status || "", project: v.project || "", pubkey: v.pubkey || "", identity: v.identity || null, authWarning: v.authWarning || "", hookVersion: v.hookVersion || "", kind: v.kind || "", deliveredUpTo: v.deliveredUpTo || v.delivered_up_to || 0, _on: v._on === true || v.online === true };
131
+ : { lastSeen: v.lastSeen || 0, status: v.status || "", project: v.project || "", pubkey: v.pubkey || "", identity: v.identity || null, authWarning: v.authWarning || "", hookVersion: v.hookVersion || "", kind: v.kind || "", gitRoot: v.gitRoot || loaded.overseerState?.roots?.[session] || "", deliveredUpTo: v.deliveredUpTo || v.delivered_up_to || 0, _on: v._on === true || v.online === true };
131
132
  }
132
133
  return s;
133
134
  }
@@ -283,6 +283,18 @@ export async function recordDutyNudges({ plan, observedIds, statePath, now = Dat
283
283
  return nudged;
284
284
  }
285
285
 
286
+ // #11355: an id the shared pre-send ledger awarded to the OTHER nudger must not keep this path's
287
+ // planned claim — it would block the winner's own re-claim machinery for up to 30 minutes.
288
+ export async function releaseDutyNudgeClaims({ ids, statePath, owner }) {
289
+ const wanted = [...new Set((ids || []).map(String).filter(Boolean))];
290
+ if (!wanted.length) return;
291
+ await withStateLock(statePath, state => {
292
+ for (const id of wanted) {
293
+ if (state.planned[id]?.owner === owner) delete state.planned[id];
294
+ }
295
+ });
296
+ }
297
+
286
298
  export async function auditDutyNudges({ plan, observedIds, statePath, reportFailure, now = Date.now() }) {
287
299
  const nudged = await recordDutyNudges({ plan, observedIds, statePath, now });
288
300
  // #7429: the audit reads the ledger — an id the mechanical wake already verified (or a terminal
@@ -1,7 +1,4 @@
1
- // #7430: the duty runner pre-flights every escalation recipient BEFORE it becomes a mandatory
2
- // nudge. A recipient observed busy is a no-op (prompt rule 4a agrees), a recipient that is not a
3
- // socket-nudgeable local interactive session is terminal, and a resolver that cannot see at all
4
- // throws so lib/duty-nudges.mjs fails OPEN to a standing nudge.
1
+ // #7430: preflight skips busy or nonlocal recipients; uncertain local discovery leaves duty a nudge.
5
2
  import { execFile } from "node:child_process";
6
3
  import { readFileSync } from "node:fs";
7
4
  import { join } from "node:path";
@@ -9,16 +6,21 @@ import { busDir, hostId } from "./project.mjs";
9
6
 
10
7
  const BUSY_STATUSES = new Set(["working", "busy"]);
11
8
 
9
+ // #11109: the recorded session outranks cwd; stale panes often share the orchestrator's directory.
10
+ export function recipientPanes(project, mapped, agents) {
11
+ const claude = (Array.isArray(agents) ? agents : []).filter(agent => agent?.agent === "claude");
12
+ const exact = mapped ? claude.filter(agent => agent.agent_session?.value === mapped) : [];
13
+ return exact.length ? exact : claude.filter(agent => String(agent.cwd || "").split("/").pop() === project);
14
+ }
15
+
12
16
  export function recipientVerdict(recipient, { localHost, mapped, agents }) {
13
17
  // Remote hosts and crew slugs (`glm:trantor`) have no socket here: the hub delivers their mail
14
18
  // through their own runners, so a nudge from this machine is neither possible nor needed.
15
19
  if (!String(recipient || "").startsWith(`${localHost}:`)) return "unknown";
16
20
  const project = recipient.slice(localHost.length + 1);
17
- const panes = (Array.isArray(agents) ? agents : []).filter(agent =>
18
- agent?.agent === "claude"
19
- && (agent.agent_session?.value === mapped || String(agent.cwd || "").split("/").pop() === project));
20
- // Zero or ambiguous panes both mean "no single local session to nudge" (#7429 agrees).
21
- if (panes.length !== 1) return "unknown";
21
+ const panes = recipientPanes(project, mapped, agents);
22
+ // #11109: uncertain local discovery must reach duty's ListAgents, never a permanent terminal mark.
23
+ if (panes.length !== 1) return mapped || panes.length ? "unresolved" : "unknown";
22
24
  return BUSY_STATUSES.has(panes[0].agent_status) ? "busy" : "idle";
23
25
  }
24
26
 
@@ -0,0 +1,95 @@
1
+ // #11355: ONE shared nudged-id ledger for BOTH orchestrator-wake nudgers — the mechanical
2
+ // com.trantor.wake-nudge daemon and the duty seat's runner-side nudge plan. Each path checks and
3
+ // stamps BEFORE sending; of two nudgers offered the same unread id exactly one send lands, the
4
+ // other logs "already nudged #N". Per-recipient file, atomic check-and-stamp under a lock.
5
+ import { closeSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { busDir } from "./project.mjs";
8
+
9
+ // While an id is nudged under this window it is suppressed; past it the stamp is pruned and a
10
+ // still-unread id is nudgeable again.
11
+ export const NUDGE_LEDGER_TTL_MS = 10 * 60 * 1000;
12
+
13
+ export function nudgeLedgerPath(recipient, { bus = busDir() } = {}) {
14
+ const safe = String(recipient || "").replace(/[^A-Za-z0-9_.-]/g, "_");
15
+ return join(bus, `nudged-${safe}.json`);
16
+ }
17
+
18
+ function readLedger(path) {
19
+ try {
20
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
21
+ if (!(parsed?.nudged instanceof Object) || Array.isArray(parsed.nudged)) throw new Error("bad shape");
22
+ return parsed;
23
+ } catch {
24
+ return { version: 1, nudged: {} };
25
+ }
26
+ }
27
+
28
+ function writeLedger(path, state) {
29
+ const temporary = `${path}.${process.pid}.tmp`;
30
+ writeFileSync(temporary, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
31
+ renameSync(temporary, path);
32
+ }
33
+
34
+ async function withLedgerLock(path, update) {
35
+ const lockPath = `${path}.lock`;
36
+ let lock = null;
37
+ for (let attempt = 0; attempt < 100 && lock === null; attempt++) {
38
+ try { lock = openSync(lockPath, "wx", 0o600); }
39
+ catch { await new Promise(resolve => setTimeout(resolve, 10)); }
40
+ }
41
+ if (lock === null) throw new Error(`could not lock ${path}`);
42
+ try {
43
+ const state = readLedger(path);
44
+ await update(state);
45
+ writeLedger(path, state);
46
+ } finally {
47
+ closeSync(lock);
48
+ try { unlinkSync(lockPath); } catch {}
49
+ }
50
+ }
51
+
52
+ // Check-and-stamp BEFORE sending: ids stamped under the TTL come back skipped (the caller logs
53
+ // "already nudged #N"), the rest are stamped for `owner`. One locked step, so of two concurrent
54
+ // nudgers exactly one wins.
55
+ export async function reserveNudgeIds({
56
+ recipient, ids, owner, now = Date.now(), ttlMs = NUDGE_LEDGER_TTL_MS, bus, ledgerPath, log = (m) => console.error(m),
57
+ } = {}) {
58
+ const wanted = [...new Set((ids || []).map(id => String(id ?? "").trim()).filter(Boolean))];
59
+ if (!recipient || !wanted.length) return { allowed: [], skipped: [] };
60
+ // ledgerPath is resolved LAZILY from `bus`: a default parameter would bind busDir() at call
61
+ // time and silently ignore the caller's bus override.
62
+ const path = ledgerPath || nudgeLedgerPath(recipient, { bus });
63
+ const allowed = [], skipped = [];
64
+ await withLedgerLock(path, state => {
65
+ for (const id of wanted) {
66
+ const at = Number(state.nudged[id]?.at || 0);
67
+ if (at > 0 && now - at < ttlMs) skipped.push({ id, reason: `already nudged #${id}` });
68
+ else {
69
+ state.nudged[id] = { at: now, by: String(owner || "") };
70
+ allowed.push(id);
71
+ }
72
+ }
73
+ for (const [id, v] of Object.entries(state.nudged)) {
74
+ if (now - Number(v?.at || 0) >= ttlMs) delete state.nudged[id];
75
+ }
76
+ });
77
+ for (const s of skipped) log(s.reason);
78
+ return { allowed, skipped };
79
+ }
80
+
81
+ // Drop stamps for ids whose send ended UNVERIFIED — the ledger mirrors the duty-claim lifecycle
82
+ // (auditDutyNudges releases unverified claims), so a failed or unconfirmed send never suppresses
83
+ // the other nudger. With `owner`, only that path's own stamps are dropped.
84
+ export async function releaseNudgeIds({ recipient, ids, owner, bus, ledgerPath, now = Date.now(), ttlMs = NUDGE_LEDGER_TTL_MS } = {}) {
85
+ const wanted = [...new Set((ids || []).map(id => String(id ?? "").trim()).filter(Boolean))];
86
+ if (!recipient || !wanted.length) return;
87
+ const path = ledgerPath || nudgeLedgerPath(recipient, { bus });
88
+ await withLedgerLock(path, state => {
89
+ for (const id of wanted) {
90
+ const at = Number(state.nudged[id]?.at || 0);
91
+ const own = !owner || state.nudged[id]?.by === String(owner);
92
+ if (at > 0 && now - at < ttlMs && own) delete state.nudged[id];
93
+ }
94
+ });
95
+ }
package/lib/overseer.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  // so the episode-recurrence path was untestable and therefore unproven.
4
4
  const PEER_LIVE_MS = Number(process.env.RELAY_OVERSEER_PEER_LIVE_MS || 5 * 60 * 1000);
5
5
  const CLAIM_LIVE_MS = Number(process.env.RELAY_OVERSEER_CLAIM_LIVE_MS || 10 * 60 * 1000);
6
- const KINDS = new Set(["same-project-sessions", "file-conflict", "linked-activity"]);
6
+ const KINDS = new Set(["same-project-sessions", "file-conflict", "linked-activity", "remote-overlap"]);
7
7
  // A card in `todo` is queued, not held; `done`/`failed`/`blocked` are nobody's hands. Only these
8
8
  // two mean a session has it open right now.
9
9
  const HELD_STATUSES = new Set(["doing", "testing"]);
@@ -21,7 +21,19 @@ export function levelFor(project, autonomy = {}) {
21
21
  const direct = autonomy?.[key];
22
22
  if (isLevel(direct)) return direct;
23
23
  const fallback = autonomy?.["*"];
24
- return isLevel(fallback) ? fallback : 1;
24
+ return isLevel(fallback) ? fallback : 2;
25
+ }
26
+
27
+ export function checkoutKey({ project, gitRoot }) {
28
+ return JSON.stringify(gitRoot ? ["checkout", gitRoot] : ["project", project]);
29
+ }
30
+
31
+ export function collisionIdentity(collision) {
32
+ // #11311: remote episodes are PROJECT/repo/PR/file per the orchestrator's spec — project, not
33
+ // checkoutKey, so one gitRoot shared across projects never merges two projects' remote episodes.
34
+ const base = collision.kind === "remote-overlap" ? clean(collision.project) : checkoutKey(collision);
35
+ const remote = collision.kind === "remote-overlap" ? ` ${clean(collision.repo)}#${finiteNumber(collision.pr) ?? ""}` : "";
36
+ return `${base} ${collision.kind} ${(collision.files || []).join(",")}${remote}`;
25
37
  }
26
38
 
27
39
  function isFresh(ts, now, ttl) {
@@ -35,13 +47,17 @@ function sortedStrings(items) {
35
47
 
36
48
  function pushCollision(out, collision) {
37
49
  if (!collision.project || !KINDS.has(collision.kind) || collision.sessions.length === 0) return;
38
- out.push({
50
+ const row = {
39
51
  project: collision.project,
40
52
  kind: collision.kind,
41
53
  sessions: sortedStrings(collision.sessions),
42
54
  files: sortedStrings(collision.files ?? []),
43
55
  detail: collision.detail,
44
- });
56
+ };
57
+ if (collision.gitRoot) { row.gitRoot = collision.gitRoot; row.projects = collision.projects; }
58
+ // (repo, PR number, file) is the remote-overlap episode identity — collisionIdentity appends it.
59
+ if (collision.repo) { row.repo = clean(collision.repo); row.pr = finiteNumber(collision.pr); }
60
+ out.push(row);
45
61
  }
46
62
 
47
63
  function collisionKey(c) {
@@ -80,22 +96,22 @@ export function detectCollisions({ peers = [], claims = [], links = [], cards =
80
96
  livePeers.push({ ...peer, session, project });
81
97
  }
82
98
 
83
- const sessionsByProject = new Map();
99
+ const sessionsByCheckout = new Map();
84
100
  for (const peer of livePeers) {
85
- const sessions = sessionsByProject.get(peer.project) ?? [];
86
- sessions.push(peer.session);
87
- sessionsByProject.set(peer.project, sessions);
101
+ const key = checkoutKey(peer);
102
+ const group = sessionsByCheckout.get(key) ?? [];
103
+ group.push(peer);
104
+ sessionsByCheckout.set(key, group);
88
105
  }
89
-
90
- for (const project of sortedStrings(sessionsByProject.keys())) {
91
- const sessions = sortedStrings(sessionsByProject.get(project));
106
+ for (const group of sessionsByCheckout.values()) {
107
+ const sessions = sortedStrings(group.map(p => p.session));
92
108
  if (sessions.length < 2) continue;
109
+ const projects = sortedStrings(group.map(p => p.project));
110
+ const gitRoot = clean(group[0].gitRoot);
93
111
  pushCollision(out, {
94
- project,
95
- kind: "same-project-sessions",
96
- sessions,
97
- files: [],
98
- detail: `${sessions.join(", ")} are live on project ${project}.`,
112
+ project: projects[0], projects, gitRoot,
113
+ kind: "same-project-sessions", sessions, files: [],
114
+ detail: `${sessions.join(", ")} are live on ${gitRoot ? `checkout ${gitRoot}` : `project ${projects[0]}`}.`,
99
115
  });
100
116
  }
101
117
 
@@ -105,30 +121,36 @@ export function detectCollisions({ peers = [], claims = [], links = [], cards =
105
121
  // file — the cross-project overlap that claimSessionsByFile, keyed on project+file, structurally
106
122
  // cannot see. linked-activity reads this one.
107
123
  const claimProjectsByPath = new Map();
124
+ const liveClaims = [];
108
125
  for (const claim of asArray(claims)) {
109
126
  const project = clean(claim?.project);
110
127
  const file = clean(claim?.file);
111
128
  const session = clean(claim?.session);
112
129
  if (!project || !file || !session || !isFresh(claim?.ts, at, CLAIM_LIVE_MS)) continue;
113
- const key = `${project}\u0000${file}`;
114
- const sessions = claimSessionsByFile.get(key) ?? new Set();
115
- sessions.add(session);
116
- claimSessionsByFile.set(key, sessions);
130
+ const gitRoot = clean(claim.gitRoot || livePeers.find(p => p.session === session)?.gitRoot);
131
+ const key = JSON.stringify([checkoutKey({ project, gitRoot }), file]);
132
+ liveClaims.push({ project, file, session, gitRoot, key, remote: claim.remote });
133
+ const group = claimSessionsByFile.get(key) ?? { gitRoot, file, sessions: new Set(), projects: new Set() };
134
+ group.sessions.add(session);
135
+ group.projects.add(project);
136
+ claimSessionsByFile.set(key, group);
117
137
  const byProject = claimProjectsByPath.get(file) ?? new Map();
118
138
  byProject.set(project, (byProject.get(project) ?? new Set()).add(session));
119
139
  claimProjectsByPath.set(file, byProject);
120
140
  }
121
141
 
122
142
  for (const key of [...claimSessionsByFile.keys()].sort()) {
123
- const [project, file] = key.split("\u0000");
124
- const sessions = sortedStrings(claimSessionsByFile.get(key));
143
+ const group = claimSessionsByFile.get(key);
144
+ const projects = sortedStrings(group.projects);
145
+ const project = projects[0], file = group.file;
146
+ const sessions = sortedStrings(group.sessions);
125
147
  if (sessions.length < 2) continue;
126
148
  pushCollision(out, {
127
149
  project,
128
- kind: "file-conflict",
150
+ kind: "file-conflict", gitRoot: group.gitRoot, projects,
129
151
  sessions,
130
152
  files: [file],
131
- detail: `${sessions.join(", ")} have live claims on ${project}/${file}.`,
153
+ detail: `${sessions.join(", ")} have live claims on ${group.gitRoot || project}/${file}.`,
132
154
  });
133
155
  }
134
156
 
@@ -191,6 +213,42 @@ export function detectCollisions({ peers = [], claims = [], links = [], cards =
191
213
  });
192
214
  }
193
215
 
216
+ // #11311: open-PR evidence is computed CLIENT-SIDE (hooks/file-claim.mjs polls the claimant's
217
+ // own checkout via lib/remote-overlap.mjs) and rides the claim as its `remote` field — repo- and
218
+ // checkout-scoped by construction, one collision per (repo, PR, file) triple, warn-level, gone
219
+ // when the PR closes. That triple IS the episode identity (collisionIdentity appends repo#pr).
220
+ for (const claim of liveClaims) {
221
+ for (const r of asArray(claim.remote)) {
222
+ const pr = finiteNumber(r?.number);
223
+ const repo = clean(r?.repo);
224
+ if (pr == null || !repo || !repo.includes("/")) continue;
225
+ const author = clean(r?.author) || "unknown";
226
+ const url = clean(r?.url);
227
+ for (const file of sortedStrings(r.files)) {
228
+ if (file !== claim.file) continue;
229
+ // Recipients are EVIDENCE-MATCHED (codex hub case): within the same project+file, only
230
+ // claimants whose OWN remote carries this same repo+PR get warned — a checkout without
231
+ // that evidence is not a participant in a PR its repo does not have.
232
+ const sessions = sortedStrings(
233
+ liveClaims
234
+ .filter((c) => c.project === claim.project && c.file === claim.file
235
+ && asArray(c.remote).some((x) => clean(x?.repo) === repo && finiteNumber(x?.number) === pr))
236
+ .map((c) => c.session)
237
+ );
238
+ pushCollision(out, {
239
+ project: claim.project,
240
+ kind: "remote-overlap",
241
+ gitRoot: claim.gitRoot,
242
+ sessions,
243
+ files: [file],
244
+ repo,
245
+ pr,
246
+ detail: `open PR #${pr} by ${author} in ${repo} also changes ${file}${url ? ` (${url})` : ""}.`,
247
+ });
248
+ }
249
+ }
250
+ }
251
+
194
252
  const deduped = new Map();
195
253
  for (const collision of sortCollisions(out)) deduped.set(collisionKey(collision), collision);
196
254
  return [...deduped.values()];