@staix/agent-hub 0.12.43 → 0.12.44

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 (89) hide show
  1. package/CHANGELOG.md +16 -3
  2. package/README.md +3 -3
  3. package/docs/adding-an-ai-tool.md +140 -0
  4. package/docs/agent-notes/README.md +5 -0
  5. package/docs/agent-notes/adapters.md +8 -3
  6. package/docs/agent-notes/budget.md +2 -2
  7. package/docs/agent-notes/daemon.md +9 -5
  8. package/docs/agent-notes/tasks.md +6 -2
  9. package/docs/agent-notes/tests.md +3 -1
  10. package/docs/operations.md +64 -5
  11. package/docs/quickstart.md +6 -2
  12. package/docs/security.md +5 -3
  13. package/docs/specs/2026-09-19-agent-hub-design.md +160 -4
  14. package/docs/verification/2026-10-21-0.12.44.md +54 -0
  15. package/docs/verification/2026-10-21-grok-config-acp-t0.md +30 -0
  16. package/docs/verified.json +34 -19
  17. package/package.json +1 -1
  18. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  19. package/plugins/agent-hub/server.js +743 -229
  20. package/src/adapters/acp-owned-group.ts +124 -0
  21. package/src/adapters/acp.ts +92 -25
  22. package/src/adapters/agy/definition.ts +1 -3
  23. package/src/adapters/agy/entry.ts +4 -1
  24. package/src/adapters/channel-peer.ts +143 -0
  25. package/src/adapters/claude-channel.ts +7 -7
  26. package/src/adapters/codex-appserver.ts +16 -2
  27. package/src/adapters/configured-acp.ts +124 -0
  28. package/src/adapters/definitions/builtin-metadata.ts +37 -33
  29. package/src/adapters/definitions/native-channel-state.ts +41 -0
  30. package/src/adapters/definitions/native-cli-entry-kimi.ts +3 -1
  31. package/src/adapters/definitions/native-cli-preview-qwen.ts +1 -1
  32. package/src/adapters/definitions/native-identity-defaults.ts +14 -0
  33. package/src/adapters/definitions/qwen.ts +3 -8
  34. package/src/adapters/definitions/reviewer-variants.ts +3 -7
  35. package/src/adapters/jcode.ts +4 -0
  36. package/src/adapters/local-worker.ts +4 -0
  37. package/src/adapters/pi.ts +4 -0
  38. package/src/adapters/registry-types.ts +18 -16
  39. package/src/adapters/registry.ts +68 -6
  40. package/src/adapters/reviewer-print.ts +4 -0
  41. package/src/cli/agy-mcp.ts +3 -2
  42. package/src/cli/configured-acp-entry.ts +58 -0
  43. package/src/cli/console-state.ts +9 -4
  44. package/src/cli/facts-hook.ts +2 -1
  45. package/src/cli/help.ts +6 -0
  46. package/src/cli/identity.ts +7 -6
  47. package/src/cli/launch.ts +5 -4
  48. package/src/cli/main.ts +78 -27
  49. package/src/cli/memory.ts +13 -10
  50. package/src/cli/output.ts +1 -0
  51. package/src/cli/preview.ts +9 -0
  52. package/src/cli/recovery-guidance.ts +1 -1
  53. package/src/cli/terminal-recovery.ts +16 -13
  54. package/src/cli/upgrade-restore-state.ts +3 -2
  55. package/src/cli/upgrade-runtime.ts +21 -6
  56. package/src/cli/upgrade.ts +3 -2
  57. package/src/hub/board.ts +9 -6
  58. package/src/hub/budget.ts +9 -2
  59. package/src/hub/bus.ts +6 -2
  60. package/src/hub/claude-state.ts +2 -37
  61. package/src/hub/conductor.ts +8 -6
  62. package/src/hub/config-apply.ts +26 -14
  63. package/src/hub/config-reload.ts +39 -11
  64. package/src/hub/config-trust.ts +1 -1
  65. package/src/hub/context-window.ts +6 -0
  66. package/src/hub/control-client.ts +1 -1
  67. package/src/hub/crash.ts +8 -6
  68. package/src/hub/daemon.ts +341 -313
  69. package/src/hub/hub-tools.ts +36 -0
  70. package/src/hub/instance-config.ts +20 -11
  71. package/src/hub/instance-execution.ts +4 -4
  72. package/src/hub/instances.ts +45 -21
  73. package/src/hub/lifecycle-schema.ts +5 -3
  74. package/src/hub/memory-profile.ts +16 -3
  75. package/src/hub/no-writes.ts +50 -0
  76. package/src/hub/permission-mode.ts +9 -7
  77. package/src/hub/roster.ts +7 -4
  78. package/src/hub/routing.ts +21 -20
  79. package/src/hub/settings.ts +50 -16
  80. package/src/hub/snapshots.ts +26 -2
  81. package/src/hub/start-mode.ts +10 -8
  82. package/src/hub/task-sweep.ts +8 -2
  83. package/src/hub/tasks.ts +85 -17
  84. package/src/memory/capture.ts +2 -1
  85. package/src/ui/index.html +5 -0
  86. package/templates/AGENTS.block.md +1 -1
  87. package/templates/profiles/general.json +1 -1
  88. package/templates/profiles/research.json +1 -1
  89. package/templates/profiles/writing.json +1 -1
@@ -105,6 +105,42 @@ export const CONDUCTOR_TOOLS: HubTool[] = [
105
105
  ];
106
106
  export const CONDUCTOR_TOOL_NAMES = new Set(CONDUCTOR_TOOLS.map((t) => t.name));
107
107
 
108
+ /** Authenticated status metadata only; malformed values never add a tool target. */
109
+ function projectPeerIds(value: unknown): string[] | undefined {
110
+ if (!Array.isArray(value) || value.length > 256 || value.some(id => typeof id !== "string" || !/^[a-z][a-z0-9-]{0,31}$/.test(id) || ["user", "hub", "digest"].includes(id)) || new Set(value).size !== value.length) return undefined;
111
+ return [...value] as string[];
112
+ }
113
+
114
+ /** A project-local schema projection; released builtin arrays and all other tools keep their identity. */
115
+ export function projectConductorTools(startPeers?: unknown): HubTool[] {
116
+ const peers = startPeers === undefined ? undefined : projectPeerIds(startPeers);
117
+ if (peers === undefined) return CONDUCTOR_TOOLS;
118
+ return CONDUCTOR_TOOLS.map(tool => tool.name !== "hub_peer_start" ? tool : {
119
+ ...tool, inputSchema: { ...tool.inputSchema, properties: { ...tool.inputSchema.properties,
120
+ peer: { ...(tool.inputSchema.properties.peer as Record<string, unknown>), enum: [...new Set([...CONDUCTOR_START_PEERS, ...peers])] },
121
+ } },
122
+ });
123
+ }
124
+
125
+ export interface ProjectToolContext { startPeers?: string[]; readOnly?: boolean }
126
+ /** Ordinary projects retain their exact prior publication context; configured metadata is body-free and bounded. */
127
+ export function configuredToolContext(status: unknown, peer: string): ProjectToolContext {
128
+ if (!status || typeof status !== "object" || Array.isArray(status)) return {};
129
+ const raw = status as Record<string, unknown>, configured = projectPeerIds(raw.configuredAdapters);
130
+ if (!configured?.length) return {};
131
+ const startPeers = projectPeerIds(raw.startablePeers), readOnlyPeers = projectPeerIds(raw.readOnlyPeers);
132
+ // With an incomplete configured-project reading, publish only R2 reads until authenticated status is complete.
133
+ if (!startPeers || !readOnlyPeers) return { readOnly: true };
134
+ return { startPeers, readOnly: readOnlyPeers.includes(peer) };
135
+ }
136
+
137
+ const READ_ONLY_PROJECT_TOOLS = Object.freeze(["hub_send", "hub_inbox", "hub_delivery_done", "hub_review", "hub_task_show", "hub_task_list", "hub_memory_search", "hub_memory_show", "hub_memory_graph", "hub_status"]);
138
+ /** Publication restricts exposure only; server-side authority remains mandatory for every call. */
139
+ export function projectToolList<T extends { name: string }>(tools: T[], readOnly = false): T[] {
140
+ return readOnly ? tools.filter(tool => READ_ONLY_PROJECT_TOOLS.includes(tool.name)) : tools;
141
+ }
142
+
143
+
108
144
  /** Role contracts, by role name. Shown to each peer for the roles `.agenthub/config.json` gives it. */
