@staix/agent-hub 0.12.30 → 0.12.32

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 (102) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +29 -9
  3. package/docs/agent-notes/adapters.md +9 -5
  4. package/docs/agent-notes/budget.md +1 -1
  5. package/docs/agent-notes/bus.md +5 -0
  6. package/docs/agent-notes/daemon.md +14 -8
  7. package/docs/agent-notes/local-worker.md +3 -1
  8. package/docs/agent-notes/tasks.md +7 -1
  9. package/docs/agent-notes/tests.md +6 -2
  10. package/docs/cooperbench.md +1 -1
  11. package/docs/gsd-collaboration-analysis.md +1 -1
  12. package/docs/memory.md +2 -2
  13. package/docs/operations.md +236 -37
  14. package/docs/quickstart.md +24 -5
  15. package/docs/security.md +42 -10
  16. package/docs/smoke.md +34 -0
  17. package/docs/specs/2026-09-19-agent-hub-design.md +241 -3
  18. package/docs/specs/2026-09-20-upgrade-recovery-design.md +275 -10
  19. package/docs/verification/2026-10-17-0.12.31.md +71 -0
  20. package/docs/verification/2026-10-17-418-probes.md +21 -0
  21. package/docs/verification/2026-10-18-0.12.32.md +54 -0
  22. package/docs/verified.json +20 -15
  23. package/package.json +2 -2
  24. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  25. package/plugins/agent-hub/server.js +1361 -44
  26. package/src/adapters/acp.ts +5 -6
  27. package/src/adapters/claude-channel.ts +29 -11
  28. package/src/adapters/definitions/builtin-metadata.ts +121 -0
  29. package/src/adapters/definitions/kimi-native.ts +4 -0
  30. package/src/adapters/definitions/qwen-native.ts +24 -0
  31. package/src/adapters/definitions/qwen.ts +71 -0
  32. package/src/adapters/local-worker.ts +2 -2
  33. package/src/adapters/pi.ts +11 -2
  34. package/src/adapters/registry-types.ts +174 -0
  35. package/src/adapters/registry.ts +36 -0
  36. package/src/cli/attention.ts +5 -1
  37. package/src/cli/console-state.ts +130 -17
  38. package/src/cli/console.ts +54 -8
  39. package/src/cli/help.ts +3 -1
  40. package/src/cli/identity.ts +16 -5
  41. package/src/cli/jcode-mcp.ts +1 -1
  42. package/src/cli/launch.ts +1 -6
  43. package/src/cli/main.ts +208 -40
  44. package/src/cli/output.ts +15 -2
  45. package/src/cli/preview.ts +8 -5
  46. package/src/cli/recovery-guidance.ts +137 -0
  47. package/src/cli/recovery-presentation-options.ts +8 -0
  48. package/src/cli/recovery-session-file.ts +18 -0
  49. package/src/cli/terminal-recovery.ts +48 -0
  50. package/src/cli/upgrade-interactive.ts +129 -74
  51. package/src/cli/upgrade-labels.ts +30 -0
  52. package/src/cli/upgrade-presentation-data.ts +41 -0
  53. package/src/cli/upgrade-presentation-flags.ts +31 -0
  54. package/src/cli/upgrade-presentation.ts +217 -0
  55. package/src/cli/upgrade-restore-state.ts +137 -0
  56. package/src/cli/upgrade-runtime.ts +430 -175
  57. package/src/cli/upgrade.ts +325 -49
  58. package/src/hub/board.ts +82 -24
  59. package/src/hub/bus.ts +89 -29
  60. package/src/hub/check-runner.ts +514 -0
  61. package/src/hub/child-process.ts +1 -0
  62. package/src/hub/cohorts.ts +2 -2
  63. package/src/hub/config-apply.ts +242 -0
  64. package/src/hub/config-reload.ts +120 -0
  65. package/src/hub/config-trust.ts +11 -2
  66. package/src/hub/control-client.ts +5 -1
  67. package/src/hub/crash.ts +8 -6
  68. package/src/hub/daemon.ts +931 -209
  69. package/src/hub/delivery-hints.ts +30 -0
  70. package/src/hub/delivery-journal.ts +185 -12
  71. package/src/hub/delivery-superseded.ts +52 -0
  72. package/src/hub/envelope.ts +1 -1
  73. package/src/hub/events.ts +2 -2
  74. package/src/hub/host-executable.ts +11 -0
  75. package/src/hub/hub-tools.ts +9 -1
  76. package/src/hub/manager.ts +1 -1
  77. package/src/hub/peer-catalog.ts +13 -13
  78. package/src/hub/peers.ts +12 -2
  79. package/src/hub/permission-mode.ts +5 -3
  80. package/src/hub/recovery-continuity.ts +152 -0
  81. package/src/hub/recovery-store.ts +10 -4
  82. package/src/hub/routing.ts +35 -16
  83. package/src/hub/sdlc.ts +5 -1
  84. package/src/hub/settings.ts +52 -37
  85. package/src/hub/settled-roles.ts +35 -0
  86. package/src/hub/spec-read.ts +120 -0
  87. package/src/hub/supervision.ts +24 -8
  88. package/src/hub/task-sweep.ts +7 -6
  89. package/src/hub/tasks.ts +199 -40
  90. package/src/hub/ui.ts +1 -1
  91. package/src/memory/brief.ts +6 -5
  92. package/src/memory/capture.ts +2 -1
  93. package/src/pi/session.ts +23 -4
  94. package/src/ui/index.html +35 -16
  95. package/src/ui/needs.ts +7 -5
  96. package/src/ui/office-model.ts +4 -1
  97. package/src/ui/task-board.ts +1 -1
  98. package/src/ui/task-progress.ts +6 -2
  99. package/templates/AGENTS.block.md +1 -1
  100. package/templates/profiles/general.json +1 -1
  101. package/templates/profiles/research.json +1 -1
  102. package/templates/profiles/writing.json +1 -1
package/src/hub/tasks.ts CHANGED
@@ -14,11 +14,11 @@ import { isAbsolute, relative } from "node:path";
14
14
  import type { Briefs, NativeItem } from "../memory/brief.ts";
15
15
  import type { MemoryClient } from "../memory/client.ts";
16
16
  import { candidateTargetRevision, type CandidateInput, type CandidateStore, extractNodes, looksSecret, PROMOTED_KINDS, taskKey, type GoalView, type Kind, type MemoryStore, type Scope, type Tier } from "@staix/agent-mem";
17
- import { CLASSES, OUTCOMES_KEPT_MS, ALL_PLAN_KEYS, TASK_MOVE_REASONS, taskHasStarted, planningUndoEntry, type HistoryEntry, type Board, type Task, type TaskClass, type TaskMoveReason, type TaskPlan, type TaskRefs } from "./board.ts";
17
+ import { CLASSES, OUTCOMES_KEPT_MS, ALL_PLAN_KEYS, TASK_MOVE_REASONS, taskHasStarted, planningUndoEntry, isClosedTaskState, type TaskStall, type HistoryEntry, type Board, type Task, type TaskClass, type TaskMoveReason, type TaskPlan, type TaskRefs } from "./board.ts";
18
18
  import type { Bus } from "./bus.ts";
19
19
  import { HUB, newEnvelope, NOTE_KINDS, noteLine, sanitize, USER, type Envelope, type PeerId, type PeerState } from "./envelope.ts";
20
20
  import type { PiiVerdict, ScreenItem, ScreenRecord } from "./inference.ts";
