@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
@@ -17,15 +17,29 @@ export function jcodeCommand(command: string[], kind: "serve" | "acp", socket: s
17
17
  return [...command, kind, "--socket", socket, "--cwd", cwd, "--no-update", "--no-selfdev", ...(model ? ["--model", model] : []), ...(profile ? ["--provider-profile", profile] : []),
18
18
  ...(kind === "serve" ? ["--temporary-server", "--owner-pid", String(process.pid), "--server-name", "agent-hub-jcode"] : ["--mcp-tools", "eager"])];
19
19
  }
20
+ /** Set on every owned jcode process: no Claude MCP import, and no native telemetry (jcode-telemetry-core is_enabled, #529). */
21
+ const JCODE_OWNED_ENV = { JCODE_DISABLE_CLAUDE_MCP: "1", JCODE_NO_TELEMETRY: "1" } as const;
22
+ /** The oldest native release accepted; its ACP, serve and MCP contract was verified live on 0.98.1 (docs/verification). */
23
+ export const JCODE_MIN_VERSION = "0.98.0";
24
+ /** The first native release assumed to break that contract: the next major. Lower it when a release is shown to break. */
25
+ export const JCODE_BREAKING_VERSION = "1.0.0";
26
+ const release = (version: string) => /^(\d+)\.(\d+)\.(\d+)$/.exec(version)?.slice(1).map(Number);
27
+ const compare = (a: number[], b: number[]) => a[0]! - b[0]! || a[1]! - b[1]! || a[2]! - b[2]!;
28
+ /** A plain release from JCODE_MIN_VERSION up to, not including, JCODE_BREAKING_VERSION; the minor is not pinned (#529). */
29
+ export function jcodeVersionSupported(version: unknown): boolean {
30
+ const v = typeof version === "string" ? release(version) : undefined;
31
+ return !!v && compare(v, release(JCODE_MIN_VERSION)!) >= 0 && compare(v, release(JCODE_BREAKING_VERSION)!) < 0;
32
+ }
33
+
20
34
  /** The wrapper contract preserves every configured prefix argument before the native probe. */
21
35
  export function verifiedJcodeCommand(command: string[], cwd: string): boolean {
22
36
  const [bin, ...prefix] = command;
23
37
  if (!bin) return false;
24
38
  try {
25
- const result = spawnSync(bin, [...prefix, "--quiet", "version", "--json"], { cwd, env: peerChildEnv("jcode"), encoding: "utf8", timeout: 10_000, maxBuffer: 64 * 1024 });
39
+ const result = spawnSync(bin, [...prefix, "--quiet", "version", "--json"], { cwd, env: { ...peerChildEnv("jcode"), ...JCODE_OWNED_ENV }, encoding: "utf8", timeout: 10_000, maxBuffer: 64 * 1024 });
26
40
  if (result.status !== 0) return false;
27
41
  const version = JSON.parse(result.stdout);
28
- return version.semver === "0.94.0";
42
+ return jcodeVersionSupported(version.semver);
29
43
  } catch { return false; }
30
44
  }
31
45
 
