@staix/agent-hub 0.12.44 → 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 (137) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +3 -3
  3. package/THIRD_PARTY_NOTICES.md +29 -0
  4. package/docs/adding-an-ai-tool.md +102 -14
  5. package/docs/agent-notes/README.md +9 -0
  6. package/docs/agent-notes/adapters.md +58 -44
  7. package/docs/agent-notes/budget.md +1 -1
  8. package/docs/agent-notes/daemon.md +9 -7
  9. package/docs/agent-notes/local-worker.md +4 -3
  10. package/docs/agent-notes/tasks.md +8 -5
  11. package/docs/agent-notes/tests.md +7 -7
  12. package/docs/operations.md +99 -7
  13. package/docs/quickstart.md +5 -1
  14. package/docs/sdlc.md +26 -8
  15. package/docs/security.md +6 -3
  16. package/docs/specs/2026-09-19-agent-hub-design.md +114 -5
  17. package/docs/verification/2026-10-21-469-r1-dashboard-tokens.md +43 -0
  18. package/docs/verification/2026-10-21-469-r2-dashboard-shell.md +54 -0
  19. package/docs/verification/2026-10-21-469-r3-dashboard-board.md +51 -0
  20. package/docs/verification/2026-10-21-469-r4-dashboard-views.md +77 -0
  21. package/docs/verification/2026-10-21-469-r6-dashboard-motion-states.md +82 -0
  22. package/docs/verification/2026-10-22-0.12.45.md +60 -0
  23. package/docs/verification/assets/469-r1-board-1440-dark.png +0 -0
  24. package/docs/verification/assets/469-r1-board-1440-light.png +0 -0
  25. package/docs/verification/assets/469-r1-board-390-dark.png +0 -0
  26. package/docs/verification/assets/469-r1-board-390-light.png +0 -0
  27. package/docs/verification/assets/469-r2-agents-1440-dark.png +0 -0
  28. package/docs/verification/assets/469-r2-agents-768-dark.png +0 -0
  29. package/docs/verification/assets/469-r2-board-1024-light.png +0 -0
  30. package/docs/verification/assets/469-r2-board-1440-dark.png +0 -0
  31. package/docs/verification/assets/469-r2-board-1440-light.png +0 -0
  32. package/docs/verification/assets/469-r2-board-390-dark.png +0 -0
  33. package/docs/verification/assets/469-r2-board-390-light.png +0 -0
  34. package/docs/verification/assets/469-r2-board-768-light.png +0 -0
  35. package/docs/verification/assets/469-r3-board-1440-dark.png +0 -0
  36. package/docs/verification/assets/469-r3-board-1440-light.png +0 -0
  37. package/docs/verification/assets/469-r3-board-390-light.png +0 -0
  38. package/docs/verification/assets/469-r3-detail-1440-dark.png +0 -0
  39. package/docs/verification/assets/469-r3-detail-1440-light.png +0 -0
  40. package/docs/verification/assets/469-r3-detail-390-dark.png +0 -0
  41. package/docs/verification/assets/469-r3-plan-1440-light.png +0 -0
  42. package/docs/verification/assets/469-r3-plan-390-light.png +0 -0
  43. package/docs/verification/assets/469-r4-agents-1440-dark.png +0 -0
  44. package/docs/verification/assets/469-r4-agents-1440-light.png +0 -0
  45. package/docs/verification/assets/469-r4-agents-390-dark.png +0 -0
  46. package/docs/verification/assets/469-r4-memory-1440-dark.png +0 -0
  47. package/docs/verification/assets/469-r4-memory-1440-light.png +0 -0
  48. package/docs/verification/assets/469-r4-memory-390-dark.png +0 -0
  49. package/docs/verification/assets/469-r4-message-1440-light.png +0 -0
  50. package/docs/verification/assets/469-r4-office-1440-dark.png +0 -0
  51. package/docs/verification/assets/469-r4-office-1440-light.png +0 -0
  52. package/docs/verification/assets/469-r4-settings-1440-dark.png +0 -0
  53. package/docs/verification/assets/469-r4-settings-1440-light.png +0 -0
  54. package/docs/verification/assets/469-r4-stream-1440-light.png +0 -0
  55. package/docs/verification/assets/469-r4-stream-390-light.png +0 -0
  56. package/docs/verification/assets/469-r6-agents-empty-390-dark.png +0 -0
  57. package/docs/verification/assets/469-r6-agents-empty-390-light.png +0 -0
  58. package/docs/verification/assets/469-r6-board-1440-light.png +0 -0
  59. package/docs/verification/assets/469-r6-board-390-dark.png +0 -0
  60. package/docs/verification/assets/469-r6-board-empty-1440-dark.png +0 -0
  61. package/docs/verification/assets/469-r6-board-empty-1440-light.png +0 -0
  62. package/docs/verification/assets/469-r6-disconnected-1440-dark.png +0 -0
  63. package/docs/verification/assets/469-r6-disconnected-1440-light.png +0 -0
  64. package/docs/verification/assets/469-r6-disconnected-390-dark.png +0 -0
  65. package/docs/verification/assets/469-r6-skeleton-1440-light.png +0 -0
  66. package/docs/verification/assets/469-r6-skeleton-390-dark.png +0 -0
  67. package/docs/verification/assets/469-r6-toasts-1440-dark.png +0 -0
  68. package/docs/verification/assets/469-r6-toasts-1440-light.png +0 -0
  69. package/docs/verification/assets/469-r6-toasts-390-light.png +0 -0
  70. package/docs/verified.json +19 -16
  71. package/package.json +5 -1
  72. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  73. package/plugins/agent-hub/server.js +9818 -9605
  74. package/src/adapters/adapter-api.ts +2 -0
  75. package/src/adapters/agy/definition.ts +3 -1
  76. package/src/adapters/claude-channel.ts +14 -5
  77. package/src/adapters/codex-appserver.ts +1 -0
  78. package/src/adapters/conformance/acp.ts +114 -0
  79. package/src/adapters/conformance/codex.ts +191 -0
  80. package/src/adapters/conformance/enrollment.ts +43 -0
  81. package/src/adapters/conformance/factories.ts +17 -0
  82. package/src/adapters/conformance/fake-paths.ts +12 -0
  83. package/src/adapters/conformance/fakes/acp-server.ts +267 -0
  84. package/src/adapters/conformance/fakes/acp-watchdog-agent.ts +10 -0
  85. package/src/adapters/conformance/fakes/app-server.ts +119 -0
  86. package/src/adapters/conformance/fakes/codex-bin.ts +15 -0
  87. package/src/adapters/conformance/fakes/jcode-agent.ts +91 -0
  88. package/src/adapters/conformance/fakes/model-server.ts +39 -0
  89. package/src/adapters/conformance/fakes/pi-rpc.ts +32 -0
  90. package/src/adapters/conformance/index.ts +11 -0
  91. package/src/adapters/conformance/kit.ts +50 -0
  92. package/src/adapters/conformance/local-pi.ts +100 -0
  93. package/src/adapters/conformance/owned-acp.ts +44 -0
  94. package/src/adapters/definitions/builtin-metadata.ts +23 -6
  95. package/src/adapters/definitions/native-cli-entry-kimi.ts +7 -1
  96. package/src/adapters/definitions/native-cli-entry.ts +7 -19
  97. package/src/adapters/definitions/native-cli-preview.ts +2 -11
  98. package/src/adapters/definitions/qwen.ts +4 -1
  99. package/src/adapters/external-metadata.ts +48 -0
  100. package/src/adapters/external-module-scan.ts +126 -0
  101. package/src/adapters/external-modules.ts +240 -0
  102. package/src/adapters/pi.ts +2 -0
  103. package/src/adapters/public.ts +7 -0
  104. package/src/adapters/registry-types.ts +10 -0
  105. package/src/adapters/registry.ts +51 -15
  106. package/src/cli/help.ts +21 -16
  107. package/src/cli/identity.ts +1 -1
  108. package/src/cli/main.ts +23 -5
  109. package/src/cli/upgrade-labels.ts +1 -1
  110. package/src/cli/upgrade-presentation.ts +1 -1
  111. package/src/cli/upgrade-restore-state.ts +3 -2
  112. package/src/cli/upgrade-runtime.ts +4 -1
  113. package/src/hub/board.ts +30 -4
  114. package/src/hub/config-apply.ts +18 -10
  115. package/src/hub/config-reload.ts +8 -5
  116. package/src/hub/config-trust.ts +1 -1
  117. package/src/hub/control-client.ts +4 -1
  118. package/src/hub/daemon.ts +95 -39
  119. package/src/hub/facts.ts +76 -13
  120. package/src/hub/hub-tools.ts +21 -5
  121. package/src/hub/instance-config.ts +17 -5
  122. package/src/hub/instance-execution.ts +20 -8
  123. package/src/hub/instances.ts +2 -1
  124. package/src/hub/peers.ts +9 -0
  125. package/src/hub/plan-conformance.ts +83 -0
  126. package/src/hub/review-findings.ts +104 -0
  127. package/src/hub/roster.ts +31 -5
  128. package/src/hub/sdlc-queue.ts +19 -4
  129. package/src/hub/sdlc-release.ts +16 -1
  130. package/src/hub/sdlc.ts +27 -12
  131. package/src/hub/settings.ts +5 -1
  132. package/src/hub/snapshots.ts +8 -6
  133. package/src/hub/tasks.ts +275 -45
  134. package/src/hub/ui.ts +2 -2
  135. package/src/ui/index.html +559 -78
  136. package/src/ui/office-model.ts +3 -1
  137. package/src/ui/peer-palette.ts +44 -0
