@staix/agent-hub 0.12.19 → 0.12.21

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 (87) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +7 -6
  3. package/docs/agent-notes/adapters.md +10 -2
  4. package/docs/agent-notes/bus.md +6 -2
  5. package/docs/agent-notes/daemon.md +10 -3
  6. package/docs/agent-notes/local-worker.md +3 -1
  7. package/docs/agent-notes/models.md +3 -0
  8. package/docs/agent-notes/tasks.md +6 -3
  9. package/docs/agent-notes/tests.md +3 -2
  10. package/docs/bench.md +143 -0
  11. package/docs/events.md +6 -2
  12. package/docs/operations.md +655 -34
  13. package/docs/quickstart.md +12 -10
  14. package/docs/research.md +92 -0
  15. package/docs/security.md +59 -17
  16. package/docs/smoke.md +137 -0
  17. package/docs/specs/2026-09-19-agent-hub-design.md +586 -16
  18. package/docs/specs/2026-09-20-pi-local-models-design.md +2 -0
  19. package/docs/specs/2026-09-20-upgrade-recovery-design.md +345 -0
  20. package/docs/specs/2026-10-04-switchyard-source-port-design.md +10 -0
  21. package/docs/verified.json +29 -18
  22. package/package.json +1 -1
  23. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  24. package/plugins/agent-hub/server.js +65 -26
  25. package/src/adapters/acp.ts +67 -6
  26. package/src/adapters/claude-channel.ts +61 -20
  27. package/src/adapters/codex-appserver.ts +70 -2
  28. package/src/adapters/local-worker.ts +8 -3
  29. package/src/adapters/pi.ts +58 -4
  30. package/src/cli/bench.ts +287 -0
  31. package/src/cli/console-state.ts +531 -110
  32. package/src/cli/console.ts +59 -26
  33. package/src/cli/facts-hook.ts +50 -8
  34. package/src/cli/help.ts +130 -0
  35. package/src/cli/identity.ts +11 -5
  36. package/src/cli/launch.ts +98 -17
  37. package/src/cli/main.ts +325 -88
  38. package/src/cli/preview.ts +14 -3
  39. package/src/cli/reset.ts +194 -0
  40. package/src/cli/status-lines.ts +10 -5
  41. package/src/cli/terminal-recovery.ts +196 -40
  42. package/src/cli/upgrade-runtime.ts +327 -60
  43. package/src/cli/upgrade.ts +442 -45
  44. package/src/hub/ask.ts +1 -1
  45. package/src/hub/bench.ts +211 -0
  46. package/src/hub/board.ts +15 -7
  47. package/src/hub/bus.ts +79 -8
  48. package/src/hub/child-process.ts +1 -0
  49. package/src/hub/conductor.ts +54 -21
  50. package/src/hub/config-trust.ts +1 -0
  51. package/src/hub/control-client.ts +9 -4
  52. package/src/hub/daemon.ts +401 -48
  53. package/src/hub/delivery-journal.ts +6 -6
  54. package/src/hub/events.ts +9 -2
  55. package/src/hub/execution-budget.ts +15 -6
  56. package/src/hub/hub-tools.ts +4 -4
  57. package/src/hub/inference.ts +136 -1
  58. package/src/hub/lifecycle.ts +13 -12
  59. package/src/hub/manager.ts +16 -14
  60. package/src/hub/peers.ts +2 -0
  61. package/src/hub/permission-mode.ts +44 -0
  62. package/src/hub/recovery-store.ts +68 -8
  63. package/src/hub/registry.ts +36 -22
  64. package/src/hub/report.ts +43 -1
  65. package/src/hub/research.ts +255 -0
  66. package/src/hub/restart.ts +25 -0
  67. package/src/hub/routing.ts +48 -16
  68. package/src/hub/snapshots.ts +3 -2
  69. package/src/hub/tasks.ts +295 -115
  70. package/src/hub/tool-refusal.ts +7 -0
  71. package/src/hub/ui.ts +4 -1
  72. package/src/local/deny.ts +2 -1
  73. package/src/local/tools.ts +14 -10
  74. package/src/models/mlx.ts +11 -8
  75. package/src/models/ollama.ts +2 -2
  76. package/src/models/relay.ts +190 -42
  77. package/src/models/route/relay-selector.ts +43 -31
  78. package/src/models/route/runtime.ts +17 -7
  79. package/src/models/route/signals.ts +12 -1
  80. package/src/models/route/stage.ts +57 -3
  81. package/src/omniroute/client.ts +1 -1
  82. package/src/pi/process-signature.ts +23 -0
  83. package/src/pi/tool-receipts.ts +7 -1
  84. package/src/ui/index.html +84 -6
  85. package/src/ui/task-progress.ts +33 -0
  86. package/templates/AGENTS.block.md +1 -1
  87. package/templates/routing.toml +8 -0
package/src/hub/tasks.ts CHANGED
@@ -4,10 +4,12 @@ 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";
12
+ import { PreEffectToolRefusal } from "./tool-refusal.ts";
11
13
  import { nextSweep, taskSweepConfig, type TaskSweepConfig, type SweepRecord } from "./task-sweep.ts";
12
14
 
