@staix/agent-hub 0.12.23 → 0.12.24

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 (158) hide show
  1. package/CHANGELOG.md +98 -0
  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 +8 -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 +4 -0
  15. package/docs/agent-notes/tests.md +2 -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 +651 -14
  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 +1 -1
  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 +282 -0
  32. package/docs/specs/2026-09-19-agent-hub-design.md +601 -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/verified.json +22 -13
  43. package/package.json +2 -1
  44. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  45. package/plugins/agent-hub/ROLE-NOTICE.md +26 -0
  46. package/plugins/agent-hub/server.js +1175 -339
  47. package/scripts/check-docs.mjs +129 -0
  48. package/src/adapters/acp.ts +112 -13
  49. package/src/adapters/claude-channel.ts +48 -11
  50. package/src/adapters/codex-appserver.ts +11 -0
  51. package/src/adapters/jcode.ts +110 -0
  52. package/src/adapters/local-worker.ts +10 -7
  53. package/src/adapters/pi.ts +94 -20
  54. package/src/cli/attention.ts +60 -0
  55. package/src/cli/bench.ts +24 -5
  56. package/src/cli/board-read.ts +12 -0
  57. package/src/cli/console-state.ts +103 -17
  58. package/src/cli/console.ts +54 -8
  59. package/src/cli/facts-hook.ts +12 -4
  60. package/src/cli/help.ts +35 -9
  61. package/src/cli/identity.ts +18 -7
  62. package/src/cli/init.ts +48 -10
  63. package/src/cli/jcode-mcp.ts +58 -0
  64. package/src/cli/kimi-mcp.ts +53 -0
  65. package/src/cli/launch.ts +16 -1
  66. package/src/cli/main.ts +392 -57
  67. package/src/cli/memory.ts +193 -0
  68. package/src/cli/output.ts +45 -4
  69. package/src/cli/pet-config.ts +1 -0
  70. package/src/cli/pet.ts +83 -0
  71. package/src/cli/preview.ts +38 -5
  72. package/src/cli/status-lines.ts +1 -1
  73. package/src/cli/tail-render.ts +1 -0
  74. package/src/cli/upgrade-runtime.ts +32 -3
  75. package/src/cli/watch.ts +31 -0
  76. package/src/hub/activity.ts +53 -0
  77. package/src/hub/ask.ts +10 -5
  78. package/src/hub/bench.ts +10 -3
  79. package/src/hub/board.ts +222 -16
  80. package/src/hub/bus.ts +48 -9
  81. package/src/hub/citations.ts +7 -0
  82. package/src/hub/conductor.ts +12 -7
  83. package/src/hub/config-trust.ts +4 -2
  84. package/src/hub/crash.ts +9 -0
  85. package/src/hub/daemon.ts +910 -120
  86. package/src/hub/delivery-journal.ts +5 -0
  87. package/src/hub/envelope.ts +1 -1
  88. package/src/hub/events.ts +12 -2
  89. package/src/hub/facts.ts +26 -7
  90. package/src/hub/git.ts +8 -2
  91. package/src/hub/github-import.ts +28 -0
  92. package/src/hub/hub-tools.ts +49 -10
  93. package/src/hub/inference.ts +1 -1
  94. package/src/hub/lifecycle-schema.ts +17 -0
  95. package/src/hub/listen.ts +15 -0
  96. package/src/hub/manager.ts +12 -5
  97. package/src/hub/path-policy.ts +96 -0
  98. package/src/hub/peer-catalog.ts +28 -0
  99. package/src/hub/permission-mode.ts +19 -5
  100. package/src/hub/planning.ts +70 -0
  101. package/src/hub/profiles.ts +74 -0
  102. package/src/hub/projected-diff.ts +73 -0
  103. package/src/hub/projection-exec.ts +24 -0
  104. package/src/hub/research.ts +144 -21
  105. package/src/hub/roles.ts +123 -0
  106. package/src/hub/roster.ts +23 -0
  107. package/src/hub/routing.ts +18 -5
  108. package/src/hub/sdlc-queue.ts +112 -0
  109. package/src/hub/sdlc-release.ts +125 -0
  110. package/src/hub/sdlc.ts +250 -0
  111. package/src/hub/settings.ts +34 -15
  112. package/src/hub/shipped-roles.ts +25 -0
  113. package/src/hub/snapshots.ts +89 -15
  114. package/src/hub/start-mode.ts +7 -5
  115. package/src/hub/supervision.ts +1 -0
  116. package/src/hub/task-sweep.ts +1 -1
  117. package/src/hub/tasks.ts +892 -87
  118. package/src/hub/text-projection.ts +126 -0
  119. package/src/hub/tooling.ts +159 -0
  120. package/src/hub/ui.ts +16 -2
  121. package/src/hub/workflow.ts +71 -0
  122. package/src/local/sandbox.ts +6 -4
  123. package/src/local/tools.ts +11 -6
  124. package/src/memory/brief.ts +28 -14
  125. package/src/memory/import.ts +55 -0
  126. package/src/memory/recall.ts +84 -0
  127. package/src/memory/store.ts +750 -0
  128. package/src/memory/sync.ts +145 -0
  129. package/src/pet/NOTICE.md +10 -0
  130. package/src/pet/characters.ts +1438 -0
  131. package/src/pet/config.ts +31 -0
  132. package/src/pet/frame.ts +17 -0
  133. package/src/pet/pixels.ts +28 -0
  134. package/src/role-text.d.ts +1 -0
  135. package/src/ui/index.html +842 -46
  136. package/src/ui/needs.ts +58 -0
  137. package/src/ui/office-model.ts +440 -0
  138. package/src/ui/peer-palette.ts +16 -0
  139. package/src/ui/task-board.ts +49 -0
  140. package/src/ui/task-progress.ts +5 -1
  141. package/templates/AGENTS.block.md +3 -2
  142. package/templates/config.json +5 -1
  143. package/templates/profiles/dev.json +22 -0
  144. package/templates/profiles/general.json +26 -0
  145. package/templates/profiles/research.json +57 -0
  146. package/templates/profiles/writing.json +28 -0
  147. package/templates/roles/NOTICE.md +26 -0
  148. package/templates/roles/dev/backend-engineer.md +26 -0
  149. package/templates/roles/dev/code-reviewer.md +26 -0
  150. package/templates/roles/dev/frontend-engineer.md +26 -0
  151. package/templates/roles/dev/security-reviewer.md +26 -0
  152. package/templates/roles/dev/software-architect.md +26 -0
  153. package/templates/roles/dev/test-engineer.md +26 -0
  154. package/templates/roles/research/analyst.md +26 -0
  155. package/templates/roles/research/fact-checker.md +26 -0
  156. package/templates/roles/research/research-lead.md +26 -0
  157. package/templates/roles/research/researcher.md +26 -0
  158. 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,371 @@ 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
