@staix/agent-hub 0.12.18 → 0.12.20

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 (66) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +3 -3
  3. package/docs/agent-notes/adapters.md +2 -0
  4. package/docs/agent-notes/bus.md +1 -0
  5. package/docs/agent-notes/daemon.md +2 -1
  6. package/docs/agent-notes/models.md +3 -0
  7. package/docs/agent-notes/tasks.md +3 -2
  8. package/docs/events.md +4 -1
  9. package/docs/operations.md +419 -17
  10. package/docs/quickstart.md +1 -1
  11. package/docs/security.md +18 -11
  12. package/docs/smoke.md +91 -0
  13. package/docs/specs/2026-09-19-agent-hub-design.md +412 -3
  14. package/docs/specs/2026-09-20-pi-local-models-design.md +2 -0
  15. package/docs/specs/2026-09-20-upgrade-recovery-design.md +287 -0
  16. package/docs/specs/2026-10-04-switchyard-source-port-design.md +10 -0
  17. package/docs/verified.json +13 -13
  18. package/package.json +1 -1
  19. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  20. package/plugins/agent-hub/server.js +63 -25
  21. package/src/adapters/claude-channel.ts +61 -20
  22. package/src/adapters/codex-appserver.ts +3 -0
  23. package/src/adapters/local-worker.ts +4 -0
  24. package/src/cli/console-state.ts +325 -102
  25. package/src/cli/console.ts +44 -23
  26. package/src/cli/help.ts +120 -0
  27. package/src/cli/identity.ts +3 -3
  28. package/src/cli/launch.ts +2 -0
  29. package/src/cli/main.ts +171 -75
  30. package/src/cli/preview.ts +1 -0
  31. package/src/cli/reset.ts +194 -0
  32. package/src/cli/status-lines.ts +9 -5
  33. package/src/cli/terminal-recovery.ts +140 -36
  34. package/src/cli/upgrade-runtime.ts +274 -56
  35. package/src/cli/upgrade.ts +366 -41
  36. package/src/hub/ask.ts +1 -1
  37. package/src/hub/board.ts +15 -7
  38. package/src/hub/bus.ts +60 -4
  39. package/src/hub/child-process.ts +1 -0
  40. package/src/hub/conductor.ts +38 -6
  41. package/src/hub/control-client.ts +5 -3
  42. package/src/hub/daemon.ts +70 -12
  43. package/src/hub/events.ts +7 -1
  44. package/src/hub/execution-budget.ts +15 -6
  45. package/src/hub/hub-tools.ts +4 -4
  46. package/src/hub/inference.ts +136 -1
  47. package/src/hub/lifecycle.ts +7 -1
  48. package/src/hub/peers.ts +2 -0
  49. package/src/hub/recovery-store.ts +51 -3
  50. package/src/hub/report.ts +43 -1
  51. package/src/hub/restart.ts +25 -0
  52. package/src/hub/routing.ts +48 -16
  53. package/src/hub/snapshots.ts +3 -2
  54. package/src/hub/tasks.ts +236 -83
  55. package/src/local/deny.ts +2 -1
  56. package/src/models/mlx.ts +11 -8
  57. package/src/models/ollama.ts +2 -2
  58. package/src/models/relay.ts +190 -42
  59. package/src/models/route/relay-selector.ts +43 -31
  60. package/src/models/route/runtime.ts +17 -7
  61. package/src/models/route/signals.ts +12 -1
  62. package/src/models/route/stage.ts +57 -3
  63. package/src/omniroute/client.ts +1 -1
  64. package/src/ui/index.html +1 -0
  65. package/templates/AGENTS.block.md +1 -1
  66. package/templates/routing.toml +8 -0
package/src/hub/tasks.ts CHANGED
@@ -4,7 +4,8 @@ import type { MemoryClient } from "../memory/client.ts";
4
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
- import { assign, detectSignals, LOCAL, PI, predictSplit, type Assignment, type Routing, type SplitObservation, type SplitPrediction } from "./routing.ts";
7
+ import type { PiiVerdict, ScreenItem, ScreenRecord } from "./inference.ts";
8
+ import { assign, detectSignals, LOCAL, peerEntry, PI, predictSplit, type Assignment, type Routing, type SplitObservation, type SplitPrediction } from "./routing.ts";
8
9
  import { ExecutionBudget, type ExecutionBudgetConfig, type ExecutionBudgetDecision, type ExecutionBudgetStatus, type ExecutionUnit } from "./execution-budget.ts";
9
10
  import { Cohorts, MAX_REQUESTS, type Cohort, type Completion } from "./cohorts.ts";
10
11
  import { realPath } from "./project.ts";
@@ -44,6 +45,10 @@ export interface TasksDeps {
44
45
  roles?: Record<string, string[]>;
45
46
  /** Optional: name a class for a task proposed without one. `onCampus` says whether the model call stays on campus. */
46
47
  triage?: { classify: (title: string, detail: string) => Promise<TaskClass | undefined>; onCampus: () => Promise<boolean> };
48
+ /** The PII screen (issue #198): a closed verdict from a model on this machine or on campus, `unknown` on any failure. */
49
+ piiScreen?: (text: string) => Promise<PiiVerdict>;
50
+ /** Each screen verdict, for the record: item, label, source and category, never the text. */
51
+ recordScreen?: (record: ScreenRecord) => void;
47
52
  /** Turn-free coordination (issue #107): configured and no PII task open. Asked at each use. */
48
53
  turnFree?: () => boolean;
49
54
  /** Whether a peer's context path for facts is verified in its current session (issue #108). */
@@ -96,7 +101,7 @@ export const samePlace = (a: string, b: string) => {
96
101
  };
97
102
 
98
103
  /** Events that hand a task to an owner (or take it away); newer history records that owner on them (#67). */
99
- const OWNERSHIP_EVENTS = new Set(["assigned", "escalated", "reassigned", "unassigned"]);
104
+ export const OWNERSHIP_EVENTS = new Set(["assigned", "escalated", "reassigned", "unassigned"]);
100
105
  /** The note on an accept recorded by the done call itself: its stages are unknown to the split prediction (issue #109). */
101
106
  const WITH_DONE = "with its done";
102
107
  /** How many times a task changed hands: a cohort member's generation (issue #107). */
@@ -130,6 +135,10 @@ function cleanPlan(input: unknown): TaskPlan {
130
135
 
131
136
  /** A plan as one line for other owners; the whole plan is on the board. */
132
137
  const planText = (plan: TaskPlan = {}) => PLAN_KEYS.filter((k) => plan[k]?.length).map((k) => `${k.replace("_", " ")}: ${plan[k]!.join("; ")}`).join(" | ");
138
+ /** What the PII screen reads of a task (#198): the text the patterns are checked against. */
139
+ const screenText = (t: { title: string; detail: string; plan?: TaskPlan }) => [t.title, t.detail, planText(t.plan)].join("\n");
140
+ /** Re-screens of one task whose verdict was unknown, per hub run (#198). */
141
+ const RESCREEN_MAX = 10;
133
142
 
134
143
  /**
135
144
  * The task flow. Adapters and tools never touch the board: every change comes through here, where assignment,
@@ -164,6 +173,42 @@ export class Tasks {
164
173
 
165
174
  isPii = (task: Pick<Task, "signals">) => task.signals.includes("pii") && this.d.routing().constraints.pii === "local_only";
166
175
 
176
+ /** `signals.pii_screen = "local"` where the PII constraint applies (issue #198); on without a screener, every verdict is unknown. */
177
+ private screenOn = (): boolean => {
178
+ const r = this.d.routing();
179
+ return r.signals.pii_screen === "local" && r.constraints.pii === "local_only";
180
+ };
181
+
182
+ /** One bounded screen call. A screener that fails is no verdict, handled as PII (fail closed). */
183
+ private async screenCall(text: string): Promise<PiiVerdict> {
184
+ return (await this.d.piiScreen?.(text).catch(() => undefined)) ?? { label: "unknown", miss: "failed", ms: 0 };
185
+ }
186
+
187
+ private recordScreen(task: number | undefined, item: ScreenItem, v: PiiVerdict | undefined): void {
188
+ const record: ScreenRecord = !v
189
+ ? { item, label: "pii", source: "regex" }
190
+ : { item, label: v.label === "clear" ? "clear" : "pii", source: v.label === "unknown" ? "unknown" : "screen", ...(v.category ? { category: v.category } : {}), ...(v.miss ? { miss: v.miss } : {}), ms: v.ms };
191
+ try {
192
+ this.d.recordScreen?.({ ...(task === undefined ? {} : { task }), ...record });
193
+ } catch { /* telemetry only */ }
194
+ }
195
+
196
+ /**
197
+ * Whether the model screen withholds free text about an ordinary task (issue #198): it is on and its verdict is not
198
+ * `clear`. Text that matches a pattern needs no call (the #69 checks withhold it; only the record says so), and a PII
199
+ * task's text is private. Tool callers are models: anything but a string is not screened, so `off` stays as it was.
200
+ */
201
+ private async withholds(item: ScreenItem, text: unknown, task?: Task): Promise<boolean> {
202
+ if (typeof text !== "string" || !text.trim() || !this.screenOn() || (task && this.isPii(task))) return false;
203
+ if (!this.nameable(text)) return (this.recordScreen(task?.id, item, undefined), false);
204
+ const v = await this.screenCall(text);
205
+ this.recordScreen(task?.id, item, v);
206
+ return v.label !== "clear";
207
+ }
208
+
209
+ /** Why free text is withheld: the pattern check first, as #69 words it. */
210
+ private whyWithheld = (text: string) => (this.nameable(text) ? "the PII screen did not clear it" : "it matches a PII pattern");
211
+
167
212
  /** Task-owned, default-off between-turn ladder. Persist before sending so restart cannot repeat a step. */
168
213
  async sweep(now = Date.now()): Promise<{ task: number; finding: SweepRecord }[]> {
169
214
  const recorded: { task: number; finding: SweepRecord }[] = [];
@@ -191,8 +236,9 @@ export class Tasks {
191
236
  }
192
237
  } else {
193
238
  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) });
