@staix/agent-hub 0.14.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 (120) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +23 -9
  3. package/docs/agent-notes/README.md +8 -0
  4. package/docs/agent-notes/adapters.md +21 -4
  5. package/docs/agent-notes/budget.md +6 -4
  6. package/docs/agent-notes/bus.md +1 -0
  7. package/docs/agent-notes/daemon.md +20 -9
  8. package/docs/agent-notes/local-worker.md +2 -2
  9. package/docs/agent-notes/tasks.md +21 -4
  10. package/docs/agent-notes/tests.md +6 -3
  11. package/docs/dogfood.md +19 -5
  12. package/docs/events.md +21 -7
  13. package/docs/memory.md +78 -10
  14. package/docs/operations.md +197 -39
  15. package/docs/profiles.md +1 -0
  16. package/docs/quickstart.md +16 -6
  17. package/docs/sdlc.md +17 -0
  18. package/docs/security.md +110 -21
  19. package/docs/smoke.md +7 -3
  20. package/docs/specs/2026-09-19-agent-hub-design.md +156 -16
  21. package/docs/verification/2026-10-23-0.15.0.md +80 -0
  22. package/docs/verification/2026-10-23-daemon-responsiveness.md +44 -0
  23. package/docs/verification/2026-10-23-headless-claude-permissions.md +47 -0
  24. package/docs/verification/2026-10-23-terminal-claude-execution-root.md +41 -0
  25. package/docs/verification/2026-10-24-always-ask-adapters.md +52 -0
  26. package/docs/verification/2026-10-24-amem-runtime.md +69 -0
  27. package/docs/verification/2026-10-24-codex-0164-reviewer-isolation.md +55 -0
  28. package/docs/verification/2026-10-24-delivery-holds-ac8.md +264 -0
  29. package/docs/verification/2026-10-24-hardcoded-values.md +2108 -0
  30. package/docs/verification/2026-10-25-0.16.0.md +82 -0
  31. package/docs/verified.json +40 -19
  32. package/package.json +2 -2
  33. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  34. package/plugins/agent-hub/server.js +3109 -2046
  35. package/src/adapters/acp.ts +46 -7
  36. package/src/adapters/claude-channel.ts +26 -4
  37. package/src/adapters/claude-implementer.ts +243 -0
  38. package/src/adapters/claude-print-child.ts +90 -0
  39. package/src/adapters/claude-task-sessions.ts +80 -0
  40. package/src/adapters/codex-appserver.ts +69 -1
  41. package/src/adapters/compat.ts +6 -2
  42. package/src/adapters/conformance/enrollment.ts +2 -1
  43. package/src/adapters/conformance/fakes/acp-server.ts +3 -1
  44. package/src/adapters/definitions/builtin-metadata.ts +17 -5
  45. package/src/adapters/definitions/native-channel-state.ts +1 -1
  46. package/src/adapters/definitions/native-cli-entry-claude.ts +13 -8
  47. package/src/adapters/definitions/native-cli-entry-types.ts +2 -0
  48. package/src/adapters/definitions/native-factories.ts +8 -0
  49. package/src/adapters/kimi-wire-usage.ts +303 -0
  50. package/src/adapters/local-worker.ts +5 -4
  51. package/src/adapters/registry-types.ts +8 -4
  52. package/src/adapters/registry.ts +14 -2
  53. package/src/adapters/reviewer-print.ts +30 -53
  54. package/src/cli/console-config.ts +123 -0
  55. package/src/cli/console-state.ts +48 -3
  56. package/src/cli/console-symbols.ts +3 -2
  57. package/src/cli/console-usage.ts +5 -2
  58. package/src/cli/console.ts +30 -2
  59. package/src/cli/facts-hook.ts +9 -5
  60. package/src/cli/help.ts +10 -7
  61. package/src/cli/identity.ts +3 -1
  62. package/src/cli/launch.ts +10 -4
  63. package/src/cli/main.ts +91 -19
  64. package/src/cli/memory.ts +119 -7
  65. package/src/cli/output.ts +40 -4
  66. package/src/cli/reset.ts +1 -1
  67. package/src/cli/settings-edit.ts +89 -0
  68. package/src/cli/terminal-recovery.ts +1 -6
  69. package/src/hub/action-class.ts +211 -17
  70. package/src/hub/action-enforce.ts +90 -0
  71. package/src/hub/action-policy.ts +40 -17
  72. package/src/hub/ask.ts +16 -4
  73. package/src/hub/board.ts +110 -31
  74. package/src/hub/budget-notes.ts +65 -0
  75. package/src/hub/budget.ts +44 -7
  76. package/src/hub/bus.ts +6 -6
  77. package/src/hub/claude-launch-proof.ts +33 -0
  78. package/src/hub/code-facts.ts +56 -16
  79. package/src/hub/conductor.ts +5 -2
  80. package/src/hub/config-apply.ts +12 -0
  81. package/src/hub/config-edit-host.ts +173 -0
  82. package/src/hub/config-edit.ts +282 -0
  83. package/src/hub/config-tier.ts +89 -0
  84. package/src/hub/control-client.ts +24 -3
  85. package/src/hub/daemon.ts +614 -104
  86. package/src/hub/deliverable-files.ts +131 -0
  87. package/src/hub/deliverables.ts +125 -0
  88. package/src/hub/delivery-journal.ts +18 -2
  89. package/src/hub/envelope.ts +1 -1
  90. package/src/hub/events.ts +5 -3
  91. package/src/hub/hub-tools.ts +22 -6
  92. package/src/hub/instances.ts +7 -4
  93. package/src/hub/kimi-usage.ts +156 -0
  94. package/src/hub/lifecycle.ts +10 -5
  95. package/src/hub/manager.ts +1 -1
  96. package/src/hub/memory-code-head.ts +44 -0
  97. package/src/hub/memory-code-overlay.ts +119 -0
  98. package/src/hub/memory-plan-guard.ts +10 -1
  99. package/src/hub/memory-stale-anchors.ts +159 -0
  100. package/src/hub/memory-symbols.ts +74 -0
  101. package/src/hub/peers.ts +7 -0
  102. package/src/hub/permission-payload.ts +15 -0
  103. package/src/hub/planning.ts +10 -4
  104. package/src/hub/read-tools.ts +21 -3
  105. package/src/hub/routing.ts +118 -3
  106. package/src/hub/sdlc.ts +34 -0
  107. package/src/hub/settings.ts +18 -3
  108. package/src/hub/settled-roles.ts +15 -3
  109. package/src/hub/task-sweep.ts +25 -3
  110. package/src/hub/tasks.ts +551 -75
  111. package/src/hub/token-window.ts +46 -0
  112. package/src/hub/usage-rollup.ts +16 -6
  113. package/src/hub/window-kind.ts +13 -0
  114. package/src/hub/workflow.ts +16 -0
  115. package/src/local/tools.ts +5 -5
  116. package/src/memory/brief.ts +22 -12
  117. package/src/memory/capture.ts +26 -40
  118. package/src/memory/client.ts +137 -65
  119. package/src/memory/recall.ts +13 -22
  120. package/src/ui/index.html +215 -19
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";
@@ -18,6 +20,7 @@ import { matchesPolicy } from "../hub/path-policy.ts";
18
20
  import { projectedDiff } from "../hub/projected-diff.ts";
19
21
  import { TextProjections } from "../hub/text-projection.ts";
