@staix/agent-hub 0.15.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +12 -8
  3. package/docs/agent-notes/README.md +4 -0
  4. package/docs/agent-notes/adapters.md +24 -8
  5. package/docs/agent-notes/budget.md +4 -2
  6. package/docs/agent-notes/daemon.md +11 -4
  7. package/docs/agent-notes/local-worker.md +2 -2
  8. package/docs/agent-notes/tasks.md +15 -3
  9. package/docs/agent-notes/tests.md +8 -3
  10. package/docs/dogfood.md +18 -6
  11. package/docs/events.md +1 -1
  12. package/docs/memory.md +78 -10
  13. package/docs/operations.md +160 -47
  14. package/docs/quickstart.md +13 -5
  15. package/docs/roles.md +1 -0
  16. package/docs/sdlc.md +11 -11
  17. package/docs/security.md +94 -22
  18. package/docs/smoke.md +58 -6
  19. package/docs/specs/2026-09-19-agent-hub-design.md +124 -12
  20. package/docs/specs/2026-09-20-upgrade-recovery-design.md +3 -1
  21. package/docs/verification/2026-10-24-always-ask-adapters.md +52 -0
  22. package/docs/verification/2026-10-24-amem-runtime.md +69 -0
  23. package/docs/verification/2026-10-24-codex-0164-reviewer-isolation.md +55 -0
  24. package/docs/verification/2026-10-24-delivery-holds-ac8.md +264 -0
  25. package/docs/verification/2026-10-24-hardcoded-values.md +2108 -0
  26. package/docs/verification/2026-10-25-0.16.0.md +82 -0
  27. package/docs/verification/2026-10-25-0.17.0.md +74 -0
  28. package/docs/verification/2026-10-25-codex-0164-reviewer-tool-call.md +52 -0
  29. package/docs/verification/2026-10-25-kimi-231-reviewer-compat.md +48 -0
  30. package/docs/verification/2026-10-25-kimi-usage-ac11.md +48 -0
  31. package/docs/verification/2026-10-25-qwen-dual-output-hub-e2e.md +23 -0
  32. package/docs/verification/2026-10-25-qwen-dual-output-t0.md +20 -0
  33. package/docs/verification/2026-10-25-reviewer-no-verdict-diagnostics.md +35 -0
  34. package/docs/verified.json +30 -18
  35. package/package.json +2 -2
  36. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  37. package/plugins/agent-hub/server.js +3421 -2176
  38. package/src/adapters/acp.ts +5 -2
  39. package/src/adapters/codex-appserver.ts +69 -1
  40. package/src/adapters/codex-review-isolation.ts +10 -1
  41. package/src/adapters/compat.ts +9 -4
  42. package/src/adapters/conformance/enrollment.ts +18 -4
  43. package/src/adapters/conformance/factories.ts +4 -1
  44. package/src/adapters/conformance/kit.ts +11 -0
  45. package/src/adapters/definitions/builtin-metadata.ts +3 -3
  46. package/src/adapters/definitions/native-cli-entry-qwen.ts +32 -4
  47. package/src/adapters/definitions/native-cli-preview-qwen.ts +9 -1
  48. package/src/adapters/definitions/native-factories.ts +8 -0
  49. package/src/adapters/definitions/native-recovery.ts +8 -0
  50. package/src/adapters/definitions/qwen.ts +7 -4
  51. package/src/adapters/local-worker.ts +5 -4
  52. package/src/adapters/qwen-dual.ts +578 -0
  53. package/src/adapters/registry-types.ts +6 -2
  54. package/src/adapters/registry.ts +9 -1
  55. package/src/adapters/reviewer-mcp.ts +12 -6
  56. package/src/adapters/reviewer-print.ts +100 -17
  57. package/src/cli/console-state.ts +20 -2
  58. package/src/cli/console-symbols.ts +3 -2
  59. package/src/cli/console-usage.ts +5 -2
  60. package/src/cli/console.ts +5 -1
  61. package/src/cli/help.ts +7 -5
  62. package/src/cli/identity.ts +3 -1
  63. package/src/cli/main.ts +44 -10
  64. package/src/cli/memory.ts +119 -7
  65. package/src/cli/output.ts +12 -4
  66. package/src/cli/upgrade.ts +4 -3
  67. package/src/hub/action-class.ts +211 -17
  68. package/src/hub/action-enforce.ts +13 -5
  69. package/src/hub/action-policy.ts +19 -8
  70. package/src/hub/ask.ts +16 -4
  71. package/src/hub/board.ts +86 -31
  72. package/src/hub/budget-notes.ts +65 -0
  73. package/src/hub/budget.ts +42 -10
  74. package/src/hub/bus.ts +1 -1
  75. package/src/hub/code-facts.ts +56 -16
  76. package/src/hub/conductor.ts +5 -2
  77. package/src/hub/config-apply.ts +7 -0
  78. package/src/hub/control-client.ts +1 -1
  79. package/src/hub/daemon.ts +384 -71
  80. package/src/hub/envelope.ts +1 -1
  81. package/src/hub/events.ts +2 -2
  82. package/src/hub/hub-tools.ts +3 -3
  83. package/src/hub/kimi-usage.ts +12 -0
  84. package/src/hub/lifecycle.ts +10 -5
  85. package/src/hub/memory-code-head.ts +44 -0
  86. package/src/hub/memory-code-overlay.ts +119 -0
  87. package/src/hub/memory-plan-guard.ts +11 -27
  88. package/src/hub/memory-stale-anchors.ts +159 -0
  89. package/src/hub/memory-symbols.ts +74 -0
  90. package/src/hub/planning.ts +10 -4
  91. package/src/hub/read-tools.ts +21 -3
  92. package/src/hub/sdlc.ts +34 -0
  93. package/src/hub/settings.ts +17 -2
  94. package/src/hub/settled-roles.ts +15 -3
  95. package/src/hub/task-sweep.ts +25 -3
  96. package/src/hub/tasks.ts +378 -76
  97. package/src/hub/token-window.ts +15 -2
  98. package/src/hub/window-kind.ts +13 -0
  99. package/src/local/tools.ts +5 -5
  100. package/src/memory/brief.ts +66 -16
  101. package/src/memory/capture.ts +26 -40
  102. package/src/memory/client.ts +137 -65
  103. package/src/memory/recall.ts +13 -22
  104. package/src/ui/index.html +100 -18
package/src/hub/tasks.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { canonicalMemorySymbolNode, normalizeMemorySymbolInput, MEMORY_SYMBOL_LIMIT, type MemorySymbolNodes } from "./memory-symbols.ts";
1
2
  import { buildTaskCodeSymbols, publicTaskCodeSymbols, taskCodeSymbolsDisjoint, HOST_CODE_SYMBOLS } from "./task-code-symbols.ts";
2
3
  import { normalizePlanSymbol, symbolsOverlap } from "./symbol-normalizer.ts";
3
4
  import { publicTestIntegrity, readTestIntegrity, testIntegrityBlockers, type TestIntegrity } from "./test-integrity.ts";
@@ -28,7 +29,7 @@ import { isAbsolute, relative } from "node:path";
28
29
  import type { Briefs, NativeItem } from "../memory/brief.ts";
29
30
  import type { MemoryClient } from "../memory/client.ts";
30
31
  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";
31
- import { CLASSES, OUTCOMES_KEPT_MS, ALL_PLAN_KEYS, TASK_MOVE_REASONS, taskCanPlan, taskHasWorkEvidence, taskHasStarted, planningUndoEntry, isClosedTaskState, type TaskStall, type HistoryEntry, type ReviewerHistory, type Board, type Task, type TaskClass, type TaskMoveReason, type TaskPlan, type TaskRefs } from "./board.ts";
32
+ import { CLASSES, OUTCOMES_KEPT_MS, ALL_PLAN_KEYS, TASK_MOVE_REASONS, taskCanPlan, taskHasWorkEvidence, taskHasStarted, planningUndoEntry, isClosedTaskState, type TaskStall, type HistoryEntry, type ReviewerHistory, type Board, type Task, type TaskClass, type TaskMoveReason, type TaskPlan, type TaskRefs, type PlannerBinding } from "./board.ts";
32
33
  import type { Bus } from "./bus.ts";
33
34
  import { HUB, newEnvelope, NOTE_KINDS, noteLine, sanitize, USER, type Envelope, type PeerId, type PeerState } from "./envelope.ts";
34
35
  import type { PiiVerdict, ScreenItem, ScreenRecord } from "./inference.ts";
@@ -43,9 +44,9 @@ import { coverageGap, renderReviewBrief, type ReviewBrief } from "./review-brief
43
44
  import { definitionOf as builtinDefinitionOf } from "../adapters/registry.ts";
44
45
  import { ADAPTERS, DEFAULT_REVIEW_POOL, definitionOf, familyOf, type ProjectAdapterCatalog } from "../adapters/registry.ts";
45
46
  import { plannerGrooming, nextSweep, taskSweepConfig, type TaskSweepConfig, type SweepRecord } from "./task-sweep.ts";
46
- import { linkedReceipt, openPrFor, readPrCostHistory, readReviewReadiness, reviewReadinessFromEvidence, type PrEvidence, type ReviewReadiness, readPlanIssueMetadata, canonicalStage, checklistOf, readSdlcPr, readSpecEdit, sdlcConfig, sdlcReceipt, sdlcStatus, specState, SPEC_STATE_TEXT, stageDefinition, lifecycleStage, type SdlcConfig, type PlanIssueMetadata, sdlcGh, type SdlcGh, type SdlcStatus, type SpecEdit, type SpecState } from "./sdlc.ts";
47
+ import { planningIssueReceipt, linkedReceipt, openPrFor, readPrCostHistory, readReviewReadiness, reviewReadinessFromEvidence, type PrEvidence, type ReviewReadiness, readPlanIssueMetadata, canonicalStage, checklistOf, readSdlcPr, readSpecEdit, sdlcConfig, sdlcReceipt, sdlcStatus, specState, SPEC_STATE_TEXT, stageDefinition, lifecycleStage, type SdlcConfig, type PlanIssueMetadata, sdlcGh, type SdlcGh, type SdlcStatus, type SpecEdit, type SpecState } from "./sdlc.ts";
47
48
 
48
- export type TaskAssignmentRole = "owner" | "reviewer";
49
+ export type TaskAssignmentRole = "owner" | "reviewer" | "planner";
49
50
 
50
51
  export interface ReleaseWindow { at: number; host: boolean; quota: boolean; idle: boolean; reasons: string[]; current?: () => boolean; close?: () => Promise<void>; }
51
52
 