+ wip(peer: PeerId, except?: number): number {
568
+ return this.d.board.plannerWip(peer, except);
569
+ }
570
+ private prerequisiteOf(task: Task, prerequisite: number): boolean {
571
+ const pending = [...(task.deps ?? [])], seen = new Set<number>();
572
+ 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 ?? [])); }
573
+ return false;
574
+ }
575
+ private plannerFull(task: Task): PeerId[] {
576
+ 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; });
577
+ }
578
+ private plannerAssignment(task: Task, reservedOnly = false): Assignment {
579
+ const states = this.states();
580
+ const full = this.plannerFull(task);
581
+ // Preview the To Do transition without routing or offering the persisted backlog row.
582
+ 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) });
583
+ }
584
+
585
+ async pullPlanned(by: PeerId, input: { id: number; revision: number; reason: string }): Promise<Task> {
586
+ this.planningAuthority(by);
587
+ 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");
588
+ const validate = () => {
589
+ const task = this.d.board.get(input.id);
590
+ 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");
591
+ const release = task.release == null ? undefined : this.d.board.release(task.release);
592
+ if (task.release != null && (!release || ["frozen", "shipped", "dropped"].includes(release.state))) throw new PreEffectToolRefusal("planner pull requires unfrozen release scope");
593
+ if (this.waitsFor(task).length) throw new PreEffectToolRefusal("planner pull waits for dependency approval");
594
+ const priority = task.priority ?? (task.signals.includes("urgent") ? 0 : 2);
595
+ if (priority === 0) {
596
+ 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")));
597
+ 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];
598
+ const order = effectiveOrder(task) ?? { rank: task.rank ?? Number.MAX_SAFE_INTEGER, id: task.id };
599
+ if (this.d.board.list("backlog").some(other => {
600
+ if (other.id === task.id || this.prerequisiteOf(other, task.id) || (other.priority ?? (other.signals.includes("urgent") ? 0 : 2)) !== 0) return false;
601
+ const competing = effectiveOrder(other);
602
+ return !!competing && (competing.rank < order.rank || competing.rank === order.rank && competing.id < order.id);
603
+ })) throw new PreEffectToolRefusal("a person's P0 backlog order is retained");
604
+ }
605
+ const assignment = this.plannerAssignment(task);
606
+ if (!assignment.owner) throw new PreEffectToolRefusal("no peer has eligible planner WIP capacity");
607
+ 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);
608
+ if (task.reserved && reservation?.by === USER && assignment.owner !== task.reserved) throw new PreEffectToolRefusal("a person's reservation is retained");
609
+ return { task, owner: assignment.owner };
610
+ };
611
+ const initial = validate(), withheld = await this.withholds("note", input.reason, initial.task);
612
+ const current = validate();
613
+ const pulled = this.d.board.pullPlanned(current.task.id, by, current.owner, input.revision, input.reason.trim(), withheld);
614
+ this.planningRecorded({ by, task: pulled.id, fields: ["pull", "reserved"], revision: pulled.planningRevision! });
615
+ return pulled;
616
+ }
617
+
618
+ backlogList(by: PeerId): Record<string, unknown>[] {
619
+ this.planningAuthority(by);
620
+ 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));
621
+ }
622
+ private addPlannerBacklog(peer: PeerId, ids: readonly number[]): void {
623
+ if (!this.d.roles?.[peer]?.includes("planner")) return;
624
+ const pending = this.plannerBacklog.get(peer) ?? new Set<number>();
625
+ for (const id of ids) if (this.d.board.get(id)?.state === "backlog") pending.add(id);
626
+ if (pending.size) this.plannerBacklog.set(peer, pending);
627
+ }
628
+ /** One bounded ids-only batch per attached planner; failed or evicted copies stay pending. */
629
+ flushPlannerNotices(): number {
630
+ const configured = Object.entries(this.d.roles ?? {}).filter(([, roles]) => roles.includes("planner")).map(([peer]) => peer);
631
+ for (const peer of this.knownPlanners) if (!configured.includes(peer)) {
632
+ this.knownPlanners.delete(peer); this.plannerBacklog.delete(peer); this.plannerBlockedQueue.delete(peer);
633
+ }
634
+ for (const peer of configured) if (!this.knownPlanners.has(peer)) {
635
+ this.knownPlanners.add(peer);
636
+ const retained = this.d.bus.queuedBacklogIds(peer);
637
+ this.addPlannerBacklog(peer, this.d.board.list("backlog").filter(task => !retained.has(task.id)).map(task => task.id));
638
+ }
639
+ const planners = configured.filter(peer => this.d.bus.isRostered(peer) && this.d.bus.peers.has(peer) && this.d.bus.stateOf(peer) !== "offline");
640
+ if (!planners.length) return 0;
641
+ for (const id of this.incomingBacklog) if (this.d.board.get(id)?.state !== "backlog") this.incomingBacklog.delete(id);
642
+ const incoming = [...this.incomingBacklog].sort((a, b) => a - b).slice(0, 64);
643
+ if (incoming.length) {
644
+ for (const peer of configured) {
645
+ const queued = this.d.bus.queuedBacklogIds(peer);
646
+ this.addPlannerBacklog(peer, incoming.filter(id => !queued.has(id)));
647
+ }
648
+ }
649
+ for (const id of incoming) this.incomingBacklog.delete(id);
650
+ const notified = new Set<number>();
651
+ for (const peer of planners) {
652
+ const pending = this.plannerBacklog.get(peer);
653
+ if (!pending) continue;
654
+ const blockedQueue = this.plannerBlockedQueue.get(peer);
655
+ if (blockedQueue !== undefined && blockedQueue === JSON.stringify(this.d.bus.queueIds(peer))) continue;
656
+ this.plannerBlockedQueue.delete(peer);
657
+ for (const id of pending) if (this.d.board.get(id)?.state !== "backlog") pending.delete(id);
658
+ const ids = [...pending].sort((a, b) => a - b).slice(0, 64);
659
+ if (!ids.length) continue;
660
+ // Remove before publish: an immediate overflow callback may put this recipient's ids back.
661
+ for (const id of ids) pending.delete(id);
662
+ try {
663
+ 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 } }));
664
+ if (!queued.includes(peer)) this.addPlannerBacklog(peer, ids);
665
+ for (const id of ids) notified.add(id);
666
+ } catch (error) { this.addPlannerBacklog(peer, ids); throw error; }
667
+ }
668
+ return notified.size;
669
+ }
670
+ releaseList(by: PeerId): Record<string, unknown>[] {
671
+ this.planningAuthority(by);
672
+ const scopes = new Map<number, { id: number; state: Task["state"]; deps: number[] }[]>();
673
+ type Approval = { id: number; release: Task["release"]; doneAt: number | null };
674
+ const approved: Approval[] = [], owned = new Map<number, Approval[]>();
675
+ for (const task of this.d.board.list()) {
676
+ if (typeof task.release === "number") {
677
+ const scope = scopes.get(task.release) ?? [];
678
+ scope.push({ id: task.id, state: task.state, deps: task.deps ?? [] }); scopes.set(task.release, scope);
679
+ }
680
+ if (task.state !== "approved") continue;
681
+ const row = { id: task.id, release: task.release, doneAt: actualTaskDates(task).doneAt };
682
+ approved.push(row);
683
+ if (typeof task.release === "number") { const rows = owned.get(task.release) ?? []; rows.push(row); owned.set(task.release, rows); }
684
+ }
685
+ const compare = (a: Approval, b: Approval) => a.doneAt! - b.doneAt! || a.id - b.id;
686
+ const known = approved.filter(task => task.doneAt !== null).sort(compare), unknown = approved.filter(task => task.doneAt === null);
687
+ const after = (rows: Approval[], cut: number) => {
688
+ let low = 0, high = rows.length;
689
+ while (low < high) { const mid = Math.floor((low + high) / 2); if (rows[mid]!.doneAt! <= cut) low = mid + 1; else high = mid; }
690
+ return low;
691
+ };
692
+ // ponytail: audit previews show 32 ids plus exact counts; add a paginated audit endpoint for exhaustive lists.
693
+ const outsideIds = (rows: Approval[], start: number, release: number) => {
694
+ const result: number[] = [];
695
+ for (let i = start; i < rows.length && result.length < 32; i++) if (rows[i]!.release !== release) result.push(rows[i]!.id);
696
+ return result;
697
+ };
698
+ return this.d.board.releases().map(release => {
699
+ const own = owned.get(release.id) ?? [], ownKnown = own.filter(task => task.doneAt !== null).sort(compare);
700
+ const cutStart = release.cut === null ? known.length : after(known, release.cut);
701
+ const unknownPool = release.cut === null ? approved : unknown;
702
+ return { ...release,
703
+ name: this.nameable(release.name) ? release.name : "[withheld]",
704
+ goal: this.nameable(release.goal) ? release.goal : "[withheld]",
705
+ tasks: scopes.get(release.id) ?? [],
706
+ afterCutUnknown: outsideIds(unknownPool, 0, release.id),
707
+ afterCutUnknownCount: release.cut === null ? approved.length - own.length : unknown.length - (own.length - ownKnown.length),
708
+ afterCut: release.cut === null ? null : outsideIds(known, cutStart, release.id),
709
+ afterCutCount: release.cut === null ? null : known.length - cutStart - (ownKnown.length - after(ownKnown, release.cut)),
710
+ };
711
+ });
712
+ }
713
+
714
+ async planBacklog(by: PeerId, input: { id: number; revision: number; fields: unknown; reason: string }): Promise<Task> {
715
+ this.planningAuthority(by);
716
+ if (!Number.isSafeInteger(input.id) || input.id <= 0 || !Number.isSafeInteger(input.revision) || input.revision < 0) throw new PreEffectToolRefusal("invalid planning identity or revision");
717
+ if (typeof input.reason !== "string" || !input.reason.trim() || input.reason.length > 1000) throw new PreEffectToolRefusal("planning needs a bounded reason");
718
+ let patch: PlanningFields;
719
+ try { patch = planningPatch(input.fields); } catch (error) { throw new PreEffectToolRefusal((error as Error).message); }
720
+ const validate = () => {
721
+ const task = this.d.board.get(input.id);
722
+ if (!task || (task.planningRevision ?? 0) !== input.revision) throw new PreEffectToolRefusal("planning revision changed");
723
+ if (task.state === "approved") throw new PreEffectToolRefusal("approved work is final");
724
+ if (by !== USER && (task.state !== "backlog" || taskHasStarted(task) || task.pinned || Object.hasOwn(patch, "pinned"))) throw new PreEffectToolRefusal("planner changes only unpinned backlog work");
725
+ 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");
726
+ if (Object.hasOwn(patch, "deps") || Object.hasOwn(patch, "reserved")) {
727
+ if (task.state !== "backlog" || taskHasStarted(task) || task.owner) throw new PreEffectToolRefusal("dependency and reservation changes require unstarted backlog work");
728
+ 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");
729
+ if (patch.reserved && !this.d.bus.isRostered(patch.reserved)) throw new PreEffectToolRefusal("reserved owner is off roster");
730
+ const rows = new Map(this.d.board.list().map(row => [row.id, row]));
731
+ const pending = [...(patch.deps ?? task.deps ?? [])], seen = new Set<number>();
732
+ while (pending.length) {
733
+ const id = pending.pop()!;
734
+ if (id === task.id) throw new PreEffectToolRefusal("planning dependencies would form a cycle");
735
+ if (seen.has(id)) continue;
736
+ seen.add(id);
737
+ const dependency = rows.get(id);
738
+ if (!dependency) throw new PreEffectToolRefusal(`no dependency task #${id}`);
739
+ pending.push(...(dependency.deps ?? []));
740
+ }
741
+ }
742
+ const target = patch.release === undefined ? task.release : patch.release;
743
+ if (target != null && !this.d.board.release(target)) throw new PreEffectToolRefusal("no such release");
744
+ for (const id of new Set([task.release, target])) {
745
+ if (id == null) continue;
746
+ const release = this.d.board.release(id)!;
747
+ if (["shipped", "dropped"].includes(release.state)) throw new PreEffectToolRefusal("settled release scope is final");
748
+ if (by !== USER && release.state === "frozen") throw new PreEffectToolRefusal("frozen release changes require a person");
749
+ }
750
+ return task;
751
+ };
752
+ const task = validate(), withheld = await this.withholds("note", input.reason, task);
753
+ validate(); // a person may have changed planning or frozen a release while the screen ran
754
+ const updated = this.d.board.plan(task.id, by, patch, input.revision, input.reason.trim(), withheld);
755
+ this.planningRecorded({ by, task: task.id, fields: Object.keys(patch), revision: updated.planningRevision! });
756
+ return updated; // persistence/readback failures must never claim no effect
757
+ }
758
+
759
+ async undoPlanning(by: PeerId, id: number, revision: number): Promise<Task> {
760
+ this.planningAuthority(by);
761
+ const task = this.d.board.get(id), entry = task ? planningUndoEntry(task) : undefined;
762
+ 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");
763
+ if (entry.planning.split) {
764
+ const release = task.release == null ? undefined : this.d.board.release(task.release);
765
+ 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");
766
+ 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");
767
+ const childIds = new Set(entry.planning.split.children.map(child => child.id)), budgets = this.d.executionBudget?.status();
768
+ 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");
769
+ if (this.d.store) {
770
+ try { this.d.store.store.refresh(); } catch { throw new PreEffectToolRefusal("split undo requires current memory link evidence"); }
771
+ 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)));
772
+ 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");
773
+ }
774
+ const restored = this.d.board.undoBacklogSplit(id, by, revision);
775
+ this.planningRecorded({ by, task: id, fields: ["split", "deps"], revision: restored.planningRevision! });
776
+ return restored;
777
+ }
778
+ if (entry.planning.pull) {
779
+ const release = task.release == null ? undefined : this.d.board.release(task.release);
780
+ 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");
781
+ const returned = this.d.board.undoPlannedPull(id, by, revision);
782
+ this.offered.delete(id); this.incomingBacklog.add(id);
783
+ this.planningRecorded({ by, task: id, fields: ["pull", "reserved"], revision: returned.planningRevision! });
784
+ return returned;
785
+ }
786
+ return this.planBacklog(by, { id, revision, fields: entry.planning.previous, reason: `undo planning revision ${revision}` });
787
+ }
788
+
789
+ async importGitHub(by: PeerId, input: GitHubImportSelection, run?: SdlcGh): Promise<{ imported: number[]; existing: number[]; partial: boolean; complete: boolean; unconfirmed?: true }> {
790
+ if (by !== USER) throw new PreEffectToolRefusal("GitHub import is a person console command");
791
+ const deadline = performance.now() + 20_000;
792
+ const page = await readGitHubImport(input, run ?? ((args, timeout, _cwd, bound) => sdlcGh(args, timeout, this.d.cwd, bound)));
793
+ const imported: number[] = [], existing: number[] = [];
794
+ for (const issue of page.issues) {
795
+ if (performance.now() >= deadline) return { imported, existing, partial: true, complete: false };
796
+ let created = false;
797
+ try {
798
+ const prior = this.d.board.imported(issue.source.repo, issue.source.issue);
799
+ if (prior) { existing.push(prior.id); continue; }
800
+ const classes = profileData(this.d.profileData ?? this.d.profile ?? "dev").classes;
801
+ const cls = Object.keys(classes).find(name => classes[name] === "implement") ?? Object.keys(classes)[0];
802
+ const task = await this.propose(USER, { title: issue.title, detail: issue.detail, class: cls, backlog: true }, issue.source, () => { created = true; });
803
+ (created ? imported : existing).push(task.id);
804
+ if (performance.now() >= deadline) return { imported, existing, partial: true, complete: false };
805
+ } catch {
806
+ if (created) {
807
+ try {
808
+ const saved = this.d.board.imported(issue.source.repo, issue.source.issue);
809
+ 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 }; }
810
+ } catch { /* A failed readback leaves the committed identity unconfirmed. */ }
811
+ }
812
+ return { imported, existing, partial: true, complete: false, unconfirmed: true };
813
+ }
814
+ }
815
+ return { imported, existing, partial: page.partial, complete: !page.partial };
816
+ }
817
+
818
+ async splitBacklog(by: PeerId, input: { id: number; revision: number; parts: unknown; reason: string }): Promise<{ parent: Task; children: Task[] }> {
819
+ this.planningAuthority(by);
820
+ const { id, revision } = input;
821
+ 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");
822
+ const reason = input.reason.trim();
823
+ let parts: unknown;
824
+ try { parts = structuredClone(input.parts); } catch { throw new PreEffectToolRefusal("split parts must be cloneable data"); }
825
+ if (!Array.isArray(parts) || parts.length < 2 || parts.length > 8) throw new PreEffectToolRefusal("split needs two to eight parts");
826
+ const rows = parts as Record<string, unknown>[];
827
+ const validate = () => {
828
+ const parent = this.d.board.get(id);
829
+ 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");
830
+ const release = parent.release == null ? undefined : this.d.board.release(parent.release);
831
+ 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");
832
+ const profile = parent.profile ?? "dev";
833
+ const drafts = rows.map((part, index) => {
834
+ 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");
835
+ const given = part.class ?? parent.profileClass ?? parent.class;
836
+ const storedProfile = !parent.profile ? "dev" : profile === (this.d.profile ?? "dev") ? this.d.profileData ?? profile : profile;
837
+ let cls: TaskClass;
838
+ try { cls = part.class === undefined ? parent.class : profileClass(given as string, storedProfile); } catch (error) { throw new PreEffectToolRefusal((error as Error).message); }
839
+ const after = part.after ?? [], afterParts = part.afterParts ?? [];
840
+ 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");
841
+ const deps = [...new Set([...(parent.deps ?? []), ...after as number[]])], pending = [...deps], seen = new Set<number>();
842
+ const direct = afterParts.length ? deps.filter(dep => !parent.deps?.includes(dep)) : deps;
843
+ if (direct.length + afterParts.length > 64) throw new PreEffectToolRefusal("split child dependencies exceed the 64-edge cap");
844
+ 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 ?? [])); }
845
+ const refs = part.refs === undefined ? parent.refs : cleanRefs(part.refs), plan = cleanPlan(part.plan, storedProfile);
846
+ 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 } : {}) } : {}) };
847
+ const fields: PlanningFields = {};
848
+ for (const key of ["priority", "release", "plannedStart", "due"] as const) if (parent[key] !== undefined) fields[key] = parent[key];
849
+ return { ...draft, fields };
850
+ });
851
+ return { parent, drafts };
852
+ };
853
+ const initial = validate(), screening = this.screenOn();
854
+ const initialPrivacy = initial.drafts.map(part => this.signalsFor({ ...part, detail: [part.detail, planText(part.plan)].join("\n") }).filter(signal => signal === "pii" || signal === "pii_path"));
855
+ const initialPrivate = initialPrivacy.map(signals => this.isPii({ signals })), initialParentPrivate = this.isPii(initial.parent);
856
+ const initialParentPrivacy = initial.parent.signals.filter(signal => signal === "pii" || signal === "pii_path");
857
+ const initialReasonPrivate = initialParentPrivate || !this.nameable(reason);
858
+ let deadline: ReturnType<typeof setTimeout> | undefined;
859
+ const attempt = Promise.all([
860
+ Promise.all(initial.drafts.map((part, index) => screening && !initialParentPrivate && !initialPrivate[index] ? this.screenCall(screenText({ title: part.title, detail: part.detail, plan: part.plan })) : undefined)),
861
+ this.withholds("note", reason, initial.parent),
862
+ ]);
863
+ let screened: Awaited<typeof attempt>;
864
+ 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); })]); }
865
+ finally { if (deadline !== undefined) clearTimeout(deadline); }
866
+ const [screenedVerdicts, withheld] = screened, current = validate();
867
+ const finalSignals = current.drafts.map(part => this.signalsFor({ ...part, detail: [part.detail, planText(part.plan)].join("\n") }));
868
+ const deterministicPrivate = finalSignals.map((signals, index) => initialPrivate[index] || this.isPii({ signals }));
869
+ const verdicts = screenedVerdicts.map((verdict, index) => deterministicPrivate[index] ? undefined : verdict);
870
+ const inherited = current.drafts.map((_part, index) => (initialParentPrivate || this.isPii(current.parent)) && !deterministicPrivate[index]);
871
+ 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"] : [])])] }));
872
+ const result = this.d.board.splitBacklog(id, by, revision, drafts, reason, withheld || initialReasonPrivate || this.isPii(current.parent) || !this.nameable(reason));
873
+ for (const [index, child] of result.children.entries()) {
874
+ this.incomingBacklog.add(child.id);
875
+ 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`); }
876
+ }
877
+ this.planningRecorded({ by, task: id, fields: ["split", "deps"], revision: result.parent.planningRevision! });
878
+ return result;
879
+ }
880
+
881
+ async newRelease(by: PeerId, input: { name: string; goal: string; order: number }): Promise<Release> {
882
+ this.planningAuthority(by);
883
+ 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");
884
+ const text = input.name + "\n" + input.goal;
885
+ if (!this.nameable(text) || this.screenOn() && (await this.screenCall(text)).label !== "clear") throw new PreEffectToolRefusal("release text was not cleared for public planning");
886
+ if (this.d.board.releases().some(release => release.name === input.name)) throw new PreEffectToolRefusal("release name already exists");
887
+ const release = this.d.board.newRelease(input.name, input.goal.trim(), input.order);
888
+ this.planningRecorded({ by, release: release.id, fields: ["created"], revision: release.revision });
889
+ return release;
890
+ }
891
+
892
+ changeRelease(by: PeerId, input: { id: number; revision: number; state?: ReleaseState; plannedStart?: number | null; cut?: number | null; ship?: number | null }): Release {
893
+ this.planningAuthority(by);
894
+ const release = this.d.board.release(input.id);
895
+ if (!Number.isSafeInteger(input.id) || input.id <= 0 || !Number.isSafeInteger(input.revision) || !release || release.revision !== input.revision) throw new PreEffectToolRefusal("release revision changed");
896
+ if (["shipped", "dropped"].includes(release.state)) throw new PreEffectToolRefusal("settled release is final");
897
+ 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");
898
+ 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");
899
+ const { id, revision, ...patch } = input;
900
+ if (!Object.keys(patch).length || Object.keys(patch).some(key => !["state", "plannedStart", "cut", "ship"].includes(key))) throw new PreEffectToolRefusal("unknown or empty release fields");
901
+ const updated = this.d.board.updateRelease(id, patch, revision);
902
+ this.planningRecorded({ by, release: id, fields: Object.keys(patch), revision: updated.revision });
903
+ return updated; // a failed readback may follow a durable release write
904
+ }
905
+
906
+ 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> {
907
+ external = external ? { ...external } : undefined;
908
+ if (external && (by !== USER || input.backlog !== true)) throw new PreEffectToolRefusal("external intake requires person-owned backlog staging");
909
+ if (input.backlog !== undefined && typeof input.backlog !== "boolean") throw new PreEffectToolRefusal("backlog must be a boolean");
910
+ const backlog = input.backlog === true;
911
+ if (backlog && input.owner && !this.d.bus.isRostered(input.owner)) throw new PreEffectToolRefusal(`${input.owner} is off roster; add it before reserving work`);
496
912
  // Callers are models: a title is one line (it is part of console and hub.log lines), not a document.
497
913
  const title = String(input.title ?? "").replace(/\s+/g, " ").trim().slice(0, 300);
498
914
  if (!title) throw new PreEffectToolRefusal("title is required");
915
+ const goal = input.goal == null || input.goal === "" ? undefined : this.goalFor(by, input.goal);
499
916
  // Dependencies can only name tasks that exist, so the new task closes no cycle: nothing can depend on it yet.
500
917
  // Callers are models: a missing or null `after` is no dependency, and an id is an integer or a digit string.
501
918
  let afterIds: unknown[] = [];
@@ -509,40 +926,54 @@ export class Tasks {
509
926
  if (!deps.includes(id)) deps.push(id);
510
927
  }
511
928
  // 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;
929
+ const reserved = backlog || this.waitsFor({ deps }).length ? input.owner : undefined;
513
930
  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(", ")}`);