239
+ const a = assign(task, this.states(), this.d.routing(), { exclude: [...this.excluded(task), responsible], waitsFor: this.waitsFor(task), ...(reviewer ? { candidates: [], notReviewer: task.owner ?? undefined } : {}), ...this.weights(task.class) });
195
240
  const candidate = reviewer ? a.reviewer : a.owner;
241
+ this.passedOver(task, a);
196
242
  this.d.notify(`${message} ${reviewer ? "Reviewer" : "Owner"} reassignment suggestion: ${candidate ?? "no eligible peer"}.`);
197
243
  if (!reviewer && candidate && this.sweepConfig.auto_reassign && this.sweepAvailable(candidate)) {
198
244
  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}` });
@@ -205,7 +251,7 @@ export class Tasks {
205
251
 
206
252
  private sweepAvailable(peer: PeerId): boolean {
207
253
  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);
254
+ return bus.stateOf(peer) === "idle" && (this.d.idle?.(peer) ?? true) && !peerEntry(this.d.held?.(), peer) && !bus.queueSummary(peer).heldBy && !bus.queued(peer) && !bus.hasInFlight(peer);
209
255
  }
210
256
 
211
257
  private sweepNotice(task: Task, peer: PeerId, body: string): void {
@@ -223,14 +269,19 @@ export class Tasks {
223
269
  /** Charge only budgets matching the task actually delivered to this peer, plus active run budgets. */
224
270
  admitExecution = (taskId: number | undefined, peer: PeerId, unit: ExecutionUnit, amount = 1): ExecutionBudgetDecision[] => this.d.executionBudget?.admitTask(taskId, peer, unit, amount) ?? [];
225
271
  /** A digest may include several tasks. Charge each matching task budget and each run budget once, atomically. */
226
- admitExecutionEnvelopes = (envs: Envelope[], peer: PeerId, unit: ExecutionUnit, amount = 1): ExecutionBudgetDecision[] => {
227
- const ids = [...new Set(envs.flatMap((e) => {
272
+ admitExecutionEnvelopes = (envs: Envelope[], peer: PeerId, unit: ExecutionUnit, amount = 1): ExecutionBudgetDecision[] =>
273
+ this.d.executionBudget?.admitTasks(this.budgetTaskIds(envs), peer, unit, amount) ?? [];
274
+
275
+ /** Whether an execution budget meters this peer's work on these envelopes; admits nothing. */
276
+ budgetApplies = (envs: Envelope[], peer: PeerId): boolean => this.d.executionBudget?.applies(this.budgetTaskIds(envs), peer) ?? false;
277
+
278
+ private budgetTaskIds(envs: Envelope[]): number[] {
279
+ return [...new Set(envs.flatMap((e) => {
228
280
  const value = e.refs?.task;
229
281
  const id = typeof value === "string" ? Number(value) : NaN;
230
282
  return Number.isSafeInteger(id) && id > 0 ? [id] : [];
231
283
  }))];
232
- return this.d.executionBudget?.admitTasks(ids, peer, unit, amount) ?? [];
233
- };
284
+ }
234
285
 
235
286
  /** What peers other than the owner, the console stream and the log may see of a task. */
236
287
  publicTitle = (task: Task) => (this.isPii(task) ? `#${task.id} [pii]` : `#${task.id} ${task.title}`);
@@ -239,10 +290,10 @@ export class Tasks {
239
290
  publicView(task: Task, fullHistory = false): Record<string, unknown> {
240
291
  const { title, detail, history, ...rest } = task;
241
292
  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}]`;
293
+ const screenHistoryText = (text: string, field: string, withheld = false): string => !withheld && this.nameable(text) ? text : `[history ${field} withheld: ${this.whyWithheld(text)}; ahub task show ${task.id}]`;
243
294
  const publicHistory = (fullHistory ? history : history.slice(-5)).map(h => ({
244
295
  at: h.at, by: h.by, event: h.event,
245
- ...(h.note === undefined ? {} : { note: screenHistoryText(h.note, "note") }),
296
+ ...(h.note === undefined ? {} : { note: screenHistoryText(h.note, "note", h.withheld) }),
246
297
  ...(h.profile === undefined ? {} : { profile: screenHistoryText(h.profile, "profile") }),
247
298
  ...(h.owner === undefined ? {} : { owner: h.owner }),
248
299
  ...(h.reason && TASK_MOVE_REASONS.includes(h.reason) ? { reason: h.reason } : {}),
@@ -257,7 +308,7 @@ export class Tasks {
257
308
  const noReview = task.class !== "review" && !task.reviewer && OPEN.includes(task.state) && !this.waitsFor(task).length
258
309
  ? this.noReviewer(assign(task, this.states(), this.d.routing(), { candidates: task.owner ? [task.owner] : [], notReviewer: task.owner ?? undefined, ...this.weights(task.class) }))
259
310
  : undefined;
260
- const held = task.owner ? this.d.held?.()[task.owner] : undefined;
311
+ const held = task.owner ? peerEntry(this.d.held?.(), task.owner) : undefined;
261
312
  const holdText = held ? `${task.owner}'s queue is held (${held}); it receives the task once the hold is resolved` : undefined;
262
313
  return [base, noReview, holdText].filter(Boolean).join("; ");
263
314
  }
@@ -390,7 +441,7 @@ export class Tasks {
390
441
  // delivered) or in which it claimed it: the routed peer this very task, the other owner the overlapped one while it
391
442
  // is not started. Busy otherwise, it is at work on something else (#109, #115; a routing or cohort record is taken
392
443
  // before the task is sent, so a busy candidate is not available then).
393
- available: Object.fromEntries(peers.map((p) => [p, !failing[p] && (states[p] === "idle" || taking(p))])),
444
+ available: Object.fromEntries(peers.map((p) => [p, !peerEntry(failing, p) && (states[p] === "idle" || taking(p))])),
394
445
  });