21
- import { assign, detectSignals, LOCAL, peerEntry, PI, predictSplit, type Assignment, type Routing, type SplitObservation, type SplitPrediction } from "./routing.ts";
21
+ import { assign, detectSignals, LOCAL, peerEntry, PI, predictSplit, type Assignment, type PeerCapabilities, type Routing, type SplitObservation, type SplitPrediction } from "./routing.ts";
22
22
  import { ExecutionBudget, type ExecutionBudgetConfig, type ExecutionBudgetDecision, type ExecutionBudgetStatus, type ExecutionUnit } from "./execution-budget.ts";
23
23
  import { Cohorts, MAX_REQUESTS, type Cohort, type Completion } from "./cohorts.ts";
24
24
  import { realPath } from "./project.ts";
@@ -78,6 +78,12 @@ export interface TasksDeps {
78
78
  /** Reviewer choice from recorded review outcomes (issue #35); off unless the project config turns it on. */
79
79
  review?: { adaptive: boolean; min_reviews: number };
80
80
  /** Peers whose recent deliveries all failed, with the reason (issue #89); routing skips them. */
81
+ capabilities?: () => Record<PeerId, PeerCapabilities>;
82
+ repository?: string;
83
+ offerRead?: (peer: PeerId, envelopeId: string) => boolean;
84
+ offerPullOnly?: (peer: PeerId) => boolean;
85
+ cancellationHeld?: () => boolean;
86
+ stallNotice?: (stall: TaskStall) => void;
81
87
  failing?: () => Record<PeerId, string>;
82
88
  /** Peers whose queue a needs_review delivery holds, with the hold detail (issue #90); routing explains the hold, never rejects. */
83
89
  held?: () => Record<PeerId, string>;
@@ -91,6 +97,8 @@ export interface TasksDeps {
91
97
  recordScreen?: (record: ScreenRecord) => void;
92
98
  /** Turn-free coordination (issue #107): configured and no PII task open. Asked at each use. */
93
99
  turnFree?: () => boolean;
100
+ /** Coordination preference captured for each newly created task; existing rows keep their initial mode. */
101
+ coordination?: () => boolean;
94
102
  /** Whether a peer's context path for facts is verified in its current session (issue #108). */
95
103
  capable?: (peer: PeerId) => boolean;
96
104
  /** Whether `peer` is between native turns now (issue #107): Codex not busy, Claude stopped since its last tool call. */
@@ -217,10 +225,15 @@ export class Tasks {
217
225
  private readonly knownPlanners = new Set<PeerId>();
218
226
  /** Turn-free cohorts (issue #107): who works without messages, and who integrates. */
219
227
  readonly cohorts: Cohorts;
220
- private readonly sweepConfig: TaskSweepConfig;
228
+ private readonly coordinationModes = new Map<number, boolean>();
229
+ private sweepConfig: TaskSweepConfig;
221
230
  private sweeping = false;
222
231
 
223
232
  constructor(private readonly d: TasksDeps) {
233
+ if (d.coordination) {
234
+ const mode = d.coordination();
235
+ for (const task of d.board.list()) this.coordinationModes.set(task.id, mode);
236
+ }
224
237
  const backlog = d.board.list("backlog");
225
238
  for (const [peer, roles] of Object.entries(d.roles ?? {})) {
226
239
  if (!roles.includes("planner")) continue;
@@ -304,16 +317,36 @@ export class Tasks {
304
317
  /** Task-owned, default-off between-turn ladder. Persist before sending so restart cannot repeat a step. */
305
318
  async sweep(now = Date.now()): Promise<{ task: number; finding: SweepRecord }[]> {
306
319
  const recorded: { task: number; finding: SweepRecord }[] = [];
307
- if (!this.sweepConfig.enabled || this.sweeping || this.d.sweepHeld?.() || this.d.bus.isRecoveryHeld || this.d.bus.storageError) return recorded;
320
+ const config = this.sweepConfig;
321
+ if (!config.enabled || this.sweeping || this.d.sweepHeld?.() || this.d.bus.isRecoveryHeld || this.d.bus.storageError) return recorded;
308
322
  this.sweeping = true;
309
323
  try {
310
324
  for (const task of this.d.board.list()) {
311
325
  if (this.d.sweepHeld?.() || this.d.bus.isRecoveryHeld || this.d.bus.storageError) break;
312
- const finding = nextSweep(task, this.sweepConfig, now);
326
+ if (!task.owner && this.personUnassigned(task)) continue;
327
+ const finding = nextSweep(task, config, now, this.waitsFor(task).length === 0);
313
328
  if (!finding || this.waitsFor(task).length || this.isChecking(task.id) || this.silentFor(task.id)) continue;
329
+ if (finding.kind === "ready-unassigned") {
330
+ this.d.board.update(task.id, HUB, "idle sweep", {}, undefined, { sweep: finding });
331
+ recorded.push({ task: task.id, finding });
332
+ const planners = Object.entries(this.d.roles ?? {}).filter(([peer, roles]) => roles.includes("planner") && this.d.bus.isRostered(peer)).map(([peer]) => peer);
333
+ const text = `Task #${task.id}: ready-unassigned, idle escalation ${finding.step}/3.`;
334
+ if (finding.step === 1 || finding.step === 2) {
335
+ if (finding.step === 2 || !planners.length) this.releaseNotice(text);
336
+ for (const peer of planners) this.releaseNotice(`task #${task.id}: sweep notice unavailable`, () => this.sweepNotice(task, peer, `${text} Check the board and assign, re-plan or cancel it.`, "planner"));
337
+ } else {
338
+ const admission = this.plannerPullRecord(task)?.pull ? this.plannerAssignment(task, true) : assign(task, this.states(), this.d.routing(), { exclude: this.excluded(task), ...this.weights(task.class) });
339
+ this.releaseNotice(`${text} Owner reassignment suggestion: ${admission.owner ?? "no eligible peer"}.`);
340
+ if (admission.owner && config.auto_reassign && this.sweepAvailable(admission.owner)) {
341
+ try { await this.assignOwner(this.d.board.get(task.id)!, HUB, { candidates: [admission.owner], event: "reassigned", reason: "idle", note: "ready-unassigned idle sweep" }); }
342
+ catch (e) { if (!(e instanceof AssignmentUndeliveredError)) throw e; }
343
+ }
344
+ }
345
+ continue;
346
+ }
314
347
  const responsible = finding.kind === "review-pending" ? task.reviewer! : task.owner!;
315
348
  if (responsible !== USER && !this.sweepAvailable(responsible)) continue;
316
- 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;
349
+ if (finding.step === 3 && finding.kind !== "review-pending" && config.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;
317
350
  const title = this.publicTitle(task);
318
351
  const message = `Task ${title}: ${finding.kind}, idle escalation ${finding.step}/3.`;
319
352
  // Only identifiers, kind and step are kept in the ladder event. A crash after this write and before publish
@@ -321,11 +354,11 @@ export class Tasks {
321
354
  this.d.board.update(task.id, HUB, "idle sweep", {}, undefined, { sweep: finding });
322
355
  recorded.push({ task: task.id, finding });
323
356
  if (finding.step === 1) {
324
- this.sweepNotice(task, responsible, `${message} Check its current state with hub_task_list and accept, complete or review your assigned work.`);
357
+ this.sweepNotice(task, responsible, `${message} Check its current state with hub_task_list and accept, complete or review your assigned work.`, finding.kind === "review-pending" ? "reviewer" : "owner");
325
358
  } else if (finding.step === 2) {
326
359
  this.d.notify(message);
327
360
  for (const [peer, roles] of Object.entries(this.d.roles ?? {})) {
328
- if (peer !== responsible && roles.includes("planner") && this.sweepAvailable(peer)) this.sweepNotice(task, peer, `${message} The assigned ${finding.kind === "review-pending" ? "reviewer" : "owner"} has not recorded fresh task activity; check the board before coordinating.`);
361
+ if (peer !== responsible && roles.includes("planner") && this.sweepAvailable(peer)) this.sweepNotice(task, peer, `${message} The assigned ${finding.kind === "review-pending" ? "reviewer" : "owner"} has not recorded fresh task activity; check the board before coordinating.`, "planner");
329
362
  }
330
363
  } else {
331
364
  const reviewer = finding.kind === "review-pending";
@@ -333,7 +366,7 @@ export class Tasks {
333
366
  const candidate = reviewer ? a.reviewer : a.owner;
334
367
  this.passedOver(task, a);
335
368
  this.d.notify(`${message} ${reviewer ? "Reviewer" : "Owner"} reassignment suggestion: ${candidate ?? "no eligible peer"}.`);
336
- if (!reviewer && candidate && this.sweepConfig.auto_reassign && this.sweepAvailable(candidate)) {
369
+ if (!reviewer && candidate && config.auto_reassign && this.sweepAvailable(candidate)) {
337
370
  try {
338
371
  await this.assignOwner(this.d.board.get(task.id)!, HUB, { candidates: [candidate], exclude: [responsible], event: "reassigned", reason: "idle", note: `idle sweep: ${finding.kind}; from ${responsible}` });
339
372
  } catch (e) {
@@ -348,14 +381,19 @@ export class Tasks {
348
381
  return recorded;
349
382
  }
350
383
 
384
+ /** A validated snapshot takes effect on the next sweep, never halfway through one. */
385
+ configureSweep(config: TaskSweepConfig): void {
386
+ this.sweepConfig = taskSweepConfig(config);
387
+ }
388
+
351
389
  private sweepAvailable(peer: PeerId): boolean {
352
390
  const bus = this.d.bus;
353
391
  return bus.stateOf(peer) === "idle" && (this.d.idle?.(peer) ?? true) && !peerEntry(this.d.held?.(), peer) && !bus.queueSummary(peer).heldBy && !bus.queued(peer) && !bus.hasInFlight(peer);
354
392
  }
355
393
 
356
- private sweepNotice(task: Task, peer: PeerId, body: string): void {
394
+ private sweepNotice(task: Task, peer: PeerId, body: string, taskRecipient: "owner" | "reviewer" | "planner"): void {
357
395
  if (peer === USER) this.d.notify(body);
358
- else this.d.bus.publish(newEnvelope(HUB, body, { to: [peer], kind: "task", refs: { task: String(task.id) } }));
396
+ else this.d.bus.publish(newEnvelope(HUB, body, { to: [peer], kind: "task", refs: { task: String(task.id), taskRecipient } }));
359
397
  }
360
398
 
361
399
  configureExecutionBudget = (config: ExecutionBudgetConfig): ExecutionBudgetStatus | undefined => {
@@ -387,19 +425,20 @@ export class Tasks {
387
425
 
388
426
  /** A task as a cloud peer may see it. */
389
427
  publicView(task: Task, fullHistory = false): Record<string, unknown> {
390
- const { title, detail, history, external, ...rest } = task;
428
+ const { title, detail, history, external, cancelled, ...rest } = task;
391
429
  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]" };
392
430
  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}]`;
393
431
  const publicHistory = (fullHistory ? history : history.slice(-5)).map(h => ({
394
432
  at: h.at, by: h.by, event: h.event,
395
433
  ...(h.note === undefined ? {} : { note: screenHistoryText(h.note, "note", h.withheld) }),
434
+ ...(h.supersededBy === undefined ? {} : { supersededBy: screenHistoryText(h.supersededBy, "supersession", h.withheld) }),
396
435
  ...(h.profile === undefined ? {} : { profile: screenHistoryText(h.profile, "profile") }),
397
436
  ...(h.owner === undefined ? {} : { owner: h.owner }),
398
437
  ...(h.planning ? { planning: { revision: h.planning.revision, fields: h.planning.fields, ...(h.planning.split ? { children: h.planning.split.children.map(child => child.id) } : {}) } } : {}),
399
438
  ...(h.reason && TASK_MOVE_REASONS.includes(h.reason) ? { reason: h.reason } : {}),
400
439
  ...(h.sweep ? { sweep: { kind: h.sweep.kind, activity: h.sweep.activity, step: h.sweep.step, at: h.sweep.at } } : {}),
401
440
  }));
402
- 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 };
441
+ return { ...rest, ...(cancelled ? { cancelled: { ...cancelled, reason: screenHistoryText(cancelled.reason, "cancellation", cancelled.withheld), ...(cancelled.supersededBy ? { supersededBy: screenHistoryText(cancelled.supersededBy, "supersession", cancelled.withheld) } : {}) } } : {}), ...(external && this.nameable(external.url) ? { external } : {}), actualDates: actualTaskDates(task), ...(((this.d.sdlc?.config().mode ?? "off") !== "off" || this.d.sdlc?.config().workflow === "gsd-lite") ? { lifecycle: sdlcStatus(this.d.sdlc?.config() ?? sdlcConfig(undefined), task.sdlc, undefined, this.workflowView(task)) } : {}), title, detail, history: publicHistory };
403
442
  }
404
443
 
405
444
  /** Console results expose direct approval and delivery holds, not just the chosen peer ids. */
@@ -470,7 +509,16 @@ export class Tasks {
470
509
  private health() {
471
510
  const failing = this.d.failing?.();
472
511
  const held = this.d.held?.();
473
- 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 } : {}) };
512
+ return { ...(this.d.capabilities ? { capabilities: this.d.capabilities(), repository: this.d.repository } : {}), pullOnly: [...this.d.bus.peers.values()].filter(peer => peer.id === "kimi" && peer.pullOnly).map(peer => peer.id), offRoster: SUPPORTED_PEERS.filter(peer => !this.d.bus.isRostered(peer)), ...(failing ? { failing } : {}), ...(held ? { held } : {}), ...(this.d.roles ? { roles: this.d.roles } : {}) };
513
+ }
514
+
515
+ /** Capability admission precedes explicit assignments/reservations, independent of readiness or transient peer health. */
516
+ private requireCapabilities(task: Parameters<typeof assign>[0], peer: PeerId, role: TaskAssignmentRole = "owner", preEffect = true): void {
517
+ if (!this.d.capabilities || this.isPii(task)) return;
518
+ const { state: _state, ...draft } = task; // backlog/readiness must not bypass an explicit reservation's admission
519
+ const admission = assign(draft, this.states(), this.d.routing(), { candidates: role === "owner" ? [peer] : [], ...(role === "owner" ? { namedOwner: peer } : { namedReviewer: peer }), ...this.weights(task.class) });
520
+ const missing = (admission.refusals ?? []).filter(row => row.peer === peer && row.role === role && (row.code === "tests" || row.code === "spec")).map(row => ` ${role} candidate ${peer}: skipped, ${row.reason}`);
521
+ if (missing.length) throw new (preEffect ? PreEffectToolRefusal : Error)(`cannot assign ${peer}: ${missing.join("; ")}`);
474
522
  }
475
523
 
476
524
  /** What assignment weighs besides states and policy: quota, demotion and the review record. */
@@ -606,7 +654,9 @@ export class Tasks {
606
654
  }
607
655
 
608
656
  async pullPlanned(by: PeerId, input: { id: number; revision: number; reason: string }): Promise<Task> {
609
- this.planningAuthority(by);
657
+ // #455: one authority check, made before the screen and again after it (a person may move the planner meanwhile).
658
+ const authorized = () => this.planningAuthority(by);
659
+ authorized();
610
660
  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");
611
661
  const validate = () => {
612
662
  const task = this.d.board.get(input.id);
@@ -616,7 +666,7 @@ export class Tasks {
616
666
  if (this.waitsFor(task).length) throw new PreEffectToolRefusal("planner pull waits for dependency approval");
617
667
  const priority = task.priority ?? (task.signals.includes("urgent") ? 0 : 2);
618
668
  if (priority === 0) {
619
- 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")));
669
+ const goals = this.d.board.list().filter(goal => !isClosedTaskState(goal.state) && (goal.priority ?? (goal.signals.includes("urgent") ? 0 : 2)) === 0 && goal.rank != null && goal.history.some(entry => entry.by === USER && entry.planning?.fields.includes("rank")));
620
670
  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];
621
671
  const order = effectiveOrder(task) ?? { rank: task.rank ?? Number.MAX_SAFE_INTEGER, id: task.id };
622
672
  if (this.d.board.list("backlog").some(other => {
@@ -631,6 +681,7 @@ export class Tasks {
631
681
  return { task, owner: assignment.owner };
632
682
  };
633
683
  const initial = validate(), withheld = await this.withholds("note", input.reason, initial.task);
684
+ authorized();
634
685
  const current = validate();
635
686
  const pulled = this.d.board.pullPlanned(current.task.id, by, current.owner, input.revision, input.reason.trim(), withheld);
636
687
  this.planningRecorded({ by, task: pulled.id, fields: ["pull", "reserved"], revision: pulled.planningRevision! });
@@ -742,7 +793,7 @@ export class Tasks {
742
793
  const validate = () => {
743
794
  const task = this.d.board.get(input.id);
744
795
  if (!task || (task.planningRevision ?? 0) !== input.revision) throw new PreEffectToolRefusal("planning revision changed");
745
- if (task.state === "approved") throw new PreEffectToolRefusal("approved work is final");
796
+ if (isClosedTaskState(task.state)) throw new PreEffectToolRefusal("closed work is final");
746
797
  if (by !== USER && (task.state !== "backlog" || taskHasStarted(task) || task.pinned || Object.hasOwn(patch, "pinned"))) throw new PreEffectToolRefusal("planner changes only unpinned backlog work");
747
798
  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");
748
799
  if (by !== USER && Object.hasOwn(patch, "reserved") && this.hasPersonReservation(task)) throw new PreEffectToolRefusal("a person's reservation is retained");
@@ -750,6 +801,7 @@ export class Tasks {
750
801
  if (task.state !== "backlog" || taskHasStarted(task) || task.owner) throw new PreEffectToolRefusal("dependency and reservation changes require unstarted backlog work");
751
802
  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");
752
803
  if (patch.reserved && !this.d.bus.isRostered(patch.reserved)) throw new PreEffectToolRefusal("reserved owner is off roster");
804
+ if (patch.reserved) this.requireCapabilities(task, patch.reserved);
753
805
  const rows = new Map(this.d.board.list().map(row => [row.id, row]));
754
806
  const pending = [...(patch.deps ?? task.deps ?? [])], seen = new Set<number>();
755
807
  while (pending.length) {
@@ -773,6 +825,7 @@ export class Tasks {
773
825
  return task;
774
826
  };
775
827
  const task = validate(), withheld = await this.withholds("note", input.reason, task);
828
+ this.planningAuthority(by); // #455: or moved the planner
776
829
  validate(); // a person may have changed planning or frozen a release while the screen ran
777
830
  const updated = this.d.board.plan(task.id, by, patch, input.revision, input.reason.trim(), withheld);
778
831
  this.planningRecorded({ by, task: task.id, fields: Object.keys(patch), revision: updated.planningRevision! });
@@ -886,6 +939,7 @@ export class Tasks {
886
939
  let screened: Awaited<typeof attempt>;
887
940
  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); })]); }
888
941
  finally { if (deadline !== undefined) clearTimeout(deadline); }
942
+ this.planningAuthority(by); // #455: a person may have moved the planner while the screen ran
889
943
  const [screenedVerdicts, withheld] = screened, current = validate();
890
944
  const finalSignals = current.drafts.map(part => this.signalsFor({ ...part, detail: [part.detail, planText(part.plan)].join("\n") }));
891
945
  const deterministicPrivate = finalSignals.map((signals, index) => initialPrivate[index] || this.isPii({ signals }));
@@ -893,6 +947,10 @@ export class Tasks {
893
947
  const inherited = current.drafts.map((_part, index) => (initialParentPrivate || this.isPii(current.parent)) && !deterministicPrivate[index]);
894
948
  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"] : [])])] }));
895
949
  const result = this.d.board.splitBacklog(id, by, revision, drafts, reason, withheld || initialReasonPrivate || this.isPii(current.parent) || !this.nameable(reason));
950
+ if (this.d.coordination) {
951
+ const mode = this.d.coordination();
952
+ for (const child of result.children) this.coordinationModes.set(child.id, mode);
953
+ }
896
954
  for (const [index, child] of result.children.entries()) {
897
955
  this.incomingBacklog.add(child.id);
898
956
  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`); }
@@ -906,6 +964,7 @@ export class Tasks {
906
964
  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");
907
965
  const text = input.name + "\n" + input.goal;
908
966
  if (!this.nameable(text) || this.screenOn() && (await this.screenCall(text)).label !== "clear") throw new PreEffectToolRefusal("release text was not cleared for public planning");
967
+ this.planningAuthority(by); // #455: a person may have moved the planner while the screen ran
909
968
  if (this.d.board.releases().some(release => release.name === input.name)) throw new PreEffectToolRefusal("release name already exists");
910
969
  const release = this.d.board.newRelease(input.name, input.goal.trim(), input.order);
911
970
  this.planningRecorded({ by, release: release.id, fields: ["created"], revision: release.revision });
@@ -918,7 +977,7 @@ export class Tasks {
918
977
  if (!Number.isSafeInteger(input.id) || input.id <= 0 || !Number.isSafeInteger(input.revision) || !release || release.revision !== input.revision) throw new PreEffectToolRefusal("release revision changed");
919
978
  if (["shipped", "dropped"].includes(release.state)) throw new PreEffectToolRefusal("settled release is final");
920
979
  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");
921
- 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");
980
+ if (input.state === "shipped" && this.d.board.list().some(task => task.release === release.id && !isClosedTaskState(task.state))) throw new PreEffectToolRefusal("every release task must be approved, cancelled, or moved out before ship");
922
981
  const { id, revision, ...patch } = input;
923
982
  if (!Object.keys(patch).length || Object.keys(patch).some(key => !["state", "plannedStart", "cut", "ship"].includes(key))) throw new PreEffectToolRefusal("unknown or empty release fields");
924
983
  const updated = this.d.board.updateRelease(id, patch, revision);
@@ -985,7 +1044,9 @@ export class Tasks {
985
1044
  catch (error) { throw new PreEffectToolRefusal((error as Error).message); }
986
1045
  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 } : {}) } : {}) };
987
1046
  if (external) { const prior = this.d.board.imported(external.repo, external.issue); if (prior) return prior; }
1047
+ if (reserved) this.requireCapabilities({ ...draft, signals, ...(external ? { external } : {}) }, reserved);
988
1048
  let task = this.d.board.propose(by, { ...draft, plan, ...(external ? { external } : {}), ...(deps.length ? { deps } : {}), ...(reserved ? { reserved } : {}), signals, ...(backlog ? { backlog: true } : {}) });
1049
+ if (this.d.coordination) this.coordinationModes.set(task.id, this.d.coordination());
989
1050
  created?.();
990
1051
  if (screening) task = this.screenedTask(task, screened);
991
1052
  if (goal) this.linkNative(task, goal, by);
@@ -1180,7 +1241,7 @@ export class Tasks {
1180
1241
  * #106): open work by default; the conflict notices of #91 count a task in review too.
1181
1242
  */
1182
1243
  whileOpen(to: PeerId, task: number, body: string, states: Task["state"][] = OPEN): void {
1183
- const env = newEnvelope(HUB, body, { to: [to], kind: "task", refs: { task: String(task) } });
1244
+ const env = newEnvelope(HUB, body, { to: [to], kind: "task", refs: { task: String(task), taskRecipient: "owner" } });
1184
1245
  this.conditional.set(env.id, { peer: to, task, states });
1185
1246
  if (this.conditional.size > 1024) this.conditional.delete(this.conditional.keys().next().value as string);
1186
1247
  this.d.bus.publish(env);
@@ -1201,6 +1262,10 @@ export class Tasks {
1201
1262
  }
1202
1263
  return true;
1203
1264
  }
1265
+ if (env.from === HUB && env.refs?.task) {
1266
+ const task = this.d.board.get(Number(env.refs.task));
1267
+ if (task?.state === "cancelled") return env.kind === "task" && typeof env.refs.taskCancelled === "number" && env.refs.taskCancelled === task.cancelled?.flowRevision;
1268
+ }
1204
1269
  const c = this.conditional.get(env.id);
1205
1270
  if (!c || c.peer !== peer) return true;
1206
1271
  const t = this.d.board.get(c.task);
@@ -1326,6 +1391,7 @@ export class Tasks {
1326
1391
  if (typeof target === "number") {
1327
1392
  const task = this.d.board.get(target);
1328
1393
  if (!task) throw new Error(`no task #${target}`);
1394
+ if (isClosedTaskState(task.state)) return [...workflow, `task ${this.publicTitle(task)} (${task.state}): closed; not routed`];
1329
1395
  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) });
1330
1396
  // The split trace is for the pair the record is about: the task's owner when it has one (issue #109).
1331
1397
  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 ?? [])];
@@ -1333,14 +1399,17 @@ export class Tasks {
1333
1399
  const draft = { title: target.title, detail: target.detail ?? "", refs: target.refs ?? {} };
1334
1400
  const signals = this.signalsFor(draft, routing);
1335
1401
  const base = profileClass(target.class, this.d.profileData ?? this.d.profile);
1336
- 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];
1402
+ const trace = [...(this.d.profile && this.d.profile !== "dev" ? [`profile class ${target.class} (base ${base}, ${this.d.profile})`] : []), ...assign({ class: base, signals, ...draft }, this.states(), routing, this.weights(base)).trace];
1337
1403
  // A draft is not sent to the PII screen (issue #198): a proposal is, and a pii or unknown verdict adds the pii signal.
1338
1404
  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)];
1339
1405
  }
1340
1406
 
1341
1407
  /** Public read path: every generated line is screened, including profile/config-derived trace fields. */
1342
1408
  publicExplain(target: number | { title: string; detail?: string; class: TaskClass; refs?: TaskRefs }, routing?: Routing): string[] {
1343
- return this.explain(target, routing).map(line => this.nameable(line) ? line : "[routing explanation withheld: it matches a PII pattern]");
1409
+ const trace = this.explain(target, routing);
1410
+ const holds = Object.entries(this.d.held?.() ?? {}).flatMap(([peer, hold]) =>
1411
+ trace.some(line => line.startsWith(` hold: ${peer}'s queue is held:`)) ? [] : [`${peer} queue: ${hold}`]);
1412
+ return [...trace, ...holds].map(line => this.nameable(line) ? line : "[routing explanation withheld: it matches a PII pattern]");
1344
1413
  }
1345
1414
 
1346
1415
  /**
@@ -1412,6 +1481,7 @@ export class Tasks {
1412
1481
  }
1413
1482
  const passed = this.passedOver(task, a);
1414
1483
  if (!a.owner) {
1484
+ if (!task.owner && task.state === "proposed") this.reportStall(task, this.ownerStallCause(task, a));
1415
1485
  this.releaseNotice(opts.roster
1416
1486
  ? `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>`
1417
1487
  : `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>`);
@@ -1483,7 +1553,7 @@ export class Tasks {
1483
1553
  if (by !== USER) throw new PreEffectToolRefusal("only the person may change an existing task catalog role");
1484
1554
  if (raw === undefined) throw new PreEffectToolRefusal("name a catalog role id or explicit null to clear it");
1485
1555
  const task = this.need(id, false, true);
1486
- if (task.state === "approved") throw new PreEffectToolRefusal("an approved task's role is historical; propose new work instead");
1556
+ if (isClosedTaskState(task.state)) throw new PreEffectToolRefusal("a closed task's role is historical; propose new work instead");
1487
1557
  let selected;
1488
1558
  try { selected = taskRole(raw === null ? undefined : raw, task.class, this.d.catalog ?? loadRoleCatalog(this.d.cwd)); }
1489
1559
  catch (error) { throw new PreEffectToolRefusal((error as Error).message); }
@@ -1493,7 +1563,7 @@ export class Tasks {
1493
1563
  if (!peer || peer === USER) continue;
1494
1564
  this.releaseNotice(`task ${this.publicTitle(next)}: catalog role update delivery unconfirmed`, () => this.d.bus.publish(newEnvelope(HUB,
1495
1565
  `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."}`,
1496
- { to: [peer], kind: "task", refs: { task: String(next.id) }, ...(this.isPii(next) ? { private: true } : {}) })));
1566
+ { to: [peer], kind: "task", refs: { task: String(next.id), taskRecipient: peer === next.reviewer ? "reviewer" : "owner" }, ...(this.isPii(next) ? { private: true } : {}) })));
1497
1567
  }
1498
1568
  return next;
1499
1569
  }
@@ -1548,7 +1618,8 @@ export class Tasks {
1548
1618
  // (Codex, Pi); held, it starts no turn yet: neither is taking it.
1549
1619
  const owner = task.owner!;
1550
1620
  const idle = this.d.bus.stateOf(owner) === "idle";
1551
- const env = newEnvelope(HUB, body, { to: [owner], kind: "task", priority: "important", refs: { ...task.refs, task: String(task.id) }, ...(pii ? { private: true } : {}) });
1621
+ const env = newEnvelope(HUB, body, { to: [owner], kind: "task", priority: "important", refs: { ...task.refs, task: String(task.id), taskRecipient: "owner", taskOffer: task.history.length }, ...(pii ? { private: true } : {}) });
1622
+ this.d.board.recordTaskOffer(task.id, { envelopeId: env.id, owner, assignmentFlowRevision: task.history.length, ...(this.d.offerPullOnly ? { pullOnly: this.d.offerPullOnly(owner) } : {}) }, task.history.length);
1552
1623
  this.d.bus.publish(env);
1553
1624
  if (idle && this.d.bus.stateOf(owner) === "busy" && !this.d.bus.queueIds(owner).includes(env.id)) this.sent.set(handOver(task, owner), owner);
1554
1625
  }
@@ -1606,14 +1677,98 @@ export class Tasks {
1606
1677
  * each such task is offered once an attached peer can take it (peers attach one by one). Only an assignment uses
1607
1678
  * up its offer for this hub run; a failed or ownerless offer stays eligible, after approval and on the timer alike.
1608
1679
  */
1680
+ private offerCandidates?: { revision: number; offers: { task: number; peer: PeerId; envelopeId: string; assignmentFlowRevision: number; pullOnly?: boolean }[] };
1681
+ private stallCandidates?: { revision: number; rows: { stall: TaskStall; task: Task; planned: boolean }[] };
1682
+ private stallView?: { key: string; stalls: TaskStall[] };
1683
+
1684
+ pendingOffers(): { task: number; peer: PeerId; envelopeId: string; assignmentFlowRevision: number; pullOnly?: boolean }[] {
1685
+ const revision = this.d.board.statusRevision();
1686
+ if (this.offerCandidates?.revision !== revision) this.offerCandidates = { revision, offers: this.d.board.list("proposed").flatMap(task => {
1687
+ const offer = task.offer;
1688
+ if (!offer || task.owner !== offer.owner || task.history.slice(offer.assignmentFlowRevision).some(h => ["accepted", "declined", "assigned", "reassigned", "escalated", "unassigned", "reserved", "backlog returned"].includes(h.event) || h.owner !== undefined && h.owner !== offer.owner)) return [];
1689
+ return [{ task: task.id, peer: offer.owner, envelopeId: offer.envelopeId, assignmentFlowRevision: offer.assignmentFlowRevision, ...(offer.pullOnly === undefined ? {} : { pullOnly: offer.pullOnly }) }];
1690
+ }) };
1691
+ return this.offerCandidates.offers.filter(offer => {
1692
+ try { return this.d.offerRead?.(offer.peer, offer.envelopeId) !== true; } catch { return true; }
1693
+ }).map(offer => ({ ...offer }));
1694
+ }
1695
+ stalled(): TaskStall[] {
1696
+ const revision = this.d.board.statusRevision();
1697
+ if (this.stallCandidates?.revision !== revision) this.stallCandidates = { revision, rows: this.d.board.stalls().flatMap(stall => {
1698
+ const task = this.d.board.get(stall.task);
1699
+ if (task?.state !== "proposed" || task.owner || (task.planningRevision ?? 0) !== stall.replanRevision) return [];
1700
+ if (stall.cause.kind === "cancelled-dependency") return task.deps?.includes(stall.cause.dependency!) === true && this.d.board.get(stall.cause.dependency!)?.state === "cancelled" ? [{ stall, task, planned: false }] : [];
1701
+ return this.waitsFor(task).length ? [] : [{ stall, task, planned: this.plannerPullRecord(task)?.pull === true }];
1702
+ }) };
1703
+ // Eligibility inputs are live; queue journal writes alone do not re-decode board histories.
1704
+ const key = JSON.stringify([revision, this.states(), this.d.routing(), this.health(), [...this.d.bus.peers.keys()].map(peer => {
1705
+ const queue = this.d.bus.queueSummary(peer);
1706
+ return [peer, this.wipLimit(peer), queue.heldBy, queue.needsReview];
1707
+ })]);
1708
+ if (this.stallView?.key !== key) this.stallView = { key, stalls: this.stallCandidates.rows.filter(({ stall, task, planned }) => stall.cause.kind === "cancelled-dependency" || !(planned ? this.plannerAssignment(task, true) : assign(task, this.states(), this.d.routing(), { exclude: this.excluded(task), ...this.health() })).owner).map(({ stall }) => stall) };
1709
+ return structuredClone(this.stallView.stalls);
1710
+ }
1711
+ private ownerStallCause(task: Task, admission: Assignment): TaskStall["cause"] {
1712
+ const refusal = admission.refusals?.find(row => row.role === "owner" && row.peer === task.reserved);
1713
+ return refusal ? { kind: "reservation", peer: refusal.peer, code: refusal.code } : { kind: "no-eligible-peer", code: "unavailable", refusals: admission.refusals?.filter(row => row.role === "owner").map(({ peer, code }) => ({ peer, code })).sort((a, b) => a.peer.localeCompare(b.peer)) ?? [] };
1714
+ }
1715
+ private reportStall(task: Task, cause: TaskStall["cause"], now = Date.now()): void {
1716
+ const stall: TaskStall = { task: task.id, cause, signature: JSON.stringify(cause), replanRevision: task.planningRevision ?? 0, at: now };
1717
+ if (!this.d.board.recordStall(stall)) return;
1718
+ const text = `Task #${task.id} stalled: ${cause.kind}${cause.peer ? ` ${cause.peer}` : ""}${cause.dependency ? ` #${cause.dependency}` : ""} (${cause.code}); configure the runner or peer, ahub task assign ${task.id} <peer>, re-plan dependencies, or cancel it.`;
1719
+ this.releaseNotice(text);
1720
+ try { this.d.stallNotice?.(stall); } catch { /* advisory never changes task authority */ }
1721
+ // Advice joins the planner's next delivery; it must not manufacture a turn beside a withheld backlog batch.
1722
+ for (const [peer, roles] of Object.entries(this.d.roles ?? {})) if (roles.includes("planner") && this.d.bus.isRostered(peer)) this.releaseNotice(`task #${task.id}: stall notice unavailable`, () => this.d.tell?.(peer, noteLine(HUB, "finding", text)));
1723
+ }
1724
+ async cancel(id: unknown, by: PeerId, input: { reason: string; supersededBy?: string; expectedFlowRevision?: number }): Promise<Task> {
1725
+ if (typeof input.reason !== "string" || !input.reason.trim() || Buffer.byteLength(input.reason) > 4000 || input.supersededBy !== undefined && (typeof input.supersededBy !== "string" || !input.supersededBy.trim() || Buffer.byteLength(input.supersededBy) > 1000)) throw new PreEffectToolRefusal("cancellation needs a bounded reason and supersession reference");
1726
+ const validate = () => {
1727
+ if (this.d.cancellationHeld?.() || this.d.bus.isRecoveryHeld) throw new PreEffectToolRefusal("recovery or shutdown is holding cancellation");
1728
+ const task = this.need(id, false, true, true);
1729
+ this.checkFlowRevision(task, input.expectedFlowRevision, true);
1730
+ if (isClosedTaskState(task.state)) throw new PreEffectToolRefusal(`task #${task.id} is already ${task.state}`);
1731
+ if (by !== USER && this.d.privateTurn?.(by) && !this.isPii(task)) throw new PreEffectToolRefusal("cancellation on ordinary work is unavailable during a private turn");
1732
+ if (by !== USER) {
1733
+ this.planningAuthority(by);
1734
+ const proposer = task.history.find(h => h.event === "proposed" || h.event === "backlogged")?.by;
1735
+ if (proposer !== by || !["backlog", "proposed"].includes(task.state) || taskHasStarted(task)) throw new PreEffectToolRefusal("planner may cancel only its own historically unaccepted backlog or proposed work");
1736
+ }
1737
+ return task;
1738
+ };
1739
+ const before = validate(), revision = before.history.length;
1740
+ const reasonText = [input.reason, input.supersededBy].filter(Boolean).join("\n");
1741
+ const withheld = looksSecret(reasonText) || this.aboutPrivatePath("", reasonText, before) || await this.withholds("note", reasonText, before);
1742
+ const task = validate();
1743
+ if (task.history.length !== revision) throw new PreEffectToolRefusal("task flow revision changed");
1744
+ const cancelled = { reason: input.reason.trim(), ...(input.supersededBy ? { supersededBy: input.supersededBy.trim() } : {}), flowRevision: revision + 1, ...(withheld ? { withheld: true as const } : {}) };
1745
+ const next = this.d.board.update(task.id, by, "cancelled", { state: "cancelled", reserved: null, cancelled }, cancelled.reason, { ...(withheld ? { withheld: true as const } : {}) }, revision);
1746
+ this.releaseNotice(`task #${task.id}: cancellation cohort cleanup unavailable`, () => this.cohorts.leave(task.id));
1747
+ this.offered.delete(task.id);
1748
+ this.releaseNotice(`task #${task.id}: cancellation stall cleanup unavailable`, () => this.d.board.clearStall(task.id));
1749
+ for (const peer of new Set([task.owner, task.reviewer])) if (peer && peer !== USER) this.releaseNotice(`task #${task.id}: cancellation notice unavailable`, () => this.d.bus.publish(newEnvelope(HUB, sanitize(`Task #${task.id} cancelled. Stop working on it. No native turn or working tree was changed.`), { to: [peer], kind: "task", priority: "important", refs: { task: String(task.id), taskCancelled: cancelled.flowRevision }, ...(this.isPii(next) ? { private: true } : {}) })));
1750
+ this.releaseNotice(`task #${task.id}: cancellation dependent advice unavailable`, () => {
1751
+ for (const dependent of this.d.board.list()) if (!isClosedTaskState(dependent.state) && !dependent.owner && dependent.deps?.includes(task.id)) this.releaseNotice(`task #${dependent.id}: cancellation stall advice unavailable`, () => this.reportStall(dependent, { kind: "cancelled-dependency", dependency: task.id, code: "cancelled" }));
1752
+ });
1753
+ return next;
1754
+ }
1755
+
1609
1756
  async releaseReady(): Promise<void> {
1610
1757
  for (const t of this.d.board.list("proposed")) {
1758
+ if (t.owner) { this.d.board.clearStall(t.id); continue; }
1759
+ const cancelledDependency = t.deps?.find(id => this.d.board.get(id)?.state === "cancelled");
1760
+ if (cancelledDependency) { this.reportStall(t, { kind: "cancelled-dependency", dependency: cancelledDependency, code: "cancelled" }); continue; }
1611
1761
  const last = t.history.findLast(entry => !QUIET_EVENTS.has(entry.event))?.event, planned = this.plannerPullRecord(t)?.pull === true;
1612
- if ((!t.deps?.length && !t.history.some(h => h.event === "backlog pulled")) || t.owner || this.personUnassigned(t) || this.offered.has(t.id) || (!planned && last !== "blocked" && last !== "ready" && last !== "reserved") || this.waitsFor(t).length) continue;
1613
- if (planned && !this.plannerAssignment(t, true).owner) continue;
1762
+ if (this.personUnassigned(t) || this.offered.has(t.id) || this.waitsFor(t).length) continue;
1763
+ const admission = planned ? this.plannerAssignment(t, true) : assign(t, this.states(), this.d.routing(), { exclude: this.excluded(t), ...this.health() });
1764
+ if (!admission.owner) {
1765
+ this.reportStall(t, this.ownerStallCause(t, admission));
1766
+ continue;
1767
+ }
1768
+ this.d.board.clearStall(t.id);
1769
+ if ((!t.deps?.length && !t.history.some(h => h.event === "backlog pulled")) || (!planned && last !== "blocked" && last !== "ready" && last !== "reserved")) continue;
1614
1770
  // ponytail: a reserved owner (#207) that attaches after another peer that can take the task is passed over here like
1615
1771
  // an offline one; hold reserved tasks for a grace period if restarts show that up.
1616
- if (!assign(t, this.states(), this.d.routing(), { exclude: this.excluded(t), ...this.health() }).owner) continue; // nobody attached can take it yet
1617
1772
  await this.offerReady(t, "nothing left to wait for");
1618
1773
  }
1619
1774
  }
@@ -1728,13 +1883,13 @@ export class Tasks {
1728
1883
  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("; ")}`);
1729
1884
  }
1730
1885
 
1731
- private need(id: unknown, open = false, preEffect = false): Task {
1886
+ private need(id: unknown, open = false, preEffect = false, allowBacklog = false): Task {
1732
1887
  // Callers are models (#70): an id is a whole number or a digit string, never an array that Number() would accept.
1733
1888
  const n = typeof id === "number" ? id : typeof id === "string" && /^\s*\d+\s*$/.test(id) ? Number(id) : NaN;
1734
1889
  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)}`);
1735
1890
  const task = this.d.board.get(n);
1736
1891
  if (!task) throw new (preEffect ? PreEffectToolRefusal : Error)(`no task #${n}`);
1737
- if (task.state === "backlog") throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${n} is backlog: pull it into To Do first`);
1892
+ if (task.state === "backlog" && !allowBacklog) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${n} is backlog: pull it into To Do first`);
1738
1893
  // Handing a task to someone else only makes sense while there is work left on it.
1739
1894
  if (open && !OPEN.includes(task.state)) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id} is ${task.state}: it can no longer change hands`);
1740
1895
  return task;
@@ -1775,8 +1930,10 @@ export class Tasks {
1775
1930
  */
1776
1931
  private formCohort(task: Task, hits: ReturnType<Tasks["overlapHits"]>): void {
1777
1932
  if (!task.owner || this.isPii(task)) return;
1933
+ const coordinate = this.coordinationModes.get(task.id);
1934
+ const coordinated = this.d.coordination ? hits.filter(hit => this.coordinationModes.get(hit.task.id) === coordinate) : hits;
1778
1935
  const before = this.cohorts.of(task.id)?.revision;
1779
- const joined = this.cohorts.join(task, hits.map((h) => h.task), ownerGen, handedAt);
1936
+ const joined = this.cohorts.join(task, coordinated.map((h) => h.task), ownerGen, handedAt, !this.d.coordination || coordinate === true);
1780
1937
  if (!joined) return;
1781
1938
  const c = joined.cohort;
1782
1939
  if (joined.formed || c.revision !== before) {
@@ -1837,7 +1994,7 @@ export class Tasks {
1837
1994
  const valid = (): Task => {
1838
1995
  const t = this.need(id, false, true);
1839
1996
  this.mine(t, by, "owner", true);
1840
- if (t.state === "in_review" || t.state === "approved") throw new PreEffectToolRefusal(`task #${t.id} is already ${t.state}`);
1997
+ if (t.state === "in_review" || isClosedTaskState(t.state)) throw new PreEffectToolRefusal(`task #${t.id} is already ${t.state}`);
1841
1998
  this.ready(t, true);
1842
1999
  if (this.checking.has(t.id)) {
1843
2000
  // The result goes only to the owner the check was started for: anyone who took the task since hears nothing.
@@ -2013,7 +2170,7 @@ export class Tasks {
2013
2170
  task = this.need(task.id, false, true);
2014
2171
  this.mine(task, by, "owner", true);
2015
2172
  // In every mode: another done may have landed during the readback.
2016
- if (task.state === "in_review" || task.state === "approved") throw new Error(`task #${task.id} is already ${task.state}`);
2173
+ if (task.state === "in_review" || isClosedTaskState(task.state)) throw new Error(`task #${task.id} is already ${task.state}`);
2017
2174
  reviewer = task.reviewer;
2018
2175
  }
2019
2176
  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 });
@@ -2219,7 +2376,7 @@ export class Tasks {
2219
2376
  if (from && task.reviewer && task.reviewer !== USER && this.requestedChanges(task).has(task.reviewer)) this.d.board.recordReview({ implementer: from, reviewer: task.reviewer, class: task.class, kind: "escalated", task: task.id });
2220
2377
  const stopFormerOwner = (moved: Task) => {
2221
2378
  if (from && moved.owner && moved.owner !== from) this.releaseNotice(`task ${this.publicTitle(moved)}: could not tell ${from} it moved to ${moved.owner}`, () =>
2222
- this.tell({ ...moved, owner: from }, `Task #${moved.id} moved to ${moved.owner} (${why}). Stop working on it.`, this.isPii(moved)));
2379
+ this.tell({ ...moved, owner: from }, `Task #${moved.id} moved to ${moved.owner} (${why}). Stop working on it.`, this.isPii(moved), "withdrawal"));
2223
2380
  };
2224
2381
  // Only our landed move authorizes a success notice and decision note. The former owner's stop
2225
2382
  // message still names the current owner when a later assignment superseded this move.
@@ -2264,7 +2421,8 @@ export class Tasks {
2264
2421
  ? `Task ${this.publicTitle(next)} is no longer assigned to you${next.owner ? `; its owner is now ${next.owner}` : "; it is unassigned"}. Do not accept or complete it; late calls under your former assignment are refused.`
2265
2422
  : `Review request for task #${previous.id} ${this.publicTitle(next)} is withdrawn: ${next.reviewer ? `its reviewer is now ${next.reviewer}` : "the task has no reviewer"}. Do not review this task; a late verdict from the previous reviewer is refused.`;
2266
2423
  if (peer === USER) this.d.notify(body);
2267
- else this.d.bus.publish(newEnvelope(HUB, body, { to: [peer], kind: "status", priority: "important", refs: { task: String(previous.id) }, ...(this.isPii(next) ? { private: true } : {}) }));
2424
+ // An untagged task notice remains needed by a former role until approval; kind review would look moved already.
2425
+ else this.d.bus.publish(newEnvelope(HUB, body, { to: [peer], kind: "task", priority: "important", refs: { task: String(previous.id) }, ...(this.isPii(next) ? { private: true } : {}) }));
2268
2426
  });
2269
2427
  }
2270
2428
 
@@ -2278,6 +2436,7 @@ export class Tasks {
2278
2436
  const task = this.need(id, role === "owner", preEffect);
2279
2437
  this.checkFlowRevision(task, expectedFlowRevision, preEffect);
2280
2438
  if (!this.d.bus.isRostered(peer)) throw new (preEffect ? PreEffectToolRefusal : Error)(`${peer} is off roster; add it to the roster before assigning work`);
2439
+ this.requireCapabilities(task, peer, role, preEffect);
2281
2440
  if (role === "reviewer") {
2282
2441
  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`);
2283
2442
  this.ready(task, preEffect);
@@ -2303,7 +2462,7 @@ export class Tasks {
2303
2462
  if (role !== "owner" && role !== "reviewer") throw new (preEffect ? PreEffectToolRefusal : Error)("role must be owner or reviewer");
2304
2463
  const task = this.need(id, false, preEffect);
2305
2464
  this.checkFlowRevision(task, expectedFlowRevision, preEffect);
2306
- if (task.state === "approved") throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id} is approved: its ${role} is historical`);
2465
+ if (isClosedTaskState(task.state)) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id} is ${task.state}: its ${role} is historical`);
2307
2466
  if (role === "owner") {
2308
2467
  if (!OPEN.includes(task.state)) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id} is ${task.state}: its owner cannot be unassigned`);
2309
2468
  if (!task.owner && !task.reserved) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id}: no owner or owner reservation to unassign`);
@@ -2331,7 +2490,7 @@ export class Tasks {
2331
2490
  /** Read-only preview using the same assignment constraints as the roster relay. */
2332
2491
  rosterPreview(peer: PeerId): { id: number; title: string; role: "owner" | "reviewer"; to: PeerId | null }[] {
2333
2492
  const routing = this.d.routing();
2334
- return this.d.board.list().filter(task => task.state !== "approved" && (task.owner === peer || task.reviewer === peer)).map(task => {
2493
+ return this.d.board.list().filter(task => !isClosedTaskState(task.state) && (task.owner === peer || task.reviewer === peer)).map(task => {
2335
2494
  const owner = task.owner === peer;
2336
2495
  const weights = this.weights(task.class);
2337
2496
  const pii = this.isPii(task);
@@ -2377,7 +2536,7 @@ export class Tasks {
2377
2536
  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.)`));
