@staix/agent-hub 0.12.43 → 0.12.45

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 (180) hide show
  1. package/CHANGELOG.md +38 -3
  2. package/README.md +3 -3
  3. package/THIRD_PARTY_NOTICES.md +29 -0
  4. package/docs/adding-an-ai-tool.md +228 -0
  5. package/docs/agent-notes/README.md +14 -0
  6. package/docs/agent-notes/adapters.md +61 -42
  7. package/docs/agent-notes/budget.md +2 -2
  8. package/docs/agent-notes/daemon.md +16 -10
  9. package/docs/agent-notes/local-worker.md +4 -3
  10. package/docs/agent-notes/tasks.md +13 -6
  11. package/docs/agent-notes/tests.md +9 -7
  12. package/docs/operations.md +163 -12
  13. package/docs/quickstart.md +10 -2
  14. package/docs/sdlc.md +26 -8
  15. package/docs/security.md +11 -6
  16. package/docs/specs/2026-09-19-agent-hub-design.md +273 -8
  17. package/docs/verification/2026-10-21-0.12.44.md +54 -0
  18. package/docs/verification/2026-10-21-469-r1-dashboard-tokens.md +43 -0
  19. package/docs/verification/2026-10-21-469-r2-dashboard-shell.md +54 -0
  20. package/docs/verification/2026-10-21-469-r3-dashboard-board.md +51 -0
  21. package/docs/verification/2026-10-21-469-r4-dashboard-views.md +77 -0
  22. package/docs/verification/2026-10-21-469-r6-dashboard-motion-states.md +82 -0
  23. package/docs/verification/2026-10-21-grok-config-acp-t0.md +30 -0
  24. package/docs/verification/2026-10-22-0.12.45.md +60 -0
  25. package/docs/verification/assets/469-r1-board-1440-dark.png +0 -0
  26. package/docs/verification/assets/469-r1-board-1440-light.png +0 -0
  27. package/docs/verification/assets/469-r1-board-390-dark.png +0 -0
  28. package/docs/verification/assets/469-r1-board-390-light.png +0 -0
  29. package/docs/verification/assets/469-r2-agents-1440-dark.png +0 -0
  30. package/docs/verification/assets/469-r2-agents-768-dark.png +0 -0
  31. package/docs/verification/assets/469-r2-board-1024-light.png +0 -0
  32. package/docs/verification/assets/469-r2-board-1440-dark.png +0 -0
  33. package/docs/verification/assets/469-r2-board-1440-light.png +0 -0
  34. package/docs/verification/assets/469-r2-board-390-dark.png +0 -0
  35. package/docs/verification/assets/469-r2-board-390-light.png +0 -0
  36. package/docs/verification/assets/469-r2-board-768-light.png +0 -0
  37. package/docs/verification/assets/469-r3-board-1440-dark.png +0 -0
  38. package/docs/verification/assets/469-r3-board-1440-light.png +0 -0
  39. package/docs/verification/assets/469-r3-board-390-light.png +0 -0
  40. package/docs/verification/assets/469-r3-detail-1440-dark.png +0 -0
  41. package/docs/verification/assets/469-r3-detail-1440-light.png +0 -0
  42. package/docs/verification/assets/469-r3-detail-390-dark.png +0 -0
  43. package/docs/verification/assets/469-r3-plan-1440-light.png +0 -0
  44. package/docs/verification/assets/469-r3-plan-390-light.png +0 -0
  45. package/docs/verification/assets/469-r4-agents-1440-dark.png +0 -0
  46. package/docs/verification/assets/469-r4-agents-1440-light.png +0 -0
  47. package/docs/verification/assets/469-r4-agents-390-dark.png +0 -0
  48. package/docs/verification/assets/469-r4-memory-1440-dark.png +0 -0
  49. package/docs/verification/assets/469-r4-memory-1440-light.png +0 -0
  50. package/docs/verification/assets/469-r4-memory-390-dark.png +0 -0
  51. package/docs/verification/assets/469-r4-message-1440-light.png +0 -0
  52. package/docs/verification/assets/469-r4-office-1440-dark.png +0 -0
  53. package/docs/verification/assets/469-r4-office-1440-light.png +0 -0
  54. package/docs/verification/assets/469-r4-settings-1440-dark.png +0 -0
  55. package/docs/verification/assets/469-r4-settings-1440-light.png +0 -0
  56. package/docs/verification/assets/469-r4-stream-1440-light.png +0 -0
  57. package/docs/verification/assets/469-r4-stream-390-light.png +0 -0
  58. package/docs/verification/assets/469-r6-agents-empty-390-dark.png +0 -0
  59. package/docs/verification/assets/469-r6-agents-empty-390-light.png +0 -0
  60. package/docs/verification/assets/469-r6-board-1440-light.png +0 -0
  61. package/docs/verification/assets/469-r6-board-390-dark.png +0 -0
  62. package/docs/verification/assets/469-r6-board-empty-1440-dark.png +0 -0
  63. package/docs/verification/assets/469-r6-board-empty-1440-light.png +0 -0
  64. package/docs/verification/assets/469-r6-disconnected-1440-dark.png +0 -0
  65. package/docs/verification/assets/469-r6-disconnected-1440-light.png +0 -0
  66. package/docs/verification/assets/469-r6-disconnected-390-dark.png +0 -0
  67. package/docs/verification/assets/469-r6-skeleton-1440-light.png +0 -0
  68. package/docs/verification/assets/469-r6-skeleton-390-dark.png +0 -0
  69. package/docs/verification/assets/469-r6-toasts-1440-dark.png +0 -0
  70. package/docs/verification/assets/469-r6-toasts-1440-light.png +0 -0
  71. package/docs/verification/assets/469-r6-toasts-390-light.png +0 -0
  72. package/docs/verified.json +38 -20
  73. package/package.json +5 -1
  74. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  75. package/plugins/agent-hub/server.js +10287 -9560
  76. package/src/adapters/acp-owned-group.ts +124 -0
  77. package/src/adapters/acp.ts +92 -25
  78. package/src/adapters/adapter-api.ts +2 -0
  79. package/src/adapters/agy/definition.ts +4 -4
  80. package/src/adapters/agy/entry.ts +4 -1
  81. package/src/adapters/channel-peer.ts +143 -0
  82. package/src/adapters/claude-channel.ts +21 -12
  83. package/src/adapters/codex-appserver.ts +17 -2
  84. package/src/adapters/configured-acp.ts +124 -0
  85. package/src/adapters/conformance/acp.ts +114 -0
  86. package/src/adapters/conformance/codex.ts +191 -0
  87. package/src/adapters/conformance/enrollment.ts +43 -0
  88. package/src/adapters/conformance/factories.ts +17 -0
  89. package/src/adapters/conformance/fake-paths.ts +12 -0
  90. package/src/adapters/conformance/fakes/acp-server.ts +267 -0
  91. package/src/adapters/conformance/fakes/acp-watchdog-agent.ts +10 -0
  92. package/src/adapters/conformance/fakes/app-server.ts +119 -0
  93. package/src/adapters/conformance/fakes/codex-bin.ts +15 -0
  94. package/src/adapters/conformance/fakes/jcode-agent.ts +91 -0
  95. package/src/adapters/conformance/fakes/model-server.ts +39 -0
  96. package/src/adapters/conformance/fakes/pi-rpc.ts +32 -0
  97. package/src/adapters/conformance/index.ts +11 -0
  98. package/src/adapters/conformance/kit.ts +50 -0
  99. package/src/adapters/conformance/local-pi.ts +100 -0
  100. package/src/adapters/conformance/owned-acp.ts +44 -0
  101. package/src/adapters/definitions/builtin-metadata.ts +60 -39
  102. package/src/adapters/definitions/native-channel-state.ts +41 -0
  103. package/src/adapters/definitions/native-cli-entry-kimi.ts +9 -1
  104. package/src/adapters/definitions/native-cli-entry.ts +7 -19
  105. package/src/adapters/definitions/native-cli-preview-qwen.ts +1 -1
  106. package/src/adapters/definitions/native-cli-preview.ts +2 -11
  107. package/src/adapters/definitions/native-identity-defaults.ts +14 -0
  108. package/src/adapters/definitions/qwen.ts +7 -9
  109. package/src/adapters/definitions/reviewer-variants.ts +3 -7
  110. package/src/adapters/external-metadata.ts +48 -0
  111. package/src/adapters/external-module-scan.ts +126 -0
  112. package/src/adapters/external-modules.ts +240 -0
  113. package/src/adapters/jcode.ts +4 -0
  114. package/src/adapters/local-worker.ts +4 -0
  115. package/src/adapters/pi.ts +6 -0
  116. package/src/adapters/public.ts +7 -0
  117. package/src/adapters/registry-types.ts +28 -16
  118. package/src/adapters/registry.ts +115 -17
  119. package/src/adapters/reviewer-print.ts +4 -0
  120. package/src/cli/agy-mcp.ts +3 -2
  121. package/src/cli/configured-acp-entry.ts +58 -0
  122. package/src/cli/console-state.ts +9 -4
  123. package/src/cli/facts-hook.ts +2 -1
  124. package/src/cli/help.ts +27 -16
  125. package/src/cli/identity.ts +8 -7
  126. package/src/cli/launch.ts +5 -4
  127. package/src/cli/main.ts +101 -32
  128. package/src/cli/memory.ts +13 -10
  129. package/src/cli/output.ts +1 -0
  130. package/src/cli/preview.ts +9 -0
  131. package/src/cli/recovery-guidance.ts +1 -1
  132. package/src/cli/terminal-recovery.ts +16 -13
  133. package/src/cli/upgrade-labels.ts +1 -1
  134. package/src/cli/upgrade-presentation.ts +1 -1
  135. package/src/cli/upgrade-restore-state.ts +6 -4
  136. package/src/cli/upgrade-runtime.ts +25 -7
  137. package/src/cli/upgrade.ts +3 -2
  138. package/src/hub/board.ts +37 -8
  139. package/src/hub/budget.ts +9 -2
  140. package/src/hub/bus.ts +6 -2
  141. package/src/hub/claude-state.ts +2 -37
  142. package/src/hub/conductor.ts +8 -6
  143. package/src/hub/config-apply.ts +37 -17
  144. package/src/hub/config-reload.ts +42 -11
  145. package/src/hub/config-trust.ts +1 -1
  146. package/src/hub/context-window.ts +6 -0
  147. package/src/hub/control-client.ts +4 -1
  148. package/src/hub/crash.ts +8 -6
  149. package/src/hub/daemon.ts +412 -328
  150. package/src/hub/facts.ts +76 -13
  151. package/src/hub/hub-tools.ts +57 -5
  152. package/src/hub/instance-config.ts +33 -12
  153. package/src/hub/instance-execution.ts +23 -11
  154. package/src/hub/instances.ts +47 -22
  155. package/src/hub/lifecycle-schema.ts +5 -3
  156. package/src/hub/memory-profile.ts +16 -3
  157. package/src/hub/no-writes.ts +50 -0
  158. package/src/hub/peers.ts +9 -0
  159. package/src/hub/permission-mode.ts +9 -7
  160. package/src/hub/plan-conformance.ts +83 -0
  161. package/src/hub/review-findings.ts +104 -0
  162. package/src/hub/roster.ts +35 -6
  163. package/src/hub/routing.ts +21 -20
  164. package/src/hub/sdlc-queue.ts +19 -4
  165. package/src/hub/sdlc-release.ts +16 -1
  166. package/src/hub/sdlc.ts +27 -12
  167. package/src/hub/settings.ts +54 -16
  168. package/src/hub/snapshots.ts +32 -6
  169. package/src/hub/start-mode.ts +10 -8
  170. package/src/hub/task-sweep.ts +8 -2
  171. package/src/hub/tasks.ts +358 -60
  172. package/src/hub/ui.ts +2 -2
  173. package/src/memory/capture.ts +2 -1
  174. package/src/ui/index.html +563 -77
  175. package/src/ui/office-model.ts +3 -1
  176. package/src/ui/peer-palette.ts +44 -0
  177. package/templates/AGENTS.block.md +1 -1
  178. package/templates/profiles/general.json +1 -1
  179. package/templates/profiles/research.json +1 -1
  180. package/templates/profiles/writing.json +1 -1
