@staix/agent-hub 0.12.45 → 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 (99) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +83 -116
  3. package/docs/agent-notes/README.md +6 -0
  4. package/docs/agent-notes/adapters.md +10 -7
  5. package/docs/agent-notes/budget.md +6 -1
  6. package/docs/agent-notes/bus.md +6 -4
  7. package/docs/agent-notes/daemon.md +6 -1
  8. package/docs/agent-notes/local-worker.md +2 -1
  9. package/docs/agent-notes/tasks.md +10 -0
  10. package/docs/agent-notes/tests.md +4 -2
  11. package/docs/dogfood.md +92 -0
  12. package/docs/events.md +14 -1
  13. package/docs/operations.md +250 -19
  14. package/docs/profiles.md +2 -2
  15. package/docs/quickstart.md +8 -2
  16. package/docs/sdlc.md +36 -6
  17. package/docs/security.md +20 -11
  18. package/docs/smoke.md +9 -4
  19. package/docs/specs/2026-09-19-agent-hub-design.md +759 -8
  20. package/docs/verification/2026-10-19-reviewer-isolation.md +2 -0
  21. package/docs/verification/2026-10-22-code-facts.md +78 -0
  22. package/docs/verification/2026-10-22-codex-reviewer-isolation.md +47 -0
  23. package/docs/verification/2026-10-22-kimi-qwen-compat.md +79 -0
  24. package/docs/verification/2026-10-23-0.13.0.md +89 -0
  25. package/docs/verification/dogfood/2026-10-22.md +3109 -0
  26. package/docs/verified.json +14 -14
  27. package/package.json +1 -1
  28. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  29. package/plugins/agent-hub/server.js +613 -329
  30. package/src/adapters/agy/entry.ts +5 -4
  31. package/src/adapters/claude-channel.ts +20 -9
  32. package/src/adapters/codex-appserver.ts +18 -2
  33. package/src/adapters/codex-review-isolation.ts +12 -5
  34. package/src/adapters/compat.ts +140 -0
  35. package/src/adapters/definitions/builtin-metadata.ts +2 -1
  36. package/src/adapters/definitions/native-cli-doctor.ts +12 -5
  37. package/src/adapters/definitions/native-cli-entry-jcode.ts +9 -6
  38. package/src/adapters/definitions/native-cli-jcode-mcp.ts +9 -2
  39. package/src/adapters/definitions/native-cli-preview-common.ts +2 -0
  40. package/src/adapters/definitions/native-cli-preview-jcode.ts +8 -2
  41. package/src/adapters/definitions/native-cli-preview-qwen.ts +2 -1
  42. package/src/adapters/definitions/qwen-native.ts +7 -3
  43. package/src/adapters/definitions/qwen.ts +2 -1
  44. package/src/adapters/jcode.ts +72 -31
  45. package/src/adapters/kimi-review.ts +6 -3
  46. package/src/adapters/registry-types.ts +3 -1
  47. package/src/adapters/registry.ts +1 -0
  48. package/src/cli/agents.ts +40 -0
  49. package/src/cli/console-review.ts +54 -0
  50. package/src/cli/console-state.ts +124 -29
  51. package/src/cli/console-symbols.ts +74 -0
  52. package/src/cli/console-usage.ts +89 -0
  53. package/src/cli/console.ts +26 -1
  54. package/src/cli/help.ts +16 -6
  55. package/src/cli/identity.ts +29 -6
  56. package/src/cli/init.ts +109 -11
  57. package/src/cli/main.ts +122 -11
  58. package/src/cli/output.ts +44 -1
  59. package/src/cli/preview.ts +1 -1
  60. package/src/cli/sdlc-release-guard.ts +82 -0
  61. package/src/hub/action-class.ts +362 -0
  62. package/src/hub/action-policy.ts +106 -0
  63. package/src/hub/board.ts +43 -8
  64. package/src/hub/budget.ts +71 -2
  65. package/src/hub/bus.ts +43 -3
  66. package/src/hub/checks.ts +80 -31
  67. package/src/hub/code-facts.ts +223 -0
  68. package/src/hub/config-apply.ts +10 -1
  69. package/src/hub/config-trust.ts +7 -4
  70. package/src/hub/daemon.ts +237 -22
  71. package/src/hub/dogfood-read.ts +199 -0
  72. package/src/hub/dogfood.ts +163 -0
  73. package/src/hub/envelope.ts +1 -1
  74. package/src/hub/events.ts +11 -2
  75. package/src/hub/hub-tools.ts +24 -2
  76. package/src/hub/inbox-polling.ts +25 -0
  77. package/src/hub/instances.ts +73 -1
  78. package/src/hub/profiles.ts +7 -2
  79. package/src/hub/release-worktree.ts +56 -0
  80. package/src/hub/review-brief.ts +36 -0
  81. package/src/hub/sdlc-queue.ts +31 -9
  82. package/src/hub/sdlc-release-authorization.ts +173 -0
  83. package/src/hub/sdlc.ts +49 -11
  84. package/src/hub/settings.ts +5 -2
  85. package/src/hub/snapshots.ts +93 -8
  86. package/src/hub/symbol-normalizer.ts +26 -0
  87. package/src/hub/task-code-symbols.ts +90 -0
  88. package/src/hub/task-sweep.ts +3 -2
  89. package/src/hub/tasks.ts +292 -54
  90. package/src/hub/test-integrity.ts +162 -0
  91. package/src/hub/usage-rollup.ts +361 -0
  92. package/src/local/sandbox.ts +5 -3
  93. package/src/ui/index.html +1 -0
  94. package/templates/AGENTS.block.md +1 -1
  95. package/templates/profiles/dev.json +6 -1
  96. package/templates/profiles/general.json +6 -1
  97. package/templates/profiles/research.json +6 -1
  98. package/templates/profiles/writing.json +6 -1
  99. package/templates/release-guard.block.md +6 -0