13
15
  export interface TasksDeps {
@@ -44,6 +46,10 @@ export interface TasksDeps {
44
46
  roles?: Record<string, string[]>;
45
47
  /** Optional: name a class for a task proposed without one. `onCampus` says whether the model call stays on campus. */
46
48
  triage?: { classify: (title: string, detail: string) => Promise<TaskClass | undefined>; onCampus: () => Promise<boolean> };
49
+ /** The PII screen (issue #198): a closed verdict from a model on this machine or on campus, `unknown` on any failure. */
50
+ piiScreen?: (text: string) => Promise<PiiVerdict>;
51
+ /** Each screen verdict, for the record: item, label, source and category, never the text. */
52
+ recordScreen?: (record: ScreenRecord) => void;
47
53
  /** Turn-free coordination (issue #107): configured and no PII task open. Asked at each use. */
48
54
  turnFree?: () => boolean;
49
55
  /** Whether a peer's context path for facts is verified in its current session (issue #108). */
@@ -96,7 +102,7 @@ export const samePlace = (a: string, b: string) => {
96
102
  };
97
103
 
98
104
  /** 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"]);
105
+ export const OWNERSHIP_EVENTS = new Set(["assigned", "escalated", "reassigned", "unassigned"]);
100
106
  /** The note on an accept recorded by the done call itself: its stages are unknown to the split prediction (issue #109). */
101
107
  const WITH_DONE = "with its done";
102
108
  /** How many times a task changed hands: a cohort member's generation (issue #107). */
@@ -130,6 +136,10 @@ function cleanPlan(input: unknown): TaskPlan {
130
136
 
131
137
  /** A plan as one line for other owners; the whole plan is on the board. */
132
138
  const planText = (plan: TaskPlan = {}) => PLAN_KEYS.filter((k) => plan[k]?.length).map((k) => `${k.replace("_", " ")}: ${plan[k]!.join("; ")}`).join(" | ");
139
+ /** What the PII screen reads of a task (#198): the text the patterns are checked against. */
140
+ const screenText = (t: { title: string; detail: string; plan?: TaskPlan }) => [t.title, t.detail, planText(t.plan)].join("\n");
141
+ /** Re-screens of one task whose verdict was unknown, per hub run (#198). */
142
+ const RESCREEN_MAX = 10;
133
143
 
134
144
  /**
135
145
  * The task flow. Adapters and tools never touch the board: every change comes through here, where assignment,
@@ -164,6 +174,42 @@ export class Tasks {
164
174
 
165
175
  isPii = (task: Pick<Task, "signals">) => task.signals.includes("pii") && this.d.routing().constraints.pii === "local_only";
166
176
 
177
+ /** `signals.pii_screen = "local"` where the PII constraint applies (issue #198); on without a screener, every verdict is unknown. */
178
+ private screenOn = (): boolean => {
179
+ const r = this.d.routing();
180
+ return r.signals.pii_screen === "local" && r.constraints.pii === "local_only";
181
+ };
182
+
183
+ /** One bounded screen call. A screener that fails is no verdict, handled as PII (fail closed). */
184
+ private async screenCall(text: string): Promise<PiiVerdict> {
185
+ return (await this.d.piiScreen?.(text).catch(() => undefined)) ?? { label: "unknown", miss: "failed", ms: 0 };
186
+ }
187
+
188
+ private recordScreen(task: number | undefined, item: ScreenItem, v: PiiVerdict | undefined): void {
189
+ const record: ScreenRecord = !v
190
+ ? { item, label: "pii", source: "regex" }
191
+ : { 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 };
192
+ try {
193
+ this.d.recordScreen?.({ ...(task === undefined ? {} : { task }), ...record });
194
+ } catch { /* telemetry only */ }
195
+ }
196
+
197
+ /**
198
+ * Whether the model screen withholds free text about an ordinary task (issue #198): it is on and its verdict is not
199
+ * `clear`. Text that matches a pattern needs no call (the #69 checks withhold it; only the record says so), and a PII
200
+ * task's text is private. Tool callers are models: anything but a string is not screened, so `off` stays as it was.
201
+ */
202
+ private async withholds(item: ScreenItem, text: unknown, task?: Task): Promise<boolean> {
203
+ if (typeof text !== "string" || !text.trim() || !this.screenOn() || (task && this.isPii(task))) return false;
204
+ if (!this.nameable(text)) return (this.recordScreen(task?.id, item, undefined), false);
205
+ const v = await this.screenCall(text);
206
+ this.recordScreen(task?.id, item, v);
207
+ return v.label !== "clear";
208
+ }
209
+
210
+ /** Why free text is withheld: the pattern check first, as #69 words it. */
211
+ private whyWithheld = (text: string) => (this.nameable(text) ? "the PII screen did not clear it" : "it matches a PII pattern");
212
+
167
213
  /** Task-owned, default-off between-turn ladder. Persist before sending so restart cannot repeat a step. */
168
214
  async sweep(now = Date.now()): Promise<{ task: number; finding: SweepRecord }[]> {
169
215
  const recorded: { task: number; finding: SweepRecord }[] = [];
@@ -191,8 +237,9 @@ export class Tasks {
191
237
  }
192
238
  } else {
193
239
  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) });
240
+ 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
241
  const candidate = reviewer ? a.reviewer : a.owner;
242
+ this.passedOver(task, a);
196
243
  this.d.notify(`${message} ${reviewer ? "Reviewer" : "Owner"} reassignment suggestion: ${candidate ?? "no eligible peer"}.`);
197
244
  if (!reviewer && candidate && this.sweepConfig.auto_reassign && this.sweepAvailable(candidate)) {
198
245
  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 +252,7 @@ export class Tasks {
205
252
 
206
253
  private sweepAvailable(peer: PeerId): boolean {
207
254
  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);
255
+ 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
256
  }
210
257
 
211
258
  private sweepNotice(task: Task, peer: PeerId, body: string): void {
@@ -223,14 +270,19 @@ export class Tasks {
223
270
  /** Charge only budgets matching the task actually delivered to this peer, plus active run budgets. */
224
271
  admitExecution = (taskId: number | undefined, peer: PeerId, unit: ExecutionUnit, amount = 1): ExecutionBudgetDecision[] => this.d.executionBudget?.admitTask(taskId, peer, unit, amount) ?? [];
225
272
  /** 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) => {
273
+ admitExecutionEnvelopes = (envs: Envelope[], peer: PeerId, unit: ExecutionUnit, amount = 1): ExecutionBudgetDecision[] =>
274
+ this.d.executionBudget?.admitTasks(this.budgetTaskIds(envs), peer, unit, amount) ?? [];
275
+
276
+ /** Whether an execution budget meters this peer's work on these envelopes; admits nothing. */
277
+ budgetApplies = (envs: Envelope[], peer: PeerId): boolean => this.d.executionBudget?.applies(this.budgetTaskIds(envs), peer) ?? false;
278
+
279
+ private budgetTaskIds(envs: Envelope[]): number[] {
280
+ return [...new Set(envs.flatMap((e) => {
228
281
  const value = e.refs?.task;
229
282
  const id = typeof value === "string" ? Number(value) : NaN;
230
283
  return Number.isSafeInteger(id) && id > 0 ? [id] : [];
231
284
  }))];
232
- return this.d.executionBudget?.admitTasks(ids, peer, unit, amount) ?? [];
233
- };
285
+ }
234
286
 
235
287
  /** What peers other than the owner, the console stream and the log may see of a task. */
236
288
  publicTitle = (task: Task) => (this.isPii(task) ? `#${task.id} [pii]` : `#${task.id} ${task.title}`);
@@ -239,10 +291,10 @@ export class Tasks {
239
291
  publicView(task: Task, fullHistory = false): Record<string, unknown> {
240
292
  const { title, detail, history, ...rest } = task;
241
293
  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}]`;
294
+ 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
295
  const publicHistory = (fullHistory ? history : history.slice(-5)).map(h => ({
244
296
  at: h.at, by: h.by, event: h.event,
245
- ...(h.note === undefined ? {} : { note: screenHistoryText(h.note, "note") }),
297
+ ...(h.note === undefined ? {} : { note: screenHistoryText(h.note, "note", h.withheld) }),
246
298
  ...(h.profile === undefined ? {} : { profile: screenHistoryText(h.profile, "profile") }),
247
299
  ...(h.owner === undefined ? {} : { owner: h.owner }),
248
300
  ...(h.reason && TASK_MOVE_REASONS.includes(h.reason) ? { reason: h.reason } : {}),
@@ -257,7 +309,7 @@ export class Tasks {
257
309
  const noReview = task.class !== "review" && !task.reviewer && OPEN.includes(task.state) && !this.waitsFor(task).length
258
310
  ? this.noReviewer(assign(task, this.states(), this.d.routing(), { candidates: task.owner ? [task.owner] : [], notReviewer: task.owner ?? undefined, ...this.weights(task.class) }))
259
311
  : undefined;
260
- const held = task.owner ? this.d.held?.()[task.owner] : undefined;
312
+ const held = task.owner ? peerEntry(this.d.held?.(), task.owner) : undefined;
261
313
  const holdText = held ? `${task.owner}'s queue is held (${held}); it receives the task once the hold is resolved` : undefined;
262
314
  return [base, noReview, holdText].filter(Boolean).join("; ");
263
315
  }
@@ -390,7 +442,7 @@ export class Tasks {
390
442
  // delivered) or in which it claimed it: the routed peer this very task, the other owner the overlapped one while it
391
443
  // is not started. Busy otherwise, it is at work on something else (#109, #115; a routing or cohort record is taken
392
444
  // 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))])),
445
+ available: Object.fromEntries(peers.map((p) => [p, !peerEntry(failing, p) && (states[p] === "idle" || taking(p))])),
394
446
  });
395
447
  }
