@staix/agent-hub 0.12.22 → 0.12.24

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.
Files changed (183) hide show
  1. package/CHANGELOG.md +117 -0
  2. package/README.md +22 -9
  3. package/docs/addons/claude-mem.md +24 -0
  4. package/docs/addons/codex-plugin.md +8 -0
  5. package/docs/addons/dot-admit.md +8 -0
  6. package/docs/addons/open-code-review.md +8 -0
  7. package/docs/addons/ponytail.md +23 -0
  8. package/docs/addons/ripwire.md +22 -0
  9. package/docs/agent-notes/README.md +5 -0
  10. package/docs/agent-notes/adapters.md +18 -6
  11. package/docs/agent-notes/benchmarks.md +3 -0
  12. package/docs/agent-notes/budget.md +4 -0
  13. package/docs/agent-notes/bus.md +3 -1
  14. package/docs/agent-notes/daemon.md +14 -1
  15. package/docs/agent-notes/local-worker.md +6 -3
  16. package/docs/agent-notes/tasks.md +6 -1
  17. package/docs/agent-notes/tests.md +4 -1
  18. package/docs/bench.md +28 -3
  19. package/docs/cooperbench.md +4 -0
  20. package/docs/data-policy.md +36 -0
  21. package/docs/events.md +10 -3
  22. package/docs/gsd-collaboration-analysis.md +48 -0
  23. package/docs/memory.md +231 -0
  24. package/docs/operations.md +873 -30
  25. package/docs/plans/2026-10-11-issues-109-sequential.md +81 -0
  26. package/docs/plans/2026-10-11-routing-evaluation-proposal.md +30 -0
  27. package/docs/profiles.md +57 -0
  28. package/docs/quickstart.md +9 -4
  29. package/docs/research.md +31 -1
  30. package/docs/roles.md +85 -0
  31. package/docs/sdlc.md +170 -0
  32. package/docs/security.md +124 -8
  33. package/docs/smoke.md +354 -5
  34. package/docs/specs/2026-09-19-agent-hub-design.md +676 -15
  35. package/docs/specs/2026-10-12-backlog-implementation-plan.md +186 -0
  36. package/docs/specs/2026-10-12-office-view-design.md +212 -0
  37. package/docs/text-projection.md +19 -0
  38. package/docs/verification/2026-10-10-cli-output-phase1.md +309 -0
  39. package/docs/verification/2026-10-10-cli-output-phase2.md +672 -0
  40. package/docs/verification/2026-10-11-0.12.23.md +90 -0
  41. package/docs/verification/2026-10-11-acp-watch-t0.md +33 -0
  42. package/docs/verification/2026-10-11-agent-config-inventory.md +20 -0
  43. package/docs/verification/2026-10-11-jcode-peer-contract.md +27 -0
  44. package/docs/verification/2026-10-13-0.12.24.md +94 -0
  45. package/docs/verification/2026-10-13-board-phone.md +31 -0
  46. package/docs/verification/2026-10-13-fsmonitor-fixture-attribution.md +19 -0
  47. package/docs/verified.json +34 -16
  48. package/package.json +2 -1
  49. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  50. package/plugins/agent-hub/ROLE-NOTICE.md +26 -0
  51. package/plugins/agent-hub/server.js +1108 -261
  52. package/scripts/check-docs.mjs +129 -0
  53. package/src/adapters/acp-quota.ts +13 -0
  54. package/src/adapters/acp.ts +201 -20
  55. package/src/adapters/claude-channel.ts +48 -11
  56. package/src/adapters/codex-appserver.ts +54 -13
  57. package/src/adapters/jcode.ts +110 -0
  58. package/src/adapters/local-worker.ts +10 -7
  59. package/src/adapters/pi.ts +94 -20
  60. package/src/cli/attention.ts +60 -0
  61. package/src/cli/bench.ts +26 -6
  62. package/src/cli/board-read.ts +12 -0
  63. package/src/cli/console-state.ts +131 -39
  64. package/src/cli/console.ts +73 -17
  65. package/src/cli/facts-hook.ts +12 -4
  66. package/src/cli/help.ts +52 -21
  67. package/src/cli/identity.ts +19 -8
  68. package/src/cli/init.ts +48 -10
  69. package/src/cli/jcode-mcp.ts +58 -0
  70. package/src/cli/kimi-mcp.ts +53 -0
  71. package/src/cli/launch.ts +16 -1
  72. package/src/cli/main.ts +641 -193
  73. package/src/cli/memory.ts +193 -0
  74. package/src/cli/output.ts +404 -0
  75. package/src/cli/pet-config.ts +1 -0
  76. package/src/cli/pet.ts +83 -0
  77. package/src/cli/preview.ts +40 -6
  78. package/src/cli/reset.ts +3 -0
  79. package/src/cli/status-lines.ts +1 -29
  80. package/src/cli/tail-render.ts +1 -0
  81. package/src/cli/terminal-recovery.ts +27 -1
  82. package/src/cli/upgrade-runtime.ts +56 -10
  83. package/src/cli/upgrade.ts +23 -5
  84. package/src/cli/watch.ts +31 -0
  85. package/src/hub/activity.ts +53 -0
  86. package/src/hub/ask.ts +10 -5
  87. package/src/hub/bench.ts +10 -3
  88. package/src/hub/board.ts +224 -16
  89. package/src/hub/budget.ts +39 -7
  90. package/src/hub/bus.ts +58 -20
  91. package/src/hub/child-process.ts +11 -0
  92. package/src/hub/citations.ts +7 -0
  93. package/src/hub/conductor.ts +34 -17
  94. package/src/hub/config-trust.ts +20 -7
  95. package/src/hub/conflicts.ts +2 -0
  96. package/src/hub/context-window.ts +8 -1
  97. package/src/hub/crash.ts +11 -1
  98. package/src/hub/daemon.ts +1218 -113
  99. package/src/hub/db-timeout.ts +3 -0
  100. package/src/hub/delivery-journal.ts +7 -1
  101. package/src/hub/envelope.ts +1 -1
  102. package/src/hub/events.ts +14 -2
  103. package/src/hub/execution-budget.ts +2 -0
  104. package/src/hub/facts.ts +28 -8
  105. package/src/hub/git.ts +38 -0
  106. package/src/hub/github-import.ts +28 -0
  107. package/src/hub/hub-tools.ts +49 -10
  108. package/src/hub/inference.ts +1 -1
  109. package/src/hub/lifecycle-schema.ts +17 -0
  110. package/src/hub/lifecycle.ts +10 -4
  111. package/src/hub/listen.ts +15 -0
  112. package/src/hub/manager.ts +26 -6
  113. package/src/hub/path-policy.ts +96 -0
  114. package/src/hub/peer-catalog.ts +28 -0
  115. package/src/hub/peers.ts +5 -3
  116. package/src/hub/permission-mode.ts +33 -5
  117. package/src/hub/planning.ts +70 -0
  118. package/src/hub/profiles.ts +74 -0
  119. package/src/hub/project.ts +2 -1
  120. package/src/hub/projected-diff.ts +73 -0
  121. package/src/hub/projection-exec.ts +24 -0
  122. package/src/hub/recovery-error.ts +31 -0
  123. package/src/hub/recovery-store.ts +6 -5
  124. package/src/hub/registry.ts +2 -1
  125. package/src/hub/research.ts +144 -21
  126. package/src/hub/roles.ts +123 -0
  127. package/src/hub/roster.ts +23 -0
  128. package/src/hub/routing.ts +132 -18
  129. package/src/hub/sdlc-queue.ts +112 -0
  130. package/src/hub/sdlc-release.ts +125 -0
  131. package/src/hub/sdlc.ts +250 -0
  132. package/src/hub/settings.ts +411 -0
  133. package/src/hub/shipped-roles.ts +25 -0
  134. package/src/hub/snapshots.ts +92 -16
  135. package/src/hub/start-mode.ts +50 -0
  136. package/src/hub/supervision.ts +1 -0
  137. package/src/hub/task-sweep.ts +1 -1
  138. package/src/hub/tasks.ts +1014 -111
  139. package/src/hub/terminal-open.ts +64 -0
  140. package/src/hub/text-projection.ts +126 -0
  141. package/src/hub/tooling.ts +159 -0
  142. package/src/hub/ui.ts +40 -14
  143. package/src/hub/workflow.ts +71 -0
  144. package/src/local/deny.ts +29 -7
  145. package/src/local/sandbox.ts +50 -10
  146. package/src/local/tools.ts +11 -6
  147. package/src/memory/brief.ts +28 -14
  148. package/src/memory/import.ts +55 -0
  149. package/src/memory/recall.ts +86 -2
  150. package/src/memory/store.ts +750 -0
  151. package/src/memory/sync.ts +145 -0
  152. package/src/models/mlx.ts +2 -1
  153. package/src/pet/NOTICE.md +10 -0
  154. package/src/pet/characters.ts +1438 -0
  155. package/src/pet/config.ts +31 -0
  156. package/src/pet/frame.ts +17 -0
  157. package/src/pet/pixels.ts +28 -0
  158. package/src/pi/tool-receipts.ts +2 -1
  159. package/src/role-text.d.ts +1 -0
  160. package/src/ui/index.html +983 -40
  161. package/src/ui/needs.ts +58 -0
  162. package/src/ui/office-model.ts +440 -0
  163. package/src/ui/peer-palette.ts +16 -0
  164. package/src/ui/task-board.ts +49 -0
  165. package/src/ui/task-progress.ts +5 -1
  166. package/templates/AGENTS.block.md +3 -2
  167. package/templates/config.json +5 -1
  168. package/templates/profiles/dev.json +22 -0
  169. package/templates/profiles/general.json +26 -0
  170. package/templates/profiles/research.json +57 -0
  171. package/templates/profiles/writing.json +28 -0
  172. package/templates/roles/NOTICE.md +26 -0
  173. package/templates/roles/dev/backend-engineer.md +26 -0
  174. package/templates/roles/dev/code-reviewer.md +26 -0
  175. package/templates/roles/dev/frontend-engineer.md +26 -0
  176. package/templates/roles/dev/security-reviewer.md +26 -0
  177. package/templates/roles/dev/software-architect.md +26 -0
  178. package/templates/roles/dev/test-engineer.md +26 -0
  179. package/templates/roles/research/analyst.md +26 -0
  180. package/templates/roles/research/fact-checker.md +26 -0
  181. package/templates/roles/research/research-lead.md +26 -0
  182. package/templates/roles/research/researcher.md +26 -0
  183. package/templates/roles/research/statistician.md +26 -0
