@staix/agent-hub 0.12.45 → 0.14.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 (108) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +85 -116
  3. package/docs/agent-notes/README.md +6 -0
  4. package/docs/agent-notes/adapters.md +10 -7
  5. package/docs/agent-notes/budget.md +7 -1
  6. package/docs/agent-notes/bus.md +6 -4
  7. package/docs/agent-notes/daemon.md +14 -2
  8. package/docs/agent-notes/local-worker.md +2 -1
  9. package/docs/agent-notes/tasks.md +16 -3
  10. package/docs/agent-notes/tests.md +5 -3
  11. package/docs/dogfood.md +95 -0
  12. package/docs/events.md +14 -1
  13. package/docs/operations.md +290 -27
  14. package/docs/profiles.md +2 -2
  15. package/docs/quickstart.md +13 -3
  16. package/docs/sdlc.md +59 -8
  17. package/docs/security.md +26 -13
  18. package/docs/smoke.md +9 -4
  19. package/docs/specs/2026-09-19-agent-hub-design.md +797 -8
  20. package/docs/verification/2026-10-19-reviewer-isolation.md +2 -0
  21. package/docs/verification/2026-10-22-code-facts.md +78 -0
  22. package/docs/verification/2026-10-22-codex-reviewer-isolation.md +61 -0
  23. package/docs/verification/2026-10-22-kimi-qwen-compat.md +79 -0
  24. package/docs/verification/2026-10-23-0.13.0.md +89 -0
  25. package/docs/verification/2026-10-23-0.14.0.md +69 -0
  26. package/docs/verification/dogfood/2026-10-22.md +3109 -0
  27. package/docs/verified.json +22 -17
  28. package/package.json +1 -1
  29. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  30. package/plugins/agent-hub/server.js +702 -347
  31. package/src/adapters/agy/entry.ts +5 -4
  32. package/src/adapters/claude-channel.ts +20 -9
  33. package/src/adapters/codex-appserver.ts +18 -2
  34. package/src/adapters/codex-review-isolation.ts +29 -11
  35. package/src/adapters/compat.ts +140 -0
  36. package/src/adapters/definitions/builtin-metadata.ts +3 -2
  37. package/src/adapters/definitions/native-cli-doctor.ts +12 -5
  38. package/src/adapters/definitions/native-cli-entry-codex.ts +13 -0
  39. package/src/adapters/definitions/native-cli-entry-jcode.ts +9 -6
  40. package/src/adapters/definitions/native-cli-jcode-mcp.ts +9 -2
  41. package/src/adapters/definitions/native-cli-preview-common.ts +2 -0
  42. package/src/adapters/definitions/native-cli-preview-jcode.ts +8 -2
  43. package/src/adapters/definitions/native-cli-preview-qwen.ts +2 -1
  44. package/src/adapters/definitions/qwen-native.ts +7 -3
  45. package/src/adapters/definitions/qwen.ts +2 -1
  46. package/src/adapters/jcode.ts +72 -31
  47. package/src/adapters/kimi-review.ts +6 -3
  48. package/src/adapters/registry-types.ts +3 -1
  49. package/src/adapters/registry.ts +1 -0
  50. package/src/adapters/reviewer-print.ts +34 -7
  51. package/src/cli/agents.ts +40 -0
  52. package/src/cli/console-review.ts +54 -0
  53. package/src/cli/console-state.ts +124 -29
  54. package/src/cli/console-symbols.ts +74 -0
  55. package/src/cli/console-usage.ts +89 -0
  56. package/src/cli/console.ts +26 -1
  57. package/src/cli/help.ts +17 -6
  58. package/src/cli/identity.ts +29 -6
  59. package/src/cli/init.ts +109 -11
  60. package/src/cli/main.ts +127 -11
  61. package/src/cli/output.ts +91 -3
  62. package/src/cli/preview.ts +1 -1
  63. package/src/cli/sdlc-release-guard.ts +82 -0
  64. package/src/cli/status-lines.ts +1 -1
  65. package/src/hub/action-class.ts +362 -0
  66. package/src/hub/action-policy.ts +106 -0
  67. package/src/hub/board.ts +65 -9
  68. package/src/hub/budget.ts +71 -2
  69. package/src/hub/bus.ts +43 -3
  70. package/src/hub/checks.ts +80 -31
  71. package/src/hub/code-facts.ts +223 -0
  72. package/src/hub/config-apply.ts +10 -1
  73. package/src/hub/config-trust.ts +7 -4
  74. package/src/hub/daemon.ts +319 -34
  75. package/src/hub/dogfood-read.ts +199 -0
  76. package/src/hub/dogfood.ts +163 -0
  77. package/src/hub/envelope.ts +1 -1
  78. package/src/hub/events.ts +11 -2
  79. package/src/hub/hub-tools.ts +26 -3
  80. package/src/hub/inbox-polling.ts +25 -0
  81. package/src/hub/instances.ts +87 -5
  82. package/src/hub/manager.ts +2 -2
  83. package/src/hub/profiles.ts +7 -2
  84. package/src/hub/release-worktree.ts +56 -0
  85. package/src/hub/review-brief.ts +36 -0
  86. package/src/hub/sdlc-metrics.ts +260 -0
  87. package/src/hub/sdlc-queue.ts +31 -9
  88. package/src/hub/sdlc-release-authorization.ts +173 -0
  89. package/src/hub/sdlc.ts +54 -14
  90. package/src/hub/settings.ts +5 -2
  91. package/src/hub/snapshots.ts +93 -8
  92. package/src/hub/spec-read.ts +31 -0
  93. package/src/hub/symbol-normalizer.ts +26 -0
  94. package/src/hub/task-code-group.ts +52 -0
  95. package/src/hub/task-code-symbols.ts +90 -0
  96. package/src/hub/task-sweep.ts +3 -2
  97. package/src/hub/tasks.ts +455 -67
  98. package/src/hub/test-integrity.ts +162 -0
  99. package/src/hub/ui.ts +3 -0
  100. package/src/hub/usage-rollup.ts +419 -0
  101. package/src/local/sandbox.ts +5 -3
  102. package/src/ui/index.html +274 -2
  103. package/templates/AGENTS.block.md +1 -1
  104. package/templates/profiles/dev.json +6 -1
  105. package/templates/profiles/general.json +6 -1
  106. package/templates/profiles/research.json +6 -1
  107. package/templates/profiles/writing.json +6 -1
  108. package/templates/release-guard.block.md +6 -0
