@staix/agent-hub 0.12.35 → 0.12.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +15 -8
  3. package/docs/agent-notes/adapters.md +9 -2
  4. package/docs/agent-notes/budget.md +3 -2
  5. package/docs/agent-notes/bus.md +3 -0
  6. package/docs/agent-notes/daemon.md +11 -3
  7. package/docs/agent-notes/tasks.md +14 -1
  8. package/docs/agent-notes/tests.md +6 -2
  9. package/docs/memory.md +3 -1
  10. package/docs/operations.md +74 -18
  11. package/docs/quickstart.md +7 -9
  12. package/docs/sdlc.md +72 -5
  13. package/docs/security.md +11 -3
  14. package/docs/smoke.md +18 -2
  15. package/docs/specs/2026-09-19-agent-hub-design.md +108 -3
  16. package/docs/verification/2026-10-19-0.12.36.md +63 -0
  17. package/docs/verification/2026-10-19-0.12.37.md +60 -0
  18. package/docs/verification/2026-10-19-jcode-0.98.1.md +30 -0
  19. package/docs/verification/2026-10-19-reviewer-isolation.md +119 -0
  20. package/docs/verified.json +23 -15
  21. package/package.json +2 -2
  22. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  23. package/plugins/agent-hub/server.js +2843 -1632
  24. package/src/adapters/acp.ts +3 -2
  25. package/src/adapters/claude-print.ts +92 -0
  26. package/src/adapters/codex-review-isolation.ts +276 -0
  27. package/src/adapters/definitions/builtin-metadata.ts +20 -1
  28. package/src/adapters/definitions/native-cli-entry-claude.ts +17 -3
  29. package/src/adapters/definitions/native-cli-entry-jcode.ts +3 -2
  30. package/src/adapters/definitions/native-cli-entry-kimi.ts +3 -2
  31. package/src/adapters/definitions/native-cli-entry-types.ts +2 -0
  32. package/src/adapters/definitions/native-factories.ts +24 -0
  33. package/src/adapters/definitions/native-policy-aux.ts +1 -1
  34. package/src/adapters/definitions/reviewer-variants.ts +32 -0
  35. package/src/adapters/jcode.ts +20 -5
  36. package/src/adapters/registry-types.ts +13 -3
  37. package/src/adapters/registry.ts +29 -5
  38. package/src/adapters/reviewer-print.ts +319 -0
  39. package/src/cli/help.ts +7 -0
  40. package/src/cli/identity.ts +2 -1
  41. package/src/cli/launch.ts +19 -6
  42. package/src/cli/main.ts +57 -4
  43. package/src/cli/memory.ts +13 -12
  44. package/src/cli/reset.ts +4 -2
  45. package/src/cli/statusline-tee.ts +5 -2
  46. package/src/cli/terminal-recovery.ts +7 -2
  47. package/src/cli/upgrade-runtime.ts +18 -15
  48. package/src/hub/board.ts +61 -11
  49. package/src/hub/bus.ts +11 -5
  50. package/src/hub/claude-state.ts +37 -0
  51. package/src/hub/config-apply.ts +3 -2
  52. package/src/hub/control-client.ts +1 -1
  53. package/src/hub/cost-governance.ts +76 -0
  54. package/src/hub/daemon.ts +269 -87
  55. package/src/hub/envelope.ts +1 -1
  56. package/src/hub/host-executable.ts +1 -1
  57. package/src/hub/hub-tools.ts +25 -1
  58. package/src/hub/instances.ts +29 -5
  59. package/src/hub/memory-profile.ts +67 -11
  60. package/src/hub/planning.ts +2 -0
  61. package/src/hub/release-admission.ts +64 -0
  62. package/src/hub/review-brief.ts +175 -0
  63. package/src/hub/reviewer-pool-config.ts +11 -0
  64. package/src/hub/reviewer-pool.ts +70 -0
  65. package/src/hub/routing.ts +58 -4
  66. package/src/hub/sdlc-release.ts +113 -0
  67. package/src/hub/sdlc.ts +205 -4
  68. package/src/hub/status-roster.ts +10 -0
  69. package/src/hub/task-sweep.ts +143 -0
  70. package/src/hub/tasks.ts +788 -52
  71. package/src/hub/usage.ts +57 -20
