trantor 0.18.82 → 0.18.86

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/lib/identity.mjs CHANGED
@@ -1,7 +1,8 @@
1
1
  // trantor — cryptographic identity, THE INTERFACE CONTRACT for Phase 0 (docs/TDD-trantor-platform.md §7).
2
2
  // An identity is an Ed25519 keypair and the label is cosmetic; every primitive lives behind this module.
3
3
  import { generateKeyPairSync, createPublicKey, createPrivateKey, sign as cryptoSign, verify as cryptoVerify, createHash, randomBytes } from "node:crypto";
4
- import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, chmodSync } from "node:fs";
4
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, chmodSync, readdirSync, unlinkSync } from "node:fs";
5
+ import { spawnSync } from "node:child_process";
5
6
  import { join } from "node:path";
6
7
  import { homedir } from "node:os";
7
8
  import { asFunction, asString } from "./decode.mjs";
@@ -64,23 +65,120 @@ function privKeyObject(identity) {
64
65
  });
65
66
  }
66
67
 
68
+ // --- the keychain layer (#11370) -----------------------------------------------------------------
69
+ // The private half moves OFF disk into the macOS Keychain (service below, account = the raw
70
+ // identity name); `trantor keys migrate` rewrites the file as a pubkey-only stub. Reads go
71
+ // Keychain-first through a per-process cache, with a one-line file fallback when locked/absent.
72
+ export const IDENTITY_SERVICE = "trantor-identity";
73
+ const SECURITY = "/usr/bin/security";
74
+ // A drill points this at a throwaway keychain so the operator's real one is never touched.
75
+ const kcArgs = () => process.env.TRANTOR_IDENTITY_KEYCHAIN ? [process.env.TRANTOR_IDENTITY_KEYCHAIN] : [];
76
+
77
+ // Test seam: drills inject a fake ({ get, set, remove } — get returns the stored string | null and
78
+ // may THROW to simulate a locked keychain) so no test needs the real thing.
79
+ let injectedKeychain = null;
80
+ export function injectIdentityKeychain(fake) {
81
+ injectedKeychain = fake || null;
82
+ processCache.clear();
83
+ warned = false;
84
+ }
85
+
86
+ const keychainUsable = () =>
87
+ !!injectedKeychain ||
88
+ (process.platform === "darwin" && process.env.TRANTOR_NO_KEYCHAIN !== "1" && existsSync(SECURITY));
89
+
90
+ let warned = false;
91
+ function warnOnce(msg) {
92
+ if (warned) return;
93
+ warned = true;
94
+ console.error(`trantor identity: ${msg}`);
95
+ }
96
+
97
+ // Three outcomes matter: a hit, a MISS (never stored — normal before migrate), and UNAVAILABLE
98
+ // (locked, headless, pre-wake). Only the last one ever warns.
99
+ function keychainGet(account) {
100
+ if (injectedKeychain) {
101
+ try { return { value: injectedKeychain.get(account) || null }; }
102
+ catch { return { error: true }; }
103
+ }
104
+ const r = spawnSync(SECURITY, ["find-generic-password", "-a", account, "-s", IDENTITY_SERVICE, "-w", ...kcArgs()], { encoding: "utf8", timeout: 8000 });
105
+ if (r.status === 0) return { value: String(r.stdout).replace(/\n$/, "") };
106
+ return r.status === 44 ? {} : { error: true }; // 44 = errSecItemNotFound
107
+ }
108
+
109
+ function keychainSet(account, value) {
110
+ if (injectedKeychain) { injectedKeychain.set(account, value); return; }
111
+ // The value travels on stdin in `security -i` syntax, so it never shows in the process table;
112
+ // only the exit code comes back (security echoes the failing line, so stderr is not relayed).
113
+ const quote = (s) => `"${String(s).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
114
+ const kc = kcArgs();
115
+ const line = `add-generic-password -a ${quote(account)} -s ${quote(IDENTITY_SERVICE)} -U -w ${quote(value)}${kc.length ? ` ${quote(kc[0])}` : ""}\n`;
116
+ const r = spawnSync(SECURITY, ["-i"], { encoding: "utf8", input: line, timeout: 8000 });
117
+ if (r.status !== 0) throw new Error(`keychain write failed for ${account} (security exited ${r.status ?? "signal"})`);
118
+ }
119
+
120
+ // A keychain entry is the whole identity as JSON, so it stays self-sufficient even if the stub
121
+ // file is lost. Anything that does not parse to a well-formed Ed25519 pair is not a key.
122
+ const HEX64 = /^[0-9a-f]{64}$/i;
123
+ function parseStored(raw) {
124
+ try {
125
+ const id = JSON.parse(raw);
126
+ return HEX64.test(id?.pubkey || "") && HEX64.test(id?.privkey || "") ? id : null;
127
+ } catch { return null; }
128
+ }
129
+
67
130
  // --- key file ----------------------------------------------------------------------------------
131
+ // One read per process: hooks sign often, and the Keychain read is the expensive half.
132
+ const processCache = new Map();
133
+ const cacheKey = (name) => `${keysDir()}\x00${name}`;
68
134
  export function load(name) {
135
+ const ck = cacheKey(String(name));
136
+ if (processCache.has(ck)) return processCache.get(ck);
137
+ const id = resolveIdentity(String(name));
138
+ processCache.set(ck, id);
139
+ return id;
140
+ }
141
+
142
+ function resolveIdentity(name) {
69
143
  const f = keyPath(name);
70
- if (!existsSync(f)) return null;
71
- try {
72
- const id = JSON.parse(readFileSync(f, "utf8"));
73
- if (!id?.pubkey || !id?.privkey) return null;
74
- return id;
75
- } catch { return null; }
144
+ let fileId = null;
145
+ if (existsSync(f)) {
146
+ try {
147
+ const parsed = JSON.parse(readFileSync(f, "utf8"));
148
+ if (parsed?.pubkey) fileId = parsed;
149
+ } catch { fileId = null; }
150
+ }
151
+ // A file still carrying its privkey is the live record: a rotate or a fresh mint lands in the
152
+ // file first, so it must win over any older keychain copy under the same name.
153
+ if (fileId?.privkey) return fileId;
154
+ if (keychainUsable()) {
155
+ const kc = keychainGet(String(name));
156
+ const stored = kc.value ? parseStored(kc.value) : null;
157
+ // The keychain holds a self-consistent pair; the stub contributes display fields only. Taking
158
+ // the stub's pubkey with the keychain's privkey would mint an identity whose signatures never
159
+ // verify — so the crypto pair always comes from the same store.
160
+ if (stored) return fileId ? { ...stored, ...fileId, privkey: stored.privkey, pubkey: stored.pubkey } : stored;
161
+ if (fileId) {
162
+ warnOnce(kc.error
163
+ ? `the Keychain is unavailable (locked?) and "${name}" has no key file to fall back on — signing fails until it unlocks`
164
+ : `"${name}": the key file is a pubkey-only stub but the Keychain has no entry — re-run \`trantor keys migrate\` or restore the key`);
165
+ }
166
+ } else if (fileId) {
167
+ warnOnce(`the Keychain is disabled on this platform and "${name}" has no key file to fall back on — signing fails`);
168
+ }
169
+ return null;
76
170
  }
