@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.
Files changed (40) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +6 -4
  3. package/docs/agent-notes/adapters.md +1 -1
  4. package/docs/agent-notes/budget.md +2 -1
  5. package/docs/agent-notes/daemon.md +8 -1
  6. package/docs/agent-notes/tasks.md +7 -4
  7. package/docs/agent-notes/tests.md +1 -1
  8. package/docs/dogfood.md +10 -7
  9. package/docs/operations.md +45 -13
  10. package/docs/quickstart.md +6 -2
  11. package/docs/sdlc.md +23 -2
  12. package/docs/security.md +9 -5
  13. package/docs/specs/2026-09-19-agent-hub-design.md +38 -0
  14. package/docs/verification/2026-10-22-codex-reviewer-isolation.md +14 -0
  15. package/docs/verification/2026-10-23-0.14.0.md +69 -0
  16. package/docs/verified.json +20 -15
  17. package/package.json +1 -1
  18. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  19. package/plugins/agent-hub/server.js +91 -20
  20. package/src/adapters/codex-review-isolation.ts +18 -7
  21. package/src/adapters/definitions/builtin-metadata.ts +1 -1
  22. package/src/adapters/definitions/native-cli-entry-codex.ts +13 -0
  23. package/src/adapters/reviewer-print.ts +34 -7
  24. package/src/cli/help.ts +2 -1
  25. package/src/cli/main.ts +5 -0
  26. package/src/cli/output.ts +47 -2
  27. package/src/cli/status-lines.ts +1 -1
  28. package/src/hub/board.ts +22 -1
  29. package/src/hub/daemon.ts +84 -14
  30. package/src/hub/hub-tools.ts +2 -1
  31. package/src/hub/instances.ts +14 -4
  32. package/src/hub/manager.ts +2 -2
  33. package/src/hub/sdlc-metrics.ts +260 -0
  34. package/src/hub/sdlc.ts +5 -3
  35. package/src/hub/spec-read.ts +31 -0
  36. package/src/hub/task-code-group.ts +52 -0
  37. package/src/hub/tasks.ts +172 -22
  38. package/src/hub/ui.ts +3 -0
  39. package/src/hub/usage-rollup.ts +58 -0
  40. 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
- export function renderBoard(tasks: any[], columns?: number, now = Date.now(), _full = false, allTasks: any[] | null = tasks): Span[][] {
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[][] {
@@ -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 ceiling = definitionOf(peer)?.routing?.allowedRoles;
294
+ const definition = definitionOf(peer), ceiling = definition?.routing?.allowedRoles;
294
295
  const declared = assigned as string[];
295
- roles[peer] = ceiling ? declared.filter(role => ceiling.includes(role)) : declared;
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
- if (t.state === "backlog") return line(t);
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") reviewerUnavailable.set(peer, "reviewer isolation unavailable; restart after checking the verified CLI and isolation requirements");
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
- : "reviewer isolation unavailable; inspect the verified CLI version and isolation requirements";
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
  }
@@ -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. S7 owns automatic band proposals.", { repo: str }),
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);
@@ -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 || entry.worktree) return fail(path, "a channel instance starts only from the person's terminal (ahub <kind> --as <id>): start_mode and worktree are not supported; a headless reviewer declares roles [\"reviewer\"] and start_mode \"headless\"");
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`, "a channel instance must declare its roles: reviewer and/or planner");
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
- return [...(declaration?.roles ?? ownRoles(id) ?? ownRoles(definition.kind) ?? definition.roles)];
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
  }
@@ -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`) {