@staix/agent-hub 0.17.0 → 0.19.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 (109) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +4 -4
  3. package/docs/agent-notes/README.md +11 -0
  4. package/docs/agent-notes/adapters.md +10 -6
  5. package/docs/agent-notes/benchmarks.md +1 -0
  6. package/docs/agent-notes/daemon.md +6 -2
  7. package/docs/agent-notes/local-worker.md +3 -1
  8. package/docs/agent-notes/tasks.md +11 -2
  9. package/docs/agent-notes/tests.md +7 -3
  10. package/docs/dogfood.md +22 -2
  11. package/docs/memory.md +1 -1
  12. package/docs/operations.md +162 -35
  13. package/docs/profiles.md +36 -0
  14. package/docs/quickstart.md +15 -5
  15. package/docs/sdlc.md +29 -3
  16. package/docs/security.md +44 -13
  17. package/docs/smoke.md +3 -3
  18. package/docs/specs/2026-09-19-agent-hub-design.md +31 -10
  19. package/docs/verification/2026-10-25-0.18.0.md +76 -0
  20. package/docs/verification/2026-10-25-codex-review-live-verdict.md +35 -0
  21. package/docs/verification/2026-10-25-remote-docker-transport.md +56 -0
  22. package/docs/verification/2026-10-26-agy-1.4.7.md +69 -0
  23. package/docs/verification/2026-10-26-claude-implementer-2.1.310.md +69 -0
  24. package/docs/verification/2026-10-26-headless-claude-shell-context.md +62 -0
  25. package/docs/verification/2026-10-26-jcode-0.99.1.md +49 -0
  26. package/docs/verification/2026-10-26-kimi-232-reviewer-compat.md +53 -0
  27. package/docs/verification/2026-10-26-pi-140-compat.md +41 -0
  28. package/docs/verification/2026-10-26-qwen-0261-acp.md +60 -0
  29. package/docs/verification/2026-10-27-0.19.0.md +86 -0
  30. package/docs/verification/claude-permission-transport-native.py +187 -0
  31. package/docs/verification/claude-shell-wrapper-host.ts +20 -0
  32. package/docs/verification/claude-shell-wrapper-native.py +145 -0
  33. package/docs/verification/pi-140-model-free.ts +57 -0
  34. package/docs/verification/pi-140-native.mjs +21 -0
  35. package/docs/verification/profiles/YYYY-MM-DD-general.md +46 -0
  36. package/docs/verification/profiles/YYYY-MM-DD-research.md +61 -0
  37. package/docs/verification/profiles/YYYY-MM-DD-writing.md +46 -0
  38. package/docs/verified.json +24 -18
  39. package/package.json +2 -2
  40. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  41. package/plugins/agent-hub/server.js +364 -98
  42. package/src/adapters/agy/entry.ts +1 -1
  43. package/src/adapters/claude-channel.ts +7 -3
  44. package/src/adapters/claude-implementer.ts +5 -3
  45. package/src/adapters/compat.ts +32 -18
  46. package/src/adapters/conformance/fakes/pi-rpc.ts +1 -0
  47. package/src/adapters/definitions/builtin-metadata.ts +3 -3
  48. package/src/adapters/definitions/native-factories.ts +7 -2
  49. package/src/adapters/definitions/reviewer-variants.ts +2 -2
  50. package/src/adapters/local-worker.ts +5 -3
  51. package/src/adapters/pi.ts +39 -3
  52. package/src/adapters/qwen-dual.ts +25 -0
  53. package/src/adapters/registry-types.ts +6 -2
  54. package/src/adapters/registry.ts +1 -0
  55. package/src/cli/agents.ts +69 -7
  56. package/src/cli/claude-shell-wrapper.ts +54 -0
  57. package/src/cli/console-state.ts +9 -2
  58. package/src/cli/help.ts +29 -28
  59. package/src/cli/identity.ts +87 -6
  60. package/src/cli/main.ts +198 -71
  61. package/src/cli/output.ts +11 -2
  62. package/src/cli/sdlc-output.ts +242 -0
  63. package/src/cli/task-output.ts +212 -0
  64. package/src/cli/upgrade-output.ts +57 -0
  65. package/src/hub/agent-drift.ts +61 -0
  66. package/src/hub/board.ts +31 -2
  67. package/src/hub/check-runner.ts +385 -25
  68. package/src/hub/claude-shell-wrapper.ts +349 -0
  69. package/src/hub/conductor.ts +5 -0
  70. package/src/hub/config-apply.ts +16 -7
  71. package/src/hub/config-edit-host.ts +1 -1
  72. package/src/hub/config-edit.ts +2 -2
  73. package/src/hub/config-tier.ts +3 -3
  74. package/src/hub/daemon.ts +530 -87
  75. package/src/hub/dogfood-profile.ts +248 -0
  76. package/src/hub/dogfood-read.ts +77 -14
  77. package/src/hub/dogfood.ts +5 -1
  78. package/src/hub/events.ts +1 -1
  79. package/src/hub/host-executable.ts +21 -1
  80. package/src/hub/hub-tools.ts +2 -2
  81. package/src/hub/instances.ts +18 -2
  82. package/src/hub/lifecycle.ts +1 -1
  83. package/src/hub/operational-authority.ts +67 -0
  84. package/src/hub/operational-view.ts +137 -0
  85. package/src/hub/orphan-process.ts +69 -0
  86. package/src/hub/permission-mode.ts +1 -1
  87. package/src/hub/read-only-shell-proof.ts +81 -0
  88. package/src/hub/read-only-shell.ts +162 -0
  89. package/src/hub/review-scope.ts +13 -0
  90. package/src/hub/reviewer-pool-config.ts +3 -2
  91. package/src/hub/reviewer-pool.ts +3 -0
  92. package/src/hub/routing.ts +23 -6
  93. package/src/hub/sdlc-merge.ts +299 -0
  94. package/src/hub/sdlc-metrics.ts +6 -4
  95. package/src/hub/sdlc-mirror.ts +129 -0
  96. package/src/hub/sdlc.ts +58 -12
  97. package/src/hub/settings.ts +11 -5
  98. package/src/hub/snapshots.ts +60 -0
  99. package/src/hub/task-repository.ts +31 -5
  100. package/src/hub/task-shell-command.ts +85 -0
  101. package/src/hub/task-shell-default.ts +78 -0
  102. package/src/hub/task-shell-proof.ts +171 -0
  103. package/src/hub/task-shell-sandbox.ts +61 -0
  104. package/src/hub/tasks.ts +378 -27
  105. package/src/local/deny.ts +91 -11
  106. package/src/local/sandbox.ts +67 -28
  107. package/src/local/shell-environment.ts +10 -0
  108. package/src/local/tools.ts +17 -4
  109. package/src/ui/index.html +3 -1
