@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.
- package/CHANGELOG.md +27 -0
- package/README.md +8 -6
- package/docs/agent-notes/adapters.md +6 -1
- package/docs/agent-notes/budget.md +4 -2
- package/docs/agent-notes/bus.md +5 -1
- package/docs/agent-notes/daemon.md +11 -3
- package/docs/agent-notes/local-worker.md +1 -1
- package/docs/agent-notes/tasks.md +15 -2
- package/docs/agent-notes/tests.md +5 -1
- package/docs/gsd-collaboration-analysis.md +5 -1
- package/docs/operations.md +97 -19
- package/docs/quickstart.md +8 -4
- package/docs/sdlc.md +100 -34
- package/docs/security.md +7 -3
- package/docs/specs/2026-09-19-agent-hub-design.md +237 -7
- package/docs/verification/2026-10-18-0.12.35.md +49 -0
- package/docs/verification/2026-10-18-493-claude-busy-channel.md +91 -0
- package/docs/verification/2026-10-19-0.12.36.md +63 -0
- package/docs/verified.json +26 -17
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +15781 -14850
- package/src/adapters/acp.ts +3 -1
- package/src/adapters/claude-channel.ts +59 -29
- package/src/adapters/claude-print.ts +230 -0
- package/src/adapters/codex-appserver.ts +1 -1
- package/src/adapters/definitions/builtin-metadata.ts +17 -1
- package/src/adapters/definitions/native-cli-entry-claude.ts +17 -3
- package/src/adapters/definitions/native-cli-entry-codex.ts +13 -5
- package/src/adapters/definitions/native-cli-entry-jcode.ts +6 -2
- package/src/adapters/definitions/native-cli-entry-kimi.ts +18 -12
- package/src/adapters/definitions/native-cli-entry-local.ts +3 -2
- package/src/adapters/definitions/native-cli-entry-pi.ts +8 -6
- package/src/adapters/definitions/native-cli-entry-qwen.ts +3 -2
- package/src/adapters/definitions/native-cli-entry-types.ts +3 -0
- package/src/adapters/definitions/native-factories.ts +7 -0
- package/src/adapters/definitions/native-recovery.ts +2 -2
- package/src/adapters/definitions/qwen-native.ts +2 -2
- package/src/adapters/local-worker.ts +3 -2
- package/src/adapters/pi.ts +3 -1
- package/src/adapters/registry-types.ts +11 -2
- package/src/adapters/registry.ts +36 -8
- package/src/cli/help.ts +5 -0
- package/src/cli/identity.ts +5 -4
- package/src/cli/launch.ts +45 -7
- package/src/cli/main.ts +136 -29
- package/src/cli/preview.ts +16 -5
- package/src/cli/recovery-guidance.ts +39 -22
- package/src/cli/reset.ts +4 -2
- package/src/cli/statusline-tee.ts +5 -2
- package/src/cli/terminal-recovery.ts +73 -16
- package/src/cli/upgrade-restore-state.ts +16 -7
- package/src/cli/upgrade-runtime.ts +83 -52
- package/src/cli/upgrade.ts +43 -27
- package/src/hub/board.ts +29 -7
- package/src/hub/budget.ts +55 -3
- package/src/hub/bus.ts +12 -6
- package/src/hub/claude-state.ts +37 -0
- package/src/hub/conductor.ts +16 -10
- package/src/hub/config-apply.ts +13 -2
- package/src/hub/config-reload.ts +11 -1
- package/src/hub/config-trust.ts +1 -1
- package/src/hub/control-client.ts +1 -1
- package/src/hub/cost-governance.ts +76 -0
- package/src/hub/crash.ts +14 -5
- package/src/hub/daemon.ts +473 -194
- package/src/hub/execution-budget.ts +3 -3
- package/src/hub/hub-tools.ts +36 -6
- package/src/hub/instance-budget.ts +73 -0
- package/src/hub/instance-config.ts +47 -0
- package/src/hub/instance-execution.ts +50 -0
- package/src/hub/instances.ts +114 -0
- package/src/hub/lifecycle-schema.ts +7 -3
- package/src/hub/permission-mode.ts +7 -7
- package/src/hub/review-brief.ts +175 -0
- package/src/hub/roster.ts +7 -6
- package/src/hub/routing.ts +97 -18
- package/src/hub/sdlc-queue.ts +2 -2
- package/src/hub/sdlc.ts +286 -39
- package/src/hub/settings.ts +34 -6
- package/src/hub/snapshots.ts +40 -9
- package/src/hub/start-mode.ts +7 -4
- package/src/hub/status-roster.ts +18 -0
- package/src/hub/task-sweep.ts +143 -0
- package/src/hub/tasks.ts +595 -47
- package/src/hub/text-projection.ts +7 -3
- package/src/hub/usage.ts +57 -20
- package/src/hub/workflow.ts +30 -52
- package/src/pi/launch.ts +4 -3
- package/src/ui/index.html +1 -1
- 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
|
-
|
|
21
|
-
return
|
|
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
|
}
|
package/src/hub/routing.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
405
|
-
const
|
|
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
|
-
|
|
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
|
/**
|
package/src/hub/sdlc-queue.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type {
|
|
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?:
|
|
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; }
|