77
171
 
78
172
  // Create-if-missing, safe against two hooks racing at session start: write to a unique temp file,
79
173
  // rename into place (atomic on the same filesystem), and if we lost the race, use the winner's key.
80
174
  // Two identities for one name would silently split a session's history in half.
175
+ // New keys are born as FILES even when the Keychain is up — `trantor keys migrate` moves them.
81
176
  export function loadOrCreate(name, kind = "agent") {
82
177
  const existing = load(name);
83
178
  if (existing) return existing;
179
+ // A record exists (a pubkey-only stub, or a keychain entry the read above could not use) but its
180
+ // private half is unreachable right now: minting a new key would split the history, so refuse.
181
+ if (existsSync(keyPath(name))) return null;
84
182
  mkdirSync(keysDir(), { recursive: true, mode: 0o700 });
85
183
  try { chmodSync(keysDir(), 0o700); } catch {}
86
184
  const { pubkey, privkey } = generate();
@@ -89,11 +187,16 @@ export function loadOrCreate(name, kind = "agent") {
89
187
  const tmp = `${f}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
90
188
  try {
91
189
  writeFileSync(tmp, JSON.stringify(id), { mode: 0o600 });
92
- if (existsSync(f)) { return load(f && name) || id; } // someone beat us between check and write
190
+ if (existsSync(f)) { // someone beat us between check and write
191
+ try { unlinkSync(tmp); } catch {} // the loser's tmp still holds a private key
192
+ return load(name) || id;
193
+ }
93
194
  renameSync(tmp, f);
94
195
  chmodSync(f, 0o600);
196
+ processCache.set(cacheKey(String(name)), id);
95
197
  return id;
96
198
  } catch {
199
+ try { unlinkSync(tmp); } catch {}
97
200
  return load(name) || id; // lost the race, or unwritable — never throw
98
201
  }
99
202
  }
@@ -103,6 +206,76 @@ export function publicView(identity) {
103
206
  return { name: identity.name, kind: identity.kind, pubkey: identity.pubkey, createdAt: identity.createdAt };
104
207
  }
105
208
 
209
+ // --- migrate + status (`trantor keys …`) ---------------------------------------------------------
210
+ // One <safe-name>.json per durable identity; the instances/ subdir holds per-session subkeys that
211
+ // die with the session and stay files by design.
212
+ export function listKeyNames() {
213
+ let files = [];
214
+ try { files = readdirSync(keysDir()); } catch { return []; }
215
+ return files.filter((f) => f.endsWith(".json")).map((f) => f.slice(0, -".json".length)).sort();
216
+ }
217
+
218
+ // The stub keeps everything a peer may see plus a pointer at where the private half now lives.
219
+ function writeKeyStub(f, id) {
220
+ const stub = { name: id.name, kind: id.kind, pubkey: id.pubkey, createdAt: id.createdAt, keychain: IDENTITY_SERVICE };
221
+ const tmp = `${f}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
222
+ writeFileSync(tmp, JSON.stringify(stub), { mode: 0o600 });
223
+ renameSync(tmp, f);
224
+ try { chmodSync(f, 0o600); } catch {}
225
+ }
226
+
227
+ // Copy each file-held private key into the Keychain, prove the stored copy SIGNS (a round-trip
228
+ // against the file's pubkey), and only then rewrite the file as a pubkey-only stub. A key that
229
+ // fails to store or to verify keeps its file untouched.
230
+ export function migrateKeysToKeychain({ dryRun = false } = {}) {
231
+ const result = { service: IDENTITY_SERVICE, usable: keychainUsable(), moved: [], already: [], failed: [], dryRun };
232
+ if (!result.usable) return result;
233
+ for (const fileBase of listKeyNames()) {
234
+ let id = null;
235
+ try { id = JSON.parse(readFileSync(keyPath(fileBase), "utf8")); } catch {}
236
+ // The keychain account is the IDENTITY's name (card #11370), i.e. the raw name the JSON
237
+ // carries — the file's name is only the sanitised lookup key and load() reads back by the
238
+ // raw name, so storing under "name_proj" while hooks look for "name:proj" would orphan
239
+ // every migrated key on its first read.
240
+ const name = id?.name ?? fileBase;
241
+ if (!id?.pubkey) { result.failed.push({ name, reason: "unreadable key file" }); continue; }
242
+ if (!id.privkey) { result.already.push(name); continue; }
243
+ if (dryRun) { result.moved.push(name); continue; }
244
+ try {
245
+ keychainSet(name, JSON.stringify(id));
246
+ const stored = parseStored(keychainGet(name).value || "");
247
+ if (!stored || stored.privkey !== id.privkey || stored.pubkey !== id.pubkey) throw new Error("read-back mismatch");
248
+ const probe = Buffer.from(`trantor-keychain-migrate\n${id.pubkey}`, "utf8");
249
+ const sig = cryptoSign(null, probe, privKeyObject(stored));
250
+ if (!cryptoVerify(null, probe, pubFromHex(id.pubkey), sig)) throw new Error("signature round-trip failed");
251
+ writeKeyStub(keyPath(name), id);
252
+ processCache.clear(); // this process now reads stub + keychain
253
+ result.moved.push(name);
254
+ } catch (e) {
255
+ result.failed.push({ name, reason: String(e?.message || e) });
256
+ }
257
+ }
258
+ return result;
259
+ }
260
+
261
+ // Where does each identity's PRIVATE half live? Presence booleans only — key material is never
262
+ // read into the report.
263
+ export function keyLocationReport() {
264
+ const usable = keychainUsable();
265
+ return listKeyNames().map((fileBase) => {
266
+ let fileKey = false, pubkey = null, name = fileBase;
267
+ try {
268
+ const id = JSON.parse(readFileSync(keyPath(fileBase), "utf8"));
269
+ fileKey = !!id?.privkey;
270
+ pubkey = id?.pubkey || null;
271
+ name = id?.name ?? fileBase; // the identity's real name is what the keychain is keyed on
272
+ } catch {}
273
+ const inKeychain = usable ? !!parseStored(keychainGet(name).value || "") : false;
274
+ const where = fileKey && inKeychain ? "file + keychain" : fileKey ? "file (plaintext)" : inKeychain ? "keychain" : "NO USABLE PRIVATE KEY";
275
+ return { name, pubkey, where, keychain: inKeychain, fileKey };
276
+ });
277
+ }
278
+
106
279
  // --- canonical request -------------------------------------------------------------------------
107
280
  export function bodyHash(body) {
108
281
  if (body === undefined || body === null || body === "") return "";
@@ -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()];
@@ -0,0 +1,43 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ export function personaName(value) {
5
+ if (!/^[A-Za-z0-9_-]+$/.test(value || "")) throw new Error("persona must contain only letters, digits, underscores or hyphens");
6
+ return value;
7
+ }
8
+
9
+ export function runnerPersona(env, agent) {
10
+ return personaName(env.CREW_PERSONA || agent);
11
+ }
12
+
13
+ export function allocatePersona({ dir, project, agent, model = "", name, occupied = [], dry = false }) {
14
+ personaName(agent);
15
+ const folder = join(dir, "personas", encodeURIComponent(project));
16
+ if (!dry) mkdirSync(folder, { recursive: true });
17
+ for (let n = 1; ; n++) {
18
+ const persona = personaName(name || (n === 1 ? agent : `${agent}-${n}`));
19
+ if (occupied.includes(persona)) {
20
+ if (name) throw new Error(`persona ${persona} is already active`);
21
+ continue;
22
+ }
23
+ const file = join(folder, `${persona}.json`);
24
+ const seat = { persona, agent, model };
25
+ if (dry) return seat;
26
+ try {
27
+ writeFileSync(file, JSON.stringify(seat), { flag: "wx", mode: 0o600 });
28
+ return seat;
29
+ } catch (error) {
30
+ if (error.code !== "EEXIST") throw error;
31
+ if (!name) continue;
32
+ const prior = JSON.parse(readFileSync(file, "utf8"));
33
+ if (prior.agent !== agent) throw new Error(`persona ${persona} runs ${prior.agent}; use swap to change its agent`);
34
+ return { ...prior, model };
35
+ }
36
+ }
37
+ }
38
+
39
+ export function updatePersona({ dir, project, persona, agent, model = "" }) {
40
+ const folder = join(dir, "personas", encodeURIComponent(project));
41
+ mkdirSync(folder, { recursive: true });
42
+ writeFileSync(join(folder, `${personaName(persona)}.json`), JSON.stringify({ persona, agent, model }), { mode: 0o600 });
43
+ }