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,12 +1,15 @@
1
1
  #!/usr/bin/env node
2
+ import { gitCheckoutRoot } from "./lib/git-checkout.mjs";
2
3
  // trantor PreToolUse file-claim — before every file edit the session posts a claim and the hub answers
3
4
  // with any LIVE claim on the same file by another session, handed to the model as context.
4
5
  // Informational, never blocking: a lock server that fails open is worse than no lock. Fail-open and
5
6
  // cheap: tight timeout, per-(session,file) stamp throttles re-claims; the FIRST touch always goes out.
6
7
  import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
7
- import { join, relative, isAbsolute } from "node:path";
8
+ import { join, relative, resolve } from "node:path";
8
9
  import { homedir } from "node:os";
9
10
  import { relayUrl, sessionContext, signedPost } from "./lib/api.mjs";
11
+ import { writeOverseerLevel } from "./lib/overseer-level-cache.mjs";
12
+ import { overlapsFor } from "../lib/remote-overlap.mjs";
10
13
 
11
14
  const FETCH_TIMEOUT_MS = Number(process.env.RELAY_CLAIM_TIMEOUT_MS || 900);
12
15
  const RECLAIM_MS = Number(process.env.RELAY_RECLAIM_MS || 60 * 1000);
@@ -41,26 +44,35 @@ try {
41
44
  const ctx = sessionContext(input.cwd);
42
45
  if (!ctx.project) allow();
43
46
  // claims compare by path, and absolute paths differ per machine — store repo-relative
44
- const file = isAbsolute(abs) && !relative(ctx.projectDir, abs).startsWith("..")
45
- ? relative(ctx.projectDir, abs)
46
- : abs;
47
+ const gitRoot = gitCheckoutRoot(ctx.projectDir);
48
+ const absolute = resolve(ctx.projectDir, abs);
49
+ const rel = relative(gitRoot || ctx.projectDir, absolute);
50
+ const file = !rel.startsWith("..") ? rel : absolute;
47
51
 
48
52
  // throttle re-claims of the same file; never throttle its first touch
49
53
  const stampDir = join(process.env.AGENT_BUS_DIR || join(homedir(), ".agent-bus"), "claims");
50
- const stamp = join(stampDir, `${ctx.session} ${file}`.replace(/[^A-Za-z0-9_.-]/g, "_"));
54
+ const stamp = join(stampDir, `${ctx.session} ${gitRoot} ${file}`.replace(/[^A-Za-z0-9_.-]/g, "_"));
51
55
  try {
52
56
  const last = Number(readFileSync(stamp, "utf8"));
53
57
  if (Date.now() - last < RECLAIM_MS) allow();
54
58
  } catch {}
55
59
 
60
+ // #11311: open-PR evidence, computed HERE from this session's own checkout (the hub's gh is not
61
+ // logged in and its cwd is the wrong repo). Cache file makes the normal call a stat+read; off
62
+ // when gh is missing. Fail-open like everything else in this hook.
63
+ let remote = [];
64
+ try { remote = overlapsFor({ gitRoot, files: [file] }); } catch {}
65
+
56
66
  const r = await signedPost(`${relayUrl(ctx.project)}/claim`,
57
- { project: ctx.project, file, session: ctx.session },
67
+ { project: ctx.project, file, session: ctx.session, gitRoot, remote },
58
68
  { timeoutMs: FETCH_TIMEOUT_MS, session: ctx.session });
59
69
 
60
70
  try { mkdirSync(stampDir, { recursive: true }); writeFileSync(stamp, String(Date.now())); } catch {}
61
71
 
72
+ if (r.ok) writeOverseerLevel({ project: ctx.project, hub: relayUrl(ctx.project) }, r.json?.level);
73
+
62
74
  const conflicts = r.ok ? r.json?.conflicts ?? [] : [];
63
- if (!conflicts.length) allow();
75
+ if (Number(r.json?.level ?? 2) < 2 || !conflicts.length) allow();
64
76
 
65
77
  const who = conflicts.map(c => `${c.session} (${ago(c.agoSec)} ago)`).join(", ");
66
78
  // NO permissionDecision on purpose: additionalContext reaches the model on its own, and an "allow"
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env node
2
+ import { gitCheckoutRoot } from "./lib/git-checkout.mjs";
3
+ import { relative, resolve } from "node:path";
4
+ import { relayUrl, sessionContext, signedPost } from "./lib/api.mjs";
5
+ import { readOverseerLevel, writeOverseerLevel } from "./lib/overseer-level-cache.mjs";
6
+
7
+ function deny(reason) {
8
+ process.stdout.write(JSON.stringify({ hookSpecificOutput: {
9
+ hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: reason,
10
+ } }));
11
+ }
12
+
13
+ function unavailable() {
14
+ process.stderr.write("trantor: file hold could not be checked; allowing this edit.\n");
15
+ process.stdout.write("{}");
16
+ }
17
+
18
+ try {
19
+ let raw = "";
20
+ for await (const chunk of process.stdin) raw += chunk;
21
+ const input = JSON.parse(raw || "{}");
22
+ const path = input.tool_name === "NotebookEdit" ? input.tool_input?.notebook_path : input.tool_input?.file_path;
23
+ const ctx = sessionContext(input.cwd);
24
+ if (!path || !ctx.project) process.stdout.write("{}");
25
+ else {
26
+ const cache = { project: ctx.project, hub: relayUrl(ctx.project) };
27
+ const level = readOverseerLevel(cache);
28
+ if (level !== null && level < 3) {
29
+ process.stdout.write("{}");
30
+ process.exit(0);
31
+ }
32
+ const gitRoot = gitCheckoutRoot(ctx.projectDir);
33
+ const absolute = resolve(ctx.projectDir, path);
34
+ const rel = relative(gitRoot || ctx.projectDir, absolute);
35
+ const file = !rel.startsWith("..") ? rel : absolute;
36
+ // Register and check atomically: parallel PreToolUse hooks cannot race the first claim.
37
+ const r = await signedPost(`${cache.hub}/hold/check`,
38
+ { project: ctx.project, file, session: ctx.session, gitRoot },
39
+ { timeoutMs: 2500, session: ctx.session });
40
+ if (!r.ok || !r.json?.ok) unavailable();
41
+ else {
42
+ writeOverseerLevel(cache, r.json.level);
43
+ if (r.json.hold) deny(r.json.hold.reason);
44
+ else process.stdout.write("{}");
45
+ }
46
+ }
47
+ } catch {
48
+ unavailable();
49
+ }
@@ -12,6 +12,7 @@ import { armBaton, readArm, clearArm, readConfig, contextUsage, warnFrac, alread
12
12
  import { resolveProject, hostId } from "../lib/project.mjs";
13
13
  import { installedVersion } from "./lib/update-check.mjs"; // report our hook version so the hub can flag stale sessions
14
14
  import { signedPost } from "./lib/api.mjs";
15
+ import { gitCheckoutRoot } from "./lib/git-checkout.mjs";
15
16
 
16
17
  const HEARTBEAT_MS = Number(process.env.RELAY_HEARTBEAT_MS || 60 * 1000);
17
18
  const FETCH_TIMEOUT_MS = Number(process.env.RELAY_HEARTBEAT_TIMEOUT_MS || 1500);
@@ -161,7 +162,7 @@ async function main(stdinRaw) {
161
162
  // strict test sessionstart.mjs uses), "agent" when the runner's TRANTOR_SEAT says crew seat.
162
163
  const beatKind = process.env.TRANTOR_ORCH && process.env.TRANTOR_ORCH === project ? "orch"
163
164
  : process.env.TRANTOR_SEAT ? "agent" : "";
164
- const beatBody = { session, project,
165
+ const beatBody = { session, project, gitRoot: gitCheckoutRoot(projectDir),
165
166
  llm: process.env.RELAY_LLM || (process.env.RELAY_AGENT ? process.env.RELAY_AGENT.replace(/-orch$/, "") : "claude"),
166
167
  model: modelFromTranscript(stdinRaw),
167
168
  hookVersion: (() => { try { return installedVersion(); } catch { return ""; } })() };
package/hooks/hooks.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "description": "trantor — auto-register each session + inject live roster + overseer landscape warning: linked projects, live peers, files in flight, collisions (SessionStart); turn each substantive user prompt into the session's live 'focus' card so a regular session's own work shows IN PROGRESS (UserPromptSubmit); post an in-flight 'doing' card when a sub-agent is dispatched + claim each file edit on the bus and hand the model a warning when ANOTHER live session is touching the same file (PreToolUse) and enrich it with the native agent_id + parent session when the sub-agent spawns (SubagentStart); heartbeat presence on every tool call + deliver unread bus messages to a busy session mid-turn + mirror the session's TodoWrite list onto the board as cards (PostToolUse); write a handoff before compaction (PreCompact); surface CC's own background/child agents (fork/--agent/subtask) on the board when they need input or complete (Notification); card each sub-agent's notional API cost when it finishes (SubagentStop); refuse to go idle while a peer's DIRECT message sits unread, handing it to the model instead (Stop)",
2
+ "description": "trantor \u2014 auto-register each session + inject live roster + overseer landscape warning: linked projects, live peers, files in flight, collisions (SessionStart); turn each substantive user prompt into the session's live 'focus' card so a regular session's own work shows IN PROGRESS (UserPromptSubmit); post an in-flight 'doing' card when a sub-agent is dispatched + claim each file edit on the bus and hand the model a warning when ANOTHER live session is touching the same file (PreToolUse) and enrich it with the native agent_id + parent session when the sub-agent spawns (SubagentStart); heartbeat presence on every tool call + deliver unread bus messages to a busy session mid-turn + mirror the session's TodoWrite list onto the board as cards (PostToolUse); write a handoff before compaction (PreCompact); surface CC's own background/child agents (fork/--agent/subtask) on the board when they need input or complete (Notification); card each sub-agent's notional API cost when it finishes (SubagentStop); refuse to go idle while a peer's DIRECT message sits unread, handing it to the model instead (Stop)",
3
3
  "hooks": {
4
4
  "SessionStart": [
5
5
  {
@@ -81,6 +81,15 @@
81
81
  "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/ask-sidecar.mjs"
82
82
  }
83
83
  ]
84
+ },
85
+ {
86
+ "matcher": "Edit|Write|MultiEdit|NotebookEdit",
87
+ "hooks": [
88
+ {
89
+ "type": "command",
90
+ "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/file-hold.mjs"
91
+ }
92
+ ]
84
93
  }
85
94
  ],
86
95
  "PermissionRequest": [
@@ -0,0 +1,10 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { realpathSync } from "node:fs";
3
+
4
+ export function gitCheckoutRoot(directory) {
5
+ try {
6
+ const root = execFileSync("git", ["-C", directory, "rev-parse", "--show-toplevel"],
7
+ { encoding: "utf8", timeout: 1000, stdio: ["ignore", "pipe", "ignore"] }).trim();
8
+ return realpathSync(root);
9
+ } catch { return ""; }
10
+ }
@@ -0,0 +1,25 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ const cacheDir = busDir => join(busDir || process.env.AGENT_BUS_DIR || join(homedir(), ".agent-bus"), "overseer-levels");
7
+ const cacheFile = ({ project, hub, busDir }) => join(cacheDir(busDir), createHash("sha256").update(JSON.stringify([hub, project])).digest("hex") + ".json");
8
+
9
+ export function readOverseerLevel(context) {
10
+ try {
11
+ const { level: cachedLevel, ts } = JSON.parse(readFileSync(cacheFile(context), "utf8"));
12
+ const level = cachedLevel ?? 2;
13
+ const age = Date.now() - ts;
14
+ return [1, 2, 3, 4].includes(level) && age >= 0 && age < 60_000 ? level : null;
15
+ } catch { return null; }
16
+ }
17
+
18
+ export function writeOverseerLevel(context, level = 2) {
19
+ level ??= 2;
20
+ if (![1, 2, 3, 4].includes(level)) return;
21
+ try {
22
+ mkdirSync(cacheDir(context.busDir), { recursive: true });
23
+ writeFileSync(cacheFile(context), JSON.stringify({ level, ts: Date.now() }));
24
+ } catch { /* A cache write failure must not affect the edit decision. */ }
25
+ }
@@ -31,7 +31,7 @@ try {
31
31
  if (!r.ok || !r.json) silent();
32
32
  const c = r.json;
33
33
 
34
- const level = Number(c.level || 1);
34
+ const level = Number(c.level ?? 2);
35
35
  const warnings = Array.isArray(c.warnings) ? c.warnings : [];
36
36
  const inflight = Array.isArray(c.inflight) ? c.inflight : [];
37
37
  const links = Array.isArray(c.links) ? c.links : [];
package/hub/auth.mjs CHANGED
@@ -117,18 +117,7 @@ function projectFromRequest(P, q, b) {
117
117
  }
118
118
  return canon(String(b?.project || q?.project || "").slice(0, 80));
119
119
  }
120
- // --- Cross-project guard (#6228) -----------------------------------------------------------
121
- // scopeAllows/canRead answer "can this identity touch project P at all" — and most identities
122
- // are minted project:"*" role:"owner" by defaultScopesFor (any non-agent kind: an orchestrator,
123
- // genesis, a human). That is not a project fence; it is exactly the loophole the pr-os
124
- // orchestrator walked through to register seats and post contracts into crebral-com from its
125
- // own session, nothing it was ever working. This is the fence: does the CALLER's own project
126
- // (its identity name's "kind:project" suffix, the same convention defaultScopesFor reads) match
127
- // the project the request ACTS on. Only a declared `trantor policy link` (state.orgPolicy.links,
128
- // the same store /policy reads and writes — POST /policy is itself OWNER_ENDPOINTS-gated) or the
129
- // operator's own identity (kind "human", the key /instance/supersede already treats as owner)
130
- // opens the door. Applies to the five endpoints that reach across sessions or mint access:
131
- // /send, /task, /task/update, /register, /invite.
120
+ // Cross-project actions require a declared link or the operator identity, even with wildcard scopes (#6228).
132
121
  const CROSS_PROJECT_ENDPOINTS = new Set(["/send", "/task", "/task/update", "/register", "/invite"]);
133
122
  function projectsLinked(a, b) {
134
123
  if (!a || !b || a === b) return true;
@@ -139,7 +128,7 @@ function projectsLinked(a, b) {
139
128
  }
140
129
  function overseerPolicy() {
141
130
  const policy = state.orgPolicy && typeof state.orgPolicy === "object" ? state.orgPolicy : {};
142
- return { autonomy: { "*": 1, ...(policy.autonomy || {}) }, links: Array.isArray(policy.links) ? policy.links : [] };
131
+ return { autonomy: { "*": 2, ...(policy.autonomy || {}) }, links: Array.isArray(policy.links) ? policy.links : [] };
143
132
  }
144
133
  // "projPair-a" is an INSTANCE of "projPair", not a different project: the shorter name is a prefix
145
134
  // of the longer and the remainder starts with a separator, not another project's first letter.
@@ -147,18 +136,7 @@ function instanceOfProject(a, b) {
147
136
  const [lo, hi] = a.length <= b.length ? [a, b] : [b, a];
148
137
  return hi.length > lo.length && hi.startsWith(lo) && !/[a-z0-9]/i.test(hi[lo.length]);
149
138
  }
150
- // The caller's home project, by the SAME "name suffix after the colon" rule defaultScopesFor
151
- // uses to mint a fresh identity's default scope. An identity with no colon in its name (a bare
152
- // human alias, or a tool identity never given a project) has no home to fence — nothing to check.
153
- //
154
- // The suffix is a CONVENTION, not a fact: session ids routinely carry an instance marker after the
155
- // project — "agent:projPair-a" is session -a OF projPair, the same shape as the fleet's per-seat
156
- // session ids — and reading that whole suffix as a home project fenced a session against its OWN
157
- // project's register/send (#6446, red since 3e18faf). So when the identity's own scopes name
158
- // exactly one concrete project (what /enroll bound at enrollment, and what /invite granted), that
159
- // enrolled project is the home — unless the suffix names a DIFFERENT project, in which case the
160
- // stricter suffix wins. Wildcard-scope identities (the fence's original target: a "*" owner like
161
- // an orchestrator or genesis) keep the pure suffix rule unchanged.
139
+ // Enrollment identifies instance sessions; a conflicting suffix stays stricter. Wildcard identities keep the suffix fence (#6446).
162
140
  function callerProject(auth) {
163
141
  const name = String(auth?.identity?.name || "");
164
142
  const suffix = name.includes(":") ? canon(name.slice(name.lastIndexOf(":") + 1)) : "";
@@ -197,15 +175,7 @@ function crossProjectGuard(auth, P, b) {
197
175
  return { ok: false, code: 403,
198
176
  error: `cross-project: ${home} may not act on ${target} — cross-project action is a breach unless the operator linked the projects. Run: trantor policy link ${home} ${target} --reason "<why>"` };
199
177
  }
200
- // Is a stored baton claim still worth honouring? The claim names the instance it spared
201
- // (`exceptInstanceId`), so we defer to THAT instance only while it is still being seen. A claimant
202
- // that died stops muzzling its twins; one that comes back starts again. Records claimed before
203
- // `supersededBy` existed carry no claimant, so they fall back to "is any OTHER live instance of this
204
- // name still carrying it?" — an orphaned flag must not outlive every possible carrier.
205
- // NOTE lastSeen only advances on a request, and the heartbeat is PostToolUse, so an alive-but-idle
206
- // claimant reads as gone after the grace window. That is the intended trade: the only session that
207
- // ever asks this question is one a human is actively driving right now, and deferring to a claimant
208
- // that has been silent for longer than the window is worse than letting the driven session work.
178
+ // Supersession lasts while its claimant is live, with startup grace; legacy claims defer to another live instance.
209
179
  function supersessionActive(rec) {
210
180
  if (!rec?.superseded) return false;
211
181
  const cut = now() - SUPERSEDE_GRACE_MS;
@@ -231,12 +201,7 @@ async function authenticate(req, path) {
231
201
  return { ok: false, code: 401, error: "signature required" };
232
202
  }
233
203
  const raw = req.method === "GET" || req.method === "HEAD" ? undefined : await rawBody(req);
234
- // WARN MODE NEVER BLOCKS — it annotates. That is its entire contract: an observation period
235
- // where the hub records what WOULD fail under enforce. The restarted local hub proved the
236
- // failure mode: signed requests from a not-yet-enrolled identity got 401 "unknown identity"
237
- // while UNSIGNED requests passed — punishing exactly the clients that already do the right
238
- // thing. Under warn: bad signature, replay and unknown identity all pass with a warning;
239
- // under enforce they are the hard failures they should be.
204
+ // Auth warn mode annotates signature, replay and enrollment failures; enforce mode rejects them.
240
205
  const soft = (warning) => AUTH_MODE === "warn"
241
206
  ? { ok: true, mode: AUTH_MODE, trusted: false, warning }
242
207
  : { ok: false, code: 401, error: warning };
@@ -287,17 +252,7 @@ function filterReadable(auth, rows, projectOf) {
287
252
  if (AUTH_MODE !== "enforce" && !auth?.identity) return rows;
288
253
  return rows.filter(row => canRead(auth, projectOf(row)));
289
254
  }
290
- // DISCOVERY follows declared links, and is deliberately wider than read.
291
- //
292
- // Sending across projects was never blocked: /send authorizes against the SENDER's project, so any
293
- // session can DM any session id it happens to know. Only the ROSTER was scoped — which meant two
294
- // sessions the operator had explicitly declared codependent could not learn each other's ids. The
295
- // overseer would tell both of them to "coordinate over the bus" and neither could find the other,
296
- // so the only remaining channel was the human. That is the exact traffic-cop role this project
297
- // exists to delete.
298
- //
299
- // A link is an operator declaration that two projects share resources. Treating it as mutual
300
- // discovery grants nothing a linked pair wasn't already told to do.
255
+ // Declared links permit mutual roster discovery without granting broader read or write access.
301
256
  function canDiscover(auth, project) {
302
257
  if (canRead(auth, project)) return true;
303
258
  const proj = canon(project || "");
package/hub/overseer.mjs CHANGED
@@ -1,12 +1,11 @@
1
+ import * as collisionRules from "../lib/overseer.mjs";
2
+ import * as sameProjectRules from "../lib/same-project.mjs";
3
+ const { levelFor, checkoutKey, collisionIdentity } = collisionRules;
1
4
  /* oxlint-disable anti-slop/no-runtime-typeof -- SAFETY: orgPolicy is loaded from legacy durable state and this structural split preserves its existing compatibility guard. */
2
5
  export function createOverseer({ state, fileClaims, now, appendEvent, markDirty, duty }) {
3
- let _overseer = null;
4
- import("../lib/overseer.mjs").then(m => { _overseer = m; }).catch(() => {});
5
- // #5760: the same-project episode rule (lib/same-project.mjs, pure) — lazy like the detector, so
6
- // a hub booted before the module lands still runs (the same-project branch fails QUIET until it
7
- // arrives: a missing rule must never re-instate the hourly metronome).
8
- let _sameProject = null;
9
- import("../lib/same-project.mjs").then(m => { _sameProject = m; }).catch(() => {});
6
+ const _overseer = collisionRules;
7
+ const _sameProject = sameProjectRules;
8
+ const saved = state.overseerState || {};
10
9
  const OVERSEER_TICK_MS = Number(process.env.RELAY_OVERSEER_TICK_MS || 30 * 1000);
11
10
  // How long a condition must be ABSENT before we consider the episode over. This is NOT a re-warn
12
11
  // timer: see overseerTick.
@@ -15,7 +14,7 @@ const OVERSEER_CLEAR_MS = Number(process.env.RELAY_OVERSEER_CLEAR_MS || process.
15
14
  // not an event: it persists (#5350 doctrine). It fires ONCE when it starts, stays quiet while it
16
15
  // holds (a metronome woke duty on 500 events for 4 conditions once), and the entry is forgotten
17
16
  // only after OVERSEER_CLEAR_MS of absence, so a genuine recurrence warns again.
18
- const overseerActive = new Map();
17
+ const overseerActive = new Map((saved.active || []).map(([key, entry]) => [key, { ...entry, sessions: new Set(entry.sessions) }]));
19
18
  // Heartbeat for the WATCHER itself: /overseer/status must distinguish "fleet is clear" from "the
20
19
  // overseer stopped ticking" — a monitor that cannot prove it is alive reads as clear when dead.
21
20
  let overseerLastTick = 0;
@@ -23,14 +22,14 @@ let overseerLastCollisions = [];
23
22
  function overseerPolicy() {
24
23
  const p = state.orgPolicy && typeof state.orgPolicy === "object" ? state.orgPolicy : {};
25
24
  return {
26
- autonomy: { "*": 1, ...(p.autonomy || {}) },
25
+ autonomy: { "*": 2, ...(p.autonomy || {}) },
27
26
  links: Array.isArray(p.links) ? p.links : [],
28
27
  };
29
28
  }
30
29
  function overseerInputs() {
31
30
  return {
32
31
  peers: Object.entries(state.peers).map(([session, v]) => ({
33
- session, project: v.project || "", lastSeen: v.lastSeen || 0,
32
+ session, project: v.project || "", gitRoot: v.gitRoot || state.overseerState?.roots?.[session] || "", lastSeen: v.lastSeen || 0,
34
33
  llm: v.llm || "", model: v.model || "", status: v.status || "",
35
34
  })),
36
35
  claims: [...fileClaims.values()],
@@ -45,11 +44,75 @@ function overseerInputs() {
45
44
  };
46
45
  }
47
46
 
47
+ // Claims and their decisions recover together so a restart cannot reopen a standing hold.
48
+ const holds = new Map(saved.holds || []);
49
+ const claimTtl = Number(process.env.RELAY_CLAIM_TTL_MS || 10 * 60 * 1000);
50
+ for (const claim of saved.claims || []) {
51
+ if (now() - claim.ts <= claimTtl) fileClaims.set(`${checkoutKey(claim)}\u0000${claim.file}\u0000${claim.session}`, claim);
52
+ }
53
+ function persistEpisodes() {
54
+ state.overseerState = {
55
+ roots: state.overseerState?.roots || {},
56
+ active: [...overseerActive].map(([key, entry]) => [key, { ...entry, sessions: [...entry.sessions] }]),
57
+ sameProjectFired: [...sameProjectFired],
58
+ holds: [...holds],
59
+ claims: [...fileClaims.values()].filter(claim => now() - claim.ts <= claimTtl),
60
+ };
61
+ markDirty();
62
+ }
63
+ function syncHolds() {
64
+ const live = [...fileClaims.values()].filter(c => now() - c.ts <= claimTtl);
65
+ for (const [key, hold] of holds) {
66
+ if (levelFor(hold.project, overseerPolicy().autonomy) < 3 || !live.some(c => checkoutKey(c) === checkoutKey(hold) && c.file === hold.file && c.session === hold.other)) {
67
+ holds.delete(key);
68
+ appendEvent("hold.expired", hold.project, "overseer", { holdId: hold.id, file: hold.file });
69
+ }
70
+ }
71
+ const first = new Map();
72
+ for (const claim of live) {
73
+ const fileKey = JSON.stringify([checkoutKey(claim), claim.file]);
74
+ const other = first.get(fileKey);
75
+ if (!other) { first.set(fileKey, claim.session); continue; }
76
+ if (other === claim.session || levelFor(claim.project, overseerPolicy().autonomy) < 3) continue;
77
+ const key = JSON.stringify([checkoutKey(claim), claim.file, claim.session]);
78
+ if (holds.has(key)) continue;
79
+ const hold = { id: ++state.verifyGateSeq, project: claim.project, file: claim.file,
80
+ session: claim.session, gitRoot: claim.gitRoot || "", other, status: "pending", ts: now() };
81
+ holds.set(key, hold);
82
+ markDirty();
83
+ appendEvent("hold.opened", hold.project, hold.session,
84
+ { holdId: hold.id, file: hold.file, sessions: [other, hold.session], reason: holdReason(hold) });
85
+ }
86
+ persistEpisodes();
87
+ }
88
+ function holdReason(hold) {
89
+ return `held: file conflict with ${hold.other}, ${hold.status === "nogo" ? "operator decided no-go" : "waiting on the operator"}`;
90
+ }
91
+ function holdFor(project, file, session, gitRoot = "") {
92
+ syncHolds();
93
+ if (levelFor(project, overseerPolicy().autonomy) < 3) return null;
94
+ const hold = holds.get(JSON.stringify([checkoutKey({ project, gitRoot }), file, session]));
95
+ return hold && hold.status !== "go" ? { ...hold, reason: holdReason(hold) } : null;
96
+ }
97
+ function listHolds() { syncHolds(); return [...holds.values()]; }
98
+ function decideHold(id, status, by) {
99
+ syncHolds();
100
+ const hold = [...holds.values()].find(h => h.id === id);
101
+ if (!hold || hold.status !== "pending") return null;
102
+ hold.status = status;
103
+ hold.decidedBy = by;
104
+ hold.decidedTs = now();
105
+ persistEpisodes();
106
+ appendEvent("hold.decided", hold.project, by, { holdId: hold.id, file: hold.file, status });
107
+ duty.hubSend(hold.session, `File hold #${hold.id}: ${status === "go" ? "Go — retry your edit" : "No-go — your edit remains held"} (${hold.file}).`, hold.project);
108
+ return hold;
109
+ }
110
+
48
111
  // --- #5760: the same-project warning is an EPISODE keyed by the MEMBER SET -------------------
49
112
  // lib/same-project.mjs (pure) decides from (previous set, current set, declared crew,
50
113
  // last-fired-at): a declared crew is the NORMAL state of a project and not a collision at all,
51
114
  // an unchanged set never re-warns (#5350 machinery), and the record reports DURATION.
52
- const sameProjectFired = new Map(); // project -> { hash, sessions, ts } — the set as of the last verdict
115
+ const sameProjectFired = new Map(saved.sameProjectFired || []); // project -> { hash, sessions, ts } — the set as of the last verdict
53
116
 
54
117
  // #8723 bounce-4: peerKindOf is the ONE answer to "what is this peer" — the row's own kind,
55
118
  // nothing else — shared with /peers, so roster and overseer can never disagree. The enrolled
@@ -63,10 +126,10 @@ function peerKindOf(p) {
63
126
  // (crew-runner stamps every /register its seats make), "orch" the project's orchestrator pane
64
127
  // (sessionstart stamps it when TRANTOR_ORCH names this project). A local crew-windows.txt reader
65
128
  // would describe the OPERATOR's machine, never this hub's. Genesis is deliberately NOT crew (#6068).
66
- function declaredCrewFor(project) {
129
+ function declaredCrewFor(project, gitRoot = "") {
67
130
  const crew = new Set();
68
131
  for (const [sid, p] of Object.entries(state.peers)) {
69
- if ((p.project || "") !== project) continue;
132
+ if (gitRoot ? (p.gitRoot || state.overseerState?.roots?.[sid]) !== gitRoot : (p.project || "") !== project) continue;
70
133
  const k = peerKindOf(p);
71
134
  if (k === "agent" || k === "orch") crew.add(sid);
72
135
  }
@@ -74,6 +137,7 @@ function declaredCrewFor(project) {
74
137
  }
75
138
 
76
139
  function overseerTick() {
140
+ syncHolds();
77
141
  if (!_overseer?.detectCollisions) return;
78
142
  let collisions = [];
79
143
  try { collisions = _overseer.detectCollisions(overseerInputs()) || []; } catch { return; }
@@ -86,11 +150,17 @@ function overseerTick() {
86
150
  // episode-start branch (all parties) and the standing branch (newcomers only, same-project
87
151
  // included): existing members never re-hear it, so a standing condition cannot re-wake every tick.
88
152
  const intro = (c, me, others) => {
153
+ const project = state.peers[me]?.project || c.project;
154
+ if (levelFor(project, pol.autonomy) < 2) return;
155
+ if (c.kind === "remote-overlap") {
156
+ duty.hubSend(me, `⚠️ OVERSEER remote-overlap: ${c.detail || ""}`, project);
157
+ return;
158
+ }
89
159
  const rest = others.filter(p => p !== me);
90
160
  if (rest.length === 0) return;
91
161
  duty.hubSend(me,
92
162
  `🤝 OVERSEER ${c.kind}: you and ${rest.join(", ")} are working on overlapping ground${c.files?.length ? ` (${c.files.slice(0, 3).join(", ")})` : ""}. ${c.detail || ""} Coordinate directly — relay_send to ${rest[0]} — and split the work between you. No human needs to relay this.`,
93
- c.project);
163
+ project);
94
164
  };
95
165
  // #5760: same-project sets judged crew-only are dropped entirely — the normal state of a
96
166
  // project, not a collision — so not even the context feed narrates them.
@@ -101,16 +171,17 @@ function overseerTick() {
101
171
  // silent, the record reports DURATION. Without the rule module the fallback is the pre-#5760
102
172
  // generic loop: a missing rule must never re-instate the metronome, only loosen it.
103
173
  if (c.kind === "same-project-sessions" && _sameProject?.sameProjectDecision) {
104
- const prior = sameProjectFired.get(c.project) || null;
174
+ const scope = checkoutKey(c);
175
+ const prior = sameProjectFired.get(scope) || null;
105
176
  const d = _sameProject.sameProjectDecision({
106
177
  previous: prior?.sessions ?? null,
107
178
  current: c.sessions,
108
- declaredCrew: declaredCrewFor(c.project),
179
+ declaredCrew: declaredCrewFor(c.project, c.gitRoot),
109
180
  lastFiredAt: prior?.ts ?? null,
110
181
  now: t,
111
182
  });
112
183
  if (d.reason === "crew-only") continue;
113
- const key = `${c.project} ${c.kind}`;
184
+ const key = `${scope} ${c.kind}`;
114
185
  c.key = key;
115
186
  seen.add(key);
116
187
  kept.push(c);
@@ -125,18 +196,18 @@ function overseerTick() {
125
196
  for (const me of parties) standing.sessions.add(me);
126
197
  // The record tracks the live membership (ts stays at the last warn) so a later open
127
198
  // judges the true previous set and can say how long the old one held.
128
- if (d.fire && prior) sameProjectFired.set(c.project, { hash: _sameProject.memberSetHash(c.sessions), sessions: c.sessions, ts: prior.ts });
199
+ if (d.fire && prior) sameProjectFired.set(scope, { hash: _sameProject.memberSetHash(c.sessions), sessions: c.sessions, ts: prior.ts });
129
200
  continue;
130
201
  }
131
202
  // The episode OPENS and the pure rule said fire — first sighting, or a membership change
132
203
  // on a remembered set; the record line states how long the previous state held.
133
- overseerActive.set(key, { since: t, lastTick: t, sessions: new Set(parties) });
204
+ overseerActive.set(key, { kind: c.kind, since: t, lastTick: t, sessions: new Set(parties) });
134
205
  c.since = t;
135
206
  if (d.reason === "membership-changed") c.detail = `${c.detail || ""} (same-project for ${_sameProject.durationLabel(d.durationMs)})`.trim();
136
- sameProjectFired.set(c.project, { hash: _sameProject.memberSetHash(c.sessions), sessions: c.sessions, ts: t });
207
+ sameProjectFired.set(scope, { hash: _sameProject.memberSetHash(c.sessions), sessions: c.sessions, ts: t });
137
208
  appendEvent("overseer.warn", c.project, "overseer",
138
209
  { kind: c.kind, sessions: c.sessions || [], files: c.files || [], detail: c.detail || "", narrated: false });
139
- if (duty.session) duty.hubSend(duty.session, `⚠️ OVERSEER ${c.kind} [${c.project}]: ${c.detail || ""} — if the parties are not already coordinating, message them.`, c.project);
210
+ if (levelFor(c.project, pol.autonomy) >= 2 && duty.session) duty.hubSend(duty.session, `⚠️ OVERSEER ${c.kind} [${c.project}]: ${c.detail || ""} — if the parties are not already coordinating, message them.`, c.project);
140
211
  if (parties.length > 1) for (const me of parties) intro(c, me, parties);
141
212
  continue;
142
213
  }
@@ -145,7 +216,7 @@ function overseerTick() {
145
216
  // membership is volatile — a third seat bouncing in and out of a standing collision minted a
146
217
  // fresh key, so a fresh episode, so a fresh warn (+ duty wake + party intros) per permutation.
147
218
  // Sessions are participants, not identity; current membership still rides every warn payload.
148
- const key = `${c.project} ${c.kind} ${(c.files || []).join(",")}`;
219
+ const key = collisionIdentity(c);
149
220
  c.key = key;
150
221
  seen.add(key);
151
222
  const parties = [...new Set(c.sessions || [])].filter(s => s && s !== duty.session);
@@ -165,25 +236,18 @@ function overseerTick() {
165
236
  for (const me of parties) standing.sessions.add(me);
166
237
  continue;
167
238
  }
168
- overseerActive.set(key, { since: t, lastTick: t, sessions: new Set(parties) });
239
+ overseerActive.set(key, { kind: c.kind, since: t, lastTick: t, sessions: new Set(parties) });
169
240
  c.since = t;
170
241
  appendEvent("overseer.warn", c.project, "overseer",
171
242
  { kind: c.kind, sessions: c.sessions || [], files: c.files || [], detail: c.detail || "", narrated: false });
172
- if (duty.session) duty.hubSend(duty.session, `⚠️ OVERSEER ${c.kind} [${c.project}]: ${c.detail || ""} — if the parties are not already coordinating, message them.`, c.project);
173
- if (parties.length > 1) for (const me of parties) intro(c, me, parties);
174
- const level = _overseer.levelFor ? _overseer.levelFor(c.project, pol.autonomy) : 1;
175
- if (level >= 3 && c.kind === "file-conflict") {
176
- const g = { id: ++state.verifyGateSeq, project: c.project, status: "open", ts: now(),
177
- by: "overseer", claim: `file conflict: ${(c.files || []).join(", ")} — ${(c.sessions || []).join(" vs ")}`,
178
- why: c.detail || "two live sessions on the same file", howToVerify: "decide who proceeds; coordinate over the bus" };
179
- state.verifyGates.push(g); markDirty();
180
- appendEvent("verify.gate.opened", c.project, "overseer", { gateId: g.id, claim: g.claim, why: g.why });
181
- }
243
+ if (levelFor(c.project, pol.autonomy) >= 2 && duty.session) duty.hubSend(duty.session, `⚠️ OVERSEER ${c.kind} [${c.project}]: ${c.detail || ""} — if the parties are not already coordinating, message them.`, c.project);
244
+ if (parties.length > 1 || c.kind === "remote-overlap") for (const me of parties) intro(c, me, parties);
245
+
182
246
  }
183
247
  // Episode end: a condition gone for the whole clear window is over, so a LATER recurrence is a
184
248
  // new episode and warns again. Without this the map would grow forever and nothing could re-fire.
185
249
  for (const [k, v] of overseerActive) {
186
- if (!seen.has(k) && t - v.lastTick > OVERSEER_CLEAR_MS) {
250
+ if (!seen.has(k) && (v.kind === "remote-overlap" || t - v.lastTick > OVERSEER_CLEAR_MS)) {
187
251
  overseerActive.delete(k);
188
252
  // #5760: the same-project verdict record dies WITH its episode — a set that returns after a
189
253
  // genuine clear is a new episode (it warns again, first sighting), not the old one continuing.
@@ -191,11 +255,13 @@ function overseerTick() {
191
255
  }
192
256
  }
193
257
  overseerLastCollisions = kept;
258
+ persistEpisodes();
194
259
  }
195
260
  setInterval(overseerTick, OVERSEER_TICK_MS).unref?.();
196
261
  setTimeout(overseerTick, 2000).unref?.();
197
262
 
198
263
  return {
264
+ syncHolds, holdFor, listHolds, decideHold,
199
265
  overseerTick, overseerPolicy, overseerInputs, declaredCrewFor, peerKindOf,
200
266
  active: overseerActive,
201
267
  get engine() { return _overseer; },