@staix/agent-hub 0.30.0 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +13 -7
  3. package/docs/agent-notes/README.md +1 -0
  4. package/docs/agent-notes/adapters.md +5 -2
  5. package/docs/agent-notes/budget.md +1 -0
  6. package/docs/agent-notes/bus.md +9 -4
  7. package/docs/agent-notes/daemon.md +15 -8
  8. package/docs/agent-notes/local-worker.md +1 -0
  9. package/docs/agent-notes/tasks.md +7 -3
  10. package/docs/agent-notes/tests.md +6 -4
  11. package/docs/design/adapters.md +52 -0
  12. package/docs/design/agent-hub-architecture.md +46 -0
  13. package/docs/design/bus.md +139 -1
  14. package/docs/design/task-board.md +82 -0
  15. package/docs/dogfood.md +22 -4
  16. package/docs/events.md +3 -0
  17. package/docs/memory.md +17 -1
  18. package/docs/operations.md +307 -78
  19. package/docs/profiles.md +35 -0
  20. package/docs/quickstart.md +18 -5
  21. package/docs/security.md +154 -60
  22. package/docs/specs/2026-09-19-agent-hub-design.md +84 -14
  23. package/docs/specs/2026-09-19-multi-project-design.md +7 -2
  24. package/docs/verification/2026-11-04-1171-channel-client-evidence.md +31 -0
  25. package/docs/verification/2026-11-05-0.31.0.md +182 -0
  26. package/docs/verification/2026-11-05-claude-implementer-2.1.318.md +70 -0
  27. package/docs/verification/2026-11-05-hardcoded-values-classification-rules.md +257 -0
  28. package/docs/verification/2026-11-05-hardcoded-values-ratchet.md +55 -0
  29. package/docs/verification/2026-11-06-rooms-presence-cost.md +152 -0
  30. package/docs/verification/claude-shell-wrapper-native.py +1 -1
  31. package/docs/verified.json +14 -14
  32. package/package.json +3 -4
  33. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  34. package/plugins/agent-hub/server.js +4393 -3609
  35. package/src/adapters/acp.ts +5 -4
  36. package/src/adapters/channel-peer.ts +72 -3
  37. package/src/adapters/claude-channel-evidence.ts +52 -0
  38. package/src/adapters/claude-channel.ts +61 -18
  39. package/src/adapters/claude-implementer.ts +29 -7
  40. package/src/adapters/claude-print-child.ts +12 -2
  41. package/src/adapters/codex-appserver.ts +13 -12
  42. package/src/adapters/compat.ts +3 -3
  43. package/src/adapters/definitions/builtin-metadata.ts +13 -13
  44. package/src/adapters/definitions/qwen.ts +1 -0
  45. package/src/adapters/local-worker.ts +61 -18
  46. package/src/adapters/pi.ts +48 -6
  47. package/src/adapters/registry-types.ts +12 -0
  48. package/src/adapters/registry.ts +2 -0
  49. package/src/adapters/reviewer-print.ts +50 -11
  50. package/src/broker/server.ts +44 -13
  51. package/src/broker/store.ts +38 -8
  52. package/src/cli/answer-args.ts +50 -0
  53. package/src/cli/attention.ts +17 -2
  54. package/src/cli/backlog-return-args.ts +37 -0
  55. package/src/cli/console-question.ts +110 -0
  56. package/src/cli/console-state.ts +77 -16
  57. package/src/cli/console.ts +54 -1
  58. package/src/cli/handoff-args.ts +15 -5
  59. package/src/cli/handoff-output.ts +15 -2
  60. package/src/cli/help.ts +8 -5
  61. package/src/cli/identity.ts +13 -3
  62. package/src/cli/main.ts +128 -16
  63. package/src/cli/memory.ts +107 -3
  64. package/src/cli/output.ts +29 -6
  65. package/src/cli/pet.ts +4 -4
  66. package/src/cli/results-args.ts +30 -0
  67. package/src/cli/runtime-version-warning.ts +50 -0
  68. package/src/cli/status-lines.ts +41 -1
  69. package/src/cli/tail-render.ts +1 -0
  70. package/src/cli/upgrade-interactive.ts +14 -2
  71. package/src/hub/action-class.ts +9 -1
  72. package/src/hub/board.ts +369 -18
  73. package/src/hub/bus.ts +493 -53
  74. package/src/hub/completion-detail.ts +50 -0
  75. package/src/hub/completions-store.ts +168 -0
  76. package/src/hub/completions.ts +120 -0
  77. package/src/hub/config-apply.ts +7 -5
  78. package/src/hub/config-reload.ts +10 -1
  79. package/src/hub/control-client.ts +40 -4
  80. package/src/hub/daemon.ts +1569 -134
  81. package/src/hub/delivery-failure.ts +14 -0
  82. package/src/hub/delivery-hints.ts +14 -4
  83. package/src/hub/delivery-journal.ts +751 -42
  84. package/src/hub/delivery-superseded.ts +4 -1
  85. package/src/hub/envelope.ts +3 -3
  86. package/src/hub/events.ts +6 -0
  87. package/src/hub/handoff.ts +145 -6
  88. package/src/hub/hub-tools.ts +56 -7
  89. package/src/hub/manager.ts +59 -3
  90. package/src/hub/memory-policy.ts +44 -1
  91. package/src/hub/memory-sync-policy.ts +109 -0
  92. package/src/hub/memory-sync-state.ts +145 -0
  93. package/src/hub/no-writes.ts +1 -1
  94. package/src/hub/operational-settings.ts +11 -2
  95. package/src/hub/operational-view.ts +12 -1
  96. package/src/hub/peers.ts +25 -2
  97. package/src/hub/planning.ts +6 -1
  98. package/src/hub/question-desk.ts +201 -0
  99. package/src/hub/question-detach.ts +6 -0
  100. package/src/hub/questions.ts +260 -0
  101. package/src/hub/research-evidence.ts +106 -0
  102. package/src/hub/restart.ts +14 -0
  103. package/src/hub/review-anchor.ts +181 -0
  104. package/src/hub/review-brief.ts +13 -0
  105. package/src/hub/review-comments.ts +115 -0
  106. package/src/hub/review-findings.ts +7 -2
  107. package/src/hub/review-material.ts +76 -0
  108. package/src/hub/routing.ts +3 -1
  109. package/src/hub/task-sweep.ts +2 -0
  110. package/src/hub/tasks.ts +577 -63
  111. package/src/memory/capture.ts +5 -2
  112. package/src/pet/config.ts +25 -13
  113. package/src/rooms/link.ts +209 -21
  114. package/src/rooms/remote-peer.ts +186 -19
  115. package/src/rooms/service.ts +338 -22
  116. package/src/rooms/store.ts +437 -65
  117. package/src/rooms/wire.ts +24 -6
  118. package/src/ui/index.html +138 -7
  119. package/src/ui/needs.ts +10 -4
  120. package/src/ui/plugin-questions.ts +56 -0
  121. package/src/ui/plugin-results.ts +81 -0
  122. package/src/ui/plugin.ts +207 -27
  123. package/src/pet/window.html +0 -114
  124. package/src/pet/window.jxa.js +0 -122
  125. package/src/pet/window.ts +0 -154
package/src/hub/board.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { parseAttemptReceipt, parseResearchRunners, verifyAttemptReceipt, type AttemptReceiptV1 } from "./research-runner.ts";
2
2
  import { canonicalJson } from "./packs.ts";
3
+ import { parseEvidenceLink, evidenceLinkCanonical, MAX_EVIDENCE_RECORD_BYTES, type EvidenceLinkV1 } from "./research-evidence.ts";
4
+ import { parseStoredReviewComment, reviewCommentIds, emptyReviewCommentCounts, REVIEW_COMMENT_LIMIT, type ReviewCommentRecord, type ReviewCommentFence, type ReviewCommentCounts, type ReviewCommentPackage, type ReviewCommentPackageView } from "./review-comments.ts";
3
5
  import { parseComparisonPlan, comparisonPlanHash, evaluatorIdentityHash, parseExperimentProtocol, protocolHash, parseExperimentAttempt, parseExperimentEvent, parseExperimentHostEvidence, experimentStatus, projectExperimentWithObservations, declaredExperimentEvents,
4
6
  type ComparisonPlanV1, type ExperimentProtocolRecord, type ExperimentAttemptRecord, type ExperimentEventRecord, type ExperimentWriteFence, type DeclaredExperimentEventInput, type ExperimentHostEvidence, type ExperimentStatus } from "./research-experiments.ts";
5
7
  import type { InvocationRow } from "./invocations.ts";
@@ -249,6 +251,7 @@ export interface HistoryEntry {
249
251
  /** Opaque artifact identity captured by the host at the queue-held role write. */
250
252
  queueReviewBinding?: string;
251
253
  reviewerFailure?: { peer: string; head?: string; cause: "start_failed" | "quota" | "no_verdict" };
254
+ resolvedReviewComments?: string[];
252
255
  planning?: { revision: number; fields: (keyof PlanningFields)[]; previous: PlanningFields; pull?: true; split?: { children: { id: number; revision: number; history: number }[] } };
253
256
  }
