@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.
- package/CHANGELOG.md +11 -0
- package/README.md +13 -8
- package/docs/agent-notes/adapters.md +7 -3
- package/docs/agent-notes/budget.md +2 -1
- package/docs/agent-notes/daemon.md +5 -1
- package/docs/agent-notes/tasks.md +5 -2
- package/docs/agent-notes/tests.md +2 -2
- package/docs/memory.md +3 -1
- package/docs/operations.md +42 -10
- package/docs/quickstart.md +7 -7
- package/docs/sdlc.md +29 -3
- package/docs/security.md +6 -2
- package/docs/smoke.md +18 -2
- package/docs/specs/2026-09-19-agent-hub-design.md +34 -4
- package/docs/verification/2026-10-19-0.12.37.md +60 -0
- package/docs/verification/2026-10-19-jcode-0.98.1.md +30 -0
- package/docs/verification/2026-10-19-reviewer-isolation.md +119 -0
- package/docs/verified.json +18 -14
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +3091 -2236
- package/src/adapters/acp.ts +3 -2
- package/src/adapters/claude-print.ts +18 -156
- package/src/adapters/codex-review-isolation.ts +276 -0
- package/src/adapters/definitions/builtin-metadata.ts +4 -1
- package/src/adapters/definitions/native-factories.ts +17 -0
- package/src/adapters/definitions/native-policy-aux.ts +1 -1
- package/src/adapters/definitions/reviewer-variants.ts +32 -0
- package/src/adapters/jcode.ts +20 -5
- package/src/adapters/registry-types.ts +7 -3
- package/src/adapters/registry.ts +17 -5
- package/src/adapters/reviewer-print.ts +319 -0
- package/src/cli/help.ts +2 -0
- package/src/cli/identity.ts +1 -1
- package/src/cli/main.ts +19 -0
- package/src/cli/memory.ts +13 -12
- package/src/hub/board.ts +34 -6
- package/src/hub/config-apply.ts +3 -2
- package/src/hub/control-client.ts +1 -1
- package/src/hub/daemon.ts +101 -27
- package/src/hub/envelope.ts +1 -1
- package/src/hub/host-executable.ts +1 -1
- package/src/hub/hub-tools.ts +5 -0
- package/src/hub/instances.ts +13 -3
- package/src/hub/memory-profile.ts +67 -11
- package/src/hub/planning.ts +2 -0
- package/src/hub/release-admission.ts +64 -0
- package/src/hub/reviewer-pool-config.ts +11 -0
- package/src/hub/reviewer-pool.ts +70 -0
- package/src/hub/routing.ts +42 -3
- package/src/hub/sdlc-release.ts +113 -0
- package/src/hub/tasks.ts +254 -36
package/src/hub/board.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ReviewerPoolReason } from "./reviewer-pool.ts";
|
|
1
2
|
import type { GitHubSource } from "./github-import.ts";
|
|
2
3
|
import { Database } from "bun:sqlite";
|
|
3
4
|
import { planningPatch, type PlanningFields, type Release, type ReleaseState } from "./planning.ts";
|
|
@@ -56,8 +57,20 @@ export interface HistoryEntry {
|
|
|
56
57
|
reviewCost?: ReviewCost;
|
|
57
58
|
/** #223 R4: on a verdict, whether the reviewer's context was new for it; set by the hub, never by the reviewer. */
|
|
58
59
|
reviewContext?: "fresh" | "continuing";
|
|
60
|
+
reviewerFamily?: string;
|
|
61
|
+
reviewerReason?: ReviewerPoolReason;
|
|
62
|
+
reviewBlocked?: true;
|
|
63
|
+
reviewerFailure?: { peer: string; head?: string; cause: "start_failed" | "quota" | "no_verdict" };
|
|
59
64
|
planning?: { revision: number; fields: (keyof PlanningFields)[]; previous: PlanningFields; pull?: true; split?: { children: { id: number; revision: number; history: number }[] } };
|
|
60
65
|
}
|
|
66
|
+
export type ReviewerHistory = Pick<HistoryEntry, "reviewerFamily" | "reviewerReason" | "reviewBlocked" | "reviewerFailure">;
|
|
67
|
+
function reviewerHistory(extra: ReviewerHistory): ReviewerHistory {
|
|
68
|
+
const id = (value: unknown) => typeof value === "string" && /^[a-z][a-z0-9-]{0,63}$/.test(value);
|
|
69
|
+
if (extra.reviewerFamily !== undefined && !id(extra.reviewerFamily) || extra.reviewerReason !== undefined && !["rotation", "implementer skip", "unavailable skip", "same-family fallback"].includes(extra.reviewerReason) || extra.reviewBlocked !== undefined && extra.reviewBlocked !== true) throw new Error("invalid reviewer metadata");
|
|
70
|
+
const failure = extra.reviewerFailure;
|
|
71
|
+
if (failure && (!id(failure.peer) || !["start_failed", "quota", "no_verdict"].includes(failure.cause) || failure.head !== undefined && (typeof failure.head !== "string" || !failure.head.trim() || failure.head.length > 300 || Buffer.byteLength(failure.head) > 1200 || /\p{Cc}/u.test(failure.head)))) throw new Error("invalid reviewer failure metadata");
|
|
72
|
+
return { ...(extra.reviewerFamily ? { reviewerFamily: extra.reviewerFamily } : {}), ...(extra.reviewerReason ? { reviewerReason: extra.reviewerReason } : {}), ...(extra.reviewBlocked ? { reviewBlocked: true } : {}), ...(failure ? { reviewerFailure: { peer: failure.peer, cause: failure.cause, ...(failure.head ? { head: failure.head } : {}) } } : {}) };
|
|
73
|
+
}
|
|
61
74
|
export interface Task extends PlanningFields {
|
|
62
75
|
offer?: TaskOffer;
|
|
63
76
|
cancelled?: TaskCancellation;
|
|
@@ -209,6 +222,7 @@ export class Board {
|
|
|
209
222
|
release_order INTEGER NOT NULL, state TEXT NOT NULL DEFAULT 'planning', planned_start INTEGER, cut INTEGER, ship INTEGER,
|
|
210
223
|
activated_at INTEGER, frozen_at INTEGER, shipped_at INTEGER, created INTEGER NOT NULL, updated INTEGER NOT NULL,
|
|
211
224
|
revision INTEGER NOT NULL DEFAULT 0)`);
|
|
225
|
+
if (!(this.db.query("PRAGMA table_info(releases)").all() as { name: string }[]).some(row => row.name === "train")) this.db.run("ALTER TABLE releases ADD COLUMN train TEXT");
|
|
212
226
|
|
|
213
227
|
}
|
|
214
228
|
|
|
@@ -288,7 +302,7 @@ export class Board {
|
|
|
288
302
|
|
|
289
303
|
/** The only way a task changes. Validates the move, records who did what, returns the new row. */
|
|
290
304
|
/** A `plan` in the patch replaces the old one whole: a new plan is the owner's current intent, not an addition. */
|
|
291
|
-
update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "reviewer" | "reserved" | "refs" | "plan" | "rejections" | "signals" | "sdlc" | "role" | "cancelled">>, note?: string, extra: Pick<HistoryEntry, "profile" | "sweep" | "reason" | "from" | "withheld" | "reviewCost" | "reviewContext"> = {}, expectedFlowRevision?: number): Task {
|
|
305
|
+
update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "reviewer" | "reserved" | "refs" | "plan" | "rejections" | "signals" | "sdlc" | "role" | "cancelled">>, note?: string, extra: Pick<HistoryEntry, "profile" | "sweep" | "reason" | "from" | "withheld" | "reviewCost" | "reviewContext" | keyof ReviewerHistory> = {}, expectedFlowRevision?: number): Task {
|
|
292
306
|
const task = this.get(id);
|
|
293
307
|
if (!task) throw new Error(`no task #${id}`);
|
|
294
308
|
if (expectedFlowRevision !== undefined && (!Number.isSafeInteger(expectedFlowRevision) || expectedFlowRevision < 0 || task.history.length !== expectedFlowRevision)) throw new Error("task flow revision changed");
|
|
@@ -303,7 +317,8 @@ export class Board {
|
|
|
303
317
|
if (patch.state === "backlog" && task.owner) throw new Error(`task #${id}: owned work cannot return to backlog`);
|
|
304
318
|
}
|
|
305
319
|
if (patch.state === "backlog" && taskHasStarted(task) && !untouchedReturn) throw new Error(`task #${id}: started work cannot return to backlog`);
|
|
306
|
-
const
|
|
320
|
+
const reviewMetadata = reviewerHistory(extra);
|
|
321
|
+
const history = [...task.history, { at: Date.now(), by, event, ...reviewMetadata, ...(note ? { note } : {}), ...(patch.cancelled?.supersededBy ? { supersededBy: patch.cancelled.supersededBy } : {}), ...("owner" in patch ? { owner: next.owner } : {}), ...(extra.profile ? { profile: extra.profile } : {}), ...(extra.sweep ? { sweep: extra.sweep } : {}), ...(extra.reason && TASK_MOVE_REASONS.includes(extra.reason) ? { reason: extra.reason } : {}), ...(extra.from ? { from: extra.from } : {}), ...(extra.withheld && note ? { withheld: true as const } : {}), ...(extra.reviewContext === "fresh" || extra.reviewContext === "continuing" ? { reviewContext: extra.reviewContext } : {}), ...(extra.reviewCost ? { reviewCost: extra.reviewCost } : {}) }];
|
|
307
322
|
this.taskWrite(() => this.db
|
|
308
323
|
.query("UPDATE tasks SET state = ?, owner = ?, reviewer = ?, reserved = ?, refs = ?, plan = ?, rejections = ?, signals = ?, history = ?, sdlc = ?, task_role = ?, cancelled = ?, updated = ? WHERE id = ?")
|
|
309
324
|
.run(next.state, next.owner, next.reviewer, next.reserved ?? null, JSON.stringify(next.refs), JSON.stringify(next.plan ?? {}), next.rejections, JSON.stringify(next.signals), JSON.stringify(history), next.sdlc ? JSON.stringify(next.sdlc) : null, next.role ?? null, next.cancelled ? JSON.stringify(next.cancelled) : null, Date.now(), id));
|
|
@@ -402,20 +417,33 @@ export class Board {
|
|
|
402
417
|
releases(): Release[] {
|
|
403
418
|
return (this.db.query("SELECT * FROM releases ORDER BY release_order,id").all() as Record<string, unknown>[]).map(parseRelease);
|
|
404
419
|
}
|
|
405
|
-
updateRelease(id: number, patch: Partial<Pick<Release, "state" | "plannedStart" | "cut" | "ship">>, revision: number): Release {
|
|
420
|
+
updateRelease(id: number, patch: Partial<Pick<Release, "state" | "plannedStart" | "cut" | "ship" | "train">>, revision: number): Release {
|
|
406
421
|
const release = this.release(id);
|
|
407
422
|
if (!release || release.revision !== revision) throw new Error("release revision changed");
|
|
408
423
|
const next = { ...release, ...patch }, now = Date.now();
|
|
409
424
|
const moves: Record<ReleaseState, ReleaseState[]> = { planning: ["active", "dropped"], active: ["frozen", "dropped"], frozen: ["shipped", "dropped"], shipped: [], dropped: [] };
|
|
410
425
|
if (next.state !== release.state && !moves[release.state].includes(next.state)) throw new Error("invalid release transition");
|
|
426
|
+
if (patch.train && next.state !== release.state && ["frozen", "shipped"].includes(next.state)) {
|
|
427
|
+
const event = next.train?.history.at(-1);
|
|
428
|
+
if (event?.event === (next.state === "frozen" ? "cut" : "shipped")) event.at = now;
|
|
429
|
+
}
|
|
411
430
|
const dates = [next.plannedStart, next.cut, next.ship].filter((date): date is number => date !== null);
|
|
412
431
|
if (dates.some(date => !Number.isSafeInteger(date) || date <= 0 || date > 8_640_000_000_000_000) || dates.some((date, i) => i > 0 && dates[i-1]! > date)) throw new Error("invalid release planned dates");
|
|
413
|
-
const result = this.db.query("UPDATE releases SET state=?,planned_start=?,cut=?,ship=?,activated_at=?,frozen_at=?,shipped_at=?,updated=?,revision=revision+1 WHERE id=? AND revision=?")
|
|
414
|
-
.run(next.state, next.plannedStart, next.cut, next.ship, release.activatedAt ?? (next.state === "active" ? now : null), release.frozenAt ?? (next.state === "frozen" ? now : null), release.shippedAt ?? (next.state === "shipped" ? now : null), now, id, revision);
|
|
432
|
+
const result = this.db.query("UPDATE releases SET state=?,planned_start=?,cut=?,ship=?,activated_at=?,frozen_at=?,shipped_at=?,updated=?,train=?,revision=revision+1 WHERE id=? AND revision=?")
|
|
433
|
+
.run(next.state, next.plannedStart, next.cut, next.ship, release.activatedAt ?? (next.state === "active" ? now : null), release.frozenAt ?? (next.state === "frozen" ? now : null), release.shippedAt ?? (next.state === "shipped" ? now : null), now, next.train === undefined ? null : JSON.stringify(next.train), id, revision);
|
|
415
434
|
if (result.changes !== 1) throw new Error("release revision changed");
|
|
416
435
|
return this.release(id)!;
|
|
417
436
|
}
|
|
418
437
|
|
|
438
|
+
/** Cross-connection scope changes cannot race the final freeze write. */
|
|
439
|
+
freezeRelease(id: number, revision: number, scope: Task[], train: NonNullable<Release["train"]>): Release {
|
|
440
|
+
return this.db.transaction(() => {
|
|
441
|
+
const release = this.release(id);
|
|
442
|
+
if (!release || release.revision !== revision || release.state !== "active" || this.releases().some(row => row.id !== id && ["active", "frozen"].includes(row.state)) || JSON.stringify(this.list().filter(task => task.release === id)) !== JSON.stringify(scope)) throw new Error("release scope changed before freeze");
|
|
443
|
+
return this.updateRelease(id, { state: "frozen", train }, revision);
|
|
444
|
+
})();
|
|
445
|
+
}
|
|
446
|
+
|
|
419
447
|
recordOutcome(peer: PeerId, cls: TaskClass, ok: boolean, at = Date.now()): void {
|
|
420
448
|
this.db.query("INSERT INTO outcomes (peer, class, ok, at) VALUES (?, ?, ?, ?)").run(peer, cls, ok ? 1 : 0, at);
|
|
421
449
|
this.db.query("DELETE FROM outcomes WHERE at < ?").run(at - OUTCOMES_KEPT_MS); // nothing older is read
|
|
@@ -474,7 +502,7 @@ function parse(row: Record<string, unknown>): Task {
|
|
|
474
502
|
}
|
|
475
503
|
|
|
476
504
|
function parseRelease(row: Record<string, unknown>): Release {
|
|
477
|
-
return { id: Number(row.id), name: String(row.name), goal: String(row.goal), order: Number(row.release_order), state: row.state as ReleaseState,
|
|
505
|
+
return { ...(row.train == null ? {} : { train: JSON.parse(String(row.train)) }), id: Number(row.id), name: String(row.name), goal: String(row.goal), order: Number(row.release_order), state: row.state as ReleaseState,
|
|
478
506
|
plannedStart: row.planned_start as number | null, cut: row.cut as number | null, ship: row.ship as number | null,
|
|
479
507
|
activatedAt: row.activated_at as number | null, frozenAt: row.frozen_at as number | null, shippedAt: row.shipped_at as number | null,
|
|
480
508
|
created: Number(row.created), updated: Number(row.updated), revision: Number(row.revision) };
|
package/src/hub/config-apply.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { reviewerPoolConfig } from "./reviewer-pool-config.ts";
|
|
1
2
|
import type { HubConfig } from "./daemon.ts";
|
|
2
3
|
import { ALL_MACHINE_LOCAL, configRepositoryPresent, configTracked } from "./config-trust.ts";
|
|
3
|
-
import { ADAPTERS, SUPPORTED_PEERS, PLANNER_PEERS, START_MODE_PEERS } from "../adapters/registry.ts";
|
|
4
|
+
import { ADAPTERS, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS, SUPPORTED_PEERS, PLANNER_PEERS, START_MODE_PEERS } from "../adapters/registry.ts";
|
|
4
5
|
import { CLASSES } from "./board.ts";
|
|
5
6
|
import { permissionDefaults } from "./permission-mode.ts";
|
|
6
7
|
import { peerStartConfig, terminalTemplate } from "./start-mode.ts";
|
|
@@ -138,7 +139,7 @@ const schema: Record<string, Check> = {
|
|
|
138
139
|
peers: (v, k) => { map(block({ start_mode: words(["tui", "headless"]) }), START_MODE_PEERS)(v, k); peerStartConfig(v); },
|
|
139
140
|
terminal: (v, k) => { block({ open: strings })(v, k); terminalTemplate(object(v, k).open); },
|
|
140
141
|
limits: block({ sender_per_min: number(0), pair_per_min: number(0), important_per_hour: number(0), repeat_window_s: number(0) }),
|
|
141
|
-
review: block({ adaptive: bool, min_reviews: number(1, Number.MAX_SAFE_INTEGER, true) }), recovery: block({ auto_resume_after_crash: bool }),
|
|
142
|
+
review: block({ adaptive: bool, min_reviews: number(1, Number.MAX_SAFE_INTEGER, true), pool: v => { reviewerPoolConfig({ pool: v }, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS); }, rerotate: bool }), recovery: block({ auto_resume_after_crash: bool }),
|
|
142
143
|
capabilities: map(strings), coordination: words(["advisory", "turn-free"]), experiments: block({ stale_notices: words(["drop", "deliver"]) }),
|
|
143
144
|
};
|
|
144
145
|
export function validateRawConfig(raw: unknown, declarations?: InstancesConfig): asserts raw is Record<string, unknown> {
|
|
@@ -8,7 +8,7 @@ export function stateDirFor(cwd: string): string {
|
|
|
8
8
|
|
|
9
9
|
/** Control WS wire version. 2 = `deliver` carries `envs` (digests); 3 = `tools` role and task messages; 4 = budget messages and `hub_checkpoint`; 5 = `ask`; 6 = console-only `ui` session bootstrap; 8 = controlled recovery; 9 = Pi bridge metadata; 10 = durable delivery receipts; 11 = queue hold diagnostics; 12 = generation-bound channel settlement and execution budgets; 13 = turn-free facts (`facts` with tool, session and transcript binding, `silenced`) and per-recipient send results (issue #108). 14 = native context-window status and session-bound context checkpoint request ids (issue #185). 15 = conductor tools and permission deadlines/closed events (issues #190, #194). 16 = a Claude session without channel evidence: hello `channel: false` and `inbox` (issue #205). The plugin is installed apart from the daemon, so they can drift. */
|
|
10
10
|
export const PROTOCOL = 16;
|
|
11
|
-
export const DAEMON_FEATURES = ["task_cancel", "recovery_deferred_hub", "peer_instances"] as const;
|
|
11
|
+
export const DAEMON_FEATURES = ["task_cancel", "recovery_deferred_hub", "peer_instances", "release_trains", "reviewer_pool"] as const;
|
|
12
12
|
/** Protocols a current coordinator may authenticate while upgrading a running source. */
|
|
13
13
|
export const RECOVERY_SOURCE_PROTOCOLS = [9, 10, 11, 12, 13, 14, 15, PROTOCOL] as const;
|
|
14
14
|
|
package/src/hub/daemon.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readReleaseHostAdmission, type ReleaseHostAdmission } from "./release-admission.ts";
|
|
1
2
|
import { ContainerRunner, type RunnerOptions, type RunReceipt } from "./check-runner.ts";
|
|
2
3
|
import { fetchSpec, specAvailable, specRefs } from "./spec-read.ts";
|
|
3
4
|
import { isDenied } from "../local/deny.ts";
|
|
@@ -26,7 +27,7 @@ import { peerRosterConfig, rosterAllows, type PeerRoster } from "./roster.ts";
|
|
|
26
27
|
import { KNOWN_PEERS, OPTIONAL_PEERS, PLANNER_PEERS, SUPPORTED_PEERS, isSupportedPeer, STARTABLE_PEERS, isStartablePeer } from "../adapters/registry.ts";
|
|
27
28
|
import { RecoveryDeferredHubError } from "./deferred-hub.ts";
|
|
28
29
|
import { databaseBusySite, isDatabaseBusy, RecoveryDatabaseBusy } from "./recovery-error.ts";
|
|
29
|
-
import { ADAPTERS, QUOTA_HANDOFF_PLATFORMS, NATIVE_CONFIG_BLOCKS, nativeConfigurationDefaults, mergeNativeConfiguration, retainNativeRouting, DAEMON_PORTS, definitionOf } from "../adapters/registry.ts";
|
|
30
|
+
import { ADAPTERS, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS, QUOTA_HANDOFF_PLATFORMS, NATIVE_CONFIG_BLOCKS, nativeConfigurationDefaults, mergeNativeConfiguration, retainNativeRouting, DAEMON_PORTS, definitionOf } from "../adapters/registry.ts";
|
|
30
31
|
import { definitionOf as instanceDefinitionOf, KNOWN_PEERS as BASE_KNOWN_PEERS, SUPPORTED_PEERS as BASE_SUPPORTED_PEERS, STARTABLE_PEERS as BASE_STARTABLE_PEERS } from "../adapters/registry.ts";
|
|
31
32
|
import { effectiveInstanceRoles, instancePeerIds, validateInstances, type InstancesConfig } from "./instances.ts";
|
|
32
33
|
import { INSTANCE_CONFIG_FILES, readInstanceConfig, readInstanceConfigLayers, readInstanceRoleConfig } from "./instance-config.ts";
|
|
@@ -63,7 +64,9 @@ import { profile, proxyEnv, type SandboxNetwork } from "../local/sandbox.ts";
|
|
|
63
64
|
import { DEFAULT_NETWORK_ALLOW, startEgressProxy, type EgressProxy } from "../local/proxy.ts";
|
|
64
65
|
import { runTool, toolResultFailed, TOOL_SCHEMAS, type ToolApproval, type ApprovalObserver, type ToolContext } from "../local/tools.ts";
|
|
65
66
|
import { LocalPeer } from "../adapters/local-worker.ts";
|
|
66
|
-
import {
|
|
67
|
+
import { ReviewerPrintPeer } from "../adapters/reviewer-print.ts";
|
|
68
|
+
import { reviewerPoolConfig } from "./reviewer-pool-config.ts";
|
|
69
|
+
import { reviewDiff } from "../adapters/claude-print.ts";
|
|
67
70
|
import { Capture, skipTools } from "../memory/capture.ts";
|
|
68
71
|
import { DEFAULT_OMNIROUTE, OmniRoute, type OmniRouteConfig } from "../omniroute/client.ts";
|
|
69
72
|
import { Sidecar } from "../switchyard/sidecar.ts";
|
|
@@ -118,10 +121,10 @@ import { ContextWindows, DEFAULT_CONTEXT, acpContext, claudeContext, type Contex
|
|
|
118
121
|
import { projectFileTool, scopedWriteBlocked } from "../cli/facts-hook.ts";
|
|
119
122
|
import { detectTooling } from "./tooling.ts";
|
|
120
123
|
import { looksSecret, DEFAULT_LEASE_MS, extractNodes, memoryHome, SUGGESTED, taskKey, type CandidateStore, type MemoryStore, type GoalView, type Suggested, type View as MemoryView } from "@staix/agent-mem";
|
|
121
|
-
import {
|
|
124
|
+
import { memoryProjectKey } from "@staix/agent-mem";
|
|
125
|
+
import { AGENT_MEM_VERSION, MEMORY_SPLIT_INCOMPLETE, memoryProjectFor, openProfileMemory, openProfileCandidates, type MemoryProfileBinding, type UnboundMemoryProfileReceipt } from "./memory-profile.ts";
|
|
122
126
|
import { syncMemory } from "@staix/agent-mem";
|
|
123
127
|
import { MEMORY_READ_TOOLS, memoryRead, validateMemoryRead } from "./read-tools.ts";
|
|
124
|
-
import { memoryProjectKey } from "@staix/agent-mem";
|
|
125
128
|
import { nativeRecall } from "../memory/recall.ts";
|
|
126
129
|
import { promotionScreen, withoutPii, mentionsPrivatePath } from "./memory-policy.ts";
|
|
127
130
|
|
|
@@ -184,7 +187,7 @@ export interface HubConfig {
|
|
|
184
187
|
/** Per-sender rate limits and repeat suppression for what agents send (issue #38). */
|
|
185
188
|
limits: LimitsConfig;
|
|
186
189
|
/** Reviewer choice from recorded review outcomes, once a reviewer has `min_reviews` of an implementer (issue #35). */
|
|
187
|
-
review: { adaptive: boolean; min_reviews: number };
|
|
190
|
+
review: { adaptive: boolean; min_reviews: number; pool?: string[]; rerotate?: boolean };
|
|
188
191
|
/** After an unplanned stop, start Kimi, Pi and the local worker again with their recorded sessions (issue #37). */
|
|
189
192
|
recovery: { auto_resume_after_crash: boolean };
|
|
190
193
|
/** Per peer, the hub-tool capabilities it has (issue #39); a peer not listed has all of them. */
|
|
@@ -438,6 +441,8 @@ function mlxPinProblem(routing: Routing, project: boolean): string | undefined {
|
|
|
438
441
|
}
|
|
439
442
|
|
|
440
443
|
export interface DaemonOptions {
|
|
444
|
+
/** Host-only admission transport; never selected by project config or tools. */
|
|
445
|
+
releaseHostAdmission?: (cwd: string) => Promise<ReleaseHostAdmission>;
|
|
441
446
|
projectId?: string;
|
|
442
447
|
instanceId?: string;
|
|
443
448
|
cwd: string;
|
|
@@ -601,6 +606,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
601
606
|
let ready = false;
|
|
602
607
|
try {
|
|
603
608
|
const loadedConfig = opts.config ?? loadConfig(opts.cwd);
|
|
609
|
+
reviewerPoolConfig(loadedConfig.review, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS);
|
|
604
610
|
const lifecycleConfig = loadSdlc(opts.cwd, VERSION);
|
|
605
611
|
// Preserve embedding-owned policy references, but never publish reloads into shared defaults.
|
|
606
612
|
const ownedConfig = Object.fromEntries(Object.entries(loadedConfig).map(([key, value]) => [key, !opts.config || value === (DEFAULT_CONFIG as unknown as Record<string, unknown>)[key] ? structuredClone(value) : value])) as unknown as HubConfig;
|
|
@@ -612,6 +618,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
612
618
|
const channelPeers = [...ADAPTERS.filter(definition => definition.identity.mcp === "channel-plugin").map(definition => definition.kind), ...channelExtras];
|
|
613
619
|
const executionRoots = new Map<string, string>();
|
|
614
620
|
const executionOwners = new Map<string, { owner: PeerAdapter; cwd: string }>();
|
|
621
|
+
const reviewerUnavailable = new Map<string, string>();
|
|
615
622
|
const roles = { ...loadedConfig.roles };
|
|
616
623
|
const permissionModesFromInstances: Record<string, PermissionMode> = {};
|
|
617
624
|
const startModesFromInstances: PeerStartConfig = {};
|
|
@@ -977,7 +984,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
977
984
|
const recalled = new Set<PeerId>(); // once per peer per hub run, however often the peer's session restarts
|
|
978
985
|
// #258: the hub's own store, machine-wide in ~/.agenthub/memory. Entries are filed under the project's memory key (its
|
|
979
986
|
// normalized origin remote, the same on every machine; without one, its path). Fail-open: without it the hub works as before.
|
|
980
|
-
|
|
987
|
+
// Daemon start never depends on the memory home: an unresolvable home falls back to the raw key.
|
|
988
|
+
const memoryProjectRaw = memoryProjectKey(opts.cwd);
|
|
989
|
+
const memoryProject = (() => { try { return memoryProjectFor(opts.cwd); } catch { return memoryProjectRaw; } })();
|
|
981
990
|
let memoryStore: MemoryStore | undefined;
|
|
982
991
|
let memoryBinding: MemoryProfileBinding | undefined;
|
|
983
992
|
let memoryProblem: string | undefined;
|
|
@@ -989,11 +998,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
989
998
|
memoryNotice(`hub native memory disabled: ${reason}`);
|
|
990
999
|
};
|
|
991
1000
|
if (config.memory.native) {
|
|
992
|
-
try { const opened = openProfileMemory({ ttlMs: (config.memory.short_ttl_days ?? 14) * 24 * 3600_000, log, screen: promotionScreen(memoryProject, opts.cwd, () => activeRouting(), () => config.pii?.paths ?? [], (key) => {
|
|
1001
|
+
try { const opened = openProfileMemory({ project: memoryProject, projectKey: memoryProjectRaw, ttlMs: (config.memory.short_ttl_days ?? 14) * 24 * 3600_000, log, screen: promotionScreen(memoryProject, opts.cwd, () => activeRouting(), () => config.pii?.paths ?? [], (key) => {
|
|
993
1002
|
const m = /#(\d+)@(\d+)$/.exec(key), t = m ? board.get(Number(m[1])) : undefined;
|
|
994
1003
|
return t && String(t.created) === m![2] ? [...(t.refs.paths ?? []), ...(t.plan?.paths ?? [])] : [];
|
|
995
1004
|
}) }, memoryUnavailable); memoryStore = opened.store; memoryBinding = opened.binding; startupCleanup.push(() => opened.store.close()); }
|
|
996
|
-
catch { if (!memoryProblem) memoryUnavailable("memory profile unavailable; inspect the selector and generation, then restart the hub"); }
|
|
1005
|
+
catch (error) { if (!memoryProblem) memoryUnavailable(error instanceof Error && error.message === MEMORY_SPLIT_INCOMPLETE ? error.message : "memory profile unavailable; inspect the selector and generation, then restart the hub"); }
|
|
997
1006
|
}
|
|
998
1007
|
const memoryProfileStatus = (person = false) => {
|
|
999
1008
|
const value = memoryBinding?.status(memoryStore) ?? ({
|
|
@@ -1292,7 +1301,38 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1292
1301
|
specReason: !may(peer, "spec_read") ? `capabilities.${peer}` : specState.reason ?? "gh unauthenticated" }];
|
|
1293
1302
|
}));
|
|
1294
1303
|
};
|
|
1304
|
+
const releaseQuotaSnapshot = () => {
|
|
1305
|
+
const headroom = budget.headroom(), rows = [...bus.peers].filter(([, peer]) => peer.state !== "offline");
|
|
1306
|
+
const reasons: string[] = [], notes: string[] = [];
|
|
1307
|
+
for (const [id] of rows) {
|
|
1308
|
+
const definition = definitionOf(id), account = definition?.budget?.account ?? id;
|
|
1309
|
+
if (manualPaused.has(id)) reasons.push(`${id}: manual pause; ahub resume ${id}`);
|
|
1310
|
+
if (budget.record(account)) reasons.push(`${id}: paused quota window; inspect ahub budget or ahub budget resume ${id}`);
|
|
1311
|
+
if (definition && (!definition.budget || definition.budget.quotaExempt)) continue;
|
|
1312
|
+
// Source availability belongs to the shared account, including its offline base and reviewer variants.
|
|
1313
|
+
// The soft token window uses the same definition capability that resets it on resume.
|
|
1314
|
+
const hasSource = accountMembers(account).some(member => {
|
|
1315
|
+
const policy = definitionOf(member)?.budget;
|
|
1316
|
+
return !!policy?.reader || !!policy?.resetSoftTokensOnResume && config.budget.kimi_tokens_5h > 0;
|
|
1317
|
+
});
|
|
1318
|
+
if (definition && !hasSource) {
|
|
1319
|
+
notes.push(`${id}: no quota source`);
|
|
1320
|
+
continue;
|
|
1321
|
+
}
|
|
1322
|
+
const reading = headroom[account];
|
|
1323
|
+
if (!reading || !Number.isFinite(reading.headroom)) reasons.push(`${id}: quota window unknown or stale; inspect ahub budget or ahub budget set ${id} <0..1>`);
|
|
1324
|
+
else if (reading.headroom <= 1 - config.budget.gate) reasons.push(`${id}: quota window below release headroom; wait for reset or inspect ahub budget set ${id} <0..1>`);
|
|
1325
|
+
}
|
|
1326
|
+
const idle = rows.every(([, peer]) => peer.state === "idle");
|
|
1327
|
+
return { quota: reasons.length === 0, idle, reasons, notes, signature: JSON.stringify({ gate: config.budget.gate, softTokens: config.budget.kimi_tokens_5h, rows: rows.map(([id, peer]) => [id, peer.state]), headroom, paused: rows.map(([id]) => manualPaused.has(id)), records: Object.entries(budget.status()).map(([peer, row]) => [peer, row.paused?.since, row.paused?.resetsAt]) }) };
|
|
1328
|
+
};
|
|
1329
|
+
const releaseWindow = async () => {
|
|
1330
|
+
const host = await (opts.releaseHostAdmission ?? readReleaseHostAdmission)(opts.cwd), quota = releaseQuotaSnapshot();
|
|
1331
|
+
if (stopping || recoveryActive()) quota.reasons.push("recovery or shutdown is holding release planning");
|
|
1332
|
+
return { at: Date.now(), host: host.admitted && !stopping && !recoveryActive(), quota: quota.quota, idle: quota.idle, reasons: [...(host.reason ? [host.reason] : []), ...quota.reasons, ...quota.notes], close: host.close, current: () => host.current?.() !== false && !stopping && !recoveryActive() && releaseQuotaSnapshot().signature === quota.signature };
|
|
1333
|
+
};
|
|
1295
1334
|
const tasks: Tasks = new Tasks({
|
|
1335
|
+
releaseWindow,
|
|
1296
1336
|
capabilities: peerCapabilities, repository: memoryRepo,
|
|
1297
1337
|
plannerBlocks: () => Object.fromEntries(bus.knownPeers().map(peer => {
|
|
1298
1338
|
const codes: ("approvals" | "quota")[] = [];
|
|
@@ -1304,12 +1344,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1304
1344
|
reviewOnly: reviewOnlyPeer,
|
|
1305
1345
|
// #223: a headless reviewer's verdict holds only while the same child it started with still runs for that task.
|
|
1306
1346
|
reviewAuthority: (by, task) => {
|
|
1307
|
-
if (definitionOf(by)?.
|
|
1308
|
-
const peer = bus.peers.get(by) as
|
|
1347
|
+
if (!definitionOf(by)?.reviewer) return undefined;
|
|
1348
|
+
const peer = bus.peers.get(by) as ReviewerPrintPeer | undefined, token = peer?.reviewToken(task);
|
|
1309
1349
|
return () => token !== undefined && bus.peers.get(by) === peer && peer?.reviewToken(task) === token ? undefined : `no review of task #${task} is running for ${by}`;
|
|
1310
1350
|
},
|
|
1311
1351
|
// #223 R4: fresh only when the verdict comes from a reviewer session the hub started for this task's review.
|
|
1312
|
-
reviewContext: (by, task) => definitionOf(by)?.
|
|
1352
|
+
reviewContext: (by, task) => !!definitionOf(by)?.reviewer && (bus.peers.get(by) as ReviewerPrintPeer | undefined)?.reviewing === task ? "fresh" : "continuing",
|
|
1313
1353
|
// Proof belongs to the adapter generation actually started in the validated root. A role-only
|
|
1314
1354
|
// reload cannot turn a canonical reviewer process into an isolated implementation owner.
|
|
1315
1355
|
ownerIsolation: () => Object.fromEntries(extraIds.map(id => {
|
|
@@ -1403,7 +1443,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1403
1443
|
recordCohort: (c) => event({ type: "cohort", ...c }),
|
|
1404
1444
|
// The trace holds peer names and numbers only (never task text): it is the inputs a later check of the prediction needs.
|
|
1405
1445
|
recordSplit: (task, p, where) => event({ type: "split", task, where, verdict: p.verdict, ...(p.single ? { single: p.single, splitS: p.splitS, singleS: p.singleS } : {}), ...(p.verdict === "unknown" ? { reason: p.trace.at(-1)!.replace(/^ {2}unknown: /, "").slice(0, 200) } : {}), trace: p.trace.slice(1).map((l) => l.trim()).slice(0, 10) }),
|
|
1406
|
-
failing: () => bus.failingPeers(),
|
|
1446
|
+
failing: () => ({ ...bus.failingPeers(), ...Object.fromEntries(reviewerUnavailable) }),
|
|
1407
1447
|
held: () => Object.fromEntries(bus.knownPeers().flatMap((peer) => { const hold = queueHold(peer); return hold ? [[peer, hold]] : []; })),
|
|
1408
1448
|
});
|
|
1409
1449
|
relevantNotice = (peer, env) => {
|
|
@@ -2022,7 +2062,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2022
2062
|
}
|
|
2023
2063
|
// Inside a PII turn the worker's words may carry the PII whatever they are attached to: a note would go to
|
|
2024
2064
|
// claude-mem (a cloud observer) and a new task could be routed to a cloud peer without matching any pattern.
|
|
2025
|
-
if (piiTurn && (MEMORY_READ_TOOLS.has(op) || op === "hub_memory_correct" || op === "hub_remember" || op === "hub_task_propose" || op === "hub_sdlc_record" || op === "hub_plan_review" || op === "hub_sdlc_costs" || op === "hub_backlog_return_untouched" || op === "hub_backlog_plan" || op === "hub_backlog_undo" || op === "hub_backlog_pull" || op === "hub_backlog_split" || op === "release_new")) throw new PreEffectToolRefusal(`${op} is not available while working on a PII task: its text must not leave this machine`);
|
|
2065
|
+
if (piiTurn && (MEMORY_READ_TOOLS.has(op) || op === "hub_memory_correct" || op === "hub_remember" || op === "hub_task_propose" || op === "hub_sdlc_record" || op === "hub_plan_review" || op === "hub_sdlc_costs" || op.startsWith("hub_release_") || op === "hub_backlog_return_untouched" || op === "hub_backlog_plan" || op === "hub_backlog_undo" || op === "hub_backlog_pull" || op === "hub_backlog_split" || op === "release_new")) throw new PreEffectToolRefusal(`${op} is not available while working on a PII task: its text must not leave this machine`);
|
|
2026
2066
|
// The same words attached to an ordinary task would reach its reviewer, its owner, overlapping owners (a plan, the
|
|
2027
2067
|
// completed-change notice) and claude-mem. A turn that holds a PII task acts on ordinary tasks in a turn of its own.
|
|
2028
2068
|
if (piiTurn && (op === "hub_task_done" || op === "hub_review" || (op === "hub_task_accept" && a.plan != null))) {
|
|
@@ -2136,6 +2176,16 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2136
2176
|
}
|
|
2137
2177
|
case "hub_backlog_list":
|
|
2138
2178
|
return JSON.stringify(tasks.backlogList(by));
|
|
2179
|
+
case "hub_release_new":
|
|
2180
|
+
return JSON.stringify(tasks.publicRelease(await tasks.newRelease(by, { name: a.name, goal: a.goal, order: a.order })));
|
|
2181
|
+
case "hub_release_report":
|
|
2182
|
+
return JSON.stringify(tasks.releaseReport(by, a.id));
|
|
2183
|
+
case "hub_release_configure":
|
|
2184
|
+
return JSON.stringify(tasks.publicRelease(await tasks.configureRelease(by, { id: a.id, revision: a.revision, hotfix: a.hotfix, state: a.state, cut: a.cut })));
|
|
2185
|
+
case "hub_release_assign":
|
|
2186
|
+
return JSON.stringify(tasks.publicView(await tasks.assignRelease(by, { id: a.id, revision: a.revision, release: a.release, reason: a.reason })));
|
|
2187
|
+
case "hub_release_cut":
|
|
2188
|
+
return JSON.stringify(tasks.publicRelease(await tasks.cutRelease(by, { id: a.id, revision: a.revision })));
|
|
2139
2189
|
case "hub_release_list":
|
|
2140
2190
|
return JSON.stringify(tasks.releaseList(by));
|
|
2141
2191
|
case "hub_planner_groom":
|
|
@@ -3245,7 +3295,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3245
3295
|
const owner = bus.peers.get(peer);
|
|
3246
3296
|
const stopNotice = definitionOf(peer)?.cli.stopNotice?.(owner instanceof WsPeer ? "tui" : !owner ? startMode(peer) : "headless", owner);
|
|
3247
3297
|
if (stopNotice) return { ok: false, error: stopNotice };
|
|
3248
|
-
if (!(owner instanceof AcpPeer || owner instanceof PiPeer || owner instanceof LocalPeer || owner instanceof
|
|
3298
|
+
if (!(owner instanceof AcpPeer || owner instanceof PiPeer || owner instanceof LocalPeer || owner instanceof ReviewerPrintPeer)) return { ok: false, error: "unknown or non-owned peer; run ahub status and choose a hub-owned headless peer" };
|
|
3249
3299
|
if (owner instanceof PiPeer && owner.mode !== "headless") return { ok: false, error: "Pi belongs to a native TUI terminal; end it in its terminal" };
|
|
3250
3300
|
if (owner.state === "offline") return { ok: false, error: `peer is already offline; start it with ${startCommand(peer)} before stopping it` };
|
|
3251
3301
|
if (starting.has(peer) || requestedPeerStops.has(peer) || permissionChanges.has(peer)) return { ok: false, error: "peer lifecycle or permission change is in progress; wait for ahub status before retrying" };
|
|
@@ -3309,6 +3359,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3309
3359
|
|
|
3310
3360
|
async function startPeerBody(peer: string, args: { model?: string; route?: string; mode?: "headless" | "tui"; backend?: "auto" | "dgx" | "mlx"; sessionId?: string; sessionFile?: string; replaceSession?: boolean; fresh?: boolean; unattended?: boolean; providerProfile?: string }, mute: (p: PeerAdapter) => void, executionCwd: string): Promise<Record<string, unknown>> {
|
|
3311
3361
|
const adapter = definitionOf(peer);
|
|
3362
|
+
if (adapter?.reviewer?.unavailable) return { ok: false, error: adapter.reviewer.unavailable };
|
|
3312
3363
|
const preflight = adapter?.start.preflight?.({ instance: peer, form: args.mode });
|
|
3313
3364
|
if (preflight) return { ok: false, error: preflight };
|
|
3314
3365
|
if (adapter?.attach?.admission && !declarations[peer]) {
|
|
@@ -3803,24 +3854,39 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3803
3854
|
await local.start();
|
|
3804
3855
|
return { ok: true, model: route ? `${route} (fallback ${routing.local.fixed_model})` : (args.model ?? routing.local.fixed_model) };
|
|
3805
3856
|
}
|
|
3806
|
-
if (adapter?.
|
|
3807
|
-
|
|
3857
|
+
if (adapter?.reviewer) {
|
|
3858
|
+
const backend = adapter.reviewer.backend;
|
|
3859
|
+
if (!backend || !adapter.start.create) return { ok: false, error: "reviewer has no verified runtime" };
|
|
3860
|
+
// #519: each backend proves isolation before admission, then starts one fresh child per review.
|
|
3808
3861
|
if (existing?.state === "busy") return { ok: false, error: `${peer} is reviewing; retry when it is idle` };
|
|
3809
3862
|
if (existing) { mute(existing); await existing.stop(); }
|
|
3810
|
-
const reviewer = adapter.start.create!({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: config, permission: permissionMode(peer), mcpServers: [], daemon: DAEMON_PORTS["
|
|
3811
|
-
cwd: executionCwd,
|
|
3863
|
+
const reviewer = adapter.start.create!({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: config, permission: permissionMode(peer), mcpServers: [], daemon: DAEMON_PORTS["reviewer-print"]({
|
|
3864
|
+
backend, cwd: executionCwd,
|
|
3812
3865
|
mcpConfig: JSON.stringify({ mcpServers: { "agent-hub": { command: process.execPath, args: ["run", SERVER_JS], env: toolEnv(peer, true) } } }),
|
|
3813
3866
|
systemPrompt: sessionRoleContract(peer),
|
|
3814
3867
|
watchdogMs: config.watchdog_ms,
|
|
3815
|
-
privatePaths: [...(config.pii?.paths ?? []), ...config.local.deny],
|
|
3868
|
+
privatePaths: [opts.stateDir, ...(config.pii?.paths ?? []), ...config.local.deny],
|
|
3816
3869
|
facts: env => env.private ? undefined : reviewDiff(executionCwd, env.refs?.commit, { privatePath: path => isDenied(path, config.local.deny) || matchesPolicy(executionCwd, path, config.pii?.paths ?? [], true), nameable: tasks.nameable }),
|
|
3817
3870
|
verdictSince: (task, since) => board.get(task)?.history.some(h => h.by === peer && h.at >= since && (h.event === "approved" || h.event === "changes_requested")) === true,
|
|
3818
3871
|
onUsage: usage => void addTokens(peer, (usage.input_tokens ?? 0) + (usage.output_tokens ?? 0)),
|
|
3872
|
+
onUnavailable: (failure, current) => {
|
|
3873
|
+
const attached = () => current() && bus.peers.get(peer) === reviewer;
|
|
3874
|
+
if (!attached()) return;
|
|
3875
|
+
if (failure.cause === "start_failed") reviewerUnavailable.set(peer, "reviewer isolation unavailable; restart after checking the verified CLI and isolation requirements");
|
|
3876
|
+
return tasks.retryReviewer(failure.task, peer, failure, attached).then(() => undefined);
|
|
3877
|
+
},
|
|
3819
3878
|
log,
|
|
3820
|
-
}) }) as
|
|
3879
|
+
}) }) as ReviewerPrintPeer;
|
|
3880
|
+
try { await reviewer.start(); }
|
|
3881
|
+
catch {
|
|
3882
|
+
await reviewer.stop().catch(() => {});
|
|
3883
|
+
const reason = "reviewer isolation unavailable; inspect the verified CLI version and isolation requirements";
|
|
3884
|
+
reviewerUnavailable.set(peer, reason);
|
|
3885
|
+
return { ok: false, error: reason };
|
|
3886
|
+
}
|
|
3887
|
+
reviewerUnavailable.delete(peer);
|
|
3821
3888
|
executionOwners.set(peer, { owner: reviewer, cwd: executionCwd });
|
|
3822
3889
|
bus.add(reviewer);
|
|
3823
|
-
await reviewer.start();
|
|
3824
3890
|
return { ok: true };
|
|
3825
3891
|
}
|
|
3826
3892
|
return { ok: false, error: `unknown peer "${peer}"` };
|
|
@@ -4686,16 +4752,23 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4686
4752
|
return ids.length && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("task_cancel"))
|
|
4687
4753
|
? `target lacks task_cancel; cancelled tasks: ${ids.map(id => `#${id}`).join(", ")}` : undefined;
|
|
4688
4754
|
};
|
|
4755
|
+
const incompatibleRecoveryTarget = () => {
|
|
4756
|
+
const cancelled = incompatibleCancellationTarget();
|
|
4757
|
+
if (cancelled) return cancelled;
|
|
4758
|
+
if (extraIds.length && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("peer_instances"))) return "target lacks peer_instances; declared instance identities cannot be restored by that daemon";
|
|
4759
|
+
if (board.releases().some(release => release.train && ["active", "frozen"].includes(release.state)) && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("release_trains"))) return "target lacks release_trains; active or frozen train safeguards cannot be preserved";
|
|
4760
|
+
if ((extraIds.some(id => !!definitionOf(id)?.reviewer) || config.review.pool !== undefined) && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("reviewer_pool"))) return "target lacks reviewer_pool; fresh review-only variants and pool policy cannot be preserved";
|
|
4761
|
+
return undefined;
|
|
4762
|
+
};
|
|
4689
4763
|
const checkTarget = msg.op === "prepare" || msg.op === "commit" || msg.op === "inspect" && msg.targetFeatures !== undefined;
|
|
4690
4764
|
if (checkTarget) {
|
|
4691
|
-
const error =
|
|
4692
|
-
if (extraIds.length && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("peer_instances"))) return recoveryError("target lacks peer_instances; declared instance identities cannot be restored by that daemon");
|
|
4765
|
+
const error = incompatibleRecoveryTarget(); if (error) return recoveryError(error);
|
|
4693
4766
|
}
|
|
4694
4767
|
|
|
4695
4768
|
if (msg.op === "inspect" || msg.op === "prepare" || msg.op === "commit") {
|
|
4696
4769
|
for (const pi of bus.peers.values()) if (pi instanceof PiPeer && pi.state === "idle") {
|
|
4697
4770
|
try { await pi.captureResume(); } catch (error) { return recoveryError((error as Error).message); }
|
|
4698
|
-
if (checkTarget) { const error =
|
|
4771
|
+
if (checkTarget) { const error = incompatibleRecoveryTarget(); if (error) return recoveryError(error); }
|
|
4699
4772
|
}
|
|
4700
4773
|
}
|
|
4701
4774
|
if (msg.op === "inspect" && msg.operationId === undefined) return { t: "recovery", ok: true, recovery: recoveryView() };
|
|
@@ -4711,7 +4784,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4711
4784
|
recoveryPeerSnapshot = undefined;
|
|
4712
4785
|
}
|
|
4713
4786
|
if (recoveryOperationId && recoveryOperationId !== op) return recoveryError("another recovery operation is active");
|
|
4714
|
-
{ const error =
|
|
4787
|
+
{ const error = incompatibleRecoveryTarget(); if (error) return recoveryError(error); }
|
|
4715
4788
|
recoveryOperationId = op;
|
|
4716
4789
|
if (!recoveryPhase) recoveryPhase = "preparing";
|
|
4717
4790
|
armRecoveryLease();
|
|
@@ -4719,7 +4792,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4719
4792
|
budget.setRecoveryHold(true);
|
|
4720
4793
|
bus.setRecoveryHold(true, Array.isArray(msg.targetFeatures) && msg.targetFeatures.includes("recovery_deferred_hub"));
|
|
4721
4794
|
await bus.fenceRecovery();
|
|
4722
|
-
{ const error =
|
|
4795
|
+
{ const error = incompatibleRecoveryTarget(); if (error) return recoveryError(error); }
|
|
4723
4796
|
const blocked = [...bus.peers].filter(([, peer]) => peer.state === "busy").map(([id]) => id);
|
|
4724
4797
|
if (!blocked.length && permissions.size === 0 && recoveryReady()) {
|
|
4725
4798
|
recoveryPeerSnapshot ??= Object.values(recoveryPeers());
|
|
@@ -4746,6 +4819,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4746
4819
|
}
|
|
4747
4820
|
if (msg.op === "commit") {
|
|
4748
4821
|
if ((recoveryPhase !== "prepared" && recoveryPhase !== "preparing") || !recoveryReady()) return recoveryError("recovery is not ready; inspect until peers are idle, approvals are complete and completion checks have finished");
|
|
4822
|
+
// Person ruling 2026-10-19: upgrade is excluded from release resource gates; original readiness still binds.
|
|
4749
4823
|
recoveryPhase = "prepared";
|
|
4750
4824
|
bus.setRecoveryHold(true, Array.isArray(msg.targetFeatures) && msg.targetFeatures.includes("recovery_deferred_hub"));
|
|
4751
4825
|
recoveryPeerSnapshot ??= Object.values(recoveryPeers());
|
|
@@ -4768,7 +4842,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4768
4842
|
peers,
|
|
4769
4843
|
integrity: integrity(),
|
|
4770
4844
|
};
|
|
4771
|
-
{ const error =
|
|
4845
|
+
{ const error = incompatibleRecoveryTarget(); if (error) return recoveryError(error); }
|
|
4772
4846
|
try { writeRestartSnapshot(opts.stateDir, snapshot); } catch { return recoveryError("could not persist restart state"); }
|
|
4773
4847
|
recoveryCommitted = true;
|
|
4774
4848
|
clearTimeout(recoveryLeaseTimer);
|
package/src/hub/envelope.ts
CHANGED
|
@@ -14,7 +14,7 @@ export interface Envelope {
|
|
|
14
14
|
kind: Kind;
|
|
15
15
|
priority: Priority;
|
|
16
16
|
body: string; // agent conclusions only, never tool noise
|
|
17
|
-
refs?: { repo?: string; branch?: string; commit?: string; paths?: string[]; task?: string; taskOffer?: number; taskCancelled?: number; taskRecipient?: "owner" | "reviewer" | "planner"; backlog?: number[]; supervision?: boolean; supervisionKey?: string; boundedCheck?: string; budgetResume?: string; budgetResumeMoves?: { id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]; budgetResumeEarlier?: string[] };
|
|
17
|
+
refs?: { repo?: string; branch?: string; commit?: string; paths?: string[]; task?: string; reviewRevision?: string; taskOffer?: number; taskCancelled?: number; taskRecipient?: "owner" | "reviewer" | "planner"; backlog?: number[]; supervision?: boolean; supervisionKey?: string; boundedCheck?: string; budgetResume?: string; budgetResumeMoves?: { id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]; budgetResumeEarlier?: string[] };
|
|
18
18
|
/** The body must not be shown outside its recipients: console tail and log print a stub instead (PII tasks). */
|
|
19
19
|
private?: boolean;
|
|
20
20
|
ts: number;
|
|
@@ -2,7 +2,7 @@ import { isAbsolute, relative } from "node:path";
|
|
|
2
2
|
import { realPath } from "./project.ts";
|
|
3
3
|
|
|
4
4
|
/** A repo-writable PATH shim is not a host-owned authenticated capability. No caller-selected binary. */
|
|
5
|
-
export function hostExecutable(name: "gh" | "container" | "git", projectDir: string, path = process.env.PATH ?? ""): string {
|
|
5
|
+
export function hostExecutable(name: "gh" | "container" | "git" | "dot", projectDir: string, path = process.env.PATH ?? ""): string {
|
|
6
6
|
const found = Bun.which(name, { PATH: path });
|
|
7
7
|
if (!found || !isAbsolute(found)) throw new Error(`${name} unavailable`);
|
|
8
8
|
const target = realPath(found), inside = relative(realPath(projectDir), target);
|
package/src/hub/hub-tools.ts
CHANGED
|
@@ -61,6 +61,11 @@ export const TASK_TOOLS: HubTool[] = [
|
|
|
61
61
|
tool("hub_backlog_pull", "Reserve a WIP slot and pull ready unpinned backlog work into To Do at its current revision. Preserves person order/reservations and frozen scope; pending unowned work can be undone with hub_backlog_undo.", { id, revision: { type: "integer", minimum: 0 }, reason: { type: "string", minLength: 1, maxLength: 1000 } }, ["id", "revision", "reason"]),
|
|
62
62
|
tool("hub_backlog_split", "Split unstarted backlog work into two to eight staged parts. The original card retains integration/acceptance and depends on the children. Use after for existing prerequisite ids and afterParts for earlier zero-based part indexes. Latest unused unchanged children can be undone with hub_backlog_undo.", { id, revision: { type: "integer", minimum: 0 }, reason: { type: "string", minLength: 1, maxLength: 1000 }, parts: { type: "array", minItems: 2, maxItems: 8, items: { type: "object", properties: { title: { type: "string", minLength: 1, maxLength: 300 }, detail: { type: "string", maxLength: 8000 }, class: str, refs, plan, after: { type: "array", maxItems: 64, uniqueItems: true, items: { type: "integer", minimum: 1 } }, afterParts: { type: "array", maxItems: 8, uniqueItems: true, items: { type: "integer", minimum: 0 } } }, required: ["title"], additionalProperties: false } } }, ["id", "revision", "reason", "parts"]),
|
|
63
63
|
tool("hub_backlog_undo", "Undo your latest planning change at its current revision. Refuses intervening changes and another actor's edit.", { id, revision: { type: "integer", minimum: 1 } }, ["id", "revision"]),
|
|
64
|
+
tool("hub_release_new", "Create a future release planning row. Planner/person only; no activation, publish or release authorization.", { name: { type: "string", minLength: 1, maxLength: 64 }, goal: { type: "string", minLength: 1, maxLength: 1000 }, order: { type: "integer", minimum: 0 } }, ["name", "goal", "order"]),
|
|
65
|
+
tool("hub_release_report", "Read a planned release's task readiness and recorded metrics. Planner/person only. A due date never waives unfinished scope.", { id }, ["id"]),
|
|
66
|
+
tool("hub_release_assign", "Assign or move only a task's release, retaining its execution state and owner. Planner/person only; requires a reason and current planning revision. Frozen scope refuses.", { id, revision: { type: "integer", minimum: 0 }, release: { type: ["integer", "null"], minimum: 1 }, reason: { type: "string", minLength: 1, maxLength: 1000 } }, ["id", "revision", "release", "reason"]),
|
|
67
|
+
tool("hub_release_configure", "Configure/activate a release train or record a hotfix cause. Planner/person only. At most one train is active/frozen; planning rows are future trains.", { id, revision: { type: "integer", minimum: 0 }, state: { type: "string", enum: ["active"] }, cut: { type: "integer", minimum: 1 }, hotfix: { type: "object", properties: { cause: { type: "string", enum: ["loss", "security_privacy", "upgrade_start_blocked", "core_flow_no_workaround"] }, reason: { type: "string", minLength: 1, maxLength: 1000 } }, required: ["cause", "reason"], additionalProperties: false } }, ["id", "revision"]),
|
|
68
|
+
tool("hub_release_cut", "Freeze an active release's approved scope after host resource admission and fresh peer quota checks. Unknown evidence defers; no publish, tag, upgrade or authorization.", { id, revision: { type: "integer", minimum: 0 } }, ["id", "revision"]),
|
|
64
69
|
tool("hub_release_list", "Read public release scope and planned/actual dates for phased planning. Configured planners only.", {}),
|
|
65
70
|
|
|
66
71
|
tool("hub_task_propose", "Put a piece of work on the shared task board. The hub assigns an owner by class (routing.toml) unless you name one. Classes: plan, implement, bulk_edit, test, review, summarize, triage. Name the class when you know it; without one the hub tries to pick it. Name yourself as owner to claim work nobody assigned you: it starts in progress. Put the paths the work touches in refs, and when you claim work, a plan; the hub says when they overlap another open task.", { title: str, class: { type: "string", enum: ["plan", "implement", "bulk_edit", "test", "review", "summarize", "triage"] }, detail: str, role: { type: "string", description: "catalog role id for this task class; guidance only, no routing or authority change" }, refs, plan, owner: { type: "string", description: "peer id; yours to claim the work, omit to let the hub route it. With after, the owner is reserved: offered the task first once it is ready" }, after: { type: "array", items: { type: "integer" }, description: "ids of tasks that must be approved first; until then this one waits, offered to nobody, and cannot be claimed" }, backlog: { type: "boolean", description: "stage unoffered work; owner is only a reservation, even when naming yourself" }, urgent: { type: "boolean", description: "hand it over even when its owner is paused for quota only briefly" }, goal: { type: "string", description: "id of this project's long-term task (ahub goal list) the work is part of" } }, ["title"]),
|
package/src/hub/instances.ts
CHANGED
|
@@ -7,6 +7,7 @@ export interface InstanceConfig {
|
|
|
7
7
|
roles?: string[];
|
|
8
8
|
start_mode?: StartForm;
|
|
9
9
|
permission_mode?: PermissionMode;
|
|
10
|
+
review_mode?: "fresh";
|
|
10
11
|
worktree?: string;
|
|
11
12
|
}
|
|
12
13
|
export type InstancesConfig = Readonly<Record<string, InstanceConfig>>;
|
|
@@ -48,15 +49,24 @@ export function validateInstances(raw: unknown): InstancesConfig {
|
|
|
48
49
|
for (const [id, descriptor] of Object.entries(declarations)) {
|
|
49
50
|
if (!PEER_ID.test(id) || RESERVED.has(id) || definitionOf(id)) return fail("instances", "ids must be nonreserved extra instance ids, not builtin ids");
|
|
50
51
|
const path = `instances.${id}`, entry = fields(descriptor.value, path);
|
|
51
|
-
if (Object.keys(entry).some(key => !["kind", "roles", "start_mode", "permission_mode", "worktree"].includes(key))) return fail(path, "unsupported field");
|
|
52
|
+
if (Object.keys(entry).some(key => !["kind", "roles", "start_mode", "permission_mode", "worktree", "review_mode"].includes(key))) return fail(path, "unsupported field");
|
|
52
53
|
const kind: unknown = entry.kind?.value;
|
|
53
54
|
if (typeof kind !== "string") return fail(`${path}.kind`, "must name a native kind");
|
|
54
55
|
const definition = definitionOf(kind), prefix = `${kind}-`;
|
|
55
56
|
if (!definition) return fail(`${path}.kind`, "unknown native kind");
|
|
56
57
|
if (!id.startsWith(prefix) || !/^[a-z0-9][a-z0-9-]*$/.test(id.slice(prefix.length))) return fail(path, "id must be <kind>-<name>");
|
|
58
|
+
const declaredRoles = entry.roles ? roles(entry.roles.value, `${path}.roles`) : [];
|
|
59
|
+
const reviewer = (definition.reviewerVariant ?? definition).reviewer;
|
|
60
|
+
const reviewerOnly = entry.start_mode?.value === "headless" && declaredRoles.length === 1 && declaredRoles[0] === "reviewer";
|
|
61
|
+
if (entry.review_mode && (entry.review_mode.value !== "fresh" || !reviewerOnly || !reviewer)) return fail(`${path}.review_mode`, "fresh requires reviewer-only roles, headless mode and a declared fresh-review capability");
|
|
62
|
+
const freshReviewer = reviewerOnly && !!reviewer && (reviewer.declaration === "automatic" || entry.review_mode?.value === "fresh");
|
|
63
|
+
if (definition.routing?.reviewOnly && !freshReviewer) return fail(path, "a review-only kind requires headless roles exactly [\"reviewer\"]");
|
|
64
|
+
if (freshReviewer && entry.worktree) return fail(`${path}.worktree`, "a fresh reviewer reads the project checkout: worktree is not supported");
|
|
65
|
+
if (freshReviewer && entry.permission_mode) return fail(`${path}.permission_mode`, "a fresh reviewer has a fixed read-only policy");
|
|
57
66
|
// #450 I3: a channel-plugin kind (Claude) runs only in the person's terminal, so it reviews or plans and never owns writes.
|
|
58
67
|
const channel = definition.identity.mcp === "channel-plugin";
|
|
59
|
-
|
|
68
|
+
const unavailableReviewer = freshReviewer && !!(definition.reviewerVariant ?? definition).reviewer?.unavailable;
|
|
69
|
+
if (!channel && !unavailableReviewer && (!definition.start.create || !definition.start.forms.includes("headless") || !["env", "acp-session"].includes(definition.identity.mcp))) return fail(`${path}.kind`, "unsupported; a headless factory with env or ACP-session identity, or a channel-plugin kind, is required, not project-file identity");
|
|
60
70
|
if (channel) {
|
|
61
71
|
const declared = entry.roles ? roles(entry.roles.value, `${path}.roles`) : [];
|
|
62
72
|
// #223: a headless one is the kind's reviewer variant, one fresh hub-run session per review request.
|
|
@@ -68,7 +78,7 @@ export function validateInstances(raw: unknown): InstancesConfig {
|
|
|
68
78
|
if (!declared.length || declared.some(role => role !== "reviewer" && role !== "planner")) return fail(`${path}.roles`, "a channel instance must declare its roles: reviewer and/or planner");
|
|
69
79
|
}
|
|
70
80
|
}
|
|
71
|
-
const value: InstanceConfig = { kind };
|
|
81
|
+
const value: InstanceConfig = { kind, ...(entry.review_mode ? { review_mode: "fresh" as const } : {}) };
|
|
72
82
|
if (entry.roles) { value.roles = roles(entry.roles.value, `${path}.roles`); Object.freeze(value.roles); }
|
|
73
83
|
if (entry.start_mode) {
|
|
74
84
|
if (entry.start_mode.value !== "headless") return fail(`${path}.start_mode`, "I2 extra instances require headless mode");
|