@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.
- package/CHANGELOG.md +16 -3
- package/README.md +3 -3
- package/docs/adding-an-ai-tool.md +140 -0
- package/docs/agent-notes/README.md +5 -0
- package/docs/agent-notes/adapters.md +8 -3
- package/docs/agent-notes/budget.md +2 -2
- package/docs/agent-notes/daemon.md +9 -5
- package/docs/agent-notes/tasks.md +6 -2
- package/docs/agent-notes/tests.md +3 -1
- package/docs/operations.md +64 -5
- package/docs/quickstart.md +6 -2
- package/docs/security.md +5 -3
- package/docs/specs/2026-09-19-agent-hub-design.md +160 -4
- package/docs/verification/2026-10-21-0.12.44.md +54 -0
- package/docs/verification/2026-10-21-grok-config-acp-t0.md +30 -0
- package/docs/verified.json +34 -19
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +743 -229
- package/src/adapters/acp-owned-group.ts +124 -0
- package/src/adapters/acp.ts +92 -25
- package/src/adapters/agy/definition.ts +1 -3
- package/src/adapters/agy/entry.ts +4 -1
- package/src/adapters/channel-peer.ts +143 -0
- package/src/adapters/claude-channel.ts +7 -7
- package/src/adapters/codex-appserver.ts +16 -2
- package/src/adapters/configured-acp.ts +124 -0
- package/src/adapters/definitions/builtin-metadata.ts +37 -33
- package/src/adapters/definitions/native-channel-state.ts +41 -0
- package/src/adapters/definitions/native-cli-entry-kimi.ts +3 -1
- package/src/adapters/definitions/native-cli-preview-qwen.ts +1 -1
- package/src/adapters/definitions/native-identity-defaults.ts +14 -0
- package/src/adapters/definitions/qwen.ts +3 -8
- package/src/adapters/definitions/reviewer-variants.ts +3 -7
- package/src/adapters/jcode.ts +4 -0
- package/src/adapters/local-worker.ts +4 -0
- package/src/adapters/pi.ts +4 -0
- package/src/adapters/registry-types.ts +18 -16
- package/src/adapters/registry.ts +68 -6
- package/src/adapters/reviewer-print.ts +4 -0
- package/src/cli/agy-mcp.ts +3 -2
- package/src/cli/configured-acp-entry.ts +58 -0
- package/src/cli/console-state.ts +9 -4
- package/src/cli/facts-hook.ts +2 -1
- package/src/cli/help.ts +6 -0
- package/src/cli/identity.ts +7 -6
- package/src/cli/launch.ts +5 -4
- package/src/cli/main.ts +78 -27
- package/src/cli/memory.ts +13 -10
- package/src/cli/output.ts +1 -0
- package/src/cli/preview.ts +9 -0
- package/src/cli/recovery-guidance.ts +1 -1
- package/src/cli/terminal-recovery.ts +16 -13
- package/src/cli/upgrade-restore-state.ts +3 -2
- package/src/cli/upgrade-runtime.ts +21 -6
- package/src/cli/upgrade.ts +3 -2
- package/src/hub/board.ts +9 -6
- package/src/hub/budget.ts +9 -2
- package/src/hub/bus.ts +6 -2
- package/src/hub/claude-state.ts +2 -37
- package/src/hub/conductor.ts +8 -6
- package/src/hub/config-apply.ts +26 -14
- package/src/hub/config-reload.ts +39 -11
- package/src/hub/config-trust.ts +1 -1
- package/src/hub/context-window.ts +6 -0
- package/src/hub/control-client.ts +1 -1
- package/src/hub/crash.ts +8 -6
- package/src/hub/daemon.ts +341 -313
- package/src/hub/hub-tools.ts +36 -0
- package/src/hub/instance-config.ts +20 -11
- package/src/hub/instance-execution.ts +4 -4
- package/src/hub/instances.ts +45 -21
- package/src/hub/lifecycle-schema.ts +5 -3
- package/src/hub/memory-profile.ts +16 -3
- package/src/hub/no-writes.ts +50 -0
- package/src/hub/permission-mode.ts +9 -7
- package/src/hub/roster.ts +7 -4
- package/src/hub/routing.ts +21 -20
- package/src/hub/settings.ts +50 -16
- package/src/hub/snapshots.ts +26 -2
- package/src/hub/start-mode.ts +10 -8
- package/src/hub/task-sweep.ts +8 -2
- package/src/hub/tasks.ts +85 -17
- package/src/memory/capture.ts +2 -1
- package/src/ui/index.html +5 -0
- package/templates/AGENTS.block.md +1 -1
- package/templates/profiles/general.json +1 -1
- package/templates/profiles/research.json +1 -1
- package/templates/profiles/writing.json +1 -1
package/src/hub/hub-tools.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
25
|
-
if (!
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
if (
|
|
29
|
-
|
|
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
|
-
|
|
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)):
|
|
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 {
|
|
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) {
|
package/src/hub/instances.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { types } from "node:util";
|
|
2
|
-
import {
|
|
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
|
-
/**
|
|
44
|
-
export function
|
|
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) ||
|
|
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 =
|
|
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
|
-
//
|
|
67
|
-
const
|
|
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 (!
|
|
70
|
-
if (
|
|
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
|
-
|
|
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
|
|
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
|
-
...
|
|
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
|
|
110
|
-
export function instanceDefaultRoles(declarations
|
|
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
|
|
113
|
-
|
|
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[]
|
|
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
|
-
/**
|
|
245
|
-
|
|
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
|
|
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
|
|
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
|
|
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);
|
package/src/hub/routing.ts
CHANGED
|
@@ -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
|
|