@staix/agent-hub 0.13.0 → 0.14.0
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 +13 -0
- package/README.md +6 -4
- package/docs/agent-notes/adapters.md +1 -1
- package/docs/agent-notes/budget.md +2 -1
- package/docs/agent-notes/daemon.md +8 -1
- package/docs/agent-notes/tasks.md +7 -4
- package/docs/agent-notes/tests.md +1 -1
- package/docs/dogfood.md +10 -7
- package/docs/operations.md +45 -13
- package/docs/quickstart.md +6 -2
- package/docs/sdlc.md +23 -2
- package/docs/security.md +9 -5
- package/docs/specs/2026-09-19-agent-hub-design.md +38 -0
- package/docs/verification/2026-10-22-codex-reviewer-isolation.md +14 -0
- package/docs/verification/2026-10-23-0.14.0.md +69 -0
- package/docs/verified.json +20 -15
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +91 -20
- package/src/adapters/codex-review-isolation.ts +18 -7
- package/src/adapters/definitions/builtin-metadata.ts +1 -1
- package/src/adapters/definitions/native-cli-entry-codex.ts +13 -0
- package/src/adapters/reviewer-print.ts +34 -7
- package/src/cli/help.ts +2 -1
- package/src/cli/main.ts +5 -0
- package/src/cli/output.ts +47 -2
- package/src/cli/status-lines.ts +1 -1
- package/src/hub/board.ts +22 -1
- package/src/hub/daemon.ts +84 -14
- package/src/hub/hub-tools.ts +2 -1
- package/src/hub/instances.ts +14 -4
- package/src/hub/manager.ts +2 -2
- package/src/hub/sdlc-metrics.ts +260 -0
- package/src/hub/sdlc.ts +5 -3
- package/src/hub/spec-read.ts +31 -0
- package/src/hub/task-code-group.ts +52 -0
- package/src/hub/tasks.ts +172 -22
- package/src/hub/ui.ts +3 -0
- package/src/hub/usage-rollup.ts +58 -0
- package/src/ui/index.html +273 -2
|
@@ -1224,13 +1224,13 @@ var DEFAULT_WATCHDOG_MS = 300000;
|
|
|
1224
1224
|
// src/adapters/reviewer-print.ts
|
|
1225
1225
|
import { spawn as spawn2 } from "child_process";
|
|
1226
1226
|
import { randomUUID as randomUUID2 } from "crypto";
|
|
1227
|
-
var MAX_REVIEW_FRAME_BYTES, MAX_REVIEW_OUTPUT_BYTES, GroupCleanupError, reviewTarget = (env) => {
|
|
1227
|
+
var ReviewerUnavailableError, MAX_REVIEW_FRAME_BYTES, MAX_REVIEW_OUTPUT_BYTES, GroupCleanupError, reviewTarget = (env) => {
|
|
1228
1228
|
const task = reviewTask(env);
|
|
1229
1229
|
if (task === undefined || env.private)
|
|
1230
1230
|
return;
|
|
1231
1231
|
const revision = Number(env.refs?.reviewRevision);
|
|
1232
1232
|
return { task, ...typeof env.refs?.commit === "string" ? { head: env.refs.commit } : {}, ...Number.isSafeInteger(revision) && revision > 0 ? { flowRevision: revision } : {} };
|
|
1233
|
-
}, remainingFailures = (batch, start, cause) => batch.targets.slice(start).flatMap((target) => target ? [{ ...target, cause }] : []), reviewTask = (env) => {
|
|
1233
|
+
}, remainingFailures = (batch, start, cause, why) => batch.targets.slice(start).flatMap((target) => target ? [{ ...target, cause, ...why ? { causeClass: why.causeClass, ...why.causeDetail ? { causeDetail: why.causeDetail } : {} } : {} }] : []), reviewTask = (env) => {
|
|
1234
1234
|
if (env.from !== HUB || env.kind !== "review")
|
|
1235
1235
|
return;
|
|
1236
1236
|
const id = Number(env.refs?.task);
|
|
@@ -1239,6 +1239,15 @@ var MAX_REVIEW_FRAME_BYTES, MAX_REVIEW_OUTPUT_BYTES, GroupCleanupError, reviewTa
|
|
|
1239
1239
|
var init_reviewer_print = __esm(() => {
|
|
1240
1240
|
init_child_process();
|
|
1241
1241
|
init_envelope();
|
|
1242
|
+
ReviewerUnavailableError = class ReviewerUnavailableError extends Error {
|
|
1243
|
+
causeClass;
|
|
1244
|
+
detail;
|
|
1245
|
+
constructor(message, causeClass, detail) {
|
|
1246
|
+
super(message);
|
|
1247
|
+
this.causeClass = causeClass;
|
|
1248
|
+
this.detail = detail;
|
|
1249
|
+
}
|
|
1250
|
+
};
|
|
1242
1251
|
MAX_REVIEW_FRAME_BYTES = 256 * 1024;
|
|
1243
1252
|
MAX_REVIEW_OUTPUT_BYTES = 4 * 1024 * 1024;
|
|
1244
1253
|
GroupCleanupError = class GroupCleanupError extends Error {
|
|
@@ -1404,7 +1413,9 @@ var init_reviewer_print = __esm(() => {
|
|
|
1404
1413
|
return;
|
|
1405
1414
|
}
|
|
1406
1415
|
failure = { state: started ? "needs_review" : "failed_safe", reason: `reviewer could not start: ${error.message}` };
|
|
1407
|
-
|
|
1416
|
+
const why = error instanceof ReviewerUnavailableError ? { causeClass: error.causeClass, causeDetail: error.detail } : { causeClass: "unknown" };
|
|
1417
|
+
this.opts.log?.(`[${this.id}] review of task #${task} could not start [cause: ${why.causeClass}]`);
|
|
1418
|
+
reroute.push(...remainingFailures(batch, index, "start_failed", why));
|
|
1408
1419
|
batch.next = envs.length;
|
|
1409
1420
|
break;
|
|
1410
1421
|
}
|
|
@@ -1476,12 +1487,12 @@ var init_reviewer_print = __esm(() => {
|
|
|
1476
1487
|
...task === undefined ? {} : { task }
|
|
1477
1488
|
});
|
|
1478
1489
|
if ("unavailable" in launch)
|
|
1479
|
-
throw new
|
|
1490
|
+
throw new ReviewerUnavailableError(launch.unavailable, launch.causeClass ?? "unknown", launch.detail);
|
|
1480
1491
|
const proof = launch.isolation;
|
|
1481
1492
|
const policy = this.opts.backend.nativePolicy ? proof?.enforcement === "native-policy" && proof.freshSession === true && proof.hubToolsOnly === true : proof?.strictMcp === true && proof.ephemeral === true;
|
|
1482
1493
|
if (abort.signal.aborted || !proof || proof.backend !== this.opts.backend.id || proof.readOnly !== true || !policy || !proof.binary || !proof.version || !proof.profile || !proof.evidence.length || !launch.command.length) {
|
|
1483
1494
|
await launch.cleanup?.();
|
|
1484
|
-
throw new
|
|
1495
|
+
throw new ReviewerUnavailableError("reviewer isolation is unavailable or preparation was cancelled", "unknown");
|
|
1485
1496
|
}
|
|
1486
1497
|
return launch;
|
|
1487
1498
|
})();
|
|
@@ -1525,7 +1536,7 @@ ${facts}` : ""}`;
|
|
|
1525
1536
|
try {
|
|
1526
1537
|
launch = await this.prepare(randomUUID2(), input, task, mcp?.config);
|
|
1527
1538
|
if (generation !== this.generation || this.opts.admit?.(env) === false || launch.current?.() === false)
|
|
1528
|
-
throw new
|
|
1539
|
+
throw new ReviewerUnavailableError("reviewer isolation or admission changed before child spawn", launch.current?.() === false ? "binary" : "unknown");
|
|
1529
1540
|
this.receipt = launch.isolation;
|
|
1530
1541
|
const [cmd, ...prefix] = launch.command;
|
|
1531
1542
|
let quota = false;
|
|
@@ -1916,6 +1927,7 @@ function ownServer(text) {
|
|
|
1916
1927
|
}
|
|
1917
1928
|
async function prepare(ctx) {
|
|
1918
1929
|
let root;
|
|
1930
|
+
let leg = "binary", timedOut = false;
|
|
1919
1931
|
try {
|
|
1920
1932
|
const cwd = realPath(ctx.cwd), env = peerChildEnv(ctx.peer), bound = resolveCommand(ctx, cwd);
|
|
1921
1933
|
const initialBinaryMetadata = bound.files.map(metadata).join(";");
|
|
@@ -1931,6 +1943,8 @@ async function prepare(ctx) {
|
|
|
1931
1943
|
};
|
|
1932
1944
|
const run = (args, capture = true, maxBytes = CAP) => new Promise((resolveOutput, reject) => {
|
|
1933
1945
|
if (ctx.signal.aborted || performance.now() >= deadline) {
|
|
1946
|
+
if (!ctx.signal.aborted)
|
|
1947
|
+
timedOut = true;
|
|
1934
1948
|
reject(new Error("metadata deadline"));
|
|
1935
1949
|
return;
|
|
1936
1950
|
}
|
|
@@ -1941,7 +1955,11 @@ async function prepare(ctx) {
|
|
|
1941
1955
|
failed = true;
|
|
1942
1956
|
finish(null);
|
|
1943
1957
|
};
|
|
1944
|
-
const timer = setTimeout(
|
|
1958
|
+
const timer = setTimeout(() => {
|
|
1959
|
+
if (!ctx.signal.aborted)
|
|
1960
|
+
timedOut = true;
|
|
1961
|
+
stop();
|
|
1962
|
+
}, Math.max(1, Math.min(2000, deadline - performance.now())));
|
|
1945
1963
|
ctx.signal.addEventListener("abort", stop, { once: true });
|
|
1946
1964
|
const finish = async (code) => {
|
|
1947
1965
|
if (settled)
|
|
@@ -1985,15 +2003,19 @@ async function prepare(ctx) {
|
|
|
1985
2003
|
finish(code);
|
|
1986
2004
|
});
|
|
1987
2005
|
});
|
|
2006
|
+
leg = "version";
|
|
1988
2007
|
const verdict = assessVersion("codex-review", COMPAT["codex-review"].parse(await run(["--version"])));
|
|
1989
2008
|
if (!verdict.ok)
|
|
1990
2009
|
throw new VersionRefusal(verdict.reason);
|
|
1991
2010
|
const version = verdict.version;
|
|
2011
|
+
leg = "mcp_inventory";
|
|
1992
2012
|
if (Buffer.byteLength(ctx.mcpConfig) > CAP || ctx.privatePaths.length > 128)
|
|
1993
2013
|
throw new Error("policy bounds exceeded");
|
|
1994
2014
|
const hub = ownServer(ctx.mcpConfig);
|
|
1995
2015
|
const policy = ["-c", "notify=[]", "--disable", "plugins", "--disable", "hooks", "--disable", "apps", "-c", 'web_search="disabled"', "-c", `projects=${toml({ [cwd]: { trust_level: "untrusted" } })}`];
|
|
2016
|
+
leg = "plugins_hooks_apps";
|
|
1996
2017
|
proveDisabledFeatures(await run(["features", "list", ...policy]));
|
|
2018
|
+
leg = "mcp_inventory";
|
|
1997
2019
|
const before = inventory(await run(["mcp", "list", "--json", ...policy]));
|
|
1998
2020
|
if (before.some((row) => row.name === "agent-hub"))
|
|
1999
2021
|
throw new Error("ambient hub MCP collision");
|
|
@@ -2011,6 +2033,7 @@ async function prepare(ctx) {
|
|
|
2011
2033
|
const observedEnv = object2(observedTransport.env ?? {}), intendedEnv = object2(hub.env);
|
|
2012
2034
|
if (observedTransport.type !== "stdio" || observedTransport.command !== hub.command || JSON.stringify(observedTransport.args ?? []) !== JSON.stringify(hub.args) || Object.keys(observedEnv).length !== Object.keys(intendedEnv).length || Object.entries(intendedEnv).some(([key, value]) => observedEnv[key] !== value))
|
|
2013
2035
|
throw new Error("hub transport changed");
|
|
2036
|
+
leg = "sandbox_proof";
|
|
2014
2037
|
root = realPath(mkdtempSync(join5(tmpdir(), "ahub-codex-review-isolation-")));
|
|
2015
2038
|
const privateFile = join5(root, "private.env"), publicFile = join5(root, "public.txt"), writeFile = join5(root, "write-proof");
|
|
2016
2039
|
const coreName = join5(root, ".env"), coreStateDir = join5(root, ".agenthub", "state"), coreState = join5(coreStateDir, "private.txt");
|
|
@@ -2031,9 +2054,11 @@ async function prepare(ctx) {
|
|
|
2031
2054
|
writeFileSync3(globFile, "glob private fixture");
|
|
2032
2055
|
symlinkSync(globFile, alias);
|
|
2033
2056
|
const filesystem = { "/": "read", [privateFile]: "deny", [join5(globRoot, "**", "*.env")]: "deny" };
|
|
2057
|
+
leg = "credential_root";
|
|
2034
2058
|
for (const glob of codexCoreDenyGlobs())
|
|
2035
2059
|
filesystem[glob] = "deny";
|
|
2036
2060
|
denyCredentialRoots(filesystem, [...hostCredentialRoots(env, cwd), ...hostCredentialRoots({ HOME: fixtureHome }, cwd)], cwd);
|
|
2061
|
+
leg = "sandbox_proof";
|
|
2037
2062
|
for (const entry of ctx.privatePaths) {
|
|
2038
2063
|
if (typeof entry !== "string" || !entry || Buffer.byteLength(entry) > 4096 || /\p{Cc}/u.test(entry))
|
|
2039
2064
|
throw new Error("invalid private path policy");
|
|
@@ -2070,6 +2095,7 @@ AHUB_CODEX_HOST_KUBE_DENIED
|
|
|
2070
2095
|
AHUB_CODEX_HOST_DOCKER_DENIED
|
|
2071
2096
|
AHUB_CODEX_HOST_ALIAS_DENIED` || existsSync2(writeFile))
|
|
2072
2097
|
throw new Error("native sandbox isolation unverified");
|
|
2098
|
+
leg = "binary";
|
|
2073
2099
|
if (!current())
|
|
2074
2100
|
throw new Error("binary or config changed during proof");
|
|
2075
2101
|
const cleanupRoot = root;
|
|
@@ -2104,12 +2130,12 @@ AHUB_CODEX_HOST_ALIAS_DENIED` || existsSync2(writeFile))
|
|
|
2104
2130
|
} catch {}
|
|
2105
2131
|
}
|
|
2106
2132
|
if (error instanceof VersionRefusal)
|
|
2107
|
-
return { unavailable: error.message };
|
|
2133
|
+
return { unavailable: error.message, causeClass: "version" };
|
|
2108
2134
|
if (error instanceof CredentialRootError) {
|
|
2109
2135
|
const why = { dangling: "a dangling link", unreadable: "an unreadable path", unsupported: "unsupported syntax or overlapping the checkout" }[error.kind];
|
|
2110
|
-
return { unavailable: `Codex reviewer isolation receipt unavailable (credential root ${error.label} is ${why}; remove or repair it)` };
|
|
2136
|
+
return { unavailable: `Codex reviewer isolation receipt unavailable (credential root ${error.label} is ${why}; remove or repair it)`, causeClass: "credential_root", detail: `${error.label}: ${why}` };
|
|
2111
2137
|
}
|
|
2112
|
-
return { unavailable: "Codex reviewer isolation receipt unavailable (version, MCP inventory, sandbox, configuration or process proof)" };
|
|
2138
|
+
return { unavailable: "Codex reviewer isolation receipt unavailable (version, MCP inventory, sandbox, configuration or process proof)", causeClass: timedOut ? "timeout" : leg };
|
|
2113
2139
|
}
|
|
2114
2140
|
}
|
|
2115
2141
|
var TOOLS, VersionRefusal, CAP, MAX_SERVERS = 64, digest = (value) => createHash2("sha256").update(value).digest("hex"), inside = (file, root) => {
|
|
@@ -7968,6 +7994,19 @@ var init_review_findings = __esm(() => {
|
|
|
7968
7994
|
MAX_BYTES2 = 64 * 1024;
|
|
7969
7995
|
});
|
|
7970
7996
|
|
|
7997
|
+
// src/hub/sdlc-metrics.ts
|
|
7998
|
+
var HOUR = 3600000, SD_FLOOR;
|
|
7999
|
+
var init_sdlc_metrics = __esm(() => {
|
|
8000
|
+
SD_FLOOR = {
|
|
8001
|
+
acceptance_to_plan_ms: HOUR / 2,
|
|
8002
|
+
acceptance_to_first_review_ms: HOUR / 2,
|
|
8003
|
+
first_pass_approval: 0.5,
|
|
8004
|
+
rework_rounds: 0.5,
|
|
8005
|
+
first_pass_check: 0.5,
|
|
8006
|
+
stale_head_re_reviews: 0.5
|
|
8007
|
+
};
|
|
8008
|
+
});
|
|
8009
|
+
|
|
7971
8010
|
// src/hub/workflow.ts
|
|
7972
8011
|
var init_workflow = __esm(() => {
|
|
7973
8012
|
init_sdlc();
|
|
@@ -7984,6 +8023,7 @@ var init_sdlc = __esm(() => {
|
|
|
7984
8023
|
init_test_integrity();
|
|
7985
8024
|
init_plan_conformance();
|
|
7986
8025
|
init_review_findings();
|
|
8026
|
+
init_sdlc_metrics();
|
|
7987
8027
|
init_workflow();
|
|
7988
8028
|
init_child_process();
|
|
7989
8029
|
init_host_executable();
|
|
@@ -8966,7 +9006,8 @@ var init_hub_tools = __esm(() => {
|
|
|
8966
9006
|
PLANNER_REVIEW_TOOLS = [
|
|
8967
9007
|
tool("hub_planner_groom", "Read a bounded planner grooming digest of dependencies, repository-scoped overlaps, WIP and blockers. No board change; unavailable evidence stays unknown.", {}),
|
|
8968
9008
|
tool("hub_plan_review", "Record a planner review against the task planning revision and host-read issue edit time. Configured planner or person only; never accepts caller issue timestamps.", { id, revision: { type: "integer", minimum: 0 }, reason: { ...str2, maxLength: 1000 }, ...costPlanProperties }, ["id", "revision", "reason"]),
|
|
8969
|
-
tool("hub_sdlc_costs", "Read bounded review and CI cost metrics for linked merged PRs. Planner/person only; metadata only, no board writes.
|
|
9009
|
+
tool("hub_sdlc_costs", "Read bounded review and CI cost metrics for linked merged PRs. Planner/person only; metadata only, no board writes.", { repo: str2 }),
|
|
9010
|
+
tool("hub_sdlc_metrics", "Read SDLC metrics from board history: time from acceptance to plan, first review and merge, first-pass approval and rework rounds, first-pass CI and same-head reruns, stale-head re-reviews, stalls, and the control band tiers when sdlc.toml sets [bands]. Planner/person only; read-only, no model and no GitHub issue.", { since: str2 }),
|
|
8970
9011
|
tool("hub_backlog_return_untouched", "Return accepted but untouched work to the backlog with its history and planning retained. Refuses execution/branch/commit evidence; planner or person only.", { id, revision: { type: "integer", minimum: 0 }, reason: { ...str2, maxLength: 1000 } }, ["id", "revision", "reason"])
|
|
8971
9012
|
];
|
|
8972
9013
|
TASK_TOOLS.push(...PLANNER_REVIEW_TOOLS);
|
|
@@ -9003,7 +9044,7 @@ function safeModelLabel(value) {
|
|
|
9003
9044
|
var init_tool_refusal = () => {};
|
|
9004
9045
|
|
|
9005
9046
|
// src/hub/spec-read.ts
|
|
9006
|
-
var BODY_LIMIT, RESULT_LIMIT, STDOUT_LIMIT;
|
|
9047
|
+
var BODY_LIMIT, RESULT_LIMIT, STDOUT_LIMIT, NOT_A_REPOSITORY;
|
|
9007
9048
|
var init_spec_read = __esm(() => {
|
|
9008
9049
|
init_tool_refusal();
|
|
9009
9050
|
init_host_executable();
|
|
@@ -9011,6 +9052,7 @@ var init_spec_read = __esm(() => {
|
|
|
9011
9052
|
BODY_LIMIT = 64 * 1024;
|
|
9012
9053
|
RESULT_LIMIT = 80 * 1024;
|
|
9013
9054
|
STDOUT_LIMIT = 512 * 1024;
|
|
9055
|
+
NOT_A_REPOSITORY = new Set(["pr", "pull", "issue", "issues", "task", "tasks", "bug", "fix", "phase", "step", "item", "ticket", "gh", "id", "no", "nr", "p", "s", "t", "u"]);
|
|
9014
9056
|
});
|
|
9015
9057
|
|
|
9016
9058
|
// src/models/route/config.ts
|
|
@@ -9945,6 +9987,23 @@ async function runCodexEntry(ctx) {
|
|
|
9945
9987
|
const peer = ctx.instance ?? "codex";
|
|
9946
9988
|
const { args, cwd, stateDir, unattendedEnv, projectConfig, connect, fail, exec } = ctx;
|
|
9947
9989
|
const { launcherPreview, buildLaunch, readControl, prepareTerminalLaunch, registerTerminalLaunch } = ctx.helpers;
|
|
9990
|
+
if (ctx.hubStarted) {
|
|
9991
|
+
if (args.includes("--print-command") || args.includes("--dry-run"))
|
|
9992
|
+
return console.log(JSON.stringify({ peer, start: "hub", note: "a headless reviewer the hub runs per review" }, null, 2));
|
|
9993
|
+
const hub = await connect();
|
|
9994
|
+
try {
|
|
9995
|
+
const snapshot = (await hub.request({ t: "status" })).status;
|
|
9996
|
+
const warning = retainedLaunchWarning(snapshot, peer, ctx.definition);
|
|
9997
|
+
if (warning)
|
|
9998
|
+
console.error(warning);
|
|
9999
|
+
const res = await hub.request({ t: "start", peer, operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
|
|
10000
|
+
if (res.ok !== true)
|
|
10001
|
+
fail(String(res.error ?? `${peer} was not started`));
|
|
10002
|
+
} finally {
|
|
10003
|
+
hub.close();
|
|
10004
|
+
}
|
|
10005
|
+
return console.log(`${peer} started: a headless reviewer the hub runs per review (stop it with ahub stop ${peer})`);
|
|
10006
|
+
}
|
|
9948
10007
|
if (args.includes("--print-command") || args.includes("--dry-run")) {
|
|
9949
10008
|
try {
|
|
9950
10009
|
return console.log(JSON.stringify(launcherPreview("codex", args, cwd, stateDir, unattendedEnv), null, 2));
|
|
@@ -11179,7 +11238,7 @@ var init_builtin_metadata = __esm(() => {
|
|
|
11179
11238
|
ownedFlags: Object.freeze(["--dangerously-load-development-channels", "--dangerously-skip-permissions"])
|
|
11180
11239
|
},
|
|
11181
11240
|
identity: { mcp: "channel-plugin" },
|
|
11182
|
-
instances: { mode: "external", roles: ["
|
|
11241
|
+
instances: { mode: "external", roles: ["planner", "implementer", "verifier", "reviewer"] },
|
|
11183
11242
|
attach: { externalHello: true, recoveryLaunch: claudeRecoveryLaunch },
|
|
11184
11243
|
permission: { grants: {
|
|
11185
11244
|
ask: "Claude's own permission rules decide",
|
|
@@ -19574,10 +19633,11 @@ init_git();
|
|
|
19574
19633
|
|
|
19575
19634
|
// src/hub/tasks.ts
|
|
19576
19635
|
init_spec_read();
|
|
19636
|
+
init_sdlc_metrics();
|
|
19577
19637
|
// package.json
|
|
19578
19638
|
var package_default = {
|
|
19579
19639
|
name: "@staix/agent-hub",
|
|
19580
|
-
version: "0.
|
|
19640
|
+
version: "0.14.0",
|
|
19581
19641
|
description: "Native multi-agent hub: Claude Code, Codex, Kimi Code, Pi and local inference as peers in one project",
|
|
19582
19642
|
license: "MIT",
|
|
19583
19643
|
type: "module",
|
|
@@ -19925,19 +19985,29 @@ function validateInstances(raw, catalog) {
|
|
|
19925
19985
|
const unavailableReviewer = freshReviewer && !!(definition.reviewerVariant ?? definition).reviewer?.unavailable;
|
|
19926
19986
|
if (!unavailableReviewer && runtime.instances?.mode !== "external" && !supportsHeadlessInstances(runtime))
|
|
19927
19987
|
return fail4(`${path}.kind`, definition.reviewerVariant ? 'unsupported; this kind only declares headless reviewer instances with roles exactly ["reviewer"]' : "unsupported; explicit external instance policy or a headless instance policy with a headless factory is required");
|
|
19988
|
+
if (declaredRoles.includes("planner") && definition.projection?.plannerEligible !== true)
|
|
19989
|
+
return fail4(`${path}.roles`, "this kind cannot plan");
|
|
19928
19990
|
if (external) {
|
|
19929
19991
|
const declared = entry.roles ? roles(entry.roles.value, `${path}.roles`) : [];
|
|
19930
19992
|
if (entry.start_mode && definition.reviewerVariant) {
|
|
19993
|
+
const reviewerRoles = declared.length === 1 && declared[0] === "reviewer";
|
|
19994
|
+
if (!reviewerRoles && declared.includes("implementer") && declared.every((role) => role === "implementer" || role === "verifier"))
|
|
19995
|
+
return fail4(`${path}.start_mode`, "headless Claude implementer is not available in this release");
|
|
19996
|
+
if (!reviewerRoles)
|
|
19997
|
+
return fail4(`${path}.roles`, 'a headless channel-kind instance is a reviewer: roles must be exactly ["reviewer"]');
|
|
19931
19998
|
if (entry.worktree)
|
|
19932
19999
|
return fail4(path, "a headless reviewer reads the project checkout: worktree is not supported");
|
|
19933
|
-
if (declared.length !== 1 || declared[0] !== "reviewer")
|
|
19934
|
-
return fail4(`${path}.roles`, 'a headless channel-kind instance is a reviewer: roles must be exactly ["reviewer"]');
|
|
19935
20000
|
} else {
|
|
19936
|
-
if (entry.start_mode
|
|
19937
|
-
return fail4(path
|
|
20001
|
+
if (entry.start_mode)
|
|
20002
|
+
return fail4(`${path}.start_mode`, `a channel instance starts only from the person's terminal (ahub <kind> --as <id>); a headless reviewer declares roles ["reviewer"] and start_mode "headless"`);
|
|
19938
20003
|
const allowed = definition.instances?.roles ?? definition.roles;
|
|
19939
20004
|
if (!declared.length || declared.some((role) => !allowed.includes(role)))
|
|
19940
|
-
return fail4(`${path}.roles`,
|
|
20005
|
+
return fail4(`${path}.roles`, `a channel instance must declare its roles within: ${allowed.join(", ")}`);
|
|
20006
|
+
const writes = declared.includes("implementer") || declared.includes("verifier");
|
|
20007
|
+
if (writes && !entry.worktree)
|
|
20008
|
+
return fail4(`${path}.worktree`, "an implementer or verifier channel instance needs its own worktree");
|
|
20009
|
+
if (!writes && entry.worktree)
|
|
20010
|
+
return fail4(`${path}.worktree`, "a planner or reviewer channel instance reads the project checkout: worktree is not supported");
|
|
19941
20011
|
}
|
|
19942
20012
|
} else {
|
|
19943
20013
|
const allowed = runtime.instances?.roles;
|
|
@@ -19990,7 +20060,8 @@ function effectiveInstanceRoles(id, declarations, policyRoles, catalog) {
|
|
|
19990
20060
|
throw new Error(`${id}: no declared native instance`);
|
|
19991
20061
|
const declaration = declarations && Object.hasOwn(declarations, id) ? declarations[id] : undefined;
|
|
19992
20062
|
const ownRoles = (key) => Object.hasOwn(policyRoles, key) ? policyRoles[key] : undefined;
|
|
19993
|
-
|
|
20063
|
+
const roles = declaration?.roles ?? ownRoles(id) ?? ownRoles(definition.kind) ?? definition.roles;
|
|
20064
|
+
return roles.filter((role) => role !== "planner" || definition.projection?.plannerEligible === true);
|
|
19994
20065
|
}
|
|
19995
20066
|
|
|
19996
20067
|
// src/hub/instance-config.ts
|
|
@@ -8,7 +8,7 @@ import { homedir, tmpdir } from "node:os";
|
|
|
8
8
|
import { basename, delimiter, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
9
9
|
import { peerChildEnv, stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
|
|
10
10
|
import { realPath } from "../hub/project.ts";
|
|
11
|
-
import type { ReviewerBackend, ReviewerLaunch, ReviewerPrepareContext } from "./reviewer-print.ts";
|
|
11
|
+
import type { ReviewerBackend, ReviewerCauseClass, ReviewerLaunch, ReviewerPrepareContext } from "./reviewer-print.ts";
|
|
12
12
|
|
|
13
13
|
const TOOLS = ["hub_review", "hub_task_show"];
|
|
14
14
|
/** A version refusal names only versions and a command, so the daemon may show it (#691). */
|
|
@@ -176,8 +176,10 @@ function ownServer(text: string): Record<string, unknown> {
|
|
|
176
176
|
}
|
|
177
177
|
|
|
178
178
|
/** All reads are model-free and bounded; stdout/config/env values never become diagnostic text. */
|
|
179
|
-
async function prepare(ctx: ReviewerPrepareContext): Promise<ReviewerLaunch | { unavailable: string }> {
|
|
179
|
+
async function prepare(ctx: ReviewerPrepareContext): Promise<ReviewerLaunch | { unavailable: string; causeClass?: ReviewerCauseClass; detail?: string }> {
|
|
180
180
|
let root: string | undefined;
|
|
181
|
+
// #722: the proof leg being run, so a refusal names a class and never text from the native CLI. `timedOut` is set by `run` only.
|
|
182
|
+
let leg: ReviewerCauseClass = "binary", timedOut = false;
|
|
181
183
|
try {
|
|
182
184
|
const cwd = realPath(ctx.cwd), env = peerChildEnv(ctx.peer), bound = resolveCommand(ctx, cwd);
|
|
183
185
|
const initialBinaryMetadata = bound.files.map(metadata).join(";");
|
|
@@ -191,12 +193,12 @@ async function prepare(ctx: ReviewerPrepareContext): Promise<ReviewerLaunch | {
|
|
|
191
193
|
return bound.files.map(metadata).join(";") === initialBinaryMetadata;
|
|
192
194
|
};
|
|
193
195
|
const run = (args: readonly string[], capture = true, maxBytes = CAP) => new Promise<string>((resolveOutput, reject) => {
|
|
194
|
-
if (ctx.signal.aborted || performance.now() >= deadline) { reject(new Error("metadata deadline")); return; }
|
|
196
|
+
if (ctx.signal.aborted || performance.now() >= deadline) { if (!ctx.signal.aborted) timedOut = true; reject(new Error("metadata deadline")); return; }
|
|
195
197
|
const [cmd, ...prefix] = bound.command;
|
|
196
198
|
const child = spawn(cmd!, [...prefix, ...args], { cwd, env, detached: true, stdio: ["ignore", "pipe", "pipe"] });
|
|
197
199
|
let output = "", bytes = 0, failed = false, settled = false;
|
|
198
200
|
const stop = () => { failed = true; void finish(null); };
|
|
199
|
-
const timer = setTimeout(stop, Math.max(1, Math.min(2000, deadline - performance.now())));
|
|
201
|
+
const timer = setTimeout(() => { if (!ctx.signal.aborted) timedOut = true; stop(); }, Math.max(1, Math.min(2000, deadline - performance.now())));
|
|
200
202
|
ctx.signal.addEventListener("abort", stop, { once: true });
|
|
201
203
|
const finish = async (code: number | null) => {
|
|
202
204
|
if (settled) return; settled = true; clearTimeout(timer); ctx.signal.removeEventListener("abort", stop);
|
|
@@ -211,14 +213,18 @@ async function prepare(ctx: ReviewerPrepareContext): Promise<ReviewerLaunch | {
|
|
|
211
213
|
child.once("close", code => { void finish(code); });
|
|
212
214
|
});
|
|
213
215
|
// #691: the verified versions and the person's trust live in compat.ts; every proof below still runs after trust.
|
|
216
|
+
leg = "version";
|
|
214
217
|
const verdict = assessVersion("codex-review", COMPAT["codex-review"].parse(await run(["--version"])));
|
|
215
218
|
if (!verdict.ok) throw new VersionRefusal(verdict.reason);
|
|
216
219
|
const version = verdict.version;
|
|
220
|
+
leg = "mcp_inventory";
|
|
217
221
|
if (Buffer.byteLength(ctx.mcpConfig) > CAP || ctx.privatePaths.length > 128) throw new Error("policy bounds exceeded");
|
|
218
222
|
const hub = ownServer(ctx.mcpConfig);
|
|
219
223
|
// Apps creates a runtime MCP server outside the configured inventory; independently pin and read back its gate.
|
|
220
224
|
const policy = ["-c", "notify=[]", "--disable", "plugins", "--disable", "hooks", "--disable", "apps", "-c", 'web_search="disabled"', "-c", `projects=${toml({ [cwd]: { trust_level: "untrusted" } })}`];
|
|
225
|
+
leg = "plugins_hooks_apps";
|
|
221
226
|
proveDisabledFeatures(await run(["features", "list", ...policy]));
|
|
227
|
+
leg = "mcp_inventory";
|
|
222
228
|
const before = inventory(await run(["mcp", "list", "--json", ...policy]));
|
|
223
229
|
if (before.some(row => row.name === "agent-hub")) throw new Error("ambient hub MCP collision");
|
|
224
230
|
const servers: Record<string, unknown> = Object.assign(Object.create(null), { "agent-hub": hub });
|
|
@@ -232,6 +238,7 @@ async function prepare(ctx: ReviewerPrepareContext): Promise<ReviewerLaunch | {
|
|
|
232
238
|
const observedEnv = object(observedTransport.env ?? {}), intendedEnv = object(hub.env);
|
|
233
239
|
if (observedTransport.type !== "stdio" || observedTransport.command !== hub.command || JSON.stringify(observedTransport.args ?? []) !== JSON.stringify(hub.args) || Object.keys(observedEnv).length !== Object.keys(intendedEnv).length || Object.entries(intendedEnv).some(([key, value]) => observedEnv[key] !== value)) throw new Error("hub transport changed");
|
|
234
240
|
// A real native sandbox probes a fresh private fixture: the model never sees these files.
|
|
241
|
+
leg = "sandbox_proof";
|
|
235
242
|
root = realPath(mkdtempSync(join(tmpdir(), "ahub-codex-review-isolation-")));
|
|
236
243
|
const privateFile = join(root, "private.env"), publicFile = join(root, "public.txt"), writeFile = join(root, "write-proof");
|
|
237
244
|
const coreName = join(root, ".env"), coreStateDir = join(root, ".agenthub", "state"), coreState = join(coreStateDir, "private.txt");
|
|
@@ -245,8 +252,10 @@ async function prepare(ctx: ReviewerPrepareContext): Promise<ReviewerLaunch | {
|
|
|
245
252
|
writeFileSync(privateFile, "private fixture"); writeFileSync(publicFile, "public fixture"); writeFileSync(globFile, "glob private fixture");
|
|
246
253
|
symlinkSync(globFile, alias);
|
|
247
254
|
const filesystem: Record<string, string> = { "/": "read", [privateFile]: "deny", [join(globRoot, "**", "*.env")]: "deny" };
|
|
255
|
+
leg = "credential_root";
|
|
248
256
|
for (const glob of codexCoreDenyGlobs()) filesystem[glob] = "deny";
|
|
249
257
|
denyCredentialRoots(filesystem, [...hostCredentialRoots(env, cwd), ...hostCredentialRoots({ HOME: fixtureHome }, cwd)], cwd);
|
|
258
|
+
leg = "sandbox_proof";
|
|
250
259
|
for (const entry of ctx.privatePaths) {
|
|
251
260
|
if (typeof entry !== "string" || !entry || Buffer.byteLength(entry) > 4096 || /\p{Cc}/u.test(entry)) throw new Error("invalid private path policy");
|
|
252
261
|
// Native Seatbelt supports *, ?, and closed character classes, not brace alternatives.
|
|
@@ -268,6 +277,7 @@ async function prepare(ctx: ReviewerPrepareContext): Promise<ReviewerLaunch | {
|
|
|
268
277
|
const script = 'if /bin/cat "$1" >/dev/null 2>&1; then echo AHUB_CODEX_PUBLIC_READ; fi; if /bin/cat "$2" >/dev/null 2>&1; then exit 12; else echo AHUB_CODEX_PRIVATE_DENIED; fi; if (echo no > "$3") 2>/dev/null; then exit 13; else echo AHUB_CODEX_WRITE_DENIED; fi; if /bin/cat "$4" >/dev/null 2>&1; then exit 14; else echo AHUB_CODEX_GLOB_PRIVATE_DENIED; fi; if /bin/cat "$5" >/dev/null 2>&1; then exit 15; else echo AHUB_CODEX_ALIAS_PRIVATE_DENIED; fi; if /bin/cat "$6" >/dev/null 2>&1; then exit 16; else echo AHUB_CODEX_CORE_NAME_DENIED; fi; if /bin/cat "$7" >/dev/null 2>&1; then exit 17; else echo AHUB_CODEX_CORE_STATE_DENIED; fi; if /bin/cat "$8" >/dev/null 2>&1; then exit 18; else echo AHUB_CODEX_HOST_CONFIG_DENIED; fi; if /bin/cat "$9" >/dev/null 2>&1; then exit 19; else echo AHUB_CODEX_HOST_KUBE_DENIED; fi; if /bin/cat "${10}" >/dev/null 2>&1; then exit 20; else echo AHUB_CODEX_HOST_DOCKER_DENIED; fi; if /bin/cat "${11}" >/dev/null 2>&1; then exit 21; else echo AHUB_CODEX_HOST_ALIAS_DENIED; fi';
|
|
269
278
|
const proof = await run(["sandbox", "-P", "ahub-review", "-C", cwd, ...policy, ...permissions, "/bin/sh", "-c", script, "proof", publicFile, privateFile, writeFile, globFile, alias, coreName, coreState, hostConfig, hostKube, hostDocker, hostAlias]);
|
|
270
279
|
if (proof.trim() !== "AHUB_CODEX_PUBLIC_READ\nAHUB_CODEX_PRIVATE_DENIED\nAHUB_CODEX_WRITE_DENIED\nAHUB_CODEX_GLOB_PRIVATE_DENIED\nAHUB_CODEX_ALIAS_PRIVATE_DENIED\nAHUB_CODEX_CORE_NAME_DENIED\nAHUB_CODEX_CORE_STATE_DENIED\nAHUB_CODEX_HOST_CONFIG_DENIED\nAHUB_CODEX_HOST_KUBE_DENIED\nAHUB_CODEX_HOST_DOCKER_DENIED\nAHUB_CODEX_HOST_ALIAS_DENIED" || existsSync(writeFile)) throw new Error("native sandbox isolation unverified");
|
|
280
|
+
leg = "binary";
|
|
271
281
|
if (!current()) throw new Error("binary or config changed during proof");
|
|
272
282
|
const cleanupRoot = root;
|
|
273
283
|
const launchProfile = digest(toml({ profile, servers, policy }));
|
|
@@ -279,12 +289,13 @@ async function prepare(ctx: ReviewerPrepareContext): Promise<ReviewerLaunch | {
|
|
|
279
289
|
} catch (error) {
|
|
280
290
|
if (root) { try { rmSync(root, { recursive: true, force: true }); } catch { /* unavailable remains refused */ } }
|
|
281
291
|
// Name only the path class of an unusable credential root so a stale ~/.docker link is fixable; never a path or contents.
|
|
282
|
-
if (error instanceof VersionRefusal) return { unavailable: error.message };
|
|
292
|
+
if (error instanceof VersionRefusal) return { unavailable: error.message, causeClass: "version" };
|
|
283
293
|
if (error instanceof CredentialRootError) {
|
|
284
294
|
const why = { dangling: "a dangling link", unreadable: "an unreadable path", unsupported: "unsupported syntax or overlapping the checkout" }[error.kind];
|
|
285
|
-
return { unavailable: `Codex reviewer isolation receipt unavailable (credential root ${error.label} is ${why}; remove or repair it)` };
|
|
295
|
+
return { unavailable: `Codex reviewer isolation receipt unavailable (credential root ${error.label} is ${why}; remove or repair it)`, causeClass: "credential_root", detail: `${error.label}: ${why}` };
|
|
286
296
|
}
|
|
287
|
-
|
|
297
|
+
// A timeout is a proof command that ran out of time, whichever leg it was on; every other refusal names the leg that was running.
|
|
298
|
+
return { unavailable: "Codex reviewer isolation receipt unavailable (version, MCP inventory, sandbox, configuration or process proof)", causeClass: timedOut ? "timeout" : leg };
|
|
288
299
|
}
|
|
289
300
|
}
|
|
290
301
|
|
|
@@ -89,7 +89,7 @@ export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition
|
|
|
89
89
|
roles: Object.freeze(["planner", "reviewer"]), defaultOnRoster: true, trust: { onPrem: false, hubSandboxed: false },
|
|
90
90
|
start: { forms: Object.freeze(["tui"] as const), defaultForm: "tui", launch: claudeNativeLaunch, version: claudeObservedVersion, ...CLAUDE_LAUNCH_POLICY, conductorStartable: true,
|
|
91
91
|
unattended: true, hubSwitches: switches, ownedFlags: Object.freeze(["--dangerously-load-development-channels", "--dangerously-skip-permissions"]) },
|
|
92
|
-
identity: { mcp: "channel-plugin" }, instances: { mode: "external", roles: ["
|
|
92
|
+
identity: { mcp: "channel-plugin" }, instances: { mode: "external", roles: ["planner", "implementer", "verifier", "reviewer"] }, attach: { externalHello: true, recoveryLaunch: claudeRecoveryLaunch },
|
|
93
93
|
// Approval enforcement belongs to the verified native transport and host permission gate.
|
|
94
94
|
permission: { grants: { ask: "Claude's own permission rules decide",
|
|
95
95
|
"ask-when-needed": "file tools on project files run without asking, outside .agenthub, .git and agent configuration; other tools keep Claude's own rules",
|
|
@@ -5,6 +5,19 @@ export async function runCodexEntry(ctx: NativeCliEntryContext): Promise<void> {
|
|
|
5
5
|
const peer = ctx.instance ?? "codex";
|
|
6
6
|
const { args, cwd, stateDir, unattendedEnv, projectConfig, connect, fail, exec } = ctx;
|
|
7
7
|
const { launcherPreview, buildLaunch, readControl, prepareTerminalLaunch, registerTerminalLaunch } = ctx.helpers;
|
|
8
|
+
if (ctx.hubStarted) {
|
|
9
|
+
// #720: the hub starts a headless reviewer (a fresh Codex per review); nothing runs in this terminal, so there is no TUI launch,
|
|
10
|
+
// no terminal recovery and no exec. Launch arguments were already refused by instanceLaunchArguments.
|
|
11
|
+
if (args.includes("--print-command") || args.includes("--dry-run")) return console.log(JSON.stringify({ peer, start: "hub", note: "a headless reviewer the hub runs per review" }, null, 2));
|
|
12
|
+
const hub = await connect();
|
|
13
|
+
try {
|
|
14
|
+
const snapshot = (await hub.request({ t: "status" })).status;
|
|
15
|
+
const warning = retainedLaunchWarning(snapshot, peer, ctx.definition); if (warning) console.error(warning);
|
|
16
|
+
const res = await hub.request({ t: "start", peer, operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
|
|
17
|
+
if (res.ok !== true) fail(String(res.error ?? `${peer} was not started`));
|
|
18
|
+
} finally { hub.close(); }
|
|
19
|
+
return console.log(`${peer} started: a headless reviewer the hub runs per review (stop it with ahub stop ${peer})`);
|
|
20
|
+
}
|
|
8
21
|
if (args.includes("--print-command") || args.includes("--dry-run")) {
|
|
9
22
|
try { return console.log(JSON.stringify(launcherPreview("codex", args, cwd, stateDir, unattendedEnv), null, 2)); }
|
|
10
23
|
catch { fail("cannot preview launch: invalid arguments or configuration (details withheld to protect credentials)"); }
|
|
@@ -6,6 +6,28 @@ import { BasePeer } from "../hub/peers.ts";
|
|
|
6
6
|
import type { Envelope } from "../hub/envelope.ts";
|
|
7
7
|
import type { ReviewerMcpSession } from "./reviewer-mcp.ts";
|
|
8
8
|
|
|
9
|
+
/** #722: the closed list of legs a reviewer's per-review start can fail on. A class names a leg, never a path, content or token. */
|
|
10
|
+
export const REVIEWER_CAUSE_CLASSES = ["version", "binary", "mcp_inventory", "plugins_hooks_apps", "sandbox_proof", "credential_root", "timeout", "unknown"] as const;
|
|
11
|
+
export type ReviewerCauseClass = typeof REVIEWER_CAUSE_CLASSES[number];
|
|
12
|
+
const REVIEWER_CAUSE_HINT: Record<ReviewerCauseClass, string> = {
|
|
13
|
+
version: "the CLI version is neither verified nor trusted; see ahub agents",
|
|
14
|
+
binary: "the CLI binary or a watched config file could not be bound, or changed during the proof; check the configured binary and retry",
|
|
15
|
+
mcp_inventory: "the MCP server inventory or the hub server entry did not match the proof",
|
|
16
|
+
plugins_hooks_apps: "plugins, hooks or apps could not be proven disabled",
|
|
17
|
+
sandbox_proof: "the native sandbox proof did not pass",
|
|
18
|
+
credential_root: "a host credential root is unusable; remove or repair it",
|
|
19
|
+
timeout: "a proof command timed out",
|
|
20
|
+
unknown: "unclassified; inspect hub.log",
|
|
21
|
+
};
|
|
22
|
+
/** The one wording for the unavailable reason, the log and `ahub status`. `detail` is only ever a credential-root path class. */
|
|
23
|
+
export function reviewerUnavailableReason(cause: ReviewerCauseClass, detail?: string): string {
|
|
24
|
+
return `reviewer isolation unavailable [cause: ${cause}]: ${REVIEWER_CAUSE_HINT[cause]}${detail ? ` (${detail})` : ""}`;
|
|
25
|
+
}
|
|
26
|
+
/** A prepare refusal that carries its class to the host; the message stays the backend's own text. */
|
|
27
|
+
export class ReviewerUnavailableError extends Error {
|
|
28
|
+
constructor(message: string, readonly causeClass: ReviewerCauseClass, readonly detail?: string) { super(message); }
|
|
29
|
+
}
|
|
30
|
+
|
|
9
31
|
export interface ReviewerPrintUsage { input_tokens?: number; output_tokens?: number; cache_creation_input_tokens?: number; cache_read_input_tokens?: number }
|
|
10
32
|
export interface ReviewerIsolationReceipt {
|
|
11
33
|
backend: string;
|
|
@@ -51,7 +73,7 @@ export interface ReviewerBackend {
|
|
|
51
73
|
id: string;
|
|
52
74
|
taskMcp?: true;
|
|
53
75
|
nativePolicy?: true;
|
|
54
|
-
prepare(context: ReviewerPrepareContext): Promise<ReviewerLaunch | { unavailable: string }>;
|
|
76
|
+
prepare(context: ReviewerPrepareContext): Promise<ReviewerLaunch | { unavailable: string; causeClass?: ReviewerCauseClass; detail?: string }>;
|
|
55
77
|
usage?(event: unknown): ReviewerPrintUsage | undefined;
|
|
56
78
|
failure?(event: unknown): "quota" | undefined;
|
|
57
79
|
}
|
|
@@ -60,6 +82,9 @@ export interface ReviewerFailure {
|
|
|
60
82
|
head?: string;
|
|
61
83
|
flowRevision?: number;
|
|
62
84
|
cause: "start_failed" | "quota" | "no_verdict";
|
|
85
|
+
/** Only for `start_failed`: the leg that refused (#722). */
|
|
86
|
+
causeClass?: ReviewerCauseClass;
|
|
87
|
+
causeDetail?: string;
|
|
63
88
|
}
|
|
64
89
|
|
|
65
90
|
export interface ReviewerReviewBinding { readonly head?: string; readonly flowRevision?: number }
|
|
@@ -93,8 +118,8 @@ const reviewTarget = (env: Envelope): ReviewTarget | undefined => {
|
|
|
93
118
|
const revision = Number(env.refs?.reviewRevision);
|
|
94
119
|
return { task, ...(typeof env.refs?.commit === "string" ? { head: env.refs.commit } : {}), ...(Number.isSafeInteger(revision) && revision > 0 ? { flowRevision: revision } : {}) };
|
|
95
120
|
};
|
|
96
|
-
const remainingFailures = (batch: ReviewBatch, start: number, cause: ReviewerFailure["cause"]): ReviewerFailure[] =>
|
|
97
|
-
batch.targets.slice(start).flatMap(target => target ? [{ ...target, cause }] : []);
|
|
121
|
+
const remainingFailures = (batch: ReviewBatch, start: number, cause: ReviewerFailure["cause"], why?: { causeClass: ReviewerCauseClass; causeDetail?: string }): ReviewerFailure[] =>
|
|
122
|
+
batch.targets.slice(start).flatMap(target => target ? [{ ...target, cause, ...(why ? { causeClass: why.causeClass, ...(why.causeDetail ? { causeDetail: why.causeDetail } : {}) } : {}) }] : []);
|
|
98
123
|
const reviewTask = (env: Envelope): number | undefined => {
|
|
99
124
|
if (env.from !== HUB || env.kind !== "review") return undefined;
|
|
100
125
|
const id = Number(env.refs?.task);
|
|
@@ -225,7 +250,9 @@ export class ReviewerPrintPeer extends BasePeer {
|
|
|
225
250
|
}
|
|
226
251
|
// Once an earlier review in this batch ran, the batch had effects: hold it for a person, never requeue it.
|
|
227
252
|
failure = { state: started ? "needs_review" : "failed_safe", reason: `reviewer could not start: ${(error as Error).message}` };
|
|
228
|
-
|
|
253
|
+
const why = error instanceof ReviewerUnavailableError ? { causeClass: error.causeClass, causeDetail: error.detail } : { causeClass: "unknown" as const };
|
|
254
|
+
this.opts.log?.(`[${this.id}] review of task #${task} could not start [cause: ${why.causeClass}]`);
|
|
255
|
+
reroute.push(...remainingFailures(batch, index, "start_failed", why));
|
|
229
256
|
batch.next = envs.length;
|
|
230
257
|
break;
|
|
231
258
|
}
|
|
@@ -273,14 +300,14 @@ export class ReviewerPrintPeer extends BasePeer {
|
|
|
273
300
|
mcpConfig: this.opts.backend.taskMcp ? (mcpConfig ?? '{"mcpServers":{}}') : this.opts.mcpConfig,
|
|
274
301
|
systemPrompt: this.opts.systemPrompt, privatePaths: this.opts.privatePaths ?? [], command: this.opts.command, signal: abort.signal,
|
|
275
302
|
...(input === undefined ? {} : { input }), ...(task === undefined ? {} : { task }) });
|
|
276
|
-
if ("unavailable" in launch) throw new
|
|
303
|
+
if ("unavailable" in launch) throw new ReviewerUnavailableError(launch.unavailable, launch.causeClass ?? "unknown", launch.detail);
|
|
277
304
|
const proof = launch.isolation;
|
|
278
305
|
const policy = this.opts.backend.nativePolicy
|
|
279
306
|
? proof?.enforcement === "native-policy" && proof.freshSession === true && proof.hubToolsOnly === true
|
|
280
307
|
: proof?.strictMcp === true && proof.ephemeral === true;
|
|
281
308
|
if (abort.signal.aborted || !proof || proof.backend !== this.opts.backend.id || proof.readOnly !== true || !policy || !proof.binary || !proof.version || !proof.profile || !proof.evidence.length || !launch.command.length) {
|
|
282
309
|
await launch.cleanup?.();
|
|
283
|
-
throw new
|
|
310
|
+
throw new ReviewerUnavailableError("reviewer isolation is unavailable or preparation was cancelled", "unknown");
|
|
284
311
|
}
|
|
285
312
|
return launch;
|
|
286
313
|
})();
|
|
@@ -307,7 +334,7 @@ export class ReviewerPrintPeer extends BasePeer {
|
|
|
307
334
|
const release = () => releasing ??= Promise.resolve().then(() => launch?.cleanup?.()).then(() => {});
|
|
308
335
|
try {
|
|
309
336
|
launch = await this.prepare(randomUUID(), input, task, mcp?.config);
|
|
310
|
-
if (generation !== this.generation || this.opts.admit?.(env) === false || launch.current?.() === false) throw new
|
|
337
|
+
if (generation !== this.generation || this.opts.admit?.(env) === false || launch.current?.() === false) throw new ReviewerUnavailableError("reviewer isolation or admission changed before child spawn", launch.current?.() === false ? "binary" : "unknown");
|
|
311
338
|
this.receipt = launch.isolation;
|
|
312
339
|
const [cmd, ...prefix] = launch.command;
|
|
313
340
|
let quota = false;
|
package/src/cli/help.ts
CHANGED
|
@@ -35,6 +35,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
|
|
|
35
35
|
["ahub sdlc train report|configure|cut <release-id> [revision] [json]", "plan a release and freeze its approved scope after resource/quota admission"],
|
|
36
36
|
["ahub sdlc release-assign <task> <revision> <release-id|none> <reason...>", "assign approved/in-flight work without changing execution"],
|
|
37
37
|
["ahub sdlc costs [owner/repo]", "read bounded planner cost metrics for linked merged PRs"],
|
|
38
|
+
["ahub sdlc metrics [--since <YYYY-MM-DD|ISO time>]", "read SDLC metrics and control band tiers from board history (planner or person; read-only)"],
|
|
38
39
|
["ahub backlog groom | return <task> <revision> <reason...>", "read grooming findings or return accepted untouched work to backlog (planner/person)"],
|
|
39
40
|
["ahub status [--json] [--full] [--color=auto|always|never]", "the hub, its peers, model backends and task counts"],
|
|
40
41
|
["ahub status --all [--json] [--color=auto|always|never]", "show every registered project"],
|
|
@@ -80,7 +81,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
|
|
|
80
81
|
]],
|
|
81
82
|
["Tasks and review", [
|
|
82
83
|
["ahub board import github --label <label> [--milestone <milestone>] [--repo <owner/repo>]", "person only: read bounded open issues into deduplicated Backlog; never writes GitHub"],
|
|
83
|
-
["ahub board [state | --ready] [--columns] [--release <name> | --release-id <id>] [--json] [--full] [--color=auto|always|never]", "the task board; --ready: proposed tasks with nothing left to wait for"],
|
|
84
|
+
["ahub board [state | --ready] [--columns] [--release <name> | --release-id <id>] [--json] [--full] [--color=auto|always|never]", "the task board, one line per task at a terminal width; --full: whole titles and SDLC advice; --ready: proposed tasks with nothing left to wait for"],
|
|
84
85
|
["ahub task propose [--class <c> | <class>] <title...> [--owner <peer>] [--path <p>]... [--after <id>]... [--urgent] [--backlog] [--detail <text>]", "put a task on the board; a first word that names a class is taken as the class; while a task named by --after is not approved yet, --owner reserves the task for that peer, offered to it first once it is ready; otherwise --owner assigns it now"],
|
|
85
86
|
["ahub backlog list | add <class> <title...> | plan <id> <revision> <fields-json> <reason...> | pin <id> <revision> true|false <reason...> | undo <id> <revision>", "person only: stage and plan work, preserve pins/frozen scope and refuse stale edits"],
|
|
86
87
|
["ahub release new <name> <goal> <order> | list | show <name> | plan <name> <revision> <dates-json> | activate|freeze|ship|drop <name> <revision>", "person only: planned UTC millisecond dates and phased scope; ship records a time after approval, never tags or publishes"],
|
package/src/cli/main.ts
CHANGED
|
@@ -1295,6 +1295,11 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1295
1295
|
if (args.length > 2) fail("usage: ahub sdlc costs [owner/repository]");
|
|
1296
1296
|
return console.log(await taskOp("hub_sdlc_costs", { ...(args[1] ? { repo: args[1] } : {}) }));
|
|
1297
1297
|
}
|
|
1298
|
+
if (sub === "metrics") {
|
|
1299
|
+
const flags = takeFlags(args.slice(1), ["--since"], []);
|
|
1300
|
+
if (flags.rest.length) fail("usage: ahub sdlc metrics [--since <YYYY-MM-DD|ISO time>]");
|
|
1301
|
+
return console.log(await taskOp("hub_sdlc_metrics", { ...(flags.one["--since"] !== undefined ? { since: flags.one["--since"] } : {}) }));
|
|
1302
|
+
}
|
|
1298
1303
|
if (sub === "plan-review") {
|
|
1299
1304
|
const revision = Number(args[2]);
|
|
1300
1305
|
if (!Number.isSafeInteger(id) || id! <= 0 || !Number.isSafeInteger(revision) || revision < 0 || args.length < 4) fail("usage: ahub sdlc plan-review <task> <revision> <reason...>");
|