2378
2537
  }
2379
2538
  moved.push({ id: task.id, title: this.publicTitle(task), to: next.owner, role: "owner" });
2380
- } else if (task.reviewer === peer && task.state !== "approved") {
2539
+ } else if (task.reviewer === peer && !isClosedTaskState(task.state)) {
2381
2540
  const a = assign(task, this.states(), routing, { exclude: [peer], candidates: [], ...(task.owner ? { notReviewer: task.owner } : {}), ...this.weights(task.class) });
2382
2541
  const reviewer = a.reviewer && a.reviewer !== peer && a.reviewer !== task.owner ? a.reviewer : null;
2383
2542
  if (roster && !reviewer) {
@@ -2529,7 +2688,7 @@ export class Tasks {
2529
2688
  let n = 0;
2530
2689
  for (const t of this.d.board.list("approved")) {
2531
2690
  if (this.isPii(t)) continue;
2532
- try { this.d.memoryCheck?.(); const key = this.memoryKey(t); if (this.d.store.store.hasTask(key) && !this.d.store.store.settled(key)) { this.settleMemory(t); n++; } }
2691
+ try { this.d.memoryCheck?.(); const key = this.memoryKey(t); if (this.d.store.store.hasTask(key) && !this.d.store.store.settled(key, { includeLegacy: true })) { this.settleMemory(t); n++; } }
2533
2692
  catch { /* fail-open: tried again at the next start */ }
2534
2693
  }
2535
2694
  return n;
@@ -2570,9 +2729,9 @@ export class Tasks {
2570
2729
  }
2571
2730
  }
2572
2731
 
2573
- private tell(task: Task, body: string, pii: boolean): void {
2732
+ private tell(task: Task, body: string, pii: boolean, audience: "owner" | "withdrawal" = "owner"): void {
2574
2733
  if (!task.owner || task.owner === USER) return;
2575
- this.d.bus.publish(newEnvelope(HUB, body, { to: [task.owner], kind: "task", priority: "important", refs: { task: String(task.id) }, ...(pii ? { private: true } : {}) }));
2734
+ this.d.bus.publish(newEnvelope(HUB, body, { to: [task.owner], kind: "task", priority: "important", refs: { task: String(task.id), ...(audience === "owner" ? { taskRecipient: "owner" as const } : {}) }, ...(pii ? { private: true } : {}) }));
2576
2735
  }
2577
2736
  }
2578
2737
 
package/src/hub/ui.ts CHANGED
@@ -131,7 +131,7 @@ export function startDashboard(options: DashboardOptions) {
131
131
  }
132
132
  }
133
133
  try {
134
- if (sessionOf(cookie).liveTest && !["start", "stop", "pause", "resume", "permit", "start_peer", "setting", "setting_undo", "setting_preview", "sdlc_queue", "sdlc_release"].includes(String(input.action))) {
134
+ if (sessionOf(cookie).liveTest && !["start", "stop", "pause", "resume", "permit", "start_peer", "setting", "setting_undo", "setting_preview", "setting_reload", "sdlc_queue", "sdlc_release"].includes(String(input.action))) {
135
135
  return reject(403, "live-test dashboard cannot write person-attributed content; use peer tools");
136
136
  }
137
137
  if (input.action === "sdlc_queue" || input.action === "sdlc_release") server.timeout(req, 35);