@staix/agent-hub 0.12.36 → 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 (52) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +13 -8
  3. package/docs/agent-notes/adapters.md +7 -3
  4. package/docs/agent-notes/budget.md +2 -1
  5. package/docs/agent-notes/daemon.md +5 -1
  6. package/docs/agent-notes/tasks.md +5 -2
  7. package/docs/agent-notes/tests.md +2 -2
  8. package/docs/memory.md +3 -1
  9. package/docs/operations.md +42 -10
  10. package/docs/quickstart.md +7 -7
  11. package/docs/sdlc.md +29 -3
  12. package/docs/security.md +6 -2
  13. package/docs/smoke.md +18 -2
  14. package/docs/specs/2026-09-19-agent-hub-design.md +34 -4
  15. package/docs/verification/2026-10-19-0.12.37.md +60 -0
  16. package/docs/verification/2026-10-19-jcode-0.98.1.md +30 -0
  17. package/docs/verification/2026-10-19-reviewer-isolation.md +119 -0
  18. package/docs/verified.json +18 -14
  19. package/package.json +2 -2
  20. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  21. package/plugins/agent-hub/server.js +3091 -2236
  22. package/src/adapters/acp.ts +3 -2
  23. package/src/adapters/claude-print.ts +18 -156
  24. package/src/adapters/codex-review-isolation.ts +276 -0
  25. package/src/adapters/definitions/builtin-metadata.ts +4 -1
  26. package/src/adapters/definitions/native-factories.ts +17 -0
  27. package/src/adapters/definitions/native-policy-aux.ts +1 -1
  28. package/src/adapters/definitions/reviewer-variants.ts +32 -0
  29. package/src/adapters/jcode.ts +20 -5
  30. package/src/adapters/registry-types.ts +7 -3
  31. package/src/adapters/registry.ts +17 -5
  32. package/src/adapters/reviewer-print.ts +319 -0
  33. package/src/cli/help.ts +2 -0
  34. package/src/cli/identity.ts +1 -1
  35. package/src/cli/main.ts +19 -0
  36. package/src/cli/memory.ts +13 -12
  37. package/src/hub/board.ts +34 -6
  38. package/src/hub/config-apply.ts +3 -2
  39. package/src/hub/control-client.ts +1 -1
  40. package/src/hub/daemon.ts +101 -27
  41. package/src/hub/envelope.ts +1 -1
  42. package/src/hub/host-executable.ts +1 -1
  43. package/src/hub/hub-tools.ts +5 -0
  44. package/src/hub/instances.ts +13 -3
  45. package/src/hub/memory-profile.ts +67 -11
  46. package/src/hub/planning.ts +2 -0
  47. package/src/hub/release-admission.ts +64 -0
  48. package/src/hub/reviewer-pool-config.ts +11 -0
  49. package/src/hub/reviewer-pool.ts +70 -0
  50. package/src/hub/routing.ts +42 -3
  51. package/src/hub/sdlc-release.ts +113 -0
  52. package/src/hub/tasks.ts +254 -36
package/src/hub/tasks.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { RELEASE_TRAIN_HISTORY_LIMIT, releaseTrainData, releaseTrainReadiness, releaseMetrics } from "./sdlc-release.ts";
1
2
  import { cleanCostPlan, costMetrics, type CostPlan, type ReviewCost } from "./cost-governance.ts";
2
3
  import { specRefs } from "./spec-read.ts";
3
4
  import type { InstancesConfig } from "./instances.ts";
@@ -18,7 +19,7 @@ import { isAbsolute, relative } from "node:path";
18
19
  import type { Briefs, NativeItem } from "../memory/brief.ts";
19
20
  import type { MemoryClient } from "../memory/client.ts";
20
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";
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";
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";
22
23
  import type { Bus } from "./bus.ts";
23
24
  import { HUB, newEnvelope, NOTE_KINDS, noteLine, sanitize, USER, type Envelope, type PeerId, type PeerState } from "./envelope.ts";
24
25
  import type { PiiVerdict, ScreenItem, ScreenRecord } from "./inference.ts";
@@ -28,13 +29,17 @@ import { Cohorts, MAX_REQUESTS, type Cohort, type Completion } from "./cohorts.t
28
29
  import { realPath } from "./project.ts";
29
30
  import { PreEffectToolRefusal } from "./tool-refusal.ts";
30
31
  import { coverageGap, renderReviewBrief, type ReviewBrief } from "./review-brief.ts";