396
448
 
@@ -419,7 +471,7 @@ export class Tasks {
419
471
  async propose(by: PeerId, input: { title?: string; detail?: string; class?: string; refs?: TaskRefs; plan?: TaskPlan; owner?: PeerId; after?: unknown; urgent?: unknown }): Promise<Task> {
420
472
  // Callers are models: a title is one line (it is part of console and hub.log lines), not a document.
421
473
  const title = String(input.title ?? "").replace(/\s+/g, " ").trim().slice(0, 300);
422
- if (!title) throw new Error("title is required");
474
+ if (!title) throw new PreEffectToolRefusal("title is required");
423
475
  // Dependencies can only name tasks that exist, so the new task closes no cycle: nothing can depend on it yet.
424
476
  // Callers are models: a missing or null `after` is no dependency, and an id is an integer or a digit string.
425
477
  let afterIds: unknown[] = [];
@@ -428,49 +480,98 @@ export class Tasks {
428
480
  const deps: number[] = [];
429
481
  for (const v of afterIds) {
430
482
  const id = typeof v === "number" || (typeof v === "string" && /^\s*\d+\s*$/.test(v)) ? Number(v) : NaN;
431
- if (!Number.isInteger(id)) throw new Error(`after: ${JSON.stringify(v)} is not a task id`);
432
- if (!this.d.board.get(id)) throw new Error(`after: no task #${id}`);
483
+ if (!Number.isInteger(id)) throw new PreEffectToolRefusal(`after: ${JSON.stringify(v)} is not a task id`);
484
+ if (!this.d.board.get(id)) throw new PreEffectToolRefusal(`after: no task #${id}`);
433
485
  if (!deps.includes(id)) deps.push(id);
434
486
  }
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
- }
487
+ // An owner for work that waits is reserved (issue #207): routing offers it to that peer first once it is ready.
488
+ const reserved = this.waitsFor({ deps }).length ? input.owner : undefined;
439
489
  const given = input.class === undefined || input.class === "" ? undefined : input.class;
440
- if (given !== undefined && !CLASSES.includes(given as TaskClass)) throw new Error(`class must be one of ${CLASSES.join(", ")}`);
490
+ if (given !== undefined && !CLASSES.includes(given as TaskClass)) throw new PreEffectToolRefusal(`class must be one of ${CLASSES.join(", ")}`);
441
491
  const text = { title, detail: String(input.detail ?? "").slice(0, 8000), refs: cleanRefs(input.refs) };
442
492
  const plan = cleanPlan(input.plan);
443
493
  // The plan reaches other owners, so a PII pattern in it makes the task a PII task like one in the detail would.
444
494
  const signals = detectSignals({ ...text, detail: [text.detail, planText(plan)].join("\n") }, this.d.routing(), this.d.cwd);
445
495
  // Urgent work is handed over even when its paused owner's window resets soon (issue #36).
446
496
  if (input.urgent === true && !signals.includes("urgent")) signals.push("urgent");
497
+ // The PII screen (issue #198) reads the same text before anything else does: until its verdict the task is on no
498
+ // board, in no envelope and before no other model. A pattern match is PII without a call; no verdict is PII too.
499
+ const screening = this.screenOn();
500
+ const screened = screening && !signals.includes("pii") ? await this.screenCall(screenText({ ...text, plan })) : undefined;
501
+ if (screened && screened.label !== "clear") signals.push("pii");
447
502
  let cls = given as TaskClass | undefined;
448
503
  let triaged = false;
504
+ let fenced = false; // a PII task off campus: triage is not asked
449
505
  if (!cls && this.d.triage) {
450
506
  // Signals first: a PII task's text may only go to a model that is reached without leaving the campus network.
451
507
  const pii = signals.includes("pii") && this.d.routing().constraints.pii === "local_only";
452
508
  if (!pii || (await this.d.triage.onCampus().catch(() => false))) cls = await this.d.triage.classify(text.title, text.detail).catch(() => undefined);
509
+ else fenced = true;
453
510
  triaged = !!cls;
454
511
  }
455
512
  // A claim is work the caller will do itself: without a class and a model to name one, it is implementation (#6).
456
513
  const defaulted = !cls && input.owner === by;
457
514
  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`);
515
+ if (!cls) throw new PreEffectToolRefusal(`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
516
  const draft = { ...text, class: cls };
460
- let task = this.d.board.propose(by, { ...draft, plan, ...(deps.length ? { deps } : {}), signals });
517
+ let task = this.d.board.propose(by, { ...draft, plan, ...(deps.length ? { deps } : {}), ...(reserved ? { reserved } : {}), signals });
518
+ if (screening) task = this.screenedTask(task, screened);
461
519
  if (triaged) task = this.d.board.update(task.id, "hub", "triaged", {}, `class ${cls} named by the hub's model`);
462
520
  if (defaulted) task = this.d.board.update(task.id, "hub", "class defaulted", {}, "class implement for a claim without one");
463
521
  this.d.notify(`task ${this.publicTitle(task)} proposed by ${by} [${task.class}]${task.signals.length ? ` signals: ${task.signals.join(", ")}` : ""}`);
464
522
  // Re-read: a dependency approved while triage was awaited looked for its dependents before this row existed.
465
523
  const still = this.waitsFor(task);
466
524
  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`);
525
+ 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
526
  return this.d.board.update(task.id, HUB, "blocked", {}, `waits for ${still.map((id) => `#${id}`).join(", ")}`);
469
527
  }
470
528
  // Naming yourself is a claim: the work is already yours, so no offer comes back to you (paper: Agensh CLAIM, #68).
471
529
  return this.assignOwner(task, by, input.owner ? { candidates: [input.owner], claim: input.owner === by } : {});
472
530
  }
473
531
 
532
+ /** 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. */
533
+ private screenedTask(task: Task, v: PiiVerdict | undefined): Task {
534
+ this.recordScreen(task.id, "task", v);
535
+ 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`);
536
+ const note = !v ? "pii: regex" : v.label === "clear" ? "clear" : v.label === "pii" ? `pii: screen, ${v.category}` : `pii: unknown, ${v.miss}`;
537
+ return this.d.board.update(task.id, HUB, "screened", {}, note);
538
+ }
539
+
540
+ /** How often each task was screened again in this hub run. ponytail: in memory, so a restart allows another round. */
541
+ private readonly rescreens = new Map<number, number>();
542
+
543
+ /**
544
+ * A task whose screen gave no verdict waits as a PII task (#198). While it is proposed and no peer ever owned it,
545
+ * nothing private was sent anywhere, so the daemon's release timer asks again here: one task per call (the timer runs
546
+ * one at a time), the least tried first, at most RESCREEN_MAX times each in a hub run; text too long for the screen
547
+ * never clears. `clear` lifts the pii signal and routes the task as usual (a named owner is not remembered, a reserved
548
+ * one is offered first as always), `pii` settles it, another unknown changes only the record. A task an owner took
549
+ * (`local`), even one declined back since, keeps its PII path: its notes, summary and decline reason are not screened.
550
+ */
551
+ async rescreen(): Promise<void> {
552
+ if (!this.screenOn()) return;
553
+ const waiting = (t: Task) => {
554
+ const last = [...t.history].reverse().find((h) => h.event === "screened")?.note ?? "";
555
+ 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";
556
+ };
557
+ const tries = (t: Task) => this.rescreens.get(t.id) ?? 0;
558
+ const task = this.d.board.list("proposed").filter((t) => waiting(t) && tries(t) < RESCREEN_MAX).sort((a, b) => tries(a) - tries(b))[0];
559
+ if (!task) return;
560
+ this.rescreens.set(task.id, (this.rescreens.get(task.id) ?? 0) + 1);
561
+ const text = screenText(task);
562
+ const v = this.nameable(text) ? await this.screenCall(text) : undefined; // a pattern added since settles it
563
+ const now = this.d.board.get(task.id);
564
+ if (!now || !waiting(now)) return; // taken or moved meanwhile: its verdict stands
565
+ this.recordScreen(task.id, "task", v);
566
+ if (v?.label === "unknown") return;
567
+ if (v?.label !== "clear") return void this.d.board.update(task.id, HUB, "screened", {}, v ? `pii: screen, ${v.category}` : "pii: regex");
568
+ const lifted = this.d.board.update(task.id, HUB, "screened", { signals: now.signals.filter((s) => s !== "pii") }, "clear, screened again");
569
+ this.d.notify(`task ${this.publicTitle(lifted)}: the PII screen cleared it on a second look; it is routed as usual`);
570
+ const still = this.waitsFor(lifted);
571
+ if (still.length) return void this.d.board.update(lifted.id, HUB, "blocked", {}, `waits for ${still.map((id) => `#${id}`).join(", ")}`);
572
+ await this.assignOwner(lifted, HUB);
573
+ }
574
+
474
575
  /**
475
576
  * Open tasks of other owners whose paths overlap this one's. The later claimant settles it with the other owner, so
476
577
  * only the new owner reads "settle it"; anyone else learns that the owner was told. PII tasks are left out on both
@@ -564,12 +665,12 @@ export class Tasks {
564
665
  * Model-written free text about an ordinary task (a done summary, a review note with its unmet items, a handoff)
565
666
  * that matches a PII pattern stays on this machine (#69): every peer gets a stub, `local` too, which handles an
566
667
  * 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.
668
+ * `ahub task show`. A PII task's messages are private already. `withheld`: the PII screen did not clear it (#198).
568
669
  */
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}]`;
670
+ private screen(task: Task, text: string, what: string, to: PeerId | null | undefined, withheld = false): string {
671
+ if (!text || this.isPii(task) || to === USER || (!withheld && this.nameable(text))) return text;
672
+ this.d.notify(`task #${task.id}: the ${what} was withheld from ${to ?? "a peer"} (${this.whyWithheld(text)})`);
673
+ return `[${what} withheld: ${this.whyWithheld(text)}; ahub task show ${task.id}]`;
573
674
  }