931
+ const profile = this.d.profile ?? "dev";
932
+ let baseGiven: TaskClass | undefined;
933
+ try { if (given !== undefined) baseGiven = profileClass(given, this.d.profileData ?? profile); } catch (error) { throw new PreEffectToolRefusal((error as Error).message); }
515
934
  const text = { title, detail: String(input.detail ?? "").slice(0, 8000), refs: cleanRefs(input.refs) };
516
- const plan = cleanPlan(input.plan);
935
+ const plan = cleanPlan(input.plan, this.d.profileData ?? profile);
517
936
  // 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);
937
+ const screenDetail = [text.detail, external?.repo, external?.url].filter(value => value !== undefined && value !== "").join("\n");
938
+ const signals = this.signalsFor({ ...text, detail: [screenDetail, planText(plan)].join("\n"), plan });
519
939
  // Urgent work is handed over even when its paused owner's window resets soon (issue #36).
520
940
  if (input.urgent === true && !signals.includes("urgent")) signals.push("urgent");
521
941
  // The PII screen (issue #198) reads the same text before anything else does: until its verdict the task is on no
522
942
  // board, in no envelope and before no other model. A pattern match is PII without a call; no verdict is PII too.
523
943
  const screening = this.screenOn();
524
- const screened = screening && !signals.includes("pii") ? await this.screenCall(screenText({ ...text, plan })) : undefined;
944
+ const screened = screening && !signals.includes("pii") ? await this.screenCall(screenText({ ...text, detail: screenDetail, plan })) : undefined;
525
945
  if (screened && screened.label !== "clear") signals.push("pii");
