@staix/agent-hub 0.12.34 → 0.12.36

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 (91) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +8 -6
  3. package/docs/agent-notes/adapters.md +6 -1
  4. package/docs/agent-notes/budget.md +4 -2
  5. package/docs/agent-notes/bus.md +5 -1
  6. package/docs/agent-notes/daemon.md +11 -3
  7. package/docs/agent-notes/local-worker.md +1 -1
  8. package/docs/agent-notes/tasks.md +15 -2
  9. package/docs/agent-notes/tests.md +5 -1
  10. package/docs/gsd-collaboration-analysis.md +5 -1
  11. package/docs/operations.md +97 -19
  12. package/docs/quickstart.md +8 -4
  13. package/docs/sdlc.md +100 -34
  14. package/docs/security.md +7 -3
  15. package/docs/specs/2026-09-19-agent-hub-design.md +237 -7
  16. package/docs/verification/2026-10-18-0.12.35.md +49 -0
  17. package/docs/verification/2026-10-18-493-claude-busy-channel.md +91 -0
  18. package/docs/verification/2026-10-19-0.12.36.md +63 -0
  19. package/docs/verified.json +26 -17
  20. package/package.json +1 -1
  21. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  22. package/plugins/agent-hub/server.js +15781 -14850
  23. package/src/adapters/acp.ts +3 -1
  24. package/src/adapters/claude-channel.ts +59 -29
  25. package/src/adapters/claude-print.ts +230 -0
  26. package/src/adapters/codex-appserver.ts +1 -1
  27. package/src/adapters/definitions/builtin-metadata.ts +17 -1
  28. package/src/adapters/definitions/native-cli-entry-claude.ts +17 -3
  29. package/src/adapters/definitions/native-cli-entry-codex.ts +13 -5
  30. package/src/adapters/definitions/native-cli-entry-jcode.ts +6 -2
  31. package/src/adapters/definitions/native-cli-entry-kimi.ts +18 -12
  32. package/src/adapters/definitions/native-cli-entry-local.ts +3 -2
  33. package/src/adapters/definitions/native-cli-entry-pi.ts +8 -6
  34. package/src/adapters/definitions/native-cli-entry-qwen.ts +3 -2
  35. package/src/adapters/definitions/native-cli-entry-types.ts +3 -0
  36. package/src/adapters/definitions/native-factories.ts +7 -0
  37. package/src/adapters/definitions/native-recovery.ts +2 -2
  38. package/src/adapters/definitions/qwen-native.ts +2 -2
  39. package/src/adapters/local-worker.ts +3 -2
  40. package/src/adapters/pi.ts +3 -1
  41. package/src/adapters/registry-types.ts +11 -2
  42. package/src/adapters/registry.ts +36 -8
  43. package/src/cli/help.ts +5 -0
  44. package/src/cli/identity.ts +5 -4
  45. package/src/cli/launch.ts +45 -7
  46. package/src/cli/main.ts +136 -29
  47. package/src/cli/preview.ts +16 -5
  48. package/src/cli/recovery-guidance.ts +39 -22
  49. package/src/cli/reset.ts +4 -2
  50. package/src/cli/statusline-tee.ts +5 -2
  51. package/src/cli/terminal-recovery.ts +73 -16
  52. package/src/cli/upgrade-restore-state.ts +16 -7
  53. package/src/cli/upgrade-runtime.ts +83 -52
  54. package/src/cli/upgrade.ts +43 -27
  55. package/src/hub/board.ts +29 -7
  56. package/src/hub/budget.ts +55 -3
  57. package/src/hub/bus.ts +12 -6
  58. package/src/hub/claude-state.ts +37 -0
  59. package/src/hub/conductor.ts +16 -10
  60. package/src/hub/config-apply.ts +13 -2
  61. package/src/hub/config-reload.ts +11 -1
  62. package/src/hub/config-trust.ts +1 -1
  63. package/src/hub/control-client.ts +1 -1
  64. package/src/hub/cost-governance.ts +76 -0
  65. package/src/hub/crash.ts +14 -5
  66. package/src/hub/daemon.ts +473 -194
  67. package/src/hub/execution-budget.ts +3 -3
  68. package/src/hub/hub-tools.ts +36 -6
  69. package/src/hub/instance-budget.ts +73 -0
  70. package/src/hub/instance-config.ts +47 -0
  71. package/src/hub/instance-execution.ts +50 -0
  72. package/src/hub/instances.ts +114 -0
  73. package/src/hub/lifecycle-schema.ts +7 -3
  74. package/src/hub/permission-mode.ts +7 -7
  75. package/src/hub/review-brief.ts +175 -0
  76. package/src/hub/roster.ts +7 -6
  77. package/src/hub/routing.ts +97 -18
  78. package/src/hub/sdlc-queue.ts +2 -2
  79. package/src/hub/sdlc.ts +286 -39
  80. package/src/hub/settings.ts +34 -6
  81. package/src/hub/snapshots.ts +40 -9
  82. package/src/hub/start-mode.ts +7 -4
  83. package/src/hub/status-roster.ts +18 -0
  84. package/src/hub/task-sweep.ts +143 -0
  85. package/src/hub/tasks.ts +595 -47
  86. package/src/hub/text-projection.ts +7 -3
  87. package/src/hub/usage.ts +57 -20
  88. package/src/hub/workflow.ts +30 -52
  89. package/src/pi/launch.ts +4 -3
  90. package/src/ui/index.html +1 -1
  91. package/templates/AGENTS.block.md +2 -1
