@staix/agent-hub 0.12.23 → 0.12.25

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 (161) hide show
  1. package/CHANGELOG.md +109 -1
  2. package/README.md +8 -3
  3. package/docs/addons/claude-mem.md +24 -0
  4. package/docs/addons/codex-plugin.md +8 -0
  5. package/docs/addons/dot-admit.md +8 -0
  6. package/docs/addons/open-code-review.md +8 -0
  7. package/docs/addons/ponytail.md +23 -0
  8. package/docs/addons/ripwire.md +22 -0
  9. package/docs/agent-notes/README.md +5 -0
  10. package/docs/agent-notes/adapters.md +10 -1
  11. package/docs/agent-notes/bus.md +3 -1
  12. package/docs/agent-notes/daemon.md +7 -1
  13. package/docs/agent-notes/local-worker.md +4 -2
  14. package/docs/agent-notes/tasks.md +8 -0
  15. package/docs/agent-notes/tests.md +5 -1
  16. package/docs/bench.md +28 -3
  17. package/docs/cooperbench.md +4 -0
  18. package/docs/data-policy.md +36 -0
  19. package/docs/events.md +3 -0
  20. package/docs/gsd-collaboration-analysis.md +48 -0
  21. package/docs/memory.md +231 -0
  22. package/docs/operations.md +666 -19
  23. package/docs/plans/2026-10-11-issues-109-sequential.md +81 -0
  24. package/docs/plans/2026-10-11-routing-evaluation-proposal.md +30 -0
  25. package/docs/profiles.md +57 -0
  26. package/docs/quickstart.md +2 -2
  27. package/docs/research.md +31 -1
  28. package/docs/roles.md +85 -0
  29. package/docs/sdlc.md +170 -0
  30. package/docs/security.md +111 -4
  31. package/docs/smoke.md +288 -0
  32. package/docs/specs/2026-09-19-agent-hub-design.md +633 -7
  33. package/docs/specs/2026-10-12-backlog-implementation-plan.md +186 -0
  34. package/docs/specs/2026-10-12-office-view-design.md +212 -0
  35. package/docs/text-projection.md +19 -0
  36. package/docs/verification/2026-10-11-acp-watch-t0.md +33 -0
  37. package/docs/verification/2026-10-11-agent-config-inventory.md +20 -0
  38. package/docs/verification/2026-10-11-jcode-peer-contract.md +27 -0
  39. package/docs/verification/2026-10-13-0.12.24.md +94 -0
  40. package/docs/verification/2026-10-13-board-phone.md +31 -0
  41. package/docs/verification/2026-10-13-fsmonitor-fixture-attribution.md +19 -0
  42. package/docs/verification/2026-10-14-0.12.24-audit.md +123 -0
  43. package/docs/verification/2026-10-14-0.12.25.md +27 -0
  44. package/docs/verified.json +22 -13
  45. package/package.json +2 -1
  46. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  47. package/plugins/agent-hub/ROLE-NOTICE.md +26 -0
  48. package/plugins/agent-hub/server.js +1175 -339
  49. package/scripts/check-docs.mjs +129 -0
  50. package/src/adapters/acp.ts +112 -13
  51. package/src/adapters/claude-channel.ts +48 -11
  52. package/src/adapters/codex-appserver.ts +11 -0
  53. package/src/adapters/jcode.ts +110 -0
  54. package/src/adapters/local-worker.ts +10 -7
  55. package/src/adapters/pi.ts +171 -23
  56. package/src/cli/attention.ts +60 -0
  57. package/src/cli/bench.ts +24 -5
  58. package/src/cli/board-read.ts +12 -0
  59. package/src/cli/console-state.ts +103 -17
  60. package/src/cli/console.ts +54 -8
  61. package/src/cli/facts-hook.ts +12 -4
  62. package/src/cli/help.ts +35 -9
  63. package/src/cli/identity.ts +18 -7
  64. package/src/cli/init.ts +48 -10
  65. package/src/cli/jcode-mcp.ts +58 -0
  66. package/src/cli/kimi-mcp.ts +53 -0
  67. package/src/cli/launch.ts +16 -1
  68. package/src/cli/main.ts +392 -57
  69. package/src/cli/memory.ts +193 -0
  70. package/src/cli/output.ts +45 -4
  71. package/src/cli/pet-config.ts +1 -0
  72. package/src/cli/pet.ts +83 -0
  73. package/src/cli/preview.ts +38 -5
  74. package/src/cli/status-lines.ts +1 -1
  75. package/src/cli/tail-render.ts +1 -0
  76. package/src/cli/upgrade-runtime.ts +32 -3
  77. package/src/cli/watch.ts +31 -0
  78. package/src/hub/activity.ts +53 -0
  79. package/src/hub/ask.ts +10 -5
  80. package/src/hub/bench.ts +10 -3
  81. package/src/hub/board.ts +222 -16
  82. package/src/hub/bus.ts +48 -9
  83. package/src/hub/citations.ts +7 -0
  84. package/src/hub/conductor.ts +12 -7
  85. package/src/hub/config-trust.ts +4 -2
  86. package/src/hub/crash.ts +9 -0
  87. package/src/hub/daemon.ts +914 -120
  88. package/src/hub/delivery-journal.ts +5 -0
  89. package/src/hub/envelope.ts +1 -1
  90. package/src/hub/events.ts +12 -2
  91. package/src/hub/facts.ts +26 -7
  92. package/src/hub/git.ts +8 -2
  93. package/src/hub/github-import.ts +28 -0
  94. package/src/hub/hub-tools.ts +49 -10
  95. package/src/hub/inference.ts +1 -1
  96. package/src/hub/lifecycle-schema.ts +17 -0
  97. package/src/hub/listen.ts +15 -0
  98. package/src/hub/manager.ts +12 -5
  99. package/src/hub/path-policy.ts +115 -0
  100. package/src/hub/peer-catalog.ts +28 -0
  101. package/src/hub/permission-mode.ts +19 -5
  102. package/src/hub/planning.ts +70 -0
  103. package/src/hub/profiles.ts +74 -0
  104. package/src/hub/projected-diff.ts +73 -0
  105. package/src/hub/projection-exec.ts +24 -0
  106. package/src/hub/research.ts +144 -21
  107. package/src/hub/roles.ts +123 -0
  108. package/src/hub/roster.ts +23 -0
  109. package/src/hub/routing.ts +18 -5
  110. package/src/hub/sdlc-queue.ts +112 -0
  111. package/src/hub/sdlc-release.ts +125 -0
  112. package/src/hub/sdlc.ts +250 -0
  113. package/src/hub/settings.ts +34 -15
  114. package/src/hub/shipped-roles.ts +25 -0
  115. package/src/hub/snapshots.ts +89 -15
  116. package/src/hub/start-mode.ts +7 -5
  117. package/src/hub/supervision.ts +1 -0
  118. package/src/hub/task-sweep.ts +1 -1
  119. package/src/hub/tasks.ts +905 -89
  120. package/src/hub/text-projection.ts +151 -0
  121. package/src/hub/tooling.ts +170 -0
  122. package/src/hub/ui.ts +16 -2
  123. package/src/hub/workflow.ts +71 -0
  124. package/src/local/sandbox.ts +6 -4
  125. package/src/local/tools.ts +11 -6
  126. package/src/memory/brief.ts +28 -14
  127. package/src/memory/import.ts +55 -0
  128. package/src/memory/recall.ts +84 -0
  129. package/src/memory/store.ts +757 -0
  130. package/src/memory/sync.ts +145 -0
  131. package/src/pet/NOTICE.md +10 -0
  132. package/src/pet/characters.ts +1438 -0
  133. package/src/pet/config.ts +31 -0
  134. package/src/pet/frame.ts +17 -0
  135. package/src/pet/pixels.ts +28 -0
  136. package/src/pi/extension.ts +40 -5
  137. package/src/role-text.d.ts +1 -0
  138. package/src/ui/index.html +843 -46
  139. package/src/ui/needs.ts +58 -0
  140. package/src/ui/office-model.ts +440 -0
  141. package/src/ui/peer-palette.ts +16 -0
  142. package/src/ui/task-board.ts +49 -0
  143. package/src/ui/task-progress.ts +5 -1
  144. package/templates/AGENTS.block.md +3 -2
  145. package/templates/config.json +5 -1
  146. package/templates/profiles/dev.json +22 -0
  147. package/templates/profiles/general.json +26 -0
  148. package/templates/profiles/research.json +57 -0
  149. package/templates/profiles/writing.json +28 -0
  150. package/templates/roles/NOTICE.md +26 -0
  151. package/templates/roles/dev/backend-engineer.md +26 -0
  152. package/templates/roles/dev/code-reviewer.md +26 -0
  153. package/templates/roles/dev/frontend-engineer.md +26 -0
  154. package/templates/roles/dev/security-reviewer.md +26 -0
  155. package/templates/roles/dev/software-architect.md +26 -0
  156. package/templates/roles/dev/test-engineer.md +26 -0
  157. package/templates/roles/research/analyst.md +26 -0
  158. package/templates/roles/research/fact-checker.md +26 -0
  159. package/templates/roles/research/research-lead.md +26 -0
  160. package/templates/roles/research/researcher.md +26 -0
  161. package/templates/roles/research/statistician.md +26 -0
package/src/hub/tasks.ts CHANGED
@@ -1,29 +1,57 @@
1
+ import { readGitHubImport, type GitHubSource, type GitHubImportSelection } from "./github-import.ts";
2
+ import { BACKLOG_SPLIT_SCREEN_TIMEOUT_MS, actualTaskDates, planningPatch, type PlanningFields, type Release, type ReleaseState } from "./planning.ts";
3
+ import { readMergeQueue } from "./sdlc-queue.ts";
4
+ import { readReleaseReadiness } from "./sdlc-release.ts";
5
+ import { loadRoleCatalog, taskRole, taskRoleText, type RoleCatalog } from "./roles.ts";
6
+ import { matchesPolicy } from "./path-policy.ts";
7
+ import { mentionsPrivatePath } from "../memory/recall.ts";
8
+ import { profileClass, profileData, profileTaskLabel, projectProfile, type ProfileName, type ProfileSelection, type ProjectProfile } from "./profiles.ts";
9
+ import { phaseView, validatePhaseLinks, workflowHash } from "./workflow.ts";
1
10
  import { isAbsolute, relative } from "node:path";
2
11
  import type { Briefs } from "../memory/brief.ts";
3
12
  import type { MemoryClient } from "../memory/client.ts";
4
- import { CLASSES, OUTCOMES_KEPT_MS, PLAN_KEYS, TASK_MOVE_REASONS, type Board, type Task, type TaskClass, type TaskMoveReason, type TaskPlan, type TaskRefs } from "./board.ts";
13
+ import { extractNodes, looksSecret, PROMOTED_KINDS, taskKey, type GoalView, type Kind, type MemoryStore, type Scope, type Tier } from "../memory/store.ts";
14
+ import { CLASSES, OUTCOMES_KEPT_MS, ALL_PLAN_KEYS, TASK_MOVE_REASONS, taskHasStarted, planningUndoEntry, type HistoryEntry, type Board, type Task, type TaskClass, type TaskMoveReason, type TaskPlan, type TaskRefs } from "./board.ts";
5
15
  import type { Bus } from "./bus.ts";
6
- import { HUB, newEnvelope, NOTE_KINDS, noteLine, USER, type Envelope, type PeerId, type PeerState } from "./envelope.ts";
16
+ import { HUB, newEnvelope, NOTE_KINDS, noteLine, sanitize, USER, type Envelope, type PeerId, type PeerState } from "./envelope.ts";
7
17
  import type { PiiVerdict, ScreenItem, ScreenRecord } from "./inference.ts";
8
18
  import { assign, detectSignals, LOCAL, peerEntry, PI, predictSplit, type Assignment, type Routing, type SplitObservation, type SplitPrediction } from "./routing.ts";
9
19
  import { ExecutionBudget, type ExecutionBudgetConfig, type ExecutionBudgetDecision, type ExecutionBudgetStatus, type ExecutionUnit } from "./execution-budget.ts";
10
20
  import { Cohorts, MAX_REQUESTS, type Cohort, type Completion } from "./cohorts.ts";
11
21
  import { realPath } from "./project.ts";
12
22
  import { PreEffectToolRefusal } from "./tool-refusal.ts";
23
+ import { SUPPORTED_PEERS } from "./peer-catalog.ts";
13
24
  import { nextSweep, taskSweepConfig, type TaskSweepConfig, type SweepRecord } from "./task-sweep.ts";
25
+ import { readSdlcPr, sdlcConfig, sdlcReceipt, sdlcStatus, stageDefinition, lifecycleStage, type SdlcConfig, sdlcGh, type SdlcGh, type SdlcStatus } from "./sdlc.ts";
26
+
27
+ export type TaskAssignmentRole = "owner" | "reviewer";
14
28
 