package/src/hub/tasks.ts CHANGED
@@ -1,3 +1,6 @@
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";
1
4
  import { completePlanInventory, planConformance, planConformanceBlockers, planFingerprint, type PlanConformance } from "./plan-conformance.ts";
2
5
  import { RELEASE_TRAIN_HISTORY_LIMIT, releaseTrainData, releaseTrainReadiness, releaseMetrics } from "./sdlc-release.ts";
3
6
  import { cleanCostPlan, costMetrics, type CostPlan, type ReviewCost } from "./cost-governance.ts";
@@ -37,7 +40,7 @@ import { coverageGap, renderReviewBrief, type ReviewBrief } from "./review-brief
37
40
  import { definitionOf as builtinDefinitionOf } from "../adapters/registry.ts";
38
41
  import { ADAPTERS, DEFAULT_REVIEW_POOL, definitionOf, familyOf, type ProjectAdapterCatalog } from "../adapters/registry.ts";
39
42
  import { plannerGrooming, nextSweep, taskSweepConfig, type TaskSweepConfig, type SweepRecord } from "./task-sweep.ts";
40
- 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";
41
44
 
42
45
  export type TaskAssignmentRole = "owner" | "reviewer";
43
46
 
@@ -72,6 +75,7 @@ export interface TasksDeps {
72
75
  /** Explicit acceptance/self-claim only; implicit done has no pre-work baseline. */
73
76
  ownerAccepted?: (task: Task) => void;
74
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>;
75
79
  reviewBrief?: (task: Task) => ReviewBrief | { unavailable: string } | undefined;
76
80
  sdlc?: { config: () => SdlcConfig; gh?: SdlcGh };
77
81
  briefs?: Briefs;
@@ -176,6 +180,7 @@ const CONTRADICTION_WINDOW_MS = 7 * 86_400_000;
176
180
  const OPEN: Task["state"][] = ["proposed", "in_progress", "changes_requested"];
177
181
  /** Board events that leave a task where its completion check found it; any other event means it moved on meanwhile. */
178
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.
179
184
  QUIET_EVENTS.add("plan observed"); // Host observation does not move the task or replace completion intent.
180
185
 
181
186
  /**
@@ -277,6 +282,9 @@ const RESCREEN_MAX = 10;
277
282
  * The task flow. Adapters and tools never touch the board: every change comes through here, where assignment,
278
283
  * PII handling, envelopes, briefs and memory notes are decided in one place.
279
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
+
280
288
  export class Tasks {
281
289
  private readonly repositories: TaskRepositories;
282
290
  private readonly incomingBacklog = new Set<number>();
@@ -401,7 +409,7 @@ export class Tasks {
401
409
  } else {
402
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) });
403
411
  this.releaseNotice(`${text} Owner reassignment suggestion: ${admission.owner ?? "no eligible peer"}.`);
404
- if (admission.owner && config.auto_reassign && this.sweepAvailable(admission.owner)) {
412
+ if (admission.owner && config.auto_reassign && this.sweepAvailable(admission.owner) && !this.reservedHold(task)) {
405
413
  try { await this.assignOwner(this.d.board.get(task.id)!, HUB, { candidates: [admission.owner], event: "reassigned", reason: "idle", note: "ready-unassigned idle sweep" }); }
406
414
  catch (e) { if (!(e instanceof AssignmentUndeliveredError)) throw e; }
407
415
  }
@@ -582,9 +590,12 @@ export class Tasks {
582
590
  /** A task as a cloud peer may see it. */
583
591
 
584
592
  publicView(task: Task, fullHistory = false): Record<string, unknown> {
585
- const { title, detail, history, external, cancelled, ...taskRest } = task;
593
+ const { title, detail, history, external, cancelled, codeSymbols, ...taskRest } = task;
586
594
  const rest = { ...taskRest, ...(history.some(h => h.by === USER && h.planDeparture) ? { planDepartureAccepted: true } : {}) };
587
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
+ }
588
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}]`;
589
600
  const publicHistory = (fullHistory ? history : history.slice(-5)).map(h => ({
590
601
  at: h.at, by: h.by, event: h.event,
@@ -603,7 +614,9 @@ export class Tasks {
603
614
  ...(h.sweep ? { sweep: { kind: h.sweep.kind, activity: h.sweep.activity, step: h.sweep.step, at: h.sweep.at } } : {}),
604
615
  }));
605
616
  const sets = this.reviewFindingSets(task), current = findingsForHead(sets, this.findingHead(task));
606
- return { ...rest, ...(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 };
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 };
607
620
  }
608
621
 
609
622
  /** Console results expose direct approval and delivery holds, not just the chosen peer ids. */
@@ -819,7 +832,7 @@ export class Tasks {
819
832
  // were stored as written, so both sides are compared in one spelling.
820
833
  if (mine.repositoryKey !== theirs.repositoryKey) continue;
821
834
  const file = (p: string) => normPath(p) !== "." && theirs.paths.some((q) => normPath(q) === normPath(p));
822
- 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)));
823
836
  if (!same || this.d.board.reviews({ task: t.id }).some((r) => r.kind === "contradicted")) continue;
824
837
  this.d.board.recordReview({ implementer: t.owner, reviewer: approval.by, class: t.class, kind: "contradicted", task: t.id });
825
838
  }
@@ -1479,6 +1492,7 @@ export class Tasks {
1479
1492
  this.withdrawRoleRequest(confirmed, returned, "reviewer");
1480
1493
  this.incomingBacklog.add(confirmed.id);
1481
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)
1482
1496
  return returned;
1483
1497
  }
1484
1498
 
@@ -1606,6 +1620,18 @@ export class Tasks {
1606
1620
  /** The tasks an overlap text for `task` names: their plans are in it (issue #108). */
1607
1621
  overlapTasks = (task: Task): number[] => this.overlapHits(task).map((h) => h.task.id);
1608
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
+
1609
1635
  /**
1610
1636
  * Dashboard warning links (#264): the board's overlap pairs and the live cohorts, as task and peer ids only.
1611
1637
  * Paths, symbols and plans stay out; PII tasks are excluded by the same rules overlapHits applies.
@@ -1648,12 +1674,23 @@ export class Tasks {
1648
1674
  * ponytail: kept in memory, so after a restart such a notice is delivered whatever its task's state; persist the
1649
1675
  * condition if stale notices after restarts show up.
1650
1676
  */
1651
- 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"][] }>();
1652
1678
 
1653
1679
  /**
1654
1680
  * Publish a notice about `task`, which only matters to `to` while that task is in one of `states` for it (issue
1655
1681
  * #106): open work by default; the conflict notices of #91 count a task in review too.
1656
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
+
1657
1694
  whileOpen(to: PeerId, task: number, body: string, states: Task["state"][] = OPEN): void {
1658
1695
  const env = newEnvelope(HUB, body, { to: [to], kind: "task", refs: { task: String(task), taskRecipient: "owner" } });
1659
1696
  this.conditional.set(env.id, { peer: to, task, states });
@@ -1682,8 +1719,8 @@ export class Tasks {
1682
1719
  }
1683
1720
  const c = this.conditional.get(env.id);
1684
1721
  if (!c || c.peer !== peer) return true;
1685
- const t = this.d.board.get(c.task);
1686
- 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); });
1687
1724
  };
1688
1725
 
1689
1726
  /** Whether a model-written name may be shown to other peers and in the log: one matching a PII pattern may be PII. */
@@ -1719,7 +1756,12 @@ export class Tasks {
1719
1756
  private places(task: Task): { repositoryKey: string; paths: string[]; symbols: string[] } {
1720
1757
  const scope = this.repositories.scope(task.refs);
1721
1758
  const paths = [...(task.refs.paths ?? []), ...(task.plan?.paths ?? [])].map((p) => scope === this.repositories.project ? this.projectPath(p) : normPath(this.repositories.relativePath(scope, p)));
1722
- 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)] };
1723
1765
  }
1724
1766
 
1725
1767
  private roots?: string[];
@@ -1745,7 +1787,7 @@ export class Tasks {
1745
1787
  const theirs = this.places(t);
1746
1788
  if (mine.repositoryKey !== theirs.repositoryKey) return [];
1747
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)));
1748
- const symbols = mine.symbols.filter((x) => theirs.symbols.includes(x));
1790
+ const symbols = mine.symbols.filter((x) => theirs.symbols.some((y) => symbolsOverlap(x, y)));
1749
1791
  return paths.length || symbols.length ? [{ task: t, paths, symbols }] : [];
1750
1792
  });
1751
1793
  }
@@ -2095,13 +2137,49 @@ export class Tasks {
2095
2137
  if (waits.length) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id} waits for ${waits.map((id) => `#${id}`).join(", ")}, not approved yet`);
