@staix/agent-hub 0.23.1 → 0.25.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 (95) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +32 -5
  3. package/docs/agent-notes/adapters.md +5 -2
  4. package/docs/agent-notes/bus.md +1 -0
  5. package/docs/agent-notes/daemon.md +16 -2
  6. package/docs/agent-notes/tasks.md +9 -2
  7. package/docs/agent-notes/tests.md +3 -0
  8. package/docs/dogfood.md +16 -2
  9. package/docs/memory.md +1 -1
  10. package/docs/operations.md +198 -5
  11. package/docs/profiles.md +2 -0
  12. package/docs/quickstart.md +19 -4
  13. package/docs/security.md +225 -8
  14. package/docs/specs/2026-09-19-agent-hub-design.md +54 -1
  15. package/docs/specs/2026-09-19-multi-project-design.md +12 -1
  16. package/docs/specs/2026-11-01-research-campaigns-design.md +351 -0
  17. package/docs/verification/2026-10-31-0.24.0.md +63 -0
  18. package/docs/verification/2026-10-31-claude-implementer-2.1.316.md +65 -0
  19. package/docs/verification/2026-11-01-0.25.0.md +91 -0
  20. package/docs/verification/claude-shell-wrapper-native.py +1 -1
  21. package/docs/verified.json +31 -15
  22. package/package.json +4 -3
  23. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  24. package/plugins/agent-hub/server.js +5120 -4710
  25. package/src/adapters/claude-channel.ts +29 -6
  26. package/src/adapters/compat.ts +3 -3
  27. package/src/adapters/definitions/native-cli-claude-setup.ts +4 -0
  28. package/src/adapters/definitions/native-cli-doctor.ts +30 -0
  29. package/src/adapters/jcode.ts +3 -0
  30. package/src/adapters/registry.ts +1 -1
  31. package/src/cli/acon-register.ts +29 -0
  32. package/src/cli/autonomy-homes.ts +33 -0
  33. package/src/cli/autonomy-offline.ts +75 -0
  34. package/src/cli/autonomy-projects.ts +103 -0
  35. package/src/cli/autonomy.ts +44 -0
  36. package/src/cli/claude-homes.ts +123 -0
  37. package/src/cli/claude-profile-hook.ts +130 -0
  38. package/src/cli/handoff-args.ts +27 -0
  39. package/src/cli/handoff-output.ts +25 -0
  40. package/src/cli/help.ts +8 -4
  41. package/src/cli/identity.ts +8 -1
  42. package/src/cli/main.ts +174 -33
  43. package/src/cli/output.ts +8 -5
  44. package/src/cli/schedule-output.ts +8 -4
  45. package/src/cli/setup-homes.ts +46 -0
  46. package/src/cli/setup.ts +1 -1
  47. package/src/cli/study-output.ts +71 -0
  48. package/src/cli/study.ts +58 -0
  49. package/src/cli/task-output.ts +1 -0
  50. package/src/hub/action-class.ts +203 -14
  51. package/src/hub/action-enforce.ts +8 -6
  52. package/src/hub/action-policy.ts +95 -9
  53. package/src/hub/autonomy-consent.ts +173 -0
  54. package/src/hub/autonomy-defaults.ts +58 -0
  55. package/src/hub/autonomy.ts +232 -0
  56. package/src/hub/board.ts +871 -2
  57. package/src/hub/channel-project.ts +61 -0
  58. package/src/hub/config-apply.ts +3 -2
  59. package/src/hub/confirmation-matrix.ts +202 -0
  60. package/src/hub/control-client.ts +10 -1
  61. package/src/hub/daemon.ts +546 -27
  62. package/src/hub/envelope.ts +2 -0
  63. package/src/hub/events.ts +4 -1
  64. package/src/hub/execution-budget.ts +12 -0
  65. package/src/hub/handoff.ts +126 -0
  66. package/src/hub/hub-tools.ts +12 -2
  67. package/src/hub/manager.ts +64 -57
  68. package/src/hub/no-writes.ts +1 -1
  69. package/src/hub/path-policy.ts +12 -8
  70. package/src/hub/project.ts +14 -5
  71. package/src/hub/provenance.ts +30 -0
  72. package/src/hub/research-campaign.ts +265 -0
  73. package/src/hub/research-experiments.ts +445 -0
  74. package/src/hub/restart.ts +26 -0
  75. package/src/hub/schedules.ts +210 -9
  76. package/src/hub/session-identity.ts +49 -0
  77. package/src/hub/settings.ts +105 -8
  78. package/src/hub/task-repository.ts +11 -6
  79. package/src/hub/tasks.ts +894 -22
  80. package/src/hub/ui.ts +15 -6
  81. package/src/memory/capture.ts +2 -2
  82. package/src/ui/index.html +5 -3
  83. package/src/ui/office-space.ts +118 -0
  84. package/src/ui/plugin.ts +293 -0
  85. package/templates/AGENTS.block.md +1 -0
  86. package/templates/presets/autonomous.json +161 -0
  87. package/templates/presets/confirmation-matrix.json +943 -0
  88. package/templates/profiles/general.json +1 -1
  89. package/templates/profiles/research.json +4 -4
  90. package/templates/profiles/writing.json +1 -1
  91. package/templates/roles/research/analyst.md +1 -0
  92. package/templates/roles/research/fact-checker.md +1 -0
  93. package/templates/roles/research/research-lead.md +1 -0
  94. package/templates/roles/research/researcher.md +1 -0
  95. package/templates/roles/research/statistician.md +1 -0
