@staix/agent-hub 0.17.0 → 0.19.0

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 (109) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +4 -4
  3. package/docs/agent-notes/README.md +11 -0
  4. package/docs/agent-notes/adapters.md +10 -6
  5. package/docs/agent-notes/benchmarks.md +1 -0
  6. package/docs/agent-notes/daemon.md +6 -2
  7. package/docs/agent-notes/local-worker.md +3 -1
  8. package/docs/agent-notes/tasks.md +11 -2
  9. package/docs/agent-notes/tests.md +7 -3
  10. package/docs/dogfood.md +22 -2
  11. package/docs/memory.md +1 -1
  12. package/docs/operations.md +162 -35
  13. package/docs/profiles.md +36 -0
  14. package/docs/quickstart.md +15 -5
  15. package/docs/sdlc.md +29 -3
  16. package/docs/security.md +44 -13
  17. package/docs/smoke.md +3 -3
  18. package/docs/specs/2026-09-19-agent-hub-design.md +31 -10
  19. package/docs/verification/2026-10-25-0.18.0.md +76 -0
  20. package/docs/verification/2026-10-25-codex-review-live-verdict.md +35 -0
  21. package/docs/verification/2026-10-25-remote-docker-transport.md +56 -0
  22. package/docs/verification/2026-10-26-agy-1.4.7.md +69 -0
  23. package/docs/verification/2026-10-26-claude-implementer-2.1.310.md +69 -0
  24. package/docs/verification/2026-10-26-headless-claude-shell-context.md +62 -0
  25. package/docs/verification/2026-10-26-jcode-0.99.1.md +49 -0
  26. package/docs/verification/2026-10-26-kimi-232-reviewer-compat.md +53 -0
  27. package/docs/verification/2026-10-26-pi-140-compat.md +41 -0
  28. package/docs/verification/2026-10-26-qwen-0261-acp.md +60 -0
  29. package/docs/verification/2026-10-27-0.19.0.md +86 -0
  30. package/docs/verification/claude-permission-transport-native.py +187 -0
  31. package/docs/verification/claude-shell-wrapper-host.ts +20 -0
  32. package/docs/verification/claude-shell-wrapper-native.py +145 -0
  33. package/docs/verification/pi-140-model-free.ts +57 -0
  34. package/docs/verification/pi-140-native.mjs +21 -0
  35. package/docs/verification/profiles/YYYY-MM-DD-general.md +46 -0
  36. package/docs/verification/profiles/YYYY-MM-DD-research.md +61 -0
  37. package/docs/verification/profiles/YYYY-MM-DD-writing.md +46 -0
  38. package/docs/verified.json +24 -18
  39. package/package.json +2 -2
  40. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  41. package/plugins/agent-hub/server.js +364 -98
  42. package/src/adapters/agy/entry.ts +1 -1
  43. package/src/adapters/claude-channel.ts +7 -3
  44. package/src/adapters/claude-implementer.ts +5 -3
  45. package/src/adapters/compat.ts +32 -18
  46. package/src/adapters/conformance/fakes/pi-rpc.ts +1 -0
  47. package/src/adapters/definitions/builtin-metadata.ts +3 -3
  48. package/src/adapters/definitions/native-factories.ts +7 -2
  49. package/src/adapters/definitions/reviewer-variants.ts +2 -2
  50. package/src/adapters/local-worker.ts +5 -3
  51. package/src/adapters/pi.ts +39 -3
  52. package/src/adapters/qwen-dual.ts +25 -0
  53. package/src/adapters/registry-types.ts +6 -2
  54. package/src/adapters/registry.ts +1 -0
  55. package/src/cli/agents.ts +69 -7
  56. package/src/cli/claude-shell-wrapper.ts +54 -0
  57. package/src/cli/console-state.ts +9 -2
  58. package/src/cli/help.ts +29 -28
  59. package/src/cli/identity.ts +87 -6
  60. package/src/cli/main.ts +198 -71
  61. package/src/cli/output.ts +11 -2
  62. package/src/cli/sdlc-output.ts +242 -0
  63. package/src/cli/task-output.ts +212 -0
  64. package/src/cli/upgrade-output.ts +57 -0
  65. package/src/hub/agent-drift.ts +61 -0
  66. package/src/hub/board.ts +31 -2
  67. package/src/hub/check-runner.ts +385 -25
  68. package/src/hub/claude-shell-wrapper.ts +349 -0
  69. package/src/hub/conductor.ts +5 -0
  70. package/src/hub/config-apply.ts +16 -7
  71. package/src/hub/config-edit-host.ts +1 -1
  72. package/src/hub/config-edit.ts +2 -2
  73. package/src/hub/config-tier.ts +3 -3
  74. package/src/hub/daemon.ts +530 -87
  75. package/src/hub/dogfood-profile.ts +248 -0
  76. package/src/hub/dogfood-read.ts +77 -14
  77. package/src/hub/dogfood.ts +5 -1
  78. package/src/hub/events.ts +1 -1
  79. package/src/hub/host-executable.ts +21 -1
  80. package/src/hub/hub-tools.ts +2 -2
  81. package/src/hub/instances.ts +18 -2
  82. package/src/hub/lifecycle.ts +1 -1
  83. package/src/hub/operational-authority.ts +67 -0
  84. package/src/hub/operational-view.ts +137 -0
  85. package/src/hub/orphan-process.ts +69 -0
  86. package/src/hub/permission-mode.ts +1 -1
  87. package/src/hub/read-only-shell-proof.ts +81 -0
  88. package/src/hub/read-only-shell.ts +162 -0
  89. package/src/hub/review-scope.ts +13 -0
  90. package/src/hub/reviewer-pool-config.ts +3 -2
  91. package/src/hub/reviewer-pool.ts +3 -0
  92. package/src/hub/routing.ts +23 -6
  93. package/src/hub/sdlc-merge.ts +299 -0
  94. package/src/hub/sdlc-metrics.ts +6 -4
  95. package/src/hub/sdlc-mirror.ts +129 -0
  96. package/src/hub/sdlc.ts +58 -12
  97. package/src/hub/settings.ts +11 -5
  98. package/src/hub/snapshots.ts +60 -0
  99. package/src/hub/task-repository.ts +31 -5
  100. package/src/hub/task-shell-command.ts +85 -0
  101. package/src/hub/task-shell-default.ts +78 -0
  102. package/src/hub/task-shell-proof.ts +171 -0
  103. package/src/hub/task-shell-sandbox.ts +61 -0
  104. package/src/hub/tasks.ts +378 -27
  105. package/src/local/deny.ts +91 -11
  106. package/src/local/sandbox.ts +67 -28
  107. package/src/local/shell-environment.ts +10 -0
  108. package/src/local/tools.ts +17 -4
  109. package/src/ui/index.html +3 -1
package/src/hub/tasks.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import { canonicalMemorySymbolNode, normalizeMemorySymbolInput, MEMORY_SYMBOL_LIMIT, type MemorySymbolNodes } from "./memory-symbols.ts";
2
+ import { createHash } from "node:crypto";
3
+ import { taskShellBindingValid, type TaskShellBinding } from "./task-shell-command.ts";
2
4
  import { buildTaskCodeSymbols, publicTaskCodeSymbols, taskCodeSymbolsDisjoint, HOST_CODE_SYMBOLS } from "./task-code-symbols.ts";
3
5
  import { normalizePlanSymbol, symbolsOverlap } from "./symbol-normalizer.ts";
4
6
  import { publicTestIntegrity, readTestIntegrity, testIntegrityBlockers, type TestIntegrity } from "./test-integrity.ts";
@@ -13,7 +15,10 @@ import { VERSION } from "../version.ts";
13
15
  import { NoWriteTurns } from "./no-writes.ts";
14
16
  import { readGitHubImport, type GitHubSource, type GitHubImportSelection } from "./github-import.ts";
15
17
  import { BACKLOG_SPLIT_SCREEN_TIMEOUT_MS, actualTaskDates, planningPatch, type PlanningFields, type Release, type ReleaseState } from "./planning.ts";
16
- import { readMergeQueue } from "./sdlc-queue.ts";
18
+ import { readMergeQueue, type DeliveryTask } from "./sdlc-queue.ts";
19
+ import { guardedMerge, mergeModeFor, mergeView, type MergeView } from "./sdlc-merge.ts";
20
+ import { mirrorVerdict as mirrorToGitHub, type MirrorVerdict } from "./sdlc-mirror.ts";
21
+ import { CODE_SCOPE_LINE, evidenceOnlyNotice, type ReviewScope } from "./review-scope.ts";
17
22
  import { DEFAULT_MAX_NITS, parseReviewFindings, findingsForHead, findingHead as resolveFindingHead, findingTally, type ReviewFinding, type ReviewFindingSet } from "./review-findings.ts";
18
23
  import { readReleaseReadiness } from "./sdlc-release.ts";
19
24
  import { loadRoleCatalog, taskRole, taskRoleText, type RoleCatalog } from "./roles.ts";
@@ -44,7 +49,7 @@ import { coverageGap, renderReviewBrief, type ReviewBrief } from "./review-brief
44
49
  import { definitionOf as builtinDefinitionOf } from "../adapters/registry.ts";
45
50
  import { ADAPTERS, DEFAULT_REVIEW_POOL, definitionOf, familyOf, type ProjectAdapterCatalog } from "../adapters/registry.ts";
46
51
  import { plannerGrooming, nextSweep, taskSweepConfig, type TaskSweepConfig, type SweepRecord } from "./task-sweep.ts";
