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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/bin/catchup.mjs +56 -0
- package/bin/cli.mjs +2 -0
- package/bin/crew-runner.mjs +6 -1
- package/bin/crew.sh +5 -2
- package/hooks/handoff-now.mjs +21 -0
- package/hooks/heartbeat.mjs +53 -6
- package/hooks/lib/handoff.mjs +225 -0
- package/hooks/precompact.mjs +23 -95
- package/hooks/sessionstart.mjs +49 -9
- package/hub.mjs +174 -10
- package/lib/project.mjs +46 -0
- package/mcp.mjs +17 -6
- package/package.json +3 -2
- package/ui.html +131 -61
package/hooks/sessionstart.mjs
CHANGED
|
@@ -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
|
-
|
|
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));
|
|
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
|
-
|
|
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 =
|
|
79
|
+
const project = resolveProject(projectDir);
|
|
73
80
|
const session = process.env.RELAY_SESSION
|
|
74
|
-
|| (process.env.RELAY_AGENT ? `${process.env.RELAY_AGENT}:${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
|
-
|
|
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
|
|
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) });
|
package/lib/project.mjs
ADDED
|
@@ -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
|
-
|
|
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}` : `${
|
|
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.
|
|
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/",
|