@staix/agent-hub 0.12.30 → 0.12.31

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 (64) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +21 -7
  3. package/docs/agent-notes/adapters.md +2 -1
  4. package/docs/agent-notes/budget.md +1 -1
  5. package/docs/agent-notes/bus.md +4 -0
  6. package/docs/agent-notes/daemon.md +5 -2
  7. package/docs/agent-notes/local-worker.md +2 -0
  8. package/docs/agent-notes/tasks.md +5 -0
  9. package/docs/agent-notes/tests.md +5 -2
  10. package/docs/memory.md +1 -1
  11. package/docs/operations.md +129 -22
  12. package/docs/quickstart.md +17 -2
  13. package/docs/security.md +30 -2
  14. package/docs/specs/2026-09-19-agent-hub-design.md +37 -3
  15. package/docs/specs/2026-09-20-upgrade-recovery-design.md +84 -10
  16. package/docs/verification/2026-10-17-0.12.31.md +71 -0
  17. package/docs/verification/2026-10-17-418-probes.md +21 -0
  18. package/docs/verified.json +13 -13
  19. package/package.json +1 -1
  20. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  21. package/plugins/agent-hub/server.js +66 -19
  22. package/src/adapters/claude-channel.ts +29 -11
  23. package/src/adapters/local-worker.ts +2 -2
  24. package/src/adapters/pi.ts +5 -0
  25. package/src/cli/attention.ts +5 -1
  26. package/src/cli/console-state.ts +56 -12
  27. package/src/cli/console.ts +19 -6
  28. package/src/cli/identity.ts +3 -2
  29. package/src/cli/jcode-mcp.ts +1 -1
  30. package/src/cli/main.ts +8 -1
  31. package/src/cli/output.ts +13 -2
  32. package/src/cli/upgrade-interactive.ts +2 -1
  33. package/src/cli/upgrade-restore-state.ts +133 -0
  34. package/src/cli/upgrade-runtime.ts +162 -58
  35. package/src/cli/upgrade.ts +49 -24
  36. package/src/hub/board.ts +82 -24
  37. package/src/hub/bus.ts +78 -29
  38. package/src/hub/check-runner.ts +514 -0
  39. package/src/hub/child-process.ts +1 -0
  40. package/src/hub/control-client.ts +5 -1
  41. package/src/hub/daemon.ts +400 -67
  42. package/src/hub/delivery-hints.ts +30 -0
  43. package/src/hub/delivery-journal.ts +185 -12
  44. package/src/hub/delivery-superseded.ts +52 -0
  45. package/src/hub/envelope.ts +1 -1
  46. package/src/hub/host-executable.ts +11 -0
  47. package/src/hub/hub-tools.ts +9 -1
  48. package/src/hub/peer-catalog.ts +3 -0
  49. package/src/hub/routing.ts +35 -16
  50. package/src/hub/settings.ts +2 -1
  51. package/src/hub/spec-read.ts +120 -0
  52. package/src/hub/supervision.ts +21 -6
  53. package/src/hub/task-sweep.ts +7 -6
  54. package/src/hub/tasks.ts +173 -34
  55. package/src/memory/capture.ts +2 -1
  56. package/src/ui/index.html +12 -9
  57. package/src/ui/needs.ts +7 -5
  58. package/src/ui/office-model.ts +4 -1
  59. package/src/ui/task-board.ts +1 -1
  60. package/src/ui/task-progress.ts +6 -2
  61. package/templates/AGENTS.block.md +1 -1
  62. package/templates/profiles/general.json +1 -1
  63. package/templates/profiles/research.json +1 -1
  64. 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>;