47
- import { planningIssueReceipt, linkedReceipt, openPrFor, 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";
52
+ import { planningIssueReceipt, linkedReceipt, openPrFor, readPrCostHistory, readReviewReadiness, reviewReadinessFromEvidence, type PrEvidence, type ReviewReadiness, readPlanIssueMetadata, canonicalStage, checklistOf, readSdlcPr, readSpecEdit, sdlcConfig, sdlcReceipt, sdlcStatus, specState, SPEC_STATE_TEXT, stageDefinition, lifecycleStage, type SdlcConfig, type MergeRecord, type PlanIssueMetadata, sdlcGh, type SdlcGh, type SdlcStatus, type SpecEdit, type SpecState } from "./sdlc.ts";
48
53
 
49
54
  export type TaskAssignmentRole = "owner" | "reviewer" | "planner";
50
55
 
@@ -102,6 +107,8 @@ export interface TasksDeps {
102
107
  privateTurn?: (peer: PeerId) => boolean;
103
108
  /** #223 R4: the hub's reading of a verdict's context (`fresh` only for a reviewer session started for this review). */
104
109
  reviewContext?: (by: PeerId, task: number) => "fresh" | "continuing" | undefined;
110
+ /** #852: what a reviewer reads. `code` (the fixed sandboxed reviewers by default, or a local `review_scope`) reads the code only; unset or `full` also runs tests and reads CI. */
111
+ reviewScope?: (peer: PeerId) => ReviewScope;
105
112
  /** #223 R2: a reviewer-only instance gets review facts, not the builder's done summary. */
106
113
  reviewOnly?: (peer: PeerId) => boolean;
107
114
  /** #223: captured when a verdict call starts; a non-undefined result refuses the verdict at every later check. */
@@ -130,7 +137,7 @@ export interface TasksDeps {
130
137
  /** Optional persisted task/run execution limits (issue #102). */
131
138
  executionBudget?: ExecutionBudget;
132
139
  /** Reviewer choice from recorded review outcomes (issue #35); off unless the project config turns it on. */
133
- review?: { adaptive: boolean; min_reviews: number; pool?: readonly string[]; rerotate?: boolean };
140
+ review?: { adaptive: boolean; min_reviews: number; pool?: readonly string[]; rerotate?: boolean; same_family?: "fallback" | "never" };
134
141
  /** Peers whose recent deliveries all failed, with the reason (issue #89); routing skips them. */
135
142
  capabilities?: () => Record<PeerId, PeerCapabilities>;
136
143
  repository?: string;
@@ -696,6 +703,8 @@ export class Tasks {
696
703
  ...(h.selfReview ? { selfReview: true } : {}),
697
704
  ...(h.reviewerFamily ? { reviewerFamily: screenHistoryText(h.reviewerFamily, "reviewer family") } : {}),
698
705
  ...(h.reviewerReason ? { reviewerReason: h.reviewerReason } : {}),
706
+ ...(h.reviewScope === "code" ? { reviewScope: "code" as const } : {}),
707
+ ...(h.evidenceOnly ? { evidenceOnly: true as const } : {}),
699
708
  ...(h.unmetCriteria?.length ? { unmetCriteria: h.unmetCriteria } : {}),
700
709
  ...(h.deliverables?.length ? { deliverables: publicDeliverables(h.deliverables, this.nameable) } : {}),
701
710
  ...(h.reviewBlocked ? { reviewBlocked: true } : {}),
@@ -773,7 +782,7 @@ export class Tasks {
773
782
  if (h.owner === undefined || h.owner !== owner) since = i;
774
783
  owner = h.owner;
775
784
  });
776
- return new Set(task.history.slice(since + 1).filter((h) => h.event === "changes_requested").map((h) => h.by));
785
+ return new Set(task.history.slice(since + 1).filter((h) => h.event === "changes_requested" && h.evidenceOnly !== true).map((h) => h.by));
777
786
  }
778
787
 
779
788
  /** Workload is board/queue data only: wip() never calls health() or weights(). */
@@ -790,7 +799,7 @@ export class Tasks {
790
799
  const held = this.d.held?.();
791
800
  const workload = this.instanceRouting();
792
801
  const peers = workload.instances ? [...new Set([...(this.d.adapterCatalog?.definitions ?? ADAPTERS).map(adapter => adapter.kind), ...Object.keys(workload.instances), ...this.d.bus.peers.keys()])] : (this.d.adapterCatalog?.definitions ?? ADAPTERS).map(adapter => adapter.kind);
793
- 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 => (this.d.adapterCatalog?.definitionOf ?? 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 } : {}) };
802
+ return { ...workload, reviewPool: { pool: this.d.review?.pool ?? DEFAULT_REVIEW_POOL, rerotate: this.d.review?.rerotate, required: this.d.review?.pool !== undefined, ...(this.d.review?.same_family === "never" ? { sameFamily: "never" as const } : {}) }, ...(this.d.capabilities ? { capabilities: this.d.capabilities(), repository: this.d.repository } : {}), pullOnly: [...this.d.bus.peers.values()].filter(peer => (this.d.adapterCatalog?.definitionOf ?? 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 } : {}) };
794
803
  }
795
804
 
796
805
  private reviewHead(task: Task): string | undefined { return task.refs.commit ?? task.sdlc?.head ?? task.sdlc?.localHead; }
@@ -799,7 +808,7 @@ export class Tasks {
799
808
  const head = this.reviewHead(task);
800
809
  const round = task.state === "in_review" ? task.history.slice(task.history.findLastIndex(entry => entry.event === "done") + 1) : [];
801
810
  const failed = [...new Set(round.flatMap(entry => entry.reviewerFailure?.head === head && entry.reviewerFailure ? [entry.reviewerFailure.peer] : []))];
802
- return { pool: this.d.review?.pool ?? DEFAULT_REVIEW_POOL, rerotate: this.d.review?.rerotate, failed, required: this.d.review?.pool !== undefined };
811
+ return { pool: this.d.review?.pool ?? DEFAULT_REVIEW_POOL, rerotate: this.d.review?.rerotate, failed, required: this.d.review?.pool !== undefined, ...(this.d.review?.same_family === "never" ? { sameFamily: "never" as const } : {}) };
803
812
  }
804
813
 
