@staix/agent-hub 0.12.35 → 0.12.37

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 (71) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +15 -8
  3. package/docs/agent-notes/adapters.md +9 -2
  4. package/docs/agent-notes/budget.md +3 -2
  5. package/docs/agent-notes/bus.md +3 -0
  6. package/docs/agent-notes/daemon.md +11 -3
  7. package/docs/agent-notes/tasks.md +14 -1
  8. package/docs/agent-notes/tests.md +6 -2
  9. package/docs/memory.md +3 -1
  10. package/docs/operations.md +74 -18
  11. package/docs/quickstart.md +7 -9
  12. package/docs/sdlc.md +72 -5
  13. package/docs/security.md +11 -3
  14. package/docs/smoke.md +18 -2
  15. package/docs/specs/2026-09-19-agent-hub-design.md +108 -3
  16. package/docs/verification/2026-10-19-0.12.36.md +63 -0
  17. package/docs/verification/2026-10-19-0.12.37.md +60 -0
  18. package/docs/verification/2026-10-19-jcode-0.98.1.md +30 -0
  19. package/docs/verification/2026-10-19-reviewer-isolation.md +119 -0
  20. package/docs/verified.json +23 -15
  21. package/package.json +2 -2
  22. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  23. package/plugins/agent-hub/server.js +2843 -1632
  24. package/src/adapters/acp.ts +3 -2
  25. package/src/adapters/claude-print.ts +92 -0
  26. package/src/adapters/codex-review-isolation.ts +276 -0
  27. package/src/adapters/definitions/builtin-metadata.ts +20 -1
  28. package/src/adapters/definitions/native-cli-entry-claude.ts +17 -3
  29. package/src/adapters/definitions/native-cli-entry-jcode.ts +3 -2
  30. package/src/adapters/definitions/native-cli-entry-kimi.ts +3 -2
  31. package/src/adapters/definitions/native-cli-entry-types.ts +2 -0
  32. package/src/adapters/definitions/native-factories.ts +24 -0
  33. package/src/adapters/definitions/native-policy-aux.ts +1 -1
  34. package/src/adapters/definitions/reviewer-variants.ts +32 -0
  35. package/src/adapters/jcode.ts +20 -5
  36. package/src/adapters/registry-types.ts +13 -3
  37. package/src/adapters/registry.ts +29 -5
  38. package/src/adapters/reviewer-print.ts +319 -0
  39. package/src/cli/help.ts +7 -0
  40. package/src/cli/identity.ts +2 -1
  41. package/src/cli/launch.ts +19 -6
  42. package/src/cli/main.ts +57 -4
  43. package/src/cli/memory.ts +13 -12
  44. package/src/cli/reset.ts +4 -2
  45. package/src/cli/statusline-tee.ts +5 -2
  46. package/src/cli/terminal-recovery.ts +7 -2
  47. package/src/cli/upgrade-runtime.ts +18 -15
  48. package/src/hub/board.ts +61 -11
  49. package/src/hub/bus.ts +11 -5
  50. package/src/hub/claude-state.ts +37 -0
  51. package/src/hub/config-apply.ts +3 -2
  52. package/src/hub/control-client.ts +1 -1
  53. package/src/hub/cost-governance.ts +76 -0
  54. package/src/hub/daemon.ts +269 -87
  55. package/src/hub/envelope.ts +1 -1
  56. package/src/hub/host-executable.ts +1 -1
  57. package/src/hub/hub-tools.ts +25 -1
  58. package/src/hub/instances.ts +29 -5
  59. package/src/hub/memory-profile.ts +67 -11
  60. package/src/hub/planning.ts +2 -0
  61. package/src/hub/release-admission.ts +64 -0
  62. package/src/hub/review-brief.ts +175 -0
  63. package/src/hub/reviewer-pool-config.ts +11 -0
  64. package/src/hub/reviewer-pool.ts +70 -0
  65. package/src/hub/routing.ts +58 -4
  66. package/src/hub/sdlc-release.ts +113 -0
  67. package/src/hub/sdlc.ts +205 -4
  68. package/src/hub/status-roster.ts +10 -0
  69. package/src/hub/task-sweep.ts +143 -0
  70. package/src/hub/tasks.ts +788 -52
  71. package/src/hub/usage.ts +57 -20
package/src/hub/tasks.ts CHANGED
@@ -1,3 +1,6 @@
1
+ import { RELEASE_TRAIN_HISTORY_LIMIT, releaseTrainData, releaseTrainReadiness, releaseMetrics } from "./sdlc-release.ts";
2
+ import { cleanCostPlan, costMetrics, type CostPlan, type ReviewCost } from "./cost-governance.ts";
3
+ import { specRefs } from "./spec-read.ts";
1
4
  import type { InstancesConfig } from "./instances.ts";
2
5
  import { composeRememberText } from "./memory-fail.ts";
3
6
  import { VERSION } from "../version.ts";
@@ -16,7 +19,7 @@ import { isAbsolute, relative } from "node:path";
16
19
  import type { Briefs, NativeItem } from "../memory/brief.ts";
17
20
  import type { MemoryClient } from "../memory/client.ts";
18
21
  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";
19
- 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";
22
+ import { CLASSES, OUTCOMES_KEPT_MS, ALL_PLAN_KEYS, TASK_MOVE_REASONS, taskCanPlan, taskHasWorkEvidence, taskHasStarted, planningUndoEntry, isClosedTaskState, type TaskStall, type HistoryEntry, type ReviewerHistory, type Board, type Task, type TaskClass, type TaskMoveReason, type TaskPlan, type TaskRefs } from "./board.ts";
20
23
  import type { Bus } from "./bus.ts";
21
24
  import { HUB, newEnvelope, NOTE_KINDS, noteLine, sanitize, USER, type Envelope, type PeerId, type PeerState } from "./envelope.ts";
22
25
  import type { PiiVerdict, ScreenItem, ScreenRecord } from "./inference.ts";
@@ -25,13 +28,18 @@ import { ExecutionBudget, type ExecutionBudgetConfig, type ExecutionBudgetDecisi
25
28
  import { Cohorts, MAX_REQUESTS, type Cohort, type Completion } from "./cohorts.ts";
26
29
  import { realPath } from "./project.ts";
27
30
  import { PreEffectToolRefusal } from "./tool-refusal.ts";
28
- import { ADAPTERS, definitionOf } from "../adapters/registry.ts";
29
- import { nextSweep, taskSweepConfig, type TaskSweepConfig, type SweepRecord } from "./task-sweep.ts";
30
- import { checklistOf, readSdlcPr, sdlcConfig, sdlcReceipt, sdlcStatus, stageDefinition, lifecycleStage, type SdlcConfig, sdlcGh, type SdlcGh, type SdlcStatus } from "./sdlc.ts";
31
+ import { coverageGap, renderReviewBrief, type ReviewBrief } from "./review-brief.ts";
32
+ import { ADAPTERS, DEFAULT_REVIEW_POOL, definitionOf, familyOf } from "../adapters/registry.ts";
33
+ import { plannerGrooming, nextSweep, taskSweepConfig, type TaskSweepConfig, type SweepRecord } from "./task-sweep.ts";
34
+ import { readPrCostHistory, readReviewReadiness, reviewReadinessFromEvidence, type PrEvidence, type ReviewReadiness, readPlanIssueMetadata, canonicalStage, checklistOf, readSdlcPr, readSpecEdit, sdlcConfig, sdlcReceipt, sdlcStatus, specState, SPEC_STATE_TEXT, stageDefinition, lifecycleStage, type SdlcConfig, type PlanIssueMetadata, sdlcGh, type SdlcGh, type SdlcStatus, type SpecEdit, type SpecState } from "./sdlc.ts";
31
35
 
32
36
  export type TaskAssignmentRole = "owner" | "reviewer";
33
37
 
38
+ export interface ReleaseWindow { at: number; host: boolean; quota: boolean; idle: boolean; reasons: string[]; current?: () => boolean; close?: () => Promise<void>; }
39
+
34
40
  export interface TasksDeps {
41
+ /** Host-bound evidence only; absent/expired observations never admit a release cut. */
42
+ releaseWindow?: () => Promise<ReleaseWindow>;
35
43
  /** Effective per-peer planner capacity, taken from the existing settings loader. */
36
44
  wipLimit?: (peer: PeerId) => number;
37
45
  recordPlanning?: (change: { by: string; task?: number; release?: number; fields: string[]; revision: number }) => void;
@@ -52,6 +60,8 @@ export interface TasksDeps {
52
60
  sessionRoles?: (peer: PeerId) => string[];
53
61
  /** Optional bounded snapshot presentation; never stored in task history or memory. */
54
62
  projectedReview?: (task: Task) => string | undefined;
63
+ /** #520: the review brief for a task's commit (git only, bounded); undefined when the task names no commit. */
64
+ reviewBrief?: (task: Task) => ReviewBrief | { unavailable: string } | undefined;
55
65
  sdlc?: { config: () => SdlcConfig; gh?: SdlcGh };
56
66
  briefs?: Briefs;
57
67
  /** Screened live fail notes directly intersecting the task plan or refs; read-only advice. */
@@ -60,6 +70,12 @@ export interface TasksDeps {
60
70
  candidateScreen?: MemoryScreen;
61
71
  /** Authenticated live turn context, not a model-supplied flag. */
62
72
  privateTurn?: (peer: PeerId) => boolean;
73
+ /** #223 R4: the hub's reading of a verdict's context (`fresh` only for a reviewer session started for this review). */
74
+ reviewContext?: (by: PeerId, task: number) => "fresh" | "continuing" | undefined;
75
+ /** #223 R2: a reviewer-only instance gets review facts, not the builder's done summary. */
76
+ reviewOnly?: (peer: PeerId) => boolean;
77
+ /** #223: captured when a verdict call starts; a non-undefined result refuses the verdict at every later check. */
78
+ reviewAuthority?: (by: PeerId, task: number) => (() => string | undefined) | undefined;
63
79
  memory?: MemoryClient;
64
80
  /** Captured native profile fence at our memory operation boundaries; no board mutation. */
65
81
  memoryCheck?: () => void;
@@ -82,7 +98,7 @@ export interface TasksDeps {
82
98
  /** Optional persisted task/run execution limits (issue #102). */
83
99
  executionBudget?: ExecutionBudget;
84
100
  /** Reviewer choice from recorded review outcomes (issue #35); off unless the project config turns it on. */
85
- review?: { adaptive: boolean; min_reviews: number };
101
+ review?: { adaptive: boolean; min_reviews: number; pool?: readonly string[]; rerotate?: boolean };
86
102
  /** Peers whose recent deliveries all failed, with the reason (issue #89); routing skips them. */
87
103
  capabilities?: () => Record<PeerId, PeerCapabilities>;
88
104
  repository?: string;
@@ -93,6 +109,8 @@ export interface TasksDeps {
93
109
  failing?: () => Record<PeerId, string>;
94
110
  /** Peers whose queue a needs_review delivery holds, with the hold detail (issue #90); routing explains the hold, never rejects. */
95
111
  held?: () => Record<PeerId, string>;
112
+ /** Closed host facts only; approval text and quota reasons never enter the grooming projection. */
113
+ plannerBlocks?: () => Record<PeerId, ("approvals" | "quota")[]>;
96
114
  /** Roles from `.agenthub/config.json` (issue #92): peers with the reviewer role join the reviewer candidates. */
97
115
  roles?: Record<string, string[]>;
98
116
  /** Optional: name a class for a task proposed without one. `onCampus` says whether the model call stays on campus. */
@@ -213,7 +231,9 @@ function cleanRefs(input: unknown): TaskRefs {
213
231
  /** The same for a plan (issue #31): only its four lists, each of short strings. */
214
232
  function cleanPlan(input: unknown, profile: ProfileSelection = "dev"): TaskPlan {
215
233
  const r = fields(input);
216
- return Object.fromEntries((profileData(profile).plan_keys as (keyof TaskPlan)[]).map((k) => [k, k === "paths" ? [...new Set(textList(r[k]).map(normPath))] : textList(r[k])] as const).filter(([, v]) => v.length));
234
+ let scope: CostPlan;
235
+ try { scope = cleanCostPlan(r); } catch { throw new PreEffectToolRefusal("invalid review or CI cost scope"); }
236
+ return { ...Object.fromEntries((profileData(profile).plan_keys as (keyof TaskPlan)[]).map((k) => [k, k === "paths" ? [...new Set(textList(r[k]).map(normPath))] : textList(r[k])] as const).filter(([, v]) => v.length)), ...scope };
217
237
  }
218
238
 
219
239
  /** A plan as one line for other owners; the whole plan is on the board. */
@@ -344,7 +364,7 @@ export class Tasks {
344
364
  if (finding.step === 2 || !planners.length) this.releaseNotice(text);
345
365
  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"));
346
366
  } else {
347
- 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) });
367
+ 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, task) });
348
368
  this.releaseNotice(`${text} Owner reassignment suggestion: ${admission.owner ?? "no eligible peer"}.`);
349
369
  if (admission.owner && config.auto_reassign && this.sweepAvailable(admission.owner)) {
350
370
  try { await this.assignOwner(this.d.board.get(task.id)!, HUB, { candidates: [admission.owner], event: "reassigned", reason: "idle", note: "ready-unassigned idle sweep" }); }
@@ -355,7 +375,7 @@ export class Tasks {
355
375
  }
356
376
  const responsible = finding.kind === "review-pending" ? task.reviewer! : task.owner!;
357
377
  if (responsible !== USER && !this.sweepAvailable(responsible)) continue;
358
- 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;
378
+ 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, task) }).owner) continue;
359
379
  const title = this.publicTitle(task);
360
380
  const message = `Task ${title}: ${finding.kind}, idle escalation ${finding.step}/3.`;
361
381
  // Only identifiers, kind and step are kept in the ladder event. A crash after this write and before publish
@@ -371,7 +391,7 @@ export class Tasks {
371
391
  }