31
- import { ADAPTERS, definitionOf } from "../adapters/registry.ts";
32
+ import { ADAPTERS, DEFAULT_REVIEW_POOL, definitionOf, familyOf } from "../adapters/registry.ts";
32
33
  import { plannerGrooming, nextSweep, taskSweepConfig, type TaskSweepConfig, type SweepRecord } from "./task-sweep.ts";
33
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";
34
35
 
35
36
  export type TaskAssignmentRole = "owner" | "reviewer";
36
37
 
38
+ export interface ReleaseWindow { at: number; host: boolean; quota: boolean; idle: boolean; reasons: string[]; current?: () => boolean; close?: () => Promise<void>; }
39
+
37
40
  export interface TasksDeps {
41
+ /** Host-bound evidence only; absent/expired observations never admit a release cut. */
42
+ releaseWindow?: () => Promise<ReleaseWindow>;
38
43
  /** Effective per-peer planner capacity, taken from the existing settings loader. */
39
44
  wipLimit?: (peer: PeerId) => number;
40
45
  recordPlanning?: (change: { by: string; task?: number; release?: number; fields: string[]; revision: number }) => void;
@@ -93,7 +98,7 @@ export interface TasksDeps {
93
98
  /** Optional persisted task/run execution limits (issue #102). */
94
99
  executionBudget?: ExecutionBudget;
95
100
  /** Reviewer choice from recorded review outcomes (issue #35); off unless the project config turns it on. */
96
- review?: { adaptive: boolean; min_reviews: number };
101
+ review?: { adaptive: boolean; min_reviews: number; pool?: readonly string[]; rerotate?: boolean };
97
102
  /** Peers whose recent deliveries all failed, with the reason (issue #89); routing skips them. */
98
103
  capabilities?: () => Record<PeerId, PeerCapabilities>;
99
104
  repository?: string;
@@ -359,7 +364,7 @@ export class Tasks {
359
364
  if (finding.step === 2 || !planners.length) this.releaseNotice(text);
360
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"));
361
366
  } else {
362
- 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) });
363
368
  this.releaseNotice(`${text} Owner reassignment suggestion: ${admission.owner ?? "no eligible peer"}.`);
364
369
  if (admission.owner && config.auto_reassign && this.sweepAvailable(admission.owner)) {
365
370
  try { await this.assignOwner(this.d.board.get(task.id)!, HUB, { candidates: [admission.owner], event: "reassigned", reason: "idle", note: "ready-unassigned idle sweep" }); }
@@ -370,7 +375,7 @@ export class Tasks {
370
375
  }
371
376
  const responsible = finding.kind === "review-pending" ? task.reviewer! : task.owner!;
372
377
  if (responsible !== USER && !this.sweepAvailable(responsible)) continue;
373
- 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;
374
379
  const title = this.publicTitle(task);
375
380
  const message = `Task ${title}: ${finding.kind}, idle escalation ${finding.step}/3.`;
376
381
  // Only identifiers, kind and step are kept in the ladder event. A crash after this write and before publish
@@ -386,7 +391,7 @@ export class Tasks {
386
391
  }
387
392
  } else {
388
393
  const reviewer = finding.kind === "review-pending";
389
- 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) });
390
395
  const candidate = reviewer ? a.reviewer : a.owner;
391
396
  this.passedOver(task, a);
392
397
  this.d.notify(`${message} ${reviewer ? "Reviewer" : "Owner"} reassignment suggestion: ${candidate ?? "no eligible peer"}.`);
@@ -458,6 +463,10 @@ export class Tasks {
458
463
  ...(h.supersededBy === undefined ? {} : { supersededBy: screenHistoryText(h.supersededBy, "supersession", h.withheld) }),
459
464
  ...(h.profile === undefined ? {} : { profile: screenHistoryText(h.profile, "profile") }),
460
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") } : {}) } } : {}),
461
470
  ...(h.planning ? { planning: { revision: h.planning.revision, fields: h.planning.fields, ...(h.planning.split ? { children: h.planning.split.children.map(child => child.id) } : {}) } } : {}),
462
471
  ...(h.reason && TASK_MOVE_REASONS.includes(h.reason) ? { reason: h.reason } : {}),
463
472
  ...(h.sweep ? { sweep: { kind: h.sweep.kind, activity: h.sweep.activity, step: h.sweep.step, at: h.sweep.at } } : {}),
@@ -469,11 +478,13 @@ export class Tasks {
469
478
  resultLine(task: Task): string {
470
479
  const base = `task #${task.id}: ${task.state}, owner ${task.owner ?? "none"}, reviewer ${task.reviewer ?? "none"}`;
471
480
  const noReview = task.class !== "review" && !task.reviewer && OPEN.includes(task.state) && !this.waitsFor(task).length
472
- ? 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) }))
473
482
  : undefined;