package/src/cli/main.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env bun
2
+ import { conductorOperation, OPERATIONAL_PERSON_GUIDANCE } 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,11 +487,17 @@ 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, authorityRefused?:boolean): never {
491
+ if(operationalAuditReady && authorityRefused!==false){if(refusalAudited)operationalRefusalRecorded=true;if(!operationalRefusalRecorded)audit("refused");}
451
492
  console.error(`ahub: ${message}`);
452
493
  process.exit(1);
453
494
  }
454
495
 
496
+ function operationalIncomplete(result: {complete?:boolean;incompleteReason?:unknown;truncated?:boolean}, history: "log"|"event"): void {
497
+ if(result.complete===false)console.error(`ahub: operational output is incomplete: ${typeof result.incompleteReason==="string"?terminalText(result.incompleteReason).replace(/[\r\n]+/g," ").slice(0,200):"public privacy or history coverage unavailable"}`);
498
+ else if(result.truncated)console.error(`ahub: operational ${history} history is truncated`);
499
+ }
500
+
455
501
  async function taskOp(op: string, a: Record<string, unknown>, throwOnError = false, connection?: ControlClient, timeoutMs?: number): Promise<string> {
456
502
  const hub = connection ?? await connect();
457
503
  try {
@@ -459,11 +505,12 @@ async function taskOp(op: string, a: Record<string, unknown>, throwOnError = fal
459
505
  const res = await hub.request({ t: "task", op, args: a }, timeoutMs ?? (op === "github_import" ? 60_000 : 30_000));
460
506
  if (!res.ok) {
461
507
  if (throwOnError) throw new Error(res.error);
462
- audit("refused");
508
+ if(res.refusalAudited===true)operationalRefusalRecorded=true;
509
+ if(!operationalAuditReady || res.authorityRefused!==false)audit("refused");
463
510
  // #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
511
  const cause = agentShellCause(identity, String(res.error));
465
512
  if (cause) console.error(`ahub: ${res.error}\n${cause}`), process.exit(1);
466
- fail(res.error);
513
+ fail(res.error,res.refusalAudited===true,res.authorityRefused);
467
514
  }
468
515
  return res.text;
469
516
  } finally { if (!connection) hub.close(); }
@@ -509,7 +556,9 @@ async function hold(t: "pause" | "resume"): Promise<void> {
509
556
  }
510
557
 
511
558
  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 });
