@staix/agent-hub 0.15.0 → 0.16.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 (80) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +12 -8
  3. package/docs/agent-notes/README.md +4 -0
  4. package/docs/agent-notes/adapters.md +6 -3
  5. package/docs/agent-notes/budget.md +4 -2
  6. package/docs/agent-notes/daemon.md +9 -3
  7. package/docs/agent-notes/local-worker.md +2 -2
  8. package/docs/agent-notes/tasks.md +13 -1
  9. package/docs/agent-notes/tests.md +3 -0
  10. package/docs/dogfood.md +13 -4
  11. package/docs/events.md +1 -1
  12. package/docs/memory.md +78 -10
  13. package/docs/operations.md +116 -30
  14. package/docs/quickstart.md +8 -4
  15. package/docs/security.md +80 -18
  16. package/docs/smoke.md +7 -3
  17. package/docs/specs/2026-09-19-agent-hub-design.md +84 -8
  18. package/docs/verification/2026-10-24-always-ask-adapters.md +52 -0
  19. package/docs/verification/2026-10-24-amem-runtime.md +69 -0
  20. package/docs/verification/2026-10-24-codex-0164-reviewer-isolation.md +55 -0
  21. package/docs/verification/2026-10-24-delivery-holds-ac8.md +264 -0
  22. package/docs/verification/2026-10-24-hardcoded-values.md +2108 -0
  23. package/docs/verification/2026-10-25-0.16.0.md +82 -0
  24. package/docs/verified.json +26 -17
  25. package/package.json +2 -2
  26. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  27. package/plugins/agent-hub/server.js +2085 -1702
  28. package/src/adapters/acp.ts +5 -2
  29. package/src/adapters/codex-appserver.ts +69 -1
  30. package/src/adapters/compat.ts +2 -2
  31. package/src/adapters/definitions/builtin-metadata.ts +3 -3
  32. package/src/adapters/local-worker.ts +5 -4
  33. package/src/adapters/registry-types.ts +1 -1
  34. package/src/cli/console-state.ts +20 -2
  35. package/src/cli/console-symbols.ts +3 -2
  36. package/src/cli/console-usage.ts +5 -2
  37. package/src/cli/console.ts +5 -1
  38. package/src/cli/help.ts +7 -5
  39. package/src/cli/identity.ts +3 -1
  40. package/src/cli/main.ts +44 -10
  41. package/src/cli/memory.ts +119 -7
  42. package/src/cli/output.ts +12 -4
  43. package/src/hub/action-class.ts +211 -17
  44. package/src/hub/action-enforce.ts +13 -5
  45. package/src/hub/action-policy.ts +19 -8
  46. package/src/hub/ask.ts +16 -4
  47. package/src/hub/board.ts +86 -31
  48. package/src/hub/budget-notes.ts +65 -0
  49. package/src/hub/budget.ts +42 -10
  50. package/src/hub/bus.ts +1 -1
  51. package/src/hub/code-facts.ts +56 -16
  52. package/src/hub/conductor.ts +5 -2
  53. package/src/hub/config-apply.ts +7 -0
  54. package/src/hub/control-client.ts +1 -1
  55. package/src/hub/daemon.ts +250 -49
  56. package/src/hub/envelope.ts +1 -1
  57. package/src/hub/events.ts +2 -2
  58. package/src/hub/hub-tools.ts +3 -3
  59. package/src/hub/kimi-usage.ts +12 -0
  60. package/src/hub/lifecycle.ts +10 -5
  61. package/src/hub/memory-code-head.ts +44 -0
  62. package/src/hub/memory-code-overlay.ts +119 -0
  63. package/src/hub/memory-plan-guard.ts +10 -1
  64. package/src/hub/memory-stale-anchors.ts +159 -0
  65. package/src/hub/memory-symbols.ts +74 -0
  66. package/src/hub/planning.ts +10 -4
  67. package/src/hub/read-tools.ts +21 -3
  68. package/src/hub/sdlc.ts +34 -0
  69. package/src/hub/settings.ts +17 -2
  70. package/src/hub/settled-roles.ts +15 -3
  71. package/src/hub/task-sweep.ts +25 -3
  72. package/src/hub/tasks.ts +353 -64
  73. package/src/hub/token-window.ts +15 -2
  74. package/src/hub/window-kind.ts +13 -0
  75. package/src/local/tools.ts +5 -5
  76. package/src/memory/brief.ts +22 -12
  77. package/src/memory/capture.ts +26 -40
  78. package/src/memory/client.ts +137 -65
  79. package/src/memory/recall.ts +13 -22
  80. package/src/ui/index.html +100 -18