20
22
  import { terminalText } from "./console-state.ts";
23
+ import { parseSettingsEditArgs, runSettingsEdit, SETTINGS_EDIT_USAGE } from "./settings-edit.ts";
21
24
  import { petView, readPet, renderPet } from "./pet.ts";
22
25
  import { startPetWindow, closePetWindow } from "../pet/window.ts";
23
26
  import { loadPetConfig, savePetPalette } from "./pet-config.ts";
@@ -36,7 +39,7 @@ import { chmodSync, existsSync, readFileSync, renameSync, writeFileSync } from "
36
39
  import { randomUUID } from "node:crypto";
37
40
  import { homedir } from "node:os";
38
41
  import { basename, dirname, join, relative, resolve } from "node:path";
39
- import { ControlClient, readControl } from "../hub/control-client.ts";
42
+ import { retryLaunchConnect, ControlClient, readControl } from "../hub/control-client.ts";
40
43
  import { loadConfig } from "../hub/daemon.ts";
41
44
  import { factsHook } from "./facts-hook.ts";
42
45
  import { hubHome, projectContext, projectRoot, realPath } from "../hub/project.ts";
@@ -45,7 +48,6 @@ import { inspectProject, startProject, stopProject, runProjectDaemon } from "../
45
48
  import { processLiveness, processSignature } from "../pi/process-signature.ts";
46
49
  import { openManager, startManager, stopManager } from "../hub/manager.ts";
47
50
  import { OmniRoute } from "../omniroute/client.ts";
48
- import { MemoryClient } from "../memory/client.ts";
49
51
  import { init, planInit } from "./init.ts";
50
52
  import { launcherPreview } from "./preview.ts";
51
53
  import { instanceLaunchArguments, requirePeerInstances, requireActiveInstance, cleanupStaleClaudeSettings, cleanupClaudeSettings, buildLaunch, buildKimiTuiLaunch, claudeObservationHooks, nativeLaunchEnv, recordClaudeLaunch, runClaudeLaunch, type ClaudeLaunchRecord, UNATTENDED_WARNING } from "./launch.ts";
@@ -74,8 +76,9 @@ import { parseSince, readEvents } from "../hub/events.ts";
74
76
  import { armRuns, benchCompare, benchCsv, benchReport, formatCompare, formatReport as formatBenchReport, formatRuns, MEMORY_MODES, readRuns, runSummary, sameSuite, type MemoryMode } from "../hub/bench.ts";
75
77
  import { benchPreflight, defaultIo, runBench } from "./bench.ts";
76
78
  import { ADDONS, isAddon, type AddonName } from "../hub/tooling.ts";
77
- import { goalCommand, memoryCommand } from "./memory.ts";
78
- 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";
79
82
  import { checkMemoryProfiles, formatMemoryProfileCheck } from "../hub/memory-profile-check.ts";
80
83
  import { addonReport, appendRecords, formatAddons, formatResearch, LABELS, labelTarget, RESEARCH_SCHEMA, projectKey, readStores, researchReport, taskRecords, toCsv, type Label, type TaskRecord } from "../hub/research.ts";
81
84
  import { summarize, summarizeByTask } from "../hub/report.ts";