package/src/cli/help.ts CHANGED
@@ -11,7 +11,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
11
11
  ["ahub projects [--json] [--color=auto|always|never]", "list registered projects and live status"],
12
12
  ["ahub projects remove <id>", "forget a stopped registration (keeps project files)"],
13
13
  ["ahub setup [--yes]", "install or update the Claude Code channel plugin from this package, then run doctor"],
14
- ["ahub init [--profile dev|research|writing|general] [--shadow] [--dry-run --json]", "preview or write .agenthub/config.json and the AGENTS.md marker block (drops a legacy CLAUDE.md block)"],
14
+ ["ahub init [--profile dev|research|writing|general] [--shadow] [--release-guard] [--dry-run --json]", "preview or write .agenthub/config.json and the AGENTS.md marker block (drops a legacy CLAUDE.md block)"],
15
15
  ["ahub --live-test [--project <path|id>] <command> [args...]", "user-authorized operational console delegation; ordinary calls retain peer identity; person-authored content refuses"],
16
16
  ["ahub help [command]", "this help, or one command's entries; also --help and -h"],
17
17
  ["ahub version | --version", "print the installed version"],
@@ -26,18 +26,24 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
26
26
  ["ahub pet --palette <blue|teal|violet|ochre>", "save the floating window's base color to pet.palette in .agenthub/config.local.json"],
27
27
  ["ahub sdlc status [task] | explain <task> | export <task> | record <task> <receipt-json>", "read head-bound lifecycle gates and the next actor/action (person only)"],
28
28
  ["ahub sdlc queue <owner/repo> | release <owner/repo> [live-issue...]", "read-only merge and release evidence (person only)"],
29
+ ["ahub sdlc authorize-release <version>", "approve this daemon checkout commit, version and fresh docs (person only; restart revokes)"],
30
+ ["ahub sdlc release-guard -- <literal git push|gh release create|npm publish argv>", "read-only authorization preflight; init --release-guard installs the tag pre-push hook"],
31
+ ["ahub sdlc release-guard-hook <remote-name> <remote-url>", "internal pre-push readback; validates version tags and forwards exact stdin on success"],
29
32
  ["ahub sdlc accept <task> --issue <n> [--repo owner/repo]", "accept the task's spec issue at its current body; build entry needs it in enforce (person only)"],
30
33
  ["ahub sdlc accept-departure <task> <head> <reason...>", "accept current plan departures on that exact PR head (person only)"],
31
34
  ["ahub sdlc plan-review <task> <revision> <reason...> [--review full|delta|none --reviewers N] [--ci full|focused|reuse]", "record planner review, scope and host-read issue metadata"],
32
35
  ["ahub sdlc train report|configure|cut <release-id> [revision] [json]", "plan a release and freeze its approved scope after resource/quota admission"],
33
36
  ["ahub sdlc release-assign <task> <revision> <release-id|none> <reason...>", "assign approved/in-flight work without changing execution"],
34
37
  ["ahub sdlc costs [owner/repo]", "read bounded planner cost metrics for linked merged PRs"],
38
+ ["ahub sdlc metrics [--since <YYYY-MM-DD|ISO time>]", "read SDLC metrics and control band tiers from board history (planner or person; read-only)"],
35
39
  ["ahub backlog groom | return <task> <revision> <reason...>", "read grooming findings or return accepted untouched work to backlog (planner/person)"],