559
+ // A terminal gives its own width; a pipe gives COLUMNS when set, else 80 (#814: a person reads a pipe too).
560
+ const width = process.stdout.columns ?? outputWidth({ isTTY: false, COLUMNS: process.env.COLUMNS }) ?? 80;
561
+ return readRecoveryPresentationFlags(args, { isTTY: !!process.stdout.isTTY, columns: width, TERM: process.env.TERM, NO_COLOR: process.env.NO_COLOR });
513
562
  }
514
563
  const humanRecovery = (json: boolean) => !json && !!process.stdin.isTTY && !!process.stdout.isTTY;
515
564
  const printRecovery = (lines: readonly string[]) => { for (const line of lines) console.log(line); };
@@ -656,12 +705,15 @@ async function upgrade(kind: "restart" | "upgrade"): Promise<void> {
656
705
  if (interactive) return onTerminal((io) => planScreen(upgradeHost(kind, to ?? VERSION), io), display.options);
657
706
  const plan = await makeUpgradePlan(kind, to ?? VERSION, kind === "restart" ? cwd : undefined);
658
707
  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`);
708
+ // #814: a person reading a pipe or a dry run gets the plan screen's rows, with every blocker, reconnect-only session and
709
+ // fresh start in them. `--json` keeps the document and the stderr lines (#206) exactly as they were.
710
+ if (display.json) {
711
+ if (!jsonApply) console.log(JSON.stringify(plan, null, 2));
712
+ 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`);
713
+ 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`);
714
+ } else printRecovery(renderUpgradePlan(plan, VERSION, display.options));
663
715
  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}`);
716
+ if (display.json) for (const blocker of blockers) console.error(`ahub: blocker: ${blocker}`);
665
717
  if (dry) return;
666
718
  if (blockers.length) { if (jsonApply) console.log(JSON.stringify(plan, null, 2)); fail("plan has blockers; no runtime was changed"); }
667
719
  assertLifecycleAvailable();
@@ -724,14 +776,10 @@ const commands: Record<string, () => Promise<void> | void> = {
724
776
  return;
725
777
  }
726
778
  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
- }
779
+ if (display.json) { console.log(JSON.stringify(publicOperation(result), null, 2)); return; }
780
+ printRecovery(renderOperation(result, undefined, display.options));
781
+ const live = await liveProjects(result, makeRecoveryDriver(undefined, delegatedPeer).inspect);
782
+ printRecovery(renderBlocked(result, nextChoices(result, undefined, live), display.options));
735
783
  return;
736
784
  }
737
785
  // #272: without an id, the operation that holds the machine's lock; bare on a terminal, its screen.
@@ -761,7 +809,8 @@ const commands: Record<string, () => Promise<void> | void> = {
761
809
  if (action === "status") {
762
810
  // #215: abort and resume are offered by what the open sources show, read as the runner's errors read them.
763
811
  const live = runner ? {} : await liveProjects(operation, makeRecoveryDriver(undefined, delegatedPeer).inspect);
764
- console.log(JSON.stringify(publicOperation(operation, runner, live), null, 2));
812
+ if (display.json) console.log(JSON.stringify(publicOperation(operation, runner, live), null, 2));
813
+ else printRecovery(renderRecoveryStatus(operation, runner, nextChoices(operation, runner, live), display.options));
765
814
  }
766
815
  else if (action === "abort") {
767
816
  await abortRecovery(id, makeRecoveryDriver(undefined, delegatedPeer));
@@ -780,11 +829,8 @@ const commands: Record<string, () => Promise<void> | void> = {
780
829
  console.log(`${one["--fresh-session"]}: a fresh session is accepted and the lost one is recorded; resuming`);
781
830
  return spawnRecovery(result, false, humanRecovery(false) ? display.options : undefined);
782
831
  }
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
- }
832
+ if (display.json) console.log(JSON.stringify(publicOperation(result), null, 2));
833
+ else printRecovery(renderSummary(result, await recoveryEndInspection(result), display.options));
788
834
  }
789
835
  else if (["completed", "cancelled"].includes(operation.phase)) console.log(display.json ? JSON.stringify(publicOperation(operation, runner), null, 2) : `recovery is already ${operation.phase}`);
790
836
  // #215: an operation being abandoned is never resumed, whichever coordinator started it (an older one would).
