@staix/agent-hub 0.12.35 → 0.12.36

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 (49) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +8 -6
  3. package/docs/agent-notes/adapters.md +4 -1
  4. package/docs/agent-notes/budget.md +1 -1
  5. package/docs/agent-notes/bus.md +3 -0
  6. package/docs/agent-notes/daemon.md +6 -2
  7. package/docs/agent-notes/tasks.md +11 -1
  8. package/docs/agent-notes/tests.md +5 -1
  9. package/docs/operations.md +40 -16
  10. package/docs/quickstart.md +6 -8
  11. package/docs/sdlc.md +46 -5
  12. package/docs/security.md +5 -1
  13. package/docs/specs/2026-09-19-agent-hub-design.md +76 -1
  14. package/docs/verification/2026-10-19-0.12.36.md +63 -0
  15. package/docs/verified.json +18 -14
  16. package/package.json +1 -1
  17. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  18. package/plugins/agent-hub/server.js +385 -29
  19. package/src/adapters/claude-print.ts +230 -0
  20. package/src/adapters/definitions/builtin-metadata.ts +17 -1
  21. package/src/adapters/definitions/native-cli-entry-claude.ts +17 -3
  22. package/src/adapters/definitions/native-cli-entry-jcode.ts +3 -2
  23. package/src/adapters/definitions/native-cli-entry-kimi.ts +3 -2
  24. package/src/adapters/definitions/native-cli-entry-types.ts +2 -0
  25. package/src/adapters/definitions/native-factories.ts +7 -0
  26. package/src/adapters/registry-types.ts +8 -2
  27. package/src/adapters/registry.ts +13 -1
  28. package/src/cli/help.ts +5 -0
  29. package/src/cli/identity.ts +2 -1
  30. package/src/cli/launch.ts +19 -6
  31. package/src/cli/main.ts +38 -4
  32. package/src/cli/reset.ts +4 -2
  33. package/src/cli/statusline-tee.ts +5 -2
  34. package/src/cli/terminal-recovery.ts +7 -2
  35. package/src/cli/upgrade-runtime.ts +18 -15
  36. package/src/hub/board.ts +29 -7
  37. package/src/hub/bus.ts +11 -5
  38. package/src/hub/claude-state.ts +37 -0
  39. package/src/hub/cost-governance.ts +76 -0
  40. package/src/hub/daemon.ts +181 -73
  41. package/src/hub/hub-tools.ts +20 -1
  42. package/src/hub/instances.ts +17 -3
  43. package/src/hub/review-brief.ts +175 -0
  44. package/src/hub/routing.ts +17 -2
  45. package/src/hub/sdlc.ts +205 -4
  46. package/src/hub/status-roster.ts +10 -0
  47. package/src/hub/task-sweep.ts +143 -0
  48. package/src/hub/tasks.ts +544 -26
  49. package/src/hub/usage.ts +57 -20
package/src/hub/tasks.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { cleanCostPlan, costMetrics, type CostPlan, type ReviewCost } from "./cost-governance.ts";
2
+ import { specRefs } from "./spec-read.ts";
1
3
  import type { InstancesConfig } from "./instances.ts";
2
4
  import { composeRememberText } from "./memory-fail.ts";
3
5
  import { VERSION } from "../version.ts";
@@ -16,7 +18,7 @@ import { isAbsolute, relative } from "node:path";
16
18
  import type { Briefs, NativeItem } from "../memory/brief.ts";
17
19
  import type { MemoryClient } from "../memory/client.ts";
18
20
  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";
21
+ import { CLASSES, OUTCOMES_KEPT_MS, ALL_PLAN_KEYS, TASK_MOVE_REASONS, taskCanPlan, taskHasWorkEvidence, taskHasStarted, planningUndoEntry, isClosedTaskState, type TaskStall, type HistoryEntry, type Board, type Task, type TaskClass, type TaskMoveReason, type TaskPlan, type TaskRefs } from "./board.ts";
20
22
  import type { Bus } from "./bus.ts";
21
23
  import { HUB, newEnvelope, NOTE_KINDS, noteLine, sanitize, USER, type Envelope, type PeerId, type PeerState } from "./envelope.ts";
22
24
  import type { PiiVerdict, ScreenItem, ScreenRecord } from "./inference.ts";