2096
2138
  }
2097
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
+
2098
2176
  /** An approved task may be the last thing others waited for: those go through assignment now (issue #34). */
2099
2177
  private async releaseDependents(approved: Task): Promise<void> {
2100
2178
  try {
2101
- for (const t of this.d.board.list("proposed")) {
2102
- if (!t.deps?.includes(approved.id) || t.owner || this.waitsFor(t).length) continue;
2103
- await this.offerReady(t, `#${approved.id} approved`);
2104
- }
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");
2105
2183
  } catch (e) {
2106
2184
  this.releaseNotice(`task #${approved.id}: could not release dependents: ${e instanceof Error ? e.message : String(e)}`);
2107
2185
  }
@@ -2223,14 +2301,25 @@ export class Tasks {
2223
2301
  this.offered.delete(task.id);
2224
2302
  this.releaseNotice(`task #${task.id}: cancellation stall cleanup unavailable`, () => this.d.board.clearStall(task.id));
2225
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)
2226
2305
  this.releaseNotice(`task #${task.id}: cancellation dependent advice unavailable`, () => {
2227
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" }));
2228
2307
  });
2229
2308
  return next;
2230
2309
  }
2231
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
+
2232
2321
  async releaseReady(): Promise<void> {
2233
- for (const t of this.d.board.list("proposed")) {
2322
+ for (const t of [...this.d.board.list("proposed")].sort(this.readyOrder)) {
2234
2323
  if (t.owner) { this.d.board.clearStall(t.id); continue; }
2235
2324
  const cancelledDependency = t.deps?.find(id => this.d.board.get(id)?.state === "cancelled");
2236
2325
  if (cancelledDependency) { this.reportStall(t, { kind: "cancelled-dependency", dependency: cancelledDependency, code: "cancelled" }); continue; }
@@ -2242,7 +2331,7 @@ export class Tasks {
2242
2331
  continue;
2243
2332
  }
2244
2333
  this.d.board.clearStall(t.id);
2245
- 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;
2246
2335
  // ponytail: a reserved owner (#207) that attaches after another peer that can take the task is passed over here like
2247
2336
  // an offline one; hold reserved tasks for a grace period if restarts show that up.
2248
2337
  await this.offerReady(t, "nothing left to wait for");
@@ -2251,13 +2340,18 @@ export class Tasks {
2251
2340
 
2252
2341
  /** Callers hold a list read before an await: re-read, or a task the other caller offered meanwhile is offered twice. */
2253
2342
  private async offerReady(stale: Task, why: string): Promise<void> {
2254
- let offering = false, undelivered: AssignmentUndeliveredError | undefined;
2343
+ let offering = false, undelivered: AssignmentUndeliveredError | undefined, slot: PeerId | undefined;
2255
2344
  try {
2256
2345
  const t = this.d.board.get(stale.id);
2257
2346
  if (!t || t.state !== "proposed" || t.owner || this.personUnassigned(t) || this.offered.has(t.id)) return;
2258
2347
  const planned = this.plannerPullRecord(t)?.pull === true;
2259
2348
  const selected = planned ? this.plannerAssignment(t, true).owner : undefined;
2260
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); }
2261
2355
  this.offered.add(t.id);
2262
2356
  offering = true;
2263
2357
  const ready = t.history.at(-1)?.event === "ready" ? t : this.d.board.update(t.id, HUB, "ready", {}, why);
@@ -2269,6 +2363,7 @@ export class Tasks {
2269
2363
  if (e instanceof AssignmentUndeliveredError) undelivered = e;
2270
2364
  else this.releaseNotice(`task ${this.publicTitle(stale)}: could not be assigned: ${e instanceof Error ? e.message : String(e)}`);
2271
2365
  } finally {
2366
+ if (slot) this.readyInFlight.delete(stale.id);
2272
2367
  if (offering && !undelivered) {
2273
2368
  try {
2274
2369
  if (!this.d.board.get(stale.id)?.owner) this.offered.delete(stale.id);
@@ -2284,6 +2379,48 @@ export class Tasks {
2284
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`);
2285
2380
  }
2286
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
+
2287
2424
  private planIssueIdentity(task: Task): { repo: string; issue: number } | undefined {
2288
2425
  const source = task.external ?? task.sdlc;
2289
2426
  if (source?.repo && source.issue) return { repo: source.repo, issue: source.issue };
@@ -2336,6 +2473,16 @@ export class Tasks {
2336
2473
  const digest = plannerGrooming(visible, { repository: this.d.repository ?? `path:${realPath(this.d.cwd)}`,
2337
2474
  limits: Object.fromEntries(peers.map(peer => [peer, this.wipLimit(peer)])), wip: Object.fromEntries(peers.map(peer => [peer, this.wip(peer)])),
2338
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
+ }
2339
2486
  return { ...digest, truncated: digest.truncated || incomplete, ...(current.some(task => this.isPii(task)) ? { privateTasks: "withheld" } : {}) };
2340
2487
  }
2341
2488
 
@@ -2432,8 +2579,9 @@ export class Tasks {
2432
2579
  const current = this.need(task.id, false, true, true);
2433
2580
  if (current.history.length !== flow || (current.planningRevision ?? 0) !== revision) throw new PreEffectToolRefusal("plan review changed during readback");
2434
2581
  const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
2435
- 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"),
2436
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
2437
2585
  if (updated.state === "proposed" && !updated.owner) {
2438
2586
  this.offered.delete(updated.id);
2439
2587
  try { await this.offerReady(updated, "planner review recorded"); }
@@ -2448,7 +2596,11 @@ export class Tasks {
2448
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");
2449
2597
  const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
2450
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");
2451
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");
2452
2604
  // #481 S1: entering a build stage in enforce needs the person's spec acceptance; the person may move any stage.
2453
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"));
2454
2606
  if (config.mode === "enforce" && config.definitions && input && typeof input === "object" && "stage" in input && next.stage !== task.sdlc?.stage && by !== USER) {
@@ -2476,7 +2628,7 @@ export class Tasks {
2476
2628
  const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
2477
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 })));
2478
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) } }));
2479
- const report = await readMergeQueue(repo, config, links, this.d.sdlc?.gh);
2631
+ const report = await readMergeQueue(repo, config, links, this.d.sdlc?.gh, this.publicMetadataText);
2480
2632
  // Policy can change across remote awaits without any board write. Never return its stale public projection.
2481
2633
  const currentPrivacy = this.d.board.list().map(task => ({ id: task.id, private: this.isPii(task) }));
2482
2634
  if (workflowHash(links.map(({ id, private: hidden }) => ({ id, private: hidden }))) !== workflowHash(currentPrivacy)) {
@@ -2492,6 +2644,13 @@ export class Tasks {
2492
2644
  this.capturePlanHead(task, row.head);
2493
2645
  }
2494
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
+ }
2495
2654
  return report;
2496
2655
  }
2497
2656
  async releaseReadiness(repo: string, liveIssues: number[]) {
@@ -2621,16 +2780,18 @@ export class Tasks {
2621
2780
  async lifecycleStatus(id: unknown, refresh = true): Promise<SdlcStatus> {
2622
2781
  const task = this.need(id), config = this.d.sdlc?.config() ?? sdlcConfig(undefined), identity = this.planIdentity(task);
2623
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; };
2624
- const read: SdlcGh = (args, timeout, cwd, cap) => { valid(); return (this.d.sdlc?.gh ?? sdlcGh)(args, timeout, cwd, cap); };
2625
- const evidence = task.sdlc && !this.isPii(task) && refresh && config.mode !== "off" ? await readSdlcPr(task.sdlc, read) : undefined;
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;
2626
2786
  valid();
2627
2787
  const accepted = task.sdlc?.acceptance;
2628
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;
2629
2790
  const current = valid();
2630
2791
  if (this.planIdentity(current) !== this.planIdentity(task) || current.history.length !== task.history.length) throw new PreEffectToolRefusal("task changed during lifecycle readback");
2631
2792
  this.capturePlanRead(current, evidence);
2632
2793
  return sdlcStatus(config, current.sdlc, evidence, this.workflowView(current), edit, this.isPii(current) ? [] : this.reviewFindingSets(current), current.refs.commit,
2633
- config.mode !== "off" ? this.planReport(this.d.board.get(current.id)!, evidence) : undefined);
2794
+ config.mode !== "off" ? this.planReport(this.d.board.get(current.id)!, evidence) : undefined, integrity);
2634
2795
  }
2635
2796
 
2636
2797
  /** #481 S1: only build work starts from an accepted spec. */
@@ -2696,26 +2857,33 @@ export class Tasks {
2696
2857
  this.d.notify(this.specText(task, state, "advice"));
2697
2858
  }
2698
2859
 
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
+
2699
2864
  private async lifecycleGate(task: Task, transition: "review" | "approval", reviewedHead?: string, findings?: readonly ReviewFindingSet[]): Promise<PrEvidence | undefined> {
2700
2865
  const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
2701
2866
  if (config.mode === "off") return;
2702
2867
  const receipt = task.sdlc && { ...task.sdlc, ...(reviewedHead ? { reviewedHead } : {}) };
2868
+ const deadline = performance.now() + 20_000;
2703
2869
  const read: SdlcGh = (args, timeout, cwd, cap) => {
2704
2870
  const current = this.need(task.id, false, true);
2705
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: "" });
2706
- 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: "" });
2707
2874
  };
2708
- 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);
2709
2877
  const current = this.need(task.id, false, true);
2710
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`);
2711
2879
  // Advise may continue on the current task, but evidence from its previous link cannot admit that task's review.
2712
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;
2713
2881
  this.capturePlanRead(current, evidence);
2714
2882
  const planReport = this.planReport(current, evidence);
2715
- const status = sdlcStatus(config, receipt, evidence, this.workflowView(current), undefined, this.isPii(current) ? [] : findings ?? this.reviewFindingSets(current), current.refs.commit, planReport);
2716
- 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;
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;
2717
2885
  if (config.mode === "enforce" && blockers.length) throw new PreEffectToolRefusal(`task #${task.id}: SDLC ${transition} blocked: ${blockers.join("; ")}`);
2718
- const advice = [...new Set([...blockers, ...planConformanceBlockers(planReport)])];
2886
+ const advice = [...new Set([...blockers, ...planConformanceBlockers(planReport), ...testIntegrityBlockers(integrity)])];
2719
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("; ")}`);
2720
2888
  return evidence;
2721
2889
  }
@@ -2946,10 +3114,14 @@ export class Tasks {
2946
3114
  await this.planEntryGate(by, admission);
2947
3115
  task = valid();
2948
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();
2949
3121
  let reviewEvidence: PrEvidence | undefined;
2950
- if (this.d.sdlc && this.d.sdlc.config().mode !== "off") { reviewEvidence = await this.lifecycleGate(task, "review"); task = valid(); }
2951
- const reviewAdmission = task.reviewer ? await this.reviewRequestAdmission(task, refs, true, by, reviewEvidence) : undefined;
2952
- 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();
2953
3125
  // A member of a silent cohort (issue #107): its done is an intent, and the last of them integrates first.
2954
3126
  const cohort = this.cohorts.of(task.id);
2955
3127
  const silentMember = !!cohort?.silent && this.turnFree() && !!task.owner && !this.isPii(task);
@@ -2969,8 +3141,8 @@ export class Tasks {
2969
3141
  if (r.action === "request") return this.d.board.update(task.id, HUB, "integration requested", {}, this.integrationRequest(task, r));
2970
3142
  if (r.action === "unresolved") {
2971
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`);
2972
- task = this.d.board.update(task.id, HUB, "integration unresolved", {}, r.why);
2973
- } 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);
2974
3146
  } else if (cohort?.held.size && !this.isPii(task)) {
2975
3147
  // No integration step for this member (silence lifted, a PII task open): the notices the silence held stand in
2976
3148
  // for it (issue #107, AC4).
@@ -2978,14 +3150,14 @@ export class Tasks {
2978
3150
  if (evidence && by === USER) this.d.notify(`task ${this.publicTitle(task)} done by the console; overlapping work finished meanwhile:\n${evidence}`);
2979
3151
  else if (evidence) this.doneNotes.set(task.id, `Overlapping work finished while you worked (no turn-free integration step ran):\n${evidence}`);
2980
3152
  }
2981
- 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
2982
3154
  const command = this.d.runCheck ? this.d.check?.(task.class, task) : undefined;
2983
- 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);
2984
3156
  // The tool call returns now; a check can outlast an agent's tool timeout. The result decides what comes next.
2985
3157
  this.checking.set(task.id, task.owner);
2986
3158
  const checkedRefs = cleanRefs(refs);
2987
3159
  this.warmRepository({ ...task, refs: { ...task.refs, ...checkedRefs } });
2988
- 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);
2989
3161
  this.releaseNotice(`task ${this.publicTitle(pending)} done by ${by}; its check is queued or running: ${command}`);
2990
3162
  const seen = { events: pending.history.length, owner: pending.owner };
2991
3163
  this.pendingChecks++;
@@ -3033,7 +3205,7 @@ export class Tasks {
3033
3205
  const theirs = this.places(u);
3034
3206
  if (mine.repositoryKey !== theirs.repositoryKey) return [];
3035
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)));
3036
- const symbols = mine.symbols.filter((x) => theirs.symbols.includes(x));
3208
+ const symbols = mine.symbols.filter((x) => theirs.symbols.some((y) => symbolsOverlap(x, y)));
3037
3209
  return paths.length || symbols.length ? [this.completedNotice(u, { task: t, paths, symbols }, at.note, at.withheld)] : [];
