@staix/agent-hub 0.12.15 → 0.12.17

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 (53) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +18 -5
  3. package/docs/agent-notes/adapters.md +1 -0
  4. package/docs/agent-notes/budget.md +5 -1
  5. package/docs/agent-notes/bus.md +2 -0
  6. package/docs/agent-notes/tasks.md +3 -1
  7. package/docs/agent-notes/tests.md +3 -1
  8. package/docs/operations.md +277 -11
  9. package/docs/quickstart.md +4 -4
  10. package/docs/security.md +26 -1
  11. package/docs/smoke.md +93 -0
  12. package/docs/specs/2026-09-19-agent-hub-design.md +223 -0
  13. package/docs/verification/2026-10-09-agent-shell-t0.md +123 -0
  14. package/docs/verified.json +168 -0
  15. package/package.json +1 -1
  16. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  17. package/plugins/agent-hub/server.js +18 -6
  18. package/src/adapters/acp.ts +2 -2
  19. package/src/adapters/claude-channel.ts +3 -2
  20. package/src/adapters/codex-appserver.ts +10 -2
  21. package/src/adapters/local-worker.ts +5 -5
  22. package/src/adapters/pi.ts +4 -14
  23. package/src/cli/console-state.ts +213 -0
  24. package/src/cli/console.ts +201 -0
  25. package/src/cli/facts-hook.ts +8 -1
  26. package/src/cli/identity-audit.ts +66 -0
  27. package/src/cli/identity.ts +54 -0
  28. package/src/cli/init.ts +40 -30
  29. package/src/cli/launch.ts +31 -1
  30. package/src/cli/main.ts +95 -39
  31. package/src/cli/preview.ts +80 -0
  32. package/src/cli/status-lines.ts +8 -2
  33. package/src/cli/statusline-tee.ts +14 -0
  34. package/src/cli/tail-render.ts +17 -0
  35. package/src/cli/upgrade-runtime.ts +1 -1
  36. package/src/hub/board.ts +9 -2
  37. package/src/hub/bus.ts +82 -10
  38. package/src/hub/child-process.ts +11 -0
  39. package/src/hub/conductor.ts +196 -0
  40. package/src/hub/context-window.ts +72 -0
  41. package/src/hub/control-client.ts +3 -3
  42. package/src/hub/daemon.ts +492 -77
  43. package/src/hub/envelope.ts +1 -1
  44. package/src/hub/events.ts +7 -1
  45. package/src/hub/hub-tools.ts +14 -1
  46. package/src/hub/report.ts +53 -4
  47. package/src/hub/supervision.ts +152 -0
  48. package/src/hub/task-sweep.ts +59 -0
  49. package/src/hub/tasks.ts +86 -15
  50. package/src/hub/usage.ts +37 -2
  51. package/src/models/relay.ts +6 -1
  52. package/src/pi/launch.ts +21 -0
  53. package/src/ui/index.html +6 -0
