trantor 0.17.14 → 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.
@@ -6,14 +6,14 @@
6
6
  },
7
7
  "metadata": {
8
8
  "description": "Trantor — the hub-world for AI agent crews: live message bus, presence, project Kanban/flow board + context-handoff for independent AI coding agents (Claude, Codex, Gemini, …)",
9
- "version": "0.17.14"
9
+ "version": "0.17.15"
10
10
  },
11
11
  "plugins": [
12
12
  {
13
13
  "name": "trantor",
14
14
  "source": "./",
15
15
  "description": "The hub-world for AI agent crews. Say \"fire up the crew\" and Claude becomes the architect: a plan-aware Advisor routes the work (solo / cheap inline calls / live crew of Codex, Gemini, Kimi & DeepSeek in their own terminal windows), a Kanban/flow command center with a testing gate tracks it, and an economics brain (Scrooge) keeps the receipts. Includes the relay MCP, a SessionStart auto-discovery hook, and a PreCompact context-handoff so a fresh session can take over a full window instead of compacting.",
16
- "version": "0.17.14",
16
+ "version": "0.17.15",
17
17
  "author": {
18
18
  "name": "Sasha Bogojevic"
19
19
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.17.14",
3
+ "version": "0.17.15",
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": {
@@ -1,21 +1,20 @@
1
1
  #!/usr/bin/env node
2
- // trantor — detached handoff worker. The PostToolUse heartbeat spawns this when a
3
- // session crosses its context warn threshold, so the (up to ~60s) scrooge summary
4
- // never blocks a tool call. Writes a whole-session handoff and prompts to open a
5
- // fresh session. Args: <projectDir> <sessionId> <transcriptPath> [trigger]
6
- import { readConfig, writeHandoff, pingBus, maybeSpawn,
7
- contextUsage, alreadyHandedOff, markHandedOff } from "./lib/handoff.mjs";
2
+ // trantor — detached EARLY-WARNING handoff worker. The PostToolUse heartbeat spawns this when a
3
+ // session crosses its context warn threshold (~85% of a known window). Its job is to PREPARE a
4
+ // safety-net handoff and NOTIFY NOT to open a window. Spawning a fresh session while the original
5
+ // is still perfectly usable created a surprise pop-up + an orphaned duplicate session; the actual
6
+ // fresh-window spawn now happens only AT THE WALL (PreCompact). The (~60s) scrooge summary runs here,
7
+ // detached, so it never blocks a tool call. Args: <projectDir> <sessionId> <transcriptPath> [trigger]
8
+ import { readConfig, writeHandoff, pingBus, contextUsage } from "./lib/handoff.mjs";
8
9
  import { basename } from "node:path";
9
10
 
10
11
  const [, , projectDir = process.cwd(), sessionId = "", transcript = "", trigger = "context-warn"] = process.argv;
11
12
  try {
12
13
  const conf = readConfig();
13
- const cur = contextUsage(transcript, conf)?.tokens || 0;
14
- if (alreadyHandedOff(sessionId, cur)) process.exit(0); // another path beat us to it
15
14
  const { file, record } = writeHandoff({ projectDir, sessionId, transcript, trigger });
16
- process.stderr.write(`[trantor] early handoff written: ${file} (frac warn)\n`);
15
+ process.stderr.write(`[trantor] early handoff written (safety net, no spawn): ${file}\n`);
16
+ // notify the bus so a watcher knows a handoff is ready — but do NOT spawn a window here.
17
17
  await pingBus(basename(projectDir), record.id, conf);
18
- if (maybeSpawn(projectDir, conf)) markHandedOff(sessionId, cur);
19
18
  } catch (e) {
20
19
  process.stderr.write(`[trantor] handoff-now error: ${e?.message || e}\n`);
21
20
  }
@@ -19,7 +19,7 @@ import { homedir, hostname } from "node:os";
19
19
  import { spawn } from "node:child_process";
20
20
  import { fileURLToPath } from "node:url";
21
21
  import { readConfig, contextUsage, warnFrac, alreadyHandedOff } from "./lib/handoff.mjs";
22
- import { resolveProject } from "../lib/project.mjs";
22
+ import { resolveProject, hostId } from "../lib/project.mjs";
23
23
 
24
24
  const HEARTBEAT_MS = Number(process.env.RELAY_HEARTBEAT_MS || 60 * 1000);
25
25
  const FETCH_TIMEOUT_MS = Number(process.env.RELAY_HEARTBEAT_TIMEOUT_MS || 1500);
@@ -83,7 +83,7 @@ async function main(stdinRaw) {
83
83
  // identity, else a RELAY_AGENT brand ("codex","kimi",…) per project, else hostname:project.
84
84
  const project = resolveProject(projectDir);
85
85
  const session = process.env.RELAY_SESSION
86
- || (process.env.RELAY_AGENT ? `${process.env.RELAY_AGENT}:${project}` : `${hostname()}:${project}`);
86
+ || (process.env.RELAY_AGENT ? `${process.env.RELAY_AGENT}:${project}` : `${hostId()}:${project}`);
87
87
 
88
88
  // Throttle: only act if HEARTBEAT_MS has elapsed since the last tick for THIS session.
89
89
  const stamp = join(homedir(), ".agent-bus", `hb-${session.replace(/[^A-Za-z0-9_.-]/g, "_")}.stamp`);
@@ -10,7 +10,7 @@ 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
12
  import { execSync } from "node:child_process";
13
- import { resolveProject } from "../lib/project.mjs";
13
+ import { resolveProject, hostId } from "../lib/project.mjs";
14
14
 
15
15
  // Load the most recent UNCONSUMED handoff for this project (written by precompact.mjs
16
16
  // / the heartbeat early-warning). `claim` marks it consumed so exactly one session
@@ -78,7 +78,7 @@ try {
78
78
  }
79
79
  const project = resolveProject(projectDir);
80
80
  const session = process.env.RELAY_SESSION
81
- || (process.env.RELAY_AGENT ? `${process.env.RELAY_AGENT}:${project}` : `${hostname()}:${project}`);
81
+ || (process.env.RELAY_AGENT ? `${process.env.RELAY_AGENT}:${project}` : `${hostId()}:${project}`);
82
82
  const url = relayUrl();
83
83
 
84
84
  // register self + post an initial presence status (no LLM turn — instant for others to read)
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, aliases: {} };
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, aliases: (loaded.aliases && typeof loaded.aliases === "object") ? loaded.aliases : {} };
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
  }
@@ -154,6 +154,95 @@ function appendCardEvent(type, task, by, from = null, to = null) {
154
154
  if (state.cardEvents.length > 5000) state.cardEvents.splice(0, state.cardEvents.length - 5000);
155
155
  }
156
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
+
157
246
  const server = http.createServer(async (req, res) => {
158
247
  const u = new URL(req.url, "http://x"); const q = Object.fromEntries(u.searchParams); const P = u.pathname;
159
248
  try {
@@ -170,6 +259,7 @@ const server = http.createServer(async (req, res) => {
170
259
  const st0 = ["todo","doing","testing","failed","done","blocked"].includes(b.status) ? b.status : "todo";
171
260
  const t = { id: ++state.taskSeq, project: canon(String(b.project || "").slice(0,80)), title: String(b.title||"").slice(0,200),
172
261
  assignee: b.assignee || "", status: st0,
262
+ phase: String(b.phase || "").slice(0, 40), // explicit phase tag (FLOW v2) — wins over title-prefix inference
173
263
  difficulty: ["easy","medium","hard"].includes(b.difficulty) ? b.difficulty : "",
174
264
  model: String(b.model || "").slice(0, 60),
175
265
  deps: Array.isArray(b.deps) ? [...new Set(b.deps.map(Number).filter(n => Number.isInteger(n) && n > 0))].slice(0, 20) : [],
@@ -323,6 +413,28 @@ const server = http.createServer(async (req, res) => {
323
413
  lastActivity,
324
414
  });
325
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
+ }
326
438
  if (req.method === "GET" && P === "/projects") { // project-grouped view
327
439
  prunePeers();
328
440
  const cutoff = now() - ONLINE_MS; const byProj = {};
package/lib/project.mjs CHANGED
@@ -6,7 +6,9 @@
6
6
  // RELAY_PROJECT always wins (deliberate override / crew inheritance). The hub
7
7
  // applies an alias map on top of this to fold any historical divergence.
8
8
  import { execSync } from "node:child_process";
9
- import { basename } from "node:path";
9
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
10
+ import { basename, join, dirname } from "node:path";
11
+ import { homedir, hostname } from "node:os";
10
12
 
11
13
  export function gitRoot(dir) {
12
14
  try {
@@ -21,3 +23,24 @@ export function resolveProject(cwd = process.cwd()) {
21
23
  const root = gitRoot(cwd);
22
24
  return basename(root || cwd).slice(0, 80);
23
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,7 +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 } from "./lib/project.mjs";
13
+ import { resolveProject, hostId } from "./lib/project.mjs";
14
14
  import { z } from "zod";
15
15
 
16
16
  function relayUrl() {
@@ -28,7 +28,7 @@ const PROJECT = resolveProject(process.env.CLAUDE_PROJECT_DIR || process.cwd());
28
28
  // Identity: RELAY_SESSION wins; else RELAY_AGENT ("codex", "kimi", …) brands the session per-project
29
29
  // (set it once in the CLI's global MCP config — works in every project); else hostname:project.
30
30
  const SESSION = process.env.RELAY_SESSION
31
- || (process.env.RELAY_AGENT ? `${process.env.RELAY_AGENT}:${PROJECT}` : `${hostname()}:${PROJECT}`);
31
+ || (process.env.RELAY_AGENT ? `${process.env.RELAY_AGENT}:${PROJECT}` : `${hostId()}:${PROJECT}`);
32
32
  let cursor = 0;
33
33
 
34
34
  async function api(method, path, payload) {
@@ -48,11 +48,19 @@ server.tool("relay_whoami", "Show this session's relay identity, project, and th
48
48
  });
49
49
 
50
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.",
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 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") },
52
- 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 }) => {
53
53
  const proj = project || PROJECT;
54
- const { task } = await api("POST", "/task", { project: proj, title, status: status || "todo", assignee: assignee || SESSION, difficulty, model, deps, by: SESSION });
55
- 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}` }] };
56
64
  });
57
65
 
58
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.14",
3
+ "version": "0.17.15",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"
package/ui.html CHANGED
@@ -107,9 +107,14 @@ main:not(.learn-open) .learn-body{display:none}
107
107
  .sortmode:hover{color:var(--mut)}
108
108
  .proj.idle .proj-h{opacity:.55;cursor:pointer}
109
109
  /* flow (DAG) view */
110
- .vtog{display:flex;gap:2px;margin-left:10px}
111
- .vbtn{font-size:10px;font-weight:700;letter-spacing:.05em;padding:2px 9px;border-radius:10px;border:1px solid var(--line);background:var(--card);color:var(--dim);cursor:pointer}
112
- .vbtn.on{color:var(--grn);border-color:#1d4a44;background:#0f1d1b}
110
+ /* view switcher — a clearly-labelled segmented control so it doesn't disappear next to the model chips */
111
+ .vtog{display:inline-flex;align-items:stretch;margin-left:12px;border:1px solid #34465f;border-radius:11px;overflow:hidden;background:#0c121e;flex:none}
112
+ .vtog .vlbl{display:flex;align-items:center;font-size:8.5px;font-weight:800;letter-spacing:.09em;color:var(--mut);padding:0 8px;text-transform:uppercase;background:#131c2c;border-right:1px solid #34465f}
113
+ .vbtn{font-size:10.5px;font-weight:800;letter-spacing:.04em;padding:4px 13px;border:0;border-left:1px solid #1f2839;background:transparent;color:#9fb0c4;cursor:pointer}
114
+ .vbtn:first-of-type{border-left:0}
115
+ .vbtn:hover{background:#172339;color:var(--tx)}
116
+ .vbtn.on{color:#04110e;background:var(--grn)}
117
+ .vbtn.on:hover{background:var(--grn);color:#04110e}
113
118
  .flowwrap{padding:14px 16px;overflow:auto}
114
119
  .flowwrap svg{display:block}
115
120
  .fnode{cursor:pointer}
@@ -165,6 +170,34 @@ main:not(.learn-open) .learn-body{display:none}
165
170
  .tlgl svg{flex:none}
166
171
  .tlwrap .fhint{position:absolute;right:10px;bottom:6px;z-index:3}
167
172
  .tlnode{cursor:pointer}
173
+ /* FLOW v2 = horizontal orchestrator-rooted flowchart TREE. A spine runs left→right; each phase is a
174
+ ◇ plan node that fans OUT (drawn edges) to its crew/LLM card nodes, then converges INTO a ◆ integrate
175
+ node, which connects to the next phase. Agent is a node property, not the axis. Native horizontal scroll. */
176
+ .pflowwrap{background:#0b1019;border-top:1px solid var(--line)}
177
+ .finfo{background:#1a2030;border:1px solid var(--line);border-radius:8px;padding:7px 10px;font-size:11px;color:var(--mut);margin:10px 14px 0}
178
+ .pflowwrap.gflow{position:relative;padding-bottom:22px}
179
+ .gscroll{overflow-x:auto;overflow-y:hidden;padding:4px 0 2px}
180
+ .gscroll svg{display:block}
181
+ .gedge{fill:none;stroke:#33405c;stroke-width:1.6;opacity:.8}
182
+ .gedge.done{stroke:var(--grn2);opacity:.5}
183
+ .gspine{fill:none;stroke:#5a6b86;stroke-width:2.5}
184
+ .gnode{cursor:default}
185
+ .gnode[data-id]{cursor:pointer}
186
+ .gnbox{fill:#101827;stroke:rgba(255,255,255,.12);stroke-width:1}
187
+ .gnode[data-id]:hover .gnbox{stroke:rgba(255,255,255,.55);fill:#162032}
188
+ .gnode.orch .gnbox{fill:#0e1730;stroke:#2c3a55;stroke-dasharray:4 3}
189
+ .gnode.failed .gnbox,.gnode.blocked .gnbox{stroke:var(--red)}
190
+ .gntext{fill:#eaf1fa;font-size:11px;font-family:ui-sans-serif,system-ui;pointer-events:none}
191
+ .gnsub{fill:var(--mut);font-size:9.5px;font-family:ui-sans-serif,system-ui;pointer-events:none}
192
+ .pfband{fill:#0c1320;opacity:.45}
193
+ .pfband.alt{fill:#0e1626;opacity:.55}
194
+ .pfbandtop{opacity:.12}
195
+ .pfbandtop.done{fill:var(--grn2)}.pfbandtop.active{fill:#f0b24b}.pfbandtop.failed,.pfbandtop.blocked{fill:var(--red)}.pfbandtop.planned{fill:#46566f}
196
+ .pflabel{font-size:16px;font-weight:800;font-family:ui-sans-serif,system-ui}
197
+ .pflabel.done{fill:var(--grn2)}.pflabel.active{fill:#f0b24b}.pflabel.failed{fill:var(--red)}.pflabel.planned,.pflabel.blocked{fill:#aeb9c8}
198
+ .pfcount{fill:var(--mut);font-size:10.5px;font-family:ui-sans-serif,system-ui}
199
+ .pfsub{fill:#9fb0c4;font-size:10.5px;font-family:ui-sans-serif,system-ui}
200
+ .pflowwrap .fhint{position:absolute;right:12px;bottom:5px}
168
201
  /* card detail modal — the full story of one card: status journey + the agent's own bus reports */
169
202
  .cmodal{position:fixed;inset:0;z-index:60;display:flex;align-items:center;justify-content:center;background:rgba(4,7,12,.62)}
170
203
  .cmpanel{background:var(--panel);border:1px solid var(--line);border-radius:14px;width:min(760px,93vw);max-height:84vh;display:flex;flex-direction:column;box-shadow:0 20px 64px rgba(0,0,0,.55)}
@@ -309,6 +342,7 @@ econ();setInterval(econ,15000);
309
342
  function poolOf(session){const b=brandOf(session);const k=b==='anthropic'?'claude':b==='openai'?'codex':b==='moonshot'?'kimi':b;return POOLS[k]||'';}
310
343
  const VIEWS = JSON.parse(localStorage.getItem("abViews") || "{}");
311
344
  let HISTORY = {};
345
+ let PHASES = {};
312
346
  function setView(proj, v){ VIEWS[proj] = v; localStorage.setItem("abViews", JSON.stringify(VIEWS)); render(); }
313
347
  function toggleIdle(name){ if(!name)return; const s=new Set(JSON.parse(localStorage.getItem("abIdleOpen")||"[]")); s.has(name)?s.delete(name):s.add(name); localStorage.setItem("abIdleOpen",JSON.stringify([...s])); render(); }
314
348
  let armedDel=null,armedTs=0; // pending ✕ confirmation (project name + when it was armed)
@@ -320,71 +354,104 @@ function moveProj(name,dir){
320
354
  [names[i],names[j]]=[names[j],names[i]];
321
355
  localStorage.setItem("abOrder",JSON.stringify(names)); render();
322
356
  }
323
- // FLOW = development timeline. Cards laid left→right in BUILD ORDER (even slots bursts don't cram,
324
- // idle gaps don't waste space), one lane per agent, each card a readable FIXED-width block segmented
325
- // by the time it spent in each status. Natively scrollable left/right; lane labels pinned in a left
326
- // gutter; dependency edges converge = merges. (No proportional-time slivers, no zoom cut-off.)
327
- const TLSCROLL = {};
357
+ // FLOW v2 = the project's TRUE shape: an orchestrator-rooted phase flowchart. The orchestrator is the
358
+ // spine (the left rail); each PHASE blooms plan the crew fans out to its cards → ◆ converge —
359
+ // then the next phase begins. Agent identity is a node badge, not the axis. Phases come from the hub
360
+ // (/phases: title-prefix + time-cluster; deps-sparse boards fall back to time + a visible notice).
361
+ // Each crew node keeps its full /card detail on click.
328
362
  function flowHTML(pt, proj){
329
- if (!pt.length) return '<div class="empty">no cards yet</div>';
330
- const NOW = Date.now();
363
+ const data = PHASES[proj];
364
+ if (!data) return '<div class="pflowwrap"><div class="empty">loading flow…</div></div>';
365
+ if (!data.phases || !data.phases.length) return '<div class="pflowwrap"><div class="empty">no cards yet</div></div>';
331
366
  const SCOL = { todo:'#3a4458', doing:'#4a90d9', testing:'#f59e0b', failed:'#ef6a6a', blocked:'#ef6a6a', done:'#14b8a6' };
332
- const GUT = 96, TH = 24, ROW = 42, BAR = 26, SLOT = 188, CW = 172, PAD = 10;
333
- const cards = pt.map(t => {
334
- const h = (t.history && t.history.length) ? t.history.slice() : [{ to: t.status || 'todo', ts: t.ts || NOW }];
335
- return { t, h, tStart: h[0].ts || t.ts || NOW, tEnd: (t.status === 'done' ? (h[h.length-1].ts || NOW) : NOW) };
367
+ const NW = 168, NH = 34, RG = 11, CG = 64, PGAP = 76, MT = 64, MB = 22;
368
+ const stackH = n => n*NH + Math.max(0,n-1)*RG;
369
+ // Per-phase layout: cards spread by their INTRA-PHASE dependency depth. Independent cards share
370
+ // column 0 (parallel, hanging off the plan node); a card that depends on another lands one column
371
+ // to the right of its parent (a branch/chain). Leaves (no dependents) converge into integrate.
372
+ const layouts = data.phases.map(ph => {
373
+ const work = [...ph.crew.map(n => ({ ...n, _orch:false })), ...ph.orchestrators.map(n => ({ ...n, _orch:true }))];
374
+ const cards = work.length ? work : [{ id:0, title:'(no cards)', status:ph.status, agent:'', synthetic:true, deps:[] }];
375
+ const idSet = new Set(cards.map(c => c.id)); const byId = {}; cards.forEach(c => byId[c.id] = c);
376
+ const parentsOf = c => (c.deps || []).filter(d => idSet.has(d) && d !== c.id);
377
+ const dmemo = {}; const depth = (c, seen) => {
378
+ if (dmemo[c.id] != null) return dmemo[c.id];
379
+ seen = seen || new Set(); if (seen.has(c.id)) return 0; seen.add(c.id);
380
+ const ps = parentsOf(c); const d = ps.length ? Math.max(...ps.map(p => depth(byId[p], seen))) + 1 : 0;
381
+ return (dmemo[c.id] = d);
382
+ };
383
+ cards.forEach(c => c._d = depth(c));
384
+ const maxDepth = Math.max(0, ...cards.map(c => c._d));
385
+ const cols = []; for (let d = 0; d <= maxDepth; d++) cols[d] = cards.filter(c => c._d === d);
386
+ const hasChild = c => cards.some(o => parentsOf(o).includes(c.id));
387
+ return { ph, cards, byId, parentsOf, hasChild, maxDepth, cols };
336
388
  });
337
- cards.sort((a,b)=> a.tStart - b.tStart || a.t.id - b.t.id);
338
- cards.forEach((c,i)=> c.col = i);
339
- const byId = {}; for (const c of cards) byId[c.t.id] = c;
340
- const laneKey = t => (String(t.assignee || 'unassigned').split(':')[0] || 'unassigned');
341
- const laneFirst = {}; for (const c of cards){ const k = laneKey(c.t); if (laneFirst[k] == null) laneFirst[k] = c.col; }
342
- const lanes = [...new Set(cards.map(c => laneKey(c.t)))].sort((a,b)=> laneFirst[a]-laneFirst[b]);
343
- const laneIdx = Object.fromEntries(lanes.map((k,i)=>[k,i]));
344
- const X = col => GUT + col*SLOT + PAD;
345
- const laneY = i => TH + i*ROW + (ROW-BAR)/2;
346
- const totalW = GUT + cards.length*SLOT + 24, totalH = TH + lanes.length*ROW + 8;
347
- let svg = '';
348
- for (let i=0;i<lanes.length;i++){ const yb = TH + i*ROW;
349
- if (i%2) svg += `<rect class="tlband" x="${GUT}" y="${yb}" width="${totalW-GUT}" height="${ROW}"/>`;
350
- svg += `<line class="tlsep" x1="${GUT}" y1="${yb}" x2="${totalW}" y2="${yb}"/>`;
351
- }
352
- // build-order time markers (every few cards, label that card's real timestamp) — scroll with the chart
353
- const step = Math.max(1, Math.round(cards.length/12));
354
- for (let i=0;i<cards.length;i+=step){ const xx = X(cards[i].col) + CW/2;
355
- svg += `<line class="tlgrid" x1="${xx}" y1="${TH}" x2="${xx}" y2="${totalH-6}"/>`;
356
- svg += `<text class="tlt" x="${xx}" y="15">${esc(new Date(cards[i].tStart).toLocaleString([], {month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'}))}</text>`;
357
- }
358
- for (const c of cards) for (const d of (c.t.deps||[])){ const A = byId[d]; if (!A) continue;
359
- const x1 = X(A.col)+CW, y1 = laneY(laneIdx[laneKey(A.t)])+BAR/2, x2 = X(c.col), y2 = laneY(laneIdx[laneKey(c.t)])+BAR/2, mx = (x1+x2)/2;
360
- svg += `<path class="tledge ${A.t.status==='done'?'done':''}" marker-end="url(#tarr)" d="M${x1},${y1} C${mx},${y1} ${mx},${y2} ${x2},${y2}"/>`;
361
- }
362
- for (const c of cards){ const xx = X(c.col), yy = laneY(laneIdx[laneKey(c.t)]), dur = Math.max(1, c.tEnd - c.tStart);
363
- svg += `<g class="tlnode ${c.t.status}" data-id="${c.t.id}"><title>${esc(c.t.title)} ${c.t.status} · @${esc(laneKey(c.t))}\n${esc(new Date(c.tStart).toLocaleString())}</title>`;
364
- let sx = xx;
365
- for (let i=0;i<c.h.length;i++){ const s0 = c.h[i].ts || c.tStart, s1 = (i+1<c.h.length) ? (c.h[i+1].ts||s0) : c.tEnd;
366
- const w = Math.max(1.5, (Math.max(0, s1-s0)/dur)*CW);
367
- svg += `<rect x="${sx.toFixed(1)}" y="${yy}" width="${w.toFixed(1)}" height="${BAR}" fill="${SCOL[c.h[i].to]||'#3a4458'}"/>`; sx += w;
389
+ const maxColCount = Math.max(1, ...layouts.flatMap(L => L.cols.map(c => c.length)));
390
+ const Y0 = MT + stackH(maxColCount)/2; // shared horizontal spine midline
391
+ const totalH = MT + stackH(maxColCount) + MB;
392
+ const gnode = (x, y, title, status, id, orch, agent) => {
393
+ const stripe = SCOL[status] || '#3a4458';
394
+ return `<g class="gnode ${status}${orch?' orch':''}"${id?` data-id="${id}"`:''}>`
395
+ + `<rect class="gnbox" x="${x}" y="${y}" width="${NW}" height="${NH}" rx="8"/>`
396
+ + `<rect x="${x}" y="${y}" width="4" height="${NH}" rx="2" fill="${stripe}"/>`
397
+ + `<text class="gntext" x="${x+11}" y="${y+(agent?15:23)}">${esc(title.slice(0,24))}${title.length>24?'…':''}</text>`
398
+ + (agent?`<text class="gnsub" x="${x+11}" y="${y+29}">@${esc(agent)}</text>`:'')
399
+ + `<title>${esc(title)}${agent?' · @'+esc(agent):''} — ${status}</title></g>`;
400
+ };
401
+ const gedge = (x1,y1,x2,y2,done) => { const mx=(x1+x2)/2;
402
+ return `<path class="gedge${done?' done':''}" d="M${x1},${y1} C${mx},${y1} ${mx},${y2} ${x2},${y2}"/>`; };
403
+ let x = 26, svg = '', bands = '', prevIntRight = null, pi = 0;
404
+ for (const L of layouts){
405
+ const { ph, cards, byId, parentsOf, hasChild, maxDepth, cols } = L;
406
+ const planX = x, planY = Y0 - NH/2;
407
+ const colX = d => planX + (d+1)*(NW+CG);
408
+ const intX = colX(maxDepth + 1), intY = Y0 - NH/2;
409
+ // per-phase background band (alternating) so each phase reads as one region; collected separately
410
+ // so all bands paint BEHIND the edges/nodes. The header sits inside the band's top strip.
411
+ const bx = planX - 16, bw = (intX + NW + 16) - bx;
412
+ bands += `<rect class="pfband ${pi%2?'alt':''}" x="${bx}" y="2" width="${bw}" height="${totalH-4}" rx="10"/>`
413
+ + `<rect class="pfbandtop ${ph.status}" x="${bx}" y="2" width="${bw}" height="${MT-12}" rx="10"/>`;
414
+ // header: phase label + the GOAL (explicit) or derived THEME "what this phase is about"
415
+ const desc = (ph.goal || ph.theme || '').slice(0, Math.max(28, Math.floor(bw/7)));
416
+ svg += `<text class="pflabel ${ph.status}" x="${planX}" y="${MT-38}">${esc(ph.label)}</text>`
417
+ + `<text class="pfcount" x="${planX}" y="${MT-38}" dx="${(esc(ph.label).length*9)+12}">${ph.status} · ${ph.total} card${ph.total>1?'s':''}</text>`
418
+ + `<text class="pfsub" x="${planX}" y="${MT-21}">${esc(desc)}</text>`;
419
+ if (prevIntRight != null) svg += `<path class="gspine" marker-end="url(#garr)" d="M${prevIntRight},${Y0} L${planX},${Y0}"/>`;
420
+ svg += gnode(planX, planY, '◇ '+ph.label+' · plan', ph.status, null, true, '');
421
+ // position every card: x by dep-depth column, y stacked + centered on the spine within its column
422
+ const pos = {};
423
+ cols.forEach((colCards, d) => {
424
+ const startY = Y0 - stackH(colCards.length)/2;
425
+ colCards.forEach((c, k) => { pos[c.id] = { x: colX(d), y: startY + k*(NH+RG) }; });
426
+ });
427
+ for (const c of cards){
428
+ const p = pos[c.id], parents = parentsOf(c);
429
+ if (parents.length) for (const pid of parents){ const pp = pos[pid]; if (pp) svg += gedge(pp.x+NW, pp.y+NH/2, p.x, p.y+NH/2, byId[pid].status==='done'); }
430
+ else svg += gedge(planX+NW, planY+NH/2, p.x, p.y+NH/2, ph.status==='done'); // root → plan
431
+ svg += gnode(p.x, p.y, (c.id?'#'+c.id+' ':'')+c.title, c.status, c.synthetic?null:c.id, !!c._orch, c.agent||'');
432
+ if (!hasChild(c)) svg += gedge(p.x+NW, p.y+NH/2, intX, intY+NH/2, c.status==='done'); // leaf → integrate
368
433
  }
369
- svg += `<rect class="tlbar" x="${xx}" y="${yy}" width="${CW}" height="${BAR}" rx="5"/>`;
370
- svg += `<text class="tltitle" x="${xx+7}" y="${yy+BAR/2+4}">${esc(c.t.title.slice(0,25))}${c.t.title.length>25?'…':''}</text>`;
371
- svg += `</g>`;
434
+ svg += gnode(intX, intY, '◆ integrate', ph.status, null, true, '');
435
+ prevIntRight = intX + NW;
436
+ x = intX + NW + PGAP;
437
+ pi++;
372
438
  }
373
- const defs = `<defs><marker id="tarr" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="6" markerHeight="6" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#14b8a6"/></marker></defs>`;
374
- let gut = `<div class="tlgut" style="width:${GUT}px"><div class="tlguth" style="height:${TH}px">time →</div>`;
375
- for (const k of lanes) gut += `<div class="tlgl" style="height:${ROW}px">${iconFor(k,13)}<span>${esc(k)}</span></div>`;
376
- gut += `</div>`;
377
- return `<div class="tlwrap" data-proj="${esc(proj)}" style="height:${Math.min(totalH+14, 540)}px">`
378
- + `<div class="tlscroll" data-proj="${esc(proj)}"><svg width="${totalW}" height="${totalH}">${defs}${svg}</svg></div>`
379
- + gut + `<div class="fhint">build order → · scroll left/right</div></div>`;
439
+ const totalW = x + 10;
440
+ const defs = `<defs><marker id="garr" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="6" markerHeight="6" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#46566f"/></marker></defs>`;
441
+ const notice = data.sparse ? `<div class="finfo">⚠ inferred phases — few cards carry explicit tags (P1/P5…), so they're grouped by time + title. Prefix cards (e.g. "P6 …") for sharper phases.</div>` : '';
442
+ return `<div class="pflowwrap gflow" data-proj="${esc(proj)}">${notice}`
443
+ + `<div class="gscroll" data-proj="${esc(proj)}"><svg width="${totalW}" height="${totalH}">${defs}${bands}${svg}</svg></div>`
444
+ + `<div class="fhint">orchestrator crew → integrate · phase by phase · scroll left/right →</div></div>`;
380
445
  }
446
+ const GSCROLL = {};
381
447
  function wireTimeline(el){
382
- el.querySelectorAll('.tlscroll').forEach(s => {
448
+ // FLOW v2 graph: click a card node to open its full /card detail; preserve horizontal scroll per project.
449
+ el.querySelectorAll('.gscroll').forEach(s => {
383
450
  const proj = s.dataset.proj;
384
- if (TLSCROLL[proj] != null) s.scrollLeft = TLSCROLL[proj];
385
- s.onscroll = () => { TLSCROLL[proj] = s.scrollLeft; };
451
+ if (GSCROLL[proj] != null) s.scrollLeft = GSCROLL[proj];
452
+ s.onscroll = () => { GSCROLL[proj] = s.scrollLeft; };
386
453
  });
387
- el.querySelectorAll('.tlnode').forEach(n => n.onclick = () => openCard(+n.dataset.id));
454
+ el.querySelectorAll('.gnode[data-id]').forEach(n => n.onclick = () => openCard(+n.dataset.id));
388
455
  }
389
456
  // Card detail modal: one card's FULL story — its status journey interleaved with the agent's own bus
390
457
  // reports (what it did, why, how). Click any card in the FLOW timeline to open it.
@@ -531,7 +598,10 @@ async function render(){
531
598
  HISTORY[p.project] = h.events || [];
532
599
  } catch(e) {}
533
600
  });
534
- await Promise.all(histP);
601
+ const phaseP = projects.filter(p => VIEWS[p.project] === "flow").map(async p => {
602
+ try { PHASES[p.project] = await (await fetch(`/phases?project=${encodeURIComponent(p.project)}`)).json(); } catch(e) {}
603
+ });
604
+ await Promise.all([...histP, ...phaseP]);
535
605
 
536
606
  // hub was reset (server empty but feed shows history) -> clear the stale client-side feed
537
607
  if(!msgs.length&&$('#feed').childElementCount>0){$('#feed').innerHTML='';nmsg=0;}
@@ -586,7 +656,7 @@ async function render(){
586
656
  const ph=p.phase||'';
587
657
  const brief=p.brief?`<span class="brief">${esc(p.brief)}</span>`:`<span class="brief dim">— no brief yet · an agent sets it with relay_project_brief</span>`;
588
658
  const view = VIEWS[p.project] || "board";
589
- const vtog = `<div class="vtog"><button class="vbtn ${view==="board"?"on":""}" data-proj="${esc(p.project)}" data-view="board">BOARD</button><button class="vbtn ${view==="flow"?"on":""}" data-proj="${esc(p.project)}" data-view="flow">FLOW</button><button class="vbtn ${view==="timeline"?"on":""}" data-proj="${esc(p.project)}" data-view="timeline">TIMELINE</button></div>`;
659
+ const vtog = `<div class="vtog"><span class="vlbl">view</span><button class="vbtn ${view==="board"?"on":""}" data-proj="${esc(p.project)}" data-view="board">BOARD</button><button class="vbtn ${view==="flow"?"on":""}" data-proj="${esc(p.project)}" data-view="flow">FLOW</button><button class="vbtn ${view==="timeline"?"on":""}" data-proj="${esc(p.project)}" data-view="timeline">TIMELINE</button></div>`;
590
660
  const ctl=`<span class="mv"><b class="mvup" data-proj="${esc(p.project)}" title="move up">▲</b><b class="mvdn" data-proj="${esc(p.project)}" title="move down">▼</b></span>${p.idle===true?`<span class="pdel" data-proj="${esc(p.project)}" title="forget this project (cards, peers, brief) — it returns if an agent registers it again">✕</span>`:''}`;
591
661
 
592
662
  return `<div class="proj${p.idle===true?' idle':''}" data-projname="${esc(p.project)}"${p.idle===true?` data-idleproj="${esc(p.project)}"`:''}>`+