@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.
Files changed (40) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +6 -4
  3. package/docs/agent-notes/adapters.md +1 -1
  4. package/docs/agent-notes/budget.md +2 -1
  5. package/docs/agent-notes/daemon.md +8 -1
  6. package/docs/agent-notes/tasks.md +7 -4
  7. package/docs/agent-notes/tests.md +1 -1
  8. package/docs/dogfood.md +10 -7
  9. package/docs/operations.md +45 -13
  10. package/docs/quickstart.md +6 -2
  11. package/docs/sdlc.md +23 -2
  12. package/docs/security.md +9 -5
  13. package/docs/specs/2026-09-19-agent-hub-design.md +38 -0
  14. package/docs/verification/2026-10-22-codex-reviewer-isolation.md +14 -0
  15. package/docs/verification/2026-10-23-0.14.0.md +69 -0
  16. package/docs/verified.json +20 -15
  17. package/package.json +1 -1
  18. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  19. package/plugins/agent-hub/server.js +91 -20
  20. package/src/adapters/codex-review-isolation.ts +18 -7
  21. package/src/adapters/definitions/builtin-metadata.ts +1 -1
  22. package/src/adapters/definitions/native-cli-entry-codex.ts +13 -0
  23. package/src/adapters/reviewer-print.ts +34 -7
  24. package/src/cli/help.ts +2 -1
  25. package/src/cli/main.ts +5 -0
  26. package/src/cli/output.ts +47 -2
  27. package/src/cli/status-lines.ts +1 -1
  28. package/src/hub/board.ts +22 -1
  29. package/src/hub/daemon.ts +84 -14
  30. package/src/hub/hub-tools.ts +2 -1
  31. package/src/hub/instances.ts +14 -4
  32. package/src/hub/manager.ts +2 -2
  33. package/src/hub/sdlc-metrics.ts +260 -0
  34. package/src/hub/sdlc.ts +5 -3
  35. package/src/hub/spec-read.ts +31 -0
  36. package/src/hub/task-code-group.ts +52 -0
  37. package/src/hub/tasks.ts +172 -22
  38. package/src/hub/ui.ts +3 -0
  39. package/src/hub/usage-rollup.ts +58 -0
  40. 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
- reroute.push(...remainingFailures(batch, index, "start_failed"));
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 Error(launch.unavailable);
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 Error("reviewer isolation is unavailable or preparation was cancelled");
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 Error("reviewer isolation or admission changed before child spawn");
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(stop, Math.max(1, Math.min(2000, deadline - performance.now())));
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. S7 owns automatic band proposals.", { repo: str2 }),
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: ["reviewer", "planner"] },
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.13.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 || entry.worktree)
19937
- return fail4(path, `a channel instance starts only from the person's terminal (ahub <kind> --as <id>): start_mode and worktree are not supported; a headless reviewer declares roles ["reviewer"] and start_mode "headless"`);
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`, "a channel instance must declare its roles: reviewer and/or planner");
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
- return [...declaration?.roles ?? ownRoles(id) ?? ownRoles(definition.kind) ?? definition.roles];
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
- return { unavailable: "Codex reviewer isolation receipt unavailable (version, MCP inventory, sandbox, configuration or process proof)" };
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: ["reviewer", "planner"] }, attach: { externalHello: true, recoveryLaunch: claudeRecoveryLaunch },
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
- reroute.push(...remainingFailures(batch, index, "start_failed"));
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 Error(launch.unavailable);
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 Error("reviewer isolation is unavailable or preparation was cancelled");
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 Error("reviewer isolation or admission changed before child spawn");
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...>");