@staix/agent-hub 0.12.45 → 0.14.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 (108) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +85 -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 +7 -1
  6. package/docs/agent-notes/bus.md +6 -4
  7. package/docs/agent-notes/daemon.md +14 -2
  8. package/docs/agent-notes/local-worker.md +2 -1
  9. package/docs/agent-notes/tasks.md +16 -3
  10. package/docs/agent-notes/tests.md +5 -3
  11. package/docs/dogfood.md +95 -0
  12. package/docs/events.md +14 -1
  13. package/docs/operations.md +290 -27
  14. package/docs/profiles.md +2 -2
  15. package/docs/quickstart.md +13 -3
  16. package/docs/sdlc.md +59 -8
  17. package/docs/security.md +26 -13
  18. package/docs/smoke.md +9 -4
  19. package/docs/specs/2026-09-19-agent-hub-design.md +797 -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 +61 -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/2026-10-23-0.14.0.md +69 -0
  26. package/docs/verification/dogfood/2026-10-22.md +3109 -0
  27. package/docs/verified.json +22 -17
  28. package/package.json +1 -1
  29. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  30. package/plugins/agent-hub/server.js +702 -347
  31. package/src/adapters/agy/entry.ts +5 -4
  32. package/src/adapters/claude-channel.ts +20 -9
  33. package/src/adapters/codex-appserver.ts +18 -2
  34. package/src/adapters/codex-review-isolation.ts +29 -11
  35. package/src/adapters/compat.ts +140 -0
  36. package/src/adapters/definitions/builtin-metadata.ts +3 -2
  37. package/src/adapters/definitions/native-cli-doctor.ts +12 -5
  38. package/src/adapters/definitions/native-cli-entry-codex.ts +13 -0
  39. package/src/adapters/definitions/native-cli-entry-jcode.ts +9 -6
  40. package/src/adapters/definitions/native-cli-jcode-mcp.ts +9 -2
  41. package/src/adapters/definitions/native-cli-preview-common.ts +2 -0
  42. package/src/adapters/definitions/native-cli-preview-jcode.ts +8 -2
  43. package/src/adapters/definitions/native-cli-preview-qwen.ts +2 -1
  44. package/src/adapters/definitions/qwen-native.ts +7 -3
  45. package/src/adapters/definitions/qwen.ts +2 -1
  46. package/src/adapters/jcode.ts +72 -31
  47. package/src/adapters/kimi-review.ts +6 -3
  48. package/src/adapters/registry-types.ts +3 -1
  49. package/src/adapters/registry.ts +1 -0
  50. package/src/adapters/reviewer-print.ts +34 -7
  51. package/src/cli/agents.ts +40 -0
  52. package/src/cli/console-review.ts +54 -0
  53. package/src/cli/console-state.ts +124 -29
  54. package/src/cli/console-symbols.ts +74 -0
  55. package/src/cli/console-usage.ts +89 -0
  56. package/src/cli/console.ts +26 -1
  57. package/src/cli/help.ts +17 -6
  58. package/src/cli/identity.ts +29 -6
  59. package/src/cli/init.ts +109 -11
  60. package/src/cli/main.ts +127 -11
  61. package/src/cli/output.ts +91 -3
  62. package/src/cli/preview.ts +1 -1
  63. package/src/cli/sdlc-release-guard.ts +82 -0
  64. package/src/cli/status-lines.ts +1 -1
  65. package/src/hub/action-class.ts +362 -0
  66. package/src/hub/action-policy.ts +106 -0
  67. package/src/hub/board.ts +65 -9
  68. package/src/hub/budget.ts +71 -2
  69. package/src/hub/bus.ts +43 -3
  70. package/src/hub/checks.ts +80 -31
  71. package/src/hub/code-facts.ts +223 -0
  72. package/src/hub/config-apply.ts +10 -1
  73. package/src/hub/config-trust.ts +7 -4
  74. package/src/hub/daemon.ts +319 -34
  75. package/src/hub/dogfood-read.ts +199 -0
  76. package/src/hub/dogfood.ts +163 -0
  77. package/src/hub/envelope.ts +1 -1
  78. package/src/hub/events.ts +11 -2
  79. package/src/hub/hub-tools.ts +26 -3
  80. package/src/hub/inbox-polling.ts +25 -0
  81. package/src/hub/instances.ts +87 -5
  82. package/src/hub/manager.ts +2 -2
  83. package/src/hub/profiles.ts +7 -2
  84. package/src/hub/release-worktree.ts +56 -0
  85. package/src/hub/review-brief.ts +36 -0
  86. package/src/hub/sdlc-metrics.ts +260 -0
  87. package/src/hub/sdlc-queue.ts +31 -9
  88. package/src/hub/sdlc-release-authorization.ts +173 -0
  89. package/src/hub/sdlc.ts +54 -14
  90. package/src/hub/settings.ts +5 -2
  91. package/src/hub/snapshots.ts +93 -8
  92. package/src/hub/spec-read.ts +31 -0
  93. package/src/hub/symbol-normalizer.ts +26 -0
  94. package/src/hub/task-code-group.ts +52 -0
  95. package/src/hub/task-code-symbols.ts +90 -0
  96. package/src/hub/task-sweep.ts +3 -2
  97. package/src/hub/tasks.ts +455 -67
  98. package/src/hub/test-integrity.ts +162 -0
  99. package/src/hub/ui.ts +3 -0
  100. package/src/hub/usage-rollup.ts +419 -0
  101. package/src/local/sandbox.ts +5 -3
  102. package/src/ui/index.html +274 -2
  103. package/templates/AGENTS.block.md +1 -1
  104. package/templates/profiles/dev.json +6 -1
  105. package/templates/profiles/general.json +6 -1
  106. package/templates/profiles/research.json +6 -1
  107. package/templates/profiles/writing.json +6 -1
  108. package/templates/release-guard.block.md +6 -0
package/src/hub/tasks.ts CHANGED
@@ -1,7 +1,11 @@
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";
4
- import { specRefs } from "./spec-read.ts";
7
+ import { qualifiedTitleRepos, siblingTitleRefs, specRefs } from "./spec-read.ts";
8
+ import { sdlcMetrics, type BandResult, type MetricTask, type MetricsReport } from "./sdlc-metrics.ts";
5
9
  import type { InstancesConfig } from "./instances.ts";
6
10
  import { composeRememberText } from "./memory-fail.ts";
7
11
  import { VERSION } from "../version.ts";
@@ -37,7 +41,7 @@ import { coverageGap, renderReviewBrief, type ReviewBrief } from "./review-brief
37
41
  import { definitionOf as builtinDefinitionOf } from "../adapters/registry.ts";
38
42
  import { ADAPTERS, DEFAULT_REVIEW_POOL, definitionOf, familyOf, type ProjectAdapterCatalog } from "../adapters/registry.ts";
39
43
  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";
44
+ 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
45
 
42
46
  export type TaskAssignmentRole = "owner" | "reviewer";
43
47
 