474
483
  const held = task.owner ? peerEntry(this.d.held?.(), task.owner) : undefined;
475
484
  const holdText = held ? `${task.owner}'s queue is held (${held}); it receives the task once the hold is resolved` : undefined;
476
- 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("; ");
477
488
  }
478
489
 
479
490
  private states(): Record<PeerId, PeerState> {
@@ -543,7 +554,37 @@ export class Tasks {
543
554
  const held = this.d.held?.();
544
555
  const workload = this.instanceRouting();
545
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);
546
- 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 } : {}) };
547
588
  }
548
589
 
549
590
  /** Capability admission precedes explicit assignments/reservations, independent of readiness or transient peer health. */
@@ -558,10 +599,10 @@ export class Tasks {
558
599
  }
559
600
 
560
601
  /** What assignment weighs besides states and policy: quota, demotion and the review record. */
561
- private weights(cls: TaskClass) {
602
+ private weights(cls: TaskClass, task?: Task) {
562
603
  const now = Date.now();
563
604
  const quota = this.d.quota?.();
564
- 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 } } : {}) };
565
606
  }
566
607
 
567
608
  /**
@@ -691,7 +732,7 @@ export class Tasks {
691
732
  const states = this.states();
692
733
  const full = this.plannerFull(task);
693
734
  // Preview the To Do transition without routing or offering the persisted backlog row.
694
- 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) });
695
736
  }
696
737
 
697
738
  async pullPlanned(by: PeerId, input: { id: number; revision: number; reason: string }): Promise<Task> {
@@ -826,7 +867,7 @@ export class Tasks {
826
867
  const own = owned.get(release.id) ?? [], ownKnown = own.filter(task => task.doneAt !== null).sort(compare);
827
868
  const cutStart = release.cut === null ? known.length : after(known, release.cut);
828
869
  const unknownPool = release.cut === null ? approved : unknown;
829
- return { ...release,
870
+ return { ...this.publicRelease(release),
830
871
  name: this.nameable(release.name) ? release.name : "[withheld]",
831
872
  goal: this.nameable(release.goal) ? release.goal : "[withheld]",
832
873
  tasks: scopes.get(release.id) ?? [],
@@ -1016,12 +1057,122 @@ export class Tasks {
1016
1057
  return result;
1017
1058
  }
1018
1059
 
1019
- 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) {
1020
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);
1021
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");
1022
1173
  const text = input.name + "\n" + input.goal;
1023
1174
  if (!this.nameable(text) || this.screenOn() && (await this.screenCall(text)).label !== "clear") throw new PreEffectToolRefusal("release text was not cleared for public planning");
1024
- 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.
1025
1176
  if (this.d.board.releases().some(release => release.name === input.name)) throw new PreEffectToolRefusal("release name already exists");
1026
1177
  const release = this.d.board.newRelease(input.name, input.goal.trim(), input.order);
1027
1178
  this.planningRecorded({ by, release: release.id, fields: ["created"], revision: release.revision });
@@ -1035,9 +1186,17 @@ export class Tasks {
1035
1186
  if (["shipped", "dropped"].includes(release.state)) throw new PreEffectToolRefusal("settled release is final");
1036
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");
1037
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
+ }
1038
1196
  const { id, revision, ...patch } = input;
1039
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");
1040
- 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);
1041
1200
  this.planningRecorded({ by, release: id, fields: Object.keys(patch), revision: updated.revision });
1042
1201
  return updated; // a failed readback may follow a durable release write
1043
1202
  }
@@ -1489,7 +1648,7 @@ export class Tasks {
1489
1648
  const task = this.d.board.get(target);
1490
1649
  if (!task) throw new Error(`no task #${target}`);
1491
1650
  if (isClosedTaskState(task.state)) return [...workflow, `task ${this.publicTitle(task)} (${task.state}): closed; not routed`];
1492
- 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) });
1493
1652
  // The split trace is for the pair the record is about: the task's owner when it has one (issue #109).
1494
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 ?? [])];
1495
1654
  }
@@ -1542,6 +1701,7 @@ export class Tasks {
1542
1701
 
1543
1702
  /** Why the task will approve directly at done: no reviewer could be picked, and who was skipped (issue #92). */
