@staix/agent-hub 0.18.0 → 0.20.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 (90) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +4 -4
  3. package/docs/agent-notes/README.md +6 -0
  4. package/docs/agent-notes/adapters.md +13 -10
  5. package/docs/agent-notes/bus.md +1 -1
  6. package/docs/agent-notes/daemon.md +11 -4
  7. package/docs/agent-notes/local-worker.md +3 -1
  8. package/docs/agent-notes/tasks.md +6 -1
  9. package/docs/agent-notes/tests.md +8 -4
  10. package/docs/dogfood.md +13 -4
  11. package/docs/memory.md +1 -1
  12. package/docs/operations.md +98 -28
  13. package/docs/quickstart.md +13 -5
  14. package/docs/sdlc.md +12 -0
  15. package/docs/security.md +24 -15
  16. package/docs/smoke.md +3 -3
  17. package/docs/specs/2026-09-19-agent-hub-design.md +101 -8
  18. package/docs/verification/2026-10-25-remote-docker-transport.md +10 -0
  19. package/docs/verification/2026-10-26-agy-1.4.7.md +69 -0
  20. package/docs/verification/2026-10-26-claude-implementer-2.1.310.md +69 -0
  21. package/docs/verification/2026-10-26-headless-claude-shell-context.md +62 -0
  22. package/docs/verification/2026-10-26-jcode-0.99.1.md +49 -0
  23. package/docs/verification/2026-10-26-kimi-232-reviewer-compat.md +53 -0
  24. package/docs/verification/2026-10-26-pi-140-compat.md +41 -0
  25. package/docs/verification/2026-10-26-qwen-0261-acp.md +60 -0
  26. package/docs/verification/2026-10-27-0.19.0.md +86 -0
  27. package/docs/verification/2026-10-27-0.20.0.md +75 -0
  28. package/docs/verification/claude-permission-transport-native.py +187 -0
  29. package/docs/verification/claude-shell-wrapper-host.ts +20 -0
  30. package/docs/verification/claude-shell-wrapper-native.py +145 -0
  31. package/docs/verification/pi-140-model-free.ts +57 -0
  32. package/docs/verification/pi-140-native.mjs +21 -0
  33. package/docs/verified.json +25 -17
  34. package/package.json +2 -2
  35. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  36. package/plugins/agent-hub/server.js +373 -72
  37. package/src/adapters/acp.ts +8 -0
  38. package/src/adapters/agy/entry.ts +1 -1
  39. package/src/adapters/channel-peer.ts +7 -5
  40. package/src/adapters/claude-channel.ts +7 -3
  41. package/src/adapters/claude-implementer.ts +6 -3
  42. package/src/adapters/codex-appserver.ts +11 -1
  43. package/src/adapters/compat.ts +32 -18
  44. package/src/adapters/conformance/enrollment.ts +4 -4
  45. package/src/adapters/conformance/factories.ts +2 -1
  46. package/src/adapters/conformance/fakes/pi-rpc.ts +1 -0
  47. package/src/adapters/conformance/kit.ts +10 -0
  48. package/src/adapters/definitions/builtin-metadata.ts +2 -2
  49. package/src/adapters/definitions/native-factories.ts +7 -2
  50. package/src/adapters/definitions/reviewer-variants.ts +2 -2
  51. package/src/adapters/local-worker.ts +2 -0
  52. package/src/adapters/pi.ts +46 -3
  53. package/src/adapters/qwen-dual.ts +25 -0
  54. package/src/adapters/registry-types.ts +6 -2
  55. package/src/adapters/registry.ts +1 -0
  56. package/src/adapters/reviewer-print.ts +1 -0
  57. package/src/cli/agents.ts +69 -7
  58. package/src/cli/claude-shell-wrapper.ts +54 -0
  59. package/src/cli/main.ts +30 -24
  60. package/src/cli/output.ts +4 -0
  61. package/src/hub/agent-drift.ts +61 -0
  62. package/src/hub/board.ts +14 -2
  63. package/src/hub/check-runner.ts +148 -18
  64. package/src/hub/ci-watch.ts +140 -0
  65. package/src/hub/claude-launch-proof.ts +67 -2
  66. package/src/hub/claude-shell-wrapper.ts +349 -0
  67. package/src/hub/config-apply.ts +2 -2
  68. package/src/hub/config-edit.ts +2 -2
  69. package/src/hub/config-tier.ts +2 -2
  70. package/src/hub/config-trust.ts +1 -0
  71. package/src/hub/daemon.ts +282 -113
  72. package/src/hub/host-executable.ts +20 -1
  73. package/src/hub/instances.ts +18 -2
  74. package/src/hub/lifecycle.ts +1 -1
  75. package/src/hub/operational-authority.ts +7 -0
  76. package/src/hub/operational-view.ts +6 -10
  77. package/src/hub/peers.ts +34 -0
  78. package/src/hub/read-only-shell-proof.ts +7 -6
  79. package/src/hub/review-scope.ts +13 -0
  80. package/src/hub/sdlc-metrics.ts +6 -4
  81. package/src/hub/settings.ts +1 -0
  82. package/src/hub/task-shell-command.ts +85 -0
  83. package/src/hub/task-shell-default.ts +79 -0
  84. package/src/hub/task-shell-proof.ts +171 -0
  85. package/src/hub/task-shell-sandbox.ts +61 -0
  86. package/src/hub/tasks.ts +274 -16
  87. package/src/local/deny.ts +112 -11
  88. package/src/local/sandbox.ts +70 -29
  89. package/src/local/shell-environment.ts +10 -0
  90. package/src/ui/index.html +1 -0
