@staix/agent-hub 0.12.35 → 0.12.36

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 (49) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +8 -6
  3. package/docs/agent-notes/adapters.md +4 -1
  4. package/docs/agent-notes/budget.md +1 -1
  5. package/docs/agent-notes/bus.md +3 -0
  6. package/docs/agent-notes/daemon.md +6 -2
  7. package/docs/agent-notes/tasks.md +11 -1
  8. package/docs/agent-notes/tests.md +5 -1
  9. package/docs/operations.md +40 -16
  10. package/docs/quickstart.md +6 -8
  11. package/docs/sdlc.md +46 -5
  12. package/docs/security.md +5 -1
  13. package/docs/specs/2026-09-19-agent-hub-design.md +76 -1
  14. package/docs/verification/2026-10-19-0.12.36.md +63 -0
  15. package/docs/verified.json +18 -14
  16. package/package.json +1 -1
  17. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  18. package/plugins/agent-hub/server.js +385 -29
  19. package/src/adapters/claude-print.ts +230 -0
  20. package/src/adapters/definitions/builtin-metadata.ts +17 -1
  21. package/src/adapters/definitions/native-cli-entry-claude.ts +17 -3
  22. package/src/adapters/definitions/native-cli-entry-jcode.ts +3 -2
  23. package/src/adapters/definitions/native-cli-entry-kimi.ts +3 -2
  24. package/src/adapters/definitions/native-cli-entry-types.ts +2 -0
  25. package/src/adapters/definitions/native-factories.ts +7 -0
  26. package/src/adapters/registry-types.ts +8 -2
  27. package/src/adapters/registry.ts +13 -1
  28. package/src/cli/help.ts +5 -0
  29. package/src/cli/identity.ts +2 -1
  30. package/src/cli/launch.ts +19 -6
  31. package/src/cli/main.ts +38 -4
  32. package/src/cli/reset.ts +4 -2
  33. package/src/cli/statusline-tee.ts +5 -2
  34. package/src/cli/terminal-recovery.ts +7 -2
  35. package/src/cli/upgrade-runtime.ts +18 -15
  36. package/src/hub/board.ts +29 -7
  37. package/src/hub/bus.ts +11 -5
  38. package/src/hub/claude-state.ts +37 -0
  39. package/src/hub/cost-governance.ts +76 -0
  40. package/src/hub/daemon.ts +181 -73
  41. package/src/hub/hub-tools.ts +20 -1
  42. package/src/hub/instances.ts +17 -3
  43. package/src/hub/review-brief.ts +175 -0
  44. package/src/hub/routing.ts +17 -2
  45. package/src/hub/sdlc.ts +205 -4
  46. package/src/hub/status-roster.ts +10 -0
  47. package/src/hub/task-sweep.ts +143 -0
  48. package/src/hub/tasks.ts +544 -26
  49. package/src/hub/usage.ts +57 -20
@@ -44,12 +44,35 @@ var __esm = (fn, res, err) => () => {
44
44
  return res;
45
45
  };
46
46
 
47
+ // src/hub/claude-state.ts
48
+ function claudeStateFile(base, peer) {
49
+ return peer.includes("-") && PEER.test(peer) ? `${base}.${peer}.json` : `${base}.json`;
50
+ }
51
+ var PEER;
52
+ var init_claude_state = __esm(() => {
53
+ PEER = /^[a-z][a-z0-9-]{0,31}$/;
54
+ });
55
+
47
56
  // src/adapters/definitions/native-cli-entry-claude.ts
48
57
  import { readFileSync } from "fs";
49
58
  import { join } from "path";
50
59
  import { randomUUID } from "crypto";
