@staix/agent-hub 0.15.0 → 0.16.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 (80) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +12 -8
  3. package/docs/agent-notes/README.md +4 -0
  4. package/docs/agent-notes/adapters.md +6 -3
  5. package/docs/agent-notes/budget.md +4 -2
  6. package/docs/agent-notes/daemon.md +9 -3
  7. package/docs/agent-notes/local-worker.md +2 -2
  8. package/docs/agent-notes/tasks.md +13 -1
  9. package/docs/agent-notes/tests.md +3 -0
  10. package/docs/dogfood.md +13 -4
  11. package/docs/events.md +1 -1
  12. package/docs/memory.md +78 -10
  13. package/docs/operations.md +116 -30
  14. package/docs/quickstart.md +8 -4
  15. package/docs/security.md +80 -18
  16. package/docs/smoke.md +7 -3
  17. package/docs/specs/2026-09-19-agent-hub-design.md +84 -8
  18. package/docs/verification/2026-10-24-always-ask-adapters.md +52 -0
  19. package/docs/verification/2026-10-24-amem-runtime.md +69 -0
  20. package/docs/verification/2026-10-24-codex-0164-reviewer-isolation.md +55 -0
  21. package/docs/verification/2026-10-24-delivery-holds-ac8.md +264 -0
  22. package/docs/verification/2026-10-24-hardcoded-values.md +2108 -0
  23. package/docs/verification/2026-10-25-0.16.0.md +82 -0
  24. package/docs/verified.json +26 -17
  25. package/package.json +2 -2
  26. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  27. package/plugins/agent-hub/server.js +2085 -1702
  28. package/src/adapters/acp.ts +5 -2
  29. package/src/adapters/codex-appserver.ts +69 -1
  30. package/src/adapters/compat.ts +2 -2
  31. package/src/adapters/definitions/builtin-metadata.ts +3 -3
  32. package/src/adapters/local-worker.ts +5 -4
  33. package/src/adapters/registry-types.ts +1 -1
  34. package/src/cli/console-state.ts +20 -2
  35. package/src/cli/console-symbols.ts +3 -2
  36. package/src/cli/console-usage.ts +5 -2
  37. package/src/cli/console.ts +5 -1
  38. package/src/cli/help.ts +7 -5
  39. package/src/cli/identity.ts +3 -1
  40. package/src/cli/main.ts +44 -10
  41. package/src/cli/memory.ts +119 -7
  42. package/src/cli/output.ts +12 -4
  43. package/src/hub/action-class.ts +211 -17
  44. package/src/hub/action-enforce.ts +13 -5
  45. package/src/hub/action-policy.ts +19 -8
  46. package/src/hub/ask.ts +16 -4
  47. package/src/hub/board.ts +86 -31
  48. package/src/hub/budget-notes.ts +65 -0
  49. package/src/hub/budget.ts +42 -10
  50. package/src/hub/bus.ts +1 -1
  51. package/src/hub/code-facts.ts +56 -16
  52. package/src/hub/conductor.ts +5 -2
  53. package/src/hub/config-apply.ts +7 -0
  54. package/src/hub/control-client.ts +1 -1
  55. package/src/hub/daemon.ts +250 -49
  56. package/src/hub/envelope.ts +1 -1
  57. package/src/hub/events.ts +2 -2
  58. package/src/hub/hub-tools.ts +3 -3
  59. package/src/hub/kimi-usage.ts +12 -0
  60. package/src/hub/lifecycle.ts +10 -5
  61. package/src/hub/memory-code-head.ts +44 -0
  62. package/src/hub/memory-code-overlay.ts +119 -0
  63. package/src/hub/memory-plan-guard.ts +10 -1
  64. package/src/hub/memory-stale-anchors.ts +159 -0
  65. package/src/hub/memory-symbols.ts +74 -0
  66. package/src/hub/planning.ts +10 -4
  67. package/src/hub/read-tools.ts +21 -3
  68. package/src/hub/sdlc.ts +34 -0
  69. package/src/hub/settings.ts +17 -2
  70. package/src/hub/settled-roles.ts +15 -3
  71. package/src/hub/task-sweep.ts +25 -3
  72. package/src/hub/tasks.ts +353 -64
  73. package/src/hub/token-window.ts +15 -2
  74. package/src/hub/window-kind.ts +13 -0
  75. package/src/local/tools.ts +5 -5
  76. package/src/memory/brief.ts +22 -12
  77. package/src/memory/capture.ts +26 -40
  78. package/src/memory/client.ts +137 -65
  79. package/src/memory/recall.ts +13 -22
  80. 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";
@@ -653,6 +687,10 @@ export class Tasks {
653
687
  ...(h.supersededBy === undefined ? {} : { supersededBy: screenHistoryText(h.supersededBy, "supersession", h.withheld) }),
654
688
  ...(h.profile === undefined ? {} : { profile: screenHistoryText(h.profile, "profile") }),
655
689
  ...(h.owner === undefined ? {} : { owner: h.owner }),
690
+ ...(h.planner === undefined ? {} : { planner: h.planner }),
691
+ ...(h.plannerReason ? { plannerReason: h.plannerReason } : {}),
692
+ ...(h.planAuthor === undefined ? {} : { planAuthor: h.planAuthor }),
693
+ ...(h.selfReview ? { selfReview: true } : {}),
656
694
  ...(h.reviewerFamily ? { reviewerFamily: screenHistoryText(h.reviewerFamily, "reviewer family") } : {}),
657
695
  ...(h.reviewerReason ? { reviewerReason: h.reviewerReason } : {}),
658
696
  ...(h.unmetCriteria?.length ? { unmetCriteria: h.unmetCriteria } : {}),
@@ -783,7 +821,7 @@ export class Tasks {
783
821
  }
784
822
 
785
823
  /** 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 {
824
+ private requireCapabilities(task: Parameters<typeof assign>[0], peer: PeerId, role: Exclude<TaskAssignmentRole, "planner"> = "owner", preEffect = true): void {
787
825
  const instances = this.d.instances?.(), adapter = (this.d.adapterCatalog?.definitionOf ?? definitionOf)(peer, instances);
788
826
  const declaredExtra = !!adapter && adapter.kind !== peer;
789
827
  if ((!this.d.capabilities || this.isPii(task)) && !declaredExtra) return;
@@ -797,7 +835,7 @@ export class Tasks {
797
835
  /**
798
836
  * #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
837
  * 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.
838
+ * Selection stays in assign(); task bindings only keep that selection while the holder remains eligible.
801
839
  */
802
840
  private plannerHolders(): PeerId[] {
803
841
  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 +844,7 @@ export class Tasks {
806
844
  private weights(cls: TaskClass, task?: Task) {
807
845
  const now = Date.now();
808
846
  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 } } : {}) };
847
+ 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
848
  }
811
849
 
812
850
  /**
@@ -901,13 +939,62 @@ export class Tasks {
901
939
  try { this.d.recordPlanning?.(change); } catch { /* telemetry never changes a durable result */ }