@@ -309,8 +315,27 @@ export class Tasks {
309
315
  try {
310
316
  for (const task of this.d.board.list()) {
311
317
  if (this.d.sweepHeld?.() || this.d.bus.isRecoveryHeld || this.d.bus.storageError) break;
312
- const finding = nextSweep(task, this.sweepConfig, now);
318
+ if (!task.owner && this.personUnassigned(task)) continue;
319
+ const finding = nextSweep(task, this.sweepConfig, now, this.waitsFor(task).length === 0);
313
320
  if (!finding || this.waitsFor(task).length || this.isChecking(task.id) || this.silentFor(task.id)) continue;
321
+ if (finding.kind === "ready-unassigned") {
322
+ this.d.board.update(task.id, HUB, "idle sweep", {}, undefined, { sweep: finding });
323
+ recorded.push({ task: task.id, finding });
324
+ const planners = Object.entries(this.d.roles ?? {}).filter(([peer, roles]) => roles.includes("planner") && this.d.bus.isRostered(peer)).map(([peer]) => peer);
325
+ const text = `Task #${task.id}: ready-unassigned, idle escalation ${finding.step}/3.`;
326
+ if (finding.step === 1 || finding.step === 2) {
327
+ if (finding.step === 2 || !planners.length) this.releaseNotice(text);
328
+ 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"));
329
+ } else {
330
+ 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) });
331
+ this.releaseNotice(`${text} Owner reassignment suggestion: ${admission.owner ?? "no eligible peer"}.`);
332
+ if (admission.owner && this.sweepConfig.auto_reassign && this.sweepAvailable(admission.owner)) {
333
+ try { await this.assignOwner(this.d.board.get(task.id)!, HUB, { candidates: [admission.owner], event: "reassigned", reason: "idle", note: "ready-unassigned idle sweep" }); }
334
+ catch (e) { if (!(e instanceof AssignmentUndeliveredError)) throw e; }
335
+ }
336
+ }
337
+ continue;
338
+ }
314
339
  const responsible = finding.kind === "review-pending" ? task.reviewer! : task.owner!;
315
340
  if (responsible !== USER && !this.sweepAvailable(responsible)) continue;
316
341
  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;
@@ -321,11 +346,11 @@ export class Tasks {
321
346
  this.d.board.update(task.id, HUB, "idle sweep", {}, undefined, { sweep: finding });
322
347
  recorded.push({ task: task.id, finding });
323
348
  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.`);
349
+ 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
350
  } else if (finding.step === 2) {
326
351
  this.d.notify(message);
327
352
  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.`);
353
+ 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
354
  }
330
355
  } else {
331
356
  const reviewer = finding.kind === "review-pending";
@@ -353,9 +378,9 @@ export class Tasks {
353
378
  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
379
  }
355
380
 
356
- private sweepNotice(task: Task, peer: PeerId, body: string): void {
381
+ private sweepNotice(task: Task, peer: PeerId, body: string, taskRecipient: "owner" | "reviewer" | "planner"): void {
357
382
  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) } }));
383
+ else this.d.bus.publish(newEnvelope(HUB, body, { to: [peer], kind: "task", refs: { task: String(task.id), taskRecipient } }));
359
384
  }
360
385
 
361
386
  configureExecutionBudget = (config: ExecutionBudgetConfig): ExecutionBudgetStatus | undefined => {
@@ -387,19 +412,20 @@ export class Tasks {
387
412
 
388
413
  /** A task as a cloud peer may see it. */
389
414
  publicView(task: Task, fullHistory = false): Record<string, unknown> {
390
- const { title, detail, history, external, ...rest } = task;
415
+ const { title, detail, history, external, cancelled, ...rest } = task;
391
416
  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
417
  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
418
  const publicHistory = (fullHistory ? history : history.slice(-5)).map(h => ({
394
419
  at: h.at, by: h.by, event: h.event,
395
420
  ...(h.note === undefined ? {} : { note: screenHistoryText(h.note, "note", h.withheld) }),
421
+ ...(h.supersededBy === undefined ? {} : { supersededBy: screenHistoryText(h.supersededBy, "supersession", h.withheld) }),
396
422
  ...(h.profile === undefined ? {} : { profile: screenHistoryText(h.profile, "profile") }),
397
423
  ...(h.owner === undefined ? {} : { owner: h.owner }),
398
424
  ...(h.planning ? { planning: { revision: h.planning.revision, fields: h.planning.fields, ...(h.planning.split ? { children: h.planning.split.children.map(child => child.id) } : {}) } } : {}),
399
425
  ...(h.reason && TASK_MOVE_REASONS.includes(h.reason) ? { reason: h.reason } : {}),
400
426
  ...(h.sweep ? { sweep: { kind: h.sweep.kind, activity: h.sweep.activity, step: h.sweep.step, at: h.sweep.at } } : {}),
401
427
  }));
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 };
428
+ 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
429
  }
404
430
 
405
431
  /** Console results expose direct approval and delivery holds, not just the chosen peer ids. */
@@ -470,7 +496,16 @@ export class Tasks {
470
496
  private health() {
471
497
  const failing = this.d.failing?.();
472
498
  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 } : {}) };
