@staix/agent-hub 0.12.36 → 0.12.38
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 +22 -0
- package/README.md +18 -11
- package/docs/agent-notes/adapters.md +19 -6
- package/docs/agent-notes/budget.md +2 -1
- package/docs/agent-notes/bus.md +3 -2
- package/docs/agent-notes/daemon.md +9 -3
- package/docs/agent-notes/tasks.md +8 -4
- package/docs/agent-notes/tests.md +2 -2
- package/docs/memory.md +3 -1
- package/docs/operations.md +57 -10
- package/docs/quickstart.md +15 -7
- package/docs/sdlc.md +29 -3
- package/docs/security.md +10 -2
- package/docs/smoke.md +38 -2
- package/docs/specs/2026-09-19-agent-hub-design.md +58 -4
- package/docs/verification/2026-10-19-0.12.37.md +60 -0
- package/docs/verification/2026-10-19-agy-p1-t0.md +55 -0
- package/docs/verification/2026-10-19-bus-defer-reset.md +9 -0
- package/docs/verification/2026-10-19-jcode-0.98.1.md +30 -0
- package/docs/verification/2026-10-19-kimi-agy-reviewer-isolation.md +220 -0
- package/docs/verification/2026-10-19-reviewer-isolation.md +119 -0
- package/docs/verification/2026-10-20-0.12.38.md +56 -0
- package/docs/verified.json +27 -15
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +5627 -3670
- package/src/adapters/acp.ts +3 -2
- package/src/adapters/agy/definition.ts +61 -0
- package/src/adapters/agy/entry.ts +76 -0
- package/src/adapters/agy/hooks.ts +81 -0
- package/src/adapters/claude-channel.ts +1 -0
- package/src/adapters/claude-print.ts +18 -156
- package/src/adapters/codex-review-isolation.ts +290 -0
- package/src/adapters/definitions/builtin-metadata.ts +9 -6
- package/src/adapters/definitions/native-cli-entry-claude.ts +3 -0
- package/src/adapters/definitions/native-cli-entry-codex.ts +5 -1
- package/src/adapters/definitions/native-cli-entry-jcode.ts +2 -0
- package/src/adapters/definitions/native-cli-entry-kimi.ts +12 -4
- package/src/adapters/definitions/native-cli-entry-local.ts +3 -0
- package/src/adapters/definitions/native-cli-entry-pi.ts +7 -1
- package/src/adapters/definitions/native-cli-entry-qwen.ts +3 -0
- package/src/adapters/definitions/native-cli-entry.ts +2 -0
- package/src/adapters/definitions/native-cli-retained.ts +15 -0
- package/src/adapters/definitions/native-factories.ts +17 -0
- package/src/adapters/definitions/native-policy-aux.ts +1 -1
- package/src/adapters/definitions/qwen.ts +1 -1
- package/src/adapters/definitions/reviewer-variants.ts +33 -0
- package/src/adapters/jcode.ts +20 -5
- package/src/adapters/kimi-review.ts +161 -0
- package/src/adapters/registry-types.ts +36 -4
- package/src/adapters/registry.ts +28 -7
- package/src/adapters/reviewer-mcp.ts +85 -0
- package/src/adapters/reviewer-print.ts +382 -0
- package/src/cli/agy-mcp.ts +370 -0
- package/src/cli/console-state.ts +6 -0
- package/src/cli/console.ts +2 -0
- package/src/cli/facts-hook.ts +15 -2
- package/src/cli/help.ts +2 -0
- package/src/cli/identity.ts +1 -1
- package/src/cli/init.ts +7 -0
- package/src/cli/launch.ts +3 -1
- package/src/cli/main.ts +20 -0
- package/src/cli/memory.ts +13 -12
- package/src/cli/output.ts +5 -0
- package/src/hub/board.ts +34 -6
- package/src/hub/bus.ts +17 -6
- package/src/hub/config-apply.ts +3 -2
- package/src/hub/control-client.ts +7 -1
- package/src/hub/daemon.ts +266 -46
- package/src/hub/envelope.ts +1 -1
- package/src/hub/host-executable.ts +1 -1
- package/src/hub/hub-tools.ts +5 -0
- package/src/hub/instances.ts +13 -3
- package/src/hub/memory-profile.ts +67 -11
- package/src/hub/native-hook-launch.ts +37 -0
- package/src/hub/peer-catalog.ts +2 -3
- package/src/hub/planning.ts +2 -0
- package/src/hub/release-admission.ts +64 -0
- package/src/hub/reviewer-pool-config.ts +11 -0
- package/src/hub/reviewer-pool.ts +76 -0
- package/src/hub/routing.ts +45 -3
- package/src/hub/sdlc-release.ts +113 -0
- package/src/hub/tasks.ts +261 -36
- package/src/ui/index.html +6 -1
- package/templates/AGENTS.block.md +1 -1
package/src/hub/envelope.ts
CHANGED
|
@@ -14,7 +14,7 @@ export interface Envelope {
|
|
|
14
14
|
kind: Kind;
|
|
15
15
|
priority: Priority;
|
|
16
16
|
body: string; // agent conclusions only, never tool noise
|
|
17
|
-
refs?: { repo?: string; branch?: string; commit?: string; paths?: string[]; task?: string; taskOffer?: number; taskCancelled?: number; taskRecipient?: "owner" | "reviewer" | "planner"; backlog?: number[]; supervision?: boolean; supervisionKey?: string; boundedCheck?: string; budgetResume?: string; budgetResumeMoves?: { id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]; budgetResumeEarlier?: string[] };
|
|
17
|
+
refs?: { repo?: string; branch?: string; commit?: string; paths?: string[]; task?: string; reviewRevision?: string; taskOffer?: number; taskCancelled?: number; taskRecipient?: "owner" | "reviewer" | "planner"; backlog?: number[]; supervision?: boolean; supervisionKey?: string; boundedCheck?: string; budgetResume?: string; budgetResumeMoves?: { id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]; budgetResumeEarlier?: string[] };
|
|
18
18
|
/** The body must not be shown outside its recipients: console tail and log print a stub instead (PII tasks). */
|
|
19
19
|
private?: boolean;
|
|
20
20
|
ts: number;
|
|
@@ -2,7 +2,7 @@ import { isAbsolute, relative } from "node:path";
|
|
|
2
2
|
import { realPath } from "./project.ts";
|
|
3
3
|
|
|
4
4
|
/** A repo-writable PATH shim is not a host-owned authenticated capability. No caller-selected binary. */
|
|
5
|
-
export function hostExecutable(name: "gh" | "container" | "git", projectDir: string, path = process.env.PATH ?? ""): string {
|
|
5
|
+
export function hostExecutable(name: "gh" | "container" | "git" | "dot", projectDir: string, path = process.env.PATH ?? ""): string {
|
|
6
6
|
const found = Bun.which(name, { PATH: path });
|
|
7
7
|
if (!found || !isAbsolute(found)) throw new Error(`${name} unavailable`);
|
|
8
8
|
const target = realPath(found), inside = relative(realPath(projectDir), target);
|
package/src/hub/hub-tools.ts
CHANGED
|
@@ -61,6 +61,11 @@ export const TASK_TOOLS: HubTool[] = [
|
|
|
61
61
|
tool("hub_backlog_pull", "Reserve a WIP slot and pull ready unpinned backlog work into To Do at its current revision. Preserves person order/reservations and frozen scope; pending unowned work can be undone with hub_backlog_undo.", { id, revision: { type: "integer", minimum: 0 }, reason: { type: "string", minLength: 1, maxLength: 1000 } }, ["id", "revision", "reason"]),
|
|
62
62
|
tool("hub_backlog_split", "Split unstarted backlog work into two to eight staged parts. The original card retains integration/acceptance and depends on the children. Use after for existing prerequisite ids and afterParts for earlier zero-based part indexes. Latest unused unchanged children can be undone with hub_backlog_undo.", { id, revision: { type: "integer", minimum: 0 }, reason: { type: "string", minLength: 1, maxLength: 1000 }, parts: { type: "array", minItems: 2, maxItems: 8, items: { type: "object", properties: { title: { type: "string", minLength: 1, maxLength: 300 }, detail: { type: "string", maxLength: 8000 }, class: str, refs, plan, after: { type: "array", maxItems: 64, uniqueItems: true, items: { type: "integer", minimum: 1 } }, afterParts: { type: "array", maxItems: 8, uniqueItems: true, items: { type: "integer", minimum: 0 } } }, required: ["title"], additionalProperties: false } } }, ["id", "revision", "reason", "parts"]),
|
|
63
63
|
tool("hub_backlog_undo", "Undo your latest planning change at its current revision. Refuses intervening changes and another actor's edit.", { id, revision: { type: "integer", minimum: 1 } }, ["id", "revision"]),
|
|
64
|
+
tool("hub_release_new", "Create a future release planning row. Planner/person only; no activation, publish or release authorization.", { name: { type: "string", minLength: 1, maxLength: 64 }, goal: { type: "string", minLength: 1, maxLength: 1000 }, order: { type: "integer", minimum: 0 } }, ["name", "goal", "order"]),
|
|
65
|
+
tool("hub_release_report", "Read a planned release's task readiness and recorded metrics. Planner/person only. A due date never waives unfinished scope.", { id }, ["id"]),
|
|
66
|
+
tool("hub_release_assign", "Assign or move only a task's release, retaining its execution state and owner. Planner/person only; requires a reason and current planning revision. Frozen scope refuses.", { id, revision: { type: "integer", minimum: 0 }, release: { type: ["integer", "null"], minimum: 1 }, reason: { type: "string", minLength: 1, maxLength: 1000 } }, ["id", "revision", "release", "reason"]),
|
|
67
|
+
tool("hub_release_configure", "Configure/activate a release train or record a hotfix cause. Planner/person only. At most one train is active/frozen; planning rows are future trains.", { id, revision: { type: "integer", minimum: 0 }, state: { type: "string", enum: ["active"] }, cut: { type: "integer", minimum: 1 }, hotfix: { type: "object", properties: { cause: { type: "string", enum: ["loss", "security_privacy", "upgrade_start_blocked", "core_flow_no_workaround"] }, reason: { type: "string", minLength: 1, maxLength: 1000 } }, required: ["cause", "reason"], additionalProperties: false } }, ["id", "revision"]),
|
|
68
|
+
tool("hub_release_cut", "Freeze an active release's approved scope after host resource admission and fresh peer quota checks. Unknown evidence defers; no publish, tag, upgrade or authorization.", { id, revision: { type: "integer", minimum: 0 } }, ["id", "revision"]),
|
|
64
69
|
tool("hub_release_list", "Read public release scope and planned/actual dates for phased planning. Configured planners only.", {}),
|
|
65
70
|
|
|
66
71
|
tool("hub_task_propose", "Put a piece of work on the shared task board. The hub assigns an owner by class (routing.toml) unless you name one. Classes: plan, implement, bulk_edit, test, review, summarize, triage. Name the class when you know it; without one the hub tries to pick it. Name yourself as owner to claim work nobody assigned you: it starts in progress. Put the paths the work touches in refs, and when you claim work, a plan; the hub says when they overlap another open task.", { title: str, class: { type: "string", enum: ["plan", "implement", "bulk_edit", "test", "review", "summarize", "triage"] }, detail: str, role: { type: "string", description: "catalog role id for this task class; guidance only, no routing or authority change" }, refs, plan, owner: { type: "string", description: "peer id; yours to claim the work, omit to let the hub route it. With after, the owner is reserved: offered the task first once it is ready" }, after: { type: "array", items: { type: "integer" }, description: "ids of tasks that must be approved first; until then this one waits, offered to nobody, and cannot be claimed" }, backlog: { type: "boolean", description: "stage unoffered work; owner is only a reservation, even when naming yourself" }, urgent: { type: "boolean", description: "hand it over even when its owner is paused for quota only briefly" }, goal: { type: "string", description: "id of this project's long-term task (ahub goal list) the work is part of" } }, ["title"]),
|
package/src/hub/instances.ts
CHANGED
|
@@ -7,6 +7,7 @@ export interface InstanceConfig {
|
|
|
7
7
|
roles?: string[];
|
|
8
8
|
start_mode?: StartForm;
|
|
9
9
|
permission_mode?: PermissionMode;
|
|
10
|
+
review_mode?: "fresh";
|
|
10
11
|
worktree?: string;
|
|
11
12
|
}
|
|
12
13
|
export type InstancesConfig = Readonly<Record<string, InstanceConfig>>;
|
|
@@ -48,15 +49,24 @@ export function validateInstances(raw: unknown): InstancesConfig {
|
|
|
48
49
|
for (const [id, descriptor] of Object.entries(declarations)) {
|
|
49
50
|
if (!PEER_ID.test(id) || RESERVED.has(id) || definitionOf(id)) return fail("instances", "ids must be nonreserved extra instance ids, not builtin ids");
|
|
50
51
|
const path = `instances.${id}`, entry = fields(descriptor.value, path);
|
|
51
|
-
if (Object.keys(entry).some(key => !["kind", "roles", "start_mode", "permission_mode", "worktree"].includes(key))) return fail(path, "unsupported field");
|
|
52
|
+
if (Object.keys(entry).some(key => !["kind", "roles", "start_mode", "permission_mode", "worktree", "review_mode"].includes(key))) return fail(path, "unsupported field");
|
|
52
53
|
const kind: unknown = entry.kind?.value;
|
|
53
54
|
if (typeof kind !== "string") return fail(`${path}.kind`, "must name a native kind");
|
|
54
55
|
const definition = definitionOf(kind), prefix = `${kind}-`;
|
|
55
56
|
if (!definition) return fail(`${path}.kind`, "unknown native kind");
|
|
56
57
|
if (!id.startsWith(prefix) || !/^[a-z0-9][a-z0-9-]*$/.test(id.slice(prefix.length))) return fail(path, "id must be <kind>-<name>");
|
|
58
|
+
const declaredRoles = entry.roles ? roles(entry.roles.value, `${path}.roles`) : [];
|
|
59
|
+
const reviewer = (definition.reviewerVariant ?? definition).reviewer;
|
|
60
|
+
const reviewerOnly = entry.start_mode?.value === "headless" && declaredRoles.length === 1 && declaredRoles[0] === "reviewer";
|
|
61
|
+
if (entry.review_mode && (entry.review_mode.value !== "fresh" || !reviewerOnly || !reviewer)) return fail(`${path}.review_mode`, "fresh requires reviewer-only roles, headless mode and a declared fresh-review capability");
|
|
62
|
+
const freshReviewer = reviewerOnly && !!reviewer && (reviewer.declaration === "automatic" || entry.review_mode?.value === "fresh");
|
|
63
|
+
if (definition.routing?.reviewOnly && !freshReviewer) return fail(path, "a review-only kind requires headless roles exactly [\"reviewer\"]");
|
|
64
|
+
if (freshReviewer && entry.worktree) return fail(`${path}.worktree`, "a fresh reviewer reads the project checkout: worktree is not supported");
|
|
65
|
+
if (freshReviewer && entry.permission_mode) return fail(`${path}.permission_mode`, "a fresh reviewer has a fixed read-only policy");
|
|
57
66
|
// #450 I3: a channel-plugin kind (Claude) runs only in the person's terminal, so it reviews or plans and never owns writes.
|
|
58
67
|
const channel = definition.identity.mcp === "channel-plugin";
|
|
59
|
-
|
|
68
|
+
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");
|
|
60
70
|
if (channel) {
|
|
61
71
|
const declared = entry.roles ? roles(entry.roles.value, `${path}.roles`) : [];
|
|
62
72
|
// #223: a headless one is the kind's reviewer variant, one fresh hub-run session per review request.
|
|
@@ -68,7 +78,7 @@ export function validateInstances(raw: unknown): InstancesConfig {
|
|
|
68
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");
|
|
69
79
|
}
|
|
70
80
|
}
|
|
71
|
-
const value: InstanceConfig = { kind };
|
|
81
|
+
const value: InstanceConfig = { kind, ...(entry.review_mode ? { review_mode: "fresh" as const } : {}) };
|
|
72
82
|
if (entry.roles) { value.roles = roles(entry.roles.value, `${path}.roles`); Object.freeze(value.roles); }
|
|
73
83
|
if (entry.start_mode) {
|
|
74
84
|
if (entry.start_mode.value !== "headless") return fail(`${path}.start_mode`, "I2 extra instances require headless mode");
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, lstatSync, readFileSync } from "node:fs";
|
|
2
2
|
import { basename, dirname, join, resolve } from "node:path";
|
|
3
3
|
import {
|
|
4
|
-
CandidateStore, MemoryStore, realPath, memoryHome, resolveProfile,
|
|
4
|
+
CandidateStore, MemoryStore, realPath, memoryHome, memoryProjectKey, openShardStore, readShardedSelection, resolveAlias, resolveProfile, resolveShardProfile,
|
|
5
5
|
type BoundProfile, type ProfileInput, type StoreOptions,
|
|
6
6
|
} from "@staix/agent-mem";
|
|
7
7
|
import packageJson from "../../package.json";
|
|
@@ -39,6 +39,14 @@ function assertLoadedAgentMem(): void {
|
|
|
39
39
|
}
|
|
40
40
|
export const MEMORY_PROFILE_UNAVAILABLE = "memory profile unavailable; inspect the selector and generation state, run ahub memory profile-check; use amem migrate --deactivate only to stop using the selected generation";
|
|
41
41
|
const UNAVAILABLE = MEMORY_PROFILE_UNAVAILABLE;
|
|
42
|
+
/** agent-mem#15 T2: a split marker that is not active (captured, malformed) is never read as an empty or legacy store. */
|
|
43
|
+
export const MEMORY_SPLIT_INCOMPLETE = "memory home has a curated split that is not active; finish it with amem migrate --shards --resume, then restart the hub";
|
|
44
|
+
/** Hub memory options: the project key selects this project's shard once the home is split. */
|
|
45
|
+
/**
|
|
46
|
+
* Hub memory options. `project` is the bound (alias-resolved) key; `projectKey`, when given, is the raw key it came
|
|
47
|
+
* from, and the entry fence latches the binding unavailable once the alias log maps it elsewhere.
|
|
48
|
+
*/
|
|
49
|
+
export type ProfileMemoryOptions = Omit<StoreOptions, "memoryDir"> & { project?: string; projectKey?: string };
|
|
42
50
|
|
|
43
51
|
/** No handle was opened; distinct from an unavailable, still-bound handle. */
|
|
44
52
|
export interface UnboundMemoryProfileReceipt {
|
|
@@ -70,7 +78,25 @@ function canonicalHome(path: string): string {
|
|
|
70
78
|
}
|
|
71
79
|
function sameProfile(a: BoundProfile, b: BoundProfile): boolean {
|
|
72
80
|
return a.home === b.home && a.dir === b.dir && a.kind === b.kind
|
|
73
|
-
&& a.generation === b.generation && a.selectorRevision === b.selectorRevision
|
|
81
|
+
&& a.generation === b.generation && a.selectorRevision === b.selectorRevision
|
|
82
|
+
&& !!a.sharded === !!b.sharded && a.shardKey === b.shardKey && !!a.shardUser === !!b.shardUser;
|
|
83
|
+
}
|
|
84
|
+
// lstat, not exists: a dangling or unreadable marker is still a marker and must fail closed.
|
|
85
|
+
const splitMarked = (home: string) => lstatSync(join(home, "shards.json"), { throwIfNoEntry: false }) !== undefined;
|
|
86
|
+
/** Refuse an inactive split before any shard read, so the remedy names resume rather than the generic profile check. */
|
|
87
|
+
function assertActiveSplit(home: string): void {
|
|
88
|
+
try { readShardedSelection(home); } catch { throw new Error(MEMORY_SPLIT_INCOMPLETE); }
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* The one profile a binding may hold: this project's shard on a split home, the selected profile otherwise.
|
|
92
|
+
* ponytail: the entry fence re-checks the project shard only; the user overlay shares its selection and selector
|
|
93
|
+
* revision, so its own seal or machine drift goes unchecked. Add resolveShardProfile(home, { user: true }) if needed.
|
|
94
|
+
*/
|
|
95
|
+
function currentProfile(home: string, project: string | undefined): BoundProfile {
|
|
96
|
+
if (!splitMarked(home)) return resolveProfile(home);
|
|
97
|
+
assertActiveSplit(home);
|
|
98
|
+
if (!project) throw new Error(UNAVAILABLE);
|
|
99
|
+
return resolveShardProfile(home, { project });
|
|
74
100
|
}
|
|
75
101
|
function homeFor(opts: Pick<StoreOptions, "home">): string {
|
|
76
102
|
return canonicalHome(opts.home === undefined ? memoryHome() : join(canonicalHome(opts.home), "memory"));
|
|
@@ -83,6 +109,14 @@ function receipt(profile: BoundProfile, state: MemoryProfileReceipt["state"], sk
|
|
|
83
109
|
};
|
|
84
110
|
}
|
|
85
111
|
|
|
112
|
+
/**
|
|
113
|
+
* The project key agent-mem's own CLI binds: memoryProjectKey(cwd) through the person-reviewed alias log of the
|
|
114
|
+
* canonical memory home. The daemon and `ahub memory`/`ahub goal` use it so both open the same shard and rows.
|
|
115
|
+
*/
|
|
116
|
+
export function memoryProjectFor(cwd: string, opts: Pick<StoreOptions, "home"> = {}): string {
|
|
117
|
+
return resolveAlias(homeFor(opts), memoryProjectKey(cwd));
|
|
118
|
+
}
|
|
119
|
+
|
|
86
120
|
/** Inspect selection without constructing a store, index, or candidate sidecar. */
|
|
87
121
|
export function selectedMemoryProfile(opts: StoreOptions = {}): MemoryProfileReceipt {
|
|
88
122
|
assertLoadedAgentMem();
|
|
@@ -95,14 +129,20 @@ export class MemoryProfileBinding {
|
|
|
95
129
|
readonly explicitProfile: ProfileInput;
|
|
96
130
|
private readonly options: Pick<StoreOptions, "home">;
|
|
97
131
|
private readonly directMemoryDir: string | undefined;
|
|
132
|
+
/** The shard key on a split home; unused on an unsplit one. */
|
|
133
|
+
private readonly project: string | undefined;
|
|
134
|
+
/** The raw key `project` was resolved from; an alias added later must not leave writes on the old key. */
|
|
135
|
+
private readonly projectKey: string | undefined;
|
|
98
136
|
private unavailable = false;
|
|
99
137
|
|
|
100
|
-
constructor(opts:
|
|
138
|
+
constructor(opts: ProfileMemoryOptions = {}, private readonly onUnavailable?: (neutralReason: string) => void, directMemoryDir?: string) {
|
|
101
139
|
assertLoadedAgentMem();
|
|
102
140
|
this.options = { home: opts.home };
|
|
103
141
|
this.directMemoryDir = directMemoryDir;
|
|
142
|
+
this.project = opts.project;
|
|
143
|
+
this.projectKey = opts.projectKey;
|
|
104
144
|
this.home = directMemoryDir === undefined ? homeFor(this.options) : canonicalHome(directMemoryDir);
|
|
105
|
-
this.profile = Object.freeze({ ...
|
|
145
|
+
this.profile = Object.freeze({ ...currentProfile(this.home, this.project) });
|
|
106
146
|
this.explicitProfile = this.profile.kind === "shared-v1" ? "shared-v1" : Object.freeze({ generation: this.profile.generation! });
|
|
107
147
|
}
|
|
108
148
|
|
|
@@ -110,8 +150,9 @@ export class MemoryProfileBinding {
|
|
|
110
150
|
if (this.unavailable) throw new Error(UNAVAILABLE);
|
|
111
151
|
try {
|
|
112
152
|
const currentHome = this.directMemoryDir === undefined ? homeFor(this.options) : canonicalHome(this.directMemoryDir);
|
|
113
|
-
const current =
|
|
153
|
+
const current = currentProfile(currentHome, this.project);
|
|
114
154
|
if (currentHome !== this.home || !sameProfile(current, this.profile)) throw new Error(UNAVAILABLE);
|
|
155
|
+
if (this.projectKey !== undefined && resolveAlias(currentHome, this.projectKey) !== this.project) throw new Error(UNAVAILABLE);
|
|
115
156
|
} catch {
|
|
116
157
|
this.invalidate();
|
|
117
158
|
throw new Error(UNAVAILABLE);
|
|
@@ -149,12 +190,27 @@ export class MemoryProfileBinding {
|
|
|
149
190
|
}
|
|
150
191
|
}
|
|
151
192
|
|
|
152
|
-
export function openProfileMemory(opts:
|
|
193
|
+
export function openProfileMemory(opts: ProfileMemoryOptions = {}, onUnavailable?: (neutralReason: string) => void): {
|
|
153
194
|
store: MemoryStore; binding: MemoryProfileBinding;
|
|
154
195
|
} {
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
196
|
+
const { project, ...storeOptions } = opts;
|
|
197
|
+
const home = homeFor(opts);
|
|
198
|
+
let raw: MemoryStore, binding: MemoryProfileBinding;
|
|
199
|
+
if (splitMarked(home)) {
|
|
200
|
+
// agent-mem#15 T2: a plain MemoryStore refuses a split home. The shard store opens this project's shard (created on
|
|
201
|
+
// first use) with the user shard as its overlay; the binding is resolved after it, since a new shard has no seal yet.
|
|
202
|
+
assertActiveSplit(home);
|
|
203
|
+
if (!project) throw new Error(UNAVAILABLE);
|
|
204
|
+
// The canonical memory directory the binding resolves: a lexical home (a symlinked AGENTHUB_HOME or memory dir)
|
|
205
|
+
// would give the shard a different profile.home and dir than the binding compares against.
|
|
206
|
+
raw = openShardStore({ ...storeOptions, home: undefined, memoryDir: home, project });
|
|
207
|
+
try { binding = new MemoryProfileBinding(opts, onUnavailable); }
|
|
208
|
+
catch (error) { raw.close(); throw error; }
|
|
209
|
+
} else {
|
|
210
|
+
binding = new MemoryProfileBinding(opts, onUnavailable);
|
|
211
|
+
binding.assertCurrent();
|
|
212
|
+
raw = new MemoryStore({ ...storeOptions, profile: binding.explicitProfile });
|
|
213
|
+
}
|
|
158
214
|
try {
|
|
159
215
|
binding.assertCurrent();
|
|
160
216
|
if (!sameProfile(raw.profile, binding.profile)) throw new Error(UNAVAILABLE);
|
|
@@ -172,7 +228,7 @@ export function openProfileMemory(opts: StoreOptions = {}, onUnavailable?: (neut
|
|
|
172
228
|
export function bindProfileMemory(store: MemoryStore, onUnavailable?: (neutralReason: string) => void): {
|
|
173
229
|
store: MemoryStore; binding: MemoryProfileBinding;
|
|
174
230
|
} {
|
|
175
|
-
const binding = new MemoryProfileBinding({}, onUnavailable, store.profile.home);
|
|
231
|
+
const binding = new MemoryProfileBinding({ project: store.profile.shardKey }, onUnavailable, store.profile.home);
|
|
176
232
|
binding.assertCurrent();
|
|
177
233
|
if (!sameProfile(store.profile, binding.profile)) throw new Error(UNAVAILABLE);
|
|
178
234
|
return { store, binding };
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** Managed launch authority is ephemeral and scoped to one peer, daemon and native session. */
|
|
2
|
+
export interface NativeHookLaunch {
|
|
3
|
+
id: string;
|
|
4
|
+
session?: string;
|
|
5
|
+
generation?: string;
|
|
6
|
+
baseline?: number;
|
|
7
|
+
turn?: string;
|
|
8
|
+
active: boolean;
|
|
9
|
+
completed?: string;
|
|
10
|
+
}
|
|
11
|
+
export class NativeHookLaunches {
|
|
12
|
+
private readonly launches = new Map<string, NativeHookLaunch>();
|
|
13
|
+
private readonly owners = new Map<string, object>();
|
|
14
|
+
begin(peer: string, owner: object): NativeHookLaunch {
|
|
15
|
+
if (this.launches.has(peer)) throw new Error("a managed native launch is already active; end it in its terminal first");
|
|
16
|
+
const launch: NativeHookLaunch = { id: crypto.randomUUID(), active: false };
|
|
17
|
+
this.launches.set(peer, launch);
|
|
18
|
+
this.owners.set(peer, owner);
|
|
19
|
+
return launch;
|
|
20
|
+
}
|
|
21
|
+
get(peer: string, id: unknown): NativeHookLaunch | undefined {
|
|
22
|
+
const launch = this.launches.get(peer);
|
|
23
|
+
return launch && typeof id === "string" && launch.id === id ? launch : undefined;
|
|
24
|
+
}
|
|
25
|
+
end(peer: string, id: unknown): boolean {
|
|
26
|
+
if (!this.get(peer, id)) return false;
|
|
27
|
+
this.owners.delete(peer);
|
|
28
|
+
return this.launches.delete(peer);
|
|
29
|
+
}
|
|
30
|
+
endOwner(owner: object): void {
|
|
31
|
+
for (const [peer, current] of this.owners) {
|
|
32
|
+
if (current !== owner) continue;
|
|
33
|
+
this.launches.delete(peer);
|
|
34
|
+
this.owners.delete(peer);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
package/src/hub/peer-catalog.ts
CHANGED
|
@@ -6,14 +6,13 @@ export { SUPPORTED_PEERS } from "../adapters/registry.ts";
|
|
|
6
6
|
/** Native peers the hub knows; membership checks share the registry. */
|
|
7
7
|
export const KNOWN_PEERS = ["claude", "codex", "kimi", "pi", "local"] as const;
|
|
8
8
|
/** Optional production peers are added explicitly; legacy defaults remain stable. */
|
|
9
|
-
export const OPTIONAL_PEERS = ["qwen", "jcode"] as const;
|
|
9
|
+
export const OPTIONAL_PEERS = ["qwen", "jcode", "agy"] as const;
|
|
10
10
|
export type KnownPeer = typeof SUPPORTED_PEERS[number];
|
|
11
11
|
|
|
12
12
|
export const isKnownPeer = (value: unknown): value is KnownPeer =>
|
|
13
13
|
typeof value === "string" && !!definitionOf(value) && !definitionOf(value)!.optional;
|
|
14
14
|
|
|
15
|
-
export
|
|
16
|
-
typeof value === "string" && !!definitionOf(value);
|
|
15
|
+
export { isSupportedPeer } from "../adapters/registry.ts";
|
|
17
16
|
|
|
18
17
|
// Policy and historical projection order belong to the registry; public compatibility exports remain intact.
|
|
19
18
|
export { defaultPeerRoles, PLANNER_PEERS } from "../adapters/registry.ts";
|
package/src/hub/planning.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ReleaseTrainData } from "./sdlc-release.ts";
|
|
1
2
|
import type { Task } from "./board.ts";
|
|
2
3
|
|
|
3
4
|
export const BACKLOG_SPLIT_SCREEN_TIMEOUT_MS = 8000;
|
|
@@ -28,6 +29,7 @@ export function planningWipLimits(input: unknown): Record<string, number> {
|
|
|
28
29
|
export const RELEASE_STATES = ["planning", "active", "frozen", "shipped", "dropped"] as const;
|
|
29
30
|
export type ReleaseState = (typeof RELEASE_STATES)[number];
|
|
30
31
|
export interface Release {
|
|
32
|
+
train?: ReleaseTrainData;
|
|
31
33
|
id: number; name: string; goal: string; order: number; state: ReleaseState;
|
|
32
34
|
plannedStart: number | null; cut: number | null; ship: number | null;
|
|
33
35
|
activatedAt: number | null; frozenAt: number | null; shippedAt: number | null;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { hubGitSync } from "./git.ts";
|
|
5
|
+
import { hostExecutable } from "./host-executable.ts";
|
|
6
|
+
import { childEnv, stopOwnedProcess } from "./child-process.ts";
|
|
7
|
+
import { detectTooling } from "./tooling.ts";
|
|
8
|
+
|
|
9
|
+
export interface ReleaseHostAdmission { admitted: boolean; reason?: string; current?: () => boolean; close?: () => Promise<void> }
|
|
10
|
+
const READY = "AHUB_RELEASE_SLOT_READY\n";
|
|
11
|
+
const HOLD = 'process.stdout.write("AHUB_RELEASE_SLOT_READY\\n");process.stdin.resume();process.stdin.on("end",()=>process.exit(0));';
|
|
12
|
+
/** Hold the installed controller's repository lease through the synchronous scope freeze. No caller command. */
|
|
13
|
+
export function readReleaseHostAdmission(cwd: string): Promise<ReleaseHostAdmission> {
|
|
14
|
+
const common = hubGitSync(["rev-parse", "--path-format=absolute", "--git-common-dir"], { cwd, encoding: "utf8", timeout: 1000, maxBuffer: 4096 });
|
|
15
|
+
const directory = common.stdout?.trim();
|
|
16
|
+
if (common.status !== 0 || !directory) return Promise.resolve({ admitted: false, reason: "repository resource identity unavailable" });
|
|
17
|
+
const locked = () => existsSync(join(directory, "ahub-heavy.lock"));
|
|
18
|
+
if (locked()) return Promise.resolve({ admitted: false, reason: "repository heavy-job slot held" });
|
|
19
|
+
return new Promise(resolve => {
|
|
20
|
+
let executable: string;
|
|
21
|
+
try { executable = hostExecutable("dot", cwd); } catch { resolve({ admitted: false, reason: "host resource controller unavailable" }); return; }
|
|
22
|
+
const env = childEnv();
|
|
23
|
+
delete env.DOT_ADMISSION_SCOPE; // A long-lived daemon must not inherit an expired launcher lease.
|
|
24
|
+
const child = spawn(executable, ["admit", "--wait", "0s", "--json", "--", process.execPath, "-e", HOLD], { cwd, env, detached: true, stdio: ["pipe", "pipe", "pipe"] });
|
|
25
|
+
let output = "", bytes = 0, invalid = false, admitted = false;
|
|
26
|
+
const stop = () => { invalid = true; void stopOwnedProcess(child, { group: true }).catch(() => {}); };
|
|
27
|
+
// ponytail: one five-second metadata cut lease, never a build lease. Heavy preparation runs through its own admission.
|
|
28
|
+
const timer = setTimeout(() => { stop(); resolve({ admitted: false, reason: "host resource admission timed out" }); }, 5000);
|
|
29
|
+
child.stdin.on("error", () => {});
|
|
30
|
+
child.stdout.setEncoding("utf8");
|
|
31
|
+
child.stdout.on("data", chunk => {
|
|
32
|
+
if (invalid) return;
|
|
33
|
+
output += chunk; bytes += Buffer.byteLength(chunk);
|
|
34
|
+
if (bytes > 16 * 1024 || !READY.startsWith(output) && output !== READY) { stop(); resolve({ admitted: false, reason: "host resource admission unknown" }); return; }
|
|
35
|
+
if (!admitted && output === READY && !locked()) {
|
|
36
|
+
admitted = true;
|
|
37
|
+
resolve({ admitted: true, current: () => !invalid && child.exitCode === null && child.signalCode === null && !locked(), close: async () => {
|
|
38
|
+
clearTimeout(timer);
|
|
39
|
+
if (child.exitCode === null && child.signalCode === null) {
|
|
40
|
+
await new Promise<void>(done => {
|
|
41
|
+
const grace = setTimeout(done, 500);
|
|
42
|
+
child.once("close", () => { clearTimeout(grace); done(); });
|
|
43
|
+
child.stdin.end();
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
await stopOwnedProcess(child, { group: true });
|
|
47
|
+
} });
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
child.stderr.on("data", (chunk: Buffer) => { bytes += chunk.length; if (bytes > 16 * 1024) stop(); });
|
|
51
|
+
child.once("error", () => { invalid = true; clearTimeout(timer); resolve({ admitted: false, reason: "host resource controller unavailable" }); });
|
|
52
|
+
child.once("close", async code => {
|
|
53
|
+
clearTimeout(timer); invalid = true;
|
|
54
|
+
if (admitted) return;
|
|
55
|
+
if (code === 75) { resolve({ admitted: false, reason: "host resource admission deferred (busy repository or pressure)" }); return; }
|
|
56
|
+
// Diagnose a failed invocation with the same bounded identity probe used by the tooling inventory (#351).
|
|
57
|
+
// This readback never grants admission or substitutes for the controller's held-lease handshake.
|
|
58
|
+
const tooling = await detectTooling("hub", { cwd, path: dirname(executable) }).catch(() => undefined);
|
|
59
|
+
resolve({ admitted: false, reason: tooling && !tooling.some(addon => addon.name === "dot-admit")
|
|
60
|
+
? "host resource controller unavailable (dot is not the resource controller; install dot-admit or fix PATH)"
|
|
61
|
+
: "host resource admission unknown" });
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface ReviewerPoolConfig { pool: string[]; rerotate: boolean }
|
|
2
|
+
|
|
3
|
+
/** Kind order is project policy; capability and isolation evidence remain host-owned registry facts. */
|
|
4
|
+
export function reviewerPoolConfig(raw: unknown, defaults: readonly string[], known: readonly string[]): ReviewerPoolConfig {
|
|
5
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("review must be an object");
|
|
6
|
+
const value = raw as Record<string, unknown>;
|
|
7
|
+
const pool = value.pool === undefined ? defaults : value.pool;
|
|
8
|
+
if (!Array.isArray(pool) || pool.length < 1 || pool.length > 16 || pool.some(kind => typeof kind !== "string" || !/^[a-z][a-z0-9-]{0,31}$/.test(kind) || !known.includes(kind)) || new Set(pool).size !== pool.length) throw new Error("review.pool must list 1..16 distinct known kinds");
|
|
9
|
+
if (value.rerotate !== undefined && typeof value.rerotate !== "boolean") throw new Error("review.rerotate must be a boolean");
|
|
10
|
+
return { pool: [...pool], rerotate: value.rerotate === true };
|
|
11
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
export type ReviewerPoolReason = "rotation" | "implementer skip" | "unavailable skip" | "same-family fallback";
|
|
2
|
+
export interface ReviewerPoolCandidate { id: string; kind: string; family: string; blocked?: string }
|
|
3
|
+
export interface ReviewerPoolInput {
|
|
4
|
+
pool: readonly string[];
|
|
5
|
+
candidates: readonly ReviewerPoolCandidate[];
|
|
6
|
+
implementerFamily?: string;
|
|
7
|
+
seed: number;
|
|
8
|
+
continuityFamily?: string;
|
|
9
|
+
rerotate?: boolean;
|
|
10
|
+
failed?: readonly string[];
|
|
11
|
+
}
|
|
12
|
+
export interface ReviewerPoolSelection {
|
|
13
|
+
reviewer: string | null;
|
|
14
|
+
family?: string;
|
|
15
|
+
reason?: ReviewerPoolReason;
|
|
16
|
+
walk: readonly { id: string; kind: string; family: string; reason: string }[];
|
|
17
|
+
blocked?: "no reviewer can run";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Pure selection: caller supplies declared candidates and current availability, never inferred authority. */
|
|
21
|
+
export function selectReviewerPool(input: ReviewerPoolInput): ReviewerPoolSelection {
|
|
22
|
+
const kinds = [...new Set(input.pool)];
|
|
23
|
+
const walk: { id: string; kind: string; family: string; reason: string }[] = [];
|
|
24
|
+
if (!kinds.length) return { reviewer: null, walk, blocked: "no reviewer can run" };
|
|
25
|
+
// A configured kind without any declaration cannot take a modulo share from actual reviewers.
|
|
26
|
+
// Blocked declarations still own their slot: current availability never changes the denominator.
|
|
27
|
+
const declaredKinds = kinds.filter(kind => input.candidates.some(candidate => candidate.kind === kind));
|
|
28
|
+
const undeclared = kinds.filter(kind => !declaredKinds.includes(kind)).map(kind => ({ id: "", kind, family: "", reason: "no declared reviewer" }));
|
|
29
|
+
const finish = (selection: Omit<ReviewerPoolSelection, "walk">): ReviewerPoolSelection => ({ ...selection, walk: [...walk, ...undeclared] });
|
|
30
|
+
if (!declaredKinds.length) return finish({ reviewer: null, blocked: "no reviewer can run" });
|
|
31
|
+
const seed = Number.isSafeInteger(input.seed) ? input.seed : 0;
|
|
32
|
+
const start = ((seed % declaredKinds.length) + declaredKinds.length) % declaredKinds.length;
|
|
33
|
+
const rotated = [...declaredKinds.slice(start), ...declaredKinds.slice(0, start)];
|
|
34
|
+
const slots = rotated.flatMap(kind => {
|
|
35
|
+
const declared = input.candidates.filter(candidate => candidate.kind === kind).sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
36
|
+
return declared;
|
|
37
|
+
});
|
|
38
|
+
let ordered = slots;
|
|
39
|
+
// Preserve the rotated order within each family, including sibling instance ids.
|
|
40
|
+
const continuity = !input.rerotate && input.continuityFamily !== undefined && input.continuityFamily !== input.implementerFamily;
|
|
41
|
+
if (continuity) ordered = [...ordered.filter(candidate => candidate.family === input.continuityFamily), ...ordered.filter(candidate => candidate.family !== input.continuityFamily)];
|
|
42
|
+
const failed = new Set(input.failed ?? []);
|
|
43
|
+
let skippedOwner = false, skippedUnavailable = false;
|
|
44
|
+
const unavailable = (candidate: ReviewerPoolCandidate) => failed.has(candidate.id) ? "start failed for this review" : candidate.blocked;
|
|
45
|
+
const record = (candidate: ReviewerPoolCandidate, reason: string) => { walk.push({ id: candidate.id, kind: candidate.kind, family: candidate.family, reason }); };
|
|
46
|
+
for (const candidate of ordered) {
|
|
47
|
+
if (!candidate.id) {
|
|
48
|
+
skippedUnavailable = true;
|
|
49
|
+
record(candidate, "no declared reviewer");
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (input.implementerFamily !== undefined && candidate.family === input.implementerFamily) {
|
|
53
|
+
skippedOwner = true;
|
|
54
|
+
record(candidate, "implementer skip");
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const blocked = unavailable(candidate);
|
|
58
|
+
if (blocked !== undefined) {
|
|
59
|
+
skippedUnavailable = true;
|
|
60
|
+
record(candidate, `unavailable skip: ${blocked}`);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
const reason: ReviewerPoolReason = skippedUnavailable ? "unavailable skip" : skippedOwner ? "implementer skip" : "rotation";
|
|
64
|
+
record(candidate, `selected (${continuity && candidate.family === input.continuityFamily ? "continuity" : reason})`);
|
|
65
|
+
return finish({ reviewer: candidate.id, family: candidate.family, reason });
|
|
66
|
+
}
|
|
67
|
+
// The caller supplies separate fresh reviewer instances; this does not authorize an implementer's own session.
|
|
68
|
+
for (const candidate of ordered) {
|
|
69
|
+
if (!candidate.id || input.implementerFamily === undefined || candidate.family !== input.implementerFamily) continue;
|
|
70
|
+
const blocked = unavailable(candidate);
|
|
71
|
+
if (blocked !== undefined) { record(candidate, `unavailable skip: ${blocked}`); continue; }
|
|
72
|
+
record(candidate, "selected (same-family fallback)");
|
|
73
|
+
return finish({ reviewer: candidate.id, family: candidate.family, reason: "same-family fallback" });
|
|
74
|
+
}
|
|
75
|
+
return finish({ reviewer: null, blocked: "no reviewer can run" });
|
|
76
|
+
}
|
package/src/hub/routing.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { selectReviewerPool, type ReviewerPoolSelection } from "./reviewer-pool.ts";
|
|
1
2
|
import { ADAPTERS, definitionOf, kindOf, familyOf } from "../adapters/registry.ts";
|
|
2
3
|
import { readFileSync, statSync } from "node:fs";
|
|
3
4
|
import { join, resolve } from "node:path";
|
|
@@ -288,6 +289,8 @@ export const peerEntry = <T>(map: Readonly<Record<string, T>> | undefined, peer:
|
|
|
288
289
|
export type RoutingRefusalCode = "instance" | "family" | "tests" | "spec" | "roster" | "pull-only" | "not-attached" | "excluded" | "owner-admission" | "offline" | "paused" | "failing" | "pii" | "policy";
|
|
289
290
|
export interface RoutingRefusal { peer: PeerId; role: "owner" | "reviewer"; code: RoutingRefusalCode; reason: string }
|
|
290
291
|
export interface Assignment {
|
|
292
|
+
reviewSelection?: ReviewerPoolSelection;
|
|
293
|
+
reviewBlocked?: true;
|
|
291
294
|
/** A same-family reviewer was chosen only because no other family was eligible. */
|
|
292
295
|
sameFamilyFallback?: true;
|
|
293
296
|
/** #223 R5: the planner reviews because the declared reviewer-only instance was not eligible. */
|
|
@@ -309,7 +312,7 @@ export interface Assignment {
|
|
|
309
312
|
* prints its trace. `states`: effective bus states of attached peers (a peer that is not in the map is not attached).
|
|
310
313
|
*/
|
|
311
314
|
export function assign(
|
|
312
|
-
task: Pick<Task, "class" | "signals" | "reserved"> & Partial<Pick<Task, "state" | "title" | "detail" | "refs" | "external">>,
|
|
315
|
+
task: Pick<Task, "class" | "signals" | "reserved"> & Partial<Pick<Task, "state" | "title" | "detail" | "refs" | "external" | "id" | "sdlc" | "history">>,
|
|
313
316
|
states: Record<PeerId, PeerState>,
|
|
314
317
|
routing: Routing,
|
|
315
318
|
opts: InstanceRoutingOptions & {
|
|
@@ -324,6 +327,7 @@ export function assign(
|
|
|
324
327
|
pullOnly?: PeerId[];
|
|
325
328
|
namedOwner?: PeerId;
|
|
326
329
|
namedReviewer?: PeerId;
|
|
330
|
+
reviewPool?: { pool: readonly string[]; rerotate?: boolean; failed?: readonly string[]; required?: boolean };
|
|
327
331
|
candidates?: PeerId[];
|
|
328
332
|
notReviewer?: PeerId;
|
|
329
333
|
waitsFor?: number[];
|
|
@@ -364,6 +368,11 @@ export function assign(
|
|
|
364
368
|
const refusals = new Map<string, RoutingRefusal>();
|
|
365
369
|
const blocked = (peer: PeerId, role: "owner" | "reviewer"): string | undefined => {
|
|
366
370
|
const deny = (code: RoutingRefusalCode, reason: string): string => { refusals.set(`${role}:${peer}`, { peer, role, code, reason }); return reason; };
|
|
371
|
+
const ceiling = definitionOf(peer, opts.instances)?.routing?.allowedRoles;
|
|
372
|
+
const requiredRole = role === "reviewer" || task.class === "review" ? "reviewer" : task.class === "plan" ? "planner" : "implementer";
|
|
373
|
+
if (ceiling && !ceiling.includes(requiredRole)) return deny("policy", "adapter role ceiling excludes this assignment");
|
|
374
|
+
const unavailable = role === "reviewer" ? definitionOf(peer, opts.instances)?.reviewer?.unavailable : undefined;
|
|
375
|
+
if (unavailable) return deny("policy", unavailable);
|
|
367
376
|
if (role === "owner" && opts.capabilities && !pii) {
|
|
368
377
|
const caps = peerEntry(opts.capabilities, peer);
|
|
369
378
|
if (needsTests && !caps?.tests) return deny("tests", `missing tests: ${caps?.testsReason ?? "capability unavailable"}`);
|
|
@@ -462,6 +471,8 @@ export function assign(
|
|
|
462
471
|
let reviewer: PeerId | undefined;
|
|
463
472
|
let sameFamilyFallback = false;
|
|
464
473
|
let plannerFallback = false;
|
|
474
|
+
let reviewSelection: ReviewerPoolSelection | undefined;
|
|
475
|
+
let reviewBlocked = false;
|
|
465
476
|
if (task.class !== "review") {
|
|
466
477
|
if (pii) trace.push("reviewer: user (pii: no second on-prem peer; ahub review <id> <verdict>)");
|
|
467
478
|
else {
|
|
@@ -475,7 +486,38 @@ export function assign(
|
|
|
475
486
|
const sameFamily = (peer: PeerId) => ownerFamily !== undefined && familyOf(peer, opts.instances) === ownerFamily;
|
|
476
487
|
const independent = ownerFamily === undefined ? [] : automatic.filter(peer => peer !== reviewOwner && !blocked(peer, "reviewer") && !sameFamily(peer));
|
|
477
488
|
const named = opts.namedReviewer;
|
|
478
|
-
|
|
489
|
+
const declaredPool = opts.reviewPool && named === undefined ? Object.keys(opts.instances ?? {}).flatMap(id => {
|
|
490
|
+
const roles = peerEntry(opts.roles, id), definition = definitionOf(id, opts.instances);
|
|
491
|
+
const kind = kindOf(id, opts.instances), family = familyOf(id, opts.instances);
|
|
492
|
+
if (roles?.length !== 1 || roles[0] !== "reviewer" || !definition?.routing?.reviewOnly || !kind || !family) return [];
|
|
493
|
+
const reason = id === reviewOwner ? "is the owner" : definition.reviewer?.unavailable ?? blocked(id, "reviewer")
|
|
494
|
+
?? (states[id] === "paused" ? "paused" : undefined)
|
|
495
|
+
?? peerEntry(opts.held, id)
|
|
496
|
+
?? ((peerEntry(opts.quota, id)?.headroom ?? Infinity) <= 0 ? "quota exhausted" : undefined);
|
|
497
|
+
return [{ id, kind, family, ...(reason === undefined ? {} : { blocked: reason }) }];
|
|
498
|
+
}) : [];
|
|
499
|
+
if (declaredPool.length || opts.reviewPool?.required && named === undefined) {
|
|
500
|
+
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"}`);
|
|
501
|
+
const last = task.history?.findLast(entry => entry.event === "changes_requested");
|
|
502
|
+
const continuityFamily = last && "reviewerFamily" in last && typeof last.reviewerFamily === "string" ? last.reviewerFamily : last ? familyOf(last.by, opts.instances) : undefined;
|
|
503
|
+
const pr = task.sdlc?.pr;
|
|
504
|
+
const seed = typeof pr === "number" && Number.isSafeInteger(pr) && pr > 0 ? pr : task.id ?? 0;
|
|
505
|
+
reviewSelection = selectReviewerPool({ pool: opts.reviewPool!.pool, candidates: declaredPool, seed, implementerFamily: ownerFamily, continuityFamily, rerotate: opts.reviewPool!.rerotate, failed: opts.reviewPool!.failed });
|
|
506
|
+
for (const row of reviewSelection.walk) trace.push(row.id ? ` reviewer pool candidate ${row.id} (${row.family}): ${row.reason}` : ` reviewer pool kind ${row.kind}: ${row.reason}`);
|
|
507
|
+
reviewer = reviewSelection.reviewer ?? "user";
|
|
508
|
+
if (reviewSelection.blocked) {
|
|
509
|
+
trace.push("blocked: no reviewer can run");
|
|
510
|
+
// Optional defaults retain the legacy planner escape when no fresh reviewer can run.
|
|
511
|
+
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"));
|
|
512
|
+
const otherFamily = planners.filter(peer => !sameFamily(peer));
|
|
513
|
+
const planner = pick(otherFamily.length ? otherFamily : planners, "reviewer", reviewOwner);
|
|
514
|
+
if (planner) {
|
|
515
|
+
reviewer = planner;
|
|
516
|
+
plannerFallback = true;
|
|
517
|
+
trace.push(` planner fallback: no declared reviewer-only instance is eligible; ${planner} reviews`);
|
|
518
|
+
} else reviewBlocked = true;
|
|
519
|
+
}
|
|
520
|
+
} else if (named !== undefined && named !== reviewOwner && !blocked(named, "reviewer") && sameFamily(named) && independent.length) {
|
|
479
521
|
const reason = `same family as ${reviewOwner}; eligible other-family reviewer available`;
|
|
480
522
|
refusals.set(`reviewer:${named}`, { peer: named, role: "reviewer", code: "family", reason });
|
|
481
523
|
trace.push(` reviewer candidate ${named}: skipped, ${reason}`);
|
|
@@ -505,7 +547,7 @@ export function assign(
|
|
|
505
547
|
const piBackend = policy?.pi_backend;
|
|
506
548
|
if (owner && definitionOf(owner, opts.instances)?.routing?.trace === "route") trace.push(`route: ${route ?? "(none)"}, fixed_model ${fixedModel}`);
|
|
507
549
|
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}` : ""}`);
|
|
508
|
-
return { ...(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 };
|
|
550
|
+
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 };
|
|
509
551
|
}
|
|
510
552
|
|
|
511
553
|
/**
|