902
940
  }
903
941
 
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");
942
+ /** A removed/off-roster binding falls back to legacy holders; a busy, paused or offline holder keeps authority. */
943
+ taskPlanner(task: Pick<Task, "planner">): PeerId[] {
944
+ const holders = Object.keys(this.d.roles ?? {}).filter(peer => this.d.roles?.[peer]?.includes("planner")).sort();
945
+ return task.planner && holders.includes(task.planner) && this.d.bus.isRostered(task.planner) ? [task.planner] : holders;
946
+ }
947
+
948
+ private planningAuthority(by: PeerId, task?: Pick<Task, "planner">): void {
949
+ 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
950
  }
907
951
 
908
- private plannerWriteAuthority(by: PeerId): void {
952
+ private plannerWriteAuthority(by: PeerId, task?: Pick<Task, "planner">): void {
909
953
  if (by !== USER && this.d.privateTurn?.(by)) throw new PreEffectToolRefusal("planner writes are unavailable during a private turn");
910
- this.planningAuthority(by);
954
+ this.planningAuthority(by, task);
955
+ }
956
+
957
+ /** Every new host proposal, including split children, consumes the same pure walk before its first notification. */
958
+ private proposalPlanner(task: Task): PlannerBinding {
959
+ const choice = assign({ ...task, state: "proposed" }, this.states(), this.d.routing(), { ...this.weights(task.class, task), candidates: [] });
960
+ return { planner: this.isPii(task) ? null : choice.planner ?? null, plannerReason: this.isPii(task) ? "none" : choice.plannerReason ?? "none" };
961
+ }
962
+
963
+ private plannerBinding(task: Task, assignment: Assignment, bindNull = false): PlannerBinding | undefined {
964
+ if (bindNull && task.planner == null) return { planner: this.isPii(task) ? null : assignment.planner ?? null, plannerReason: assignment.plannerReason ?? "none" };
965
+ return task.planner && this.plannerHolders().includes(task.planner) ? { planner: task.planner, plannerReason: "kept" } : undefined;
966
+ }
967
+
968
+ private planAuthor(task: Task): PeerId | null {
969
+ // Review scope bookkeeping preserves the existing plan-content author.
970
+ const authored = task.history.findLast(entry => entry.event !== "plan reviewed" && entry.planRevision !== undefined)
971
+ ?? task.history.findLast(entry => entry.event !== "plan reviewed" && (entry.planning?.fields as readonly string[] | undefined)?.includes("plan"));
972
+ if (authored && authored !== task.history[0]) return authored.by;
973
+ return ALL_PLAN_KEYS.some(key => !!task.plan?.[key]?.length) ? authored?.by ?? task.history[0]?.by ?? null : null;
974
+ }
975
+
976
+ private planReviewAuthority(by: PeerId, task: Task): { planAuthor: PeerId | null; selfReview?: true } {
977
+ this.plannerWriteAuthority(by); // Independent settled holders have review authority only, not the task's other planner powers.
978
+ const author = this.planAuthor(task);
979
+ const independent = this.plannerHolders().filter(peer => peer !== author && this.d.bus.stateOf(peer) !== "offline");
980
+ if (by !== USER && by === author && independent.length) throw new PreEffectToolRefusal("an independent planner reviews this plan");
981
+ return { planAuthor: author, ...(by === author ? { selfReview: true as const } : {}) };
982
+ }
983
+
984
+ private requestIndependentPlanReview(task: Task): void {
985
+ const author = this.planAuthor(task);
986
+ if (!author || !this.taskPlanner(task).includes(author)) return;
987
+ const planners = this.plannerHolders().filter(peer => peer !== author && this.d.bus.stateOf(peer) !== "offline");
988
+ if (!planners.length) return;
989
+ const selected = assign({ ...task, state: "proposed" }, this.states(), this.d.routing(), { ...this.weights(task.class, task), candidates: [], planners, keptPlanner: undefined }).planner;
990
+ if (!selected) return;
991
+ const revision = task.planningRevision ?? 0, signature = `${revision}:${author}:${selected}`;
992
+ if (this.planReviewRequests.get(task.id) === signature) return;
993
+ const view = this.publicView(task);
994
+ 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.`, {
995
+ to: [selected], kind: "task", refs: { task: String(task.id), planReview: revision, planReviewFlow: task.history.length, planAuthor: author }, ...(this.isPii(task) ? { private: true } : {}),
996
+ }));
997
+ if (queued.includes(selected)) this.planReviewRequests.set(task.id, signature);
911
998
  }
912
999
 
913
1000
  wipLimit(peer: PeerId): number {
@@ -966,6 +1053,7 @@ export class Tasks {
966
1053
  const validate = () => {
967
1054
  const task = this.d.board.get(input.id);
968
1055
  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");
1056
+ this.planningAuthority(by, task);
969
1057
  const release = task.release == null ? undefined : this.d.board.release(task.release);
970
1058
  if (task.release != null && (!release || ["frozen", "shipped", "dropped"].includes(release.state))) throw new PreEffectToolRefusal("planner pull requires unfrozen release scope");
971
1059
  if (this.waitsFor(task).length) throw new PreEffectToolRefusal("planner pull waits for dependency approval");
@@ -986,7 +1074,7 @@ export class Tasks {
986
1074
  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
1075
  throw new PreEffectToolRefusal(`${this.hasPersonReservation(task) ? "a person's reservation is retained; " : ""}${refusal}`);
988
1076
  }
989
- return { task, owner: assignment.owner };
1077
+ return { task, owner: assignment.owner, assignment };
990
1078
  };
991
1079
  const initial = validate(), withheld = await this.withholds("note", input.reason, initial.task);
992
1080
  authorized();
@@ -999,7 +1087,7 @@ export class Tasks {
999
1087
  if (checked.task.history.length !== current.task.history.length || checked.owner !== current.owner) throw new PreEffectToolRefusal("planner pull changed during admission");
1000
1088
  } } });
1001
1089
  }
1002
- const pulled = this.d.board.pullPlanned(current.task.id, by, current.owner, input.revision, input.reason.trim(), withheld);
1090
+ 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
1091
  this.planningRecorded({ by, task: pulled.id, fields: ["pull", "reserved"], revision: pulled.planningRevision! });
1004
1092
  return pulled;
1005
1093
  }
@@ -1011,15 +1099,77 @@ export class Tasks {
1011
1099
  private addPlannerBacklog(peer: PeerId, ids: readonly number[]): void {
1012
1100
  if (!this.d.roles?.[peer]?.includes("planner")) return;
1013
1101
  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);
1102
+ for (const id of ids) {
1103
+ const task = this.d.board.get(id);
1104
+ if (task?.state === "backlog" && this.taskPlanner(task).includes(peer)) pending.add(id);
1105
+ }
1015
1106
  if (pending.size) this.plannerBacklog.set(peer, pending);
1016
1107
  }
1017
- private readonly groomingNotified = new Map<string, number>();
1108
+ /**
1109
+ * 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
1110
+ * what it did not write itself and plans (`taskPlanner`, the #525-2 scoping): a planning revision or dependency change (in or outside a
1111
+ * `planning` entry) or the creation of a card is a task notice, replacing a copy that
1112
+ * is still unsettled so the planner meets the latest revision once; a state move, review or assignment is recorded at `fyi` weight (no turn),
1113
+ * 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
1114
+ * (a restart, or the first one after it was attached), as before.
1115
+ */
1116
+ private groomNotices(configured: readonly PeerId[], planners: readonly PeerId[]): void {
1117
+ const scan = this.groomScan, revision = this.d.board.statusRevision(), first = scan.revision < 0;
1118
+ if (scan.revision !== revision) {
1119
+ for (const task of this.d.board.list()) {
1120
+ const seen = scan.lengths.get(task.id), depsKey = JSON.stringify(task.deps ?? []), depsBefore = scan.deps.get(task.id);
1121
+ scan.lengths.set(task.id, task.history.length); scan.deps.set(task.id, depsKey);
1122
+ if (first || seen === task.history.length && depsBefore === depsKey) continue;
1123
+ const holders = this.taskPlanner(task);
1124
+ const tell = (author: string, review: boolean) => {
1125
+ for (const holder of configured) { // every configured planner, so a change made while one was offline is not lost
1126
+ if (holder === author || !holders.includes(holder)) continue;
1127
+ const pending = scan.pending.get(holder) ?? { review: false, other: false };
1128
+ if (review) pending.review = true; else pending.other = true;
1129
+ scan.pending.set(holder, pending);
1130
+ }
1131
+ };
1132
+ // 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.
1133
+ if (seen === undefined) tell(task.history[0]?.by ?? HUB, true);
1134
+ for (const entry of seen === undefined ? task.history.slice(1) : task.history.slice(seen)) {
1135
+ 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
1136
+ tell(entry.by, entry.planning !== undefined);
1137
+ }
1138
+ // Dependencies changed outside a `planning` entry (a propose with `after`, a split, any other write): the same as a planning change.
1139
+ // 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.
1140
+ if (depsBefore !== undefined && depsBefore !== depsKey) tell(seen !== undefined && seen < task.history.length ? task.history.at(-1)!.by : "", true);
1141
+ }
1142
+ scan.revision = revision;
1143
+ }
1144
+ const now = this.d.now?.() ?? Date.now();
1145
+ for (const peer of planners) {
1146
+ const told = scan.sent.get(peer), pending = scan.pending.get(peer);
1147
+ const review = told === undefined || pending?.review === true;
1148
+ if (!review && !pending?.other) continue;
1149
+ if (!review && now - told!.fyiAt < GROOM_FYI_WINDOW_MS) { scan.pending.delete(peer); continue; }
1150
+ this.releaseNotice(`could not notify planner ${peer} about grooming`, () => {
1151
+ if (review) this.sendGroomNotice(peer, revision);
1152
+ 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" }));
1153
+ scan.pending.delete(peer);
1154
+ scan.sent.set(peer, { fyiAt: review ? told?.fyiAt ?? 0 : now });
1155
+ });
1156
+ }
1157
+ }
1158
+
1159
+ /** The task-weight notice: an unsettled earlier copy for this planner is withdrawn and replaced, never a second one queued. */
1160
+ private sendGroomNotice(peer: PeerId, revision: number): void {
1161
+ 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.`;
1162
+ 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);
1163
+ const recipients = this.d.bus.publish(newEnvelope(HUB, body, { to: [peer], kind: "task" }));
1164
+ if (!recipients.includes(peer)) throw new Error("the notice was not queued");
1165
+ }
1166
+
1018
1167
  /** One bounded ids-only batch per attached planner; failed or evicted copies stay pending. */