@@ -14,7 +14,7 @@ export interface Envelope {
14
14
  kind: Kind;
15
15
  priority: Priority;
16
16
  body: string; // agent conclusions only, never tool noise
17
- refs?: { repo?: string; branch?: string; commit?: string; paths?: string[]; task?: string; taskOffer?: number; taskCancelled?: number; taskRecipient?: "owner" | "reviewer" | "planner"; backlog?: number[]; supervision?: boolean; supervisionKey?: string; boundedCheck?: string; budgetResume?: string; budgetResumeMoves?: { id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]; budgetResumeEarlier?: string[] };
17
+ refs?: { repo?: string; branch?: string; commit?: string; paths?: string[]; task?: string; reviewRevision?: string; taskOffer?: number; taskCancelled?: number; taskRecipient?: "owner" | "reviewer" | "planner"; backlog?: number[]; supervision?: boolean; supervisionKey?: string; boundedCheck?: string; budgetResume?: string; budgetResumeMoves?: { id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]; budgetResumeEarlier?: string[] };
18
18
  /** The body must not be shown outside its recipients: console tail and log print a stub instead (PII tasks). */
19
19
  private?: boolean;
20
20
  ts: number;
@@ -2,7 +2,7 @@ import { isAbsolute, relative } from "node:path";
2
2
  import { realPath } from "./project.ts";
3
3
 
4
4
  /** A repo-writable PATH shim is not a host-owned authenticated capability. No caller-selected binary. */
5
- export function hostExecutable(name: "gh" | "container" | "git", projectDir: string, path = process.env.PATH ?? ""): string {
5
+ export function hostExecutable(name: "gh" | "container" | "git" | "dot", projectDir: string, path = process.env.PATH ?? ""): string {
6
6
  const found = Bun.which(name, { PATH: path });
7
7
  if (!found || !isAbsolute(found)) throw new Error(`${name} unavailable`);
8
8
  const target = realPath(found), inside = relative(realPath(projectDir), target);
@@ -17,6 +17,10 @@ const refs = {
17
17
  additionalProperties: false,
18
18
  } as const;
19
19
  const list = (description: string) => ({ type: "array", items: str, description }) as const;
20
+ const costPlanProperties = {
21
+ review: { type: "object", properties: { scope: { type: "string", enum: ["none", "delta", "full"] }, reviewers: { type: "integer", minimum: 0, maximum: 8 } }, required: ["scope", "reviewers"], additionalProperties: false },
22
+ ci: { type: "string", enum: ["focused", "full", "reuse"] },
23
+ } as const;
20
24
  const plan = {
21
25
  type: "object",
22
26
  description: "what you will change, before you start. Owners of open tasks on the same files or symbols see it, and get a notice when your task is done.",
@@ -57,6 +61,11 @@ export const TASK_TOOLS: HubTool[] = [
57
61
  tool("hub_backlog_pull", "Reserve a WIP slot and pull ready unpinned backlog work into To Do at its current revision. Preserves person order/reservations and frozen scope; pending unowned work can be undone with hub_backlog_undo.", { id, revision: { type: "integer", minimum: 0 }, reason: { type: "string", minLength: 1, maxLength: 1000 } }, ["id", "revision", "reason"]),
58
62
  tool("hub_backlog_split", "Split unstarted backlog work into two to eight staged parts. The original card retains integration/acceptance and depends on the children. Use after for existing prerequisite ids and afterParts for earlier zero-based part indexes. Latest unused unchanged children can be undone with hub_backlog_undo.", { id, revision: { type: "integer", minimum: 0 }, reason: { type: "string", minLength: 1, maxLength: 1000 }, parts: { type: "array", minItems: 2, maxItems: 8, items: { type: "object", properties: { title: { type: "string", minLength: 1, maxLength: 300 }, detail: { type: "string", maxLength: 8000 }, class: str, refs, plan, after: { type: "array", maxItems: 64, uniqueItems: true, items: { type: "integer", minimum: 1 } }, afterParts: { type: "array", maxItems: 8, uniqueItems: true, items: { type: "integer", minimum: 0 } } }, required: ["title"], additionalProperties: false } } }, ["id", "revision", "reason", "parts"]),
59
63
  tool("hub_backlog_undo", "Undo your latest planning change at its current revision. Refuses intervening changes and another actor's edit.", { id, revision: { type: "integer", minimum: 1 } }, ["id", "revision"]),
64
+ tool("hub_release_new", "Create a future release planning row. Planner/person only; no activation, publish or release authorization.", { name: { type: "string", minLength: 1, maxLength: 64 }, goal: { type: "string", minLength: 1, maxLength: 1000 }, order: { type: "integer", minimum: 0 } }, ["name", "goal", "order"]),
65
+ tool("hub_release_report", "Read a planned release's task readiness and recorded metrics. Planner/person only. A due date never waives unfinished scope.", { id }, ["id"]),
66
+ tool("hub_release_assign", "Assign or move only a task's release, retaining its execution state and owner. Planner/person only; requires a reason and current planning revision. Frozen scope refuses.", { id, revision: { type: "integer", minimum: 0 }, release: { type: ["integer", "null"], minimum: 1 }, reason: { type: "string", minLength: 1, maxLength: 1000 } }, ["id", "revision", "release", "reason"]),
67
+ tool("hub_release_configure", "Configure/activate a release train or record a hotfix cause. Planner/person only. At most one train is active/frozen; planning rows are future trains.", { id, revision: { type: "integer", minimum: 0 }, state: { type: "string", enum: ["active"] }, cut: { type: "integer", minimum: 1 }, hotfix: { type: "object", properties: { cause: { type: "string", enum: ["loss", "security_privacy", "upgrade_start_blocked", "core_flow_no_workaround"] }, reason: { type: "string", minLength: 1, maxLength: 1000 } }, required: ["cause", "reason"], additionalProperties: false } }, ["id", "revision"]),
68
+ tool("hub_release_cut", "Freeze an active release's approved scope after host resource admission and fresh peer quota checks. Unknown evidence defers; no publish, tag, upgrade or authorization.", { id, revision: { type: "integer", minimum: 0 } }, ["id", "revision"]),
60
69
  tool("hub_release_list", "Read public release scope and planned/actual dates for phased planning. Configured planners only.", {}),
61
70
 
62
71
  tool("hub_task_propose", "Put a piece of work on the shared task board. The hub assigns an owner by class (routing.toml) unless you name one. Classes: plan, implement, bulk_edit, test, review, summarize, triage. Name the class when you know it; without one the hub tries to pick it. Name yourself as owner to claim work nobody assigned you: it starts in progress. Put the paths the work touches in refs, and when you claim work, a plan; the hub says when they overlap another open task.", { title: str, class: { type: "string", enum: ["plan", "implement", "bulk_edit", "test", "review", "summarize", "triage"] }, detail: str, role: { type: "string", description: "catalog role id for this task class; guidance only, no routing or authority change" }, refs, plan, owner: { type: "string", description: "peer id; yours to claim the work, omit to let the hub route it. With after, the owner is reserved: offered the task first once it is ready" }, after: { type: "array", items: { type: "integer" }, description: "ids of tasks that must be approved first; until then this one waits, offered to nobody, and cannot be claimed" }, backlog: { type: "boolean", description: "stage unoffered work; owner is only a reservation, even when naming yourself" }, urgent: { type: "boolean", description: "hand it over even when its owner is paused for quota only briefly" }, goal: { type: "string", description: "id of this project's long-term task (ahub goal list) the work is part of" } }, ["title"]),
@@ -66,7 +75,7 @@ export const TASK_TOOLS: HubTool[] = [
66
75
  tool("hub_task_done", "Mark your task finished. It goes to its reviewer with your summary and refs; no duplicate hub_send chat is needed. When the project configures a check for its class, the hub runs it first and the result comes as a task message: a failed check keeps the task with you.", { id, summary: { type: "string", description: "what changed, why, and the check you ran with its result" }, refs }, ["id", "summary"]),
67
76
  tool("hub_sdlc_record", "Record artifact ids or claimed local checks on your task. This grants no authority or external-check evidence. Only its owner/reviewer or the person may record; only its reviewer/person may name a reviewed head or phase checklist. Phase evidence refs are content hashes, not external verification.", { id, receipt: { type: "object", properties: { stage: { type: "string", enum: ["spec", "plan", "implement", "review", "verify", "merge", "release"] }, repo: str, issue: id, pr: id, localHead: str, localResult: { type: "string", enum: ["passed", "failed", "unknown"] }, reviewedHead: str, phase: { type: "object", properties: { id: str, head: str, tasks: { type: "array", maxItems: 64, items: { type: "object", properties: { id, after: { type: "array", maxItems: 16, items: id } }, required: ["id", "after"], additionalProperties: false } }, criteria: { type: "array", maxItems: 32, items: { type: "object", properties: { id: str, required: { type: "boolean" }, result: { type: "string", enum: ["verified", "failed", "unverified"] }, refs: { type: "array", maxItems: 8, items: str } }, required: ["id", "required", "result", "refs"], additionalProperties: false } } }, required: ["id", "tasks", "criteria"], additionalProperties: false } }, additionalProperties: false } }, ["id", "receipt"]),
68
77
  tool("hub_task_list", "The task board. PII tasks show as [pii].", { state: { type: "string", enum: ["proposed", "in_progress", "in_review", "approved", "changes_requested", "cancelled"] }, ready: { type: "boolean", description: "only proposed tasks with nothing left to wait for" } }),
69
- tool("hub_review", "Give your verdict on a task you were asked to review: map the changed signatures and call sites to the task's plan or detail, read the check result (hub_task_show has it, with the done summary), and list what is unmet. The verdict notifies the task owner; no duplicate hub_send chat is needed. Consecutive changes_requested verdicts (three by default, `tasks.escalate_after`) move the task to another peer.", { id, head: { type: "string", description: "full SHA actually reviewed; enforce binds approval to the current PR head" }, verdict: { type: "string", enum: ["approved", "changes_requested"] }, note: str, unmet: { type: "array", items: str, description: "each requirement of the plan or detail that the change does not meet" } }, ["id", "verdict"]),
78
+ tool("hub_review", "Give your verdict on a task you were asked to review: map the changed signatures and call sites to the task's plan or detail, read the check result (hub_task_show has it, with the done summary), and list what is unmet. The verdict notifies the task owner; no duplicate hub_send chat is needed. Consecutive changes_requested verdicts (three by default, `tasks.escalate_after`) move the task to another peer.", { id, head: { type: "string", description: "full SHA actually reviewed; enforce binds approval to the current PR head" }, verdict: { type: "string", enum: ["approved", "changes_requested"] }, note: str, unmet: { type: "array", items: str, description: "each requirement of the plan or detail that the change does not meet" }, coverage: { type: "object", description: "the review brief's reviewable paths you read (reviewed) and any you did not (skipped, each with a reason); in enforce an approval must account for every one", properties: { reviewed: { type: "array", items: str }, skipped: { type: "array", items: { type: "object", properties: { path: str, reason: str }, required: ["path", "reason"] } } } } }, ["id", "verdict"]),
70
79
  tool("hub_checkpoint", "Answer a checkpoint request from the hub (quota or context window pressure): what you were doing, what is half done, what whoever continues must know. Write the same to .agenthub/checkpoint.md first if you can.", { summary: str, request_id: { type: "string", description: "The request id supplied by a context checkpoint request; required for context checkpoints" } }, ["summary"]),
71
80
  tool("hub_memory_search", "Search daemon-scoped visible memory, fails first. Query <=200 UTF-8 bytes; default 8, max 20 lines; whole output <=4 KB. Incomplete excerpts are marked. No project argument.", { query: { type: "string", maxLength: 200, description: "At most 200 UTF-8 bytes, enforced by the daemon" }, limit: { type: "integer", minimum: 1, maximum: 20 } }, ["query"]),
72
81
  tool("hub_memory_show", "Read up to 8 visible memory ids or unique prefixes (6..64 UTF-8 bytes each). Original ids redirect; unavailable ids share one refusal. Whole output <=8 KB.", { ids: { type: "array", minItems: 1, maxItems: 8, items: { type: "string", minLength: 6, maxLength: 64, description: "6 to 64 UTF-8 bytes" } } }, ["ids"]),
@@ -75,6 +84,13 @@ export const TASK_TOOLS: HubTool[] = [
75
84
  tool("hub_remember", "Save a decision, finding, contract or fail to the memory all agents share (the hub's memory and claude-mem); the other agents also get it with their next message. A fail is an approach you tried that does not work, and why: the most useful note, it stops the others spending their quota on it. Do not retry what a fail note rules out without new evidence. Conclusions worth recalling, not chatter. For kind fail, optional approach, symptom, cause, evidence and retry_when strings compose fixed labeled text; text may be omitted when structured fields are supplied. The complete text must fit 4000 UTF-8 bytes and is refused, never truncated, when oversized. A note on an open task is short-term until the task is approved, when its decisions, contracts and fails become long-term; horizon long keeps it now, scope shared lets the person's other projects find it.", { text: str, approach: str, symptom: str, cause: str, evidence: str, retry_when: str, title: str, kind: { type: "string", enum: [...NOTE_KINDS] }, task: id, horizon: { type: "string", enum: ["short", "long"] }, scope: { type: "string", enum: ["project", "shared"] } }),
76
85
  ];
77
86
 
87
+ export const PLANNER_REVIEW_TOOLS: HubTool[] = [
88
+ 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.", {}),
89
+ 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: { ...str, maxLength: 1000 }, ...costPlanProperties }, ["id", "revision", "reason"]),
90
+ 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: str }),
91
+ 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: { ...str, maxLength: 1000 } }, ["id", "revision", "reason"]),
92
+ ];
93
+ TASK_TOOLS.push(...PLANNER_REVIEW_TOOLS);
78
94
  export const TASK_TOOL_NAMES = new Set(TASK_TOOLS.map((t) => t.name));