package/src/hub/tasks.ts CHANGED
@@ -1,3 +1,4 @@
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";
@@ -8,6 +9,7 @@ import { NoWriteTurns } from "./no-writes.ts";
8
9
  import { readGitHubImport, type GitHubSource, type GitHubImportSelection } from "./github-import.ts";
9
10
  import { BACKLOG_SPLIT_SCREEN_TIMEOUT_MS, actualTaskDates, planningPatch, type PlanningFields, type Release, type ReleaseState } from "./planning.ts";
10
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";
11
13
  import { readReleaseReadiness } from "./sdlc-release.ts";
12
14
  import { loadRoleCatalog, taskRole, taskRoleText, type RoleCatalog } from "./roles.ts";
13
15
  import { matchesPolicy } from "./path-policy.ts";
@@ -174,6 +176,7 @@ const CONTRADICTION_WINDOW_MS = 7 * 86_400_000;
174
176
  const OPEN: Task["state"][] = ["proposed", "in_progress", "changes_requested"];
175
177
  /** Board events that leave a task where its completion check found it; any other event means it moved on meanwhile. */
176
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.
177
180
 
178
181
  /**
179
182
  * Thrown when an assignment's board write is saved but a publish or notify after it failed (#297): the move is
@@ -250,9 +253,17 @@ function acceptRefs(input: unknown): TaskRefs | undefined {
250
253
  /** The same for a plan (issue #31): only its four lists, each of short strings. */