package/src/hub/tasks.ts CHANGED
@@ -1,12 +1,15 @@
1
+ import { completePlanInventory, planConformance, planConformanceBlockers, planFingerprint, type PlanConformance } from "./plan-conformance.ts";
1
2
  import { RELEASE_TRAIN_HISTORY_LIMIT, releaseTrainData, releaseTrainReadiness, releaseMetrics } from "./sdlc-release.ts";
2
3
  import { cleanCostPlan, costMetrics, type CostPlan, type ReviewCost } from "./cost-governance.ts";
3
4
  import { specRefs } from "./spec-read.ts";
4
5
  import type { InstancesConfig } from "./instances.ts";
5
6
  import { composeRememberText } from "./memory-fail.ts";
6
7
  import { VERSION } from "../version.ts";
8
+ import { NoWriteTurns } from "./no-writes.ts";
7
9
  import { readGitHubImport, type GitHubSource, type GitHubImportSelection } from "./github-import.ts";
8
10
  import { BACKLOG_SPLIT_SCREEN_TIMEOUT_MS, actualTaskDates, planningPatch, type PlanningFields, type Release, type ReleaseState } from "./planning.ts";
9
11
  import { readMergeQueue } from "./sdlc-queue.ts";
12
+ import { DEFAULT_MAX_NITS, parseReviewFindings, findingsForHead, findingHead as resolveFindingHead, findingTally, type ReviewFinding, type ReviewFindingSet } from "./review-findings.ts";
10
13
  import { readReleaseReadiness } from "./sdlc-release.ts";
11
14
  import { loadRoleCatalog, taskRole, taskRoleText, type RoleCatalog } from "./roles.ts";
12
15
  import { matchesPolicy } from "./path-policy.ts";
@@ -31,7 +34,8 @@ import { TaskRepositories } from "./task-repository.ts";
31
34
  import { mainCheckoutWarning } from "./main-checkout.ts";
32
35
  import { PreEffectToolRefusal } from "./tool-refusal.ts";
33
36
  import { coverageGap, renderReviewBrief, type ReviewBrief } from "./review-brief.ts";
34
- import { ADAPTERS, DEFAULT_REVIEW_POOL, definitionOf, familyOf } from "../adapters/registry.ts";
37
+ import { definitionOf as builtinDefinitionOf } from "../adapters/registry.ts";
38
+ import { ADAPTERS, DEFAULT_REVIEW_POOL, definitionOf, familyOf, type ProjectAdapterCatalog } from "../adapters/registry.ts";
35
39
  import { plannerGrooming, nextSweep, taskSweepConfig, type TaskSweepConfig, type SweepRecord } from "./task-sweep.ts";
36
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";
37
41
 
@@ -40,6 +44,7 @@ export type TaskAssignmentRole = "owner" | "reviewer";
40
44
  export interface ReleaseWindow { at: number; host: boolean; quota: boolean; idle: boolean; reasons: string[]; current?: () => boolean; close?: () => Promise<void>; }
41
45
 
