@staix/agent-hub 0.12.35 → 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 (71) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +15 -8
  3. package/docs/agent-notes/adapters.md +9 -2
  4. package/docs/agent-notes/budget.md +3 -2
  5. package/docs/agent-notes/bus.md +3 -0
  6. package/docs/agent-notes/daemon.md +11 -3
  7. package/docs/agent-notes/tasks.md +14 -1
  8. package/docs/agent-notes/tests.md +6 -2
  9. package/docs/memory.md +3 -1
  10. package/docs/operations.md +74 -18
  11. package/docs/quickstart.md +7 -9
  12. package/docs/sdlc.md +72 -5
  13. package/docs/security.md +11 -3
  14. package/docs/smoke.md +18 -2
  15. package/docs/specs/2026-09-19-agent-hub-design.md +108 -3
  16. package/docs/verification/2026-10-19-0.12.36.md +63 -0
  17. package/docs/verification/2026-10-19-0.12.37.md +60 -0
  18. package/docs/verification/2026-10-19-jcode-0.98.1.md +30 -0
  19. package/docs/verification/2026-10-19-reviewer-isolation.md +119 -0
  20. package/docs/verified.json +23 -15
  21. package/package.json +2 -2
  22. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  23. package/plugins/agent-hub/server.js +2843 -1632
  24. package/src/adapters/acp.ts +3 -2
  25. package/src/adapters/claude-print.ts +92 -0
  26. package/src/adapters/codex-review-isolation.ts +276 -0
  27. package/src/adapters/definitions/builtin-metadata.ts +20 -1
  28. package/src/adapters/definitions/native-cli-entry-claude.ts +17 -3
  29. package/src/adapters/definitions/native-cli-entry-jcode.ts +3 -2
  30. package/src/adapters/definitions/native-cli-entry-kimi.ts +3 -2
  31. package/src/adapters/definitions/native-cli-entry-types.ts +2 -0
  32. package/src/adapters/definitions/native-factories.ts +24 -0
  33. package/src/adapters/definitions/native-policy-aux.ts +1 -1
  34. package/src/adapters/definitions/reviewer-variants.ts +32 -0
  35. package/src/adapters/jcode.ts +20 -5
  36. package/src/adapters/registry-types.ts +13 -3
  37. package/src/adapters/registry.ts +29 -5
  38. package/src/adapters/reviewer-print.ts +319 -0
  39. package/src/cli/help.ts +7 -0
  40. package/src/cli/identity.ts +2 -1
  41. package/src/cli/launch.ts +19 -6
  42. package/src/cli/main.ts +57 -4
  43. package/src/cli/memory.ts +13 -12
  44. package/src/cli/reset.ts +4 -2
  45. package/src/cli/statusline-tee.ts +5 -2
  46. package/src/cli/terminal-recovery.ts +7 -2
  47. package/src/cli/upgrade-runtime.ts +18 -15
  48. package/src/hub/board.ts +61 -11
  49. package/src/hub/bus.ts +11 -5
  50. package/src/hub/claude-state.ts +37 -0
  51. package/src/hub/config-apply.ts +3 -2
  52. package/src/hub/control-client.ts +1 -1
  53. package/src/hub/cost-governance.ts +76 -0
  54. package/src/hub/daemon.ts +269 -87
  55. package/src/hub/envelope.ts +1 -1
  56. package/src/hub/host-executable.ts +1 -1
  57. package/src/hub/hub-tools.ts +25 -1
  58. package/src/hub/instances.ts +29 -5
  59. package/src/hub/memory-profile.ts +67 -11
  60. package/src/hub/planning.ts +2 -0
  61. package/src/hub/release-admission.ts +64 -0
  62. package/src/hub/review-brief.ts +175 -0
  63. package/src/hub/reviewer-pool-config.ts +11 -0
  64. package/src/hub/reviewer-pool.ts +70 -0
  65. package/src/hub/routing.ts +58 -4
  66. package/src/hub/sdlc-release.ts +113 -0
  67. package/src/hub/sdlc.ts +205 -4
  68. package/src/hub/status-roster.ts +10 -0
  69. package/src/hub/task-sweep.ts +143 -0
  70. package/src/hub/tasks.ts +788 -52
  71. package/src/hub/usage.ts +57 -20