79
95
 
80
96
  /** Separately published: serving a schema grants no authority, only the daemon's explicit role does. */
@@ -134,6 +150,14 @@ export function profileTaskTools(profile: ProfileSelection = "dev", stages?: rea
134
150
  const selectedPlan = { ...plan, properties: Object.fromEntries(data.plan_keys.map(key => [key, plan.properties[key as keyof typeof plan.properties] ?? list(key === "sections" ? "document heading anchors, such as report.md#methods" : key === "sources" ? "citation ids, DOI or source URL ids" : "short claim ids to verify")])) };
135
151
  properties.parts = { ...parts, items: { ...parts.items, properties: { ...parts.items.properties, plan: selectedPlan } } };
136
152
  }
153
+ if (criteria && properties.plan) {
154
+ const current = properties.plan as { properties: Record<string, unknown> };
155
+ properties.plan = { ...current, properties: { ...current.properties, ...costPlanProperties } };
156
+ }
157
+ if (criteria && tool.name === "hub_backlog_split") {
158
+ const parts = properties.parts as { items: { properties: { plan: { properties: Record<string, unknown> } } } };
159
+ properties.parts = { ...parts, items: { ...parts.items, properties: { ...parts.items.properties, plan: { ...parts.items.properties.plan, properties: { ...parts.items.properties.plan.properties, ...costPlanProperties } } } } };
160
+ }
137
161
  return { ...tool, ...(customProfile && tool.name === "hub_task_propose" ? { description: tool.description + ` Selected profile ${data.name}: ${Object.keys(data.classes).join(", ")}. Routing uses their existing base classes.` } : {}), inputSchema: { ...tool.inputSchema, properties } };
138
162
  });
139
163
  }
@@ -7,6 +7,7 @@ export interface InstanceConfig {
7
7
  roles?: string[];
8
8
  start_mode?: StartForm;
9
9
  permission_mode?: PermissionMode;
10
+ review_mode?: "fresh";
10
11
  worktree?: string;
11
12
  }
12
13
  export type InstancesConfig = Readonly<Record<string, InstanceConfig>>;