254
257
  /** #901: one person verdict on a plan claim, with the model label the person says produced the claim. All closed or bounded; nothing here is claim text. */
@@ -259,6 +262,14 @@ export interface ClaimVerdict { slot: number; verdict: (typeof CLAIM_VERDICTS)[n
259
262
  export function claimListId(claims: readonly string[] | undefined): string {
260
263
  return createHash("sha256").update(planFingerprint({ claims: [...(claims ?? [])] })).digest("hex").slice(0, 16);
261
264
  }
265
+ /** #901's one claim identity boundary, shared by person verdicts and declared passage links. */
266
+ export function taskClaimIdentity(task: Pick<Task, "id" | "created" | "plan">, slot: number): { claimId: string; claimRevision: string; slot: number } {
267
+ if (!Number.isSafeInteger(task.id) || task.id < 1 || !Number.isSafeInteger(task.created) || task.created < 0
268
+ || !Number.isSafeInteger(slot) || slot < 1 || slot > Math.min(50, task.plan?.claims?.length ?? 0)) throw new Error("claim slot is unavailable; refresh ahub task show and use a current declared claim slot");
269
+ // ponytail: revision binds the whole ordered list, so another claim's edit invalidates every link.
270
+ // Upgrade to shared per-claim revision bindings at this #901 boundary, preserving exact-list restoration of existing verdicts.
271
+ return { claimId: `claim:${task.id}:${task.created}:${slot}`, claimRevision: claimListId(task.plan?.claims), slot };
272
+ }
262
273
  /** A claim slot is a position in the plan's claim list (at most 50, as the scorecard's own bound); a model label is a bounded token, never prose. */
263
274
  export function parseClaimVerdict(value: unknown, slots: number): ClaimVerdict {
264
275
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("claim verdict must be an object");
@@ -320,9 +331,12 @@ function plannerMetadata(planner: unknown, reason?: PlannerReason): Pick<History
320
331
  return { planner: plannerValue(planner), ...(reason === undefined ? {} : { plannerReason: reason }) };
321
332
  }
322
333
  /** #604: what this project handed to another project, and the last state that project reported (agent-hub#1030 design 4). */
323
- export interface HandoffOutboundRow { remoteProject: string; remoteTask: number; originPeer: string; localTask: number | null; title: string; state: string; pr: number | null; head: string | null; updatedAt: number }
334
+ export interface HandoffOutboundRow { remoteProject: string; remoteTask: number; originPeer: string; localTask: number | null; title: string; state: string; pr: number | null; head: string | null; updatedAt: number;
335
+ /** #733: the target task's flow revision at the last event applied; null for a row from before sequences or from a target that sends none. */
336
+ seq: number | null }
337
+ const OUTBOUND_COLUMNS = "remote_project, remote_task, origin_peer, local_task, title, last_state, pr, head, updated_at, seq";
324
338
  const handoffOutboundFromRow = (row: Record<string, unknown>): HandoffOutboundRow => ({ remoteProject: String(row.remote_project), remoteTask: Number(row.remote_task), originPeer: String(row.origin_peer),
325
- localTask: row.local_task === null ? null : Number(row.local_task), title: String(row.title), state: String(row.last_state), pr: row.pr === null ? null : Number(row.pr), head: row.head === null ? null : String(row.head), updatedAt: Number(row.updated_at) });
339
+ localTask: row.local_task === null ? null : Number(row.local_task), title: String(row.title), state: String(row.last_state), pr: row.pr === null ? null : Number(row.pr), head: row.head === null ? null : String(row.head), updatedAt: Number(row.updated_at), seq: row.seq === null || row.seq === undefined ? null : Number(row.seq) });
326
340
 
