@staix/agent-hub 0.12.36 → 0.12.37

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 (52) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +13 -8
  3. package/docs/agent-notes/adapters.md +7 -3
  4. package/docs/agent-notes/budget.md +2 -1
  5. package/docs/agent-notes/daemon.md +5 -1
  6. package/docs/agent-notes/tasks.md +5 -2
  7. package/docs/agent-notes/tests.md +2 -2
  8. package/docs/memory.md +3 -1
  9. package/docs/operations.md +42 -10
  10. package/docs/quickstart.md +7 -7
  11. package/docs/sdlc.md +29 -3
  12. package/docs/security.md +6 -2
  13. package/docs/smoke.md +18 -2
  14. package/docs/specs/2026-09-19-agent-hub-design.md +34 -4
  15. package/docs/verification/2026-10-19-0.12.37.md +60 -0
  16. package/docs/verification/2026-10-19-jcode-0.98.1.md +30 -0
  17. package/docs/verification/2026-10-19-reviewer-isolation.md +119 -0
  18. package/docs/verified.json +18 -14
  19. package/package.json +2 -2
  20. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  21. package/plugins/agent-hub/server.js +3091 -2236
  22. package/src/adapters/acp.ts +3 -2
  23. package/src/adapters/claude-print.ts +18 -156
  24. package/src/adapters/codex-review-isolation.ts +276 -0
  25. package/src/adapters/definitions/builtin-metadata.ts +4 -1
  26. package/src/adapters/definitions/native-factories.ts +17 -0
  27. package/src/adapters/definitions/native-policy-aux.ts +1 -1
  28. package/src/adapters/definitions/reviewer-variants.ts +32 -0
  29. package/src/adapters/jcode.ts +20 -5
  30. package/src/adapters/registry-types.ts +7 -3
  31. package/src/adapters/registry.ts +17 -5
  32. package/src/adapters/reviewer-print.ts +319 -0
  33. package/src/cli/help.ts +2 -0
  34. package/src/cli/identity.ts +1 -1
  35. package/src/cli/main.ts +19 -0
  36. package/src/cli/memory.ts +13 -12
  37. package/src/hub/board.ts +34 -6
  38. package/src/hub/config-apply.ts +3 -2
  39. package/src/hub/control-client.ts +1 -1
  40. package/src/hub/daemon.ts +101 -27
  41. package/src/hub/envelope.ts +1 -1
  42. package/src/hub/host-executable.ts +1 -1
  43. package/src/hub/hub-tools.ts +5 -0
  44. package/src/hub/instances.ts +13 -3
  45. package/src/hub/memory-profile.ts +67 -11
  46. package/src/hub/planning.ts +2 -0
  47. package/src/hub/release-admission.ts +64 -0
  48. package/src/hub/reviewer-pool-config.ts +11 -0
  49. package/src/hub/reviewer-pool.ts +70 -0
  50. package/src/hub/routing.ts +42 -3
  51. package/src/hub/sdlc-release.ts +113 -0
  52. package/src/hub/tasks.ts +254 -36