@@ -14,7 +14,7 @@ export interface Envelope {
14
14
  kind: Kind;
15
15
  priority: Priority;
16
16
  body: string; // agent conclusions only, never tool noise
17
- refs?: { repo?: string; branch?: string; commit?: string; paths?: string[]; task?: string };
17
+ refs?: { repo?: string; branch?: string; commit?: string; paths?: string[]; task?: string; supervision?: boolean; supervisionKey?: string };
18
18
  /** The body must not be shown outside its recipients: console tail and log print a stub instead (PII tasks). */
19
19
  private?: boolean;
20
20
  ts: number;
package/src/hub/events.ts CHANGED
@@ -24,7 +24,12 @@ export type HubEvent =
24
24
  | { type: "state"; peer: string; state: string }
25
25
  | { type: "turn_start"; peer: string; turn: string }
26
26
  | { type: "turn_end"; peer: string; turn: string; ms: number; tokens?: number; files?: number; snapshotMs?: number }
27
- | { type: "native_turn_end"; peer: string }
27
+ | { type: "native_turn_end"; peer: string; id?: string }
28
+ | { type: "conduct"; peer: string; action: string; task?: number; target?: string }
29
+ | { type: "agent_cli"; peer: string; command: string; refused: boolean }
30
+ | { type: "permission"; id: string; peer: string; event: "requested" | "answered" | "expired" | "cancelled"; latencyMs?: number; surface?: "console" | "dashboard" | "terminal"; option?: "allow_once" | "allow_always" | "reject_once" | "reject_always" }
31
+ /** Completed native turns that received supervision, not feed enqueues or inferred costs. */
32
+ | { type: "supervision_turn"; peer: string; turn: string; tokens?: number; ms?: number }
28
33
  | { type: "hook_stats"; peer: string; n: number; startupMs: number; hubMs: number; maxStartupMs: number }
29
34
  | { type: "cohort"; id: number; event: "formed" | "joined" | "lifted"; silent: boolean; tasks: number[]; owners: string[] }
30
35
  | { type: "tokens"; peer: string; n: number }
@@ -32,6 +37,7 @@ export type HubEvent =
32
37
  | { type: "task"; id: number; event: string; by: string; state: string; owner: string | null; reviewer: string | null; class: string; pii: boolean }
33
38
  | { type: "overlap"; task: number; owner: string; others: { task: number; owner: string; paths: string[]; symbols?: string[] }[] }
34
39
  | { type: "conflict"; peer: string; task?: number; other: number; owner: string; paths: string[]; concurrent: boolean; turns?: [string, string] }
40
+ | { type: "context_pressure"; peer: string; source: string; measuredAt: number; used: number; window: number | null }
35
41
  | { type: "quota"; peer: string; windows: { id: string; used: number; resetsAt?: number }[]; hard: boolean; measuredAt?: string };
36
42
 
37
43
  export type StampedEvent = HubEvent & { v: number; at: string };
@@ -41,14 +41,27 @@ export const TASK_TOOLS: HubTool[] = [
41
41
  tool("hub_task_done", "Mark your task finished. It goes to its reviewer with your summary and refs. When the project configures a check for its class, the hub runs it first and the result comes as a task message: a failed check keeps the task with you.", { id, summary: { type: "string", description: "what changed, why, and the check you ran with its result" }, refs }, ["id", "summary"]),
42
42
  tool("hub_task_list", "The task board. PII tasks show as [pii].", { state: { type: "string", enum: ["proposed", "in_progress", "in_review", "approved", "changes_requested"] }, ready: { type: "boolean", description: "only proposed tasks with nothing left to wait for" } }),
43
43
  tool("hub_review", "Give your verdict on a task you were asked to review: map the changed signatures and call sites to the task's plan or detail, read the check result, and list what is unmet. Two changes_requested in a row move the task to another peer.", { id, verdict: { type: "string", enum: ["approved", "changes_requested"] }, note: str, unmet: { type: "array", items: str, description: "each requirement of the plan or detail that the change does not meet" } }, ["id", "verdict"]),
44
- tool("hub_checkpoint", "Answer a checkpoint request from the hub (your quota window is nearly used up): what you were doing, what is half done, what whoever continues must know. Write the same to .agenthub/checkpoint.md first if you can.", { summary: str }, ["summary"]),
44
+ tool("hub_checkpoint", "Answer a checkpoint request from the hub (quota or context window pressure): what you were doing, what is half done, what whoever continues must know. Write the same to .agenthub/checkpoint.md first if you can.", { summary: str, request_id: { type: "string", description: "The request id supplied by a context checkpoint request; required for context checkpoints" } }, ["summary"]),
45
45
  tool("hub_remember", "Save a decision, finding, contract or fail to the memory all agents share (claude-mem); the other agents also get it with their next message. A fail is an approach you tried that does not work, and why: the most useful note, it stops the others spending their quota on it. Do not retry what a fail note rules out without new evidence. Conclusions worth recalling, not chatter.", { text: str, title: str, kind: { type: "string", enum: [...NOTE_KINDS] }, task: id }, ["text"]),
46
46
  ];
47
47
 
48
48
  export const TASK_TOOL_NAMES = new Set(TASK_TOOLS.map((t) => t.name));
49
49
 
50
+ /** Separately published: serving a schema grants no authority, only the daemon's explicit role does. */
51
+ export const CONDUCTOR_TOOLS: HubTool[] = [
52
+ tool("hub_status", "Inspect public team state, holds, quota windows, task counts and pending approval peer/tool/age. Only the configured conductor may use this tool.", {}),
53
+ tool("hub_task_show", "Read a task's public view and history. PII tasks remain stubs. Only the conductor may use this tool.", { id }, ["id"]),
54
+ tool("hub_task_assign", "Move a task to another peer, as the conductor. Requires assign capability when the conductor has an explicit capabilities list.", { id, peer: str }, ["id", "peer"]),
55
+ tool("hub_task_escalate", "Escalate a task through the normal task flow, as the conductor. Requires assign capability when explicitly listed.", { id }, ["id"]),
56
+ tool("hub_peer_start", "Start local, kimi or headless pi. Claude, Codex and Pi TUI requests return a command for the person to run, without launching a terminal.", { peer: { type: "string", enum: ["local", "kimi", "pi", "claude", "codex"] }, mode: { type: "string", enum: ["headless", "tui"] } }, ["peer"]),
57
+ tool("hub_peer_hold", "Hold a peer's deliveries as the conductor. This hold is separate from the person's hold and budget pauses.", { peer: str }, ["peer"]),
58
+ tool("hub_peer_release", "Release only the conductor hold you placed. Never lifts a person's hold or a budget pause.", { peer: str }, ["peer"]),
59
+ ];
60
+ export const CONDUCTOR_TOOL_NAMES = new Set(CONDUCTOR_TOOLS.map((t) => t.name));
61
+
50
62
  /** Role contracts, by role name. Shown to each peer for the roles `.agenthub/config.json` gives it. */
51
63
  export const ROLE_TEXT: Record<string, string> = {
64
+ conductor: "conductor: plan and split work into tasks with owners, watch the team with hub_status, move stalled work, and ensure every task is reviewed (review it yourself only if you also hold reviewer). Report results and open decisions to the person. Do not implement tasks you handed out. Never ask a peer to answer an approval; ask the person for human-only actions. You may release only holds you placed, never human holds or budget pauses.",
52
65
  planner: "planner: break work into tasks with hub_task_propose (one outcome each, the right class, paths in refs, and after: [ids] for work that must wait for other tasks) instead of doing everything yourself.",
53
66
  implementer: "implementer: accept tasks assigned to you, do them, and finish with hub_task_done (summary: what changed, why, and the check you ran with its result; refs). Decline what you cannot do. Before starting work nobody assigned you, claim it with hub_task_propose naming yourself as owner, with the paths in refs. With a claim or an accept, give a plan: the files, symbols and signatures you will change and where new code goes.",
54
67
  verifier: "verifier: run the checks a task names and report what passed and what did not in hub_task_done.",
package/src/hub/report.ts CHANGED
@@ -3,7 +3,7 @@ import type { StampedEvent } from "./events.ts";
3
3
  export interface Report {
4
4
  from?: string;
5
5
  to?: string;
6
- peers: Record<string, { turns: number; busyMinutes: number; tokens: number }>;
6
+ peers: Record<string, { turns: number | null; busyMinutes: number; tokens: number; turnSource?: "native-stop" | "logical-state" | "unknown" }>;
7
7
  usage: { peers: Record<string, UsageCoverage>; totals: UsageTotals; unknownPeers: string[]; completeCoverage: boolean; estimatedUsd?: number; measuredSpendUsd?: number };
8
8
  messages: { total: number; dropped: Record<string, number>; overflow: number; undeliverable: number; perTask: number };
9
9
  overlaps: { warnings: number; pairs: number };
@@ -11,6 +11,8 @@ export interface Report {
11
11
  conflicts: number;
12
12
  tasks: Record<string, number>;
13
13
  quota: { readings: number; hard: number };
14
+ conduct: Record<string, Record<string, number>>;
15
+ supervision: Record<string, { turns: number | null; tokens: number | null; measuredTokens: number | null; knownTokenTurns: number; unknownTokenTurns: number }>;
14
16
  }
15
17
 
16
18
  export interface UsageCoverage {
@@ -83,16 +85,39 @@ function formatUsage(usage: Report["usage"]): string[] {
83
85
 
84
86
  /** The numbers `ahub report` prints, from `events.jsonl` alone (issue #40). */
85
87
  export function summarize(events: StampedEvent[]): Report {
86
- const r: Report = { peers: {}, usage: { peers: {}, totals: { inputTokens: 0, inputRecords: 0, outputTokens: 0, outputRecords: 0, cacheReadTokens: 0, cacheReadRecords: 0, cacheWriteTokens: 0, cacheWriteRecords: 0, totalTokens: 0, totalRecords: 0 }, unknownPeers: [], completeCoverage: true }, messages: { total: 0, dropped: {}, overflow: 0, undeliverable: 0, perTask: 0 }, overlaps: { warnings: 0, pairs: 0 }, conflicts: 0, tasks: {}, quota: { readings: 0, hard: 0 } };
88
+ const r: Report = { peers: {}, conduct: {}, supervision: {}, usage: { peers: {}, totals: { inputTokens: 0, inputRecords: 0, outputTokens: 0, outputRecords: 0, cacheReadTokens: 0, cacheReadRecords: 0, cacheWriteTokens: 0, cacheWriteRecords: 0, totalTokens: 0, totalRecords: 0 }, unknownPeers: [], completeCoverage: true }, messages: { total: 0, dropped: {}, overflow: 0, undeliverable: 0, perTask: 0 }, overlaps: { warnings: 0, pairs: 0 }, conflicts: 0, tasks: {}, quota: { readings: 0, hard: 0 } };
89
+ const supervisor = (id: string) => (r.supervision[id] ??= { turns: null, tokens: null, measuredTokens: null, knownTokenTurns: 0, unknownTokenTurns: 0 });
87
90
  const peer = (id: string) => (r.peers[id] ??= { turns: 0, busyMinutes: 0, tokens: 0 });
91
+ const claudeNativeStops = events.some(event => event.type === "native_turn_end" && event.peer === "claude" && !!event.id);
92
+ const seenNativeStops = new Set<string>();
88
93
  const usagePeer = (id: string) => (r.usage.peers[id] ??= emptyUsage());
89
94
  const pairs = new Set<string>();
90
95
  const taskMessages = new Map<string, number>();
91
96
  const seenUsage = new Set<string>();
97
+ const seenSupervision = new Set<string>();
92
98
  for (const e of events) {
93
99
  r.from ??= e.at;
94
100
  r.to = e.at;
95
101
  switch (e.type) {
102
+ case "conduct": {
103
+ const actions = r.conduct[e.peer] ??= {};
104
+ actions[e.action] = (actions[e.action] ?? 0) + 1;
105
+ supervisor(e.peer);
106
+ break;
107
+ }
108
+ case "supervision_turn": {
109
+ const key = `${e.peer}\0${e.turn}`;
110
+ if (seenSupervision.has(key)) break;
111
+ seenSupervision.add(key);
112
+ const s = supervisor(e.peer);
113
+ s.turns = (s.turns ?? 0) + 1;
114
+ if (typeof e.tokens === "number" && Number.isSafeInteger(e.tokens) && e.tokens >= 0) {
115
+ s.measuredTokens = (s.measuredTokens ?? 0) + e.tokens;
116
+ s.tokens = s.unknownTokenTurns ? null : s.measuredTokens;
117
+ s.knownTokenTurns++;
118
+ } else { s.unknownTokenTurns++; s.tokens = null; }
119
+ break;
120
+ }
96
121
  case "envelope":
97
122
  r.messages.total++;
98
123
  if (e.dropped) r.messages.dropped[e.dropped] = (r.messages.dropped[e.dropped] ?? 0) + 1;
@@ -106,9 +131,16 @@ export function summarize(events: StampedEvent[]): Report {
106
131
  r.messages.dropped.stale = (r.messages.dropped.stale ?? 0) + 1;
107
132
  break;
108
133
  case "turn_end":
109
- peer(e.peer).turns++;
134
+ if (e.peer !== "claude" || !claudeNativeStops) peer(e.peer).turns = (peer(e.peer).turns ?? 0) + 1;
110
135
  peer(e.peer).busyMinutes += e.ms / 60_000;
111
136
  break;
137
+ case "native_turn_end":
138
+ if (e.peer === "claude" && e.id) {
139
+ if (seenNativeStops.has(e.id)) break;
140
+ seenNativeStops.add(e.id);
141
+ peer(e.peer).turns = (peer(e.peer).turns ?? 0) + 1;
142
+ }
143
+ break;
112
144
  case "state":
113
145
  peer(e.peer);
114
146
  break;
@@ -143,13 +175,30 @@ export function summarize(events: StampedEvent[]): Report {
143
175
  // Pricing is intentionally absent: a token count is neither an estimated price nor provider-reported spend.
144
176
  r.messages.perTask = taskMessages.size ? Number(([...taskMessages.values()].reduce((a, b) => a + b, 0) / taskMessages.size).toFixed(1)) : 0;
145
177
  for (const p of Object.values(r.peers)) p.busyMinutes = Number(p.busyMinutes.toFixed(1));
178
+ if (r.peers.claude) {
179
+ const logical = events.some(event => event.type === "turn_end" && event.peer === "claude");
180
+ r.peers.claude.turnSource = claudeNativeStops ? "native-stop" : logical ? "logical-state" : "unknown";
181
+ if (!claudeNativeStops && !logical) r.peers.claude.turns = null;
182
+ }
146
183
  return r;
147
184
  }
148
185
 
149
186
  export function formatReport(r: Report): string[] {
150
187
  const lines = [`period: ${r.from ?? "-"} .. ${r.to ?? "-"}`];
151
- for (const [id, p] of Object.entries(r.peers).sort(([a], [b]) => a.localeCompare(b))) lines.push(`peer ${id}: ${p.turns} turn${p.turns === 1 ? "" : "s"}, ${p.busyMinutes} busy minutes, ${p.tokens ? `${p.tokens} tokens` : id === "pi" ? "tokens not reported" : "- tokens"}`);
188
+ for (const [id, p] of Object.entries(r.peers).sort(([a], [b]) => a.localeCompare(b))) {
189
+ const turns = p.turns === null ? "turns unknown" : `${p.turns} turn${p.turns === 1 ? "" : "s"}${p.turnSource === "native-stop" ? " (native Stop)" : p.turnSource === "logical-state" ? " (logical state, native completion unobserved)" : ""}`;
190
+ lines.push(`peer ${id}: ${turns}, ${p.busyMinutes} busy minutes, ${p.tokens ? `${p.tokens} tokens` : id === "pi" ? "tokens not reported" : "- tokens"}`);
191
+ }
152
192
  if (Object.keys(r.usage.peers).length) lines.push(...formatUsage(r.usage));
193
+ for (const [id, s] of Object.entries(r.supervision).sort(([a], [b]) => a.localeCompare(b))) {
194
+ const turns = s.turns === null ? "turns unknown" : `${s.turns} completed native turns`;
195
+ const tokens = s.tokens === null ? "tokens unknown" : `${s.tokens} tokens`;
196
+ const measured = s.measuredTokens === null ? "measured subset unknown" : `${s.measuredTokens} measured tokens (${s.knownTokenTurns} known turns)`;
197
+ lines.push(`supervision ${id}: ${turns}; ${tokens}; ${measured}; ${s.unknownTokenTurns} completed turns with unknown tokens; whole native turns containing supervision, including other work; estimated price unknown; measured spend unknown`);
198
+ }
199
+ for (const [id, actions] of Object.entries(r.conduct).sort(([a], [b]) => a.localeCompare(b))) {
200
+ lines.push(`conductor ${id}: ${Object.entries(actions).sort(([a], [b]) => a.localeCompare(b)).map(([action, n]) => `${action} ${n}`).join(", ")}`);
201
+ }
153
202
  const dropped = Object.entries(r.messages.dropped).map(([k, n]) => `${n} ${k}`).join(", ");
154
203
  lines.push(`messages: ${r.messages.total} (dropped: ${dropped || "none"}; overflow ${r.messages.overflow}; undeliverable ${r.messages.undeliverable}); ${r.messages.perTask} per task that had any`);
155
204
  lines.push(`overlap warnings: ${r.overlaps.warnings}, task pairs: ${r.overlaps.pairs}; edit conflicts: ${r.conflicts}`);
@@ -0,0 +1,152 @@
1
+ import type { HistoryEntry, Task, TaskMoveReason } from "./board.ts";
2
+ import { sanitize, type Kind, type Priority } from "./envelope.ts";
3
+
4
+ export type SupervisionScope = "own" | "all" | "off";
5
+ export type SupervisionMilestone = "assigned" | "accepted" | "declined" | "done" | "review" | "approved" | "moved" | "escalated" | "stuck" | "sweep";
6
+ /** Codes, never free-form notes or check output. */
7
+ export type SupervisionReason = TaskMoveReason | "repetition" | "false_progress" | "drift" | "desperation" | "capability_gap";
8
+ export interface SupervisionFields {
9
+ by?: string;
10
+ owner?: string | null;
11
+ check?: "passed" | "failed" | "unknown";
12
+ verdict?: "approved" | "changes_requested";
13
+ reason?: SupervisionReason;
14
+ sweep?: "unaccepted-assignment" | "idle-owner" | "review-pending";
15
+ }
16
+ export interface SupervisionNotice {
17
+ peer: string;
18
+ /** Replace only queued entries with this key; accepted/in-flight deliveries stay authoritative. */
19
+ key: string;
20
+ body: string;
21
+ priority: Priority;
22
+ kind: Kind;
23
+ task?: number;
24
+ }
25
+ export interface SupervisionDeps {
26
+ /** Current role holder; undefined immediately revokes the feed. */
27
+ conductor: () => string | undefined;
28
+ scope: () => SupervisionScope;
29
+ tasks: () => Task[];
30
+ publicTitle: (task: Task) => string;
31
+ isPrivate: (task: Task) => boolean;
32
+ /** false means not admitted, so latches must remain retryable. No separate batching timer. */
33
+ emit: (notice: SupervisionNotice) => boolean | void;
34
+ approvalAgeMs?: number;
35
+ now?: () => number;
36
+ /** Durable round state belongs to the daemon's store, not the controller. */
37
+ readRound?: (peer: string) => string | undefined;
38
+ writeRound?: (peer: string, signature: string) => void;
39
+ }
40
+
41
+ /** Deterministic milestone producer. The Bus alone owns batching, retry and native turns. */
42
+ export class SupervisionFeed {
43
+ private readonly approvals = new Set<string>();
44
+ private readonly holds = new Set<string>();
45
+ private readonly rounds = new Map<string, string>();
46
+ constructor(private readonly d: SupervisionDeps) {}
47
+
48
+ /** Pending notices may have been withdrawn on role/off change; live requests can notify again on regrant. */
49
+ resetPending(peer: string): void {
50
+ for (const key of this.approvals) if (key.startsWith(`${peer}:`)) this.approvals.delete(key);
51
+ for (const key of this.holds) if (key.startsWith(`${peer}:`)) this.holds.delete(key);
52
+ }
53
+
54
+ private recipient(): string | undefined {
55
+ return this.d.scope() === "off" ? undefined : this.d.conductor();
56
+ }
57
+
58
+ includes(task: Task): boolean {
59
+ const peer = this.recipient();
60
+ return !!peer && (this.d.scope() === "all" || task.history.some((h) => h.by === peer && ["proposed", "assigned", "reassigned", "escalated"].includes(h.event)));
61
+ }
62
+
63
+ private send(key: string, body: string, priority: Priority = "status", task?: number, kind: Kind = "task"): boolean {
64
+ const peer = this.recipient();
65
+ if (!peer) return false;
66
+ try {
67
+ return this.d.emit({ peer, key: `supervision:${peer}:${key}`, body: sanitize(body), priority, kind, ...(task === undefined ? {} : { task }) }) !== false;
68
+ } catch { return false; } // a feed failure must not invalidate a successful board write
69
+ }
70
+
71
+ milestone(id: number, milestone: SupervisionMilestone, fields: SupervisionFields = {}): boolean {
72
+ const task = this.d.tasks().find((t) => t.id === id);
73
+ if (!task || !this.includes(task)) return false;
74
+ if (this.d.isPrivate(task)) return this.send(`task:${id}:${milestone}`, `#${id} [pii]: ${milestone}; ahub task show ${id}`, "status", id);
75
+ const bits = [this.d.publicTitle(task), milestone];
76
+ if (fields.by) bits.push(`by ${fields.by}`);
77
+ if ("owner" in fields) bits.push(`owner ${fields.owner ?? "none"}`);
78
+ if (fields.check) bits.push(`check ${fields.check}`);
79
+ if (fields.verdict) bits.push(`verdict ${fields.verdict}`);
80
+ if (fields.reason) bits.push(`reason ${fields.reason}`);
81
+ if (fields.sweep) bits.push(`finding ${fields.sweep}`);
82
+ return this.send(`task:${id}:${milestone}`, bits.join("; "), "status", id);
83
+ }
84
+
85
+ /** Only structured history metadata is read; notes may contain private content. */
86
+ taskChanged(task: Task, entry: HistoryEntry, metadata: SupervisionFields = {}): void {
87
+ const fields: SupervisionFields = { by: entry.by, owner: task.owner, ...(entry.reason ? { reason: entry.reason } : {}), ...metadata };
88
+ const event = entry.event;
89
+ if (event === "done" || event === "check failed") {
90
+ const latestCheck = task.history.findLast((h) => ["check passed", "check failed", "check interrupted", "done (checking)", "accepted", "reopened"].includes(h.event));
91
+ fields.check = event === "check failed" ? "failed" : metadata.check ?? (latestCheck?.event === "check passed" ? "passed" : "unknown");
92
+ this.milestone(task.id, "done", fields);
93
+ if (event === "done" && task.state === "approved") this.milestone(task.id, "approved", fields);
94
+ } else if (event === "changes_requested") this.milestone(task.id, "review", { ...fields, verdict: "changes_requested" });
95
+ else if (event === "approved") {
96
+ this.milestone(task.id, "review", { ...fields, verdict: "approved" });
97
+ this.milestone(task.id, "approved", fields);
98
+ } else if (event === "idle sweep" && entry.sweep) this.milestone(task.id, "sweep", { ...fields, sweep: entry.sweep.kind });
99
+ else if (event === "reassigned") this.milestone(task.id, "moved", fields);
100
+ else if (["assigned", "accepted", "declined", "escalated"].includes(event)) this.milestone(task.id, event as SupervisionMilestone, fields);
101
+ this.checkRound();
102
+ }
103
+
104
+ peerOffline(peer: string): boolean {
105
+ if (this.d.scope() !== "all" && !this.d.tasks().some((t) => this.includes(t) && t.state !== "approved" && (t.owner === peer || t.reviewer === peer))) return false;
106
+ return this.send(`peer:${peer}:offline`, `${peer}: offline`);
107
+ }
108
+
109
+ budgetPaused(peer: string, resetsAt?: number): boolean {
110
+ if (this.d.scope() !== "all" && !this.d.tasks().some((t) => this.includes(t) && t.state !== "approved" && (t.owner === peer || t.reviewer === peer))) return false;
111
+ const resetDate = new Date(resetsAt ?? NaN);
112
+ const reset = Number.isFinite(resetDate.getTime()) ? resetDate.toISOString() : "unknown";
113
+ return this.send(`peer:${peer}:budget`, `${peer}: budget paused; reset ${reset}`, "status", undefined, "budget");
114
+ }
115
+
116
+ approvalWaiting(request: { id: string; peer: string; tool: string; createdAt: number }): boolean {
117
+ const peer = this.recipient();
118
+ const age = (this.d.now?.() ?? Date.now()) - request.createdAt;
119
+ const key = `${peer}:${request.id}`;
120
+ if (!peer || !Number.isFinite(age) || age < (this.d.approvalAgeMs ?? 60_000) || this.approvals.has(key)) return false;
121
+ const sent = this.send(`approval:${request.id}`, `${request.peer}: approval waiting; tool ${request.tool}; age ${Math.floor(age / 1000)}s; ask the person to answer it in ahub console`, "important");
122
+ if (sent) this.approvals.add(key);
123
+ return sent;
124
+ }
125
+
126
+ needsReview(peer: string, deliveryId: string): boolean {
127
+ const conductor = this.recipient();
128
+ const key = `${conductor}:${peer}:${deliveryId}`;
129
+ if (!conductor || this.holds.has(key)) return false;
130
+ const sent = this.send(`hold:${peer}:${deliveryId}`, `${peer}: needs_review hold; delivery ${deliveryId}; ask the person to resolve it with ahub queue resolve`, "important");
131
+ if (sent) this.holds.add(key);
132
+ return sent;
133
+ }
134
+
135
+ checkRound(): boolean {
136
+ const peer = this.recipient();
137
+ if (!peer) return false;
138
+ const tasks = this.d.tasks().filter((t) => this.includes(t));
139
+ if (!tasks.length || tasks.some((t) => t.state !== "approved")) return false;
140
+ const signature = tasks.map((t) => t.id).sort((a, b) => a - b).join(",");
141
+ const previous = this.d.readRound?.(peer) ?? this.rounds.get(peer);
142
+ const completed = new Set(previous?.split(",") ?? []);
143
+ const newlyJoined = tasks.filter((t) => !completed.has(String(t.id)));
144
+ if (!newlyJoined.length) return false;
145
+ const sent = this.send("round", `Supervision round complete: ${newlyJoined.length} task${newlyJoined.length === 1 ? "" : "s"} approved; report the results and open decisions to the person.`);
146
+ if (sent) {
147
+ this.rounds.set(peer, signature);
148
+ this.d.writeRound?.(peer, signature);
149
+ }
150
+ return sent;
151
+ }
152
+ }
@@ -0,0 +1,59 @@
1
+ import type { Task } from "./board.ts";
2
+
3
+ export type SweepKind = "unaccepted-assignment" | "idle-owner" | "review-pending";
4
+ export interface SweepRecord {
5
+ kind: SweepKind;
6
+ /** Index of the last real activity, distinguishing even events at the same time. */
7
+ activity: number;
8
+ step: 1 | 2 | 3;
9
+ at: number;
10
+ }
11
+ export interface TaskSweepConfig {
12
+ enabled: boolean;
13
+ interval_s: number;
14
+ unaccepted_min: number;
15
+ idle_min: number;
16
+ review_min: number;
17
+ ladder_min: number;
18
+ auto_reassign: boolean;
19
+ }
20
+ export const DEFAULT_TASK_SWEEP: TaskSweepConfig = {
21
+ enabled: false, interval_s: 300, unaccepted_min: 60, idle_min: 120, review_min: 120, ladder_min: 30, auto_reassign: false,
22
+ };
23
+
24
+ /** Config cannot silently enable a sweep or reassignment through a truthy string. */
25
+ export function taskSweepConfig(input: unknown): TaskSweepConfig {
26
+ if (input === undefined) return { ...DEFAULT_TASK_SWEEP };
27
+ if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error("task_sweep must be an object");
28
+ const out = { ...DEFAULT_TASK_SWEEP };
29
+ const values = input as Record<string, unknown>;
30
+ for (const key of ["enabled", "auto_reassign"] as const) {
31
+ if (values[key] === undefined) continue;
32
+ if (typeof values[key] !== "boolean") throw new Error(`task_sweep.${key} must be a boolean`);
33
+ out[key] = values[key];
34
+ }
35
+ for (const key of ["interval_s", "unaccepted_min", "idle_min", "review_min", "ladder_min"] as const) {
36
+ if (values[key] === undefined) continue;
37
+ const value = values[key];
38
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 1 || value * (key === "interval_s" ? 1000 : 60_000) > 2_147_483_647) throw new Error(`task_sweep.${key} must be a bounded number of at least 1`);
39
+ out[key] = value;
40
+ }
41
+ return out;
42
+ }
43
+
44
+ /** Pure time/kind ladder; Tasks decides whether the responsible peer is available. */
45
+ export function nextSweep(task: Task, config: TaskSweepConfig, now: number): SweepRecord | undefined {
46
+ if (!config.enabled || !Number.isFinite(now)) return undefined;
47
+ const kind = task.state === "proposed" && task.owner ? "unaccepted-assignment"
48
+ : task.state === "in_progress" && task.owner ? "idle-owner"
49
+ : task.state === "in_review" && task.reviewer ? "review-pending" : undefined;
50
+ if (!kind) return undefined;
51
+ let activity = task.history.length - 1;
52
+ while (activity >= 0 && task.history[activity]?.sweep) activity--;
53
+ const at = task.history[activity]?.at ?? task.created;
54
+ const threshold = kind === "unaccepted-assignment" ? config.unaccepted_min : kind === "idle-owner" ? config.idle_min : config.review_min;
55
+ if (now - at < threshold * 60_000) return undefined;
56
+ const previous = task.history.flatMap((h) => h.sweep?.kind === kind && h.sweep.activity === activity ? [h.sweep] : []).at(-1);
57
+ if (previous && (previous.step === 3 || now - previous.at < config.ladder_min * 60_000)) return undefined;
58
+ return { kind, activity, step: previous ? (previous.step + 1) as 2 | 3 : 1, at: now };
59
+ }
package/src/hub/tasks.ts CHANGED
@@ -1,13 +1,14 @@
1
1
  import { isAbsolute, relative } from "node:path";
2
2
  import type { Briefs } from "../memory/brief.ts";
3
3
  import type { MemoryClient } from "../memory/client.ts";
4
- import { CLASSES, OUTCOMES_KEPT_MS, PLAN_KEYS, type Board, type Task, type TaskClass, type TaskPlan, type TaskRefs } from "./board.ts";
4
+ import { CLASSES, OUTCOMES_KEPT_MS, PLAN_KEYS, TASK_MOVE_REASONS, type Board, type Task, type TaskClass, type TaskMoveReason, type TaskPlan, type TaskRefs } from "./board.ts";
5
5
  import type { Bus } from "./bus.ts";
6
6
  import { HUB, newEnvelope, NOTE_KINDS, noteLine, USER, type Envelope, type PeerId, type PeerState } from "./envelope.ts";
7
7
  import { assign, detectSignals, LOCAL, PI, predictSplit, type Assignment, type Routing, type SplitObservation, type SplitPrediction } from "./routing.ts";
8
8
  import { ExecutionBudget, type ExecutionBudgetConfig, type ExecutionBudgetDecision, type ExecutionBudgetStatus, type ExecutionUnit } from "./execution-budget.ts";
9
9
  import { Cohorts, MAX_REQUESTS, type Cohort, type Completion } from "./cohorts.ts";
10
10
  import { realPath } from "./project.ts";
11
+ import { nextSweep, taskSweepConfig, type TaskSweepConfig, type SweepRecord } from "./task-sweep.ts";
11
12
 
12
13
  export interface TasksDeps {
13
14
  board: Board;
@@ -68,6 +69,9 @@ export interface TasksDeps {
68
69
  recordSplit?: (task: number, prediction: SplitPrediction, where: "routing" | "cohort") => void;
69
70
  /** A cohort formed, changed or was lifted (issue #107), for the record: the benchmark's treatment check reads it. */
70
71
  recordCohort?: (cohort: { id: number; event: "formed" | "joined" | "lifted"; silent: boolean; tasks: number[]; owners: PeerId[] }) => void;
72
+ sweep?: TaskSweepConfig;
73
+ /** Shutdown or coordinator work holds task sweeps too. */
74
+ sweepHeld?: () => boolean;
71
75
  }
72
76
 
73
77
  const ESCALATE_AFTER = 2;
@@ -80,7 +84,7 @@ const DEMOTE_AT = 1.5;
80
84
  const CONTRADICTION_WINDOW_MS = 7 * 86_400_000;
81
85
  const OPEN: Task["state"][] = ["proposed", "in_progress", "changes_requested"];
82
86
  /** Board events that leave a task where its completion check found it; any other event means it moved on meanwhile. */
83
- const QUIET_EVENTS = new Set(["answer", "reviewer changed"]);
87
+ const QUIET_EVENTS = new Set(["answer", "reviewer changed", "idle sweep"]);
84
88
 
85
89
  /** A project path as one spelling (#67): no leading `./`, no repeated or trailing `/`; the root is `.`. */
86
90
  export const normPath = (p: string) => p.replace(/^(\.\/)+/, "").replace(/\/{2,}/g, "/").replace(/\/+$/, "") || ".";
@@ -134,8 +138,11 @@ const planText = (plan: TaskPlan = {}) => PLAN_KEYS.filter((k) => plan[k]?.lengt
134
138
  export class Tasks {
135
139
  /** Turn-free cohorts (issue #107): who works without messages, and who integrates. */
136
140
  readonly cohorts: Cohorts;
141
+ private readonly sweepConfig: TaskSweepConfig;
142
+ private sweeping = false;
137
143
 
138
144
  constructor(private readonly d: TasksDeps) {
145
+ this.sweepConfig = taskSweepConfig(d.sweep);
139
146
  this.cohorts = new Cohorts({
140
147
  silence: (owners) => this.turnFree() && owners.every((p) => p !== USER && p !== HUB && (this.d.capable?.(p) ?? false)),
141
148
  idle: (peer) => this.d.idle?.(peer) ?? false,
@@ -157,6 +164,55 @@ export class Tasks {
157
164
 
158
165
  isPii = (task: Pick<Task, "signals">) => task.signals.includes("pii") && this.d.routing().constraints.pii === "local_only";
159
166
 
167
+ /** Task-owned, default-off between-turn ladder. Persist before sending so restart cannot repeat a step. */
168
+ async sweep(now = Date.now()): Promise<{ task: number; finding: SweepRecord }[]> {
169
+ const recorded: { task: number; finding: SweepRecord }[] = [];
170
+ if (!this.sweepConfig.enabled || this.sweeping || this.d.sweepHeld?.() || this.d.bus.isRecoveryHeld || this.d.bus.storageError) return recorded;
171
+ this.sweeping = true;
172
+ try {
173
+ for (const task of this.d.board.list()) {
174
+ if (this.d.sweepHeld?.() || this.d.bus.isRecoveryHeld || this.d.bus.storageError) break;
175
+ const finding = nextSweep(task, this.sweepConfig, now);
176
+ if (!finding || this.waitsFor(task).length || this.isChecking(task.id) || this.silentFor(task.id)) continue;
177
+ const responsible = finding.kind === "review-pending" ? task.reviewer! : task.owner!;
178
+ if (responsible !== USER && !this.sweepAvailable(responsible)) continue;
179
+ const title = this.publicTitle(task);
180
+ const message = `Task ${title}: ${finding.kind}, idle escalation ${finding.step}/3.`;
181
+ // Only identifiers, kind and step are kept in the ladder event. A crash after this write and before publish
182
+ // leaves the notice unpublished or uncertain; the durable-delivery journal owns any delivery retries.
183
+ this.d.board.update(task.id, HUB, "idle sweep", {}, undefined, { sweep: finding });
184
+ recorded.push({ task: task.id, finding });
185
+ if (finding.step === 1) {
186
+ this.sweepNotice(task, responsible, `${message} Check its current state with hub_task_list and accept, complete or review your assigned work.`);
187
+ } else if (finding.step === 2) {
188
+ this.d.notify(message);
189
+ for (const [peer, roles] of Object.entries(this.d.roles ?? {})) {
190
+ if (peer !== responsible && roles.includes("planner") && this.sweepAvailable(peer)) this.sweepNotice(task, peer, `${message} The assigned ${finding.kind === "review-pending" ? "reviewer" : "owner"} has not recorded fresh task activity; check the board before coordinating.`);
191
+ }
192
+ } else {
193
+ const reviewer = finding.kind === "review-pending";
194
+ const a = assign(task, this.states(), this.d.routing(), { exclude: [...this.declined(task), responsible], waitsFor: this.waitsFor(task), ...(reviewer ? { candidates: [], notReviewer: task.owner ?? undefined } : {}), ...this.weights(task.class) });
195
+ const candidate = reviewer ? a.reviewer : a.owner;
196
+ this.d.notify(`${message} ${reviewer ? "Reviewer" : "Owner"} reassignment suggestion: ${candidate ?? "no eligible peer"}.`);
197
+ if (!reviewer && candidate && this.sweepConfig.auto_reassign && this.sweepAvailable(candidate)) {
198
+ await this.assignOwner(this.d.board.get(task.id)!, HUB, { candidates: [candidate], exclude: [responsible], event: "reassigned", reason: "idle", note: `idle sweep: ${finding.kind}; from ${responsible}` });
199
+ }
200
+ }
201
+ }
202
+ } finally { this.sweeping = false; }
203
+ return recorded;
204
+ }
205
+
206
+ private sweepAvailable(peer: PeerId): boolean {
207
+ const bus = this.d.bus;
208
+ return bus.stateOf(peer) === "idle" && (this.d.idle?.(peer) ?? true) && !this.d.held?.()[peer] && !bus.queueSummary(peer).heldBy && !bus.queued(peer) && !bus.hasInFlight(peer);
209
+ }
210
+
211
+ private sweepNotice(task: Task, peer: PeerId, body: string): void {
212
+ if (peer === USER) this.d.notify(body);
213
+ else this.d.bus.publish(newEnvelope(HUB, body, { to: [peer], kind: "task", refs: { task: String(task.id) } }));
214
+ }
215
+
160
216
  configureExecutionBudget = (config: ExecutionBudgetConfig): ExecutionBudgetStatus | undefined => {
161
217
  if (!this.d.executionBudget) return undefined;
162
218
  if (config.kind === "task" && (!config.taskId || !this.d.board.get(config.taskId))) throw new Error(`task #${config.taskId ?? "?"} does not exist`);
@@ -180,9 +236,19 @@ export class Tasks {
180
236
  publicTitle = (task: Task) => (this.isPii(task) ? `#${task.id} [pii]` : `#${task.id} ${task.title}`);
181
237
 
182
238
  /** A task as a cloud peer may see it. */
183
- publicView(task: Task): Record<string, unknown> {
239
+ publicView(task: Task, fullHistory = false): Record<string, unknown> {
184
240
  const { title, detail, history, ...rest } = task;
185
- return this.isPii(task) ? { ...rest, refs: {}, plan: {}, title: "[pii]", detail: "[pii]" } : { ...rest, title, detail, history: history.slice(-5) };
241
+ if (this.isPii(task)) return { ...rest, refs: {}, plan: {}, title: "[pii]", detail: "[pii]" };
242
+ const screenHistoryText = (text: string, field: string): string => this.nameable(text) ? text : `[history ${field} withheld: it matches a PII pattern; ahub task show ${task.id}]`;
243
+ const publicHistory = (fullHistory ? history : history.slice(-5)).map(h => ({
244
+ at: h.at, by: h.by, event: h.event,
245
+ ...(h.note === undefined ? {} : { note: screenHistoryText(h.note, "note") }),
246
+ ...(h.profile === undefined ? {} : { profile: screenHistoryText(h.profile, "profile") }),
247
+ ...(h.owner === undefined ? {} : { owner: h.owner }),
248
+ ...(h.reason && TASK_MOVE_REASONS.includes(h.reason) ? { reason: h.reason } : {}),
249
+ ...(h.sweep ? { sweep: { kind: h.sweep.kind, activity: h.sweep.activity, step: h.sweep.step, at: h.sweep.at } } : {}),
250
+ }));
251
+ return { ...rest, title, detail, history: publicHistory };
186
252
  }
187
253
 
188
254
  /** Console results expose direct approval and delivery holds, not just the chosen peer ids. */
@@ -585,7 +651,7 @@ export class Tasks {
585
651
  if (!assign(task, this.states(), this.d.routing(), { exclude: [...this.declined(task), peer], ...this.health() }).owner) continue;
586
652
  try {
587
653
  const back = task.state === "in_progress" ? this.d.board.update(task.id, HUB, "released", { state: "proposed" }, why) : task;
588
- const next = await this.assignOwner(back, HUB, { exclude: [peer], event: "reassigned", note: why });
654
+ const next = await this.assignOwner(back, HUB, { exclude: [peer], event: "reassigned", reason: "offline", note: why });
589
655
  moved.push({ id: task.id, title: this.publicTitle(task), to: next.owner });
590
656
  // The gone owner hears it on its next delivery, if it comes back mid-work.
591
657
  if (next.owner && next.owner !== peer) this.d.tell?.(peer, noteLine(HUB, "decision", `task #${task.id} moved to ${next.owner} while you were offline; stop working on it`));
@@ -613,6 +679,11 @@ export class Tasks {
613
679
  return assign({ class: target.class, signals: detectSignals(draft, routing, this.d.cwd) }, this.states(), routing, this.weights(target.class)).trace;
614
680
  }
615
681
 
682
+ /** Public read path: every generated line is screened, including profile/config-derived trace fields. */
683
+ publicExplain(target: number | { title: string; detail?: string; class: TaskClass; refs?: TaskRefs }): string[] {
684
+ return this.explain(target).map(line => this.nameable(line) ? line : "[routing explanation withheld: it matches a PII pattern]");
685
+ }
686
+
616
687
  private declined = (task: Task) => task.history.filter((h) => h.event === "declined").map((h) => h.by);
617
688
 
618
689
  /** Why the task will approve directly at done: no reviewer could be picked, and who was skipped (issue #92). */
@@ -630,7 +701,7 @@ export class Tasks {
630
701
  if (hold) this.d.notify(`task ${this.publicTitle(task)}: ${a.owner}'s queue is held (${hold}); the task arrives once the hold is resolved`);
631
702
  }
632
703
 
633
- private async assignOwner(task: Task, by: PeerId, opts: { candidates?: PeerId[]; event?: string; note?: string; clearOnFail?: boolean; exclude?: PeerId[]; context?: string; claim?: boolean } = {}): Promise<Task> {
704
+ private async assignOwner(task: Task, by: PeerId, opts: { candidates?: PeerId[]; event?: string; note?: string; reason?: TaskMoveReason; clearOnFail?: boolean; exclude?: PeerId[]; context?: string; claim?: boolean } = {}): Promise<Task> {
634
705
  const waits = this.waitsFor(task);
635
706
  const a = assign(task, this.states(), this.d.routing(), { exclude: [...this.declined(task), ...(opts.exclude ?? []), ...(opts.event === "escalated" && task.owner ? [task.owner] : [])], ...(opts.candidates ? { candidates: opts.candidates } : {}), waitsFor: waits, ...this.weights(task.class) });
636
707
 
@@ -648,7 +719,7 @@ export class Tasks {
648
719
  return task;
649
720
  }
650
721
  const profile = this.d.splitProfile?.(a.owner);
651
- const next = this.d.board.update(task.id, by, opts.event ?? "assigned", { owner: a.owner, reviewer: a.reviewer ?? null, ...(opts.event === "escalated" ? { rejections: 0 } : {}) }, opts.note ?? `to ${a.owner}`, profile ? { profile } : {});
722
+ const next = this.d.board.update(task.id, by, opts.event ?? "assigned", { owner: a.owner, reviewer: a.reviewer ?? null, ...(opts.event === "escalated" ? { rejections: 0 } : {}) }, opts.note ?? `to ${a.owner}`, { ...(profile ? { profile } : {}), ...(opts.reason ? { reason: opts.reason } : {}) });
652
723
  // What calibration reads (issue #109): routing chose the first owner (no single named candidate, no claim; not an
653
724
  // escalation, relay or reassignment of work already begun), and the work overlaps another owner's task not started
654
725
  // yet. For the record only. Work routed back to its proposer is left out, as its observations are (by === owner).
@@ -837,7 +908,7 @@ export class Tasks {
837
908
  this.mine(task, by, "owner");
838
909
  const back = this.d.board.update(task.id, by, "declined", task.state === "in_progress" ? { state: "proposed" } : {}, reason);
839
910
  this.d.notify(`task ${this.publicTitle(back)} declined by ${by}${reason && !this.isPii(back) ? `: ${reason}` : ""}`);
840
- return this.assignOwner(back, HUB, { event: "reassigned", clearOnFail: true });
911
+ return this.assignOwner(back, HUB, { event: "reassigned", reason: "declined", clearOnFail: true });
841
912
  }
842
913
 
843
914
  /** Tasks whose check is queued or running, and the owner it was started for. */
@@ -1126,7 +1197,7 @@ export class Tasks {
1126
1197
  this.contradict(rejected);
1127
1198
  this.note(rejected, by, "decision", `Task #${rejected.id} changes requested by ${by}: ${rejected.title}\n${note ?? ""}`);
1128
1199
  if (rejected.rejections >= ESCALATE_AFTER) {
1129
- const moved = await this.escalate(HUB, rejected.id, `${rejected.rejections} consecutive changes_requested`);
1200
+ const moved = await this.escalate(HUB, rejected.id, `${rejected.rejections} consecutive changes_requested`, "rejections");
1130
1201
  if (moved.owner !== rejected.owner) return moved;
1131
1202
  // Nobody to escalate to: the owner still has to hear the verdict and the note.
1132
1203
  this.tell(moved, `Task #${moved.id}: ${by} requests changes again.${note ? ` ${this.screen(moved, note, "review note", moved.owner)}` : ""} Nobody else can take it; fix it and call hub_task_done again.`, pii);
@@ -1138,7 +1209,7 @@ export class Tasks {
1138
1209
  }
1139
1210
 
1140
1211
  /** Task-level escalation: the next attached peer in the class's escalate_to takes over, with the history. */
1141
- async escalate(by: PeerId, id: unknown, why = "by hand"): Promise<Task> {
1212
+ async escalate(by: PeerId, id: unknown, why = "by hand", reason: TaskMoveReason | undefined = by === HUB ? undefined : "manual"): Promise<Task> {
1142
1213
  let task = this.need(id, true);
1143
1214
  const list = this.d.routing().classes[task.class]?.escalate_to ?? [];
1144
1215
  if (task.state === "changes_requested") task = this.d.board.update(task.id, HUB, "reopened", { state: "in_progress" });
@@ -1148,7 +1219,7 @@ export class Tasks {
1148
1219
  if (from && by !== HUB) this.d.board.recordOutcome(from, task.class, false);
1149
1220
  // Only a reviewer that asked for changes on this work saw it fail: not an escalation of unreviewed work (a Pi failure).
1150
1221
  if (from && task.reviewer && task.reviewer !== USER && this.requestedChanges(task).has(task.reviewer)) this.d.board.recordReview({ implementer: from, reviewer: task.reviewer, class: task.class, kind: "escalated", task: task.id });
1151
- const next = await this.assignOwner(task, by, { candidates: list, event: "escalated", note: `${why}; from ${from ?? "none"}`, context: why });
1222
+ const next = await this.assignOwner(task, by, { candidates: list, event: "escalated", reason, note: `${why}; from ${from ?? "none"}`, context: why });
1152
1223
  if (next.owner && next.owner !== from) {
1153
1224
  this.d.notify(`task ${this.publicTitle(next)} escalated from ${from} to ${next.owner} (${why})`);
1154
1225
  this.note(next, by, "decision", `Task #${next.id} escalated from ${from} to ${next.owner}: ${why}`);
@@ -1157,9 +1228,9 @@ export class Tasks {
1157
1228
  return this.d.board.get(next.id)!;
1158
1229
  }
1159
1230
 
1160
- /** Console only. */
1161
- async assignTo(id: unknown, peer: PeerId): Promise<Task> {
1162
- return this.assignOwner(this.need(id, true), USER, { candidates: [peer], event: "reassigned" });
1231
+ /** Caller authority is checked by the daemon; keep the real actor in task history. */
1232
+ async assignTo(id: unknown, peer: PeerId, by: PeerId = USER): Promise<Task> {
1233
+ return this.assignOwner(this.need(id, true), by, { candidates: [peer], event: "reassigned", reason: "manual" });
1163
1234
  }
1164
1235
 
1165
1236
  /**
@@ -1176,7 +1247,7 @@ export class Tasks {
1176
1247
  const pii = this.isPii(task);
1177
1248
  const candidates = [pii ? LOCAL : PI, pii ? undefined : LOCAL, ...(routing.classes[task.class]?.peers ?? []).filter((p) => p !== LOCAL && p !== PI)].filter((p): p is PeerId => !!p);
1178
1249
  const back = task.state === "in_progress" ? this.d.board.update(task.id, HUB, "released", { state: "proposed" }, `budget pause of ${peer}`) : task;
1179
- const next = await this.assignOwner(back, HUB, { candidates, exclude: [peer], event: "reassigned", note: `budget pause of ${peer}`, clearOnFail: true, ...(context ? { context } : {}) });
1250
+ const next = await this.assignOwner(back, HUB, { candidates, exclude: [peer], event: "reassigned", reason: "budget", note: `budget pause of ${peer}`, clearOnFail: true, ...(context ? { context } : {}) });
1180
1251
  moved.push({ id: task.id, title: this.publicTitle(task), to: next.owner, role: "owner" });
1181
1252
  } else if (task.reviewer === peer && task.state !== "approved") {
1182
1253
  // No owner candidates: only the reviewer is wanted, and it must be neither the paused peer nor the task's owner.