499
+ 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 } : {}) };
500
+ }
501
+
502
+ /** Capability admission precedes explicit assignments/reservations, independent of readiness or transient peer health. */
503
+ private requireCapabilities(task: Parameters<typeof assign>[0], peer: PeerId, role: TaskAssignmentRole = "owner", preEffect = true): void {
504
+ if (!this.d.capabilities || this.isPii(task)) return;
505
+ const { state: _state, ...draft } = task; // backlog/readiness must not bypass an explicit reservation's admission
506
+ const admission = assign(draft, this.states(), this.d.routing(), { candidates: role === "owner" ? [peer] : [], ...(role === "owner" ? { namedOwner: peer } : { namedReviewer: peer }), ...this.weights(task.class) });
507
+ 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}`);
508
+ if (missing.length) throw new (preEffect ? PreEffectToolRefusal : Error)(`cannot assign ${peer}: ${missing.join("; ")}`);
474
509
  }
475
510
 
476
511
  /** What assignment weighs besides states and policy: quota, demotion and the review record. */
@@ -606,7 +641,9 @@ export class Tasks {
606
641
  }
607
642
 
608
643
  async pullPlanned(by: PeerId, input: { id: number; revision: number; reason: string }): Promise<Task> {
609
- this.planningAuthority(by);
644
+ // #455: one authority check, made before the screen and again after it (a person may move the planner meanwhile).
645
+ const authorized = () => this.planningAuthority(by);
646
+ authorized();
610
647
  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
648
  const validate = () => {
612
649
  const task = this.d.board.get(input.id);
@@ -616,7 +653,7 @@ export class Tasks {
616
653
  if (this.waitsFor(task).length) throw new PreEffectToolRefusal("planner pull waits for dependency approval");
617
654
  const priority = task.priority ?? (task.signals.includes("urgent") ? 0 : 2);
618
655
  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")));
656
+ 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
657
  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
658
  const order = effectiveOrder(task) ?? { rank: task.rank ?? Number.MAX_SAFE_INTEGER, id: task.id };
622
659
  if (this.d.board.list("backlog").some(other => {
@@ -631,6 +668,7 @@ export class Tasks {
631
668
  return { task, owner: assignment.owner };
632
669
  };
633
670
  const initial = validate(), withheld = await this.withholds("note", input.reason, initial.task);
671
+ authorized();
634
672
  const current = validate();
635
673
  const pulled = this.d.board.pullPlanned(current.task.id, by, current.owner, input.revision, input.reason.trim(), withheld);
636
674
  this.planningRecorded({ by, task: pulled.id, fields: ["pull", "reserved"], revision: pulled.planningRevision! });
@@ -742,7 +780,7 @@ export class Tasks {
742
780
  const validate = () => {
743
781
  const task = this.d.board.get(input.id);
744
782
  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");
783
+ if (isClosedTaskState(task.state)) throw new PreEffectToolRefusal("closed work is final");
746
784
  if (by !== USER && (task.state !== "backlog" || taskHasStarted(task) || task.pinned || Object.hasOwn(patch, "pinned"))) throw new PreEffectToolRefusal("planner changes only unpinned backlog work");
747
785
  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
786
  if (by !== USER && Object.hasOwn(patch, "reserved") && this.hasPersonReservation(task)) throw new PreEffectToolRefusal("a person's reservation is retained");
@@ -750,6 +788,7 @@ export class Tasks {
750
788
  if (task.state !== "backlog" || taskHasStarted(task) || task.owner) throw new PreEffectToolRefusal("dependency and reservation changes require unstarted backlog work");
751
789
  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
790
  if (patch.reserved && !this.d.bus.isRostered(patch.reserved)) throw new PreEffectToolRefusal("reserved owner is off roster");
791
+ if (patch.reserved) this.requireCapabilities(task, patch.reserved);
753
792
  const rows = new Map(this.d.board.list().map(row => [row.id, row]));
754
793
  const pending = [...(patch.deps ?? task.deps ?? [])], seen = new Set<number>();
755
794
  while (pending.length) {
@@ -773,6 +812,7 @@ export class Tasks {
773
812
  return task;
774
813
  };
775
814
  const task = validate(), withheld = await this.withholds("note", input.reason, task);
815
+ this.planningAuthority(by); // #455: or moved the planner
776
816
  validate(); // a person may have changed planning or frozen a release while the screen ran
777
817
  const updated = this.d.board.plan(task.id, by, patch, input.revision, input.reason.trim(), withheld);
778
818
  this.planningRecorded({ by, task: task.id, fields: Object.keys(patch), revision: updated.planningRevision! });
@@ -886,6 +926,7 @@ export class Tasks {
886
926
  let screened: Awaited<typeof attempt>;
887
927
  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
928
  finally { if (deadline !== undefined) clearTimeout(deadline); }
929
+ this.planningAuthority(by); // #455: a person may have moved the planner while the screen ran
889
930
  const [screenedVerdicts, withheld] = screened, current = validate();
890
931
  const finalSignals = current.drafts.map(part => this.signalsFor({ ...part, detail: [part.detail, planText(part.plan)].join("\n") }));
891
932
  const deterministicPrivate = finalSignals.map((signals, index) => initialPrivate[index] || this.isPii({ signals }));
@@ -906,6 +947,7 @@ export class Tasks {
906
947
  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
948
  const text = input.name + "\n" + input.goal;
908
949
  if (!this.nameable(text) || this.screenOn() && (await this.screenCall(text)).label !== "clear") throw new PreEffectToolRefusal("release text was not cleared for public planning");
950
+ this.planningAuthority(by); // #455: a person may have moved the planner while the screen ran
909
951
  if (this.d.board.releases().some(release => release.name === input.name)) throw new PreEffectToolRefusal("release name already exists");
910
952
  const release = this.d.board.newRelease(input.name, input.goal.trim(), input.order);
911
953
  this.planningRecorded({ by, release: release.id, fields: ["created"], revision: release.revision });
@@ -918,7 +960,7 @@ export class Tasks {
918
960
  if (!Number.isSafeInteger(input.id) || input.id <= 0 || !Number.isSafeInteger(input.revision) || !release || release.revision !== input.revision) throw new PreEffectToolRefusal("release revision changed");
919
961
  if (["shipped", "dropped"].includes(release.state)) throw new PreEffectToolRefusal("settled release is final");
920
962
  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");
963
+ 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
964
  const { id, revision, ...patch } = input;
923
965
  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
966
  const updated = this.d.board.updateRelease(id, patch, revision);
@@ -985,6 +1027,7 @@ export class Tasks {
985
1027
  catch (error) { throw new PreEffectToolRefusal((error as Error).message); }
986
1028
  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
1029
  if (external) { const prior = this.d.board.imported(external.repo, external.issue); if (prior) return prior; }
1030
+ if (reserved) this.requireCapabilities({ ...draft, signals, ...(external ? { external } : {}) }, reserved);
988
1031
  let task = this.d.board.propose(by, { ...draft, plan, ...(external ? { external } : {}), ...(deps.length ? { deps } : {}), ...(reserved ? { reserved } : {}), signals, ...(backlog ? { backlog: true } : {}) });
989
1032
  created?.();
990
1033
  if (screening) task = this.screenedTask(task, screened);
@@ -1180,7 +1223,7 @@ export class Tasks {
1180
1223
  * #106): open work by default; the conflict notices of #91 count a task in review too.
1181
1224
  */
1182
1225
  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) } });
1226
+ const env = newEnvelope(HUB, body, { to: [to], kind: "task", refs: { task: String(task), taskRecipient: "owner" } });
1184
1227
  this.conditional.set(env.id, { peer: to, task, states });
1185
1228
  if (this.conditional.size > 1024) this.conditional.delete(this.conditional.keys().next().value as string);
1186
1229
  this.d.bus.publish(env);
@@ -1201,6 +1244,10 @@ export class Tasks {
1201
1244
  }
1202
1245
  return true;
1203
1246
  }
1247
+ if (env.from === HUB && env.refs?.task) {
1248
+ const task = this.d.board.get(Number(env.refs.task));
1249
+ if (task?.state === "cancelled") return env.kind === "task" && typeof env.refs.taskCancelled === "number" && env.refs.taskCancelled === task.cancelled?.flowRevision;
1250
+ }
1204
1251
  const c = this.conditional.get(env.id);
1205
1252
  if (!c || c.peer !== peer) return true;
1206
1253
  const t = this.d.board.get(c.task);
@@ -1326,6 +1373,7 @@ export class Tasks {
1326
1373
  if (typeof target === "number") {
1327
1374
  const task = this.d.board.get(target);
1328
1375
  if (!task) throw new Error(`no task #${target}`);