@@ -0,0 +1,193 @@
1
+ import { extractNodes, GOAL_STATES, KINDS, looksSecret, MemoryStore, setMachineLabel, type GoalState, type GoalView, type Kind, type Tier, type View } from "../memory/store.ts";
2
+ import { syncMemory, type SyncOptions } from "../memory/sync.ts";
3
+ import { importClaudeMem } from "../memory/import.ts";
4
+ import { currentRouting, detectSignals } from "../hub/routing.ts";
5
+ import { memoryProjectKey, mentionsPrivatePath, projectChain, promotionScreen, withoutPii } from "../memory/recall.ts";
6
+ import { loadConfig } from "../hub/daemon.ts";
7
+
8
+ /** The project's config, or none when there is no valid one (reading memory needs none). */
9
+ const loadConfigSafe = (cwd: string) => { try { return loadConfig(cwd); } catch { return undefined; } };
10
+
11
+ /**
12
+ * #258: `ahub memory`, straight on the machine's store (no hub needed). Reading is anyone's; promote, demote, forget,
13
+ * supersede and rebuild are a person's (the identity gate refuses agent shells).
14
+ */
15
+ export const MEMORY_USAGE = "usage: ahub memory [list] [--tier short|long] [--kind <kind>] [--peer <p>] [--machine <id>] [--since <iso>] [--limit N] | search <words> | show <id> | graph <id|node> [--hops 1|2] | promote|demote|forget <id> | supersede <old> <new> | rebuild | sync [--remote <url>] | machine [--label <name>] | import claude-mem [--db <path>] [--limit N] | suggestions | confirm|reject <suggestion id> [--json]";
16
+ export const GOAL_USAGE = "usage: ahub goal [list] [--all] | show <id> | new <title> [--milestone <text>]... [--depends <id>]... [--shared] | link <id> <task id> | state <id> open|active|blocked|done|dropped | done <id> | take <id> [--force] [--json]";
17
+
18
+ const fail = (message: string): never => { throw new Error(message); };
19
+ const one = (v: View) => `${v.id.slice(0, 8)} ${v.at.slice(0, 16).replace("T", " ")} ${v.tier.padEnd(5)} ${v.kind.padEnd(8)} ${v.peer.padEnd(8)} ${v.scope === "shared" ? "shared " : ""}${`${v.title ? `${v.title}: ` : ""}${v.text}`.replace(/\s+/g, " ").slice(0, 100)}${v.supersededBy ? ` (superseded by ${v.supersededBy.slice(0, 8)})` : ""}`;
20
+ function detail(v: View): string[] {
21
+ return [`${v.id} ${v.kind}, ${v.tier}-term${v.expiresAt ? ` until ${v.expiresAt}` : ""}, ${v.scope}`, `by ${v.peer} on machine ${v.machine}, ${v.at}, project ${v.project}${v.task ? `, task ${v.task}` : ""}`,
22
+ ...(v.promotedFrom ? [`promoted from ${v.promotedFrom}`] : []), ...(v.supersededBy ? [`superseded by ${v.supersededBy}`] : []), ...(v.about.length ? [`about: ${v.about.join(", ")}`] : []), "", ...(v.title ? [v.title] : []), v.text];
23
+ }
24
+
25
+ export async function memoryCommand(args: string[], cwd: string, opts: { store?: MemoryStore; sync?: Omit<SyncOptions, "remote"> } = {}): Promise<string> {
26
+ const json = args.includes("--json");
27
+ const rest = args.filter((a) => a !== "--json");
28
+ const flag = (name: string) => { const i = rest.indexOf(name); if (i < 0) return undefined; const v = rest[i + 1] ?? fail(`${name} needs a value`); rest.splice(i, 2); return v; };
29
+ const sub = rest[0] && !rest[0].startsWith("--") ? rest.shift()! : "list";
30
+ // The project's private paths (`pii.paths`): a note about one is withheld like a note matching a PII pattern. A config
31
+ // that does not load fails the read closed: its privacy policy is not dropped for agent-readable output.
32
+ const privatePaths = (): string[] => { try { return loadConfig(cwd).pii?.paths ?? []; } catch (e) { return fail(`the project's config does not load (${(e as Error).message}); fix it before reading hub memory`); } };
33
+ // A store opened here promotes this project's notes only through its current policy (privatePaths fails closed).
34
+ // The project's own short-term lifetime, so a deadline this command writes (a demotion) matches what its hub would write.
35
+ const store = opts.store ?? new MemoryStore({ ttlMs: (loadConfigSafe(cwd)?.memory.short_ttl_days ?? 14) * 24 * 3600_000, screen: promotionScreen(memoryProjectKey(cwd), cwd, () => currentRouting(cwd), () => privatePaths()) });
36
+ const actor = "user";
37
+ const project = memoryProjectKey(cwd);
38
+ const need = (v: View | undefined, id: string) => v ?? fail(`no memory entry ${id} (ahub memory list shows the ids)`);
39
+ try {
40
+ switch (sub) {
41
+ case "list": case "search": {
42
+ const tier = flag("--tier"), kind = flag("--kind"), peer = flag("--peer"), machine = flag("--machine"), since = flag("--since"), limit = flag("--limit");
43
+ if (since !== undefined && Number.isNaN(Date.parse(since))) fail(`--since needs a date or time (ISO 8601), not ${since}`);
44
+ if (tier && tier !== "short" && tier !== "long") fail("--tier takes short or long");
45
+ if (kind && !KINDS.includes(kind as Kind)) fail(`--kind takes ${KINDS.join(", ")}`);
46
+ if (limit && !(Number(limit) > 0)) fail("--limit takes a positive number");
47
+ const q = sub === "search" ? rest.join(" ") || fail("usage: ahub memory search <words>") : rest.length ? fail(MEMORY_USAGE) : undefined;
48
+ // Agent shells may run this: notes are screened under this project's current PII policy, as briefs and recall are
49
+ // (a shared note was accepted under another project's patterns). The dashboard shows a person everything.
50
+ const all = store.list({ project, ...(tier ? { tier: tier as Tier } : {}), ...(kind ? { kind: kind as Kind } : {}), ...(peer ? { peer } : {}), ...(machine ? { machine } : {}), ...(since ? { since } : {}), ...(q ? { q } : {}), limit: Number.MAX_SAFE_INTEGER });
51
+ // Screened first, then cut to the limit: withheld entries never crowd out readable ones.
52
+ const readable = withoutPii(all, currentRouting(cwd), cwd, privatePaths()), rows = readable.slice(0, limit ? Number(limit) : 50);
53
+ if (json) return JSON.stringify(rows, null, 2);
54
+ const c = store.counts(project);
55
+ const withheld = all.length - readable.length;
56
+ return [`hub memory for ${project} on machine ${c.machine.label}: ${c.long} long-term, ${c.short} short-term`, ...(rows.length ? rows.map(one) : ["(nothing)"]),
57
+ ...(withheld ? [`(${withheld} withheld: they match a PII pattern; the dashboard shows them)`] : [])].join("\n");
58
+ }
59
+ case "show": {
60
+ const id = rest[0] ?? fail("usage: ahub memory show <id>");
61
+ const v = need(store.get(id, project), id);
62
+ if (!withoutPii([v], currentRouting(cwd), cwd, privatePaths()).length) fail(`${v.id.slice(0, 8)} is withheld: it matches a PII pattern (the dashboard shows it)`);
63
+ return json ? JSON.stringify(v, null, 2) : detail(v).join("\n");
64
+ }
65
+ case "graph": {
66
+ const hops = Number(flag("--hops") ?? 1);
67
+ if (hops !== 1 && hops !== 2) fail("--hops takes 1 or 2");
68
+ const start = rest[0] ?? fail("usage: ahub memory graph <id|node>");
69
+ const node = start.includes(":") && !start.startsWith("mem:") ? start : `mem:${need(store.get(start, project), start).id}`;
70
+ const g = store.graph(project, node, hops);
71
+ return json ? JSON.stringify(g, null, 2) : [`${g.nodes.length} nodes, ${g.edges.length} edges around ${node}`, ...g.edges.map((e) => `${e.from} -${e.rel}-> ${e.to}`)].join("\n");
72
+ }
73
+ case "promote": { const v = need(store.get(rest[0] ?? fail(MEMORY_USAGE), project), rest[0]!); if (v.supersededBy) fail(`${v.id.slice(0, 8)} is replaced by ${v.supersededBy.slice(0, 8)}: promote that one instead`); const id = store.promote(v.id, actor, project) ?? fail(v.project !== project ? `${v.id.slice(0, 8)} is another project's note: promote it from that project` : `${v.id.slice(0, 8)} stays short-term: it holds a secret, matches a PII pattern or is about a private path`); return id === v.id ? `${v.id.slice(0, 8)} is already long-term` : `promoted to long-term as ${id.slice(0, 8)}`; }
74
+ case "demote": { const v = need(store.get(rest[0] ?? fail(MEMORY_USAGE), project), rest[0]!); if (v.tier !== "long") return `${v.id.slice(0, 8)} is already short-term`; store.demote(v.id, actor, project); return `demoted ${v.id.slice(0, 8)}: it expires ${store.get(v.id, project)?.expiresAt ?? "with its short-term lifetime"}`; }
75
+ case "forget": { const v = need(store.forget(rest[0] ?? fail(MEMORY_USAGE), actor, project), rest[0]!); return `forgot ${v.id.slice(0, 8)}${v.local ? "" : "; it stays in the sync remote's git history until a person rewrites it (docs/memory.md)"}`; }
76
+ case "supersede": {
77
+ if (rest.length !== 2) fail("usage: ahub memory supersede <old> <new>");
78
+ return store.supersede(rest[0]!, rest[1]!, actor, project) ? `${rest[0]} is superseded by ${rest[1]}` : fail("both ids must name different entries, and the newer one must reach as far: shared for a shared entry, long-term for a long-term one (ahub memory promote it first)");
79
+ }
80
+ case "sync": {
81
+ const remote = flag("--remote");
82
+ if (rest.length) fail("usage: ahub memory sync [--remote <private git url>]");
83
+ const r = await syncMemory(store, { ...opts.sync, ...(remote ? { remote } : {}) });
84
+ if (json) return JSON.stringify(r, null, 2);
85
+ if (!r.ok) fail(`hub memory sync: ${r.error}`);
86
+ return `synced${r.pushed ? " and pushed" : ""}: ${r.entries} long-term entries visible here${r.warning ? `\nnote: ${r.warning}` : ""}`;
87
+ }
88
+ case "import": {
89
+ const db = flag("--db"), limit = flag("--limit");
90
+ if (rest[0] !== "claude-mem" || rest.length !== 1) fail("usage: ahub memory import claude-mem [--db <path>] [--limit N]");
91
+ if (limit && !(Number(limit) > 0)) fail("--limit takes a positive number");
92
+ const routing = currentRouting(cwd);
93
+ const policy = privatePaths(); // a note about a private path never reaches the synced log
94
+ const key = memoryProjectKey(cwd), deny = loadConfigSafe(cwd)?.local.deny ?? [];
95
+ const nodes = (text: string) => extractNodes(text, { cwd, project: key, repo: key.startsWith("github.com/") ? key.slice("github.com/".length) : key, deny });
96
+ const r = importClaudeMem(store, { project: key, chain: projectChain(cwd), nodes, isPii: (text) => detectSignals({ title: text, detail: "", refs: {} }, routing, cwd).includes("pii") || mentionsPrivatePath(text, [], policy, cwd), ...(db ? { db } : {}), ...(limit ? { limit: Number(limit) } : {}) });
97
+ if (json) return JSON.stringify(r, null, 2);
98
+ return `imported ${r.added} of ${r.read} hub notes from claude-mem as long-term entries (skipped: ${r.skipped.project} other projects, ${r.skipped.model} model answers, ${r.skipped.pii} PII, ${r.skipped.secret} secret-looking; the rest were already here)`;
99
+ }
100
+ case "suggestions": {
101
+ const pending = store.suggestions(project).filter((x) => x.status === "suggested");
102
+ if (json) return JSON.stringify(pending, null, 2);
103
+ return pending.length ? pending.map((x) => `${x.id.slice(0, 8)} ${x.from.slice(0, 8)} ${x.rel} ${x.to.slice(0, 8)} (suggested by the hub's model ${x.at}; not evidence until confirmed)`).join("\n") : "no edge suggestions waiting";
104
+ }
105
+ case "confirm": case "reject": {
106
+ const x = store.judge(rest[0] ?? fail(`usage: ahub memory ${sub} <suggestion id>`), sub, actor, project) ?? fail(`no suggestion ${rest[0]} (ahub memory suggestions lists them)`);
107
+ return `${sub === "confirm" ? "confirmed" : "rejected"}: ${x.from.slice(0, 8)} ${x.rel} ${x.to.slice(0, 8)}`;
108
+ }
109
+ case "machine": {
110
+ const label = flag("--label");
111
+ const m = label ? setMachineLabel(store.dir, label) : store.machine;
112
+ return json ? JSON.stringify(m) : `this machine: ${m.id} (${m.label})`;
113
+ }
114
+ case "rebuild": { store.rebuild(); const c = store.counts(); return `rebuilt the index from ${c.segments} segment${c.segments === 1 ? "" : "s"}: ${c.long} long-term, ${c.short} short-term entries`; }
115
+ default: return fail(MEMORY_USAGE);
116
+ }
117
+ } finally { if (!opts.store) store.close(); }
118
+ }
119
+
120
+ const goalOne = (g: GoalView, me: string) => `${g.id.slice(0, 8)} ${g.state.padEnd(7)} ${g.title}${g.scope === "shared" ? " [shared]" : ""} ${leaseText(g, me)}`;
121
+ function leaseText(g: GoalView, me: string): string {
122
+ const who = (m: string) => m === me ? "this machine" : `machine ${m.slice(0, 8)}`;
123
+ if (g.lease.holder) return `held by ${who(g.lease.holder)} until ${g.lease.until}${g.lease.conflict.length ? `, CONFLICT with ${g.lease.conflict.map(who).join(", ")} (the earlier claim holds; a person settles it with ahub goal take --force)` : ""}`;
124
+ return g.lease.expired ? `free (${who(g.lease.lastHolder!)}'s lease ended ${g.lease.until})` : "free";
125
+ }
126
+
127
+ /**
128
+ * #258: `ahub goal`, long-term tasks. Listing and showing are anyone's; new, link, state, done and take are a
129
+ * person's. A peer links its own board task with hub_task_propose's `goal`.
130
+ */
131
+ export async function goalCommand(args: string[], cwd: string, opts: { store?: MemoryStore; link?: (goal: string, task: string) => Promise<string> } = {}): Promise<string> {
132
+ const json = args.includes("--json");
133
+ const rest = args.filter((a) => a !== "--json");
134
+ const take = (name: string) => { const out: string[] = []; for (let i = rest.indexOf(name); i >= 0; i = rest.indexOf(name)) { out.push(rest[i + 1] ?? fail(`${name} needs a value`)); rest.splice(i, 2); } return out; };
135
+ const sub = rest[0] && !rest[0].startsWith("--") ? rest.shift()! : "list";
136
+ const store = opts.store ?? new MemoryStore();
137
+ const project = memoryProjectKey(cwd);
138
+ const me = store.machine.id;
139
+ // Only this project's long-term tasks and shared ones, like every other lookup an agent may run.
140
+ const need = (id: string | undefined) => store.goalOf(id?.replace(/^goal:/, "") ?? fail(GOAL_USAGE), project) ?? fail(`no long-term task ${id} for this project (ahub goal list shows them)`);
141
+ // Agent shells may read goals and a goal syncs to every machine: titles and milestones pass the same screen as notes
142
+ // (PII patterns, pii.paths; a config that does not load fails closed).
143
+ const policy = (): string[] => { try { return loadConfig(cwd).pii?.paths ?? []; } catch (e) { return fail(`the project's config does not load (${(e as Error).message}); fix it before using long-term tasks`); } };
144
+ const clean = (title: string, milestones: string[], of = project) => !looksSecret(`${title}\n${milestones.join("\n")}`) && withoutPii([{ title, text: milestones.join("\n"), project: of }], currentRouting(cwd), cwd, policy()).length > 0;
145
+ try {
146
+ switch (sub) {
147
+ case "list": {
148
+ const all = rest.includes("--all");
149
+ const listed = store.goals(project).filter((g) => all || (g.state !== "done" && g.state !== "dropped"));
150
+ const goals = listed.filter((g) => clean(g.title, g.milestones, g.project)), withheld = listed.length - goals.length;
151
+ if (json) return JSON.stringify(goals, null, 2);
152
+ return [...(goals.length ? goals.map((g) => goalOne(g, me)) : [`no ${all ? "" : "open "}long-term tasks for ${project} (ahub goal new <title>)`]),
153
+ ...(withheld ? [`(${withheld} withheld: they match a PII pattern or a private path)`] : [])].join("\n");
154
+ }
155
+ case "show": {
156
+ const g = need(rest[0]);
157
+ if (!clean(g.title, g.milestones, g.project)) fail(`${g.id.slice(0, 8)} is withheld: it matches a PII pattern or a private path`);
158
+ if (json) return JSON.stringify(g, null, 2);
159
+ return [goalOne(g, me), `${g.id}, project ${g.project}, created ${g.at} on machine ${g.machine.slice(0, 8)}`,
160
+ ...g.milestones.map((m) => `milestone: ${m}`), ...g.dependsOn.map((d) => `depends on ${d.slice(0, 8)}`),
161
+ ...(g.links.length ? g.links.map((l) => `board task ${l.task} (linked ${l.at} on machine ${l.machine.slice(0, 8)})`) : ["no board tasks linked yet"])].join("\n");
162
+ }
163
+ case "new": {
164
+ const milestones = take("--milestone"), depends = take("--depends").map((d) => need(d).id);
165
+ const shared = rest.includes("--shared");
166
+ const title = rest.filter((a) => a !== "--shared").join(" ").trim() || fail(GOAL_USAGE);
167
+ if (!clean(title, milestones)) fail("a long-term task syncs to every machine: this one holds a secret, a PII pattern or a private path and is not saved");
168
+ const id = store.goal({ project, scope: shared ? "shared" : "project", title, milestones, dependsOn: depends, actor: "user" });
169
+ return `long-term task ${id.slice(0, 8)} created: ${title}`;
170
+ }
171
+ case "link": {
172
+ const g = need(rest[0]);
173
+ const task = rest[1] ?? fail("usage: ahub goal link <id> <task id>");
174
+ if (!/^\d+$/.test(task)) fail("the task is a board task id of this project");
175
+ return await (opts.link ?? fail("linking needs this project's hub running (ahub up)"))(g.id, task);
176
+ }
177
+ case "state": case "done": {
178
+ const g = need(rest[0]);
179
+ const state = (sub === "done" ? "done" : rest[1]) as GoalState;
180
+ if (!GOAL_STATES.includes(state)) fail(`the state is one of ${GOAL_STATES.join(", ")}`);
181
+ store.goalState(g.id, state, "user");
182
+ return `long-term task ${g.id.slice(0, 8)} is ${state}`;
183
+ }
184
+ case "take": {
185
+ const g = need(rest[0]);
186
+ const r = store.take(g.id, "user", { force: rest.includes("--force") });
187
+ if (!r.ok) fail(`held by machine ${r.holder.slice(0, 8)} until ${r.until}; wait for it to expire or take it over with --force`);
188
+ return `this machine holds long-term task ${g.id.slice(0, 8)} until ${r.until}${rest.includes("--force") ? " (earlier claims are void)" : ""}`;
189
+ }
190
+ default: return fail(GOAL_USAGE);
191
+ }
192
+ } finally { if (!opts.store) store.close(); }
193
+ }
@@ -0,0 +1,404 @@
1
+ import { boardColumns, type BoardPeerInput } from "../ui/task-board.ts";
2
+ import { peerStyle } from "../ui/peer-palette.ts";
3
+ import { cells, TABLES, table, fitLine, wrap, duration, relative, plural, terminalText, initialConsoleState, stateTone, type Span, type Tone } from "./console-state.ts";
4
+ import { taskProgress } from "../ui/task-progress.ts";
5
+ import { backendLabel } from "./status-lines.ts";
6
+ import { formatReport, formatTaskReport, type Report, type TaskReport } from "../hub/report.ts";
7
+
8
+ export interface DoctorCheck { section: "Tools" | "Hub" | "Config" | "Models" | "Memory"; level: "ok" | "warn" | "fail" | "unknown"; name: string; detail: string }
9
+ export interface StatusOutput {
10
+ taskWip?: Record<string, { wip: number; wipLimit: number }>;
11
+ pid?: number; controlPort?: number; cwd?: string; version?: string; peers: Record<string, any>; roleInfo?: Record<string, any>; budget?: Record<string, any>;
12
+ tasks?: Record<string, number>; models?: { backends?: any[] }; permissionDefaults?: any[]; deliveryError?: string; crash?: string[]; switchyard?: string;
13
+ }
14
+ export interface BudgetOutput { budget: Record<string, any>; gate?: number }
15
+ export interface RosterRow { peer: string; on: boolean; startMode: string; state: string; queued: number; tasks: number }
16
+ export function renderRoster(data: RosterRow[], columns?: number): Span[][] {
17
+ return rows(["PEER", "ROSTER", "START", "STATE", "Q", "TASKS"], data.map(row => [span(row.peer, "info", true), span(row.on ? "on" : "off", row.on ? "success" : "muted", true), span(row.startMode, undefined, true), span(row.state, stateTone(row.state), true), span(row.queued, undefined, true), span(row.tasks, undefined, true)]), columns);
18
+ }
19
+ export function outputWidth(env: { isTTY: boolean; columns?: number; COLUMNS?: string }): number | undefined {
20
+ if (env.isTTY) return Math.max(80, Number.isFinite(env.columns) ? env.columns! : 80);
21
+ return env.COLUMNS && /^[1-9][0-9]*$/.test(env.COLUMNS) && Number.isSafeInteger(Number(env.COLUMNS)) ? Number(env.COLUMNS) : undefined;
22
+ }
23
+ interface OutputCell extends Span { atomic?: boolean; items?: string[]; pathItems?: boolean; prefix?: string }
24
+ const span = (text: unknown, tone?: Tone, atomic = false): OutputCell => ({ text: String(text ?? ""), ...(tone ? { tone } : {}), ...(atomic ? { atomic: true } : {}) });
25
+ const flat = (value: unknown) => terminalText(value).replace(/[\n\t]/g, " ");
26
+ const width = (value: string) => Bun.stringWidth(value);
27
+ const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
28
+ const characters = (value: string) => [...graphemes.segment(value)].map(unit => unit.segment);
29
+ const peerTone = (id: string): Tone => peerStyle(id).tone;
30
+ const shortId = (id: unknown, columns?: number, full = false) => full || columns === undefined ? String(id) : String(id).slice(0, 8);
31
+ function lines(text: string, columns?: number, tone?: Tone, indent = ""): Span[][] {
32
+ return (columns === undefined ? [flat(indent + text)] : wrap(flat(indent + text), columns)).map(text => [span(text, tone)]);
33
+ }
34
+ /** Prefer path separators while retaining every character; only oversized individual segments use ordinary wrapping, with a cell reserved so the segment's trailing slash rides its last piece. */
35
+ function pathChunks(text: string, columns: number): string[] {
36
+ const chunks: string[] = []; let line = "";
37
+ for (const part of text.match(/[^/]+\/?|\//g) ?? []) {
38
+ if (line && width(line + part) > columns) { chunks.push(line); line = ""; }
39
+ if (width(part) > columns) {
40
+ const slash = part.endsWith("/");
41
+ const split = wrap(slash ? part.slice(0, -1) : part, slash ? columns - 1 : columns, 0);
42
+ chunks.push(...split.slice(0, -1)); line = split.at(-1)! + (slash ? "/" : "");
43
+ } else line += part;
44
+ }
45
+ if (line || !chunks.length) chunks.push(line);
46
+ return chunks.flatMap(chunk => wrap(chunk, columns, 0));
47
+ }
48
+ /** Keep logical list items together before breaking oversized paths or words. A separator trails the item it follows: when an item and its separator do not fit, the item moves to the next line whole (or breaks after a slash, reserving a cell for the separator); the separator never gives, never leads a line, never holds one alone. */
49
+ function itemChunks(cell: OutputCell, columns: number): string[] {
50
+ if (!cell.items?.length) return wrap(cell.text, columns, 0);
51
+ const chunks: string[] = []; let line = cell.prefix ?? "";
52
+ for (const [index, item] of cell.items!.entries()) {
53
+ const last = index === cell.items!.length - 1;
54
+ const unit = item + (last ? "" : ",");
55
+ const glue = line && (index || cell.prefix) ? " " : "";
56
+ if (width(line + glue + unit) <= columns) { line += glue + unit; continue; }
57
+ if (line) chunks.push(line);
58
+ const room = last ? columns : columns - 1;
59
+ const split = width(item) <= room ? [item] : cell.pathItems ? pathChunks(item, room) : wrap(item, room, 0);
60
+ // A slashless split keeps three visible graphemes beside its comma when they fit.
61
+ // Rebalance complete units, including combining marks/ZWJ sequences, never UTF-16 halves.
62
+ if (!last && split.length > 1 && !item.includes("/") && characters(split.at(-1)!).filter(unit => width(unit) > 0).length < 3 && split.join("") === item) {
63
+ const units = characters(split.slice(-2).join(""));
64
+ let cut = units.length, visible = 0;
65
+ while (cut > 0 && visible < 3) if (width(units[--cut]!) > 0) visible++;
66
+ const prefix = units.slice(0, cut).join(""), tail = units.slice(cut).join("");
67
+ if (visible === 3 && prefix && width(prefix) <= room && width(tail) <= room) split.splice(-2, 2, prefix, tail);
68
+ }
69
+ if (!last) split[split.length - 1] += ",";
70
+ chunks.push(...split.slice(0, -1)); line = split.at(-1)!;
71
+ }
72
+ if (line || !chunks.length) chunks.push(line);
73
+ return chunks.flatMap(chunk => wrap(chunk, columns, 0));
74
+ }
75
+ /** Wrap complete cells before handing canonical headers and explicit widths to the shared console table. A column that is "-" on every row is dropped at the top level; a band keeps it, so a narrow render carries the same cells as the unbounded one (#284 round 4). */
76
+ function rows(head: string[], data: OutputCell[][], columns?: number, details?: Span[][][], keepDash = false): Span[][] {
77
+ if (!data.length) return [];
78
+ const keep = head.map((_, i) => i).filter(i => keepDash || !data.every(row => flat(row[i]?.text) === "-"));
79
+ head = keep.map(i => head[i]!); data = data.map(row => fitLine(keep.map(i => ({ ...row[i]!, text: flat(row[i]?.text), ...(row[i]?.items ? { items: row[i]!.items!.map(flat), prefix: row[i]!.prefix === undefined ? undefined : flat(row[i]!.prefix) } : {}) })), Infinity));
80
+ const natural = head.map((h, i) => Math.max(width(h), ...data.map(row => width(row[i]!.text))));
81
+ const interleave = (rendered: Span[][], lengths: number[], headerHeight = 1) => {
82
+ if (!details) return rendered;
83
+ let at = headerHeight;
84
+ return [...rendered.slice(0, headerHeight), ...lengths.flatMap((length, i) => { const group = rendered.slice(at, at + length); at += length; return [...group, ...(details[i] ?? [])]; })];
85
+ };
86
+ const renderAtWidths = (widths: number[]) => {
87
+ const lengths: number[] = [];
88
+ const physical = data.flatMap(row => {
89
+ const chunks = row.map((cell, i) => cell.items ? itemChunks(cell, widths[i]!) : head[i] === "ROOT" ? pathChunks(cell.text, widths[i]!) : wrap(cell.text, widths[i]!, 0));
90
+ const length = Math.max(...chunks.map(c => c.length)); lengths.push(length);
91
+ return Array.from({ length }, (_, n) => row.map((cell, i) => ({ ...cell, text: chunks[i]![n] ?? "" })));
92
+ });
93
+ return interleave(table(head, physical, columns ?? Infinity, widths), lengths);
94
+ };
95
+ if (columns === undefined) return interleave(table(head, data, Infinity, natural), data.map(() => 1));
96
+ if (head.length === 1) {
97
+ const size = Math.min(columns, natural[0]!);
98
+ const chunk = (cell: OutputCell) => cell.items ? itemChunks(cell, size) : head[0] === "ROOT" ? pathChunks(cell.text, size) : wrap(cell.text, size, 0);
99
+ const lengths = data.map(row => chunk(row[0]!).length);
100
+ const physical = data.flatMap(row => chunk(row[0]!).map(text => [{ ...row[0]!, text }]));
101
+ return interleave(table(head, physical, columns, [size]), lengths, wrap(head[0]!, size, 0).length);
102
+ }
103
+ const flexible = head.includes("TITLE") ? head.indexOf("TITLE") : head.length - 1;
104
+ // TASKS is not protected: its items stay whole at any width (itemChunks breaks between them), so it wraps
105
+ // before ROOT floors (#284 round 3).
106
+ const protectedColumns = new Set(head.map((h, i) => ["ID", "PROJECT", "TURN", "TASK", "OWNER", "REVIEWER", "AGE", "STAGE", "Q", "!", "REVIEW", "REV", "PEER", "PEERS", "PIDS", "TURNS", "BUSY", "TOKENS", "COUNT", "FIELD", "STATE", "MODE", "LEVEL", "LINK", "CLASS"].includes(h) ? i : -1));
107
+ const words = head.map((_, i) => Math.max(...data.flatMap(row => row[i]!.text.split(/\s+/).map(width))));
108
+ const minimum = head.map((h, i) => Math.max(width(h), protectedColumns.has(i) ? words[i]! : 0,
109
+ Math.max(0, ...data.filter(row => row[i]!.atomic).map(row => width(row[i]!.text))),
110
+ h === "TASKS" ? Math.max(0, ...data.flatMap(row => row[i]!.items?.map((item, j, all) => width(item) + (j < all.length - 1 ? 1 : 0)) ?? [])) : 0));
111
+ // Reserve readable flexible text before a multi-word state consumes the space; atomic words still win.
112
+ // A ROOT column keeps a quarter of a wide table: an item list (TASKS) wraps between its items instead (#284 round 3).
113
+ const pathFloor = head[flexible] === "ROOT" && columns !== undefined && columns > 80 ? Math.floor(columns / 4) : 12;
114
+ if (["TITLE", "CONTEXT", "ROOT", "FILES"].includes(head[flexible]!)) minimum[flexible] = Math.max(minimum[flexible]!, Math.min(natural[flexible]!, pathFloor));
115
+ // A FILES column has no floor of its own: a floor is paid by the time and state cells before the shorter-only
116
+ // comparison below looks at anything, so it would wrap them without the listing getting shorter (#284 round 5).
117
+ // Label/value pairs never become unrelated lists. Narrow values wrap beside their own label.
118
+ if (head[0] === "FIELD" && head[1] === "VALUE" && natural[0]! + minimum[1]! + 2 > columns) {
119
+ if (columns < 12) return [...lines("FIELD VALUE", columns), ...data.flatMap(row => lines(`${row[0]!.text}: ${row[1]!.text}`, columns, row[1]!.tone))];
120
+ const label = Math.min(natural[0]!, columns - 2 - width(head[1]!));
121
+ return renderAtWidths([label, columns - 2 - label]);
122
+ }
123
+ // Narrow tables become linked bands before an id, peer, age, counter or state word would split.
124
+ if (minimum.reduce((sum, n) => sum + n, 0) + 2 * (head.length - 1) > columns) {
125
+ const bands: number[][] = []; let band = [0];
126
+ for (let i = 1; i < head.length; i++) {
127
+ // Keep a board title with its stage meter: if both cannot fit here, give the title the next band's room.
128
+ const stage = head.indexOf("STAGE");
129
+ const trial = [...band, i, ...(i === flexible && stage > i ? [stage] : [])];
130
+ if (trial.reduce((sum, index) => sum + minimum[index]!, 0) + 2 * (trial.length - 1) > columns) {
131
+ bands.push(band);
132
+ band = minimum[0]! + minimum[i]! + 2 <= columns ? [0] : [];
133
+ }
134
+ band.push(i);
135
+ }
136
+ bands.push(band);
137
+ return bands.flatMap((indices, i) => {
138
+ const part = indices.includes(0)
139
+ ? rows(indices.map(index => head[index]!), data.map(row => indices.map(index => row[index]!)), columns, i === bands.length - 1 ? details : undefined, true)
140
+ : data.flatMap((row, n) => [...rows([head[0]!], [[row[0]!]], columns, undefined, true),
141
+ ...rows(indices.map(index => head[index]!), [indices.map(index => row[index]!)], columns, i === bands.length - 1 && details ? [details[n]!] : undefined, true)]);
142
+ return [...(i ? [[]] : []), ...part];
143
+ });
144
+ }
145
+ const available = columns - 2 * (head.length - 1);
146
+ const widths = natural.map((n, i) => i === flexible ? Math.max(minimum[i]!, Math.min(n, 12)) : Math.max(minimum[i]!, Math.min(n, Math.max(24, Math.floor(columns / 3)))));
147
+ // Whole state phrases have priority too: they shrink to word boundaries only if the other columns need the room.
148
+ for (const i of protectedColumns) if (i >= 0) widths[i] = natural[i]!;
149
+ while (widths.reduce((sum, n) => sum + n, 0) > available) {
150
+ const choices = widths.map((n, i) => ({ i, excess: n - minimum[i]! })).filter(item => item.excess > 0);
151
+ if (!choices.length) break;
152
+ choices.sort((a, b) => Number(b.i === flexible) - Number(a.i === flexible) ||
153
+ Number(protectedColumns.has(a.i)) - Number(protectedColumns.has(b.i)) || b.excess - a.excess);
154
+ widths[choices[0]!.i]!--;
155
+ }
156
+ widths[flexible] = Math.max(minimum[flexible]!, Math.min(natural[flexible]!, available - widths.reduce((sum, n, i) => sum + (i === flexible ? 0 : n), 0)));
157
+ // A flexible FILES column grows toward natural while protected columns give down to their longest word, but only
158
+ // when that makes the listing shorter: both layouts are rendered and the taller one is dropped (#284 round 4).
159
+ if (head[flexible] === "FILES") {
160
+ const grown = [...widths];
161
+ while (grown[flexible]! < natural[flexible]!) {
162
+ const donors = grown.map((n, i) => ({ i, room: i === flexible ? 0 : n - Math.max(minimum[i]!, protectedColumns.has(i) ? words[i]! : 0) })).filter(d => d.room > 0);
163
+ if (!donors.length) break;
164
+ donors.sort((a, b) => b.room - a.room);
165
+ grown[donors[0]!.i]!--; grown[flexible]!++;
166
+ }
167
+ if (grown[flexible]! > widths[flexible]!) {
168
+ const grownRender = renderAtWidths(grown);
169
+ const plainRender = renderAtWidths(widths);
170
+ return grownRender.length < plainRender.length ? grownRender : plainRender;
171
+ }
172
+ }
173
+ return renderAtWidths(widths);
174
+ }
175
+ function detail(label: string, text: unknown, columns?: number, tone?: Tone): Span[][] {
176
+ return lines(`${label} ${flat(text)}`, columns, tone, " ");
177
+ }
178
+ function contextText(p: any, now: number): string {
179
+ const c = p.context;
180
+ if (!c) return "unknown";
181
+ return `${c.used === null || c.used === undefined ? "unknown" : `${Math.round(c.used * 100)}%`}${c.freshness === "stale" ? " stale" : ""}${typeof c.measuredAt === "number" ? ` ${relative(c.measuredAt, now)}` : ""}${c.source ? ` ${c.source}` : ""}`;
182
+ }
183
+ export function renderStatus(data: StatusOutput, columns?: number, now = Date.now(), full = false): Span[][] {
184
+ if (data.roleInfo) data = { ...data, peers: Object.fromEntries(Object.entries(data.peers).map(([peer, value]) => [peer, { ...value, ...data.roleInfo?.[peer] }])) };
185
+ const out = lines(`agent-hub${data.version ? ` ${data.version}` : ""} pid ${data.pid ?? "unknown"} control 127.0.0.1:${data.controlPort ?? "unknown"}`, columns, "info");
186
+ if (data.cwd) out.push(...lines(data.cwd, columns));
187
+ if (data.deliveryError) out.push(...detail("delivery storage", `${data.deliveryError}; dispatch is stopped`, columns, "failure"));
188
+ for (const pending of data.permissionDefaults ?? []) out.push(...detail("permission default", `${pending.peer}: never-ask from ${pending.source}, stays ask until a person confirms in ahub console`, columns, "attention"));
189
+ for (const crash of data.crash ?? []) out.push(...detail("crash recovery", crash, columns, "attention"));
190
+ const s = initialConsoleState(); s.panel = 1; s.peers = data.peers; s.budget = data.budget ?? {};
191
+ const head = Object.values(data.peers).some(p => p.permissionMode && p.permissionMode !== "ask") ? ["PEER", "STATE", "MODE", ...TABLES[1]!.slice(2)] : [...TABLES[1]!];
192
+ head.push("CONTEXT");
193
+ const peerRows = Object.entries(data.peers).map(([id, p]) => {
194
+ const result = cells(s, { id, ...p }, false, now); result[0] = span(id, peerTone(id));
195
+ result.push(span(contextText(p, now))); return result;
196
+ });
197
+ let settling = false;
198
+ const peerDetails = Object.entries(data.peers).map(([id, p]) => {
199
+ const out: Span[][] = [];
200
+ if (p.flowRoles) out.push(...detail(`${id} flow roles`, p.flowRoles.join(", ") || "none", columns));
201
+ if (p.catalogRoles) { out.push(...detail(`${id} catalog roles`, Array.isArray(p.catalogRoles.value) ? p.catalogRoles.value.join(", ") || "none" : "unconfirmed", columns)); if (p.catalogRoles.pending !== undefined) out.push(...detail(`${id} pending roles`, p.catalogRoles.pending.join(", ") || "none", columns, "attention")); }
202
+ if (p.liveAccepted?.length) { settling = true; out.push(...detail("settling", p.liveAccepted.map((v: string) => shortId(v, columns, full)).join(", "), columns, "attention")); }
203
+ if (p.heldBy) {
204
+ const note = flat(p.holdNote ?? "");
205
+ const command = `ahub queue resolve ${p.heldBy} --action completed|retry|discard --reason <text>`;
206
+ const normalized = note.startsWith(`held by needs_review ${p.heldBy}`)
207
+ ? note.replace(`held by needs_review ${p.heldBy}`, `by needs_review ${shortId(p.heldBy, columns, full)}`)
208
+ : `${shortId(p.heldBy, columns, full)}${note ? `: ${note}` : ""}`;
209
+ out.push(...detail("held", `${normalized}${note.includes(`ahub queue resolve ${p.heldBy}`) ? "" : `; ${command}`}`, columns, "failure"));
210
+ }
211
+ if (p.paused) {
212
+ const budgetPause = s.budget[id]?.paused;
213
+ const pauseText = budgetPause ? `budget: ${budgetPause.reason ?? "threshold"}${typeof budgetPause.resetsAt === "number" ? `; resets ${relative(budgetPause.resetsAt, now)}` : "; reset unknown"}`
214
+ : typeof p.paused === "object" ? `${p.paused.by ?? "paused"}${p.paused.reason ? `: ${p.paused.reason}` : ""}${typeof p.paused.at === "number" ? ` ${relative(p.paused.at, now)}` : ""}`
215
+ : flat(p.paused).replace(/^(budget: .*), resets .+$/, "$1; reset unknown");
216
+ out.push(...detail("paused", pauseText, columns, "attention"));
217
+ }
218
+ if (p.toolsOnly) out.push(...detail(id === "kimi" && p.pullOnly ? "pull-only" : "tools-only", flat(p.toolsOnly).replace(/^(?:tools-only|pull-only):\s*/, ""), columns, "attention"));
219
+ if (p.pullOnly && id === "kimi") out.push(...detail("kimi last read", typeof p.lastInboxAt === "number" ? relative(p.lastInboxAt, now) : "never", columns, "muted"));
220
+ if (p.permissionNote) out.push(...detail("permission", p.permissionNote, columns, "muted"));
221
+ if (p.requestedModel && p.servedBy && p.requestedModel !== p.servedBy) out.push(...detail("requested model", p.requestedModel, columns));
222
+ if (typeof p.oldestQueuedAt === "number") out.push(...detail(`${id} oldest queued`, relative(p.oldestQueuedAt, now), columns, "muted"));
223
+ return out;
224
+ });
225
+ if (peerRows.length) out.push([], ...rows(head, peerRows, columns, peerDetails));
226
+ else out.push(...lines("no peers attached yet (ahub claude | ahub codex | ahub kimi)", columns));
227
+ if (settling) out.push([], ...lines("settling: the adapter has not confirmed these deliveries yet (Claude: reply or hub_delivery_done). Task state is independent.", columns, "muted"));
228
+ if (data.taskWip) for (const [peer, value] of Object.entries(data.taskWip)) out.push(...detail(`${peer} planner WIP`, `${value.wip}/${value.wipLimit}`, columns));
229
+ if (data.models?.backends?.length) {
230
+ out.push([], ...rows(["MODEL", "STATE", "ACTIVE", "REQUESTED", "ACTUAL", "PROVIDER"], data.models.backends.map(b => [span(backendLabel(b)), span(b.state ?? "unknown", stateTone(b.state ?? "")), span(b.active || "-"), span(b.requestedModel ?? "-"), span(b.actualModel ?? "-"), span(b.provider ?? "-")]), columns));
231
+ for (const b of data.models.backends) for (const [key, label] of [["coolingUntil", "cooling"], ["failingUntil", "dispatch held"]]) if (b[key!]) { const at = Date.parse(b[key!]); out.push(...detail(`${backendLabel(b)} ${label}`, `${Number.isFinite(at) ? relative(at, now) : "unknown"}${key === "coolingUntil" && typeof b.failures === "number" ? ` after ${b.failures} failures` : key === "failingUntil" ? "; no load moves until then" : ""}`, columns, "attention")); }
232
+ }
233
+ if (data.switchyard) out.push(...detail("switchyard", data.switchyard, columns));
234
+ const counts = Object.entries(data.tasks ?? {}).map(([state, count]) => `${count} ${state}`).join(", ");
235
+ if (counts) out.push([], ...lines(`TASKS ${counts} (ahub board)`, columns));
236
+ return out;
237
+ }
238
+ export function renderBoard(tasks: any[], columns?: number, now = Date.now(), _full = false, allTasks: any[] | null = tasks): Span[][] {
239
+ if (!tasks.length) return [[span("no tasks")]];
240
+ const s = initialConsoleState(); s.panel = 3; s.tasks = tasks;
241
+ const stages = allTasks === null ? undefined : new Map(taskProgress(boardColumnContext(tasks, allTasks)).stages.map(stage => [stage.id, stage]));
242
+ const data = tasks.map(task => {
243
+ const row = cells(s, task, false, now, stages);
244
+ row[2] = span(row[2]!.text, peerTone(task.owner ?? ""));
245
+ row[3] = span(row[3]!.text, peerTone(task.reviewer ?? ""));
246
+ row[6] = span(`${task.title}${task.deps?.length ? ` after ${task.deps.map((id: number) => `#${id}`).join(", ")}` : ""}${task.signals?.includes("pii") ? ` (ahub task show ${task.id})` : ""}`);
247
+ if (allTasks === null) row.pop();
248
+ return row;
249
+ });
250
+ const counts = [...new Set(tasks.map(task => task.state))].map(state => `${tasks.filter(task => task.state === state).length} ${state}`).join(", ");
251
+ return [...rows(allTasks === null ? TABLES[3]!.slice(0, -1) : [...TABLES[3]!], data, columns), [], ...lines(`${plural(tasks.length, "task")}: ${counts}`, columns)];
252
+ }
253
+ export function renderBudget(data: BudgetOutput, columns?: number, now = Date.now(), _full = false): Span[][] {
254
+ const out: Span[][] = [];
255
+ const windows = Object.entries(data.budget).flatMap(([peer, b]) => (b.windows ?? []).map((w: any) => [span(peer, peerTone(peer)), span(w.id), span(`${Math.round(w.used * 100)}%`, "number"), span(typeof w.resetsAt === "number" ? relative(w.resetsAt, now) : "-", "muted"), span(w.source ?? "unknown", "muted"), span(typeof w.at === "number" ? relative(w.at, now) : "unknown", "muted"), span(w.stale ? "stale" : "fresh", w.stale ? "attention" : undefined)]));
256
+ if (windows.length) out.push(...rows(["PEER", "WINDOW", "USED", "RESETS", "SOURCE", "MEASURED", "STATE"], windows, columns));
257
+ else out.push(...lines(`no quota readings yet (gate ${data.gate ?? "unknown"}). Sources: Codex rate limits, Claude's status line (ahub claude), ahub budget set.`, columns));
258
+ for (const [peer, b] of Object.entries(data.budget)) if (b.paused) out.push(...detail(`paused ${peer}`, `${b.paused.reason ?? "budget"}${typeof b.paused.resetsAt === "number" ? `; resumes ${relative(b.paused.resetsAt, now)}` : ""}`, columns, "attention"));
259
+ return out;
260
+ }
261
+ function boardColumnContext(tasks: any[], allTasks: any[] | null): any[] {
262
+ if (allTasks === null) throw new Error("board column dependency context unavailable; retry with a complete snapshot");
263
+ const first = new Map(tasks.map(task => [task.id, task])), context = allTasks.map(task => first.get(task.id) ?? task);
264
+ const included = new Set(context.map(task => task.id));
265
+ for (const task of tasks) if (!included.has(task.id)) context.push(task);
266
+ return context;
267
+ }
268
+ export function boardColumnModel(tasks: any[], peers: Record<string, BoardPeerInput>, allTasks: any[] | null = tasks) {
269
+ const visible = new Set(tasks.map(task => task.id)), context = boardColumnContext(tasks, allTasks);
270
+ const model = boardColumns(context.map(task => ({ ...task, urgent: task.signals?.includes("urgent") === true })), peers);
271
+ return { columns: model.columns.map(column => ({ ...column, ready: column.ready.filter(card => visible.has(card.id)), waiting: column.waiting.filter(card => visible.has(card.id)) })), unknown: model.unknown.filter(id => visible.has(id)) };
272
+ }
273
+ export function renderBoardColumns(tasks: any[], peers: Record<string, BoardPeerInput>, columns?: number, now = Date.now(), full = false, allTasks: any[] | null = tasks): Span[][] {
274
+ const visible = new Set(tasks.map(task => task.id)), byId = new Map(tasks.map(task => [task.id, task]));
275
+ const context = boardColumnContext(tasks, allTasks), model = boardColumnModel(tasks, peers, context), out: Span[][] = [];
276
+ for (const column of model.columns) {
277
+ const ready = column.ready.filter(card => visible.has(card.id)), waiting = column.waiting.filter(card => visible.has(card.id));
278
+ if (out.length) out.push([]); out.push([span(column.label, "strong")]);
279
+ if (!ready.length && !waiting.length) { out.push([span("(empty)", "muted")]); continue; }
280
+ if (column.id === "todo") out.push([span("Ready", "strong")]);
281
+ if (ready.length) out.push(...renderBoard(ready.map(card => byId.get(card.id)), columns, now, full, context));
282
+ if (waiting.length) { out.push([span("Waiting", "strong")]); out.push(...renderBoard(waiting.map(card => byId.get(card.id)), columns, now, full, context)); for (const card of waiting) out.push(...lines(`#${card.id}: ${card.waiting.join("; ")}`, columns)); }
283
+ }
284
+ const unknown = model.unknown.filter(id => visible.has(id));
285
+ if (unknown.length) out.push([], [span("Unknown state", "strong")], ...renderBoard(unknown.map(id => byId.get(id)), columns, now, full, context));
286
+ return out;
287
+ }
288
+
289
+ export function renderDoctor(checks: DoctorCheck[], columns?: number, _now = Date.now(), _full = false): Span[][] {
290
+ const out: Span[][] = [];
291
+ const tone = { ok: "success", warn: "attention", fail: "failure", unknown: "muted" } as const;
292
+ for (const section of ["Tools", "Hub", "Config", "Models", "Memory"] as const) {
293
+ const group = checks.filter(check => check.section === section); if (!group.length) continue;
294
+ if (out.length) out.push([]); out.push([span(section, "strong")]);
295
+ out.push(...rows(["LEVEL", "CHECK", "FINDING"], group.map(check => [span(check.level, tone[check.level]), span(check.name), span(check.detail || "finding unknown")]), columns));
296
+ }
297
+ const count = (level: DoctorCheck["level"]) => checks.filter(c => c.level === level).length;
298
+ out.push([], ...lines(`${count("fail")} failure${count("fail") === 1 ? "" : "s"}, ${count("warn")} warning${count("warn") === 1 ? "" : "s"}, ${count("ok")} ok, ${count("unknown")} unknown`, columns));
299
+ return out;
300
+ }
301
+
302
+ export function renderProjects(projects: any[], columns?: number, _now = Date.now()): Span[][] {
303
+ if (!projects.length) return lines("No registered projects. Run ahub init in a project directory.", columns);
304
+ return rows(["PROJECT", "STATE", "PEERS", "TASKS", "ROOT"], projects.map(project => {
305
+ const tasks = Object.entries(project.status?.tasks ?? {}).map(([state, n]) => `${n} ${state}`).join(", ");
306
+ return [span(project.id, "info"), span(project.state ?? "unknown", stateTone(project.state ?? "")),
307
+ span(project.status ? Object.keys(project.status.peers ?? {}).length : "unknown", "number"), { ...span(tasks || "-"), items: Object.entries(project.status?.tasks ?? {}).map(([state, n]) => `${n} ${state}`) }, span(project.root)];
308
+ }), columns, projects.map(project => project.error ? detail("error", project.error, columns, "failure") : []));
309
+ }
310
+ export function renderQueue(deliveries: any[], columns?: number, now = Date.now()): Span[][] {
311
+ if (!deliveries.length) return lines("no retained deliveries", columns);
312
+ const s = initialConsoleState(); s.panel = 4;
313
+ const data = deliveries.map(delivery => {
314
+ const row = cells(s, { ...delivery, id: String(delivery.id) }, false, now);
315
+ row[1] = span(row[1]!.text, peerTone(delivery.peer)); return row;
316
+ });
317
+ return rows([...TABLES[4]!], data, columns, deliveries.map(delivery => delivery.important ? detail("priority", "important", columns, "attention") : []));
318
+ }
319
+ const moment = (value: unknown, now: number) => {
320
+ const at = typeof value === "number" ? value : typeof value === "string" ? Date.parse(value) : NaN;
321
+ return Number.isFinite(at) ? relative(at, now) : "unknown";
322
+ };
323
+ export function renderTurns(turns: any[], columns?: number, now = Date.now()): Span[][] {
324
+ if (!turns.length) return lines("no turns recorded (they need a git work tree and snapshots.enabled)", columns);
325
+ return rows(["TURN", "PEER", "STARTED", "STATE", "FILES"], turns.map(turn => {
326
+ const state = !turn.ended ? "running" : !turn.end_tree ? "no end snapshot" : "completed";
327
+ const files = turn.changed ?? [];
328
+ return [span(turn.id, "info"), span(turn.peer ?? "unknown", peerTone(turn.peer)), span(moment(turn.started, now), "muted"),
329
+ span(state, state === "running" ? "attention" : state === "no end snapshot" ? "failure" : "success"), { ...span(`${plural(files.length, "file")}${files.length ? `: ${files.join(", ")}` : ""}`), ...(files.length ? { items: files, pathItems: true, prefix: `${plural(files.length, "file")}:` } : {}) }];
330
+ }), columns);
331
+ }
332
+ export function renderOrphans(orphans: { project: any; pids: number[] }[], columns?: number, _now = Date.now(), killing = false): Span[][] {
333
+ if (!orphans.length) return lines("no orphaned hub registrations", columns);
334
+ return [...lines("orphaned hub registrations (the project root is gone):", columns, "strong"),
335
+ ...rows(["PROJECT", "STATE", "PIDS", "ROOT"], orphans.map(({ project, pids }) => [span(project.id, "info"), span(pids.length ? "live" : "no live process", pids.length ? "attention" : "muted"), span(pids.length ? pids.join(", ") : "-", "number"), span(project.root)]), columns),
336
+ ...(killing ? [] : [[], ...lines("kill live orphans with ahub doctor --orphans --kill", columns)]),
337
+ ...orphans.flatMap(({ project }) => detail("forget", `ahub projects remove ${project.id}`, columns))];
338
+ }
339
+ const TIME_FIELD = /^(?:at|ts|started|ended|created|updated|expires)$|(?:At|Until)$/;
340
+ const ID_FIELD = /^(?:id|trace|previousId|sessionId|threadId|operationId|turnId|envelopeIds)$/;
341
+ /** Labelled scalar leaves preserve the daemon's public view without a raw document or inferred privacy changes. */
342
+ function fields(value: unknown, columns: number | undefined, now: number, prefix = ""): Span[][] {
343
+ const leaves: OutputCell[][] = [];
344
+ const visit = (current: unknown, label: string, key: string) => {
345
+ if (Array.isArray(current)) {
346
+ if (!current.length) leaves.push([span(label), span("none", "muted")]);
347
+ else current.forEach((item, index) => visit(item, `${label}${label ? " " : ""}${index + 1}`, key));
348
+ return;
349
+ }
350
+ if (current && typeof current === "object") {
351
+ const entries = Object.entries(current);
352
+ if (!entries.length) leaves.push([span(label), span("none", "muted")]);
353
+ else for (const [child, item] of entries) visit(item, `${label}${label ? " " : ""}${child}`, child);
354
+ return;
355
+ }
356
+ let text: string;
357
+ if (current === null || current === undefined) text = "unknown";
358
+ else if (TIME_FIELD.test(key)) text = moment(current, now);
359
+ else if (/(?:^| )elapsed_ms(?: |$)/.test(label) && typeof current === "number") text = duration(current);
360
+ else text = flat(current);
361
+ leaves.push([span(label || "value"), span(text, ID_FIELD.test(key) ? "info" : key === "state" ? stateTone(text) : typeof current === "number" ? "number" : TIME_FIELD.test(key) ? "muted" : undefined, ID_FIELD.test(key))]);
362
+ };
363
+ visit(value, prefix, prefix);
364
+ return rows(["FIELD", "VALUE"], leaves, columns);
365
+ }
366
+ export function renderQueueShow(delivery: any, columns?: number, now = Date.now()): Span[][] {
367
+ const out = [...lines("Delivery", columns, "strong"), ...fields(delivery, columns, now)];
368
+ if (delivery?.state === "needs_review" && delivery.id) out.push([], ...detail("resolve", `ahub queue resolve ${delivery.id} --action completed|retry|discard --reason <text>`, columns, "failure"));
369
+ return out;
370
+ }
371
+ export function renderModelsStatus(status: unknown, columns?: number, now = Date.now()): Span[][] {
372
+ return [...lines("Models", columns, "strong"), ...fields(status, columns, now)];
373
+ }
374
+ export function renderExecutionBudgetStatus(budgets: unknown, columns?: number, now = Date.now()): Span[][] {
375
+ return [...lines("Execution budget", columns, "strong"), ...(Array.isArray(budgets) && !budgets.length ? lines("No execution budgets configured.", columns) : budgets === undefined ? lines("No matching execution budget.", columns) : fields(budgets, columns, now))];
376
+ }
377
+ /** Retain the report's coverage and attribution sentences; add sections and aligned counters for scanning. */
378
+ export function renderReport(report: Report | TaskReport, columns?: number, now = Date.now(), byTask = false): Span[][] {
379
+ const out = lines(`period: ${report.from ? moment(report.from, now) : "-"} .. ${report.to ? moment(report.to, now) : "-"}`, columns, "muted");
380
+ const formatted = byTask ? formatTaskReport(report as TaskReport) : formatReport(report as Report);
381
+ if (!byTask) {
382
+ const r = report as Report;
383
+ out.push([], ...lines("Counters", columns, "strong"), ...rows(["METRIC", "COUNT"], [
384
+ [span("messages"), span(r.messages.total, "number")], [span("overflow"), span(r.messages.overflow, "number")],
385
+ [span("undeliverable"), span(r.messages.undeliverable, "number")], [span("messages per task"), span(r.messages.perTask, "number")],
386
+ [span("overlap warnings"), span(r.overlaps.warnings, "number")], [span("task pairs"), span(r.overlaps.pairs, "number")],
387
+ [span("edit conflicts"), span(r.conflicts, "number")], [span("quota readings"), span(r.quota.readings, "number")], [span("hard limits"), span(r.quota.hard, "number")],
388
+ ], columns));
389
+ }
390
+ let previous = "";
391
+ const section = (line: string) => byTask
392
+ ? line.startsWith("task #") ? "Tasks" : line.startsWith("class ") ? "Classes" : line.startsWith("unattributed:") ? "Unattributed" : line.startsWith("before attribution:") ? "Before attribution" : previous
393
+ : line.startsWith("peer ") ? "Peers" : line.startsWith("usage ") ? "Usage" : line.startsWith("supervision ") ? "Supervision" : line.startsWith("conductor ") ? "Conductor" : line.startsWith("messages:") ? "Messages" : line.startsWith("overlap warnings:") ? "Coordination" : line.startsWith("task events:") ? "Tasks" : line.startsWith("quota readings:") ? "Quota" : line.startsWith("route ") ? "Routes" : "Report";
394
+ for (const sentence of formatted.slice(1)) {
395
+ const name = section(sentence);
396
+ if (name !== previous) {
397
+ out.push([], ...lines(name, columns, "strong")); previous = name;
398
+ if (name === "Peers" && !byTask) out.push(...rows(["PEER", "TURNS", "BUSY", "TOKENS"], Object.entries((report as Report).peers).map(([peer, p]) => [span(peer, peerTone(peer)), span(p.turns ?? "unknown", "number"), span(`${p.busyMinutes}m`, "muted"), span(p.tokens || "unknown", "number")]), columns));
399
+ if (name === "Tasks" && byTask) out.push(...rows(["TASK", "CLASS", "OUTCOME", "TURNS", "WALL"], Object.entries((report as TaskReport).tasks).map(([id, task]) => [span(`#${id}${task.pii ? " [pii]" : ""}`, "taskRef"), span(task.class ?? "unknown"), span(task.outcome ?? "unknown", stateTone(task.outcome ?? "")), span(task.turns, "number"), span(task.wallMs === null ? "unknown" : duration(task.wallMs), "muted")]), columns));
400
+ }
401
+ out.push(...lines(sentence, columns, undefined, " "));
402
+ }
403
+ return out;
404
+ }
@@ -0,0 +1 @@
1
+ export { loadPetConfig, type PetConfig } from "../pet/config.ts";