36
40
  ["ahub status [--json] [--full] [--color=auto|always|never]", "the hub, its peers, model backends and task counts"],
37
41
  ["ahub status --all [--json] [--color=auto|always|never]", "show every registered project"],
38
42
  ["ahub logs [-f]", "the last 100 lines of hub.log; -f shows the last 10 and follows it"],
39
- ["ahub doctor [--json] [--color=auto|always|never]", "check the tools, daemon, plugin, gateway, models and memory worker this project uses"],
43
+ ["ahub doctor [--json] [--full] [--color=auto|always|never]", "check the tools, daemon, plugin, gateway, models and memory worker this project uses"],
40
44
  ["ahub doctor --orphans [--kill] [--json] [--color=auto|always|never]", "list registrations whose project root is gone; --kill stops their daemons (SIGTERM, then SIGKILL) only after the process identity checks out"],
45
+ ["ahub agents [--json]", "show each gated agent CLI's installed version and verdict (verified, in-line, trusted, refused or unknown)"],
46
+ ["ahub agents trust <id> <version> [--revoke]", "accept an untested agent CLI version on this machine (person only; runtime proofs still run)"],
41
47
  ["ahub kill", "stop this project's hub"],
42
48
  ["ahub reset [--all] [--yes]", "list, then with --yes stop the hub and discard every queued and needs_review delivery, clear holds and pauses and drop session pointers; --all moves the state directory to .agenthub/archive instead"],
43
49
  ["ahub restart [--dry-run] [--yes] [--json] [--verbose] [--color=auto|always|never]", "recover this project's runtime; on a terminal without a flag it reviews the plan with you, follows the progress and offers the next steps"],
@@ -70,11 +76,12 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
70
76
  ["ahub pause|resume <peer>", "hold a peer's deliveries in its queue / release them"],
71
77
  ["ahub queue list [--peer <id>] [--json] [--color=auto|always|never]", "inspect durable deliveries"],
72
78
  ["ahub queue show <delivery-id> [--json] [--color=auto|always|never]", "inspect one delivery and revision"],
73
- ["ahub queue resolve <delivery-id> --action completed|retry|discard --reason <text> [--revision N]", "resolve a queued or needs_review delivery; --revision binds the displayed row instead of a fresh show"],
79
+ ["ahub queue resolve <delivery-id> --action completed|retry|discard --reason <text> [--revision N]", "resolve a queued or needs_review delivery; --revision binds the displayed row instead of a fresh show; a queued delivery can be discarded while its peer is busy, and a refusal names its cause"],
80
+ ["ahub queue discard --peer <id> --reason <text> [--state queued|needs_review] [--yes] [--json]", "person only: without --yes count one peer's queued (default) deliveries; with --yes discard them all with the reason, one recorded discard each; other peers' queues are untouched"],
74
81
  ]],
75
82
  ["Tasks and review", [
76
83
  ["ahub board import github --label <label> [--milestone <milestone>] [--repo <owner/repo>]", "person only: read bounded open issues into deduplicated Backlog; never writes GitHub"],
77
- ["ahub board [state | --ready] [--columns] [--release <name> | --release-id <id>] [--json] [--color=auto|always|never]", "the task board; --ready: proposed tasks with nothing left to wait for"],
84
+ ["ahub board [state | --ready] [--columns] [--release <name> | --release-id <id>] [--json] [--full] [--color=auto|always|never]", "the task board, one line per task at a terminal width; --full: whole titles and SDLC advice; --ready: proposed tasks with nothing left to wait for"],
78
85
  ["ahub task propose [--class <c> | <class>] <title...> [--owner <peer>] [--path <p>]... [--after <id>]... [--urgent] [--backlog] [--detail <text>]", "put a task on the board; a first word that names a class is taken as the class; while a task named by --after is not approved yet, --owner reserves the task for that peer, offered to it first once it is ready; otherwise --owner assigns it now"],
79
86
  ["ahub backlog list | add <class> <title...> | plan <id> <revision> <fields-json> <reason...> | pin <id> <revision> true|false <reason...> | undo <id> <revision>", "person only: stage and plan work, preserve pins/frozen scope and refuse stale edits"],
80
87
  ["ahub release new <name> <goal> <order> | list | show <name> | plan <name> <revision> <dates-json> | activate|freeze|ship|drop <name> <revision>", "person only: planned UTC millisecond dates and phased scope; ship records a time after approval, never tags or publishes"],
@@ -87,7 +94,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
87
94
  ["ahub task unassign <id> [--role owner|reviewer] [--revision N]", "person only: remove the owner/reservation or reviewer without routing a replacement or stopping native work"],
88
95
  ["ahub task cancel <id> --reason <text> [--superseded-by <ref>] [--revision N]", "person only: close obsolete work with a reason; retain its history without stopping native work or releasing dependents"],
89
96
  ["ahub task label <id> ok|regressed|reverted|incomplete|wrong|abandoned [--created <time>]", "your later verdict on an approved task, kept in the research records (research must be on)"],
90
- ["ahub review <id> approved|changes_requested [note...] [--unmet <item>]... [--head <sha>] [--findings <json>]", "give a review verdict on a task"],
97
+ ["ahub review <id> approved|changes_requested [note...] [--unmet <item>]... [--head <sha>] [--findings <json>]", "give a review verdict on a task; the person can do the same in ahub console: Tasks, select the task, r, then a (approve) or c (request changes, with a note)"],
91
98
  ["ahub review brief <id>", "print the review brief of a task's commit: reviewable and excluded files, rules, coverage to account for"],
92
99
  ["ahub route explain <id>", "why a task went where it went, its reserved owner included"],
93
100
  ["ahub route explain --class <c> <title...>", "what would happen to such a task now"],
@@ -95,7 +102,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
95
102
  ["ahub remember <text...>", "save a note to the memory all agents share"],
96
103
  ]],
