@staix/agent-hub 0.12.29 → 0.12.31

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 (95) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +92 -218
  3. package/docs/agent-notes/README.md +12 -0
  4. package/docs/agent-notes/adapters.md +3 -2
  5. package/docs/agent-notes/budget.md +1 -1
  6. package/docs/agent-notes/bus.md +7 -1
  7. package/docs/agent-notes/daemon.md +14 -1
  8. package/docs/agent-notes/local-worker.md +4 -2
  9. package/docs/agent-notes/tasks.md +14 -0
  10. package/docs/agent-notes/tests.md +6 -3
  11. package/docs/memory.md +70 -152
  12. package/docs/operations.md +317 -25
  13. package/docs/plans/2026-10-16-amem-failure-registry.md +101 -0
  14. package/docs/plans/2026-10-16-amem-p0-baseline.md +110 -0
  15. package/docs/plans/2026-10-16-amem-p2-read-tools.md +28 -0
  16. package/docs/plans/amem-cases.json +482 -0
  17. package/docs/quickstart.md +24 -2
  18. package/docs/security.md +42 -2
  19. package/docs/smoke.md +11 -0
  20. package/docs/specs/2026-09-19-agent-hub-design.md +142 -7
  21. package/docs/specs/2026-09-20-upgrade-recovery-design.md +84 -10
  22. package/docs/verification/2026-10-17-0.12.30.md +70 -0
  23. package/docs/verification/2026-10-17-0.12.31.md +71 -0
  24. package/docs/verification/2026-10-17-418-probes.md +21 -0
  25. package/docs/verified.json +13 -13
  26. package/package.json +5 -2
  27. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  28. package/plugins/agent-hub/server.js +129 -62
  29. package/src/adapters/claude-channel.ts +29 -11
  30. package/src/adapters/local-worker.ts +2 -2
  31. package/src/adapters/pi.ts +5 -0
  32. package/src/cli/attention.ts +5 -1
  33. package/src/cli/console-state.ts +145 -29
  34. package/src/cli/console.ts +40 -10
  35. package/src/cli/help.ts +3 -2
  36. package/src/cli/identity.ts +4 -3
  37. package/src/cli/jcode-mcp.ts +1 -1
  38. package/src/cli/main.js +2 -2
  39. package/src/cli/main.ts +43 -14
  40. package/src/cli/memory.ts +87 -187
  41. package/src/cli/output.ts +19 -2
  42. package/src/cli/recovery-package.ts +59 -10
  43. package/src/cli/upgrade-interactive.ts +2 -1
  44. package/src/cli/upgrade-restore-state.ts +133 -0
  45. package/src/cli/upgrade-runtime.ts +204 -63
  46. package/src/cli/upgrade.ts +57 -24
  47. package/src/hub/board.ts +83 -24
  48. package/src/hub/bus.ts +108 -38
  49. package/src/hub/check-runner.ts +514 -0
  50. package/src/hub/child-process.ts +1 -0
  51. package/src/hub/control-client.ts +5 -1
  52. package/src/hub/daemon.ts +529 -89
  53. package/src/hub/delivery-hints.ts +30 -0
  54. package/src/hub/delivery-journal.ts +185 -12
  55. package/src/hub/delivery-superseded.ts +52 -0
  56. package/src/hub/envelope.ts +31 -1
  57. package/src/hub/git.ts +2 -38
  58. package/src/hub/host-executable.ts +11 -0
  59. package/src/hub/hub-tools.ts +14 -2
  60. package/src/hub/manager.ts +1 -1
  61. package/src/hub/memory-candidates.ts +103 -0
  62. package/src/hub/memory-fail.ts +25 -0
  63. package/src/hub/memory-observations.ts +47 -0
  64. package/src/hub/memory-plan-guard.ts +33 -0
  65. package/src/hub/memory-policy.ts +42 -0
  66. package/src/hub/memory-profile-check.ts +170 -0
  67. package/src/hub/memory-profile.ts +192 -0
  68. package/src/hub/memory-staleness.ts +55 -0
  69. package/src/hub/path-policy.ts +8 -0
  70. package/src/hub/peer-catalog.ts +3 -0
  71. package/src/hub/project.ts +4 -37
  72. package/src/hub/read-tools.ts +127 -0
  73. package/src/hub/routing.ts +35 -16
  74. package/src/hub/settings.ts +2 -1
  75. package/src/hub/spec-read.ts +120 -0
  76. package/src/hub/supervision.ts +21 -6
  77. package/src/hub/task-sweep.ts +7 -6
  78. package/src/hub/tasks.ts +370 -71
  79. package/src/local/deny.ts +4 -17
  80. package/src/memory/brief.ts +8 -5
  81. package/src/memory/capture.ts +3 -1
  82. package/src/memory/recall.ts +50 -110
  83. package/src/pi/process-signature.ts +2 -42
  84. package/src/ui/index.html +12 -9
  85. package/src/ui/needs.ts +7 -5
  86. package/src/ui/office-model.ts +4 -1
  87. package/src/ui/task-board.ts +1 -1
  88. package/src/ui/task-progress.ts +6 -2
  89. package/templates/AGENTS.block.md +1 -1
  90. package/templates/profiles/general.json +1 -1
  91. package/templates/profiles/research.json +1 -1
  92. package/templates/profiles/writing.json +1 -1
  93. package/src/memory/import.ts +0 -90
  94. package/src/memory/store.ts +0 -805
  95. package/src/memory/sync.ts +0 -145
package/src/hub/tasks.ts CHANGED
@@ -1,21 +1,24 @@
1
+ import { composeRememberText } from "./memory-fail.ts";
1
2
  import { readGitHubImport, type GitHubSource, type GitHubImportSelection } from "./github-import.ts";
2
3
  import { BACKLOG_SPLIT_SCREEN_TIMEOUT_MS, actualTaskDates, planningPatch, type PlanningFields, type Release, type ReleaseState } from "./planning.ts";
3
4
  import { readMergeQueue } from "./sdlc-queue.ts";
4
5
  import { readReleaseReadiness } from "./sdlc-release.ts";
5
6
  import { loadRoleCatalog, taskRole, taskRoleText, type RoleCatalog } from "./roles.ts";
6
7
  import { matchesPolicy } from "./path-policy.ts";
7
- import { mentionsPrivatePath } from "../memory/recall.ts";
8
+ import { correctionInput, resolveVisibleMemory } from "./memory-candidates.ts";
9
+ import type { MemoryScreen } from "./read-tools.ts";
10
+ import { withoutPii, mentionsPrivatePath } from "./memory-policy.ts";
8
11
  import { profileClass, profileData, profileTaskLabel, projectProfile, type ProfileName, type ProfileSelection, type ProjectProfile } from "./profiles.ts";
9
12
  import { phaseView, validatePhaseLinks, workflowHash } from "./workflow.ts";
10
13
  import { isAbsolute, relative } from "node:path";
11
- import type { Briefs } from "../memory/brief.ts";
14
+ import type { Briefs, NativeItem } from "../memory/brief.ts";
12
15
  import type { MemoryClient } from "../memory/client.ts";
13
- import { extractNodes, looksSecret, PROMOTED_KINDS, taskKey, type GoalView, type Kind, type MemoryStore, type Scope, type Tier } from "../memory/store.ts";
14
- import { CLASSES, OUTCOMES_KEPT_MS, ALL_PLAN_KEYS, TASK_MOVE_REASONS, taskHasStarted, planningUndoEntry, type HistoryEntry, type Board, type Task, type TaskClass, type TaskMoveReason, type TaskPlan, type TaskRefs } from "./board.ts";
16
+ import { candidateTargetRevision, type CandidateInput, type CandidateStore, extractNodes, looksSecret, PROMOTED_KINDS, taskKey, type GoalView, type Kind, type MemoryStore, type Scope, type Tier } from "@staix/agent-mem";
17
+ import { CLASSES, OUTCOMES_KEPT_MS, ALL_PLAN_KEYS, TASK_MOVE_REASONS, taskHasStarted, planningUndoEntry, isClosedTaskState, type TaskStall, type HistoryEntry, type Board, type Task, type TaskClass, type TaskMoveReason, type TaskPlan, type TaskRefs } from "./board.ts";
15
18
  import type { Bus } from "./bus.ts";
16
19
  import { HUB, newEnvelope, NOTE_KINDS, noteLine, sanitize, USER, type Envelope, type PeerId, type PeerState } from "./envelope.ts";
17
20
  import type { PiiVerdict, ScreenItem, ScreenRecord } from "./inference.ts";
18
- import { assign, detectSignals, LOCAL, peerEntry, PI, predictSplit, type Assignment, type Routing, type SplitObservation, type SplitPrediction } from "./routing.ts";
21
+ import { assign, detectSignals, LOCAL, peerEntry, PI, predictSplit, type Assignment, type PeerCapabilities, type Routing, type SplitObservation, type SplitPrediction } from "./routing.ts";
19
22
  import { ExecutionBudget, type ExecutionBudgetConfig, type ExecutionBudgetDecision, type ExecutionBudgetStatus, type ExecutionUnit } from "./execution-budget.ts";
20
23
  import { Cohorts, MAX_REQUESTS, type Cohort, type Completion } from "./cohorts.ts";
21
24
  import { realPath } from "./project.ts";
