@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.
- package/CHANGELOG.md +25 -0
- package/README.md +15 -8
- package/docs/agent-notes/adapters.md +9 -2
- package/docs/agent-notes/budget.md +3 -2
- package/docs/agent-notes/bus.md +3 -0
- package/docs/agent-notes/daemon.md +11 -3
- package/docs/agent-notes/tasks.md +14 -1
- package/docs/agent-notes/tests.md +6 -2
- package/docs/memory.md +3 -1
- package/docs/operations.md +74 -18
- package/docs/quickstart.md +7 -9
- package/docs/sdlc.md +72 -5
- package/docs/security.md +11 -3
- package/docs/smoke.md +18 -2
- package/docs/specs/2026-09-19-agent-hub-design.md +108 -3
- package/docs/verification/2026-10-19-0.12.36.md +63 -0
- package/docs/verification/2026-10-19-0.12.37.md +60 -0
- package/docs/verification/2026-10-19-jcode-0.98.1.md +30 -0
- package/docs/verification/2026-10-19-reviewer-isolation.md +119 -0
- package/docs/verified.json +23 -15
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +2843 -1632
- package/src/adapters/acp.ts +3 -2
- package/src/adapters/claude-print.ts +92 -0
- package/src/adapters/codex-review-isolation.ts +276 -0
- package/src/adapters/definitions/builtin-metadata.ts +20 -1
- package/src/adapters/definitions/native-cli-entry-claude.ts +17 -3
- package/src/adapters/definitions/native-cli-entry-jcode.ts +3 -2
- package/src/adapters/definitions/native-cli-entry-kimi.ts +3 -2
- package/src/adapters/definitions/native-cli-entry-types.ts +2 -0
- package/src/adapters/definitions/native-factories.ts +24 -0
- package/src/adapters/definitions/native-policy-aux.ts +1 -1
- package/src/adapters/definitions/reviewer-variants.ts +32 -0
- package/src/adapters/jcode.ts +20 -5
- package/src/adapters/registry-types.ts +13 -3
- package/src/adapters/registry.ts +29 -5
- package/src/adapters/reviewer-print.ts +319 -0
- package/src/cli/help.ts +7 -0
- package/src/cli/identity.ts +2 -1
- package/src/cli/launch.ts +19 -6
- package/src/cli/main.ts +57 -4
- package/src/cli/memory.ts +13 -12
- package/src/cli/reset.ts +4 -2
- package/src/cli/statusline-tee.ts +5 -2
- package/src/cli/terminal-recovery.ts +7 -2
- package/src/cli/upgrade-runtime.ts +18 -15
- package/src/hub/board.ts +61 -11
- package/src/hub/bus.ts +11 -5
- package/src/hub/claude-state.ts +37 -0
- package/src/hub/config-apply.ts +3 -2
- package/src/hub/control-client.ts +1 -1
- package/src/hub/cost-governance.ts +76 -0
- package/src/hub/daemon.ts +269 -87
- package/src/hub/envelope.ts +1 -1
- package/src/hub/host-executable.ts +1 -1
- package/src/hub/hub-tools.ts +25 -1
- package/src/hub/instances.ts +29 -5
- package/src/hub/memory-profile.ts +67 -11
- package/src/hub/planning.ts +2 -0
- package/src/hub/release-admission.ts +64 -0
- package/src/hub/review-brief.ts +175 -0
- package/src/hub/reviewer-pool-config.ts +11 -0
- package/src/hub/reviewer-pool.ts +70 -0
- package/src/hub/routing.ts +58 -4
- package/src/hub/sdlc-release.ts +113 -0
- package/src/hub/sdlc.ts +205 -4
- package/src/hub/status-roster.ts +10 -0
- package/src/hub/task-sweep.ts +143 -0
- package/src/hub/tasks.ts +788 -52
- package/src/hub/usage.ts +57 -20
package/src/hub/task-sweep.ts
CHANGED
|
@@ -58,3 +58,146 @@ export function nextSweep(task: Task, config: TaskSweepConfig, now: number, read
|
|
|
58
58
|
if (previous && (previous.step === 3 || now - previous.at < config.ladder_min * 60_000)) return undefined;
|
|
59
59
|
return { kind, activity, step: previous ? (previous.step + 1) as 2 | 3 : 1, at: now };
|
|
60
60
|
}
|
|
61
|
+
|
|
62
|
+
export interface GroomingItem {
|
|
63
|
+
kind: "text-dependency" | "overlap" | "duplicate" | "over-wip" | "stale" | "superseded" | "blocked" | "closed-issue";
|
|
64
|
+
task: number;
|
|
65
|
+
related?: number[];
|
|
66
|
+
code?: string;
|
|
67
|
+
personCommand?: string;
|
|
68
|
+
}
|
|
69
|
+
export interface PlannerGroomingOptions {
|
|
70
|
+
repository: string;
|
|
71
|
+
limits: Record<string, number>;
|
|
72
|
+
wip: Record<string, number>;
|
|
73
|
+
states: Record<string, string>;
|
|
74
|
+
blockers?: readonly { task: number; code: string }[];
|
|
75
|
+
issues?: ReadonlyMap<string, { repo: string; issue: number; state: "OPEN" | "CLOSED"; editedAt: string; lastEditedAt?: string | null }>;
|
|
76
|
+
reviewedIssues?: ReadonlyMap<number, { editedAt: string }>;
|
|
77
|
+
acceptedIssues?: ReadonlyMap<number, { repo: string; issue: number; lastEditedAt: string | null }>;
|
|
78
|
+
}
|
|
79
|
+
const GROOMING_TASK_LIMIT = 500, GROOMING_ITEM_LIMIT = 256;
|
|
80
|
+
const GROOMING_CODES = new Set(["tests", "spec", "roster", "pull-only", "not-attached", "excluded", "owner-admission", "offline", "paused", "failing", "pii", "policy", "cancelled", "unavailable", "runner", "approvals", "quota", "dependency"]);
|
|
81
|
+
const groomingRepo = (value: unknown): string | undefined => {
|
|
82
|
+
if (typeof value !== "string") return undefined;
|
|
83
|
+
if (value.startsWith("path:/") && value.length <= 4096 && !/\p{Cc}/u.test(value)) return value;
|
|
84
|
+
return /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value) && !value.startsWith("-") ? value.toLowerCase() : undefined;
|
|
85
|
+
};
|
|
86
|
+
const groomingOwn = <T>(values: Record<string, T>, key: string): T | undefined => Object.hasOwn(values, key) ? values[key] : undefined;
|
|
87
|
+
function groomingPath(value: string): string | undefined {
|
|
88
|
+
if (!value || value.length > 1000 || /\p{Cc}/u.test(value)) return;
|
|
89
|
+
const parts: string[] = [];
|
|
90
|
+
for (const part of value.split("/")) {
|
|
91
|
+
if (!part || part === ".") continue;
|
|
92
|
+
if (part === "..") { if (!parts.length) return; parts.pop(); }
|
|
93
|
+
else parts.push(part);
|
|
94
|
+
}
|
|
95
|
+
return parts.length ? (value.startsWith("/") ? "/" : "") + parts.join("/") : value.startsWith("/") ? "/" : ".";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Read-only ids/codes projection. Callers retain authority, privacy screening and the current metadata snapshot. */
|
|
99
|
+
export function plannerGrooming(tasks: readonly Task[], options: PlannerGroomingOptions): { items: GroomingItem[]; unknown: number[]; truncated: boolean } {
|
|
100
|
+
const items: GroomingItem[] = [], unknown = new Set<number>(), seen = new Set<string>();
|
|
101
|
+
let truncated = tasks.length > GROOMING_TASK_LIMIT;
|
|
102
|
+
const repository = groomingRepo(options.repository);
|
|
103
|
+
// ponytail: bounded O(500²) snapshot; add revision-keyed paging/indexing before supporting larger complete boards.
|
|
104
|
+
const snapshot = tasks.slice(0, GROOMING_TASK_LIMIT).filter(task => task.state !== "approved" && (task.state !== "cancelled" || task.history.some(entry => entry.supersededBy))).sort((a, b) => a.id - b.id);
|
|
105
|
+
const add = (item: GroomingItem) => {
|
|
106
|
+
const key = JSON.stringify(item);
|
|
107
|
+
if (seen.has(key)) return;
|
|
108
|
+
seen.add(key);
|
|
109
|
+
if (items.length >= GROOMING_ITEM_LIMIT) { truncated = true; return; }
|
|
110
|
+
items.push(item);
|
|
111
|
+
};
|
|
112
|
+
const repositories = new Map<number, string>();
|
|
113
|
+
const scoped = snapshot.flatMap(task => {
|
|
114
|
+
const scopes = [task.refs.repo, task.external?.repo, task.sdlc?.repo].filter(value => value !== undefined);
|
|
115
|
+
const normalized = scopes.map(groomingRepo);
|
|
116
|
+
if (normalized.some(value => !value) || new Set(normalized).size > 1 || (!repository && !normalized.length)) { unknown.add(task.id); return []; }
|
|
117
|
+
// ponytail: without project-root/Git identity, unqualified absolute paths cannot be attributed safely.
|
|
118
|
+
// A future hardened repository resolver (#169) may prove their scope instead of leaving it unknown.
|
|
119
|
+
if (!normalized.length && [...(task.refs.paths ?? []), ...(task.plan?.paths ?? [])].some(path => path.startsWith("/"))) { unknown.add(task.id); return []; }
|
|
120
|
+
repositories.set(task.id, (normalized[0] ?? repository)!);
|
|
121
|
+
return [task];
|
|
122
|
+
});
|
|
123
|
+
const ids = new Set(scoped.map(task => task.id));
|
|
124
|
+
const issueIds = new Map<number, Set<number>>();
|
|
125
|
+
for (const task of scoped) {
|
|
126
|
+
const taskRepository = repositories.get(task.id)!;
|
|
127
|
+
const replacements = new Set<number>();
|
|
128
|
+
for (const entry of task.history) {
|
|
129
|
+
if (!entry.supersededBy) continue;
|
|
130
|
+
const match = /^#([1-9][0-9]*)$/.exec(entry.supersededBy);
|
|
131
|
+
const id = match ? Number(match[1]) : NaN;
|
|
132
|
+
if (Number.isSafeInteger(id) && id !== task.id && ids.has(id) && repositories.get(id) === taskRepository) replacements.add(id);
|
|
133
|
+
else unknown.add(task.id);
|
|
134
|
+
}
|
|
135
|
+
if (replacements.size) add({ kind: "superseded", task: task.id, related: [...replacements].sort((a, b) => a - b) });
|
|
136
|
+
if (task.state === "cancelled") continue; // Closed rows contribute supersession evidence only.
|
|
137
|
+
const issues = new Set<number>();
|
|
138
|
+
for (const issue of [task.external?.issue, task.sdlc?.issue]) {
|
|
139
|
+
if (issue === undefined) continue;
|
|
140
|
+
if (Number.isSafeInteger(issue) && issue > 0) issues.add(issue); else unknown.add(task.id);
|
|
141
|
+
}
|
|
142
|
+
// A bare title reference is an issue. Bare detail numbers remain board task references.
|
|
143
|
+
for (const match of task.title.matchAll(/(?<![A-Za-z0-9_./-])(?:https:\/\/github\.com\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)\/issues\/([1-9][0-9]*)\b|([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)#([1-9][0-9]*)\b|#([1-9][0-9]*)\b)/g)) {
|
|
144
|
+
const scope = match[1] ?? match[3], issue = Number(match[2] ?? match[4] ?? match[5]);
|
|
145
|
+
if (scope && groomingRepo(scope) !== taskRepository) { unknown.add(task.id); continue; }
|
|
146
|
+
if (Number.isSafeInteger(issue)) issues.add(issue); else unknown.add(task.id);
|
|
147
|
+
}
|
|
148
|
+
issueIds.set(task.id, issues);
|
|
149
|
+
const missing = new Set<number>();
|
|
150
|
+
for (const match of task.detail.matchAll(/^\s*(?:after|waits for)\s+#([1-9][0-9]*)\b/gim)) {
|
|
151
|
+
const id = Number(match[1]);
|
|
152
|
+
if (id !== task.id && Number.isSafeInteger(id) && !(task.deps ?? []).includes(id)) {
|
|
153
|
+
if (ids.has(id)) missing.add(id); else unknown.add(task.id);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (missing.size) add({ kind: "text-dependency", task: task.id, related: [...missing].sort((a, b) => a - b) });
|
|
157
|
+
const owner = task.owner ?? task.reserved;
|
|
158
|
+
if (owner) {
|
|
159
|
+
const count = groomingOwn(options.wip, owner), limit = groomingOwn(options.limits, owner), state = groomingOwn(options.states, owner);
|
|
160
|
+
if (count === undefined || limit === undefined || !Number.isSafeInteger(count) || count < 0 || !Number.isSafeInteger(limit) || limit < 0) unknown.add(task.id);
|
|
161
|
+
else if (task.owner && count > limit) add({ kind: "over-wip", task: task.id, personCommand: `ahub task unassign ${task.id}` });
|
|
162
|
+
if (state === "offline" || state === "paused") add({ kind: "blocked", task: task.id, code: state });
|
|
163
|
+
else if (state === undefined) unknown.add(task.id);
|
|
164
|
+
}
|
|
165
|
+
for (const issue of issues) {
|
|
166
|
+
const metadata = options.issues?.get(`${taskRepository}#${issue}`);
|
|
167
|
+
if (!metadata || groomingRepo(metadata.repo) !== taskRepository || metadata.issue !== issue || !["OPEN", "CLOSED"].includes(metadata.state) || !Number.isFinite(Date.parse(metadata.editedAt))) { unknown.add(task.id); continue; }
|
|
168
|
+
if (metadata.state === "CLOSED") add({ kind: "closed-issue", task: task.id });
|
|
169
|
+
const reviewed = options.reviewedIssues?.get(task.id), accepted = options.acceptedIssues?.get(task.id);
|
|
170
|
+
if (reviewed) {
|
|
171
|
+
if (!Number.isFinite(Date.parse(reviewed.editedAt))) unknown.add(task.id);
|
|
172
|
+
else if (Date.parse(metadata.editedAt) > Date.parse(reviewed.editedAt)) add({ kind: "stale", task: task.id });
|
|
173
|
+
}
|
|
174
|
+
if (accepted) {
|
|
175
|
+
const stamp = (value: unknown): value is string | null => value === null || typeof value === "string" && Number.isFinite(Date.parse(value));
|
|
176
|
+
// An old metadata snapshot has no lastEditedAt: its createdAt fallback cannot prove S1's null body stamp.
|
|
177
|
+
if (groomingRepo(accepted.repo) !== taskRepository || accepted.issue !== issue || !stamp(accepted.lastEditedAt)
|
|
178
|
+
|| !Object.hasOwn(metadata, "lastEditedAt") || !stamp(metadata.lastEditedAt)) unknown.add(task.id);
|
|
179
|
+
else if (accepted.lastEditedAt !== metadata.lastEditedAt) add({ kind: "stale", task: task.id });
|
|
180
|
+
}
|
|
181
|
+
if (!reviewed && !accepted) unknown.add(task.id);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const places = (task: Task) => ({ paths: new Set([...(task.refs.paths ?? []), ...(task.plan?.paths ?? [])].map(groomingPath).filter((path): path is string => path !== undefined)), symbols: new Set(task.plan?.symbols ?? []) });
|
|
185
|
+
const open = scoped.filter(task => task.state !== "cancelled");
|
|
186
|
+
const positions = new Map(open.map(task => [task.id, places(task)]));
|
|
187
|
+
const overlaps = (left: string, right: string): boolean => left === right || left === "." || right === "."
|
|
188
|
+
|| left.startsWith(right.endsWith("/") ? right : right + "/") || right.startsWith(left.endsWith("/") ? left : left + "/");
|
|
189
|
+
for (let left = 0; left < open.length; left++) for (const right of open.slice(left + 1)) {
|
|
190
|
+
const task = open[left]!;
|
|
191
|
+
if (repositories.get(task.id) !== repositories.get(right.id)) continue;
|
|
192
|
+
const a = positions.get(task.id)!, b = positions.get(right.id)!;
|
|
193
|
+
if ([...a.paths].some(path => [...b.paths].some(other => overlaps(path, other))) || [...a.symbols].some(symbol => b.symbols.has(symbol))) add({ kind: "overlap", task: task.id, related: [right.id] });
|
|
194
|
+
if ([...issueIds.get(task.id)!].some(issue => issueIds.get(right.id)!.has(issue))) add({ kind: "duplicate", task: task.id, related: [right.id] });
|
|
195
|
+
}
|
|
196
|
+
for (const blocker of (options.blockers ?? []).slice(0, GROOMING_TASK_LIMIT)) {
|
|
197
|
+
if (!positions.has(blocker.task)) continue;
|
|
198
|
+
if (GROOMING_CODES.has(blocker.code)) add({ kind: "blocked", task: blocker.task, code: blocker.code });
|
|
199
|
+
else unknown.add(blocker.task);
|
|
200
|
+
}
|
|
201
|
+
if ((options.blockers?.length ?? 0) > GROOMING_TASK_LIMIT) truncated = true;
|
|
202
|
+
return { items, unknown: [...unknown].sort((a, b) => a - b), truncated };
|
|
203
|
+
}
|