251
254
  function cleanPlan(input: unknown, profile: ProfileSelection = "dev"): TaskPlan {
252
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
+ }
253
264
  let scope: CostPlan;
254
265
  try { scope = cleanCostPlan(r); } catch { throw new PreEffectToolRefusal("invalid review or CI cost scope"); }
255
- return { ...Object.fromEntries((profileData(profile).plan_keys as (keyof TaskPlan)[]).map((k) => [k, k === "paths" ? [...new Set(textList(r[k]).map(normPath))] : textList(r[k])] as const).filter(([, v]) => v.length)), ...scope };
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 };
256
267
  }
257
268
 
258
269
  /** A plan as one line for other owners; the whole plan is on the board. */
@@ -280,6 +291,7 @@ export class Tasks {
280
291
  private sweeping = false;
281
292
 
282
293
  constructor(private readonly d: TasksDeps) {
294
+ d.board.trackPlanRevisions = () => (d.sdlc?.config().mode ?? "off") !== "off";
283
295
  this.repositories = new TaskRepositories(d.cwd, d.repository);
284
296
  for (const task of d.board.list()) this.warmRepository(task);
285
297
  if (d.coordination) {
@@ -526,14 +538,59 @@ export class Tasks {
526
538
  /** What peers other than the owner, the console stream and the log may see of a task. */
527
539
  publicTitle = (task: Task) => (this.isPii(task) ? `#${task.id} [pii]` : `#${task.id} ${task.title}`);
528
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
+
529
582
  /** A task as a cloud peer may see it. */
583
+
530
584
  publicView(task: Task, fullHistory = false): Record<string, unknown> {
531
- 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 } : {}) };
532
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]" };
533
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}]`;
534
589
  const publicHistory = (fullHistory ? history : history.slice(-5)).map(h => ({
535
590
  at: h.at, by: h.by, event: h.event,
536
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 } } : {}),
537
594
  ...(h.supersededBy === undefined ? {} : { supersededBy: screenHistoryText(h.supersededBy, "supersession", h.withheld) }),
538
595
  ...(h.profile === undefined ? {} : { profile: screenHistoryText(h.profile, "profile") }),
539
596
  ...(h.owner === undefined ? {} : { owner: h.owner }),
@@ -545,7 +602,8 @@ export class Tasks {
545
602
  ...(h.reason && TASK_MOVE_REASONS.includes(h.reason) ? { reason: h.reason } : {}),
546
603
  ...(h.sweep ? { sweep: { kind: h.sweep.kind, activity: h.sweep.activity, step: h.sweep.step, at: h.sweep.at } } : {}),
547
604
  }));
548
- return { ...rest, ...(cancelled ? { cancelled: { ...cancelled, reason: screenHistoryText(cancelled.reason, "cancellation", cancelled.withheld), ...(cancelled.supersededBy ? { supersededBy: screenHistoryText(cancelled.supersededBy, "supersession", cancelled.withheld) } : {}) } } : {}), ...(external && this.nameable(external.url) ? { external } : {}), actualDates: actualTaskDates(task), ...(((this.d.sdlc?.config().mode ?? "off") !== "off" || this.d.sdlc?.config().workflow === "gsd-lite") ? { lifecycle: sdlcStatus(this.d.sdlc?.config() ?? sdlcConfig(undefined), task.sdlc, undefined, this.workflowView(task)) } : {}), title, detail, history: publicHistory };
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 };
549
607
  }
550
608
 
551
609
  /** Console results expose direct approval and delivery holds, not just the chosen peer ids. */
@@ -2409,13 +2467,16 @@ export class Tasks {
2409
2467
  const suppliedHead = !!input && typeof input === "object" && (input as { reviewedHead?: unknown }).reviewedHead !== undefined;
2410
2468
  const artifactChanged = next.repo !== task.sdlc?.repo || next.pr !== task.sdlc?.pr;
2411
2469
  if (next.reviewedHead !== undefined && (next.reviewedHead !== task.sdlc?.reviewedHead || (suppliedHead && artifactChanged))) this.mine(task, by, "reviewer", true);
2412
- return this.d.board.update(task.id, by, "lifecycle", { sdlc: next });
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 } : {}) });
2413
2473
  }
2414
2474
 
2415
2475
  async deliveryQueue(repo: string) {
2476
+ const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
2416
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 })));
2417
- const links = this.d.board.list().map(t => ({ id: t.id, after: t.deps ?? [], private: this.isPii(t), receipt: t.sdlc, phase: this.isPii(t) ? undefined : this.workflowView(t) }));
2418
- const report = await readMergeQueue(repo, this.d.sdlc?.config() ?? sdlcConfig(undefined), links, this.d.sdlc?.gh);
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);
2419
2480
  // Policy can change across remote awaits without any board write. Never return its stale public projection.
2420
2481
  const currentPrivacy = this.d.board.list().map(task => ({ id: task.id, private: this.isPii(task) }));
2421
2482
  if (workflowHash(links.map(({ id, private: hidden }) => ({ id, private: hidden }))) !== workflowHash(currentPrivacy)) {
@@ -2424,6 +2485,13 @@ export class Tasks {
2424
2485
 
2425
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 })));
2426
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
+ }
2427
2495
  return report;
2428
2496
  }
2429
2497
  async releaseReadiness(repo: string, liveIssues: number[]) {
@@ -2457,12 +2525,112 @@ export class Tasks {
2457
2525
  const state = { id: task.id, state: task.state, stage, ...(this.isPii(task) ? { private: true } : { criteria: this.workflowView(task) ?? null }) };
2458
2526
  return { schema: 2, state, stateHash: workflowHash(state), sourceHash: workflowHash(config) };
2459
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
+ }
2460
2621
  async lifecycleStatus(id: unknown, refresh = true): Promise<SdlcStatus> {
2461
- const task = this.need(id), config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
2462
- const evidence = task.sdlc && !this.isPii(task) && refresh && config.mode !== "off" ? await readSdlcPr(task.sdlc, this.d.sdlc?.gh) : undefined;
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();
2463
2627
  const accepted = task.sdlc?.acceptance;
2464
- const edit = accepted && !this.isPii(task) && refresh && config.mode !== "off" ? await readSpecEdit(accepted.repo, accepted.issue, this.d.sdlc?.gh) : undefined;
2465
- return sdlcStatus(config, task.sdlc, evidence, this.workflowView(task), edit);
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);
2466
2634
  }
2467
2635
 
2468
2636
  /** #481 S1: only build work starts from an accepted spec. */
@@ -2528,7 +2696,7 @@ export class Tasks {
2528
2696
  this.d.notify(this.specText(task, state, "advice"));
2529
2697
  }
2530
2698
 
2531
- 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> {
2532
2700
  const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
2533
2701
  if (config.mode === "off") return;
2534
2702
  const receipt = task.sdlc && { ...task.sdlc, ...(reviewedHead ? { reviewedHead } : {}) };
@@ -2542,10 +2710,13 @@ export class Tasks {
2542
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`);
2543
2711
  // Advise may continue on the current task, but evidence from its previous link cannot admit that task's review.