526
- let cls = given as TaskClass | undefined;
946
+ let cls = baseGiven;
527
947
  let triaged = false;
528
948
  let fenced = false; // a PII task off campus: triage is not asked
529
949
  if (!cls && this.d.triage) {
530
950
  // 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";
951
+ const pii = this.isPii({ signals });
532
952
  if (!pii || (await this.d.triage.onCampus().catch(() => false))) cls = await this.d.triage.classify(text.title, text.detail).catch(() => undefined);
533
953
  else fenced = true;
534
954
  triaged = !!cls;
535
955
  }
536
956
  // 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;
957
+ const defaulted = !backlog && !cls && input.owner === by;
538
958
  if (defaulted) cls = "implement";
539
959
  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 });
960
+ let selected;
961
+ try { selected = taskRole(input.role, cls, this.d.catalog ?? loadRoleCatalog(this.d.cwd)); }
962
+ catch (error) { throw new PreEffectToolRefusal((error as Error).message); }
963
+ 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 } : {}) } : {}) };
964
+ if (external) { const prior = this.d.board.imported(external.repo, external.issue); if (prior) return prior; }
965
+ let task = this.d.board.propose(by, { ...draft, plan, ...(external ? { external } : {}), ...(deps.length ? { deps } : {}), ...(reserved ? { reserved } : {}), signals, ...(backlog ? { backlog: true } : {}) });
966
+ created?.();
542
967
  if (screening) task = this.screenedTask(task, screened);
968
+ if (goal) this.linkNative(task, goal, by);
543
969
  if (triaged) task = this.d.board.update(task.id, "hub", "triaged", {}, `class ${cls} named by the hub's model`);
544
970
  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(", ")}` : ""}`);
971
+ if (backlog) {
972
+ this.incomingBacklog.add(task.id);
973
+ this.releaseNotice(`task ${this.publicTitle(task)} backlogged by ${by} [${profileTaskLabel(task)}]`);
974
+ return task;
975
+ }
976
+ this.d.notify(`task ${this.publicTitle(task)} proposed by ${by} [${profileTaskLabel(task)}]${task.signals.length ? ` signals: ${task.signals.join(", ")}` : ""}`);
546
977
  // Re-read: a dependency approved while triage was awaited looked for its dependents before this row existed.
547
978
  const still = this.waitsFor(task);
548
979
  if (still.length) {
@@ -550,14 +981,37 @@ export class Tasks {
550
981
  return this.d.board.update(task.id, HUB, "blocked", {}, `waits for ${still.map((id) => `#${id}`).join(", ")}`);
551
982
  }
552
983
  // 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 } : {});
984
+ return this.assignOwner(task, by, input.owner ? { candidates: [input.owner], namedOwner: input.owner, claim: input.owner === by } : {});
985
+ }
986
+
987
+ /** Person-owned staging only. Flow transitions remain with their existing owners and reviewers. */
988
+ async moveBacklog(by: PeerId, id: unknown, pull: boolean): Promise<Task> {
989
+ if (by !== USER) throw new PreEffectToolRefusal("backlog pull/return is a console command");
990
+ if (typeof pull !== "boolean") throw new PreEffectToolRefusal("pull must be a boolean");
991
+ const n = typeof id === "number" ? id : typeof id === "string" && /^\s*\d+\s*$/.test(id) ? Number(id) : NaN;
992
+ if (!Number.isSafeInteger(n) || n <= 0) throw new PreEffectToolRefusal("id must be a positive task number");
993
+ const task = this.d.board.get(n);
994
+ if (!task) throw new PreEffectToolRefusal(`no task #${n}`);
995
+ 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"}`);
996
+ if (!pull && taskHasStarted(task)) throw new PreEffectToolRefusal(`task #${n}: started work cannot return to backlog`);
997
+ const pulled = task.history.findLast(entry => entry.event === "backlog pulled" || entry.event === "backlog returned");
998
+ const restoreReservation = !pull && pulled?.planning?.pull && !task.history.slice(task.history.lastIndexOf(pulled) + 1).some(entry => entry.event === "reserved" || entry.planning?.fields.includes("reserved"));
999
+ 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 } : {}) });
1000
+ this.offered.delete(n);
1001
+ if (!pull) { this.incomingBacklog.add(n); return next; }
1002
+ const waits = this.waitsFor(next);
1003
+ if (waits.length) return this.d.board.update(n, HUB, "blocked", {}, `waits for ${waits.map(v => `#${v}`).join(", ")}`);
1004
+ // This persisted ready marker also lets a restart offer ownerless work once a peer attaches.
1005
+ const ready = this.d.board.update(n, HUB, "ready", {}, "pulled from backlog");
1006
+ return this.assignOwner(ready, by);
554
1007
  }
555
1008
 
556
1009
  /** 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. */
1010
+ 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
1011
  private screenedTask(task: Task, v: PiiVerdict | undefined): Task {
558
1012
  this.recordScreen(task.id, "task", v);
559
1013
  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}`;
1014
+ const note = this.screenNote(v);
561
1015
  return this.d.board.update(task.id, HUB, "screened", {}, note);
562
1016
  }
563
1017
 
@@ -653,6 +1107,38 @@ export class Tasks {
653
1107
  /** The tasks an overlap text for `task` names: their plans are in it (issue #108). */
654
1108
  overlapTasks = (task: Task): number[] => this.overlapHits(task).map((h) => h.task.id);
655
1109
 
1110
+ /**
1111
+ * Dashboard warning links (#264): the board's overlap pairs and the live cohorts, as task and peer ids only.
1112
+ * Paths, symbols and plans stay out; PII tasks are excluded by the same rules overlapHits applies.
1113
+ * ponytail: O(open²) board reads per call, fine at dashboard board sizes; cache by board revision if it ever shows.
1114
+ */
1115
+ overlapLinks(): { tasks: [number, number]; peers: string[] }[] {
1116
+ const links: { tasks: [number, number]; peers: string[] }[] = [];
1117
+ const seen = new Set<string>();
1118
+ for (const task of this.d.board.list()) {
1119
+ if (!OPEN.includes(task.state) || !task.owner || this.isPii(task)) continue;
1120
+ for (const hit of this.overlapHits(task)) {
1121
+ const pair = [task.id, hit.task.id].sort((a, b) => a - b) as [number, number];
1122
+ const key = pair.join(":");
1123
+ if (seen.has(key)) continue;
1124
+ seen.add(key);
1125
+ links.push({ tasks: pair, peers: [...new Set([task.owner, hit.task.owner!])].sort() });
1126
+ }
1127
+ }
1128
+ return links;
1129
+ }
1130
+
1131
+ /** The live cohorts as dashboard links (#264): member task ids and their owners, nothing else. */
1132
+ cohortLinks(): { tasks: number[]; peers: string[] }[] {
1133
+ const links: { tasks: number[]; peers: string[] }[] = [];
1134
+ for (const cohort of this.cohorts.list()) {
1135
+ const members = [...cohort.members.values()].filter(member => { const task = this.d.board.get(member.task); return task && !this.isPii(task); });
1136
+ if (members.length < 2) continue;
1137
+ links.push({ tasks: members.map((m) => m.task).sort((a, b) => a - b), peers: [...new Set(members.map((m) => m.owner))].sort() });
1138
+ }
1139
+ return links;
1140
+ }
1141
+
656
1142
  /** While a gone owner's tasks move, its other tasks are about to move too: they are no one to settle with. */
657
1143
  private releasing: PeerId | undefined;
658
1144
 
@@ -681,6 +1167,16 @@ export class Tasks {
681
1167
  * for a recorded notice to this peer whose task is gone, has another owner, or left the states it was about.
682
1168
  */
683
1169
  relevant = (peer: PeerId, env: Envelope): boolean => {
1170
+ if (env.from === HUB && env.kind === "task" && env.refs?.backlog) {
1171
+ const ids = env.refs.backlog;
1172
+ if (!Array.isArray(ids) || !ids.length || ids.length > 64 || ids.some(id => !Number.isSafeInteger(id) || id <= 0)) return false;
1173
+ const current = ids.filter(id => this.d.board.get(id)?.state === "backlog");
1174
+ if (current.length !== ids.length || !this.d.roles?.[peer]?.includes("planner") || !this.d.bus.isRostered(peer)) {
1175
+ this.addPlannerBacklog(peer, current);
1176
+ return false;
1177
+ }
1178
+ return true;
1179
+ }
684
1180
  const c = this.conditional.get(env.id);
685
1181
  if (!c || c.peer !== peer) return true;
686
1182
  const t = this.d.board.get(c.task);
@@ -714,7 +1210,7 @@ export class Tasks {
714
1210
  */
715
1211
  private places(task: Task): { paths: string[]; symbols: string[] } {
716
1212
  const paths = [...(task.refs.paths ?? []), ...(task.plan?.paths ?? [])].map((p) => this.projectPath(p));
717
- return { paths: [...new Set(paths)], symbols: task.plan?.symbols ?? [] };
1213
+ return { paths: [...new Set(paths)], symbols: [...(task.plan?.symbols ?? []), ...(task.plan?.sections ?? []).map(v => `section ${v}`), ...(task.plan?.sources ?? []).map(v => `source ${v}`)] };
718
1214
  }
719
1215
 
720
1216
  private roots?: string[];
@@ -738,7 +1234,7 @@ export class Tasks {
738
1234
  return this.d.board.list().flatMap((t) => {
739
1235
  if (t.id === task.id || !t.owner || t.owner === task.owner || t.owner === this.releasing || !OPEN.includes(t.state) || this.isPii(t)) return [];
740
1236
  const theirs = this.places(t);
741
- const paths = mine.paths.filter((p) => theirs.paths.some((q) => samePlace(p, q)));
1237
+ 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
1238
  const symbols = mine.symbols.filter((x) => theirs.symbols.includes(x));
743
1239
  return paths.length || symbols.length ? [{ task: t, paths, symbols }] : [];
744
1240
  });
@@ -778,7 +1274,7 @@ export class Tasks {
778
1274
  // Awaits run between tasks (briefs, memory): re-read, and stop for anything that changed meanwhile.
779
1275
  const task = this.d.board.get(id);
780
1276
  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;
1277
+ 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
1278
  try {
783
1279
  const back = task.state === "in_progress" ? this.d.board.update(task.id, HUB, "released", { state: "proposed" }, why) : task;
784
1280
  const next = await this.assignOwner(back, HUB, { exclude: [peer], event: "reassigned", reason: "offline", note: why });
@@ -802,18 +1298,20 @@ export class Tasks {
802
1298
 
803
1299
  /** Same code as assignment, without doing it. `routing`: a candidate policy to explain under (the settings preview, #269). */
804
1300
  explain(target: number | { title: string; detail?: string; class: TaskClass; refs?: TaskRefs }, routing: Routing = this.d.routing()): string[] {
1301
+ const workflow = this.d.sdlc?.config().workflow === "gsd-lite" ? [`workflow: gsd-lite (${this.d.sdlc.config().mode}); checklist guidance, board dependencies authoritative`] : [];
805
1302
  if (typeof target === "number") {
806
1303
  const task = this.d.board.get(target);
807
1304
  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) });
1305
+ 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
1306
  // 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 ?? [])];
1307
+ 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
1308
  }
812
1309
  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;
1310
+ const signals = this.signalsFor(draft, routing);
1311
+ const base = profileClass(target.class, this.d.profileData ?? this.d.profile);
1312
+ 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
1313
  // 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;
1314
+ 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
1315
  }
818
1316
 
819
1317
  /** Public read path: every generated line is screened, including profile/config-derived trace fields. */
@@ -855,9 +1353,12 @@ export class Tasks {
855
1353
  }
856
1354
 
857
1355
  /** `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> {
1356
+ 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> {
1357
+ if (task.state === "backlog") throw new PreEffectToolRefusal(`task #${task.id}: pull backlog work before assigning it`);
859
1358
  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) });
