@staix/agent-hub 0.12.23 → 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 (158) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/README.md +8 -3
  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 +8 -1
  11. package/docs/agent-notes/bus.md +3 -1
  12. package/docs/agent-notes/daemon.md +7 -1
  13. package/docs/agent-notes/local-worker.md +4 -2
  14. package/docs/agent-notes/tasks.md +4 -0
  15. package/docs/agent-notes/tests.md +2 -1
  16. package/docs/bench.md +28 -3
  17. package/docs/cooperbench.md +4 -0
  18. package/docs/data-policy.md +36 -0
  19. package/docs/events.md +3 -0
  20. package/docs/gsd-collaboration-analysis.md +48 -0
  21. package/docs/memory.md +231 -0
  22. package/docs/operations.md +651 -14
  23. package/docs/plans/2026-10-11-issues-109-sequential.md +81 -0
  24. package/docs/plans/2026-10-11-routing-evaluation-proposal.md +30 -0
  25. package/docs/profiles.md +57 -0
  26. package/docs/quickstart.md +1 -1
  27. package/docs/research.md +31 -1
  28. package/docs/roles.md +85 -0
  29. package/docs/sdlc.md +170 -0
  30. package/docs/security.md +111 -4
  31. package/docs/smoke.md +282 -0
  32. package/docs/specs/2026-09-19-agent-hub-design.md +601 -7
  33. package/docs/specs/2026-10-12-backlog-implementation-plan.md +186 -0
  34. package/docs/specs/2026-10-12-office-view-design.md +212 -0
  35. package/docs/text-projection.md +19 -0
  36. package/docs/verification/2026-10-11-acp-watch-t0.md +33 -0
  37. package/docs/verification/2026-10-11-agent-config-inventory.md +20 -0
  38. package/docs/verification/2026-10-11-jcode-peer-contract.md +27 -0
  39. package/docs/verification/2026-10-13-0.12.24.md +94 -0
  40. package/docs/verification/2026-10-13-board-phone.md +31 -0
  41. package/docs/verification/2026-10-13-fsmonitor-fixture-attribution.md +19 -0
  42. package/docs/verified.json +22 -13
  43. package/package.json +2 -1
  44. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  45. package/plugins/agent-hub/ROLE-NOTICE.md +26 -0
  46. package/plugins/agent-hub/server.js +1175 -339
  47. package/scripts/check-docs.mjs +129 -0
  48. package/src/adapters/acp.ts +112 -13
  49. package/src/adapters/claude-channel.ts +48 -11
  50. package/src/adapters/codex-appserver.ts +11 -0
  51. package/src/adapters/jcode.ts +110 -0
  52. package/src/adapters/local-worker.ts +10 -7
  53. package/src/adapters/pi.ts +94 -20
  54. package/src/cli/attention.ts +60 -0
  55. package/src/cli/bench.ts +24 -5
  56. package/src/cli/board-read.ts +12 -0
  57. package/src/cli/console-state.ts +103 -17
  58. package/src/cli/console.ts +54 -8
  59. package/src/cli/facts-hook.ts +12 -4
  60. package/src/cli/help.ts +35 -9
  61. package/src/cli/identity.ts +18 -7
  62. package/src/cli/init.ts +48 -10
  63. package/src/cli/jcode-mcp.ts +58 -0
  64. package/src/cli/kimi-mcp.ts +53 -0
  65. package/src/cli/launch.ts +16 -1
  66. package/src/cli/main.ts +392 -57
  67. package/src/cli/memory.ts +193 -0
  68. package/src/cli/output.ts +45 -4
  69. package/src/cli/pet-config.ts +1 -0
  70. package/src/cli/pet.ts +83 -0
  71. package/src/cli/preview.ts +38 -5
  72. package/src/cli/status-lines.ts +1 -1
  73. package/src/cli/tail-render.ts +1 -0
  74. package/src/cli/upgrade-runtime.ts +32 -3
  75. package/src/cli/watch.ts +31 -0
  76. package/src/hub/activity.ts +53 -0
  77. package/src/hub/ask.ts +10 -5
  78. package/src/hub/bench.ts +10 -3
  79. package/src/hub/board.ts +222 -16
  80. package/src/hub/bus.ts +48 -9
  81. package/src/hub/citations.ts +7 -0
  82. package/src/hub/conductor.ts +12 -7
  83. package/src/hub/config-trust.ts +4 -2
  84. package/src/hub/crash.ts +9 -0
  85. package/src/hub/daemon.ts +910 -120
  86. package/src/hub/delivery-journal.ts +5 -0
  87. package/src/hub/envelope.ts +1 -1
  88. package/src/hub/events.ts +12 -2
  89. package/src/hub/facts.ts +26 -7
  90. package/src/hub/git.ts +8 -2
  91. package/src/hub/github-import.ts +28 -0
  92. package/src/hub/hub-tools.ts +49 -10
  93. package/src/hub/inference.ts +1 -1
  94. package/src/hub/lifecycle-schema.ts +17 -0
  95. package/src/hub/listen.ts +15 -0
  96. package/src/hub/manager.ts +12 -5
  97. package/src/hub/path-policy.ts +96 -0
  98. package/src/hub/peer-catalog.ts +28 -0
  99. package/src/hub/permission-mode.ts +19 -5
  100. package/src/hub/planning.ts +70 -0
  101. package/src/hub/profiles.ts +74 -0
  102. package/src/hub/projected-diff.ts +73 -0
  103. package/src/hub/projection-exec.ts +24 -0
  104. package/src/hub/research.ts +144 -21
  105. package/src/hub/roles.ts +123 -0
  106. package/src/hub/roster.ts +23 -0
  107. package/src/hub/routing.ts +18 -5
  108. package/src/hub/sdlc-queue.ts +112 -0
  109. package/src/hub/sdlc-release.ts +125 -0
  110. package/src/hub/sdlc.ts +250 -0
  111. package/src/hub/settings.ts +34 -15
  112. package/src/hub/shipped-roles.ts +25 -0
  113. package/src/hub/snapshots.ts +89 -15
  114. package/src/hub/start-mode.ts +7 -5
  115. package/src/hub/supervision.ts +1 -0
  116. package/src/hub/task-sweep.ts +1 -1
  117. package/src/hub/tasks.ts +892 -87
  118. package/src/hub/text-projection.ts +126 -0
  119. package/src/hub/tooling.ts +159 -0
  120. package/src/hub/ui.ts +16 -2
  121. package/src/hub/workflow.ts +71 -0
  122. package/src/local/sandbox.ts +6 -4
  123. package/src/local/tools.ts +11 -6
  124. package/src/memory/brief.ts +28 -14
  125. package/src/memory/import.ts +55 -0
  126. package/src/memory/recall.ts +84 -0
  127. package/src/memory/store.ts +750 -0
  128. package/src/memory/sync.ts +145 -0
  129. package/src/pet/NOTICE.md +10 -0
  130. package/src/pet/characters.ts +1438 -0
  131. package/src/pet/config.ts +31 -0
  132. package/src/pet/frame.ts +17 -0
  133. package/src/pet/pixels.ts +28 -0
  134. package/src/role-text.d.ts +1 -0
  135. package/src/ui/index.html +842 -46
  136. package/src/ui/needs.ts +58 -0
  137. package/src/ui/office-model.ts +440 -0
  138. package/src/ui/peer-palette.ts +16 -0
  139. package/src/ui/task-board.ts +49 -0
  140. package/src/ui/task-progress.ts +5 -1
  141. package/templates/AGENTS.block.md +3 -2
  142. package/templates/config.json +5 -1
  143. package/templates/profiles/dev.json +22 -0
  144. package/templates/profiles/general.json +26 -0
  145. package/templates/profiles/research.json +57 -0
  146. package/templates/profiles/writing.json +28 -0
  147. package/templates/roles/NOTICE.md +26 -0
  148. package/templates/roles/dev/backend-engineer.md +26 -0
  149. package/templates/roles/dev/code-reviewer.md +26 -0
  150. package/templates/roles/dev/frontend-engineer.md +26 -0
  151. package/templates/roles/dev/security-reviewer.md +26 -0
  152. package/templates/roles/dev/software-architect.md +26 -0
  153. package/templates/roles/dev/test-engineer.md +26 -0
  154. package/templates/roles/research/analyst.md +26 -0
  155. package/templates/roles/research/fact-checker.md +26 -0
  156. package/templates/roles/research/research-lead.md +26 -0
  157. package/templates/roles/research/researcher.md +26 -0
  158. package/templates/roles/research/statistician.md +26 -0
package/src/cli/main.ts CHANGED
@@ -1,6 +1,20 @@
1
1
  #!/usr/bin/env bun
2
+ import { supportsBoardReleaseFilter, supportsBoardColumnMetadata } from "./board-read.ts";
3
+ import { matchesPolicy } from "../hub/path-policy.ts";
4
+ import { projectedDiff } from "../hub/projected-diff.ts";
5
+ import { TextProjections } from "../hub/text-projection.ts";
6
+ import { terminalText } from "./console-state.ts";
7
+ import { petView, readPet, renderPet } from "./pet.ts";
8
+ import { loadPetConfig } from "./pet-config.ts";
9
+ import { profileName, projectProfile, resolveProfile } from "../hub/profiles.ts";
10
+ import { catalogAssignments, loadRoleCatalog, selectedRoles } from "../hub/roles.ts";
11
+ import { attachWatch } from "./watch.ts";
12
+ import { verifiedJcodeCommand } from "../adapters/jcode.ts";
13
+ import { configureJcodeMcp } from "./jcode-mcp.ts";
14
+ import { qwenSessionId } from "../hub/crash.ts";
2
15
  import { isPermissionMode } from "../hub/permission-mode.ts";