2544
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;
2545
- 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);
2546
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;
2547
2717
  if (config.mode === "enforce" && blockers.length) throw new PreEffectToolRefusal(`task #${task.id}: SDLC ${transition} blocked: ${blockers.join("; ")}`);
2548
- if (config.mode === "advise" && blockers.length) this.d.notify(`task ${this.publicTitle(task)}: SDLC ${transition} advice: ${this.isPii(task) ? "private lifecycle evidence unknown" : blockers.join("; ")}`);
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("; ")}`);
2549
2720
  return evidence;
2550
2721
  }
2551
2722
 
@@ -2746,19 +2917,25 @@ export class Tasks {
2746
2917
  /** Whether a completion check is still running for this task. */
2747
2918
  isChecking = (id: number) => this.checking.has(id);
2748
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
+
2749
2937
  async done(by: PeerId, id: unknown, summary?: string, refs?: TaskRefs): Promise<Task> {
2750
- const valid = (): Task => {
2751
- const t = this.need(id, false, true);
2752
- this.mine(t, by, "owner", true);
2753
- if (t.state === "in_review" || isClosedTaskState(t.state)) throw new PreEffectToolRefusal(`task #${t.id} is already ${t.state}`);
2754
- this.ready(t, true);
2755
- if (t.state === "proposed" || t.state === "changes_requested") this.acceptanceCapacity(t);
2756
- if (this.checking.has(t.id)) {
2757
- // The result goes only to the owner the check was started for: anyone who took the task since hears nothing.
2758
- throw new PreEffectToolRefusal(this.checking.get(t.id) === t.owner ? `task #${t.id}: its check is still running; its result comes as a task message` : `task #${t.id}: a check from before it changed hands is still running; call hub_task_done again in a few minutes`);
2759
- }
2760
- return t;
2761
- };
2938
+ const valid = () => this.completionCurrent(id, by, true);
2762
2939
  // The PII screen reads the summary of a done that passes these checks (issue #198); they run again after it.