1359
+ const plannedMove = opts.planner || this.plannerPullRecord(task)?.pull === true && (by === HUB || opts.roster) && (opts.reason !== "manual" || opts.roster);
1360
+ const plannerFull = plannedMove ? this.plannerFull(task) : [];
1361
+ 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
1362
 
862
1363
  if (waits.length) {
863
1364
  this.releaseNotice(`task ${this.publicTitle(task)} waits for ${waits.map((id) => `#${id}`).join(", ")}; it is offered once they are approved`);
@@ -865,7 +1366,9 @@ export class Tasks {
865
1366
  }
866
1367
  const passed = this.passedOver(task, a);
867
1368
  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>`);
1369
+ this.releaseNotice(opts.roster
1370
+ ? `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>`
1371
+ : `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
1372
  // Only a decline takes the task away from its owner; a failed console assign or escalation leaves it where it was.
870
1373
  if (opts.clearOnFail && task.owner) {
871
1374
  this.cohorts.leave(task.id);
@@ -877,7 +1380,7 @@ export class Tasks {
877
1380
  // A person's explicit assign replaces an agent's reservation (#207). Only a refusal keeps the owner it moves away from
878
1381
  // out for good: a reviewer's rejections or an escalation by hand, never the hub's own move after a failed delivery.
879
1382
  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! } : {}) });
1383
+ 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
1384
  // What calibration reads (issue #109): routing chose the first owner (no single named candidate, no claim; not an
882
1385
  // escalation, relay or reassignment of work already begun), and the work overlaps another owner's task not started
883
1386
  // yet. For the record only. Work routed back to its proposer is left out, as its observations are (by === owner).
@@ -925,25 +1428,61 @@ export class Tasks {
925
1428
  return next;
926
1429
  }
927
1430
 
1431
+ /** Person-only advice changes never change ownership, class or authority. */
1432
+ setRole(by: PeerId, id: unknown, raw: unknown): Task {
1433
+ if (by !== USER) throw new PreEffectToolRefusal("only the person may change an existing task catalog role");
1434
+ if (raw === undefined) throw new PreEffectToolRefusal("name a catalog role id or explicit null to clear it");
1435
+ const task = this.need(id, false, true);
1436
+ if (task.state === "approved") throw new PreEffectToolRefusal("an approved task's role is historical; propose new work instead");
1437
+ let selected;
1438
+ try { selected = taskRole(raw === null ? undefined : raw, task.class, this.d.catalog ?? loadRoleCatalog(this.d.cwd)); }
1439
+ catch (error) { throw new PreEffectToolRefusal((error as Error).message); }
1440
+ if (task.role === selected?.id) return task;
1441
+ const next = this.d.board.update(task.id, by, "catalog role changed", { role: selected?.id });
1442
+ for (const peer of new Set([next.owner, next.state === "in_review" ? next.reviewer : null])) {
1443
+ if (!peer || peer === USER) continue;
1444
+ this.releaseNotice(`task ${this.publicTitle(next)}: catalog role update delivery unconfirmed`, () => this.d.bus.publish(newEnvelope(HUB,
1445
+ `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."}`,
1446
+ { to: [peer], kind: "task", refs: { task: String(next.id) }, ...(this.isPii(next) ? { private: true } : {}) })));
1447
+ }
1448
+ return next;
1449
+ }
1450
+
1451
+ /** A self-claim has no assignment envelope; its tool result carries the same bounded guidance. */
1452
+ claimRoleGuidance(task: Task, peer: PeerId): string {
1453
+ return task.owner === peer && task.state === "in_progress" ? this.roleGuidance(task, peer) : "";
1454
+ }
1455
+
1456
+ private roleGuidance(task: Task, peer: PeerId, review = false): string {
1457
+ if (!task.role) return "";
1458
+ if (!review && this.d.sessionRoles?.(peer).includes(task.role)) return `Task role ${task.role} (already in your session guidance).`;
1459
+ const role = (this.d.catalog ?? loadRoleCatalog(this.d.cwd)).roles.get(task.role);
1460
+ if (role) try { return taskRoleText(role, review); } catch { /* a changed definition cannot break a saved assignment */ }
1461
+ return `Task role ${task.role}: definition unavailable or exceeds the task guidance limit; grants no authority.`;
1462
+ }
1463
+
928
1464
  /** `contextWithheld`: the PII screen did not clear a budget hand-off (#198); an escalation's reason is the hub's own words. */
929
1465
  private async sendTask(task: Task, a: Assignment, context?: string, overlap = "", contextWithheld = false): Promise<void> {
930
1466
  const pii = this.isPii(task);
931
1467
  const brief = pii ? undefined : await this.d.briefs?.forTask(task.owner!, task).catch(() => undefined);
932
1468
  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
1469
  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("; ");
1470
+ 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
1471
  const plan = planText(task.plan);
936
1472
  const body = [
937
- `Task #${task.id} [${task.class}] ${task.title}`,
1473
+ `Task #${task.id} [${profileTaskLabel(task)}] ${task.title}`,
938
1474
  task.detail,
939
1475
  `Facts: ${facts}`,
1476
+ this.roleGuidance(task, task.owner!),
940
1477
  plan ? `Plan so far: ${plan}` : "",
941
1478
  overlap,
942
1479
  rejected.length ? `Earlier review notes:\n${rejected.join("\n")}` : "",
943
1480
  brief ?? "",
944
1481
  // What the previous owner left behind. Peer-written free text: never attached to a PII task.
945
1482
  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}.`,
1483
+ task.state === "in_review"
1484
+ ? `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.`
1485
+ : `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
1486
  ].filter(Boolean).join("\n\n");
948
1487
  // A hand-over the owner takes in a turn of its own (#109, #115): idle before, busy after and not queued, this envelope
949
1488
  // was in the delivery that started its turn. Busy before, it is queued, or steered into a turn about something else