1376
+ if (isClosedTaskState(task.state)) return [...workflow, `task ${this.publicTitle(task)} (${task.state}): closed; not routed`];
1329
1377
  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
1378
  // The split trace is for the pair the record is about: the task's owner when it has one (issue #109).
1331
1379
  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 +1381,17 @@ export class Tasks {
1333
1381
  const draft = { title: target.title, detail: target.detail ?? "", refs: target.refs ?? {} };
1334
1382
  const signals = this.signalsFor(draft, routing);
1335
1383
  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];
1384
+ 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
1385
  // 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
1386
  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
1387
  }
1340
1388
 
1341
1389
  /** Public read path: every generated line is screened, including profile/config-derived trace fields. */
1342
1390
  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]");
1391
+ const trace = this.explain(target, routing);
1392
+ const holds = Object.entries(this.d.held?.() ?? {}).flatMap(([peer, hold]) =>
1393
+ trace.some(line => line.startsWith(` hold: ${peer}'s queue is held:`)) ? [] : [`${peer} queue: ${hold}`]);
1394
+ return [...trace, ...holds].map(line => this.nameable(line) ? line : "[routing explanation withheld: it matches a PII pattern]");
1344
1395
  }
1345
1396
 
1346
1397
  /**
@@ -1412,6 +1463,7 @@ export class Tasks {
1412
1463
  }
1413
1464
  const passed = this.passedOver(task, a);
1414
1465
  if (!a.owner) {
1466
+ if (!task.owner && task.state === "proposed") this.reportStall(task, this.ownerStallCause(task, a));
1415
1467
  this.releaseNotice(opts.roster
1416
1468
  ? `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
1469
  : `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 +1535,7 @@ export class Tasks {
1483
1535
  if (by !== USER) throw new PreEffectToolRefusal("only the person may change an existing task catalog role");
1484
1536
  if (raw === undefined) throw new PreEffectToolRefusal("name a catalog role id or explicit null to clear it");
1485
1537
  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");
1538
+ if (isClosedTaskState(task.state)) throw new PreEffectToolRefusal("a closed task's role is historical; propose new work instead");
1487
1539
  let selected;
1488
1540
  try { selected = taskRole(raw === null ? undefined : raw, task.class, this.d.catalog ?? loadRoleCatalog(this.d.cwd)); }
1489
1541
  catch (error) { throw new PreEffectToolRefusal((error as Error).message); }
@@ -1493,7 +1545,7 @@ export class Tasks {
1493
1545
  if (!peer || peer === USER) continue;
1494
1546
  this.releaseNotice(`task ${this.publicTitle(next)}: catalog role update delivery unconfirmed`, () => this.d.bus.publish(newEnvelope(HUB,
1495
1547
  `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 } : {}) })));
1548
+ { to: [peer], kind: "task", refs: { task: String(next.id), taskRecipient: peer === next.reviewer ? "reviewer" : "owner" }, ...(this.isPii(next) ? { private: true } : {}) })));
1497
1549
  }
1498
1550
  return next;
1499
1551
  }
@@ -1548,7 +1600,8 @@ export class Tasks {
1548
1600
  // (Codex, Pi); held, it starts no turn yet: neither is taking it.
1549
1601
  const owner = task.owner!;
1550
1602
  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 } : {}) });
1603
+ 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 } : {}) });
1604
+ 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
1605
  this.d.bus.publish(env);
1553
1606
  if (idle && this.d.bus.stateOf(owner) === "busy" && !this.d.bus.queueIds(owner).includes(env.id)) this.sent.set(handOver(task, owner), owner);
1554
1607
  }
@@ -1606,14 +1659,98 @@ export class Tasks {
1606
1659
  * each such task is offered once an attached peer can take it (peers attach one by one). Only an assignment uses
1607
1660
  * up its offer for this hub run; a failed or ownerless offer stays eligible, after approval and on the timer alike.
1608
1661
  */