@@ -41,9 +55,10 @@ export class JcodePeer extends AcpPeer {
41
55
  constructor(private readonly jcode: JcodeOptions) {
42
56
  if (!isAbsolute(jcode.home)) throw new Error("jcode_home must be an absolute native data path");
43
57
  jcodeCommand(jcode.command, "acp", "preview", jcode.cwd);
44
- const root = mkdtempSync(join(tmpdir(), "ahub-jcode-")), socket = join(root, "s");
58
+ // The name must end in ".sock": jcode names its debug socket by replacing that suffix, so "s" binds one path twice.
59
+ const root = mkdtempSync(join(tmpdir(), "ahub-jcode-")), socket = join(root, "j.sock");
45
60
  const cmd = jcodeCommand(jcode.command, "acp", socket, jcode.cwd, jcode.launchModel, jcode.providerProfile);
46
- super("jcode", { ...jcode, cmd, env: { ...jcode.env, JCODE_HOME: jcode.home, JCODE_SOCKET: socket, JCODE_DISABLE_CLAUDE_MCP: "1" }, mcpServers: [], requireLoadSessionEcho: true, expectedAgent: { name: "jcode", version: "0.94.0" } });
61
+ super("jcode", { ...jcode, cmd, env: { ...jcode.env, JCODE_HOME: jcode.home, JCODE_SOCKET: socket, ...JCODE_OWNED_ENV }, mcpServers: [], requireLoadSessionEcho: true, expectedAgent: { name: "jcode", version: jcodeVersionSupported } });
47
62
  this.root = root; this.socket = socket;
48
63
  }
49
64
 
@@ -53,7 +68,7 @@ export class JcodePeer extends AcpPeer {
53
68
  if (existsSync(this.socket)) { this.preserveRoot = true; throw new Error("Jcode private socket is occupied; no daemon was adopted or removed"); }
54
69
  this.ending = false;
55
70
  const [bin, ...args] = jcodeCommand(this.jcode.command, "serve", this.socket, this.jcode.cwd, this.jcode.launchModel, this.jcode.providerProfile);
56
- const server = spawn(bin!, args, { cwd: this.jcode.cwd, detached: true, env: { ...peerChildEnv("jcode"), JCODE_HOME: this.jcode.home, JCODE_SOCKET: this.socket, JCODE_READY_FD: "3", JCODE_DISABLE_CLAUDE_MCP: "1" }, stdio: ["ignore", "ignore", "ignore", "pipe"] });
71
+ const server = spawn(bin!, args, { cwd: this.jcode.cwd, detached: true, env: { ...peerChildEnv("jcode"), JCODE_HOME: this.jcode.home, JCODE_SOCKET: this.socket, JCODE_READY_FD: "3", ...JCODE_OWNED_ENV }, stdio: ["ignore", "ignore", "ignore", "pipe"] });
57
72
  this.server = server; trackGroup(server);
58
73
  try {
59
74
  await new Promise<void>((resolve, reject) => {
@@ -1,3 +1,4 @@
1
+ import type { ReviewerBackend, ReviewerPrintOptions } from "./reviewer-print.ts";
1
2
  import type { SettingDef } from "../hub/settings.ts";
2
3
  import type { Addon } from "../hub/tooling.ts";
3
4
  import type { RecoveryContinuityBookmark } from "../hub/recovery-continuity.ts";
@@ -16,7 +17,7 @@ import type { PiOptions } from "./pi.ts";
16
17
  import type { ClaudePrintOptions } from "./claude-print.ts";
17
18
 
18
19
  export type StartForm = "tui" | "headless";
19
- export type DaemonEngine = "codex-appserver" | "acp" | "owned-acp" | "pi-rpc" | "local-worker" | "claude-print";
20
+ export type DaemonEngine = "codex-appserver" | "acp" | "owned-acp" | "pi-rpc" | "local-worker" | "claude-print" | "reviewer-print";
20
21
 
21
22
  /** Existing buildLaunch inputs plus the startup-bound instance and project identity. */
22
23
  export interface LaunchContext {
@@ -55,7 +56,8 @@ export type DaemonPorts =
55
56
  | { jcode: JcodeOptions }
56
57
  | { pi: PiOptions }
57
58
  | { local: LocalOptions }
58
- | { claudePrint: ClaudePrintOptions };
59
+ | { claudePrint: ClaudePrintOptions }
60
+ | { reviewerPrint: ReviewerPrintOptions };
59
61
 
60
62
  export interface CreateContext<Ports extends DaemonPorts = DaemonPorts> {
61
63
  instance: string;
@@ -264,7 +266,9 @@ export interface AdapterDefinition {
264
266
  hooks?: Partial<NativeHooks>;
265
267
  /** #223: what a declared headless reviewer-only instance of this kind runs as. Fixed builtins only. */
266
268
  reviewerVariant?: AdapterDefinition;
269
+ /** Fixed host backend; runtime isolation proof is required before bus admission. */
270
+ reviewer?: { backend?: ReviewerBackend; defaultPool: boolean; unavailable?: string; declaration: "automatic" | "explicit" };
267
271
  }
268
272
 
269
273
  /** Explicit project declarations, passed by the bound host. No process-global instance registration. */
270
- export type KindMap = Readonly<Record<string, { readonly kind: string }>>;
274
+ export type KindMap = Readonly<Record<string, { readonly kind: string; readonly roles?: readonly string[]; readonly start_mode?: "tui" | "headless"; readonly review_mode?: "fresh" }>>;
@@ -1,3 +1,4 @@
1
+ import { AGY_DEFINITION } from "./definitions/reviewer-variants.ts";
1
2
  import { builtinCliEntries } from "./definitions/native-cli-entry.ts";
2
3
  import { builtinPreview } from "./definitions/native-cli-preview.ts";
3
4
  import { DAEMON_PORTS } from "./definitions/native-factories.ts";
@@ -25,7 +26,7 @@ function immutable<T>(value: T, seen = new WeakSet<object>()): T {
25
26
  return Object.freeze(value);
26
27
  }
27
28
 
28
- const fixedBuiltinDefinitions = new Set<AdapterDefinition>([...NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN, QWEN_DEFINITION]);
29
+ const fixedBuiltinDefinitions = new Set<AdapterDefinition>([...NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN, QWEN_DEFINITION, AGY_DEFINITION]);
29
30
  for (const definition of [...fixedBuiltinDefinitions]) if (definition.reviewerVariant) fixedBuiltinDefinitions.add(definition.reviewerVariant);
30
31
 
31
32
  /** Keep the native catalog's order, independently of the legacy default-role object order. */
@@ -34,8 +35,10 @@ export const ADAPTERS: readonly AdapterDefinition[] = immutable([
34
35
  QWEN_DEFINITION,
35
36
  ...NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN.filter(definition => definition.kind === "jcode"),
36
37
  ]);
38
+ // Unavailable declarations are metadata, not supported native peers or launchers.
39
+ const declarationOnlyDefinitions: readonly AdapterDefinition[] = immutable([AGY_DEFINITION]);
37
40
  const definitions = new Map<string, AdapterDefinition>();
38
- for (const definition of ADAPTERS) {
41
+ for (const definition of [...ADAPTERS, ...declarationOnlyDefinitions]) {
39
42
  assertDaemonEngine(definition);
40
43
  assertNativeCapabilityScope(definition);
41
44
  if (definition.reviewerVariant) { assertDaemonEngine(definition.reviewerVariant); assertNativeCapabilityScope(definition.reviewerVariant); }
@@ -43,6 +46,10 @@ for (const definition of ADAPTERS) {
43
46
  definitions.set(definition.kind, definition);
44
47
  }
45
48
 
49
+ /** Default kind slots derive from fixed reviewer policy, not a second vendor list in the host. */
50
+ export const REVIEW_POOL_KINDS: readonly string[] = Object.freeze([...definitions.values()].filter(definition => !!(definition.reviewerVariant ?? definition).reviewer).map(definition => definition.kind));
51
+ export const DEFAULT_REVIEW_POOL: readonly string[] = Object.freeze(ADAPTERS.filter(definition => (definition.reviewerVariant ?? definition).reviewer?.defaultPool === true).map(definition => definition.kind));
52
+
46
53
  /** Native quota handoff platform names, distinct from per-delivery recall injection policy. */
47
54
  export const QUOTA_HANDOFF_PLATFORMS: Readonly<Record<string, string>> = Object.freeze(Object.fromEntries(
48
55
  ADAPTERS.flatMap(adapter => adapter.hooks?.recallPlatform ? [[adapter.kind, adapter.hooks.recallPlatform]] : []),
@@ -77,7 +84,11 @@ export function definitionOf(id: string, instances?: KindMap): AdapterDefinition
77
84
  const kind = kindOf(id, instances);
78
85
  const definition = kind === undefined ? undefined : definitions.get(kind);
79
86
  // #223: only a declared extra (never the builtin id) with start_mode "headless" runs as the kind's reviewer variant.
80
- return definition?.reviewerVariant && kind !== id && ownField(ownField(instances, id), "start_mode") === "headless" ? definition.reviewerVariant : definition;
87
+ const declaration = ownField(instances, id), roles = ownField(declaration, "roles");
88
+ const reviewerOnly = Array.isArray(roles) && !types.isProxy(roles) && roles.length === 1 && Object.getOwnPropertyDescriptor(roles, "0")?.value === "reviewer";
89
+ const variant = definition?.reviewerVariant;
90
+ const fresh = variant?.reviewer?.declaration === "automatic" || ownField(declaration, "review_mode") === "fresh";
91
+ return variant && kind !== id && reviewerOnly && ownField(declaration, "start_mode") === "headless" && fresh ? variant : definition;
81
92
  }
82
93
  export function familyOf(id: string, instances?: KindMap): string | undefined {
83
94
  return definitionOf(id, instances)?.family;
@@ -125,6 +136,7 @@ export function assertNativeCapabilityScope(definition: AdapterDefinition): void
125
136
  ["start.daemonPort pi-rpc", definition.start.daemonPort === "pi-rpc"],
126
137
  ["routing", definition.routing !== undefined],
127
138
  ["reviewerVariant", definition.reviewerVariant !== undefined],
139
+ ["reviewer", definition.reviewer !== undefined],
128
140
  ];
129
141
  const field = restricted.find(([, present]) => present)?.[0];
130
142
  if (field) throw new Error(`${field} is restricted to fixed builtin adapter definitions`);
@@ -133,8 +145,8 @@ export function assertNativeCapabilityScope(definition: AdapterDefinition): void
133
145
  /** Ordered catalog projections consumed by generic core hosts. */
134
146
  export const STARTABLE_PEERS = SUPPORTED_PEERS;
135
147
  export type SupportedPeer = typeof SUPPORTED_PEERS[number];
136
- export function isSupportedPeer(peer: unknown): peer is SupportedPeer { return typeof peer === "string" && definitionOf(peer) !== undefined; }
137
- export function isStartablePeer(peer: unknown): peer is SupportedPeer { return typeof peer === "string" && definitionOf(peer)?.start.conductorStartable === true; }
148
+ export function isSupportedPeer(peer: unknown): peer is SupportedPeer { return typeof peer === "string" && ADAPTERS.some(definition => definition.kind === peer); }
149
+ export function isStartablePeer(peer: unknown): peer is SupportedPeer { return isSupportedPeer(peer) && definitionOf(peer)?.start.conductorStartable === true; }
138
150
 
139
151
  /** Body-free browser metadata, derived from the same native participation and managed-tool contracts. */
140
152
  export const ADAPTER_DISPLAY = Object.fromEntries(ADAPTERS.map(adapter => [adapter.kind, {
@@ -0,0 +1,319 @@
1
+ import { spawn, type ChildProcess } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { peerChildEnv, stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
4
+ import { HUB, frame } from "../hub/envelope.ts";
5
+ import { BasePeer } from "../hub/peers.ts";
6
+ import type { Envelope } from "../hub/envelope.ts";
7
+
8
+ export interface ReviewerPrintUsage { input_tokens?: number; output_tokens?: number; cache_creation_input_tokens?: number; cache_read_input_tokens?: number }
9
+ export interface ReviewerIsolationReceipt {
10
+ backend: string;
11
+ version: string;
12
+ binary: string;
13
+ profile: string;
14
+ readOnly: true;
15
+ strictMcp: true;
16
+ ephemeral: true;
17
+ evidence: readonly string[];
18
+ }
19
+ export interface ReviewerPrepareContext {
20
+ peer: string;
21
+ cwd: string;
22
+ sessionId: string;
23
+ mcpConfig: string;
24
+ systemPrompt: string;
25
+ privatePaths: readonly string[];
26
+ command?: readonly string[];
27
+ signal: AbortSignal;
28
+ }
29
+ export interface ReviewerLaunch {
30
+ command: readonly string[];
31
+ args: readonly string[];
32
+ isolation: ReviewerIsolationReceipt;
33
+ /** Recheck the fixed producer's binary/config binding immediately before the model child is spawned. */
34
+ current?: () => boolean;
35
+ env?: NodeJS.ProcessEnv;
36
+ cleanup?: () => void | Promise<void>;
37
+ }
38
+ export interface ReviewerBackend {
39
+ id: string;
40
+ prepare(context: ReviewerPrepareContext): Promise<ReviewerLaunch | { unavailable: string }>;
41
+ usage?(event: unknown): ReviewerPrintUsage | undefined;
42
+ failure?(event: unknown): "quota" | undefined;
43
+ }
44
+ export interface ReviewerFailure {
45
+ task: number;
46
+ head?: string;
47
+ flowRevision?: number;
48
+ cause: "start_failed" | "quota" | "no_verdict";
49
+ }
50
+
51
+ export interface ReviewerPrintOptions {
52
+ cwd: string;
53
+ command?: readonly string[];
54
+ mcpConfig: string;
55
+ systemPrompt: string;
56
+ privatePaths?: readonly string[];
57
+ watchdogMs: number;
58
+ backend: ReviewerBackend;
59
+ facts?(env: Envelope): string | undefined;
60
+ verdictSince(task: number, since: number): boolean;
61
+ onUsage?(usage: ReviewerPrintUsage, task: number): void;
62
+ onUnavailable?(failure: ReviewerFailure, current: () => boolean): void | Promise<void>;
63
+ log?(line: string): void;
64
+ }
65
+
66
+ // ponytail: 256 KiB per NDJSON frame and 4 MiB stdout per review child. Raise only with measured native receipts.
67
+ const MAX_REVIEW_FRAME_BYTES = 256 * 1024, MAX_REVIEW_OUTPUT_BYTES = 4 * 1024 * 1024;
68
+ class GroupCleanupError extends Error {}
69
+ type ReviewTarget = Omit<ReviewerFailure, "cause">;
70
+ interface ReviewBatch { targets: (ReviewTarget | undefined)[]; next: number; failures: ReviewerFailure[] }
71
+ const reviewTarget = (env: Envelope): ReviewTarget | undefined => {
72
+ const task = reviewTask(env);
73
+ if (task === undefined || env.private) return undefined;
74
+ const revision = Number(env.refs?.reviewRevision);
75
+ return { task, ...(typeof env.refs?.commit === "string" ? { head: env.refs.commit } : {}), ...(Number.isSafeInteger(revision) && revision > 0 ? { flowRevision: revision } : {}) };
76
+ };
77
+ const remainingFailures = (batch: ReviewBatch, start: number, cause: ReviewerFailure["cause"]): ReviewerFailure[] =>
78
+ batch.targets.slice(start).flatMap(target => target ? [{ ...target, cause }] : []);
79
+ const reviewTask = (env: Envelope): number | undefined => {
80
+ if (env.from !== HUB || env.kind !== "review") return undefined;
81
+ const id = Number(env.refs?.task);
82
+ return Number.isSafeInteger(id) && id > 0 ? id : undefined;
83
+ };
84
+
85
+ export class ReviewerPrintPeer extends BasePeer {
86
+ readonly hubNative = true;
87
+ private generation = 0;
88
+ private child: ChildProcess | undefined;
89
+ private activeDeliveryId: string | undefined;
90
+ private batch: ReviewBatch | undefined;
91
+ private preparation: { abort: AbortController; done: Promise<ReviewerLaunch> } | undefined;
92
+ private receipt: ReviewerIsolationReceipt | undefined;
93
+ get isolationReceipt(): ReviewerIsolationReceipt | undefined { return this.receipt; }
94
+ /** The task whose review the running child was started for (R4: the hub records `fresh` for its verdict). */
95
+ reviewing: number | undefined;
96
+ private reviewSeq = 0;
97
+ /** Identifies the running child's authority over `task`; a replacement child for the same task gets a new token. */
98
+ reviewToken(task: number): number | undefined { return this.reviewing === task ? this.reviewSeq : undefined; }
99
+
100
+ constructor(id: string, private readonly opts: ReviewerPrintOptions) { super(id, opts.watchdogMs); }
101
+
102
+ recoveryMetadata(): Record<string, unknown> { return { launch: { kind: "reviewer-print", peer: this.id } }; }
103
+
104
+ async start(): Promise<void> {
105
+ // A retained child means a review's process group was never confirmed gone: no new review beside it.
106
+ if (this.child) throw new Error(`${this.id}: an earlier review's process group is still running; stop it first`);
107
+ if (this.preparation) throw new Error(`${this.id}: reviewer preparation is still running`);
108
+ this.receipt = undefined;
109
+ const generation = ++this.generation;
110
+ try {
111
+ const launch = await this.prepare(randomUUID());
112
+ try {
113
+ if (generation !== this.generation || launch.current?.() === false) throw new Error("reviewer isolation changed before startup");
114
+ this.receipt = launch.isolation;
115
+ } finally { await launch.cleanup?.(); }
116
+ } catch (error) { if (generation === this.generation) this.setState("offline"); throw error; }
117
+ if (generation === this.generation) this.setState("idle");
118
+ }
119
+
120
+ async stop(reason?: string): Promise<void> {
121
+ this.generation++; // fence before the first await: a late exit settles nothing
122
+ this.receipt = undefined;
123
+ this.batch = undefined;
124
+ this.preparation?.abort.abort();
125
+ await this.preparation?.done.catch(() => {});
126
+ this.settle("needs_review", reason ?? "review stopped before settlement");
127
+ if (this.child) this.setState("busy"); // unavailable, not offline, until the group is confirmed gone
128
+ await this.kill(); // throws when the child could not be stopped; its handle is kept and the peer stays busy
129
+ this.setState("offline");
130
+ }
131
+
132
+ protected override onWatchdog(): void { this.abortReview("review watchdog timeout"); }
133
+
134
+ private abortReview(reason: "review watchdog timeout" | "review output limit exceeded"): void {
135
+ const generation = ++this.generation, batch = this.batch, preparation = this.preparation;
136
+ this.reviewing = undefined;
137
+ this.receipt = undefined;
138
+ this.clearWatchdog();
139
+ preparation?.abort.abort();
140
+ this.settle("needs_review", reason);
141
+ // Revoke first, then confirm both preparation and the model's whole group are gone before fallback.
142
+ void (async () => {
143
+ if (preparation) await preparation.done.catch(() => {});
144
+ if (generation !== this.generation) return;
145
+ await this.kill();
146
+ if (generation !== this.generation) return; // a person's stop owns the hold; never reroute it
147
+ if (batch) await this.reportFailures([...batch.failures, ...remainingFailures(batch, batch.next, "no_verdict")], generation);
148
+ if (generation === this.generation && this.state === "busy") { this.batch = undefined; super.onWatchdog(); }
149
+ })().catch((error: Error) => this.opts.log?.(`[${this.id}] review cleanup failed, staying busy`));
150
+ }
151
+
152
+ async deliver(envs: Envelope[], deliveryId?: string): Promise<void> {
153
+ if (this.state !== "idle") {
154
+ if (deliveryId) this.delivery({ id: deliveryId, state: "failed_safe", reason: `${this.id} is ${this.state}` });
155
+ throw new Error(`${this.id} is ${this.state}`);
156
+ }
157
+ const generation = ++this.generation;
158
+ this.activeDeliveryId = deliveryId;
159
+ this.batch = { targets: envs.map(reviewTarget), next: 0, failures: [] };
160
+ this.setState("busy");
161
+ if (deliveryId) this.delivery({ id: deliveryId, state: "accepted" });
162
+ void this.run(envs, generation).catch((error: Error) => {
163
+ if (generation !== this.generation) return;
164
+ this.settle("needs_review", `reviewer failed: ${error.message}`);
165
+ void this.kill().then(() => { if (generation === this.generation) { this.batch = undefined; this.setState("idle"); } }, () => undefined);
166
+ });
167
+ }
168
+
169
+ private async run(envs: Envelope[], generation: number): Promise<void> {
170
+ const missing: number[] = [];
171
+ let started = false;
172
+ const batch = this.batch!;
173
+ const reroute = batch.failures;
174
+ let failure: { state: "failed_safe" | "needs_review"; reason: string } | undefined;
175
+ for (const [index, env] of envs.entries()) {
176
+ batch.next = index;
177
+ const task = reviewTask(env);
178
+ if (task === undefined) continue; // a withdrawal or other notice: nothing to review
179
+ if (env.private) { failure = { state: "needs_review", reason: "a private review is the person's, never a cloud reviewer's" }; break; }
180
+ const since = Date.now();
181
+ let result: { code: number | null; quota: boolean };
182
+ try { result = await this.review(env, task, generation); }
183
+ catch (error) {
184
+ if (generation !== this.generation) return;
185
+ if (error instanceof GroupCleanupError) {
186
+ // Unavailable until a stop confirms the group gone: no new review runs beside the survivors.
187
+ this.opts.log?.(`[${this.id}] review of task #${task}: ${error.message}`);
188
+ this.settle("needs_review", `review process group still running: ${error.message}`);
189
+ return;
190
+ }
191
+ // Once an earlier review in this batch ran, the batch had effects: hold it for a person, never requeue it.
192
+ failure = { state: started ? "needs_review" : "failed_safe", reason: `reviewer could not start: ${(error as Error).message}` };
193
+ reroute.push(...remainingFailures(batch, index, "start_failed"));
194
+ batch.next = envs.length;
195
+ break;
196
+ }
197
+ started = true;
198
+ if (generation !== this.generation) return;
199
+ let recorded = false;
200
+ try { recorded = this.opts.verdictSince(task, since); } catch (error) { this.opts.log?.(`[${this.id}] verdict check for task #${task} failed: ${(error as Error).message}`); }
201
+ if (!recorded) {
202
+ missing.push(task);
203
+ const target = batch.targets[index];
204
+ if (target) reroute.push({ ...target, cause: result.quota ? "quota" : "no_verdict" });
205
+ if (result.quota) {
206
+ reroute.push(...remainingFailures(batch, index + 1, "quota"));
207
+ batch.next = envs.length;
208
+ break;
209
+ }
210
+ }
211
+ batch.next = index + 1;
212
+ if (result.code !== 0) this.opts.log?.(`[${this.id}] review of task #${task} exited ${result.code}`);
213
+ }
214
+ if (generation !== this.generation) return;
215
+ this.clearWatchdog(); // the child is gone; keep busy while the host settles the remaining walk
216
+ this.settle(failure?.state ?? (missing.length ? "needs_review" : "completed"), failure?.reason ?? (missing.length ? `no verdict recorded for task ${missing.map(id => `#${id}`).join(", ")}` : undefined));
217
+ await this.reportFailures(reroute, generation);
218
+ if (generation === this.generation) { this.batch = undefined; this.setState("idle"); }
219
+ }
220
+
221
+ private async reportFailures(failures: readonly ReviewerFailure[], generation: number): Promise<void> {
222
+ const seen = new Set<string>();
223
+ for (const failed of failures) {
224
+ if (generation !== this.generation) return;
225
+ const identity = JSON.stringify([failed.task, failed.head, failed.flowRevision]);
226
+ if (seen.has(identity)) continue;
227
+ seen.add(identity);
228
+ try { await this.opts.onUnavailable?.(failed, () => generation === this.generation); }
229
+ catch { this.opts.log?.(`[${this.id}] reviewer fallback could not be recorded; inspect the task`); }
230
+ }
231
+ }
232
+
233
+ private prepare(sessionId: string): Promise<ReviewerLaunch> {
234
+ const abort = new AbortController();
235
+ const done = (async () => {
236
+ const launch = await this.opts.backend.prepare({ peer: this.id, cwd: this.opts.cwd, sessionId, mcpConfig: this.opts.mcpConfig,
237
+ systemPrompt: this.opts.systemPrompt, privatePaths: this.opts.privatePaths ?? [], command: this.opts.command, signal: abort.signal });
238
+ if ("unavailable" in launch) throw new Error(launch.unavailable);
239
+ const proof = launch.isolation;
240
+ if (abort.signal.aborted || !proof || proof.backend !== this.opts.backend.id || proof.readOnly !== true || proof.strictMcp !== true || proof.ephemeral !== true || !proof.binary || !proof.version || !proof.profile || !proof.evidence.length || !launch.command.length) {
241
+ await launch.cleanup?.();
242
+ throw new Error("reviewer isolation is unavailable or preparation was cancelled");
243
+ }
244
+ return launch;
245
+ })();
246
+ const pending = { abort, done };
247
+ this.preparation = pending;
248
+ void done.finally(() => { if (this.preparation === pending) this.preparation = undefined; }).catch(() => {});
249
+ return done;
250
+ }
251
+
252
+ private async review(env: Envelope, task: number, generation: number): Promise<{ code: number | null; quota: boolean }> {
253
+ const launch = await this.prepare(randomUUID());
254
+ try {
255
+ if (generation !== this.generation || launch.current?.() === false) throw new Error("reviewer isolation changed before child spawn");
256
+ this.receipt = launch.isolation;
257
+ const [cmd, ...prefix] = launch.command;
258
+ let quota = false;
259
+ return await new Promise<{ code: number | null; quota: boolean }>((resolve, reject) => {
260
+ const child = spawn(cmd!, [...prefix, ...launch.args], { cwd: this.opts.cwd, detached: true, env: peerChildEnv(this.id, { ...process.env, ...launch.env }), stdio: ["pipe", "pipe", "ignore"] });
261
+ // Owned before any await, so a stop that lands before "spawn" still finds and kills it.
262
+ this.child = child;
263
+ child.once("error", error => { if (this.child === child) this.child = undefined; reject(error); });
264
+ child.stdin!.on("error", () => { /* the child exited before reading its prompt; its exit settles the review */ });
265
+ child.once("spawn", () => {
266
+ trackGroup(child);
267
+ if (generation !== this.generation) { child.stdin!.destroy(); return; } // stopped meanwhile: no prompt
268
+ this.reviewing = task; this.reviewSeq++;
269
+ let facts: string | undefined;
270
+ try { facts = this.opts.facts?.(env); } catch (error) { this.opts.log?.(`[${this.id}] review facts for task #${task} failed: ${(error as Error).message}`); }
271
+ child.stdin!.end(`${frame(env)}${facts ? `\n\n${facts}` : ""}`);
272
+ });
273
+ let buffer = "", outputBytes = 0;
274
+ child.stdout!.on("data", (chunk: Buffer) => {
275
+ if (generation !== this.generation) return;
276
+ outputBytes += chunk.length;
277
+ if (outputBytes > MAX_REVIEW_OUTPUT_BYTES) { this.abortReview("review output limit exceeded"); return; }
278
+ buffer += chunk.toString("utf8");
279
+ for (let i; (i = buffer.indexOf("\n")) >= 0;) {
280
+ const line = buffer.slice(0, i); buffer = buffer.slice(i + 1);
281
+ if (Buffer.byteLength(line) > MAX_REVIEW_FRAME_BYTES) { this.abortReview("review output limit exceeded"); return; }
282
+ try {
283
+ const event: unknown = JSON.parse(line);
284
+ const usage = this.opts.backend.usage?.(event);
285
+ if (usage) this.opts.onUsage?.(usage, task);
286
+ if (this.opts.backend.failure?.(event) === "quota") quota = true;
287
+ } catch { /* not a stream-json line */ }
288
+ }
289
+ if (Buffer.byteLength(buffer) > MAX_REVIEW_FRAME_BYTES) { this.abortReview("review output limit exceeded"); return; }
290
+ this.touch();
291
+ });
292
+ child.once("exit", code => {
293
+ if (this.child === child) this.reviewing = undefined; // the verdict authority ends with the leader
294
+ // The review ends when its whole process group is gone (an MCP server or other descendant may outlive the leader).
295
+ stopOwnedProcess(child, { group: true }).then(() => { if (this.child === child) this.child = undefined; resolve({ code, quota }); },
296
+ (error: Error) => reject(new GroupCleanupError(error.message)));
297
+ });
298
+ });
299
+ } finally {
300
+ try { await launch.cleanup?.(); }
301
+ catch { this.opts.log?.(`[${this.id}] reviewer isolation cleanup incomplete`); }
302
+ }
303
+ }
304
+
305
+ private settle(state: "completed" | "failed_safe" | "needs_review", reason?: string): void {
306
+ const id = this.activeDeliveryId;
307
+ this.activeDeliveryId = undefined;
308
+ if (id) this.delivery({ id, state, ...(reason ? { reason } : {}) });
309
+ }
310
+
311
+ /** Revokes the verdict authority at once, then stops the child; on failure the handle is kept and the error thrown. */
312
+ private async kill(): Promise<void> {
313
+ this.reviewing = undefined;
314
+ const child = this.child;
315
+ if (!child) return;
316
+ await stopOwnedProcess(child, { group: true });
317
+ if (this.child === child) this.child = undefined;
318
+ }
319
+ }
package/src/cli/help.ts CHANGED
@@ -25,6 +25,8 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
25
25
  ["ahub sdlc queue <owner/repo> | release <owner/repo> [live-issue...]", "read-only merge and release evidence (person only)"],
26
26
  ["ahub sdlc accept <task> --issue <n> [--repo owner/repo]", "accept the task's spec issue at its current body; build entry needs it in enforce (person only)"],
27
27
  ["ahub sdlc plan-review <task> <revision> <reason...> [--review full|delta|none --reviewers N --ci full|focused|reuse]", "record planner review, scope and host-read issue metadata"],
28
+ ["ahub sdlc train report|configure|cut <release-id> [revision] [json]", "plan a release and freeze its approved scope after resource/quota admission"],
29
+ ["ahub sdlc release-assign <task> <revision> <release-id|none> <reason>", "assign approved/in-flight work without changing execution"],
28
30
  ["ahub sdlc costs [owner/repo]", "read bounded planner cost metrics for linked merged PRs"],
29
31
  ["ahub backlog groom | return <task> <revision> <reason...>", "read grooming findings or return accepted untouched work to backlog (planner/person)"],
30
32
  ["ahub status [--json] [--full] [--color=auto|always|never]", "the hub, its peers, model backends and task counts"],
@@ -13,7 +13,7 @@ export const LIVE_TEST_PERSON_REFUSAL = "live-test cannot write person-attribute
13
13
 
14
14
  /** Person verdicts and user-authored workflow content are never delegated. */
15
15
  export function liveTestRefusal(command: string, args: readonly string[], context: { repairedFresh?: boolean } = {}): boolean {
16
- if (command === "sdlc" && args[0] === "plan-review") return true;
16
+ if (command === "sdlc" && (args[0] === "plan-review" || args[0] === "release-assign" || args[0] === "train" && args[1] !== "report")) return true;
17
17
  if (args.some(arg => arg === "--as-user" || arg.startsWith("--as-user="))) return true;
18
18
  const sub = command === "recovery" ? recoveryAction(args) : args.find(arg => arg !== "--json");
19
19
  return ["task", "role", "goal"].includes(command)
package/src/cli/main.ts CHANGED
@@ -1118,6 +1118,25 @@ const commands: Record<string, () => Promise<void> | void> = {
1118
1118
  finally { hub.close(); }
1119
1119
  return;
1120
1120
  }
1121
+ if (sub === "train") {
1122
+ const operation = args[1], releaseId = Number(args[2]), revision = Number(args[3]);
1123
+ if (!Number.isSafeInteger(releaseId) || releaseId <= 0) fail("usage: ahub sdlc train report|configure|cut <release-id> [revision] [configuration-json]");
1124
+ if (operation === "report" && args.length === 3) return console.log(await taskOp("hub_release_report", { id: releaseId }));
1125
+ if (!Number.isSafeInteger(revision) || revision < 0) fail("train mutation requires its current revision");
1126
+ if (operation === "cut" && args.length === 4) return console.log(await taskOp("hub_release_cut", { id: releaseId, revision }));
1127
+ if (operation === "configure" && args.length === 5) {
1128
+ let fields;
1129
+ try { fields = JSON.parse(args[4]!); } catch { return fail("release train configuration must be JSON"); }
1130
+ if (!fields || typeof fields !== "object" || Array.isArray(fields) || Object.keys(fields).some(key => !["state", "cut", "hotfix"].includes(key))) fail("invalid release train configuration fields");
1131
+ return console.log(await taskOp("hub_release_configure", { ...fields, id: releaseId, revision }));
1132
+ }
1133
+ fail("usage: ahub sdlc train report|configure|cut <release-id> [revision] [configuration-json]");
1134
+ }
1135
+ if (sub === "release-assign") {
1136
+ const revision = Number(args[2]), release = args[3] === "none" ? null : Number(args[3]);
1137
+ if (!Number.isSafeInteger(id) || id! <= 0 || !Number.isSafeInteger(revision) || revision < 0 || release !== null && (!Number.isSafeInteger(release) || release <= 0) || args.length < 5) fail("usage: ahub sdlc release-assign <task> <planning-revision> <release-id|none> <reason...>");
1138
+ return console.log(await taskOp("hub_release_assign", { id, revision, release, reason: args.slice(4).join(" ") }));
1139
+ }
1121
1140
  if (sub === "costs") {
1122
1141
  if (args.length > 2) fail("usage: ahub sdlc costs [owner/repository]");
1123
1142
  return console.log(await taskOp("hub_sdlc_costs", { ...(args[1] ? { repo: args[1] } : {}) }));
package/src/cli/memory.ts CHANGED
@@ -1,13 +1,12 @@
1
1
  import { detectCliIdentity } from "./identity.ts";
2
2
  import { createStalenessProbe } from "../hub/memory-staleness.ts";
3
- import { looksSecret, type MemoryStore, type StoreOptions } from "@staix/agent-mem";
3
+ import { looksSecret, type MemoryStore } from "@staix/agent-mem";
4
4
  import type { SyncOptions } from "@staix/agent-mem";
5
5
  import { MEMORY_USAGE as CORE_MEMORY_USAGE, rawMemoryCommand, rawGoalCommand } from "@staix/agent-mem";
6
- import { memoryProjectKey } from "@staix/agent-mem";
7
6
  import { mentionsPrivatePath, promotionScreen, withoutPii } from "../hub/memory-policy.ts";
8
7
  import { currentRouting, detectSignals } from "../hub/routing.ts";
9
8
  import { loadConfig } from "../hub/daemon.ts";
10
- import { bindProfileMemory, openProfileMemory, MEMORY_PROFILE_UNAVAILABLE, type MemoryProfileReceipt } from "../hub/memory-profile.ts";
9
+ import { bindProfileMemory, memoryProjectFor, openProfileMemory, MEMORY_PROFILE_UNAVAILABLE, MEMORY_SPLIT_INCOMPLETE, type MemoryProfileReceipt, type ProfileMemoryOptions } from "../hub/memory-profile.ts";
11
10
 
12
11
  export { GOAL_USAGE } from "@staix/agent-mem";
13
12
  export const MEMORY_USAGE = `${CORE_MEMORY_USAGE} | decay [--days N] [--json] | profile-check [--json]`;
@@ -18,9 +17,9 @@ const loadConfigSafe = (cwd: string) => { try { return loadConfig(cwd); } catch
18
17
 
19
18
 
20
19
  /** Bind each command at entry; the CLI closes default opens and injected stores remain caller-owned. */
21
- function commandStores(provided?: MemoryStore, options: StoreOptions = {}) {
20
+ function commandStores(provided: MemoryStore | undefined, options: ProfileMemoryOptions) {
22
21
  try { return { ...(provided ? bindProfileMemory(provided) : openProfileMemory(options)), owned: !provided }; }
23
- catch { throw new Error(MEMORY_PROFILE_UNAVAILABLE); }
22
+ catch (error) { throw new Error(error instanceof Error && error.message === MEMORY_SPLIT_INCOMPLETE ? error.message : MEMORY_PROFILE_UNAVAILABLE); }
24
23
  }
25
24
  function profileOutput(text: string, json: boolean, profile: MemoryProfileReceipt | undefined, person: boolean): string {
26
25
  if (!profile) return text;
@@ -44,9 +43,9 @@ export async function memoryCommand(args: string[], cwd: string, opts: { store?:
44
43
  const cutoffMs = (opts.now ?? Date.now)() - days * 24 * 3600_000;
45
44
  if (!Number.isSafeInteger(days) || !Number.isFinite(cutoffMs) || Math.abs(cutoffMs) > 8_640_000_000_000_000) fail("--days is outside the supported date range");
46
45
  const cutoff = new Date(cutoffMs).toISOString();
47
- const project = memoryProjectKey(cwd), routing = currentRouting(cwd), paths = privatePaths();
46
+ const project = memoryProjectFor(cwd), routing = currentRouting(cwd), paths = privatePaths();
48
47
  // Review must not promote a held source while reading it: only explicit person actions may append rows.
49
- const context = commandStores(opts.store, { deferPromotions: true, now: opts.now });
48
+ const context = commandStores(opts.store, { project, deferPromotions: true, now: opts.now });
50
49
  const store = context.store;
51
50
  try {
52
51
  context.binding.assertCurrent();
@@ -68,11 +67,12 @@ export async function memoryCommand(args: string[], cwd: string, opts: { store?:
68
67
  } finally { if (context.owned) store.close(); }
69
68
  }
70
69
  let showStale = false;
71
- const context = commandStores(opts.store, { ttlMs: (loadConfigSafe(cwd)?.memory.short_ttl_days ?? 14) * 24 * 3600_000,
72
- screen: promotionScreen(memoryProjectKey(cwd), cwd, () => currentRouting(cwd), privatePaths) });
70
+ const project = memoryProjectFor(cwd);
71
+ const context = commandStores(opts.store, { project, ttlMs: (loadConfigSafe(cwd)?.memory.short_ttl_days ?? 14) * 24 * 3600_000,
72
+ screen: promotionScreen(project, cwd, () => currentRouting(cwd), privatePaths) });
73
73
  try {
74
74
  context.binding.assertCurrent();
75
- const text = await rawMemoryCommand(args, cwd, { ...opts, store: context.store, open: () => context.store,
75
+ const text = await rawMemoryCommand(args, cwd, { ...opts, project, store: context.store, open: () => context.store,
76
76
  readable: notes => {
77
77
  const paths = privatePaths(), visible = withoutPii(notes, currentRouting(cwd), cwd, paths);
78
78
  if (args[0] !== "show" || args.includes("--json")) return visible;
@@ -92,10 +92,11 @@ export async function memoryCommand(args: string[], cwd: string, opts: { store?:
92
92
  export async function goalCommand(args: string[], cwd: string, opts: { store?: MemoryStore; link?: (goal: string, task: string) => Promise<string>; person?: boolean } = {}): Promise<string> {
93
93
  const person = opts.person ?? detectCliIdentity(process.env).role === "console";
94
94
  const policy = (): string[] => { try { return loadConfig(cwd).pii?.paths ?? []; } catch (e) { return fail(`the project's config does not load (${(e as Error).message}); fix it before using long-term tasks`); } };
95
- const context = commandStores(opts.store);
95
+ const project = memoryProjectFor(cwd);
96
+ const context = commandStores(opts.store, { project });
96
97
  try {
97
98
  context.binding.assertCurrent();
98
- const text = await rawGoalCommand(args, cwd, { ...opts, store: context.store, open: () => context.store,
99
+ const text = await rawGoalCommand(args, cwd, { ...opts, project, store: context.store, open: () => context.store,
99
100
  clean: (title, milestones, project) => !looksSecret(`${title}\n${milestones.join("\n")}`) && withoutPii([{ title, text: milestones.join("\n"), project }], currentRouting(cwd), cwd, policy()).length > 0,
100
101
  });
101
102
  return profileOutput(text, args.includes("--json"), context.binding.status(context.store), person);