@staix/agent-hub 0.12.28 → 0.12.30

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 (68) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +79 -219
  3. package/docs/agent-notes/README.md +12 -0
  4. package/docs/agent-notes/adapters.md +1 -1
  5. package/docs/agent-notes/bus.md +3 -1
  6. package/docs/agent-notes/daemon.md +13 -0
  7. package/docs/agent-notes/local-worker.md +2 -2
  8. package/docs/agent-notes/tasks.md +9 -0
  9. package/docs/agent-notes/tests.md +1 -1
  10. package/docs/memory.md +70 -152
  11. package/docs/operations.md +237 -14
  12. package/docs/plans/2026-10-16-amem-failure-registry.md +101 -0
  13. package/docs/plans/2026-10-16-amem-p0-baseline.md +110 -0
  14. package/docs/plans/2026-10-16-amem-p2-read-tools.md +28 -0
  15. package/docs/plans/amem-cases.json +482 -0
  16. package/docs/quickstart.md +9 -2
  17. package/docs/security.md +39 -2
  18. package/docs/smoke.md +65 -0
  19. package/docs/specs/2026-09-19-agent-hub-design.md +113 -5
  20. package/docs/verification/2026-10-16-0.12.29.md +25 -0
  21. package/docs/verification/2026-10-16-273-live-verification.md +150 -0
  22. package/docs/verification/2026-10-17-0.12.30.md +70 -0
  23. package/docs/verified.json +13 -13
  24. package/package.json +5 -2
  25. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  26. package/plugins/agent-hub/server.js +64 -44
  27. package/src/cli/console-state.ts +92 -20
  28. package/src/cli/console.ts +25 -6
  29. package/src/cli/help.ts +4 -2
  30. package/src/cli/identity-audit.ts +2 -2
  31. package/src/cli/identity.ts +15 -1
  32. package/src/cli/main.js +2 -2
  33. package/src/cli/main.ts +78 -39
  34. package/src/cli/memory.ts +87 -187
  35. package/src/cli/output.ts +6 -0
  36. package/src/cli/recovery-package.ts +59 -10
  37. package/src/cli/upgrade-runtime.ts +60 -18
  38. package/src/cli/upgrade.ts +8 -0
  39. package/src/hub/board.ts +2 -1
  40. package/src/hub/bus.ts +33 -12
  41. package/src/hub/daemon.ts +141 -31
  42. package/src/hub/envelope.ts +30 -0
  43. package/src/hub/events.ts +1 -1
  44. package/src/hub/git.ts +2 -38
  45. package/src/hub/hub-tools.ts +5 -1
  46. package/src/hub/lifecycle.ts +2 -2
  47. package/src/hub/manager.ts +15 -11
  48. package/src/hub/memory-candidates.ts +103 -0
  49. package/src/hub/memory-fail.ts +25 -0
  50. package/src/hub/memory-observations.ts +47 -0
  51. package/src/hub/memory-plan-guard.ts +33 -0
  52. package/src/hub/memory-policy.ts +42 -0
  53. package/src/hub/memory-profile-check.ts +170 -0
  54. package/src/hub/memory-profile.ts +192 -0
  55. package/src/hub/memory-staleness.ts +55 -0
  56. package/src/hub/path-policy.ts +8 -0
  57. package/src/hub/project.ts +4 -37
  58. package/src/hub/read-tools.ts +127 -0
  59. package/src/hub/tasks.ts +201 -41
  60. package/src/hub/ui.ts +10 -7
  61. package/src/local/deny.ts +4 -17
  62. package/src/memory/brief.ts +8 -5
  63. package/src/memory/capture.ts +2 -1
  64. package/src/memory/recall.ts +50 -110
  65. package/src/pi/process-signature.ts +2 -42
  66. package/src/memory/import.ts +0 -90
  67. package/src/memory/store.ts +0 -805
  68. package/src/memory/sync.ts +0 -145
@@ -3,7 +3,7 @@ import { join } from "node:path";
3
3
  import { cliCommandLabel } from "./identity.ts";
4
4
 
5
5
  export interface CliAudit {
6
- v: 1; id: string; at: number; peer: string; command: string; outcome: "run" | "refused" | "invalid";
6
+ v: 1; id: string; at: number; peer: string; command: string; outcome: "run" | "refused" | "invalid" | "delegated";
7
7
  }
8
8
  const MAX_FILES = 256;