@@ -973,7 +1019,7 @@ const commands: Record<string, () => Promise<void> | void> = {
973
1019
 
974
1020
  models: async () => {
975
1021
  const action = outputArguments()[0] ?? "status";
976
- const options = action === "status" ? outputOptions() : undefined;
1022
+ const options = ["status", "start", "setup"].includes(action) ? outputOptions() : undefined;
977
1023
  const showStatus = (data: unknown) => options!.json ? console.log(JSON.stringify(data, null, 2)) : printOutput(renderModelsStatus(data, options!.columns, Date.now()), options!.color);
978
1024
  const configured = projectConfig().mlx;
979
1025
  if (configured.enabled === false) {
@@ -985,13 +1031,13 @@ const commands: Record<string, () => Promise<void> | void> = {
985
1031
  const modelPath = configured.modelPath ? resolve(cwd, configured.modelPath) : join(homedir(), ".agenthub", "models", "qwen3-8b-mlx");
986
1032
  const mlxOptions = configured.provider === "ollama" ? configured : { ...configured, runtimeDir, modelPath };
987
1033
  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)); }
1034
+ if (action === "start") { const handle = await ensureMlx(mlxOptions); return showStatus(handle.status()); }
989
1035
  if (action === "stop") { await stopMlx(mlxOptions); return console.log("MLX stopped"); }
990
1036
  if (action !== "setup") fail("usage: ahub models setup|status|start|stop");
991
1037
  if (configured.provider === "ollama") {
992
1038
  await setupOllamaModel(configured);
993
1039
  const handle = await ensureMlx(configured);
994
- return console.log(JSON.stringify(handle.status(), null, 2));
1040
+ return showStatus(handle.status());
995
1041
  }
996
1042
  const python = join(runtimeDir, "bin", "python");
997
1043
  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 +1070,7 @@ const commands: Record<string, () => Promise<void> | void> = {
1024
1070
  const { one, rest: flags } = takeFlags(rest, ["--peer"], []);
1025
1071
  if (flags.length) fail("usage: ahub queue list [--peer <id>] [--json] [--full] [--color=auto|always|never]");
1026
1072
  const result = await hub.request({ t: "queue", op: "list", ...(one["--peer"] ? { peer: one["--peer"] } : {}) });
1027
- if (!result.ok) fail(result.error);
1073
+ if (!result.ok) fail(result.error,result.refusalAudited===true,result.authorityRefused);
1028
1074
  if (options.json) return console.log(JSON.stringify(result.deliveries, null, 2));
1029
1075
  printOutput(renderQueue(result.deliveries, options.columns, Date.now()), options.color);
1030
1076
  return;
@@ -1036,7 +1082,7 @@ const commands: Record<string, () => Promise<void> | void> = {
1036
1082
  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
1083
  if (yes && !one["--reason"]?.trim()) fail("ahub queue discard needs --reason <text> with --yes: it is recorded on every discarded delivery");
1038
1084
  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);
1085
+ if (!result.ok) fail(result.error,result.refusalAudited===true,result.authorityRefused);
1040
1086
  if (options.json) return console.log(JSON.stringify(result, null, 2));
1041
1087
  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
1088
  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 +1093,7 @@ const commands: Record<string, () => Promise<void> | void> = {
1047
1093
  if (operation === "show") {
1048
1094
  if (flags.length) fail("usage: ahub queue show <id>");
1049
1095
  const shown = await hub.request({ t: "queue", op: "show", id });
1050
- if (!shown.ok) fail(shown.error);
1096
+ if (!shown.ok) fail(shown.error,shown.refusalAudited===true,shown.authorityRefused);
1051
1097
  if (options.json) return console.log(JSON.stringify(shown.delivery, null, 2));
1052
1098
  return printOutput(renderQueueShow(shown.delivery, options.columns, Date.now()), options.color);
1053
1099
  }
@@ -1057,12 +1103,13 @@ const commands: Record<string, () => Promise<void> | void> = {
1057
1103
  let revision = parsedRevision;
1058
1104
  if (revision === undefined) {
1059
1105
  const shown = await hub.request({ t: "queue", op: "show", id });
1060
- if (!shown.ok) fail(shown.error);
1106
+ if (!shown.ok) fail(shown.error,shown.refusalAudited===true,shown.authorityRefused);
1061
1107
  revision = shown.delivery.revision;
1062
1108
  }
1063
1109
  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));
1110
+ if (!result.ok) fail(result.error,result.refusalAudited===true,result.authorityRefused);
1111
+ if (options.json) console.log(JSON.stringify(result.delivery, null, 2));
1112
+ else printOutput(renderQueueShow(result.delivery, options.columns, Date.now()), options.color);
1066
1113
  } finally { hub.close(); }
1067
1114
  },
1068
1115
 