15
29
  export interface TasksDeps {
30
+ /** Effective per-peer planner capacity, taken from the existing settings loader. */
31
+ wipLimit?: (peer: PeerId) => number;
32
+ recordPlanning?: (change: { by: string; task?: number; release?: number; fields: string[]; revision: number }) => void;
16
33
  board: Board;
17
34
  bus: Bus;
18
35
  routing: () => Routing;
19
36
  cwd: string;
20
37
  project: string;
38
+ profile?: ProfileName;
39
+ profileData?: ProjectProfile;
40
+ piiPaths?: string[];
41
+ catalog?: RoleCatalog;
42
+ /** Role ids whose contract was prepared for the current native session. */
43
+ sessionRoles?: (peer: PeerId) => string[];
44
+ /** Optional bounded snapshot presentation; never stored in task history or memory. */
45
+ projectedReview?: (task: Task) => string | undefined;
46
+ sdlc?: { config: () => SdlcConfig; gh?: SdlcGh };
21
47
  briefs?: Briefs;
22
48
  memory?: MemoryClient;
49
+ /** #258: the hub's own store, the project name it files entries under (the git chain) and the repo `#N` issues belong to. */
50
+ store?: { store: MemoryStore; project: string; repo: string; deny?: string[]; onNote?: (id: string) => void };
23
51
  /** a line for the human: console tail and hub.log */
24
52
  notify: (line: string) => void;
25
53
  /** The completion check configured for a class, if any (local config only; issue #7). */
26
- check?: (cls: TaskClass) => string | undefined;
54
+ check?: (cls: TaskClass, task?: Task) => string | undefined;
27
55
  /** Runs one check; the hub runs them one at a time. */
28
56
  runCheck?: (command: string) => Promise<{ code: number | null; timedOut: boolean; interrupted?: boolean; tail: string }>;
29
57
  /** Hands a saved note to every other peer; it rides on their next delivery. */
@@ -90,7 +118,7 @@ const DEMOTE_AT = 1.5;
90
118
  const CONTRADICTION_WINDOW_MS = 7 * 86_400_000;
91
119
  const OPEN: Task["state"][] = ["proposed", "in_progress", "changes_requested"];
92
120
  /** Board events that leave a task where its completion check found it; any other event means it moved on meanwhile. */
93
- const QUIET_EVENTS = new Set(["answer", "reviewer changed", "idle sweep"]);
121
+ const QUIET_EVENTS = new Set(["answer", "reviewer changed", "idle sweep", "memory reference started"]);
94
122
 
95
123
  /**
96
124
  * Thrown when an assignment's board write is saved but a publish or notify after it failed (#297): the move is
@@ -147,15 +175,15 @@ function cleanRefs(input: unknown): TaskRefs {
147
175
  }
148
176
 
149
177
  /** The same for a plan (issue #31): only its four lists, each of short strings. */
150
- function cleanPlan(input: unknown): TaskPlan {
178
+ function cleanPlan(input: unknown, profile: ProfileSelection = "dev"): TaskPlan {
151
179
  const r = fields(input);
152
- return Object.fromEntries(PLAN_KEYS.map((k) => [k, k === "paths" ? [...new Set(textList(r[k]).map(normPath))] : textList(r[k])] as const).filter(([, v]) => v.length));
180
+ return Object.fromEntries((profileData(profile).plan_keys as (keyof TaskPlan)[]).map((k) => [k, k === "paths" ? [...new Set(textList(r[k]).map(normPath))] : textList(r[k])] as const).filter(([, v]) => v.length));
153
181
  }
154
182
 
155
183
  /** A plan as one line for other owners; the whole plan is on the board. */
156
- const planText = (plan: TaskPlan = {}) => PLAN_KEYS.filter((k) => plan[k]?.length).map((k) => `${k.replace("_", " ")}: ${plan[k]!.join("; ")}`).join(" | ");
184
+ const planText = (plan: TaskPlan = {}) => ALL_PLAN_KEYS.filter((k) => plan[k]?.length).map((k) => `${k.replace("_", " ")}: ${plan[k]!.join("; ")}`).join(" | ");
157
185
  /** What the PII screen reads of a task (#198): the text the patterns are checked against. */
158
- const screenText = (t: { title: string; detail: string; plan?: TaskPlan }) => [t.title, t.detail, planText(t.plan)].join("\n");
186
+ 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");
159
187
  /** Re-screens of one task whose verdict was unknown, per hub run (#198). */
160
188
  const RESCREEN_MAX = 10;
161
189
 
@@ -164,12 +192,23 @@ const RESCREEN_MAX = 10;
164
192
  * PII handling, envelopes, briefs and memory notes are decided in one place.
165
193
  */
166
194
  export class Tasks {
195
+ private readonly incomingBacklog = new Set<number>();
196
+ private readonly plannerBacklog = new Map<PeerId, Set<number>>();
197
+ private readonly plannerBlockedQueue = new Map<PeerId, string>();
198
+ private readonly knownPlanners = new Set<PeerId>();
167
199
  /** Turn-free cohorts (issue #107): who works without messages, and who integrates. */
168
200
  readonly cohorts: Cohorts;
169
201
  private readonly sweepConfig: TaskSweepConfig;
170
202
  private sweeping = false;
171
203
 
172
204
  constructor(private readonly d: TasksDeps) {
205
+ const backlog = d.board.list("backlog");
206
+ for (const [peer, roles] of Object.entries(d.roles ?? {})) {
207
+ if (!roles.includes("planner")) continue;
208
+ this.knownPlanners.add(peer);
209
+ const queued = d.bus.queuedBacklogIds(peer);
210
+ this.addPlannerBacklog(peer, backlog.filter(task => !queued.has(task.id)).map(task => task.id));
211
+ }
173
212
  this.sweepConfig = taskSweepConfig(d.sweep);
174
213
  this.cohorts = new Cohorts({
175
214
  silence: (owners) => this.turnFree() && owners.every((p) => p !== USER && p !== HUB && (this.d.capable?.(p) ?? false)),
@@ -178,6 +217,11 @@ export class Tasks {
178
217
  // What the on-prem worker says about a PII task is private on the bus (console tail and log show a stub), so the
179
218
  // board keeps the text: `ahub task show <id>` is where the console user reads it, a refusal included.
180
219
  d.bus.tap((e) => {
220
+ if (e.t === "overflow" && e.env.from === HUB && e.env.kind === "task" && Array.isArray(e.env.refs?.backlog)) {
221
+ const ids = e.env.refs.backlog;
222
+ if (ids.length <= 64 && ids.every(id => Number.isSafeInteger(id) && id > 0)) this.addPlannerBacklog(e.peer, ids);
223
+ this.plannerBlockedQueue.set(e.peer, JSON.stringify(d.bus.queueIds(e.peer)));
224
+ }
181
225
  // A turn that ends takes its hand-overs with it: busy later is another turn (#115). The adapter's own state, since a
182
226
  // pause shows as `paused` while the turn goes on.
183
227
  if (e.t === "state" && d.bus.peers.get(e.peer)?.state !== "busy") for (const [k, p] of this.sent) if (p === e.peer) this.sent.delete(k);
@@ -190,7 +234,17 @@ export class Tasks {
190
234
  });
191
235
  }
192
236
 
193
- isPii = (task: Pick<Task, "signals">) => task.signals.includes("pii") && this.d.routing().constraints.pii === "local_only";
237
+ isPii = (task: Pick<Task, "signals">) => task.signals.includes("pii_path") || (task.signals.includes("pii") && this.d.routing().constraints.pii === "local_only");
238
+
239
+ private signalsFor(task: Pick<Task, "title" | "detail" | "refs"> & { plan?: TaskPlan }, routing = this.d.routing()) {
240
+ const signals = detectSignals(task, routing, this.d.cwd);
241
+ const paths = [...(task.refs.paths ?? []), ...(task.plan?.paths ?? []), ...(task.plan?.sections ?? []).map(anchor => anchor.split("#")[0]!).filter(Boolean)];
242
+ if (paths.some(path => matchesPolicy(this.d.cwd, path, this.d.piiPaths ?? [], true))) {
243
+ if (!signals.includes("pii")) signals.push("pii");
244
+ signals.push("pii_path");
245
+ }
246
+ return signals;
247
+ }
194
248
 
195
249
  /** `signals.pii_screen = "local"` where the PII constraint applies (issue #198); on without a screener, every verdict is unknown. */
196
250
  private screenOn = (): boolean => {
@@ -203,8 +257,8 @@ export class Tasks {
203
257
  return (await this.d.piiScreen?.(text).catch(() => undefined)) ?? { label: "unknown", miss: "failed", ms: 0 };
204
258
  }
205
259
 
206
- private recordScreen(task: number | undefined, item: ScreenItem, v: PiiVerdict | undefined): void {
207
- const record: ScreenRecord = !v
260
+ private recordScreen(task: number | undefined, item: ScreenItem, v: PiiVerdict | undefined, inherited = false): void {
261
+ const record: ScreenRecord = inherited ? { item, label: "pii", source: "inherited" } : !v
208
262
  ? { item, label: "pii", source: "regex" }
209
263
  : { item, label: v.label === "clear" ? "clear" : "pii", source: v.label === "unknown" ? "unknown" : "screen", ...(v.category ? { category: v.category } : {}), ...(v.miss ? { miss: v.miss } : {}), ms: v.ms };
210
264
  try {
@@ -240,6 +294,7 @@ export class Tasks {
240
294
  if (!finding || this.waitsFor(task).length || this.isChecking(task.id) || this.silentFor(task.id)) continue;
241
295
  const responsible = finding.kind === "review-pending" ? task.reviewer! : task.owner!;
242
296
  if (responsible !== USER && !this.sweepAvailable(responsible)) continue;
297
+ if (finding.step === 3 && finding.kind !== "review-pending" && this.sweepConfig.auto_reassign && this.plannerPullRecord(task)?.pull && !assign(task, this.states(), this.d.routing(), { exclude: [...this.excluded(task), responsible], excludeOwners: this.plannerFull(task), waitsFor: this.waitsFor(task), ...this.weights(task.class) }).owner) continue;
243
298
  const title = this.publicTitle(task);
244
299
  const message = `Task ${title}: ${finding.kind}, idle escalation ${finding.step}/3.`;
245
300
  // Only identifiers, kind and step are kept in the ladder event. A crash after this write and before publish
@@ -255,7 +310,7 @@ export class Tasks {
255
310
  }
256
311
  } else {
257
312
  const reviewer = finding.kind === "review-pending";
258
- const a = assign(task, this.states(), this.d.routing(), { exclude: [...this.excluded(task), responsible], waitsFor: this.waitsFor(task), ...(reviewer ? { candidates: [], notReviewer: task.owner ?? undefined } : {}), ...this.weights(task.class) });
313
+ const a = assign(task, this.states(), this.d.routing(), { exclude: [...this.excluded(task), responsible], excludeOwners: finding.kind !== "review-pending" && this.plannerPullRecord(task)?.pull ? this.plannerFull(task) : [], waitsFor: this.waitsFor(task), ...(reviewer ? { candidates: [], notReviewer: task.owner ?? undefined } : {}), ...this.weights(task.class) });
259
314
  const candidate = reviewer ? a.reviewer : a.owner;
260
315
  this.passedOver(task, a);
261
316
  this.d.notify(`${message} ${reviewer ? "Reviewer" : "Owner"} reassignment suggestion: ${candidate ?? "no eligible peer"}.`);
@@ -313,18 +368,19 @@ export class Tasks {
313
368
 
314
369
  /** A task as a cloud peer may see it. */
315
370
  publicView(task: Task, fullHistory = false): Record<string, unknown> {
316
- const { title, detail, history, ...rest } = task;
317
- if (this.isPii(task)) return { ...rest, refs: {}, plan: {}, title: "[pii]", detail: "[pii]" };
371
+ const { title, detail, history, external, ...rest } = task;
372
+ if (this.isPii(task)) return { ...rest, actualDates: actualTaskDates(task), refs: {}, plan: {}, ...(task.sdlc ? { sdlc: { stage: stageDefinition(this.d.sdlc?.config() ?? sdlcConfig(undefined), task.sdlc.stage)?.base ?? "unknown" } } : {}), title: "[pii]", detail: "[pii]" };
318
373
  const screenHistoryText = (text: string, field: string, withheld = false): string => !withheld && this.nameable(text) ? text : `[history ${field} withheld: ${this.whyWithheld(text)}; ahub task show ${task.id}]`;
319
374
  const publicHistory = (fullHistory ? history : history.slice(-5)).map(h => ({
320
375
  at: h.at, by: h.by, event: h.event,
321
376
  ...(h.note === undefined ? {} : { note: screenHistoryText(h.note, "note", h.withheld) }),
322
377
  ...(h.profile === undefined ? {} : { profile: screenHistoryText(h.profile, "profile") }),
323
378
  ...(h.owner === undefined ? {} : { owner: h.owner }),
379
+ ...(h.planning ? { planning: { revision: h.planning.revision, fields: h.planning.fields, ...(h.planning.split ? { children: h.planning.split.children.map(child => child.id) } : {}) } } : {}),
324
380
  ...(h.reason && TASK_MOVE_REASONS.includes(h.reason) ? { reason: h.reason } : {}),
325
381
  ...(h.sweep ? { sweep: { kind: h.sweep.kind, activity: h.sweep.activity, step: h.sweep.step, at: h.sweep.at } } : {}),
326
382
  }));
327
- return { ...rest, title, detail, history: publicHistory };
383
+ return { ...rest, ...(external && this.nameable(external.url) ? { external } : {}), actualDates: actualTaskDates(task), ...(((this.d.sdlc?.config().mode ?? "off") !== "off" || this.d.sdlc?.config().workflow === "gsd-lite") ? { lifecycle: sdlcStatus(this.d.sdlc?.config() ?? sdlcConfig(undefined), task.sdlc, undefined, this.workflowView(task)) } : {}), title, detail, history: publicHistory };
328
384
  }
329
385
 
330
386
  /** Console results expose direct approval and delivery holds, not just the chosen peer ids. */
@@ -395,7 +451,7 @@ export class Tasks {
395
451
  private health() {
396
452
  const failing = this.d.failing?.();
397
453
  const held = this.d.held?.();
398
- return { ...(failing ? { failing } : {}), ...(held ? { held } : {}), ...(this.d.roles ? { roles: this.d.roles } : {}) };
454
+ return { pullOnly: [...this.d.bus.peers.values()].filter(peer => peer.id === "kimi" && peer.pullOnly).map(peer => peer.id), offRoster: SUPPORTED_PEERS.filter(peer => !this.d.bus.isRostered(peer)), ...(failing ? { failing } : {}), ...(held ? { held } : {}), ...(this.d.roles ? { roles: this.d.roles } : {}) };
399
455
  }
400
456
 
401
457
  /** What assignment weighs besides states and policy: quota, demotion and the review record. */
@@ -492,10 +548,375 @@ export class Tasks {
492
548
  }
493
549
  }
494
550
 
495
- async propose(by: PeerId, input: { title?: string; detail?: string; class?: string; refs?: TaskRefs; plan?: TaskPlan; owner?: PeerId; after?: unknown; urgent?: unknown }): Promise<Task> {
551
+ private planningRecorded(change: { by: string; task?: number; release?: number; fields: string[]; revision: number }): void {
552
+ try { this.d.recordPlanning?.(change); } catch { /* telemetry never changes a durable result */ }
553
+ }
554
+
555
+ private planningAuthority(by: PeerId): void {
556
+ if (by !== USER && !this.d.roles?.[by]?.includes("planner")) throw new PreEffectToolRefusal("planning requires a person or configured planner role");
557
+ }
558
+
559
+ wipLimit(peer: PeerId): number {
560
+ const limit = this.d.wipLimit?.(peer) ?? 1;
561
+ if (!Number.isSafeInteger(limit) || limit < 0 || limit > 32) throw new PreEffectToolRefusal("invalid planner WIP capacity");
562
+ return limit;
563
+ }
564
+ private plannerPullRecord(task: Task): HistoryEntry["planning"] {
565
+ return task.history.findLast(entry => entry.event === "backlog pulled" || entry.event === "backlog returned")?.planning;
566
+ }
567
+ private hasPersonReservation(task: Task): boolean {
568
+ const reservation = task.history.findLast(entry => entry.event === "reserved" || entry.planning?.fields.includes("reserved") && !["backlog pulled", "backlog returned"].includes(entry.event)) ?? (task.history[0]?.event === "backlogged" ? task.history[0] : undefined);
569
+ return !!task.reserved && reservation?.by === USER;
570
+ }
571
+ wip(peer: PeerId, except?: number): number {
572
+ return this.d.board.plannerWip(peer, except);
573
+ }
574
+ private prerequisiteOf(task: Task, prerequisite: number): boolean {
575
+ const pending = [...(task.deps ?? [])], seen = new Set<number>();
576
+ while (pending.length) { const id = pending.pop()!; if (id === prerequisite) return true; if (seen.has(id)) continue; seen.add(id); pending.push(...(this.d.board.get(id)?.deps ?? [])); }
577
+ return false;
578
+ }
579
+ private plannerFull(task: Task): PeerId[] {
580
+ return Object.keys(this.states()).filter(peer => { const queue = this.d.bus.queueSummary(peer); return this.wip(peer, task.id) >= this.wipLimit(peer) || !!queue.heldBy || queue.needsReview > 0; });
581
+ }
582
+ private plannerAssignment(task: Task, reservedOnly = false): Assignment {
583
+ const states = this.states();
584
+ const full = this.plannerFull(task);
585
+ // Preview the To Do transition without routing or offering the persisted backlog row.
586
+ return assign({ ...task, state: "proposed" }, states, this.d.routing(), { exclude: this.excluded(task), excludeOwners: full, ...(reservedOnly && task.reserved && !this.excluded(task).includes(task.reserved) ? { candidates: [task.reserved], namedOwner: task.reserved } : {}), waitsFor: this.waitsFor(task), ...this.weights(task.class) });
587
+ }
588
+
589
+ async pullPlanned(by: PeerId, input: { id: number; revision: number; reason: string }): Promise<Task> {
590
+ this.planningAuthority(by);
591
+ if (!Number.isSafeInteger(input.id) || input.id <= 0 || !Number.isSafeInteger(input.revision) || input.revision < 0 || typeof input.reason !== "string" || !input.reason.trim() || input.reason.length > 1000) throw new PreEffectToolRefusal("planner pull needs an id, revision and bounded reason");
592
+ const validate = () => {
593
+ const task = this.d.board.get(input.id);
594
+ if (!task || (task.planningRevision ?? 0) !== input.revision || task.state !== "backlog" || task.owner || taskHasStarted(task) || task.pinned) throw new PreEffectToolRefusal("planner pull changed, pinned or already started");
595
+ const release = task.release == null ? undefined : this.d.board.release(task.release);
596
+ if (task.release != null && (!release || ["frozen", "shipped", "dropped"].includes(release.state))) throw new PreEffectToolRefusal("planner pull requires unfrozen release scope");
597
+ if (this.waitsFor(task).length) throw new PreEffectToolRefusal("planner pull waits for dependency approval");
598
+ const priority = task.priority ?? (task.signals.includes("urgent") ? 0 : 2);
599
+ if (priority === 0) {
600
+ const goals = this.d.board.list().filter(goal => goal.state !== "approved" && (goal.priority ?? (goal.signals.includes("urgent") ? 0 : 2)) === 0 && goal.rank != null && goal.history.some(entry => entry.by === USER && entry.planning?.fields.includes("rank")));
601
+ const effectiveOrder = (candidate: Task) => goals.filter(goal => goal.id === candidate.id || this.prerequisiteOf(goal, candidate.id)).map(goal => ({ rank: goal.rank!, id: goal.id })).sort((a,b) => a.rank-b.rank || a.id-b.id)[0];
602
+ const order = effectiveOrder(task) ?? { rank: task.rank ?? Number.MAX_SAFE_INTEGER, id: task.id };
603
+ if (this.d.board.list("backlog").some(other => {
604
+ if (other.id === task.id || this.prerequisiteOf(other, task.id) || (other.priority ?? (other.signals.includes("urgent") ? 0 : 2)) !== 0) return false;
605
+ const competing = effectiveOrder(other);
606
+ return !!competing && (competing.rank < order.rank || competing.rank === order.rank && competing.id < order.id);
607
+ })) throw new PreEffectToolRefusal("a person's P0 backlog order is retained");
608
+ }
609
+ const assignment = this.plannerAssignment(task);
610
+ if (!assignment.owner) throw new PreEffectToolRefusal("no peer has eligible planner WIP capacity");
611
+ if (this.hasPersonReservation(task) && assignment.owner !== task.reserved) throw new PreEffectToolRefusal("a person's reservation is retained");
612
+ return { task, owner: assignment.owner };
613
+ };
614
+ const initial = validate(), withheld = await this.withholds("note", input.reason, initial.task);
615
+ const current = validate();
616
+ const pulled = this.d.board.pullPlanned(current.task.id, by, current.owner, input.revision, input.reason.trim(), withheld);
617
+ this.planningRecorded({ by, task: pulled.id, fields: ["pull", "reserved"], revision: pulled.planningRevision! });
618
+ return pulled;
619
+ }
620
+
621
+ backlogList(by: PeerId): Record<string, unknown>[] {
622
+ this.planningAuthority(by);
623
+ return this.d.board.list("backlog").sort((a, b) => (a.rank ?? Number.MAX_SAFE_INTEGER) - (b.rank ?? Number.MAX_SAFE_INTEGER) || a.id - b.id).map(task => this.publicView(task));
624
+ }
625
+ private addPlannerBacklog(peer: PeerId, ids: readonly number[]): void {
626
+ if (!this.d.roles?.[peer]?.includes("planner")) return;
627
+ const pending = this.plannerBacklog.get(peer) ?? new Set<number>();
628
+ for (const id of ids) if (this.d.board.get(id)?.state === "backlog") pending.add(id);
629
+ if (pending.size) this.plannerBacklog.set(peer, pending);
630
+ }
631
+ /** One bounded ids-only batch per attached planner; failed or evicted copies stay pending. */
632
+ flushPlannerNotices(): number {
633
+ const configured = Object.entries(this.d.roles ?? {}).filter(([, roles]) => roles.includes("planner")).map(([peer]) => peer);
634
+ for (const peer of this.knownPlanners) if (!configured.includes(peer)) {
635
+ this.knownPlanners.delete(peer); this.plannerBacklog.delete(peer); this.plannerBlockedQueue.delete(peer);
636
+ }
637
+ for (const peer of configured) if (!this.knownPlanners.has(peer)) {
638
+ this.knownPlanners.add(peer);
639
+ const retained = this.d.bus.queuedBacklogIds(peer);
640
+ this.addPlannerBacklog(peer, this.d.board.list("backlog").filter(task => !retained.has(task.id)).map(task => task.id));
641
+ }
642
+ const planners = configured.filter(peer => this.d.bus.isRostered(peer) && this.d.bus.peers.has(peer) && this.d.bus.stateOf(peer) !== "offline");
643
+ if (!planners.length) return 0;
644
+ for (const id of this.incomingBacklog) if (this.d.board.get(id)?.state !== "backlog") this.incomingBacklog.delete(id);
645
+ const incoming = [...this.incomingBacklog].sort((a, b) => a - b).slice(0, 64);
646
+ if (incoming.length) {
647
+ for (const peer of configured) {
648
+ const queued = this.d.bus.queuedBacklogIds(peer);
649
+ this.addPlannerBacklog(peer, incoming.filter(id => !queued.has(id)));
650
+ }
651
+ }
652
+ for (const id of incoming) this.incomingBacklog.delete(id);
653
+ const notified = new Set<number>();
654
+ for (const peer of planners) {
655
+ const pending = this.plannerBacklog.get(peer);
656
+ if (!pending) continue;
657
+ const blockedQueue = this.plannerBlockedQueue.get(peer);
658
+ if (blockedQueue !== undefined && blockedQueue === JSON.stringify(this.d.bus.queueIds(peer))) continue;
659
+ this.plannerBlockedQueue.delete(peer);
660
+ for (const id of pending) if (this.d.board.get(id)?.state !== "backlog") pending.delete(id);
661
+ const ids = [...pending].sort((a, b) => a - b).slice(0, 64);
662
+ if (!ids.length) continue;
663
+ // Remove before publish: an immediate overflow callback may put this recipient's ids back.
664
+ for (const id of ids) pending.delete(id);
665
+ try {
666
+ const queued = this.d.bus.publish(newEnvelope(HUB, `Backlog items ${ids.map(id => `#${id}`).join(", ")}. Read hub_backlog_list and plan the next work with a reason for each change.`, { to: [peer], kind: "task", refs: { backlog: ids } }));
667
+ if (!queued.includes(peer)) this.addPlannerBacklog(peer, ids);
668
+ for (const id of ids) notified.add(id);
669
+ } catch (error) { this.addPlannerBacklog(peer, ids); throw error; }
670
+ }
671
+ return notified.size;
672
+ }
673
+ releaseList(by: PeerId): Record<string, unknown>[] {
674
+ this.planningAuthority(by);
675
+ const scopes = new Map<number, { id: number; state: Task["state"]; deps: number[] }[]>();
676
+ type Approval = { id: number; release: Task["release"]; doneAt: number | null };
677
+ const approved: Approval[] = [], owned = new Map<number, Approval[]>();
678
+ for (const task of this.d.board.list()) {
679
+ if (typeof task.release === "number") {
680
+ const scope = scopes.get(task.release) ?? [];
681
+ scope.push({ id: task.id, state: task.state, deps: task.deps ?? [] }); scopes.set(task.release, scope);
682
+ }
683
+ if (task.state !== "approved") continue;
684
+ const row = { id: task.id, release: task.release, doneAt: actualTaskDates(task).doneAt };
685
+ approved.push(row);
686
+ if (typeof task.release === "number") { const rows = owned.get(task.release) ?? []; rows.push(row); owned.set(task.release, rows); }
687
+ }
688
+ const compare = (a: Approval, b: Approval) => a.doneAt! - b.doneAt! || a.id - b.id;
689
+ const known = approved.filter(task => task.doneAt !== null).sort(compare), unknown = approved.filter(task => task.doneAt === null);
690
+ const after = (rows: Approval[], cut: number) => {
691
+ let low = 0, high = rows.length;
692
+ while (low < high) { const mid = Math.floor((low + high) / 2); if (rows[mid]!.doneAt! <= cut) low = mid + 1; else high = mid; }
693
+ return low;
694
+ };
695
+ // ponytail: audit previews show 32 ids plus exact counts; add a paginated audit endpoint for exhaustive lists.
696
+ const outsideIds = (rows: Approval[], start: number, release: number) => {
697
+ const result: number[] = [];
698
+ for (let i = start; i < rows.length && result.length < 32; i++) if (rows[i]!.release !== release) result.push(rows[i]!.id);
699
+ return result;
700
+ };
701
+ return this.d.board.releases().map(release => {
702
+ const own = owned.get(release.id) ?? [], ownKnown = own.filter(task => task.doneAt !== null).sort(compare);
703
+ const cutStart = release.cut === null ? known.length : after(known, release.cut);
704
+ const unknownPool = release.cut === null ? approved : unknown;
705
+ return { ...release,
706
+ name: this.nameable(release.name) ? release.name : "[withheld]",
707
+ goal: this.nameable(release.goal) ? release.goal : "[withheld]",
708
+ tasks: scopes.get(release.id) ?? [],
709
+ afterCutUnknown: outsideIds(unknownPool, 0, release.id),
710
+ afterCutUnknownCount: release.cut === null ? approved.length - own.length : unknown.length - (own.length - ownKnown.length),
711
+ afterCut: release.cut === null ? null : outsideIds(known, cutStart, release.id),
712
+ afterCutCount: release.cut === null ? null : known.length - cutStart - (ownKnown.length - after(ownKnown, release.cut)),
713
+ };
714
+ });
715
+ }
716
+
717
+ async planBacklog(by: PeerId, input: { id: number; revision: number; fields: unknown; reason: string }): Promise<Task> {
718
+ this.planningAuthority(by);
719
+ if (!Number.isSafeInteger(input.id) || input.id <= 0 || !Number.isSafeInteger(input.revision) || input.revision < 0) throw new PreEffectToolRefusal("invalid planning identity or revision");
720
+ if (typeof input.reason !== "string" || !input.reason.trim() || input.reason.length > 1000) throw new PreEffectToolRefusal("planning needs a bounded reason");
721
+ let patch: PlanningFields;
722
+ try { patch = planningPatch(input.fields); } catch (error) { throw new PreEffectToolRefusal((error as Error).message); }
723
+ const validate = () => {
724
+ const task = this.d.board.get(input.id);
725
+ if (!task || (task.planningRevision ?? 0) !== input.revision) throw new PreEffectToolRefusal("planning revision changed");
726
+ if (task.state === "approved") throw new PreEffectToolRefusal("approved work is final");
727
+ if (by !== USER && (task.state !== "backlog" || taskHasStarted(task) || task.pinned || Object.hasOwn(patch, "pinned"))) throw new PreEffectToolRefusal("planner changes only unpinned backlog work");
728
+ if (by !== USER && patch.rank !== undefined && task.history.some(entry => entry.by === USER && entry.planning?.fields.includes("rank"))) throw new PreEffectToolRefusal("a person's backlog order is retained");
729
+ if (by !== USER && Object.hasOwn(patch, "reserved") && this.hasPersonReservation(task)) throw new PreEffectToolRefusal("a person's reservation is retained");
730
+ if (Object.hasOwn(patch, "deps") || Object.hasOwn(patch, "reserved")) {
731
+ if (task.state !== "backlog" || taskHasStarted(task) || task.owner) throw new PreEffectToolRefusal("dependency and reservation changes require unstarted backlog work");
732
+ if (patch.reserved && !(SUPPORTED_PEERS as readonly string[]).includes(patch.reserved) && !this.d.bus.knownPeers().includes(patch.reserved) && !this.d.routing().classes[task.class]?.peers.includes(patch.reserved)) throw new PreEffectToolRefusal("reserved owner is not a supported or configured peer");
733
+ if (patch.reserved && !this.d.bus.isRostered(patch.reserved)) throw new PreEffectToolRefusal("reserved owner is off roster");
734
+ const rows = new Map(this.d.board.list().map(row => [row.id, row]));
735
+ const pending = [...(patch.deps ?? task.deps ?? [])], seen = new Set<number>();
736
+ while (pending.length) {
737
+ const id = pending.pop()!;
738
+ if (id === task.id) throw new PreEffectToolRefusal("planning dependencies would form a cycle");
739
+ if (seen.has(id)) continue;
740
+ seen.add(id);
741
+ const dependency = rows.get(id);
742
+ if (!dependency) throw new PreEffectToolRefusal(`no dependency task #${id}`);
743
+ pending.push(...(dependency.deps ?? []));
744
+ }
745
+ }
746
+ const target = patch.release === undefined ? task.release : patch.release;
747
+ if (target != null && !this.d.board.release(target)) throw new PreEffectToolRefusal("no such release");
748
+ for (const id of new Set([task.release, target])) {
749
+ if (id == null) continue;
750
+ const release = this.d.board.release(id)!;
751
+ if (["shipped", "dropped"].includes(release.state)) throw new PreEffectToolRefusal("settled release scope is final");
752
+ if (by !== USER && release.state === "frozen") throw new PreEffectToolRefusal("frozen release changes require a person");
753
+ }
754
+ return task;
755
+ };
756
+ const task = validate(), withheld = await this.withholds("note", input.reason, task);
757
+ validate(); // a person may have changed planning or frozen a release while the screen ran
758
+ const updated = this.d.board.plan(task.id, by, patch, input.revision, input.reason.trim(), withheld);
759
+ this.planningRecorded({ by, task: task.id, fields: Object.keys(patch), revision: updated.planningRevision! });
760
+ return updated; // persistence/readback failures must never claim no effect
761
+ }
762
+
763
+ async undoPlanning(by: PeerId, id: number, revision: number): Promise<Task> {
764
+ this.planningAuthority(by);
765
+ const task = this.d.board.get(id), entry = task ? planningUndoEntry(task) : undefined;
766
+ 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");
767
+ if (entry.planning.split) {
768
+ const release = task.release == null ? undefined : this.d.board.release(task.release);
769
+ 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");
770
+ if (entry.planning.split.children.some(saved => this.d.board.get(saved.id)?.history.some(item => item.event === "memory reference started"))) throw new PreEffectToolRefusal("split children are referenced by persistent long-term memory");
771
+ const childIds = new Set(entry.planning.split.children.map(child => child.id)), budgets = this.d.executionBudget?.status();
772
+ if (Array.isArray(budgets) && budgets.some(budget => budget.kind === "task" && budget.taskId !== undefined && childIds.has(budget.taskId))) throw new PreEffectToolRefusal("split children are referenced by execution budgets");
773
+ if (this.d.store) {
774
+ try { this.d.store.store.refresh(); } catch { throw new PreEffectToolRefusal("split undo requires current memory link evidence"); }
775
+ const children = new Set(entry.planning.split.children.map(child => this.d.board.get(child.id)).filter((child): child is Task => !!child).map(child => this.memoryKey(child)));
776
+ if ([...children].some(key => this.d.store!.store.hasTask(key)) || this.d.store.store.goals().some(goal => goal.links.some(link => children.has(link.task)))) throw new PreEffectToolRefusal("split children are referenced by long-term tasks");
777
+ }
778
+ const restored = this.d.board.undoBacklogSplit(id, by, revision);
779
+ this.planningRecorded({ by, task: id, fields: ["split", "deps"], revision: restored.planningRevision! });
780
+ return restored;
781
+ }
782
+ if (entry.planning.pull) {
783
+ const release = task.release == null ? undefined : this.d.board.release(task.release);
784
+ if (task.state !== "proposed" || task.owner || taskHasStarted(task) || by !== USER && (task.pinned || release && ["frozen", "shipped", "dropped"].includes(release.state))) throw new PreEffectToolRefusal("planner pull undo is no longer eligible");
785
+ const returned = this.d.board.undoPlannedPull(id, by, revision);
786
+ this.offered.delete(id); this.incomingBacklog.add(id);
787
+ this.planningRecorded({ by, task: id, fields: ["pull", "reserved"], revision: returned.planningRevision! });
788
+ return returned;
789
+ }
790
+ return this.planBacklog(by, { id, revision, fields: entry.planning.previous, reason: `undo planning revision ${revision}` });
791
+ }
792
+
793
+ async importGitHub(by: PeerId, input: GitHubImportSelection, run?: SdlcGh): Promise<{ imported: number[]; existing: number[]; partial: boolean; complete: boolean; unconfirmed?: true }> {
794
+ if (by !== USER) throw new PreEffectToolRefusal("GitHub import is a person console command");
795
+ const deadline = performance.now() + 20_000;
796
+ const page = await readGitHubImport(input, run ?? ((args, timeout, _cwd, bound) => sdlcGh(args, timeout, this.d.cwd, bound)));
797
+ const imported: number[] = [], existing: number[] = [];
798
+ for (const issue of page.issues) {
799
+ if (performance.now() >= deadline) return { imported, existing, partial: true, complete: false };
800
+ let created = false;
801
+ try {
802
+ const prior = this.d.board.imported(issue.source.repo, issue.source.issue);
803
+ if (prior) { existing.push(prior.id); continue; }
804
+ const classes = profileData(this.d.profileData ?? this.d.profile ?? "dev").classes;
805
+ const cls = Object.keys(classes).find(name => classes[name] === "implement") ?? Object.keys(classes)[0];
806
+ const task = await this.propose(USER, { title: issue.title, detail: issue.detail, class: cls, backlog: true }, issue.source, () => { created = true; });
807
+ (created ? imported : existing).push(task.id);
808
+ if (performance.now() >= deadline) return { imported, existing, partial: true, complete: false };
809
+ } catch {
810
+ if (created) {
811
+ try {
812
+ const saved = this.d.board.imported(issue.source.repo, issue.source.issue);
813
+ if (saved) { if (!imported.includes(saved.id)) imported.push(saved.id); if (saved.state === "backlog") this.incomingBacklog.add(saved.id); return { imported, existing, partial: true, complete: false }; }
814
+ } catch { /* A failed readback leaves the committed identity unconfirmed. */ }
815
+ }
816
+ return { imported, existing, partial: true, complete: false, unconfirmed: true };
817
+ }
818
+ }
819
+ return { imported, existing, partial: page.partial, complete: !page.partial };
820
+ }
821
+
822
+ async splitBacklog(by: PeerId, input: { id: number; revision: number; parts: unknown; reason: string }): Promise<{ parent: Task; children: Task[] }> {
823
+ this.planningAuthority(by);
824
+ const { id, revision } = input;
825
+ if (!Number.isSafeInteger(id) || id <= 0 || !Number.isSafeInteger(revision) || revision < 0 || typeof input.reason !== "string" || !input.reason.trim() || input.reason.length > 1000) throw new PreEffectToolRefusal("split needs an id, revision and bounded reason");
826
+ const reason = input.reason.trim();
827
+ let parts: unknown;
828
+ try { parts = structuredClone(input.parts); } catch { throw new PreEffectToolRefusal("split parts must be cloneable data"); }
829
+ if (!Array.isArray(parts) || parts.length < 2 || parts.length > 8) throw new PreEffectToolRefusal("split needs two to eight parts");
830
+ const rows = parts as Record<string, unknown>[];
831
+ const validate = () => {
832
+ const parent = this.d.board.get(id);
833
+ 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");
834
+ const release = parent.release == null ? undefined : this.d.board.release(parent.release);
835
+ 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");
836
+ const profile = parent.profile ?? "dev";
837
+ const drafts = rows.map((part, index) => {
838
+ 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");
839
+ const given = part.class ?? parent.profileClass ?? parent.class;
840
+ const storedProfile = !parent.profile ? "dev" : profile === (this.d.profile ?? "dev") ? this.d.profileData ?? profile : profile;
841
+ let cls: TaskClass;
842
+ try { cls = part.class === undefined ? parent.class : profileClass(given as string, storedProfile); } catch (error) { throw new PreEffectToolRefusal((error as Error).message); }
843
+ const after = part.after ?? [], afterParts = part.afterParts ?? [];
844
+ if (!Array.isArray(after) || after.length > 64 || after.some(dep => !Number.isSafeInteger(dep) || dep <= 0) || new Set(after).size !== after.length || !Array.isArray(afterParts) || afterParts.length > 8 || afterParts.some(dep => !Number.isSafeInteger(dep) || dep < 0 || dep >= index) || new Set(afterParts).size !== afterParts.length) throw new PreEffectToolRefusal("split dependencies must name existing tasks or earlier parts");
845
+ const deps = [...new Set([...(parent.deps ?? []), ...after as number[]])], pending = [...deps], seen = new Set<number>();
846
+ const direct = afterParts.length ? deps.filter(dep => !parent.deps?.includes(dep)) : deps;
847
+ if (direct.length + afterParts.length > 64) throw new PreEffectToolRefusal("split child dependencies exceed the 64-edge cap");
848
+ while (pending.length) { const dependency = pending.pop()!; if (dependency === id) throw new PreEffectToolRefusal("split dependency reaches its parent"); if (seen.has(dependency)) continue; seen.add(dependency); const task = this.d.board.get(dependency); if (!task) throw new PreEffectToolRefusal("split dependency is missing"); pending.push(...(task.deps ?? [])); }
849
+ const refs = part.refs === undefined ? parent.refs : cleanRefs(part.refs), plan = cleanPlan(part.plan, storedProfile);
850
+ const draft = { title: part.title.replace(/\s+/g, " ").trim(), detail: part.detail as string | undefined ?? "", class: cls, refs, plan, deps, afterParts: afterParts as number[], ...(parent.role && cls === parent.class ? { role: parent.role } : {}), ...(parent.profile ? { profile: parent.profile, profileClass: given as string, ...(parent.profile === "dev" ? { customProfile: true as const } : {}) } : {}) };
851
+ const fields: PlanningFields = {};
852
+ for (const key of ["priority", "release", "plannedStart", "due"] as const) if (parent[key] !== undefined) fields[key] = parent[key];
853
+ return { ...draft, fields };
854
+ });
855
+ return { parent, drafts };
856
+ };
857
+ const initial = validate(), screening = this.screenOn();
858
+ const initialPrivacy = initial.drafts.map(part => this.signalsFor({ ...part, detail: [part.detail, planText(part.plan)].join("\n") }).filter(signal => signal === "pii" || signal === "pii_path"));
859
+ const initialPrivate = initialPrivacy.map(signals => this.isPii({ signals })), initialParentPrivate = this.isPii(initial.parent);
860
+ const initialParentPrivacy = initial.parent.signals.filter(signal => signal === "pii" || signal === "pii_path");
861
+ const initialReasonPrivate = initialParentPrivate || !this.nameable(reason);
862
+ let deadline: ReturnType<typeof setTimeout> | undefined;
863
+ const attempt = Promise.all([
864
+ Promise.all(initial.drafts.map((part, index) => screening && !initialParentPrivate && !initialPrivate[index] ? this.screenCall(screenText({ title: part.title, detail: part.detail, plan: part.plan })) : undefined)),
865
+ this.withholds("note", reason, initial.parent),
866
+ ]);
867
+ let screened: Awaited<typeof attempt>;
868
+ try { screened = await Promise.race([attempt, new Promise<never>((_, reject) => { deadline = setTimeout(() => reject(new PreEffectToolRefusal("split screening request deadline exceeded")), BACKLOG_SPLIT_SCREEN_TIMEOUT_MS); })]); }
869
+ finally { if (deadline !== undefined) clearTimeout(deadline); }
870
+ const [screenedVerdicts, withheld] = screened, current = validate();
871
+ const finalSignals = current.drafts.map(part => this.signalsFor({ ...part, detail: [part.detail, planText(part.plan)].join("\n") }));
872
+ const deterministicPrivate = finalSignals.map((signals, index) => initialPrivate[index] || this.isPii({ signals }));
873
+ const verdicts = screenedVerdicts.map((verdict, index) => deterministicPrivate[index] ? undefined : verdict);
874
+ const inherited = current.drafts.map((_part, index) => (initialParentPrivate || this.isPii(current.parent)) && !deterministicPrivate[index]);
875
+ const drafts = current.drafts.map((part, index) => ({ ...part, ...(screening ? { screeningNote: inherited[index] ? "pii: inherited" : this.screenNote(verdicts[index]) } : {}), signals: [...new Set([...initialPrivacy[index]!, ...initialParentPrivacy, ...finalSignals[index]!, ...current.parent.signals.filter(signal => ["pii", "pii_path", "urgent"].includes(signal)), ...(verdicts[index] && verdicts[index]!.label !== "clear" ? ["pii"] : [])])] }));
876
+ const result = this.d.board.splitBacklog(id, by, revision, drafts, reason, withheld || initialReasonPrivate || this.isPii(current.parent) || !this.nameable(reason));
877
+ for (const [index, child] of result.children.entries()) {
878
+ this.incomingBacklog.add(child.id);
879
+ if (screening) { this.recordScreen(child.id, "task", verdicts[index], inherited[index]); if (verdicts[index]?.label === "unknown") this.releaseNotice(`task #${child.id}: the PII screen gave no verdict (${verdicts[index]!.miss}); it is handled as a PII task`); }
880
+ }
881
+ this.planningRecorded({ by, task: id, fields: ["split", "deps"], revision: result.parent.planningRevision! });
882
+ return result;
883
+ }
884
+
885
+ async newRelease(by: PeerId, input: { name: string; goal: string; order: number }): Promise<Release> {
886
+ this.planningAuthority(by);
887
+ if (typeof input.name !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(input.name) || typeof input.goal !== "string" || !input.goal.trim() || input.goal.length > 1000 || !Number.isSafeInteger(input.order) || input.order < 0) throw new PreEffectToolRefusal("invalid release name, goal or order");
888
+ const text = input.name + "\n" + input.goal;
889
+ if (!this.nameable(text) || this.screenOn() && (await this.screenCall(text)).label !== "clear") throw new PreEffectToolRefusal("release text was not cleared for public planning");
890
+ if (this.d.board.releases().some(release => release.name === input.name)) throw new PreEffectToolRefusal("release name already exists");
891
+ const release = this.d.board.newRelease(input.name, input.goal.trim(), input.order);
892
+ this.planningRecorded({ by, release: release.id, fields: ["created"], revision: release.revision });
893
+ return release;
894
+ }
895
+
896
+ changeRelease(by: PeerId, input: { id: number; revision: number; state?: ReleaseState; plannedStart?: number | null; cut?: number | null; ship?: number | null }): Release {
897
+ this.planningAuthority(by);
898
+ const release = this.d.board.release(input.id);
899
+ if (!Number.isSafeInteger(input.id) || input.id <= 0 || !Number.isSafeInteger(input.revision) || !release || release.revision !== input.revision) throw new PreEffectToolRefusal("release revision changed");
900
+ if (["shipped", "dropped"].includes(release.state)) throw new PreEffectToolRefusal("settled release is final");
901
+ if (by !== USER && (release.state === "frozen" || input.state === "frozen" || input.state === "shipped" || input.state === "dropped")) throw new PreEffectToolRefusal("freeze, ship, drop and frozen changes require a person");
902
+ if (input.state === "shipped" && this.d.board.list().some(task => task.release === release.id && task.state !== "approved")) throw new PreEffectToolRefusal("every release task must be approved or moved out before ship");
903
+ const { id, revision, ...patch } = input;
904
+ if (!Object.keys(patch).length || Object.keys(patch).some(key => !["state", "plannedStart", "cut", "ship"].includes(key))) throw new PreEffectToolRefusal("unknown or empty release fields");
905
+ const updated = this.d.board.updateRelease(id, patch, revision);
906
+ this.planningRecorded({ by, release: id, fields: Object.keys(patch), revision: updated.revision });
907
+ return updated; // a failed readback may follow a durable release write
908
+ }
909
+
910
+ async propose(by: PeerId, input: { title?: string; detail?: string; class?: string; role?: unknown; refs?: TaskRefs; plan?: TaskPlan; owner?: PeerId; after?: unknown; urgent?: unknown; backlog?: unknown; goal?: unknown }, external?: GitHubSource, created?: () => void): Promise<Task> {
911
+ external = external ? { ...external } : undefined;
912
+ if (external && (by !== USER || input.backlog !== true)) throw new PreEffectToolRefusal("external intake requires person-owned backlog staging");
913
+ if (input.backlog !== undefined && typeof input.backlog !== "boolean") throw new PreEffectToolRefusal("backlog must be a boolean");
914
+ const backlog = input.backlog === true;
915
+ if (backlog && input.owner && !this.d.bus.isRostered(input.owner)) throw new PreEffectToolRefusal(`${input.owner} is off roster; add it before reserving work`);
496
916
  // Callers are models: a title is one line (it is part of console and hub.log lines), not a document.
497
917
  const title = String(input.title ?? "").replace(/\s+/g, " ").trim().slice(0, 300);
498
918
  if (!title) throw new PreEffectToolRefusal("title is required");
919
+ const goal = input.goal == null || input.goal === "" ? undefined : this.goalFor(by, input.goal);
499
920
  // Dependencies can only name tasks that exist, so the new task closes no cycle: nothing can depend on it yet.
500
921
  // Callers are models: a missing or null `after` is no dependency, and an id is an integer or a digit string.
501
922
  let afterIds: unknown[] = [];
@@ -509,40 +930,54 @@ export class Tasks {
509
930
  if (!deps.includes(id)) deps.push(id);
510
931
  }
511
932
  // An owner for work that waits is reserved (issue #207): routing offers it to that peer first once it is ready.
512
- const reserved = this.waitsFor({ deps }).length ? input.owner : undefined;
933
+ const reserved = backlog || this.waitsFor({ deps }).length ? input.owner : undefined;
513
934
  const given = input.class === undefined || input.class === "" ? undefined : input.class;
514
- if (given !== undefined && !CLASSES.includes(given as TaskClass)) throw new PreEffectToolRefusal(`class must be one of ${CLASSES.join(", ")}`);
935
+ const profile = this.d.profile ?? "dev";
936
+ let baseGiven: TaskClass | undefined;
937
+ try { if (given !== undefined) baseGiven = profileClass(given, this.d.profileData ?? profile); } catch (error) { throw new PreEffectToolRefusal((error as Error).message); }
515
938
  const text = { title, detail: String(input.detail ?? "").slice(0, 8000), refs: cleanRefs(input.refs) };
516
- const plan = cleanPlan(input.plan);
939
+ const plan = cleanPlan(input.plan, this.d.profileData ?? profile);
517
940
  // The plan reaches other owners, so a PII pattern in it makes the task a PII task like one in the detail would.
518
- const signals = detectSignals({ ...text, detail: [text.detail, planText(plan)].join("\n") }, this.d.routing(), this.d.cwd);
941
+ const screenDetail = [text.detail, external?.repo, external?.url].filter(value => value !== undefined && value !== "").join("\n");
942
+ const signals = this.signalsFor({ ...text, detail: [screenDetail, planText(plan)].join("\n"), plan });
519
943
  // Urgent work is handed over even when its paused owner's window resets soon (issue #36).
520
944
  if (input.urgent === true && !signals.includes("urgent")) signals.push("urgent");
521
945
  // The PII screen (issue #198) reads the same text before anything else does: until its verdict the task is on no
522
946
  // board, in no envelope and before no other model. A pattern match is PII without a call; no verdict is PII too.
523
947
  const screening = this.screenOn();
524
- const screened = screening && !signals.includes("pii") ? await this.screenCall(screenText({ ...text, plan })) : undefined;
948
+ const screened = screening && !signals.includes("pii") ? await this.screenCall(screenText({ ...text, detail: screenDetail, plan })) : undefined;
525
949
  if (screened && screened.label !== "clear") signals.push("pii");
526
- let cls = given as TaskClass | undefined;
950
+ let cls = baseGiven;
527
951
  let triaged = false;
528
952
  let fenced = false; // a PII task off campus: triage is not asked
529
953
  if (!cls && this.d.triage) {
530
954
  // Signals first: a PII task's text may only go to a model that is reached without leaving the campus network.
531
- const pii = signals.includes("pii") && this.d.routing().constraints.pii === "local_only";
955
+ const pii = this.isPii({ signals });
532
956
  if (!pii || (await this.d.triage.onCampus().catch(() => false))) cls = await this.d.triage.classify(text.title, text.detail).catch(() => undefined);
533
957
  else fenced = true;
534
958
  triaged = !!cls;
535
959
  }
536
960
  // A claim is work the caller will do itself: without a class and a model to name one, it is implementation (#6).
537
- const defaulted = !cls && input.owner === by;
961
+ const defaulted = !backlog && !cls && input.owner === by;
538
962
  if (defaulted) cls = "implement";
539
963
  if (!cls) throw new PreEffectToolRefusal(`class is required (one of ${CLASSES.join(", ")}); ${fenced ? "the task is handled as PII and the hub's model is not reached on campus, so it was not asked to name one" : "the hub could not name one for you"}`);
540
- const draft = { ...text, class: cls };
541
- let task = this.d.board.propose(by, { ...draft, plan, ...(deps.length ? { deps } : {}), ...(reserved ? { reserved } : {}), signals });
964
+ let selected;
965
+ try { selected = taskRole(input.role, cls, this.d.catalog ?? loadRoleCatalog(this.d.cwd)); }
966
+ catch (error) { throw new PreEffectToolRefusal((error as Error).message); }
967
+ const draft = { ...text, class: cls, ...(selected ? { role: selected.id } : {}), ...(profile !== "dev" || given !== undefined && given !== cls || this.d.profileData && this.d.profileData !== projectProfile("dev") ? { profile, profileClass: given ?? cls, ...(profile === "dev" ? { customProfile: true as const } : {}) } : {}) };
968
+ if (external) { const prior = this.d.board.imported(external.repo, external.issue); if (prior) return prior; }
969
+ let task = this.d.board.propose(by, { ...draft, plan, ...(external ? { external } : {}), ...(deps.length ? { deps } : {}), ...(reserved ? { reserved } : {}), signals, ...(backlog ? { backlog: true } : {}) });
970
+ created?.();
542
971
  if (screening) task = this.screenedTask(task, screened);
972
+ if (goal) this.linkNative(task, goal, by);
543
973
  if (triaged) task = this.d.board.update(task.id, "hub", "triaged", {}, `class ${cls} named by the hub's model`);
544
974
  if (defaulted) task = this.d.board.update(task.id, "hub", "class defaulted", {}, "class implement for a claim without one");
545
- this.d.notify(`task ${this.publicTitle(task)} proposed by ${by} [${task.class}]${task.signals.length ? ` signals: ${task.signals.join(", ")}` : ""}`);
975
+ if (backlog) {
976
+ this.incomingBacklog.add(task.id);
977
+ this.releaseNotice(`task ${this.publicTitle(task)} backlogged by ${by} [${profileTaskLabel(task)}]`);
978
+ return task;
979
+ }
980
+ this.d.notify(`task ${this.publicTitle(task)} proposed by ${by} [${profileTaskLabel(task)}]${task.signals.length ? ` signals: ${task.signals.join(", ")}` : ""}`);
546
981
  // Re-read: a dependency approved while triage was awaited looked for its dependents before this row existed.
547
982
  const still = this.waitsFor(task);
548
983
  if (still.length) {
@@ -550,14 +985,37 @@ export class Tasks {
550
985
  return this.d.board.update(task.id, HUB, "blocked", {}, `waits for ${still.map((id) => `#${id}`).join(", ")}`);
551
986
  }
552
987
  // Naming yourself is a claim: the work is already yours, so no offer comes back to you (paper: Agensh CLAIM, #68).
553
- return this.assignOwner(task, by, input.owner ? { candidates: [input.owner], claim: input.owner === by } : {});
988
+ return this.assignOwner(task, by, input.owner ? { candidates: [input.owner], namedOwner: input.owner, claim: input.owner === by } : {});
989
+ }
990
+
991
+ /** Person-owned staging only. Flow transitions remain with their existing owners and reviewers. */
992
+ async moveBacklog(by: PeerId, id: unknown, pull: boolean): Promise<Task> {
993
+ if (by !== USER) throw new PreEffectToolRefusal("backlog pull/return is a console command");
994
+ if (typeof pull !== "boolean") throw new PreEffectToolRefusal("pull must be a boolean");
995
+ const n = typeof id === "number" ? id : typeof id === "string" && /^\s*\d+\s*$/.test(id) ? Number(id) : NaN;
996
+ if (!Number.isSafeInteger(n) || n <= 0) throw new PreEffectToolRefusal("id must be a positive task number");
997
+ const task = this.d.board.get(n);
998
+ if (!task) throw new PreEffectToolRefusal(`no task #${n}`);
999
+ if (task.state !== (pull ? "backlog" : "proposed") || task.owner) throw new PreEffectToolRefusal(`task #${n}: ${pull ? "only backlog may be pulled" : "only ownerless proposed work may return to backlog"}`);
1000
+ if (!pull && taskHasStarted(task)) throw new PreEffectToolRefusal(`task #${n}: started work cannot return to backlog`);
1001
+ const pulled = task.history.findLast(entry => entry.event === "backlog pulled" || entry.event === "backlog returned");
1002
+ const restoreReservation = !pull && pulled?.planning?.pull && !task.history.slice(task.history.lastIndexOf(pulled) + 1).some(entry => entry.event === "reserved" || entry.planning?.fields.includes("reserved"));
1003
+ const next = this.d.board.update(n, by, pull ? "backlog pulled" : "backlog returned", { state: pull ? "proposed" : "backlog", owner: null, reviewer: null, ...(restoreReservation ? { reserved: pulled.planning!.previous.reserved ?? null } : {}) });
1004
+ this.offered.delete(n);
1005
+ if (!pull) { this.incomingBacklog.add(n); return next; }
1006
+ const waits = this.waitsFor(next);
1007
+ if (waits.length) return this.d.board.update(n, HUB, "blocked", {}, `waits for ${waits.map(v => `#${v}`).join(", ")}`);
1008
+ // This persisted ready marker also lets a restart offer ownerless work once a peer attaches.
1009
+ const ready = this.d.board.update(n, HUB, "ready", {}, "pulled from backlog");
1010
+ return this.assignOwner(ready, by);
554
1011
  }
555
1012
 
556
1013
  /** Where a screened task's `pii` signal came from, on its history and in events (issue #198); no verdict is a console line by id. */
1014
+ private screenNote(v: PiiVerdict | undefined): string { return !v ? "pii: regex" : v.label === "clear" ? "clear" : v.label === "pii" ? `pii: screen, ${v.category}` : `pii: unknown, ${v.miss}`; }
557
1015
  private screenedTask(task: Task, v: PiiVerdict | undefined): Task {
558
1016
  this.recordScreen(task.id, "task", v);
559
1017
  if (v?.label === "unknown") this.d.notify(`task #${task.id}: the PII screen gave no verdict (${v.miss}); it is handled as a PII task`);
560
- const note = !v ? "pii: regex" : v.label === "clear" ? "clear" : v.label === "pii" ? `pii: screen, ${v.category}` : `pii: unknown, ${v.miss}`;
1018
+ const note = this.screenNote(v);
561
1019
  return this.d.board.update(task.id, HUB, "screened", {}, note);
562
1020
  }
563
1021
 
@@ -653,6 +1111,38 @@ export class Tasks {
653
1111
  /** The tasks an overlap text for `task` names: their plans are in it (issue #108). */
654
1112
  overlapTasks = (task: Task): number[] => this.overlapHits(task).map((h) => h.task.id);
655
1113
 
1114
+ /**
1115
+ * Dashboard warning links (#264): the board's overlap pairs and the live cohorts, as task and peer ids only.
1116
+ * Paths, symbols and plans stay out; PII tasks are excluded by the same rules overlapHits applies.
1117
+ * ponytail: O(open²) board reads per call, fine at dashboard board sizes; cache by board revision if it ever shows.
1118
+ */
1119
+ overlapLinks(): { tasks: [number, number]; peers: string[] }[] {
1120
+ const links: { tasks: [number, number]; peers: string[] }[] = [];
1121
+ const seen = new Set<string>();
1122
+ for (const task of this.d.board.list()) {
1123
+ if (!OPEN.includes(task.state) || !task.owner || this.isPii(task)) continue;
1124
+ for (const hit of this.overlapHits(task)) {
1125
+ const pair = [task.id, hit.task.id].sort((a, b) => a - b) as [number, number];
1126
+ const key = pair.join(":");
1127
+ if (seen.has(key)) continue;
1128
+ seen.add(key);
1129
+ links.push({ tasks: pair, peers: [...new Set([task.owner, hit.task.owner!])].sort() });
1130
+ }
1131
+ }
1132
+ return links;
1133
+ }
1134
+
1135
+ /** The live cohorts as dashboard links (#264): member task ids and their owners, nothing else. */
1136
+ cohortLinks(): { tasks: number[]; peers: string[] }[] {
1137
+ const links: { tasks: number[]; peers: string[] }[] = [];
1138
+ for (const cohort of this.cohorts.list()) {
1139
+ const members = [...cohort.members.values()].filter(member => { const task = this.d.board.get(member.task); return task && !this.isPii(task); });
1140
+ if (members.length < 2) continue;
1141
+ links.push({ tasks: members.map((m) => m.task).sort((a, b) => a - b), peers: [...new Set(members.map((m) => m.owner))].sort() });
1142
+ }
1143
+ return links;
1144
+ }
1145
+
656
1146
  /** While a gone owner's tasks move, its other tasks are about to move too: they are no one to settle with. */
657
1147
  private releasing: PeerId | undefined;
658
1148
 
@@ -681,6 +1171,16 @@ export class Tasks {
681
1171
  * for a recorded notice to this peer whose task is gone, has another owner, or left the states it was about.
682
1172
  */
683
1173
  relevant = (peer: PeerId, env: Envelope): boolean => {
1174
+ if (env.from === HUB && env.kind === "task" && env.refs?.backlog) {
1175
+ const ids = env.refs.backlog;
1176
+ if (!Array.isArray(ids) || !ids.length || ids.length > 64 || ids.some(id => !Number.isSafeInteger(id) || id <= 0)) return false;
1177
+ const current = ids.filter(id => this.d.board.get(id)?.state === "backlog");
1178
+ if (current.length !== ids.length || !this.d.roles?.[peer]?.includes("planner") || !this.d.bus.isRostered(peer)) {
1179
+ this.addPlannerBacklog(peer, current);
1180
+ return false;
1181
+ }
1182
+ return true;
1183
+ }
684
1184
  const c = this.conditional.get(env.id);
685
1185
  if (!c || c.peer !== peer) return true;
686
1186
  const t = this.d.board.get(c.task);
@@ -714,7 +1214,7 @@ export class Tasks {
714
1214
  */
715
1215
  private places(task: Task): { paths: string[]; symbols: string[] } {
716
1216
  const paths = [...(task.refs.paths ?? []), ...(task.plan?.paths ?? [])].map((p) => this.projectPath(p));
717
- return { paths: [...new Set(paths)], symbols: task.plan?.symbols ?? [] };
1217
+ return { paths: [...new Set(paths)], symbols: [...(task.plan?.symbols ?? []), ...(task.plan?.sections ?? []).map(v => `section ${v}`), ...(task.plan?.sources ?? []).map(v => `source ${v}`)] };
718
1218
  }
719
1219
 
720
1220
  private roots?: string[];
@@ -738,7 +1238,7 @@ export class Tasks {
738
1238
  return this.d.board.list().flatMap((t) => {
739
1239
  if (t.id === task.id || !t.owner || t.owner === task.owner || t.owner === this.releasing || !OPEN.includes(t.state) || this.isPii(t)) return [];
740
1240
  const theirs = this.places(t);
741
- const paths = mine.paths.filter((p) => theirs.paths.some((q) => samePlace(p, q)));
1241
+ const paths = task.profile && t.profile && (task.plan?.sections?.length && t.plan?.sections?.length) ? [] : mine.paths.filter((p) => theirs.paths.some((q) => samePlace(p, q)));
742
1242
  const symbols = mine.symbols.filter((x) => theirs.symbols.includes(x));
743
1243
  return paths.length || symbols.length ? [{ task: t, paths, symbols }] : [];
744
1244
  });
@@ -778,7 +1278,7 @@ export class Tasks {
778
1278
  // Awaits run between tasks (briefs, memory): re-read, and stop for anything that changed meanwhile.
779
1279
  const task = this.d.board.get(id);
780
1280
  if (!task || task.owner !== peer || !OPEN.includes(task.state) || this.d.bus.stateOf(peer) !== "offline") continue;
781
- if (!assign(task, this.states(), this.d.routing(), { exclude: [...this.excluded(task), peer], ...this.health() }).owner) continue;
1281
+ if (!assign(task, this.states(), this.d.routing(), { exclude: [...this.excluded(task), peer], excludeOwners: this.plannerPullRecord(task)?.pull ? this.plannerFull(task) : [], ...this.health() }).owner) continue;
782
1282
  try {
783
1283
  const back = task.state === "in_progress" ? this.d.board.update(task.id, HUB, "released", { state: "proposed" }, why) : task;
784
1284
  const next = await this.assignOwner(back, HUB, { exclude: [peer], event: "reassigned", reason: "offline", note: why });
@@ -802,18 +1302,20 @@ export class Tasks {
802
1302
 
803
1303
  /** Same code as assignment, without doing it. `routing`: a candidate policy to explain under (the settings preview, #269). */
804
1304
  explain(target: number | { title: string; detail?: string; class: TaskClass; refs?: TaskRefs }, routing: Routing = this.d.routing()): string[] {
1305
+ const workflow = this.d.sdlc?.config().workflow === "gsd-lite" ? [`workflow: gsd-lite (${this.d.sdlc.config().mode}); checklist guidance, board dependencies authoritative`] : [];
805
1306
  if (typeof target === "number") {
806
1307
  const task = this.d.board.get(target);
807
1308
  if (!task) throw new Error(`no task #${target}`);
808
- const a = assign(task, this.states(), routing, { exclude: this.excluded(task), waitsFor: this.waitsFor(task), ...this.weights(task.class) });
1309
+ const a = assign(task, this.states(), routing, { exclude: this.excluded(task), excludeOwners: this.plannerPullRecord(task)?.pull ? this.plannerFull(task) : [], waitsFor: this.waitsFor(task), ...this.weights(task.class) });
809
1310
  // The split trace is for the pair the record is about: the task's owner when it has one (issue #109).
810
- return [`task ${this.publicTitle(task)} (${task.state}, owner ${task.owner ?? "none"}${task.reserved ? `, reserved for ${task.reserved}` : ""})`, "if it were assigned now:", ...a.trace, ...(this.splitShadow(task, task.owner ?? a.owner)?.trace ?? [])];
1311
+ return [...workflow, `task ${this.publicTitle(task)} (${task.state}, owner ${task.owner ?? "none"}${task.reserved ? `, reserved for ${task.reserved}` : ""})`, ...(task.profileClass ? [`profile class ${profileTaskLabel(task)}`] : []), ...(task.role ? [`task role ${task.role} (guidance only)`] : []), "if it were assigned now:", ...a.trace, ...(this.splitShadow(task, task.owner ?? a.owner)?.trace ?? [])];
811
1312
  }
812
1313
  const draft = { title: target.title, detail: target.detail ?? "", refs: target.refs ?? {} };
813
- const signals = detectSignals(draft, routing, this.d.cwd);
814
- const trace = assign({ class: target.class, signals }, this.states(), routing, this.weights(target.class)).trace;
1314
+ const signals = this.signalsFor(draft, routing);
1315
+ const base = profileClass(target.class, this.d.profileData ?? this.d.profile);
1316
+ const trace = [...(this.d.profile && this.d.profile !== "dev" ? [`profile class ${target.class} (base ${base}, ${this.d.profile})`] : []), ...assign({ class: base, signals }, this.states(), routing, this.weights(base)).trace];
815
1317
  // A draft is not sent to the PII screen (issue #198): a proposal is, and a pii or unknown verdict adds the pii signal.
816
- return this.screenOn() && !signals.includes("pii") ? [...trace, "pii_screen is on: at proposal a pii or unknown verdict adds signal pii (local only)"] : trace;
1318
+ return [...workflow, ...(this.screenOn() && !signals.includes("pii") ? [...trace, "pii_screen is on: at proposal a pii or unknown verdict adds signal pii (local only)"] : trace)];
817
1319
  }
818
1320
 
819
1321
  /** Public read path: every generated line is screened, including profile/config-derived trace fields. */
@@ -855,9 +1357,12 @@ export class Tasks {
855
1357
  }
856
1358
 
857
1359
  /** `override`: a person's explicit assign, which past refusals do not block and which drops an agent's reservation (#207). */
858
- private async assignOwner(task: Task, by: PeerId, opts: { candidates?: PeerId[]; event?: string; note?: string; reason?: TaskMoveReason; clearOnFail?: boolean; exclude?: PeerId[]; context?: string; contextWithheld?: boolean; claim?: boolean; override?: boolean } = {}): Promise<Task> {
1360
+ private async assignOwner(task: Task, by: PeerId, opts: { candidates?: PeerId[]; namedOwner?: PeerId; event?: string; note?: string; reason?: TaskMoveReason; clearOnFail?: boolean; exclude?: PeerId[]; context?: string; contextWithheld?: boolean; claim?: boolean; override?: boolean; roster?: boolean; planner?: boolean } = {}): Promise<Task> {
1361
+ if (task.state === "backlog") throw new PreEffectToolRefusal(`task #${task.id}: pull backlog work before assigning it`);
859
1362
  const waits = this.waitsFor(task);
860
- const a = assign(task, this.states(), this.d.routing(), { exclude: [...(opts.override ? [] : this.excluded(task)), ...(opts.exclude ?? []), ...(opts.event === "escalated" && task.owner ? [task.owner] : [])], ...(opts.candidates ? { candidates: opts.candidates } : {}), waitsFor: waits, ...this.weights(task.class) });
1363
+ const plannedMove = opts.planner || this.plannerPullRecord(task)?.pull === true && (by === HUB || opts.roster) && (opts.reason !== "manual" || opts.roster);
1364
+ const plannerFull = plannedMove ? this.plannerFull(task) : [];
1365
+ const a = assign(task, this.states(), this.d.routing(), { exclude: [...(opts.override ? [] : this.excluded(task)), ...(opts.exclude ?? []), ...(opts.event === "escalated" && task.owner ? [task.owner] : [])], excludeOwners: plannerFull, ...(opts.candidates ? { candidates: opts.candidates } : {}), waitsFor: waits, ...this.weights(task.class), ...(opts.namedOwner ? { namedOwner: opts.namedOwner } : {}) });
861
1366
 
862
1367
  if (waits.length) {
863
1368
  this.releaseNotice(`task ${this.publicTitle(task)} waits for ${waits.map((id) => `#${id}`).join(", ")}; it is offered once they are approved`);
@@ -865,7 +1370,9 @@ export class Tasks {
865
1370
  }
866
1371
  const passed = this.passedOver(task, a);
867
1372
  if (!a.owner) {
868
- this.releaseNotice(`task ${this.publicTitle(task)}: no peer can take it (${a.trace.filter((l) => l.includes("skipped")).length} skipped); assign with: ahub task assign ${task.id} <peer>`);
1373
+ this.releaseNotice(opts.roster
1374
+ ? `task ${this.publicTitle(task)}: ${task.owner} is off roster and no peer can take it; it stays with ${task.owner} in ${task.state}; assign with: ahub task assign ${task.id} <peer>`
1375
+ : `task ${this.publicTitle(task)}: no peer can take it (${a.trace.filter((l) => l.includes("skipped")).length} skipped); assign with: ahub task assign ${task.id} <peer>`);
869
1376
  // Only a decline takes the task away from its owner; a failed console assign or escalation leaves it where it was.
870
1377
  if (opts.clearOnFail && task.owner) {
871
1378
  this.cohorts.leave(task.id);
@@ -877,7 +1384,7 @@ export class Tasks {
877
1384
  // A person's explicit assign replaces an agent's reservation (#207). Only a refusal keeps the owner it moves away from
878
1385
  // out for good: a reviewer's rejections or an escalation by hand, never the hub's own move after a failed delivery.
879
1386
  const lasting = opts.event === "escalated" && task.owner && (opts.reason === "rejections" || opts.reason === "manual");
880
- const next = this.d.board.update(task.id, by, opts.event ?? "assigned", { owner: a.owner, reviewer: a.reviewer ?? null, ...(opts.event === "escalated" ? { rejections: 0 } : {}), ...(opts.override && task.reserved ? { reserved: null } : {}) }, `${opts.note ?? `to ${a.owner}`}${passed}`, { ...(profile ? { profile } : {}), ...(opts.reason ? { reason: opts.reason } : {}), ...(lasting ? { from: task.owner! } : {}) });
1387
+ const next = this.d.board.update(task.id, by, opts.event ?? "assigned", { owner: a.owner, reviewer: a.reviewer ?? null, ...(opts.roster && task.state === "in_progress" ? { state: "proposed" as const } : {}), ...(opts.event === "escalated" ? { rejections: 0 } : {}), ...(opts.override && task.reserved ? { reserved: null } : {}) }, `${opts.note ?? `to ${a.owner}`}${passed}`, { ...(profile ? { profile } : {}), ...(opts.reason ? { reason: opts.reason } : {}), ...(lasting ? { from: task.owner! } : {}) });
881
1388
  // What calibration reads (issue #109): routing chose the first owner (no single named candidate, no claim; not an
882
1389
  // escalation, relay or reassignment of work already begun), and the work overlaps another owner's task not started
883
1390
  // yet. For the record only. Work routed back to its proposer is left out, as its observations are (by === owner).
@@ -925,25 +1432,61 @@ export class Tasks {
925
1432
  return next;
926
1433
  }
927
1434
 
1435
+ /** Person-only advice changes never change ownership, class or authority. */
1436
+ setRole(by: PeerId, id: unknown, raw: unknown): Task {
1437
+ if (by !== USER) throw new PreEffectToolRefusal("only the person may change an existing task catalog role");
1438
+ if (raw === undefined) throw new PreEffectToolRefusal("name a catalog role id or explicit null to clear it");
1439
+ const task = this.need(id, false, true);
1440
+ if (task.state === "approved") throw new PreEffectToolRefusal("an approved task's role is historical; propose new work instead");
1441
+ let selected;
1442
+ try { selected = taskRole(raw === null ? undefined : raw, task.class, this.d.catalog ?? loadRoleCatalog(this.d.cwd)); }
1443
+ catch (error) { throw new PreEffectToolRefusal((error as Error).message); }
1444
+ if (task.role === selected?.id) return task;
1445
+ const next = this.d.board.update(task.id, by, "catalog role changed", { role: selected?.id });
1446
+ for (const peer of new Set([next.owner, next.state === "in_review" ? next.reviewer : null])) {
1447
+ if (!peer || peer === USER) continue;
1448
+ this.releaseNotice(`task ${this.publicTitle(next)}: catalog role update delivery unconfirmed`, () => this.d.bus.publish(newEnvelope(HUB,
1449
+ `Task #${next.id}: catalog role changed by the person. ${next.role ? this.roleGuidance(next, peer, peer === next.reviewer) : "Catalog guidance cleared; follow the task's existing plan and requirements."}`,
1450
+ { to: [peer], kind: "task", refs: { task: String(next.id) }, ...(this.isPii(next) ? { private: true } : {}) })));
1451
+ }
1452
+ return next;
1453
+ }
1454
+
1455
+ /** A self-claim has no assignment envelope; its tool result carries the same bounded guidance. */
1456
+ claimRoleGuidance(task: Task, peer: PeerId): string {
1457
+ return task.owner === peer && task.state === "in_progress" ? this.roleGuidance(task, peer) : "";
1458
+ }
1459
+
1460
+ private roleGuidance(task: Task, peer: PeerId, review = false): string {
1461
+ if (!task.role) return "";
1462
+ if (!review && this.d.sessionRoles?.(peer).includes(task.role)) return `Task role ${task.role} (already in your session guidance).`;
1463
+ const role = (this.d.catalog ?? loadRoleCatalog(this.d.cwd)).roles.get(task.role);
1464
+ if (role) try { return taskRoleText(role, review); } catch { /* a changed definition cannot break a saved assignment */ }
1465
+ return `Task role ${task.role}: definition unavailable or exceeds the task guidance limit; grants no authority.`;
1466
+ }
1467
+
928
1468
  /** `contextWithheld`: the PII screen did not clear a budget hand-off (#198); an escalation's reason is the hub's own words. */
929
1469
  private async sendTask(task: Task, a: Assignment, context?: string, overlap = "", contextWithheld = false): Promise<void> {
930
1470
  const pii = this.isPii(task);
931
1471
  const brief = pii ? undefined : await this.d.briefs?.forTask(task.owner!, task).catch(() => undefined);
932
1472
  const rejected = task.history.filter((h) => h.event === "changes_requested").map((h) => `- ${h.by}: ${this.screen(task, h.note ?? "", "review note", a.owner, h.withheld)}`);
933
1473
  const handoff = context && !pii ? this.screen(task, context, "handoff", a.owner, contextWithheld).slice(0, 3000) : "";
934
- const facts = [`class ${task.class}`, a.owner === PI ? `backend pi/${a.piBackend ?? "dgx"}` : "", task.refs.paths?.length ? `paths ${task.refs.paths.join(", ")}` : "", task.refs.branch ? `branch ${task.refs.branch}` : "", a.reviewer ? `reviewer ${a.reviewer}` : task.class === "review" ? "no reviewer" : this.noReviewer(a)].filter(Boolean).join("; ");
1474
+ const facts = [`class ${profileTaskLabel(task)}`, a.owner === PI ? `backend pi/${a.piBackend ?? "dgx"}` : "", task.refs.paths?.length ? `paths ${task.refs.paths.join(", ")}` : "", task.refs.branch ? `branch ${task.refs.branch}` : "", a.reviewer ? `reviewer ${a.reviewer}` : task.class === "review" ? "no reviewer" : this.noReviewer(a)].filter(Boolean).join("; ");
935
1475
  const plan = planText(task.plan);
936
1476
  const body = [
937
- `Task #${task.id} [${task.class}] ${task.title}`,
1477
+ `Task #${task.id} [${profileTaskLabel(task)}] ${task.title}`,
938
1478
  task.detail,
939
1479
  `Facts: ${facts}`,
1480
+ this.roleGuidance(task, task.owner!),
940
1481
  plan ? `Plan so far: ${plan}` : "",
941
1482
  overlap,
942
1483
  rejected.length ? `Earlier review notes:\n${rejected.join("\n")}` : "",
943
1484
  brief ?? "",
944
1485
  // What the previous owner left behind. Peer-written free text: never attached to a PII task.
945
1486
  handoff ? `Handoff from the previous owner:\n${handoff}` : "",
946
- `Take it with hub_task_accept {id: ${task.id}, plan: {paths, symbols, signatures, insertion_points}} (what you will change, before you start${pii ? "" : "; owners of overlapping tasks see it"}) or pass with hub_task_decline. When finished: hub_task_done {id: ${task.id}, summary: what changed, why, and the check you ran with its result, refs}.`,
1487
+ task.state === "in_review"
1488
+ ? `Ownership moved while this task is already under review. Read it with hub_task_show {id: ${task.id}} and wait for the review verdict; do not accept it or repeat completed work.`
1489
+ : `Take it with hub_task_accept {id: ${task.id}, plan: {paths, symbols, signatures, insertion_points}} (what you will change, before you start${pii ? "" : "; owners of overlapping tasks see it"}) or pass with hub_task_decline. When finished: hub_task_done {id: ${task.id}, summary: what changed, why, and the check you ran with its result, refs}.`,
947
1490
  ].filter(Boolean).join("\n\n");
948
1491
  // A hand-over the owner takes in a turn of its own (#109, #115): idle before, busy after and not queued, this envelope
949
1492
  // was in the delivery that started its turn. Busy before, it is queued, or steered into a turn about something else
@@ -1010,8 +1553,9 @@ export class Tasks {
1010
1553
  */
1011
1554
  async releaseReady(): Promise<void> {
1012
1555
  for (const t of this.d.board.list("proposed")) {
1013
- const last = t.history.at(-1)?.event;
1014
- if (!t.deps?.length || t.owner || this.offered.has(t.id) || (last !== "blocked" && last !== "ready" && last !== "reserved") || this.waitsFor(t).length) continue;
1556
+ const last = t.history.findLast(entry => !QUIET_EVENTS.has(entry.event))?.event, planned = this.plannerPullRecord(t)?.pull === true;
1557
+ if ((!t.deps?.length && !t.history.some(h => h.event === "backlog pulled")) || t.owner || this.offered.has(t.id) || (!planned && last !== "blocked" && last !== "ready" && last !== "reserved") || this.waitsFor(t).length) continue;
1558
+ if (planned && !this.plannerAssignment(t, true).owner) continue;
1015
1559
  // ponytail: a reserved owner (#207) that attaches after another peer that can take the task is passed over here like
1016
1560
  // an offline one; hold reserved tasks for a grace period if restarts show that up.
1017
1561
  if (!assign(t, this.states(), this.d.routing(), { exclude: this.excluded(t), ...this.health() }).owner) continue; // nobody attached can take it yet
@@ -1025,11 +1569,14 @@ export class Tasks {
1025
1569
  try {
1026
1570
  const t = this.d.board.get(stale.id);
1027
1571
  if (!t || t.state !== "proposed" || t.owner || this.offered.has(t.id)) return;
1572
+ const planned = this.plannerPullRecord(t)?.pull === true;
1573
+ const selected = planned ? this.plannerAssignment(t, true).owner : undefined;
1574
+ if (planned && !selected) return;
1028
1575
  this.offered.add(t.id);
1029
1576
  offering = true;
1030
1577
  const ready = t.history.at(-1)?.event === "ready" ? t : this.d.board.update(t.id, HUB, "ready", {}, why);
1031
1578
  this.releaseNotice(`task ${this.publicTitle(ready)} is ready: what it waited for is approved`);
1032
- await this.assignOwner(ready, HUB);
1579
+ await this.assignOwner(ready, HUB, planned ? { candidates: [selected!], namedOwner: selected!, planner: true } : {});
1033
1580
  } catch (e) {
1034
1581
  // A saved move whose delivery failed is not "could not be assigned" (#297); the console notice was printed
1035
1582
  // where the error was thrown. The offer stays consumed by the error's own evidence, without the readback.
@@ -1051,12 +1598,88 @@ export class Tasks {
1051
1598
  if (by !== USER && task[role] !== by) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id}: only its ${role} (${task[role] ?? "none"}) or the console user can do that`);
1052
1599
  }
1053
1600
 
1601
+ /** Artifact links and claimed local gates stay on the board; no outside text is accepted. */
1602
+ recordLifecycle(by: PeerId, id: unknown, input: unknown): Task {
1603
+ const task = this.need(id, false, true);
1604
+ if (by !== USER && by !== task.owner && by !== task.reviewer) throw new PreEffectToolRefusal("only the task owner, reviewer or console user may record lifecycle evidence");
1605
+ const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
1606
+ const next = sdlcReceipt(input, task.sdlc, config);
1607
+ const definition = stageDefinition(config, next.stage);
1608
+ if (config.mode === "enforce" && config.definitions && input && typeof input === "object" && "stage" in input && next.stage !== task.sdlc?.stage && by !== USER) {
1609
+ const actor = definition?.actor;
1610
+ 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"));
1611
+ if (!acts) throw new PreEffectToolRefusal(`lifecycle stage requires its ${actor ?? "unknown"} actor or the person`);
1612
+ }
1613
+ if (input && typeof input === "object" && "phase" in input) {
1614
+ this.mine(task, by, "reviewer", true);
1615
+ if (next.phase && !this.isPii(task) && !this.nameable(JSON.stringify(next.phase))) throw new PreEffectToolRefusal("phase metadata matches a PII pattern; use private work");
1616
+ if (next.phase) validatePhaseLinks(task, next.phase, id => this.d.board.get(id), t => this.isPii(t));
1617
+ }
1618
+ // Recording a head is the reviewer's; a head dropped because the repo or PR changed is not a recording, and a
1619
+ // head supplied with a new PR identity is one even when its value is unchanged (#382).
1620
+ const suppliedHead = !!input && typeof input === "object" && (input as { reviewedHead?: unknown }).reviewedHead !== undefined;
1621
+ const artifactChanged = next.repo !== task.sdlc?.repo || next.pr !== task.sdlc?.pr;
1622
+ if (next.reviewedHead !== undefined && (next.reviewedHead !== task.sdlc?.reviewedHead || (suppliedHead && artifactChanged))) this.mine(task, by, "reviewer", true);
1623
+ return this.d.board.update(task.id, by, "lifecycle", { sdlc: next });
1624
+ }
1625
+
1626
+ async deliveryQueue(repo: string) {
1627
+ const before = workflowHash(this.d.board.list().map(t => ({ id: t.id, state: t.state, after: t.deps, sdlc: t.sdlc, history: t.history.length })));
1628
+ const links = this.d.board.list().map(t => ({ id: t.id, after: t.deps ?? [], private: this.isPii(t), receipt: t.sdlc, phase: this.isPii(t) ? undefined : this.workflowView(t) }));
1629
+ const report = await readMergeQueue(repo, this.d.sdlc?.config() ?? sdlcConfig(undefined), links, this.d.sdlc?.gh);
1630
+ // Policy can change across remote awaits without any board write. Never return its stale public projection.
1631
+ const currentPrivacy = this.d.board.list().map(task => ({ id: task.id, private: this.isPii(task) }));
1632
+ if (workflowHash(links.map(({ id, private: hidden }) => ({ id, private: hidden }))) !== workflowHash(currentPrivacy)) {
1633
+ return { ...report, complete: false, rows: [], order: [], unordered: [], conflicts: [] };
1634
+ }
1635
+
1636
+ const after = workflowHash(this.d.board.list().map(t => ({ id: t.id, state: t.state, after: t.deps, sdlc: t.sdlc, history: t.history.length })));
1637
+ if (before !== after) { report.complete = false; report.order = []; if (report.rows.length) report.unordered = report.rows.map(row => row.pr); for (const row of report.rows) { row.ready = false; row.blockers.push("board changed during delivery readback"); } }
1638
+ return report;
1639
+ }
1640
+ async releaseReadiness(repo: string, liveIssues: number[]) {
1641
+ const report = await readReleaseReadiness(this.d.cwd, repo, liveIssues, { gh: this.d.sdlc?.gh });
1642
+ if (report.documents) report.documents.documents = report.documents.documents.map(d => {
1643
+ if (d.path && (!this.nameable(d.path) || matchesPolicy(this.d.cwd, d.path, this.d.piiPaths ?? [], true))) { const { path: _privatePath, ...metadata } = d; return metadata; }
1644
+ return d;
1645
+ });
1646
+ return report;
1647
+ }
1648
+
1649
+ private workflowView(task: Task) {
1650
+ return phaseView(this.d.sdlc?.config() ?? sdlcConfig(undefined), task, id => this.d.board.get(id), t => this.isPii(t));
1651
+ }
1652
+ workflowExport(id: unknown): Record<string, unknown> {
1653
+ const task = this.need(id), config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
1654
+ const state = { id: task.id, state: task.state, stage: task.sdlc?.stage ?? config.stages[0] ?? "spec", ...(this.isPii(task) ? { private: true } : { phase: this.workflowView(task) ?? null }) };
1655
+ return { schema: 1, workflow: config.workflow ?? "none", state, stateHash: workflowHash(state), sourceHash: workflowHash(config) };
1656
+ }
1657
+ async lifecycleStatus(id: unknown, refresh = true): Promise<SdlcStatus> {
1658
+ const task = this.need(id), config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
1659
+ const evidence = task.sdlc && !this.isPii(task) && refresh && config.mode !== "off" ? await readSdlcPr(task.sdlc, this.d.sdlc?.gh) : undefined;
1660
+ return sdlcStatus(config, task.sdlc, evidence, this.workflowView(task));
1661
+ }
1662
+
1663
+ private async lifecycleGate(task: Task, transition: "review" | "approval", reviewedHead?: string): Promise<void> {
1664
+ const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
1665
+ if (config.mode === "off") return;
1666
+ const receipt = task.sdlc && { ...task.sdlc, ...(reviewedHead ? { reviewedHead } : {}) };
1667
+ const evidence = receipt && !this.isPii(task) ? await readSdlcPr(receipt, this.d.sdlc?.gh) : undefined;
1668
+ const current = this.need(task.id, false, true);
1669
+ if (config.mode === "enforce" && (current.history.length !== task.history.length || current.owner !== task.owner || current.reviewer !== task.reviewer || current.state !== task.state)) throw new PreEffectToolRefusal(`task #${task.id}: changed during lifecycle readback; retry on current work`);
1670
+ const status = sdlcStatus(config, receipt, evidence, this.workflowView(current));
1671
+ const blockers = transition === "review" ? [...new Set([...status.blockers.filter(blocker => ["lifecycle stage definition unknown", "issue identity missing", "PR identity missing", "current PR evidence unknown", "PR is not open", "local gate on current head missing or failed"].includes(blocker)), ...(status.entry ?? []), ...(status.exit ?? [])])] : status.blockers;
1672
+ if (config.mode === "enforce" && blockers.length) throw new PreEffectToolRefusal(`task #${task.id}: SDLC ${transition} blocked: ${blockers.join("; ")}`);
1673
+ if (config.mode === "advise" && blockers.length) this.d.notify(`task ${this.publicTitle(task)}: SDLC ${transition} advice: ${this.isPii(task) ? "private lifecycle evidence unknown" : blockers.join("; ")}`);
1674
+ }
1675
+
1054
1676
  private need(id: unknown, open = false, preEffect = false): Task {
1055
1677
  // Callers are models (#70): an id is a whole number or a digit string, never an array that Number() would accept.
1056
1678
  const n = typeof id === "number" ? id : typeof id === "string" && /^\s*\d+\s*$/.test(id) ? Number(id) : NaN;
1057
1679
  if (!Number.isInteger(n)) throw new (preEffect ? PreEffectToolRefusal : Error)(`id must be a task number, not ${(typeof id === "number" ? String(id) : JSON.stringify(id ?? null)).slice(0, 60)}`);
1058
1680
  const task = this.d.board.get(n);
1059
1681
  if (!task) throw new (preEffect ? PreEffectToolRefusal : Error)(`no task #${n}`);
1682
+ if (task.state === "backlog") throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${n} is backlog: pull it into To Do first`);
1060
1683
  // Handing a task to someone else only makes sense while there is work left on it.
1061
1684
  if (open && !OPEN.includes(task.state)) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id} is ${task.state}: it can no longer change hands`);
1062
1685
  return task;
@@ -1072,10 +1695,10 @@ export class Tasks {
1072
1695
  this.ready(task, true);
1073
1696
  if (task.state !== "proposed" && task.state !== "changes_requested" && task.state !== "in_progress") throw new PreEffectToolRefusal(`task #${task.id} is ${task.state}: cannot move to in_progress`);
1074
1697
  // Models send null or {} for an optional field they leave empty: neither replaces a plan.
1075
- const given = plan == null ? undefined : cleanPlan(plan);
1698
+ const given = plan == null ? undefined : cleanPlan(plan, (task.profile ?? "dev") === (this.d.profile ?? "dev") ? this.d.profileData ?? task.profile ?? "dev" : task.profile ?? "dev");
1076
1699
  const cleaned = given && Object.keys(given).length ? given : undefined;
1077
1700
  // A task's signals are fixed when it is proposed: text that matches a PII pattern cannot be let in afterwards.
1078
- if (cleaned && !this.isPii(task) && this.isPii({ signals: detectSignals({ title: "", detail: planText(cleaned), refs: {} }, this.d.routing(), this.d.cwd) })) {
1701
+ if (cleaned && !this.isPii(task) && this.isPii({ signals: this.signalsFor({ title: "", detail: planText(cleaned), refs: {}, plan: cleaned }) })) {
1079
1702
  throw new PreEffectToolRefusal("this plan matches a PII pattern and is not kept: other owners would see it");
1080
1703
  }
1081
1704
  const before = new Set(this.overlapHits(task).map((h) => h.task.id));
@@ -1138,9 +1761,11 @@ export class Tasks {
1138
1761
  const task = this.need(id, true, true);
1139
1762
  this.mine(task, by, "owner", true);
1140
1763
  // The owner it is declined for goes on the entry, also when the console declines for it: `excluded()` keeps it out (#207).
1141
- const back = this.d.board.update(task.id, by, "declined", task.state === "in_progress" ? { state: "proposed" } : {}, reason, task.owner ? { from: task.owner } : {});
1764
+ const planned = this.plannerPullRecord(task)?.pull === true;
1765
+ const back = this.d.board.update(task.id, by, "declined", { ...(["in_progress", "changes_requested"].includes(task.state) ? { state: "proposed" as const } : {}), ...(planned ? { reserved: null } : {}) }, reason, task.owner ? { from: task.owner } : {});
1766
+ if (planned) this.offered.delete(task.id);
1142
1767
  this.d.notify(`task ${this.publicTitle(back)} declined by ${by}${reason && !this.isPii(back) ? `: ${reason}` : ""}`);
1143
- return this.assignOwner(back, HUB, { event: "reassigned", reason: "declined", clearOnFail: true });
1768
+ return this.assignOwner(back, HUB, { event: "reassigned", reason: "declined", clearOnFail: true, ...(planned ? { planner: true } : {}) });
1144
1769
  }
1145
1770
 
1146
1771
  /** Tasks whose check is queued or running, and the owner it was started for. */
@@ -1168,6 +1793,7 @@ export class Tasks {
1168
1793
  // The PII screen reads the summary of a done that passes these checks (issue #198); they run again after it.
1169
1794
  const withheld = await this.withholds("summary", summary, valid());
1170
1795
  let task = valid();
1796
+ if (this.d.sdlc && this.d.sdlc.config().mode !== "off") { await this.lifecycleGate(task, "review"); task = valid(); }
1171
1797
  // A member of a silent cohort (issue #107): its done is an intent, and the last of them integrates first.
1172
1798
  const cohort = this.cohorts.of(task.id);
1173
1799
  const silentMember = !!cohort?.silent && this.turnFree() && !!task.owner && !this.isPii(task);
@@ -1197,8 +1823,8 @@ export class Tasks {
1197
1823
  else if (evidence) this.doneNotes.set(task.id, `Overlapping work finished while you worked (no turn-free integration step ran):\n${evidence}`);
1198
1824
  }
1199
1825
  if (task.state === "proposed" || task.state === "changes_requested") task = this.d.board.update(task.id, by, "accepted", { state: "in_progress" }, WITH_DONE); // done without a separate accept
1200
- const command = this.d.runCheck ? this.d.check?.(task.class) : undefined;
1201
- if (!command) return this.complete(task, by, summary, refs, "", withheld);
1826
+ const command = this.d.runCheck ? this.d.check?.(task.class, task) : undefined;
1827
+ if (!command) return this.complete(task, by, summary, refs, "", withheld, "review");
1202
1828
  // The tool call returns now; a check can outlast an agent's tool timeout. The result decides what comes next.
1203
1829
  this.checking.set(task.id, task.owner);
1204
1830
  const pending = this.d.board.update(task.id, by, "done (checking)", { refs: cleanRefs(refs) }, summary, { withheld: withheld || undefined });
@@ -1241,7 +1867,7 @@ export class Tasks {
1241
1867
  const at = [...u.history].reverse().find((h) => h.event === "done");
1242
1868
  if (u.owner === t.owner || !at || at.at < handedAt(t)) return [];
1243
1869
  const theirs = this.places(u);
1244
- const paths = mine.paths.filter((p) => theirs.paths.some((q) => samePlace(p, q)));
1870
+ const paths = t.profile && u.profile && (t.plan?.sections?.length && u.plan?.sections?.length) ? [] : mine.paths.filter((p) => theirs.paths.some((q) => samePlace(p, q)));
1245
1871
  const symbols = mine.symbols.filter((x) => theirs.symbols.includes(x));
1246
1872
  return paths.length || symbols.length ? [this.completedNotice(u, { task: t, paths, symbols }, at.note, at.withheld)] : [];
1247
1873
  });
@@ -1324,13 +1950,23 @@ export class Tasks {
1324
1950
  this.tell(task, `Task #${id}: its check failed.\n$ ${outcome}${result.tail ? `\n${result.tail}` : ""}\nFix it and call hub_task_done again.`, pii);
1325
1951
  }
1326
1952
 
1327
- private async complete(task: Task, by: PeerId, summary?: string, refs?: TaskRefs, checkOutput = "", withheld = false): Promise<Task> {
1328
- const reviewer = task.reviewer;
1329
- const next = this.d.board.update(task.id, by, "done", { state: reviewer ? "in_review" : "approved", refs: cleanRefs(refs) }, checkOutput ? `${summary ?? ""}\n${checkOutput}`.trim() : summary, { withheld: withheld || undefined });
1953
+ /** `gated`: the transition `done()` already gated with no await since, so it is not read back twice (#382). */
1954
+ private async complete(task: Task, by: PeerId, summary?: string, refs?: TaskRefs, checkOutput = "", withheld = false, gated?: "review"): Promise<Task> {
1955
+ let reviewer = task.reviewer;
1956
+ if (this.d.sdlc && this.d.sdlc.config().mode !== "off" && gated !== (reviewer ? "review" : "approval")) {
1957
+ await this.lifecycleGate(task, reviewer ? "review" : "approval");
1958
+ task = this.need(task.id, false, true);
1959
+ this.mine(task, by, "owner", true);
1960
+ // In every mode: another done may have landed during the readback.
1961
+ if (task.state === "in_review" || task.state === "approved") throw new Error(`task #${task.id} is already ${task.state}`);
1962
+ reviewer = task.reviewer;
1963
+ }
1964
+ const next = this.d.board.update(task.id, by, "done", { state: reviewer ? "in_review" : "approved", ...(task.sdlc ? { sdlc: { ...task.sdlc, stage: lifecycleStage(this.d.sdlc?.config() ?? sdlcConfig(undefined), reviewer ? "review" : "merge") } } : {}), refs: cleanRefs(refs) }, checkOutput ? `${summary ?? ""}\n${checkOutput}`.trim() : summary, { withheld: withheld || undefined });
1330
1965
  this.cohorts.closed(next.id); // its owner's next native turn end settles it in its cohort (issue #107)
1331
1966
  this.note(next, by, "finding", `Task #${next.id} done by ${by}: ${next.title}\n${summary ?? ""}`, withheld);
1332
1967
  this.tellCompleted(next, summary, withheld);
1333
1968
  if (!reviewer) {
1969
+ this.settleMemory(next);
1334
1970
  if (next.owner) this.d.board.recordOutcome(next.owner, next.class, true); // approved without a review is a success too
1335
1971
  this.releaseNotice(`task ${this.publicTitle(next)} done by ${by}, no reviewer: approved`);
1336
1972
  await this.releaseDependents(next);
@@ -1405,11 +2041,15 @@ export class Tasks {
1405
2041
  `- List what is unmet in hub_review's unmet, one item each.`,
1406
2042
  ].join("\n");
1407
2043
  const summary = last?.note ? this.screen(task, last.note, "summary", reviewer, last.withheld) : "(no summary)";
1408
- const body = `Review task #${task.id} [${task.class}] ${task.title}\nDone by ${last?.by ?? task.owner}: ${summary}\n${where ? `Where: ${where}\n` : ""}${why ? `${why}\n` : ""}${!plan && task.detail ? `Task detail:\n${task.detail}\n` : ""}${checklist}\nGive your verdict with hub_review {id: ${task.id}, verdict: "approved" | "changes_requested", note, unmet}.`;
2044
+ const guidance = this.roleGuidance(task, reviewer, true);
2045
+ let projection = "";
2046
+ try { const text = this.d.projectedReview?.(task); if (text) projection = this.screen(task, sanitize(text), "document projection", reviewer); }
2047
+ catch { projection = "(document projection unavailable)"; }
2048
+ const body = `Review task #${task.id} [${profileTaskLabel(task)}] ${task.title}\nDone by ${last?.by ?? task.owner}: ${summary}\n${where ? `Where: ${where}\n` : ""}${why ? `${why}\n` : ""}${!plan && task.detail ? `Task detail:\n${task.detail}\n` : ""}${guidance ? guidance + "\n" : ""}${projection ? projection + "\n" : ""}${checklist}\nGive your verdict with hub_review {id: ${task.id}, verdict: "approved" | "changes_requested", note, unmet}.`;
1409
2049
  this.d.bus.publish(newEnvelope(HUB, body, { to: [reviewer], kind: "review", priority: "important", refs: { ...r, task: String(task.id) }, ...(this.isPii(task) ? { private: true } : {}) }));
1410
2050
  }
1411
2051
 
1412
- async review(by: PeerId, id: unknown, verdict: unknown, note?: string, unmet?: unknown): Promise<Task> {
2052
+ async review(by: PeerId, id: unknown, verdict: unknown, note?: string, unmet?: unknown, head?: unknown): Promise<Task> {
1413
2053
  const valid = (): Task => {
1414
2054
  const t = this.need(id, false, true);
1415
2055
  this.mine(t, by, "reviewer", true);
@@ -1421,11 +2061,17 @@ export class Tasks {
1421
2061
  if (items.length) note = `${note ?? ""}\nUnmet: ${items.join("; ")}`.trim();
1422
2062
  // The PII screen reads the note of a review that passes these checks (issue #198); they run again after it.
1423
2063
  const withheld = await this.withholds("review note", note, valid());
1424
- const task = valid();
2064
+ let task = valid();
2065
+ let reviewedHead: string | undefined;
2066
+ if (head !== undefined) {
2067
+ const checked = sdlcReceipt({ reviewedHead: head }, task.sdlc, this.d.sdlc?.config() ?? sdlcConfig(undefined));
2068
+ reviewedHead = checked.reviewedHead;
2069
+ }
2070
+ if (verdict === "approved" && this.d.sdlc && this.d.sdlc.config().mode !== "off") { await this.lifecycleGate(task, "approval", reviewedHead); task = valid(); }
1425
2071
  const pii = this.isPii(task);
1426
2072
  const flag = { withheld: withheld || undefined };
1427
2073
  if (verdict === "approved") {
1428
- const next = this.d.board.update(task.id, by, "approved", { state: "approved", rejections: 0 }, note, flag);
2074
+ const next = this.d.board.update(task.id, by, "approved", { state: "approved", rejections: 0, ...(reviewedHead && task.sdlc ? { sdlc: { ...task.sdlc, reviewedHead, stage: lifecycleStage(this.d.sdlc?.config() ?? sdlcConfig(undefined), "merge") } } : {}) }, note, flag);
1429
2075
  if (next.owner) {
1430
2076
  this.d.board.recordOutcome(next.owner, next.class, true);
1431
2077
  this.d.board.recordReview({ implementer: next.owner, reviewer: by, class: next.class, kind: "approved", task: next.id });
@@ -1435,6 +2081,7 @@ export class Tasks {
1435
2081
  }
1436
2082
  }
1437
2083
  this.note(next, by, "decision", `Task #${next.id} approved by ${by}: ${next.title}\n${note ?? ""}`, withheld);
2084
+ this.settleMemory(next);
1438
2085
  this.releaseNotice(`task ${this.publicTitle(next)}: could not tell ${next.owner ?? "its owner"} it was approved`, () =>
1439
2086
  this.tell(next, `Task #${next.id} approved by ${by}.${note ? ` ${this.screen(next, note, "review note", next.owner, withheld)}` : ""}`, pii));
1440
2087
  await this.releaseDependents(next);
@@ -1507,10 +2154,36 @@ export class Tasks {
1507
2154
  }
1508
2155
 
1509
2156
  /** Caller authority is checked by the daemon; keep the real actor in task history. */
1510
- async assignTo(id: unknown, peer: PeerId, by: PeerId = USER, preEffect = false): Promise<Task> {
1511
- const task = this.need(id, true, preEffect);
2157
+ async assignTo(id: unknown, peer: PeerId, by: PeerId = USER, preEffect = false, role: TaskAssignmentRole = "owner"): Promise<Task> {
2158
+ if (role !== "owner" && role !== "reviewer") throw new (preEffect ? PreEffectToolRefusal : Error)("role must be owner or reviewer");
2159
+ if (role === "reviewer" && by !== USER) {
2160
+ const conductors = Object.entries(this.d.roles ?? {}).filter(([, roles]) => Array.isArray(roles) && roles.includes("conductor")).map(([actor]) => actor);
2161
+ if (conductors.length !== 1 || conductors[0] !== by) throw new (preEffect ? PreEffectToolRefusal : Error)("only the console user or the configured conductor may name a reviewer");
2162
+ }
2163
+ const task = this.need(id, role === "owner", preEffect);
2164
+ if (!this.d.bus.isRostered(peer)) throw new (preEffect ? PreEffectToolRefusal : Error)(`${peer} is off roster; add it to the roster before assigning work`);
2165
+ if (role === "reviewer") {
2166
+ if (!OPEN.includes(task.state) && task.state !== "in_review") throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id} is ${task.state}: its reviewer can no longer change`);
2167
+ this.ready(task, preEffect);
2168
+ const a = assign(task, this.states(), this.d.routing(), { candidates: [], namedReviewer: peer, ...(task.owner ? { notReviewer: task.owner } : {}), ...this.weights(task.class) });
2169
+ if (a.reviewer !== peer) throw new (preEffect ? PreEffectToolRefusal : Error)(`cannot name ${peer} as reviewer: ${a.trace.filter(line => line.includes("reviewer") || line.includes("pii")).join("; ")}`);
2170
+ const next = this.d.board.update(task.id, by, "reviewer changed", { reviewer: peer }, `named reviewer ${peer}`, { reason: "manual" });
2171
+ if (next.state === "in_review" && task.reviewer && task.reviewer !== peer) {
2172
+ const previous = task.reviewer;
2173
+ this.releaseNotice(`task ${this.publicTitle(next)}: could not notify previous reviewer ${previous} that its review request is withdrawn`, () => {
2174
+ for (const env of this.d.bus.snapshot(false).queues[previous] ?? []) {
2175
+ if (env.from === HUB && env.kind === "review" && env.refs?.task === String(task.id)) this.d.bus.withdraw(env.id);
2176
+ }
2177
+ const body = `Review request for task #${task.id} ${this.publicTitle(next)} is withdrawn: its reviewer is now ${peer}. Do not review this task; a late verdict from the previous reviewer is refused.`;
2178
+ if (previous === USER) this.d.notify(body);
2179
+ else this.d.bus.publish(newEnvelope(HUB, body, { to: [previous], kind: "status", priority: "important", refs: { task: String(task.id) }, ...(this.isPii(next) ? { private: true } : {}) }));
2180
+ });
2181
+ }
2182
+ if (next.state === "in_review" && peer !== USER) this.releaseNotice(`task ${this.publicTitle(next)}: could not send the review request to ${peer}`, () => this.sendReview(next, peer));
2183
+ return next;
2184
+ }
1512
2185
  // Work that waits is handed to nobody yet (issue #207): the peer becomes its reserved owner instead.
1513
- if (!this.waitsFor(task).length) return this.assignOwner(task, by, { candidates: [peer], event: "reassigned", reason: "manual", override: by === USER });
2186
+ if (!this.waitsFor(task).length) return this.assignOwner(task, by, { candidates: [peer], namedOwner: peer, event: "reassigned", reason: "manual", override: by === USER });
1514
2187
  const next = this.d.board.update(task.id, by, "reserved", { reserved: peer }, `for ${peer}`, { reason: "manual" });
1515
2188
  this.releaseNotice(`task ${this.publicTitle(next)} reserved for ${peer} by ${by}; it is offered to ${peer} first once what it waits for is approved`);
1516
2189
  return next;
@@ -1521,35 +2194,70 @@ export class Tasks {
1521
2194
  * the usual constraints. Tasks it was reviewing get another reviewer. Moves are reported, never taken back automatically.
1522
2195
  */
1523
2196
  async reassignForPause(peer: PeerId, context: string | undefined, urgentOnly = false): Promise<{ id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]> {
1524
- const moved: { id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[] = [];
2197
+ return this.reassignWork(peer, context, urgentOnly, false);
2198
+ }
2199
+
2200
+ /** Read-only preview using the same assignment constraints as the roster relay. */
2201
+ rosterPreview(peer: PeerId): { id: number; title: string; role: "owner" | "reviewer"; to: PeerId | null }[] {
1525
2202
  const routing = this.d.routing();
1526
- // The hand-off is screened once (#198), before anything moves, when an ordinary task will carry it.
2203
+ return this.d.board.list().filter(task => task.state !== "approved" && (task.owner === peer || task.reviewer === peer)).map(task => {
2204
+ const owner = task.owner === peer;
2205
+ const weights = this.weights(task.class);
2206
+ const pii = this.isPii(task);
2207
+ const candidates = owner ? [pii ? LOCAL : PI, pii ? undefined : LOCAL, ...(routing.classes[task.class]?.peers ?? []).filter(p => p !== LOCAL && p !== PI)].filter((p): p is PeerId => !!p) : [];
2208
+ const a = assign(task, this.states(), routing, { ...weights, offRoster: [...(weights.offRoster ?? []), peer], exclude: [peer], excludeOwners: owner && this.plannerPullRecord(task)?.pull ? this.plannerFull(task) : [], candidates, ...(owner ? { waitsFor: this.waitsFor(task) } : task.owner ? { notReviewer: task.owner } : {}) });
2209
+ return { id: task.id, title: this.publicTitle(task), role: owner ? "owner" : "reviewer", to: (owner ? a.owner : a.reviewer) ?? null };
2210
+ });
2211
+ }
2212
+
2213
+ /** A roster removal offers work through the quota relay's constraints, preserving assignments nobody can take. */
2214
+ async reassignForRoster(peer: PeerId): Promise<{ id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]> {
2215
+ return this.reassignWork(peer, undefined, false, true);
2216
+ }
2217
+
2218
+ private async reassignWork(peer: PeerId, context: string | undefined, urgentOnly: boolean, roster: boolean): Promise<{ id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]> {
2219
+ const moved: { id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[] = [];
2220
+ const routing = this.d.routing(), why = roster ? `roster removal of ${peer}` : `budget pause of ${peer}`;
2221
+ // Budget context is screened exactly as before; a roster removal supplies no agent-written context.
1527
2222
  const moves = (t: Task) => (!urgentOnly || t.signals.includes("urgent")) && t.owner === peer && OPEN.includes(t.state);
1528
2223
  const contextWithheld = context && this.d.board.list().some((t) => moves(t) && !this.isPii(t)) ? await this.withholds("handoff", context) : false;
1529
2224
  for (const task of this.d.board.list()) {
1530
- // Waiting out a window that resets soon (issue #36): only urgent work moves.
1531
2225
  if (urgentOnly && !task.signals.includes("urgent")) continue;
1532
- if (task.owner === peer && OPEN.includes(task.state)) {
2226
+ if (task.owner === peer && (OPEN.includes(task.state) || roster && task.state === "in_review")) {
1533
2227
  const pii = this.isPii(task);
1534
2228
  const candidates = [pii ? LOCAL : PI, pii ? undefined : LOCAL, ...(routing.classes[task.class]?.peers ?? []).filter((p) => p !== LOCAL && p !== PI)].filter((p): p is PeerId => !!p);
1535
- const back = task.state === "in_progress" ? this.d.board.update(task.id, HUB, "released", { state: "proposed" }, `budget pause of ${peer}`) : task;
2229
+ if (this.plannerPullRecord(task)?.pull && !assign(task, this.states(), routing, { candidates, exclude: [...this.excluded(task), peer], excludeOwners: this.plannerFull(task), waitsFor: this.waitsFor(task), ...this.weights(task.class) }).owner) continue;
2230
+ // Budget keeps its original release/unassign behaviour for ordinary work. Roster changes the state in
2231
+ // assignOwner's same owner write only after routing has found somebody to take it.
2232
+ const back = !roster && task.state === "in_progress" ? this.d.board.update(task.id, HUB, "released", { state: "proposed" }, why) : task;
2233
+ let next: Task;
1536
2234
  try {
1537
- const next = await this.assignOwner(back, HUB, { candidates, exclude: [peer], event: "reassigned", reason: "budget", note: `budget pause of ${peer}`, clearOnFail: true, ...(context ? { context, contextWithheld } : {}) });
1538
- moved.push({ id: task.id, title: this.publicTitle(task), to: next.owner, role: "owner" });
2235
+ next = await this.assignOwner(back, HUB, { candidates, exclude: [peer], event: "reassigned", reason: roster ? "manual" : "budget", note: why, ...(roster ? { roster: true } : { clearOnFail: true }), ...(context ? { context, contextWithheld } : {}) });
1539
2236
  } catch (e) {
1540
- // A saved move whose delivery failed: the console notice was printed where the error was thrown; the
1541
- // task counts as moved and the relay goes on (#297). No retry is needed for it: the notice already spoke.
2237
+ // The move is saved and its central #297 notice already spoke; relay the
2238
+ // remaining tasks and run the roster tail on the saved row as on success.
1542
2239
  if (!(e instanceof AssignmentUndeliveredError)) throw e;
1543
- moved.push({ id: task.id, title: this.publicTitle(task), to: e.task.owner, role: "owner" });
2240
+ next = e.task;
2241
+ }
2242
+ if (roster) {
2243
+ if (!next.owner || next.owner === peer) continue;
2244
+ this.releaseNotice(`task ${this.publicTitle(next)} moved from ${peer} to ${next.owner} (off roster)`);
2245
+ if (next.state === "in_review" && next.reviewer && next.reviewer !== task.reviewer && next.reviewer !== USER) this.releaseNotice(`task ${this.publicTitle(next)}: could not send the review request to ${next.reviewer}`, () => this.sendReview(next, next.reviewer!, `(${peer} owned this and is off roster.)`));
1544
2246
  }
2247
+ moved.push({ id: task.id, title: this.publicTitle(task), to: next.owner, role: "owner" });
1545
2248
  } else if (task.reviewer === peer && task.state !== "approved") {
1546
- // No owner candidates: only the reviewer is wanted, and it must be neither the paused peer nor the task's owner.
1547
2249
  const a = assign(task, this.states(), routing, { exclude: [peer], candidates: [], ...(task.owner ? { notReviewer: task.owner } : {}), ...this.weights(task.class) });
1548
2250
  const reviewer = a.reviewer && a.reviewer !== peer && a.reviewer !== task.owner ? a.reviewer : null;
1549
- const next = this.d.board.update(task.id, HUB, "reviewer changed", { reviewer }, `budget pause of ${peer}`);
2251
+ if (roster && !reviewer) {
2252
+ this.releaseNotice(`task ${this.publicTitle(task)}: its reviewer ${peer} is off roster and nobody else can review; the reviewer stays ${peer}; use ahub review ${task.id}`);
2253
+ continue;
2254
+ }
2255
+ const next = this.d.board.update(task.id, HUB, "reviewer changed", { reviewer }, why);
1550
2256
  moved.push({ id: task.id, title: this.publicTitle(task), to: reviewer, role: "reviewer" });
1551
- if (next.state === "in_review" && reviewer && reviewer !== USER) this.sendReview(next, reviewer, `(${peer} was reviewing this and is paused for quota.)`);
1552
- else if (next.state === "in_review" && !reviewer) this.d.notify(`task ${this.publicTitle(next)}: its reviewer ${peer} is paused and nobody else can review; use ahub review ${next.id}`);
2257
+ if (next.state === "in_review" && reviewer && reviewer !== USER) {
2258
+ if (roster) this.releaseNotice(`task ${this.publicTitle(next)}: could not send the review request to ${reviewer}`, () => this.sendReview(next, reviewer, `(${peer} was reviewing this and is off roster.)`));
2259
+ else this.sendReview(next, reviewer, `(${peer} was reviewing this and is paused for quota.)`);
2260
+ } else if (next.state === "in_review" && !reviewer) this.d.notify(`task ${this.publicTitle(next)}: its reviewer ${peer} is paused and nobody else can review; use ahub review ${next.id}`);
1553
2261
  }
1554
2262
  }
1555
2263
  return moved;
@@ -1566,7 +2274,7 @@ export class Tasks {
1566
2274
  return { ...(policy?.route ? { route: policy.route } : {}), ...(policy?.fixed_model ? { fixedModel: policy.fixed_model } : {}), pii, task: String(about.id) };
1567
2275
  }
1568
2276
 
1569
- async remember(by: PeerId, input: { text?: string; title?: string; kind?: string; task?: unknown }): Promise<string> {
2277
+ async remember(by: PeerId, input: { text?: string; title?: string; kind?: string; task?: unknown; horizon?: unknown; scope?: unknown }): Promise<string> {
1570
2278
  const text = String(input.text ?? "").trim();
1571
2279
  if (!text) throw new Error("text is required");
1572
2280
  const task = input.task !== undefined ? this.d.board.get(Number(input.task)) : undefined;
@@ -1574,24 +2282,132 @@ export class Tasks {
1574
2282
  // Shared with every other peer as well, so text that matches a PII pattern is refused like a note about a PII task.
1575
2283
  const title = String(input.title ?? "").trim();
1576
2284
  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");
1577
- if (!this.d.memory) return "memory is disabled; nothing saved";
2285
+ if (input.horizon !== undefined && input.horizon !== "short" && input.horizon !== "long") throw new Error('horizon is "short" or "long"');
2286
+ if (input.scope !== undefined && input.scope !== "project" && input.scope !== "shared") throw new Error('scope is "project" or "shared"');
2287
+ if (!this.d.memory && !this.d.store) return "memory is disabled; nothing saved";
2288
+ // Long-term notes leave the machine (#258), and claude-mem's observer is a cloud model: a secret goes to neither.
2289
+ 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");
2290
+ 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");
1578
2291
  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");
1579
- const kind = (NOTE_KINDS as readonly string[]).includes(String(input.kind)) ? String(input.kind) : "finding";
1580
- const res = await this.d.memory.save({ text, ...(title ? { title } : {}), project: this.d.project, metadata: { peer: by, kind, ...(task ? { task: task.id } : {}) } });
1581
- if (!res) return "memory worker unavailable; nothing saved";
2292
+ const kind = (NOTE_KINDS as readonly string[]).includes(String(input.kind)) ? String(input.kind) as Kind : "finding";
2293
+ const native = this.saveNative(by, kind, text, title, task, input.horizon === "long" || input.horizon === "short" ? input.horizon : undefined, input.scope === "shared" ? "shared" : "project");
2294
+ if (task && this.d.memory) this.markMemoryReference(task, by);
2295
+ 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;
2296
+ if (!res && !native) return this.d.memory ? "memory worker unavailable; nothing saved" : "memory store unavailable; nothing saved";
1582
2297
  const line = noteLine(by, kind, title ? `${title}: ${text}` : text);
1583
2298
  this.d.notify(line);
1584
2299
  this.d.share?.(by, line);
1585
- return "saved to shared memory; the other agents get it with their next message";
2300
+ return `saved to shared memory${native ? ` (hub memory ${native.id.slice(0, 8)}, ${native.tier}-term${this.d.memory && !res ? "; claude-mem unavailable" : ""})` : ""}; the other agents get it with their next message`;
2301
+ }
2302
+
2303
+ /** The task's key in the hub's store: unique across `ahub reset --all`, which restarts the ids. */
2304
+ private memoryKey(task: Task): string { return taskKey(this.d.store!.project, task); }
2305
+
2306
+ /**
2307
+ * The long-term task a board task may join (#258). A peer names one of this project's; a person may name any (a link
2308
+ * across projects is a person's). Refused before any board write.
2309
+ */
2310
+ private goalFor(by: PeerId, ref: unknown): GoalView {
2311
+ if (!this.d.store) throw new PreEffectToolRefusal("goal: hub memory is off for this project");
2312
+ const g = typeof ref === "string" && ref.trim() ? this.d.store.store.goalOf(ref.trim().replace(/^goal:/, "")) : undefined;
2313
+ if (!g) throw new PreEffectToolRefusal(`goal: no long-term task ${JSON.stringify(ref).slice(0, 40)} (ahub goal list shows them)`);
2314
+ if (by !== USER && g.project !== this.d.store.project) throw new PreEffectToolRefusal("goal: that long-term task belongs to another project; linking across projects is a person's");
2315
+ if (g.state === "done" || g.state === "dropped") throw new PreEffectToolRefusal(`goal: that long-term task is ${g.state}`);
2316
+ return g;
2317
+ }
2318
+ /** Fence deletion before external memory writes; one marker survives store absence. */
2319
+ private markMemoryReference(task: Task, by: PeerId): void {
2320
+ if (!this.d.board.get(task.id)?.history.some(entry => entry.event === "memory reference started")) this.d.board.update(task.id, by, "memory reference started", {});
2321
+ }
2322
+ /** Record a long-term link, never for PII; failures remain notices. */
2323
+ private linkNative(task: Task, goal: GoalView, by: PeerId): string {
2324
+ if (this.isPii(task)) { this.releaseNotice(`task #${task.id} is not linked to a long-term task: nothing about a PII task enters hub memory`); return "not linked (PII task)"; }
2325
+ try { this.markMemoryReference(task, by); this.d.store!.store.goalLink(goal.id, this.memoryKey(task), by); return `part of long-term task ${goal.id.slice(0, 8)}`; }
2326
+ catch (e) { this.releaseNotice(`task #${task.id}: the link to long-term task ${goal.id.slice(0, 8)} was not saved (${(e as Error).message})`); return "not linked"; }
2327
+ }
2328
+ /** A person links a board task of this project to a long-term task (`ahub goal link`). */
2329
+ linkGoal(by: PeerId, id: unknown, ref: unknown): string {
2330
+ const task = this.d.board.get(Number(id));
2331
+ if (!task) throw new Error(`no task #${String(id).slice(0, 12)}`);
2332
+ const goal = this.goalFor(by, ref);
2333
+ return `task #${task.id}: ${this.linkNative(task, goal, by)}`;
2334
+ }
2335
+
2336
+ /**
2337
+ * One note in the hub's own store (#258). Short-term while its task is open; on an approved task, or with horizon
2338
+ * long, decisions, contracts and fails are long-term. Fail-open: a store that cannot be written costs one notice.
2339
+ * The caller has screened the text for PII; never a PII task's.
2340
+ */
2341
+ private saveNative(by: PeerId, kind: Kind, text: string, title: string, task: Task | undefined, horizon?: Tier, scope: Scope = "project"): { id: string; tier: Tier } | undefined {
2342
+ const st = this.d.store;
2343
+ if (!st || (task && this.isPii(task))) return undefined;
2344
+ try { // never throws: callers run after a board write
2345
+ // Approved, or settled in the store already: the snapshot the caller holds can predate an approval made while it awaited.
2346
+ const approved = !!task && (task.state === "approved" || st.store.settled(this.memoryKey(task)));
2347
+ const tier: Tier = horizon ?? (approved && PROMOTED_KINDS.includes(kind) ? "long" : "short");
2348
+ 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 } } : {}) });
2349
+ // A short-term note on a task already approved (before hub memory existed, or late): the task is settled first, so
2350
+ // the note gets its own deadline and is never taken for a source waiting for promotion.
2351
+ if (task && approved && tier === "short" && !st.store.settled(this.memoryKey(task))) st.store.settle(this.memoryKey(task), by);
2352
+ if (task) this.markMemoryReference(task, by);
2353
+ const id = st.store.note({ op: "note", project: st.project, scope, tier, kind, peer: by, ...(title ? { title } : {}), text, ...(task ? { task: this.memoryKey(task) } : {}), about, source: by === USER ? "person" : "hub" }, horizon === "short");
2354
+ st.onNote?.(id);
2355
+ return { id, tier };
2356
+ } catch (e) {
2357
+ this.releaseNotice(`a ${kind} note was not saved to hub memory (${(e as Error).message})`);
2358
+ return undefined;
2359
+ }
2360
+ }
2361
+
2362
+ /** An approved task's decision, contract and fail notes move to long-term; its short-term entries start to expire (#258). */
2363
+ /**
2364
+ * At hub start: an approved task whose settle entry a crash or a failed write cut off is settled now, so its
2365
+ * decisions, contracts and fails still reach long-term memory and its findings get their deadline.
2366
+ */
2367
+ reconcileMemory(): number {
2368
+ if (!this.d.store) return 0;
2369
+ let n = 0;
2370
+ for (const t of this.d.board.list("approved")) {
2371
+ if (this.isPii(t)) continue;
2372
+ try { const key = this.memoryKey(t); if (this.d.store.store.hasTask(key) && !this.d.store.store.settled(key)) { this.settleMemory(t); n++; } }
2373
+ catch { /* fail-open: tried again at the next start */ }
2374
+ }
2375
+ return n;
2376
+ }
2377
+
2378
+ private settleMemory(task: Task): void {
2379
+ if (!this.d.store || this.isPii(task)) return;
2380
+ // The store screens each note as it moves to the synced log (the promotion screen the daemon gives it).
2381
+ try { this.d.store.store.settle(this.memoryKey(task), task.reviewer ?? task.owner ?? HUB); }
2382
+ catch (e) { this.releaseNotice(`task #${task.id}: hub memory could not settle its notes (${(e as Error).message})`); }
2383
+ }
2384
+
2385
+ /**
2386
+ * Whether a note names, or its task touches, a path the current `pii.paths` makes private: checked before either memory
2387
+ * backend sees it, whatever the task's signals said when it was proposed.
2388
+ */
2389
+ private aboutPrivatePath(title: string, text: string, task: Task | undefined): boolean {
2390
+ const st = this.d.store;
2391
+ // No denylist here: a path the graph leaves out is still checked against the privacy policy.
2392
+ const about = extractNodes(`${title}\n${text}`, { cwd: this.d.cwd, project: st?.project ?? this.d.project, repo: st?.repo ?? this.d.project, deny: [],
2393
+ paths: [...(task?.refs.paths ?? []), ...(task?.plan?.paths ?? [])], symbols: task?.plan?.symbols });
2394
+ return mentionsPrivatePath(`${title}\n${text}`, about, this.d.piiPaths ?? [], this.d.cwd);
1586
2395
  }
1587
2396
 
1588
2397
  /** Auto notes: only transitions that carry content, never for PII. */
1589
- private note(task: Task, by: PeerId, kind: string, text: string, withheld = false): void {
1590
- if (this.isPii(task) || !this.d.memory) return;
2398
+ private note(task: Task, by: PeerId, kind: Kind, text: string, withheld = false): void {
2399
+ if (this.isPii(task) || (!this.d.memory && !this.d.store)) return;
1591
2400
  // claude-mem's observer is a cloud model: model-written text that matches a PII pattern, or that the PII screen did
1592
- // not clear (#198), is not sent (#69).
2401
+ // not clear (#198), is not sent (#69). Long-term hub memory leaves the machine (#258): the same rule, and no secrets.
1593
2402
  if (withheld || !this.nameable(text)) return this.releaseNotice(`task #${task.id}: a ${kind} note was not saved to shared memory (${this.whyWithheld(text)})`);
1594
- void this.d.memory.save({ text, title: `agent-hub task #${task.id}: ${task.title}`.slice(0, 120), project: this.d.project, metadata: { peer: by, task: task.id, kind } });
2403
+ 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)`);
2404
+ const title = `agent-hub task #${task.id}: ${task.title}`.slice(0, 120);
2405
+ 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)`);
2406
+ this.saveNative(by, kind, text, title, task);
2407
+ if (this.d.memory) {
2408
+ try { this.markMemoryReference(task, by); } catch { return this.releaseNotice(`task #${task.id}: memory reference could not be recorded; note was not saved`); }
2409
+ void this.d.memory.save({ text, title, project: this.d.project, metadata: { peer: by, task: task.id, kind } });
2410
+ }
1595
2411
  }
1596
2412
 
1597
2413
  private tell(task: Task, body: string, pii: boolean): void {