2763
2940
  const withheld = await this.withholds("summary", summary, valid());
2764
2941
  let task = valid();
@@ -2814,7 +2991,13 @@ export class Tasks {
2814
2991
  this.pendingChecks++;
2815
2992
  this.checkQueue = this.checkQueue
2816
2993
  .then(() => this.finishChecked(pending.id, by, summary, command, seen, withheld))
2817
- .catch((e: Error) => this.d.notify(`task #${pending.id}: its check result could not be recorded: ${e.message}`))
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
+ })
2818
3001
  .finally(() => this.pendingChecks--);
2819
3002
  return pending;
2820
3003
  }
@@ -2938,20 +3121,14 @@ export class Tasks {
2938
3121
  let reviewer = selection?.reviewer ?? task.reviewer, reviewEvidence: PrEvidence | undefined;
2939
3122
  if (this.d.sdlc && this.d.sdlc.config().mode !== "off" && gated !== (reviewer ? "review" : "approval")) {
2940
3123
  reviewEvidence = await this.lifecycleGate(task, reviewer ? "review" : "approval");
2941
- task = this.need(task.id, false, true);
2942
- this.mine(task, by, "owner", true);
2943
- // In every mode: another done may have landed during the readback.
2944
- if (task.state === "in_review" || isClosedTaskState(task.state)) throw new Error(`task #${task.id} is already ${task.state}`);
3124
+ task = this.completionCurrent(task.id, by, false);
2945
3125
  selection = this.poolReview(task, refs);
2946
3126
  reviewer = selection?.reviewer ?? task.reviewer;
2947
3127
  }
2948
3128
  // A delayed local check needs a new readback; the immediate done path already admitted before effects.
2949
3129
  if (reviewer && !admission && (this.d.sdlc?.config().mode ?? "off") !== "off") {
2950
- const observed = task;
2951
3130
  admission = await this.reviewRequestAdmission(task, refs, false, by, reviewEvidence);
2952
- task = this.need(task.id);
2953
- this.mine(task, by, "owner");
2954
- if (task.history.length !== observed.history.length || task.state === "in_review" || isClosedTaskState(task.state)) throw new Error("task changed during review admission");
3131
+ task = this.completionCurrent(task.id, by, false);
2955
3132
  }
2956
3133
  selection = this.poolReview(task, refs);
2957
3134
  reviewer = selection?.reviewer ?? task.reviewer;
@@ -2961,7 +3138,7 @@ export class Tasks {
2961
3138
  const completedRefs = cleanRefs(refs);
2962
3139
  this.warmRepository({ ...task, refs: { ...task.refs, ...completedRefs } });
2963
3140
  const noWrites = this.noWritesRecorded({ ...task, refs: { ...task.refs, ...completedRefs } });
2964
- const next = this.d.board.update(task.id, by, "done", { state: reviewer ? "in_review" : "approved", ...(selection ? { reviewer: reviewer ?? USER } : {}), ...(task.sdlc ? { sdlc: { ...task.sdlc, stage: lifecycleStage(this.d.sdlc?.config() ?? sdlcConfig(undefined), reviewer ? "review" : "merge") } } : {}), refs: completedRefs }, checkOutput ? `${routedSummary ?? ""}\n${checkOutput}`.trim() : routedSummary, { withheld: withheld || undefined, ...(noWrites ? { noWrites: true as const } : {}), ...this.reviewMetadata(selection), ...(reviewCost ? { reviewCost } : {}) });
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 } : {}) });
2965
3142
  this.cohorts.closed(next.id); // its owner's next native turn end settles it in its cohort (issue #107)
