@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.
Files changed (85) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +18 -11
  3. package/docs/agent-notes/adapters.md +19 -6
  4. package/docs/agent-notes/budget.md +2 -1
  5. package/docs/agent-notes/bus.md +3 -2
  6. package/docs/agent-notes/daemon.md +9 -3
  7. package/docs/agent-notes/tasks.md +8 -4
  8. package/docs/agent-notes/tests.md +2 -2
  9. package/docs/memory.md +3 -1
  10. package/docs/operations.md +57 -10
  11. package/docs/quickstart.md +15 -7
  12. package/docs/sdlc.md +29 -3
  13. package/docs/security.md +10 -2
  14. package/docs/smoke.md +38 -2
  15. package/docs/specs/2026-09-19-agent-hub-design.md +58 -4
  16. package/docs/verification/2026-10-19-0.12.37.md +60 -0
  17. package/docs/verification/2026-10-19-agy-p1-t0.md +55 -0
  18. package/docs/verification/2026-10-19-bus-defer-reset.md +9 -0
  19. package/docs/verification/2026-10-19-jcode-0.98.1.md +30 -0
  20. package/docs/verification/2026-10-19-kimi-agy-reviewer-isolation.md +220 -0
  21. package/docs/verification/2026-10-19-reviewer-isolation.md +119 -0
  22. package/docs/verification/2026-10-20-0.12.38.md +56 -0
  23. package/docs/verified.json +27 -15
  24. package/package.json +2 -2
  25. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  26. package/plugins/agent-hub/server.js +5627 -3670
  27. package/src/adapters/acp.ts +3 -2
  28. package/src/adapters/agy/definition.ts +61 -0
  29. package/src/adapters/agy/entry.ts +76 -0
  30. package/src/adapters/agy/hooks.ts +81 -0
  31. package/src/adapters/claude-channel.ts +1 -0
  32. package/src/adapters/claude-print.ts +18 -156
  33. package/src/adapters/codex-review-isolation.ts +290 -0
  34. package/src/adapters/definitions/builtin-metadata.ts +9 -6
  35. package/src/adapters/definitions/native-cli-entry-claude.ts +3 -0
  36. package/src/adapters/definitions/native-cli-entry-codex.ts +5 -1
  37. package/src/adapters/definitions/native-cli-entry-jcode.ts +2 -0
  38. package/src/adapters/definitions/native-cli-entry-kimi.ts +12 -4
  39. package/src/adapters/definitions/native-cli-entry-local.ts +3 -0
  40. package/src/adapters/definitions/native-cli-entry-pi.ts +7 -1
  41. package/src/adapters/definitions/native-cli-entry-qwen.ts +3 -0
  42. package/src/adapters/definitions/native-cli-entry.ts +2 -0
  43. package/src/adapters/definitions/native-cli-retained.ts +15 -0
  44. package/src/adapters/definitions/native-factories.ts +17 -0
  45. package/src/adapters/definitions/native-policy-aux.ts +1 -1
  46. package/src/adapters/definitions/qwen.ts +1 -1
  47. package/src/adapters/definitions/reviewer-variants.ts +33 -0
  48. package/src/adapters/jcode.ts +20 -5
  49. package/src/adapters/kimi-review.ts +161 -0
  50. package/src/adapters/registry-types.ts +36 -4
  51. package/src/adapters/registry.ts +28 -7
  52. package/src/adapters/reviewer-mcp.ts +85 -0
  53. package/src/adapters/reviewer-print.ts +382 -0
  54. package/src/cli/agy-mcp.ts +370 -0
  55. package/src/cli/console-state.ts +6 -0
  56. package/src/cli/console.ts +2 -0
  57. package/src/cli/facts-hook.ts +15 -2
  58. package/src/cli/help.ts +2 -0
  59. package/src/cli/identity.ts +1 -1
  60. package/src/cli/init.ts +7 -0
  61. package/src/cli/launch.ts +3 -1
  62. package/src/cli/main.ts +20 -0
  63. package/src/cli/memory.ts +13 -12
  64. package/src/cli/output.ts +5 -0
  65. package/src/hub/board.ts +34 -6
  66. package/src/hub/bus.ts +17 -6
  67. package/src/hub/config-apply.ts +3 -2
  68. package/src/hub/control-client.ts +7 -1
  69. package/src/hub/daemon.ts +266 -46
  70. package/src/hub/envelope.ts +1 -1
  71. package/src/hub/host-executable.ts +1 -1
  72. package/src/hub/hub-tools.ts +5 -0
  73. package/src/hub/instances.ts +13 -3
  74. package/src/hub/memory-profile.ts +67 -11
  75. package/src/hub/native-hook-launch.ts +37 -0
  76. package/src/hub/peer-catalog.ts +2 -3
  77. package/src/hub/planning.ts +2 -0
  78. package/src/hub/release-admission.ts +64 -0
  79. package/src/hub/reviewer-pool-config.ts +11 -0
  80. package/src/hub/reviewer-pool.ts +76 -0
  81. package/src/hub/routing.ts +45 -3
  82. package/src/hub/sdlc-release.ts +113 -0
  83. package/src/hub/tasks.ts +261 -36
  84. package/src/ui/index.html +6 -1
  85. package/templates/AGENTS.block.md +1 -1
@@ -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);
@@ -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"]),
@@ -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
- if (!channel && (!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");
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: StoreOptions = {}, private readonly onUnavailable?: (neutralReason: string) => void, directMemoryDir?: string) {
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({ ...resolveProfile(this.home) });
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 = resolveProfile(currentHome);
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: StoreOptions = {}, onUnavailable?: (neutralReason: string) => void): {
193
+ export function openProfileMemory(opts: ProfileMemoryOptions = {}, onUnavailable?: (neutralReason: string) => void): {
153
194
  store: MemoryStore; binding: MemoryProfileBinding;
154
195
  } {
155
- const binding = new MemoryProfileBinding(opts, onUnavailable);
156
- binding.assertCurrent();
157
- const raw = new MemoryStore({ ...opts, profile: binding.explicitProfile });
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
+ }
@@ -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 const isSupportedPeer = (value: unknown): value is KnownPeer =>
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";
@@ -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
+ }
@@ -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
- if (named !== undefined && named !== reviewOwner && !blocked(named, "reviewer") && sameFamily(named) && independent.length) {
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
  /**