9
9
  const MAX_BYTES = 64 * 1024;
@@ -14,7 +14,7 @@ const valid = (row: any): row is CliAudit => row?.v === 1 && typeof row.id === "
14
14
  && typeof row.at === "number" && Number.isFinite(row.at) && row.at > 0
15
15
  && typeof row.peer === "string" && /^[a-z][a-z0-9-]{0,31}$/.test(row.peer) && !/\s/.test(row.peer)
16
16
  && typeof row.command === "string" && row.command.length <= 64 && row.command === cliCommandLabel(...splitLabel(row.command))
17
- && ["run", "refused", "invalid"].includes(row.outcome);
17
+ && ["run", "refused", "invalid", "delegated"].includes(row.outcome);
18
18
  const splitLabel = (label: string): [string, string[]] => { const [command = "unknown", ...args] = label.split(" "); return [command, args]; };
19
19
 
20
20
  /**
@@ -1,5 +1,19 @@
1
1
  import { isSupportedPeer } from "../hub/peer-catalog.ts";
2
2
 
3
+ /** Recovery reads this declaration before delegating to another package's CLI. */
4
+ export const LIVE_TEST_SUPPORTED = true;
5
+
6
+ /** Person verdicts and user-authored workflow content are never delegated. */
7
+ export function liveTestRefusal(command: string, args: readonly string[]): boolean {
8
+ if (args.some(arg => arg === "--as-user" || arg.startsWith("--as-user="))) return true;
9
+ const sub = args.find(arg => arg !== "--json");
10
+ return ["task", "role", "goal"].includes(command)
11
+ || (command === "backlog" && sub !== "list") || (command === "release" && !["list", "show"].includes(sub ?? ""))
12
+ || (command === "memory" && sub !== "graph")
13
+ || (command === "board" && args[0] === "import") || (command === "sdlc" && args[0] === "record")
14
+ || (command === "bench" && args.find(arg => arg !== "--json") === "run") || command === "pet";
15
+ }
16
+
3
17
  export type CliIdentity =
4
18
  | { role: "console" }
5
19
  | { role: "tools"; peer: string }
@@ -63,7 +77,7 @@ export function cliCommandLabel(command: string, args: readonly string[]): strin
63
77
  if (!["upgrade", "restart", "recovery", "recovery-run", "help", "--help", "--version", "version", "projects", "ui", "manager", "setup", "init", "daemon", "up", "console", "claude", "codex", "kimi", "qwen", "jcode", "pi", "local", "stop", "models", "say", "queue", "tail", "watch", "budget", "board", "task", "review", "remember", "ask", "route", "pause", "resume", "permit", "permission", "settings", "roster", "status", "pet", "logs", "export", "report", "research", "memory", "goal", "bench", "sdlc", "role", "facts", "check-path", "turns", "undo", "kill", "reset", "doctor"].includes(command)) return "unknown";
64
78
  if (["task", "queue", "budget", "route", "recovery", "models", "projects", "research", "memory", "goal", "bench"].includes(command)) {
65
79
  const sub = command === "bench" || command === "memory" || command === "goal" ? args.find((arg) => arg !== "--json") : args[0];
66
- if (sub && ["propose", "show", "assign", "escalate", "label", "list", "resolve", "resume", "set", "execution", "explain", "status", "abort", "dispose", "setup", "start", "stop", "remove", "export", "backfill", "addons", "run", "report", "compare", "search", "graph", "promote", "demote", "forget", "supersede", "dedupe", "rebuild", "sync", "machine", "import", "suggestions", "confirm", "reject", "new", "link", "state", "done", "take"].includes(sub)) return `${command} ${sub}`;
80
+ if (sub && ["propose", "show", "assign", "unassign", "escalate", "label", "list", "resolve", "resume", "set", "execution", "explain", "status", "abort", "dispose", "setup", "start", "stop", "remove", "export", "backfill", "addons", "run", "report", "compare", "search", "graph", "promote", "demote", "forget", "supersede", "dedupe", "decay", "rebuild", "sync", "machine", "import", "suggestions", "confirm", "reject", "new", "link", "state", "done", "take"].includes(sub)) return `${command} ${sub}`;
67
81
  }
68
82
  return (/^[a-z][a-z0-9-]*$/.test(command) && !/\s/.test(command)) || ["--help", "--version"].includes(command) ? command : "unknown";
69
83
  }