2966
3143
  this.note(next, by, "finding", `Task #${next.id} done by ${by}: ${next.title}\n${summary ?? ""}`, withheld);
2967
3144
  this.tellCompleted(next, summary, withheld);
@@ -3052,30 +3229,41 @@ export class Tasks {
3052
3229
  private async reviewRequestAdmission(task: Task, refs?: TaskRefs, preEffect = true, actor: PeerId = task.owner ?? USER, previous?: PrEvidence, sameRequest?: () => boolean): Promise<ReviewReadiness | undefined> {
3053
3230
  const mode = (this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode;
3054
3231
  if (mode === "off") return undefined;
3232
+ let quietDrift = false;
3055
3233
  const guard = () => {
3056
3234
  if (actor !== USER && actor !== task.owner) {
3057
3235
  const holders = Object.entries(this.d.roles ?? {}).filter(([, roles]) => Array.isArray(roles) && roles.includes("conductor")).map(([peer]) => peer);
3058
3236
  if (holders.length !== 1 || holders[0] !== actor) throw new (preEffect ? PreEffectToolRefusal : Error)("review admission authority changed");
3059
3237
  }
3060
3238
  const current = this.need(task.id, false, preEffect);
3061
- if ((this.d.sdlc?.config().mode ?? "off") !== mode || current.history.length !== task.history.length && !sameRequest?.() || current.owner !== task.owner || current.reviewer !== task.reviewer
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
3062
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)
3063
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;
3064
3247
  return current;
3065
3248
  };
3066
3249
  guard();
3067
3250
  const expected = cleanRefs(refs).commit ?? task.refs.commit ?? task.sdlc?.head ?? task.sdlc?.localHead;
3068
- const read: SdlcGh = (args, timeout, cwd, cap) => { guard(); return (this.d.sdlc?.gh ?? sdlcGh)(args, timeout, cwd, cap); };
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
+ };
3069
3256
  const evidence: ReviewReadiness = this.isPii(task)
3070
3257
  ? { ready: false, blockers: ["private review readiness unavailable"] }
3071
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"] };
3072
3259
  const current = guard();
3073
- if (!evidence.ready) {
3074
- 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("; ")}`;
3075
3263
  if (mode === "enforce") throw new (preEffect ? PreEffectToolRefusal : Error)(message);
3076
3264
  this.releaseNotice(message);
3077
3265
  }
3078
- return evidence;
3266
+ return admitted;
3079
3267
  }
3080
3268
 
3081
3269
  private sendReview(task: Task, reviewer: PeerId, why = "", admission?: ReviewReadiness): void {
@@ -3092,6 +3280,9 @@ export class Tasks {
3092
3280
  // A checklist that maps the change to its contract (issue #35): reviewers who check against the written plan catch more.
3093
3281
  const plan = planText(task.plan);
3094
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" : "";
3095
3286
  const checklist = [
3096
3287
  "Checklist:",
3097
3288
  ...this.planGuardItems(task).map(item => `- Check the plan against this live fail before the remaining checks: ${item.line}`),
@@ -3105,6 +3296,9 @@ export class Tasks {
3105
3296
  const specRepo = task.sdlc?.repo ?? task.external?.repo, specIssue = task.sdlc?.issue ?? task.external?.issue;
3106
3297
  const spec = reviewOnly && specRepo && specIssue ? `Spec issue: ${specRepo}#${specIssue}\n` : "";
3107
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` : "";
3108
3302
  const guidance = this.roleGuidance(task, reviewer, true);
3109
3303
  let projection = "";
3110
3304
  try { const text = this.d.projectedReview?.(task); if (text) projection = this.screen(task, sanitize(text), "document projection", reviewer); }
@@ -3112,7 +3306,7 @@ export class Tasks {
3112
3306
  const brief = this.briefText(task);
3113
3307
  // #572 AC3: advisory only, from git state never from task text, so it is safe for a PII task too.
3114
3308
  const checkout = mainCheckoutWarning(this.d.cwd, this.publicMetadataText);
3115
- const body = `Review task #${task.id} [${profileTaskLabel(task)}] ${task.title}\nDone by ${last?.by ?? task.owner}: ${summary}\n${where ? `Where: ${where}\n` : ""}${last?.noWrites ? "no writes recorded since acceptance\n" : ""}${spec}${why ? `${why}\n` : ""}${!plan && task.detail ? `Task detail:\n${task.detail}\n` : ""}${guidance ? guidance + "\n" : ""}${projection ? projection + "\n" : ""}${brief ? brief + "\n" : ""}${checkout ? `Note: ${checkout}.\n` : ""}${findings.length ? `Earlier findings (set a disposition for each):\n${findings.join("\n")}\n` : ""}${checklist}\nGive your verdict with hub_review {id: ${task.id}, verdict: "approved" | "changes_requested", note, unmet}.`;
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.`;
3116
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 } : {}) }));
3117
3311
  }
3118
3312
 
@@ -3161,11 +3355,13 @@ export class Tasks {
3161
3355
  } catch { return "Review brief unavailable: it could not be built."; }
3162
3356
  }
3163
3357
 
3164
- 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> {
3165
3359
  const authority = by === USER ? undefined : this.d.reviewAuthority?.(by, Number(id));
3360
+ let findingGuard: ((task: Task) => void) | undefined;
3166
3361
  // #520: what the coverage was checked against, pinned through every await: the commit and the lifecycle receipt (the
3167
3362
  // inherited reviewedHead lives there).
3168
3363
  let covered: { commit: string | undefined; receipt: string } | undefined;
3364
+ let conformanceIdentity: string | undefined;
3169
3365
  const valid = (): Task => {
3170
3366
  const refused = authority?.();
3171
3367
  if (refused) throw new PreEffectToolRefusal(refused); // rechecked after every await, right before each write
@@ -3174,13 +3370,42 @@ export class Tasks {
3174
3370
  this.mine(t, by, "reviewer", true);
3175
3371
  if (verdict !== "approved" && verdict !== "changes_requested") throw new PreEffectToolRefusal('verdict must be "approved" or "changes_requested"');
3176
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);
3177
3375
  return t;
3178
3376
  };
3377
+ if ((this.d.sdlc?.config().mode ?? "off") !== "off") conformanceIdentity = this.planIdentity(valid());
3179
3378
  const items = textList(unmet);
3180
3379
  if (items.length) note = `${note ?? ""}\nUnmet: ${items.join("; ")}`.trim();
3181
3380
  // #520: an approval accounts for every reviewable path of the brief. enforce refuses before any effect, advise records
3182
3381
  // the gap in the verdict note, off and a person's own verdict skip it.
3183
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
+ }
3184
3409
  // A Tasks without a brief provider (library use; the daemon always has one) has no coverage gate.
3185
3410
  if (verdict === "approved" && by !== USER && mode !== "off" && this.d.reviewBrief) {
3186
3411
  const t = valid(), enforce = mode === "enforce", list = (paths: string[]) => `${paths.slice(0, 20).join(", ")}${paths.length > 20 ? ", ..." : ""}`;
@@ -3206,15 +3431,17 @@ export class Tasks {
3206
3431
  if (gap.length) note = `${note ?? ""}\nCoverage gap (${gap.length}): ${list(gap)}`.trim();
3207
3432
  }
3208
3433
  }
3434
+ if (head !== undefined && resolvedHead && findingHead !== resolvedHead) throw new PreEffectToolRefusal("supplied review head differs from the task resolved head");
3209
3435
  // The PII screen reads the note of a review that passes these checks (issue #198); they run again after it.
3210
- const withheld = await this.withholds("review note", note, valid());
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);
3211
3438
  let task = valid();
3212
3439
  let reviewedHead: string | undefined;
3213
3440
  if (head !== undefined) {
3214
3441
  const checked = sdlcReceipt({ reviewedHead: head }, task.sdlc, this.d.sdlc?.config() ?? sdlcConfig(undefined));
3215
3442
  reviewedHead = checked.reviewedHead;
3216
3443
  }
3217
- 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(); }
3218
3445
  let candidate: Extract<CandidateInput, { kind: "fail" }> | undefined;
3219
3446
  if (verdict === "changes_requested" && items.length && this.d.candidates && !withheld && !this.isPii(task) && !this.d.privateTurn?.(by)) {
3220
3447
  const input: CandidateInput = { project: this.d.store?.project ?? this.d.project, kind: "fail", approach: this.publicTitle(task), symptom: items.join("\n"),
@@ -3232,7 +3459,7 @@ export class Tasks {
3232
3459
  const family = by === USER ? undefined : (this.d.adapterCatalog?.familyOf ?? familyOf)(by, this.d.instances?.());
3233
3460
  const reviewerFamily = poolRequest?.reviewerFamily === family ? family : undefined;
3234
3461
  const reviewerReason = reviewerFamily ? poolRequest?.reviewerReason : undefined;
3235
- 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 } : {}) };
3236
3463
  if (verdict === "approved") {
3237
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);
3238
3465
  if (next.owner) {
@@ -3248,9 +3475,12 @@ export class Tasks {
3248
3475
  this.releaseNotice(`task ${this.publicTitle(next)}: could not tell ${next.owner ?? "its owner"} it was approved`, () =>
3249
3476
  this.tell(next, `Task #${next.id} approved by ${by}.${note ? ` ${this.screen(next, note, "review note", next.owner, withheld)}` : ""}`, pii));
3250
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));
3251
3480
  return next;
