trantor 0.18.59 → 0.18.60

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,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.59",
3
+ "version": "0.18.60",
4
4
  "description": "Trantor — the hub-world for AI agent crews: live message bus, presence, project Kanban/flow board + crew orchestration for independent AI coding agents (Claude, Codex, Gemini, Kimi, DeepSeek)",
5
5
  "mcpServers": {
6
6
  "relay": {
package/README.md CHANGED
@@ -200,6 +200,14 @@ project takes over with a full window (and a PreCompact hook does this automatic
200
200
  **best live model** for the work at spawn (capability × cost), enumerated from the provider
201
201
  itself — never a guessed endpoint. **Serialized and then verified on the bus** — the launcher
202
202
  ends with "crew verified" or names the no-shows loudly. The orchestrator never gets a green lie.
203
+ **Seats build in their own git worktrees** (`~/.agent-bus/worktrees/<project>/<seat>`), and a
204
+ fresh checkout lacks gitignored files and cannot resolve relative sibling packages. Declare what a
205
+ worktree needs in `.trantor/worktree.json`:
206
+ `{"link": ["../sibling-repo"], "provision": [{"path": "ios/Config.swift", "mode": "link|stub|operator"}], "preflight": "npm test --silent"}`.
207
+ `trantor up` links the siblings beside the worktree, links or stubs the declared files (a real
208
+ credential is never copied; `operator` entries are named for you), runs the preflight once in the
209
+ first fresh worktree (5-minute cap) and prints `preflight ok` or the last 20 lines — broadcast on
210
+ the bus too, so the orchestrator sees it before writing contracts. No declaration, no preflight.
203
211
  3. **Work flows over the bus.** Contracts arrive as messages; each agent owns its own files;
204
212
  coordination happens in <280-char messages you can read on the dashboard. Crew members
205
213
  live under a **runner**: the CLI works one turn and exits, the runner long-polls the bus
package/bin/connect.mjs CHANGED
@@ -1,27 +1,25 @@
1
1
  #!/usr/bin/env node
2
- // trantor connect — wire every AI coding CLI on this machine to the bus, in one shot.
3
- //
4
- // node bin/connect.mjs # detect installed CLIs, patch each one's MCP config (idempotent)
5
- // node bin/connect.mjs --dry-run # show what would change, touch nothing
6
- //
7
- // Each CLI keeps its own MCP config file/format; this writes the one "relay" entry into each
8
- // (with a timestamped .bak backup the first time it changes a file). Claude Code is handled by
9
- // the plugin (claude plugin install trantor), so it's only verified here, not patched.
2
+ // trantor connect — wire every AI coding CLI on this machine to the bus, in one shot (idempotent;
3
+ // --dry-run touches nothing). Writes the one "relay" MCP entry into each CLI's own config format,
4
+ // with a timestamped .bak backup on first change. Claude Code rides the plugin: verified, not patched.
10
5
  import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync } from "node:fs";
11
6
  import { join, dirname } from "node:path";
12
7
  import { homedir } from "node:os";
13
8
  import { execSync } from "node:child_process";
14
9
  import { fileURLToPath } from "node:url";
10
+ import { resolveProject, resolveHubInfo } from "../lib/project.mjs";
15
11
 
16
12
  const DRY = process.argv.includes("--dry-run");
17
13
  const MCP = join(dirname(dirname(fileURLToPath(import.meta.url))), "mcp.mjs");
18
- const URL_ = process.env.RELAY_URL || "http://127.0.0.1:4477";
19
- // Graft (github.com/NanoNets/context-graph-engine): a local Tree-sitter dependency graph served
20
- // over MCP (graft_find_code / _find_all / _trace_calls / _file_api / _repo_map). Wired next to
21
- // `relay` so a seat can locate code with one call instead of grep+read-manythe graph refreshes
22
- // itself before each query (no freshness hook) and serves the nearest ancestor with a graft/ index,
23
- // so it keys off the seat's cwd project. A project with no graft/ index simply returns empty tools,
24
- // never an error. `graft build` (or `graft init`) seeds a project's index; graft/ is gitignored.
14
+ // Connect-time truth stamped into every relay entry env (#7893): the project this checkout resolves
15
+ // to and the hub THAT project resolves to. Some CLIs spawn MCP with a scrubbed env where even `git`
16
+ // is missing, so the stamp is the belt; the worktree path rule in lib/project.mjs stays primary.
17
+ // Env wins in resolveHubInfo, so these keys are REFRESHED on every connect runre-run after a pin change.
18
+ const PROJECT_AT_CONNECT = resolveProject(process.cwd());
19
+ const URL_ = resolveHubInfo(PROJECT_AT_CONNECT).url;
20
+ // Graft (github.com/NanoNets/context-graph-engine): local Tree-sitter dependency graph over MCP,
21
+ // wired next to `relay` so a seat locates code in one call; the graph refreshes itself per query
22
+ // and a project with no graft/ index simply returns empty tools. `graft build` seeds an index.
25
23
  const GRAFT = (() => { try { return execSync("command -v graft", { encoding: "utf8", shell: "/bin/sh" }).trim(); } catch { return "graft"; } })();
26
24
  const HAS_GRAFT = GRAFT !== "graft" || (() => { try { execSync("command -v graft", { stdio: "ignore", shell: "/bin/sh" }); return true; } catch { return false; } })();
27
25
  const has = (cmd) => { try { execSync(`command -v ${cmd}`, { stdio: "ignore", shell: "/bin/sh" }); return true; } catch { return false; } };
@@ -43,11 +41,11 @@ function patchJson(path, mutate) {
43
41
  return exists ? "wired" : "wired (new config)";
44
42
  }
45
43
 
46
- // NO RELAY_URL here. A hardcoded URL in a CLI's MCP config OVERRIDES the per-project hub pin
47
- // (env wins in resolveHub), which silently sent every crew seat's relay tools to the local hub
48
- // while its runner sat on the pinned one the residual split-brain mechanism (2026-08-20).
49
- // mcp.mjs resolves the hub from the session's project pin; that resolution must stay in charge.
50
- const relayEnv = (agent) => ({ RELAY_AGENT: agent });
44
+ // Two keys, refreshed by every connect run: agent identity and the hub. The PROJECT is never
45
+ // stamped: these configs are global to the CLI, and a seat of that CLI in another project would
46
+ // inherit the wrong board (RELAY_PROJECT outranks the worktree rule in resolveProject, #7893).
47
+ // A user-added env key survives the refresh merge.
48
+ const relayEnv = (agent) => ({ RELAY_AGENT: agent, RELAY_URL: URL_ });
51
49
  // OpenCode hosts several differently-named seats. Its global MCP environment must not stamp all
52
50
  // of them "opencode": ambient runner identity wins, while this fallback names a normal interactive
53
51
  // OpenCode session that has no RELAY_AGENT/RELAY_SESSION of its own.
@@ -63,15 +61,31 @@ if (has("claude")) {
63
61
  report("claude", st);
64
62
  }
65
63
 
66
- // ---- Codex (TOML append no TOML lib needed) ----
64
+ // ---- Codex (TOML append a missing relay section, refresh its env when it exists) ----
65
+ const tomlRelayEnv = `env = { RELAY_AGENT = "codex", RELAY_URL = "${URL_}" }`;
67
66
  if (has("codex")) {
68
67
  const p = join(homedir(), ".codex", "config.toml");
69
- const cur = existsSync(p) ? readFileSync(p, "utf8") : "";
70
- if (cur.includes("[mcp_servers.relay]")) report("codex", "already wired");
71
- else {
72
- const block = `\n# trantor — auto-registers each Codex session on the bus + adds relay_* tools\n# (no RELAY_URL on purpose: the per-project hub pin decides the hub)\n[mcp_servers.relay]\ncommand = "node"\nargs = ["${MCP}"]\nenv = { RELAY_AGENT = "codex" }\n`;
68
+ let cur = existsSync(p) ? readFileSync(p, "utf8") : "";
69
+ if (!cur.includes("[mcp_servers.relay]")) {
70
+ const block = `\n# trantor — auto-registers each Codex session on the bus + adds relay_* tools\n# (env is REFRESHED by every \`trantor connect\`: agent + connect-time hub + project)\n[mcp_servers.relay]\ncommand = "node"\nargs = ["${MCP}"]\n${tomlRelayEnv}\n`;
73
71
  if (!DRY) { if (existsSync(p)) backup(p); else mkdirSync(dirname(p), { recursive: true }); writeFileSync(p, cur + block); }
74
72
  report("codex", cur ? "wired" : "wired (new config)", p);
73
+ } else {
74
+ // Refresh the connect-written single-line `env = { ... }` inside the existing section; a
75
+ // hand-rolled multi-line env table is left untouched (user customization wins) and is named.
76
+ const start = cur.indexOf("[mcp_servers.relay]");
77
+ const next = cur.indexOf("\n[", start + 1);
78
+ const end = next === -1 ? cur.length : next + 1;
79
+ const section = cur.slice(start, end);
80
+ let refreshed = section, note = "already wired";
81
+ if (/^env\s*=\s*\{.*\}\s*$/m.test(section)) refreshed = section.replace(/^env\s*=.*$/m, tomlRelayEnv);
82
+ else if (!/^env\s*=/m.test(section)) refreshed = section.replace(/\n*$/, "\n") + tomlRelayEnv + "\n";
83
+ else note = "relay env kept (custom shape) — refresh it by hand";
84
+ if (refreshed !== section) {
85
+ if (!DRY) { backup(p); writeFileSync(p, cur.slice(0, start) + refreshed + cur.slice(end)); }
86
+ note = "relay env refreshed";
87
+ }
88
+ report("codex", note, p);
75
89
  }
76
90
  // graft alongside relay
77
91
  if (HAS_GRAFT) {
@@ -85,27 +99,32 @@ if (has("codex")) {
85
99
  }
86
100
  }
87
101
 
88
- // ---- Gemini CLI ---- (existing relay entries are never overwritten user customization wins)
102
+ // ---- Gemini CLI ---- (relay entry env is REFRESHED: ||= kept an older connect's stale env forever)
89
103
  if (has("gemini")) {
90
104
  const p = join(homedir(), ".gemini", "settings.json");
91
105
  report("gemini", patchJson(p, d => {
92
106
  d.mcpServers ||= {};
93
- d.mcpServers.relay ||= { command: "node", args: [MCP], env: relayEnv("gemini") };
107
+ d.mcpServers.relay ||= { command: "node", args: [MCP], env: {} };
108
+ d.mcpServers.relay.env = { ...d.mcpServers.relay.env, ...relayEnv("gemini") };
94
109
  if (HAS_GRAFT) d.mcpServers.graft ||= { command: GRAFT, args: ["mcp"] };
95
110
  }), p);
96
111
  }
97
112
 
98
- // ---- Kimi CLI ----
113
+ // ---- Kimi CLI ---- (same refresh: the stale entry that caused #7893 was {RELAY_AGENT: kimi} only)
99
114
  if (has("kimi")) {
100
115
  const p = join(homedir(), ".kimi", "mcp.json");
101
116
  report("kimi", patchJson(p, d => {
102
117
  d.mcpServers ||= {};
103
- d.mcpServers.relay ||= { command: "node", args: [MCP], env: relayEnv("kimi") };
118
+ d.mcpServers.relay ||= { command: "node", args: [MCP], env: {} };
119
+ d.mcpServers.relay.env = { ...d.mcpServers.relay.env, ...relayEnv("kimi") };
104
120
  if (HAS_GRAFT) d.mcpServers.graft ||= { command: GRAFT, args: ["mcp"] };
105
121
  }), p);
106
122
  }
107
123
 
108
124
  // ---- OpenCode ----
125
+ // Deliberately NOT stamped with RELAY_URL/RELAY_PROJECT: OpenCode hosts several differently-named
126
+ // seats from one config, and a stamped project/hub would override every hosted seat's runner-provided
127
+ // env (the overlay bug the deletes below fix). Ambient runner env + RELAY_AGENT_FALLBACK stay in charge.
109
128
  if (has("opencode")) {
110
129
  const p = join(homedir(), ".config", "opencode", "opencode.json");
111
130
  report("opencode", patchJson(p, d => {
@@ -122,16 +141,10 @@ if (has("opencode")) {
122
141
  }), p);
123
142
  }
124
143
 
125
- // ---- DeepSeek Harness (dsh) ----
126
- // dsh has no single MCP config file composition is a PROFILE (~/.dsh/profiles/<name>): a package.json
127
- // naming the bundles it stacks and a cordis.patch.yml inserting plugin rows. We build a "trantor"
128
- // profile on the stock headless bundle and mount two rows:
129
- // 1. their Claude Code hooks bridge pointed at OUR hooks.json — presence, focus cards, heartbeats,
130
- // file claims run inside dsh exactly as they do inside CC (verified live 2026-08-19);
131
- // 2. their MCP client spawning our relay server — relay_* tools with the seat identity forwarded
132
- // from the ambient RELAY_* env (crew-runner sets those per seat).
133
- // The bridge's own protocol lib is declared as a dependency explicitly: the rc package forgets it
134
- // (ERR_MODULE_NOT_FOUND at boot without it — reported upstream).
144
+ // ---- DeepSeek Harness (dsh): composes from PROFILES (~/.dsh/profiles/<name>), no single MCP config.
145
+ // We build a "trantor" profile mounting their Claude Code hooks bridge at OUR hooks.json plus their
146
+ // MCP client running our relay server with ambient RELAY_* identity; the bridge's protocol lib is
147
+ // declared explicitly the rc package forgets it (ERR_MODULE_NOT_FOUND at boot).
135
148
  if (has("dsh")) {
136
149
  const ROOT = dirname(MCP);
137
150
  const prof = join(homedir(), ".dsh", "profiles", "trantor");
@@ -183,12 +196,10 @@ ${HAS_GRAFT ? ` - id: trantor-graft
183
196
  command: ${GRAFT}
184
197
  args: ['mcp']
185
198
  ` : ""}`;
186
- // "a profile exists" is not "a profile is current": connect grows rows over time (relay, then
187
- // graft, then whatever comes next), and an existence check short-circuits on a profile written
188
- // by an older connect forever the seat silently never gets the new row. So the gate is
189
- // CONTENT-based: every row id this connect would write must already be in the patch; a missing
190
- // one regenerates the patch (backed up). Presence, not diff — user edits to rows that ARE there
191
- // still win, the same rule as the gemini/kimi/opencode `||=` patches above.
199
+ // "a profile exists" is not "a profile is current": connect grows rows over time, and an existence
200
+ // check short-circuits on a profile written by an older connect forever. The gate is CONTENT-based:
201
+ // every row id this connect writes must already be in the patch, else regenerate (backed up).
202
+ // Presence, not diff user edits to rows that ARE there still win, like the JSON patches above.
192
203
  const expectedIds = [...patch.matchAll(/- id: (\S+)/g)].map(m => m[1]);
193
204
  const cur = existsSync(patchPath) ? readFileSync(patchPath, "utf8") : "";
194
205
  const missing = expectedIds.filter(id => !cur.split("\n").some(l => l.trim() === `- id: ${id}`));
@@ -201,11 +212,9 @@ ${HAS_GRAFT ? ` - id: trantor-graft
201
212
  if (!DRY) {
202
213
  mkdirSync(prof, { recursive: true });
203
214
  // The seat runs the plugin's hooks MINUS SessionStart: the crew runner already owns
204
- // registration/announcement, and per-turn roster/catchup injection is wasted spend in a
205
- // fresh one-shot session (headless has no resume every turn re-pays it). Note: an earlier
206
- // version of this comment blamed a dsh teardown crash on SessionStart; that was FALSE — the
207
- // crash was the duplicated-core install below, refuted by a clean-profile repro before we
208
- // reported upstream (deepseek-harness discussions #3515/#3516).
215
+ // registration/announcement, and per-turn roster injection is wasted spend in a one-shot
216
+ // session. (A dsh teardown crash was once blamed on SessionStart — FALSE: it was the
217
+ // duplicated-core install below, refuted by clean-profile repro; deepseek-harness #3515/#3516.)
209
218
  try {
210
219
  const full = JSON.parse(readFileSync(join(ROOT, "hooks", "hooks.json"), "utf8"));
211
220
  const subset = Object.fromEntries(Object.entries(full.hooks || {}).filter(([k]) => k !== "SessionStart"));
@@ -222,10 +231,8 @@ ${HAS_GRAFT ? ` - id: trantor-graft
222
231
  const rootPath = join(prof, "cordis.yml");
223
232
  if (!existsSync(rootPath)) writeFileSync(rootPath, "# dsh profile root — an empty entry list; the tree is composed from bundles + cordis.patch.yml.\n[]\n");
224
233
  // pnpm settings mirroring dsh's own profile template. autoInstallPeers:false is LOAD-BEARING:
225
- // an installer that pulls the bridge's peers drops a SECOND copy of dsh's core packages into
226
- // the profile, the loader mounts services from both module instances, and the first tool call
227
- // dies on ctx.tools[TOOL_RUNTIME_SCHEDULER] being undefined (observed: every turn that used
228
- // any tool crashed "reading 'prepare'"; tool-free turns worked).
234
+ // an installer that pulls the bridge's peers drops a SECOND copy of dsh's core into the
235
+ // profile, both instances mount, and the first tool call dies on ctx.tools being undefined.
229
236
  writeFileSync(join(prof, "pnpm-workspace.yaml"), "packages:\n - .\n\nnodeLinker: hoisted\nautoInstallPeers: false\n");
230
237
  // the two bridge packages must be importable from the profile's node_modules — via pnpm
231
238
  // (peers OFF, hoisted) like dsh's own template; npm needs --legacy-peer-deps for the same
@@ -240,7 +247,7 @@ ${HAS_GRAFT ? ` - id: trantor-graft
240
247
  }
241
248
 
242
249
  const found = out.length;
243
- console.log(`trantor connect${DRY ? " (dry run)" : ""} — hub: ${URL_}`);
250
+ console.log(`trantor connect${DRY ? " (dry run)" : ""} — project: ${PROJECT_AT_CONNECT}, hub: ${URL_}`);
244
251
  for (const r of out) console.log(` ${r.cli.padEnd(9)} ${r.status}${r.detail ? ` (${r.detail})` : ""}`);
245
252
  if (!found) console.log(" no supported CLIs found on PATH (claude, codex, gemini, kimi, opencode, dsh)");
246
253
  console.log(DRY ? "\nRun without --dry-run to apply." : "\nDone. New sessions of each CLI auto-join the bus.");
@@ -0,0 +1,70 @@
1
+ // #7760: `trantor up` used to hand out worktrees that could not build — every seat discovered the
2
+ // missing sibling package and the gitignored config at once. Up now provisions the first seat's
3
+ // worktree from .trantor/worktree.json, runs the declared preflight there once, and broadcasts.
4
+ import {
5
+ PREFLIGHT_CAP_MS, applyWorktreeDeclaration, ensureSeatWorktree, preflightLine, provisioningLines,
6
+ readWorktreeDeclaration, runPreflight,
7
+ } from "../../lib/seat-worktree.mjs";
8
+ import { gitRoot, hostId } from "../../lib/project.mjs";
9
+ import { loadOrCreate } from "../../lib/identity.mjs";
10
+ import { ensureEnrolled } from "../../lib/enroll.mjs";
11
+ import { sfetchJson } from "../../lib/signed-fetch.mjs";
12
+
13
+ const BROADCAST_MAX = 1500;
14
+
15
+ export function broadcastText(agent, applied, result) {
16
+ const parts = [];
17
+ if (result) parts.push(result.ok ? `${preflightLine(result)} in the ${agent} worktree` : `preflight failed in the ${agent} worktree (${result.command}): ${result.tail.join(" | ")}`);
18
+ else parts.push(`worktree declaration applied for ${agent} (no preflight declared)`);
19
+ if (applied.operator.length) parts.push(`operator steps: ${applied.operator.join("; ")}`);
20
+ if (applied.problems.length) parts.push(`problems: ${applied.problems.join("; ")}`);
21
+ const text = parts.join(" — ");
22
+ return text.length > BROADCAST_MAX ? `${text.slice(0, BROADCAST_MAX - 1)}…` : text;
23
+ }
24
+
25
+ async function broadcast(ctx, text, log) {
26
+ const session = ctx.env.RELAY_SESSION || `${hostId()}:${ctx.project}`;
27
+ try {
28
+ const identity = loadOrCreate(session, "agent");
29
+ await ensureEnrolled(ctx.hub, identity, ctx.project);
30
+ const r = await sfetchJson(`${ctx.hub}/send`, {
31
+ identity, payload: { from: session, to: "all", project: ctx.project, kind: "status", text },
32
+ signal: AbortSignal.timeout(5000),
33
+ });
34
+ if (!r.ok) log(`— preflight result NOT recorded on the bus (${ctx.hub}/send answered ${r.status}) —`);
35
+ return r.ok;
36
+ } catch (e) {
37
+ log(`— preflight result NOT recorded on the bus (${String(e?.message || e).slice(0, 80)}) —`);
38
+ return false;
39
+ }
40
+ }
41
+
42
+ // Returns { skipped } when the project declares nothing (unchanged behaviour), else what was
43
+ // applied, the preflight result, and whether the bus recorded it.
44
+ export async function preflightFirstSeat(ctx, agent, { capMs = PREFLIGHT_CAP_MS, log = console.log } = {}) {
45
+ const decl = readWorktreeDeclaration(gitRoot(ctx.dir) || ctx.dir);
46
+ if (!decl) return { skipped: "no declaration" };
47
+ const wt = ensureSeatWorktree({ sourceDir: ctx.dir, project: ctx.project, agent, home: ctx.home, env: ctx.env, log });
48
+ if (!wt.root) {
49
+ log(`— preflight skipped: no seat worktree for ${agent} (running from ${wt.dir}) —`);
50
+ return { skipped: "no worktree" };
51
+ }
52
+ const applied = applyWorktreeDeclaration(decl, { root: wt.root, seatDir: wt.dir });
53
+ log(`— ${agent} worktree ${wt.created ? "created" : "reused"} at ${wt.dir}; .trantor/worktree.json applied —`);
54
+ for (const l of provisioningLines(applied, { prefix: " " })) log(l);
55
+
56
+ let result = null;
57
+ if (!decl.preflight) log("— no preflight declared —");
58
+ else if (wt.dirty) log(`— preflight skipped: the ${agent} worktree has uncommitted work (commit or harvest it, then re-run up) —`);
59
+ else {
60
+ log(`— preflight in ${wt.dir}: ${decl.preflight} (cap ${Math.round(capMs / 60000)} min) —`);
61
+ result = runPreflight(decl.preflight, { seatDir: wt.dir, capMs, env: ctx.env });
62
+ log(result.ok ? `\x1b[32m${preflightLine(result)}\x1b[0m` : `\x1b[31m${preflightLine(result)}\x1b[0m`);
63
+ if (!result.ok) log(" ✗ fix the worktree need above (or the declaration) BEFORE writing contracts — the seats will hit the same wall.");
64
+ }
65
+
66
+ const worthPosting = result || applied.operator.length || applied.problems.length;
67
+ const text = worthPosting ? broadcastText(agent, applied, result) : "";
68
+ const posted = text ? await broadcast(ctx, text, log) : false;
69
+ return { applied, result, text, posted, seatDir: wt.dir, created: wt.created };
70
+ }
@@ -1,11 +1,7 @@
1
1
  // Payload composition for crew-runner turn prompts — pure, unit-testable (card #5683).
2
- //
3
- // A fresh codex seat burned 306k tokens and crash-looped into a remote-compact 404. The runner-side
4
- // part of that: every turn re-feeds the FULL lessons block (22,298 of the 24,698 chars in codex's
5
- // last turn file — 90%), plus an unbounded FYI-broadcast backlog that grows across a failure streak
6
- // and is replayed on every redelivery, on a RESUMED session where it all stacks. So every section
7
- // here is capped, and the assembled prompt has ONE hard total cap with a visible truncation notice.
8
- // Below the caps the output is byte-identical to the old string concatenation.
2
+ // A fresh codex seat burned 306k tokens re-feeding the FULL lessons block plus an unbounded
3
+ // broadcast backlog every turn, so every section here is capped and the assembled prompt has ONE
4
+ // hard total cap with a visible truncation notice; below the caps the output is byte-identical.
9
5
 
10
6
  export const PAYLOAD_CAPS = Object.freeze({
11
7
  wakeCount: 10, // direct/@mention messages: keep the last ~10
@@ -80,9 +76,8 @@ export function pickLessons(lessons, trigger = "", caps = PAYLOAD_CAPS) {
80
76
 
81
77
  // ---- one composer, one hard total cap ----
82
78
  // sections: [{ name, text, trim?, order? }] joined in order. `trim: "drop"` removes the whole
83
- // section when the total is over `totalChars` (lowest `order` dropped first); `trim: "truncate"`
84
- // cuts the section to the remaining budget. Sections without `trim` are never touched they are
85
- // the runner-authored frame. The payload carries a visible notice naming every trim.
79
+ // section when over `totalChars` (lowest `order` first); `trim: "truncate"` cuts it to the remaining
80
+ // budget; no `trim` = the runner-authored frame, never touched. A visible notice names every trim.
86
81
  export function composePrompt(sections, caps = PAYLOAD_CAPS) {
87
82
  const secs = sections.map(s => ({ ...s, text: String(s?.text ?? "") }));
88
83
  let total = secs.reduce((a, s) => a + s.text.length, 0);
@@ -113,3 +108,17 @@ export function composePrompt(sections, caps = PAYLOAD_CAPS) {
113
108
  dropped,
114
109
  };
115
110
  }
111
+
112
+ // ---- the integration head (#7754): one `base: <sha>` line on every contract. A seat starts its
113
+ // branch at that sha, never at origin/main, which trails the orchestrator's unpushed integration.
114
+ const BASE_LINE_RE = /^\s*base:\s*([0-9a-f]{7,40})\b/im;
115
+ export const baseLine = (sha) => (sha ? `base: ${sha}` : "");
116
+ // The newest wake message naming a base wins: an orchestrator's explicit head outranks local main.
117
+ export function contractBase(wake) {
118
+ const list = Array.isArray(wake) ? wake : [];
119
+ for (let i = list.length - 1; i >= 0; i--) {
120
+ const m = String(list[i]?.text ?? "").match(BASE_LINE_RE);
121
+ if (m) return m[1];
122
+ }
123
+ return "";
124
+ }