@@ -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 };
@@ -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,70 @@
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
+ const seed = Number.isSafeInteger(input.seed) ? input.seed : 0;
26
+ const start = ((seed % kinds.length) + kinds.length) % kinds.length;
27
+ const rotated = [...kinds.slice(start), ...kinds.slice(0, start)];
28
+ const slots = rotated.flatMap(kind => {
29
+ const declared = input.candidates.filter(candidate => candidate.kind === kind).sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
30
+ return declared.length ? declared : [{ id: "", kind, family: "", blocked: "no declared reviewer" }];
31
+ });
32
+ let ordered = slots;
33
+ // Preserve the rotated order within each family, including sibling instance ids.
34
+ const continuity = !input.rerotate && input.continuityFamily !== undefined && input.continuityFamily !== input.implementerFamily;
35
+ if (continuity) ordered = [...ordered.filter(candidate => candidate.family === input.continuityFamily), ...ordered.filter(candidate => candidate.family !== input.continuityFamily)];
36
+ const failed = new Set(input.failed ?? []);
37
+ let skippedOwner = false, skippedUnavailable = false;
38
+ const unavailable = (candidate: ReviewerPoolCandidate) => failed.has(candidate.id) ? "start failed for this review" : candidate.blocked;
39
+ const record = (candidate: ReviewerPoolCandidate, reason: string) => { walk.push({ id: candidate.id, kind: candidate.kind, family: candidate.family, reason }); };
40
+ for (const candidate of ordered) {
41
+ if (!candidate.id) {
42
+ skippedUnavailable = true;
43
+ record(candidate, "no declared reviewer");
44
+ continue;
45
+ }
46
+ if (input.implementerFamily !== undefined && candidate.family === input.implementerFamily) {
47
+ skippedOwner = true;
48
+ record(candidate, "implementer skip");
49
+ continue;
50
+ }
51
+ const blocked = unavailable(candidate);
52
+ if (blocked !== undefined) {
53
+ skippedUnavailable = true;
54
+ record(candidate, `unavailable skip: ${blocked}`);
55
+ continue;
56
+ }
57
+ const reason: ReviewerPoolReason = skippedUnavailable ? "unavailable skip" : skippedOwner ? "implementer skip" : "rotation";
58
+ record(candidate, `selected (${continuity && candidate.family === input.continuityFamily ? "continuity" : reason})`);
59
+ return { reviewer: candidate.id, family: candidate.family, reason, walk };
60
+ }
61
+ // The caller supplies separate fresh reviewer instances; this does not authorize an implementer's own session.
62
+ for (const candidate of ordered) {
63
+ if (!candidate.id || input.implementerFamily === undefined || candidate.family !== input.implementerFamily) continue;
64
+ const blocked = unavailable(candidate);
65
+ if (blocked !== undefined) { record(candidate, `unavailable skip: ${blocked}`); continue; }
66
+ record(candidate, "selected (same-family fallback)");
67
+ return { reviewer: candidate.id, family: candidate.family, reason: "same-family fallback", walk };
68
+ }
69
+ return { reviewer: null, walk, blocked: "no reviewer can run" };
70
+ }
@@ -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,8 @@ 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 unavailable = role === "reviewer" ? definitionOf(peer, opts.instances)?.reviewer?.unavailable : undefined;
372
+ if (unavailable) return deny("policy", unavailable);
367
373
  if (role === "owner" && opts.capabilities && !pii) {
368
374
  const caps = peerEntry(opts.capabilities, peer);
369
375
  if (needsTests && !caps?.tests) return deny("tests", `missing tests: ${caps?.testsReason ?? "capability unavailable"}`);
@@ -462,6 +468,8 @@ export function assign(
462
468
  let reviewer: PeerId | undefined;
463
469
  let sameFamilyFallback = false;
464
470
  let plannerFallback = false;
471
+ let reviewSelection: ReviewerPoolSelection | undefined;
472
+ let reviewBlocked = false;
465
473
  if (task.class !== "review") {
466
474
  if (pii) trace.push("reviewer: user (pii: no second on-prem peer; ahub review <id> <verdict>)");
467
475
  else {
@@ -475,7 +483,38 @@ export function assign(
475
483
  const sameFamily = (peer: PeerId) => ownerFamily !== undefined && familyOf(peer, opts.instances) === ownerFamily;
476
484
  const independent = ownerFamily === undefined ? [] : automatic.filter(peer => peer !== reviewOwner && !blocked(peer, "reviewer") && !sameFamily(peer));
477
485
  const named = opts.namedReviewer;
478
- if (named !== undefined && named !== reviewOwner && !blocked(named, "reviewer") && sameFamily(named) && independent.length) {
486
+ const declaredPool = opts.reviewPool && named === undefined ? Object.keys(opts.instances ?? {}).flatMap(id => {
487
+ const roles = peerEntry(opts.roles, id), definition = definitionOf(id, opts.instances);
488
+ const kind = kindOf(id, opts.instances), family = familyOf(id, opts.instances);
489
+ if (roles?.length !== 1 || roles[0] !== "reviewer" || !definition?.routing?.reviewOnly || !kind || !family) return [];
490
+ const reason = id === reviewOwner ? "is the owner" : definition.reviewer?.unavailable ?? blocked(id, "reviewer")
491
+ ?? (states[id] === "paused" ? "paused" : undefined)
492
+ ?? peerEntry(opts.held, id)
493
+ ?? ((peerEntry(opts.quota, id)?.headroom ?? Infinity) <= 0 ? "quota exhausted" : undefined);
494
+ return [{ id, kind, family, ...(reason === undefined ? {} : { blocked: reason }) }];
495
+ }) : [];
496
+ if (declaredPool.length || opts.reviewPool?.required && named === undefined) {
497
+ 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"}`);
498
+ const last = task.history?.findLast(entry => entry.event === "changes_requested");
499
+ const continuityFamily = last && "reviewerFamily" in last && typeof last.reviewerFamily === "string" ? last.reviewerFamily : last ? familyOf(last.by, opts.instances) : undefined;
500
+ const pr = task.sdlc?.pr;
501
+ const seed = typeof pr === "number" && Number.isSafeInteger(pr) && pr > 0 ? pr : task.id ?? 0;
502
+ reviewSelection = selectReviewerPool({ pool: opts.reviewPool!.pool, candidates: declaredPool, seed, implementerFamily: ownerFamily, continuityFamily, rerotate: opts.reviewPool!.rerotate, failed: opts.reviewPool!.failed });
503
+ 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}`);
504
+ reviewer = reviewSelection.reviewer ?? "user";
505
+ if (reviewSelection.blocked) {
506
+ trace.push("blocked: no reviewer can run");
507
+ // Optional defaults retain the legacy planner escape when no fresh reviewer can run.
508
+ 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"));
509
+ const otherFamily = planners.filter(peer => !sameFamily(peer));
510
+ const planner = pick(otherFamily.length ? otherFamily : planners, "reviewer", reviewOwner);
511
+ if (planner) {
512
+ reviewer = planner;
513
+ plannerFallback = true;
514
+ trace.push(` planner fallback: no declared reviewer-only instance is eligible; ${planner} reviews`);
515
+ } else reviewBlocked = true;
516
+ }
517
+ } else if (named !== undefined && named !== reviewOwner && !blocked(named, "reviewer") && sameFamily(named) && independent.length) {
479
518
  const reason = `same family as ${reviewOwner}; eligible other-family reviewer available`;
480
519
  refusals.set(`reviewer:${named}`, { peer: named, role: "reviewer", code: "family", reason });
481
520
  trace.push(` reviewer candidate ${named}: skipped, ${reason}`);
@@ -505,7 +544,7 @@ export function assign(
505
544
  const piBackend = policy?.pi_backend;
506
545
  if (owner && definitionOf(owner, opts.instances)?.routing?.trace === "route") trace.push(`route: ${route ?? "(none)"}, fixed_model ${fixedModel}`);
507
546
  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 };
547
+ 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
548
  }
510
549
 
511
550
  /**
@@ -1,3 +1,5 @@
1
+ import type { Task } from "./board.ts";
2
+ import type { Release } from "./planning.ts";
1
3
  import { spawn } from "node:child_process";
2
4
  import { fileURLToPath } from "node:url";
3
5
  import { childEnv, stopOwnedProcess } from "./child-process.ts";
@@ -123,3 +125,114 @@ export async function readReleaseReadiness(root: string, repo: string, liveIssue
123
125
  report.blockers.push("native acceptance and person release decision unverified");
124
126
  return report;
125
127
  }
128
+
129
+ export type HotfixCause = "loss" | "security_privacy" | "upgrade_start_blocked" | "core_flow_no_workaround";
130
+ export interface ReleaseTrainData {
131
+ hotfix?: { cause: HotfixCause; reason: string };
132
+ scope?: number[];
133
+ history: { at: number; by: string; event: "configured" | "cut" | "shipped"; scope?: number[]; ciRuns?: number; reviews?: number }[];
134
+ }
135
+ export const RELEASE_TRAIN_HISTORY_LIMIT = 256;
136
+ const HOTFIX_CAUSES: readonly string[] = ["loss", "security_privacy", "upgrade_start_blocked", "core_flow_no_workaround"];
137
+ const trainObject = (value: unknown): Record<string, unknown> => {
138
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("release train: expected an object");
139
+ return value as Record<string, unknown>;
140
+ };
141
+ const trainKeys = (value: Record<string, unknown>, keys: readonly string[]) => {
142
+ if (Object.keys(value).some(key => !keys.includes(key))) throw new Error("release train: unknown field");
143
+ };
144
+ const trainScope = (value: unknown): number[] => {
145
+ // ponytail: 1000 tasks per release; raise together with bounded report readers if larger trains are needed.
146
+ if (!Array.isArray(value) || value.length > 1000 || value.some(id => !Number.isSafeInteger(id) || id < 1) || new Set(value).size !== value.length) throw new Error("release train: invalid scope");
147
+ return [...value].sort((a, b) => a - b);
148
+ };
149
+
150
+ /** Structural validation only: the Tasks boundary screens reason text and establishes caller authority. */
151
+ export function releaseTrainData(input: unknown): ReleaseTrainData {
152
+ const raw = trainObject(input); trainKeys(raw, ["hotfix", "scope", "history"]);
153
+ const result: ReleaseTrainData = { history: [] };
154
+ if (raw.hotfix !== undefined) {
155
+ const hotfix = trainObject(raw.hotfix); trainKeys(hotfix, ["cause", "reason"]);
156
+ if (typeof hotfix.cause !== "string" || !HOTFIX_CAUSES.includes(hotfix.cause) || typeof hotfix.reason !== "string" || !hotfix.reason.trim() || Buffer.byteLength(hotfix.reason) > 1000) throw new Error("release train: invalid hotfix cause or reason");
157
+ result.hotfix = { cause: hotfix.cause as HotfixCause, reason: hotfix.reason.trim() };
158
+ }
159
+ if (raw.scope !== undefined) result.scope = trainScope(raw.scope);
160
+ // ponytail: 256 recorded train events; archive settled release evidence before increasing this ceiling.
161
+ if (!Array.isArray(raw.history) || raw.history.length > RELEASE_TRAIN_HISTORY_LIMIT) throw new Error("release train: invalid history");
162
+ result.history = raw.history.map(value => {
163
+ const row = trainObject(value); trainKeys(row, ["at", "by", "event", "scope", "ciRuns", "reviews"]);
164
+ if (!Number.isSafeInteger(row.at) || (row.at as number) < 1 || (row.at as number) > 8_640_000_000_000_000 || typeof row.by !== "string" || !/^[a-z][a-z0-9-]{0,63}$/.test(row.by) || !["configured", "cut", "shipped"].includes(String(row.event))) throw new Error("release train: invalid history event");
165
+ const event: ReleaseTrainData["history"][number] = { at: row.at as number, by: row.by, event: row.event as "configured" | "cut" | "shipped" };
166
+ if (row.scope !== undefined) event.scope = trainScope(row.scope);
167
+ for (const key of ["ciRuns", "reviews"] as const) if (row[key] !== undefined) {
168
+ if (!Number.isSafeInteger(row[key]) || (row[key] as number) < 0 || (row[key] as number) > 1_000_000) throw new Error("release train: invalid resource count");
169
+ event[key] = row[key] as number;
170
+ }
171
+ return event;
172
+ });
173
+ if (result.history.some((row, index) => index > 0 && row.at < result.history[index - 1]!.at)) throw new Error("release train: history dates are out of order");
174
+ return result;
175
+ }
176
+
177
+ const storedTrain = (release: Release): ReleaseTrainData | undefined => {
178
+ const raw = (release as Release & { train?: ReleaseTrainData }).train;
179
+ if (raw === undefined) return undefined;
180
+ try { return releaseTrainData(raw); } catch { return undefined; }
181
+ };
182
+
183
+ /** Board metadata only; a trigger is a request to cut, never release authorization or native readiness. */
184
+ export function releaseTrainReadiness(release: Release, tasks: Task[], now = Date.now()) {
185
+ const train = storedTrain(release), assigned = tasks.filter(task => task.release === release.id), frozen = ["frozen", "shipped"].includes(release.state);
186
+ const scope = frozen ? train?.scope : assigned.map(task => task.id);
187
+ const blockers: string[] = [];
188
+ if ((release as Release & { train?: ReleaseTrainData }).train !== undefined && !train) blockers.push("release train evidence unknown");
189
+ if (!scope) blockers.push("release scope unknown");
190
+ else if (!scope.length) blockers.push("release scope empty");
191
+ if (frozen && scope && JSON.stringify([...scope].sort((a, b) => a - b)) !== JSON.stringify(assigned.map(task => task.id).sort((a, b) => a - b))) blockers.push("frozen release scope changed");
192
+ const rows = new Map(tasks.map(task => [task.id, task]));
193
+ const states = (scope ?? []).map(id => {
194
+ const task = rows.get(id), state = task?.state ?? "unknown", ready = state === "approved";
195
+ if (!task) blockers.push(`task #${id}: scope evidence unknown`);
196
+ else if (task.release !== release.id) blockers.push(`task #${id}: release assignment changed`);
197
+ if (!ready) blockers.push(`task #${id}: not approved`);
198
+ return { id, state, ready };
199
+ });
200
+ const trigger: "tasks_approved" | "planned_date" | "waiting" = states.length && states.every(task => task.ready) && !blockers.length ? "tasks_approved"
201
+ : release.cut !== null && Number.isFinite(now) && release.cut <= now ? "planned_date" : "waiting";
202
+ return { trigger, tasks: states, blockers };
203
+ }
204
+
205
+ /** Counts are explicit recorded event deltas, not inferred from successful gates or absent history. */
206
+ export function releaseMetrics(releases: Release[], tasks: Task[]) {
207
+ const unknown = new Set<number>(), shipped: { id: number; at: number }[] = [];
208
+ const approvalToReleaseMs: { release: number; task: number; ms: number | null }[] = [];
209
+ let hotfixCount = 0, knownKinds = 0, ciRuns = 0, reviews = 0, ciKnown = true, reviewsKnown = true;
210
+ const byTask = new Map(tasks.map(task => [task.id, task]));
211
+ const settled = releases.filter(release => release.state === "shipped");
212
+ for (const release of settled) {
213
+ const train = storedTrain(release);
214
+ if (!train?.history.length) { unknown.add(release.id); ciKnown = false; reviewsKnown = false; continue; }
215
+ knownKinds++;
216
+ if (train.hotfix) hotfixCount++;
217
+ for (const key of ["ciRuns", "reviews"] as const) {
218
+ const recorded = train.history.flatMap(event => event[key] === undefined ? [] : [event[key]!]);
219
+ if (!recorded.length) { unknown.add(release.id); if (key === "ciRuns") ciKnown = false; else reviewsKnown = false; }
220
+ else if (key === "ciRuns") ciRuns += recorded.reduce((sum, value) => sum + value, 0);
221
+ else reviews += recorded.reduce((sum, value) => sum + value, 0);
222
+ }
223
+ const event = [...train.history].reverse().find(row => row.event === "shipped");
224
+ if (!event || release.shippedAt !== event.at) { unknown.add(release.id); continue; }
225
+ shipped.push({ id: release.id, at: event.at });
226
+ const scope = train.scope ?? [...train.history].reverse().find(row => row.event === "cut")?.scope;
227
+ if (!scope) { unknown.add(release.id); continue; }
228
+ for (const id of scope) {
229
+ const task = byTask.get(id), approved = task?.history.findLast(row => row.at <= event.at && (row.event === "approved" || row.event === "done" && task.state === "approved" && task.reviewer === null))?.at ?? null;
230
+ const ms = approved !== null && approved <= event.at ? event.at - approved : null;
231
+ if (ms === null) unknown.add(release.id);
232
+ approvalToReleaseMs.push({ release: release.id, task: id, ms });
233
+ }
234
+ }
235
+ shipped.sort((a, b) => a.at - b.at || a.id - b.id);
236
+ const intervals = shipped.length !== releases.filter(release => release.state === "shipped").length ? [] : shipped.slice(1).map((row, index) => ({ from: shipped[index]!.id, to: row.id, ms: row.at - shipped[index]!.at }));
237
+ return { intervals, approvalToReleaseMs, hotfixCount: knownKinds === settled.length && knownKinds > 0 ? hotfixCount : null, hotfixRate: knownKinds === settled.length && knownKinds > 0 ? hotfixCount / knownKinds : null, ciRuns: ciKnown && settled.length ? ciRuns : null, reviews: reviewsKnown && settled.length ? reviews : null, unknown: [...unknown].sort((a, b) => a - b) };
238
+ }