@ra3orblade/swarm 0.4.1 → 0.5.0

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/README.md CHANGED
@@ -22,7 +22,7 @@ A local-first control plane for AI-agent development on any repository.</p>
22
22
  <a href="LICENSE"><img alt="Apache-2.0" src="https://img.shields.io/badge/license-Apache--2.0-lightgrey?labelColor=0e1013"></a>
23
23
  </p>
24
24
 
25
- <p align="center"><a href="https://getswarm.vercel.app"><img src="docs/art/screens/fleet.jpg" alt="Swarm Fleet view — every agent session on the machine, live" width="100%"></a></p>
25
+ <p align="center"><a href="https://getswarm.vercel.app"><img src="docs/art/screens/fleet.png" alt="Swarm Fleet view — every agent session on the machine, live" width="100%"></a></p>
26
26
 
27
27
  Run more than one [Claude Code](https://claude.com/claude-code) session at a time — or a [Codex CLI](https://github.com/openai/codex) or Grok run on the side — and you lose the thread fast: which session is on which branch, what it's costing, which worktree has uncommitted work nobody owns, why that edit got blocked. Swarm is one daemon that watches every session on your machine — live tool calls, reasoning, token spend, cost — keeps a ledger of who holds which task, worktree and runtime resource, turns the "never do X" prose in `CLAUDE.md` into real permission decisions, and streams all of it to one dashboard.
28
28
 
@@ -42,11 +42,11 @@ bunx @ra3orblade/swarm setup
42
42
 
43
43
  **Session** — an agent's reasoning and tool calls as a live stream, with cost per turn, cache hit rate, thinking share, tool histogram and the transcript path.
44
44
 
45
- <p align="center"><img src="docs/art/screens/session.jpg" alt="Session view — live reasoning stream with a stats panel" width="100%"></p>
45
+ <p align="center"><img src="docs/art/screens/session.png" alt="Session view — live reasoning stream with a stats panel" width="100%"></p>
46
46
 
47
47
  **Board** — the coordination ledger for a project: task **claims** (each in an isolated git worktree), **worktrees** with branch, dirty/unpushed state and which session is inside, **runtime resources** (ports, dev servers, databases held as named singletons), and **incidents** — every command the rules asked about or denied, with the rule and the command.
48
48
 
49
- <p align="center"><img src="docs/art/screens/board.jpg" alt="Board view — worktrees and incidents" width="100%"></p>
49
+ <p align="center"><img src="docs/art/screens/board.png" alt="Board view — worktrees and incidents" width="100%"></p>
50
50
 
51
51
  **Rules** — guardrails on the Bash commands a Claude Code session runs: `shared_tree`, `destructive_git`, `pattern_kill`, `protected_ports`, plus `no_foreign_worktree` and the opt-in `claim_required_to_write` on file writes; each `ask | deny | off` per repo in `.swarm.toml`. A `deny` is returned to Claude Code as a real permission denial. Ports held as resources are protected automatically. Guardrails against accidents, not a sandbox — see [what rules are and aren't](https://getswarm.vercel.app/docs/03-rules-and-config#what-rules-are--and-arent).
52
52
 
@@ -54,11 +54,11 @@ bunx @ra3orblade/swarm setup
54
54
 
55
55
  **Timeline** — session lanes per project, coloured by agent, 3–72 h.
56
56
 
57
- <p align="center"><img src="docs/art/screens/timeline.jpg" alt="Timeline view — session lanes per project" width="100%"></p>
57
+ <p align="center"><img src="docs/art/screens/timeline.png" alt="Timeline view — session lanes per project" width="100%"></p>
58
58
 
59
59
  **Spend & Stats** — cost by project, model and agent, today and all-time; plus the fun numbers: tokens, turns, streaks, activity calendar, words written, what it adds up to in novels and coffee.
60
60
 
61
- <p align="center"><img src="docs/art/screens/stats.jpg" alt="Stats view" width="100%"></p>
61
+ <p align="center"><img src="docs/art/screens/stats.png" alt="Stats view" width="100%"></p>
62
62
 
63
63
  **Multi-agent** — Claude Code via its hooks and transcripts; Codex CLI and Grok by tailing the session logs they already write (`~/.codex`, ACP `updates.jsonl`). Every session is tagged with its agent; Spend breaks down per agent.
64
64
 
package/dist/swarm-mcp.js CHANGED
@@ -19723,6 +19723,7 @@ async function ensureDaemon(opts = {}) {
19723
19723
  }
19724
19724
  // packages/mcp/src/server.ts
19725
19725
  var OWNER = process.env.SWARM_OWNER ?? "agent";
19726
+ var SESSION = process.env.CLAUDE_SESSION_ID ?? null;
19726
19727
  async function api2(path, init) {
19727
19728
  const base = await ensureDaemon({ quiet: true }).catch(() => resolveBaseUrl());
19728
19729
  const r = await fetch(`${base}${path}`, init);
@@ -19782,6 +19783,92 @@ function buildServer() {
19782
19783
  const n = ready[0];
19783
19784
  return ok(`next: ${n.id} \u2014 ${n.title} (claim it with swarm_claim)`, n);
19784
19785
  });
19786
+ server.registerTool("swarm_handoff", {
19787
+ title: "Leave a handoff",
19788
+ description: "Before stopping or releasing a task, record what was done, what remains (in order), files worth reading first, and how to verify. The next session that starts in this task's worktree receives it automatically as context; `swarm_resume` reads it on demand.",
19789
+ inputSchema: {
19790
+ task: exports_external.string(),
19791
+ done: exports_external.string().describe("what was finished"),
19792
+ remaining: exports_external.string().describe("what is left, in the order to do it"),
19793
+ files: exports_external.array(exports_external.string()).optional().describe("files touched or to read first"),
19794
+ verify: exports_external.string().optional().describe("how to verify the work so far")
19795
+ }
19796
+ }, async ({ task, done, remaining, files, verify }) => {
19797
+ const pid = await projectId();
19798
+ const r = await api2("/v1/handoffs", {
19799
+ method: "POST",
19800
+ headers: { "content-type": "application/json" },
19801
+ body: JSON.stringify({
19802
+ projectId: pid,
19803
+ task,
19804
+ done,
19805
+ remaining,
19806
+ files: files ?? [],
19807
+ verify: verify ?? null,
19808
+ by: OWNER,
19809
+ sessionId: SESSION
19810
+ })
19811
+ });
19812
+ if (!r.ok)
19813
+ return fail(`REFUSED: ${r.error}`);
19814
+ return ok(`handoff recorded on ${task}`, r.handoff);
19815
+ });
19816
+ server.registerTool("swarm_resume", {
19817
+ title: "Read the latest handoff",
19818
+ description: "The latest handoff on a task: done, remaining, files, verify. Read it before continuing someone else's work.",
19819
+ inputSchema: { task: exports_external.string() }
19820
+ }, async ({ task }) => {
19821
+ const pid = await projectId();
19822
+ const j = await api2(`/v1/handoffs?project=${pid}&task=${encodeURIComponent(task)}`);
19823
+ if (!j.text)
19824
+ return ok(`no handoff on ${task}`, null);
19825
+ return ok(j.text, j.handoff);
19826
+ });
19827
+ server.registerTool("swarm_gate_record", {
19828
+ title: "Record a verification gate",
19829
+ description: "Record the result of a verification gate (review, tests, security, \u2026) on a task. The rubric \u2014 what you actually checked \u2014 is required; a verdict without one is rejected. The latest run of a gate decides; failed runs stay on record and open an incident. Check `swarm_gates` for the gates this repo requires.",
19830
+ inputSchema: {
19831
+ task: exports_external.string().describe("task id, e.g. M1.2"),
19832
+ gate: exports_external.string().describe("gate name, e.g. review, tests, security"),
19833
+ verdict: exports_external.enum(["pass", "fail"]),
19834
+ rubric: exports_external.string().describe("what was checked, concretely"),
19835
+ evidence: exports_external.string().optional().describe("how: command output, PR link, notes")
19836
+ }
19837
+ }, async ({ task, gate, verdict, rubric, evidence }) => {
19838
+ const pid = await projectId();
19839
+ const r = await api2("/v1/gates", {
19840
+ method: "POST",
19841
+ headers: { "content-type": "application/json" },
19842
+ body: JSON.stringify({
19843
+ projectId: pid,
19844
+ task,
19845
+ gate,
19846
+ verdict,
19847
+ rubric,
19848
+ evidence,
19849
+ sessionId: SESSION
19850
+ })
19851
+ });
19852
+ if (!r.ok)
19853
+ return fail(`REFUSED: ${r.error}`);
19854
+ return ok(`recorded ${gate} ${verdict} on ${task}`, r.run);
19855
+ });
19856
+ server.registerTool("swarm_gates", {
19857
+ title: "Gate status",
19858
+ description: "The gates this repo requires (.swarm.toml [gates] required) and, for a task, the latest verdict per gate with run history. A task is done only when every required gate's latest run is a pass.",
19859
+ inputSchema: { task: exports_external.string().optional() }
19860
+ }, async ({ task }) => {
19861
+ const pid = await projectId();
19862
+ const q = new URLSearchParams({ project: pid });
19863
+ if (task)
19864
+ q.set("task", task);
19865
+ const g = await api2(`/v1/gates?${q}`);
19866
+ const head = g.required.length ? `required: ${g.required.join(", ")}` : "no required gates declared";
19867
+ const body = task ? (g.status ?? []).map((s) => `${s.gate}: ${s.verdict ?? "not run"}`).join(`
19868
+ `) || `no runs on ${task}` : `${g.runs.length} runs`;
19869
+ return ok(`${head}
19870
+ ${body}`, g);
19871
+ });
19785
19872
  server.registerTool("swarm_claim", {
19786
19873
  title: "Claim a task",
19787
19874
  description: "Claim a task and get an isolated git worktree to work in. Fails closed if another session holds it \u2014 pick another task or coordinate. cd into the returned worktree before editing.",
package/dist/swarm.js CHANGED
@@ -151,6 +151,45 @@ var WRITE_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
151
151
  var MARK = "swarm-hook";
152
152
  var isOurs = (h) => h.command.includes(MARK) || h.command.includes("/packages/hook/src/bin.ts");
153
153
  var settingsPath = () => process.env.CLAUDE_SETTINGS ?? join2(homedir2(), ".claude", "settings.json");
154
+ var claudeJsonPath = () => process.env.CLAUDE_JSON ?? join2(homedir2(), ".claude.json");
155
+ function loadClaudeJson() {
156
+ const p = claudeJsonPath();
157
+ if (!existsSync3(p))
158
+ return {};
159
+ try {
160
+ return JSON.parse(readFileSync2(p, "utf8"));
161
+ } catch {
162
+ return {};
163
+ }
164
+ }
165
+ function saveClaudeJson(c) {
166
+ writeFileSync2(claudeJsonPath(), `${JSON.stringify(c, null, 2)}
167
+ `);
168
+ }
169
+ function registerMcp() {
170
+ const c = loadClaudeJson();
171
+ const mcp = c.mcpServers ?? {};
172
+ mcp.swarm = { type: "stdio", ...mcpServerConfig() };
173
+ c.mcpServers = mcp;
174
+ saveClaudeJson(c);
175
+ }
176
+ function unregisterMcp() {
177
+ const c = loadClaudeJson();
178
+ const mcp = c.mcpServers ?? {};
179
+ if (!mcp.swarm)
180
+ return false;
181
+ delete mcp.swarm;
182
+ if (Object.keys(mcp).length)
183
+ c.mcpServers = mcp;
184
+ else
185
+ delete c.mcpServers;
186
+ saveClaudeJson(c);
187
+ return true;
188
+ }
189
+ function mcpRegistered() {
190
+ const c = loadClaudeJson();
191
+ return Boolean(c.mcpServers?.swarm);
192
+ }
154
193
  var hookCommand = (event) => `${binCommand("swarm-hook")} ${event}`;
155
194
  var shimPath = () => resolveBin("swarm-hook").at(-1);
156
195
  function mcpServerConfig() {
@@ -179,10 +218,16 @@ function install() {
179
218
  added.push(ev);
180
219
  }
181
220
  s.hooks = hooks2;
182
- const mcp = s.mcpServers ?? {};
183
- mcp.swarm = { type: "stdio", ...mcpServerConfig() };
184
- s.mcpServers = mcp;
221
+ const stale = s.mcpServers ?? {};
222
+ if (stale.swarm) {
223
+ delete stale.swarm;
224
+ if (Object.keys(stale).length)
225
+ s.mcpServers = stale;
226
+ else
227
+ delete s.mcpServers;
228
+ }
185
229
  save(s);
230
+ registerMcp();
186
231
  return added;
187
232
  }
188
233
  function uninstall() {
@@ -211,23 +256,22 @@ function uninstall() {
211
256
  else
212
257
  delete s.hooks;
213
258
  const mcp = s.mcpServers ?? {};
214
- if (mcp.swarm) {
259
+ if (mcp.swarm)
215
260
  delete mcp.swarm;
216
- removed++;
217
- }
218
261
  if (Object.keys(mcp).length)
219
262
  s.mcpServers = mcp;
220
263
  else
221
264
  delete s.mcpServers;
222
265
  save(s);
266
+ if (unregisterMcp())
267
+ removed++;
223
268
  return removed;
224
269
  }
225
270
  function status() {
226
271
  const s = load();
227
272
  const hooks2 = s.hooks ?? {};
228
273
  const installed = Object.values(hooks2).some((l) => l.some((g) => g.hooks.some(isOurs)));
229
- const mcp = Boolean(s.mcpServers?.swarm);
230
- return { installed, mcp, path: settingsPath(), shim: shimPath() };
274
+ return { installed, mcp: mcpRegistered(), path: settingsPath(), shim: shimPath() };
231
275
  }
232
276
 
233
277
  // packages/cli/src/procs.ts
@@ -348,6 +392,13 @@ var help = `swarm \u2014 control plane for AI-agent development
348
392
  renew <task> extend the lease; release <task> [--force] release + remove worktree
349
393
  claims list claims; reap release abandoned claims (keeps ones holding work)
350
394
  tasks [--ready] [--json] the repo's task source (.swarm.toml [tasks] source); --ready = claimable now
395
+ gate record <task> <gate> pass|fail --rubric "\u2026" [--evidence "\u2026"] record a verification run (rubric required)
396
+ gate ls [task] latest verdict per gate (and the run history for one task)
397
+ run --task <id> (--prompt "\u2026" | --prompt-file f) [--model m] [--permission-mode m] [--allowed-tools a,b] [--max-turns n]
398
+ claim the task and spawn claude -p in its worktree; the session shows in Fleet
399
+ run ls | send <task|id> "text" | stop <task|id> steer (stdin) or stop a spawned run, by pid never pattern
400
+ handoff <task> --done "\u2026" --remaining "\u2026" [--files a,b] [--verify "\u2026"] leave notes for the next holder
401
+ resume <task> print the latest handoff (the next session gets it automatically on start)
351
402
  res ls | acquire <name> [--owner n] [--pid n] [--port n] | release <name> [--force]
352
403
  named singletons (ports, processes); fail-closed
353
404
  serve start [--name web] [--from-port 3400 | --port n] -- <cmd>
@@ -546,6 +597,226 @@ url: ${resolveBaseUrl()}`);
546
597
  console.log(`${c.state.padEnd(9)} ${c.task.padEnd(16)} ${(c.owner || "").padEnd(12)} ${c.worktree}`);
547
598
  break;
548
599
  }
600
+ case "run": {
601
+ await ensureDaemon({ quiet: true });
602
+ const proj = await api("/v1/projects", {
603
+ method: "POST",
604
+ headers: { "content-type": "application/json" },
605
+ body: JSON.stringify({ path: resolve3(".") })
606
+ });
607
+ const base = new SwarmClient().baseUrl;
608
+ const valueFlags = new Set([
609
+ "--task",
610
+ "--prompt",
611
+ "--prompt-file",
612
+ "--model",
613
+ "--permission-mode",
614
+ "--allowed-tools",
615
+ "--max-turns",
616
+ "--owner"
617
+ ]);
618
+ const positionals = [];
619
+ for (let i = 0;i < rest.length; i++) {
620
+ const a = rest[i];
621
+ if (valueFlags.has(a))
622
+ i++;
623
+ else if (!a.startsWith("--"))
624
+ positionals.push(a);
625
+ }
626
+ const flag = (n) => {
627
+ const i = rest.indexOf(n);
628
+ return i >= 0 ? rest[i + 1] : undefined;
629
+ };
630
+ const sub = positionals[0];
631
+ if (sub === "ls") {
632
+ const runs = await api(`/v1/runs?project=${proj.id}`);
633
+ if (json)
634
+ console.log(JSON.stringify(runs));
635
+ else if (!runs.length)
636
+ console.log("no live runs here");
637
+ else
638
+ for (const r2 of runs)
639
+ console.log(`${r2.id} ${r2.task.padEnd(12)} pid ${String(r2.pid).padEnd(7)} ${r2.owner.padEnd(10)} ${r2.result ? `$${r2.result.costUsd.toFixed(2)} \xB7 ${r2.result.turns} turns${r2.result.isError ? " \xB7 error" : ""}` : "starting\u2026"}`);
640
+ break;
641
+ }
642
+ if (sub === "send" || sub === "stop") {
643
+ const target = positionals[1];
644
+ if (!target)
645
+ throw new Error(`usage: swarm run ${sub} <task|id>${sub === "send" ? ' "text"' : ""}`);
646
+ const r2 = sub === "send" ? await fetch(`${base}/v1/runs/${encodeURIComponent(target)}/send`, {
647
+ method: "POST",
648
+ headers: { "content-type": "application/json" },
649
+ body: JSON.stringify({ text: positionals.slice(2).join(" ") })
650
+ }) : await fetch(`${base}/v1/runs/${encodeURIComponent(target)}`, { method: "DELETE" });
651
+ const j = await r2.json();
652
+ if (json)
653
+ console.log(JSON.stringify(j));
654
+ else if (j.ok)
655
+ console.log(sub === "send" ? `sent to ${target}` : `stopped ${target}`);
656
+ else {
657
+ console.error(`REFUSED: ${j.error}`);
658
+ process.exit(1);
659
+ }
660
+ break;
661
+ }
662
+ const task = flag("--task") ?? sub;
663
+ let prompt = flag("--prompt");
664
+ const pf = flag("--prompt-file");
665
+ if (!prompt && pf)
666
+ prompt = await Bun.file(resolve3(pf)).text();
667
+ if (!task || !prompt)
668
+ throw new Error('usage: swarm run --task <id> --prompt "\u2026" | --prompt-file f [--model] [--permission-mode] [--allowed-tools a,b] [--max-turns n]');
669
+ const r = await fetch(`${base}/v1/runs`, {
670
+ method: "POST",
671
+ headers: { "content-type": "application/json" },
672
+ body: JSON.stringify({
673
+ projectId: proj.id,
674
+ task,
675
+ prompt,
676
+ owner: flag("--owner") ?? process.env.USER ?? "me",
677
+ model: flag("--model"),
678
+ permissionMode: flag("--permission-mode"),
679
+ allowedTools: flag("--allowed-tools")?.split(",").map((t) => t.trim()).filter(Boolean),
680
+ maxTurns: flag("--max-turns") ? Number(flag("--max-turns")) : undefined
681
+ })
682
+ }).then((x) => x.json());
683
+ if (json)
684
+ console.log(JSON.stringify(r));
685
+ else if (r.ok && r.run)
686
+ console.log(`run ${r.run.id} on ${task} (pid ${r.run.pid})
687
+ worktree: ${r.run.worktree}
688
+ session: ${r.run.sessionId}
689
+ log: ${r.run.log}
690
+ steer: swarm run send ${task} "\u2026" stop: swarm run stop ${task} watch: swarm tail --session ${r.run.sessionId}`);
691
+ else {
692
+ console.error(`REFUSED: ${r.error}`);
693
+ process.exit(1);
694
+ }
695
+ break;
696
+ }
697
+ case "handoff":
698
+ case "resume": {
699
+ await ensureDaemon({ quiet: true });
700
+ const task = arg();
701
+ if (!task)
702
+ throw new Error(`usage: swarm ${cmd} <task> \u2026`);
703
+ const proj = await api("/v1/projects", {
704
+ method: "POST",
705
+ headers: { "content-type": "application/json" },
706
+ body: JSON.stringify({ path: resolve3(".") })
707
+ });
708
+ if (cmd === "resume") {
709
+ const r2 = await fetch(`${new SwarmClient().baseUrl}/v1/handoffs?project=${proj.id}&task=${encodeURIComponent(task)}`);
710
+ const j = await r2.json();
711
+ if (json)
712
+ console.log(JSON.stringify(j.handoff));
713
+ else
714
+ console.log(j.text ?? `no handoff on ${task}`);
715
+ break;
716
+ }
717
+ const flag = (n) => {
718
+ const i = rest.indexOf(n);
719
+ return i >= 0 ? rest[i + 1] : undefined;
720
+ };
721
+ const r = await fetch(`${new SwarmClient().baseUrl}/v1/handoffs`, {
722
+ method: "POST",
723
+ headers: { "content-type": "application/json" },
724
+ body: JSON.stringify({
725
+ projectId: proj.id,
726
+ task,
727
+ done: flag("--done"),
728
+ remaining: flag("--remaining"),
729
+ files: (flag("--files") ?? "").split(",").map((f) => f.trim()).filter(Boolean),
730
+ verify: flag("--verify") ?? null,
731
+ by: flag("--by") ?? process.env.USER ?? null,
732
+ sessionId: process.env.CLAUDE_SESSION_ID ?? null
733
+ })
734
+ }).then((x) => x.json());
735
+ if (json)
736
+ console.log(JSON.stringify(r));
737
+ else if (r.ok)
738
+ console.log(`handoff recorded on ${task} \u2014 the next session in its worktree sees it on start`);
739
+ else {
740
+ console.error(`REFUSED: ${r.error}`);
741
+ process.exit(1);
742
+ }
743
+ break;
744
+ }
745
+ case "gate": {
746
+ await ensureDaemon({ quiet: true });
747
+ const proj = await api("/v1/projects", {
748
+ method: "POST",
749
+ headers: { "content-type": "application/json" },
750
+ body: JSON.stringify({ path: resolve3(".") })
751
+ });
752
+ const valueFlags = new Set(["--rubric", "--evidence"]);
753
+ const positionals = [];
754
+ for (let i = 0;i < rest.length; i++) {
755
+ const a = rest[i];
756
+ if (valueFlags.has(a))
757
+ i++;
758
+ else if (!a.startsWith("--"))
759
+ positionals.push(a);
760
+ }
761
+ const flag = (n) => {
762
+ const i = rest.indexOf(n);
763
+ return i >= 0 ? rest[i + 1] : undefined;
764
+ };
765
+ const sub = positionals[0] ?? "ls";
766
+ if (sub === "record") {
767
+ const [, task, gate, verdict] = positionals;
768
+ if (!task || !gate || !verdict)
769
+ throw new Error('usage: swarm gate record <task> <gate> pass|fail --rubric "what was checked" [--evidence "\u2026"]');
770
+ const r = await fetch(`${new SwarmClient().baseUrl}/v1/gates`, {
771
+ method: "POST",
772
+ headers: { "content-type": "application/json" },
773
+ body: JSON.stringify({
774
+ projectId: proj.id,
775
+ task,
776
+ gate,
777
+ verdict,
778
+ rubric: flag("--rubric"),
779
+ evidence: flag("--evidence"),
780
+ sessionId: process.env.CLAUDE_SESSION_ID ?? null
781
+ })
782
+ }).then((x) => x.json());
783
+ if (json)
784
+ console.log(JSON.stringify(r));
785
+ else if (r.ok)
786
+ console.log(`recorded ${gate} ${verdict} on ${task}`);
787
+ else {
788
+ console.error(`REFUSED: ${r.error}`);
789
+ process.exit(1);
790
+ }
791
+ break;
792
+ }
793
+ if (sub === "ls") {
794
+ const task = positionals[1];
795
+ const q = new URLSearchParams({ project: proj.id });
796
+ if (task)
797
+ q.set("task", task);
798
+ const g = await api(`/v1/gates?${q}`);
799
+ if (json)
800
+ console.log(JSON.stringify(g));
801
+ else if (task) {
802
+ if (!g.status?.length)
803
+ console.log(`no gates on ${task}${g.required.length ? ` (required: ${g.required.join(", ")})` : ""}`);
804
+ for (const st of g.status ?? [])
805
+ console.log(`${(st.verdict ?? "\u2014").padEnd(5)} ${st.gate.padEnd(12)} ${st.runs} run${st.runs === 1 ? "" : "s"}, ${st.fails} fail${st.fails === 1 ? "" : "s"}`);
806
+ for (const r of g.runs)
807
+ console.log(` ${r.createdAt.slice(0, 16)} ${r.gate.padEnd(12)} ${r.verdict.padEnd(5)} ${r.rubric.slice(0, 70)}`);
808
+ } else {
809
+ if (g.required.length)
810
+ console.log(`required: ${g.required.join(", ")}`);
811
+ if (!g.runs.length)
812
+ console.log("no gate runs yet");
813
+ for (const r of g.runs.slice(0, 50))
814
+ console.log(`${r.createdAt.slice(0, 16)} ${r.task.padEnd(10)} ${r.gate.padEnd(12)} ${r.verdict.padEnd(5)} ${r.rubric.slice(0, 60)}`);
815
+ }
816
+ break;
817
+ }
818
+ throw new Error("usage: swarm gate record|ls");
819
+ }
549
820
  case "tasks": {
550
821
  await ensureDaemon({ quiet: true });
551
822
  const proj = await api("/v1/projects", {