805
814
  private manuallyNamedReviewer(task: Task): boolean {
@@ -870,7 +879,7 @@ export class Tasks {
870
879
  if (!OWNERSHIP_EVENTS.has(given.event) || given.event === "unassigned" || given.owner !== peer || given.profile !== profile || given.by === peer) return [];
871
880
  const next = t.history.findIndex((h, j) => j > i && OWNERSHIP_EVENTS.has(h.event));
872
881
  const span = t.history.slice(i + 1, next < 0 ? undefined : next + 1);
873
- if (span.some((h) => failed.includes(h.event))) return [{ outcome: "failed" }];
882
+ if (span.some((h) => failed.includes(h.event) && h.evidenceOnly !== true)) return [{ outcome: "failed" }];
874
883
  if (next >= 0 || t.state !== "approved") return []; // handed on without a failure (a relay), or still open
875
884
  const accepted = span.find((h) => h.event === "accepted" && h.by === peer);
876
885
  // The work stage ends at its first done call: checks and an integration step are not the work itself.
@@ -948,10 +957,31 @@ export class Tasks {
948
957
  return task.planner && holders.includes(task.planner) && this.d.bus.isRostered(task.planner) ? [task.planner] : holders;
949
958
  }
950
959
 
960
+ /** Current planner rulings amend review context, never person-only plan-departure acceptance. */
961
+ plannerRulings(task: Task, recipient: PeerId): { historyIndex: number; by: PeerId; at: number; note: string }[] {
962
+ if (this.isPii(task)) return [];
963
+ const boundary = task.history.findLastIndex(entry => entry.event === "accepted" || entry.event === "claimed");
964
+ if (boundary < 0) return [];
965
+ const planners = new Set(this.taskPlanner(task));
966
+ const rulings: { historyIndex: number; by: PeerId; at: number; note: string }[] = [];
967
+ for (let historyIndex = boundary + 1; historyIndex < task.history.length && rulings.length < 5; historyIndex++) {
968
+ const entry = task.history[historyIndex]!;
969
+ if (entry.event !== "plan reviewed" || !planners.has(entry.by) || !this.d.roles?.[entry.by]?.includes("planner")
970
+ || typeof entry.note !== "string" || !entry.note.trim() || !Number.isFinite(entry.at)) continue;
971
+ // Screen the entire original note before the display bound, including a sensitive suffix past 1000 characters.
972
+ const note = sanitize(this.screen(task, entry.note, "planner ruling", recipient, entry.withheld)).slice(0, 1000);
973
+ rulings.push({ historyIndex, by: entry.by, at: entry.at, note });
974
+ }
975
+ return rulings;
976
+ }
977
+
951
978
  private planningAuthority(by: PeerId, task?: Pick<Task, "planner">): void {
952
979
  if (by !== USER && (!this.d.roles?.[by]?.includes("planner") || task !== undefined && !this.taskPlanner(task).includes(by))) throw new PreEffectToolRefusal(`planning requires a person or configured planner role${task ? "; only the task's effective planner may act" : ""}`);
953
980
  }
954
981
 
982
+ /** #812 AC5: a peer holding the configured planner role. The conductor's task reads accept it; it grants no write. */
983
+ isConfiguredPlanner(peer: PeerId): boolean { return peer !== USER && peer !== HUB && !!this.d.roles?.[peer]?.includes("planner"); }
984
+
955
985
  private plannerWriteAuthority(by: PeerId, task?: Pick<Task, "planner">): void {
956
986
  if (by !== USER && this.d.privateTurn?.(by)) throw new PreEffectToolRefusal("planner writes are unavailable during a private turn");
957
987
  this.planningAuthority(by, task);
@@ -1015,6 +1045,93 @@ export class Tasks {
1015
1045
  wip(peer: PeerId, except?: number): number {
1016
1046
  return this.d.board.plannerWip(peer, except);
1017
1047
  }
1048
+
1049
+ /** Host-observed task branch/root binding (#845), never a tool-call branch claim or an execution grant. */
1050
+ bindShellBranch(peer: PeerId, id: number, binding: TaskShellBinding & { worktree: string }, current: () => boolean): boolean {
1051
+ try {
1052
+ if (!Number.isSafeInteger(id) || id < 1 || !taskShellBindingValid(binding) || !this.publicMetadataText(binding.branch)
1053
+ || !current() || this.d.privateTurn?.(peer)) return false;
1054
+ const task = this.d.board.get(id), root = this.d.executionRoot?.(peer);
1055
+ if (!task || task.owner !== peer || !["in_progress", "changes_requested"].includes(task.state) || this.isPii(task)
1056
+ || !root || realPath(root) === realPath(this.d.cwd) || realPath(root) !== realPath(binding.worktree)) return false;
1057
+ const worktree = createHash("sha256").update(realPath(root)).digest("hex");
1058
+ const previous = task.history.findLast(entry => entry.by === HUB && entry.event === "shell branch bound");
1059
+ if (previous) {
1060
+ const saved: unknown = JSON.parse(previous.note ?? "");
1061
+ if (!saved || typeof saved !== "object" || Array.isArray(saved)) return false;
1062
+ const record = saved as Record<string, unknown>;
1063
+ if (record.branch !== binding.branch || record.worktree !== worktree) return false;
1064
+ }
1065
+ if (task.refs.branch !== undefined && task.refs.branch !== binding.branch) return false;
1066
+ const scope = this.repositories.scope(task.refs);
1067
+ const others = this.d.board.list().filter(row => row.id !== id && !isClosedTaskState(row.state))
1068
+ .map(row => ({ row, scope: this.repositories.scope(row.refs) }));
1069
+ if (scope.key !== this.repositories.project.key || others.some(other => other.scope.key === scope.key && other.row.refs.branch === binding.branch)) return false;
1070
+ if (!current() || this.d.privateTurn?.(peer) || this.d.board.get(id)?.history.length !== task.history.length) return false;
1071
+ if (previous) return true;
1072
+ let recorded = false;
1073
+ this.releaseNotice(`task #${id}: execution branch binding unavailable`, () => {
1074
+ this.d.board.update(id, HUB, "shell branch bound", { refs: { branch: binding.branch } }, JSON.stringify({ branch: binding.branch, worktree }), {}, task.history.length);
1075
+ recorded = true;
1076
+ });
1077
+ return recorded;
1078
+ } catch { return false; }
1079
+ }
1080
+ private capacityBefore(task: Task): Map<PeerId, number> {
1081
+ return new Map([...new Set([task.owner, task.reserved])].filter((peer): peer is PeerId => !!peer).map(peer => [peer, this.wip(peer)]));
1082
+ }
1083
+
1084
+ /** Write-driven wake-ups (#785): one unsettled ids-only task notice per planner, never a polling pull. */
1085
+ private writePlannerNotices(task: Task, before: ReadonlyMap<PeerId, number>, approved = false): void {
1086
+ this.releaseNotice(`task #${task.id}: could not notify planner about ready work`, () => {
1087
+ const changes = new Map<PeerId, { capacity: Set<PeerId>; ready: Set<number> }>();
1088
+ const add = (planner: PeerId, peer?: PeerId, id?: number) => {
1089
+ if (!this.d.bus.isRostered(planner)) return;
1090
+ const change = changes.get(planner) ?? { capacity: new Set<PeerId>(), ready: new Set<number>() };
1091
+ if (peer) change.capacity.add(peer);
1092
+ if (id !== undefined) change.ready.add(id);
1093
+ changes.set(planner, change);
1094
+ };
1095
+ for (const [peer, used] of before) if (this.wip(peer) < used) for (const planner of this.taskPlanner(task)) add(planner, peer);
1096
+ if (approved) for (const card of this.d.board.list("backlog")) {
1097
+ if (card.deps?.includes(task.id) && !this.waitsFor(card).length) for (const planner of this.taskPlanner(card)) add(planner, undefined, card.id);
1098
+ }
1099
+ for (const [planner, change] of changes) this.releaseNotice(`could not notify planner ${planner} about ready work`, () => {
1100
+ const pending = this.d.bus.pendingEnvelopes(planner).filter(env => env.from === HUB && env.kind === "task" && env.body.startsWith("Planner work ready:"));
1101
+ // The bus retains these hub-written lines across restart; an in-memory cache would lose unsettled events.
1102
+ for (const env of pending) for (const line of env.body.split("\n")) {
1103
+ const capacity = /^capacity_released: (\S+) WIP \d+; reserved ready (?:#\d+|none)$/.exec(line);
1104
+ if (capacity) change.capacity.add(capacity[1]!);
1105
+ const ready = /^backlog_ready: #(\d+)$/.exec(line);
1106
+ if (ready && Number.isSafeInteger(Number(ready[1]))) change.ready.add(Number(ready[1]));
1107
+ }
1108
+ const lines: string[] = [];
1109
+ for (const peer of change.capacity) {
1110
+ const top = this.d.board.list().filter(card => ["backlog", "proposed"].includes(card.state) && !card.owner && card.reserved === peer
1111
+ && this.taskPlanner(card).includes(planner) && !card.pinned && taskCanPlan(card) && !this.waitsFor(card).length && !this.excluded(card).includes(peer)
1112
+ && (card.release == null || ["planning", "active"].includes(this.d.board.release(card.release)?.state ?? ""))).sort(this.readyOrder)[0];
1113
+ lines.push(`capacity_released: ${peer} WIP ${this.wip(peer)}; reserved ready ${top ? `#${top.id}` : "none"}`);
1114
+ }
1115
+ for (const id of change.ready) {
1116
+ const card = this.d.board.get(id);
1117
+ if (card?.state === "backlog" && this.taskPlanner(card).includes(planner) && !this.waitsFor(card).length) lines.push(`backlog_ready: #${id}`);
1118
+ else change.ready.delete(id);
1119
+ }
1120
+ for (const env of pending) this.d.bus.withdraw(env.id);
1121
+ const env = newEnvelope(HUB, sanitize(`Planner work ready:\n${lines.join("\n")}\nRead the current board before planning or pulling work.`), { to: [planner], kind: "task" });
1122
+ if (!this.d.bus.publish(env).includes(planner)) throw new Error("the notice was not queued");
1123
+ });
1124
+ });
1125
+ }
1126
+
1127
+ /** #784 AC3: the read-only breakdown of `wip` for status, the console and the dashboard: one grouped query for every peer. Admission still reads `wip()`. */
1128
+ wipBreakdown(peers: readonly PeerId[]): Record<string, { wip: number; active: number; reviewWait: number; reserved: number; wipLimit: number }> {
1129
+ const grouped = this.d.board.plannerWipBreakdown();
1130
+ return Object.fromEntries(peers.map(peer => {
1131
+ const row = grouped[peer] ?? { active: 0, reviewWait: 0, reserved: 0 };
1132
+ return [peer, { wip: row.active + row.reviewWait + row.reserved, ...row, wipLimit: this.wipLimit(peer) }];
1133
+ }));
1134
+ }
1018
1135
  private prerequisiteOf(task: Task, prerequisite: number): boolean {
1019
1136
  const pending = [...(task.deps ?? [])], seen = new Set<number>();
1020
1137
  while (pending.length) { const id = pending.pop()!; if (id === prerequisite) return true; if (seen.has(id)) continue; seen.add(id); pending.push(...(this.d.board.get(id)?.deps ?? [])); }
@@ -1349,7 +1466,9 @@ export class Tasks {
1349
1466
  if (entry.planning.pull) {
1350
1467
  const release = task.release == null ? undefined : this.d.board.release(task.release);
1351
1468
  if (task.state !== "proposed" || task.owner || taskHasStarted(task) || by !== USER && (task.pinned || release && ["frozen", "shipped", "dropped"].includes(release.state))) throw new PreEffectToolRefusal("planner pull undo is no longer eligible");
1469
+ const capacity = this.capacityBefore(task);
1352
1470
  const returned = this.d.board.undoPlannedPull(id, by, revision);
1471
+ this.writePlannerNotices(returned, capacity);
1353
1472
  this.offered.delete(id); this.incomingBacklog.add(id);
1354
1473
  this.planningRecorded({ by, task: id, fields: ["pull", "reserved"], revision: returned.planningRevision! });
1355
1474
  return returned;
@@ -1729,7 +1848,9 @@ export class Tasks {
1729
1848
  this.plannerWriteAuthority(by);
1730
1849
  const confirmed = validate();
1731
1850
  if (confirmed.history.length !== flow) throw new PreEffectToolRefusal("task changed during untouched return");
1851
+ const capacity = this.capacityBefore(confirmed);
1732
1852
  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);
1853
+ this.writePlannerNotices(returned, capacity);
1733
1854
  this.offered.delete(confirmed.id);
1734
1855
  this.cohorts.leave(confirmed.id);
1735
1856
  this.withdrawRoleRequest(confirmed, returned, "owner");
@@ -1752,7 +1873,9 @@ export class Tasks {
1752
1873
  if (!pull && taskHasStarted(task)) throw new PreEffectToolRefusal(`task #${n}: started work cannot return to backlog`);
1753
1874
  const pulled = task.history.findLast(entry => entry.event === "backlog pulled" || entry.event === "backlog returned");
1754
1875
  const restoreReservation = !pull && pulled?.planning?.pull && !task.history.slice(task.history.lastIndexOf(pulled) + 1).some(entry => entry.event === "reserved" || entry.planning?.fields.includes("reserved"));
1876
+ const capacity = this.capacityBefore(task);
1755
1877
  const next = this.d.board.update(n, by, pull ? "backlog pulled" : "backlog returned", { state: pull ? "proposed" : "backlog", owner: null, reviewer: null, ...(restoreReservation ? { reserved: pulled.planning!.previous.reserved ?? null } : {}) });
1878
+ this.writePlannerNotices(next, capacity);
1756
1879
  this.offered.delete(n);
1757
1880
  if (!pull) { this.incomingBacklog.add(n); return next; }
1758
1881
  const waits = this.waitsFor(next);
@@ -2202,12 +2325,14 @@ export class Tasks {
2202
2325
  }
2203
2326
 
2204
2327
  /** `override`: a person's explicit assign, which past refusals do not block and which drops an agent's reservation (#207). */
2205
- 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; refuseNamed?: "pre" | "plain"; plannerPull?: { revision: number; note: string; withheld: boolean; guard: () => void } } = {}): Promise<Task> {
2328
+ private async assignOwner(task: Task, by: PeerId, opts: { current?: () => void; 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; refuseNamed?: "pre" | "plain"; plannerPull?: { revision: number; note: string; withheld: boolean; guard: () => void } } = {}): Promise<Task> {
2329
+ opts.current?.();
2206
2330
  if (task.state === "backlog" && !opts.plannerPull) throw new PreEffectToolRefusal(`task #${task.id}: pull backlog work before assigning it`);
2207
2331
  const mode = (this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode;
2208
2332
  let stagedClaimGap: string | undefined;
2209
2333
  if (mode !== "off" && this.isBuildTask(task)) {
2210
2334
  const gap = task.sdlc?.planReview ? await this.planReviewGap(task) : "plan not reviewed since proposal";
2335
+ opts.current?.();
2211
2336
  const current = this.d.board.get(task.id);
2212
2337
  if (!current || !this.ownerAssignmentCurrent(task, current)) {
2213
2338
  if (opts.plannerPull) throw new PreEffectToolRefusal("planner pull changed during admission");
@@ -2263,6 +2388,7 @@ export class Tasks {
2263
2388
  const assignmentNote = this.assignmentNote(`${opts.note ?? `to ${a.owner}`}${passed}`, a);
2264
2389
  const binding = this.plannerBinding(task, a, !!opts.plannerPull);
2265
2390
  const assignmentExtra = { ...this.reviewMetadata(a), ...(binding ? { plannerReason: binding.plannerReason } : {}), ...(profile ? { profile } : {}), ...(opts.reason ? { reason: opts.reason } : {}), ...(lasting ? { from: task.owner! } : {}) };
2391
+ opts.current?.();
2266
2392
  const next = opts.plannerPull
2267
2393
  ? this.d.board.pullAssigned(task.id, by, a.owner, opts.plannerPull.revision, opts.plannerPull.note, opts.plannerPull.withheld, a.reviewer ?? null, assignmentNote, assignmentExtra, task.history.length, binding)
2268
2394
  : this.d.board.update(task.id, by, opts.event ?? "assigned", { owner: a.owner, reviewer: a.reviewer ?? null, ...(binding ? { planner: binding.planner } : {}), ...(opts.roster && task.state === "in_progress" ? { state: "proposed" as const } : {}), ...(opts.event === "escalated" ? { rejections: 0 } : {}), ...(opts.override && task.reserved ? { reserved: null } : {}) }, assignmentNote, assignmentExtra, opts.expectedFlowRevision);
@@ -2280,6 +2406,7 @@ export class Tasks {
2280
2406
  // The cohort record formed just below reads it, and so does `route explain` while that turn lasts.
2281
2407
  if (opts.claim && a.owner === by && this.d.bus.peers.get(by)?.state === "busy") this.sent.set(handOver(next, by), by);
2282
2408
  try {
2409
+ opts.current?.();
2283
2410
  if (opts.plannerPull && task.offer) {
2284
2411
  const prior = { ...task, owner: task.offer.owner };
2285
2412
  this.withdrawRoleRequest(prior, next, "owner");
@@ -2293,7 +2420,7 @@ export class Tasks {
2293
2420
  this.tellEarlierOwners(next, hits);
2294
2421
  }
2295
2422
  this.announceRouting(next, a);
2296
- if (!opts.claim || a.owner !== by) await this.sendTask(next, a, opts.context, this.overlaps(next, true, hits), opts.contextWithheld);
2423
+ if (!opts.claim || a.owner !== by) await this.sendTask(next, a, opts.context, this.overlaps(next, true, hits), opts.contextWithheld, opts.current);
2297
2424
  } catch (e) {
2298
2425
  // Everything between the write and the end of the delivery is in here: the move is fact, and a failure is a
2299
2426
  // delivery failure (#297). The one console notice is printed here, once, for every caller; callers decide
@@ -2371,8 +2498,10 @@ export class Tasks {
2371
2498
  }
2372
2499
 
2373
2500
  /** `contextWithheld`: the PII screen did not clear a budget hand-off (#198); an escalation's reason is the hub's own words. */
2374
- private async sendTask(task: Task, a: Assignment, context?: string, overlap = "", contextWithheld = false): Promise<void> {
2501
+ private async sendTask(task: Task, a: Assignment, context?: string, overlap = "", contextWithheld = false, currentAuthority?: () => void): Promise<void> {
2502
+ currentAuthority?.();
2375
2503
  const brief = this.isPii(task) ? undefined : await this.d.briefs?.forTask(task.owner!, task, this.planGuardItems(task)).catch(() => undefined);
2504
+ currentAuthority?.();
2376
2505
  const current = this.d.board.get(task.id);
2377
2506
  if (!current || !this.ownerAssignmentCurrent(task, current)) throw new AssignmentSupersededError(current ?? task);
2378
2507
  task = current;
@@ -2402,7 +2531,9 @@ export class Tasks {
2402
2531
  const owner = task.owner!;
2403
2532
  const idle = this.d.bus.stateOf(owner) === "idle";
2404
2533
  const env = newEnvelope(HUB, body, { to: [owner], kind: "task", priority: "important", refs: { ...task.refs, task: String(task.id), taskRecipient: "owner", taskOffer: task.history.length }, ...(pii ? { private: true } : {}) });
2534
+ currentAuthority?.();
2405
2535
  this.d.board.recordTaskOffer(task.id, { envelopeId: env.id, owner, assignmentFlowRevision: task.history.length, ...(this.d.offerPullOnly ? { pullOnly: this.d.offerPullOnly(owner) } : {}) }, task.history.length);
2536
+ currentAuthority?.();
2406
2537
  this.d.bus.publish(env);
2407
2538
  if (idle && this.d.bus.stateOf(owner) === "busy" && !this.d.bus.queueIds(owner).includes(env.id)) this.sent.set(handOver(task, owner), owner);
2408
2539
  }
@@ -2562,7 +2693,8 @@ export class Tasks {
2562
2693
  if (by !== USER) {
2563
2694
  this.planningAuthority(by, task);
2564
2695
  const proposer = task.history.find(h => h.event === "proposed" || h.event === "backlogged")?.by;
2565
- if (proposer !== by || !["backlog", "proposed"].includes(task.state) || taskHasStarted(task)) throw new PreEffectToolRefusal("planner may cancel only its own historically unaccepted backlog or proposed work");
2696
+ // #812 AC5: back in proposed or backlog with no execution evidence (the `hub_backlog_return_untouched` test: no branch, commit, PR, local check, done, verdict or reopen), an accepted-then-declined task is cancellable; any such evidence keeps today's refusal.
2697
+ if (proposer !== by || !["backlog", "proposed"].includes(task.state) || taskHasWorkEvidence(task)) throw new PreEffectToolRefusal("planner may cancel only its own historically unaccepted backlog or proposed work");
2566
2698
  }
2567
2699
  return task;
2568
2700
  };
@@ -2572,7 +2704,9 @@ export class Tasks {
2572
2704
  const task = validate();
2573
2705
  if (task.history.length !== revision) throw new PreEffectToolRefusal("task flow revision changed");
2574
2706
  const cancelled = { reason: input.reason.trim(), ...(input.supersededBy ? { supersededBy: input.supersededBy.trim() } : {}), flowRevision: revision + 1, ...(withheld ? { withheld: true as const } : {}) };
2707
+ const capacity = this.capacityBefore(task);
2575
2708
  const next = this.d.board.update(task.id, by, "cancelled", { state: "cancelled", reserved: null, cancelled }, cancelled.reason, { ...(withheld ? { withheld: true as const } : {}) }, revision);
2709
+ this.writePlannerNotices(next, capacity);
2576
2710
  this.releaseNotice(`task #${task.id}: cancellation cohort cleanup unavailable`, () => this.cohorts.leave(task.id));
2577
2711
  this.offered.delete(task.id);
2578
2712
  this.releaseNotice(`task #${task.id}: cancellation stall cleanup unavailable`, () => this.d.board.clearStall(task.id));
@@ -2823,7 +2957,7 @@ export class Tasks {
2823
2957
  }
2824
2958
  const rows = this.d.board.list();
2825
2959
  const tasks: MetricTask[] = rows.map(task => ({ id: task.id, private: this.isPii(task), history: task.history.map(entry => ({ at: entry.at, event: entry.event,
2826
- ...(entry.reviewCost?.head ? { requestHead: entry.reviewCost.head } : {}), ...(entry.reviewFindings?.head ? { verdictHead: entry.reviewFindings.head } : {}) })) }));
2960
+ ...(entry.reviewCost?.head ? { requestHead: entry.reviewCost.head } : {}), ...(entry.reviewFindings?.head ? { verdictHead: entry.reviewFindings.head } : {}), ...(entry.evidenceOnly ? { evidenceOnly: true as const } : {}) })) }));
2827
2961
  const visibleIds = new Set(rows.filter(task => !this.isPii(task)).map(task => task.id));
2828
2962
  const byKind: Record<string, number> = {};
2829
2963
  for (const stall of this.stalled()) if (visibleIds.has(stall.task)) byKind[stall.cause.kind] = (byKind[stall.cause.kind] ?? 0) + 1;
@@ -3052,10 +3186,15 @@ export class Tasks {
3052
3186
  return this.d.board.update(task.id, by, "lifecycle", { sdlc: next }, undefined, { ...this.planObservation({ ...task, sdlc: next }), ...(invalidatesDeparture ? { planDepartureInvalidated: true } : {}), ...(deliverables ? { deliverables } : {}) });
3053
3187
  }
3054
3188
 
3189
+ /** The board as the merge queue and the guarded merge read it (#786): ids, dependencies, privacy, receipts, findings and plans. */
3190
+ private deliveryLinks(config: SdlcConfig): DeliveryTask[] {
3191
+ return this.d.board.list().map(t => ({ id: t.id, after: t.deps ?? [], private: this.isPii(t), receipt: t.sdlc, phase: this.isPii(t) ? undefined : this.workflowView(t), findings: this.isPii(t) ? undefined : this.reviewFindingSets(t), plan: config.mode === "off" || this.isPii(t) ? undefined : { paths: this.planPaths(t), revision: t.history.findLast(h => h.planRevision)?.planRevision?.revision ?? 0, fingerprint: planFingerprint(t.plan ?? {}), accepted: this.validPlanDepartures(t) } }));
3192
+ }
3193
+
3055
3194
  async deliveryQueue(repo: string) {
3056
3195
  const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
3057
3196
  const before = workflowHash(this.d.board.list().map(t => ({ id: t.id, state: t.state, after: t.deps, sdlc: t.sdlc, history: t.history.length })));
3058
- const links = this.d.board.list().map(t => ({ id: t.id, after: t.deps ?? [], private: this.isPii(t), receipt: t.sdlc, phase: this.isPii(t) ? undefined : this.workflowView(t), findings: this.isPii(t) ? undefined : this.reviewFindingSets(t), plan: config.mode === "off" || this.isPii(t) ? undefined : { paths: this.planPaths(t), revision: t.history.findLast(h => h.planRevision)?.planRevision?.revision ?? 0, fingerprint: planFingerprint(t.plan ?? {}), accepted: this.validPlanDepartures(t) } }));
3197
+ const links = this.deliveryLinks(config);
3059
3198
  const report = await readMergeQueue(repo, config, links, this.d.sdlc?.gh, this.publicMetadataText);
3060
3199
  // Policy can change across remote awaits without any board write. Never return its stale public projection.
3061
3200
  const currentPrivacy = this.d.board.list().map(task => ({ id: task.id, private: this.isPii(task) }));
@@ -3081,6 +3220,150 @@ export class Tasks {
3081
3220
  }
3082
3221
  return report;
3083
3222
  }
3223
+ private readonly mergesInFlight = new Set<number>();
3224
+ /** #786: who may ask for a guarded merge. The hub's own approval trigger acts as HUB; a peer must be the one configured conductor. */
3225
+ private mergeAuthority(by: PeerId): boolean {
3226
+ if (by === USER || by === HUB) return true;
3227
+ const holders = Object.entries(this.d.roles ?? {}).filter(([, roles]) => Array.isArray(roles) && roles.includes("conductor")).map(([actor]) => actor);
3228
+ return holders.length === 1 && holders[0] === by;
3229
+ }
3230
+ /**
3231
+ * #786 T1/T2: the guarded merge of one approved task. `sdlc.auto_merge` (or the repository's table) says whether it only reports (`dry-run`) or acts (`on`);
3232
+ * `opts.dryRun` forces a report. Every gate is re-read right now (`sdlc-merge.ts`); this method owns authority, privacy and the notices, which name the task
3233
+ * and the gates only (a PII task shows its stub and is never merged by the hub). Off/dry-run write nothing to the board; on-mode records gate refusals
3234
+ * and post-run host observations, claiming eligible post-MERGED remote cleanup and owner notices durably.
3235
+ */
3236
+ async guardedMerge(by: PeerId, id: unknown, opts: { dryRun?: boolean; trigger?: "review" | "command" } = {}): Promise<{ task: number } & MergeView> {
3237
+ if (!this.mergeAuthority(by)) throw new Error("only the console user or the configured conductor may merge");
3238
+ const task = this.need(id), label = `#${task.id}`; // the number only: a notice about a merge never carries a title (a PII task has none to show)
3239
+ const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
3240
+ const mode = opts.dryRun ? "dry-run" as const : mergeModeFor(config, task.sdlc?.repo);
3241
+ const refuse = (gate: string) => {
3242
+ if(mode==="on")this.recordMergeRefusal(task.id,by,[gate]);
3243
+ return { task: task.id, ...mergeView({ mode, decision: { refused: [gate] }, ran: false }) };
3244
+ };
3245
+ if (this.isPii(task)) return refuse("private task: a person merges");
3246
+ if (mode === "off") return refuse("auto_merge is off for this repository");
3247
+ if (this.mergesInFlight.has(task.id)) return refuse("a merge check for this task is already running");
3248
+ const previous=task.sdlc?.merge;
3249
+ if(previous && !(opts.trigger==="command" && previous.state==="not_merged") && previous.repo===task.sdlc?.repo && previous.pr===task.sdlc.pr && previous.head===task.sdlc.reviewedHead)
3250
+ return refuse("merge attempt already recorded: inspect its result and any uncertain cleanup before acting");
3251
+ // Ephemeral identity excludes merge progress so our own durable writes cannot rebind the observation.
3252
+ const artifactIdentity=(row:Task)=>JSON.stringify([row.sdlc?.repo,row.sdlc?.pr,row.sdlc?.issue,row.sdlc?.head,row.sdlc?.reviewedHead,row.sdlc?.localHead,row.refs.repo,row.refs.commit]);
3253
+ const initialArtifact=artifactIdentity(task);
3254
+ const artifactCurrent=()=>{const row=this.d.board.get(task.id);return !!row && artifactIdentity(row)===initialArtifact;};
3255
+ this.mergesInFlight.add(task.id);
3256
+ try {
3257
+ const links = this.deliveryLinks(config), subject = links.find(link => link.id === task.id)!;
3258
+ const queueIdentity = workflowHash({ config, links });
3259
+ const outcome = await guardedMerge({ mode, subject, state: task.state, taskState: n => this.d.board.get(n)?.state, config, tasks: links, run: this.d.sdlc?.gh ?? sdlcGh, readableMetadata: this.publicMetadataText,
3260
+ recordMerge: record=>this.recordMergeResult(task.id,by,record,artifactCurrent),
3261
+ beforeMerge: () => {
3262
+ if (!this.mergeAuthority(by)) return "merge authority changed during the merge check: nothing was run";
3263
+ // The queue used every link, not only the subject. A changed dependency artifact or ordering edge invalidates that remote evidence.
3264
+ const currentConfig = this.d.sdlc?.config() ?? sdlcConfig(undefined);
3265
+ return workflowHash({ config: currentConfig, links: this.deliveryLinks(currentConfig) }) === queueIdentity
3266
+ ? undefined : "merge queue inputs changed during the merge check: nothing was run";
3267
+ },
3268
+ // Read synchronously at dispatch, after the last PR read: the task (state, receipt, privacy, dependencies, findings), the configuration and mode in force, and whether
3269
+ // the asker may still act and the hub may still write. Nothing is awaited between this read and the gh call.
3270
+ fresh: () => {
3271
+ const now = this.d.board.get(task.id), cfg = this.d.sdlc?.config() ?? sdlcConfig(undefined);
3272
+ const current = now && this.deliveryLinks(cfg).find(link => link.id === now.id);
3273
+ return now && current ? { subject: current, state: now.state, config: cfg, mode: opts.dryRun ? "dry-run" : mergeModeFor(cfg, now.sdlc?.repo), allowed: this.mergeAuthority(by) && !this.d.bus.isRecoveryHeld && !this.d.bus.storageError } : undefined;
3274
+ } });
3275
+ if(mode==="on" && !outcome.ran && "refused" in outcome.decision)this.recordMergeRefusal(task.id,by,outcome.decision.refused);
3276
+ if(outcome.result?.state==="merged") {
3277
+ try {this.mergeOwnerNotice(task.id,by,artifactCurrent);} catch {this.releaseNotice(`task ${label}: merge owner notice unconfirmed`);}
3278
+ }
3279
+ const view = mergeView(outcome);
3280
+ const head = (text?: string) => text ? text.slice(0, 8) : "unknown";
3281
+ if (outcome.result && (this.isPii(this.d.board.get(task.id) ?? task) || this.d.privateTurn?.(by))) this.releaseNotice(`task ${label}: merge result withheld`);
3282
+ else if (outcome.result) this.releaseNotice(outcome.result.state === "merged" ? `task ${label}: merged (squash, commit ${head(outcome.result.mergeCommit)})` : `task ${label}: gh pr merge exited ${outcome.result.exit}; GitHub reports it ${outcome.result.state === "unknown" ? "unreadable" : "not merged"}`);
3283
+ else if (opts.trigger === "review") this.releaseNotice("action" in outcome.decision ? `task ${label}: auto-merge dry-run: would merge at ${head(outcome.decision.head)}` : `task ${label}: auto-merge refused: ${outcome.decision.refused.join("; ")}`);
3284
+ return { task: task.id, ...view };
3285
+ } finally { this.mergesInFlight.delete(task.id); }
3286
+ }
3287
+ private recordMergeRefusal(id:number,by:PeerId,gates:string[]):void {
3288
+ const current=this.d.board.get(id);
3289
+ if(!current)return;
3290
+ const note=JSON.stringify({gates:gates.slice(0,32).map(g=>this.publicMetadataText(g)?g.slice(0,256):"merge gate withheld")});
3291
+ if(!current.history.some(h=>h.by===HUB && h.event==="merge refused" && h.note===note))
3292
+ this.d.board.update(id,HUB,"merge refused",{},note,{...(this.isPii(current)||this.d.privateTurn?.(by)?{withheld:true}:{})});
3293
+ }
3294
+ private mergeRecordCurrent(id:number,by:PeerId,record:MergeRecord,artifactCurrent:()=>boolean):Task|undefined {
3295
+ const now=this.d.board.get(id);
3296
+ return artifactCurrent() && now && now.sdlc?.repo===record.repo && now.sdlc.pr===record.pr && now.sdlc.reviewedHead===record.head
3297
+ && now.state==="approved" && !this.isPii(now) && !this.d.privateTurn?.(by) && this.mergeAuthority(by)
3298
+ && !this.d.bus.isRecoveryHeld && !this.d.bus.storageError ? now : undefined;
3299
+ }
3300
+ /** Save host facts without ever rebinding an old result to a replacement artifact. */
3301
+ private recordMergeResult(id: number, by: PeerId, record: MergeRecord,artifactCurrent:()=>boolean): boolean {
3302
+ const now=this.d.board.get(id);
3303
+ if(!now)return false;
3304
+ const allowed=this.mergeRecordCurrent(id,by,record,artifactCurrent)!==undefined;
3305
+ // A bounded old-attempt fact remains history even after a relink or privacy/authority change. No remote prose or branch name is retained.
3306
+ const note=JSON.stringify(record);
3307
+ if(allowed) {
3308
+ if(JSON.stringify(now.sdlc!.merge)!==note)this.d.board.update(id,HUB,"merge result",{sdlc:{...now.sdlc!,merge:record}},note);
3309
+ } else if(!now.history.some(h=>h.by===HUB && h.event==="merge result (detached)" && h.note===note)) {
3310
+ this.d.board.update(id,HUB,"merge result (detached)",{},note,{...(this.isPii(now)||this.d.privateTurn?.(by)?{withheld:true}:{})});
3311
+ }
3312
+ // Board observers run synchronously. A claim write can itself revoke authority, privacy or artifact identity.
3313
+ return this.mergeRecordCurrent(id,by,record,artifactCurrent)!==undefined && JSON.stringify(this.d.board.get(id)?.sdlc?.merge)===note;
3314
+ }
3315
+
3316
+ /** Claim before publishing: a lost acknowledgement remains unknown and is never automatically sent twice. */
3317
+ private mergeOwnerNotice(id:number,by:PeerId,artifactCurrent:()=>boolean):void {
3318
+ const task=this.d.board.get(id),record=task?.sdlc?.merge;
3319
+ if(!task || !record || record.state!=="merged" || !record.mergeCommit || record.ownerNotice!=="not_started")return;
3320
+ const claimed:MergeRecord={...record,ownerNotice:"claimed"};
3321
+ if(!this.recordMergeResult(id,by,claimed,artifactCurrent))return;
3322
+ const current=this.mergeRecordCurrent(id,by,claimed,artifactCurrent);
3323
+ if(!current)return;
3324
+ const view=this.publicView(current);
3325
+ if(view.title==="[pii]")return;
3326
+ const owner=typeof view.owner==="string"?view.owner:undefined;
3327
+ const body=`Task #${id}: GitHub reports the PR merged (commit ${record.mergeCommit.slice(0,8)}). Remote branch cleanup: ${record.cleanup}. Remove your local worktree and local branch when safe; the hub leaves them in place.`;
3328
+ // Public projection and a synchronous claim observer must not let stale owner text escape.
3329
+ const last=this.mergeRecordCurrent(id,by,claimed,artifactCurrent);
3330
+ if(!last || (last.owner??undefined)!==owner)return;
3331
+ let sent=false;
3332
+ try {
3333
+ if(owner && owner!==USER)sent=this.d.bus.publish(newEnvelope(HUB,body,{to:[owner],kind:"task",refs:{task:String(id),taskRecipient:"owner"}})).includes(owner);
3334
+ else {this.d.notify(body);sent=true;}
3335
+ } catch { /* The durable claim keeps an uncertain publication from being repeated. */ }
3336
+ this.recordMergeResult(id,by,{...claimed,ownerNotice:sent?"sent":"unknown"},artifactCurrent);
3337
+ }
3338
+ /**
3339
+ * #786 T3 (AC1): mirror a saved verdict as ONE `COMMENT` review on the head it was given for (`sdlc-mirror.ts`). Gated by the same `auto_merge` setting as the merge: off posts
3340
+ * nothing, dry-run only reports, on posts. Never awaited by the verdict and never throws: the returned promise always resolves, and a failure is a notice naming the task number.
3341
+ * The note goes out only when it is public: not a PII task, not withheld by the review screen, not in a private turn and matching no private pattern; otherwise the review is its headline alone.
3342
+ */
3343
+ private mirrorVerdict(task: Task, by: PeerId, verdict: MirrorVerdict, head: string | undefined, note: string | undefined, withheld: boolean): Promise<void> {
3344
+ try {
3345
+ const repo = task.sdlc?.repo, pr = task.sdlc?.pr;
3346
+ if (!repo || !pr || !head) return Promise.resolve();
3347
+ const mode = mergeModeFor(this.d.sdlc?.config() ?? sdlcConfig(undefined), repo);
3348
+ if (mode === "off") return Promise.resolve();
3349
+ const text = note && !withheld && !this.isPii(task) && !this.d.privateTurn?.(by) && this.publicMetadataText(note) ? note : undefined;
3350
+ const label = `#${task.id}`;
3351
+ return Promise.resolve().then(() => mirrorToGitHub({ repo, pr, head, verdict, task: task.id, reviewer: by, ...(text !== undefined ? { note: text } : {}), mode, run: this.d.sdlc?.gh ?? sdlcGh })).then(out => {
3352
+ if (out.reason === "posted") this.releaseNotice(`task ${label}: ${verdict} mirrored to GitHub as a comment review`);
3353
+ else if (out.reason === "post failed") this.releaseNotice(`task ${label}: verdict mirror could not be posted`);
3354
+ else if (out.reason === "dry-run") this.releaseNotice(`task ${label}: verdict mirror dry-run: would post one ${verdict} comment review at ${head.slice(0, 8)}`);
3355
+ }).catch(() => this.releaseNotice(`task ${label}: verdict mirror unavailable`));
3356
+ } catch { return Promise.resolve(); }
3357
+ }
3358
+ /** After an approving verdict, never blocking or failing it: the check runs on a later tick and reports through a notice. `after` (the verdict mirror) settles first, so the review lands before the PR can merge. */
3359
+ private autoMerge(task: Task, after?: Promise<void>): void {
3360
+ try {
3361
+ if (!task.sdlc?.repo || !task.sdlc.pr || mergeModeFor(this.d.sdlc?.config() ?? sdlcConfig(undefined), task.sdlc.repo) === "off") return;
3362
+ const label = `#${task.id}`;
3363
+ // Promise.resolve().then also catches a synchronous throw, which would otherwise escape a microtask.
3364
+ void Promise.resolve(after).then(() => this.guardedMerge(HUB, task.id, { trigger: "review" })).catch(() => this.releaseNotice(`task ${label}: auto-merge check unavailable`));
3365
+ } catch { /* an approval never fails because of the merge check */ }
3366
+ }
3084
3367
  async releaseReadiness(repo: string, liveIssues: number[]) {
3085
3368
  const report = await readReleaseReadiness(this.d.cwd, repo, liveIssues, { gh: this.d.sdlc?.gh });
3086
3369
  if (report.documents) report.documents.documents = report.documents.documents.map(d => {
@@ -3499,12 +3782,17 @@ export class Tasks {
3499
3782
  const task = this.need(id, true, true);
3500
3783
  this.mine(task, by, "owner", true);
3501
3784
  // The owner it is declined for goes on the entry, also when the console declines for it: `excluded()` keeps it out (#207).
3785
+ const capacityBefore = this.capacityBefore(task);
3502
3786
  const planned = this.plannerPullRecord(task)?.pull === true;
3503
3787
  const back = this.d.board.update(task.id, by, "declined", { ...(["in_progress", "changes_requested"].includes(task.state) ? { state: "proposed" as const } : {}), ...(planned ? { reserved: null } : {}) }, reason, task.owner ? { from: task.owner, ...(capacity === true ? { capacity: true as const } : {}) } : {});
3504
3788
  if (planned) this.offered.delete(task.id);
3505
3789
  this.d.notify(`task ${this.publicTitle(back)} declined${capacity === true ? " (capacity only)" : ""} by ${by}${reason && !this.isPii(back) ? `: ${reason}` : ""}`);
3506
3790
  // A capacity decline is not in excluded(), so this move's own reroute leaves the decliner out explicitly.
3507
- return this.assignOwner(back, HUB, { event: "reassigned", reason: "declined", clearOnFail: true, ...(capacity === true && task.owner ? { exclude: [task.owner] } : {}), ...(planned ? { planner: true } : {}) });
3791
+ try {
3792
+ return await this.assignOwner(back, HUB, { event: "reassigned", reason: "declined", clearOnFail: true, ...(capacity === true && task.owner ? { exclude: [task.owner] } : {}), ...(planned ? { planner: true } : {}) });
3793
+ } finally {
3794
+ this.writePlannerNotices(this.d.board.get(task.id) ?? back, capacityBefore);
3795
+ }
3508
3796
  }
3509
3797
 
3510
3798
  /**
@@ -3848,7 +4136,9 @@ export class Tasks {
3848
4136
  const completedRefs = cleanRefs(refs);
3849
4137
  this.warmRepository({ ...task, refs: { ...task.refs, ...completedRefs } });
3850
4138
  const noWrites = this.noWritesRecorded({ ...task, refs: { ...task.refs, ...completedRefs } });
4139
+ const capacity = this.capacityBefore(task);
3851
4140
  const next = this.d.board.update(task.id, by, "done", { codeSymbols: this.observedCodeSymbols({ ...task, refs: { ...task.refs, ...completedRefs } }), 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: completedRefs }, checkOutput ? `${routedSummary ?? ""}\n${checkOutput}`.trim() : routedSummary, { ...this.planObservation(task), withheld: withheld || undefined, ...(noWrites ? { noWrites: true as const } : {}), ...this.reviewMetadata(selection), ...(reviewCost ? { reviewCost } : {}), ...(records ? { deliverables: records } : {}) }, task.history.length, HOST_CODE_SYMBOLS);
4141
+ this.writePlannerNotices(next, capacity, next.state === "approved");
3852
4142
  this.cohorts.closed(next.id); // its owner's next native turn end settles it in its cohort (issue #107)
3853
4143
  const memorySymbols = this.completionMemorySymbols(next);
3854
4144
  this.note(next, by, "finding", `Task #${next.id} done by ${by}: ${next.title}\nChanged-symbol memory anchors: ${memorySymbols.state === "known" ? memorySymbols.nodes.length : "unknown (no complete code facts)"}\n${summary ?? ""}`, withheld, memorySymbols);
@@ -4006,6 +4296,21 @@ export class Tasks {
4006
4296
  return admitted;
4007
4297
  }
4008
4298
 
4299
+ /**
4300
+ * #824: one `Hub-verified at <head>` line from the review admission, for every reviewer. Ready: the required checks that passed at this exact head (and an identical-tree run when the hub
4301
+ * reused one) and that no bot review thread is waiting for a reply (the readiness rule: an unresolved thread a person already answered does not block, so the line says exactly that). Not ready (advise mode sends anyway): its blockers. No admission (sdlc off, or a request sent without one) and a PII task: no block, the
4302
+ * admission of a PII task being only a stub. Every word comes from the hub's own readiness read; blockers are fixed hub strings, never PR text.
4303
+ */
4304
+ private hubVerifiedBlock(task: Task, admission?: ReviewReadiness): string {
4305
+ if (!admission || this.isPii(task)) return "";
4306
+ const head = admission.head ?? this.reviewHead(task) ?? "unknown head";
4307
+ if (admission.ready) {
4308
+ const names = admission.checks?.length ? admission.checks.join(", ") : "the required checks";
4309
+ return `Hub-verified at ${head}: required checks ${names} passed at this exact head${admission.reusedRun !== undefined ? ` via identical-tree run ${admission.reusedRun}` : ""}; no bot review thread is waiting for a reply.\n`;
4310
+ }
4311
+ return `Hub-verified at ${head}: not ready: ${admission.blockers.join("; ")}.\n`;
4312
+ }
4313
+
4009
4314
  private sendReview(task: Task, reviewer: PeerId, why = "", admission?: ReviewReadiness): boolean {
4010
4315
  const mode = (this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode;
4011
4316
  if (mode === "enforce" && !admission?.ready) {
@@ -4030,7 +4335,15 @@ export class Tasks {
4030
4335
  : profile.name === "general"
4031
4336
  ? "No check ran for this class: verify the outcome yourself."
4032
4337
  : "No check ran for this class: run the affected tests yourself.";
4033
- const checkResult = check ? `Check result: ${(check.note ?? "").split("\n")[0]}` : noCheckWording;
4338
+ // #824: the hub's own exact-head CI evidence, from the admission this request already carries (no new gh call). Nothing here comes from the builder's summary or a recorded claim.
4339
+ const codeScoped = this.d.reviewScope?.(reviewer) === "code";
4340
+ const verified = codeScoped ? "" : this.hubVerifiedBlock(task, admission);
4341
+ const ciPassed = admission?.ready === true && !this.isPii(task);
4342
+ // #852 AC2: a code-scope reviewer's sandbox cannot run tests or read CI, so its Hub-verified and check lines are replaced by one fixed line.
4343
+ const checkResult = codeScoped ? CODE_SCOPE_LINE
4344
+ : check ? `Check result: ${(check.note ?? "").split("\n")[0]}`
4345
+ : ciPassed ? "CI passed at this exact head (see the Hub-verified line above); do not list test execution or CI evidence as unmet: name the specific missing test or behaviour instead."
4346
+ : noCheckWording;
4034
4347
  const checklistTemplates = profile.review_checklist ?? [
4035
4348
  "Map the changed signatures and call sites to {target}, and name each one that does not match.",
4036
4349
  "{check}",
@@ -4051,10 +4364,16 @@ export class Tasks {
4051
4364
  ? ["Deliverables (the hub hashed these files when the task was done; approval binds these hashes):",
4052
4365
  ...delivered.map(d => `- ${d.id}: ${d.path ?? "(path withheld)"} [${d.format}${d.version ? ` ${d.version}` : ""}] sha256 ${d.sha256.slice(0, 16)}, ${d.bytes} bytes`)].join("\n") + "\n"
4053
4366
  : "";
4367
+ const rulings = this.plannerRulings(task, reviewer);
4368
+ const rulingsBlock = rulings.length ? rulings.map(ruling => [
4369
+ `Planner rulings (recorded by the configured planner ${ruling.by}; they amend the plan for this review):`,
4370
+ `- ${new Date(ruling.at).toISOString()}: ${ruling.note.replace(/[\r\n]+/g, " ")}`,
4371
+ ].join("\n")).join("\n") + "\n" : "";
4054
4372
  const checklist = deliverablesBlock + criteriaBlock + [
4055
4373
  "Checklist:",
4056
4374
  ...this.planGuardItems(task).map(item => `- Check the plan against this live fail before the remaining checks: ${item.line}`),
4057
4375
  ...checklistLines.map(line => `- ${line}`),
4376
+ ...(rulings.length ? ["- a departure a listed ruling names is not an unmet item by itself; judge its code."] : []),
4058
4377
  ].join("\n");
4059
4378
  // #223 R2: a reviewer-only instance judges from facts; the builder's narrative stays out, earlier findings go in.
4060
4379
  const reviewOnly = this.d.reviewOnly?.(reviewer) === true;
@@ -4072,7 +4391,7 @@ export class Tasks {
4072
4391
  const brief = this.briefText(task);
4073
4392
  // #572 AC3: advisory only, from git state never from task text, so it is safe for a PII task too.
4074
4393
  const checkout = mainCheckoutWarning(this.d.cwd, this.publicMetadataText);
4075
- const body = `Review task #${task.id} [${profileTaskLabel(task)}] ${task.title}\nDone by ${last?.by ?? task.owner}: ${summary}\n${where ? `Where: ${where}\n` : ""}${last?.noWrites ? "no writes recorded since acceptance\n" : ""}${spec}${why ? `${why}\n` : ""}${!plan && task.detail ? `Task detail:\n${task.detail}\n` : ""}${guidance ? guidance + "\n" : ""}${projection ? projection + "\n" : ""}${brief ? brief + "\n" : ""}${checkout ? `Note: ${checkout}.\n` : ""}${findings.length ? `Earlier findings (set a disposition for each):\n${findings.join("\n")}\n` : ""}${carriedText}${conformanceText}${checklist}\nGive your verdict with hub_review {id: ${task.id}, verdict: "approved" | "changes_requested", note, unmet, findings: [{id, pass: "bugs" | "security" | "compliance", severity: "important" | "nit", path?, disposition: "open" | "fixed" | "false_positive"}]}. Reuse finding ids when updating their disposition; only the person may accept a finding with a rationale.`;
4394
+ const body = `Review task #${task.id} [${profileTaskLabel(task)}] ${task.title}\nDone by ${last?.by ?? task.owner}: ${summary}\n${where ? `Where: ${where}\n` : ""}${last?.noWrites ? "no writes recorded since acceptance\n" : ""}${spec}${why ? `${why}\n` : ""}${!plan && task.detail ? `Task detail:\n${task.detail}\n` : ""}${guidance ? guidance + "\n" : ""}${projection ? projection + "\n" : ""}${brief ? brief + "\n" : ""}${checkout ? `Note: ${checkout}.\n` : ""}${findings.length ? `Earlier findings (set a disposition for each):\n${findings.join("\n")}\n` : ""}${carriedText}${conformanceText}${rulingsBlock}${verified}${checklist}\nGive your verdict with hub_review {id: ${task.id}, verdict: "approved" | "changes_requested", note, unmet, findings: [{id, pass: "bugs" | "security" | "compliance", severity: "important" | "nit", path?, disposition: "open" | "fixed" | "false_positive"}]}. Reuse finding ids when updating their disposition; only the person may accept a finding with a rationale.`;
4076
4395
  return 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 } : {}) })).includes(reviewer);
4077
4396
  }
4078
4397
 
@@ -4216,6 +4535,11 @@ export class Tasks {
4216
4535
  let parsed: ReviewFinding[];
4217
4536
  try { parsed = parseReviewFindings(findings, { person: by === USER, maxNits: this.d.sdlc?.config().maxNits ?? DEFAULT_MAX_NITS }); }
4218
4537
  catch (error) { throw new PreEffectToolRefusal(error instanceof Error ? error.message : "invalid structured findings"); }
4538
+ // #852 AC3 (amended): a code-scope reviewer's changes_requested is always recorded, never refused. Without an open structured finding it is
4539
+ // evidence-only: it does not count toward rejections or escalation, the task returns to its owner as usual, and the conductor gets one ids-only
4540
+ // notice. The rule is structural (a finding with disposition open), never a reading of the unmet text.
4541
+ const codeScope = by !== USER && this.d.reviewScope?.(by) === "code";
4542
+ const evidenceOnly = codeScope && verdict === "changes_requested" && !parsed.some(finding => finding.disposition === "open");
4219
4543
  const resolvedHead = this.findingHead(initial);
4220
4544
  let findingHead = resolvedHead;
4221
4545
  if (head !== undefined) {
@@ -4297,7 +4621,7 @@ export class Tasks {
4297
4621
  }
4298
4622
  if (verdict === "approved" && this.d.sdlc && this.d.sdlc.config().mode !== "off") { await this.lifecycleGate(task, "approval", reviewedHead, proposedSets(task)); task = valid(); }
4299
4623
  let candidate: Extract<CandidateInput, { kind: "fail" }> | undefined;
4300
- if (verdict === "changes_requested" && items.length && this.d.candidates && !withheld && !this.isPii(task) && !this.d.privateTurn?.(by)) {
4624
+ if (verdict === "changes_requested" && !evidenceOnly && items.length && this.d.candidates && !withheld && !this.isPii(task) && !this.d.privateTurn?.(by)) {
4301
4625
  const input: CandidateInput = { project: this.d.store?.project ?? this.d.project, kind: "fail", approach: this.publicTitle(task), symptom: items.join("\n"),
4302
4626
  cause: "unknown: review unmet items do not establish a cause", retryWhen: "unknown: a person must review the unmet items before accepting",
4303
4627
  evidenceRefs: [`task:${task.id}`, `reviewed-head:${reviewedHead ?? task.refs.commit ?? "unknown"}`] };
@@ -4316,9 +4640,11 @@ export class Tasks {
4316
4640
  // #507b: unmet items that start with a recorded criterion id name that criterion. The ids come from the checklist on the board as it is at this
4317
4641
  // write (the `task` just rechecked by valid()), never from the caller; a PII task carries none, its checklist being private.
4318
4642
  const unmetCriteria = pii ? [] : unmetCriteriaIds(items, checklistOf(task.sdlc));
4319
- const flag = { ...this.planObservation(task), withheld: withheld || undefined, ...(report ? { reviewFindings: report } : {}), ...(unmetCriteria.length ? { unmetCriteria } : {}), ...(boundDeliverables ? { deliverables: boundDeliverables } : {}), ...(reviewContext ? { reviewContext } : {}), ...(reviewerFamily ? { reviewerFamily } : {}), ...(reviewerReason ? { reviewerReason } : {}) };
4643
+ const flag = { ...this.planObservation(task), withheld: withheld || undefined, ...(report ? { reviewFindings: report } : {}), ...(unmetCriteria.length ? { unmetCriteria } : {}), ...(boundDeliverables ? { deliverables: boundDeliverables } : {}), ...(reviewContext ? { reviewContext } : {}), ...(codeScope ? { reviewScope: "code" as const } : {}), ...(evidenceOnly ? { evidenceOnly: true as const } : {}), ...(reviewerFamily ? { reviewerFamily } : {}), ...(reviewerReason ? { reviewerReason } : {}) };
4320
4644
  if (verdict === "approved") {
4645
+ const capacity = this.capacityBefore(task);
4321
4646
  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);
4647
+ this.writePlannerNotices(next, capacity, next.state === "approved");
4322
4648
  if (next.owner) {
4323
4649
  this.d.board.recordOutcome(next.owner, next.class, true);
4324
4650
  this.d.board.recordReview({ implementer: next.owner, reviewer: by, class: next.class, kind: "approved", task: next.id });
@@ -4332,20 +4658,24 @@ export class Tasks {
4332
4658
  this.releaseNotice(`task ${this.publicTitle(next)}: could not tell ${next.owner ?? "its owner"} it was approved`, () =>
4333
4659
  this.tell(next, `Task #${next.id} approved by ${by}.${note ? ` ${this.screen(next, note, "review note", next.owner, withheld)}` : ""}`, pii));
4334
4660
  await this.releaseDependents(next);
4661
+ this.autoMerge(next, this.mirrorVerdict(next, by, "approved", reviewedHead ?? next.sdlc?.reviewedHead, note, withheld));
4335
4662
  // A proposal storage failure cannot roll back the saved verdict; another matching verdict retries it.
4336
4663
  this.releaseNotice(`task #${next.id}: review saved but recurring-finding proposal unavailable`, () => this.proposeRecurringFindings(next, by, parsed, withheld));
4337
4664
  return next;
4338
4665
  }
4339
- const rejected = this.d.board.update(task.id, by, "changes_requested", { state: "changes_requested", rejections: task.rejections + 1 }, note, flag);
4340
- this.releaseNotice(`task #${rejected.id}: review saved but recurring-finding proposal unavailable`, () => this.proposeRecurringFindings(rejected, by, parsed, withheld));
4666
+ const rejected = this.d.board.update(task.id, by, "changes_requested", { state: "changes_requested", rejections: evidenceOnly ? task.rejections : task.rejections + 1 }, note, flag);
4667
+ void this.mirrorVerdict(rejected, by, "changes_requested", reviewedHead ?? rejected.sdlc?.reviewedHead, note, withheld);
4668
+ if (!evidenceOnly) this.releaseNotice(`task #${rejected.id}: review saved but recurring-finding proposal unavailable`, () => this.proposeRecurringFindings(rejected, by, parsed, withheld));
4341
4669
  if (candidate && !pii && !this.d.privateTurn?.(by) && this.candidateTextAllowed(JSON.stringify({ candidate, refs: rejected.refs, plan: rejected.plan }), rejected)) {
4342
4670
  try { this.d.memoryCheck?.(); this.d.candidates?.put({ ...candidate, evidenceRefs: [...candidate.evidenceRefs, `review:${rejected.id}:${rejected.history.length}`] }, "public"); } catch { /* a saved review remains authoritative if the optional sidecar fails */ }
4343
4671
  }
4344
4672
  this.cohorts.withdraw(rejected.id); // open again: its done intent no longer counts (issue #107)
4345
- if (rejected.owner) this.d.board.recordOutcome(rejected.owner, rejected.class, false);
4346
- this.contradict(rejected);
4673
+ // Evidence-only is not a verdict on the owner's work: no failed outcome, no contradiction of an earlier approval, and no escalation count.
4674
+ if (rejected.owner && !evidenceOnly) this.d.board.recordOutcome(rejected.owner, rejected.class, false);
4675
+ if (!evidenceOnly) this.contradict(rejected);
4347
4676
  this.note(rejected, by, "decision", `Task #${rejected.id} changes requested by ${by}: ${rejected.title}\n${note ?? ""}`, withheld);
4348
- if (rejected.rejections >= (this.d.escalateAfter?.() ?? ESCALATE_AFTER)) {
4677
+ if (evidenceOnly) this.tellConductorEvidenceOnly(rejected, by);
4678
+ if (!evidenceOnly && rejected.rejections >= (this.d.escalateAfter?.() ?? ESCALATE_AFTER)) {
4349
4679
  let moved: Task;
4350
4680
  try {
4351
4681
  moved = await this.escalate(HUB, rejected.id, `${rejected.rejections} consecutive changes_requested`, "rejections");
@@ -4370,6 +4700,19 @@ export class Tasks {
4370
4700
  return reopened;
4371
4701
  }
4372
4702
 
4703
+ /**
4704
+ * #852 AC3: one task-weight notice to the configured conductor for a verdict recorded as evidence-only. Ids only (task and reviewer), so nothing of
4705
+ * the review or the task reaches it; a PII task's notice is private. With no conductor the person sees one console notice instead.
4706
+ */
4707
+ private tellConductorEvidenceOnly(task: Task, reviewer: PeerId): void {
4708
+ const conductors = Object.entries(this.d.roles ?? {}).filter(([, roles]) => Array.isArray(roles) && roles.includes("conductor")).map(([actor]) => actor).filter(actor => actor !== reviewer);
4709
+ const text = evidenceOnlyNotice(task.id, reviewer);
4710
+ if (!conductors.length) { this.releaseNotice(text); return; }
4711
+ this.releaseNotice(`task #${task.id}: could not tell the conductor a code-scope review was evidence-only`, () => {
4712
+ this.d.bus.publish(newEnvelope(HUB, sanitize(text), { to: conductors, kind: "task", refs: { task: String(task.id) }, ...(this.isPii(task) ? { private: true } : {}) }));
4713
+ });
4714
+ }
4715
+
4373
4716
  /** Task-level escalation: the next attached peer in the class's escalate_to takes over, with the history. */
4374
4717
  async escalate(by: PeerId, id: unknown, why = "by hand", reason: TaskMoveReason | undefined = by === HUB ? undefined : "manual", preEffect = false): Promise<Task> {
4375
4718
  let task = this.need(id, true, preEffect);
@@ -4531,17 +4874,20 @@ export class Tasks {
4531
4874
  }
4532
4875
 
4533
4876
  /** A roster removal offers work through the quota relay's constraints, preserving assignments nobody can take. */
4534
- async reassignForRoster(peer: PeerId): Promise<{ id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]> {
4535
- return this.reassignWork(peer, undefined, false, true);
4877
+ async reassignForRoster(peer: PeerId, current?: () => void): Promise<{ id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]> {
4878
+ return this.reassignWork(peer, undefined, false, true, current);
4536
4879
  }
4537
4880
 
4538
- private async reassignWork(peer: PeerId, context: string | undefined, urgentOnly: boolean, roster: boolean): Promise<{ id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]> {
4881
+ private async reassignWork(peer: PeerId, context: string | undefined, urgentOnly: boolean, roster: boolean, current?: () => void): Promise<{ id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]> {
4882
+ current?.();
4539
4883
  const moved: { id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[] = [];
4540
4884
  const routing = this.d.routing(), why = roster ? `roster removal of ${peer}` : `budget pause of ${peer}`;
4541
4885
  // Budget context is screened exactly as before; a roster removal supplies no agent-written context.
4542
4886
  const moves = (t: Task) => (!urgentOnly || t.signals.includes("urgent")) && t.owner === peer && OPEN.includes(t.state);
4543
4887
  const contextWithheld = context && this.d.board.list().some((t) => moves(t) && !this.isPii(t)) ? await this.withholds("handoff", context) : false;
4888
+ current?.();
4544
4889
  for (const task of this.d.board.list()) {
4890
+ current?.();
4545
4891
  if (urgentOnly && !task.signals.includes("urgent")) continue;
4546
4892
  if (task.owner === peer && (OPEN.includes(task.state) || roster && task.state === "in_review")) {
4547
4893
  const pii = this.isPii(task);
@@ -4552,19 +4898,22 @@ export class Tasks {
4552
4898
  const back = !roster && task.state === "in_progress" ? this.d.board.update(task.id, HUB, "released", { state: "proposed" }, why) : task;
4553
4899
  let next: Task;
4554
4900
  try {
4555
- next = await this.assignOwner(back, HUB, { candidates, exclude: [peer], event: "reassigned", reason: roster ? "manual" : "budget", note: why, ...(roster ? { roster: true } : { clearOnFail: true }), ...(context ? { context, contextWithheld } : {}) });
4901
+ next = await this.assignOwner(back, HUB, { current, candidates, exclude: [peer], event: "reassigned", reason: roster ? "manual" : "budget", note: why, ...(roster ? { roster: true } : { clearOnFail: true }), ...(context ? { context, contextWithheld } : {}) });
4556
4902
  } catch (e) {
4557
4903
  // The move is saved and its central #297 notice already spoke; relay the
4558
4904
  // remaining tasks and run the roster tail on the saved row as on success.
4559
4905
  if (!(e instanceof AssignmentUndeliveredError)) throw e;
4560
4906
  next = e.task;
4561
4907
  }
4908
+ // A saved assignment failure is retained, but never permits a revoked initiating operation to continue.
4909
+ current?.();
4562
4910
  if (!this.ownOwnerMove(back, next, HUB, "reassigned") && !this.ownOwnerMove(back, next, HUB, "unassigned")) continue;
4563
4911
  if (roster) {
4564
4912
  if (!next.owner || next.owner === peer) continue;
4565
4913
  this.releaseNotice(`task ${this.publicTitle(next)} moved from ${peer} to ${next.owner} (off roster)`);
4566
4914
  if (next.state === "in_review" && next.reviewer && next.reviewer !== task.reviewer && next.reviewer !== USER) this.releaseNotice(`task ${this.publicTitle(next)}: could not send the review request to ${next.reviewer}`, () => this.sendReview(next, next.reviewer!, `(${peer} owned this and is off roster.)`));
4567
4915
  }
4916
+ current?.();
4568
4917
  moved.push({ id: task.id, title: this.publicTitle(task), to: next.owner, role: "owner" });
4569
4918
  } else if (task.reviewer === peer && !isClosedTaskState(task.state)) {
4570
4919
  const a = assign(task, this.states(), routing, { exclude: [peer], candidates: [], ...(task.owner ? { notReviewer: task.owner } : {}), ...this.weights(task.class, task) });
@@ -4573,8 +4922,10 @@ export class Tasks {
4573
4922
  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}`);
4574
4923
  continue;
4575
4924
  }
4925
+ current?.();
4576
4926
  const next = this.d.board.update(task.id, HUB, "reviewer changed", { reviewer }, this.assignmentNote(why, a), this.reviewMetadata(a));
4577
4927
  moved.push({ id: task.id, title: this.publicTitle(task), to: reviewer, role: "reviewer" });
4928
+ current?.();
4578
4929
  if (next.state === "in_review" && reviewer && reviewer !== USER) {
4579
4930
  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.)`));
4580
4931
  else this.sendReview(next, reviewer, `(${peer} was reviewing this and is paused for quota.)`);