@@ -72,6 +76,7 @@ export interface TasksDeps {
72
76
  /** Explicit acceptance/self-claim only; implicit done has no pre-work baseline. */
73
77
  ownerAccepted?: (task: Task) => void;
74
78
  /** #520: the review brief for a task's commit (git only, bounded); undefined when the task names no commit. */
79
+ prepareReviewBrief?: (task: Task) => Promise<void>;
75
80
  reviewBrief?: (task: Task) => ReviewBrief | { unavailable: string } | undefined;
76
81
  sdlc?: { config: () => SdlcConfig; gh?: SdlcGh };
77
82
  briefs?: Briefs;
@@ -176,6 +181,7 @@ const CONTRADICTION_WINDOW_MS = 7 * 86_400_000;
176
181
  const OPEN: Task["state"][] = ["proposed", "in_progress", "changes_requested"];
177
182
  /** Board events that leave a task where its completion check found it; any other event means it moved on meanwhile. */
178
183
  const QUIET_EVENTS = new Set(["answer", "reviewer changed", "idle sweep", "memory reference started"]);
184
+ QUIET_EVENTS.add("ready held"); // #655: the hub's note that a ready task waits for its reserved owner's WIP slot.
179
185
  QUIET_EVENTS.add("plan observed"); // Host observation does not move the task or replace completion intent.
180
186
 
181
187
  /**
@@ -277,6 +283,9 @@ const RESCREEN_MAX = 10;
277
283
  * The task flow. Adapters and tools never touch the board: every change comes through here, where assignment,
278
284
  * PII handling, envelopes, briefs and memory notes are decided in one place.
279
285
  */
286
+ /** #694: a PR staged at done with the commit or branch it was read for. */
287
+ interface IdentityLink { repo: string; pr: number; basis: { commit?: string; branch?: string } }
288
+
280
289
  export class Tasks {
281
290
  private readonly repositories: TaskRepositories;
282
291
  private readonly incomingBacklog = new Set<number>();
@@ -401,7 +410,7 @@ export class Tasks {
401
410
  } else {
402
411
  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
412
  this.releaseNotice(`${text} Owner reassignment suggestion: ${admission.owner ?? "no eligible peer"}.`);
404
- if (admission.owner && config.auto_reassign && this.sweepAvailable(admission.owner)) {
413
+ if (admission.owner && config.auto_reassign && this.sweepAvailable(admission.owner) && !this.reservedHold(task)) {
405
414
  try { await this.assignOwner(this.d.board.get(task.id)!, HUB, { candidates: [admission.owner], event: "reassigned", reason: "idle", note: "ready-unassigned idle sweep" }); }
406
415
  catch (e) { if (!(e instanceof AssignmentUndeliveredError)) throw e; }
407
416
  }
@@ -582,9 +591,12 @@ export class Tasks {
582
591
  /** A task as a cloud peer may see it. */
583
592
 
584
593
  publicView(task: Task, fullHistory = false): Record<string, unknown> {
585
- const { title, detail, history, external, cancelled, ...taskRest } = task;
594
+ const { title, detail, history, external, cancelled, codeSymbols, ...taskRest } = task;
586
595
  const rest = { ...taskRest, ...(history.some(h => h.by === USER && h.planDeparture) ? { planDepartureAccepted: true } : {}) };
587
596
  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]" };
597
+ if (rest.sdlc?.redFirst && !this.publicMetadataText(JSON.stringify(rest.sdlc.redFirst))) {
598
+ const { redFirst: _withheld, ...receipt } = rest.sdlc; rest.sdlc = receipt;
599
+ }
588
600
  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
601
  const publicHistory = (fullHistory ? history : history.slice(-5)).map(h => ({
590
602
  at: h.at, by: h.by, event: h.event,
@@ -603,7 +615,9 @@ export class Tasks {
603
615
  ...(h.sweep ? { sweep: { kind: h.sweep.kind, activity: h.sweep.activity, step: h.sweep.step, at: h.sweep.at } } : {}),
604
616
  }));
605
617
  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 };
618
+ const symbolRepository = codeSymbols && this.codeSymbolsRepository(task);
619
+ const publicSymbols = codeSymbols && symbolRepository ? publicTaskCodeSymbols(codeSymbols, this.reviewHead(task), symbolRepository.key, this.publicMetadataText) : undefined;
620
+ 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
621
  }
608
622
 
609
623
  /** Console results expose direct approval and delivery holds, not just the chosen peer ids. */
@@ -819,7 +833,7 @@ export class Tasks {
819
833
  // were stored as written, so both sides are compared in one spelling.
820
834
  if (mine.repositoryKey !== theirs.repositoryKey) continue;
821
835
  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));
836
+ const same = mine.paths.some(file) || mine.symbols.some((x) => theirs.symbols.some((y) => symbolsOverlap(x, y)));
823
837
  if (!same || this.d.board.reviews({ task: t.id }).some((r) => r.kind === "contradicted")) continue;
824
838
  this.d.board.recordReview({ implementer: t.owner, reviewer: approval.by, class: t.class, kind: "contradicted", task: t.id });
825
839
  }
@@ -879,11 +893,11 @@ export class Tasks {
879
893
  }
880
894
  return parts.length ? `no peer can take the planner pull: ${parts.join("; ")}` : "no peer has eligible planner WIP capacity";
881
895
  }
882
- private plannerAssignment(task: Task, reservedOnly = false): Assignment {
896
+ private plannerAssignment(task: Task, reservedOnly = false, routing: Routing = this.d.routing()): Assignment {
883
897
  const states = this.states();
884
898
  const full = this.plannerFull(task);
885
899
  // Preview the To Do transition without routing or offering the persisted backlog row.
886
- return assign({ ...task, state: "proposed" }, states, this.d.routing(), { exclude: this.excluded(task), excludeOwners: full, ...(reservedOnly && task.reserved && !this.excluded(task).includes(task.reserved) ? { candidates: [task.reserved], namedOwner: task.reserved } : {}), waitsFor: this.waitsFor(task), ...this.weights(task.class, task) });
900
+ return assign({ ...task, state: "proposed" }, states, routing, { exclude: this.excluded(task), excludeOwners: full, ...(reservedOnly && task.reserved ? { candidates: [task.reserved], namedOwner: task.reserved } : {}), waitsFor: this.waitsFor(task), ...this.weights(task.class, task) });
887
901
  }
888
902
 
889
903
  async pullPlanned(by: PeerId, input: { id: number; revision: number; reason: string }): Promise<Task> {
@@ -908,14 +922,25 @@ export class Tasks {
908
922
  return !!competing && (competing.rank < order.rank || competing.rank === order.rank && competing.id < order.id);
909
923
  })) throw new PreEffectToolRefusal("a person's P0 backlog order is retained");
910
924
  }
911
- const assignment = this.plannerAssignment(task);
912
- if (!assignment.owner) throw new PreEffectToolRefusal(this.plannerRefusal(task));
913
- if (this.hasPersonReservation(task) && assignment.owner !== task.reserved) throw new PreEffectToolRefusal("a person's reservation is retained");
925
+ if (task.reserved && this.excluded(task).includes(task.reserved)) throw new PreEffectToolRefusal(this.plannerRefusal(task));
926
+ const assignment = this.plannerAssignment(task, !!task.reserved);
927
+ if (!assignment.owner) {
928
+ const refusal = task.reserved ? `reserved peer ${task.reserved} cannot take the planner pull: ${assignment.refusals?.find(row => row.role === "owner" && row.peer === task.reserved)?.reason ?? "not eligible"}; ${this.plannerRefusal(task)}` : this.plannerRefusal(task);
929
+ throw new PreEffectToolRefusal(`${this.hasPersonReservation(task) ? "a person's reservation is retained; " : ""}${refusal}`);
930
+ }
914
931
  return { task, owner: assignment.owner };
915
932
  };
916
933
  const initial = validate(), withheld = await this.withholds("note", input.reason, initial.task);
917
934
  authorized();
918
935
  const current = validate();
936
+ if (current.task.reserved) {
937
+ return this.assignOwner(current.task, by, { candidates: [current.owner], namedOwner: current.owner, planner: true, refuseNamed: "pre",
938
+ plannerPull: { revision: input.revision, note: input.reason.trim(), withheld, guard: () => {
939
+ authorized();
940
+ const checked = validate();
941
+ if (checked.task.history.length !== current.task.history.length || checked.owner !== current.owner) throw new PreEffectToolRefusal("planner pull changed during admission");
942
+ } } });
943
+ }
919
944
  const pulled = this.d.board.pullPlanned(current.task.id, by, current.owner, input.revision, input.reason.trim(), withheld);
920
945
  this.planningRecorded({ by, task: pulled.id, fields: ["pull", "reserved"], revision: pulled.planningRevision! });
921
946
  return pulled;