1662
+ private offerCandidates?: { revision: number; offers: { task: number; peer: PeerId; envelopeId: string; assignmentFlowRevision: number; pullOnly?: boolean }[] };
1663
+ private stallCandidates?: { revision: number; rows: { stall: TaskStall; task: Task; planned: boolean }[] };
1664
+ private stallView?: { key: string; stalls: TaskStall[] };
1665
+
1666
+ pendingOffers(): { task: number; peer: PeerId; envelopeId: string; assignmentFlowRevision: number; pullOnly?: boolean }[] {
1667
+ const revision = this.d.board.statusRevision();
1668
+ if (this.offerCandidates?.revision !== revision) this.offerCandidates = { revision, offers: this.d.board.list("proposed").flatMap(task => {
1669
+ const offer = task.offer;
1670
+ 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 [];
1671
+ return [{ task: task.id, peer: offer.owner, envelopeId: offer.envelopeId, assignmentFlowRevision: offer.assignmentFlowRevision, ...(offer.pullOnly === undefined ? {} : { pullOnly: offer.pullOnly }) }];
1672
+ }) };
1673
+ return this.offerCandidates.offers.filter(offer => {
1674
+ try { return this.d.offerRead?.(offer.peer, offer.envelopeId) !== true; } catch { return true; }
1675
+ }).map(offer => ({ ...offer }));
1676
+ }
1677
+ stalled(): TaskStall[] {
1678
+ const revision = this.d.board.statusRevision();
1679
+ if (this.stallCandidates?.revision !== revision) this.stallCandidates = { revision, rows: this.d.board.stalls().flatMap(stall => {
1680
+ const task = this.d.board.get(stall.task);
1681
+ if (task?.state !== "proposed" || task.owner || (task.planningRevision ?? 0) !== stall.replanRevision) return [];
1682
+ 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 }] : [];
1683
+ return this.waitsFor(task).length ? [] : [{ stall, task, planned: this.plannerPullRecord(task)?.pull === true }];
1684
+ }) };
1685
+ // Eligibility inputs are live; queue journal writes alone do not re-decode board histories.
1686
+ const key = JSON.stringify([revision, this.states(), this.d.routing(), this.health(), [...this.d.bus.peers.keys()].map(peer => {
1687
+ const queue = this.d.bus.queueSummary(peer);
1688
+ return [peer, this.wipLimit(peer), queue.heldBy, queue.needsReview];
1689
+ })]);
1690
+ 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) };
1691
+ return structuredClone(this.stallView.stalls);
1692
+ }
1693
+ private ownerStallCause(task: Task, admission: Assignment): TaskStall["cause"] {
1694
+ const refusal = admission.refusals?.find(row => row.role === "owner" && row.peer === task.reserved);
1695
+ 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)) ?? [] };
1696
+ }
1697
+ private reportStall(task: Task, cause: TaskStall["cause"], now = Date.now()): void {
1698
+ const stall: TaskStall = { task: task.id, cause, signature: JSON.stringify(cause), replanRevision: task.planningRevision ?? 0, at: now };
1699
+ if (!this.d.board.recordStall(stall)) return;
1700
+ 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.`;
1701
+ this.releaseNotice(text);
1702
+ try { this.d.stallNotice?.(stall); } catch { /* advisory never changes task authority */ }
1703
+ // Advice joins the planner's next delivery; it must not manufacture a turn beside a withheld backlog batch.
1704
+ 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)));
1705
+ }
1706
+ async cancel(id: unknown, by: PeerId, input: { reason: string; supersededBy?: string; expectedFlowRevision?: number }): Promise<Task> {
1707
+ 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");
1708
+ const validate = () => {
1709
+ if (this.d.cancellationHeld?.() || this.d.bus.isRecoveryHeld) throw new PreEffectToolRefusal("recovery or shutdown is holding cancellation");
1710
+ const task = this.need(id, false, true, true);
1711
+ this.checkFlowRevision(task, input.expectedFlowRevision, true);
1712
+ if (isClosedTaskState(task.state)) throw new PreEffectToolRefusal(`task #${task.id} is already ${task.state}`);
1713
+ if (by !== USER && this.d.privateTurn?.(by) && !this.isPii(task)) throw new PreEffectToolRefusal("cancellation on ordinary work is unavailable during a private turn");
1714
+ if (by !== USER) {
1715
+ this.planningAuthority(by);
1716
+ const proposer = task.history.find(h => h.event === "proposed" || h.event === "backlogged")?.by;
1717
+ 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");
1718
+ }
1719
+ return task;
1720
+ };
1721
+ const before = validate(), revision = before.history.length;
1722
+ const reasonText = [input.reason, input.supersededBy].filter(Boolean).join("\n");
1723
+ const withheld = looksSecret(reasonText) || this.aboutPrivatePath("", reasonText, before) || await this.withholds("note", reasonText, before);
1724
+ const task = validate();
1725
+ if (task.history.length !== revision) throw new PreEffectToolRefusal("task flow revision changed");
1726
+ const cancelled = { reason: input.reason.trim(), ...(input.supersededBy ? { supersededBy: input.supersededBy.trim() } : {}), flowRevision: revision + 1, ...(withheld ? { withheld: true as const } : {}) };
1727
+ const next = this.d.board.update(task.id, by, "cancelled", { state: "cancelled", reserved: null, cancelled }, cancelled.reason, { ...(withheld ? { withheld: true as const } : {}) }, revision);
1728
+ this.releaseNotice(`task #${task.id}: cancellation cohort cleanup unavailable`, () => this.cohorts.leave(task.id));
1729
+ this.offered.delete(task.id);
1730
+ this.releaseNotice(`task #${task.id}: cancellation stall cleanup unavailable`, () => this.d.board.clearStall(task.id));
1731
+ 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 } : {}) })));
1732
+ this.releaseNotice(`task #${task.id}: cancellation dependent advice unavailable`, () => {
1733
+ 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" }));
1734
+ });
1735
+ return next;
1736
+ }
1737
+
1609
1738
  async releaseReady(): Promise<void> {
1610
1739
  for (const t of this.d.board.list("proposed")) {
1740
+ if (t.owner) { this.d.board.clearStall(t.id); continue; }
1741
+ const cancelledDependency = t.deps?.find(id => this.d.board.get(id)?.state === "cancelled");
1742
+ if (cancelledDependency) { this.reportStall(t, { kind: "cancelled-dependency", dependency: cancelledDependency, code: "cancelled" }); continue; }
1611
1743
  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;
1744
+ if (this.personUnassigned(t) || this.offered.has(t.id) || this.waitsFor(t).length) continue;
1745
+ const admission = planned ? this.plannerAssignment(t, true) : assign(t, this.states(), this.d.routing(), { exclude: this.excluded(t), ...this.health() });
1746
+ if (!admission.owner) {
1747
+ this.reportStall(t, this.ownerStallCause(t, admission));
1748
+ continue;
1749
+ }
1750
+ this.d.board.clearStall(t.id);
1751
+ if ((!t.deps?.length && !t.history.some(h => h.event === "backlog pulled")) || (!planned && last !== "blocked" && last !== "ready" && last !== "reserved")) continue;
1614
1752
  // ponytail: a reserved owner (#207) that attaches after another peer that can take the task is passed over here like
1615
1753
  // 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
1754
  await this.offerReady(t, "nothing left to wait for");
1618
1755
  }
1619
1756
  }