@@ -48,14 +49,36 @@ export function validateInstances(raw: unknown): InstancesConfig {
48
49
  for (const [id, descriptor] of Object.entries(declarations)) {
49
50
  if (!PEER_ID.test(id) || RESERVED.has(id) || definitionOf(id)) return fail("instances", "ids must be nonreserved extra instance ids, not builtin ids");
50
51
  const path = `instances.${id}`, entry = fields(descriptor.value, path);
51
- if (Object.keys(entry).some(key => !["kind", "roles", "start_mode", "permission_mode", "worktree"].includes(key))) return fail(path, "unsupported field");
52
+ if (Object.keys(entry).some(key => !["kind", "roles", "start_mode", "permission_mode", "worktree", "review_mode"].includes(key))) return fail(path, "unsupported field");
52
53
  const kind: unknown = entry.kind?.value;
53
54
  if (typeof kind !== "string") return fail(`${path}.kind`, "must name a native kind");
54
55
  const definition = definitionOf(kind), prefix = `${kind}-`;
55
56
  if (!definition) return fail(`${path}.kind`, "unknown native kind");
56
57
  if (!id.startsWith(prefix) || !/^[a-z0-9][a-z0-9-]*$/.test(id.slice(prefix.length))) return fail(path, "id must be <kind>-<name>");
57
- if (!definition.start.create || !definition.start.forms.includes("headless") || !["env", "acp-session"].includes(definition.identity.mcp)) return fail(`${path}.kind`, "unsupported in I2; a headless factory with env or ACP-session identity is required, not channel-only or project-file identity");
58
- const value: InstanceConfig = { kind };
58
+ const declaredRoles = entry.roles ? roles(entry.roles.value, `${path}.roles`) : [];
59
+ const reviewer = (definition.reviewerVariant ?? definition).reviewer;
60
+ const reviewerOnly = entry.start_mode?.value === "headless" && declaredRoles.length === 1 && declaredRoles[0] === "reviewer";
61
+ if (entry.review_mode && (entry.review_mode.value !== "fresh" || !reviewerOnly || !reviewer)) return fail(`${path}.review_mode`, "fresh requires reviewer-only roles, headless mode and a declared fresh-review capability");
62
+ const freshReviewer = reviewerOnly && !!reviewer && (reviewer.declaration === "automatic" || entry.review_mode?.value === "fresh");
63
+ if (definition.routing?.reviewOnly && !freshReviewer) return fail(path, "a review-only kind requires headless roles exactly [\"reviewer\"]");
64
+ if (freshReviewer && entry.worktree) return fail(`${path}.worktree`, "a fresh reviewer reads the project checkout: worktree is not supported");
65
+ if (freshReviewer && entry.permission_mode) return fail(`${path}.permission_mode`, "a fresh reviewer has a fixed read-only policy");
66
+ // #450 I3: a channel-plugin kind (Claude) runs only in the person's terminal, so it reviews or plans and never owns writes.
67
+ const channel = definition.identity.mcp === "channel-plugin";
68
+ const unavailableReviewer = freshReviewer && !!(definition.reviewerVariant ?? definition).reviewer?.unavailable;
69
+ if (!channel && !unavailableReviewer && (!definition.start.create || !definition.start.forms.includes("headless") || !["env", "acp-session"].includes(definition.identity.mcp))) return fail(`${path}.kind`, "unsupported; a headless factory with env or ACP-session identity, or a channel-plugin kind, is required, not project-file identity");
70
+ if (channel) {
71
+ const declared = entry.roles ? roles(entry.roles.value, `${path}.roles`) : [];
72
+ // #223: a headless one is the kind's reviewer variant, one fresh hub-run session per review request.
73
+ if (entry.start_mode && definition.reviewerVariant) {
74
+ if (entry.worktree) return fail(path, "a headless reviewer reads the project checkout: worktree is not supported");
75
+ if (declared.length !== 1 || declared[0] !== "reviewer") return fail(`${path}.roles`, "a headless channel-kind instance is a reviewer: roles must be exactly [\"reviewer\"]");
76
+ } else {
77
+ if (entry.start_mode || entry.worktree) return fail(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\"");
78
+ if (!declared.length || declared.some(role => role !== "reviewer" && role !== "planner")) return fail(`${path}.roles`, "a channel instance must declare its roles: reviewer and/or planner");
79
+ }
80
+ }
81
+ const value: InstanceConfig = { kind, ...(entry.review_mode ? { review_mode: "fresh" as const } : {}) };
59
82
  if (entry.roles) { value.roles = roles(entry.roles.value, `${path}.roles`); Object.freeze(value.roles); }
60
83
  if (entry.start_mode) {
61
84
  if (entry.start_mode.value !== "headless") return fail(`${path}.start_mode`, "I2 extra instances require headless mode");
@@ -80,13 +103,14 @@ export function instanceCatalog(declarations?: InstancesConfig): readonly Instan
80
103
  const instances = validateInstances(declarations);
81
104
  return Object.freeze([
82
105
  ...ADAPTERS.map(definition => Object.freeze({ id: definition.kind, kind: definition.kind, definition })),
83
- ...Object.entries(instances).map(([id, config]) => Object.freeze({ id, kind: config.kind, definition: definitionOf(config.kind)!, config })),
106
+ ...Object.entries(instances).map(([id, config]) => Object.freeze({ id, kind: config.kind, definition: definitionOf(id, instances)!, config })),
84
107
  ]);
85
108
  }
86
109
  export function instancePeerIds(declarations?: InstancesConfig): string[] { return instanceCatalog(declarations).map(entry => entry.id); }
87
110
  export function instanceDefaultRoles(declarations?: InstancesConfig, includeOptional = true): Record<string, string[]> {
88
111
  const result = defaultPeerRoles(includeOptional);
89
- for (const [id, config] of Object.entries(validateInstances(declarations))) result[id] = [...(config.roles ?? definitionOf(config.kind)!.roles)];
112
+ const instances = validateInstances(declarations);
113
+ for (const [id, config] of Object.entries(instances)) result[id] = [...(config.roles ?? definitionOf(id, instances)!.roles)];
90
114
  return result;
91
115
  }
92
116
 
@@ -1,7 +1,7 @@
1
- import { existsSync, readFileSync } from "node:fs";
1
+ import { existsSync, lstatSync, readFileSync } from "node:fs";
2
2
  import { basename, dirname, join, resolve } from "node:path";
3
3
  import {
4
- CandidateStore, MemoryStore, realPath, memoryHome, resolveProfile,
4
+ CandidateStore, MemoryStore, realPath, memoryHome, memoryProjectKey, openShardStore, readShardedSelection, resolveAlias, resolveProfile, resolveShardProfile,
5
5
  type BoundProfile, type ProfileInput, type StoreOptions,
6
6
  } from "@staix/agent-mem";
7
7
  import packageJson from "../../package.json";
@@ -39,6 +39,14 @@ function assertLoadedAgentMem(): void {
39
39
  }
40
40
  export const MEMORY_PROFILE_UNAVAILABLE = "memory profile unavailable; inspect the selector and generation state, run ahub memory profile-check; use amem migrate --deactivate only to stop using the selected generation";
41
41
  const UNAVAILABLE = MEMORY_PROFILE_UNAVAILABLE;
42
+ /** agent-mem#15 T2: a split marker that is not active (captured, malformed) is never read as an empty or legacy store. */
43
+ export const MEMORY_SPLIT_INCOMPLETE = "memory home has a curated split that is not active; finish it with amem migrate --shards --resume, then restart the hub";
44
+ /** Hub memory options: the project key selects this project's shard once the home is split. */
45
+ /**
46
+ * Hub memory options. `project` is the bound (alias-resolved) key; `projectKey`, when given, is the raw key it came
47
+ * from, and the entry fence latches the binding unavailable once the alias log maps it elsewhere.
48
+ */
49
+ export type ProfileMemoryOptions = Omit<StoreOptions, "memoryDir"> & { project?: string; projectKey?: string };
42
50
 
43
51
  /** No handle was opened; distinct from an unavailable, still-bound handle. */
44
52
  export interface UnboundMemoryProfileReceipt {
@@ -70,7 +78,25 @@ function canonicalHome(path: string): string {
70
78
  }
71
79
  function sameProfile(a: BoundProfile, b: BoundProfile): boolean {
72
80
  return a.home === b.home && a.dir === b.dir && a.kind === b.kind
73
- && a.generation === b.generation && a.selectorRevision === b.selectorRevision;
81
+ && a.generation === b.generation && a.selectorRevision === b.selectorRevision
82
+ && !!a.sharded === !!b.sharded && a.shardKey === b.shardKey && !!a.shardUser === !!b.shardUser;
83
+ }
84
+ // lstat, not exists: a dangling or unreadable marker is still a marker and must fail closed.
85
+ const splitMarked = (home: string) => lstatSync(join(home, "shards.json"), { throwIfNoEntry: false }) !== undefined;
86
+ /** Refuse an inactive split before any shard read, so the remedy names resume rather than the generic profile check. */
87
+ function assertActiveSplit(home: string): void {
88
+ try { readShardedSelection(home); } catch { throw new Error(MEMORY_SPLIT_INCOMPLETE); }
89
+ }
90
+ /**
91
+ * The one profile a binding may hold: this project's shard on a split home, the selected profile otherwise.
92
+ * ponytail: the entry fence re-checks the project shard only; the user overlay shares its selection and selector
93
+ * revision, so its own seal or machine drift goes unchecked. Add resolveShardProfile(home, { user: true }) if needed.
94
+ */
95
+ function currentProfile(home: string, project: string | undefined): BoundProfile {
96
+ if (!splitMarked(home)) return resolveProfile(home);
97
+ assertActiveSplit(home);
98
+ if (!project) throw new Error(UNAVAILABLE);
99
+ return resolveShardProfile(home, { project });
74
100
  }
75
101
  function homeFor(opts: Pick<StoreOptions, "home">): string {
76
102
  return canonicalHome(opts.home === undefined ? memoryHome() : join(canonicalHome(opts.home), "memory"));
@@ -83,6 +109,14 @@ function receipt(profile: BoundProfile, state: MemoryProfileReceipt["state"], sk
83
109
  };
84
110
  }
85
111
 
112
+ /**
113
+ * The project key agent-mem's own CLI binds: memoryProjectKey(cwd) through the person-reviewed alias log of the
114
+ * canonical memory home. The daemon and `ahub memory`/`ahub goal` use it so both open the same shard and rows.
115
+ */
116
+ export function memoryProjectFor(cwd: string, opts: Pick<StoreOptions, "home"> = {}): string {
117
+ return resolveAlias(homeFor(opts), memoryProjectKey(cwd));
118
+ }
119
+
86
120
  /** Inspect selection without constructing a store, index, or candidate sidecar. */
87
121
  export function selectedMemoryProfile(opts: StoreOptions = {}): MemoryProfileReceipt {
88
122
  assertLoadedAgentMem();
@@ -95,14 +129,20 @@ export class MemoryProfileBinding {
95
129
  readonly explicitProfile: ProfileInput;
96
130
  private readonly options: Pick<StoreOptions, "home">;
97
131
  private readonly directMemoryDir: string | undefined;
132
+ /** The shard key on a split home; unused on an unsplit one. */
133
+ private readonly project: string | undefined;
134
+ /** The raw key `project` was resolved from; an alias added later must not leave writes on the old key. */
135
+ private readonly projectKey: string | undefined;
98
136
  private unavailable = false;
99
137
 
100
- constructor(opts: StoreOptions = {}, private readonly onUnavailable?: (neutralReason: string) => void, directMemoryDir?: string) {
138
+ constructor(opts: ProfileMemoryOptions = {}, private readonly onUnavailable?: (neutralReason: string) => void, directMemoryDir?: string) {
101
139
  assertLoadedAgentMem();
102
140
  this.options = { home: opts.home };
103
141
  this.directMemoryDir = directMemoryDir;
142
+ this.project = opts.project;
143
+ this.projectKey = opts.projectKey;
104
144
  this.home = directMemoryDir === undefined ? homeFor(this.options) : canonicalHome(directMemoryDir);
105
- this.profile = Object.freeze({ ...resolveProfile(this.home) });
145
+ this.profile = Object.freeze({ ...currentProfile(this.home, this.project) });
106
146
  this.explicitProfile = this.profile.kind === "shared-v1" ? "shared-v1" : Object.freeze({ generation: this.profile.generation! });
107
147
  }
108
148
 
@@ -110,8 +150,9 @@ export class MemoryProfileBinding {
110
150
  if (this.unavailable) throw new Error(UNAVAILABLE);
111
151
  try {
112
152
  const currentHome = this.directMemoryDir === undefined ? homeFor(this.options) : canonicalHome(this.directMemoryDir);
113
- const current = resolveProfile(currentHome);
153
+ const current = currentProfile(currentHome, this.project);
114
154
  if (currentHome !== this.home || !sameProfile(current, this.profile)) throw new Error(UNAVAILABLE);
155
+ if (this.projectKey !== undefined && resolveAlias(currentHome, this.projectKey) !== this.project) throw new Error(UNAVAILABLE);
115
156
  } catch {
116
157
  this.invalidate();
117
158
  throw new Error(UNAVAILABLE);
@@ -149,12 +190,27 @@ export class MemoryProfileBinding {
149
190
  }
150
191
  }
151
192
 
152
- export function openProfileMemory(opts: StoreOptions = {}, onUnavailable?: (neutralReason: string) => void): {
193
+ export function openProfileMemory(opts: ProfileMemoryOptions = {}, onUnavailable?: (neutralReason: string) => void): {
153
194
  store: MemoryStore; binding: MemoryProfileBinding;
154
195
  } {
155
- const binding = new MemoryProfileBinding(opts, onUnavailable);
156
- binding.assertCurrent();
157
- const raw = new MemoryStore({ ...opts, profile: binding.explicitProfile });
196
+ const { project, ...storeOptions } = opts;
197
+ const home = homeFor(opts);
198
+ let raw: MemoryStore, binding: MemoryProfileBinding;
199
+ if (splitMarked(home)) {
200
+ // agent-mem#15 T2: a plain MemoryStore refuses a split home. The shard store opens this project's shard (created on
201
+ // first use) with the user shard as its overlay; the binding is resolved after it, since a new shard has no seal yet.
202
+ assertActiveSplit(home);
203
+ if (!project) throw new Error(UNAVAILABLE);
204
+ // The canonical memory directory the binding resolves: a lexical home (a symlinked AGENTHUB_HOME or memory dir)
205
+ // would give the shard a different profile.home and dir than the binding compares against.
206
+ raw = openShardStore({ ...storeOptions, home: undefined, memoryDir: home, project });
207
+ try { binding = new MemoryProfileBinding(opts, onUnavailable); }
208
+ catch (error) { raw.close(); throw error; }
209
+ } else {
210
+ binding = new MemoryProfileBinding(opts, onUnavailable);
211
+ binding.assertCurrent();
212
+ raw = new MemoryStore({ ...storeOptions, profile: binding.explicitProfile });
213
+ }
158
214
  try {
159
215
  binding.assertCurrent();
160
216
  if (!sameProfile(raw.profile, binding.profile)) throw new Error(UNAVAILABLE);
@@ -172,7 +228,7 @@ export function openProfileMemory(opts: StoreOptions = {}, onUnavailable?: (neut
172
228
  export function bindProfileMemory(store: MemoryStore, onUnavailable?: (neutralReason: string) => void): {
173
229
  store: MemoryStore; binding: MemoryProfileBinding;
174
230
  } {
175
- const binding = new MemoryProfileBinding({}, onUnavailable, store.profile.home);
231
+ const binding = new MemoryProfileBinding({ project: store.profile.shardKey }, onUnavailable, store.profile.home);
176
232
  binding.assertCurrent();
177
233
  if (!sameProfile(store.profile, binding.profile)) throw new Error(UNAVAILABLE);
178
234
  return { store, binding };
@@ -1,3 +1,4 @@
1
+ import type { ReleaseTrainData } from "./sdlc-release.ts";
1
2
  import type { Task } from "./board.ts";
2
3
 
3
4
  export const BACKLOG_SPLIT_SCREEN_TIMEOUT_MS = 8000;
@@ -28,6 +29,7 @@ export function planningWipLimits(input: unknown): Record<string, number> {
28
29
  export const RELEASE_STATES = ["planning", "active", "frozen", "shipped", "dropped"] as const;
29
30
  export type ReleaseState = (typeof RELEASE_STATES)[number];
30
31
  export interface Release {
32
+ train?: ReleaseTrainData;
31
33
  id: number; name: string; goal: string; order: number; state: ReleaseState;
32
34
  plannedStart: number | null; cut: number | null; ship: number | null;
33
35
  activatedAt: number | null; frozenAt: number | null; shippedAt: number | null;
@@ -0,0 +1,64 @@
1
+ import { existsSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { spawn } from "node:child_process";
4
+ import { hubGitSync } from "./git.ts";
5
+ import { hostExecutable } from "./host-executable.ts";
6
+ import { childEnv, stopOwnedProcess } from "./child-process.ts";
7
+ import { detectTooling } from "./tooling.ts";
8
+
9
+ export interface ReleaseHostAdmission { admitted: boolean; reason?: string; current?: () => boolean; close?: () => Promise<void> }
10
+ const READY = "AHUB_RELEASE_SLOT_READY\n";
11
+ const HOLD = 'process.stdout.write("AHUB_RELEASE_SLOT_READY\\n");process.stdin.resume();process.stdin.on("end",()=>process.exit(0));';
12
+ /** Hold the installed controller's repository lease through the synchronous scope freeze. No caller command. */
13
+ export function readReleaseHostAdmission(cwd: string): Promise<ReleaseHostAdmission> {
14
+ const common = hubGitSync(["rev-parse", "--path-format=absolute", "--git-common-dir"], { cwd, encoding: "utf8", timeout: 1000, maxBuffer: 4096 });
15
+ const directory = common.stdout?.trim();
16
+ if (common.status !== 0 || !directory) return Promise.resolve({ admitted: false, reason: "repository resource identity unavailable" });
17
+ const locked = () => existsSync(join(directory, "ahub-heavy.lock"));
18
+ if (locked()) return Promise.resolve({ admitted: false, reason: "repository heavy-job slot held" });
19
+ return new Promise(resolve => {
20
+ let executable: string;
21
+ try { executable = hostExecutable("dot", cwd); } catch { resolve({ admitted: false, reason: "host resource controller unavailable" }); return; }
22
+ const env = childEnv();
23
+ delete env.DOT_ADMISSION_SCOPE; // A long-lived daemon must not inherit an expired launcher lease.
24
+ const child = spawn(executable, ["admit", "--wait", "0s", "--json", "--", process.execPath, "-e", HOLD], { cwd, env, detached: true, stdio: ["pipe", "pipe", "pipe"] });
25
+ let output = "", bytes = 0, invalid = false, admitted = false;
26
+ const stop = () => { invalid = true; void stopOwnedProcess(child, { group: true }).catch(() => {}); };
27
+ // ponytail: one five-second metadata cut lease, never a build lease. Heavy preparation runs through its own admission.
28
+ const timer = setTimeout(() => { stop(); resolve({ admitted: false, reason: "host resource admission timed out" }); }, 5000);
29
+ child.stdin.on("error", () => {});
30
+ child.stdout.setEncoding("utf8");
31
+ child.stdout.on("data", chunk => {
32
+ if (invalid) return;
33
+ output += chunk; bytes += Buffer.byteLength(chunk);
34
+ if (bytes > 16 * 1024 || !READY.startsWith(output) && output !== READY) { stop(); resolve({ admitted: false, reason: "host resource admission unknown" }); return; }
35
+ if (!admitted && output === READY && !locked()) {
36
+ admitted = true;
37
+ resolve({ admitted: true, current: () => !invalid && child.exitCode === null && child.signalCode === null && !locked(), close: async () => {
38
+ clearTimeout(timer);
39
+ if (child.exitCode === null && child.signalCode === null) {
40
+ await new Promise<void>(done => {
41
+ const grace = setTimeout(done, 500);
42
+ child.once("close", () => { clearTimeout(grace); done(); });
43
+ child.stdin.end();
44
+ });
45
+ }
46
+ await stopOwnedProcess(child, { group: true });
47
+ } });
48
+ }
49
+ });
50
+ child.stderr.on("data", (chunk: Buffer) => { bytes += chunk.length; if (bytes > 16 * 1024) stop(); });
51
+ child.once("error", () => { invalid = true; clearTimeout(timer); resolve({ admitted: false, reason: "host resource controller unavailable" }); });
52
+ child.once("close", async code => {
53
+ clearTimeout(timer); invalid = true;
54
+ if (admitted) return;
55
+ if (code === 75) { resolve({ admitted: false, reason: "host resource admission deferred (busy repository or pressure)" }); return; }
56
+ // Diagnose a failed invocation with the same bounded identity probe used by the tooling inventory (#351).
57
+ // This readback never grants admission or substitutes for the controller's held-lease handshake.
58
+ const tooling = await detectTooling("hub", { cwd, path: dirname(executable) }).catch(() => undefined);
59
+ resolve({ admitted: false, reason: tooling && !tooling.some(addon => addon.name === "dot-admit")
60
+ ? "host resource controller unavailable (dot is not the resource controller; install dot-admit or fix PATH)"
61
+ : "host resource admission unknown" });
62
+ });
63
+ });
64
+ }
@@ -0,0 +1,175 @@
1
+ import { childEnv } from "./child-process.ts";
2
+ import { hubGitSync } from "./git.ts";
3
+
4
+ /**
5
+ * #520: the review brief ahub builds itself, in place of `ocr delegate preview` and `rule`. Every reviewer gets the same
6
+ * one: the changed files of the head against its merge base with the checkout's HEAD, the files left out and why, the
7
+ * review rules that apply to each file, and the files the plain name list shows that the brief does not. Only git is
8
+ * read, bounded; nothing here touches the board.
9
+ */
10
+ export interface BriefFile { path: string; status: string; oldPath?: string; insertions?: number; deletions?: number }
11
+ export interface BriefExcluded { path: string; status: string; oldPath?: string; reason: string }
12
+ export interface ReviewBrief {
13
+ head: string;
14
+ /** The default branch the head is compared with: origin/HEAD, origin/main or origin/master. */
15
+ base: string;
16
+ mergeBase: string;
17
+ reviewable: BriefFile[];
18
+ excluded: BriefExcluded[];
19
+ /** Changes left out by the privacy policy, both sides of a move included; counted, never named. */
20
+ withheld: number;
21
+ /** Rule files by name (REVIEW.md, then `ahub-review-rules` attribute values) with their text at the merge base. */
22
+ rules: { source: string; text: string }[];
23
+ /** Configured rule files the brief could not load, with why: the reviewer reads them in the repository. */
24
+ rulesMissing: { source: string; reason: string }[];
25
+ /** Reviewable paths grouped by the rule sources that apply to them. */
26
+ ruleGroups: { sources: string[]; paths: string[] }[];
27
+ /** Paths in the plain name list of the range that the brief does not list: read them directly. */
28
+ unlisted: string[];
29
+ }
30
+ export interface BriefPolicy { privatePath(path: string): boolean; nameable(text: string): boolean }
31
+
32
+ const MAX_FILES = 500, RULE_BYTES = 32_000, MAX_RULE_FILES = 8;
33
+ const LOCKFILES = new Set(["bun.lock", "bun.lockb", "package-lock.json", "yarn.lock", "pnpm-lock.yaml", "Cargo.lock", "poetry.lock", "uv.lock", "go.sum", "composer.lock", "Gemfile.lock"]);
34
+ const HEX = /^[0-9a-f]{7,40}$/i;
35
+
36
+ /** Every diff of the brief reads attributes from the trusted tree, never the checkout (a head's `* -diff` cannot mark its own files binary). */
37
+ const attrFrom = (source?: string) => source ? [`--attr-source=${source}`] : [];
38
+ const git = (cwd: string, args: string[]) => hubGitSync(["-C", cwd, ...args], { encoding: "utf8", env: childEnv(process.env), maxBuffer: 16 * 1024 * 1024, timeout: 15_000 });
39
+
40
+ /**
41
+ * The changes of `HEAD...head` as groups: one path, or both sides of a rename or copy. A caller that must not show a
42
+ * private file withholds a whole group when any side is private, so content never shows up under a public name.
43
+ * ponytail: git's 50% similarity decides what counts as a move.
44
+ */
45
+ export function changedPathGroups(cwd: string, head: string, base = "HEAD", attrSource?: string): { status: string; paths: string[] }[] | undefined {
46
+ const r = git(cwd, [...attrFrom(attrSource), "diff", "--no-ext-diff", "--no-textconv", "--relative", "-M", "-C", "--find-copies-harder", "--name-status", "-z", `${base}...${head}`, "--"]);
47
+ if (r.status !== 0 || typeof r.stdout !== "string") return undefined;
48
+ const fields = r.stdout.split("\0"), groups: { status: string; paths: string[] }[] = [];
49
+ for (let i = 0; i < fields.length - 1;) {
50
+ const status = fields[i++]!, sides = /^[RC]/.test(status) ? 2 : 1;
51
+ groups.push({ status, paths: fields.slice(i, i + sides) }); i += sides;
52
+ }
53
+ return groups;
54
+ }
55
+
56
+ export function buildReviewBrief(cwd: string, head: unknown, policy: BriefPolicy): ReviewBrief | { unavailable: string } {
57
+ if (typeof head !== "string" || !HEX.test(head)) return { unavailable: "the task names no commit" };
58
+ const full = git(cwd, ["rev-parse", "--verify", "-q", `${head}^{commit}`]);
59
+ if (full.status !== 0 || typeof full.stdout !== "string") return { unavailable: `commit ${head} is not in this checkout` };
60
+ const commit = full.stdout.trim();
61
+ // A reliable base only: the remote's default branch. The checkout's HEAD may be the head itself (an empty inventory)
62
+ // or another session's branch.
63
+ const from = ["origin/HEAD", "origin/main", "origin/master"].find(ref => git(cwd, ["rev-parse", "--verify", "-q", `${ref}^{commit}`]).status === 0);
64
+ if (!from) return { unavailable: "no default branch (origin/HEAD, origin/main or origin/master) to compare with" };
65
+ const base = git(cwd, ["merge-base", from, commit]);
66
+ if (base.status !== 0 || typeof base.stdout !== "string") return { unavailable: `commit ${head} shares no history with ${from}` };
67
+ const mergeBase = base.stdout.trim();
68
+ const groups = changedPathGroups(cwd, commit, from, mergeBase);
69
+ if (!groups) return { unavailable: `the changes of ${head} could not be read` };
70
+ if (!groups.length) return { unavailable: `commit ${head} has no changes against ${from}` };
71
+ const shown = groups.filter(g => !g.paths.some(path => policy.privatePath(path) || !policy.nameable(path)));
72
+ if (shown.length > MAX_FILES) return { unavailable: `${shown.length} changed files, over the brief's ${MAX_FILES}` };
73
+ const withheld = groups.length - shown.length;
74
+ const counts = numstat(cwd, commit, from, mergeBase);
75
+ // Attributes and rules come from the merge base: a change cannot lift its own review obligations (a head that marks
76
+ // everything generated, or edits REVIEW.md, is still reviewed under the rules it is merging into).
77
+ // Both sides of a move: renaming a reviewed file to a generated or unscoped name must not shed its obligations.
78
+ const attrs = attributes(cwd, mergeBase, [...new Set(shown.flatMap(g => g.paths))]);
79
+ if (!counts || !attrs) return { unavailable: `the file details of ${head} could not be read` };
80
+ const reviewable: BriefFile[] = [], excluded: BriefExcluded[] = [];
81
+ for (const g of shown) {
82
+ const path = g.paths.at(-1)!, count = counts.get(path);
83
+ const sides = { status: g.status[0]!, ...(g.paths.length === 2 ? { oldPath: g.paths[0]! } : {}) };
84
+ // Excluded only when every side is: a move keeps the source's obligation.
85
+ const sideReason = (p: string) => attrs.get(p)?.generated ? "generated (linguist-generated)" : LOCKFILES.has(p.split("/").at(-1)!) ? "lockfile" : undefined;
86
+ const reasons = g.paths.map(sideReason);
87
+ const reason = count?.binary ? "binary" : reasons.every(Boolean) ? reasons.at(-1) : undefined;
88
+ if (reason) { excluded.push({ path, ...sides, reason }); continue; }
89
+ reviewable.push({ path, ...sides, ...(count && !count.binary ? { insertions: count.insertions, deletions: count.deletions } : {}) });
90
+ }
91
+ const ruleText = new Map<string, string>(), rulesMissing = new Map<string, string>();
92
+ const readRule = (source: string): boolean => {
93
+ if (ruleText.has(source)) return true;
94
+ if (rulesMissing.has(source)) return false;
95
+ // A private or non-nameable rule path is not named either: it is left out silently.
96
+ if (policy.privatePath(source) || !policy.nameable(source)) return false;
97
+ if (ruleText.size >= MAX_RULE_FILES) { rulesMissing.set(source, `over the brief's ${MAX_RULE_FILES} rule files`); return false; }
98
+ const r = git(cwd, ["show", `${mergeBase}:${source}`]);
99
+ if (r.status !== 0 || typeof r.stdout !== "string") { rulesMissing.set(source, "not found at the merge base"); return false; }
100
+ // Screened whole, before any cut: a rule file that matches a private-data pattern is named but not shown.
101
+ if (!policy.nameable(r.stdout)) { ruleText.set(source, "(withheld: it matches a private-data pattern; read it in the repository)"); return true; }
102
+ const bytes = Buffer.from(r.stdout, "utf8");
103
+ ruleText.set(source, bytes.length > RULE_BYTES ? `${bytes.subarray(0, RULE_BYTES).toString("utf8")}\n[cut at ${RULE_BYTES} bytes]` : r.stdout);
104
+ return true;
105
+ };
106
+ const root = readRule("REVIEW.md");
107
+ const byKey = new Map<string, { sources: string[]; paths: string[] }>();
108
+ for (const file of reviewable) {
109
+ // The rules of every side apply, so a move out of a scoped directory keeps its rule file.
110
+ const scoped = [...new Set([file.oldPath, file.path].flatMap(p => { const r = p ? attrs.get(p)?.rules : undefined; return r ? [r] : []; }))];
111
+ const sources = [...(root ? ["REVIEW.md"] : []), ...scoped.filter(readRule)];
112
+ const key = sources.join("\0");
113
+ (byKey.get(key) ?? byKey.set(key, { sources, paths: [] }).get(key)!).paths.push(file.path);
114
+ }
115
+ // Cross-check with the plain name list (no rename detection), as OCR's list is checked against the PR's.
116
+ const plain = git(cwd, [...attrFrom(mergeBase), "diff", "--relative", "--no-renames", "--name-only", "-z", `${from}...${commit}`, "--"]);
117
+ if (plain.status !== 0 || typeof plain.stdout !== "string") return { unavailable: `the name list of ${head} could not be read` };
118
+ const listed = new Set(shown.flatMap(g => g.paths)), hidden = new Set(groups.filter(g => !shown.includes(g)).flatMap(g => g.paths));
119
+ const unlisted = plain.stdout.split("\0").filter(path => path && !listed.has(path) && !hidden.has(path));
120
+ return { head: commit, base: from, mergeBase, reviewable, excluded, withheld, rules: [...ruleText].map(([source, text]) => ({ source, text })), rulesMissing: [...rulesMissing].map(([source, reason]) => ({ source, reason })), ruleGroups: [...byKey.values()], unlisted };
121
+ }
122
+
123
+ function numstat(cwd: string, head: string, base: string, attrSource: string): Map<string, { insertions: number; deletions: number; binary: boolean }> | undefined {
124
+ const out = new Map<string, { insertions: number; deletions: number; binary: boolean }>();
125
+ const r = git(cwd, [...attrFrom(attrSource), "diff", "--no-ext-diff", "--no-textconv", "--relative", "-M", "-C", "--find-copies-harder", "--numstat", "-z", `${base}...${head}`, "--"]);
126
+ if (r.status !== 0 || typeof r.stdout !== "string") return undefined;
127
+ // -z: "ins\tdel\tpath\0", or for a move "ins\tdel\t\0old\0new\0"; a binary file has "-" counts.
128
+ const fields = r.stdout.split("\0");
129
+ for (let i = 0; i < fields.length - 1;) {
130
+ const [ins = "", del = "", path = ""] = fields[i++]!.split("\t");
131
+ const target = path || (i++, fields[i++] ?? "");
132
+ out.set(target, { insertions: Number(ins) || 0, deletions: Number(del) || 0, binary: ins === "-" });
133
+ }
134
+ return out;
135
+ }
136
+
137
+ function attributes(cwd: string, source: string, paths: string[]): Map<string, { generated?: boolean; rules?: string }> | undefined {
138
+ const out = new Map<string, { generated?: boolean; rules?: string }>();
139
+ if (!paths.length) return out;
140
+ const r = hubGitSync(["-C", cwd, "check-attr", "-z", "--stdin", `--source=${source}`, "linguist-generated", "ahub-review-rules"], { input: paths.join("\0") + "\0", encoding: "utf8", env: childEnv(process.env), maxBuffer: 4 * 1024 * 1024, timeout: 15_000 });
141
+ if (r.status !== 0 || typeof r.stdout !== "string") return undefined;
142
+ const f = r.stdout.split("\0");
143
+ for (let i = 0; i + 2 < f.length; i += 3) {
144
+ const [path, attr, value] = [f[i]!, f[i + 1]!, f[i + 2]!], entry = out.get(path) ?? {};
145
+ if (attr === "linguist-generated" && (value === "set" || value === "true")) entry.generated = true;
146
+ if (attr === "ahub-review-rules" && value !== "unspecified" && value !== "unset" && value !== "set" && !value.startsWith("/") && !value.split("/").includes("..")) entry.rules = value;
147
+ out.set(path, entry);
148
+ }
149
+ return out;
150
+ }
151
+
152
+ /** The brief as review text: what every reviewer reads and how it accounts for its coverage. */
153
+ export function renderReviewBrief(brief: ReviewBrief): string {
154
+ const file = (f: BriefFile) => `- ${f.oldPath ? `${f.oldPath} -> ` : ""}${f.path} (${f.status}${f.insertions !== undefined ? `, +${f.insertions} -${f.deletions}` : ""})`;
155
+ return [
156
+ `Review brief: head ${brief.head} against ${brief.base} (merge base ${brief.mergeBase.slice(0, 12)}); ${brief.reviewable.length} reviewable, ${brief.excluded.length} excluded${brief.withheld ? `, ${brief.withheld} withheld by the privacy policy` : ""}.`,
157
+ ...(brief.reviewable.length ? ["Reviewable:", ...brief.reviewable.map(file)] : ["Reviewable: none (record why in the note)."]),
158
+ ...(brief.excluded.length ? ["Excluded (still read their diff when it matters):", ...brief.excluded.map(e => `- ${e.oldPath ? `${e.oldPath} -> ` : ""}${e.path} (${e.status}): ${e.reason}`)] : []),
159
+ ...(brief.unlisted.length ? ["Not in the brief's list, read directly:", ...brief.unlisted.map(path => `- ${path}`)] : []),
160
+ ...brief.ruleGroups.filter(g => g.sources.length).map(g => `Rules ${g.sources.join(" + ")} apply to: ${brief.ruleGroups.length === 1 ? "every reviewable file" : g.paths.join(", ")}`),
161
+ ...(brief.rulesMissing.length ? ["Configured rules not loaded (read them in the repository):", ...brief.rulesMissing.map(m => `- ${m.source}: ${m.reason}`)] : []),
162
+ ...brief.rules.map(r => `--- ${r.source} ---\n${r.text.trimEnd()}`),
163
+ `Coverage: pass hub_review coverage {reviewed: [paths], skipped: [{path, reason}]} naming every reviewable path above.`,
164
+ ].join("\n");
165
+ }
166
+
167
+ /** The reviewable paths a verdict's coverage does not account for: reviewed, or skipped with a reason. */
168
+ export function coverageGap(brief: ReviewBrief, coverage: unknown): string[] {
169
+ const c = coverage && typeof coverage === "object" && !Array.isArray(coverage) ? coverage as { reviewed?: unknown; skipped?: unknown } : {};
170
+ const accounted = new Set<string>([
171
+ ...(Array.isArray(c.reviewed) ? c.reviewed.filter((p): p is string => typeof p === "string") : []),
172
+ ...(Array.isArray(c.skipped) ? c.skipped.flatMap(s => s && typeof s === "object" && typeof (s as { path?: unknown }).path === "string" && typeof (s as { reason?: unknown }).reason === "string" && (s as { reason: string }).reason.trim() ? [(s as { path: string }).path] : []) : []),
173
+ ]);
174
+ return brief.reviewable.map(f => f.path).filter(path => !accounted.has(path));
175
+ }
@@ -0,0 +1,11 @@
1
+ export interface ReviewerPoolConfig { pool: string[]; rerotate: boolean }
2
+
3
+ /** Kind order is project policy; capability and isolation evidence remain host-owned registry facts. */
4
+ export function reviewerPoolConfig(raw: unknown, defaults: readonly string[], known: readonly string[]): ReviewerPoolConfig {
5
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("review must be an object");
6
+ const value = raw as Record<string, unknown>;
7
+ const pool = value.pool === undefined ? defaults : value.pool;
8
+ if (!Array.isArray(pool) || pool.length < 1 || pool.length > 16 || pool.some(kind => typeof kind !== "string" || !/^[a-z][a-z0-9-]{0,31}$/.test(kind) || !known.includes(kind)) || new Set(pool).size !== pool.length) throw new Error("review.pool must list 1..16 distinct known kinds");
9
+ if (value.rerotate !== undefined && typeof value.rerotate !== "boolean") throw new Error("review.rerotate must be a boolean");
10
+ return { pool: [...pool], rerotate: value.rerotate === true };
11
+ }