@staix/agent-hub 0.16.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +2 -2
  3. package/docs/agent-notes/README.md +5 -0
  4. package/docs/agent-notes/adapters.md +20 -5
  5. package/docs/agent-notes/benchmarks.md +1 -0
  6. package/docs/agent-notes/daemon.md +5 -3
  7. package/docs/agent-notes/local-worker.md +1 -0
  8. package/docs/agent-notes/tasks.md +9 -3
  9. package/docs/agent-notes/tests.md +6 -3
  10. package/docs/dogfood.md +21 -4
  11. package/docs/memory.md +1 -1
  12. package/docs/operations.md +150 -34
  13. package/docs/profiles.md +36 -0
  14. package/docs/quickstart.md +14 -4
  15. package/docs/roles.md +1 -0
  16. package/docs/sdlc.md +39 -13
  17. package/docs/security.md +41 -9
  18. package/docs/smoke.md +51 -3
  19. package/docs/specs/2026-09-19-agent-hub-design.md +56 -10
  20. package/docs/specs/2026-09-20-upgrade-recovery-design.md +3 -1
  21. package/docs/verification/2026-10-25-0.17.0.md +74 -0
  22. package/docs/verification/2026-10-25-0.18.0.md +76 -0
  23. package/docs/verification/2026-10-25-codex-0164-reviewer-tool-call.md +52 -0
  24. package/docs/verification/2026-10-25-codex-review-live-verdict.md +35 -0
  25. package/docs/verification/2026-10-25-kimi-231-reviewer-compat.md +48 -0
  26. package/docs/verification/2026-10-25-kimi-usage-ac11.md +48 -0
  27. package/docs/verification/2026-10-25-qwen-dual-output-hub-e2e.md +23 -0
  28. package/docs/verification/2026-10-25-qwen-dual-output-t0.md +20 -0
  29. package/docs/verification/2026-10-25-remote-docker-transport.md +46 -0
  30. package/docs/verification/2026-10-25-reviewer-no-verdict-diagnostics.md +35 -0
  31. package/docs/verification/profiles/YYYY-MM-DD-general.md +46 -0
  32. package/docs/verification/profiles/YYYY-MM-DD-research.md +61 -0
  33. package/docs/verification/profiles/YYYY-MM-DD-writing.md +46 -0
  34. package/docs/verified.json +28 -19
  35. package/package.json +2 -2
  36. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  37. package/plugins/agent-hub/server.js +1115 -192
  38. package/src/adapters/codex-review-isolation.ts +10 -1
  39. package/src/adapters/compat.ts +7 -2
  40. package/src/adapters/conformance/enrollment.ts +18 -4
  41. package/src/adapters/conformance/factories.ts +4 -1
  42. package/src/adapters/conformance/kit.ts +11 -0
  43. package/src/adapters/definitions/builtin-metadata.ts +1 -1
  44. package/src/adapters/definitions/native-cli-entry-qwen.ts +32 -4
  45. package/src/adapters/definitions/native-cli-preview-qwen.ts +9 -1
  46. package/src/adapters/definitions/native-factories.ts +8 -0
  47. package/src/adapters/definitions/native-recovery.ts +8 -0
  48. package/src/adapters/definitions/qwen.ts +7 -4
  49. package/src/adapters/local-worker.ts +5 -3
  50. package/src/adapters/qwen-dual.ts +578 -0
  51. package/src/adapters/registry-types.ts +5 -1
  52. package/src/adapters/registry.ts +9 -1
  53. package/src/adapters/reviewer-mcp.ts +12 -6
  54. package/src/adapters/reviewer-print.ts +100 -17
  55. package/src/cli/console-state.ts +9 -2
  56. package/src/cli/help.ts +29 -28
  57. package/src/cli/identity.ts +87 -6
  58. package/src/cli/main.ts +190 -69
  59. package/src/cli/output.ts +7 -2
  60. package/src/cli/sdlc-output.ts +242 -0
  61. package/src/cli/task-output.ts +212 -0
  62. package/src/cli/upgrade-output.ts +57 -0
  63. package/src/cli/upgrade.ts +4 -3
  64. package/src/hub/board.ts +17 -0
  65. package/src/hub/check-runner.ts +252 -22
  66. package/src/hub/conductor.ts +5 -0
  67. package/src/hub/config-apply.ts +16 -7
  68. package/src/hub/config-edit-host.ts +1 -1
  69. package/src/hub/config-tier.ts +1 -1
  70. package/src/hub/daemon.ts +487 -94
  71. package/src/hub/dogfood-profile.ts +248 -0
  72. package/src/hub/dogfood-read.ts +77 -14
  73. package/src/hub/dogfood.ts +5 -1
  74. package/src/hub/events.ts +1 -1
  75. package/src/hub/host-executable.ts +2 -1
  76. package/src/hub/hub-tools.ts +2 -2
  77. package/src/hub/memory-plan-guard.ts +8 -33
  78. package/src/hub/operational-authority.ts +60 -0
  79. package/src/hub/operational-view.ts +141 -0
  80. package/src/hub/orphan-process.ts +69 -0
  81. package/src/hub/permission-mode.ts +1 -1
  82. package/src/hub/read-only-shell-proof.ts +80 -0
  83. package/src/hub/read-only-shell.ts +162 -0
  84. package/src/hub/reviewer-pool-config.ts +3 -2
  85. package/src/hub/reviewer-pool.ts +3 -0
  86. package/src/hub/routing.ts +23 -6
  87. package/src/hub/sdlc-merge.ts +299 -0
  88. package/src/hub/sdlc-mirror.ts +129 -0
  89. package/src/hub/sdlc.ts +58 -12
  90. package/src/hub/settings.ts +11 -5
  91. package/src/hub/snapshots.ts +60 -0
  92. package/src/hub/task-repository.ts +31 -5
  93. package/src/hub/tasks.ts +266 -28
  94. package/src/local/tools.ts +17 -4
  95. package/src/memory/brief.ts +45 -5
  96. package/src/ui/index.html +2 -1
package/src/cli/main.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env bun
2
+ import { conductorOperation } from "../hub/operational-authority.ts";
2
3
  import type { DeliverableRow } from "../hub/tasks.ts";
3
4
  import { kimiSourceRow } from "../hub/budget-notes.ts";
4
5
  import { hubGitSync, hubGitEnv } from "../hub/git.ts";
@@ -66,8 +67,8 @@ import { exactVersion, latestRelease, newerVersion } from "./recovery-package.ts
66
67
  import { operationScreen, planScreen, type ScreenIO, type UpgradeHost } from "./upgrade-interactive.ts";
67
68
  import { readRecoveryPresentationFlags } from "./upgrade-presentation-flags.ts";
68
69
  import { renderOperation, renderBlocked, renderSummary, type RecoveryPresentationOptions } from "./upgrade-presentation.ts";
70
+ import { renderBudgetResult, renderRecoveryStatus, renderUpgradePlan } from "./upgrade-output.ts";
69
71
  import { recordTerminalLaunch, prepareTerminalLaunch, registerTerminalLaunch, type TerminalPeer } from "./terminal-recovery.ts";
70
- import { stopAndArchiveSummary } from "./recovery-guidance.ts";
71
72
  import { ensureMlx, inspectMlx, stopMlx } from "../models/mlx.ts";
72
73
  import { setupOllamaModel } from "./models-setup.ts";
73
74
 