@@ -1010,8 +1549,9 @@ export class Tasks {
1010
1549
  */
1011
1550
  async releaseReady(): Promise<void> {
1012
1551
  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;
1552
+ const last = t.history.findLast(entry => !QUIET_EVENTS.has(entry.event))?.event, planned = this.plannerPullRecord(t)?.pull === true;
1553
+ 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;
1554
+ if (planned && !this.plannerAssignment(t, true).owner) continue;
1015
1555
  // ponytail: a reserved owner (#207) that attaches after another peer that can take the task is passed over here like
1016
1556
  // an offline one; hold reserved tasks for a grace period if restarts show that up.
1017
1557
  if (!assign(t, this.states(), this.d.routing(), { exclude: this.excluded(t), ...this.health() }).owner) continue; // nobody attached can take it yet
@@ -1025,11 +1565,14 @@ export class Tasks {
1025
1565
  try {
1026
1566
  const t = this.d.board.get(stale.id);
1027
1567
  if (!t || t.state !== "proposed" || t.owner || this.offered.has(t.id)) return;
1568
+ const planned = this.plannerPullRecord(t)?.pull === true;
1569
+ const selected = planned ? this.plannerAssignment(t, true).owner : undefined;
1570
+ if (planned && !selected) return;
1028
1571
  this.offered.add(t.id);
1029
1572
  offering = true;
1030
1573
  const ready = t.history.at(-1)?.event === "ready" ? t : this.d.board.update(t.id, HUB, "ready", {}, why);
1031
1574
  this.releaseNotice(`task ${this.publicTitle(ready)} is ready: what it waited for is approved`);
1032
- await this.assignOwner(ready, HUB);
1575
+ await this.assignOwner(ready, HUB, planned ? { candidates: [selected!], namedOwner: selected!, planner: true } : {});
1033
1576
  } catch (e) {
1034
1577
  // A saved move whose delivery failed is not "could not be assigned" (#297); the console notice was printed
1035
1578
  // where the error was thrown. The offer stays consumed by the error's own evidence, without the readback.
@@ -1051,12 +1594,84 @@ export class Tasks {
1051
1594
  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
1595
  }
1053
1596
 
1597
+ /** Artifact links and claimed local gates stay on the board; no outside text is accepted. */
1598
+ recordLifecycle(by: PeerId, id: unknown, input: unknown): Task {
1599
+ const task = this.need(id, false, true);
1600
+ if (by !== USER && by !== task.owner && by !== task.reviewer) throw new PreEffectToolRefusal("only the task owner, reviewer or console user may record lifecycle evidence");
1601
+ const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
1602
+ const next = sdlcReceipt(input, task.sdlc, config);
1603
+ const definition = stageDefinition(config, next.stage);
1604
+ if (config.mode === "enforce" && config.definitions && input && typeof input === "object" && "stage" in input && next.stage !== task.sdlc?.stage && by !== USER) {
1605
+ const actor = definition?.actor;
1606
+ 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"));
1607
+ if (!acts) throw new PreEffectToolRefusal(`lifecycle stage requires its ${actor ?? "unknown"} actor or the person`);
1608
+ }
1609
+ if (input && typeof input === "object" && "phase" in input) {
1610
+ this.mine(task, by, "reviewer", true);
1611
+ if (next.phase && !this.isPii(task) && !this.nameable(JSON.stringify(next.phase))) throw new PreEffectToolRefusal("phase metadata matches a PII pattern; use private work");
1612
+ if (next.phase) validatePhaseLinks(task, next.phase, id => this.d.board.get(id), t => this.isPii(t));
1613
+ }
1614
+ if (next.reviewedHead !== task.sdlc?.reviewedHead) this.mine(task, by, "reviewer", true);
1615
+ return this.d.board.update(task.id, by, "lifecycle", { sdlc: next });
1616
+ }
1617
+
1618
+ async deliveryQueue(repo: string) {
1619
+ 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 })));
1620
+ 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) }));
1621
+ const report = await readMergeQueue(repo, this.d.sdlc?.config() ?? sdlcConfig(undefined), links, this.d.sdlc?.gh);
1622
+ // Policy can change across remote awaits without any board write. Never return its stale public projection.
1623
+ const currentPrivacy = this.d.board.list().map(task => ({ id: task.id, private: this.isPii(task) }));
1624
+ if (workflowHash(links.map(({ id, private: hidden }) => ({ id, private: hidden }))) !== workflowHash(currentPrivacy)) {
1625
+ return { ...report, complete: false, rows: [], order: [], unordered: [], conflicts: [] };
1626
+ }
1627
+
1628
+ 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 })));
1629
+ 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"); } }
1630
+ return report;
1631
+ }
1632
+ async releaseReadiness(repo: string, liveIssues: number[]) {
1633
+ const report = await readReleaseReadiness(this.d.cwd, repo, liveIssues, { gh: this.d.sdlc?.gh });
1634
+ if (report.documents) report.documents.documents = report.documents.documents.map(d => {
1635
+ if (d.path && (!this.nameable(d.path) || matchesPolicy(this.d.cwd, d.path, this.d.piiPaths ?? [], true))) { const { path: _privatePath, ...metadata } = d; return metadata; }
1636
+ return d;
1637
+ });
1638
+ return report;
1639
+ }
1640
+
1641
+ private workflowView(task: Task) {
1642
+ return phaseView(this.d.sdlc?.config() ?? sdlcConfig(undefined), task, id => this.d.board.get(id), t => this.isPii(t));
1643
+ }
1644
+ workflowExport(id: unknown): Record<string, unknown> {
1645
+ const task = this.need(id), config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
1646
+ 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 }) };
1647
+ return { schema: 1, workflow: config.workflow ?? "none", state, stateHash: workflowHash(state), sourceHash: workflowHash(config) };
1648
+ }
1649
+ async lifecycleStatus(id: unknown, refresh = true): Promise<SdlcStatus> {
1650
+ const task = this.need(id), config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
1651
+ const evidence = task.sdlc && !this.isPii(task) && refresh && config.mode !== "off" ? await readSdlcPr(task.sdlc, this.d.sdlc?.gh) : undefined;
1652
+ return sdlcStatus(config, task.sdlc, evidence, this.workflowView(task));
1653
+ }
1654
+
1655
+ private async lifecycleGate(task: Task, transition: "review" | "approval", reviewedHead?: string): Promise<void> {
1656
+ const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
1657
+ if (config.mode === "off") return;
1658
+ const receipt = task.sdlc && { ...task.sdlc, ...(reviewedHead ? { reviewedHead } : {}) };
1659
+ const evidence = receipt && !this.isPii(task) ? await readSdlcPr(receipt, this.d.sdlc?.gh) : undefined;
1660
+ const current = this.need(task.id, false, true);
1661
+ 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`);
1662
+ const status = sdlcStatus(config, receipt, evidence, this.workflowView(current));
1663
+ 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;
1664
+ if (config.mode === "enforce" && blockers.length) throw new PreEffectToolRefusal(`task #${task.id}: SDLC ${transition} blocked: ${blockers.join("; ")}`);
1665
+ 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("; ")}`);
1666
+ }
1667
+
1054
1668
  private need(id: unknown, open = false, preEffect = false): Task {
1055
1669
  // Callers are models (#70): an id is a whole number or a digit string, never an array that Number() would accept.
1056
1670
  const n = typeof id === "number" ? id : typeof id === "string" && /^\s*\d+\s*$/.test(id) ? Number(id) : NaN;
1057
1671
  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
1672
  const task = this.d.board.get(n);
1059
1673
  if (!task) throw new (preEffect ? PreEffectToolRefusal : Error)(`no task #${n}`);
1674
+ if (task.state === "backlog") throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${n} is backlog: pull it into To Do first`);
1060
1675
  // Handing a task to someone else only makes sense while there is work left on it.
1061
1676
  if (open && !OPEN.includes(task.state)) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id} is ${task.state}: it can no longer change hands`);
1062
1677
  return task;
@@ -1072,10 +1687,10 @@ export class Tasks {
1072
1687
  this.ready(task, true);
1073
1688
  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
1689
  // Models send null or {} for an optional field they leave empty: neither replaces a plan.
1075
- const given = plan == null ? undefined : cleanPlan(plan);
1690
+ const given = plan == null ? undefined : cleanPlan(plan, (task.profile ?? "dev") === (this.d.profile ?? "dev") ? this.d.profileData ?? task.profile ?? "dev" : task.profile ?? "dev");
1076
1691
  const cleaned = given && Object.keys(given).length ? given : undefined;
1077
1692
  // 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) })) {
1693
+ if (cleaned && !this.isPii(task) && this.isPii({ signals: this.signalsFor({ title: "", detail: planText(cleaned), refs: {}, plan: cleaned }) })) {
1079
1694
  throw new PreEffectToolRefusal("this plan matches a PII pattern and is not kept: other owners would see it");
1080
1695
  }
1081
1696
  const before = new Set(this.overlapHits(task).map((h) => h.task.id));
@@ -1138,9 +1753,11 @@ export class Tasks {
1138
1753
  const task = this.need(id, true, true);
1139
1754
  this.mine(task, by, "owner", true);
1140
1755
  // 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 } : {});
1756
+ const planned = this.plannerPullRecord(task)?.pull === true;
1757
+ 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 } : {});
1758
+ if (planned) this.offered.delete(task.id);
1142
1759
  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 });
1760
+ return this.assignOwner(back, HUB, { event: "reassigned", reason: "declined", clearOnFail: true, ...(planned ? { planner: true } : {}) });
1144
1761
  }
1145
1762
 
1146
1763
  /** Tasks whose check is queued or running, and the owner it was started for. */
@@ -1168,6 +1785,7 @@ export class Tasks {
1168
1785
  // The PII screen reads the summary of a done that passes these checks (issue #198); they run again after it.
1169
1786
  const withheld = await this.withholds("summary", summary, valid());
1170
1787
  let task = valid();
1788
+ if (this.d.sdlc && this.d.sdlc.config().mode !== "off") { await this.lifecycleGate(task, "review"); task = valid(); }
1171
1789
  // A member of a silent cohort (issue #107): its done is an intent, and the last of them integrates first.
1172
1790
  const cohort = this.cohorts.of(task.id);
1173
1791
  const silentMember = !!cohort?.silent && this.turnFree() && !!task.owner && !this.isPii(task);
@@ -1197,7 +1815,7 @@ export class Tasks {
1197
1815
  else if (evidence) this.doneNotes.set(task.id, `Overlapping work finished while you worked (no turn-free integration step ran):\n${evidence}`);
1198
1816
  }
1199
1817
  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;
1818
+ const command = this.d.runCheck ? this.d.check?.(task.class, task) : undefined;
1201
1819
  if (!command) return this.complete(task, by, summary, refs, "", withheld);
1202
1820
  // The tool call returns now; a check can outlast an agent's tool timeout. The result decides what comes next.
1203
1821
  this.checking.set(task.id, task.owner);
@@ -1241,7 +1859,7 @@ export class Tasks {
1241
1859
  const at = [...u.history].reverse().find((h) => h.event === "done");
1242
1860
  if (u.owner === t.owner || !at || at.at < handedAt(t)) return [];
1243
1861
  const theirs = this.places(u);
1244
- const paths = mine.paths.filter((p) => theirs.paths.some((q) => samePlace(p, q)));
1862
+ 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
1863
  const symbols = mine.symbols.filter((x) => theirs.symbols.includes(x));
1246
1864
  return paths.length || symbols.length ? [this.completedNotice(u, { task: t, paths, symbols }, at.note, at.withheld)] : [];
1247
1865
  });
@@ -1325,12 +1943,19 @@ export class Tasks {
1325
1943
  }
1326
1944
 
1327
1945
  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 });
1946
+ let reviewer = task.reviewer;
1947
+ if (this.d.sdlc && this.d.sdlc.config().mode !== "off") {
1948
+ await this.lifecycleGate(task, reviewer ? "review" : "approval");
1949
+ task = this.need(task.id, false, true);
1950
+ this.mine(task, by, "owner", true);
1951
+ reviewer = task.reviewer;
1952
+ }
1953
+ 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
1954
  this.cohorts.closed(next.id); // its owner's next native turn end settles it in its cohort (issue #107)