1544
1703
  private noReviewer(a: Assignment): string {
1704
+ if (a.reviewBlocked) return "blocked: no reviewer can run";
1545
1705
  const skipped = a.trace.flatMap((l) => {
1546
1706
  const m = /^ {2}reviewer candidate (\S+): skipped, (.*)$/.exec(l);
1547
1707
  return m ? [`${m[1]} (${m[2]})`] : [];
@@ -1557,7 +1717,13 @@ export class Tasks {
1557
1717
 
1558
1718
  private assignmentNote(note: string, assignment: Assignment): string {
1559
1719
  const tagged = assignment.sameFamilyFallback ? `${note}; sameFamilyFallback=true` : note;
1560
- return assignment.plannerFallback ? `${tagged}; reviewer=planner (fallback)` : tagged;
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;
1561
1727
  }
1562
1728
 
1563
1729
  /** A later move to another owner means the caller's assignment was superseded, so its success tail is not authoritative. */
@@ -1593,7 +1759,7 @@ export class Tasks {
1593
1759
  const waits = this.waitsFor(task);
1594
1760
  const plannedMove = opts.planner || this.plannerPullRecord(task)?.pull === true && (by === HUB || opts.roster) && (opts.reason !== "manual" || opts.roster);
1595
1761
  const plannerFull = plannedMove ? this.plannerFull(task) : [];
1596
- 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 } : {}) });
1597
1763
 
1598
1764
  if (waits.length) {
1599
1765
  this.releaseNotice(`task ${this.publicTitle(task)} waits for ${waits.map((id) => `#${id}`).join(", ")}; it is offered once they are approved`);
@@ -1616,7 +1782,7 @@ export class Tasks {
1616
1782
  // A person's explicit assign replaces an agent's reservation (#207). Only a refusal keeps the owner it moves away from
1617
1783
  // out for good: a reviewer's rejections or an escalation by hand, never the hub's own move after a failed delivery.
1618
1784
  const lasting = opts.event === "escalated" && task.owner && (opts.reason === "rejections" || opts.reason === "manual");
1619
- 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);
1620
1786
  // What calibration reads (issue #109): routing chose the first owner (no single named candidate, no claim; not an
1621
1787
  // escalation, relay or reassignment of work already begun), and the work overlaps another owner's task not started
1622
1788
  // yet. For the record only. Work routed back to its proposer is left out, as its observations are (by === owner).
@@ -1992,7 +2158,7 @@ export class Tasks {
1992
2158
  }
1993
2159
 
1994
2160
  /** S10 read-only planner report; S7 owns rolling bands and automatic board proposals. */
1995
- async costReport(by: PeerId, repository?: unknown): Promise<Record<string, unknown>> {
2161
+ async costReport(by: PeerId, repository?: unknown, scopePrs?: readonly number[]): Promise<Record<string, unknown>> {
1996
2162
  const authority = () => {
1997
2163
  this.planningAuthority(by);
1998
2164
  if (by !== USER && this.d.privateTurn?.(by)) throw new PreEffectToolRefusal("cost report unavailable during private work");
@@ -2004,7 +2170,8 @@ export class Tasks {
2004
2170
  const bounded = (args: string[], timeout?: number, cwd?: string, cap?: number) => performance.now() < deadline
2005
2171
  ? (this.d.sdlc?.gh ?? sdlcGh)(args, Math.max(1, Math.min(timeout ?? 10_000, deadline - performance.now())), cwd, cap)
2006
2172
  : 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);
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!)));
2008
2175
  const ids = [...new Set(linked().filter(task => !this.isPii(task)).map(task => task.sdlc!.pr!))].sort((a, b) => a - b);
2009
2176
  const observations: import("./cost-governance.ts").CostPr[] = [];
2010
2177
  let incomplete = ids.length > 16;
@@ -2032,7 +2199,12 @@ export class Tasks {
2032
2199
  async planReviewGap(task: Task): Promise<string | undefined> {
2033
2200
  const receipt = task.sdlc?.planReview;
2034
2201
  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";
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
+ }
2036
2208
  if (receipt.scope && JSON.stringify(receipt.scope) !== JSON.stringify(cleanCostPlan(task.plan ?? {}))) return "plan not reviewed since cost scope change";
2037
2209
  const identity = this.planIssueIdentity(task);
2038
2210
  if (identity?.issue && this.isPii(task)) return "private issue plan review evidence unavailable";
@@ -2133,6 +2305,15 @@ export class Tasks {
2133
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; }
2134
2306
  return d;
2135
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
+ }
2136
2317
  return report;
2137
2318
  }
2138
2319
 
@@ -2604,14 +2785,16 @@ export class Tasks {
2604
2785
 
2605
2786
  /** `gated`: the transition `done()` already gated with no await since, so it is not read back twice (#382). */
2606
2787
  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;
2788
+ let selection = this.poolReview(task, refs);
2789
+ let reviewer = selection?.reviewer ?? task.reviewer, reviewEvidence: PrEvidence | undefined;
2608
2790
  if (this.d.sdlc && this.d.sdlc.config().mode !== "off" && gated !== (reviewer ? "review" : "approval")) {
2609
2791
  reviewEvidence = await this.lifecycleGate(task, reviewer ? "review" : "approval");
2610
2792
  task = this.need(task.id, false, true);
2611
2793
  this.mine(task, by, "owner", true);
2612
2794
  // In every mode: another done may have landed during the readback.
2613
2795
  if (task.state === "in_review" || isClosedTaskState(task.state)) throw new Error(`task #${task.id} is already ${task.state}`);
2614
- reviewer = task.reviewer;
2796
+ selection = this.poolReview(task, refs);
2797
+ reviewer = selection?.reviewer ?? task.reviewer;
2615
2798
  }
2616
2799
  // A delayed local check needs a new readback; the immediate done path already admitted before effects.
2617
2800
  if (reviewer && !admission && (this.d.sdlc?.config().mode ?? "off") !== "off") {
@@ -2621,9 +2804,12 @@ export class Tasks {
2621
2804
  this.mine(task, by, "owner");
2622
2805
  if (task.history.length !== observed.history.length || task.state === "in_review" || isClosedTaskState(task.state)) throw new Error("task changed during review admission");
2623
2806
  }
2807
+ selection = this.poolReview(task, refs);
2808
+ reviewer = selection?.reviewer ?? task.reviewer;
2624
2809
  const reviewCost: ReviewCost | undefined = reviewer && task.plan && (task.plan.review || task.plan.ci)
2625
2810
  ? { ...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 } : {}) });
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 } : {}) });
2627
2813
  this.cohorts.closed(next.id); // its owner's next native turn end settles it in its cohort (issue #107)