574
675
 
575
676
  /** Where two tasks meet: shared paths, then shared symbols, leaving out any name that matches a PII pattern. */
@@ -648,7 +749,7 @@ export class Tasks {
648
749
  // Awaits run between tasks (briefs, memory): re-read, and stop for anything that changed meanwhile.
649
750
  const task = this.d.board.get(id);
650
751
  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;
752
+ if (!assign(task, this.states(), this.d.routing(), { exclude: [...this.excluded(task), peer], ...this.health() }).owner) continue;
652
753
  try {
653
754
  const back = task.state === "in_progress" ? this.d.board.update(task.id, HUB, "released", { state: "proposed" }, why) : task;
654
755
  const next = await this.assignOwner(back, HUB, { exclude: [peer], event: "reassigned", reason: "offline", note: why });
@@ -671,12 +772,15 @@ export class Tasks {
671
772
  if (typeof target === "number") {
672
773
  const task = this.d.board.get(target);
673
774
  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) });
775
+ const a = assign(task, this.states(), routing, { exclude: this.excluded(task), waitsFor: this.waitsFor(task), ...this.weights(task.class) });
675
776
  // 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 ?? [])];
777
+ 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
778
  }
678
779
  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;
780
+ const signals = detectSignals(draft, routing, this.d.cwd);
781
+ const trace = assign({ class: target.class, signals }, this.states(), routing, this.weights(target.class)).trace;
782
+ // A draft is not sent to the PII screen (issue #198): a proposal is, and a pii or unknown verdict adds the pii signal.
783
+ 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
784
  }
681
785
 
682
786
  /** Public read path: every generated line is screened, including profile/config-derived trace fields. */
@@ -684,7 +788,22 @@ export class Tasks {
684
788
  return this.explain(target).map(line => this.nameable(line) ? line : "[routing explanation withheld: it matches a PII pattern]");
685
789
  }
686
790
 
687
- private declined = (task: Task) => task.history.filter((h) => h.event === "declined").map((h) => h.by);
791
+ /**
792
+ * Peers routing leaves out for good (#207): the owners a task was declined for (the console declines for one; older rows
793
+ * name only the decliner) and the owners it was escalated away from, so a reservation never hands it back to them.
794
+ */
795
+ private excluded = (task: Task) => task.history.flatMap((h) => (h.event === "declined" ? [h.from ?? h.by] : h.event === "escalated" && h.from ? [h.from] : []));
796
+
797
+ /**
798
+ * Says on the console and hub.log that routing passed over the reserved owner (#207); returns the history note's suffix.
799
+ * Only news is said: not when the reserved owner is the one the task moves away from, nor once it refused the task for
800
+ * good (its decline or escalation said that).
801
+ */
802
+ private passedOver(task: Task, a: Assignment): string {
803
+ if (!a.unreserved || task.reserved === task.owner || this.excluded(task).includes(task.reserved!)) return "";
804
+ this.d.notify(`task ${this.publicTitle(task)}: its reserved owner ${task.reserved} is passed over (${a.unreserved}); routing proceeds`);
805
+ return `; reserved owner ${task.reserved} passed over: ${a.unreserved}`;
806
+ }
688
807
 
689
808
  /** Why the task will approve directly at done: no reviewer could be picked, and who was skipped (issue #92). */
690
809
  private noReviewer(a: Assignment): string {
@@ -697,18 +816,20 @@ export class Tasks {
697
816
 
698
817
  private announceRouting(task: Task, a: Assignment): void {
699
818
  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;
819
+ const hold = a.owner ? peerEntry(this.d.held?.(), a.owner) : undefined;
701
820
  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
821
  }
703
822
 
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> {
823
+ /** `override`: a person's explicit assign, which past refusals do not block and which drops an agent's reservation (#207). */
824
+ 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
825
  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) });
826
+ 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
827
 
708
828
  if (waits.length) {
709
829
  this.d.notify(`task ${this.publicTitle(task)} waits for ${waits.map((id) => `#${id}`).join(", ")}; it is offered once they are approved`);
710
830
  return task;
711
831
  }
832
+ const passed = this.passedOver(task, a);
712
833
  if (!a.owner) {
713
834
  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
835
  // Only a decline takes the task away from its owner; a failed console assign or escalation leaves it where it was.
@@ -719,7 +840,10 @@ export class Tasks {
719
840
  return task;
720
841
  }
721
842
  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 } : {}) });
