cyberfleet 0.0.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.
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+ import { dirname, join } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+
5
+ const dir = dirname(fileURLToPath(import.meta.url))
6
+ await import(join(dir, '..', 'dist', 'cli.mjs'))
package/dist/cli.mjs ADDED
@@ -0,0 +1,1155 @@
1
+ #!/usr/bin/env node
2
+ import { execFileSync } from "node:child_process";
3
+ import { Command } from "commander";
4
+ import { randomBytes } from "node:crypto";
5
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from "node:fs";
6
+ import { dirname, join, resolve } from "node:path";
7
+ //#region src/console/worktree.ts
8
+ /** The only worktree backend at MVP — plain `git worktree`. */
9
+ const gitWorktreeAdapter = {
10
+ add(exec, opts) {
11
+ if (exec("git", [
12
+ "-C",
13
+ opts.primaryRoot,
14
+ "worktree",
15
+ "add",
16
+ "-b",
17
+ opts.branch,
18
+ opts.path
19
+ ]) === null) throw new Error(`git worktree add failed for ${opts.path}`);
20
+ return {
21
+ root: resolve(opts.path),
22
+ branch: opts.branch
23
+ };
24
+ },
25
+ remove(exec, path, opts) {
26
+ if (exec("git", [
27
+ "-C",
28
+ opts.primaryRoot,
29
+ "worktree",
30
+ "remove",
31
+ path,
32
+ "--force"
33
+ ]) === null) throw new Error(`git worktree remove failed for ${path}`);
34
+ }
35
+ };
36
+ /**
37
+ * Resolve the primary checkout's root regardless of whether the caller's cwd is the primary
38
+ * checkout or a linked worktree — `--git-common-dir` always points at the main repo's `.git`.
39
+ */
40
+ function resolvePrimaryRoot(exec) {
41
+ const commonDir = exec("git", [
42
+ "rev-parse",
43
+ "--path-format=absolute",
44
+ "--git-common-dir"
45
+ ]);
46
+ if (!commonDir) throw new Error("cannot resolve the primary checkout — not inside a git repository");
47
+ return dirname(commonDir);
48
+ }
49
+ /**
50
+ * The flagship rule: refuse when a ship's resolved worktree root would be the primary checkout
51
+ * itself. Mirrors firstmate's "refuse unless the path is a real worktree root distinct from the
52
+ * project primary checkout" — a mission is never run in the primary checkout.
53
+ */
54
+ function assertDistinctFromPrimary(worktreeRoot, primaryRoot) {
55
+ if (resolve(worktreeRoot) === resolve(primaryRoot)) throw new Error("refusing to run a mission in the primary checkout — spawn a worktree-ship distinct from the flagship");
56
+ }
57
+ //#endregion
58
+ //#region src/paths.ts
59
+ /** The tracked marker file inside `.cyberfleet/` that makes a project root a ship (see detectMode). */
60
+ const MARKER_FILE = "config.json";
61
+ /** Walk up from `cwd` to the nearest git repo root; fall back to `cwd`. */
62
+ function projectRoot(cwd = process.cwd()) {
63
+ let dir = resolve(cwd);
64
+ for (;;) {
65
+ if (existsSync(join(dir, ".git"))) return dir;
66
+ const parent = dirname(dir);
67
+ if (parent === dir) return resolve(cwd);
68
+ dir = parent;
69
+ }
70
+ }
71
+ function makeDefaultExec(cwd) {
72
+ return (cmd, args) => {
73
+ try {
74
+ return execFileSync(cmd, args, {
75
+ encoding: "utf8",
76
+ stdio: [
77
+ "ignore",
78
+ "pipe",
79
+ "ignore"
80
+ ],
81
+ cwd
82
+ }).trim();
83
+ } catch {
84
+ return null;
85
+ }
86
+ };
87
+ }
88
+ /**
89
+ * Resolve the shared `.cyberfleet` transport root. Precedence: explicit --root/--space, then
90
+ * CYBERFLEET_ROOT, then the PRIMARY checkout's `.cyberfleet` (ADR-0022 decision 10 — the primary
91
+ * and every worktree are both ships, but runtime state — agents/inbox/panes — stays pinned to the
92
+ * primary so it is shared fleet-wide instead of forking per worktree), falling back to the
93
+ * caller's own project root when not inside a git repository.
94
+ */
95
+ function resolveRoot(opts = {}) {
96
+ const env = opts.env ?? process.env;
97
+ const explicit = opts.root ?? opts.space ?? env.CYBERFLEET_ROOT;
98
+ if (explicit) return resolve(explicit);
99
+ const exec = opts.exec ?? makeDefaultExec(opts.cwd);
100
+ let primary;
101
+ try {
102
+ primary = resolvePrimaryRoot(exec);
103
+ } catch {
104
+ primary = void 0;
105
+ }
106
+ return join(primary ?? projectRoot(opts.cwd), ".cyberfleet");
107
+ }
108
+ /**
109
+ * Detect ship vs. command-center by the tracked `.cyberfleet/config.json` marker's presence alone
110
+ * at this project root — no check against `.agents/specs` or any other SDD state. The marker is
111
+ * tracked (`.gitignore` excludes the rest of `.cyberfleet/`), so it travels to every worktree: the
112
+ * primary checkout AND every worktree it spawns are both ships once the marker exists. Checking
113
+ * the marker file rather than just the directory avoids a false positive from a stray empty
114
+ * `.cyberfleet/` dir (e.g. one left behind after a partial teardown).
115
+ */
116
+ function detectMode(opts = {}) {
117
+ const cwdRoot = projectRoot(opts.cwd);
118
+ return {
119
+ mode: existsSync(join(cwdRoot, ".cyberfleet", MARKER_FILE)) ? "ship" : "command-center",
120
+ cwdRoot,
121
+ fleetRoot: resolveRoot(opts)
122
+ };
123
+ }
124
+ /**
125
+ * Create the tracked `.cyberfleet/config.json` marker at `root` (a fleet root, i.e. a
126
+ * `.cyberfleet/` dir path) if it does not already exist, making that project root a ship. Callers
127
+ * pass the root under which `.cyberfleet/` should be created/ensured; this mkdir's it as needed.
128
+ * Idempotent — never overwrites an existing marker.
129
+ */
130
+ function ensureFleetMarker(root) {
131
+ mkdirSync(root, { recursive: true });
132
+ const marker = join(root, MARKER_FILE);
133
+ if (!existsSync(marker)) writeFileSync(marker, `${JSON.stringify({ version: 1 }, null, 2)}\n`);
134
+ }
135
+ const paths = {
136
+ agentsDir: (root) => join(root, "agents"),
137
+ agentFile: (root, id) => join(root, "agents", `${id}.json`),
138
+ panesDir: (root) => join(root, "panes"),
139
+ paneFile: (root, pane) => join(root, "panes", `${sanitizePane(pane)}.id`),
140
+ selfFile: (root) => join(root, "self"),
141
+ inboxDir: (root, id) => join(root, "inbox", id),
142
+ inboxReadDir: (root, id) => join(root, "inbox", id, "read"),
143
+ dataDir: (root, id) => join(root, "data", id),
144
+ briefFile: (root, id) => join(root, "data", id, "brief.md"),
145
+ worktreeDir: (root, id) => join(root, "worktrees", id)
146
+ };
147
+ /** tmux pane ids look like "%3"; make them filesystem-safe. */
148
+ function sanitizePane(pane) {
149
+ return pane.replace(/[^A-Za-z0-9_-]/g, "_");
150
+ }
151
+ //#endregion
152
+ //#region src/identity.ts
153
+ const realExec = (cmd, args) => {
154
+ try {
155
+ return execFileSync(cmd, args, {
156
+ encoding: "utf8",
157
+ stdio: [
158
+ "ignore",
159
+ "pipe",
160
+ "ignore"
161
+ ]
162
+ }).trim();
163
+ } catch {
164
+ return null;
165
+ }
166
+ };
167
+ const nowIso = (ctx) => new Date(ctx.now?.() ?? Date.now()).toISOString();
168
+ function randomId() {
169
+ return randomBytes(8).toString("hex");
170
+ }
171
+ function writeJson$1(file, data) {
172
+ mkdirSync(dirname(file), { recursive: true });
173
+ writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`);
174
+ }
175
+ /** Detect the harness: explicit wins, then env probes, then the tmux pane command. */
176
+ function detectHarness(explicit, ctx) {
177
+ if (explicit) {
178
+ if (explicit === "claude" || explicit === "cursor" || explicit === "codex") return explicit;
179
+ throw new Error(`unknown --harness "${explicit}" (expected claude | cursor | codex)`);
180
+ }
181
+ const env = ctx.env ?? process.env;
182
+ if (env.CLAUDECODE || env.CLAUDE_CODE_ENTRYPOINT) return "claude";
183
+ if (Object.keys(env).some((k) => k.startsWith("CURSOR"))) return "cursor";
184
+ if (Object.keys(env).some((k) => k.startsWith("CODEX"))) return "codex";
185
+ const pane = env.TMUX_PANE;
186
+ if (pane) {
187
+ const cmd = (ctx.exec ?? realExec)("tmux", [
188
+ "display-message",
189
+ "-p",
190
+ "-t",
191
+ pane,
192
+ "#{pane_current_command}"
193
+ ]);
194
+ if (cmd?.includes("cursor")) return "cursor";
195
+ if (cmd?.includes("codex")) return "codex";
196
+ if (cmd?.includes("claude")) return "claude";
197
+ }
198
+ }
199
+ /**
200
+ * Recover the calling agent's own id. In tmux the pane file is authoritative — a pane with no
201
+ * pane file is simply unregistered and must NOT adopt the non-tmux self identity (per the frozen
202
+ * scenario, the env/self-file fallback applies only when there is no $TMUX_PANE).
203
+ */
204
+ function resolveSelfId(ctx) {
205
+ const env = ctx.env ?? process.env;
206
+ const pane = env.TMUX_PANE;
207
+ if (pane) {
208
+ const file = paths.paneFile(ctx.root, pane);
209
+ return existsSync(file) ? readFileSync(file, "utf8").trim() : void 0;
210
+ }
211
+ if (env.CYBERFLEET_AGENT_ID) return env.CYBERFLEET_AGENT_ID;
212
+ const self = paths.selfFile(ctx.root);
213
+ if (existsSync(self)) return readFileSync(self, "utf8").trim();
214
+ }
215
+ function loadAgent(root, id) {
216
+ const file = paths.agentFile(root, id);
217
+ if (!existsSync(file)) return void 0;
218
+ return JSON.parse(readFileSync(file, "utf8"));
219
+ }
220
+ function saveAgent(root, rec) {
221
+ writeJson$1(paths.agentFile(root, rec.id), rec);
222
+ }
223
+ function listAgents(root) {
224
+ const dir = paths.agentsDir(root);
225
+ if (!existsSync(dir)) return [];
226
+ return readdirSync(dir).filter((f) => f.endsWith(".json")).map((f) => JSON.parse(readFileSync(`${dir}/${f}`, "utf8"))).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
227
+ }
228
+ /** Register (or idempotently refresh) this session's identity. */
229
+ function register(ctx, input) {
230
+ ensureFleetMarker(ctx.root);
231
+ const env = ctx.env ?? process.env;
232
+ const harness = detectHarness(input.harness, ctx);
233
+ if (!harness) throw new Error("could not detect harness — pass --harness claude|cursor|codex");
234
+ const existingId = resolveSelfId(ctx);
235
+ const existing = existingId ? loadAgent(ctx.root, existingId) : void 0;
236
+ const id = existing?.id ?? existingId ?? randomId();
237
+ const ts = nowIso(ctx);
238
+ const pane = env.TMUX_PANE;
239
+ const exec = ctx.exec ?? realExec;
240
+ const rec = {
241
+ id,
242
+ handle: input.handle ?? existing?.handle ?? id.slice(0, 6),
243
+ harness,
244
+ cwd: process.cwd(),
245
+ worktree: existing?.worktree ?? gitWorktree(exec),
246
+ tmux: pane ? {
247
+ pane,
248
+ window: exec("tmux", [
249
+ "display-message",
250
+ "-p",
251
+ "-t",
252
+ pane,
253
+ "#{window_id}"
254
+ ]) ?? void 0,
255
+ session: env.TMUX?.split(",")[0]
256
+ } : null,
257
+ status: "active",
258
+ createdAt: existing?.createdAt ?? ts,
259
+ lastSeen: ts,
260
+ ...existing?.brief ? { brief: existing.brief } : {},
261
+ ...existing?.spawnedBy ? { spawnedBy: existing.spawnedBy } : {}
262
+ };
263
+ saveAgent(ctx.root, rec);
264
+ if (pane) writeFileText(paths.paneFile(ctx.root, pane), id);
265
+ else writeFileText(paths.selfFile(ctx.root), id);
266
+ return rec;
267
+ }
268
+ function gitWorktree(exec) {
269
+ const root = exec("git", ["rev-parse", "--show-toplevel"]);
270
+ if (!root) return null;
271
+ return {
272
+ root,
273
+ branch: exec("git", [
274
+ "rev-parse",
275
+ "--abbrev-ref",
276
+ "HEAD"
277
+ ]) ?? void 0
278
+ };
279
+ }
280
+ function writeFileText(file, text) {
281
+ mkdirSync(dirname(file), { recursive: true });
282
+ writeFileSync(file, text);
283
+ }
284
+ /** Resolve a recipient argument (id or handle) to an agent id. */
285
+ function resolveRecipient(root, to) {
286
+ if (loadAgent(root, to)) return to;
287
+ const match = listAgents(root).find((a) => a.handle === to);
288
+ if (!match) throw new Error(`no agent addressable as "${to}"`);
289
+ return match.id;
290
+ }
291
+ /**
292
+ * Resolve a ship reference by id, handle, or its worktree branch (the ship↔CR join key, ADR-0022
293
+ * decision 8 + the `add-fleet-comms` CR's convention: an `AgentRecord.worktree.branch` equals the
294
+ * SDD `<cr-ref>` it maps to). Used by verbs that address "the ship working on CR X" as well as
295
+ * "the ship named X".
296
+ */
297
+ function resolveShip(root, ref) {
298
+ const byId = loadAgent(root, ref);
299
+ if (byId) return byId;
300
+ const agents = listAgents(root);
301
+ const byHandle = agents.find((a) => a.handle === ref);
302
+ if (byHandle) return byHandle;
303
+ const byBranch = agents.find((a) => a.worktree?.branch === ref);
304
+ if (byBranch) return byBranch;
305
+ throw new Error(`no ship addressable as "${ref}" (tried id, handle, and worktree branch/CR)`);
306
+ }
307
+ /**
308
+ * Pause a ship's mission — a cyberfleet-level marker on its `AgentRecord.status` only. This is
309
+ * NOT a bridge to SDD's `pause-mission` checkpoint (which rewrites the plan brief's todos/NEXT
310
+ * anchor) — that gap is flagged, not silently papered over: a caller who wants the actual mission
311
+ * checkpoint must run `sdd:pause-mission` in-session.
312
+ */
313
+ function pauseAgent(root, id) {
314
+ const rec = loadAgent(root, id);
315
+ if (!rec) throw new Error(`no agent "${id}"`);
316
+ rec.status = "paused";
317
+ saveAgent(root, rec);
318
+ return rec;
319
+ }
320
+ function bumpLastSeen(ctx, id) {
321
+ const rec = loadAgent(ctx.root, id);
322
+ if (!rec) return;
323
+ rec.lastSeen = nowIso(ctx);
324
+ saveAgent(ctx.root, rec);
325
+ }
326
+ /** Refresh this session's own last-seen if it is registered — best-effort, never throws. */
327
+ function touch(ctx) {
328
+ const id = resolveSelfId(ctx);
329
+ if (id) bumpLastSeen(ctx, id);
330
+ }
331
+ const STALE_MS = 900 * 1e3;
332
+ /** Mark agents whose pane is gone or whose last-seen is stale as exited. */
333
+ function prune(ctx) {
334
+ const exec = ctx.exec ?? realExec;
335
+ const now = ctx.now?.() ?? Date.now();
336
+ const changed = [];
337
+ for (const rec of listAgents(ctx.root)) {
338
+ if (rec.status === "exited") continue;
339
+ const paneGone = rec.tmux?.pane ? exec("tmux", [
340
+ "has-session",
341
+ "-t",
342
+ rec.tmux.pane
343
+ ]) === null && !(exec("tmux", [
344
+ "list-panes",
345
+ "-a",
346
+ "-F",
347
+ "#{pane_id}"
348
+ ]) ?? "").split("\n").includes(rec.tmux.pane) : false;
349
+ const stale = now - new Date(rec.lastSeen).getTime() > STALE_MS;
350
+ if (paneGone || stale) {
351
+ rec.status = "exited";
352
+ saveAgent(ctx.root, rec);
353
+ changed.push(rec);
354
+ }
355
+ }
356
+ return changed;
357
+ }
358
+ //#endregion
359
+ //#region src/install.ts
360
+ const hookCommand = (event) => `cyberfleet inbox --hook --event ${event}`;
361
+ const VENDORS = {
362
+ claude: {
363
+ file: ".claude/settings.json",
364
+ shape: "claude",
365
+ events: {
366
+ SessionStart: "SessionStart",
367
+ PostToolUse: "PostToolUse"
368
+ }
369
+ },
370
+ cursor: {
371
+ file: ".cursor/hooks.json",
372
+ shape: "cursor",
373
+ events: { SessionStart: "sessionStart" }
374
+ },
375
+ codex: {
376
+ file: ".codex/hooks.json",
377
+ shape: "cursor",
378
+ events: {
379
+ SessionStart: "SessionStart",
380
+ PostToolUse: "PostToolUse"
381
+ }
382
+ }
383
+ };
384
+ function readJson(file) {
385
+ if (!existsSync(file)) return {};
386
+ try {
387
+ return JSON.parse(readFileSync(file, "utf8"));
388
+ } catch {
389
+ return {};
390
+ }
391
+ }
392
+ function writeJson(file, data) {
393
+ mkdirSync(dirname(file), { recursive: true });
394
+ writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`);
395
+ }
396
+ /** Register the surfacing hook into one harness's config, idempotently. */
397
+ function install(harness, projectDir = process.cwd()) {
398
+ const spec = VENDORS[harness];
399
+ if (!spec) throw new Error(`unknown harness "${harness}" (expected claude | cursor | codex)`);
400
+ const file = join(projectDir, spec.file);
401
+ const settings = readJson(file);
402
+ const results = [];
403
+ for (const [canonical, vendorEvent] of Object.entries(spec.events)) {
404
+ const command = hookCommand(canonical);
405
+ const status = spec.shape === "claude" ? upsertClaude(settings, vendorEvent, command) : upsertCursor(settings, vendorEvent, command);
406
+ results.push({
407
+ harness,
408
+ event: canonical,
409
+ vendorEvent,
410
+ file,
411
+ status
412
+ });
413
+ }
414
+ writeJson(file, settings);
415
+ return results;
416
+ }
417
+ function upsertClaude(settings, event, command) {
418
+ const hooks = settings.hooks ??= {};
419
+ const groups = hooks[event] ??= [];
420
+ if (groups.some((g) => g.hooks?.some((h) => h.command === command))) return "already present";
421
+ const group = { hooks: [{
422
+ type: "command",
423
+ command
424
+ }] };
425
+ if (event === "PostToolUse") group.matcher = "Write|Edit";
426
+ groups.push(group);
427
+ return "registered";
428
+ }
429
+ function upsertCursor(settings, event, command) {
430
+ if (settings.version == null) settings.version = 1;
431
+ const hooks = settings.hooks ??= {};
432
+ const list = hooks[event] ??= [];
433
+ if (list.some((h) => h.command === command)) return "already present";
434
+ list.push({ command });
435
+ return "registered";
436
+ }
437
+ //#endregion
438
+ //#region src/message.ts
439
+ /** Write one message file into the recipient's inbox. Collision-free by <epochMs>-<hex>. */
440
+ function send(ctx, input) {
441
+ const toId = resolveRecipient(ctx.root, input.to);
442
+ const from = loadAgent(ctx.root, input.fromId);
443
+ const ts = ctx.now?.() ?? Date.now();
444
+ const id = `${ts}-${randomBytes(3).toString("hex")}`;
445
+ const msg = {
446
+ id,
447
+ from: input.fromId,
448
+ fromHandle: from?.handle ?? input.fromId,
449
+ to: toId,
450
+ ...input.subject ? { subject: input.subject } : {},
451
+ body: input.body,
452
+ ...input.thread ? { thread: input.thread } : {},
453
+ ...input.replyTo ? { replyTo: input.replyTo } : {},
454
+ ts,
455
+ sentAt: new Date(ts).toISOString()
456
+ };
457
+ const dir = paths.inboxDir(ctx.root, toId);
458
+ mkdirSync(dir, { recursive: true });
459
+ writeFileSync(join(dir, `${id}.json`), `${JSON.stringify(msg, null, 2)}\n`);
460
+ return msg;
461
+ }
462
+ /** Resolve a message body from the --body flag, a --body-file path, or stdin (--body-file -). */
463
+ function resolveBody(body, bodyFile, readStdin = () => readFileSync(0, "utf8")) {
464
+ if (body != null) return body;
465
+ if (bodyFile) return bodyFile === "-" ? readStdin() : readFileSync(bodyFile, "utf8");
466
+ throw new Error("provide --body <text> or --body-file <path|->");
467
+ }
468
+ function readDir(dir) {
469
+ if (!existsSync(dir)) return [];
470
+ return readdirSync(dir).filter((f) => f.endsWith(".json")).map((f) => JSON.parse(readFileSync(join(dir, f), "utf8")));
471
+ }
472
+ /** List the caller's mail, chronological (lexical filename sort == time order). */
473
+ function inbox(ctx, q) {
474
+ const unread = readDir(paths.inboxDir(ctx.root, q.meId)).map((m) => ({
475
+ ...m,
476
+ read: false
477
+ }));
478
+ const acked = q.unread ? [] : readDir(paths.inboxReadDir(ctx.root, q.meId)).map((m) => ({
479
+ ...m,
480
+ read: true
481
+ }));
482
+ let items = [...unread, ...acked];
483
+ if (q.from) items = items.filter((m) => m.from === q.from || m.fromHandle === q.from);
484
+ return items.sort((a, b) => a.id.localeCompare(b.id));
485
+ }
486
+ /** Print + ack a message by moving it out of the unread set. Errors if not unread. */
487
+ function read(ctx, meId, msgId) {
488
+ const src = join(paths.inboxDir(ctx.root, meId), `${msgId}.json`);
489
+ if (!existsSync(src)) throw new Error(`"${msgId}" is not an unread message in this inbox`);
490
+ const msg = JSON.parse(readFileSync(src, "utf8"));
491
+ const readDirPath = paths.inboxReadDir(ctx.root, meId);
492
+ mkdirSync(readDirPath, { recursive: true });
493
+ renameSync(src, join(readDirPath, `${msgId}.json`));
494
+ return msg;
495
+ }
496
+ //#endregion
497
+ //#region src/sdd/hal.ts
498
+ /**
499
+ * Infer whether a CR's persisted gate lines show a self-assertion the run-level leash did not
500
+ * cover.
501
+ *
502
+ * Truth table (leash × any self-asserted `by: "agent"` gate):
503
+ *
504
+ * | leash | spec gate by:agent | impl gate by:agent | aboveLeash |
505
+ * |--------------|---------------------|----------------------|------------|
506
+ * | auto-none | — | — | false |
507
+ * | auto-none | yes | — | **true** |
508
+ * | auto-none | — | yes | **true** |
509
+ * | auto-spec | yes | — | false |
510
+ * | auto-spec | — | yes | **true** |
511
+ * | auto-all | yes | yes | false |
512
+ * | (no leash line recorded) | —/yes | —/yes | false (cannot infer) |
513
+ *
514
+ * `auto-none` covers no gate, so ANY self-assert is above leash. `auto-spec` covers only the spec
515
+ * gate, so a self-asserted impl gate is above leash (a spec-gate self-assert is within). `auto-all`
516
+ * covers both, so self-assertion is never above leash. With no leash line at all there is nothing
517
+ * to compare against — this returns false rather than guessing (an unearned HAL flash would be
518
+ * worse than a missed one, per the ADR's "rare, earned signal" framing).
519
+ */
520
+ function inferHal(ledger) {
521
+ if (!ledger.leash) return false;
522
+ const specSelfAsserted = ledger.gates.spec?.by === "agent";
523
+ const implSelfAsserted = ledger.gates.impl?.by === "agent";
524
+ switch (ledger.leash.leash) {
525
+ case "auto-none": return specSelfAsserted || implSelfAsserted;
526
+ case "auto-spec": return implSelfAsserted;
527
+ case "auto-all": return false;
528
+ default: return false;
529
+ }
530
+ }
531
+ /**
532
+ * "Who needs the Council's hands" (ADR-0022 decision 10) — true when any of:
533
+ * - a gate line's verdict is `"pause"` (the conductor stopped and is waiting), or
534
+ * - the CR's combat log has a `halt` line (a mid-flight stop, not at a gate), or
535
+ * - any gate line is still self-asserted (`by: "agent"`) — provisional, awaiting a human's
536
+ * ratification into the ledger (the relayed-ratification seam — see `gate approve`).
537
+ */
538
+ function computeNeedsCouncil(ledger, hasHaltLine) {
539
+ if (hasHaltLine) return true;
540
+ for (const gate of [ledger.gates.spec, ledger.gates.impl]) {
541
+ if (!gate) continue;
542
+ if (gate.verdict === "pause") return true;
543
+ if (gate.by === "agent") return true;
544
+ }
545
+ return false;
546
+ }
547
+ //#endregion
548
+ //#region src/sdd/read.ts
549
+ /**
550
+ * Locate every project's ledger sibling dir under the three SDD spec conventions this reader
551
+ * supports: `.agents/specs/<project>/` (multi-project) and `.agents/spec/` (single-project, named
552
+ * `"repo"`). Never throws; an absent `.agents/specs` or `.agents/spec` simply contributes nothing.
553
+ */
554
+ function discoverProjectLedgerLocations(agentsRoot) {
555
+ const out = [];
556
+ const specsDir = join(agentsRoot, ".agents", "specs");
557
+ if (existsSync(specsDir)) try {
558
+ for (const e of readdirSync(specsDir, { withFileTypes: true })) if (e.isDirectory()) out.push({
559
+ project: e.name,
560
+ dir: join(specsDir, e.name)
561
+ });
562
+ } catch {}
563
+ const singleDir = join(agentsRoot, ".agents", "spec");
564
+ if (existsSync(singleDir)) out.push({
565
+ project: "repo",
566
+ dir: singleDir
567
+ });
568
+ return out;
569
+ }
570
+ function parseJsonlFile(file, project) {
571
+ let text;
572
+ try {
573
+ text = readFileSync(file, "utf8");
574
+ } catch {
575
+ return [];
576
+ }
577
+ const lines = [];
578
+ for (const raw of text.split("\n")) {
579
+ const s = raw.trim();
580
+ if (!s) continue;
581
+ try {
582
+ lines.push({
583
+ project,
584
+ ...JSON.parse(s)
585
+ });
586
+ } catch {}
587
+ }
588
+ return lines;
589
+ }
590
+ /**
591
+ * Glob every ledger line across every discovered project: `<project>/ledger/*.jsonl` (ADR-0020
592
+ * shards, sorted for stable but not authoritative-across-shards order) plus each project's
593
+ * legacy single-file `<project>/ledger.jsonl` (pre-shard corpora, ADR-0020's tolerated legacy
594
+ * form). Never throws.
595
+ */
596
+ function readAllLedgerLines(agentsRoot) {
597
+ const out = [];
598
+ for (const { project, dir } of discoverProjectLedgerLocations(agentsRoot)) {
599
+ const legacy = join(dir, "ledger.jsonl");
600
+ if (existsSync(legacy)) out.push(...parseJsonlFile(legacy, project));
601
+ const shardsDir = join(dir, "ledger");
602
+ if (!existsSync(shardsDir)) continue;
603
+ let files = [];
604
+ try {
605
+ files = readdirSync(shardsDir).filter((f) => f.endsWith(".jsonl"));
606
+ } catch {
607
+ files = [];
608
+ }
609
+ for (const f of files.sort()) out.push(...parseJsonlFile(join(shardsDir, f), project));
610
+ }
611
+ return out;
612
+ }
613
+ /** Read the latest `gate`/`leash` ledger lines for one CR ref across every discovered project. */
614
+ function readLedgerState(agentsRoot, cr) {
615
+ const lines = readAllLedgerLines(agentsRoot).filter((l) => l.cr === cr);
616
+ const gates = {
617
+ spec: null,
618
+ impl: null
619
+ };
620
+ let leash = null;
621
+ let project = null;
622
+ for (const l of lines) {
623
+ project ??= l.project;
624
+ if (l.kind === "gate" && (l.gate === "spec" || l.gate === "impl") && l.verdict && l.by) gates[l.gate] = {
625
+ gate: l.gate,
626
+ verdict: l.verdict,
627
+ by: l.by
628
+ };
629
+ else if (l.kind === "leash" && l.leash && l.by) leash = {
630
+ leash: l.leash,
631
+ by: l.by
632
+ };
633
+ }
634
+ return {
635
+ project,
636
+ gates,
637
+ leash
638
+ };
639
+ }
640
+ /**
641
+ * True when the CR's combat log (`.agents/plans/<cr>.log.jsonl`, sibling to its plan brief) has
642
+ * any `halt` line — a mid-flight stop not at a gate (see combat-log-governance). The combat log
643
+ * may be absent even for a real mission (not every mission writes one); absence is not an error.
644
+ */
645
+ function hasHalt(agentsRoot, cr) {
646
+ const file = join(agentsRoot, ".agents", "plans", `${cr}.log.jsonl`);
647
+ if (!existsSync(file)) return false;
648
+ let text;
649
+ try {
650
+ text = readFileSync(file, "utf8");
651
+ } catch {
652
+ return false;
653
+ }
654
+ for (const raw of text.split("\n")) {
655
+ const s = raw.trim();
656
+ if (!s) continue;
657
+ try {
658
+ if (JSON.parse(s).kind === "halt") return true;
659
+ } catch {}
660
+ }
661
+ return false;
662
+ }
663
+ /** The first content line of the `## NEXT` resume anchor, or '' when there is none. */
664
+ function nextLead(text) {
665
+ const lines = text.split("\n");
666
+ const i = lines.findIndex((l) => /^##\s+NEXT\b/i.test(l.trim()));
667
+ if (i === -1) return "";
668
+ for (let j = i + 1; j < lines.length; j++) {
669
+ const l = lines[j].replace(/\r$/, "").trim();
670
+ if (l === "") continue;
671
+ if (l.startsWith("#")) return "";
672
+ return l.replace(/^[-*]\s+/, "").replace(/\*\*/g, "").trim();
673
+ }
674
+ return "";
675
+ }
676
+ function unquote(v) {
677
+ return v.replace(/^["']|["']$/g, "");
678
+ }
679
+ /**
680
+ * Parse `.agents/plans/<cr>.plan.md`'s frontmatter (dispatch `status` + the `todos:` tally) plus
681
+ * its `## NEXT` lead line. Returns null when the plan brief is absent or has no frontmatter block
682
+ * (mirrors discover-plans.mts: a `*.plan.md` with no frontmatter is a stray, not a brief).
683
+ */
684
+ function readPlanBrief(agentsRoot, cr) {
685
+ const file = join(agentsRoot, ".agents", "plans", `${cr}.plan.md`);
686
+ if (!existsSync(file)) return null;
687
+ let text;
688
+ try {
689
+ text = readFileSync(file, "utf8");
690
+ } catch {
691
+ return null;
692
+ }
693
+ const m = /^---\r?\n([\s\S]*?)\r?\n---\s*(?:\r?\n|$)/.exec(text);
694
+ if (!m) return null;
695
+ let status = "active";
696
+ let total = 0;
697
+ let completed = 0;
698
+ let inTodos = false;
699
+ for (const raw of m[1].split("\n")) {
700
+ const line = raw.replace(/\r$/, "");
701
+ if (line.trim() === "" || line.trim().startsWith("#")) continue;
702
+ const indent = line.length - line.trimStart().length;
703
+ const trimmed = line.trim();
704
+ if (indent === 0) {
705
+ inTodos = false;
706
+ const [key, ...rest] = trimmed.split(":");
707
+ const value = unquote(rest.join(":").trim());
708
+ if (key === "status" && value !== "") status = value;
709
+ else if (key === "todos") inTodos = true;
710
+ continue;
711
+ }
712
+ if (inTodos) {
713
+ const sm = /^(?:-\s+)?status:\s*(.+)$/.exec(trimmed);
714
+ if (sm) {
715
+ total++;
716
+ if (unquote(sm[1].trim()) === "completed") completed++;
717
+ }
718
+ }
719
+ }
720
+ return {
721
+ status,
722
+ total,
723
+ completed,
724
+ next: nextLead(text)
725
+ };
726
+ }
727
+ /**
728
+ * Parse one project's `spec.md` frontmatter `status` only (never the body). Tries the multi-
729
+ * project convention (`.agents/specs/<project>/spec.md`) first, falling back to the single-
730
+ * project convention (`.agents/spec/spec.md`, used when `project` is `"repo"` or unresolved).
731
+ * Returns null when neither exists or the frontmatter has no `status`.
732
+ */
733
+ function readSpecStatus(agentsRoot, project) {
734
+ const candidates = project === "repo" ? [join(agentsRoot, ".agents", "spec", "spec.md")] : [join(agentsRoot, ".agents", "specs", project, "spec.md")];
735
+ for (const file of candidates) {
736
+ if (!existsSync(file)) continue;
737
+ let text;
738
+ try {
739
+ text = readFileSync(file, "utf8");
740
+ } catch {
741
+ continue;
742
+ }
743
+ const m = /^---\r?\n([\s\S]*?)\r?\n---\s*(?:\r?\n|$)/.exec(text);
744
+ if (!m) continue;
745
+ for (const raw of m[1].split("\n")) {
746
+ const line = raw.replace(/\r$/, "");
747
+ if (line.length - line.trimStart().length !== 0) continue;
748
+ const [key, ...rest] = line.trim().split(":");
749
+ if (key === "status") {
750
+ const value = unquote(rest.join(":").trim());
751
+ return value === "" ? null : value;
752
+ }
753
+ }
754
+ }
755
+ return null;
756
+ }
757
+ //#endregion
758
+ //#region src/missions.ts
759
+ /**
760
+ * Resolve the root under which `.agents/` lives — the primary checkout (so `missions` sees the
761
+ * whole fleet's SDD state regardless of which ship's worktree it is invoked from), falling back to
762
+ * this project root when not inside a git repository at all.
763
+ */
764
+ function resolveAgentsRoot(exec, cwd) {
765
+ try {
766
+ return resolvePrimaryRoot(exec);
767
+ } catch {
768
+ return projectRoot(cwd);
769
+ }
770
+ }
771
+ /**
772
+ * Derive one ship's mission row. The ship↔CR join key is `AgentRecord.worktree.branch` — this
773
+ * repo's convention is that a ship's worktree branch equals the `<cr-ref>` its plan brief and
774
+ * ledger shards are filed under (e.g. branch `add-fleet-comms` ↔ `add-fleet-comms.plan.md` ↔
775
+ * ledger shard prefix `add-fleet-comms.*`). A ship with no worktree/branch (not yet spawned into
776
+ * one, or standalone) joins to nothing — every SDD-derived field is null, never thrown.
777
+ */
778
+ function buildMissionRow(agentsRoot, agent) {
779
+ const branch = agent.worktree?.branch ?? null;
780
+ const base = {
781
+ handle: agent.handle,
782
+ id: agent.id,
783
+ worktreeRoot: agent.worktree?.root ?? null,
784
+ branch,
785
+ status: agent.status
786
+ };
787
+ if (!branch) return {
788
+ ...base,
789
+ cr: null,
790
+ mission: null,
791
+ spec: null,
792
+ gate: {
793
+ spec: null,
794
+ impl: null
795
+ },
796
+ leash: null,
797
+ needsCouncil: false,
798
+ hal: false
799
+ };
800
+ const cr = branch;
801
+ const ledger = readLedgerState(agentsRoot, cr);
802
+ const plan = readPlanBrief(agentsRoot, cr);
803
+ const specStatus = ledger.project ? readSpecStatus(agentsRoot, ledger.project) : null;
804
+ const halt = hasHalt(agentsRoot, cr);
805
+ return {
806
+ ...base,
807
+ cr,
808
+ mission: plan ? {
809
+ status: plan.status,
810
+ completed: plan.completed,
811
+ total: plan.total,
812
+ next: plan.next
813
+ } : null,
814
+ spec: specStatus ? { status: specStatus } : null,
815
+ gate: {
816
+ spec: ledger.gates.spec ? {
817
+ verdict: ledger.gates.spec.verdict,
818
+ by: ledger.gates.spec.by
819
+ } : null,
820
+ impl: ledger.gates.impl ? {
821
+ verdict: ledger.gates.impl.verdict,
822
+ by: ledger.gates.impl.by
823
+ } : null
824
+ },
825
+ leash: ledger.leash?.leash ?? null,
826
+ needsCouncil: computeNeedsCouncil(ledger, halt),
827
+ hal: inferHal(ledger)
828
+ };
829
+ }
830
+ /** Derive the mission row set for every ship in the fleet registry. */
831
+ function buildMissions(agentsRoot, agents) {
832
+ return agents.map((a) => buildMissionRow(agentsRoot, a));
833
+ }
834
+ //#endregion
835
+ //#region src/output.ts
836
+ function printFields(fields) {
837
+ const entries = Object.entries(fields).filter(([, v]) => v != null);
838
+ if (entries.length === 0) return;
839
+ const width = Math.max(...entries.map(([k]) => k.length));
840
+ for (const [key, val] of entries) console.log(`${key.padEnd(width)} ${val}`);
841
+ }
842
+ function printTable(items, cols) {
843
+ if (items.length === 0) {
844
+ console.log("(none)");
845
+ return;
846
+ }
847
+ const widths = cols.map((c) => Math.max(c.label.length, ...items.map((i) => c.get(i).length)));
848
+ console.log(cols.map((c, i) => c.label.toUpperCase().padEnd(widths[i])).join(" "));
849
+ console.log(widths.map((w) => "-".repeat(w)).join(" "));
850
+ for (const item of items) console.log(cols.map((c, i) => c.get(item).padEnd(widths[i])).join(" "));
851
+ }
852
+ //#endregion
853
+ //#region src/runtime/inject-inbox.ts
854
+ const EVENTS = ["SessionStart", "PostToolUse"];
855
+ /**
856
+ * Resolve the calling agent, gather its pending brief + unread mail, and return the
857
+ * SessionStart-style injection payload — or null when there is nothing to inject (an
858
+ * unregistered caller or an empty inbox never fails the harness hook).
859
+ */
860
+ function injectInbox(ctx, event) {
861
+ if (!EVENTS.includes(event)) throw new Error(`unsupported --event "${event}" (expected ${EVENTS.join(" | ")})`);
862
+ const meId = resolveSelfId(ctx);
863
+ if (!meId) return null;
864
+ const parts = [];
865
+ const rec = loadAgent(ctx.root, meId);
866
+ if (rec?.status === "spawning") {
867
+ const briefFile = paths.briefFile(ctx.root, meId);
868
+ if (existsSync(briefFile)) parts.push(`## Your brief\n\n${readFileSync(briefFile, "utf8").trim()}`);
869
+ rec.status = "active";
870
+ saveAgent(ctx.root, rec);
871
+ }
872
+ const unread = inbox({ root: ctx.root }, {
873
+ meId,
874
+ unread: true
875
+ });
876
+ if (unread.length > 0) {
877
+ const lines = unread.map((m) => `- **${m.fromHandle}**${m.subject ? ` — ${m.subject}` : ""}: ${m.body} \`(${m.id})\``);
878
+ parts.push(`## Unread fleet mail (${unread.length})\n\n${lines.join("\n")}`);
879
+ }
880
+ if (parts.length === 0) return null;
881
+ return { hookSpecificOutput: {
882
+ hookEventName: event,
883
+ additionalContext: parts.join("\n\n")
884
+ } };
885
+ }
886
+ //#endregion
887
+ //#region src/cli.ts
888
+ function ctxOf(opts) {
889
+ return {
890
+ root: resolveRoot({
891
+ root: opts.root,
892
+ space: opts.space
893
+ }),
894
+ env: process.env
895
+ };
896
+ }
897
+ function requireSelf(ctx) {
898
+ const id = resolveSelfId(ctx);
899
+ if (!id) throw new Error("no fleet identity in this session — run `cyberfleet register` first");
900
+ bumpLastSeen(ctx, id);
901
+ return id;
902
+ }
903
+ const rootOpts = (cmd) => cmd.option("--root <path>", "transport root (.cyberfleet dir)").option("--space <path>", "alias for --root");
904
+ const program = new Command();
905
+ program.name("cyberfleet").description("Harness-agnostic, MCP-free inter-agent sessions and messaging").version("0.0.0");
906
+ rootOpts(program.command("register")).description("register or refresh this session identity").option("--handle <name>", "human handle for this agent").option("--harness <h>", "claude | cursor | codex (else auto-detected)").action((opts) => {
907
+ const rec = register(ctxOf(opts), {
908
+ handle: opts.handle,
909
+ harness: opts.harness
910
+ });
911
+ printFields({
912
+ id: rec.id,
913
+ handle: rec.handle,
914
+ harness: rec.harness,
915
+ status: rec.status
916
+ });
917
+ });
918
+ rootOpts(program.command("who")).description("list the addressable fleet").option("--all", "include exited agents").action((opts) => {
919
+ const ctx = ctxOf(opts);
920
+ touch(ctx);
921
+ printTable(listAgents(ctx.root).filter((a) => opts.all || a.status !== "exited"), [
922
+ {
923
+ label: "handle",
924
+ get: (a) => a.handle
925
+ },
926
+ {
927
+ label: "harness",
928
+ get: (a) => a.harness
929
+ },
930
+ {
931
+ label: "cwd",
932
+ get: (a) => a.cwd
933
+ },
934
+ {
935
+ label: "status",
936
+ get: (a) => a.status
937
+ },
938
+ {
939
+ label: "pane",
940
+ get: (a) => a.tmux?.pane ?? "-"
941
+ },
942
+ {
943
+ label: "last-seen",
944
+ get: (a) => a.lastSeen
945
+ },
946
+ {
947
+ label: "id",
948
+ get: (a) => a.id
949
+ }
950
+ ]);
951
+ });
952
+ rootOpts(program.command("mode")).description("report ship (a .cyberfleet/ dir here) vs command-center, and the shared fleet root").action((opts) => {
953
+ const info = detectMode({
954
+ root: opts.root,
955
+ space: opts.space
956
+ });
957
+ printFields({
958
+ mode: info.mode,
959
+ cwdRoot: info.cwdRoot,
960
+ fleetRoot: info.fleetRoot
961
+ });
962
+ });
963
+ rootOpts(program.command("send")).description("send a message to a peer (by handle or id)").requiredOption("--to <peer>", "recipient handle or id").option("--subject <s>", "subject").option("--body <text>", "message body").option("--body-file <path>", "read body from a file, or - for stdin").option("--thread <id>", "thread id").option("--reply-to <msg>", "message id this replies to").action((opts) => {
964
+ const ctx = ctxOf(opts);
965
+ const fromId = requireSelf(ctx);
966
+ const body = resolveBody(opts.body, opts.bodyFile);
967
+ const msg = send({ root: ctx.root }, {
968
+ fromId,
969
+ to: opts.to,
970
+ subject: opts.subject,
971
+ body,
972
+ thread: opts.thread,
973
+ replyTo: opts.replyTo
974
+ });
975
+ printFields({
976
+ sent: msg.id,
977
+ to: opts.to,
978
+ subject: msg.subject
979
+ });
980
+ });
981
+ rootOpts(program.command("inbox")).description("list your mail, or emit the SessionStart hook payload").option("--unread", "only un-acked mail").option("--from <id>", "filter by sender").option("--hook", "emit the harness hook injection payload instead").option("--event <event>", "SessionStart | PostToolUse (with --hook)", "SessionStart").action((opts) => {
982
+ const ctx = ctxOf(opts);
983
+ if (opts.hook) {
984
+ touch(ctx);
985
+ const payload = injectInbox(ctx, opts.event);
986
+ if (payload) console.log(JSON.stringify(payload));
987
+ return;
988
+ }
989
+ const meId = requireSelf(ctx);
990
+ const items = inbox({ root: ctx.root }, {
991
+ meId,
992
+ unread: opts.unread,
993
+ from: opts.from
994
+ });
995
+ if (items.length === 0) {
996
+ console.log(opts.unread ? "(no unread mail)" : "(inbox empty)");
997
+ return;
998
+ }
999
+ for (const m of items) console.log(`${m.read ? " " : "*"} ${m.id} ${m.fromHandle}${m.subject ? ` — ${m.subject}` : ""}: ${m.body}`);
1000
+ });
1001
+ rootOpts(program.command("read")).description("print a message and acknowledge it").argument("<msg-id>", "message id").action((msgId, opts) => {
1002
+ const ctx = ctxOf(opts);
1003
+ const meId = requireSelf(ctx);
1004
+ const msg = read({ root: ctx.root }, meId, msgId);
1005
+ printFields({
1006
+ from: msg.fromHandle,
1007
+ subject: msg.subject,
1008
+ id: msg.id
1009
+ });
1010
+ console.log(`\n${msg.body}`);
1011
+ });
1012
+ rootOpts(program.command("spawn")).description("launch a new peer session in its own git worktree (tmux or herdr)").requiredOption("--harness <h>", "claude | cursor | codex").option("--task <text>", "brief text, or - for stdin").option("--brief-file <path>", "read the brief from a file").option("--handle <name>", "handle for the new peer").option("--branch <name>", "branch for the new ship worktree (default cyberfleet/ship-<id>)").option("--worktree-path <path>", "where to check out the new ship worktree").action(async (opts) => {
1013
+ const { spawn } = await import("./spawn-C036T1Ob.mjs");
1014
+ const ctx = ctxOf(opts);
1015
+ touch(ctx);
1016
+ const res = spawn(ctx, {
1017
+ harness: opts.harness,
1018
+ task: opts.task,
1019
+ briefFile: opts.briefFile,
1020
+ handle: opts.handle,
1021
+ branch: opts.branch,
1022
+ worktreePath: opts.worktreePath
1023
+ });
1024
+ printFields({
1025
+ spawned: res.agent.id,
1026
+ handle: res.agent.handle,
1027
+ harness: res.agent.harness,
1028
+ worktree: res.agent.worktree?.root,
1029
+ pane: res.pane,
1030
+ launch: res.launch
1031
+ });
1032
+ });
1033
+ rootOpts(program.command("prune")).description("mark dead agents exited and sweep").action((opts) => {
1034
+ const ctx = ctxOf(opts);
1035
+ touch(ctx);
1036
+ const changed = prune(ctx);
1037
+ console.log(changed.length ? `pruned ${changed.length} agent(s)` : "nothing to prune");
1038
+ });
1039
+ program.command("install").description("wire the fleet surfacing hook into a harness config").requiredOption("--agent <harness>", "claude | cursor | codex").option("--dir <path>", "project dir to write config into", process.cwd()).action((opts) => {
1040
+ const results = install(opts.agent, opts.dir);
1041
+ for (const r of results) console.log(`${r.status}: ${r.harness} ${r.vendorEvent} → ${r.file}`);
1042
+ });
1043
+ rootOpts(program.command("missions")).description("who needs the Council's hands — ships × mission × gate × leash, derived from SDD state").option("--json", "emit the full structured array instead of the table").option("--agents-root <path>", "override the root under which .agents/ is resolved (default: the primary checkout)").action((opts) => {
1044
+ const ctx = ctxOf(opts);
1045
+ touch(ctx);
1046
+ const rows = buildMissions(opts.agentsRoot ?? resolveAgentsRoot(realExec), listAgents(ctx.root).filter((a) => a.status !== "exited"));
1047
+ if (opts.json) {
1048
+ console.log(JSON.stringify(rows, null, 2));
1049
+ return;
1050
+ }
1051
+ printTable(rows, [
1052
+ {
1053
+ label: "handle",
1054
+ get: (r) => r.handle
1055
+ },
1056
+ {
1057
+ label: "branch/cr",
1058
+ get: (r) => r.branch ?? "-"
1059
+ },
1060
+ {
1061
+ label: "status",
1062
+ get: (r) => r.status
1063
+ },
1064
+ {
1065
+ label: "mission",
1066
+ get: (r) => r.mission ? `${r.mission.status} ${r.mission.completed}/${r.mission.total}` : "-"
1067
+ },
1068
+ {
1069
+ label: "spec",
1070
+ get: (r) => r.spec?.status ?? "-"
1071
+ },
1072
+ {
1073
+ label: "gate:spec",
1074
+ get: (r) => r.gate.spec ? `${r.gate.spec.verdict}(${r.gate.spec.by})` : "-"
1075
+ },
1076
+ {
1077
+ label: "gate:impl",
1078
+ get: (r) => r.gate.impl ? `${r.gate.impl.verdict}(${r.gate.impl.by})` : "-"
1079
+ },
1080
+ {
1081
+ label: "leash",
1082
+ get: (r) => r.leash ?? "-"
1083
+ },
1084
+ {
1085
+ label: "council",
1086
+ get: (r) => r.needsCouncil ? "yes" : "-"
1087
+ },
1088
+ {
1089
+ label: "HAL",
1090
+ get: (r) => r.hal ? "!" : ""
1091
+ }
1092
+ ]);
1093
+ });
1094
+ rootOpts(program.command("jump")).description("select/focus a ship's session (tmux pane), or print its worktree path to cd into").argument("<peer>", "handle, id, or worktree branch/CR ref").action((peer, opts) => {
1095
+ const ctx = ctxOf(opts);
1096
+ touch(ctx);
1097
+ const agent = resolveShip(ctx.root, peer);
1098
+ if (agent.tmux?.pane) try {
1099
+ execFileSync("tmux", [
1100
+ "select-pane",
1101
+ "-t",
1102
+ agent.tmux.pane
1103
+ ], { stdio: "ignore" });
1104
+ printFields({
1105
+ jumped: agent.handle,
1106
+ pane: agent.tmux.pane
1107
+ });
1108
+ return;
1109
+ } catch {}
1110
+ console.log(agent.worktree?.root ?? agent.cwd);
1111
+ });
1112
+ rootOpts(program.command("ack")).description("acknowledge a message (thin alias of `read` — both print + move it to acked)").argument("<msg-id>", "message id").action((msgId, opts) => {
1113
+ const ctx = ctxOf(opts);
1114
+ const meId = requireSelf(ctx);
1115
+ const msg = read({ root: ctx.root }, meId, msgId);
1116
+ printFields({
1117
+ acked: msg.id,
1118
+ from: msg.fromHandle,
1119
+ subject: msg.subject
1120
+ });
1121
+ });
1122
+ rootOpts(program.command("pause")).description("pause a ship's mission — a cyberfleet-level status marker only (see note)").argument("<peer>", "handle, id, or worktree branch/CR ref").action((peer, opts) => {
1123
+ const ctx = ctxOf(opts);
1124
+ const agent = resolveShip(ctx.root, peer);
1125
+ const rec = pauseAgent(ctx.root, agent.id);
1126
+ printFields({
1127
+ paused: rec.handle,
1128
+ status: rec.status
1129
+ });
1130
+ process.stderr.write("note: this only flips the cyberfleet ship record to status:paused — it is NOT a bridge to SDD's pause-mission checkpoint (which rewrites the plan brief's todos/## NEXT anchor). Run `sdd:pause-mission` in-session for the actual mission checkpoint (flagged gap).\n");
1131
+ });
1132
+ rootOpts(program.command("gate").description("SDD gate operations").command("approve")).description("Council ratification of a gate — STUBBED, not safely relayable through this CLI (see note)").argument("<cr>", "CR ref").argument("<gateName>", "spec | impl").action((cr, gateName, _opts) => {
1133
+ if (gateName !== "spec" && gateName !== "impl") throw new Error("<gateName> must be spec | impl");
1134
+ const wouldWrite = {
1135
+ kind: "gate",
1136
+ cr,
1137
+ gate: gateName,
1138
+ verdict: "approve",
1139
+ by: process.env.SDD_HANDLE ?? realExec("git", ["config", "user.name"]) ?? realExec("git", [
1140
+ "log",
1141
+ "-1",
1142
+ "--format=%an"
1143
+ ]) ?? "<unknown>"
1144
+ };
1145
+ process.stderr.write(`cyberfleet gate approve is NOT implemented — a human ratification into the SDD ledger cannot be safely relayed through this CLI without running in-session with the SDD schema in force (the relayed-ratification seam). It would have written:
1146
+ ${JSON.stringify(wouldWrite)}\nRatify this gate via the SDD spec-gate skill, in-session, instead. Flagging for the Council.
1147
+ `);
1148
+ process.exitCode = 1;
1149
+ });
1150
+ program.parseAsync(process.argv).catch((err) => {
1151
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
1152
+ process.exit(1);
1153
+ });
1154
+ //#endregion
1155
+ export { ensureFleetMarker as a, gitWorktreeAdapter as c, saveAgent as i, resolvePrimaryRoot as l, realExec as n, paths as o, resolveSelfId as r, assertDistinctFromPrimary as s, randomId as t };
@@ -0,0 +1,193 @@
1
+ import { a as ensureFleetMarker, c as gitWorktreeAdapter, i as saveAgent, l as resolvePrimaryRoot, n as realExec, o as paths, r as resolveSelfId, s as assertDistinctFromPrimary, t as randomId } from "./cli.mjs";
2
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ //#region src/console/session.herdr.ts
5
+ /**
6
+ * herdr backend — detected via `$HERDR_ENV`. herdr (https://herdr.dev) is an agent-aware terminal
7
+ * multiplexer that also reports real busy-state (working / idle / blocked / done); this adapter
8
+ * only drives its pane lifecycle, not the state feed. Talks to herdr's own CLI (`herdr pane ...`)
9
+ * rather than its Unix-socket API, so it composes with this codebase's synchronous `Exec`
10
+ * convention exactly like the tmux adapter — no new client/transport needed.
11
+ *
12
+ * herdr is optional/experimental and is not installed in this environment; this follows herdr's
13
+ * documented CLI reference (split/run/read/close) but is unverified against a live binary.
14
+ */
15
+ const herdrSessionAdapter = {
16
+ name: "herdr",
17
+ open(exec, opts) {
18
+ const id = exec("herdr", [
19
+ "pane",
20
+ "split",
21
+ "--current",
22
+ "--direction",
23
+ "right",
24
+ "--cwd",
25
+ opts.cwd
26
+ ]);
27
+ if (!id) throw new Error("herdr pane split failed");
28
+ const target = { id };
29
+ exec("herdr", [
30
+ "pane",
31
+ "run",
32
+ id,
33
+ opts.launch
34
+ ]);
35
+ return target;
36
+ },
37
+ send(exec, target, text) {
38
+ exec("herdr", [
39
+ "pane",
40
+ "run",
41
+ target.id,
42
+ text
43
+ ]);
44
+ },
45
+ read(exec, target, opts) {
46
+ const args = [
47
+ "pane",
48
+ "read",
49
+ target.id,
50
+ "--source",
51
+ "visible"
52
+ ];
53
+ if (opts?.lines != null) args.push("--lines", String(opts.lines));
54
+ return exec("herdr", args) ?? "";
55
+ },
56
+ teardown(exec, target) {
57
+ exec("herdr", [
58
+ "pane",
59
+ "close",
60
+ target.id
61
+ ]);
62
+ }
63
+ };
64
+ //#endregion
65
+ //#region src/console/session.tmux.ts
66
+ /** tmux backend — detected via `$TMUX`. */
67
+ const tmuxSessionAdapter = {
68
+ name: "tmux",
69
+ open(exec, opts) {
70
+ const pane = exec("tmux", [
71
+ "split-window",
72
+ "-h",
73
+ "-c",
74
+ opts.cwd,
75
+ "-P",
76
+ "-F",
77
+ "#{pane_id}"
78
+ ]);
79
+ if (!pane) throw new Error("tmux split-window failed");
80
+ const target = { id: pane };
81
+ tmuxSessionAdapter.send(exec, target, opts.launch);
82
+ return target;
83
+ },
84
+ send(exec, target, text) {
85
+ exec("tmux", [
86
+ "send-keys",
87
+ "-t",
88
+ target.id,
89
+ text,
90
+ "Enter"
91
+ ]);
92
+ },
93
+ read(exec, target, opts) {
94
+ const args = [
95
+ "capture-pane",
96
+ "-p",
97
+ "-t",
98
+ target.id
99
+ ];
100
+ if (opts?.lines != null) args.push("-S", `-${opts.lines}`);
101
+ return exec("tmux", args) ?? "";
102
+ },
103
+ teardown(exec, target) {
104
+ exec("tmux", [
105
+ "kill-pane",
106
+ "-t",
107
+ target.id
108
+ ]);
109
+ }
110
+ };
111
+ //#endregion
112
+ //#region src/console/index.ts
113
+ /** Backend selection: `$TMUX` → tmux, `$HERDR_ENV` → herdr, else a clear error. */
114
+ function selectSessionAdapter(env) {
115
+ if (env.TMUX) return tmuxSessionAdapter;
116
+ if (env.HERDR_ENV) return herdrSessionAdapter;
117
+ throw new Error("spawn requires a session backend — run inside tmux ($TMUX) or herdr ($HERDR_ENV=1)");
118
+ }
119
+ //#endregion
120
+ //#region src/spawn.ts
121
+ /** How each harness's own CLI is launched in the new pane. */
122
+ const LAUNCH_MAP = {
123
+ claude: "claude",
124
+ cursor: "cursor-agent",
125
+ codex: "codex"
126
+ };
127
+ /**
128
+ * Launch a new peer session as a genuine sibling ship (ADR-0022 decision 8): create a real git
129
+ * worktree distinct from the primary checkout (the flagship rule), open a session backend (tmux
130
+ * or herdr — decision 9) with its cwd set to that worktree, pre-register the peer, and drop its
131
+ * brief as a file the peer's own SessionStart hook reads — never typed into its prompt.
132
+ */
133
+ function spawn(ctx, input) {
134
+ ensureFleetMarker(ctx.root);
135
+ const env = ctx.env ?? process.env;
136
+ const exec = ctx.exec ?? realExec;
137
+ const sessionAdapter = selectSessionAdapter(env);
138
+ const harness = input.harness;
139
+ if (!harness || !(harness in LAUNCH_MAP)) throw new Error(`spawn needs a --harness in the launch map (${Object.keys(LAUNCH_MAP).join(" | ")})`);
140
+ const brief = resolveBrief(input);
141
+ if (brief == null) throw new Error("spawn needs a brief — pass --task <text>, --task - (stdin), or --brief-file <path>");
142
+ const id = randomId();
143
+ const primaryRoot = resolvePrimaryRoot(exec);
144
+ const branch = input.branch ?? `cyberfleet/ship-${id}`;
145
+ const worktreePath = input.worktreePath ?? paths.worktreeDir(ctx.root, id);
146
+ const worktree = gitWorktreeAdapter.add(exec, {
147
+ primaryRoot,
148
+ path: worktreePath,
149
+ branch
150
+ });
151
+ assertDistinctFromPrimary(worktree.root, primaryRoot);
152
+ ensureFleetMarker(join(worktree.root, ".cyberfleet"));
153
+ const launch = LAUNCH_MAP[harness];
154
+ const target = sessionAdapter.open(exec, {
155
+ cwd: worktree.root,
156
+ launch
157
+ });
158
+ const ts = new Date(ctx.now?.() ?? Date.now()).toISOString();
159
+ const rec = {
160
+ id,
161
+ handle: input.handle ?? id.slice(0, 6),
162
+ harness,
163
+ cwd: worktree.root,
164
+ worktree,
165
+ tmux: sessionAdapter.name === "tmux" ? { pane: target.id } : null,
166
+ status: "spawning",
167
+ createdAt: ts,
168
+ lastSeen: ts,
169
+ brief: paths.briefFile(ctx.root, id),
170
+ ...resolveSelfId(ctx) ? { spawnedBy: resolveSelfId(ctx) } : {}
171
+ };
172
+ saveAgent(ctx.root, rec);
173
+ writeText(paths.paneFile(ctx.root, target.id), id);
174
+ writeText(paths.briefFile(ctx.root, id), brief);
175
+ return {
176
+ agent: rec,
177
+ pane: target.id,
178
+ launch
179
+ };
180
+ }
181
+ /** Resolve a spawn brief from --brief-file, --task -, or --task <text>; null if no source given. */
182
+ function resolveBrief(input, readStdin = () => readFileSync(0, "utf8")) {
183
+ if (input.briefFile) return readFileSync(input.briefFile, "utf8");
184
+ if (input.task === "-") return readStdin();
185
+ if (input.task != null && input.task !== "") return input.task;
186
+ return null;
187
+ }
188
+ function writeText(file, text) {
189
+ mkdirSync(dirname(file), { recursive: true });
190
+ writeFileSync(file, text);
191
+ }
192
+ //#endregion
193
+ export { spawn };
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "cyberfleet",
3
+ "version": "0.0.0",
4
+ "description": "Harness-agnostic, MCP-free inter-agent sessions and messaging over the filesystem",
5
+ "keywords": [
6
+ "agent",
7
+ "claude-code",
8
+ "cursor",
9
+ "codex",
10
+ "inter-agent",
11
+ "tmux"
12
+ ],
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/cyberuni/cyberplace.git",
16
+ "directory": "packages/cyberfleet"
17
+ },
18
+ "license": "MIT",
19
+ "author": "unional <homawong@gmail.com>",
20
+ "type": "module",
21
+ "bin": {
22
+ "cyberfleet": "bin/cyberfleet.mjs"
23
+ },
24
+ "exports": {
25
+ "./package.json": "./package.json"
26
+ },
27
+ "files": [
28
+ "bin",
29
+ "dist"
30
+ ],
31
+ "scripts": {
32
+ "build": "tsdown",
33
+ "dev": "tsx src/cli.ts",
34
+ "prepack": "pnpm build",
35
+ "test": "pnpm build && vitest run src",
36
+ "test:watch": "vitest",
37
+ "typecheck": "tsc --noEmit"
38
+ },
39
+ "dependencies": {
40
+ "commander": "^14.0.3"
41
+ },
42
+ "devDependencies": {
43
+ "@types/node": "^24.10.1",
44
+ "tsdown": "^0.22.0",
45
+ "tsx": "^4.22.3",
46
+ "typescript": "^6.0.0",
47
+ "vitest": "^4.1.7"
48
+ },
49
+ "engines": {
50
+ "node": ">=22"
51
+ },
52
+ "publishConfig": {
53
+ "access": "public",
54
+ "provenance": true
55
+ }
56
+ }