2628
2814
  this.note(next, by, "finding", `Task #${next.id} done by ${by}: ${next.title}\n${summary ?? ""}`, withheld);
2629
2815
  this.tellCompleted(next, summary, withheld);
@@ -2709,7 +2895,7 @@ export class Tasks {
2709
2895
 
2710
2896
  /** The one place a review request is written: the first reviewer and a replacement get the same text, refs and privacy. */
2711
2897
  /** 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> {
2898
+ private async reviewRequestAdmission(task: Task, refs?: TaskRefs, preEffect = true, actor: PeerId = task.owner ?? USER, previous?: PrEvidence, sameRequest?: () => boolean): Promise<ReviewReadiness | undefined> {
2713
2899
  const mode = (this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode;
2714
2900
  if (mode === "off") return undefined;
2715
2901
  const guard = () => {
@@ -2718,7 +2904,7 @@ export class Tasks {
2718
2904
  if (holders.length !== 1 || holders[0] !== actor) throw new (preEffect ? PreEffectToolRefusal : Error)("review admission authority changed");
2719
2905
  }
2720
2906
  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
2907
+ if ((this.d.sdlc?.config().mode ?? "off") !== mode || current.history.length !== task.history.length && !sameRequest?.() || current.owner !== task.owner || current.reviewer !== task.reviewer
2722
2908
  || this.isPii(current) !== this.isPii(task) || JSON.stringify(current.sdlc ?? null) !== JSON.stringify(task.sdlc ?? null)
2723
2909
  || actor !== USER && this.d.privateTurn?.(actor) && !this.isPii(current)) throw new (preEffect ? PreEffectToolRefusal : Error)("task or authority changed during review admission");
2724
2910
  return current;
@@ -2771,7 +2957,35 @@ export class Tasks {
2771
2957
  catch { projection = "(document projection unavailable)"; }
2772
2958
  const brief = this.briefText(task);
2773
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}.`;
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 } : {}) }));
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 } : {}) }));
2961
+ }
2962
+
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;
2775
2989
  }
2776
2990
 
2777
2991
  /** #520: the rendered review brief of a non-PII task's commit, or why there is none; empty without a commit. */
@@ -2851,7 +3065,11 @@ export class Tasks {
2851
3065
  }
2852
3066
  const pii = this.isPii(task);
2853
3067
  const reviewContext = by === USER ? undefined : this.d.reviewContext?.(by, task.id);
2854
- const flag = { withheld: withheld || undefined, ...(reviewContext ? { reviewContext } : {}) };
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 } : {}) };
2855
3073
  if (verdict === "approved") {
2856
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);
2857
3075
  if (next.owner) {
@@ -2979,7 +3197,7 @@ export class Tasks {
2979
3197
  if (role === "reviewer") {
2980
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`);
2981
3199
  this.ready(task, preEffect);
2982
- 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) });
2983
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("; ")}`);
2984
3202
  const admission = task.state === "in_review" ? await this.reviewRequestAdmission(task, undefined, preEffect, by) : undefined;
2985
3203
  // Every runtime authority check precedes the final write, including revocations during the metadata await.
@@ -2991,7 +3209,7 @@ export class Tasks {
2991
3209
  }
2992
3210
  if (!this.d.bus.isRostered(peer)) throw new (preEffect ? PreEffectToolRefusal : Error)(`${peer} is off roster`);
2993
3211
  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) });