@@ -1120,7 +1167,8 @@ const commands: Record<string, () => Promise<void> | void> = {
1120
1167
  const result = await hub.request(request); hub.close();
1121
1168
  if (!result.ok) fail(result.error);
1122
1169
  const data = result.budgets ?? result.budget ?? { disabled: result.disabled };
1123
- if (op !== "status" || options.json) return console.log(JSON.stringify(data, null, 2));
1170
+ if (options.json) return console.log(JSON.stringify(data, null, 2));
1171
+ if (op === "configure" || op === "disable") return printOutput(renderBudgetResult(op, op === "configure" ? result.budget : result, options.columns, Date.now()), options.color);
1124
1172
  return printOutput(renderExecutionBudgetStatus(result.budgets, options.columns, Date.now()), options.color);
1125
1173
  }
1126
1174
  let set: Record<string, unknown> | undefined;
@@ -1148,7 +1196,10 @@ const commands: Record<string, () => Promise<void> | void> = {
1148
1196
  },
1149
1197
 
1150
1198
  backlog: async () => {
1151
- const [sub, id, revision, value, ...reason] = args;
1199
+ const split = splitPresentation(args, { edges: true }), operands = split.operands;
1200
+ const [sub, id, revision, value, ...reason] = operands;
1201
+ const present = outputOptions(split.flags); // before any request: an invalid --color must refuse before a mutation is sent
1202
+ const shown = (render: (data: any, columns?: number, now?: number) => Span[][]) => async (text: Promise<string>) => showToolText(render, await text, present);
1152
1203
  if (sub === "list" && outputArguments().length === 1) {
1153
1204
  // #399: a table by default; --json prints the hub's listing unchanged, and a refusal is printed as it came.
1154
1205
  const options = outputOptions(), raw = await taskOp("hub_backlog_list", {});
@@ -1158,43 +1209,44 @@ const commands: Record<string, () => Promise<void> | void> = {
1158
1209
  if (!Array.isArray(cards)) return console.log(raw);
1159
1210
  return printOutput(renderBacklog(cards, options.columns, Date.now(), options.full), options.color);
1160
1211
  }
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(" ") }));
1212
+ if (sub === "groom" && operands.length === 1) return shown(renderGroom)(taskOp("hub_planner_groom", {}));
1213
+ if (["return", "return-untouched"].includes(sub ?? "") && /^[1-9]\d*$/.test(id ?? "") && /^\d+$/.test(revision ?? "") && operands.length >= 4) {
1214
+ 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
1215
  }
1165
1216
  if (sub === "add" && args.length >= 3) return console.log(await taskOp("hub_task_propose", { class: id, title: args.slice(2).join(" "), backlog: true }));
1166
1217
  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 }));
1218
+ if (["pull", "return"].includes(sub ?? "") && operands.length === 2) return console.log(await taskOp(sub === "pull" ? "backlog_pull" : "backlog_return", { id }));
1168
1219
  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(" ") }));
1220
+ 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
1221
  if (sub === "split" && value && reason.length) {
1171
1222
  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(" ") }));
1223
+ 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
1224
  }
1174
- if (sub === "undo" && args.length === 3) return console.log(await taskOp("hub_backlog_undo", { id: Number(id), revision: Number(revision) }));
1225
+ 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
1226
  if (!["plan", "pin"].includes(sub ?? "") || !value || !reason.length) fail("planning needs fields and a reason");
1176
1227
  let fields: unknown;
1177
1228
  try { fields = sub === "pin" ? { pinned: value === "true" ? true : value === "false" ? false : undefined } : JSON.parse(value); }
1178
1229
  catch { return fail("planning fields must be JSON"); }
1179
1230
  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(" ") }));
1231
+ 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
1232
  },
1182
1233
 
1183
1234
  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) }));
1235
+ const split = splitPresentation(args, { edges: true }), operands = split.operands;
1236
+ const [sub, name, revision, value] = operands;
1237
+ const present = outputOptions(split.flags); // before any request: an invalid --color must refuse before a mutation is sent
1238
+ const shown = (render: (data: any, columns?: number, now?: number) => Span[][]) => async (text: Promise<string>) => showToolText(render, await text, present);
1239
+ if (sub === "list" && operands.length === 1) return shown(renderReleaseList)(taskOp("hub_release_list", {}));
1240
+ if (sub === "show" && name && operands.length === 2) return shown(renderReleaseShow)(taskOp("release_show", { name }));
1241
+ if (sub === "new" && name && revision && operands.length === 4 && /^\d+$/.test(value ?? "")) return shown(renderReleaseNew)(taskOp("release_new", { name, goal: revision, order: Number(value) }));
1190
1242
  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
1243
  const states: Record<string, string> = { activate: "active", freeze: "frozen", ship: "shipped", drop: "dropped" };
1192
1244
  let fields: unknown;
1193
- if (sub === "plan" && value && args.length === 4) {
1245
+ if (sub === "plan" && value && operands.length === 4) {
1194
1246
  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] };