@@ -0,0 +1,130 @@
1
+ import { closeSync, constants, fchmodSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readSync, renameSync, rmSync, writeFileSync, type Stats } from "node:fs";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import { dirname, isAbsolute, join } from "node:path";
4
+ import { canonicalJson } from "../hub/packs.ts";
5
+ import { factsHook } from "./facts-hook.ts";
6
+ import { shellQuote } from "./shell-quote.ts";
7
+ import type { ClaudeHome } from "./claude-homes.ts";
8
+
9
+ export interface ClaudeProfileHookOptions { script?: string; runtime?: string }
10
+ export interface ClaudeProfileHookCheck { state: "installed" | "missing" | "refused"; reason: string }
11
+ export interface ClaudeProfileHookInstall extends ClaudeProfileHookCheck { changed: boolean }
12
+ const MAX_SETTINGS_BYTES = 1024 * 1024;
13
+ const validPath = (value: unknown): value is string => typeof value === "string" && value.length > 0 && value.length <= 4096 && isAbsolute(value) && !/[\x00-\x1f\x7f]/.test(value);
14
+ const object = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value);
15
+ const owned = (info: Stats) => !!process.getuid && info.uid === process.getuid() && (info.mode & 0o022) === 0;
16
+ const same = (left: Stats | undefined, right: Stats | undefined) => !left && !right || !!left && !!right && left.dev === right.dev && left.ino === right.ino;
17
+
18
+ interface HookPlan {
19
+ check: ClaudeProfileHookCheck;
20
+ file: string;
21
+ next?: string;
22
+ home?: Stats;
23
+ parent?: Stats;
24
+ source?: { info: Stats; digest: string };
25
+ }
26
+
27
+ function hookEntry(options: ClaudeProfileHookOptions): Record<string, unknown> {
28
+ const script = options.script ?? join(import.meta.dir, "claude-profile-hook.ts"), runtime = options.runtime ?? process.execPath;
29
+ if (!validPath(script) || !validPath(runtime)) throw new Error("invalid profile hook command");
30
+ return { matcher: "*", hooks: [{ type: "command", command: `${shellQuote(runtime)} ${shellQuote(script)} --run`, timeout: 5 }] };
31
+ }
32
+
33
+ function settingsSource(file: string): { value: Record<string, unknown>; source?: HookPlan["source"] } {
34
+ let fd: number | undefined;
35
+ try {
36
+ fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
37
+ const info = fstatSync(fd);
38
+ if (!info.isFile() || info.nlink !== 1 || !owned(info) || info.size > MAX_SETTINGS_BYTES) throw new Error("unsafe settings");
39
+ const bytes = Buffer.alloc(MAX_SETTINGS_BYTES + 1), count = readSync(fd, bytes, 0, bytes.length, 0);
40
+ if (count > MAX_SETTINGS_BYTES) throw new Error("oversized settings");
41
+ const content = bytes.subarray(0, count), value: unknown = JSON.parse(content.toString("utf8"));
42
+ if (!object(value)) throw new Error("invalid settings");
43
+ return { value, source: { info, digest: createHash("sha256").update(content).digest("hex") } };
44
+ } catch (error) {
45
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return { value: {} };
46
+ throw error;
47
+ } finally { if (fd !== undefined) closeSync(fd); }
48
+ }
49
+
50
+ /** A shared read-only plan keeps inspection and installation's refusal rules identical. */
51
+ function planClaudeProfileHook(home: ClaudeHome, options: ClaudeProfileHookOptions): HookPlan {
52
+ const file = validPath(home.path) ? join(home.path, "settings.json") : "";
53
+ const refuse = (reason: string): HookPlan => ({ file, check: { state: "refused", reason } });
54
+ if (!file || !home.sources.length || home.sources.some(source => !["environment", "default", "recorded-launch"].includes(source))) return refuse("No explicitly selected Claude configuration home is available.");
55
+ try {
56
+ const directory = lstatSync(home.path, { throwIfNoEntry: false });
57
+ let parent: Stats | undefined;
58
+ if (directory && (!directory.isDirectory() || directory.isSymbolicLink() || !owned(directory))) return refuse("The selected Claude home is unsafe or not owned by this user.");
59
+ if (!directory) {
60
+ if (home.exists && !home.sources.some(source => source === "environment" || source === "recorded-launch")) return refuse("The previously observed default Claude home is no longer present.");
61
+ parent = lstatSync(dirname(home.path), { throwIfNoEntry: false });
62
+ if (!parent?.isDirectory() || parent.isSymbolicLink() || !owned(parent)) return refuse("The selected Claude home's parent is unsafe or unavailable.");
63
+ }
64
+ const { value, source } = settingsSource(file);
65
+ if (value.disableAllHooks === true) return refuse("Claude has disableAllHooks enabled; the profile hook cannot be installed.");
66
+ if (value.disableAllHooks !== undefined && typeof value.disableAllHooks !== "boolean") return refuse("Claude's hook enablement setting is malformed.");
67
+ if (value.hooks !== undefined && !object(value.hooks)) return refuse("Claude's hook configuration is malformed.");
68
+ const hooks = value.hooks ?? {}, existing = hooks.PreToolUse ?? [];
69
+ if (!Array.isArray(existing)) return refuse("Claude's PreToolUse hook list is malformed.");
70
+ const entry = hookEntry(options), expected = canonicalJson(entry);
71
+ const matches = existing.filter(candidate => canonicalJson(candidate) === expected).length;
72
+ const next = matches === 1 ? undefined : `${JSON.stringify({ ...value, hooks: { ...hooks, PreToolUse: [...existing.filter(candidate => canonicalJson(candidate) !== expected), entry] } }, null, 2)}\n`;
73
+ if (next !== undefined && Buffer.byteLength(next, "utf8") > MAX_SETTINGS_BYTES) return refuse("Claude's settings would exceed the bounded profile settings size.");
74
+ return { file, home: directory, parent, source, next, check: matches === 1
75
+ ? { state: "installed", reason: "The project-neutral profile hook is configured; this does not prove live permission enforcement." }
76
+ : { state: "missing", reason: matches ? "Duplicate managed profile hooks need normalization." : "The project-neutral profile hook is not configured." } };
77
+ } catch { return refuse("Claude profile settings could not be safely inspected; no settings were changed."); }
78
+ }
79
+
80
+ export function checkClaudeProfileHook(home: ClaudeHome, options: ClaudeProfileHookOptions = {}): ClaudeProfileHookCheck {
81
+ return planClaudeProfileHook(home, options).check;
82
+ }
83
+
84
+ /** Only the selected home is created; unrelated profile directories and settings are never touched. */
85
+ export function installClaudeProfileHook(home: ClaudeHome, options: ClaudeProfileHookOptions = {}): ClaudeProfileHookInstall {
86
+ const plan = planClaudeProfileHook(home, options);
87
+ if (plan.check.state !== "missing" || plan.next === undefined) return { ...plan.check, changed: false };
88
+ const temporary = join(home.path, `.agent-hub-profile-hook-${randomUUID()}.tmp`);
89
+ let created: Stats | undefined;
90
+ try {
91
+ if (!plan.home) {
92
+ const current = lstatSync(dirname(home.path));
93
+ if (!same(plan.parent, current) || !owned(current)) throw new Error("parent changed");
94
+ mkdirSync(home.path, { mode: 0o700 });
95
+ created = lstatSync(home.path);
96
+ if (!created.isDirectory() || created.isSymbolicLink() || !owned(created)) throw new Error("home changed");
97
+ }
98
+ const fd = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
99
+ let prepared: Stats;
100
+ try { writeFileSync(fd, plan.next); fchmodSync(fd, 0o600); fsyncSync(fd); prepared = fstatSync(fd); }
101
+ finally { closeSync(fd); }
102
+ const current = planClaudeProfileHook(home, options);
103
+ if (current.check.state === "refused" || !same(plan.home ?? created, current.home) || !same(plan.source?.info, current.source?.info) || plan.source?.digest !== current.source?.digest) throw new Error("settings changed");
104
+ const named = lstatSync(temporary);
105
+ if (!same(prepared, named) || !named.isFile() || named.isSymbolicLink() || named.nlink !== 1 || !owned(named)) throw new Error("temporary settings changed");
106
+ renameSync(temporary, plan.file);
107
+ return { state: "installed", changed: true, reason: "The project-neutral profile hook is configured; this does not prove live permission enforcement." };
108
+ } catch { return { state: "refused", changed: false, reason: "Claude profile settings changed or could not be safely written; inspect the selected home before trying again." }; }
109
+ finally { try { rmSync(temporary, { force: true }); } catch { /* Only this invocation's generated temporary name is considered. */ } }
110
+ }
111
+
112
+ export interface ClaudeProfileHookTarget { stateDir: string; peer: string }
113
+ export function claudeProfileHookTarget(env: NodeJS.ProcessEnv): ClaudeProfileHookTarget | undefined {
114
+ if (Object.hasOwn(env, "AGENTHUB_LAUNCH_ID") || !validPath(env.AGENTHUB_STATE_DIR) || !/^[a-z][a-z0-9_-]{0,63}$/.test(env.AGENTHUB_PEER_ID ?? "")) return;
115
+ return { stateDir: env.AGENTHUB_STATE_DIR, peer: env.AGENTHUB_PEER_ID! };
116
+ }
117
+ export type ClaudeProfileHookTransport = (input: string, stateDir: string, peer: string) => Promise<string | undefined>;
118
+ export async function runClaudeProfileHook(input: string, env: NodeJS.ProcessEnv, transport: ClaudeProfileHookTransport = factsHook): Promise<string | undefined> {
119
+ const target = claudeProfileHookTarget(env);
120
+ if (!target || Buffer.byteLength(input, "utf8") > MAX_SETTINGS_BYTES) return;
121
+ try { return await transport(input, target.stateDir, target.peer); } catch { return; }
122
+ }
123
+
124
+ // Managed launches inject their own authoritative session hook; ordinary non-hub sessions have no target.
125
+ if (import.meta.main && process.argv.length === 3 && process.argv[2] === "--run" && claudeProfileHookTarget(process.env)) {
126
+ try {
127
+ const output = await runClaudeProfileHook(await Bun.stdin.text(), process.env);
128
+ if (output) console.log(output);
129
+ } catch { /* A profile hook never turns a transport failure into a native permission decision. */ }
130
+ }
@@ -0,0 +1,27 @@
1
+ // Argument parsing for `ahub task handoff` (#604, agent-hub#1030). Pure: argv in, one request or a usage line out, so main.ts stays a thin dispatcher and every form is testable without a daemon.
2
+ import { CLASSES } from "../hub/board.ts";
3
+
4
+ export const HANDOFF_USAGE = "usage: ahub task handoff --project <root|id> [<class>] <title...> [--path <p>]... [--issue <n>] [--task <n>] [--detail <text>] [--json] [--color=auto|always|never]";
5
+
6
+ export interface HandoffCliRequest { project: string; title: string; class?: string; detail?: string; paths?: string[]; issue?: number; task?: number }
7
+
8
+ /** `operands` is argv after `task handoff`, presentation flags already removed. A first word that is a class name is the class, said out loud by the caller (the documented short form). */
9
+ export function parseHandoffArgs(operands: readonly string[]): HandoffCliRequest | { usage: string } {
10
+ const one: Record<string, string> = {}, paths: string[] = [], rest: string[] = [];
11
+ for (let i = 0; i < operands.length; i++) {
12
+ const arg = operands[i]!;
13
+ if (arg === "--project" || arg === "--detail" || arg === "--issue" || arg === "--task" || arg === "--path") {
14
+ const value = operands[++i];
15
+ if (value === undefined || value.startsWith("--")) return { usage: HANDOFF_USAGE };
16
+ if (arg === "--path") paths.push(value); else { if (Object.hasOwn(one, arg)) return { usage: HANDOFF_USAGE }; one[arg] = value; }
17
+ } else if (arg.startsWith("--")) return { usage: HANDOFF_USAGE };
18
+ else rest.push(arg);
19
+ }
20
+ const project = one["--project"];
21
+ const classed = rest[0] !== undefined && (CLASSES as readonly string[]).includes(rest[0]) && rest.length > 1;
22
+ const title = (classed ? rest.slice(1) : rest).join(" ").trim();
23
+ if (!project || !title) return { usage: HANDOFF_USAGE };
24
+ if (one["--issue"] !== undefined && !/^[1-9]\d{0,9}$/.test(one["--issue"])) return { usage: `${HANDOFF_USAGE}\n--issue is the target repository's issue number` };
25
+ if (one["--task"] !== undefined && !/^[1-9]\d{0,9}$/.test(one["--task"])) return { usage: `${HANDOFF_USAGE}\n--task is this project's own task id` };
26
+ return { project, title, ...(classed ? { class: rest[0]! } : {}), ...(one["--detail"] !== undefined ? { detail: one["--detail"] } : {}), ...(paths.length ? { paths } : {}), ...(one["--issue"] !== undefined ? { issue: Number(one["--issue"]) } : {}), ...(one["--task"] !== undefined ? { task: Number(one["--task"]) } : {}) };
27
+ }
@@ -0,0 +1,25 @@
1
+ // Human output for `ahub task handoff` and `ahub board --handoffs` (#604, agent-hub#1030). Pure: the hub's parsed JSON in, spans out, no I/O. `--json` is the caller's branch and prints the same text.
2
+ // Only ids, states and counts are shown: the title of a handed-off task is the caller's own and was bounded and sanitized by the hub.
3
+ import { blank, grid, heading, pair, s } from "./sdlc-output.ts";
4
+ import type { Span, Tone } from "./console-state.ts";
5
+
6
+ const STATE_TONE: Record<string, Tone | undefined> = { approved: undefined, cancelled: "muted", declined: "attention", proposed: "attention" };
7
+ const num = (value: unknown): string => typeof value === "number" && Number.isSafeInteger(value) ? String(value) : "-";
8
+
9
+ /** The receipt of one handoff: where the task now lives and what to run to follow it. */
10
+ export function renderHandoffReceipt(receipt: unknown, columns?: number): Span[][] {
11
+ const r = receipt as any;
12
+ return [...heading(`Handed off to project ${r?.project ?? "?"}`, columns),
13
+ ...pair("task", `#${num(r?.task)} (${r?.state ?? "proposed"})`, columns, 8, "taskRef"),
14
+ blank, [s("it is on that project's board as an ownerless proposal; its own roles plan, implement and review it, and you are told when it changes state", "muted")],
15
+ [s("ahub board --handoffs shows the last state it reported", "muted")]];
16
+ }
17
+
18
+ /** What this project handed to others, with the last state each target reported. */
19
+ export function renderHandoffs(data: unknown, columns?: number): Span[][] {
20
+ const rows: any[] = Array.isArray((data as any)?.handoffs) ? (data as any).handoffs : [];
21
+ if (!rows.length) return [[s("no handoffs: hand work that belongs to another project with `ahub task handoff --project <root|id> <title...>`", "muted")]];
22
+ return [...heading(`Handoffs (${rows.length})`, columns), ...grid(["PROJECT", "TASK", "STATE", "PR", "BY", "TITLE"], rows.map(row => [
23
+ s(String(row.remoteProject ?? "?").slice(0, 24), "issueRef"), s(`#${num(row.remoteTask)}`, "taskRef"), s(String(row.state ?? "?"), STATE_TONE[row.state]),
24
+ s(typeof row.pr === "number" ? `#${row.pr}` : "-"), s(String(row.originPeer ?? "?")), s(String(row.title ?? ""))]), columns)];
25
+ }
package/src/cli/help.ts CHANGED
@@ -10,8 +10,8 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
10
10
  ["ahub --project <path|id> <command>", "select a repository or worktree explicitly"],