3
16
  import { parseSettingText, settingDef, settingText, type SettingRow } from "../hub/settings.ts";
17
+ import { isSupportedPeer } from "../hub/peer-catalog.ts";
4
18
  import { startModeOf } from "../hub/start-mode.ts";
5
19
  import { currentRouting } from "../hub/routing.ts";
6
20
  import { spawn, spawnSync } from "node:child_process";
@@ -11,7 +25,7 @@ import { basename, dirname, join, relative, resolve } from "node:path";
11
25
  import { ControlClient, readControl } from "../hub/control-client.ts";
12
26
  import { loadConfig } from "../hub/daemon.ts";
13
27
  import { factsHook } from "./facts-hook.ts";
14
- import { projectContext, realPath } from "../hub/project.ts";
28
+ import { hubHome, projectContext, realPath } from "../hub/project.ts";
15
29
  import { Registry, type Project } from "../hub/registry.ts";
16
30
  import { inspectProject, startProject, stopProject, runProjectDaemon } from "../hub/lifecycle.ts";
17
31
  import { processLiveness, processSignature } from "../pi/process-signature.ts";
@@ -20,7 +34,8 @@ import { OmniRoute } from "../omniroute/client.ts";
20
34
  import { MemoryClient } from "../memory/client.ts";
21
35
  import { init, planInit } from "./init.ts";
22
36
  import { launcherPreview } from "./preview.ts";
23
- import { cleanupStaleClaudeSettings, cleanupClaudeSettings, buildLaunch, claudeObservationHooks, nativeLaunchEnv, recordClaudeLaunch, runClaudeLaunch, type ClaudeLaunchRecord, UNATTENDED_WARNING } from "./launch.ts";
37
+ import { cleanupStaleClaudeSettings, cleanupClaudeSettings, buildLaunch, buildKimiTuiLaunch, claudeObservationHooks, nativeLaunchEnv, recordClaudeLaunch, runClaudeLaunch, type ClaudeLaunchRecord, UNATTENDED_WARNING } from "./launch.ts";
38
+ import { configureKimiMcp } from "./kimi-mcp.ts";
24
39
  import { nextStep, parseList, pluginState, type InstalledPlugin, type Marketplace } from "./setup.ts";
25
40
  import { CLASSES } from "../hub/board.ts";
26
41
  import { VERSION } from "../version.ts";
@@ -39,9 +54,11 @@ import { setupOllamaModel } from "./models-setup.ts";
39
54
 
40
55
  import { contextLine, type PeerRow } from "./status-lines.ts";
41
56
  import { parseSince, readEvents } from "../hub/events.ts";
42
- import { armRuns, benchCompare, benchCsv, benchReport, formatCompare, formatReport as formatBenchReport, formatRuns, readRuns, runSummary, sameSuite } from "../hub/bench.ts";
57
+ import { armRuns, benchCompare, benchCsv, benchReport, formatCompare, formatReport as formatBenchReport, formatRuns, MEMORY_MODES, readRuns, runSummary, sameSuite, type MemoryMode } from "../hub/bench.ts";
43
58
  import { benchPreflight, defaultIo, runBench } from "./bench.ts";
44
- import { appendRecords, formatResearch, LABELS, labelTarget, RESEARCH_SCHEMA, projectKey, readStores, researchReport, taskRecords, toCsv, type Label, type TaskRecord } from "../hub/research.ts";
59
+ import { ADDONS, isAddon, type AddonName } from "../hub/tooling.ts";
60
+ import { goalCommand, memoryCommand } from "./memory.ts";
61
+ import { addonReport, appendRecords, formatAddons, formatResearch, LABELS, labelTarget, RESEARCH_SCHEMA, projectKey, readStores, researchReport, taskRecords, toCsv, type Label, type TaskRecord } from "../hub/research.ts";
45
62
  import { summarize, summarizeByTask } from "../hub/report.ts";
46
63
  import { hasTree, planUndo, repoOf, restore, Turns } from "../hub/snapshots.ts";
47
64
  import { pathWarnings } from "../hub/conflicts.ts";