1247
+ } else if (sub && Object.hasOwn(states, sub) && operands.length === 3) fields = { state: states[sub] };
1196
1248
  else return fail("invalid release command");
1197
- console.log(await taskOp("release_change", { name, revision: Number(revision), fields }));
1249
+ return shown(renderReleaseChange)(taskOp("release_change", { name, revision: Number(revision), fields }));
1198
1250
  },
1199
1251
 
1200
1252
  deliverables: async () => {
@@ -1256,6 +1308,9 @@ const commands: Record<string, () => Promise<void> | void> = {
1256
1308
  },
1257
1309
 
1258
1310
  sdlc: async () => {
1311
+ const args = sdlcOperands(), present = SDLC_READ.includes(args[0] ?? "") ? outputOptions() : undefined;
1312
+ /** 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. */
1313
+ const human = (render: (data: any, columns?: number, now?: number) => Span[][], data: unknown) => printOutput(render(data, present!.columns, Date.now()), present!.color);
1259
1314
  const sub = args[0], id = args[1] === undefined ? undefined : Number(args[1]);
1260
1315
  if (sub === "authorize-release") {
1261
1316
  if (args.length !== 2 || !validReleaseVersion(args[1])) fail("usage: ahub sdlc authorize-release <version>");
@@ -1263,7 +1318,7 @@ const commands: Record<string, () => Promise<void> | void> = {
1263
1318
  const commit = result.stdout?.trim();
1264
1319
  if (result.status !== 0 || !commit || !/^[a-f0-9]{40}$/.test(commit)) fail("candidate commit unavailable");
1265
1320
  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)); }
1321
+ 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
1322
  finally { hub.close(); }
1268
1323
  return;
1269
1324
  }
@@ -1305,7 +1360,7 @@ const commands: Record<string, () => Promise<void> | void> = {
1305
1360
  const repo = args[1], liveIssues = args.slice(2).map(Number);
1306
1361
  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
1362
  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)); }
1363
+ 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
1364
  finally { hub.close(); }
1310
1365
  return;
1311
1366
  }
@@ -1330,12 +1385,14 @@ const commands: Record<string, () => Promise<void> | void> = {
1330
1385
  }
1331
1386
  if (sub === "costs") {
1332
1387
  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] } : {}) }));
1388
+ const text = await taskOp("hub_sdlc_costs", { ...(args[1] ? { repo: args[1] } : {}) });
1389
+ return showToolText(renderSdlcCosts, text, present!);
1334
1390
  }
1335
1391
  if (sub === "metrics") {
1336
1392
  const flags = takeFlags(args.slice(1), ["--since"], []);
1337
1393
  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"] } : {}) }));
1394
+ const text = await taskOp("hub_sdlc_metrics", { ...(flags.one["--since"] !== undefined ? { since: flags.one["--since"] } : {}) });
1395
+ return showToolText(renderSdlcMetrics, text, present!);
1339
1396
  }
1340
1397
  if (sub === "plan-review") {
1341
1398
  const revision = Number(args[2]);
@@ -1356,6 +1413,20 @@ const commands: Record<string, () => Promise<void> | void> = {
1356
1413
  } finally { hub.close(); }
1357
1414
  return;
1358
1415
  }