11
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
- ["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|<pack>] [--shadow] [--release-guard] [--dry-run --json]", "preview or write .agenthub/config.json and the AGENTS.md marker block (drops a legacy CLAUDE.md block); a pack is a shipped one (writing-pack, analysis, ops) or a project file .agenthub/roles/<name>.pack.json"],
13
+ ["ahub setup [--yes]", "install or update the channel plugin and project-neutral hook in Claude configuration homes in use, preserving unrelated profile settings, register the acon console plugin (console/plugins/ahub.json in the hub home, read at acon's next start), then run doctor"],
14
+ ["ahub init [--profile dev|research|writing|general|<pack>] [--shadow] [--release-guard] [--dry-run --json]", "preview or write .agenthub/config.json and the AGENTS.md marker block (drops a legacy CLAUDE.md block); a pack is a shipped one (writing-pack, analysis, ops) or a project file .agenthub/roles/<name>.pack.json; a new project inherits a trusted machine autonomy default into config.local.json"],
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"],
@@ -41,7 +41,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
41
41
  ["ahub status [--json] [--full] [--color=auto|always|never]", "the hub, its peers, model backends and task counts"],
42
42
  ["ahub status --all [--json] [--color=auto|always|never]", "show every registered project"],
43
43
  ["ahub logs [-f]", "person: last 100 hub.log lines (-f starts at 10); conductor: bounded public lines and follow while authority remains"],