@@ -89,7 +92,7 @@ import { agentShellCause, classifyPeerCommand, cliCommandLabel, detectCliIdentit
89
92
  import { recordCliAudit, recordDelegatedCliAudit } from "./identity-audit.ts";
90
93
  import { runConsole } from "./console.ts";
91
94
  import { paint, permissionBoundary, resolveColor, wrap, type Span } from "./console-state.ts";
92
- import { outputWidth, renderRoster, renderStatus, renderBoard, renderBoardColumns, boardColumnModel, renderBudget, renderDoctor, renderProjects, renderQueue, renderTurns, renderOrphans, renderQueueShow, renderModelsStatus, renderExecutionBudgetStatus, renderReport, renderUsage, renderLimits, renderTotal, type DoctorCheck } from "./output.ts";
95
+ 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";
93
96
  import { renderHelp, configuredAcpHelp } from "./help.ts";
94
97
  import { renderTailEvent } from "./tail-render.ts";
95
98
  import { archiveProblem, archiveState, damagedState, failureText, MANIFEST, planReset, resetLines, resetRuntime, startState, type ResetPlan } from "./reset.ts";
@@ -274,13 +277,15 @@ function printOutput(lines: Span[][], color: boolean): void {
274
277
  }
275
278
  const unattendedEnv = process.env.AGENTHUB_UNATTENDED === "1";
276
279
  const lifecycle = { inspectProject, startProject, stopProject };
277
- const connect = async () => {
280
+ const connect = async (timeoutMs = 3000) => {
278
281
  assertDelegatedAuditConsumer();
279
282
  if (delegatedAuditHub && !delegatedConnectionTaken) { delegatedConnectionTaken = true; return delegatedAuditHub; }
280
283
  return ControlClient.connect(stateDir, identity.role === "tools" ? { role: "tools", peer: identity.peer, projectRoot: cwd }
281
- : { role: "console", projectRoot: cwd, ...(delegatedAuditHub ? { instanceId: delegatedAuditHub.instanceId } : {}) });
284
+ : { role: "console", projectRoot: cwd, ...(delegatedAuditHub ? { instanceId: delegatedAuditHub.instanceId } : {}) }, timeoutMs);
282
285
  };
283
286
 
287
+ const connectLaunch = () => retryLaunchConnect(connect, () => console.error("hub busy, retrying"));
288
+
284
289
  function registeredProject(): Project {
285
290
  const registry = new Registry();
286
291
  try { return registry.register(cwd, stateDir); }
@@ -943,7 +948,7 @@ const commands: Record<string, () => Promise<void> | void> = {
943
948
  // Preserve the original guard before native effects; previews remain read-only and precede this guard.
944
949
  if (entry.lifecycleAdmission && !args.includes("--print-command") && !args.includes("--dry-run")) assertLifecycleAvailable();
945
950
  const nativeConnect = async () => {
946
- const hub = await connect();
951
+ const hub = await connectLaunch();
947
952
  try { if (selectedInstance) { requirePeerInstances(hub.features); requireActiveInstance((await hub.request({ t: "status" })).status, selectedInstance); } return hub; }
948
953
  catch (error) { hub.close(); throw error; }
949
954
  };
@@ -955,7 +960,10 @@ const commands: Record<string, () => Promise<void> | void> = {
955
960
  projectConfig, connect: nativeConnect, fail,
956
961
  exec: (bin, argv, tool) => exec(bin, argv, tool, context.executionCwd),
957
962
  execWithEnv: (bin, argv, env) => execWithEnv(bin, argv, env, selectedInstance ?? entry.command, context.executionCwd), takeFlags,
958
- helpers: { launcherPreview: (tool, raw, projectRoot, state, unattended) => launcherPreview(tool, raw, projectRoot, state, unattended, selectedInstance), readControl, recordTerminalLaunch, prepareTerminalLaunch, registerTerminalLaunch, recoveryLock, processSignature,
963
+ helpers: { resolveExecutionCwd: peer => {
964
+ const current = readInstanceRoleConfig(cwd);
965
+ return resolveInstanceExecutionCwd(cwd, peer, current.instances, effectiveInstanceRoles(peer, current.instances, current.roles, current.catalog), current.catalog);
966
+ }, launcherPreview: (tool, raw, projectRoot, state, unattended) => launcherPreview(tool, raw, projectRoot, state, unattended, selectedInstance), readControl, recordTerminalLaunch, prepareTerminalLaunch, registerTerminalLaunch, recoveryLock, processSignature,
959
967
  buildLaunch, claudeObservationHooks, recordClaudeLaunch, cleanupStaleClaudeSettings, cleanupClaudeSettings,
960
968
  nativeLaunchEnv, runClaudeLaunch, childEnv, qwenSessionId, configureJcodeMcp, configureKimiMcp,
961
969
  buildKimiTuiLaunch, startModeOf },
@@ -1134,14 +1142,22 @@ const commands: Record<string, () => Promise<void> | void> = {
1134
1142
  const res = await hub.request({ t: "budget", ...(set ? { set } : {}) });
1135
1143
  hub.close();
1136
1144
  if (!res.ok) fail(res.error);
1137
- const data = { budget: res.budget, gate: res.gate };
1145
+ const data = { budget: res.budget, ...(res.peers ? { peers: res.peers } : {}), gate: res.gate };
1138
1146
  if (options.json) return console.log(JSON.stringify(data, null, 2));
1139
1147
  printOutput(renderBudget(data, options.columns, Date.now(), options.full), options.color);
1140
1148
  },
1141
1149
 
1142
1150
  backlog: async () => {
1143
1151
  const [sub, id, revision, value, ...reason] = args;
1144
- if (sub === "list" && args.length === 1) return console.log(await taskOp("hub_backlog_list", {}));
1152
+ if (sub === "list" && outputArguments().length === 1) {
1153
+ // #399: a table by default; --json prints the hub's listing unchanged, and a refusal is printed as it came.
1154
+ const options = outputOptions(), raw = await taskOp("hub_backlog_list", {});
1155
+ if (options.json) return console.log(raw);
1156
+ let cards: unknown;
1157
+ try { cards = JSON.parse(raw); } catch { return console.log(raw); }
1158
+ if (!Array.isArray(cards)) return console.log(raw);
1159
+ return printOutput(renderBacklog(cards, options.columns, Date.now(), options.full), options.color);
1160
+ }
1145
1161
  if (sub === "groom" && args.length === 1) return console.log(await taskOp("hub_planner_groom", {}));
1146
1162
  if (["return", "return-untouched"].includes(sub ?? "") && /^[1-9]\d*$/.test(id ?? "") && /^\d+$/.test(revision ?? "") && args.length >= 4) {
1147
1163
  return console.log(await taskOp("hub_backlog_return_untouched", { id: Number(id), revision: Number(revision), reason: args.slice(3).join(" ") }));
@@ -1181,6 +1197,27 @@ const commands: Record<string, () => Promise<void> | void> = {
1181
1197
  console.log(await taskOp("release_change", { name, revision: Number(revision), fields }));
1182
1198
  },
1183
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
+
1184
1221
  board: async () => {
1185
1222
  if (args[0] === "import") {
1186
1223
  if (args[1] !== "github" || (args.length - 2) % 2 !== 0) fail("usage: ahub board import github --label <label> [--milestone <milestone>] [--repo <owner/repo>]");
@@ -1366,8 +1403,8 @@ const commands: Record<string, () => Promise<void> | void> = {
1366
1403
  if (sub === "assign") {
1367
1404
  const { one, rest: positional } = takeFlags(rest, ["--role", "--revision"], []);
1368
1405
  const role = one["--role"] ?? "owner", rawRevision = one["--revision"], revision = rawRevision === undefined ? undefined : Number(rawRevision);
1369
- 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]");
1370
- 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 }) }));
1371
1408
  }
1372
1409
  if (sub === "unassign") {
1373
1410
  const { one, rest: positional } = takeFlags(rest, ["--role", "--revision"], []);
@@ -1509,7 +1546,31 @@ const commands: Record<string, () => Promise<void> | void> = {
1509
1546
  } finally { hub.close(); }
1510
1547
  },
1511
1548
  settings: async () => {
1512
- const usage = "usage: ahub settings [list] [--json] | get <key> [--json] | set <key> <value|inherit> [--yes] [--preview] | undo | reload";
1549
+ // #689: structural config edits (instances, review, snapshots, approvals, context.gate, sdlc.toml) and their backups. The hub validates and writes.
1550
+ if (args[0] === "edit" || args[0] === "backups") {
1551
+ // A command line the parser refuses never reaches the hub, not even as a connection.
1552
+ const early = args[0] === "backups" ? (args.length > 2 || (args[1] !== undefined && args[1] !== "--json") ? SETTINGS_EDIT_USAGE : undefined) : (() => { const parsed = parseSettingsEditArgs(args.slice(1)); return typeof parsed === "string" ? parsed : undefined; })();
1553
+ if (early) fail(early);
1554
+ const hub = await connect();
1555
+ try {
1556
+ const rl = process.stdin.isTTY ? createInterface({ input: process.stdin, output: process.stdout }) : undefined;
1557
+ const usage = await runSettingsEdit(args, {
1558
+ request: async message => {
1559
+ const reply = await hub.request(message, 35_000);
1560
+ if (reply.ok !== true) {
1561
+ const error = String(reply.error ?? "config edit refused");
1562
+ fail(/unknown (?:control )?(?:message|request|command)(?:\b|:)|this hub does not know "config_edit/i.test(error) ? `${error}; upgrade and restart the running hub to use ahub settings edit` : error);
1563
+ }
1564
+ return reply;
1565
+ },
1566
+ ask: async prompt => { if (!rl) return null; try { return await rl.question(prompt); } catch { return null; } },
1567
+ print: line => console.log(terminalText(line)),
1568
+ }).finally(() => rl?.close());
1569
+ if (usage) fail(usage);
1570
+ } finally { hub.close(); }
1571
+ return;
1572
+ }
1573
+ const usage = "usage: ahub settings [list] [--json] | get <key> [--json] | set <key> <value|inherit> [--yes] [--preview] | undo | reload | edit <file> ... | backups";
1513
1574
  const flags = args.filter((arg) => arg.startsWith("--")), [sub = "list", key, text, ...extra] = args.filter((arg) => !arg.startsWith("--"));
1514
1575
  const allowed: Record<string, string[]> = { list: ["--json"], get: ["--json"], set: ["--yes", "--preview", "--now"], undo: [], reload: [] };
1515
1576
  if (!Object.hasOwn(allowed, sub) || flags.some((flag) => !allowed[sub]!.includes(flag)) || extra.length) fail(usage);
@@ -2063,6 +2124,12 @@ const commands: Record<string, () => Promise<void> | void> = {
2063
2124
 
2064
2125
  doctor: async () => {
2065
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
+ }
2066
2133
  if (args.includes("--orphans")) return orphanDoctor(args.includes("--kill"), options);
2067
2134
  const version = (bin: string) => {
2068
2135
  const res = spawnSync(bin, ["--version"], { encoding: "utf8" });
@@ -2090,6 +2157,10 @@ const commands: Record<string, () => Promise<void> | void> = {
2090
2157
  const { status } = await hub.request({ t: "status" });
2091
2158
  const pending = Object.values(status.peers as Record<string, PeerRow>).reduce((n, peer) => n + (peer.needsReview ?? 0), 0);
2092
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);
2093
2164
  const observed = await hub.request({ t: "recovery", op: "inspect", expectedInstanceId: status.instanceId });
2094
2165
  for (const peer of Object.values(observed.recovery?.peers ?? {}) as { id: string; state: string; sessionId?: string; threadId?: string }[]) {
2095
2166
  const check = nativeDoctorRecoveryIdentity(peer);
@@ -2144,9 +2215,9 @@ const commands: Record<string, () => Promise<void> | void> = {
2144
2215
  row(mlx.state === "ready" || mlx.state === "stopped", "pi mlx", `${mlx.state}${mlx.model ? ` (${mlx.model})` : ""}${mlx.lastError ? `: ${mlx.lastError}` : ""}`);
2145
2216
  }
2146
2217
 
2147
- const memory = new MemoryClient();
2148
- const mem = await memory.health();
2149
- 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");
2150
2221
  const bridge = spawnSync("dot", ["ai", "memory", "status"], { encoding: "utf8" });
2151
2222
  const out = bridge.error ? "" : bridge.stdout.replace(/\x1b\[[0-9;]*m/g, "");
2152
2223
  for (const tool of DOCTOR_MEMORY_CAPTURE_PEERS) {
@@ -2222,7 +2293,8 @@ async function runConductorCommand(): Promise<void> {
2222
2293
  op = args[0] === "assign" ? "hub_task_assign" : "hub_task_escalate";
2223
2294
  const { one, rest: positional } = takeFlags(args.slice(1), args[0] === "assign" ? ["--role"] : [], []);
2224
2295
  const role = one["--role"] ?? "owner";
2225
- 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");
2226
2298
  input = { id: Number(positional[0]), ...(args[0] === "assign" ? { peer: positional[1], ...(role === "reviewer" ? { role } : {}) } : {}) };
2227
2299
  } else if (cmd === "pause" || cmd === "resume") {
2228
2300
  if (args.length !== 1) fail(`usage: ahub ${cmd} <peer>`);
@@ -2254,7 +2326,7 @@ async function runConductorCommand(): Promise<void> {
2254
2326
  const runConfigured = configuredCommand ? async () => {
2255
2327
  const selection = parseConfiguredAcpArguments(cmd, args, projectCatalog, declaredInstances);
2256
2328
  if (!selection.preview) assertLifecycleAvailable();
2257
- await runConfiguredAcpEntry({ kind: cmd, raw: args, catalog: projectCatalog, instances: declaredInstances, connect, print: text => console.log(text), operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
2329
+ await runConfiguredAcpEntry({ kind: cmd, raw: args, catalog: projectCatalog, instances: declaredInstances, connect: connectLaunch, print: text => console.log(text), operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
2258
2330
  } : undefined;
2259
2331
  const run = identity.role === "tools" && commandAccess === "conductor" ? runConductorCommand : (Object.hasOwn(commands, cmd) ? commands[cmd] : undefined) ?? runConfigured ?? (() => fail(`unknown command "${cmd}"; run ahub help`));
2260
2332
  try {
package/src/cli/memory.ts CHANGED
@@ -1,15 +1,32 @@
1
+ import { TaskRepositories } from "../hub/task-repository.ts";
2
+ import type { TaskPlan, TaskRefs } from "../hub/board.ts";
3
+ import { Database } from "bun:sqlite";
4
+ import { lstatSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { CodeFacts } from "../hub/code-facts.ts";
7
+ import { HUB_DB_BUSY_TIMEOUT_MS } from "../hub/db-timeout.ts";
8
+ import { hubGitEnv, hubGitSync } from "../hub/git.ts";
9
+ import { stateDirFor } from "../hub/control-client.ts";
10
+ import { isDenied } from "../local/deny.ts";
11
+ import { foldSegment } from "../hub/permission-mode.ts";
12
+ import { matchesPolicyLexically } from "../hub/path-policy.ts";
13
+ import { memoryAnchorTaskIdentity, memoryStaleAnchors, unavailableMemoryStaleAnchors, type MemoryStaleAnchorReport, type MemoryTaskPrivacy } from "../hub/memory-stale-anchors.ts";
14
+ import { MEMORY_SYMBOL_START_BUDGET_MS } from "../hub/memory-symbols.ts";
1
15
  import { detectCliIdentity } from "./identity.ts";
2
16
  import { createStalenessProbe } from "../hub/memory-staleness.ts";
3
- import { looksSecret, memoryProjectKey, type MemoryStore } from "@staix/agent-mem";
17
+ import { looksSecret, memoryProjectKey, pathCandidates, readProfileJSON, resolveShardProfile, type MemoryStore } from "@staix/agent-mem";
4
18
  import type { SyncOptions } from "@staix/agent-mem";
5
- import { MEMORY_USAGE as CORE_MEMORY_USAGE, rawMemoryCommand, rawGoalCommand } from "@staix/agent-mem";
19
+ import { memoryUsage, goalUsage, rawMemoryCommand, rawGoalCommand } from "@staix/agent-mem";
20
+
21
+ /** The hub's command names in agent-mem usage and refusal text (agent-mem 0.7.0 defaults to `amem`). */
22
+ const AHUB_NAMES = { memory: "ahub memory", goal: "ahub goal" };
6
23
  import { mentionsPrivatePath, promotionScreen, withoutPii } from "../hub/memory-policy.ts";
7
24
  import { currentRouting, detectSignals } from "../hub/routing.ts";
8
25
  import { loadConfig } from "../hub/daemon.ts";
9
- import { bindProfileMemory, memoryProjectFor, memoryProjectForStore, openProfileMemory, MEMORY_PROFILE_UNAVAILABLE, MEMORY_SPLIT_INCOMPLETE, type MemoryProfileReceipt, type ProfileMemoryOptions } from "../hub/memory-profile.ts";
26
+ import { bindProfileMemory, memoryProjectFor, memoryProjectForStore, openProfileMemory, MemoryProfileBinding, MEMORY_PROFILE_UNAVAILABLE, MEMORY_SPLIT_INCOMPLETE, type MemoryProfileReceipt, type ProfileMemoryOptions } from "../hub/memory-profile.ts";
10
27
 
11
- export { GOAL_USAGE } from "@staix/agent-mem";
12
- export const MEMORY_USAGE = `${CORE_MEMORY_USAGE} | decay [--days N] [--json] | profile-check [--json]`;
28
+ export const GOAL_USAGE = goalUsage(AHUB_NAMES.goal);
29
+ export const MEMORY_USAGE = `${memoryUsage(AHUB_NAMES.memory)} | decay [--days N] [--json] | profile-check [--json]`;
13
30
 
14
31
  const fail = (message: string): never => { throw new Error(message); };
15
32
  /** The project's config, or none when there is no valid one (reading memory needs none). */
@@ -75,7 +92,7 @@ export async function memoryCommand(args: string[], cwd: string, opts: { store?:
75
92
  screen: promotionScreen(project, cwd, () => currentRouting(cwd), privatePaths) }, cwd);
76
93
  try {
77
94
  context.binding.assertCurrent();
78
- const text = await rawMemoryCommand(args, cwd, { ...opts, project, store: context.store, open: () => context.store,
95
+ const text = await rawMemoryCommand(args, cwd, { ...opts, names: AHUB_NAMES, project, store: context.store, open: () => context.store,
79
96
  readable: notes => {
80
97
  const paths = privatePaths(), visible = withoutPii(notes, currentRouting(cwd), cwd, paths);
81
98
  if (args[0] !== "show" || args.includes("--json")) return visible;
@@ -99,9 +116,104 @@ export async function goalCommand(args: string[], cwd: string, opts: { store?: M
99
116
  const context = commandStores(opts.store, { project }, cwd);
100
117
  try {
101
118
  context.binding.assertCurrent();
102
- const text = await rawGoalCommand(args, cwd, { ...opts, project, store: context.store, open: () => context.store,
119
+ const text = await rawGoalCommand(args, cwd, { ...opts, names: AHUB_NAMES, project, store: context.store, open: () => context.store,
103
120
  clean: (title, milestones, project) => !looksSecret(`${title}\n${milestones.join("\n")}`) && withoutPii([{ title, text: milestones.join("\n"), project }], currentRouting(cwd), cwd, policy()).length > 0,
104
121
  });
105
122
  return profileOutput(text, args.includes("--json"), context.binding.status(context.store), person);
106
123
  } finally { if (context.owned) context.store.close(); }
107
124
  }
125
+
126
+
127
+ interface StaleAnchorCommandOptions {
128
+ store?: MemoryStore;
129
+ facts?: Pick<CodeFacts, "exists">;
130
+ /** Trusted host test seam; never read from CLI arguments or project configuration. */
131
+ createFacts?: () => Pick<CodeFacts, "exists" | "close">;
132
+ stateDir?: string;
133
+ head?: () => string | undefined;
134
+ taskPrivacy?: (key: string) => MemoryTaskPrivacy;
135
+ now?: () => number;
136
+ }
137
+ /** Unlike an ordinary core open, inspection must not initialize a missing profile, machine or shard. */
138
+ function existingInspectionProfile(project: string, cwd: string): boolean {
139
+ try {
140
+ const binding = new MemoryProfileBinding({ project, projectKey: memoryProjectKey(cwd) });
141
+ const profiles = [binding.profile, ...(binding.profile.sharded ? [resolveShardProfile(binding.home, { user: true })] : [])];
142
+ for (const profile of profiles) {
143
+ const machineFile = join(profile.sharded ? profile.home : profile.dir, "machine.json"), machine = lstatSync(machineFile);
144
+ if (!machine.isFile() || machine.isSymbolicLink()) return false;
145
+ const metadata = readProfileJSON(machineFile, 4096) as { id?: unknown } | undefined;
146
+ if (!metadata || typeof metadata.id !== "string" || !/^[a-f0-9]{16}$/.test(metadata.id)) return false;
147
+ const logDirectory = lstatSync(join(profile.dir, "log"));
148
+ const localDirectory = lstatSync(join(profile.dir, "local"));
149
+ if (!logDirectory.isDirectory() || logDirectory.isSymbolicLink() || !localDirectory.isDirectory() || localDirectory.isSymbolicLink()) return false;
150
+ }
151
+ binding.assertCurrent(); return true;
152
+ } catch { return false; }
153
+ }
154
+
155
+ /** Host-only doctor report: selected amem reads may maintain derived indexes/leases, never notes or candidates. */
156
+ export async function memoryStaleAnchorsCommand(cwd: string, opts: StaleAnchorCommandOptions = {}): Promise<MemoryStaleAnchorReport> {
157
+ let ownedFacts: Pick<CodeFacts, "exists" | "close"> | undefined;
158
+ let context: ReturnType<typeof commandStores> | undefined;
159
+ try {
160
+ const project = projectOf(cwd, opts.store);
161
+ if (!opts.store && !existingInspectionProfile(project, cwd)) return unavailableMemoryStaleAnchors();
162
+ context = commandStores(opts.store, { project, projectKey: memoryProjectKey(cwd), deferPromotions: true }, cwd);
163
+ const config = () => loadConfig(cwd);
164
+ const publicMetadataText = (text: string): boolean => {
165
+ try {
166
+ const c = config(), paths = [...(c.pii?.paths ?? []), ...c.local.deny];
167
+ return !looksSecret(text) && !detectSignals({ title: "", detail: text, refs: {} }, currentRouting(cwd), cwd).includes("pii")
168
+ && !mentionsPrivatePath(text, [], paths, cwd)
169
+ && !pathCandidates(text).some(path => isDenied(path, c.local.deny) || isDenied(foldSegment(path), c.local.deny.map(foldSegment)));
170
+ } catch { return false; }
171
+ };
172
+ const clock = opts.now ?? performance.now.bind(performance), metadataDeadline = clock() + MEMORY_SYMBOL_START_BUDGET_MS;
173
+ const blockedTasks = new Map<string, "private" | "unknown">(), repositories = new TaskRepositories(cwd); let taskSourceUnavailable = false;
174
+ const taskPrivacy = opts.taskPrivacy ?? ((key: string): MemoryTaskPrivacy => {
175
+ const identity = memoryAnchorTaskIdentity(key, project); if (!identity) return "unknown";
176
+ const blocked = blockedTasks.get(key); if (blocked) return blocked;
177
+ if (taskSourceUnavailable || clock() >= metadataDeadline) return "unknown";
178
+ let db: Database | undefined;
179
+ try {
180
+ const file = join(opts.stateDir ?? stateDirFor(cwd), "hub.db"), stat = lstatSync(file);
181
+ if (!stat.isFile() || stat.isSymbolicLink()) { taskSourceUnavailable = true; return "unknown"; }
182
+ db = new Database(file, { readonly: true, create: false });
183
+ db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
184
+ const row = db.query(`SELECT id,created,signals,title,detail,refs,plan FROM tasks WHERE id=? AND created=?
185
+ AND length(signals)<=65536 AND length(title)<=300 AND length(detail)<=8000 AND length(refs)<=65536 AND length(plan)<=65536`).get(identity.id, identity.created) as { id: number; created: number; signals: string; title: string; detail: string; refs: string; plan: string } | null;
186
+ if (!row || row.id !== identity.id || row.created !== identity.created) { blockedTasks.set(key, "unknown"); return "unknown"; }
187
+ const signals: unknown = JSON.parse(row.signals), refs: unknown = JSON.parse(row.refs), plan: unknown = JSON.parse(row.plan);
188
+ if (!Array.isArray(signals) || !signals.every(signal => typeof signal === "string") || !refs || typeof refs !== "object" || Array.isArray(refs) || !plan || typeof plan !== "object" || Array.isArray(plan)) { blockedTasks.set(key, "unknown"); return "unknown"; }
189
+ const raw = [row.title, row.detail];
190
+ JSON.stringify({ refs, plan }, (key: string, value: unknown) => { if (key) raw.push(key); if (typeof value === "string") raw.push(value); return value; });
191
+ if (signals.includes("pii") || signals.includes("pii_path") || !publicMetadataText(raw.join("\n"))) { blockedTasks.set(key, "private"); return "private"; }
192
+ const paths = [...Object.values(refs), ...Object.values(plan)].flatMap(value => Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []);
193
+ const c = config();
194
+ if (paths.some(path => isDenied(path, c.local.deny) || matchesPolicyLexically(cwd, path, [...(c.pii?.paths ?? []), ...c.local.deny]))) { blockedTasks.set(key, "private"); return "private"; }
195
+ const scope = repositories.scope(refs as TaskRefs);
196
+ if (scope.key !== repositories.project.key || repositories.mismatch(refs as TaskRefs, plan as TaskPlan)) { blockedTasks.set(key, "unknown"); return "unknown"; }
197
+ return "public";
198
+ } catch { taskSourceUnavailable = true; return "unknown"; }
199
+ finally { db?.close(); }
200
+ });
201
+ const head = opts.head ?? (() => {
202
+ try {
203
+ const result = hubGitSync(["-C", cwd, "rev-parse", "--verify", "HEAD"], { encoding: "utf8", env: hubGitEnv(process.env, { repositoryIsolation: true }), timeout: 1000, maxBuffer: 4096 });
204
+ const value = result.stdout.trim(); return result.status === 0 && /^[a-f0-9]{40}$/.test(value) ? value : undefined;
205
+ } catch { return undefined; }
206
+ });
207
+ const facts = opts.facts ?? (ownedFacts = opts.createFacts?.() ?? new CodeFacts({ timeoutMs: MEMORY_SYMBOL_START_BUDGET_MS }));
208
+ const binding = context.binding;
209
+ return await memoryStaleAnchors({ cwd, project, store: context.store, head, now: opts.now, taskPrivacy, publicMetadataText,
210
+ assertCurrent: () => binding.assertCurrent(),
211
+ context: () => ({ cwd, project, repo: project, deny: config().local.deny, allowed: publicMetadataText }),
212
+ exists: (repo, at, name) => facts.exists(repo, at, name),
213
+ finishQueries: async () => { if (ownedFacts) { await ownedFacts.close(); ownedFacts = undefined; } } });
214
+ } catch { return unavailableMemoryStaleAnchors(); }
215
+ finally {
216
+ try { if (ownedFacts) await ownedFacts.close(); } catch { /* Optional cleanup failure remains neutral, including a second failed close. */ }
217
+ try { if (context?.owned) context.store.close(); } catch { /* Inspection must still return its neutral report. */ }
218
+ }
219
+ }
package/src/cli/output.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { definitionOf } from "../adapters/registry.ts";
2
+ import { windowKind } from "../hub/window-kind.ts";
2
3
  import { boardColumns, type BoardPeerInput } from "../ui/task-board.ts";
3
4
  import { peerStyle } from "../ui/peer-palette.ts";
4
5
  import { cells, TABLES, table, fitLine, wrap, duration, relative, plural, terminalText, initialConsoleState, stateTone, type Span, type Tone } from "./console-state.ts";
@@ -16,12 +17,14 @@ export interface StatusOutput {
16
17
  taskStalls?: readonly { task: number; cause: { kind: string; peer?: string; dependency?: number; code: string } }[];
17
18
  pendingTaskOffers?: readonly { task: number; peer: string }[];
18
19
  isolatedChecks?: { pending?: number; orphans?: string[]; unknownReceipts?: readonly string[]; maintenanceUnavailable?: boolean };
20
+ memoryRead?: { truncated: boolean; scanned: number; limit: number };
19
21
  memoryProfile?: { profile?: string; generation?: string; state?: string; agentMemVersion?: string; skippedRows?: number; error?: string };
20
22
  taskWip?: Record<string, { wip: number; wipLimit: number }>;
21
23
  pid?: number; controlPort?: number; cwd?: string; version?: string; peers: Record<string, any>; roleInfo?: Record<string, any>; actionPolicyInfo?: Record<string, { note: string; enforceable: boolean }>; budget?: Record<string, any>; planner?: { peers?: string[]; source?: string };
22
24
  tasks?: Record<string, number>; models?: { backends?: any[] }; permissionDefaults?: any[]; deliveryError?: string; crash?: string[];
23
25
  }
24
- export interface BudgetOutput { budget: Record<string, any>; gate?: number }
26
+ /** `peers`: one note per peer that shows no number, and why (no quota, nothing read yet); absent from an older hub. */
27
+ export interface BudgetOutput { budget: Record<string, any>; gate?: number; peers?: { peer: string; kind?: string; note: string }[] }
25
28
  export interface RosterRow { peer: string; on: boolean; startMode: string; state: string; queued: number; tasks: number }
26
29
  export function renderRoster(data: RosterRow[], columns?: number): Span[][] {
27
30
  return rows(["PEER", "ROSTER", "START", "STATE", "Q", "TASKS"], data.map(row => [span(row.peer, "info", true), span(row.on ? "on" : "off", row.on ? "success" : "muted", true), span(row.startMode, undefined, true), span(row.state, stateTone(row.state), true), span(row.queued, undefined, true), span(row.tasks, undefined, true)]), columns);
@@ -207,6 +210,7 @@ export function renderStatus(data: StatusOutput, columns?: number, now = Date.no
207
210
  const profile = memory.profile === "generation" ? `generation ${memory.generation?.slice(0, 8) ?? "unknown"}` : memory.profile ?? "unbound";
208
211
  out.push(...detail("memory", `${profile}; ${memory.state ?? "unknown"}; agent-mem ${memory.agentMemVersion ?? "unknown"}${memory.skippedRows ? `; malformed legacy rows skipped: ${memory.skippedRows}` : ""}${memory.error ? `; ${memory.error}` : ""}`, columns, memory.state === "unavailable" ? "attention" : undefined));
209
212
  }
213
+ if (data.memoryRead?.truncated) out.push(...detail("memory view", `truncated: newest ${data.memoryRead.limit} project candidates considered`, columns, "attention"));
210
214
  if (data.planner) out.push(...detail("planner", `${data.planner.peers?.join(", ") || "nobody"} (${data.planner.source ?? "unknown"})${data.planner.source === "automatic" || data.planner.source === "none" ? "; pin it with ahub settings set planner <peer>" : ""}`, columns, data.planner.source === "none" ? "attention" : undefined));
211
215
  if (data.deliveryError) out.push(...detail("delivery storage", `${data.deliveryError}; dispatch is stopped`, columns, "failure"));
212
216
  for (const pending of data.permissionDefaults ?? []) out.push(...detail("permission default", `${pending.peer}: never-ask from ${pending.source}, stays ask until a person confirms in ahub console`, columns, "attention"));
@@ -249,6 +253,7 @@ export function renderStatus(data: StatusOutput, columns?: number, now = Date.no
249
253
  const actionPolicy = data.actionPolicyInfo?.[id];
250
254
  if (actionPolicy?.note) out.push(...detail(`${id} action policy`, actionPolicy.note, columns, actionPolicy.enforceable ? "muted" : "attention"));
251
255
  if (typeof p.reviewerUnavailable === "string" && p.reviewerUnavailable) out.push(...detail("reviewer", flat(p.reviewerUnavailable), columns, "failure")); // #722: the cause class, never a path
256
+ if (p.ownerIsolationNote) out.push(...detail("owner isolation", p.ownerIsolationNote, columns, "attention"));
252
257
  if (p.permissionNote) out.push(...detail("permission", p.permissionNote, columns, "muted"));
253
258
  if (typeof p.statusNotice?.message === "string" && p.statusNotice.message) out.push(...detail("status notice", p.statusNotice.message, columns, "attention"));
254
259
  if (p.requestedModel && p.servedBy && p.requestedModel !== p.servedBy) out.push(...detail("requested model", p.requestedModel, columns));
@@ -279,6 +284,33 @@ export function stageCode(next: string): string {
279
284
  }
280
285
  const BOARD_LEGEND = "STAGE: [#---] SDLC progress, stage: next step. issue? no issue, PR? no PR, CI? checks, review? not reviewed, threads? open threads. Full advice: ahub board --full.";
281
286
  const ellipsis = (text: string, room: number) => width(text) <= room ? text : room <= 3 ? ".".repeat(Math.max(0, room)) : (() => { let out = ""; for (const unit of characters(text)) { if (width(out + unit) > room - 3) break; out += unit; } return out + "..."; })();
287
+ /**
288
+ * #399: `ahub backlog list` as a table: one line per card, by rank (unranked last) then id. With a width, TITLE goes last and is
289
+ * cut like the compact board, and AGE then CLASS give way first on a narrow terminal; without one (piped) or with full, titles are whole.
290
+ */
291
+ export function renderBacklog(cards: any[], columns?: number, now = Date.now(), full = false): Span[][] {
292
+ if (!cards.length) return [[span("no backlog cards")]];
293
+ const sorted = [...cards].sort((a, b) => (typeof a.rank === "number" ? a.rank : Infinity) - (typeof b.rank === "number" ? b.rank : Infinity) || a.id - b.id);
294
+ let names = ["RANK", "ID", "PRI", "RESERVED", "AFTER", "CLASS", "AGE", "TITLE"];
295
+ let table: OutputCell[][] = sorted.map(card => {
296
+ const deps: number[] = Array.isArray(card.deps) ? card.deps : [];
297
+ return [span(typeof card.rank === "number" ? card.rank : "-", "muted", true), span(`#${card.id}`, "info", true), span(typeof card.priority === "number" ? `P${card.priority}` : "-", undefined, true),
298
+ span(card.reserved ?? "-", peerTone(card.reserved ?? ""), true), span(deps.length ? (full || columns === undefined ? deps.map(id => `#${id}`).join(",") : `#${deps[0]}${deps.length > 1 ? `,+${deps.length - 1}` : ""}`) : "-", undefined, true), span(card.class ?? "-", undefined, true),
299
+ span(typeof card.created === "number" ? duration(now - card.created) : "-", "muted", true), span(flat(card.title))];
300
+ });
301
+ if (!full && columns !== undefined) {
302
+ const drop = (name: string) => { const at = names.indexOf(name); if (at < 0) return; names = names.filter((_, i) => i !== at); table = table.map(row => row.filter((_, i) => i !== at)); };
303
+ const room = () => columns - names.reduce((sum, name, i) => name === "TITLE" ? sum : sum + Math.max(width(name), ...table.map(row => width(flat(row[i]!.text)))), 0) - 2 * (names.length - 1);
304
+ const want = Math.min(30, Math.max(...table.map(row => width(row[row.length - 1]!.text))));
305
+ for (const name of ["AGE", "CLASS", "PRI"]) if (room() < want) drop(name);
306
+ const titleRoom = Math.max(5, room());
307
+ table = table.map(row => [...row.slice(0, -1), { ...row[row.length - 1]!, text: ellipsis(row[row.length - 1]!.text, titleRoom) }]);
308
+ }
309
+ const owners = new Map<string, number>();
310
+ for (const card of sorted) owners.set(card.reserved ?? "unreserved", (owners.get(card.reserved ?? "unreserved") ?? 0) + 1);
311
+ const summary = `${plural(sorted.length, "backlog card")}; reserved: ${[...owners].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([owner, n]) => `${owner} ${n}`).join(", ")}`;
312
+ return [...rows(names, table, columns), [], ...lines(summary, columns)];
313
+ }
282
314
  /** #724: one line per task. TITLE goes last and is cut to the room left; at a narrow width REVIEWER, CLASS and AGE give way first. */
283
315
  function compactBoard(tasks: any[], data: OutputCell[][], columns: number, staged: boolean): Span[][] {
284
316
  const head = ["ID", "STATE", "OWNER", "REVIEWER", "CLASS", "AGE", "AFTER", ...(staged ? ["STAGE"] : []), "TITLE"];
@@ -330,9 +362,13 @@ export function renderBoard(tasks: any[], columns?: number, now = Date.now(), fu
330
362
  }
331
363
  export function renderBudget(data: BudgetOutput, columns?: number, now = Date.now(), _full = false): Span[][] {
332
364
  const out: Span[][] = [];
333
- const windows = Object.entries(data.budget).flatMap(([peer, b]) => (b.windows ?? []).map((w: any) => [span(peer, peerTone(peer)), span(w.id), span(`${Math.round(w.used * 100)}%`, "number"), span(typeof w.resetsAt === "number" ? relative(w.resetsAt, now) : "-", "muted"), span(w.source ?? "unknown", "muted"), span(typeof w.at === "number" ? relative(w.at, now) : "unknown", "muted"), span(w.stale ? "stale" : "fresh", w.stale ? "attention" : undefined)]));
334
- if (windows.length) out.push(...rows(["PEER", "WINDOW", "USED", "RESETS", "SOURCE", "MEASURED", "STATE"], windows, columns));
335
- else out.push(...lines(`no quota readings yet (gate ${data.gate ?? "unknown"}). Sources: Codex rate limits, Claude's status line (ahub claude), ahub budget set.`, columns));
365
+ // KIND names what the number is (#285): the provider's own (native), tokens the hub counted against a ceiling (count), or a person's (manual).
366
+ // A window without a kind, from an older hub, is classified by `windowKind`. A fresh window that does not gate says so in STATE.
367
+ const windows = Object.entries(data.budget).flatMap(([peer, b]) => (b.windows ?? []).map((w: any) => [span(peer, peerTone(peer)), span(w.id), span(`${Math.round(w.used * 100)}%`, "number"), span(typeof w.resetsAt === "number" ? relative(w.resetsAt, now) : "-", "muted"), span(windowKind({ id: w.id, source: w.source ?? "", kind: w.kind }), "muted"), span(w.source ?? "unknown", "muted"), span(typeof w.at === "number" ? relative(w.at, now) : "unknown", "muted"), span(w.stale ? "stale" : w.superseded ? "info only" : "fresh", w.stale ? "attention" : undefined)]));
368
+ if (windows.length) out.push(...rows(["PEER", "WINDOW", "USED", "RESETS", "KIND", "SOURCE", "MEASURED", "STATE"], windows, columns));
369
+ else if (!data.peers?.length) out.push(...lines(`no quota readings yet (gate ${data.gate ?? "unknown"}). Sources: Codex rate limits, Claude's status line (ahub claude), ahub budget set.`, columns));
370
+ // A peer with no number says why, in its own line, so a peer without quota is not mistaken for one that has not reported.
371
+ for (const note of data.peers ?? []) out.push(...detail(note.peer, note.note, columns, peerTone(note.peer)));
336
372
  for (const [peer, b] of Object.entries(data.budget)) if (b.paused) out.push(...detail(`paused ${peer}`, `${b.paused.reason ?? "budget"}${typeof b.paused.resetsAt === "number" ? `; resumes ${relative(b.paused.resetsAt, now)}` : ""}`, columns, "attention"));
337
373
  return out;
338
374
  }
package/src/cli/reset.ts CHANGED
@@ -11,7 +11,7 @@ import { HUB_DB_BUSY_TIMEOUT_MS } from "../hub/db-timeout.ts";
11
11
  /** Agent session resume pointers. `pi-sessions/` holds Pi transcripts, history rather than pointers, and stays. */
12
12
  export const SESSION_POINTERS = ["sessions.json", "claude-session.json", "claude-context.json"];
13
13
  /** The fixed pointers plus each Claude instance's own session and context files (#450 I3). */
14
- const sessionPointers = (stateDir: string): string[] => [...SESSION_POINTERS, ...(existsSync(stateDir) ? readdirSync(stateDir).filter(name => /^claude-(session|context)\.[a-z][a-z0-9-]{0,31}\.json$/.test(name)) : [])];
14
+ const sessionPointers = (stateDir: string): string[] => [...SESSION_POINTERS, ...(existsSync(stateDir) ? readdirSync(stateDir).filter(name => /^claude-(session|context|task-sessions)\.[a-z][a-z0-9-]{0,31}\.json$/.test(name)) : [])];
15
15
  /** What a running daemon publishes; a daemon that did not stop cleanly (crash, SIGKILL, forced exit) leaves it. */
16
16
  export const MANIFEST = ["status.json", "control-token", "hub.pid"];
17
17
 
@@ -0,0 +1,89 @@
1
+ /** #689: `ahub settings edit` and `ahub settings backups`. Parsing and rendering only; the hub validates, previews and writes. */
2
+ export interface SettingsEditArgs { file: "config.local.json" | "sdlc.toml"; set: { path: string[]; value: unknown }[]; reason?: string; preview: boolean; yes: boolean }
3
+ export const SETTINGS_EDIT_USAGE = "usage: ahub settings edit <config.local.json|sdlc.toml> (set <path> <json> | remove <path>)... [--reason <text>] [--preview] [--yes] | ahub settings backups [--json]";
4
+
5
+ /** The request a command line asks for, or the usage line. Values are JSON, so a string needs its quotes. */
6
+ export function parseSettingsEditArgs(argv: readonly string[]): SettingsEditArgs | string {
7
+ const [file, ...rest] = argv;
8
+ if (file !== "config.local.json" && file !== "sdlc.toml") return SETTINGS_EDIT_USAGE;
9
+ const set: SettingsEditArgs["set"] = [];
10
+ let reason: string | undefined, preview = false, yes = false;
11
+ const path = (text: string | undefined): string[] | undefined => {
12
+ if (!text || text.startsWith(".") || text.endsWith(".") || text.includes("..")) return undefined;
13
+ return text.split(".");
14
+ };
15
+ for (let index = 0; index < rest.length; index++) {
16
+ const word = rest[index]!;
17
+ if (word === "--preview") preview = true;
18
+ else if (word === "--yes") yes = true;
19
+ else if (word === "--reason") { reason = rest[++index]; if (reason === undefined) return SETTINGS_EDIT_USAGE; }
20
+ else if (word === "set") {
21
+ const target = path(rest[++index]), text = rest[++index];
22
+ if (!target || text === undefined) return SETTINGS_EDIT_USAGE;
23
+ try { set.push({ path: target, value: JSON.parse(text) }); } catch { return SETTINGS_EDIT_USAGE; }
24
+ } else if (word === "remove") {
25
+ const target = path(rest[++index]);
26
+ if (!target) return SETTINGS_EDIT_USAGE;
27
+ set.push({ path: target, value: null });
28
+ } else return SETTINGS_EDIT_USAGE;
29
+ }
30
+ if (!set.length) return SETTINGS_EDIT_USAGE;
31
+ return { file, set, ...(reason !== undefined ? { reason } : {}), preview, yes };
32
+ }
33
+
34
+ /** What a person reads before confirming: the diff lines, the warning of a tier 3 key and the word to type. */
35
+ export function renderEditPreview(reply: { ok?: unknown; lines?: unknown; warning?: unknown; confirm?: unknown }): string[] {
36
+ const lines = Array.isArray(reply.lines) ? reply.lines.map(line => String(line)) : [];
37
+ if (typeof reply.warning === "string") lines.push(`warning: ${reply.warning}`);
38
+ if (typeof reply.confirm === "string") lines.push(`type ${reply.confirm} to confirm`);
39
+ return lines;
40
+ }
41
+
42
+ export interface SettingsEditPorts {
43
+ request: (message: Record<string, unknown>) => Promise<Record<string, unknown>>;
44
+ /** Asks one line on a terminal; null when there is none. */
45
+ ask: (prompt: string) => Promise<string | null>;
46
+ print: (line: string) => void;
47
+ }
48
+ /**
49
+ * The whole command, with its ports injected. The person's terminal is the only place a tier 3 key can be changed, and only by
50
+ * typing the key's name: `--yes` confirms an ordinary structural edit and never a tier 3 one.
51
+ */
52
+ export async function runSettingsEdit(argv: readonly string[], ports: SettingsEditPorts): Promise<string | undefined> {
53
+ const { request, ask, print } = ports;
54
+ if (argv[0] === "backups") {
55
+ if (argv.length > 2 || (argv[1] !== undefined && argv[1] !== "--json")) return SETTINGS_EDIT_USAGE;
56
+ const view = await request({ t: "config_edit_get" });
57
+ const backups = Array.isArray(view.backups) ? view.backups as { name: string; file: string; at: number; bytes: number }[] : [];
58
+ if (argv[1] === "--json") print(JSON.stringify(backups, null, 2));
59
+ else if (!backups.length) print("no config backups yet");
60
+ else for (const item of backups) print(`${new Date(item.at).toISOString()} ${item.file} ${item.bytes} bytes ${item.name}`);
61
+ return undefined;
62
+ }
63
+ const parsed = parseSettingsEditArgs(argv[0] === "edit" ? argv.slice(1) : argv);
64
+ if (typeof parsed === "string") return parsed;
65
+ const view = await request({ t: "config_edit_get" });
66
+ const revisions = view.revisions as Record<string, string> | undefined;
67
+ const revision = revisions?.[parsed.file];
68
+ if (typeof revision !== "string") return "the running hub does not report config revisions: upgrade and restart it to use ahub settings edit";
69
+ const body = { file: parsed.file, revision, set: parsed.set };
70
+ const preview = await request({ t: "config_edit_preview", edit: body });
71
+ for (const line of renderEditPreview(preview)) print(line);
72
+ if (parsed.preview) { print("preview only; nothing was changed"); return undefined; }
73
+ const typed = typeof preview.confirm === "string" ? preview.confirm : undefined;
74
+ let confirm: string | undefined;
75
+ if (typed) {
76
+ const answer = await ask(`Type ${typed} to confirm: `);
77
+ if (answer === null) return "not a terminal: a key that runs code or locates secrets is confirmed by typing its name; nothing was changed";
78
+ if (answer.trim() !== typed) { print("nothing was changed"); return undefined; }
79
+ confirm = typed;
80
+ } else if (!parsed.yes) {
81
+ const answer = await ask("Write this change? [y/N] ");
82
+ if (answer === null) return "not a terminal: nothing was changed; rerun with --yes after reviewing the preview";
83
+ if (!/^(y|yes)$/i.test(answer.trim())) { print("nothing was changed"); return undefined; }
84
+ }
85
+ const result = await request({ t: "config_edit_apply", edit: body, ...(confirm ? { confirm } : {}), ...(parsed.reason !== undefined ? { reason: parsed.reason } : {}) });
86
+ print(String(result.text ?? "config written"));
87
+ if (Array.isArray(result.restartPending) && result.restartPending.length) print(`restart pending: ${result.restartPending.join(", ")}; restart the hub (ahub restart) to apply`);
88
+ return undefined;
89
+ }