@staix/agent-hub 0.17.0 → 0.18.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 (68) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +2 -2
  3. package/docs/agent-notes/README.md +5 -0
  4. package/docs/agent-notes/adapters.md +2 -0
  5. package/docs/agent-notes/benchmarks.md +1 -0
  6. package/docs/agent-notes/daemon.md +1 -0
  7. package/docs/agent-notes/local-worker.md +1 -0
  8. package/docs/agent-notes/tasks.md +7 -1
  9. package/docs/agent-notes/tests.md +1 -0
  10. package/docs/dogfood.md +15 -1
  11. package/docs/memory.md +1 -1
  12. package/docs/operations.md +105 -16
  13. package/docs/profiles.md +36 -0
  14. package/docs/quickstart.md +9 -3
  15. package/docs/sdlc.md +29 -3
  16. package/docs/security.md +26 -4
  17. package/docs/specs/2026-09-19-agent-hub-design.md +16 -6
  18. package/docs/verification/2026-10-25-0.18.0.md +76 -0
  19. package/docs/verification/2026-10-25-codex-review-live-verdict.md +35 -0
  20. package/docs/verification/2026-10-25-remote-docker-transport.md +46 -0
  21. package/docs/verification/profiles/YYYY-MM-DD-general.md +46 -0
  22. package/docs/verification/profiles/YYYY-MM-DD-research.md +61 -0
  23. package/docs/verification/profiles/YYYY-MM-DD-writing.md +46 -0
  24. package/docs/verified.json +24 -18
  25. package/package.json +2 -2
  26. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  27. package/plugins/agent-hub/server.js +94 -33
  28. package/src/adapters/definitions/builtin-metadata.ts +1 -1
  29. package/src/adapters/local-worker.ts +5 -3
  30. package/src/cli/console-state.ts +9 -2
  31. package/src/cli/help.ts +29 -28
  32. package/src/cli/identity.ts +87 -6
  33. package/src/cli/main.ts +190 -69
  34. package/src/cli/output.ts +7 -2
  35. package/src/cli/sdlc-output.ts +242 -0
  36. package/src/cli/task-output.ts +212 -0
  37. package/src/cli/upgrade-output.ts +57 -0
  38. package/src/hub/board.ts +17 -0
  39. package/src/hub/check-runner.ts +252 -22
  40. package/src/hub/conductor.ts +5 -0
  41. package/src/hub/config-apply.ts +16 -7
  42. package/src/hub/config-edit-host.ts +1 -1
  43. package/src/hub/config-tier.ts +1 -1
  44. package/src/hub/daemon.ts +353 -72
  45. package/src/hub/dogfood-profile.ts +248 -0
  46. package/src/hub/dogfood-read.ts +77 -14
  47. package/src/hub/dogfood.ts +5 -1
  48. package/src/hub/events.ts +1 -1
  49. package/src/hub/host-executable.ts +2 -1
  50. package/src/hub/hub-tools.ts +2 -2
  51. package/src/hub/operational-authority.ts +60 -0
  52. package/src/hub/operational-view.ts +141 -0
  53. package/src/hub/orphan-process.ts +69 -0
  54. package/src/hub/permission-mode.ts +1 -1
  55. package/src/hub/read-only-shell-proof.ts +80 -0
  56. package/src/hub/read-only-shell.ts +162 -0
  57. package/src/hub/reviewer-pool-config.ts +3 -2
  58. package/src/hub/reviewer-pool.ts +3 -0
  59. package/src/hub/routing.ts +23 -6
  60. package/src/hub/sdlc-merge.ts +299 -0
  61. package/src/hub/sdlc-mirror.ts +129 -0
  62. package/src/hub/sdlc.ts +58 -12
  63. package/src/hub/settings.ts +11 -5
  64. package/src/hub/snapshots.ts +60 -0
  65. package/src/hub/task-repository.ts +31 -5
  66. package/src/hub/tasks.ts +241 -16
  67. package/src/local/tools.ts +17 -4
  68. package/src/ui/index.html +2 -1