109
145
  export const ROLE_TEXT: Record<string, string> = {
110
146
  conductor: "conductor: plan and split work into tasks with owners, watch the team with hub_status, move stalled work, and ensure every task is reviewed (review it yourself only if you also hold reviewer). Report results and open decisions to the person. Do not implement tasks you handed out. Never ask a peer to answer an approval; ask the person for human-only actions. You may release only holds you placed, never human holds or budget pauses.",
@@ -1,6 +1,6 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { defaultPeerRoles } from "../adapters/registry.ts";
3
+ import { createProjectAdapterCatalog, defaultPeerRoles, definitionOf, type ProjectAdapterCatalog } from "../adapters/registry.ts";
4
4
  import { conductorPeer } from "./conductor.ts";
5
5
  import { configRefusal } from "./config-trust.ts";
6
6
  import { validateInstances, type InstancesConfig } from "./instances.ts";
@@ -17,24 +17,33 @@ export function readInstanceConfigLayers(cwd: string): InstanceConfigLayer[] {
17
17
  }
18
18
 
19
19
  /** Declaration authority is file provenance, never a suffix or a claim made by a native child. */
20
- export function readInstanceConfig(cwd: string, layers: readonly InstanceConfigLayer[] = readInstanceConfigLayers(cwd)): { instances?: InstancesConfig; ignored: string[] } {
20
+ export interface InstanceRoleConfig {
21
+ adapters?: Readonly<Record<string, import("../adapters/configured-acp.ts").ConfiguredAcpAdapterConfig>>;
22
+ instances?: InstancesConfig;
23
+ catalog: ProjectAdapterCatalog;
24
+ ignored: string[];
25
+ }
26
+ export function readInstanceConfig(cwd: string, layers: readonly InstanceConfigLayer[] = readInstanceConfigLayers(cwd)): InstanceRoleConfig {
21
27
  const ignored: string[] = [];
22
- let instances: InstancesConfig | undefined;
28
+ let adapters: Readonly<Record<string, import("../adapters/configured-acp.ts").ConfiguredAcpAdapterConfig>> | undefined, rawInstances: unknown;
23
29
  for (const { name, raw } of layers) {
24
- const checked = validateInstances(raw.instances);
25
- if (!Object.keys(checked).length) continue;
26
- if (name !== "config.local.json") { ignored.push(`instances in .agenthub/${name} ignored: instances are machine-local config.local.json declarations`); continue; }
27
- const refusal = configRefusal(cwd, name);
28
- if (refusal) ignored.push(`instances in .agenthub/${name} ignored: ${refusal}`);
29
- else instances = checked;
30
+ const keys = ["adapters", "instances"].filter(key => raw[key] !== undefined);
31
+ if (!keys.length) continue;
32
+ const refusal = name !== "config.local.json" ? "declarations are machine-local config.local.json values" : configRefusal(cwd, name);
33
+ if (refusal) { ignored.push(`${keys.join(", ")} in .agenthub/${name} ignored: ${refusal}`); continue; }
34
+ if (raw.adapters !== undefined) adapters = raw.adapters as Readonly<Record<string, import("../adapters/configured-acp.ts").ConfiguredAcpAdapterConfig>>;
35
+ if (raw.instances !== undefined) rawInstances = raw.instances;
30
36
  }
31
- return { ...(instances ? { instances } : {}), ignored };
37
+ const catalog = createProjectAdapterCatalog(adapters);
38
+ const instances = validateInstances(rawInstances, catalog);
39
+ return { ...(adapters !== undefined ? { adapters } : {}), ...(Object.keys(instances).length ? { instances } : {}), catalog, ignored };
32
40
  }
33
41
 
34
42
  /** Capture ordinary role policy and trusted instance declarations together, without loading daemon configuration. */
35
- export function readInstanceRoleConfig(cwd: string, layers: readonly InstanceConfigLayer[] = readInstanceConfigLayers(cwd)): { instances?: InstancesConfig; roles: Record<string, string[]>; ignored: string[] } {
43
+ export function readInstanceRoleConfig(cwd: string, layers: readonly InstanceConfigLayer[] = readInstanceConfigLayers(cwd)): InstanceRoleConfig & { roles: Record<string, string[]> } {
36
44
  const declarationConfig = readInstanceConfig(cwd, layers);
37
45
  const roles = defaultPeerRoles(false);
46
+ for (const definition of declarationConfig.catalog.definitions) if (!definitionOf(definition.kind)) roles[definition.kind] = [...definition.roles];
38
47
  for (const name of INSTANCE_CONFIG_FILES) {
39
48
  const raw = layers.find(layer => layer.name === name)?.raw;
40
49
  if (raw?.roles !== undefined) {
@@ -1,6 +1,6 @@
1
1
  import { lstatSync } from "node:fs";
2
2
  import { join, resolve } from "node:path";
3
- import { definitionOf } from "../adapters/registry.ts";
3
+ import type { ProjectAdapterCatalog } from "../adapters/registry.ts";
4
4
  import { hubGitSync } from "./git.ts";
5
5
  import { realPath } from "./project.ts";
6
6
  import { validateInstances, type InstancesConfig } from "./instances.ts";
@@ -15,9 +15,9 @@ function git(cwd: string, args: string[]): string {
15
15
  const commonDirectory = (cwd: string): string => realPath(git(cwd, ["rev-parse", "--path-format=absolute", "--git-common-dir"]).replace(/\n$/, ""));
16
16
 
17
17
  /** Point-in-time read only. No worktree is created, repaired or chosen implicitly. */
18
- export function resolveInstanceExecutionCwd(projectRoot: string, id: string, declarations: InstancesConfig | undefined, roles: readonly string[]): string {
19
- if (definitionOf(id)) return projectRoot;
20
- const declaration = validateInstances(declarations)[id];
18
+ export function resolveInstanceExecutionCwd(projectRoot: string, id: string, declarations: InstancesConfig | undefined, roles: readonly string[], adapterCatalog: ProjectAdapterCatalog): string {
19
+ if (adapterCatalog.definitionOf(id)) return projectRoot;
20
+ const declaration = validateInstances(declarations, adapterCatalog)[id];
21
21
  if (!declaration) return refuse("extra instance is not declared");
22
22
  const implementer = roles.includes("implementer");
23
23
  if (!declaration.worktree) {
@@ -1,5 +1,5 @@
1
1
  import { types } from "node:util";
2
- import { ADAPTERS, defaultPeerRoles, definitionOf, type AdapterDefinition, type StartForm } from "../adapters/registry.ts";
2
+ import { defaultPeerRoles, definitionOf, type AdapterDefinition, type StartForm, type ProjectAdapterCatalog } from "../adapters/registry.ts";
3
3
  import { isPermissionMode, type PermissionMode } from "./permission-mode.ts";
4
4
 
5
5
  export interface InstanceConfig {
@@ -9,6 +9,7 @@ export interface InstanceConfig {
9
9
  permission_mode?: PermissionMode;
10
10
  review_mode?: "fresh";
11
11
  worktree?: string;
12
+ readOnly?: boolean;
12
13
  }
13
14
  export type InstancesConfig = Readonly<Record<string, InstanceConfig>>;
14
15
  export interface InstanceCatalogEntry { id: string; kind: string; definition: AdapterDefinition; config?: InstanceConfig }
@@ -40,19 +41,30 @@ function roles(value: unknown, field: string): string[] {
40
41
  return out;
41
42
  }
42
43
 
43
- /** Pure I2 declaration validation. Physical worktree and startup authority checks belong to the bound host. */
44
- export function validateInstances(raw: unknown): InstancesConfig {
44
+ /** Declaration support is explicit policy, independent of the native MCP binding mechanism. */
45
+ export function supportsHeadlessInstances(definition: AdapterDefinition): boolean {
46
+ return definition.instances?.mode === "headless" && !!definition.start.create && definition.start.forms.includes("headless");
47
+ }
48
+
49
+ /** Resolve project declarations only through their captured, explicit catalog. */
50
+ export function instanceDefinitionOf(id: string, declarations: InstancesConfig | undefined, catalog: ProjectAdapterCatalog): AdapterDefinition | undefined {
51
+ return catalog.definitionOf(id, declarations);
52
+ }
53
+
54
+ /** Pure I2 validation; physical worktree and startup authority stay host-bound. */
55
+ export function validateInstances(raw: unknown, catalog: ProjectAdapterCatalog): InstancesConfig {
56
+ const resolve = catalog.definitionOf;
45
57
  const result: Record<string, InstanceConfig> = Object.create(null);
46
58
  if (raw === undefined) return Object.freeze(result);
47
59
  const declarations = fields(raw, "instances");
48
60
  if (Object.keys(declarations).length > MAX_INSTANCES) return fail("instances", "at most 64 extra instances are supported");
49
61
  for (const [id, descriptor] of Object.entries(declarations)) {
50
- if (!PEER_ID.test(id) || RESERVED.has(id) || definitionOf(id)) return fail("instances", "ids must be nonreserved extra instance ids, not builtin ids");
62
+ if (!PEER_ID.test(id) || RESERVED.has(id) || resolve(id)) return fail("instances", "ids must be nonreserved extra instance ids, not builtin ids");
51
63
  const path = `instances.${id}`, entry = fields(descriptor.value, path);
52
- if (Object.keys(entry).some(key => !["kind", "roles", "start_mode", "permission_mode", "worktree", "review_mode"].includes(key))) return fail(path, "unsupported field");
64
+ if (Object.keys(entry).some(key => !["kind", "roles", "start_mode", "permission_mode", "worktree", "review_mode", "readOnly"].includes(key))) return fail(path, "unsupported field");
53
65
  const kind: unknown = entry.kind?.value;
54
66
  if (typeof kind !== "string") return fail(`${path}.kind`, "must name a native kind");
55
- const definition = definitionOf(kind), prefix = `${kind}-`;
67
+ const definition = resolve(kind), prefix = `${kind}-`;
56
68
  if (!definition) return fail(`${path}.kind`, "unknown native kind");
57
69
  if (!id.startsWith(prefix) || !/^[a-z0-9][a-z0-9-]*$/.test(id.slice(prefix.length))) return fail(path, "id must be <kind>-<name>");
58
70
  const declaredRoles = entry.roles ? roles(entry.roles.value, `${path}.roles`) : [];
@@ -63,11 +75,12 @@ export function validateInstances(raw: unknown): InstancesConfig {
63
75
  if (definition.routing?.reviewOnly && !freshReviewer) return fail(path, "a review-only kind requires headless roles exactly [\"reviewer\"]");
64
76
  if (freshReviewer && entry.worktree) return fail(`${path}.worktree`, "a fresh reviewer reads the project checkout: worktree is not supported");
65
77
  if (freshReviewer && entry.permission_mode) return fail(`${path}.permission_mode`, "a fresh reviewer has a fixed read-only policy");
66
- // #450 I3: a channel-plugin kind (Claude) runs only in the person's terminal, so it reviews or plans and never owns writes.
67
- const channel = definition.identity.mcp === "channel-plugin";
78
+ // An external instance runs only in the person's terminal; its definition bounds the declared roles.
79
+ const external = definition.instances?.mode === "external";
80
+ const runtime = freshReviewer ? definition.reviewerVariant ?? definition : definition;
68
81
  const unavailableReviewer = freshReviewer && !!(definition.reviewerVariant ?? definition).reviewer?.unavailable;
69
- if (!channel && !unavailableReviewer && (!definition.start.create || !definition.start.forms.includes("headless") || !["env", "acp-session"].includes(definition.identity.mcp))) 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");
70
- if (channel) {
82
+ if (!unavailableReviewer && runtime.instances?.mode !== "external" && !supportsHeadlessInstances(runtime)) return fail(`${path}.kind`, definition.reviewerVariant ? 'unsupported; this kind only declares headless reviewer instances with roles exactly ["reviewer"]' : "unsupported; explicit external instance policy or a headless instance policy with a headless factory is required");
83
+ if (external) {
71
84
  const declared = entry.roles ? roles(entry.roles.value, `${path}.roles`) : [];
72
85
  // #223: a headless one is the kind's reviewer variant, one fresh hub-run session per review request.
73
86
  if (entry.start_mode && definition.reviewerVariant) {
@@ -75,10 +88,20 @@ export function validateInstances(raw: unknown): InstancesConfig {
75
88
  if (declared.length !== 1 || declared[0] !== "reviewer") return fail(`${path}.roles`, "a headless channel-kind instance is a reviewer: roles must be exactly [\"reviewer\"]");
76
89
  } else {
77
90
  if (entry.start_mode || entry.worktree) 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\"");
78
- if (!declared.length || declared.some(role => role !== "reviewer" && role !== "planner")) return fail(`${path}.roles`, "a channel instance must declare its roles: reviewer and/or planner");
91
+ const allowed = definition.instances?.roles ?? definition.roles;
92
+ if (!declared.length || declared.some(role => !allowed.includes(role))) return fail(`${path}.roles`, "a channel instance must declare its roles: reviewer and/or planner");
79
93
  }
94
+ } else {
95
+ const allowed = runtime.instances?.roles;
96
+ if (allowed && (declaredRoles.length ? declaredRoles : runtime.roles).some(role => !allowed.includes(role))) return fail(`${path}.roles`, "roles must fit the definition's instance policy");
97
+ }
98
+ if (entry.readOnly) {
99
+ if (typeof entry.readOnly.value !== "boolean") return fail(`${path}.readOnly`, "must be a boolean");
100
+ if (!runtime.permission.genericAcp?.readOnly) return fail(`${path}.readOnly`, "native adapter has no verified read-only mode");
101
+ if (entry.readOnly.value && entry.permission_mode) return fail(`${path}.permission_mode`, "read-only instances cannot select a permission mode");
80
102
  }
81
103
  const value: InstanceConfig = { kind, ...(entry.review_mode ? { review_mode: "fresh" as const } : {}) };
104
+ if (entry.readOnly) value.readOnly = entry.readOnly.value as boolean;
82
105
  if (entry.roles) { value.roles = roles(entry.roles.value, `${path}.roles`); Object.freeze(value.roles); }
83
106
  if (entry.start_mode) {
84
107
  if (entry.start_mode.value !== "headless") return fail(`${path}.start_mode`, "I2 extra instances require headless mode");
@@ -99,24 +122,25 @@ export function validateInstances(raw: unknown): InstancesConfig {
99
122
  }
100
123
 
101
124
  /** Each call derives a fresh project-local projection, without registration or suffix discovery. */
102
- export function instanceCatalog(declarations?: InstancesConfig): readonly InstanceCatalogEntry[] {
103
- const instances = validateInstances(declarations);
125
+ export function instanceCatalog(declarations: InstancesConfig | undefined, catalog: ProjectAdapterCatalog): readonly InstanceCatalogEntry[] {
126
+ const instances = validateInstances(declarations, catalog);
104
127
  return Object.freeze([
105
- ...ADAPTERS.map(definition => Object.freeze({ id: definition.kind, kind: definition.kind, definition })),
106
- ...Object.entries(instances).map(([id, config]) => Object.freeze({ id, kind: config.kind, definition: definitionOf(id, instances)!, config })),
128
+ ...catalog.definitions.map(definition => Object.freeze({ id: definition.kind, kind: definition.kind, definition })),
129
+ ...Object.entries(instances).map(([id, config]) => Object.freeze({ id, kind: config.kind, definition: catalog.definitionOf(id, instances)!, config })),
107
130
  ]);
108
131
  }
109
- export function instancePeerIds(declarations?: InstancesConfig): string[] { return instanceCatalog(declarations).map(entry => entry.id); }
110
- export function instanceDefaultRoles(declarations?: InstancesConfig, includeOptional = true): Record<string, string[]> {
132
+ export function instancePeerIds(declarations: InstancesConfig | undefined, catalog: ProjectAdapterCatalog): string[] { return instanceCatalog(declarations, catalog).map(entry => entry.id); }
133
+ export function instanceDefaultRoles(declarations: InstancesConfig | undefined, includeOptional: boolean, catalog: ProjectAdapterCatalog): Record<string, string[]> {
111
134
  const result = defaultPeerRoles(includeOptional);
112
- const instances = validateInstances(declarations);
113
- for (const [id, config] of Object.entries(instances)) result[id] = [...(config.roles ?? definitionOf(id, instances)!.roles)];
135
+ for (const definition of catalog.definitions) if (!definitionOf(definition.kind) && (includeOptional || !definition.optional)) result[definition.kind] = [...definition.roles];
136
+ const instances = validateInstances(declarations, catalog);
137
+ for (const [id, config] of Object.entries(instances)) result[id] = [...(config.roles ?? catalog.definitionOf(id, instances)!.roles)];
114
138
  return result;
115
139
  }
116
140
 
117
141
  /** One precedence rule for startup, native launch and recovery; ordinary roles may use the instance id or its kind. */
118
- export function effectiveInstanceRoles(id: string, declarations: InstancesConfig | undefined, policyRoles: Readonly<Record<string, readonly string[]>>): string[] {
119
- const definition = definitionOf(id, declarations);
142
+ export function effectiveInstanceRoles(id: string, declarations: InstancesConfig | undefined, policyRoles: Readonly<Record<string, readonly string[]>>, catalog: ProjectAdapterCatalog): string[] {
143
+ const definition = catalog.definitionOf(id, declarations);
120
144
  if (!definition) throw new Error(`${id}: no declared native instance`);
121
145
  const declaration = declarations && Object.hasOwn(declarations, id) ? declarations[id] : undefined;
122
146
  const ownRoles = (key: string): readonly string[] | undefined => Object.hasOwn(policyRoles, key) ? policyRoles[key] : undefined;
@@ -1,18 +1,20 @@
1
1
  import { ControlClient } from "./control-client.ts";
2
+ import { configuredToolContext, type ProjectToolContext } from "./hub-tools.ts";
2
3
  import { SDLC_STAGES } from "./sdlc.ts";
3
4
  /** The daemon owns the active definition. Unavailable reads never invent a project-file stage set. */
4
5
  /** The active stage ids and whether the lifecycle is enabled (`criteria`, #481 S8); an older daemon never says so. */
5
- export async function activeLifecycle(stateDir: string, peer: string, projectRoot: string, active?: ControlClient): Promise<{ stages: string[]; criteria: boolean } | undefined> {
6
+ export async function activeLifecycle(stateDir: string, peer: string, projectRoot: string, active?: ControlClient): Promise<({ stages: string[]; criteria: boolean } & ProjectToolContext) | undefined> {
6
7
  let client: ControlClient | undefined;
7
8
  try {
8
9
  client = active ?? await ControlClient.connect(stateDir, { role: "tools", peer, projectRoot }, 1000);
9
10
  const reply = await client.request({ t: "status" }, 1000);
10
11
  if (reply.t !== "status" || !reply.status || typeof reply.status !== "object" || Array.isArray(reply.status)) return undefined;
12
+ const projectTools = configuredToolContext(reply.status, peer);
11
13
  // A live older daemon has the fixed seven-stage contract and no optional metadata field.
12
- if (!Object.hasOwn(reply.status, "lifecycleStages")) return { stages: [...SDLC_STAGES], criteria: false };
14
+ if (!Object.hasOwn(reply.status, "lifecycleStages")) return { stages: [...SDLC_STAGES], criteria: false, ...projectTools };
13
15
  const stages: unknown = reply.status.lifecycleStages;
14
16
  if (!Array.isArray(stages) || !stages.length || stages.length > 32 || stages.some(s => typeof s !== "string" || !/^[a-z][a-z0-9_-]{0,47}$/.test(s)) || new Set(stages).size !== stages.length) return undefined;
15
- return { stages: [...stages] as string[], criteria: reply.status.lifecycleCriteria === true };
17
+ return { stages: [...stages] as string[], criteria: reply.status.lifecycleCriteria === true, ...projectTools };
16
18
  } catch { return undefined; }
17
19
  finally { if (client !== active) client?.close(); }
18
20
  }
@@ -117,6 +117,14 @@ export function memoryProjectFor(cwd: string, opts: Pick<StoreOptions, "home"> =
117
117
  return resolveAlias(homeFor(opts), memoryProjectKey(cwd));
118
118
  }
119
119
 
120
+ /**
121
+ * #240: the project key for a caller-injected store. Its alias log is the one in its own bound home (`profile.home`),
122
+ * never the ambient memory home's: a different alias log could hide that store's rows or refuse its writes.
123
+ */
124
+ export function memoryProjectForStore(store: MemoryStore, cwd: string): string {
125
+ return resolveAlias(canonicalHome(store.profile.home), memoryProjectKey(cwd));
126
+ }
127
+
120
128
  /** Inspect selection without constructing a store, index, or candidate sidecar. */
121
129
  export function selectedMemoryProfile(opts: StoreOptions = {}): MemoryProfileReceipt {
122
130
  assertLoadedAgentMem();
@@ -241,11 +249,16 @@ export function openProfileMemory(opts: ProfileMemoryOptions = {}, onUnavailable
241
249
  }
242
250
  }
243
251
 
244
- /** Bind a caller-owned store only when it exactly matches the current person selection. */
245
- export function bindProfileMemory(store: MemoryStore, onUnavailable?: (neutralReason: string) => void): {
252
+ /**
253
+ * Bind a caller-owned store only when it exactly matches the current person selection. `projectKey` is the raw key
254
+ * the caller resolves its project from (#240): a sharded store whose home now aliases that key to another project is
255
+ * refused, and a later remap latches the binding unavailable, so a command never runs as B through A's shard.
256
+ */
257
+ export function bindProfileMemory(store: MemoryStore, onUnavailable?: (neutralReason: string) => void, projectKey?: string): {
246
258
  store: MemoryStore; binding: MemoryProfileBinding;
247
259
  } {
248
- const binding = new MemoryProfileBinding({ project: store.profile.shardKey }, onUnavailable, store.profile.home);
260
+ const sharded = store.profile.shardKey !== undefined;
261
+ const binding = new MemoryProfileBinding({ project: store.profile.shardKey, ...(sharded && projectKey !== undefined ? { projectKey } : {}) }, onUnavailable, store.profile.home);
249
262
  binding.assertCurrent();
250
263
  if (!sameProfile(store.profile, binding.profile)) throw new Error(UNAVAILABLE);
251
264
  return { store, binding };
@@ -0,0 +1,50 @@
1
+ import type { Task } from "./board.ts";
2
+
3
+ export interface NoWriteObservation {
4
+ task: number;
5
+ /** Opaque binding to the current owner and its latest acceptance. */
6
+ generation: string;
7
+ limit: number;
8
+ changed: boolean | undefined;
9
+ effect: boolean;
10
+ }
11
+
12
+ /** Pure per-task streaks; callers own evidence, privacy and warning delivery. */
13
+ export class NoWriteTurns {
14
+ private readonly streaks = new Map<number, { generation: string; count: number; emitted: boolean }>();
15
+
16
+ observe({ task, generation, limit, changed, effect }: NoWriteObservation): number | undefined {
17
+ if (!Number.isSafeInteger(limit) || limit <= 0 || changed !== false || effect !== false) {
18
+ this.reset(task);
19
+ return undefined;
20
+ }
21
+ const previous = this.streaks.get(task);
22
+ const streak = previous?.generation === generation ? previous : { generation, count: 0, emitted: false };
23
+ if (streak !== previous) this.streaks.set(task, streak);
24
+ if (streak.emitted) return undefined;
25
+ if (streak.count < Number.MAX_SAFE_INTEGER) streak.count++;
26
+ if (streak.count < limit) return undefined;
27
+ streak.emitted = true;
28
+ return streak.count;
29
+ }
30
+
31
+ reset(task: number): void { this.streaks.delete(task); }
32
+
33
+ retain(activeTaskIds: readonly number[]): void {
34
+ const active = new Set(activeTaskIds);
35
+ for (const task of this.streaks.keys()) if (!active.has(task)) this.reset(task);
36
+ }
37
+ }
38
+
39
+
40
+ const OWNER_TOOL_EFFECTS = new Set(["hub_task_propose", "hub_task_decline", "hub_task_done", "hub_task_cancel", "hub_task_assign", "hub_task_escalate", "hub_review", "hub_checkpoint", "hub_remember", "hub_memory_correct", "hub_check_start", "hub_check_cancel", "hub_sdlc_record", "hub_plan_review", "hub_backlog_plan", "hub_backlog_pull", "hub_backlog_undo", "hub_backlog_split", "hub_backlog_return_untouched", "hub_release_new", "hub_release_configure", "hub_release_assign", "hub_release_cut"]);
41
+ /** A read does not prove progress; failed/pending mutations are handled conservatively by the host. */
42
+ export function ownerToolEffect(op: string, input: Record<string, unknown> | undefined): boolean {
43
+ return OWNER_TOOL_EFFECTS.has(op) || op === "hub_task_accept" && !!input?.plan && typeof input.plan === "object" && Object.keys(input.plan).length > 0;
44
+ }
45
+
46
+ /** Canonical snapshots cannot disprove work in the owner's separately recorded checkout/PR. */
47
+ export function canonicalWriteUnknown(task: Pick<Task, "refs" | "plan" | "sdlc">): boolean {
48
+ return !!(task.refs.branch || task.refs.commit || task.sdlc?.head || task.sdlc?.localHead || task.sdlc?.pr)
49
+ || [...(task.refs.paths ?? []), ...(task.plan?.paths ?? [])].some(path => /(?:^|[\\/])\.worktrees(?:[\\/]|$)/i.test(path));
50
+ }
@@ -1,4 +1,4 @@
1
- import { definitionOf, type KindMap } from "../adapters/registry.ts";
1
+ import { definitionOf, type ProjectAdapterCatalog, type KindMap } from "../adapters/registry.ts";
2
2
  import { lstatSync } from "node:fs";
3
3
  import { isAbsolute, relative, resolve, sep } from "node:path";
4
4
 
@@ -8,13 +8,15 @@ export type PermissionMode = typeof PERMISSION_MODES[number];
8
8
  export { KIMI_MODE_IDS } from "../adapters/definitions/kimi-native.ts";
9
9
  export { QWEN_MODE_IDS } from "../adapters/definitions/qwen-native.ts";
10
10
  export const isPermissionMode = (value: unknown): value is PermissionMode => typeof value === "string" && (PERMISSION_MODES as readonly string[]).includes(value);
11
- export function permissionDefaults(value: unknown, instances?: KindMap): Record<string, PermissionMode> {
11
+ export function permissionDefaults(value: unknown, instances?: KindMap, catalog?: ProjectAdapterCatalog): Record<string, PermissionMode> {
12
12
  if (value === undefined) return {};
13
13
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("permission_modes must be an object with ask, ask-when-needed or never-ask values");
14
14
  const result: Record<string, PermissionMode> = {};
15
15
  for (const [peer, mode] of Object.entries(value)) {
16
- const definition = typeof peer === "string" ? definitionOf(peer, instances) : undefined;
16
+ const definition = typeof peer === "string" ? (catalog?.definitionOf ?? definitionOf)(peer, instances) : undefined;
17
17
  if (!definition) throw new Error(`permission_modes.${peer}: unsupported peer`);
18
+ if (instances && Object.hasOwn(instances, peer) && (instances[peer] as { readOnly?: boolean })?.readOnly) throw new Error(`permission_modes.${peer}: read-only ceiling is locked`);
19
+ if (definition.permission.genericAcp && (!definition.permission.genericAcp.modes || typeof mode !== "string" || !Object.hasOwn(definition.permission.genericAcp.modes, mode))) throw new Error(`permission_modes.${peer}: native mode mapping is unavailable`);
18
20
  if (definition.permission.unmanaged) throw new Error(`${definition.displayName} native policy is unmanaged; no hub permission_modes.${peer} mapping exists`);
19
21
  if (!isPermissionMode(mode)) throw new Error(`permission_modes.${peer} must be ask, ask-when-needed or never-ask`);
20
22
  result[peer] = mode;
@@ -22,16 +24,16 @@ export function permissionDefaults(value: unknown, instances?: KindMap): Record<
22
24
  return result;
23
25
  }
24
26
  /** Approval frequency does not create a common sandbox across native peers. */
25
- export function permissionBoundary(peer: string, instances?: KindMap): string {
26
- const definition = definitionOf(peer, instances);
27
+ export function permissionBoundary(peer: string, instances: KindMap | undefined, catalog: ProjectAdapterCatalog): string {
28
+ const definition = catalog.definitionOf(peer, instances);
27
29
  if (definition?.permission.unmanaged) return `${peer}: native policy unmanaged; NO hub sandbox; approvals belong to its native tools`;
28
30
  if (definition?.trust.hubSandboxed) return `${peer}: inside hub sandbox, path guard and denylist`;
29
31
  if (definition?.transport === "claude-channel" || definition?.transport === "codex-appserver") return `${peer}: native vendor bounds; no hub sandbox`;
30
32
  return `${peer}: runs its own tools; NO hub sandbox`;
31
33
  }
32
34
  /** What a mode lets a peer do without asking, in one line a person reads before choosing it (#269). */
33
- export function permissionGrant(peer: string, mode: PermissionMode, instances?: KindMap): string {
34
- const grant = definitionOf(peer, instances)?.permission.grants?.[mode];
35
+ export function permissionGrant(peer: string, mode: PermissionMode, instances?: KindMap, catalog?: ProjectAdapterCatalog): string {
36
+ const grant = (catalog?.definitionOf ?? definitionOf)(peer, instances)?.permission.grants?.[mode];
35
37
  if (grant !== undefined) return grant;
36
38
  if (mode === "ask") return "every write, edit and shell command asks";
37
39
  return mode === "never-ask" ? "every tool runs without asking, inside the hub sandbox and path guard" : "write and edit inside the project run without asking; shell and git writes still ask";
package/src/hub/roster.ts CHANGED
@@ -1,9 +1,10 @@
1
- import { definitionOf, type KindMap } from "../adapters/registry.ts";
2
- import { defaultRosterEnabled } from "../adapters/registry.ts";
1
+ import { definitionOf as builtinDefinitionOf, type ProjectAdapterCatalog, type KindMap } from "../adapters/registry.ts";
2
+ import { defaultRosterEnabled as builtinDefaultRosterEnabled } from "../adapters/registry.ts";
3
3
 
4
4
  export type PeerRoster = Partial<Record<string, boolean>>;
5
5
 
6
- export function peerRosterConfig(value: unknown, instances?: KindMap): PeerRoster {
6
+ export function peerRosterConfig(value: unknown, instances?: KindMap, catalog?: ProjectAdapterCatalog): PeerRoster {
7
+ const definitionOf = catalog?.definitionOf ?? builtinDefinitionOf;
7
8
  if (value === undefined) return {};
8
9
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("roster must be an object of boolean peer values");
9
10
  const roster: PeerRoster = {};
@@ -16,7 +17,9 @@ export function peerRosterConfig(value: unknown, instances?: KindMap): PeerRoste
16
17
  }
17
18
 
18
19
  /** External ids keep their existing admission rules; Pi's legacy switch is only its default. */
19
- export function rosterAllows(roster: PeerRoster, peer: string, piEnabled: boolean, instances?: KindMap): boolean {
20
+ export function rosterAllows(roster: PeerRoster, peer: string, piEnabled: boolean, instances: KindMap | undefined, catalog: ProjectAdapterCatalog): boolean {
21
+ const definitionOf = catalog.definitionOf;
22
+ const defaultRosterEnabled = (id: string, enabled: boolean) => !builtinDefinitionOf(id) ? catalog.definitionOf(id)?.defaultOnRoster ?? false : builtinDefaultRosterEnabled(id, enabled);
20
23
  const definition = definitionOf(peer, instances);
21
24
  if (!definition) return true; // External clients do not acquire a native kind from a project declaration.
22
25
  return roster[peer] ?? (definition.kind === peer ? defaultRosterEnabled(peer, piEnabled) : true);
@@ -1,5 +1,5 @@
1
1
  import { selectReviewerPool, type ReviewerPoolSelection } from "./reviewer-pool.ts";
2
- import { ADAPTERS, definitionOf, kindOf, familyOf } from "../adapters/registry.ts";
2
+ import { ADAPTERS, definitionOf, kindOf, familyOf, type ProjectAdapterCatalog } from "../adapters/registry.ts";
3
3
  import { readFileSync, statSync } from "node:fs";
4
4
  import { join, resolve } from "node:path";
5
5
  import { CLASSES, type Task, type TaskClass } from "./board.ts";
@@ -115,6 +115,7 @@ export const PI: PeerId = "pi";
115
115
 
116
116
  /** Startup-bound project data, never inferred from a suffix or read from configuration here. */
117
117
  export interface InstanceRoutingOptions {
118
+ adapterCatalog?: ProjectAdapterCatalog;
118
119
  instances?: Readonly<Record<string, { kind: string }>>;
119
120
  wip?: Record<string, number>;
120
121
  load?: Record<string, number>;
@@ -133,8 +134,8 @@ function siblingOrder(states: Readonly<Record<string, PeerState>>, opts: Instanc
133
134
  function expandClassPeers(peers: readonly PeerId[], states: Readonly<Record<string, PeerState>>, opts: InstanceRoutingOptions): PeerId[] {
134
135
  if (!hasInstances(opts)) return [...peers];
135
136
  return [...new Set(peers.flatMap(peer => {
136
- const kind = kindOf(peer, opts.instances);
137
- return kind && kind === peer ? [peer, ...Object.keys(opts.instances!).filter(id => kindOf(id, opts.instances) === kind)].sort(siblingOrder(states, opts)) : [peer];
137
+ const kind = (opts.adapterCatalog?.kindOf ?? kindOf)(peer, opts.instances);
138
+ return kind && kind === peer ? [peer, ...Object.keys(opts.instances!).filter(id => (opts.adapterCatalog?.kindOf ?? kindOf)(id, opts.instances) === kind)].sort(siblingOrder(states, opts)) : [peer];
138
139
  }))];
139
140
  }
140
141
  /** Quota/adaptive policy retains kind slots; sibling workload chooses only within that kind's slots. */
@@ -142,7 +143,7 @@ function orderSiblingSlots(peers: PeerId[], states: Readonly<Record<string, Peer
142
143
  if (!hasInstances(opts)) return peers;
143
144
  const groups = new Map<string, number[]>(), out = [...peers];
144
145
  for (const [index, peer] of peers.entries()) {
145
- const kind = kindOf(peer, opts.instances);
146
+ const kind = (opts.adapterCatalog?.kindOf ?? kindOf)(peer, opts.instances);
146
147
  if (kind) { const slots = groups.get(kind) ?? []; slots.push(index); groups.set(kind, slots); }
147
148
  }
148
149
  for (const slots of groups.values()) {
@@ -154,11 +155,11 @@ function orderSiblingSlots(peers: PeerId[], states: Readonly<Record<string, Peer
154
155
 
155
156
  /** The quota/roster relay keeps its native preference order, then the class's original tail. */
156
157
  export function relayCandidates(classPeers: readonly PeerId[], pii: boolean, opts: InstanceRoutingOptions & { states?: Readonly<Record<string, PeerState>> } = {}): PeerId[] {
157
- const preferred = ADAPTERS.filter(adapter => adapter.routing?.handoffPriority !== undefined)
158
+ const preferred = (opts.adapterCatalog?.definitions ?? ADAPTERS).filter(adapter => adapter.routing?.handoffPriority !== undefined)
158
159
  .sort((a, b) => a.routing!.handoffPriority! - b.routing!.handoffPriority!);
159
160
  const candidates = [
160
161
  ...preferred.filter(adapter => !pii || adapter.trust.onPrem).map(adapter => adapter.kind),
161
- ...classPeers.filter(peer => definitionOf(peer, opts.instances)?.routing?.handoffPriority === undefined),
162
+ ...classPeers.filter(peer => (opts.adapterCatalog?.definitionOf ?? definitionOf)(peer, opts.instances)?.routing?.handoffPriority === undefined),
162
163
  ].filter((peer): peer is PeerId => !!peer);
163
164
  return expandClassPeers(candidates, opts.states ?? {}, opts);
164
165
  }
@@ -378,10 +379,10 @@ export function assign(
378
379
  const refusals = new Map<string, RoutingRefusal>();
379
380
  const blocked = (peer: PeerId, role: "owner" | "reviewer"): string | undefined => {
380
381
  const deny = (code: RoutingRefusalCode, reason: string): string => { refusals.set(`${role}:${peer}`, { peer, role, code, reason }); return reason; };
381
- const ceiling = definitionOf(peer, opts.instances)?.routing?.allowedRoles;
382
+ const ceiling = (opts.adapterCatalog?.definitionOf ?? definitionOf)(peer, opts.instances)?.routing?.allowedRoles;
382
383
  const requiredRole = role === "reviewer" || task.class === "review" ? "reviewer" : task.class === "plan" ? "planner" : "implementer";
383
384
  if (ceiling && !ceiling.includes(requiredRole)) return deny("policy", "adapter role ceiling excludes this assignment");
384
- const unavailable = role === "reviewer" ? definitionOf(peer, opts.instances)?.reviewer?.unavailable : undefined;
385
+ const unavailable = role === "reviewer" ? (opts.adapterCatalog?.definitionOf ?? definitionOf)(peer, opts.instances)?.reviewer?.unavailable : undefined;
385
386
  if (unavailable) return deny("policy", unavailable);
386
387
  if (role === "owner" && opts.capabilities && !pii) {
387
388
  const caps = peerEntry(opts.capabilities, peer);
@@ -394,7 +395,7 @@ export function assign(
394
395
  if (!Object.hasOwn(states, peer)) return deny("not-attached", "not attached");
395
396
  if (opts.exclude?.includes(peer)) return deny("excluded", "excluded (declined or replaced)");
396
397
  if (role === "owner" && opts.excludeOwners?.includes(peer)) return deny("owner-admission", "owner admission: WIP or delivery hold");
397
- const adapter = definitionOf(peer, opts.instances);
398
+ const adapter = (opts.adapterCatalog?.definitionOf ?? definitionOf)(peer, opts.instances);
398
399
  if (role === "owner" && adapter?.routing?.reviewOnly) return deny("owner-admission", "owner admission: a review-only instance owns no task");
399
400
  if (role === "owner" && needsTests && adapter && adapter.kind !== peer) {
400
401
  if (!peerEntry(opts.roles, peer)?.includes("implementer")) return deny("owner-admission", "owner admission: declared write owner requires the current implementer role");
@@ -404,8 +405,8 @@ export function assign(
404
405
  if (states[peer] === "paused" && routing.constraints.budget_paused === "skip_peer") return deny("paused", "paused");
405
406
  const failing = peerEntry(opts.failing, peer);
406
407
  if (failing) return deny("failing", `failing: ${failing}`);
407
- if (pii && definitionOf(peer, opts.instances)?.trust.onPrem !== true) return deny("pii", "pii: on-prem peers only");
408
- const nativeRouting = definitionOf(peer, opts.instances)?.routing;
408
+ if (pii && (opts.adapterCatalog?.definitionOf ?? definitionOf)(peer, opts.instances)?.trust.onPrem !== true) return deny("pii", "pii: on-prem peers only");
409
+ const nativeRouting = (opts.adapterCatalog?.definitionOf ?? definitionOf)(peer, opts.instances)?.routing;
409
410
  if (nativeRouting?.localAllowedRefusal && policy?.local_allowed === false) return deny("policy", nativeRouting.localAllowedRefusal);
410
411
  if (nativeRouting?.longContext === "backend-limit" && task.signals.includes("long_context")) {
411
412
  const backend = policy?.pi_backend ?? "dgx";
@@ -462,7 +463,7 @@ export function assign(
462
463
  }
463
464
  const ranked = sortSiblings ? orderSiblingSlots(rank(ok, role, not), states, opts) : rank(ok, role, not);
464
465
  // Demotion applies to local and Pi too: a demoted one loses its place ahead of the cloud peers.
465
- const localTier = ranked.filter((p) => definitionOf(p, opts.instances)?.routing?.preferredTier === true && !(role === "owner" && peerEntry(opts.demoted, p)));
466
+ const localTier = ranked.filter((p) => (opts.adapterCatalog?.definitionOf ?? definitionOf)(p, opts.instances)?.routing?.preferredTier === true && !(role === "owner" && peerEntry(opts.demoted, p)));
466
467
  if (localTier.length) return localTier.find((p) => states[p] === "idle") ?? localTier[0]; // local/Pi stays ahead of an idle cloud peer
467
468
  return ranked.find((p) => states[p] === "idle") ?? ranked[0];
468
469
  };
@@ -492,13 +493,13 @@ export function assign(
492
493
  trace.push(opts.namedReviewer === undefined ? ` reviewer candidates: [classes.review] peers ${classPeers.join(", ") || "none"}${rolePeers.length ? `; reviewer role ${rolePeers.join(", ")}` : ""}` : ` reviewer candidate explicitly named: ${opts.namedReviewer}`);
493
494
  const reviewOwner = owner ?? opts.notReviewer;
494
495
  const automatic = [...new Set([...expandClassPeers(classPeers, states, opts), ...rolePeers])];
495
- const ownerFamily = instanceMode && reviewOwner ? familyOf(reviewOwner, opts.instances) : undefined;
496
- const sameFamily = (peer: PeerId) => ownerFamily !== undefined && familyOf(peer, opts.instances) === ownerFamily;
496
+ const ownerFamily = instanceMode && reviewOwner ? (opts.adapterCatalog?.familyOf ?? familyOf)(reviewOwner, opts.instances) : undefined;
497
+ const sameFamily = (peer: PeerId) => ownerFamily !== undefined && (opts.adapterCatalog?.familyOf ?? familyOf)(peer, opts.instances) === ownerFamily;
497
498
  const independent = ownerFamily === undefined ? [] : automatic.filter(peer => peer !== reviewOwner && !blocked(peer, "reviewer") && !sameFamily(peer));
498
499
  const named = opts.namedReviewer;
499
500
  const declaredPool = opts.reviewPool && named === undefined ? Object.keys(opts.instances ?? {}).flatMap(id => {
500
- const roles = peerEntry(opts.roles, id), definition = definitionOf(id, opts.instances);
501
- const kind = kindOf(id, opts.instances), family = familyOf(id, opts.instances);
501
+ const roles = peerEntry(opts.roles, id), definition = (opts.adapterCatalog?.definitionOf ?? definitionOf)(id, opts.instances);
502
+ const kind = (opts.adapterCatalog?.kindOf ?? kindOf)(id, opts.instances), family = (opts.adapterCatalog?.familyOf ?? familyOf)(id, opts.instances);
502
503
  if (roles?.length !== 1 || roles[0] !== "reviewer" || !definition?.routing?.reviewOnly || !kind || !family) return [];
503
504
  const reason = id === reviewOwner ? "is the owner" : definition.reviewer?.unavailable ?? blocked(id, "reviewer")
504
505
  ?? (states[id] === "paused" ? "paused" : undefined)
@@ -509,7 +510,7 @@ export function assign(
509
510
  if (declaredPool.length || opts.reviewPool?.required && named === undefined) {
510
511
  for (const candidate of declaredPool) if (!opts.reviewPool!.pool.includes(candidate.kind)) trace.push(` reviewer pool candidate ${candidate.id} (${candidate.family}): unavailable skip: ${candidate.blocked ?? "kind outside configured pool"}`);
511
512
  const last = task.history?.findLast(entry => entry.event === "changes_requested");
512
- const continuityFamily = last && "reviewerFamily" in last && typeof last.reviewerFamily === "string" ? last.reviewerFamily : last ? familyOf(last.by, opts.instances) : undefined;
513
+ const continuityFamily = last && "reviewerFamily" in last && typeof last.reviewerFamily === "string" ? last.reviewerFamily : last ? (opts.adapterCatalog?.familyOf ?? familyOf)(last.by, opts.instances) : undefined;
513
514
  const pr = task.sdlc?.pr;
514
515
  const seed = typeof pr === "number" && Number.isSafeInteger(pr) && pr > 0 ? pr : task.id ?? 0;
515
516
  reviewSelection = selectReviewerPool({ pool: opts.reviewPool!.pool, candidates: declaredPool, seed, implementerFamily: ownerFamily, continuityFamily, rerotate: opts.reviewPool!.rerotate, failed: opts.reviewPool!.failed });
@@ -518,7 +519,7 @@ export function assign(
518
519
  if (reviewSelection.blocked) {
519
520
  trace.push("blocked: no reviewer can run");
520
521
  // Optional defaults retain the legacy planner escape when no fresh reviewer can run.
521
- const planners = opts.reviewPool?.required ? [] : automatic.filter(peer => peer !== reviewOwner && peerEntry(opts.roles, peer)?.includes("planner") && !definitionOf(peer, opts.instances)?.routing?.reviewOnly && !blocked(peer, "reviewer"));
522
+ const planners = opts.reviewPool?.required ? [] : automatic.filter(peer => peer !== reviewOwner && peerEntry(opts.roles, peer)?.includes("planner") && !(opts.adapterCatalog?.definitionOf ?? definitionOf)(peer, opts.instances)?.routing?.reviewOnly && !blocked(peer, "reviewer"));
522
523
  const otherFamily = planners.filter(peer => !sameFamily(peer));
523
524
  const planner = pick(otherFamily.length ? otherFamily : planners, "reviewer", reviewOwner);
524
525
  if (planner) {
@@ -555,8 +556,8 @@ export function assign(
555
556
  const route = effectiveLocalRoute(routing.local.route, policy?.route, policy?.fixed_model);
556
557
  const fixedModel = policy?.fixed_model ?? routing.local.fixed_model;
557
558
  const piBackend = policy?.pi_backend;
558
- if (owner && definitionOf(owner, opts.instances)?.routing?.trace === "route") trace.push(`route: ${route ?? "(none)"}, fixed_model ${fixedModel}`);
559
- if (owner && definitionOf(owner, opts.instances)?.routing?.trace === "backend") trace.push(`${definitionOf(owner, opts.instances)!.routing!.backendLabel} decision: backend ${piBackend ?? "dgx"}${task.signals.includes("long_context") ? `, context limit ${piBackend === "mlx" ? routing.pi.mlx_max_context_tokens : routing.pi.dgx_max_context_tokens}` : ""}`);
559
+ if (owner && (opts.adapterCatalog?.definitionOf ?? definitionOf)(owner, opts.instances)?.routing?.trace === "route") trace.push(`route: ${route ?? "(none)"}, fixed_model ${fixedModel}`);
560
+ if (owner && (opts.adapterCatalog?.definitionOf ?? definitionOf)(owner, opts.instances)?.routing?.trace === "backend") trace.push(`${(opts.adapterCatalog?.definitionOf ?? definitionOf)(owner, opts.instances)!.routing!.backendLabel} decision: backend ${piBackend ?? "dgx"}${task.signals.includes("long_context") ? `, context limit ${piBackend === "mlx" ? routing.pi.mlx_max_context_tokens : routing.pi.dgx_max_context_tokens}` : ""}`);
560
561
  return { ...(reviewSelection ? { reviewSelection } : {}), ...(reviewBlocked ? { reviewBlocked: true as const } : {}), ...(sameFamilyFallback ? { sameFamilyFallback: true as const } : {}), ...(plannerFallback ? { plannerFallback: true as const } : {}), ...(owner ? { owner } : {}), ...(pii ? { reviewer: "user" } : reviewer ? { reviewer } : {}), ...(route ? { route } : {}), fixedModel, ...(piBackend ? { piBackend } : {}), ...(unreserved ? { unreserved } : {}), ...(refusals.size ? { refusals: [...refusals.values()] } : {}), trace };
561
562
  }
562
563