@@ -1430,6 +1455,7 @@ export class Tasks {
1430
1455
  if (this.d.coordination) this.coordinationModes.set(task.id, this.d.coordination());
1431
1456
  created?.();
1432
1457
  if (screening) task = this.screenedTask(task, screened);
1458
+ task = this.linkOnWrite(task); // #723
1433
1459
  if (goal) this.linkNative(task, goal, by);
1434
1460
  if (triaged) task = this.d.board.update(task.id, "hub", "triaged", {}, `class ${cls} named by the hub's model`);
1435
1461
  if (defaulted) task = this.d.board.update(task.id, "hub", "class defaulted", {}, "class implement for a claim without one");
@@ -1479,6 +1505,7 @@ export class Tasks {
1479
1505
  this.withdrawRoleRequest(confirmed, returned, "reviewer");
1480
1506
  this.incomingBacklog.add(confirmed.id);
1481
1507
  this.planningRecorded({ by, task: confirmed.id, fields: ["return"], revision: confirmed.planningRevision ?? 0 });
1508
+ await this.releaseHeld(confirmed.owner); // its owner's slot is free (#655)
1482
1509
  return returned;
1483
1510
  }
1484
1511
 
@@ -1606,6 +1633,18 @@ export class Tasks {
1606
1633
  /** The tasks an overlap text for `task` names: their plans are in it (issue #108). */
1607
1634
  overlapTasks = (task: Task): number[] => this.overlapHits(task).map((h) => h.task.id);
1608
1635
 
1636
+ /** Console detail advice only: no pair persistence, and no private task on either side. */
1637
+ symbolOverlaps(task: Task): { task: number; files: string[]; sameFileDifferentSymbols: boolean }[] {
1638
+ // A routing-locality opt-out does not make symbol metadata for a marked task public.
1639
+ const markedPrivate = (value: Task) => value.signals.includes("pii") || value.signals.includes("pii_path");
1640
+ if (markedPrivate(task)) return [];
1641
+ return this.overlapHits(task).filter(hit => !markedPrivate(hit.task)).map(hit => ({
1642
+ task: hit.task.id,
1643
+ files: hit.paths.filter(this.publicMetadataText),
1644
+ sameFileDifferentSymbols: this.sameFileDifferentSymbols(task, hit),
1645
+ }));
1646
+ }
1647
+
1609
1648
  /**
1610
1649
  * Dashboard warning links (#264): the board's overlap pairs and the live cohorts, as task and peer ids only.
1611
1650
  * Paths, symbols and plans stay out; PII tasks are excluded by the same rules overlapHits applies.
@@ -1648,12 +1687,23 @@ export class Tasks {
1648
1687
  * ponytail: kept in memory, so after a restart such a notice is delivered whatever its task's state; persist the
1649
1688
  * condition if stale notices after restarts show up.
1650
1689
  */
1651
- private readonly conditional = new Map<string, { peer: PeerId; task: number; states: Task["state"][] }>();
1690
+ private readonly conditional = new Map<string, { peer: PeerId; task: number; tasks?: number[]; states: Task["state"][] }>();
1652
1691
 
1653
1692
  /**
1654
1693
  * Publish a notice about `task`, which only matters to `to` while that task is in one of `states` for it (issue
1655
1694
  * #106): open work by default; the conflict notices of #91 count a task in review too.
1656
1695
  */
1696
+ /**
1697
+ * #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
1698
+ * recipient, and carries `completedFor` so the bus replaces an earlier queued notice for the same `done` task and recipient.
1699
+ */
1700
+ whileOpenFor(to: PeerId, done: number, about: number[], body: string): void {
1701
+ const env = newEnvelope(HUB, body, { to: [to], kind: "task", refs: { task: String(about[0]), taskRecipient: "owner", completedFor: done } });
1702
+ this.conditional.set(env.id, { peer: to, task: about[0]!, tasks: about, states: OPEN });
1703
+ if (this.conditional.size > 1024) this.conditional.delete(this.conditional.keys().next().value as string);
1704
+ this.d.bus.publish(env);
1705
+ }
1706
+
1657
1707
  whileOpen(to: PeerId, task: number, body: string, states: Task["state"][] = OPEN): void {
1658
1708
  const env = newEnvelope(HUB, body, { to: [to], kind: "task", refs: { task: String(task), taskRecipient: "owner" } });
1659
1709
  this.conditional.set(env.id, { peer: to, task, states });
@@ -1682,8 +1732,8 @@ export class Tasks {
1682
1732
  }
1683
1733
  const c = this.conditional.get(env.id);
1684
1734
  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);
1735
+ // A notice about several of the recipient's tasks matters while any one of them is still open for it (#713).
1736
+ 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
1737
  };
1688
1738
 
1689
1739
  /** 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 +1769,12 @@ export class Tasks {
1719
1769
  private places(task: Task): { repositoryKey: string; paths: string[]; symbols: string[] } {
1720
1770
  const scope = this.repositories.scope(task.refs);
1721
1771
  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}`)] };
1772
+ const symbols = [
1773
+ ...(task.plan?.symbols ?? []).map(normalizePlanSymbol),
1774
+ ...(task.plan?.sections ?? []).map(v => `section ${v}`),
1775
+ ...(task.plan?.sources ?? []).map(v => `source ${v}`),
1776
+ ];
1777
+ return { repositoryKey: scope.key, paths: [...new Set(paths)], symbols: [...new Set(symbols)] };
1723
1778
  }
1724
1779
 
1725
1780
  private roots?: string[];
@@ -1745,7 +1800,7 @@ export class Tasks {
1745
1800
  const theirs = this.places(t);
1746
1801
  if (mine.repositoryKey !== theirs.repositoryKey) return [];
1747
1802
  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));
1803
+ const symbols = mine.symbols.filter((x) => theirs.symbols.some((y) => symbolsOverlap(x, y)));
1749
1804
  return paths.length || symbols.length ? [{ task: t, paths, symbols }] : [];
1750
1805
  });
1751
1806
  }
@@ -1813,7 +1868,9 @@ export class Tasks {
1813
1868
  const task = this.d.board.get(target);
1814
1869
  if (!task) throw new Error(`no task #${target}`);
1815
1870
  if (isClosedTaskState(task.state)) return [...workflow, `task ${this.publicTitle(task)} (${task.state}): closed; not routed`];
1816
- const a = assign(task, this.states(), routing, { exclude: this.excluded(task), excludeOwners: this.plannerPullRecord(task)?.pull ? this.plannerFull(task) : [], waitsFor: this.waitsFor(task), ...this.weights(task.class, task) });
1871
+ const planned = task.state === "backlog" && !!task.reserved || this.plannerPullRecord(task)?.pull === true;
1872
+ const a = planned ? this.plannerAssignment(task, true, routing)
1873
+ : assign(task, this.states(), routing, { exclude: this.excluded(task), waitsFor: this.waitsFor(task), ...this.weights(task.class, task) });
1817
1874
  // The split trace is for the pair the record is about: the task's owner when it has one (issue #109).
1818
1875
  return [...workflow, `task ${this.publicTitle(task)} (${task.state}, owner ${task.owner ?? "none"}${task.reserved ? `, reserved for ${task.reserved}` : ""})`, ...(task.profileClass ? [`profile class ${profileTaskLabel(task)}`] : []), ...(task.role ? [`task role ${task.role} (guidance only)`] : []), "if it were assigned now:", ...a.trace, ...(this.splitShadow(task, task.owner ?? a.owner)?.trace ?? [])];
1819
1876
  }
@@ -1901,14 +1958,17 @@ export class Tasks {
1901
1958
  }
1902
1959
 
1903
1960
  /** `override`: a person's explicit assign, which past refusals do not block and which drops an agent's reservation (#207). */
1904
- private async assignOwner(task: Task, by: PeerId, opts: { candidates?: PeerId[]; namedOwner?: PeerId; event?: string; note?: string; reason?: TaskMoveReason; clearOnFail?: boolean; exclude?: PeerId[]; context?: string; contextWithheld?: boolean; claim?: boolean; override?: boolean; roster?: boolean; planner?: boolean; expectedFlowRevision?: number; refuseNamed?: "pre" | "plain" } = {}): Promise<Task> {
1905
- if (task.state === "backlog") throw new PreEffectToolRefusal(`task #${task.id}: pull backlog work before assigning it`);
1961
+ private async assignOwner(task: Task, by: PeerId, opts: { candidates?: PeerId[]; namedOwner?: PeerId; event?: string; note?: string; reason?: TaskMoveReason; clearOnFail?: boolean; exclude?: PeerId[]; context?: string; contextWithheld?: boolean; claim?: boolean; override?: boolean; roster?: boolean; planner?: boolean; expectedFlowRevision?: number; refuseNamed?: "pre" | "plain"; plannerPull?: { revision: number; note: string; withheld: boolean; guard: () => void } } = {}): Promise<Task> {
1962
+ if (task.state === "backlog" && !opts.plannerPull) throw new PreEffectToolRefusal(`task #${task.id}: pull backlog work before assigning it`);
1906
1963
  const mode = (this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode;
1907
1964
  let stagedClaimGap: string | undefined;
1908
1965
  if (mode !== "off" && this.isBuildTask(task)) {
1909
1966
  const gap = task.sdlc?.planReview ? await this.planReviewGap(task) : "plan not reviewed since proposal";
1910
1967
  const current = this.d.board.get(task.id);
1911
- if (!current || !this.ownerAssignmentCurrent(task, current)) return current ?? task;
1968
+ if (!current || !this.ownerAssignmentCurrent(task, current)) {
1969
+ if (opts.plannerPull) throw new PreEffectToolRefusal("planner pull changed during admission");
1970
+ return current ?? task;
1971
+ }
1912
1972
  task = current; // Reviewer-only and quiet bookkeeping updates do not supersede ownership authority.
1913
1973
  if (gap) {
1914
1974
  this.releaseNotice(`task ${this.publicTitle(current)}: ${gap}; planner: call hub_planner_groom then hub_plan_review`);
@@ -1925,9 +1985,10 @@ export class Tasks {
1925
1985
  const waits = this.waitsFor(task);
1926
1986
  const plannedMove = opts.planner || this.plannerPullRecord(task)?.pull === true && (by === HUB || opts.roster) && (opts.reason !== "manual" || opts.roster);
1927
1987
  const plannerFull = plannedMove ? this.plannerFull(task) : [];
1928
- const a = assign(task, this.states(), this.d.routing(), { exclude: [...(opts.override ? [] : this.excluded(task)), ...(opts.exclude ?? []), ...(opts.event === "escalated" && task.owner ? [task.owner] : [])], excludeOwners: plannerFull, ...(opts.candidates ? { candidates: opts.candidates } : {}), waitsFor: waits, ...this.weights(task.class, task), ...(opts.namedOwner ? { namedOwner: opts.namedOwner } : {}) });
1988
+ const a = assign(opts.plannerPull ? { ...task, state: "proposed" } : task, this.states(), this.d.routing(), { exclude: [...(opts.override ? [] : this.excluded(task)), ...(opts.exclude ?? []), ...(opts.event === "escalated" && task.owner ? [task.owner] : [])], excludeOwners: plannerFull, ...(opts.candidates ? { candidates: opts.candidates } : {}), waitsFor: waits, ...this.weights(task.class, task), ...(opts.namedOwner ? { namedOwner: opts.namedOwner } : {}) });
1929
1989
 
1930
1990
  if (waits.length) {
1991
+ if (opts.plannerPull) throw new PreEffectToolRefusal("planner pull waits for dependency approval");
1931
1992
  this.releaseNotice(`task ${this.publicTitle(task)} waits for ${waits.map((id) => `#${id}`).join(", ")}; it is offered once they are approved`);
1932
1993
  return task;
1933
1994
  }
@@ -1953,7 +2014,13 @@ export class Tasks {
1953
2014
  // A person's explicit assign replaces an agent's reservation (#207). Only a refusal keeps the owner it moves away from
1954
2015
  // out for good: a reviewer's rejections or an escalation by hand, never the hub's own move after a failed delivery.
1955
2016
  const lasting = opts.event === "escalated" && task.owner && (opts.reason === "rejections" || opts.reason === "manual");
1956
- const next = this.d.board.update(task.id, by, opts.event ?? "assigned", { owner: a.owner, reviewer: a.reviewer ?? null, ...(opts.roster && task.state === "in_progress" ? { state: "proposed" as const } : {}), ...(opts.event === "escalated" ? { rejections: 0 } : {}), ...(opts.override && task.reserved ? { reserved: null } : {}) }, this.assignmentNote(`${opts.note ?? `to ${a.owner}`}${passed}`, a), { ...this.reviewMetadata(a), ...(profile ? { profile } : {}), ...(opts.reason ? { reason: opts.reason } : {}), ...(lasting ? { from: task.owner! } : {}) }, opts.expectedFlowRevision);
2017
+ opts.plannerPull?.guard();
2018
+ const assignmentNote = this.assignmentNote(`${opts.note ?? `to ${a.owner}`}${passed}`, a);
2019
+ const assignmentExtra = { ...this.reviewMetadata(a), ...(profile ? { profile } : {}), ...(opts.reason ? { reason: opts.reason } : {}), ...(lasting ? { from: task.owner! } : {}) };
2020
+ const next = opts.plannerPull
2021
+ ? this.d.board.pullAssigned(task.id, by, a.owner, opts.plannerPull.revision, opts.plannerPull.note, opts.plannerPull.withheld, a.reviewer ?? null, assignmentNote, assignmentExtra, task.history.length)
2022
+ : this.d.board.update(task.id, by, opts.event ?? "assigned", { owner: a.owner, reviewer: a.reviewer ?? null, ...(opts.roster && task.state === "in_progress" ? { state: "proposed" as const } : {}), ...(opts.event === "escalated" ? { rejections: 0 } : {}), ...(opts.override && task.reserved ? { reserved: null } : {}) }, assignmentNote, assignmentExtra, opts.expectedFlowRevision);
2023
+ if (opts.plannerPull) this.planningRecorded({ by, task: next.id, fields: ["pull", "reserved"], revision: next.planningRevision! });
1957
2024
  // What calibration reads (issue #109): routing chose the first owner (no single named candidate, no claim; not an
1958
2025
  // escalation, relay or reassignment of work already begun), and the work overlaps another owner's task not started
1959
2026
  // yet. For the record only. Work routed back to its proposer is left out, as its observations are (by === owner).
@@ -1967,6 +2034,11 @@ export class Tasks {
1967
2034
  // The cohort record formed just below reads it, and so does `route explain` while that turn lasts.
1968
2035
  if (opts.claim && a.owner === by && this.d.bus.peers.get(by)?.state === "busy") this.sent.set(handOver(next, by), by);
1969
2036
  try {
2037
+ if (opts.plannerPull && task.offer) {
2038
+ const prior = { ...task, owner: task.offer.owner };
2039
+ this.withdrawRoleRequest(prior, next, "owner");
2040
+ this.releaseNotice(`task #${next.id}: stale offer withdrawal failed`, () => this.d.bus.withdraw(task.offer!.envelopeId));
2041
+ }
1970
2042
  if (by === USER && opts.event === "reassigned") this.withdrawRoleRequest(task, next, "owner");
1971
2043
  const hits = this.overlapHits(next);
1972
2044
  this.formCohort(next, hits);
@@ -2095,13 +2167,49 @@ export class Tasks {
2095
2167
  if (waits.length) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id} waits for ${waits.map((id) => `#${id}`).join(", ")}, not approved yet`);
2096
2168
  }
2097
2169
 
2170
+ /** Ready offers between their cap check and the owner write (#655): task id -> reserved owner whose slot it takes. */
2171
+ private readonly readyInFlight = new Map<number, PeerId>();
2172
+
2173
+ /** Offer order for ready work (#655): priority (urgent is P0, none is P2), then rank, then id. */
2174
+ private readonly readyOrder = (a: Task, b: Task): number =>
2175
+ (a.priority ?? (a.signals.includes("urgent") ? 0 : 2)) - (b.priority ?? (b.signals.includes("urgent") ? 0 : 2))
2176
+ || (a.rank ?? Number.MAX_SAFE_INTEGER) - (b.rank ?? Number.MAX_SAFE_INTEGER) || a.id - b.id;
2177
+
2178
+ /** Ready work the hub parked on its reserved owner's WIP cap (#655): its latest move-or-hold entry is `ready held`. */
2179
+ private isHeld(task: Task): boolean {
2180
+ // A planner's priority or rank edit (`planned`) is no move: it must not make held work look released.
2181
+ return task.history.findLast(entry => entry.event === "ready held" || !QUIET_EVENTS.has(entry.event) && entry.event !== "planned")?.event === "ready held";
2182
+ }
2183
+
2184
+ /**
2185
+ * Why readiness must not hand `task` to its reserved owner yet (#655): that owner is at its planner WIP cap, the count
2186
+ * `acceptanceCapacity` refuses at. Peer id and counts only, so it is safe for a PII task. A reserved owner the task
2187
+ * excludes is not honoured by routing, and planner-pulled work was admitted by its pull: neither is held.
2188
+ */
2189
+ private reservedHold(task: Task): string | undefined {
2190
+ const owner = task.reserved;
2191
+ if (!owner || task.owner || this.plannerPullRecord(task)?.pull === true || this.excluded(task).includes(owner)) return undefined;
2192
+ // Offers in flight take a slot before their owner is written; once it is written wip() counts it, so never twice.
2193
+ const flying = [...this.readyInFlight].filter(([id, peer]) => peer === owner && id !== task.id && !this.d.board.get(id)?.owner).length;
2194
+ const limit = this.wipLimit(owner), used = this.wip(owner, task.id) + (this.pendingClaims.get(owner) ?? 0) + flying;
2195
+ 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;
2196
+ }
2197
+
2198
+ /** The task is ready but its reserved owner has no room: say so once on the task history, never assign. */
2199
+ private holdReady(task: Task, why: string, reason: string): void {
2200
+ if (this.isHeld(task)) return;
2201
+ const ready = task.history.at(-1)?.event === "ready" ? task : this.d.board.update(task.id, HUB, "ready", {}, why);
2202
+ this.d.board.update(ready.id, HUB, "ready held", {}, reason);
2203
+ this.releaseNotice(`task ${this.publicTitle(ready)} is ready but held: ${reason}`);
2204
+ }
2205
+
2098
2206
  /** An approved task may be the last thing others waited for: those go through assignment now (issue #34). */
2099
2207
  private async releaseDependents(approved: Task): Promise<void> {
2100
2208
  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
- }
2209
+ // Its dependents and, as its owner's slot just freed (#655), that owner's held ready tasks: one list in priority order.
2210
+ const ready = this.d.board.list("proposed").filter(t => !t.owner && !this.waitsFor(t).length
2211
+ && (t.deps?.includes(approved.id) || !!approved.owner && t.reserved === approved.owner && this.isHeld(t))).sort(this.readyOrder);
2212
+ for (const t of ready) await this.offerReady(t, t.deps?.includes(approved.id) ? `#${approved.id} approved` : "owner slot freed");
2105
2213
  } catch (e) {
2106
2214
  this.releaseNotice(`task #${approved.id}: could not release dependents: ${e instanceof Error ? e.message : String(e)}`);
2107
2215
  }
@@ -2223,14 +2331,25 @@ export class Tasks {
2223
2331
  this.offered.delete(task.id);
2224
2332
  this.releaseNotice(`task #${task.id}: cancellation stall cleanup unavailable`, () => this.d.board.clearStall(task.id));
2225
2333
  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 } : {}) })));
2334
+ await this.releaseHeld(task.owner); // its owner's slot is free (#655)
2226
2335
  this.releaseNotice(`task #${task.id}: cancellation dependent advice unavailable`, () => {
2227
2336
  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
2337
  });
2229
2338
  return next;
2230
2339
  }
2231
2340
 
2341
+ /** A slot freed for `peer` (cancel, untouched return): its held ready tasks are offered in priority order, and the cap stops the offers (#655). */
2342
+ private async releaseHeld(peer: PeerId | null | undefined): Promise<void> {
2343
+ if (!peer) return;
2344
+ try {
2345
+ 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");
2346
+ } catch (e) {
2347
+ this.releaseNotice(`task ${peer}: could not offer held ready work: ${e instanceof Error ? e.message : String(e)}`);
2348
+ }
2349
+ }
2350
+
2232
2351
  async releaseReady(): Promise<void> {
2233
- for (const t of this.d.board.list("proposed")) {
2352
+ for (const t of [...this.d.board.list("proposed")].sort(this.readyOrder)) {
2234
2353
  if (t.owner) { this.d.board.clearStall(t.id); continue; }
2235
2354
  const cancelledDependency = t.deps?.find(id => this.d.board.get(id)?.state === "cancelled");
2236
2355
  if (cancelledDependency) { this.reportStall(t, { kind: "cancelled-dependency", dependency: cancelledDependency, code: "cancelled" }); continue; }
@@ -2242,7 +2361,7 @@ export class Tasks {
2242
2361
  continue;
2243
2362
  }
2244
2363
  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;
2364
+ if ((!t.deps?.length && !t.history.some(h => h.event === "backlog pulled")) || (!planned && last !== "blocked" && last !== "ready" && last !== "reserved" && !this.isHeld(t))) continue;
2246
2365
  // ponytail: a reserved owner (#207) that attaches after another peer that can take the task is passed over here like
2247
2366
  // an offline one; hold reserved tasks for a grace period if restarts show that up.
2248
2367
  await this.offerReady(t, "nothing left to wait for");
@@ -2251,13 +2370,18 @@ export class Tasks {
2251
2370
 
2252
2371
  /** Callers hold a list read before an await: re-read, or a task the other caller offered meanwhile is offered twice. */
2253
2372
  private async offerReady(stale: Task, why: string): Promise<void> {
2254
- let offering = false, undelivered: AssignmentUndeliveredError | undefined;
2373
+ let offering = false, undelivered: AssignmentUndeliveredError | undefined, slot: PeerId | undefined;
2255
2374
  try {
2256
2375
  const t = this.d.board.get(stale.id);
2257
2376
  if (!t || t.state !== "proposed" || t.owner || this.personUnassigned(t) || this.offered.has(t.id)) return;
2258
2377
  const planned = this.plannerPullRecord(t)?.pull === true;
2259
2378
  const selected = planned ? this.plannerAssignment(t, true).owner : undefined;
2260
2379
  if (planned && !selected) return;
2380
+ const held = planned ? undefined : this.reservedHold(t); // #655: the cap holds before the ready move is offered
2381
+ if (held) { this.holdReady(t, why, held); return; }
2382
+ // The check and the slot are one synchronous step: assignOwner can yield before it writes the owner, and a second offer
2383
+ // for the same reserved owner must see this one as taking the last slot.
2384
+ if (!planned && t.reserved) { slot = t.reserved; this.readyInFlight.set(t.id, slot); }
2261
2385
  this.offered.add(t.id);
2262
2386
  offering = true;
2263
2387
  const ready = t.history.at(-1)?.event === "ready" ? t : this.d.board.update(t.id, HUB, "ready", {}, why);
@@ -2269,6 +2393,7 @@ export class Tasks {
2269
2393
  if (e instanceof AssignmentUndeliveredError) undelivered = e;
2270
2394
  else this.releaseNotice(`task ${this.publicTitle(stale)}: could not be assigned: ${e instanceof Error ? e.message : String(e)}`);
2271
2395
  } finally {
2396
+ if (slot) this.readyInFlight.delete(stale.id);
2272
2397
  if (offering && !undelivered) {
2273
2398
  try {
2274
2399
  if (!this.d.board.get(stale.id)?.owner) this.offered.delete(stale.id);
@@ -2284,6 +2409,67 @@ export class Tasks {
2284
2409
  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
2410
  }
2286
2411
 
2412
+ /**
2413
+ * #723: link the issue a task names the moment it is proposed or accepted, in advise and enforce, so the lifecycle never
2414
+ * waits for a groom. It is a local parse with no GitHub read, and it never refuses or fails the write it follows.
2415
+ */
2416
+ private linkOnWrite(task: Task): Task {
2417
+ try { return (this.d.sdlc?.config().mode ?? "off") === "off" ? task : this.linkIssue(task); }
2418
+ catch { return task; }
2419
+ }
2420
+
2421
+ /**
2422
+ * #723: a title that qualifies another repository (`agent-mem#13`, `owner/repo#N`) while `refs.repo` is absent cannot link
2423
+ * (the identity needs the repository), so the proposer is told to set it. Advice only; a PII task gets none.
2424
+ */
2425
+ refsRepoHint(task: Task): string | undefined {
2426
+ if (this.isPii(task) || task.refs.repo || task.sdlc?.issue || (this.d.sdlc?.config().mode ?? "off") === "off") return undefined;
2427
+ const names = qualifiedTitleRepos(task.title);
2428
+ return names.length ? `task #${task.id}: the title names ${names.join(", ")}; set refs.repo to that repository (owner/name) so its issue links and the lifecycle shows it` : undefined;
2429
+ }
2430
+
2431
+ /** #694: write the one issue the task itself names (the plan-review source) into its lifecycle receipt, once; never relinks. */
2432
+ private linkIssue(task: Task): Task {
2433
+ if (this.isPii(task) || task.sdlc?.issue) return task;
2434
+ let identity: { repo: string; issue: number } | undefined;
2435
+ try { identity = this.planIssueIdentity(task); } catch { return task; }
2436
+ const next = identity && linkedReceipt(task.sdlc, identity, this.d.sdlc?.config() ?? sdlcConfig(undefined));
2437
+ return next ? this.d.board.update(task.id, HUB, "lifecycle linked", { sdlc: next }, `issue #${identity!.issue}`) : task;
2438
+ }
2439
+
2440
+ /**
2441
+ * #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
2442
+ * (or, with no commit, its branch). The read's basis travels with it, so a task whose commit or branch changed while the
2443
+ * read was awaited never receives a PR found for another head. The done persists it only after its gates admit it, so a
2444
+ * refused done stays effect-free (#254).
2445
+ */
2446
+ private async identityLink(task: Task, refs?: TaskRefs): Promise<IdentityLink | undefined> {
2447
+ if (this.isPii(task) || task.sdlc?.pr) return undefined;
2448
+ const repository = task.sdlc?.repo ?? task.refs.repo ?? this.d.repository;
2449
+ const commit = refs?.commit ?? task.refs.commit, branch = refs?.branch ?? task.refs.branch;
2450
+ if (!repository || !commit && !branch) return undefined;
2451
+ const pr = await openPrFor(repository, commit ? { commit } : { branch }, this.d.sdlc?.gh ?? sdlcGh);
2452
+ return pr ? { repo: repository, pr, basis: { commit, branch } } : undefined;
2453
+ }
2454
+
2455
+ /**
2456
+ * The task as the done's gates see it: the issue the current task names (re-resolved now, so a changed title or refs
2457
+ * counts) and the staged PR only while the done's effective commit and branch are still the ones it was read for.
2458
+ */
2459
+ private withLink(task: Task, link: IdentityLink | undefined, refs?: TaskRefs): Task {
2460
+ if ((this.d.sdlc?.config().mode ?? "off") === "off" || this.isPii(task)) return task;
2461
+ const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
2462
+ let issue: { repo: string; issue: number } | undefined;
2463
+ if (!task.sdlc?.issue) { try { issue = this.planIssueIdentity(task); } catch { issue = undefined; } }
2464
+ const commit = refs?.commit ?? task.refs.commit, branch = refs?.branch ?? task.refs.branch;
2465
+ const repository = task.sdlc?.repo ?? task.refs.repo ?? this.d.repository;
2466
+ const pr = link && !task.sdlc?.pr && repository?.toLowerCase() === link.repo.toLowerCase() && link.basis.commit === commit && (commit !== undefined || link.basis.branch === branch) ? link : undefined;
2467
+ let next = task.sdlc;
2468
+ if (issue) next = linkedReceipt(next, issue, config) ?? next;
2469
+ if (pr) next = linkedReceipt(next, { repo: pr.repo, pr: pr.pr }, config) ?? next;
2470
+ return next === task.sdlc ? task : { ...task, sdlc: next };
2471
+ }
2472
+
2287
2473
  private planIssueIdentity(task: Task): { repo: string; issue: number } | undefined {
2288
2474
  const source = task.external ?? task.sdlc;
2289
2475
  if (source?.repo && source.issue) return { repo: source.repo, issue: source.issue };
@@ -2291,12 +2477,16 @@ export class Tasks {
2291
2477
  let issues: number[];
2292
2478
  try { issues = specRefs(task, repository); }
2293
2479
  catch { throw new PreEffectToolRefusal("plan review issue identity unavailable"); }
2480
+ // #723: a title that qualifies the sibling repository `refs.repo` names (`agent-mem#13`) is a local parse, never a read.
2481
+ // A sibling shorthand next to a bare #N is two identities, refused below, never the bare one alone.
2482
+ issues = [...new Set([...issues, ...siblingTitleRefs(task.title, task.refs.repo)])];
2294
2483
  if (issues.length > 1) throw new PreEffectToolRefusal("plan review needs one explicit issue identity");
2295
2484
  return issues[0] ? { repo: repository, issue: issues[0] } : undefined;
2296
2485
  }
2297
2486
 
2298
2487
  async groomingDigest(by: PeerId): Promise<Record<string, unknown>> {
2299
2488
  this.planningAuthority(by);
2489
+ this.backfillIssueLinks(by); // #723: first, bounded, before the slow reads
2300
2490
  const included = (task: Task) => !isClosedTaskState(task.state) || task.state === "cancelled" && task.history.some(entry => !!entry.supersededBy);
2301
2491
  const snapshot = this.d.board.list().filter(included), issues = new Map<string, PlanIssueMetadata>(), attempted = new Set<string>();
2302
2492
  const deadline = performance.now() + 4_000;
@@ -2339,6 +2529,113 @@ export class Tasks {
2339
2529
  return { ...digest, truncated: digest.truncated || incomplete, ...(current.some(task => this.isPii(task)) ? { privateTasks: "withheld" } : {}) };
2340
2530
  }
2341
2531
 
2532
+ /**
2533
+ * #694, #723: backfill lifecycle issue links with their own bounds, run first and apart from the slow metadata reads and
2534
+ * the digest, so a groom that later times out has still linked. Each call links up to 32 open tasks that still lack one
2535
+ * within a one-second budget, so later tasks are reached on later calls. A local parse; a failure skips that task only.
2536
+ */
2537
+ private backfillCursor = 0;
2538
+ private backfillIssueLinks(by: PeerId): number {
2539
+ if ((this.d.sdlc?.config().mode ?? "off") === "off") return 0;
2540
+ // ponytail: 32 links and 1 s per call; raise both only with a measured groom time on a 400-task board.
2541
+ const deadline = performance.now() + 1_000;
2542
+ let linked = 0, i = 0;
2543
+ const list = this.d.board.list(), n = list.length;
2544
+ // The scan resumes where the last call stopped, so rows that never link cannot hold later ones back.
2545
+ for (; i < n; i++) {
2546
+ if (linked >= 32 || performance.now() >= deadline) break;
2547
+ const task = list[(this.backfillCursor + i) % n]!;
2548
+ if (isClosedTaskState(task.state) || task.sdlc?.issue || this.isPii(task)) continue;
2549
+ this.planningAuthority(by);
2550
+ try { if (this.linkIssue(task) !== task) linked++; } catch { /* one task never stops the rest */ }
2551
+ }
2552
+ this.backfillCursor = n ? (this.backfillCursor + i) % n : 0;
2553
+ return linked;
2554
+ }
2555
+
2556
+ /**
2557
+ * #481 S7: the SDLC metrics from board history, read-only. Private tasks are counted as withheld and read for nothing else; PR
2558
+ * readbacks are not made here (the cost report owns them), so merge time and same-head reruns read `unknown` unless given.
2559
+ * Detection never calls a model and the report writes nothing.
2560
+ */
2561
+ sdlcMetrics(by: PeerId, since?: unknown, merged?: { mergedAt?: Record<number, number>; ciReruns?: Record<number, number | null> }): MetricsReport {
2562
+ this.planningAuthority(by);
2563
+ if (by !== USER && this.d.privateTurn?.(by)) throw new PreEffectToolRefusal("metrics unavailable during private work");
2564
+ let cutoff: number | undefined;
2565
+ if (since !== undefined && since !== null && since !== "") {
2566
+ cutoff = typeof since === "number" ? since : /^\d+$/.test(String(since)) ? Number(since) : Date.parse(String(since));
2567
+ if (!Number.isFinite(cutoff) || cutoff < 0) throw new PreEffectToolRefusal("since must be a time (YYYY-MM-DD or an ISO timestamp) or milliseconds");
2568
+ }
2569
+ const rows = this.d.board.list();
2570
+ const tasks: MetricTask[] = rows.map(task => ({ id: task.id, private: this.isPii(task), history: task.history.map(entry => ({ at: entry.at, event: entry.event,
2571
+ ...(entry.reviewCost?.head ? { requestHead: entry.reviewCost.head } : {}), ...(entry.reviewFindings?.head ? { verdictHead: entry.reviewFindings.head } : {}) })) }));
2572
+ const visibleIds = new Set(rows.filter(task => !this.isPii(task)).map(task => task.id));
2573
+ const byKind: Record<string, number> = {};
2574
+ for (const stall of this.stalled()) if (visibleIds.has(stall.task)) byKind[stall.cause.kind] = (byKind[stall.cause.kind] ?? 0) + 1;
2575
+ const stalls = { open: Object.values(byKind).reduce((sum, n) => sum + n, 0), byKind, doneWithoutWrites: rows.filter(task => visibleIds.has(task.id) && task.history.findLast(entry => entry.event === "done")?.noWrites).length };
2576
+ return sdlcMetrics(tasks, { ...(cutoff !== undefined ? { since: cutoff } : {}), ...(this.d.sdlc?.config().bands ? { bands: this.d.sdlc.config().bands } : {}), stalls, ...(merged ?? {}) });
2577
+ }
2578
+
2579
+ /**
2580
+ * #481 S7: act on the band tiers, deterministically and once per breach episode. An episode is a run of non-ok values for one metric
2581
+ * (`BandResult.episode`, derived from history), so a sustained shift is one episode however many tasks finish inside it. `log` is one
2582
+ * hub notice, `diagnose` one read-only `triage` backlog task and `propose` one `plan` backlog card naming the metric and its evidence.
2583
+ * A metric with an open band card (a backlog or open task whose detail carries its key) writes nothing more, so a value that grows
2584
+ * from diagnose to propose inside one episode does not add a second card. Held while the hub is stopping, recovering or has a
2585
+ * storage error, like every host writer. The text is fixed words, numbers and task ids, never task text, and no GitHub issue is
2586
+ * created. Returns the task ids created.
2587
+ */
2588
+ /**
2589
+ * Whether `proposeFromBands` would run now, and a signature of what it would read. The daemon timer records a board revision only for a
2590
+ * pass that ran, and re-runs when the signature changes (a reload that enables bands or edits them on an idle board).
2591
+ */
2592
+ bandsState(): { ready: boolean; signature: string } {
2593
+ const config = this.d.sdlc?.config();
2594
+ const configured = !!config && config.mode !== "off" && !!config.bands && Object.keys(config.bands).length > 0;
2595
+ return { ready: configured && !this.d.sweepHeld?.() && !this.d.bus.isRecoveryHeld && !this.d.bus.storageError, signature: configured ? JSON.stringify([config!.mode, config!.bands]) : "" };
2596
+ }
2597
+
2598
+ proposeFromBands(by: PeerId = HUB): number[] {
2599
+ const config = this.d.sdlc?.config();
2600
+ if (!config || config.mode === "off" || !config.bands || !Object.keys(config.bands).length) return [];
2601
+ if (this.d.sweepHeld?.() || this.d.bus.isRecoveryHeld || this.d.bus.storageError) return [];
2602
+ if (by !== HUB && by !== USER) this.planningAuthority(by);
2603
+ const report = this.sdlcMetrics(by === HUB ? USER : by);
2604
+ if (report.bands === "off") return [];
2605
+ const rows = this.d.board.list();
2606
+ const bandRows = rows.filter(row => row.history[0]?.by === HUB && row.detail?.startsWith("Band breach key: "));
2607
+ const keyOf = (row: Task) => row.detail!.split("\n")[0]!.slice("Band breach key: ".length);
2608
+ const seen = new Set(bandRows.map(keyOf));
2609
+ // A card counts as open while its task is not closed: the planner has not yet decided on it.
2610
+ const open = new Set(bandRows.filter(row => !isClosedTaskState(row.state)).map(row => keyOf(row).split(":")[0]!));
2611
+ const created: number[] = [];
2612
+ for (const band of report.bands as BandResult[]) {
2613
+ if (band.tier === "ok" || band.lastTask === null || band.episode === null) continue;
2614
+ const key = `${band.metric}:#${band.episode}`;
2615
+ if (band.tier === "log") {
2616
+ const marker = `${key}:log`;
2617
+ if (!open.has(band.metric) && this.d.board.recordBandNotice(marker)) this.releaseNotice(`sdlc band: ${band.metric} is ${band.reason}; last value ${band.last} on task #${band.lastTask}`);
2618
+ continue;
2619
+ }
2620
+ if (seen.has(`${key}:propose`) || seen.has(`${key}:diagnose`) || open.has(band.metric)) continue;
2621
+ const tier = band.tier === "propose" ? "propose" : "diagnose";
2622
+ const full = `${key}:${tier}`;
2623
+ const evidence = `metric ${band.metric}; newest value ${band.last} on task #${band.lastTask}; episode began at task #${band.episode}; baseline mean ${band.mean}, deviation ${band.sd} over ${band.baseline} tasks; ${band.reason}`;
2624
+ const diagnose = tier === "diagnose";
2625
+ const title = diagnose ? `Diagnose SDLC metric ${band.metric}: read-only triage` : `Review SDLC metric ${band.metric}: control band breach`;
2626
+ const detail = `Band breach key: ${full}\n${evidence}\n${diagnose
2627
+ ? "Read-only triage for the planner: inspect the recent task histories behind this metric and report what changed. Do not edit files, branches or the board."
2628
+ : "Backlog card for the planner: decide whether this breach needs a process change. It names the metric and its evidence; it opens no GitHub issue."}`;
2629
+ const draft = { title, detail, refs: {} };
2630
+ const signals = this.signalsFor(draft);
2631
+ if (this.isPii({ signals }) || !this.candidateTextAllowed(`${title}\n${detail}`)) continue;
2632
+ const proposal = this.d.board.propose(HUB, { ...draft, class: diagnose ? "triage" : "plan", backlog: true, signals });
2633
+ this.incomingBacklog.add(proposal.id); seen.add(full); open.add(band.metric); created.push(proposal.id);
2634
+ }
2635
+ for (const id of created) this.releaseNotice(`task #${id}: SDLC control band breach proposed in the backlog`);
2636
+ return created;
2637
+ }
2638
+
2342
2639
  /** S10 read-only planner report; S7 owns rolling bands and automatic board proposals. */
2343
2640
  async costReport(by: PeerId, repository?: unknown, scopePrs?: readonly number[]): Promise<Record<string, unknown>> {
2344
2641
  const authority = () => {
@@ -2432,8 +2729,9 @@ export class Tasks {
2432
2729
  const current = this.need(task.id, false, true, true);
2433
2730
  if (current.history.length !== flow || (current.planningRevision ?? 0) !== revision) throw new PreEffectToolRefusal("plan review changed during readback");
2434
2731
  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"),
2732
+ 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
2733
  planReview: { planner: by, planningRevision: revision, recordedAt: Date.now(), ...(Object.keys(scope).length ? { scope } : {}), ...(issue ? { issue } : {}) } } }, raw.reason.trim(), { ...(withheld ? { withheld: true } : {}) }, flow);
2734
+ if (config.mode !== "off") updated = this.linkIssue(updated); // #694
2437
2735
  if (updated.state === "proposed" && !updated.owner) {
2438
2736
  this.offered.delete(updated.id);
2439
2737
  try { await this.offerReady(updated, "planner review recorded"); }
@@ -2448,7 +2746,11 @@ export class Tasks {
2448
2746
  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
2747
  const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
2450
2748
  const next = sdlcReceipt(input, task.sdlc, config, VERSION);
2749
+ // The spec identity is person-owned in enforce. No observation cache or restart can reopen a relink bypass.
2750
+ const specRelink = (task.sdlc?.repo !== undefined && next.repo !== task.sdlc.repo) || (task.sdlc?.issue !== undefined && next.issue !== task.sdlc.issue);
2751
+ if (config.mode === "enforce" && by !== USER && specRelink) throw new PreEffectToolRefusal("only the person may relink an established repository or issue in enforce");
2451
2752
  const definition = stageDefinition(config, next.stage);
2753
+ 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
2754
  // #481 S1: entering a build stage in enforce needs the person's spec acceptance; the person may move any stage.
2453
2755
  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
2756
  if (config.mode === "enforce" && config.definitions && input && typeof input === "object" && "stage" in input && next.stage !== task.sdlc?.stage && by !== USER) {
@@ -2476,7 +2778,7 @@ export class Tasks {
2476
2778
  const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
2477
2779
  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
2780
  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);
2781
+ const report = await readMergeQueue(repo, config, links, this.d.sdlc?.gh, this.publicMetadataText);
2480
2782
  // Policy can change across remote awaits without any board write. Never return its stale public projection.
2481
2783
  const currentPrivacy = this.d.board.list().map(task => ({ id: task.id, private: this.isPii(task) }));
2482
2784
  if (workflowHash(links.map(({ id, private: hidden }) => ({ id, private: hidden }))) !== workflowHash(currentPrivacy)) {
@@ -2492,6 +2794,13 @@ export class Tasks {
2492
2794
  this.capturePlanHead(task, row.head);
2493
2795
  }
2494
2796
  }
2797
+ for (const row of report.rows) if (row.testIntegrity) {
2798
+ row.testIntegrity = row.testIntegrity.map(item => ({ ...publicTestIntegrity(item, this.publicMetadataText), ...(item.task === undefined ? {} : { task: item.task }) }));
2799
+ if (config.mode === "enforce") {
2800
+ row.blockers = [...new Set([...row.blockers, ...row.testIntegrity.flatMap(testIntegrityBlockers)])];
2801
+ row.ready = row.ready && row.blockers.length === 0;
2802
+ }
2803
+ }
2495
2804
  return report;
2496
2805
  }
2497
2806
  async releaseReadiness(repo: string, liveIssues: number[]) {
@@ -2621,16 +2930,18 @@ export class Tasks {
2621
2930
  async lifecycleStatus(id: unknown, refresh = true): Promise<SdlcStatus> {
2622
2931
  const task = this.need(id), config = this.d.sdlc?.config() ?? sdlcConfig(undefined), identity = this.planIdentity(task);
2623
2932
  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;
2933
+ const deadline = performance.now() + 20_000;
2934
+ 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: "" }); };
2935
+ const evidence = task.sdlc && !this.isPii(task) && refresh && config.mode !== "off" ? await readSdlcPr(task.sdlc, read, true) : undefined;
2626
2936
  valid();
2627
2937
  const accepted = task.sdlc?.acceptance;
2628
2938
  const edit = accepted && !this.isPii(task) && refresh && config.mode !== "off" ? await readSpecEdit(accepted.repo, accepted.issue, read) : undefined;
2939
+ 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
2940
  const current = valid();
2630
2941
  if (this.planIdentity(current) !== this.planIdentity(task) || current.history.length !== task.history.length) throw new PreEffectToolRefusal("task changed during lifecycle readback");
2631
2942
  this.capturePlanRead(current, evidence);
2632
2943
  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);
2944
+ config.mode !== "off" ? this.planReport(this.d.board.get(current.id)!, evidence) : undefined, integrity);
2634
2945
  }
2635
2946
 
2636
2947
  /** #481 S1: only build work starts from an accepted spec. */
@@ -2696,26 +3007,33 @@ export class Tasks {
2696
3007
  this.d.notify(this.specText(task, state, "advice"));
2697
3008
  }
2698
3009
 
3010
+ private unknownTestIntegrity(privateTask = false): TestIntegrity {
3011
+ return { applicability: "unknown", redFirst: "unknown", fileState: "unknown", modifiedTests: [], blockers: [privateTask ? "test integrity: private evidence unavailable" : "test integrity: current evidence unknown"] };
3012
+ }
3013
+
2699
3014
  private async lifecycleGate(task: Task, transition: "review" | "approval", reviewedHead?: string, findings?: readonly ReviewFindingSet[]): Promise<PrEvidence | undefined> {
2700
3015
  const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
2701
3016
  if (config.mode === "off") return;
2702
3017
  const receipt = task.sdlc && { ...task.sdlc, ...(reviewedHead ? { reviewedHead } : {}) };
3018
+ const deadline = performance.now() + 20_000;
2703
3019
  const read: SdlcGh = (args, timeout, cwd, cap) => {
2704
3020
  const current = this.need(task.id, false, true);
2705
3021
  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);
3022
+ const remaining = deadline - performance.now();
3023
+ return remaining > 0 ? (this.d.sdlc?.gh ?? sdlcGh)(args, Math.min(timeout ?? 10_000, remaining), cwd, cap) : Promise.resolve({ code: 1, stdout: "" });
2707
3024
  };
2708
- const evidence = receipt && !this.isPii(task) ? await readSdlcPr(receipt, read) : undefined;
3025
+ const evidence = receipt && !this.isPii(task) ? await readSdlcPr(receipt, read, true) : undefined;
3026
+ const integrity = publicTestIntegrity(receipt && evidence && !this.isPii(task) ? await readTestIntegrity(receipt, evidence, read) : this.unknownTestIntegrity(this.isPii(task)), this.publicMetadataText);
2709
3027
  const current = this.need(task.id, false, true);
2710
3028
  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
3029
  // Advise may continue on the current task, but evidence from its previous link cannot admit that task's review.
2712
3030
  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
3031
  this.capturePlanRead(current, evidence);
2714
3032
  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;
3033
+ const status = sdlcStatus(config, receipt, evidence, this.workflowView(current), undefined, this.isPii(current) ? [] : findings ?? this.reviewFindingSets(current), current.refs.commit, planReport, integrity);
3034
+ 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
3035
  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)])];
3036
+ const advice = [...new Set([...blockers, ...planConformanceBlockers(planReport), ...testIntegrityBlockers(integrity)])];
2719
3037
  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
3038
  return evidence;
2721
3039
  }
@@ -2829,7 +3147,7 @@ export class Tasks {
2829
3147
  this.warmRepository(draft);
2830
3148
  const repositoryChanged = this.repositories.scope(task.refs).key !== this.repositories.scope(draft.refs).key;
2831
3149
  const before = new Set(this.overlapHits(task).map((h) => h.task.id));
2832
- const next = this.d.board.update(task.id, by, "accepted", { state: "in_progress", ...(cleaned ? { plan: cleaned } : {}), ...(metadata ? { refs: metadata } : {}) });
3150
+ const next = this.linkOnWrite(this.d.board.update(task.id, by, "accepted", { state: "in_progress", ...(cleaned ? { plan: cleaned } : {}), ...(metadata ? { refs: metadata } : {}) })); // #723: refs.repo may have just arrived
2833
3151
  this.captureOwnerAcceptance(next);
2834
3152
  this.d.notify(`task ${this.publicTitle(next)} accepted by ${by}`);
2835
3153
  if (repositoryChanged) this.cohorts.leave(next.id);
@@ -2946,10 +3264,14 @@ export class Tasks {
2946
3264
  await this.planEntryGate(by, admission);
2947
3265
  task = valid();
2948
3266
  }
3267
+ // #694: stage the identities the hub can verify; the gates read them, and only the admitted done persists them.
3268
+ const link = this.d.sdlc && this.d.sdlc.config().mode !== "off" ? await this.identityLink(task, refs) : undefined;
3269
+ const current = () => this.withLink(valid(), link, refs);
3270
+ task = current();
2949
3271
  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();
3272
+ if (this.d.sdlc && this.d.sdlc.config().mode !== "off") { reviewEvidence = await this.lifecycleGate(task, "review"); task = current(); }
3273
+ 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;
3274
+ task = current();
2953
3275
  // A member of a silent cohort (issue #107): its done is an intent, and the last of them integrates first.
2954
3276
  const cohort = this.cohorts.of(task.id);
2955
3277
  const silentMember = !!cohort?.silent && this.turnFree() && !!task.owner && !this.isPii(task);
@@ -2969,8 +3291,8 @@ export class Tasks {
2969
3291
  if (r.action === "request") return this.d.board.update(task.id, HUB, "integration requested", {}, this.integrationRequest(task, r));
2970
3292
  if (r.action === "unresolved") {
2971
3293
  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}`);
3294
+ task = this.withLink(this.d.board.update(task.id, HUB, "integration unresolved", {}, r.why), link, refs);
3295
+ } else if (r.integrated) task = this.withLink(this.d.board.update(task.id, HUB, "integrated", {}, `cohort #${cohort!.id}, revision ${cohort!.revision}`), link, refs);
2974
3296
  } else if (cohort?.held.size && !this.isPii(task)) {
2975
3297
  // No integration step for this member (silence lifted, a PII task open): the notices the silence held stand in
2976
3298
  // for it (issue #107, AC4).
@@ -2978,14 +3300,14 @@ export class Tasks {
2978
3300
  if (evidence && by === USER) this.d.notify(`task ${this.publicTitle(task)} done by the console; overlapping work finished meanwhile:\n${evidence}`);
2979
3301
  else if (evidence) this.doneNotes.set(task.id, `Overlapping work finished while you worked (no turn-free integration step ran):\n${evidence}`);
2980
3302
  }
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
3303
+ 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
3304
  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);
3305
+ if (!command) return this.complete(task, by, summary, refs, "", withheld, "review", reviewAdmission, link);
2984
3306
  // The tool call returns now; a check can outlast an agent's tool timeout. The result decides what comes next.
2985
3307
  this.checking.set(task.id, task.owner);
2986
3308
  const checkedRefs = cleanRefs(refs);
2987
3309
  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 });
3310
+ 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
3311
  this.releaseNotice(`task ${this.publicTitle(pending)} done by ${by}; its check is queued or running: ${command}`);
2990
3312
  const seen = { events: pending.history.length, owner: pending.owner };
2991
3313
  this.pendingChecks++;
@@ -3033,7 +3355,7 @@ export class Tasks {
3033
3355
  const theirs = this.places(u);
3034
3356
  if (mine.repositoryKey !== theirs.repositoryKey) return [];
3035
3357
  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));
3358
+ const symbols = mine.symbols.filter((x) => theirs.symbols.some((y) => symbolsOverlap(x, y)));
3037
3359
  return paths.length || symbols.length ? [this.completedNotice(u, { task: t, paths, symbols }, at.note, at.withheld)] : [];
3038
3360
  });
3039
3361
  if (!open.length && !done.length) continue;
@@ -3116,19 +3438,19 @@ export class Tasks {
3116
3438
  }
3117
3439
 
3118
3440
  /** `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> {
3441
+ private async complete(task: Task, by: PeerId, summary?: string, refs?: TaskRefs, checkOutput = "", withheld = false, gated?: "review", admission?: ReviewReadiness, link?: IdentityLink): Promise<Task> {
3120
3442
  let selection = this.poolReview(task, refs);
3121
3443
  let reviewer = selection?.reviewer ?? task.reviewer, reviewEvidence: PrEvidence | undefined;
3122
3444
  if (this.d.sdlc && this.d.sdlc.config().mode !== "off" && gated !== (reviewer ? "review" : "approval")) {
3123
3445
  reviewEvidence = await this.lifecycleGate(task, reviewer ? "review" : "approval");
3124
- task = this.completionCurrent(task.id, by, false);
3446
+ task = this.withLink(this.completionCurrent(task.id, by, false), link, refs);
3125
3447
  selection = this.poolReview(task, refs);
3126
3448
  reviewer = selection?.reviewer ?? task.reviewer;
3127
3449
  }
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);
3450
+ // A delayed local check needs a new readback; a completion-selected reviewer also needs optional facts in off mode.
3451
+ if (!admission && ((reviewer && (this.d.sdlc?.config().mode ?? "off") !== "off") || this.d.prepareReviewBrief)) {
3452
+ admission = await this.reviewRequestAdmission(task, refs, false, by, reviewEvidence, undefined, staged => this.withLink(staged, link, refs), !reviewer);
3453
+ task = this.withLink(this.completionCurrent(task.id, by, false), link, refs);
3132
3454
  }
3133
3455
  selection = this.poolReview(task, refs);
3134
3456
  reviewer = selection?.reviewer ?? task.reviewer;
@@ -3138,7 +3460,7 @@ export class Tasks {
3138
3460
  const completedRefs = cleanRefs(refs);
3139
3461
  this.warmRepository({ ...task, refs: { ...task.refs, ...completedRefs } });
3140
3462
  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 } : {}) });
3463
+ 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
3464
  this.cohorts.closed(next.id); // its owner's next native turn end settles it in its cohort (issue #107)
3143
3465
  this.note(next, by, "finding", `Task #${next.id} done by ${by}: ${next.title}\n${summary ?? ""}`, withheld);
3144
3466
  this.tellCompleted(next, summary, withheld);
@@ -3148,7 +3470,7 @@ export class Tasks {
3148
3470
  this.releaseNotice(`task ${this.publicTitle(next)} done by ${by}, no reviewer: approved`);
3149
3471
  await this.releaseDependents(next);
3150
3472
  }
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]`);
3473
+ 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
3474
  else this.releaseNotice(`task ${this.publicTitle(next)}: could not send the review request to ${reviewer}`, () => this.sendReview(next, reviewer, "", admission));
3153
3475
  return next;
3154
3476
  }
@@ -3162,6 +3484,9 @@ export class Tasks {
3162
3484
  const hits = this.overlapHits(task).filter((h) => h.task.owner !== USER && h.task.owner !== HUB);
3163
3485
  const cohort = this.cohorts.of(task.id);
3164
3486
  const silent = this.silentFor(task.id);
3487
+ // #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
3488
+ // 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).
3489
+ const byOwner = new Map<PeerId, typeof hits>();
3165
3490
  for (const hit of hits) {
3166
3491
  // A member of the same silent cohort (issue #107): the member that finishes last integrates instead
3167
3492
  // (Cohorts.completion). The notice is held, so something replaces it if no integration runs. Others hear it.
@@ -3169,9 +3494,23 @@ export class Tasks {
3169
3494
  cohort.held.add(task.id);
3170
3495
  continue;
3171
3496
  }
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)));
3497
+ // 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.
3498
+ if (!["in_progress", "changes_requested", "in_review"].includes(hit.task.state) || !this.ownerAcceptance(hit.task)) continue;
3499
+ byOwner.set(hit.task.owner!, [...(byOwner.get(hit.task.owner!) ?? []), hit]);
3174
3500
  }
3501
+ for (const [owner, mine] of byOwner) {
3502
+ this.releaseNotice(`task ${this.publicTitle(task)}: could not publish the completed-change notice to ${owner}`, () =>
3503
+ this.whileOpenFor(owner, task.id, mine.map((h) => h.task.id), this.completedNotice(task, mine, summary, withheld)));
3504
+ }
3505
+ }
3506
+
3507
+ /** Share the same screened observation rule between completion notices and read-only console detail. */
3508
+ private sameFileDifferentSymbols(task: Task, hit: { task: Task; paths: string[] }): boolean {
3509
+ const symbolRepository = task.codeSymbols && !this.isPii(task) ? this.codeSymbolsRepository(task) : undefined;
3510
+ const otherRepository = hit.task.codeSymbols && !this.isPii(hit.task) ? this.codeSymbolsRepository(hit.task) : undefined;
3511
+ return !!symbolRepository && symbolRepository.key === otherRepository?.key && !this.isPii(task) && !this.isPii(hit.task) && taskCodeSymbolsDisjoint(
3512
+ task.codeSymbols, hit.task.codeSymbols, this.reviewHead(task), this.reviewHead(hit.task), symbolRepository.key,
3513
+ hit.paths, false, this.publicMetadataText);
3175
3514
  }
3176
3515
 
3177
3516
  /**
@@ -3179,13 +3518,18 @@ export class Tasks {
3179
3518
  * included), and any item that matches a PII pattern is left out of what other owners get, the summary also when the
3180
3519
  * PII screen did not clear it (`withheld`, #198).
3181
3520
  */
3182
- private completedNotice(task: Task, hit: { task: Task; paths: string[]; symbols: string[] }, summary?: string, withheld = false): string {
3521
+ private completedNotice(task: Task, hitOrHits: { task: Task; paths: string[]; symbols: string[] } | { task: Task; paths: string[]; symbols: string[] }[], summary?: string, withheld = false): string {
3522
+ const hits = Array.isArray(hitOrHits) ? hitOrHits : [hitOrHits];
3183
3523
  const paths = this.places(task).paths.filter(this.nameable);
3184
3524
  const signatures = (task.plan?.signatures ?? []).filter(this.nameable);
3185
3525
  const first = (summary ?? "").split("\n").find((l) => l.trim())?.trim().slice(0, 300);
3186
3526
  const line = first && !withheld && this.nameable(first) ? first : undefined;
3527
+ // One line per overlapping task of the recipient, so a single notice names all of them (#713).
3528
+ 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)" : ""}`);
3529
+ const open = hits.length > 1 ? `your open tasks ${touches.join("; ")}` : `your open ${touches[0]}`;
3530
+
3187
3531
  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.`,
3532
+ `Task #${task.id} (owner ${task.owner}) is done and touches ${open}. Check your work against it before you go on.`,
3189
3533
  paths.length ? `Changed files: ${paths.join(", ")}` : "",
3190
3534
  signatures.length ? `New or changed signatures: ${signatures.join("; ")}` : "",
3191
3535
  line ? `Summary: ${line}` : "",
@@ -3226,16 +3570,17 @@ export class Tasks {
3226
3570
 
3227
3571
  /** The one place a review request is written: the first reviewer and a replacement get the same text, refs and privacy. */
3228
3572
  /** 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> {
3573
+ 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
3574
  const mode = (this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode;
3231
- if (mode === "off") return undefined;
3575
+ if (mode === "off" && !this.d.prepareReviewBrief) return undefined;
3232
3576
  let quietDrift = false;
3233
3577
  const guard = () => {
3234
3578
  if (actor !== USER && actor !== task.owner) {
3235
3579
  const holders = Object.entries(this.d.roles ?? {}).filter(([, roles]) => Array.isArray(roles) && roles.includes("conductor")).map(([peer]) => peer);
3236
3580
  if (holders.length !== 1 || holders[0] !== actor) throw new (preEffect ? PreEffectToolRefusal : Error)("review admission authority changed");
3237
3581
  }
3238
- const current = this.need(task.id, false, preEffect);
3582
+ // #694: the board copy seen through the same staged identity link the caller's task carries.
3583
+ const current = stage(this.need(task.id, false, preEffect));
3239
3584
  const changed = current.history.length !== task.history.length;
3240
3585
  const quiet = current.history.length > task.history.length && current.history.slice(task.history.length).every(entry => QUIET_EVENTS.has(entry.event));
3241
3586
  const sameHistory = !changed || (mode === "advise" && quiet) || !!sameRequest?.();
@@ -3247,6 +3592,12 @@ export class Tasks {
3247
3592
  return current;
3248
3593
  };
3249
3594
  guard();
3595
+ if (!this.isPii(task) && this.d.prepareReviewBrief) {
3596
+ try { await this.d.prepareReviewBrief({ ...task, refs: { ...task.refs, ...cleanRefs(refs) } }); }
3597
+ catch { /* Optional code facts must not refuse a review. */ }
3598
+ guard();
3599
+ }
3600
+ if (mode === "off" || prepareOnly) return undefined;
3250
3601
  const expected = cleanRefs(refs).commit ?? task.refs.commit ?? task.sdlc?.head ?? task.sdlc?.localHead;
3251
3602
  const read: SdlcGh = (args, timeout, cwd, cap) => {
3252
3603
  guard();
@@ -3283,12 +3634,26 @@ export class Tasks {
3283
3634
  const cachedPlanRead = this.planReads.get(task.id);
3284
3635
  const planRead = this.planReport(task, cachedPlanRead?.identity === this.planReadIdentity(task) && cachedPlanRead.evidence.head === this.findingHead(task) ? cachedPlanRead.evidence : undefined);
3285
3636
  const conformanceText = mode !== "off" ? this.screen(task, `Plan conformance: ${JSON.stringify(planRead)}`, "plan conformance", reviewer) + "\n" : "";
3637
+ const profile = (task.profile ?? "dev") === (this.d.profile ?? "dev") ? profileData(this.d.profileData ?? task.profile ?? "dev") : profileData(task.profile);
3638
+ const target = plan ? `the plan (${plan})` : task.detail ? "the task detail above" : "the task title";
3639
+ const noCheckWording = profile.name === "research" || profile.name === "writing"
3640
+ ? "No check ran for this class: verify the claims and sources yourself."
3641
+ : profile.name === "general"
3642
+ ? "No check ran for this class: verify the outcome yourself."
3643
+ : "No check ran for this class: run the affected tests yourself.";
3644
+ const checkResult = check ? `Check result: ${(check.note ?? "").split("\n")[0]}` : noCheckWording;
3645
+ const checklistTemplates = profile.review_checklist ?? [
3646
+ "Map the changed signatures and call sites to {target}, and name each one that does not match.",
3647
+ "{check}",
3648
+ "List what is unmet in hub_review's unmet, one item each.",
3649
+ ];
3650
+ const checklistLines = checklistTemplates.map(template =>
3651
+ template.replaceAll("{target}", target).replaceAll("{check}", checkResult)
3652
+ );
3286
3653
  const checklist = [
3287
3654
  "Checklist:",
3288
3655
  ...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.`,
3656
+ ...checklistLines.map(line => `- ${line}`),
3292
3657
  ].join("\n");
3293
3658
  // #223 R2: a reviewer-only instance judges from facts; the builder's narrative stays out, earlier findings go in.
3294
3659
  const reviewOnly = this.d.reviewOnly?.(reviewer) === true;
@@ -3345,6 +3710,29 @@ export class Tasks {
3345
3710
  return next;
3346
3711
  }
3347
3712
 
3713
+ private codeSymbolsRepository(task: Task) {
3714
+ try {
3715
+ const repository = this.repositories.scope(task.refs);
3716
+ if (task.sdlc?.repo && this.repositories.scope({ repo: task.sdlc.repo }).key !== repository.key) return undefined;
3717
+ return repository;
3718
+ } catch { return undefined; }
3719
+ }
3720
+
3721
+ /** Host observations are saved only with an admitted transition, never by an optional read. */
3722
+ private observedCodeSymbols(task: Task) {
3723
+ if (this.isPii(task) || !task.refs.commit) return undefined;
3724
+ const repository = this.codeSymbolsRepository(task);
3725
+ // ponytail: the brief analyzes the validated project execution root. Foreign repositories stay unknown
3726
+ // until the brief carries independent repository provenance; a model's refs.repo cannot supply that proof.
3727
+ if (!repository || repository.key !== this.repositories.project.key) return undefined;
3728
+ try {
3729
+ const brief = this.d.reviewBrief?.(task);
3730
+ const observation = brief && !("unavailable" in brief) ? buildTaskCodeSymbols(brief, repository.key, this.publicMetadataText) : undefined;
3731
+ // A resolved short ref or a stale provider result is unknown advice, never a reason to refuse done.
3732
+ return observation?.head === task.refs.commit ? observation : undefined;
3733
+ } catch { return undefined; } // Optional metadata never costs the admitted result.
3734
+ }
3735
+
3348
3736
  /** #520: the rendered review brief of a non-PII task's commit, or why there is none; empty without a commit. */
3349
3737
  briefText(task: Task): string {
3350
3738
  if (this.isPii(task) || !task.refs.commit) return "";