trantor 0.17.13 → 0.17.15

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.
@@ -9,9 +9,15 @@
9
9
  import { readFileSync, writeFileSync, existsSync, readdirSync } from "node:fs";
10
10
  import { join, basename } from "node:path";
11
11
  import { homedir, hostname } from "node:os";
12
+ import { execSync } from "node:child_process";
13
+ import { resolveProject, hostId } from "../lib/project.mjs";
12
14
 
13
- // Load the most recent UNCONSUMED handoff for this project (written by precompact.mjs).
14
- function loadPendingHandoff(projectName) {
15
+ // Load the most recent UNCONSUMED handoff for this project (written by precompact.mjs
16
+ // / the heartbeat early-warning). `claim` marks it consumed so exactly one session
17
+ // takes it. A compaction-triggered SessionStart (source="compact") is the SAME session
18
+ // that just wrote the handoff for a FRESH window to pick up — it may show the summary
19
+ // for continuity but must NOT claim it, or it steals the handoff from the new window.
20
+ function loadPendingHandoff(projectName, { claim = true } = {}) {
15
21
  try {
16
22
  const dir = join(homedir(), ".agent-bus", "handoffs");
17
23
  if (!existsSync(dir)) return null;
@@ -20,7 +26,7 @@ function loadPendingHandoff(projectName) {
20
26
  const p = join(dir, f);
21
27
  const rec = JSON.parse(readFileSync(p, "utf8"));
22
28
  if (!rec.consumed) {
23
- rec.consumed = true; writeFileSync(p, JSON.stringify(rec, null, 2)); // claim it
29
+ if (claim) { rec.consumed = true; writeFileSync(p, JSON.stringify(rec, null, 2)); }
24
30
  return rec;
25
31
  }
26
32
  }
@@ -59,7 +65,8 @@ function sanitize(s) {
59
65
 
60
66
  let additionalContext = "";
61
67
  try {
62
- await readStdin();
68
+ let source = "";
69
+ try { source = (JSON.parse((await readStdin()) || "{}").source) || ""; } catch {}
63
70
  const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
64
71
  // Sessions started in the home directory itself aren't project work — registering
65
72
  // them spawns a phantom "<username>" project board on the dashboard. Set
@@ -69,9 +76,9 @@ try {
69
76
  process.stdout.write("{}");
70
77
  process.exit(0);
71
78
  }
72
- const project = process.env.RELAY_PROJECT || basename(projectDir);
79
+ const project = resolveProject(projectDir);
73
80
  const session = process.env.RELAY_SESSION
74
- || (process.env.RELAY_AGENT ? `${process.env.RELAY_AGENT}:${project}` : `${hostname()}:${project}`);
81
+ || (process.env.RELAY_AGENT ? `${process.env.RELAY_AGENT}:${project}` : `${hostId()}:${project}`);
75
82
  const url = relayUrl();
76
83
 
77
84
  // register self + post an initial presence status (no LLM turn — instant for others to read)
@@ -92,11 +99,44 @@ try {
92
99
  additionalContext += `</trantor>\n`;
93
100
  }
94
101
 
102
+ // CATCH-UP: a project is a DURABLE, continuous lane — not a session. Before doing
103
+ // anything, this session reconciles with the living board: what's been built, what's
104
+ // in flight, what's queued, plus the latest commits. So a fresh window resumes the
105
+ // SAME project where it stands instead of starting blind. Cheap + LLM-free.
106
+ try {
107
+ const cu = await jget(`${url}/catchup?project=${encodeURIComponent(project)}`).catch(() => null);
108
+ let gitlog = "";
109
+ try { gitlog = execSync(`git -C ${JSON.stringify(projectDir)} log --oneline -5 2>/dev/null`, { encoding: "utf8", timeout: 2500 }).trim(); } catch {}
110
+ if ((cu && cu.total > 0) || gitlog) {
111
+ const line = (arr) => (arr || []).map(t => `#${t.id} ${String(t.title).slice(0, 72)}${t.assignee ? ` @${t.assignee}` : ""}`).join("\n ");
112
+ additionalContext += `<trantor-project-state project="${sanitize(project)}">\n`;
113
+ additionalContext += `📋 **Catching up on the continuous "${sanitize(project)}" board** (this project's living record across all sessions — read it before starting; don't duplicate done work).\n`;
114
+ if (cu && cu.brief) additionalContext += `\n**Brief:** ${sanitize(cu.brief)}\n`;
115
+ if (cu && cu.total > 0) {
116
+ const c = cu.counts;
117
+ additionalContext += `\n**Cards:** ${cu.total} total — ${c.done} done · ${c.doing} doing · ${c.testing} testing · ${c.todo} todo · ${c.failed} failed · ${c.blocked} blocked.\n`;
118
+ if (cu.doing?.length) additionalContext += `\n_In progress:_\n ${sanitize(line(cu.doing))}\n`;
119
+ if (cu.testing?.length) additionalContext += `\n_In testing:_\n ${sanitize(line(cu.testing))}\n`;
120
+ if (cu.failed?.length) additionalContext += `\n_Failed (needs attention):_\n ${sanitize(line(cu.failed))}\n`;
121
+ if (cu.blocked?.length) additionalContext += `\n_Blocked:_\n ${sanitize(line(cu.blocked))}\n`;
122
+ if (cu.todo?.length) additionalContext += `\n_Queued (todo):_\n ${sanitize(line(cu.todo))}\n`;
123
+ if (cu.recentDone?.length) additionalContext += `\n_Recently done:_\n ${sanitize(line(cu.recentDone))}\n`;
124
+ }
125
+ if (gitlog) additionalContext += `\n**Recent commits:**\n\`\`\`\n${sanitize(gitlog)}\n\`\`\`\n`;
126
+ additionalContext += `\nFor a synthesized "where are we" narrative on demand, run \`trantor catchup\`.\n`;
127
+ additionalContext += `</trantor-project-state>\n`;
128
+ process.stderr.write(`[trantor] injected project-state catch-up for ${project} (${cu?.total || 0} cards)\n`);
129
+ }
130
+ } catch {}
131
+
95
132
  // Pending handoff? A prior session hit the context limit and left a handoff for this
96
- // project — take over with this fresh full window instead of starting cold.
97
- const handoff = loadPendingHandoff(basename(projectDir));
133
+ // project — take over with this fresh full window instead of starting cold. On a
134
+ // compaction-triggered start, DON'T claim it (that's the same session that wrote it;
135
+ // claiming would steal it from the freshly-spawned window) — show it for continuity only.
136
+ const isCompact = source === "compact";
137
+ const handoff = loadPendingHandoff(basename(projectDir), { claim: !isCompact });
98
138
  if (handoff) {
99
- process.stderr.write(`[trantor] loaded pending handoff ${handoff.id}\n`);
139
+ process.stderr.write(`[trantor] ${isCompact ? "showing (not claiming, compact)" : "loaded"} pending handoff ${handoff.id}\n`);
100
140
  additionalContext += `<trantor-handoff id="${sanitize(handoff.id)}" from="${sanitize(handoff.machine)}" trigger="${sanitize(handoff.trigger)}">\n`;
101
141
  additionalContext += `🔄 **You are taking over from a prior session that hit its context limit.** This is a fresh full window. Resume the work below — the prior session's summary, git state, and a pointer to its full transcript (searchable; Foundation/Gaia has it ingested) follow. Continue from "OPEN THREADS & NEXT STEPS"; do not restart from scratch.\n\n`;
102
142
  additionalContext += `## Handoff summary\n${sanitize(handoff.summary)}\n`;
package/hub.mjs CHANGED
@@ -50,11 +50,11 @@ function scanTelemetry() {
50
50
 
51
51
  // peers: { session: { lastSeen, status, project } } ; tasks: kanban cards
52
52
  // projectMeta: { project: { brief, by, updated } } — the "what & why" blurb per project
53
- let state = { messages: [], peers: {}, seq: 0, tasks: [], taskSeq: 0, projectMeta: {}, lessons: [], cardEvents: [], cardEventsBackfilled: false };
53
+ let state = { messages: [], peers: {}, seq: 0, tasks: [], taskSeq: 0, projectMeta: {}, lessons: [], cardEvents: [], cardEventsBackfilled: false, aliases: {}, phaseMeta: {} };
54
54
  try {
55
55
  if (existsSync(DATA)) {
56
56
  const loaded = JSON.parse(readFileSync(DATA, "utf8"));
57
- state = { messages: loaded.messages || [], peers: {}, seq: loaded.seq || 0, tasks: loaded.tasks || [], taskSeq: loaded.taskSeq || 0, projectMeta: loaded.projectMeta || {}, lessons: loaded.lessons || [], cardEvents: Array.isArray(loaded.cardEvents) ? loaded.cardEvents : [], cardEventsBackfilled: !!loaded.cardEventsBackfilled };
57
+ state = { messages: loaded.messages || [], peers: {}, seq: loaded.seq || 0, tasks: loaded.tasks || [], taskSeq: loaded.taskSeq || 0, projectMeta: loaded.projectMeta || {}, lessons: loaded.lessons || [], cardEvents: Array.isArray(loaded.cardEvents) ? loaded.cardEvents : [], cardEventsBackfilled: !!loaded.cardEventsBackfilled, aliases: (loaded.aliases && typeof loaded.aliases === "object") ? loaded.aliases : {}, phaseMeta: (loaded.phaseMeta && typeof loaded.phaseMeta === "object") ? loaded.phaseMeta : {} };
58
58
  for (const [s, v] of Object.entries(loaded.peers || {})) // migrate old numeric form
59
59
  state.peers[s] = typeof v === "number" ? { lastSeen: v, status: "", project: "" } : { lastSeen: v.lastSeen || 0, status: v.status || "", project: v.project || "" };
60
60
  }
@@ -104,14 +104,23 @@ const now = () => Date.now();
104
104
  const fmtAge = ms => { const m = Math.floor(ms / 60000); return m > 48 * 60 ? `${Math.floor(m / 1440)}d ago` : m > 90 ? `${Math.floor(m / 60)}h ago` : `${m}m ago`; };
105
105
  function body(req) { return new Promise(r => { let d = ""; req.on("data", c => (d += c)); req.on("end", () => { try { r(d ? JSON.parse(d) : {}); } catch { r({}); } }); }); }
106
106
  function json(res, code, obj) { res.writeHead(code, { "content-type": "application/json", "access-control-allow-origin": "*" }); res.end(JSON.stringify(obj)); }
107
+ // Canonical project name: follow the alias chain so historically-divergent keys
108
+ // (e.g. "builtbetter" → "builtbetter.ai") fold into one lane on every read AND
109
+ // write. Cycle-guarded. Empty/"all" pass through untouched.
110
+ function canon(name) {
111
+ let n = String(name || "").slice(0, 80);
112
+ const seen = new Set();
113
+ while (n && state.aliases[n] && !seen.has(n)) { seen.add(n); n = state.aliases[n]; }
114
+ return n;
115
+ }
107
116
  function touch(session, status, project) {
108
117
  if (!session || session === "all") return; // "all" is a wildcard, not a real peer
109
118
  const p = state.peers[session] || { lastSeen: 0, status: "", project: "" };
110
119
  p.lastSeen = now();
111
120
  if (status !== undefined) p.status = String(status).slice(0, 280);
112
- if (project) p.project = String(project).slice(0, 80);
121
+ if (project) p.project = canon(String(project).slice(0, 80));
113
122
  // derive project from a "host:project" session id if none given
114
- if (!p.project && session.includes(":")) p.project = session.split(":").pop().slice(0, 80);
123
+ if (!p.project && session.includes(":")) p.project = canon(session.split(":").pop().slice(0, 80));
115
124
  state.peers[session] = p; dirty = true;
116
125
  }
117
126
  // Derive a coarse health from the free-text status the runner sets on a failed turn
@@ -145,6 +154,95 @@ function appendCardEvent(type, task, by, from = null, to = null) {
145
154
  if (state.cardEvents.length > 5000) state.cardEvents.splice(0, state.cardEvents.length - 5000);
146
155
  }
147
156
 
157
+ // --- FLOW v2: derive a project's PHASES (the orchestrator-rooted flowchart spine) ---
158
+ // Real data is deps-sparse (most cards carry no deps), so we DON'T derive phases from the
159
+ // dependency graph. Instead: a card's phase = its title-prefix family (P5a/P5b → "P5",
160
+ // CBv2-1/CBfix → "CB", FA-comp1 → "FA", …) when present; otherwise it's clustered with its
161
+ // time-neighbours into a "Setup N" round (gap > 8h opens a new round). Phases are ordered by
162
+ // first-seen. The orchestrator (host session, "machine:project") vs crew ("brand:project")
163
+ // split gives each phase its fan-out actors; the plan/integrate spine nodes are synthetic
164
+ // because real per-phase orchestrator cards are rare. `sparse` flags a board that's mostly
165
+ // un-prefixed (the UI shows an "inferred phases" notice — never a silent blob).
166
+ const PHASE_GAP_MS = 8 * 60 * 60 * 1000;
167
+ const agentBrand = (a) => { const s = String(a || ""); const i = s.indexOf(":"); return i > 0 ? s.slice(0, i) : (s || ""); };
168
+ // Crew = a known helper-CLI brand; anything else with a brand (a machine hostname like
169
+ // "MacBook-Pro-M1.local" or "MacBookPro.hsd1.fl.comcast.net", or a generic "host") is the
170
+ // orchestrator. Brand-based (not hostname-pattern) so it's robust to hostname instability.
171
+ const CREW_BRANDS = /^(codex|gemini|kimi|deepseek|claude|qwen|grok|glm|mistral|llama)$/i;
172
+ const isOrchAssignee = (a) => { const b = agentBrand(a); return !!b && !CREW_BRANDS.test(b); };
173
+ function phaseFamily(title) {
174
+ const s = String(title || "").trim();
175
+ // "P5a Structured…", "P4-construction", "P3 Quantity" → P5/P4/P3 (group all P5a/b/c/d together).
176
+ // The trailing letter and the separator must NOT be swallowed by \b (P5a has none between 5 and a).
177
+ let m;
178
+ if ((m = s.match(/^P(\d+)[a-z]?(?:[\s\-:.]|$)/i))) return "P" + m[1];
179
+ if (/^CBv?\d/i.test(s) || /^CBfix/i.test(s) || /^CB[\s\-:.]/i.test(s)) return "CB";
180
+ if (/^FA[\s\-:.\d]/i.test(s)) return "FA";
181
+ if (/^RunCost/i.test(s)) return "RunCost";
182
+ return null;
183
+ }
184
+ function phaseStatus(counts) {
185
+ if (counts.failed) return "failed";
186
+ if (counts.doing || counts.testing) return "active";
187
+ const total = counts.todo + counts.doing + counts.testing + counts.failed + counts.done + counts.blocked;
188
+ if (total > 0 && counts.done === total) return "done";
189
+ if (counts.blocked) return "blocked";
190
+ if (counts.todo === total) return "planned";
191
+ return "active";
192
+ }
193
+ // A human "what is this phase about" line derived from the cards themselves: strip the phase-prefix
194
+ // token, take the subject before the first em/en-dash, dedupe, join the first few. Retroactive — no
195
+ // captured plan needed. An explicit phase goal (phaseMeta) overrides this in the /phases endpoint.
196
+ function phaseTheme(cards) {
197
+ const subs = [];
198
+ const seen = new Set();
199
+ for (const c of cards) {
200
+ let s = String(c.title || "")
201
+ // drop the phase token INCLUDING any sub-index (P3.5, P5a, CBv2-1) + separators, so no "1"/".5" leaks
202
+ .replace(/^\s*(P\d+[a-z]?(?:[.\-]\d+)?|CBv?\d+(?:[.\-]\d+)?|CBfix|FA[-\s:]?\w*|RunCost)[\s:\-–—#]*/i, "")
203
+ .split(/[—–]| - /)[0].trim(); // subject before a dash
204
+ if (!s) continue;
205
+ const k = s.toLowerCase().slice(0, 22);
206
+ if (seen.has(k)) continue;
207
+ seen.add(k); subs.push(s.slice(0, 48));
208
+ if (subs.length >= 3) break;
209
+ }
210
+ return subs.join(" · ").slice(0, 120);
211
+ }
212
+ function derivePhases(tasks) {
213
+ const sorted = [...tasks].sort((a, b) => (a.ts || 0) - (b.ts || 0));
214
+ let miscRound = 0, lastMiscTs = 0;
215
+ for (const t of sorted) {
216
+ // an explicit phase tag (set at plan time) wins; else infer from the title prefix; else time-cluster.
217
+ const explicit = t.phase && String(t.phase).trim();
218
+ const fam = explicit || phaseFamily(t.title);
219
+ if (fam) { t._phase = fam; }
220
+ else {
221
+ if (!lastMiscTs || (t.ts || 0) - lastMiscTs > PHASE_GAP_MS) miscRound++;
222
+ lastMiscTs = t.ts || lastMiscTs;
223
+ t._phase = `Setup ${miscRound}`;
224
+ }
225
+ }
226
+ const byPhase = new Map();
227
+ for (const t of sorted) { if (!byPhase.has(t._phase)) byPhase.set(t._phase, []); byPhase.get(t._phase).push(t); }
228
+ const phases = [...byPhase.entries()].map(([key, cards]) => {
229
+ const counts = { todo:0, doing:0, testing:0, failed:0, done:0, blocked:0 };
230
+ for (const c of cards) counts[c.status] = (counts[c.status] || 0) + 1;
231
+ const node = (c) => ({ id: c.id, title: c.title, assignee: c.assignee || "", agent: agentBrand(c.assignee), model: c.model || "", status: c.status, difficulty: c.difficulty || "", ts: c.ts || 0, updated: c.updated || c.ts || 0, deps: Array.isArray(c.deps) ? c.deps : [] });
232
+ const crew = cards.filter(c => !isOrchAssignee(c.assignee)).map(node);
233
+ const orchestrators = cards.filter(c => isOrchAssignee(c.assignee)).map(node);
234
+ return {
235
+ key, label: key, theme: phaseTheme(cards),
236
+ start: Math.min(...cards.map(c => c.ts || 0)), end: Math.max(...cards.map(c => c.updated || c.ts || 0)),
237
+ counts, total: cards.length, status: phaseStatus(counts),
238
+ agents: [...new Set(crew.map(c => c.agent).filter(Boolean))],
239
+ crew, orchestrators,
240
+ };
241
+ }).sort((a, b) => a.start - b.start);
242
+ const miscCount = sorted.filter(t => /^Setup /.test(t._phase)).length;
243
+ return { phases, total: sorted.length, sparse: sorted.length > 0 && miscCount / sorted.length > 0.5, derivedBy: "title-prefix + time-cluster" };
244
+ }
245
+
148
246
  const server = http.createServer(async (req, res) => {
149
247
  const u = new URL(req.url, "http://x"); const q = Object.fromEntries(u.searchParams); const P = u.pathname;
150
248
  try {
@@ -159,8 +257,9 @@ const server = http.createServer(async (req, res) => {
159
257
  if (req.method === "POST" && P === "/task") { // create a card
160
258
  const b = await body(req); touch(b.by, undefined, b.project);
161
259
  const st0 = ["todo","doing","testing","failed","done","blocked"].includes(b.status) ? b.status : "todo";
162
- const t = { id: ++state.taskSeq, project: String(b.project || "").slice(0,80), title: String(b.title||"").slice(0,200),
260
+ const t = { id: ++state.taskSeq, project: canon(String(b.project || "").slice(0,80)), title: String(b.title||"").slice(0,200),
163
261
  assignee: b.assignee || "", status: st0,
262
+ phase: String(b.phase || "").slice(0, 40), // explicit phase tag (FLOW v2) — wins over title-prefix inference
164
263
  difficulty: ["easy","medium","hard"].includes(b.difficulty) ? b.difficulty : "",
165
264
  model: String(b.model || "").slice(0, 60),
166
265
  deps: Array.isArray(b.deps) ? [...new Set(b.deps.map(Number).filter(n => Number.isInteger(n) && n > 0))].slice(0, 20) : [],
@@ -196,7 +295,7 @@ const server = http.createServer(async (req, res) => {
196
295
  if (req.method === "POST" && P === "/todos") {
197
296
  const b = await body(req);
198
297
  const session = String(b.session || b.by || "").slice(0, 120);
199
- const project = String(b.project || "").slice(0, 80);
298
+ const project = canon(String(b.project || "").slice(0, 80));
200
299
  if (!session || !project) return json(res, 400, { error: "session and project required" });
201
300
  touch(session, undefined, project);
202
301
  const ST = { pending: "todo", in_progress: "doing", completed: "done" };
@@ -228,13 +327,14 @@ const server = http.createServer(async (req, res) => {
228
327
  return json(res, 200, { ok: true, count: todos.length });
229
328
  }
230
329
  if (req.method === "GET" && P === "/tasks") {
231
- const proj = q.project; const ts = proj ? state.tasks.filter(t => t.project === proj) : state.tasks;
330
+ const proj = q.project ? canon(q.project) : ""; const ts = proj ? state.tasks.filter(t => canon(t.project) === proj) : state.tasks;
232
331
  return json(res, 200, { tasks: ts });
233
332
  }
234
333
  if (req.method === "GET" && P === "/history") {
235
334
  const requestedLimit = Number(q.limit || 200);
236
335
  const limit = Math.min(Math.max(Number.isFinite(requestedLimit) ? requestedLimit : 200, 0), 1000);
237
- const events = (q.project ? state.cardEvents.filter(e => e.project === q.project) : state.cardEvents).slice(-limit);
336
+ const proj = q.project ? canon(q.project) : "";
337
+ const events = (proj ? state.cardEvents.filter(e => canon(e.project) === proj) : state.cardEvents).slice(-limit);
238
338
  return json(res, 200, { events });
239
339
  }
240
340
  // A single card's FULL story for the detail panel: the card itself, its status events, and the
@@ -252,7 +352,7 @@ const server = http.createServer(async (req, res) => {
252
352
  return json(res, 200, { task: meta, events, messages });
253
353
  }
254
354
  if (req.method === "POST" && P === "/project") { // set a project's brief (what & why)
255
- const b = await body(req); const k = String(b.project || "").slice(0, 80);
355
+ const b = await body(req); const k = canon(String(b.project || "").slice(0, 80));
256
356
  if (!k) return json(res, 400, { error: "project required" });
257
357
  const m = state.projectMeta[k] || {};
258
358
  if (b.brief !== undefined) m.brief = String(b.brief).slice(0, 600);
@@ -271,10 +371,74 @@ const server = http.createServer(async (req, res) => {
271
371
  dirty = true; // the project reappears cleanly if an agent ever registers it again
272
372
  return json(res, 200, { ok: true, project: k, removed: { tasks: nt - state.tasks.length, peers: np - Object.keys(state.peers).length, messages: nm - state.messages.length } });
273
373
  }
374
+ // Fold one project lane into another: rewrite all stored project fields from→to AND
375
+ // record an alias so future writes under `from` canonicalize to `to`. Idempotent.
376
+ // This is how a fragmented project (one repo, two lane keys) becomes one continuous lane.
377
+ if (req.method === "POST" && P === "/project/merge") {
378
+ const b = await body(req);
379
+ const from = String(b.from || "").slice(0, 80), to = String(b.to || "").slice(0, 80);
380
+ if (!from || !to || from === to) return json(res, 400, { error: "distinct from+to required" });
381
+ let cards = 0, events = 0, peers = 0, msgs = 0;
382
+ for (const t of state.tasks) if (t.project === from) { t.project = to; cards++; }
383
+ for (const e of state.cardEvents) if (e.project === from) { e.project = to; events++; }
384
+ for (const v of Object.values(state.peers)) if (v.project === from) { v.project = to; peers++; }
385
+ for (const m of state.messages) if ((m.project || "") === from) { m.project = to; msgs++; }
386
+ if (state.projectMeta[from]) {
387
+ if (!state.projectMeta[to]) state.projectMeta[to] = state.projectMeta[from];
388
+ else if (!state.projectMeta[to].brief && state.projectMeta[from].brief) state.projectMeta[to].brief = state.projectMeta[from].brief;
389
+ delete state.projectMeta[from];
390
+ }
391
+ state.aliases[from] = to; // future writes fold automatically
392
+ for (const [k, v] of Object.entries(state.aliases)) if (v === from) state.aliases[k] = to; // re-point chains
393
+ dirty = true;
394
+ return json(res, 200, { ok: true, from, to, moved: { cards, events, peers, messages: msgs } });
395
+ }
396
+ // Catch-up snapshot: everything a NEW session needs to resume a project's continuous
397
+ // lane — the brief, card counts, what's in-flight (doing/testing/todo) and the most
398
+ // recent done work, plus last activity. Cheap + LLM-free; the SessionStart hook injects it.
399
+ if (req.method === "GET" && P === "/catchup") {
400
+ const proj = canon(q.project || "");
401
+ if (!proj) return json(res, 400, { error: "project required" });
402
+ const mine = state.tasks.filter(t => canon(t.project) === proj);
403
+ const counts = { todo:0, doing:0, testing:0, failed:0, done:0, blocked:0 };
404
+ for (const t of mine) counts[t.status] = (counts[t.status] || 0) + 1;
405
+ const pick = (st, n) => mine.filter(t => t.status === st).sort((a,b)=>(b.updated||0)-(a.updated||0)).slice(0, n)
406
+ .map(t => ({ id: t.id, title: t.title, assignee: t.assignee || "", updated: t.updated || 0 }));
407
+ const lastActivity = mine.reduce((mx,t)=>Math.max(mx, t.updated||0), state.projectMeta[proj]?.updated || 0);
408
+ return json(res, 200, {
409
+ project: proj, brief: state.projectMeta[proj]?.brief || "",
410
+ counts, total: mine.length,
411
+ doing: pick("doing", 8), testing: pick("testing", 8), failed: pick("failed", 8),
412
+ blocked: pick("blocked", 8), todo: pick("todo", 10), recentDone: pick("done", 8),
413
+ lastActivity,
414
+ });
415
+ }
416
+ // FLOW v2: the orchestrator-rooted phase flowchart. Returns the project's cards grouped into
417
+ // ordered phases (title-prefix + time-cluster), each with its crew fan-out + orchestrator nodes.
418
+ if (req.method === "GET" && P === "/phases") {
419
+ const proj = canon(q.project || "");
420
+ if (!proj) return json(res, 400, { error: "project required" });
421
+ const mine = state.tasks.filter(t => canon(t.project) === proj);
422
+ const out = derivePhases(mine);
423
+ for (const p of out.phases) p.goal = state.phaseMeta[`${proj}::${p.key}`]?.goal || ""; // explicit goal overrides the derived theme
424
+ return json(res, 200, { project: proj, brief: state.projectMeta[proj]?.brief || "", ...out });
425
+ }
426
+ // Set a phase's explicit GOAL — what this phase needs to do (the orchestrator captures this at plan
427
+ // time, like a per-phase brief). Surfaces in the FLOW v2 header in place of the derived theme.
428
+ if (req.method === "POST" && P === "/phase") {
429
+ const b = await body(req);
430
+ const proj = canon(String(b.project || "").slice(0, 80)), phase = String(b.phase || "").slice(0, 40);
431
+ if (!proj || !phase) return json(res, 400, { error: "project + phase required" });
432
+ const k = `${proj}::${phase}`; const m = state.phaseMeta[k] || {};
433
+ if (b.goal !== undefined) m.goal = String(b.goal).slice(0, 400);
434
+ m.by = b.by || m.by || ""; m.updated = now();
435
+ state.phaseMeta[k] = m; dirty = true;
436
+ return json(res, 200, { ok: true, project: proj, phase, goal: m.goal || "" });
437
+ }
274
438
  if (req.method === "GET" && P === "/projects") { // project-grouped view
275
439
  prunePeers();
276
440
  const cutoff = now() - ONLINE_MS; const byProj = {};
277
- const proj = p => p || "(unassigned)";
441
+ const proj = p => canon(p) || "(unassigned)";
278
442
  const mk = k => (byProj[k] ||= { project: k, brief: (state.projectMeta[k]?.brief) || "", agents: [], tasks: { todo:0,doing:0,testing:0,failed:0,done:0,blocked:0 }, doingTitles: [], lastActivity: 0 });
279
443
  for (const [s, v] of Object.entries(state.peers)) {
280
444
  const k = proj(v.project); const e = mk(k); e.agents.push({ session: s, online: v.lastSeen > cutoff, status: v.status || "", health: healthOf(v.status) });
@@ -0,0 +1,46 @@
1
+ // trantor — canonical project identity (client side).
2
+ // One repo = one lane. The loose `basename(cwd)` used everywhere before let a
3
+ // project fragment into multiple lanes (e.g. the host registered "builtbetter.ai"
4
+ // while its crew registered "builtbetter"). We now key by the GIT REPO ROOT
5
+ // basename, which is stable across subdirectories and sessions. An explicit
6
+ // RELAY_PROJECT always wins (deliberate override / crew inheritance). The hub
7
+ // applies an alias map on top of this to fold any historical divergence.
8
+ import { execSync } from "node:child_process";
9
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
10
+ import { basename, join, dirname } from "node:path";
11
+ import { homedir, hostname } from "node:os";
12
+
13
+ export function gitRoot(dir) {
14
+ try {
15
+ return execSync(`git -C ${JSON.stringify(dir)} rev-parse --show-toplevel`,
16
+ { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 2000 }).trim();
17
+ } catch { return ""; }
18
+ }
19
+
20
+ // Stable project key for a working directory. RELAY_PROJECT > git-root basename > cwd basename.
21
+ export function resolveProject(cwd = process.cwd()) {
22
+ if (process.env.RELAY_PROJECT) return process.env.RELAY_PROJECT.slice(0, 80);
23
+ const root = gitRoot(cwd);
24
+ return basename(root || cwd).slice(0, 80);
25
+ }
26
+
27
+ // Stable machine identity. os.hostname() is network-dependent — the same Mac reports
28
+ // "MacBook-Pro-M1.local" on one network and "MacBookPro.hsd1.fl.comcast.net" on another, which
29
+ // forks one machine into two session identities on the bus. Resolve a stable id ONCE and persist it
30
+ // to ~/.agent-bus/machine-id so it never drifts: RELAY_HOST_ID > persisted id > macOS LocalHostName
31
+ // (stable, no domain) > hostname() without its domain suffix.
32
+ let _hostId = null;
33
+ export function hostId() {
34
+ if (_hostId) return _hostId;
35
+ if (process.env.RELAY_HOST_ID) return (_hostId = process.env.RELAY_HOST_ID.slice(0, 60));
36
+ const f = join(homedir(), ".agent-bus", "machine-id");
37
+ try { if (existsSync(f)) { const v = readFileSync(f, "utf8").trim(); if (v) return (_hostId = v.slice(0, 60)); } } catch {}
38
+ let id = "";
39
+ if (process.platform === "darwin") {
40
+ try { id = execSync("scutil --get LocalHostName", { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 1500 }).trim(); } catch {}
41
+ }
42
+ if (!id) id = String(hostname() || "host").split(".")[0];
43
+ id = id.slice(0, 60) || "host";
44
+ try { mkdirSync(dirname(f), { recursive: true }); writeFileSync(f, id); } catch {}
45
+ return (_hostId = id);
46
+ }
package/mcp.mjs CHANGED
@@ -10,6 +10,7 @@ import { join, basename } from "node:path";
10
10
  import { homedir, hostname } from "node:os";
11
11
  import { execSync, spawnSync } from "node:child_process";
12
12
  import { advise } from "./bin/advise.mjs";
13
+ import { resolveProject, hostId } from "./lib/project.mjs";
13
14
  import { z } from "zod";
14
15
 
15
16
  function relayUrl() {
@@ -21,11 +22,13 @@ function relayUrl() {
21
22
  return "http://127.0.0.1:4477";
22
23
  }
23
24
  const URL_BASE = relayUrl();
24
- const PROJECT = process.env.RELAY_PROJECT || basename(process.env.CLAUDE_PROJECT_DIR || process.cwd());
25
+ // Stable project key: RELAY_PROJECT > git-repo-root basename > cwd basename. Keying by
26
+ // the git root (not a loose cwd basename) stops one repo fragmenting into several lanes.
27
+ const PROJECT = resolveProject(process.env.CLAUDE_PROJECT_DIR || process.cwd());
25
28
  // Identity: RELAY_SESSION wins; else RELAY_AGENT ("codex", "kimi", …) brands the session per-project
26
29
  // (set it once in the CLI's global MCP config — works in every project); else hostname:project.
27
30
  const SESSION = process.env.RELAY_SESSION
28
- || (process.env.RELAY_AGENT ? `${process.env.RELAY_AGENT}:${PROJECT}` : `${hostname()}:${PROJECT}`);
31
+ || (process.env.RELAY_AGENT ? `${process.env.RELAY_AGENT}:${PROJECT}` : `${hostId()}:${PROJECT}`);
29
32
  let cursor = 0;
30
33
 
31
34
  async function api(method, path, payload) {
@@ -45,11 +48,19 @@ server.tool("relay_whoami", "Show this session's relay identity, project, and th
45
48
  });
46
49
 
47
50
  server.tool("relay_task_add", "Add a Kanban card to a project's board on the dashboard (what you're about to work on). Defaults: THIS project, assigned to you, status 'todo'. Pass `project` to target another board — e.g. when you orchestrate a crew that runs in a different directory than the one you launched Claude from. Keep the team's progress visible.",
48
- { title: z.string().describe("short task title"), status: z.enum(["todo","doing","testing","failed","done","blocked"]).optional(), assignee: z.string().optional().describe("session id to assign (default: you)"), difficulty: z.enum(["easy","medium","hard"]).optional().describe("difficulty tag — drives model/agent routing (relay_advise) and shows on the board"), model: z.string().optional().describe("the model this card is routed to (from relay_advise routing, or the CLI default) — shown on the card"), deps: z.array(z.number()).optional().describe("card ids this card depends on — drawn as edges in the Flow view (e.g. integration depends on every crew card)"), project: z.string().optional().describe("board to add to (default: this session's project). Set to the crew's project when you orchestrate from a different directory") },
49
- async ({ title, status, assignee, difficulty, model, deps, project }) => {
51
+ { title: z.string().describe("short task title"), status: z.enum(["todo","doing","testing","failed","done","blocked"]).optional(), assignee: z.string().optional().describe("session id to assign (default: you)"), difficulty: z.enum(["easy","medium","hard"]).optional().describe("difficulty tag — drives model/agent routing (relay_advise) and shows on the board"), model: z.string().optional().describe("the model this card is routed to (from relay_advise routing, or the CLI default) — shown on the card"), deps: z.array(z.number()).optional().describe("card ids this card depends on — drawn as branch edges in the Flow view (e.g. integration depends on every crew card)"), phase: z.string().optional().describe("phase/milestone this card belongs to (e.g. 'P5', 'Auth', 'Launch') — groups it in the Flow view's phase flowchart. Optional; otherwise inferred from the title prefix + time."), project: z.string().optional().describe("board to add to (default: this session's project). Set to the crew's project when you orchestrate from a different directory") },
52
+ async ({ title, status, assignee, difficulty, model, deps, phase, project }) => {
50
53
  const proj = project || PROJECT;
51
- const { task } = await api("POST", "/task", { project: proj, title, status: status || "todo", assignee: assignee || SESSION, difficulty, model, deps, by: SESSION });
52
- return { content: [{ type: "text", text: `card #${task.id} added to ${proj}: "${title}" [${task.status}]` }] };
54
+ const { task } = await api("POST", "/task", { project: proj, title, status: status || "todo", assignee: assignee || SESSION, difficulty, model, deps, phase, by: SESSION });
55
+ return { content: [{ type: "text", text: `card #${task.id} added to ${proj}: "${title}" [${task.status}]${phase?` · phase ${phase}`:""}` }] };
56
+ });
57
+
58
+ server.tool("relay_phase_goal", "Set what a PHASE is for — its goal — shown as the phase header in the Flow view (overrides the theme auto-derived from card titles). Capture this when you plan a phase so the board says what each milestone needs to do, not just 'P5'. Phase keys match relay_task_add's `phase` (or the inferred title-prefix family like 'P5').",
59
+ { phase: z.string().describe("the phase key (e.g. 'P5', 'Auth', 'Launch')"), goal: z.string().describe("1-2 sentences: what this phase delivers + done-criteria"), project: z.string().optional().describe("board (default: this session's project)") },
60
+ async ({ phase, goal, project }) => {
61
+ const proj = project || PROJECT;
62
+ await api("POST", "/phase", { project: proj, phase, goal, by: SESSION });
63
+ return { content: [{ type: "text", text: `phase "${phase}" goal set for ${proj}` }] };
53
64
  });
54
65
 
55
66
  server.tool("relay_task_move", "Move a Kanban card as you progress: todo -> doing -> testing -> done. NEVER move straight to done: move to 'testing' when you finish, run the project's tests/typecheck, then 'done' only if green — or 'failed' (with a relay_send explaining what broke) if not. The orchestrator bounces failed cards back to doing. blocked = waiting on something external.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.17.13",
3
+ "version": "0.17.15",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"
@@ -10,7 +10,7 @@
10
10
  "zod": "^4.4.3"
11
11
  },
12
12
  "scripts": {
13
- "test": "node test.mjs && node test-scenarios.mjs && node test-failure.mjs"
13
+ "test": "node test.mjs && node test-scenarios.mjs && node test-failure.mjs && node test-handoff.mjs"
14
14
  },
15
15
  "description": "The hub-world for AI agent crews \u2014 orchestrate Claude Code, Codex, Gemini, Kimi & DeepSeek as live crews with a plan-aware Advisor, a Kanban/flow command center, a testing gate, and an economics brain (Scrooge).",
16
16
  "files": [
@@ -19,6 +19,7 @@
19
19
  "ui.html",
20
20
  "bin/",
21
21
  "hooks/",
22
+ "lib/",
22
23
  "skills/",
23
24
  "deploy/",
24
25
  "configs/",