@@ -25,9 +27,10 @@ import { ExecutionBudget, type ExecutionBudgetConfig, type ExecutionBudgetDecisi
25
27
  import { Cohorts, MAX_REQUESTS, type Cohort, type Completion } from "./cohorts.ts";
26
28
  import { realPath } from "./project.ts";
27
29
  import { PreEffectToolRefusal } from "./tool-refusal.ts";
30
+ import { coverageGap, renderReviewBrief, type ReviewBrief } from "./review-brief.ts";
28
31
  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";
32
+ import { plannerGrooming, nextSweep, taskSweepConfig, type TaskSweepConfig, type SweepRecord } from "./task-sweep.ts";
33
+ 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
34
 
32
35
  export type TaskAssignmentRole = "owner" | "reviewer";
33
36
 
@@ -52,6 +55,8 @@ export interface TasksDeps {
52
55
  sessionRoles?: (peer: PeerId) => string[];
53
56
  /** Optional bounded snapshot presentation; never stored in task history or memory. */
54
57
  projectedReview?: (task: Task) => string | undefined;
58
+ /** #520: the review brief for a task's commit (git only, bounded); undefined when the task names no commit. */
59
+ reviewBrief?: (task: Task) => ReviewBrief | { unavailable: string } | undefined;
55
60
  sdlc?: { config: () => SdlcConfig; gh?: SdlcGh };
56
61
  briefs?: Briefs;
57
62
  /** Screened live fail notes directly intersecting the task plan or refs; read-only advice. */
@@ -60,6 +65,12 @@ export interface TasksDeps {
60
65
  candidateScreen?: MemoryScreen;
61
66
  /** Authenticated live turn context, not a model-supplied flag. */
62
67
  privateTurn?: (peer: PeerId) => boolean;
68
+ /** #223 R4: the hub's reading of a verdict's context (`fresh` only for a reviewer session started for this review). */
69
+ reviewContext?: (by: PeerId, task: number) => "fresh" | "continuing" | undefined;
70
+ /** #223 R2: a reviewer-only instance gets review facts, not the builder's done summary. */
71
+ reviewOnly?: (peer: PeerId) => boolean;
72
+ /** #223: captured when a verdict call starts; a non-undefined result refuses the verdict at every later check. */
73
+ reviewAuthority?: (by: PeerId, task: number) => (() => string | undefined) | undefined;
63
74
  memory?: MemoryClient;
64
75
  /** Captured native profile fence at our memory operation boundaries; no board mutation. */
65
76
  memoryCheck?: () => void;
@@ -93,6 +104,8 @@ export interface TasksDeps {
93
104
  failing?: () => Record<PeerId, string>;
94
105
  /** Peers whose queue a needs_review delivery holds, with the hold detail (issue #90); routing explains the hold, never rejects. */
95
106
  held?: () => Record<PeerId, string>;
107
+ /** Closed host facts only; approval text and quota reasons never enter the grooming projection. */
108
+ plannerBlocks?: () => Record<PeerId, ("approvals" | "quota")[]>;
96
109
  /** Roles from `.agenthub/config.json` (issue #92): peers with the reviewer role join the reviewer candidates. */
97
110
  roles?: Record<string, string[]>;
98
111
  /** Optional: name a class for a task proposed without one. `onCampus` says whether the model call stays on campus. */
@@ -213,7 +226,9 @@ function cleanRefs(input: unknown): TaskRefs {
213
226
  /** The same for a plan (issue #31): only its four lists, each of short strings. */
214
227
  function cleanPlan(input: unknown, profile: ProfileSelection = "dev"): TaskPlan {
215
228
  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));
229
+ let scope: CostPlan;
230
+ try { scope = cleanCostPlan(r); } catch { throw new PreEffectToolRefusal("invalid review or CI cost scope"); }
231
+ 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
232
  }
218
233
 
219
234
  /** A plan as one line for other owners; the whole plan is on the board. */
@@ -644,6 +659,11 @@ export class Tasks {
644
659
  if (by !== USER && !this.d.roles?.[by]?.includes("planner")) throw new PreEffectToolRefusal("planning requires a person or configured planner role");
645
660
  }
646
661
 
662
+ private plannerWriteAuthority(by: PeerId): void {
663
+ if (by !== USER && this.d.privateTurn?.(by)) throw new PreEffectToolRefusal("planner writes are unavailable during a private turn");
664
+ this.planningAuthority(by);
665
+ }
666
+
647
667
  wipLimit(peer: PeerId): number {
648
668
  const limit = this.d.wipLimit?.(peer) ?? 1;
649
669
  if (!Number.isSafeInteger(limit) || limit < 0 || limit > 32) throw new PreEffectToolRefusal("invalid planner WIP capacity");
@@ -681,7 +701,7 @@ export class Tasks {
681
701
  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
702
  const validate = () => {
683
703
  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");
704
+ 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
705
  const release = task.release == null ? undefined : this.d.board.release(task.release);
686
706
  if (task.release != null && (!release || ["frozen", "shipped", "dropped"].includes(release.state))) throw new PreEffectToolRefusal("planner pull requires unfrozen release scope");
687
707
  if (this.waitsFor(task).length) throw new PreEffectToolRefusal("planner pull waits for dependency approval");
@@ -719,6 +739,7 @@ export class Tasks {
719
739
  for (const id of ids) if (this.d.board.get(id)?.state === "backlog") pending.add(id);
720
740
  if (pending.size) this.plannerBacklog.set(peer, pending);
721
741
  }
742
+ private readonly groomingNotified = new Map<string, number>();
722
743
  /** One bounded ids-only batch per attached planner; failed or evicted copies stay pending. */
723
744
  flushPlannerNotices(): number {
724
745
  const configured = Object.entries(this.d.roles ?? {}).filter(([, roles]) => roles.includes("planner")).map(([peer]) => peer);
@@ -732,6 +753,18 @@ export class Tasks {
732
753
  }
733
754
  const planners = configured.filter(peer => this.d.bus.isRostered(peer) && this.d.bus.peers.has(peer) && this.d.bus.stateOf(peer) !== "offline");
734
755
  if (!planners.length) return 0;
756
+ if ((this.d.sdlc?.config().mode ?? "off") !== "off") {
757
+ const revision = this.d.board.statusRevision();
758
+ 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.";
759
+ for (const peer of planners) if (this.groomingNotified.get(peer) !== revision) {
760
+ const queued = this.d.bus.pendingEnvelopes(peer).some(env => env.from === HUB && env.kind === "task" && env.body === body);
761
+ if (queued) continue;
762
+ this.releaseNotice(`could not notify planner ${peer} about grooming`, () => {
763
+ const recipients = this.d.bus.publish(newEnvelope(HUB, body, { to: [peer], kind: "task" }));
764
+ if (recipients.includes(peer)) this.groomingNotified.set(peer, revision);
765
+ });
766
+ }
767
+ }
735
768
  for (const id of this.incomingBacklog) if (this.d.board.get(id)?.state !== "backlog") this.incomingBacklog.delete(id);
736
769
  const incoming = [...this.incomingBacklog].sort((a, b) => a - b).slice(0, 64);
737
770
  if (incoming.length) {
@@ -815,11 +848,14 @@ export class Tasks {
815
848
  const task = this.d.board.get(input.id);
816
849
  if (!task || (task.planningRevision ?? 0) !== input.revision) throw new PreEffectToolRefusal("planning revision changed");
817
850
  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");
851
+ if (by !== USER && (task.state !== "backlog" || !taskCanPlan(task) || task.pinned || Object.hasOwn(patch, "pinned"))) throw new PreEffectToolRefusal("planner changes only unpinned backlog work");
819
852
  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
853
  if (by !== USER && Object.hasOwn(patch, "reserved") && this.hasPersonReservation(task)) throw new PreEffectToolRefusal("a person's reservation is retained");
821
854
  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");
855
+ const returnedUntouched = task.state === "backlog" && !task.owner && taskHasStarted(task) && taskCanPlan(task);
856
+ if (!returnedUntouched) {
857
+ if (task.state !== "backlog" || taskHasStarted(task) || task.owner) throw new PreEffectToolRefusal("dependency and reservation changes require unstarted backlog work");
858
+ }
823
859
  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
860
  if (patch.reserved && !this.d.bus.isRostered(patch.reserved)) throw new PreEffectToolRefusal("reserved owner is off roster");
825
861
  if (patch.reserved) this.requireCapabilities(task, patch.reserved);
@@ -1011,6 +1047,12 @@ export class Tasks {
1011
1047
  if (external && (by !== USER || input.backlog !== true)) throw new PreEffectToolRefusal("external intake requires person-owned backlog staging");
1012
1048
  if (input.backlog !== undefined && typeof input.backlog !== "boolean") throw new PreEffectToolRefusal("backlog must be a boolean");
1013
1049
  const backlog = input.backlog === true;
1050
+ const claiming = !backlog && input.owner === by;
1051
+ if (claiming) {
1052
+ this.acceptanceCapacity({ id: -1, owner: by, state: "proposed" });
1053
+ this.pendingClaims.set(by, (this.pendingClaims.get(by) ?? 0) + 1);
1054
+ }
1055
+ try {
1014
1056
  if (backlog && input.owner && !this.d.bus.isRostered(input.owner)) throw new PreEffectToolRefusal(`${input.owner} is off roster; add it before reserving work`);
1015
1057
  // Callers are models: a title is one line (it is part of console and hub.log lines), not a document.
1016
1058
  const title = String(input.title ?? "").replace(/\s+/g, " ").trim().slice(0, 300);
@@ -1060,6 +1102,7 @@ export class Tasks {
1060
1102
  const defaulted = !backlog && !cls && input.owner === by;
1061
1103
  if (defaulted) cls = "implement";
1062
1104
  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"}`);
1105
+ if (claiming) this.acceptanceCapacity({ id: -1, owner: by, state: "proposed" }, 1);
1063
1106
  let selected;
1064
1107
  try { selected = taskRole(input.role, cls, this.d.catalog ?? loadRoleCatalog(this.d.cwd)); }
1065
1108
  catch (error) { throw new PreEffectToolRefusal((error as Error).message); }
@@ -1086,7 +1129,40 @@ export class Tasks {
1086
1129
  return this.d.board.update(task.id, HUB, "blocked", {}, `waits for ${still.map((id) => `#${id}`).join(", ")}`);
1087
1130
  }
1088
1131
  // 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 } : {});
1132
+ return await this.assignOwner(task, by, input.owner ? { candidates: [input.owner], namedOwner: input.owner, claim: input.owner === by } : {});
1133
+ } finally {
1134
+ if (claiming) {
1135
+ const remaining = (this.pendingClaims.get(by) ?? 1) - 1;
1136
+ if (remaining) this.pendingClaims.set(by, remaining); else this.pendingClaims.delete(by);
1137
+ }
1138
+ }
1139
+ }
1140
+
1141
+ async returnUntouchedToBacklog(by: PeerId, input: { id: number; revision: number; reason: string }): Promise<Task> {
1142
+ this.plannerWriteAuthority(by);
1143
+ if (!Number.isSafeInteger(input.id) || input.id < 1 || !Number.isSafeInteger(input.revision) || input.revision < 0
1144
+ || typeof input.reason !== "string" || !input.reason.trim() || input.reason.length > 1000) throw new PreEffectToolRefusal("untouched return needs an id, revision and bounded reason");
1145
+ const validate = () => {
1146
+ const task = this.need(input.id, false, true);
1147
+ 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");
1148
+ if (by !== USER && task.pinned) throw new PreEffectToolRefusal("pinned work requires the person");
1149
+ const release = task.release == null ? undefined : this.d.board.release(task.release);
1150
+ if (release && (["shipped", "dropped"].includes(release.state) || by !== USER && release.state === "frozen")) throw new PreEffectToolRefusal("settled or frozen release scope cannot return");
1151
+ return task;
1152
+ };
1153
+ const task = validate(), flow = task.history.length;
1154
+ const withheld = await this.withholds("note", input.reason, task);
1155
+ this.plannerWriteAuthority(by);
1156
+ const confirmed = validate();
1157
+ if (confirmed.history.length !== flow) throw new PreEffectToolRefusal("task changed during untouched return");
1158
+ 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);
1159
+ this.offered.delete(confirmed.id);
1160
+ this.cohorts.leave(confirmed.id);
1161
+ this.withdrawRoleRequest(confirmed, returned, "owner");
1162
+ this.withdrawRoleRequest(confirmed, returned, "reviewer");
1163
+ this.incomingBacklog.add(confirmed.id);
1164
+ this.planningRecorded({ by, task: confirmed.id, fields: ["return"], revision: confirmed.planningRevision ?? 0 });
1165
+ return returned;
1090
1166
  }
1091
1167
 
1092
1168
  /** Person-owned staging only. Flow transitions remain with their existing owners and reviewers. */
@@ -1480,7 +1556,8 @@ export class Tasks {
1480
1556
  }
1481
1557
 
1482
1558
  private assignmentNote(note: string, assignment: Assignment): string {
1483
- return assignment.sameFamilyFallback ? `${note}; sameFamilyFallback=true` : note;
1559
+ const tagged = assignment.sameFamilyFallback ? `${note}; sameFamilyFallback=true` : note;
1560
+ return assignment.plannerFallback ? `${tagged}; reviewer=planner (fallback)` : tagged;
1484
1561
  }
1485
1562
 
1486
1563
  /** A later move to another owner means the caller's assignment was superseded, so its success tail is not authoritative. */
@@ -1495,6 +1572,24 @@ export class Tasks {
1495
1572
  /** `override`: a person's explicit assign, which past refusals do not block and which drops an agent's reservation (#207). */
1496
1573
  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
1574
  if (task.state === "backlog") throw new PreEffectToolRefusal(`task #${task.id}: pull backlog work before assigning it`);
1575
+ const mode = (this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode;
1576
+ let stagedClaimGap: string | undefined;
1577
+ if (mode !== "off" && this.isBuildTask(task)) {
1578
+ const gap = task.sdlc?.planReview ? await this.planReviewGap(task) : "plan not reviewed since proposal";
1579
+ const current = this.d.board.get(task.id);
1580
+ if (!current || !this.ownerAssignmentCurrent(task, current)) return current ?? task;
1581
+ task = current; // Reviewer-only and quiet bookkeeping updates do not supersede ownership authority.
1582
+ if (gap) {
1583
+ this.releaseNotice(`task ${this.publicTitle(current)}: ${gap}; planner: call hub_planner_groom then hub_plan_review`);
1584
+ if (mode === "enforce") {
1585
+ if (opts.claim && opts.namedOwner === by) stagedClaimGap = gap;
1586
+ else {
1587
+ this.reportStall(current, { kind: "no-eligible-peer", code: "policy" });
1588
+ return current;
1589
+ }
1590
+ }
1591
+ }
1592
+ }
1498
1593
  const waits = this.waitsFor(task);
1499
1594
  const plannedMove = opts.planner || this.plannerPullRecord(task)?.pull === true && (by === HUB || opts.roster) && (opts.reason !== "manual" || opts.roster);
1500
1595
  const plannerFull = plannedMove ? this.plannerFull(task) : [];
@@ -1566,6 +1661,13 @@ export class Tasks {
1566
1661
  }
1567
1662
  // A claim is its own hand-over, with no delivery: its accepted write and its line stay as they were (#297).
1568
1663
  if (opts.claim && a.owner === by) {
1664
+ // #481 S1: a claim of build work starts it only from an accepted spec. In enforce the claim stays an assignment
1665
+ // (owner set, still proposed); hub_task_accept starts it once the person accepted the spec.
1666
+ if (this.specGated(next) && specState(next.sdlc, undefined) === "missing") {
1667
+ if (this.d.sdlc?.config().mode === "enforce") { this.releaseNotice(`${this.specText(next, "missing", "blocked")}; the claim stays assigned to ${by}`); return next; }
1668
+ this.releaseNotice(this.specText(next, "missing", "advice"));
1669
+ }
1670
+ if (stagedClaimGap) return next; // An explicit claim may reserve its owner, but no offer or acceptance bypasses review.
1569
1671
  const claimed = this.d.board.update(next.id, by, "accepted", { state: "in_progress" });
1570
1672
  this.releaseNotice(`task ${this.publicTitle(claimed)} claimed by ${by}`);
1571
1673
  return claimed;
@@ -1835,6 +1937,154 @@ export class Tasks {
1835
1937
  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
1938
  }
1837
1939
 
1940
+ private planIssueIdentity(task: Task): { repo: string; issue: number } | undefined {
1941
+ const source = task.external ?? task.sdlc;
1942
+ if (source?.repo && source.issue) return { repo: source.repo, issue: source.issue };
1943
+ const repository = task.refs.repo ?? this.d.repository ?? "";
1944
+ let issues: number[];
1945
+ try { issues = specRefs(task, repository); }
1946
+ catch { throw new PreEffectToolRefusal("plan review issue identity unavailable"); }
1947
+ if (issues.length > 1) throw new PreEffectToolRefusal("plan review needs one explicit issue identity");
1948
+ return issues[0] ? { repo: repository, issue: issues[0] } : undefined;
1949
+ }
1950
+
1951
+ async groomingDigest(by: PeerId): Promise<Record<string, unknown>> {
1952
+ this.planningAuthority(by);
1953
+ const included = (task: Task) => !isClosedTaskState(task.state) || task.state === "cancelled" && task.history.some(entry => !!entry.supersededBy);
1954
+ const snapshot = this.d.board.list().filter(included), issues = new Map<string, PlanIssueMetadata>(), attempted = new Set<string>();
1955
+ const deadline = performance.now() + 4_000;
1956
+ let reads = 0, incomplete = false;
1957
+ // ponytail: at most eight metadata reads within four seconds; add paginated revision-bound readback for larger boards.
1958
+ for (const listed of snapshot) {
1959
+ this.planningAuthority(by);
1960
+ const task = this.d.board.get(listed.id);
1961
+ if (!task || this.isPii(task) || isClosedTaskState(task.state)) continue;
1962
+ let source: { repo: string; issue: number } | undefined;
1963
+ try { source = this.planIssueIdentity(task); } catch { continue; }
1964
+ if (!source) continue;
1965
+ const key = `${source.repo.toLowerCase()}#${source.issue}`;
1966
+ if (attempted.has(key)) continue;
1967
+ attempted.add(key);
1968
+ if (reads >= 8 || performance.now() >= deadline) { incomplete = true; break; }
1969
+ reads++;
1970
+ const run = this.d.sdlc?.gh ?? sdlcGh;
1971
+ 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));
1972
+ if (issue) issues.set(key, issue);
1973
+ }
1974
+ this.planningAuthority(by);
1975
+ const current = this.d.board.list().filter(included), visible = current.filter(task => !this.isPii(task));
1976
+ const peers = [...new Set([...Object.keys(this.states()), ...current.flatMap(task => task.owner ? [task.owner] : [])])];
1977
+ const reviewedIssues = new Map(visible.flatMap(task => task.sdlc?.planReview?.issue ? [[task.id, task.sdlc.planReview.issue] as const] : []));
1978
+ const acceptedIssues = new Map(visible.flatMap(task => task.sdlc?.acceptance ? [[task.id, task.sdlc.acceptance] as const] : []));
1979
+ const blockers: { task: number; code: string }[] = this.d.board.stalls().map(stall => ({ task: stall.task, code: stall.cause.code }));
1980
+ const health = this.health(), states = this.states(), routing = this.d.routing(), hostBlocks = this.d.plannerBlocks?.() ?? {};
1981
+ for (const task of visible.filter(task => !isClosedTaskState(task.state)).slice(0, 500)) {
1982
+ const owner = task.owner ?? task.reserved;
1983
+ if (owner) for (const code of peerEntry(hostBlocks, owner) ?? []) blockers.push({ task: task.id, code });
1984
+ const result = assign(task.state === "backlog" ? { ...task, state: "proposed" } : task, states, routing, { ...health, waitsFor: this.waitsFor(task), ...(owner ? { candidates: [owner], namedOwner: owner } : {}) });
1985
+ 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 });
1986
+ if (this.waitsFor(task).length) blockers.push({ task: task.id, code: "dependency" });
1987
+ }
1988
+ const digest = plannerGrooming(visible, { repository: this.d.repository ?? `path:${realPath(this.d.cwd)}`,
1989
+ limits: Object.fromEntries(peers.map(peer => [peer, this.wipLimit(peer)])), wip: Object.fromEntries(peers.map(peer => [peer, this.wip(peer)])),
1990
+ states, issues, reviewedIssues, acceptedIssues, blockers });
1991
+ return { ...digest, truncated: digest.truncated || incomplete, ...(current.some(task => this.isPii(task)) ? { privateTasks: "withheld" } : {}) };
1992
+ }
1993
+
1994
+ /** S10 read-only planner report; S7 owns rolling bands and automatic board proposals. */
1995
+ async costReport(by: PeerId, repository?: unknown): Promise<Record<string, unknown>> {
1996
+ const authority = () => {
1997
+ this.planningAuthority(by);
1998
+ if (by !== USER && this.d.privateTurn?.(by)) throw new PreEffectToolRefusal("cost report unavailable during private work");
1999
+ };
2000
+ authority();
2001
+ const repo = repository ?? this.d.repository;
2002
+ 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");
2003
+ const deadline = performance.now() + 20_000;
2004
+ const bounded = (args: string[], timeout?: number, cwd?: string, cap?: number) => performance.now() < deadline
2005
+ ? (this.d.sdlc?.gh ?? sdlcGh)(args, Math.max(1, Math.min(timeout ?? 10_000, deadline - performance.now())), cwd, cap)
2006
+ : Promise.resolve({ code: 1, stdout: "" });
2007
+ const linked = () => this.d.board.list().filter(task => task.sdlc?.repo?.toLowerCase() === repo.toLowerCase() && Number.isSafeInteger(task.sdlc.pr) && task.sdlc.pr! > 0);
2008
+ const ids = [...new Set(linked().filter(task => !this.isPii(task)).map(task => task.sdlc!.pr!))].sort((a, b) => a - b);
2009
+ const observations: import("./cost-governance.ts").CostPr[] = [];
2010
+ let incomplete = ids.length > 16;
2011
+ for (const pr of ids.slice(0, 16)) {
2012
+ authority();
2013
+ if (linked().some(task => task.sdlc?.pr === pr && this.isPii(task))) continue;
2014
+ if (performance.now() >= deadline) { incomplete = true; break; }
2015
+ const evidence = await readPrCostHistory(repo, pr, (args, timeout, cwd, cap) => {
2016
+ authority();
2017
+ const fresh = linked().filter(task => task.sdlc?.pr === pr);
2018
+ if (!fresh.length || fresh.some(task => this.isPii(task))) return Promise.resolve({ code: 1, stdout: "" });
2019
+ return bounded(args, timeout, cwd, cap);
2020
+ });
2021
+ if (evidence) observations.push(evidence); else incomplete = true;
2022
+ }
2023
+ authority();
2024
+ const current = linked();
2025
+ const visiblePrs = new Set(current.filter(task => !this.isPii(task)).map(task => task.sdlc!.pr!));
2026
+ for (const task of current) if (this.isPii(task)) visiblePrs.delete(task.sdlc!.pr!);
2027
+ if (JSON.stringify([...visiblePrs].sort((a, b) => a - b)) !== JSON.stringify(ids)) incomplete = true;
2028
+ 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)));
2029
+ return { ...report, truncated: report.truncated || incomplete, ...(current.some(task => this.isPii(task)) ? { privateTasks: "withheld" } : {}), bands: "S7 pending" };
2030
+ }
2031
+
2032
+ async planReviewGap(task: Task): Promise<string | undefined> {
2033
+ const receipt = task.sdlc?.planReview;
2034
+ if (!receipt) return "plan not reviewed since proposal";
2035
+ if (!receipt.planner || !Number.isFinite(receipt.recordedAt) || receipt.planningRevision !== (task.planningRevision ?? 0)) return "plan not reviewed since planning revision change";
2036
+ if (receipt.scope && JSON.stringify(receipt.scope) !== JSON.stringify(cleanCostPlan(task.plan ?? {}))) return "plan not reviewed since cost scope change";
2037
+ const identity = this.planIssueIdentity(task);
2038
+ if (identity?.issue && this.isPii(task)) return "private issue plan review evidence unavailable";
2039
+ if (!identity?.issue) return receipt.issue ? "plan not reviewed since issue identity change" : undefined;
2040
+ 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";
2041
+ const current = await readPlanIssueMetadata(identity.repo, identity.issue, this.d.sdlc?.gh ?? sdlcGh);
2042
+ if (!current) return "plan review issue metadata unavailable";
2043
+ if (current.state !== "OPEN") return "plan review issue is closed";
2044
+ return current.editedAt !== receipt.issue.editedAt ? "plan not reviewed since issue edit" : undefined;
2045
+ }
2046
+
2047
+ /** Planner authority and issue edit evidence are rechecked across every external await. */
2048
+ async recordPlanReview(by: PeerId, id: unknown, input: unknown): Promise<Task> {
2049
+ this.plannerWriteAuthority(by);
2050
+ if (!input || typeof input !== "object" || Array.isArray(input)) throw new PreEffectToolRefusal("plan review needs a revision and reason");
2051
+ const raw = input as Record<string, unknown>;
2052
+ if (Object.keys(raw).some(key => !["revision", "reason", "review", "ci"].includes(key)) || !Number.isSafeInteger(raw.revision) || (raw.revision as number) < 0
2053
+ || typeof raw.reason !== "string" || !raw.reason.trim() || raw.reason.length > 1000) throw new PreEffectToolRefusal("plan review needs a current revision and bounded reason");
2054
+ const task = this.need(id, false, true, true), revision = raw.revision as number;
2055
+ if (isClosedTaskState(task.state)) throw new PreEffectToolRefusal("closed work is final");
2056
+ if ((task.planningRevision ?? 0) !== revision) throw new PreEffectToolRefusal("planning revision changed");
2057
+ let scope: CostPlan;
2058
+ try { scope = cleanCostPlan({ ...task.plan, ...("review" in raw ? { review: raw.review } : {}), ...("ci" in raw ? { ci: raw.ci } : {}) }); }
2059
+ catch { throw new PreEffectToolRefusal("invalid review or CI cost scope"); }
2060
+ const mode = (this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode;
2061
+ if (!scope.review || !scope.ci) {
2062
+ if (mode === "enforce") throw new PreEffectToolRefusal("review and CI cost scope required for a new plan receipt");
2063
+ if (mode === "advise") this.releaseNotice(`task ${this.publicTitle(task)}: cost scope unspecified`);
2064
+ }
2065
+ const flow = task.history.length, withheld = await this.withholds("note", raw.reason, task);
2066
+ this.plannerWriteAuthority(by);
2067
+ const observed = this.need(task.id, false, true, true);
2068
+ if (observed.history.length !== flow || (observed.planningRevision ?? 0) !== revision) throw new PreEffectToolRefusal("plan review changed during readback");
2069
+ const identity = this.planIssueIdentity(observed);
2070
+ if (identity?.issue && this.isPii(observed)) throw new PreEffectToolRefusal("private issue plan review readback unavailable");
2071
+ const issue = identity?.repo && identity.issue ? await readPlanIssueMetadata(identity.repo, identity.issue, this.d.sdlc?.gh ?? sdlcGh) : undefined;
2072
+ if (identity?.issue && !issue) throw new PreEffectToolRefusal("plan review issue metadata unavailable");
2073
+ if (issue && !this.isPii(task) && !this.nameable(JSON.stringify(issue))) throw new PreEffectToolRefusal("plan review metadata matches a PII pattern");
2074
+ this.plannerWriteAuthority(by);
2075
+ const current = this.need(task.id, false, true, true);
2076
+ if (current.history.length !== flow || (current.planningRevision ?? 0) !== revision) throw new PreEffectToolRefusal("plan review changed during readback");
2077
+ const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
2078
+ 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"),
2079
+ planReview: { planner: by, planningRevision: revision, recordedAt: Date.now(), ...(Object.keys(scope).length ? { scope } : {}), ...(issue ? { issue } : {}) } } }, raw.reason.trim(), { ...(withheld ? { withheld: true } : {}) }, flow);
2080
+ if (updated.state === "proposed" && !updated.owner) {
2081
+ this.offered.delete(updated.id);
2082
+ try { await this.offerReady(updated, "planner review recorded"); }
2083
+ catch { this.releaseNotice(`task #${updated.id}: plan review saved; inspect its owner delivery before retrying`); }
2084
+ }
2085
+ return this.d.board.get(updated.id) ?? updated;
2086
+ }
2087
+
1838
2088
  /** Artifact links and claimed local gates stay on the board; no outside text is accepted. */
1839
2089
  recordLifecycle(by: PeerId, id: unknown, input: unknown): Task {
1840
2090
  const task = this.need(id, false, true);
@@ -1842,6 +2092,8 @@ export class Tasks {
1842
2092
  const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
1843
2093
  const next = sdlcReceipt(input, task.sdlc, config, VERSION);
1844
2094
  const definition = stageDefinition(config, next.stage);
2095
+ // #481 S1: entering a build stage in enforce needs the person's spec acceptance; the person may move any stage.
2096
+ 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
2097
  if (config.mode === "enforce" && config.definitions && input && typeof input === "object" && "stage" in input && next.stage !== task.sdlc?.stage && by !== USER) {
1846
2098
  const actor = definition?.actor;
1847
2099
  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"));
@@ -1900,20 +2152,93 @@ export class Tasks {
1900
2152
  async lifecycleStatus(id: unknown, refresh = true): Promise<SdlcStatus> {
1901
2153
  const task = this.need(id), config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
1902
2154
  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));
2155
+ const accepted = task.sdlc?.acceptance;
2156
+ const edit = accepted && !this.isPii(task) && refresh && config.mode !== "off" ? await readSpecEdit(accepted.repo, accepted.issue, this.d.sdlc?.gh) : undefined;
2157
+ return sdlcStatus(config, task.sdlc, evidence, this.workflowView(task), edit);
2158
+ }
2159
+
2160
+ /** #481 S1: only build work starts from an accepted spec. */
2161
+ private specGated(task: Task): boolean {
2162
+ return (this.d.sdlc?.config().mode ?? "off") !== "off" && (task.class === "implement" || task.class === "bulk_edit");
2163
+ }
2164
+ private specText(task: Task, state: Exclude<SpecState, "current">, verb: "blocked" | "advice"): string {
2165
+ const issue = task.sdlc?.issue;
2166
+ 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";
2167
+ return `task #${task.id}: SDLC build entry ${verb}: ${why}`;
1904
2168
  }
1905
2169
 
1906
- private async lifecycleGate(task: Task, transition: "review" | "approval", reviewedHead?: string): Promise<void> {
2170
+ /** The person's acceptance of the task's spec issue at its current body (#481 S1); identity and edit times only. */
2171
+ acceptSpec(by: PeerId, id: unknown, spec: { repo?: unknown; issue: unknown; edit: SpecEdit }): Task {
2172
+ if (by !== USER) throw new PreEffectToolRefusal("spec acceptance is the person's: run ahub sdlc accept in a terminal");
2173
+ const task = this.need(id, false, true, true), config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
2174
+ const repo = spec.repo ?? task.sdlc?.repo;
2175
+ 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`);
2176
+ const next = sdlcReceipt({ repo, issue: spec.issue }, task.sdlc, config);
2177
+ if (!next.repo || !next.issue) throw new PreEffectToolRefusal("spec acceptance needs the repository (owner/repository) and the issue number");
2178
+ next.acceptance = { repo: next.repo, issue: next.issue, lastEditedAt: spec.edit.lastEditedAt, updatedAt: spec.edit.updatedAt, acceptedAt: Date.now() };
2179
+ return this.d.board.update(task.id, USER, "spec accepted", { sdlc: next }, `issue #${next.issue}, body last edited ${spec.edit.lastEditedAt ?? "never"}`);
2180
+ }
2181
+
2182
+ /** Build entry with a live readback of the spec's body edit time (#481 S1): enforce refuses, advise notifies, off does nothing. */
2183
+ async specGate(by: PeerId, id: unknown): Promise<void> {
2184
+ const task = this.need(id, false, true);
2185
+ if (!this.specGated(task) || task.owner !== by && by !== USER) return; // accept reports its own refusals
2186
+ const accepted = task.sdlc?.acceptance;
2187
+ const edit = accepted && !this.isPii(task) ? await readSpecEdit(accepted.repo, accepted.issue, this.d.sdlc?.gh) : undefined;
2188
+ if (this.specMoved(task)) return;
2189
+ this.specVerdict(task, task.sdlc?.issue ? specState(task.sdlc, edit) : "missing");
2190
+ }
2191
+
2192
+ /** #481 S1: a build stage change by an agent re-reads the body edit time first, as accept does. */
2193
+ async specStageGate(by: PeerId, id: unknown, input: unknown): Promise<void> {
2194
+ const task = this.need(id, false, true), config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
2195
+ if (config.mode === "off" || by === USER || !input || typeof input !== "object" || !("stage" in input)) return;
2196
+ let next: ReturnType<typeof sdlcReceipt>;
2197
+ try { next = sdlcReceipt(input, task.sdlc, config, VERSION); } catch { return; } // recordLifecycle reports an invalid receipt
2198
+ const definition = stageDefinition(config, next.stage);
2199
+ if (next.stage === task.sdlc?.stage || !definition || canonicalStage(definition.base) !== "build") return;
2200
+ const accepted = next.acceptance;
2201
+ const edit = accepted && !this.isPii(task) ? await readSpecEdit(accepted.repo, accepted.issue, this.d.sdlc?.gh) : undefined;
2202
+ if (this.specMoved(task)) return;
2203
+ this.specVerdict(task, next.issue ? specState(next, edit) : "missing");
2204
+ }
2205
+
2206
+ /**
2207
+ * The task a spec readback started from changed while it was awaited (flow, link, acceptance, owner or privacy): the
2208
+ * reading belongs to other work. Enforce refuses before any write; advise drops the stale advice.
2209
+ */
2210
+ private specMoved(before: Task): boolean {
2211
+ const now = this.need(before.id, false, true);
2212
+ if (now.history.length === before.history.length && now.owner === before.owner && this.isPii(now) === this.isPii(before)
2213
+ && JSON.stringify(now.sdlc ?? null) === JSON.stringify(before.sdlc ?? null)) return false;
2214
+ 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`);
2215
+ return true;
2216
+ }
2217
+ private specVerdict(task: Task, state: SpecState): void {
2218
+ if (state === "current") return;
2219
+ if (this.d.sdlc?.config().mode === "enforce") throw new PreEffectToolRefusal(this.specText(task, state, "blocked"));
2220
+ this.d.notify(this.specText(task, state, "advice"));
2221
+ }
2222
+
2223
+ private async lifecycleGate(task: Task, transition: "review" | "approval", reviewedHead?: string): Promise<PrEvidence | undefined> {
1907
2224
  const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
1908
2225
  if (config.mode === "off") return;
1909
2226
  const receipt = task.sdlc && { ...task.sdlc, ...(reviewedHead ? { reviewedHead } : {}) };
1910
- const evidence = receipt && !this.isPii(task) ? await readSdlcPr(receipt, this.d.sdlc?.gh) : undefined;
2227
+ const read: SdlcGh = (args, timeout, cwd, cap) => {
2228
+ const current = this.need(task.id, false, true);
2229
+ 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: "" });
2230
+ return (this.d.sdlc?.gh ?? sdlcGh)(args, timeout, cwd, cap);
2231
+ };
2232
+ const evidence = receipt && !this.isPii(task) ? await readSdlcPr(receipt, read) : undefined;
1911
2233
  const current = this.need(task.id, false, true);
1912
2234
  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`);
2235
+ // Advise may continue on the current task, but evidence from its previous link cannot admit that task's review.
2236
+ 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
2237
  const status = sdlcStatus(config, receipt, evidence, this.workflowView(current));
1914
2238
  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
2239
  if (config.mode === "enforce" && blockers.length) throw new PreEffectToolRefusal(`task #${task.id}: SDLC ${transition} blocked: ${blockers.join("; ")}`);
1916
2240
  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("; ")}`);
2241
+ return evidence;
1917
2242
  }
1918
2243
 
1919
2244
  private need(id: unknown, open = false, preEffect = false, allowBacklog = false): Task {
@@ -1928,6 +2253,62 @@ export class Tasks {
1928
2253
  return task;
1929
2254
  }
1930
2255
 
2256
+ private readonly pendingClaims = new Map<PeerId, number>();
2257
+ private readonly reviewedAdmissions = new Map<number, { flow: number; owner: string | null }>();
2258
+ private isBuildTask(task: Task): boolean {
2259
+ const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
2260
+ if (["implement", "bulk_edit"].includes(task.class)) return true;
2261
+ const base = task.sdlc ? stageDefinition(config, task.sdlc.stage)?.base : undefined;
2262
+ return base === "build" || base === "implement";
2263
+ }
2264
+ async acceptReviewed(by: PeerId, id: unknown, plan?: unknown): Promise<Task> {
2265
+ const task = this.need(id, false, true);
2266
+ this.mine(task, by, "owner", true);
2267
+ this.acceptanceCapacity(task);
2268
+ if ((this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode === "off" || !this.isBuildTask(task)) return this.accept(by, id, plan);
2269
+ await this.specGate(by, id);
2270
+ await this.planEntryGate(by, task);
2271
+ const current = this.need(id, false, true);
2272
+ this.mine(current, by, "owner", true);
2273
+ if (current.history.length !== task.history.length || current.owner !== task.owner) throw new PreEffectToolRefusal("task changed during build admission");
2274
+ this.reviewedAdmissions.set(current.id, { flow: current.history.length, owner: current.owner });
2275
+ try { return this.accept(by, id, plan); }
2276
+ finally { this.reviewedAdmissions.delete(current.id); }
2277
+ }
2278
+
2279
+ /** Spec and plan readbacks share the caller's original task snapshot until the final acceptance write. */
2280
+ private async planEntryGate(by: PeerId, task: Task): Promise<void> {
2281
+ const mode = (this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode;
2282
+ if (mode === "off" || !this.isBuildTask(task)) return;
2283
+ const gap = await this.planReviewGap(task);
2284
+ if (mode === "advise" && !task.sdlc?.planReview?.scope) this.releaseNotice(`task ${this.publicTitle(task)}: cost scope unspecified`);
2285
+ const current = this.need(task.id, false, true);
2286
+ this.mine(current, by, "owner", true);
2287
+ if (current.history.length !== task.history.length || current.owner !== task.owner) throw new PreEffectToolRefusal("task changed during plan readback");
2288
+ if (gap && mode === "enforce") throw new PreEffectToolRefusal(gap);
2289
+ if (gap) this.releaseNotice(`task ${this.publicTitle(current)}: ${gap}`);
2290
+ }
2291
+
2292
+ private acceptanceCapacity(task: Pick<Task, "id" | "owner" | "reserved" | "state"> & Partial<Pick<Task, "history">>, excludePending = 0): void {
2293
+ const owner = task.owner ?? task.reserved;
2294
+ if (!owner) return;
2295
+ if ((task.state === "in_progress" || task.state === "changes_requested") && task.history) {
2296
+ let boundary = 0, previous: PeerId | null | undefined;
2297
+ task.history.forEach((entry, index) => {
2298
+ if (entry.owner !== undefined) {
2299
+ if (entry.owner !== previous) boundary = index;
2300
+ previous = entry.owner;
2301
+ } else if (OWNERSHIP_EVENTS.has(entry.event)) { boundary = index; previous = undefined; }
2302
+ });
2303
+ if (task.history.slice(boundary).some(entry => entry.event === "accepted" && (entry.by === owner || entry.by === USER))) return;
2304
+ }
2305
+ const limit = this.wipLimit(owner), pending = Math.max(0, (this.pendingClaims.get(owner) ?? 0) - excludePending);
2306
+ if (this.wip(owner, task.id) + pending < limit) return;
2307
+ const open = this.d.board.list().filter(other => other.id !== task.id && !["backlog", "approved", "cancelled"].includes(other.state)
2308
+ && (other.owner === owner || !other.owner && other.reserved === owner && this.plannerPullRecord(other)?.pull)).map(other => `#${other.id}`);
2309
+ throw new PreEffectToolRefusal(`planner WIP capacity for ${owner}: limit ${limit}; open tasks ${open.join(", ") || "none"}${pending ? `; pending claims ${pending}` : ""}`);
2310
+ }
2311
+
1931
2312
  /**
1932
2313
  * With a plan, the owners of overlapping tasks get it as a ride-along line, and an overlap only the plan reveals is
1933
2314
  * announced like one found at assignment (issue #31).
@@ -1937,9 +2318,24 @@ export class Tasks {
1937
2318
  this.mine(task, by, "owner", true);
1938
2319
  this.ready(task, true);
1939
2320
  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`);
2321
+ this.acceptanceCapacity(task);
2322
+ // S1 spec acceptance and S9 plan review are independent prerequisites; retain S1's missing-spec refusal first.
2323
+ if (this.specGated(task) && this.d.sdlc?.config().mode === "enforce" && specState(task.sdlc, undefined) === "missing") throw new PreEffectToolRefusal(this.specText(task, "missing", "blocked"));
2324
+ if ((this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode === "enforce" && this.isBuildTask(task)) {
2325
+ const readback = this.reviewedAdmissions.get(task.id);
2326
+ this.reviewedAdmissions.delete(task.id);
2327
+ if (!readback || readback.flow !== task.history.length || readback.owner !== task.owner) throw new PreEffectToolRefusal("current plan-review readback required before acceptance");
2328
+ }
1940
2329
  // Models send null or {} for an optional field they leave empty: neither replaces a plan.
1941
2330
  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;
2331
+ // File-list intent still replaces the old lists; omitted budget keys retain the planner-reviewed scope.
2332
+ const cleaned = given && Object.keys(given).length ? { ...cleanCostPlan(task.plan ?? {}), ...given } : undefined;
2333
+ if (this.d.sdlc?.config().mode === "enforce" && task.sdlc?.planReview && !task.sdlc.planReview.scope
2334
+ && given && Object.keys(cleanCostPlan(given)).length) throw new PreEffectToolRefusal("legacy plan cost scope is unspecified; record a scoped planner review before acceptance");
2335
+ if (this.d.sdlc?.config().mode === "enforce" && task.sdlc?.planReview?.scope
2336
+ && JSON.stringify(cleanCostPlan(cleaned ?? task.plan ?? {})) !== JSON.stringify(task.sdlc.planReview.scope)) {
2337
+ throw new PreEffectToolRefusal("plan cost scope changed; record a new planner review before acceptance");
2338
+ }
1943
2339
  // A task's signals are fixed when it is proposed: text that matches a PII pattern cannot be let in afterwards.
1944
2340
  if (cleaned && !this.isPii(task) && this.isPii({ signals: this.signalsFor({ title: "", detail: planText(cleaned), refs: {}, plan: cleaned }) })) {
1945
2341
  throw new PreEffectToolRefusal("this plan matches a PII pattern and is not kept: other owners would see it");
@@ -2029,6 +2425,7 @@ export class Tasks {
2029
2425
  this.mine(t, by, "owner", true);
2030
2426
  if (t.state === "in_review" || isClosedTaskState(t.state)) throw new PreEffectToolRefusal(`task #${t.id} is already ${t.state}`);
2031
2427
  this.ready(t, true);
2428
+ if (t.state === "proposed" || t.state === "changes_requested") this.acceptanceCapacity(t);
2032
2429
  if (this.checking.has(t.id)) {
2033
2430
  // The result goes only to the owner the check was started for: anyone who took the task since hears nothing.
2034
2431
  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 +2435,17 @@ export class Tasks {
2038
2435
  // The PII screen reads the summary of a done that passes these checks (issue #198); they run again after it.
2039
2436
  const withheld = await this.withholds("summary", summary, valid());
2040
2437
  let task = valid();
2041
- if (this.d.sdlc && this.d.sdlc.config().mode !== "off") { await this.lifecycleGate(task, "review"); task = valid(); }
2438
+ // #481 S1: done without a separate accept starts build work too: the same live spec check as accept, before any write.
2439
+ if (task.state === "proposed" || task.state === "changes_requested") {
2440
+ const admission = task;
2441
+ await this.specGate(by, task.id);
2442
+ await this.planEntryGate(by, admission);
2443
+ task = valid();
2444
+ }
2445
+ let reviewEvidence: PrEvidence | undefined;
2446
+ if (this.d.sdlc && this.d.sdlc.config().mode !== "off") { reviewEvidence = await this.lifecycleGate(task, "review"); task = valid(); }
2447
+ const reviewAdmission = task.reviewer ? await this.reviewRequestAdmission(task, refs, true, by, reviewEvidence) : undefined;
2448
+ task = valid();
2042
2449
  // A member of a silent cohort (issue #107): its done is an intent, and the last of them integrates first.
2043
2450
  const cohort = this.cohorts.of(task.id);
2044
2451
  const silentMember = !!cohort?.silent && this.turnFree() && !!task.owner && !this.isPii(task);
@@ -2069,7 +2476,7 @@ export class Tasks {
2069
2476
  }
2070
2477
  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
2478
  const command = this.d.runCheck ? this.d.check?.(task.class, task) : undefined;
2072
- if (!command) return this.complete(task, by, summary, refs, "", withheld, "review");
2479
+ if (!command) return this.complete(task, by, summary, refs, "", withheld, "review", reviewAdmission);
2073
2480
  // The tool call returns now; a check can outlast an agent's tool timeout. The result decides what comes next.
2074
2481
  this.checking.set(task.id, task.owner);
2075
2482
  const pending = this.d.board.update(task.id, by, "done (checking)", { refs: cleanRefs(refs) }, summary, { withheld: withheld || undefined });
@@ -2196,17 +2603,27 @@ export class Tasks {
2196
2603
  }
2197
2604
 
2198
2605
  /** `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;
2606
+ private async complete(task: Task, by: PeerId, summary?: string, refs?: TaskRefs, checkOutput = "", withheld = false, gated?: "review", admission?: ReviewReadiness): Promise<Task> {
2607
+ let reviewer = task.reviewer, reviewEvidence: PrEvidence | undefined;
2201
2608
  if (this.d.sdlc && this.d.sdlc.config().mode !== "off" && gated !== (reviewer ? "review" : "approval")) {
2202
- await this.lifecycleGate(task, reviewer ? "review" : "approval");
2609
+ reviewEvidence = await this.lifecycleGate(task, reviewer ? "review" : "approval");
2203
2610
  task = this.need(task.id, false, true);
2204
2611
  this.mine(task, by, "owner", true);
2205
2612
  // In every mode: another done may have landed during the readback.
2206
2613
  if (task.state === "in_review" || isClosedTaskState(task.state)) throw new Error(`task #${task.id} is already ${task.state}`);
2207
2614
  reviewer = task.reviewer;
2208
2615
  }
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 });
2616
+ // A delayed local check needs a new readback; the immediate done path already admitted before effects.
2617
+ if (reviewer && !admission && (this.d.sdlc?.config().mode ?? "off") !== "off") {
2618
+ const observed = task;
2619
+ admission = await this.reviewRequestAdmission(task, refs, false, by, reviewEvidence);
2620
+ task = this.need(task.id);
2621
+ this.mine(task, by, "owner");
2622
+ if (task.history.length !== observed.history.length || task.state === "in_review" || isClosedTaskState(task.state)) throw new Error("task changed during review admission");
2623
+ }
2624
+ const reviewCost: ReviewCost | undefined = reviewer && task.plan && (task.plan.review || task.plan.ci)
2625
+ ? { ...cleanCostPlan(task.plan), ...(admission?.ready && admission.head ? { head: admission.head } : {}) } : undefined;
2626
+ 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, ...(reviewCost ? { reviewCost } : {}) });
2210
2627
  this.cohorts.closed(next.id); // its owner's next native turn end settles it in its cohort (issue #107)
2211
2628
  this.note(next, by, "finding", `Task #${next.id} done by ${by}: ${next.title}\n${summary ?? ""}`, withheld);
2212
2629
  this.tellCompleted(next, summary, withheld);
@@ -2217,7 +2634,7 @@ export class Tasks {
2217
2634
  await this.releaseDependents(next);
2218
2635
  }
2219
2636
  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));
2637
+ else this.releaseNotice(`task ${this.publicTitle(next)}: could not send the review request to ${reviewer}`, () => this.sendReview(next, reviewer, "", admission));
2221
2638
  return next;
2222
2639
  }
2223
2640
 
@@ -2291,7 +2708,44 @@ export class Tasks {
2291
2708
  }
2292
2709
 
2293
2710
  /** 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 {
2711
+ /** Review admission is separate from brief construction (#228). All refusals precede the requesting done's writes. */
2712
+ private async reviewRequestAdmission(task: Task, refs?: TaskRefs, preEffect = true, actor: PeerId = task.owner ?? USER, previous?: PrEvidence): Promise<ReviewReadiness | undefined> {
2713
+ const mode = (this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode;
2714
+ if (mode === "off") return undefined;
2715
+ const guard = () => {
2716
+ if (actor !== USER && actor !== task.owner) {
2717
+ const holders = Object.entries(this.d.roles ?? {}).filter(([, roles]) => Array.isArray(roles) && roles.includes("conductor")).map(([peer]) => peer);
2718
+ if (holders.length !== 1 || holders[0] !== actor) throw new (preEffect ? PreEffectToolRefusal : Error)("review admission authority changed");
2719
+ }
2720
+ const current = this.need(task.id, false, preEffect);
2721
+ if ((this.d.sdlc?.config().mode ?? "off") !== mode || current.history.length !== task.history.length || current.owner !== task.owner || current.reviewer !== task.reviewer
2722
+ || this.isPii(current) !== this.isPii(task) || JSON.stringify(current.sdlc ?? null) !== JSON.stringify(task.sdlc ?? null)
2723
+ || actor !== USER && this.d.privateTurn?.(actor) && !this.isPii(current)) throw new (preEffect ? PreEffectToolRefusal : Error)("task or authority changed during review admission");
2724
+ return current;
2725
+ };
2726
+ guard();
2727
+ const expected = cleanRefs(refs).commit ?? task.refs.commit ?? task.sdlc?.head ?? task.sdlc?.localHead;
2728
+ const read: SdlcGh = (args, timeout, cwd, cap) => { guard(); return (this.d.sdlc?.gh ?? sdlcGh)(args, timeout, cwd, cap); };
2729
+ const evidence: ReviewReadiness = this.isPii(task)
2730
+ ? { ready: false, blockers: ["private review readiness unavailable"] }
2731
+ : 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"] };
2732
+ const current = guard();
2733
+ if (!evidence.ready) {
2734
+ const message = `task ${this.publicTitle(current)}: premature review: ${evidence.blockers.join("; ")}`;
2735
+ if (mode === "enforce") throw new (preEffect ? PreEffectToolRefusal : Error)(message);
2736
+ this.releaseNotice(message);
2737
+ }
2738
+ return evidence;
2739
+ }
2740
+
2741
+ private sendReview(task: Task, reviewer: PeerId, why = "", admission?: ReviewReadiness): void {
2742
+ const mode = (this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode;
2743
+ if (mode === "enforce" && !admission?.ready) {
2744
+ // Background roster/quota role changes are retained, but never invent a remotely admitted review request.
2745
+ this.releaseNotice(`task ${this.publicTitle(task)}: premature review held; retry the reviewer assignment after current CI and bot replies are verified`);
2746
+ return;
2747
+ }
2748
+
2295
2749
  const r = task.refs;
2296
2750
  const last = [...task.history].reverse().find((h) => h.event === "done");
2297
2751
  const where = [r.branch ? `branch ${r.branch}` : "", r.commit ? `commit ${r.commit}` : "", r.paths?.length ? `paths ${r.paths.join(", ")}` : ""].filter(Boolean).join("; ");
@@ -2305,18 +2759,41 @@ export class Tasks {
2305
2759
  `- ${check ? `Check result: ${(check.note ?? "").split("\n")[0]}` : "No check ran for this class: run the affected tests yourself."}`,
2306
2760
  `- List what is unmet in hub_review's unmet, one item each.`,
2307
2761
  ].join("\n");
2308
- const summary = last?.note ? this.screen(task, last.note, "summary", reviewer, last.withheld) : "(no summary)";
2762
+ // #223 R2: a reviewer-only instance judges from facts; the builder's narrative stays out, earlier findings go in.
2763
+ const reviewOnly = this.d.reviewOnly?.(reviewer) === true;
2764
+ 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)";
2765
+ const specRepo = task.sdlc?.repo ?? task.external?.repo, specIssue = task.sdlc?.issue ?? task.external?.issue;
2766
+ const spec = reviewOnly && specRepo && specIssue ? `Spec issue: ${specRepo}#${specIssue}\n` : "";
2767
+ 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
2768
  const guidance = this.roleGuidance(task, reviewer, true);
2310
2769
  let projection = "";
2311
2770
  try { const text = this.d.projectedReview?.(task); if (text) projection = this.screen(task, sanitize(text), "document projection", reviewer); }
2312
2771
  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}.`;
2772
+ const brief = this.briefText(task);
2773
+ 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}.`;
2314
2774
  this.d.bus.publish(newEnvelope(HUB, body, { to: [reviewer], kind: "review", priority: "important", refs: { ...r, task: String(task.id) }, ...(this.isPii(task) ? { private: true } : {}) }));
2315
2775
  }
2316
2776
 
2317
- async review(by: PeerId, id: unknown, verdict: unknown, note?: string, unmet?: unknown, head?: unknown): Promise<Task> {
2777
+ /** #520: the rendered review brief of a non-PII task's commit, or why there is none; empty without a commit. */
2778
+ briefText(task: Task): string {
2779
+ if (this.isPii(task) || !task.refs.commit) return "";
2780
+ try {
2781
+ const brief = this.d.reviewBrief?.(task);
2782
+ if (!brief) return "";
2783
+ return "unavailable" in brief ? `Review brief unavailable: ${brief.unavailable}.` : sanitize(renderReviewBrief(brief));
2784
+ } catch { return "Review brief unavailable: it could not be built."; }
2785
+ }
2786
+
2787
+ async review(by: PeerId, id: unknown, verdict: unknown, note?: string, unmet?: unknown, head?: unknown, coverage?: unknown): Promise<Task> {
2788
+ const authority = by === USER ? undefined : this.d.reviewAuthority?.(by, Number(id));
2789
+ // #520: what the coverage was checked against, pinned through every await: the commit and the lifecycle receipt (the
2790
+ // inherited reviewedHead lives there).
2791
+ let covered: { commit: string | undefined; receipt: string } | undefined;
2318
2792
  const valid = (): Task => {
2793
+ const refused = authority?.();
2794
+ if (refused) throw new PreEffectToolRefusal(refused); // rechecked after every await, right before each write
2319
2795
  const t = this.need(id, false, true);
2796
+ 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
2797
  this.mine(t, by, "reviewer", true);
2321
2798
  if (verdict !== "approved" && verdict !== "changes_requested") throw new PreEffectToolRefusal('verdict must be "approved" or "changes_requested"');
2322
2799
  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 +2801,34 @@ export class Tasks {
2324
2801
  };
2325
2802
  const items = textList(unmet);
2326
2803
  if (items.length) note = `${note ?? ""}\nUnmet: ${items.join("; ")}`.trim();
2804
+ // #520: an approval accounts for every reviewable path of the brief. enforce refuses before any effect, advise records
2805
+ // the gap in the verdict note, off and a person's own verdict skip it.
2806
+ const mode = this.d.sdlc?.config().mode ?? "off";
2807
+ // A Tasks without a brief provider (library use; the daemon always has one) has no coverage gate.
2808
+ if (verdict === "approved" && by !== USER && mode !== "off" && this.d.reviewBrief) {
2809
+ const t = valid(), enforce = mode === "enforce", list = (paths: string[]) => `${paths.slice(0, 20).join(", ")}${paths.length > 20 ? ", ..." : ""}`;
2810
+ // Code work (a commit, branch or PR) needs a brief; work with none of them is exempt and has nothing to account for.
2811
+ const code = !this.isPii(t) && !!(t.refs.commit || t.refs.branch || t.sdlc?.pr);
2812
+ // The commit under review: the task's, else the head the verdict names, else the lifecycle receipt's.
2813
+ const commit = t.refs.commit ?? (typeof head === "string" ? head : undefined) ?? t.sdlc?.reviewedHead ?? t.sdlc?.localHead;
2814
+ const brief: ReviewBrief | { unavailable: string } | undefined = !code ? undefined
2815
+ : !commit ? { unavailable: "the task links a branch or PR but names no commit" }
2816
+ : this.d.reviewBrief({ ...t, refs: { ...t.refs, commit } }) ?? { unavailable: "the review brief could not be built" };
2817
+ if (brief && "unavailable" in brief) {
2818
+ // An unknown inventory is never an empty one.
2819
+ if (enforce) throw new PreEffectToolRefusal(`coverage: the review brief is unavailable (${brief.unavailable}); an approval needs a complete brief of the task's commit`);
2820
+ note = `${note ?? ""}\nCoverage unknown: the review brief is unavailable (${brief.unavailable})`.trim();
2821
+ } else if (brief) {
2822
+ // One head: the brief's, the task's commit, and the head the verdict names or inherits from an earlier review (with a
2823
+ // PR, the lifecycle gate binds that head to the PR's).
2824
+ const effective = typeof head === "string" ? head : t.sdlc?.reviewedHead;
2825
+ 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)`);
2826
+ covered = { commit: t.refs.commit, receipt: JSON.stringify(t.sdlc ?? null) };
2827
+ const gap = coverageGap(brief, coverage);
2828
+ 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}]}.`);
2829
+ if (gap.length) note = `${note ?? ""}\nCoverage gap (${gap.length}): ${list(gap)}`.trim();
2830
+ }
2831
+ }
2327
2832
  // The PII screen reads the note of a review that passes these checks (issue #198); they run again after it.
2328
2833
  const withheld = await this.withholds("review note", note, valid());
2329
2834
  let task = valid();
@@ -2345,7 +2850,8 @@ export class Tasks {
2345
2850
  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
2851
  }
2347
2852
  const pii = this.isPii(task);
2348
- const flag = { withheld: withheld || undefined };
2853
+ const reviewContext = by === USER ? undefined : this.d.reviewContext?.(by, task.id);
2854
+ const flag = { withheld: withheld || undefined, ...(reviewContext ? { reviewContext } : {}) };
2349
2855
  if (verdict === "approved") {
2350
2856
  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
2857
  if (next.owner) {
@@ -2475,11 +2981,23 @@ export class Tasks {
2475
2981
  this.ready(task, preEffect);
2476
2982
  const a = assign(task, this.states(), this.d.routing(), { candidates: [], namedReviewer: peer, ...(task.owner ? { notReviewer: task.owner } : {}), ...this.weights(task.class) });
2477
2983
  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("; ")}`);
2984
+ const admission = task.state === "in_review" ? await this.reviewRequestAdmission(task, undefined, preEffect, by) : undefined;
2985
+ // Every runtime authority check precedes the final write, including revocations during the metadata await.
2986
+ const current = this.need(task.id, false, preEffect);
2987
+ if (current.history.length !== task.history.length) throw new (preEffect ? PreEffectToolRefusal : Error)("task changed during reviewer admission");
2988
+ if (by !== USER) {
2989
+ const holders = Object.entries(this.d.roles ?? {}).filter(([, roles]) => Array.isArray(roles) && roles.includes("conductor")).map(([actor]) => actor);
2990
+ if (holders.length !== 1 || holders[0] !== by) throw new (preEffect ? PreEffectToolRefusal : Error)("only the console user or the configured conductor may name a reviewer");
2991
+ }
2992
+ if (!this.d.bus.isRostered(peer)) throw new (preEffect ? PreEffectToolRefusal : Error)(`${peer} is off roster`);
2993
+ this.requireCapabilities(current, peer, role, preEffect);
2994
+ const eligible = assign(current, this.states(), this.d.routing(), { candidates: [], namedReviewer: peer, ...(current.owner ? { notReviewer: current.owner } : {}), ...this.weights(current.class) });
2995
+ if (eligible.reviewer !== peer) throw new (preEffect ? PreEffectToolRefusal : Error)("reviewer is no longer eligible");
2478
2996
  const next = this.d.board.update(task.id, by, "reviewer changed", { reviewer: peer }, this.assignmentNote(`named reviewer ${peer}`, a), { reason: "manual" }, expectedFlowRevision);
2479
2997
  if (next.state === "in_review" && task.reviewer && task.reviewer !== peer) {
2480
2998
  this.withdrawRoleRequest(task, next, "reviewer");
2481
2999
  }
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));
3000
+ 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
3001
  return next;
2484
3002
  }
2485
3003
  // Work that waits is handed to nobody yet (issue #207): the peer becomes its reserved owner instead.