3252
3481
  }
3253
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));
3254
3484
  if (candidate && !pii && !this.d.privateTurn?.(by) && this.candidateTextAllowed(JSON.stringify({ candidate, refs: rejected.refs, plan: rejected.plan }), rejected)) {
3255
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 */ }
3256
3486
  }
package/src/hub/ui.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { BACKLOG_SPLIT_SCREEN_TIMEOUT_MS } from "./planning.ts";
2
2
  import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
3
3
  import { boardColumns } from "../ui/task-board.ts";
4
- import { PEER_PALETTE, peerPaletteCss } from "../ui/peer-palette.ts";
4
+ import { PEER_PALETTE, peerPaletteThemeCss } from "../ui/peer-palette.ts";
5
5
  import { PEER_START_RETRY_MS } from "./start-mode.ts";
6
6
  import { taskProgress } from "../ui/task-progress.ts";
7
7
  import { officeModel } from "../ui/office-model.ts";
@@ -37,7 +37,7 @@ export function startDashboard(options: DashboardOptions) {
37
37
  const html = readFileSync(new URL("../ui/index.html", import.meta.url), "utf8")
38
38
  .replace("/* BOARD_MODEL */", () => `const boardColumns = ${boardColumns.toString()}; const peerPalette = ${JSON.stringify(PEER_PALETTE)};`)
39
39
  .replace("/* ADAPTER_DISPLAY */", () => `const adapterDisplay = ${JSON.stringify(ADAPTER_DISPLAY)};`)
40
- .replace("/* PEER_PALETTE_CSS */", () => peerPaletteCss())
40
+ .replace("/* PEER_PALETTE_CSS */", () => peerPaletteThemeCss())
41
41
  .replace("/* TASK_PROGRESS_MODEL */", () => `const taskProgress = ${taskProgress.toString()};`)
42
42
  .replace("/* PEER_START_RETRY_MS */", () => `const PEER_START_RETRY_MS = ${PEER_START_RETRY_MS};`)
43
43
  .replace("/* OFFICE_MODEL */", () => `const officeModel = ((OFFICE_PEER_ORDER) => ${officeModel.toString()})(${JSON.stringify(OFFICE_PEER_ORDER)});`)