@@ -94,6 +95,8 @@ import { runConsole } from "./console.ts";
94
95
  import { paint, permissionBoundary, resolveColor, wrap, type Span } from "./console-state.ts";
95
96
  import { outputWidth, renderRoster, renderStatus, renderBoard, renderBacklog, renderBoardColumns, boardColumnModel, renderBudget, renderDoctor, renderProjects, renderQueue, renderTurns, renderOrphans, renderQueueShow, renderModelsStatus, renderExecutionBudgetStatus, renderReport, renderUsage, renderLimits, renderTotal, type DoctorCheck } from "./output.ts";
96
97
  import { renderHelp, configuredAcpHelp } from "./help.ts";
98
+ import { renderBacklogResult, renderGroom, renderReleaseChange, renderReleaseList, renderReleaseNew, renderReleaseShow, renderTaskCancel, renderTaskShow } from "./task-output.ts";
99
+ import { renderAuthorization, renderSdlcCosts, renderSdlcExplain, renderSdlcMetrics, renderSdlcQueue, renderSdlcRelease, renderSdlcStatus } from "./sdlc-output.ts";
97
100
  import { renderTailEvent } from "./tail-render.ts";
98
101
  import { archiveProblem, archiveState, damagedState, failureText, MANIFEST, planReset, resetLines, resetRuntime, startState, type ResetPlan } from "./reset.ts";
99
102
 
@@ -201,6 +204,7 @@ const projectConfig = () => {
201
204
  return config;
202
205
  };
203
206
  const stateDir = selected.stateDir;
207
+ var operationalAuditReady=false, operationalRefusalRecorded=false;
204
208
  const caller = declaredInstances ? detectCliIdentity(process.env, declaredInstances) : unboundCaller;
205
209
  const commandLabel = cliCommandLabel(cmd, args, projectCatalog);
206
210
  const commandAccess = classifyPeerCommand(cmd, args, projectCatalog);
@@ -208,8 +212,10 @@ const commandAccess = classifyPeerCommand(cmd, args, projectCatalog);
208
212
  const delegationRefused = liveTest && caller.role === "tools" && commandAccess !== "allowed" && liveTestRefusal(cmd, args);
209
213
  const delegatedPeer = liveTest && caller.role === "tools" && commandAccess !== "allowed" && !delegationRefused ? caller.peer : undefined;
210
214
  const identity = delegatedPeer ? { role: "console" as const } : caller;