1331
1955
  this.note(next, by, "finding", `Task #${next.id} done by ${by}: ${next.title}\n${summary ?? ""}`, withheld);
1332
1956
  this.tellCompleted(next, summary, withheld);
1333
1957
  if (!reviewer) {
1958
+ this.settleMemory(next);
1334
1959
  if (next.owner) this.d.board.recordOutcome(next.owner, next.class, true); // approved without a review is a success too
1335
1960
  this.releaseNotice(`task ${this.publicTitle(next)} done by ${by}, no reviewer: approved`);
1336
1961
  await this.releaseDependents(next);
@@ -1405,11 +2030,15 @@ export class Tasks {
1405
2030
  `- List what is unmet in hub_review's unmet, one item each.`,
1406
2031
  ].join("\n");
1407
2032
  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}.`;
2033
+ const guidance = this.roleGuidance(task, reviewer, true);
2034
+ let projection = "";
2035
+ try { const text = this.d.projectedReview?.(task); if (text) projection = this.screen(task, sanitize(text), "document projection", reviewer); }
2036
+ catch { projection = "(document projection unavailable)"; }
2037
+ 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
2038
  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
2039
  }
1411
2040
 
1412
- async review(by: PeerId, id: unknown, verdict: unknown, note?: string, unmet?: unknown): Promise<Task> {
2041
+ async review(by: PeerId, id: unknown, verdict: unknown, note?: string, unmet?: unknown, head?: unknown): Promise<Task> {
1413
2042
  const valid = (): Task => {
1414
2043
  const t = this.need(id, false, true);
1415
2044
  this.mine(t, by, "reviewer", true);
@@ -1421,11 +2050,17 @@ export class Tasks {
1421
2050
  if (items.length) note = `${note ?? ""}\nUnmet: ${items.join("; ")}`.trim();
1422
2051
  // The PII screen reads the note of a review that passes these checks (issue #198); they run again after it.
1423
2052
  const withheld = await this.withholds("review note", note, valid());
1424
- const task = valid();
2053
+ let task = valid();
2054
+ let reviewedHead: string | undefined;
2055
+ if (head !== undefined) {
2056
+ const checked = sdlcReceipt({ reviewedHead: head }, task.sdlc, this.d.sdlc?.config() ?? sdlcConfig(undefined));
2057
+ reviewedHead = checked.reviewedHead;
2058
+ }
2059
+ if (verdict === "approved" && this.d.sdlc && this.d.sdlc.config().mode !== "off") { await this.lifecycleGate(task, "approval", reviewedHead); task = valid(); }
1425
2060
  const pii = this.isPii(task);
1426
2061
  const flag = { withheld: withheld || undefined };
1427
2062
  if (verdict === "approved") {
1428
- const next = this.d.board.update(task.id, by, "approved", { state: "approved", rejections: 0 }, note, flag);
2063
+ 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
2064
  if (next.owner) {
1430
2065
  this.d.board.recordOutcome(next.owner, next.class, true);
1431
2066
  this.d.board.recordReview({ implementer: next.owner, reviewer: by, class: next.class, kind: "approved", task: next.id });
@@ -1435,6 +2070,7 @@ export class Tasks {
1435
2070
  }
1436
2071
  }
1437
2072
  this.note(next, by, "decision", `Task #${next.id} approved by ${by}: ${next.title}\n${note ?? ""}`, withheld);
2073
+ this.settleMemory(next);
1438
2074
  this.releaseNotice(`task ${this.publicTitle(next)}: could not tell ${next.owner ?? "its owner"} it was approved`, () =>
1439
2075
  this.tell(next, `Task #${next.id} approved by ${by}.${note ? ` ${this.screen(next, note, "review note", next.owner, withheld)}` : ""}`, pii));
1440
2076
  await this.releaseDependents(next);
@@ -1507,10 +2143,36 @@ export class Tasks {
1507
2143
  }
1508
2144
 
1509
2145
  /** 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);
2146
+ async assignTo(id: unknown, peer: PeerId, by: PeerId = USER, preEffect = false, role: TaskAssignmentRole = "owner"): Promise<Task> {
2147
+ if (role !== "owner" && role !== "reviewer") throw new (preEffect ? PreEffectToolRefusal : Error)("role must be owner or reviewer");
2148
+ if (role === "reviewer" && by !== USER) {
2149
+ const conductors = Object.entries(this.d.roles ?? {}).filter(([, roles]) => Array.isArray(roles) && roles.includes("conductor")).map(([actor]) => actor);
2150
+ if (conductors.length !== 1 || conductors[0] !== by) throw new (preEffect ? PreEffectToolRefusal : Error)("only the console user or the configured conductor may name a reviewer");
2151
+ }
2152
+ const task = this.need(id, role === "owner", preEffect);
2153
+ if (!this.d.bus.isRostered(peer)) throw new (preEffect ? PreEffectToolRefusal : Error)(`${peer} is off roster; add it to the roster before assigning work`);
2154
+ if (role === "reviewer") {
2155
+ 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`);
2156
+ this.ready(task, preEffect);
2157
+ const a = assign(task, this.states(), this.d.routing(), { candidates: [], namedReviewer: peer, ...(task.owner ? { notReviewer: task.owner } : {}), ...this.weights(task.class) });
2158
+ 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("; ")}`);
2159
+ const next = this.d.board.update(task.id, by, "reviewer changed", { reviewer: peer }, `named reviewer ${peer}`, { reason: "manual" });
2160
+ if (next.state === "in_review" && task.reviewer && task.reviewer !== peer) {
2161
+ const previous = task.reviewer;
2162
+ this.releaseNotice(`task ${this.publicTitle(next)}: could not notify previous reviewer ${previous} that its review request is withdrawn`, () => {
2163
+ for (const env of this.d.bus.snapshot(false).queues[previous] ?? []) {
2164
+ if (env.from === HUB && env.kind === "review" && env.refs?.task === String(task.id)) this.d.bus.withdraw(env.id);
2165
+ }
2166
+ 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.`;
2167
+ if (previous === USER) this.d.notify(body);
2168
+ else this.d.bus.publish(newEnvelope(HUB, body, { to: [previous], kind: "status", priority: "important", refs: { task: String(task.id) }, ...(this.isPii(next) ? { private: true } : {}) }));
2169
+ });
2170
+ }
2171
+ 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));
2172
+ return next;
2173
+ }
1512
2174
  // 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 });
2175
+ if (!this.waitsFor(task).length) return this.assignOwner(task, by, { candidates: [peer], namedOwner: peer, event: "reassigned", reason: "manual", override: by === USER });
1514
2176
  const next = this.d.board.update(task.id, by, "reserved", { reserved: peer }, `for ${peer}`, { reason: "manual" });
1515
2177
  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
2178
  return next;
@@ -1521,35 +2183,70 @@ export class Tasks {
1521
2183
  * the usual constraints. Tasks it was reviewing get another reviewer. Moves are reported, never taken back automatically.
1522
2184
  */
1523
2185
  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" }[] = [];
2186
+ return this.reassignWork(peer, context, urgentOnly, false);
2187
+ }
2188
+
2189
+ /** Read-only preview using the same assignment constraints as the roster relay. */
2190
+ rosterPreview(peer: PeerId): { id: number; title: string; role: "owner" | "reviewer"; to: PeerId | null }[] {
1525
2191
  const routing = this.d.routing();
1526
- // The hand-off is screened once (#198), before anything moves, when an ordinary task will carry it.
2192
+ return this.d.board.list().filter(task => task.state !== "approved" && (task.owner === peer || task.reviewer === peer)).map(task => {
2193
+ const owner = task.owner === peer;
2194
+ const weights = this.weights(task.class);
2195
+ const pii = this.isPii(task);
2196
+ 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) : [];
2197
+ 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 } : {}) });
2198
+ return { id: task.id, title: this.publicTitle(task), role: owner ? "owner" : "reviewer", to: (owner ? a.owner : a.reviewer) ?? null };
2199
+ });
2200
+ }
2201
+
2202
+ /** A roster removal offers work through the quota relay's constraints, preserving assignments nobody can take. */
2203
+ async reassignForRoster(peer: PeerId): Promise<{ id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]> {
2204
+ return this.reassignWork(peer, undefined, false, true);
2205
+ }
2206
+
2207
+ private async reassignWork(peer: PeerId, context: string | undefined, urgentOnly: boolean, roster: boolean): Promise<{ id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]> {
2208
+ const moved: { id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[] = [];
2209
+ const routing = this.d.routing(), why = roster ? `roster removal of ${peer}` : `budget pause of ${peer}`;
2210
+ // Budget context is screened exactly as before; a roster removal supplies no agent-written context.
1527
2211
  const moves = (t: Task) => (!urgentOnly || t.signals.includes("urgent")) && t.owner === peer && OPEN.includes(t.state);
1528
2212
  const contextWithheld = context && this.d.board.list().some((t) => moves(t) && !this.isPii(t)) ? await this.withholds("handoff", context) : false;
1529
2213
  for (const task of this.d.board.list()) {
1530
- // Waiting out a window that resets soon (issue #36): only urgent work moves.
1531
2214
  if (urgentOnly && !task.signals.includes("urgent")) continue;
1532
- if (task.owner === peer && OPEN.includes(task.state)) {
2215
+ if (task.owner === peer && (OPEN.includes(task.state) || roster && task.state === "in_review")) {
1533
2216
  const pii = this.isPii(task);
1534
2217
  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;
2218
+ 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;
2219
+ // Budget keeps its original release/unassign behaviour for ordinary work. Roster changes the state in
2220
+ // assignOwner's same owner write only after routing has found somebody to take it.
2221
+ const back = !roster && task.state === "in_progress" ? this.d.board.update(task.id, HUB, "released", { state: "proposed" }, why) : task;
2222
+ let next: Task;
1536
2223
  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" });
2224
+ 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
2225
  } 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.
2226
+ // The move is saved and its central #297 notice already spoke; relay the
2227
+ // remaining tasks and run the roster tail on the saved row as on success.
1542
2228
  if (!(e instanceof AssignmentUndeliveredError)) throw e;
1543
- moved.push({ id: task.id, title: this.publicTitle(task), to: e.task.owner, role: "owner" });
2229
+ next = e.task;
2230
+ }
2231
+ if (roster) {
2232
+ if (!next.owner || next.owner === peer) continue;
2233
+ this.releaseNotice(`task ${this.publicTitle(next)} moved from ${peer} to ${next.owner} (off roster)`);
2234
+ 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
2235
  }
2236
+ moved.push({ id: task.id, title: this.publicTitle(task), to: next.owner, role: "owner" });
1545
2237
  } 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
2238
  const a = assign(task, this.states(), routing, { exclude: [peer], candidates: [], ...(task.owner ? { notReviewer: task.owner } : {}), ...this.weights(task.class) });
1548
2239
  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}`);
2240
+ if (roster && !reviewer) {
2241
+ 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}`);
2242
+ continue;
2243
+ }
2244
+ const next = this.d.board.update(task.id, HUB, "reviewer changed", { reviewer }, why);
1550
2245
  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}`);
2246
+ if (next.state === "in_review" && reviewer && reviewer !== USER) {
2247
+ 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.)`));
2248
+ else this.sendReview(next, reviewer, `(${peer} was reviewing this and is paused for quota.)`);
2249
+ } 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
2250
  }