package/src/cli/main.js CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env bun
2
2
  // Keep this entrypoint plain JavaScript: Node must fail before loading TypeScript or bun:sqlite.
3
3
  const version = /^(\d+)\.(\d+)\.(\d+)$/.exec(process.versions.bun ?? "");
4
- if (!version || !(Number(version[1]) > 1 || (Number(version[1]) === 1 && Number(version[2]) >= 3))) {
5
- console.error("ahub: Bun >=1.3.0 is required. Install Bun: https://bun.sh");
4
+ if (!version || !(Number(version[1]) > 1 || (Number(version[1]) === 1 && (Number(version[2]) > 4 || (Number(version[2]) === 4 && Number(version[3]) >= 2))))) {
5
+ console.error("ahub: Bun >=1.4.2 is required. Install Bun: https://bun.sh");
6
6
  process.exit(1);
7
7
  }
8
8
 
package/src/cli/main.ts CHANGED
@@ -59,11 +59,13 @@ import { armRuns, benchCompare, benchCsv, benchReport, formatCompare, formatRepo
59
59
  import { benchPreflight, defaultIo, runBench } from "./bench.ts";
60
60
  import { ADDONS, isAddon, type AddonName } from "../hub/tooling.ts";
61
61
  import { goalCommand, memoryCommand } from "./memory.ts";
62
+ import { selectedMemoryProfile } from "../hub/memory-profile.ts";
63
+ import { checkMemoryProfiles, formatMemoryProfileCheck } from "../hub/memory-profile-check.ts";
62
64
  import { addonReport, appendRecords, formatAddons, formatResearch, LABELS, labelTarget, RESEARCH_SCHEMA, projectKey, readStores, researchReport, taskRecords, toCsv, type Label, type TaskRecord } from "../hub/research.ts";
63
65
  import { summarize, summarizeByTask } from "../hub/report.ts";
64
66
  import { hasTree, planUndo, repoOf, restore, Turns } from "../hub/snapshots.ts";
65
67
  import { pathWarnings } from "../hub/conflicts.ts";
66
- import { classifyPeerCommand, cliCommandLabel, detectCliIdentity, peerCommandRefusal } from "./identity.ts";
68
+ import { classifyPeerCommand, cliCommandLabel, detectCliIdentity, liveTestRefusal, peerCommandRefusal } from "./identity.ts";
67
69
  import { recordCliAudit } from "./identity-audit.ts";
68
70
  import { runConsole } from "./console.ts";
69
71
  import { paint, permissionBoundary, resolveColor, wrap, type Span } from "./console-state.ts";
@@ -83,10 +85,16 @@ const since = (): number => {
83
85
 
84
86
  const argv = process.argv.slice(2);
85
87
  let selector: string | undefined;
86
- if (argv[0] === "--project") {
87
- argv.shift();
88
- selector = argv.shift();
89
- if (!selector || selector.startsWith("--")) fail("--project needs a path or project ID");
88
+ let liveTest = false;
89
+ while (argv[0] === "--project" || argv[0] === "--live-test") {
90
+ if (argv.shift() === "--live-test") {
91
+ if (liveTest) fail("--live-test may appear only once");
92
+ liveTest = true;
93
+ } else {
94
+ if (selector !== undefined) fail("--project may appear only once");
95
+ selector = argv.shift();
96
+ if (!selector || selector.startsWith("--")) fail("--project needs a path or project ID");
97
+ }
90
98
  }
91
99
  const [given = "help", ...rest] = argv;
92
100
  // `ahub <command> -h` alone asks for that command's help instead of running it. Elsewhere `-h` may be message text
@@ -114,17 +122,27 @@ const projectConfig = () => {
114
122
  return config;
115
123
  };
116
124
  const stateDir = selected.stateDir;
117
- const identity = detectCliIdentity(process.env);
125
+ const caller = detectCliIdentity(process.env);
118
126
  const commandLabel = cliCommandLabel(cmd, args);
119
127
  const commandAccess = classifyPeerCommand(cmd, args);
128
+ // Agent-authored content keeps its peer. Operational starts/holds may borrow console authority for live verification.
129
+ const delegationRefused = liveTest && caller.role === "tools" && commandAccess !== "allowed" && liveTestRefusal(cmd, args);
130
+ const delegatedPeer = liveTest && caller.role === "tools" && commandAccess !== "allowed" && !delegationRefused ? caller.peer : undefined;
131
+ const identity = delegatedPeer ? { role: "console" as const } : caller;
120
132
  function audit(outcome: "run" | "refused" | "invalid"): void {
121
- if (identity.role === "console") return;
122
- try { recordCliAudit(stateDir, identity.role === "tools" ? identity.peer : "unknown", commandLabel, outcome); }
123
- catch { console.error("ahub: CLI audit could not be recorded (outbox unavailable or full)"); }
133
+ if (caller.role === "console") return;
134
+ try { recordCliAudit(stateDir, caller.role === "tools" ? caller.peer : "unknown", commandLabel, delegatedPeer && outcome === "run" ? "delegated" : outcome); }
135
+ catch {
136
+ if (delegatedPeer) fail("live-test delegation audit unavailable; nothing was executed");
137
+ console.error("ahub: CLI audit could not be recorded (outbox unavailable or full)");
138
+ }
124
139
  }
125
140
  if (identity.role === "invalid") { audit("invalid"); fail(`${identity.reason}; use ahub console or a terminal with no agent markers`); }
141
+ if (delegationRefused) { audit("refused"); fail("live-test cannot write person-attributed content; use peer tools or a person's terminal"); }
126
142
  if (identity.role === "tools" && commandAccess === "console") { audit("refused"); fail(peerCommandRefusal(identity.peer, commandLabel)); }
127
- if (identity.role === "tools") audit("run");
143
+ if (identity.role === "tools" || delegatedPeer) audit("run");
144
+ // Forward only to ahub's own continuation commands, never to native agent argv or an environment flag.
145
+ const liveTestArgs = liveTest ? ["--live-test"] : [];
128
146
  const invokedFrom = process.cwd(); // relative paths a person types (a bench suite) are theirs, not the project's
129
147
  try { process.chdir(cwd); } catch { fail(`project directory is unavailable: ${cwd}`); }
130
148
  /** One-shot output flags do not alter the fetched JSON document. */
@@ -381,7 +399,7 @@ async function hold(t: "pause" | "resume"): Promise<void> {
381
399
  }
382
400
 
383
401
  function spawnRecovery(operation: RecoveryOperation, quiet = false): void {
384
- const child = spawn(process.execPath, [join(operation.sourceRoot, "src/cli/main.js"), "recovery-run", operation.id], {
402
+ const child = spawn(process.execPath, [join(operation.sourceRoot, "src/cli/main.js"), ...liveTestArgs, "recovery-run", operation.id], {
385
403
  cwd, detached: true, stdio: "ignore", env: { ...process.env, AGENTHUB_RECOVERY_OPERATION: operation.id },
386
404
  });
387
405
  child.on("error", () => console.error(`runner launch failed; use ${recoveryCommand(operation, "resume")}`));
@@ -415,7 +433,7 @@ async function onTerminal(screens: (io: ScreenIO) => Promise<void>): Promise<voi
415
433
  // The command owns the terminal while it runs; a reader whose input already ended has nothing to pause.
416
434
  if (!ended) rl.pause();
417
435
  const done = (code: number) => { if (!ended) rl.resume(); resolve(code); };
418
- const child = spawn(process.execPath, argv, { cwd, stdio: "inherit" });
436
+ const child = spawn(process.execPath, [argv[0]!, ...liveTestArgs, ...argv.slice(1)], { cwd, stdio: "inherit" });
419
437
  child.on("error", () => done(1));
420
438
  child.on("close", (code) => done(code ?? 1));
421
439
  }),
@@ -444,7 +462,7 @@ function upgradeHost(kind: "restart" | "upgrade", version: string): UpgradeHost
444
462
  lock: recoveryLock,
445
463
  read: (id) => readOperation<RecoveryOperation>(id),
446
464
  runner: recoveryRunner,
447
- live: (op) => liveProjects(op, makeRecoveryDriver().inspect),
465
+ live: (op) => liveProjects(op, makeRecoveryDriver(undefined, delegatedPeer).inspect),
448
466
  endPeer: (planned, peer) => endPlannedPeer(planned, peer),
449
467
  runnerStoppable: (id) => signedRunner(id) !== undefined,
450
468
  stopRunner: (id) => stopSignedRunner(id),
@@ -470,7 +488,7 @@ async function upgrade(kind: "restart" | "upgrade"): Promise<void> {
470
488
  if (to !== undefined) { try { exactVersion(to); } catch (error) { fail((error as Error).message); } }
471
489
  // An older CLI must not coordinate a newer target (docs/operations.md): the target's own coordinator takes over.
472
490
  if (kind === "upgrade" && newerVersion(to!, VERSION) && process.env.AGENTHUB_UPGRADE_HANDOVER !== "1") {
473
- const argv = ["x", "--package", `@staix/agent-hub@${to}`, "ahub", "upgrade", "--to", to!, ...rest];
491
+ const argv = ["x", "--package", `@staix/agent-hub@${to}`, "ahub", ...liveTestArgs, "upgrade", "--to", to!, ...rest];
474
492
  console.error(`ahub: ${to} is newer than this CLI (${VERSION}), so its own coordinator runs the upgrade: bun ${argv.join(" ")}`);
475
493
  if (interactive) {
476
494
  const rl = createInterface({ input: process.stdin, output: process.stdout });
@@ -532,31 +550,31 @@ const commands: Record<string, () => Promise<void> | void> = {
532
550
  const runner = recoveryRunner(id);
533
551
  if (action === "status") {
534
552
  // #215: abort and resume are offered by what the open sources show, read as the runner's errors read them.
535
- const live = runner ? {} : await liveProjects(operation, makeRecoveryDriver().inspect);
553
+ const live = runner ? {} : await liveProjects(operation, makeRecoveryDriver(undefined, delegatedPeer).inspect);
536
554
  console.log(JSON.stringify(publicOperation(operation, runner, live), null, 2));
537
555
  }
538
- else if (action === "abort") { await abortRecovery(id, makeRecoveryDriver()); console.log("preflight cancelled; no committed transition was rolled back"); }
556
+ else if (action === "abort") { await abortRecovery(id, makeRecoveryDriver(undefined, delegatedPeer)); console.log("preflight cancelled; no committed transition was rolled back"); }
539
557
  else if (action === "dispose") {
540
- // #215: human-only (the identity gate refuses agent shells); the reason is kept in the operation's audit.
558
+ // Console authority (including explicit --live-test delegation); the reason is kept in the operation's audit.
541
559
  const { one, rest: flags } = takeFlags(rest, ["--fresh-session", "--reason"], []);
542
560
  const stop = flags.length === 1 && flags[0] === "--stop-and-archive";
543
561
  const reason = one["--reason"]?.trim() ?? "";
544
562
  if (!reason || reason.length > 500 || (flags.length && !stop) || stop === !!one["--fresh-session"]) fail(usage);
545
- const result = await disposeRecovery(id, stop ? { stop: true } : { fresh: one["--fresh-session"]! }, reason, makeRecoveryDriver());
563
+ const result = await disposeRecovery(id, stop ? { stop: true } : { fresh: one["--fresh-session"]! }, reason, makeRecoveryDriver(undefined, delegatedPeer));
546
564
  if (!stop) { console.log(`${one["--fresh-session"]}: a fresh session is accepted and the lost one is recorded; resuming`); return spawnRecovery(result); }
547
565
  console.log(JSON.stringify(publicOperation(result), null, 2));
548
566
  console.log(`${result.plan.kind} abandoned, not completed; the recovery lock is released. A project whose target ran starts again with that version's CLI.`);
549
567
  }
550
568
  else if (["completed", "cancelled"].includes(operation.phase)) console.log(`recovery is already ${operation.phase}`);
551
569
  // #215: an operation being abandoned is never resumed, whichever coordinator started it (an older one would).
552
- else if (operation.disposition) fail(`a stop-and-archive of this operation is partway; nothing was resumed; ${nextActionsText(operation, await liveProjects(operation, makeRecoveryDriver().inspect))}`);
570
+ else if (operation.disposition) fail(`a stop-and-archive of this operation is partway; nothing was resumed; ${nextActionsText(operation, await liveProjects(operation, makeRecoveryDriver(undefined, delegatedPeer).inspect))}`);
553
571
  else if (runner === "unknown") console.log(`whether a runner holds this operation could not be read; nothing was started; ${recoveryCommand(operation, "status")}`);
554
572
  else if (runner) console.log(`runner ${runner} is still working on this operation; ${recoveryCommand(operation, "status")}`);
555
573
  else spawnRecovery(operation);
556
574
  },
557
575
  "recovery-run": async () => {
558
576
  if (args.length !== 1 || process.env.AGENTHUB_RECOVERY_OPERATION !== args[0]) fail("recovery-run is an internal command");
559
- const result = await runRecovery(args[0]!, makeRecoveryDriver());
577
+ const result = await runRecovery(args[0]!, makeRecoveryDriver(undefined, delegatedPeer));
560
578
  if (result.phase !== "completed") process.exitCode = 1;
561
579
  },
562
580
  help: () => {
@@ -593,12 +611,13 @@ const commands: Record<string, () => Promise<void> | void> = {
593
611
  return console.log("dashboard manager stopped; project hubs were kept running");
594
612
  }
595
613
  let url: string;
596
- if (args.includes("--all")) url = await openManager();
614
+ if (args.includes("--all")) url = await openManager({ liveTest: !!delegatedPeer });
597
615
  else {
598
616
  const hub = await connect();
599
617
  try {
600
- const res = await hub.request({ t: "ui", ...(settings ? { settings: true } : {}) }, 10_000);
618
+ const res = await hub.request({ t: "ui", ...(settings ? { settings: true } : {}), ...(delegatedPeer ? { liveTest: true } : {}) }, 10_000);
601
619
  if (!res.ok) fail(res.error);
620
+ if (delegatedPeer && res.liveTest !== true) fail("this hub cannot restrict delegated dashboard content; use a live-test-capable hub");
602
621
  // A hub older than #269 ignores the field and would hand out an ordinary session without saying so.
603
622
  if (settings && res.settings !== true) fail("this hub does not open settings sessions; upgrade the running hub to use ahub ui --settings");
604
623
  url = res.url;
@@ -673,17 +692,17 @@ const commands: Record<string, () => Promise<void> | void> = {
673
692
  if (args.some(arg => arg !== "--panels" && arg !== "--no-pet" && !arg.startsWith("--color=")) || args.filter(arg => arg.startsWith("--color=")).length > 1) fail("usage: ahub console [--panels] [--no-pet] [--color=auto|always|never]");
674
693
  const color = resolveColor(args.find(arg => arg.startsWith("--color="))?.slice("--color=".length), { isTTY: !!process.stdin.isTTY && !!process.stdout.isTTY, TERM: process.env.TERM, NO_COLOR: process.env.NO_COLOR });
675
694
  if (color instanceof Error) return fail("usage: ahub console [--panels] [--no-pet] [--color=auto|always|never]");
676
- await runConsole({ client: await connect(), cwd, stateDir, panels: args.includes("--panels"), noPet: args.includes("--no-pet"), color });
695
+ await runConsole({ client: await connect(), cwd, stateDir, liveTest, panels: args.includes("--panels"), noPet: args.includes("--no-pet"), color });
677
696
  },
678
697
 
679
698
  up: async () => {
680
699
  const unattended = unattendedEnv || args.includes("--unattended");
681
700
  if (unattended) console.error(UNATTENDED_WARNING);
682
- const status = await startProject(registeredProject(), { unattended });
701
+ const status = await startProject(registeredProject(), { unattended, liveTest });
683
702
  console.log(`ahub up (ws://127.0.0.1:${status.controlPort}), state in ${stateDir}`);
684
703
  try { if (loadPetConfig(cwd).window) console.log(await startPetWindow(stateDir, cwd)); }
685
704
  catch { console.error("Pet window was not started; run ahub pet --window to inspect it."); }
686
- if (process.stdin.isTTY && process.stdout.isTTY && !args.includes("--no-console")) await runConsole({ client: await connect(), cwd, stateDir });
705
+ if (process.stdin.isTTY && process.stdout.isTTY && !args.includes("--no-console")) await runConsole({ client: await connect(), cwd, stateDir, liveTest });
687
706
  },
688
707
 
689
708
  claude: async () => {
@@ -958,17 +977,24 @@ const commands: Record<string, () => Promise<void> | void> = {
958
977
  return;
959
978
  }
960
979
  const [id, ...flags] = rest;
961
- if (!id || !["show", "resolve"].includes(operation)) fail("usage: ahub queue show <id> | resolve <id> --action completed|retry|discard --reason <text>");
962
- const shown = await hub.request({ t: "queue", op: "show", id });
963
- if (!shown.ok) fail(shown.error);
980
+ if (!id || !["show", "resolve"].includes(operation)) fail("usage: ahub queue show <id> | resolve <id> --action completed|retry|discard --reason <text> [--revision N]");
964
981
  if (operation === "show") {
965
982
  if (flags.length) fail("usage: ahub queue show <id>");
983
+ const shown = await hub.request({ t: "queue", op: "show", id });
984
+ if (!shown.ok) fail(shown.error);
966
985
  if (options.json) return console.log(JSON.stringify(shown.delivery, null, 2));
967
986
  return printOutput(renderQueueShow(shown.delivery, options.columns, Date.now()), options.color);
968
987
  }
969
- const { one, rest: extra } = takeFlags(args.slice(2), ["--action", "--reason"], []);
970
- if (extra.length || !["completed", "retry", "discard"].includes(one["--action"] ?? "") || !one["--reason"]?.trim()) fail("usage: ahub queue resolve <id> --action completed|retry|discard --reason <text>");
971
- const result = await hub.request({ t: "queue", op: "resolve", id, revision: shown.delivery.revision, action: one["--action"], reason: one["--reason"] });
988
+ const { one, rest: extra } = takeFlags(flags, ["--action", "--reason", "--revision"], []);
989
+ const rawRevision = one["--revision"], parsedRevision = rawRevision === undefined ? undefined : Number(rawRevision);
990
+ if (extra.length || !["completed", "retry", "discard"].includes(one["--action"] ?? "") || !one["--reason"]?.trim() || (rawRevision !== undefined && (!/^\d+$/.test(rawRevision) || !Number.isSafeInteger(parsedRevision)))) fail("usage: ahub queue resolve <id> --action completed|retry|discard --reason <text> [--revision N]");
991
+ let revision = parsedRevision;
992
+ if (revision === undefined) {
993
+ const shown = await hub.request({ t: "queue", op: "show", id });
994
+ if (!shown.ok) fail(shown.error);
995
+ revision = shown.delivery.revision;
996
+ }
997
+ const result = await hub.request({ t: "queue", op: "resolve", id, revision, action: one["--action"], reason: one["--reason"] });
972
998
  if (!result.ok) fail(result.error);
973
999
  console.log(JSON.stringify(result.delivery, null, 2));
974
1000
  } finally { hub.close(); }
@@ -1166,10 +1192,16 @@ const commands: Record<string, () => Promise<void> | void> = {
1166
1192
  if (sub === "show") return console.log(await taskOp("task_show", { id: rest[0] }));
1167
1193
  if (sub === "escalate") return console.log(await taskOp("task_escalate", { id: rest[0] }));
1168
1194
  if (sub === "assign") {
1169
- const { one, rest: positional } = takeFlags(rest, ["--role"], []);
1170
- const role = one["--role"] ?? "owner";
1171
- if (positional.length !== 2 || !["owner", "reviewer"].includes(role)) fail("usage: ahub task assign <id> <peer> [--role owner|reviewer]");
1172
- return console.log(await taskOp("task_assign", { id: positional[0], peer: positional[1], ...(role === "reviewer" ? { role } : {}) }));
1195
+ const { one, rest: positional } = takeFlags(rest, ["--role", "--revision"], []);
1196
+ const role = one["--role"] ?? "owner", rawRevision = one["--revision"], revision = rawRevision === undefined ? undefined : Number(rawRevision);
1197
+ if (positional.length !== 2 || !["owner", "reviewer"].includes(role) || (rawRevision !== undefined && (!/^\d+$/.test(rawRevision) || !Number.isSafeInteger(revision)))) fail("usage: ahub task assign <id> <peer> [--role owner|reviewer] [--revision N]");
1198
+ return console.log(await taskOp(revision === undefined ? "task_assign" : "task_assign_checked", { id: positional[0], peer: positional[1], ...(role === "reviewer" ? { role } : {}), ...(revision === undefined ? {} : { revision }) }));
1199
+ }
1200
+ if (sub === "unassign") {
1201
+ const { one, rest: positional } = takeFlags(rest, ["--role", "--revision"], []);
1202
+ const role = one["--role"] ?? "owner", rawRevision = one["--revision"], revision = rawRevision === undefined ? undefined : Number(rawRevision);
1203
+ if (positional.length !== 1 || !["owner", "reviewer"].includes(role) || (rawRevision !== undefined && (!/^\d+$/.test(rawRevision) || !Number.isSafeInteger(revision)))) fail("usage: ahub task unassign <id> [--role owner|reviewer] [--revision N]");
1204
+ return console.log(await taskOp("task_unassign", { id: positional[0], role, ...(revision === undefined ? {} : { revision }) }));
1173
1205
  }
1174
1206
  if (sub === "label") {
1175
1207
  // #247: a person's later verdict on an approved task (the identity gate keeps it human-only), for research records.
@@ -1182,7 +1214,7 @@ const commands: Record<string, () => Promise<void> | void> = {
1182
1214
  appendRecords(project.id, [{ schema: RESEARCH_SCHEMA, kind: "label", project: projectKey(project.id), task: id, createdAt: record!.createdAt!, label, at: new Date().toISOString() }]);
1183
1215
  return console.log(`task #${id} (approved ${record!.approvedAt}) labelled ${label} in the research records`);
1184
1216
  }
1185
- if (sub !== "propose" || rest.length < 1) fail("usage: ahub task propose [<class>] <title...> | show <id> | assign <id> <peer> | escalate <id>");
1217
+ if (sub !== "propose" || rest.length < 1) fail("usage: ahub task propose [<class>] <title...> | show <id> | assign <id> <peer> | unassign <id> | escalate <id>");
1186
1218
  // `--class` is the explicit form. A first word that is a class name is still taken as the class (the documented
1187
1219
  // short form), but said out loud: "review the auth module" would otherwise be filed as class review, silently.
1188
1220
  const urgent = rest.includes("--urgent"), backlog = rest.includes("--backlog");
@@ -1394,7 +1426,7 @@ const commands: Record<string, () => Promise<void> | void> = {
1394
1426
  printOutput(renderStatus({ ...status, budget: quota.budget ?? {} }, options.columns, Date.now(), options.full), options.color);
1395
1427
  if (status.needs) { const pet = loadPetConfig(cwd); if (pet.enabled) console.log(renderPet(petView(status), false, pet.character)); }
1396
1428
  // #258: open long-term tasks and who holds them, as of this machine's last memory sync.
1397
- if (existsSync(join(hubHome(), "memory", "index.db"))) {
1429
+ if (status.memoryProfile ? status.memoryProfile.state === "ready" : existsSync(join(hubHome(), "memory", "index.db"))) {
1398
1430
  try { const lines = await goalCommand(["list"], cwd); if (!lines.startsWith("no open")) console.log(`\nlong-term tasks\n${lines}`); } catch { /* memory unreadable: status goes on */ }
1399
1431
  }
1400
1432
  } finally { hub.close(); }
@@ -1470,7 +1502,14 @@ const commands: Record<string, () => Promise<void> | void> = {
1470
1502
  },
1471
1503
  memory: async () => {
1472
1504
  // #258: the hub's own memory on this machine; reads are anyone's, changes a person's.
1473
- try { console.log(await memoryCommand(args, cwd)); } catch (e) { fail((e as Error).message); }
1505
+ if (args.filter(arg => arg !== "--json")[0] === "profile-check") {
1506
+ if (args.some(arg => arg !== "profile-check" && arg !== "--json")) fail("usage: ahub memory profile-check [--json]");
1507
+ const result = await checkMemoryProfiles(selectedMemoryProfile());
1508
+ console.log(args.includes("--json") ? JSON.stringify(result, null, 2) : formatMemoryProfileCheck(result));
1509
+ if (!result.ready) process.exitCode = 1;
1510
+ return;
1511
+ }
1512
+ try { console.log(await memoryCommand(args, cwd, { person: caller.role === "console" })); } catch (e) { fail((e as Error).message); }
1474
1513
  },
1475
1514
  goal: async () => {
1476
1515
  // #258: long-term tasks; a link goes through the project's hub, so Tasks validates the board task.
@@ -1479,7 +1518,7 @@ const commands: Record<string, () => Promise<void> | void> = {
1479
1518
  try { const r = await hub.request({ t: "goal_link", goal, task: Number(task) }); if (!r.ok) throw new Error(String(r.error)); return String(r.text); }
1480
1519
  finally { hub.close(); }
1481
1520
  };
1482
- try { console.log(await goalCommand(args, cwd, { link })); } catch (e) { fail((e as Error).message); }
1521
+ try { console.log(await goalCommand(args, cwd, { link, person: caller.role === "console" })); } catch (e) { fail((e as Error).message); }
1483
1522
  },
1484
1523
  research: () => {
1485
1524
  // #247: measures from the opt-in research records; `export` writes them out, `backfill` builds them from events.jsonl.