44
- ["ahub doctor [--json] [--full] [--color=auto|always|never]", "check the tools, daemon, plugin, gateway, models and memory library this project uses"],
44
+ ["ahub doctor [--json] [--full] [--color=auto|always|never]", "check the tools, daemon, plugin, gateway, models, memory library and memory capture setup (read from `amem setup <tool> --json`, a preview) this project uses"],
45
45
  ["ahub doctor --stale-anchors [--json]", "read-only bounded report of public memory symbol anchors at current HEAD; private or unavailable evidence stays unknown"],
46
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"],
47
47
  ["ahub agents [--json]", "show each gated agent CLI's installed version and verdict (verified, in-line, trusted, refused or unknown)"],
@@ -74,6 +74,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
74
74
  ["ahub permit <id> <option>", "answer a permission request shown by tail (\"deny\" cancels)"],
75
75
  ["ahub roster [--json] [--color=auto|always|never]", "every known peer, participation, start mode and retained work"],
76
76
  ["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"],
77
+ ["ahub autonomy on [--all-projects] [--yes] | off [--all-projects] | status [--json]", "person only: apply the autonomy preset and machine default with one confirmation, optionally to every registered project; off removes fixed preset keys (manual edits included); status reports modes, floor enforcement and Claude homes; settings undo restores a live-hub project write"],
77
78
  ["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"],
78
79
  ["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"],
79
80
  ["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"],
@@ -85,8 +86,9 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
85
86
  ]],
86
87
  ["Tasks and review", [
87
88
  ["ahub board import github --label <label> [--milestone <milestone>] [--repo <owner/repo>]", "person or configured conductor: read bounded open issues into deduplicated Backlog; never writes GitHub"],
88
- ["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"],
89
+ ["ahub board [state | --ready] [--columns] [--release <name> | --release-id <id>] [--handoffs] [--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; --handoffs: what this project handed to other projects and the last state each reported"],
89
90
  ["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"],
91
+ ["ahub task handoff --project <root|id> [<class>] <title...> [--path <p>]... [--issue <n>] [--task <n>] [--detail <text>] [--json] [--color=auto|always|never]", "person only: register work that belongs to ANOTHER project that runs agent-hub on that project's board (an ownerless proposal its own roles take from there); the target must be registered and running, nothing is queued; a feature or behavior change first needs a spec issue there (--issue); its state changes show in ahub board --handoffs (an agent that hands off gets them as task messages)"],
90
92
  ["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)"],
91
93
  ["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"],
92
94
  ["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"],
@@ -104,6 +106,8 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
104
106
  ["ahub intake \"<text>\" [--source <id>...] | approve <id> | discard <id> [--json] [--color=auto|always|never]", "person only, except approve (person or configured conductor): turn a plain request into a draft backlog card that nothing offers or pulls until you approve it; the configured planner is asked to split it with hub_backlog_split, or the hub model proposes the split (a PII request only on an on-prem model, never to a planner); each --source must be a registered src: id; approve clears the draft on it and its parts, discard cancels them through task cancel"],
105
107
  ["ahub capabilities [--json] [--color=auto|always|never]", "person only, never a conductor or an agent shell: list the skills and commands each attached peer offers (Kimi 2.3.2 and Qwen 0.26.1 sessions, Codex 0.166.0 skills; other versions list nothing), whether you share each one, its digest and the class a call would count as; sharing is invocation.share in .agenthub/config.local.json, this machine only; it only lists and never calls anything"],
106
108
  ["ahub learn list [--all] | accept <id> | reject <id> [--json] [--color=auto|always|never]", "person only, never a conductor or an agent shell: review a recurring-unmet-criterion candidate the hub filed (#514a); accept writes its one review checklist line, added to your current checklist, to .agenthub/config.local.json through the settings writer (this machine only, never a tracked file, ahub settings undo puts it back, applies after a restart) and closes the card; reject only closes it"],
109
+ ["ahub study draft <parent> <contract.json> | revise <id> <revision> <contract.json> | approve <id> <revision> | close <id> <revision> --reason <text> | approve-protocol <hash> [--json] [--color=auto|always|never]", "person only: store a research campaign contract as a draft, revise it, approve it (the only step that creates the unit tasks and lets work start) or close it; approve-protocol binds approval to the exact protocol and evaluator hashes; a planner drafts through hub_study_draft and hub_study_revise and can never approve"],
110
+ ["ahub study show [<id> | --after <id>] [--json] [--color=auto|always|never]", "read-only for anyone: list this project's research campaigns, 50 per page (the page names the --after cursor that continues it), or one campaign's coverage per unit, handoff state, budget support and why dispatch is held; a private campaign, task or source shows ids, counts and states only"],
107
111
  ["ahub deliverables [--task <id>] [--approved] [--check] [--json] [--color=auto|always|never]", "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; an agent sees only the tasks hub_task_show shows it"],
108
112
  ["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)"],
109
113
  ["ahub task role <id> <role|none>", "the person, or an agent on a task it owns: change bounded catalog guidance on an open task; routing and authority are unchanged"],
@@ -22,10 +22,13 @@ export function liveTestRefusal(command: string, args: readonly string[], contex
22
22
  || (command === "room" && !["list", "status"].includes(sub ?? ""))
23
23
  || (command === "backlog" && sub !== "list") || (command === "release" && !["list", "show"].includes(sub ?? ""))
24
24
  || (command === "memory" && sub !== "graph")
25
+ // #601: a campaign's draft, revise, approve and close are the person's verdicts; only `show` is a read. Without this an agent shell could run them as the person through `--live-test`.
26
+ || (command === "study" && sub !== "show")
25
27
  || (command === "recovery" && (sub === "reattach" || sub === "dispose" && context.repairedFresh === true))
26
28
  || (command === "board" && args[0] === "import") || (command === "sdlc" && (args[0] === "record" || args[0] === "accept" || args[0] === "accept-departure"))
27
29
  || (command === "bench" && args.find(arg => arg !== "--json") === "run") || command === "pet"
28
30
  // #691: trusting an untested agent CLI version is the person's consent, never a delegated live-test operation.
31
+ || command === "autonomy"
29
32
  || (command === "agents" && !args.every(arg => arg === "--json"));
30
33
  }
31
34
 
@@ -81,6 +84,8 @@ const peerReadForms: Readonly<Record<string, Readonly<Record<string, PeerReadFor
81
84
  // #814 renders these reads for people and keeps JSON as an explicit data format.
82
85
  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 } },
83
86
  release: { show: { positional: 1, minimum: 1, displayFlags: [] } },
87
+ // #601: reading a campaign is harmless for any agent (the hub stubs a private one); draft, revise, approve and close fall to `console`.
88
+ study: { show: { positional: 1, valueFlags: ["--after"], displayFlags: sdlcReadDisplayFlags } },
84
89
  projects: { "": {} },
85
90
  models: { status: {} },
86
91
  recovery: { status: { positional: 1 } },
