@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.
- package/CHANGELOG.md +25 -0
- package/README.md +15 -8
- package/docs/agent-notes/adapters.md +9 -2
- package/docs/agent-notes/budget.md +3 -2
- package/docs/agent-notes/bus.md +3 -0
- package/docs/agent-notes/daemon.md +11 -3
- package/docs/agent-notes/tasks.md +14 -1
- package/docs/agent-notes/tests.md +6 -2
- package/docs/memory.md +3 -1
- package/docs/operations.md +74 -18
- package/docs/quickstart.md +7 -9
- package/docs/sdlc.md +72 -5
- package/docs/security.md +11 -3
- package/docs/smoke.md +18 -2
- package/docs/specs/2026-09-19-agent-hub-design.md +108 -3
- package/docs/verification/2026-10-19-0.12.36.md +63 -0
- 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 +23 -15
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +2843 -1632
- package/src/adapters/acp.ts +3 -2
- package/src/adapters/claude-print.ts +92 -0
- package/src/adapters/codex-review-isolation.ts +276 -0
- package/src/adapters/definitions/builtin-metadata.ts +20 -1
- package/src/adapters/definitions/native-cli-entry-claude.ts +17 -3
- package/src/adapters/definitions/native-cli-entry-jcode.ts +3 -2
- package/src/adapters/definitions/native-cli-entry-kimi.ts +3 -2
- package/src/adapters/definitions/native-cli-entry-types.ts +2 -0
- package/src/adapters/definitions/native-factories.ts +24 -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 +13 -3
- package/src/adapters/registry.ts +29 -5
- package/src/adapters/reviewer-print.ts +319 -0
- package/src/cli/help.ts +7 -0
- package/src/cli/identity.ts +2 -1
- package/src/cli/launch.ts +19 -6
- package/src/cli/main.ts +57 -4
- package/src/cli/memory.ts +13 -12
- package/src/cli/reset.ts +4 -2
- package/src/cli/statusline-tee.ts +5 -2
- package/src/cli/terminal-recovery.ts +7 -2
- package/src/cli/upgrade-runtime.ts +18 -15
- package/src/hub/board.ts +61 -11
- package/src/hub/bus.ts +11 -5
- package/src/hub/claude-state.ts +37 -0
- package/src/hub/config-apply.ts +3 -2
- package/src/hub/control-client.ts +1 -1
- package/src/hub/cost-governance.ts +76 -0
- package/src/hub/daemon.ts +269 -87
- package/src/hub/envelope.ts +1 -1
- package/src/hub/host-executable.ts +1 -1
- package/src/hub/hub-tools.ts +25 -1
- package/src/hub/instances.ts +29 -5
- 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/review-brief.ts +175 -0
- package/src/hub/reviewer-pool-config.ts +11 -0
- package/src/hub/reviewer-pool.ts +70 -0
- package/src/hub/routing.ts +58 -4
- package/src/hub/sdlc-release.ts +113 -0
- package/src/hub/sdlc.ts +205 -4
- package/src/hub/status-roster.ts +10 -0
- package/src/hub/task-sweep.ts +143 -0
- package/src/hub/tasks.ts +788 -52
- package/src/hub/usage.ts +57 -20
package/src/hub/bus.ts
CHANGED
|
@@ -40,9 +40,9 @@ export interface BusOptions {
|
|
|
40
40
|
/**
|
|
41
41
|
* Optional: does a queued envelope still matter to this recipient (issue #106)? Asked per recipient when a delivery is
|
|
42
42
|
* built and again right before it is handed over, after condensation. False drops it unsent: the journal records it
|
|
43
|
-
* as discarded and taps see a `stale` event.
|
|
43
|
+
* as discarded and taps see a `stale` event. A string is false with its own reason (#223, a review-only peer).
|
|
44
44
|
*/
|
|
45
|
-
relevant?: (peer: PeerId, env: Envelope) => boolean;
|
|
45
|
+
relevant?: (peer: PeerId, env: Envelope) => boolean | string;
|
|
46
46
|
/**
|
|
47
47
|
* Optional: why this recipient does not get this envelope (issue #107, a turn-free cohort). Asked per recipient once
|
|
48
48
|
* the audience is final (implicit replies and `digest` resolved), so the other recipients still get it, unchanged;
|
|
@@ -1088,7 +1088,13 @@ export class Bus {
|
|
|
1088
1088
|
}
|
|
1089
1089
|
|
|
1090
1090
|
private isRelevant(id: PeerId, env: Envelope): boolean {
|
|
1091
|
-
return
|
|
1091
|
+
return this.irrelevance(id, env) === undefined;
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
/** Why a queued envelope no longer goes to `id`, or undefined when it still does. */
|
|
1095
|
+
private irrelevance(id: PeerId, env: Envelope): string | undefined {
|
|
1096
|
+
const verdict = this.withdrawn.has(env.id) ? false : this.opts.relevant?.(id, env) ?? true;
|
|
1097
|
+
return verdict === true ? undefined : typeof verdict === "string" ? verdict : `stale: task #${env.refs?.task ?? "?"} is no longer open for ${id}`;
|
|
1092
1098
|
}
|
|
1093
1099
|
|
|
1094
1100
|
/**
|
|
@@ -1101,8 +1107,8 @@ export class Bus {
|
|
|
1101
1107
|
let dropped = false;
|
|
1102
1108
|
for (let i = queue.length - 1; i >= 0; i--) {
|
|
1103
1109
|
const env = queue[i]!;
|
|
1104
|
-
|
|
1105
|
-
|
|
1110
|
+
const reason = this.irrelevance(id, env);
|
|
1111
|
+
if (reason === undefined) continue;
|
|
1106
1112
|
queue.splice(i, 1);
|
|
1107
1113
|
dropped = true;
|
|
1108
1114
|
if (this.journal) this.pendingOutcomes.push({ id: crypto.randomUUID(), peer: id, state: "discarded", createdAt: Date.now(), originals: [env], out: [], reason });
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// No imports: the statusline tee and the CLI load this without the daemon (#450 I3).
|
|
2
|
+
const PEER = /^[a-z][a-z0-9-]{0,31}$/;
|
|
3
|
+
|
|
4
|
+
/** The peer a Claude process writes for: its AGENTHUB_PEER_ID when well formed, otherwise the builtin claude peer. */
|
|
5
|
+
export function claudePeer(value: string | undefined): string {
|
|
6
|
+
return value !== undefined && PEER.test(value) ? value : "claude";
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Per-peer Claude state file names. The builtin claude peer keeps its original names, so nothing migrates. */
|
|
10
|
+
export function claudeStateFile(base: "claude-launch" | "claude-session" | "claude-context", peer: string): string {
|
|
11
|
+
// Instance ids are always <kind>-<name>; a builtin id has no hyphen.
|
|
12
|
+
return peer.includes("-") && PEER.test(peer) ? `${base}.${peer}.json` : `${base}.json`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** A Claude peer's own row in terminal-recovery.json for this daemon, never a sibling instance's (row order is arbitrary). */
|
|
16
|
+
export function ownTerminalRow(records: unknown, peer: string, projectRoot: string, instanceId: string): { launchId?: unknown } | undefined {
|
|
17
|
+
if (!Array.isArray(records)) return undefined;
|
|
18
|
+
return records.find((row) => row?.peer === peer && row?.projectRoot === projectRoot && row?.instanceId === instanceId);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Whether a Claude session's transcript exists: the path the peer's own status line reported for that session, else
|
|
23
|
+
* the builtin's default location. An instance may run under its own CLAUDE_CONFIG_DIR, which the hub cannot see:
|
|
24
|
+
* without its reported path the answer is unknown, and unknown counts as persisted (it never waives session identity).
|
|
25
|
+
*/
|
|
26
|
+
export function transcriptPersisted(reported: { sessionId?: string; transcriptPath?: string }, sessionId: string, instance: boolean, fallbackPath: string, exists: (path: string) => boolean | "unknown"): boolean {
|
|
27
|
+
// Only a positive "missing" (ENOENT) is not persisted; an unreadable path is unknown and keeps the identity strict.
|
|
28
|
+
const found = (path: string) => exists(path) !== false;
|
|
29
|
+
if (reported.sessionId === sessionId && reported.transcriptPath) return found(reported.transcriptPath);
|
|
30
|
+
return instance || found(fallbackPath);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** lstat evidence: true when present, false only on ENOENT/ENOTDIR, "unknown" for any other failure (EACCES included). */
|
|
34
|
+
export function pathEvidence(path: string, lstat: (path: string) => unknown): boolean | "unknown" {
|
|
35
|
+
try { lstat(path); return true; }
|
|
36
|
+
catch (error) { const code = (error as { code?: string }).code; return code === "ENOENT" || code === "ENOTDIR" ? false : "unknown"; }
|
|
37
|
+
}
|
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
|
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/** S10 cost evidence is metadata only. Legacy scope and incomplete readback remain unknown. */
|
|
2
|
+
export interface ReviewScope { scope: "none" | "delta" | "full"; reviewers: number }
|
|
3
|
+
export interface CostPlan { review?: ReviewScope; ci?: "focused" | "full" | "reuse" }
|
|
4
|
+
export interface ReviewCost extends CostPlan { head?: string }
|
|
5
|
+
export interface CostTask { repo: string; pr: number; private?: boolean; history: { event: string; at: number; reviewCost?: ReviewCost }[] }
|
|
6
|
+
export interface CostPr { repo: string; pr: number; state: "OPEN" | "CLOSED" | "MERGED"; mergedAt?: number; complete: boolean; ciRuns?: { id: number; attempt: number }[]; reviews?: number }
|
|
7
|
+
export interface CostRow { repo: string; pr: number; reviewRounds: number; ciRuns: number | null; ciReruns: number | null; fullReReviews: number; deltaReviews: number; reviewMs: number | null; unknownReviewScopes: number; githubReviews: number | null; complete: boolean }
|
|
8
|
+
export interface CostReport { rows: CostRow[]; truncated: boolean }
|
|
9
|
+
export function cleanCostPlan(input: unknown): CostPlan {
|
|
10
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error("invalid cost scope");
|
|
11
|
+
const value = input as Record<string, unknown>;
|
|
12
|
+
const result: CostPlan = {};
|
|
13
|
+
if (value.review !== undefined) {
|
|
14
|
+
const review = value.review as Record<string, unknown>;
|
|
15
|
+
if (!review || typeof review !== "object" || Array.isArray(review) || Object.keys(review).some(key => !["scope", "reviewers"].includes(key))
|
|
16
|
+
|| !["none", "delta", "full"].includes(review.scope as string) || !Number.isSafeInteger(review.reviewers)
|
|
17
|
+
|| (review.scope === "none" ? review.reviewers !== 0 : (review.reviewers as number) < 1 || (review.reviewers as number) > 8)) throw new Error("invalid review cost scope");
|
|
18
|
+
result.review = { scope: review.scope as ReviewScope["scope"], reviewers: review.reviewers as number };
|
|
19
|
+
}
|
|
20
|
+
if (value.ci !== undefined) {
|
|
21
|
+
if (!["focused", "full", "reuse"].includes(value.ci as string)) throw new Error("invalid CI cost scope");
|
|
22
|
+
result.ci = value.ci as CostPlan["ci"];
|
|
23
|
+
}
|
|
24
|
+
return result;
|
|
25
|
+
}
|
|
26
|
+
const validRepo = (repo: string) => /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo) && !repo.startsWith("-");
|
|
27
|
+
const key = (repo: string, pr: number) => `${repo.toLowerCase()}#${pr}`;
|
|
28
|
+
/** One round is a board review handoff, not one reviewer in a multi-reviewer round. CI attempts share a run id. */
|
|
29
|
+
export function costMetrics(tasks: CostTask[], prs: CostPr[]): CostReport {
|
|
30
|
+
const withheld = new Set(tasks.filter(task => task.private).map(task => key(task.repo, task.pr)));
|
|
31
|
+
const rows: CostRow[] = [];
|
|
32
|
+
let truncated = tasks.length > 500 || prs.length > 200, historyLeft = 10_000;
|
|
33
|
+
const seen = new Set<string>();
|
|
34
|
+
for (const pr of prs.slice(0, 200)) {
|
|
35
|
+
const identity = key(pr.repo, pr.pr);
|
|
36
|
+
if (!validRepo(pr.repo) || !Number.isSafeInteger(pr.pr) || pr.pr < 1 || pr.state !== "MERGED" || withheld.has(identity) || seen.has(identity)) continue;
|
|
37
|
+
if (!tasks.some(task => !task.private && key(task.repo, task.pr) === identity)) continue;
|
|
38
|
+
seen.add(identity);
|
|
39
|
+
const rounds: { at: number; scope?: ReviewScope["scope"]; elapsed?: number }[] = [];
|
|
40
|
+
let complete = pr.complete && Number.isFinite(pr.mergedAt);
|
|
41
|
+
for (const task of tasks.slice(0, 500).filter(task => key(task.repo, task.pr) === identity)) {
|
|
42
|
+
const history = task.history.slice(0, Math.max(0, historyLeft)); historyLeft -= history.length;
|
|
43
|
+
if (history.length < task.history.length) { complete = false; truncated = true; }
|
|
44
|
+
let pending: typeof rounds[number] | undefined;
|
|
45
|
+
for (const event of [...history].sort((a, b) => a.at - b.at)) {
|
|
46
|
+
if (!Number.isFinite(event.at) || event.at < 0 || !Number.isFinite(pr.mergedAt) || event.at > pr.mergedAt!) continue;
|
|
47
|
+
if (event.event === "done") {
|
|
48
|
+
pending = { at: event.at, scope: event.reviewCost?.review?.scope }; rounds.push(pending);
|
|
49
|
+
} else if (pending && ["approved", "changes_requested"].includes(event.event)) { pending.elapsed = event.at - pending.at; pending = undefined; }
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
rounds.sort((a, b) => a.at - b.at);
|
|
53
|
+
const attempts = new Map<number, number>();
|
|
54
|
+
if (pr.ciRuns) for (const run of pr.ciRuns.slice(0, 100)) {
|
|
55
|
+
if (!Number.isSafeInteger(run.id) || run.id < 1 || !Number.isSafeInteger(run.attempt) || run.attempt < 1 || run.attempt > 10_000) { complete = false; continue; }
|
|
56
|
+
attempts.set(run.id, Math.max(attempts.get(run.id) ?? 0, run.attempt));
|
|
57
|
+
}
|
|
58
|
+
if (pr.ciRuns && pr.ciRuns.length > 100) { complete = false; truncated = true; }
|
|
59
|
+
if (!pr.ciRuns || pr.reviews === undefined || rounds.some(round => round.elapsed === undefined || round.scope === undefined)) complete = false;
|
|
60
|
+
rows.push({ repo: pr.repo.toLowerCase(), pr: pr.pr, reviewRounds: rounds.length,
|
|
61
|
+
ciRuns: pr.ciRuns ? attempts.size : null, ciReruns: pr.ciRuns ? [...attempts.values()].reduce((sum, n) => sum + n - 1, 0) : null,
|
|
62
|
+
fullReReviews: rounds.filter((round, index) => index > 0 && round.scope === "full").length,
|
|
63
|
+
deltaReviews: rounds.filter(round => round.scope === "delta").length,
|
|
64
|
+
reviewMs: rounds.every(round => round.elapsed !== undefined) ? rounds.reduce((sum, round) => sum + round.elapsed!, 0) : null,
|
|
65
|
+
unknownReviewScopes: rounds.filter(round => round.scope === undefined).length, githubReviews: pr.reviews ?? null, complete });
|
|
66
|
+
}
|
|
67
|
+
return { rows: rows.sort((a, b) => a.repo.localeCompare(b.repo) || a.pr - b.pr), truncated };
|
|
68
|
+
}
|
|
69
|
+
export function costBandProposals(report: CostReport, upper: Partial<Record<"reviewRounds" | "ciRuns" | "ciReruns" | "fullReReviews" | "deltaReviews" | "reviewMs", number>>): { repo: string; pr: number; metric: string; observed: number; upper: number }[] {
|
|
70
|
+
if (report.truncated) return [];
|
|
71
|
+
return report.rows.filter(row => row.complete).flatMap(row => Object.entries(upper).flatMap(([metric, limit]) => {
|
|
72
|
+
if (!["reviewRounds", "ciRuns", "ciReruns", "fullReReviews", "deltaReviews", "reviewMs"].includes(metric) || !Number.isFinite(limit) || limit! < 0) return [];
|
|
73
|
+
const observed = row[metric as keyof CostRow];
|
|
74
|
+
return typeof observed === "number" && observed > limit! ? [{ repo: row.repo, pr: row.pr, metric, observed, upper: limit! }] : [];
|
|
75
|
+
}));
|
|
76
|
+
}
|