3038
3210
  });
3039
3211
  if (!open.length && !done.length) continue;
@@ -3116,19 +3288,19 @@ export class Tasks {
3116
3288
  }
3117
3289
 
3118
3290
  /** `gated`: the transition `done()` already gated with no await since, so it is not read back twice (#382). */
3119
- 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> {
3120
3292
  let selection = this.poolReview(task, refs);
3121
3293
  let reviewer = selection?.reviewer ?? task.reviewer, reviewEvidence: PrEvidence | undefined;
3122
3294
  if (this.d.sdlc && this.d.sdlc.config().mode !== "off" && gated !== (reviewer ? "review" : "approval")) {
3123
3295
  reviewEvidence = await this.lifecycleGate(task, reviewer ? "review" : "approval");
3124
- task = this.completionCurrent(task.id, by, false);
3296
+ task = this.withLink(this.completionCurrent(task.id, by, false), link, refs);
3125
3297
  selection = this.poolReview(task, refs);
3126
3298
  reviewer = selection?.reviewer ?? task.reviewer;
3127
3299
  }
3128
- // A delayed local check needs a new readback; the immediate done path already admitted before effects.
3129
- if (reviewer && !admission && (this.d.sdlc?.config().mode ?? "off") !== "off") {
3130
- admission = await this.reviewRequestAdmission(task, refs, false, by, reviewEvidence);
3131
- task = this.completionCurrent(task.id, by, false);
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);
3132
3304
  }
