@staix/agent-hub 0.18.0 → 0.20.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 (90) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +4 -4
  3. package/docs/agent-notes/README.md +6 -0
  4. package/docs/agent-notes/adapters.md +13 -10
  5. package/docs/agent-notes/bus.md +1 -1
  6. package/docs/agent-notes/daemon.md +11 -4
  7. package/docs/agent-notes/local-worker.md +3 -1
  8. package/docs/agent-notes/tasks.md +6 -1
  9. package/docs/agent-notes/tests.md +8 -4
  10. package/docs/dogfood.md +13 -4
  11. package/docs/memory.md +1 -1
  12. package/docs/operations.md +98 -28
  13. package/docs/quickstart.md +13 -5
  14. package/docs/sdlc.md +12 -0
  15. package/docs/security.md +24 -15
  16. package/docs/smoke.md +3 -3
  17. package/docs/specs/2026-09-19-agent-hub-design.md +101 -8
  18. package/docs/verification/2026-10-25-remote-docker-transport.md +10 -0
  19. package/docs/verification/2026-10-26-agy-1.4.7.md +69 -0
  20. package/docs/verification/2026-10-26-claude-implementer-2.1.310.md +69 -0
  21. package/docs/verification/2026-10-26-headless-claude-shell-context.md +62 -0
  22. package/docs/verification/2026-10-26-jcode-0.99.1.md +49 -0
  23. package/docs/verification/2026-10-26-kimi-232-reviewer-compat.md +53 -0
  24. package/docs/verification/2026-10-26-pi-140-compat.md +41 -0
  25. package/docs/verification/2026-10-26-qwen-0261-acp.md +60 -0
  26. package/docs/verification/2026-10-27-0.19.0.md +86 -0
  27. package/docs/verification/2026-10-27-0.20.0.md +75 -0
  28. package/docs/verification/claude-permission-transport-native.py +187 -0
  29. package/docs/verification/claude-shell-wrapper-host.ts +20 -0
  30. package/docs/verification/claude-shell-wrapper-native.py +145 -0
  31. package/docs/verification/pi-140-model-free.ts +57 -0
  32. package/docs/verification/pi-140-native.mjs +21 -0
  33. package/docs/verified.json +25 -17
  34. package/package.json +2 -2
  35. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  36. package/plugins/agent-hub/server.js +373 -72
  37. package/src/adapters/acp.ts +8 -0
  38. package/src/adapters/agy/entry.ts +1 -1
  39. package/src/adapters/channel-peer.ts +7 -5
  40. package/src/adapters/claude-channel.ts +7 -3
  41. package/src/adapters/claude-implementer.ts +6 -3
  42. package/src/adapters/codex-appserver.ts +11 -1
  43. package/src/adapters/compat.ts +32 -18
  44. package/src/adapters/conformance/enrollment.ts +4 -4
  45. package/src/adapters/conformance/factories.ts +2 -1
  46. package/src/adapters/conformance/fakes/pi-rpc.ts +1 -0
  47. package/src/adapters/conformance/kit.ts +10 -0
  48. package/src/adapters/definitions/builtin-metadata.ts +2 -2
  49. package/src/adapters/definitions/native-factories.ts +7 -2
  50. package/src/adapters/definitions/reviewer-variants.ts +2 -2
  51. package/src/adapters/local-worker.ts +2 -0
  52. package/src/adapters/pi.ts +46 -3
  53. package/src/adapters/qwen-dual.ts +25 -0
  54. package/src/adapters/registry-types.ts +6 -2
  55. package/src/adapters/registry.ts +1 -0
  56. package/src/adapters/reviewer-print.ts +1 -0
  57. package/src/cli/agents.ts +69 -7
  58. package/src/cli/claude-shell-wrapper.ts +54 -0
  59. package/src/cli/main.ts +30 -24
  60. package/src/cli/output.ts +4 -0
  61. package/src/hub/agent-drift.ts +61 -0
  62. package/src/hub/board.ts +14 -2
  63. package/src/hub/check-runner.ts +148 -18
  64. package/src/hub/ci-watch.ts +140 -0
  65. package/src/hub/claude-launch-proof.ts +67 -2
  66. package/src/hub/claude-shell-wrapper.ts +349 -0
  67. package/src/hub/config-apply.ts +2 -2
  68. package/src/hub/config-edit.ts +2 -2
  69. package/src/hub/config-tier.ts +2 -2
  70. package/src/hub/config-trust.ts +1 -0
  71. package/src/hub/daemon.ts +282 -113
  72. package/src/hub/host-executable.ts +20 -1
  73. package/src/hub/instances.ts +18 -2
  74. package/src/hub/lifecycle.ts +1 -1
  75. package/src/hub/operational-authority.ts +7 -0
  76. package/src/hub/operational-view.ts +6 -10
  77. package/src/hub/peers.ts +34 -0
  78. package/src/hub/read-only-shell-proof.ts +7 -6
  79. package/src/hub/review-scope.ts +13 -0
  80. package/src/hub/sdlc-metrics.ts +6 -4
  81. package/src/hub/settings.ts +1 -0
  82. package/src/hub/task-shell-command.ts +85 -0
  83. package/src/hub/task-shell-default.ts +79 -0
  84. package/src/hub/task-shell-proof.ts +171 -0
  85. package/src/hub/task-shell-sandbox.ts +61 -0
  86. package/src/hub/tasks.ts +274 -16
  87. package/src/local/deny.ts +112 -11
  88. package/src/local/sandbox.ts +70 -29
  89. package/src/local/shell-environment.ts +10 -0
  90. package/src/ui/index.html +1 -0
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";
@@ -16,6 +18,7 @@ import { BACKLOG_SPLIT_SCREEN_TIMEOUT_MS, actualTaskDates, planningPatch, type P
16
18
  import { readMergeQueue, type DeliveryTask } from "./sdlc-queue.ts";
17
19
  import { guardedMerge, mergeModeFor, mergeView, type MergeView } from "./sdlc-merge.ts";
18
20
  import { mirrorVerdict as mirrorToGitHub, type MirrorVerdict } from "./sdlc-mirror.ts";
21
+ import { CODE_SCOPE_LINE, evidenceOnlyNotice, type ReviewScope } from "./review-scope.ts";
19
22
  import { DEFAULT_MAX_NITS, parseReviewFindings, findingsForHead, findingHead as resolveFindingHead, findingTally, type ReviewFinding, type ReviewFindingSet } from "./review-findings.ts";
20
23
  import { readReleaseReadiness } from "./sdlc-release.ts";
21
24
  import { loadRoleCatalog, taskRole, taskRoleText, type RoleCatalog } from "./roles.ts";
@@ -46,6 +49,7 @@ import { coverageGap, renderReviewBrief, type ReviewBrief } from "./review-brief
46
49
  import { definitionOf as builtinDefinitionOf } from "../adapters/registry.ts";
47
50
  import { ADAPTERS, DEFAULT_REVIEW_POOL, definitionOf, familyOf, type ProjectAdapterCatalog } from "../adapters/registry.ts";
48
51
  import { plannerGrooming, nextSweep, taskSweepConfig, type TaskSweepConfig, type SweepRecord } from "./task-sweep.ts";
52
+ import { CiWatchers, pollCi, type CiOutcome, type CiWatch } from "./ci-watch.ts";
49
53
  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";
50
54
 
51
55
  export type TaskAssignmentRole = "owner" | "reviewer" | "planner";
@@ -95,6 +99,10 @@ export interface TasksDeps {
95
99
  /** Host-verified existing symbols at this task's admitted completion head; never plan guesses. */
96
100
  memorySymbolNodes?: (task: Task) => MemorySymbolNodes | undefined;
97
101
  sdlc?: { config: () => SdlcConfig; gh?: SdlcGh };
102
+ /** #785 P1b: the per-head CI watcher's bounds; tests shorten them, the defaults are `CI_WATCH_*` in `ci-watch.ts`. */
103
+ ciWatch?: { deadlineMs?: number; pollMs?: number };
104
+ /** #785 P1c: `tasks.auto_pull`, read at each capacity release so a reload applies to the next one; absent or false pulls nothing. */
105
+ autoPull?: () => boolean;
98
106
  briefs?: Briefs;
99
107
  /** Screened live fail notes directly intersecting the task plan or refs; read-only advice. */
100
108
  planFails?: (task: Task) => NativeItem[];
@@ -104,6 +112,8 @@ export interface TasksDeps {
104
112
  privateTurn?: (peer: PeerId) => boolean;
105
113
  /** #223 R4: the hub's reading of a verdict's context (`fresh` only for a reviewer session started for this review). */
106
114
  reviewContext?: (by: PeerId, task: number) => "fresh" | "continuing" | undefined;
115
+ /** #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. */
116
+ reviewScope?: (peer: PeerId) => ReviewScope;
107
117
  /** #223 R2: a reviewer-only instance gets review facts, not the builder's done summary. */
108
118
  reviewOnly?: (peer: PeerId) => boolean;
109
119
  /** #223: captured when a verdict call starts; a non-undefined result refuses the verdict at every later check. */
@@ -202,6 +212,7 @@ const QUIET_EVENTS = new Set(["answer", "reviewer changed", "idle sweep", "memor
202
212
  QUIET_EVENTS.add("ready held"); // #655: the hub's note that a ready task waits for its reserved owner's WIP slot.
203
213
  QUIET_EVENTS.add("plan observed"); // Host observation does not move the task or replace completion intent.
204
214
  QUIET_EVENTS.add("planner changed"); // Planning authority does not move the execution owner or revoke its handoff.
215
+ QUIET_EVENTS.add("shell branch bound"); // #880: the hub's host-observed branch binding (#845) is bookkeeping; it neither moves nor settles the task.
205
216
  /** The records of the `done (checking)` entry a passed check now completes: what the owner delivered then (#509a). */
206
217
  const latestPendingDeliverables = (task: Task): DeliverableRecord[] | undefined => {
207
218
  const checking = task.history.findLastIndex(entry => entry.event === "done (checking)");
@@ -349,8 +360,17 @@ export class Tasks {
349
360
  private sweepConfig: TaskSweepConfig;
350
361
  private readonly noWriteTurns = new NoWriteTurns();
351
362
  private sweeping = false;
363
+ /** #785 P1b: one bounded CI watcher per exact PR head, and the reviews the hub held as premature, by task id. */
364
+ private readonly ciWatchers: CiWatchers;
365
+ private readonly heldReviews = new Map<number, { head: string; reviewer: PeerId; revision: number }>();
352
366
 
353
367
  constructor(private readonly d: TasksDeps) {
368
+ this.ciWatchers = new CiWatchers({
369
+ ...d.ciWatch,
370
+ poll: watch => pollCi(watch, watch.head, d.sdlc?.config().requiredChecks ?? [], d.sdlc?.gh ?? sdlcGh),
371
+ current: watch => this.ciWatchCurrent(watch),
372
+ finish: (watch, outcome) => this.ciCompleted(watch, outcome),
373
+ });
354
374
  d.board.trackPlanRevisions = () => (d.sdlc?.config().mode ?? "off") !== "off";
355
375
  this.repositories = new TaskRepositories(d.cwd, d.repository);
356
376
  d.board.proposalPlanner = task => this.proposalPlanner(task);
@@ -698,6 +718,8 @@ export class Tasks {
698
718
  ...(h.selfReview ? { selfReview: true } : {}),
699
719
  ...(h.reviewerFamily ? { reviewerFamily: screenHistoryText(h.reviewerFamily, "reviewer family") } : {}),
700
720
  ...(h.reviewerReason ? { reviewerReason: h.reviewerReason } : {}),
721
+ ...(h.reviewScope === "code" ? { reviewScope: "code" as const } : {}),
722
+ ...(h.evidenceOnly ? { evidenceOnly: true as const } : {}),
701
723
  ...(h.unmetCriteria?.length ? { unmetCriteria: h.unmetCriteria } : {}),
702
724
  ...(h.deliverables?.length ? { deliverables: publicDeliverables(h.deliverables, this.nameable) } : {}),
703
725
  ...(h.reviewBlocked ? { reviewBlocked: true } : {}),
@@ -775,7 +797,7 @@ export class Tasks {
775
797
  if (h.owner === undefined || h.owner !== owner) since = i;
776
798
  owner = h.owner;
777
799
  });
778
- return new Set(task.history.slice(since + 1).filter((h) => h.event === "changes_requested").map((h) => h.by));
800
+ return new Set(task.history.slice(since + 1).filter((h) => h.event === "changes_requested" && h.evidenceOnly !== true).map((h) => h.by));
779
801
  }
780
802
 
781
803
  /** Workload is board/queue data only: wip() never calls health() or weights(). */
@@ -872,7 +894,7 @@ export class Tasks {
872
894
  if (!OWNERSHIP_EVENTS.has(given.event) || given.event === "unassigned" || given.owner !== peer || given.profile !== profile || given.by === peer) return [];
873
895
  const next = t.history.findIndex((h, j) => j > i && OWNERSHIP_EVENTS.has(h.event));
874
896
  const span = t.history.slice(i + 1, next < 0 ? undefined : next + 1);
875
- if (span.some((h) => failed.includes(h.event))) return [{ outcome: "failed" }];
897
+ if (span.some((h) => failed.includes(h.event) && h.evidenceOnly !== true)) return [{ outcome: "failed" }];
876
898
  if (next >= 0 || t.state !== "approved") return []; // handed on without a failure (a relay), or still open
877
899
  const accepted = span.find((h) => h.event === "accepted" && h.by === peer);
878
900
  // The work stage ends at its first done call: checks and an integration step are not the work itself.
@@ -1038,6 +1060,129 @@ export class Tasks {
1038
1060
  wip(peer: PeerId, except?: number): number {
1039
1061
  return this.d.board.plannerWip(peer, except);
1040
1062
  }
1063
+
1064
+ /** Host-observed task branch/root binding (#845), never a tool-call branch claim or an execution grant. */
1065
+ bindShellBranch(peer: PeerId, id: number, binding: TaskShellBinding & { worktree: string }, current: () => boolean): boolean {
1066
+ try {
1067
+ if (!Number.isSafeInteger(id) || id < 1 || !taskShellBindingValid(binding) || !this.publicMetadataText(binding.branch)
1068
+ || !current() || this.d.privateTurn?.(peer)) return false;
1069
+ const task = this.d.board.get(id), root = this.d.executionRoot?.(peer);
1070
+ if (!task || task.owner !== peer || !["in_progress", "changes_requested"].includes(task.state) || this.isPii(task)
1071
+ || !root || realPath(root) === realPath(this.d.cwd) || realPath(root) !== realPath(binding.worktree)) return false;
1072
+ const worktree = createHash("sha256").update(realPath(root)).digest("hex");
1073
+ const previous = task.history.findLast(entry => entry.by === HUB && entry.event === "shell branch bound");
1074
+ if (previous) {
1075
+ const saved: unknown = JSON.parse(previous.note ?? "");
1076
+ if (!saved || typeof saved !== "object" || Array.isArray(saved)) return false;
1077
+ const record = saved as Record<string, unknown>;
1078
+ if (record.branch !== binding.branch || record.worktree !== worktree) return false;
1079
+ }
1080
+ if (task.refs.branch !== undefined && task.refs.branch !== binding.branch) return false;
1081
+ const scope = this.repositories.scope(task.refs);
1082
+ const others = this.d.board.list().filter(row => row.id !== id && !isClosedTaskState(row.state))
1083
+ .map(row => ({ row, scope: this.repositories.scope(row.refs) }));
1084
+ if (scope.key !== this.repositories.project.key || others.some(other => other.scope.key === scope.key && other.row.refs.branch === binding.branch)) return false;
1085
+ if (!current() || this.d.privateTurn?.(peer) || this.d.board.get(id)?.history.length !== task.history.length) return false;
1086
+ if (previous) return true;
1087
+ let recorded = false;
1088
+ this.releaseNotice(`task #${id}: execution branch binding unavailable`, () => {
1089
+ this.d.board.update(id, HUB, "shell branch bound", { refs: { branch: binding.branch } }, JSON.stringify({ branch: binding.branch, worktree }), {}, task.history.length);
1090
+ recorded = true;
1091
+ });
1092
+ return recorded;
1093
+ } catch { return false; }
1094
+ }
1095
+ private capacityBefore(task: Task): Map<PeerId, number> {
1096
+ return new Map([...new Set([task.owner, task.reserved])].filter((peer): peer is PeerId => !!peer).map(peer => [peer, this.wip(peer)]));
1097
+ }
1098
+
1099
+ /** Write-driven wake-ups (#785): one unsettled ids-only task notice per planner, never a polling pull. */
1100
+ private writePlannerNotices(task: Task, before: ReadonlyMap<PeerId, number>, approved = false): void {
1101
+ this.releaseNotice(`task #${task.id}: could not notify planner about ready work`, () => {
1102
+ const changes = new Map<PeerId, { capacity: Set<PeerId>; ready: Set<number> }>();
1103
+ const add = (planner: PeerId, peer?: PeerId, id?: number) => {
1104
+ if (!this.d.bus.isRostered(planner)) return;
1105
+ const change = changes.get(planner) ?? { capacity: new Set<PeerId>(), ready: new Set<number>() };
1106
+ if (peer) change.capacity.add(peer);
1107
+ if (id !== undefined) change.ready.add(id);
1108
+ changes.set(planner, change);
1109
+ };
1110
+ const released = new Set<PeerId>();
1111
+ for (const [peer, used] of before) if (this.wip(peer) < used) { released.add(peer); for (const planner of this.taskPlanner(task)) add(planner, peer); }
1112
+ if (approved) for (const card of this.d.board.list("backlog")) {
1113
+ if (card.deps?.includes(task.id) && !this.waitsFor(card).length) for (const planner of this.taskPlanner(card)) add(planner, undefined, card.id);
1114
+ }
1115
+ for (const [planner, change] of changes) this.releaseNotice(`could not notify planner ${planner} about ready work`, () => {
1116
+ const pending = this.d.bus.pendingEnvelopes(planner).filter(env => env.from === HUB && env.kind === "task" && env.body.startsWith("Planner work ready:"));
1117
+ // The bus retains these hub-written lines across restart; an in-memory cache would lose unsettled events.
1118
+ for (const env of pending) for (const line of env.body.split("\n")) {
1119
+ const capacity = /^capacity_released: (\S+) WIP \d+; reserved ready (?:#\d+|none)$/.exec(line);
1120
+ if (capacity) change.capacity.add(capacity[1]!);
1121
+ const ready = /^backlog_ready: #(\d+)$/.exec(line);
1122
+ if (ready && Number.isSafeInteger(Number(ready[1]))) change.ready.add(Number(ready[1]));
1123
+ }
1124
+ const lines: string[] = [];
1125
+ for (const peer of change.capacity) {
1126
+ const top = this.topReservedReady(peer, planner);
1127
+ lines.push(`capacity_released: ${peer} WIP ${this.wip(peer)}; reserved ready ${top ? `#${top.id}` : "none"}`);
1128
+ }
1129
+ for (const id of change.ready) {
1130
+ const card = this.d.board.get(id);
1131
+ if (card?.state === "backlog" && this.taskPlanner(card).includes(planner) && !this.waitsFor(card).length) lines.push(`backlog_ready: #${id}`);
1132
+ else change.ready.delete(id);
1133
+ }
1134
+ for (const env of pending) this.d.bus.withdraw(env.id);
1135
+ 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" });
1136
+ if (!this.d.bus.publish(env).includes(planner)) throw new Error("the notice was not queued");
1137
+ });
1138
+ // #785 P1c: only this write's own release, never a merged earlier one, and only when the person turned it on.
1139
+ if (this.d.autoPull?.() === true) this.queueAutoPulls(released);
1140
+ });
1141
+ }
1142
+
1143
+ /** The top card explicitly reserved for `peer` that this planner may pull now; the notice and the opt-in pull read the same rule. `pullable` keeps the staged backlog cards only. */
1144
+ private topReservedReady(peer: PeerId, planner?: PeerId, pullable = false): Task | undefined {
1145
+ return this.d.board.list().filter(card => (pullable ? card.state === "backlog" : ["backlog", "proposed"].includes(card.state)) && !card.owner && card.reserved === peer
1146
+ && (planner === undefined || this.taskPlanner(card).includes(planner)) && !(pullable && this.isPii(card)) && !card.pinned && taskCanPlan(card) && !this.waitsFor(card).length && !this.excluded(card).includes(peer)
1147
+ && (card.release == null || ["planning", "active"].includes(this.d.board.release(card.release)?.state ?? ""))).sort(this.readyOrder)[0];
1148
+ }
1149
+
1150
+ /**
1151
+ * #785 P1c: `tasks.auto_pull`. A slot freed for a peer pulls the one top backlog card explicitly reserved for it, through `pullPlanned` as the hub acting for that card's planner of
1152
+ * record: revision, dependency, reservation, pinned, frozen-release, P0 order and WIP checks all run there unchanged. One card per release. Never an unreserved card, never PII.
1153
+ * Serialized on one chain, so two releases cannot pick the same card; each job re-reads the board, so a card that moved meanwhile is skipped.
1154
+ */
1155
+ private autoPulls: Promise<void> = Promise.resolve();
1156
+ private autoPullsOpen = 0;
1157
+ /** Queued or running auto pulls, so a recovery commit waits for them the way it waits for `checksPending` (#785 P1c): one that is mid-admission would otherwise write after the commit's digest. */
1158
+ autoPullsPending = (): number => this.autoPullsOpen;
1159
+ private queueAutoPulls(released: ReadonlySet<PeerId>): void {
1160
+ for (const peer of released) {
1161
+ this.autoPullsOpen++;
1162
+ this.autoPulls = this.autoPulls.then(() => this.autoPull(peer)).catch(() => { /* autoPull reports its own refusals */ }).finally(() => { this.autoPullsOpen--; });
1163
+ }
1164
+ }
1165
+ /** Whether the hub may still start work on its own: the setting is on, and no recovery, shutdown or storage hold is active. Read at entry and again right before the assignment write. */
1166
+ private autoPullAdmitted(): boolean {
1167
+ return this.d.autoPull?.() === true && !this.d.sweepHeld?.() && !this.d.bus.isRecoveryHeld && !this.d.bus.storageError;
1168
+ }
1169
+ private async autoPull(peer: PeerId): Promise<void> {
1170
+ if (!this.autoPullAdmitted()) return;
1171
+ const card = this.topReservedReady(peer, undefined, true);
1172
+ if (!card) return;
1173
+ // The planner of record is the card's own: its bound planner, else the first current holder, and it must be rostered. A private card never gets here (topReservedReady skips it).
1174
+ const planner = this.taskPlanner(card)[0];
1175
+ if (!planner || !this.d.bus.isRostered(planner)) return;
1176
+ const reason = `auto_pull: capacity released for ${peer}; hub acted for planner ${planner}`;
1177
+ try {
1178
+ await this.pullPlanned(HUB, { id: card.id, revision: card.planningRevision ?? 0, reason }, { onBehalfOf: planner, guard: () => { if (!this.autoPullAdmitted()) throw new PreEffectToolRefusal("auto pull is no longer admitted: tasks.auto_pull was turned off or a recovery, shutdown or storage hold began"); } });
1179
+ } catch (e) {
1180
+ // A guard refusal is the setting or a hold doing its job, not a failure to report.
1181
+ if (!this.autoPullAdmitted()) return;
1182
+ this.releaseNotice(`task ${this.publicTitle(card)}: auto_pull for ${peer} refused: ${e instanceof Error ? e.message : String(e)}`);
1183
+ }
1184
+ }
1185
+
1041
1186
  /** #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()`. */
1042
1187
  wipBreakdown(peers: readonly PeerId[]): Record<string, { wip: number; active: number; reviewWait: number; reserved: number; wipLimit: number }> {
1043
1188
  const grouped = this.d.board.plannerWipBreakdown();
@@ -1079,15 +1224,20 @@ export class Tasks {
1079
1224
  return assign({ ...task, state: "proposed" }, states, routing, { exclude: this.excluded(task), excludeOwners: full, ...(reservedOnly && task.reserved ? { candidates: [task.reserved], namedOwner: task.reserved } : {}), waitsFor: this.waitsFor(task), ...this.weights(task.class, task) });
1080
1225
  }
1081
1226
 
1082
- async pullPlanned(by: PeerId, input: { id: number; revision: number; reason: string }): Promise<Task> {
1227
+ async pullPlanned(by: PeerId, input: { id: number; revision: number; reason: string }, opts: { onBehalfOf?: PeerId; guard?: () => void } = {}): Promise<Task> {
1083
1228
  // #455: one authority check, made before the screen and again after it (a person may move the planner meanwhile).
1084
- const authorized = () => this.planningAuthority(by);
1229
+ // #785 P1c: `by` is the actor the history records; the hub's opt-in auto pull names the planner it acts for, and that planner's authority is what is checked.
1230
+ if (opts.onBehalfOf !== undefined && by !== HUB) throw new PreEffectToolRefusal("only the hub pulls on a planner's behalf");
1231
+ const planner = opts.onBehalfOf ?? by;
1232
+ // `opts.guard` (the auto pull's own admission, #785 P1c) throws a PreEffectToolRefusal once the pull is no longer wanted; it runs wherever authority is rechecked, so it
1233
+ // also runs after the awaited PII screen and right before the assignment write, where a setting turned off or a recovery that began meanwhile must still stop it.
1234
+ const authorized = () => { this.planningAuthority(planner); opts.guard?.(); };
1085
1235
  authorized();
1086
1236
  if (!Number.isSafeInteger(input.id) || input.id <= 0 || !Number.isSafeInteger(input.revision) || input.revision < 0 || typeof input.reason !== "string" || !input.reason.trim() || input.reason.length > 1000) throw new PreEffectToolRefusal("planner pull needs an id, revision and bounded reason");
1087
1237
  const validate = () => {
1088
1238
  const task = this.d.board.get(input.id);
1089
1239
  if (!task || (task.planningRevision ?? 0) !== input.revision || task.state !== "backlog" || task.owner || !taskCanPlan(task) || task.pinned) throw new PreEffectToolRefusal("planner pull changed, pinned or already started");
1090
- this.planningAuthority(by, task);
1240
+ this.planningAuthority(planner, task);
1091
1241
  const release = task.release == null ? undefined : this.d.board.release(task.release);
1092
1242
  if (task.release != null && (!release || ["frozen", "shipped", "dropped"].includes(release.state))) throw new PreEffectToolRefusal("planner pull requires unfrozen release scope");
1093
1243
  if (this.waitsFor(task).length) throw new PreEffectToolRefusal("planner pull waits for dependency approval");
@@ -1121,6 +1271,7 @@ export class Tasks {
1121
1271
  if (checked.task.history.length !== current.task.history.length || checked.owner !== current.owner) throw new PreEffectToolRefusal("planner pull changed during admission");
1122
1272
  } } });
1123
1273
  }
1274
+ opts.guard?.();
1124
1275
  const pulled = this.d.board.pullPlanned(current.task.id, by, current.owner, input.revision, input.reason.trim(), withheld, this.plannerBinding(current.task, current.assignment, true));
1125
1276
  this.planningRecorded({ by, task: pulled.id, fields: ["pull", "reserved"], revision: pulled.planningRevision! });
1126
1277
  return pulled;
@@ -1380,7 +1531,9 @@ export class Tasks {
1380
1531
  if (entry.planning.pull) {
1381
1532
  const release = task.release == null ? undefined : this.d.board.release(task.release);
1382
1533
  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");
1534
+ const capacity = this.capacityBefore(task);
1383
1535
  const returned = this.d.board.undoPlannedPull(id, by, revision);
1536
+ this.writePlannerNotices(returned, capacity);
1384
1537
  this.offered.delete(id); this.incomingBacklog.add(id);
1385
1538
  this.planningRecorded({ by, task: id, fields: ["pull", "reserved"], revision: returned.planningRevision! });
1386
1539
  return returned;
@@ -1760,7 +1913,9 @@ export class Tasks {
1760
1913
  this.plannerWriteAuthority(by);
1761
1914
  const confirmed = validate();
1762
1915
  if (confirmed.history.length !== flow) throw new PreEffectToolRefusal("task changed during untouched return");
1916
+ const capacity = this.capacityBefore(confirmed);
1763
1917
  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);
1918
+ this.writePlannerNotices(returned, capacity);
1764
1919
  this.offered.delete(confirmed.id);
1765
1920
  this.cohorts.leave(confirmed.id);
1766
1921
  this.withdrawRoleRequest(confirmed, returned, "owner");
@@ -1783,7 +1938,9 @@ export class Tasks {
1783
1938
  if (!pull && taskHasStarted(task)) throw new PreEffectToolRefusal(`task #${n}: started work cannot return to backlog`);
1784
1939
  const pulled = task.history.findLast(entry => entry.event === "backlog pulled" || entry.event === "backlog returned");
1785
1940
  const restoreReservation = !pull && pulled?.planning?.pull && !task.history.slice(task.history.lastIndexOf(pulled) + 1).some(entry => entry.event === "reserved" || entry.planning?.fields.includes("reserved"));
1941
+ const capacity = this.capacityBefore(task);
1786
1942
  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 } : {}) });
1943
+ this.writePlannerNotices(next, capacity);
1787
1944
  this.offered.delete(n);
1788
1945
  if (!pull) { this.incomingBacklog.add(n); return next; }
1789
1946
  const waits = this.waitsFor(next);
@@ -2612,7 +2769,10 @@ export class Tasks {
2612
2769
  const task = validate();
2613
2770
  if (task.history.length !== revision) throw new PreEffectToolRefusal("task flow revision changed");
2614
2771
  const cancelled = { reason: input.reason.trim(), ...(input.supersededBy ? { supersededBy: input.supersededBy.trim() } : {}), flowRevision: revision + 1, ...(withheld ? { withheld: true as const } : {}) };
2772
+ const capacity = this.capacityBefore(task);
2773
+ this.ciWatchers.cancel(task.id); this.heldReviews.delete(task.id);
2615
2774
  const next = this.d.board.update(task.id, by, "cancelled", { state: "cancelled", reserved: null, cancelled }, cancelled.reason, { ...(withheld ? { withheld: true as const } : {}) }, revision);
2775
+ this.writePlannerNotices(next, capacity);
2616
2776
  this.releaseNotice(`task #${task.id}: cancellation cohort cleanup unavailable`, () => this.cohorts.leave(task.id));
2617
2777
  this.offered.delete(task.id);
2618
2778
  this.releaseNotice(`task #${task.id}: cancellation stall cleanup unavailable`, () => this.d.board.clearStall(task.id));
@@ -2863,7 +3023,7 @@ export class Tasks {
2863
3023
  }
2864
3024
  const rows = this.d.board.list();
2865
3025
  const tasks: MetricTask[] = rows.map(task => ({ id: task.id, private: this.isPii(task), history: task.history.map(entry => ({ at: entry.at, event: entry.event,
2866
- ...(entry.reviewCost?.head ? { requestHead: entry.reviewCost.head } : {}), ...(entry.reviewFindings?.head ? { verdictHead: entry.reviewFindings.head } : {}) })) }));
3026
+ ...(entry.reviewCost?.head ? { requestHead: entry.reviewCost.head } : {}), ...(entry.reviewFindings?.head ? { verdictHead: entry.reviewFindings.head } : {}), ...(entry.evidenceOnly ? { evidenceOnly: true as const } : {}) })) }));
2867
3027
  const visibleIds = new Set(rows.filter(task => !this.isPii(task)).map(task => task.id));
2868
3028
  const byKind: Record<string, number> = {};
2869
3029
  for (const stall of this.stalled()) if (visibleIds.has(stall.task)) byKind[stall.cause.kind] = (byKind[stall.cause.kind] ?? 0) + 1;
@@ -3688,12 +3848,17 @@ export class Tasks {
3688
3848
  const task = this.need(id, true, true);
3689
3849
  this.mine(task, by, "owner", true);
3690
3850
  // The owner it is declined for goes on the entry, also when the console declines for it: `excluded()` keeps it out (#207).
3851
+ const capacityBefore = this.capacityBefore(task);
3691
3852
  const planned = this.plannerPullRecord(task)?.pull === true;
3692
3853
  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 } : {}) } : {});
3693
3854
  if (planned) this.offered.delete(task.id);
3694
3855
  this.d.notify(`task ${this.publicTitle(back)} declined${capacity === true ? " (capacity only)" : ""} by ${by}${reason && !this.isPii(back) ? `: ${reason}` : ""}`);
3695
3856
  // A capacity decline is not in excluded(), so this move's own reroute leaves the decliner out explicitly.
3696
- return this.assignOwner(back, HUB, { event: "reassigned", reason: "declined", clearOnFail: true, ...(capacity === true && task.owner ? { exclude: [task.owner] } : {}), ...(planned ? { planner: true } : {}) });
3857
+ try {
3858
+ return await this.assignOwner(back, HUB, { event: "reassigned", reason: "declined", clearOnFail: true, ...(capacity === true && task.owner ? { exclude: [task.owner] } : {}), ...(planned ? { planner: true } : {}) });
3859
+ } finally {
3860
+ this.writePlannerNotices(this.d.board.get(task.id) ?? back, capacityBefore);
3861
+ }
3697
3862
  }
3698
3863
 
3699
3864
  /**
@@ -4037,7 +4202,10 @@ export class Tasks {
4037
4202
  const completedRefs = cleanRefs(refs);
4038
4203
  this.warmRepository({ ...task, refs: { ...task.refs, ...completedRefs } });
4039
4204
  const noWrites = this.noWritesRecorded({ ...task, refs: { ...task.refs, ...completedRefs } });
4205
+ const capacity = this.capacityBefore(task);
4040
4206
  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);
4207
+ this.writePlannerNotices(next, capacity, next.state === "approved");
4208
+ this.ciWatchers.cancel(next.id); this.heldReviews.delete(next.id); // a newer head or a move ends the old head's watcher (#785 P1b)
4041
4209
  this.cohorts.closed(next.id); // its owner's next native turn end settles it in its cohort (issue #107)
4042
4210
  const memorySymbols = this.completionMemorySymbols(next);
4043
4211
  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);
@@ -4050,6 +4218,7 @@ export class Tasks {
4050
4218
  }
4051
4219
  else if (reviewer === USER) this.releaseNotice(`task ${this.publicTitle(next)} done by ${by}: review it in the ahub console (Tasks, select #${next.id}, press r) or with ahub review ${next.id} approved|changes_requested [note]`);
4052
4220
  else this.releaseNotice(`task ${this.publicTitle(next)}: could not send the review request to ${reviewer}`, () => this.sendReview(next, reviewer, "", admission));
4221
+ if (reviewer && !(admission?.ready && admission.head === this.reviewHead(next))) this.watchCi(next);
4053
4222
  return next;
4054
4223
  }
4055
4224
 
@@ -4215,6 +4384,7 @@ export class Tasks {
4215
4384
  if (mode === "enforce" && !admission?.ready) {
4216
4385
  // Background roster/quota role changes are retained, but never invent a remotely admitted review request.
4217
4386
  this.releaseNotice(`task ${this.publicTitle(task)}: premature review held; retry the reviewer assignment after current CI and bot replies are verified`);
4387
+ this.holdReview(task, reviewer);
4218
4388
  return false;
4219
4389
  }
4220
4390
 
@@ -4235,9 +4405,12 @@ export class Tasks {
4235
4405
  ? "No check ran for this class: verify the outcome yourself."
4236
4406
  : "No check ran for this class: run the affected tests yourself.";
4237
4407
  // #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.
4238
- const verified = this.hubVerifiedBlock(task, admission);
4408
+ const codeScoped = this.d.reviewScope?.(reviewer) === "code";
4409
+ const verified = codeScoped ? "" : this.hubVerifiedBlock(task, admission);
4239
4410
  const ciPassed = admission?.ready === true && !this.isPii(task);
4240
- const checkResult = check ? `Check result: ${(check.note ?? "").split("\n")[0]}`
4411
+ // #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.
4412
+ const checkResult = codeScoped ? CODE_SCOPE_LINE
4413
+ : check ? `Check result: ${(check.note ?? "").split("\n")[0]}`
4241
4414
  : 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."
4242
4415
  : noCheckWording;
4243
4416
  const checklistTemplates = profile.review_checklist ?? [
@@ -4291,6 +4464,67 @@ export class Tasks {
4291
4464
  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);
4292
4465
  }
4293
4466
 
4467
+ /** #785 P1b: remember a review the hub held as premature, so a green CI at this exact head can re-offer it once. Also watches that head. */
4468
+ private holdReview(task: Task, reviewer: PeerId): void {
4469
+ try {
4470
+ const head = this.reviewHead(task);
4471
+ if (this.isPii(task) || !head || reviewer === USER) return;
4472
+ this.heldReviews.set(task.id, { head, reviewer, revision: this.reviewRequestRevision(task) });
4473
+ this.watchCi(task);
4474
+ } catch { /* optional bookkeeping never fails a review path */ }
4475
+ }
4476
+
4477
+ /** #785 P1b AC3: start the one watcher for this task's exact head. Not for private work, an unlinked PR or a lifecycle that is off; never throws. */
4478
+ private watchCi(task: Task): void {
4479
+ try {
4480
+ const head = this.reviewHead(task), repo = task.sdlc?.repo, pr = task.sdlc?.pr;
4481
+ if (!this.d.sdlc || this.d.sdlc.config().mode === "off" || this.isPii(task) || task.state !== "in_review" || !head || !/^[0-9a-f]{40}$/.test(head) || !repo || !pr) return;
4482
+ this.ciWatchers.start({ id: task.id, head, repo, pr });
4483
+ } catch { /* a watcher that cannot start leaves the review path as it was */ }
4484
+ }
4485
+
4486
+ /** The watcher's own check before every read and every notice: the task must still be in review at this exact head and PR. */
4487
+ private ciWatchCurrent(watch: CiWatch): boolean {
4488
+ const task = this.d.board.get(watch.id);
4489
+ return !!task && task.state === "in_review" && !this.isPii(task) && this.reviewHead(task) === watch.head && task.sdlc?.repo === watch.repo && task.sdlc?.pr === watch.pr
4490
+ && (this.d.sdlc?.config().mode ?? "off") !== "off";
4491
+ }
4492
+
4493
+ /**
4494
+ * #785 P1b: the result of the exact head's CI, told once to the owner and the reviewer (one ids-only task envelope each, `status` weight: `fyi` is recorded but never delivered to the peer),
4495
+ * then a review held as premature is offered once more after a fresh readiness read. A deadline tells the console only.
4496
+ */
4497
+ private async ciCompleted(watch: CiWatch, outcome: CiOutcome): Promise<void> {
4498
+ const task = this.d.board.get(watch.id);
4499
+ if (!task || !this.ciWatchCurrent(watch)) return;
4500
+ const label = `#${task.id} head ${watch.head.slice(0, 12)}`;
4501
+ if (outcome === "deadline") { this.releaseNotice(`task ${label}: CI did not finish within the watch deadline; no further reads`); return; }
4502
+ const line = `ci_completed: ${label} ${outcome}`;
4503
+ for (const peer of new Set([task.owner, task.reviewer])) {
4504
+ if (!peer || peer === HUB) continue;
4505
+ if (peer === USER) { this.releaseNotice(`task ${label}: CI ${outcome} at its exact head`); continue; }
4506
+ this.releaseNotice(`task ${label}: could not tell ${peer} the CI result`, () => this.d.bus.publish(newEnvelope(HUB, `${line}\nRead hub_task_show for the task before acting.`, { to: [peer], kind: "task", priority: "status", refs: { task: String(task.id), commit: watch.head } })));
4507
+ }
4508
+ if (outcome === "failed") return;
4509
+ await this.reofferHeldReview(task, watch);
4510
+ }
4511
+
4512
+ /** The held review of this exact head and request, once: only while it is still the same reviewer, head and revision, and a fresh admission says it is ready. */
4513
+ private async reofferHeldReview(task: Task, watch: CiWatch): Promise<void> {
4514
+ const held = this.heldReviews.get(task.id);
4515
+ if (!held || held.head !== watch.head || task.reviewer !== held.reviewer || this.reviewRequestRevision(task) !== held.revision) return;
4516
+ this.heldReviews.delete(task.id); // once: a later hold needs a new head or a new request
4517
+ try {
4518
+ const admission = await this.reviewRequestAdmission(task, undefined, false, task.owner ?? USER);
4519
+ const current = this.d.board.get(task.id);
4520
+ if (!admission?.ready || !current || !this.ciWatchCurrent(watch) || current.reviewer !== held.reviewer || this.reviewRequestRevision(current) !== held.revision) return;
4521
+ this.releaseNotice(`task ${this.publicTitle(current)}: could not re-offer the held review to ${held.reviewer}`, () => this.sendReview(current, held.reviewer, "(Held earlier because exact-head CI was not green; it is green now.)", admission));
4522
+ } catch { /* still not ready: the hold notice already named why; a reviewer reassignment retries it */ }
4523
+ }
4524
+
4525
+ /** Daemon shutdown: no watcher outlives the hub. */
4526
+ stopCiWatchers(): void { this.ciWatchers.stopAll(); this.heldReviews.clear(); }
4527
+
4294
4528
  private reviewRequestRevision(task: Task): number {
4295
4529
  // Role changes fence older requests; bookkeeping appended after the request does not.
4296
4530
  return task.history.findLastIndex(entry => ["done", "reviewer changed", "reviewer unavailable"].includes(entry.event) || OWNERSHIP_EVENTS.has(entry.event)) + 1;
@@ -4431,6 +4665,11 @@ export class Tasks {
4431
4665
  let parsed: ReviewFinding[];
4432
4666
  try { parsed = parseReviewFindings(findings, { person: by === USER, maxNits: this.d.sdlc?.config().maxNits ?? DEFAULT_MAX_NITS }); }
4433
4667
  catch (error) { throw new PreEffectToolRefusal(error instanceof Error ? error.message : "invalid structured findings"); }
4668
+ // #852 AC3 (amended): a code-scope reviewer's changes_requested is always recorded, never refused. Without an open structured finding it is
4669
+ // 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
4670
+ // notice. The rule is structural (a finding with disposition open), never a reading of the unmet text.
4671
+ const codeScope = by !== USER && this.d.reviewScope?.(by) === "code";
4672
+ const evidenceOnly = codeScope && verdict === "changes_requested" && !parsed.some(finding => finding.disposition === "open");
4434
4673
  const resolvedHead = this.findingHead(initial);
4435
4674
  let findingHead = resolvedHead;
4436
4675
  if (head !== undefined) {
@@ -4512,7 +4751,7 @@ export class Tasks {
4512
4751
  }
4513
4752
  if (verdict === "approved" && this.d.sdlc && this.d.sdlc.config().mode !== "off") { await this.lifecycleGate(task, "approval", reviewedHead, proposedSets(task)); task = valid(); }
4514
4753
  let candidate: Extract<CandidateInput, { kind: "fail" }> | undefined;
4515
- if (verdict === "changes_requested" && items.length && this.d.candidates && !withheld && !this.isPii(task) && !this.d.privateTurn?.(by)) {
4754
+ if (verdict === "changes_requested" && !evidenceOnly && items.length && this.d.candidates && !withheld && !this.isPii(task) && !this.d.privateTurn?.(by)) {
4516
4755
  const input: CandidateInput = { project: this.d.store?.project ?? this.d.project, kind: "fail", approach: this.publicTitle(task), symptom: items.join("\n"),
4517
4756
  cause: "unknown: review unmet items do not establish a cause", retryWhen: "unknown: a person must review the unmet items before accepting",
4518
4757
  evidenceRefs: [`task:${task.id}`, `reviewed-head:${reviewedHead ?? task.refs.commit ?? "unknown"}`] };
@@ -4531,9 +4770,12 @@ export class Tasks {
4531
4770
  // #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
4532
4771
  // write (the `task` just rechecked by valid()), never from the caller; a PII task carries none, its checklist being private.
4533
4772
  const unmetCriteria = pii ? [] : unmetCriteriaIds(items, checklistOf(task.sdlc));
4534
- const flag = { ...this.planObservation(task), withheld: withheld || undefined, ...(report ? { reviewFindings: report } : {}), ...(unmetCriteria.length ? { unmetCriteria } : {}), ...(boundDeliverables ? { deliverables: boundDeliverables } : {}), ...(reviewContext ? { reviewContext } : {}), ...(reviewerFamily ? { reviewerFamily } : {}), ...(reviewerReason ? { reviewerReason } : {}) };
4773
+ 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 } : {}) };
4535
4774
  if (verdict === "approved") {
4775
+ const capacity = this.capacityBefore(task);
4776
+ this.ciWatchers.cancel(task.id); this.heldReviews.delete(task.id);
4536
4777
  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);
4778
+ this.writePlannerNotices(next, capacity, next.state === "approved");
4537
4779
  if (next.owner) {
4538
4780
  this.d.board.recordOutcome(next.owner, next.class, true);
4539
4781
  this.d.board.recordReview({ implementer: next.owner, reviewer: by, class: next.class, kind: "approved", task: next.id });
@@ -4552,17 +4794,20 @@ export class Tasks {
4552
4794
  this.releaseNotice(`task #${next.id}: review saved but recurring-finding proposal unavailable`, () => this.proposeRecurringFindings(next, by, parsed, withheld));
4553
4795
  return next;
4554
4796
  }
4555
- const rejected = this.d.board.update(task.id, by, "changes_requested", { state: "changes_requested", rejections: task.rejections + 1 }, note, flag);
4797
+ this.ciWatchers.cancel(task.id); this.heldReviews.delete(task.id);
4798
+ const rejected = this.d.board.update(task.id, by, "changes_requested", { state: "changes_requested", rejections: evidenceOnly ? task.rejections : task.rejections + 1 }, note, flag);
4556
4799
  void this.mirrorVerdict(rejected, by, "changes_requested", reviewedHead ?? rejected.sdlc?.reviewedHead, note, withheld);
4557
- this.releaseNotice(`task #${rejected.id}: review saved but recurring-finding proposal unavailable`, () => this.proposeRecurringFindings(rejected, by, parsed, withheld));
4800
+ if (!evidenceOnly) this.releaseNotice(`task #${rejected.id}: review saved but recurring-finding proposal unavailable`, () => this.proposeRecurringFindings(rejected, by, parsed, withheld));
4558
4801
  if (candidate && !pii && !this.d.privateTurn?.(by) && this.candidateTextAllowed(JSON.stringify({ candidate, refs: rejected.refs, plan: rejected.plan }), rejected)) {
4559
4802
  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 */ }
4560
4803
  }
4561
4804
  this.cohorts.withdraw(rejected.id); // open again: its done intent no longer counts (issue #107)
4562
- if (rejected.owner) this.d.board.recordOutcome(rejected.owner, rejected.class, false);
4563
- this.contradict(rejected);
4805
+ // Evidence-only is not a verdict on the owner's work: no failed outcome, no contradiction of an earlier approval, and no escalation count.
4806
+ if (rejected.owner && !evidenceOnly) this.d.board.recordOutcome(rejected.owner, rejected.class, false);
4807
+ if (!evidenceOnly) this.contradict(rejected);
4564
4808
  this.note(rejected, by, "decision", `Task #${rejected.id} changes requested by ${by}: ${rejected.title}\n${note ?? ""}`, withheld);
4565
- if (rejected.rejections >= (this.d.escalateAfter?.() ?? ESCALATE_AFTER)) {
4809
+ if (evidenceOnly) this.tellConductorEvidenceOnly(rejected, by);
4810
+ if (!evidenceOnly && rejected.rejections >= (this.d.escalateAfter?.() ?? ESCALATE_AFTER)) {
4566
4811
  let moved: Task;
4567
4812
  try {
4568
4813
  moved = await this.escalate(HUB, rejected.id, `${rejected.rejections} consecutive changes_requested`, "rejections");
@@ -4587,6 +4832,19 @@ export class Tasks {
4587
4832
  return reopened;
4588
4833
  }
4589
4834
 
4835
+ /**
4836
+ * #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
4837
+ * the review or the task reaches it; a PII task's notice is private. With no conductor the person sees one console notice instead.
4838
+ */
4839
+ private tellConductorEvidenceOnly(task: Task, reviewer: PeerId): void {
4840
+ const conductors = Object.entries(this.d.roles ?? {}).filter(([, roles]) => Array.isArray(roles) && roles.includes("conductor")).map(([actor]) => actor).filter(actor => actor !== reviewer);
4841
+ const text = evidenceOnlyNotice(task.id, reviewer);
4842
+ if (!conductors.length) { this.releaseNotice(text); return; }
4843
+ this.releaseNotice(`task #${task.id}: could not tell the conductor a code-scope review was evidence-only`, () => {
4844
+ this.d.bus.publish(newEnvelope(HUB, sanitize(text), { to: conductors, kind: "task", refs: { task: String(task.id) }, ...(this.isPii(task) ? { private: true } : {}) }));
4845
+ });
4846
+ }
4847
+
4590
4848
  /** Task-level escalation: the next attached peer in the class's escalate_to takes over, with the history. */
4591
4849
  async escalate(by: PeerId, id: unknown, why = "by hand", reason: TaskMoveReason | undefined = by === HUB ? undefined : "manual", preEffect = false): Promise<Task> {
4592
4850
  let task = this.need(id, true, preEffect);