51
60
  async function runClaudeEntry(ctx) {
52
61
  const { args, cwd, stateDir, cliDir, unattendedEnv, projectConfig, fail } = ctx;
62
+ const peer = ctx.instance ?? "claude";
63
+ if (ctx.hubStarted) {
64
+ if (args.includes("--print-command") || args.includes("--dry-run"))
65
+ return console.log(JSON.stringify({ peer, start: "hub", note: "a headless reviewer the hub runs per review" }, null, 2));
66
+ const hub = await ctx.connect();
67
+ try {
68
+ const res = await hub.request({ t: "start", peer, operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
69
+ if (res.ok !== true)
70
+ fail(String(res.error ?? `${peer} was not started`));
71
+ } finally {
72
+ hub.close();
73
+ }
74
+ return console.log(`${peer} started: a headless reviewer the hub runs per review (stop it with ahub stop ${peer})`);
75
+ }
53
76
  const { launcherPreview, readControl, recordTerminalLaunch, claudeObservationHooks, buildLaunch, runClaudeLaunch, processSignature, recordClaudeLaunch, cleanupStaleClaudeSettings, nativeLaunchEnv, childEnv, cleanupClaudeSettings } = ctx.helpers;
54
77
  if (args.includes("--print-command") || args.includes("--dry-run")) {
55
78
  try {
@@ -61,7 +84,7 @@ async function runClaudeEntry(ctx) {
61
84
  const control = readControl(stateDir);
62
85
  if (control?.instanceId) {
63
86
  process.env.AGENTHUB_INSTANCE_ID = control.instanceId;
64
- const terminal = await recordTerminalLaunch("claude", cwd, stateDir, control.instanceId);
87
+ const terminal = await recordTerminalLaunch(peer, cwd, stateDir, control.instanceId, ctx.instance ? { executionCwd: cwd } : undefined);
65
88
  if (!terminal)
66
89
  process.env.AGENTHUB_LAUNCH_ID = randomUUID();
67
90
  }
@@ -74,20 +97,22 @@ async function runClaudeEntry(ctx) {
74
97
  const facts = claudeObservationHooks(projectConfig(), { script: join(cliDir, "facts-hook.ts"), stateDir });
75
98
  let previousLaunch;
76
99
  try {
77
- previousLaunch = JSON.parse(readFileSync(join(stateDir, "claude-launch.json"), "utf8"));
100
+ previousLaunch = JSON.parse(readFileSync(join(stateDir, claudeStateFile("claude-launch", peer)), "utf8"));
78
101
  } catch {}
79
102
  const launch = buildLaunch("claude", args, { unattended: unattendedEnv, statusLine: { script: join(cliDir, "statusline-tee.ts"), stateDir, ...original ? { original } : {} }, ...facts ? { facts } : {} });
80
103
  let result;
81
104
  try {
82
105
  let launchRecord;
83
106
  if (control?.instanceId) {
84
- launchRecord = { instanceId: control.instanceId, launchId: process.env.AGENTHUB_LAUNCH_ID, permissionHook: launch.permissionHook === true, hookPurpose: launch.hookPurpose, settingsFile: launch.settingsFile, launcherPid: process.pid, launcherSignature: processSignature(process.pid), unattended: launch.unattended === true };
107
+ launchRecord = { ...ctx.instance ? { peer } : {}, instanceId: control.instanceId, launchId: process.env.AGENTHUB_LAUNCH_ID, permissionHook: launch.permissionHook === true, hookPurpose: launch.hookPurpose, settingsFile: launch.settingsFile, launcherPid: process.pid, launcherSignature: processSignature(process.pid), unattended: launch.unattended === true };
85
108
  recordClaudeLaunch(stateDir, launchRecord);
86
109
  cleanupStaleClaudeSettings(stateDir, previousLaunch);
87
110
  }
88
111
  if (launch.warning)
89
112
  console.error(launch.warning);
90
113
  const env = nativeLaunchEnv("claude", childEnv());
114
+ if (ctx.instance)
115
+ env.AGENTHUB_PEER_ID = peer;
91
116
  result = await runClaudeLaunch(launch, { cwd, stateDir, env: { ...env, AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd }, ...launchRecord ? { record: launchRecord } : {} });
92
117
  } finally {
93
118
  cleanupClaudeSettings(stateDir, launch.settingsFile);
@@ -96,7 +121,9 @@ async function runClaudeEntry(ctx) {
96
121
  fail(`cannot run ${launch.cmd}: ${result.error.message}`);
97
122
  process.exit(result.status ?? 1);
98
123
  }
99
- var init_native_cli_entry_claude = () => {};
124
+ var init_native_cli_entry_claude = __esm(() => {
125
+ init_claude_state();
126
+ });
100
127
 
101
128
  // src/adapters/definitions/native-cli-entry-codex.ts
102
129
  async function runCodexEntry(ctx) {
@@ -142,6 +169,10 @@ function rosterInStatus(status, peer) {
142
169
  const row = rows.find((r) => !!r && typeof r === "object" && r.peer === peer);
143
170
  return typeof row?.on === "boolean" ? row.on : undefined;
144
171
  }
172
+ function bundleInStatus(status) {
173
+ const value = status?.pluginBundle;
174
+ return typeof value === "string" && value.startsWith("/") && /[\\/]plugins[\\/]agent-hub[\\/]server\.js$/.test(value) ? value : undefined;
175
+ }
145
176
 
146
177
  // src/adapters/definitions/native-cli-entry-jcode.ts
147
178
  import { resolve } from "path";
@@ -168,7 +199,7 @@ async function runJcodeEntry(ctx) {
168
199
  fail("cannot read jcode's roster membership from the running hub; upgrade or restart it, then retry (nothing was started)");
169
200
  if (!rostered)
170
201
  fail("jcode is off roster; add it with ahub roster add jcode");
171
- const written = configureJcodeMcp(cwd, stateDir, resolve(cliDir, "../../plugins/agent-hub/server.js"));
202
+ const written = configureJcodeMcp(cwd, stateDir, bundleInStatus(snapshot) ?? resolve(cliDir, "../../plugins/agent-hub/server.js"));
172
203
  if (written.tracked)
173
204
  console.error(".jcode/mcp.json is tracked; review this local machine-path change before committing it");
174
205
  const reply = await hub.request({ t: "start", peer: "jcode", args: { mode: "headless", model: one["--model"], providerProfile: one["--provider-profile"], sessionId: one["--session-id"] }, operationId: process.env.AGENTHUB_RECOVERY_OPERATION }, 65000);
@@ -261,7 +292,7 @@ async function runKimiEntry(ctx) {
261
292
  fail(admission.error);
262
293
  let result;
263
294
  try {
264
- const written = configureKimiMcp(cwd, stateDir, resolve2(cliDir, "../../plugins/agent-hub/server.js"), "tui");
295
+ const written = configureKimiMcp(cwd, stateDir, bundleInStatus(snapshot) ?? resolve2(cliDir, "../../plugins/agent-hub/server.js"), "tui");
265
296
  if (written.tracked)
266
297
  console.error(".kimi-code/mcp.json is tracked; the agent-hub entry contains machine paths. Review this local change before committing it.");
267
298
  result = spawnSync(launch.cmd, launch.args, { cwd, stdio: "inherit", env: { ...nativeLaunchEnv("kimi", childEnv()), AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd } });
@@ -6781,7 +6812,7 @@ function catalogAssignments(raw) {
6781
6812
  throw new Error("catalog_roles must be a bounded peer map");
6782
6813
  const out = Object.create(null);
6783
6814
  for (const [peer, ids] of Object.entries(raw)) {
6784
- if (!PEER.test(peer) || ["user", "hub", "digest"].includes(peer) || !Array.isArray(ids) || ids.length > 2 || new Set(ids).size !== ids.length || ids.some((id) => typeof id !== "string" || !ID.test(id)))
6815
+ if (!PEER2.test(peer) || ["user", "hub", "digest"].includes(peer) || !Array.isArray(ids) || ids.length > 2 || new Set(ids).size !== ids.length || ids.some((id) => typeof id !== "string" || !ID.test(id)))
6785
6816
  throw new Error("catalog_roles takes at most two distinct role ids per peer");
6786
6817
  out[peer] = [...ids];
6787
6818
  }
@@ -6808,7 +6839,7 @@ function catalogSection(roles) {
6808
6839
 
6809
6840
  `);
6810
6841
  }
6811
- var ROLE_SECTIONS, BASES2, CLASSES2, ID, PEER;
6842
+ var ROLE_SECTIONS, BASES2, CLASSES2, ID, PEER2;
6812
6843
  var init_roles = __esm(() => {
6813
6844
  init_envelope();
6814
6845
  init_shipped_roles();
@@ -6816,7 +6847,7 @@ var init_roles = __esm(() => {
6816
6847
  BASES2 = ["planner", "implementer", "verifier", "reviewer"];
6817
6848
  CLASSES2 = ["plan", "implement", "bulk_edit", "test", "review", "summarize", "triage"];
6818
6849
  ID = /^[a-z][a-z0-9-]{0,47}$/;
6819
- PEER = /^[a-z][a-z0-9-]{0,31}$/;
6850
+ PEER2 = /^[a-z][a-z0-9-]{0,31}$/;
6820
6851
  });
6821
6852
 
6822
6853
  // src/hub/profiles.ts
@@ -6934,14 +6965,22 @@ function profileTaskTools(profile = "dev", stages, criteria = false) {
6934
6965
  const selectedPlan = { ...plan, properties: Object.fromEntries(data.plan_keys.map((key) => [key, plan.properties[key] ?? list(key === "sections" ? "document heading anchors, such as report.md#methods" : key === "sources" ? "citation ids, DOI or source URL ids" : "short claim ids to verify")])) };
6935
6966
  properties.parts = { ...parts, items: { ...parts.items, properties: { ...parts.items.properties, plan: selectedPlan } } };
6936
6967
  }
6968
+ if (criteria && properties.plan) {
6969
+ const current = properties.plan;
6970
+ properties.plan = { ...current, properties: { ...current.properties, ...costPlanProperties } };
6971
+ }
6972
+ if (criteria && tool.name === "hub_backlog_split") {
6973
+ const parts = properties.parts;
6974
+ properties.parts = { ...parts, items: { ...parts.items, properties: { ...parts.items.properties, plan: { ...parts.items.properties.plan, properties: { ...parts.items.properties.plan.properties, ...costPlanProperties } } } } };
6975
+ }
6937
6976
  return { ...tool, ...customProfile && tool.name === "hub_task_propose" ? { description: tool.description + ` Selected profile ${data.name}: ${Object.keys(data.classes).join(", ")}. Routing uses their existing base classes.` } : {}, inputSchema: { ...tool.inputSchema, properties } };
6938
6977
  });
6939
6978
  }
6940
- var ACTIONABLE_MESSAGE_INSTRUCTION = "Do not use [FYI] for PR ready, done, submitted, ruling needed, blocker, or changed contract or path messages: each requires action from the recipient. Use [FYI] only for progress or information that needs no action. hub_task_done and hub_review already notify the assigned recipients; no duplicate hub_send chat is needed.", str2, id, refs, list = (description) => ({ type: "array", items: str2, description }), plan, acceptPlan, tool = (name, description, properties, required = []) => ({
6979
+ var ACTIONABLE_MESSAGE_INSTRUCTION = "Do not use [FYI] for PR ready, done, submitted, ruling needed, blocker, or changed contract or path messages: each requires action from the recipient. Use [FYI] only for progress or information that needs no action. hub_task_done and hub_review already notify the assigned recipients; no duplicate hub_send chat is needed.", str2, id, refs, list = (description) => ({ type: "array", items: str2, description }), costPlanProperties, plan, acceptPlan, tool = (name, description, properties, required = []) => ({
6941
6980
  name,
6942
6981
  description,
6943
6982
  inputSchema: { type: "object", properties, required, additionalProperties: false }
6944
- }), BOUNDED_TASK_TOOLS, TASK_TOOLS, TASK_TOOL_NAMES, CONDUCTOR_TOOLS, CONDUCTOR_TOOL_NAMES, ROLE_TEXT, DEFAULT_ROLES;
6983
+ }), BOUNDED_TASK_TOOLS, TASK_TOOLS, PLANNER_REVIEW_TOOLS, TASK_TOOL_NAMES, CONDUCTOR_TOOLS, CONDUCTOR_TOOL_NAMES, ROLE_TEXT, DEFAULT_ROLES;
6945
6984
  var init_hub_tools = __esm(() => {
6946
6985
  init_sdlc();
6947
6986
  init_profiles();
@@ -6956,6 +6995,10 @@ var init_hub_tools = __esm(() => {
6956
6995
  properties: { branch: str2, commit: str2, paths: { type: "array", items: str2 } },
6957
6996
  additionalProperties: false
6958
6997
  };
6998
+ costPlanProperties = {
6999
+ review: { type: "object", properties: { scope: { type: "string", enum: ["none", "delta", "full"] }, reviewers: { type: "integer", minimum: 0, maximum: 8 } }, required: ["scope", "reviewers"], additionalProperties: false },
7000
+ ci: { type: "string", enum: ["focused", "full", "reuse"] }
7001
+ };
6959
7002
  plan = {
6960
7003
  type: "object",
6961
7004
  description: "what you will change, before you start. Owners of open tasks on the same files or symbols see it, and get a notice when your task is done.",
@@ -6988,7 +7031,7 @@ var init_hub_tools = __esm(() => {
6988
7031
  tool("hub_task_done", "Mark your task finished. It goes to its reviewer with your summary and refs; no duplicate hub_send chat is needed. When the project configures a check for its class, the hub runs it first and the result comes as a task message: a failed check keeps the task with you.", { id, summary: { type: "string", description: "what changed, why, and the check you ran with its result" }, refs }, ["id", "summary"]),
6989
7032
  tool("hub_sdlc_record", "Record artifact ids or claimed local checks on your task. This grants no authority or external-check evidence. Only its owner/reviewer or the person may record; only its reviewer/person may name a reviewed head or phase checklist. Phase evidence refs are content hashes, not external verification.", { id, receipt: { type: "object", properties: { stage: { type: "string", enum: ["spec", "plan", "implement", "review", "verify", "merge", "release"] }, repo: str2, issue: id, pr: id, localHead: str2, localResult: { type: "string", enum: ["passed", "failed", "unknown"] }, reviewedHead: str2, phase: { type: "object", properties: { id: str2, head: str2, tasks: { type: "array", maxItems: 64, items: { type: "object", properties: { id, after: { type: "array", maxItems: 16, items: id } }, required: ["id", "after"], additionalProperties: false } }, criteria: { type: "array", maxItems: 32, items: { type: "object", properties: { id: str2, required: { type: "boolean" }, result: { type: "string", enum: ["verified", "failed", "unverified"] }, refs: { type: "array", maxItems: 8, items: str2 } }, required: ["id", "required", "result", "refs"], additionalProperties: false } } }, required: ["id", "tasks", "criteria"], additionalProperties: false } }, additionalProperties: false } }, ["id", "receipt"]),
6990
7033
  tool("hub_task_list", "The task board. PII tasks show as [pii].", { state: { type: "string", enum: ["proposed", "in_progress", "in_review", "approved", "changes_requested", "cancelled"] }, ready: { type: "boolean", description: "only proposed tasks with nothing left to wait for" } }),
6991
- tool("hub_review", "Give your verdict on a task you were asked to review: map the changed signatures and call sites to the task's plan or detail, read the check result (hub_task_show has it, with the done summary), and list what is unmet. The verdict notifies the task owner; no duplicate hub_send chat is needed. Consecutive changes_requested verdicts (three by default, `tasks.escalate_after`) move the task to another peer.", { id, head: { type: "string", description: "full SHA actually reviewed; enforce binds approval to the current PR head" }, verdict: { type: "string", enum: ["approved", "changes_requested"] }, note: str2, unmet: { type: "array", items: str2, description: "each requirement of the plan or detail that the change does not meet" } }, ["id", "verdict"]),
7034
+ tool("hub_review", "Give your verdict on a task you were asked to review: map the changed signatures and call sites to the task's plan or detail, read the check result (hub_task_show has it, with the done summary), and list what is unmet. The verdict notifies the task owner; no duplicate hub_send chat is needed. Consecutive changes_requested verdicts (three by default, `tasks.escalate_after`) move the task to another peer.", { id, head: { type: "string", description: "full SHA actually reviewed; enforce binds approval to the current PR head" }, verdict: { type: "string", enum: ["approved", "changes_requested"] }, note: str2, unmet: { type: "array", items: str2, description: "each requirement of the plan or detail that the change does not meet" }, coverage: { type: "object", description: "the review brief's reviewable paths you read (reviewed) and any you did not (skipped, each with a reason); in enforce an approval must account for every one", properties: { reviewed: { type: "array", items: str2 }, skipped: { type: "array", items: { type: "object", properties: { path: str2, reason: str2 }, required: ["path", "reason"] } } } } }, ["id", "verdict"]),
6992
7035
  tool("hub_checkpoint", "Answer a checkpoint request from the hub (quota or context window pressure): what you were doing, what is half done, what whoever continues must know. Write the same to .agenthub/checkpoint.md first if you can.", { summary: str2, request_id: { type: "string", description: "The request id supplied by a context checkpoint request; required for context checkpoints" } }, ["summary"]),
6993
7036
  tool("hub_memory_search", "Search daemon-scoped visible memory, fails first. Query <=200 UTF-8 bytes; default 8, max 20 lines; whole output <=4 KB. Incomplete excerpts are marked. No project argument.", { query: { type: "string", maxLength: 200, description: "At most 200 UTF-8 bytes, enforced by the daemon" }, limit: { type: "integer", minimum: 1, maximum: 20 } }, ["query"]),
6994
7037
  tool("hub_memory_show", "Read up to 8 visible memory ids or unique prefixes (6..64 UTF-8 bytes each). Original ids redirect; unavailable ids share one refusal. Whole output <=8 KB.", { ids: { type: "array", minItems: 1, maxItems: 8, items: { type: "string", minLength: 6, maxLength: 64, description: "6 to 64 UTF-8 bytes" } } }, ["ids"]),
@@ -6996,6 +7039,13 @@ var init_hub_tools = __esm(() => {
6996
7039
  tool("hub_memory_correct", "Propose a correction to a visible memory entry. This creates a machine-local candidate only; a person accepts or rejects it with amem review. It never edits a note. id is an exact id or unique visible prefix; text and reason must each fit 4000 UTF-8 bytes.", { id: str2, text: str2, reason: str2 }, ["id", "text", "reason"]),
6997
7040
  tool("hub_remember", "Save a decision, finding, contract or fail to the memory all agents share (the hub's memory and claude-mem); the other agents also get it with their next message. A fail is an approach you tried that does not work, and why: the most useful note, it stops the others spending their quota on it. Do not retry what a fail note rules out without new evidence. Conclusions worth recalling, not chatter. For kind fail, optional approach, symptom, cause, evidence and retry_when strings compose fixed labeled text; text may be omitted when structured fields are supplied. The complete text must fit 4000 UTF-8 bytes and is refused, never truncated, when oversized. A note on an open task is short-term until the task is approved, when its decisions, contracts and fails become long-term; horizon long keeps it now, scope shared lets the person's other projects find it.", { text: str2, approach: str2, symptom: str2, cause: str2, evidence: str2, retry_when: str2, title: str2, kind: { type: "string", enum: [...NOTE_KINDS] }, task: id, horizon: { type: "string", enum: ["short", "long"] }, scope: { type: "string", enum: ["project", "shared"] } })
6998
7041
  ];
7042
+ PLANNER_REVIEW_TOOLS = [
7043
+ tool("hub_planner_groom", "Read a bounded planner grooming digest of dependencies, repository-scoped overlaps, WIP and blockers. No board change; unavailable evidence stays unknown.", {}),
7044
+ tool("hub_plan_review", "Record a planner review against the task planning revision and host-read issue edit time. Configured planner or person only; never accepts caller issue timestamps.", { id, revision: { type: "integer", minimum: 0 }, reason: { ...str2, maxLength: 1000 }, ...costPlanProperties }, ["id", "revision", "reason"]),
7045
+ tool("hub_sdlc_costs", "Read bounded review and CI cost metrics for linked merged PRs. Planner/person only; metadata only, no board writes. S7 owns automatic band proposals.", { repo: str2 }),
7046
+ tool("hub_backlog_return_untouched", "Return accepted but untouched work to the backlog with its history and planning retained. Refuses execution/branch/commit evidence; planner or person only.", { id, revision: { type: "integer", minimum: 0 }, reason: { ...str2, maxLength: 1000 } }, ["id", "revision", "reason"])
7047
+ ];
7048
+ TASK_TOOLS.push(...PLANNER_REVIEW_TOOLS);
6999
7049
  TASK_TOOL_NAMES = new Set(TASK_TOOLS.map((t) => t.name));
7000
7050
  CONDUCTOR_TOOLS = [
7001
7051
  tool("hub_status", "Inspect public team state, holds, quota windows, task counts and pending approval peer/tool/age. Only the configured conductor may use this tool.", {}),
@@ -7516,6 +7566,246 @@ var init_local_worker = __esm(() => {
7516
7566
  };
7517
7567
  });
7518
7568
 
7569
+ // src/hub/review-brief.ts
7570
+ var LOCKFILES;
7571
+ var init_review_brief = __esm(() => {
7572
+ init_child_process();
7573
+ init_git();
7574
+ LOCKFILES = new Set(["bun.lock", "bun.lockb", "package-lock.json", "yarn.lock", "pnpm-lock.yaml", "Cargo.lock", "poetry.lock", "uv.lock", "go.sum", "composer.lock", "Gemfile.lock"]);
7575
+ });
7576
+
7577
+ // src/adapters/claude-print.ts
7578
+ import { spawn as spawn6 } from "child_process";
7579
+ import { randomUUID as randomUUID5 } from "crypto";
7580
+ function claudePrintArgs(sessionId, mcpConfig, systemPrompt, privatePaths = []) {
7581
+ return [
7582
+ "-p",
7583
+ "--output-format",
7584
+ "stream-json",
7585
+ "--verbose",
7586
+ "--no-session-persistence",
7587
+ "--session-id",
7588
+ sessionId,
7589
+ "--strict-mcp-config",
7590
+ "--mcp-config",
7591
+ mcpConfig,
7592
+ "--setting-sources",
7593
+ "",
7594
+ "--tools",
7595
+ "Read,Grep,Glob",
7596
+ "--allowedTools",
7597
+ "mcp__agent-hub__hub_review",
7598
+ "mcp__agent-hub__hub_task_show",
7599
+ "Read",
7600
+ "Grep",
7601
+ "Glob",
7602
+ ...privatePaths.length ? ["--disallowedTools", ...readDenyRules(privatePaths)] : [],
7603
+ "--permission-mode",
7604
+ "dontAsk",
7605
+ "--append-system-prompt",
7606
+ systemPrompt
7607
+ ];
7608
+ }
7609
+ function readDenyRules(paths) {
7610
+ return paths.flatMap((entry) => {
7611
+ const rule = entry.startsWith("/") ? `/${entry}` : `./${entry.replace(/^\.\//, "")}`;
7612
+ return /[*?]/.test(entry) ? [`Read(${rule})`] : [`Read(${rule})`, `Read(${rule.replace(/\/$/, "")}/**)`];
7613
+ });
7614
+ }
7615
+ var GroupCleanupError, reviewTask = (env) => {
7616
+ if (env.from !== HUB || env.kind !== "review")
7617
+ return;
7618
+ const id = Number(env.refs?.task);
7619
+ return Number.isSafeInteger(id) && id > 0 ? id : undefined;
7620
+ }, ClaudePrintPeer;
7621
+ var init_claude_print = __esm(() => {
7622
+ init_child_process();
7623
+ init_git();
7624
+ init_review_brief();
7625
+ init_envelope();
7626
+ GroupCleanupError = class GroupCleanupError extends Error {
7627
+ };
7628
+ ClaudePrintPeer = class ClaudePrintPeer extends BasePeer {
7629
+ opts;
7630
+ hubNative = true;
7631
+ generation = 0;
7632
+ child;
7633
+ activeDeliveryId;
7634
+ reviewing;
7635
+ reviewSeq = 0;
7636
+ reviewToken(task) {
7637
+ return this.reviewing === task ? this.reviewSeq : undefined;
7638
+ }
7639
+ constructor(id, opts) {
7640
+ super(id, opts.watchdogMs);
7641
+ this.opts = opts;
7642
+ }
7643
+ recoveryMetadata() {
7644
+ return { launch: { kind: "claude-print", peer: this.id } };
7645
+ }
7646
+ async start() {
7647
+ if (this.child)
7648
+ throw new Error(`${this.id}: an earlier review's process group is still running; stop it first`);
7649
+ this.setState("idle");
7650
+ }
7651
+ async stop(reason) {
7652
+ this.generation++;
7653
+ this.settle("needs_review", reason ?? "review stopped before settlement");
7654
+ if (this.child)
7655
+ this.setState("busy");
7656
+ await this.kill();
7657
+ this.setState("offline");
7658
+ }
7659
+ onWatchdog() {
7660
+ this.generation++;
7661
+ this.settle("needs_review", "review watchdog timeout");
7662
+ this.kill().then(() => {
7663
+ if (this.state === "busy")
7664
+ super.onWatchdog();
7665
+ }, (error) => this.opts.log?.(`[${this.id}] watchdog stop failed, staying busy: ${error.message}`));
7666
+ }
7667
+ async deliver(envs, deliveryId) {
7668
+ if (this.state !== "idle") {
7669
+ if (deliveryId)
7670
+ this.delivery({ id: deliveryId, state: "failed_safe", reason: `${this.id} is ${this.state}` });
7671
+ throw new Error(`${this.id} is ${this.state}`);
7672
+ }
7673
+ const generation = ++this.generation;
7674
+ this.activeDeliveryId = deliveryId;
7675
+ this.setState("busy");
7676
+ if (deliveryId)
7677
+ this.delivery({ id: deliveryId, state: "accepted" });
7678
+ this.run(envs, generation).catch((error) => {
7679
+ if (generation !== this.generation)
7680
+ return;
7681
+ this.settle("needs_review", `claude reviewer failed: ${error.message}`);
7682
+ this.kill().then(() => this.setState("idle"), () => {
7683
+ return;
7684
+ });
7685
+ });
7686
+ }
7687
+ async run(envs, generation) {
7688
+ const missing = [];
7689
+ let started = false;
7690
+ let failure;
7691
+ for (const env of envs) {
7692
+ const task = reviewTask(env);
7693
+ if (task === undefined)
7694
+ continue;
7695
+ if (env.private) {
7696
+ failure = { state: "needs_review", reason: "a private review is the person's, never a cloud reviewer's" };
7697
+ break;
7698
+ }
7699
+ const since = Date.now();
7700
+ let code;
7701
+ try {
7702
+ code = await this.review(env, task, generation);
7703
+ } catch (error) {
7704
+ if (generation !== this.generation)
7705
+ return;
7706
+ if (error instanceof GroupCleanupError) {
7707
+ this.opts.log?.(`[${this.id}] review of task #${task}: ${error.message}`);
7708
+ this.settle("needs_review", `review process group still running: ${error.message}`);
7709
+ return;
7710
+ }
7711
+ failure = { state: started ? "needs_review" : "failed_safe", reason: `claude reviewer could not start: ${error.message}` };
7712
+ break;
7713
+ }
7714
+ started = true;
7715
+ if (generation !== this.generation)
7716
+ return;
7717
+ let recorded = false;
7718
+ try {
7719
+ recorded = this.opts.verdictSince(task, since);
7720
+ } catch (error) {
7721
+ this.opts.log?.(`[${this.id}] verdict check for task #${task} failed: ${error.message}`);
7722
+ }
7723
+ if (!recorded)
7724
+ missing.push(task);
7725
+ if (code !== 0)
7726
+ this.opts.log?.(`[${this.id}] review of task #${task} exited ${code}`);
7727
+ }
7728
+ if (generation !== this.generation)
7729
+ return;
7730
+ this.settle(failure?.state ?? (missing.length ? "needs_review" : "completed"), failure?.reason ?? (missing.length ? `no verdict recorded for task ${missing.map((id) => `#${id}`).join(", ")}` : undefined));
7731
+ this.setState("idle");
7732
+ }
7733
+ review(env, task, generation) {
7734
+ const [cmd = "claude", ...prefix] = this.opts.command ?? ["claude"];
7735
+ const args = [...prefix, ...claudePrintArgs(randomUUID5(), this.opts.mcpConfig, this.opts.systemPrompt, this.opts.privatePaths)];
7736
+ return new Promise((resolve, reject) => {
7737
+ const child = spawn6(cmd, args, { cwd: this.opts.cwd, detached: true, env: peerChildEnv(this.id), stdio: ["pipe", "pipe", "ignore"] });
7738
+ this.child = child;
7739
+ child.once("error", (error) => {
7740
+ if (this.child === child)
7741
+ this.child = undefined;
7742
+ reject(error);
7743
+ });
7744
+ child.stdin.on("error", () => {});
7745
+ child.once("spawn", () => {
7746
+ trackGroup(child);
7747
+ if (generation !== this.generation) {
7748
+ child.stdin.destroy();
7749
+ return;
7750
+ }
7751
+ this.reviewing = task;
7752
+ this.reviewSeq++;
7753
+ let facts;
7754
+ try {
7755
+ facts = this.opts.facts?.(env);
7756
+ } catch (error) {
7757
+ this.opts.log?.(`[${this.id}] review facts for task #${task} failed: ${error.message}`);
7758
+ }
7759
+ child.stdin.end(`${frame(env)}${facts ? `
7760
+
7761
+ ${facts}` : ""}`);
7762
+ });
7763
+ let buffer = "";
7764
+ child.stdout.on("data", (chunk) => {
7765
+ if (generation !== this.generation)
7766
+ return;
7767
+ this.touch();
7768
+ buffer += chunk.toString("utf8");
7769
+ for (let i;(i = buffer.indexOf(`
7770
+ `)) >= 0; ) {
7771
+ const line = buffer.slice(0, i);
7772
+ buffer = buffer.slice(i + 1);
7773
+ try {
7774
+ const event = JSON.parse(line);
7775
+ if (event.type === "result" && event.usage)
7776
+ this.opts.onUsage?.(event.usage, task);
7777
+ } catch {}
7778
+ }
7779
+ });
7780
+ child.once("exit", (code) => {
7781
+ if (this.child === child)
7782
+ this.reviewing = undefined;
7783
+ stopOwnedProcess(child, { group: true }).then(() => {
7784
+ if (this.child === child)
7785
+ this.child = undefined;
7786
+ resolve(code);
7787
+ }, (error) => reject(new GroupCleanupError(error.message)));
7788
+ });
7789
+ });
7790
+ }
7791
+ settle(state, reason) {
7792
+ const id = this.activeDeliveryId;
7793
+ this.activeDeliveryId = undefined;
7794
+ if (id)
7795
+ this.delivery({ id, state, ...reason ? { reason } : {} });
7796
+ }
7797
+ async kill() {
7798
+ this.reviewing = undefined;
7799
+ const child = this.child;
7800
+ if (!child)
7801
+ return;
7802
+ await stopOwnedProcess(child, { group: true });
7803
+ if (this.child === child)
7804
+ this.child = undefined;
7805
+ }
7806
+ };
7807
+ });
7808
+
7519
7809
  // src/adapters/definitions/native-factories.ts
7520
7810
  function createCodex(ctx) {
7521
7811
  if (!("codex" in ctx.daemon))
@@ -7557,6 +7847,12 @@ function createLocal(ctx) {
7557
7847
  init_local_worker();
7558
7848
  return new LocalPeer(ctx.instance, ctx.daemon.local);
7559
7849
  }
7850
+ function createClaudeReviewer(ctx) {
7851
+ if (!("claudePrint" in ctx.daemon))
7852
+ throw new Error("Claude reviewer requires its print transport options");
7853
+ init_claude_print();
7854
+ return new ClaudePrintPeer(ctx.instance, ctx.daemon.claudePrint);
7855
+ }
7560
7856
  var DAEMON_PORTS;
7561
7857
  var init_native_factories = __esm(() => {
7562
7858
  DAEMON_PORTS = {
@@ -7564,7 +7860,8 @@ var init_native_factories = __esm(() => {
7564
7860
  acp: (options) => ({ acp: options }),
7565
7861
  "owned-acp": (options) => ({ jcode: options }),
7566
7862
  "pi-rpc": (options) => ({ pi: options }),
7567
- "local-worker": (options) => ({ local: options })
7863
+ "local-worker": (options) => ({ local: options }),
7864
+ "claude-print": (options) => ({ claudePrint: options })
7568
7865
  };
7569
7866
  });
7570
7867
 
@@ -7781,6 +8078,7 @@ function sessionSettings(tee, facts) {
7781
8078
  }
7782
8079
  var CLAUDE_CHANNEL = "plugin:agent-hub@agent-hub", UNATTENDED_WARNING = "WARNING: --unattended disables permission prompts. Other agents' messages are untrusted input and this agent will act on its own judgment without asking you.", sh = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
7783
8080
  var init_launch2 = __esm(() => {
8081
+ init_claude_state();
7784
8082
  init_process_signature();
7785
8083
  init_project();
7786
8084
  init_child_process();
@@ -7812,7 +8110,7 @@ var init_native_cli_launch_common = __esm(() => {
7812
8110
 
7813
8111
  // src/adapters/definitions/native-cli-claude-launch.ts
7814
8112
  import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync2, renameSync, unlinkSync, readFileSync as readFileSync7 } from "fs";
7815
- import { randomUUID as randomUUID5 } from "crypto";
8113
+ import { randomUUID as randomUUID6 } from "crypto";
7816
8114
  import { resolve as resolve11, join as join17 } from "path";
7817
8115
  function claudeChannel(userArgs) {
7818
8116
  const bundle = resolve11(import.meta.dir, "../../../plugins/agent-hub/server.js");
@@ -7875,7 +8173,7 @@ function claudeNativeLaunch(ctx) {
7875
8173
  const stateDir = ctx.facts?.stateDir ?? ctx.statusLine?.stateDir;
7876
8174
  if (!stateDir)
7877
8175
  throw new Error("Claude session settings require the hub state directory");
7878
- const file = join17(stateDir, `claude-settings-${randomUUID5()}.json`);
8176
+ const file = join17(stateDir, `claude-settings-${randomUUID6()}.json`);
7879
8177
  try {
7880
8178
  mkdirSync4(stateDir, { recursive: true, mode: 448 });
7881
8179
  writeFileSync2(`${file}.tmp`, settingsArgs[1], { mode: 384, flag: "wx" });
@@ -8138,7 +8436,7 @@ function sessionId(field) {
8138
8436
  return typeof value === "string" && value ? value : undefined;
8139
8437
  };
8140
8438
  }
8141
- var terminal = (form) => form === "tui", headless = () => false, switches, implementerRoles, dualForms, headlessForms, hubGrants, NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN, palette = (fg) => ({ palette: { fg, bg: "transparent" } }), BUILTIN_DEFINITIONS_WITHOUT_QWEN;
8439
+ var terminal = (form) => form === "tui", headless = () => false, switches, implementerRoles, dualForms, headlessForms, hubGrants, CLAUDE_REVIEWER_VARIANT, NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN, palette = (fg) => ({ palette: { fg, bg: "transparent" } }), BUILTIN_DEFINITIONS_WITHOUT_QWEN;
8142
8440
  var init_builtin_metadata = __esm(() => {
8143
8441
  init_native_config_policy();
8144
8442
  init_native_policy_aux();
@@ -8156,6 +8454,27 @@ var init_builtin_metadata = __esm(() => {
8156
8454
  "ask-when-needed": "write and edit inside the project run without asking; shell and git writes still ask",
8157
8455
  "never-ask": "every tool runs without asking, inside the hub sandbox and path guard"
8158
8456
  };
8457
+ CLAUDE_REVIEWER_VARIANT = {
8458
+ api: 1,
8459
+ kind: "claude",
8460
+ displayName: "Claude Code reviewer",
8461
+ family: "claude",
8462
+ transport: "hub-native",
8463
+ roles: Object.freeze(["reviewer"]),
8464
+ defaultOnRoster: true,
8465
+ trust: { onPrem: false, hubSandboxed: false },
8466
+ start: { create: createClaudeReviewer, daemonPort: "claude-print", forms: headlessForms, defaultForm: "headless", ownsTerminal: headless, launch: (ctx) => compatibilityLaunch("claude", ctx), conductorStartable: true, hubSwitches: [], ownedFlags: [] },
8467
+ identity: { mcp: "env" },
8468
+ permission: { modes: {}, unmanaged: "each review runs read-only (Read, Grep, Glob) and may call only hub_review and hub_task_show" },
8469
+ session: { idField: "sessionId", currentId: () => {
8470
+ return;
8471
+ }, freshOnRestore: true, launchKind: "claude-print", crashPlan: () => ({ resume: {}, how: "claude reviewer: starts again; every review runs in a new session" }) },
8472
+ turnEnd: "adapter",
8473
+ budget: { account: "claude" },
8474
+ routing: { reviewOnly: true },
8475
+ cli: { command: "claude", startWords: () => ["claude"] },
8476
+ display: CLAUDE_DISPLAY
8477
+ };
8159
8478
  NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN = Object.freeze([
8160
8479
  {
8161
8480
  api: 1,
@@ -8190,7 +8509,8 @@ var init_builtin_metadata = __esm(() => {
8190
8509
  budget: { account: "claude", checkpoint: true, reader: { source: "claude status line", read: claudeWindows }, context: { source: "claude_statusline", read: claudeContext } },
8191
8510
  cli: { command: "claude", retainedTerminalNotice: claudeRetainedTerminalNotice, stopNotice: () => "this peer belongs to a native terminal; end it in its terminal", startWords: () => ["claude"] },
8192
8511
  display: CLAUDE_DISPLAY,
8193
- hooks: { recallPlatform: "claude", recallSource: "claude", recallInjected: true, pluginInventory: claudePluginInventory }
8512
+ hooks: { recallPlatform: "claude", recallSource: "claude", recallInjected: true, pluginInventory: claudePluginInventory },
8513
+ reviewerVariant: CLAUDE_REVIEWER_VARIANT
8194
8514
  },
8195
8515
  {
8196
8516
  api: 1,
@@ -8595,6 +8915,12 @@ function immutable(value, seen = new WeakSet) {
8595
8915
  immutable(member, seen);
8596
8916
  return Object.freeze(value);
8597
8917
  }
8918
+ function ownField(value, key) {
8919
+ if (!value || typeof value !== "object" || types.isProxy(value) || Array.isArray(value) || ![Object.prototype, null].includes(Object.getPrototypeOf(value)))
8920
+ return;
8921
+ const field = Object.getOwnPropertyDescriptor(value, key);
8922
+ return field && Object.hasOwn(field, "value") && field.enumerable ? field.value : undefined;
8923
+ }
8598
8924
  function declaredKind(id, instances) {
8599
8925
  if (!instances || typeof instances !== "object" || types.isProxy(instances) || Array.isArray(instances) || ![Object.prototype, null].includes(Object.getPrototypeOf(instances)))
8600
8926
  return;
@@ -8615,7 +8941,8 @@ function kindOf(id, instances) {
8615
8941
  }
8616
8942
  function definitionOf(id, instances) {
8617
8943
  const kind = kindOf(id, instances);
8618
- return kind === undefined ? undefined : definitions.get(kind);
8944
+ const definition = kind === undefined ? undefined : definitions.get(kind);
8945
+ return definition?.reviewerVariant && kind !== id && ownField(ownField(instances, id), "start_mode") === "headless" ? definition.reviewerVariant : definition;
8619
8946
  }
8620
8947
  function defaultPeerRoles(includeOptional = false) {
8621
8948
  return Object.fromEntries(ROLE_ORDER.filter((peer) => includeOptional || !definitionOf(peer).optional).map((peer) => [peer, [...definitionOf(peer).roles]]));
@@ -8635,7 +8962,8 @@ function assertNativeCapabilityScope(definition) {
8635
8962
  ["budget.resetSoftTokensOnResume", declared(definition.budget, "resetSoftTokensOnResume")],
8636
8963
  ["session.crashIdentity", declared(definition.session, "crashIdentity")],
8637
8964
  ["start.daemonPort pi-rpc", definition.start.daemonPort === "pi-rpc"],
8638
- ["routing", definition.routing !== undefined]
8965
+ ["routing", definition.routing !== undefined],
8966
+ ["reviewerVariant", definition.reviewerVariant !== undefined]
8639
8967
  ];
8640
8968
  const field = restricted.find(([, present]) => present)?.[0];
8641
8969
  if (field)
@@ -8655,6 +8983,9 @@ var init_registry = __esm(() => {
8655
8983
  OPTIONAL_PEERS = ["qwen", "jcode"];
8656
8984
  SUPPORTED_PEERS = [...KNOWN_PEERS, ...OPTIONAL_PEERS];
8657
8985
  fixedBuiltinDefinitions = new Set([...NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN, QWEN_DEFINITION]);
8986
+ for (const definition of [...fixedBuiltinDefinitions])
8987
+ if (definition.reviewerVariant)
8988
+ fixedBuiltinDefinitions.add(definition.reviewerVariant);
8658
8989
  ADAPTERS = immutable([
8659
8990
  ...NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN.filter((definition) => definition.kind !== "jcode"),
8660
8991
  QWEN_DEFINITION,
@@ -8664,6 +8995,10 @@ var init_registry = __esm(() => {
8664
8995
  for (const definition of ADAPTERS) {
8665
8996
  assertDaemonEngine(definition);
8666
8997
  assertNativeCapabilityScope(definition);
8998
+ if (definition.reviewerVariant) {
8999
+ assertDaemonEngine(definition.reviewerVariant);
9000
+ assertNativeCapabilityScope(definition.reviewerVariant);
9001
+ }
8667
9002
  if (definition.api !== ADAPTER_API || definitions.has(definition.kind))
8668
9003
  throw new Error("invalid builtin adapter registry");
8669
9004
  definitions.set(definition.kind, definition);
@@ -15779,10 +16114,17 @@ import { join as join22 } from "path";
15779
16114
  init_registry();
15780
16115
  init_hub_tools();
15781
16116
  init_envelope();
16117
+
16118
+ // src/hub/spec-read.ts
16119
+ init_host_executable();
16120
+ init_child_process();
16121
+ var BODY_LIMIT = 64 * 1024;
16122
+ var RESULT_LIMIT = 80 * 1024;
16123
+ var STDOUT_LIMIT = 512 * 1024;
15782
16124
  // package.json
15783
16125
  var package_default = {
15784
16126
  name: "@staix/agent-hub",
15785
- version: "0.12.35",
16127
+ version: "0.12.36",
15786
16128
  description: "Native multi-agent hub: Claude Code, Codex, Kimi Code, Pi and local inference as peers in one project",
15787
16129
  license: "MIT",
15788
16130
  type: "module",
@@ -15902,13 +16244,6 @@ init_registry();
15902
16244
  init_board();
15903
16245
  import { join as join20, resolve as resolve12 } from "path";
15904
16246
 
15905
- // src/hub/spec-read.ts
15906
- init_host_executable();
15907
- init_child_process();
15908
- var BODY_LIMIT = 64 * 1024;
15909
- var RESULT_LIMIT = 80 * 1024;
15910
- var STDOUT_LIMIT = 512 * 1024;
15911
-
15912
16247
  // src/models/route/config.ts
15913
16248
  var TYPES = new Set(["stage", "plan_execute", "advisor", "escalation"]);
15914
16249
 
@@ -15926,7 +16261,13 @@ init_workflow();
15926
16261
  init_board();
15927
16262
  init_envelope();
15928
16263
  init_project();
16264
+ init_review_brief();
15929
16265
  init_registry();
16266
+
16267
+ // src/hub/task-sweep.ts
16268
+ var GROOMING_CODES = new Set(["tests", "spec", "roster", "pull-only", "not-attached", "excluded", "owner-admission", "offline", "paused", "failing", "pii", "policy", "cancelled", "unavailable", "runner", "approvals", "quota", "dependency"]);
16269
+
16270
+ // src/hub/tasks.ts
15930
16271
  init_sdlc();
15931
16272
  var DEMOTION_HALF_LIFE_MS = 24 * 3600000;
15932
16273
  var CONTRADICTION_WINDOW_MS = 7 * 86400000;
@@ -16041,8 +16382,23 @@ function validateInstances(raw) {
16041
16382
  return fail(`${path}.kind`, "unknown native kind");
16042
16383
  if (!id.startsWith(prefix) || !/^[a-z0-9][a-z0-9-]*$/.test(id.slice(prefix.length)))
16043
16384
  return fail(path, "id must be <kind>-<name>");
16044
- if (!definition.start.create || !definition.start.forms.includes("headless") || !["env", "acp-session"].includes(definition.identity.mcp))
16045
- return fail(`${path}.kind`, "unsupported in I2; a headless factory with env or ACP-session identity is required, not channel-only or project-file identity");
16385
+ const channel = definition.identity.mcp === "channel-plugin";
16386
+ if (!channel && (!definition.start.create || !definition.start.forms.includes("headless") || !["env", "acp-session"].includes(definition.identity.mcp)))
16387
+ return fail(`${path}.kind`, "unsupported; a headless factory with env or ACP-session identity, or a channel-plugin kind, is required, not project-file identity");
16388
+ if (channel) {
16389
+ const declared = entry.roles ? roles(entry.roles.value, `${path}.roles`) : [];
16390
+ if (entry.start_mode && definition.reviewerVariant) {
16391
+ if (entry.worktree)
16392
+ return fail(path, "a headless reviewer reads the project checkout: worktree is not supported");
16393
+ if (declared.length !== 1 || declared[0] !== "reviewer")
16394
+ return fail(`${path}.roles`, 'a headless channel-kind instance is a reviewer: roles must be exactly ["reviewer"]');
16395
+ } else {
16396
+ if (entry.start_mode || entry.worktree)
16397
+ return fail(path, `a channel instance starts only from the person's terminal (ahub <kind> --as <id>): start_mode and worktree are not supported; a headless reviewer declares roles ["reviewer"] and start_mode "headless"`);
16398
+ if (!declared.length || declared.some((role) => role !== "reviewer" && role !== "planner"))
16399
+ return fail(`${path}.roles`, "a channel instance must declare its roles: reviewer and/or planner");
16400
+ }
16401
+ }
16046
16402
  const value = { kind };
16047
16403
  if (entry.roles) {
16048
16404
  value.roles = roles(entry.roles.value, `${path}.roles`);