@@ -47,7 +47,8 @@ export interface ConsoleState {
47
47
  autoSelected?: boolean;
48
48
  input: string; editing: boolean; history: string[]; historyIndex: number;
49
49
  approvals: Approval[]; events: ConsoleEvent[]; peers: Record<string, any>; budget: Record<string, any>;
50
- taskWip?: Record<string, { wip: number; wipLimit: number }>;
50
+ /** #784 AC3: the split of `wip` (active, review-wait, reserved); absent from an older hub. */
51
+ taskWip?: Record<string, { wip: number; wipLimit: number; active?: number; reviewWait?: number; reserved?: number }>;
51
52
  backlogView?: boolean;
52
53
  tasks: any[]; tasksKnown?: boolean; taskCounts?: Record<string, number>; queue: any[]; detail?: Detail; detailOffset: number; help: boolean; notice: string; noticeAt?: number; noticeTone?: Tone;
53
54
  peerFilter?: string; kindFilter?: string; project?: string;
@@ -348,6 +349,12 @@ export function budgetFields(budget: any, now: number): [string, unknown][] {
348
349
  });
349
350
  return [["quota", lines.length ? lines : typeof budget.note === "string" ? budget.note : "no reading"], ...(lines.length && typeof budget.note === "string" ? [["quota note", budget.note] as [string, unknown]] : [])];
350
351
  }
352
+ /** #784 AC3: the Peers detail carries the planner WIP split as one line (`1 of 2: active 1, review-wait 0, reserved 0`); a hub that sends none adds nothing. */
353
+ function peerDetailRow(s: ConsoleState, row: any): Record<string, unknown> {
354
+ const wip = s.taskWip?.[row.id];
355
+ const split = wip && [wip.active, wip.reviewWait, wip.reserved].every(n => Number.isSafeInteger(n)) ? `${wip.wip} of ${wip.wipLimit}: active ${wip.active}, review-wait ${wip.reviewWait}, reserved ${wip.reserved}` : undefined;
356
+ return { ...row, ...(split ? { "planner WIP": split } : {}) };
357
+ }
351
358
  const detailLines = (detail: Detail, columns: number, now: number, task = false): Span[][] => {
352
359
  if (typeof detail === "string") return wrap(detail, columns).map(text => [span(text)]);
353
360
  const { budget, ...rest } = detail as Record<string, unknown>;
@@ -794,7 +801,7 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
794
801
  if (s.panel === 3 || s.panel === 4) effects.push({ type: "show", panel: s.panel, id: String(row.id) });
795
802
  else if (s.panel === 2) { s.requestDetail = row.id; s.detailOffset = 0; } // the row is the selected request
796
803
  else if (s.panel === 7) { s.detail = { usagePeer: row.id }; s.detailOffset = 0; } // rendered live from s.usage, so a refresh updates it
797
- else { s.detail = s.panel === 5 ? String(row.text) : { ...row }; s.detailOffset = 0; }
804
+ else { s.detail = s.panel === 5 ? String(row.text) : s.panel === 1 ? peerDetailRow(s, row) : { ...row }; s.detailOffset = 0; }
798
805
  return done();
799
806
  }