package/src/cli/agents.ts CHANGED
@@ -4,19 +4,81 @@ import { assessVersion, COMPAT, describe, isCompatId, readTrust, trustFile, writ
4
4
  /** #691: what `ahub agents` and `ahub doctor` show for each gated native CLI. */
5
5
  export interface AgentVersionRow { id: CompatId; executable?: string; verdict: CompatVerdict; policy: string }
6
6
 
7
- /** `commands` carries the configured argv (codex_bin, jcode_cmd, qwen_cmd); the probe words follow it. */
7
+ const PROBE_ENV = { JCODE_NO_TELEMETRY: "1", JCODE_DISABLE_CLAUDE_MCP: "1" };
8
+
9
+ /** The one place that decides what is run for an id: the configured argv (including pi.cmd) plus the probe words. */
10
+ function probeTarget(id: CompatId, commands: Partial<Record<CompatId, readonly string[]>>): { executable: string; args: string[] } | undefined {
11
+ const [defaultBin, ...words] = COMPAT[id].probe;
12
+ const [bin = defaultBin, ...prefix] = commands[id] ?? [defaultBin];
13
+ const executable = Bun.which(bin) ?? undefined;
14
+ return executable ? { executable, args: [...prefix, ...words] } : undefined;
15
+ }
16
+
17
+ function probeRow(id: CompatId, executable: string | undefined, stdout: string | undefined, trusted: ReturnType<typeof readTrust>): AgentVersionRow {
18
+ if (!executable) return { id, verdict: { ok: false, state: "unknown", reason: `${COMPAT[id].label} not found on PATH` }, policy: describe(id) };
19
+ return { id, executable, verdict: assessVersion(id, stdout === undefined ? undefined : COMPAT[id].parse(stdout), trusted), policy: describe(id) };
20
+ }
21
+
22
+ /** `commands` carries the configured argv (including pi.cmd); the probe words follow it. */
8
23
  export function agentVersions(commands: Partial<Record<CompatId, readonly string[]>> = {}): AgentVersionRow[] {
9
24
  const trusted = readTrust();
10
25
  return (Object.keys(COMPAT) as CompatId[]).map(id => {
11
- const [defaultBin, ...words] = COMPAT[id].probe;
12
- const [bin = defaultBin, ...prefix] = commands[id] ?? [defaultBin];
13
- const executable = Bun.which(bin) ?? undefined;
14
- if (!executable) return { id, verdict: { ok: false, state: "unknown", reason: `${COMPAT[id].label} not found on PATH` }, policy: describe(id) };
15
- const result = spawnSync(executable, [...prefix, ...words], { encoding: "utf8", timeout: 10_000, maxBuffer: 64 * 1024, env: { ...process.env, JCODE_NO_TELEMETRY: "1", JCODE_DISABLE_CLAUDE_MCP: "1" } });
16
- return { id, executable, verdict: assessVersion(id, result.status === 0 ? COMPAT[id].parse(result.stdout ?? "") : undefined, trusted), policy: describe(id) };
26
+ const target = probeTarget(id, commands);
27
+ if (!target) return probeRow(id, undefined, undefined, trusted);
28
+ const result = spawnSync(target.executable, target.args, { encoding: "utf8", timeout: 10_000, maxBuffer: 64 * 1024, env: { ...process.env, ...PROBE_ENV } });
29
+ return probeRow(id, target.executable, result.status === 0 ? result.stdout ?? "" : undefined, trusted);
17
30
  });
18
31
  }
19
32
 
33
+ const PROBE_OUTPUT_CAP = 64 * 1024;
34
+ /** After the probe process ends, how long a descendant that kept the pipe open may still be read before the reader is cancelled. */
35
+ const PROBE_EXIT_GRACE_MS = 250;
36
+
37
+ /**
38
+ * One bounded probe run. Output is read as a stream and counted as it arrives: past the cap the probe is killed and the
39
+ * reader cancelled, so a chatty or endless CLI never accumulates in memory. A deadline kills the probe and cancels the
40
+ * reader, and a descendant that inherited stdout cannot hold the reader past the grace after the probe's own exit.
41
+ * ponytail: only the probe process is killed, not its descendants; a stray one ends on its own or when its pipe closes.
42
+ * The upgrade is a process group via child_process (as `jcodeVersionAsync` does).
43
+ */
44
+ async function runProbe(executable: string, args: readonly string[], timeoutMs: number, graceMs = PROBE_EXIT_GRACE_MS): Promise<string | undefined> {
45
+ const child = Bun.spawn([executable, ...args], { stdout: "pipe", stderr: "ignore", stdin: "ignore", env: { ...process.env, ...PROBE_ENV }, timeout: timeoutMs, killSignal: "SIGKILL" });
46
+ const reader = child.stdout.getReader();
47
+ const chunks: Uint8Array[] = [];
48
+ let total = 0, overflow = false, cancelled = false;
49
+ const release = () => { if (cancelled) return; cancelled = true; void reader.cancel().catch(() => undefined); };
50
+ const pump = (async () => {
51
+ try {
52
+ for (;;) {
53
+ const { done, value } = await reader.read();
54
+ if (done) return;
55
+ total += value.byteLength;
56
+ if (total > PROBE_OUTPUT_CAP) { overflow = true; child.kill("SIGKILL"); release(); return; }
57
+ chunks.push(value);
58
+ }
59
+ } catch { /* a cancelled or failed read ends the probe with what was counted */ }
60
+ })();
61
+ const deadline = setTimeout(() => { child.kill("SIGKILL"); release(); }, timeoutMs + graceMs);
62
+ let grace: ReturnType<typeof setTimeout> | undefined;
63
+ try {
64
+ const code = await child.exited;
65
+ grace = setTimeout(release, graceMs);
66
+ await pump;
67
+ return code === 0 && !overflow ? Buffer.concat(chunks).toString("utf8") : undefined;
68
+ } finally { clearTimeout(deadline); clearTimeout(grace); release(); }
69
+ }
70
+
71
+ /** #843 AC3: the same probe for the daemon, which must not block its event loop for up to ten seconds an id. */
72
+ export async function agentVersionsAsync(commands: Partial<Record<CompatId, readonly string[]>> = {}, timeoutMs = 10_000, graceMs = PROBE_EXIT_GRACE_MS): Promise<AgentVersionRow[]> {
73
+ const trusted = readTrust();
74
+ return Promise.all((Object.keys(COMPAT) as CompatId[]).map(async id => {
75
+ const target = probeTarget(id, commands);
76
+ if (!target) return probeRow(id, undefined, undefined, trusted);
77
+ try { return probeRow(id, target.executable, await runProbe(target.executable, target.args, timeoutMs, graceMs), trusted); }
78
+ catch { return probeRow(id, target.executable, undefined, trusted); }
79
+ }));
80
+ }
81
+
20
82
  export function runAgents(args: string[], commands: Partial<Record<CompatId, readonly string[]>>, fail: (message: string) => never): void {
21
83
  const [sub, id, version, ...rest] = args.filter(arg => arg !== "--json");
22
84
  if (sub === "trust") {
@@ -0,0 +1,54 @@
1
+ import { request } from "node:http";
2
+ import { isAbsolute, basename } from "node:path";
3
+
4
+ export const HOST_SHELL_PROTOCOL = Object.freeze({ chunkBytes: 16 * 1024, frameBytes: 24 * 1024, failure: 125, maximumMs: 3_600_000, refusal: "host shell execution refused" });
5
+
6
+ async function output(stream: NodeJS.WriteStream, data: Buffer): Promise<void> {
7
+ await new Promise<void>((resolve, reject) => stream.write(data, error => error ? reject(error) : resolve()));
8
+ }
9
+
10
+ async function main(): Promise<number> {
11
+ const [socket, ticket, ...extra] = process.argv.slice(2);
12
+ if (!socket || !isAbsolute(socket) || socket.length > 4096 || !ticket || !/^[a-f0-9]{64}$/.test(ticket) || extra.length) throw new Error();
13
+ const body = JSON.stringify({ ticket });
14
+ const call = request({ socketPath: socket, path: "/execute", method: "POST", headers: { "content-type": "application/json", "content-length": Buffer.byteLength(body), connection: "close" } });
15
+ const connection = new Promise<import("node:http").IncomingMessage>((resolve, reject) => { call.once("response", resolve); call.once("error", reject); });
16
+ const opening = setTimeout(() => call.destroy(new Error()), 5000);
17
+ const total = setTimeout(() => call.destroy(new Error()), HOST_SHELL_PROTOCOL.maximumMs + 10_000);
18
+ let response: import("node:http").IncomingMessage | undefined;
19
+ try {
20
+ call.end(body); response = await connection; clearTimeout(opening);
21
+ if (response.statusCode !== 200 || response.headers["content-type"] !== "application/x-ndjson") throw new Error();
22
+ let pending = "", status: number | undefined;
23
+ for await (const raw of response) {
24
+ const chunk = Buffer.isBuffer(raw) ? raw : Buffer.from(raw);
25
+ if (chunk.length > 1024 * 1024) throw new Error();
26
+ pending += chunk.toString("utf8");
27
+ for (let end; (end = pending.indexOf("\n")) >= 0;) {
28
+ if (end > HOST_SHELL_PROTOCOL.frameBytes) throw new Error();
29
+ const line = pending.slice(0, end); pending = pending.slice(end + 1);
30
+ if (!line || status !== undefined) throw new Error();
31
+ const frame = JSON.parse(line);
32
+ if (!frame || typeof frame !== "object" || Array.isArray(frame)) throw new Error();
33
+ const keys = Object.keys(frame).sort().join(",");
34
+ if (frame.type === "exit" && keys === "code,type" && Number.isSafeInteger(frame.code) && frame.code >= 0 && frame.code <= 255) { status = frame.code; continue; }
35
+ if (!["stdout", "stderr"].includes(frame.type) || keys !== "data,type" || typeof frame.data !== "string" || frame.data.length > 4 * Math.ceil(HOST_SHELL_PROTOCOL.chunkBytes / 3)) throw new Error();
36
+ const bytes = Buffer.from(frame.data, "base64");
37
+ if (bytes.length > HOST_SHELL_PROTOCOL.chunkBytes || bytes.toString("base64") !== frame.data) throw new Error();
38
+ await output(frame.type === "stdout" ? process.stdout : process.stderr, bytes);
39
+ }
40
+ if (pending.length > HOST_SHELL_PROTOCOL.frameBytes) throw new Error();
41
+ }
42
+ if (pending || status === undefined) throw new Error();
43
+ return status;
44
+ } finally { clearTimeout(opening); clearTimeout(total); response?.destroy(); call.destroy(); }
45
+ }
46
+
47
+ // The filename fence also keeps this inert if a host imports the protocol constants through a bundle.
48
+ if (import.meta.main && basename(process.argv[1] ?? "") === "claude-shell-wrapper.ts") {
49
+ process.stdout.on("error", () => {}); process.stderr.on("error", () => {});
50
+ void main().then(code => { process.exitCode = code; }, async () => {
51
+ try { await output(process.stderr, Buffer.from(`${HOST_SHELL_PROTOCOL.refusal}\n`)); } catch { /* broken caller pipe */ }
52
+ process.exitCode = HOST_SHELL_PROTOCOL.failure;
53
+ });
54
+ }
package/src/cli/main.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bun
2
- import { conductorOperation } from "../hub/operational-authority.ts";
2
+ import { conductorOperation, OPERATIONAL_PERSON_GUIDANCE } from "../hub/operational-authority.ts";
3
3
  import type { DeliverableRow } from "../hub/tasks.ts";
4
4
  import { kimiSourceRow } from "../hub/budget-notes.ts";
5
5
  import { hubGitSync, hubGitEnv } from "../hub/git.ts";
@@ -487,12 +487,17 @@ function startupDiagnostic(error: unknown): string {
487
487
  return message.replace(/[\p{Cc}\p{Cf}]/gu, " ").replace(/\s+/g, " ").trim().slice(0, 512) || "invalid startup configuration";
488
488
  }
489
489
 
490
- function fail(message: string, refusalAudited=false): never {
491
- if(operationalAuditReady){if(refusalAudited)operationalRefusalRecorded=true;if(!operationalRefusalRecorded)audit("refused");}
490
+ function fail(message: string, refusalAudited=false, authorityRefused?:boolean): never {
491
+ if(operationalAuditReady && authorityRefused!==false){if(refusalAudited)operationalRefusalRecorded=true;if(!operationalRefusalRecorded)audit("refused");}
492
492
  console.error(`ahub: ${message}`);
493
493
  process.exit(1);
494
494
  }
495
495
 
496
+ function operationalIncomplete(result: {complete?:boolean;incompleteReason?:unknown;truncated?:boolean}, history: "log"|"event"): void {
497
+ if(result.complete===false)console.error(`ahub: operational output is incomplete: ${typeof result.incompleteReason==="string"?terminalText(result.incompleteReason).replace(/[\r\n]+/g," ").slice(0,200):"public privacy or history coverage unavailable"}`);
498
+ else if(result.truncated)console.error(`ahub: operational ${history} history is truncated`);
499
+ }
500
+
496
501
  async function taskOp(op: string, a: Record<string, unknown>, throwOnError = false, connection?: ControlClient, timeoutMs?: number): Promise<string> {
497
502
  const hub = connection ?? await connect();
498
503
  try {
@@ -501,11 +506,11 @@ async function taskOp(op: string, a: Record<string, unknown>, throwOnError = fal
501
506
  if (!res.ok) {
502
507
  if (throwOnError) throw new Error(res.error);
503
508
  if(res.refusalAudited===true)operationalRefusalRecorded=true;
504
- audit("refused");
509
+ if(!operationalAuditReady || res.authorityRefused!==false)audit("refused");
505
510
  // #711: an agent shell refused something only the person may do is told why it counts as an agent. The refusal and the authority stay as they were.
506
511
  const cause = agentShellCause(identity, String(res.error));
507
512
  if (cause) console.error(`ahub: ${res.error}\n${cause}`), process.exit(1);
508
- fail(res.error);
513
+ fail(res.error,res.refusalAudited===true,res.authorityRefused);
509
514
  }
510
515
  return res.text;
511
516
  } finally { if (!connection) hub.close(); }
@@ -1065,7 +1070,7 @@ const commands: Record<string, () => Promise<void> | void> = {
1065
1070
  const { one, rest: flags } = takeFlags(rest, ["--peer"], []);
1066
1071
  if (flags.length) fail("usage: ahub queue list [--peer <id>] [--json] [--full] [--color=auto|always|never]");
1067
1072
  const result = await hub.request({ t: "queue", op: "list", ...(one["--peer"] ? { peer: one["--peer"] } : {}) });
1068
- if (!result.ok) fail(result.error,result.refusalAudited===true);
1073
+ if (!result.ok) fail(result.error,result.refusalAudited===true,result.authorityRefused);
1069
1074
  if (options.json) return console.log(JSON.stringify(result.deliveries, null, 2));
1070
1075
  printOutput(renderQueue(result.deliveries, options.columns, Date.now()), options.color);
1071
1076
  return;
@@ -1077,7 +1082,7 @@ const commands: Record<string, () => Promise<void> | void> = {
1077
1082
  if (others.length || !one["--peer"] || (one["--state"] !== undefined && !["queued", "needs_review"].includes(one["--state"]))) fail("usage: ahub queue discard --peer <id> --reason <text> [--state queued|needs_review] [--yes]");
1078
1083
  if (yes && !one["--reason"]?.trim()) fail("ahub queue discard needs --reason <text> with --yes: it is recorded on every discarded delivery");
1079
1084
  const result = await hub.request({ t: "queue", op: "discard", peer: one["--peer"], ...(one["--state"] ? { state: one["--state"] } : {}), ...(yes ? { confirmed: true, reason: one["--reason"] } : {}) }, 60_000);
1080
- if (!result.ok) fail(result.error,result.refusalAudited===true);
1085
+ if (!result.ok) fail(result.error,result.refusalAudited===true,result.authorityRefused);
1081
1086
  if (options.json) return console.log(JSON.stringify(result, null, 2));
1082
1087
  if (!yes) return console.log(`${result.count} ${result.state} deliver${result.count === 1 ? "y" : "ies"} for ${result.peer}; nothing discarded. Run again with --reason <text> --yes to discard them.`);
1083
1088
  console.log(`discarded ${result.discarded} of ${result.count} ${result.state} deliver${result.count === 1 ? "y" : "ies"} for ${result.peer}${result.refused ? `; ${result.refused} refused (${Object.entries(result.refusedBy ?? {}).map(([why, n]) => `${why} ${n}`).join(", ")})` : ""}`);
@@ -1088,7 +1093,7 @@ const commands: Record<string, () => Promise<void> | void> = {
1088
1093
  if (operation === "show") {
1089
1094
  if (flags.length) fail("usage: ahub queue show <id>");
1090
1095
  const shown = await hub.request({ t: "queue", op: "show", id });
1091
- if (!shown.ok) fail(shown.error,shown.refusalAudited===true);
1096
+ if (!shown.ok) fail(shown.error,shown.refusalAudited===true,shown.authorityRefused);
1092
1097
  if (options.json) return console.log(JSON.stringify(shown.delivery, null, 2));
1093
1098
  return printOutput(renderQueueShow(shown.delivery, options.columns, Date.now()), options.color);
1094
1099
  }
@@ -1098,11 +1103,11 @@ const commands: Record<string, () => Promise<void> | void> = {
1098
1103
  let revision = parsedRevision;
1099
1104
  if (revision === undefined) {
1100
1105
  const shown = await hub.request({ t: "queue", op: "show", id });
1101
- if (!shown.ok) fail(shown.error,shown.refusalAudited===true);
1106
+ if (!shown.ok) fail(shown.error,shown.refusalAudited===true,shown.authorityRefused);
1102
1107
  revision = shown.delivery.revision;
1103
1108
  }
1104
1109
  const result = await hub.request({ t: "queue", op: "resolve", id, revision, action: one["--action"], reason: one["--reason"] });
1105
- if (!result.ok) fail(result.error,result.refusalAudited===true);
1110
+ if (!result.ok) fail(result.error,result.refusalAudited===true,result.authorityRefused);
1106
1111
  if (options.json) console.log(JSON.stringify(result.delivery, null, 2));
1107
1112
  else printOutput(renderQueueShow(result.delivery, options.columns, Date.now()), options.color);
1108
1113
  } finally { hub.close(); }
@@ -1566,7 +1571,7 @@ const commands: Record<string, () => Promise<void> | void> = {
1566
1571
  const reply = await hub.request({ t: "peer_stop", peer }, 35_000);
1567
1572
  if (reply.ok !== true) {
1568
1573
  const error = String(reply.error ?? "peer stop request refused");
1569
- fail(/unknown (?:control )?(?:message|request|command)(?:\b|:)|this hub does not know "peer_stop"/i.test(error) ? `${error}; upgrade the running hub to use ahub stop` : error,reply.refusalAudited===true);
1574
+ fail(/unknown (?:control )?(?:message|request|command)(?:\b|:)|this hub does not know "peer_stop"/i.test(error) ? `${error}; upgrade the running hub to use ahub stop` : error,reply.refusalAudited===true,reply.authorityRefused);
1570
1575
  }
1571
1576
  console.log(`${peer}: ${reply.state}`);
1572
1577
  } finally { hub.close(); }
@@ -1602,7 +1607,7 @@ const commands: Record<string, () => Promise<void> | void> = {
1602
1607
  const hub = await connect();
1603
1608
  try {
1604
1609
  requireNamedPeerFeature(hub, peer);
1605
- const request = async (message: Record<string, unknown>) => { const reply = await hub.request(message, 35_000); if (reply.ok !== true) fail(String(reply.error ?? "roster request refused"),reply.refusalAudited===true); return reply; };
1610
+ const request = async (message: Record<string, unknown>) => { const reply = await hub.request(message, 35_000); if (reply.ok !== true) fail(String(reply.error ?? "roster request refused"),reply.refusalAudited===true,reply.authorityRefused); return reply; };
1606
1611
  if (sub === "list") {
1607
1612
  const view = await request({ t: "settings_get" });
1608
1613
  if (!Array.isArray(view.roster)) fail("upgrade the running hub to use ahub roster");
@@ -1668,7 +1673,7 @@ const commands: Record<string, () => Promise<void> | void> = {
1668
1673
  const reply = await hub.request(message, 35_000);
1669
1674
  if (reply.ok !== true) {
1670
1675
  const error = String(reply.error ?? "settings request refused");
1671
- fail(/unknown (?:control )?(?:message|request|command)(?:\b|:)|this hub does not know "settings_/i.test(error) ? `${error}; upgrade the running hub to use ahub settings` : error,reply.refusalAudited===true);
1676
+ fail(/unknown (?:control )?(?:message|request|command)(?:\b|:)|this hub does not know "settings_/i.test(error) ? `${error}; upgrade the running hub to use ahub settings` : error,reply.refusalAudited===true,reply.authorityRefused);
1672
1677
  }
1673
1678
  return reply;
1674
1679
  };
@@ -1798,14 +1803,14 @@ const commands: Record<string, () => Promise<void> | void> = {
1798
1803
 
1799
1804
  logs: async () => {
1800
1805
  if (identity.role !== "tools") return exec("tail", [args.includes("-f") ? "-f" : "-n100", join(stateDir, "hub.log")]);
1801
- const hub=await connect();const pending:string[]=[];let ready=false;
1802
- hub.onPush=message=>{if(message.t==="operational_log" && typeof message.line==="string"){if(ready)console.log(message.line);else if(pending.length<100)pending.push(message.line);}};
1806
+ const hub=await connect();const pending:string[]=[];let ready=false,incompleteShown=false;
1807
+ hub.onPush=message=>{if(message.t==="operational_log" && typeof message.line==="string"){if(message.complete===false && !incompleteShown){operationalIncomplete(message,"log");incompleteShown=true;}if(ready)console.log(message.line);else if(pending.length<100)pending.push(message.line);}};
1803
1808
  try {
1804
1809
  const result=await hub.request({t:"conductor_operational",op:"logs",follow:args.includes("-f")});
1805
- if(result.ok!==true)fail(String(result.error??"operational logs unavailable"),result.refusalAudited===true);
1810
+ if(result.ok!==true)fail(String(result.error??"operational logs unavailable"),result.refusalAudited===true,result.authorityRefused);
1806
1811
  for(const line of result.lines??[])console.log(line);
1807
1812
  ready=true;for(const line of pending)console.log(line);
1808
- if(result.truncated)console.error("ahub: operational log history is truncated");
1813
+ if(!incompleteShown){operationalIncomplete(result,"log");if(result.complete===false)incompleteShown=true;}
1809
1814
  if(args.includes("-f"))await new Promise<void>(resolve=>{
1810
1815
  const finish=()=>{process.off("SIGINT",finish);resolve();};
1811
1816
  hub.onClose=finish;process.once("SIGINT",finish);
@@ -1815,7 +1820,7 @@ const commands: Record<string, () => Promise<void> | void> = {
1815
1820
  export: async () => {
1816
1821
  if(identity.role==="tools") {
1817
1822
  const hub=await connect();
1818
- try {const result=await hub.request({t:"conductor_operational",op:"export",since:since()});if(result.ok!==true)fail(String(result.error??"operational export unavailable"),result.refusalAudited===true);for(const row of result.events??[])console.log(JSON.stringify(row));if(result.truncated)console.error("ahub: operational event history is truncated");}
1823
+ try {const result=await hub.request({t:"conductor_operational",op:"export",since:since()});if(result.ok!==true)fail(String(result.error??"operational export unavailable"),result.refusalAudited===true,result.authorityRefused);for(const row of result.events??[])console.log(JSON.stringify(row));operationalIncomplete(result,"event");}
1819
1824
  finally {hub.close();}
1820
1825
  return;
1821
1826
  }
@@ -2230,7 +2235,7 @@ const commands: Record<string, () => Promise<void> | void> = {
2230
2235
  }
2231
2236
  if (args.includes("--orphans")) {
2232
2237
  if(identity.role==="tools" && args.includes("--kill")) {
2233
- const hub=await connect();try {const result=await hub.request({t:"conductor_operational",op:"orphans"},10_000);if(result.ok!==true)fail(String(result.error??"orphan inspection unavailable"),result.refusalAudited===true);console.log(JSON.stringify(result,null,2));}finally{hub.close();}return;
2238
+ const hub=await connect();try {const result=await hub.request({t:"conductor_operational",op:"orphans"},10_000);if(result.ok!==true)fail(String(result.error??"orphan inspection unavailable"),result.refusalAudited===true,result.authorityRefused);console.log(JSON.stringify(result,null,2));}finally{hub.close();}return;
2234
2239
  }
2235
2240
  return orphanDoctor(args.includes("--kill"), options);
2236
2241
  }
@@ -2392,16 +2397,17 @@ function inspectDoctorRouting(cwd: string): { routing?: Routing; errors: string[
2392
2397
  async function runConductorCommand(): Promise<void> {
2393
2398
  const operation=conductorOperation(cmd,args);
2394
2399
  if(operation) {
2395
- const denial=()=>peerCommandRefusal(identity.role==="tools"?identity.peer:"unknown",commandLabel);
2400
+ const unavailable=`conductor authorization unavailable; nothing was executed; ${OPERATIONAL_PERSON_GUIDANCE}`;
2396
2401
  const descriptor=readControl(stateDir);
2397
- if(!descriptor?.instanceId || !descriptor.projectId || !descriptor.pid || !descriptor.pidSignature || processLiveness(descriptor.pid,descriptor.pidSignature)!=="live")fail(denial());
2402
+ if(!descriptor?.instanceId || !descriptor.projectId || !descriptor.pid || !descriptor.pidSignature || processLiveness(descriptor.pid,descriptor.pidSignature)!=="live")fail(unavailable);
2398
2403
  let hub:ControlClient;
2399
- try {hub=await connect(750);} catch {fail(denial());}
2404
+ try {hub=await connect(3000);} catch {fail(unavailable);}
2400
2405
  let release:(()=>void)|undefined;
2401
2406
  try {
2402
2407
  let result:any;
2403
- try {result=await hub.request({t:"conductor_operational",op:"authorize",command:operation},750);} catch {fail(denial());}
2404
- if(result.ok!==true || !hub.instanceId)fail(`${denial()}${result.error?`; ${String(result.error)}`:""}`,result.refusalAudited===true);
2408
+ try {result=await hub.request({t:"conductor_operational",op:"authorize",command:operation},3000);} catch {fail(unavailable);}
2409
+ if(result.ok!==true)fail(result.authorityRefused===true || result.authorityRefused===false?String(result.error??unavailable):unavailable,result.refusalAudited===true,result.authorityRefused);
2410
+ if(!hub.instanceId)fail(unavailable);
2405
2411
  release=ControlClient.pinInstance(stateDir,hub.instanceId);
2406
2412
  await commands[cmd]!();
2407
2413
  } finally {release?.();hub.close();}
package/src/cli/output.ts CHANGED
@@ -14,6 +14,8 @@ export interface StatusOutput {
14
14
  retainedConfig?: readonly { key: string; reason: string }[];
15
15
  /** #572 AC3: advisory, present while the project root checkout is on a branch other than the default. */
16
16
  mainCheckout?: string;
17
+ /** #843 AC3: installed agent CLIs not on a verified version; ids, version, state and a receipt path only. */
18
+ agentDrift?: readonly { id: string; version: string; state: string; receipt: string }[];
17
19
  taskStalls?: readonly { task: number; cause: { kind: string; peer?: string; dependency?: number; code: string } }[];
18
20
  pendingTaskOffers?: readonly { task: number; peer: string }[];
19
21
  isolatedChecks?: { pending?: number; orphans?: string[]; unknownReceipts?: readonly string[]; maintenanceUnavailable?: boolean };
@@ -204,6 +206,7 @@ export function renderStatus(data: StatusOutput, columns?: number, now = Date.no
204
206
  if (data.cwd) out.push(...lines(data.cwd, columns));
205
207
  for (const pending of data.pendingRestart ?? []) out.push(...detail("pending restart", `${pending.key}: ${pending.reason}`, columns, "attention"));
206
208
  if (data.mainCheckout) out.push(...detail("main checkout", data.mainCheckout, columns, "attention"));
209
+ for (const drift of data.agentDrift ?? []) out.push(...detail("agent CLI drift", `${drift.id} ${drift.version} ${drift.state}; last receipt ${drift.receipt}`, columns, "attention"));
207
210
  if (data.retainedConfig?.length) {
208
211
  out.push(...detail("config retained", `${data.retainedConfig.map(row => row.key).join(", ")}; run ahub settings reload`, columns, "attention"));
209
212
  for (const pending of data.retainedConfig) out.push(...detail("retained reason", `${pending.key}: ${pending.reason}`, columns, "attention"));
@@ -262,6 +265,7 @@ export function renderStatus(data: StatusOutput, columns?: number, now = Date.no
262
265
  if (p.permissionNote) out.push(...detail("permission", p.permissionNote, columns, "muted"));
263
266
  if (typeof p.statusNotice?.message === "string" && p.statusNotice.message) out.push(...detail("status notice", p.statusNotice.message, columns, "attention"));
264
267
  if (p.requestedModel && p.servedBy && p.requestedModel !== p.servedBy) out.push(...detail("requested model", p.requestedModel, columns));
268
+ if (p.reviewScope === "code" || p.reviewScope === "full") out.push(...detail("review scope", p.reviewScope === "code" ? "code only (cannot run tests or read CI; the merger gates on exact-head CI)" : "full", columns, "muted")); // #852
265
269
  if (typeof p.modelSource === "string" && p.modelSource) out.push(...detail("model source", flat(p.modelSource), columns, "muted")); // an extra Jcode instance (#685): names only, never a URL or token
266
270
  if (typeof p.importantWaitingSince === "number" && p.importantWaitingNote) out.push(...detail("important waiting", `${p.importantWaitingNote.replace(/^\d+ important messages? waiting/, "important messages waiting")}`, columns, "attention"));
267
271
  if (typeof p.oldestQueuedAt === "number") out.push(...detail(`${id} oldest queued`, relative(p.oldestQueuedAt, now), columns, "muted"));
@@ -0,0 +1,61 @@
1
+ import { COMPAT, type CompatId } from "../adapters/compat.ts";
2
+ import type { AgentVersionRow } from "../cli/agents.ts";
3
+ import { HUB, newEnvelope, sanitize, type PeerId } from "./envelope.ts";
4
+
5
+ /** #843 AC3: an installed gated CLI whose version is not `verified`. Ids, version, state and one receipt path, never output of the CLI. */
6
+ export interface DriftedAgent { id: CompatId; version: string; state: "in-line" | "trusted" | "refused"; receipt: string }
7
+
8
+ /** A version string that is safe to carry in a notice and a marker: what `COMPAT[id].parse` yields for a real CLI. */
9
+ const SAFE_VERSION = /^[0-9A-Za-z][0-9A-Za-z.+_-]{0,63}$/;
10
+
11
+ /**
12
+ * Pure: which rows are drift. A missing executable is not drift (no executable, no version), and a CLI whose
13
+ * version could not be read has no (id, version) to announce, so `unknown` is never drift either.
14
+ */
15
+ export function driftedAgents(rows: readonly AgentVersionRow[]): DriftedAgent[] {
16
+ const out: DriftedAgent[] = [];
17
+ for (const row of rows) {
18
+ const { verdict } = row;
19
+ if (!row.executable || verdict.state === "verified" || verdict.state === "unknown") continue;
20
+ const version = verdict.version;
21
+ if (!version || !SAFE_VERSION.test(version)) continue;
22
+ out.push({ id: row.id, version, state: verdict.state, receipt: COMPAT[row.id].receipt });
23
+ }
24
+ return out;
25
+ }
26
+
27
+ /** Durable key of one notice: the (id, version) and the planner it was queued for. */
28
+ export const driftMarker = (agent: Pick<DriftedAgent, "id" | "version">, planner: PeerId): string => `${agent.id}:${agent.version}@${planner}`;
29
+
30
+ export const driftNoticeBody = (agent: DriftedAgent): string =>
31
+ sanitize(`Agent CLI drift: ${agent.id} ${agent.version} ${agent.state}; last receipt ${agent.receipt}`);
32
+
33
+ export interface DriftDeps {
34
+ /** True when the (id, version, planner) marker is new and now recorded (durable: `Board.recordAgentDriftNotice`). */
35
+ record(marker: string): boolean;
36
+ /** Undo a record whose notice could not be queued, so the next sweep tries again. */
37
+ forget(marker: string): void;
38
+ /** The effective planner holders from the settled role map. */
39
+ planners(): readonly PeerId[];
40
+ isRostered(peer: PeerId): boolean;
41
+ publish(env: ReturnType<typeof newEnvelope>): PeerId[];
42
+ }
43
+
44
+ /**
45
+ * One notice per (id, version) to each effective planner, kept durably per recipient: a planner that was queued is
46
+ * never told again, and one that could not be queued (or was not rostered yet) is retried alone by the next sweep.
47
+ * With no rostered planner nothing is recorded. Returns the notices queued.
48
+ */
49
+ export function noticeDrift(drift: readonly DriftedAgent[], deps: DriftDeps): number {
50
+ const planners = deps.planners().filter(peer => deps.isRostered(peer));
51
+ let sent = 0;
52
+ for (const agent of drift) for (const planner of planners) {
53
+ const marker = driftMarker(agent, planner);
54
+ if (!deps.record(marker)) continue;
55
+ try {
56
+ if (!deps.publish(newEnvelope(HUB, driftNoticeBody(agent), { to: [planner], kind: "task" })).includes(planner)) throw new Error("the notice was not queued");
57
+ sent++;
58
+ } catch { deps.forget(marker); }
59
+ }
60
+ return sent;
61
+ }
package/src/hub/board.ts CHANGED
@@ -72,6 +72,10 @@ export interface HistoryEntry {
72
72
  reviewCost?: ReviewCost;
73
73
  /** #223 R4: on a verdict, whether the reviewer's context was new for it; set by the hub, never by the reviewer. */
74
74
  reviewContext?: "fresh" | "continuing";
75
+ /** #852: a verdict by a `code`-scope reviewer says so; a `full` reviewer's verdict carries nothing, so existing rows are unchanged. */
76
+ reviewScope?: "code";
77
+ /** #852 AC3: a `code`-scope reviewer's changes_requested with no open finding. It is recorded, returns the task to its owner and counts toward nothing. */
78
+ evidenceOnly?: true;
75
79
  /** Structured review report bound to the reviewed artifact, stored in the existing history JSON. */
76
80
  reviewFindings?: ReviewFindingSet;
77
81
  /**
@@ -259,6 +263,8 @@ export class Board {
259
263
  this.db.run("CREATE TABLE IF NOT EXISTS task_stalls (task INTEGER PRIMARY KEY, payload TEXT NOT NULL)");
260
264
  // #481 S7: one row per log-tier band breach episode already announced, so a restart never repeats the notice.
261
265
  this.db.run("CREATE TABLE IF NOT EXISTS band_notices (marker TEXT PRIMARY KEY, at INTEGER NOT NULL)");
266
+ // #843 AC3: one row per (agent id, installed version, planner) already queued, so a restart never repeats it.
267
+ this.db.run("CREATE TABLE IF NOT EXISTS agent_drift_notices (marker TEXT PRIMARY KEY, at INTEGER NOT NULL)");
262
268
  // Derived status-cache generation: only Board task/stall writes advance it, never journal writes.
263
269
  this.db.run("CREATE TABLE IF NOT EXISTS board_status_revision (id INTEGER PRIMARY KEY CHECK(id=1), revision INTEGER NOT NULL)");
264
270
  this.db.run("INSERT OR IGNORE INTO board_status_revision VALUES (1,0)");
@@ -373,6 +379,12 @@ export class Board {
373
379
  if (!/^[a-z_]{1,64}:#[1-9][0-9]{0,9}:log$/.test(marker)) throw new Error("invalid band notice marker");
374
380
  return this.db.query("INSERT OR IGNORE INTO band_notices(marker, at) VALUES(?, ?)").run(marker, Date.now()).changes > 0;
375
381
  }
382
+ /** True when the (id, version, planner) marker was new and is now recorded; false when it was announced before. */
383
+ recordAgentDriftNotice(marker: string): boolean {
384
+ if (!/^[a-z][a-z0-9-]{0,31}:[0-9A-Za-z][0-9A-Za-z.+_-]{0,63}@[0-9A-Za-z][0-9A-Za-z._-]{0,63}$/.test(marker)) throw new Error("invalid agent drift marker");
385
+ return this.db.query("INSERT OR IGNORE INTO agent_drift_notices(marker, at) VALUES(?, ?)").run(marker, Date.now()).changes > 0;
386
+ }
387
+ forgetAgentDriftNotice(marker: string): void { this.db.query("DELETE FROM agent_drift_notices WHERE marker=?").run(marker); }
376
388
  clearStall(task: number): void { this.taskWrite(() => this.db.query("DELETE FROM task_stalls WHERE task=?").run(task)); }
377
389
  stalls(): TaskStall[] {
378
390
  return (this.db.query("SELECT payload FROM task_stalls ORDER BY task").all() as { payload: string }[]).flatMap(row => {
@@ -398,7 +410,7 @@ export class Board {
398
410
 
399
411
  /** The only way a task changes. Validates the move, records who did what, returns the new row. */
400
412
  /** A `plan` in the patch replaces the old one whole: a new plan is the owner's current intent, not an addition. */
401
- update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "planner" | "reviewer" | "reserved" | "refs" | "plan" | "rejections" | "signals" | "sdlc" | "role" | "cancelled" | "codeSymbols">>, note?: string, extra: Pick<HistoryEntry, "plannerReason" | "planAuthor" | "selfReview" | "profile" | "sweep" | "reason" | "from" | "capacity" | "withheld" | "noWrites" | "reviewCost" | "reviewContext" | "reviewFindings" | "unmetCriteria" | "deliverables" | "planObservation" | "planDeparture" | "planDepartureInvalidated" | keyof ReviewerHistory> = {}, expectedFlowRevision?: number, codeSymbolsAuthority?: typeof HOST_CODE_SYMBOLS): Task {
413
+ update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "planner" | "reviewer" | "reserved" | "refs" | "plan" | "rejections" | "signals" | "sdlc" | "role" | "cancelled" | "codeSymbols">>, note?: string, extra: Pick<HistoryEntry, "plannerReason" | "planAuthor" | "selfReview" | "profile" | "sweep" | "reason" | "from" | "capacity" | "withheld" | "noWrites" | "reviewCost" | "reviewContext" | "reviewScope" | "evidenceOnly" | "reviewFindings" | "unmetCriteria" | "deliverables" | "planObservation" | "planDeparture" | "planDepartureInvalidated" | keyof ReviewerHistory> = {}, expectedFlowRevision?: number, codeSymbolsAuthority?: typeof HOST_CODE_SYMBOLS): Task {
402
414
  const task = this.get(id);
403
415
  if (!task) throw new Error(`no task #${id}`);
404
416
  if (expectedFlowRevision !== undefined && (!Number.isSafeInteger(expectedFlowRevision) || expectedFlowRevision < 0 || task.history.length !== expectedFlowRevision)) throw new Error("task flow revision changed");
@@ -454,7 +466,7 @@ export class Board {
454
466
  // Disabling reporting must not let plan or repository refs edits/reverts resurrect prior consent.
455
467
  const repositoryRefsChanged = patch.refs !== undefined && JSON.stringify([task.refs.repo, task.refs.paths]) !== JSON.stringify([next.refs.repo, next.refs.paths]);
456
468
  const departureInvalidated = extra.planDepartureInvalidated === true || hasDeparture && (planChanged || repositoryRefsChanged);
457
- const history = [...task.history, { at: Date.now(), by, event, ...plannerHistory, ...authorHistory, ...(extra.selfReview === true ? { selfReview: true as const } : {}), ...(planRevision ? { planRevision } : {}), ...(extra.planObservation ? { planObservation: extra.planObservation } : {}), ...(extra.planDeparture ? { planDeparture: extra.planDeparture } : {}), ...(departureInvalidated ? { planDepartureInvalidated: true as const } : {}), ...reviewMetadata, ...(note ? { note } : {}), ...(patch.cancelled?.supersededBy ? { supersededBy: patch.cancelled.supersededBy } : {}), ...("owner" in patch ? { owner: next.owner } : {}), ...(extra.profile ? { profile: extra.profile } : {}), ...(extra.sweep ? { sweep: extra.sweep } : {}), ...(extra.reason && TASK_MOVE_REASONS.includes(extra.reason) ? { reason: extra.reason } : {}), ...(extra.from ? { from: extra.from } : {}), ...(extra.capacity === true ? { capacity: true as const } : {}), ...(extra.withheld && (note || reviewFindings) ? { withheld: true as const } : {}), ...(reviewFindings ? { reviewFindings } : {}), ...(unmetCriteria?.length ? { unmetCriteria } : {}), ...(deliverables ? { deliverables } : {}), ...(extra.reviewContext === "fresh" || extra.reviewContext === "continuing" ? { reviewContext: extra.reviewContext } : {}), ...(extra.reviewCost ? { reviewCost: extra.reviewCost } : {}), ...(event === "done" && extra.noWrites === true ? { noWrites: true as const } : {}) }];
469
+ const history = [...task.history, { at: Date.now(), by, event, ...plannerHistory, ...authorHistory, ...(extra.selfReview === true ? { selfReview: true as const } : {}), ...(planRevision ? { planRevision } : {}), ...(extra.planObservation ? { planObservation: extra.planObservation } : {}), ...(extra.planDeparture ? { planDeparture: extra.planDeparture } : {}), ...(departureInvalidated ? { planDepartureInvalidated: true as const } : {}), ...reviewMetadata, ...(note ? { note } : {}), ...(patch.cancelled?.supersededBy ? { supersededBy: patch.cancelled.supersededBy } : {}), ...("owner" in patch ? { owner: next.owner } : {}), ...(extra.profile ? { profile: extra.profile } : {}), ...(extra.sweep ? { sweep: extra.sweep } : {}), ...(extra.reason && TASK_MOVE_REASONS.includes(extra.reason) ? { reason: extra.reason } : {}), ...(extra.from ? { from: extra.from } : {}), ...(extra.capacity === true ? { capacity: true as const } : {}), ...(extra.withheld && (note || reviewFindings) ? { withheld: true as const } : {}), ...(reviewFindings ? { reviewFindings } : {}), ...(unmetCriteria?.length ? { unmetCriteria } : {}), ...(deliverables ? { deliverables } : {}), ...(extra.reviewContext === "fresh" || extra.reviewContext === "continuing" ? { reviewContext: extra.reviewContext } : {}), ...(extra.reviewScope === "code" ? { reviewScope: "code" as const } : {}), ...(extra.evidenceOnly === true && event === "changes_requested" ? { evidenceOnly: true as const } : {}), ...(extra.reviewCost ? { reviewCost: extra.reviewCost } : {}), ...(event === "done" && extra.noWrites === true ? { noWrites: true as const } : {}) }];
458
470
  this.taskWrite(() => {
459
471
  const result = this.db.query("UPDATE tasks SET state = ?, owner = ?, reviewer = ?, reserved = ?, refs = ?, plan = ?, rejections = ?, signals = ?, history = ?, sdlc = ?, task_role = ?, cancelled = ?, updated = ?, planner = ? WHERE id = ?")
460
472
  .run(next.state, next.owner, next.reviewer, next.reserved ?? null, JSON.stringify(next.refs), JSON.stringify(next.plan ?? {}), next.rejections, JSON.stringify(next.signals), JSON.stringify(history), next.sdlc ? JSON.stringify(next.sdlc) : null, next.role ?? null, next.cancelled ? JSON.stringify(next.cancelled) : null, Date.now(), planner, id);