@@ -1728,13 +1865,13 @@ export class Tasks {
1728
1865
  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
1866
  }
1730
1867
 
1731
- private need(id: unknown, open = false, preEffect = false): Task {
1868
+ private need(id: unknown, open = false, preEffect = false, allowBacklog = false): Task {
1732
1869
  // Callers are models (#70): an id is a whole number or a digit string, never an array that Number() would accept.
1733
1870
  const n = typeof id === "number" ? id : typeof id === "string" && /^\s*\d+\s*$/.test(id) ? Number(id) : NaN;
1734
1871
  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
1872
  const task = this.d.board.get(n);
1736
1873
  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`);
1874
+ if (task.state === "backlog" && !allowBacklog) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${n} is backlog: pull it into To Do first`);
1738
1875
  // Handing a task to someone else only makes sense while there is work left on it.
1739
1876
  if (open && !OPEN.includes(task.state)) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id} is ${task.state}: it can no longer change hands`);
1740
1877
  return task;
@@ -1837,7 +1974,7 @@ export class Tasks {
1837
1974
  const valid = (): Task => {
1838
1975
  const t = this.need(id, false, true);
1839
1976
  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}`);
1977
+ if (t.state === "in_review" || isClosedTaskState(t.state)) throw new PreEffectToolRefusal(`task #${t.id} is already ${t.state}`);
1841
1978
  this.ready(t, true);