1554
2251
  }
1555
2252
  return moved;
@@ -1566,7 +2263,7 @@ export class Tasks {
1566
2263
  return { ...(policy?.route ? { route: policy.route } : {}), ...(policy?.fixed_model ? { fixedModel: policy.fixed_model } : {}), pii, task: String(about.id) };
1567
2264
  }
1568
2265
 
1569
- async remember(by: PeerId, input: { text?: string; title?: string; kind?: string; task?: unknown }): Promise<string> {
2266
+ async remember(by: PeerId, input: { text?: string; title?: string; kind?: string; task?: unknown; horizon?: unknown; scope?: unknown }): Promise<string> {
1570
2267
  const text = String(input.text ?? "").trim();
1571
2268
  if (!text) throw new Error("text is required");
1572
2269
  const task = input.task !== undefined ? this.d.board.get(Number(input.task)) : undefined;
@@ -1574,24 +2271,132 @@ export class Tasks {
1574
2271
  // Shared with every other peer as well, so text that matches a PII pattern is refused like a note about a PII task.
1575
2272
  const title = String(input.title ?? "").trim();
1576
2273
  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";
2274
+ if (input.horizon !== undefined && input.horizon !== "short" && input.horizon !== "long") throw new Error('horizon is "short" or "long"');
2275
+ if (input.scope !== undefined && input.scope !== "project" && input.scope !== "shared") throw new Error('scope is "project" or "shared"');
2276
+ if (!this.d.memory && !this.d.store) return "memory is disabled; nothing saved";
2277
+ // Long-term notes leave the machine (#258), and claude-mem's observer is a cloud model: a secret goes to neither.
2278
+ 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");
2279
+ 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
2280
  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";
2281
+ const kind = (NOTE_KINDS as readonly string[]).includes(String(input.kind)) ? String(input.kind) as Kind : "finding";
2282
+ const native = this.saveNative(by, kind, text, title, task, input.horizon === "long" || input.horizon === "short" ? input.horizon : undefined, input.scope === "shared" ? "shared" : "project");
2283
+ if (task && this.d.memory) this.markMemoryReference(task, by);
2284
+ 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;
2285
+ if (!res && !native) return this.d.memory ? "memory worker unavailable; nothing saved" : "memory store unavailable; nothing saved";
1582
2286
  const line = noteLine(by, kind, title ? `${title}: ${text}` : text);
1583
2287
  this.d.notify(line);
1584
2288
  this.d.share?.(by, line);
1585
- return "saved to shared memory; the other agents get it with their next message";
2289
+ 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`;
2290
+ }
2291
+
2292
+ /** The task's key in the hub's store: unique across `ahub reset --all`, which restarts the ids. */
2293
+ private memoryKey(task: Task): string { return taskKey(this.d.store!.project, task); }
2294
+
2295
+ /**
2296
+ * 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
2297
+ * across projects is a person's). Refused before any board write.
2298
+ */
2299
+ private goalFor(by: PeerId, ref: unknown): GoalView {
2300
+ if (!this.d.store) throw new PreEffectToolRefusal("goal: hub memory is off for this project");
2301
+ const g = typeof ref === "string" && ref.trim() ? this.d.store.store.goalOf(ref.trim().replace(/^goal:/, "")) : undefined;
2302
+ if (!g) throw new PreEffectToolRefusal(`goal: no long-term task ${JSON.stringify(ref).slice(0, 40)} (ahub goal list shows them)`);
2303
+ 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");
2304
+ if (g.state === "done" || g.state === "dropped") throw new PreEffectToolRefusal(`goal: that long-term task is ${g.state}`);
2305
+ return g;
2306
+ }
2307
+ /** Fence deletion before external memory writes; one marker survives store absence. */
2308
+ private markMemoryReference(task: Task, by: PeerId): void {
2309
+ 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", {});
2310
+ }
2311
+ /** Record a long-term link, never for PII; failures remain notices. */
2312
+ private linkNative(task: Task, goal: GoalView, by: PeerId): string {
2313
+ 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)"; }
2314
+ 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)}`; }
2315
+ 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"; }
2316
+ }
2317
+ /** A person links a board task of this project to a long-term task (`ahub goal link`). */
2318
+ linkGoal(by: PeerId, id: unknown, ref: unknown): string {
2319
+ const task = this.d.board.get(Number(id));
2320
+ if (!task) throw new Error(`no task #${String(id).slice(0, 12)}`);
2321
+ const goal = this.goalFor(by, ref);
2322
+ return `task #${task.id}: ${this.linkNative(task, goal, by)}`;
2323
+ }
2324
+
2325
+ /**
2326
+ * One note in the hub's own store (#258). Short-term while its task is open; on an approved task, or with horizon
2327
+ * long, decisions, contracts and fails are long-term. Fail-open: a store that cannot be written costs one notice.
2328
+ * The caller has screened the text for PII; never a PII task's.
2329
+ */
2330
+ private saveNative(by: PeerId, kind: Kind, text: string, title: string, task: Task | undefined, horizon?: Tier, scope: Scope = "project"): { id: string; tier: Tier } | undefined {
2331
+ const st = this.d.store;
2332
+ if (!st || (task && this.isPii(task))) return undefined;
2333
+ try { // never throws: callers run after a board write
2334
+ // Approved, or settled in the store already: the snapshot the caller holds can predate an approval made while it awaited.
2335
+ const approved = !!task && (task.state === "approved" || st.store.settled(this.memoryKey(task)));
2336
+ const tier: Tier = horizon ?? (approved && PROMOTED_KINDS.includes(kind) ? "long" : "short");
2337
+ 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 } } : {}) });
2338
+ // A short-term note on a task already approved (before hub memory existed, or late): the task is settled first, so
2339
+ // the note gets its own deadline and is never taken for a source waiting for promotion.
2340
+ if (task && approved && tier === "short" && !st.store.settled(this.memoryKey(task))) st.store.settle(this.memoryKey(task), by);
2341
+ if (task) this.markMemoryReference(task, by);
2342
+ 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");
2343
+ st.onNote?.(id);
2344
+ return { id, tier };
2345
+ } catch (e) {
2346
+ this.releaseNotice(`a ${kind} note was not saved to hub memory (${(e as Error).message})`);
2347
+ return undefined;
2348
+ }
2349
+ }
2350
+
2351
+ /** An approved task's decision, contract and fail notes move to long-term; its short-term entries start to expire (#258). */
2352
+ /**
2353
+ * At hub start: an approved task whose settle entry a crash or a failed write cut off is settled now, so its
2354
+ * decisions, contracts and fails still reach long-term memory and its findings get their deadline.
2355
+ */
2356
+ reconcileMemory(): number {
2357
+ if (!this.d.store) return 0;
2358
+ let n = 0;
2359
+ for (const t of this.d.board.list("approved")) {
2360
+ if (this.isPii(t)) continue;
2361
+ try { const key = this.memoryKey(t); if (this.d.store.store.hasTask(key) && !this.d.store.store.settled(key)) { this.settleMemory(t); n++; } }
2362
+ catch { /* fail-open: tried again at the next start */ }
2363
+ }
2364
+ return n;
2365
+ }
2366
+
2367
+ private settleMemory(task: Task): void {
2368
+ if (!this.d.store || this.isPii(task)) return;
2369
+ // The store screens each note as it moves to the synced log (the promotion screen the daemon gives it).
2370
+ try { this.d.store.store.settle(this.memoryKey(task), task.reviewer ?? task.owner ?? HUB); }
2371
+ catch (e) { this.releaseNotice(`task #${task.id}: hub memory could not settle its notes (${(e as Error).message})`); }
2372
+ }
2373
+
2374
+ /**
2375
+ * Whether a note names, or its task touches, a path the current `pii.paths` makes private: checked before either memory
2376
+ * backend sees it, whatever the task's signals said when it was proposed.
2377
+ */
2378
+ private aboutPrivatePath(title: string, text: string, task: Task | undefined): boolean {
2379
+ const st = this.d.store;
2380
+ // No denylist here: a path the graph leaves out is still checked against the privacy policy.
2381
+ const about = extractNodes(`${title}\n${text}`, { cwd: this.d.cwd, project: st?.project ?? this.d.project, repo: st?.repo ?? this.d.project, deny: [],
2382
+ paths: [...(task?.refs.paths ?? []), ...(task?.plan?.paths ?? [])], symbols: task?.plan?.symbols });
2383
+ return mentionsPrivatePath(`${title}\n${text}`, about, this.d.piiPaths ?? [], this.d.cwd);
1586
2384
  }
1587
2385
 
1588
2386
  /** 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;
2387
+ private note(task: Task, by: PeerId, kind: Kind, text: string, withheld = false): void {
2388
+ if (this.isPii(task) || (!this.d.memory && !this.d.store)) return;
1591
2389
  // 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).
2390
+ // not clear (#198), is not sent (#69). Long-term hub memory leaves the machine (#258): the same rule, and no secrets.
1593
2391
  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 } });
2392
+ 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)`);
2393
+ const title = `agent-hub task #${task.id}: ${task.title}`.slice(0, 120);
2394
+ 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)`);
2395
+ this.saveNative(by, kind, text, title, task);
2396
+ if (this.d.memory) {
2397
+ try { this.markMemoryReference(task, by); } catch { return this.releaseNotice(`task #${task.id}: memory reference could not be recorded; note was not saved`); }
2398
+ void this.d.memory.save({ text, title, project: this.d.project, metadata: { peer: by, task: task.id, kind } });
2399
+ }
1595
2400
  }
1596
2401
 
1597
2402
  private tell(task: Task, body: string, pii: boolean): void {