trantor 0.17.15 → 0.17.17

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.15"
9
+ "version": "0.17.17"
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.15",
16
+ "version": "0.17.17",
17
17
  "author": {
18
18
  "name": "Sasha Bogojevic"
19
19
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.17.15",
3
+ "version": "0.17.17",
4
4
  "description": "Trantor — the hub-world for AI agent crews: live message bus, presence, project Kanban/flow board + crew orchestration for independent AI coding agents (Claude, Codex, Gemini, Kimi, DeepSeek)",
5
5
  "mcpServers": {
6
6
  "relay": {
package/bin/cli.mjs CHANGED
@@ -26,6 +26,7 @@ switch (cmd) {
26
26
  case "hub": run("hub.mjs"); break;
27
27
  case "watch": run("bin/relay-watch.mjs"); break;
28
28
  case "catchup": run("bin/catchup.mjs"); break;
29
+ case "backfill": run("bin/git-backfill.mjs"); break;
29
30
  case "ui": {
30
31
  let url = "http://127.0.0.1:4477";
31
32
  try { url = JSON.parse(readFileSync(join(process.env.HOME || "", ".agent-bus", "config.json"), "utf8")).url || url; } catch {}
@@ -46,6 +47,7 @@ switch (cmd) {
46
47
  trantor down tear the crew down (kills processes, closes windows, no dialogs)
47
48
  trantor ui open the live dashboard (board + flow views)
48
49
  trantor catchup "where are we?" — the continuous board + git, with a synthesized brief
50
+ trantor backfill card past GIT work onto the board (solo commits that were never carded) — [--since "14 days ago"] [--dry-run]
49
51
  trantor advise ask the Advisor directly (JSON on stdin; --demo to see it)
50
52
  trantor hub run the hub in the foreground (setup installs it as a service instead)
51
53
  trantor watch live bus feed in the terminal
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env node
2
+ // trantor backfill — bridge GIT history → the board. Solo work that was committed but never carded
3
+ // (no crew, no TodoWrite) is invisible on the board; this turns it into done-cards so the project's
4
+ // living record reflects what actually happened. Commits are grouped by feature THEME (conventional-
5
+ // commit scope `feat(x):` → "x", or a "Prefix:" → the prefix), one done-card per theme placed at its
6
+ // latest commit time (so it slots into the FLOW timeline correctly). Idempotent: skips titles already
7
+ // on the board. Usage: trantor backfill [--since "14 days ago"] [--project <p>] [--dry-run]
8
+ import { execSync } from "node:child_process";
9
+ import { readFileSync, existsSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { homedir } from "node:os";
12
+ import { resolveProject, hostId } from "../lib/project.mjs";
13
+
14
+ function relayUrl() {
15
+ if (process.env.RELAY_URL) return process.env.RELAY_URL;
16
+ try { const c = join(homedir(), ".agent-bus", "config.json"); if (existsSync(c)) { const u = JSON.parse(readFileSync(c, "utf8")).url; if (u) return u; } } catch {}
17
+ return "http://127.0.0.1:4477";
18
+ }
19
+ const args = process.argv.slice(2);
20
+ const arg = (name, def) => { const i = args.indexOf("--" + name); return i >= 0 ? args[i + 1] : def; };
21
+ const dir = process.cwd();
22
+ const project = arg("project", resolveProject(dir));
23
+ const since = arg("since", "14 days ago");
24
+ const dry = args.includes("--dry-run");
25
+ const url = relayUrl();
26
+ const me = `${hostId()}:${project}`;
27
+
28
+ const themeOf = (s) => {
29
+ let m;
30
+ if ((m = s.match(/^[a-z]+\(([^)]+)\)\s*:/i))) return m[1].trim(); // feat(engine): → engine
31
+ if ((m = s.match(/^([A-Za-z][\w &+/.]*?)\s*:/))) return m[1].trim(); // "Landing: …" → Landing
32
+ if ((m = s.match(/^([A-Za-z][\w.+-]*)/))) return m[1]; // first word
33
+ return "misc";
34
+ };
35
+
36
+ let rows = [];
37
+ try {
38
+ rows = execSync(`git -C ${JSON.stringify(dir)} log --since=${JSON.stringify(since)} --format=%H%x09%ct%x09%s`,
39
+ { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 }).trim().split("\n").filter(Boolean);
40
+ } catch (e) { console.error(`git log failed: ${e.message}`); process.exit(1); }
41
+ if (!rows.length) { console.log(`no commits since "${since}" in ${dir}`); process.exit(0); }
42
+
43
+ const groups = new Map();
44
+ for (const r of rows) {
45
+ const [hash, ct, ...rest] = r.split("\t");
46
+ const subject = rest.join("\t");
47
+ const key = themeOf(subject).slice(0, 32);
48
+ if (!groups.has(key)) groups.set(key, { commits: [], latest: 0 });
49
+ const g = groups.get(key); const ms = Number(ct) * 1000;
50
+ g.commits.push({ hash, ts: ms, subject }); if (ms > g.latest) g.latest = ms;
51
+ }
52
+
53
+ let existing = new Set();
54
+ try { const t = (await (await fetch(`${url}/tasks?project=${encodeURIComponent(project)}`)).json()).tasks || []; existing = new Set(t.map(x => x.title)); } catch {}
55
+
56
+ const ents = [...groups.entries()].sort((a, b) => a[1].latest - b[1].latest);
57
+ let posted = 0, skipped = 0;
58
+ for (const [theme, g] of ents) {
59
+ const latest = g.commits.sort((a, b) => b.ts - a.ts)[0];
60
+ const subj = latest.subject.replace(/^[a-z]+\([^)]*\)\s*:\s*/i, "").replace(/^[A-Za-z][\w &+/.]*?:\s*/, "").slice(0, 70);
61
+ const title = `${theme}: ${subj}${g.commits.length > 1 ? ` (+${g.commits.length - 1} more)` : ""}`.slice(0, 190);
62
+ if (existing.has(title)) { skipped++; continue; }
63
+ if (dry) { console.log(`+ [${new Date(g.latest).toISOString().slice(0, 10)}] ${theme.padEnd(20)} ${g.commits.length}c ${title.slice(0, 64)}`); posted++; continue; }
64
+ try {
65
+ await fetch(`${url}/task`, { method: "POST", headers: { "content-type": "application/json" },
66
+ body: JSON.stringify({ project, title, status: "done", phase: theme, source: "git", ts: g.latest, assignee: me, by: me }) });
67
+ posted++;
68
+ } catch (e) { console.error(`post failed for "${title}": ${e.message}`); }
69
+ }
70
+ console.log(`${dry ? "[dry-run] " : ""}backfill: ${posted} theme-card(s) from ${rows.length} commits / ${groups.size} themes (${skipped} already on board) → ${project}`);
package/hub.mjs CHANGED
@@ -257,14 +257,17 @@ const server = http.createServer(async (req, res) => {
257
257
  if (req.method === "POST" && P === "/task") { // create a card
258
258
  const b = await body(req); touch(b.by, undefined, b.project);
259
259
  const st0 = ["todo","doing","testing","failed","done","blocked"].includes(b.status) ? b.status : "todo";
260
+ // optional historical ts (backfill from git/import) — accept a past epoch-ms; else now().
261
+ const ts0 = (Number.isFinite(b.ts) && b.ts > 0 && b.ts <= now() + 864e5) ? Math.floor(b.ts) : now();
260
262
  const t = { id: ++state.taskSeq, project: canon(String(b.project || "").slice(0,80)), title: String(b.title||"").slice(0,200),
261
263
  assignee: b.assignee || "", status: st0,
262
264
  phase: String(b.phase || "").slice(0, 40), // explicit phase tag (FLOW v2) — wins over title-prefix inference
265
+ source: String(b.source || "").slice(0, 20), // e.g. "git" (backfill), "todo" — provenance
263
266
  difficulty: ["easy","medium","hard"].includes(b.difficulty) ? b.difficulty : "",
264
267
  model: String(b.model || "").slice(0, 60),
265
268
  deps: Array.isArray(b.deps) ? [...new Set(b.deps.map(Number).filter(n => Number.isInteger(n) && n > 0))].slice(0, 20) : [],
266
- by: b.by || "", ts: now(), updated: now(),
267
- history: [{ to: st0, by: b.by || "", ts: now() }] };
269
+ by: b.by || "", ts: ts0, updated: ts0,
270
+ history: [{ to: st0, by: b.by || "", ts: ts0 }] };
268
271
  state.tasks.push(t); if (state.tasks.length > 2000) state.tasks.splice(0, 500);
269
272
  appendCardEvent("created", t, b.by, null, st0);
270
273
  dirty = true; return json(res, 200, { ok: true, task: t });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.17.15",
3
+ "version": "0.17.17",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"
package/ui.html CHANGED
@@ -198,6 +198,12 @@ main:not(.learn-open) .learn-body{display:none}
198
198
  .pfcount{fill:var(--mut);font-size:10.5px;font-family:ui-sans-serif,system-ui}
199
199
  .pfsub{fill:#9fb0c4;font-size:10.5px;font-family:ui-sans-serif,system-ui}
200
200
  .pflowwrap .fhint{position:absolute;right:12px;bottom:5px}
201
+ /* mini-map scrubber: whole project compressed to a bar; click/drag to jump, .gmapview shows position */
202
+ .gmap{position:relative;height:18px;margin:10px 16px 4px;border-radius:6px;background:#0c1320;border:1px solid var(--line);overflow:hidden;cursor:pointer;touch-action:none}
203
+ .gmapseg{position:absolute;top:2px;bottom:2px;border-radius:2px;opacity:.5}
204
+ .gmapseg.done{background:var(--grn2)}.gmapseg.active{background:#f0b24b}.gmapseg.failed,.gmapseg.blocked{background:var(--red)}.gmapseg.planned{background:#46566f}
205
+ .gmap:hover .gmapseg{opacity:.7}
206
+ .gmapview{position:absolute;top:-1px;bottom:-1px;min-width:8px;background:rgba(255,255,255,.14);border:1.5px solid rgba(255,255,255,.6);border-radius:5px;pointer-events:none;transition:left .04s linear}
201
207
  /* card detail modal — the full story of one card: status journey + the agent's own bus reports */
202
208
  .cmodal{position:fixed;inset:0;z-index:60;display:flex;align-items:center;justify-content:center;background:rgba(4,7,12,.62)}
203
209
  .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)}
@@ -400,7 +406,7 @@ function flowHTML(pt, proj){
400
406
  };
401
407
  const gedge = (x1,y1,x2,y2,done) => { const mx=(x1+x2)/2;
402
408
  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;
409
+ let x = 26, svg = '', bands = '', segs = [], prevIntRight = null, pi = 0;
404
410
  for (const L of layouts){
405
411
  const { ph, cards, byId, parentsOf, hasChild, maxDepth, cols } = L;
406
412
  const planX = x, planY = Y0 - NH/2;
@@ -411,6 +417,7 @@ function flowHTML(pt, proj){
411
417
  const bx = planX - 16, bw = (intX + NW + 16) - bx;
412
418
  bands += `<rect class="pfband ${pi%2?'alt':''}" x="${bx}" y="2" width="${bw}" height="${totalH-4}" rx="10"/>`
413
419
  + `<rect class="pfbandtop ${ph.status}" x="${bx}" y="2" width="${bw}" height="${MT-12}" rx="10"/>`;
420
+ segs.push({ label: ph.label, status: ph.status, total: ph.total, x0: bx, cx: planX, x1: bx + bw });
414
421
  // header: phase label + the GOAL (explicit) or derived THEME — "what this phase is about"
415
422
  const desc = (ph.goal || ph.theme || '').slice(0, Math.max(28, Math.floor(bw/7)));
416
423
  svg += `<text class="pflabel ${ph.status}" x="${planX}" y="${MT-38}">${esc(ph.label)}</text>`
@@ -439,17 +446,50 @@ function flowHTML(pt, proj){
439
446
  const totalW = x + 10;
440
447
  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
448
  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}`
449
+ // mini-map / scrubber: each phase a status-colored segment (% of total width). Click or drag to
450
+ // jump anywhere; the .gmapview overlay tracks where you are. data-total lets the JS map px↔ratio.
451
+ const pct = v => (100 * v / totalW).toFixed(3);
452
+ const mapsegs = segs.map(s =>
453
+ `<div class="gmapseg ${s.status}" style="left:${pct(s.x0)}%;width:${pct(s.x1 - s.x0)}%" data-cx="${s.cx}" title="${esc(s.label)} · ${s.status} · ${s.total} card${s.total>1?'s':''}"></div>`).join('');
454
+ const map = `<div class="gmap" data-proj="${esc(proj)}" data-total="${totalW}">${mapsegs}<div class="gmapview"></div></div>`;
455
+ return `<div class="pflowwrap gflow" data-proj="${esc(proj)}">${notice}${map}`
443
456
  + `<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>`;
457
+ + `<div class="fhint">orchestrator → crew → integrate · phase by phase · click/drag the bar above to scrub →</div></div>`;
445
458
  }
446
459
  const GSCROLL = {};
447
460
  function wireTimeline(el){
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 => {
450
- const proj = s.dataset.proj;
461
+ // FLOW v2 graph: scroll-position scrubber (mini-map) + click a card node for its /card detail.
462
+ el.querySelectorAll('.gflow').forEach(wrap => {
463
+ const proj = wrap.dataset.proj;
464
+ const s = wrap.querySelector('.gscroll'), map = wrap.querySelector('.gmap'), view = map && map.querySelector('.gmapview');
465
+ if (!s) return;
451
466
  if (GSCROLL[proj] != null) s.scrollLeft = GSCROLL[proj];
452
- s.onscroll = () => { GSCROLL[proj] = s.scrollLeft; };
467
+ const syncView = () => { // reflect where we are onto the mini-map
468
+ if (!view) return;
469
+ const w = s.scrollWidth || 1;
470
+ view.style.left = (100 * s.scrollLeft / w) + '%';
471
+ view.style.width = Math.min(100, 100 * s.clientWidth / w) + '%';
472
+ };
473
+ s.onscroll = () => { GSCROLL[proj] = s.scrollLeft; syncView(); };
474
+ requestAnimationFrame(syncView);
475
+ if (map) {
476
+ const scrubTo = (clientX) => { // map a pointer x onto a scroll position (centered)
477
+ const r = map.getBoundingClientRect();
478
+ const ratio = Math.max(0, Math.min(1, (clientX - r.left) / r.width));
479
+ s.scrollLeft = ratio * s.scrollWidth - s.clientWidth / 2;
480
+ };
481
+ let dragging = false;
482
+ map.addEventListener('pointerdown', e => {
483
+ dragging = true;
484
+ try { map.setPointerCapture?.(e.pointerId); } catch {}
485
+ const seg = e.target.closest && e.target.closest('.gmapseg'); // click a phase segment → jump to that phase
486
+ if (seg && seg.dataset.cx != null) s.scrollLeft = +seg.dataset.cx - 40; else scrubTo(e.clientX);
487
+ e.preventDefault();
488
+ });
489
+ map.addEventListener('pointermove', e => { if (dragging) scrubTo(e.clientX); });
490
+ const end = () => { dragging = false; };
491
+ map.addEventListener('pointerup', end); map.addEventListener('pointercancel', end);
492
+ }
453
493
  });
454
494
  el.querySelectorAll('.gnode[data-id]').forEach(n => n.onclick = () => openCard(+n.dataset.id));
455
495
  }