1842
1979
  if (this.checking.has(t.id)) {
1843
1980
  // The result goes only to the owner the check was started for: anyone who took the task since hears nothing.
@@ -2013,7 +2150,7 @@ export class Tasks {
2013
2150
  task = this.need(task.id, false, true);
2014
2151
  this.mine(task, by, "owner", true);
2015
2152
  // 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}`);
2153
+ if (task.state === "in_review" || isClosedTaskState(task.state)) throw new Error(`task #${task.id} is already ${task.state}`);
2017
2154
  reviewer = task.reviewer;
2018
2155
  }
2019
2156
  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 +2356,7 @@ export class Tasks {
2219
2356
  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
2357
  const stopFormerOwner = (moved: Task) => {
2221
2358
  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)));
2359
+ this.tell({ ...moved, owner: from }, `Task #${moved.id} moved to ${moved.owner} (${why}). Stop working on it.`, this.isPii(moved), "withdrawal"));
2223
2360
  };
2224
2361
  // Only our landed move authorizes a success notice and decision note. The former owner's stop
2225
2362
  // message still names the current owner when a later assignment superseded this move.
@@ -2264,7 +2401,8 @@ export class Tasks {
2264
2401
  ? `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
2402
  : `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
2403
  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 } : {}) }));
2404
+ // An untagged task notice remains needed by a former role until approval; kind review would look moved already.
2405
+ 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
2406
  });
2269
2407
  }
2270
2408
 