372
392
  } else {
373
393
  const reviewer = finding.kind === "review-pending";
374
- const a = assign(task, this.states(), this.d.routing(), { exclude: [...this.excluded(task), responsible], excludeOwners: finding.kind !== "review-pending" && this.plannerPullRecord(task)?.pull ? this.plannerFull(task) : [], waitsFor: this.waitsFor(task), ...(reviewer ? { candidates: [], notReviewer: task.owner ?? undefined } : {}), ...this.weights(task.class) });
394
+ const a = assign(task, this.states(), this.d.routing(), { exclude: [...this.excluded(task), responsible], excludeOwners: finding.kind !== "review-pending" && this.plannerPullRecord(task)?.pull ? this.plannerFull(task) : [], waitsFor: this.waitsFor(task), ...(reviewer ? { candidates: [], notReviewer: task.owner ?? undefined } : {}), ...this.weights(task.class, task) });
375
395
  const candidate = reviewer ? a.reviewer : a.owner;
376
396
  this.passedOver(task, a);
377
397
  this.d.notify(`${message} ${reviewer ? "Reviewer" : "Owner"} reassignment suggestion: ${candidate ?? "no eligible peer"}.`);
@@ -443,6 +463,10 @@ export class Tasks {
443
463
  ...(h.supersededBy === undefined ? {} : { supersededBy: screenHistoryText(h.supersededBy, "supersession", h.withheld) }),
444
464
  ...(h.profile === undefined ? {} : { profile: screenHistoryText(h.profile, "profile") }),
445
465
  ...(h.owner === undefined ? {} : { owner: h.owner }),
466
+ ...(h.reviewerFamily ? { reviewerFamily: screenHistoryText(h.reviewerFamily, "reviewer family") } : {}),
467
+ ...(h.reviewerReason ? { reviewerReason: h.reviewerReason } : {}),
468
+ ...(h.reviewBlocked ? { reviewBlocked: true } : {}),
469
+ ...(h.reviewerFailure ? { reviewerFailure: { peer: screenHistoryText(h.reviewerFailure.peer, "reviewer"), cause: h.reviewerFailure.cause, ...(h.reviewerFailure.head ? { head: screenHistoryText(h.reviewerFailure.head, "review head") } : {}) } } : {}),
446
470
  ...(h.planning ? { planning: { revision: h.planning.revision, fields: h.planning.fields, ...(h.planning.split ? { children: h.planning.split.children.map(child => child.id) } : {}) } } : {}),
447
471
  ...(h.reason && TASK_MOVE_REASONS.includes(h.reason) ? { reason: h.reason } : {}),
448
472
  ...(h.sweep ? { sweep: { kind: h.sweep.kind, activity: h.sweep.activity, step: h.sweep.step, at: h.sweep.at } } : {}),
@@ -454,11 +478,13 @@ export class Tasks {
454
478
  resultLine(task: Task): string {
455
479
  const base = `task #${task.id}: ${task.state}, owner ${task.owner ?? "none"}, reviewer ${task.reviewer ?? "none"}`;
456
480
  const noReview = task.class !== "review" && !task.reviewer && OPEN.includes(task.state) && !this.waitsFor(task).length
457
- ? this.noReviewer(assign(task, this.states(), this.d.routing(), { candidates: task.owner ? [task.owner] : [], notReviewer: task.owner ?? undefined, ...this.weights(task.class) }))
481
+ ? this.noReviewer(assign(task, this.states(), this.d.routing(), { candidates: task.owner ? [task.owner] : [], notReviewer: task.owner ?? undefined, ...this.weights(task.class, task) }))
458
482
  : undefined;
459
483
  const held = task.owner ? peerEntry(this.d.held?.(), task.owner) : undefined;
460
484
  const holdText = held ? `${task.owner}'s queue is held (${held}); it receives the task once the hold is resolved` : undefined;
461
- return [base, noReview, holdText].filter(Boolean).join("; ");
485
+ const request = task.history.findLast(entry => ["done", "reviewer changed", "reviewer unavailable"].includes(entry.event));
486
+ const poolBlocked = task.state === "in_review" && task.reviewer === USER && request?.reviewBlocked ? "blocked: no reviewer can run" : undefined;
487
+ return [base, noReview, poolBlocked, holdText].filter(Boolean).join("; ");
462
488
  }
463
489
 
464
490
  private states(): Record<PeerId, PeerState> {
@@ -528,7 +554,37 @@ export class Tasks {
528
554
  const held = this.d.held?.();
529
555
  const workload = this.instanceRouting();
530
556
  const peers = workload.instances ? [...new Set([...ADAPTERS.map(adapter => adapter.kind), ...Object.keys(workload.instances), ...this.d.bus.peers.keys()])] : ADAPTERS.map(adapter => adapter.kind);
531
- return { ...workload, ...(this.d.capabilities ? { capabilities: this.d.capabilities(), repository: this.d.repository } : {}), pullOnly: [...this.d.bus.peers.values()].filter(peer => definitionOf(peer.id, workload.instances)?.attach?.pullOnlyForms?.length && peer.pullOnly).map(peer => peer.id), offRoster: peers.filter(peer => !this.d.bus.isRostered(peer)), ...(failing ? { failing } : {}), ...(held ? { held } : {}), ...(this.d.roles ? { roles: this.d.roles } : {}) };
557
+ return { ...workload, reviewPool: { pool: this.d.review?.pool ?? DEFAULT_REVIEW_POOL, rerotate: this.d.review?.rerotate, required: this.d.review?.pool !== undefined }, ...(this.d.capabilities ? { capabilities: this.d.capabilities(), repository: this.d.repository } : {}), pullOnly: [...this.d.bus.peers.values()].filter(peer => definitionOf(peer.id, workload.instances)?.attach?.pullOnlyForms?.length && peer.pullOnly).map(peer => peer.id), offRoster: peers.filter(peer => !this.d.bus.isRostered(peer)), ...(failing ? { failing } : {}), ...(held ? { held } : {}), ...(this.d.roles ? { roles: this.d.roles } : {}) };
558
+ }
559
+
560
+ private reviewHead(task: Task): string | undefined { return task.refs.commit ?? task.sdlc?.head ?? task.sdlc?.localHead; }
561
+
562
+ private reviewPoolOptions(task: Task) {
563
+ const head = this.reviewHead(task);
564
+ const round = task.state === "in_review" ? task.history.slice(task.history.findLastIndex(entry => entry.event === "done") + 1) : [];
565
+ const failed = [...new Set(round.flatMap(entry => entry.reviewerFailure?.head === head && entry.reviewerFailure ? [entry.reviewerFailure.peer] : []))];
566
+ return { pool: this.d.review?.pool ?? DEFAULT_REVIEW_POOL, rerotate: this.d.review?.rerotate, failed, required: this.d.review?.pool !== undefined };
567
+ }
568
+
569
+ private manuallyNamedReviewer(task: Task): boolean {
570
+ const event = task.history.findLast(entry => ["reviewer changed", "assigned", "reassigned", "escalated"].includes(entry.event));
571
+ return event?.event === "reviewer changed" && event.reason === "manual";
572
+ }
573
+
574
+ private poolReview(task: Task, refs?: TaskRefs): Assignment | undefined {
575
+ if (this.manuallyNamedReviewer(task) || this.isPii(task) || task.class === "review") return undefined;
576
+ const draft = { ...task, refs: { ...task.refs, ...cleanRefs(refs) } };
577
+ const selection = assign(draft, this.states(), this.d.routing(), { ...this.weights(task.class, draft), candidates: [], ...(task.owner ? { notReviewer: task.owner } : {}) });
578
+ return selection.reviewSelection ? selection : undefined;
579
+ }
580
+
581
+ private reviewMetadata(assignment?: Assignment): ReviewerHistory {
582
+ const choice = assignment?.reviewSelection;
583
+ const family = choice && assignment?.plannerFallback && assignment.reviewer ? familyOf(assignment.reviewer, this.d.instances?.()) : choice?.family;
584
+ const reason = choice && assignment?.plannerFallback
585
+ ? assignment.sameFamilyFallback ? "same-family fallback" as const : "unavailable skip" as const
586
+ : choice?.reason;
587
+ return { ...(family ? { reviewerFamily: family } : {}), ...(reason ? { reviewerReason: reason } : {}), ...(assignment?.reviewBlocked ? { reviewBlocked: true } : {}) };
532
588
  }
533
589
 
534
590
  /** Capability admission precedes explicit assignments/reservations, independent of readiness or transient peer health. */
@@ -543,10 +599,10 @@ export class Tasks {
543
599
  }
544
600
 
545
601
  /** What assignment weighs besides states and policy: quota, demotion and the review record. */
546
- private weights(cls: TaskClass) {
602
+ private weights(cls: TaskClass, task?: Task) {
547
603
  const now = Date.now();
548
604
  const quota = this.d.quota?.();
549
- return { ...this.health(), now, demoted: this.demoted(cls, now), reviews: this.reviewRecord(cls), ...(quota ? { quota } : {}), ...(this.d.review?.adaptive ? { adaptive: { min: this.d.review.min_reviews } } : {}) };
605
+ return { ...this.health(), ...(task ? { reviewPool: this.reviewPoolOptions(task) } : {}), now, demoted: this.demoted(cls, now), reviews: this.reviewRecord(cls), ...(quota ? { quota } : {}), ...(this.d.review?.adaptive ? { adaptive: { min: this.d.review.min_reviews } } : {}) };
550
606
  }
551
607
 
552
608
  /**
@@ -644,6 +700,11 @@ export class Tasks {
644
700
  if (by !== USER && !this.d.roles?.[by]?.includes("planner")) throw new PreEffectToolRefusal("planning requires a person or configured planner role");
645
701
  }
646
702
 
703
+ private plannerWriteAuthority(by: PeerId): void {
704
+ if (by !== USER && this.d.privateTurn?.(by)) throw new PreEffectToolRefusal("planner writes are unavailable during a private turn");
705
+ this.planningAuthority(by);
706
+ }
707
+
647
708
  wipLimit(peer: PeerId): number {
648
709
  const limit = this.d.wipLimit?.(peer) ?? 1;
649
710
  if (!Number.isSafeInteger(limit) || limit < 0 || limit > 32) throw new PreEffectToolRefusal("invalid planner WIP capacity");
@@ -671,7 +732,7 @@ export class Tasks {
671
732
  const states = this.states();
672
733
  const full = this.plannerFull(task);
673
734
  // Preview the To Do transition without routing or offering the persisted backlog row.
674
- return assign({ ...task, state: "proposed" }, states, this.d.routing(), { exclude: this.excluded(task), excludeOwners: full, ...(reservedOnly && task.reserved && !this.excluded(task).includes(task.reserved) ? { candidates: [task.reserved], namedOwner: task.reserved } : {}), waitsFor: this.waitsFor(task), ...this.weights(task.class) });
735
+ return assign({ ...task, state: "proposed" }, states, this.d.routing(), { exclude: this.excluded(task), excludeOwners: full, ...(reservedOnly && task.reserved && !this.excluded(task).includes(task.reserved) ? { candidates: [task.reserved], namedOwner: task.reserved } : {}), waitsFor: this.waitsFor(task), ...this.weights(task.class, task) });
675
736
  }
676
737
 
677
738
  async pullPlanned(by: PeerId, input: { id: number; revision: number; reason: string }): Promise<Task> {
@@ -681,7 +742,7 @@ export class Tasks {
681
742
  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");
682
743
  const validate = () => {
683
744
  const task = this.d.board.get(input.id);
684
- if (!task || (task.planningRevision ?? 0) !== input.revision || task.state !== "backlog" || task.owner || taskHasStarted(task) || task.pinned) throw new PreEffectToolRefusal("planner pull changed, pinned or already started");
745
+ if (!task || (task.planningRevision ?? 0) !== input.revision || task.state !== "backlog" || task.owner || !taskCanPlan(task) || task.pinned) throw new PreEffectToolRefusal("planner pull changed, pinned or already started");
685
746
  const release = task.release == null ? undefined : this.d.board.release(task.release);
686
747
  if (task.release != null && (!release || ["frozen", "shipped", "dropped"].includes(release.state))) throw new PreEffectToolRefusal("planner pull requires unfrozen release scope");
687
748
  if (this.waitsFor(task).length) throw new PreEffectToolRefusal("planner pull waits for dependency approval");
@@ -719,6 +780,7 @@ export class Tasks {
719
780
  for (const id of ids) if (this.d.board.get(id)?.state === "backlog") pending.add(id);
720
781
  if (pending.size) this.plannerBacklog.set(peer, pending);
721
782
  }
783
+ private readonly groomingNotified = new Map<string, number>();
722
784
  /** One bounded ids-only batch per attached planner; failed or evicted copies stay pending. */
723
785
  flushPlannerNotices(): number {
724
786
  const configured = Object.entries(this.d.roles ?? {}).filter(([, roles]) => roles.includes("planner")).map(([peer]) => peer);
@@ -732,6 +794,18 @@ export class Tasks {
732
794
  }
733
795
  const planners = configured.filter(peer => this.d.bus.isRostered(peer) && this.d.bus.peers.has(peer) && this.d.bus.stateOf(peer) !== "offline");
734
796
  if (!planners.length) return 0;
797
+ if ((this.d.sdlc?.config().mode ?? "off") !== "off") {
798
+ const revision = this.d.board.statusRevision();
799
+ const body = "The board changed. Before offering or accepting build work, read hub_planner_groom, consolidate dependencies and priorities, and record hub_plan_review against the current planning revision.";
800
+ for (const peer of planners) if (this.groomingNotified.get(peer) !== revision) {
801
+ const queued = this.d.bus.pendingEnvelopes(peer).some(env => env.from === HUB && env.kind === "task" && env.body === body);
802
+ if (queued) continue;
803
+ this.releaseNotice(`could not notify planner ${peer} about grooming`, () => {
804
+ const recipients = this.d.bus.publish(newEnvelope(HUB, body, { to: [peer], kind: "task" }));
805
+ if (recipients.includes(peer)) this.groomingNotified.set(peer, revision);
806
+ });
807
+ }
808
+ }
735
809
  for (const id of this.incomingBacklog) if (this.d.board.get(id)?.state !== "backlog") this.incomingBacklog.delete(id);
736
810
  const incoming = [...this.incomingBacklog].sort((a, b) => a - b).slice(0, 64);
737
811
  if (incoming.length) {
@@ -793,7 +867,7 @@ export class Tasks {
793
867
  const own = owned.get(release.id) ?? [], ownKnown = own.filter(task => task.doneAt !== null).sort(compare);
794
868
  const cutStart = release.cut === null ? known.length : after(known, release.cut);
795
869
  const unknownPool = release.cut === null ? approved : unknown;
796
- return { ...release,
870
+ return { ...this.publicRelease(release),
797
871
  name: this.nameable(release.name) ? release.name : "[withheld]",
798
872
  goal: this.nameable(release.goal) ? release.goal : "[withheld]",
799
873
  tasks: scopes.get(release.id) ?? [],
@@ -815,11 +889,14 @@ export class Tasks {
815
889
  const task = this.d.board.get(input.id);
816
890
  if (!task || (task.planningRevision ?? 0) !== input.revision) throw new PreEffectToolRefusal("planning revision changed");
817
891
  if (isClosedTaskState(task.state)) throw new PreEffectToolRefusal("closed work is final");
818
- if (by !== USER && (task.state !== "backlog" || taskHasStarted(task) || task.pinned || Object.hasOwn(patch, "pinned"))) throw new PreEffectToolRefusal("planner changes only unpinned backlog work");
892
+ if (by !== USER && (task.state !== "backlog" || !taskCanPlan(task) || task.pinned || Object.hasOwn(patch, "pinned"))) throw new PreEffectToolRefusal("planner changes only unpinned backlog work");
819
893
  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");
820
894
  if (by !== USER && Object.hasOwn(patch, "reserved") && this.hasPersonReservation(task)) throw new PreEffectToolRefusal("a person's reservation is retained");
821
895
  if (Object.hasOwn(patch, "deps") || Object.hasOwn(patch, "reserved")) {
822
- if (task.state !== "backlog" || taskHasStarted(task) || task.owner) throw new PreEffectToolRefusal("dependency and reservation changes require unstarted backlog work");
896
+ const returnedUntouched = task.state === "backlog" && !task.owner && taskHasStarted(task) && taskCanPlan(task);
897
+ if (!returnedUntouched) {
898
+ if (task.state !== "backlog" || taskHasStarted(task) || task.owner) throw new PreEffectToolRefusal("dependency and reservation changes require unstarted backlog work");
899
+ }
823
900
  if (patch.reserved && !definitionOf(patch.reserved, this.d.instances?.()) && !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");
824
901
  if (patch.reserved && !this.d.bus.isRostered(patch.reserved)) throw new PreEffectToolRefusal("reserved owner is off roster");
825
902
  if (patch.reserved) this.requireCapabilities(task, patch.reserved);
@@ -980,12 +1057,122 @@ export class Tasks {
980
1057
  return result;
981
1058
  }
982
1059
 
983
- async newRelease(by: PeerId, input: { name: string; goal: string; order: number }): Promise<Release> {
1060
+ /** Release assignment changes no execution role, dependency, reservation or state. */
1061
+ async assignRelease(by: PeerId, input: { id: number; revision: number; release: number | null; reason: string }): Promise<Task> {
1062
+ this.plannerWriteAuthority(by);
1063
+ if (typeof input.reason !== "string" || !input.reason.trim() || Buffer.byteLength(input.reason) > 1000) throw new PreEffectToolRefusal("release assignment needs a bounded reason");
1064
+ if (!Number.isSafeInteger(input.id) || input.id <= 0 || !Number.isSafeInteger(input.revision) || input.revision < 0 || input.release !== null && (!Number.isSafeInteger(input.release) || input.release <= 0)) throw new PreEffectToolRefusal("invalid release assignment");
1065
+ const validate = () => {
1066
+ this.plannerWriteAuthority(by);
1067
+ const task = this.d.board.get(input.id);
1068
+ if (!task || (task.planningRevision ?? 0) !== input.revision) throw new PreEffectToolRefusal("release assignment changed");
1069
+ if (by !== USER && (this.isPii(task) || task.pinned)) throw new PreEffectToolRefusal("private or pinned release scope requires the person");
1070
+ for (const id of new Set([task.release, input.release])) if (id != null) {
1071
+ const release = this.d.board.release(id);
1072
+ if (!release || ["frozen", "shipped", "dropped"].includes(release.state)) throw new PreEffectToolRefusal("release scope is frozen or final");
1073
+ }
1074
+ return task;
1075
+ };
1076
+ const before = validate(), signature = JSON.stringify(before), privateScope = this.isPii(before);
1077
+ if (!privateScope && (!this.nameable(input.reason) || this.screenOn() && (await this.screenCall(input.reason)).label !== "clear")) throw new PreEffectToolRefusal("release reason was not cleared for public planning");
1078
+ if (JSON.stringify(validate()) !== signature) throw new PreEffectToolRefusal("release assignment changed during screening");
1079
+ const result = this.d.board.plan(input.id, by, { release: input.release }, input.revision, input.reason.trim(), privateScope);
1080
+ this.planningRecorded({ by, task: input.id, fields: ["release"], revision: result.planningRevision! });
1081
+ return result;
1082
+ }
1083
+
1084
+ async configureRelease(by: PeerId, input: { id: number; revision: number; hotfix?: unknown; state?: "active"; cut?: number }): Promise<Release> {
1085
+ this.plannerWriteAuthority(by);
1086
+ const validate = () => {
1087
+ this.plannerWriteAuthority(by);
1088
+ if (!Number.isSafeInteger(input.id) || input.id <= 0 || !Number.isSafeInteger(input.revision) || input.revision < 0) throw new PreEffectToolRefusal("invalid release identity or revision");
1089
+ const release = this.d.board.release(input.id);
1090
+ if (!release || release.revision !== input.revision || !["planning", "active"].includes(release.state)) throw new PreEffectToolRefusal("release revision changed or scope frozen");
1091
+ if (input.state !== undefined && input.state !== "active") throw new PreEffectToolRefusal("invalid train state");
1092
+ if (input.cut !== undefined && (!Number.isSafeInteger(input.cut) || input.cut <= 0 || input.cut > 8_640_000_000_000_000)) throw new PreEffectToolRefusal("release cut must be a positive safe UTC timestamp");
1093
+ const dates = [release.plannedStart, input.cut ?? release.cut, release.ship].filter((date): date is number => date !== null);
1094
+ if (dates.some((date, index) => index > 0 && dates[index - 1]! > date)) throw new PreEffectToolRefusal("invalid release planned dates");
1095
+ if ((input.state ?? release.state) === "active" && this.d.board.releases().some(row => row.id !== input.id && ["active", "frozen"].includes(row.state))) throw new PreEffectToolRefusal("one open release train at a time");
1096
+ return release;
1097
+ };
1098
+ const release = validate();
1099
+ let train;
1100
+ try { train = releaseTrainData({ ...(release.train ?? { history: [] }), ...(input.hotfix === undefined ? {} : { hotfix: input.hotfix }) }); }
1101
+ catch (error) { throw new PreEffectToolRefusal((error as Error).message); }
1102
+ if (train.hotfix && (!this.nameable(train.hotfix.reason) || this.screenOn() && (await this.screenCall(train.hotfix.reason)).label !== "clear")) throw new PreEffectToolRefusal("hotfix reason was not cleared");
1103
+ const validatedRelease = validate();
1104
+ if (JSON.stringify(validatedRelease) !== JSON.stringify(release)) throw new PreEffectToolRefusal("release changed during screening");
1105
+ if (release.train && JSON.stringify(train) === JSON.stringify(release.train) && (input.state ?? release.state) === release.state && (input.cut ?? release.cut) === release.cut) return validatedRelease;
1106
+ if (train.history.length >= RELEASE_TRAIN_HISTORY_LIMIT - 2) throw new PreEffectToolRefusal("release train history full; keep the remaining slots for cut and ship");
1107
+ train.history.push({ at: Date.now(), by, event: "configured" });
1108
+ const updated = this.d.board.updateRelease(input.id, { train: releaseTrainData(train), ...(input.state ? { state: input.state } : {}), ...(input.cut === undefined ? {} : { cut: input.cut }) }, input.revision);
1109
+ this.planningRecorded({ by, release: input.id, fields: ["train"], revision: updated.revision });
1110
+ return updated;
1111
+ }
1112
+
1113
+ publicRelease(release: Release): Release {
1114
+ return { ...release, name: this.nameable(release.name) ? release.name : "[withheld]", goal: this.nameable(release.goal) ? release.goal : "[withheld]", ...(release.train?.hotfix && !this.nameable(release.train.hotfix.reason) ? { train: { ...release.train, hotfix: { ...release.train.hotfix, reason: "[withheld]" } } } : {}) };
1115
+ }
1116
+
1117
+ releaseReport(by: PeerId, id: number) {
984
1118
  this.planningAuthority(by);
1119
+ if (by !== USER && this.d.privateTurn?.(by)) throw new PreEffectToolRefusal("release report unavailable during a private turn");
1120
+ const release = Number.isSafeInteger(id) && id > 0 ? this.d.board.release(id) : undefined;
1121
+ if (!release) throw new PreEffectToolRefusal("no such release");
1122
+ const all = this.d.board.list(), publicTasks = all.filter(task => !this.isPii(task));
1123
+ if (all.some(task => task.release === id && this.isPii(task))) throw new PreEffectToolRefusal("release scope contains private work; public readiness unavailable");
1124
+ return { release: this.publicRelease(release), readiness: releaseTrainReadiness(release, publicTasks), metrics: releaseMetrics(this.d.board.releases().filter(row => !all.some(task => task.release === row.id && this.isPii(task))), publicTasks), native: "unverified" as const };
1125
+ }
1126
+
1127
+ /** Scope freeze only. This never tags, publishes, upgrades, or authorizes a release. */
1128
+ async cutRelease(by: PeerId, input: { id: number; revision: number }): Promise<Release> {
1129
+ const validate = () => {
1130
+ this.plannerWriteAuthority(by);
1131
+ if (!Number.isSafeInteger(input.id) || input.id <= 0 || !Number.isSafeInteger(input.revision) || input.revision < 0) throw new PreEffectToolRefusal("invalid release identity or revision");
1132
+ const release = this.d.board.release(input.id);
1133
+ if (!release || release.revision !== input.revision || release.state !== "active") throw new PreEffectToolRefusal("release revision changed or train is not active");
1134
+ if (this.d.board.releases().some(row => row.id !== input.id && ["active", "frozen"].includes(row.state))) throw new PreEffectToolRefusal("one open release train at a time");
1135
+ if ((release.train?.history.length ?? 0) > RELEASE_TRAIN_HISTORY_LIMIT - 2) throw new PreEffectToolRefusal("release train history full; cut needs slots for cut and ship, drop this train and create a replacement");
1136
+ const report = this.releaseReport(by, input.id);
1137
+ if (report.readiness.blockers.length) throw new PreEffectToolRefusal(`release cut blocked: ${report.readiness.blockers.join("; ")}`);
1138
+ return { release, scope: this.d.board.list().filter(task => task.release === input.id) };
1139
+ };
1140
+ const before = validate(), signature = JSON.stringify(before);
1141
+ const expected = new Set(before.scope.flatMap(task => task.sdlc?.repo && task.sdlc.pr ? [`${task.sdlc.repo.toLowerCase()}#${task.sdlc.pr}`] : []));
1142
+ let ciRuns: number | undefined;
1143
+ if (expected.size && before.scope.every(task => task.sdlc?.repo && /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(task.sdlc.repo) && !task.sdlc.repo.startsWith("-") && Number.isSafeInteger(task.sdlc.pr) && task.sdlc.pr! > 0)) {
1144
+ const repositories = [...new Set(before.scope.flatMap(task => task.sdlc?.repo ? [task.sdlc.repo] : []))];
1145
+ if (repositories.length === 1 && expected.size <= 16) {
1146
+ try {
1147
+ const costs = await this.costReport(by, repositories[0], before.scope.map(task => task.sdlc!.pr!)), rows = costs.rows as import("./cost-governance.ts").CostRow[];
1148
+ const selected = rows.filter(row => expected.has(`${row.repo.toLowerCase()}#${row.pr}`));
1149
+ if (!costs.truncated && selected.length === expected.size && selected.every(row => row.ciRuns !== null && row.ciReruns !== null)) ciRuns = selected.reduce((sum, row) => sum + row.ciRuns! + row.ciReruns!, 0);
1150
+ } catch { /* Optional cost evidence stays unknown; validation below still rechecks scope and authority. */ }
1151
+ }
1152
+ }
1153
+ if (JSON.stringify(validate()) !== signature) throw new PreEffectToolRefusal("release scope changed during cost readback");
1154
+ const window = await this.d.releaseWindow?.();
1155
+ try {
1156
+ if (JSON.stringify({ release: this.d.board.release(input.id), scope: this.d.board.list().filter(task => task.release === input.id) }) !== signature) throw new PreEffectToolRefusal("release scope changed during admission");
1157
+ const freshScope = validate();
1158
+ if (!window || !Number.isFinite(window.at) || Date.now() - window.at > 5000 || window.at > Date.now() || window.host !== true || window.quota !== true || window.current?.() === false) throw new PreEffectToolRefusal(`release cut deferred: ${window?.reasons?.join("; ") || "host or peer quota evidence unavailable"}; inspect ahub budget and ahub budget set <peer> <0..1>; stop or resume the paused peer; the person can use ahub release drop or explicitly disable lifecycle mode before retrying`);
1159
+ const scope = freshScope.scope.map(task => task.id).sort((a, b) => a - b), train = releaseTrainData(freshScope.release.train ?? { history: [] });
1160
+ train.scope = scope; train.history.push({ at: Date.now(), by, event: "cut", scope, reviews: freshScope.scope.reduce((sum, task) => sum + task.history.filter(row => ["approved", "changes_requested"].includes(row.event)).length, 0), ...(ciRuns === undefined ? {} : { ciRuns }) });
1161
+ const updated = this.d.board.freezeRelease(input.id, input.revision, freshScope.scope, releaseTrainData(train));
1162
+ this.planningRecorded({ by, release: input.id, fields: ["state", "train"], revision: updated.revision });
1163
+ return updated;
1164
+ } finally {
1165
+ try { await window?.close?.(); }
1166
+ catch { this.releaseNotice(`release #${input.id}: admission cleanup incomplete; inspect the host lease before retrying`); }
1167
+ }
1168
+ }
1169
+
1170
+ async newRelease(by: PeerId, input: { name: string; goal: string; order: number }): Promise<Release> {
1171
+ this.plannerWriteAuthority(by);
985
1172
  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");
986
1173
  const text = input.name + "\n" + input.goal;
987
1174
  if (!this.nameable(text) || this.screenOn() && (await this.screenCall(text)).label !== "clear") throw new PreEffectToolRefusal("release text was not cleared for public planning");
988
- this.planningAuthority(by); // #455: a person may have moved the planner while the screen ran
1175
+ this.plannerWriteAuthority(by); // Privacy and planner identity may change while screening.
989
1176
  if (this.d.board.releases().some(release => release.name === input.name)) throw new PreEffectToolRefusal("release name already exists");
990
1177
  const release = this.d.board.newRelease(input.name, input.goal.trim(), input.order);
991
1178
  this.planningRecorded({ by, release: release.id, fields: ["created"], revision: release.revision });
@@ -999,9 +1186,17 @@ export class Tasks {
999
1186
  if (["shipped", "dropped"].includes(release.state)) throw new PreEffectToolRefusal("settled release is final");
1000
1187
  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");
1001
1188
  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");
1189
+ if (input.state === "active" && (release.train || this.d.sdlc?.config().mode !== undefined && this.d.sdlc.config().mode !== "off" || this.d.board.releases().some(row => row.train && ["active", "frozen"].includes(row.state))) && this.d.board.releases().some(row => row.id !== release.id && ["active", "frozen"].includes(row.state))) throw new PreEffectToolRefusal("one open release train at a time");
1190
+ if (input.state === "frozen" && (release.train || this.d.sdlc?.config().mode !== undefined && this.d.sdlc.config().mode !== "off")) throw new PreEffectToolRefusal("use the resource-admitted release cut operation");
1191
+ if (input.state === "shipped" && release.train) {
1192
+ if (release.train.history.length >= RELEASE_TRAIN_HISTORY_LIMIT) throw new PreEffectToolRefusal("release train history full; ship needs one history slot, drop this train and create a replacement");
1193
+ const readiness = releaseTrainReadiness(release, this.d.board.list());
1194
+ if (readiness.blockers.length) throw new PreEffectToolRefusal(`release ship blocked: ${readiness.blockers.join("; ")}`);
1195
+ }
1002
1196
  const { id, revision, ...patch } = input;
1003
1197
  if (!Object.keys(patch).length || Object.keys(patch).some(key => !["state", "plannedStart", "cut", "ship"].includes(key))) throw new PreEffectToolRefusal("unknown or empty release fields");
1004
- const updated = this.d.board.updateRelease(id, patch, revision);
1198
+ const trained = release.train && input.state === "shipped" ? releaseTrainData({ ...release.train, history: [...release.train.history, { at: Date.now(), by, event: "shipped" }] }) : undefined;
1199
+ const updated = this.d.board.updateRelease(id, { ...patch, ...(trained ? { train: trained } : {}) }, revision);
1005
1200
  this.planningRecorded({ by, release: id, fields: Object.keys(patch), revision: updated.revision });
1006
1201
  return updated; // a failed readback may follow a durable release write
1007
1202
  }
@@ -1011,6 +1206,12 @@ export class Tasks {
1011
1206
  if (external && (by !== USER || input.backlog !== true)) throw new PreEffectToolRefusal("external intake requires person-owned backlog staging");
1012
1207
  if (input.backlog !== undefined && typeof input.backlog !== "boolean") throw new PreEffectToolRefusal("backlog must be a boolean");
1013
1208
  const backlog = input.backlog === true;
1209
+ const claiming = !backlog && input.owner === by;
1210
+ if (claiming) {
1211
+ this.acceptanceCapacity({ id: -1, owner: by, state: "proposed" });
1212
+ this.pendingClaims.set(by, (this.pendingClaims.get(by) ?? 0) + 1);
1213
+ }
1214
+ try {
1014
1215
  if (backlog && input.owner && !this.d.bus.isRostered(input.owner)) throw new PreEffectToolRefusal(`${input.owner} is off roster; add it before reserving work`);
1015
1216
  // Callers are models: a title is one line (it is part of console and hub.log lines), not a document.
1016
1217
  const title = String(input.title ?? "").replace(/\s+/g, " ").trim().slice(0, 300);
@@ -1060,6 +1261,7 @@ export class Tasks {
1060
1261
  const defaulted = !backlog && !cls && input.owner === by;
1061
1262
  if (defaulted) cls = "implement";
1062
1263
  if (!cls) throw new PreEffectToolRefusal(`class is required (one of ${CLASSES.join(", ")}); ${fenced ? "the task is handled as PII and the hub's model is not reached on campus, so it was not asked to name one" : "the hub could not name one for you"}`);
1264
+ if (claiming) this.acceptanceCapacity({ id: -1, owner: by, state: "proposed" }, 1);
1063
1265
  let selected;
1064
1266
  try { selected = taskRole(input.role, cls, this.d.catalog ?? loadRoleCatalog(this.d.cwd)); }
1065
1267
  catch (error) { throw new PreEffectToolRefusal((error as Error).message); }
@@ -1086,7 +1288,40 @@ export class Tasks {
1086
1288
  return this.d.board.update(task.id, HUB, "blocked", {}, `waits for ${still.map((id) => `#${id}`).join(", ")}`);
1087
1289
  }
1088
1290
  // Naming yourself is a claim: the work is already yours, so no offer comes back to you (paper: Agensh CLAIM, #68).
1089
- return this.assignOwner(task, by, input.owner ? { candidates: [input.owner], namedOwner: input.owner, claim: input.owner === by } : {});
1291
+ return await this.assignOwner(task, by, input.owner ? { candidates: [input.owner], namedOwner: input.owner, claim: input.owner === by } : {});
1292
+ } finally {
1293
+ if (claiming) {
1294
+ const remaining = (this.pendingClaims.get(by) ?? 1) - 1;
1295
+ if (remaining) this.pendingClaims.set(by, remaining); else this.pendingClaims.delete(by);
1296
+ }
1297
+ }
1298
+ }
1299
+
1300
+ async returnUntouchedToBacklog(by: PeerId, input: { id: number; revision: number; reason: string }): Promise<Task> {
1301
+ this.plannerWriteAuthority(by);
1302
+ if (!Number.isSafeInteger(input.id) || input.id < 1 || !Number.isSafeInteger(input.revision) || input.revision < 0
1303
+ || typeof input.reason !== "string" || !input.reason.trim() || input.reason.length > 1000) throw new PreEffectToolRefusal("untouched return needs an id, revision and bounded reason");
1304
+ const validate = () => {
1305
+ const task = this.need(input.id, false, true);
1306
+ if (!["proposed", "in_progress"].includes(task.state) || (task.planningRevision ?? 0) !== input.revision || taskHasWorkEvidence(task)) throw new PreEffectToolRefusal("work has execution evidence or its planning revision changed");
1307
+ if (by !== USER && task.pinned) throw new PreEffectToolRefusal("pinned work requires the person");
1308
+ const release = task.release == null ? undefined : this.d.board.release(task.release);
1309
+ if (release && (["shipped", "dropped"].includes(release.state) || by !== USER && release.state === "frozen")) throw new PreEffectToolRefusal("settled or frozen release scope cannot return");
1310
+ return task;
1311
+ };
1312
+ const task = validate(), flow = task.history.length;
1313
+ const withheld = await this.withholds("note", input.reason, task);
1314
+ this.plannerWriteAuthority(by);
1315
+ const confirmed = validate();
1316
+ if (confirmed.history.length !== flow) throw new PreEffectToolRefusal("task changed during untouched return");
1317
+ const returned = this.d.board.update(confirmed.id, by, "backlog returned untouched", { state: "backlog", owner: null, reviewer: null }, input.reason.trim(), { ...(withheld ? { withheld: true } : {}) }, flow);
1318
+ this.offered.delete(confirmed.id);
1319
+ this.cohorts.leave(confirmed.id);
1320
+ this.withdrawRoleRequest(confirmed, returned, "owner");
1321
+ this.withdrawRoleRequest(confirmed, returned, "reviewer");
1322
+ this.incomingBacklog.add(confirmed.id);
1323
+ this.planningRecorded({ by, task: confirmed.id, fields: ["return"], revision: confirmed.planningRevision ?? 0 });
1324
+ return returned;
1090
1325
  }
1091
1326
 
1092
1327
  /** Person-owned staging only. Flow transitions remain with their existing owners and reviewers. */
@@ -1413,7 +1648,7 @@ export class Tasks {
1413
1648
  const task = this.d.board.get(target);
1414
1649
  if (!task) throw new Error(`no task #${target}`);
1415
1650
  if (isClosedTaskState(task.state)) return [...workflow, `task ${this.publicTitle(task)} (${task.state}): closed; not routed`];
1416
- 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) });
1651
+ 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, task) });
1417
1652
  // The split trace is for the pair the record is about: the task's owner when it has one (issue #109).
1418
1653
  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 ?? [])];
1419
1654
  }
@@ -1466,6 +1701,7 @@ export class Tasks {
1466
1701
 
1467
1702
  /** Why the task will approve directly at done: no reviewer could be picked, and who was skipped (issue #92). */
1468
1703
  private noReviewer(a: Assignment): string {
1704
+ if (a.reviewBlocked) return "blocked: no reviewer can run";
1469
1705
  const skipped = a.trace.flatMap((l) => {
1470
1706
  const m = /^ {2}reviewer candidate (\S+): skipped, (.*)$/.exec(l);
1471
1707
  return m ? [`${m[1]} (${m[2]})`] : [];
@@ -1480,7 +1716,14 @@ export class Tasks {
1480
1716
  }
1481
1717
 
1482
1718
  private assignmentNote(note: string, assignment: Assignment): string {
1483
- return assignment.sameFamilyFallback ? `${note}; sameFamilyFallback=true` : note;
1719
+ const tagged = assignment.sameFamilyFallback ? `${note}; sameFamilyFallback=true` : note;
1720
+ const pool = assignment.reviewSelection, metadata = this.reviewMetadata(assignment);
1721
+ const walk = pool?.blocked ? pool.walk.slice(0, 16).map(row => {
1722
+ const reason = this.nameable(row.reason) ? Array.from(sanitize(row.reason).replace(/\s+/g, " ")).slice(0, 80).join("") : "[withheld]";
1723
+ return `${row.id || row.kind}: ${reason}`;
1724
+ }).join("; ") + (pool.walk.length > 16 ? `; +${pool.walk.length - 16} walk entries` : "") : "";
1725
+ const recorded = pool ? `${tagged}; reviewerFamily=${metadata.reviewerFamily ?? "none"}; reviewerReason=${metadata.reviewerReason ?? pool.blocked}${pool.blocked ? `; pool=${pool.blocked}; walk: ${walk}` : ""}` : tagged;
1726
+ return assignment.plannerFallback ? `${recorded}; reviewer=planner (fallback)` : recorded;
1484
1727
  }
1485
1728
 
1486
1729
  /** A later move to another owner means the caller's assignment was superseded, so its success tail is not authoritative. */
@@ -1495,10 +1738,28 @@ export class Tasks {
1495
1738
  /** `override`: a person's explicit assign, which past refusals do not block and which drops an agent's reservation (#207). */
1496
1739
  private async assignOwner(task: Task, by: PeerId, opts: { candidates?: PeerId[]; namedOwner?: PeerId; event?: string; note?: string; reason?: TaskMoveReason; clearOnFail?: boolean; exclude?: PeerId[]; context?: string; contextWithheld?: boolean; claim?: boolean; override?: boolean; roster?: boolean; planner?: boolean; expectedFlowRevision?: number } = {}): Promise<Task> {
1497
1740
  if (task.state === "backlog") throw new PreEffectToolRefusal(`task #${task.id}: pull backlog work before assigning it`);
1741
+ const mode = (this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode;
1742
+ let stagedClaimGap: string | undefined;
1743
+ if (mode !== "off" && this.isBuildTask(task)) {
1744
+ const gap = task.sdlc?.planReview ? await this.planReviewGap(task) : "plan not reviewed since proposal";
1745
+ const current = this.d.board.get(task.id);
1746
+ if (!current || !this.ownerAssignmentCurrent(task, current)) return current ?? task;
1747
+ task = current; // Reviewer-only and quiet bookkeeping updates do not supersede ownership authority.
1748
+ if (gap) {
1749
+ this.releaseNotice(`task ${this.publicTitle(current)}: ${gap}; planner: call hub_planner_groom then hub_plan_review`);
1750
+ if (mode === "enforce") {
1751
+ if (opts.claim && opts.namedOwner === by) stagedClaimGap = gap;
1752
+ else {
1753
+ this.reportStall(current, { kind: "no-eligible-peer", code: "policy" });
1754
+ return current;
1755
+ }
1756
+ }
1757
+ }
1758
+ }
1498
1759
  const waits = this.waitsFor(task);
1499
1760
  const plannedMove = opts.planner || this.plannerPullRecord(task)?.pull === true && (by === HUB || opts.roster) && (opts.reason !== "manual" || opts.roster);
1500
1761
  const plannerFull = plannedMove ? this.plannerFull(task) : [];
1501
- const a = assign(task, this.states(), this.d.routing(), { exclude: [...(opts.override ? [] : this.excluded(task)), ...(opts.exclude ?? []), ...(opts.event === "escalated" && task.owner ? [task.owner] : [])], excludeOwners: plannerFull, ...(opts.candidates ? { candidates: opts.candidates } : {}), waitsFor: waits, ...this.weights(task.class), ...(opts.namedOwner ? { namedOwner: opts.namedOwner } : {}) });
1762
+ const a = assign(task, this.states(), this.d.routing(), { exclude: [...(opts.override ? [] : this.excluded(task)), ...(opts.exclude ?? []), ...(opts.event === "escalated" && task.owner ? [task.owner] : [])], excludeOwners: plannerFull, ...(opts.candidates ? { candidates: opts.candidates } : {}), waitsFor: waits, ...this.weights(task.class, task), ...(opts.namedOwner ? { namedOwner: opts.namedOwner } : {}) });
1502
1763
 
1503
1764
  if (waits.length) {
1504
1765
  this.releaseNotice(`task ${this.publicTitle(task)} waits for ${waits.map((id) => `#${id}`).join(", ")}; it is offered once they are approved`);
@@ -1521,7 +1782,7 @@ export class Tasks {
1521
1782
  // A person's explicit assign replaces an agent's reservation (#207). Only a refusal keeps the owner it moves away from
1522
1783
  // out for good: a reviewer's rejections or an escalation by hand, never the hub's own move after a failed delivery.
1523
1784
  const lasting = opts.event === "escalated" && task.owner && (opts.reason === "rejections" || opts.reason === "manual");
1524
- const next = this.d.board.update(task.id, by, opts.event ?? "assigned", { owner: a.owner, reviewer: a.reviewer ?? null, ...(opts.roster && task.state === "in_progress" ? { state: "proposed" as const } : {}), ...(opts.event === "escalated" ? { rejections: 0 } : {}), ...(opts.override && task.reserved ? { reserved: null } : {}) }, this.assignmentNote(`${opts.note ?? `to ${a.owner}`}${passed}`, a), { ...(profile ? { profile } : {}), ...(opts.reason ? { reason: opts.reason } : {}), ...(lasting ? { from: task.owner! } : {}) }, opts.expectedFlowRevision);
1785
+ const next = this.d.board.update(task.id, by, opts.event ?? "assigned", { owner: a.owner, reviewer: a.reviewer ?? null, ...(opts.roster && task.state === "in_progress" ? { state: "proposed" as const } : {}), ...(opts.event === "escalated" ? { rejections: 0 } : {}), ...(opts.override && task.reserved ? { reserved: null } : {}) }, this.assignmentNote(`${opts.note ?? `to ${a.owner}`}${passed}`, a), { ...this.reviewMetadata(a), ...(profile ? { profile } : {}), ...(opts.reason ? { reason: opts.reason } : {}), ...(lasting ? { from: task.owner! } : {}) }, opts.expectedFlowRevision);
1525
1786
  // What calibration reads (issue #109): routing chose the first owner (no single named candidate, no claim; not an
1526
1787
  // escalation, relay or reassignment of work already begun), and the work overlaps another owner's task not started
1527
1788
  // yet. For the record only. Work routed back to its proposer is left out, as its observations are (by === owner).
@@ -1566,6 +1827,13 @@ export class Tasks {
1566
1827
  }
1567
1828
  // A claim is its own hand-over, with no delivery: its accepted write and its line stay as they were (#297).
1568
1829
  if (opts.claim && a.owner === by) {
1830
+ // #481 S1: a claim of build work starts it only from an accepted spec. In enforce the claim stays an assignment
1831
+ // (owner set, still proposed); hub_task_accept starts it once the person accepted the spec.
1832
+ if (this.specGated(next) && specState(next.sdlc, undefined) === "missing") {
1833
+ if (this.d.sdlc?.config().mode === "enforce") { this.releaseNotice(`${this.specText(next, "missing", "blocked")}; the claim stays assigned to ${by}`); return next; }
1834
+ this.releaseNotice(this.specText(next, "missing", "advice"));
1835
+ }
1836
+ if (stagedClaimGap) return next; // An explicit claim may reserve its owner, but no offer or acceptance bypasses review.
1569
1837
  const claimed = this.d.board.update(next.id, by, "accepted", { state: "in_progress" });
1570
1838
  this.releaseNotice(`task ${this.publicTitle(claimed)} claimed by ${by}`);
1571
1839
  return claimed;
@@ -1835,6 +2103,160 @@ export class Tasks {
1835
2103
  if (by !== USER && task[role] !== by) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id}: only its ${role} (${task[role] ?? "none"}) or the console user can do that`);
1836
2104
  }
1837
2105
 
2106
+ private planIssueIdentity(task: Task): { repo: string; issue: number } | undefined {
2107
+ const source = task.external ?? task.sdlc;
2108
+ if (source?.repo && source.issue) return { repo: source.repo, issue: source.issue };
2109
+ const repository = task.refs.repo ?? this.d.repository ?? "";
2110
+ let issues: number[];
2111
+ try { issues = specRefs(task, repository); }
2112
+ catch { throw new PreEffectToolRefusal("plan review issue identity unavailable"); }
2113
+ if (issues.length > 1) throw new PreEffectToolRefusal("plan review needs one explicit issue identity");
2114
+ return issues[0] ? { repo: repository, issue: issues[0] } : undefined;
2115
+ }
2116
+
2117
+ async groomingDigest(by: PeerId): Promise<Record<string, unknown>> {
2118
+ this.planningAuthority(by);
2119
+ const included = (task: Task) => !isClosedTaskState(task.state) || task.state === "cancelled" && task.history.some(entry => !!entry.supersededBy);
2120
+ const snapshot = this.d.board.list().filter(included), issues = new Map<string, PlanIssueMetadata>(), attempted = new Set<string>();
2121
+ const deadline = performance.now() + 4_000;
2122
+ let reads = 0, incomplete = false;
2123
+ // ponytail: at most eight metadata reads within four seconds; add paginated revision-bound readback for larger boards.
2124
+ for (const listed of snapshot) {
2125
+ this.planningAuthority(by);
2126
+ const task = this.d.board.get(listed.id);
2127
+ if (!task || this.isPii(task) || isClosedTaskState(task.state)) continue;
2128
+ let source: { repo: string; issue: number } | undefined;
2129
+ try { source = this.planIssueIdentity(task); } catch { continue; }
2130
+ if (!source) continue;
2131
+ const key = `${source.repo.toLowerCase()}#${source.issue}`;
2132
+ if (attempted.has(key)) continue;
2133
+ attempted.add(key);
2134
+ if (reads >= 8 || performance.now() >= deadline) { incomplete = true; break; }
2135
+ reads++;
2136
+ const run = this.d.sdlc?.gh ?? sdlcGh;
2137
+ const issue = await readPlanIssueMetadata(source.repo, source.issue, (args, timeout, cwd, cap) => run(args, Math.max(1, Math.min(timeout ?? 10_000, deadline - performance.now())), cwd, cap));
2138
+ if (issue) issues.set(key, issue);
2139
+ }
2140
+ this.planningAuthority(by);
2141
+ const current = this.d.board.list().filter(included), visible = current.filter(task => !this.isPii(task));
2142
+ const peers = [...new Set([...Object.keys(this.states()), ...current.flatMap(task => task.owner ? [task.owner] : [])])];
2143
+ const reviewedIssues = new Map(visible.flatMap(task => task.sdlc?.planReview?.issue ? [[task.id, task.sdlc.planReview.issue] as const] : []));
2144
+ const acceptedIssues = new Map(visible.flatMap(task => task.sdlc?.acceptance ? [[task.id, task.sdlc.acceptance] as const] : []));
2145
+ const blockers: { task: number; code: string }[] = this.d.board.stalls().map(stall => ({ task: stall.task, code: stall.cause.code }));
2146
+ const health = this.health(), states = this.states(), routing = this.d.routing(), hostBlocks = this.d.plannerBlocks?.() ?? {};
2147
+ for (const task of visible.filter(task => !isClosedTaskState(task.state)).slice(0, 500)) {
2148
+ const owner = task.owner ?? task.reserved;
2149
+ if (owner) for (const code of peerEntry(hostBlocks, owner) ?? []) blockers.push({ task: task.id, code });
2150
+ const result = assign(task.state === "backlog" ? { ...task, state: "proposed" } : task, states, routing, { ...health, waitsFor: this.waitsFor(task), ...(owner ? { candidates: [owner], namedOwner: owner } : {}) });
2151
+ if (!result.owner) for (const refusal of result.refusals ?? []) if (refusal.role === "owner") blockers.push({ task: task.id, code: refusal.code === "instance" || refusal.code === "family" ? "policy" : refusal.code });
2152
+ if (this.waitsFor(task).length) blockers.push({ task: task.id, code: "dependency" });
2153
+ }
2154
+ const digest = plannerGrooming(visible, { repository: this.d.repository ?? `path:${realPath(this.d.cwd)}`,
2155
+ limits: Object.fromEntries(peers.map(peer => [peer, this.wipLimit(peer)])), wip: Object.fromEntries(peers.map(peer => [peer, this.wip(peer)])),
2156
+ states, issues, reviewedIssues, acceptedIssues, blockers });
2157
+ return { ...digest, truncated: digest.truncated || incomplete, ...(current.some(task => this.isPii(task)) ? { privateTasks: "withheld" } : {}) };
2158
+ }
2159
+
2160
+ /** S10 read-only planner report; S7 owns rolling bands and automatic board proposals. */
2161
+ async costReport(by: PeerId, repository?: unknown, scopePrs?: readonly number[]): Promise<Record<string, unknown>> {
2162
+ const authority = () => {
2163
+ this.planningAuthority(by);
2164
+ if (by !== USER && this.d.privateTurn?.(by)) throw new PreEffectToolRefusal("cost report unavailable during private work");
2165
+ };
2166
+ authority();
2167
+ const repo = repository ?? this.d.repository;
2168
+ if (typeof repo !== "string" || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo) || repo.startsWith("-") || !this.nameable(repo)) throw new PreEffectToolRefusal("cost report requires a public owner/repository identity");
2169
+ const deadline = performance.now() + 20_000;
2170
+ const bounded = (args: string[], timeout?: number, cwd?: string, cap?: number) => performance.now() < deadline
2171
+ ? (this.d.sdlc?.gh ?? sdlcGh)(args, Math.max(1, Math.min(timeout ?? 10_000, deadline - performance.now())), cwd, cap)
2172
+ : Promise.resolve({ code: 1, stdout: "" });
2173
+ const requested = scopePrs === undefined ? undefined : new Set(scopePrs);
2174
+ const linked = () => this.d.board.list().filter(task => task.sdlc?.repo?.toLowerCase() === repo.toLowerCase() && Number.isSafeInteger(task.sdlc.pr) && task.sdlc.pr! > 0 && (!requested || requested.has(task.sdlc.pr!)));
2175
+ const ids = [...new Set(linked().filter(task => !this.isPii(task)).map(task => task.sdlc!.pr!))].sort((a, b) => a - b);
2176
+ const observations: import("./cost-governance.ts").CostPr[] = [];
2177
+ let incomplete = ids.length > 16;
2178
+ for (const pr of ids.slice(0, 16)) {
2179
+ authority();
2180
+ if (linked().some(task => task.sdlc?.pr === pr && this.isPii(task))) continue;
2181
+ if (performance.now() >= deadline) { incomplete = true; break; }
2182
+ const evidence = await readPrCostHistory(repo, pr, (args, timeout, cwd, cap) => {
2183
+ authority();
2184
+ const fresh = linked().filter(task => task.sdlc?.pr === pr);
2185
+ if (!fresh.length || fresh.some(task => this.isPii(task))) return Promise.resolve({ code: 1, stdout: "" });
2186
+ return bounded(args, timeout, cwd, cap);
2187
+ });
2188
+ if (evidence) observations.push(evidence); else incomplete = true;
2189
+ }
2190
+ authority();
2191
+ const current = linked();
2192
+ const visiblePrs = new Set(current.filter(task => !this.isPii(task)).map(task => task.sdlc!.pr!));
2193
+ for (const task of current) if (this.isPii(task)) visiblePrs.delete(task.sdlc!.pr!);
2194
+ if (JSON.stringify([...visiblePrs].sort((a, b) => a - b)) !== JSON.stringify(ids)) incomplete = true;
2195
+ const report = costMetrics(current.map(task => ({ repo, pr: task.sdlc!.pr!, private: this.isPii(task), history: task.history })), observations.filter(pr => visiblePrs.has(pr.pr)));
2196
+ return { ...report, truncated: report.truncated || incomplete, ...(current.some(task => this.isPii(task)) ? { privateTasks: "withheld" } : {}), bands: "S7 pending" };
2197
+ }
2198
+
2199
+ async planReviewGap(task: Task): Promise<string | undefined> {
2200
+ const receipt = task.sdlc?.planReview;
2201
+ if (!receipt) return "plan not reviewed since proposal";
2202
+ if (!receipt.planner || !Number.isFinite(receipt.recordedAt)) return "plan not reviewed since planning revision change";
2203
+ const revision = task.planningRevision ?? 0;
2204
+ if (receipt.planningRevision !== revision) {
2205
+ const changes = task.history.filter(entry => entry.planning && entry.planning.revision > receipt.planningRevision);
2206
+ if (receipt.planningRevision > revision || changes.length !== revision - receipt.planningRevision || changes.some((entry, index) => entry.event !== "planned" || entry.planning!.revision !== receipt.planningRevision + index + 1 || entry.planning!.fields.length !== 1 || entry.planning!.fields[0] !== "release")) return "plan not reviewed since planning revision change";
2207
+ }
2208
+ if (receipt.scope && JSON.stringify(receipt.scope) !== JSON.stringify(cleanCostPlan(task.plan ?? {}))) return "plan not reviewed since cost scope change";
2209
+ const identity = this.planIssueIdentity(task);
2210
+ if (identity?.issue && this.isPii(task)) return "private issue plan review evidence unavailable";
2211
+ if (!identity?.issue) return receipt.issue ? "plan not reviewed since issue identity change" : undefined;
2212
+ if (!identity.repo || typeof receipt.issue?.repo !== "string" || receipt.issue.repo.toLowerCase() !== identity.repo.toLowerCase() || receipt.issue.issue !== identity.issue) return "plan not reviewed since issue identity change";
2213
+ const current = await readPlanIssueMetadata(identity.repo, identity.issue, this.d.sdlc?.gh ?? sdlcGh);
2214
+ if (!current) return "plan review issue metadata unavailable";
2215
+ if (current.state !== "OPEN") return "plan review issue is closed";
2216
+ return current.editedAt !== receipt.issue.editedAt ? "plan not reviewed since issue edit" : undefined;
2217
+ }
2218
+
2219
+ /** Planner authority and issue edit evidence are rechecked across every external await. */
2220
+ async recordPlanReview(by: PeerId, id: unknown, input: unknown): Promise<Task> {
2221
+ this.plannerWriteAuthority(by);
2222
+ if (!input || typeof input !== "object" || Array.isArray(input)) throw new PreEffectToolRefusal("plan review needs a revision and reason");
2223
+ const raw = input as Record<string, unknown>;
2224
+ if (Object.keys(raw).some(key => !["revision", "reason", "review", "ci"].includes(key)) || !Number.isSafeInteger(raw.revision) || (raw.revision as number) < 0
2225
+ || typeof raw.reason !== "string" || !raw.reason.trim() || raw.reason.length > 1000) throw new PreEffectToolRefusal("plan review needs a current revision and bounded reason");
2226
+ const task = this.need(id, false, true, true), revision = raw.revision as number;
2227
+ if (isClosedTaskState(task.state)) throw new PreEffectToolRefusal("closed work is final");
2228
+ if ((task.planningRevision ?? 0) !== revision) throw new PreEffectToolRefusal("planning revision changed");
2229
+ let scope: CostPlan;
2230
+ try { scope = cleanCostPlan({ ...task.plan, ...("review" in raw ? { review: raw.review } : {}), ...("ci" in raw ? { ci: raw.ci } : {}) }); }
2231
+ catch { throw new PreEffectToolRefusal("invalid review or CI cost scope"); }
2232
+ const mode = (this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode;
2233
+ if (!scope.review || !scope.ci) {
2234
+ if (mode === "enforce") throw new PreEffectToolRefusal("review and CI cost scope required for a new plan receipt");
2235
+ if (mode === "advise") this.releaseNotice(`task ${this.publicTitle(task)}: cost scope unspecified`);
2236
+ }
2237
+ const flow = task.history.length, withheld = await this.withholds("note", raw.reason, task);
2238
+ this.plannerWriteAuthority(by);
2239
+ const observed = this.need(task.id, false, true, true);
2240
+ if (observed.history.length !== flow || (observed.planningRevision ?? 0) !== revision) throw new PreEffectToolRefusal("plan review changed during readback");
2241
+ const identity = this.planIssueIdentity(observed);
2242
+ if (identity?.issue && this.isPii(observed)) throw new PreEffectToolRefusal("private issue plan review readback unavailable");
2243
+ const issue = identity?.repo && identity.issue ? await readPlanIssueMetadata(identity.repo, identity.issue, this.d.sdlc?.gh ?? sdlcGh) : undefined;
2244
+ if (identity?.issue && !issue) throw new PreEffectToolRefusal("plan review issue metadata unavailable");
2245
+ if (issue && !this.isPii(task) && !this.nameable(JSON.stringify(issue))) throw new PreEffectToolRefusal("plan review metadata matches a PII pattern");
2246
+ this.plannerWriteAuthority(by);
2247
+ const current = this.need(task.id, false, true, true);
2248
+ if (current.history.length !== flow || (current.planningRevision ?? 0) !== revision) throw new PreEffectToolRefusal("plan review changed during readback");
2249
+ const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
2250
+ const updated = this.d.board.update(current.id, by, "plan reviewed", { ...(Object.keys(scope).length ? { plan: { ...current.plan, ...scope } } : {}), sdlc: { ...current.sdlc, stage: current.sdlc?.stage ?? lifecycleStage(config, "plan"),
2251
+ planReview: { planner: by, planningRevision: revision, recordedAt: Date.now(), ...(Object.keys(scope).length ? { scope } : {}), ...(issue ? { issue } : {}) } } }, raw.reason.trim(), { ...(withheld ? { withheld: true } : {}) }, flow);
2252
+ if (updated.state === "proposed" && !updated.owner) {
2253
+ this.offered.delete(updated.id);
2254
+ try { await this.offerReady(updated, "planner review recorded"); }
2255
+ catch { this.releaseNotice(`task #${updated.id}: plan review saved; inspect its owner delivery before retrying`); }
2256
+ }
2257
+ return this.d.board.get(updated.id) ?? updated;
2258
+ }
2259
+
1838
2260
  /** Artifact links and claimed local gates stay on the board; no outside text is accepted. */
1839
2261
  recordLifecycle(by: PeerId, id: unknown, input: unknown): Task {
1840
2262
  const task = this.need(id, false, true);
@@ -1842,6 +2264,8 @@ export class Tasks {
1842
2264
  const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
1843
2265
  const next = sdlcReceipt(input, task.sdlc, config, VERSION);
1844
2266
  const definition = stageDefinition(config, next.stage);
2267
+ // #481 S1: entering a build stage in enforce needs the person's spec acceptance; the person may move any stage.
2268
+ if (config.mode === "enforce" && by !== USER && next.stage !== task.sdlc?.stage && definition && canonicalStage(definition.base) === "build" && specState(next, undefined) === "missing") throw new PreEffectToolRefusal(this.specText(task, "missing", "blocked"));
1845
2269
  if (config.mode === "enforce" && config.definitions && input && typeof input === "object" && "stage" in input && next.stage !== task.sdlc?.stage && by !== USER) {
1846
2270
  const actor = definition?.actor;
1847
2271
  const acts = actor === "owner" ? by === task.owner : actor === "reviewer" ? by === task.reviewer : actor === "planner" ? this.d.roles?.[by]?.includes("planner") : actor === "owner/planner" && (by === task.owner || this.d.roles?.[by]?.includes("planner"));
@@ -1881,6 +2305,15 @@ export class Tasks {
1881
2305
  if (d.path && (!this.nameable(d.path) || matchesPolicy(this.d.cwd, d.path, this.d.piiPaths ?? [], true))) { const { path: _privatePath, ...metadata } = d; return metadata; }
1882
2306
  return d;
1883
2307
  });
2308
+ const trains = this.d.board.releases().filter(release => release.train && ["active", "frozen"].includes(release.state));
2309
+ if (trains.length) {
2310
+ const planned = trains.map(release => {
2311
+ try { return this.releaseReport(USER, release.id); }
2312
+ catch { return { release: { id: release.id }, readiness: { trigger: "waiting", tasks: [], blockers: ["release scope unavailable"] } }; }
2313
+ });
2314
+ if (planned.some(row => row.readiness.blockers.length)) { report.sourceReady = false; report.blockers.push("planned release scope is not approved or available"); }
2315
+ return { ...report, trains: planned };
2316
+ }
1884
2317
  return report;
1885
2318
  }
1886
2319
 
@@ -1900,20 +2333,93 @@ export class Tasks {
1900
2333
  async lifecycleStatus(id: unknown, refresh = true): Promise<SdlcStatus> {
1901
2334
  const task = this.need(id), config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
1902
2335
  const evidence = task.sdlc && !this.isPii(task) && refresh && config.mode !== "off" ? await readSdlcPr(task.sdlc, this.d.sdlc?.gh) : undefined;
1903
- return sdlcStatus(config, task.sdlc, evidence, this.workflowView(task));
2336
+ const accepted = task.sdlc?.acceptance;
2337
+ const edit = accepted && !this.isPii(task) && refresh && config.mode !== "off" ? await readSpecEdit(accepted.repo, accepted.issue, this.d.sdlc?.gh) : undefined;
2338
+ return sdlcStatus(config, task.sdlc, evidence, this.workflowView(task), edit);
2339
+ }
2340
+
2341
+ /** #481 S1: only build work starts from an accepted spec. */
2342
+ private specGated(task: Task): boolean {
2343
+ return (this.d.sdlc?.config().mode ?? "off") !== "off" && (task.class === "implement" || task.class === "bulk_edit");
2344
+ }
2345
+ private specText(task: Task, state: Exclude<SpecState, "current">, verb: "blocked" | "advice"): string {
2346
+ const issue = task.sdlc?.issue;
2347
+ const why = issue ? SPEC_STATE_TEXT[state].replace("<task>", String(task.id)).replace("<n>", String(issue)) : "spec issue unknown: record repo and issue with hub_sdlc_record, then the person accepts it with ahub sdlc accept";
2348
+ return `task #${task.id}: SDLC build entry ${verb}: ${why}`;
2349
+ }
2350
+
2351
+ /** The person's acceptance of the task's spec issue at its current body (#481 S1); identity and edit times only. */
2352
+ acceptSpec(by: PeerId, id: unknown, spec: { repo?: unknown; issue: unknown; edit: SpecEdit }): Task {
2353
+ if (by !== USER) throw new PreEffectToolRefusal("spec acceptance is the person's: run ahub sdlc accept in a terminal");
2354
+ const task = this.need(id, false, true, true), config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
2355
+ const repo = spec.repo ?? task.sdlc?.repo;
2356
+ if (task.sdlc?.issue !== undefined && task.sdlc.issue !== spec.issue) throw new PreEffectToolRefusal(`task #${task.id} links issue #${task.sdlc.issue}; relink it with hub_sdlc_record before accepting another`);
2357
+ const next = sdlcReceipt({ repo, issue: spec.issue }, task.sdlc, config);
2358
+ if (!next.repo || !next.issue) throw new PreEffectToolRefusal("spec acceptance needs the repository (owner/repository) and the issue number");
2359
+ next.acceptance = { repo: next.repo, issue: next.issue, lastEditedAt: spec.edit.lastEditedAt, updatedAt: spec.edit.updatedAt, acceptedAt: Date.now() };
2360
+ return this.d.board.update(task.id, USER, "spec accepted", { sdlc: next }, `issue #${next.issue}, body last edited ${spec.edit.lastEditedAt ?? "never"}`);
2361
+ }
2362
+
2363
+ /** Build entry with a live readback of the spec's body edit time (#481 S1): enforce refuses, advise notifies, off does nothing. */
2364
+ async specGate(by: PeerId, id: unknown): Promise<void> {
2365
+ const task = this.need(id, false, true);
2366
+ if (!this.specGated(task) || task.owner !== by && by !== USER) return; // accept reports its own refusals
2367
+ const accepted = task.sdlc?.acceptance;
2368
+ const edit = accepted && !this.isPii(task) ? await readSpecEdit(accepted.repo, accepted.issue, this.d.sdlc?.gh) : undefined;
2369
+ if (this.specMoved(task)) return;
2370
+ this.specVerdict(task, task.sdlc?.issue ? specState(task.sdlc, edit) : "missing");
2371
+ }
2372
+
2373
+ /** #481 S1: a build stage change by an agent re-reads the body edit time first, as accept does. */
2374
+ async specStageGate(by: PeerId, id: unknown, input: unknown): Promise<void> {
2375
+ const task = this.need(id, false, true), config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
2376
+ if (config.mode === "off" || by === USER || !input || typeof input !== "object" || !("stage" in input)) return;
2377
+ let next: ReturnType<typeof sdlcReceipt>;
2378
+ try { next = sdlcReceipt(input, task.sdlc, config, VERSION); } catch { return; } // recordLifecycle reports an invalid receipt
2379
+ const definition = stageDefinition(config, next.stage);
2380
+ if (next.stage === task.sdlc?.stage || !definition || canonicalStage(definition.base) !== "build") return;
2381
+ const accepted = next.acceptance;
2382
+ const edit = accepted && !this.isPii(task) ? await readSpecEdit(accepted.repo, accepted.issue, this.d.sdlc?.gh) : undefined;
2383
+ if (this.specMoved(task)) return;
2384
+ this.specVerdict(task, next.issue ? specState(next, edit) : "missing");
2385
+ }
2386
+
2387
+ /**
2388
+ * The task a spec readback started from changed while it was awaited (flow, link, acceptance, owner or privacy): the
2389
+ * reading belongs to other work. Enforce refuses before any write; advise drops the stale advice.
2390
+ */
2391
+ private specMoved(before: Task): boolean {
2392
+ const now = this.need(before.id, false, true);
2393
+ if (now.history.length === before.history.length && now.owner === before.owner && this.isPii(now) === this.isPii(before)
2394
+ && JSON.stringify(now.sdlc ?? null) === JSON.stringify(before.sdlc ?? null)) return false;
2395
+ if (this.d.sdlc?.config().mode === "enforce") throw new PreEffectToolRefusal(`task #${before.id}: changed during the spec readback; nothing was recorded, retry on current work`);
2396
+ return true;
2397
+ }
2398
+ private specVerdict(task: Task, state: SpecState): void {
2399
+ if (state === "current") return;
2400
+ if (this.d.sdlc?.config().mode === "enforce") throw new PreEffectToolRefusal(this.specText(task, state, "blocked"));
2401
+ this.d.notify(this.specText(task, state, "advice"));
1904
2402
  }
1905
2403
 
1906
- private async lifecycleGate(task: Task, transition: "review" | "approval", reviewedHead?: string): Promise<void> {
2404
+ private async lifecycleGate(task: Task, transition: "review" | "approval", reviewedHead?: string): Promise<PrEvidence | undefined> {
1907
2405
  const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
1908
2406
  if (config.mode === "off") return;
1909
2407
  const receipt = task.sdlc && { ...task.sdlc, ...(reviewedHead ? { reviewedHead } : {}) };
1910
- const evidence = receipt && !this.isPii(task) ? await readSdlcPr(receipt, this.d.sdlc?.gh) : undefined;
2408
+ const read: SdlcGh = (args, timeout, cwd, cap) => {
2409
+ const current = this.need(task.id, false, true);
2410
+ if (current.history.length !== task.history.length || current.owner !== task.owner || current.reviewer !== task.reviewer || this.isPii(current) !== this.isPii(task)) return Promise.resolve({ code: 1, stdout: "" });
2411
+ return (this.d.sdlc?.gh ?? sdlcGh)(args, timeout, cwd, cap);
2412
+ };
2413
+ const evidence = receipt && !this.isPii(task) ? await readSdlcPr(receipt, read) : undefined;
1911
2414
  const current = this.need(task.id, false, true);
1912
2415
  if (config.mode === "enforce" && (current.history.length !== task.history.length || current.owner !== task.owner || current.reviewer !== task.reviewer || current.state !== task.state)) throw new PreEffectToolRefusal(`task #${task.id}: changed during lifecycle readback; retry on current work`);
2416
+ // Advise may continue on the current task, but evidence from its previous link cannot admit that task's review.
2417
+ if (current.history.length !== task.history.length || current.owner !== task.owner || current.reviewer !== task.reviewer || current.state !== task.state || this.isPii(current) !== this.isPii(task)) return;
1913
2418
  const status = sdlcStatus(config, receipt, evidence, this.workflowView(current));
1914
2419
  const blockers = transition === "review" ? [...new Set([...status.blockers.filter(blocker => ["lifecycle stage definition unknown", "issue identity missing", "PR identity missing", "current PR evidence unknown", "PR is not open", "local gate on current head missing or failed"].includes(blocker)), ...(status.entry ?? []), ...(status.exit ?? [])])] : status.blockers;
1915
2420
  if (config.mode === "enforce" && blockers.length) throw new PreEffectToolRefusal(`task #${task.id}: SDLC ${transition} blocked: ${blockers.join("; ")}`);
1916
2421
  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("; ")}`);
2422
+ return evidence;
1917
2423
  }
1918
2424
 
1919
2425
  private need(id: unknown, open = false, preEffect = false, allowBacklog = false): Task {
@@ -1928,6 +2434,62 @@ export class Tasks {
1928
2434
  return task;
1929
2435
  }
1930
2436
 
2437
+ private readonly pendingClaims = new Map<PeerId, number>();
2438
+ private readonly reviewedAdmissions = new Map<number, { flow: number; owner: string | null }>();
2439
+ private isBuildTask(task: Task): boolean {
2440
+ const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
2441
+ if (["implement", "bulk_edit"].includes(task.class)) return true;
2442
+ const base = task.sdlc ? stageDefinition(config, task.sdlc.stage)?.base : undefined;
2443
+ return base === "build" || base === "implement";
2444
+ }
2445
+ async acceptReviewed(by: PeerId, id: unknown, plan?: unknown): Promise<Task> {
2446
+ const task = this.need(id, false, true);
2447
+ this.mine(task, by, "owner", true);
2448
+ this.acceptanceCapacity(task);
2449
+ if ((this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode === "off" || !this.isBuildTask(task)) return this.accept(by, id, plan);
2450
+ await this.specGate(by, id);
2451
+ await this.planEntryGate(by, task);
2452
+ const current = this.need(id, false, true);
2453
+ this.mine(current, by, "owner", true);
2454
+ if (current.history.length !== task.history.length || current.owner !== task.owner) throw new PreEffectToolRefusal("task changed during build admission");
2455
+ this.reviewedAdmissions.set(current.id, { flow: current.history.length, owner: current.owner });
2456
+ try { return this.accept(by, id, plan); }
2457
+ finally { this.reviewedAdmissions.delete(current.id); }
2458
+ }
2459
+
2460
+ /** Spec and plan readbacks share the caller's original task snapshot until the final acceptance write. */
2461
+ private async planEntryGate(by: PeerId, task: Task): Promise<void> {
2462
+ const mode = (this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode;
2463
+ if (mode === "off" || !this.isBuildTask(task)) return;
2464
+ const gap = await this.planReviewGap(task);
2465
+ if (mode === "advise" && !task.sdlc?.planReview?.scope) this.releaseNotice(`task ${this.publicTitle(task)}: cost scope unspecified`);
2466
+ const current = this.need(task.id, false, true);
2467
+ this.mine(current, by, "owner", true);
2468
+ if (current.history.length !== task.history.length || current.owner !== task.owner) throw new PreEffectToolRefusal("task changed during plan readback");
2469
+ if (gap && mode === "enforce") throw new PreEffectToolRefusal(gap);
2470
+ if (gap) this.releaseNotice(`task ${this.publicTitle(current)}: ${gap}`);
2471
+ }
2472
+
2473
+ private acceptanceCapacity(task: Pick<Task, "id" | "owner" | "reserved" | "state"> & Partial<Pick<Task, "history">>, excludePending = 0): void {
2474
+ const owner = task.owner ?? task.reserved;
2475
+ if (!owner) return;
2476
+ if ((task.state === "in_progress" || task.state === "changes_requested") && task.history) {
2477
+ let boundary = 0, previous: PeerId | null | undefined;
2478
+ task.history.forEach((entry, index) => {
2479
+ if (entry.owner !== undefined) {
2480
+ if (entry.owner !== previous) boundary = index;
2481
+ previous = entry.owner;
2482
+ } else if (OWNERSHIP_EVENTS.has(entry.event)) { boundary = index; previous = undefined; }
2483
+ });
2484
+ if (task.history.slice(boundary).some(entry => entry.event === "accepted" && (entry.by === owner || entry.by === USER))) return;
2485
+ }
2486
+ const limit = this.wipLimit(owner), pending = Math.max(0, (this.pendingClaims.get(owner) ?? 0) - excludePending);
2487
+ if (this.wip(owner, task.id) + pending < limit) return;
2488
+ const open = this.d.board.list().filter(other => other.id !== task.id && !["backlog", "approved", "cancelled"].includes(other.state)
2489
+ && (other.owner === owner || !other.owner && other.reserved === owner && this.plannerPullRecord(other)?.pull)).map(other => `#${other.id}`);
2490
+ throw new PreEffectToolRefusal(`planner WIP capacity for ${owner}: limit ${limit}; open tasks ${open.join(", ") || "none"}${pending ? `; pending claims ${pending}` : ""}`);
2491
+ }
2492
+
1931
2493
  /**
1932
2494
  * With a plan, the owners of overlapping tasks get it as a ride-along line, and an overlap only the plan reveals is
1933
2495
  * announced like one found at assignment (issue #31).
@@ -1937,9 +2499,24 @@ export class Tasks {
1937
2499
  this.mine(task, by, "owner", true);
1938
2500
  this.ready(task, true);
1939
2501
  if (task.state !== "proposed" && task.state !== "changes_requested" && task.state !== "in_progress") throw new PreEffectToolRefusal(`task #${task.id} is ${task.state}: cannot move to in_progress`);
2502
+ this.acceptanceCapacity(task);
2503
+ // S1 spec acceptance and S9 plan review are independent prerequisites; retain S1's missing-spec refusal first.
2504
+ if (this.specGated(task) && this.d.sdlc?.config().mode === "enforce" && specState(task.sdlc, undefined) === "missing") throw new PreEffectToolRefusal(this.specText(task, "missing", "blocked"));
2505
+ if ((this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode === "enforce" && this.isBuildTask(task)) {
2506
+ const readback = this.reviewedAdmissions.get(task.id);
2507
+ this.reviewedAdmissions.delete(task.id);
2508
+ if (!readback || readback.flow !== task.history.length || readback.owner !== task.owner) throw new PreEffectToolRefusal("current plan-review readback required before acceptance");
2509
+ }
1940
2510
  // Models send null or {} for an optional field they leave empty: neither replaces a plan.
1941
2511
  const given = plan == null ? undefined : cleanPlan(plan, (task.profile ?? "dev") === (this.d.profile ?? "dev") ? this.d.profileData ?? task.profile ?? "dev" : task.profile ?? "dev");
1942
- const cleaned = given && Object.keys(given).length ? given : undefined;
2512
+ // File-list intent still replaces the old lists; omitted budget keys retain the planner-reviewed scope.
2513
+ const cleaned = given && Object.keys(given).length ? { ...cleanCostPlan(task.plan ?? {}), ...given } : undefined;
2514
+ if (this.d.sdlc?.config().mode === "enforce" && task.sdlc?.planReview && !task.sdlc.planReview.scope
2515
+ && given && Object.keys(cleanCostPlan(given)).length) throw new PreEffectToolRefusal("legacy plan cost scope is unspecified; record a scoped planner review before acceptance");
2516
+ if (this.d.sdlc?.config().mode === "enforce" && task.sdlc?.planReview?.scope
2517
+ && JSON.stringify(cleanCostPlan(cleaned ?? task.plan ?? {})) !== JSON.stringify(task.sdlc.planReview.scope)) {
2518
+ throw new PreEffectToolRefusal("plan cost scope changed; record a new planner review before acceptance");
2519
+ }
1943
2520
  // A task's signals are fixed when it is proposed: text that matches a PII pattern cannot be let in afterwards.
1944
2521
  if (cleaned && !this.isPii(task) && this.isPii({ signals: this.signalsFor({ title: "", detail: planText(cleaned), refs: {}, plan: cleaned }) })) {
1945
2522
  throw new PreEffectToolRefusal("this plan matches a PII pattern and is not kept: other owners would see it");
@@ -2029,6 +2606,7 @@ export class Tasks {
2029
2606
  this.mine(t, by, "owner", true);
2030
2607
  if (t.state === "in_review" || isClosedTaskState(t.state)) throw new PreEffectToolRefusal(`task #${t.id} is already ${t.state}`);
2031
2608
  this.ready(t, true);
2609
+ if (t.state === "proposed" || t.state === "changes_requested") this.acceptanceCapacity(t);
2032
2610
  if (this.checking.has(t.id)) {
2033
2611
  // The result goes only to the owner the check was started for: anyone who took the task since hears nothing.
2034
2612
  throw new PreEffectToolRefusal(this.checking.get(t.id) === t.owner ? `task #${t.id}: its check is still running; its result comes as a task message` : `task #${t.id}: a check from before it changed hands is still running; call hub_task_done again in a few minutes`);
@@ -2038,7 +2616,17 @@ export class Tasks {
2038
2616
  // The PII screen reads the summary of a done that passes these checks (issue #198); they run again after it.
2039
2617
  const withheld = await this.withholds("summary", summary, valid());
2040
2618
  let task = valid();
2041
- if (this.d.sdlc && this.d.sdlc.config().mode !== "off") { await this.lifecycleGate(task, "review"); task = valid(); }
2619
+ // #481 S1: done without a separate accept starts build work too: the same live spec check as accept, before any write.
2620
+ if (task.state === "proposed" || task.state === "changes_requested") {
2621
+ const admission = task;
2622
+ await this.specGate(by, task.id);
2623
+ await this.planEntryGate(by, admission);
2624
+ task = valid();
2625
+ }
2626
+ let reviewEvidence: PrEvidence | undefined;
2627
+ if (this.d.sdlc && this.d.sdlc.config().mode !== "off") { reviewEvidence = await this.lifecycleGate(task, "review"); task = valid(); }
2628
+ const reviewAdmission = task.reviewer ? await this.reviewRequestAdmission(task, refs, true, by, reviewEvidence) : undefined;
2629
+ task = valid();
2042
2630
  // A member of a silent cohort (issue #107): its done is an intent, and the last of them integrates first.
2043
2631
  const cohort = this.cohorts.of(task.id);
2044
2632
  const silentMember = !!cohort?.silent && this.turnFree() && !!task.owner && !this.isPii(task);
@@ -2069,7 +2657,7 @@ export class Tasks {
2069
2657
  }
2070
2658
  if (task.state === "proposed" || task.state === "changes_requested") task = this.d.board.update(task.id, by, "accepted", { state: "in_progress" }, WITH_DONE); // done without a separate accept
2071
2659
  const command = this.d.runCheck ? this.d.check?.(task.class, task) : undefined;
2072
- if (!command) return this.complete(task, by, summary, refs, "", withheld, "review");
2660
+ if (!command) return this.complete(task, by, summary, refs, "", withheld, "review", reviewAdmission);
2073
2661
  // The tool call returns now; a check can outlast an agent's tool timeout. The result decides what comes next.
2074
2662
  this.checking.set(task.id, task.owner);
2075
2663
  const pending = this.d.board.update(task.id, by, "done (checking)", { refs: cleanRefs(refs) }, summary, { withheld: withheld || undefined });
@@ -2196,17 +2784,32 @@ export class Tasks {
2196
2784
  }
2197
2785
 
2198
2786
  /** `gated`: the transition `done()` already gated with no await since, so it is not read back twice (#382). */
2199
- private async complete(task: Task, by: PeerId, summary?: string, refs?: TaskRefs, checkOutput = "", withheld = false, gated?: "review"): Promise<Task> {
2200
- let reviewer = task.reviewer;
2787
+ private async complete(task: Task, by: PeerId, summary?: string, refs?: TaskRefs, checkOutput = "", withheld = false, gated?: "review", admission?: ReviewReadiness): Promise<Task> {
2788
+ let selection = this.poolReview(task, refs);
2789
+ let reviewer = selection?.reviewer ?? task.reviewer, reviewEvidence: PrEvidence | undefined;
2201
2790
  if (this.d.sdlc && this.d.sdlc.config().mode !== "off" && gated !== (reviewer ? "review" : "approval")) {
2202
- await this.lifecycleGate(task, reviewer ? "review" : "approval");
2791
+ reviewEvidence = await this.lifecycleGate(task, reviewer ? "review" : "approval");
2203
2792
  task = this.need(task.id, false, true);
2204
2793
  this.mine(task, by, "owner", true);
2205
2794
  // In every mode: another done may have landed during the readback.
2206
2795
  if (task.state === "in_review" || isClosedTaskState(task.state)) throw new Error(`task #${task.id} is already ${task.state}`);
2207
- reviewer = task.reviewer;
2208
- }
2209
- 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 });
2796
+ selection = this.poolReview(task, refs);
2797
+ reviewer = selection?.reviewer ?? task.reviewer;
2798
+ }
2799
+ // A delayed local check needs a new readback; the immediate done path already admitted before effects.
2800
+ if (reviewer && !admission && (this.d.sdlc?.config().mode ?? "off") !== "off") {
2801
+ const observed = task;
2802
+ admission = await this.reviewRequestAdmission(task, refs, false, by, reviewEvidence);
2803
+ task = this.need(task.id);
2804
+ this.mine(task, by, "owner");
2805
+ if (task.history.length !== observed.history.length || task.state === "in_review" || isClosedTaskState(task.state)) throw new Error("task changed during review admission");
2806
+ }
2807
+ selection = this.poolReview(task, refs);
2808
+ reviewer = selection?.reviewer ?? task.reviewer;
2809
+ const reviewCost: ReviewCost | undefined = reviewer && task.plan && (task.plan.review || task.plan.ci)
2810
+ ? { ...cleanCostPlan(task.plan), ...(admission?.ready && admission.head ? { head: admission.head } : {}) } : undefined;
2811
+ const routedSummary = selection?.reviewSelection?.blocked ? `${summary ?? ""}\nReview routing: ${this.assignmentNote("pool unavailable", selection)}`.trim() : summary;
2812
+ const next = this.d.board.update(task.id, by, "done", { state: reviewer ? "in_review" : "approved", ...(selection ? { reviewer: reviewer ?? USER } : {}), ...(task.sdlc ? { sdlc: { ...task.sdlc, stage: lifecycleStage(this.d.sdlc?.config() ?? sdlcConfig(undefined), reviewer ? "review" : "merge") } } : {}), refs: cleanRefs(refs) }, checkOutput ? `${routedSummary ?? ""}\n${checkOutput}`.trim() : routedSummary, { withheld: withheld || undefined, ...this.reviewMetadata(selection), ...(reviewCost ? { reviewCost } : {}) });
2210
2813
  this.cohorts.closed(next.id); // its owner's next native turn end settles it in its cohort (issue #107)
2211
2814
  this.note(next, by, "finding", `Task #${next.id} done by ${by}: ${next.title}\n${summary ?? ""}`, withheld);
2212
2815
  this.tellCompleted(next, summary, withheld);
@@ -2217,7 +2820,7 @@ export class Tasks {
2217
2820
  await this.releaseDependents(next);
2218
2821
  }
2219
2822
  else if (reviewer === USER) this.releaseNotice(`task ${this.publicTitle(next)} done by ${by}: review it with ahub task show ${next.id}, then ahub review ${next.id} approved|changes_requested [note]`);
2220
- else this.releaseNotice(`task ${this.publicTitle(next)}: could not send the review request to ${reviewer}`, () => this.sendReview(next, reviewer));
2823
+ else this.releaseNotice(`task ${this.publicTitle(next)}: could not send the review request to ${reviewer}`, () => this.sendReview(next, reviewer, "", admission));
2221
2824
  return next;
2222
2825
  }
2223
2826
 
@@ -2291,7 +2894,44 @@ export class Tasks {
2291
2894
  }
2292
2895
 
2293
2896
  /** The one place a review request is written: the first reviewer and a replacement get the same text, refs and privacy. */
2294
- private sendReview(task: Task, reviewer: PeerId, why = ""): void {
2897
+ /** Review admission is separate from brief construction (#228). All refusals precede the requesting done's writes. */
2898
+ private async reviewRequestAdmission(task: Task, refs?: TaskRefs, preEffect = true, actor: PeerId = task.owner ?? USER, previous?: PrEvidence, sameRequest?: () => boolean): Promise<ReviewReadiness | undefined> {
2899
+ const mode = (this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode;
2900
+ if (mode === "off") return undefined;
2901
+ const guard = () => {
2902
+ if (actor !== USER && actor !== task.owner) {
2903
+ const holders = Object.entries(this.d.roles ?? {}).filter(([, roles]) => Array.isArray(roles) && roles.includes("conductor")).map(([peer]) => peer);
2904
+ if (holders.length !== 1 || holders[0] !== actor) throw new (preEffect ? PreEffectToolRefusal : Error)("review admission authority changed");
2905
+ }
2906
+ const current = this.need(task.id, false, preEffect);
2907
+ if ((this.d.sdlc?.config().mode ?? "off") !== mode || current.history.length !== task.history.length && !sameRequest?.() || current.owner !== task.owner || current.reviewer !== task.reviewer
2908
+ || this.isPii(current) !== this.isPii(task) || JSON.stringify(current.sdlc ?? null) !== JSON.stringify(task.sdlc ?? null)
2909
+ || actor !== USER && this.d.privateTurn?.(actor) && !this.isPii(current)) throw new (preEffect ? PreEffectToolRefusal : Error)("task or authority changed during review admission");
2910
+ return current;
2911
+ };
2912
+ guard();
2913
+ const expected = cleanRefs(refs).commit ?? task.refs.commit ?? task.sdlc?.head ?? task.sdlc?.localHead;
2914
+ const read: SdlcGh = (args, timeout, cwd, cap) => { guard(); return (this.d.sdlc?.gh ?? sdlcGh)(args, timeout, cwd, cap); };
2915
+ const evidence: ReviewReadiness = this.isPii(task)
2916
+ ? { ready: false, blockers: ["private review readiness unavailable"] }
2917
+ : task.sdlc ? reviewReadinessFromEvidence(previous, expected, this.d.sdlc?.config().requiredChecks ?? []) ?? await readReviewReadiness(task.sdlc, expected, read, this.d.sdlc?.config().requiredChecks ?? []) : { ready: false, blockers: ["review PR identity missing"] };
2918
+ const current = guard();
2919
+ if (!evidence.ready) {
2920
+ const message = `task ${this.publicTitle(current)}: premature review: ${evidence.blockers.join("; ")}`;
2921
+ if (mode === "enforce") throw new (preEffect ? PreEffectToolRefusal : Error)(message);
2922
+ this.releaseNotice(message);
2923
+ }
2924
+ return evidence;
2925
+ }
2926
+
2927
+ private sendReview(task: Task, reviewer: PeerId, why = "", admission?: ReviewReadiness): void {
2928
+ const mode = (this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode;
2929
+ if (mode === "enforce" && !admission?.ready) {
2930
+ // Background roster/quota role changes are retained, but never invent a remotely admitted review request.
2931
+ this.releaseNotice(`task ${this.publicTitle(task)}: premature review held; retry the reviewer assignment after current CI and bot replies are verified`);
2932
+ return;
2933
+ }
2934
+
2295
2935
  const r = task.refs;
2296
2936
  const last = [...task.history].reverse().find((h) => h.event === "done");
2297
2937
  const where = [r.branch ? `branch ${r.branch}` : "", r.commit ? `commit ${r.commit}` : "", r.paths?.length ? `paths ${r.paths.join(", ")}` : ""].filter(Boolean).join("; ");
@@ -2305,18 +2945,69 @@ export class Tasks {
2305
2945
  `- ${check ? `Check result: ${(check.note ?? "").split("\n")[0]}` : "No check ran for this class: run the affected tests yourself."}`,
2306
2946
  `- List what is unmet in hub_review's unmet, one item each.`,
2307
2947
  ].join("\n");
2308
- const summary = last?.note ? this.screen(task, last.note, "summary", reviewer, last.withheld) : "(no summary)";
2948
+ // #223 R2: a reviewer-only instance judges from facts; the builder's narrative stays out, earlier findings go in.
2949
+ const reviewOnly = this.d.reviewOnly?.(reviewer) === true;
2950
+ const summary = reviewOnly ? "(summary withheld from a review-only reviewer; judge the change itself)" : last?.note ? this.screen(task, last.note, "summary", reviewer, last.withheld) : "(no summary)";
2951
+ const specRepo = task.sdlc?.repo ?? task.external?.repo, specIssue = task.sdlc?.issue ?? task.external?.issue;
2952
+ const spec = reviewOnly && specRepo && specIssue ? `Spec issue: ${specRepo}#${specIssue}\n` : "";
2953
+ const findings = reviewOnly ? task.history.filter(h => h.event === "changes_requested" && h.note).map(h => `- ${h.by}: ${this.screen(task, h.note!, "review note", reviewer, h.withheld)}`) : [];
2309
2954
  const guidance = this.roleGuidance(task, reviewer, true);
2310
2955
  let projection = "";
2311
2956
  try { const text = this.d.projectedReview?.(task); if (text) projection = this.screen(task, sanitize(text), "document projection", reviewer); }
2312
2957
  catch { projection = "(document projection unavailable)"; }
2313
- const body = `Review task #${task.id} [${profileTaskLabel(task)}] ${task.title}\nDone by ${last?.by ?? task.owner}: ${summary}\n${where ? `Where: ${where}\n` : ""}${why ? `${why}\n` : ""}${!plan && task.detail ? `Task detail:\n${task.detail}\n` : ""}${guidance ? guidance + "\n" : ""}${projection ? projection + "\n" : ""}${checklist}\nGive your verdict with hub_review {id: ${task.id}, verdict: "approved" | "changes_requested", note, unmet}.`;
2314
- this.d.bus.publish(newEnvelope(HUB, body, { to: [reviewer], kind: "review", priority: "important", refs: { ...r, task: String(task.id) }, ...(this.isPii(task) ? { private: true } : {}) }));
2958
+ const brief = this.briefText(task);
2959
+ const body = `Review task #${task.id} [${profileTaskLabel(task)}] ${task.title}\nDone by ${last?.by ?? task.owner}: ${summary}\n${where ? `Where: ${where}\n` : ""}${spec}${why ? `${why}\n` : ""}${!plan && task.detail ? `Task detail:\n${task.detail}\n` : ""}${guidance ? guidance + "\n" : ""}${projection ? projection + "\n" : ""}${brief ? brief + "\n" : ""}${findings.length ? `Earlier findings (set a disposition for each):\n${findings.join("\n")}\n` : ""}${checklist}\nGive your verdict with hub_review {id: ${task.id}, verdict: "approved" | "changes_requested", note, unmet}.`;
2960
+ this.d.bus.publish(newEnvelope(HUB, body, { to: [reviewer], kind: "review", priority: "important", refs: { ...r, ...(this.reviewHead(task) ? { commit: this.reviewHead(task)! } : {}), task: String(task.id), reviewRevision: String(this.reviewRequestRevision(task)) }, ...(this.isPii(task) ? { private: true } : {}) }));
2315
2961
  }
2316
2962
 
2317
- async review(by: PeerId, id: unknown, verdict: unknown, note?: string, unmet?: unknown, head?: unknown): Promise<Task> {
2963
+ private reviewRequestRevision(task: Task): number {
2964
+ // Role changes fence older requests; bookkeeping appended after the request does not.
2965
+ return task.history.findLastIndex(entry => ["done", "reviewer changed", "reviewer unavailable"].includes(entry.event) || OWNERSHIP_EVENTS.has(entry.event)) + 1;
2966
+ }
2967
+
2968
+ /** Host-only failure report, bound to the exact review request and its still-current child generation. */
2969
+ async retryReviewer(id: number, failedPeer: PeerId, failure: { head?: string; flowRevision?: number; cause: "start_failed" | "quota" | "no_verdict" }, current: () => boolean): Promise<Task> {
2970
+ if (!failure || !["start_failed", "quota", "no_verdict"].includes(failure.cause) || !/^[a-z][a-z0-9-]{0,63}$/.test(failedPeer) || !Number.isSafeInteger(failure.flowRevision) || failure.flowRevision! < 1 || failure.head !== undefined && (typeof failure.head !== "string" || text(failure.head) !== failure.head || Buffer.byteLength(failure.head) > 1200 || /\p{Cc}/u.test(failure.head))) throw new PreEffectToolRefusal("invalid reviewer failure binding");
2971
+ const valid = () => {
2972
+ const task = this.need(id, false, true);
2973
+ if (!current() || task.state !== "in_review" || task.reviewer !== failedPeer || this.reviewRequestRevision(task) !== failure.flowRevision || this.reviewHead(task) !== failure.head || this.isPii(task) || this.manuallyNamedReviewer(task)) throw new PreEffectToolRefusal("reviewer failure is no longer current");
2974
+ return task;
2975
+ };
2976
+ const before = valid();
2977
+ const admission = await this.reviewRequestAdmission(before, undefined, true, before.owner ?? USER, undefined, () => { valid(); return true; });
2978
+ const task = valid();
2979
+ const options = this.reviewPoolOptions(task);
2980
+ const selection = assign(task, this.states(), this.d.routing(), { ...this.weights(task.class, task), candidates: [], ...(task.owner ? { notReviewer: task.owner } : {}), reviewPool: { ...options, failed: [...options.failed, failedPeer] } });
2981
+ if (!selection.reviewSelection) throw new PreEffectToolRefusal("no declared reviewer pool can receive the fallback");
2982
+ const reviewer = selection.reviewer ?? USER;
2983
+ valid();
2984
+ const next = this.d.board.update(task.id, HUB, "reviewer unavailable", { reviewer }, this.assignmentNote(`reviewer unavailable: ${failure.cause}`, selection), { ...this.reviewMetadata(selection), reviewerFailure: { peer: failedPeer, cause: failure.cause, ...(failure.head ? { head: failure.head } : {}) } }, task.history.length);
2985
+ this.releaseNotice(`task ${this.publicTitle(next)}: former review request withdrawal incomplete`, () => this.withdrawRoleRequest(task, next, "reviewer"));
2986
+ if (reviewer === USER) this.releaseNotice(`task ${this.publicTitle(next)}: blocked: no reviewer can run; the planner or person must review`);
2987
+ else this.releaseNotice(`task ${this.publicTitle(next)}: fallback review request unavailable`, () => this.sendReview(next, reviewer, "", admission));
2988
+ return next;
2989
+ }
2990
+
2991
+ /** #520: the rendered review brief of a non-PII task's commit, or why there is none; empty without a commit. */
2992
+ briefText(task: Task): string {
2993
+ if (this.isPii(task) || !task.refs.commit) return "";
2994
+ try {
2995
+ const brief = this.d.reviewBrief?.(task);
2996
+ if (!brief) return "";
2997
+ return "unavailable" in brief ? `Review brief unavailable: ${brief.unavailable}.` : sanitize(renderReviewBrief(brief));
2998
+ } catch { return "Review brief unavailable: it could not be built."; }
2999
+ }
3000
+
3001
+ async review(by: PeerId, id: unknown, verdict: unknown, note?: string, unmet?: unknown, head?: unknown, coverage?: unknown): Promise<Task> {
3002
+ const authority = by === USER ? undefined : this.d.reviewAuthority?.(by, Number(id));
3003
+ // #520: what the coverage was checked against, pinned through every await: the commit and the lifecycle receipt (the
3004
+ // inherited reviewedHead lives there).
3005
+ let covered: { commit: string | undefined; receipt: string } | undefined;
2318
3006
  const valid = (): Task => {
3007
+ const refused = authority?.();
3008
+ if (refused) throw new PreEffectToolRefusal(refused); // rechecked after every await, right before each write
2319
3009
  const t = this.need(id, false, true);
3010
+ if (covered && (t.refs.commit !== covered.commit || JSON.stringify(t.sdlc ?? null) !== covered.receipt)) throw new PreEffectToolRefusal(`task #${t.id}'s commit or lifecycle receipt changed during the verdict: review the new head`);
2320
3011
  this.mine(t, by, "reviewer", true);
2321
3012
  if (verdict !== "approved" && verdict !== "changes_requested") throw new PreEffectToolRefusal('verdict must be "approved" or "changes_requested"');
2322
3013
  if (t.state !== "in_review") throw new PreEffectToolRefusal(`task #${t.id} is ${t.state}: cannot move to ${verdict} before its owner calls hub_task_done`);
@@ -2324,6 +3015,34 @@ export class Tasks {
2324
3015
  };
2325
3016
  const items = textList(unmet);
2326
3017
  if (items.length) note = `${note ?? ""}\nUnmet: ${items.join("; ")}`.trim();
3018
+ // #520: an approval accounts for every reviewable path of the brief. enforce refuses before any effect, advise records
3019
+ // the gap in the verdict note, off and a person's own verdict skip it.
3020
+ const mode = this.d.sdlc?.config().mode ?? "off";
3021
+ // A Tasks without a brief provider (library use; the daemon always has one) has no coverage gate.
3022
+ if (verdict === "approved" && by !== USER && mode !== "off" && this.d.reviewBrief) {
3023
+ const t = valid(), enforce = mode === "enforce", list = (paths: string[]) => `${paths.slice(0, 20).join(", ")}${paths.length > 20 ? ", ..." : ""}`;
3024
+ // Code work (a commit, branch or PR) needs a brief; work with none of them is exempt and has nothing to account for.
3025
+ const code = !this.isPii(t) && !!(t.refs.commit || t.refs.branch || t.sdlc?.pr);
3026
+ // The commit under review: the task's, else the head the verdict names, else the lifecycle receipt's.
3027
+ const commit = t.refs.commit ?? (typeof head === "string" ? head : undefined) ?? t.sdlc?.reviewedHead ?? t.sdlc?.localHead;
3028
+ const brief: ReviewBrief | { unavailable: string } | undefined = !code ? undefined
3029
+ : !commit ? { unavailable: "the task links a branch or PR but names no commit" }
3030
+ : this.d.reviewBrief({ ...t, refs: { ...t.refs, commit } }) ?? { unavailable: "the review brief could not be built" };
3031
+ if (brief && "unavailable" in brief) {
3032
+ // An unknown inventory is never an empty one.
3033
+ if (enforce) throw new PreEffectToolRefusal(`coverage: the review brief is unavailable (${brief.unavailable}); an approval needs a complete brief of the task's commit`);
3034
+ note = `${note ?? ""}\nCoverage unknown: the review brief is unavailable (${brief.unavailable})`.trim();
3035
+ } else if (brief) {
3036
+ // One head: the brief's, the task's commit, and the head the verdict names or inherits from an earlier review (with a
3037
+ // PR, the lifecycle gate binds that head to the PR's).
3038
+ const effective = typeof head === "string" ? head : t.sdlc?.reviewedHead;
3039
+ if (effective !== undefined && effective !== brief.head && enforce) throw new PreEffectToolRefusal(`coverage: the verdict ${typeof head === "string" ? "names" : "inherits"} head ${effective}, but the review brief is for ${brief.head} (the commit under review)`);
3040
+ covered = { commit: t.refs.commit, receipt: JSON.stringify(t.sdlc ?? null) };
3041
+ const gap = coverageGap(brief, coverage);
3042
+ if (gap.length && enforce) throw new PreEffectToolRefusal(`coverage: ${gap.length} reviewable path(s) of the review brief are not accounted for: ${list(gap)}. Pass coverage {reviewed, skipped: [{path, reason}]}.`);
3043
+ if (gap.length) note = `${note ?? ""}\nCoverage gap (${gap.length}): ${list(gap)}`.trim();
3044
+ }
3045
+ }
2327
3046
  // The PII screen reads the note of a review that passes these checks (issue #198); they run again after it.
2328
3047
  const withheld = await this.withholds("review note", note, valid());
2329
3048
  let task = valid();
@@ -2345,7 +3064,12 @@ export class Tasks {
2345
3064
  if (cleared && context === JSON.stringify({ title: task.title, detail: task.detail, refs: task.refs, plan: task.plan, signals: task.signals })) candidate = input; // Async screening cannot authorize a verdict under an obsolete role/state.
2346
3065
  }
2347
3066
  const pii = this.isPii(task);
2348
- const flag = { withheld: withheld || undefined };
3067
+ const reviewContext = by === USER ? undefined : this.d.reviewContext?.(by, task.id);
3068
+ const poolRequest = task.history.findLast(entry => ["done", "reviewer changed", "reviewer unavailable"].includes(entry.event));
3069
+ const family = by === USER ? undefined : familyOf(by, this.d.instances?.());
3070
+ const reviewerFamily = poolRequest?.reviewerFamily === family ? family : undefined;
3071
+ const reviewerReason = reviewerFamily ? poolRequest?.reviewerReason : undefined;
3072
+ const flag = { withheld: withheld || undefined, ...(reviewContext ? { reviewContext } : {}), ...(reviewerFamily ? { reviewerFamily } : {}), ...(reviewerReason ? { reviewerReason } : {}) };
2349
3073
  if (verdict === "approved") {
2350
3074
  const next = this.d.board.update(task.id, by, "approved", { state: "approved", rejections: 0, ...(reviewedHead && task.sdlc ? { sdlc: { ...task.sdlc, reviewedHead, stage: lifecycleStage(this.d.sdlc?.config() ?? sdlcConfig(undefined), "merge") } } : {}) }, note, flag);
2351
3075
  if (next.owner) {
@@ -2473,13 +3197,25 @@ export class Tasks {
2473
3197
  if (role === "reviewer") {
2474
3198
  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`);
2475
3199
  this.ready(task, preEffect);
2476
- const a = assign(task, this.states(), this.d.routing(), { candidates: [], namedReviewer: peer, ...(task.owner ? { notReviewer: task.owner } : {}), ...this.weights(task.class) });
3200
+ const a = assign(task, this.states(), this.d.routing(), { candidates: [], namedReviewer: peer, ...(task.owner ? { notReviewer: task.owner } : {}), ...this.weights(task.class, task) });
2477
3201
  if (a.reviewer !== peer) throw new (preEffect ? PreEffectToolRefusal : Error)(`cannot name ${peer} as reviewer: ${a.trace.filter(line => line.includes("reviewer") || line.includes("pii")).join("; ")}`);
3202
+ const admission = task.state === "in_review" ? await this.reviewRequestAdmission(task, undefined, preEffect, by) : undefined;
3203
+ // Every runtime authority check precedes the final write, including revocations during the metadata await.
3204
+ const current = this.need(task.id, false, preEffect);
3205
+ if (current.history.length !== task.history.length) throw new (preEffect ? PreEffectToolRefusal : Error)("task changed during reviewer admission");
3206
+ if (by !== USER) {
3207
+ const holders = Object.entries(this.d.roles ?? {}).filter(([, roles]) => Array.isArray(roles) && roles.includes("conductor")).map(([actor]) => actor);
3208
+ if (holders.length !== 1 || holders[0] !== by) throw new (preEffect ? PreEffectToolRefusal : Error)("only the console user or the configured conductor may name a reviewer");
3209
+ }
3210
+ if (!this.d.bus.isRostered(peer)) throw new (preEffect ? PreEffectToolRefusal : Error)(`${peer} is off roster`);
3211
+ this.requireCapabilities(current, peer, role, preEffect);
3212
+ const eligible = assign(current, this.states(), this.d.routing(), { candidates: [], namedReviewer: peer, ...(current.owner ? { notReviewer: current.owner } : {}), ...this.weights(current.class, current) });
3213
+ if (eligible.reviewer !== peer) throw new (preEffect ? PreEffectToolRefusal : Error)("reviewer is no longer eligible");
2478
3214
  const next = this.d.board.update(task.id, by, "reviewer changed", { reviewer: peer }, this.assignmentNote(`named reviewer ${peer}`, a), { reason: "manual" }, expectedFlowRevision);
2479
3215
  if (next.state === "in_review" && task.reviewer && task.reviewer !== peer) {
2480
3216
  this.withdrawRoleRequest(task, next, "reviewer");
2481
3217
  }
2482
- if (next.state === "in_review" && peer !== USER) this.releaseNotice(`task ${this.publicTitle(next)}: could not send the review request to ${peer}`, () => this.sendReview(next, peer));
3218
+ if (next.state === "in_review" && peer !== USER) this.releaseNotice(`task ${this.publicTitle(next)}: could not send the review request to ${peer}`, () => this.sendReview(next, peer, "", admission));
2483
3219
  return next;
2484
3220
  }
2485
3221
  // Work that waits is handed to nobody yet (issue #207): the peer becomes its reserved owner instead.
@@ -2525,7 +3261,7 @@ export class Tasks {
2525
3261
  const routing = this.d.routing();
2526
3262
  return this.d.board.list().filter(task => !isClosedTaskState(task.state) && (task.owner === peer || task.reviewer === peer)).map(task => {
2527
3263
  const owner = task.owner === peer;
2528
- const weights = this.weights(task.class);
3264
+ const weights = this.weights(task.class, task);
2529
3265
  const pii = this.isPii(task);
2530
3266
  const candidates = owner ? relayCandidates(routing.classes[task.class]?.peers ?? [], pii, { ...this.instanceRouting(), states: this.states() }) : [];
2531
3267
  const a = assign(task, this.states(), routing, { ...weights, offRoster: [...(weights.offRoster ?? []), peer], exclude: [peer], excludeOwners: owner && this.plannerPullRecord(task)?.pull ? this.plannerFull(task) : [], candidates, ...(owner ? { waitsFor: this.waitsFor(task) } : task.owner ? { notReviewer: task.owner } : {}) });
@@ -2549,7 +3285,7 @@ export class Tasks {
2549
3285
  if (task.owner === peer && (OPEN.includes(task.state) || roster && task.state === "in_review")) {
2550
3286
  const pii = this.isPii(task);
2551
3287
  const candidates = relayCandidates(routing.classes[task.class]?.peers ?? [], pii, { ...this.instanceRouting(), states: this.states() });
2552
- if (this.plannerPullRecord(task)?.pull && !assign(task, this.states(), routing, { candidates, exclude: [...this.excluded(task), peer], excludeOwners: this.plannerFull(task), waitsFor: this.waitsFor(task), ...this.weights(task.class) }).owner) continue;
3288
+ if (this.plannerPullRecord(task)?.pull && !assign(task, this.states(), routing, { candidates, exclude: [...this.excluded(task), peer], excludeOwners: this.plannerFull(task), waitsFor: this.waitsFor(task), ...this.weights(task.class, task) }).owner) continue;
2553
3289
  // Budget keeps its original release/unassign behaviour for ordinary work. Roster changes the state in
2554
3290
  // assignOwner's same owner write only after routing has found somebody to take it.
2555
3291
  const back = !roster && task.state === "in_progress" ? this.d.board.update(task.id, HUB, "released", { state: "proposed" }, why) : task;
@@ -2570,13 +3306,13 @@ export class Tasks {
2570
3306
  }
2571
3307
  moved.push({ id: task.id, title: this.publicTitle(task), to: next.owner, role: "owner" });
2572
3308
  } else if (task.reviewer === peer && !isClosedTaskState(task.state)) {
2573
- const a = assign(task, this.states(), routing, { exclude: [peer], candidates: [], ...(task.owner ? { notReviewer: task.owner } : {}), ...this.weights(task.class) });
3309
+ const a = assign(task, this.states(), routing, { exclude: [peer], candidates: [], ...(task.owner ? { notReviewer: task.owner } : {}), ...this.weights(task.class, task) });
2574
3310
  const reviewer = a.reviewer && a.reviewer !== peer && a.reviewer !== task.owner ? a.reviewer : null;
2575
3311
  if (roster && !reviewer) {
2576
3312
  this.releaseNotice(`task ${this.publicTitle(task)}: its reviewer ${peer} is off roster and nobody else can review; the reviewer stays ${peer}; use ahub review ${task.id}`);
2577
3313
  continue;
2578
3314
  }
2579
- const next = this.d.board.update(task.id, HUB, "reviewer changed", { reviewer }, this.assignmentNote(why, a));
3315
+ const next = this.d.board.update(task.id, HUB, "reviewer changed", { reviewer }, this.assignmentNote(why, a), this.reviewMetadata(a));
2580
3316
  moved.push({ id: task.id, title: this.publicTitle(task), to: reviewer, role: "reviewer" });
2581
3317
  if (next.state === "in_review" && reviewer && reviewer !== USER) {
2582
3318
  if (roster) this.releaseNotice(`task ${this.publicTitle(next)}: could not send the review request to ${reviewer}`, () => this.sendReview(next, reviewer, `(${peer} was reviewing this and is off roster.)`));