215
+ operationalAuditReady=identity.role==="tools" && commandAccess==="conductor" && conductorOperation(cmd,args)!==undefined;
211
216
  function audit(outcome: "run" | "refused" | "invalid"): void {
212
217
  if (caller.role === "console") return;
218
+ if(outcome==="refused" && operationalAuditReady){if(operationalRefusalRecorded)return;operationalRefusalRecorded=true;}
213
219
  try { recordCliAudit(stateDir, caller.role === "tools" ? caller.peer : "unknown", commandLabel, outcome); }
214
220
  catch {
215
221
  if (delegatedPeer) fail("live-test delegation audit unavailable; nothing was executed");
@@ -249,7 +255,7 @@ if (delegatedPeer && !recoveryAudit && !noAuditConsumer) {
249
255
  }
250
256
  fail((error as Error).message);
251
257
  }
252
- } else if (identity.role === "tools" || delegatedPeer) audit("run");
258
+ } else if ((identity.role === "tools" || delegatedPeer) && !operationalAuditReady) audit("run");
253
259
  function assertDelegatedAuditConsumer(): void {
254
260
  if (delegatedAuditHub && ((!delegatedConnectionTaken && !delegatedAuditHub.isOpen) || readControl(stateDir)?.instanceId !== delegatedAuditHub.instanceId)) {
255
261
  throw new Error("delegated audit consumer changed; refusing further actions; inspect the result before retrying");
@@ -260,18 +266,52 @@ const liveTestArgs = liveTest ? ["--live-test"] : [];
260
266
  const invokedFrom = process.cwd(); // relative paths a person types (a bench suite) are theirs, not the project's
261
267
  try { process.chdir(cwd); } catch { fail(`project directory is unavailable: ${cwd}`); }
262
268
  /** One-shot output flags do not alter the fetched JSON document. */
263
- function outputOptions() {
264
- const flags = args.filter(arg => arg.startsWith("--color="));
265
- if (flags.length > 1 || args.includes("--color")) fail(`usage: ahub ${cmd} [--color=auto|always|never] [--json] [--full]`);
269
+ function outputOptions(source: readonly string[] = args) {
270
+ const flags = source.filter(arg => arg.startsWith("--color="));
271
+ if (flags.length > 1 || source.includes("--color")) fail(`usage: ahub ${cmd} [--color=auto|always|never] [--json] [--full]`);
266
272
  const color = resolveColor(flags[0]?.slice("--color=".length), { isTTY: !!process.stdout.isTTY, TERM: process.env.TERM, NO_COLOR: process.env.NO_COLOR });
267
273
  if (color instanceof Error) fail(`usage: ahub ${cmd} [--color=auto|always|never] [--json] [--full]`);
268
- return { color: color === true, json: args.includes("--json"), full: args.includes("--full"),
274
+ return { color: color === true, json: source.includes("--json"), full: source.includes("--full"),
269
275
  columns: outputWidth({ isTTY: !!process.stdout.isTTY, columns: process.stdout.columns, COLUMNS: process.env.COLUMNS }) };
270
276
  }
277
+ /**
278
+ * #814 T2: split `--json`, `--full` and `--color=` from a command that also takes free text. A value flag's value is never a
279
+ * presentation flag (`task cancel --reason --json` records the reason `--json`), and with `edges` only a leading or trailing run
280
+ * counts, so a reason that mentions `--json` in the middle of a sentence keeps its words. A reason that ENDS in a bare
281
+ * presentation flag loses it: the documented cost of accepting the flag after the reason.
282
+ */
283
+ function splitPresentation(argv: readonly string[], opts: { valueFlags?: readonly string[]; edges?: boolean } = {}): { operands: string[]; flags: string[] } {
284
+ const isFlag = (arg: string) => arg === "--json" || arg === "--full" || arg.startsWith("--color=");
285
+ const operands: string[] = [], flags: string[] = [], value = new Set(opts.valueFlags ?? []);
286
+ let lead = true;
287
+ const middle: string[] = [];
288
+ for (let i = 0; i < argv.length; i++) {
289
+ const arg = argv[i]!;
290
+ if (value.has(arg)) { (opts.edges ? middle : operands).push(arg); if (i + 1 < argv.length) (opts.edges ? middle : operands).push(argv[++i]!); lead = false; continue; }
291
+ if (!isFlag(arg)) { (opts.edges ? middle : operands).push(arg); lead = false; continue; }
292
+ if (!opts.edges || lead) flags.push(arg); else middle.push(arg);
293
+ }
294
+ if (!opts.edges) return { operands, flags };
295
+ // a trailing run of presentation flags moves out of the middle; anything earlier stays operand text
296
+ let end = middle.length;
297
+ const trailing: string[] = [];
298
+ while (end > 0 && isFlag(middle[end - 1]!) && !value.has(middle[end - 2] ?? "")) trailing.unshift(middle[--end]!);
299
+ return { operands: middle.slice(0, end), flags: [...flags, ...trailing] };
300
+ }
271
301
  /** Remove only presentation flags from read-only command positional arguments. */
272
302
  function outputArguments(): string[] {
273
303
  return args.filter(arg => arg !== "--json" && arg !== "--full" && !arg.startsWith("--color="));
274
304
  }
305
+ /** `sdlc` operands: the read subcommands that print for a person (#814) drop `--json`, `--full` and `--color=`; every other subcommand keeps its argv exactly. */
306
+ /** A hub tool answers JSON text. Text that does not parse is printed as it came: a renderer never sees it, so it cannot read as an empty report. */
307
+ function showToolText(render: (data: any, columns?: number, now?: number) => Span[][], text: string, present: ReturnType<typeof outputOptions>): void {
308
+ let data: unknown;
309
+ try { data = JSON.parse(text); } catch { return console.log(text); }
310
+ if (present.json) return console.log(text);
311
+ printOutput(render(data, present.columns, Date.now()), present.color);
312
+ }
313
+ const SDLC_READ = ["status", "explain", "queue", "release", "metrics", "costs", "authorize-release"];
314
+ function sdlcOperands(): string[] { return SDLC_READ.includes(args[0] ?? "") ? outputArguments() : args; }
275
315
  function printOutput(lines: Span[][], color: boolean): void {
276
316
  for (const line of lines) console.log(paint(line, color));
277
317
  }
@@ -447,7 +487,8 @@ function startupDiagnostic(error: unknown): string {
447
487
  return message.replace(/[\p{Cc}\p{Cf}]/gu, " ").replace(/\s+/g, " ").trim().slice(0, 512) || "invalid startup configuration";
448
488
  }
449
489
 
450
- function fail(message: string): never {
490
+ function fail(message: string, refusalAudited=false): never {
491
+ if(operationalAuditReady){if(refusalAudited)operationalRefusalRecorded=true;if(!operationalRefusalRecorded)audit("refused");}
451
492
  console.error(`ahub: ${message}`);
452
493
  process.exit(1);
453
494
  }
@@ -459,6 +500,7 @@ async function taskOp(op: string, a: Record<string, unknown>, throwOnError = fal
459
500
  const res = await hub.request({ t: "task", op, args: a }, timeoutMs ?? (op === "github_import" ? 60_000 : 30_000));
460
501
  if (!res.ok) {
461
502
  if (throwOnError) throw new Error(res.error);
503
+ if(res.refusalAudited===true)operationalRefusalRecorded=true;
462
504
  audit("refused");
463
505
  // #711: an agent shell refused something only the person may do is told why it counts as an agent. The refusal and the authority stay as they were.
464
506
  const cause = agentShellCause(identity, String(res.error));
@@ -509,7 +551,9 @@ async function hold(t: "pause" | "resume"): Promise<void> {
509
551
  }
510
552
 
511
553
  function recoveryDisplay() {
512
- return readRecoveryPresentationFlags(args, { isTTY: !!process.stdout.isTTY, columns: process.stdout.columns ?? 80, TERM: process.env.TERM, NO_COLOR: process.env.NO_COLOR });
554
+ // A terminal gives its own width; a pipe gives COLUMNS when set, else 80 (#814: a person reads a pipe too).
555
+ const width = process.stdout.columns ?? outputWidth({ isTTY: false, COLUMNS: process.env.COLUMNS }) ?? 80;
556
+ return readRecoveryPresentationFlags(args, { isTTY: !!process.stdout.isTTY, columns: width, TERM: process.env.TERM, NO_COLOR: process.env.NO_COLOR });
513
557
  }
514
558
  const humanRecovery = (json: boolean) => !json && !!process.stdin.isTTY && !!process.stdout.isTTY;
515
559
  const printRecovery = (lines: readonly string[]) => { for (const line of lines) console.log(line); };
@@ -656,12 +700,15 @@ async function upgrade(kind: "restart" | "upgrade"): Promise<void> {
656
700
  if (interactive) return onTerminal((io) => planScreen(upgradeHost(kind, to ?? VERSION), io), display.options);
657
701
  const plan = await makeUpgradePlan(kind, to ?? VERSION, kind === "restart" ? cwd : undefined);
658
702
  const jsonApply = display.json && yes && !dry;
659
- if (!jsonApply) console.log(JSON.stringify(plan, null, 2));
660
- // #206: name every blocker and reconnect-only session on stderr, not only inside the JSON above.
661
- for (const p of plan.projects) for (const peer of p.reconnectOnly ?? []) console.error(`ahub: ${p.project.id}: ${peer} is reconnect-only (unmanaged session): its plugin reattaches to the new hub; no terminal is closed or relaunched`);
662
- for (const p of plan.projects) for (const peer of p.freshStart ?? []) console.error(`ahub: ${p.project.id}: ${peer} restarts as a new session: its thread has no rollout and the hub recorded no turn on it, so nothing is lost`);
703
+ // #814: a person reading a pipe or a dry run gets the plan screen's rows, with every blocker, reconnect-only session and
704
+ // fresh start in them. `--json` keeps the document and the stderr lines (#206) exactly as they were.
705
+ if (display.json) {
706
+ if (!jsonApply) console.log(JSON.stringify(plan, null, 2));
707
+ for (const p of plan.projects) for (const peer of p.reconnectOnly ?? []) console.error(`ahub: ${p.project.id}: ${peer} is reconnect-only (unmanaged session): its plugin reattaches to the new hub; no terminal is closed or relaunched`);
708
+ for (const p of plan.projects) for (const peer of p.freshStart ?? []) console.error(`ahub: ${p.project.id}: ${peer} restarts as a new session: its thread has no rollout and the hub recorded no turn on it, so nothing is lost`);
709
+ } else printRecovery(renderUpgradePlan(plan, VERSION, display.options));
663
710
  const blockers = [...plan.blockers, ...plan.projects.flatMap((p) => p.blockers.map((b) => `${p.project.id}: ${b}`))];
664
- for (const blocker of blockers) console.error(`ahub: blocker: ${blocker}`);
711
+ if (display.json) for (const blocker of blockers) console.error(`ahub: blocker: ${blocker}`);
665
712
  if (dry) return;
666
713
  if (blockers.length) { if (jsonApply) console.log(JSON.stringify(plan, null, 2)); fail("plan has blockers; no runtime was changed"); }
667
714
  assertLifecycleAvailable();
@@ -724,14 +771,10 @@ const commands: Record<string, () => Promise<void> | void> = {
724
771
  return;
725
772
  }
726
773
  const result = await reattachRecovery(selected.id, selected.peer, makeRecoveryDriver());
727
- if (humanRecovery(display.json)) {
728
- printRecovery(renderOperation(result, undefined, display.options));
729
- const live = await liveProjects(result, makeRecoveryDriver(undefined, delegatedPeer).inspect);
730
- printRecovery(renderBlocked(result, nextChoices(result, undefined, live), display.options));
731
- } else {
732
- console.log(JSON.stringify(publicOperation(result), null, 2));
733
- if (!display.json) console.log(nextActionsText(result));
734
- }
774
+ if (display.json) { console.log(JSON.stringify(publicOperation(result), null, 2)); return; }
775
+ printRecovery(renderOperation(result, undefined, display.options));
776
+ const live = await liveProjects(result, makeRecoveryDriver(undefined, delegatedPeer).inspect);
777
+ printRecovery(renderBlocked(result, nextChoices(result, undefined, live), display.options));
735
778
  return;
736
779
  }
737
780
  // #272: without an id, the operation that holds the machine's lock; bare on a terminal, its screen.
@@ -761,7 +804,8 @@ const commands: Record<string, () => Promise<void> | void> = {
761
804
  if (action === "status") {
762
805
  // #215: abort and resume are offered by what the open sources show, read as the runner's errors read them.
763
806
  const live = runner ? {} : await liveProjects(operation, makeRecoveryDriver(undefined, delegatedPeer).inspect);
764
- console.log(JSON.stringify(publicOperation(operation, runner, live), null, 2));
807
+ if (display.json) console.log(JSON.stringify(publicOperation(operation, runner, live), null, 2));
808
+ else printRecovery(renderRecoveryStatus(operation, runner, nextChoices(operation, runner, live), display.options));
765
809
  }
766
810
  else if (action === "abort") {
767
811
  await abortRecovery(id, makeRecoveryDriver(undefined, delegatedPeer));
@@ -780,11 +824,8 @@ const commands: Record<string, () => Promise<void> | void> = {
780
824
  console.log(`${one["--fresh-session"]}: a fresh session is accepted and the lost one is recorded; resuming`);
781
825
  return spawnRecovery(result, false, humanRecovery(false) ? display.options : undefined);
782
826
  }
783
- if (humanRecovery(display.json)) printRecovery(renderSummary(result, await recoveryEndInspection(result), display.options));
784
- else {
785
- console.log(JSON.stringify(publicOperation(result), null, 2));
786
- if (!display.json) for (const line of stopAndArchiveSummary(result)) console.log(line);
787
- }
827
+ if (display.json) console.log(JSON.stringify(publicOperation(result), null, 2));
828
+ else printRecovery(renderSummary(result, await recoveryEndInspection(result), display.options));
788
829
  }
789
830
  else if (["completed", "cancelled"].includes(operation.phase)) console.log(display.json ? JSON.stringify(publicOperation(operation, runner), null, 2) : `recovery is already ${operation.phase}`);
790
831
  // #215: an operation being abandoned is never resumed, whichever coordinator started it (an older one would).
@@ -973,7 +1014,7 @@ const commands: Record<string, () => Promise<void> | void> = {
973
1014
 
974
1015
  models: async () => {
975
1016
  const action = outputArguments()[0] ?? "status";
976
- const options = action === "status" ? outputOptions() : undefined;
1017
+ const options = ["status", "start", "setup"].includes(action) ? outputOptions() : undefined;
977
1018
  const showStatus = (data: unknown) => options!.json ? console.log(JSON.stringify(data, null, 2)) : printOutput(renderModelsStatus(data, options!.columns, Date.now()), options!.color);
978
1019
  const configured = projectConfig().mlx;
979
1020
  if (configured.enabled === false) {
@@ -985,13 +1026,13 @@ const commands: Record<string, () => Promise<void> | void> = {
985
1026
  const modelPath = configured.modelPath ? resolve(cwd, configured.modelPath) : join(homedir(), ".agenthub", "models", "qwen3-8b-mlx");
986
1027
  const mlxOptions = configured.provider === "ollama" ? configured : { ...configured, runtimeDir, modelPath };
987
1028
  if (action === "status") return showStatus(await inspectMlx(mlxOptions));
988
- if (action === "start") { const handle = await ensureMlx(mlxOptions); return console.log(JSON.stringify(handle.status(), null, 2)); }
1029
+ if (action === "start") { const handle = await ensureMlx(mlxOptions); return showStatus(handle.status()); }
989
1030
  if (action === "stop") { await stopMlx(mlxOptions); return console.log("MLX stopped"); }
990
1031
  if (action !== "setup") fail("usage: ahub models setup|status|start|stop");
991
1032
  if (configured.provider === "ollama") {
992
1033
  await setupOllamaModel(configured);
993
1034
  const handle = await ensureMlx(configured);
994
- return console.log(JSON.stringify(handle.status(), null, 2));
1035
+ return showStatus(handle.status());
995
1036
  }
996
1037
  const python = join(runtimeDir, "bin", "python");
997
1038
  const run = (argv: string[]) => { const result = spawnSync(argv[0]!, argv.slice(1), { cwd, stdio: "inherit" }); if (result.status !== 0) fail(`models setup failed: ${argv.join(" ")}`); };
@@ -1024,7 +1065,7 @@ const commands: Record<string, () => Promise<void> | void> = {
1024
1065
  const { one, rest: flags } = takeFlags(rest, ["--peer"], []);
1025
1066
  if (flags.length) fail("usage: ahub queue list [--peer <id>] [--json] [--full] [--color=auto|always|never]");
1026
1067
  const result = await hub.request({ t: "queue", op: "list", ...(one["--peer"] ? { peer: one["--peer"] } : {}) });
1027
- if (!result.ok) fail(result.error);
1068
+ if (!result.ok) fail(result.error,result.refusalAudited===true);
1028
1069
  if (options.json) return console.log(JSON.stringify(result.deliveries, null, 2));
1029
1070
  printOutput(renderQueue(result.deliveries, options.columns, Date.now()), options.color);
1030
1071
  return;
@@ -1036,7 +1077,7 @@ const commands: Record<string, () => Promise<void> | void> = {
1036
1077
  if (others.length || !one["--peer"] || (one["--state"] !== undefined && !["queued", "needs_review"].includes(one["--state"]))) fail("usage: ahub queue discard --peer <id> --reason <text> [--state queued|needs_review] [--yes]");
1037
1078
  if (yes && !one["--reason"]?.trim()) fail("ahub queue discard needs --reason <text> with --yes: it is recorded on every discarded delivery");
1038
1079
  const result = await hub.request({ t: "queue", op: "discard", peer: one["--peer"], ...(one["--state"] ? { state: one["--state"] } : {}), ...(yes ? { confirmed: true, reason: one["--reason"] } : {}) }, 60_000);
1039
- if (!result.ok) fail(result.error);
1080
+ if (!result.ok) fail(result.error,result.refusalAudited===true);
1040
1081
  if (options.json) return console.log(JSON.stringify(result, null, 2));
1041
1082
  if (!yes) return console.log(`${result.count} ${result.state} deliver${result.count === 1 ? "y" : "ies"} for ${result.peer}; nothing discarded. Run again with --reason <text> --yes to discard them.`);
1042
1083
  console.log(`discarded ${result.discarded} of ${result.count} ${result.state} deliver${result.count === 1 ? "y" : "ies"} for ${result.peer}${result.refused ? `; ${result.refused} refused (${Object.entries(result.refusedBy ?? {}).map(([why, n]) => `${why} ${n}`).join(", ")})` : ""}`);
@@ -1047,7 +1088,7 @@ const commands: Record<string, () => Promise<void> | void> = {
1047
1088
  if (operation === "show") {
1048
1089
  if (flags.length) fail("usage: ahub queue show <id>");
1049
1090
  const shown = await hub.request({ t: "queue", op: "show", id });
1050
- if (!shown.ok) fail(shown.error);
1091
+ if (!shown.ok) fail(shown.error,shown.refusalAudited===true);
1051
1092
  if (options.json) return console.log(JSON.stringify(shown.delivery, null, 2));
1052
1093
  return printOutput(renderQueueShow(shown.delivery, options.columns, Date.now()), options.color);
1053
1094
  }
@@ -1057,12 +1098,13 @@ const commands: Record<string, () => Promise<void> | void> = {
1057
1098
  let revision = parsedRevision;
1058
1099
  if (revision === undefined) {
1059
1100
  const shown = await hub.request({ t: "queue", op: "show", id });
1060
- if (!shown.ok) fail(shown.error);
1101
+ if (!shown.ok) fail(shown.error,shown.refusalAudited===true);
1061
1102
  revision = shown.delivery.revision;
1062
1103
  }
1063
1104
  const result = await hub.request({ t: "queue", op: "resolve", id, revision, action: one["--action"], reason: one["--reason"] });
1064
- if (!result.ok) fail(result.error);
1065
- console.log(JSON.stringify(result.delivery, null, 2));
1105
+ if (!result.ok) fail(result.error,result.refusalAudited===true);
1106
+ if (options.json) console.log(JSON.stringify(result.delivery, null, 2));
1107
+ else printOutput(renderQueueShow(result.delivery, options.columns, Date.now()), options.color);
1066
1108
  } finally { hub.close(); }
1067
1109
  },
1068
1110
 
@@ -1120,7 +1162,8 @@ const commands: Record<string, () => Promise<void> | void> = {
1120
1162
  const result = await hub.request(request); hub.close();
1121
1163
  if (!result.ok) fail(result.error);
1122
1164
  const data = result.budgets ?? result.budget ?? { disabled: result.disabled };
1123
- if (op !== "status" || options.json) return console.log(JSON.stringify(data, null, 2));
1165
+ if (options.json) return console.log(JSON.stringify(data, null, 2));
1166
+ if (op === "configure" || op === "disable") return printOutput(renderBudgetResult(op, op === "configure" ? result.budget : result, options.columns, Date.now()), options.color);
1124
1167
  return printOutput(renderExecutionBudgetStatus(result.budgets, options.columns, Date.now()), options.color);
1125
1168
  }
1126
1169
  let set: Record<string, unknown> | undefined;
@@ -1148,7 +1191,10 @@ const commands: Record<string, () => Promise<void> | void> = {
1148
1191
  },
1149
1192
 
1150
1193
  backlog: async () => {
1151
- const [sub, id, revision, value, ...reason] = args;
1194
+ const split = splitPresentation(args, { edges: true }), operands = split.operands;
1195
+ const [sub, id, revision, value, ...reason] = operands;
1196
+ const present = outputOptions(split.flags); // before any request: an invalid --color must refuse before a mutation is sent
1197
+ const shown = (render: (data: any, columns?: number, now?: number) => Span[][]) => async (text: Promise<string>) => showToolText(render, await text, present);
1152
1198
  if (sub === "list" && outputArguments().length === 1) {
1153
1199
  // #399: a table by default; --json prints the hub's listing unchanged, and a refusal is printed as it came.
1154
1200
  const options = outputOptions(), raw = await taskOp("hub_backlog_list", {});
@@ -1158,43 +1204,44 @@ const commands: Record<string, () => Promise<void> | void> = {
1158
1204
  if (!Array.isArray(cards)) return console.log(raw);
1159
1205
  return printOutput(renderBacklog(cards, options.columns, Date.now(), options.full), options.color);
1160
1206
  }
1161
- if (sub === "groom" && args.length === 1) return console.log(await taskOp("hub_planner_groom", {}));
1162
- if (["return", "return-untouched"].includes(sub ?? "") && /^[1-9]\d*$/.test(id ?? "") && /^\d+$/.test(revision ?? "") && args.length >= 4) {
1163
- return console.log(await taskOp("hub_backlog_return_untouched", { id: Number(id), revision: Number(revision), reason: args.slice(3).join(" ") }));
1207
+ if (sub === "groom" && operands.length === 1) return shown(renderGroom)(taskOp("hub_planner_groom", {}));
1208
+ if (["return", "return-untouched"].includes(sub ?? "") && /^[1-9]\d*$/.test(id ?? "") && /^\d+$/.test(revision ?? "") && operands.length >= 4) {
1209
+ return shown((data, columns, now) => renderBacklogResult("return-untouched", data, columns, now))(taskOp("hub_backlog_return_untouched", { id: Number(id), revision: Number(revision), reason: operands.slice(3).join(" ") }));
1164
1210
  }
1165
1211
  if (sub === "add" && args.length >= 3) return console.log(await taskOp("hub_task_propose", { class: id, title: args.slice(2).join(" "), backlog: true }));
1166
1212
  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>");
1167
- if (["pull", "return"].includes(sub ?? "") && args.length === 2) return console.log(await taskOp(sub === "pull" ? "backlog_pull" : "backlog_return", { id }));
1213
+ if (["pull", "return"].includes(sub ?? "") && operands.length === 2) return console.log(await taskOp(sub === "pull" ? "backlog_pull" : "backlog_return", { id }));
1168
1214
  if (!/^\d+$/.test(revision ?? "")) fail("planning revision is required");
1169
- 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(" ") }));
1215
+ if (sub === "pull-ready" && operands.length >= 4) return shown((data, columns, now) => renderBacklogResult("pull-ready", data, columns, now))(taskOp("hub_backlog_pull", { id: Number(id), revision: Number(revision), reason: operands.slice(3).join(" ") }));
1170
1216
  if (sub === "split" && value && reason.length) {
1171
1217
  let parts: unknown;try { parts=JSON.parse(value); } catch { return fail("split parts must be JSON"); }
1172
- return console.log(await taskOp("hub_backlog_split", { id: Number(id), revision: Number(revision), parts, reason: reason.join(" ") }));
1218
+ return shown((data, columns, now) => renderBacklogResult("split", data, columns, now))(taskOp("hub_backlog_split", { id: Number(id), revision: Number(revision), parts, reason: reason.join(" ") }));
1173
1219
  }
1174
- if (sub === "undo" && args.length === 3) return console.log(await taskOp("hub_backlog_undo", { id: Number(id), revision: Number(revision) }));
1220
+ if (sub === "undo" && operands.length === 3) return shown((data, columns, now) => renderBacklogResult("undo", data, columns, now))(taskOp("hub_backlog_undo", { id: Number(id), revision: Number(revision) }));
1175
1221
  if (!["plan", "pin"].includes(sub ?? "") || !value || !reason.length) fail("planning needs fields and a reason");
1176
1222
  let fields: unknown;
1177
1223
  try { fields = sub === "pin" ? { pinned: value === "true" ? true : value === "false" ? false : undefined } : JSON.parse(value); }
1178
1224
  catch { return fail("planning fields must be JSON"); }
1179
1225
  if (sub === "pin" && value !== "true" && value !== "false") fail("pin must be true or false");
1180
- console.log(await taskOp("hub_backlog_plan", { id: Number(id), revision: Number(revision), fields, reason: reason.join(" ") }));
1226
+ return shown((data, columns, now) => renderBacklogResult(sub as "plan" | "pin", data, columns, now))(taskOp("hub_backlog_plan", { id: Number(id), revision: Number(revision), fields, reason: reason.join(" ") }));
1181
1227
  },
1182
1228
 
1183
1229
  release: async () => {
1184
- const [sub, name, revision, value] = args;
1185
- if (sub === "list" && args.length === 1) return console.log(await taskOp("hub_release_list", {}));
1186
- if (sub === "show" && name && args.length === 2) {
1187
- return console.log(await taskOp("release_show", { name }));
1188
- }
1189
- if (sub === "new" && name && revision && args.length === 4 && /^\d+$/.test(value ?? "")) return console.log(await taskOp("release_new", { name, goal: revision, order: Number(value) }));
1230
+ const split = splitPresentation(args, { edges: true }), operands = split.operands;
1231
+ const [sub, name, revision, value] = operands;
1232
+ const present = outputOptions(split.flags); // before any request: an invalid --color must refuse before a mutation is sent
1233
+ const shown = (render: (data: any, columns?: number, now?: number) => Span[][]) => async (text: Promise<string>) => showToolText(render, await text, present);
1234
+ if (sub === "list" && operands.length === 1) return shown(renderReleaseList)(taskOp("hub_release_list", {}));
1235
+ if (sub === "show" && name && operands.length === 2) return shown(renderReleaseShow)(taskOp("release_show", { name }));
1236
+ if (sub === "new" && name && revision && operands.length === 4 && /^\d+$/.test(value ?? "")) return shown(renderReleaseNew)(taskOp("release_new", { name, goal: revision, order: Number(value) }));
1190
1237
  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>");
1191
1238
  const states: Record<string, string> = { activate: "active", freeze: "frozen", ship: "shipped", drop: "dropped" };
1192
1239
  let fields: unknown;
1193
- if (sub === "plan" && value && args.length === 4) {
1240
+ if (sub === "plan" && value && operands.length === 4) {
1194
1241
  try { fields = JSON.parse(value); } catch { return fail("release dates must be JSON"); }
1195
- } else if (sub && Object.hasOwn(states, sub) && args.length === 3) fields = { state: states[sub] };
1242
+ } else if (sub && Object.hasOwn(states, sub) && operands.length === 3) fields = { state: states[sub] };
1196
1243
  else return fail("invalid release command");
1197
- console.log(await taskOp("release_change", { name, revision: Number(revision), fields }));
1244
+ return shown(renderReleaseChange)(taskOp("release_change", { name, revision: Number(revision), fields }));
1198
1245
  },
1199
1246
 
1200
1247
  deliverables: async () => {
@@ -1256,6 +1303,9 @@ const commands: Record<string, () => Promise<void> | void> = {
1256
1303
  },
1257
1304
 
1258
1305
  sdlc: async () => {
1306
+ const args = sdlcOperands(), present = SDLC_READ.includes(args[0] ?? "") ? outputOptions() : undefined;
1307
+ /** The human form (#814). Each site keeps its own `if (present.json) console.log(JSON.stringify(...))`, so the JSON branch stays visible to the guard test. */
1308
+ const human = (render: (data: any, columns?: number, now?: number) => Span[][], data: unknown) => printOutput(render(data, present!.columns, Date.now()), present!.color);
1259
1309
  const sub = args[0], id = args[1] === undefined ? undefined : Number(args[1]);
1260
1310
  if (sub === "authorize-release") {
1261
1311
  if (args.length !== 2 || !validReleaseVersion(args[1])) fail("usage: ahub sdlc authorize-release <version>");
@@ -1263,7 +1313,7 @@ const commands: Record<string, () => Promise<void> | void> = {
1263
1313
  const commit = result.stdout?.trim();
1264
1314
  if (result.status !== 0 || !commit || !/^[a-f0-9]{40}$/.test(commit)) fail("candidate commit unavailable");
1265
1315
  const hub = await connect();
1266
- try { const reply = await hub.request({ t: "sdlc", op: sub, version: args[1], commit }, 30_000); if (!reply.ok) fail(reply.error); console.log(JSON.stringify(reply.authorization, null, 2)); }
1316
+ try { const reply = await hub.request({ t: "sdlc", op: sub, version: args[1], commit }, 30_000); if (!reply.ok) fail(reply.error); if (present!.json) console.log(JSON.stringify(reply.authorization, null, 2)); else human(renderAuthorization, reply.authorization); }
1267
1317
  finally { hub.close(); }
1268
1318
  return;
1269
1319
  }
@@ -1305,7 +1355,7 @@ const commands: Record<string, () => Promise<void> | void> = {
1305
1355
  const repo = args[1], liveIssues = args.slice(2).map(Number);
1306
1356
  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...]");
1307
1357
  const hub = await connect();
1308
- 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)); }
1358
+ try { const reply = await hub.request({ t: "sdlc", op: sub, repo, liveIssues }, 30_000); if (!reply.ok) fail(reply.error); if (present!.json) console.log(JSON.stringify(reply, null, 2)); else human(sub === "queue" ? renderSdlcQueue : renderSdlcRelease, reply); }
1309
1359
  finally { hub.close(); }
1310
1360
  return;
1311
1361
  }
@@ -1330,12 +1380,14 @@ const commands: Record<string, () => Promise<void> | void> = {
1330
1380
  }
1331
1381
  if (sub === "costs") {
1332
1382
  if (args.length > 2) fail("usage: ahub sdlc costs [owner/repository]");
1333
- return console.log(await taskOp("hub_sdlc_costs", { ...(args[1] ? { repo: args[1] } : {}) }));
1383
+ const text = await taskOp("hub_sdlc_costs", { ...(args[1] ? { repo: args[1] } : {}) });
1384
+ return showToolText(renderSdlcCosts, text, present!);
1334
1385
  }
1335
1386
  if (sub === "metrics") {
1336
1387
  const flags = takeFlags(args.slice(1), ["--since"], []);
1337
1388
  if (flags.rest.length) fail("usage: ahub sdlc metrics [--since <YYYY-MM-DD|ISO time>]");
1338
- return console.log(await taskOp("hub_sdlc_metrics", { ...(flags.one["--since"] !== undefined ? { since: flags.one["--since"] } : {}) }));
1389
+ const text = await taskOp("hub_sdlc_metrics", { ...(flags.one["--since"] !== undefined ? { since: flags.one["--since"] } : {}) });
1390
+ return showToolText(renderSdlcMetrics, text, present!);
1339
1391
  }
1340
1392
  if (sub === "plan-review") {
1341
1393
  const revision = Number(args[2]);
@@ -1356,6 +1408,20 @@ const commands: Record<string, () => Promise<void> | void> = {
1356
1408
  } finally { hub.close(); }
1357
1409
  return;
1358
1410
  }
1411
+ if (sub === "merge") {
1412
+ // #786 T1: the guarded merge of one approved task; a person or the configured conductor asks, the daemon re-reads every gate.
1413
+ const flags = takeFlags(args.slice(2), [], []);
1414
+ const dryRun = flags.rest.includes("--dry-run");
1415
+ if (flags.rest.some(arg => arg !== "--dry-run") || !Number.isSafeInteger(id) || id! <= 0) fail("usage: ahub sdlc merge <task> [--dry-run]");
1416
+ const hub = await connect();
1417
+ try {
1418
+ const reply = await hub.request({ t: "sdlc", op: "merge", id, ...(dryRun ? { dryRun: true } : {}) }, 60_000);
1419
+ if (!reply.ok) fail(reply.error);
1420
+ console.log(JSON.stringify({ task: reply.task, mode: reply.mode, ran: reply.ran, decision: reply.decision, ...(reply.argv ? { argv: reply.argv } : {}), ...(reply.refused ? { refused: reply.refused } : {}), ...(reply.result ? { result: reply.result } : {}) }, null, 2));
1421
+ if (reply.decision === "refused" || reply.ran && reply.result?.state !== "merged") process.exitCode = 1;
1422
+ } finally { hub.close(); }
1423
+ return;
1424
+ }
1359
1425
  if (sub === "accept") {
1360
1426
  // #481 S1: the person accepts the task's spec issue at its current body; agent shells are refused before this runs.
1361
1427
  const flags = takeFlags(args.slice(2), ["--issue", "--repo"], []);
@@ -1379,12 +1445,19 @@ const commands: Record<string, () => Promise<void> | void> = {
1379
1445
  try {
1380
1446
  const reply = await hub.request({ t: "sdlc", op: sub, ...(id === undefined ? {} : { id }) }, 30_000);
1381
1447
  if (!reply.ok) fail(reply.error);
1382
- console.log(JSON.stringify(reply, null, 2));
1448
+ // `sdlc export` is a data format by purpose (#814): it stays JSON and never reaches a renderer.
1449
+ if (sub === "export") console.log(JSON.stringify(reply, null, 2));
1450
+ else if (present!.json) console.log(JSON.stringify(reply, null, 2));
1451
+ else human(sub === "explain" ? renderSdlcExplain : renderSdlcStatus, reply);
1383
1452
  } finally { hub.close(); }
1384
1453
  },
1385
1454
 
1386
1455
  task: async () => {
1387
- const [sub, ...rest] = args;
1456
+ const presentation = splitPresentation(args, { valueFlags: ["--reason", "--superseded-by", "--revision"], edges: true });
1457
+ const shownTask = (render: (data: any, columns?: number, now?: number) => Span[][]) => async (text: Promise<string>) => showToolText(render, await text, outputOptions(presentation.flags));
1458
+ // task show and task cancel read their presentation options before the request goes out (a refusal after a cancel would invite a retry of work already done)
1459
+ if (["show", "cancel"].includes(args[0] ?? "")) outputOptions(presentation.flags);
1460
+ const [sub, ...rest] = ["show", "cancel"].includes(args[0] ?? "") ? presentation.operands : args;
1388
1461
  if (sub === "role") {
1389
1462
  if (rest.length !== 2 || !/^[1-9]\d*$/.test(rest[0]!)) fail("usage: ahub task role <id> <role|none>");
1390
1463
  const hub = await connect();
@@ -1396,9 +1469,13 @@ const commands: Record<string, () => Promise<void> | void> = {
1396
1469
  const { one, rest: positional } = takeFlags(rest, ["--reason", "--superseded-by", "--revision"], []);
1397
1470
  const revision = one["--revision"] === undefined ? undefined : Number(one["--revision"]);
1398
1471
  if (positional.length !== 1 || !/^[1-9]\d*$/.test(positional[0]!) || !one["--reason"]?.trim() || (revision !== undefined && (!/^\d+$/.test(one["--revision"]!) || !Number.isSafeInteger(revision)))) fail("usage: ahub task cancel <id> --reason <text> [--superseded-by <ref>] [--revision N]");
1399
- return console.log(await taskOp("hub_task_cancel", { id: Number(positional[0]), reason: one["--reason"], ...(one["--superseded-by"] === undefined ? {} : { superseded_by: one["--superseded-by"] }), ...(revision === undefined ? {} : { revision }) }));
1472
+ return shownTask(renderTaskCancel)(taskOp("hub_task_cancel", { id: Number(positional[0]), reason: one["--reason"], ...(one["--superseded-by"] === undefined ? {} : { superseded_by: one["--superseded-by"] }), ...(revision === undefined ? {} : { revision }) }));
1473
+ }
1474
+ if (sub === "show") {
1475
+ // a stray operand (for example the value of a `--color always` that is not the `--color=` form) is a usage error, never silently ignored
1476
+ if (rest.length !== 1) fail("usage: ahub task show <id> [--json] [--full] [--color=auto|always|never]");
1477
+ return shownTask(renderTaskShow)(taskOp("task_show", { id: rest[0] }));
1400
1478
  }
1401
- if (sub === "show") return console.log(await taskOp("task_show", { id: rest[0] }));
1402
1479
  if (sub === "escalate") return console.log(await taskOp("task_escalate", { id: rest[0] }));
1403
1480
  if (sub === "assign") {
1404
1481
  const { one, rest: positional } = takeFlags(rest, ["--role", "--revision"], []);
@@ -1489,7 +1566,7 @@ const commands: Record<string, () => Promise<void> | void> = {
1489
1566
  const reply = await hub.request({ t: "peer_stop", peer }, 35_000);
1490
1567
  if (reply.ok !== true) {
1491
1568
  const error = String(reply.error ?? "peer stop request refused");
1492
- fail(/unknown (?:control )?(?:message|request|command)(?:\b|:)|this hub does not know "peer_stop"/i.test(error) ? `${error}; upgrade the running hub to use ahub stop` : error);
1569
+ fail(/unknown (?:control )?(?:message|request|command)(?:\b|:)|this hub does not know "peer_stop"/i.test(error) ? `${error}; upgrade the running hub to use ahub stop` : error,reply.refusalAudited===true);
1493
1570
  }
1494
1571
  console.log(`${peer}: ${reply.state}`);
1495
1572
  } finally { hub.close(); }
@@ -1525,7 +1602,7 @@ const commands: Record<string, () => Promise<void> | void> = {
1525
1602
  const hub = await connect();
1526
1603
  try {
1527
1604
  requireNamedPeerFeature(hub, peer);
1528
- 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; };
1605
+ 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"),reply.refusalAudited===true); return reply; };
1529
1606
  if (sub === "list") {
1530
1607
  const view = await request({ t: "settings_get" });
1531
1608
  if (!Array.isArray(view.roster)) fail("upgrade the running hub to use ahub roster");
@@ -1591,7 +1668,7 @@ const commands: Record<string, () => Promise<void> | void> = {
1591
1668
  const reply = await hub.request(message, 35_000);
1592
1669
  if (reply.ok !== true) {
1593
1670
  const error = String(reply.error ?? "settings request refused");
1594
- fail(/unknown (?:control )?(?:message|request|command)(?:\b|:)|this hub does not know "settings_/i.test(error) ? `${error}; upgrade the running hub to use ahub settings` : error);
1671
+ fail(/unknown (?:control )?(?:message|request|command)(?:\b|:)|this hub does not know "settings_/i.test(error) ? `${error}; upgrade the running hub to use ahub settings` : error,reply.refusalAudited===true);
1595
1672
  }
1596
1673
  return reply;
1597
1674
  };
@@ -1719,8 +1796,29 @@ const commands: Record<string, () => Promise<void> | void> = {
1719
1796
  } finally { hub.close(); }
1720
1797
  },
1721
1798
 
1722
- logs: () => exec("tail", [args.includes("-f") ? "-f" : "-n100", join(stateDir, "hub.log")]),
1723
- export: () => {
1799
+ logs: async () => {
1800
+ if (identity.role !== "tools") return exec("tail", [args.includes("-f") ? "-f" : "-n100", join(stateDir, "hub.log")]);
1801
+ const hub=await connect();const pending:string[]=[];let ready=false;
1802
+ hub.onPush=message=>{if(message.t==="operational_log" && typeof message.line==="string"){if(ready)console.log(message.line);else if(pending.length<100)pending.push(message.line);}};
1803
+ try {
1804
+ const result=await hub.request({t:"conductor_operational",op:"logs",follow:args.includes("-f")});
1805
+ if(result.ok!==true)fail(String(result.error??"operational logs unavailable"),result.refusalAudited===true);
1806
+ for(const line of result.lines??[])console.log(line);
1807
+ ready=true;for(const line of pending)console.log(line);
1808
+ if(result.truncated)console.error("ahub: operational log history is truncated");
1809
+ if(args.includes("-f"))await new Promise<void>(resolve=>{
1810
+ const finish=()=>{process.off("SIGINT",finish);resolve();};
1811
+ hub.onClose=finish;process.once("SIGINT",finish);
1812
+ });
1813
+ } finally {hub.close();}
1814
+ },
1815
+ export: async () => {
1816
+ if(identity.role==="tools") {
1817
+ const hub=await connect();
1818
+ try {const result=await hub.request({t:"conductor_operational",op:"export",since:since()});if(result.ok!==true)fail(String(result.error??"operational export unavailable"),result.refusalAudited===true);for(const row of result.events??[])console.log(JSON.stringify(row));if(result.truncated)console.error("ahub: operational event history is truncated");}
1819
+ finally {hub.close();}
1820
+ return;
1821
+ }
1724
1822
  for (const e of readEvents(join(stateDir, "events.jsonl"), since())) console.log(JSON.stringify(e));
1725
1823
  },
1726
1824
  bench: async () => {
@@ -2130,7 +2228,12 @@ const commands: Record<string, () => Promise<void> | void> = {
2130
2228
  console.log(options.json ? JSON.stringify(report, null, 2) : formatMemoryStaleAnchors(report));
2131
2229
  return;
2132
2230
  }
2133
- if (args.includes("--orphans")) return orphanDoctor(args.includes("--kill"), options);
2231
+ if (args.includes("--orphans")) {
2232
+ if(identity.role==="tools" && args.includes("--kill")) {
2233
+ const hub=await connect();try {const result=await hub.request({t:"conductor_operational",op:"orphans"},10_000);if(result.ok!==true)fail(String(result.error??"orphan inspection unavailable"),result.refusalAudited===true);console.log(JSON.stringify(result,null,2));}finally{hub.close();}return;
2234
+ }
2235
+ return orphanDoctor(args.includes("--kill"), options);
2236
+ }
2134
2237
  const version = (bin: string) => {
2135
2238
  const res = spawnSync(bin, ["--version"], { encoding: "utf8" });
2136
2239
  return res.status === 0 ? res.stdout.trim().split("\n")[0]! : undefined;
@@ -2287,8 +2390,26 @@ function inspectDoctorRouting(cwd: string): { routing?: Routing; errors: string[
2287
2390
  }
2288
2391
 
2289
2392
  async function runConductorCommand(): Promise<void> {
2393
+ const operation=conductorOperation(cmd,args);
2394
+ if(operation) {
2395
+ const denial=()=>peerCommandRefusal(identity.role==="tools"?identity.peer:"unknown",commandLabel);
2396
+ const descriptor=readControl(stateDir);
2397
+ if(!descriptor?.instanceId || !descriptor.projectId || !descriptor.pid || !descriptor.pidSignature || processLiveness(descriptor.pid,descriptor.pidSignature)!=="live")fail(denial());
2398
+ let hub:ControlClient;
2399
+ try {hub=await connect(750);} catch {fail(denial());}
2400
+ let release:(()=>void)|undefined;
2401
+ try {
2402
+ let result:any;
2403
+ try {result=await hub.request({t:"conductor_operational",op:"authorize",command:operation},750);} catch {fail(denial());}
2404
+ if(result.ok!==true || !hub.instanceId)fail(`${denial()}${result.error?`; ${String(result.error)}`:""}`,result.refusalAudited===true);
2405
+ release=ControlClient.pinInstance(stateDir,hub.instanceId);
2406
+ await commands[cmd]!();
2407
+ } finally {release?.();hub.close();}
2408
+ return;
2409
+ }
2290
2410
  let op: string;
2291
2411
  let input: Record<string, unknown>;
2412
+ if (cmd === "sdlc") return commands.sdlc!(); // #786: `sdlc merge` is the one conductor-class sdlc subcommand; the daemon checks the role.
2292
2413
  if (cmd === "task") {
2293
2414
  op = args[0] === "assign" ? "hub_task_assign" : "hub_task_escalate";
2294
2415
  const { one, rest: positional } = takeFlags(args.slice(1), args[0] === "assign" ? ["--role"] : [], []);
package/src/cli/output.ts CHANGED
@@ -19,7 +19,8 @@ export interface StatusOutput {
19
19
  isolatedChecks?: { pending?: number; orphans?: string[]; unknownReceipts?: readonly string[]; maintenanceUnavailable?: boolean };
20
20
  memoryRead?: { truncated: boolean; scanned: number; limit: number };
21
21
  memoryProfile?: { profile?: string; generation?: string; state?: string; agentMemVersion?: string; skippedRows?: number; error?: string };
22
- taskWip?: Record<string, { wip: number; wipLimit: number }>;
22
+ /** #784 AC3: `active`, `reviewWait` and `reserved` add up to `wip`; absent from an older hub. */
23
+ taskWip?: Record<string, { wip: number; wipLimit: number; active?: number; reviewWait?: number; reserved?: number }>;
23
24
  pid?: number; controlPort?: number; cwd?: string; version?: string; peers: Record<string, any>; roleInfo?: Record<string, any>; actionPolicyInfo?: Record<string, { note: string; enforceable: boolean }>; budget?: Record<string, any>; planner?: { peers?: string[]; source?: string };
24
25
  tasks?: Record<string, number>; models?: { backends?: any[] }; permissionDefaults?: any[]; deliveryError?: string; crash?: string[];
25
26
  }
@@ -185,6 +186,10 @@ function rows(head: string[], data: OutputCell[][], columns?: number, details?:
185
186
  }
186
187
  return renderAtWidths(widths);
187
188
  }
189
+ /** #784 AC3: ` (active 1, review-wait 2, reserved 0)`; nothing from an older hub that sends no breakdown. */
190
+ function wipSplit(value: { active?: number; reviewWait?: number; reserved?: number }): string {
191
+ return [value.active, value.reviewWait, value.reserved].every(n => Number.isSafeInteger(n)) ? ` (active ${value.active}, review-wait ${value.reviewWait}, reserved ${value.reserved})` : "";
192
+ }
188
193
  function detail(label: string, text: unknown, columns?: number, tone?: Tone): Span[][] {
189
194
  return lines(`${label} ${flat(text)}`, columns, tone, " ");
190
195
  }
@@ -265,7 +270,7 @@ export function renderStatus(data: StatusOutput, columns?: number, now = Date.no
265
270
  if (peerRows.length) out.push([], ...rows(head, peerRows, columns, peerDetails));
266
271
  else out.push(...lines("no peers attached yet (ahub claude | ahub codex | ahub kimi)", columns));
267
272
  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"));
268
- if (data.taskWip) for (const [peer, value] of Object.entries(data.taskWip)) out.push(...detail(`${peer} planner WIP`, `${value.wip}/${value.wipLimit}`, columns));
273
+ if (data.taskWip) for (const [peer, value] of Object.entries(data.taskWip)) out.push(...detail(`${peer} planner WIP`, `${value.wip}/${value.wipLimit}${wipSplit(value)}`, columns));
269
274
  if (data.models?.backends?.length) {
270
275
  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));
271
276
  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")); }