@@ -49,7 +66,7 @@ import { classifyPeerCommand, cliCommandLabel, detectCliIdentity, peerCommandRef
49
66
  import { recordCliAudit } from "./identity-audit.ts";
50
67
  import { runConsole } from "./console.ts";
51
68
  import { paint, permissionBoundary, resolveColor, wrap, type Span } from "./console-state.ts";
52
- import { outputWidth, renderStatus, renderBoard, renderBudget, renderDoctor, renderProjects, renderQueue, renderTurns, renderOrphans, renderQueueShow, renderModelsStatus, renderExecutionBudgetStatus, renderReport, type DoctorCheck } from "./output.ts";
69
+ import { outputWidth, renderRoster, renderStatus, renderBoard, renderBoardColumns, boardColumnModel, renderBudget, renderDoctor, renderProjects, renderQueue, renderTurns, renderOrphans, renderQueueShow, renderModelsStatus, renderExecutionBudgetStatus, renderReport, type DoctorCheck } from "./output.ts";
53
70
  import { renderHelp } from "./help.ts";
54
71
  import { renderTailEvent } from "./tail-render.ts";
55
72
  import { archiveProblem, archiveState, damagedState, failureText, MANIFEST, planReset, resetLines, resetRuntime, startState, type ResetPlan } from "./reset.ts";
@@ -176,7 +193,7 @@ async function healthy(): Promise<boolean> {
176
193
  finally { hub?.close(); }
177
194
  }
178
195
 
179
- function exec(bin: string, argv: string[], tool?: "claude" | "codex"): never {
196
+ function exec(bin: string, argv: string[], tool?: "claude" | "codex" | "kimi"): never {
180
197
  const env = tool ? nativeLaunchEnv(tool, childEnv()) : childEnv();
181
198
  const res = spawnSync(bin, argv, { cwd, stdio: "inherit", env: { ...env, AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd } });
182
199
  if (res.error) fail(`cannot run ${bin}: ${res.error.message}`);
@@ -311,13 +328,13 @@ function fail(message: string): never {
311
328
  process.exit(1);
312
329
  }
313
330
 
314
- async function taskOp(op: string, a: Record<string, unknown>, throwOnError = false): Promise<string> {
315
- const hub = await connect();
331
+ async function taskOp(op: string, a: Record<string, unknown>, throwOnError = false, connection?: ControlClient): Promise<string> {
332
+ const hub = connection ?? await connect();
316
333
  try {
317
- const res = await hub.request({ t: "task", op, args: a });
334
+ const res = await hub.request({ t: "task", op, args: a }, op === "github_import" ? 60_000 : 30_000);
318
335
  if (!res.ok) { if (throwOnError) throw new Error(res.error); audit("refused"); fail(res.error); }
319
336
  return res.text;
320
- } finally { hub.close(); }
337
+ } finally { if (!connection) hub.close(); }
321
338
  }
322
339
 
323
340
  /** Reads hub.db's turn records; `none` when the hub never kept one (snapshots off, no git work tree, an older hub). */
@@ -465,6 +482,7 @@ async function upgrade(kind: "restart" | "upgrade"): Promise<void> {
465
482
  });
466
483
  process.exit(code);
467
484
  }
485
+ if (kind === "upgrade") console.error("ahub: Kimi now defaults to its own TUI (pull-only) in projects without peers.kimi.start_mode. To keep ACP: ahub settings set peers.kimi.start_mode headless");
468
486
  if (interactive) return onTerminal((io) => planScreen(upgradeHost(kind, to ?? VERSION), io));
469
487
  const plan = await makeUpgradePlan(kind, to ?? VERSION, kind === "restart" ? cwd : undefined);
470
488
  console.log(JSON.stringify(plan, null, 2));
@@ -625,14 +643,19 @@ const commands: Record<string, () => Promise<void> | void> = {
625
643
  },
626
644
 
627
645
  init: () => {
646
+ const flags = takeFlags(args.filter(arg => arg !== "--dry-run" && arg !== "--json" && arg !== "--shadow"), ["--profile"], []);
647
+ if (flags.rest.length) fail("usage: ahub init [--profile dev|research|writing|general] [--shadow] [--dry-run --json]");
648
+ const requestedProfile = flags.one["--profile"] === undefined ? undefined : profileName(flags.one["--profile"]);
628
649
  if (args.includes("--dry-run")) {
629
- const plan = planInit(cwd);
650
+ const plan = planInit(cwd, requestedProfile, args.includes("--shadow"));
630
651
  return console.log(args.includes("--json") ? JSON.stringify(plan, null, 2) : plan.map(change => `${change.action} ${change.path}: ${change.reason}${change.managedBlock ? ` (managed block: ${change.managedBlock})` : ""}`).join("\n") || "already up to date");
631
652
  }
632
653
  assertLifecycleAvailable();
633
- const changed = init(cwd);
654
+ const changed = init(cwd, requestedProfile, args.includes("--shadow"));
634
655
  registeredProject();
635
656
  console.log(changed.length ? changed.map((p) => `${existsSync(p) ? "wrote" : "removed"} ${p}`).join("\n") : "already up to date");
657
+ const config = projectConfig(), examples = resolveProfile(config.profile, config.profile_overrides).example_checks;
658
+ if (examples.length) console.log(terminalText("Example checks (advisory):\n" + examples.map(example => "- " + example).join("\n")));
636
659
  },
637
660
 
638
661
  // Internal: the detached daemon process started by ahub up or the manager.
@@ -643,10 +666,10 @@ const commands: Record<string, () => Promise<void> | void> = {
643
666
  },
644
667
 
645
668
  console: async () => {
646
- if (args.some(arg => arg !== "--panels" && !arg.startsWith("--color=")) || args.filter(arg => arg.startsWith("--color=")).length > 1) fail("usage: ahub console [--panels] [--color=auto|always|never]");
669
+ if (args.some(arg => arg !== "--panels" && arg !== "--no-pet" && !arg.startsWith("--color=")) || args.filter(arg => arg.startsWith("--color=")).length > 1) fail("usage: ahub console [--panels] [--no-pet] [--color=auto|always|never]");
647
670
  const color = resolveColor(args.find(arg => arg.startsWith("--color="))?.slice("--color=".length), { isTTY: !!process.stdin.isTTY && !!process.stdout.isTTY, TERM: process.env.TERM, NO_COLOR: process.env.NO_COLOR });
648
- if (color instanceof Error) return fail("usage: ahub console [--panels] [--color=auto|always|never]");
649
- await runConsole({ client: await connect(), cwd, stateDir, panels: args.includes("--panels"), color });
671
+ if (color instanceof Error) return fail("usage: ahub console [--panels] [--no-pet] [--color=auto|always|never]");
672
+ await runConsole({ client: await connect(), cwd, stateDir, panels: args.includes("--panels"), noPet: args.includes("--no-pet"), color });
650
673
  },
651
674
 
652
675
  up: async () => {
@@ -727,18 +750,89 @@ const commands: Record<string, () => Promise<void> | void> = {
727
750
  exec(launch.cmd, launch.args, "codex");
728
751
  },
729
752
 
753
+ jcode: async () => {
754
+ if (args.includes("--print-command") || args.includes("--dry-run")) {
755
+ try { return console.log(JSON.stringify(launcherPreview("jcode", args, cwd, stateDir, unattendedEnv), null, 2)); }
756
+ catch { fail("cannot preview Jcode launch: invalid arguments or configuration (details withheld)"); }
757
+ }
758
+ assertLifecycleAvailable();
759
+ const { one, rest } = takeFlags(args, ["--mode", "--model", "--provider-profile", "--session-id"], []);
760
+ if (rest.length || (one["--mode"] !== undefined && one["--mode"] !== "headless")) fail("usage: ahub jcode [--mode headless] [--model <alias>] [--provider-profile <name>] [--session-id <id>]");
761
+ if (one["--session-id"] !== undefined && !qwenSessionId(one["--session-id"])) fail("invalid Jcode session identity");
762
+ const hub = await connect();
763
+ try {
764
+ const snapshot = (await hub.request({ t: "status" })).status;
765
+ if (snapshot?.roster?.find((row: any) => row.peer === "jcode")?.on !== true) fail("jcode is off roster; add it with ahub roster add jcode");
766
+ const written = configureJcodeMcp(cwd, stateDir, resolve(import.meta.dir, "../../plugins/agent-hub/server.js"));
767
+ if (written.tracked) console.error(".jcode/mcp.json is tracked; review this local machine-path change before committing it");
768
+ const reply = await hub.request({ t: "start", peer: "jcode", args: { mode: "headless", model: one["--model"], providerProfile: one["--provider-profile"], sessionId: one["--session-id"] }, operationId: process.env.AGENTHUB_RECOVERY_OPERATION }, 65_000);
769
+ if (!reply.ok) fail(reply.error);
770
+ console.log(reply.already ? "jcode is already attached" : "jcode attached (headless); native policy unmanaged, NO hub sandbox");
771
+ } finally { hub.close(); }
772
+ },
773
+
774
+ qwen: async () => {
775
+ if (args.includes("--print-command") || args.includes("--dry-run")) {
776
+ try { return console.log(JSON.stringify(launcherPreview("qwen", args, cwd, stateDir, unattendedEnv), null, 2)); }
777
+ catch { fail("cannot preview Qwen launch: invalid arguments or configuration (details withheld)"); }
778
+ }
779
+ assertLifecycleAvailable();
780
+ const { one, rest } = takeFlags(args, ["--mode", "--model", "--session-id"], []);
781
+ if (one["--session-id"] !== undefined && !qwenSessionId(one["--session-id"])) fail("invalid Qwen session identity");
782
+ if (rest.length || (one["--mode"] !== undefined && one["--mode"] !== "headless")) fail("Qwen TUI is not offered before verification; usage: ahub qwen [--mode headless] [--model <alias>] [--session-id <id>]");
783
+ const hub = await connect();
784
+ try {
785
+ const reply = await hub.request({ t: "start", peer: "qwen", args: { mode: "headless", ...(one["--model"] ? { model: one["--model"] } : {}), ...(one["--session-id"] ? { sessionId: one["--session-id"] } : {}) }, operationId: process.env.AGENTHUB_RECOVERY_OPERATION }, 35_000);
786
+ if (!reply.ok) fail(reply.error);
787
+ console.log(reply.already ? "qwen is already attached" : "qwen attached (headless); use ahub say @qwen and the console for approvals");
788
+ } finally { hub.close(); }
789
+ },
730
790
  kimi: async () => {
731
791
  if (args.includes("--print-command") || args.includes("--dry-run")) {
732
792
  try { return console.log(JSON.stringify(launcherPreview("kimi", args, cwd, stateDir, unattendedEnv), null, 2)); }
733
793
  catch { fail("cannot preview launch: invalid arguments or configuration (details withheld to protect credentials)"); }
734
794
  }
735
- const i = args.indexOf("--model");
736
- const model = i === -1 ? undefined : args[i + 1] ?? fail("--model needs an alias");
795
+ assertLifecycleAvailable();
796
+ const { one, rest } = takeFlags(args, ["--mode", "--model"], []);
797
+ const mode = one["--mode"] ?? startModeOf(projectConfig().peers, "kimi"), model = one["--model"];
798
+ if (rest.length || !["tui", "headless"].includes(mode)) fail("usage: ahub kimi [--mode tui|headless] [--model <alias>]");
799
+ if (mode === "tui" && one["--mode"] === undefined && !(process.stdin.isTTY && process.stdout.isTTY)) {
800
+ if (model) fail("Kimi's TUI needs a terminal for these options; run in a terminal or add --mode headless");
801
+ const hub = await connect();
802
+ try {
803
+ const reply = await hub.request({ t: "peer_start", peer: "kimi" }, 35_000);
804
+ if (!reply.ok) fail(reply.error);
805
+ return console.log(reply.already ? "kimi is already attached" : `kimi: opened a terminal running ${reply.command}; it attaches pull-only when its TUI is ready`);
806
+ } finally { hub.close(); }
807
+ }
737
808
  const hub = await connect();
738
- const res = await hub.request({ t: "start", peer: "kimi", args: { model }, operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
739
- hub.close();
740
- if (!res.ok) fail(res.error);
741
- console.log(res.already ? "kimi is already attached" : 'kimi attached (headless). Talk to it with: ahub say @kimi "..."');
809
+ try {
810
+ if (mode === "tui") {
811
+ const launch = buildKimiTuiLaunch(projectConfig().kimi_cmd, model);
812
+ const snapshot = (await hub.request({ t: "status" })).status;
813
+ if (snapshot?.roster?.find((row: any) => row.peer === "kimi")?.on === false) fail("kimi is off roster; add it with ahub roster add kimi");
814
+ const peer = snapshot?.peers?.kimi;
815
+ if (peer && !["offline"].includes(peer.state) && !peer.pullOnly) fail("Kimi is running headless; run ahub stop kimi before starting its TUI");
816
+ const admission = await hub.request({ t: "start", peer: "kimi", args: { mode: "tui" }, operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
817
+ if (!admission.ok) fail(admission.error);
818
+ let result: ReturnType<typeof spawnSync>;
819
+ try {
820
+ const written = configureKimiMcp(cwd, stateDir, resolve(import.meta.dir, "../../plugins/agent-hub/server.js"), "tui");
821
+ if (written.tracked) console.error(".kimi-code/mcp.json is tracked; the agent-hub entry contains machine paths. Review this local change before committing it.");
822
+ result = spawnSync(launch.cmd, launch.args, { cwd, stdio: "inherit", env: { ...nativeLaunchEnv("kimi", childEnv()), AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd } });
823
+ } finally {
824
+ if (admission.admissionId) await hub.request({ t: "kimi_launch_end", admissionId: admission.admissionId }).catch(() => {});
825
+ }
826
+ if (result.error) fail(`cannot run ${launch.cmd}: ${result.error.message}`);
827
+ process.exit(result.status ?? 1);
828
+ }
829
+ const peer = (await hub.request({ t: "status" })).status?.peers?.kimi;
830
+ if (peer?.pullOnly && peer.state !== "offline") fail("Kimi runs in a terminal; end it there before starting a headless one");
831
+ configureKimiMcp(cwd, stateDir, resolve(import.meta.dir, "../../plugins/agent-hub/server.js"), "headless");
832
+ const res = await hub.request({ t: "start", peer: "kimi", args: { mode: "headless", model }, operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
833
+ if (!res.ok) fail(res.error);
834
+ console.log(res.already ? "kimi is already attached" : 'kimi attached (headless). Talk to it with: ahub say @kimi "..."');
835
+ } finally { hub.close(); }
742
836
  },
743
837
 
744
838
  pi: async () => {
@@ -839,7 +933,8 @@ const commands: Record<string, () => Promise<void> | void> = {
839
933
  hub.close();
840
934
  if (!res.ok) fail(res.error);
841
935
  if (res.recorded) return console.log("recorded only ([FYI]); no peer was interrupted");
842
- console.log(res.targets.length ? `queued for: ${res.targets.join(", ")}` : "no peers attached; nothing delivered");
936
+ console.log(paint([{ text: res.targets.length ? `queued for: ${res.targets.join(", ")}` : "no peers attached; nothing delivered" }], false));
937
+ if (typeof res.notice === "string" && res.notice) console.log(paint([{ text: res.notice }], false));
843
938
  },
844
939
 
845
940
  queue: async () => {
@@ -873,6 +968,30 @@ const commands: Record<string, () => Promise<void> | void> = {
873
968
  } finally { hub.close(); }
874
969
  },
875
970
 
971
+ watch: async () => {
972
+ const peer = args[0];
973
+ if (peer !== "kimi" && peer !== "qwen") fail("usage: ahub watch kimi|qwen [--no-thoughts] [--color=auto|always|never]");
974
+ if (args.slice(1).some(arg => arg !== "--no-thoughts" && !/^--color=(auto|always|never)$/.test(arg))) fail("unsupported watch option");
975
+ const color = resolveColor(args.find(arg => arg.startsWith("--color="))?.slice(8), { isTTY: !!process.stdout.isTTY, ...process.env });
976
+ if (color instanceof Error) fail(color.message);
977
+ const hub = await connect();
978
+ const input = process.stdin;
979
+ const raw = !!input.isRaw;
980
+ let leave = () => {};
981
+ const closed = new Promise<void>(resolve => { leave = resolve; });
982
+ const key = (chunk: Buffer) => { if (chunk.includes(3) || chunk.toString().includes("q")) leave(); };
983
+ hub.onClose = leave;
984
+ process.on("SIGINT", leave);
985
+ try {
986
+ await attachWatch(hub, peer, line => console.log(line), { columns: () => process.stdout.columns || 80, color, noThoughts: args.includes("--no-thoughts") });
987
+ if (input.isTTY) { input.setRawMode(true); input.on("data", key); input.resume(); }
988
+ await closed;
989
+ } finally {
990
+ input.off("data", key); process.off("SIGINT", leave);
991
+ if (input.isTTY) input.setRawMode(raw);
992
+ input.pause(); hub.close();
993
+ }
994
+ },
876
995
  tail: async () => {
877
996
  const hub = await connect();
878
997
  hub.onPush = (msg) => {
@@ -930,28 +1049,122 @@ const commands: Record<string, () => Promise<void> | void> = {
930
1049
  printOutput(renderBudget(data, options.columns, Date.now(), options.full), options.color);
931
1050
  },
932
1051
 
1052
+ backlog: async () => {
1053
+ const [sub, id, revision, value, ...reason] = args;
1054
+ if (sub === "list" && args.length === 1) return console.log(await taskOp("hub_backlog_list", {}));
1055
+ if (sub === "add" && args.length >= 3) return console.log(await taskOp("hub_task_propose", { class: id, title: args.slice(2).join(" "), backlog: true }));
1056
+ if (!/^[1-9]\d*$/.test(id ?? "")) fail("usage: ahub backlog list|add <class> <title...>|pull|return <id>|plan|pin <id> <revision> <value> <reason...>|undo <id> <revision>");
1057
+ if (["pull", "return"].includes(sub ?? "") && args.length === 2) return console.log(await taskOp(sub === "pull" ? "backlog_pull" : "backlog_return", { id }));
1058
+ if (!/^\d+$/.test(revision ?? "")) fail("planning revision is required");
1059
+ if (sub === "pull-ready" && args.length >= 4) return console.log(await taskOp("hub_backlog_pull", { id: Number(id), revision: Number(revision), reason: args.slice(3).join(" ") }));
1060
+ if (sub === "split" && value && reason.length) {
1061
+ let parts: unknown;try { parts=JSON.parse(value); } catch { return fail("split parts must be JSON"); }
1062
+ return console.log(await taskOp("hub_backlog_split", { id: Number(id), revision: Number(revision), parts, reason: reason.join(" ") }));
1063
+ }
1064
+ if (sub === "undo" && args.length === 3) return console.log(await taskOp("hub_backlog_undo", { id: Number(id), revision: Number(revision) }));
1065
+ if (!["plan", "pin"].includes(sub ?? "") || !value || !reason.length) fail("planning needs fields and a reason");
1066
+ let fields: unknown;
1067
+ try { fields = sub === "pin" ? { pinned: value === "true" ? true : value === "false" ? false : undefined } : JSON.parse(value); }
1068
+ catch { return fail("planning fields must be JSON"); }
1069
+ if (sub === "pin" && value !== "true" && value !== "false") fail("pin must be true or false");
1070
+ console.log(await taskOp("hub_backlog_plan", { id: Number(id), revision: Number(revision), fields, reason: reason.join(" ") }));
1071
+ },
1072
+
1073
+ release: async () => {
1074
+ const [sub, name, revision, value] = args;
1075
+ if (sub === "list" && args.length === 1) return console.log(await taskOp("hub_release_list", {}));
1076
+ if (sub === "show" && name && args.length === 2) {
1077
+ return console.log(await taskOp("release_show", { name }));
1078
+ }
1079
+ if (sub === "new" && name && revision && args.length === 4 && /^\d+$/.test(value ?? "")) return console.log(await taskOp("release_new", { name, goal: revision, order: Number(value) }));
1080
+ if (!name || !/^\d+$/.test(revision ?? "")) fail("usage: ahub release new <name> <goal> <order>|list|show <name>|plan <name> <revision> <dates-json>|activate|freeze|ship|drop <name> <revision>");
1081
+ const states: Record<string, string> = { activate: "active", freeze: "frozen", ship: "shipped", drop: "dropped" };
1082
+ let fields: unknown;
1083
+ if (sub === "plan" && value && args.length === 4) {
1084
+ try { fields = JSON.parse(value); } catch { return fail("release dates must be JSON"); }
1085
+ } else if (sub && Object.hasOwn(states, sub) && args.length === 3) fields = { state: states[sub] };
1086
+ else return fail("invalid release command");
1087
+ console.log(await taskOp("release_change", { name, revision: Number(revision), fields }));
1088
+ },
1089
+
933
1090
  board: async () => {
1091
+ if (args[0] === "import") {
1092
+ if (args[1] !== "github" || (args.length - 2) % 2 !== 0) fail("usage: ahub board import github --label <label> [--milestone <milestone>] [--repo <owner/repo>]");
1093
+ const selection: Record<string, string> = {};
1094
+ for (let i = 2; i < args.length; i += 2) { const key = args[i]!, value = args[i+1]; if (!["--repo", "--label", "--milestone"].includes(key) || !value || Object.hasOwn(selection, key.slice(2))) fail("invalid GitHub import options"); selection[key.slice(2)] = value; }
1095
+ if (!selection.label) fail("GitHub import requires --label");
1096
+ return console.log(await taskOp("github_import", selection));
1097
+ }
934
1098
  const options = outputOptions();
935
- const ready = args.includes("--ready");
936
- const state = args.find((a) => !a.startsWith("--"));
937
- const tasks = JSON.parse(await taskOp("hub_task_list", ready ? { ready: true } : state ? { state } : {})) as any[];
938
- if (options.json) return console.log(JSON.stringify(tasks, null, 2));
1099
+ const { one, rest } = takeFlags(args, ["--release", "--release-id"], []);
1100
+ if (one["--release"] !== undefined && !one["--release"].trim() || one["--release"] !== undefined && one["--release-id"] !== undefined || one["--release-id"] !== undefined && !/^[1-9]\d*$/.test(one["--release-id"])) fail("choose --release <nonempty name> or --release-id <positive id>");
1101
+ const release = one["--release-id"] ? "#"+one["--release-id"] : one["--release"];
1102
+ const boardHub = await connect();
1103
+ try {
1104
+ let boardStatus: any;
1105
+ if (release) { const reply = await boardHub.request({ t: "status" }); boardStatus = reply.status; if (!supportsBoardReleaseFilter(boardStatus)) fail("running hub does not support release filters; use a compatible daemon"); }
1106
+ const ready = rest.includes("--ready"), byColumn = rest.includes("--columns");
1107
+ const state = rest.find((a) => !a.startsWith("--"));
1108
+ if (byColumn && !boardStatus) boardStatus = (await boardHub.request({ t: "status" })).status;
1109
+ if (byColumn && !supportsBoardColumnMetadata(boardStatus)) fail("running hub does not support accurate board column metadata; use a compatible daemon");
1110
+ const tasks = JSON.parse(await taskOp("hub_task_list", { ...(ready ? { ready: true } : state ? { state } : {}), ...(release ? { release } : {}), ...(byColumn ? { columnMetadata: true } : {}) }, false, boardHub)) as any[];
1111
+ if (options.json && !byColumn) return console.log(JSON.stringify(tasks, null, 2));
939
1112
  let allTasks: any[] | null = tasks;
940
- if (ready || state) {
941
- try { allTasks = JSON.parse(await taskOp("hub_task_list", {}, true)) as any[]; }
1113
+ if (ready || state || release) {
1114
+ try { allTasks = JSON.parse(await taskOp("hub_task_list", byColumn ? { columnMetadata: true } : {}, true, boardHub)) as any[]; }
942
1115
  catch (error) {
1116
+ if (byColumn) fail("board column dependency context unavailable; retry with a complete snapshot");
943
1117
  allTasks = null;
944
1118
  console.error(paint([{ text: `ahub: board dependency stages unavailable; showing filtered rows without stages: ${String((error as Error).message).replace(/[\n\t]/g, " ")}` }], false));
945
1119
  }
946
1120
  }
947
- printOutput(renderBoard(tasks, options.columns, Date.now(), options.full, allTasks), options.color);
1121
+ if (byColumn) {
1122
+ const status = (await boardHub.request({ t: "status" })).status; if (!status?.peers) fail("peer status unavailable"); if (options.json) console.log(JSON.stringify({ tasks, ...boardColumnModel(tasks, status.peers, allTasks) }, null, 2)); else printOutput(renderBoardColumns(tasks, status.peers, options.columns, Date.now(), options.full, allTasks), options.color);
1123
+ } else printOutput(renderBoard(tasks, options.columns, Date.now(), options.full, allTasks), options.color);
1124
+ } finally { boardHub.close(); }
1125
+ },
1126
+
1127
+ sdlc: async () => {
1128
+ const sub = args[0], id = args[1] === undefined ? undefined : Number(args[1]);
1129
+ if (sub === "queue" || sub === "release") {
1130
+ const repo = args[1], liveIssues = args.slice(2).map(Number);
1131
+ if (!repo || args.length > 34 || (sub === "queue" && args.length !== 2) || liveIssues.some(id => !Number.isSafeInteger(id) || id <= 0)) fail("usage: ahub sdlc queue <owner/repository> | release <owner/repository> [live-issue...]");
1132
+ const hub = await connect();
1133
+ try { const reply = await hub.request({ t: "sdlc", op: sub, repo, liveIssues }, 30_000); if (!reply.ok) fail(reply.error); console.log(JSON.stringify(reply, null, 2)); }
1134
+ finally { hub.close(); }
1135
+ return;
1136
+ }
1137
+ if (sub === "record") {
1138
+ if (args.length !== 3 || !Number.isSafeInteger(id) || id! <= 0 || Buffer.byteLength(args[2]!) > 32768) fail("usage: ahub sdlc record <task> '<receipt JSON>'");
1139
+ let receipt: unknown; try { receipt = JSON.parse(args[2]!); } catch { fail("receipt must be valid JSON"); }
1140
+ return console.log(await taskOp("hub_sdlc_record", { id, receipt }));
1141
+ }
1142
+ if (!["status", "explain", "export"].includes(sub ?? "") || args.length > 2 || (["explain", "export"].includes(sub ?? "") && id === undefined) || (id !== undefined && (!Number.isSafeInteger(id) || id <= 0))) fail("usage: ahub sdlc status [task] | explain <task> | export <task>");
1143
+ const hub = await connect();
1144
+ try {
1145
+ const reply = await hub.request({ t: "sdlc", op: sub, ...(id === undefined ? {} : { id }) }, 30_000);
1146
+ if (!reply.ok) fail(reply.error);
1147
+ console.log(JSON.stringify(reply, null, 2));
1148
+ } finally { hub.close(); }
948
1149
  },
949
1150
 
950
1151
  task: async () => {
951
1152
  const [sub, ...rest] = args;
1153
+ if (sub === "role") {
1154
+ if (rest.length !== 2 || !/^[1-9]\d*$/.test(rest[0]!)) fail("usage: ahub task role <id> <role|none>");
1155
+ const hub = await connect();
1156
+ try { const reply = await hub.request({ t: "task_catalog_role", id: Number(rest[0]), role: rest[1] === "none" ? null : rest[1] }); if (!reply.ok) fail(reply.error); console.log(reply.text); }
1157
+ finally { hub.close(); }
1158
+ return;
1159
+ }
952
1160
  if (sub === "show") return console.log(await taskOp("task_show", { id: rest[0] }));
953
1161
  if (sub === "escalate") return console.log(await taskOp("task_escalate", { id: rest[0] }));
954
- if (sub === "assign") return console.log(await taskOp("task_assign", { id: rest[0], peer: rest[1] }));
1162
+ if (sub === "assign") {
1163
+ const { one, rest: positional } = takeFlags(rest, ["--role"], []);
1164
+ const role = one["--role"] ?? "owner";
1165
+ if (positional.length !== 2 || !["owner", "reviewer"].includes(role)) fail("usage: ahub task assign <id> <peer> [--role owner|reviewer]");
1166
+ return console.log(await taskOp("task_assign", { id: positional[0], peer: positional[1], ...(role === "reviewer" ? { role } : {}) }));
1167
+ }
955
1168
  if (sub === "label") {
956
1169
  // #247: a person's later verdict on an approved task (the identity gate keeps it human-only), for research records.
957
1170
  const { one, rest: positional } = takeFlags(rest, ["--created"], []);
@@ -966,20 +1179,20 @@ const commands: Record<string, () => Promise<void> | void> = {
966
1179
  if (sub !== "propose" || rest.length < 1) fail("usage: ahub task propose [<class>] <title...> | show <id> | assign <id> <peer> | escalate <id>");
967
1180
  // `--class` is the explicit form. A first word that is a class name is still taken as the class (the documented
968
1181
  // short form), but said out loud: "review the auth module" would otherwise be filed as class review, silently.
969
- const urgent = rest.includes("--urgent");
970
- const flags = takeFlags(rest.filter((a) => a !== "--urgent"), ["--owner", "--detail", "--class"], ["--path", "--after"]);
971
- const positional = !flags.one["--class"] && (CLASSES as readonly string[]).includes(flags.rest[0] ?? "") && flags.rest.length > 1;
1182
+ const urgent = rest.includes("--urgent"), backlog = rest.includes("--backlog");
1183
+ const flags = takeFlags(rest.filter((a) => a !== "--urgent" && a !== "--backlog"), ["--owner", "--detail", "--class"], ["--path", "--after"]);
1184
+ const positional = !flags.one["--class"] && ((CLASSES as readonly string[]).includes(flags.rest[0] ?? "") || Object.hasOwn((() => { const config = projectConfig(); return resolveProfile(config.profile, config.profile_overrides); })().classes, flags.rest[0] ?? "")) && flags.rest.length > 1;
972
1185
  const cls = flags.one["--class"] ?? (positional ? flags.rest[0] : undefined);
973
1186
  const title = (positional ? flags.rest.slice(1) : flags.rest).join(" ");
974
1187
  if (positional) console.error(`note: "${cls}" was taken as the class and left out of the title; use --class <c> when the title itself starts with that word`);
975
- console.log(await taskOp("hub_task_propose", { ...(cls ? { class: cls } : {}), title, owner: flags.one["--owner"], detail: flags.one["--detail"], ...(flags.many["--path"]?.length ? { refs: { paths: flags.many["--path"] } } : {}), ...(flags.many["--after"]?.length ? { after: flags.many["--after"].map(Number) } : {}), ...(urgent ? { urgent: true } : {}) }));
1188
+ console.log(await taskOp("hub_task_propose", { ...(cls ? { class: cls } : {}), title, owner: flags.one["--owner"], detail: flags.one["--detail"], ...(flags.many["--path"]?.length ? { refs: { paths: flags.many["--path"] } } : {}), ...(flags.many["--after"]?.length ? { after: flags.many["--after"].map(Number) } : {}), ...(urgent ? { urgent: true } : {}), ...(backlog ? { backlog: true } : {}) }));
976
1189
  },
977
1190
 
978
1191
  review: async () => {
979
- const { many, rest } = takeFlags(args, [], ["--unmet"]);
1192
+ const { one, many, rest } = takeFlags(args, ["--head"], ["--unmet"]);
980
1193
  const [id, verdict, ...note] = rest;
981
1194
  if (!id || !verdict) fail("usage: ahub review <id> approved|changes_requested [note...] [--unmet <item>]...");
982
- console.log(await taskOp("hub_review", { id: Number(id), verdict, note: note.join(" "), ...(many["--unmet"]?.length ? { unmet: many["--unmet"] } : {}) }));
1195
+ console.log(await taskOp("hub_review", { id: Number(id), verdict, ...(one["--head"] ? { head: one["--head"] } : {}), note: note.join(" "), ...(many["--unmet"]?.length ? { unmet: many["--unmet"] } : {}) }));
983
1196
  },
984
1197
 
985
1198
  remember: async () => console.log(await taskOp("hub_remember", { text: freeText(args, "ahub remember <text>") })),
@@ -1047,16 +1260,42 @@ const commands: Record<string, () => Promise<void> | void> = {
1047
1260
  // #269: the terminal's side of the settings registry. Everything the dashboard's Settings section can do, and the
1048
1261
  // same refusals; it talks to the running hub, so a setting is validated and announced in one place.
1049
1262
  // ponytail: needs a running hub, also for a value read only at hub start; a file-only path if that gets in the way.
1263
+ roster: async () => {
1264
+ const options = outputOptions(), flags = args.filter(arg => arg.startsWith("--")), positional = outputArguments().filter(arg => arg !== "--yes" && arg !== "--now");
1265
+ const [sub = "list", peer, ...extra] = positional;
1266
+ if (!["list", "add", "remove"].includes(sub) || extra.length || (sub === "list" ? peer !== undefined : !isSupportedPeer(peer)) || flags.some(flag => !["--json", "--full", "--yes", "--now"].includes(flag) && !flag.startsWith("--color=")) || (sub !== "list" && options.json) || (sub !== "remove" && flags.some(flag => ["--yes", "--now"].includes(flag)))) fail("usage: ahub roster [--json] | add <peer> | remove <peer> [--yes] [--now]");
1267
+ const hub = await connect();
1268
+ try {
1269
+ const request = async (message: Record<string, unknown>) => { const reply = await hub.request(message, 35_000); if (reply.ok !== true) fail(String(reply.error ?? "roster request refused")); return reply; };
1270
+ if (sub === "list") {
1271
+ const view = await request({ t: "settings_get" });
1272
+ if (!Array.isArray(view.roster)) fail("upgrade the running hub to use ahub roster");
1273
+ return options.json ? console.log(JSON.stringify(view.roster, null, 2)) : printOutput(renderRoster(view.roster, options.columns), options.color);
1274
+ }
1275
+ const key = `roster.${peer}`, value = sub === "add";
1276
+ const preview = await request({ t: "settings_preview", key, value });
1277
+ printOutput((preview.lines ?? []).map((text: string) => [{ text }]), options.color);
1278
+ if (sub === "remove" && !flags.includes("--yes")) {
1279
+ if (!process.stdin.isTTY) fail("not a terminal: nothing was changed; rerun with --yes after reviewing the preview");
1280
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
1281
+ let answer: string; try { answer = await rl.question(`Remove ${peer}? [y/N] `); } finally { rl.close(); }
1282
+ if (!/^(y|yes)$/i.test(answer.trim())) return console.log("nothing was changed");
1283
+ }
1284
+ const result = await request({ t: "settings_set", key, value, ...(sub === "remove" ? { confirm: peer, now: flags.includes("--now") } : {}) });
1285
+ printOutput([[{ text: result.text }]], options.color);
1286
+ } finally { hub.close(); }
1287
+ },
1050
1288
  settings: async () => {
1051
1289
  const usage = "usage: ahub settings [list] [--json] | get <key> [--json] | set <key> <value|inherit> [--yes] [--preview] | undo";
1052
1290
  const flags = args.filter((arg) => arg.startsWith("--")), [sub = "list", key, text, ...extra] = args.filter((arg) => !arg.startsWith("--"));
1053
- const allowed: Record<string, string[]> = { list: ["--json"], get: ["--json"], set: ["--yes", "--preview"], undo: [] };
1291
+ const allowed: Record<string, string[]> = { list: ["--json"], get: ["--json"], set: ["--yes", "--preview", "--now"], undo: [] };
1054
1292
  if (!Object.hasOwn(allowed, sub) || flags.some((flag) => !allowed[sub]!.includes(flag)) || extra.length) fail(usage);
1055
1293
  if ((sub === "get" && (!key || text !== undefined)) || (sub === "set" && (!key || text === undefined)) || ((sub === "list" || sub === "undo") && key !== undefined)) fail(usage);
1056
1294
  const def = key === undefined ? undefined : settingDef(key);
1057
1295
  if (typeof def === "string") fail(`${key}: ${def}`);
1296
+ if (flags.includes("--now") && (typeof def !== "object" || def.group !== "Roster")) fail(usage);
1058
1297
  const value = sub === "set" ? parseSettingText(def!, text!) : undefined;
1059
- const peer = def?.group === "Permissions" ? def.path.at(-1)! : undefined;
1298
+ const peer = def?.group === "Permissions" || def?.group === "Roster" ? def.path.at(-1)! : undefined;
1060
1299
  if (sub === "set" && value === "never-ask" && !flags.includes("--preview")) {
1061
1300
  if (!flags.includes("--yes")) fail("never-ask requires --yes; nothing was changed");
1062
1301
  console.error(`never-ask: ${permissionBoundary(peer!)}; approval prompts are disabled`);
@@ -1071,19 +1310,29 @@ const commands: Record<string, () => Promise<void> | void> = {
1071
1310
  }
1072
1311
  return reply;
1073
1312
  };
1074
- if (sub === "undo") return console.log((await request({ t: "settings_undo" })).text);
1313
+ if (sub === "undo") {
1314
+ const view = await request({ t: "settings_get" }), undoDef = settingDef(view.undo);
1315
+ const rosterPeer = typeof undoDef !== "string" && undoDef.group === "Roster" ? undoDef.path[1] : undefined;
1316
+ let confirm: string | undefined;
1317
+ if (rosterPeer) {
1318
+ if (!process.stdin.isTTY) fail("undoing a roster setting needs confirmation in a terminal; use ahub settings set for an explicit scripted change");
1319
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
1320
+ try { confirm = (await rl.question(`Type ${rosterPeer} to confirm this roster undo: `)).trim(); } finally { rl.close(); }
1321
+ }
1322
+ return console.log(paint([{ text: (await request({ t: "settings_undo", ...(confirm ? { confirm } : {}) })).text }], false));
1323
+ }
1075
1324
  if (sub === "set" && flags.includes("--preview")) {
1076
1325
  const preview = await request({ t: "settings_preview", key, value });
1077
- for (const line of preview.lines ?? []) console.log(line);
1326
+ for (const line of preview.lines ?? []) console.log(paint([{ text: line }], false));
1078
1327
  // Each side is `route explain`: the task line, "if it were assigned now:", then the trace.
1079
1328
  for (const task of preview.tasks ?? []) console.log([task.before[0], " before:", ...task.before.slice(2).map((line: string) => ` ${line}`), " after:", ...task.after.slice(2).map((line: string) => ` ${line}`)].join("\n"));
1080
1329
  if (preview.hidden) console.log(`${preview.hidden} more open tasks are not shown`);
1081
1330
  return console.log("preview only; nothing was changed");
1082
1331
  }
1083
1332
  if (sub === "set") {
1084
- const reply = await request({ t: "settings_set", key, value, ...(flags.includes("--yes") && peer ? { confirm: peer } : {}) });
1085
- console.log(reply.text);
1086
- if (typeof reply.note === "string" && reply.note) console.log(reply.note);
1333
+ const reply = await request({ t: "settings_set", key, value, now: flags.includes("--now"), ...(flags.includes("--yes") && peer ? { confirm: peer } : {}) });
1334
+ console.log(paint([{ text: reply.text }], false));
1335
+ if (typeof reply.note === "string" && reply.note) console.log(paint([{ text: reply.note }], false));
1087
1336
  return;
1088
1337
  }
1089
1338
  const view = await request({ t: "settings_get" });
@@ -1093,9 +1342,9 @@ const commands: Record<string, () => Promise<void> | void> = {
1093
1342
  let group = "";
1094
1343
  for (const row of rows) {
1095
1344
  if (sub === "list" && row.group !== group) console.log(`${group ? "\n" : ""}${group = row.group}`);
1096
- const facts = [`from ${row.source}`, row.applies === "live" ? "applies at once" : row.applies === "peer start" ? "applies at the peer's next start" : "read at hub start", ...(row.pending !== undefined ? [`${settingText(row.pending)} at the next hub start`] : []), ...(row.note ? [row.note] : [])];
1345
+ const facts = [`from ${row.source}`, row.applies === "live" ? "applies at once" : row.applies === "peer start" ? "applies at the peer's next start" : "read at hub start", ...(row.pending !== undefined ? [`${settingText(row.pending)} at the next ${row.applies === "peer start" ? "peer" : "hub"} start`] : []), ...(row.note ? [row.note] : [])];
1097
1346
  console.log(` ${row.key.padEnd(width)} ${settingText(row.value).padEnd(valueWidth)} ${facts.join("; ")}`);
1098
- if (sub === "get") console.log(` ${row.label}; written to ${row.file}; takes ${row.type === "boolean" ? "true, false" : row.type === "peers" ? "a comma list of peer ids" : (row.values ?? []).join(", ")}${row.file === "this hub run" ? "" : ", or inherit to remove the machine-local value"}${row.risk === "raises" ? "; a dashboard needs a settings session (ahub ui --settings) to raise it" : ""}`);
1347
+ if (sub === "get") console.log(` ${row.label}; written to ${row.file}; takes ${row.type === "boolean" ? "true, false" : row.type === "peers" ? "a comma list of peer ids" : row.type === "roles" ? "up to two comma-separated catalog ids" : (row.values ?? []).join(", ")}${row.file === "this hub run" ? "" : ", or inherit to remove the machine-local value"}${row.risk === "raises" ? "; a dashboard needs a settings session (ahub ui --settings) to raise it" : ""}`);
1099
1348
  }
1100
1349
  if (sub === "list" && view.undo) console.log(`\nahub settings undo puts back the last write (${view.undo})`);
1101
1350
  } finally { hub.close(); }
@@ -1110,6 +1359,15 @@ const commands: Record<string, () => Promise<void> | void> = {
1110
1359
  hub.close();
1111
1360
  },
1112
1361
 
1362
+ pet: () => {
1363
+ if (args.some(arg => arg !== "--short")) return fail("usage: ahub pet [--short]");
1364
+ const short = args.includes("--short");
1365
+ let character: import("../pet/frame.ts").CharacterId | undefined;
1366
+ try { character = loadPetConfig(cwd).character; } catch { /* explicit structural reads remain available with malformed presentation config */ }
1367
+ const text = renderPet(readPet(stateDir), short, character);
1368
+ process.stdout.write(short ? text : text + "\n");
1369
+ },
1370
+
1113
1371
  status: async () => {
1114
1372
  const options = outputOptions();
1115
1373
  if (args.includes("--all")) return printProjects(options);
@@ -1119,6 +1377,11 @@ const commands: Record<string, () => Promise<void> | void> = {
1119
1377
  if (options.json) return console.log(JSON.stringify(status, null, 2));
1120
1378
  const quota = await hub.request({ t: "budget" });
1121
1379
  printOutput(renderStatus({ ...status, budget: quota.budget ?? {} }, options.columns, Date.now(), options.full), options.color);
1380
+ if (status.needs) { const pet = loadPetConfig(cwd); if (pet.enabled) console.log(renderPet(petView(status), false, pet.character)); }
1381
+ // #258: open long-term tasks and who holds them, as of this machine's last memory sync.
1382
+ if (existsSync(join(hubHome(), "memory", "index.db"))) {
1383
+ try { const lines = await goalCommand(["list"], cwd); if (!lines.startsWith("no open")) console.log(`\nlong-term tasks\n${lines}`); } catch { /* memory unreadable: status goes on */ }
1384
+ }
1122
1385
  } finally { hub.close(); }
1123
1386
  },
1124
1387
 
@@ -1131,18 +1394,26 @@ const commands: Record<string, () => Promise<void> | void> = {
1131
1394
  const json = args.includes("--json");
1132
1395
  const [sub, ...rest] = args.filter((a) => a !== "--json");
1133
1396
  if (sub === "run") {
1134
- const { one, rest: positional } = takeFlags(rest, ["--arm", "--repeat", "--tasks"], []);
1397
+ const { one, many, rest: positional } = takeFlags(rest, ["--arm", "--repeat", "--tasks", "--memory"], ["--addon"]);
1398
+ if (one["--memory"] !== undefined && !(MEMORY_MODES as readonly string[]).includes(one["--memory"])) fail(`--memory takes ${MEMORY_MODES.join(", ")}`);
1399
+ // #248: `--addon ripwire=off` declares the arm's add-on state; the run refuses peers that do not match it.
1400
+ const addons: Partial<Record<AddonName, "on" | "off">> = {};
1401
+ for (const spec of many["--addon"] ?? []) {
1402
+ const [name, state] = spec.split("=");
1403
+ if (!name || !isAddon(name) || (state !== "on" && state !== "off") || addons[name]) fail(`--addon takes <name>=on|off once per add-on, name one of ${ADDONS.join(", ")}`);
1404
+ addons[name] = state;
1405
+ }
1135
1406
  const suite = positional[0];
1136
1407
  const repeat = one["--repeat"] === undefined ? 1 : Number(one["--repeat"]);
1137
1408
  if (!suite || positional.length !== 1 || !one["--arm"]?.trim() || !Number.isSafeInteger(repeat) || repeat < 1 || repeat > 100) {
1138
- fail("usage: ahub bench run <suite.json> --arm <label> [--repeat 1..100] [--tasks a,b]");
1409
+ fail("usage: ahub bench run <suite.json> --arm <label> [--repeat 1..100] [--tasks a,b] [--addon <name>=on|off]... [--memory off|claude-mem|native|both]");
1139
1410
  }
1140
1411
  await benchPreflight(cwd, loadConfig(cwd).bench.enabled); // the suite's refs are checked once it is read
1141
1412
  let interrupted = false;
1142
1413
  const stop = () => { interrupted = true; console.log("stopping at the next check: the attempt in progress is recorded as interrupted (a task still open is named)"); };
1143
1414
  process.on("SIGINT", stop);
1144
1415
  try {
1145
- const run = await runBench({ cwd, stateDir, suitePath: resolve(invokedFrom, suite!), arm: one["--arm"]!.trim(), repeat, only: one["--tasks"]?.split(",").map((t) => t.trim()).filter(Boolean), stopped: () => interrupted }, defaultIo(stateDir, cwd));
1416
+ const run = await runBench({ cwd, stateDir, suitePath: resolve(invokedFrom, suite!), arm: one["--arm"]!.trim(), repeat, addons, ...(one["--memory"] ? { memory: one["--memory"] as MemoryMode } : {}), only: one["--tasks"]?.split(",").map((t) => t.trim()).filter(Boolean), stopped: () => interrupted }, defaultIo(stateDir, cwd));
1146
1417
  return console.log(formatBenchReport(benchReport(readRuns().filter((r) => r.header.run === run))).join("\n"));
1147
1418
  } finally { process.off("SIGINT", stop); }
1148
1419
  }
@@ -1182,12 +1453,25 @@ const commands: Record<string, () => Promise<void> | void> = {
1182
1453
  }
1183
1454
  fail("usage: ahub bench run|list|status|report|compare|export (ahub help bench)");
1184
1455
  },
1456
+ memory: async () => {
1457
+ // #258: the hub's own memory on this machine; reads are anyone's, changes a person's.
1458
+ try { console.log(await memoryCommand(args, cwd)); } catch (e) { fail((e as Error).message); }
1459
+ },
1460
+ goal: async () => {
1461
+ // #258: long-term tasks; a link goes through the project's hub, so Tasks validates the board task.
1462
+ const link = async (goal: string, task: string) => {
1463
+ const hub = await connect();
1464
+ try { const r = await hub.request({ t: "goal_link", goal, task: Number(task) }); if (!r.ok) throw new Error(String(r.error)); return String(r.text); }
1465
+ finally { hub.close(); }
1466
+ };
1467
+ try { console.log(await goalCommand(args, cwd, { link })); } catch (e) { fail((e as Error).message); }
1468
+ },
1185
1469
  research: () => {
1186
1470
  // #247: measures from the opt-in research records; `export` writes them out, `backfill` builds them from events.jsonl.
1187
1471
  const [sub] = args;
1188
1472
  // Another project is `ahub --project <dir> research`; anything unknown is refused rather than ignored.
1189
1473
  const flags = new Set(["--all", "--json", "--since", "--format"]), valued = new Set(["--since", "--format"]);
1190
- for (let i = sub === "export" || sub === "backfill" ? 1 : 0; i < args.length; i++) {
1474
+ for (let i = sub === "export" || sub === "backfill" || sub === "addons" ? 1 : 0; i < args.length; i++) {
1191
1475
  if (!flags.has(args[i]!)) fail(`ahub research: unknown argument ${args[i]} (another project: ahub --project <dir> research)`);
1192
1476
  if (valued.has(args[i]!)) i++;
1193
1477
  }
@@ -1205,7 +1489,12 @@ const commands: Record<string, () => Promise<void> | void> = {
1205
1489
  const kept = records.filter((r) => r.kind === "label" || Date.parse(r.approvedAt) >= from);
1206
1490
  return void process.stdout.write(args.includes("--format") && format === "csv" ? toCsv(kept) : kept.map((r) => JSON.stringify(r)).join("\n") + (kept.length ? "\n" : ""));
1207
1491
  }
1208
- if (sub !== undefined && !sub.startsWith("--")) fail("usage: ahub research [--since 30d] [--all] [--json] | export [--format jsonl|csv] [--since] [--all] | backfill");
1492
+ if (sub === "addons") {
1493
+ // #248: approved tasks with each add-on on against off, by the tooling fingerprint in their records.
1494
+ const rows = addonReport(records, from);
1495
+ return console.log(args.includes("--json") ? JSON.stringify(rows, null, 2) : formatAddons(rows).join("\n"));
1496
+ }
1497
+ if (sub !== undefined && !sub.startsWith("--")) fail("usage: ahub research [--since 30d] [--all] [--json] | addons [--since] [--all] [--json] | export [--format jsonl|csv] [--since] [--all] | backfill");
1209
1498
  const r = researchReport(records, from);
1210
1499
  console.log(args.includes("--json") ? JSON.stringify(r, null, 2) : formatResearch(r).join("\n"));
1211
1500
  },
@@ -1284,9 +1573,10 @@ const commands: Record<string, () => Promise<void> | void> = {
1284
1573
  const { start_tree: startTree, end_tree: endTree } = turn;
1285
1574
  if (!startTree || !endTree) fail(`turn ${id} has no ${startTree ? "end" : "start"} snapshot: the hub stopped during it, or a snapshot failed (see hub.log)`);
1286
1575
  const repo = repoOf(cwd) ?? fail(`${cwd} is not in a git work tree`);
1287
- if (!hasTree(repo.top, startTree) || !hasTree(repo.top, endTree)) fail(`turn ${id}'s snapshots are gone from the git object store (git gc prunes them after two weeks)`);
1576
+ if (!hasTree(repo, startTree) || !hasTree(repo, endTree)) fail(`turn ${id}'s snapshots are gone from the git object store (git gc prunes them after two weeks)`);
1288
1577
  // Another peer's turn that overlapped this one in time has its changes in this turn's diff as well.
1289
- const keep = Math.max(1, Number(projectConfig().snapshots.keep) || 20);
1578
+ const undoConfig = projectConfig();
1579
+ const keep = Math.max(1, Number(undoConfig.snapshots.keep) || 20);
1290
1580
  const overlapping = turnRecords((t) => t.overlapping(turn, keep), { paths: [] as string[], unknown: [] as string[] });
1291
1581
  if (overlapping.unknown.length) fail(`refusing to undo ${id}: these turns of other peers ran at the same time and their changes are not known (still running, cut short, or not snapshotted): ${overlapping.unknown.join(", ")}`);
1292
1582
  let reverted = false;
@@ -1304,6 +1594,10 @@ const commands: Record<string, () => Promise<void> | void> = {
1304
1594
  if (first.undone) return console.log(`turn ${id} is already undone: its files are as they were before it started`);
1305
1595
  console.log(first.restore.length ? `turn ${id} changed:\n ${first.restore.join("\n ")}` : `turn ${id} changed no files`);
1306
1596
  console.log("(every change made in the project during the turn counts as its own, including any by Claude or by you)");
1597
+ const projections = new TextProjections({ cwd, stateDir, config: undoConfig.text_projection ?? {}, deny: undoConfig.local.deny, readAllow: undoConfig.local.read_allow, log: line => console.error(line) });
1598
+ const preview = projectedDiff({ repo, from: endTree, to: startTree, paths: first.restore, projections, deny: undoConfig.local.deny, privatePath: path => matchesPolicy(cwd, join(repo.top, path), undoConfig.pii?.paths ?? []) });
1599
+ if (preview) console.log(terminalText("Undo preview (end snapshot to start snapshot):\n" + preview));
1600
+
1307
1601
  if (!args.includes("--yes")) return console.log("nothing was changed; add --yes to restore these files");
1308
1602
  if (args.includes("--context")) {
1309
1603
  console.log(await taskOp("turn_revert", { turn: id }));
@@ -1406,6 +1700,32 @@ const commands: Record<string, () => Promise<void> | void> = {
1406
1700
  console.log("Claude Code sessions attached to this hub lost their hub session: relaunch them with ahub claude");
1407
1701
  },
1408
1702
 
1703
+ role: async () => {
1704
+ const catalog = loadRoleCatalog(cwd);
1705
+ if (args[0] === "list") {
1706
+ const { one, rest } = takeFlags(args.slice(1), ["--group"], []);
1707
+ if (rest.length) fail("usage: ahub role list [--group <group>]");
1708
+ return console.log(paint([{ text: JSON.stringify({ roles: [...catalog.roles.values()].filter(role => !one["--group"] || role.group === one["--group"]).map(({ body: _body, review: _review, ...role }) => role), errors: catalog.errors }, null, 2) }], false));
1709
+ }
1710
+ if (args[0] === "show" && args.length === 2) {
1711
+ const role = catalog.roles.get(args[1]!); if (!role) fail("unknown catalog role; run ahub role list");
1712
+ return console.log(paint([{ text: JSON.stringify(role, null, 2) }], false));
1713
+ }
1714
+ if (["assign", "clear"].includes(args[0] ?? "")) {
1715
+ const [op, peer, id] = args;
1716
+ if (!peer || !isSupportedPeer(peer) || !id && op === "assign" || args.length > 3) fail("usage: ahub role assign <peer> <id> | clear <peer> [<id>]");
1717
+ const hub = await connect();
1718
+ try {
1719
+ const view = await hub.request({ t: "settings_get" }); if (!view.ok) fail(view.error);
1720
+ const row = (view.rows as SettingRow[]).find(row => row.key === `catalog_roles.${peer}`); if (!row) fail("this hub does not support catalog role settings");
1721
+ const current = Array.isArray(row!.pending) ? row!.pending : Array.isArray(row!.value) ? row!.value : [];
1722
+ const value = op === "assign" ? [...new Set([...current, id!])] : id ? current.filter(role => role !== id) : [];
1723
+ const reply = await hub.request({ t: "settings_set", key: row!.key, value }); if (!reply.ok) fail(reply.error); console.log(reply.text);
1724
+ } finally { hub.close(); }
1725
+ return;
1726
+ }
1727
+ fail("usage: ahub role list [--group <group>] | show <id> | assign <peer> <id> | clear <peer> [<id>]");
1728
+ },
1409
1729
  doctor: async () => {
1410
1730
  const options = outputOptions();
1411
1731
  if (args.includes("--orphans")) return orphanDoctor(args.includes("--kill"), options);
@@ -1415,16 +1735,22 @@ const commands: Record<string, () => Promise<void> | void> = {
1415
1735
  };
1416
1736
  const checks: DoctorCheck[] = [];
1417
1737
  const row = (ok: boolean | undefined, name: string, detail: string) => {
1418
- const section: DoctorCheck["section"] = ["bun", "claude", "codex", "kimi"].includes(name) ? "Tools"
1738
+ const section: DoctorCheck["section"] = ["bun", "claude", "codex", "kimi", "qwen", "jcode"].includes(name) ? "Tools"
1419
1739
  : name === "config" || name === "retired setting" ? "Config"
1420
1740
  : name.startsWith("memory") ? "Memory"
1421
1741
  : ["omniroute", "omniroute key", "local fixed_model", "switchyard", "pi mlx"].includes(name) ? "Models" : "Hub";
1422
- const optional = ["claude", "codex", "kimi", "ahub daemon", "switchyard", "pi mlx"].includes(name) || section === "Memory";
1742
+ const optional = ["claude", "codex", "kimi", "qwen", "jcode", "ahub daemon", "switchyard", "pi mlx"].includes(name) || section === "Memory";
1423
1743
  const level: DoctorCheck["level"] = ok === undefined ? (["switchyard", "omniroute"].includes(name) ? "warn" : "unknown") : ok ? "ok" : optional ? "warn" : "fail";
1424
1744
  checks.push({ section, level, name, detail });
1425
1745
  };
1426
1746
 
1427
1747
  try {
1748
+ const catalog = loadRoleCatalog(cwd);
1749
+ for (const error of catalog.errors) row(false, "catalog role", `${error.id}: ${error.reason}`);
1750
+ try {
1751
+ const config = loadConfig(cwd), assigned = catalogAssignments(config.catalog_roles);
1752
+ for (const peer of Object.keys(assigned)) selectedRoles(peer, config.roles, assigned, catalog);
1753
+ } catch { row(false, "catalog roles", "invalid assignment id, shape or flow base"); }
1428
1754
  for (const bin of ["bun", "claude", "codex", "kimi"]) {
1429
1755
  const v = version(bin);
1430
1756
  row(!!v, bin, v ?? "not found on PATH");
@@ -1450,7 +1776,14 @@ const commands: Record<string, () => Promise<void> | void> = {
1450
1776
 
1451
1777
  const config = loadConfig(cwd);
1452
1778
  row(!config.ignored, "config", config.ignored ? `${config.ignored.join("; ")} (move them to .agenthub/config.local.json)` : "no machine-local field ignored");
1453
- for (const line of config.retired ?? []) row(false, "retired setting", line); // issue #83
1779
+ for (const line of config.retired ?? []) row(false, "retired setting", line);
1780
+ if (config.roster.jcode === true) {
1781
+ row(verifiedJcodeCommand(config.jcode_cmd, cwd), "jcode", "CLI/source identity only; provider, project MCP and native teardown require separate receipts; native policy unmanaged");
1782
+ }
1783
+ if (config.roster.qwen === true) {
1784
+ const v = version(config.qwen_cmd[0]!);
1785
+ row(v === "0.25.0", "qwen", v === "0.25.0" ? "CLI reports 0.25.0; native ACP identity/modes and provider access require a separate live check" : "Qwen Code 0.25.0 executable not confirmed; provider and ACP readiness unknown");
1786
+ } // issue #83
1454
1787
  const omni = new OmniRoute(config.omniroute);
1455
1788
  const gateway = await omni.base();
1456
1789
  row(gateway ? true : config.omniroute.urls.length || process.env.AGENTHUB_OMNIROUTE_URL ? false : undefined, "omniroute", gateway ? `${new URL(gateway).host} healthy` : config.omniroute.urls.length || process.env.AGENTHUB_OMNIROUTE_URL ? "no candidate reachable (VPN off?); ahub local cannot run" : "not configured: set omniroute.urls in .agenthub/config.local.json (any OpenAI-compatible gateway); ahub local cannot run");
@@ -1493,8 +1826,10 @@ async function runConductorCommand(): Promise<void> {
1493
1826
  let input: Record<string, unknown>;
1494
1827
  if (cmd === "task") {
1495
1828
  op = args[0] === "assign" ? "hub_task_assign" : "hub_task_escalate";
1496
- if (!args[1] || (args[0] === "assign" && !args[2]) || args.length !== (args[0] === "assign" ? 3 : 2)) fail("usage: ahub task assign <id> <peer> | escalate <id>");
1497
- input = { id: Number(args[1]), ...(args[0] === "assign" ? { peer: args[2] } : {}) };
1829
+ const { one, rest: positional } = takeFlags(args.slice(1), args[0] === "assign" ? ["--role"] : [], []);
1830
+ const role = one["--role"] ?? "owner";
1831
+ if (!positional[0] || positional.length !== (args[0] === "assign" ? 2 : 1) || !["owner", "reviewer"].includes(role)) fail("usage: ahub task assign <id> <peer> [--role owner|reviewer] | escalate <id>");
1832
+ input = { id: Number(positional[0]), ...(args[0] === "assign" ? { peer: positional[1], ...(role === "reviewer" ? { role } : {}) } : {}) };
1498
1833
  } else if (cmd === "pause" || cmd === "resume") {
1499
1834
  if (args.length !== 1) fail(`usage: ahub ${cmd} <peer>`);
1500
1835
  op = cmd === "pause" ? "hub_peer_hold" : "hub_peer_release";