@@ -56,6 +57,8 @@ export interface TasksDeps {
56
57
  /** Effective per-peer planner capacity, taken from the existing settings loader. */
57
58
  wipLimit?: (peer: PeerId) => number;
58
59
  recordPlanning?: (change: { by: string; task?: number; release?: number; fields: string[]; revision: number }) => void;
60
+ /** The clock of the planner board-changed notice window (#758); tests inject one. Absent: `Date.now`. */
61
+ now?: () => number;
59
62
  board: Board;
60
63
  bus: Bus;
61
64
  routing: () => Routing;
@@ -87,6 +90,8 @@ export interface TasksDeps {
87
90
  /** #520: the review brief for a task's commit (git only, bounded); undefined when the task names no commit. */
88
91
  prepareReviewBrief?: (task: Task) => Promise<void>;
89
92
  reviewBrief?: (task: Task) => ReviewBrief | { unavailable: string } | undefined;
93
+ /** Host-verified existing symbols at this task's admitted completion head; never plan guesses. */
94
+ memorySymbolNodes?: (task: Task) => MemorySymbolNodes | undefined;
90
95
  sdlc?: { config: () => SdlcConfig; gh?: SdlcGh };
91
96
  briefs?: Briefs;
92
97
  /** Screened live fail notes directly intersecting the task plan or refs; read-only advice. */
@@ -194,6 +199,7 @@ const OPEN: Task["state"][] = ["proposed", "in_progress", "changes_requested"];
194
199
  const QUIET_EVENTS = new Set(["answer", "reviewer changed", "idle sweep", "memory reference started"]);
195
200
  QUIET_EVENTS.add("ready held"); // #655: the hub's note that a ready task waits for its reserved owner's WIP slot.
196
201
  QUIET_EVENTS.add("plan observed"); // Host observation does not move the task or replace completion intent.
202
+ QUIET_EVENTS.add("planner changed"); // Planning authority does not move the execution owner or revoke its handoff.
197
203
  /** The records of the `done (checking)` entry a passed check now completes: what the owner delivered then (#509a). */
198
204
  const latestPendingDeliverables = (task: Task): DeliverableRecord[] | undefined => {
199
205
  const checking = task.history.findLastIndex(entry => entry.event === "done (checking)");
@@ -294,6 +300,10 @@ const planText = (plan: TaskPlan = {}) => ALL_PLAN_KEYS.filter((k) => plan[k]?.l
294
300
  const screenText = (t: { title: string; detail: string; plan?: TaskPlan; external?: Task["external"] }) => [t.title, t.detail, planText(t.plan), t.external?.repo, t.external?.url].filter(value => value !== undefined).join("\n");
295
301
  /** Re-screens of one task whose verdict was unknown, per hub run (#198). */
296
302
  const RESCREEN_MAX = 10;
303
+ /** #758: the planner "board changed" notice. Its body starts with this, so an unsettled copy is found (and replaced) by the prefix. */
304
+ const GROOM_NOTICE_PREFIX = "The board changed";
305
+ /** #758: a board change that needs no plan review is recorded at most once per planner in this window. */
306
+ const GROOM_FYI_WINDOW_MS = 10 * 60_000;
297
307
 
298
308
  /**
299
309
  * The task flow. Adapters and tools never touch the board: every change comes through here, where assignment,
@@ -302,12 +312,35 @@ const RESCREEN_MAX = 10;
302
312
  /** #694: a PR staged at done with the commit or branch it was read for. */
303
313
  interface IdentityLink { repo: string; pr: number; basis: { commit?: string; branch?: string } }
304
314
 
315
+ /** What one live check of a task's files says as a whole (#317): `unchanged` only when EVERY file measured unchanged. A change outranks a missing file, which outranks one the hub could not read. */
316
+ export type DeliverableResult = "changed" | "missing" | "unknown" | "unchanged";
317
+ export const deliverableResult = (states: readonly DeliverableStatus["state"][]): DeliverableResult =>
318
+ states.includes("changed") ? "changed" : states.includes("missing") ? "missing" : states.includes("unknown") || !states.length ? "unknown" : "unchanged";
319
+
320
+ /** One row of `ahub deliverables` and one card's badge (#317): ids, hashes and sizes always; a path only where `publicDeliverables` allows it. */
321
+ export interface DeliverableRow {
322
+ task: number; title: string; taskState: string; state: "delivered" | "approved";
323
+ /** True only when the files were measured for this row. Without it `changedSinceReview` is absent: the board alone cannot know. */
324
+ checked: boolean; changedSinceReview?: boolean;
325
+ /** Present only when `checked`: the whole-row reading of the measurement (never `unchanged` while any file is missing or unreadable). */
326
+ result?: DeliverableResult;
327
+ files: { id: string; path?: string; format: string; version?: string; sha256: string; bytes: number; private?: true; state?: DeliverableStatus["state"] }[];
328
+ }
329
+
305
330
  export class Tasks {
331
+ private readonly planReviewRequests = new Map<number, string>();
306
332
  private readonly repositories: TaskRepositories;
307
333
  private readonly incomingBacklog = new Set<number>();
308
334
  private readonly plannerBacklog = new Map<PeerId, Set<number>>();
309
335
  private readonly plannerBlockedQueue = new Map<PeerId, string>();
310
336
  private readonly knownPlanners = new Set<PeerId>();
337
+ /**
338
+ * #758: what the planner board-changed notice has read of the board. `revision` is the board status revision last scanned, `lengths` each task's
339
+ * history length at that scan (so only entries added since are read), `pending` what each planner has not yet been told (`review`: a planning
340
+ * revision or dependency change of a card it plans; `other`: a state move, review or assignment) and `sent` when it was last told.
341
+ * ponytail: a scan lists the whole board once per status revision (at most one per flush); the upgrade is an indexed history read.
342
+ */
343
+ private readonly groomScan = { revision: -1, lengths: new Map<number, number>(), deps: new Map<number, string>(), pending: new Map<PeerId, { review: boolean; other: boolean }>(), sent: new Map<PeerId, { fyiAt: number }>() };
311
344
  /** Turn-free cohorts (issue #107): who works without messages, and who integrates. */
312
345
  readonly cohorts: Cohorts;
313
346
  private readonly coordinationModes = new Map<number, boolean>();
@@ -318,6 +351,7 @@ export class Tasks {
318
351
  constructor(private readonly d: TasksDeps) {
319
352
  d.board.trackPlanRevisions = () => (d.sdlc?.config().mode ?? "off") !== "off";
320
353
  this.repositories = new TaskRepositories(d.cwd, d.repository);
354
+ d.board.proposalPlanner = task => this.proposalPlanner(task);
321
355
  for (const task of d.board.list()) this.warmRepository(task);
322
356
  if (d.coordination) {
323
357
  const mode = d.coordination();
@@ -424,7 +458,7 @@ export class Tasks {
424
458
  if (finding.kind === "ready-unassigned") {
425
459
  this.d.board.update(task.id, HUB, "idle sweep", {}, undefined, { sweep: finding });
426
460
  recorded.push({ task: task.id, finding });
427
- const planners = Object.entries(this.d.roles ?? {}).filter(([peer, roles]) => roles.includes("planner") && this.d.bus.isRostered(peer)).map(([peer]) => peer);
461
+ const planners = this.taskPlanner(task).filter(peer => this.d.bus.isRostered(peer));
428
462
  const text = `Task #${task.id}: ready-unassigned, idle escalation ${finding.step}/3.`;
429
463
  if (finding.step === 1 || finding.step === 2) {
430
464
  if (finding.step === 2 || !planners.length) this.releaseNotice(text);
@@ -452,8 +486,8 @@ export class Tasks {
452
486
  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");
453
487
  } else if (finding.step === 2) {
454
488
  this.d.notify(message);
455
- for (const [peer, roles] of Object.entries(this.d.roles ?? {})) {
456
- 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");
489
+ for (const peer of this.taskPlanner(task)) {
490
+ if (peer !== responsible && 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");
457
491
  }
458
492
  } else {
459
493
  const reviewer = finding.kind === "review-pending";
@@ -619,7 +653,10 @@ export class Tasks {
619
653
  if (this.isPii(row)) return false;
620
654
  const last = row.history.findLast(entry => entry.reviewFindings?.items.some(value => value.id === item.id));
621
655
  const finding = last?.reviewFindings?.items.find(value => value.id === item.id);
622
- return !last?.withheld && finding && same(finding) && finding.disposition !== "false_positive" && this.findingTextAllowed(row, [finding]);
656
+ // #328: a follow-up that only records an earlier task's finding as fixed never had it; it counts once seen open.
657
+ // By id alone: a reclassified finding keeps its id (task note); a withheld review is never evidence.
658
+ const occurred = row.history.some(entry => !entry.withheld && entry.reviewFindings?.items.some(value => value.id === item.id && (value.disposition === "open" || value.disposition === "accepted")));
659
+ return !last?.withheld && finding && same(finding) && occurred && finding.disposition !== "false_positive" && this.findingTextAllowed(row, [finding]);
623
660
  });
624
661
  if (this.workUnits(holders) < 2) continue;
625
662
  const key = `Review finding key: ${item.pass}:${item.id}`;
@@ -653,6 +690,10 @@ export class Tasks {
653
690
  ...(h.supersededBy === undefined ? {} : { supersededBy: screenHistoryText(h.supersededBy, "supersession", h.withheld) }),
654
691
  ...(h.profile === undefined ? {} : { profile: screenHistoryText(h.profile, "profile") }),
655
692
  ...(h.owner === undefined ? {} : { owner: h.owner }),
693
+ ...(h.planner === undefined ? {} : { planner: h.planner }),
694
+ ...(h.plannerReason ? { plannerReason: h.plannerReason } : {}),
695
+ ...(h.planAuthor === undefined ? {} : { planAuthor: h.planAuthor }),
696
+ ...(h.selfReview ? { selfReview: true } : {}),
656
697
  ...(h.reviewerFamily ? { reviewerFamily: screenHistoryText(h.reviewerFamily, "reviewer family") } : {}),
657
698
  ...(h.reviewerReason ? { reviewerReason: h.reviewerReason } : {}),
658
699
  ...(h.unmetCriteria?.length ? { unmetCriteria: h.unmetCriteria } : {}),
@@ -783,7 +824,7 @@ export class Tasks {
783
824
  }
784
825
 
785
826
  /** Capability admission precedes explicit assignments/reservations, independent of readiness or transient peer health. */
786
- private requireCapabilities(task: Parameters<typeof assign>[0], peer: PeerId, role: TaskAssignmentRole = "owner", preEffect = true): void {
827
+ private requireCapabilities(task: Parameters<typeof assign>[0], peer: PeerId, role: Exclude<TaskAssignmentRole, "planner"> = "owner", preEffect = true): void {
787
828
  const instances = this.d.instances?.(), adapter = (this.d.adapterCatalog?.definitionOf ?? definitionOf)(peer, instances);
788
829
  const declaredExtra = !!adapter && adapter.kind !== peer;
789
830
  if ((!this.d.capabilities || this.isPii(task)) && !declaredExtra) return;
@@ -797,7 +838,7 @@ export class Tasks {
797
838
  /**
798
839
  * #525-3: the settled planner holders, sorted: the one input of the planner walk. It comes from the same settled role map as planner
799
840
  * authority (`d.roles`), restricted to rostered peers, so assignment, planner notices and `route explain` agree on who the holders are.
800
- * Read-only for now: the walk's result is shown and recorded nowhere until 525-2.
841
+ * Selection stays in assign(); task bindings only keep that selection while the holder remains eligible.
801
842
  */
802
843
  private plannerHolders(): PeerId[] {
803
844
  return Object.entries(this.d.roles ?? {}).filter(([peer, roles]) => Array.isArray(roles) && roles.includes("planner") && this.d.bus.isRostered(peer)).map(([peer]) => peer).sort();
@@ -806,7 +847,7 @@ export class Tasks {
806
847
  private weights(cls: TaskClass, task?: Task) {
807
848
  const now = Date.now();
808
849
  const quota = this.d.quota?.();
809
- return { ...this.health(), planners: this.plannerHolders(), ...(task ? { reviewPool: this.reviewPoolOptions(task) } : {}), now, demoted: this.demoted(cls, now), reviews: this.reviewRecord(cls), ...(quota ? { quota } : {}), ...(this.d.review?.adaptive ? { adaptive: { min: this.d.review.min_reviews } } : {}) };
850
+ return { ...this.health(), planners: this.plannerHolders(), ...(task ? { reviewPool: this.reviewPoolOptions(task), ...(task.planner ? { keptPlanner: task.planner } : {}) } : {}), now, demoted: this.demoted(cls, now), reviews: this.reviewRecord(cls), ...(quota ? { quota } : {}), ...(this.d.review?.adaptive ? { adaptive: { min: this.d.review.min_reviews } } : {}) };
810
851
  }
811
852
 
812
853
  /**
@@ -901,13 +942,62 @@ export class Tasks {
901
942
  try { this.d.recordPlanning?.(change); } catch { /* telemetry never changes a durable result */ }
902
943
  }
903
944
 
904
- private planningAuthority(by: PeerId): void {
905
- if (by !== USER && !this.d.roles?.[by]?.includes("planner")) throw new PreEffectToolRefusal("planning requires a person or configured planner role");
945
+ /** A removed/off-roster binding falls back to legacy holders; a busy, paused or offline holder keeps authority. */
946
+ taskPlanner(task: Pick<Task, "planner">): PeerId[] {
947
+ const holders = Object.keys(this.d.roles ?? {}).filter(peer => this.d.roles?.[peer]?.includes("planner")).sort();
948
+ return task.planner && holders.includes(task.planner) && this.d.bus.isRostered(task.planner) ? [task.planner] : holders;
949
+ }
950
+
951
+ private planningAuthority(by: PeerId, task?: Pick<Task, "planner">): void {
952
+ if (by !== USER && (!this.d.roles?.[by]?.includes("planner") || task !== undefined && !this.taskPlanner(task).includes(by))) throw new PreEffectToolRefusal(`planning requires a person or configured planner role${task ? "; only the task's effective planner may act" : ""}`);
906
953
  }
907
954
 
908
- private plannerWriteAuthority(by: PeerId): void {
955
+ private plannerWriteAuthority(by: PeerId, task?: Pick<Task, "planner">): void {
909
956
  if (by !== USER && this.d.privateTurn?.(by)) throw new PreEffectToolRefusal("planner writes are unavailable during a private turn");
910
- this.planningAuthority(by);
957
+ this.planningAuthority(by, task);
958
+ }
959
+
960
+ /** Every new host proposal, including split children, consumes the same pure walk before its first notification. */
961
+ private proposalPlanner(task: Task): PlannerBinding {
962
+ const choice = assign({ ...task, state: "proposed" }, this.states(), this.d.routing(), { ...this.weights(task.class, task), candidates: [] });
963
+ return { planner: this.isPii(task) ? null : choice.planner ?? null, plannerReason: this.isPii(task) ? "none" : choice.plannerReason ?? "none" };
964
+ }
965
+
966
+ private plannerBinding(task: Task, assignment: Assignment, bindNull = false): PlannerBinding | undefined {
967
+ if (bindNull && task.planner == null) return { planner: this.isPii(task) ? null : assignment.planner ?? null, plannerReason: assignment.plannerReason ?? "none" };
968
+ return task.planner && this.plannerHolders().includes(task.planner) ? { planner: task.planner, plannerReason: "kept" } : undefined;
969
+ }
970
+
971
+ private planAuthor(task: Task): PeerId | null {
972
+ // Review scope bookkeeping preserves the existing plan-content author.
973
+ const authored = task.history.findLast(entry => entry.event !== "plan reviewed" && entry.planRevision !== undefined)
974
+ ?? task.history.findLast(entry => entry.event !== "plan reviewed" && (entry.planning?.fields as readonly string[] | undefined)?.includes("plan"));
975
+ if (authored && authored !== task.history[0]) return authored.by;
976
+ return ALL_PLAN_KEYS.some(key => !!task.plan?.[key]?.length) ? authored?.by ?? task.history[0]?.by ?? null : null;
977
+ }
978
+
979
+ private planReviewAuthority(by: PeerId, task: Task): { planAuthor: PeerId | null; selfReview?: true } {
980
+ this.plannerWriteAuthority(by); // Independent settled holders have review authority only, not the task's other planner powers.
981
+ const author = this.planAuthor(task);
982
+ const independent = this.plannerHolders().filter(peer => peer !== author && this.d.bus.stateOf(peer) !== "offline");
983
+ if (by !== USER && by === author && independent.length) throw new PreEffectToolRefusal("an independent planner reviews this plan");
984
+ return { planAuthor: author, ...(by === author ? { selfReview: true as const } : {}) };
985
+ }
986
+
987
+ private requestIndependentPlanReview(task: Task): void {
988
+ const author = this.planAuthor(task);
989
+ if (!author || !this.taskPlanner(task).includes(author)) return;
990
+ const planners = this.plannerHolders().filter(peer => peer !== author && this.d.bus.stateOf(peer) !== "offline");
991
+ if (!planners.length) return;
992
+ const selected = assign({ ...task, state: "proposed" }, this.states(), this.d.routing(), { ...this.weights(task.class, task), candidates: [], planners, keptPlanner: undefined }).planner;
993
+ if (!selected) return;
994
+ const revision = task.planningRevision ?? 0, signature = `${revision}:${author}:${selected}`;
995
+ if (this.planReviewRequests.get(task.id) === signature) return;
996
+ const view = this.publicView(task);
997
+ const queued = this.d.bus.publish(newEnvelope(HUB, `Independent plan review requested for task #${view.id}, revision ${revision}. Record hub_plan_review for that revision.`, {
998
+ to: [selected], kind: "task", refs: { task: String(task.id), planReview: revision, planReviewFlow: task.history.length, planAuthor: author }, ...(this.isPii(task) ? { private: true } : {}),
999
+ }));
1000
+ if (queued.includes(selected)) this.planReviewRequests.set(task.id, signature);
911
1001
  }
912
1002
 
913
1003
  wipLimit(peer: PeerId): number {
@@ -966,6 +1056,7 @@ export class Tasks {
966
1056
  const validate = () => {
967
1057
  const task = this.d.board.get(input.id);
968
1058
  if (!task || (task.planningRevision ?? 0) !== input.revision || task.state !== "backlog" || task.owner || !taskCanPlan(task) || task.pinned) throw new PreEffectToolRefusal("planner pull changed, pinned or already started");
1059
+ this.planningAuthority(by, task);
969
1060
  const release = task.release == null ? undefined : this.d.board.release(task.release);
970
1061
  if (task.release != null && (!release || ["frozen", "shipped", "dropped"].includes(release.state))) throw new PreEffectToolRefusal("planner pull requires unfrozen release scope");
971
1062
  if (this.waitsFor(task).length) throw new PreEffectToolRefusal("planner pull waits for dependency approval");
@@ -986,7 +1077,7 @@ export class Tasks {
986
1077
  const refusal = task.reserved ? `reserved peer ${task.reserved} cannot take the planner pull: ${assignment.refusals?.find(row => row.role === "owner" && row.peer === task.reserved)?.reason ?? "not eligible"}; ${this.plannerRefusal(task)}` : this.plannerRefusal(task);
987
1078
  throw new PreEffectToolRefusal(`${this.hasPersonReservation(task) ? "a person's reservation is retained; " : ""}${refusal}`);
988
1079
  }
989
- return { task, owner: assignment.owner };
1080
+ return { task, owner: assignment.owner, assignment };
990
1081
  };
991
1082
  const initial = validate(), withheld = await this.withholds("note", input.reason, initial.task);
992
1083
  authorized();
@@ -999,7 +1090,7 @@ export class Tasks {
999
1090
  if (checked.task.history.length !== current.task.history.length || checked.owner !== current.owner) throw new PreEffectToolRefusal("planner pull changed during admission");
1000
1091
  } } });
1001
1092
  }
1002
- const pulled = this.d.board.pullPlanned(current.task.id, by, current.owner, input.revision, input.reason.trim(), withheld);
1093
+ const pulled = this.d.board.pullPlanned(current.task.id, by, current.owner, input.revision, input.reason.trim(), withheld, this.plannerBinding(current.task, current.assignment, true));
1003
1094
  this.planningRecorded({ by, task: pulled.id, fields: ["pull", "reserved"], revision: pulled.planningRevision! });
1004
1095
  return pulled;
1005
1096
  }
@@ -1011,15 +1102,77 @@ export class Tasks {
1011
1102
  private addPlannerBacklog(peer: PeerId, ids: readonly number[]): void {
1012
1103
  if (!this.d.roles?.[peer]?.includes("planner")) return;
1013
1104
  const pending = this.plannerBacklog.get(peer) ?? new Set<number>();
1014
- for (const id of ids) if (this.d.board.get(id)?.state === "backlog") pending.add(id);
1105
+ for (const id of ids) {
1106
+ const task = this.d.board.get(id);
1107
+ if (task?.state === "backlog" && this.taskPlanner(task).includes(peer)) pending.add(id);
1108
+ }
1015
1109
  if (pending.size) this.plannerBacklog.set(peer, pending);
1016
1110
  }
1017
- private readonly groomingNotified = new Map<string, number>();
1111
+ /**
1112
+ * The planner "board changed" notice (#758). Read only what was added to a card's history since the last scan, and tell each attached planner of
1113
+ * what it did not write itself and plans (`taskPlanner`, the #525-2 scoping): a planning revision or dependency change (in or outside a
1114
+ * `planning` entry) or the creation of a card is a task notice, replacing a copy that
1115
+ * is still unsettled so the planner meets the latest revision once; a state move, review or assignment is recorded at `fyi` weight (no turn),
1116
+ * at most once per planner per ten minutes. The hub's own bookkeeping entries tell nobody. A planner with no earlier notice gets one task notice
1117
+ * (a restart, or the first one after it was attached), as before.
1118
+ */
1119
+ private groomNotices(configured: readonly PeerId[], planners: readonly PeerId[]): void {
1120
+ const scan = this.groomScan, revision = this.d.board.statusRevision(), first = scan.revision < 0;
1121
+ if (scan.revision !== revision) {
1122
+ for (const task of this.d.board.list()) {
1123
+ const seen = scan.lengths.get(task.id), depsKey = JSON.stringify(task.deps ?? []), depsBefore = scan.deps.get(task.id);
1124
+ scan.lengths.set(task.id, task.history.length); scan.deps.set(task.id, depsKey);
1125
+ if (first || seen === task.history.length && depsBefore === depsKey) continue;
1126
+ const holders = this.taskPlanner(task);
1127
+ const tell = (author: string, review: boolean) => {
1128
+ for (const holder of configured) { // every configured planner, so a change made while one was offline is not lost
1129
+ if (holder === author || !holders.includes(holder)) continue;
1130
+ const pending = scan.pending.get(holder) ?? { review: false, other: false };
1131
+ if (review) pending.review = true; else pending.other = true;
1132
+ scan.pending.set(holder, pending);
1133
+ }
1134
+ };
1135
+ // A card's creation (a propose, with or without `after`, a split child, a generated card) is new work to consolidate, so it needs a plan review.
1136
+ if (seen === undefined) tell(task.history[0]?.by ?? HUB, true);
1137
+ for (const entry of seen === undefined ? task.history.slice(1) : task.history.slice(seen)) {
1138
+ if (entry.by === HUB && entry.planning === undefined) continue; // the hub's own bookkeeping (lifecycle links, sweeps, holds) is not a board change to tell anyone about
1139
+ tell(entry.by, entry.planning !== undefined);
1140
+ }
1141
+ // Dependencies changed outside a `planning` entry (a propose with `after`, a split, any other write): the same as a planning change.
1142
+ // Its author is the last entry's only when this scan saw a new entry; a write with no entry has no known author, so no planner is skipped.
1143
+ if (depsBefore !== undefined && depsBefore !== depsKey) tell(seen !== undefined && seen < task.history.length ? task.history.at(-1)!.by : "", true);
1144
+ }
1145
+ scan.revision = revision;
1146
+ }
1147
+ const now = this.d.now?.() ?? Date.now();
1148
+ for (const peer of planners) {
1149
+ const told = scan.sent.get(peer), pending = scan.pending.get(peer);
1150
+ const review = told === undefined || pending?.review === true;
1151
+ if (!review && !pending?.other) continue;
1152
+ if (!review && now - told!.fyiAt < GROOM_FYI_WINDOW_MS) { scan.pending.delete(peer); continue; }
1153
+ this.releaseNotice(`could not notify planner ${peer} about grooming`, () => {
1154
+ if (review) this.sendGroomNotice(peer, revision);
1155
+ else this.d.bus.publish(newEnvelope(HUB, `${GROOM_NOTICE_PREFIX} (revision ${revision}): state moves, reviews or assignments only. No plan review is needed.`, { to: [peer], kind: "task", priority: "fyi" }));
1156
+ scan.pending.delete(peer);
1157
+ scan.sent.set(peer, { fyiAt: review ? told?.fyiAt ?? 0 : now });
1158
+ });
1159
+ }
1160
+ }
1161
+
1162
+ /** The task-weight notice: an unsettled earlier copy for this planner is withdrawn and replaced, never a second one queued. */
1163
+ private sendGroomNotice(peer: PeerId, revision: number): void {
1164
+ const body = `${GROOM_NOTICE_PREFIX} (revision ${revision}). Before offering or accepting build work, read hub_planner_groom, consolidate dependencies and priorities, and record hub_plan_review against the current planning revision.`;
1165
+ for (const old of this.d.bus.pendingEnvelopes(peer)) if (old.from === HUB && old.kind === "task" && old.body.startsWith(GROOM_NOTICE_PREFIX)) this.d.bus.withdraw(old.id);
1166
+ const recipients = this.d.bus.publish(newEnvelope(HUB, body, { to: [peer], kind: "task" }));
1167
+ if (!recipients.includes(peer)) throw new Error("the notice was not queued");
1168
+ }
1169
+
1018
1170
  /** One bounded ids-only batch per attached planner; failed or evicted copies stay pending. */
1019
1171
  flushPlannerNotices(): number {
1020
1172
  const configured = Object.entries(this.d.roles ?? {}).filter(([, roles]) => roles.includes("planner")).map(([peer]) => peer);
1021
1173
  for (const peer of this.knownPlanners) if (!configured.includes(peer)) {
1022
1174
  this.knownPlanners.delete(peer); this.plannerBacklog.delete(peer); this.plannerBlockedQueue.delete(peer);
1175
+ this.groomScan.pending.delete(peer); this.groomScan.sent.delete(peer);
1023
1176
  }
1024
1177
  for (const peer of configured) if (!this.knownPlanners.has(peer)) {
1025
1178
  this.knownPlanners.add(peer);
@@ -1028,18 +1181,7 @@ export class Tasks {
1028
1181
  }
1029
1182
  const planners = configured.filter(peer => this.d.bus.isRostered(peer) && this.d.bus.peers.has(peer) && this.d.bus.stateOf(peer) !== "offline");
1030
1183
  if (!planners.length) return 0;
1031
- if ((this.d.sdlc?.config().mode ?? "off") !== "off") {
1032
- const revision = this.d.board.statusRevision();
1033
- const body = "The board changed. Before offering or accepting build work, read hub_planner_groom, consolidate dependencies and priorities, and record hub_plan_review against the current planning revision.";
1034
- for (const peer of planners) if (this.groomingNotified.get(peer) !== revision) {
1035
- const queued = this.d.bus.pendingEnvelopes(peer).some(env => env.from === HUB && env.kind === "task" && env.body === body);
1036
- if (queued) continue;
1037
- this.releaseNotice(`could not notify planner ${peer} about grooming`, () => {
1038
- const recipients = this.d.bus.publish(newEnvelope(HUB, body, { to: [peer], kind: "task" }));
1039
- if (recipients.includes(peer)) this.groomingNotified.set(peer, revision);
1040
- });
1041
- }
1042
- }
1184
+ if ((this.d.sdlc?.config().mode ?? "off") !== "off") this.groomNotices(configured, planners);
1043
1185
  for (const id of this.incomingBacklog) if (this.d.board.get(id)?.state !== "backlog") this.incomingBacklog.delete(id);
1044
1186
  const incoming = [...this.incomingBacklog].sort((a, b) => a - b).slice(0, 64);
1045
1187
  if (incoming.length) {
@@ -1053,10 +1195,17 @@ export class Tasks {
1053
1195
  for (const peer of planners) {
1054
1196
  const pending = this.plannerBacklog.get(peer);
1055
1197
  if (!pending) continue;
1198
+ for (const id of pending) {
1199
+ const task = this.d.board.get(id);
1200
+ if (task?.state !== "backlog") pending.delete(id);
1201
+ else if (!this.taskPlanner(task).includes(peer)) {
1202
+ pending.delete(id);
1203
+ for (const recipient of this.taskPlanner(task)) this.addPlannerBacklog(recipient, [id]);
1204
+ }
1205
+ }
1056
1206
  const blockedQueue = this.plannerBlockedQueue.get(peer);
1057
1207
  if (blockedQueue !== undefined && blockedQueue === JSON.stringify(this.d.bus.queueIds(peer))) continue;
1058
1208
  this.plannerBlockedQueue.delete(peer);
1059
- for (const id of pending) if (this.d.board.get(id)?.state !== "backlog") pending.delete(id);
1060
1209
  const ids = [...pending].sort((a, b) => a - b).slice(0, 64);
1061
1210
  if (!ids.length) continue;
1062
1211
  // Remove before publish: an immediate overflow callback may put this recipient's ids back.
@@ -1113,7 +1262,7 @@ export class Tasks {
1113
1262
  });
1114
1263
  }
1115
1264
 
1116
- async planBacklog(by: PeerId, input: { id: number; revision: number; fields: unknown; reason: string }): Promise<Task> {
1265
+ async planBacklog(by: PeerId, input: { id: number; revision: number; fields?: unknown; reason: string }): Promise<Task> {
1117
1266
  this.planningAuthority(by);
1118
1267
  if (!Number.isSafeInteger(input.id) || input.id <= 0 || !Number.isSafeInteger(input.revision) || input.revision < 0) throw new PreEffectToolRefusal("invalid planning identity or revision");
1119
1268
  if (typeof input.reason !== "string" || !input.reason.trim() || input.reason.length > 1000) throw new PreEffectToolRefusal("planning needs a bounded reason");
@@ -1122,6 +1271,12 @@ export class Tasks {
1122
1271
  const validate = () => {
1123
1272
  const task = this.d.board.get(input.id);
1124
1273
  if (!task || (task.planningRevision ?? 0) !== input.revision) throw new PreEffectToolRefusal("planning revision changed");
1274
+ this.planningAuthority(by, task);
1275
+ if (patch.sdlc !== undefined) {
1276
+ if (task.state !== "backlog" || task.owner || taskHasStarted(task)) throw new PreEffectToolRefusal("issue identity changes require unstarted backlog work");
1277
+ if (by !== USER && this.isPii(task)) throw new PreEffectToolRefusal("private issue identity changes require the person");
1278
+ if (!this.isPii(task) && !this.publicMetadataText(JSON.stringify(patch.sdlc))) throw new PreEffectToolRefusal("planning identity metadata is private or sensitive");
1279
+ }
1125
1280
  if (isClosedTaskState(task.state)) throw new PreEffectToolRefusal("closed work is final");
1126
1281
  if (by !== USER && (task.state !== "backlog" || !taskCanPlan(task) || task.pinned || Object.hasOwn(patch, "pinned"))) throw new PreEffectToolRefusal("planner changes only unpinned backlog work");
1127
1282
  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");
@@ -1154,6 +1309,7 @@ export class Tasks {
1154
1309
  for (const id of new Set([task.release, target])) {
1155
1310
  if (id == null) continue;
1156
1311
  const release = this.d.board.release(id)!;
1312
+ if (Object.hasOwn(patch, "release")) this.planningAuthority(by, release);
1157
1313
  if (["shipped", "dropped"].includes(release.state)) throw new PreEffectToolRefusal("settled release scope is final");
1158
1314
  if (by !== USER && release.state === "frozen") throw new PreEffectToolRefusal("frozen release changes require a person");
1159
1315
  }
@@ -1161,8 +1317,11 @@ export class Tasks {
1161
1317
  };
1162
1318
  const task = validate(), withheld = await this.withholds("note", input.reason, task);
1163
1319
  this.planningAuthority(by); // #455: or moved the planner
1164
- validate(); // a person may have changed planning or frozen a release while the screen ran
1165
- const updated = this.d.board.plan(task.id, by, patch, input.revision, input.reason.trim(), withheld);
1320
+ const latest = validate(); // a person may have changed planning or frozen a release while the screen ran
1321
+ let receipt: Task["sdlc"];
1322
+ try { receipt = patch.sdlc === undefined ? undefined : planningIssueReceipt(patch.sdlc, latest.sdlc, this.d.sdlc?.config() ?? sdlcConfig(undefined)); }
1323
+ catch (error) { throw new PreEffectToolRefusal((error as Error).message); }
1324
+ const updated = this.d.board.plan(task.id, by, patch, input.revision, input.reason.trim(), withheld, receipt);
1166
1325
  this.planningRecorded({ by, task: task.id, fields: Object.keys(patch), revision: updated.planningRevision! });
1167
1326
  return updated; // persistence/readback failures must never claim no effect
1168
1327
  }
@@ -1171,6 +1330,7 @@ export class Tasks {
1171
1330
  this.planningAuthority(by);
1172
1331
  const task = this.d.board.get(id), entry = task ? planningUndoEntry(task) : undefined;
1173
1332
  if (!task || !entry?.planning || entry.planning.revision !== revision || (task.planningRevision ?? 0) !== revision || by !== USER && entry.by !== by) throw new PreEffectToolRefusal("planning undo is stale or not owned");
1333
+ this.planningAuthority(by, task);
1174
1334
  if (entry.planning.split) {
1175
1335
  const release = task.release == null ? undefined : this.d.board.release(task.release);
1176
1336
  if (task.state !== "backlog" || task.owner || taskHasStarted(task) || release && ["shipped", "dropped"].includes(release.state) || by !== USER && (task.pinned || release?.state === "frozen")) throw new PreEffectToolRefusal("split undo is no longer eligible");
@@ -1240,6 +1400,7 @@ export class Tasks {
1240
1400
  if (!parent || parent.state !== "backlog" || parent.owner || taskHasStarted(parent) || (parent.planningRevision ?? 0) !== revision || by !== USER && parent.pinned) throw new PreEffectToolRefusal("split changed, pinned or already started");
1241
1401
  const release = parent.release == null ? undefined : this.d.board.release(parent.release);
1242
1402
  if (parent.release != null && (!release || ["shipped", "dropped"].includes(release.state) || by !== USER && release.state === "frozen")) throw new PreEffectToolRefusal("split release scope is frozen or final");
1403
+ this.planningAuthority(by, parent);
1243
1404
  const profile = parent.profile ?? "dev";
1244
1405
  const drafts = rows.map((part, index) => {
1245
1406
  if (!part || typeof part !== "object" || Array.isArray(part) || Object.keys(part).some(key => !["title", "detail", "class", "refs", "plan", "after", "afterParts"].includes(key)) || typeof part.title !== "string" || !part.title.trim() || part.title.length > 300 || part.detail !== undefined && (typeof part.detail !== "string" || part.detail.length > 8000) || part.class !== undefined && typeof part.class !== "string") throw new PreEffectToolRefusal("invalid split part");
@@ -1304,10 +1465,12 @@ export class Tasks {
1304
1465
  this.plannerWriteAuthority(by);
1305
1466
  const task = this.d.board.get(input.id);
1306
1467
  if (!task || (task.planningRevision ?? 0) !== input.revision) throw new PreEffectToolRefusal("release assignment changed");
1468
+ this.plannerWriteAuthority(by, task);
1307
1469
  if (by !== USER && (this.isPii(task) || task.pinned)) throw new PreEffectToolRefusal("private or pinned release scope requires the person");
1308
1470
  for (const id of new Set([task.release, input.release])) if (id != null) {
1309
1471
  const release = this.d.board.release(id);
1310
1472
  if (!release || ["frozen", "shipped", "dropped"].includes(release.state)) throw new PreEffectToolRefusal("release scope is frozen or final");
1473
+ this.plannerWriteAuthority(by, release);
1311
1474
  }
1312
1475
  return task;
1313
1476
  };
@@ -1326,6 +1489,7 @@ export class Tasks {
1326
1489
  if (!Number.isSafeInteger(input.id) || input.id <= 0 || !Number.isSafeInteger(input.revision) || input.revision < 0) throw new PreEffectToolRefusal("invalid release identity or revision");
1327
1490
  const release = this.d.board.release(input.id);
1328
1491
  if (!release || release.revision !== input.revision || !["planning", "active"].includes(release.state)) throw new PreEffectToolRefusal("release revision changed or scope frozen");
1492
+ this.plannerWriteAuthority(by, release);
1329
1493
  if (input.state !== undefined && input.state !== "active") throw new PreEffectToolRefusal("invalid train state");
1330
1494
  if (input.cut !== undefined && (!Number.isSafeInteger(input.cut) || input.cut <= 0 || input.cut > 8_640_000_000_000_000)) throw new PreEffectToolRefusal("release cut must be a positive safe UTC timestamp");
1331
1495
  const dates = [release.plannedStart, input.cut ?? release.cut, release.ship].filter((date): date is number => date !== null);
@@ -1369,6 +1533,7 @@ export class Tasks {
1369
1533
  if (!Number.isSafeInteger(input.id) || input.id <= 0 || !Number.isSafeInteger(input.revision) || input.revision < 0) throw new PreEffectToolRefusal("invalid release identity or revision");
1370
1534
  const release = this.d.board.release(input.id);
1371
1535
  if (!release || release.revision !== input.revision || release.state !== "active") throw new PreEffectToolRefusal("release revision changed or train is not active");
1536
+ this.plannerWriteAuthority(by, release);
1372
1537
  if (this.d.board.releases().some(row => row.id !== input.id && ["active", "frozen"].includes(row.state))) throw new PreEffectToolRefusal("one open release train at a time");
1373
1538
  if ((release.train?.history.length ?? 0) > RELEASE_TRAIN_HISTORY_LIMIT - 2) throw new PreEffectToolRefusal("release train history full; cut needs slots for cut and ship, drop this train and create a replacement");
1374
1539
  const report = this.releaseReport(by, input.id);
@@ -1412,7 +1577,7 @@ export class Tasks {
1412
1577
  if (!this.nameable(text) || this.screenOn() && (await this.screenCall(text)).label !== "clear") throw new PreEffectToolRefusal("release text was not cleared for public planning");
1413
1578
  this.plannerWriteAuthority(by); // Privacy and planner identity may change while screening.
1414
1579
  if (this.d.board.releases().some(release => release.name === input.name)) throw new PreEffectToolRefusal("release name already exists");
1415
- const release = this.d.board.newRelease(input.name, input.goal.trim(), input.order);
1580
+ const release = this.d.board.newRelease(input.name, input.goal.trim(), input.order, by === USER ? null : by);
1416
1581
  this.planningRecorded({ by, release: release.id, fields: ["created"], revision: release.revision });
1417
1582
  return release;
1418
1583
  }
@@ -1421,6 +1586,7 @@ export class Tasks {
1421
1586
  this.planningAuthority(by);
1422
1587
  const release = this.d.board.release(input.id);
1423
1588
  if (!Number.isSafeInteger(input.id) || input.id <= 0 || !Number.isSafeInteger(input.revision) || !release || release.revision !== input.revision) throw new PreEffectToolRefusal("release revision changed");
1589
+ this.planningAuthority(by, release);
1424
1590
  if (["shipped", "dropped"].includes(release.state)) throw new PreEffectToolRefusal("settled release is final");
1425
1591
  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");
1426
1592
  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");
@@ -1551,6 +1717,7 @@ export class Tasks {
1551
1717
  || typeof input.reason !== "string" || !input.reason.trim() || input.reason.length > 1000) throw new PreEffectToolRefusal("untouched return needs an id, revision and bounded reason");
1552
1718
  const validate = () => {
1553
1719
  const task = this.need(input.id, false, true);
1720
+ this.plannerWriteAuthority(by, task);
1554
1721
  if (!["proposed", "in_progress"].includes(task.state) || (task.planningRevision ?? 0) !== input.revision || taskHasWorkEvidence(task)) throw new PreEffectToolRefusal("work has execution evidence or its planning revision changed");
1555
1722
  if (by !== USER && task.pinned) throw new PreEffectToolRefusal("pinned work requires the person");
1556
1723
  const release = task.release == null ? undefined : this.d.board.release(task.release);
@@ -1783,9 +1950,18 @@ export class Tasks {
1783
1950
  if (env.from === HUB && env.kind === "task" && env.refs?.backlog) {
1784
1951
  const ids = env.refs.backlog;
1785
1952
  if (!Array.isArray(ids) || !ids.length || ids.length > 64 || ids.some(id => !Number.isSafeInteger(id) || id <= 0)) return false;
1786
- const current = ids.filter(id => this.d.board.get(id)?.state === "backlog");
1953
+ const current = ids.filter(id => {
1954
+ const task = this.d.board.get(id);
1955
+ return task?.state === "backlog" && this.taskPlanner(task).includes(peer);
1956
+ });
1787
1957
  if (current.length !== ids.length || !this.d.roles?.[peer]?.includes("planner") || !this.d.bus.isRostered(peer)) {
1788
1958
  this.addPlannerBacklog(peer, current);
1959
+ for (const id of ids) {
1960
+ const task = this.d.board.get(id);
1961
+ if (task?.state === "backlog" && !this.taskPlanner(task).includes(peer)) {
1962
+ for (const recipient of this.taskPlanner(task)) this.addPlannerBacklog(recipient, [id]);
1963
+ }
1964
+ }
1789
1965
  return false;
1790
1966
  }
1791
1967
  return true;
@@ -1793,6 +1969,10 @@ export class Tasks {
1793
1969
  if (env.from === HUB && env.refs?.task) {
1794
1970
  const task = this.d.board.get(Number(env.refs.task));
1795
1971
  if (task?.state === "cancelled") return env.kind === "task" && typeof env.refs.taskCancelled === "number" && env.refs.taskCancelled === task.cancelled?.flowRevision;
1972
+ if (env.refs.taskRecipient === "planner") return !!task && this.taskPlanner(task).includes(peer);
1973
+ if (env.refs.planReview !== undefined) return !!task && !isClosedTaskState(task.state) && (task.planningRevision ?? 0) === env.refs.planReview
1974
+ && this.plannerHolders().includes(peer) && this.planAuthor(task) !== peer && Number.isSafeInteger(env.refs.planReviewFlow)
1975
+ && !task.history.slice(env.refs.planReviewFlow).some(entry => entry.event === "plan reviewed");
1796
1976
  }
1797
1977
  const c = this.conditional.get(env.id);
1798
1978
  if (!c || c.peer !== peer) return true;
@@ -2035,6 +2215,7 @@ export class Tasks {
2035
2215
  }
2036
2216
  task = current; // Reviewer-only and quiet bookkeeping updates do not supersede ownership authority.
2037
2217
  if (gap) {
2218
+ this.requestIndependentPlanReview(current);
2038
2219
  this.releaseNotice(`task ${this.publicTitle(current)}: ${gap}; planner: call hub_planner_groom then hub_plan_review`);
2039
2220
  if (mode === "enforce") {
2040
2221
  if (opts.claim && opts.namedOwner === by) stagedClaimGap = gap;
@@ -2080,10 +2261,11 @@ export class Tasks {
2080
2261
  const lasting = opts.event === "escalated" && task.owner && (opts.reason === "rejections" || opts.reason === "manual");
2081
2262
  opts.plannerPull?.guard();
2082
2263
  const assignmentNote = this.assignmentNote(`${opts.note ?? `to ${a.owner}`}${passed}`, a);
2083
- const assignmentExtra = { ...this.reviewMetadata(a), ...(profile ? { profile } : {}), ...(opts.reason ? { reason: opts.reason } : {}), ...(lasting ? { from: task.owner! } : {}) };
2264
+ const binding = this.plannerBinding(task, a, !!opts.plannerPull);
2265
+ const assignmentExtra = { ...this.reviewMetadata(a), ...(binding ? { plannerReason: binding.plannerReason } : {}), ...(profile ? { profile } : {}), ...(opts.reason ? { reason: opts.reason } : {}), ...(lasting ? { from: task.owner! } : {}) };
2084
2266
  const next = opts.plannerPull
2085
- ? this.d.board.pullAssigned(task.id, by, a.owner, opts.plannerPull.revision, opts.plannerPull.note, opts.plannerPull.withheld, a.reviewer ?? null, assignmentNote, assignmentExtra, task.history.length)
2086
- : 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 } : {}) }, assignmentNote, assignmentExtra, opts.expectedFlowRevision);
2267
+ ? this.d.board.pullAssigned(task.id, by, a.owner, opts.plannerPull.revision, opts.plannerPull.note, opts.plannerPull.withheld, a.reviewer ?? null, assignmentNote, assignmentExtra, task.history.length, binding)
2268
+ : this.d.board.update(task.id, by, opts.event ?? "assigned", { owner: a.owner, reviewer: a.reviewer ?? null, ...(binding ? { planner: binding.planner } : {}), ...(opts.roster && task.state === "in_progress" ? { state: "proposed" as const } : {}), ...(opts.event === "escalated" ? { rejections: 0 } : {}), ...(opts.override && task.reserved ? { reserved: null } : {}) }, assignmentNote, assignmentExtra, opts.expectedFlowRevision);
2087
2269
  if (opts.plannerPull) this.planningRecorded({ by, task: next.id, fields: ["pull", "reserved"], revision: next.planningRevision! });
2088
2270
  // What calibration reads (issue #109): routing chose the first owner (no single named candidate, no claim; not an
2089
2271
  // escalation, relay or reassignment of work already begun), and the work overlaps another owner's task not started
@@ -2367,7 +2549,7 @@ export class Tasks {
2367
2549
  this.releaseNotice(text);
2368
2550
  try { this.d.stallNotice?.(stall); } catch { /* advisory never changes task authority */ }
2369
2551
  // Advice joins the planner's next delivery; it must not manufacture a turn beside a withheld backlog batch.
2370
- 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)));
2552
+ for (const peer of this.taskPlanner(task).filter(peer => this.d.bus.isRostered(peer))) this.releaseNotice(`task #${task.id}: stall notice unavailable`, () => this.d.tell?.(peer, noteLine(HUB, "finding", text)));
2371
2553
  }
2372
2554
  async cancel(id: unknown, by: PeerId, input: { reason: string; supersededBy?: string; expectedFlowRevision?: number }): Promise<Task> {
2373
2555
  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");
@@ -2378,7 +2560,7 @@ export class Tasks {
2378
2560
  if (isClosedTaskState(task.state)) throw new PreEffectToolRefusal(`task #${task.id} is already ${task.state}`);
2379
2561
  if (by !== USER && this.d.privateTurn?.(by) && !this.isPii(task)) throw new PreEffectToolRefusal("cancellation on ordinary work is unavailable during a private turn");
2380
2562
  if (by !== USER) {
2381
- this.planningAuthority(by);
2563
+ this.planningAuthority(by, task);
2382
2564
  const proposer = task.history.find(h => h.event === "proposed" || h.event === "backlogged")?.by;
2383
2565
  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");
2384
2566
  }
@@ -2573,7 +2755,16 @@ export class Tasks {
2573
2755
  if (issue) issues.set(key, issue);
2574
2756
  }
2575
2757
  this.planningAuthority(by);
2576
- const current = this.d.board.list().filter(included), visible = current.filter(task => !this.isPii(task));
2758
+ const all = this.d.board.list(), current = all.filter(included), visible = current.filter(task => !this.isPii(task));
2759
+ // Include only bounded public dependency context, so approved intermediate phases do not disappear from reachability.
2760
+ const indexed = new Map(all.map(task => [task.id, task])), dependencies = new Map<number, readonly number[]>();
2761
+ const pending = visible.slice(0, 500).map(task => task.id).reverse(), visited = new Set<number>();
2762
+ while (pending.length && visited.size < 500) {
2763
+ const id = pending.pop()!; if (visited.has(id)) continue; visited.add(id);
2764
+ const task = indexed.get(id); if (!task || this.isPii(task)) continue;
2765
+ dependencies.set(id, task.deps ?? []); pending.push(...task.deps ?? []);
2766
+ }
2767
+ const dependencyTruncated = pending.some(id => !visited.has(id));
2577
2768
  const peers = [...new Set([...Object.keys(this.states()), ...current.flatMap(task => task.owner ? [task.owner] : [])])];
2578
2769
  const reviewedIssues = new Map(visible.flatMap(task => task.sdlc?.planReview?.issue ? [[task.id, task.sdlc.planReview.issue] as const] : []));
2579
2770
  const acceptedIssues = new Map(visible.flatMap(task => task.sdlc?.acceptance ? [[task.id, task.sdlc.acceptance] as const] : []));
@@ -2587,7 +2778,7 @@ export class Tasks {
2587
2778
  if (!result.owner) for (const refusal of result.refusals ?? []) if (refusal.role === "owner") blockers.push({ task: task.id, code: refusal.code === "instance" || refusal.code === "family" ? "policy" : refusal.code });
2588
2779
  if (this.waitsFor(task).length) blockers.push({ task: task.id, code: "dependency" });
2589
2780
  }
2590
- const digest = plannerGrooming(visible, { repository: this.d.repository ?? `path:${realPath(this.d.cwd)}`,
2781
+ const digest = plannerGrooming(visible, { dependencies, dependencyTruncated, repository: this.d.repository ?? `path:${realPath(this.d.cwd)}`,
2591
2782
  limits: Object.fromEntries(peers.map(peer => [peer, this.wipLimit(peer)])), wip: Object.fromEntries(peers.map(peer => [peer, this.wip(peer)])),
2592
2783
  states, issues, reviewedIssues, acceptedIssues, blockers });
2593
2784
  return { ...digest, truncated: digest.truncated || incomplete, ...(current.some(task => this.isPii(task)) ? { privateTasks: "withheld" } : {}) };
@@ -2770,6 +2961,7 @@ export class Tasks {
2770
2961
  if (Object.keys(raw).some(key => !["revision", "reason", "review", "ci"].includes(key)) || !Number.isSafeInteger(raw.revision) || (raw.revision as number) < 0
2771
2962
  || typeof raw.reason !== "string" || !raw.reason.trim() || raw.reason.length > 1000) throw new PreEffectToolRefusal("plan review needs a current revision and bounded reason");
2772
2963
  const task = this.need(id, false, true, true), revision = raw.revision as number;
2964
+ this.planReviewAuthority(by, task);
2773
2965
  if (isClosedTaskState(task.state)) throw new PreEffectToolRefusal("closed work is final");
2774
2966
  if ((task.planningRevision ?? 0) !== revision) throw new PreEffectToolRefusal("planning revision changed");
2775
2967
  let scope: CostPlan;
@@ -2783,6 +2975,7 @@ export class Tasks {
2783
2975
  const flow = task.history.length, withheld = await this.withholds("note", raw.reason, task);
2784
2976
  this.plannerWriteAuthority(by);
2785
2977
  const observed = this.need(task.id, false, true, true);
2978
+ this.planReviewAuthority(by, observed);
2786
2979
  if (observed.history.length !== flow || (observed.planningRevision ?? 0) !== revision) throw new PreEffectToolRefusal("plan review changed during readback");
2787
2980
  const identity = this.planIssueIdentity(observed);
2788
2981
  if (identity?.issue && this.isPii(observed)) throw new PreEffectToolRefusal("private issue plan review readback unavailable");
@@ -2791,10 +2984,12 @@ export class Tasks {
2791
2984
  if (issue && !this.isPii(task) && !this.nameable(JSON.stringify(issue))) throw new PreEffectToolRefusal("plan review metadata matches a PII pattern");
2792
2985
  this.plannerWriteAuthority(by);
2793
2986
  const current = this.need(task.id, false, true, true);
2987
+ const authorship = this.planReviewAuthority(by, current);
2794
2988
  if (current.history.length !== flow || (current.planningRevision ?? 0) !== revision) throw new PreEffectToolRefusal("plan review changed during readback");
2795
2989
  const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
2796
2990
  let updated = this.d.board.update(current.id, by, "plan reviewed", { ...(Object.keys(scope).length ? { plan: { ...current.plan, ...scope } } : {}), sdlc: { ...current.sdlc, stage: current.sdlc?.stage ?? lifecycleStage(config, "plan"),
2797
- planReview: { planner: by, planningRevision: revision, recordedAt: Date.now(), ...(Object.keys(scope).length ? { scope } : {}), ...(issue ? { issue } : {}) } } }, raw.reason.trim(), { ...(withheld ? { withheld: true } : {}) }, flow);
2991
+ planReview: { planner: by, ...authorship, planningRevision: revision, recordedAt: Date.now(), ...(Object.keys(scope).length ? { scope } : {}), ...(issue ? { issue } : {}) } } }, raw.reason.trim(), { ...authorship, ...(withheld ? { withheld: true } : {}) }, flow);
2992
+ this.planReviewRequests.delete(updated.id);
2798
2993
  if (config.mode !== "off") updated = this.linkIssue(updated); // #694
2799
2994
  if (updated.state === "proposed" && !updated.owner) {
2800
2995
  this.offered.delete(updated.id);
@@ -2823,19 +3018,22 @@ export class Tasks {
2823
3018
 
2824
3019
  recordLifecycle(by: PeerId, id: unknown, input: unknown, deliverables?: DeliverableRecord[]): Task {
2825
3020
  const task = this.need(id, false, true);
2826
- if (by !== USER && by !== task.owner && by !== task.reviewer) throw new PreEffectToolRefusal("only the task owner, reviewer or console user may record lifecycle evidence");
2827
3021
  const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
2828
3022
  const next = sdlcReceipt(input, task.sdlc, config, VERSION);
3023
+ const definition = stageDefinition(config, next.stage);
3024
+ const plannerStage = !!input && typeof input === "object" && !Array.isArray(input) && Object.hasOwn(input, "stage")
3025
+ && Object.keys(input).every(key => ["stage", "repo", "issue"].includes(key))
3026
+ && (definition?.actor === "planner" || definition?.actor === "owner/planner") && this.taskPlanner(task).includes(by);
3027
+ if (by !== USER && by !== task.owner && by !== task.reviewer && !plannerStage) throw new PreEffectToolRefusal("only the task owner, reviewer or console user may record lifecycle evidence; a stage planner may record stage identity");
2829
3028
  // The spec identity is person-owned in enforce. No observation cache or restart can reopen a relink bypass.
2830
3029
  const specRelink = (task.sdlc?.repo !== undefined && next.repo !== task.sdlc.repo) || (task.sdlc?.issue !== undefined && next.issue !== task.sdlc.issue);
2831
3030
  if (config.mode === "enforce" && by !== USER && specRelink) throw new PreEffectToolRefusal("only the person may relink an established repository or issue in enforce");
2832
- const definition = stageDefinition(config, next.stage);
2833
3031
  if (next.redFirst && input && typeof input === "object" && "redFirst" in input && !this.isPii(task) && !this.publicMetadataText(JSON.stringify(next.redFirst))) throw new PreEffectToolRefusal("red-first metadata matches a PII pattern; use private work");
2834
3032
  // #481 S1: entering a build stage in enforce needs the person's spec acceptance; the person may move any stage.
2835
3033
  if (config.mode === "enforce" && by !== USER && next.stage !== task.sdlc?.stage && definition && canonicalStage(definition.base) === "build" && specState(next, undefined) === "missing") throw new PreEffectToolRefusal(this.specText(task, "missing", "blocked"));
2836
3034
  if (config.mode === "enforce" && config.definitions && input && typeof input === "object" && "stage" in input && next.stage !== task.sdlc?.stage && by !== USER) {
2837
3035
  const actor = definition?.actor;
2838
- const acts = actor === "owner" ? by === task.owner : actor === "reviewer" ? by === task.reviewer : actor === "planner" ? this.d.roles?.[by]?.includes("planner") : actor === "owner/planner" && (by === task.owner || this.d.roles?.[by]?.includes("planner"));
3036
+ const acts = actor === "owner" ? by === task.owner : actor === "reviewer" ? by === task.reviewer : actor === "planner" ? this.taskPlanner(task).includes(by) : actor === "owner/planner" && (by === task.owner || this.taskPlanner(task).includes(by));
2839
3037
  if (!acts) throw new PreEffectToolRefusal(`lifecycle stage requires its ${actor ?? "unknown"} actor or the person`);
2840
3038
  }
2841
3039
  if (input && typeof input === "object" && ("criteria" in input || "phase" in input)) {
@@ -3325,7 +3523,49 @@ export class Tasks {
3325
3523
  const { sha256, id, format, version, path, private: hidden } = shown[i]!;
3326
3524
  return { id, sha256, state: check.state, ...(path ? { path } : {}), format, ...(version ? { version } : {}), ...(hidden ? { private: true as const } : {}) };
3327
3525
  });
3328
- return { state: approved ? "approved" : "delivered", changedSinceReview: files.some(file => file.state === "changed"), files };
3526
+ const changedSinceReview = files.some(file => file.state === "changed");
3527
+ this.deliverableSeen.set(task.id, { key: Tasks.deliverableKey(basis), at: Date.now(), result: deliverableResult(files.map(file => file.state)) });
3528
+ return { state: approved ? "approved" : "delivered", changedSinceReview, files };
3529
+ }
3530
+
3531
+ /** The last live check per task (#317): what the dashboard may say about "changed since review" without hashing on its 1 s poll. Keyed by the records it measured. */
3532
+ private deliverableSeen = new Map<number, { key: string; at: number; result: DeliverableResult }>();
3533
+ private static deliverableKey = (records: readonly DeliverableRecord[]) => records.map(record => `${record.id}:${record.sha256}`).join(",");
3534
+
3535
+ /**
3536
+ * #317: the deliverables of the board's tasks, newest task first, for `ahub deliverables`. `approved` is what a reviewer's approval bound (else what was delivered).
3537
+ * Without `check` this reads the board only: no file is opened, so no row claims `changedSinceReview`. With `check` every file is measured now (one task at a time).
3538
+ * Every path goes through `publicDeliverables`, so a PII task or a path under `pii.paths` shows id, hash and size only.
3539
+ */
3540
+ async deliverableList(opts: { task?: number; approvedOnly?: boolean; check?: boolean } = {}): Promise<DeliverableRow[]> {
3541
+ const rows: DeliverableRow[] = [];
3542
+ const tasks = (opts.task !== undefined ? [this.d.board.get(opts.task)].filter((task): task is Task => !!task) : this.d.board.list()).sort((a, b) => b.id - a.id);
3543
+ for (const task of tasks) {
3544
+ const delivered = latestDeliverables(task.history);
3545
+ if (!delivered) continue;
3546
+ const approved = task.state === "approved" ? approvedDeliverables(task.history) : undefined;
3547
+ if (opts.approvedOnly && !approved) continue;
3548
+ const basis = approved ?? delivered, shown = publicDeliverables(basis, this.nameable)!;
3549
+ const row: DeliverableRow = { task: task.id, title: this.publicTitle(task), taskState: task.state, state: approved ? "approved" : "delivered", checked: false,
3550
+ files: shown.map(({ id, path, format, version, sha256, bytes, private: hidden }) => ({ id, ...(path ? { path } : {}), format, ...(version ? { version } : {}), sha256, bytes, ...(hidden ? { private: true as const } : {}) })) };
3551
+ if (opts.check) {
3552
+ const status = await this.deliverableStatus(task);
3553
+ if (status) { row.checked = true; row.changedSinceReview = status.changedSinceReview; row.result = deliverableResult(status.files.map(file => file.state)); row.files = row.files.map((file, i) => ({ ...file, state: status.files[i]!.state })); }
3554
+ }
3555
+ rows.push(row);
3556
+ }
3557
+ return rows;
3558
+ }
3559
+
3560
+ /** What the dashboard card shows: the board's records, plus the last live check when it measured exactly these records. Opens no file. */
3561
+ deliverableBadge(task: Task): { state: "delivered" | "approved"; files: DeliverableRow["files"]; checked?: { at: number; result: DeliverableResult } } | undefined {
3562
+ const delivered = latestDeliverables(task.history);
3563
+ if (!delivered) return undefined;
3564
+ const approved = task.state === "approved" ? approvedDeliverables(task.history) : undefined;
3565
+ const basis = approved ?? delivered, seen = this.deliverableSeen.get(task.id);
3566
+ return { state: approved ? "approved" : "delivered",
3567
+ files: publicDeliverables(basis, this.nameable)!.map(({ id, path, format, version, sha256, bytes, private: hidden }) => ({ id, ...(path ? { path } : {}), format, ...(version ? { version } : {}), sha256, bytes, ...(hidden ? { private: true as const } : {}) })),
3568
+ ...(seen && seen.key === Tasks.deliverableKey(basis) ? { checked: { at: seen.at, result: seen.result } } : {}) };
3329
3569
  }
3330
3570
 
3331
3571
  /** Check records against the owner's root; with that root unproven every file is `unknown` (never `unchanged`, never an exception). */
@@ -3610,7 +3850,8 @@ export class Tasks {
3610
3850
  const noWrites = this.noWritesRecorded({ ...task, refs: { ...task.refs, ...completedRefs } });
3611
3851
  const next = this.d.board.update(task.id, by, "done", { codeSymbols: this.observedCodeSymbols({ ...task, refs: { ...task.refs, ...completedRefs } }), state: reviewer ? "in_review" : "approved", ...(selection ? { reviewer: reviewer ?? USER } : {}), ...(task.sdlc ? { sdlc: { ...task.sdlc, stage: lifecycleStage(this.d.sdlc?.config() ?? sdlcConfig(undefined), reviewer ? "review" : "merge") } } : {}), refs: completedRefs }, checkOutput ? `${routedSummary ?? ""}\n${checkOutput}`.trim() : routedSummary, { ...this.planObservation(task), withheld: withheld || undefined, ...(noWrites ? { noWrites: true as const } : {}), ...this.reviewMetadata(selection), ...(reviewCost ? { reviewCost } : {}), ...(records ? { deliverables: records } : {}) }, task.history.length, HOST_CODE_SYMBOLS);
3612
3852
  this.cohorts.closed(next.id); // its owner's next native turn end settles it in its cohort (issue #107)
3613
- this.note(next, by, "finding", `Task #${next.id} done by ${by}: ${next.title}\n${summary ?? ""}`, withheld);
3853
+ const memorySymbols = this.completionMemorySymbols(next);
3854
+ this.note(next, by, "finding", `Task #${next.id} done by ${by}: ${next.title}\nChanged-symbol memory anchors: ${memorySymbols.state === "known" ? memorySymbols.nodes.length : "unknown (no complete code facts)"}\n${summary ?? ""}`, withheld, memorySymbols);
3614
3855
  this.tellCompleted(next, summary, withheld);
3615
3856
  if (!reviewer) {
3616
3857
  this.settleMemory(next);
@@ -3765,12 +4006,12 @@ export class Tasks {
3765
4006
  return admitted;
3766
4007
  }
3767
4008
 
3768
- private sendReview(task: Task, reviewer: PeerId, why = "", admission?: ReviewReadiness): void {
4009
+ private sendReview(task: Task, reviewer: PeerId, why = "", admission?: ReviewReadiness): boolean {
3769
4010
  const mode = (this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode;
3770
4011
  if (mode === "enforce" && !admission?.ready) {
3771
4012
  // Background roster/quota role changes are retained, but never invent a remotely admitted review request.
3772
4013
  this.releaseNotice(`task ${this.publicTitle(task)}: premature review held; retry the reviewer assignment after current CI and bot replies are verified`);
3773
- return;
4014
+ return false;
3774
4015
  }
3775
4016
 
3776
4017
  const r = task.refs;
@@ -3832,7 +4073,7 @@ export class Tasks {
3832
4073
  // #572 AC3: advisory only, from git state never from task text, so it is safe for a PII task too.
3833
4074
  const checkout = mainCheckoutWarning(this.d.cwd, this.publicMetadataText);
3834
4075
  const body = `Review task #${task.id} [${profileTaskLabel(task)}] ${task.title}\nDone by ${last?.by ?? task.owner}: ${summary}\n${where ? `Where: ${where}\n` : ""}${last?.noWrites ? "no writes recorded since acceptance\n" : ""}${spec}${why ? `${why}\n` : ""}${!plan && task.detail ? `Task detail:\n${task.detail}\n` : ""}${guidance ? guidance + "\n" : ""}${projection ? projection + "\n" : ""}${brief ? brief + "\n" : ""}${checkout ? `Note: ${checkout}.\n` : ""}${findings.length ? `Earlier findings (set a disposition for each):\n${findings.join("\n")}\n` : ""}${carriedText}${conformanceText}${checklist}\nGive your verdict with hub_review {id: ${task.id}, verdict: "approved" | "changes_requested", note, unmet, findings: [{id, pass: "bugs" | "security" | "compliance", severity: "important" | "nit", path?, disposition: "open" | "fixed" | "false_positive"}]}. Reuse finding ids when updating their disposition; only the person may accept a finding with a rationale.`;
3835
- this.d.bus.publish(newEnvelope(HUB, body, { to: [reviewer], kind: "review", priority: "important", refs: { ...r, ...(this.reviewHead(task) ? { commit: this.reviewHead(task)! } : {}), task: String(task.id), reviewRevision: String(this.reviewRequestRevision(task)) }, ...(this.isPii(task) ? { private: true } : {}) }));
4076
+ return this.d.bus.publish(newEnvelope(HUB, body, { to: [reviewer], kind: "review", priority: "important", refs: { ...r, ...(this.reviewHead(task) ? { commit: this.reviewHead(task)! } : {}), task: String(task.id), reviewRevision: String(this.reviewRequestRevision(task)) }, ...(this.isPii(task) ? { private: true } : {}) })).includes(reviewer);
3836
4077
  }
3837
4078
 
3838
4079
  private reviewRequestRevision(task: Task): number {
@@ -3848,25 +4089,35 @@ export class Tasks {
3848
4089
  }
3849
4090
 
3850
4091
  /** Host-only failure report, bound to the exact review request and its still-current child generation. */
3851
- async retryReviewer(id: number, failedPeer: PeerId, failure: { head?: string; flowRevision?: number; cause: "start_failed" | "quota" | "no_verdict" }, current: () => boolean): Promise<Task> {
4092
+ async retryReviewer(id: number, failedPeer: PeerId, failure: { head?: string; flowRevision?: number; cause: "start_failed" | "quota" | "no_verdict" }, current: () => boolean, onReoffered?: () => void): Promise<Task> {
3852
4093
  if (!failure || !["start_failed", "quota", "no_verdict"].includes(failure.cause) || !/^[a-z][a-z0-9-]{0,63}$/.test(failedPeer) || !Number.isSafeInteger(failure.flowRevision) || failure.flowRevision! < 1 || failure.head !== undefined && (typeof failure.head !== "string" || text(failure.head) !== failure.head || Buffer.byteLength(failure.head) > 1200 || /\p{Cc}/u.test(failure.head))) throw new PreEffectToolRefusal("invalid reviewer failure binding");
3853
4094
  const valid = () => {
3854
4095
  const task = this.need(id, false, true);
3855
- if (!current() || task.state !== "in_review" || task.reviewer !== failedPeer || this.reviewRequestRevision(task) !== failure.flowRevision || this.reviewHead(task) !== failure.head || this.isPii(task) || this.manuallyNamedReviewer(task)) throw new PreEffectToolRefusal("reviewer failure is no longer current");
4096
+ if (!current() || task.state !== "in_review" || task.reviewer !== failedPeer || this.reviewRequestRevision(task) !== failure.flowRevision || this.reviewHead(task) !== failure.head || this.isPii(task) || this.manuallyNamedReviewer(task) && failure.cause !== "no_verdict") throw new PreEffectToolRefusal("reviewer failure is no longer current");
3856
4097
  return task;
3857
4098
  };
3858
4099
  const before = valid();
3859
4100
  const admission = await this.reviewRequestAdmission(before, undefined, true, before.owner ?? USER, undefined, () => { valid(); return true; });
3860
4101
  const task = valid();
3861
4102
  const options = this.reviewPoolOptions(task);
3862
- const selection = assign(task, this.states(), this.d.routing(), { ...this.weights(task.class, task), candidates: [], ...(task.owner ? { notReviewer: task.owner } : {}), reviewPool: { ...options, failed: [...options.failed, failedPeer] } });
3863
- if (!selection.reviewSelection) throw new PreEffectToolRefusal("no declared reviewer pool can receive the fallback");
3864
- const reviewer = selection.reviewer ?? USER;
4103
+ // A person-selected reviewer never falls through to an agent the person did not select.
4104
+ const manual = this.manuallyNamedReviewer(task);
4105
+ const selection = manual ? undefined : assign(task, this.states(), this.d.routing(), { ...this.weights(task.class, task), candidates: [], ...(task.owner ? { notReviewer: task.owner } : {}), reviewPool: { ...options, failed: [...options.failed, failedPeer] } });
4106
+ if (!selection?.reviewSelection && failure.cause !== "no_verdict") throw new PreEffectToolRefusal("no declared reviewer pool can receive the fallback");
4107
+ const reviewer = selection?.reviewSelection ? selection.reviewer ?? USER : USER;
3865
4108
  valid();
3866
- const next = this.d.board.update(task.id, HUB, "reviewer unavailable", { reviewer }, this.assignmentNote(`reviewer unavailable: ${failure.cause}`, selection), { ...this.reviewMetadata(selection), reviewerFailure: { peer: failedPeer, cause: failure.cause, ...(failure.head ? { head: failure.head } : {}) } }, task.history.length);
4109
+ const note = manual ? "named reviewer recorded no verdict; the person must review"
4110
+ : selection?.reviewSelection ? this.assignmentNote(`reviewer unavailable: ${failure.cause}`, selection) : "no declared reviewer pool can receive the fallback; the person must review";
4111
+ const next = this.d.board.update(task.id, HUB, "reviewer unavailable", { reviewer }, note, { ...this.reviewMetadata(selection), ...(reviewer === USER ? { reviewBlocked: true } : {}), reviewerFailure: { peer: failedPeer, cause: failure.cause, ...(failure.head ? { head: failure.head } : {}) } }, task.history.length);
3867
4112
  this.releaseNotice(`task ${this.publicTitle(next)}: former review request withdrawal incomplete`, () => this.withdrawRoleRequest(task, next, "reviewer"));
3868
- if (reviewer === USER) this.releaseNotice(`task ${this.publicTitle(next)}: blocked: no reviewer can run; the planner or person must review`);
3869
- else this.releaseNotice(`task ${this.publicTitle(next)}: fallback review request unavailable`, () => this.sendReview(next, reviewer, "", admission));
4113
+ let offered = false;
4114
+ if (reviewer === USER) {
4115
+ const notice = `task ${this.publicTitle(next)}: blocked: the person must review`;
4116
+ this.releaseNotice(notice, () => { this.d.notify(notice); offered = true; });
4117
+ } else this.releaseNotice(`task ${this.publicTitle(next)}: fallback review request unavailable`, () => { offered = this.sendReview(next, reviewer, "", admission); });
4118
+ if (offered && current() && this.reviewBindingCurrent(reviewer, next.id, { head: this.reviewHead(next), flowRevision: this.reviewRequestRevision(next) })) {
4119
+ try { onReoffered?.(); } catch { /* Unconfirmed host settlement retains the delivery hold. */ }
4120
+ }
3870
4121
  return next;
3871
4122
  }
3872
4123
 
@@ -3878,6 +4129,39 @@ export class Tasks {
3878
4129
  } catch { return undefined; }
3879
4130
  }
3880
4131
 
4132
+ /** Read-time code advice uses this project's tree only; declared foreign or inconsistent scope is unknown. */
4133
+ memoryCodeEligible(task: Task): boolean {
4134
+ if (this.isPii(task)) return false;
4135
+ const repository = this.codeSymbolsRepository(task);
4136
+ return !!repository && repository.key === this.repositories.project.key && !this.repositories.mismatch(task.refs, task.plan);
4137
+ }
4138
+
4139
+ /** Read-only host input for exists verification; private/foreign/stale observations supply nothing. */
4140
+ memorySymbolCandidates(task: Task): string[] | undefined {
4141
+ if (this.isPii(task) || !this.d.store) return undefined;
4142
+ const observed = this.observedCodeSymbols(task);
4143
+ return observed?.state === "known" ? observed.symbols.map(symbol => symbol.id) : undefined;
4144
+ }
4145
+
4146
+ /** Only current host observations and positive exists checks may anchor the completion note. */
4147
+ private completionMemorySymbols(task: Task): MemorySymbolNodes {
4148
+ const unknown: MemorySymbolNodes = { state: "unknown", nodes: [], unknown: 0, truncated: false };
4149
+ if (this.isPii(task) || !this.d.store) return unknown;
4150
+ const repository = this.codeSymbolsRepository(task);
4151
+ if (!repository || repository.key !== this.repositories.project.key) return unknown;
4152
+ const observed = publicTaskCodeSymbols(task.codeSymbols, this.reviewHead(task), repository.key, this.publicMetadataText);
4153
+ if (!observed || observed.state !== "known") return unknown;
4154
+ try {
4155
+ const verified = this.d.memorySymbolNodes?.(task);
4156
+ if (!verified) return unknown;
4157
+ const context = { cwd: this.d.cwd, project: this.d.store.project, repo: this.d.store.repo, deny: this.d.store.deny, allowed: this.publicMetadataText };
4158
+ const actual = new Set(observed.symbols.map(symbol => canonicalMemorySymbolNode(symbol.id, context)).filter(Boolean));
4159
+ const nodes = [...new Set(verified.nodes)].filter(node => actual.has(node) && canonicalMemorySymbolNode(node, context) === node).slice(0, MEMORY_SYMBOL_LIMIT);
4160
+ const complete = observed.complete && verified.state === "known" && nodes.length === verified.nodes.length;
4161
+ return { state: complete ? "known" : "unknown", nodes, unknown: verified.unknown, truncated: verified.truncated || nodes.length !== verified.nodes.length };
4162
+ } catch { return unknown; }
4163
+ }
4164
+
3881
4165
  /** Host observations are saved only with an admitted transition, never by an optional read. */
3882
4166
  private observedCodeSymbols(task: Task) {
3883
4167
  if (this.isPii(task) || !task.refs.commit) return undefined;
@@ -4127,7 +4411,7 @@ export class Tasks {
4127
4411
  }
4128
4412
 
4129
4413
  /** Withdraw only a former role's queued request, then tell it that its authority changed. */
4130
- private withdrawRoleRequest(previous: Task, next: Task, role: TaskAssignmentRole): void {
4414
+ private withdrawRoleRequest(previous: Task, next: Task, role: Exclude<TaskAssignmentRole, "planner">): void {
4131
4415
  const peer = previous[role];
4132
4416
  if (!peer || peer === next[role]) return;
4133
4417
  const kind = role === "owner" ? "task" : "review";
@@ -4151,7 +4435,18 @@ export class Tasks {
4151
4435
 
4152
4436
  /** Caller authority is checked by the daemon; keep the real actor in task history. */
4153
4437
  async assignTo(id: unknown, peer: PeerId, by: PeerId = USER, preEffect = false, role: TaskAssignmentRole = "owner", expectedFlowRevision?: number): Promise<Task> {
4154
- if (role !== "owner" && role !== "reviewer") throw new (preEffect ? PreEffectToolRefusal : Error)("role must be owner or reviewer");
4438
+ if (role !== "owner" && role !== "reviewer" && role !== "planner") throw new (preEffect ? PreEffectToolRefusal : Error)("role must be owner, reviewer or planner");
4439
+ if (role === "planner") {
4440
+ if (by !== USER) throw new PreEffectToolRefusal("only the person may name a task planner");
4441
+ const task = this.need(id, false, preEffect);
4442
+ this.checkFlowRevision(task, expectedFlowRevision, preEffect);
4443
+ if (isClosedTaskState(task.state) || this.isPii(task)) throw new PreEffectToolRefusal("closed or private work cannot name a planner");
4444
+ const choice = assign({ ...task, state: "proposed" }, this.states(), this.d.routing(), { ...this.weights(task.class, task), candidates: [], namedPlanner: peer });
4445
+ if (choice.planner !== peer) throw new PreEffectToolRefusal("named planner must be a rostered settled planner holder");
4446
+ const next = this.d.board.update(task.id, by, "planner changed", { planner: peer }, "named", { plannerReason: "named" }, expectedFlowRevision);
4447
+ if (next.state === "backlog") { this.incomingBacklog.add(next.id); this.addPlannerBacklog(peer, [next.id]); }
4448
+ return next;
4449
+ }
4155
4450
  if (role === "reviewer" && by !== USER) {
4156
4451
  const conductors = Object.entries(this.d.roles ?? {}).filter(([, roles]) => Array.isArray(roles) && roles.includes("conductor")).map(([actor]) => actor);
4157
4452
  if (conductors.length !== 1 || conductors[0] !== by) throw new (preEffect ? PreEffectToolRefusal : Error)("only the console user or the configured conductor may name a reviewer");
@@ -4335,28 +4630,31 @@ export class Tasks {
4335
4630
  const requestCheck = this.requestCheck(by);
4336
4631
  const text = composeRememberText(input);
4337
4632
  const task = input.task !== undefined ? this.d.board.get(Number(input.task)) : undefined;
4338
- 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");
4633
+ if (task && this.isPii(task)) throw new Error("notes about a PII task are not saved: shared memory excludes private data");
4339
4634
  // Shared with every other peer as well, so text that matches a PII pattern is refused like a note about a PII task.
4340
4635
  const title = String(input.title ?? "").trim();
4341
- if (this.isPii({ signals: detectSignals({ title, detail: text, refs: {} }, this.d.routing(), this.d.cwd) })) throw new Error("this note matches a PII pattern and is not saved: claude-mem processes what it stores with a cloud model");
4636
+ if (this.isPii({ signals: detectSignals({ title, detail: text, refs: {} }, this.d.routing(), this.d.cwd) })) throw new Error("this note matches a PII pattern and is not saved: shared memory excludes private data");
4342
4637
  if (input.horizon !== undefined && input.horizon !== "short" && input.horizon !== "long") throw new Error('horizon is "short" or "long"');
4343
4638
  if (input.scope !== undefined && input.scope !== "project" && input.scope !== "shared") throw new Error('scope is "project" or "shared"');
4344
4639
  if (!this.d.memory && !this.d.store) return "memory is disabled; nothing saved";
4345
- // Long-term notes leave the machine (#258), and claude-mem's observer is a cloud model: a secret goes to neither.
4640
+ // Long-term notes can leave the machine (#258); local storage does not relax secret exclusion.
4346
4641
  if (looksSecret(`${title}\n${text}`)) throw new Error("this note looks like it holds a secret (a key, token or password) and is not saved");
4347
4642
  if (this.aboutPrivatePath(title, text, task)) throw new Error("this note is about a path the project keeps private (pii.paths) and is not saved");
4348
- if (await this.withholds("note", `${title}\n${text}`, task)) throw new Error("the PII screen did not clear this note, so it is not saved: claude-mem processes what it stores with a cloud model");
4643
+ if (await this.withholds("note", `${title}\n${text}`, task)) throw new Error("the PII screen did not clear this note, so it is not saved: shared memory excludes private data");
4349
4644
  requestCheck();
4350
4645
  const kind = (NOTE_KINDS as readonly string[]).includes(String(input.kind)) ? String(input.kind) as Kind : "finding";
4351
4646
  const native = this.saveNative(by, kind, text, title, task, input.horizon === "long" || input.horizon === "short" ? input.horizon : undefined, input.scope === "shared" ? "shared" : "project");
4352
4647
  if (task && this.d.memory) this.markMemoryReference(task, by);
4353
- const res = this.d.memory ? await this.d.memory.save({ text, ...(title ? { title } : {}), project: this.d.project, metadata: { peer: by, kind, ...(task ? { task: task.id } : {}) } }) : undefined;
4354
- if (!res && !native) return this.d.memory ? "memory worker unavailable; nothing saved" : "memory store unavailable; nothing saved";
4648
+ const res = !native && this.d.memory ? await this.d.memory.save({ text, ...(title ? { title } : {}), project: this.d.project, metadata: { peer: by, kind, ...(task ? { task: task.id } : {}) } }) : undefined;
4649
+ if (!res && !native) return "memory store unavailable; nothing saved";
4650
+ if (!native && res && typeof res === "object" && "status" in res && res.status === "candidate") {
4651
+ return "saved as a pending memory candidate; a person must review it before it becomes shared evidence";
4652
+ }
4355
4653
  const line = noteLine(by, kind, title ? `${title}: ${text}` : text);
4356
4654
  this.d.notify(line);
4357
4655
  // Display identity only: a queued recall may already contain this saved entry. Stored/captured text stays unchanged.
4358
4656
  this.d.share?.(by, native ? noteLine(by, kind, `mem:${native.id} ${title ? `${title}: ` : ""}${text}`) : line);
4359
- 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`;
4657
+ return `saved to shared memory${native ? ` (hub memory ${native.id.slice(0, 8)}, ${native.tier}-term)` : ""}; the other agents get it with their next message`;
4360
4658
  }
4361
4659
 
4362
4660
  /** The task's key in the hub's store: unique across `ahub reset --all`, which restarts the ids. */
@@ -4399,7 +4697,7 @@ export class Tasks {
4399
4697
  * long, decisions, contracts and fails are long-term. Fail-open: a store that cannot be written costs one notice.
4400
4698
  * The caller has screened the text for PII; never a PII task's.
4401
4699
  */
4402
- private saveNative(by: PeerId, kind: Kind, text: string, title: string, task: Task | undefined, horizon?: Tier, scope: Scope = "project"): { id: string; tier: Tier } | undefined {
4700
+ private saveNative(by: PeerId, kind: Kind, text: string, title: string, task: Task | undefined, horizon?: Tier, scope: Scope = "project", symbols?: MemorySymbolNodes): { id: string; tier: Tier } | undefined {
4403
4701
  const st = this.d.store;
4404
4702
  if (!st || (task && this.isPii(task))) return undefined;
4405
4703
  try { // never throws: callers run after a board write
@@ -4407,7 +4705,12 @@ export class Tasks {
4407
4705
  // Approved, or settled in the store already: the snapshot the caller holds can predate an approval made while it awaited.
4408
4706
  const approved = !!task && (task.state === "approved" || st.store.settled(this.memoryKey(task)));
4409
4707
  const tier: Tier = horizon ?? (approved && PROMOTED_KINDS.includes(kind) ? "long" : "short");
4410
- const about = extractNodes(`${title}\n${text}`, { cwd: this.d.cwd, project: st.project, repo: st.repo, deny: st.deny, paths: [...(task?.refs.paths ?? []), ...(task?.plan?.paths ?? [])], symbols: task?.plan?.symbols, ...(task ? { task: { id: task.id, created: task.created } } : {}) });
4708
+ const about = extractNodes(`${title}\n${text}`, { cwd: this.d.cwd, project: st.project, repo: st.repo, deny: st.deny, paths: [...(task?.refs.paths ?? []), ...(task?.plan?.paths ?? [])], symbols: symbols ? [] : task?.plan?.symbols?.map(normalizeMemorySymbolInput), ...(task ? { task: { id: task.id, created: task.created } } : {}) });
4709
+ // Existing notes remain immutable; the completion note connects actual symbols through the shared task node.
4710
+ if (symbols && task) {
4711
+ const taskNode = `task:${this.memoryKey(task)}`;
4712
+ about.splice(0, about.length, ...[...new Set([taskNode, ...symbols.nodes, ...about])].slice(0, 64).sort());
4713
+ }
4411
4714
  // A short-term note on a task already approved (before hub memory existed, or late): the task is settled first, so
4412
4715
  // the note gets its own deadline and is never taken for a source waiting for promotion.
4413
4716
  if (task && approved && tier === "short" && !st.store.settled(this.memoryKey(task))) st.store.settle(this.memoryKey(task), by);
@@ -4457,16 +4760,15 @@ export class Tasks {
4457
4760
  }
4458
4761
 
4459
4762
  /** Auto notes: only transitions that carry content, never for PII. */
4460
- private note(task: Task, by: PeerId, kind: Kind, text: string, withheld = false): void {
4763
+ private note(task: Task, by: PeerId, kind: Kind, text: string, withheld = false, symbols?: MemorySymbolNodes): void {
4461
4764
  if (this.isPii(task) || (!this.d.memory && !this.d.store)) return;
4462
- // claude-mem's observer is a cloud model: model-written text that matches a PII pattern, or that the PII screen did
4463
- // not clear (#198), is not sent (#69). Long-term hub memory leaves the machine (#258): the same rule, and no secrets.
4765
+ // Model-written text must clear the host PII screen (#198/#69); long-term memory may sync (#258). No secrets.
4464
4766
  if (withheld || !this.nameable(text)) return this.releaseNotice(`task #${task.id}: a ${kind} note was not saved to shared memory (${this.whyWithheld(text)})`);
4465
4767
  if (looksSecret(text)) return this.releaseNotice(`task #${task.id}: a ${kind} note was not saved to shared memory (it looks like it holds a secret)`);
4466
4768
  const title = `agent-hub task #${task.id}: ${task.title}`.slice(0, 120);
4467
4769
  if (this.aboutPrivatePath(title, text, task)) return this.releaseNotice(`task #${task.id}: a ${kind} note was not saved to shared memory (it is about a private path)`);
4468
- this.saveNative(by, kind, text, title, task);
4469
- if (this.d.memory) {
4770
+ const native = this.saveNative(by, kind, text, title, task, undefined, "project", symbols);
4771
+ if (!native && this.d.memory) {
4470
4772
  try { this.markMemoryReference(task, by); } catch { return this.releaseNotice(`task #${task.id}: memory reference could not be recorded; note was not saved`); }
4471
4773
  void this.d.memory.save({ text, title, project: this.d.project, metadata: { peer: by, task: task.id, kind } });
4472
4774
  }