1019
1168
  flushPlannerNotices(): number {
1020
1169
  const configured = Object.entries(this.d.roles ?? {}).filter(([, roles]) => roles.includes("planner")).map(([peer]) => peer);
1021
1170
  for (const peer of this.knownPlanners) if (!configured.includes(peer)) {
1022
1171
  this.knownPlanners.delete(peer); this.plannerBacklog.delete(peer); this.plannerBlockedQueue.delete(peer);
1172
+ this.groomScan.pending.delete(peer); this.groomScan.sent.delete(peer);
1023
1173
  }
1024
1174
  for (const peer of configured) if (!this.knownPlanners.has(peer)) {
1025
1175
  this.knownPlanners.add(peer);
@@ -1028,18 +1178,7 @@ export class Tasks {
1028
1178
  }
1029
1179
  const planners = configured.filter(peer => this.d.bus.isRostered(peer) && this.d.bus.peers.has(peer) && this.d.bus.stateOf(peer) !== "offline");
1030
1180
  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
- }
1181
+ if ((this.d.sdlc?.config().mode ?? "off") !== "off") this.groomNotices(configured, planners);
1043
1182
  for (const id of this.incomingBacklog) if (this.d.board.get(id)?.state !== "backlog") this.incomingBacklog.delete(id);
1044
1183
  const incoming = [...this.incomingBacklog].sort((a, b) => a - b).slice(0, 64);
1045
1184
  if (incoming.length) {
@@ -1053,10 +1192,17 @@ export class Tasks {
1053
1192
  for (const peer of planners) {
1054
1193
  const pending = this.plannerBacklog.get(peer);
1055
1194
  if (!pending) continue;
1195
+ for (const id of pending) {
1196
+ const task = this.d.board.get(id);
1197
+ if (task?.state !== "backlog") pending.delete(id);
1198
+ else if (!this.taskPlanner(task).includes(peer)) {
1199
+ pending.delete(id);
1200
+ for (const recipient of this.taskPlanner(task)) this.addPlannerBacklog(recipient, [id]);
1201
+ }
1202
+ }
1056
1203
  const blockedQueue = this.plannerBlockedQueue.get(peer);
1057
1204
  if (blockedQueue !== undefined && blockedQueue === JSON.stringify(this.d.bus.queueIds(peer))) continue;
1058
1205
  this.plannerBlockedQueue.delete(peer);
1059
- for (const id of pending) if (this.d.board.get(id)?.state !== "backlog") pending.delete(id);
1060
1206
  const ids = [...pending].sort((a, b) => a - b).slice(0, 64);
1061
1207
  if (!ids.length) continue;
1062
1208
  // Remove before publish: an immediate overflow callback may put this recipient's ids back.
@@ -1113,7 +1259,7 @@ export class Tasks {
1113
1259
  });
1114
1260
  }
1115
1261
 
1116
- async planBacklog(by: PeerId, input: { id: number; revision: number; fields: unknown; reason: string }): Promise<Task> {
1262
+ async planBacklog(by: PeerId, input: { id: number; revision: number; fields?: unknown; reason: string }): Promise<Task> {
1117
1263
  this.planningAuthority(by);
1118
1264
  if (!Number.isSafeInteger(input.id) || input.id <= 0 || !Number.isSafeInteger(input.revision) || input.revision < 0) throw new PreEffectToolRefusal("invalid planning identity or revision");
1119
1265
  if (typeof input.reason !== "string" || !input.reason.trim() || input.reason.length > 1000) throw new PreEffectToolRefusal("planning needs a bounded reason");
@@ -1122,6 +1268,12 @@ export class Tasks {
1122
1268
  const validate = () => {
1123
1269
  const task = this.d.board.get(input.id);
1124
1270
  if (!task || (task.planningRevision ?? 0) !== input.revision) throw new PreEffectToolRefusal("planning revision changed");
1271
+ this.planningAuthority(by, task);
1272
+ if (patch.sdlc !== undefined) {
1273
+ if (task.state !== "backlog" || task.owner || taskHasStarted(task)) throw new PreEffectToolRefusal("issue identity changes require unstarted backlog work");
1274
+ if (by !== USER && this.isPii(task)) throw new PreEffectToolRefusal("private issue identity changes require the person");
1275
+ if (!this.isPii(task) && !this.publicMetadataText(JSON.stringify(patch.sdlc))) throw new PreEffectToolRefusal("planning identity metadata is private or sensitive");
1276
+ }
1125
1277
  if (isClosedTaskState(task.state)) throw new PreEffectToolRefusal("closed work is final");
1126
1278
  if (by !== USER && (task.state !== "backlog" || !taskCanPlan(task) || task.pinned || Object.hasOwn(patch, "pinned"))) throw new PreEffectToolRefusal("planner changes only unpinned backlog work");
1127
1279
  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 +1306,7 @@ export class Tasks {
1154
1306
  for (const id of new Set([task.release, target])) {
1155
1307
  if (id == null) continue;
1156
1308
  const release = this.d.board.release(id)!;
1309
+ if (Object.hasOwn(patch, "release")) this.planningAuthority(by, release);
1157
1310
  if (["shipped", "dropped"].includes(release.state)) throw new PreEffectToolRefusal("settled release scope is final");
1158
1311
  if (by !== USER && release.state === "frozen") throw new PreEffectToolRefusal("frozen release changes require a person");
1159
1312
  }
@@ -1161,8 +1314,11 @@ export class Tasks {
1161
1314
  };
1162
1315
  const task = validate(), withheld = await this.withholds("note", input.reason, task);
1163
1316
  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);
1317
+ const latest = validate(); // a person may have changed planning or frozen a release while the screen ran
1318
+ let receipt: Task["sdlc"];
1319
+ try { receipt = patch.sdlc === undefined ? undefined : planningIssueReceipt(patch.sdlc, latest.sdlc, this.d.sdlc?.config() ?? sdlcConfig(undefined)); }
1320
+ catch (error) { throw new PreEffectToolRefusal((error as Error).message); }
1321
+ const updated = this.d.board.plan(task.id, by, patch, input.revision, input.reason.trim(), withheld, receipt);
1166
1322
  this.planningRecorded({ by, task: task.id, fields: Object.keys(patch), revision: updated.planningRevision! });
1167
1323
  return updated; // persistence/readback failures must never claim no effect
1168
1324
  }
@@ -1171,6 +1327,7 @@ export class Tasks {
1171
1327
  this.planningAuthority(by);
1172
1328
  const task = this.d.board.get(id), entry = task ? planningUndoEntry(task) : undefined;
1173
1329
  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");
1330
+ this.planningAuthority(by, task);
1174
1331
  if (entry.planning.split) {
1175
1332
  const release = task.release == null ? undefined : this.d.board.release(task.release);
1176
1333
  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 +1397,7 @@ export class Tasks {
1240
1397
  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
1398
  const release = parent.release == null ? undefined : this.d.board.release(parent.release);
1242
1399
  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");
1400
+ this.planningAuthority(by, parent);
1243
1401
  const profile = parent.profile ?? "dev";
1244
1402
  const drafts = rows.map((part, index) => {
1245
1403
  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 +1462,12 @@ export class Tasks {
1304
1462
  this.plannerWriteAuthority(by);
1305
1463
  const task = this.d.board.get(input.id);
1306
1464
  if (!task || (task.planningRevision ?? 0) !== input.revision) throw new PreEffectToolRefusal("release assignment changed");
1465
+ this.plannerWriteAuthority(by, task);
1307
1466
  if (by !== USER && (this.isPii(task) || task.pinned)) throw new PreEffectToolRefusal("private or pinned release scope requires the person");
1308
1467
  for (const id of new Set([task.release, input.release])) if (id != null) {
1309
1468
  const release = this.d.board.release(id);
1310
1469
  if (!release || ["frozen", "shipped", "dropped"].includes(release.state)) throw new PreEffectToolRefusal("release scope is frozen or final");
1470
+ this.plannerWriteAuthority(by, release);
1311
1471
  }
1312
1472
  return task;
1313
1473
  };
@@ -1326,6 +1486,7 @@ export class Tasks {
1326
1486
  if (!Number.isSafeInteger(input.id) || input.id <= 0 || !Number.isSafeInteger(input.revision) || input.revision < 0) throw new PreEffectToolRefusal("invalid release identity or revision");
1327
1487
  const release = this.d.board.release(input.id);
1328
1488
  if (!release || release.revision !== input.revision || !["planning", "active"].includes(release.state)) throw new PreEffectToolRefusal("release revision changed or scope frozen");
1489
+ this.plannerWriteAuthority(by, release);
1329
1490
  if (input.state !== undefined && input.state !== "active") throw new PreEffectToolRefusal("invalid train state");
1330
1491
  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
1492
  const dates = [release.plannedStart, input.cut ?? release.cut, release.ship].filter((date): date is number => date !== null);
@@ -1369,6 +1530,7 @@ export class Tasks {
1369
1530
  if (!Number.isSafeInteger(input.id) || input.id <= 0 || !Number.isSafeInteger(input.revision) || input.revision < 0) throw new PreEffectToolRefusal("invalid release identity or revision");
1370
1531
  const release = this.d.board.release(input.id);
1371
1532
  if (!release || release.revision !== input.revision || release.state !== "active") throw new PreEffectToolRefusal("release revision changed or train is not active");
1533
+ this.plannerWriteAuthority(by, release);
1372
1534
  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
1535
  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
1536
  const report = this.releaseReport(by, input.id);
@@ -1412,7 +1574,7 @@ export class Tasks {
1412
1574
  if (!this.nameable(text) || this.screenOn() && (await this.screenCall(text)).label !== "clear") throw new PreEffectToolRefusal("release text was not cleared for public planning");
1413
1575
  this.plannerWriteAuthority(by); // Privacy and planner identity may change while screening.
1414
1576
  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);
1577
+ const release = this.d.board.newRelease(input.name, input.goal.trim(), input.order, by === USER ? null : by);
1416
1578
  this.planningRecorded({ by, release: release.id, fields: ["created"], revision: release.revision });
1417
1579
  return release;
1418
1580
  }
@@ -1421,6 +1583,7 @@ export class Tasks {
1421
1583
  this.planningAuthority(by);
1422
1584
  const release = this.d.board.release(input.id);
1423
1585
  if (!Number.isSafeInteger(input.id) || input.id <= 0 || !Number.isSafeInteger(input.revision) || !release || release.revision !== input.revision) throw new PreEffectToolRefusal("release revision changed");
1586
+ this.planningAuthority(by, release);
1424
1587
  if (["shipped", "dropped"].includes(release.state)) throw new PreEffectToolRefusal("settled release is final");
1425
1588
  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
1589
  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 +1714,7 @@ export class Tasks {
1551
1714
  || typeof input.reason !== "string" || !input.reason.trim() || input.reason.length > 1000) throw new PreEffectToolRefusal("untouched return needs an id, revision and bounded reason");
1552
1715
  const validate = () => {
1553
1716
  const task = this.need(input.id, false, true);
1717
+ this.plannerWriteAuthority(by, task);
1554
1718
  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
1719
  if (by !== USER && task.pinned) throw new PreEffectToolRefusal("pinned work requires the person");
1556
1720
  const release = task.release == null ? undefined : this.d.board.release(task.release);
@@ -1783,9 +1947,18 @@ export class Tasks {
1783
1947
  if (env.from === HUB && env.kind === "task" && env.refs?.backlog) {
1784
1948
  const ids = env.refs.backlog;
1785
1949
  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");
1950
+ const current = ids.filter(id => {
1951
+ const task = this.d.board.get(id);
1952
+ return task?.state === "backlog" && this.taskPlanner(task).includes(peer);
1953
+ });
1787
1954
  if (current.length !== ids.length || !this.d.roles?.[peer]?.includes("planner") || !this.d.bus.isRostered(peer)) {
1788
1955
  this.addPlannerBacklog(peer, current);
1956
+ for (const id of ids) {
1957
+ const task = this.d.board.get(id);
1958
+ if (task?.state === "backlog" && !this.taskPlanner(task).includes(peer)) {
1959
+ for (const recipient of this.taskPlanner(task)) this.addPlannerBacklog(recipient, [id]);
1960
+ }
1961
+ }
1789
1962
  return false;
1790
1963
  }
1791
1964
  return true;
@@ -1793,6 +1966,10 @@ export class Tasks {
1793
1966
  if (env.from === HUB && env.refs?.task) {
1794
1967
  const task = this.d.board.get(Number(env.refs.task));
1795
1968
  if (task?.state === "cancelled") return env.kind === "task" && typeof env.refs.taskCancelled === "number" && env.refs.taskCancelled === task.cancelled?.flowRevision;
1969
+ if (env.refs.taskRecipient === "planner") return !!task && this.taskPlanner(task).includes(peer);
1970
+ if (env.refs.planReview !== undefined) return !!task && !isClosedTaskState(task.state) && (task.planningRevision ?? 0) === env.refs.planReview
1971
+ && this.plannerHolders().includes(peer) && this.planAuthor(task) !== peer && Number.isSafeInteger(env.refs.planReviewFlow)
1972
+ && !task.history.slice(env.refs.planReviewFlow).some(entry => entry.event === "plan reviewed");
1796
1973
  }
1797
1974
  const c = this.conditional.get(env.id);
1798
1975
  if (!c || c.peer !== peer) return true;
@@ -2035,6 +2212,7 @@ export class Tasks {
2035
2212
  }
2036
2213
  task = current; // Reviewer-only and quiet bookkeeping updates do not supersede ownership authority.
2037
2214
  if (gap) {
2215
+ this.requestIndependentPlanReview(current);
2038
2216
  this.releaseNotice(`task ${this.publicTitle(current)}: ${gap}; planner: call hub_planner_groom then hub_plan_review`);
2039
2217
  if (mode === "enforce") {
2040
2218
  if (opts.claim && opts.namedOwner === by) stagedClaimGap = gap;
@@ -2080,10 +2258,11 @@ export class Tasks {
2080
2258
  const lasting = opts.event === "escalated" && task.owner && (opts.reason === "rejections" || opts.reason === "manual");
2081
2259
  opts.plannerPull?.guard();
2082
2260
  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! } : {}) };
2261
+ const binding = this.plannerBinding(task, a, !!opts.plannerPull);
2262
+ const assignmentExtra = { ...this.reviewMetadata(a), ...(binding ? { plannerReason: binding.plannerReason } : {}), ...(profile ? { profile } : {}), ...(opts.reason ? { reason: opts.reason } : {}), ...(lasting ? { from: task.owner! } : {}) };
2084
2263
  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);
2264
+ ? 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)
2265
+ : 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
2266
  if (opts.plannerPull) this.planningRecorded({ by, task: next.id, fields: ["pull", "reserved"], revision: next.planningRevision! });
2088
2267
  // What calibration reads (issue #109): routing chose the first owner (no single named candidate, no claim; not an
2089
2268
  // escalation, relay or reassignment of work already begun), and the work overlaps another owner's task not started
@@ -2367,7 +2546,7 @@ export class Tasks {
2367
2546
  this.releaseNotice(text);
2368
2547
  try { this.d.stallNotice?.(stall); } catch { /* advisory never changes task authority */ }
2369
2548
  // 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)));
2549
+ 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
2550
  }
2372
2551
  async cancel(id: unknown, by: PeerId, input: { reason: string; supersededBy?: string; expectedFlowRevision?: number }): Promise<Task> {
2373
2552
  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 +2557,7 @@ export class Tasks {
2378
2557
  if (isClosedTaskState(task.state)) throw new PreEffectToolRefusal(`task #${task.id} is already ${task.state}`);
2379
2558
  if (by !== USER && this.d.privateTurn?.(by) && !this.isPii(task)) throw new PreEffectToolRefusal("cancellation on ordinary work is unavailable during a private turn");
2380
2559
  if (by !== USER) {
2381
- this.planningAuthority(by);
2560
+ this.planningAuthority(by, task);
2382
2561
  const proposer = task.history.find(h => h.event === "proposed" || h.event === "backlogged")?.by;
2383
2562
  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
2563
  }
@@ -2573,7 +2752,16 @@ export class Tasks {
2573
2752
  if (issue) issues.set(key, issue);
2574
2753
  }
2575
2754
  this.planningAuthority(by);
2576
- const current = this.d.board.list().filter(included), visible = current.filter(task => !this.isPii(task));
2755
+ const all = this.d.board.list(), current = all.filter(included), visible = current.filter(task => !this.isPii(task));
2756
+ // Include only bounded public dependency context, so approved intermediate phases do not disappear from reachability.
2757
+ const indexed = new Map(all.map(task => [task.id, task])), dependencies = new Map<number, readonly number[]>();
2758
+ const pending = visible.slice(0, 500).map(task => task.id).reverse(), visited = new Set<number>();
2759
+ while (pending.length && visited.size < 500) {
2760
+ const id = pending.pop()!; if (visited.has(id)) continue; visited.add(id);
2761
+ const task = indexed.get(id); if (!task || this.isPii(task)) continue;
2762
+ dependencies.set(id, task.deps ?? []); pending.push(...task.deps ?? []);
2763
+ }
2764
+ const dependencyTruncated = pending.some(id => !visited.has(id));
2577
2765
  const peers = [...new Set([...Object.keys(this.states()), ...current.flatMap(task => task.owner ? [task.owner] : [])])];
2578
2766
  const reviewedIssues = new Map(visible.flatMap(task => task.sdlc?.planReview?.issue ? [[task.id, task.sdlc.planReview.issue] as const] : []));
2579
2767
  const acceptedIssues = new Map(visible.flatMap(task => task.sdlc?.acceptance ? [[task.id, task.sdlc.acceptance] as const] : []));
@@ -2587,7 +2775,7 @@ export class Tasks {
2587
2775
  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
2776
  if (this.waitsFor(task).length) blockers.push({ task: task.id, code: "dependency" });
2589
2777
  }
2590
- const digest = plannerGrooming(visible, { repository: this.d.repository ?? `path:${realPath(this.d.cwd)}`,
2778
+ const digest = plannerGrooming(visible, { dependencies, dependencyTruncated, repository: this.d.repository ?? `path:${realPath(this.d.cwd)}`,
2591
2779
  limits: Object.fromEntries(peers.map(peer => [peer, this.wipLimit(peer)])), wip: Object.fromEntries(peers.map(peer => [peer, this.wip(peer)])),
2592
2780
  states, issues, reviewedIssues, acceptedIssues, blockers });
2593
2781
  return { ...digest, truncated: digest.truncated || incomplete, ...(current.some(task => this.isPii(task)) ? { privateTasks: "withheld" } : {}) };
@@ -2770,6 +2958,7 @@ export class Tasks {
2770
2958
  if (Object.keys(raw).some(key => !["revision", "reason", "review", "ci"].includes(key)) || !Number.isSafeInteger(raw.revision) || (raw.revision as number) < 0
2771
2959
  || typeof raw.reason !== "string" || !raw.reason.trim() || raw.reason.length > 1000) throw new PreEffectToolRefusal("plan review needs a current revision and bounded reason");
2772
2960
  const task = this.need(id, false, true, true), revision = raw.revision as number;
2961
+ this.planReviewAuthority(by, task);
2773
2962
  if (isClosedTaskState(task.state)) throw new PreEffectToolRefusal("closed work is final");
2774
2963
  if ((task.planningRevision ?? 0) !== revision) throw new PreEffectToolRefusal("planning revision changed");
2775
2964
  let scope: CostPlan;
@@ -2783,6 +2972,7 @@ export class Tasks {
2783
2972
  const flow = task.history.length, withheld = await this.withholds("note", raw.reason, task);
2784
2973
  this.plannerWriteAuthority(by);
2785
2974
  const observed = this.need(task.id, false, true, true);
2975
+ this.planReviewAuthority(by, observed);
2786
2976
  if (observed.history.length !== flow || (observed.planningRevision ?? 0) !== revision) throw new PreEffectToolRefusal("plan review changed during readback");
2787
2977
  const identity = this.planIssueIdentity(observed);
2788
2978
  if (identity?.issue && this.isPii(observed)) throw new PreEffectToolRefusal("private issue plan review readback unavailable");
@@ -2791,10 +2981,12 @@ export class Tasks {
2791
2981
  if (issue && !this.isPii(task) && !this.nameable(JSON.stringify(issue))) throw new PreEffectToolRefusal("plan review metadata matches a PII pattern");
2792
2982
  this.plannerWriteAuthority(by);
2793
2983
  const current = this.need(task.id, false, true, true);
2984
+ const authorship = this.planReviewAuthority(by, current);
2794
2985
  if (current.history.length !== flow || (current.planningRevision ?? 0) !== revision) throw new PreEffectToolRefusal("plan review changed during readback");
2795
2986
  const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
2796
2987
  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);
2988
+ planReview: { planner: by, ...authorship, planningRevision: revision, recordedAt: Date.now(), ...(Object.keys(scope).length ? { scope } : {}), ...(issue ? { issue } : {}) } } }, raw.reason.trim(), { ...authorship, ...(withheld ? { withheld: true } : {}) }, flow);
2989
+ this.planReviewRequests.delete(updated.id);
2798
2990
  if (config.mode !== "off") updated = this.linkIssue(updated); // #694
2799
2991
  if (updated.state === "proposed" && !updated.owner) {
2800
2992
  this.offered.delete(updated.id);
@@ -2823,19 +3015,22 @@ export class Tasks {
2823
3015
 
2824
3016
  recordLifecycle(by: PeerId, id: unknown, input: unknown, deliverables?: DeliverableRecord[]): Task {
2825
3017
  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
3018
  const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
2828
3019
  const next = sdlcReceipt(input, task.sdlc, config, VERSION);
3020
+ const definition = stageDefinition(config, next.stage);
3021
+ const plannerStage = !!input && typeof input === "object" && !Array.isArray(input) && Object.hasOwn(input, "stage")
3022
+ && Object.keys(input).every(key => ["stage", "repo", "issue"].includes(key))
3023
+ && (definition?.actor === "planner" || definition?.actor === "owner/planner") && this.taskPlanner(task).includes(by);
3024
+ 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
3025
  // The spec identity is person-owned in enforce. No observation cache or restart can reopen a relink bypass.
2830
3026
  const specRelink = (task.sdlc?.repo !== undefined && next.repo !== task.sdlc.repo) || (task.sdlc?.issue !== undefined && next.issue !== task.sdlc.issue);
2831
3027
  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
3028
  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
3029
  // #481 S1: entering a build stage in enforce needs the person's spec acceptance; the person may move any stage.
2835
3030
  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
3031
  if (config.mode === "enforce" && config.definitions && input && typeof input === "object" && "stage" in input && next.stage !== task.sdlc?.stage && by !== USER) {
2837
3032
  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"));
3033
+ 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
3034
  if (!acts) throw new PreEffectToolRefusal(`lifecycle stage requires its ${actor ?? "unknown"} actor or the person`);
2840
3035
  }
2841
3036
  if (input && typeof input === "object" && ("criteria" in input || "phase" in input)) {
@@ -3325,7 +3520,49 @@ export class Tasks {
3325
3520
  const { sha256, id, format, version, path, private: hidden } = shown[i]!;
3326
3521
  return { id, sha256, state: check.state, ...(path ? { path } : {}), format, ...(version ? { version } : {}), ...(hidden ? { private: true as const } : {}) };
3327
3522
  });
3328
- return { state: approved ? "approved" : "delivered", changedSinceReview: files.some(file => file.state === "changed"), files };
3523
+ const changedSinceReview = files.some(file => file.state === "changed");
3524
+ this.deliverableSeen.set(task.id, { key: Tasks.deliverableKey(basis), at: Date.now(), result: deliverableResult(files.map(file => file.state)) });
3525
+ return { state: approved ? "approved" : "delivered", changedSinceReview, files };
3526
+ }
3527
+
3528
+ /** 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. */
3529
+ private deliverableSeen = new Map<number, { key: string; at: number; result: DeliverableResult }>();
3530
+ private static deliverableKey = (records: readonly DeliverableRecord[]) => records.map(record => `${record.id}:${record.sha256}`).join(",");
3531
+
3532
+ /**
3533
+ * #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).
3534
+ * 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).
3535
+ * Every path goes through `publicDeliverables`, so a PII task or a path under `pii.paths` shows id, hash and size only.
3536
+ */
3537
+ async deliverableList(opts: { task?: number; approvedOnly?: boolean; check?: boolean } = {}): Promise<DeliverableRow[]> {
3538
+ const rows: DeliverableRow[] = [];
3539
+ 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);
3540
+ for (const task of tasks) {
3541
+ const delivered = latestDeliverables(task.history);
3542
+ if (!delivered) continue;
3543
+ const approved = task.state === "approved" ? approvedDeliverables(task.history) : undefined;
3544
+ if (opts.approvedOnly && !approved) continue;
3545
+ const basis = approved ?? delivered, shown = publicDeliverables(basis, this.nameable)!;
3546
+ const row: DeliverableRow = { task: task.id, title: this.publicTitle(task), taskState: task.state, state: approved ? "approved" : "delivered", checked: false,
3547
+ files: shown.map(({ id, path, format, version, sha256, bytes, private: hidden }) => ({ id, ...(path ? { path } : {}), format, ...(version ? { version } : {}), sha256, bytes, ...(hidden ? { private: true as const } : {}) })) };
3548
+ if (opts.check) {
3549
+ const status = await this.deliverableStatus(task);
3550
+ 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 })); }
3551
+ }
3552
+ rows.push(row);
3553
+ }
3554
+ return rows;
3555
+ }
3556
+
3557
+ /** What the dashboard card shows: the board's records, plus the last live check when it measured exactly these records. Opens no file. */
3558
+ deliverableBadge(task: Task): { state: "delivered" | "approved"; files: DeliverableRow["files"]; checked?: { at: number; result: DeliverableResult } } | undefined {
3559
+ const delivered = latestDeliverables(task.history);
3560
+ if (!delivered) return undefined;
3561
+ const approved = task.state === "approved" ? approvedDeliverables(task.history) : undefined;
3562
+ const basis = approved ?? delivered, seen = this.deliverableSeen.get(task.id);
3563
+ return { state: approved ? "approved" : "delivered",
3564
+ 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 } : {}) })),
3565
+ ...(seen && seen.key === Tasks.deliverableKey(basis) ? { checked: { at: seen.at, result: seen.result } } : {}) };
3329
3566
  }
3330
3567
 
3331
3568
  /** Check records against the owner's root; with that root unproven every file is `unknown` (never `unchanged`, never an exception). */
@@ -3610,7 +3847,8 @@ export class Tasks {
3610
3847
  const noWrites = this.noWritesRecorded({ ...task, refs: { ...task.refs, ...completedRefs } });
3611
3848
  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
3849
  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);
3850
+ const memorySymbols = this.completionMemorySymbols(next);
3851
+ 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
3852
  this.tellCompleted(next, summary, withheld);
3615
3853
  if (!reviewer) {
3616
3854
  this.settleMemory(next);
@@ -3878,6 +4116,39 @@ export class Tasks {
3878
4116
  } catch { return undefined; }
3879
4117
  }
3880
4118
 
4119
+ /** Read-time code advice uses this project's tree only; declared foreign or inconsistent scope is unknown. */
4120
+ memoryCodeEligible(task: Task): boolean {
4121
+ if (this.isPii(task)) return false;
4122
+ const repository = this.codeSymbolsRepository(task);
4123
+ return !!repository && repository.key === this.repositories.project.key && !this.repositories.mismatch(task.refs, task.plan);
4124
+ }
4125
+
4126
+ /** Read-only host input for exists verification; private/foreign/stale observations supply nothing. */
4127
+ memorySymbolCandidates(task: Task): string[] | undefined {
4128
+ if (this.isPii(task) || !this.d.store) return undefined;
4129
+ const observed = this.observedCodeSymbols(task);
4130
+ return observed?.state === "known" ? observed.symbols.map(symbol => symbol.id) : undefined;
4131
+ }
4132
+
4133
+ /** Only current host observations and positive exists checks may anchor the completion note. */
4134
+ private completionMemorySymbols(task: Task): MemorySymbolNodes {
4135
+ const unknown: MemorySymbolNodes = { state: "unknown", nodes: [], unknown: 0, truncated: false };
4136
+ if (this.isPii(task) || !this.d.store) return unknown;
4137
+ const repository = this.codeSymbolsRepository(task);
4138
+ if (!repository || repository.key !== this.repositories.project.key) return unknown;
4139
+ const observed = publicTaskCodeSymbols(task.codeSymbols, this.reviewHead(task), repository.key, this.publicMetadataText);
4140
+ if (!observed || observed.state !== "known") return unknown;
4141
+ try {
4142
+ const verified = this.d.memorySymbolNodes?.(task);
4143
+ if (!verified) return unknown;
4144
+ const context = { cwd: this.d.cwd, project: this.d.store.project, repo: this.d.store.repo, deny: this.d.store.deny, allowed: this.publicMetadataText };
4145
+ const actual = new Set(observed.symbols.map(symbol => canonicalMemorySymbolNode(symbol.id, context)).filter(Boolean));
4146
+ const nodes = [...new Set(verified.nodes)].filter(node => actual.has(node) && canonicalMemorySymbolNode(node, context) === node).slice(0, MEMORY_SYMBOL_LIMIT);
4147
+ const complete = observed.complete && verified.state === "known" && nodes.length === verified.nodes.length;
4148
+ return { state: complete ? "known" : "unknown", nodes, unknown: verified.unknown, truncated: verified.truncated || nodes.length !== verified.nodes.length };
4149
+ } catch { return unknown; }
4150
+ }
4151
+
3881
4152
  /** Host observations are saved only with an admitted transition, never by an optional read. */
3882
4153
  private observedCodeSymbols(task: Task) {
3883
4154
  if (this.isPii(task) || !task.refs.commit) return undefined;
@@ -4127,7 +4398,7 @@ export class Tasks {
4127
4398
  }
4128
4399
 
4129
4400
  /** 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 {
4401
+ private withdrawRoleRequest(previous: Task, next: Task, role: Exclude<TaskAssignmentRole, "planner">): void {
4131
4402
  const peer = previous[role];
4132
4403
  if (!peer || peer === next[role]) return;
4133
4404
  const kind = role === "owner" ? "task" : "review";
@@ -4151,7 +4422,18 @@ export class Tasks {
4151
4422
 
4152
4423
  /** Caller authority is checked by the daemon; keep the real actor in task history. */
4153
4424
  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");
4425
+ if (role !== "owner" && role !== "reviewer" && role !== "planner") throw new (preEffect ? PreEffectToolRefusal : Error)("role must be owner, reviewer or planner");
4426
+ if (role === "planner") {
4427
+ if (by !== USER) throw new PreEffectToolRefusal("only the person may name a task planner");
4428
+ const task = this.need(id, false, preEffect);
4429
+ this.checkFlowRevision(task, expectedFlowRevision, preEffect);
4430
+ if (isClosedTaskState(task.state) || this.isPii(task)) throw new PreEffectToolRefusal("closed or private work cannot name a planner");
4431
+ const choice = assign({ ...task, state: "proposed" }, this.states(), this.d.routing(), { ...this.weights(task.class, task), candidates: [], namedPlanner: peer });
4432
+ if (choice.planner !== peer) throw new PreEffectToolRefusal("named planner must be a rostered settled planner holder");
4433
+ const next = this.d.board.update(task.id, by, "planner changed", { planner: peer }, "named", { plannerReason: "named" }, expectedFlowRevision);
4434
+ if (next.state === "backlog") { this.incomingBacklog.add(next.id); this.addPlannerBacklog(peer, [next.id]); }
4435
+ return next;
4436
+ }
4155
4437
  if (role === "reviewer" && by !== USER) {
4156
4438
  const conductors = Object.entries(this.d.roles ?? {}).filter(([, roles]) => Array.isArray(roles) && roles.includes("conductor")).map(([actor]) => actor);
4157
4439
  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 +4617,31 @@ export class Tasks {
4335
4617
  const requestCheck = this.requestCheck(by);
4336
4618
  const text = composeRememberText(input);
4337
4619
  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");
4620
+ if (task && this.isPii(task)) throw new Error("notes about a PII task are not saved: shared memory excludes private data");
4339
4621
  // Shared with every other peer as well, so text that matches a PII pattern is refused like a note about a PII task.
4340
4622
  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");
4623
+ 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
4624
  if (input.horizon !== undefined && input.horizon !== "short" && input.horizon !== "long") throw new Error('horizon is "short" or "long"');
4343
4625
  if (input.scope !== undefined && input.scope !== "project" && input.scope !== "shared") throw new Error('scope is "project" or "shared"');
4344
4626
  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.
4627
+ // Long-term notes can leave the machine (#258); local storage does not relax secret exclusion.
4346
4628
  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
4629
  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");
4630
+ 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
4631
  requestCheck();
4350
4632
  const kind = (NOTE_KINDS as readonly string[]).includes(String(input.kind)) ? String(input.kind) as Kind : "finding";
4351
4633
  const native = this.saveNative(by, kind, text, title, task, input.horizon === "long" || input.horizon === "short" ? input.horizon : undefined, input.scope === "shared" ? "shared" : "project");
4352
4634
  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";
4635
+ 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;
4636
+ if (!res && !native) return "memory store unavailable; nothing saved";
4637
+ if (!native && res && typeof res === "object" && "status" in res && res.status === "candidate") {
4638
+ return "saved as a pending memory candidate; a person must review it before it becomes shared evidence";
4639
+ }
4355
4640
  const line = noteLine(by, kind, title ? `${title}: ${text}` : text);
4356
4641
  this.d.notify(line);
4357
4642
  // Display identity only: a queued recall may already contain this saved entry. Stored/captured text stays unchanged.
4358
4643
  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`;
4644
+ 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
4645
  }
4361
4646
 
4362
4647
  /** The task's key in the hub's store: unique across `ahub reset --all`, which restarts the ids. */
@@ -4399,7 +4684,7 @@ export class Tasks {
4399
4684
  * long, decisions, contracts and fails are long-term. Fail-open: a store that cannot be written costs one notice.
4400
4685
  * The caller has screened the text for PII; never a PII task's.
4401
4686
  */
4402
- private saveNative(by: PeerId, kind: Kind, text: string, title: string, task: Task | undefined, horizon?: Tier, scope: Scope = "project"): { id: string; tier: Tier } | undefined {
4687
+ 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
4688
  const st = this.d.store;
4404
4689
  if (!st || (task && this.isPii(task))) return undefined;
4405
4690
  try { // never throws: callers run after a board write
@@ -4407,7 +4692,12 @@ export class Tasks {
4407
4692
  // Approved, or settled in the store already: the snapshot the caller holds can predate an approval made while it awaited.
4408
4693
  const approved = !!task && (task.state === "approved" || st.store.settled(this.memoryKey(task)));
4409
4694
  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 } } : {}) });
4695
+ 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 } } : {}) });
4696
+ // Existing notes remain immutable; the completion note connects actual symbols through the shared task node.
4697
+ if (symbols && task) {
4698
+ const taskNode = `task:${this.memoryKey(task)}`;
4699
+ about.splice(0, about.length, ...[...new Set([taskNode, ...symbols.nodes, ...about])].slice(0, 64).sort());
4700
+ }
4411
4701
  // A short-term note on a task already approved (before hub memory existed, or late): the task is settled first, so
4412
4702
  // the note gets its own deadline and is never taken for a source waiting for promotion.
4413
4703
  if (task && approved && tier === "short" && !st.store.settled(this.memoryKey(task))) st.store.settle(this.memoryKey(task), by);
@@ -4457,16 +4747,15 @@ export class Tasks {
4457
4747
  }
4458
4748
 
4459
4749
  /** Auto notes: only transitions that carry content, never for PII. */
4460
- private note(task: Task, by: PeerId, kind: Kind, text: string, withheld = false): void {
4750
+ private note(task: Task, by: PeerId, kind: Kind, text: string, withheld = false, symbols?: MemorySymbolNodes): void {
4461
4751
  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.
4752
+ // Model-written text must clear the host PII screen (#198/#69); long-term memory may sync (#258). No secrets.
4464
4753
  if (withheld || !this.nameable(text)) return this.releaseNotice(`task #${task.id}: a ${kind} note was not saved to shared memory (${this.whyWithheld(text)})`);
4465
4754
  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
4755
  const title = `agent-hub task #${task.id}: ${task.title}`.slice(0, 120);
4467
4756
  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) {
4757
+ const native = this.saveNative(by, kind, text, title, task, undefined, "project", symbols);
4758
+ if (!native && this.d.memory) {
4470
4759
  try { this.markMemoryReference(task, by); } catch { return this.releaseNotice(`task #${task.id}: memory reference could not be recorded; note was not saved`); }
4471
4760
  void this.d.memory.save({ text, title, project: this.d.project, metadata: { peer: by, task: task.id, kind } });
4472
4761
  }