800
807
  if (s.mode === "panels" && item !== undefined && item !== null) {
package/src/cli/help.ts CHANGED
@@ -8,7 +8,7 @@ export interface HelpEntry { section: string; usage: string; description: string
8
8
  const SECTIONS: [string, [usage: string, description: string][]][] = [
9
9
  ["Projects", [
10
10
  ["ahub --project <path|id> <command>", "select a repository or worktree explicitly"],
11
- ["ahub projects [--json] [--color=auto|always|never]", "list registered projects and live status"],
11
+ ["ahub projects [--json] [--full] [--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
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)"],
@@ -24,9 +24,10 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
24
24
  ["ahub pet --window [--close]", "start or close the macOS floating window, from a person's terminal"],
25
25
  ["ahub pet [--short]", "read attention without connecting; --short prints ahub!N only when action is needed"],
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
- ["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
- ["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)"],
27
+ ["ahub sdlc status [task] | explain <task> [--json] [--color=auto|always|never] | export <task> | record <task> <receipt-json>", "read head-bound lifecycle gates and the next actor/action; status and explain are open to any agent as a public view (private tasks stay stubs), export and record are person only; export stays JSON"],
28
+ ["ahub sdlc queue <owner/repo> | release <owner/repo> [live-issue...] [--json] [--color=auto|always|never]", "read-only merge and release evidence; open to any agent as public readback of the checkout's repositories, never an authorization"],
29
+ ["ahub sdlc merge <task> [--dry-run]", "guarded squash merge of an approved task at its approved head after every gate is re-read; follows sdlc.toml auto_merge (off, dry-run, on); --dry-run only reports (person or conductor)"],
30
+ ["ahub sdlc authorize-release <version> [--json] [--color=auto|always|never]", "approve this daemon checkout commit, version and fresh docs (person only; restart revokes)"],
30
31
  ["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
32
  ["ahub sdlc release-guard-hook <remote-name> <remote-url>", "internal pre-push readback; validates version tags and forwards exact stdin on success"],
32
33
  ["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)"],
@@ -34,22 +35,22 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
34
35
  ["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"],
35
36
  ["ahub sdlc train report|configure|cut <release-id> [revision] [json]", "plan a release and freeze its approved scope after resource/quota admission"],
36
37
  ["ahub sdlc release-assign <task> <revision> <release-id|none> <reason...>", "assign approved/in-flight work without changing execution"],
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)"],
39
- ["ahub backlog groom | return <task> <revision> <reason...>", "read grooming findings or return accepted untouched work to backlog (planner/person)"],
38
+ ["ahub sdlc costs [owner/repo] [--json] [--color=auto|always|never]", "read bounded planner cost metrics for linked merged PRs"],
39
+ ["ahub sdlc metrics [--since <YYYY-MM-DD|ISO time>] [--json] [--color=auto|always|never]", "read SDLC metrics and control band tiers from board history (planner or person; read-only)"],
40
+ ["ahub backlog groom | return <task> <revision> <reason...> [--json] [--color=auto|always|never]", "read grooming findings or return accepted untouched work to backlog (planner/person)"],
40
41
  ["ahub status [--json] [--full] [--color=auto|always|never]", "the hub, its peers, model backends and task counts"],
41
42
  ["ahub status --all [--json] [--color=auto|always|never]", "show every registered project"],
42
- ["ahub logs [-f]", "the last 100 lines of hub.log; -f shows the last 10 and follows it"],
43
+ ["ahub logs [-f]", "person: last 100 hub.log lines (-f starts at 10); conductor: bounded public lines and follow while authority remains"],
43
44
  ["ahub doctor [--json] [--full] [--color=auto|always|never]", "check the tools, daemon, plugin, gateway, models and memory library this project uses"],
44
45
  ["ahub doctor --stale-anchors [--json]", "read-only bounded report of public memory symbol anchors at current HEAD; private or unavailable evidence stays unknown"],
45
- ["ahub doctor --orphans [--kill] [--json] [--color=auto|always|never]", "list registrations whose project root is gone; --kill stops their daemons (SIGTERM, then SIGKILL) only after the process identity checks out"],
46
+ ["ahub doctor --orphans [--kill] [--json] [--color=auto|always|never]", "person: inspect missing-root registrations; conductor --kill: selected project only; signed hub ownership is required before any stop"],
46
47
  ["ahub agents [--json]", "show each gated agent CLI's installed version and verdict (verified, in-line, trusted, refused or unknown)"],
47
48
  ["ahub agents trust <id> <version> [--revoke]", "accept an untested agent CLI version on this machine (person only; runtime proofs still run)"],
48
49
  ["ahub kill", "stop this project's hub"],
49
50
  ["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"],
50
51
  ["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"],
51
52
  ["ahub upgrade [--to <version>] [--dry-run] [--yes] [--json] [--verbose] [--color=auto|always|never]", "review and upgrade running projects; --to defaults to the latest release (--yes needs it), and a newer release's own coordinator takes over; on a terminal without --dry-run or --yes: review, end agents first if you choose, apply, follow, and resume, cancel, end or reset on the spot"],
52
- ["ahub recovery [status|resume|abort] [<operation-id>]", "inspect, resume or cancel a preflight; status names the next actions; without an id, the operation that holds the lock, and with nothing else on a terminal its screen"],
53
+ ["ahub recovery [status|resume|abort] [<operation-id>] [--json] [--color=auto|always|never]", "inspect, resume or cancel a preflight; status names the next actions; without an id, the operation that holds the lock, and with nothing else on a terminal its screen"],
53
54
  ["ahub recovery reattach <operation-id> codex|claude|pi", "person only: repair that blocked peer with its recorded session; leaves the rest for recovery resume"],
54
55
  ["ahub recovery dispose [<operation-id>] --fresh-session codex|claude --reason <text>", "end a partial operation by starting that peer as a new session, recording the lost conversation"],
55
56
  ["ahub recovery dispose [<operation-id>] --stop-and-archive --reason <text>", "end a partial operation: stop its own targets, archive its snapshot and release the lock; the upgrade is abandoned, not completed"],
@@ -60,9 +61,9 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
60
61
  ["ahub <configured-kind> [--mode headless] [--as <instance>] [--session-id <id>] [--print-command | --dry-run]", "start a trusted config.local.json ACP kind (default off roster); native argv is withheld in previews"],
61
62
  ["ahub role list [--group <group>] | show <id>", "read bounded catalog guidance with layer and content hash"],
62
63
  ["ahub role assign <peer> <id> | clear <peer> [<id>]", "person only: add or clear catalog guidance, applied at the next peer start"],
63
- ["ahub stop <peer>", "human only: stop a hub-owned headless peer; end TUI agents in their terminals"],
64
+ ["ahub stop <peer>", "person or configured conductor: stop a hub-owned headless peer; end TUI agents in their terminals"],
64
65
  ["ahub models status [--json] [--color=auto|always|never]", "inspect local model state as labelled fields"],
65
- ["ahub models setup|start|stop", "prepare or inspect local Ollama MLX (legacy stop is explicit)"],
66
+ ["ahub models setup|start [--json] [--color=auto|always|never] | stop", "prepare or inspect local Ollama MLX; setup and start print the model state like status (legacy stop is explicit)"],
66
67
  ]],
67
68
  ["Messages and approvals", [
68
69
  ["ahub say [@peer ...] <text>", "send as the console user (no @peer = broadcast); delivered at once, start the text with [STATUS] to let it batch or [FYI] for the record only"],
@@ -70,32 +71,32 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
70
71
  ["ahub tail", "live stream of messages, states and permission requests"],
71
72
  ["ahub permission [<peer> [ask|ask-when-needed|never-ask]] [--yes]", "human only: inspect or set a running peer mode; never-ask requires --yes; config never-ask waits for console y"],
72
73
  ["ahub permit <id> <option>", "answer a permission request shown by tail (\"deny\" cancels)"],
73
- ["ahub roster [--json] [--color=auto|always|never]", "human only: every known peer, participation, start mode and retained work"],
74
- ["ahub roster add <peer> | remove <peer> [--yes] [--now]", "human only: change live participation through settings; removal previews retained messages and task handoff; remove also deletes the roster key of an instance no longer declared"],
75
- ["ahub settings [list] [--json] | get <key> [--json]", "human only: the settings the dashboard can change, each with its value, the file it comes from and when it applies"],
76
- ["ahub settings set <key> <value|inherit> [--yes] [--preview] [--now] | undo | reload", "human only: change or reload validated settings, written to .agenthub/config.local.json or routing.local.toml (never a tracked file); --preview shows the effect and saves nothing; undo puts back the last write"],
74
+ ["ahub roster [--json] [--color=auto|always|never]", "every known peer, participation, start mode and retained work"],
75
+ ["ahub roster add <peer> | remove <peer> [--yes] [--now]", "person or configured conductor: change live participation through settings; removal previews retained messages and task handoff; remove also deletes the roster key of an instance no longer declared"],
76
+ ["ahub settings [list] [--json] | get <key> [--json]", "the settings the dashboard can change, each with its value, the file it comes from and when it applies"],
77
+ ["ahub settings set <key> <value|inherit> [--yes] [--preview] [--now] | undo | reload", "validated settings: conductor set is limited to exact live operational keys; undo/reload and other keys are person only; --preview saves nothing; conductor writes keep the person undo record"],
77
78
  ["ahub settings edit <config.local.json|sdlc.toml> (set <path> <json> | remove <path>)... [--reason <text>] [--preview] [--yes] | backups [--json]", "human only: structural config (instances, review, snapshots, approvals, context.gate, sdlc.toml mode and required_checks) validated by the hub's own loaders, previewed as a diff and written with a backup; a key that runs code or locates secrets needs its name typed, never --yes"],
78
79
  ["ahub pause|resume <peer>", "hold a peer's deliveries in its queue / release them"],
79
- ["ahub queue list [--peer <id>] [--json] [--color=auto|always|never]", "inspect durable deliveries"],
80
- ["ahub queue show <delivery-id> [--json] [--color=auto|always|never]", "inspect one delivery and revision"],
81
- ["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"],
82
- ["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"],
80
+ ["ahub queue list [--peer <id>] [--json] [--full] [--color=auto|always|never]", "inspect durable deliveries; open to any agent as public queue metadata, held bodies stay with the person or conductor"],
81
+ ["ahub queue show <delivery-id> [--json] [--color=auto|always|never]", "person or configured conductor: inspect one delivery and revision; agent output uses the public view"],
82
+ ["ahub queue resolve <delivery-id> --action completed|retry|discard --reason <text> [--revision N] [--json] [--color=auto|always|never]", "person or configured conductor: 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"],
83
+ ["ahub queue discard --peer <id> --reason <text> [--state queued|needs_review] [--yes] [--json]", "person or configured conductor: 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"],
83
84
  ]],
84
85
  ["Tasks and review", [
85
86
  ["ahub board import github --label <label> [--milestone <milestone>] [--repo <owner/repo>]", "person only: read bounded open issues into deduplicated Backlog; never writes GitHub"],
86
87
  ["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"],
87
88
  ["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"],
88
- ["ahub backlog list [--json] [--full] [--color=auto|always|never] | add <class> <title...> | plan <id> <revision> <fields-json> <reason...> | pin <id> <revision> true|false <reason...> | undo <id> <revision>", "person only: plan work with a reason; {} records a note-only revision, fields.sdlc sets backlog repo/issue identity and clears old spec acceptance; preserve pins/frozen scope and refuse stale edits; list is by rank (--json: raw listing)"],
89
- ["ahub release new <name> <goal> <order> | list | show <name> | plan <name> <revision> <dates-json> | activate|freeze|ship|drop <name> <revision>", "person only: planned UTC millisecond dates and phased scope; ship records a time after approval, never tags or publishes"],
90
- ["ahub backlog split <id> <revision> <parts-json> <reason...>", "person only: stage2..8 child prerequisites; original task retains integration, undo refuses used or changed children"],
91
- ["ahub backlog pull-ready <id> <revision> <reason...>", "person only: reserve an eligible WIP slot before routing; pending unowned work can be undone"],
89
+ ["ahub backlog list [--json] [--full] [--color=auto|always|never] | add <class> <title...> | plan <id> <revision> <fields-json> <reason...> | pin <id> <revision> true|false <reason...> | undo <id> <revision> [--json] [--color=auto|always|never]", "person only: plan work with a reason; {} records a note-only revision, fields.sdlc sets backlog repo/issue identity and clears old spec acceptance; preserve pins/frozen scope and refuse stale edits; list is by rank (--json: raw listing)"],
90
+ ["ahub release new <name> <goal> <order> | list | show <name> | plan <name> <revision> <dates-json> | activate|freeze|ship|drop <name> <revision> [--json] [--color=auto|always|never]", "person only, except show (any agent, public metadata): planned UTC millisecond dates and phased scope; ship records a time after approval, never tags or publishes"],
91
+ ["ahub backlog split <id> <revision> <parts-json> <reason...> [--json] [--color=auto|always|never]", "person only: stage2..8 child prerequisites; original task retains integration, undo refuses used or changed children"],
92
+ ["ahub backlog pull-ready <id> <revision> <reason...> [--json] [--color=auto|always|never]", "person only: reserve an eligible WIP slot before routing; pending unowned work can be undone"],
92
93
  ["ahub backlog pull|return <id>", "person only: pull backlog into To Do or return ownerless proposed work; --backlog on task propose stages unoffered work"],
93
94
  ["ahub deliverables [--task <id>] [--approved] [--check] [--json] [--color=auto|always|never]", "person only: what each task delivered and a reviewer approved, by id, hash and size (a path only where the PII screen allows); --check measures the files now and marks one changed since review"],
94
- ["ahub task show|escalate <id>", "full task with history (PII text included) / hand it to the next peer in escalate_to"],
95
+ ["ahub task show <id> [--json] [--full] [--color=auto|always|never] | escalate <id>", "full task with history (the person sees PII text, an agent the public view) / hand it to the next peer in escalate_to (person or configured conductor)"],
95
96
  ["ahub task role <id> <role|none>", "person only: change bounded catalog guidance on an open task; routing and authority are unchanged"],
96
97
  ["ahub task assign <id> <peer> [--role owner|reviewer|planner] [--revision N]", "name the owner (default), reviewer or person-only planner; a task that still waits reserves its owner; a peer that cannot take it is refused with the reason; --revision refuses stale console actions"],
97
98
  ["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"],
98
- ["ahub task cancel <id> --reason <text> [--superseded-by <ref>] [--revision N]", "person only: close obsolete work with a reason; retain its history without stopping native work or releasing dependents"],
99
+ ["ahub task cancel <id> --reason <text> [--superseded-by <ref>] [--revision N] [--json] [--color=auto|always|never]", "person only, plus the configured conductor: close obsolete work with a reason; retain its history without stopping native work or releasing dependents; eligibility follows the task-board policy"],
99
100
  ["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)"],
100
101
  ["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)"],
101
102
  ["ahub review brief <id>", "print the review brief of a task's commit: reviewable and excluded files, rules, coverage to account for"],
@@ -109,7 +110,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
109
110
  ["ahub budget set <peer> <0..1> [--resets-in 30m] [--window 5h|week]", "feed a reading by hand (also: test the relay)"],
110
111
  ["ahub budget resume <peer>", "override a budget pause; readings are ignored for that peer until the window resets"],
111
112
  ["ahub budget execution status [id] [--json] [--color=auto|always|never]", "read execution counters and limits"],
112
- ["ahub budget execution configure <config.json> | disable <id>", "opt-in limits on model calls, tool calls, time and tokens for pi and local"],
113
+ ["ahub budget execution configure <config.json> | disable <id> [--json] [--color=auto|always|never]", "opt-in limits on model calls, tool calls, time and tokens for pi and local"],
113
114
  ]],
114
115
  ["History and reports", [
115
116
  ["ahub turns [peer] [--limit N] [--json] [--color=auto|always|never]", "recent turns and the files each changed (a git work tree only)"],
@@ -119,7 +120,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
119
120
  ["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"],
120
121
  ["ahub usage limits [--peer <id>] [--limit <n>] [--json] [--color=auto|always|never]", "current quota windows and the history of pauses, resumes and resets"],
121
122
  ["ahub usage total [--json] [--color=auto|always|never]", "lifetime development tokens since the first event, per peer and overall"],
122
- ["ahub export [--since 7d|<iso>]", "structured events (events.jsonl) as JSON lines; never message bodies"],
123
+ ["ahub export [--since 7d|<iso>]", "structured events (events.jsonl) as JSON lines; never message bodies; the configured conductor gets a bounded projection (last 1000 events)"],
123
124
  ["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"],
124
125
  ["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"],
125
126
  ["ahub research export [--format jsonl|csv] [--since 30d] [--all]", "the research records for outside analysis: ids, counts and tokens, never text"],
@@ -1,3 +1,4 @@
1
+ import { conductorOperation, OPERATIONAL_PERSON_GUIDANCE } from "../hub/operational-authority.ts";
1
2
  import { nativeCliMarkerPeer } from "../adapters/definitions/native-identity-defaults.ts";
2
3
  import { recoveryPresentationFlag } from "./recovery-presentation-options.ts";
3
4
  import { definitionOf, kindOf, type KindMap, type ProjectAdapterCatalog } from "../adapters/registry.ts";
@@ -14,7 +15,7 @@ export const LIVE_TEST_PERSON_REFUSAL = "live-test cannot write person-attribute
14
15
 
15
16
  /** Person verdicts and user-authored workflow content are never delegated. */
16
17
  export function liveTestRefusal(command: string, args: readonly string[], context: { repairedFresh?: boolean } = {}): boolean {
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
+ if (command === "sdlc" && (args[0] === "authorize-release" || args[0] === "plan-review" || args[0] === "release-assign" || args[0] === "merge" || args[0] === "train" && args[1] !== "report")) return true;
18
19
  if (args.some(arg => arg === "--as-user" || arg.startsWith("--as-user="))) return true;
19
20
  const sub = command === "recovery" ? recoveryAction(args) : args.find(arg => arg !== "--json");
20
21
  return ["task", "role", "goal", "deliverables"].includes(command)
@@ -53,15 +54,85 @@ export function detectCliIdentity(env: Readonly<Record<string, string | undefine
53
54
 
54
55
  export type PeerCommandAccess = "allowed" | "conductor" | "console";
55
56
 
56
- /** A closed list: unknown commands cannot acquire console authority through an agent shell. */
57
+ /** Flags the read-only forms of #812 accept: display options that change no behaviour. */
58
+ const peerReadFlags = (args: readonly string[]): boolean =>
59
+ args.every((arg) => ["--json", "--full", "--verbose", "--color=always", "--color=never", "--color=auto"].includes(arg) || arg.startsWith("--color="));
60
+
61
+ /**
62
+ * The read-only command forms #812 makes Allowed: command -> subcommand -> what the rest of argv may hold.
63
+ * positional: bare words naming what to read (an operation id, a task, a release, a repo);
64
+ * valueFlags: options consuming one value an agent may name (--peer, --to, --since).
65
+ * A subcommand of "" is the bare form: display flags only. dryRun is checked separately for upgrade and restart.
66
+ */
67
+ interface PeerReadForm {
68
+ positional?: number;
69
+ minimum?: number;
70
+ valueFlags?: readonly string[];
71
+ displayFlags?: readonly string[];
72
+ }
73
+ const publicReadDisplayFlags = ["--json", "--full", "--color=auto", "--color=always", "--color=never"] as const;
74
+ const sdlcReadDisplayFlags = ["--json", "--color=auto", "--color=always", "--color=never"] as const;
75
+ const peerReadForms: Readonly<Record<string, Readonly<Record<string, PeerReadForm>>>> = {
76
+ queue: { list: { valueFlags: ["--peer"], displayFlags: publicReadDisplayFlags } },
77
+ roster: { "": { displayFlags: publicReadDisplayFlags } },
78
+ settings: { "": { displayFlags: ["--json"] }, list: { displayFlags: ["--json"] }, get: { positional: 1, minimum: 1, displayFlags: ["--json"] } },
79
+ // #814 renders these reads for people and keeps JSON as an explicit data format.
80
+ sdlc: { status: { positional: 1, displayFlags: sdlcReadDisplayFlags }, explain: { positional: 1, displayFlags: sdlcReadDisplayFlags }, queue: { positional: 1, minimum: 1, displayFlags: sdlcReadDisplayFlags }, release: { positional: 33, minimum: 1, displayFlags: sdlcReadDisplayFlags } },
81
+ release: { show: { positional: 1, minimum: 1, displayFlags: [] } },
82
+ projects: { "": {} },
83
+ models: { status: {} },
84
+ recovery: { status: { positional: 1 } },
85
+ };
86
+
87
+ /** Whether argv matches one Allowed read-form of this command: display flags, the subcommand shape's values only. */
88
+ const readFormArgs = (args: readonly string[], { valueFlags = [], positional = 0, minimum = 0, displayFlags }: PeerReadForm = {}): boolean => {
89
+ let positionals = 0;
90
+ let expectValue: string | undefined;
91
+ for (const arg of args) {
92
+ if (expectValue !== undefined) {
93
+ if (arg.startsWith("-")) return false;
94
+ expectValue = undefined;
95
+ } else if (valueFlags.includes(arg)) {
96
+ expectValue = arg;
97
+ } else if (arg.startsWith("--")) {
98
+ if (displayFlags === undefined ? !peerReadFlags([arg]) : !displayFlags.includes(arg)) return false;
99
+ } else if (positionals >= positional) {
100
+ return false;
101
+ } else {
102
+ positionals++;
103
+ }
104
+ }
105
+ return expectValue === undefined && positionals >= minimum;
106
+ };
107
+
108
+ const isPeerReadForm = (command: string, args: readonly string[]): boolean => {
109
+ const subs = peerReadForms[command];
110
+ if (subs === undefined) return false;
111
+ // The bare form ("") carries no subcommand: args empty or all display flags; otherwise args[0] is the subcommand.
112
+ const first = args[0];
113
+ const bare = first === undefined || first.startsWith("--");
114
+ const sub = bare ? "" : first;
115
+ if (!Object.hasOwn(subs, sub)) return false;
116
+ return readFormArgs(bare ? args : args.slice(1), subs[sub]!);
117
+ };
118
+
119
+ /**
120
+ * A closed list: unknown commands cannot acquire console authority through an agent shell.
121
+ * #812: the read-only forms matched by the branches just above the catch-alls are Allowed for any agent.
122
+ * The branches above them are the older authority rules and stay unchanged.
123
+ */
57
124
  export function classifyPeerCommand(command: string, args: readonly string[], catalog?: ProjectAdapterCatalog): PeerCommandAccess {
58
125
  if (command === "recovery" && recoveryAction(args) === "reattach") return "console";
59
126
  const native = catalog?.definitionOf(command) ?? definitionOf(command);
60
127
  if (native?.cli.personOnlyFlags?.some(flag => args.includes(flag))) return "console";
61
128
  if (command === "pet" && args.includes("--window")) return "console";
62
- if (command === "permission" || command === "stop" || command === "watch") return "console";
129
+ if (command === "permission" || command === "watch") return "console";
63
130
  if (args.some((arg) => arg === "--as-user" || arg.startsWith("--as-user="))) return "console";
131
+ if (conductorOperation(command,args)) return "conductor";
132
+ if (command === "stop") return "console";
64
133
  if (command === "sdlc" && ["release-guard", "release-guard-hook"].includes(args[0] ?? "")) return "allowed";
134
+ // #786: the guarded merge is the person's or the configured conductor's; the daemon refuses any other peer by role.
135
+ if (command === "sdlc" && args[0] === "merge") return "conductor";
65
136
  if (command === "role") return ["list", "show"].includes(args[0] ?? "") ? "allowed" : "console";
66
137
  if (command === "task") {
67
138
  if (["propose", "show"].includes(args[0] ?? "")) return "allowed";
@@ -82,6 +153,16 @@ export function classifyPeerCommand(command: string, args: readonly string[], ca
82
153
  // The subcommand is the first argument that is not --json, which the command accepts anywhere.
83
154
  if (command === "bench") return args.find((arg) => arg !== "--json") === "run" ? "console" : "allowed";
84
155
  if (command === "budget") return !args.length || args.every((arg) => arg === "--json" || arg === "--full" || arg.startsWith("--color=")) ? "allowed" : "console";
156
+ // #812 Allowed: the read-only forms below print what the person sees and change nothing. Each command's
157
+ // other forms fall to the existing rules underneath, so every older branch above and below keeps its class.
158
+ if (command === "doctor") {
159
+ if (peerReadFlags(args)) return "allowed"; // no args, or only --json, --full, --color=...
160
+ if (args[0] === "--stale-anchors" && peerReadFlags(args.slice(1))) return "allowed"; // a bounded read-only anchor report
161
+ // --orphans (it can kill) and anything else stay as today.
162
+ }
163
+ if ((command === "upgrade" || command === "restart") && !args.includes("--yes") && args.includes("--dry-run")
164
+ && readFormArgs(args.filter((arg) => arg !== "--dry-run"), { valueFlags: ["--to"] })) return "allowed"; // a preview that applies nothing; --yes stays console
165
+ if (isPeerReadForm(command, args)) return "allowed"; // exact read forms; every mutating neighbour remains below its existing authority gate
85
166
  if (native?.cli.authority === "person") return "console";
86
167
  if (native || ["pause", "resume"].includes(command)) return "conductor";
87
168
  if (command === "board" && args[0] === "import") return "console";
@@ -95,9 +176,9 @@ export function cliCommandLabel(command: string, args: readonly string[], catalo
95
176
  if (command === "board" && args[0] === "import") return "board import";
96
177
  if (command === "release") return ["new", "list", "show", "plan", "activate", "freeze", "ship", "drop"].includes(args[0] ?? "") ? `release ${args[0]}` : "release";
97
178
  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";
98
- if (["task", "queue", "budget", "route", "recovery", "models", "projects", "research", "memory", "goal", "bench", "agents"].includes(command)) {
179
+ if (["task", "queue", "budget", "route", "recovery", "models", "projects", "research", "memory", "goal", "bench", "agents", "roster", "settings"].includes(command)) {
99
180
  const sub = command === "bench" || command === "memory" || command === "goal" ? args.find((arg) => arg !== "--json") : args[0];
100
- 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}`;
181
+ if (sub && ["propose", "show", "assign", "unassign", "escalate", "cancel", "label", "list", "add", "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}`;
101
182
  }
102
183
  return (/^[a-z][a-z0-9-]*$/.test(command) && !/\s/.test(command)) || ["--help", "--version"].includes(command) ? command : "unknown";
103
184
  }
@@ -122,5 +203,5 @@ export function agentShellCause(identity: CliIdentity, error: string, env: Reado
122
203
 
123
204
  export function peerCommandRefusal(peer: string, command: string, env: Readonly<Record<string, string | undefined>> = process.env): string {
124
205
  const markers = ["CLAUDECODE", "CLAUDE_CODE_SESSION_ID", "AGENTHUB_PEER_ID", "CODEX_THREAD_ID"].filter(name => env[name] !== undefined);
125
- 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`;
206
+ return `${peer} cannot run ahub ${command}; ${OPERATIONAL_PERSON_GUIDANCE}. 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`;
126
207
  }