843
+ // A person's explicit assign replaces an agent's reservation (#207). Only a refusal keeps the owner it moves away from
844
+ // out for good: a reviewer's rejections or an escalation by hand, never the hub's own move after a failed delivery.
845
+ const lasting = opts.event === "escalated" && task.owner && (opts.reason === "rejections" || opts.reason === "manual");
846
+ 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
847
  // What calibration reads (issue #109): routing chose the first owner (no single named candidate, no claim; not an
724
848
  // escalation, relay or reassignment of work already begun), and the work overlaps another owner's task not started
725
849
  // yet. For the record only. Work routed back to its proposer is left out, as its observations are (by === owner).
@@ -744,14 +868,16 @@ export class Tasks {
744
868
  this.d.notify(`task ${this.publicTitle(claimed)} claimed by ${by}`);
745
869
  return claimed;
746
870
  }
747
- await this.sendTask(next, a, opts.context, this.overlaps(next, true, hits));
871
+ await this.sendTask(next, a, opts.context, this.overlaps(next, true, hits), opts.contextWithheld);
748
872
  return next;
749
873
  }
750
874
 
751
- private async sendTask(task: Task, a: Assignment, context?: string, overlap = ""): Promise<void> {
875
+ /** `contextWithheld`: the PII screen did not clear a budget hand-off (#198); an escalation's reason is the hub's own words. */
876
+ private async sendTask(task: Task, a: Assignment, context?: string, overlap = "", contextWithheld = false): Promise<void> {
752
877
  const pii = this.isPii(task);
753
878
  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)}`);
879
+ 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)}`);
880
+ const handoff = context && !pii ? this.screen(task, context, "handoff", a.owner, contextWithheld).slice(0, 3000) : "";
755
881
  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
882
  const plan = planText(task.plan);
757
883
  const body = [
@@ -763,7 +889,7 @@ export class Tasks {
763
889
  rejected.length ? `Earlier review notes:\n${rejected.join("\n")}` : "",
764
890
  brief ?? "",
765
891
  // 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)}` : "",
892
+ handoff ? `Handoff from the previous owner:\n${handoff}` : "",
767
893
  `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
894
  ].filter(Boolean).join("\n\n");
769
895
  // A hand-over the owner takes in a turn of its own (#109, #115): idle before, busy after and not queued, this envelope
@@ -777,58 +903,85 @@ export class Tasks {
777
903
  }
778
904
 
779
905
  /** Nobody, the console user included, works on a task before what it waits for is approved. */
780
- private ready(task: Task): void {
906
+ private ready(task: Task, preEffect = false): void {
781
907
  const waits = this.waitsFor(task);
782
- if (waits.length) throw new Error(`task #${task.id} waits for ${waits.map((id) => `#${id}`).join(", ")}, not approved yet`);
908
+ if (waits.length) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id} waits for ${waits.map((id) => `#${id}`).join(", ")}, not approved yet`);
783
909
  }
784
910
 
785
911
  /** An approved task may be the last thing others waited for: those go through assignment now (issue #34). */
786
912
  private async releaseDependents(approved: Task): Promise<void> {
787
- for (const t of this.d.board.list("proposed")) {
788
- if (!t.deps?.includes(approved.id) || t.owner || this.waitsFor(t).length) continue;
789
- await this.offerReady(t, `#${approved.id} approved`);
913
+ try {
914
+ for (const t of this.d.board.list("proposed")) {
915
+ if (!t.deps?.includes(approved.id) || t.owner || this.waitsFor(t).length) continue;
916
+ await this.offerReady(t, `#${approved.id} approved`);
917
+ }
918
+ } catch (e) {
919
+ this.releaseNotice(`task #${approved.id}: could not release dependents: ${e instanceof Error ? e.message : String(e)}`);
790
920
  }
791
921
  }
792
922
 
793
- private readonly offered = new Set<number>(); // ready tasks offered in this hub run
923
+ /** Notifications may read the same unavailable board; they must not undo a saved approval's return. */
924
+ private releaseNotice(line: string): void {
925
+ try { this.d.notify(line); } catch { /* best effort; the release timer retries ownerless work */ }
926
+ }
927
+
928
+ private readonly offered = new Set<number>(); // assignments in this hub run, including offers still in flight
794
929
  private readonly sent = new Map<string, PeerId>(); // hand-over (`handOver`) -> owner, for the owner's current turn: its task envelope started it, or it claimed the task in it
795
930
 
796
931
  /**
797
932
  * A stop between an approval and the assignment of its dependents (both are saved on their own) leaves them ownerless
798
933
  * with nothing left to wait for, and no later approval to offer them. The daemon calls this on its release timer:
799
- * each such task is offered once per hub run, once an attached peer can take it (peers attach one by one).
934
+ * each such task is offered once an attached peer can take it (peers attach one by one). Only an assignment uses
935
+ * up its offer for this hub run; a failed or ownerless offer stays eligible, after approval and on the timer alike.
800
936
  */
801
937
  async releaseReady(): Promise<void> {
802
938
  for (const t of this.d.board.list("proposed")) {
803
939
  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
940
+ if (!t.deps?.length || t.owner || this.offered.has(t.id) || (last !== "blocked" && last !== "ready" && last !== "reserved") || this.waitsFor(t).length) continue;
941
+ // ponytail: a reserved owner (#207) that attaches after another peer that can take the task is passed over here like
942
+ // an offline one; hold reserved tasks for a grace period if restarts show that up.
943
+ if (!assign(t, this.states(), this.d.routing(), { exclude: this.excluded(t), ...this.health() }).owner) continue; // nobody attached can take it yet
806
944
  await this.offerReady(t, "nothing left to wait for");
807
945
  }
808
946
  }
809
947
 
810
948
  /** Callers hold a list read before an await: re-read, or a task the other caller offered meanwhile is offered twice. */
811
949
  private async offerReady(stale: Task, why: string): Promise<void> {
812
- const t = this.d.board.get(stale.id);
813
- if (!t || t.state !== "proposed" || t.owner || this.offered.has(t.id)) return;
814
- this.offered.add(t.id);
815
- const ready = t.history.at(-1)?.event === "ready" ? t : this.d.board.update(t.id, HUB, "ready", {}, why);
816
- this.d.notify(`task ${this.publicTitle(ready)} is ready: what it waited for is approved`);
817
- await this.assignOwner(ready, HUB).catch((e: Error) => this.d.notify(`task ${this.publicTitle(ready)}: could not be assigned: ${e.message}`));
950
+ let offering = false;
951
+ try {
952
+ const t = this.d.board.get(stale.id);
953
+ if (!t || t.state !== "proposed" || t.owner || this.offered.has(t.id)) return;
954
+ this.offered.add(t.id);
955
+ offering = true;
956
+ const ready = t.history.at(-1)?.event === "ready" ? t : this.d.board.update(t.id, HUB, "ready", {}, why);
957
+ this.releaseNotice(`task ${this.publicTitle(ready)} is ready: what it waited for is approved`);
958
+ await this.assignOwner(ready, HUB);
959
+ } catch (e) {
960
+ this.releaseNotice(`task ${this.publicTitle(stale)}: could not be assigned: ${e instanceof Error ? e.message : String(e)}`);
961
+ } finally {
962
+ if (offering) {
963
+ try {
964
+ if (!this.d.board.get(stale.id)?.owner) this.offered.delete(stale.id);
965
+ } catch (e) {
966
+ this.offered.delete(stale.id);
967
+ this.releaseNotice(`task ${this.publicTitle(stale)}: could not verify assignment: ${e instanceof Error ? e.message : String(e)}`);
968
+ }
969
+ }
970
+ }
818
971
  }
