@staix/agent-hub 0.12.44 → 0.13.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 (197) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +83 -116
  3. package/THIRD_PARTY_NOTICES.md +29 -0
  4. package/docs/adding-an-ai-tool.md +102 -14
  5. package/docs/agent-notes/README.md +15 -0
  6. package/docs/agent-notes/adapters.md +63 -46
  7. package/docs/agent-notes/budget.md +7 -2
  8. package/docs/agent-notes/bus.md +6 -4
  9. package/docs/agent-notes/daemon.md +15 -8
  10. package/docs/agent-notes/local-worker.md +6 -4
  11. package/docs/agent-notes/tasks.md +18 -5
  12. package/docs/agent-notes/tests.md +11 -9
  13. package/docs/dogfood.md +92 -0
  14. package/docs/events.md +14 -1
  15. package/docs/operations.md +349 -26
  16. package/docs/profiles.md +2 -2
  17. package/docs/quickstart.md +12 -2
  18. package/docs/sdlc.md +61 -13
  19. package/docs/security.md +24 -12
  20. package/docs/smoke.md +9 -4
  21. package/docs/specs/2026-09-19-agent-hub-design.md +873 -13
  22. package/docs/verification/2026-10-19-reviewer-isolation.md +2 -0
  23. package/docs/verification/2026-10-21-469-r1-dashboard-tokens.md +43 -0
  24. package/docs/verification/2026-10-21-469-r2-dashboard-shell.md +54 -0
  25. package/docs/verification/2026-10-21-469-r3-dashboard-board.md +51 -0
  26. package/docs/verification/2026-10-21-469-r4-dashboard-views.md +77 -0
  27. package/docs/verification/2026-10-21-469-r6-dashboard-motion-states.md +82 -0
  28. package/docs/verification/2026-10-22-0.12.45.md +60 -0
  29. package/docs/verification/2026-10-22-code-facts.md +78 -0
  30. package/docs/verification/2026-10-22-codex-reviewer-isolation.md +47 -0
  31. package/docs/verification/2026-10-22-kimi-qwen-compat.md +79 -0
  32. package/docs/verification/2026-10-23-0.13.0.md +89 -0
  33. package/docs/verification/assets/469-r1-board-1440-dark.png +0 -0
  34. package/docs/verification/assets/469-r1-board-1440-light.png +0 -0
  35. package/docs/verification/assets/469-r1-board-390-dark.png +0 -0
  36. package/docs/verification/assets/469-r1-board-390-light.png +0 -0
  37. package/docs/verification/assets/469-r2-agents-1440-dark.png +0 -0
  38. package/docs/verification/assets/469-r2-agents-768-dark.png +0 -0
  39. package/docs/verification/assets/469-r2-board-1024-light.png +0 -0
  40. package/docs/verification/assets/469-r2-board-1440-dark.png +0 -0
  41. package/docs/verification/assets/469-r2-board-1440-light.png +0 -0
  42. package/docs/verification/assets/469-r2-board-390-dark.png +0 -0
  43. package/docs/verification/assets/469-r2-board-390-light.png +0 -0
  44. package/docs/verification/assets/469-r2-board-768-light.png +0 -0
  45. package/docs/verification/assets/469-r3-board-1440-dark.png +0 -0
  46. package/docs/verification/assets/469-r3-board-1440-light.png +0 -0
  47. package/docs/verification/assets/469-r3-board-390-light.png +0 -0
  48. package/docs/verification/assets/469-r3-detail-1440-dark.png +0 -0
  49. package/docs/verification/assets/469-r3-detail-1440-light.png +0 -0
  50. package/docs/verification/assets/469-r3-detail-390-dark.png +0 -0
  51. package/docs/verification/assets/469-r3-plan-1440-light.png +0 -0
  52. package/docs/verification/assets/469-r3-plan-390-light.png +0 -0
  53. package/docs/verification/assets/469-r4-agents-1440-dark.png +0 -0
  54. package/docs/verification/assets/469-r4-agents-1440-light.png +0 -0
  55. package/docs/verification/assets/469-r4-agents-390-dark.png +0 -0
  56. package/docs/verification/assets/469-r4-memory-1440-dark.png +0 -0
  57. package/docs/verification/assets/469-r4-memory-1440-light.png +0 -0
  58. package/docs/verification/assets/469-r4-memory-390-dark.png +0 -0
  59. package/docs/verification/assets/469-r4-message-1440-light.png +0 -0
  60. package/docs/verification/assets/469-r4-office-1440-dark.png +0 -0
  61. package/docs/verification/assets/469-r4-office-1440-light.png +0 -0
  62. package/docs/verification/assets/469-r4-settings-1440-dark.png +0 -0
  63. package/docs/verification/assets/469-r4-settings-1440-light.png +0 -0
  64. package/docs/verification/assets/469-r4-stream-1440-light.png +0 -0
  65. package/docs/verification/assets/469-r4-stream-390-light.png +0 -0
  66. package/docs/verification/assets/469-r6-agents-empty-390-dark.png +0 -0
  67. package/docs/verification/assets/469-r6-agents-empty-390-light.png +0 -0
  68. package/docs/verification/assets/469-r6-board-1440-light.png +0 -0
  69. package/docs/verification/assets/469-r6-board-390-dark.png +0 -0
  70. package/docs/verification/assets/469-r6-board-empty-1440-dark.png +0 -0
  71. package/docs/verification/assets/469-r6-board-empty-1440-light.png +0 -0
  72. package/docs/verification/assets/469-r6-disconnected-1440-dark.png +0 -0
  73. package/docs/verification/assets/469-r6-disconnected-1440-light.png +0 -0
  74. package/docs/verification/assets/469-r6-disconnected-390-dark.png +0 -0
  75. package/docs/verification/assets/469-r6-skeleton-1440-light.png +0 -0
  76. package/docs/verification/assets/469-r6-skeleton-390-dark.png +0 -0
  77. package/docs/verification/assets/469-r6-toasts-1440-dark.png +0 -0
  78. package/docs/verification/assets/469-r6-toasts-1440-light.png +0 -0
  79. package/docs/verification/assets/469-r6-toasts-390-light.png +0 -0
  80. package/docs/verification/dogfood/2026-10-22.md +3109 -0
  81. package/docs/verified.json +19 -16
  82. package/package.json +5 -1
  83. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  84. package/plugins/agent-hub/server.js +10120 -9623
  85. package/src/adapters/adapter-api.ts +2 -0
  86. package/src/adapters/agy/definition.ts +3 -1
  87. package/src/adapters/agy/entry.ts +5 -4
  88. package/src/adapters/claude-channel.ts +32 -12
  89. package/src/adapters/codex-appserver.ts +19 -2
  90. package/src/adapters/codex-review-isolation.ts +12 -5
  91. package/src/adapters/compat.ts +140 -0
  92. package/src/adapters/conformance/acp.ts +114 -0
  93. package/src/adapters/conformance/codex.ts +191 -0
  94. package/src/adapters/conformance/enrollment.ts +43 -0
  95. package/src/adapters/conformance/factories.ts +17 -0
  96. package/src/adapters/conformance/fake-paths.ts +12 -0
  97. package/src/adapters/conformance/fakes/acp-server.ts +267 -0
  98. package/src/adapters/conformance/fakes/acp-watchdog-agent.ts +10 -0
  99. package/src/adapters/conformance/fakes/app-server.ts +119 -0
  100. package/src/adapters/conformance/fakes/codex-bin.ts +15 -0
  101. package/src/adapters/conformance/fakes/jcode-agent.ts +91 -0
  102. package/src/adapters/conformance/fakes/model-server.ts +39 -0
  103. package/src/adapters/conformance/fakes/pi-rpc.ts +32 -0
  104. package/src/adapters/conformance/index.ts +11 -0
  105. package/src/adapters/conformance/kit.ts +50 -0
  106. package/src/adapters/conformance/local-pi.ts +100 -0
  107. package/src/adapters/conformance/owned-acp.ts +44 -0
  108. package/src/adapters/definitions/builtin-metadata.ts +25 -7
  109. package/src/adapters/definitions/native-cli-doctor.ts +12 -5
  110. package/src/adapters/definitions/native-cli-entry-jcode.ts +9 -6
  111. package/src/adapters/definitions/native-cli-entry-kimi.ts +7 -1
  112. package/src/adapters/definitions/native-cli-entry.ts +7 -19
  113. package/src/adapters/definitions/native-cli-jcode-mcp.ts +9 -2
  114. package/src/adapters/definitions/native-cli-preview-common.ts +2 -0
  115. package/src/adapters/definitions/native-cli-preview-jcode.ts +8 -2
  116. package/src/adapters/definitions/native-cli-preview-qwen.ts +2 -1
  117. package/src/adapters/definitions/native-cli-preview.ts +2 -11
  118. package/src/adapters/definitions/qwen-native.ts +7 -3
  119. package/src/adapters/definitions/qwen.ts +5 -1
  120. package/src/adapters/external-metadata.ts +48 -0
  121. package/src/adapters/external-module-scan.ts +126 -0
  122. package/src/adapters/external-modules.ts +240 -0
  123. package/src/adapters/jcode.ts +72 -31
  124. package/src/adapters/kimi-review.ts +6 -3
  125. package/src/adapters/pi.ts +2 -0
  126. package/src/adapters/public.ts +7 -0
  127. package/src/adapters/registry-types.ts +13 -1
  128. package/src/adapters/registry.ts +52 -15
  129. package/src/cli/agents.ts +40 -0
  130. package/src/cli/console-review.ts +54 -0
  131. package/src/cli/console-state.ts +124 -29
  132. package/src/cli/console-symbols.ts +74 -0
  133. package/src/cli/console-usage.ts +89 -0
  134. package/src/cli/console.ts +26 -1
  135. package/src/cli/help.ts +36 -21
  136. package/src/cli/identity.ts +30 -7
  137. package/src/cli/init.ts +109 -11
  138. package/src/cli/main.ts +145 -16
  139. package/src/cli/output.ts +44 -1
  140. package/src/cli/preview.ts +1 -1
  141. package/src/cli/sdlc-release-guard.ts +82 -0
  142. package/src/cli/upgrade-labels.ts +1 -1
  143. package/src/cli/upgrade-presentation.ts +1 -1
  144. package/src/cli/upgrade-restore-state.ts +3 -2
  145. package/src/cli/upgrade-runtime.ts +4 -1
  146. package/src/hub/action-class.ts +362 -0
  147. package/src/hub/action-policy.ts +106 -0
  148. package/src/hub/board.ts +72 -11
  149. package/src/hub/budget.ts +71 -2
  150. package/src/hub/bus.ts +43 -3
  151. package/src/hub/checks.ts +80 -31
  152. package/src/hub/code-facts.ts +223 -0
  153. package/src/hub/config-apply.ts +28 -11
  154. package/src/hub/config-reload.ts +8 -5
  155. package/src/hub/config-trust.ts +7 -4
  156. package/src/hub/control-client.ts +4 -1
  157. package/src/hub/daemon.ts +332 -61
  158. package/src/hub/dogfood-read.ts +199 -0
  159. package/src/hub/dogfood.ts +163 -0
  160. package/src/hub/envelope.ts +1 -1
  161. package/src/hub/events.ts +11 -2
  162. package/src/hub/facts.ts +76 -13
  163. package/src/hub/hub-tools.ts +45 -7
  164. package/src/hub/inbox-polling.ts +25 -0
  165. package/src/hub/instance-config.ts +17 -5
  166. package/src/hub/instance-execution.ts +20 -8
  167. package/src/hub/instances.ts +75 -2
  168. package/src/hub/peers.ts +9 -0
  169. package/src/hub/plan-conformance.ts +83 -0
  170. package/src/hub/profiles.ts +7 -2
  171. package/src/hub/release-worktree.ts +56 -0
  172. package/src/hub/review-brief.ts +36 -0
  173. package/src/hub/review-findings.ts +104 -0
  174. package/src/hub/roster.ts +31 -5
  175. package/src/hub/sdlc-queue.ts +48 -11
  176. package/src/hub/sdlc-release-authorization.ts +173 -0
  177. package/src/hub/sdlc-release.ts +16 -1
  178. package/src/hub/sdlc.ts +71 -18
  179. package/src/hub/settings.ts +10 -3
  180. package/src/hub/snapshots.ts +101 -14
  181. package/src/hub/symbol-normalizer.ts +26 -0
  182. package/src/hub/task-code-symbols.ts +90 -0
  183. package/src/hub/task-sweep.ts +3 -2
  184. package/src/hub/tasks.ts +556 -88
  185. package/src/hub/test-integrity.ts +162 -0
  186. package/src/hub/ui.ts +2 -2
  187. package/src/hub/usage-rollup.ts +361 -0
  188. package/src/local/sandbox.ts +5 -3
  189. package/src/ui/index.html +560 -78
  190. package/src/ui/office-model.ts +3 -1
  191. package/src/ui/peer-palette.ts +44 -0
  192. package/templates/AGENTS.block.md +1 -1
  193. package/templates/profiles/dev.json +6 -1
  194. package/templates/profiles/general.json +6 -1
  195. package/templates/profiles/research.json +6 -1
  196. package/templates/profiles/writing.json +6 -1
  197. package/templates/release-guard.block.md +6 -0
