@staix/agent-hub 0.12.44 → 0.13.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 (197) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +83 -116
  3. package/THIRD_PARTY_NOTICES.md +29 -0
  4. package/docs/adding-an-ai-tool.md +102 -14
  5. package/docs/agent-notes/README.md +15 -0
  6. package/docs/agent-notes/adapters.md +63 -46
  7. package/docs/agent-notes/budget.md +7 -2
  8. package/docs/agent-notes/bus.md +6 -4
  9. package/docs/agent-notes/daemon.md +15 -8
  10. package/docs/agent-notes/local-worker.md +6 -4
  11. package/docs/agent-notes/tasks.md +18 -5
  12. package/docs/agent-notes/tests.md +11 -9
  13. package/docs/dogfood.md +92 -0
  14. package/docs/events.md +14 -1
  15. package/docs/operations.md +349 -26
  16. package/docs/profiles.md +2 -2
  17. package/docs/quickstart.md +12 -2
  18. package/docs/sdlc.md +61 -13
  19. package/docs/security.md +24 -12
  20. package/docs/smoke.md +9 -4
  21. package/docs/specs/2026-09-19-agent-hub-design.md +873 -13
  22. package/docs/verification/2026-10-19-reviewer-isolation.md +2 -0
  23. package/docs/verification/2026-10-21-469-r1-dashboard-tokens.md +43 -0
  24. package/docs/verification/2026-10-21-469-r2-dashboard-shell.md +54 -0
  25. package/docs/verification/2026-10-21-469-r3-dashboard-board.md +51 -0
  26. package/docs/verification/2026-10-21-469-r4-dashboard-views.md +77 -0
  27. package/docs/verification/2026-10-21-469-r6-dashboard-motion-states.md +82 -0
  28. package/docs/verification/2026-10-22-0.12.45.md +60 -0
  29. package/docs/verification/2026-10-22-code-facts.md +78 -0
  30. package/docs/verification/2026-10-22-codex-reviewer-isolation.md +47 -0
  31. package/docs/verification/2026-10-22-kimi-qwen-compat.md +79 -0
  32. package/docs/verification/2026-10-23-0.13.0.md +89 -0
  33. package/docs/verification/assets/469-r1-board-1440-dark.png +0 -0
  34. package/docs/verification/assets/469-r1-board-1440-light.png +0 -0
  35. package/docs/verification/assets/469-r1-board-390-dark.png +0 -0
  36. package/docs/verification/assets/469-r1-board-390-light.png +0 -0
  37. package/docs/verification/assets/469-r2-agents-1440-dark.png +0 -0
  38. package/docs/verification/assets/469-r2-agents-768-dark.png +0 -0
  39. package/docs/verification/assets/469-r2-board-1024-light.png +0 -0
  40. package/docs/verification/assets/469-r2-board-1440-dark.png +0 -0
  41. package/docs/verification/assets/469-r2-board-1440-light.png +0 -0
  42. package/docs/verification/assets/469-r2-board-390-dark.png +0 -0
  43. package/docs/verification/assets/469-r2-board-390-light.png +0 -0
  44. package/docs/verification/assets/469-r2-board-768-light.png +0 -0
  45. package/docs/verification/assets/469-r3-board-1440-dark.png +0 -0
  46. package/docs/verification/assets/469-r3-board-1440-light.png +0 -0
  47. package/docs/verification/assets/469-r3-board-390-light.png +0 -0
  48. package/docs/verification/assets/469-r3-detail-1440-dark.png +0 -0
  49. package/docs/verification/assets/469-r3-detail-1440-light.png +0 -0
  50. package/docs/verification/assets/469-r3-detail-390-dark.png +0 -0
  51. package/docs/verification/assets/469-r3-plan-1440-light.png +0 -0
  52. package/docs/verification/assets/469-r3-plan-390-light.png +0 -0
  53. package/docs/verification/assets/469-r4-agents-1440-dark.png +0 -0
  54. package/docs/verification/assets/469-r4-agents-1440-light.png +0 -0
  55. package/docs/verification/assets/469-r4-agents-390-dark.png +0 -0
  56. package/docs/verification/assets/469-r4-memory-1440-dark.png +0 -0
  57. package/docs/verification/assets/469-r4-memory-1440-light.png +0 -0
  58. package/docs/verification/assets/469-r4-memory-390-dark.png +0 -0
  59. package/docs/verification/assets/469-r4-message-1440-light.png +0 -0
  60. package/docs/verification/assets/469-r4-office-1440-dark.png +0 -0
  61. package/docs/verification/assets/469-r4-office-1440-light.png +0 -0
  62. package/docs/verification/assets/469-r4-settings-1440-dark.png +0 -0
  63. package/docs/verification/assets/469-r4-settings-1440-light.png +0 -0
  64. package/docs/verification/assets/469-r4-stream-1440-light.png +0 -0
  65. package/docs/verification/assets/469-r4-stream-390-light.png +0 -0
  66. package/docs/verification/assets/469-r6-agents-empty-390-dark.png +0 -0
  67. package/docs/verification/assets/469-r6-agents-empty-390-light.png +0 -0
  68. package/docs/verification/assets/469-r6-board-1440-light.png +0 -0
  69. package/docs/verification/assets/469-r6-board-390-dark.png +0 -0
  70. package/docs/verification/assets/469-r6-board-empty-1440-dark.png +0 -0
  71. package/docs/verification/assets/469-r6-board-empty-1440-light.png +0 -0
  72. package/docs/verification/assets/469-r6-disconnected-1440-dark.png +0 -0
  73. package/docs/verification/assets/469-r6-disconnected-1440-light.png +0 -0
  74. package/docs/verification/assets/469-r6-disconnected-390-dark.png +0 -0
  75. package/docs/verification/assets/469-r6-skeleton-1440-light.png +0 -0
  76. package/docs/verification/assets/469-r6-skeleton-390-dark.png +0 -0
  77. package/docs/verification/assets/469-r6-toasts-1440-dark.png +0 -0
  78. package/docs/verification/assets/469-r6-toasts-1440-light.png +0 -0
  79. package/docs/verification/assets/469-r6-toasts-390-light.png +0 -0
  80. package/docs/verification/dogfood/2026-10-22.md +3109 -0
  81. package/docs/verified.json +19 -16
  82. package/package.json +5 -1
  83. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  84. package/plugins/agent-hub/server.js +10120 -9623
  85. package/src/adapters/adapter-api.ts +2 -0
  86. package/src/adapters/agy/definition.ts +3 -1
  87. package/src/adapters/agy/entry.ts +5 -4
  88. package/src/adapters/claude-channel.ts +32 -12
  89. package/src/adapters/codex-appserver.ts +19 -2
  90. package/src/adapters/codex-review-isolation.ts +12 -5
  91. package/src/adapters/compat.ts +140 -0
  92. package/src/adapters/conformance/acp.ts +114 -0
  93. package/src/adapters/conformance/codex.ts +191 -0
  94. package/src/adapters/conformance/enrollment.ts +43 -0
  95. package/src/adapters/conformance/factories.ts +17 -0
  96. package/src/adapters/conformance/fake-paths.ts +12 -0
  97. package/src/adapters/conformance/fakes/acp-server.ts +267 -0
  98. package/src/adapters/conformance/fakes/acp-watchdog-agent.ts +10 -0
  99. package/src/adapters/conformance/fakes/app-server.ts +119 -0
  100. package/src/adapters/conformance/fakes/codex-bin.ts +15 -0
  101. package/src/adapters/conformance/fakes/jcode-agent.ts +91 -0
  102. package/src/adapters/conformance/fakes/model-server.ts +39 -0
  103. package/src/adapters/conformance/fakes/pi-rpc.ts +32 -0
  104. package/src/adapters/conformance/index.ts +11 -0
  105. package/src/adapters/conformance/kit.ts +50 -0
  106. package/src/adapters/conformance/local-pi.ts +100 -0
  107. package/src/adapters/conformance/owned-acp.ts +44 -0
  108. package/src/adapters/definitions/builtin-metadata.ts +25 -7
  109. package/src/adapters/definitions/native-cli-doctor.ts +12 -5
  110. package/src/adapters/definitions/native-cli-entry-jcode.ts +9 -6
  111. package/src/adapters/definitions/native-cli-entry-kimi.ts +7 -1
  112. package/src/adapters/definitions/native-cli-entry.ts +7 -19
  113. package/src/adapters/definitions/native-cli-jcode-mcp.ts +9 -2
  114. package/src/adapters/definitions/native-cli-preview-common.ts +2 -0
  115. package/src/adapters/definitions/native-cli-preview-jcode.ts +8 -2
  116. package/src/adapters/definitions/native-cli-preview-qwen.ts +2 -1
  117. package/src/adapters/definitions/native-cli-preview.ts +2 -11
  118. package/src/adapters/definitions/qwen-native.ts +7 -3
  119. package/src/adapters/definitions/qwen.ts +5 -1
  120. package/src/adapters/external-metadata.ts +48 -0
  121. package/src/adapters/external-module-scan.ts +126 -0
  122. package/src/adapters/external-modules.ts +240 -0
  123. package/src/adapters/jcode.ts +72 -31
  124. package/src/adapters/kimi-review.ts +6 -3
  125. package/src/adapters/pi.ts +2 -0
  126. package/src/adapters/public.ts +7 -0
  127. package/src/adapters/registry-types.ts +13 -1
  128. package/src/adapters/registry.ts +52 -15
  129. package/src/cli/agents.ts +40 -0
  130. package/src/cli/console-review.ts +54 -0
  131. package/src/cli/console-state.ts +124 -29
  132. package/src/cli/console-symbols.ts +74 -0
  133. package/src/cli/console-usage.ts +89 -0
  134. package/src/cli/console.ts +26 -1
  135. package/src/cli/help.ts +36 -21
  136. package/src/cli/identity.ts +30 -7
  137. package/src/cli/init.ts +109 -11
  138. package/src/cli/main.ts +145 -16
  139. package/src/cli/output.ts +44 -1
  140. package/src/cli/preview.ts +1 -1
  141. package/src/cli/sdlc-release-guard.ts +82 -0
  142. package/src/cli/upgrade-labels.ts +1 -1
  143. package/src/cli/upgrade-presentation.ts +1 -1
  144. package/src/cli/upgrade-restore-state.ts +3 -2
  145. package/src/cli/upgrade-runtime.ts +4 -1
  146. package/src/hub/action-class.ts +362 -0
  147. package/src/hub/action-policy.ts +106 -0
  148. package/src/hub/board.ts +72 -11
  149. package/src/hub/budget.ts +71 -2
  150. package/src/hub/bus.ts +43 -3
  151. package/src/hub/checks.ts +80 -31
  152. package/src/hub/code-facts.ts +223 -0
  153. package/src/hub/config-apply.ts +28 -11
  154. package/src/hub/config-reload.ts +8 -5
  155. package/src/hub/config-trust.ts +7 -4
  156. package/src/hub/control-client.ts +4 -1
  157. package/src/hub/daemon.ts +332 -61
  158. package/src/hub/dogfood-read.ts +199 -0
  159. package/src/hub/dogfood.ts +163 -0
  160. package/src/hub/envelope.ts +1 -1
  161. package/src/hub/events.ts +11 -2
  162. package/src/hub/facts.ts +76 -13
  163. package/src/hub/hub-tools.ts +45 -7
  164. package/src/hub/inbox-polling.ts +25 -0
  165. package/src/hub/instance-config.ts +17 -5
  166. package/src/hub/instance-execution.ts +20 -8
  167. package/src/hub/instances.ts +75 -2
  168. package/src/hub/peers.ts +9 -0
  169. package/src/hub/plan-conformance.ts +83 -0
  170. package/src/hub/profiles.ts +7 -2
  171. package/src/hub/release-worktree.ts +56 -0
  172. package/src/hub/review-brief.ts +36 -0
  173. package/src/hub/review-findings.ts +104 -0
  174. package/src/hub/roster.ts +31 -5
  175. package/src/hub/sdlc-queue.ts +48 -11
  176. package/src/hub/sdlc-release-authorization.ts +173 -0
  177. package/src/hub/sdlc-release.ts +16 -1
  178. package/src/hub/sdlc.ts +71 -18
  179. package/src/hub/settings.ts +10 -3
  180. package/src/hub/snapshots.ts +101 -14
  181. package/src/hub/symbol-normalizer.ts +26 -0
  182. package/src/hub/task-code-symbols.ts +90 -0
  183. package/src/hub/task-sweep.ts +3 -2
  184. package/src/hub/tasks.ts +556 -88
  185. package/src/hub/test-integrity.ts +162 -0
  186. package/src/hub/ui.ts +2 -2
  187. package/src/hub/usage-rollup.ts +361 -0
  188. package/src/local/sandbox.ts +5 -3
  189. package/src/ui/index.html +560 -78
  190. package/src/ui/office-model.ts +3 -1
  191. package/src/ui/peer-palette.ts +44 -0
  192. package/templates/AGENTS.block.md +1 -1
  193. package/templates/profiles/dev.json +6 -1
  194. package/templates/profiles/general.json +6 -1
  195. package/templates/profiles/research.json +6 -1
  196. package/templates/profiles/writing.json +6 -1
  197. package/templates/release-guard.block.md +6 -0
package/src/hub/tasks.ts CHANGED
@@ -1,3 +1,7 @@
1
+ import { buildTaskCodeSymbols, publicTaskCodeSymbols, taskCodeSymbolsDisjoint, HOST_CODE_SYMBOLS } from "./task-code-symbols.ts";
2
+ import { normalizePlanSymbol, symbolsOverlap } from "./symbol-normalizer.ts";
3
+ import { publicTestIntegrity, readTestIntegrity, testIntegrityBlockers, type TestIntegrity } from "./test-integrity.ts";
4
+ import { completePlanInventory, planConformance, planConformanceBlockers, planFingerprint, type PlanConformance } from "./plan-conformance.ts";
1
5
  import { RELEASE_TRAIN_HISTORY_LIMIT, releaseTrainData, releaseTrainReadiness, releaseMetrics } from "./sdlc-release.ts";
2
6
  import { cleanCostPlan, costMetrics, type CostPlan, type ReviewCost } from "./cost-governance.ts";
3
7
  import { specRefs } from "./spec-read.ts";
@@ -8,6 +12,7 @@ import { NoWriteTurns } from "./no-writes.ts";
8
12
  import { readGitHubImport, type GitHubSource, type GitHubImportSelection } from "./github-import.ts";
9
13
  import { BACKLOG_SPLIT_SCREEN_TIMEOUT_MS, actualTaskDates, planningPatch, type PlanningFields, type Release, type ReleaseState } from "./planning.ts";
10
14
  import { readMergeQueue } from "./sdlc-queue.ts";
15
+ import { DEFAULT_MAX_NITS, parseReviewFindings, findingsForHead, findingHead as resolveFindingHead, findingTally, type ReviewFinding, type ReviewFindingSet } from "./review-findings.ts";
11
16
  import { readReleaseReadiness } from "./sdlc-release.ts";