97
104
  ["Budget", [
98
- ["ahub budget [--json] [--color=auto|always|never]", "quota windows per peer, and who is paused until when"],
105
+ ["ahub budget [--json] [--full] [--color=auto|always|never]", "quota windows per peer, and who is paused until when"],
99
106
  ["ahub budget set <peer> <0..1> [--resets-in 30m] [--window 5h|week]", "feed a reading by hand (also: test the relay)"],
100
107
  ["ahub budget resume <peer>", "override a budget pause; readings are ignored for that peer until the window resets"],
101
108
  ["ahub budget execution status [id] [--json] [--color=auto|always|never]", "read execution counters and limits"],
@@ -105,6 +112,10 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
105
112
  ["ahub turns [peer] [--limit N] [--json] [--color=auto|always|never]", "recent turns and the files each changed (a git work tree only)"],
106
113
  ["ahub undo <turn> [--yes] [--context]", "put back the files a turn changed; refuses files changed since. Without --yes it only lists them; --context also drops a Codex turn from its conversation"],
107
114
  ["ahub report [--since 7d|<iso>] [--by task] [--json] [--color=auto|always|never]", "turns, tokens, messages, overlaps and task events per period"],
115
+ ["ahub report --dogfood [--since 7d|<iso>] [--json]", "read-only scorecard: flow, quality, cost, delivery, performance and adoption; missing evidence is unknown"],
116
+ ["ahub usage [--since 7d|<iso>] [--by peer|task|issue|pr|release|day|model] [--json] [--color=auto|always|never]", "tokens per group from the incremental roll-up: the provider's total with and without cache reads, input, output, reasoning and the hub's own count; each number names its source, unknown is never 0"],
117
+ ["ahub usage limits [--peer <id>] [--limit <n>] [--json] [--color=auto|always|never]", "current quota windows and the history of pauses, resumes and resets"],
118
+ ["ahub usage total [--json] [--color=auto|always|never]", "lifetime development tokens since the first event, per peer and overall"],
108
119
  ["ahub export [--since 7d|<iso>]", "structured events (events.jsonl) as JSON lines; never message bodies"],
109
120
  ["ahub research [--since 30d] [--all] [--json]", "success, first-pass, rework and check-failure rates, tokens and wall time per approved task, from the opt-in research records"],
110
121
  ["ahub research addons [--since 30d] [--all] [--json]", "approved tasks with each add-on (claude-mem, ponytail, ripwire, ...) on against off, with intervals; association, not cause"],
@@ -14,7 +14,7 @@ export const LIVE_TEST_PERSON_REFUSAL = "live-test cannot write person-attribute
14
14
 
15
15
  /** Person verdicts and user-authored workflow content are never delegated. */
16
16
  export function liveTestRefusal(command: string, args: readonly string[], context: { repairedFresh?: boolean } = {}): boolean {
17
- if (command === "sdlc" && (args[0] === "plan-review" || args[0] === "release-assign" || args[0] === "train" && args[1] !== "report")) return true;
17
+ if (command === "sdlc" && (args[0] === "authorize-release" || args[0] === "plan-review" || args[0] === "release-assign" || args[0] === "train" && args[1] !== "report")) return true;
18
18
  if (args.some(arg => arg === "--as-user" || arg.startsWith("--as-user="))) return true;
19
19
  const sub = command === "recovery" ? recoveryAction(args) : args.find(arg => arg !== "--json");
20
20
  return ["task", "role", "goal"].includes(command)
@@ -22,7 +22,9 @@ export function liveTestRefusal(command: string, args: readonly string[], contex
22
22
  || (command === "memory" && sub !== "graph")
23
23
  || (command === "recovery" && (sub === "reattach" || sub === "dispose" && context.repairedFresh === true))
24
24
  || (command === "board" && args[0] === "import") || (command === "sdlc" && (args[0] === "record" || args[0] === "accept" || args[0] === "accept-departure"))
25
- || (command === "bench" && args.find(arg => arg !== "--json") === "run") || command === "pet";
25
+ || (command === "bench" && args.find(arg => arg !== "--json") === "run") || command === "pet"
26
+ // #691: trusting an untested agent CLI version is the person's consent, never a delegated live-test operation.
27
+ || (command === "agents" && !args.every(arg => arg === "--json"));
26
28
  }
27
29
 
28
30
  export type CliIdentity =
@@ -59,6 +61,7 @@ export function classifyPeerCommand(command: string, args: readonly string[], ca
59
61
  if (command === "pet" && args.includes("--window")) return "console";
60
62
  if (command === "permission" || command === "stop" || command === "watch") return "console";
61
63
  if (args.some((arg) => arg === "--as-user" || arg.startsWith("--as-user="))) return "console";
64
+ if (command === "sdlc" && ["release-guard", "release-guard-hook"].includes(args[0] ?? "")) return "allowed";
62
65
  if (command === "role") return ["list", "show"].includes(args[0] ?? "") ? "allowed" : "console";
63
66
  if (command === "task") {
64
67
  if (["propose", "show"].includes(args[0] ?? "")) return "allowed";
@@ -66,6 +69,8 @@ export function classifyPeerCommand(command: string, args: readonly string[], ca
66
69
  return "console";
67
70
  }
68
71
  if (command === "route") return args[0] === "explain" ? "allowed" : "console";
72
+ // #691: reading agent CLI versions is harmless; trusting an untested version on this machine is a person's.
73
+ if (command === "agents") return args.every(arg => arg === "--json") ? "allowed" : "console";
69
74
  // #247: reading the research measures and records is harmless (ids and counts only); writing them is a person's.
70
75
  if (command === "research") return args[0] === "backfill" ? "console" : "allowed";
71
76
  // #258: reading hub memory is harmless; promote, demote, forget, supersede and rebuild are a person's.
@@ -78,7 +83,7 @@ export function classifyPeerCommand(command: string, args: readonly string[], ca
78
83
  if (native?.cli.authority === "person") return "console";
79
84
  if (native || ["pause", "resume"].includes(command)) return "conductor";
80
85
  if (command === "board" && args[0] === "import") return "console";
81
- if (["help", "version", "--version", "completion", "say", "status", "pet", "board", "report", "turns", "review", "remember", "facts", "check-path"].includes(command)) return "allowed";
86
+ if (["help", "version", "--version", "completion", "say", "status", "pet", "board", "report", "usage", "turns", "review", "remember", "facts", "check-path"].includes(command)) return "allowed";
82
87
  return "console";
83
88
  }
84
89
 
@@ -87,14 +92,32 @@ export function cliCommandLabel(command: string, args: readonly string[], catalo
87
92
  if (command === "backlog") return ["list", "add", "pull", "pull-ready", "split", "return", "plan", "pin", "undo"].includes(args[0] ?? "") ? `backlog ${args[0]}` : "backlog";
88
93
  if (command === "board" && args[0] === "import") return "board import";
89
94
  if (command === "release") return ["new", "list", "show", "plan", "activate", "freeze", "ship", "drop"].includes(args[0] ?? "") ? `release ${args[0]}` : "release";
90
- if (!(catalog?.definitionOf(command) ?? definitionOf(command)) && !["upgrade", "restart", "recovery", "recovery-run", "help", "--help", "--version", "version", "completion", "projects", "ui", "manager", "setup", "init", "daemon", "up", "console", "stop", "models", "say", "queue", "tail", "watch", "budget", "board", "task", "review", "remember", "ask", "route", "pause", "resume", "permit", "permission", "settings", "roster", "status", "pet", "logs", "export", "report", "research", "memory", "goal", "bench", "sdlc", "role", "facts", "check-path", "turns", "undo", "kill", "reset", "doctor"].includes(command)) return "unknown";
91
- if (["task", "queue", "budget", "route", "recovery", "models", "projects", "research", "memory", "goal", "bench"].includes(command)) {
95
+ if (!(catalog?.definitionOf(command) ?? definitionOf(command)) && !["upgrade", "restart", "recovery", "recovery-run", "help", "--help", "--version", "version", "completion", "projects", "ui", "manager", "setup", "init", "daemon", "up", "console", "stop", "models", "say", "queue", "tail", "watch", "budget", "board", "task", "review", "remember", "ask", "route", "pause", "resume", "permit", "permission", "settings", "roster", "status", "pet", "logs", "export", "report", "research", "memory", "goal", "bench", "sdlc", "role", "facts", "check-path", "turns", "undo", "kill", "reset", "doctor", "agents"].includes(command)) return "unknown";
96
+ if (["task", "queue", "budget", "route", "recovery", "models", "projects", "research", "memory", "goal", "bench", "agents"].includes(command)) {
92
97
  const sub = command === "bench" || command === "memory" || command === "goal" ? args.find((arg) => arg !== "--json") : args[0];
93
- if (sub && ["propose", "show", "assign", "unassign", "escalate", "label", "list", "resolve", "resume", "set", "execution", "explain", "status", "abort", "dispose", "reattach", "setup", "start", "stop", "remove", "export", "backfill", "addons", "run", "report", "compare", "search", "graph", "promote", "demote", "forget", "supersede", "dedupe", "decay", "rebuild", "sync", "machine", "import", "suggestions", "confirm", "reject", "new", "link", "state", "done", "take"].includes(sub)) return `${command} ${sub}`;
98
+ if (sub && ["propose", "show", "assign", "unassign", "escalate", "label", "list", "resolve", "resume", "set", "execution", "explain", "status", "abort", "dispose", "reattach", "setup", "start", "stop", "remove", "discard", "export", "backfill", "addons", "run", "report", "compare", "search", "graph", "promote", "demote", "forget", "supersede", "dedupe", "decay", "rebuild", "sync", "machine", "import", "suggestions", "confirm", "reject", "new", "link", "state", "done", "take", "trust"].includes(sub)) return `${command} ${sub}`;
94
99
  }
95
100
  return (/^[a-z][a-z0-9-]*$/.test(command) && !/\s/.test(command)) || ["--help", "--version"].includes(command) ? command : "unknown";
96
101
  }
97
102
 
103
+ /**
104
+ * #711: a hub refusal that only the person or the task's own reviewer could have avoided ("only its reviewer (user) or the console user can do that",
105
+ * "only the person may ..."). The wording is the hub's, so this reads it and never invents it: anything else gets no extra line.
106
+ */
107
+ export const personOnlyRefusal = (error: string): boolean => /\bonly\b[^.]*\b(?:console user|the person)\b/i.test(error);
108
+
109
+ /**
110
+ * #711: the extra stderr line for an agent shell that was refused something only the person may do. The refusal itself and the authority are unchanged: this only
111
+ * says why the shell was treated as an agent and where to act as the person. `undefined` for the console identity and for any refusal that is not person-only.
112
+ */
113
+ export function agentShellCause(identity: CliIdentity, error: string, env: Readonly<Record<string, string | undefined>> = process.env): string | undefined {
114
+ if (identity.role !== "tools" || !personOnlyRefusal(error)) return undefined;
115
+ const markers = ["AGENTHUB_PEER_ID", "CLAUDECODE", "CLAUDE_CODE_SESSION_ID", "CODEX_THREAD_ID"].filter(name => env[name] !== undefined);
116
+ const set = markers.length ? `${markers.join(" and ")} ${markers.length === 1 ? "is" : "are"} set` : "an agent marker is set";
117
+ const task = /\btask #(\d+)\b/.exec(error)?.[1];
118
+ return `This shell runs as agent "${identity.peer}" (${set}), not as you. Run the command in your own terminal, or use the ahub console: ${task ? `Tasks, select #${task}, r` : "Tasks, select the task, r"}.`;
119
+ }
120
+
98
121
  export function peerCommandRefusal(peer: string, command: string, env: Readonly<Record<string, string | undefined>> = process.env): string {
99
122
  const markers = ["CLAUDECODE", "CLAUDE_CODE_SESSION_ID", "AGENTHUB_PEER_ID", "CODEX_THREAD_ID"].filter(name => env[name] !== undefined);
100
123
  return `${peer} cannot run ahub ${command}; the person runs it in ahub console or a terminal. Refused because inherited agent markers${markers.length ? ` (${markers.join(", ")})` : ""} identify this shell as an agent. A ! command in an agent session runs as that agent; use a separate terminal outside the agent session`;
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,4 +1,9 @@
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";
@@ -74,14 +79,17 @@ import { selectedMemoryProfile } from "../hub/memory-profile.ts";
74
79
  import { checkMemoryProfiles, formatMemoryProfileCheck } from "../hub/memory-profile-check.ts";
75
80
  import { addonReport, appendRecords, formatAddons, formatResearch, LABELS, labelTarget, RESEARCH_SCHEMA, projectKey, readStores, researchReport, taskRecords, toCsv, type Label, type TaskRecord } from "../hub/research.ts";
76
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";
77
85
  import { hasTree, planUndo, repoOf, restore, Turns } from "../hub/snapshots.ts";
78
86
  import { pathWarnings } from "../hub/conflicts.ts";
79
87
  import { COMPLETION_SHELLS, completionScript } from "./completion.ts";
80
- 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";
81
89
  import { recordCliAudit, recordDelegatedCliAudit } from "./identity-audit.ts";
82
90
  import { runConsole } from "./console.ts";
83
91
  import { paint, permissionBoundary, resolveColor, wrap, type Span } from "./console-state.ts";
84
- 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";
85
93
  import { renderHelp, configuredAcpHelp } from "./help.ts";
86
94
  import { renderTailEvent } from "./tail-render.ts";
87
95
  import { archiveProblem, archiveState, damagedState, failureText, MANIFEST, planReset, resetLines, resetRuntime, startState, type ResetPlan } from "./reset.ts";
@@ -444,7 +452,14 @@ async function taskOp(op: string, a: Record<string, unknown>, throwOnError = fal
444
452
  try {
445
453
  if (op === "hub_task_cancel" && !hub.features.includes("task_cancel")) fail("this daemon does not support task cancellation; upgrade it first");
446
454
  const res = await hub.request({ t: "task", op, args: a }, timeoutMs ?? (op === "github_import" ? 60_000 : 30_000));
447
- 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
+ }
448
463
  return res.text;
449
464
  } finally { if (!connection) hub.close(); }
450
465
  }
@@ -885,15 +900,15 @@ const commands: Record<string, () => Promise<void> | void> = {
885
900
  },
886
901
 
887
902
  init: () => {
888
- const flags = takeFlags(args.filter(arg => arg !== "--dry-run" && arg !== "--json" && arg !== "--shadow"), ["--profile"], []);
889
- 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]");
890
905
  const requestedProfile = flags.one["--profile"] === undefined ? undefined : profileName(flags.one["--profile"]);
891
906
  if (args.includes("--dry-run")) {
892
- const plan = planInit(cwd, requestedProfile, args.includes("--shadow"));
907
+ const plan = planInit(cwd, requestedProfile, args.includes("--shadow"), args.includes("--release-guard"));
893
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");
894
909
  }
895
910
  assertLifecycleAvailable();
896
- const changed = init(cwd, requestedProfile, args.includes("--shadow"));
911
+ const changed = init(cwd, requestedProfile, args.includes("--shadow"), args.includes("--release-guard"));
897
912
  registeredProject();
898
913
  console.log(changed.length ? changed.map((p) => `${existsSync(p) ? "wrote" : "removed"} ${p}`).join("\n") : "already up to date");
899
914
  const config = projectConfig(), examples = resolveProfile(config.profile, config.profile_overrides).example_checks;
@@ -1006,8 +1021,21 @@ const commands: Record<string, () => Promise<void> | void> = {
1006
1021
  printOutput(renderQueue(result.deliveries, options.columns, Date.now()), options.color);
1007
1022
  return;
1008
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
+ }
1009
1037
  const [id, ...flags] = rest;
1010
- 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]");
1011
1039
  if (operation === "show") {
1012
1040
  if (flags.length) fail("usage: ahub queue show <id>");
1013
1041
  const shown = await hub.request({ t: "queue", op: "show", id });
@@ -1192,6 +1220,50 @@ const commands: Record<string, () => Promise<void> | void> = {
1192
1220
 
1193
1221
  sdlc: async () => {
1194
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
+ }
1195
1267
  if (sub === "queue" || sub === "release") {
1196
1268
  const repo = args[1], liveIssues = args.slice(2).map(Number);
1197
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...]");
@@ -1223,6 +1295,11 @@ const commands: Record<string, () => Promise<void> | void> = {
1223
1295
  if (args.length > 2) fail("usage: ahub sdlc costs [owner/repository]");
1224
1296
  return console.log(await taskOp("hub_sdlc_costs", { ...(args[1] ? { repo: args[1] } : {}) }));
1225
1297
  }
1298
+ if (sub === "metrics") {
1299
+ const flags = takeFlags(args.slice(1), ["--since"], []);
1300
+ if (flags.rest.length) fail("usage: ahub sdlc metrics [--since <YYYY-MM-DD|ISO time>]");
1301
+ return console.log(await taskOp("hub_sdlc_metrics", { ...(flags.one["--since"] !== undefined ? { since: flags.one["--since"] } : {}) }));
1302
+ }
1226
1303
  if (sub === "plan-review") {
1227
1304
  const revision = Number(args[2]);
1228
1305
  if (!Number.isSafeInteger(id) || id! <= 0 || !Number.isSafeInteger(revision) || revision < 0 || args.length < 4) fail("usage: ahub sdlc plan-review <task> <revision> <reason...>");
@@ -1703,8 +1780,15 @@ const commands: Record<string, () => Promise<void> | void> = {
1703
1780
  const r = researchReport(records, from);
1704
1781
  console.log(args.includes("--json") ? JSON.stringify(r, null, 2) : formatResearch(r).join("\n"));
1705
1782
  },
1706
- report: () => {
1783
+ report: async () => {
1707
1784
  const options = outputOptions();
1785
+ if (args.includes("--dogfood")) {
1786
+ let from: number;
1787
+ try { from = dogfoodSince(args.filter(arg => !arg.startsWith("--color="))); }
1788
+ catch { return fail("usage: ahub report --dogfood [--since 7d|<iso>] [--json]"); }
1789
+ const scorecard = await readDogfood(cwd, stateDir, from);
1790
+ return console.log(options.json ? JSON.stringify(scorecard, null, 2) : formatDogfood(scorecard).join("\n"));
1791
+ }
1708
1792
  const by = args.indexOf("--by");
1709
1793
  if (by >= 0 && args[by + 1] !== "task") return fail("--by takes task");
1710
1794
  const events = readEvents(join(stateDir, "events.jsonl"), since());
@@ -1712,6 +1796,32 @@ const commands: Record<string, () => Promise<void> | void> = {
1712
1796
  if (options.json) return console.log(JSON.stringify(data, null, 2));
1713
1797
  printOutput(renderReport(data, options.columns, Date.now(), by >= 0), options.color);
1714
1798
  },
1799
+ usage: async () => {
1800
+ const options = outputOptions();
1801
+ const rest = outputArguments();
1802
+ const sub = rest[0] !== undefined && !rest[0].startsWith("--") ? rest[0] : undefined;
1803
+ 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]");
1804
+ const flags = sub === undefined ? rest : rest.slice(1);
1805
+ const valueFlags = sub === "limits" ? ["--peer", "--limit"] : sub === undefined ? ["--since", "--by"] : [];
1806
+ for (let i = 0; i < flags.length; i++) {
1807
+ if (!valueFlags.includes(flags[i]!)) fail(`unknown argument ${JSON.stringify(flags[i]!.slice(0, 40))}; run ahub help`);
1808
+ if (flags[++i] === undefined || flags[i]!.startsWith("--")) fail(`${flags[i - 1]} needs a value`);
1809
+ }
1810
+ const value = (flag: string) => { const at = args.indexOf(flag); return at >= 0 ? args[at + 1] : undefined; };
1811
+ const by = value("--by"), peer = value("--peer"), limitText = value("--limit");
1812
+ const limit = limitText === undefined ? undefined : /^\d+$/.test(limitText) ? Number(limitText) : NaN;
1813
+ if (limit !== undefined && !(limit >= 1 && limit <= LIMITS_MAX)) fail(`--limit takes a whole number from 1 to ${LIMITS_MAX}`);
1814
+ if (by !== undefined && !(USAGE_GROUPS as readonly string[]).includes(by)) fail(`--by takes ${USAGE_GROUPS.join(", ")}`);
1815
+ const from = since();
1816
+ const hub = await connect();
1817
+ const reply = await hub.request(sub === "limits" ? { t: "usage", op: "limits", ...(peer ? { peer } : {}), ...(limit !== undefined ? { limit } : {}) } : sub === "total" ? { t: "usage", op: "total" }
1818
+ : { t: "usage", op: "query", by: by ?? "peer", ...(from ? { since: from } : {}) }, 35_000);
1819
+ hub.close();
1820
+ if (!reply.ok) return fail(String(reply.error ?? "usage refused"));
1821
+ 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");
1822
+ if (options.json) return console.log(JSON.stringify(sub === "limits" ? { limits: reply.limits, current: reply.current } : sub === "total" ? reply.total : reply.usage, null, 2));
1823
+ printOutput(sub === "limits" ? renderLimits(reply.limits, reply.current, options.columns) : sub === "total" ? renderTotal(reply.total, options.columns) : renderUsage(reply.usage, options.columns), options.color);
1824
+ },
1715
1825
  facts: async () => {
1716
1826
  if (!args.includes("--hook")) return fail("usage: ahub facts --hook (a Claude Code PreToolUse, PostToolUse and Stop hook)");
1717
1827
  try {
@@ -1949,6 +2059,8 @@ const commands: Record<string, () => Promise<void> | void> = {
1949
2059
  }
1950
2060
  fail("usage: ahub role list [--group <group>] | show <id> | assign <peer> <id> | clear <peer> [<id>]");
1951
2061
  },
2062
+ agents: () => runAgents(args, configuredCommands(loadConfig(cwd)), fail),
2063
+
1952
2064
  doctor: async () => {
1953
2065
  const options = outputOptions();
1954
2066
  if (args.includes("--orphans")) return orphanDoctor(args.includes("--kill"), options);
@@ -2001,10 +2113,14 @@ const commands: Record<string, () => Promise<void> | void> = {
2001
2113
  [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` : "",
2002
2114
  unreadable.length ? `unreadable or unsafe, not listed: ${unreadable.join(", ")}` : ""].filter(Boolean).join("; ") || "none besides the hub's own");
2003
2115
  } else {
2004
- const v = version(probe.command[0]!);
2005
- row(v === probe.expected, probe.name, v === probe.expected ? probe.confirmed : probe.unconfirmed);
2116
+ const verdict = assessVersion(probe.compat, COMPAT[probe.compat].parse(version(probe.command[0]!) ?? ""));
2117
+ row(verdict.ok ? verdict.state === "verified" || undefined : false, probe.name, verdict.ok ? verdict.note ?? probe.confirmed : `${probe.unconfirmed}: ${verdict.reason}`);
2006
2118
  }
2007
2119
  }
2120
+ // #691: one line for each installed gated CLI that is not on a verified version.
2121
+ for (const agent of agentVersions(configuredCommands(config))) {
2122
+ 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);
2123
+ }
2008
2124
  const omni = new OmniRoute(config.omniroute);
2009
2125
  const gateway = await omni.base();
2010
2126
  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");