package/src/cli/init.ts CHANGED
@@ -1,9 +1,13 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { initShadow, repoOf } from "../hub/snapshots.ts";
2
3
  import { profileName, projectProfile, resolveProfile, mergeProfileOverrides, type ProfileName } from "../hub/profiles.ts";
3
4
  import { JCODE_MCP_FILE } from "./jcode-mcp.ts";
4
- import { existsSync, lstatSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
5
- import { join } from "node:path";
6
- import { hubGitSync } from "../hub/git.ts";
5
+ import { existsSync, lstatSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync, renameSync } from "node:fs";
6
+ import { dirname, isAbsolute, join, resolve } from "node:path";
7
+ import { spawnSync } from "node:child_process";
8
+ import type { Stats } from "node:fs";
9
+ import { realPath } from "../hub/project.ts";
10
+ import { hubGitArgv, hubGitEnv, hubGitSync } from "../hub/git.ts";
7
11
  import { KIMI_MCP_FILE } from "./kimi-mcp.ts";
8
12
  import { AGY_PLUGIN_FILES } from "./agy-mcp.ts";
9
13
 
@@ -36,8 +40,70 @@ export interface InitChange {
36
40
  managedBlock?: "insert" | "replace" | "remove";
37
41
  }
38
42
  /** Content is internal to apply, never included in a public preview. */
39
- interface PlannedWrite extends InitChange { next?: string; shadow?: true; }
40
- function changes(cwd: string, requested?: ProfileName, shadow = false): PlannedWrite[] {
43
+ interface PlannedWrite extends InitChange { next?: string; shadow?: true; directory?: true; executable?: true; moveFrom?: string; expected?: { dev: number; ino: number }; }
44
+ const RELEASE_BEGIN = "<!-- AGENT_HUB_RELEASE_GUARD:BEGIN (managed by `ahub init --release-guard`) -->";
45
+ const RELEASE_END = "<!-- AGENT_HUB_RELEASE_GUARD:END -->";
46
+ const HOOK_MARKER = "# agent-hub managed release pre-push guard";
47
+ const shellQuote = (value: string) => "'" + value.replace(/'/g, "'\"'\"'") + "'";
48
+
49
+ function releaseBlock(existing: string): string {
50
+ const block = `${RELEASE_BEGIN}\n${readFileSync(join(TEMPLATES, "release-guard.block.md"), "utf8").trim()}\n${RELEASE_END}`;
51
+ const start = existing.indexOf("<!-- AGENT_HUB_RELEASE_GUARD:BEGIN"), end = existing.indexOf(RELEASE_END, Math.max(start, 0));
52
+ if (start >= 0 && end > start) return existing.slice(0, start) + block + existing.slice(end + RELEASE_END.length);
53
+ if (start >= 0 || existing.includes(RELEASE_END)) throw new Error("release guard instruction markers are incomplete");
54
+ return `${existing.trimEnd()}\n\n${block}\n`;
55
+ }
56
+ function regularHook(file: string): Stats | undefined {
57
+ const info = lstatSync(file, { throwIfNoEntry: false });
58
+ if (info && (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1)) throw new Error("release guard hook must be an unlinked regular file");
59
+ return info;
60
+ }
61
+ function releaseHookPlan(cwd: string): PlannedWrite[] {
62
+ for (const scope of [["--local"], ["--global"], []]) {
63
+ // Read the actual hooksPath without the hardened override masking it. All
64
+ // other portable Git hardening stays; `git config --get` executes no hook.
65
+ const argv = hubGitArgv(["config", ...scope, "--get", "core.hooksPath"]);
66
+ const override = argv.indexOf("core.hooksPath=/dev/null");
67
+ if (override < 1 || argv[override - 1] !== "-c") throw new Error("release guard Git hardening unavailable");
68
+ argv.splice(override - 1, 2);
69
+ const configured = spawnSync(argv[0]!, argv.slice(1), { cwd, env: hubGitEnv(process.env, { repositoryIsolation: true }), encoding: "utf8", timeout: 3000, maxBuffer: 16 * 1024 });
70
+ if (configured.status !== 1) throw new Error(configured.status === 0 ? "release guard refuses custom core.hooksPath; no files changed" : "release guard cannot verify core.hooksPath");
71
+ }
72
+ const common = hubGitSync(["rev-parse", "--git-common-dir"], { cwd, env: hubGitEnv(process.env, { repositoryIsolation: true }), encoding: "utf8", timeout: 3000 });
73
+ if (common.status !== 0 || !common.stdout.trim()) throw new Error("release guard requires a Git repository");
74
+ const raw = isAbsolute(common.stdout.trim()) ? common.stdout.trim() : resolve(cwd, common.stdout.trim());
75
+ const own = lstatSync(raw);
76
+ if (!own.isDirectory() || own.isSymbolicLink()) throw new Error("release guard Git directory must not be a link");
77
+ const commonDir = realPath(raw), hooks = join(commonDir, "hooks");
78
+ const parent = lstatSync(hooks, { throwIfNoEntry: false });
79
+ if (parent && (!parent.isDirectory() || parent.isSymbolicLink())) throw new Error("release guard hooks directory must not be a link");
80
+ const hook = join(hooks, "pre-push"), backup = join(hooks, "pre-push.agent-hub-original");
81
+ const original = regularHook(hook), saved = regularHook(backup);
82
+ const current = original ? readFileSync(hook, "utf8") : "";
83
+ const managed = current.startsWith(`#!/bin/sh\n${HOOK_MARKER}\n`);
84
+ if (!managed && saved) throw new Error("release guard backup is occupied; no files changed");
85
+ const wrapper = `#!/bin/sh
86
+ ${HOOK_MARKER}
87
+ guard_input=$(umask 077; mktemp "\${TMPDIR:-/tmp}/ahub-release-guard.XXXXXX") || exit 1
88
+ trap 'rm -f "$guard_input"' 0
89
+ trap 'exit 1' 1 2 15
90
+ ${shellQuote(process.execPath)} ${shellQuote(join(import.meta.dir, "main.ts"))} sdlc release-guard-hook "$1" "$2" >"$guard_input" || exit "$?"
91
+ if [ -x ${shellQuote(backup)} ]; then
92
+ ${shellQuote(backup)} "$@" <"$guard_input"
93
+ else
94
+ exit 0
95
+ fi
96
+ `;
97
+ const plan: PlannedWrite[] = [];
98
+ if (!parent) plan.push({ action: "create", path: hooks, reason: "create default Git hooks directory", directory: true });
99
+ if (original && !managed) plan.push({ action: "create", path: backup, reason: "preserve original pre-push hook inode and mode", moveFrom: hook, expected: { dev: original.dev, ino: original.ino } });
100
+ if (current !== wrapper || original && !(original.mode & 0o111)) plan.push({ action: original ? "modify" : "create", path: hook, reason: "install release authorization pre-push guard", next: wrapper, executable: true });
101
+ return plan;
102
+ }
103
+ function changes(cwd: string, requested?: ProfileName, shadow = false, releaseGuard = false): PlannedWrite[] {
104
+ const originalAgents = existsSync(join(cwd, "AGENTS.md")) ? readFileSync(join(cwd, "AGENTS.md"), "utf8") : "";
105
+ const guarded = releaseGuard || originalAgents.includes("<!-- AGENT_HUB_RELEASE_GUARD:BEGIN");
106
+ const hooks = guarded ? releaseHookPlan(cwd) : [];
41
107
  const repository = shadow ? repoOf(cwd) : undefined;
42
108
  if (shadow && repository && !repository.shadow) throw new Error("project already has a Git work tree; shadow mode is for non-git folders");
43
109
  if (shadow) {
@@ -80,7 +146,8 @@ function changes(cwd: string, requested?: ProfileName, shadow = false): PlannedW
80
146
  }
81
147
  const agents = join(cwd, "AGENTS.md");
82
148
  const existing = existsSync(agents) ? readFileSync(agents, "utf8") : "";
83
- write(agents, upsertBlock(existing, overrides && Object.hasOwn(overrides as object, "agents") ? profile.agents : profile.agents || readFileSync(join(TEMPLATES, "AGENTS.block.md"), "utf8")), "refresh hub instructions; preserve surrounding text", existing.includes("<!-- AGENT_HUB:BEGIN") ? "replace" : "insert");
149
+ const instructions = upsertBlock(existing, overrides && Object.hasOwn(overrides as object, "agents") ? profile.agents : profile.agents || readFileSync(join(TEMPLATES, "AGENTS.block.md"), "utf8"));
150
+ write(agents, guarded ? releaseBlock(instructions) : instructions, "refresh hub instructions; preserve surrounding text", existing.includes("<!-- AGENT_HUB:BEGIN") ? "replace" : "insert");
84
151
  const claude = join(cwd, "CLAUDE.md");
85
152
  const own = lstatSync(claude, { throwIfNoEntry: false });
86
153
  // The write preserves an existing inode, including AGENTS.md -> CLAUDE.md and hard links.
@@ -105,19 +172,50 @@ function changes(cwd: string, requested?: ProfileName, shadow = false): PlannedW
105
172
  }
106
173
  if (existsSync(join(cwd, JCODE_MCP_FILE)) && hubGitSync(["ls-files", "--error-unmatch", "--", JCODE_MCP_FILE], { cwd, encoding: "utf8", timeout: 3000 }).status !== 0 && !lines.split("\n").includes(JCODE_MCP_FILE)) lines = `${lines}${lines && !lines.endsWith("\n") ? "\n" : ""}${JCODE_MCP_FILE}\n`;
107
174
  write(ignore, lines, "ignore runtime state and machine-local configuration");
108
- return changed;
175
+ return [...hooks, ...changed];
109
176
  }
110
177
  /** Read-only metadata, deliberately omitting project/user file contents. */
111
- export function planInit(cwd: string, profile?: ProfileName, shadow = false): InitChange[] {
112
- return changes(cwd, profile, shadow).map(({ next, shadow: _shadow, ...change }) => change);
178
+ export function planInit(cwd: string, profile?: ProfileName, shadow = false, releaseGuard = false): InitChange[] {
179
+ return changes(cwd, profile, shadow, releaseGuard).map(({ next, shadow: _shadow, directory, executable, moveFrom, expected, ...change }) => change);
113
180
  }
114
181
  /** Returns the paths it changed, applying the same plan as the preview. */
115
- export function init(cwd: string, profile?: ProfileName, shadow = false): string[] {
116
- const plan = changes(cwd, profile, shadow);
182
+ export function init(cwd: string, profile?: ProfileName, shadow = false, releaseGuard = false): string[] {
183
+ const plan = changes(cwd, profile, shadow, releaseGuard);
184
+ // Recheck hook parents and rename identities before any project write.
185
+ for (const change of plan.filter(change => change.executable || change.moveFrom)) {
186
+ const parent = lstatSync(dirname(change.path), { throwIfNoEntry: false });
187
+ if (parent && (!parent.isDirectory() || parent.isSymbolicLink())) throw new Error("release guard hook parent changed before installation");
188
+ if (change.moveFrom) {
189
+ const source = regularHook(change.moveFrom);
190
+ if (!source || source.dev !== change.expected?.dev || source.ino !== change.expected.ino || lstatSync(change.path, { throwIfNoEntry: false })) throw new Error("release guard hook changed before installation");
191
+ } else regularHook(change.path);
192
+ }
117
193
  mkdirSync(join(cwd, ".agenthub"), { recursive: true });
118
194
  for (const change of plan) {
119
195
  if (change.shadow) initShadow(cwd);
196
+ else if (change.directory) mkdirSync(change.path);
197
+ else if (change.moveFrom) continue; // Applied atomically with its prepared wrapper below.
120
198
  else if (change.action === "delete") unlinkSync(change.path);
199
+ else if (change.executable) {
200
+ const parent = lstatSync(dirname(change.path));
201
+ if (!parent.isDirectory() || parent.isSymbolicLink()) throw new Error("release guard hook parent changed before installation");
202
+ const current = regularHook(change.path), move = plan.find(row => row.moveFrom === change.path);
203
+ const temporary = join(dirname(change.path), `.pre-push.${randomUUID()}.tmp`);
204
+ let moved = false;
205
+ try {
206
+ // Prepare bytes before disturbing any existing gate. Refreshing our own
207
+ // managed hook is one atomic rename, so a failed write leaves it intact.
208
+ writeFileSync(temporary, change.next!, { mode: 0o755, flag: "wx" });
209
+ if (move) {
210
+ if (!current || current.dev !== move.expected?.dev || current.ino !== move.expected.ino || lstatSync(move.path, { throwIfNoEntry: false })) throw new Error("release guard hook changed before installation");
211
+ renameSync(change.path, move.path); moved = true;
212
+ }
213
+ renameSync(temporary, change.path);
214
+ } catch (error) {
215
+ if (moved && !lstatSync(change.path, { throwIfNoEntry: false })) renameSync(move!.path, change.path);
216
+ throw error;
217
+ } finally { if (lstatSync(temporary, { throwIfNoEntry: false })) unlinkSync(temporary); }
218
+ }
121
219
  else writeFileSync(change.path, change.next!);
122
220
  }
123
221
  return plan.map(change => change.path);
package/src/cli/main.ts CHANGED
@@ -1,8 +1,14 @@
1
1
  #!/usr/bin/env bun
2
+ import { hubGitSync, hubGitEnv } from "../hub/git.ts";
3
+ import { parseReleaseGuardArgv, parseReleasePushUpdates } from "./sdlc-release-guard.ts";
4
+ import { validReleaseVersion } from "../hub/sdlc-release-authorization.ts";
5
+ import { agentVersions, runAgents } from "./agents.ts";
6
+ import { assessVersion, COMPAT, configuredCommands } from "../adapters/compat.ts";
2
7
  import { claudePeer as defaultChannelPeer } from "../hub/claude-state.ts";
3
8
  import { effectiveInstanceRoles } from "../hub/instances.ts";
4
9
  import { resolveInstanceExecutionCwd } from "../hub/instance-execution.ts";
5
10
  import { readInstanceRoleConfig, readInstanceConfig, type InstanceRoleConfig } from "../hub/instance-config.ts";
11
+ import { staleInstanceKey } from "../hub/roster.ts";
6
12
  import { parseConfiguredAcpArguments, runConfiguredAcpEntry, requireActiveConfiguredAcp } from "./configured-acp-entry.ts";
7
13
  import type { NativeCliEntryContext } from "../adapters/definitions/native-cli-entry-types.ts";
8
14
  import { BUILTIN_CLI_ENTRIES, createProjectAdapterCatalog, definitionOf, DOCTOR_TOOL_PROBES, DOCTOR_MEMORY_CAPTURE_PEERS, DOCTOR_PLUGIN_PROBE, nativeDoctorCheck, nativeDoctorRecoveryIdentity, nativeDoctorOptionalProbes } from "../adapters/registry.ts";
@@ -73,14 +79,17 @@ import { selectedMemoryProfile } from "../hub/memory-profile.ts";
73
79
  import { checkMemoryProfiles, formatMemoryProfileCheck } from "../hub/memory-profile-check.ts";
74
80
  import { addonReport, appendRecords, formatAddons, formatResearch, LABELS, labelTarget, RESEARCH_SCHEMA, projectKey, readStores, researchReport, taskRecords, toCsv, type Label, type TaskRecord } from "../hub/research.ts";
75
81
  import { summarize, summarizeByTask } from "../hub/report.ts";
82
+ import { readDogfood } from "../hub/dogfood-read.ts";
83
+ import { dogfoodSince, formatDogfood } from "../hub/dogfood.ts";
84
+ import { USAGE_GROUPS, LIMITS_MAX } from "../hub/usage-rollup.ts";
76
85
  import { hasTree, planUndo, repoOf, restore, Turns } from "../hub/snapshots.ts";
77
86
  import { pathWarnings } from "../hub/conflicts.ts";
78
87
  import { COMPLETION_SHELLS, completionScript } from "./completion.ts";
79
- import { classifyPeerCommand, cliCommandLabel, detectCliIdentity, LIVE_TEST_PERSON_REFUSAL, liveTestRefusal, peerCommandRefusal } from "./identity.ts";
88
+ import { agentShellCause, classifyPeerCommand, cliCommandLabel, detectCliIdentity, LIVE_TEST_PERSON_REFUSAL, liveTestRefusal, peerCommandRefusal } from "./identity.ts";
80
89
  import { recordCliAudit, recordDelegatedCliAudit } from "./identity-audit.ts";
81
90
  import { runConsole } from "./console.ts";
82
91
  import { paint, permissionBoundary, resolveColor, wrap, type Span } from "./console-state.ts";
83
- import { outputWidth, renderRoster, renderStatus, renderBoard, renderBoardColumns, boardColumnModel, renderBudget, renderDoctor, renderProjects, renderQueue, renderTurns, renderOrphans, renderQueueShow, renderModelsStatus, renderExecutionBudgetStatus, renderReport, type DoctorCheck } from "./output.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";
84
93
  import { renderHelp, configuredAcpHelp } from "./help.ts";
85
94
  import { renderTailEvent } from "./tail-render.ts";
86
95
  import { archiveProblem, archiveState, damagedState, failureText, MANIFEST, planReset, resetLines, resetRuntime, startState, type ResetPlan } from "./reset.ts";
@@ -443,7 +452,14 @@ async function taskOp(op: string, a: Record<string, unknown>, throwOnError = fal
443
452
  try {
444
453
  if (op === "hub_task_cancel" && !hub.features.includes("task_cancel")) fail("this daemon does not support task cancellation; upgrade it first");
445
454
  const res = await hub.request({ t: "task", op, args: a }, timeoutMs ?? (op === "github_import" ? 60_000 : 30_000));
446
- if (!res.ok) { if (throwOnError) throw new Error(res.error); audit("refused"); fail(res.error); }
455
+ if (!res.ok) {
456
+ if (throwOnError) throw new Error(res.error);
457
+ audit("refused");
458
+ // #711: an agent shell refused something only the person may do is told why it counts as an agent. The refusal and the authority stay as they were.
459
+ const cause = agentShellCause(identity, String(res.error));
460
+ if (cause) console.error(`ahub: ${res.error}\n${cause}`), process.exit(1);
461
+ fail(res.error);
462
+ }
447
463
  return res.text;
448
464
  } finally { if (!connection) hub.close(); }
449
465
  }
@@ -884,15 +900,15 @@ const commands: Record<string, () => Promise<void> | void> = {
884
900
  },
885
901
 
886
902
  init: () => {
887
- const flags = takeFlags(args.filter(arg => arg !== "--dry-run" && arg !== "--json" && arg !== "--shadow"), ["--profile"], []);
888
- if (flags.rest.length) fail("usage: ahub init [--profile dev|research|writing|general] [--shadow] [--dry-run --json]");
903
+ const flags = takeFlags(args.filter(arg => arg !== "--dry-run" && arg !== "--json" && arg !== "--shadow" && arg !== "--release-guard"), ["--profile"], []);
904
+ if (flags.rest.length) fail("usage: ahub init [--profile dev|research|writing|general] [--shadow] [--release-guard] [--dry-run --json]");
889
905
  const requestedProfile = flags.one["--profile"] === undefined ? undefined : profileName(flags.one["--profile"]);
890
906
  if (args.includes("--dry-run")) {
891
- const plan = planInit(cwd, requestedProfile, args.includes("--shadow"));
907
+ const plan = planInit(cwd, requestedProfile, args.includes("--shadow"), args.includes("--release-guard"));
892
908
  return console.log(args.includes("--json") ? JSON.stringify(plan, null, 2) : plan.map(change => `${change.action} ${change.path}: ${change.reason}${change.managedBlock ? ` (managed block: ${change.managedBlock})` : ""}`).join("\n") || "already up to date");
893
909
  }
894
910
  assertLifecycleAvailable();
895
- const changed = init(cwd, requestedProfile, args.includes("--shadow"));
911
+ const changed = init(cwd, requestedProfile, args.includes("--shadow"), args.includes("--release-guard"));
896
912
  registeredProject();
897
913
  console.log(changed.length ? changed.map((p) => `${existsSync(p) ? "wrote" : "removed"} ${p}`).join("\n") : "already up to date");
898
914
  const config = projectConfig(), examples = resolveProfile(config.profile, config.profile_overrides).example_checks;
@@ -1005,8 +1021,21 @@ const commands: Record<string, () => Promise<void> | void> = {
1005
1021
  printOutput(renderQueue(result.deliveries, options.columns, Date.now()), options.color);
1006
1022
  return;
1007
1023
  }
1024
+ if (operation === "discard") {
1025
+ // #713: the person's bulk clear of one peer's queue. Without --yes it only says how many it would discard.
1026
+ const yes = rest.includes("--yes");
1027
+ const { one, rest: others } = takeFlags(rest.filter((word) => word !== "--yes"), ["--peer", "--reason", "--state"], []);
1028
+ if (others.length || !one["--peer"] || (one["--state"] !== undefined && !["queued", "needs_review"].includes(one["--state"]))) fail("usage: ahub queue discard --peer <id> --reason <text> [--state queued|needs_review] [--yes]");
1029
+ if (yes && !one["--reason"]?.trim()) fail("ahub queue discard needs --reason <text> with --yes: it is recorded on every discarded delivery");
1030
+ const result = await hub.request({ t: "queue", op: "discard", peer: one["--peer"], ...(one["--state"] ? { state: one["--state"] } : {}), ...(yes ? { confirmed: true, reason: one["--reason"] } : {}) }, 60_000);
1031
+ if (!result.ok) fail(result.error);
1032
+ if (options.json) return console.log(JSON.stringify(result, null, 2));
1033
+ if (!yes) return console.log(`${result.count} ${result.state} deliver${result.count === 1 ? "y" : "ies"} for ${result.peer}; nothing discarded. Run again with --reason <text> --yes to discard them.`);
1034
+ console.log(`discarded ${result.discarded} of ${result.count} ${result.state} deliver${result.count === 1 ? "y" : "ies"} for ${result.peer}${result.refused ? `; ${result.refused} refused (${Object.entries(result.refusedBy ?? {}).map(([why, n]) => `${why} ${n}`).join(", ")})` : ""}`);
1035
+ return;
1036
+ }
1008
1037
  const [id, ...flags] = rest;
1009
- if (!id || !["show", "resolve"].includes(operation)) fail("usage: ahub queue show <id> | resolve <id> --action completed|retry|discard --reason <text> [--revision N]");
1038
+ if (!id || !["show", "resolve"].includes(operation)) fail("usage: ahub queue show <id> | resolve <id> --action completed|retry|discard --reason <text> [--revision N] | discard --peer <id> --reason <text> [--state queued|needs_review] [--yes]");
1010
1039
  if (operation === "show") {
1011
1040
  if (flags.length) fail("usage: ahub queue show <id>");
1012
1041
  const shown = await hub.request({ t: "queue", op: "show", id });
@@ -1191,6 +1220,50 @@ const commands: Record<string, () => Promise<void> | void> = {
1191
1220
 
1192
1221
  sdlc: async () => {
1193
1222
  const sub = args[0], id = args[1] === undefined ? undefined : Number(args[1]);
1223
+ if (sub === "authorize-release") {
1224
+ if (args.length !== 2 || !validReleaseVersion(args[1])) fail("usage: ahub sdlc authorize-release <version>");
1225
+ const result = hubGitSync(["rev-parse", "--verify", "HEAD"], { cwd, env: hubGitEnv(process.env, { repositoryIsolation: true }), encoding: "utf8", timeout: 2000 });
1226
+ const commit = result.stdout?.trim();
1227
+ if (result.status !== 0 || !commit || !/^[a-f0-9]{40}$/.test(commit)) fail("candidate commit unavailable");
1228
+ const hub = await connect();
1229
+ try { const reply = await hub.request({ t: "sdlc", op: sub, version: args[1], commit }, 30_000); if (!reply.ok) fail(reply.error); console.log(JSON.stringify(reply.authorization, null, 2)); }
1230
+ finally { hub.close(); }
1231
+ return;
1232
+ }
1233
+ if (sub === "release-guard" || sub === "release-guard-hook") {
1234
+ let original: Buffer | undefined;
1235
+ let request: Record<string, unknown>;
1236
+ if (sub === "release-guard-hook") {
1237
+ if (args.length !== 3) fail("release hook needs its remote name and URL");
1238
+ const chunks: Buffer[] = []; let size = 0;
1239
+ for await (const chunk of process.stdin) { const bytes = Buffer.from(chunk); size += bytes.length; if (size > 128 * 1024) fail("release push inventory exceeds the guard bound"); chunks.push(bytes); }
1240
+ original = Buffer.concat(chunks);
1241
+ let input: string;
1242
+ try { input = new TextDecoder("utf-8", { fatal: true }).decode(original); } catch { fail("release push inventory is not UTF-8"); }
1243
+ const parsed = parseReleasePushUpdates(args[1]!, args[2]!, input);
1244
+ if (!parsed.ok) fail(parsed.reason);
1245
+ // Ordinary branch pushes keep working with the daemon offline.
1246
+ if (!parsed.updates.length) { process.stdout.write(original); return; }
1247
+ request = { t: "sdlc", op: sub, remoteName: parsed.remoteName, remoteUrl: parsed.remoteUrl, stdin: input };
1248
+ } else {
1249
+ if (args[1] !== "--") fail("usage: ahub sdlc release-guard -- git push origin v<version> | gh release create v<version> | npm publish");
1250
+ const argv = args.slice(2), parsed = parseReleaseGuardArgv(argv);
1251
+ if (!parsed.ok) fail(parsed.reason);
1252
+ // Literal preflights cannot attest a command redirected by ambient target selectors.
1253
+ const selectors = ["GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR", ...(parsed.request.kind === "gh-release" ? ["GH_REPO", "GH_HOST"] : []), ...(parsed.request.kind === "npm-publish" ? Object.keys(process.env).filter(key => /^npm_config_(?:prefix|workspace|workspaces|userconfig|globalconfig|registry)$/i.test(key)) : [])];
1254
+ if (selectors.some(key => process.env[key] !== undefined)) fail("release guard refuses ambient target selectors");
1255
+ request = { t: "sdlc", op: sub, argv };
1256
+ }
1257
+ let hub: ControlClient | undefined;
1258
+ try {
1259
+ hub = await connect();
1260
+ const reply = await hub.request(request, 30_000);
1261
+ if (!reply.ok) fail(reply.error ?? "release authorization refused");
1262
+ if (original) process.stdout.write(original); else console.log("release authorization matched");
1263
+ } catch { fail("release authorization unavailable; the daemon must be reachable with a matching person approval"); }
1264
+ finally { hub?.close(); }
1265
+ return;
1266
+ }
1194
1267
  if (sub === "queue" || sub === "release") {
1195
1268
  const repo = args[1], liveIssues = args.slice(2).map(Number);
1196
1269
  if (!repo || args.length > 34 || (sub === "queue" && args.length !== 2) || liveIssues.some(id => !Number.isSafeInteger(id) || id <= 0)) fail("usage: ahub sdlc queue <owner/repository> | release <owner/repository> [live-issue...]");
@@ -1230,6 +1303,17 @@ const commands: Record<string, () => Promise<void> | void> = {
1230
1303
  if (!flags.rest.length || (scope === undefined) !== (reviewers === undefined)) fail("plan-review needs a reason and both --review and --reviewers when supplying review scope");
1231
1304
  return console.log(await taskOp("hub_plan_review", { id, revision, reason: flags.rest.join(" "), ...(scope !== undefined ? { review: { scope, reviewers: Number(reviewers) } } : {}), ...(flags.one["--ci"] !== undefined ? { ci: flags.one["--ci"] } : {}) }));
1232
1305
  }
1306
+ if (sub === "accept-departure") {
1307
+ const head = args[2], rawReason = args.slice(3).join(" "), reason = rawReason.trim();
1308
+ if (!Number.isSafeInteger(id) || id! <= 0 || typeof head !== "string" || !/^[a-f0-9]{40}$/.test(head) || !reason || rawReason.length > 2048) fail("usage: ahub sdlc accept-departure <task> <head> <reason...>");
1309
+ const hub = await connect();
1310
+ try {
1311
+ const reply = await hub.request({ t: "sdlc", op: "accept-departure", id, head, reason }, 30_000);
1312
+ if (!reply.ok) fail(reply.error);
1313
+ console.log(`task #${id}: plan departure accepted for the reviewed head`);
1314
+ } finally { hub.close(); }
1315
+ return;
1316
+ }
1233
1317
  if (sub === "accept") {
1234
1318
  // #481 S1: the person accepts the task's spec issue at its current body; agent shells are refused before this runs.
1235
1319
  const flags = takeFlags(args.slice(2), ["--issue", "--repo"], []);
@@ -1315,10 +1399,15 @@ const commands: Record<string, () => Promise<void> | void> = {
1315
1399
  if (args.length !== 2 || !/^\d+$/.test(args[1]!)) fail("usage: ahub review brief <task id>");
1316
1400
  return console.log(await taskOp("review_brief", { id: Number(args[1]) }));
1317
1401
  }
1318
- const { one, many, rest } = takeFlags(args, ["--head"], ["--unmet"]);
1402
+ const { one, many, rest } = takeFlags(args, ["--head", "--findings"], ["--unmet"]);
1319
1403
  const [id, verdict, ...note] = rest;
1320
- if (!id || !verdict) fail("usage: ahub review <id> approved|changes_requested [note...] [--unmet <item>]... | ahub review brief <id>");
1321
- console.log(await taskOp("hub_review", { id: Number(id), verdict, ...(one["--head"] ? { head: one["--head"] } : {}), note: note.join(" "), ...(many["--unmet"]?.length ? { unmet: many["--unmet"] } : {}) }));
1404
+ if (!id || !verdict) fail("usage: ahub review <id> approved|changes_requested [note...] [--unmet <item>]... [--head <sha>] [--findings <json>] | ahub review brief <id>");
1405
+ let findings: unknown;
1406
+ if (one["--findings"] !== undefined) {
1407
+ try { findings = JSON.parse(one["--findings"]); } catch { fail("--findings must be a JSON array"); }
1408
+ if (!Array.isArray(findings)) fail("--findings must be a JSON array");
1409
+ }
1410
+ console.log(await taskOp("hub_review", { id: Number(id), verdict, ...(one["--head"] ? { head: one["--head"] } : {}), note: note.join(" "), ...(findings === undefined ? {} : { findings }), ...(many["--unmet"]?.length ? { unmet: many["--unmet"] } : {}) }));
1322
1411
  },
1323
1412
 
1324
1413
  remember: async () => console.log(await taskOp("hub_remember", { text: freeText(args, "ahub remember <text>") })),
@@ -1390,7 +1479,7 @@ const commands: Record<string, () => Promise<void> | void> = {
1390
1479
  roster: async () => {
1391
1480
  const options = outputOptions(), flags = args.filter(arg => arg.startsWith("--")), positional = outputArguments().filter(arg => arg !== "--yes" && arg !== "--now");
1392
1481
  const [sub = "list", peer, ...extra] = positional;
1393
- if (!["list", "add", "remove"].includes(sub) || extra.length || (sub === "list" ? peer !== undefined : !peerDefinition(peer)) || flags.some(flag => !["--json", "--full", "--yes", "--now"].includes(flag) && !flag.startsWith("--color=")) || (sub !== "list" && options.json) || (sub !== "remove" && flags.some(flag => ["--yes", "--now"].includes(flag)))) fail("usage: ahub roster [--json] | add <peer> | remove <peer> [--yes] [--now]");
1482
+ if (!["list", "add", "remove"].includes(sub) || extra.length || (sub === "list" ? peer !== undefined : !peerDefinition(peer) && !(sub === "remove" && typeof peer === "string" && staleInstanceKey(peer, projectCatalog))) || flags.some(flag => !["--json", "--full", "--yes", "--now"].includes(flag) && !flag.startsWith("--color=")) || (sub !== "list" && options.json) || (sub !== "remove" && flags.some(flag => ["--yes", "--now"].includes(flag)))) fail("usage: ahub roster [--json] | add <peer> | remove <peer> [--yes] [--now]");
1394
1483
  const hub = await connect();
1395
1484
  try {
1396
1485
  requireNamedPeerFeature(hub, peer);
@@ -1400,7 +1489,8 @@ const commands: Record<string, () => Promise<void> | void> = {
1400
1489
  if (!Array.isArray(view.roster)) fail("upgrade the running hub to use ahub roster");
1401
1490
  return options.json ? console.log(JSON.stringify(view.roster, null, 2)) : printOutput(renderRoster(view.roster, options.columns), options.color);
1402
1491
  }
1403
- const key = `roster.${peer}`, value = sub === "add";
1492
+ // A key whose instance is no longer declared is deleted (null), never set to false: there is no peer left for it to switch off (#225).
1493
+ const key = `roster.${peer}`, stale = sub === "remove" && !peerDefinition(peer) && staleInstanceKey(peer!, projectCatalog), value = stale ? null : sub === "add";
1404
1494
  const preview = await request({ t: "settings_preview", key, value });
1405
1495
  printOutput((preview.lines ?? []).map((text: string) => [{ text }]), options.color);
1406
1496
  if (sub === "remove" && !flags.includes("--yes")) {
@@ -1685,8 +1775,15 @@ const commands: Record<string, () => Promise<void> | void> = {
1685
1775
  const r = researchReport(records, from);
1686
1776
  console.log(args.includes("--json") ? JSON.stringify(r, null, 2) : formatResearch(r).join("\n"));
1687
1777
  },
1688
- report: () => {
1778
+ report: async () => {
1689
1779
  const options = outputOptions();
1780
+ if (args.includes("--dogfood")) {
1781
+ let from: number;
1782
+ try { from = dogfoodSince(args.filter(arg => !arg.startsWith("--color="))); }
1783
+ catch { return fail("usage: ahub report --dogfood [--since 7d|<iso>] [--json]"); }
1784
+ const scorecard = await readDogfood(cwd, stateDir, from);
1785
+ return console.log(options.json ? JSON.stringify(scorecard, null, 2) : formatDogfood(scorecard).join("\n"));
1786
+ }
1690
1787
  const by = args.indexOf("--by");
1691
1788
  if (by >= 0 && args[by + 1] !== "task") return fail("--by takes task");
1692
1789
  const events = readEvents(join(stateDir, "events.jsonl"), since());
@@ -1694,6 +1791,32 @@ const commands: Record<string, () => Promise<void> | void> = {
1694
1791
  if (options.json) return console.log(JSON.stringify(data, null, 2));
1695
1792
  printOutput(renderReport(data, options.columns, Date.now(), by >= 0), options.color);
1696
1793
  },
1794
+ usage: async () => {
1795
+ const options = outputOptions();
1796
+ const rest = outputArguments();
1797
+ const sub = rest[0] !== undefined && !rest[0].startsWith("--") ? rest[0] : undefined;
1798
+ if (sub !== undefined && !["limits", "total"].includes(sub)) fail("usage: ahub usage [--since 7d|<iso>] [--by peer|task|issue|pr|release|day|model] [--json] | limits [--peer <id>] [--limit <n>] [--json] | total [--json]");
1799
+ const flags = sub === undefined ? rest : rest.slice(1);
1800
+ const valueFlags = sub === "limits" ? ["--peer", "--limit"] : sub === undefined ? ["--since", "--by"] : [];
1801
+ for (let i = 0; i < flags.length; i++) {
1802
+ if (!valueFlags.includes(flags[i]!)) fail(`unknown argument ${JSON.stringify(flags[i]!.slice(0, 40))}; run ahub help`);
1803
+ if (flags[++i] === undefined || flags[i]!.startsWith("--")) fail(`${flags[i - 1]} needs a value`);
1804
+ }
1805
+ const value = (flag: string) => { const at = args.indexOf(flag); return at >= 0 ? args[at + 1] : undefined; };
1806
+ const by = value("--by"), peer = value("--peer"), limitText = value("--limit");
1807
+ const limit = limitText === undefined ? undefined : /^\d+$/.test(limitText) ? Number(limitText) : NaN;
1808
+ if (limit !== undefined && !(limit >= 1 && limit <= LIMITS_MAX)) fail(`--limit takes a whole number from 1 to ${LIMITS_MAX}`);
1809
+ if (by !== undefined && !(USAGE_GROUPS as readonly string[]).includes(by)) fail(`--by takes ${USAGE_GROUPS.join(", ")}`);
1810
+ const from = since();
1811
+ const hub = await connect();
1812
+ const reply = await hub.request(sub === "limits" ? { t: "usage", op: "limits", ...(peer ? { peer } : {}), ...(limit !== undefined ? { limit } : {}) } : sub === "total" ? { t: "usage", op: "total" }
1813
+ : { t: "usage", op: "query", by: by ?? "peer", ...(from ? { since: from } : {}) }, 35_000);
1814
+ hub.close();
1815
+ if (!reply.ok) return fail(String(reply.error ?? "usage refused"));
1816
+ if (reply.behind) process.stderr.write("ahub: the usage roll-up is still catching up with the event log: these numbers are partial; run it again in a few seconds\n");
1817
+ if (options.json) return console.log(JSON.stringify(sub === "limits" ? { limits: reply.limits, current: reply.current } : sub === "total" ? reply.total : reply.usage, null, 2));
1818
+ printOutput(sub === "limits" ? renderLimits(reply.limits, reply.current, options.columns) : sub === "total" ? renderTotal(reply.total, options.columns) : renderUsage(reply.usage, options.columns), options.color);
1819
+ },
1697
1820
  facts: async () => {
1698
1821
  if (!args.includes("--hook")) return fail("usage: ahub facts --hook (a Claude Code PreToolUse, PostToolUse and Stop hook)");
1699
1822
  try {
@@ -1931,6 +2054,8 @@ const commands: Record<string, () => Promise<void> | void> = {
1931
2054
  }
1932
2055
  fail("usage: ahub role list [--group <group>] | show <id> | assign <peer> <id> | clear <peer> [<id>]");
1933
2056
  },
2057
+ agents: () => runAgents(args, configuredCommands(loadConfig(cwd)), fail),
2058
+
1934
2059
  doctor: async () => {
1935
2060
  const options = outputOptions();
1936
2061
  if (args.includes("--orphans")) return orphanDoctor(args.includes("--kill"), options);
@@ -1983,10 +2108,14 @@ const commands: Record<string, () => Promise<void> | void> = {
1983
2108
  [servers.length ? `${servers.length} besides the hub's own would load in the hub-owned jcode (${servers.join(", ")}); jcode has no switch to choose them, so remove them from its config or use another JCODE_HOME` : "",
1984
2109
  unreadable.length ? `unreadable or unsafe, not listed: ${unreadable.join(", ")}` : ""].filter(Boolean).join("; ") || "none besides the hub's own");
1985
2110
  } else {
1986
- const v = version(probe.command[0]!);
1987
- row(v === probe.expected, probe.name, v === probe.expected ? probe.confirmed : probe.unconfirmed);
2111
+ const verdict = assessVersion(probe.compat, COMPAT[probe.compat].parse(version(probe.command[0]!) ?? ""));
2112
+ row(verdict.ok ? verdict.state === "verified" || undefined : false, probe.name, verdict.ok ? verdict.note ?? probe.confirmed : `${probe.unconfirmed}: ${verdict.reason}`);
1988
2113
  }
1989
2114
  }
2115
+ // #691: one line for each installed gated CLI that is not on a verified version.
2116
+ for (const agent of agentVersions(configuredCommands(config))) {
2117
+ if (agent.executable && !(agent.verdict.ok && agent.verdict.state === "verified")) row(agent.verdict.ok ? undefined : false, `${agent.id} version`, agent.verdict.ok ? agent.verdict.note ?? agent.verdict.state : agent.verdict.reason);
2118
+ }
1990
2119
  const omni = new OmniRoute(config.omniroute);
1991
2120
  const gateway = await omni.base();
1992
2121
  row(gateway ? true : config.omniroute.urls.length || process.env.AGENTHUB_OMNIROUTE_URL ? false : undefined, "omniroute", gateway ? `${new URL(gateway).host} healthy` : config.omniroute.urls.length || process.env.AGENTHUB_OMNIROUTE_URL ? "no candidate reachable (VPN off?); ahub local cannot run" : "not configured: set omniroute.urls in .agenthub/config.local.json (any OpenAI-compatible gateway); ahub local cannot run");
package/src/cli/output.ts CHANGED
@@ -5,6 +5,7 @@ import { cells, TABLES, table, fitLine, wrap, duration, relative, plural, termin
5
5
  import { taskProgress } from "../ui/task-progress.ts";
6
6
  import { backendLabel } from "./status-lines.ts";
7
7
  import { formatReport, formatTaskReport, type Report, type TaskReport } from "../hub/report.ts";
8
+ import type { Counter, LimitsResult, TotalResult, UsageResult } from "../hub/usage-rollup.ts";
8
9
 
9
10
  export interface DoctorCheck { section: "Tools" | "Hub" | "Config" | "Models" | "Memory"; level: "ok" | "warn" | "fail" | "unknown"; name: string; detail: string }
10
11
  export interface StatusOutput {
@@ -17,7 +18,7 @@ export interface StatusOutput {
17
18
  isolatedChecks?: { pending?: number; orphans?: string[]; unknownReceipts?: readonly string[]; maintenanceUnavailable?: boolean };
18
19
  memoryProfile?: { profile?: string; generation?: string; state?: string; agentMemVersion?: string; skippedRows?: number; error?: string };
19
20
  taskWip?: Record<string, { wip: number; wipLimit: number }>;
20
- pid?: number; controlPort?: number; cwd?: string; version?: string; peers: Record<string, any>; roleInfo?: Record<string, any>; budget?: Record<string, any>; planner?: { peers?: string[]; source?: string };
21
+ 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 };
21
22
  tasks?: Record<string, number>; models?: { backends?: any[] }; permissionDefaults?: any[]; deliveryError?: string; crash?: string[];
22
23
  }
23
24
  export interface BudgetOutput { budget: Record<string, any>; gate?: number }
@@ -245,9 +246,12 @@ export function renderStatus(data: StatusOutput, columns?: number, now = Date.no
245
246
  }
246
247
  if (p.toolsOnly) out.push(...detail(definitionOf(id)?.attach?.pullOnlyForms?.includes("tui") && p.pullOnly ? "pull-only" : "tools-only", flat(p.toolsOnly).replace(/^(?:tools-only|pull-only):\s*/, ""), columns, "attention"));
247
248
  if (p.pullOnly && definitionOf(id)?.attach?.pullOnlyForms?.includes("tui")) out.push(...detail(`${id} last read`, typeof p.lastInboxAt === "number" ? relative(p.lastInboxAt, now) : "never", columns, "muted"));
249
+ const actionPolicy = data.actionPolicyInfo?.[id];
250
+ if (actionPolicy?.note) out.push(...detail(`${id} action policy`, actionPolicy.note, columns, actionPolicy.enforceable ? "muted" : "attention"));
248
251
  if (p.permissionNote) out.push(...detail("permission", p.permissionNote, columns, "muted"));
249
252
  if (typeof p.statusNotice?.message === "string" && p.statusNotice.message) out.push(...detail("status notice", p.statusNotice.message, columns, "attention"));
250
253
  if (p.requestedModel && p.servedBy && p.requestedModel !== p.servedBy) out.push(...detail("requested model", p.requestedModel, columns));
254
+ if (typeof p.modelSource === "string" && p.modelSource) out.push(...detail("model source", flat(p.modelSource), columns, "muted")); // an extra Jcode instance (#685): names only, never a URL or token
251
255
  if (typeof p.importantWaitingSince === "number" && p.importantWaitingNote) out.push(...detail("important waiting", `${p.importantWaitingNote.replace(/^\d+ important messages? waiting/, "important messages waiting")}`, columns, "attention"));
252
256
  if (typeof p.oldestQueuedAt === "number") out.push(...detail(`${id} oldest queued`, relative(p.oldestQueuedAt, now), columns, "muted"));
253
257
  return out;
@@ -431,3 +435,42 @@ export function renderReport(report: Report | TaskReport, columns?: number, now
431
435
  }
432
436
  return out;
433
437
  }
438
+
439
+ // ---- ahub usage (#683 U2): every number names its source; an unknown number prints `unknown`, never 0 ----------------------------------------------
440
+ const SOURCE_NAME: Record<Counter["source"], string> = { native: "native", count: "hub count", manual: "manual", partial: "partial, at least", unknown: "unknown" };
441
+ /** `1234 (native)`, `1234 (hub count)`, or `unknown`: a missing counter is never shown as zero. */
442
+ export const usageNumber = (c: Counter): string => c.value === null ? "unknown" : `${c.value} (${SOURCE_NAME[c.source]})`;
443
+ const usageCell = (c: Counter): OutputCell => c.value === null ? span("unknown", "muted") : span(usageNumber(c), "number");
444
+
445
+ export function renderUsage(result: UsageResult, columns?: number): Span[][] {
446
+ const out = lines(`usage by ${result.by}: ${result.coverage.records} records, ${result.coverage.withUsage} with provider counters, ${result.coverage.withoutUsage} without`, columns, "muted");
447
+ const header = ["KEY", "TOTAL", "WITHOUT CACHE", "CACHE READ", "INPUT", "OUTPUT", "REASONING", "HUB COUNT"];
448
+ const row = (r: UsageResult["rows"][number]) => [span(r.pii ? `${r.key} [pii]` : r.key), usageCell(r.total), usageCell(r.totalWithoutCache), usageCell(r.cacheRead), usageCell(r.input), usageCell(r.output), usageCell(r.reasoning), usageCell(r.counted)];
449
+ const all = [...result.rows, ...(result.unattributed ? [result.unattributed] : [])];
450
+ if (!all.length) return [...out, ...lines("no usage recorded in this period", columns)];
451
+ return [...out, [], ...rows(header, all.map(row), columns, undefined, true),
452
+ ...lines("TOTAL is the provider's own total, cache reads included; WITHOUT CACHE removes them. HUB COUNT is what the hub counted itself and covers peers that report no usage record. unknown is never zero.", columns, "muted")];
453
+ }
454
+
455
+ export function renderLimits(result: LimitsResult, current: unknown, columns?: number): Span[][] {
456
+ const out = lines(`limit history: ${result.truncated ? `the newest ${result.shown} of ${result.total}` : `${result.total}`} events, ${result.open.length} open pause${result.open.length === 1 ? "" : "s"}`, columns, "muted");
457
+ if (result.truncated) out.push(...lines(`${result.omitted} older events are not shown: use --limit <n> (up to ${result.maxLimit}) for more`, columns, "attention"));
458
+ for (const o of result.open) out.push(...lines(`open pause: ${o.peer} ${o.window} since ${o.since}, resets ${new Date(o.resetsAt).toISOString()}`, columns, "attention"));
459
+ for (const o of result.endedUnknown) out.push(...lines(`pause ended, end unknown: ${o.peer} ${o.window} since ${o.since} (the hub no longer holds it, for example after ahub reset; no resume was recorded)`, columns, "muted"));
460
+ const windows = current && typeof current === "object" ? Object.entries(current as Record<string, { windows?: { id: string; used: number; resetsAt?: number }[] }>) : [];
461
+ const live = windows.flatMap(([peer, b]) => (b?.windows ?? []).map(w => [span(peer), span(w.id), span(`${Math.round(w.used * 100)}%`, "number"), span(w.resetsAt ? new Date(w.resetsAt).toISOString() : "unknown", w.resetsAt ? undefined : "muted")]));
462
+ if (live.length) out.push([], ...lines("current windows", columns, "strong"), ...rows(["PEER", "WINDOW", "USED", "RESETS"], live, columns, undefined, true));
463
+ const hist = result.history.map(h => [span(h.at), span(h.type.replace("budget_", "").replace("quota_reset", "reset")), span(h.peer), span(h.window), span(Object.entries(h.detail).map(([k, v]) => `${k} ${v}`).join(", "), "muted")]);
464
+ if (hist.length) out.push([], ...lines("history", columns, "strong"), ...rows(["AT", "EVENT", "PEER", "WINDOW", "DETAIL"], hist, columns, undefined, true));
465
+ return out;
466
+ }
467
+
468
+ export function renderTotal(result: TotalResult, columns?: number): Span[][] {
469
+ const out = lines(`lifetime development tokens since ${result.since ?? "the first event"}`, columns, "muted");
470
+ const table = result.peers.map(p => [span(p.peer), usageCell(p.tokens), usageCell(p.withoutCache), usageCell(p.cacheRead)]);
471
+ table.push([span("ALL PEERS", "strong"), usageCell(result.overall.tokens), usageCell(result.overall.withoutCache), usageCell(result.overall.cacheRead)]);
472
+ const left = (label: string, peers: string[]) => peers.length ? lines(`${label} leaves out ${peers.join(", ")} (unknown, not counted as 0): a lower bound`, columns, "attention") : [];
473
+ return [...out, [], ...rows(["PEER", "TOTAL", "WITHOUT CACHE", "CACHE READ"], table, columns, undefined, true),
474
+ ...left("ALL PEERS TOTAL", result.overall.tokens.source === "partial" ? result.unknownPeers.tokens : []), ...left("ALL PEERS CACHE READ", result.overall.cacheRead.source === "partial" ? result.unknownPeers.cacheRead : []),
475
+ ...lines("A peer with no provider counters (for example jcode, kimi) shows the hub's own count where one exists, otherwise unknown. The overall WITHOUT CACHE is unknown unless every peer's cache split is known.", columns, "muted")];
476
+ }
@@ -21,7 +21,7 @@ export function launcherPreview(tool: "claude" | "codex" | "kimi" | "pi" | "qwen
21
21
  if (mode >= 0 && args[mode + 1] !== "headless") throw new Error("extra instances require headless mode");
22
22
  if (mode < 0 && !args.includes("--headless")) args.push("--mode", "headless");
23
23
  }
24
- const result = preview({ tool, args, cwd, stateDir, cliDir: import.meta.dir, unattended, config, definition,
24
+ const result = preview({ tool, args, cwd, stateDir, cliDir: import.meta.dir, unattended, config, definition, ...(selected.instance ? { instance: selected.instance } : {}),
25
25
  baseEnvNames: Object.keys(childEnv()).concat(["AGENTHUB_STATE_DIR", "AGENTHUB_PROJECT_DIR"]),
26
26
  helpers: { buildLaunch: (native, argv, ctx) => buildLaunch(native, argv, { ...ctx, ...(selected.instance ? { instance: selected.instance } : {}) }), claudeObservationHooks, buildKimiLaunch, buildKimiTuiLaunch, nativeLaunchEnv, childEnv,
27
27
  startModeOf, kimiMcpPlan, jcodeCommand, jcodeTuiCommand, qwenSessionId, relayModelIds, buildPiLaunch },
@@ -0,0 +1,82 @@
1
+ /** Literal release targets only. Parsing never executes a command or reads the repository. */
2
+ export type ReleaseGuardRequest =
3
+ | { kind: "tag-push"; version: string; tag: string; sourceRef: string; remote: string }
4
+ | { kind: "gh-release"; version: string; tag: string; target?: string }
5
+ | { kind: "npm-publish" };
6
+ export interface ReleasePushUpdate { localRef: string; localOid: string; remoteRef: string; remoteOid: string; version: string; }
7
+ type Refusal = { ok: false; reason: string };
8
+ export type ReleaseGuardParse = { ok: true; request: ReleaseGuardRequest } | Refusal;
9
+ export type ReleasePushParse = { ok: true; remoteName: string; remoteUrl: string; updates: ReleasePushUpdate[]; stdin: string } | Refusal;
10
+ const refuse = (): Refusal => ({ ok: false, reason: "release target is ambiguous, unsupported or invalid" });
11
+ const SEMVER = /^(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)(?:-[0-9A-Za-z.-]+)?(?:[+][0-9A-Za-z.-]+)?$/;
12
+ const oid = (value: string) => /^[0-9a-f]{40}$/.test(value);
13
+ const zero = (value: string) => /^0{40}$/.test(value);
14
+ const literal = (value: string) => value.length > 0 && value.length <= 4096 && !/[\s\0-\x1f\x7f$`*?\\]/.test(value);
15
+ const ref = (value: string) => literal(value) && !value.startsWith("-") && !value.includes("..") && !value.includes("@{") && !value.includes("//") && !value.endsWith("/") && !value.endsWith(".") && !value.endsWith(".lock") && !"~^:[]()".split("").some(char => value.includes(char));
16
+ const versionOf = (tag: string): string | undefined => tag.startsWith("v") && SEMVER.test(tag.slice(1)) ? tag.slice(1) : undefined;
17
+
18
+ // Hook values are opaque Git data, not shell source. Unicode and shell-looking
19
+ // characters are valid in refs; only Git's forbidden ref syntax is rejected.
20
+ const hookText = (value: string) => value.length > 0 && value.length <= 4096 && !/[\p{Cc}]/u.test(value);
21
+ const hookRef = (value: string) => hookText(value) && !/[\0-\x20\x7f]/.test(value) && !"~^:?*[\\".split("").some(char => value.includes(char)) && !value.includes("..") && !value.includes("@{") && !value.includes("//") && !value.endsWith("/") && !value.endsWith(".") && !value.split("/").some(part => !part || part.startsWith(".") || part.endsWith(".lock"));
22
+
23
+ export function parseReleaseGuardArgv(argv: readonly string[]): ReleaseGuardParse {
24
+ if (!Array.isArray(argv) || !argv.length || argv.length > 128 || !argv.every(word => typeof word === "string" && word.length <= 4096 && !/[\0-\x1f\x7f]/.test(word)) || Buffer.byteLength(argv.join("\0")) > 16 * 1024) return refuse();
25
+ const [program, ...args] = argv;
26
+ if (program === "git" && args[0] === "push") {
27
+ if (args.length !== 3 || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(args[1] ?? "")) return refuse();
28
+ const spec = args[2]!, parts = spec.split(":");
29
+ if (parts.length > 2 || spec.startsWith("+")) return refuse();
30
+ const destination = parts.length === 2 ? parts[1]! : parts[0]!;
31
+ const tag = destination.startsWith("refs/tags/") ? destination.slice(10) : destination;
32
+ const version = versionOf(tag), sourceRef = parts[0]!;
33
+ if (!version || !ref(sourceRef) || parts.length === 2 && !destination.startsWith("refs/tags/")) return refuse();
34
+ return { ok: true, request: { kind: "tag-push", version, tag, sourceRef, remote: args[1]! } };
35
+ }
36
+ if (program === "gh" && args[0] === "release" && args[1] === "create") {
37
+ const tag = args[2] ?? "", version = versionOf(tag);
38
+ if (!version) return refuse();
39
+ let target: string | undefined;
40
+ const valued = new Set(["--title", "-t", "--notes", "-n", "--notes-file", "-F", "--notes-start-tag", "--target"]);
41
+ const flags = new Set(["--verify-tag", "--generate-notes", "--draft", "-d", "--prerelease", "-p", "--fail-on-no-commits"]);
42
+ for (let i = 3; i < args.length; i++) {
43
+ const flag = args[i]!;
44
+ if (flags.has(flag)) continue;
45
+ if (!valued.has(flag) || args[i + 1] === undefined) return refuse();
46
+ const value = args[++i]!;
47
+ if (flag === "--target") { if (target !== undefined || !ref(value)) return refuse(); target = value; }
48
+ }
49
+ return { ok: true, request: { kind: "gh-release", version, tag, ...(target === undefined ? {} : { target }) } };
50
+ }
51
+ if (program === "npm" && args[0] === "publish") {
52
+ const flags = new Set(["--provenance", "--ignore-scripts", "--dry-run"]);
53
+ for (let i = 1; i < args.length; i++) {
54
+ if (flags.has(args[i]!)) continue;
55
+ if (args[i] === "--access" && ["public", "restricted"].includes(args[i + 1] ?? "")) { i++; continue; }
56
+ if (args[i] === "--tag" && /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(args[i + 1] ?? "")) { i++; continue; }
57
+ return refuse();
58
+ }
59
+ return { ok: true, request: { kind: "npm-publish" } };
60
+ }
61
+ return refuse();
62
+ }
63
+
64
+ /** Validate every hook row before selecting version-tag updates. Preserve the exact original input. */
65
+ export function parseReleasePushUpdates(remoteName: string, remoteUrl: string, stdin: string): ReleasePushParse {
66
+ if (typeof remoteName !== "string" || typeof remoteUrl !== "string" || typeof stdin !== "string" || !hookText(remoteName) || !hookText(remoteUrl) || Buffer.byteLength(stdin) > 128 * 1024) return refuse();
67
+ const lines = stdin.endsWith("\n") ? stdin.slice(0, -1).split("\n") : stdin.split("\n");
68
+ if (stdin === "") lines.length = 0;
69
+ if (lines.length > 128) return refuse();
70
+ const updates: ReleasePushUpdate[] = [];
71
+ for (const line of lines) {
72
+ const fields = line.split(" ");
73
+ if (fields.length !== 4) return refuse();
74
+ const [localRef, localOid, remoteRef, remoteOid] = fields as [string, string, string, string];
75
+ if ((!hookRef(localRef) && localRef !== "(delete)") || !hookRef(remoteRef) || !remoteRef.startsWith("refs/") || !oid(localOid) || !oid(remoteOid)) return refuse();
76
+ if (!remoteRef.startsWith("refs/tags/v")) continue;
77
+ const version = versionOf(remoteRef.slice(10));
78
+ if (!version || zero(localOid) || localRef === "(delete)") return refuse();
79
+ updates.push({ localRef, localOid, remoteRef, remoteOid, version });
80
+ }
81
+ return { ok: true, remoteName, remoteUrl, updates, stdin };
82
+ }