1416
+ if (sub === "merge") {
1417
+ // #786 T1: the guarded merge of one approved task; a person or the configured conductor asks, the daemon re-reads every gate.
1418
+ const flags = takeFlags(args.slice(2), [], []);
1419
+ const dryRun = flags.rest.includes("--dry-run");
1420
+ if (flags.rest.some(arg => arg !== "--dry-run") || !Number.isSafeInteger(id) || id! <= 0) fail("usage: ahub sdlc merge <task> [--dry-run]");
1421
+ const hub = await connect();
1422
+ try {
1423
+ const reply = await hub.request({ t: "sdlc", op: "merge", id, ...(dryRun ? { dryRun: true } : {}) }, 60_000);
1424
+ if (!reply.ok) fail(reply.error);
1425
+ 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));
1426
+ if (reply.decision === "refused" || reply.ran && reply.result?.state !== "merged") process.exitCode = 1;
1427
+ } finally { hub.close(); }
1428
+ return;
1429
+ }
1359
1430
  if (sub === "accept") {
1360
1431
  // #481 S1: the person accepts the task's spec issue at its current body; agent shells are refused before this runs.
1361
1432
  const flags = takeFlags(args.slice(2), ["--issue", "--repo"], []);
@@ -1379,12 +1450,19 @@ const commands: Record<string, () => Promise<void> | void> = {
1379
1450
  try {
1380
1451
  const reply = await hub.request({ t: "sdlc", op: sub, ...(id === undefined ? {} : { id }) }, 30_000);
1381
1452
  if (!reply.ok) fail(reply.error);
1382
- console.log(JSON.stringify(reply, null, 2));
1453
+ // `sdlc export` is a data format by purpose (#814): it stays JSON and never reaches a renderer.
1454
+ if (sub === "export") console.log(JSON.stringify(reply, null, 2));
1455
+ else if (present!.json) console.log(JSON.stringify(reply, null, 2));
1456
+ else human(sub === "explain" ? renderSdlcExplain : renderSdlcStatus, reply);
1383
1457
  } finally { hub.close(); }
1384
1458
  },
1385
1459
 
1386
1460
  task: async () => {
1387
- const [sub, ...rest] = args;
1461
+ const presentation = splitPresentation(args, { valueFlags: ["--reason", "--superseded-by", "--revision"], edges: true });
1462
+ const shownTask = (render: (data: any, columns?: number, now?: number) => Span[][]) => async (text: Promise<string>) => showToolText(render, await text, outputOptions(presentation.flags));
1463
+ // 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)
1464
+ if (["show", "cancel"].includes(args[0] ?? "")) outputOptions(presentation.flags);
1465
+ const [sub, ...rest] = ["show", "cancel"].includes(args[0] ?? "") ? presentation.operands : args;
1388
1466
  if (sub === "role") {
1389
1467
  if (rest.length !== 2 || !/^[1-9]\d*$/.test(rest[0]!)) fail("usage: ahub task role <id> <role|none>");
1390
1468
  const hub = await connect();
@@ -1396,9 +1474,13 @@ const commands: Record<string, () => Promise<void> | void> = {
1396
1474
  const { one, rest: positional } = takeFlags(rest, ["--reason", "--superseded-by", "--revision"], []);
1397
1475
  const revision = one["--revision"] === undefined ? undefined : Number(one["--revision"]);
1398
1476
  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 }) }));
1477
+ 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 }) }));
1478
+ }
1479
+ if (sub === "show") {
1480
+ // a stray operand (for example the value of a `--color always` that is not the `--color=` form) is a usage error, never silently ignored
1481
+ if (rest.length !== 1) fail("usage: ahub task show <id> [--json] [--full] [--color=auto|always|never]");
1482
+ return shownTask(renderTaskShow)(taskOp("task_show", { id: rest[0] }));
1400
1483
  }
1401
- if (sub === "show") return console.log(await taskOp("task_show", { id: rest[0] }));
1402
1484
  if (sub === "escalate") return console.log(await taskOp("task_escalate", { id: rest[0] }));
1403
1485
  if (sub === "assign") {
1404
1486
  const { one, rest: positional } = takeFlags(rest, ["--role", "--revision"], []);
@@ -1489,7 +1571,7 @@ const commands: Record<string, () => Promise<void> | void> = {
1489
1571
  const reply = await hub.request({ t: "peer_stop", peer }, 35_000);
1490
1572
  if (reply.ok !== true) {
1491
1573
  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);
1574
+ 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,reply.authorityRefused);
1493
1575
  }
1494
1576
  console.log(`${peer}: ${reply.state}`);
1495
1577
  } finally { hub.close(); }
@@ -1525,7 +1607,7 @@ const commands: Record<string, () => Promise<void> | void> = {
1525
1607
  const hub = await connect();
1526
1608
  try {
1527
1609
  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; };
1610
+ 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,reply.authorityRefused); return reply; };
1529
1611
  if (sub === "list") {
1530
1612
  const view = await request({ t: "settings_get" });
1531
1613
  if (!Array.isArray(view.roster)) fail("upgrade the running hub to use ahub roster");
@@ -1591,7 +1673,7 @@ const commands: Record<string, () => Promise<void> | void> = {
1591
1673
  const reply = await hub.request(message, 35_000);
1592
1674
  if (reply.ok !== true) {
1593
1675
  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);
1676
+ 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,reply.authorityRefused);
1595
1677
  }
1596
1678
  return reply;
1597
1679
  };
@@ -1719,8 +1801,29 @@ const commands: Record<string, () => Promise<void> | void> = {
1719
1801
  } finally { hub.close(); }
1720
1802
  },
1721
1803
 