819
972
 
820
- private mine(task: Task, by: PeerId, role: "owner" | "reviewer"): void {
821
- if (by !== USER && task[role] !== by) throw new Error(`task #${task.id}: only its ${role} (${task[role] ?? "none"}) or the console user can do that`);
973
+ private mine(task: Task, by: PeerId, role: "owner" | "reviewer", preEffect = false): void {
974
+ if (by !== USER && task[role] !== by) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id}: only its ${role} (${task[role] ?? "none"}) or the console user can do that`);
822
975
  }
823
976
 
824
- private need(id: unknown, open = false): Task {
977
+ private need(id: unknown, open = false, preEffect = false): Task {
825
978
  // Callers are models (#70): an id is a whole number or a digit string, never an array that Number() would accept.
826
979
  const n = typeof id === "number" ? id : typeof id === "string" && /^\s*\d+\s*$/.test(id) ? Number(id) : NaN;
827
- if (!Number.isInteger(n)) throw new Error(`id must be a task number, not ${(typeof id === "number" ? String(id) : JSON.stringify(id ?? null)).slice(0, 60)}`);
980
+ if (!Number.isInteger(n)) throw new (preEffect ? PreEffectToolRefusal : Error)(`id must be a task number, not ${(typeof id === "number" ? String(id) : JSON.stringify(id ?? null)).slice(0, 60)}`);
828
981
  const task = this.d.board.get(n);
829
- if (!task) throw new Error(`no task #${n}`);
982
+ if (!task) throw new (preEffect ? PreEffectToolRefusal : Error)(`no task #${n}`);
830
983
  // Handing a task to someone else only makes sense while there is work left on it.
831
- if (open && !OPEN.includes(task.state)) throw new Error(`task #${task.id} is ${task.state}: it can no longer change hands`);
984
+ if (open && !OPEN.includes(task.state)) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id} is ${task.state}: it can no longer change hands`);
832
985
  return task;
833
986
  }
834
987
 
@@ -837,15 +990,16 @@ export class Tasks {
837
990
  * announced like one found at assignment (issue #31).
838
991
  */
839
992
  accept(by: PeerId, id: unknown, plan?: unknown): Task {
840
- const task = this.need(id);
841
- this.mine(task, by, "owner");
842
- this.ready(task);
993
+ const task = this.need(id, false, true);
994
+ this.mine(task, by, "owner", true);
995
+ this.ready(task, true);
996
+ if (task.state !== "proposed" && task.state !== "changes_requested" && task.state !== "in_progress") throw new PreEffectToolRefusal(`task #${task.id} is ${task.state}: cannot move to in_progress`);
843
997
  // Models send null or {} for an optional field they leave empty: neither replaces a plan.
844
998
  const given = plan == null ? undefined : cleanPlan(plan);
845
999
  const cleaned = given && Object.keys(given).length ? given : undefined;
846
1000
  // A task's signals are fixed when it is proposed: text that matches a PII pattern cannot be let in afterwards.
847
1001
  if (cleaned && !this.isPii(task) && this.isPii({ signals: detectSignals({ title: "", detail: planText(cleaned), refs: {} }, this.d.routing(), this.d.cwd) })) {
848
- throw new Error("this plan matches a PII pattern and is not kept: other owners would see it");
1002
+ throw new PreEffectToolRefusal("this plan matches a PII pattern and is not kept: other owners would see it");
849
1003
  }
850
1004
  const before = new Set(this.overlapHits(task).map((h) => h.task.id));
851
1005
  const next = this.d.board.update(task.id, by, "accepted", { state: "in_progress", ...(cleaned ? { plan: cleaned } : {}) });
@@ -904,9 +1058,10 @@ export class Tasks {
904
1058
  };
905
1059
 
906
1060
  async decline(by: PeerId, id: unknown, reason?: string): Promise<Task> {
907
- const task = this.need(id, true);
908
- this.mine(task, by, "owner");
909
- const back = this.d.board.update(task.id, by, "declined", task.state === "in_progress" ? { state: "proposed" } : {}, reason);
1061
+ const task = this.need(id, true, true);
1062
+ this.mine(task, by, "owner", true);
1063
+ // The owner it is declined for goes on the entry, also when the console declines for it: `excluded()` keeps it out (#207).
1064
+ const back = this.d.board.update(task.id, by, "declined", task.state === "in_progress" ? { state: "proposed" } : {}, reason, task.owner ? { from: task.owner } : {});
910
1065
  this.d.notify(`task ${this.publicTitle(back)} declined by ${by}${reason && !this.isPii(back) ? `: ${reason}` : ""}`);
911
1066
  return this.assignOwner(back, HUB, { event: "reassigned", reason: "declined", clearOnFail: true });
912
1067
  }
@@ -922,14 +1077,20 @@ export class Tasks {
922
1077
  isChecking = (id: number) => this.checking.has(id);
923
1078
 
924
1079
  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
- }
1080
+ const valid = (): Task => {
1081
+ const t = this.need(id, false, true);
1082
+ this.mine(t, by, "owner", true);
1083
+ if (t.state === "in_review" || t.state === "approved") throw new PreEffectToolRefusal(`task #${t.id} is already ${t.state}`);
1084
+ this.ready(t, true);
1085
+ if (this.checking.has(t.id)) {
1086
+ // The result goes only to the owner the check was started for: anyone who took the task since hears nothing.
1087
+ throw new PreEffectToolRefusal(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`);
1088
+ }
1089
+ return t;
1090
+ };
1091
+ // The PII screen reads the summary of a done that passes these checks (issue #198); they run again after it.
1092
+ const withheld = await this.withholds("summary", summary, valid());
1093
+ let task = valid();
933
1094
  // A member of a silent cohort (issue #107): its done is an intent, and the last of them integrates first.
934
1095
  const cohort = this.cohorts.of(task.id);
935
1096
  const silentMember = !!cohort?.silent && this.turnFree() && !!task.owner && !this.isPii(task);
@@ -960,15 +1121,15 @@ export class Tasks {
960
1121
  }
961
1122
  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
1123
  const command = this.d.runCheck ? this.d.check?.(task.class) : undefined;
963
- if (!command) return this.complete(task, by, summary, refs);
1124
+ if (!command) return this.complete(task, by, summary, refs, "", withheld);
964
1125
  // The tool call returns now; a check can outlast an agent's tool timeout. The result decides what comes next.
965
1126
  this.checking.set(task.id, task.owner);
966
- const pending = this.d.board.update(task.id, by, "done (checking)", { refs: cleanRefs(refs) }, summary);
1127
+ const pending = this.d.board.update(task.id, by, "done (checking)", { refs: cleanRefs(refs) }, summary, { withheld: withheld || undefined });
967
1128
  this.d.notify(`task ${this.publicTitle(pending)} done by ${by}; its check is queued or running: ${command}`);
968
1129
  const seen = { events: pending.history.length, owner: pending.owner };
969
1130
  this.pendingChecks++;
970
1131
  this.checkQueue = this.checkQueue
971
- .then(() => this.finishChecked(pending.id, by, summary, command, seen))
1132
+ .then(() => this.finishChecked(pending.id, by, summary, command, seen, withheld))
972
1133
  .catch((e: Error) => this.d.notify(`task #${pending.id}: its check result could not be recorded: ${e.message}`))
973
1134
  .finally(() => this.pendingChecks--);
974
1135
  return pending;
@@ -1005,7 +1166,7 @@ export class Tasks {
1005
1166
  const theirs = this.places(u);
1006
1167
  const paths = mine.paths.filter((p) => theirs.paths.some((q) => samePlace(p, q)));
1007
1168
  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)] : [];
1169
+ return paths.length || symbols.length ? [this.completedNotice(u, { task: t, paths, symbols }, at.note, at.withheld)] : [];
1009
1170
  });