@@ -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
+ }
package/src/hub/roster.ts CHANGED
@@ -1,14 +1,14 @@
1
- import { definitionOf } from "../adapters/registry.ts";
1
+ import { definitionOf, type KindMap } from "../adapters/registry.ts";
2
2
  import { defaultRosterEnabled } from "../adapters/registry.ts";
3
3
 
4
4
  export type PeerRoster = Partial<Record<string, boolean>>;
5
5
 
6
- export function peerRosterConfig(value: unknown): PeerRoster {
6
+ export function peerRosterConfig(value: unknown, instances?: KindMap): PeerRoster {
7
7
  if (value === undefined) return {};
8
8
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("roster must be an object of boolean peer values");
9
9
  const roster: PeerRoster = {};
10
10
  for (const [peer, on] of Object.entries(value)) {
11
- if (!definitionOf(peer)) throw new Error("roster contains an unsupported peer");
11
+ if (!definitionOf(peer, instances)) throw new Error("roster contains an unsupported peer");
12
12
  if (typeof on !== "boolean") throw new Error(`roster.${peer} must be a boolean`);
13
13
  roster[peer] = on;
14
14
  }
@@ -16,7 +16,8 @@ export function peerRosterConfig(value: unknown): PeerRoster {
16
16
  }
17
17
 
18
18
  /** External ids keep their existing admission rules; Pi's legacy switch is only its default. */
19
- export function rosterAllows(roster: PeerRoster, peer: string, piEnabled: boolean): boolean {
20
- if (!definitionOf(peer)) return true;
21
- return roster[peer] ?? defaultRosterEnabled(peer, piEnabled);
19
+ export function rosterAllows(roster: PeerRoster, peer: string, piEnabled: boolean, instances?: KindMap): boolean {
20
+ const definition = definitionOf(peer, instances);
21
+ if (!definition) return true; // External clients do not acquire a native kind from a project declaration.
22
+ return roster[peer] ?? (definition.kind === peer ? defaultRosterEnabled(peer, piEnabled) : true);
22
23
  }
@@ -1,4 +1,4 @@
1
- import { ADAPTERS, definitionOf } from "../adapters/registry.ts";
1
+ import { ADAPTERS, definitionOf, kindOf, familyOf } from "../adapters/registry.ts";
2
2
  import { readFileSync, statSync } from "node:fs";
3
3
  import { join, resolve } from "node:path";
4
4
  import { CLASSES, type Task, type TaskClass } from "./board.ts";
@@ -109,14 +109,54 @@ const TEMPLATE = join(import.meta.dir, "..", "..", "templates", "routing.toml");
109
109
  export const LOCAL: PeerId = "local";
110
110
  export const PI: PeerId = "pi";
111
111
 
112
+ /** Startup-bound project data, never inferred from a suffix or read from configuration here. */
113
+ export interface InstanceRoutingOptions {
114
+ instances?: Readonly<Record<string, { kind: string }>>;
115
+ wip?: Record<string, number>;
116
+ load?: Record<string, number>;
117
+ /** Positive host verification of the live extra runtime's noncanonical execution root. */
118
+ ownerIsolation?: Readonly<Record<string, boolean>>;
119
+ }
120
+ const hasInstances = (opts: InstanceRoutingOptions): boolean => !!opts.instances && Object.keys(opts.instances).length > 0;
121
+ function siblingOrder(states: Readonly<Record<string, PeerState>>, opts: InstanceRoutingOptions): (a: string, b: string) => number {
122
+ const count = (map: Record<string, number> | undefined, id: string) => {
123
+ const value = peerEntry(map, id);
124
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0;
125
+ };
126
+ return (a, b) => Number(states[a] !== "idle") - Number(states[b] !== "idle") || count(opts.wip, a) - count(opts.wip, b) || count(opts.load, a) - count(opts.load, b);
127
+ }
128
+ /** A class kind expands once; an explicitly declared instance remains an exact id. */
129
+ function expandClassPeers(peers: readonly PeerId[], states: Readonly<Record<string, PeerState>>, opts: InstanceRoutingOptions): PeerId[] {
130
+ if (!hasInstances(opts)) return [...peers];
131
+ return [...new Set(peers.flatMap(peer => {
132
+ const kind = kindOf(peer, opts.instances);
133
+ return kind && kind === peer ? [peer, ...Object.keys(opts.instances!).filter(id => kindOf(id, opts.instances) === kind)].sort(siblingOrder(states, opts)) : [peer];
134
+ }))];
135
+ }
136
+ /** Quota/adaptive policy retains kind slots; sibling workload chooses only within that kind's slots. */
137
+ function orderSiblingSlots(peers: PeerId[], states: Readonly<Record<string, PeerState>>, opts: InstanceRoutingOptions): PeerId[] {
138
+ if (!hasInstances(opts)) return peers;
139
+ const groups = new Map<string, number[]>(), out = [...peers];
140
+ for (const [index, peer] of peers.entries()) {
141
+ const kind = kindOf(peer, opts.instances);
142
+ if (kind) { const slots = groups.get(kind) ?? []; slots.push(index); groups.set(kind, slots); }
143
+ }
144
+ for (const slots of groups.values()) {
145
+ const sorted = slots.map(index => peers[index]!).sort(siblingOrder(states, opts));
146
+ slots.forEach((index, position) => { out[index] = sorted[position]!; });
147
+ }
148
+ return out;
149
+ }
150
+
112
151
  /** The quota/roster relay keeps its native preference order, then the class's original tail. */
113
- export function relayCandidates(classPeers: readonly PeerId[], pii: boolean): PeerId[] {
152
+ export function relayCandidates(classPeers: readonly PeerId[], pii: boolean, opts: InstanceRoutingOptions & { states?: Readonly<Record<string, PeerState>> } = {}): PeerId[] {
114
153
  const preferred = ADAPTERS.filter(adapter => adapter.routing?.handoffPriority !== undefined)
115
154
  .sort((a, b) => a.routing!.handoffPriority! - b.routing!.handoffPriority!);
116
- return [
155
+ const candidates = [
117
156
  ...preferred.filter(adapter => !pii || adapter.trust.onPrem).map(adapter => adapter.kind),
118
- ...classPeers.filter(peer => definitionOf(peer)?.routing?.handoffPriority === undefined),
157
+ ...classPeers.filter(peer => definitionOf(peer, opts.instances)?.routing?.handoffPriority === undefined),
119
158
  ].filter((peer): peer is PeerId => !!peer);
159
+ return expandClassPeers(candidates, opts.states ?? {}, opts);
120
160
  }
121
161
 
122
162
  /**
@@ -245,9 +285,13 @@ export function detectSignals(task: Pick<Task, "title" | "detail" | "refs">, rou
245
285
  /** A peer's entry in a peer-indexed map: own keys only, so a peer named `constructor` is never an inherited Object member. */
246
286
  export const peerEntry = <T>(map: Readonly<Record<string, T>> | undefined, peer: string): T | undefined => (map && Object.hasOwn(map, peer) ? map[peer] : undefined);
247
287
 
248
- export type RoutingRefusalCode = "tests" | "spec" | "roster" | "pull-only" | "not-attached" | "excluded" | "owner-admission" | "offline" | "paused" | "failing" | "pii" | "policy";
288
+ export type RoutingRefusalCode = "instance" | "family" | "tests" | "spec" | "roster" | "pull-only" | "not-attached" | "excluded" | "owner-admission" | "offline" | "paused" | "failing" | "pii" | "policy";
249
289
  export interface RoutingRefusal { peer: PeerId; role: "owner" | "reviewer"; code: RoutingRefusalCode; reason: string }
250
290
  export interface Assignment {
291
+ /** A same-family reviewer was chosen only because no other family was eligible. */
292
+ sameFamilyFallback?: true;
293
+ /** #223 R5: the planner reviews because the declared reviewer-only instance was not eligible. */
294
+ plannerFallback?: true;
251
295
  refusals?: RoutingRefusal[];
252
296
  owner?: PeerId;
253
297
  reviewer?: PeerId;
@@ -268,7 +312,7 @@ export function assign(
268
312
  task: Pick<Task, "class" | "signals" | "reserved"> & Partial<Pick<Task, "state" | "title" | "detail" | "refs" | "external">>,
269
313
  states: Record<PeerId, PeerState>,
270
314
  routing: Routing,
271
- opts: {
315
+ opts: InstanceRoutingOptions & {
272
316
  capabilities?: Record<PeerId, PeerCapabilities>;
273
317
  repository?: string;
274
318
  exclude?: PeerId[];
@@ -301,13 +345,14 @@ export function assign(
301
345
  ): Assignment {
302
346
  if (task.state === "cancelled" || task.state === "approved") return { trace: [`${task.state}: closed; not routed`] };
303
347
  if (task.state === "backlog") return { trace: ["backlog: not routed; pull into To Do first"] };
348
+ const instanceMode = hasInstances(opts);
304
349
  const policy = routing.classes[task.class];
305
350
  const trace: string[] = [`class ${task.class}${policy ? "" : " (no [classes] entry: only an explicit owner can take it)"}`, `signals: ${task.signals.join(", ") || "none"}`];
306
351
  // Which file each of this class's values comes from (#269), only when the machine-local overlay sets one.
307
352
  const local = Object.keys(routing.sources ?? {}).filter((key) => key.startsWith(`classes.${task.class}.`)).map((key) => key.slice(`classes.${task.class}.`.length));
308
353
  if (local.length) trace.push(`policy source: ${local.join(", ")} from ${OVERLAY_FILE}; the rest from routing.toml`);
309
354
  // A reserved owner (issue #207) is offered the task first. Named candidates (an assign, an escalation, a relay) replace it.
310
- const reserved = opts.candidates ? undefined : task.reserved ?? undefined;
355
+ const reserved = opts.candidates || instanceMode && opts.namedOwner !== undefined ? undefined : task.reserved ?? undefined;
311
356
  // Readiness is an input like peer states (issue #34): a task that waits for others goes to nobody yet.
312
357
  if (opts.waitsFor?.length) return { trace: [...trace, `blocked: waits for ${opts.waitsFor.map((id) => `#${id}`).join(", ")} (not approved)`, ...(reserved ? [`reserved owner ${reserved}: offered first once it is ready`] : [])] };
313
358
  const pii = task.signals.includes("pii_path") || (task.signals.includes("pii") && routing.constraints.pii === "local_only");
@@ -330,12 +375,18 @@ export function assign(
330
375
  if (!Object.hasOwn(states, peer)) return deny("not-attached", "not attached");
331
376
  if (opts.exclude?.includes(peer)) return deny("excluded", "excluded (declined or replaced)");
332
377
  if (role === "owner" && opts.excludeOwners?.includes(peer)) return deny("owner-admission", "owner admission: WIP or delivery hold");
378
+ const adapter = definitionOf(peer, opts.instances);
379
+ if (role === "owner" && adapter?.routing?.reviewOnly) return deny("owner-admission", "owner admission: a review-only instance owns no task");
380
+ if (role === "owner" && needsTests && adapter && adapter.kind !== peer) {
381
+ if (!peerEntry(opts.roles, peer)?.includes("implementer")) return deny("owner-admission", "owner admission: declared write owner requires the current implementer role");
382
+ if (peerEntry(opts.ownerIsolation, peer) !== true) return deny("owner-admission", "owner admission: declared write owner requires a verified isolated execution root");
383
+ }
333
384
  if (states[peer] === "offline") return deny("offline", "offline");
334
385
  if (states[peer] === "paused" && routing.constraints.budget_paused === "skip_peer") return deny("paused", "paused");
335
386
  const failing = peerEntry(opts.failing, peer);
336
387
  if (failing) return deny("failing", `failing: ${failing}`);
337
- if (pii && definitionOf(peer)?.trust.onPrem !== true) return deny("pii", "pii: on-prem peers only");
338
- const nativeRouting = definitionOf(peer)?.routing;
388
+ if (pii && definitionOf(peer, opts.instances)?.trust.onPrem !== true) return deny("pii", "pii: on-prem peers only");
389
+ const nativeRouting = definitionOf(peer, opts.instances)?.routing;
339
390
  if (nativeRouting?.localAllowedRefusal && policy?.local_allowed === false) return deny("policy", nativeRouting.localAllowedRefusal);
340
391
  if (nativeRouting?.longContext === "backend-limit" && task.signals.includes("long_context")) {
341
392
  const backend = policy?.pi_backend ?? "dgx";
@@ -383,16 +434,16 @@ export function assign(
383
434
  return role === "reviewer" ? byRecord(ranked, owner) : ranked;
384
435
  };
385
436
 
386
- const pick = (list: PeerId[], role: "owner" | "reviewer", not?: PeerId): PeerId | undefined => {
437
+ const pick = (list: PeerId[], role: "owner" | "reviewer", not?: PeerId, sortSiblings = false): PeerId | undefined => {
387
438
  const ok: PeerId[] = [];
388
439
  for (const peer of list) {
389
440
  const why = peer === not ? "is the owner" : blocked(peer, role);
390
441
  trace.push(` ${role} candidate ${peer}: ${why ? `skipped, ${why}` : states[peer]}`);
391
442
  if (!why) ok.push(peer);
392
443
  }
393
- const ranked = rank(ok, role, not);
444
+ const ranked = sortSiblings ? orderSiblingSlots(rank(ok, role, not), states, opts) : rank(ok, role, not);
394
445
  // Demotion applies to local and Pi too: a demoted one loses its place ahead of the cloud peers.
395
- const localTier = ranked.filter((p) => definitionOf(p)?.routing?.preferredTier === true && !(role === "owner" && peerEntry(opts.demoted, p)));
446
+ const localTier = ranked.filter((p) => definitionOf(p, opts.instances)?.routing?.preferredTier === true && !(role === "owner" && peerEntry(opts.demoted, p)));
396
447
  if (localTier.length) return localTier.find((p) => states[p] === "idle") ?? localTier[0]; // local/Pi stays ahead of an idle cloud peer
397
448
  return ranked.find((p) => states[p] === "idle") ?? ranked[0];
398
449
  };
@@ -401,13 +452,16 @@ export function assign(
401
452
  const unreserved = reserved ? blocked(reserved, "owner") : undefined;
402
453
  if (reserved) trace.push(`reserved owner ${reserved}: ${unreserved ? `not honored, ${unreserved}; routing proceeds` : "honored"}`);
403
454
  // Never the task's current owner by default: a decline or an escalation has to reach the next peer in the list.
404
- const wanted = reserved && !unreserved ? [reserved] : opts.candidates ?? policy?.peers ?? [];
405
- const owner = pick(wanted, "owner");
455
+ const automaticOwner = !(reserved && !unreserved) && opts.candidates === undefined && !(instanceMode && opts.namedOwner !== undefined);
456
+ const wanted = reserved && !unreserved ? [reserved] : opts.candidates ?? (instanceMode && opts.namedOwner !== undefined ? [opts.namedOwner] : expandClassPeers(policy?.peers ?? [], states, opts));
457
+ const owner = pick(wanted, "owner", undefined, automaticOwner);
406
458
  trace.push(owner ? `owner: ${owner}` : "owner: none available, task stays proposed (ahub task assign <id> <peer>)");
407
459
  const ownerHold = owner ? peerEntry(opts.held, owner) : undefined;
408
460
  if (ownerHold) trace.push(` hold: ${owner}'s queue is held: ${ownerHold} (it receives the task once the hold is resolved)`);
409
461
 
410
462
  let reviewer: PeerId | undefined;
463
+ let sameFamilyFallback = false;
464
+ let plannerFallback = false;
411
465
  if (task.class !== "review") {
412
466
  if (pii) trace.push("reviewer: user (pii: no second on-prem peer; ahub review <id> <verdict>)");
413
467
  else {
@@ -415,7 +469,32 @@ export function assign(
415
469
  const classPeers = routing.classes.review?.peers ?? [];
416
470
  const rolePeers = Object.keys(opts.roles ?? {}).filter((p) => opts.roles![p]!.includes("reviewer"));
417
471
  trace.push(opts.namedReviewer === undefined ? ` reviewer candidates: [classes.review] peers ${classPeers.join(", ") || "none"}${rolePeers.length ? `; reviewer role ${rolePeers.join(", ")}` : ""}` : ` reviewer candidate explicitly named: ${opts.namedReviewer}`);
418
- reviewer = pick(opts.namedReviewer === undefined ? [...new Set([...classPeers, ...rolePeers])] : [opts.namedReviewer], "reviewer", owner ?? opts.notReviewer);
472
+ const reviewOwner = owner ?? opts.notReviewer;
473
+ const automatic = [...new Set([...expandClassPeers(classPeers, states, opts), ...rolePeers])];
474
+ const ownerFamily = instanceMode && reviewOwner ? familyOf(reviewOwner, opts.instances) : undefined;
475
+ const sameFamily = (peer: PeerId) => ownerFamily !== undefined && familyOf(peer, opts.instances) === ownerFamily;
476
+ const independent = ownerFamily === undefined ? [] : automatic.filter(peer => peer !== reviewOwner && !blocked(peer, "reviewer") && !sameFamily(peer));
477
+ const named = opts.namedReviewer;
478
+ if (named !== undefined && named !== reviewOwner && !blocked(named, "reviewer") && sameFamily(named) && independent.length) {
479
+ const reason = `same family as ${reviewOwner}; eligible other-family reviewer available`;
480
+ refusals.set(`reviewer:${named}`, { peer: named, role: "reviewer", code: "family", reason });
481
+ trace.push(` reviewer candidate ${named}: skipped, ${reason}`);
482
+ } else {
483
+ // #223 R1: a declared instance that reviews and does not plan goes first, busy or not.
484
+ const holds = (peer: PeerId, role: string) => opts.roles?.[peer]?.includes(role) === true;
485
+ const dedicated = (peer: PeerId) => !!opts.instances && Object.hasOwn(opts.instances, peer) && holds(peer, "reviewer") && !holds(peer, "planner");
486
+ const pool = independent.length ? independent : automatic;
487
+ const ready = named === undefined ? pool.filter(peer => dedicated(peer) && peer !== reviewOwner && !blocked(peer, "reviewer")) : [];
488
+ reviewer = pick(named === undefined ? ready.length ? ready : pool : [named], "reviewer", reviewOwner, named === undefined);
489
+ if (named === undefined && !ready.length && reviewer && holds(reviewer, "planner") && Object.keys(opts.roles ?? {}).some(dedicated)) {
490
+ plannerFallback = true;
491
+ trace.push(` planner fallback: no declared reviewer-only instance is eligible; ${reviewer} reviews`);
492
+ }
493
+ }
494
+ if (reviewer && sameFamily(reviewer)) {
495
+ sameFamilyFallback = true;
496
+ trace.push(` same-family fallback: ${reviewer} and ${reviewOwner} share ${ownerFamily}; no eligible other family`);
497
+ }
419
498
  trace.push(reviewer ? `reviewer: ${reviewer}` : "reviewer: none, done will approve directly");
420
499
  const reviewerHold = reviewer ? peerEntry(opts.held, reviewer) : undefined;
421
500
  if (reviewerHold) trace.push(` hold: ${reviewer}'s queue is held: ${reviewerHold} (it receives the review once the hold is resolved)`);
@@ -424,9 +503,9 @@ export function assign(
424
503
  const route = policy?.route ?? routing.local.route;
425
504
  const fixedModel = policy?.fixed_model ?? routing.local.fixed_model;
426
505
  const piBackend = policy?.pi_backend;
427
- if (owner && definitionOf(owner)?.routing?.trace === "route") trace.push(`route: ${route ?? "(none)"}, fixed_model ${fixedModel}`);
428
- if (owner && definitionOf(owner)?.routing?.trace === "backend") trace.push(`${definitionOf(owner)!.routing!.backendLabel} decision: backend ${piBackend ?? "dgx"}${task.signals.includes("long_context") ? `, context limit ${piBackend === "mlx" ? routing.pi.mlx_max_context_tokens : routing.pi.dgx_max_context_tokens}` : ""}`);
429
- return { ...(owner ? { owner } : {}), ...(pii ? { reviewer: "user" } : reviewer ? { reviewer } : {}), ...(route ? { route } : {}), fixedModel, ...(piBackend ? { piBackend } : {}), ...(unreserved ? { unreserved } : {}), ...(refusals.size ? { refusals: [...refusals.values()] } : {}), trace };
506
+ if (owner && definitionOf(owner, opts.instances)?.routing?.trace === "route") trace.push(`route: ${route ?? "(none)"}, fixed_model ${fixedModel}`);
507
+ if (owner && definitionOf(owner, opts.instances)?.routing?.trace === "backend") trace.push(`${definitionOf(owner, opts.instances)!.routing!.backendLabel} decision: backend ${piBackend ?? "dgx"}${task.signals.includes("long_context") ? `, context limit ${piBackend === "mlx" ? routing.pi.mlx_max_context_tokens : routing.pi.dgx_max_context_tokens}` : ""}`);
508
+ return { ...(sameFamilyFallback ? { sameFamilyFallback: true as const } : {}), ...(plannerFallback ? { plannerFallback: true as const } : {}), ...(owner ? { owner } : {}), ...(pii ? { reviewer: "user" } : reviewer ? { reviewer } : {}), ...(route ? { route } : {}), fixedModel, ...(piBackend ? { piBackend } : {}), ...(unreserved ? { unreserved } : {}), ...(refusals.size ? { refusals: [...refusals.values()] } : {}), trace };
430
509
  }
431
510
 
432
511
  /**
@@ -1,6 +1,6 @@
1
- import type { PhaseView } from "./workflow.ts";
1
+ import type { CriteriaView } from "./workflow.ts";
2
2
  import { parsePrEvidence, readSdlcPr, sdlcGh, sdlcStatus, type PrEvidence, type SdlcConfig, type SdlcGh, type SdlcReceipt } from "./sdlc.ts";
3
- export interface DeliveryTask { id: number; after: number[]; private: boolean; receipt?: SdlcReceipt; phase?: PhaseView; }
3
+ export interface DeliveryTask { id: number; after: number[]; private: boolean; receipt?: SdlcReceipt; phase?: CriteriaView; }
4
4
  export interface MergeQueueRow { pr: number; tasks: number[]; head?: string; private?: true; ready: boolean; blockers: string[]; after: number[]; }
5
5
  export interface MergeConflict { left: number; right: number; files: number; order: "dependency" | "person_required"; }
6
6
  export interface MergeQueue { repo: string; rows: MergeQueueRow[]; order: number[]; unordered?: number[]; conflicts: MergeConflict[]; complete: boolean; truncated: boolean; }