42
46
  export interface TasksDeps {
47
+ adapterCatalog?: ProjectAdapterCatalog;
43
48
  /** Host-bound evidence only; absent/expired observations never admit a release cut. */
44
49
  releaseWindow?: () => Promise<ReleaseWindow>;
45
50
  /** Effective per-peer planner capacity, taken from the existing settings loader. */
@@ -62,6 +67,10 @@ export interface TasksDeps {
62
67
  sessionRoles?: (peer: PeerId) => string[];
63
68
  /** Optional bounded snapshot presentation; never stored in task history or memory. */
64
69
  projectedReview?: (task: Task) => string | undefined;
70
+ /** Closed host evidence since the current acceptance: false means proven no writes; undefined is unknown. */
71
+ ownerWrites?: (task: Task, since: number) => boolean | undefined;
72
+ /** Explicit acceptance/self-claim only; implicit done has no pre-work baseline. */
73
+ ownerAccepted?: (task: Task) => void;
65
74
  /** #520: the review brief for a task's commit (git only, bounded); undefined when the task names no commit. */
66
75
  reviewBrief?: (task: Task) => ReviewBrief | { unavailable: string } | undefined;
67
76
  sdlc?: { config: () => SdlcConfig; gh?: SdlcGh };
@@ -167,6 +176,7 @@ const CONTRADICTION_WINDOW_MS = 7 * 86_400_000;
167
176
  const OPEN: Task["state"][] = ["proposed", "in_progress", "changes_requested"];
168
177
  /** Board events that leave a task where its completion check found it; any other event means it moved on meanwhile. */
169
178
  const QUIET_EVENTS = new Set(["answer", "reviewer changed", "idle sweep", "memory reference started"]);
179
+ QUIET_EVENTS.add("plan observed"); // Host observation does not move the task or replace completion intent.
170
180
 
171
181
  /**
172
182
  * Thrown when an assignment's board write is saved but a publish or notify after it failed (#297): the move is
@@ -243,9 +253,17 @@ function acceptRefs(input: unknown): TaskRefs | undefined {
243
253
  /** The same for a plan (issue #31): only its four lists, each of short strings. */
244
254
  function cleanPlan(input: unknown, profile: ProfileSelection = "dev"): TaskPlan {
245
255
  const r = fields(input);
256
+ const evidence: Pick<TaskPlan, "proof" | "risks"> = {};
257
+ for (const key of ["proof", "risks"] as const) {
258
+ const value = r[key];
259
+ if (value === undefined) continue;
260
+ if (!Array.isArray(value) || value.length > 32 || value.some(item => typeof item !== "string" || item.length > 512)) throw new PreEffectToolRefusal(`plan.${key}: expected at most 32 strings of at most 512 characters`);
261
+ const items = value.map(item => item.replace(/\s+/g, " ").trim()).filter(Boolean);
262
+ if (items.length) evidence[key] = items;
263
+ }
246
264
  let scope: CostPlan;
247
265
  try { scope = cleanCostPlan(r); } catch { throw new PreEffectToolRefusal("invalid review or CI cost scope"); }
248
- return { ...Object.fromEntries((profileData(profile).plan_keys as (keyof TaskPlan)[]).map((k) => [k, k === "paths" ? [...new Set(textList(r[k]).map(normPath))] : textList(r[k])] as const).filter(([, v]) => v.length)), ...scope };
266
+ return { ...Object.fromEntries((profileData(profile).plan_keys as (keyof TaskPlan)[]).map((k) => [k, k === "paths" ? [...new Set(textList(r[k]).map(normPath))] : textList(r[k])] as const).filter(([, v]) => v.length)), ...evidence, ...scope };
249
267
  }
250
268
 
251
269
  /** A plan as one line for other owners; the whole plan is on the board. */
@@ -269,9 +287,11 @@ export class Tasks {
269
287
  readonly cohorts: Cohorts;
270
288
  private readonly coordinationModes = new Map<number, boolean>();
271
289
  private sweepConfig: TaskSweepConfig;
290
+ private readonly noWriteTurns = new NoWriteTurns();
272
291
  private sweeping = false;
273
292
 
274
293
  constructor(private readonly d: TasksDeps) {
294
+ d.board.trackPlanRevisions = () => (d.sdlc?.config().mode ?? "off") !== "off";
275
295
  this.repositories = new TaskRepositories(d.cwd, d.repository);
276
296
  for (const task of d.board.list()) this.warmRepository(task);
277
297
  if (d.coordination) {
@@ -428,6 +448,57 @@ export class Tasks {
428
448
  /** A validated snapshot takes effect on the next sweep, never halfway through one. */
429
449
  configureSweep(config: TaskSweepConfig): void {
430
450
  this.sweepConfig = taskSweepConfig(config);
451
+ if (this.sweepConfig.no_writes_turns === 0) {
452
+ this.noWriteTurns.retain([]);
453
+ for (const stall of this.d.board.stalls()) if (stall.cause.kind === "no-writes") this.d.board.clearStall(stall.task);
454
+ }
455
+ }
456
+
457
+ private ownerAcceptance(task: Task, first = false): { index: number; at: number } | undefined {
458
+ if (!task.owner) return undefined;
459
+ const boundary = task.history.findLastIndex(entry => OWNERSHIP_EVENTS.has(entry.event) || entry.owner !== undefined && entry.owner !== task.owner);
460
+ const accepted = (entry: Task["history"][number], index: number) => index > boundary && entry.event === "accepted" && (entry.by === task.owner || entry.by === USER);
461
+ const index = first ? task.history.findIndex(accepted) : task.history.findLastIndex(accepted);
462
+ return index > boundary && index >= 0 ? { index, at: task.history[index]!.at } : undefined;
463
+ }
464
+
465
+ /** Captured at native turn start, not supplied by a model. */
466
+ ownerTurnTasks(peer: PeerId): { id: number; acceptance: number }[] {
467
+ return this.d.board.list("in_progress").flatMap(task => {
468
+ const accepted = task.owner === peer && ["implement", "bulk_edit"].includes(task.class) ? this.ownerAcceptance(task) : undefined;
469
+ return accepted ? [{ id: task.id, acceptance: accepted.index }] : [];
470
+ });
471
+ }
472
+
473
+ recordOwnerTurn(peer: PeerId, bindings: readonly { id: number; acceptance: number }[], changed: boolean | undefined, effect: boolean): void {
474
+ this.noWriteTurns.retain(this.d.board.list("in_progress").filter(task => ["implement", "bulk_edit"].includes(task.class)).map(task => task.id));
475
+ const current = new Map(this.ownerTurnTasks(peer).map(binding => [binding.id, binding.acceptance]));
476
+ for (const binding of bindings) {
477
+ if (current.get(binding.id) !== binding.acceptance) continue;
478
+ const task = this.d.board.get(binding.id)!;
479
+ const limit = this.sweepConfig.no_writes_turns ?? 2;
480
+ if (changed !== false || effect || limit === 0) {
481
+ if (this.d.board.stalls().some(stall => stall.task === task.id && stall.cause.kind === "no-writes")) this.d.board.clearStall(task.id);
482
+ }
483
+ const count = this.noWriteTurns.observe({ task: task.id, generation: `${task.created}:${peer}:${binding.acceptance}`, limit, changed, effect });
484
+ if (count === undefined) continue;
485
+ this.reportStall(task, { kind: "no-writes", code: "no-writes", peer, turns: count, acceptance: binding.acceptance }, Date.now(),
486
+ `Task ${this.publicTitle(task)} stalled: ${count} consecutive owner turns with no writes or task tool effects recorded.`);
487
+ }
488
+ }
489
+
490
+ private captureOwnerAcceptance(task: Task): void {
491
+ try { this.d.ownerAccepted?.(task); } catch { /* advisory capture cannot fail a saved acceptance */ }
492
+ }
493
+
494
+ /** Host evidence spans the first acceptance after the latest ownership change, including plan revisions. */
495
+ ownerWriteAcceptance(task: Task): { index: number; at: number } | undefined { return this.ownerAcceptance(task, true); }
496
+
497
+ private noWritesRecorded(task: Task): boolean {
498
+ if (!["implement", "bulk_edit"].includes(task.class) || task.refs.branch || this.reviewHead(task) || task.sdlc?.pr) return false;
499
+ const accepted = this.ownerWriteAcceptance(task);
500
+ if (!accepted) return false;
501
+ try { return this.d.ownerWrites?.(task, accepted.at) === false; } catch { return false; }
431
502
  }
432
503
 
433
504
  private sweepAvailable(peer: PeerId): boolean {
@@ -467,14 +538,59 @@ export class Tasks {
467
538
  /** What peers other than the owner, the console stream and the log may see of a task. */
468
539
  publicTitle = (task: Task) => (this.isPii(task) ? `#${task.id} [pii]` : `#${task.id} ${task.title}`);
469
540
 
541
+ private reviewFindingSets(task: Task): ReviewFindingSet[] { return task.history.flatMap(entry => entry.reviewFindings ? [entry.reviewFindings] : []); }
542
+
543
+ private findingHead(task: Task, reviewedHead?: string): string | undefined {
544
+ return resolveFindingHead(task.sdlc, reviewedHead ? { head: reviewedHead } : undefined, task.refs.commit);
545
+ }
546
+
547
+ private findingTextAllowed(task: Task, items: readonly ReviewFinding[]): boolean {
548
+ return this.candidateTextAllowed(JSON.stringify(items), task)
549
+ && items.every(item => !item.path || !matchesPolicy(this.d.cwd, item.path, this.d.piiPaths ?? [], true));
550
+ }
551
+
552
+ /** Host-authored proposals contain only cleared IDs, integer task references and fixed instruction targets. */
553
+ private proposeRecurringFindings(task: Task, by: PeerId, items: readonly ReviewFinding[], withheld: boolean): void {
554
+ if (withheld || this.isPii(task) || this.d.privateTurn?.(by) || !items.length || !this.findingTextAllowed(task, items)) return;
555
+ const rows = this.d.board.list();
556
+ const keys = new Set(rows.filter(row => row.class === "plan" && row.history[0]?.by === HUB)
557
+ .flatMap(row => row.detail?.split("\n").filter(line => line.startsWith("Review finding key: ")) ?? []));
558
+ const created: number[] = [];
559
+ // No await separates the durable dedupe read from creation; concurrent verdicts see the first proposal.
560
+ for (const item of items.filter(item => item.disposition !== "false_positive")) {
561
+ const same = (finding: ReviewFinding) => finding.id === item.id && finding.pass === item.pass;
562
+ const holders = rows.filter(row => {
563
+ if (this.isPii(row)) return false;
564
+ const last = row.history.findLast(entry => entry.reviewFindings?.items.some(value => value.id === item.id));
565
+ const finding = last?.reviewFindings?.items.find(value => value.id === item.id);
566
+ return !last?.withheld && finding && same(finding) && finding.disposition !== "false_positive" && this.findingTextAllowed(row, [finding]);
567
+ });
568
+ if (new Set(holders.map(row => row.id)).size < 2) continue;
569
+ const key = `Review finding key: ${item.pass}:${item.id}`;
570
+ if (keys.has(key)) continue;
571
+ const title = `Review policy amendment for recurring finding ${item.pass}:${item.id}`;
572
+ const detail = `${key}\nFinding ${item.pass}:${item.id} recurred on tasks ${holders.slice(0, 8).map(row => `#${row.id}`).join(", ")}. Inspect their review histories and propose an AGENTS.md or relevant docs/agent-notes/ amendment. This backlog card is a proposal, not authorization to edit instruction files.`;
573
+ const draft = { title, detail, refs: { paths: ["AGENTS.md", "docs/agent-notes/"] } };
574
+ const signals = this.signalsFor(draft);
575
+ if (this.isPii({ signals }) || !this.candidateTextAllowed(`${title}\n${detail}`)) continue;
576
+ const proposal = this.d.board.propose(HUB, { ...draft, class: "plan", backlog: true, signals });
577
+ this.incomingBacklog.add(proposal.id); keys.add(key); created.push(proposal.id);
578
+ }
579
+ for (const id of created) this.releaseNotice(`task #${id}: recurring review finding amendment proposed in the backlog`);
580
+ }
581
+
470
582
  /** A task as a cloud peer may see it. */
583
+
471
584
  publicView(task: Task, fullHistory = false): Record<string, unknown> {
472
- const { title, detail, history, external, cancelled, ...rest } = task;
585
+ const { title, detail, history, external, cancelled, ...taskRest } = task;
586
+ const rest = { ...taskRest, ...(history.some(h => h.by === USER && h.planDeparture) ? { planDepartureAccepted: true } : {}) };
473
587
  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]" };
474
588
  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}]`;
475
589
  const publicHistory = (fullHistory ? history : history.slice(-5)).map(h => ({
476
590
  at: h.at, by: h.by, event: h.event,
477
591
  ...(h.note === undefined ? {} : { note: screenHistoryText(h.note, "note", h.withheld) }),
592
+ ...(h.reviewFindings ? { reviewFindings: !h.withheld && this.findingTextAllowed(task, h.reviewFindings.items) ? h.reviewFindings
593
+ : { ...(h.reviewFindings.head ? { head: h.reviewFindings.head } : {}), tally: findingTally(h.reviewFindings.items), withheld: true } } : {}),
478
594
  ...(h.supersededBy === undefined ? {} : { supersededBy: screenHistoryText(h.supersededBy, "supersession", h.withheld) }),
479
595
  ...(h.profile === undefined ? {} : { profile: screenHistoryText(h.profile, "profile") }),
480
596
  ...(h.owner === undefined ? {} : { owner: h.owner }),
@@ -486,7 +602,8 @@ export class Tasks {
486
602
  ...(h.reason && TASK_MOVE_REASONS.includes(h.reason) ? { reason: h.reason } : {}),
487
603
  ...(h.sweep ? { sweep: { kind: h.sweep.kind, activity: h.sweep.activity, step: h.sweep.step, at: h.sweep.at } } : {}),
488
604
  }));
489
- return { ...rest, ...(cancelled ? { cancelled: { ...cancelled, reason: screenHistoryText(cancelled.reason, "cancellation", cancelled.withheld), ...(cancelled.supersededBy ? { supersededBy: screenHistoryText(cancelled.supersededBy, "supersession", cancelled.withheld) } : {}) } } : {}), ...(external && this.nameable(external.url) ? { external } : {}), actualDates: actualTaskDates(task), ...(((this.d.sdlc?.config().mode ?? "off") !== "off" || this.d.sdlc?.config().workflow === "gsd-lite") ? { lifecycle: sdlcStatus(this.d.sdlc?.config() ?? sdlcConfig(undefined), task.sdlc, undefined, this.workflowView(task)) } : {}), title, detail, history: publicHistory };
605
+ 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 };
490
607
  }
491
608
 
492
609
  /** Console results expose direct approval and delivery holds, not just the chosen peer ids. */
@@ -558,9 +675,9 @@ export class Tasks {
558
675
  /** Workload is board/queue data only: wip() never calls health() or weights(). */
559
676
  private instanceRouting(): InstanceRoutingOptions {
560
677
  const instances = this.d.instances?.();
561
- if (!instances || !Object.keys(instances).length) return {};
562
- const peers = [...new Set([...ADAPTERS.map(adapter => adapter.kind), ...Object.keys(instances), ...this.d.bus.peers.keys()])];
563
- return { instances, ownerIsolation: this.d.ownerIsolation?.() ?? {}, wip: Object.fromEntries(peers.map(peer => [peer, this.wip(peer)])), load: Object.fromEntries(peers.map(peer => [peer, this.d.bus.queued(peer)])) };
678
+ if (!instances || !Object.keys(instances).length) return { adapterCatalog: this.d.adapterCatalog };
679
+ const peers = [...new Set([...(this.d.adapterCatalog?.definitions ?? ADAPTERS).map(adapter => adapter.kind), ...Object.keys(instances), ...this.d.bus.peers.keys()])];
680
+ return { adapterCatalog: this.d.adapterCatalog, instances, ownerIsolation: this.d.ownerIsolation?.() ?? {}, wip: Object.fromEntries(peers.map(peer => [peer, this.wip(peer)])), load: Object.fromEntries(peers.map(peer => [peer, this.d.bus.queued(peer)])) };
564
681
  }
565
682
 
566
683
  /** Peer health and roles the daemon feeds routing (issues #89, #90, #92): failing peers are skipped, held queues explained. */
@@ -568,8 +685,8 @@ export class Tasks {
568
685
  const failing = this.d.failing?.();
569
686
  const held = this.d.held?.();
570
687
  const workload = this.instanceRouting();
571
- const peers = workload.instances ? [...new Set([...ADAPTERS.map(adapter => adapter.kind), ...Object.keys(workload.instances), ...this.d.bus.peers.keys()])] : ADAPTERS.map(adapter => adapter.kind);
572
- return { ...workload, reviewPool: { pool: this.d.review?.pool ?? DEFAULT_REVIEW_POOL, rerotate: this.d.review?.rerotate, required: this.d.review?.pool !== undefined }, ...(this.d.capabilities ? { capabilities: this.d.capabilities(), repository: this.d.repository } : {}), pullOnly: [...this.d.bus.peers.values()].filter(peer => definitionOf(peer.id, workload.instances)?.attach?.pullOnlyForms?.length && peer.pullOnly).map(peer => peer.id), offRoster: peers.filter(peer => !this.d.bus.isRostered(peer)), ...(failing ? { failing } : {}), ...(held ? { held } : {}), ...(this.d.roles ? { roles: this.d.roles } : {}) };
688
+ const peers = workload.instances ? [...new Set([...(this.d.adapterCatalog?.definitions ?? ADAPTERS).map(adapter => adapter.kind), ...Object.keys(workload.instances), ...this.d.bus.peers.keys()])] : (this.d.adapterCatalog?.definitions ?? ADAPTERS).map(adapter => adapter.kind);
689
+ return { ...workload, reviewPool: { pool: this.d.review?.pool ?? DEFAULT_REVIEW_POOL, rerotate: this.d.review?.rerotate, required: this.d.review?.pool !== undefined }, ...(this.d.capabilities ? { capabilities: this.d.capabilities(), repository: this.d.repository } : {}), pullOnly: [...this.d.bus.peers.values()].filter(peer => (this.d.adapterCatalog?.definitionOf ?? definitionOf)(peer.id, workload.instances)?.attach?.pullOnlyForms?.length && peer.pullOnly).map(peer => peer.id), offRoster: peers.filter(peer => !this.d.bus.isRostered(peer)), ...(failing ? { failing } : {}), ...(held ? { held } : {}), ...(this.d.roles ? { roles: this.d.roles } : {}) };
573
690
  }
574
691
 
575
692
  private reviewHead(task: Task): string | undefined { return task.refs.commit ?? task.sdlc?.head ?? task.sdlc?.localHead; }
@@ -595,7 +712,7 @@ export class Tasks {
595
712
 
596
713
  private reviewMetadata(assignment?: Assignment): ReviewerHistory {
597
714
  const choice = assignment?.reviewSelection;
598
- const family = choice && assignment?.plannerFallback && assignment.reviewer ? familyOf(assignment.reviewer, this.d.instances?.()) : choice?.family;
715
+ const family = choice && assignment?.plannerFallback && assignment.reviewer ? (this.d.adapterCatalog?.familyOf ?? familyOf)(assignment.reviewer, this.d.instances?.()) : choice?.family;
599
716
  const reason = choice && assignment?.plannerFallback
600
717
  ? assignment.sameFamilyFallback ? "same-family fallback" as const : "unavailable skip" as const
601
718
  : choice?.reason;
@@ -604,7 +721,7 @@ export class Tasks {
604
721
 
605
722
  /** Capability admission precedes explicit assignments/reservations, independent of readiness or transient peer health. */
606
723
  private requireCapabilities(task: Parameters<typeof assign>[0], peer: PeerId, role: TaskAssignmentRole = "owner", preEffect = true): void {
607
- const instances = this.d.instances?.(), adapter = definitionOf(peer, instances);
724
+ const instances = this.d.instances?.(), adapter = (this.d.adapterCatalog?.definitionOf ?? definitionOf)(peer, instances);
608
725
  const declaredExtra = !!adapter && adapter.kind !== peer;
609
726
  if ((!this.d.capabilities || this.isPii(task)) && !declaredExtra) return;
610
727
  const { state: _state, ...draft } = task; // backlog/readiness must not bypass an explicit reservation's admission
@@ -931,6 +1048,7 @@ export class Tasks {
931
1048
  if (!returnedUntouched) {
932
1049
  if (task.state !== "backlog" || taskHasStarted(task) || task.owner) throw new PreEffectToolRefusal("dependency and reservation changes require unstarted backlog work");
933
1050
  }
1051
+ const definitionOf = this.d.adapterCatalog?.definitionOf ?? builtinDefinitionOf;
934
1052
  if (patch.reserved && !definitionOf(patch.reserved, this.d.instances?.()) && !this.d.bus.knownPeers().includes(patch.reserved) && !this.d.routing().classes[task.class]?.peers.includes(patch.reserved)) throw new PreEffectToolRefusal("reserved owner is not a supported or configured peer");
935
1053
  if (patch.reserved && !this.d.bus.isRostered(patch.reserved)) throw new PreEffectToolRefusal("reserved owner is off roster");
936
1054
  // #531 D3: a reservation for a peer routing leaves out for good could never be honoured. Say so, and who can lift it.
@@ -1888,6 +2006,7 @@ export class Tasks {
1888
2006
  }
1889
2007
  if (stagedClaimGap) return next; // An explicit claim may reserve its owner, but no offer or acceptance bypasses review.
1890
2008
  const claimed = this.d.board.update(next.id, by, "accepted", { state: "in_progress" });
2009
+ this.captureOwnerAcceptance(claimed);
1891
2010
  this.releaseNotice(`task ${this.publicTitle(claimed)} claimed by ${by}`);
1892
2011
  return claimed;
1893
2012
  }
@@ -1942,7 +2061,7 @@ export class Tasks {
1942
2061
  const pii = this.isPii(task), delivery = { ...a, reviewer: task.reviewer ?? undefined };
1943
2062
  const rejected = task.history.filter((h) => h.event === "changes_requested").map((h) => `- ${h.by}: ${this.screen(task, h.note ?? "", "review note", delivery.owner, h.withheld)}`);
1944
2063
  const handoff = context && !pii ? this.screen(task, context, "handoff", delivery.owner, contextWithheld).slice(0, 3000) : "";
1945
- const facts = [`class ${profileTaskLabel(task)}`, delivery.owner && definitionOf(delivery.owner, this.d.instances?.())?.routing?.backendLabel ? `backend ${definitionOf(delivery.owner, this.d.instances?.())!.routing!.backendLabel}/${delivery.piBackend ?? "dgx"}` : "", task.refs.paths?.length ? `paths ${task.refs.paths.join(", ")}` : "", task.refs.branch ? `branch ${task.refs.branch}` : "", delivery.reviewer ? `reviewer ${delivery.reviewer}` : task.class === "review" ? "no reviewer" : this.noReviewer(delivery)].filter(Boolean).join("; ");
2064
+ const facts = [`class ${profileTaskLabel(task)}`, delivery.owner && (this.d.adapterCatalog?.definitionOf ?? definitionOf)(delivery.owner, this.d.instances?.())?.routing?.backendLabel ? `backend ${(this.d.adapterCatalog?.definitionOf ?? definitionOf)(delivery.owner, this.d.instances?.())!.routing!.backendLabel}/${delivery.piBackend ?? "dgx"}` : "", task.refs.paths?.length ? `paths ${task.refs.paths.join(", ")}` : "", task.refs.branch ? `branch ${task.refs.branch}` : "", delivery.reviewer ? `reviewer ${delivery.reviewer}` : task.class === "review" ? "no reviewer" : this.noReviewer(delivery)].filter(Boolean).join("; ");
1946
2065
  const plan = planText(task.plan);
1947
2066
  const body = [
1948
2067
  `Task #${task.id} [${profileTaskLabel(task)}] ${task.title}`,
@@ -2042,6 +2161,7 @@ export class Tasks {
2042
2161
  const revision = this.d.board.statusRevision();
2043
2162
  if (this.stallCandidates?.revision !== revision) this.stallCandidates = { revision, rows: this.d.board.stalls().flatMap(stall => {
2044
2163
  const task = this.d.board.get(stall.task);
2164
+ if (stall.cause.kind === "no-writes") return (this.sweepConfig.no_writes_turns ?? 2) > 0 && task?.state === "in_progress" && ["implement", "bulk_edit"].includes(task.class) && task.owner === stall.cause.peer && this.ownerAcceptance(task)?.index === stall.cause.acceptance ? [{ stall, task, planned: false }] : [];
2045
2165
  if (task?.state !== "proposed" || task.owner || (task.planningRevision ?? 0) !== stall.replanRevision) return [];
2046
2166
  if (stall.cause.kind === "cancelled-dependency") return task.deps?.includes(stall.cause.dependency!) === true && this.d.board.get(stall.cause.dependency!)?.state === "cancelled" ? [{ stall, task, planned: false }] : [];
2047
2167
  return this.waitsFor(task).length ? [] : [{ stall, task, planned: this.plannerPullRecord(task)?.pull === true }];
@@ -2051,7 +2171,7 @@ export class Tasks {
2051
2171
  const queue = this.d.bus.queueSummary(peer);
2052
2172
  return [peer, this.wipLimit(peer), queue.heldBy, queue.needsReview];
2053
2173
  })]);
2054
- if (this.stallView?.key !== key) this.stallView = { key, stalls: this.stallCandidates.rows.filter(({ stall, task, planned }) => stall.cause.kind === "cancelled-dependency" || !(planned ? this.plannerAssignment(task, true) : assign(task, this.states(), this.d.routing(), { exclude: this.excluded(task), ...this.health() })).owner).map(({ stall }) => stall) };
2174
+ if (this.stallView?.key !== key) this.stallView = { key, stalls: this.stallCandidates.rows.filter(({ stall, task, planned }) => stall.cause.kind === "no-writes" || stall.cause.kind === "cancelled-dependency" || !(planned ? this.plannerAssignment(task, true) : assign(task, this.states(), this.d.routing(), { exclude: this.excluded(task), ...this.health() })).owner).map(({ stall }) => stall) };
2055
2175
  return structuredClone(this.stallView.stalls);
2056
2176
  }
2057
2177
  /** Why a proposal's named owner (a self-claim included) did not get it, from the stall its proposal recorded (#601); undefined when it did. */
@@ -2068,10 +2188,10 @@ export class Tasks {
2068
2188
  const persisted = (code: NonNullable<Assignment["refusals"]>[number]["code"]): TaskStall["cause"]["code"] => code === "instance" || code === "family" ? "policy" : code;
2069
2189
  return refusal ? { kind: "reservation", peer: refusal.peer, code: persisted(refusal.code) } : { kind: "no-eligible-peer", code: "unavailable", refusals: admission.refusals?.filter(row => row.role === "owner").map(({ peer, code }) => ({ peer, code: persisted(code) })).sort((a, b) => a.peer.localeCompare(b.peer)) ?? [] };
2070
2190
  }
2071
- private reportStall(task: Task, cause: TaskStall["cause"], now = Date.now()): void {
2191
+ private reportStall(task: Task, cause: TaskStall["cause"], now = Date.now(), description?: string): void {
2072
2192
  const stall: TaskStall = { task: task.id, cause, signature: JSON.stringify(cause), replanRevision: task.planningRevision ?? 0, at: now };
2073
2193
  if (!this.d.board.recordStall(stall)) return;
2074
- const text = `Task #${task.id} stalled: ${cause.kind}${cause.peer ? ` ${cause.peer}` : ""}${cause.dependency ? ` #${cause.dependency}` : ""} (${cause.code}); configure the runner or peer, ahub task assign ${task.id} <peer>, re-plan dependencies, or cancel it.`;
2194
+ const text = description ?? `Task #${task.id} stalled: ${cause.kind}${cause.peer ? ` ${cause.peer}` : ""}${cause.dependency ? ` #${cause.dependency}` : ""} (${cause.code}); configure the runner or peer, ahub task assign ${task.id} <peer>, re-plan dependencies, or cancel it.`;
2075
2195
  this.releaseNotice(text);
2076
2196
  try { this.d.stallNotice?.(stall); } catch { /* advisory never changes task authority */ }
2077
2197
  // Advice joins the planner's next delivery; it must not manufacture a turn beside a withheld backlog batch.
@@ -2203,7 +2323,8 @@ export class Tasks {
2203
2323
  const peers = [...new Set([...Object.keys(this.states()), ...current.flatMap(task => task.owner ? [task.owner] : [])])];
2204
2324
  const reviewedIssues = new Map(visible.flatMap(task => task.sdlc?.planReview?.issue ? [[task.id, task.sdlc.planReview.issue] as const] : []));
2205
2325
  const acceptedIssues = new Map(visible.flatMap(task => task.sdlc?.acceptance ? [[task.id, task.sdlc.acceptance] as const] : []));
2206
- const blockers: { task: number; code: string }[] = this.d.board.stalls().map(stall => ({ task: stall.task, code: stall.cause.code }));
2326
+ const currentNoWrites = new Set(this.stalled().filter(stall => stall.cause.kind === "no-writes").map(stall => stall.task));
2327
+ const blockers: { task: number; code: string }[] = this.d.board.stalls().filter(stall => stall.cause.kind !== "no-writes" || currentNoWrites.has(stall.task)).map(stall => ({ task: stall.task, code: stall.cause.code }));
2207
2328
  const health = this.health(), states = this.states(), routing = this.d.routing(), hostBlocks = this.d.plannerBlocks?.() ?? {};
2208
2329
  for (const task of visible.filter(task => !isClosedTaskState(task.state)).slice(0, 500)) {
2209
2330
  const owner = task.owner ?? task.reserved;
@@ -2254,7 +2375,10 @@ export class Tasks {
2254
2375
  for (const task of current) if (this.isPii(task)) visiblePrs.delete(task.sdlc!.pr!);
2255
2376
  if (JSON.stringify([...visiblePrs].sort((a, b) => a - b)) !== JSON.stringify(ids)) incomplete = true;
2256
2377
  const report = costMetrics(current.map(task => ({ repo, pr: task.sdlc!.pr!, private: this.isPii(task), history: task.history })), observations.filter(pr => visiblePrs.has(pr.pr)));
2257
- return { ...report, truncated: report.truncated || incomplete, ...(current.some(task => this.isPii(task)) ? { privateTasks: "withheld" } : {}), bands: "S7 pending" };
2378
+ const scoped = current.filter(task => visiblePrs.has(task.sdlc!.pr!) && !this.isPii(task));
2379
+ const scopedIds = new Set(scoped.map(task => task.id));
2380
+ const stalls = { noWrites: this.stalled().filter(stall => stall.cause.kind === "no-writes" && scopedIds.has(stall.task)).length, doneWithoutWrites: scoped.filter(task => task.history.findLast(entry => entry.event === "done")?.noWrites).length };
2381
+ return { ...report, stalls, truncated: report.truncated || incomplete, ...(current.some(task => this.isPii(task)) ? { privateTasks: "withheld" } : {}), bands: "S7 pending" };
2258
2382
  }
2259
2383
 
2260
2384
  async planReviewGap(task: Task): Promise<string | undefined> {
@@ -2343,13 +2467,16 @@ export class Tasks {
2343
2467
  const suppliedHead = !!input && typeof input === "object" && (input as { reviewedHead?: unknown }).reviewedHead !== undefined;
2344
2468
  const artifactChanged = next.repo !== task.sdlc?.repo || next.pr !== task.sdlc?.pr;
2345
2469
  if (next.reviewedHead !== undefined && (next.reviewedHead !== task.sdlc?.reviewedHead || (suppliedHead && artifactChanged))) this.mine(task, by, "reviewer", true);
2346
- return this.d.board.update(task.id, by, "lifecycle", { sdlc: next });
2470
+ const departure = task.history.findLast(h => h.by === USER && h.planDeparture)?.planDeparture;
2471
+ const invalidatesDeparture = !!departure && (artifactChanged || next.localHead !== task.sdlc?.localHead && next.localHead !== undefined && next.localHead !== departure.head || next.reviewedHead !== task.sdlc?.reviewedHead && next.reviewedHead !== undefined && next.reviewedHead !== departure.head);
2472
+ return this.d.board.update(task.id, by, "lifecycle", { sdlc: next }, undefined, { ...this.planObservation({ ...task, sdlc: next }), ...(invalidatesDeparture ? { planDepartureInvalidated: true } : {}) });
2347
2473
  }
2348
2474
 
2349
2475
  async deliveryQueue(repo: string) {
2476
+ const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
2350
2477
  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 })));
2351
- 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) }));
2352
- const report = await readMergeQueue(repo, this.d.sdlc?.config() ?? sdlcConfig(undefined), links, this.d.sdlc?.gh);
2478
+ 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);
2353
2480
  // Policy can change across remote awaits without any board write. Never return its stale public projection.
2354
2481
  const currentPrivacy = this.d.board.list().map(task => ({ id: task.id, private: this.isPii(task) }));
2355
2482
  if (workflowHash(links.map(({ id, private: hidden }) => ({ id, private: hidden }))) !== workflowHash(currentPrivacy)) {
@@ -2358,6 +2485,13 @@ export class Tasks {
2358
2485
 
2359
2486
  const after = workflowHash(this.d.board.list().map(t => ({ id: t.id, state: t.state, after: t.deps, sdlc: t.sdlc, history: t.history.length })));
2360
2487
  if (before !== after) { report.complete = false; report.order = []; if (report.rows.length) report.unordered = report.rows.map(row => row.pr); for (const row of report.rows) { row.ready = false; row.blockers.push("board changed during delivery readback"); } }
2488
+ else if (config.mode !== "off") for (const row of report.rows) if (row.head && !row.private) for (const id of row.tasks) {
2489
+ const task = this.d.board.get(id);
2490
+ if (task?.sdlc?.repo?.toLowerCase() === report.repo.toLowerCase() && task.sdlc.pr === row.pr) {
2491
+ if (this.validPlanDepartures(task).length && row.planConformance?.some(item => item.task === id && item.state !== "unknown" && !item.accepted)) this.invalidatedPlanDepartures.add(id);
2492
+ this.capturePlanHead(task, row.head);
2493
+ }
2494
+ }
2361
2495
  return report;
2362
2496
  }
2363
2497
  async releaseReadiness(repo: string, liveIssues: number[]) {
@@ -2391,12 +2525,112 @@ export class Tasks {
2391
2525
  const state = { id: task.id, state: task.state, stage, ...(this.isPii(task) ? { private: true } : { criteria: this.workflowView(task) ?? null }) };
2392
2526
  return { schema: 2, state, stateHash: workflowHash(state), sourceHash: workflowHash(config) };
2393
2527
  }
2528
+ private readonly planReads = new Map<number, { identity: string; evidence: PrEvidence }>();
2529
+ // ponytail: consent expires on restart because a refused readback cannot first mutate the task's history.
2530
+ // A durable host observation journal can retain safe consent across lifetimes without reviving pending invalidations.
2531
+ private readonly departureGeneration = crypto.randomUUID();
2532
+ private readonly invalidatedPlanDepartures = new Set<number>();
2533
+ private readonly observedPlans = new Map<number, NonNullable<HistoryEntry["planObservation"]>>();
2534
+ private planIdentity(task: Task): string {
2535
+ return JSON.stringify([task.refs, task.sdlc, task.plan, task.owner, task.reviewer, task.state, this.isPii(task), this.d.sdlc?.config()]);
2536
+ }
2537
+ private planReadIdentity(task: Task): string { return JSON.stringify([task.sdlc?.repo, task.sdlc?.pr, task.refs.repo, planFingerprint(task.plan ?? {})]); }
2538
+ private planPaths(task: Task): string[] | undefined {
2539
+ if (!task.sdlc?.repo) return;
2540
+ // Resolve the artifact slug first: it may warm the canonical project's previously unnamed identity.
2541
+ const artifact = this.repositories.scope({ repo: task.sdlc.repo, paths: task.refs.paths });
2542
+ const scope = this.repositories.scope(task.refs);
2543
+ if (scope.key !== artifact.key) return;
2544
+ return task.plan?.paths?.map(path => scope === this.repositories.project ? this.projectPath(path) : this.repositories.relativePath(scope, path));
2545
+ }
2546
+ private planScopeIdentity(task: Task): string {
2547
+ const paths = this.planPaths(task); // Warm verified aliases before capturing the scope identity.
2548
+ const scope = this.repositories.scope(task.refs);
2549
+ return planFingerprint({ key: scope.key, root: scope.root ?? null, aliases: [...scope.aliases].sort(),
2550
+ repo: task.refs.repo ?? null, absoluteRefs: (task.refs.paths ?? []).filter(isAbsolute).sort(), paths: paths ?? null });
2551
+ }
2552
+ private validPlanDepartures(task: Task): NonNullable<HistoryEntry["planDeparture"]>[] {
2553
+ const invalidated = task.history.findLastIndex(h => h.planDepartureInvalidated && !h.planDeparture);
2554
+ if (this.invalidatedPlanDepartures.has(task.id)) return [];
2555
+ const latest = task.history.findLastIndex(h => h.by === USER && h.event === "plan departure accepted" && h.planDeparture);
2556
+ return latest > invalidated && task.history[latest]!.planDeparture!.generation === this.departureGeneration
2557
+ && task.history[latest]!.planDeparture!.scope === this.planScopeIdentity(task) ? [task.history[latest]!.planDeparture!] : [];
2558
+ }
2559
+ private planReport(task: Task, evidence?: PrEvidence) {
2560
+ const rawReport = planConformance(this.isPii(task) ? undefined : this.planPaths(task), this.isPii(task) ? undefined : evidence);
2561
+ const safe = [...rawReport.outside, ...rawReport.untouched].every(path => this.publicMetadataText(path));
2562
+ const report: PlanConformance = safe ? rawReport : { state: "unknown", head: rawReport.head, outside: [], untouched: [] };
2563
+ const fingerprint = planFingerprint(task.plan ?? {});
2564
+ const observation = task.history.find(h => h.planObservation?.repo === task.sdlc?.repo && h.planObservation?.pr === task.sdlc?.pr)?.planObservation ?? this.observedPlans.get(task.id);
2565
+ const revision = task.history.findLast(h => h.planRevision)?.planRevision?.revision;
2566
+ const acceptance = evidence && this.validPlanDepartures(task).findLast(item => item.repo === task.sdlc?.repo && item.pr === task.sdlc?.pr && item.head === evidence.head
2567
+ && item.revision === (revision ?? 0) && item.fingerprint === fingerprint && JSON.stringify([...item.paths].sort()) === JSON.stringify([...evidence.paths].sort()));
2568
+ return { ...report, accepted: !!acceptance, earlierPushes: "unknown" as const,
2569
+ ...(observation && observation.repo === task.sdlc?.repo && observation.pr === task.sdlc?.pr ? { revisionsAfterFirstObservedPush: Math.max(0, (revision ?? 0) - observation.revision) } : {}) };
2570
+ }
2571
+ private capturePlanRead(task: Task, evidence: PrEvidence | undefined): void {
2572
+ if ((this.d.sdlc?.config().mode ?? "off") === "off" || !evidence || this.isPii(task) || !task.sdlc?.repo || !task.sdlc.pr) return;
2573
+ if (this.planReads.size >= 256 && !this.planReads.has(task.id)) this.planReads.delete(this.planReads.keys().next().value!);
2574
+ this.planReads.set(task.id, { identity: this.planReadIdentity(task), evidence });
2575
+ const accepted = task.history.findLast(h => h.by === USER && h.planDeparture)?.planDeparture;
2576
+ if (accepted && evidence.pathsComplete === true && JSON.stringify([...accepted.paths].sort()) !== JSON.stringify([...evidence.paths].sort())) this.invalidatedPlanDepartures.add(task.id);
2577
+ this.capturePlanHead(task, evidence.head);
2578
+ }
2579
+ private capturePlanHead(task: Task, head: string): void {
2580
+ if ((this.d.sdlc?.config().mode ?? "off") === "off" || this.isPii(task) || !task.sdlc?.repo || !task.sdlc.pr) return;
2581
+ if (this.planReads.get(task.id)?.evidence.head !== head) this.planReads.delete(task.id);
2582
+ const accepted = task.history.findLast(h => h.by === USER && h.planDeparture)?.planDeparture;
2583
+ if (accepted && (accepted.head !== head || accepted.repo !== task.sdlc.repo || accepted.pr !== task.sdlc.pr)) this.invalidatedPlanDepartures.add(task.id);
2584
+ if (!task.history.some(h => h.planObservation?.repo === task.sdlc?.repo && h.planObservation?.pr === task.sdlc?.pr)
2585
+ && (!this.observedPlans.has(task.id) || this.observedPlans.get(task.id)?.repo !== task.sdlc.repo || this.observedPlans.get(task.id)?.pr !== task.sdlc.pr)) {
2586
+ // ponytail: this is first observed push, not a true push event. Earlier pushes stay unknown; upgrade to a webhook/Actions push receipt.
2587
+ if (this.observedPlans.size >= 256) this.observedPlans.delete(this.observedPlans.keys().next().value!);
2588
+ this.observedPlans.set(task.id, { repo: task.sdlc.repo, pr: task.sdlc.pr, head,
2589
+ revision: task.history.findLast(h => h.planRevision)?.planRevision?.revision ?? 0, fingerprint: planFingerprint(task.plan ?? {}) });
2590
+ }
2591
+ }
2592
+ private planObservation(task: Task): Pick<HistoryEntry, "planObservation" | "planDepartureInvalidated"> {
2593
+ if ((this.d.sdlc?.config().mode ?? "off") === "off") return {};
2594
+ const observation = this.observedPlans.get(task.id);
2595
+ const lastAcceptance = task.history.findLastIndex(h => h.by === USER && h.planDeparture);
2596
+ return {
2597
+ ...(observation && observation.repo === task.sdlc?.repo && observation.pr === task.sdlc?.pr
2598
+ && !task.history.some(h => h.planObservation?.repo === observation.repo && h.planObservation.pr === observation.pr) ? { planObservation: observation } : {}),
2599
+ ...(this.invalidatedPlanDepartures.has(task.id) && !task.history.slice(lastAcceptance + 1).some(h => h.planDepartureInvalidated) ? { planDepartureInvalidated: true as const } : {}),
2600
+ };
2601
+ }
2602
+ async acceptPlanDeparture(by: PeerId, id: unknown, head: unknown, reason: unknown): Promise<Task> {
2603
+ if (by !== USER) throw new PreEffectToolRefusal("only the person may accept a plan departure");
2604
+ if (typeof head !== "string" || !/^[a-f0-9]{40}$/.test(head) || typeof reason !== "string" || !reason.trim() || reason.length > 2048) throw new PreEffectToolRefusal("departure acceptance requires a full head and a bounded reason");
2605
+ const initial = this.need(id, false, true), identity = this.planIdentity(initial), scope = this.planScopeIdentity(initial), history = initial.history.length;
2606
+ const valid = () => { const now = this.need(id, false, true); if (isClosedTaskState(now.state) || identity !== this.planIdentity(now) || scope !== this.planScopeIdentity(now) || history !== now.history.length) throw new PreEffectToolRefusal("task changed during departure readback"); return now; };
2607
+ if (this.isPii(initial)) throw new PreEffectToolRefusal("private PR file evidence is unavailable; amend the private plan instead");
2608
+ const read: SdlcGh = (args, timeout, cwd, cap) => { valid(); return (this.d.sdlc?.gh ?? sdlcGh)(args, timeout, cwd, cap); };
2609
+ const evidence = initial.sdlc && await readSdlcPr(initial.sdlc, read); valid();
2610
+ if (!evidence || completePlanInventory(evidence) === undefined) throw new PreEffectToolRefusal("complete PR file inventory unknown");
2611
+ if (evidence.head !== head) throw new PreEffectToolRefusal("departure acceptance head is not the current PR head");
2612
+ if (this.planReport(initial, evidence).state !== "departure") throw new PreEffectToolRefusal("current plan conformance is not a known departure; amend unknown scope or coverage first");
2613
+ const note = sanitize(reason.replace(/\s+/g, " ").trim());
2614
+ const withheld = await this.withholds("note", note, initial); const current = valid();
2615
+ this.capturePlanRead(current, evidence);
2616
+ const next = this.d.board.update(current.id, USER, "plan departure accepted", {}, note, { withheld: withheld || undefined, ...this.planObservation(current),
2617
+ planDeparture: { generation: this.departureGeneration, scope, repo: current.sdlc!.repo!, pr: current.sdlc!.pr!, head, revision: current.history.findLast(h => h.planRevision)?.planRevision?.revision ?? 0, fingerprint: planFingerprint(current.plan ?? {}), paths: [...evidence.paths] } });
2618
+ this.invalidatedPlanDepartures.delete(current.id);
2619
+ return next;
2620
+ }
2394
2621
  async lifecycleStatus(id: unknown, refresh = true): Promise<SdlcStatus> {
2395
- const task = this.need(id), config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
2396
- const evidence = task.sdlc && !this.isPii(task) && refresh && config.mode !== "off" ? await readSdlcPr(task.sdlc, this.d.sdlc?.gh) : undefined;
2622
+ const task = this.need(id), config = this.d.sdlc?.config() ?? sdlcConfig(undefined), identity = this.planIdentity(task);
2623
+ 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;
2626
+ valid();
2397
2627
  const accepted = task.sdlc?.acceptance;
2398
- const edit = accepted && !this.isPii(task) && refresh && config.mode !== "off" ? await readSpecEdit(accepted.repo, accepted.issue, this.d.sdlc?.gh) : undefined;
2399
- return sdlcStatus(config, task.sdlc, evidence, this.workflowView(task), edit);
2628
+ const edit = accepted && !this.isPii(task) && refresh && config.mode !== "off" ? await readSpecEdit(accepted.repo, accepted.issue, read) : undefined;
2629
+ const current = valid();
2630
+ if (this.planIdentity(current) !== this.planIdentity(task) || current.history.length !== task.history.length) throw new PreEffectToolRefusal("task changed during lifecycle readback");
2631
+ this.capturePlanRead(current, evidence);
2632
+ 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);
2400
2634
  }
2401
2635
 
2402
2636
  /** #481 S1: only build work starts from an accepted spec. */
@@ -2462,7 +2696,7 @@ export class Tasks {
2462
2696
  this.d.notify(this.specText(task, state, "advice"));
2463
2697
  }
2464
2698
 
2465
- private async lifecycleGate(task: Task, transition: "review" | "approval", reviewedHead?: string): Promise<PrEvidence | undefined> {
2699
+ private async lifecycleGate(task: Task, transition: "review" | "approval", reviewedHead?: string, findings?: readonly ReviewFindingSet[]): Promise<PrEvidence | undefined> {
2466
2700
  const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
2467
2701
  if (config.mode === "off") return;
2468
2702
  const receipt = task.sdlc && { ...task.sdlc, ...(reviewedHead ? { reviewedHead } : {}) };
@@ -2476,10 +2710,13 @@ export class Tasks {
2476
2710
  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`);
2477
2711
  // Advise may continue on the current task, but evidence from its previous link cannot admit that task's review.
2478
2712
  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;
2479
- const status = sdlcStatus(config, receipt, evidence, this.workflowView(current));
2713
+ this.capturePlanRead(current, evidence);
2714
+ 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);
2480
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;
2481
2717
  if (config.mode === "enforce" && blockers.length) throw new PreEffectToolRefusal(`task #${task.id}: SDLC ${transition} blocked: ${blockers.join("; ")}`);
2482
- if (config.mode === "advise" && blockers.length) this.d.notify(`task ${this.publicTitle(task)}: SDLC ${transition} advice: ${this.isPii(task) ? "private lifecycle evidence unknown" : blockers.join("; ")}`);
2718
+ const advice = [...new Set([...blockers, ...planConformanceBlockers(planReport)])];
2719
+ 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("; ")}`);
2483
2720
  return evidence;
2484
2721
  }
2485
2722
 
@@ -2593,6 +2830,7 @@ export class Tasks {
2593
2830
  const repositoryChanged = this.repositories.scope(task.refs).key !== this.repositories.scope(draft.refs).key;
2594
2831
  const before = new Set(this.overlapHits(task).map((h) => h.task.id));
2595
2832
  const next = this.d.board.update(task.id, by, "accepted", { state: "in_progress", ...(cleaned ? { plan: cleaned } : {}), ...(metadata ? { refs: metadata } : {}) });
2833
+ this.captureOwnerAcceptance(next);
2596
2834
  this.d.notify(`task ${this.publicTitle(next)} accepted by ${by}`);
2597
2835
  if (repositoryChanged) this.cohorts.leave(next.id);
2598
2836
  if (cleaned || metadata) {
@@ -2679,19 +2917,25 @@ export class Tasks {
2679
2917
  /** Whether a completion check is still running for this task. */
2680
2918
  isChecking = (id: number) => this.checking.has(id);
2681
2919
 
2920
+ private completionCurrent(id: unknown, by: PeerId, preEffect: boolean): Task {
2921
+ const task = this.need(id, false, preEffect);
2922
+ this.mine(task, by, "owner", preEffect);
2923
+ if (preEffect) {
2924
+ if (task.state === "in_review" || isClosedTaskState(task.state)) throw new PreEffectToolRefusal(`task #${task.id} is already ${task.state}`);
2925
+ } else {
2926
+ if (task.state === "in_review" || isClosedTaskState(task.state)) throw new Error(`task #${task.id} is already ${task.state}`);
2927
+ }
2928
+ this.ready(task, preEffect);
2929
+ if (task.state === "proposed" || task.state === "changes_requested") this.acceptanceCapacity(task);
2930
+ if (this.checking.has(task.id)) {
2931
+ // The result goes only to the owner the check was started for: anyone who took the task since hears nothing.
2932
+ throw new (preEffect ? PreEffectToolRefusal : Error)(this.checking.get(task.id) === task.owner ? `task #${task.id}: its check is still running; its result comes as a task message` : `task #${task.id}: a check from before it changed hands is still running; call hub_task_done again in a few minutes`);
2933
+ }
2934
+ return task;
2935
+ }
2936
+
2682
2937
  async done(by: PeerId, id: unknown, summary?: string, refs?: TaskRefs): Promise<Task> {
2683
- const valid = (): Task => {
2684
- const t = this.need(id, false, true);
2685
- this.mine(t, by, "owner", true);
2686
- if (t.state === "in_review" || isClosedTaskState(t.state)) throw new PreEffectToolRefusal(`task #${t.id} is already ${t.state}`);
2687
- this.ready(t, true);
2688
- if (t.state === "proposed" || t.state === "changes_requested") this.acceptanceCapacity(t);
2689
- if (this.checking.has(t.id)) {
2690
- // The result goes only to the owner the check was started for: anyone who took the task since hears nothing.
2691
- throw new PreEffectToolRefusal(this.checking.get(t.id) === t.owner ? `task #${t.id}: its check is still running; its result comes as a task message` : `task #${t.id}: a check from before it changed hands is still running; call hub_task_done again in a few minutes`);
2692
- }
2693
- return t;
2694
- };
2938
+ const valid = () => this.completionCurrent(id, by, true);
2695
2939
  // The PII screen reads the summary of a done that passes these checks (issue #198); they run again after it.
2696
2940
  const withheld = await this.withholds("summary", summary, valid());
2697
2941
  let task = valid();
@@ -2747,7 +2991,13 @@ export class Tasks {
2747
2991
  this.pendingChecks++;
2748
2992
  this.checkQueue = this.checkQueue
2749
2993
  .then(() => this.finishChecked(pending.id, by, summary, command, seen, withheld))
2750
- .catch((e: Error) => this.d.notify(`task #${pending.id}: its check result could not be recorded: ${e.message}`))
2994
+ .catch((e: Error) => {
2995
+ this.releaseNotice(`task #${pending.id}: its check result could not be recorded: ${e.message}`);
2996
+ this.releaseNotice(`task #${pending.id}: its owner retry notice could not be published`, () => {
2997
+ const current = this.d.board.get(pending.id);
2998
+ if (current?.state === "in_progress" && current.owner === seen.owner) this.tell(current, `Task #${current.id}: its check completion was refused. Call hub_task_done again when it is ready.`, this.isPii(current));
2999
+ });
3000
+ })
2751
3001
  .finally(() => this.pendingChecks--);
2752
3002
  return pending;
2753
3003
  }
@@ -2871,20 +3121,14 @@ export class Tasks {
2871
3121
  let reviewer = selection?.reviewer ?? task.reviewer, reviewEvidence: PrEvidence | undefined;
2872
3122
  if (this.d.sdlc && this.d.sdlc.config().mode !== "off" && gated !== (reviewer ? "review" : "approval")) {
2873
3123
  reviewEvidence = await this.lifecycleGate(task, reviewer ? "review" : "approval");
2874
- task = this.need(task.id, false, true);
2875
- this.mine(task, by, "owner", true);
2876
- // In every mode: another done may have landed during the readback.
2877
- if (task.state === "in_review" || isClosedTaskState(task.state)) throw new Error(`task #${task.id} is already ${task.state}`);
3124
+ task = this.completionCurrent(task.id, by, false);
2878
3125
  selection = this.poolReview(task, refs);
2879
3126
  reviewer = selection?.reviewer ?? task.reviewer;
2880
3127
  }
2881
3128
  // A delayed local check needs a new readback; the immediate done path already admitted before effects.
2882
3129
  if (reviewer && !admission && (this.d.sdlc?.config().mode ?? "off") !== "off") {
2883
- const observed = task;
2884
3130
  admission = await this.reviewRequestAdmission(task, refs, false, by, reviewEvidence);
2885
- task = this.need(task.id);
2886
- this.mine(task, by, "owner");
2887
- if (task.history.length !== observed.history.length || task.state === "in_review" || isClosedTaskState(task.state)) throw new Error("task changed during review admission");
3131
+ task = this.completionCurrent(task.id, by, false);
2888
3132
  }
2889
3133
  selection = this.poolReview(task, refs);
2890
3134
  reviewer = selection?.reviewer ?? task.reviewer;
@@ -2893,7 +3137,8 @@ export class Tasks {
2893
3137
  const routedSummary = selection?.reviewSelection?.blocked ? `${summary ?? ""}\nReview routing: ${this.assignmentNote("pool unavailable", selection)}`.trim() : summary;
2894
3138
  const completedRefs = cleanRefs(refs);
2895
3139
  this.warmRepository({ ...task, refs: { ...task.refs, ...completedRefs } });
2896
- const next = this.d.board.update(task.id, by, "done", { state: reviewer ? "in_review" : "approved", ...(selection ? { reviewer: reviewer ?? USER } : {}), ...(task.sdlc ? { sdlc: { ...task.sdlc, stage: lifecycleStage(this.d.sdlc?.config() ?? sdlcConfig(undefined), reviewer ? "review" : "merge") } } : {}), refs: completedRefs }, checkOutput ? `${routedSummary ?? ""}\n${checkOutput}`.trim() : routedSummary, { withheld: withheld || undefined, ...this.reviewMetadata(selection), ...(reviewCost ? { reviewCost } : {}) });
3140
+ 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 } : {}) });
2897
3142
  this.cohorts.closed(next.id); // its owner's next native turn end settles it in its cohort (issue #107)
2898
3143
  this.note(next, by, "finding", `Task #${next.id} done by ${by}: ${next.title}\n${summary ?? ""}`, withheld);
2899
3144
  this.tellCompleted(next, summary, withheld);
@@ -2984,30 +3229,41 @@ export class Tasks {
2984
3229
  private async reviewRequestAdmission(task: Task, refs?: TaskRefs, preEffect = true, actor: PeerId = task.owner ?? USER, previous?: PrEvidence, sameRequest?: () => boolean): Promise<ReviewReadiness | undefined> {
2985
3230
  const mode = (this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode;
2986
3231
  if (mode === "off") return undefined;
3232
+ let quietDrift = false;
2987
3233
  const guard = () => {
2988
3234
  if (actor !== USER && actor !== task.owner) {
2989
3235
  const holders = Object.entries(this.d.roles ?? {}).filter(([, roles]) => Array.isArray(roles) && roles.includes("conductor")).map(([peer]) => peer);
2990
3236
  if (holders.length !== 1 || holders[0] !== actor) throw new (preEffect ? PreEffectToolRefusal : Error)("review admission authority changed");
2991
3237
  }
2992
3238
  const current = this.need(task.id, false, preEffect);
2993
- if ((this.d.sdlc?.config().mode ?? "off") !== mode || current.history.length !== task.history.length && !sameRequest?.() || current.owner !== task.owner || current.reviewer !== task.reviewer
3239
+ const changed = current.history.length !== task.history.length;
3240
+ const quiet = current.history.length > task.history.length && current.history.slice(task.history.length).every(entry => QUIET_EVENTS.has(entry.event));
3241
+ const sameHistory = !changed || (mode === "advise" && quiet) || !!sameRequest?.();
3242
+ if ((this.d.sdlc?.config().mode ?? "off") !== mode || !sameHistory || current.owner !== task.owner || mode !== "advise" && current.reviewer !== task.reviewer
2994
3243
  || this.isPii(current) !== this.isPii(task) || JSON.stringify(current.sdlc ?? null) !== JSON.stringify(task.sdlc ?? null)
3244
+ || mode === "advise" && (current.state !== task.state || current.refs.commit !== task.refs.commit)
2995
3245
  || actor !== USER && this.d.privateTurn?.(actor) && !this.isPii(current)) throw new (preEffect ? PreEffectToolRefusal : Error)("task or authority changed during review admission");
3246
+ quietDrift ||= mode === "advise" && changed && quiet;
2996
3247
  return current;
2997
3248
  };
2998
3249
  guard();
2999
3250
  const expected = cleanRefs(refs).commit ?? task.refs.commit ?? task.sdlc?.head ?? task.sdlc?.localHead;
3000
- const read: SdlcGh = (args, timeout, cwd, cap) => { guard(); return (this.d.sdlc?.gh ?? sdlcGh)(args, timeout, cwd, cap); };
3251
+ const read: SdlcGh = (args, timeout, cwd, cap) => {
3252
+ guard();
3253
+ if (quietDrift) throw new Error("review readiness unknown: task changed during readback");
3254
+ return (this.d.sdlc?.gh ?? sdlcGh)(args, timeout, cwd, cap);
3255
+ };
3001
3256
  const evidence: ReviewReadiness = this.isPii(task)
3002
3257
  ? { ready: false, blockers: ["private review readiness unavailable"] }
3003
3258
  : task.sdlc ? reviewReadinessFromEvidence(previous, expected, this.d.sdlc?.config().requiredChecks ?? []) ?? await readReviewReadiness(task.sdlc, expected, read, this.d.sdlc?.config().requiredChecks ?? []) : { ready: false, blockers: ["review PR identity missing"] };
3004
3259
  const current = guard();
3005
- if (!evidence.ready) {
3006
- const message = `task ${this.publicTitle(current)}: premature review: ${evidence.blockers.join("; ")}`;
3260
+ const admitted: ReviewReadiness = quietDrift ? { ready: false, blockers: ["review readiness unknown: task changed during readback"] } : evidence;
3261
+ if (!admitted.ready) {
3262
+ const message = `task ${this.publicTitle(current)}: premature review: ${admitted.blockers.join("; ")}`;
3007
3263
  if (mode === "enforce") throw new (preEffect ? PreEffectToolRefusal : Error)(message);
3008
3264
  this.releaseNotice(message);
3009
3265
  }
3010
- return evidence;
3266
+ return admitted;
3011
3267
  }
3012
3268
 
3013
3269
  private sendReview(task: Task, reviewer: PeerId, why = "", admission?: ReviewReadiness): void {
@@ -3024,6 +3280,9 @@ export class Tasks {
3024
3280
  // A checklist that maps the change to its contract (issue #35): reviewers who check against the written plan catch more.
3025
3281
  const plan = planText(task.plan);
3026
3282
  const check = [...task.history].reverse().find((h) => h.event === "check passed");
3283
+ const cachedPlanRead = this.planReads.get(task.id);
3284
+ const planRead = this.planReport(task, cachedPlanRead?.identity === this.planReadIdentity(task) && cachedPlanRead.evidence.head === this.findingHead(task) ? cachedPlanRead.evidence : undefined);
3285
+ const conformanceText = mode !== "off" ? this.screen(task, `Plan conformance: ${JSON.stringify(planRead)}`, "plan conformance", reviewer) + "\n" : "";
3027
3286
  const checklist = [
3028
3287
  "Checklist:",
3029
3288
  ...this.planGuardItems(task).map(item => `- Check the plan against this live fail before the remaining checks: ${item.line}`),
@@ -3037,6 +3296,9 @@ export class Tasks {
3037
3296
  const specRepo = task.sdlc?.repo ?? task.external?.repo, specIssue = task.sdlc?.issue ?? task.external?.issue;
3038
3297
  const spec = reviewOnly && specRepo && specIssue ? `Spec issue: ${specRepo}#${specIssue}\n` : "";
3039
3298
  const findings = reviewOnly ? task.history.filter(h => h.event === "changes_requested" && h.note).map(h => `- ${h.by}: ${this.screen(task, h.note!, "review note", reviewer, h.withheld)}`) : [];
3299
+ const carried = findingsForHead(this.reviewFindingSets(task), this.findingHead(task)).filter(item => item.severity === "important" && item.disposition === "open");
3300
+ const visible = this.isPii(task) ? [] : carried.filter(item => this.findingTextAllowed(task, [item]) && !task.history.some(entry => entry.withheld && entry.reviewFindings?.items.some(value => value.id === item.id)));
3301
+ const carriedText = carried.length ? `Open Important findings carried until explicitly dispositioned: ${carried.length}\n${visible.map(({ id, pass, severity, path }) => JSON.stringify({ id, pass, severity, ...(path ? { path } : {}) })).join("\n")}${visible.length < carried.length ? "\nSome finding details withheld." : ""}\n` : "";
3040
3302
  const guidance = this.roleGuidance(task, reviewer, true);
3041
3303
  let projection = "";
3042
3304
  try { const text = this.d.projectedReview?.(task); if (text) projection = this.screen(task, sanitize(text), "document projection", reviewer); }
@@ -3044,7 +3306,7 @@ export class Tasks {
3044
3306
  const brief = this.briefText(task);
3045
3307
  // #572 AC3: advisory only, from git state never from task text, so it is safe for a PII task too.
3046
3308
  const checkout = mainCheckoutWarning(this.d.cwd, this.publicMetadataText);
3047
- const body = `Review task #${task.id} [${profileTaskLabel(task)}] ${task.title}\nDone by ${last?.by ?? task.owner}: ${summary}\n${where ? `Where: ${where}\n` : ""}${spec}${why ? `${why}\n` : ""}${!plan && task.detail ? `Task detail:\n${task.detail}\n` : ""}${guidance ? guidance + "\n" : ""}${projection ? projection + "\n" : ""}${brief ? brief + "\n" : ""}${checkout ? `Note: ${checkout}.\n` : ""}${findings.length ? `Earlier findings (set a disposition for each):\n${findings.join("\n")}\n` : ""}${checklist}\nGive your verdict with hub_review {id: ${task.id}, verdict: "approved" | "changes_requested", note, unmet}.`;
3309
+ const body = `Review task #${task.id} [${profileTaskLabel(task)}] ${task.title}\nDone by ${last?.by ?? task.owner}: ${summary}\n${where ? `Where: ${where}\n` : ""}${last?.noWrites ? "no writes recorded since acceptance\n" : ""}${spec}${why ? `${why}\n` : ""}${!plan && task.detail ? `Task detail:\n${task.detail}\n` : ""}${guidance ? guidance + "\n" : ""}${projection ? projection + "\n" : ""}${brief ? brief + "\n" : ""}${checkout ? `Note: ${checkout}.\n` : ""}${findings.length ? `Earlier findings (set a disposition for each):\n${findings.join("\n")}\n` : ""}${carriedText}${conformanceText}${checklist}\nGive your verdict with hub_review {id: ${task.id}, verdict: "approved" | "changes_requested", note, unmet, findings: [{id, pass: "bugs" | "security" | "compliance", severity: "important" | "nit", path?, disposition: "open" | "fixed" | "false_positive"}]}. Reuse finding ids when updating their disposition; only the person may accept a finding with a rationale.`;
3048
3310
  this.d.bus.publish(newEnvelope(HUB, body, { to: [reviewer], kind: "review", priority: "important", refs: { ...r, ...(this.reviewHead(task) ? { commit: this.reviewHead(task)! } : {}), task: String(task.id), reviewRevision: String(this.reviewRequestRevision(task)) }, ...(this.isPii(task) ? { private: true } : {}) }));
3049
3311
  }
3050
3312
 
@@ -3093,11 +3355,13 @@ export class Tasks {
3093
3355
  } catch { return "Review brief unavailable: it could not be built."; }
3094
3356
  }
3095
3357
 
3096
- async review(by: PeerId, id: unknown, verdict: unknown, note?: string, unmet?: unknown, head?: unknown, coverage?: unknown): Promise<Task> {
3358
+ async review(by: PeerId, id: unknown, verdict: unknown, note?: string, unmet?: unknown, head?: unknown, coverage?: unknown, findings?: unknown): Promise<Task> {
3097
3359
  const authority = by === USER ? undefined : this.d.reviewAuthority?.(by, Number(id));
3360
+ let findingGuard: ((task: Task) => void) | undefined;
3098
3361
  // #520: what the coverage was checked against, pinned through every await: the commit and the lifecycle receipt (the
3099
3362
  // inherited reviewedHead lives there).
3100
3363
  let covered: { commit: string | undefined; receipt: string } | undefined;
3364
+ let conformanceIdentity: string | undefined;
3101
3365
  const valid = (): Task => {
3102
3366
  const refused = authority?.();
3103
3367
  if (refused) throw new PreEffectToolRefusal(refused); // rechecked after every await, right before each write
@@ -3106,13 +3370,42 @@ export class Tasks {
3106
3370
  this.mine(t, by, "reviewer", true);
3107
3371
  if (verdict !== "approved" && verdict !== "changes_requested") throw new PreEffectToolRefusal('verdict must be "approved" or "changes_requested"');
3108
3372
  if (t.state !== "in_review") throw new PreEffectToolRefusal(`task #${t.id} is ${t.state}: cannot move to ${verdict} before its owner calls hub_task_done`);
3373
+ if (conformanceIdentity !== undefined && conformanceIdentity !== this.planIdentity(t)) throw new PreEffectToolRefusal("plan conformance identity changed during verdict");
3374
+ findingGuard?.(t);
3109
3375
  return t;
3110
3376
  };
3377
+ if ((this.d.sdlc?.config().mode ?? "off") !== "off") conformanceIdentity = this.planIdentity(valid());
3111
3378
  const items = textList(unmet);
3112
3379
  if (items.length) note = `${note ?? ""}\nUnmet: ${items.join("; ")}`.trim();
3113
3380
  // #520: an approval accounts for every reviewable path of the brief. enforce refuses before any effect, advise records
3114
3381
  // the gap in the verdict note, off and a person's own verdict skip it.
3115
3382
  const mode = this.d.sdlc?.config().mode ?? "off";
3383
+ const initial = valid(), suppliedFindings = findings !== undefined;
3384
+ let parsed: ReviewFinding[];
3385
+ try { parsed = parseReviewFindings(findings, { person: by === USER, maxNits: this.d.sdlc?.config().maxNits ?? DEFAULT_MAX_NITS }); }
3386
+ catch (error) { throw new PreEffectToolRefusal(error instanceof Error ? error.message : "invalid structured findings"); }
3387
+ const resolvedHead = this.findingHead(initial);
3388
+ let findingHead = resolvedHead;
3389
+ if (head !== undefined) {
3390
+ try { findingHead = sdlcReceipt({ reviewedHead: head }, initial.sdlc, this.d.sdlc?.config() ?? sdlcConfig(undefined)).reviewedHead; }
3391
+ catch (error) { throw new PreEffectToolRefusal(error instanceof Error ? error.message : "invalid finding head"); }
3392
+ }
3393
+ const report: ReviewFindingSet | undefined = suppliedFindings ? { ...(findingHead ? { head: findingHead } : {}), items: parsed } : undefined;
3394
+ const proposedSets = (task: Task) => [...this.reviewFindingSets(task), ...(report ? [report] : [])];
3395
+ if (head !== undefined || suppliedFindings || this.reviewFindingSets(initial).length) {
3396
+ const identity = (task: Task) => JSON.stringify({ owner: task.owner, reviewer: task.reviewer, commit: task.refs.commit, receipt: task.sdlc, resolvedHead: this.findingHead(task), private: this.isPii(task), findings: this.reviewFindingSets(task) });
3397
+ const original = identity(initial);
3398
+ findingGuard = task => {
3399
+ if (identity(task) !== original) throw new PreEffectToolRefusal("task review identity, privacy or findings changed during the verdict; review current work again");
3400
+ if (by !== USER && suppliedFindings && this.d.privateTurn?.(by) && !this.isPii(task)) throw new PreEffectToolRefusal("structured review of ordinary work is unavailable during a private turn");
3401
+ if (parsed.filter(item => item.severity === "nit").length > (this.d.sdlc?.config().maxNits ?? DEFAULT_MAX_NITS)) throw new PreEffectToolRefusal("review nit cap exceeded");
3402
+ if (report?.items.length && !report.head && this.d.sdlc?.config().mode === "enforce") throw new PreEffectToolRefusal("a finding report requires a resolvable full reviewed head in enforce");
3403
+ const current = findingsForHead(proposedSets(task), findingHead);
3404
+ if (current.length > 64) throw new PreEffectToolRefusal("one reviewed head supports at most 64 distinct finding IDs");
3405
+ if (verdict === "approved" && this.d.sdlc?.config().mode === "enforce" && findingTally(current).openImportant) throw new PreEffectToolRefusal("approval blocked: open important review findings on this head");
3406
+ };
3407
+ findingGuard(initial);
3408
+ }
3116
3409
  // A Tasks without a brief provider (library use; the daemon always has one) has no coverage gate.
3117
3410
  if (verdict === "approved" && by !== USER && mode !== "off" && this.d.reviewBrief) {
3118
3411
  const t = valid(), enforce = mode === "enforce", list = (paths: string[]) => `${paths.slice(0, 20).join(", ")}${paths.length > 20 ? ", ..." : ""}`;
@@ -3138,15 +3431,17 @@ export class Tasks {
3138
3431
  if (gap.length) note = `${note ?? ""}\nCoverage gap (${gap.length}): ${list(gap)}`.trim();
3139
3432
  }
3140
3433
  }
3434
+ if (head !== undefined && resolvedHead && findingHead !== resolvedHead) throw new PreEffectToolRefusal("supplied review head differs from the task resolved head");
3141
3435
  // The PII screen reads the note of a review that passes these checks (issue #198); they run again after it.
3142
- const withheld = await this.withholds("review note", note, valid());
3436
+ const withheld = await this.withholds("review note", parsed.length ? JSON.stringify({ note, findings: parsed }) : note, valid())
3437
+ || parsed.length > 0 && !this.findingTextAllowed(initial, parsed);
3143
3438
  let task = valid();
3144
3439
  let reviewedHead: string | undefined;
3145
3440
  if (head !== undefined) {
3146
3441
  const checked = sdlcReceipt({ reviewedHead: head }, task.sdlc, this.d.sdlc?.config() ?? sdlcConfig(undefined));
3147
3442
  reviewedHead = checked.reviewedHead;
3148
3443
  }
3149
- if (verdict === "approved" && this.d.sdlc && this.d.sdlc.config().mode !== "off") { await this.lifecycleGate(task, "approval", reviewedHead); task = valid(); }
3444
+ if (verdict === "approved" && this.d.sdlc && this.d.sdlc.config().mode !== "off") { await this.lifecycleGate(task, "approval", reviewedHead, proposedSets(task)); task = valid(); }
3150
3445
  let candidate: Extract<CandidateInput, { kind: "fail" }> | undefined;
3151
3446
  if (verdict === "changes_requested" && items.length && this.d.candidates && !withheld && !this.isPii(task) && !this.d.privateTurn?.(by)) {
3152
3447
  const input: CandidateInput = { project: this.d.store?.project ?? this.d.project, kind: "fail", approach: this.publicTitle(task), symptom: items.join("\n"),
@@ -3161,10 +3456,10 @@ export class Tasks {
3161
3456
  const pii = this.isPii(task);
3162
3457
  const reviewContext = by === USER ? undefined : this.d.reviewContext?.(by, task.id);
3163
3458
  const poolRequest = task.history.findLast(entry => ["done", "reviewer changed", "reviewer unavailable"].includes(entry.event));
3164
- const family = by === USER ? undefined : familyOf(by, this.d.instances?.());
3459
+ const family = by === USER ? undefined : (this.d.adapterCatalog?.familyOf ?? familyOf)(by, this.d.instances?.());
3165
3460
  const reviewerFamily = poolRequest?.reviewerFamily === family ? family : undefined;
3166
3461
  const reviewerReason = reviewerFamily ? poolRequest?.reviewerReason : undefined;
3167
- const flag = { withheld: withheld || undefined, ...(reviewContext ? { reviewContext } : {}), ...(reviewerFamily ? { reviewerFamily } : {}), ...(reviewerReason ? { reviewerReason } : {}) };
3462
+ const flag = { ...this.planObservation(task), withheld: withheld || undefined, ...(report ? { reviewFindings: report } : {}), ...(reviewContext ? { reviewContext } : {}), ...(reviewerFamily ? { reviewerFamily } : {}), ...(reviewerReason ? { reviewerReason } : {}) };
3168
3463
  if (verdict === "approved") {
3169
3464
  const next = this.d.board.update(task.id, by, "approved", { state: "approved", rejections: 0, ...(reviewedHead && task.sdlc ? { sdlc: { ...task.sdlc, reviewedHead, stage: lifecycleStage(this.d.sdlc?.config() ?? sdlcConfig(undefined), "merge") } } : {}) }, note, flag);
3170
3465
  if (next.owner) {
@@ -3180,9 +3475,12 @@ export class Tasks {
3180
3475
  this.releaseNotice(`task ${this.publicTitle(next)}: could not tell ${next.owner ?? "its owner"} it was approved`, () =>
3181
3476
  this.tell(next, `Task #${next.id} approved by ${by}.${note ? ` ${this.screen(next, note, "review note", next.owner, withheld)}` : ""}`, pii));
3182
3477
  await this.releaseDependents(next);
3478
+ // A proposal storage failure cannot roll back the saved verdict; another matching verdict retries it.
3479
+ this.releaseNotice(`task #${next.id}: review saved but recurring-finding proposal unavailable`, () => this.proposeRecurringFindings(next, by, parsed, withheld));
3183
3480
  return next;
3184
3481
  }
3185
3482
  const rejected = this.d.board.update(task.id, by, "changes_requested", { state: "changes_requested", rejections: task.rejections + 1 }, note, flag);
3483
+ this.releaseNotice(`task #${rejected.id}: review saved but recurring-finding proposal unavailable`, () => this.proposeRecurringFindings(rejected, by, parsed, withheld));
3186
3484
  if (candidate && !pii && !this.d.privateTurn?.(by) && this.candidateTextAllowed(JSON.stringify({ candidate, refs: rejected.refs, plan: rejected.plan }), rejected)) {
3187
3485
  try { this.d.memoryCheck?.(); this.d.candidates?.put({ ...candidate, evidenceRefs: [...candidate.evidenceRefs, `review:${rejected.id}:${rejected.history.length}`] }, "public"); } catch { /* a saved review remains authoritative if the optional sidecar fails */ }
3188
3486
  }