327
341
  export interface Task extends PlanningFields {
328
342
  offer?: TaskOffer;
@@ -355,6 +369,8 @@ export interface Task extends PlanningFields {
355
369
  deps?: number[];
356
370
  /** Who routing offers the task first once nothing is left to wait for (issue #207); null when nobody was named. */
357
371
  reserved?: PeerId | null;
372
+ /** #1176: only the person can run this leg. Present (true) only when set; no agent is ever offered it, and it counts toward no planner's WIP. */
373
+ needsPerson?: true;
358
374
  signals: string[];
359
375
  /** consecutive changes_requested verdicts */
360
376
  rejections: number;
@@ -407,6 +423,8 @@ const TASK_SELECT = "SELECT tasks.*, (SELECT CASE WHEN length(CAST(payload AS BL
407
423
  /** Task board in `.agenthub/state/hub.db`. It outlives the hub process: `ahub kill` leaves the file. */
408
424
  export class Board {
409
425
  private readonly db: Database;
426
+ private reviewObserverCacheStamp = "";
427
+ private readonly reviewObserverScopeCache = new Map<string, boolean>();
410
428
  private pendingChanges: { task: Task; entry: HistoryEntry }[] | undefined;
411
429
  /** Every recorded change, as it lands (telemetry, issue #40). */
412
430
  onChange?: (task: Task, entry: HistoryEntry) => void;
@@ -494,6 +512,8 @@ export class Board {
494
512
  if (!have.has(col)) this.db.run(`ALTER TABLE tasks ADD COLUMN "${col}" INTEGER`);
495
513
  }
496
514
  if (!have.has("pinned")) this.db.run("ALTER TABLE tasks ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0");
515
+ // #1176: additive, like `pinned`; an older hub ignores the column.
516
+ if (!have.has("needs_person")) this.db.run("ALTER TABLE tasks ADD COLUMN needs_person INTEGER NOT NULL DEFAULT 0");
497
517
  if (!have.has("draft")) this.db.run("ALTER TABLE tasks ADD COLUMN draft INTEGER NOT NULL DEFAULT 0");
498
518
  if (!have.has("planning_revision")) this.db.run("ALTER TABLE tasks ADD COLUMN planning_revision INTEGER NOT NULL DEFAULT 0");
499
519
  this.db.run(`CREATE TABLE IF NOT EXISTS releases (
@@ -524,12 +544,28 @@ export class Board {
524
544
  // is the ORIGIN's record of what it handed to another project and the last state that project reported. Both are additive: a daemon without `task_handoff` never reads them, so a target
525
545
  // lacking the feature is refused at recovery while either holds a row.
526
546
  this.db.run("CREATE TABLE IF NOT EXISTS task_handoff_origin (task_id INTEGER PRIMARY KEY, project_id TEXT NOT NULL, peer TEXT NOT NULL, origin_task INTEGER, at INTEGER NOT NULL)");
547
+ // #731 (agent-hub#1203): the origin's idempotency key and a digest of what it asked for. A key is unique per origin project, so a replay can never file a second task. Rows from before keep NULL.
548
+ for (const [column, type] of [["handoff_key", "TEXT"], ["request_digest", "TEXT"]] as const)
549
+ if (!(this.db.query("PRAGMA table_info(task_handoff_origin)").all() as { name: string }[]).some(c => c.name === column)) this.db.run(`ALTER TABLE task_handoff_origin ADD COLUMN ${column} ${type}`);
550
+ this.db.run("CREATE UNIQUE INDEX IF NOT EXISTS task_handoff_origin_key ON task_handoff_origin(project_id, handoff_key) WHERE handoff_key IS NOT NULL");
527
551
  this.db.run(`CREATE TABLE IF NOT EXISTS task_handoff_outbound (remote_project TEXT NOT NULL, remote_task INTEGER NOT NULL, origin_peer TEXT NOT NULL, local_task INTEGER,
528
552
  title TEXT NOT NULL, last_state TEXT NOT NULL, pr INTEGER, head TEXT, updated_at INTEGER NOT NULL, PRIMARY KEY(remote_project, remote_task))`);
553
+ // #733: the sequence of the last applied event, so an event that arrives late cannot move a row backwards. Rows from before keep NULL and accept any event.
554
+ if (!(this.db.query("PRAGMA table_info(task_handoff_outbound)").all() as { name: string }[]).some(c => c.name === "seq")) this.db.run("ALTER TABLE task_handoff_outbound ADD COLUMN seq INTEGER");
529
555
  this.db.run(`CREATE TABLE IF NOT EXISTS research_handoffs (
530
556
  campaign_id TEXT NOT NULL, unit_id TEXT NOT NULL, seq INTEGER NOT NULL, revision INTEGER NOT NULL,
531
557
  task_id INTEGER NOT NULL, body TEXT NOT NULL, by_peer TEXT NOT NULL, at INTEGER NOT NULL, private INTEGER NOT NULL,
532
558
  PRIMARY KEY(campaign_id,unit_id,seq))`);
559
+ this.db.run(`CREATE TABLE IF NOT EXISTS research_evidence_links (
560
+ project TEXT NOT NULL, id TEXT NOT NULL, revision INTEGER NOT NULL, task_id INTEGER NOT NULL,
561
+ body TEXT NOT NULL, body_sha256 TEXT NOT NULL, PRIMARY KEY(project,id,revision))`);
562
+ this.db.run(`CREATE TABLE IF NOT EXISTS review_comments (
563
+ task_id INTEGER NOT NULL,task_created INTEGER NOT NULL,id TEXT NOT NULL,body TEXT NOT NULL,lifecycle TEXT NOT NULL,revision INTEGER NOT NULL,
564
+ PRIMARY KEY(task_id,task_created,id))`);
565
+ this.db.run("CREATE INDEX IF NOT EXISTS review_comments_sent ON review_comments(task_id,task_created,id) WHERE lifecycle='sent'");
566
+ this.db.run(`CREATE TABLE IF NOT EXISTS review_comment_packages (
567
+ id TEXT PRIMARY KEY,task_id INTEGER NOT NULL,task_created INTEGER NOT NULL,body TEXT NOT NULL)`);
568
+ this.db.run("CREATE INDEX IF NOT EXISTS review_comment_packages_owner ON review_comment_packages(CASE WHEN json_valid(body)=1 THEN json_extract(body,'$.owner') END,task_id,task_created)");
533
569
  this.db.run(`CREATE TABLE IF NOT EXISTS research_protocols (
534
570
  hash TEXT PRIMARY KEY, project TEXT NOT NULL, campaign_id TEXT NOT NULL, campaign_revision INTEGER NOT NULL, task_id INTEGER NOT NULL, body TEXT NOT NULL,
535
571
  by_peer TEXT NOT NULL, at INTEGER NOT NULL, private INTEGER NOT NULL)`);
@@ -576,26 +612,72 @@ export class Board {
576
612
  this.db.query("INSERT INTO task_handoff_origin(task_id, project_id, peer, origin_task, at) VALUES(?,?,?,?,?) ON CONFLICT(task_id) DO NOTHING").run(taskId, origin.projectId, origin.peer, origin.originTask ?? null, Date.now());
577
613
  }
578
614
 
579
- /** ORIGIN side: what this project handed to another one. Keyed by the target project and its task id, so a repeated event updates one row. */
615
+ /**
616
+ * #731: the task a handoff key already made for this origin project, with the digest of the request that made it, or undefined. A read: a replayed key is answered from here.
617
+ */
618
+ handoffOriginByKey(projectId: string, key: string): { taskId: number; digest: string | null } | undefined {
619
+ const row = this.db.query("SELECT task_id, request_digest FROM task_handoff_origin WHERE project_id=? AND handoff_key=?").get(projectId, key) as { task_id: number; request_digest: string | null } | null;
620
+ return row ? { taskId: row.task_id, digest: row.request_digest } : undefined;
621
+ }
622
+ /**
623
+ * #731 (H9): the proposal and its origin row in ONE transaction, so a crash or a failure between them can never leave a task with no origin (which the origin could then never be told about),
624
+ * and a replayed key answers with the task it already made instead of filing a second one. The unique index is the last guard: even two writers of one key cannot both commit.
625
+ * Observers (`onChange`) run after the commit, as for every other multi-row write.
626
+ */
627
+ proposeHandoff(by: PeerId, t: Parameters<Board["propose"]>[1], origin: { projectId: string; peer: string; originTask?: number; key?: string; digest?: string }): { task: Task; replayed: boolean } {
628
+ return this.atomic(() => {
629
+ if (origin.key !== undefined) {
630
+ const existing = this.handoffOriginByKey(origin.projectId, origin.key);
631
+ if (existing) { const task = this.get(existing.taskId); if (task) return { task, replayed: true }; }
632
+ }
633
+ const task = this.propose(by, t);
634
+ this.db.query("INSERT INTO task_handoff_origin(task_id, project_id, peer, origin_task, at, handoff_key, request_digest) VALUES(?,?,?,?,?,?,?)")
635
+ .run(task.id, origin.projectId, origin.peer, origin.originTask ?? null, Date.now(), origin.key ?? null, origin.digest ?? null);
636
+ return { task, replayed: false };
637
+ });
638
+ }
639
+
640
+ /**
641
+ * ORIGIN side: what this project handed to another one, newest first, for the person's list (`ahub board --handoffs`). Keyed by the target project and its task id, so a repeated event updates
642
+ * one row. Capped for display only: the catch-up never reads this (`handoffOutboundOpen`), so a row the list does not show is still caught up (#733).
643
+ */
580
644
  handoffOutbound(): HandoffOutboundRow[] {
581
- return (this.db.query("SELECT remote_project, remote_task, origin_peer, local_task, title, last_state, pr, head, updated_at FROM task_handoff_outbound ORDER BY updated_at DESC, remote_project, remote_task LIMIT 200").all() as Record<string, unknown>[]).map(handoffOutboundFromRow);
645
+ return (this.db.query(`SELECT ${OUTBOUND_COLUMNS} FROM task_handoff_outbound ORDER BY updated_at DESC, remote_project, remote_task LIMIT 200`).all() as Record<string, unknown>[]).map(handoffOutboundFromRow);
646
+ }
647
+ /** #733 (H5): every row the catch-up still asks about, no cap, oldest first so a long backlog is worked through in the same order every pass. The caller filters with `handoffCatchUpRow`. */
648
+ handoffOutboundOpen(): HandoffOutboundRow[] {
649
+ return (this.db.query(`SELECT ${OUTBOUND_COLUMNS} FROM task_handoff_outbound WHERE last_state != 'cancelled' ORDER BY updated_at, remote_project, remote_task`).all() as Record<string, unknown>[]).map(handoffOutboundFromRow);
582
650
  }
583
651
  handoffOutboundFor(remoteProject: string, remoteTask: number): HandoffOutboundRow | undefined {
584
- const row = this.db.query("SELECT remote_project, remote_task, origin_peer, local_task, title, last_state, pr, head, updated_at FROM task_handoff_outbound WHERE remote_project=? AND remote_task=?").get(remoteProject, remoteTask) as Record<string, unknown> | null;
652
+ const row = this.db.query(`SELECT ${OUTBOUND_COLUMNS} FROM task_handoff_outbound WHERE remote_project=? AND remote_task=?`).get(remoteProject, remoteTask) as Record<string, unknown> | null;
585
653
  return row ? handoffOutboundFromRow(row) : undefined;
586
654
  }
587
- recordHandoffOutbound(row: { remoteProject: string; remoteTask: number; originPeer: string; localTask?: number; title: string; state: string; pr?: number; head?: string }): void {
588
- this.db.query(`INSERT INTO task_handoff_outbound(remote_project, remote_task, origin_peer, local_task, title, last_state, pr, head, updated_at) VALUES(?,?,?,?,?,?,?,?,?)
589
- ON CONFLICT(remote_project, remote_task) DO UPDATE SET last_state=excluded.last_state, pr=COALESCE(excluded.pr, pr), head=COALESCE(excluded.head, head), updated_at=excluded.updated_at`)
590
- .run(row.remoteProject, row.remoteTask, row.originPeer, row.localTask ?? null, row.title, row.state, row.pr ?? null, row.head ?? null, Date.now());
655
+ /**
656
+ * Record a state. With a `seq` the write applies only when it is newer than the stored one (#733 H4: events use a connection each and can arrive out of order), decided inside the one statement so
657
+ * two events cannot both pass a check made before either wrote. Returns whether the row changed. An event with no `seq` (an older target, or the origin's own first write) behaves as before:
658
+ * it applies, and keeps the highest `seq` already stored.
659
+ */
660
+ recordHandoffOutbound(row: { remoteProject: string; remoteTask: number; originPeer: string; localTask?: number; title: string; state: string; pr?: number; head?: string; seq?: number }): { applied: boolean } {
661
+ const result = this.db.query(`INSERT INTO task_handoff_outbound(remote_project, remote_task, origin_peer, local_task, title, last_state, pr, head, updated_at, seq) VALUES(?,?,?,?,?,?,?,?,?,?)
662
+ ON CONFLICT(remote_project, remote_task) DO UPDATE SET last_state=excluded.last_state, pr=COALESCE(excluded.pr, pr), head=COALESCE(excluded.head, head), updated_at=excluded.updated_at, seq=COALESCE(MAX(excluded.seq, seq), excluded.seq, seq)
663
+ WHERE excluded.seq IS NULL OR seq IS NULL OR excluded.seq > seq`)
664
+ .run(row.remoteProject, row.remoteTask, row.originPeer, row.localTask ?? null, row.title, row.state, row.pr ?? null, row.head ?? null, Date.now(), row.seq ?? null);
665
+ return { applied: result.changes > 0 };
591
666
  }
592
667
  /** A digest over both tables, so a recovery that restores or loses a row is seen. */
593
668
  handoffIntegrity(): { origins: number; outbound: number; digest: string } {
594
669
  return this.db.transaction(() => {
595
670
  const digest = createHash("sha256").update("agent-hub.handoff-integrity/v1\n");
596
671
  let origins = 0, outbound = 0;
597
- for (const row of this.db.query("SELECT task_id, project_id, peer, origin_task, at FROM task_handoff_origin ORDER BY task_id").iterate()) { digest.update(JSON.stringify(row)).update("\n"); origins++; }
598
- for (const row of this.db.query("SELECT remote_project, remote_task, origin_peer, local_task, title, last_state, pr, head, updated_at FROM task_handoff_outbound ORDER BY remote_project, remote_task").iterate()) { digest.update(JSON.stringify(row)).update("\n"); outbound++; }
672
+ for (const row of this.db.query("SELECT task_id, project_id, peer, origin_task, at, handoff_key, request_digest FROM task_handoff_origin ORDER BY task_id").iterate() as Iterable<Record<string, unknown>>) {
673
+ // A row from before handoff keys has no key columns in its digest, so an upgrade does not change what a recovery compares (#731).
674
+ if (row.handoff_key === null) { delete row.handoff_key; delete row.request_digest; }
675
+ digest.update(JSON.stringify(row)).update("\n"); origins++;
676
+ }
677
+ for (const row of this.db.query(`SELECT ${OUTBOUND_COLUMNS} FROM task_handoff_outbound ORDER BY remote_project, remote_task`).iterate() as Iterable<Record<string, unknown>>) {
678
+ if (row.seq === null) delete row.seq; // a row from before sequences keeps its digest (#733)
679
+ digest.update(JSON.stringify(row)).update("\n"); outbound++;
680
+ }
599
681
  return { origins, outbound, digest: digest.digest("hex") };
600
682
  })();
601
683
  }
@@ -912,6 +994,257 @@ export class Board {
912
994
  });
913
995
  }
914
996
 
997
+ /** Immutable declared link revisions. Caller/source/privacy admission belongs to Tasks. */
998
+ evidenceLink(project: string, id: string, revision?: number): EvidenceLinkV1 | undefined {
999
+ if (!researchText(project) || !/^ev:[a-f0-9]{32}$/.test(id) || revision !== undefined && !researchInteger(revision)) throw new Error("invalid evidence identity; use the recorded evidence id and revision");
1000
+ const row = this.db.query(`SELECT project,id,revision,task_id,CASE WHEN length(CAST(body AS BLOB))<=${MAX_EVIDENCE_RECORD_BYTES} THEN body END AS body,body_sha256
1001
+ FROM research_evidence_links WHERE project=? AND id=?${revision === undefined ? "" : " AND revision=?"} ORDER BY revision DESC LIMIT 1`)
1002
+ .get(...(revision === undefined ? [project, id] : [project, id, revision])) as { project: string; id: string; revision: number; task_id: number; body: string | null; body_sha256: string } | null;
1003
+ if (!row) return undefined;
1004
+ try {
1005
+ if (typeof row.body !== "string") throw new Error("invalid body");
1006
+ const link = parseEvidenceLink(JSON.parse(row.body)), canonical = evidenceLinkCanonical(link);
1007
+ if (link.id !== row.id || link.revision !== row.revision || link.taskId !== row.task_id || createHash("sha256").update(canonical).digest("hex") !== row.body_sha256) throw new Error("invalid binding");
1008
+ return link;
1009
+ } catch { throw new Error("evidence storage invalid; stop the hub and restore a trusted hub.db backup; then restart and retry"); }
1010
+ }
1011
+
1012
+ recordEvidenceLink(project: string, input: EvidenceLinkV1, fence: { taskFlowRevision: number; taskCreated: number; owner: PeerId }): EvidenceLinkV1 {
1013
+ const link = parseEvidenceLink(input), canonical = evidenceLinkCanonical(link);
1014
+ if (!researchText(project) || !researchInteger(fence.taskFlowRevision, 0) || !researchInteger(fence.taskCreated, 0) || fence.owner !== link.recordedBy) throw new Error("evidence write binding is invalid; refresh the task before retrying");
1015
+ return this.atomic(() => {
1016
+ const task = this.get(link.taskId);
1017
+ if (!task || task.created !== fence.taskCreated || task.history.length !== fence.taskFlowRevision || task.owner !== fence.owner || task.state !== "in_progress" || task.profile !== "research"
1018
+ || task.signals.includes("pii") || task.signals.includes("pii_path")) throw new Error("evidence task authority changed; refresh ahub task show before retrying");
1019
+ const identity = taskClaimIdentity(task, Number(link.claimId.split(":").at(-1)));
1020
+ if (identity.claimId !== link.claimId || identity.claimRevision !== link.claimRevision) throw new Error("evidence claim revision changed; refresh the claim binding before retrying");
1021
+ const latest = this.evidenceLink(project, link.id), next = (latest?.revision ?? 0) + 1;
1022
+ if (latest && (latest.taskId !== link.taskId || latest.claimId !== link.claimId)) throw new Error("evidence id belongs to another claim; use a new evidence id");
1023
+ const known = this.evidenceLink(project, link.id, link.revision);
1024
+ if (known) {
1025
+ if (evidenceLinkCanonical(known) !== canonical) throw new Error(`evidence revision already records different content; use next revision ${next} or a new evidence id`);
1026
+ return known;
1027
+ }
1028
+ if (link.revision !== next) throw new Error(`evidence revision is out of sequence; expected next revision ${next}; use that revision or replay an unchanged recorded revision`);
1029
+ this.taskWrite(() => this.db.query("INSERT INTO research_evidence_links(project,id,revision,task_id,body,body_sha256) VALUES(?,?,?,?,?,?)")
1030
+ .run(project, link.id, link.revision, link.taskId, canonical, createHash("sha256").update(canonical).digest("hex")));
1031
+ return this.evidenceLink(project, link.id, link.revision)!;
1032
+ });
1033
+ }
1034
+
1035
+ hasResearchEvidenceState(): boolean {
1036
+ return (this.db.query("SELECT EXISTS(SELECT 1 FROM research_evidence_links) AS present").get() as { present: number }).present === 1;
1037
+ }
1038
+
1039
+ researchEvidenceIntegrity(): { links: number; revisions: number; digest: string } {
1040
+ return this.db.transaction(() => {
1041
+ const digest = createHash("sha256").update("agent-hub.research-evidence/v1\n");
1042
+ let links = 0, revisions = 0, previous: string | undefined, previousRevision = 0;
1043
+ let claim: string | undefined, task: number | undefined;
1044
+ for (const row of this.db.query("SELECT project,id,revision FROM research_evidence_links ORDER BY project,id,revision").iterate() as Iterable<{ project: string; id: string; revision: number }>) {
1045
+ const link = this.evidenceLink(row.project, row.id, row.revision);
1046
+ if (!link) throw new Error("evidence storage changed during integrity read; retry recovery inspection");
1047
+ const key = JSON.stringify([row.project, row.id]);
1048
+ if (key !== previous) { links++; previous = key; previousRevision = 0; claim = link.claimId; task = link.taskId; }
1049
+ if (row.revision !== previousRevision + 1 || link.claimId !== claim || link.taskId !== task) throw new Error("evidence revision history is incomplete or changed claims; stop the hub and restore a trusted hub.db backup; then restart and retry recovery");
1050
+ previousRevision = row.revision; revisions++;
1051
+ digest.update(JSON.stringify([row.project, evidenceLinkCanonical(link)])).update("\n");
1052
+ }
1053
+ return { links, revisions, digest: digest.digest("hex") };
1054
+ })();
1055
+ }
1056
+
1057
+ reviewComments(task: number, created: number): ReviewCommentRecord[] {
1058
+ const rows = this.db.query(`SELECT id,lifecycle,revision,CASE WHEN length(CAST(body AS BLOB))<=32768 THEN body END AS body FROM review_comments WHERE task_id=? AND task_created=? ORDER BY id LIMIT ${REVIEW_COMMENT_LIMIT + 1}`).all(task, created) as { id: string; lifecycle: string; revision: number; body: string | null }[];
1059
+ if (rows.length > REVIEW_COMMENT_LIMIT) throw new Error("review comment storage limit invalid; restore a trusted hub.db backup before retrying");
1060
+ return rows.map(row => {
1061
+ try {
1062
+ if (typeof row.body !== "string") throw new Error("missing body");
1063
+ const record = parseStoredReviewComment(JSON.parse(row.body));
1064
+ if (record.task !== task || record.taskCreated !== created || record.thread.id !== row.id || record.lifecycle !== row.lifecycle || record.revision !== row.revision) throw new Error("binding differs");
1065
+ return record;
1066
+ } catch { throw new Error("review comment storage invalid; stop the hub and restore a trusted hub.db backup, then restart and retry"); }
1067
+ });
1068
+ }
1069
+ reviewCommentCounts(task: number, created: number): ReviewCommentCounts {
1070
+ const counts = emptyReviewCommentCounts();
1071
+ for (const row of this.db.query("SELECT lifecycle,COUNT(*) AS n FROM review_comments WHERE task_id=? AND task_created=? GROUP BY lifecycle").all(task, created) as { lifecycle: string; n: number }[]) {
1072
+ if (!Object.hasOwn(counts, row.lifecycle)) throw new Error("review comment storage invalid; restore a trusted hub.db backup before retrying");
1073
+ counts[row.lifecycle as keyof ReviewCommentCounts] = row.n;
1074
+ }
1075
+ return counts;
1076
+ }
1077
+ private reviewCommentFence(fence: ReviewCommentFence): Task {
1078
+ const task = this.get(fence.task);
1079
+ if (!task || task.created !== fence.taskCreated || task.history.length !== fence.flow || task.owner !== fence.owner || (task.refs.commit ?? task.sdlc?.head ?? task.sdlc?.localHead) !== fence.head) throw new Error("review task changed; refresh the task review before retrying");
1080
+ return task;
1081
+ }
1082
+ putReviewComment(input: ReviewCommentRecord, fence: ReviewCommentFence, expected?: number): ReviewCommentRecord {
1083
+ const record = parseStoredReviewComment(input);
1084
+ return this.atomic(() => {
1085
+ this.reviewCommentFence(fence);
1086
+ if (record.task !== fence.task || record.taskCreated !== fence.taskCreated || record.lifecycle !== "draft") throw new Error("review comment binding changed; refresh the draft before retrying");
1087
+ const comments = this.reviewComments(record.task, record.taskCreated), previous = comments.find(row => row.thread.id === record.thread.id);
1088
+ if (previous ? previous.lifecycle !== "draft" || previous.revision !== expected || record.revision !== previous.revision + 1 : expected !== undefined || record.revision !== 1 || comments.length >= REVIEW_COMMENT_LIMIT) throw new Error("review draft changed or the comment limit is full; refresh the draft list before retrying");
1089
+ this.taskWrite(() => this.db.query("INSERT INTO review_comments(task_id,task_created,id,body,lifecycle,revision) VALUES(?,?,?,?,?,?) ON CONFLICT(task_id,task_created,id) DO UPDATE SET body=excluded.body,lifecycle=excluded.lifecycle,revision=excluded.revision")
1090
+ .run(record.task, record.taskCreated, record.thread.id, JSON.stringify(record), record.lifecycle, record.revision));
1091
+ return this.reviewComments(record.task, record.taskCreated).find(row => row.thread.id === record.thread.id)!;
1092
+ });
1093
+ }
1094
+ deleteReviewComment(id: string, revision: number, fence: ReviewCommentFence): { deleted: boolean; record: ReviewCommentRecord } {
1095
+ return this.atomic(() => {
1096
+ this.reviewCommentFence(fence);
1097
+ const record = this.reviewComments(fence.task, fence.taskCreated).find(row => row.thread.id === id);
1098
+ if (!record) throw new Error("review comment is unavailable; refresh the draft list before retrying");
1099
+ if (record.lifecycle === "dismissed") return { deleted: false, record };
1100
+ if (record.revision !== revision) throw new Error("review comment revision changed; refresh the list before retrying");
1101
+ if (record.lifecycle === "draft") {
1102
+ this.taskWrite(() => this.db.query("DELETE FROM review_comments WHERE task_id=? AND task_created=? AND id=?").run(fence.task, fence.taskCreated, id));
1103
+ return { deleted: true, record };
1104
+ }
1105
+ const dismissed: ReviewCommentRecord = { ...record, lifecycle: "dismissed", revision: record.revision + 1 };
1106
+ this.taskWrite(() => this.db.query("UPDATE review_comments SET body=?,lifecycle=?,revision=? WHERE task_id=? AND task_created=? AND id=?").run(JSON.stringify(dismissed), dismissed.lifecycle, dismissed.revision, fence.task, fence.taskCreated, id));
1107
+ return { deleted: false, record: dismissed };
1108
+ });
1109
+ }
1110
+ sendReviewComments(selected: readonly ReviewCommentRecord[], packet: ReviewCommentPackage, fence: ReviewCommentFence): Task {
1111
+ return this.atomic(() => {
1112
+ const task = this.reviewCommentFence(fence), rows = this.reviewComments(task.id, task.created);
1113
+ if (!selected.length || selected.length > REVIEW_COMMENT_LIMIT || new Set(selected.map(row => row.thread.id)).size !== selected.length || selected.length !== packet.ids.length
1114
+ || task.id !== packet.task || task.created !== packet.taskCreated || task.owner !== packet.owner || selected.some(row => !packet.ids.includes(row.thread.id))) throw new Error("review package binding changed; refresh the selected drafts before sending");
1115
+ const encodedPacket = JSON.stringify(packet);
1116
+ if (typeof encodedPacket !== "string" || Buffer.byteLength(encodedPacket, "utf8") > 32768) throw new Error("review package storage exceeds 32768 UTF-8 bytes; select fewer ids before sending");
1117
+ for (const expected of selected) {
1118
+ const record = rows.find(row => row.thread.id === expected.thread.id);
1119
+ if (!record || record.lifecycle !== "draft" || record.revision !== expected.revision) throw new Error("review drafts changed; refresh the list before sending");
1120
+ const sent: ReviewCommentRecord = { ...record, lifecycle: "sent", sentAt: packet.at, revision: record.revision + 1 };
1121
+ this.taskWrite(() => this.db.query("UPDATE review_comments SET body=?,lifecycle=?,revision=? WHERE task_id=? AND task_created=? AND id=?").run(JSON.stringify(sent), sent.lifecycle, sent.revision, task.id, task.created, record.thread.id));
1122
+ }
1123
+ this.taskWrite(() => this.db.query("INSERT INTO review_comment_packages(id,task_id,task_created,body) VALUES(?,?,?,?)").run(packet.id, task.id, task.created, encodedPacket));
1124
+ return this.update(task.id, "user", `person review comments sent (${selected.length})`, {}, undefined, {}, task.history.length);
1125
+ });
1126
+ }
1127
+ reviewCommentPackages(task: number, created: number): ReviewCommentPackageView[] {
1128
+ const rows = this.db.query("SELECT id,CASE WHEN length(CAST(body AS BLOB))<=32768 THEN body END AS body FROM review_comment_packages WHERE task_id=? AND task_created=? ORDER BY id LIMIT 65").all(task, created) as { id: string; body: string | null }[];
1129
+ if (rows.length > 64) throw new Error("review comment package storage limit invalid; restore a trusted hub.db backup before retrying");
1130
+ return rows.map(row => {
1131
+ try {
1132
+ if (typeof row.body !== "string") throw new Error();
1133
+ const packet = JSON.parse(row.body) as ReviewCommentPackage;
1134
+ if (!packet || packet.id !== row.id || packet.task !== task || packet.taskCreated !== created || !Array.isArray(packet.ids) || packet.ids.length < 1 || packet.ids.length > REVIEW_COMMENT_LIMIT) throw new Error();
1135
+ const ids = reviewCommentIds(packet.ids);
1136
+ if (packet.visibleIds !== undefined && (reviewCommentIds(packet.visibleIds).some(id => !ids.includes(id)))) throw new Error();
1137
+ const delivery = packet.delivery ?? "unconfirmed";
1138
+ if (delivery !== "queued" && delivery !== "unconfirmed") throw new Error();
1139
+ return { id: row.id, count: packet.ids.length, delivery: packet.body && packet.envelopeId && packet.trace && packet.ts !== undefined && packet.publicationEvidenceVersion === 1 ? delivery : "unavailable" };
1140
+ } catch { throw new Error("review comment package storage invalid; stop the hub and restore a trusted hub.db backup, then restart and retry"); }
1141
+ });
1142
+ }
1143
+ reviewCommentPackage(id: string): ReviewCommentPackage | undefined {
1144
+ const row = this.db.query("SELECT CASE WHEN length(CAST(body AS BLOB))<=32768 THEN body END AS body FROM review_comment_packages WHERE id=?").get(id) as { body: string | null } | null;
1145
+ if (!row) return undefined;
1146
+ try {
1147
+ if (typeof row.body !== "string") throw new Error();
1148
+ const packet = JSON.parse(row.body) as ReviewCommentPackage;
1149
+ if (!packet || packet.id !== id || !Number.isSafeInteger(packet.task) || !Number.isSafeInteger(packet.taskCreated) || !Array.isArray(packet.ids)
1150
+ || packet.ids.length < 1 || packet.ids.length > REVIEW_COMMENT_LIMIT || reviewCommentIds(packet.ids).length !== packet.ids.length
1151
+ || typeof packet.owner !== "string" || typeof packet.head !== "string" || typeof packet.at !== "string"
1152
+ || packet.delivery !== undefined && packet.delivery !== "queued" && packet.delivery !== "unconfirmed"
1153
+ || packet.publicationEvidenceVersion !== undefined && packet.publicationEvidenceVersion !== 1
1154
+ || packet.visibleIds !== undefined && (reviewCommentIds(packet.visibleIds).some(value => !packet.ids.includes(value)))
1155
+ || packet.body !== undefined && (typeof packet.body !== "string" || Buffer.byteLength(packet.body, "utf8") > 12000)
1156
+ || packet.envelopeId !== undefined && !/^[a-f0-9-]{36}$/.test(packet.envelopeId)
1157
+ || packet.trace !== undefined && !/^[a-f0-9-]{36}$/.test(packet.trace)
1158
+ || packet.ts !== undefined && (!Number.isSafeInteger(packet.ts) || packet.ts < 0)) throw new Error();
1159
+ return packet;
1160
+ } catch { throw new Error("review comment package storage invalid; stop the hub and restore a trusted hub.db backup, then restart and retry"); }
1161
+ }
1162
+ markReviewCommentPackageQueued(id: string): void {
1163
+ this.taskWrite(() => {
1164
+ const packet = this.reviewCommentPackage(id);
1165
+ if (!packet || !packet.body || !packet.envelopeId || !packet.trace || packet.ts === undefined) throw new Error("review comment package is unavailable for retry; inspect the task review before sending a new comment");
1166
+ return this.db.query("UPDATE review_comment_packages SET body=? WHERE id=?").run(JSON.stringify({ ...packet, delivery: "queued" }), id);
1167
+ });
1168
+ }
1169
+ hasOpenReviewCommentsFor(owner: string, taskId?: number): boolean {
1170
+ const taskScope = taskId === undefined ? "" : " AND task.id=?", packetScope = taskId === undefined ? "" : " AND packet.task_id=?";
1171
+ const currentOwner = this.db.query(`SELECT EXISTS(SELECT 1 FROM review_comments AS comment JOIN tasks AS task ON task.id=comment.task_id AND task.created=comment.task_created
1172
+ WHERE task.owner=? AND comment.lifecycle='sent'${taskScope}) AS present`).get(...(taskId === undefined ? [owner] : [owner, taskId])) as { present: number };
1173
+ if (currentOwner.present === 1) return true;
1174
+ const savedOwner = this.db.query(`WITH candidates AS (
1175
+ SELECT packet.id,packet.task_id,packet.task_created FROM review_comment_packages AS packet
1176
+ WHERE CASE WHEN json_valid(packet.body)=1 THEN json_extract(packet.body,'$.owner') END=?${packetScope}
1177
+ AND EXISTS(SELECT 1 FROM json_each(packet.body,'$.ids') AS package_id
1178
+ JOIN review_comments AS comment ON comment.task_id=packet.task_id AND comment.task_created=packet.task_created AND comment.id=package_id.value
1179
+ WHERE comment.lifecycle='sent')
1180
+ LIMIT 501
1181
+ ) SELECT COUNT(*) AS count FROM candidates`).get(...(taskId === undefined ? [owner] : [owner, taskId])) as { count: number };
1182
+ return savedOwner.count > 0;
1183
+ }
1184
+ /**
1185
+ * Durable task-scoped exclusion for sent person comments and anchored findings; resolved/dismissed comments no longer hold memory.
1186
+ * ponytail: anchored-finding scope is conservative until task close; upgrade to a persisted review-package epoch for precise closure. Scans cap at 500
1187
+ * tasks and 16 MiB aggregate history; corrupt or over-limit state fails closed. A stamp over local total_changes and cross-connection
1188
+ * data_version makes repeated progress reads O(1) until the board changes.
1189
+ */
1190
+ hasReviewObserverScope(peer: string, taskId: number): boolean {
1191
+ try {
1192
+ const dataVersion = this.db.query("PRAGMA data_version").get() as { data_version: number };
1193
+ const totalChanges = this.db.query("SELECT total_changes() AS changes").get() as { changes: number };
1194
+ const stamp = `${dataVersion.data_version}:${totalChanges.changes}`;
1195
+ if (stamp !== this.reviewObserverCacheStamp) { this.reviewObserverScopeCache.clear(); this.reviewObserverCacheStamp = stamp; }
1196
+ const cacheKey = JSON.stringify([peer, taskId]), cached = this.reviewObserverScopeCache.get(cacheKey);
1197
+ if (cached !== undefined) return cached;
1198
+ const scope = " AND id=?";
1199
+ const params = [peer, peer, taskId];
1200
+ const counts = this.db.query(`WITH candidate AS (
1201
+ SELECT history FROM tasks WHERE state NOT IN ('approved','cancelled') AND (owner=? OR reviewer=?)${scope} LIMIT 501
1202
+ )
1203
+ SELECT COUNT(*) AS tasks, COALESCE(SUM(length(CAST(history AS BLOB))),0) AS bytes,
1204
+ SUM(CASE WHEN json_valid(history)<>1 THEN 1 ELSE 0 END) AS invalid FROM candidate`).get(...params) as { tasks: number; bytes: number; invalid: number };
1205
+ let scoped = counts.tasks > 500 || counts.bytes > 16 * 1024 * 1024 || counts.invalid > 0;
1206
+ if (!scoped) {
1207
+ const comments = this.hasOpenReviewCommentsFor(peer, taskId);
1208
+ const reviewerCommentScope = this.db.query(`WITH candidate AS (
1209
+ SELECT id,created FROM tasks WHERE state='in_review' AND reviewer=?${scope} LIMIT 501
1210
+ ) SELECT EXISTS(SELECT 1 FROM candidate JOIN review_comments AS comment ON comment.task_id=candidate.id AND comment.task_created=candidate.created
1211
+ WHERE comment.lifecycle='sent') AS present`).get(peer, taskId) as { present: number };
1212
+ const findings = this.db.query(`WITH candidate AS (
1213
+ SELECT id,history FROM tasks WHERE state NOT IN ('approved','cancelled') AND (owner=? OR reviewer=?)${scope} LIMIT 501
1214
+ )
1215
+ SELECT EXISTS(SELECT 1 FROM candidate, json_each(candidate.history) AS history,
1216
+ json_each(json_extract(history.value,'$.reviewFindings.items')) AS item
1217
+ WHERE json_type(history.value,'$.reviewFindings.items')='array'
1218
+ AND json_type(item.value,'$.anchor')='object') AS present`).get(...params) as { present: number };
1219
+ scoped = comments || reviewerCommentScope.present === 1 || findings.present === 1;
1220
+ }
1221
+ this.reviewObserverScopeCache.set(cacheKey, scoped);
1222
+ if (this.reviewObserverScopeCache.size > 512) this.reviewObserverScopeCache.delete(this.reviewObserverScopeCache.keys().next().value as string);
1223
+ return scoped;
1224
+ } catch { return true; }
1225
+ }
1226
+ reviewCommentResolution(task: Task, ids: readonly string[]): string[] {
1227
+ const parsed = reviewCommentIds(ids), records = this.reviewComments(task.id, task.created);
1228
+ return parsed.flatMap(id => {
1229
+ const record = records.find(row => row.thread.id === id);
1230
+ if (!record || record.lifecycle === "draft") throw new Error("only sent comments can be resolved; refresh the task review and use sent comment ids");
1231
+ return record.lifecycle === "sent" ? [id] : []; // Dismissal is terminal and can never be reopened by the owner.
1232
+ });
1233
+ }
1234
+ hasReviewCommentsState(): boolean {
1235
+ return (this.db.query(`SELECT EXISTS(SELECT 1 FROM review_comments) OR EXISTS(SELECT 1 FROM review_comment_packages)
1236
+ OR EXISTS(SELECT 1 FROM tasks AS task,json_each(task.history) AS history,json_each(json_extract(history.value,'$.reviewFindings.items')) AS finding
1237
+ WHERE json_type(finding.value,'$.anchor')='object') AS present`).get() as { present: number }).present === 1;
1238
+ }
1239
+ reviewCommentsIntegrity(): { comments: number; packages: number; digest: string } {
1240
+ return this.db.transaction(() => {
1241
+ const digest = createHash("sha256").update("agent-hub.review-comments/v1\n"); let comments = 0, packages = 0;
1242
+ for (const row of this.db.query("SELECT DISTINCT task_id,task_created FROM review_comments ORDER BY task_id,task_created").iterate() as Iterable<{ task_id: number; task_created: number }>) for (const record of this.reviewComments(row.task_id, row.task_created)) { digest.update(JSON.stringify(record)).update("\n"); comments++; }
1243
+ for (const row of this.db.query("SELECT id,task_id,task_created,body FROM review_comment_packages ORDER BY id").iterate()) { digest.update(JSON.stringify(row)).update("\n"); packages++; }
1244
+ return { comments, packages, digest: digest.digest("hex") };
1245
+ })();
1246
+ }
1247
+
915
1248
  hasResearchState(): boolean {
916
1249
  return (this.db.query("SELECT EXISTS(SELECT 1 FROM research_campaigns) OR EXISTS(SELECT 1 FROM research_units) OR EXISTS(SELECT 1 FROM research_protocols) OR EXISTS(SELECT 1 FROM research_attempts) OR EXISTS(SELECT 1 FROM research_attempt_events) OR EXISTS(SELECT 1 FROM research_attempt_observations) OR EXISTS(SELECT 1 FROM research_runner_receipts) AS present").get() as { present: number }).present === 1;
917
1250
  }
@@ -1421,7 +1754,7 @@ export class Board {
1421
1754
  }
1422
1755
  plannerWip(peer: string, except?: number): number {
1423
1756
  const row = this.db.query(`SELECT COUNT(*) AS n FROM tasks WHERE state NOT IN ('approved', 'cancelled', 'backlog') AND (? IS NULL OR id != ?) AND
1424
- (owner = ? OR (owner IS NULL AND state = 'proposed' AND reserved = ? AND
1757
+ (owner = ? OR (owner IS NULL AND state = 'proposed' AND reserved = ? AND needs_person = 0 AND
1425
1758
  json_extract((SELECT value FROM json_each(tasks.history) WHERE json_extract(value, '$.event') IN ('backlog pulled', 'backlog returned') ORDER BY CAST(key AS INTEGER) DESC LIMIT 1), '$.planning.pull') = 1))`).get(except ?? null, except ?? null, peer, peer) as { n: number };
1426
1759
  return row.n;
1427
1760
  }
@@ -1435,7 +1768,7 @@ export class Board {
1435
1768
  const rows = this.db.query(`SELECT peer, kind, COUNT(*) AS n FROM (
1436
1769
  SELECT owner AS peer, CASE WHEN state = 'in_review' THEN 'reviewWait' ELSE 'active' END AS kind FROM tasks WHERE state NOT IN ('approved', 'cancelled', 'backlog') AND owner IS NOT NULL
1437
1770
  UNION ALL
1438
- SELECT reserved AS peer, 'reserved' AS kind FROM tasks WHERE state = 'proposed' AND owner IS NULL AND reserved IS NOT NULL AND
1771
+ SELECT reserved AS peer, 'reserved' AS kind FROM tasks WHERE state = 'proposed' AND owner IS NULL AND reserved IS NOT NULL AND needs_person = 0 AND
1439
1772
  json_extract((SELECT value FROM json_each(tasks.history) WHERE json_extract(value, '$.event') IN ('backlog pulled', 'backlog returned') ORDER BY CAST(key AS INTEGER) DESC LIMIT 1), '$.planning.pull') = 1
1440
1773
  ) GROUP BY peer, kind`).all() as { peer: string; kind: "active" | "reviewWait" | "reserved"; n: number }[];
1441
1774
  const result: Record<string, { active: number; reviewWait: number; reserved: number }> = {};
@@ -1521,6 +1854,11 @@ export class Board {
1521
1854
  }
1522
1855
 
1523
1856
  /** Tasks per state, without parsing a single row: `status.json` is rewritten on every bus event and the board only grows. */
1857
+ /** #1176: ids of open cards only the person can run, for status and the board (ids only, never text). Backlog first, then the rest, by id. */
1858
+ personRunIds(): number[] {
1859
+ return (this.db.query("SELECT id FROM tasks WHERE needs_person = 1 AND state NOT IN ('approved', 'cancelled') ORDER BY id").all() as { id: number }[]).map(row => row.id);
1860
+ }
1861
+
1524
1862
  counts(): Record<string, number> {
1525
1863
  const rows = this.db.query("SELECT state, COUNT(*) AS n FROM tasks GROUP BY state").all() as { state: string; n: number }[];
1526
1864
  return Object.fromEntries(rows.map((r) => [r.state, r.n]));
@@ -1528,7 +1866,8 @@ export class Board {
1528
1866
 
1529
1867
  /** The only way a task changes. Validates the move, records who did what, returns the new row. */
1530
1868
  /** A `plan` in the patch replaces the old one whole: a new plan is the owner's current intent, not an addition. */
1531
- update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "planner" | "reviewer" | "reserved" | "refs" | "plan" | "rejections" | "signals" | "sdlc" | "role" | "cancelled" | "codeSymbols">>, note?: string, extra: Pick<HistoryEntry, "invocation" | "plannerReason" | "planAuthor" | "selfReview" | "profile" | "sweep" | "reason" | "from" | "capacity" | "conductorManual" | "withheld" | "noWrites" | "reviewCost" | "reviewContext" | "reviewScope" | "evidenceOnly" | "reviewedHead" | "reviewFindings" | "unmetCriteria" | "deliverables" | "checkKey" | "missingKeys" | "claim" | "planObservation" | "planDeparture" | "planDepartureInvalidated" | "specReport" | "specRefBinding" | keyof ReviewerHistory> = {}, expectedFlowRevision?: number, codeSymbolsAuthority?: typeof HOST_CODE_SYMBOLS): Task {
1869
+ update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "planner" | "reviewer" | "reserved" | "refs" | "plan" | "rejections" | "signals" | "sdlc" | "role" | "cancelled" | "codeSymbols">>, note?: string, extra: Pick<HistoryEntry, "invocation" | "plannerReason" | "planAuthor" | "selfReview" | "profile" | "sweep" | "reason" | "from" | "capacity" | "conductorManual" | "withheld" | "noWrites" | "reviewCost" | "reviewContext" | "reviewScope" | "evidenceOnly" | "reviewedHead" | "reviewFindings" | "unmetCriteria" | "deliverables" | "checkKey" | "missingKeys" | "claim" | "planObservation" | "planDeparture" | "planDepartureInvalidated" | "specReport" | "specRefBinding" | "resolvedReviewComments" | keyof ReviewerHistory> = {}, expectedFlowRevision?: number, codeSymbolsAuthority?: typeof HOST_CODE_SYMBOLS): Task {
1870
+ if (extra.resolvedReviewComments?.length && !this.db.inTransaction) return this.atomic(() => this.update(id, by, event, patch, note, extra, expectedFlowRevision, codeSymbolsAuthority));
1532
1871
  const task = this.get(id);
1533
1872
  if (!task) throw new Error(`no task #${id}`);
1534
1873
  if (expectedFlowRevision !== undefined && (!Number.isSafeInteger(expectedFlowRevision) || expectedFlowRevision < 0 || task.history.length !== expectedFlowRevision)) throw new Error("task flow revision changed");
@@ -1579,6 +1918,9 @@ export class Board {
1579
1918
  if (patch.state === "backlog" && task.owner) throw new Error(`task #${id}: owned work cannot return to backlog`);
1580
1919
  }
1581
1920
  if (patch.state === "backlog" && taskHasStarted(task) && !untouchedReturn) throw new Error(`task #${id}: started work cannot return to backlog`);
1921
+ const requestedReviewComments = extra.resolvedReviewComments === undefined ? undefined : reviewCommentIds(extra.resolvedReviewComments);
1922
+ if (requestedReviewComments?.length && (!["done", "done (checking)"].includes(event) || by !== task.owner || expectedFlowRevision === undefined)) throw new Error("comment resolution requires an admitted owner completion; use hub_task_done with sent comment ids");
1923
+ const resolvedReviewComments = requestedReviewComments?.length ? this.reviewCommentResolution(task, requestedReviewComments) : undefined;
1582
1924
  const reviewMetadata = reviewerHistory(extra);
1583
1925
  let reviewFindings: ReviewFindingSet | undefined;
1584
1926
  if (extra.reviewFindings !== undefined) {
@@ -1612,8 +1954,13 @@ export class Board {
1612
1954
  && priorHead !== undefined && /^[a-f0-9]{40}$/.test(priorHead);
1613
1955
  const previousHistory = backfill ? task.history.map((entry, index) => index === priorApproval && !entry.reviewedHead
1614
1956
  ? { ...entry, reviewedHead: entry.reviewFindings?.head ?? priorHead } : entry) : task.history;
1615
- const history = [...previousHistory, { at: Date.now(), by, event, ...(returnPlanning ? { planning: returnPlanning } : {}), ...(extra.invocation ? { invocation: extra.invocation } : {}), ...plannerHistory, ...authorHistory, ...(extra.selfReview === true ? { selfReview: true as const } : {}), ...(planRevision ? { planRevision } : {}), ...(extra.planObservation ? { planObservation: extra.planObservation } : {}), ...(extra.planDeparture ? { planDeparture: extra.planDeparture } : {}), ...(departureInvalidated ? { planDepartureInvalidated: true as const } : {}), ...reviewMetadata, ...specMetadata, ...(note ? { note } : {}), ...(patch.cancelled?.supersededBy ? { supersededBy: patch.cancelled.supersededBy } : {}), ...("owner" in patch ? { owner: next.owner } : {}), ...(extra.profile ? { profile: extra.profile } : {}), ...(extra.sweep ? { sweep: extra.sweep } : {}), ...(extra.reason && TASK_MOVE_REASONS.includes(extra.reason) ? { reason: extra.reason } : {}), ...(extra.from ? { from: extra.from } : {}), ...(extra.capacity === true ? { capacity: true as const } : {}), ...(extra.conductorManual === true ? { conductorManual: true as const } : {}), ...(extra.withheld && (note || reviewFindings) ? { withheld: true as const } : {}), ...(reviewFindings ? { reviewFindings } : {}), ...(extra.reviewedHead ? { reviewedHead: extra.reviewedHead } : {}), ...(unmetCriteria?.length ? { unmetCriteria } : {}), ...(deliverables ? { deliverables } : {}), ...(extra.reviewContext === "fresh" || extra.reviewContext === "continuing" ? { reviewContext: extra.reviewContext } : {}), ...(extra.reviewScope === "code" ? { reviewScope: "code" as const } : {}), ...(extra.evidenceOnly === true && event === "changes_requested" ? { evidenceOnly: true as const } : {}), ...(extra.reviewCost ? { reviewCost: extra.reviewCost } : {}), ...(event === "done" && extra.noWrites === true ? { noWrites: true as const } : {}), ...(["check passed", "check failed"].includes(event) && typeof extra.checkKey === "string" && /^[a-z][a-z0-9_]{0,47}$/.test(extra.checkKey) ? { checkKey: extra.checkKey } : {}), ...(["check passed", "check failed"].includes(event) && Number.isSafeInteger(extra.missingKeys) && extra.missingKeys! >= 0 && extra.missingKeys! <= 100_000 ? { missingKeys: extra.missingKeys } : {}), ...(event === "claim verdict" && extra.claim ? { claim: extra.claim } : {}) }];
1957
+ const history = [...previousHistory, { at: Date.now(), by, event, ...(resolvedReviewComments?.length ? { resolvedReviewComments } : {}), ...(returnPlanning ? { planning: returnPlanning } : {}), ...(extra.invocation ? { invocation: extra.invocation } : {}), ...plannerHistory, ...authorHistory, ...(extra.selfReview === true ? { selfReview: true as const } : {}), ...(planRevision ? { planRevision } : {}), ...(extra.planObservation ? { planObservation: extra.planObservation } : {}), ...(extra.planDeparture ? { planDeparture: extra.planDeparture } : {}), ...(departureInvalidated ? { planDepartureInvalidated: true as const } : {}), ...reviewMetadata, ...specMetadata, ...(note ? { note } : {}), ...(patch.cancelled?.supersededBy ? { supersededBy: patch.cancelled.supersededBy } : {}), ...("owner" in patch ? { owner: next.owner } : {}), ...(extra.profile ? { profile: extra.profile } : {}), ...(extra.sweep ? { sweep: extra.sweep } : {}), ...(extra.reason && TASK_MOVE_REASONS.includes(extra.reason) ? { reason: extra.reason } : {}), ...(extra.from ? { from: extra.from } : {}), ...(extra.capacity === true ? { capacity: true as const } : {}), ...(extra.conductorManual === true ? { conductorManual: true as const } : {}), ...(extra.withheld && (note || reviewFindings) ? { withheld: true as const } : {}), ...(reviewFindings ? { reviewFindings } : {}), ...(extra.reviewedHead ? { reviewedHead: extra.reviewedHead } : {}), ...(unmetCriteria?.length ? { unmetCriteria } : {}), ...(deliverables ? { deliverables } : {}), ...(extra.reviewContext === "fresh" || extra.reviewContext === "continuing" ? { reviewContext: extra.reviewContext } : {}), ...(extra.reviewScope === "code" ? { reviewScope: "code" as const } : {}), ...(extra.evidenceOnly === true && event === "changes_requested" ? { evidenceOnly: true as const } : {}), ...(extra.reviewCost ? { reviewCost: extra.reviewCost } : {}), ...(event === "done" && extra.noWrites === true ? { noWrites: true as const } : {}), ...(["check passed", "check failed"].includes(event) && typeof extra.checkKey === "string" && /^[a-z][a-z0-9_]{0,47}$/.test(extra.checkKey) ? { checkKey: extra.checkKey } : {}), ...(["check passed", "check failed"].includes(event) && Number.isSafeInteger(extra.missingKeys) && extra.missingKeys! >= 0 && extra.missingKeys! <= 100_000 ? { missingKeys: extra.missingKeys } : {}), ...(event === "claim verdict" && extra.claim ? { claim: extra.claim } : {}) }];
1616
1958
  this.taskWrite(() => {
1959
+ if (resolvedReviewComments?.length) for (const record of this.reviewComments(task.id, task.created)) {
1960
+ if (!resolvedReviewComments.includes(record.thread.id) || record.lifecycle !== "sent") continue;
1961
+ const resolved: ReviewCommentRecord = { ...record, lifecycle: "resolved", revision: record.revision + 1 };
1962
+ this.db.query("UPDATE review_comments SET body=?,lifecycle=?,revision=? WHERE task_id=? AND task_created=? AND id=?").run(JSON.stringify(resolved), resolved.lifecycle, resolved.revision, task.id, task.created, record.thread.id);
1963
+ }
1617
1964
  const result = this.db.query("UPDATE tasks SET state = ?, owner = ?, reviewer = ?, reserved = ?, refs = ?, plan = ?, rejections = ?, signals = ?, history = ?, sdlc = ?, task_role = ?, cancelled = ?, updated = ?, planner = ?, planning_revision = ? WHERE id = ?")
1618
1965
  .run(next.state, next.owner, next.reviewer, next.reserved ?? null, JSON.stringify(next.refs), JSON.stringify(next.plan ?? {}), next.rejections, JSON.stringify(next.signals), JSON.stringify(history), next.sdlc ? JSON.stringify(next.sdlc) : null, next.role ?? null, next.cancelled ? JSON.stringify(next.cancelled) : null, Date.now(), planner, returnPlanning?.revision ?? task.planningRevision ?? 0, id);
1619
1966
  if (result.changes) {
@@ -1636,10 +1983,10 @@ export class Board {
1636
1983
  const next = { ...task, ...patch, sdlc: receipt ?? task.sdlc };
1637
1984
  if (next.plannedStart != null && next.due != null && next.plannedStart > next.due) throw new Error("planned start must not follow due");
1638
1985
  if (next.release != null && !this.release(next.release)) throw new Error("no such release");
1639
- const fields = Object.keys(patch) as (keyof PlanningFields)[], previous = Object.fromEntries(fields.map(key => [key, key === "sdlc" ? { repo: task.sdlc?.repo ?? null, issue: task.sdlc?.issue ?? null } : task[key] ?? (key === "pinned" ? false : key === "deps" ? [] : null)])) as PlanningFields;
1986
+ const fields = Object.keys(patch) as (keyof PlanningFields)[], previous = Object.fromEntries(fields.map(key => [key, key === "sdlc" ? { repo: task.sdlc?.repo ?? null, issue: task.sdlc?.issue ?? null } : task[key] ?? (key === "pinned" || key === "needsPerson" ? false : key === "deps" ? [] : null)])) as PlanningFields;
1640
1987
  const now = Date.now(), entry: HistoryEntry = { at: now, by, event: "planned", planning: { revision: revision + 1, fields, previous }, ...(note ? { note } : {}), ...(withheld && note ? { withheld: true as const } : {}) };
1641
- const result = this.taskWrite(() => this.db.query(`UPDATE tasks SET rank=?, priority=?, "release"=?, planned_start=?, due=?, pinned=?, deps=?, reserved=?, planning_revision=planning_revision+1, history=?, updated=?, sdlc=? WHERE id=? AND planning_revision=?`)
1642
- .run(next.rank ?? null, next.priority ?? null, next.release ?? null, next.plannedStart ?? null, next.due ?? null, next.pinned ? 1 : 0, JSON.stringify(next.deps ?? []), next.reserved ?? null, JSON.stringify([...task.history, entry]), now, next.sdlc === undefined ? null : JSON.stringify(next.sdlc), id, revision));
1988
+ const result = this.taskWrite(() => this.db.query(`UPDATE tasks SET rank=?, priority=?, "release"=?, planned_start=?, due=?, pinned=?, needs_person=?, deps=?, reserved=?, planning_revision=planning_revision+1, history=?, updated=?, sdlc=? WHERE id=? AND planning_revision=?`)
1989
+ .run(next.rank ?? null, next.priority ?? null, next.release ?? null, next.plannedStart ?? null, next.due ?? null, next.pinned ? 1 : 0, next.needsPerson ? 1 : 0, JSON.stringify(next.deps ?? []), next.reserved ?? null, JSON.stringify([...task.history, entry]), now, next.sdlc === undefined ? null : JSON.stringify(next.sdlc), id, revision));
1643
1990
  if (result.changes !== 1) throw new Error("planning revision changed");
1644
1991
  const updated = this.get(id)!; this.changed(updated, entry); return updated;
1645
1992
  }
@@ -1692,6 +2039,8 @@ export class Board {
1692
2039
  // #334: the hub's own intake split is only ever for a draft. An approval clears the mark without moving the planning revision, so the revision alone cannot tell the
1693
2040
  // person approved the card while the model answered; checked here, in the same transaction as the write, nothing can slip between this and the children.
1694
2041
  if (requireDraft && !parent.draft) throw new Error("split changed or invalid");
2042
+ // #1176: an agent's split of a person-run card, checked in the transaction so nothing slips in between the planner's check and the write.
2043
+ if (by !== USER && parent.needsPerson) throw new Error("a person-run card is the person's to split");
1695
2044
  const children: Task[] = [];
1696
2045
  for (const part of parts) {
1697
2046
  const inherited = part.afterParts?.length ? (part.deps ?? []).filter(dep => !parent.deps?.includes(dep)) : part.deps ?? [];
@@ -1870,6 +2219,8 @@ function parse(row: Record<string, unknown>): Task {
1870
2219
  if (out.planning_revision) out.planningRevision = out.planning_revision;
1871
2220
  delete out.planning_revision;
1872
2221
  if (out.pinned) out.pinned = true; else delete out.pinned;
2222
+ if (out.needs_person) out.needsPerson = true;
2223
+ delete out.needs_person;
1873
2224
  if (out.draft) out.draft = true; else delete out.draft;
1874
2225
  for (const key of ["rank", "priority", "release", "due"]) if (out[key] == null) delete out[key];
1875
2226
  out.planner = row.planner ?? null;