@@ -0,0 +1,32 @@
1
+ import type { AdapterDefinition } from "../registry-types.ts";
2
+ import { claudeReviewerBackend } from "../claude-print.ts";
3
+ import { codexReviewerBackend } from "../codex-review-isolation.ts";
4
+ import { createReviewer, createCodexReviewer } from "./native-factories.ts";
5
+ import { CODEX_DISPLAY, KIMI_DISPLAY } from "./native-display.ts";
6
+
7
+ export { claudeReviewerBackend };
8
+ /** No launch path is exposed by metadata-only placeholders. */
9
+ function unavailableLaunch(): never { throw new Error("Reviewer unavailable: verified runtime support is deferred to #533"); }
10
+
11
+ function reviewerVariant(kind: string, displayName: string, reviewer: NonNullable<AdapterDefinition["reviewer"]>, display: AdapterDefinition["display"]): AdapterDefinition {
12
+ return {
13
+ api: 1, kind, family: kind, displayName, transport: "hub-native", roles: ["reviewer"],
14
+ defaultOnRoster: false, trust: { onPrem: false, hubSandboxed: false },
15
+ start: { ...(reviewer.backend ? { create: createReviewer, daemonPort: "reviewer-print" as const } : {}), forms: ["headless"], defaultForm: "headless", ownsTerminal: () => false,
16
+ launch: unavailableLaunch,
17
+ conductorStartable: true, hubSwitches: [], ownedFlags: [] },
18
+ identity: { mcp: "env" },
19
+ permission: { modes: {}, readOnly: { kind: "hub-gate", allow: ["read", "search", "think"] }, unmanaged: "fresh read-only reviewer; an isolation receipt is required before startup" },
20
+ session: { idField: "sessionId", currentId: () => undefined, freshOnRestore: true, launchKind: "reviewer-print", crashPlan: () => ({ resume: {}, how: "starts a fresh isolated review session" }) },
21
+ turnEnd: "adapter", budget: { account: kind }, routing: { reviewOnly: true },
22
+ reviewer, cli: { command: kind, startWords: () => [] }, display,
23
+ };
24
+ }
25
+ const codexVariant = reviewerVariant("codex", "Codex reviewer", { backend: codexReviewerBackend, defaultPool: true, declaration: "explicit" }, CODEX_DISPLAY);
26
+ export const CODEX_REVIEWER_VARIANT: AdapterDefinition = { ...codexVariant, start: { ...codexVariant.start, create: createCodexReviewer } };
27
+ export const KIMI_REVIEWER_VARIANT = reviewerVariant("kimi", "Kimi reviewer", { defaultPool: false, declaration: "explicit", unavailable: "Kimi reviewer unavailable: runtime and isolation verification deferred to #533" }, KIMI_DISPLAY);
28
+ /** Data-only declared placeholder for route explanations; no factory, backend or native argv. */
29
+ export const AGY_DEFINITION: AdapterDefinition = {
30
+ ...reviewerVariant("agy", "agy reviewer", { defaultPool: false, declaration: "automatic", unavailable: "agy reviewer unavailable: native adapter and isolation verification deferred to #533" }, { palette: { fg: "#6558a6", bg: "#f1effa" } }),
31
+ optional: true,
32
+ };
@@ -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";
@@ -13,9 +14,10 @@ import type { CodexOptions } from "./codex-appserver.ts";
13
14
  import type { JcodeOptions } from "./jcode.ts";
14
15
  import type { LocalOptions } from "./local-worker.ts";
15
16
  import type { PiOptions } from "./pi.ts";
17
+ import type { ClaudePrintOptions } from "./claude-print.ts";
16
18
 
17
19
  export type StartForm = "tui" | "headless";
18
- export type DaemonEngine = "codex-appserver" | "acp" | "owned-acp" | "pi-rpc" | "local-worker";
20
+ export type DaemonEngine = "codex-appserver" | "acp" | "owned-acp" | "pi-rpc" | "local-worker" | "claude-print" | "reviewer-print";
19
21
 
20
22
  /** Existing buildLaunch inputs plus the startup-bound instance and project identity. */