1010
1171
  if (!open.length && !done.length) continue;
1011
1172
  const owners = [...new Set(open.map((h) => h.task.owner!))];
@@ -1033,7 +1194,8 @@ export class Tasks {
1033
1194
  const lines = others.map((t) => {
1034
1195
  const files = this.places(t).paths.filter(this.nameable);
1035
1196
  const signatures = (t.plan?.signatures ?? []).filter(this.nameable);
1036
- const first = (doneAt(t)?.note ?? "").split("\n").find((l) => l.trim())?.trim().slice(0, 300);
1197
+ const done = doneAt(t);
1198
+ const first = done?.withheld ? undefined : (done?.note ?? "").split("\n").find((l) => l.trim())?.trim().slice(0, 300);
1037
1199
  return [
1038
1200
  `- ${this.publicTitle(t)} (owner ${t.owner}, ${t.state})`,
1039
1201
  files.length ? ` changed files: ${files.join(", ")}` : "",
@@ -1049,7 +1211,7 @@ export class Tasks {
1049
1211
  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
1212
  }
1051
1213
 
1052
- private async finishChecked(id: number, by: PeerId, summary: string | undefined, command: string, seen: { events: number; owner: PeerId | null }): Promise<void> {
1214
+ private async finishChecked(id: number, by: PeerId, summary: string | undefined, command: string, seen: { events: number; owner: PeerId | null }, withheld: boolean): Promise<void> {
1053
1215
  const result = await this.d.runCheck!(command).catch((e: Error) => ({ code: null, timedOut: false, interrupted: false, tail: e.message }));
1054
1216
  this.checking.delete(id);
1055
1217
  const task = this.d.board.get(id);
@@ -1074,7 +1236,7 @@ export class Tasks {
1074
1236
  }
1075
1237
  this.d.board.update(id, HUB, "check passed", {}, `${outcome}\n${result.tail}`.trim());
1076
1238
  // 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);
1239
+ await this.complete(this.d.board.get(id)!, by, `${summary ?? ""}\nCheck: ${outcome}`.trim(), undefined, result.tail, withheld);
1078
1240
  return;
1079
1241
  }
1080
1242
  this.d.board.update(id, HUB, "check failed", {}, `${outcome}\n${result.tail}`.trim());
@@ -1085,12 +1247,12 @@ export class Tasks {
1085
1247
  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
1248
  }
1087
1249
 
1088
- private async complete(task: Task, by: PeerId, summary?: string, refs?: TaskRefs, checkOutput = ""): Promise<Task> {
1250
+ private async complete(task: Task, by: PeerId, summary?: string, refs?: TaskRefs, checkOutput = "", withheld = false): Promise<Task> {
1089
1251
  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);
1252
+ 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
1253
  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);
1254
+ this.note(next, by, "finding", `Task #${next.id} done by ${by}: ${next.title}\n${summary ?? ""}`, withheld);
1255
+ this.tellCompleted(next, summary, withheld);
1094
1256
  if (!reviewer) {
1095
1257
  if (next.owner) this.d.board.recordOutcome(next.owner, next.class, true); // approved without a review is a success too
1096
1258
  this.d.notify(`task ${this.publicTitle(next)} done by ${by}, no reviewer: approved`);
@@ -1106,7 +1268,7 @@ export class Tasks {
1106
1268
  * work is done (after its check, when one runs), and nobody else does (issue #31). A message of its own, not a
1107
1269
  * ride-along line: an owner in the middle of those files needs it before its next task arrives.
1108
1270
  */
1109
- private tellCompleted(task: Task, summary?: string): void {
1271
+ private tellCompleted(task: Task, summary?: string, withheld = false): void {
1110
1272
  const hits = this.overlapHits(task).filter((h) => h.task.owner !== USER && h.task.owner !== HUB);
1111
1273
  const cohort = this.cohorts.of(task.id);
1112
1274
  const silent = this.silentFor(task.id);
@@ -1117,19 +1279,20 @@ export class Tasks {
1117
1279
  cohort.held.add(task.id);
1118
1280
  continue;
1119
1281
  }
1120
- this.whileOpen(hit.task.owner!, hit.task.id, this.completedNotice(task, hit, summary));
1282
+ this.whileOpen(hit.task.owner!, hit.task.id, this.completedNotice(task, hit, summary, withheld));
1121
1283
  }
1122
1284
  }
1123
1285
 
1124
1286
  /**
1125
1287
  * 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.
1288
+ * included), and any item that matches a PII pattern is left out of what other owners get, the summary also when the
1289
+ * PII screen did not clear it (`withheld`, #198).
1127
1290
  */
1128
- private completedNotice(task: Task, hit: { task: Task; paths: string[]; symbols: string[] }, summary?: string): string {
1291
+ private completedNotice(task: Task, hit: { task: Task; paths: string[]; symbols: string[] }, summary?: string, withheld = false): string {
1129
1292
  const paths = this.places(task).paths.filter(this.nameable);
1130
1293
  const signatures = (task.plan?.signatures ?? []).filter(this.nameable);
1131
1294
  const first = (summary ?? "").split("\n").find((l) => l.trim())?.trim().slice(0, 300);
1132
- const line = first && this.nameable(first) ? first : undefined;
1295
+ const line = first && !withheld && this.nameable(first) ? first : undefined;
1133
1296
  return [
1134
1297
  `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
1298
  paths.length ? `Changed files: ${paths.join(", ")}` : "",
@@ -1144,7 +1307,7 @@ export class Tasks {
1144
1307
  const t = this.d.board.get(id);
1145
1308
  if (!t || this.isPii(t)) return [];
1146
1309
  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)];
1310
+ 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
1311
  });
1149
1312
  return lines.length ? lines.join("\n") : undefined;
1150
1313
  }
@@ -1163,21 +1326,28 @@ export class Tasks {
1163
1326
  `- ${check ? `Check result: ${(check.note ?? "").split("\n")[0]}` : "No check ran for this class: run the affected tests yourself."}`,
1164
1327
  `- List what is unmet in hub_review's unmet, one item each.`,
1165
1328
  ].join("\n");
1166
- const summary = last?.note ? this.screen(task, last.note, "summary", reviewer) : "(no summary)";
1329
+ const summary = last?.note ? this.screen(task, last.note, "summary", reviewer, last.withheld) : "(no summary)";
1167
1330
  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
1331
  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
1332
  }
1170
1333
 
1171
1334
  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);
1335
+ const valid = (): Task => {
1336
+ const t = this.need(id, false, true);
1337
+ this.mine(t, by, "reviewer", true);
1338
+ if (verdict !== "approved" && verdict !== "changes_requested") throw new PreEffectToolRefusal('verdict must be "approved" or "changes_requested"');
1339
+ if (t.state !== "in_review") throw new PreEffectToolRefusal(`task #${t.id} is ${t.state}: cannot move to ${verdict} before its owner calls hub_task_done`);
1340
+ return t;
1341
+ };
1177
1342
  const items = textList(unmet);
1178
1343
  if (items.length) note = `${note ?? ""}\nUnmet: ${items.join("; ")}`.trim();
1344
+ // The PII screen reads the note of a review that passes these checks (issue #198); they run again after it.
1345
+ const withheld = await this.withholds("review note", note, valid());
1346
+ const task = valid();
1347
+ const pii = this.isPii(task);
1348
+ const flag = { withheld: withheld || undefined };
1179
1349
  if (verdict === "approved") {
1180
- const next = this.d.board.update(task.id, by, "approved", { state: "approved", rejections: 0 }, note);
1350
+ const next = this.d.board.update(task.id, by, "approved", { state: "approved", rejections: 0 }, note, flag);
1181
1351
  if (next.owner) {
1182
1352
  this.d.board.recordOutcome(next.owner, next.class, true);
1183
1353
  this.d.board.recordReview({ implementer: next.owner, reviewer: by, class: next.class, kind: "approved", task: next.id });
@@ -1186,31 +1356,31 @@ export class Tasks {
1186
1356
  this.d.board.recordReview({ implementer: next.owner, reviewer: r, class: next.class, kind: "caught", task: next.id });
1187
1357
  }
1188
1358
  }
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);
1359
+ this.note(next, by, "decision", `Task #${next.id} approved by ${by}: ${next.title}\n${note ?? ""}`, withheld);
1360
+ this.tell(next, `Task #${next.id} approved by ${by}.${note ? ` ${this.screen(next, note, "review note", next.owner, withheld)}` : ""}`, pii);
1191
1361
  await this.releaseDependents(next);
1192
1362
  return next;
1193
1363
  }
1194
- const rejected = this.d.board.update(task.id, by, "changes_requested", { state: "changes_requested", rejections: task.rejections + 1 }, note);
1364
+ const rejected = this.d.board.update(task.id, by, "changes_requested", { state: "changes_requested", rejections: task.rejections + 1 }, note, flag);
1195
1365
  this.cohorts.withdraw(rejected.id); // open again: its done intent no longer counts (issue #107)
1196
1366
  if (rejected.owner) this.d.board.recordOutcome(rejected.owner, rejected.class, false);
1197
1367
  this.contradict(rejected);
1198
- this.note(rejected, by, "decision", `Task #${rejected.id} changes requested by ${by}: ${rejected.title}\n${note ?? ""}`);
1368
+ this.note(rejected, by, "decision", `Task #${rejected.id} changes requested by ${by}: ${rejected.title}\n${note ?? ""}`, withheld);
1199
1369
  if (rejected.rejections >= ESCALATE_AFTER) {
1200
1370
  const moved = await this.escalate(HUB, rejected.id, `${rejected.rejections} consecutive changes_requested`, "rejections");
1201
1371
  if (moved.owner !== rejected.owner) return moved;
1202
1372
  // 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);
1373
+ 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
1374
  return moved;
1205
1375
  }
1206
1376
  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);
1377
+ 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
1378
  return reopened;
1209
1379
  }
1210
1380
 
1211
1381
  /** Task-level escalation: the next attached peer in the class's escalate_to takes over, with the history. */
1212
- async escalate(by: PeerId, id: unknown, why = "by hand", reason: TaskMoveReason | undefined = by === HUB ? undefined : "manual"): Promise<Task> {
1213
- let task = this.need(id, true);
1382
+ async escalate(by: PeerId, id: unknown, why = "by hand", reason: TaskMoveReason | undefined = by === HUB ? undefined : "manual", preEffect = false): Promise<Task> {
1383
+ let task = this.need(id, true, preEffect);
1214
1384
  const list = this.d.routing().classes[task.class]?.escalate_to ?? [];
1215
1385
  if (task.state === "changes_requested") task = this.d.board.update(task.id, HUB, "reopened", { state: "in_progress" });
1216
1386
  const from = task.owner;
@@ -1229,8 +1399,13 @@ export class Tasks {
1229
1399
  }
1230
1400
 
1231
1401
  /** 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" });
1402
+ async assignTo(id: unknown, peer: PeerId, by: PeerId = USER, preEffect = false): Promise<Task> {
1403
+ const task = this.need(id, true, preEffect);
1404
+ // Work that waits is handed to nobody yet (issue #207): the peer becomes its reserved owner instead.
1405
+ if (!this.waitsFor(task).length) return this.assignOwner(task, by, { candidates: [peer], event: "reassigned", reason: "manual", override: by === USER });
1406
+ const next = this.d.board.update(task.id, by, "reserved", { reserved: peer }, `for ${peer}`, { reason: "manual" });
1407
+ 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`);
1408
+ return next;
1234
1409
  }
1235
1410
 
1236
1411
  /**
@@ -1240,6 +1415,9 @@ export class Tasks {
1240
1415
  async reassignForPause(peer: PeerId, context: string | undefined, urgentOnly = false): Promise<{ id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]> {
1241
1416
  const moved: { id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[] = [];
1242
1417
  const routing = this.d.routing();
1418
+ // The hand-off is screened once (#198), before anything moves, when an ordinary task will carry it.
1419
+ const moves = (t: Task) => (!urgentOnly || t.signals.includes("urgent")) && t.owner === peer && OPEN.includes(t.state);
1420
+ const contextWithheld = context && this.d.board.list().some((t) => moves(t) && !this.isPii(t)) ? await this.withholds("handoff", context) : false;
1243
1421
  for (const task of this.d.board.list()) {
1244
1422
  // Waiting out a window that resets soon (issue #36): only urgent work moves.
1245
1423
  if (urgentOnly && !task.signals.includes("urgent")) continue;
@@ -1247,7 +1425,7 @@ export class Tasks {
1247
1425
  const pii = this.isPii(task);
1248
1426
  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
1427
  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 } : {}) });
1428
+ 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
1429
  moved.push({ id: task.id, title: this.publicTitle(task), to: next.owner, role: "owner" });
1252
1430
  } else if (task.reviewer === peer && task.state !== "approved") {
1253
1431
  // No owner candidates: only the reviewer is wanted, and it must be neither the paused peer nor the task's owner.
@@ -1282,6 +1460,7 @@ export class Tasks {
1282
1460
  const title = String(input.title ?? "").trim();
1283
1461
  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
1462
  if (!this.d.memory) return "memory is disabled; nothing saved";
1463
+ 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
1464
  const kind = (NOTE_KINDS as readonly string[]).includes(String(input.kind)) ? String(input.kind) : "finding";
1286
1465
  const res = await this.d.memory.save({ text, ...(title ? { title } : {}), project: this.d.project, metadata: { peer: by, kind, ...(task ? { task: task.id } : {}) } });
1287
1466
  if (!res) return "memory worker unavailable; nothing saved";
@@ -1292,10 +1471,11 @@ export class Tasks {
1292
1471
  }
1293
1472
 
1294
1473
  /** Auto notes: only transitions that carry content, never for PII. */
1295
- private note(task: Task, by: PeerId, kind: string, text: string): void {
1474
+ private note(task: Task, by: PeerId, kind: string, text: string, withheld = false): void {
1296
1475
  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)`);
1476
+ // claude-mem's observer is a cloud model: model-written text that matches a PII pattern, or that the PII screen did
1477
+ // not clear (#198), is not sent (#69).
1478
+ 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
1479
  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
1480
  }
1301
1481