3212
+ const eligible = assign(current, this.states(), this.d.routing(), { candidates: [], namedReviewer: peer, ...(current.owner ? { notReviewer: current.owner } : {}), ...this.weights(current.class, current) });
2995
3213
  if (eligible.reviewer !== peer) throw new (preEffect ? PreEffectToolRefusal : Error)("reviewer is no longer eligible");
2996
3214
  const next = this.d.board.update(task.id, by, "reviewer changed", { reviewer: peer }, this.assignmentNote(`named reviewer ${peer}`, a), { reason: "manual" }, expectedFlowRevision);
2997
3215
  if (next.state === "in_review" && task.reviewer && task.reviewer !== peer) {
@@ -3043,7 +3261,7 @@ export class Tasks {
3043
3261
  const routing = this.d.routing();
3044
3262
  return this.d.board.list().filter(task => !isClosedTaskState(task.state) && (task.owner === peer || task.reviewer === peer)).map(task => {
3045
3263
  const owner = task.owner === peer;
3046
- const weights = this.weights(task.class);
3264
+ const weights = this.weights(task.class, task);
3047
3265
  const pii = this.isPii(task);
3048
3266
  const candidates = owner ? relayCandidates(routing.classes[task.class]?.peers ?? [], pii, { ...this.instanceRouting(), states: this.states() }) : [];
3049
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 } : {}) });
@@ -3067,7 +3285,7 @@ export class Tasks {
3067
3285
  if (task.owner === peer && (OPEN.includes(task.state) || roster && task.state === "in_review")) {
3068
3286
  const pii = this.isPii(task);
3069
3287
  const candidates = relayCandidates(routing.classes[task.class]?.peers ?? [], pii, { ...this.instanceRouting(), states: this.states() });
3070
- 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;
3071
3289
  // Budget keeps its original release/unassign behaviour for ordinary work. Roster changes the state in
3072
3290
  // assignOwner's same owner write only after routing has found somebody to take it.
3073
3291
  const back = !roster && task.state === "in_progress" ? this.d.board.update(task.id, HUB, "released", { state: "proposed" }, why) : task;
@@ -3088,13 +3306,13 @@ export class Tasks {
3088
3306
  }
3089
3307
  moved.push({ id: task.id, title: this.publicTitle(task), to: next.owner, role: "owner" });
3090
3308
  } else if (task.reviewer === peer && !isClosedTaskState(task.state)) {
3091
- 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) });
3092
3310
  const reviewer = a.reviewer && a.reviewer !== peer && a.reviewer !== task.owner ? a.reviewer : null;
3093
3311
  if (roster && !reviewer) {
3094
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}`);
3095
3313
  continue;
3096
3314
  }
3097
- 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));
3098
3316
  moved.push({ id: task.id, title: this.publicTitle(task), to: reviewer, role: "reviewer" });
3099
3317
  if (next.state === "in_review" && reviewer && reviewer !== USER) {
3100
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.)`));