@@ -45,7 +48,15 @@ export interface TasksDeps {
45
48
  projectedReview?: (task: Task) => string | undefined;
46
49
  sdlc?: { config: () => SdlcConfig; gh?: SdlcGh };
47
50
  briefs?: Briefs;
51
+ /** Screened live fail notes directly intersecting the task plan or refs; read-only advice. */
52
+ planFails?: (task: Task) => NativeItem[];
53
+ candidates?: CandidateStore;
54
+ candidateScreen?: MemoryScreen;
55
+ /** Authenticated live turn context, not a model-supplied flag. */
56
+ privateTurn?: (peer: PeerId) => boolean;
48
57
  memory?: MemoryClient;
58
+ /** Captured native profile fence at our memory operation boundaries; no board mutation. */
59
+ memoryCheck?: () => void;
49
60
  /** #258: the hub's own store, the project name it files entries under (the git chain) and the repo `#N` issues belong to. */
50
61
  store?: { store: MemoryStore; project: string; repo: string; deny?: string[]; onNote?: (id: string) => void };
51
62
  /** a line for the human: console tail and hub.log */
@@ -67,6 +78,12 @@ export interface TasksDeps {
67
78
  /** Reviewer choice from recorded review outcomes (issue #35); off unless the project config turns it on. */
68
79
  review?: { adaptive: boolean; min_reviews: number };
69
80
  /** Peers whose recent deliveries all failed, with the reason (issue #89); routing skips them. */
81
+ capabilities?: () => Record<PeerId, PeerCapabilities>;
82
+ repository?: string;
83
+ offerRead?: (peer: PeerId, envelopeId: string) => boolean;
84
+ offerPullOnly?: (peer: PeerId) => boolean;
85
+ cancellationHeld?: () => boolean;
86
+ stallNotice?: (stall: TaskStall) => void;
70
87
  failing?: () => Record<PeerId, string>;
71
88
  /** Peers whose queue a needs_review delivery holds, with the hold detail (issue #90); routing explains the hold, never rejects. */
72
89
  held?: () => Record<PeerId, string>;
@@ -138,6 +155,14 @@ export class AssignmentUndeliveredError extends Error {
138
155
  }
139
156
  }
140
157
 
158
+ /** A later task-flow event won before the assignment could be published, so the stale envelope is suppressed. */
159
+ export class AssignmentSupersededError extends Error {
160
+ constructor(task: Task) {
161
+ super(`task #${task.id}: assignment was superseded before publication`);
162
+ this.name = "AssignmentSupersededError";
163
+ }
164
+ }
165
+
141
166
  /** A project path as one spelling (#67): no leading `./`, no repeated or trailing `/`; the root is `.`. */
142
167
  export const normPath = (p: string) => p.replace(/^(\.\/)+/, "").replace(/\/{2,}/g, "/").replace(/\/+$/, "") || ".";
143
168
 
@@ -290,8 +315,27 @@ export class Tasks {
290
315
  try {
291
316
  for (const task of this.d.board.list()) {
292
317
  if (this.d.sweepHeld?.() || this.d.bus.isRecoveryHeld || this.d.bus.storageError) break;
293
- const finding = nextSweep(task, this.sweepConfig, now);
318
+ if (!task.owner && this.personUnassigned(task)) continue;
319
+ const finding = nextSweep(task, this.sweepConfig, now, this.waitsFor(task).length === 0);
294
320
  if (!finding || this.waitsFor(task).length || this.isChecking(task.id) || this.silentFor(task.id)) continue;
321
+ if (finding.kind === "ready-unassigned") {
322
+ this.d.board.update(task.id, HUB, "idle sweep", {}, undefined, { sweep: finding });
323
+ recorded.push({ task: task.id, finding });
324
+ const planners = Object.entries(this.d.roles ?? {}).filter(([peer, roles]) => roles.includes("planner") && this.d.bus.isRostered(peer)).map(([peer]) => peer);
325
+ const text = `Task #${task.id}: ready-unassigned, idle escalation ${finding.step}/3.`;
326
+ if (finding.step === 1 || finding.step === 2) {
327
+ if (finding.step === 2 || !planners.length) this.releaseNotice(text);
328
+ for (const peer of planners) this.releaseNotice(`task #${task.id}: sweep notice unavailable`, () => this.sweepNotice(task, peer, `${text} Check the board and assign, re-plan or cancel it.`, "planner"));
329
+ } else {
330
+ const admission = this.plannerPullRecord(task)?.pull ? this.plannerAssignment(task, true) : assign(task, this.states(), this.d.routing(), { exclude: this.excluded(task), ...this.weights(task.class) });
331
+ this.releaseNotice(`${text} Owner reassignment suggestion: ${admission.owner ?? "no eligible peer"}.`);
332
+ if (admission.owner && this.sweepConfig.auto_reassign && this.sweepAvailable(admission.owner)) {
333
+ try { await this.assignOwner(this.d.board.get(task.id)!, HUB, { candidates: [admission.owner], event: "reassigned", reason: "idle", note: "ready-unassigned idle sweep" }); }
334
+ catch (e) { if (!(e instanceof AssignmentUndeliveredError)) throw e; }
335
+ }
336
+ }
337
+ continue;
338
+ }
295
339
  const responsible = finding.kind === "review-pending" ? task.reviewer! : task.owner!;
296
340
  if (responsible !== USER && !this.sweepAvailable(responsible)) continue;
297
341
  if (finding.step === 3 && finding.kind !== "review-pending" && this.sweepConfig.auto_reassign && this.plannerPullRecord(task)?.pull && !assign(task, this.states(), this.d.routing(), { exclude: [...this.excluded(task), responsible], excludeOwners: this.plannerFull(task), waitsFor: this.waitsFor(task), ...this.weights(task.class) }).owner) continue;
@@ -302,11 +346,11 @@ export class Tasks {
302
346
  this.d.board.update(task.id, HUB, "idle sweep", {}, undefined, { sweep: finding });
303
347
  recorded.push({ task: task.id, finding });
304
348
  if (finding.step === 1) {
305
- this.sweepNotice(task, responsible, `${message} Check its current state with hub_task_list and accept, complete or review your assigned work.`);
349
+ this.sweepNotice(task, responsible, `${message} Check its current state with hub_task_list and accept, complete or review your assigned work.`, finding.kind === "review-pending" ? "reviewer" : "owner");
306
350
  } else if (finding.step === 2) {
307
351
  this.d.notify(message);
308
352
  for (const [peer, roles] of Object.entries(this.d.roles ?? {})) {
309
- if (peer !== responsible && roles.includes("planner") && this.sweepAvailable(peer)) this.sweepNotice(task, peer, `${message} The assigned ${finding.kind === "review-pending" ? "reviewer" : "owner"} has not recorded fresh task activity; check the board before coordinating.`);
353
+ if (peer !== responsible && roles.includes("planner") && this.sweepAvailable(peer)) this.sweepNotice(task, peer, `${message} The assigned ${finding.kind === "review-pending" ? "reviewer" : "owner"} has not recorded fresh task activity; check the board before coordinating.`, "planner");
310
354
  }
311
355
  } else {
312
356
  const reviewer = finding.kind === "review-pending";
@@ -334,9 +378,9 @@ export class Tasks {
334
378
  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);
335
379
  }
336
380
 
337
- private sweepNotice(task: Task, peer: PeerId, body: string): void {
381
+ private sweepNotice(task: Task, peer: PeerId, body: string, taskRecipient: "owner" | "reviewer" | "planner"): void {
338
382
  if (peer === USER) this.d.notify(body);
339
- else this.d.bus.publish(newEnvelope(HUB, body, { to: [peer], kind: "task", refs: { task: String(task.id) } }));
383
+ else this.d.bus.publish(newEnvelope(HUB, body, { to: [peer], kind: "task", refs: { task: String(task.id), taskRecipient } }));
340
384
  }
341
385
 
342
386
  configureExecutionBudget = (config: ExecutionBudgetConfig): ExecutionBudgetStatus | undefined => {
@@ -368,19 +412,20 @@ export class Tasks {
368
412
 
369
413
  /** A task as a cloud peer may see it. */
370
414
  publicView(task: Task, fullHistory = false): Record<string, unknown> {
371
- const { title, detail, history, external, ...rest } = task;
415
+ const { title, detail, history, external, cancelled, ...rest } = task;
372
416
  if (this.isPii(task)) return { ...rest, actualDates: actualTaskDates(task), refs: {}, plan: {}, ...(task.sdlc ? { sdlc: { stage: stageDefinition(this.d.sdlc?.config() ?? sdlcConfig(undefined), task.sdlc.stage)?.base ?? "unknown" } } : {}), title: "[pii]", detail: "[pii]" };
373
417
  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}]`;
374
418
  const publicHistory = (fullHistory ? history : history.slice(-5)).map(h => ({
375
419
  at: h.at, by: h.by, event: h.event,
376
420
  ...(h.note === undefined ? {} : { note: screenHistoryText(h.note, "note", h.withheld) }),
421
+ ...(h.supersededBy === undefined ? {} : { supersededBy: screenHistoryText(h.supersededBy, "supersession", h.withheld) }),
377
422
  ...(h.profile === undefined ? {} : { profile: screenHistoryText(h.profile, "profile") }),
378
423
  ...(h.owner === undefined ? {} : { owner: h.owner }),
379
424
  ...(h.planning ? { planning: { revision: h.planning.revision, fields: h.planning.fields, ...(h.planning.split ? { children: h.planning.split.children.map(child => child.id) } : {}) } } : {}),
380
425
  ...(h.reason && TASK_MOVE_REASONS.includes(h.reason) ? { reason: h.reason } : {}),
381
426
  ...(h.sweep ? { sweep: { kind: h.sweep.kind, activity: h.sweep.activity, step: h.sweep.step, at: h.sweep.at } } : {}),
382
427
  }));
383
- return { ...rest, ...(external && this.nameable(external.url) ? { external } : {}), actualDates: actualTaskDates(task), ...(((this.d.sdlc?.config().mode ?? "off") !== "off" || this.d.sdlc?.config().workflow === "gsd-lite") ? { lifecycle: sdlcStatus(this.d.sdlc?.config() ?? sdlcConfig(undefined), task.sdlc, undefined, this.workflowView(task)) } : {}), title, detail, history: publicHistory };
428
+ return { ...rest, ...(cancelled ? { cancelled: { ...cancelled, reason: screenHistoryText(cancelled.reason, "cancellation", cancelled.withheld), ...(cancelled.supersededBy ? { supersededBy: screenHistoryText(cancelled.supersededBy, "supersession", cancelled.withheld) } : {}) } } : {}), ...(external && this.nameable(external.url) ? { external } : {}), actualDates: actualTaskDates(task), ...(((this.d.sdlc?.config().mode ?? "off") !== "off" || this.d.sdlc?.config().workflow === "gsd-lite") ? { lifecycle: sdlcStatus(this.d.sdlc?.config() ?? sdlcConfig(undefined), task.sdlc, undefined, this.workflowView(task)) } : {}), title, detail, history: publicHistory };
384
429
  }
385
430
 
386
431
  /** Console results expose direct approval and delivery holds, not just the chosen peer ids. */
@@ -451,7 +496,16 @@ export class Tasks {
451
496
  private health() {
452
497
  const failing = this.d.failing?.();
453
498
  const held = this.d.held?.();
454
- return { pullOnly: [...this.d.bus.peers.values()].filter(peer => peer.id === "kimi" && peer.pullOnly).map(peer => peer.id), offRoster: SUPPORTED_PEERS.filter(peer => !this.d.bus.isRostered(peer)), ...(failing ? { failing } : {}), ...(held ? { held } : {}), ...(this.d.roles ? { roles: this.d.roles } : {}) };
499
+ return { ...(this.d.capabilities ? { capabilities: this.d.capabilities(), repository: this.d.repository } : {}), pullOnly: [...this.d.bus.peers.values()].filter(peer => peer.id === "kimi" && peer.pullOnly).map(peer => peer.id), offRoster: SUPPORTED_PEERS.filter(peer => !this.d.bus.isRostered(peer)), ...(failing ? { failing } : {}), ...(held ? { held } : {}), ...(this.d.roles ? { roles: this.d.roles } : {}) };
500
+ }
501
+
502
+ /** Capability admission precedes explicit assignments/reservations, independent of readiness or transient peer health. */
503
+ private requireCapabilities(task: Parameters<typeof assign>[0], peer: PeerId, role: TaskAssignmentRole = "owner", preEffect = true): void {
504
+ if (!this.d.capabilities || this.isPii(task)) return;
505
+ const { state: _state, ...draft } = task; // backlog/readiness must not bypass an explicit reservation's admission
506
+ const admission = assign(draft, this.states(), this.d.routing(), { candidates: role === "owner" ? [peer] : [], ...(role === "owner" ? { namedOwner: peer } : { namedReviewer: peer }), ...this.weights(task.class) });
507
+ const missing = (admission.refusals ?? []).filter(row => row.peer === peer && row.role === role && (row.code === "tests" || row.code === "spec")).map(row => ` ${role} candidate ${peer}: skipped, ${row.reason}`);
508
+ if (missing.length) throw new (preEffect ? PreEffectToolRefusal : Error)(`cannot assign ${peer}: ${missing.join("; ")}`);
455
509
  }
456
510
 
457
511
  /** What assignment weighs besides states and policy: quota, demotion and the review record. */
@@ -587,7 +641,9 @@ export class Tasks {
587
641
  }
588
642
 
589
643
  async pullPlanned(by: PeerId, input: { id: number; revision: number; reason: string }): Promise<Task> {
590
- this.planningAuthority(by);
644
+ // #455: one authority check, made before the screen and again after it (a person may move the planner meanwhile).
645
+ const authorized = () => this.planningAuthority(by);
646
+ authorized();
591
647
  if (!Number.isSafeInteger(input.id) || input.id <= 0 || !Number.isSafeInteger(input.revision) || input.revision < 0 || typeof input.reason !== "string" || !input.reason.trim() || input.reason.length > 1000) throw new PreEffectToolRefusal("planner pull needs an id, revision and bounded reason");
592
648
  const validate = () => {
593
649
  const task = this.d.board.get(input.id);
@@ -597,7 +653,7 @@ export class Tasks {
597
653
  if (this.waitsFor(task).length) throw new PreEffectToolRefusal("planner pull waits for dependency approval");
598
654
  const priority = task.priority ?? (task.signals.includes("urgent") ? 0 : 2);
599
655
  if (priority === 0) {
600
- const goals = this.d.board.list().filter(goal => goal.state !== "approved" && (goal.priority ?? (goal.signals.includes("urgent") ? 0 : 2)) === 0 && goal.rank != null && goal.history.some(entry => entry.by === USER && entry.planning?.fields.includes("rank")));
656
+ const goals = this.d.board.list().filter(goal => !isClosedTaskState(goal.state) && (goal.priority ?? (goal.signals.includes("urgent") ? 0 : 2)) === 0 && goal.rank != null && goal.history.some(entry => entry.by === USER && entry.planning?.fields.includes("rank")));
601
657
  const effectiveOrder = (candidate: Task) => goals.filter(goal => goal.id === candidate.id || this.prerequisiteOf(goal, candidate.id)).map(goal => ({ rank: goal.rank!, id: goal.id })).sort((a,b) => a.rank-b.rank || a.id-b.id)[0];
602
658
  const order = effectiveOrder(task) ?? { rank: task.rank ?? Number.MAX_SAFE_INTEGER, id: task.id };
603
659
  if (this.d.board.list("backlog").some(other => {
@@ -612,6 +668,7 @@ export class Tasks {
612
668
  return { task, owner: assignment.owner };
613
669
  };
614
670
  const initial = validate(), withheld = await this.withholds("note", input.reason, initial.task);
671
+ authorized();
615
672
  const current = validate();
616
673
  const pulled = this.d.board.pullPlanned(current.task.id, by, current.owner, input.revision, input.reason.trim(), withheld);
617
674
  this.planningRecorded({ by, task: pulled.id, fields: ["pull", "reserved"], revision: pulled.planningRevision! });
@@ -723,7 +780,7 @@ export class Tasks {
723
780
  const validate = () => {
724
781
  const task = this.d.board.get(input.id);
725
782
  if (!task || (task.planningRevision ?? 0) !== input.revision) throw new PreEffectToolRefusal("planning revision changed");
726
- if (task.state === "approved") throw new PreEffectToolRefusal("approved work is final");
783
+ if (isClosedTaskState(task.state)) throw new PreEffectToolRefusal("closed work is final");
727
784
  if (by !== USER && (task.state !== "backlog" || taskHasStarted(task) || task.pinned || Object.hasOwn(patch, "pinned"))) throw new PreEffectToolRefusal("planner changes only unpinned backlog work");
728
785
  if (by !== USER && patch.rank !== undefined && task.history.some(entry => entry.by === USER && entry.planning?.fields.includes("rank"))) throw new PreEffectToolRefusal("a person's backlog order is retained");
729
786
  if (by !== USER && Object.hasOwn(patch, "reserved") && this.hasPersonReservation(task)) throw new PreEffectToolRefusal("a person's reservation is retained");
@@ -731,6 +788,7 @@ export class Tasks {
731
788
  if (task.state !== "backlog" || taskHasStarted(task) || task.owner) throw new PreEffectToolRefusal("dependency and reservation changes require unstarted backlog work");
732
789
  if (patch.reserved && !(SUPPORTED_PEERS as readonly string[]).includes(patch.reserved) && !this.d.bus.knownPeers().includes(patch.reserved) && !this.d.routing().classes[task.class]?.peers.includes(patch.reserved)) throw new PreEffectToolRefusal("reserved owner is not a supported or configured peer");
733
790
  if (patch.reserved && !this.d.bus.isRostered(patch.reserved)) throw new PreEffectToolRefusal("reserved owner is off roster");
791
+ if (patch.reserved) this.requireCapabilities(task, patch.reserved);
734
792
  const rows = new Map(this.d.board.list().map(row => [row.id, row]));
735
793
  const pending = [...(patch.deps ?? task.deps ?? [])], seen = new Set<number>();
736
794
  while (pending.length) {
@@ -754,6 +812,7 @@ export class Tasks {
754
812
  return task;
755
813
  };
756
814
  const task = validate(), withheld = await this.withholds("note", input.reason, task);
815
+ this.planningAuthority(by); // #455: or moved the planner
757
816
  validate(); // a person may have changed planning or frozen a release while the screen ran
758
817
  const updated = this.d.board.plan(task.id, by, patch, input.revision, input.reason.trim(), withheld);
759
818
  this.planningRecorded({ by, task: task.id, fields: Object.keys(patch), revision: updated.planningRevision! });
@@ -771,7 +830,7 @@ export class Tasks {
771
830
  const childIds = new Set(entry.planning.split.children.map(child => child.id)), budgets = this.d.executionBudget?.status();
772
831
  if (Array.isArray(budgets) && budgets.some(budget => budget.kind === "task" && budget.taskId !== undefined && childIds.has(budget.taskId))) throw new PreEffectToolRefusal("split children are referenced by execution budgets");
773
832
  if (this.d.store) {
774
- try { this.d.store.store.refresh(); } catch { throw new PreEffectToolRefusal("split undo requires current memory link evidence"); }
833
+ try { this.d.memoryCheck?.(); this.d.store.store.refresh(); } catch { throw new PreEffectToolRefusal("split undo requires current memory link evidence"); }
775
834
  const children = new Set(entry.planning.split.children.map(child => this.d.board.get(child.id)).filter((child): child is Task => !!child).map(child => this.memoryKey(child)));
776
835
  if ([...children].some(key => this.d.store!.store.hasTask(key)) || this.d.store.store.goals().some(goal => goal.links.some(link => children.has(link.task)))) throw new PreEffectToolRefusal("split children are referenced by long-term tasks");
777
836
  }
@@ -867,6 +926,7 @@ export class Tasks {
867
926
  let screened: Awaited<typeof attempt>;
868
927
  try { screened = await Promise.race([attempt, new Promise<never>((_, reject) => { deadline = setTimeout(() => reject(new PreEffectToolRefusal("split screening request deadline exceeded")), BACKLOG_SPLIT_SCREEN_TIMEOUT_MS); })]); }
869
928
  finally { if (deadline !== undefined) clearTimeout(deadline); }
929
+ this.planningAuthority(by); // #455: a person may have moved the planner while the screen ran
870
930
  const [screenedVerdicts, withheld] = screened, current = validate();
871
931
  const finalSignals = current.drafts.map(part => this.signalsFor({ ...part, detail: [part.detail, planText(part.plan)].join("\n") }));
872
932
  const deterministicPrivate = finalSignals.map((signals, index) => initialPrivate[index] || this.isPii({ signals }));
@@ -887,6 +947,7 @@ export class Tasks {
887
947
  if (typeof input.name !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(input.name) || typeof input.goal !== "string" || !input.goal.trim() || input.goal.length > 1000 || !Number.isSafeInteger(input.order) || input.order < 0) throw new PreEffectToolRefusal("invalid release name, goal or order");
888
948
  const text = input.name + "\n" + input.goal;
889
949
  if (!this.nameable(text) || this.screenOn() && (await this.screenCall(text)).label !== "clear") throw new PreEffectToolRefusal("release text was not cleared for public planning");
950
+ this.planningAuthority(by); // #455: a person may have moved the planner while the screen ran
890
951
  if (this.d.board.releases().some(release => release.name === input.name)) throw new PreEffectToolRefusal("release name already exists");
891
952
  const release = this.d.board.newRelease(input.name, input.goal.trim(), input.order);
892
953
  this.planningRecorded({ by, release: release.id, fields: ["created"], revision: release.revision });
@@ -899,7 +960,7 @@ export class Tasks {
899
960
  if (!Number.isSafeInteger(input.id) || input.id <= 0 || !Number.isSafeInteger(input.revision) || !release || release.revision !== input.revision) throw new PreEffectToolRefusal("release revision changed");
900
961
  if (["shipped", "dropped"].includes(release.state)) throw new PreEffectToolRefusal("settled release is final");
901
962
  if (by !== USER && (release.state === "frozen" || input.state === "frozen" || input.state === "shipped" || input.state === "dropped")) throw new PreEffectToolRefusal("freeze, ship, drop and frozen changes require a person");
902
- if (input.state === "shipped" && this.d.board.list().some(task => task.release === release.id && task.state !== "approved")) throw new PreEffectToolRefusal("every release task must be approved or moved out before ship");
963
+ if (input.state === "shipped" && this.d.board.list().some(task => task.release === release.id && !isClosedTaskState(task.state))) throw new PreEffectToolRefusal("every release task must be approved, cancelled, or moved out before ship");
903
964
  const { id, revision, ...patch } = input;
904
965
  if (!Object.keys(patch).length || Object.keys(patch).some(key => !["state", "plannedStart", "cut", "ship"].includes(key))) throw new PreEffectToolRefusal("unknown or empty release fields");
905
966
  const updated = this.d.board.updateRelease(id, patch, revision);
@@ -966,6 +1027,7 @@ export class Tasks {
966
1027
  catch (error) { throw new PreEffectToolRefusal((error as Error).message); }
967
1028
  const draft = { ...text, class: cls, ...(selected ? { role: selected.id } : {}), ...(profile !== "dev" || given !== undefined && given !== cls || this.d.profileData && this.d.profileData !== projectProfile("dev") ? { profile, profileClass: given ?? cls, ...(profile === "dev" ? { customProfile: true as const } : {}) } : {}) };
968
1029
  if (external) { const prior = this.d.board.imported(external.repo, external.issue); if (prior) return prior; }
1030
+ if (reserved) this.requireCapabilities({ ...draft, signals, ...(external ? { external } : {}) }, reserved);
969
1031
  let task = this.d.board.propose(by, { ...draft, plan, ...(external ? { external } : {}), ...(deps.length ? { deps } : {}), ...(reserved ? { reserved } : {}), signals, ...(backlog ? { backlog: true } : {}) });
970
1032
  created?.();
971
1033
  if (screening) task = this.screenedTask(task, screened);
@@ -1048,6 +1110,7 @@ export class Tasks {
1048
1110
  if (v?.label === "unknown") return;
1049
1111
  if (v?.label !== "clear") return void this.d.board.update(task.id, HUB, "screened", {}, v ? `pii: screen, ${v.category}` : "pii: regex");
1050
1112
  const lifted = this.d.board.update(task.id, HUB, "screened", { signals: now.signals.filter((s) => s !== "pii") }, "clear, screened again");
1113
+ if (this.personUnassigned(lifted)) return;
1051
1114
  this.d.notify(`task ${this.publicTitle(lifted)}: the PII screen cleared it on a second look; it is routed as usual`);
1052
1115
  const still = this.waitsFor(lifted);
1053
1116
  if (still.length) return void this.d.board.update(lifted.id, HUB, "blocked", {}, `waits for ${still.map((id) => `#${id}`).join(", ")}`);
@@ -1160,7 +1223,7 @@ export class Tasks {
1160
1223
  * #106): open work by default; the conflict notices of #91 count a task in review too.
1161
1224
  */
1162
1225
  whileOpen(to: PeerId, task: number, body: string, states: Task["state"][] = OPEN): void {
1163
- const env = newEnvelope(HUB, body, { to: [to], kind: "task", refs: { task: String(task) } });
1226
+ const env = newEnvelope(HUB, body, { to: [to], kind: "task", refs: { task: String(task), taskRecipient: "owner" } });
1164
1227
  this.conditional.set(env.id, { peer: to, task, states });
1165
1228
  if (this.conditional.size > 1024) this.conditional.delete(this.conditional.keys().next().value as string);
1166
1229
  this.d.bus.publish(env);
@@ -1181,6 +1244,10 @@ export class Tasks {
1181
1244
  }
1182
1245
  return true;
1183
1246
  }
1247
+ if (env.from === HUB && env.refs?.task) {
1248
+ const task = this.d.board.get(Number(env.refs.task));
1249
+ if (task?.state === "cancelled") return env.kind === "task" && typeof env.refs.taskCancelled === "number" && env.refs.taskCancelled === task.cancelled?.flowRevision;
1250
+ }
1184
1251
  const c = this.conditional.get(env.id);
1185
1252
  if (!c || c.peer !== peer) return true;
1186
1253
  const t = this.d.board.get(c.task);
@@ -1282,7 +1349,7 @@ export class Tasks {
1282
1349
  try {
1283
1350
  const back = task.state === "in_progress" ? this.d.board.update(task.id, HUB, "released", { state: "proposed" }, why) : task;
1284
1351
  const next = await this.assignOwner(back, HUB, { exclude: [peer], event: "reassigned", reason: "offline", note: why });
1285
- moved.push({ id: task.id, title: this.publicTitle(task), to: next.owner });
1352
+ if (this.ownOwnerMove(back, next, HUB, "reassigned")) moved.push({ id: task.id, title: this.publicTitle(task), to: next.owner });
1286
1353
  // The gone owner hears it on its next delivery, if it comes back mid-work.
1287
1354
  if (next.owner && next.owner !== peer) this.d.tell?.(peer, noteLine(HUB, "decision", `task #${task.id} moved to ${next.owner} while you were offline; stop working on it`));
1288
1355
  } catch (e) {
@@ -1306,6 +1373,7 @@ export class Tasks {
1306
1373
  if (typeof target === "number") {
1307
1374
  const task = this.d.board.get(target);
1308
1375
  if (!task) throw new Error(`no task #${target}`);
1376
+ if (isClosedTaskState(task.state)) return [...workflow, `task ${this.publicTitle(task)} (${task.state}): closed; not routed`];
1309
1377
  const a = assign(task, this.states(), routing, { exclude: this.excluded(task), excludeOwners: this.plannerPullRecord(task)?.pull ? this.plannerFull(task) : [], waitsFor: this.waitsFor(task), ...this.weights(task.class) });
1310
1378
  // The split trace is for the pair the record is about: the task's owner when it has one (issue #109).
1311
1379
  return [...workflow, `task ${this.publicTitle(task)} (${task.state}, owner ${task.owner ?? "none"}${task.reserved ? `, reserved for ${task.reserved}` : ""})`, ...(task.profileClass ? [`profile class ${profileTaskLabel(task)}`] : []), ...(task.role ? [`task role ${task.role} (guidance only)`] : []), "if it were assigned now:", ...a.trace, ...(this.splitShadow(task, task.owner ?? a.owner)?.trace ?? [])];
@@ -1313,14 +1381,17 @@ export class Tasks {
1313
1381
  const draft = { title: target.title, detail: target.detail ?? "", refs: target.refs ?? {} };
1314
1382
  const signals = this.signalsFor(draft, routing);
1315
1383
  const base = profileClass(target.class, this.d.profileData ?? this.d.profile);
1316
- const trace = [...(this.d.profile && this.d.profile !== "dev" ? [`profile class ${target.class} (base ${base}, ${this.d.profile})`] : []), ...assign({ class: base, signals }, this.states(), routing, this.weights(base)).trace];
1384
+ const trace = [...(this.d.profile && this.d.profile !== "dev" ? [`profile class ${target.class} (base ${base}, ${this.d.profile})`] : []), ...assign({ class: base, signals, ...draft }, this.states(), routing, this.weights(base)).trace];
1317
1385
  // A draft is not sent to the PII screen (issue #198): a proposal is, and a pii or unknown verdict adds the pii signal.
1318
1386
  return [...workflow, ...(this.screenOn() && !signals.includes("pii") ? [...trace, "pii_screen is on: at proposal a pii or unknown verdict adds signal pii (local only)"] : trace)];
1319
1387
  }
1320
1388
 
1321
1389
  /** Public read path: every generated line is screened, including profile/config-derived trace fields. */
1322
1390
  publicExplain(target: number | { title: string; detail?: string; class: TaskClass; refs?: TaskRefs }, routing?: Routing): string[] {
1323
- return this.explain(target, routing).map(line => this.nameable(line) ? line : "[routing explanation withheld: it matches a PII pattern]");
1391
+ const trace = this.explain(target, routing);
1392
+ const holds = Object.entries(this.d.held?.() ?? {}).flatMap(([peer, hold]) =>
1393
+ trace.some(line => line.startsWith(` hold: ${peer}'s queue is held:`)) ? [] : [`${peer} queue: ${hold}`]);
1394
+ return [...trace, ...holds].map(line => this.nameable(line) ? line : "[routing explanation withheld: it matches a PII pattern]");
1324
1395
  }
1325
1396
 
1326
1397
  /**
@@ -1329,6 +1400,19 @@ export class Tasks {
1329
1400
  */
1330
1401
  private excluded = (task: Task) => task.history.flatMap((h) => (h.event === "declined" ? [h.from ?? h.by] : h.event === "escalated" && h.from ? [h.from] : []));
1331
1402
 
1403
+ private checkFlowRevision(task: Task, expected: number | undefined, preEffect = false): void {
1404
+ if (expected !== undefined && (!Number.isSafeInteger(expected) || expected < 0 || task.history.length !== expected)) {
1405
+ throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id}: flow revision changed; reload and retry`);
1406
+ }
1407
+ }
1408
+
1409
+ /** A person's owner removal sticks until a later explicit assignment or reservation, across hub runs. */
1410
+ private personUnassigned(task: Task): boolean {
1411
+ const removed = task.history.findLastIndex((h) => h.event === "unassigned" && h.by === USER);
1412
+ if (removed < 0) return false;
1413
+ return !task.history.slice(removed + 1).some((h) => h.by === USER && ["assigned", "reassigned", "escalated", "reserved", "backlog pulled"].includes(h.event));
1414
+ }
1415
+
1332
1416
  /**
1333
1417
  * Says on the console and hub.log that routing passed over the reserved owner (#207); returns the history note's suffix.
1334
1418
  * Only news is said: not when the reserved owner is the one the task moves away from, nor once it refused the task for
@@ -1356,8 +1440,17 @@ export class Tasks {
1356
1440
  if (hold) this.d.notify(`task ${this.publicTitle(task)}: ${a.owner}'s queue is held (${hold}); the task arrives once the hold is resolved`);
1357
1441
  }
1358
1442
 
1443
+ /** A later move to another owner means the caller's assignment was superseded, so its success tail is not authoritative. */
1444
+ private ownOwnerMove(before: Task, after: Task, by: PeerId, event: string): boolean {
1445
+ const moves = (task: Task) => task.history.filter(entry => ["assigned", "reassigned", "escalated", "unassigned"].includes(entry.event));
1446
+ const prior = moves(before).length, entries = moves(after).slice(prior), own = entries[0];
1447
+ if (!own || own.by !== by || own.event !== event) return false;
1448
+ if (own.event === "unassigned") return entries.length === 1;
1449
+ return entries.every(entry => entry.owner === own.owner);
1450
+ }
1451
+
1359
1452
  /** `override`: a person's explicit assign, which past refusals do not block and which drops an agent's reservation (#207). */
1360
- private async assignOwner(task: Task, by: PeerId, opts: { candidates?: PeerId[]; namedOwner?: PeerId; event?: string; note?: string; reason?: TaskMoveReason; clearOnFail?: boolean; exclude?: PeerId[]; context?: string; contextWithheld?: boolean; claim?: boolean; override?: boolean; roster?: boolean; planner?: boolean } = {}): Promise<Task> {
1453
+ private async assignOwner(task: Task, by: PeerId, opts: { candidates?: PeerId[]; namedOwner?: PeerId; event?: string; note?: string; reason?: TaskMoveReason; clearOnFail?: boolean; exclude?: PeerId[]; context?: string; contextWithheld?: boolean; claim?: boolean; override?: boolean; roster?: boolean; planner?: boolean; expectedFlowRevision?: number } = {}): Promise<Task> {
1361
1454
  if (task.state === "backlog") throw new PreEffectToolRefusal(`task #${task.id}: pull backlog work before assigning it`);
1362
1455
  const waits = this.waitsFor(task);
1363
1456
  const plannedMove = opts.planner || this.plannerPullRecord(task)?.pull === true && (by === HUB || opts.roster) && (opts.reason !== "manual" || opts.roster);
@@ -1370,6 +1463,7 @@ export class Tasks {
1370
1463
  }
1371
1464
  const passed = this.passedOver(task, a);
1372
1465
  if (!a.owner) {
1466
+ if (!task.owner && task.state === "proposed") this.reportStall(task, this.ownerStallCause(task, a));
1373
1467
  this.releaseNotice(opts.roster
1374
1468
  ? `task ${this.publicTitle(task)}: ${task.owner} is off roster and no peer can take it; it stays with ${task.owner} in ${task.state}; assign with: ahub task assign ${task.id} <peer>`
1375
1469
  : `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>`);
@@ -1384,7 +1478,7 @@ export class Tasks {
1384
1478
  // A person's explicit assign replaces an agent's reservation (#207). Only a refusal keeps the owner it moves away from
1385
1479
  // out for good: a reviewer's rejections or an escalation by hand, never the hub's own move after a failed delivery.
1386
1480
  const lasting = opts.event === "escalated" && task.owner && (opts.reason === "rejections" || opts.reason === "manual");
1387
- const next = this.d.board.update(task.id, by, opts.event ?? "assigned", { owner: a.owner, reviewer: a.reviewer ?? null, ...(opts.roster && task.state === "in_progress" ? { state: "proposed" as const } : {}), ...(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! } : {}) });
1481
+ const next = this.d.board.update(task.id, by, opts.event ?? "assigned", { owner: a.owner, reviewer: a.reviewer ?? null, ...(opts.roster && task.state === "in_progress" ? { state: "proposed" as const } : {}), ...(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! } : {}) }, opts.expectedFlowRevision);
1388
1482
  // What calibration reads (issue #109): routing chose the first owner (no single named candidate, no claim; not an
1389
1483
  // escalation, relay or reassignment of work already begun), and the work overlaps another owner's task not started
1390
1484
  // yet. For the record only. Work routed back to its proposer is left out, as its observations are (by === owner).
@@ -1398,6 +1492,7 @@ export class Tasks {
1398
1492
  // The cohort record formed just below reads it, and so does `route explain` while that turn lasts.
1399
1493
  if (opts.claim && a.owner === by && this.d.bus.peers.get(by)?.state === "busy") this.sent.set(handOver(next, by), by);
1400
1494
  try {
1495
+ if (by === USER && opts.event === "reassigned") this.withdrawRoleRequest(task, next, "owner");
1401
1496
  const hits = this.overlapHits(next);
1402
1497
  this.formCohort(next, hits);
1403
1498
  if (hits.length) {
@@ -1419,7 +1514,10 @@ export class Tasks {
1419
1514
  // result landing during the brief) leave the assignment exactly as undelivered as before: the notice and
1420
1515
  // the typed error stand (#297 Decisions).
1421
1516
  const moves = (entries: Task["history"]) => entries.filter((h) => !QUIET_EVENTS.has(h.event) && !h.event.startsWith("check "));
1422
- if (moves(saved.history).length !== moves(next.history).length) throw e;
1517
+ if (moves(saved.history).length !== moves(next.history).length) {
1518
+ if (e instanceof AssignmentSupersededError) return saved;
1519
+ throw e;
1520
+ }
1423
1521
  this.releaseNotice(this.undeliveredNotice(saved, e));
1424
1522
  throw new AssignmentUndeliveredError(saved, e);
1425
1523
  }
@@ -1437,7 +1535,7 @@ export class Tasks {
1437
1535
  if (by !== USER) throw new PreEffectToolRefusal("only the person may change an existing task catalog role");
1438
1536
  if (raw === undefined) throw new PreEffectToolRefusal("name a catalog role id or explicit null to clear it");
1439
1537
  const task = this.need(id, false, true);
1440
- if (task.state === "approved") throw new PreEffectToolRefusal("an approved task's role is historical; propose new work instead");
1538
+ if (isClosedTaskState(task.state)) throw new PreEffectToolRefusal("a closed task's role is historical; propose new work instead");
1441
1539
  let selected;
1442
1540
  try { selected = taskRole(raw === null ? undefined : raw, task.class, this.d.catalog ?? loadRoleCatalog(this.d.cwd)); }
1443
1541
  catch (error) { throw new PreEffectToolRefusal((error as Error).message); }
@@ -1447,7 +1545,7 @@ export class Tasks {
1447
1545
  if (!peer || peer === USER) continue;
1448
1546
  this.releaseNotice(`task ${this.publicTitle(next)}: catalog role update delivery unconfirmed`, () => this.d.bus.publish(newEnvelope(HUB,
1449
1547
  `Task #${next.id}: catalog role changed by the person. ${next.role ? this.roleGuidance(next, peer, peer === next.reviewer) : "Catalog guidance cleared; follow the task's existing plan and requirements."}`,
1450
- { to: [peer], kind: "task", refs: { task: String(next.id) }, ...(this.isPii(next) ? { private: true } : {}) })));
1548
+ { to: [peer], kind: "task", refs: { task: String(next.id), taskRecipient: peer === next.reviewer ? "reviewer" : "owner" }, ...(this.isPii(next) ? { private: true } : {}) })));
1451
1549
  }
1452
1550
  return next;
1453
1551
  }
@@ -1465,13 +1563,22 @@ export class Tasks {
1465
1563
  return `Task role ${task.role}: definition unavailable or exceeds the task guidance limit; grants no authority.`;
1466
1564
  }
1467
1565
 
1566
+ private ownerAssignmentCurrent(assigned: Task, current: Task): boolean {
1567
+ if (current.owner !== assigned.owner) return false;
1568
+ const moves = (entries: Task["history"]) => entries.filter((h) => !QUIET_EVENTS.has(h.event) && !h.event.startsWith("check "));
1569
+ return moves(current.history).length === moves(assigned.history).length;
1570
+ }
1571
+
1468
1572
  /** `contextWithheld`: the PII screen did not clear a budget hand-off (#198); an escalation's reason is the hub's own words. */
1469
1573
  private async sendTask(task: Task, a: Assignment, context?: string, overlap = "", contextWithheld = false): Promise<void> {
1470
- const pii = this.isPii(task);
1471
- const brief = pii ? undefined : await this.d.briefs?.forTask(task.owner!, task).catch(() => undefined);
1472
- 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)}`);
1473
- const handoff = context && !pii ? this.screen(task, context, "handoff", a.owner, contextWithheld).slice(0, 3000) : "";
1474
- const facts = [`class ${profileTaskLabel(task)}`, 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("; ");
1574
+ const brief = this.isPii(task) ? undefined : await this.d.briefs?.forTask(task.owner!, task, this.planGuardItems(task)).catch(() => undefined);
1575
+ const current = this.d.board.get(task.id);
1576
+ if (!current || !this.ownerAssignmentCurrent(task, current)) throw new AssignmentSupersededError(current ?? task);
1577
+ task = current;
1578
+ const pii = this.isPii(task), delivery = { ...a, reviewer: task.reviewer ?? undefined };
1579
+ const rejected = task.history.filter((h) => h.event === "changes_requested").map((h) => `- ${h.by}: ${this.screen(task, h.note ?? "", "review note", delivery.owner, h.withheld)}`);
1580
+ const handoff = context && !pii ? this.screen(task, context, "handoff", delivery.owner, contextWithheld).slice(0, 3000) : "";
1581
+ const facts = [`class ${profileTaskLabel(task)}`, delivery.owner === PI ? `backend pi/${delivery.piBackend ?? "dgx"}` : "", task.refs.paths?.length ? `paths ${task.refs.paths.join(", ")}` : "", task.refs.branch ? `branch ${task.refs.branch}` : "", delivery.reviewer ? `reviewer ${delivery.reviewer}` : task.class === "review" ? "no reviewer" : this.noReviewer(delivery)].filter(Boolean).join("; ");
1475
1582
  const plan = planText(task.plan);
1476
1583
  const body = [
1477
1584
  `Task #${task.id} [${profileTaskLabel(task)}] ${task.title}`,
@@ -1493,7 +1600,8 @@ export class Tasks {
1493
1600
  // (Codex, Pi); held, it starts no turn yet: neither is taking it.
1494
1601
  const owner = task.owner!;
1495
1602
  const idle = this.d.bus.stateOf(owner) === "idle";
1496
- const env = newEnvelope(HUB, body, { to: [owner], kind: "task", priority: "important", refs: { ...task.refs, task: String(task.id) }, ...(pii ? { private: true } : {}) });
1603
+ const env = newEnvelope(HUB, body, { to: [owner], kind: "task", priority: "important", refs: { ...task.refs, task: String(task.id), taskRecipient: "owner", taskOffer: task.history.length }, ...(pii ? { private: true } : {}) });
1604
+ this.d.board.recordTaskOffer(task.id, { envelopeId: env.id, owner, assignmentFlowRevision: task.history.length, ...(this.d.offerPullOnly ? { pullOnly: this.d.offerPullOnly(owner) } : {}) }, task.history.length);
1497
1605
  this.d.bus.publish(env);
1498
1606
  if (idle && this.d.bus.stateOf(owner) === "busy" && !this.d.bus.queueIds(owner).includes(env.id)) this.sent.set(handOver(task, owner), owner);
1499
1607
  }
@@ -1551,14 +1659,98 @@ export class Tasks {
1551
1659
  * each such task is offered once an attached peer can take it (peers attach one by one). Only an assignment uses
1552
1660
  * up its offer for this hub run; a failed or ownerless offer stays eligible, after approval and on the timer alike.
1553
1661
  */
1662
+ private offerCandidates?: { revision: number; offers: { task: number; peer: PeerId; envelopeId: string; assignmentFlowRevision: number; pullOnly?: boolean }[] };
1663
+ private stallCandidates?: { revision: number; rows: { stall: TaskStall; task: Task; planned: boolean }[] };
1664
+ private stallView?: { key: string; stalls: TaskStall[] };
1665
+
1666
+ pendingOffers(): { task: number; peer: PeerId; envelopeId: string; assignmentFlowRevision: number; pullOnly?: boolean }[] {
1667
+ const revision = this.d.board.statusRevision();
1668
+ if (this.offerCandidates?.revision !== revision) this.offerCandidates = { revision, offers: this.d.board.list("proposed").flatMap(task => {
1669
+ const offer = task.offer;
1670
+ if (!offer || task.owner !== offer.owner || task.history.slice(offer.assignmentFlowRevision).some(h => ["accepted", "declined", "assigned", "reassigned", "escalated", "unassigned", "reserved", "backlog returned"].includes(h.event) || h.owner !== undefined && h.owner !== offer.owner)) return [];
1671
+ return [{ task: task.id, peer: offer.owner, envelopeId: offer.envelopeId, assignmentFlowRevision: offer.assignmentFlowRevision, ...(offer.pullOnly === undefined ? {} : { pullOnly: offer.pullOnly }) }];
1672
+ }) };
1673
+ return this.offerCandidates.offers.filter(offer => {
1674
+ try { return this.d.offerRead?.(offer.peer, offer.envelopeId) !== true; } catch { return true; }
1675
+ }).map(offer => ({ ...offer }));
1676
+ }
1677
+ stalled(): TaskStall[] {
1678
+ const revision = this.d.board.statusRevision();
1679
+ if (this.stallCandidates?.revision !== revision) this.stallCandidates = { revision, rows: this.d.board.stalls().flatMap(stall => {
1680
+ const task = this.d.board.get(stall.task);
1681
+ if (task?.state !== "proposed" || task.owner || (task.planningRevision ?? 0) !== stall.replanRevision) return [];
1682
+ if (stall.cause.kind === "cancelled-dependency") return task.deps?.includes(stall.cause.dependency!) === true && this.d.board.get(stall.cause.dependency!)?.state === "cancelled" ? [{ stall, task, planned: false }] : [];
1683
+ return this.waitsFor(task).length ? [] : [{ stall, task, planned: this.plannerPullRecord(task)?.pull === true }];
1684
+ }) };
1685
+ // Eligibility inputs are live; queue journal writes alone do not re-decode board histories.
1686
+ const key = JSON.stringify([revision, this.states(), this.d.routing(), this.health(), [...this.d.bus.peers.keys()].map(peer => {
1687
+ const queue = this.d.bus.queueSummary(peer);
1688
+ return [peer, this.wipLimit(peer), queue.heldBy, queue.needsReview];
1689
+ })]);
1690
+ if (this.stallView?.key !== key) this.stallView = { key, stalls: this.stallCandidates.rows.filter(({ stall, task, planned }) => stall.cause.kind === "cancelled-dependency" || !(planned ? this.plannerAssignment(task, true) : assign(task, this.states(), this.d.routing(), { exclude: this.excluded(task), ...this.health() })).owner).map(({ stall }) => stall) };
1691
+ return structuredClone(this.stallView.stalls);
1692
+ }
1693
+ private ownerStallCause(task: Task, admission: Assignment): TaskStall["cause"] {
1694
+ const refusal = admission.refusals?.find(row => row.role === "owner" && row.peer === task.reserved);
1695
+ return refusal ? { kind: "reservation", peer: refusal.peer, code: refusal.code } : { kind: "no-eligible-peer", code: "unavailable", refusals: admission.refusals?.filter(row => row.role === "owner").map(({ peer, code }) => ({ peer, code })).sort((a, b) => a.peer.localeCompare(b.peer)) ?? [] };
1696
+ }
1697
+ private reportStall(task: Task, cause: TaskStall["cause"], now = Date.now()): void {
1698
+ const stall: TaskStall = { task: task.id, cause, signature: JSON.stringify(cause), replanRevision: task.planningRevision ?? 0, at: now };
1699
+ if (!this.d.board.recordStall(stall)) return;
1700
+ const text = `Task #${task.id} stalled: ${cause.kind}${cause.peer ? ` ${cause.peer}` : ""}${cause.dependency ? ` #${cause.dependency}` : ""} (${cause.code}); configure the runner or peer, ahub task assign ${task.id} <peer>, re-plan dependencies, or cancel it.`;
1701
+ this.releaseNotice(text);
1702
+ try { this.d.stallNotice?.(stall); } catch { /* advisory never changes task authority */ }
1703
+ // Advice joins the planner's next delivery; it must not manufacture a turn beside a withheld backlog batch.
1704
+ for (const [peer, roles] of Object.entries(this.d.roles ?? {})) if (roles.includes("planner") && this.d.bus.isRostered(peer)) this.releaseNotice(`task #${task.id}: stall notice unavailable`, () => this.d.tell?.(peer, noteLine(HUB, "finding", text)));
1705
+ }
1706
+ async cancel(id: unknown, by: PeerId, input: { reason: string; supersededBy?: string; expectedFlowRevision?: number }): Promise<Task> {
1707
+ if (typeof input.reason !== "string" || !input.reason.trim() || Buffer.byteLength(input.reason) > 4000 || input.supersededBy !== undefined && (typeof input.supersededBy !== "string" || !input.supersededBy.trim() || Buffer.byteLength(input.supersededBy) > 1000)) throw new PreEffectToolRefusal("cancellation needs a bounded reason and supersession reference");
1708
+ const validate = () => {
1709
+ if (this.d.cancellationHeld?.() || this.d.bus.isRecoveryHeld) throw new PreEffectToolRefusal("recovery or shutdown is holding cancellation");
1710
+ const task = this.need(id, false, true, true);
1711
+ this.checkFlowRevision(task, input.expectedFlowRevision, true);
1712
+ if (isClosedTaskState(task.state)) throw new PreEffectToolRefusal(`task #${task.id} is already ${task.state}`);
1713
+ if (by !== USER && this.d.privateTurn?.(by) && !this.isPii(task)) throw new PreEffectToolRefusal("cancellation on ordinary work is unavailable during a private turn");
1714
+ if (by !== USER) {
1715
+ this.planningAuthority(by);
1716
+ const proposer = task.history.find(h => h.event === "proposed" || h.event === "backlogged")?.by;
1717
+ if (proposer !== by || !["backlog", "proposed"].includes(task.state) || taskHasStarted(task)) throw new PreEffectToolRefusal("planner may cancel only its own historically unaccepted backlog or proposed work");
1718
+ }
1719
+ return task;
1720
+ };
1721
+ const before = validate(), revision = before.history.length;
1722
+ const reasonText = [input.reason, input.supersededBy].filter(Boolean).join("\n");
1723
+ const withheld = looksSecret(reasonText) || this.aboutPrivatePath("", reasonText, before) || await this.withholds("note", reasonText, before);
1724
+ const task = validate();
1725
+ if (task.history.length !== revision) throw new PreEffectToolRefusal("task flow revision changed");
1726
+ const cancelled = { reason: input.reason.trim(), ...(input.supersededBy ? { supersededBy: input.supersededBy.trim() } : {}), flowRevision: revision + 1, ...(withheld ? { withheld: true as const } : {}) };
1727
+ const next = this.d.board.update(task.id, by, "cancelled", { state: "cancelled", reserved: null, cancelled }, cancelled.reason, { ...(withheld ? { withheld: true as const } : {}) }, revision);
1728
+ this.releaseNotice(`task #${task.id}: cancellation cohort cleanup unavailable`, () => this.cohorts.leave(task.id));
1729
+ this.offered.delete(task.id);
1730
+ this.releaseNotice(`task #${task.id}: cancellation stall cleanup unavailable`, () => this.d.board.clearStall(task.id));
1731
+ for (const peer of new Set([task.owner, task.reviewer])) if (peer && peer !== USER) this.releaseNotice(`task #${task.id}: cancellation notice unavailable`, () => this.d.bus.publish(newEnvelope(HUB, sanitize(`Task #${task.id} cancelled. Stop working on it. No native turn or working tree was changed.`), { to: [peer], kind: "task", priority: "important", refs: { task: String(task.id), taskCancelled: cancelled.flowRevision }, ...(this.isPii(next) ? { private: true } : {}) })));
1732
+ this.releaseNotice(`task #${task.id}: cancellation dependent advice unavailable`, () => {
1733
+ for (const dependent of this.d.board.list()) if (!isClosedTaskState(dependent.state) && !dependent.owner && dependent.deps?.includes(task.id)) this.releaseNotice(`task #${dependent.id}: cancellation stall advice unavailable`, () => this.reportStall(dependent, { kind: "cancelled-dependency", dependency: task.id, code: "cancelled" }));
1734
+ });
1735
+ return next;
1736
+ }
1737
+
1554
1738
  async releaseReady(): Promise<void> {
1555
1739
  for (const t of this.d.board.list("proposed")) {
1740
+ if (t.owner) { this.d.board.clearStall(t.id); continue; }
1741
+ const cancelledDependency = t.deps?.find(id => this.d.board.get(id)?.state === "cancelled");
1742
+ if (cancelledDependency) { this.reportStall(t, { kind: "cancelled-dependency", dependency: cancelledDependency, code: "cancelled" }); continue; }
1556
1743
  const last = t.history.findLast(entry => !QUIET_EVENTS.has(entry.event))?.event, planned = this.plannerPullRecord(t)?.pull === true;
1557
- if ((!t.deps?.length && !t.history.some(h => h.event === "backlog pulled")) || t.owner || this.offered.has(t.id) || (!planned && last !== "blocked" && last !== "ready" && last !== "reserved") || this.waitsFor(t).length) continue;
1558
- if (planned && !this.plannerAssignment(t, true).owner) continue;
1744
+ if (this.personUnassigned(t) || this.offered.has(t.id) || this.waitsFor(t).length) continue;
1745
+ const admission = planned ? this.plannerAssignment(t, true) : assign(t, this.states(), this.d.routing(), { exclude: this.excluded(t), ...this.health() });
1746
+ if (!admission.owner) {
1747
+ this.reportStall(t, this.ownerStallCause(t, admission));
1748
+ continue;
1749
+ }
1750
+ this.d.board.clearStall(t.id);
1751
+ if ((!t.deps?.length && !t.history.some(h => h.event === "backlog pulled")) || (!planned && last !== "blocked" && last !== "ready" && last !== "reserved")) continue;
1559
1752
  // ponytail: a reserved owner (#207) that attaches after another peer that can take the task is passed over here like
1560
1753
  // an offline one; hold reserved tasks for a grace period if restarts show that up.
1561
- if (!assign(t, this.states(), this.d.routing(), { exclude: this.excluded(t), ...this.health() }).owner) continue; // nobody attached can take it yet
1562
1754
  await this.offerReady(t, "nothing left to wait for");
1563
1755
  }
1564
1756
  }
@@ -1568,7 +1760,7 @@ export class Tasks {
1568
1760
  let offering = false, undelivered: AssignmentUndeliveredError | undefined;
1569
1761
  try {
1570
1762
  const t = this.d.board.get(stale.id);
1571
- if (!t || t.state !== "proposed" || t.owner || this.offered.has(t.id)) return;
1763
+ if (!t || t.state !== "proposed" || t.owner || this.personUnassigned(t) || this.offered.has(t.id)) return;
1572
1764
  const planned = this.plannerPullRecord(t)?.pull === true;
1573
1765
  const selected = planned ? this.plannerAssignment(t, true).owner : undefined;
1574
1766
  if (planned && !selected) return;
@@ -1673,13 +1865,13 @@ export class Tasks {
1673
1865
  if (config.mode === "advise" && blockers.length) this.d.notify(`task ${this.publicTitle(task)}: SDLC ${transition} advice: ${this.isPii(task) ? "private lifecycle evidence unknown" : blockers.join("; ")}`);
1674
1866
  }
1675
1867
 
1676
- private need(id: unknown, open = false, preEffect = false): Task {
1868
+ private need(id: unknown, open = false, preEffect = false, allowBacklog = false): Task {
1677
1869
  // Callers are models (#70): an id is a whole number or a digit string, never an array that Number() would accept.
1678
1870
  const n = typeof id === "number" ? id : typeof id === "string" && /^\s*\d+\s*$/.test(id) ? Number(id) : NaN;
1679
1871
  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)}`);
1680
1872
  const task = this.d.board.get(n);
1681
1873
  if (!task) throw new (preEffect ? PreEffectToolRefusal : Error)(`no task #${n}`);
1682
- if (task.state === "backlog") throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${n} is backlog: pull it into To Do first`);
1874
+ if (task.state === "backlog" && !allowBacklog) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${n} is backlog: pull it into To Do first`);
1683
1875
  // Handing a task to someone else only makes sense while there is work left on it.
1684
1876
  if (open && !OPEN.includes(task.state)) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id} is ${task.state}: it can no longer change hands`);
1685
1877
  return task;
@@ -1782,7 +1974,7 @@ export class Tasks {
1782
1974
  const valid = (): Task => {
1783
1975
  const t = this.need(id, false, true);
1784
1976
  this.mine(t, by, "owner", true);
1785
- if (t.state === "in_review" || t.state === "approved") throw new PreEffectToolRefusal(`task #${t.id} is already ${t.state}`);
1977
+ if (t.state === "in_review" || isClosedTaskState(t.state)) throw new PreEffectToolRefusal(`task #${t.id} is already ${t.state}`);
1786
1978
  this.ready(t, true);
1787
1979
  if (this.checking.has(t.id)) {
1788
1980
  // The result goes only to the owner the check was started for: anyone who took the task since hears nothing.
@@ -1958,7 +2150,7 @@ export class Tasks {
1958
2150
  task = this.need(task.id, false, true);
1959
2151
  this.mine(task, by, "owner", true);
1960
2152
  // In every mode: another done may have landed during the readback.
1961
- if (task.state === "in_review" || task.state === "approved") throw new Error(`task #${task.id} is already ${task.state}`);
2153
+ if (task.state === "in_review" || isClosedTaskState(task.state)) throw new Error(`task #${task.id} is already ${task.state}`);
1962
2154
  reviewer = task.reviewer;
1963
2155
  }
1964
2156
  const next = this.d.board.update(task.id, by, "done", { state: reviewer ? "in_review" : "approved", ...(task.sdlc ? { sdlc: { ...task.sdlc, stage: lifecycleStage(this.d.sdlc?.config() ?? sdlcConfig(undefined), reviewer ? "review" : "merge") } } : {}), refs: cleanRefs(refs) }, checkOutput ? `${summary ?? ""}\n${checkOutput}`.trim() : summary, { withheld: withheld || undefined });
@@ -2026,6 +2218,25 @@ export class Tasks {
2026
2218
  return lines.length ? lines.join("\n") : undefined;
2027
2219
  }
2028
2220
 
2221
+ private planGuardItems(task: Task): NativeItem[] {
2222
+ if (this.isPii(task)) return [];
2223
+ try {
2224
+ const items: NativeItem[] = [];
2225
+ for (const item of this.d.planFails?.(task) ?? []) {
2226
+ const line = sanitize(item.line.replace(/\s+/g, " ").trim());
2227
+ if (item.kind === "fail" && this.nameable(line) && !items.some(existing => existing.id === item.id)) items.push({ ...item, line });
2228
+ if (items.length === 8) break;
2229
+ }
2230
+ return items;
2231
+ } catch { return []; } // Optional memory cannot refuse an assignment or review.
2232
+ }
2233
+
2234
+ /** Current plan advice for an accepted plan's tool result, which has no new assignment envelope. */
2235
+ planGuardText(task: Task): string {
2236
+ const items = this.planGuardItems(task);
2237
+ return items.length ? ["Live fail notes for this plan (advice, not a block):", ...items.map(item => item.line)].join("\n") : "";
2238
+ }
2239
+
2029
2240
  /** The one place a review request is written: the first reviewer and a replacement get the same text, refs and privacy. */
2030
2241
  private sendReview(task: Task, reviewer: PeerId, why = ""): void {
2031
2242
  const r = task.refs;
@@ -2036,6 +2247,7 @@ export class Tasks {
2036
2247
  const check = [...task.history].reverse().find((h) => h.event === "check passed");
2037
2248
  const checklist = [
2038
2249
  "Checklist:",
2250
+ ...this.planGuardItems(task).map(item => `- Check the plan against this live fail before the remaining checks: ${item.line}`),
2039
2251
  `- Map the changed signatures and call sites to ${plan ? `the plan (${plan})` : task.detail ? "the task detail above" : "the task title"}, and name each one that does not match.`,
2040
2252
  `- ${check ? `Check result: ${(check.note ?? "").split("\n")[0]}` : "No check ran for this class: run the affected tests yourself."}`,
2041
2253
  `- List what is unmet in hub_review's unmet, one item each.`,
@@ -2068,6 +2280,17 @@ export class Tasks {
2068
2280
  reviewedHead = checked.reviewedHead;
2069
2281
  }
2070
2282
  if (verdict === "approved" && this.d.sdlc && this.d.sdlc.config().mode !== "off") { await this.lifecycleGate(task, "approval", reviewedHead); task = valid(); }
2283
+ let candidate: Extract<CandidateInput, { kind: "fail" }> | undefined;
2284
+ if (verdict === "changes_requested" && items.length && this.d.candidates && !withheld && !this.isPii(task) && !this.d.privateTurn?.(by)) {
2285
+ const input: CandidateInput = { project: this.d.store?.project ?? this.d.project, kind: "fail", approach: this.publicTitle(task), symptom: items.join("\n"),
2286
+ cause: "unknown: review unmet items do not establish a cause", retryWhen: "unknown: a person must review the unmet items before accepting",
2287
+ evidenceRefs: [`task:${task.id}`, `reviewed-head:${reviewedHead ?? task.refs.commit ?? "unknown"}`] };
2288
+ const context = JSON.stringify({ title: task.title, detail: task.detail, refs: task.refs, plan: task.plan, signals: task.signals });
2289
+ const payload = JSON.stringify({ input, note, context });
2290
+ const cleared = this.candidateTextAllowed(payload, task) && !await this.withholds("review note", payload, task);
2291
+ task = valid();
2292
+ if (cleared && context === JSON.stringify({ title: task.title, detail: task.detail, refs: task.refs, plan: task.plan, signals: task.signals })) candidate = input; // Async screening cannot authorize a verdict under an obsolete role/state.
2293
+ }
2071
2294
  const pii = this.isPii(task);
2072
2295
  const flag = { withheld: withheld || undefined };
2073
2296
  if (verdict === "approved") {
@@ -2088,6 +2311,9 @@ export class Tasks {
2088
2311
  return next;
2089
2312
  }
2090
2313
  const rejected = this.d.board.update(task.id, by, "changes_requested", { state: "changes_requested", rejections: task.rejections + 1 }, note, flag);
2314
+ if (candidate && !pii && !this.d.privateTurn?.(by) && this.candidateTextAllowed(JSON.stringify({ candidate, refs: rejected.refs, plan: rejected.plan }), rejected)) {
2315
+ try { this.d.memoryCheck?.(); this.d.candidates?.put({ ...candidate, evidenceRefs: [...candidate.evidenceRefs, `review:${rejected.id}:${rejected.history.length}`] }, "public"); } catch { /* a saved review remains authoritative if the optional sidecar fails */ }
2316
+ }
2091
2317
  this.cohorts.withdraw(rejected.id); // open again: its done intent no longer counts (issue #107)
2092
2318
  if (rejected.owner) this.d.board.recordOutcome(rejected.owner, rejected.class, false);
2093
2319
  this.contradict(rejected);
@@ -2128,13 +2354,16 @@ export class Tasks {
2128
2354
  if (from && by !== HUB) this.d.board.recordOutcome(from, task.class, false);
2129
2355
  // Only a reviewer that asked for changes on this work saw it fail: not an escalation of unreviewed work (a Pi failure).
2130
2356
  if (from && task.reviewer && task.reviewer !== USER && this.requestedChanges(task).has(task.reviewer)) this.d.board.recordReview({ implementer: from, reviewer: task.reviewer, class: task.class, kind: "escalated", task: task.id });
2131
- // The guarded tail of a landed move: the console notice, the decision note and the old owner's stop message,
2132
- // each behind its own releaseNotice. It runs on the success path and on a saved move whose delivery failed.
2357
+ const stopFormerOwner = (moved: Task) => {
2358
+ if (from && moved.owner && moved.owner !== from) this.releaseNotice(`task ${this.publicTitle(moved)}: could not tell ${from} it moved to ${moved.owner}`, () =>
2359
+ this.tell({ ...moved, owner: from }, `Task #${moved.id} moved to ${moved.owner} (${why}). Stop working on it.`, this.isPii(moved), "withdrawal"));
2360
+ };
2361
+ // Only our landed move authorizes a success notice and decision note. The former owner's stop
2362
+ // message still names the current owner when a later assignment superseded this move.
2133
2363
  const tail = (moved: Task) => {
2134
2364
  this.releaseNotice(`task ${this.publicTitle(moved)} escalated from ${from} to ${moved.owner} (${why})`);
2135
2365
  this.note(moved, by, "decision", `Task #${moved.id} escalated from ${from} to ${moved.owner}: ${why}`);
2136
- if (from) this.releaseNotice(`task ${this.publicTitle(moved)}: could not tell ${from} it moved to ${moved.owner}`, () =>
2137
- this.tell({ ...moved, owner: from }, `Task #${moved.id} moved to ${moved.owner} (${why}). Stop working on it.`, this.isPii(moved)));
2366
+ stopFormerOwner(moved);
2138
2367
  };
2139
2368
  let next: Task;
2140
2369
  try {
@@ -2143,52 +2372,93 @@ export class Tasks {
2143
2372
  // A saved move whose delivery failed arrives as AssignmentUndeliveredError carrying the saved task (#297);
2144
2373
  // the tail still runs on it (its parts are guarded), and the caller still gets the throw. Anything before
2145
2374
  // the write gets neither.
2146
- if (e instanceof AssignmentUndeliveredError) tail(e.task);
2375
+ if (e instanceof AssignmentUndeliveredError && this.ownOwnerMove(task, e.task, by, "escalated")) tail(e.task);
2147
2376
  throw e;
2148
2377
  }
2149
2378
  if (next.owner && next.owner !== from) {
2150
2379
  // The move is on the board (#243): review() reaches here after its changes_requested write.
2151
- tail(next);
2380
+ if (this.ownOwnerMove(task, next, by, "escalated")) tail(next);
2381
+ else stopFormerOwner(next);
2152
2382
  } else this.releaseNotice(`task ${this.publicTitle(task)}: escalation found nobody in [${list.join(", ")}]; it stays with ${from ?? "nobody"}`);
2153
2383
  return this.d.board.get(next.id)!;
2154
2384
  }
2155
2385
 
2386
+ /** Withdraw only a former role's queued request, then tell it that its authority changed. */
2387
+ private withdrawRoleRequest(previous: Task, next: Task, role: TaskAssignmentRole): void {
2388
+ const peer = previous[role];
2389
+ if (!peer || peer === next[role]) return;
2390
+ const kind = role === "owner" ? "task" : "review";
2391
+ this.releaseNotice(`task ${this.publicTitle(next)}: could not notify previous ${role} ${peer} that its request is withdrawn`, () => {
2392
+ const matches = (env: Envelope) => (role === "reviewer" || env.body.startsWith(`Task #${previous.id} [`)) && env.from === HUB && env.kind === kind && env.refs?.task === String(previous.id);
2393
+ const assignment = previous.history.map(entry => OWNERSHIP_EVENTS.has(entry.event)).lastIndexOf(true);
2394
+ // A self-claim also records accepted, after assigned. State can belong to an earlier owner.
2395
+ const acceptance = previous.history.slice(assignment + 1).some(entry => entry.event === "accepted" && entry.by === peer);
2396
+ const boardEvidence = role === "owner" && acceptance;
2397
+ const mayHaveReceived = boardEvidence || this.d.bus.lastDeliveredTo(peer).some(matches) || this.d.bus.queueList(peer).some(row => ["dispatching", "accepted", "needs_review", "completed"].includes(row.state) && row.originals.some(matches));
2398
+ for (const env of this.d.bus.snapshot(false).queues[peer] ?? []) if (matches(env)) this.d.bus.withdraw(env.id);
2399
+ if (!mayHaveReceived) return;
2400
+ const body = role === "owner"
2401
+ ? `Task ${this.publicTitle(next)} is no longer assigned to you${next.owner ? `; its owner is now ${next.owner}` : "; it is unassigned"}. Do not accept or complete it; late calls under your former assignment are refused.`
2402
+ : `Review request for task #${previous.id} ${this.publicTitle(next)} is withdrawn: ${next.reviewer ? `its reviewer is now ${next.reviewer}` : "the task has no reviewer"}. Do not review this task; a late verdict from the previous reviewer is refused.`;
2403
+ if (peer === USER) this.d.notify(body);
2404
+ // An untagged task notice remains needed by a former role until approval; kind review would look moved already.
2405
+ else this.d.bus.publish(newEnvelope(HUB, body, { to: [peer], kind: "task", priority: "important", refs: { task: String(previous.id) }, ...(this.isPii(next) ? { private: true } : {}) }));
2406
+ });
2407
+ }
2408
+
2156
2409
  /** Caller authority is checked by the daemon; keep the real actor in task history. */
2157
- async assignTo(id: unknown, peer: PeerId, by: PeerId = USER, preEffect = false, role: TaskAssignmentRole = "owner"): Promise<Task> {
2410
+ async assignTo(id: unknown, peer: PeerId, by: PeerId = USER, preEffect = false, role: TaskAssignmentRole = "owner", expectedFlowRevision?: number): Promise<Task> {
2158
2411
  if (role !== "owner" && role !== "reviewer") throw new (preEffect ? PreEffectToolRefusal : Error)("role must be owner or reviewer");
2159
2412
  if (role === "reviewer" && by !== USER) {
2160
2413
  const conductors = Object.entries(this.d.roles ?? {}).filter(([, roles]) => Array.isArray(roles) && roles.includes("conductor")).map(([actor]) => actor);
2161
2414
  if (conductors.length !== 1 || conductors[0] !== by) throw new (preEffect ? PreEffectToolRefusal : Error)("only the console user or the configured conductor may name a reviewer");
2162
2415
  }
2163
2416
  const task = this.need(id, role === "owner", preEffect);
2417
+ this.checkFlowRevision(task, expectedFlowRevision, preEffect);
2164
2418
  if (!this.d.bus.isRostered(peer)) throw new (preEffect ? PreEffectToolRefusal : Error)(`${peer} is off roster; add it to the roster before assigning work`);
2419
+ this.requireCapabilities(task, peer, role, preEffect);
2165
2420
  if (role === "reviewer") {
2166
2421
  if (!OPEN.includes(task.state) && task.state !== "in_review") throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id} is ${task.state}: its reviewer can no longer change`);
2167
2422
  this.ready(task, preEffect);
2168
2423
  const a = assign(task, this.states(), this.d.routing(), { candidates: [], namedReviewer: peer, ...(task.owner ? { notReviewer: task.owner } : {}), ...this.weights(task.class) });
2169
2424
  if (a.reviewer !== peer) throw new (preEffect ? PreEffectToolRefusal : Error)(`cannot name ${peer} as reviewer: ${a.trace.filter(line => line.includes("reviewer") || line.includes("pii")).join("; ")}`);
2170
- const next = this.d.board.update(task.id, by, "reviewer changed", { reviewer: peer }, `named reviewer ${peer}`, { reason: "manual" });
2425
+ const next = this.d.board.update(task.id, by, "reviewer changed", { reviewer: peer }, `named reviewer ${peer}`, { reason: "manual" }, expectedFlowRevision);
2171
2426
  if (next.state === "in_review" && task.reviewer && task.reviewer !== peer) {
2172
- const previous = task.reviewer;
2173
- this.releaseNotice(`task ${this.publicTitle(next)}: could not notify previous reviewer ${previous} that its review request is withdrawn`, () => {
2174
- for (const env of this.d.bus.snapshot(false).queues[previous] ?? []) {
2175
- if (env.from === HUB && env.kind === "review" && env.refs?.task === String(task.id)) this.d.bus.withdraw(env.id);
2176
- }
2177
- const body = `Review request for task #${task.id} ${this.publicTitle(next)} is withdrawn: its reviewer is now ${peer}. Do not review this task; a late verdict from the previous reviewer is refused.`;
2178
- if (previous === USER) this.d.notify(body);
2179
- else this.d.bus.publish(newEnvelope(HUB, body, { to: [previous], kind: "status", priority: "important", refs: { task: String(task.id) }, ...(this.isPii(next) ? { private: true } : {}) }));
2180
- });
2427
+ this.withdrawRoleRequest(task, next, "reviewer");
2181
2428
  }
2182
2429
  if (next.state === "in_review" && peer !== USER) this.releaseNotice(`task ${this.publicTitle(next)}: could not send the review request to ${peer}`, () => this.sendReview(next, peer));
2183
2430
  return next;
2184
2431
  }
2185
2432
  // Work that waits is handed to nobody yet (issue #207): the peer becomes its reserved owner instead.
2186
- if (!this.waitsFor(task).length) return this.assignOwner(task, by, { candidates: [peer], namedOwner: peer, event: "reassigned", reason: "manual", override: by === USER });
2187
- const next = this.d.board.update(task.id, by, "reserved", { reserved: peer }, `for ${peer}`, { reason: "manual" });
2433
+ if (!this.waitsFor(task).length) return this.assignOwner(task, by, { candidates: [peer], namedOwner: peer, event: "reassigned", reason: "manual", override: by === USER, expectedFlowRevision });
2434
+ const next = this.d.board.update(task.id, by, "reserved", { reserved: peer }, `for ${peer}`, { reason: "manual" }, expectedFlowRevision);
2188
2435
  this.releaseNotice(`task ${this.publicTitle(next)} reserved for ${peer} by ${by}; it is offered to ${peer} first once what it waits for is approved`);
2189
2436
  return next;
2190
2437
  }
2191
2438
 
2439
+ /** Person-only removal without routing a replacement. The history marker suppresses later automatic owner offers. */
2440
+ async unassign(id: unknown, by: PeerId = USER, preEffect = false, role: TaskAssignmentRole = "owner", expectedFlowRevision?: number): Promise<Task> {
2441
+ if (by !== USER) throw new (preEffect ? PreEffectToolRefusal : Error)("only the console user may unassign a task role");
2442
+ if (role !== "owner" && role !== "reviewer") throw new (preEffect ? PreEffectToolRefusal : Error)("role must be owner or reviewer");
2443
+ const task = this.need(id, false, preEffect);
2444
+ this.checkFlowRevision(task, expectedFlowRevision, preEffect);
2445
+ if (isClosedTaskState(task.state)) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id} is ${task.state}: its ${role} is historical`);
2446
+ if (role === "owner") {
2447
+ if (!OPEN.includes(task.state)) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id} is ${task.state}: its owner cannot be unassigned`);
2448
+ if (!task.owner && !task.reserved) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id}: no owner or owner reservation to unassign`);
2449
+ const next = this.d.board.update(task.id, by, "unassigned", { owner: null, reserved: null, ...(task.state === "proposed" ? {} : { state: "proposed" as const }) }, `person unassigned ${[task.owner, task.reserved].filter(Boolean).join(" and ")}`, { reason: "manual" }, expectedFlowRevision);
2450
+ this.cohorts.leave(task.id);
2451
+ this.withdrawRoleRequest(task, next, "owner");
2452
+ return next;
2453
+ }
2454
+ if (!OPEN.includes(task.state) && task.state !== "in_review") throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id} is ${task.state}: its reviewer cannot be unassigned`);
2455
+ if (!task.reviewer) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id}: no reviewer to unassign`);
2456
+ if (task.reviewer === USER && this.isPii(task)) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id}: the human reviewer is required for PII work`);
2457
+ const next = this.d.board.update(task.id, by, "reviewer changed", { reviewer: null }, `person unassigned reviewer ${task.reviewer}`, { reason: "manual" }, expectedFlowRevision);
2458
+ this.withdrawRoleRequest(task, next, "reviewer");
2459
+ return next;
2460
+ }
2461
+
2192
2462
  /**
2193
2463
  * Budget relay: a paused peer's open work moves on. `local` first (it has no quota), then the class list, all through
2194
2464
  * the usual constraints. Tasks it was reviewing get another reviewer. Moves are reported, never taken back automatically.
@@ -2200,7 +2470,7 @@ export class Tasks {
2200
2470
  /** Read-only preview using the same assignment constraints as the roster relay. */
2201
2471
  rosterPreview(peer: PeerId): { id: number; title: string; role: "owner" | "reviewer"; to: PeerId | null }[] {
2202
2472
  const routing = this.d.routing();
2203
- return this.d.board.list().filter(task => task.state !== "approved" && (task.owner === peer || task.reviewer === peer)).map(task => {
2473
+ return this.d.board.list().filter(task => !isClosedTaskState(task.state) && (task.owner === peer || task.reviewer === peer)).map(task => {
2204
2474
  const owner = task.owner === peer;
2205
2475
  const weights = this.weights(task.class);
2206
2476
  const pii = this.isPii(task);
@@ -2239,13 +2509,14 @@ export class Tasks {
2239
2509
  if (!(e instanceof AssignmentUndeliveredError)) throw e;
2240
2510
  next = e.task;
2241
2511
  }
2512
+ if (!this.ownOwnerMove(back, next, HUB, "reassigned") && !this.ownOwnerMove(back, next, HUB, "unassigned")) continue;
2242
2513
  if (roster) {
2243
2514
  if (!next.owner || next.owner === peer) continue;
2244
2515
  this.releaseNotice(`task ${this.publicTitle(next)} moved from ${peer} to ${next.owner} (off roster)`);
2245
2516
  if (next.state === "in_review" && next.reviewer && next.reviewer !== task.reviewer && next.reviewer !== USER) this.releaseNotice(`task ${this.publicTitle(next)}: could not send the review request to ${next.reviewer}`, () => this.sendReview(next, next.reviewer!, `(${peer} owned this and is off roster.)`));
2246
2517
  }
2247
2518
  moved.push({ id: task.id, title: this.publicTitle(task), to: next.owner, role: "owner" });
2248
- } else if (task.reviewer === peer && task.state !== "approved") {
2519
+ } else if (task.reviewer === peer && !isClosedTaskState(task.state)) {
2249
2520
  const a = assign(task, this.states(), routing, { exclude: [peer], candidates: [], ...(task.owner ? { notReviewer: task.owner } : {}), ...this.weights(task.class) });
2250
2521
  const reviewer = a.reviewer && a.reviewer !== peer && a.reviewer !== task.owner ? a.reviewer : null;
2251
2522
  if (roster && !reviewer) {
@@ -2274,9 +2545,33 @@ export class Tasks {
2274
2545
  return { ...(policy?.route ? { route: policy.route } : {}), ...(policy?.fixed_model ? { fixedModel: policy.fixed_model } : {}), pii, task: String(about.id) };
2275
2546
  }
2276
2547
 
2277
- async remember(by: PeerId, input: { text?: string; title?: string; kind?: string; task?: unknown; horizon?: unknown; scope?: unknown }): Promise<string> {
2278
- const text = String(input.text ?? "").trim();
2279
- if (!text) throw new Error("text is required");
2548
+ private candidateTextAllowed(text: string, task?: Task): boolean {
2549
+ return (!task || !this.isPii(task)) && this.nameable(text) && !looksSecret(text) && !this.aboutPrivatePath("", text, task);
2550
+ }
2551
+
2552
+ /** Candidate only: the person remains the sole acceptance authority. */
2553
+ async correct(by: PeerId, input: unknown): Promise<string> {
2554
+ const args = correctionInput(input);
2555
+ const st = this.d.store;
2556
+ if (!st || !this.d.candidates) throw new PreEffectToolRefusal("memory correction unavailable");
2557
+ const screen: MemoryScreen = rows => this.d.candidateScreen?.(rows) ?? withoutPii(rows, this.d.routing(), this.d.cwd, this.d.piiPaths ?? []);
2558
+ const valid = () => {
2559
+ this.d.memoryCheck?.();
2560
+ if (this.d.privateTurn?.(by)) throw new PreEffectToolRefusal("memory correction unavailable during a private turn");
2561
+ if (!this.candidateTextAllowed(JSON.stringify(args))) throw new PreEffectToolRefusal("memory correction withheld by privacy policy");
2562
+ return resolveVisibleMemory(st.store, st.project, screen, args.id);
2563
+ };
2564
+ const target = valid(), revision = candidateTargetRevision(target);
2565
+ if (await this.withholds("note", `${args.text}\n${args.reason}`)) throw new PreEffectToolRefusal("memory correction withheld by privacy policy");
2566
+ const current = valid();
2567
+ if (current.id !== target.id || candidateTargetRevision(current) !== revision) throw new PreEffectToolRefusal("memory correction target changed; read it again");
2568
+ const id = this.d.candidates.put({ project: st.project, kind: "correction", target: { id: current.id, revision }, proposedText: args.text, reason: args.reason,
2569
+ ...(current.kind === "decision" || current.kind === "contract" || current.kind === "fail" ? { noteKind: current.kind } : {}) }, "public");
2570
+ return `candidate:${id}; a person reviews it with amem review; memory was not edited`;
2571
+ }
2572
+
2573
+ async remember(by: PeerId, input: { text?: string; title?: string; kind?: string; task?: unknown; horizon?: unknown; scope?: unknown; approach?: unknown; symptom?: unknown; cause?: unknown; evidence?: unknown; retry_when?: unknown }): Promise<string> {
2574
+ const text = composeRememberText(input);
2280
2575
  const task = input.task !== undefined ? this.d.board.get(Number(input.task)) : undefined;
2281
2576
  if (task && this.isPii(task)) throw new Error("notes about a PII task are not saved: claude-mem processes what it stores with a cloud model");
2282
2577
  // Shared with every other peer as well, so text that matches a PII pattern is refused like a note about a PII task.
@@ -2296,7 +2591,8 @@ export class Tasks {
2296
2591
  if (!res && !native) return this.d.memory ? "memory worker unavailable; nothing saved" : "memory store unavailable; nothing saved";
2297
2592
  const line = noteLine(by, kind, title ? `${title}: ${text}` : text);
2298
2593
  this.d.notify(line);
2299
- this.d.share?.(by, line);
2594
+ // Display identity only: a queued recall may already contain this saved entry. Stored/captured text stays unchanged.
2595
+ this.d.share?.(by, native ? noteLine(by, kind, `mem:${native.id} ${title ? `${title}: ` : ""}${text}`) : line);
2300
2596
  return `saved to shared memory${native ? ` (hub memory ${native.id.slice(0, 8)}, ${native.tier}-term${this.d.memory && !res ? "; claude-mem unavailable" : ""})` : ""}; the other agents get it with their next message`;
2301
2597
  }
2302
2598
 
@@ -2309,6 +2605,8 @@ export class Tasks {
2309
2605
  */
2310
2606
  private goalFor(by: PeerId, ref: unknown): GoalView {
2311
2607
  if (!this.d.store) throw new PreEffectToolRefusal("goal: hub memory is off for this project");
2608
+ try { this.d.memoryCheck?.(); }
2609
+ catch { throw new PreEffectToolRefusal("goal: memory profile unavailable; inspect the selector and generation state before linking a long-term task"); }
2312
2610
  const g = typeof ref === "string" && ref.trim() ? this.d.store.store.goalOf(ref.trim().replace(/^goal:/, "")) : undefined;
2313
2611
  if (!g) throw new PreEffectToolRefusal(`goal: no long-term task ${JSON.stringify(ref).slice(0, 40)} (ahub goal list shows them)`);
2314
2612
  if (by !== USER && g.project !== this.d.store.project) throw new PreEffectToolRefusal("goal: that long-term task belongs to another project; linking across projects is a person's");
@@ -2322,7 +2620,7 @@ export class Tasks {
2322
2620
  /** Record a long-term link, never for PII; failures remain notices. */
2323
2621
  private linkNative(task: Task, goal: GoalView, by: PeerId): string {
2324
2622
  if (this.isPii(task)) { this.releaseNotice(`task #${task.id} is not linked to a long-term task: nothing about a PII task enters hub memory`); return "not linked (PII task)"; }
2325
- try { this.markMemoryReference(task, by); this.d.store!.store.goalLink(goal.id, this.memoryKey(task), by); return `part of long-term task ${goal.id.slice(0, 8)}`; }
2623
+ try { this.d.memoryCheck?.(); this.markMemoryReference(task, by); this.d.store!.store.goalLink(goal.id, this.memoryKey(task), by); return `part of long-term task ${goal.id.slice(0, 8)}`; }
2326
2624
  catch (e) { this.releaseNotice(`task #${task.id}: the link to long-term task ${goal.id.slice(0, 8)} was not saved (${(e as Error).message})`); return "not linked"; }
2327
2625
  }
2328
2626
  /** A person links a board task of this project to a long-term task (`ahub goal link`). */
@@ -2342,6 +2640,7 @@ export class Tasks {
2342
2640
  const st = this.d.store;
2343
2641
  if (!st || (task && this.isPii(task))) return undefined;
2344
2642
  try { // never throws: callers run after a board write
2643
+ this.d.memoryCheck?.();
2345
2644
  // Approved, or settled in the store already: the snapshot the caller holds can predate an approval made while it awaited.
2346
2645
  const approved = !!task && (task.state === "approved" || st.store.settled(this.memoryKey(task)));
2347
2646
  const tier: Tier = horizon ?? (approved && PROMOTED_KINDS.includes(kind) ? "long" : "short");
@@ -2369,7 +2668,7 @@ export class Tasks {
2369
2668
  let n = 0;
2370
2669
  for (const t of this.d.board.list("approved")) {
2371
2670
  if (this.isPii(t)) continue;
2372
- try { const key = this.memoryKey(t); if (this.d.store.store.hasTask(key) && !this.d.store.store.settled(key)) { this.settleMemory(t); n++; } }
2671
+ try { this.d.memoryCheck?.(); const key = this.memoryKey(t); if (this.d.store.store.hasTask(key) && !this.d.store.store.settled(key)) { this.settleMemory(t); n++; } }
2373
2672
  catch { /* fail-open: tried again at the next start */ }
2374
2673
  }
2375
2674
  return n;
@@ -2378,7 +2677,7 @@ export class Tasks {
2378
2677
  private settleMemory(task: Task): void {
2379
2678
  if (!this.d.store || this.isPii(task)) return;
2380
2679
  // The store screens each note as it moves to the synced log (the promotion screen the daemon gives it).
2381
- try { this.d.store.store.settle(this.memoryKey(task), task.reviewer ?? task.owner ?? HUB); }
2680
+ try { this.d.memoryCheck?.(); this.d.store.store.settle(this.memoryKey(task), task.reviewer ?? task.owner ?? HUB); }
2382
2681
  catch (e) { this.releaseNotice(`task #${task.id}: hub memory could not settle its notes (${(e as Error).message})`); }
2383
2682
  }
2384
2683
 
@@ -2410,9 +2709,9 @@ export class Tasks {
2410
2709
  }
2411
2710
  }
2412
2711
 
2413
- private tell(task: Task, body: string, pii: boolean): void {
2712
+ private tell(task: Task, body: string, pii: boolean, audience: "owner" | "withdrawal" = "owner"): void {
2414
2713
  if (!task.owner || task.owner === USER) return;
2415
- this.d.bus.publish(newEnvelope(HUB, body, { to: [task.owner], kind: "task", priority: "important", refs: { task: String(task.id) }, ...(pii ? { private: true } : {}) }));
2714
+ this.d.bus.publish(newEnvelope(HUB, body, { to: [task.owner], kind: "task", priority: "important", refs: { task: String(task.id), ...(audience === "owner" ? { taskRecipient: "owner" as const } : {}) }, ...(pii ? { private: true } : {}) }));
2416
2715
  }
2417
2716
  }
2418
2717