@staix/agent-hub 0.24.0 → 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 (70) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +27 -5
  3. package/docs/agent-notes/adapters.md +1 -0
  4. package/docs/agent-notes/bus.md +1 -0
  5. package/docs/agent-notes/daemon.md +15 -2
  6. package/docs/agent-notes/tasks.md +9 -2
  7. package/docs/agent-notes/tests.md +2 -1
  8. package/docs/dogfood.md +12 -2
  9. package/docs/memory.md +1 -1
  10. package/docs/operations.md +137 -4
  11. package/docs/profiles.md +2 -0
  12. package/docs/quickstart.md +10 -3
  13. package/docs/security.md +155 -3
  14. package/docs/specs/2026-09-19-agent-hub-design.md +25 -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-11-01-0.25.0.md +91 -0
  18. package/docs/verified.json +31 -15
  19. package/package.json +4 -3
  20. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  21. package/plugins/agent-hub/server.js +5118 -4708
  22. package/src/adapters/claude-channel.ts +29 -6
  23. package/src/adapters/definitions/native-cli-doctor.ts +30 -0
  24. package/src/adapters/jcode.ts +3 -0
  25. package/src/adapters/registry.ts +1 -1
  26. package/src/cli/acon-register.ts +29 -0
  27. package/src/cli/handoff-args.ts +27 -0
  28. package/src/cli/handoff-output.ts +25 -0
  29. package/src/cli/help.ts +6 -3
  30. package/src/cli/identity.ts +7 -1
  31. package/src/cli/main.ts +76 -7
  32. package/src/cli/output.ts +8 -5
  33. package/src/cli/schedule-output.ts +8 -4
  34. package/src/cli/study-output.ts +71 -0
  35. package/src/cli/study.ts +58 -0
  36. package/src/cli/task-output.ts +1 -0
  37. package/src/hub/board.ts +871 -2
  38. package/src/hub/channel-project.ts +61 -0
  39. package/src/hub/control-client.ts +10 -1
  40. package/src/hub/daemon.ts +247 -11
  41. package/src/hub/envelope.ts +2 -0
  42. package/src/hub/events.ts +4 -1
  43. package/src/hub/execution-budget.ts +12 -0
  44. package/src/hub/handoff.ts +126 -0
  45. package/src/hub/hub-tools.ts +12 -2
  46. package/src/hub/manager.ts +64 -57
  47. package/src/hub/no-writes.ts +1 -1
  48. package/src/hub/path-policy.ts +12 -8
  49. package/src/hub/project.ts +14 -5
  50. package/src/hub/provenance.ts +30 -0
  51. package/src/hub/research-campaign.ts +265 -0
  52. package/src/hub/research-experiments.ts +445 -0
  53. package/src/hub/restart.ts +26 -0
  54. package/src/hub/schedules.ts +210 -9
  55. package/src/hub/session-identity.ts +49 -0
  56. package/src/hub/tasks.ts +894 -22
  57. package/src/hub/ui.ts +15 -6
  58. package/src/memory/capture.ts +2 -2
  59. package/src/ui/index.html +5 -3
  60. package/src/ui/office-space.ts +118 -0
  61. package/src/ui/plugin.ts +293 -0
  62. package/templates/AGENTS.block.md +1 -0
  63. package/templates/profiles/general.json +1 -1
  64. package/templates/profiles/research.json +4 -4
  65. package/templates/profiles/writing.json +1 -1
  66. package/templates/roles/research/analyst.md +1 -0
  67. package/templates/roles/research/fact-checker.md +1 -0
  68. package/templates/roles/research/research-lead.md +1 -0
  69. package/templates/roles/research/researcher.md +1 -0
  70. package/templates/roles/research/statistician.md +1 -0
@@ -12,7 +12,8 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
12
12
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
13
13
  import { readFileSync } from "node:fs";
14
14
  import { join } from "node:path";
15
- import { ControlClient, INVOCATION_CONTROL_TIMEOUT_MS, stateDirFor } from "../hub/control-client.ts";
15
+ import { ControlClient, INVOCATION_CONTROL_TIMEOUT_MS } from "../hub/control-client.ts";
16
+ import { channelProject } from "../hub/channel-project.ts";
16
17
  import { VERSION } from "../version.ts";
17
18
  import { IMPLEMENTER_HUB_TOOL_NAMES, IMPLEMENTER_PERMISSION_TOOL, ACTIONABLE_MESSAGE_INSTRUCTION, projectConductorTools, projectToolList, CONDUCTOR_TOOL_NAMES, DEFAULT_ROLES, roleContract, TASK_TOOL_NAMES, TASK_TOOLS, profileTaskTools } from "../hub/hub-tools.ts";