21
23
  export interface LaunchContext {
@@ -53,7 +55,9 @@ export type DaemonPorts =
53
55
  | { acp: AcpOptions }
54
56
  | { jcode: JcodeOptions }
55
57
  | { pi: PiOptions }
56
- | { local: LocalOptions };
58
+ | { local: LocalOptions }
59
+ | { claudePrint: ClaudePrintOptions }
60
+ | { reviewerPrint: ReviewerPrintOptions };
57
61
 
58
62
  export interface CreateContext<Ports extends DaemonPorts = DaemonPorts> {
59
63
  instance: string;
@@ -223,6 +227,8 @@ export interface AdapterDefinition {
223
227
  reporting?: { tokensNotReported?: boolean };
224
228
  /** Legacy native routing policy; external definitions cannot claim these host-specific policies. */
225
229
  routing?: {
230
+ /** Takes reviews only: never chosen or nominated as a task owner, for any class. */
231
+ reviewOnly?: true;
226
232
  localAllowedRefusal?: string;
227
233
  longContext?: "backend-limit" | "skip-owner";
228
234
  preferredTier?: boolean;
@@ -258,7 +264,11 @@ export interface AdapterDefinition {
258
264
  };
259
265
  display: { palette: { fg: string; bg: string; tone?: string; ansi?: string } };
260
266
  hooks?: Partial<NativeHooks>;
267
+ /** #223: what a declared headless reviewer-only instance of this kind runs as. Fixed builtins only. */
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" };
261
271
  }
262
272
 
263
273
  /** Explicit project declarations, passed by the bound host. No process-global instance registration. */
264
- 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,8 @@ 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]);
30
+ for (const definition of [...fixedBuiltinDefinitions]) if (definition.reviewerVariant) fixedBuiltinDefinitions.add(definition.reviewerVariant);
29
31
 
30
32
  /** Keep the native catalog's order, independently of the legacy default-role object order. */
31
33
  export const ADAPTERS: readonly AdapterDefinition[] = immutable([
@@ -33,19 +35,33 @@ export const ADAPTERS: readonly AdapterDefinition[] = immutable([
33
35
  QWEN_DEFINITION,
34
36
  ...NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN.filter(definition => definition.kind === "jcode"),
35
37
  ]);
38
+ // Unavailable declarations are metadata, not supported native peers or launchers.
39
+ const declarationOnlyDefinitions: readonly AdapterDefinition[] = immutable([AGY_DEFINITION]);
36
40
  const definitions = new Map<string, AdapterDefinition>();
37
- for (const definition of ADAPTERS) {
41
+ for (const definition of [...ADAPTERS, ...declarationOnlyDefinitions]) {
38
42
  assertDaemonEngine(definition);
39
43
  assertNativeCapabilityScope(definition);
44
+ if (definition.reviewerVariant) { assertDaemonEngine(definition.reviewerVariant); assertNativeCapabilityScope(definition.reviewerVariant); }
40
45
  if (definition.api !== ADAPTER_API || definitions.has(definition.kind)) throw new Error("invalid builtin adapter registry");
41
46
  definitions.set(definition.kind, definition);
42
47
  }
43
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
+
44
53
  /** Native quota handoff platform names, distinct from per-delivery recall injection policy. */
45
54
  export const QUOTA_HANDOFF_PLATFORMS: Readonly<Record<string, string>> = Object.freeze(Object.fromEntries(
46
55
  ADAPTERS.flatMap(adapter => adapter.hooks?.recallPlatform ? [[adapter.kind, adapter.hooks.recallPlatform]] : []),
47
56
  ));
48
57
 
58
+ /** Own enumerable data field of a plain object, never a caller accessor or an inherited value. */
59
+ function ownField(value: unknown, key: string): unknown {
60
+ if (!value || typeof value !== "object" || types.isProxy(value) || Array.isArray(value)
61
+ || ![Object.prototype, null].includes(Object.getPrototypeOf(value))) return undefined;
62
+ const field = Object.getOwnPropertyDescriptor(value, key);
63
+ return field && Object.hasOwn(field, "value") && field.enumerable ? field.value : undefined;
64
+ }
49
65
  /** Declaration reads never execute caller accessors or inherit another project's mapping. */
50
66
  function declaredKind(id: string, instances: KindMap | undefined): string | undefined {
51
67
  if (!instances || typeof instances !== "object" || types.isProxy(instances) || Array.isArray(instances)
@@ -66,7 +82,13 @@ export function kindOf(id: string, instances?: KindMap): string | undefined {
66
82
  }
67
83
  export function definitionOf(id: string, instances?: KindMap): AdapterDefinition | undefined {
68
84
  const kind = kindOf(id, instances);
69
- return kind === undefined ? undefined : definitions.get(kind);
85
+ const definition = kind === undefined ? undefined : definitions.get(kind);
86
+ // #223: only a declared extra (never the builtin id) with start_mode "headless" runs as the kind's reviewer variant.
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;
70
92
  }
71
93
  export function familyOf(id: string, instances?: KindMap): string | undefined {
72
94
  return definitionOf(id, instances)?.family;
@@ -113,6 +135,8 @@ export function assertNativeCapabilityScope(definition: AdapterDefinition): void
113
135
  ["session.crashIdentity", declared(definition.session, "crashIdentity")],
114
136
  ["start.daemonPort pi-rpc", definition.start.daemonPort === "pi-rpc"],
115
137
  ["routing", definition.routing !== undefined],
138
+ ["reviewerVariant", definition.reviewerVariant !== undefined],
139
+ ["reviewer", definition.reviewer !== undefined],
116
140
  ];
117
141
  const field = restricted.find(([, present]) => present)?.[0];
118
142
  if (field) throw new Error(`${field} is restricted to fixed builtin adapter definitions`);
@@ -121,8 +145,8 @@ export function assertNativeCapabilityScope(definition: AdapterDefinition): void
121
145
  /** Ordered catalog projections consumed by generic core hosts. */
122
146
  export const STARTABLE_PEERS = SUPPORTED_PEERS;
123
147
  export type SupportedPeer = typeof SUPPORTED_PEERS[number];
124
- export function isSupportedPeer(peer: unknown): peer is SupportedPeer { return typeof peer === "string" && definitionOf(peer) !== undefined; }
125
- 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; }
126
150
 
127
151
  /** Body-free browser metadata, derived from the same native participation and managed-tool contracts. */
128
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
@@ -23,6 +23,12 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
23
23
  ["ahub pet [--short]", "read attention without connecting; --short prints ahub!N only when action is needed"],
24
24
  ["ahub sdlc status [task] | explain <task> | export <task> | record <task> <receipt-json>", "read head-bound lifecycle gates and the next actor/action (person only)"],
25
25
  ["ahub sdlc queue <owner/repo> | release <owner/repo> [live-issue...]", "read-only merge and release evidence (person only)"],
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
+ ["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"],
30
+ ["ahub sdlc costs [owner/repo]", "read bounded planner cost metrics for linked merged PRs"],
31
+ ["ahub backlog groom | return <task> <revision> <reason...>", "read grooming findings or return accepted untouched work to backlog (planner/person)"],
26
32
  ["ahub status [--json] [--full] [--color=auto|always|never]", "the hub, its peers, model backends and task counts"],
27
33
  ["ahub status --all [--json] [--color=auto|always|never]", "show every registered project"],
28
34
  ["ahub logs [-f]", "the last 100 lines of hub.log; -f shows the last 10 and follows it"],
@@ -84,6 +90,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
84
90
  ["ahub task cancel <id> --reason <text> [--superseded-by <ref>] [--revision N]", "person only: close obsolete work with a reason; retain its history without stopping native work or releasing dependents"],
85
91
  ["ahub task label <id> ok|regressed|reverted|incomplete|wrong|abandoned", "your later verdict on an approved task, kept in the research records (research must be on)"],
86
92
  ["ahub review <id> approved|changes_requested [note...] [--unmet <item>]...", "give a review verdict on a task"],
93
+ ["ahub review brief <id>", "print the review brief of a task's commit: reviewable and excluded files, rules, coverage to account for"],
87
94
  ["ahub route explain <id>", "why a task went where it went, its reserved owner included"],
88
95
  ["ahub route explain --class <c> <title...>", "what would happen to such a task now"],
89
96
  ["ahub ask [--remember] <question...>", "answer from the task board, shared memory and this run's log, with the ids it rests on"],
@@ -13,13 +13,14 @@ 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" || args[0] === "release-assign" || args[0] === "train" && args[1] !== "report")) return true;
16
17
  if (args.some(arg => arg === "--as-user" || arg.startsWith("--as-user="))) return true;
17
18
  const sub = command === "recovery" ? recoveryAction(args) : args.find(arg => arg !== "--json");
18
19
  return ["task", "role", "goal"].includes(command)
19
20
  || (command === "backlog" && sub !== "list") || (command === "release" && !["list", "show"].includes(sub ?? ""))
20
21
  || (command === "memory" && sub !== "graph")
21
22
  || (command === "recovery" && (sub === "reattach" || sub === "dispose" && context.repairedFresh === true))
22
- || (command === "board" && args[0] === "import") || (command === "sdlc" && args[0] === "record")
23
+ || (command === "board" && args[0] === "import") || (command === "sdlc" && (args[0] === "record" || args[0] === "accept"))
23
24
  || (command === "bench" && args.find(arg => arg !== "--json") === "run") || command === "pet";
24
25
  }
25
26