@@ -190,8 +195,10 @@ export function cliCommandLabel(command: string, args: readonly string[], catalo
190
195
  if (command === "capabilities") return "capabilities"; // #445 C1: no argument is ever echoed into the audit
191
196
  if (command === "learn") return ["list", "accept", "reject"].includes(args.find(arg => arg !== "--json") ?? "") ? `learn ${args.find(arg => arg !== "--json")}` : "learn";
192
197
  if (command === "source") return ["add", "list"].includes(args.find(arg => arg !== "--json") ?? "") ? `source ${args.find(arg => arg !== "--json")}` : "source";
198
+ // #601: a campaign id and a contract path are text the person chose, so the audit names the subcommand only.
199
+ if (command === "study") return ["draft", "revise", "approve", "close", "show"].includes(args[0] ?? "") ? `study ${args[0]}` : "study";
193
200
  if (command === "release") return ["new", "list", "show", "plan", "activate", "freeze", "ship", "drop"].includes(args[0] ?? "") ? `release ${args[0]}` : "release";
194
- 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", "down", "reset", "doctor", "agents", "source", "broker", "schedule", "intake", "learn"].includes(command)) return "unknown";
201
+ 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", "down", "reset", "doctor", "agents", "source", "broker", "schedule", "intake", "learn", "study"].includes(command)) return "unknown";
195
202
  if (["task", "queue", "budget", "route", "recovery", "models", "projects", "research", "memory", "goal", "bench", "agents", "roster", "settings"].includes(command)) {
196
203
  const sub = command === "bench" || command === "memory" || command === "goal" ? args.find((arg) => arg !== "--json") : args[0];
197
204
  if (sub && ["propose", "show", "assign", "unassign", "escalate", "cancel", "claim", "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}`;
package/src/cli/main.ts CHANGED
@@ -16,7 +16,7 @@ import { readInstanceRoleConfig, readInstanceConfig, type InstanceRoleConfig } f
16
16
  import { staleInstanceKey } from "../hub/roster.ts";
17
17
  import { parseConfiguredAcpArguments, runConfiguredAcpEntry, requireActiveConfiguredAcp } from "./configured-acp-entry.ts";
18
18
  import type { NativeCliEntryContext } from "../adapters/definitions/native-cli-entry-types.ts";
19
- import { BUILTIN_CLI_ENTRIES, createProjectAdapterCatalog, definitionOf, DOCTOR_TOOL_PROBES, DOCTOR_MEMORY_CAPTURE_PEERS, DOCTOR_PLUGIN_PROBE, nativeDoctorCheck, nativeDoctorRecoveryIdentity, nativeDoctorOptionalProbes } from "../adapters/registry.ts";
19
+ import { BUILTIN_CLI_ENTRIES, createProjectAdapterCatalog, definitionOf, DOCTOR_TOOL_PROBES, DOCTOR_MEMORY_CAPTURE_PEERS, DOCTOR_PLUGIN_PROBE, nativeDoctorCheck, nativeMemoryCaptureRow, type MemorySetupReading, nativeDoctorRecoveryIdentity, nativeDoctorOptionalProbes } from "../adapters/registry.ts";
20
20
  import { parsePiFlags } from "../adapters/definitions/native-cli-pi.ts";
21
21
  import { supportsBoardReleaseFilter, supportsBoardColumnMetadata } from "./board-read.ts";
22
22
  import { matchesPolicy } from "../hub/path-policy.ts";
@@ -28,8 +28,20 @@ import { renderScheduleChange, renderScheduleList, renderScheduleRun } from "./s
28
28
  import { renderIntake, renderIntakeChange } from "./intake-output.ts";
29
29
  import { renderLearnChange, renderLearnList } from "./learn-output.ts";
30
30
  import { renderCapabilities } from "./capabilities-output.ts";
31
+ import { parseStudyArgs } from "./study.ts";
32
+ import { renderStudyList, renderStudyReceipt, renderStudyShow } from "./study-output.ts";
33
+ import { renderHandoffReceipt, renderHandoffs } from "./handoff-output.ts";
34
+ import { parseHandoffArgs } from "./handoff-args.ts";
31
35
  import { terminalText } from "./console-state.ts";
32
36
  import { parseSettingsEditArgs, runSettingsEdit, SETTINGS_EDIT_USAGE } from "./settings-edit.ts";
37
+ import { autonomyEnvironment, runAutonomy } from "./autonomy.ts";
38
+ import { readAutonomyDefaults, writeAutonomyDefaults } from "../hub/autonomy-defaults.ts";
39
+ import { inheritMachineAutonomy, prepareAutonomyProject, runAllProjectAutonomy, type PreparedAutonomyProject } from "./autonomy-projects.ts";
40
+ import { offlineAutonomyStatus } from "./autonomy-offline.ts";
41
+ import { autonomyHomeStatus, usedClaudeHomes } from "./autonomy-homes.ts";
42
+ import { discoverClaudeHomes } from "./claude-homes.ts";
43
+ import { setupClaudeHomes } from "./setup-homes.ts";
44
+ import { registerAconPlugin } from "./acon-register.ts";
33
45
  import { petView, readPet, renderPet } from "./pet.ts";
34
46
  import { startPetWindow, closePetWindow } from "../pet/window.ts";
35
47
  import { loadPetConfig, savePetPalette } from "./pet-config.ts";
@@ -44,7 +56,7 @@ import { parseSettingText, settingDef, settingText, undoableDef, type SettingRow
44
56
  import { startModeOf } from "../hub/start-mode.ts";
45
57
  import { currentRouting, OVERLAY_FILE, parseOverlay, parseRouting, routingText, type Routing, type RoutingOverlay } from "../hub/routing.ts";
46
58
  import { spawn, spawnSync } from "node:child_process";
47
- import { chmodSync, closeSync, constants, existsSync, fstatSync, openSync, readFileSync, readSync, renameSync, writeFileSync } from "node:fs";
59
+ import { chmodSync, closeSync, constants, existsSync, fstatSync, openSync, readFileSync, readSync, renameSync, statSync, writeFileSync } from "node:fs";
48
60
  import { randomUUID } from "node:crypto";
49
61
  import { homedir } from "node:os";
50
62
  import { basename, dirname, join, relative, resolve } from "node:path";
@@ -1006,47 +1018,48 @@ const commands: Record<string, () => Promise<void> | void> = {
1006
1018
  recoveryLauncherCurrent?.();
1007
1019
  assertLifecycleAvailable();
1008
1020
  const root = join(import.meta.dir, "..", "..");
1009
- const state = () => {
1010
- const out = (argv: string[]) => { recoveryLauncherCurrent?.(); const result = spawnSync("claude", argv, { encoding: "utf8", ...(recoveryLauncherCurrent ? { timeout: 30_000 } : {}) }); recoveryLauncherCurrent?.(); return result.stdout ?? ""; };
1011
- return nextStep(parseList<InstalledPlugin>(out(["plugin", "list", "--json"])), parseList<Marketplace>(out(["plugin", "marketplace", "list", "--json"])), root);
1012
- };
1013
- let step = state();
1014
- if (!step) console.log(`Claude Code plugin agent-hub@agent-hub ${VERSION} is installed from this package.`);
1015
- else {
1016
- console.log(`ahub setup changes your Claude Code plugin configuration, one step at a time, re-checking after each. First step:\n ${step.argv.join(" ")}\n ${step.why}`);
1017
- if (!args.includes("--yes")) {
1018
- // Nobody can answer without a terminal: waiting would hang a script forever (issue #73).
1019
- if (!process.stdin.isTTY) fail("not a terminal: nothing was changed; rerun with --yes to apply");
1020
- const rl = createInterface({ input: process.stdin, output: process.stdout });
1021
- const answer = (await rl.question("Proceed (this and the steps that follow from it)? [y/N] ")).trim().toLowerCase();
1022
- rl.close();
1023
- if (answer !== "y" && answer !== "yes") return console.log("nothing was changed");
1024
- }
1025
- for (let i = 0; step && i < 6; i++, step = state()) {
1026
- console.log(`> ${step.argv.join(" ")} (${step.why})`);
1027
- recoveryLauncherCurrent?.();
1028
- if (spawnSync(step.argv[0]!, step.argv.slice(1), { stdio: "inherit", ...(recoveryLauncherCurrent ? { timeout: 30_000 } : {}) }).status !== 0) fail(`"${step.argv.join(" ")}" failed; nothing after it was run`);
1029
- recoveryLauncherCurrent?.();
1030
- }
1031
- if (step) fail(`still not done after 6 steps; next would be: ${step.argv.join(" ")}`);
1032
- }
1021
+ const registry = new Registry(), rl = process.stdin.isTTY ? createInterface({ input: process.stdin, output: process.stdout }) : undefined;
1022
+ try {
1023
+ const found = recoveryLauncherCurrent || delegatedPeer ? await discoverClaudeHomes({ home: homedir(), env: process.env }) : await usedClaudeHomes(registry, homedir(), process.env);
1024
+ for (const notice of found.notices) console.log(terminalText(notice));
1025
+ await setupClaudeHomes({ homes: found.homes, env: process.env, packageRoot: root, yes: args.includes("--yes"),
1026
+ interactive: !!rl,
1027
+ current: () => { recoveryLauncherCurrent?.(); assertLifecycleAvailable(); },
1028
+ print: line => console.log(terminalText(line)), ask: async prompt => rl ? rl.question(prompt) : null,
1029
+ probe: (argv, options) => { recoveryLauncherCurrent?.(); const result = spawnSync(argv[0]!, argv.slice(1), { encoding: "utf8", env: options.env, timeout: options.timeoutMs, maxBuffer: 1024 * 1024 }); recoveryLauncherCurrent?.(); return { status: result.status ?? -1, stdout: result.stdout ?? "" }; },
1030
+ run: async (argv, env) => { recoveryLauncherCurrent?.(); const result = spawnSync(argv[0]!, argv.slice(1), { stdio: "inherit", env, timeout: 30_000 }); recoveryLauncherCurrent?.(); return result.status ?? -1; },
1031
+ });
1032
+ } finally { registry.close(); rl?.close(); }
1033
+ // #935: register the acon console plugin (acon reads it at its next start; no acon install is needed for this).
1034
+ const acon = registerAconPlugin(hubHome(), join(root, "src", "ui", "plugin.ts"));
1035
+ console.log("error" in acon ? `acon plugin not registered: ${acon.error}` : acon.changed ? `acon plugin registered: ${acon.path}` : `acon plugin up to date: ${acon.path}`);
1033
1036
  if (recoveryLauncherCurrent) recoveryLauncherCurrent();
1034
1037
  else await commands.doctor!();
1035
1038
  },
1036
1039
 
1037
- init: () => {
1040
+ init: async () => {
1038
1041
  const flags = takeFlags(args.filter(arg => arg !== "--dry-run" && arg !== "--json" && arg !== "--shadow" && arg !== "--release-guard"), ["--profile"], []);
1039
1042
  if (flags.rest.length) fail("usage: ahub init [--profile dev|research|writing|general|<pack>] [--shadow] [--release-guard] [--dry-run --json]");
1040
1043
  // A pack is a project file in .agenthub/roles/<name>.pack.json (#513a); init resolves the name and refuses an unknown one with the profiles and packs it knows.
1041
1044
  const requestedProfile = flags.one["--profile"];
1045
+ const newProject = !existsSync(join(cwd, ".agenthub/config.json"));
1042
1046
  if (args.includes("--dry-run")) {
1043
1047
  const plan = planInit(cwd, requestedProfile, args.includes("--shadow"), args.includes("--release-guard"));
1048
+ const defaults = readAutonomyDefaults(hubHome(), [cwd]);
1049
+ if (newProject && defaults.enabled) plan.push({ action: existsSync(join(cwd, ".agenthub/config.local.json")) ? "modify" : "create", path: join(cwd, ".agenthub/config.local.json"), reason: "inherit the trusted machine autonomy preset; consent is checked at daemon startup" });
1050
+ if (defaults.notice) console.error(defaults.notice);
1044
1051
  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");
1045
1052
  }
1046
1053
  assertLifecycleAvailable();
1054
+ if (newProject && delegatedPeer && readAutonomyDefaults(hubHome(), [cwd]).enabled) fail("machine autonomy inheritance is person-only; delegated live tests cannot apply it");
1047
1055
  const changed = init(cwd, requestedProfile, args.includes("--shadow"), args.includes("--release-guard"));
1048
- registeredProject();
1056
+ const project = registeredProject();
1049
1057
  console.log(changed.length ? changed.map((p) => `${existsSync(p) ? "wrote" : "removed"} ${p}`).join("\n") : "already up to date");
1058
+ if (newProject) {
1059
+ const registry = new Registry();
1060
+ try { for (const line of await inheritMachineAutonomy(project, hubHome(), autonomyEnvironment(process.env, !!process.stdin.isTTY), registry)) console.log(terminalText(line)); }
1061
+ finally { registry.close(); }
1062
+ }
1050
1063
  const config = projectConfig(), examples = resolveProfile(config.profile, config.profile_overrides).example_checks;
1051
1064
  if (examples.length) console.log(terminalText("Example checks (advisory):\n" + examples.map(example => "- " + example).join("\n")));
1052
1065
  },
@@ -1452,6 +1465,40 @@ const commands: Record<string, () => Promise<void> | void> = {
1452
1465
  return showToolText(data => renderScheduleChange(sub as "pause" | "resume", data, present.columns, Date.now()), await taskOp(sub === "pause" ? "schedule_pause" : "schedule_resume", { id }), present);
1453
1466
  },
1454
1467
 
1468
+ study: async () => {
1469
+ // #601: research campaigns. The hub keeps every campaign and decides every rule (Tasks.study*); this process parses argv, reads the contract file the person names and asks.
1470
+ // Person only for draft, revise, approve and close (a console control op the daemon refuses on any other connection, and `study` has no agent form in classifyPeerCommand besides the
1471
+ // read-only `show`); an agent drafts through the hub_study_* tools. `show` is a read: for a private campaign, task or source the hub sends ids, counts and states only.
1472
+ const split = splitPresentation(args, { edges: true });
1473
+ const present = outputOptions(split.flags); // before any request: an invalid --color must refuse before a mutation is sent
1474
+ const parsed = parseStudyArgs(split.operands);
1475
+ if ("usage" in parsed) return fail(parsed.usage);
1476
+ const contract = (file: string): unknown => {
1477
+ // One descriptor decides everything: opened non-blocking and without following a final symlink (a FIFO would otherwise block the open), checked to be a regular file with fstat, and
1478
+ // read for at most the cap plus one byte, so a file that grows or is replaced after the check, or a device that reports size zero, cannot exhaust memory or hang this process.
1479
+ const cap = 64 * 1024;
1480
+ let text: string;
1481
+ try {
1482
+ const fd = openSync(resolve(invokedFrom, file), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
1483
+ try {
1484
+ if (!fstatSync(fd).isFile()) return fail("the contract file is not a regular file");
1485
+ const bytes = Buffer.alloc(cap + 1); let got = 0;
1486
+ while (got < bytes.length) { const n = readSync(fd, bytes, got, bytes.length - got, null); if (!n) break; got += n; }
1487
+ if (got > cap) return fail("the contract file is over 64 KiB");
1488
+ text = bytes.subarray(0, got).toString("utf8");
1489
+ } finally { closeSync(fd); }
1490
+ }
1491
+ catch { return fail("cannot read the contract file"); }
1492
+ try { return JSON.parse(text); } catch { return fail("the contract file is not valid JSON"); }
1493
+ };
1494
+ if (parsed.op === "show") return showToolText(data => parsed.id ? renderStudyShow(data, present.columns) : renderStudyList(data, present.columns), await taskOp("hub_study_show", parsed.id ? { id: parsed.id } : parsed.after ? { after: parsed.after } : {}), present);
1495
+ const request = parsed.op === "draft" ? { parentTaskId: parsed.parentTaskId, contract: contract(parsed.file) }
1496
+ : parsed.op === "revise" ? { id: parsed.id, revision: parsed.revision, contract: contract(parsed.file) }
1497
+ : parsed.op === "approve_protocol" ? { hash: parsed.hash }
1498
+ : parsed.op === "approve" ? { id: parsed.id, revision: parsed.revision } : { id: parsed.id, revision: parsed.revision, reason: parsed.reason };
1499
+ return showToolText(data => renderStudyReceipt(parsed.op, data, present.columns), await taskOp(`study_${parsed.op}`, request, false, undefined, parsed.op === "approve" ? 90_000 : undefined), present);
1500
+ },
1501
+
1455
1502
  capabilities: async () => {
1456
1503
  // #445 C1: what each attached peer offers and which of it you share. Person only, like `learn`: `capabilities` has no entry in classifyPeerCommand, so an agent shell is refused before this runs and the daemon refuses any caller but the person again. It lists; it never calls anything.
1457
1504
  const split = splitPresentation(args);
@@ -1525,6 +1572,11 @@ const commands: Record<string, () => Promise<void> | void> = {
1525
1572
  if (!selection.label) fail("GitHub import requires --label");
1526
1573
  return console.log(await taskOp("github_import", selection));
1527
1574
  }
1575
+ if (args[0] === "--handoffs" || args.includes("--handoffs")) {
1576
+ // #604: what this project handed to other projects and the last state each reported (read-only).
1577
+ const options = outputOptions();
1578
+ return showToolText(data => renderHandoffs(data, options.columns), await taskOp("hub_task_handoffs", {}), options);
1579
+ }
1528
1580
  const options = outputOptions();
1529
1581
  const { one, rest } = takeFlags(args, ["--release", "--release-id"], []);
1530
1582
  if (one["--release"] !== undefined && !one["--release"].trim() || one["--release"] !== undefined && one["--release-id"] !== undefined || one["--release-id"] !== undefined && !/^[1-9]\d*$/.test(one["--release-id"])) fail("choose --release <nonempty name> or --release-id <positive id>");
@@ -1768,7 +1820,16 @@ const commands: Record<string, () => Promise<void> | void> = {
1768
1820
  appendRecords(project.id, [{ schema: RESEARCH_SCHEMA, kind: "label", project: projectKey(project.id), task: id, createdAt: record!.createdAt!, label, at: new Date().toISOString(), ...(identity.role === "tools" ? { by: identity.peer } : {}) }]);
1769
1821
  return console.log(`task #${id} (approved ${record!.approvedAt}) labelled ${label} in the research records`);
1770
1822
  }
1771
- if (sub !== "propose" || rest.length < 1) fail("usage: ahub task propose [<class>] <title...> | show <id> | assign <id> <peer> | unassign <id> | cancel <id> --reason <text> | escalate <id>");
1823
+ if (sub === "handoff") {
1824
+ // #604: work that belongs to ANOTHER project that runs agent-hub goes onto that project's board. The person's form (a console command: the classifier gives an agent `console` for it, and an
1825
+ // agent uses hub_task_handoff); the hub resolves the target, asks its daemon and records the answer. Nothing is queued: a target that is not running is named with the action.
1826
+ const present = outputOptions(splitPresentation(rest, { valueFlags: ["--project", "--path", "--issue", "--task", "--detail"], edges: true }).flags);
1827
+ const parsed = parseHandoffArgs(splitPresentation(rest, { valueFlags: ["--project", "--path", "--issue", "--task", "--detail"], edges: true }).operands);
1828
+ if ("usage" in parsed) return fail(parsed.usage);
1829
+ const text = await taskOp("hub_task_handoff", { ...parsed });
1830
+ return showToolText(data => renderHandoffReceipt(data, present.columns), text, present);
1831
+ }
1832
+ if (sub !== "propose" || rest.length < 1) fail("usage: ahub task propose [<class>] <title...> | show <id> | assign <id> <peer> | unassign <id> | cancel <id> --reason <text> | escalate <id> | handoff --project <root|id> <title...>");
1772
1833
  // `--class` is the explicit form. A first word that is a class name is still taken as the class (the documented
1773
1834
  // short form), but said out loud: "review the auth module" would otherwise be filed as class review, silently.
1774
1835
  const urgent = rest.includes("--urgent"), backlog = rest.includes("--backlog");
@@ -1890,6 +1951,68 @@ const commands: Record<string, () => Promise<void> | void> = {
1890
1951
  printOutput([[{ text: result.text }]], options.color);
1891
1952
  } finally { hub.close(); }
1892
1953
  },
1954
+ autonomy: async () => {
1955
+ let hub: ControlClient | undefined;
1956
+ let prepared: PreparedAutonomyProject | undefined, previewToken: string | undefined;
1957
+ const registry = new Registry();
1958
+ const rl = process.stdin.isTTY ? createInterface({ input: process.stdin, output: process.stdout }) : undefined;
1959
+ try {
1960
+ const environment = autonomyEnvironment(process.env, !!process.stdin.isTTY), home = hubHome();
1961
+ if (args.includes("--all-projects")) {
1962
+ assertLifecycleAvailable();
1963
+ await runAllProjectAutonomy(args, { home, environment, projects: registry.list(),
1964
+ prepare: (project, enabled) => prepareAutonomyProject(project, environment, enabled, registry),
1965
+ ask: async prompt => rl ? rl.question(prompt) : null, print: line => console.log(terminalText(line)),
1966
+ });
1967
+ return;
1968
+ }
1969
+ const roots = [...registry.list().map(project => project.root), cwd], defaults = readAutonomyDefaults(home, roots);
1970
+ const usage = await runAutonomy(args, {
1971
+ environment,
1972
+ request: async message => {
1973
+ if (message.action === "preview") {
1974
+ prepared?.close();
1975
+ prepared = await prepareAutonomyProject(registeredProject(), environment, true, registry);
1976
+ previewToken = randomUUID();
1977
+ return { ok: true, confirmation: previewToken, lines: prepared.lines };
1978
+ }
1979
+ if (message.action === "on") {
1980
+ if (!prepared || message.confirmation !== previewToken) return { ok: false, error: "autonomy preview is stale or missing" };
1981
+ previewToken = undefined;
1982
+ return { ok: true, lines: await prepared.apply() };
1983
+ }
1984
+ if (message.action === "off") {
1985
+ prepared = await prepareAutonomyProject(registeredProject(), environment, false, registry);
1986
+ return { ok: true, lines: await prepared.apply() };
1987
+ }
1988
+ const project = registry.list().find(row => row.root === cwd && row.stateDir === stateDir);
1989
+ if (project && (await inspectProject(project)).state === "stopped") return offlineAutonomyStatus(project, environment);
1990
+ hub ??= await connect(); return hub.request(message, 35_000);
1991
+ },
1992
+ ask: async prompt => { if (!rl) return null; try { return await rl.question(prompt); } catch { return null; } },
1993
+ print: line => console.log(terminalText(line)),
1994
+ contextLines: [...(defaults.notice ? [defaults.notice] : []), "On/off also changes this machine's autonomy default for new projects; --all-projects applies the change to every registered project."],
1995
+ confirmPrompt: "Apply this project's preset and this machine's autonomy default? [y/N] ",
1996
+ beforeApply: action => {
1997
+ assertLifecycleAvailable();
1998
+ if (action === "off" && defaults.fingerprint === "untrusted") console.log("Unsafe machine defaults are already ignored; their file was left unchanged.");
1999
+ else writeAutonomyDefaults(home, action === "on", defaults.fingerprint, roots);
2000
+ console.log(`Machine autonomy default: ${action}.`);
2001
+ },
2002
+ decorateStatus: async reply => {
2003
+ const machine = readAutonomyDefaults(home, roots);
2004
+ const profiles = await autonomyHomeStatus(registry, homedir(), process.env, join(import.meta.dir, "..", ".."), (argv, options) => {
2005
+ const result = spawnSync(argv[0]!, argv.slice(1), { encoding: "utf8", env: options.env, timeout: options.timeoutMs, maxBuffer: 1024 * 1024 });
2006
+ return { status: result.status ?? -1, stdout: result.stdout ?? "" };
2007
+ });
2008
+ return { ...reply, status: { ...(reply.status as Record<string, unknown>), machineDefault: { enabled: machine.enabled, ...(machine.notice ? { notice: machine.notice } : {}) }, claudeHomes: profiles.homes, claudeHomeDiscoveryComplete: profiles.complete, claudeHomeNotices: profiles.notices },
2009
+ lines: [...(reply.lines as string[]), `Machine autonomy default: ${machine.enabled ? "on" : "off"}.`, ...(machine.notice ? [machine.notice] : []), ...profiles.notices,
2010
+ ...profiles.homes.map(profile => `Claude home ${profile.home.path}: plugin ${profile.plugin.state}; hook ${profile.hook.state}. ${profile.hook.reason}`)] };
2011
+ },
2012
+ });
2013
+ if (usage) fail(usage);
2014
+ } finally { prepared?.close(); rl?.close(); hub?.close(); registry.close(); }
2015
+ },
1893
2016
  settings: async () => {
1894
2017
  // #689: structural config edits (instances, review, snapshots, approvals, context.gate, sdlc.toml) and their backups. The hub validates and writes.
1895
2018
  if (args[0] === "edit" || args[0] === "backups") {
@@ -1983,6 +2106,11 @@ const commands: Record<string, () => Promise<void> | void> = {
1983
2106
  if (typeof undoDef === "string" && settingScopeError) fail(`pending undo ${String(view.undo)}: ${undoDef}; the project config could not be read: ${settingScopeError}`);
1984
2107
  const rosterPeer = typeof undoDef !== "string" && undoDef.group === "Roster" ? undoDef.path[1] : undefined;
1985
2108
  let confirm: string | undefined;
2109
+ if (typeof undoDef !== "string" && undoDef.managed === "autonomy") {
2110
+ if (!process.stdin.isTTY) fail("undoing project autonomy requires a person's terminal confirmation");
2111
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
2112
+ try { confirm = (await rl.question("Type autonomy to undo the complete preset change: ")).trim(); } finally { rl.close(); }
2113
+ }
1986
2114
  if (rosterPeer) {
1987
2115
  if (!process.stdin.isTTY) fail("undoing a roster setting needs confirmation in a terminal; use ahub settings set for an explicit scripted change");
1988
2116
  const rl = createInterface({ input: process.stdin, output: process.stdout });
@@ -2586,11 +2714,11 @@ const commands: Record<string, () => Promise<void> | void> = {
2586
2714
  let memoryLibraryReady = false;
2587
2715
  try { assertAgentMemVersion({ name: "@staix/agent-mem", version: AGENT_MEM_VERSION }); memoryLibraryReady = true; } catch { /* report the failed exact pin without opening a store */ }
2588
2716
  row(memoryLibraryReady, "memory library", memoryLibraryReady ? `amem ${AGENT_MEM_VERSION}; in-process, no claude-mem worker` : "installed amem does not match the exact dependency pin");
2589
- const bridge = spawnSync("dot", ["ai", "memory", "status"], { encoding: "utf8" });
2590
- const out = bridge.error ? "" : bridge.stdout.replace(/\x1b\[[0-9;]*m/g, "");
2717
+ // #1008: memory capture is agent-mem's own setup state, read as structured data (`amem setup <tool> --json`, a preview that writes nothing), never dotfiles text. The caller's
2718
+ // identity is NOT scrubbed: agent-mem screens a plan for an agent, and a refusal reads as unknown here instead of being laundered into a person's answer.
2591
2719
  for (const tool of DOCTOR_MEMORY_CAPTURE_PEERS) {
2592
- const ready = new RegExp(`✓\\s+${tool} ready`).test(out);
2593
- row(bridge.error ? undefined : ready, `memory capture: ${tool}`, bridge.error ? "unknown (`dot ai memory status` not available)" : `${ready ? "ready" : "not ready"}, per ` + "`dot ai memory status`");
2720
+ const found = nativeMemoryCaptureRow(tool, readMemorySetup(tool));
2721
+ row(found.ok, `memory capture: ${tool}`, found.detail);
2594
2722
  }
2595
2723
  } finally {
2596
2724
  // Preserve findings already collected if a later probe (for example config parsing) throws.
@@ -2600,6 +2728,19 @@ const commands: Record<string, () => Promise<void> | void> = {
2600
2728
  },
2601
2729
  };
2602
2730
 
2731
+ /**
2732
+ * One bounded read of agent-mem's setup state for a tool: `amem setup <tool> --json` with no mode flag is a preview and writes nothing. No shell, a 10 s timeout, a 256 KiB output cap,
2733
+ * and the answer is parsed as JSON here and judged by `nativeMemoryCaptureRow`; anything that does not parse, exits non-zero or times out is "unavailable", never a guess.
2734
+ * ponytail: runs the `amem` found on PATH (the hub pins @staix/agent-mem exactly, but the person's own `amem` may be another version); the upgrade is to call `planSetup` from the pinned
2735
+ * library in-process once it is exported from its public entry.
2736
+ */
2737
+ function readMemorySetup(tool: string): MemorySetupReading {
2738
+ const found = spawnSync("amem", ["setup", tool, "--json"], { encoding: "utf8", timeout: 10_000, maxBuffer: 256 * 1024, stdio: ["ignore", "pipe", "pipe"] });
2739
+ if (found.error) return { kind: "unavailable", reason: (found.error as NodeJS.ErrnoException).code === "ENOENT" ? "`amem` is not on PATH" : "`amem setup` could not be run" };
2740
+ if (found.status !== 0 || typeof found.stdout !== "string") return { kind: "unavailable", reason: found.status === null ? "`amem setup` timed out" : `\`amem setup ${tool}\` refused or failed` };
2741
+ try { return { kind: "read", plan: JSON.parse(found.stdout) }; } catch { return { kind: "unavailable", reason: "`amem setup --json` did not print JSON" }; }
2742
+ }
2743
+
2603
2744
  function sanitizeRoutingParseError(file: "routing.toml" | "routing.local.toml", error: unknown): string {
2604
2745
  const raw = ((error as Error)?.message || "invalid TOML").split("\n")[0]!.trim();
2605
2746
  const cleaned = raw