12
17
  import { loadRoleCatalog, taskRole, taskRoleText, type RoleCatalog } from "./roles.ts";
13
18
  import { matchesPolicy } from "./path-policy.ts";
@@ -35,7 +40,7 @@ import { coverageGap, renderReviewBrief, type ReviewBrief } from "./review-brief
35
40
  import { definitionOf as builtinDefinitionOf } from "../adapters/registry.ts";
36
41
  import { ADAPTERS, DEFAULT_REVIEW_POOL, definitionOf, familyOf, type ProjectAdapterCatalog } from "../adapters/registry.ts";
37
42
  import { plannerGrooming, nextSweep, taskSweepConfig, type TaskSweepConfig, type SweepRecord } from "./task-sweep.ts";
38
- import { readPrCostHistory, readReviewReadiness, reviewReadinessFromEvidence, type PrEvidence, type ReviewReadiness, readPlanIssueMetadata, canonicalStage, checklistOf, readSdlcPr, readSpecEdit, sdlcConfig, sdlcReceipt, sdlcStatus, specState, SPEC_STATE_TEXT, stageDefinition, lifecycleStage, type SdlcConfig, type PlanIssueMetadata, sdlcGh, type SdlcGh, type SdlcStatus, type SpecEdit, type SpecState } from "./sdlc.ts";
43
+ import { 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";
39
44
 
40
45
  export type TaskAssignmentRole = "owner" | "reviewer";
41
46
 
@@ -70,6 +75,7 @@ export interface TasksDeps {
70
75
  /** Explicit acceptance/self-claim only; implicit done has no pre-work baseline. */
71
76
  ownerAccepted?: (task: Task) => void;
72
77
  /** #520: the review brief for a task's commit (git only, bounded); undefined when the task names no commit. */
78
+ prepareReviewBrief?: (task: Task) => Promise<void>;
73
79
  reviewBrief?: (task: Task) => ReviewBrief | { unavailable: string } | undefined;
74
80
  sdlc?: { config: () => SdlcConfig; gh?: SdlcGh };
75
81
  briefs?: Briefs;
@@ -174,6 +180,8 @@ const CONTRADICTION_WINDOW_MS = 7 * 86_400_000;
174
180
  const OPEN: Task["state"][] = ["proposed", "in_progress", "changes_requested"];
175
181
  /** Board events that leave a task where its completion check found it; any other event means it moved on meanwhile. */
176
182
  const QUIET_EVENTS = new Set(["answer", "reviewer changed", "idle sweep", "memory reference started"]);
183
+ QUIET_EVENTS.add("ready held"); // #655: the hub's note that a ready task waits for its reserved owner's WIP slot.
184
+ QUIET_EVENTS.add("plan observed"); // Host observation does not move the task or replace completion intent.
177
185
 
178
186
  /**
179
187
  * Thrown when an assignment's board write is saved but a publish or notify after it failed (#297): the move is
@@ -250,9 +258,17 @@ function acceptRefs(input: unknown): TaskRefs | undefined {
250
258
  /** The same for a plan (issue #31): only its four lists, each of short strings. */
251
259
  function cleanPlan(input: unknown, profile: ProfileSelection = "dev"): TaskPlan {
252
260
  const r = fields(input);
261
+ const evidence: Pick<TaskPlan, "proof" | "risks"> = {};
262
+ for (const key of ["proof", "risks"] as const) {
263
+ const value = r[key];
264
+ if (value === undefined) continue;
265
+ if (!Array.isArray(value) || value.length > 32 || value.some(item => typeof item !== "string" || item.length > 512)) throw new PreEffectToolRefusal(`plan.${key}: expected at most 32 strings of at most 512 characters`);
266
+ const items = value.map(item => item.replace(/\s+/g, " ").trim()).filter(Boolean);
267
+ if (items.length) evidence[key] = items;
268
+ }
253
269
  let scope: CostPlan;
254
270
  try { scope = cleanCostPlan(r); } catch { throw new PreEffectToolRefusal("invalid review or CI cost scope"); }
255
- return { ...Object.fromEntries((profileData(profile).plan_keys as (keyof TaskPlan)[]).map((k) => [k, k === "paths" ? [...new Set(textList(r[k]).map(normPath))] : textList(r[k])] as const).filter(([, v]) => v.length)), ...scope };
271
+ return { ...Object.fromEntries((profileData(profile).plan_keys as (keyof TaskPlan)[]).map((k) => [k, k === "paths" ? [...new Set(textList(r[k]).map(normPath))] : textList(r[k])] as const).filter(([, v]) => v.length)), ...evidence, ...scope };
256
272
  }
257
273
 
258
274
  /** A plan as one line for other owners; the whole plan is on the board. */
@@ -266,6 +282,9 @@ const RESCREEN_MAX = 10;
266
282
  * The task flow. Adapters and tools never touch the board: every change comes through here, where assignment,
267
283
  * PII handling, envelopes, briefs and memory notes are decided in one place.
268
284
  */
285
+ /** #694: a PR staged at done with the commit or branch it was read for. */
286
+ interface IdentityLink { repo: string; pr: number; basis: { commit?: string; branch?: string } }
287
+
269
288
  export class Tasks {
270
289
  private readonly repositories: TaskRepositories;
271
290
  private readonly incomingBacklog = new Set<number>();
@@ -280,6 +299,7 @@ export class Tasks {
280
299
  private sweeping = false;
281
300
 
282
301
  constructor(private readonly d: TasksDeps) {
302
+ d.board.trackPlanRevisions = () => (d.sdlc?.config().mode ?? "off") !== "off";
283
303
  this.repositories = new TaskRepositories(d.cwd, d.repository);
284
304
  for (const task of d.board.list()) this.warmRepository(task);
285
305
  if (d.coordination) {
@@ -389,7 +409,7 @@ export class Tasks {
389
409
  } else {
390
410
  const admission = this.plannerPullRecord(task)?.pull ? this.plannerAssignment(task, true) : assign(task, this.states(), this.d.routing(), { exclude: this.excluded(task), ...this.weights(task.class, task) });
391
411
  this.releaseNotice(`${text} Owner reassignment suggestion: ${admission.owner ?? "no eligible peer"}.`);
392
- if (admission.owner && config.auto_reassign && this.sweepAvailable(admission.owner)) {
412
+ if (admission.owner && config.auto_reassign && this.sweepAvailable(admission.owner) && !this.reservedHold(task)) {
393
413
  try { await this.assignOwner(this.d.board.get(task.id)!, HUB, { candidates: [admission.owner], event: "reassigned", reason: "idle", note: "ready-unassigned idle sweep" }); }
394
414
  catch (e) { if (!(e instanceof AssignmentUndeliveredError)) throw e; }
395
415
  }
@@ -526,14 +546,62 @@ export class Tasks {
526
546
  /** What peers other than the owner, the console stream and the log may see of a task. */
527
547
  publicTitle = (task: Task) => (this.isPii(task) ? `#${task.id} [pii]` : `#${task.id} ${task.title}`);
528
548
 
549
+ private reviewFindingSets(task: Task): ReviewFindingSet[] { return task.history.flatMap(entry => entry.reviewFindings ? [entry.reviewFindings] : []); }
550
+
551
+ private findingHead(task: Task, reviewedHead?: string): string | undefined {
552
+ return resolveFindingHead(task.sdlc, reviewedHead ? { head: reviewedHead } : undefined, task.refs.commit);
553
+ }
554
+
555
+ private findingTextAllowed(task: Task, items: readonly ReviewFinding[]): boolean {
556
+ return this.candidateTextAllowed(JSON.stringify(items), task)
557
+ && items.every(item => !item.path || !matchesPolicy(this.d.cwd, item.path, this.d.piiPaths ?? [], true));
558
+ }
559
+
560
+ /** Host-authored proposals contain only cleared IDs, integer task references and fixed instruction targets. */
561
+ private proposeRecurringFindings(task: Task, by: PeerId, items: readonly ReviewFinding[], withheld: boolean): void {
562
+ if (withheld || this.isPii(task) || this.d.privateTurn?.(by) || !items.length || !this.findingTextAllowed(task, items)) return;
563
+ const rows = this.d.board.list();
564
+ const keys = new Set(rows.filter(row => row.class === "plan" && row.history[0]?.by === HUB)
565
+ .flatMap(row => row.detail?.split("\n").filter(line => line.startsWith("Review finding key: ")) ?? []));
566
+ const created: number[] = [];
567
+ // No await separates the durable dedupe read from creation; concurrent verdicts see the first proposal.
568
+ for (const item of items.filter(item => item.disposition !== "false_positive")) {
569
+ const same = (finding: ReviewFinding) => finding.id === item.id && finding.pass === item.pass;
570
+ const holders = rows.filter(row => {
571
+ if (this.isPii(row)) return false;
572
+ const last = row.history.findLast(entry => entry.reviewFindings?.items.some(value => value.id === item.id));
573
+ const finding = last?.reviewFindings?.items.find(value => value.id === item.id);
574
+ return !last?.withheld && finding && same(finding) && finding.disposition !== "false_positive" && this.findingTextAllowed(row, [finding]);
575
+ });
576
+ if (new Set(holders.map(row => row.id)).size < 2) continue;
577
+ const key = `Review finding key: ${item.pass}:${item.id}`;
578
+ if (keys.has(key)) continue;
579
+ const title = `Review policy amendment for recurring finding ${item.pass}:${item.id}`;
580
+ const detail = `${key}\nFinding ${item.pass}:${item.id} recurred on tasks ${holders.slice(0, 8).map(row => `#${row.id}`).join(", ")}. Inspect their review histories and propose an AGENTS.md or relevant docs/agent-notes/ amendment. This backlog card is a proposal, not authorization to edit instruction files.`;
581
+ const draft = { title, detail, refs: { paths: ["AGENTS.md", "docs/agent-notes/"] } };
582
+ const signals = this.signalsFor(draft);
583
+ if (this.isPii({ signals }) || !this.candidateTextAllowed(`${title}\n${detail}`)) continue;
584
+ const proposal = this.d.board.propose(HUB, { ...draft, class: "plan", backlog: true, signals });
585
+ this.incomingBacklog.add(proposal.id); keys.add(key); created.push(proposal.id);
586
+ }
587
+ for (const id of created) this.releaseNotice(`task #${id}: recurring review finding amendment proposed in the backlog`);
588
+ }
589
+
529
590
  /** A task as a cloud peer may see it. */
591
+
530
592
  publicView(task: Task, fullHistory = false): Record<string, unknown> {
531
- const { title, detail, history, external, cancelled, ...rest } = task;
593
+ const { title, detail, history, external, cancelled, codeSymbols, ...taskRest } = task;
594
+ const rest = { ...taskRest, ...(history.some(h => h.by === USER && h.planDeparture) ? { planDepartureAccepted: true } : {}) };
532
595
  if (this.isPii(task)) return { ...rest, actualDates: actualTaskDates(task), refs: {}, plan: {}, ...(task.sdlc ? { sdlc: { stage: stageDefinition(this.d.sdlc?.config() ?? sdlcConfig(undefined), task.sdlc.stage)?.base ?? "unknown" } } : {}), title: "[pii]", detail: "[pii]" };
596
+ if (rest.sdlc?.redFirst && !this.publicMetadataText(JSON.stringify(rest.sdlc.redFirst))) {
597
+ const { redFirst: _withheld, ...receipt } = rest.sdlc; rest.sdlc = receipt;
598
+ }
533
599
  const screenHistoryText = (text: string, field: string, withheld = false): string => !withheld && this.nameable(text) ? text : `[history ${field} withheld: ${this.whyWithheld(text)}; ahub task show ${task.id}]`;
534
600
  const publicHistory = (fullHistory ? history : history.slice(-5)).map(h => ({
535
601
  at: h.at, by: h.by, event: h.event,
536
602
  ...(h.note === undefined ? {} : { note: screenHistoryText(h.note, "note", h.withheld) }),
603
+ ...(h.reviewFindings ? { reviewFindings: !h.withheld && this.findingTextAllowed(task, h.reviewFindings.items) ? h.reviewFindings
604
+ : { ...(h.reviewFindings.head ? { head: h.reviewFindings.head } : {}), tally: findingTally(h.reviewFindings.items), withheld: true } } : {}),
537
605
  ...(h.supersededBy === undefined ? {} : { supersededBy: screenHistoryText(h.supersededBy, "supersession", h.withheld) }),
538
606
  ...(h.profile === undefined ? {} : { profile: screenHistoryText(h.profile, "profile") }),
539
607
  ...(h.owner === undefined ? {} : { owner: h.owner }),
@@ -545,7 +613,10 @@ export class Tasks {
545
613
  ...(h.reason && TASK_MOVE_REASONS.includes(h.reason) ? { reason: h.reason } : {}),
546
614
  ...(h.sweep ? { sweep: { kind: h.sweep.kind, activity: h.sweep.activity, step: h.sweep.step, at: h.sweep.at } } : {}),
547
615
  }));
548
- return { ...rest, ...(cancelled ? { cancelled: { ...cancelled, reason: screenHistoryText(cancelled.reason, "cancellation", cancelled.withheld), ...(cancelled.supersededBy ? { supersededBy: screenHistoryText(cancelled.supersededBy, "supersession", cancelled.withheld) } : {}) } } : {}), ...(external && this.nameable(external.url) ? { external } : {}), actualDates: actualTaskDates(task), ...(((this.d.sdlc?.config().mode ?? "off") !== "off" || this.d.sdlc?.config().workflow === "gsd-lite") ? { lifecycle: sdlcStatus(this.d.sdlc?.config() ?? sdlcConfig(undefined), task.sdlc, undefined, this.workflowView(task)) } : {}), title, detail, history: publicHistory };
616
+ const sets = this.reviewFindingSets(task), current = findingsForHead(sets, this.findingHead(task));
617
+ const symbolRepository = codeSymbols && this.codeSymbolsRepository(task);
618
+ const publicSymbols = codeSymbols && symbolRepository ? publicTaskCodeSymbols(codeSymbols, this.reviewHead(task), symbolRepository.key, this.publicMetadataText) : undefined;
619
+ return { ...rest, ...(publicSymbols ? { codeSymbols: publicSymbols } : {}), ...(current.length ? { findings: findingTally(current) } : {}), ...(cancelled ? { cancelled: { ...cancelled, reason: screenHistoryText(cancelled.reason, "cancellation", cancelled.withheld), ...(cancelled.supersededBy ? { supersededBy: screenHistoryText(cancelled.supersededBy, "supersession", cancelled.withheld) } : {}) } } : {}), ...(external && this.nameable(external.url) ? { external } : {}), actualDates: actualTaskDates(task), ...(((this.d.sdlc?.config().mode ?? "off") !== "off" || this.d.sdlc?.config().workflow === "gsd-lite") ? { lifecycle: sdlcStatus(this.d.sdlc?.config() ?? sdlcConfig(undefined), task.sdlc, undefined, this.workflowView(task), undefined, sets, task.refs.commit) } : {}), title, detail, history: publicHistory };
549
620
  }
550
621
 
551
622
  /** Console results expose direct approval and delivery holds, not just the chosen peer ids. */
@@ -761,7 +832,7 @@ export class Tasks {
761
832
  // were stored as written, so both sides are compared in one spelling.
762
833
  if (mine.repositoryKey !== theirs.repositoryKey) continue;
763
834
  const file = (p: string) => normPath(p) !== "." && theirs.paths.some((q) => normPath(q) === normPath(p));
764
- const same = mine.paths.some(file) || mine.symbols.some((x) => theirs.symbols.includes(x));
835
+ const same = mine.paths.some(file) || mine.symbols.some((x) => theirs.symbols.some((y) => symbolsOverlap(x, y)));
765
836
  if (!same || this.d.board.reviews({ task: t.id }).some((r) => r.kind === "contradicted")) continue;
766
837
  this.d.board.recordReview({ implementer: t.owner, reviewer: approval.by, class: t.class, kind: "contradicted", task: t.id });
767
838
  }
@@ -1421,6 +1492,7 @@ export class Tasks {
1421
1492
  this.withdrawRoleRequest(confirmed, returned, "reviewer");
1422
1493
  this.incomingBacklog.add(confirmed.id);
1423
1494
  this.planningRecorded({ by, task: confirmed.id, fields: ["return"], revision: confirmed.planningRevision ?? 0 });
1495
+ await this.releaseHeld(confirmed.owner); // its owner's slot is free (#655)
1424
1496
  return returned;
1425
1497
  }
1426
1498
 
@@ -1548,6 +1620,18 @@ export class Tasks {
1548
1620
  /** The tasks an overlap text for `task` names: their plans are in it (issue #108). */
1549
1621
  overlapTasks = (task: Task): number[] => this.overlapHits(task).map((h) => h.task.id);
1550
1622
 
1623
+ /** Console detail advice only: no pair persistence, and no private task on either side. */
1624
+ symbolOverlaps(task: Task): { task: number; files: string[]; sameFileDifferentSymbols: boolean }[] {
1625
+ // A routing-locality opt-out does not make symbol metadata for a marked task public.
1626
+ const markedPrivate = (value: Task) => value.signals.includes("pii") || value.signals.includes("pii_path");
1627
+ if (markedPrivate(task)) return [];
1628
+ return this.overlapHits(task).filter(hit => !markedPrivate(hit.task)).map(hit => ({
1629
+ task: hit.task.id,
1630
+ files: hit.paths.filter(this.publicMetadataText),
1631
+ sameFileDifferentSymbols: this.sameFileDifferentSymbols(task, hit),
1632
+ }));
1633
+ }
1634
+
1551
1635
  /**
1552
1636
  * Dashboard warning links (#264): the board's overlap pairs and the live cohorts, as task and peer ids only.
1553
1637
  * Paths, symbols and plans stay out; PII tasks are excluded by the same rules overlapHits applies.
@@ -1590,12 +1674,23 @@ export class Tasks {
1590
1674
  * ponytail: kept in memory, so after a restart such a notice is delivered whatever its task's state; persist the
1591
1675
  * condition if stale notices after restarts show up.
1592
1676
  */
1593
- private readonly conditional = new Map<string, { peer: PeerId; task: number; states: Task["state"][] }>();
1677
+ private readonly conditional = new Map<string, { peer: PeerId; task: number; tasks?: number[]; states: Task["state"][] }>();
1594
1678
 
1595
1679
  /**
1596
1680
  * Publish a notice about `task`, which only matters to `to` while that task is in one of `states` for it (issue
1597
1681
  * #106): open work by default; the conflict notices of #91 count a task in review too.
1598
1682
  */
1683
+ /**
1684
+ * #713: the completed-change notice of task `done` for one recipient, about all of its tasks in `about`. It stays relevant while any of them is still open for the
1685
+ * recipient, and carries `completedFor` so the bus replaces an earlier queued notice for the same `done` task and recipient.
1686
+ */
1687
+ whileOpenFor(to: PeerId, done: number, about: number[], body: string): void {
1688
+ const env = newEnvelope(HUB, body, { to: [to], kind: "task", refs: { task: String(about[0]), taskRecipient: "owner", completedFor: done } });
1689
+ this.conditional.set(env.id, { peer: to, task: about[0]!, tasks: about, states: OPEN });
1690
+ if (this.conditional.size > 1024) this.conditional.delete(this.conditional.keys().next().value as string);
1691
+ this.d.bus.publish(env);
1692
+ }
1693
+
1599
1694
  whileOpen(to: PeerId, task: number, body: string, states: Task["state"][] = OPEN): void {
1600
1695
  const env = newEnvelope(HUB, body, { to: [to], kind: "task", refs: { task: String(task), taskRecipient: "owner" } });
1601
1696
  this.conditional.set(env.id, { peer: to, task, states });
@@ -1624,8 +1719,8 @@ export class Tasks {
1624
1719
  }
1625
1720
  const c = this.conditional.get(env.id);
1626
1721
  if (!c || c.peer !== peer) return true;
1627
- const t = this.d.board.get(c.task);
1628
- return !!t && t.owner === peer && c.states.includes(t.state);
1722
+ // A notice about several of the recipient's tasks matters while any one of them is still open for it (#713).
1723
+ return (c.tasks ?? [c.task]).some((id) => { const t = this.d.board.get(id); return !!t && t.owner === peer && c.states.includes(t.state); });
1629
1724
  };
1630
1725
 
1631
1726
  /** Whether a model-written name may be shown to other peers and in the log: one matching a PII pattern may be PII. */
@@ -1661,7 +1756,12 @@ export class Tasks {
1661
1756
  private places(task: Task): { repositoryKey: string; paths: string[]; symbols: string[] } {
1662
1757
  const scope = this.repositories.scope(task.refs);
1663
1758
  const paths = [...(task.refs.paths ?? []), ...(task.plan?.paths ?? [])].map((p) => scope === this.repositories.project ? this.projectPath(p) : normPath(this.repositories.relativePath(scope, p)));
1664
- return { repositoryKey: scope.key, paths: [...new Set(paths)], symbols: [...(task.plan?.symbols ?? []), ...(task.plan?.sections ?? []).map(v => `section ${v}`), ...(task.plan?.sources ?? []).map(v => `source ${v}`)] };
1759
+ const symbols = [
1760
+ ...(task.plan?.symbols ?? []).map(normalizePlanSymbol),
1761
+ ...(task.plan?.sections ?? []).map(v => `section ${v}`),
1762
+ ...(task.plan?.sources ?? []).map(v => `source ${v}`),
1763
+ ];
1764
+ return { repositoryKey: scope.key, paths: [...new Set(paths)], symbols: [...new Set(symbols)] };
1665
1765
  }
1666
1766
 
1667
1767
  private roots?: string[];
@@ -1687,7 +1787,7 @@ export class Tasks {
1687
1787
  const theirs = this.places(t);
1688
1788
  if (mine.repositoryKey !== theirs.repositoryKey) return [];
1689
1789
  const paths = task.profile && t.profile && (task.plan?.sections?.length && t.plan?.sections?.length) ? [] : mine.paths.filter((p) => theirs.paths.some((q) => samePlace(p, q)));
1690
- const symbols = mine.symbols.filter((x) => theirs.symbols.includes(x));
1790
+ const symbols = mine.symbols.filter((x) => theirs.symbols.some((y) => symbolsOverlap(x, y)));
1691
1791
  return paths.length || symbols.length ? [{ task: t, paths, symbols }] : [];
1692
1792
  });
1693
1793
  }
@@ -2037,13 +2137,49 @@ export class Tasks {
2037
2137
  if (waits.length) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id} waits for ${waits.map((id) => `#${id}`).join(", ")}, not approved yet`);
2038
2138
  }
2039
2139
 
2140
+ /** Ready offers between their cap check and the owner write (#655): task id -> reserved owner whose slot it takes. */
2141
+ private readonly readyInFlight = new Map<number, PeerId>();
2142
+
2143
+ /** Offer order for ready work (#655): priority (urgent is P0, none is P2), then rank, then id. */
2144
+ private readonly readyOrder = (a: Task, b: Task): number =>
2145
+ (a.priority ?? (a.signals.includes("urgent") ? 0 : 2)) - (b.priority ?? (b.signals.includes("urgent") ? 0 : 2))
2146
+ || (a.rank ?? Number.MAX_SAFE_INTEGER) - (b.rank ?? Number.MAX_SAFE_INTEGER) || a.id - b.id;
2147
+
2148
+ /** Ready work the hub parked on its reserved owner's WIP cap (#655): its latest move-or-hold entry is `ready held`. */
2149
+ private isHeld(task: Task): boolean {
2150
+ // A planner's priority or rank edit (`planned`) is no move: it must not make held work look released.
2151
+ return task.history.findLast(entry => entry.event === "ready held" || !QUIET_EVENTS.has(entry.event) && entry.event !== "planned")?.event === "ready held";
2152
+ }
2153
+
2154
+ /**
2155
+ * Why readiness must not hand `task` to its reserved owner yet (#655): that owner is at its planner WIP cap, the count
2156
+ * `acceptanceCapacity` refuses at. Peer id and counts only, so it is safe for a PII task. A reserved owner the task
2157
+ * excludes is not honoured by routing, and planner-pulled work was admitted by its pull: neither is held.
2158
+ */
2159
+ private reservedHold(task: Task): string | undefined {
2160
+ const owner = task.reserved;
2161
+ if (!owner || task.owner || this.plannerPullRecord(task)?.pull === true || this.excluded(task).includes(owner)) return undefined;
2162
+ // Offers in flight take a slot before their owner is written; once it is written wip() counts it, so never twice.
2163
+ const flying = [...this.readyInFlight].filter(([id, peer]) => peer === owner && id !== task.id && !this.d.board.get(id)?.owner).length;
2164
+ const limit = this.wipLimit(owner), used = this.wip(owner, task.id) + (this.pendingClaims.get(owner) ?? 0) + flying;
2165
+ return used >= limit ? `reserved owner ${owner} is at its planner WIP cap (${used}/${limit}); stays ready in To Do with its reservation until a slot frees` : undefined;
2166
+ }
2167
+
2168
+ /** The task is ready but its reserved owner has no room: say so once on the task history, never assign. */
2169
+ private holdReady(task: Task, why: string, reason: string): void {
2170
+ if (this.isHeld(task)) return;
2171
+ const ready = task.history.at(-1)?.event === "ready" ? task : this.d.board.update(task.id, HUB, "ready", {}, why);
2172
+ this.d.board.update(ready.id, HUB, "ready held", {}, reason);
2173
+ this.releaseNotice(`task ${this.publicTitle(ready)} is ready but held: ${reason}`);
2174
+ }
2175
+
2040
2176
  /** An approved task may be the last thing others waited for: those go through assignment now (issue #34). */
2041
2177
  private async releaseDependents(approved: Task): Promise<void> {
2042
2178
  try {
2043
- for (const t of this.d.board.list("proposed")) {
2044
- if (!t.deps?.includes(approved.id) || t.owner || this.waitsFor(t).length) continue;
2045
- await this.offerReady(t, `#${approved.id} approved`);
2046
- }
2179
+ // Its dependents and, as its owner's slot just freed (#655), that owner's held ready tasks: one list in priority order.
2180
+ const ready = this.d.board.list("proposed").filter(t => !t.owner && !this.waitsFor(t).length
2181
+ && (t.deps?.includes(approved.id) || !!approved.owner && t.reserved === approved.owner && this.isHeld(t))).sort(this.readyOrder);
2182
+ for (const t of ready) await this.offerReady(t, t.deps?.includes(approved.id) ? `#${approved.id} approved` : "owner slot freed");
2047
2183
  } catch (e) {
2048
2184
  this.releaseNotice(`task #${approved.id}: could not release dependents: ${e instanceof Error ? e.message : String(e)}`);
2049
2185
  }
@@ -2165,14 +2301,25 @@ export class Tasks {
2165
2301
  this.offered.delete(task.id);
2166
2302
  this.releaseNotice(`task #${task.id}: cancellation stall cleanup unavailable`, () => this.d.board.clearStall(task.id));
2167
2303
  for (const peer of new Set([task.owner, task.reviewer])) if (peer && peer !== USER) this.releaseNotice(`task #${task.id}: cancellation notice unavailable`, () => this.d.bus.publish(newEnvelope(HUB, sanitize(`Task #${task.id} cancelled. Stop working on it. No native turn or working tree was changed.`), { to: [peer], kind: "task", priority: "important", refs: { task: String(task.id), taskCancelled: cancelled.flowRevision }, ...(this.isPii(next) ? { private: true } : {}) })));
2304
+ await this.releaseHeld(task.owner); // its owner's slot is free (#655)
2168
2305
  this.releaseNotice(`task #${task.id}: cancellation dependent advice unavailable`, () => {
2169
2306
  for (const dependent of this.d.board.list()) if (!isClosedTaskState(dependent.state) && !dependent.owner && dependent.deps?.includes(task.id)) this.releaseNotice(`task #${dependent.id}: cancellation stall advice unavailable`, () => this.reportStall(dependent, { kind: "cancelled-dependency", dependency: task.id, code: "cancelled" }));
2170
2307
  });
2171
2308
  return next;
2172
2309
  }
2173
2310
 
2311
+ /** A slot freed for `peer` (cancel, untouched return): its held ready tasks are offered in priority order, and the cap stops the offers (#655). */
2312
+ private async releaseHeld(peer: PeerId | null | undefined): Promise<void> {
2313
+ if (!peer) return;
2314
+ try {
2315
+ for (const t of this.d.board.list("proposed").filter(t => !t.owner && t.reserved === peer && this.isHeld(t) && !this.waitsFor(t).length).sort(this.readyOrder)) await this.offerReady(t, "owner slot freed");
2316
+ } catch (e) {
2317
+ this.releaseNotice(`task ${peer}: could not offer held ready work: ${e instanceof Error ? e.message : String(e)}`);
2318
+ }
2319
+ }
2320
+
2174
2321
  async releaseReady(): Promise<void> {
2175
- for (const t of this.d.board.list("proposed")) {
2322
+ for (const t of [...this.d.board.list("proposed")].sort(this.readyOrder)) {
2176
2323
  if (t.owner) { this.d.board.clearStall(t.id); continue; }
2177
2324
  const cancelledDependency = t.deps?.find(id => this.d.board.get(id)?.state === "cancelled");
2178
2325
  if (cancelledDependency) { this.reportStall(t, { kind: "cancelled-dependency", dependency: cancelledDependency, code: "cancelled" }); continue; }
@@ -2184,7 +2331,7 @@ export class Tasks {
2184
2331
  continue;
2185
2332
  }
2186
2333
  this.d.board.clearStall(t.id);
2187
- if ((!t.deps?.length && !t.history.some(h => h.event === "backlog pulled")) || (!planned && last !== "blocked" && last !== "ready" && last !== "reserved")) continue;
2334
+ if ((!t.deps?.length && !t.history.some(h => h.event === "backlog pulled")) || (!planned && last !== "blocked" && last !== "ready" && last !== "reserved" && !this.isHeld(t))) continue;
2188
2335
  // ponytail: a reserved owner (#207) that attaches after another peer that can take the task is passed over here like
2189
2336
  // an offline one; hold reserved tasks for a grace period if restarts show that up.
2190
2337
  await this.offerReady(t, "nothing left to wait for");
@@ -2193,13 +2340,18 @@ export class Tasks {
2193
2340
 
2194
2341
  /** Callers hold a list read before an await: re-read, or a task the other caller offered meanwhile is offered twice. */
2195
2342
  private async offerReady(stale: Task, why: string): Promise<void> {
2196
- let offering = false, undelivered: AssignmentUndeliveredError | undefined;
2343
+ let offering = false, undelivered: AssignmentUndeliveredError | undefined, slot: PeerId | undefined;
2197
2344
  try {
2198
2345
  const t = this.d.board.get(stale.id);
2199
2346
  if (!t || t.state !== "proposed" || t.owner || this.personUnassigned(t) || this.offered.has(t.id)) return;
2200
2347
  const planned = this.plannerPullRecord(t)?.pull === true;
2201
2348
  const selected = planned ? this.plannerAssignment(t, true).owner : undefined;
2202
2349
  if (planned && !selected) return;
2350
+ const held = planned ? undefined : this.reservedHold(t); // #655: the cap holds before the ready move is offered
2351
+ if (held) { this.holdReady(t, why, held); return; }
2352
+ // The check and the slot are one synchronous step: assignOwner can yield before it writes the owner, and a second offer
2353
+ // for the same reserved owner must see this one as taking the last slot.
2354
+ if (!planned && t.reserved) { slot = t.reserved; this.readyInFlight.set(t.id, slot); }
2203
2355
  this.offered.add(t.id);
2204
2356
  offering = true;
2205
2357
  const ready = t.history.at(-1)?.event === "ready" ? t : this.d.board.update(t.id, HUB, "ready", {}, why);
@@ -2211,6 +2363,7 @@ export class Tasks {
2211
2363
  if (e instanceof AssignmentUndeliveredError) undelivered = e;
2212
2364
  else this.releaseNotice(`task ${this.publicTitle(stale)}: could not be assigned: ${e instanceof Error ? e.message : String(e)}`);
2213
2365
  } finally {
2366
+ if (slot) this.readyInFlight.delete(stale.id);
2214
2367
  if (offering && !undelivered) {
2215
2368
  try {
2216
2369
  if (!this.d.board.get(stale.id)?.owner) this.offered.delete(stale.id);
@@ -2226,6 +2379,48 @@ export class Tasks {
2226
2379
  if (by !== USER && task[role] !== by) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id}: only its ${role} (${task[role] ?? "none"}) or the console user can do that`);
2227
2380
  }
2228
2381
 
2382
+ /** #694: write the one issue the task itself names (the plan-review source) into its lifecycle receipt, once; never relinks. */
2383
+ private linkIssue(task: Task): Task {
2384
+ if (this.isPii(task) || task.sdlc?.issue) return task;
2385
+ let identity: { repo: string; issue: number } | undefined;
2386
+ try { identity = this.planIssueIdentity(task); } catch { return task; }
2387
+ const next = identity && linkedReceipt(task.sdlc, identity, this.d.sdlc?.config() ?? sdlcConfig(undefined));
2388
+ return next ? this.d.board.update(task.id, HUB, "lifecycle linked", { sdlc: next }, `issue #${identity!.issue}`) : task;
2389
+ }
2390
+
2391
+ /**
2392
+ * #694: the PR the hub can verify for a done, without writing it: the one open PR whose head is the done's full commit
2393
+ * (or, with no commit, its branch). The read's basis travels with it, so a task whose commit or branch changed while the
2394
+ * read was awaited never receives a PR found for another head. The done persists it only after its gates admit it, so a
2395
+ * refused done stays effect-free (#254).
2396
+ */
2397
+ private async identityLink(task: Task, refs?: TaskRefs): Promise<IdentityLink | undefined> {
2398
+ if (this.isPii(task) || task.sdlc?.pr) return undefined;
2399
+ const repository = task.sdlc?.repo ?? task.refs.repo ?? this.d.repository;
2400
+ const commit = refs?.commit ?? task.refs.commit, branch = refs?.branch ?? task.refs.branch;
2401
+ if (!repository || !commit && !branch) return undefined;
2402
+ const pr = await openPrFor(repository, commit ? { commit } : { branch }, this.d.sdlc?.gh ?? sdlcGh);
2403
+ return pr ? { repo: repository, pr, basis: { commit, branch } } : undefined;
2404
+ }
2405
+
2406
+ /**
2407
+ * The task as the done's gates see it: the issue the current task names (re-resolved now, so a changed title or refs
2408
+ * counts) and the staged PR only while the done's effective commit and branch are still the ones it was read for.
2409
+ */
2410
+ private withLink(task: Task, link: IdentityLink | undefined, refs?: TaskRefs): Task {
2411
+ if ((this.d.sdlc?.config().mode ?? "off") === "off" || this.isPii(task)) return task;
2412
+ const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
2413
+ let issue: { repo: string; issue: number } | undefined;
2414
+ if (!task.sdlc?.issue) { try { issue = this.planIssueIdentity(task); } catch { issue = undefined; } }
2415
+ const commit = refs?.commit ?? task.refs.commit, branch = refs?.branch ?? task.refs.branch;
2416
+ const repository = task.sdlc?.repo ?? task.refs.repo ?? this.d.repository;
2417
+ const pr = link && !task.sdlc?.pr && repository?.toLowerCase() === link.repo.toLowerCase() && link.basis.commit === commit && (commit !== undefined || link.basis.branch === branch) ? link : undefined;
2418
+ let next = task.sdlc;
2419
+ if (issue) next = linkedReceipt(next, issue, config) ?? next;
2420
+ if (pr) next = linkedReceipt(next, { repo: pr.repo, pr: pr.pr }, config) ?? next;
2421
+ return next === task.sdlc ? task : { ...task, sdlc: next };
2422
+ }
2423
+
2229
2424
  private planIssueIdentity(task: Task): { repo: string; issue: number } | undefined {
2230
2425
  const source = task.external ?? task.sdlc;
2231
2426
  if (source?.repo && source.issue) return { repo: source.repo, issue: source.issue };
@@ -2278,6 +2473,16 @@ export class Tasks {
2278
2473
  const digest = plannerGrooming(visible, { repository: this.d.repository ?? `path:${realPath(this.d.cwd)}`,
2279
2474
  limits: Object.fromEntries(peers.map(peer => [peer, this.wipLimit(peer)])), wip: Object.fromEntries(peers.map(peer => [peer, this.wip(peer)])),
2280
2475
  states, issues, reviewedIssues, acceptedIssues, blockers });
2476
+ // #694: backfill lifecycle issue links after every authority check, with their own bound (not the metadata-read
2477
+ // budget): each call links up to 32 open tasks that still lack one, so later tasks are reached on later calls.
2478
+ if ((this.d.sdlc?.config().mode ?? "off") !== "off") {
2479
+ let linked = 0;
2480
+ for (const task of this.d.board.list()) {
2481
+ if (linked >= 32) break;
2482
+ if (isClosedTaskState(task.state) || task.sdlc?.issue || this.isPii(task)) continue;
2483
+ if (this.linkIssue(task) !== task) linked++;
2484
+ }
2485
+ }
2281
2486
  return { ...digest, truncated: digest.truncated || incomplete, ...(current.some(task => this.isPii(task)) ? { privateTasks: "withheld" } : {}) };
2282
2487
  }
2283
2488
 
@@ -2374,8 +2579,9 @@ export class Tasks {
2374
2579
  const current = this.need(task.id, false, true, true);
2375
2580
  if (current.history.length !== flow || (current.planningRevision ?? 0) !== revision) throw new PreEffectToolRefusal("plan review changed during readback");
2376
2581
  const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
2377
- const updated = this.d.board.update(current.id, by, "plan reviewed", { ...(Object.keys(scope).length ? { plan: { ...current.plan, ...scope } } : {}), sdlc: { ...current.sdlc, stage: current.sdlc?.stage ?? lifecycleStage(config, "plan"),
2582
+ let updated = this.d.board.update(current.id, by, "plan reviewed", { ...(Object.keys(scope).length ? { plan: { ...current.plan, ...scope } } : {}), sdlc: { ...current.sdlc, stage: current.sdlc?.stage ?? lifecycleStage(config, "plan"),
2378
2583
  planReview: { planner: by, planningRevision: revision, recordedAt: Date.now(), ...(Object.keys(scope).length ? { scope } : {}), ...(issue ? { issue } : {}) } } }, raw.reason.trim(), { ...(withheld ? { withheld: true } : {}) }, flow);
2584
+ if (config.mode !== "off") updated = this.linkIssue(updated); // #694
2379
2585
  if (updated.state === "proposed" && !updated.owner) {
2380
2586
  this.offered.delete(updated.id);
2381
2587
  try { await this.offerReady(updated, "planner review recorded"); }
@@ -2390,7 +2596,11 @@ export class Tasks {
2390
2596
  if (by !== USER && by !== task.owner && by !== task.reviewer) throw new PreEffectToolRefusal("only the task owner, reviewer or console user may record lifecycle evidence");
2391
2597
  const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
2392
2598
  const next = sdlcReceipt(input, task.sdlc, config, VERSION);
2599
+ // The spec identity is person-owned in enforce. No observation cache or restart can reopen a relink bypass.
2600
+ const specRelink = (task.sdlc?.repo !== undefined && next.repo !== task.sdlc.repo) || (task.sdlc?.issue !== undefined && next.issue !== task.sdlc.issue);
2601
+ if (config.mode === "enforce" && by !== USER && specRelink) throw new PreEffectToolRefusal("only the person may relink an established repository or issue in enforce");
2393
2602
  const definition = stageDefinition(config, next.stage);
2603
+ if (next.redFirst && input && typeof input === "object" && "redFirst" in input && !this.isPii(task) && !this.publicMetadataText(JSON.stringify(next.redFirst))) throw new PreEffectToolRefusal("red-first metadata matches a PII pattern; use private work");
2394
2604
  // #481 S1: entering a build stage in enforce needs the person's spec acceptance; the person may move any stage.
2395
2605
  if (config.mode === "enforce" && by !== USER && next.stage !== task.sdlc?.stage && definition && canonicalStage(definition.base) === "build" && specState(next, undefined) === "missing") throw new PreEffectToolRefusal(this.specText(task, "missing", "blocked"));
2396
2606
  if (config.mode === "enforce" && config.definitions && input && typeof input === "object" && "stage" in input && next.stage !== task.sdlc?.stage && by !== USER) {
@@ -2409,13 +2619,16 @@ export class Tasks {
2409
2619
  const suppliedHead = !!input && typeof input === "object" && (input as { reviewedHead?: unknown }).reviewedHead !== undefined;
2410
2620
  const artifactChanged = next.repo !== task.sdlc?.repo || next.pr !== task.sdlc?.pr;
2411
2621
  if (next.reviewedHead !== undefined && (next.reviewedHead !== task.sdlc?.reviewedHead || (suppliedHead && artifactChanged))) this.mine(task, by, "reviewer", true);
2412
- return this.d.board.update(task.id, by, "lifecycle", { sdlc: next });
2622
+ const departure = task.history.findLast(h => h.by === USER && h.planDeparture)?.planDeparture;
2623
+ const invalidatesDeparture = !!departure && (artifactChanged || next.localHead !== task.sdlc?.localHead && next.localHead !== undefined && next.localHead !== departure.head || next.reviewedHead !== task.sdlc?.reviewedHead && next.reviewedHead !== undefined && next.reviewedHead !== departure.head);
2624
+ return this.d.board.update(task.id, by, "lifecycle", { sdlc: next }, undefined, { ...this.planObservation({ ...task, sdlc: next }), ...(invalidatesDeparture ? { planDepartureInvalidated: true } : {}) });
2413
2625
  }
2414
2626
 
2415
2627
  async deliveryQueue(repo: string) {
2628
+ const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
2416
2629
  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 })));
2417
- 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) }));
2418
- const report = await readMergeQueue(repo, this.d.sdlc?.config() ?? sdlcConfig(undefined), links, this.d.sdlc?.gh);
2630
+ 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) } }));
2631
+ const report = await readMergeQueue(repo, config, links, this.d.sdlc?.gh, this.publicMetadataText);
2419
2632
  // Policy can change across remote awaits without any board write. Never return its stale public projection.
2420
2633
  const currentPrivacy = this.d.board.list().map(task => ({ id: task.id, private: this.isPii(task) }));
2421
2634
  if (workflowHash(links.map(({ id, private: hidden }) => ({ id, private: hidden }))) !== workflowHash(currentPrivacy)) {
@@ -2424,6 +2637,20 @@ export class Tasks {
2424
2637
 
2425
2638
  const after = workflowHash(this.d.board.list().map(t => ({ id: t.id, state: t.state, after: t.deps, sdlc: t.sdlc, history: t.history.length })));
2426
2639
  if (before !== after) { report.complete = false; report.order = []; if (report.rows.length) report.unordered = report.rows.map(row => row.pr); for (const row of report.rows) { row.ready = false; row.blockers.push("board changed during delivery readback"); } }
2640
+ else if (config.mode !== "off") for (const row of report.rows) if (row.head && !row.private) for (const id of row.tasks) {
2641
+ const task = this.d.board.get(id);
2642
+ if (task?.sdlc?.repo?.toLowerCase() === report.repo.toLowerCase() && task.sdlc.pr === row.pr) {
2643
+ if (this.validPlanDepartures(task).length && row.planConformance?.some(item => item.task === id && item.state !== "unknown" && !item.accepted)) this.invalidatedPlanDepartures.add(id);
2644
+ this.capturePlanHead(task, row.head);
2645
+ }
2646
+ }
2647
+ for (const row of report.rows) if (row.testIntegrity) {
2648
+ row.testIntegrity = row.testIntegrity.map(item => ({ ...publicTestIntegrity(item, this.publicMetadataText), ...(item.task === undefined ? {} : { task: item.task }) }));
2649
+ if (config.mode === "enforce") {
2650
+ row.blockers = [...new Set([...row.blockers, ...row.testIntegrity.flatMap(testIntegrityBlockers)])];
2651
+ row.ready = row.ready && row.blockers.length === 0;
2652
+ }
2653
+ }
2427
2654
  return report;
2428
2655
  }
2429
2656
  async releaseReadiness(repo: string, liveIssues: number[]) {
@@ -2457,12 +2684,114 @@ export class Tasks {
2457
2684
  const state = { id: task.id, state: task.state, stage, ...(this.isPii(task) ? { private: true } : { criteria: this.workflowView(task) ?? null }) };
2458
2685
  return { schema: 2, state, stateHash: workflowHash(state), sourceHash: workflowHash(config) };
2459
2686
  }
2687
+ private readonly planReads = new Map<number, { identity: string; evidence: PrEvidence }>();
2688
+ // ponytail: consent expires on restart because a refused readback cannot first mutate the task's history.
2689
+ // A durable host observation journal can retain safe consent across lifetimes without reviving pending invalidations.
2690
+ private readonly departureGeneration = crypto.randomUUID();
2691
+ private readonly invalidatedPlanDepartures = new Set<number>();
2692
+ private readonly observedPlans = new Map<number, NonNullable<HistoryEntry["planObservation"]>>();
2693
+ private planIdentity(task: Task): string {
2694
+ return JSON.stringify([task.refs, task.sdlc, task.plan, task.owner, task.reviewer, task.state, this.isPii(task), this.d.sdlc?.config()]);
2695
+ }
2696
+ private planReadIdentity(task: Task): string { return JSON.stringify([task.sdlc?.repo, task.sdlc?.pr, task.refs.repo, planFingerprint(task.plan ?? {})]); }
2697
+ private planPaths(task: Task): string[] | undefined {
2698
+ if (!task.sdlc?.repo) return;
2699
+ // Resolve the artifact slug first: it may warm the canonical project's previously unnamed identity.
2700
+ const artifact = this.repositories.scope({ repo: task.sdlc.repo, paths: task.refs.paths });
2701
+ const scope = this.repositories.scope(task.refs);
2702
+ if (scope.key !== artifact.key) return;
2703
+ return task.plan?.paths?.map(path => scope === this.repositories.project ? this.projectPath(path) : this.repositories.relativePath(scope, path));
2704
+ }
2705
+ private planScopeIdentity(task: Task): string {
2706
+ const paths = this.planPaths(task); // Warm verified aliases before capturing the scope identity.
2707
+ const scope = this.repositories.scope(task.refs);
2708
+ return planFingerprint({ key: scope.key, root: scope.root ?? null, aliases: [...scope.aliases].sort(),
2709
+ repo: task.refs.repo ?? null, absoluteRefs: (task.refs.paths ?? []).filter(isAbsolute).sort(), paths: paths ?? null });
2710
+ }
2711
+ private validPlanDepartures(task: Task): NonNullable<HistoryEntry["planDeparture"]>[] {
2712
+ const invalidated = task.history.findLastIndex(h => h.planDepartureInvalidated && !h.planDeparture);
2713
+ if (this.invalidatedPlanDepartures.has(task.id)) return [];
2714
+ const latest = task.history.findLastIndex(h => h.by === USER && h.event === "plan departure accepted" && h.planDeparture);
2715
+ return latest > invalidated && task.history[latest]!.planDeparture!.generation === this.departureGeneration
2716
+ && task.history[latest]!.planDeparture!.scope === this.planScopeIdentity(task) ? [task.history[latest]!.planDeparture!] : [];
2717
+ }
2718
+ private planReport(task: Task, evidence?: PrEvidence) {
2719
+ const rawReport = planConformance(this.isPii(task) ? undefined : this.planPaths(task), this.isPii(task) ? undefined : evidence);
2720
+ const safe = [...rawReport.outside, ...rawReport.untouched].every(path => this.publicMetadataText(path));
2721
+ const report: PlanConformance = safe ? rawReport : { state: "unknown", head: rawReport.head, outside: [], untouched: [] };
2722
+ const fingerprint = planFingerprint(task.plan ?? {});
2723
+ const observation = task.history.find(h => h.planObservation?.repo === task.sdlc?.repo && h.planObservation?.pr === task.sdlc?.pr)?.planObservation ?? this.observedPlans.get(task.id);
2724
+ const revision = task.history.findLast(h => h.planRevision)?.planRevision?.revision;
2725
+ const acceptance = evidence && this.validPlanDepartures(task).findLast(item => item.repo === task.sdlc?.repo && item.pr === task.sdlc?.pr && item.head === evidence.head
2726
+ && item.revision === (revision ?? 0) && item.fingerprint === fingerprint && JSON.stringify([...item.paths].sort()) === JSON.stringify([...evidence.paths].sort()));
2727
+ return { ...report, accepted: !!acceptance, earlierPushes: "unknown" as const,
2728
+ ...(observation && observation.repo === task.sdlc?.repo && observation.pr === task.sdlc?.pr ? { revisionsAfterFirstObservedPush: Math.max(0, (revision ?? 0) - observation.revision) } : {}) };
2729
+ }
2730
+ private capturePlanRead(task: Task, evidence: PrEvidence | undefined): void {
2731
+ if ((this.d.sdlc?.config().mode ?? "off") === "off" || !evidence || this.isPii(task) || !task.sdlc?.repo || !task.sdlc.pr) return;
2732
+ if (this.planReads.size >= 256 && !this.planReads.has(task.id)) this.planReads.delete(this.planReads.keys().next().value!);
2733
+ this.planReads.set(task.id, { identity: this.planReadIdentity(task), evidence });
2734
+ const accepted = task.history.findLast(h => h.by === USER && h.planDeparture)?.planDeparture;
2735
+ if (accepted && evidence.pathsComplete === true && JSON.stringify([...accepted.paths].sort()) !== JSON.stringify([...evidence.paths].sort())) this.invalidatedPlanDepartures.add(task.id);
2736
+ this.capturePlanHead(task, evidence.head);
2737
+ }
2738
+ private capturePlanHead(task: Task, head: string): void {
2739
+ if ((this.d.sdlc?.config().mode ?? "off") === "off" || this.isPii(task) || !task.sdlc?.repo || !task.sdlc.pr) return;
2740
+ if (this.planReads.get(task.id)?.evidence.head !== head) this.planReads.delete(task.id);
2741
+ const accepted = task.history.findLast(h => h.by === USER && h.planDeparture)?.planDeparture;
2742
+ if (accepted && (accepted.head !== head || accepted.repo !== task.sdlc.repo || accepted.pr !== task.sdlc.pr)) this.invalidatedPlanDepartures.add(task.id);
2743
+ if (!task.history.some(h => h.planObservation?.repo === task.sdlc?.repo && h.planObservation?.pr === task.sdlc?.pr)
2744
+ && (!this.observedPlans.has(task.id) || this.observedPlans.get(task.id)?.repo !== task.sdlc.repo || this.observedPlans.get(task.id)?.pr !== task.sdlc.pr)) {
2745
+ // ponytail: this is first observed push, not a true push event. Earlier pushes stay unknown; upgrade to a webhook/Actions push receipt.
2746
+ if (this.observedPlans.size >= 256) this.observedPlans.delete(this.observedPlans.keys().next().value!);
2747
+ this.observedPlans.set(task.id, { repo: task.sdlc.repo, pr: task.sdlc.pr, head,
2748
+ revision: task.history.findLast(h => h.planRevision)?.planRevision?.revision ?? 0, fingerprint: planFingerprint(task.plan ?? {}) });
2749
+ }
2750
+ }
2751
+ private planObservation(task: Task): Pick<HistoryEntry, "planObservation" | "planDepartureInvalidated"> {
2752
+ if ((this.d.sdlc?.config().mode ?? "off") === "off") return {};
2753
+ const observation = this.observedPlans.get(task.id);
2754
+ const lastAcceptance = task.history.findLastIndex(h => h.by === USER && h.planDeparture);
2755
+ return {
2756
+ ...(observation && observation.repo === task.sdlc?.repo && observation.pr === task.sdlc?.pr
2757
+ && !task.history.some(h => h.planObservation?.repo === observation.repo && h.planObservation.pr === observation.pr) ? { planObservation: observation } : {}),
2758
+ ...(this.invalidatedPlanDepartures.has(task.id) && !task.history.slice(lastAcceptance + 1).some(h => h.planDepartureInvalidated) ? { planDepartureInvalidated: true as const } : {}),
2759
+ };
2760
+ }
2761
+ async acceptPlanDeparture(by: PeerId, id: unknown, head: unknown, reason: unknown): Promise<Task> {
2762
+ if (by !== USER) throw new PreEffectToolRefusal("only the person may accept a plan departure");
2763
+ if (typeof head !== "string" || !/^[a-f0-9]{40}$/.test(head) || typeof reason !== "string" || !reason.trim() || reason.length > 2048) throw new PreEffectToolRefusal("departure acceptance requires a full head and a bounded reason");
2764
+ const initial = this.need(id, false, true), identity = this.planIdentity(initial), scope = this.planScopeIdentity(initial), history = initial.history.length;
2765
+ const valid = () => { const now = this.need(id, false, true); if (isClosedTaskState(now.state) || identity !== this.planIdentity(now) || scope !== this.planScopeIdentity(now) || history !== now.history.length) throw new PreEffectToolRefusal("task changed during departure readback"); return now; };
2766
+ if (this.isPii(initial)) throw new PreEffectToolRefusal("private PR file evidence is unavailable; amend the private plan instead");
2767
+ const read: SdlcGh = (args, timeout, cwd, cap) => { valid(); return (this.d.sdlc?.gh ?? sdlcGh)(args, timeout, cwd, cap); };
2768
+ const evidence = initial.sdlc && await readSdlcPr(initial.sdlc, read); valid();
2769
+ if (!evidence || completePlanInventory(evidence) === undefined) throw new PreEffectToolRefusal("complete PR file inventory unknown");
2770
+ if (evidence.head !== head) throw new PreEffectToolRefusal("departure acceptance head is not the current PR head");
2771
+ if (this.planReport(initial, evidence).state !== "departure") throw new PreEffectToolRefusal("current plan conformance is not a known departure; amend unknown scope or coverage first");
2772
+ const note = sanitize(reason.replace(/\s+/g, " ").trim());
2773
+ const withheld = await this.withholds("note", note, initial); const current = valid();
2774
+ this.capturePlanRead(current, evidence);
2775
+ const next = this.d.board.update(current.id, USER, "plan departure accepted", {}, note, { withheld: withheld || undefined, ...this.planObservation(current),
2776
+ planDeparture: { generation: this.departureGeneration, scope, repo: current.sdlc!.repo!, pr: current.sdlc!.pr!, head, revision: current.history.findLast(h => h.planRevision)?.planRevision?.revision ?? 0, fingerprint: planFingerprint(current.plan ?? {}), paths: [...evidence.paths] } });
2777
+ this.invalidatedPlanDepartures.delete(current.id);
2778
+ return next;
2779
+ }
2460
2780
  async lifecycleStatus(id: unknown, refresh = true): Promise<SdlcStatus> {
2461
- const task = this.need(id), config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
2462
- const evidence = task.sdlc && !this.isPii(task) && refresh && config.mode !== "off" ? await readSdlcPr(task.sdlc, this.d.sdlc?.gh) : undefined;
2781
+ const task = this.need(id), config = this.d.sdlc?.config() ?? sdlcConfig(undefined), identity = this.planIdentity(task);
2782
+ const valid = () => { const now = this.need(id, false, true); if (this.planIdentity(now) !== identity || now.history.length !== task.history.length) throw new PreEffectToolRefusal("task changed during lifecycle readback"); return now; };
2783
+ const deadline = performance.now() + 20_000;
2784
+ const read: SdlcGh = (args, timeout, cwd, cap) => { valid(); const remaining = deadline - performance.now(); return remaining > 0 ? (this.d.sdlc?.gh ?? sdlcGh)(args, Math.min(timeout ?? 10_000, remaining), cwd, cap) : Promise.resolve({ code: 1, stdout: "" }); };
2785
+ const evidence = task.sdlc && !this.isPii(task) && refresh && config.mode !== "off" ? await readSdlcPr(task.sdlc, read, true) : undefined;
2786
+ valid();
2463
2787
  const accepted = task.sdlc?.acceptance;
2464
- const edit = accepted && !this.isPii(task) && refresh && config.mode !== "off" ? await readSpecEdit(accepted.repo, accepted.issue, this.d.sdlc?.gh) : undefined;
2465
- return sdlcStatus(config, task.sdlc, evidence, this.workflowView(task), edit);
2788
+ const edit = accepted && !this.isPii(task) && refresh && config.mode !== "off" ? await readSpecEdit(accepted.repo, accepted.issue, read) : undefined;
2789
+ const integrity = config.mode !== "off" ? publicTestIntegrity(task.sdlc && evidence && !this.isPii(task) ? await readTestIntegrity(task.sdlc, evidence, read) : this.unknownTestIntegrity(this.isPii(task)), this.publicMetadataText) : undefined;
2790
+ const current = valid();
2791
+ if (this.planIdentity(current) !== this.planIdentity(task) || current.history.length !== task.history.length) throw new PreEffectToolRefusal("task changed during lifecycle readback");
2792
+ this.capturePlanRead(current, evidence);
2793
+ return sdlcStatus(config, current.sdlc, evidence, this.workflowView(current), edit, this.isPii(current) ? [] : this.reviewFindingSets(current), current.refs.commit,
2794
+ config.mode !== "off" ? this.planReport(this.d.board.get(current.id)!, evidence) : undefined, integrity);
2466
2795
  }
2467
2796
 
2468
2797
  /** #481 S1: only build work starts from an accepted spec. */
@@ -2528,24 +2857,34 @@ export class Tasks {
2528
2857
  this.d.notify(this.specText(task, state, "advice"));
2529
2858
  }
2530
2859
 
2531
- private async lifecycleGate(task: Task, transition: "review" | "approval", reviewedHead?: string): Promise<PrEvidence | undefined> {
2860
+ private unknownTestIntegrity(privateTask = false): TestIntegrity {
2861
+ return { applicability: "unknown", redFirst: "unknown", fileState: "unknown", modifiedTests: [], blockers: [privateTask ? "test integrity: private evidence unavailable" : "test integrity: current evidence unknown"] };
2862
+ }
2863
+
2864
+ private async lifecycleGate(task: Task, transition: "review" | "approval", reviewedHead?: string, findings?: readonly ReviewFindingSet[]): Promise<PrEvidence | undefined> {
2532
2865
  const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
2533
2866
  if (config.mode === "off") return;
2534
2867
  const receipt = task.sdlc && { ...task.sdlc, ...(reviewedHead ? { reviewedHead } : {}) };
2868
+ const deadline = performance.now() + 20_000;
2535
2869
  const read: SdlcGh = (args, timeout, cwd, cap) => {
2536
2870
  const current = this.need(task.id, false, true);
2537
2871
  if (current.history.length !== task.history.length || current.owner !== task.owner || current.reviewer !== task.reviewer || this.isPii(current) !== this.isPii(task)) return Promise.resolve({ code: 1, stdout: "" });
2538
- return (this.d.sdlc?.gh ?? sdlcGh)(args, timeout, cwd, cap);
2872
+ const remaining = deadline - performance.now();
2873
+ return remaining > 0 ? (this.d.sdlc?.gh ?? sdlcGh)(args, Math.min(timeout ?? 10_000, remaining), cwd, cap) : Promise.resolve({ code: 1, stdout: "" });
2539
2874
  };
2540
- const evidence = receipt && !this.isPii(task) ? await readSdlcPr(receipt, read) : undefined;
2875
+ const evidence = receipt && !this.isPii(task) ? await readSdlcPr(receipt, read, true) : undefined;
2876
+ const integrity = publicTestIntegrity(receipt && evidence && !this.isPii(task) ? await readTestIntegrity(receipt, evidence, read) : this.unknownTestIntegrity(this.isPii(task)), this.publicMetadataText);
2541
2877
  const current = this.need(task.id, false, true);
2542
2878
  if (config.mode === "enforce" && (current.history.length !== task.history.length || current.owner !== task.owner || current.reviewer !== task.reviewer || current.state !== task.state)) throw new PreEffectToolRefusal(`task #${task.id}: changed during lifecycle readback; retry on current work`);
2543
2879
  // Advise may continue on the current task, but evidence from its previous link cannot admit that task's review.
2544
2880
  if (current.history.length !== task.history.length || current.owner !== task.owner || current.reviewer !== task.reviewer || current.state !== task.state || this.isPii(current) !== this.isPii(task)) return;
2545
- const status = sdlcStatus(config, receipt, evidence, this.workflowView(current));
2546
- const blockers = transition === "review" ? [...new Set([...status.blockers.filter(blocker => ["lifecycle stage definition unknown", "issue identity missing", "PR identity missing", "current PR evidence unknown", "PR is not open", "local gate on current head missing or failed"].includes(blocker)), ...(status.entry ?? []), ...(status.exit ?? [])])] : status.blockers;
2881
+ this.capturePlanRead(current, evidence);
2882
+ const planReport = this.planReport(current, evidence);
2883
+ const status = sdlcStatus(config, receipt, evidence, this.workflowView(current), undefined, this.isPii(current) ? [] : findings ?? this.reviewFindingSets(current), current.refs.commit, planReport, integrity);
2884
+ const blockers = transition === "review" ? [...new Set([...status.blockers.filter(blocker => ["lifecycle stage definition unknown", "issue identity missing", "PR identity missing", "current PR evidence unknown", "PR is not open", "local gate on current head missing or failed"].includes(blocker) || blocker.startsWith("test integrity:")), ...(status.entry ?? []), ...(status.exit ?? [])])] : status.blockers;
2547
2885
  if (config.mode === "enforce" && blockers.length) throw new PreEffectToolRefusal(`task #${task.id}: SDLC ${transition} blocked: ${blockers.join("; ")}`);
2548
- if (config.mode === "advise" && blockers.length) this.d.notify(`task ${this.publicTitle(task)}: SDLC ${transition} advice: ${this.isPii(task) ? "private lifecycle evidence unknown" : blockers.join("; ")}`);
2886
+ const advice = [...new Set([...blockers, ...planConformanceBlockers(planReport), ...testIntegrityBlockers(integrity)])];
2887
+ if (config.mode === "advise" && advice.length) this.d.notify(`task ${this.publicTitle(task)}: SDLC ${transition} advice: ${this.isPii(task) ? "private lifecycle evidence unknown" : advice.join("; ")}`);
2549
2888
  return evidence;
2550
2889
  }
2551
2890
 
@@ -2746,19 +3085,25 @@ export class Tasks {
2746
3085
  /** Whether a completion check is still running for this task. */
2747
3086
  isChecking = (id: number) => this.checking.has(id);
2748
3087
 
3088
+ private completionCurrent(id: unknown, by: PeerId, preEffect: boolean): Task {
3089
+ const task = this.need(id, false, preEffect);
3090
+ this.mine(task, by, "owner", preEffect);
3091
+ if (preEffect) {
3092
+ if (task.state === "in_review" || isClosedTaskState(task.state)) throw new PreEffectToolRefusal(`task #${task.id} is already ${task.state}`);
3093
+ } else {
3094
+ if (task.state === "in_review" || isClosedTaskState(task.state)) throw new Error(`task #${task.id} is already ${task.state}`);
3095
+ }
3096
+ this.ready(task, preEffect);
3097
+ if (task.state === "proposed" || task.state === "changes_requested") this.acceptanceCapacity(task);
3098
+ if (this.checking.has(task.id)) {
3099
+ // The result goes only to the owner the check was started for: anyone who took the task since hears nothing.
3100
+ throw new (preEffect ? PreEffectToolRefusal : Error)(this.checking.get(task.id) === task.owner ? `task #${task.id}: its check is still running; its result comes as a task message` : `task #${task.id}: a check from before it changed hands is still running; call hub_task_done again in a few minutes`);
3101
+ }
3102
+ return task;
3103
+ }
3104
+
2749
3105
  async done(by: PeerId, id: unknown, summary?: string, refs?: TaskRefs): Promise<Task> {
2750
- const valid = (): Task => {
2751
- const t = this.need(id, false, true);
2752
- this.mine(t, by, "owner", true);
2753
- if (t.state === "in_review" || isClosedTaskState(t.state)) throw new PreEffectToolRefusal(`task #${t.id} is already ${t.state}`);
2754
- this.ready(t, true);
2755
- if (t.state === "proposed" || t.state === "changes_requested") this.acceptanceCapacity(t);
2756
- if (this.checking.has(t.id)) {
2757
- // The result goes only to the owner the check was started for: anyone who took the task since hears nothing.
2758
- throw new PreEffectToolRefusal(this.checking.get(t.id) === t.owner ? `task #${t.id}: its check is still running; its result comes as a task message` : `task #${t.id}: a check from before it changed hands is still running; call hub_task_done again in a few minutes`);
2759
- }
2760
- return t;
2761
- };
3106
+ const valid = () => this.completionCurrent(id, by, true);
2762
3107
  // The PII screen reads the summary of a done that passes these checks (issue #198); they run again after it.
2763
3108
  const withheld = await this.withholds("summary", summary, valid());
2764
3109
  let task = valid();
@@ -2769,10 +3114,14 @@ export class Tasks {
2769
3114
  await this.planEntryGate(by, admission);
2770
3115
  task = valid();
2771
3116
  }
3117
+ // #694: stage the identities the hub can verify; the gates read them, and only the admitted done persists them.
3118
+ const link = this.d.sdlc && this.d.sdlc.config().mode !== "off" ? await this.identityLink(task, refs) : undefined;
3119
+ const current = () => this.withLink(valid(), link, refs);
3120
+ task = current();
2772
3121
  let reviewEvidence: PrEvidence | undefined;
2773
- if (this.d.sdlc && this.d.sdlc.config().mode !== "off") { reviewEvidence = await this.lifecycleGate(task, "review"); task = valid(); }
2774
- const reviewAdmission = task.reviewer ? await this.reviewRequestAdmission(task, refs, true, by, reviewEvidence) : undefined;
2775
- task = valid();
3122
+ if (this.d.sdlc && this.d.sdlc.config().mode !== "off") { reviewEvidence = await this.lifecycleGate(task, "review"); task = current(); }
3123
+ const reviewAdmission = task.reviewer || this.d.prepareReviewBrief ? await this.reviewRequestAdmission(task, refs, true, by, reviewEvidence, undefined, staged => this.withLink(staged, link, refs), !task.reviewer) : undefined;
3124
+ task = current();
2776
3125
  // A member of a silent cohort (issue #107): its done is an intent, and the last of them integrates first.
2777
3126
  const cohort = this.cohorts.of(task.id);
2778
3127
  const silentMember = !!cohort?.silent && this.turnFree() && !!task.owner && !this.isPii(task);
@@ -2792,8 +3141,8 @@ export class Tasks {
2792
3141
  if (r.action === "request") return this.d.board.update(task.id, HUB, "integration requested", {}, this.integrationRequest(task, r));
2793
3142
  if (r.action === "unresolved") {
2794
3143
  this.d.notify(`task ${this.publicTitle(task)}: turn-free integration unresolved after ${MAX_REQUESTS} requests (${r.why}); its done is recorded, check the overlapping work by hand`);
2795
- task = this.d.board.update(task.id, HUB, "integration unresolved", {}, r.why);
2796
- } else if (r.integrated) task = this.d.board.update(task.id, HUB, "integrated", {}, `cohort #${cohort!.id}, revision ${cohort!.revision}`);
3144
+ task = this.withLink(this.d.board.update(task.id, HUB, "integration unresolved", {}, r.why), link, refs);
3145
+ } else if (r.integrated) task = this.withLink(this.d.board.update(task.id, HUB, "integrated", {}, `cohort #${cohort!.id}, revision ${cohort!.revision}`), link, refs);
2797
3146
  } else if (cohort?.held.size && !this.isPii(task)) {
2798
3147
  // No integration step for this member (silence lifted, a PII task open): the notices the silence held stand in
2799
3148
  // for it (issue #107, AC4).
@@ -2801,20 +3150,26 @@ export class Tasks {
2801
3150
  if (evidence && by === USER) this.d.notify(`task ${this.publicTitle(task)} done by the console; overlapping work finished meanwhile:\n${evidence}`);
2802
3151
  else if (evidence) this.doneNotes.set(task.id, `Overlapping work finished while you worked (no turn-free integration step ran):\n${evidence}`);
2803
3152
  }
2804
- if (task.state === "proposed" || task.state === "changes_requested") task = this.d.board.update(task.id, by, "accepted", { state: "in_progress" }, WITH_DONE); // done without a separate accept
3153
+ if (task.state === "proposed" || task.state === "changes_requested") task = this.withLink(this.d.board.update(task.id, by, "accepted", { state: "in_progress" }, WITH_DONE), link, refs); // done without a separate accept
2805
3154
  const command = this.d.runCheck ? this.d.check?.(task.class, task) : undefined;
2806
- if (!command) return this.complete(task, by, summary, refs, "", withheld, "review", reviewAdmission);
3155
+ if (!command) return this.complete(task, by, summary, refs, "", withheld, "review", reviewAdmission, link);
2807
3156
  // The tool call returns now; a check can outlast an agent's tool timeout. The result decides what comes next.
2808
3157
  this.checking.set(task.id, task.owner);
2809
3158
  const checkedRefs = cleanRefs(refs);
2810
3159
  this.warmRepository({ ...task, refs: { ...task.refs, ...checkedRefs } });
2811
- const pending = this.d.board.update(task.id, by, "done (checking)", { refs: checkedRefs }, summary, { withheld: withheld || undefined });
3160
+ const pending = this.d.board.update(task.id, by, "done (checking)", { codeSymbols: this.observedCodeSymbols({ ...task, refs: { ...task.refs, ...checkedRefs } }), refs: checkedRefs, ...(task.sdlc ? { sdlc: task.sdlc } : {}) }, summary, { withheld: withheld || undefined }, task.history.length, HOST_CODE_SYMBOLS);
2812
3161
  this.releaseNotice(`task ${this.publicTitle(pending)} done by ${by}; its check is queued or running: ${command}`);
2813
3162
  const seen = { events: pending.history.length, owner: pending.owner };
2814
3163
  this.pendingChecks++;
2815
3164
  this.checkQueue = this.checkQueue
2816
3165
  .then(() => this.finishChecked(pending.id, by, summary, command, seen, withheld))
2817
- .catch((e: Error) => this.d.notify(`task #${pending.id}: its check result could not be recorded: ${e.message}`))
3166
+ .catch((e: Error) => {
3167
+ this.releaseNotice(`task #${pending.id}: its check result could not be recorded: ${e.message}`);
3168
+ this.releaseNotice(`task #${pending.id}: its owner retry notice could not be published`, () => {
3169
+ const current = this.d.board.get(pending.id);
3170
+ if (current?.state === "in_progress" && current.owner === seen.owner) this.tell(current, `Task #${current.id}: its check completion was refused. Call hub_task_done again when it is ready.`, this.isPii(current));
3171
+ });
3172
+ })
2818
3173
  .finally(() => this.pendingChecks--);
2819
3174
  return pending;
2820
3175
  }
@@ -2850,7 +3205,7 @@ export class Tasks {
2850
3205
  const theirs = this.places(u);
2851
3206
  if (mine.repositoryKey !== theirs.repositoryKey) return [];
2852
3207
  const paths = t.profile && u.profile && (t.plan?.sections?.length && u.plan?.sections?.length) ? [] : mine.paths.filter((p) => theirs.paths.some((q) => samePlace(p, q)));
2853
- const symbols = mine.symbols.filter((x) => theirs.symbols.includes(x));
3208
+ const symbols = mine.symbols.filter((x) => theirs.symbols.some((y) => symbolsOverlap(x, y)));
2854
3209
  return paths.length || symbols.length ? [this.completedNotice(u, { task: t, paths, symbols }, at.note, at.withheld)] : [];
2855
3210
  });
2856
3211
  if (!open.length && !done.length) continue;
@@ -2933,25 +3288,19 @@ export class Tasks {
2933
3288
  }
2934
3289
 
2935
3290
  /** `gated`: the transition `done()` already gated with no await since, so it is not read back twice (#382). */
2936
- private async complete(task: Task, by: PeerId, summary?: string, refs?: TaskRefs, checkOutput = "", withheld = false, gated?: "review", admission?: ReviewReadiness): Promise<Task> {
3291
+ private async complete(task: Task, by: PeerId, summary?: string, refs?: TaskRefs, checkOutput = "", withheld = false, gated?: "review", admission?: ReviewReadiness, link?: IdentityLink): Promise<Task> {
2937
3292
  let selection = this.poolReview(task, refs);
2938
3293
  let reviewer = selection?.reviewer ?? task.reviewer, reviewEvidence: PrEvidence | undefined;
2939
3294
  if (this.d.sdlc && this.d.sdlc.config().mode !== "off" && gated !== (reviewer ? "review" : "approval")) {
2940
3295
  reviewEvidence = await this.lifecycleGate(task, reviewer ? "review" : "approval");
2941
- task = this.need(task.id, false, true);
2942
- this.mine(task, by, "owner", true);
2943
- // In every mode: another done may have landed during the readback.
2944
- if (task.state === "in_review" || isClosedTaskState(task.state)) throw new Error(`task #${task.id} is already ${task.state}`);
3296
+ task = this.withLink(this.completionCurrent(task.id, by, false), link, refs);
2945
3297
  selection = this.poolReview(task, refs);
2946
3298
  reviewer = selection?.reviewer ?? task.reviewer;
2947
3299
  }
2948
- // A delayed local check needs a new readback; the immediate done path already admitted before effects.
2949
- if (reviewer && !admission && (this.d.sdlc?.config().mode ?? "off") !== "off") {
2950
- const observed = task;
2951
- admission = await this.reviewRequestAdmission(task, refs, false, by, reviewEvidence);
2952
- task = this.need(task.id);
2953
- this.mine(task, by, "owner");
2954
- if (task.history.length !== observed.history.length || task.state === "in_review" || isClosedTaskState(task.state)) throw new Error("task changed during review admission");
3300
+ // A delayed local check needs a new readback; a completion-selected reviewer also needs optional facts in off mode.
3301
+ if (!admission && ((reviewer && (this.d.sdlc?.config().mode ?? "off") !== "off") || this.d.prepareReviewBrief)) {
3302
+ admission = await this.reviewRequestAdmission(task, refs, false, by, reviewEvidence, undefined, staged => this.withLink(staged, link, refs), !reviewer);
3303
+ task = this.withLink(this.completionCurrent(task.id, by, false), link, refs);
2955
3304
  }
2956
3305
  selection = this.poolReview(task, refs);
2957
3306
  reviewer = selection?.reviewer ?? task.reviewer;
@@ -2961,7 +3310,7 @@ export class Tasks {
2961
3310
  const completedRefs = cleanRefs(refs);
2962
3311
  this.warmRepository({ ...task, refs: { ...task.refs, ...completedRefs } });
2963
3312
  const noWrites = this.noWritesRecorded({ ...task, refs: { ...task.refs, ...completedRefs } });
2964
- const next = this.d.board.update(task.id, by, "done", { state: reviewer ? "in_review" : "approved", ...(selection ? { reviewer: reviewer ?? USER } : {}), ...(task.sdlc ? { sdlc: { ...task.sdlc, stage: lifecycleStage(this.d.sdlc?.config() ?? sdlcConfig(undefined), reviewer ? "review" : "merge") } } : {}), refs: completedRefs }, checkOutput ? `${routedSummary ?? ""}\n${checkOutput}`.trim() : routedSummary, { withheld: withheld || undefined, ...(noWrites ? { noWrites: true as const } : {}), ...this.reviewMetadata(selection), ...(reviewCost ? { reviewCost } : {}) });
3313
+ 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 } : {}) }, task.history.length, HOST_CODE_SYMBOLS);
2965
3314
  this.cohorts.closed(next.id); // its owner's next native turn end settles it in its cohort (issue #107)
2966
3315
  this.note(next, by, "finding", `Task #${next.id} done by ${by}: ${next.title}\n${summary ?? ""}`, withheld);
2967
3316
  this.tellCompleted(next, summary, withheld);
@@ -2971,7 +3320,7 @@ export class Tasks {
2971
3320
  this.releaseNotice(`task ${this.publicTitle(next)} done by ${by}, no reviewer: approved`);
2972
3321
  await this.releaseDependents(next);
2973
3322
  }
2974
- else if (reviewer === USER) this.releaseNotice(`task ${this.publicTitle(next)} done by ${by}: review it with ahub task show ${next.id}, then ahub review ${next.id} approved|changes_requested [note]`);
3323
+ 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]`);
2975
3324
  else this.releaseNotice(`task ${this.publicTitle(next)}: could not send the review request to ${reviewer}`, () => this.sendReview(next, reviewer, "", admission));
2976
3325
  return next;
2977
3326
  }
@@ -2985,6 +3334,9 @@ export class Tasks {
2985
3334
  const hits = this.overlapHits(task).filter((h) => h.task.owner !== USER && h.task.owner !== HUB);
2986
3335
  const cohort = this.cohorts.of(task.id);
2987
3336
  const silent = this.silentFor(task.id);
3337
+ // #713: one notice per recipient per done, naming all of that recipient's overlapping tasks, and only for work the recipient has accepted. An offered or proposed
3338
+ // task has nothing to check yet (its offer, or the accept that follows, already shows the current board), and a repeat done replaces the queued notice (see Bus.enqueue).
3339
+ const byOwner = new Map<PeerId, typeof hits>();
2988
3340
  for (const hit of hits) {
2989
3341
  // A member of the same silent cohort (issue #107): the member that finishes last integrates instead
2990
3342
  // (Cohorts.completion). The notice is held, so something replaces it if no integration runs. Others hear it.
@@ -2992,9 +3344,23 @@ export class Tasks {
2992
3344
  cohort.held.add(task.id);
2993
3345
  continue;
2994
3346
  }
2995
- this.releaseNotice(`task ${this.publicTitle(task)}: could not publish the completed-change notice to ${hit.task.owner}`, () =>
2996
- this.whileOpen(hit.task.owner!, hit.task.id, this.completedNotice(task, hit, summary, withheld)));
3347
+ // Accepted by its own owner since it last changed hands (`ownerAcceptance`, the rule the rest of this class uses), and still in work or review: an offered or proposed task is not.
3348
+ if (!["in_progress", "changes_requested", "in_review"].includes(hit.task.state) || !this.ownerAcceptance(hit.task)) continue;
3349
+ byOwner.set(hit.task.owner!, [...(byOwner.get(hit.task.owner!) ?? []), hit]);
2997
3350
  }
3351
+ for (const [owner, mine] of byOwner) {
3352
+ this.releaseNotice(`task ${this.publicTitle(task)}: could not publish the completed-change notice to ${owner}`, () =>
3353
+ this.whileOpenFor(owner, task.id, mine.map((h) => h.task.id), this.completedNotice(task, mine, summary, withheld)));
3354
+ }
3355
+ }
3356
+
3357
+ /** Share the same screened observation rule between completion notices and read-only console detail. */
3358
+ private sameFileDifferentSymbols(task: Task, hit: { task: Task; paths: string[] }): boolean {
3359
+ const symbolRepository = task.codeSymbols && !this.isPii(task) ? this.codeSymbolsRepository(task) : undefined;
3360
+ const otherRepository = hit.task.codeSymbols && !this.isPii(hit.task) ? this.codeSymbolsRepository(hit.task) : undefined;
3361
+ return !!symbolRepository && symbolRepository.key === otherRepository?.key && !this.isPii(task) && !this.isPii(hit.task) && taskCodeSymbolsDisjoint(
3362
+ task.codeSymbols, hit.task.codeSymbols, this.reviewHead(task), this.reviewHead(hit.task), symbolRepository.key,
3363
+ hit.paths, false, this.publicMetadataText);
2998
3364
  }
2999
3365
 
3000
3366
  /**
@@ -3002,13 +3368,18 @@ export class Tasks {
3002
3368
  * included), and any item that matches a PII pattern is left out of what other owners get, the summary also when the
3003
3369
  * PII screen did not clear it (`withheld`, #198).
3004
3370
  */
3005
- private completedNotice(task: Task, hit: { task: Task; paths: string[]; symbols: string[] }, summary?: string, withheld = false): string {
3371
+ private completedNotice(task: Task, hitOrHits: { task: Task; paths: string[]; symbols: string[] } | { task: Task; paths: string[]; symbols: string[] }[], summary?: string, withheld = false): string {
3372
+ const hits = Array.isArray(hitOrHits) ? hitOrHits : [hitOrHits];
3006
3373
  const paths = this.places(task).paths.filter(this.nameable);
3007
3374
  const signatures = (task.plan?.signatures ?? []).filter(this.nameable);
3008
3375
  const first = (summary ?? "").split("\n").find((l) => l.trim())?.trim().slice(0, 300);
3009
3376
  const line = first && !withheld && this.nameable(first) ? first : undefined;
3377
+ // One line per overlapping task of the recipient, so a single notice names all of them (#713).
3378
+ const touches = hits.map((hit) => `#${hit.task.id} on ${this.where(hit)}${this.sameFileDifferentSymbols(task, hit) ? " (same file, different symbols; observed names only, deleted definitions may be missing)" : ""}`);
3379
+ const open = hits.length > 1 ? `your open tasks ${touches.join("; ")}` : `your open ${touches[0]}`;
3380
+
3010
3381
  return [
3011
- `Task #${task.id} (owner ${task.owner}) is done and touches your open #${hit.task.id} on ${this.where(hit)}. Check your work against it before you go on.`,
3382
+ `Task #${task.id} (owner ${task.owner}) is done and touches ${open}. Check your work against it before you go on.`,
3012
3383
  paths.length ? `Changed files: ${paths.join(", ")}` : "",
3013
3384
  signatures.length ? `New or changed signatures: ${signatures.join("; ")}` : "",
3014
3385
  line ? `Summary: ${line}` : "",
@@ -3049,33 +3420,51 @@ export class Tasks {
3049
3420
 
3050
3421
  /** The one place a review request is written: the first reviewer and a replacement get the same text, refs and privacy. */
3051
3422
  /** Review admission is separate from brief construction (#228). All refusals precede the requesting done's writes. */
3052
- private async reviewRequestAdmission(task: Task, refs?: TaskRefs, preEffect = true, actor: PeerId = task.owner ?? USER, previous?: PrEvidence, sameRequest?: () => boolean): Promise<ReviewReadiness | undefined> {
3423
+ private async reviewRequestAdmission(task: Task, refs?: TaskRefs, preEffect = true, actor: PeerId = task.owner ?? USER, previous?: PrEvidence, sameRequest?: () => boolean, stage: (task: Task) => Task = task => task, prepareOnly = false): Promise<ReviewReadiness | undefined> {
3053
3424
  const mode = (this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode;
3054
- if (mode === "off") return undefined;
3425
+ if (mode === "off" && !this.d.prepareReviewBrief) return undefined;
3426
+ let quietDrift = false;
3055
3427
  const guard = () => {
3056
3428
  if (actor !== USER && actor !== task.owner) {
3057
3429
  const holders = Object.entries(this.d.roles ?? {}).filter(([, roles]) => Array.isArray(roles) && roles.includes("conductor")).map(([peer]) => peer);
3058
3430
  if (holders.length !== 1 || holders[0] !== actor) throw new (preEffect ? PreEffectToolRefusal : Error)("review admission authority changed");
3059
3431
  }
3060
- const current = this.need(task.id, false, preEffect);
3061
- if ((this.d.sdlc?.config().mode ?? "off") !== mode || current.history.length !== task.history.length && !sameRequest?.() || current.owner !== task.owner || current.reviewer !== task.reviewer
3432
+ // #694: the board copy seen through the same staged identity link the caller's task carries.
3433
+ const current = stage(this.need(task.id, false, preEffect));
3434
+ const changed = current.history.length !== task.history.length;
3435
+ const quiet = current.history.length > task.history.length && current.history.slice(task.history.length).every(entry => QUIET_EVENTS.has(entry.event));
3436
+ const sameHistory = !changed || (mode === "advise" && quiet) || !!sameRequest?.();
3437
+ if ((this.d.sdlc?.config().mode ?? "off") !== mode || !sameHistory || current.owner !== task.owner || mode !== "advise" && current.reviewer !== task.reviewer
3062
3438
  || this.isPii(current) !== this.isPii(task) || JSON.stringify(current.sdlc ?? null) !== JSON.stringify(task.sdlc ?? null)
3439
+ || mode === "advise" && (current.state !== task.state || current.refs.commit !== task.refs.commit)
3063
3440
  || actor !== USER && this.d.privateTurn?.(actor) && !this.isPii(current)) throw new (preEffect ? PreEffectToolRefusal : Error)("task or authority changed during review admission");
3441
+ quietDrift ||= mode === "advise" && changed && quiet;
3064
3442
  return current;
3065
3443
  };
3066
3444
  guard();
3445
+ if (!this.isPii(task) && this.d.prepareReviewBrief) {
3446
+ try { await this.d.prepareReviewBrief({ ...task, refs: { ...task.refs, ...cleanRefs(refs) } }); }
3447
+ catch { /* Optional code facts must not refuse a review. */ }
3448
+ guard();
3449
+ }
3450
+ if (mode === "off" || prepareOnly) return undefined;
3067
3451
  const expected = cleanRefs(refs).commit ?? task.refs.commit ?? task.sdlc?.head ?? task.sdlc?.localHead;
3068
- const read: SdlcGh = (args, timeout, cwd, cap) => { guard(); return (this.d.sdlc?.gh ?? sdlcGh)(args, timeout, cwd, cap); };
3452
+ const read: SdlcGh = (args, timeout, cwd, cap) => {
3453
+ guard();
3454
+ if (quietDrift) throw new Error("review readiness unknown: task changed during readback");
3455
+ return (this.d.sdlc?.gh ?? sdlcGh)(args, timeout, cwd, cap);
3456
+ };
3069
3457
  const evidence: ReviewReadiness = this.isPii(task)
3070
3458
  ? { ready: false, blockers: ["private review readiness unavailable"] }
3071
3459
  : task.sdlc ? reviewReadinessFromEvidence(previous, expected, this.d.sdlc?.config().requiredChecks ?? []) ?? await readReviewReadiness(task.sdlc, expected, read, this.d.sdlc?.config().requiredChecks ?? []) : { ready: false, blockers: ["review PR identity missing"] };
3072
3460
  const current = guard();
3073
- if (!evidence.ready) {
3074
- const message = `task ${this.publicTitle(current)}: premature review: ${evidence.blockers.join("; ")}`;
3461
+ const admitted: ReviewReadiness = quietDrift ? { ready: false, blockers: ["review readiness unknown: task changed during readback"] } : evidence;
3462
+ if (!admitted.ready) {
3463
+ const message = `task ${this.publicTitle(current)}: premature review: ${admitted.blockers.join("; ")}`;
3075
3464
  if (mode === "enforce") throw new (preEffect ? PreEffectToolRefusal : Error)(message);
3076
3465
  this.releaseNotice(message);
3077
3466
  }
3078
- return evidence;
3467
+ return admitted;
3079
3468
  }
3080
3469
 
3081
3470
  private sendReview(task: Task, reviewer: PeerId, why = "", admission?: ReviewReadiness): void {
@@ -3092,12 +3481,29 @@ export class Tasks {
3092
3481
  // A checklist that maps the change to its contract (issue #35): reviewers who check against the written plan catch more.
3093
3482
  const plan = planText(task.plan);
3094
3483
  const check = [...task.history].reverse().find((h) => h.event === "check passed");
3484
+ const cachedPlanRead = this.planReads.get(task.id);
3485
+ const planRead = this.planReport(task, cachedPlanRead?.identity === this.planReadIdentity(task) && cachedPlanRead.evidence.head === this.findingHead(task) ? cachedPlanRead.evidence : undefined);
3486
+ const conformanceText = mode !== "off" ? this.screen(task, `Plan conformance: ${JSON.stringify(planRead)}`, "plan conformance", reviewer) + "\n" : "";
3487
+ const profile = (task.profile ?? "dev") === (this.d.profile ?? "dev") ? profileData(this.d.profileData ?? task.profile ?? "dev") : profileData(task.profile);
3488
+ const target = plan ? `the plan (${plan})` : task.detail ? "the task detail above" : "the task title";
3489
+ const noCheckWording = profile.name === "research" || profile.name === "writing"
3490
+ ? "No check ran for this class: verify the claims and sources yourself."
3491
+ : profile.name === "general"
3492
+ ? "No check ran for this class: verify the outcome yourself."
3493
+ : "No check ran for this class: run the affected tests yourself.";
3494
+ const checkResult = check ? `Check result: ${(check.note ?? "").split("\n")[0]}` : noCheckWording;
3495
+ const checklistTemplates = profile.review_checklist ?? [
3496
+ "Map the changed signatures and call sites to {target}, and name each one that does not match.",
3497
+ "{check}",
3498
+ "List what is unmet in hub_review's unmet, one item each.",
3499
+ ];
3500
+ const checklistLines = checklistTemplates.map(template =>
3501
+ template.replaceAll("{target}", target).replaceAll("{check}", checkResult)
3502
+ );
3095
3503
  const checklist = [
3096
3504
  "Checklist:",
3097
3505
  ...this.planGuardItems(task).map(item => `- Check the plan against this live fail before the remaining checks: ${item.line}`),
3098
- `- Map the changed signatures and call sites to ${plan ? `the plan (${plan})` : task.detail ? "the task detail above" : "the task title"}, and name each one that does not match.`,
3099
- `- ${check ? `Check result: ${(check.note ?? "").split("\n")[0]}` : "No check ran for this class: run the affected tests yourself."}`,
3100
- `- List what is unmet in hub_review's unmet, one item each.`,
3506
+ ...checklistLines.map(line => `- ${line}`),
3101
3507
  ].join("\n");
3102
3508
  // #223 R2: a reviewer-only instance judges from facts; the builder's narrative stays out, earlier findings go in.
3103
3509
  const reviewOnly = this.d.reviewOnly?.(reviewer) === true;
@@ -3105,6 +3511,9 @@ export class Tasks {
3105
3511
  const specRepo = task.sdlc?.repo ?? task.external?.repo, specIssue = task.sdlc?.issue ?? task.external?.issue;
3106
3512
  const spec = reviewOnly && specRepo && specIssue ? `Spec issue: ${specRepo}#${specIssue}\n` : "";
3107
3513
  const findings = reviewOnly ? task.history.filter(h => h.event === "changes_requested" && h.note).map(h => `- ${h.by}: ${this.screen(task, h.note!, "review note", reviewer, h.withheld)}`) : [];
3514
+ const carried = findingsForHead(this.reviewFindingSets(task), this.findingHead(task)).filter(item => item.severity === "important" && item.disposition === "open");
3515
+ const visible = this.isPii(task) ? [] : carried.filter(item => this.findingTextAllowed(task, [item]) && !task.history.some(entry => entry.withheld && entry.reviewFindings?.items.some(value => value.id === item.id)));
3516
+ const carriedText = carried.length ? `Open Important findings carried until explicitly dispositioned: ${carried.length}\n${visible.map(({ id, pass, severity, path }) => JSON.stringify({ id, pass, severity, ...(path ? { path } : {}) })).join("\n")}${visible.length < carried.length ? "\nSome finding details withheld." : ""}\n` : "";
3108
3517
  const guidance = this.roleGuidance(task, reviewer, true);
3109
3518
  let projection = "";
3110
3519
  try { const text = this.d.projectedReview?.(task); if (text) projection = this.screen(task, sanitize(text), "document projection", reviewer); }
@@ -3112,7 +3521,7 @@ export class Tasks {
3112
3521
  const brief = this.briefText(task);
3113
3522
  // #572 AC3: advisory only, from git state never from task text, so it is safe for a PII task too.
3114
3523
  const checkout = mainCheckoutWarning(this.d.cwd, this.publicMetadataText);
3115
- 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` : ""}${checklist}\nGive your verdict with hub_review {id: ${task.id}, verdict: "approved" | "changes_requested", note, unmet}.`;
3524
+ 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.`;
3116
3525
  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 } : {}) }));
3117
3526
  }
3118
3527
 
@@ -3151,6 +3560,29 @@ export class Tasks {
3151
3560
  return next;
3152
3561
  }
3153
3562
 
3563
+ private codeSymbolsRepository(task: Task) {
3564
+ try {
3565
+ const repository = this.repositories.scope(task.refs);
3566
+ if (task.sdlc?.repo && this.repositories.scope({ repo: task.sdlc.repo }).key !== repository.key) return undefined;
3567
+ return repository;
3568
+ } catch { return undefined; }
3569
+ }
3570
+
3571
+ /** Host observations are saved only with an admitted transition, never by an optional read. */
3572
+ private observedCodeSymbols(task: Task) {
3573
+ if (this.isPii(task) || !task.refs.commit) return undefined;
3574
+ const repository = this.codeSymbolsRepository(task);
3575
+ // ponytail: the brief analyzes the validated project execution root. Foreign repositories stay unknown
3576
+ // until the brief carries independent repository provenance; a model's refs.repo cannot supply that proof.
3577
+ if (!repository || repository.key !== this.repositories.project.key) return undefined;
3578
+ try {
3579
+ const brief = this.d.reviewBrief?.(task);
3580
+ const observation = brief && !("unavailable" in brief) ? buildTaskCodeSymbols(brief, repository.key, this.publicMetadataText) : undefined;
3581
+ // A resolved short ref or a stale provider result is unknown advice, never a reason to refuse done.
3582
+ return observation?.head === task.refs.commit ? observation : undefined;
3583
+ } catch { return undefined; } // Optional metadata never costs the admitted result.
3584
+ }
3585
+
3154
3586
  /** #520: the rendered review brief of a non-PII task's commit, or why there is none; empty without a commit. */
3155
3587
  briefText(task: Task): string {
3156
3588
  if (this.isPii(task) || !task.refs.commit) return "";
@@ -3161,11 +3593,13 @@ export class Tasks {
3161
3593
  } catch { return "Review brief unavailable: it could not be built."; }
3162
3594
  }
3163
3595
 
3164
- async review(by: PeerId, id: unknown, verdict: unknown, note?: string, unmet?: unknown, head?: unknown, coverage?: unknown): Promise<Task> {
3596
+ async review(by: PeerId, id: unknown, verdict: unknown, note?: string, unmet?: unknown, head?: unknown, coverage?: unknown, findings?: unknown): Promise<Task> {
3165
3597
  const authority = by === USER ? undefined : this.d.reviewAuthority?.(by, Number(id));
3598
+ let findingGuard: ((task: Task) => void) | undefined;
3166
3599
  // #520: what the coverage was checked against, pinned through every await: the commit and the lifecycle receipt (the
3167
3600
  // inherited reviewedHead lives there).
3168
3601
  let covered: { commit: string | undefined; receipt: string } | undefined;
3602
+ let conformanceIdentity: string | undefined;
3169
3603
  const valid = (): Task => {
3170
3604
  const refused = authority?.();
3171
3605
  if (refused) throw new PreEffectToolRefusal(refused); // rechecked after every await, right before each write
@@ -3174,13 +3608,42 @@ export class Tasks {
3174
3608
  this.mine(t, by, "reviewer", true);
3175
3609
  if (verdict !== "approved" && verdict !== "changes_requested") throw new PreEffectToolRefusal('verdict must be "approved" or "changes_requested"');
3176
3610
  if (t.state !== "in_review") throw new PreEffectToolRefusal(`task #${t.id} is ${t.state}: cannot move to ${verdict} before its owner calls hub_task_done`);
3611
+ if (conformanceIdentity !== undefined && conformanceIdentity !== this.planIdentity(t)) throw new PreEffectToolRefusal("plan conformance identity changed during verdict");
3612
+ findingGuard?.(t);
3177
3613
  return t;
3178
3614
  };
3615
+ if ((this.d.sdlc?.config().mode ?? "off") !== "off") conformanceIdentity = this.planIdentity(valid());
3179
3616
  const items = textList(unmet);
3180
3617
  if (items.length) note = `${note ?? ""}\nUnmet: ${items.join("; ")}`.trim();
3181
3618
  // #520: an approval accounts for every reviewable path of the brief. enforce refuses before any effect, advise records
3182
3619
  // the gap in the verdict note, off and a person's own verdict skip it.
3183
3620
  const mode = this.d.sdlc?.config().mode ?? "off";
3621
+ const initial = valid(), suppliedFindings = findings !== undefined;
3622
+ let parsed: ReviewFinding[];
3623
+ try { parsed = parseReviewFindings(findings, { person: by === USER, maxNits: this.d.sdlc?.config().maxNits ?? DEFAULT_MAX_NITS }); }
3624
+ catch (error) { throw new PreEffectToolRefusal(error instanceof Error ? error.message : "invalid structured findings"); }
3625
+ const resolvedHead = this.findingHead(initial);
3626
+ let findingHead = resolvedHead;
3627
+ if (head !== undefined) {
3628
+ try { findingHead = sdlcReceipt({ reviewedHead: head }, initial.sdlc, this.d.sdlc?.config() ?? sdlcConfig(undefined)).reviewedHead; }
3629
+ catch (error) { throw new PreEffectToolRefusal(error instanceof Error ? error.message : "invalid finding head"); }
3630
+ }
3631
+ const report: ReviewFindingSet | undefined = suppliedFindings ? { ...(findingHead ? { head: findingHead } : {}), items: parsed } : undefined;
3632
+ const proposedSets = (task: Task) => [...this.reviewFindingSets(task), ...(report ? [report] : [])];
3633
+ if (head !== undefined || suppliedFindings || this.reviewFindingSets(initial).length) {
3634
+ const identity = (task: Task) => JSON.stringify({ owner: task.owner, reviewer: task.reviewer, commit: task.refs.commit, receipt: task.sdlc, resolvedHead: this.findingHead(task), private: this.isPii(task), findings: this.reviewFindingSets(task) });
3635
+ const original = identity(initial);
3636
+ findingGuard = task => {
3637
+ if (identity(task) !== original) throw new PreEffectToolRefusal("task review identity, privacy or findings changed during the verdict; review current work again");
3638
+ if (by !== USER && suppliedFindings && this.d.privateTurn?.(by) && !this.isPii(task)) throw new PreEffectToolRefusal("structured review of ordinary work is unavailable during a private turn");
3639
+ if (parsed.filter(item => item.severity === "nit").length > (this.d.sdlc?.config().maxNits ?? DEFAULT_MAX_NITS)) throw new PreEffectToolRefusal("review nit cap exceeded");
3640
+ if (report?.items.length && !report.head && this.d.sdlc?.config().mode === "enforce") throw new PreEffectToolRefusal("a finding report requires a resolvable full reviewed head in enforce");
3641
+ const current = findingsForHead(proposedSets(task), findingHead);
3642
+ if (current.length > 64) throw new PreEffectToolRefusal("one reviewed head supports at most 64 distinct finding IDs");
3643
+ if (verdict === "approved" && this.d.sdlc?.config().mode === "enforce" && findingTally(current).openImportant) throw new PreEffectToolRefusal("approval blocked: open important review findings on this head");
3644
+ };
3645
+ findingGuard(initial);
3646
+ }
3184
3647
  // A Tasks without a brief provider (library use; the daemon always has one) has no coverage gate.
3185
3648
  if (verdict === "approved" && by !== USER && mode !== "off" && this.d.reviewBrief) {
3186
3649
  const t = valid(), enforce = mode === "enforce", list = (paths: string[]) => `${paths.slice(0, 20).join(", ")}${paths.length > 20 ? ", ..." : ""}`;
@@ -3206,15 +3669,17 @@ export class Tasks {
3206
3669
  if (gap.length) note = `${note ?? ""}\nCoverage gap (${gap.length}): ${list(gap)}`.trim();
3207
3670
  }
3208
3671
  }
3672
+ if (head !== undefined && resolvedHead && findingHead !== resolvedHead) throw new PreEffectToolRefusal("supplied review head differs from the task resolved head");
3209
3673
  // The PII screen reads the note of a review that passes these checks (issue #198); they run again after it.
3210
- const withheld = await this.withholds("review note", note, valid());
3674
+ const withheld = await this.withholds("review note", parsed.length ? JSON.stringify({ note, findings: parsed }) : note, valid())
3675
+ || parsed.length > 0 && !this.findingTextAllowed(initial, parsed);
3211
3676
  let task = valid();
3212
3677
  let reviewedHead: string | undefined;
3213
3678
  if (head !== undefined) {
3214
3679
  const checked = sdlcReceipt({ reviewedHead: head }, task.sdlc, this.d.sdlc?.config() ?? sdlcConfig(undefined));
3215
3680
  reviewedHead = checked.reviewedHead;
3216
3681
  }
3217
- if (verdict === "approved" && this.d.sdlc && this.d.sdlc.config().mode !== "off") { await this.lifecycleGate(task, "approval", reviewedHead); task = valid(); }
3682
+ if (verdict === "approved" && this.d.sdlc && this.d.sdlc.config().mode !== "off") { await this.lifecycleGate(task, "approval", reviewedHead, proposedSets(task)); task = valid(); }
3218
3683
  let candidate: Extract<CandidateInput, { kind: "fail" }> | undefined;
3219
3684
  if (verdict === "changes_requested" && items.length && this.d.candidates && !withheld && !this.isPii(task) && !this.d.privateTurn?.(by)) {
3220
3685
  const input: CandidateInput = { project: this.d.store?.project ?? this.d.project, kind: "fail", approach: this.publicTitle(task), symptom: items.join("\n"),
@@ -3232,7 +3697,7 @@ export class Tasks {
3232
3697
  const family = by === USER ? undefined : (this.d.adapterCatalog?.familyOf ?? familyOf)(by, this.d.instances?.());
3233
3698
  const reviewerFamily = poolRequest?.reviewerFamily === family ? family : undefined;
3234
3699
  const reviewerReason = reviewerFamily ? poolRequest?.reviewerReason : undefined;
3235
- const flag = { withheld: withheld || undefined, ...(reviewContext ? { reviewContext } : {}), ...(reviewerFamily ? { reviewerFamily } : {}), ...(reviewerReason ? { reviewerReason } : {}) };
3700
+ const flag = { ...this.planObservation(task), withheld: withheld || undefined, ...(report ? { reviewFindings: report } : {}), ...(reviewContext ? { reviewContext } : {}), ...(reviewerFamily ? { reviewerFamily } : {}), ...(reviewerReason ? { reviewerReason } : {}) };
3236
3701
  if (verdict === "approved") {
3237
3702
  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);
3238
3703
  if (next.owner) {
@@ -3248,9 +3713,12 @@ export class Tasks {
3248
3713
  this.releaseNotice(`task ${this.publicTitle(next)}: could not tell ${next.owner ?? "its owner"} it was approved`, () =>
3249
3714
  this.tell(next, `Task #${next.id} approved by ${by}.${note ? ` ${this.screen(next, note, "review note", next.owner, withheld)}` : ""}`, pii));
3250
3715
  await this.releaseDependents(next);
3716
+ // A proposal storage failure cannot roll back the saved verdict; another matching verdict retries it.
3717
+ this.releaseNotice(`task #${next.id}: review saved but recurring-finding proposal unavailable`, () => this.proposeRecurringFindings(next, by, parsed, withheld));
3251
3718
  return next;
3252
3719
  }
3253
3720
  const rejected = this.d.board.update(task.id, by, "changes_requested", { state: "changes_requested", rejections: task.rejections + 1 }, note, flag);
3721
+ this.releaseNotice(`task #${rejected.id}: review saved but recurring-finding proposal unavailable`, () => this.proposeRecurringFindings(rejected, by, parsed, withheld));
3254
3722
  if (candidate && !pii && !this.d.privateTurn?.(by) && this.candidateTextAllowed(JSON.stringify({ candidate, refs: rejected.refs, plan: rejected.plan }), rejected)) {
3255
3723
  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 */ }
3256
3724
  }