@staix/agent-hub 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/README.md +6 -4
- package/docs/agent-notes/adapters.md +1 -1
- package/docs/agent-notes/budget.md +2 -1
- package/docs/agent-notes/daemon.md +8 -1
- package/docs/agent-notes/tasks.md +7 -4
- package/docs/agent-notes/tests.md +1 -1
- package/docs/dogfood.md +10 -7
- package/docs/operations.md +45 -13
- package/docs/quickstart.md +6 -2
- package/docs/sdlc.md +23 -2
- package/docs/security.md +9 -5
- package/docs/specs/2026-09-19-agent-hub-design.md +38 -0
- package/docs/verification/2026-10-22-codex-reviewer-isolation.md +14 -0
- package/docs/verification/2026-10-23-0.14.0.md +69 -0
- package/docs/verified.json +20 -15
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +91 -20
- package/src/adapters/codex-review-isolation.ts +18 -7
- package/src/adapters/definitions/builtin-metadata.ts +1 -1
- package/src/adapters/definitions/native-cli-entry-codex.ts +13 -0
- package/src/adapters/reviewer-print.ts +34 -7
- package/src/cli/help.ts +2 -1
- package/src/cli/main.ts +5 -0
- package/src/cli/output.ts +47 -2
- package/src/cli/status-lines.ts +1 -1
- package/src/hub/board.ts +22 -1
- package/src/hub/daemon.ts +84 -14
- package/src/hub/hub-tools.ts +2 -1
- package/src/hub/instances.ts +14 -4
- package/src/hub/manager.ts +2 -2
- package/src/hub/sdlc-metrics.ts +260 -0
- package/src/hub/sdlc.ts +5 -3
- package/src/hub/spec-read.ts +31 -0
- package/src/hub/task-code-group.ts +52 -0
- package/src/hub/tasks.ts +172 -22
- package/src/hub/ui.ts +3 -0
- package/src/hub/usage-rollup.ts +58 -0
- package/src/ui/index.html +273 -2
package/src/cli/output.ts
CHANGED
|
@@ -248,6 +248,7 @@ export function renderStatus(data: StatusOutput, columns?: number, now = Date.no
|
|
|
248
248
|
if (p.pullOnly && definitionOf(id)?.attach?.pullOnlyForms?.includes("tui")) out.push(...detail(`${id} last read`, typeof p.lastInboxAt === "number" ? relative(p.lastInboxAt, now) : "never", columns, "muted"));
|
|
249
249
|
const actionPolicy = data.actionPolicyInfo?.[id];
|
|
250
250
|
if (actionPolicy?.note) out.push(...detail(`${id} action policy`, actionPolicy.note, columns, actionPolicy.enforceable ? "muted" : "attention"));
|
|
251
|
+
if (typeof p.reviewerUnavailable === "string" && p.reviewerUnavailable) out.push(...detail("reviewer", flat(p.reviewerUnavailable), columns, "failure")); // #722: the cause class, never a path
|
|
251
252
|
if (p.permissionNote) out.push(...detail("permission", p.permissionNote, columns, "muted"));
|
|
252
253
|
if (typeof p.statusNotice?.message === "string" && p.statusNotice.message) out.push(...detail("status notice", p.statusNotice.message, columns, "attention"));
|
|
253
254
|
if (p.requestedModel && p.servedBy && p.requestedModel !== p.servedBy) out.push(...detail("requested model", p.requestedModel, columns));
|
|
@@ -268,10 +269,55 @@ export function renderStatus(data: StatusOutput, columns?: number, now = Date.no
|
|
|
268
269
|
if (counts) out.push([], ...lines(`TASKS ${counts} (ahub board)`, columns));
|
|
269
270
|
return out;
|
|
270
271
|
}
|
|
271
|
-
|
|
272
|
+
// #724: short next-step codes for the compact board, keyed on the blocker texts of src/hub/sdlc.ts; the full advice stays in --full and ahub task show.
|
|
273
|
+
const STAGE_CODES: [string, string][] = [["issue identity missing", "issue?"], ["PR identity missing", "PR?"], ["PR is not open", "PR closed"], ["current PR evidence unknown", "PR state?"],
|
|
274
|
+
["required check", "CI?"], ["local gate", "gate?"], ["current head has not been reviewed", "review?"], ["GitHub review approval", "approval?"], ["review requests", "requests?"],
|
|
275
|
+
["review threads", "threads?"], ["open important review findings", "findings"], ["person may inspect merge readiness", "ready"]];
|
|
276
|
+
export function stageCode(next: string): string {
|
|
277
|
+
const known = STAGE_CODES.find(([prefix]) => next.startsWith(prefix));
|
|
278
|
+
return known ? known[1] : width(next) <= 12 ? next : characters(next).slice(0, 9).join("") + "...";
|
|
279
|
+
}
|
|
280
|
+
const BOARD_LEGEND = "STAGE: [#---] SDLC progress, stage: next step. issue? no issue, PR? no PR, CI? checks, review? not reviewed, threads? open threads. Full advice: ahub board --full.";
|
|
281
|
+
const ellipsis = (text: string, room: number) => width(text) <= room ? text : room <= 3 ? ".".repeat(Math.max(0, room)) : (() => { let out = ""; for (const unit of characters(text)) { if (width(out + unit) > room - 3) break; out += unit; } return out + "..."; })();
|
|
282
|
+
/** #724: one line per task. TITLE goes last and is cut to the room left; at a narrow width REVIEWER, CLASS and AGE give way first. */
|
|
283
|
+
function compactBoard(tasks: any[], data: OutputCell[][], columns: number, staged: boolean): Span[][] {
|
|
284
|
+
const head = ["ID", "STATE", "OWNER", "REVIEWER", "CLASS", "AGE", "AFTER", ...(staged ? ["STAGE"] : []), "TITLE"];
|
|
285
|
+
let table = tasks.map((task, i) => {
|
|
286
|
+
const row = data[i]!, deps: number[] = task.deps ?? [];
|
|
287
|
+
const after = deps.length ? `#${deps[0]}${deps.length > 1 ? `,+${deps.length - 1}` : ""}` : "-";
|
|
288
|
+
const meter = /^\[[^\]]*\](?: cancelled)?/.exec(row[7]?.text ?? "")?.[0] ?? "[----]";
|
|
289
|
+
const stage = task.lifecycle ? `${meter} ${task.lifecycle.stage}: ${stageCode(String(task.lifecycle.next ?? ""))}` : meter;
|
|
290
|
+
const short = task.lifecycle ? `${meter} ${stageCode(String(task.lifecycle.next ?? ""))}` : meter;
|
|
291
|
+
return [row[0]!, row[1]!, row[2]!, row[3]!, row[4]!, row[5]!, span(after, undefined, true), ...(staged ? [{ ...span(stage, row[7]!.tone, true), short }] : []), span(flat(task.title))];
|
|
292
|
+
});
|
|
293
|
+
let names = [...head];
|
|
294
|
+
const drop = (name: string) => { const at = names.indexOf(name); if (at < 0) return; names = names.filter((_, i) => i !== at); table = table.map(row => row.filter((_, i) => i !== at)); };
|
|
295
|
+
for (const name of [...names]) if (name !== "TITLE" && table.every(row => flat(row[names.indexOf(name)]?.text) === "-")) drop(name);
|
|
296
|
+
const room = () => { const t = names.indexOf("TITLE"); return columns - names.reduce((sum, name, i) => i === t ? sum : sum + Math.max(width(name), ...table.map(row => width(flat(row[i]!.text)))), 0) - 2 * (names.length - 1); };
|
|
297
|
+
const want = Math.min(30, Math.max(...table.map(row => width(row[row.length - 1]!.text))));
|
|
298
|
+
for (const name of ["REVIEWER", "CLASS", "AGE", "STAGE"]) if (room() < want) {
|
|
299
|
+
// STAGE is never dropped: it falls back to the meter and the code, the stage name being the meter's position.
|
|
300
|
+
if (name !== "STAGE") drop(name);
|
|
301
|
+
else { const at = names.indexOf("STAGE"); if (at >= 0) table = table.map(row => row.map((cell, i) => i === at ? { ...cell, text: (cell as OutputCell & { short?: string }).short ?? cell.text } : cell)); }
|
|
302
|
+
}
|
|
303
|
+
// Still too narrow (a long instance id as OWNER): AFTER gives way, then OWNER is cut, so the row never becomes linked bands.
|
|
304
|
+
if (room() < 8) drop("AFTER");
|
|
305
|
+
const owner = names.indexOf("OWNER");
|
|
306
|
+
if (room() < 8 && owner >= 0) { const cut = Math.max(5, Math.max(...table.map(row => width(flat(row[owner]!.text)))) - (8 - room())); table = table.map(row => row.map((cell, i) => i === owner ? { ...cell, text: ellipsis(flat(cell.text), cut) } : cell)); }
|
|
307
|
+
const titleRoom = Math.max(5, room());
|
|
308
|
+
// A PII row keeps its whole "ahub task show" hint when 8 columns of title remain; the cut title is never wrapped.
|
|
309
|
+
table = table.map((row, i) => { const pii = ` (ahub task show ${tasks[i].id})`, hint = tasks[i].signals?.includes("pii") && titleRoom - width(pii) >= 8 ? pii : ""; return [...row.slice(0, -1), { ...row[row.length - 1]!, text: ellipsis(row[row.length - 1]!.text, Math.max(4, titleRoom - width(hint))) + hint }]; });
|
|
310
|
+
return rows(names, table, columns);
|
|
311
|
+
}
|
|
312
|
+
export function renderBoard(tasks: any[], columns?: number, now = Date.now(), full = false, allTasks: any[] | null = tasks): Span[][] {
|
|
272
313
|
if (!tasks.length) return [[span("no tasks")]];
|
|
273
314
|
const s = initialConsoleState(); s.panel = 3; s.tasks = tasks;
|
|
274
315
|
const stages = allTasks === null ? undefined : new Map(taskProgress(boardColumnContext(tasks, allTasks)).stages.map(stage => [stage.id, stage]));
|
|
316
|
+
const counts = [...new Set(tasks.map(task => task.state))].map(state => `${tasks.filter(task => task.state === state).length} ${state}`).join(", ");
|
|
317
|
+
if (!full && columns !== undefined) {
|
|
318
|
+
const data = tasks.map(task => { const row = cells(s, task, false, now, stages); row[2] = span(row[2]!.text, peerTone(task.owner ?? "")); row[3] = span(row[3]!.text, peerTone(task.reviewer ?? "")); return row; });
|
|
319
|
+
return [...compactBoard(tasks, data, columns, allTasks !== null), [], ...lines(`${plural(tasks.length, "task")}: ${counts}`, columns), ...(allTasks !== null ? lines(BOARD_LEGEND, columns, "muted") : [])];
|
|
320
|
+
}
|
|
275
321
|
const data = tasks.map(task => {
|
|
276
322
|
const row = cells(s, task, false, now, stages);
|
|
277
323
|
row[2] = span(row[2]!.text, peerTone(task.owner ?? ""));
|
|
@@ -280,7 +326,6 @@ export function renderBoard(tasks: any[], columns?: number, now = Date.now(), _f
|
|
|
280
326
|
if (allTasks === null) row.pop();
|
|
281
327
|
return row;
|
|
282
328
|
});
|
|
283
|
-
const counts = [...new Set(tasks.map(task => task.state))].map(state => `${tasks.filter(task => task.state === state).length} ${state}`).join(", ");
|
|
284
329
|
return [...rows(allTasks === null ? TABLES[3]!.slice(0, -1) : [...TABLES[3]!], data, columns), [], ...lines(`${plural(tasks.length, "task")}: ${counts}`, columns)];
|
|
285
330
|
}
|
|
286
331
|
export function renderBudget(data: BudgetOutput, columns?: number, now = Date.now(), _full = false): Span[][] {
|
package/src/cli/status-lines.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ContextView } from "../hub/context-window.ts";
|
|
2
2
|
// What `ahub status` prints for a peer and for a model backend, kept pure so it can be checked.
|
|
3
3
|
|
|
4
|
-
export interface PeerRow { permissionMode?: string; context?: ContextView; state?: string; queued?: number; queuedImportant?: number; needsReview?: number; heldBy?: string; holdNote?: string; liveAccepted?: string[]; oldestQueuedAt?: number; importantWaitingSince?: number; importantWaitingNote?: string; attached?: boolean; toolsOnly?: string; pullOnly?: boolean; lastInboxAt?: number | null; permissionNote?: string; paused?: string; servedBy?: string; requestedModel?: string }
|
|
4
|
+
export interface PeerRow { permissionMode?: string; context?: ContextView; state?: string; queued?: number; queuedImportant?: number; needsReview?: number; heldBy?: string; holdNote?: string; liveAccepted?: string[]; oldestQueuedAt?: number; importantWaitingSince?: number; importantWaitingNote?: string; attached?: boolean; toolsOnly?: string; pullOnly?: boolean; lastInboxAt?: number | null; permissionNote?: string; reviewerUnavailable?: string; paused?: string; servedBy?: string; requestedModel?: string }
|
|
5
5
|
export interface BackendRow { kind?: string; alias?: string; state?: string; active?: number; requestedModel?: string; actualModel?: string; provider?: string; coolingUntil?: string; failures?: number; failingUntil?: string }
|
|
6
6
|
|
|
7
7
|
/** Aliases are already namespaced ("dgx/coding"); only an alias that is not gets its kind in front of it. */
|
package/src/hub/board.ts
CHANGED
|
@@ -222,6 +222,8 @@ export class Board {
|
|
|
222
222
|
// And from before issue #207: existing rows reserve nobody.
|
|
223
223
|
for (const col of ["offer", "cancelled"]) if (!have.has(col)) this.db.run(`ALTER TABLE tasks ADD COLUMN ${col} TEXT`);
|
|
224
224
|
this.db.run("CREATE TABLE IF NOT EXISTS task_stalls (task INTEGER PRIMARY KEY, payload TEXT NOT NULL)");
|
|
225
|
+
// #481 S7: one row per log-tier band breach episode already announced, so a restart never repeats the notice.
|
|
226
|
+
this.db.run("CREATE TABLE IF NOT EXISTS band_notices (marker TEXT PRIMARY KEY, at INTEGER NOT NULL)");
|
|
225
227
|
// Derived status-cache generation: only Board task/stall writes advance it, never journal writes.
|
|
226
228
|
this.db.run("CREATE TABLE IF NOT EXISTS board_status_revision (id INTEGER PRIMARY KEY CHECK(id=1), revision INTEGER NOT NULL)");
|
|
227
229
|
this.db.run("INSERT OR IGNORE INTO board_status_revision VALUES (1,0)");
|
|
@@ -300,6 +302,11 @@ export class Board {
|
|
|
300
302
|
this.taskWrite(() => this.db.query("INSERT INTO task_stalls(task,payload) VALUES(?,?) ON CONFLICT(task) DO UPDATE SET payload=excluded.payload").run(stall.task, JSON.stringify(stall)));
|
|
301
303
|
return true;
|
|
302
304
|
}
|
|
305
|
+
/** True when the marker was new (and is now recorded), false when it was announced before. Ids, metric names and task numbers only. */
|
|
306
|
+
recordBandNotice(marker: string): boolean {
|
|
307
|
+
if (!/^[a-z_]{1,64}:#[1-9][0-9]{0,9}:log$/.test(marker)) throw new Error("invalid band notice marker");
|
|
308
|
+
return this.db.query("INSERT OR IGNORE INTO band_notices(marker, at) VALUES(?, ?)").run(marker, Date.now()).changes > 0;
|
|
309
|
+
}
|
|
303
310
|
clearStall(task: number): void { this.taskWrite(() => this.db.query("DELETE FROM task_stalls WHERE task=?").run(task)); }
|
|
304
311
|
stalls(): TaskStall[] {
|
|
305
312
|
return (this.db.query("SELECT payload FROM task_stalls ORDER BY task").all() as { payload: string }[]).flatMap(row => {
|
|
@@ -403,12 +410,26 @@ export class Board {
|
|
|
403
410
|
const task = this.get(id);
|
|
404
411
|
if (!task || (task.planningRevision ?? 0) !== revision || task.state !== "backlog" || task.owner || !taskCanPlan(task)) throw new Error("planner pull changed or already started");
|
|
405
412
|
const now = Date.now(), entry: HistoryEntry = { at: now, by, event: "backlog pulled", planning: { revision: revision + 1, fields: ["reserved"], previous: { reserved: task.reserved ?? null }, pull: true }, note, ...(withheld ? { withheld: true as const } : {}) };
|
|
406
|
-
const result = this.taskWrite(() => this.db.query("UPDATE tasks SET state='proposed',owner=NULL,reviewer=NULL,reserved=?,planning_revision=planning_revision+1,history=?,updated=? WHERE id=? AND planning_revision=? AND state='backlog' AND owner IS NULL")
|
|
413
|
+
const result = this.taskWrite(() => this.db.query("UPDATE tasks SET state='proposed',owner=NULL,reviewer=NULL,offer=NULL,reserved=?,planning_revision=planning_revision+1,history=?,updated=? WHERE id=? AND planning_revision=? AND state='backlog' AND owner IS NULL")
|
|
407
414
|
.run(reserved, JSON.stringify([...task.history, entry]), now, id, revision));
|
|
408
415
|
if (result.changes !== 1) throw new Error("planner pull changed");
|
|
409
416
|
const updated = this.get(id)!; this.changed(updated, entry); return updated;
|
|
410
417
|
}
|
|
411
418
|
|
|
419
|
+
/** Publish no intermediate ownerless pull: Tasks has already admitted the exact reserved peer. */
|
|
420
|
+
pullAssigned(id: number, by: PeerId, owner: PeerId, revision: number, note: string, withheld: boolean,
|
|
421
|
+
reviewer: PeerId | null, assignmentNote: string, extra: Parameters<Board["update"]>[5], expectedFlowRevision: number): Task {
|
|
422
|
+
return this.atomic(() => {
|
|
423
|
+
const current = this.get(id);
|
|
424
|
+
if (!current || current.history.length !== expectedFlowRevision) throw new Error("task flow revision changed");
|
|
425
|
+
const pulled = this.pullPlanned(id, by, owner, revision, note, withheld);
|
|
426
|
+
const assigned = this.update(id, by, "assigned", { owner, reviewer }, assignmentNote, extra, pulled.history.length);
|
|
427
|
+
// Both deferred events describe the committed handoff, including the callback payload.
|
|
428
|
+
for (const change of this.pendingChanges ?? []) if (change.task.id === id) change.task = assigned;
|
|
429
|
+
return assigned;
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
|
|
412
433
|
/** No owner or execution evidence may be taken back by planning undo. */
|
|
413
434
|
undoPlannedPull(id: number, by: PeerId, revision: number): Task {
|
|
414
435
|
const task = this.get(id), pull = task ? planningUndoEntry(task)?.planning : undefined;
|
package/src/hub/daemon.ts
CHANGED
|
@@ -75,7 +75,7 @@ import { profile, proxyEnv, type SandboxNetwork } from "../local/sandbox.ts";
|
|
|
75
75
|
import { DEFAULT_NETWORK_ALLOW, startEgressProxy, type EgressProxy } from "../local/proxy.ts";
|
|
76
76
|
import { runTool, toolResultFailed, TOOL_SCHEMAS, type ToolApproval, type ApprovalObserver, type ToolContext } from "../local/tools.ts";
|
|
77
77
|
import { isLocalPeer, type LocalPeer } from "../adapters/local-worker.ts";
|
|
78
|
-
import { isReviewerPrintPeer, type ReviewerPrintPeer } from "../adapters/reviewer-print.ts";
|
|
78
|
+
import { isReviewerPrintPeer, reviewerUnavailableReason, ReviewerUnavailableError, type ReviewerPrintPeer } from "../adapters/reviewer-print.ts";
|
|
79
79
|
import { createReviewerMcpSession } from "../adapters/reviewer-mcp.ts";
|
|
80
80
|
import { reviewerPoolConfig } from "./reviewer-pool-config.ts";
|
|
81
81
|
import { reviewDiff } from "../adapters/claude-print.ts";
|
|
@@ -106,7 +106,8 @@ import { projectSettings, checkSettingValue, ignoredLine, pendingUndo, pendingUn
|
|
|
106
106
|
import { Bus } from "./bus.ts";
|
|
107
107
|
import { attribute, deliveryTask, type TaskAttribution } from "./attribution.ts";
|
|
108
108
|
import { DeliveryJournal } from "./delivery-journal.ts";
|
|
109
|
-
import { startDashboard, type DashboardSession } from "./ui.ts";
|
|
109
|
+
import { startDashboard, dashboardView, type DashboardSession } from "./ui.ts";
|
|
110
|
+
import { codeGroup } from "./task-code-group.ts";
|
|
110
111
|
import { DAEMON_FEATURES, PROTOCOL, stateDirFor } from "./control-client.ts";
|
|
111
112
|
import { MAX_HOP, newEnvelope, parseMarker, replyParent, sanitize, USER, type Envelope, type PeerId, type Priority } from "./envelope.ts";
|
|
112
113
|
import { BasePeer, DEFAULT_WATCHDOG_MS, type PeerAdapter } from "./peers.ts";
|
|
@@ -290,9 +291,10 @@ export function loadConductorPolicy(cwd: string, layers?: readonly ConfigLayer[]
|
|
|
290
291
|
if (!file.roles || typeof file.roles !== "object" || Array.isArray(file.roles)) throw new Error("roles must be an object of peer role lists");
|
|
291
292
|
for (const [peer, assigned] of Object.entries(file.roles)) {
|
|
292
293
|
conductorPeer({ [peer]: assigned });
|
|
293
|
-
const
|
|
294
|
+
const definition = definitionOf(peer), ceiling = definition?.routing?.allowedRoles;
|
|
294
295
|
const declared = assigned as string[];
|
|
295
|
-
|
|
296
|
+
// #525 R1: planner needs a kind that can plan (projection.plannerEligible); such an entry is dropped with the ceiling notice, never thrown.
|
|
297
|
+
roles[peer] = (ceiling ? declared.filter(role => ceiling.includes(role)) : declared).filter(role => role !== "planner" || !definition || definition.projection?.plannerEligible === true);
|
|
296
298
|
if (roles[peer]!.length !== declared.length) notices.push(`roles.${peer}: ignored roles outside the adapter ceiling`);
|
|
297
299
|
}
|
|
298
300
|
}
|
|
@@ -521,6 +523,10 @@ export interface DaemonOptions {
|
|
|
521
523
|
orphanWatchMs?: number;
|
|
522
524
|
/** Deterministic task-sweep time; the production default is Date.now. */
|
|
523
525
|
taskSweepNow?: () => number;
|
|
526
|
+
/** Test seam: the period of the SDLC band evaluation, default five minutes. */
|
|
527
|
+
bandsEveryMs?: number;
|
|
528
|
+
/** Test seam: an extra hold on the SDLC band evaluation, like a storage error. */
|
|
529
|
+
bandsHeld?: () => boolean;
|
|
524
530
|
/** How long a research record waits after an approval for the task's open turns to end (#247). Tests shrink this. */
|
|
525
531
|
researchGraceMs?: number;
|
|
526
532
|
/** Tests observe timer-driven sync without opening a remote; production uses the package sync. */
|
|
@@ -2051,6 +2057,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2051
2057
|
};
|
|
2052
2058
|
// Claude's numbers arrive through the status line tee `ahub claude` installs (src/cli/statusline-tee.ts).
|
|
2053
2059
|
let claudeUsageSeen = 0;
|
|
2060
|
+
let bandsRevision = ""; // #481 S7: the board revision and band configuration the last band evaluation that ran saw
|
|
2061
|
+
let bandEvaluations = 0; // evaluations that ran (not skipped for an unchanged board); read by tests through the daemon
|
|
2054
2062
|
const claudeContextSeen = new Map<string, string>();
|
|
2055
2063
|
const contextTailSeen = new Map<PeerId, string>();
|
|
2056
2064
|
const retainedCliAudits = new Set<string>();
|
|
@@ -2109,6 +2117,21 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2109
2117
|
}, 1000),
|
|
2110
2118
|
setInterval(() => budget.tick(), 30_000),
|
|
2111
2119
|
setInterval(pollUsageRollup, 10_000),
|
|
2120
|
+
// #481 S7: band tiers are evaluated every five minutes, and only when the board changed since the last evaluation (the status
|
|
2121
|
+
// revision moves on every task write), so an idle board costs one integer read. Held while stopping or recovering, like the task sweep.
|
|
2122
|
+
// ponytail: one evaluation reads every task with its history (a few ms per hundred tasks); bound it by a history window if a board passes several thousand tasks.
|
|
2123
|
+
setInterval(() => {
|
|
2124
|
+
if (stopping || recoveryActive()) return;
|
|
2125
|
+
try {
|
|
2126
|
+
const state = tasks.bandsState();
|
|
2127
|
+
if (!state.ready || opts.bandsHeld?.()) return; // held, mode off or no bands: nothing is consumed, so the breach is still there when it clears
|
|
2128
|
+
const revision = `${board.statusRevision()}:${state.signature}`;
|
|
2129
|
+
if (revision === bandsRevision) return;
|
|
2130
|
+
bandEvaluations++;
|
|
2131
|
+
tasks.proposeFromBands();
|
|
2132
|
+
bandsRevision = `${board.statusRevision()}:${state.signature}`; // after the write: the cards this pass made are not a reason to run again
|
|
2133
|
+
} catch (error) { log(`sdlc band evaluation failed: ${sanitize((error as Error).message).slice(0, 160)}`); }
|
|
2134
|
+
}, opts.bandsEveryMs ?? 300_000),
|
|
2112
2135
|
setInterval(() => {
|
|
2113
2136
|
if (!ready || !memoryBinding) return;
|
|
2114
2137
|
const before = memoryProblem;
|
|
@@ -2234,7 +2257,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2234
2257
|
}
|
|
2235
2258
|
// Inside a PII turn the worker's words may carry the PII whatever they are attached to: a note would go to
|
|
2236
2259
|
// claude-mem (a cloud observer) and a new task could be routed to a cloud peer without matching any pattern.
|
|
2237
|
-
if (piiTurn && (MEMORY_READ_TOOLS.has(op) || op === "hub_memory_correct" || op === "hub_remember" || op === "hub_task_propose" || op === "hub_sdlc_record" || op === "hub_plan_review" || op === "hub_sdlc_costs" || op.startsWith("hub_release_") || op === "hub_backlog_return_untouched" || op === "hub_backlog_plan" || op === "hub_backlog_undo" || op === "hub_backlog_pull" || op === "hub_backlog_split" || op === "release_new")) throw new PreEffectToolRefusal(`${op} is not available while working on a PII task: its text must not leave this machine`);
|
|
2260
|
+
if (piiTurn && (MEMORY_READ_TOOLS.has(op) || op === "hub_memory_correct" || op === "hub_remember" || op === "hub_task_propose" || op === "hub_sdlc_record" || op === "hub_plan_review" || op === "hub_sdlc_costs" || op === "hub_sdlc_metrics" || op.startsWith("hub_release_") || op === "hub_backlog_return_untouched" || op === "hub_backlog_plan" || op === "hub_backlog_undo" || op === "hub_backlog_pull" || op === "hub_backlog_split" || op === "release_new")) throw new PreEffectToolRefusal(`${op} is not available while working on a PII task: its text must not leave this machine`);
|
|
2238
2261
|
// The same words attached to an ordinary task would reach its reviewer, its owner, overlapping owners (a plan, the
|
|
2239
2262
|
// completed-change notice) and claude-mem. A turn that holds a PII task acts on ordinary tasks in a turn of its own.
|
|
2240
2263
|
if (piiTurn && (op === "hub_task_done" || op === "hub_review" || (op === "hub_task_accept" && (a.plan != null || a.refs != null)))) {
|
|
@@ -2364,6 +2387,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2364
2387
|
return JSON.stringify(await tasks.groomingDigest(by));
|
|
2365
2388
|
case "hub_sdlc_costs":
|
|
2366
2389
|
return JSON.stringify(await tasks.costReport(by, a.repo));
|
|
2390
|
+
case "hub_sdlc_metrics":
|
|
2391
|
+
return JSON.stringify(tasks.sdlcMetrics(by, a.since));
|
|
2367
2392
|
case "hub_plan_review": {
|
|
2368
2393
|
const { id, ...review } = a;
|
|
2369
2394
|
return JSON.stringify(tasks.publicView(await tasks.recordPlanReview(by, id, review)));
|
|
@@ -2384,10 +2409,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2384
2409
|
return JSON.stringify(tasks.publicView(await tasks.undoPlanning(by, a.id, a.revision)));
|
|
2385
2410
|
case "hub_task_propose": {
|
|
2386
2411
|
const t = await tasks.propose(by, a);
|
|
2387
|
-
|
|
2412
|
+
const repoHint = tasks.refsRepoHint(t); // #723: advice only; never part of the write
|
|
2413
|
+
if (t.state === "backlog") return [line(t), repoHint].filter(Boolean).join("\n");
|
|
2388
2414
|
const overlap = tasks.overlaps(t, t.owner === by);
|
|
2389
2415
|
if (t.owner === by && tasks.silentFor(t.id) && factsOn()) factsFor(by).sawPlans(by, tasks.overlapTasks(t)); // as for an accept
|
|
2390
|
-
return [line(t), tasks.namedOwnerNote(t, a.owner as PeerId | undefined, by), overlap, tasks.claimRoleGuidance(t, by), t.owner === by ? tasks.planGuardText(t) : ""].filter(Boolean).join("\n");
|
|
2416
|
+
return [line(t), tasks.namedOwnerNote(t, a.owner as PeerId | undefined, by), overlap, tasks.claimRoleGuidance(t, by), t.owner === by ? tasks.planGuardText(t) : "", repoHint].filter(Boolean).join("\n");
|
|
2391
2417
|
}
|
|
2392
2418
|
case "hub_task_accept": {
|
|
2393
2419
|
// S1 spec acceptance and S9 plan review are checked against one task snapshot before acceptance.
|
|
@@ -2885,7 +2911,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2885
2911
|
...(dashboard ? { uiOrigin: dashboard.origin } : {}),
|
|
2886
2912
|
codexProxyPort: opts.codexProxyPort,
|
|
2887
2913
|
peers: Object.fromEntries(
|
|
2888
|
-
peerIds.map((id) => { const p = bus.peers.get(id); const summary = bus.queueSummary(id); const context = contexts.view(id, contextSession(id), !!p && ["idle", "busy", "paused"].includes(bus.stateOf(id))); return [id, { state: bus.stateOf(id), ...(!membershipAllowed(id) ? { rostered: false, rosterNote: "off roster" } : {}), ...permissionStatus(id, p), ...(isCodexPeer(p) && p.statusNotice ? { statusNotice: p.statusNotice } : {}), ...(context.source !== null || context.measuredAt !== null ? { context } : {}), queued: bus.queued(id), ...(p ? {} : { attached: false }), ...(summary.needsReview ? { needsReview: summary.needsReview } : {}), ...(summary.liveAccepted ? { liveAccepted: summary.liveAccepted, settlementNote: "awaiting adapter completion (Claude: correlated reply or hub_delivery_done); task state is independent" } : {}), ...queueHoldStatus(id, summary.heldBy), ...(summary.oldestQueuedAt !== undefined ? { oldestQueuedAt: summary.oldestQueuedAt } : {}), ...(bus.queuedImportant(id) ? { queuedImportant: bus.queuedImportant(id) } : {}), ...importantWaitingStatus(id, p), ...pausedNote(id), ...jcodeModelSource(id), ...(isLocalPeer(p) && p.lastServedBy ? { servedBy: p.lastServedBy } : {}), ...(isWsPeer(p) && p.claiming ? { claiming: true } : {}), ...(isWsPeer(p) && p.pullOnly ? { pullOnly: true, toolsOnly: definitionOf(id)?.attach?.pullOnlyNotice ?? "tools-only: messages wait for hub_inbox; for pushes restart Claude with ahub claude", lastInboxAt: p.lastInboxAt ?? null } : {}), ...(isPiPeer(p) ? { requestedModel: p.getRequestedModel(), backends: modelRelays.get(id)?.status().backends ?? [] } : {}) }]; }),
|
|
2914
|
+
peerIds.map((id) => { const p = bus.peers.get(id); const summary = bus.queueSummary(id); const context = contexts.view(id, contextSession(id), !!p && ["idle", "busy", "paused"].includes(bus.stateOf(id))); return [id, { state: bus.stateOf(id), ...(!membershipAllowed(id) ? { rostered: false, rosterNote: "off roster" } : {}), ...permissionStatus(id, p), ...(isCodexPeer(p) && p.statusNotice ? { statusNotice: p.statusNotice } : {}), ...(context.source !== null || context.measuredAt !== null ? { context } : {}), queued: bus.queued(id), ...(p ? {} : { attached: false }), ...(summary.needsReview ? { needsReview: summary.needsReview } : {}), ...(summary.liveAccepted ? { liveAccepted: summary.liveAccepted, settlementNote: "awaiting adapter completion (Claude: correlated reply or hub_delivery_done); task state is independent" } : {}), ...queueHoldStatus(id, summary.heldBy), ...(summary.oldestQueuedAt !== undefined ? { oldestQueuedAt: summary.oldestQueuedAt } : {}), ...(bus.queuedImportant(id) ? { queuedImportant: bus.queuedImportant(id) } : {}), ...importantWaitingStatus(id, p), ...pausedNote(id), ...(reviewerUnavailable.has(id) ? { reviewerUnavailable: reviewerUnavailable.get(id) } : {}), ...jcodeModelSource(id), ...(isLocalPeer(p) && p.lastServedBy ? { servedBy: p.lastServedBy } : {}), ...(isWsPeer(p) && p.claiming ? { claiming: true } : {}), ...(isWsPeer(p) && p.pullOnly ? { pullOnly: true, toolsOnly: definitionOf(id)?.attach?.pullOnlyNotice ?? "tools-only: messages wait for hub_inbox; for pushes restart Claude with ahub claude", lastInboxAt: p.lastInboxAt ?? null } : {}), ...(isPiPeer(p) ? { requestedModel: p.getRequestedModel(), backends: modelRelays.get(id)?.status().backends ?? [] } : {}) }]; }),
|
|
2889
2915
|
),
|
|
2890
2916
|
taskWip: Object.fromEntries(peerIds.map(id => [id, { wip: tasks.wip(id), wipLimit: tasks.wipLimit(id) }])),
|
|
2891
2917
|
boardReadCapabilities: { releaseFilter: true, columnMetadata: true },
|
|
@@ -4262,7 +4288,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4262
4288
|
onUnavailable: (failure, current) => {
|
|
4263
4289
|
const attached = () => current() && bus.peers.get(peer) === reviewer;
|
|
4264
4290
|
if (!attached()) return;
|
|
4265
|
-
if (failure.cause === "start_failed")
|
|
4291
|
+
if (failure.cause === "start_failed") {
|
|
4292
|
+
// #722: the class (a closed list) is the only detail; never a path, native text or token.
|
|
4293
|
+
const reason = reviewerUnavailableReason(failure.causeClass ?? "unknown", failure.causeDetail);
|
|
4294
|
+
log(`reviewer ${peer} unavailable for task #${failure.task}: ${sanitize(reason).slice(0, 300)}`);
|
|
4295
|
+
reviewerUnavailable.set(peer, sanitize(reason).slice(0, 1000));
|
|
4296
|
+
}
|
|
4266
4297
|
return tasks.retryReviewer(failure.task, peer, failure, attached).then(() => undefined);
|
|
4267
4298
|
},
|
|
4268
4299
|
log,
|
|
@@ -4272,7 +4303,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4272
4303
|
await reviewer.stop().catch(() => {});
|
|
4273
4304
|
// #691: a version refusal names only versions and the trust command, so it is shown for every backend.
|
|
4274
4305
|
const reason = error instanceof Error && (backend.nativePolicy || error.message.startsWith(VERSION_REFUSAL)) ? sanitize(error.message).slice(0, 1000)
|
|
4275
|
-
:
|
|
4306
|
+
: reviewerUnavailableReason(error instanceof ReviewerUnavailableError ? error.causeClass : "unknown", error instanceof ReviewerUnavailableError ? error.detail : undefined);
|
|
4307
|
+
log(`start ${peer} failed [cause: ${error instanceof ReviewerUnavailableError ? error.causeClass : "unknown"}]`);
|
|
4276
4308
|
reviewerUnavailable.set(peer, reason);
|
|
4277
4309
|
return { ok: false, error: reason };
|
|
4278
4310
|
}
|
|
@@ -4432,7 +4464,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4432
4464
|
const text = (v: unknown, max = 200) => typeof v === "string" && v.trim() && v.length <= max ? v.trim() : undefined;
|
|
4433
4465
|
const tier = q.tier === "short" || q.tier === "long" || q.tier === "goals" ? q.tier : "long";
|
|
4434
4466
|
const kind = typeof q.kind === "string" && (["decision", "finding", "contract", "fail"] as string[]).includes(q.kind) ? q.kind as MemoryView["kind"] : undefined;
|
|
4435
|
-
const peer = text(q.peer, 32), machine = text(q.machine, 32), search = text(q.q), node = text(q.node), since = [text(q.since, 40)].find((v) => v !== undefined && !Number.isNaN(Date.parse(v))); // an unreadable since filters nothing
|
|
4467
|
+
const peer = text(q.peer, 32), machine = text(q.machine, 32), search = text(q.q), node = text(q.node, typeof q.node === "string" && q.node.startsWith("symbol:") ? 512 : 200), since = [text(q.since, 40)].find((v) => v !== undefined && !Number.isNaN(Date.parse(v))); // an unreadable since filters nothing
|
|
4436
4468
|
const hops = q.hops === 2 ? 2 : 1;
|
|
4437
4469
|
const all = memoryStore.list({ project: memoryProject, limit: Number.MAX_SAFE_INTEGER });
|
|
4438
4470
|
const goals = memoryStore.goals(memoryProject);
|
|
@@ -4470,6 +4502,43 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4470
4502
|
const attentionStatus = status(false, true);
|
|
4471
4503
|
let pet; try { const config = loadPetConfig(opts.cwd); pet = { enabled: config.enabled, character: config.character ?? "stai", blink: config.blink }; } catch { pet = { enabled: false }; }
|
|
4472
4504
|
const peerView = Object.fromEntries(Object.entries(attentionStatus.peers).map(([id, peer]) => [id, { ...peer, ...turnView(id as PeerId) }])), boardTasks = board.list();
|
|
4505
|
+
const usageView = (open: boolean) => {
|
|
4506
|
+
// #683 U4: the roll-up runs only while the Agents view that shows it is open, never on a hidden view's 1 s poll.
|
|
4507
|
+
if (!open) return undefined;
|
|
4508
|
+
try {
|
|
4509
|
+
const caught = usageRollup.catchUp(100);
|
|
4510
|
+
const status = budget.status();
|
|
4511
|
+
const live = new Set(Object.entries(status).filter(([, v]) => v.paused).map(([peer]) => peer));
|
|
4512
|
+
const limits = usageRollup.limits(undefined, { limit: 100, live });
|
|
4513
|
+
const total = usageRollup.total();
|
|
4514
|
+
const daily = usageRollup.daily(14);
|
|
4515
|
+
const taskUsage = usageRollup.query({ by: "task" });
|
|
4516
|
+
return {
|
|
4517
|
+
// `behind`: the roll-up has not folded the whole log, so every figure below is a lower bound, never the whole (unknown is not 0).
|
|
4518
|
+
behind: !caught,
|
|
4519
|
+
daily,
|
|
4520
|
+
limits,
|
|
4521
|
+
total,
|
|
4522
|
+
tasks: taskUsage.rows.map(r => ({
|
|
4523
|
+
id: r.key === "unattributed" ? 0 : Number(r.key.replace(/^#/, "")),
|
|
4524
|
+
pii: r.pii === true,
|
|
4525
|
+
records: r.records,
|
|
4526
|
+
input: r.input,
|
|
4527
|
+
output: r.output,
|
|
4528
|
+
cacheRead: r.cacheRead,
|
|
4529
|
+
cacheWrite: r.cacheWrite,
|
|
4530
|
+
reasoning: r.reasoning,
|
|
4531
|
+
total: r.total,
|
|
4532
|
+
counted: r.counted,
|
|
4533
|
+
totalWithoutCache: r.totalWithoutCache,
|
|
4534
|
+
})),
|
|
4535
|
+
};
|
|
4536
|
+
} catch (e) {
|
|
4537
|
+
log(`dashboard usage view failed: ${(e as Error).message}`);
|
|
4538
|
+
// The page keeps the last figures for `undefined` (a hidden view), so a failed read says so explicitly instead.
|
|
4539
|
+
return { unavailable: true };
|
|
4540
|
+
}
|
|
4541
|
+
};
|
|
4473
4542
|
return {
|
|
4474
4543
|
ok: true,
|
|
4475
4544
|
projectId,
|
|
@@ -4489,7 +4558,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4489
4558
|
tasks: boardTasks.map((task) => {
|
|
4490
4559
|
const view = tasks.publicView(task);
|
|
4491
4560
|
// Refs and history can themselves quote private text. The dashboard needs neither.
|
|
4492
|
-
return { id: task.id, title: view.title, detail: view.detail, class: task.class, ...(task.profile ? { profile: task.profile, profileClass: task.profileClass } : {}), ...(task.role ? { role: task.role } : {}), state: task.state, owner: task.owner, reviewer: task.reviewer, deps: view.deps ?? [], ...(task.reserved ? { reserved: task.reserved } : {}), planningRevision: task.planningRevision ?? 0, rank: task.rank ?? null, priority: task.priority ?? null, release: task.release ?? null, plannedStart: task.plannedStart ?? null, due: task.due ?? null, pinned: task.pinned === true, actualDates: view.actualDates, ...(view.codeSymbols ? { codeSymbols: view.codeSymbols } : {}), ...(view.cancelled ? { cancelled: view.cancelled } : {}), ...(view.external ? { external: view.external } : {}), started: taskHasStarted(task), urgent: task.signals.includes("urgent"), updated: task.updated, reworkRound: task.history.filter(entry => entry.event === "changes_requested").length, checking: tasks.isChecking(task.id), ...(view.lifecycle ? { lifecycle: view.lifecycle } : {}) };
|
|
4561
|
+
return { id: task.id, title: view.title, detail: view.detail, class: task.class, ...(task.profile ? { profile: task.profile, profileClass: task.profileClass } : {}), ...(task.role ? { role: task.role } : {}), state: task.state, owner: task.owner, reviewer: task.reviewer, deps: view.deps ?? [], ...(task.reserved ? { reserved: task.reserved } : {}), planningRevision: task.planningRevision ?? 0, rank: task.rank ?? null, priority: task.priority ?? null, release: task.release ?? null, plannedStart: task.plannedStart ?? null, due: task.due ?? null, pinned: task.pinned === true, actualDates: view.actualDates, ...(view.codeSymbols ? { codeSymbols: view.codeSymbols } : {}), ...(() => { const group = codeGroup(task, view.codeSymbols, task.plan?.symbols, tasks.publicMetadataText, tasks.isPii(task)); return group ? { codeGroup: group } : {}; })(), ...(view.cancelled ? { cancelled: view.cancelled } : {}), ...(view.external ? { external: view.external } : {}), started: taskHasStarted(task), urgent: task.signals.includes("urgent"), updated: task.updated, reworkRound: task.history.filter(entry => entry.event === "changes_requested").length, checking: tasks.isChecking(task.id), ...(view.lifecycle ? { lifecycle: view.lifecycle } : {}) };
|
|
4493
4562
|
}),
|
|
4494
4563
|
links: links.filter(link => link.tasks.every(id => { const task = board.get(id); return task && !tasks.isPii(task); })),
|
|
4495
4564
|
budget: Object.fromEntries(peers.map((id) => [id, { ...(quota[accountOf(id)] ?? { windows: [] }), context: contexts.view(id, contextSession(id), bus.peers.has(id) && ["idle", "busy", "paused"].includes(bus.stateOf(id))) }])),
|
|
@@ -4508,6 +4577,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4508
4577
|
roster: rosterView(),
|
|
4509
4578
|
...benchView(),
|
|
4510
4579
|
...memoryView(_input?.memory),
|
|
4580
|
+
usage: usageView(_input?.view === "agents"),
|
|
4511
4581
|
};
|
|
4512
4582
|
}
|
|
4513
4583
|
|
|
@@ -5984,7 +6054,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5984
6054
|
case "ui_snapshot":
|
|
5985
6055
|
if (c.role !== "console") return void reply({ ok: false, error: "ui_snapshot is a console command" });
|
|
5986
6056
|
if (!Number.isSafeInteger(msg.after ?? 0) || (msg.after ?? 0) < 0) return void reply({ ok: false, error: "invalid cursor" });
|
|
5987
|
-
return void reply(uiSnapshot(msg.after ?? 0, msg.memory !== undefined ? { memory: msg.memory } : undefined));
|
|
6057
|
+
return void reply(uiSnapshot(msg.after ?? 0, msg.memory !== undefined || dashboardView(msg.view) ? { ...(msg.memory !== undefined ? { memory: msg.memory } : {}), ...(dashboardView(msg.view) ? { view: msg.view } : {}) } : undefined));
|
|
5988
6058
|
case "ui_action":
|
|
5989
6059
|
if (c.role !== "console") return void reply({ ok: false, error: "ui_action is a console command" });
|
|
5990
6060
|
if (recoveryActive()) return void reply({ ok: false, error: "recovery is holding mutations" });
|
|
@@ -6323,7 +6393,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6323
6393
|
// After a crash that recorded Pi, crash recovery starts it (#66): its recorded session first, a fresh one if that fails.
|
|
6324
6394
|
const piRecovered = !archivedPiContinuity && !!crashed?.peers.some((p) => definitionOf(p.peer)?.session.restoreOnStart);
|
|
6325
6395
|
if (piAutoStart() && !recoveryActive() && !piRecovered) autoStartPi();
|
|
6326
|
-
return { bus, token, port: server.port as number, stop, stopped: new Promise<void>((r) => (onStop = r)) };
|
|
6396
|
+
return { bus, token, port: server.port as number, bandEvaluations: () => bandEvaluations, stop, stopped: new Promise<void>((r) => (onStop = r)) };
|
|
6327
6397
|
} finally {
|
|
6328
6398
|
if (!ready) for (const cleanup of startupCleanup.reverse()) { try { await cleanup(); } catch { /* preserve startup error */ } }
|
|
6329
6399
|
}
|
package/src/hub/hub-tools.ts
CHANGED
|
@@ -104,7 +104,8 @@ export const TASK_TOOLS: HubTool[] = [
|
|
|
104
104
|
export const PLANNER_REVIEW_TOOLS: HubTool[] = [
|
|
105
105
|
tool("hub_planner_groom", "Read a bounded planner grooming digest of dependencies, repository-scoped overlaps, WIP and blockers. No board change; unavailable evidence stays unknown.", {}),
|
|
106
106
|
tool("hub_plan_review", "Record a planner review against the task planning revision and host-read issue edit time. Configured planner or person only; never accepts caller issue timestamps.", { id, revision: { type: "integer", minimum: 0 }, reason: { ...str, maxLength: 1000 }, ...costPlanProperties }, ["id", "revision", "reason"]),
|
|
107
|
-
tool("hub_sdlc_costs", "Read bounded review and CI cost metrics for linked merged PRs. Planner/person only; metadata only, no board writes.
|
|
107
|
+
tool("hub_sdlc_costs", "Read bounded review and CI cost metrics for linked merged PRs. Planner/person only; metadata only, no board writes.", { repo: str }),
|
|
108
|
+
tool("hub_sdlc_metrics", "Read SDLC metrics from board history: time from acceptance to plan, first review and merge, first-pass approval and rework rounds, first-pass CI and same-head reruns, stale-head re-reviews, stalls, and the control band tiers when sdlc.toml sets [bands]. Planner/person only; read-only, no model and no GitHub issue.", { since: str }),
|
|
108
109
|
tool("hub_backlog_return_untouched", "Return accepted but untouched work to the backlog with its history and planning retained. Refuses execution/branch/commit evidence; planner or person only.", { id, revision: { type: "integer", minimum: 0 }, reason: { ...str, maxLength: 1000 } }, ["id", "revision", "reason"]),
|
|
109
110
|
];
|
|
110
111
|
TASK_TOOLS.push(...PLANNER_REVIEW_TOOLS);
|
package/src/hub/instances.ts
CHANGED
|
@@ -149,16 +149,24 @@ export function validateInstances(raw: unknown, catalog: ProjectAdapterCatalog):
|
|
|
149
149
|
const runtime = freshReviewer ? definition.reviewerVariant ?? definition : definition;
|
|
150
150
|
const unavailableReviewer = freshReviewer && !!(definition.reviewerVariant ?? definition).reviewer?.unavailable;
|
|
151
151
|
if (!unavailableReviewer && runtime.instances?.mode !== "external" && !supportsHeadlessInstances(runtime)) return fail(`${path}.kind`, definition.reviewerVariant ? 'unsupported; this kind only declares headless reviewer instances with roles exactly ["reviewer"]' : "unsupported; explicit external instance policy or a headless instance policy with a headless factory is required");
|
|
152
|
+
if (declaredRoles.includes("planner") && definition.projection?.plannerEligible !== true) return fail(`${path}.roles`, "this kind cannot plan");
|
|
152
153
|
if (external) {
|
|
153
154
|
const declared = entry.roles ? roles(entry.roles.value, `${path}.roles`) : [];
|
|
154
155
|
// #223: a headless one is the kind's reviewer variant, one fresh hub-run session per review request.
|
|
155
156
|
if (entry.start_mode && definition.reviewerVariant) {
|
|
157
|
+
const reviewerRoles = declared.length === 1 && declared[0] === "reviewer";
|
|
158
|
+
// #525 R1: a headless writer is the kind's implementer variant, which arrives with slice 525-4; until then it is refused.
|
|
159
|
+
if (!reviewerRoles && declared.includes("implementer") && declared.every(role => role === "implementer" || role === "verifier")) return fail(`${path}.start_mode`, "headless Claude implementer is not available in this release");
|
|
160
|
+
if (!reviewerRoles) return fail(`${path}.roles`, "a headless channel-kind instance is a reviewer: roles must be exactly [\"reviewer\"]");
|
|
156
161
|
if (entry.worktree) return fail(path, "a headless reviewer reads the project checkout: worktree is not supported");
|
|
157
|
-
if (declared.length !== 1 || declared[0] !== "reviewer") return fail(`${path}.roles`, "a headless channel-kind instance is a reviewer: roles must be exactly [\"reviewer\"]");
|
|
158
162
|
} else {
|
|
159
|
-
if (entry.start_mode
|
|
163
|
+
if (entry.start_mode) return fail(`${path}.start_mode`, "a channel instance starts only from the person's terminal (ahub <kind> --as <id>); a headless reviewer declares roles [\"reviewer\"] and start_mode \"headless\"");
|
|
160
164
|
const allowed = definition.instances?.roles ?? definition.roles;
|
|
161
|
-
if (!declared.length || declared.some(role => !allowed.includes(role))) return fail(`${path}.roles`,
|
|
165
|
+
if (!declared.length || declared.some(role => !allowed.includes(role))) return fail(`${path}.roles`, `a channel instance must declare its roles within: ${allowed.join(", ")}`);
|
|
166
|
+
// #525 R1: an implementer or verifier writes, so it needs its own worktree; a planner or reviewer reads the project checkout.
|
|
167
|
+
const writes = declared.includes("implementer") || declared.includes("verifier");
|
|
168
|
+
if (writes && !entry.worktree) return fail(`${path}.worktree`, "an implementer or verifier channel instance needs its own worktree");
|
|
169
|
+
if (!writes && entry.worktree) return fail(`${path}.worktree`, "a planner or reviewer channel instance reads the project checkout: worktree is not supported");
|
|
162
170
|
}
|
|
163
171
|
} else {
|
|
164
172
|
const allowed = runtime.instances?.roles;
|
|
@@ -217,5 +225,7 @@ export function effectiveInstanceRoles(id: string, declarations: InstancesConfig
|
|
|
217
225
|
if (!definition) throw new Error(`${id}: no declared native instance`);
|
|
218
226
|
const declaration = declarations && Object.hasOwn(declarations, id) ? declarations[id] : undefined;
|
|
219
227
|
const ownRoles = (key: string): readonly string[] | undefined => Object.hasOwn(policyRoles, key) ? policyRoles[key] : undefined;
|
|
220
|
-
|
|
228
|
+
const roles = declaration?.roles ?? ownRoles(id) ?? ownRoles(definition.kind) ?? definition.roles;
|
|
229
|
+
// #525 R1: a roles entry keyed by an instance id skips loadConductorPolicy's kind check, so planner is gated here too.
|
|
230
|
+
return roles.filter(role => role !== "planner" || definition.projection?.plannerEligible === true);
|
|
221
231
|
}
|
package/src/hub/manager.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { spawn } from "node:child_process";
|
|
|
8
8
|
import { ControlClient, PROTOCOL, RECOVERY_SOURCE_PROTOCOLS } from "./control-client.ts";
|
|
9
9
|
import { Registry, type Project } from "./registry.ts";
|
|
10
10
|
import { hubHome } from "./project.ts";
|
|
11
|
-
import { startDashboard } from "./ui.ts";
|
|
11
|
+
import { startDashboard, dashboardView } from "./ui.ts";
|
|
12
12
|
import { projectChain } from "@staix/agent-mem";
|
|
13
13
|
import type { ProjectInspection } from "./lifecycle.ts";
|
|
14
14
|
import { MAX_COMMAND_MS } from "../cli/terminal-recovery.ts";
|
|
@@ -164,7 +164,7 @@ export async function startManager(options: ManagerOptions = {}): Promise<Manage
|
|
|
164
164
|
else if (deliveryRead) nominalMs = 30_000;
|
|
165
165
|
const timeoutMs = options.forwardTimeoutMs?.(String(input.action), nominalMs) ?? nominalMs;
|
|
166
166
|
const result = await client.request(input.action === "snapshot"
|
|
167
|
-
? { t: "ui_snapshot", after: input.after ?? 0, ...(input.memory !== undefined ? { memory: input.memory } : {}) }
|
|
167
|
+
? { t: "ui_snapshot", after: input.after ?? 0, ...(input.memory !== undefined ? { memory: input.memory } : {}), ...(dashboardView(input.view) ? { view: input.view } : {}) }
|
|
168
168
|
: { t: "ui_action", action: input, instanceId: input.instanceId }, timeoutMs);
|
|
169
169
|
// ControlClient resolves its deadline as this exact sentinel; a hub refusal stays a known failure.
|
|
170
170
|
if (["start_peer", "backlog_split"].includes(String(input.action)) && result.ok === false && result.error === `no answer from the hub within ${Math.round(timeoutMs / 1000)} s`) {
|