3133
3305
  selection = this.poolReview(task, refs);
3134
3306
  reviewer = selection?.reviewer ?? task.reviewer;
@@ -3138,7 +3310,7 @@ export class Tasks {
3138
3310
  const completedRefs = cleanRefs(refs);
3139
3311
  this.warmRepository({ ...task, refs: { ...task.refs, ...completedRefs } });
3140
3312
  const noWrites = this.noWritesRecorded({ ...task, refs: { ...task.refs, ...completedRefs } });
3141
- 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, { ...this.planObservation(task), 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);
3142
3314
  this.cohorts.closed(next.id); // its owner's next native turn end settles it in its cohort (issue #107)
3143
3315
  this.note(next, by, "finding", `Task #${next.id} done by ${by}: ${next.title}\n${summary ?? ""}`, withheld);
3144
3316
  this.tellCompleted(next, summary, withheld);
@@ -3148,7 +3320,7 @@ export class Tasks {
3148
3320
  this.releaseNotice(`task ${this.publicTitle(next)} done by ${by}, no reviewer: approved`);
3149
3321
  await this.releaseDependents(next);
3150
3322
  }
3151
- 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]`);
3152
3324
  else this.releaseNotice(`task ${this.publicTitle(next)}: could not send the review request to ${reviewer}`, () => this.sendReview(next, reviewer, "", admission));
3153
3325
  return next;
3154
3326
  }
@@ -3162,6 +3334,9 @@ export class Tasks {
3162
3334
  const hits = this.overlapHits(task).filter((h) => h.task.owner !== USER && h.task.owner !== HUB);
3163
3335
  const cohort = this.cohorts.of(task.id);
3164
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>();
3165
3340
  for (const hit of hits) {
3166
3341
  // A member of the same silent cohort (issue #107): the member that finishes last integrates instead
3167
3342
  // (Cohorts.completion). The notice is held, so something replaces it if no integration runs. Others hear it.
@@ -3169,23 +3344,42 @@ export class Tasks {
3169
3344
  cohort.held.add(task.id);
3170
3345
  continue;
3171
3346
  }
3172
- this.releaseNotice(`task ${this.publicTitle(task)}: could not publish the completed-change notice to ${hit.task.owner}`, () =>
3173
- 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]);
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)));
3174
3354
  }
3175
3355
  }
3176
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);
3364
+ }
3365
+
3177
3366
  /**
3178
3367
  * The completed-change notice: files, signatures and the summary are the owner's own words (paths given at done
3179
3368
  * included), and any item that matches a PII pattern is left out of what other owners get, the summary also when the
3180
3369
  * PII screen did not clear it (`withheld`, #198).
3181
3370
  */
3182
- 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];
3183
3373
  const paths = this.places(task).paths.filter(this.nameable);
3184
3374
  const signatures = (task.plan?.signatures ?? []).filter(this.nameable);
3185
3375
  const first = (summary ?? "").split("\n").find((l) => l.trim())?.trim().slice(0, 300);
3186
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
+
3187
3381
  return [
3188
- `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.`,
3189
3383
  paths.length ? `Changed files: ${paths.join(", ")}` : "",
3190
3384
  signatures.length ? `New or changed signatures: ${signatures.join("; ")}` : "",
3191
3385
  line ? `Summary: ${line}` : "",
@@ -3226,16 +3420,17 @@ export class Tasks {
3226
3420
 
3227
3421
  /** The one place a review request is written: the first reviewer and a replacement get the same text, refs and privacy. */
3228
3422
  /** Review admission is separate from brief construction (#228). All refusals precede the requesting done's writes. */
3229
- 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> {
3230
3424
  const mode = (this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode;
3231
- if (mode === "off") return undefined;
3425
+ if (mode === "off" && !this.d.prepareReviewBrief) return undefined;
3232
3426
  let quietDrift = false;
3233
3427
  const guard = () => {
3234
3428
  if (actor !== USER && actor !== task.owner) {
3235
3429
  const holders = Object.entries(this.d.roles ?? {}).filter(([, roles]) => Array.isArray(roles) && roles.includes("conductor")).map(([peer]) => peer);
3236
3430
  if (holders.length !== 1 || holders[0] !== actor) throw new (preEffect ? PreEffectToolRefusal : Error)("review admission authority changed");
3237
3431
  }
3238
- const current = this.need(task.id, false, preEffect);
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));
3239
3434
  const changed = current.history.length !== task.history.length;
3240
3435
  const quiet = current.history.length > task.history.length && current.history.slice(task.history.length).every(entry => QUIET_EVENTS.has(entry.event));
3241
3436
  const sameHistory = !changed || (mode === "advise" && quiet) || !!sameRequest?.();
@@ -3247,6 +3442,12 @@ export class Tasks {
3247
3442
  return current;
3248
3443
  };
3249
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;
3250
3451
  const expected = cleanRefs(refs).commit ?? task.refs.commit ?? task.sdlc?.head ?? task.sdlc?.localHead;
3251
3452
  const read: SdlcGh = (args, timeout, cwd, cap) => {
3252
3453
  guard();
@@ -3283,12 +3484,26 @@ export class Tasks {
3283
3484
  const cachedPlanRead = this.planReads.get(task.id);
3284
3485
  const planRead = this.planReport(task, cachedPlanRead?.identity === this.planReadIdentity(task) && cachedPlanRead.evidence.head === this.findingHead(task) ? cachedPlanRead.evidence : undefined);
3285
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
+ );
3286
3503
  const checklist = [
3287
3504
  "Checklist:",
3288
3505
  ...this.planGuardItems(task).map(item => `- Check the plan against this live fail before the remaining checks: ${item.line}`),
3289
- `- 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.`,
3290
- `- ${check ? `Check result: ${(check.note ?? "").split("\n")[0]}` : "No check ran for this class: run the affected tests yourself."}`,
3291
- `- List what is unmet in hub_review's unmet, one item each.`,
3506
+ ...checklistLines.map(line => `- ${line}`),
3292
3507
  ].join("\n");
3293
3508
  // #223 R2: a reviewer-only instance judges from facts; the builder's narrative stays out, earlier findings go in.
3294
3509
  const reviewOnly = this.d.reviewOnly?.(reviewer) === true;
@@ -3345,6 +3560,29 @@ export class Tasks {
3345
3560
  return next;
3346
3561
  }
3347
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
+
3348
3586
  /** #520: the rendered review brief of a non-PII task's commit, or why there is none; empty without a commit. */
3349
3587
  briefText(task: Task): string {
3350
3588
  if (this.isPii(task) || !task.refs.commit) return "";