1722
- logs: () => exec("tail", [args.includes("-f") ? "-f" : "-n100", join(stateDir, "hub.log")]),
1723
- export: () => {
1804
+ logs: async () => {
1805
+ if (identity.role !== "tools") return exec("tail", [args.includes("-f") ? "-f" : "-n100", join(stateDir, "hub.log")]);
1806
+ const hub=await connect();const pending:string[]=[];let ready=false,incompleteShown=false;
1807
+ hub.onPush=message=>{if(message.t==="operational_log" && typeof message.line==="string"){if(message.complete===false && !incompleteShown){operationalIncomplete(message,"log");incompleteShown=true;}if(ready)console.log(message.line);else if(pending.length<100)pending.push(message.line);}};
1808
+ try {
1809
+ const result=await hub.request({t:"conductor_operational",op:"logs",follow:args.includes("-f")});
1810
+ if(result.ok!==true)fail(String(result.error??"operational logs unavailable"),result.refusalAudited===true,result.authorityRefused);
1811
+ for(const line of result.lines??[])console.log(line);
1812
+ ready=true;for(const line of pending)console.log(line);
1813
+ if(!incompleteShown){operationalIncomplete(result,"log");if(result.complete===false)incompleteShown=true;}
1814
+ if(args.includes("-f"))await new Promise<void>(resolve=>{
1815
+ const finish=()=>{process.off("SIGINT",finish);resolve();};
1816
+ hub.onClose=finish;process.once("SIGINT",finish);
1817
+ });
1818
+ } finally {hub.close();}
1819
+ },
1820
+ export: async () => {
1821
+ if(identity.role==="tools") {
1822
+ const hub=await connect();
1823
+ 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,result.authorityRefused);for(const row of result.events??[])console.log(JSON.stringify(row));operationalIncomplete(result,"event");}
1824
+ finally {hub.close();}
1825
+ return;
1826
+ }
1724
1827
  for (const e of readEvents(join(stateDir, "events.jsonl"), since())) console.log(JSON.stringify(e));
1725
1828
  },
1726
1829
  bench: async () => {
@@ -2130,7 +2233,12 @@ const commands: Record<string, () => Promise<void> | void> = {
2130
2233
  console.log(options.json ? JSON.stringify(report, null, 2) : formatMemoryStaleAnchors(report));
2131
2234
  return;
2132
2235
  }
2133
- if (args.includes("--orphans")) return orphanDoctor(args.includes("--kill"), options);
2236
+ if (args.includes("--orphans")) {
2237
+ if(identity.role==="tools" && args.includes("--kill")) {
2238
+ 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,result.authorityRefused);console.log(JSON.stringify(result,null,2));}finally{hub.close();}return;
2239
+ }
2240
+ return orphanDoctor(args.includes("--kill"), options);
2241
+ }
2134
2242
  const version = (bin: string) => {
2135
2243
  const res = spawnSync(bin, ["--version"], { encoding: "utf8" });
2136
2244
  return res.status === 0 ? res.stdout.trim().split("\n")[0]! : undefined;
@@ -2287,8 +2395,27 @@ function inspectDoctorRouting(cwd: string): { routing?: Routing; errors: string[
2287
2395
  }
2288
2396
 
2289
2397
  async function runConductorCommand(): Promise<void> {
2398
+ const operation=conductorOperation(cmd,args);
2399
+ if(operation) {
2400
+ const unavailable=`conductor authorization unavailable; nothing was executed; ${OPERATIONAL_PERSON_GUIDANCE}`;
2401
+ const descriptor=readControl(stateDir);
2402
+ if(!descriptor?.instanceId || !descriptor.projectId || !descriptor.pid || !descriptor.pidSignature || processLiveness(descriptor.pid,descriptor.pidSignature)!=="live")fail(unavailable);
2403
+ let hub:ControlClient;
2404
+ try {hub=await connect(3000);} catch {fail(unavailable);}
2405
+ let release:(()=>void)|undefined;
2406
+ try {
2407
+ let result:any;
2408
+ try {result=await hub.request({t:"conductor_operational",op:"authorize",command:operation},3000);} catch {fail(unavailable);}
2409
+ if(result.ok!==true)fail(result.authorityRefused===true || result.authorityRefused===false?String(result.error??unavailable):unavailable,result.refusalAudited===true,result.authorityRefused);
2410
+ if(!hub.instanceId)fail(unavailable);
2411
+ release=ControlClient.pinInstance(stateDir,hub.instanceId);
2412
+ await commands[cmd]!();
2413
+ } finally {release?.();hub.close();}
2414
+ return;
2415
+ }
2290
2416
  let op: string;
2291
2417
  let input: Record<string, unknown>;
2418
+ if (cmd === "sdlc") return commands.sdlc!(); // #786: `sdlc merge` is the one conductor-class sdlc subcommand; the daemon checks the role.
2292
2419
  if (cmd === "task") {
2293
2420
  op = args[0] === "assign" ? "hub_task_assign" : "hub_task_escalate";
2294
2421
  const { one, rest: positional } = takeFlags(args.slice(1), args[0] === "assign" ? ["--role"] : [], []);