18
19
  import { frame, replyParent, sanitize, HUB_MESSAGE_INSTRUCTION, type Envelope } from "../hub/envelope.ts";
@@ -22,8 +23,23 @@ import { acceptsNativePermissionRequest, buildNativePermissionProbe, NATIVE_PERM
22
23
  // A native session is pinned at launch. Unlike a new CLI invocation after `cd`,
23
24
  // its MCP server must not silently move to another hub (including while offline).
24
25
  // ControlClient verifies the manifest identity on every connection.
25
- const stateDir = process.env.AGENTHUB_STATE_DIR ?? stateDirFor(process.cwd());
26
- const projectRoot = process.env.AGENTHUB_PROJECT_DIR ?? process.cwd();
26
+ //
27
+ // agent-hub#1036: which project this plugin joins is decided by `channelProject`, the same decision the CLI makes at startup. An inherited AGENTHUB_STATE_DIR is used as the peer only when the
28
+ // root recorded in it equals the resolved root of this process's cwd, or (a declared extra instance) when its managed worktree is this cwd. A session started in `<repo>/.worktrees/<name>`
29
+ // uses its own project. A session with no project of its own that inherited another project's state joins THAT hub as a distinct session (`<peer>~<id>`, minted by the hub): it can send and
30
+ // read, holds none of the peer's roles, and never replaces the attached session. Nothing is refused. Limit: this makes sessions distinguishable, it does not authenticate them.
31
+ const own = channelProject(process.cwd(), process.env, root => {
32
+ const read = (name: string) => { try { return JSON.parse(readFileSync(join(root, ".agenthub", name), "utf8")).roles ?? {}; } catch { return {}; } };
33
+ return { ...DEFAULT_ROLES, ...read("config.json"), ...read("config.local.json") };
34
+ });
35
+ /** Set when this process joins as a distinct session. The hub's answer names the minted id; until then the session is only "a session". */
36
+ const sessionJoin = own?.session;
37
+ /** A hub that predates `session_identity` would ignore the request and bind the real peer id, which is the takeover this exists to prevent: such a session does not connect, and says why. */
38
+ const NO_SESSION_SUPPORT = "this process has no project of its own and the hub it inherited predates session identities, so it would be taken for the peer it names; restart the hub (ahub restart) or start this session from its own project";
39
+ const NO_PROJECT = "agent-hub has no hub to join: this process has no project of its own and inherited no state directory. Start it from a project that runs agent-hub.";
40
+ if (process.env.AGENTHUB_STATE_DIR && own && !own.inherited && !own.session) console.error(`[agent-hub] ignoring an inherited AGENTHUB_STATE_DIR: it records another project, not ${own.root}. Using this project's own hub.`);
41
+ const stateDir = own?.stateDir ?? "";
42
+ const projectRoot = own?.root ?? "";
27
43
  /** tools mode: the same server, run by Kimi (ACP mcpServers), Codex (mcp_servers override) or Jcode. Their messages arrive through their own adapters, so no channel here. Not the "tools-only" Claude below. */
28
44
  const toolsMode = process.env.AGENTHUB_MODE === "tools";
29
45
  const permissionTool = toolsMode && process.env.AGENTHUB_PERMISSION_TOOL === "1";
@@ -111,6 +127,10 @@ const INBOX_CAP = 200;
111
127
  * whoever said hello last, so reconnecting blind would evict the session that just took it and the two
112
128
  * would trade the peer forever. This file is advisory: a standby hello also checks live ownership at the daemon.
113
129
  */
130
+ /** Whether the hub this process would join as a session knows `session_identity`, from the daemon's own status file. Unknown reads as unsupported. */
131
+ function sessionSupported(): boolean {
132
+ try { return (JSON.parse(readFileSync(join(stateDir, "status.json"), "utf8")).features ?? []).includes("session_identity"); } catch { return false; }
133
+ }
114
134
  function peerHeld(): boolean {
115
135
  try {
116
136
  const status = JSON.parse(readFileSync(join(stateDir, "status.json"), "utf8")) as { peers?: Record<string, { state?: string; claiming?: boolean; toolsOnly?: string }> };
@@ -266,7 +286,8 @@ async function connectLoop(): Promise<void> {
266
286
  try { executionCwd = realPath(process.cwd()); }
267
287
  catch { /* A removed native cwd revokes root evidence, never the messaging connection. */ }
268
288
  }
269
- const client = await ControlClient.connect(stateDir, { role: toolsMode ? "tools" : "peer", peer: peerId, ...(toolsMode ? {} : { channel }),
289
+ const client = await ControlClient.connect(stateDir, { role: toolsMode || sessionJoin ? "tools" : "peer", peer: peerId, ...(toolsMode || sessionJoin ? {} : { channel }),
290
+ ...(sessionJoin ? { session: sessionJoin.label } : {}),
270
291
  ...(process.env.AGENTHUB_LAUNCH_ID ? { launchId: process.env.AGENTHUB_LAUNCH_ID } : {}),
271
292
  ...(executionCwd !== undefined ? { cwd: executionCwd } : {}),
272
293
  ...(!toolsMode && standingBy ? { standby: true } : {}),
@@ -293,7 +314,7 @@ async function connectLoop(): Promise<void> {
293
314
  attempt = -1;
294
315
  standingBy = false;
295
316
  detached = undefined;
296
- log(`connected to hub as "${peerId}"`);
317
+ log(sessionJoin ? `connected to hub as the distinct session "${client.sessionId ?? "?"}", not as "${peerId}": it can send and read, and holds none of ${peerId}'s roles` : `connected to hub as "${peerId}"`);
297
318
  // The tool list depends on the connected hub (its lifecycle): when it differs from what the host last listed,
298
319
  // ask the host to list it again (#490).
299
320
  if (listedTools !== undefined) void reannounce(client).catch(() => {});
@@ -394,6 +415,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
394
415
  async function callTool(req: { params: { name: string; arguments?: Record<string, unknown> } }, signal?: AbortSignal) {
395
416
  const { name, arguments: args } = req.params;
396
417
  if (unidentified) return { ...text(UNIDENTIFIED), isError: true };
418
+ if (!own) return { ...text(NO_PROJECT), isError: true };
419
+ if (sessionJoin && !sessionSupported()) return { ...text(NO_SESSION_SUPPORT), isError: true };
397
420
  if (permissionTool && !IMPLEMENTER_HUB_TOOL_NAMES.includes(name)) return { ...text("tool unavailable to this owned implementer child"), isError: true };
398
421
  if (name === "hub_permission_request") {
399
422
  const deny = (message: string) => text(JSON.stringify({ behavior: "deny", message }));
@@ -473,4 +496,4 @@ await server.connect(new StdioServerTransport());
473
496
  // The host (Claude Code, Codex, Kimi) talks over stdin; once it is gone nothing can use this server, and a leftover would keep reconnecting.
474
497
  process.stdin.on("end", () => process.exit(0));
475
498
  process.stdin.on("close", () => process.exit(0));
476
- if (unidentified) log(UNIDENTIFIED); else void connectLoop();
499
+ if (unidentified) log(UNIDENTIFIED); else if (!own) log(NO_PROJECT); else if (sessionJoin && !sessionSupported()) log(NO_SESSION_SUPPORT); else void connectLoop();
@@ -8,6 +8,36 @@ const nativeTools = [
8
8
  ] as const;
9
9
  export const DOCTOR_TOOL_PROBES: readonly string[] = Object.freeze(["bun", ...nativeTools.filter(tool => tool.initial).map(tool => tool.id)]);
10
10
  export const DOCTOR_MEMORY_CAPTURE_PEERS = Object.freeze(["codex", "kimi"] as const);
11
+ /**
12
+ * What one read of agent-mem's own setup state gave (`amem setup <tool> --json`, a preview that writes nothing): the parsed plan, or why there is none. Agent-mem owns the state;
13
+ * doctor never reads dotfiles text or a client's config itself (#1008).
14
+ */
15
+ export type MemorySetupReading = { kind: "read"; plan: unknown } | { kind: "unavailable"; reason: string };
16
+
17
+ /**
18
+ * One memory-capture doctor row from agent-mem's structured setup plan: `applied` (the hub's receipt matches and the MCP entry and hooks are as setup would write them), `pending`
19
+ * (setup would add or replace something, so `amem setup <tool> --apply` is the fix), or `client missing` (the native CLI is not installed). A reading that cannot be trusted as the shape
20
+ * agent-mem documents is unknown, never ready: the row says it could not be read instead of guessing. Pure: the plan is data, never text, and nothing here runs a process.
21
+ * Codex also needs the person's native `/hooks` review before a newly registered hook runs, which agent-mem's setup does not grant, so an applied Codex row says so.
22
+ */
23
+ export function nativeMemoryCaptureRow(tool: string, reading: MemorySetupReading): { ok: boolean | undefined; detail: string } {
24
+ const unknown = (why: string) => ({ ok: undefined, detail: `unknown (${why})` });
25
+ if (reading.kind !== "read") return unknown(reading.reason);
26
+ const plan = reading.plan as { mode?: unknown; actions?: unknown } | null;
27
+ const actions = plan && typeof plan === "object" && plan.mode === "preview" && Array.isArray(plan.actions) ? plan.actions : undefined;
28
+ const action = actions?.length === 1 ? actions[0] as Record<string, unknown> | null : undefined;
29
+ if (!action || typeof action !== "object" || action.target !== tool || typeof action.available !== "boolean" || !["add", "replace", "restore", "unchanged"].includes(String(action.mcpChange)))
30
+ return unknown("`amem setup " + tool + " --json` did not return the setup plan it documents");
31
+ if (action.available === false) return { ok: false, detail: `client missing: the ${tool} CLI is not installed, so there is nothing to set up` };
32
+ const setup = `amem setup ${tool} --apply`;
33
+ if (action.mcpChange !== "unchanged") {
34
+ const state = typeof action.state === "string" ? action.state.slice(0, 60).replace(/[^\x20-\x7e]/g, "?") : "unknown";
35
+ return { ok: false, detail: `not set up (${state}); run \`${setup}\`` };
36
+ }
37
+ const hooks = tool === "codex" ? "; newly registered hooks need the native /hooks review in Codex before they run (amem setup does not grant hook trust)" : "";
38
+ return { ok: true, detail: `set up, per \`amem setup ${tool}\`${hooks}` };
39
+ }
40
+
11
41
  export const DOCTOR_PLUGIN_PROBE = Object.freeze({ command: "claude", argv: Object.freeze(["plugin", "list", "--json"] as const), name: "claude plugin" });
12
42
 
13
43
  /** Exact legacy section and severity classification, including non-native and unknown diagnostic names. */
@@ -210,6 +210,9 @@ export class JcodePeer extends AcpPeer {
210
210
  }
211
211
 
212
212
  /** True only for the id of this peer's current owned launch, while it is not ending: the proof a hub-tools hello must carry (#368). */
213
+ /** The id of this peer's current owned launch, for the provenance of what it publishes (agent-hub#1036). Evidence for the log, never a credential: nothing admits a connection by reading it. */
214
+ get ownedLaunchId(): string | undefined { return this.ending ? undefined : this.launchId; }
215
+
213
216
  ownsLaunch(launchId: unknown): boolean { return !this.ending && typeof launchId === "string" && launchId === this.launchId; }
214
217
 
215
218
  override async start(): Promise<void> {
@@ -292,7 +292,7 @@ export function builtinPreviewOf(id: string) {
292
292
  }
293
293
 
294
294
  /** Fixed builtin doctor metadata and pure observed-value projections; the CLI retains all probe effects. */
295
- export { DOCTOR_TOOL_PROBES, DOCTOR_MEMORY_CAPTURE_PEERS, DOCTOR_PLUGIN_PROBE, nativeDoctorCheck, nativeDoctorRecoveryIdentity, nativeDoctorOptionalProbes } from "./definitions/native-cli-doctor.ts";
295
+ export { DOCTOR_TOOL_PROBES, DOCTOR_MEMORY_CAPTURE_PEERS, DOCTOR_PLUGIN_PROBE, nativeDoctorCheck, nativeMemoryCaptureRow, type MemorySetupReading, nativeDoctorRecoveryIdentity, nativeDoctorOptionalProbes } from "./definitions/native-cli-doctor.ts";
296
296
 
297
297
  /** Definition-owned CLI entries are restricted to fixed builtins, never config or external execution extensions. */
298
298
  export const BUILTIN_CLI_ENTRIES = Object.freeze(builtinCliEntries(ADAPTERS).map(entry => {
@@ -0,0 +1,29 @@
1
+ import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { createHash } from "node:crypto";
3
+ import { join } from "node:path";
4
+
5
+ /** Where `ahub setup` registers the acon plugin (#935): acon reads $AGENTHUB_HOME/console/plugins/<tool>.json. */
6
+ export function aconManifestPath(home: string): string {
7
+ return join(home, "console", "plugins", "ahub.json");
8
+ }
9
+
10
+ /**
11
+ * Write the acon registration for this package's plugin module with its sha256, so acon refuses a module that changed
12
+ * after setup. The directory is created 0700; the file is replaced atomically. Returns what was written, or why not.
13
+ */
14
+ export function registerAconPlugin(home: string, module: string): { path: string; changed: boolean } | { error: string } {
15
+ if (!existsSync(module)) return { error: `acon plugin module ${module} is missing` };
16
+ const dir = join(home, "console", "plugins");
17
+ for (const part of [join(home, "console"), dir]) {
18
+ if (!existsSync(part)) mkdirSync(part, { mode: 0o700 });
19
+ if (!lstatSync(part).isDirectory()) return { error: `${part} is not a directory` };
20
+ chmodSync(part, 0o700);
21
+ }
22
+ const path = aconManifestPath(home);
23
+ const body = `${JSON.stringify({ api: 1, tool: "ahub", module, sha256: createHash("sha256").update(readFileSync(module)).digest("hex") })}\n`;
24
+ if (existsSync(path) && readFileSync(path, "utf8") === body) return { path, changed: false };
25
+ const temp = `${path}.${process.pid}.tmp`;
26
+ writeFileSync(temp, body, { mode: 0o600 });
27
+ renameSync(temp, path);
28
+ return { path, changed: true };
29
+ }
@@ -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,7 +10,7 @@ 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 channel plugin and project-neutral hook in Claude configuration homes in use, preserving unrelated profile settings, then run doctor"],
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
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"],
@@ -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)"],
@@ -86,8 +86,9 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
86
86
  ]],
87
87
  ["Tasks and review", [
88
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"],
89
- ["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"],
90
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)"],
91
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)"],
92
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"],
93
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"],
@@ -105,6 +106,8 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
105
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"],
106
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"],
107
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"],
108
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"],
109
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)"],
110
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,6 +22,8 @@ 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"
@@ -82,6 +84,8 @@ const peerReadForms: Readonly<Record<string, Readonly<Record<string, PeerReadFor
82
84
  // #814 renders these reads for people and keeps JSON as an explicit data format.
83
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 } },
84
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 } },
85
89
  projects: { "": {} },
86
90
  models: { status: {} },
87
91
  recovery: { status: { positional: 1 } },
@@ -191,8 +195,10 @@ export function cliCommandLabel(command: string, args: readonly string[], catalo
191
195
  if (command === "capabilities") return "capabilities"; // #445 C1: no argument is ever echoed into the audit
192
196
  if (command === "learn") return ["list", "accept", "reject"].includes(args.find(arg => arg !== "--json") ?? "") ? `learn ${args.find(arg => arg !== "--json")}` : "learn";
193
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";
194
200
  if (command === "release") return ["new", "list", "show", "plan", "activate", "freeze", "ship", "drop"].includes(args[0] ?? "") ? `release ${args[0]}` : "release";
195
- 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";
196
202
  if (["task", "queue", "budget", "route", "recovery", "models", "projects", "research", "memory", "goal", "bench", "agents", "roster", "settings"].includes(command)) {
197
203
  const sub = command === "bench" || command === "memory" || command === "goal" ? args.find((arg) => arg !== "--json") : args[0];
198
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,6 +28,10 @@ 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";
33
37
  import { autonomyEnvironment, runAutonomy } from "./autonomy.ts";
@@ -37,6 +41,7 @@ import { offlineAutonomyStatus } from "./autonomy-offline.ts";
37
41
  import { autonomyHomeStatus, usedClaudeHomes } from "./autonomy-homes.ts";
38
42
  import { discoverClaudeHomes } from "./claude-homes.ts";
39
43
  import { setupClaudeHomes } from "./setup-homes.ts";
44
+ import { registerAconPlugin } from "./acon-register.ts";
40
45
  import { petView, readPet, renderPet } from "./pet.ts";
41
46
  import { startPetWindow, closePetWindow } from "../pet/window.ts";
42
47
  import { loadPetConfig, savePetPalette } from "./pet-config.ts";
@@ -51,7 +56,7 @@ import { parseSettingText, settingDef, settingText, undoableDef, type SettingRow
51
56
  import { startModeOf } from "../hub/start-mode.ts";
52
57
  import { currentRouting, OVERLAY_FILE, parseOverlay, parseRouting, routingText, type Routing, type RoutingOverlay } from "../hub/routing.ts";
53
58
  import { spawn, spawnSync } from "node:child_process";
54
- 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";
55
60
  import { randomUUID } from "node:crypto";
56
61
  import { homedir } from "node:os";
57
62
  import { basename, dirname, join, relative, resolve } from "node:path";
@@ -1025,6 +1030,9 @@ const commands: Record<string, () => Promise<void> | void> = {
1025
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; },
1026
1031
  });
1027
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}`);
1028
1036
  if (recoveryLauncherCurrent) recoveryLauncherCurrent();
1029
1037
  else await commands.doctor!();
1030
1038
  },
@@ -1457,6 +1465,40 @@ const commands: Record<string, () => Promise<void> | void> = {
1457
1465
  return showToolText(data => renderScheduleChange(sub as "pause" | "resume", data, present.columns, Date.now()), await taskOp(sub === "pause" ? "schedule_pause" : "schedule_resume", { id }), present);
1458
1466
  },
1459
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
+
1460
1502
  capabilities: async () => {
1461
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.
1462
1504
  const split = splitPresentation(args);
@@ -1530,6 +1572,11 @@ const commands: Record<string, () => Promise<void> | void> = {
1530
1572
  if (!selection.label) fail("GitHub import requires --label");
1531
1573
  return console.log(await taskOp("github_import", selection));
1532
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
+ }
1533
1580
  const options = outputOptions();
1534
1581
  const { one, rest } = takeFlags(args, ["--release", "--release-id"], []);
1535
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>");
@@ -1773,7 +1820,16 @@ const commands: Record<string, () => Promise<void> | void> = {
1773
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 } : {}) }]);
1774
1821
  return console.log(`task #${id} (approved ${record!.approvedAt}) labelled ${label} in the research records`);
1775
1822
  }
1776
- 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...>");
1777
1833
  // `--class` is the explicit form. A first word that is a class name is still taken as the class (the documented
1778
1834
  // short form), but said out loud: "review the auth module" would otherwise be filed as class review, silently.
1779
1835
  const urgent = rest.includes("--urgent"), backlog = rest.includes("--backlog");
@@ -2658,11 +2714,11 @@ const commands: Record<string, () => Promise<void> | void> = {
2658
2714
  let memoryLibraryReady = false;
2659
2715
  try { assertAgentMemVersion({ name: "@staix/agent-mem", version: AGENT_MEM_VERSION }); memoryLibraryReady = true; } catch { /* report the failed exact pin without opening a store */ }
2660
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");
2661
- const bridge = spawnSync("dot", ["ai", "memory", "status"], { encoding: "utf8" });
2662
- 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.
2663
2719
  for (const tool of DOCTOR_MEMORY_CAPTURE_PEERS) {
2664
- const ready = new RegExp(`✓\\s+${tool} ready`).test(out);
2665
- 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);
2666
2722
  }
2667
2723
  } finally {
2668
2724
  // Preserve findings already collected if a later probe (for example config parsing) throws.
@@ -2672,6 +2728,19 @@ const commands: Record<string, () => Promise<void> | void> = {
2672
2728
  },
2673
2729
  };
2674
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
+
2675
2744
  function sanitizeRoutingParseError(file: "routing.toml" | "routing.local.toml", error: unknown): string {
2676
2745
  const raw = ((error as Error)?.message || "invalid TOML").split("\n")[0]!.trim();
2677
2746
  const cleaned = raw
package/src/cli/output.ts CHANGED
@@ -7,6 +7,7 @@ import { cells, TABLES, table, fitLine, wrap, duration, relative, plural, termin
7
7
  import { taskProgress } from "../ui/task-progress.ts";
8
8
  import { backendLabel } from "./status-lines.ts";
9
9
  import { ownerIsolationText } from "./role-holders.ts";
10
+ import { schedulePeriodText, scheduleWhen } from "../hub/schedules.ts";
10
11
  import { formatReport, formatTaskReport, type Report, type TaskReport } from "../hub/report.ts";
11
12
  import type { Counter, LimitsResult, TotalResult, UsageResult } from "../hub/usage-rollup.ts";
12
13
 
@@ -20,7 +21,7 @@ export interface StatusOutput {
20
21
  /** #843 AC3: installed agent CLIs not on a verified version; ids, version, state and a receipt path only. */
21
22
  agentDrift?: readonly { id: string; version: string; state: string; receipt: string }[];
22
23
  /** #512a: one row per machine-local schedule; ids, times and a state only, never task text. */
23
- schedules?: readonly { id: string; every: string; class: string; queue: string; next: number; last?: number; task?: number; state: string; coalesced?: number; error?: string }[];
24
+ schedules?: readonly { id: string; every?: string; cron?: string; zone?: string; class: string; queue: string; next: number; last?: number; task?: number; state: string; coalesced?: number; error?: string }[];
24
25
  taskStalls?: readonly { task: number; cause: { kind: string; peer?: string; dependency?: number; code: string } }[];
25
26
  pendingTaskOffers?: readonly { task: number; peer: string }[];
26
27
  isolatedChecks?: { pending?: number; orphans?: string[]; unknownReceipts?: readonly string[]; maintenanceUnavailable?: boolean };
@@ -218,8 +219,8 @@ export function renderStatus(data: StatusOutput, columns?: number, now = Date.no
218
219
  for (const pending of data.retainedConfig) out.push(...detail("retained reason", `${pending.key}: ${pending.reason}`, columns, "attention"));
219
220
  }
220
221
  for (const row of data.schedules ?? []) {
221
- const when = (at: number) => new Date(at).toISOString().replace("T", " ").slice(0, 16) + "Z";
222
- const text = `${row.state === "paused" ? `paused (ahub schedule resume ${row.id}); would be next ${when(row.next)}` : row.state === "scheduled" ? `next ${when(row.next)}` : row.state === "due" ? "due now" : row.state === "waiting" ? `due, waiting for task #${row.task} to close` : "due, held (the hub is stopping or recovering)"}; every ${row.every}; ${row.class} to ${row.queue}${row.last ? `; last ${when(row.last)}${row.task ? ` as task #${row.task}` : ""}` : "; never run"}${row.coalesced ? `; ${row.coalesced} skipped run${row.coalesced === 1 ? "" : "s"} coalesced` : ""}${row.error ? `; last error: ${row.error}` : ""}`;
222
+ const when = (at: number) => row.zone ? scheduleWhen(row, at) : new Date(at).toISOString().replace("T", " ").slice(0, 16) + "Z";
223
+ const text = `${row.state === "paused" ? `paused (ahub schedule resume ${row.id}); would be next ${when(row.next)}` : row.state === "scheduled" ? `next ${when(row.next)}` : row.state === "due" ? "due now" : row.state === "waiting" ? `due, waiting for task #${row.task} to close` : "due, held (the hub is stopping or recovering)"}; ${schedulePeriodText(row)}; ${row.class} to ${row.queue}${row.last ? `; last ${when(row.last)}${row.task ? ` as task #${row.task}` : ""}` : "; never run"}${row.coalesced ? `; ${row.coalesced} skipped run${row.coalesced === 1 ? "" : "s"} coalesced` : ""}${row.error ? `; last error: ${row.error}` : ""}`;
223
224
  out.push(...detail(`schedule ${row.id}`, text, columns, row.error ? "attention" : undefined));
224
225
  }
225
226
  if (data.queueBackpressure) {
@@ -347,6 +348,8 @@ export function renderBacklog(cards: any[], columns?: number, now = Date.now(),
347
348
  return [...rows(names, table, columns), [], ...lines(summary, columns)];
348
349
  }
349
350
  /** #724: one line per task. TITLE goes last and is cut to the room left; at a narrow width REVIEWER, CLASS and AGE give way first. */
351
+ /** #604: a task another project handed over says so on the board: the project id and the peer, both ids and nothing a handoff wrote. */
352
+ const handoffMark = (task: any): string => task?.handoffOrigin ? ` (handed off from ${String(task.handoffOrigin.projectId).slice(0, 24)} by ${String(task.handoffOrigin.peer).slice(0, 32)})` : "";
350
353
  function compactBoard(tasks: any[], data: OutputCell[][], columns: number, staged: boolean): Span[][] {
351
354
  const head = ["ID", "STATE", "OWNER", "REVIEWER", "CLASS", "AGE", "AFTER", ...(staged ? ["STAGE"] : []), "TITLE"];
352
355
  let table = tasks.map((task, i) => {
@@ -355,7 +358,7 @@ function compactBoard(tasks: any[], data: OutputCell[][], columns: number, stage
355
358
  const meter = /^\[[^\]]*\](?: cancelled)?/.exec(row[7]?.text ?? "")?.[0] ?? "[----]";
356
359
  const stage = task.lifecycle ? `${meter} ${task.lifecycle.stage}: ${stageCode(String(task.lifecycle.next ?? ""))}` : meter;
357
360
  const short = task.lifecycle ? `${meter} ${stageCode(String(task.lifecycle.next ?? ""))}` : meter;
358
- return [row[0]!, row[1]!, row[2]!, row[3]!, row[4]!, row[5]!, span(after, undefined, true), ...(staged ? [{ ...span(stage, row[7]!.tone, true), short }] : []), span(flat(task.title))];
361
+ return [row[0]!, row[1]!, row[2]!, row[3]!, row[4]!, row[5]!, span(after, undefined, true), ...(staged ? [{ ...span(stage, row[7]!.tone, true), short }] : []), span(`${flat(task.title)}${handoffMark(task)}`)];
359
362
  });
360
363
  let names = [...head];
361
364
  const drop = (name: string) => { const at = names.indexOf(name); if (at < 0) return; names = names.filter((_, i) => i !== at); table = table.map(row => row.filter((_, i) => i !== at)); };
@@ -389,7 +392,7 @@ export function renderBoard(tasks: any[], columns?: number, now = Date.now(), fu
389
392
  const row = cells(s, task, false, now, stages);
390
393
  row[2] = span(row[2]!.text, peerTone(task.owner ?? ""));
391
394
  row[3] = span(row[3]!.text, peerTone(task.reviewer ?? ""));
392
- row[6] = span(`${task.title}${task.deps?.length ? ` after ${task.deps.map((id: number) => `#${id}`).join(", ")}` : ""}${task.signals?.includes("pii") ? ` (ahub task show ${task.id})` : ""}`);
395
+ row[6] = span(`${task.title}${handoffMark(task)}${task.deps?.length ? ` after ${task.deps.map((id: number) => `#${id}`).join(", ")}` : ""}${task.signals?.includes("pii") ? ` (ahub task show ${task.id})` : ""}`);
393
396
  if (allTasks === null) row.pop();
394
397
  return row;
395
398
  });
@@ -3,22 +3,26 @@
3
3
  import { blank, grid, heading, pair, s } from "./sdlc-output.ts";
4
4
  import { moment } from "./sdlc-output.ts";
5
5
  import type { Span, Tone } from "./console-state.ts";
6
+ import { schedulePeriodText, scheduleWhen } from "../hub/schedules.ts";
6
7
 
7
8
  const list = (value: unknown): any[] => Array.isArray(value) ? value : [];
8
9
  const TONE: Record<string, Tone | undefined> = { due: "attention", waiting: "attention", held: "attention", paused: "muted", scheduled: undefined };
9
10
  const WHEN: Record<string, string> = { scheduled: "next", due: "due now", waiting: "due, waiting for its task to close", held: "due, held (the hub is stopping or recovering)", paused: "paused" };
10
11
 
11
12
  /** The next run, as the person reads it: a paused schedule says so and what it would have been. */
12
- export const nextText = (row: any, now: number): string =>
13
- row?.state === "paused" ? `paused (would be ${moment(row.next, now)})` : row?.state === "scheduled" ? moment(row.next, now) : WHEN[row?.state] ?? "unknown";
13
+ export const nextText = (row: any, now: number): string => {
14
+ // A calendar schedule reads its next run in its own zone, with the relative time after it; an `every` schedule keeps the UTC form.
15
+ const at = (instant: unknown) => row?.zone && typeof instant === "number" && Number.isFinite(instant) ? `${scheduleWhen(row, instant)} (${moment(instant, now).replace(/^.*?\((.*)\)$/, "$1")})` : moment(instant, now);
16
+ return row?.state === "paused" ? `paused (would be ${at(row.next)})` : row?.state === "scheduled" ? at(row.next) : WHEN[row?.state] ?? "unknown";
17
+ };
14
18
 
15
19
  /** `ahub schedule list`: one row per configured schedule with its next run. */
16
20
  export function renderScheduleList(rows: unknown, columns?: number, now = Date.now()): Span[][] {
17
21
  const schedules = list(rows);
18
22
  if (!schedules.length) return [[s("no schedules configured; add them under `schedules` in .agenthub/config.local.json", "muted")]];
19
23
  const out: Span[][] = [...heading(`Schedules (${schedules.length})`, columns)];
20
- out.push(...grid(["ID", "EVERY", "CLASS", "QUEUE", "STATE", "NEXT RUN", "LAST RUN", "TASK"], schedules.map((row: any) => [
21
- s(row.id, "issueRef"), s(row.every), s(row.class), s(row.queue), s(row.state, TONE[row.state]), s(nextText(row, now), TONE[row.state]),
24
+ out.push(...grid(["ID", "SCHEDULE", "CLASS", "QUEUE", "STATE", "NEXT RUN", "LAST RUN", "TASK"], schedules.map((row: any) => [
25
+ s(row.id, "issueRef"), s(row.cron !== undefined ? schedulePeriodText(row) : String(row.every)), s(row.class), s(row.queue), s(row.state, TONE[row.state]), s(nextText(row, now), TONE[row.state]),
22
26
  s(row.last ? moment(row.last, now) : "never", "muted"), s(row.task === undefined ? "-" : `#${row.task}`, "taskRef"),
23
27
  ]), columns));
24
28
  for (const row of schedules) {