@@ -2278,6 +2416,7 @@ export class Tasks {
2278
2416
  const task = this.need(id, role === "owner", preEffect);
2279
2417
  this.checkFlowRevision(task, expectedFlowRevision, preEffect);
2280
2418
  if (!this.d.bus.isRostered(peer)) throw new (preEffect ? PreEffectToolRefusal : Error)(`${peer} is off roster; add it to the roster before assigning work`);
2419
+ this.requireCapabilities(task, peer, role, preEffect);
2281
2420
  if (role === "reviewer") {
2282
2421
  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
2422
  this.ready(task, preEffect);
@@ -2303,7 +2442,7 @@ export class Tasks {
2303
2442
  if (role !== "owner" && role !== "reviewer") throw new (preEffect ? PreEffectToolRefusal : Error)("role must be owner or reviewer");
2304
2443
  const task = this.need(id, false, preEffect);
2305
2444
  this.checkFlowRevision(task, expectedFlowRevision, preEffect);
2306
- if (task.state === "approved") throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id} is approved: its ${role} is historical`);
2445
+ if (isClosedTaskState(task.state)) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id} is ${task.state}: its ${role} is historical`);
2307
2446
  if (role === "owner") {
2308
2447
  if (!OPEN.includes(task.state)) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id} is ${task.state}: its owner cannot be unassigned`);
2309
2448
  if (!task.owner && !task.reserved) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id}: no owner or owner reservation to unassign`);
@@ -2331,7 +2470,7 @@ export class Tasks {
2331
2470
  /** Read-only preview using the same assignment constraints as the roster relay. */
2332
2471
  rosterPreview(peer: PeerId): { id: number; title: string; role: "owner" | "reviewer"; to: PeerId | null }[] {
2333
2472
  const routing = this.d.routing();
2334
- return this.d.board.list().filter(task => task.state !== "approved" && (task.owner === peer || task.reviewer === peer)).map(task => {
2473
+ return this.d.board.list().filter(task => !isClosedTaskState(task.state) && (task.owner === peer || task.reviewer === peer)).map(task => {
2335
2474
  const owner = task.owner === peer;
2336
2475
  const weights = this.weights(task.class);
2337
2476
  const pii = this.isPii(task);
@@ -2377,7 +2516,7 @@ export class Tasks {
2377
2516
  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
2517
  }
2379
2518
  moved.push({ id: task.id, title: this.publicTitle(task), to: next.owner, role: "owner" });
2380
- } else if (task.reviewer === peer && task.state !== "approved") {
2519
+ } else if (task.reviewer === peer && !isClosedTaskState(task.state)) {
2381
2520
  const a = assign(task, this.states(), routing, { exclude: [peer], candidates: [], ...(task.owner ? { notReviewer: task.owner } : {}), ...this.weights(task.class) });
2382
2521
  const reviewer = a.reviewer && a.reviewer !== peer && a.reviewer !== task.owner ? a.reviewer : null;
2383
2522
  if (roster && !reviewer) {
@@ -2570,9 +2709,9 @@ export class Tasks {
2570
2709
  }
2571
2710
  }
2572
2711
 
2573
- private tell(task: Task, body: string, pii: boolean): void {
2712
+ private tell(task: Task, body: string, pii: boolean, audience: "owner" | "withdrawal" = "owner"): void {
2574
2713
  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 } : {}) }));
2714
+ 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
2715
  }
2577
2716
  }
2578
2717
 
@@ -1,3 +1,4 @@
1
+ import { BOUNDED_TASK_TOOLS } from "../hub/hub-tools.ts";
1
2
  import { MEMORY_READ_TOOLS } from "../hub/read-tools.ts";
2
3
  import { readFileSync } from "node:fs";
3
4
  import { homedir } from "node:os";
@@ -48,7 +49,7 @@ export class Capture {
48
49
  }
49
50
 
50
51
  observe(call: { tool: string; args: string; output: string; id: string; paths: string[] }): void {
51
- if (!this.sessionId || (MEMORY_READ_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, "")) || call.tool.replace(/^mcp__[^ ]+__/, "") === "hub_memory_correct") || this.opts.skip?.includes(call.tool)) return;
52
+ if (!this.sessionId || ((MEMORY_READ_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, "")) || BOUNDED_TASK_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, ""))) || call.tool.replace(/^mcp__[^ ]+__/, "") === "hub_memory_correct") || this.opts.skip?.includes(call.tool)) return;
52
53
  // Anything that names a denylisted path is kept out of memory entirely, input and output alike.
53
54
  if (call.paths.some((p) => p.split(/\s+/).some((word) => isDenied(word, this.opts.deny)))) return;
54
55
  void this.client.request("POST", "/api/sessions/observations", {