395
446
  }
396
447
 
@@ -432,10 +483,8 @@ export class Tasks {
432
483
  if (!this.d.board.get(id)) throw new Error(`after: no task #${id}`);
433
484
  if (!deps.includes(id)) deps.push(id);
434
485
  }
435
- const waits = this.waitsFor({ deps });
436
- if (waits.length && input.owner) {
437
- throw new Error(`this task would wait for ${waits.map((id) => `#${id}`).join(", ")}, not approved yet: ${input.owner === by ? "claim it once they are" : "propose it without an owner; routing offers it when they are approved"}`);
438
- }
486
+ // An owner for work that waits is reserved (issue #207): routing offers it to that peer first once it is ready.
487
+ const reserved = this.waitsFor({ deps }).length ? input.owner : undefined;
439
488
  const given = input.class === undefined || input.class === "" ? undefined : input.class;
440
489
  if (given !== undefined && !CLASSES.includes(given as TaskClass)) throw new Error(`class must be one of ${CLASSES.join(", ")}`);
441
490
  const text = { title, detail: String(input.detail ?? "").slice(0, 8000), refs: cleanRefs(input.refs) };
@@ -444,33 +493,84 @@ export class Tasks {
444
493
  const signals = detectSignals({ ...text, detail: [text.detail, planText(plan)].join("\n") }, this.d.routing(), this.d.cwd);
445
494
  // Urgent work is handed over even when its paused owner's window resets soon (issue #36).
446
495
  if (input.urgent === true && !signals.includes("urgent")) signals.push("urgent");
496
+ // The PII screen (issue #198) reads the same text before anything else does: until its verdict the task is on no
497
+ // board, in no envelope and before no other model. A pattern match is PII without a call; no verdict is PII too.
498
+ const screening = this.screenOn();
499
+ const screened = screening && !signals.includes("pii") ? await this.screenCall(screenText({ ...text, plan })) : undefined;
500
+ if (screened && screened.label !== "clear") signals.push("pii");
447
501
  let cls = given as TaskClass | undefined;
448
502
  let triaged = false;
503
+ let fenced = false; // a PII task off campus: triage is not asked
449
504
  if (!cls && this.d.triage) {
450
505
  // Signals first: a PII task's text may only go to a model that is reached without leaving the campus network.
451
506
  const pii = signals.includes("pii") && this.d.routing().constraints.pii === "local_only";
452
507
  if (!pii || (await this.d.triage.onCampus().catch(() => false))) cls = await this.d.triage.classify(text.title, text.detail).catch(() => undefined);
508
+ else fenced = true;
453
509
  triaged = !!cls;
454
510
  }
455
511
  // A claim is work the caller will do itself: without a class and a model to name one, it is implementation (#6).
456
512
  const defaulted = !cls && input.owner === by;
457
513
  if (defaulted) cls = "implement";
458
- if (!cls) throw new Error(`class is required (one of ${CLASSES.join(", ")}); the hub could not name one for you`);
514
+ if (!cls) throw new Error(`class is required (one of ${CLASSES.join(", ")}); ${fenced ? "the task is handled as PII and the hub's model is not reached on campus, so it was not asked to name one" : "the hub could not name one for you"}`);
459
515
  const draft = { ...text, class: cls };
460
- let task = this.d.board.propose(by, { ...draft, plan, ...(deps.length ? { deps } : {}), signals });
516
+ let task = this.d.board.propose(by, { ...draft, plan, ...(deps.length ? { deps } : {}), ...(reserved ? { reserved } : {}), signals });
517
+ if (screening) task = this.screenedTask(task, screened);
461
518
  if (triaged) task = this.d.board.update(task.id, "hub", "triaged", {}, `class ${cls} named by the hub's model`);
462
519
  if (defaulted) task = this.d.board.update(task.id, "hub", "class defaulted", {}, "class implement for a claim without one");
463
520
  this.d.notify(`task ${this.publicTitle(task)} proposed by ${by} [${task.class}]${task.signals.length ? ` signals: ${task.signals.join(", ")}` : ""}`);
464
521
  // Re-read: a dependency approved while triage was awaited looked for its dependents before this row existed.
465
522
  const still = this.waitsFor(task);
466
523
  if (still.length) {
467
- this.d.notify(`task ${this.publicTitle(task)} waits for ${still.map((id) => `#${id}`).join(", ")}; it is offered once they are approved`);
524
+ this.d.notify(`task ${this.publicTitle(task)} waits for ${still.map((id) => `#${id}`).join(", ")}; it is offered ${task.reserved ? `to ${task.reserved} first ` : ""}once they are approved`);
468
525
  return this.d.board.update(task.id, HUB, "blocked", {}, `waits for ${still.map((id) => `#${id}`).join(", ")}`);
469
526
  }
470
527
  // Naming yourself is a claim: the work is already yours, so no offer comes back to you (paper: Agensh CLAIM, #68).
471
528
  return this.assignOwner(task, by, input.owner ? { candidates: [input.owner], claim: input.owner === by } : {});
472
529
  }
473
530
 
531
+ /** Where a screened task's `pii` signal came from, on its history and in events (issue #198); no verdict is a console line by id. */
532
+ private screenedTask(task: Task, v: PiiVerdict | undefined): Task {
533
+ this.recordScreen(task.id, "task", v);
534
+ if (v?.label === "unknown") this.d.notify(`task #${task.id}: the PII screen gave no verdict (${v.miss}); it is handled as a PII task`);
535
+ const note = !v ? "pii: regex" : v.label === "clear" ? "clear" : v.label === "pii" ? `pii: screen, ${v.category}` : `pii: unknown, ${v.miss}`;
536
+ return this.d.board.update(task.id, HUB, "screened", {}, note);
537
+ }
538
+
539
+ /** How often each task was screened again in this hub run. ponytail: in memory, so a restart allows another round. */
540
+ private readonly rescreens = new Map<number, number>();
541
+
542
+ /**
543
+ * A task whose screen gave no verdict waits as a PII task (#198). While it is proposed and no peer ever owned it,
544
+ * nothing private was sent anywhere, so the daemon's release timer asks again here: one task per call (the timer runs
545
+ * one at a time), the least tried first, at most RESCREEN_MAX times each in a hub run; text too long for the screen
546
+ * never clears. `clear` lifts the pii signal and routes the task as usual (a named owner is not remembered, a reserved
547
+ * one is offered first as always), `pii` settles it, another unknown changes only the record. A task an owner took
548
+ * (`local`), even one declined back since, keeps its PII path: its notes, summary and decline reason are not screened.
549
+ */
550
+ async rescreen(): Promise<void> {
551
+ if (!this.screenOn()) return;
552
+ const waiting = (t: Task) => {
553
+ const last = [...t.history].reverse().find((h) => h.event === "screened")?.note ?? "";
554
+ return t.state === "proposed" && !t.owner && !t.history.some((h) => h.owner) && t.signals.includes("pii") && last.startsWith("pii: unknown") && last !== "pii: unknown, too long";
555
+ };
556
+ const tries = (t: Task) => this.rescreens.get(t.id) ?? 0;
557
+ const task = this.d.board.list("proposed").filter((t) => waiting(t) && tries(t) < RESCREEN_MAX).sort((a, b) => tries(a) - tries(b))[0];
558
+ if (!task) return;
559
+ this.rescreens.set(task.id, (this.rescreens.get(task.id) ?? 0) + 1);
560
+ const text = screenText(task);
561
+ const v = this.nameable(text) ? await this.screenCall(text) : undefined; // a pattern added since settles it
562
+ const now = this.d.board.get(task.id);
563
+ if (!now || !waiting(now)) return; // taken or moved meanwhile: its verdict stands
564
+ this.recordScreen(task.id, "task", v);
565
+ if (v?.label === "unknown") return;
566
+ if (v?.label !== "clear") return void this.d.board.update(task.id, HUB, "screened", {}, v ? `pii: screen, ${v.category}` : "pii: regex");
567
+ const lifted = this.d.board.update(task.id, HUB, "screened", { signals: now.signals.filter((s) => s !== "pii") }, "clear, screened again");
568
+ this.d.notify(`task ${this.publicTitle(lifted)}: the PII screen cleared it on a second look; it is routed as usual`);
569
+ const still = this.waitsFor(lifted);
570
+ if (still.length) return void this.d.board.update(lifted.id, HUB, "blocked", {}, `waits for ${still.map((id) => `#${id}`).join(", ")}`);
571
+ await this.assignOwner(lifted, HUB);
572
+ }
573
+
474
574
  /**
475
575
  * Open tasks of other owners whose paths overlap this one's. The later claimant settles it with the other owner, so
476
576
  * only the new owner reads "settle it"; anyone else learns that the owner was told. PII tasks are left out on both
@@ -564,12 +664,12 @@ export class Tasks {
564
664
  * Model-written free text about an ordinary task (a done summary, a review note with its unmet items, a handoff)
565
665
  * that matches a PII pattern stays on this machine (#69): every peer gets a stub, `local` too, which handles an
566
666
  * ordinary task's turn like any other (gateway off campus allowed, memory capture on). The board keeps the text for
567
- * `ahub task show`. A PII task's messages are private already.
667
+ * `ahub task show`. A PII task's messages are private already. `withheld`: the PII screen did not clear it (#198).
568
668
  */
569
- private screen(task: Task, text: string, what: string, to: PeerId | null | undefined): string {
570
- if (!text || this.isPii(task) || to === USER || this.nameable(text)) return text;
571
- this.d.notify(`task #${task.id}: the ${what} was withheld from ${to ?? "a peer"} (it matches a PII pattern)`);
572
- return `[${what} withheld: it matches a PII pattern; ahub task show ${task.id}]`;
669
+ private screen(task: Task, text: string, what: string, to: PeerId | null | undefined, withheld = false): string {
670
+ if (!text || this.isPii(task) || to === USER || (!withheld && this.nameable(text))) return text;
671
+ this.d.notify(`task #${task.id}: the ${what} was withheld from ${to ?? "a peer"} (${this.whyWithheld(text)})`);
672
+ return `[${what} withheld: ${this.whyWithheld(text)}; ahub task show ${task.id}]`;
573
673
  }
574
674
 
575
675
  /** Where two tasks meet: shared paths, then shared symbols, leaving out any name that matches a PII pattern. */
@@ -648,7 +748,7 @@ export class Tasks {
648
748
  // Awaits run between tasks (briefs, memory): re-read, and stop for anything that changed meanwhile.
649
749
  const task = this.d.board.get(id);
650
750
  if (!task || task.owner !== peer || !OPEN.includes(task.state) || this.d.bus.stateOf(peer) !== "offline") continue;
651
- if (!assign(task, this.states(), this.d.routing(), { exclude: [...this.declined(task), peer], ...this.health() }).owner) continue;
751
+ if (!assign(task, this.states(), this.d.routing(), { exclude: [...this.excluded(task), peer], ...this.health() }).owner) continue;
652
752
  try {
653
753
  const back = task.state === "in_progress" ? this.d.board.update(task.id, HUB, "released", { state: "proposed" }, why) : task;
654
754
  const next = await this.assignOwner(back, HUB, { exclude: [peer], event: "reassigned", reason: "offline", note: why });
@@ -671,12 +771,15 @@ export class Tasks {
671
771
  if (typeof target === "number") {
672
772
  const task = this.d.board.get(target);
673
773
  if (!task) throw new Error(`no task #${target}`);
674
- const a = assign(task, this.states(), routing, { exclude: this.declined(task), waitsFor: this.waitsFor(task), ...this.weights(task.class) });
774
+ const a = assign(task, this.states(), routing, { exclude: this.excluded(task), waitsFor: this.waitsFor(task), ...this.weights(task.class) });
675
775
  // The split trace is for the pair the record is about: the task's owner when it has one (issue #109).
676
- return [`task ${this.publicTitle(task)} (${task.state}, owner ${task.owner ?? "none"})`, "if it were assigned now:", ...a.trace, ...(this.splitShadow(task, task.owner ?? a.owner)?.trace ?? [])];
776
+ return [`task ${this.publicTitle(task)} (${task.state}, owner ${task.owner ?? "none"}${task.reserved ? `, reserved for ${task.reserved}` : ""})`, "if it were assigned now:", ...a.trace, ...(this.splitShadow(task, task.owner ?? a.owner)?.trace ?? [])];
677
777
  }
678
778
  const draft = { title: target.title, detail: target.detail ?? "", refs: target.refs ?? {} };
679
- return assign({ class: target.class, signals: detectSignals(draft, routing, this.d.cwd) }, this.states(), routing, this.weights(target.class)).trace;
779
+ const signals = detectSignals(draft, routing, this.d.cwd);
780
+ const trace = assign({ class: target.class, signals }, this.states(), routing, this.weights(target.class)).trace;
781
+ // A draft is not sent to the PII screen (issue #198): a proposal is, and a pii or unknown verdict adds the pii signal.
782
+ return this.screenOn() && !signals.includes("pii") ? [...trace, "pii_screen is on: at proposal a pii or unknown verdict adds signal pii (local only)"] : trace;
680
783
  }
681
784
 
682
785
  /** Public read path: every generated line is screened, including profile/config-derived trace fields. */
@@ -684,7 +787,22 @@ export class Tasks {
684
787
  return this.explain(target).map(line => this.nameable(line) ? line : "[routing explanation withheld: it matches a PII pattern]");
685
788
  }
686
789
 
687
- private declined = (task: Task) => task.history.filter((h) => h.event === "declined").map((h) => h.by);
790
+ /**
791
+ * Peers routing leaves out for good (#207): the owners a task was declined for (the console declines for one; older rows
792
+ * name only the decliner) and the owners it was escalated away from, so a reservation never hands it back to them.
793
+ */
794
+ private excluded = (task: Task) => task.history.flatMap((h) => (h.event === "declined" ? [h.from ?? h.by] : h.event === "escalated" && h.from ? [h.from] : []));
795
+
796
+ /**
797
+ * Says on the console and hub.log that routing passed over the reserved owner (#207); returns the history note's suffix.
798
+ * Only news is said: not when the reserved owner is the one the task moves away from, nor once it refused the task for
799
+ * good (its decline or escalation said that).
800
+ */
801
+ private passedOver(task: Task, a: Assignment): string {
802
+ if (!a.unreserved || task.reserved === task.owner || this.excluded(task).includes(task.reserved!)) return "";
803
+ this.d.notify(`task ${this.publicTitle(task)}: its reserved owner ${task.reserved} is passed over (${a.unreserved}); routing proceeds`);
804
+ return `; reserved owner ${task.reserved} passed over: ${a.unreserved}`;
805
+ }
688
806
 
689
807
  /** Why the task will approve directly at done: no reviewer could be picked, and who was skipped (issue #92). */
690
808
  private noReviewer(a: Assignment): string {
@@ -697,18 +815,20 @@ export class Tasks {
697
815
 
698
816
  private announceRouting(task: Task, a: Assignment): void {
699
817
  if (task.class !== "review" && !a.reviewer) this.d.notify(`task ${this.publicTitle(task)}: ${this.noReviewer(a)}`);
700
- const hold = a.owner ? this.d.held?.()[a.owner] : undefined;
818
+ const hold = a.owner ? peerEntry(this.d.held?.(), a.owner) : undefined;
701
819
  if (hold) this.d.notify(`task ${this.publicTitle(task)}: ${a.owner}'s queue is held (${hold}); the task arrives once the hold is resolved`);
702
820
  }
703
821
 
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> {
822
+ /** `override`: a person's explicit assign, which past refusals do not block and which drops an agent's reservation (#207). */
823
+ private async assignOwner(task: Task, by: PeerId, opts: { candidates?: PeerId[]; event?: string; note?: string; reason?: TaskMoveReason; clearOnFail?: boolean; exclude?: PeerId[]; context?: string; contextWithheld?: boolean; claim?: boolean; override?: boolean } = {}): Promise<Task> {
705
824
  const waits = this.waitsFor(task);
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) });
825
+ const a = assign(task, this.states(), this.d.routing(), { exclude: [...(opts.override ? [] : this.excluded(task)), ...(opts.exclude ?? []), ...(opts.event === "escalated" && task.owner ? [task.owner] : [])], ...(opts.candidates ? { candidates: opts.candidates } : {}), waitsFor: waits, ...this.weights(task.class) });
707
826
 
708
827
  if (waits.length) {
709
828
  this.d.notify(`task ${this.publicTitle(task)} waits for ${waits.map((id) => `#${id}`).join(", ")}; it is offered once they are approved`);
710
829
  return task;
711
830
  }
831
+ const passed = this.passedOver(task, a);
712
832
  if (!a.owner) {
713
833
  this.d.notify(`task ${this.publicTitle(task)}: no peer can take it (${a.trace.filter((l) => l.includes("skipped")).length} skipped); assign with: ahub task assign ${task.id} <peer>`);
714
834
  // Only a decline takes the task away from its owner; a failed console assign or escalation leaves it where it was.
@@ -719,7 +839,10 @@ export class Tasks {
719
839
  return task;
720
840
  }
721
841
  const profile = this.d.splitProfile?.(a.owner);
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 } : {}) });
842
+ // A person's explicit assign replaces an agent's reservation (#207). Only a refusal keeps the owner it moves away from
843
+ // out for good: a reviewer's rejections or an escalation by hand, never the hub's own move after a failed delivery.
844
+ const lasting = opts.event === "escalated" && task.owner && (opts.reason === "rejections" || opts.reason === "manual");
845
+ 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.override && task.reserved ? { reserved: null } : {}) }, `${opts.note ?? `to ${a.owner}`}${passed}`, { ...(profile ? { profile } : {}), ...(opts.reason ? { reason: opts.reason } : {}), ...(lasting ? { from: task.owner! } : {}) });
723
846
  // What calibration reads (issue #109): routing chose the first owner (no single named candidate, no claim; not an
724
847
  // escalation, relay or reassignment of work already begun), and the work overlaps another owner's task not started
725
848
  // yet. For the record only. Work routed back to its proposer is left out, as its observations are (by === owner).
@@ -744,14 +867,16 @@ export class Tasks {
744
867
  this.d.notify(`task ${this.publicTitle(claimed)} claimed by ${by}`);
745
868
  return claimed;
746
869
  }
747
- await this.sendTask(next, a, opts.context, this.overlaps(next, true, hits));
870
+ await this.sendTask(next, a, opts.context, this.overlaps(next, true, hits), opts.contextWithheld);
748
871
  return next;
749
872
  }
750
873
 
751
- private async sendTask(task: Task, a: Assignment, context?: string, overlap = ""): Promise<void> {
874
+ /** `contextWithheld`: the PII screen did not clear a budget hand-off (#198); an escalation's reason is the hub's own words. */
875
+ private async sendTask(task: Task, a: Assignment, context?: string, overlap = "", contextWithheld = false): Promise<void> {
752
876
  const pii = this.isPii(task);
753
877
  const brief = pii ? undefined : await this.d.briefs?.forTask(task.owner!, task).catch(() => undefined);
754
- const rejected = task.history.filter((h) => h.event === "changes_requested").map((h) => `- ${h.by}: ${this.screen(task, h.note ?? "", "review note", a.owner)}`);
878
+ const rejected = task.history.filter((h) => h.event === "changes_requested").map((h) => `- ${h.by}: ${this.screen(task, h.note ?? "", "review note", a.owner, h.withheld)}`);
879
+ const handoff = context && !pii ? this.screen(task, context, "handoff", a.owner, contextWithheld).slice(0, 3000) : "";
755
880
  const facts = [`class ${task.class}`, a.owner === PI ? `backend pi/${a.piBackend ?? "dgx"}` : "", task.refs.paths?.length ? `paths ${task.refs.paths.join(", ")}` : "", task.refs.branch ? `branch ${task.refs.branch}` : "", a.reviewer ? `reviewer ${a.reviewer}` : task.class === "review" ? "no reviewer" : this.noReviewer(a)].filter(Boolean).join("; ");
756
881
  const plan = planText(task.plan);
757
882
  const body = [
@@ -763,7 +888,7 @@ export class Tasks {
763
888
  rejected.length ? `Earlier review notes:\n${rejected.join("\n")}` : "",
764
889
  brief ?? "",
765
890
  // What the previous owner left behind. Peer-written free text: never attached to a PII task.
766
- context && !pii ? `Handoff from the previous owner:\n${this.screen(task, context, "handoff", a.owner).slice(0, 3000)}` : "",
891
+ handoff ? `Handoff from the previous owner:\n${handoff}` : "",
767
892
  `Take it with hub_task_accept {id: ${task.id}, plan: {paths, symbols, signatures, insertion_points}} (what you will change, before you start${pii ? "" : "; owners of overlapping tasks see it"}) or pass with hub_task_decline. When finished: hub_task_done {id: ${task.id}, summary: what changed, why, and the check you ran with its result, refs}.`,
768
893
  ].filter(Boolean).join("\n\n");
769
894
  // A hand-over the owner takes in a turn of its own (#109, #115): idle before, busy after and not queued, this envelope
@@ -801,8 +926,10 @@ export class Tasks {
801
926
  async releaseReady(): Promise<void> {
802
927
  for (const t of this.d.board.list("proposed")) {
803
928
  const last = t.history.at(-1)?.event;
804
- if (!t.deps?.length || t.owner || this.offered.has(t.id) || (last !== "blocked" && last !== "ready") || this.waitsFor(t).length) continue;
805
- if (!assign(t, this.states(), this.d.routing(), { exclude: this.declined(t), ...this.health() }).owner) continue; // nobody attached can take it yet
929
+ if (!t.deps?.length || t.owner || this.offered.has(t.id) || (last !== "blocked" && last !== "ready" && last !== "reserved") || this.waitsFor(t).length) continue;
930
+ // ponytail: a reserved owner (#207) that attaches after another peer that can take the task is passed over here like
931
+ // an offline one; hold reserved tasks for a grace period if restarts show that up.
932
+ if (!assign(t, this.states(), this.d.routing(), { exclude: this.excluded(t), ...this.health() }).owner) continue; // nobody attached can take it yet
806
933
  await this.offerReady(t, "nothing left to wait for");
807
934
  }
808
935
  }
@@ -906,7 +1033,8 @@ export class Tasks {
906
1033
  async decline(by: PeerId, id: unknown, reason?: string): Promise<Task> {
907
1034
  const task = this.need(id, true);
908
1035
  this.mine(task, by, "owner");
909
- const back = this.d.board.update(task.id, by, "declined", task.state === "in_progress" ? { state: "proposed" } : {}, reason);
1036
+ // The owner it is declined for goes on the entry, also when the console declines for it: `excluded()` keeps it out (#207).
1037
+ const back = this.d.board.update(task.id, by, "declined", task.state === "in_progress" ? { state: "proposed" } : {}, reason, task.owner ? { from: task.owner } : {});
910
1038
  this.d.notify(`task ${this.publicTitle(back)} declined by ${by}${reason && !this.isPii(back) ? `: ${reason}` : ""}`);
911
1039
  return this.assignOwner(back, HUB, { event: "reassigned", reason: "declined", clearOnFail: true });
912
1040
  }
@@ -922,14 +1050,20 @@ export class Tasks {
922
1050
  isChecking = (id: number) => this.checking.has(id);
923
1051
 
924
1052
  async done(by: PeerId, id: unknown, summary?: string, refs?: TaskRefs): Promise<Task> {
925
- let task = this.need(id);
926
- this.mine(task, by, "owner");
927
- if (task.state === "in_review" || task.state === "approved") throw new Error(`task #${task.id} is already ${task.state}`);
928
- this.ready(task);
929
- if (this.checking.has(task.id)) {
930
- // The result goes only to the owner the check was started for: anyone who took the task since hears nothing.
931
- throw new Error(this.checking.get(task.id) === task.owner ? `task #${task.id}: its check is still running; its result comes as a task message` : `task #${task.id}: a check from before it changed hands is still running; call hub_task_done again in a few minutes`);
932
- }
1053
+ const valid = (): Task => {
1054
+ const t = this.need(id);
1055
+ this.mine(t, by, "owner");
1056
+ if (t.state === "in_review" || t.state === "approved") throw new Error(`task #${t.id} is already ${t.state}`);
1057
+ this.ready(t);
1058
+ if (this.checking.has(t.id)) {
1059
+ // The result goes only to the owner the check was started for: anyone who took the task since hears nothing.
1060
+ throw new Error(this.checking.get(t.id) === t.owner ? `task #${t.id}: its check is still running; its result comes as a task message` : `task #${t.id}: a check from before it changed hands is still running; call hub_task_done again in a few minutes`);
1061
+ }
1062
+ return t;
1063
+ };
1064
+ // The PII screen reads the summary of a done that passes these checks (issue #198); they run again after it.
1065
+ const withheld = await this.withholds("summary", summary, valid());
1066
+ let task = valid();
933
1067
  // A member of a silent cohort (issue #107): its done is an intent, and the last of them integrates first.
934
1068
  const cohort = this.cohorts.of(task.id);
935
1069
  const silentMember = !!cohort?.silent && this.turnFree() && !!task.owner && !this.isPii(task);
@@ -960,15 +1094,15 @@ export class Tasks {
960
1094
  }
961
1095
  if (task.state === "proposed" || task.state === "changes_requested") task = this.d.board.update(task.id, by, "accepted", { state: "in_progress" }, WITH_DONE); // done without a separate accept
962
1096
  const command = this.d.runCheck ? this.d.check?.(task.class) : undefined;
963
- if (!command) return this.complete(task, by, summary, refs);
1097
+ if (!command) return this.complete(task, by, summary, refs, "", withheld);
964
1098
  // The tool call returns now; a check can outlast an agent's tool timeout. The result decides what comes next.
965
1099
  this.checking.set(task.id, task.owner);
966
- const pending = this.d.board.update(task.id, by, "done (checking)", { refs: cleanRefs(refs) }, summary);
1100
+ const pending = this.d.board.update(task.id, by, "done (checking)", { refs: cleanRefs(refs) }, summary, { withheld: withheld || undefined });
967
1101
  this.d.notify(`task ${this.publicTitle(pending)} done by ${by}; its check is queued or running: ${command}`);
968
1102
  const seen = { events: pending.history.length, owner: pending.owner };
969
1103
  this.pendingChecks++;
970
1104
  this.checkQueue = this.checkQueue
971
- .then(() => this.finishChecked(pending.id, by, summary, command, seen))
1105
+ .then(() => this.finishChecked(pending.id, by, summary, command, seen, withheld))
972
1106
  .catch((e: Error) => this.d.notify(`task #${pending.id}: its check result could not be recorded: ${e.message}`))
973
1107
  .finally(() => this.pendingChecks--);
974
1108
  return pending;
@@ -1005,7 +1139,7 @@ export class Tasks {
1005
1139
  const theirs = this.places(u);
1006
1140
  const paths = mine.paths.filter((p) => theirs.paths.some((q) => samePlace(p, q)));
1007
1141
  const symbols = mine.symbols.filter((x) => theirs.symbols.includes(x));
1008
- return paths.length || symbols.length ? [this.completedNotice(u, { task: t, paths, symbols }, at.note)] : [];
1142
+ return paths.length || symbols.length ? [this.completedNotice(u, { task: t, paths, symbols }, at.note, at.withheld)] : [];
1009
1143
  });
1010
1144
  if (!open.length && !done.length) continue;
1011
1145
  const owners = [...new Set(open.map((h) => h.task.owner!))];
@@ -1033,7 +1167,8 @@ export class Tasks {
1033
1167
  const lines = others.map((t) => {
1034
1168
  const files = this.places(t).paths.filter(this.nameable);
1035
1169
  const signatures = (t.plan?.signatures ?? []).filter(this.nameable);
1036
- const first = (doneAt(t)?.note ?? "").split("\n").find((l) => l.trim())?.trim().slice(0, 300);
1170
+ const done = doneAt(t);
1171
+ const first = done?.withheld ? undefined : (done?.note ?? "").split("\n").find((l) => l.trim())?.trim().slice(0, 300);
1037
1172
  return [
1038
1173
  `- ${this.publicTitle(t)} (owner ${t.owner}, ${t.state})`,
1039
1174
  files.length ? ` changed files: ${files.join(", ")}` : "",
@@ -1049,7 +1184,7 @@ export class Tasks {
1049
1184
  return [head, "The done counts once the files did not change between two calls and the others have stopped.", ...lines, facts?.text ?? ""].filter(Boolean).join("\n");
1050
1185
  }
1051
1186
 
1052
- private async finishChecked(id: number, by: PeerId, summary: string | undefined, command: string, seen: { events: number; owner: PeerId | null }): Promise<void> {
1187
+ private async finishChecked(id: number, by: PeerId, summary: string | undefined, command: string, seen: { events: number; owner: PeerId | null }, withheld: boolean): Promise<void> {
1053
1188
  const result = await this.d.runCheck!(command).catch((e: Error) => ({ code: null, timedOut: false, interrupted: false, tail: e.message }));
1054
1189
  this.checking.delete(id);
1055
1190
  const task = this.d.board.get(id);
@@ -1074,7 +1209,7 @@ export class Tasks {
1074
1209
  }
1075
1210
  this.d.board.update(id, HUB, "check passed", {}, `${outcome}\n${result.tail}`.trim());
1076
1211
  // The output goes to the reviewer with the done note, not into shared memory: nobody screened it.
1077
- await this.complete(this.d.board.get(id)!, by, `${summary ?? ""}\nCheck: ${outcome}`.trim(), undefined, result.tail);
1212
+ await this.complete(this.d.board.get(id)!, by, `${summary ?? ""}\nCheck: ${outcome}`.trim(), undefined, result.tail, withheld);
1078
1213
  return;
1079
1214
  }
1080
1215
  this.d.board.update(id, HUB, "check failed", {}, `${outcome}\n${result.tail}`.trim());
@@ -1085,12 +1220,12 @@ export class Tasks {
1085
1220
  this.tell(task, `Task #${id}: its check failed.\n$ ${outcome}${result.tail ? `\n${result.tail}` : ""}\nFix it and call hub_task_done again.`, pii);
1086
1221
  }
1087
1222
 
1088
- private async complete(task: Task, by: PeerId, summary?: string, refs?: TaskRefs, checkOutput = ""): Promise<Task> {
1223
+ private async complete(task: Task, by: PeerId, summary?: string, refs?: TaskRefs, checkOutput = "", withheld = false): Promise<Task> {
1089
1224
  const reviewer = task.reviewer;
1090
- const next = this.d.board.update(task.id, by, "done", { state: reviewer ? "in_review" : "approved", refs: cleanRefs(refs) }, checkOutput ? `${summary ?? ""}\n${checkOutput}`.trim() : summary);
1225
+ const next = this.d.board.update(task.id, by, "done", { state: reviewer ? "in_review" : "approved", refs: cleanRefs(refs) }, checkOutput ? `${summary ?? ""}\n${checkOutput}`.trim() : summary, { withheld: withheld || undefined });
1091
1226
  this.cohorts.closed(next.id); // its owner's next native turn end settles it in its cohort (issue #107)
1092
- this.note(next, by, "finding", `Task #${next.id} done by ${by}: ${next.title}\n${summary ?? ""}`);
1093
- this.tellCompleted(next, summary);
1227
+ this.note(next, by, "finding", `Task #${next.id} done by ${by}: ${next.title}\n${summary ?? ""}`, withheld);
1228
+ this.tellCompleted(next, summary, withheld);
1094
1229
  if (!reviewer) {
1095
1230
  if (next.owner) this.d.board.recordOutcome(next.owner, next.class, true); // approved without a review is a success too
1096
1231
  this.d.notify(`task ${this.publicTitle(next)} done by ${by}, no reviewer: approved`);
@@ -1106,7 +1241,7 @@ export class Tasks {
1106
1241
  * work is done (after its check, when one runs), and nobody else does (issue #31). A message of its own, not a
1107
1242
  * ride-along line: an owner in the middle of those files needs it before its next task arrives.
1108
1243
  */
1109
- private tellCompleted(task: Task, summary?: string): void {
1244
+ private tellCompleted(task: Task, summary?: string, withheld = false): void {
1110
1245
  const hits = this.overlapHits(task).filter((h) => h.task.owner !== USER && h.task.owner !== HUB);
1111
1246
  const cohort = this.cohorts.of(task.id);
1112
1247
  const silent = this.silentFor(task.id);
@@ -1117,19 +1252,20 @@ export class Tasks {
1117
1252
  cohort.held.add(task.id);
1118
1253
  continue;
1119
1254
  }
1120
- this.whileOpen(hit.task.owner!, hit.task.id, this.completedNotice(task, hit, summary));
1255
+ this.whileOpen(hit.task.owner!, hit.task.id, this.completedNotice(task, hit, summary, withheld));
1121
1256
  }
1122
1257
  }
1123
1258
 
1124
1259
  /**
1125
1260
  * The completed-change notice: files, signatures and the summary are the owner's own words (paths given at done
1126
- * included), and any item that matches a PII pattern is left out of what other owners get.
1261
+ * included), and any item that matches a PII pattern is left out of what other owners get, the summary also when the
1262
+ * PII screen did not clear it (`withheld`, #198).
1127
1263
  */
1128
- private completedNotice(task: Task, hit: { task: Task; paths: string[]; symbols: string[] }, summary?: string): string {
1264
+ private completedNotice(task: Task, hit: { task: Task; paths: string[]; symbols: string[] }, summary?: string, withheld = false): string {
1129
1265
  const paths = this.places(task).paths.filter(this.nameable);
1130
1266
  const signatures = (task.plan?.signatures ?? []).filter(this.nameable);
1131
1267
  const first = (summary ?? "").split("\n").find((l) => l.trim())?.trim().slice(0, 300);
1132
- const line = first && this.nameable(first) ? first : undefined;
1268
+ const line = first && !withheld && this.nameable(first) ? first : undefined;
1133
1269
  return [
1134
1270
  `Task #${task.id} (owner ${task.owner}) is done and touches your open #${hit.task.id} on ${this.where(hit)}. Check your work against it before you go on.`,
1135
1271
  paths.length ? `Changed files: ${paths.join(", ")}` : "",
@@ -1144,7 +1280,7 @@ export class Tasks {
1144
1280
  const t = this.d.board.get(id);
1145
1281
  if (!t || this.isPii(t)) return [];
1146
1282
  const done = [...t.history].reverse().find((h) => h.event === "done");
1147
- return [this.completedNotice(t, { task, paths: this.places(task).paths.filter((p) => this.places(t).paths.some((q) => samePlace(p, q))), symbols: [] }, done?.note)];
1283
+ return [this.completedNotice(t, { task, paths: this.places(task).paths.filter((p) => this.places(t).paths.some((q) => samePlace(p, q))), symbols: [] }, done?.note, done?.withheld)];
1148
1284
  });
1149
1285
  return lines.length ? lines.join("\n") : undefined;
1150
1286
  }
@@ -1163,21 +1299,28 @@ export class Tasks {
1163
1299
  `- ${check ? `Check result: ${(check.note ?? "").split("\n")[0]}` : "No check ran for this class: run the affected tests yourself."}`,
1164
1300
  `- List what is unmet in hub_review's unmet, one item each.`,
1165
1301
  ].join("\n");
1166
- const summary = last?.note ? this.screen(task, last.note, "summary", reviewer) : "(no summary)";
1302
+ const summary = last?.note ? this.screen(task, last.note, "summary", reviewer, last.withheld) : "(no summary)";
1167
1303
  const body = `Review task #${task.id} [${task.class}] ${task.title}\nDone by ${last?.by ?? task.owner}: ${summary}\n${where ? `Where: ${where}\n` : ""}${why ? `${why}\n` : ""}${!plan && task.detail ? `Task detail:\n${task.detail}\n` : ""}${checklist}\nGive your verdict with hub_review {id: ${task.id}, verdict: "approved" | "changes_requested", note, unmet}.`;
1168
1304
  this.d.bus.publish(newEnvelope(HUB, body, { to: [reviewer], kind: "review", priority: "important", refs: { ...r, task: String(task.id) }, ...(this.isPii(task) ? { private: true } : {}) }));
1169
1305
  }
1170
1306
 
1171
1307
  async review(by: PeerId, id: unknown, verdict: unknown, note?: string, unmet?: unknown): Promise<Task> {
1172
- const task = this.need(id);
1173
- this.mine(task, by, "reviewer");
1174
- if (verdict !== "approved" && verdict !== "changes_requested") throw new Error('verdict must be "approved" or "changes_requested"');
1175
- if (task.state !== "in_review") throw new Error(`task #${task.id} is ${task.state}: cannot move to ${verdict} before its owner calls hub_task_done`);
1176
- const pii = this.isPii(task);
1308
+ const valid = (): Task => {
1309
+ const t = this.need(id);
1310
+ this.mine(t, by, "reviewer");
1311
+ if (verdict !== "approved" && verdict !== "changes_requested") throw new Error('verdict must be "approved" or "changes_requested"');
1312
+ if (t.state !== "in_review") throw new Error(`task #${t.id} is ${t.state}: cannot move to ${verdict} before its owner calls hub_task_done`);
1313
+ return t;
1314
+ };
1177
1315
  const items = textList(unmet);
1178
1316
  if (items.length) note = `${note ?? ""}\nUnmet: ${items.join("; ")}`.trim();
1317
+ // The PII screen reads the note of a review that passes these checks (issue #198); they run again after it.
1318
+ const withheld = await this.withholds("review note", note, valid());
1319
+ const task = valid();
1320
+ const pii = this.isPii(task);
1321
+ const flag = { withheld: withheld || undefined };
1179
1322
  if (verdict === "approved") {
1180
- const next = this.d.board.update(task.id, by, "approved", { state: "approved", rejections: 0 }, note);
1323
+ const next = this.d.board.update(task.id, by, "approved", { state: "approved", rejections: 0 }, note, flag);
1181
1324
  if (next.owner) {
1182
1325
  this.d.board.recordOutcome(next.owner, next.class, true);
1183
1326
  this.d.board.recordReview({ implementer: next.owner, reviewer: by, class: next.class, kind: "approved", task: next.id });
@@ -1186,25 +1329,25 @@ export class Tasks {
1186
1329
  this.d.board.recordReview({ implementer: next.owner, reviewer: r, class: next.class, kind: "caught", task: next.id });
1187
1330
  }
1188
1331
  }
1189
- this.note(next, by, "decision", `Task #${next.id} approved by ${by}: ${next.title}\n${note ?? ""}`);
1190
- this.tell(next, `Task #${next.id} approved by ${by}.${note ? ` ${this.screen(next, note, "review note", next.owner)}` : ""}`, pii);
1332
+ this.note(next, by, "decision", `Task #${next.id} approved by ${by}: ${next.title}\n${note ?? ""}`, withheld);
1333
+ this.tell(next, `Task #${next.id} approved by ${by}.${note ? ` ${this.screen(next, note, "review note", next.owner, withheld)}` : ""}`, pii);
1191
1334
  await this.releaseDependents(next);
1192
1335
  return next;
1193
1336
  }
1194
- const rejected = this.d.board.update(task.id, by, "changes_requested", { state: "changes_requested", rejections: task.rejections + 1 }, note);
1337
+ const rejected = this.d.board.update(task.id, by, "changes_requested", { state: "changes_requested", rejections: task.rejections + 1 }, note, flag);
1195
1338
  this.cohorts.withdraw(rejected.id); // open again: its done intent no longer counts (issue #107)
1196
1339
  if (rejected.owner) this.d.board.recordOutcome(rejected.owner, rejected.class, false);
1197
1340
  this.contradict(rejected);
1198
- this.note(rejected, by, "decision", `Task #${rejected.id} changes requested by ${by}: ${rejected.title}\n${note ?? ""}`);
1341
+ this.note(rejected, by, "decision", `Task #${rejected.id} changes requested by ${by}: ${rejected.title}\n${note ?? ""}`, withheld);
1199
1342
  if (rejected.rejections >= ESCALATE_AFTER) {
1200
1343
  const moved = await this.escalate(HUB, rejected.id, `${rejected.rejections} consecutive changes_requested`, "rejections");
1201
1344
  if (moved.owner !== rejected.owner) return moved;
1202
1345
  // Nobody to escalate to: the owner still has to hear the verdict and the note.
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);
1346
+ this.tell(moved, `Task #${moved.id}: ${by} requests changes again.${note ? ` ${this.screen(moved, note, "review note", moved.owner, withheld)}` : ""} Nobody else can take it; fix it and call hub_task_done again.`, pii);
1204
1347
  return moved;
1205
1348
  }
1206
1349
  const reopened = this.d.board.update(rejected.id, HUB, "reopened", { state: "in_progress" });
1207
- this.tell(reopened, `Task #${reopened.id}: ${by} requests changes.${note ? ` ${this.screen(reopened, note, "review note", reopened.owner)}` : ""} Fix it and call hub_task_done again.`, pii);
1350
+ this.tell(reopened, `Task #${reopened.id}: ${by} requests changes.${note ? ` ${this.screen(reopened, note, "review note", reopened.owner, withheld)}` : ""} Fix it and call hub_task_done again.`, pii);
1208
1351
  return reopened;
1209
1352
  }
1210
1353
 
@@ -1230,7 +1373,12 @@ export class Tasks {
1230
1373
 
1231
1374
  /** Caller authority is checked by the daemon; keep the real actor in task history. */
1232
1375
  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" });
1376
+ const task = this.need(id, true);
1377
+ // Work that waits is handed to nobody yet (issue #207): the peer becomes its reserved owner instead.
1378
+ if (!this.waitsFor(task).length) return this.assignOwner(task, by, { candidates: [peer], event: "reassigned", reason: "manual", override: by === USER });
1379
+ const next = this.d.board.update(task.id, by, "reserved", { reserved: peer }, `for ${peer}`, { reason: "manual" });
1380
+ this.d.notify(`task ${this.publicTitle(next)} reserved for ${peer} by ${by}; it is offered to ${peer} first once what it waits for is approved`);
1381
+ return next;
1234
1382
  }
1235
1383
 
1236
1384
  /**
@@ -1240,6 +1388,9 @@ export class Tasks {
1240
1388
  async reassignForPause(peer: PeerId, context: string | undefined, urgentOnly = false): Promise<{ id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]> {
1241
1389
  const moved: { id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[] = [];
1242
1390
  const routing = this.d.routing();
1391
+ // The hand-off is screened once (#198), before anything moves, when an ordinary task will carry it.
1392
+ const moves = (t: Task) => (!urgentOnly || t.signals.includes("urgent")) && t.owner === peer && OPEN.includes(t.state);
1393
+ const contextWithheld = context && this.d.board.list().some((t) => moves(t) && !this.isPii(t)) ? await this.withholds("handoff", context) : false;
1243
1394
  for (const task of this.d.board.list()) {
1244
1395
  // Waiting out a window that resets soon (issue #36): only urgent work moves.
1245
1396
  if (urgentOnly && !task.signals.includes("urgent")) continue;
@@ -1247,7 +1398,7 @@ export class Tasks {
1247
1398
  const pii = this.isPii(task);
1248
1399
  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);
1249
1400
  const back = task.state === "in_progress" ? this.d.board.update(task.id, HUB, "released", { state: "proposed" }, `budget pause of ${peer}`) : task;
1250
- const next = await this.assignOwner(back, HUB, { candidates, exclude: [peer], event: "reassigned", reason: "budget", note: `budget pause of ${peer}`, clearOnFail: true, ...(context ? { context } : {}) });
1401
+ const next = await this.assignOwner(back, HUB, { candidates, exclude: [peer], event: "reassigned", reason: "budget", note: `budget pause of ${peer}`, clearOnFail: true, ...(context ? { context, contextWithheld } : {}) });
1251
1402
  moved.push({ id: task.id, title: this.publicTitle(task), to: next.owner, role: "owner" });
1252
1403
  } else if (task.reviewer === peer && task.state !== "approved") {
1253
1404
  // No owner candidates: only the reviewer is wanted, and it must be neither the paused peer nor the task's owner.
@@ -1282,6 +1433,7 @@ export class Tasks {
1282
1433
  const title = String(input.title ?? "").trim();
1283
1434
  if (this.isPii({ signals: detectSignals({ title, detail: text, refs: {} }, this.d.routing(), this.d.cwd) })) throw new Error("this note matches a PII pattern and is not saved: claude-mem processes what it stores with a cloud model");
1284
1435
  if (!this.d.memory) return "memory is disabled; nothing saved";
1436
+ if (await this.withholds("note", `${title}\n${text}`, task)) throw new Error("the PII screen did not clear this note, so it is not saved: claude-mem processes what it stores with a cloud model");
1285
1437
  const kind = (NOTE_KINDS as readonly string[]).includes(String(input.kind)) ? String(input.kind) : "finding";
1286
1438
  const res = await this.d.memory.save({ text, ...(title ? { title } : {}), project: this.d.project, metadata: { peer: by, kind, ...(task ? { task: task.id } : {}) } });
1287
1439
  if (!res) return "memory worker unavailable; nothing saved";
@@ -1292,10 +1444,11 @@ export class Tasks {
1292
1444
  }
1293
1445
 
1294
1446
  /** Auto notes: only transitions that carry content, never for PII. */
1295
- private note(task: Task, by: PeerId, kind: string, text: string): void {
1447
+ private note(task: Task, by: PeerId, kind: string, text: string, withheld = false): void {
1296
1448
  if (this.isPii(task) || !this.d.memory) return;
1297
- // claude-mem's observer is a cloud model: model-written text that matches a PII pattern is not sent (#69).
1298
- if (!this.nameable(text)) return this.d.notify(`task #${task.id}: a ${kind} note was not saved to shared memory (it matches a PII pattern)`);
1449
+ // claude-mem's observer is a cloud model: model-written text that matches a PII pattern, or that the PII screen did
1450
+ // not clear (#198), is not sent (#69).
1451
+ if (withheld || !this.nameable(text)) return this.d.notify(`task #${task.id}: a ${kind} note was not saved to shared memory (${this.whyWithheld(text)})`);
1299
1452
  void this.d.memory.save({ text, title: `agent-hub task #${task.id}: ${task.title}`.slice(0, 120), project: this.d.project, metadata: { peer: by, task: task.id, kind } });
1300
1453
  }
1301
1454