@@ -31,6 +31,8 @@ export interface PermissionRequest {
31
31
  /** What the host's always-ask gate says about one request (#636): the approval text it must show, and a class-budget choice when one applies. */
32
32
  export interface ActionGateVerdict {
33
33
  title: string;
34
+ /** The person cannot see the complete command: no allow option or class-budget grant is valid. */
35
+ denyOnly?: true;
34
36
  /** A person's explicit choice to approve the classes for the rest of the session's budget (`label` names them); never an ordinary allow. */
35
37
  budget?: { label: string; approve(): boolean };
36
38
  }
@@ -687,7 +689,7 @@ export class AcpPeer extends BasePeer {
687
689
  let gate: ActionGateVerdict | undefined;
688
690
  try {
689
691
  gate = rawInput === undefined || !this.sessionId ? undefined : this.opts.actionGate?.({ names: [call.title, announced, canonical].filter((name): name is string => typeof name === "string"), ...(kind ? { kind } : {}), input: rawInput }, this.sessionId);
690
- } catch { gate = { title: "Action approval: the always-ask check failed; approve only if you know what runs" }; } // a failing gate asks, never allows
692
+ } catch { gate = { title: "Action approval: the always-ask check failed; command withheld", denyOnly: true }; } // a failing gate asks, never allows
691
693
  if (once && identity && !gate) {
692
694
  this.opts.log?.(`permission auto-approved for ${this.id}: ${identity}`); // the name only: arguments may quote a PII turn
693
695
  this.emitActivity("ask", `${String(msg.id)}: ${activityText(announced ?? call.title)} (automatic)`);
@@ -708,8 +710,9 @@ export class AcpPeer extends BasePeer {
708
710
  const title: string = gate ? gate.title : raw === undefined ? `${named ?? "tool call"} (payload not reported by the agent)` : `${named ?? "tool call"}${input}`;
709
711
  this.emitActivity("ask", `${String(msg.id)}: ${activityText(title)}; answer in ahub console`);
710
712
  let options: PermissionOption[] = (msg.params?.options ?? []).filter((o: PermissionOption) => o.kind !== "allow_always" || !gate && !this.nativePolicy?.lockedReadOnly && raw !== undefined && !cut);
713
+ if (gate?.denyOnly) options = options.filter(o => o.kind === "reject_once" || o.kind === "reject_always");
711
714
  // The class-budget choice is the hub's own option: answered to the agent as its allow_once once the ledger has recorded the grant.
712
- const budgetOption: PermissionOption | undefined = gate?.budget && once ? { optionId: ACTION_BUDGET_OPTION, name: `Allow ${gate.budget.label} for this session`, kind: "allow_once" } : undefined;
715
+ const budgetOption: PermissionOption | undefined = !gate?.denyOnly && gate?.budget && once ? { optionId: ACTION_BUDGET_OPTION, name: `Allow ${gate.budget.label} for this session`, kind: "allow_once" } : undefined;
713
716
  if (budgetOption) options = [...options, budgetOption];
714
717
  // Only a bare tool name travels on its own (a desktop notice shows it); anything prose-like stays in the title.
715
718
  const tool = typeof named === "string" && /^[\w.:@-]{1,80}$/.test(named) ? named : undefined;
@@ -6,6 +6,7 @@ import { BasePeer } from "../hub/peers.ts";
6
6
  import { codexContext, type ContextReading } from "../hub/context-window.ts";
7
7
  import { peerChildEnv, stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
8
8
  import type { ContextReader } from "./registry-types.ts";
9
+ import { ACTION_BUDGET_OPTION, type ActionGateVerdict, type PermissionRequest } from "./acp.ts";
9
10
 
10
11
  const UNIDENTIFIED_CANCELLATION_NOTICE = "Codex cancelled turn has no verified native id; reattach the native session before further hub delivery";
11
12
 
@@ -15,6 +16,9 @@ export interface CodexOptions {
15
16
  /** Port the TUI attaches to: `codex --enable tui_app_server --remote ws://127.0.0.1:<proxyPort>`. */
16
17
  proxyPort: number;
17
18
  permissionMode?: PermissionMode;
19
+ /** Host-only always-ask gate, shared with ACP; an absent verdict preserves native handling. */
20
+ actionGate?: (call: { names: readonly string[]; kind?: string; input: unknown }, session: string) => ActionGateVerdict | undefined;
21
+ onPermission?: (request: PermissionRequest, signal?: AbortSignal) => Promise<string | undefined>;
18
22
  /** Port for the spawned `codex app-server`. Ignored when `upstreamUrl` is set. */
19
23
  appPort: number;
20
24
  /** Attach to an already running app-server instead of spawning one (tests). */
@@ -65,6 +69,7 @@ interface Link {
65
69
  tui: ServerWebSocket<Link>;
66
70
  up: WebSocket;
67
71
  backlog: string[]; // TUI frames that arrived before the upstream socket opened
72
+ approvalIds: Set<string | number>; // intercepted native ids stay tombstoned for this socket lifetime
68
73
  tracked: Map<string | number, string>; // request id -> method, for thread/start and thread/resume
69
74
  }
70
75
 
@@ -78,6 +83,8 @@ const TRACKED = new Set(["thread/start", "thread/resume"]);
78
83
  */
79
84
  export class CodexPeer extends BasePeer {
80
85
  private permissionMode: PermissionMode;
86
+ private readonly actionWaits = new Map<AbortController, { link: Link; turn: string | undefined }>();
87
+ get actionGated(): boolean { return this.opts.actionGate !== undefined; }
81
88
  private readonly nativeApprovalPolicies = new Map<string, unknown>();
82
89
  private readonly overriddenApprovals = new Set<string>();
83
90
  private readonly restoringApprovals = new Map<string | number, string>();
@@ -200,6 +207,7 @@ export class CodexPeer extends BasePeer {
200
207
  }
201
208
 
202
209
  async stop(): Promise<void> {
210
+ this.abortActionWaits();
203
211
  this.server?.stop(true);
204
212
  this.claimedTui?.tui.close(1001, "hub shutting down");
205
213
  const proc = this.proc;
@@ -353,6 +361,7 @@ export class CodexPeer extends BasePeer {
353
361
 
354
362
  /** A silent turn is interrupted before the peer is declared idle, or the next turn/start would land inside it. */
355
363
  protected override onWatchdog(): void {
364
+ this.abortActionWaits();
356
365
  this.deliveryEpoch++;
357
366
  const known = new Set([...this.activeTurns, ...this.turnDeliveries.keys()].filter(id => !id.startsWith("unknown:")));
358
367
  const pendingStarts = [...this.pending.values()].filter(pending => pending.kind === "deliver");
@@ -379,6 +388,7 @@ export class CodexPeer extends BasePeer {
379
388
  }
380
389
 
381
390
  private retireTurn(turnId: string): void {
391
+ this.abortActionWaits(undefined, turnId);
382
392
  this.retiredTurns.add(turnId);
383
393
  if (this.link?.up.readyState === WebSocket.OPEN) this.link.up.send(JSON.stringify({ method: "turn/interrupt", id: this.nextId--, params: { threadId: this.threadId, turnId } }));
384
394
  }
@@ -445,7 +455,7 @@ export class CodexPeer extends BasePeer {
445
455
 
446
456
  private attach(tui: ServerWebSocket<Link>, upstream: string): void {
447
457
  const up = new WebSocket(upstream);
448
- const link: Link = { tui, up, backlog: [], tracked: new Map(), ...(tui.data.launchId ? { launchId: tui.data.launchId } : {}) };
458
+ const link: Link = { tui, up, backlog: [], tracked: new Map(), approvalIds: new Set(), ...(tui.data.launchId ? { launchId: tui.data.launchId } : {}) };
449
459
  this.claimedTui = link;
450
460
  tui.data = link;
451
461
  up.onopen = () => {
@@ -456,6 +466,7 @@ export class CodexPeer extends BasePeer {
456
466
  }
457
467
 
458
468
  private detach(link: Link): void {
469
+ this.abortActionWaits(link);
459
470
  link.up.close();
460
471
  if (this.claimedTui === link) this.claimedTui = undefined;
461
472
  if (this.link !== link) return;
@@ -539,6 +550,8 @@ export class CodexPeer extends BasePeer {
539
550
 
540
551
  private fromTui(link: Link, raw: string): void {
541
552
  const msg = parse(raw);
553
+ if (msg?.id !== undefined && !msg.method && link.approvalIds.has(msg.id)) return;
554
+ if (msg?.method === "turn/interrupt" && link === this.link) this.abortActionWaits(link, typeof msg.params?.turnId === "string" ? msg.params.turnId : undefined);
542
555
  if (msg?.method === "turn/start" && msg.params && link === this.link) {
543
556
  const thread = typeof msg.params.threadId === "string" ? msg.params.threadId : this.threadId;
544
557
  // A resumed TUI can echo the sticky hub overlay. The restoring turn must use the kept baseline.
@@ -561,6 +574,7 @@ export class CodexPeer extends BasePeer {
561
574
  if (this.state === "busy" && !(typeof nativeId === "string" && this.retiredTurns.has(nativeId))) this.touch();
562
575
  if (!msg) return void link.tui.send(raw);
563
576
 
577
+ if (this.interceptActionApproval(link, msg)) return;
564
578
  if (msg.id !== undefined && !msg.method) this.settlePermissionRestore(msg.id, !!msg.error);
565
579
  if (typeof msg.id === "number" && msg.id < 0 && !msg.method) {
566
580
  const p = this.pending.get(msg.id);
@@ -583,8 +597,61 @@ export class CodexPeer extends BasePeer {
583
597
  link.tui.send(raw);
584
598
  }
585
599
 
600
+ private abortActionWaits(link?: Link, turn?: string): void {
601
+ for (const [controller, wait] of this.actionWaits) if ((!link || wait.link === link) && (!turn || wait.turn === turn)) controller.abort();
602
+ }
603
+
604
+ /** Only the two native command schemas verified on 0.163.0. Missing payloads keep their native policy. */
605
+ private interceptActionApproval(link: Link, msg: any): boolean {
606
+ if (!this.opts.actionGate || !(typeof msg.id === "string" || Number.isSafeInteger(msg.id))) return false;
607
+ const legacy = msg.method === "execCommandApproval", params = msg.params;
608
+ if (!legacy && msg.method !== "item/commandExecution/requestApproval") return false;
609
+ if (!params || typeof params !== "object") return false;
610
+ if (link.approvalIds.has(msg.id)) return true; // one intercepted callback has exactly one host answer
611
+ if (legacy ? !Array.isArray(params.command) || !params.command.length || !params.command.every((word: unknown) => typeof word === "string")
612
+ : typeof params.command !== "string" || !params.command.length || params.kind != null && params.kind !== "command") return false;
613
+ const thread = legacy ? params.conversationId : params.threadId;
614
+ let gate: ActionGateVerdict | undefined;
615
+ try { gate = this.opts.actionGate({ names: ["shell"], kind: "execute", input: { command: params.command, cwd: params.cwd } }, typeof thread === "string" ? thread : ""); }
616
+ catch { gate = { title: "Action approval: the always-ask check failed; command withheld", denyOnly: true }; }
617
+ if (!gate) return false;
618
+ const verdict = gate;
619
+ // ponytail: retain at most 1024 intercepted ids per socket, never evict a tombstone and accept a late native answer.
620
+ // Reattach is the upgrade path after this finite lifetime cap; closing both sides fails closed.
621
+ if (link.approvalIds.size >= 1024) { this.abortActionWaits(link); link.up.close(); link.tui.close(1008, "Codex action approval id capacity reached; reattach"); return true; }
622
+ link.approvalIds.add(msg.id);
623
+ const named = [...this.activeTurns].filter(turn => !turn.startsWith("unknown:") && !this.retiredTurns.has(turn));
624
+ const turn = legacy ? named.length === 1 ? named[0] : undefined : typeof params.turnId === "string" ? params.turnId : undefined;
625
+ const epoch = this.contextEpoch, controller = new AbortController();
626
+ const live = () => !controller.signal.aborted && link === this.link && epoch === this.contextEpoch && thread === this.threadId &&
627
+ typeof turn === "string" && this.activeTurns.has(turn) && !this.retiredTurns.has(turn) && !this.unidentifiedCancellation && this.state === "busy" &&
628
+ link.tui.readyState === WebSocket.OPEN && link.up.readyState === WebSocket.OPEN;
629
+ const answer = (allow: boolean) => { if (link.up.readyState === WebSocket.OPEN) link.up.send(JSON.stringify({ id: msg.id, result: { decision: legacy ? allow ? "approved" : "abort" : allow ? "accept" : "decline" } })); };
630
+ if (!live()) { answer(false); return true; } // no invented native turn binding, even when the command is classed
631
+ this.actionWaits.set(controller, { link, turn });
632
+ const options: PermissionRequest["options"] = [{ optionId: "allow", name: "Allow once", kind: "allow_once" }, { optionId: "deny", name: "Deny", kind: "reject_once" }];
633
+ if (verdict.denyOnly) options.splice(0, 1); // keep only denial, even if a broken callback later returns allow
634
+ if (!verdict.denyOnly && verdict.budget) options.push({ optionId: ACTION_BUDGET_OPTION, name: `Allow ${verdict.budget.label} for this session`, kind: "allow_once" });
635
+ const alive = setInterval(() => { if (live()) this.touch(); }, Math.max(10, Math.min(30_000, Math.floor(this.watchdogMs / 3))));
636
+ void (async () => {
637
+ let picked: string | undefined;
638
+ try {
639
+ const cancelled = new Promise<undefined>(resolve => controller.signal.addEventListener("abort", () => resolve(undefined), { once: true }));
640
+ picked = await Promise.race([this.opts.onPermission?.({ peer: this.id, title: verdict.title, tool: "shell", kind: "execute", classed: true, options }, controller.signal), cancelled]);
641
+ } catch { /* a broken or missing approval handler denies */ }
642
+ finally { clearInterval(alive); this.actionWaits.delete(controller); }
643
+ let allow = !verdict.denyOnly && live() && picked === "allow";
644
+ if (!verdict.denyOnly && live() && verdict.budget && picked === ACTION_BUDGET_OPTION) {
645
+ try { allow = verdict.budget.approve() && live(); } catch { allow = false; }
646
+ }
647
+ answer(allow);
648
+ })();
649
+ return true;
650
+ }
651
+
586
652
  private adopt(link: Link, threadId: unknown, fresh = false): void {
587
653
  if (typeof threadId !== "string" || !threadId || link !== this.claimedTui || link.tui.readyState !== WebSocket.OPEN || link.up.readyState !== WebSocket.OPEN) return;
654
+ this.abortActionWaits();
588
655
  this.abandonSteers("thread changed");
589
656
  for (const [id, pending] of this.pending) if (pending.kind === "deliver") {
590
657
  pending.invalidate?.("Codex native thread changed before acceptance was confirmed");
@@ -699,6 +766,7 @@ export class CodexPeer extends BasePeer {
699
766
  if (item.phase !== "commentary" && text.trim()) this.lastAnswer = text.trim();
700
767
  } else if (method === "turn/completed") {
701
768
  const nativeTurn = params.turn?.id;
769
+ this.abortActionWaits(link, typeof nativeTurn === "string" ? nativeTurn : undefined);
702
770
  // A watchdog or a newer turn has invalidated this completion. Unknown-id legacy starts retain their fallback.
703
771
  if (!this.activeTurns.size || typeof nativeTurn === "string" && !this.activeTurns.has(nativeTurn) && ![...this.activeTurns].some(id => id.startsWith("unknown:"))) return;
704
772
  this.activeTurns.delete(nativeTurn);
@@ -31,8 +31,8 @@ export const COMPAT = {
31
31
  "claude-implementer": { label: "Claude headless implementer", verified: ["2.1.309"], gate: "contract", probe: ["claude", "--version"],
32
32
  parse: out => plain(out.trim().replace(/ \(Claude Code\)$/, "")),
33
33
  trustNote: "The native prompt-tool allow, deny, MCP error/disconnect and hook-failure fallthrough have an isolated receipt. No dontAsk/bypass fallback exists; another version's permission behavior is unverified." },
34
- // 0.163.0 receipt: docs/verification/2026-10-22-codex-reviewer-isolation.md
35
- "codex-review": { label: "Codex reviewer (codex-cli)", verified: ["0.146.0", "0.163.0"], gate: "isolation", probe: ["codex", "--version"], config: "codex_bin",
34
+ // 0.163.0 receipt: docs/verification/2026-10-22-codex-reviewer-isolation.md; 0.164.0 receipt: docs/verification/2026-10-24-codex-0164-reviewer-isolation.md
35
+ "codex-review": { label: "Codex reviewer (codex-cli)", verified: ["0.146.0", "0.163.0", "0.164.0"], gate: "isolation", probe: ["codex", "--version"], config: "codex_bin",
36
36
  parse: out => plain(out.trim().replace(/^codex-cli /, "")),
37
37
  trustNote: "Every launch still proves the MCP inventory, Apps/plugins/hooks disabled, sandbox read/write denial and the binary fence; a failed leg still refuses. Trust covers only the source-level claims those probes cannot see (docs/verification/2026-10-19-reviewer-isolation.md)." },
38
38
  // Kimi 2.3.0 / Qwen 0.26.0 receipt: docs/verification/2026-10-22-kimi-qwen-compat.md
@@ -149,7 +149,7 @@ export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition
149
149
  // Approval enforcement belongs to the verified native transport and host permission gate.
150
150
  permission: { grants: { ...hubGrants } },
151
151
  session: { idField: "sessionId", currentId: sessionId("sessionId"), restoreOnStart: true, launchKind: "pi", launchRecord: true, ...PI_RECOVERY_SESSION, crashPlan: piCrashPlan, unrecordedTerminal: "tui", continuityBookmark: piRecoveryContinuity, continuity: { key: "pi", parse: parsePiContinuity } },
152
- turnEnd: "adapter", budget: { account: "pi", instrumented: true }, reporting: { tokensNotReported: true }, configuration: { schema: piConfigSchema, routingSchema: piRoutingSchema, settings: PI_SETTINGS, restartReasons: PI_RESTART_REASONS, validateEffective: piEffectiveConfig },
152
+ turnEnd: "adapter", budget: { account: "pi", instrumented: true, noQuotaReason: "on-prem backends have no provider quota" }, reporting: { tokensNotReported: true }, configuration: { schema: piConfigSchema, routingSchema: piRoutingSchema, settings: PI_SETTINGS, restartReasons: PI_RESTART_REASONS, validateEffective: piEffectiveConfig },
153
153
  routing: { localAllowedRefusal: "local_allowed = false also excludes pi for this class", longContext: "backend-limit", preferredTier: true, handoffPriority: 0, trace: "backend", backendLabel: "pi" },
154
154
  cli: { command: "pi", entry: { lifecycleAdmission: false, execute: runPiEntry }, preview: previewPi, usage: [["ahub pi [--print-command] [--mode headless|tui | --headless] [--backend auto|dgx|mlx] [--model <alias>] [--session-id <id>] [--session-file <path>] [--replace-session]", "start Pi: its TUI in this terminal unless --headless or the start mode setting (peers.pi.start_mode) says headless; without a terminal the hub opens one or says how"]], launchConfigKeys: ["pi.cmd", "pi.backend", "pi.dgx_coding", "pi.dgx_fast", "pi.max_steps", "routing.pi", "peers.pi.start_mode"], previewArgs: ["--mode", "tui"], retainedTerminalNotice: piRetainedTerminalNotice, personOnlyFlags: ["--replace-session"], startWords: form => ["pi", "--mode", form] }, display: PI_DISPLAY,
155
155
  },
@@ -162,7 +162,7 @@ export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition
162
162
  // Approval enforcement belongs to the verified native transport and host permission gate.
163
163
  permission: { grants: { ...hubGrants } },
164
164
  session: { idField: "sessionId", currentId: sessionId("sessionId"), freshOnRestore: true, launchKind: "local", ...LOCAL_RECOVERY_SESSION, crashPlan: localCrashPlan },
165
- turnEnd: "adapter", budget: { account: "local", quotaExempt: true, instrumented: true }, configuration: { schema: localConfigSchema, routingSchema: localRoutingSchema, restartReasons: LOCAL_RESTART_REASONS },
165
+ turnEnd: "adapter", budget: { account: "local", quotaExempt: true, instrumented: true, noQuotaReason: "on-prem gateway has no provider quota" }, configuration: { schema: localConfigSchema, routingSchema: localRoutingSchema, restartReasons: LOCAL_RESTART_REASONS },
166
166
  routing: { localAllowedRefusal: "local_allowed = false for this class", longContext: "skip-owner", preferredTier: true, handoffPriority: 1, trace: "route" },
167
167
  cli: { command: "local", entry: { lifecycleAdmission: false, execute: runLocalEntry }, usage: [["ahub local [--route <id> | --model <id>]", "start the hub-native worker on the self-hosted models (routing.toml)"]], launchConfigKeys: ["local.deny", "local.bash_network", "local.network_allow", "local.max_steps", "local.read_allow", "routing.local"], startWords: () => ["local"] }, display: LOCAL_DISPLAY,
168
168
  },
@@ -243,7 +243,7 @@ export const BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly LegacyAdapterDefinition[
243
243
  identity: { mcp: "env" },
244
244
  permission: { modes: {}, grants: { ...hubGrants } },
245
245
  session: { idField: "sessionId", currentId: sessionId("sessionId"), launchKind: "pi", launchRecord: true },
246
- turnEnd: "adapter", budget: { account: "pi" },
246
+ turnEnd: "adapter", budget: { account: "pi", noQuotaReason: "on-prem backends have no provider quota" },
247
247
  cli: { command: "pi", startWords: form => ["pi", "--mode", form] }, display: palette("#c79936"),
248
248
  },
249
249
  {
@@ -235,7 +235,7 @@ export class LocalPeer extends BasePeer {
235
235
  ): Promise<string> {
236
236
  const { maxSteps = 30 } = this.opts;
237
237
  const turnSignal = this.abort!.signal;
238
- // claude-mem's observer is a cloud model: nothing of a PII turn is captured.
238
+ // Local capture preserves the host privacy fence: nothing of a PII turn is recorded.
239
239
  const capture = policy?.pii ? undefined : this.opts.capture;
240
240
  // Unknown gateway state is never permission to send PII.
241
241
  if (policy?.pii && !(await this.opts.omni.onCampus())) {
@@ -245,6 +245,7 @@ export class LocalPeer extends BasePeer {
245
245
  this.routePii = !!policy?.pii;
246
246
  const ctx = this.toolContext(envs, turnSignal, !!policy?.pii, reply);
247
247
  let usedTools = false;
248
+ let captureInvocation = 0;
248
249
  for (let step = 0; step < maxSteps; step++) {
249
250
  this.elide(msgs);
250
251
  const res = await this.call(msgs, policy, envs);
@@ -280,7 +281,7 @@ export class LocalPeer extends BasePeer {
280
281
  if (this.approvalStopReason) { this.abort?.abort(); throw new ApprovalWaitStop(this.approvalStopReason); }
281
282
  this.observeTool(call, output, policy, `${this.sessionId}.${turn}.${step}`);
282
283
  // Hub tool arguments/results are not capture evidence; Tasks owns its screened memory writes.
283
- if (name !== "hub_send" && !TASK_TOOL_NAMES.has(name) && !CONDUCTOR_TOOL_NAMES.has(name)) capture?.observe({ tool: call.function.name, args: call.function.arguments, output, id: call.id, paths: touchedPaths(call.function.name, safeParse(call.function.arguments)) });
284
+ if (name !== "hub_send" && !TASK_TOOL_NAMES.has(name) && !CONDUCTOR_TOOL_NAMES.has(name)) capture?.observe({ tool: call.function.name, args: call.function.arguments, output, id: call.id, sourceId: `${this.sessionId}.${turn}.${step}.${captureInvocation++}`, paths: touchedPaths(call.function.name, safeParse(call.function.arguments)) });
284
285
  }
285
286
  } finally { if (results.length) this.routes.observeResults(res.routeDecision, res.message, results); }
286
287
  }
@@ -293,7 +294,7 @@ export class LocalPeer extends BasePeer {
293
294
  cwd: this.opts.cwd,
294
295
  deny: this.opts.tools.deny,
295
296
  readOnly: this.opts.tools.readOnly,
296
- permit: async (title, tool, _signal, canonicalTarget, _observe, originalPath) => {
297
+ permit: async (title, tool, _signal, canonicalTarget, _observe, originalPath, input) => {
297
298
  if (turnSignal.aborted) return "aborted";
298
299
  const generation = this.turn;
299
300
  let observed = false;
@@ -313,7 +314,7 @@ export class LocalPeer extends BasePeer {
313
314
  const finish = (decision: ToolApproval) => { turnSignal.removeEventListener("abort", onAbort); resolve(decision); };
314
315
  const onAbort = () => finish("aborted");
315
316
  turnSignal.addEventListener("abort", onAbort, { once: true });
316
- this.opts.tools.permit(title, tool, turnSignal, canonicalTarget, observe, originalPath).then(finish, (error) => { turnSignal.removeEventListener("abort", onAbort); reject(error); });
317
+ this.opts.tools.permit(title, tool, turnSignal, canonicalTarget, observe, originalPath, input).then(finish, (error) => { turnSignal.removeEventListener("abort", onAbort); reject(error); });
317
318
  });
318
319
  if (generation !== this.turn || (turnSignal.aborted && picked !== "expired")) return "aborted";
319
320
  // Legacy permit callbacks can represent a person's denial without provenance. A true
@@ -270,7 +270,7 @@ export interface AdapterDefinition {
270
270
  trace?: "route" | "backend";
271
271
  backendLabel?: string;
272
272
  };
273
- budget?: { account: string; quotaExempt?: boolean; instrumented?: boolean; reader?: QuotaReader; context?: ContextReader; checkpoint?: boolean; resetSoftTokensOnResume?: boolean };
273
+ budget?: { account: string; quotaExempt?: boolean; /** Why this peer has no provider quota, shown by `ahub budget` and the dashboard (#285 AC6). */ noQuotaReason?: string; instrumented?: boolean; reader?: QuotaReader; context?: ContextReader; checkpoint?: boolean; resetSoftTokensOnResume?: boolean };
274
274
  configuration?: {
275
275
  schema?(ports: NativeConfigValidators): Record<string, NativeConfigCheck>;
276
276
  routingSchema?(ports: NativeConfigValidators): Record<string, NativeConfigCheck>;
@@ -10,6 +10,7 @@ import type { SettingRow, SettingValue } from "../hub/settings.ts";
10
10
  import { symbolFields, overlapFields } from "./console-symbols.ts";
11
11
  import { choicePrompt, reviewChoiceFor, reviewHint, reviewRefusal, verdictGuide, verdictInput, type ReviewChoice, type Verdict } from "./console-review.ts";
12
12
  import { configFormPrompt, reduceConfigForm, type ConfigEditBody, type ConfigForm } from "./console-config.ts";
13
+ import { windowKind } from "../hub/window-kind.ts";
13
14
  import { USAGE_DROP, USAGE_HEAD, usageCells, usageDetail, usagePeers, usageTaskLines, type UsageState } from "./console-usage.ts";
14
15
 
15
16
  export interface Approval {
@@ -334,8 +335,25 @@ function taskFields(detail: Record<string, unknown>): [string, unknown][] {
334
335
  const at = fields.findIndex(([key]) => key === "plan");
335
336
  return at < 0 ? [...fields, ...block] : [...fields.slice(0, at + 1), ...block, ...fields.slice(at + 1)];
336
337
  }
337
- const detailLines = (detail: Detail, columns: number, now: number, task = false): Span[][] =>
338
- typeof detail === "string" ? wrap(detail, columns).map(text => [span(text)]) : fieldLines(task ? taskFields(detail) : Object.entries(detail), columns, now).flat();
338
+ /**
339
+ * The Peers panel's detail row carries the whole peer, `budget` included. Its windows read as one line each: `5h 23% resets in 10m, native, claude status line, measured 6s ago`,
340
+ * so a person sees what each number is and where it came from (#285 AC7). A window without a kind (an older hub) is classified like the CLI does.
341
+ */
342
+ export function budgetFields(budget: any, now: number): [string, unknown][] {
343
+ if (!budget || typeof budget !== "object") return [];
344
+ const windows = Array.isArray(budget.windows) ? budget.windows : [];
345
+ const lines = windows.map((w: any) => {
346
+ const stale = w.stale ? " (stale)" : w.superseded ? " (information only: a measurement outranks it)" : "";
347
+ return `${w.id} ${Math.round(Number(w.used) * 100)}%${typeof w.resetsAt === "number" ? ` resets ${relative(w.resetsAt, now)}` : ""}, ${windowKind({ id: String(w.id), source: typeof w.source === "string" ? w.source : "", kind: w.kind })}, ${typeof w.source === "string" ? w.source : "unknown source"}${typeof w.at === "number" ? `, measured ${relative(w.at, now)}` : ""}${stale}`;
348
+ });
349
+ return [["quota", lines.length ? lines : typeof budget.note === "string" ? budget.note : "no reading"], ...(lines.length && typeof budget.note === "string" ? [["quota note", budget.note] as [string, unknown]] : [])];
350
+ }
351
+ const detailLines = (detail: Detail, columns: number, now: number, task = false): Span[][] => {
352
+ if (typeof detail === "string") return wrap(detail, columns).map(text => [span(text)]);
353
+ const { budget, ...rest } = detail as Record<string, unknown>;
354
+ const entries: [string, unknown][] = task ? taskFields(detail) : "budget" in detail && !task ? [...Object.entries(rest), ...budgetFields(budget, now)] : Object.entries(detail);
355
+ return fieldLines(entries, columns, now).flat();
356
+ };
339
357
  const allowOptions = (a: Approval) => a.options.filter(o => o.kind.startsWith("allow"));
340
358
  /** A pushed request as the console keeps it: every agent-supplied field a string, so no draw can throw on one. */
341
359
  export function approvalFrom(msg: any): Approval {
@@ -40,8 +40,9 @@ export function classifySymbols(task: any): SymbolBlock {
40
40
  export function symbolFields(task: any): [string, unknown][] {
41
41
  // Only a task as `task_show` shapes it (a numeric id and a state): any other detail an agent or a test hands the Tasks panel keeps its own layout.
42
42
  if (!task || typeof task.id !== "number" || typeof task.state !== "string" || isPrivate(task)) return [];
43
+ const related: [string, unknown][] = task.relatedMemory ? [["related notes", task.relatedMemory.state === "known" && Number.isSafeInteger(task.relatedMemory.count) && task.relatedMemory.count >= 0 ? task.relatedMemory.count : "unknown"]] : [];
43
44
  const block = classifySymbols(task);
44
- if (block.state === "unknown") return [["symbols", "changed symbols: unknown (no code facts)"]];
45
+ if (block.state === "unknown") return [...related, ["symbols", "changed symbols: unknown (no code facts)"]];
45
46
  const items: Record<string, string>[] = [
46
47
  ...block.matching.map(s => ({ matching: s.name, in: s.path })), ...block.unplanned.map(s => ({ unplanned: s.name, in: s.path })), ...block.untouched.map(name => ({ untouched: name })),
47
48
  ];
@@ -49,7 +50,7 @@ export function symbolFields(task: any): [string, unknown][] {
49
50
  const note = [`changed symbols: ${block.matching.length} matching, ${block.unplanned.length} unplanned, ${block.untouched.length} untouched${block.head ? ` at ${block.head}` : ""}`,
50
51
  block.complete ? "" : "incomplete: some changed symbols were not listed, so an untouched one may have changed",
51
52
  "advisory: deleted symbols are not reported"].filter(Boolean).join("; ");
52
- return [["symbols", shown.length ? shown : "none observed"], ["symbols note", items.length > shown.length ? `${note}; ${items.length - shown.length} more not shown` : note]];
53
+ return [...related, ["symbols", shown.length ? shown : "none observed"], ["symbols note", items.length > shown.length ? `${note}; ${items.length - shown.length} more not shown` : note]];
53
54
  }
54
55
 
55
56
 
@@ -1,3 +1,4 @@
1
+ import { windowKind, type WindowKind } from "../hub/window-kind.ts";
1
2
  import type { Counter, LimitsResult, UsageResult, UsageRow } from "../hub/usage-rollup.ts";
2
3
 
3
4
  // #683 U3: what the console's Usage panel shows, as plain strings. No terminal code and no state import here: console-state.ts renders it.
@@ -32,7 +33,7 @@ export function usagePeers(usage: UsageState | undefined, budget: Record<string,
32
33
  return [...ids].sort().map(id => ({ id }));
33
34
  }
34
35
 
35
- interface Budget { windows?: { id: string; used: number; resetsAt?: number }[]; paused?: { resetsAt: number } }
36
+ interface Budget { windows?: { id: string; used: number; resetsAt?: number; kind?: string; source?: string }[]; paused?: { resetsAt: number }; note?: string }
36
37
  const windowText = (w: { id: string; used: number; resetsAt?: number }, rel: (at: number) => string) =>
37
38
  `${w.id} ${Math.round(w.used * 100)}%${typeof w.resetsAt === "number" ? ` ${rel(w.resetsAt)}` : ""}`;
38
39
  const lastResets = (limits: LimitsResult | undefined, peer: string, count: number) =>
@@ -66,7 +67,9 @@ export function usageDetail(usage: UsageState | undefined, peer: string, budget:
66
67
  const resets = lastResets(usage?.limits, peer, 3).map(h => `${h.window} ${rel(Date.parse(h.at))}`);
67
68
  const out: Record<string, string> = {
68
69
  peer,
69
- quota: budget?.windows?.length ? budget.windows.map(w => windowText(w, rel)).join(", ") : "no reading",
70
+ quota: budget?.windows?.length ? budget.windows.map(w => windowText(w, rel)).join(", ") : budget?.note ?? "no reading",
71
+ // What each number is and where it came from (#285 AC7), in its own field so the quota line keeps its shape.
72
+ ...(budget?.windows?.length ? { "quota sources": budget.windows.map(w => `${w.id} ${windowKind({ id: w.id, source: w.source, kind: w.kind as WindowKind | undefined })}${w.source ? `, ${w.source}` : ""}`).join("; ") } : {}),
70
73
  pause: budget?.paused ? `budget pause, resumes ${rel(budget.paused.resetsAt)}` : "none",
71
74
  "last resets": resets.length ? resets.join(", ") : "none recorded",
72
75
  today: splitText(rowOf(usage?.today, peer)),
@@ -226,7 +226,11 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
226
226
  state.recoveryNeed = status.status.recovery ? { id: status.status.recovery.operationId, running: true } : undefined;
227
227
  }
228
228
  if (syncPermissionDefaults(state, status.status?.permissionDefaults) && state.confirm?.type === "permission_default") stream({ text: permissionDefaultText(state.confirm), kind: "permission_default", peer: state.confirm.peer, tone: "attention" });
229
- if (budget.budget) state.budget = budget.budget;
229
+ if (budget.budget) {
230
+ // #285: the hub's note per peer (no quota, nothing read yet) rides next to the windows, so the peer detail says why a number is missing.
231
+ const notes = new Map<string, any>((Array.isArray(budget.peers) ? budget.peers : []).map((n: any) => [String(n?.peer), n] as const));
232
+ state.budget = Object.fromEntries([...new Set([...Object.keys(budget.budget), ...notes.keys()])].map(peer => [peer, { windows: [], ...budget.budget[peer], ...(notes.get(peer)?.note ? { note: String(notes.get(peer).note) } : {}) }]));
233
+ }
230
234
  if (usageReplies) {
231
235
  const [today, week, life, limits, task] = usageReplies, failed = usageReplies.find(reply => reply?.ok === false);
232
236
  if (failed || !today?.usage || !week?.usage || !life?.usage || !limits?.limits) state.usage = { ...state.usage, problem: String(failed?.error ?? "usage is unavailable") };
package/src/cli/help.ts CHANGED
@@ -40,7 +40,8 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
40
40
  ["ahub status [--json] [--full] [--color=auto|always|never]", "the hub, its peers, model backends and task counts"],
41
41
  ["ahub status --all [--json] [--color=auto|always|never]", "show every registered project"],
42
42
  ["ahub logs [-f]", "the last 100 lines of hub.log; -f shows the last 10 and follows it"],
43
- ["ahub doctor [--json] [--full] [--color=auto|always|never]", "check the tools, daemon, plugin, gateway, models and memory worker this project uses"],
43
+ ["ahub doctor [--json] [--full] [--color=auto|always|never]", "check the tools, daemon, plugin, gateway, models and memory library this project uses"],
44
+ ["ahub doctor --stale-anchors [--json]", "read-only bounded report of public memory symbol anchors at current HEAD; private or unavailable evidence stays unknown"],
44
45
  ["ahub doctor --orphans [--kill] [--json] [--color=auto|always|never]", "list registrations whose project root is gone; --kill stops their daemons (SIGTERM, then SIGKILL) only after the process identity checks out"],
45
46
  ["ahub agents [--json]", "show each gated agent CLI's installed version and verdict (verified, in-line, trusted, refused or unknown)"],
46
47
  ["ahub agents trust <id> <version> [--revoke]", "accept an untested agent CLI version on this machine (person only; runtime proofs still run)"],
@@ -84,14 +85,15 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
84
85
  ["ahub board import github --label <label> [--milestone <milestone>] [--repo <owner/repo>]", "person only: read bounded open issues into deduplicated Backlog; never writes GitHub"],
85
86
  ["ahub board [state | --ready] [--columns] [--release <name> | --release-id <id>] [--json] [--full] [--color=auto|always|never]", "the task board, one line per task at a terminal width; --full: whole titles and SDLC advice; --ready: proposed tasks with nothing left to wait for"],
86
87
  ["ahub task propose [--class <c> | <class>] <title...> [--owner <peer>] [--path <p>]... [--after <id>]... [--urgent] [--backlog] [--detail <text>]", "put a task on the board; a first word that names a class is taken as the class; while a task named by --after is not approved yet, --owner reserves the task for that peer, offered to it first once it is ready; otherwise --owner assigns it now"],
87
- ["ahub backlog list [--json] [--full] [--color=auto|always|never] | add <class> <title...> | plan <id> <revision> <fields-json> <reason...> | pin <id> <revision> true|false <reason...> | undo <id> <revision>", "person only: stage and plan work, preserve pins/frozen scope and refuse stale edits; list is a table by rank (--json: the raw listing)"],
88
+ ["ahub backlog list [--json] [--full] [--color=auto|always|never] | add <class> <title...> | plan <id> <revision> <fields-json> <reason...> | pin <id> <revision> true|false <reason...> | undo <id> <revision>", "person only: plan work with a reason; {} records a note-only revision, fields.sdlc sets backlog repo/issue identity and clears old spec acceptance; preserve pins/frozen scope and refuse stale edits; list is by rank (--json: raw listing)"],
88
89
  ["ahub release new <name> <goal> <order> | list | show <name> | plan <name> <revision> <dates-json> | activate|freeze|ship|drop <name> <revision>", "person only: planned UTC millisecond dates and phased scope; ship records a time after approval, never tags or publishes"],
89
90
  ["ahub backlog split <id> <revision> <parts-json> <reason...>", "person only: stage2..8 child prerequisites; original task retains integration, undo refuses used or changed children"],
90
91
  ["ahub backlog pull-ready <id> <revision> <reason...>", "person only: reserve an eligible WIP slot before routing; pending unowned work can be undone"],
91
92
  ["ahub backlog pull|return <id>", "person only: pull backlog into To Do or return ownerless proposed work; --backlog on task propose stages unoffered work"],
93
+ ["ahub deliverables [--task <id>] [--approved] [--check] [--json] [--color=auto|always|never]", "person only: what each task delivered and a reviewer approved, by id, hash and size (a path only where the PII screen allows); --check measures the files now and marks one changed since review"],
92
94
  ["ahub task show|escalate <id>", "full task with history (PII text included) / hand it to the next peer in escalate_to"],
93
95
  ["ahub task role <id> <role|none>", "person only: change bounded catalog guidance on an open task; routing and authority are unchanged"],
94
- ["ahub task assign <id> <peer> [--role owner|reviewer] [--revision N]", "name the owner (default) or reviewer; a task that still waits reserves its owner; a peer that cannot take it is refused with the reason; --revision refuses stale console actions"],
96
+ ["ahub task assign <id> <peer> [--role owner|reviewer|planner] [--revision N]", "name the owner (default), reviewer or person-only planner; a task that still waits reserves its owner; a peer that cannot take it is refused with the reason; --revision refuses stale console actions"],
95
97
  ["ahub task unassign <id> [--role owner|reviewer] [--revision N]", "person only: remove the owner/reservation or reviewer without routing a replacement or stopping native work"],
96
98
  ["ahub task cancel <id> --reason <text> [--superseded-by <ref>] [--revision N]", "person only: close obsolete work with a reason; retain its history without stopping native work or releasing dependents"],
97
99
  ["ahub task label <id> ok|regressed|reverted|incomplete|wrong|abandoned [--created <time>]", "your later verdict on an approved task, kept in the research records (research must be on)"],
@@ -103,7 +105,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
103
105
  ["ahub remember <text...>", "save a note to the memory all agents share"],
104
106
  ]],
105
107
  ["Budget", [
106
- ["ahub budget [--json] [--full] [--color=auto|always|never]", "quota windows per peer, and who is paused until when"],
108
+ ["ahub budget [--json] [--full] [--color=auto|always|never]", "quota windows per peer with each number's kind (native, count, manual) and source, why a peer shows no number, and who is paused until when"],
107
109
  ["ahub budget set <peer> <0..1> [--resets-in 30m] [--window 5h|week]", "feed a reading by hand (also: test the relay)"],
108
110
  ["ahub budget resume <peer>", "override a budget pause; readings are ignored for that peer until the window resets"],
109
111
  ["ahub budget execution status [id] [--json] [--color=auto|always|never]", "read execution counters and limits"],
@@ -129,7 +131,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
129
131
  ["ahub bench export [--format jsonl|csv]", "benchmark records for outside analysis: ids, outcomes and counts, never suite text"],
130
132
  ["ahub check-path <file> [--peer <id>]", "other owners' open tasks that claim or changed a file"],
131
133
  ["ahub memory [list] [--tier short|long] [--kind <kind>] [--peer <p>] [--machine <id>] [--since <iso>] [--limit N] [--json]", "the hub's own memory for this project: decisions, contracts, fails and findings, short- and long-term (docs/memory.md)"],
132
- ["ahub memory search <words> | show <id> | graph <id|node> [--hops 1|2] [--json]", "find entries, read one with its provenance, or list its graph neighbourhood"],
134
+ ["ahub memory search <words> | show <id> | graph <id|node> [--hops 1|2] [--history | --as-of <iso>] [--json]", "find entries, read one with its provenance, or list its graph neighbourhood; superseded and expired entries are hidden unless a person asks for --history or --as-of"],
133
135
  ["ahub memory promote|demote|forget <id> | supersede <old> <new> | rebuild", "a person's changes to memory: move an entry between tiers, forget it, mark it superseded, or rebuild the index from the logs"],
134
136
  ["ahub memory sync [--remote <private git url>] | machine [--label <name>]", "sync hub memory with your other machines through a private git remote (a person's), or show or name this machine"],
135
137
  ["ahub memory import claude-mem [--db <path>] [--limit N] [--restart] | dedupe [--apply] | suggestions | confirm|reject <id>", "copy the hub's notes from claude-mem into hub memory (a person's); review the model's suggested edges"],
@@ -17,7 +17,7 @@ export function liveTestRefusal(command: string, args: readonly string[], contex
17
17
  if (command === "sdlc" && (args[0] === "authorize-release" || args[0] === "plan-review" || args[0] === "release-assign" || args[0] === "train" && args[1] !== "report")) return true;
18
18
  if (args.some(arg => arg === "--as-user" || arg.startsWith("--as-user="))) return true;
19
19
  const sub = command === "recovery" ? recoveryAction(args) : args.find(arg => arg !== "--json");
20
- return ["task", "role", "goal"].includes(command)
20
+ return ["task", "role", "goal", "deliverables"].includes(command)
21
21
  || (command === "backlog" && sub !== "list") || (command === "release" && !["list", "show"].includes(sub ?? ""))
22
22
  || (command === "memory" && sub !== "graph")
23
23
  || (command === "recovery" && (sub === "reattach" || sub === "dispose" && context.repairedFresh === true))
@@ -68,6 +68,8 @@ export function classifyPeerCommand(command: string, args: readonly string[], ca
68
68
  if (["assign", "escalate"].includes(args[0] ?? "")) return "conductor";
69
69
  return "console";
70
70
  }
71
+ // #317: the deliverable list is the person's view of the board (it can name files); an agent reads its own tasks through hub_task_show.
72
+ if (command === "deliverables") return "console";
71
73
  if (command === "route") return args[0] === "explain" ? "allowed" : "console";
72
74
  // #691: reading agent CLI versions is harmless; trusting an untested version on this machine is a person's.
73
75
  if (command === "agents") return args.every(arg => arg === "--json") ? "allowed" : "console";
package/src/cli/main.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  #!/usr/bin/env bun
2
+ import type { DeliverableRow } from "../hub/tasks.ts";
3
+ import { kimiSourceRow } from "../hub/budget-notes.ts";
2
4
  import { hubGitSync, hubGitEnv } from "../hub/git.ts";
3
5
  import { parseReleaseGuardArgv, parseReleasePushUpdates } from "./sdlc-release-guard.ts";
4
6
  import { validReleaseVersion } from "../hub/sdlc-release-authorization.ts";
@@ -46,7 +48,6 @@ import { inspectProject, startProject, stopProject, runProjectDaemon } from "../
46
48
  import { processLiveness, processSignature } from "../pi/process-signature.ts";
47
49
  import { openManager, startManager, stopManager } from "../hub/manager.ts";
48
50
  import { OmniRoute } from "../omniroute/client.ts";
49
- import { MemoryClient } from "../memory/client.ts";
50
51
  import { init, planInit } from "./init.ts";
51
52
  import { launcherPreview } from "./preview.ts";
52
53
  import { instanceLaunchArguments, requirePeerInstances, requireActiveInstance, cleanupStaleClaudeSettings, cleanupClaudeSettings, buildLaunch, buildKimiTuiLaunch, claudeObservationHooks, nativeLaunchEnv, recordClaudeLaunch, runClaudeLaunch, type ClaudeLaunchRecord, UNATTENDED_WARNING } from "./launch.ts";
@@ -75,8 +76,9 @@ import { parseSince, readEvents } from "../hub/events.ts";
75
76
  import { armRuns, benchCompare, benchCsv, benchReport, formatCompare, formatReport as formatBenchReport, formatRuns, MEMORY_MODES, readRuns, runSummary, sameSuite, type MemoryMode } from "../hub/bench.ts";
76
77
  import { benchPreflight, defaultIo, runBench } from "./bench.ts";
77
78
  import { ADDONS, isAddon, type AddonName } from "../hub/tooling.ts";
78
- import { goalCommand, memoryCommand } from "./memory.ts";
79
- import { selectedMemoryProfile } from "../hub/memory-profile.ts";
79
+ import { goalCommand, memoryCommand, memoryStaleAnchorsCommand } from "./memory.ts";
80
+ import { formatMemoryStaleAnchors } from "../hub/memory-stale-anchors.ts";
81
+ import { AGENT_MEM_VERSION, assertAgentMemVersion, selectedMemoryProfile } from "../hub/memory-profile.ts";
80
82
  import { checkMemoryProfiles, formatMemoryProfileCheck } from "../hub/memory-profile-check.ts";
81
83
  import { addonReport, appendRecords, formatAddons, formatResearch, LABELS, labelTarget, RESEARCH_SCHEMA, projectKey, readStores, researchReport, taskRecords, toCsv, type Label, type TaskRecord } from "../hub/research.ts";
82
84
  import { summarize, summarizeByTask } from "../hub/report.ts";
@@ -1140,7 +1142,7 @@ const commands: Record<string, () => Promise<void> | void> = {
1140
1142
  const res = await hub.request({ t: "budget", ...(set ? { set } : {}) });
1141
1143
  hub.close();
1142
1144
  if (!res.ok) fail(res.error);
1143
- const data = { budget: res.budget, gate: res.gate };
1145
+ const data = { budget: res.budget, ...(res.peers ? { peers: res.peers } : {}), gate: res.gate };
1144
1146
  if (options.json) return console.log(JSON.stringify(data, null, 2));
1145
1147
  printOutput(renderBudget(data, options.columns, Date.now(), options.full), options.color);
1146
1148
  },
@@ -1195,6 +1197,27 @@ const commands: Record<string, () => Promise<void> | void> = {
1195
1197
  console.log(await taskOp("release_change", { name, revision: Number(revision), fields }));
1196
1198
  },
1197
1199
 
1200
+ deliverables: async () => {
1201
+ // #317: what each task delivered and what a reviewer approved, by id, hash and size (a path only where the PII screen allows it). Person only: the daemon refuses an agent.
1202
+ const options = outputOptions();
1203
+ const { one, rest } = takeFlags(args, ["--task"], []);
1204
+ const approved = rest.includes("--approved"), check = rest.includes("--check");
1205
+ const extra = rest.filter(arg => !["--json", "--full", "--approved", "--check"].includes(arg) && !arg.startsWith("--color="));
1206
+ const task = one["--task"] === undefined ? undefined : Number(one["--task"]);
1207
+ // takeFlags keeps the last of a repeated flag: for a command that selects a task that would silently drop one of two named tasks.
1208
+ if (args.filter(arg => arg === "--task").length > 1) fail("usage: ahub deliverables [--task <id>] [--approved] [--check] [--json] [--color=auto|always|never] (--task takes one id)");
1209
+ if (extra.length || (one["--task"] !== undefined && !/^[1-9]\d*$/.test(one["--task"])) || (task !== undefined && !Number.isSafeInteger(task))) fail("usage: ahub deliverables [--task <id>] [--approved] [--check] [--json] [--color=auto|always|never]");
1210
+ const rows = JSON.parse(await taskOp("deliverables", { ...(task === undefined ? {} : { task }), ...(approved ? { approved: true } : {}), ...(check ? { check: true } : {}) })) as DeliverableRow[];
1211
+ if (options.json) return console.log(JSON.stringify(rows, null, 2));
1212
+ if (!rows.length) return console.log(task === undefined ? `no ${approved ? "approved " : ""}deliverables on the board` : `task #${task} has no ${approved ? "approved " : ""}deliverables`);
1213
+ for (const row of rows) {
1214
+ const flag = row.checked ? ({ changed: " (changed since review)", missing: " (a file is missing)", unknown: " (could not be measured)", unchanged: " (unchanged)" } as const)[row.result ?? "unknown"] : "";
1215
+ console.log(`${row.title} [${row.state}${flag}]`); // `title` is the hub's publicTitle: it already starts with `#<id>`
1216
+ for (const file of row.files) console.log(` ${file.id} ${file.path ?? "(private)"} ${file.format}${file.version ? ` ${file.version}` : ""} ${file.bytes} B sha256 ${file.sha256}${file.state ? ` ${file.state}` : ""}`);
1217
+ }
1218
+ if (!check) console.log("(hashes are the board's records; add --check to measure the files now)");
1219
+ },
1220
+
1198
1221
  board: async () => {
1199
1222
  if (args[0] === "import") {
1200
1223
  if (args[1] !== "github" || (args.length - 2) % 2 !== 0) fail("usage: ahub board import github --label <label> [--milestone <milestone>] [--repo <owner/repo>]");
@@ -1380,8 +1403,8 @@ const commands: Record<string, () => Promise<void> | void> = {
1380
1403
  if (sub === "assign") {
1381
1404
  const { one, rest: positional } = takeFlags(rest, ["--role", "--revision"], []);
1382
1405
  const role = one["--role"] ?? "owner", rawRevision = one["--revision"], revision = rawRevision === undefined ? undefined : Number(rawRevision);
1383
- 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]");
1384
- return console.log(await taskOp(revision === undefined ? "task_assign" : "task_assign_checked", { id: positional[0], peer: positional[1], ...(role === "reviewer" ? { role } : {}), ...(revision === undefined ? {} : { revision }) }));
1406
+ if (positional.length !== 2 || !["owner", "reviewer", "planner"].includes(role) || (rawRevision !== undefined && (!/^\d+$/.test(rawRevision) || !Number.isSafeInteger(revision)))) fail("usage: ahub task assign <id> <peer> [--role owner|reviewer|planner] [--revision N]");
1407
+ return console.log(await taskOp(revision === undefined ? "task_assign" : "task_assign_checked", { id: positional[0], peer: positional[1], ...(role !== "owner" ? { role } : {}), ...(revision === undefined ? {} : { revision }) }));
1385
1408
  }
1386
1409
  if (sub === "unassign") {
1387
1410
  const { one, rest: positional } = takeFlags(rest, ["--role", "--revision"], []);
@@ -2101,6 +2124,12 @@ const commands: Record<string, () => Promise<void> | void> = {
2101
2124
 
2102
2125
  doctor: async () => {
2103
2126
  const options = outputOptions();
2127
+ if (args.includes("--stale-anchors")) {
2128
+ if (args.some(arg => arg !== "--stale-anchors" && arg !== "--json")) fail("usage: ahub doctor --stale-anchors [--json]");
2129
+ const report = await memoryStaleAnchorsCommand(cwd, { stateDir });
2130
+ console.log(options.json ? JSON.stringify(report, null, 2) : formatMemoryStaleAnchors(report));
2131
+ return;
2132
+ }
2104
2133
  if (args.includes("--orphans")) return orphanDoctor(args.includes("--kill"), options);
2105
2134
  const version = (bin: string) => {
2106
2135
  const res = spawnSync(bin, ["--version"], { encoding: "utf8" });
@@ -2128,6 +2157,10 @@ const commands: Record<string, () => Promise<void> | void> = {
2128
2157
  const { status } = await hub.request({ t: "status" });
2129
2158
  const pending = Object.values(status.peers as Record<string, PeerRow>).reduce((n, peer) => n + (peer.needsReview ?? 0), 0);
2130
2159
  row(!status.deliveryError && pending === 0, "delivery recovery", status.deliveryError ?? (pending ? `${pending} deliveries need review; run ahub queue list` : "journal healthy; no deliveries need review"));
2160
+ // #285 AC10: where Kimi's number comes from, and whether the hub can trust it right now. An older hub answers without `kimiSource`: no row, not a failure.
2161
+ const quota = await hub.request({ t: "budget" });
2162
+ const kimiRow = quota.ok ? kimiSourceRow(quota.kimiSource) : undefined;
2163
+ if (kimiRow) row(kimiRow.ok, "kimi usage source", kimiRow.detail);
2131
2164
  const observed = await hub.request({ t: "recovery", op: "inspect", expectedInstanceId: status.instanceId });
2132
2165
  for (const peer of Object.values(observed.recovery?.peers ?? {}) as { id: string; state: string; sessionId?: string; threadId?: string }[]) {
2133
2166
  const check = nativeDoctorRecoveryIdentity(peer);
@@ -2182,9 +2215,9 @@ const commands: Record<string, () => Promise<void> | void> = {
2182
2215
  row(mlx.state === "ready" || mlx.state === "stopped", "pi mlx", `${mlx.state}${mlx.model ? ` (${mlx.model})` : ""}${mlx.lastError ? `: ${mlx.lastError}` : ""}`);
2183
2216
  }
2184
2217
 
2185
- const memory = new MemoryClient();
2186
- const mem = await memory.health();
2187
- row(mem.ok, "memory worker", mem.ok ? `claude-mem ${mem.version ?? ""} at ${memory.url}` : `unavailable at ${memory.url} (the hub works without it)`);
2218
+ let memoryLibraryReady = false;
2219
+ try { assertAgentMemVersion({ name: "@staix/agent-mem", version: AGENT_MEM_VERSION }); memoryLibraryReady = true; } catch { /* report the failed exact pin without opening a store */ }
2220
+ row(memoryLibraryReady, "memory library", memoryLibraryReady ? `amem ${AGENT_MEM_VERSION}; in-process, no claude-mem worker` : "installed amem does not match the exact dependency pin");
2188
2221
  const bridge = spawnSync("dot", ["ai", "memory", "status"], { encoding: "utf8" });
2189
2222
  const out = bridge.error ? "" : bridge.stdout.replace(/\x1b\[[0-9;]*m/g, "");
2190
2223
  for (const tool of DOCTOR_MEMORY_CAPTURE_PEERS) {
@@ -2260,7 +2293,8 @@ async function runConductorCommand(): Promise<void> {
2260
2293
  op = args[0] === "assign" ? "hub_task_assign" : "hub_task_escalate";
2261
2294
  const { one, rest: positional } = takeFlags(args.slice(1), args[0] === "assign" ? ["--role"] : [], []);
2262
2295
  const role = one["--role"] ?? "owner";
2263
- if (!positional[0] || positional.length !== (args[0] === "assign" ? 2 : 1) || !["owner", "reviewer"].includes(role)) fail("usage: ahub task assign <id> <peer> [--role owner|reviewer] | escalate <id>");
2296
+ if (!positional[0] || positional.length !== (args[0] === "assign" ? 2 : 1) || !["owner", "reviewer", "planner"].includes(role)) fail("usage: ahub task assign <id> <peer> [--role owner|reviewer|planner] | escalate <id>");
2297
+ if (role === "planner") fail("only the person may name a task planner");
2264
2298
  input = { id: Number(positional[0]), ...(args[0] === "assign" ? { peer: positional[1], ...(role === "reviewer" ? { role } : {}) } : {}) };
2265
2299
  } else if (cmd === "pause" || cmd === "resume") {
2266
2300
  if (args.length !== 1) fail(`usage: ahub ${cmd} <peer>`);