@staix/agent-hub 0.20.0 → 0.22.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 (183) hide show
  1. package/CHANGELOG.md +66 -3
  2. package/README.md +2 -2
  3. package/docs/agent-notes/README.md +11 -0
  4. package/docs/agent-notes/adapters.md +7 -3
  5. package/docs/agent-notes/benchmarks.md +27 -0
  6. package/docs/agent-notes/budget.md +2 -1
  7. package/docs/agent-notes/bus.md +3 -3
  8. package/docs/agent-notes/daemon.md +17 -8
  9. package/docs/agent-notes/local-worker.md +4 -0
  10. package/docs/agent-notes/models.md +2 -0
  11. package/docs/agent-notes/tasks.md +17 -9
  12. package/docs/agent-notes/tests.md +9 -2
  13. package/docs/bench.md +1 -1
  14. package/docs/data-policy.md +2 -0
  15. package/docs/dogfood.md +39 -4
  16. package/docs/events.md +2 -1
  17. package/docs/memory.md +9 -3
  18. package/docs/operations.md +610 -62
  19. package/docs/profiles.md +13 -1
  20. package/docs/quickstart.md +18 -9
  21. package/docs/research.md +7 -4
  22. package/docs/sdlc.md +54 -10
  23. package/docs/security.md +396 -34
  24. package/docs/smoke.md +133 -0
  25. package/docs/specs/2026-09-19-agent-hub-design.md +1101 -22
  26. package/docs/verification/2026-10-27-0.19.0.md +5 -5
  27. package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-bash-permission.json +635 -0
  28. package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-permission-transport.json +403 -0
  29. package/docs/verification/2026-10-28-claude-implementer-permission-triage.md +123 -0
  30. package/docs/verification/2026-10-28-jcode-0.99.1-usage.md +41 -0
  31. package/docs/verification/2026-10-28-realpath-name-audit.md +222 -0
  32. package/docs/verification/2026-10-28-routing-f0bb8ad0.md +1000 -0
  33. package/docs/verification/2026-10-29-0.21.0.md +61 -0
  34. package/docs/verification/2026-10-29-model-effort-capabilities.md +123 -0
  35. package/docs/verification/2026-10-29-modelrouting-b152ddb9.md +953 -0
  36. package/docs/verification/2026-10-30-0.22.0.md +49 -0
  37. package/docs/verification/2026-10-30-445-capability-probes.md +172 -0
  38. package/docs/verification/2026-10-30-bus-delivery-operational-settings.md +77 -0
  39. package/docs/verification/2026-10-30-jcode-ac4.md +24 -0
  40. package/docs/verification/2026-10-30-jcode-ac6.md +52 -0
  41. package/docs/verification/2026-10-30-recovery-wait-settings.md +141 -0
  42. package/docs/verification/claude-implementer-permission-triage.ts +160 -0
  43. package/docs/verification/claude-permission-transport-native.py +71 -14
  44. package/docs/verification/profiles/YYYY-MM-DD-research.md +1 -1
  45. package/docs/verified.json +15 -15
  46. package/package.json +2 -2
  47. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  48. package/plugins/agent-hub/server.js +1016 -280
  49. package/src/adapters/acp.ts +79 -3
  50. package/src/adapters/channel-peer.ts +4 -0
  51. package/src/adapters/claude-channel.ts +13 -6
  52. package/src/adapters/claude-implementer.ts +60 -7
  53. package/src/adapters/codex-appserver.ts +77 -4
  54. package/src/adapters/conformance/fakes/jcode-agent.ts +6 -2
  55. package/src/adapters/conformance/kit.ts +3 -9
  56. package/src/adapters/definitions/builtin-metadata.ts +3 -114
  57. package/src/adapters/definitions/native-config-defaults.ts +1 -1
  58. package/src/adapters/definitions/native-config-policy.ts +3 -2
  59. package/src/adapters/definitions/native-context.ts +12 -0
  60. package/src/adapters/external-metadata.ts +48 -0
  61. package/src/adapters/external-modules.ts +63 -3
  62. package/src/adapters/jcode.ts +6 -2
  63. package/src/adapters/local-worker.ts +40 -25
  64. package/src/adapters/native-proof-ports.ts +10 -0
  65. package/src/adapters/registry-types.ts +4 -0
  66. package/src/broker/protocol.ts +203 -0
  67. package/src/broker/server.ts +314 -0
  68. package/src/broker/store.ts +548 -0
  69. package/src/cli/bench.ts +3 -2
  70. package/src/cli/broker.ts +58 -0
  71. package/src/cli/conductor-recovery.ts +142 -0
  72. package/src/cli/configured-acp-entry.ts +26 -6
  73. package/src/cli/console-state.ts +59 -17
  74. package/src/cli/console.ts +17 -2
  75. package/src/cli/facts-hook.ts +3 -1
  76. package/src/cli/help.ts +29 -16
  77. package/src/cli/identity.ts +25 -10
  78. package/src/cli/init.ts +34 -7
  79. package/src/cli/intake-output.ts +33 -0
  80. package/src/cli/learn-output.ts +38 -0
  81. package/src/cli/main.ts +334 -73
  82. package/src/cli/output.ts +41 -1
  83. package/src/cli/recovery-package.ts +18 -1
  84. package/src/cli/recovery-waits.ts +26 -0
  85. package/src/cli/role-holders.ts +35 -0
  86. package/src/cli/room.ts +304 -0
  87. package/src/cli/say-input.ts +16 -0
  88. package/src/cli/schedule-output.ts +49 -0
  89. package/src/cli/sdlc-output.ts +44 -6
  90. package/src/cli/smoke-pty-bridge.ts +82 -0
  91. package/src/cli/smoke-pty.ts +153 -0
  92. package/src/cli/smoke-run.ts +139 -0
  93. package/src/cli/smoke-target.ts +130 -0
  94. package/src/cli/source-output.ts +29 -0
  95. package/src/cli/task-output.ts +3 -2
  96. package/src/cli/upgrade-runtime.ts +112 -28
  97. package/src/cli/upgrade.ts +68 -8
  98. package/src/hub/ask.ts +76 -12
  99. package/src/hub/board.ts +132 -22
  100. package/src/hub/bus.ts +416 -65
  101. package/src/hub/child-process.ts +2 -0
  102. package/src/hub/ci-watch.ts +42 -20
  103. package/src/hub/claude-failure-stage.ts +85 -0
  104. package/src/hub/claude-shell-wrapper.ts +11 -5
  105. package/src/hub/conductor.ts +17 -4
  106. package/src/hub/config-apply.ts +34 -6
  107. package/src/hub/config-reload.ts +7 -5
  108. package/src/hub/config-trust.ts +7 -1
  109. package/src/hub/context-window.ts +33 -3
  110. package/src/hub/control-client.ts +14 -2
  111. package/src/hub/corpus-config.ts +34 -0
  112. package/src/hub/corpus-evidence.ts +184 -0
  113. package/src/hub/corpus.ts +373 -0
  114. package/src/hub/cost-governance.ts +26 -0
  115. package/src/hub/daemon.ts +1209 -183
  116. package/src/hub/deliverable-files.ts +8 -2
  117. package/src/hub/delivery-journal.ts +200 -73
  118. package/src/hub/dogfood-profile.ts +32 -5
  119. package/src/hub/dogfood-read.ts +6 -2
  120. package/src/hub/envelope.ts +11 -0
  121. package/src/hub/eval-gate.ts +131 -0
  122. package/src/hub/events.ts +1 -1
  123. package/src/hub/external-instance.ts +69 -0
  124. package/src/hub/facts.ts +15 -5
  125. package/src/hub/folder-trigger.ts +223 -0
  126. package/src/hub/hub-tools.ts +2 -1
  127. package/src/hub/inference.ts +20 -5
  128. package/src/hub/instance-config.ts +69 -3
  129. package/src/hub/instance-execution.ts +6 -2
  130. package/src/hub/instances.ts +32 -4
  131. package/src/hub/intake.ts +125 -0
  132. package/src/hub/learning.ts +117 -0
  133. package/src/hub/lifecycle.ts +7 -3
  134. package/src/hub/limits.ts +12 -5
  135. package/src/hub/native-permission-request.ts +77 -0
  136. package/src/hub/operational-authority.ts +63 -5
  137. package/src/hub/operational-settings.ts +154 -0
  138. package/src/hub/operational-view.ts +1 -1
  139. package/src/hub/packs.ts +132 -0
  140. package/src/hub/path-policy.ts +7 -0
  141. package/src/hub/peer-doing.ts +130 -0
  142. package/src/hub/peers.ts +12 -0
  143. package/src/hub/queue-backpressure.ts +58 -0
  144. package/src/hub/read-tools.ts +2 -0
  145. package/src/hub/recovery-authority.ts +151 -0
  146. package/src/hub/recovery-policy.ts +220 -0
  147. package/src/hub/research.ts +24 -14
  148. package/src/hub/restart.ts +7 -0
  149. package/src/hub/routing.ts +54 -11
  150. package/src/hub/schedules.ts +266 -0
  151. package/src/hub/sdlc-merge.ts +38 -14
  152. package/src/hub/sdlc-metrics.ts +32 -10
  153. package/src/hub/sdlc-queue.ts +7 -1
  154. package/src/hub/sdlc-release.ts +1 -1
  155. package/src/hub/sdlc.ts +37 -7
  156. package/src/hub/security-baseline.ts +38 -0
  157. package/src/hub/settings.ts +27 -2
  158. package/src/hub/shipped-packs.ts +8 -0
  159. package/src/hub/snapshots.ts +72 -6
  160. package/src/hub/source-read.ts +160 -0
  161. package/src/hub/sources.ts +311 -0
  162. package/src/hub/tasks.ts +987 -110
  163. package/src/hub/workflow.ts +1 -1
  164. package/src/local/doc-read.ts +69 -0
  165. package/src/local/proxy.ts +4 -13
  166. package/src/local/tools.ts +37 -3
  167. package/src/local/web-fetch-config.ts +43 -0
  168. package/src/local/web-fetch.ts +183 -0
  169. package/src/memory/brief.ts +85 -13
  170. package/src/memory/capture.ts +1 -1
  171. package/src/rooms/application.ts +114 -0
  172. package/src/rooms/boundary.ts +103 -0
  173. package/src/rooms/commands.ts +294 -0
  174. package/src/rooms/crypto.ts +371 -0
  175. package/src/rooms/link.ts +584 -0
  176. package/src/rooms/policy.ts +82 -0
  177. package/src/rooms/remote-peer.ts +80 -0
  178. package/src/rooms/service.ts +426 -0
  179. package/src/rooms/status.ts +146 -0
  180. package/src/rooms/store.ts +1210 -0
  181. package/src/rooms/wire.ts +465 -0
  182. package/src/ui/index.html +91 -4
  183. package/templates/packs/example.pack.json +13 -0
@@ -1,10 +1,12 @@
1
1
  import { constants, lstatSync, type Stats } from "node:fs";
2
2
  import { open } from "node:fs/promises";
3
3
  import { createHash } from "node:crypto";
4
- import { relative } from "node:path";
4
+ import { relative, resolve } from "node:path";
5
5
  import { guardPath } from "../local/tools.ts";
6
+ import { isDenied } from "../local/deny.ts";
6
7
  import { isHubWritePath } from "./permission-mode.ts";
7
8
  import { realPath } from "./project.ts";
9
+ import { insideRoot } from "./path-policy.ts";
8
10
  import { DELIVERABLE_LIMITS, deliverableId, type DeliverableClaim, type DeliverableRecord, type DeliverableState } from "./deliverables.ts";
9
11
 
10
12
  /**
@@ -49,7 +51,11 @@ function locate(path: string, ports: DeliverablePorts): Located {
49
51
  // git file ever reaches a record (a hash of a low-entropy file confirms guesses). Compared on the resolved path, folded like the write guard.
50
52
  let root: string;
51
53
  try { root = realPath(ports.cwd); } catch { return refuse(path, "the project root cannot be resolved"); }
52
- if (isHubWritePath(relative(root, real))) return refuse(path, "the hub's own state is not a deliverable");
54
+ // Judged on the resolved name and on the name the caller gave (#552, the #541 class): Bun's realpath can name another hardlink of the same file.
55
+ const typed = relative(root, resolve(root, path));
56
+ const inside = insideRoot(typed);
57
+ if (isHubWritePath(relative(root, real)) || inside && isHubWritePath(typed)) return refuse(path, "the hub's own state is not a deliverable");
58
+ if (inside && isDenied(typed, [...ports.deny])) return refuse(path, "the path is on the secrets denylist");
53
59
  let stat: Stats;
54
60
  try { stat = lstatSync(real); }
55
61
  catch (error) { return refuse(path, (error as NodeJS.ErrnoException).code === "ENOENT" ? "the file does not exist" : "the file cannot be read"); }
@@ -2,10 +2,12 @@ import { chmodSync, existsSync, mkdirSync, readFileSync } from "node:fs";
2
2
  import { dirname } from "node:path";
3
3
  import { Database } from "bun:sqlite";
4
4
  import type { BusSnapshot } from "./bus.ts";
5
+ import type { QueuePressure } from "./queue-backpressure.ts";
5
6
  import { HUB, USER, type Envelope, type Kind } from "./envelope.ts";
6
7
  import { heldDeliveryHints } from "./delivery-hints.ts";
7
8
  import { processLiveness } from "../pi/process-signature.ts";
8
9
  import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
10
+ import { parseRemotePeerId } from "../rooms/application.ts";
9
11
  import { validateDeferredHub } from "./deferred-hub.ts";
10
12
 
11
13
  export type JournalDeliveryState =
@@ -18,8 +20,8 @@ export type JournalDeliveryState =
18
20
  | "discarded";
19
21
  export type JournalResolution = "completed" | "retry" | "discard";
20
22
  export const MAX_ENVELOPE_BODY = 1_000_000;
23
+ export const TERMINAL_CAP = 2048;
21
24
  const SCHEMA_VERSION = 1,
22
- TERMINAL_CAP = 2048,
23
25
  MAX_ROWS = 100_000,
24
26
  MAX_JSON = 20 * 1024 * 1024;
25
27
  const STATES = new Set<JournalDeliveryState>([
@@ -54,6 +56,8 @@ export interface DeliveryJournalOptions {
54
56
  projectId: string;
55
57
  instanceId: string;
56
58
  operationId?: string;
59
+ /** Accepted live history target; only terminal mutations apply it, other pruning keeps the legacy cap. */
60
+ terminalHistoryLimit?: () => number;
57
61
  }
58
62
  export interface JournalSnapshot {
59
63
  schemaVersion: number;
@@ -168,6 +172,32 @@ function validateBus(value: unknown, name = "bus"): BusSnapshot {
168
172
  list(b.manualPaused, `${name}.manualPaused`, 4096).forEach((id) =>
169
173
  text(id, `${name}.manualPaused[]`, 256),
170
174
  );
175
+ if (b.remoteQueueMembers !== undefined) {
176
+ const members = obj(b.remoteQueueMembers, `${name}.remoteQueueMembers`);
177
+ if (Object.keys(members).length > 12800) fail(`${name}.remoteQueueMembers is too large`);
178
+ for (const [peer, member] of Object.entries(members)) {
179
+ if (!parseRemotePeerId(peer) || !Array.isArray(queues[peer]) || typeof member !== "string" || !/^[0-9a-f]{64}$/.test(member)) fail(`${name}.remoteQueueMembers is invalid`);
180
+ }
181
+ }
182
+ if (b.remoteQueueRooms !== undefined) {
183
+ const rooms = obj(b.remoteQueueRooms, `${name}.remoteQueueRooms`);
184
+ if (Object.keys(rooms).length > 12800) fail(`${name}.remoteQueueRooms is too large`);
185
+ for (const [peer, room] of Object.entries(rooms)) {
186
+ if (!parseRemotePeerId(peer) || !Array.isArray(queues[peer])) fail(`${name}.remoteQueueRooms key is invalid`);
187
+ text(room, `${name}.remoteQueueRooms room`, 256);
188
+ }
189
+ }
190
+ if (b.remoteQueueExpires !== undefined) {
191
+ const expiries = obj(b.remoteQueueExpires, `${name}.remoteQueueExpires`);
192
+ if (Object.keys(expiries).length > 4096) fail(`${name}.remoteQueueExpires is too large`);
193
+ let count = 0;
194
+ for (const [peer, values] of Object.entries(expiries)) {
195
+ if (!parseRemotePeerId(peer)) fail(`${name}.remoteQueueExpires key is invalid`);
196
+ const entries = Object.entries(obj(values, `${name}.remoteQueueExpires.${peer}`));
197
+ if (entries.length > 200 || (count += entries.length) > 12800) fail(`${name}.remoteQueueExpires is too large`);
198
+ for (const [id, expiry] of entries) { text(id, `${name}.remoteQueueExpires id`, 256); integer(expiry, `${name}.remoteQueueExpires expiry`); }
199
+ }
200
+ }
171
201
  const occupied = new Set<string>([
172
202
  ...(b.seen as Envelope[]).map(env => env.id),
173
203
  ...Object.values(queues).flatMap(items => (items as Envelope[]).map(env => env.id)),
@@ -241,13 +271,18 @@ export class DeliveryJournal {
241
271
  readonly instanceId: string;
242
272
  private readonly db: Database;
243
273
  private closed = false;
274
+ private queueMetadataTracking = false;
275
+ private queueMetadataInvalidated = false;
276
+ private pruningDeferred = 0;
244
277
  private readonly operationId?: string;
278
+ private readonly terminalHistoryLimit?: () => number;
245
279
  constructor(options: DeliveryJournalOptions) {
246
280
  this.file = options.file;
247
281
  this.projectRoot = text(options.projectRoot, "projectRoot", 4096);
248
282
  this.projectId = text(options.projectId, "projectId", 256);
249
283
  this.instanceId = text(options.instanceId, "instanceId", 256);
250
284
  this.operationId = options.operationId;
285
+ this.terminalHistoryLimit = options.terminalHistoryLimit;
251
286
  mkdirSync(dirname(options.file), { recursive: true, mode: 0o700 });
252
287
  this.db = new Database(options.file, { create: true });
253
288
  this.db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
@@ -323,6 +358,8 @@ export class DeliveryJournal {
323
358
  this.db.run(
324
359
  `CREATE TABLE IF NOT EXISTS deliveries (id TEXT PRIMARY KEY, project_id TEXT NOT NULL, peer TEXT NOT NULL, state TEXT NOT NULL, revision INTEGER NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, originals TEXT NOT NULL, out_json TEXT NOT NULL, reason TEXT, attempt INTEGER, previous_id TEXT)`,
325
360
  );
361
+ this.db.run("CREATE TABLE IF NOT EXISTS delivery_queue_meta_state (project_id TEXT PRIMARY KEY, revision INTEGER NOT NULL)");
362
+ this.db.run("CREATE TABLE IF NOT EXISTS delivery_queue_meta (project_id TEXT NOT NULL, peer TEXT NOT NULL, queued INTEGER NOT NULL, oldest_at INTEGER, unknown_age INTEGER NOT NULL, PRIMARY KEY(project_id,peer))");
326
363
  this.db.run("CREATE TABLE IF NOT EXISTS task_offer_reads (project_id TEXT NOT NULL, peer TEXT NOT NULL, envelope_id TEXT NOT NULL, delivery_id TEXT NOT NULL, source TEXT NOT NULL CHECK(source IN ('adapter','reply','pull')), offer_revision INTEGER NOT NULL CHECK(offer_revision >= 1), PRIMARY KEY(project_id,peer,envelope_id))");
327
364
  this.db.run(
328
365
  `CREATE TABLE IF NOT EXISTS resolution_history (id INTEGER PRIMARY KEY AUTOINCREMENT, project_id TEXT NOT NULL, delivery_id TEXT NOT NULL, observed_revision INTEGER NOT NULL, action TEXT NOT NULL, reason TEXT NOT NULL, result_revision INTEGER NOT NULL, result_state TEXT NOT NULL, UNIQUE(project_id, delivery_id, observed_revision, action, reason))`,
@@ -474,11 +511,71 @@ export class DeliveryJournal {
474
511
  ) !== 1
475
512
  )
476
513
  throw new Error("delivery journal instance fence lost");
514
+ // New writers carry unchanged summary provenance across non-queue revisions.
515
+ // An older writer does not, so its next revision makes these rows unknown.
516
+ if (this.queueMetadataTracking) this.db.query("UPDATE delivery_queue_meta_state SET revision=? WHERE project_id=? AND revision=?")
517
+ .run(revision, this.projectId, revision - 1);
477
518
  return revision;
478
519
  }
520
+
521
+ /** Atomically accompanies the bus snapshot, never extracts body JSON on a periodic path. */
522
+ private writeQueueMetadata(bus: BusSnapshot, revision: number, tracking: boolean): void {
523
+ if (!tracking) {
524
+ // Invalidate once per disabled period; ordinary revisions already stale old summaries.
525
+ if (!this.queueMetadataInvalidated) {
526
+ this.db.query("UPDATE delivery_queue_meta_state SET revision=-1 WHERE project_id=? AND revision>=0").run(this.projectId);
527
+ this.queueMetadataInvalidated = true;
528
+ }
529
+ return;
530
+ }
531
+ this.db.query("DELETE FROM delivery_queue_meta WHERE project_id=?").run(this.projectId);
532
+ const put = this.db.query("INSERT INTO delivery_queue_meta(project_id,peer,queued,oldest_at,unknown_age) VALUES(?,?,?,?,?)");
533
+ for (const [peer, queue] of Object.entries(bus.queues)) {
534
+ const provenance = bus.queuedAt;
535
+ const times = provenance && typeof provenance === "object" && !Array.isArray(provenance) && Object.hasOwn(provenance, peer) ? provenance[peer] : undefined;
536
+ let oldest: number | undefined, unknown = false;
537
+ for (const env of queue) {
538
+ const at = times && typeof times === "object" && !Array.isArray(times) && Object.hasOwn(times, env.id) ? times[env.id] : null;
539
+ if (typeof at !== "number" || !Number.isSafeInteger(at) || at <= 0) unknown = true;
540
+ else oldest = oldest === undefined ? at : Math.min(oldest, at);
541
+ }
542
+ put.run(this.projectId, peer, queue.length, oldest ?? null, unknown ? 1 : 0);
543
+ }
544
+ this.db.query("INSERT INTO delivery_queue_meta_state(project_id,revision) VALUES(?,?) ON CONFLICT(project_id) DO UPDATE SET revision=excluded.revision")
545
+ .run(this.projectId, revision);
546
+ this.queueMetadataInvalidated = false;
547
+ }
548
+
549
+ /** Host policy only, refreshed before receipt/reply mutations as well as snapshot writes. No SQL. */
550
+ setQueueMetadataTracking(tracking: boolean): void { this.queueMetadataTracking = tracking === true; }
551
+
552
+ private queueMetadataTransaction<T>(tracking: boolean, fn: () => T): T {
553
+ return this.transaction(() => { this.setQueueMetadataTracking(tracking); return fn(); });
554
+ }
555
+ private queueMetadataSnapshot(bus: BusSnapshot, tracking: boolean): BusSnapshot {
556
+ if (tracking || bus.queuedAt === undefined) return bus;
557
+ const legacy = { ...bus }; delete legacy.queuedAt;
558
+ return legacy;
559
+ }
560
+
561
+ /** Indexed per-peer pending counts/age only, independent of delivery history and payload columns. */
562
+ pendingQueueMetadata(peer: string): QueuePressure | undefined {
563
+ this.ensureOpen();
564
+ const row = this.db.query("SELECT m.revision,s.revision AS metadata_revision,q.peer AS metadata_peer,q.queued,q.oldest_at,q.unknown_age FROM delivery_meta m LEFT JOIN delivery_queue_meta_state s ON s.project_id=m.project_id LEFT JOIN delivery_queue_meta q ON q.project_id=m.project_id AND q.peer=? WHERE m.project_id=?")
565
+ .get(peer, this.projectId) as { revision: number; metadata_revision: number | null; metadata_peer: string | null; queued: number | null; oldest_at: number | null; unknown_age: number | null } | null;
566
+ if (!row || !Number.isSafeInteger(row.revision) || row.revision < 0 || row.metadata_revision !== row.revision) return undefined;
567
+ if (row.metadata_peer === null) return { queued: 0, oldestQueuedAt: null };
568
+ if (typeof row.queued !== "number" || !Number.isSafeInteger(row.queued) || row.queued < 0 || row.queued > 2048 || ![0, 1].includes(row.unknown_age!)) return undefined;
569
+ if (row.queued === 0) return row.oldest_at === null && row.unknown_age === 0 ? { queued: 0, oldestQueuedAt: null } : undefined;
570
+ if (row.unknown_age === 1) return { queued: row.queued };
571
+ if (!Number.isSafeInteger(row.oldest_at) || row.oldest_at! <= 0) return undefined;
572
+ return { queued: row.queued, oldestQueuedAt: row.oldest_at! };
573
+ }
479
574
  transaction<T>(fn: () => T): T {
480
575
  this.ensureOpen();
481
- return this.db.transaction(fn)();
576
+ const invalidated = this.queueMetadataInvalidated;
577
+ try { return this.db.transaction(fn)(); }
578
+ catch (error) { this.queueMetadataInvalidated = invalidated; throw error; }
482
579
  }
483
580
  snapshot(): JournalSnapshot {
484
581
  this.ensureOpen();
@@ -513,26 +610,34 @@ export class DeliveryJournal {
513
610
  validateJournalSnapshot(result);
514
611
  return result;
515
612
  }
516
- persistBus(bus: unknown, manualPaused: string[]): number {
613
+ persistBus(bus: unknown, manualPaused: string[], trackQueuePressure = false): number {
517
614
  this.ensureOpen();
518
- validateBus(bus);
615
+ let checkedBus = this.queueMetadataSnapshot(validateBus(bus), trackQueuePressure);
616
+ let encodedBus: string;
617
+ try { encodedBus = JSON.stringify(checkedJSON(checkedBus, "bus")); }
618
+ catch (error) {
619
+ if (checkedBus.queuedAt === undefined || !(error instanceof Error) || error.message !== "invalid delivery journal: bus is too large") throw error;
620
+ checkedBus = { ...checkedBus }; delete checkedBus.queuedAt;
621
+ encodedBus = JSON.stringify(checkedJSON(checkedBus, "bus"));
622
+ }
519
623
  list(manualPaused, "manualPaused", 4096).forEach((id) =>
520
624
  text(id, "manualPaused[]", 256),
521
625
  );
522
- return this.db.transaction(() => {
626
+ return this.queueMetadataTransaction(trackQueuePressure, () => {
523
627
  const revision = this.bump();
524
628
  this.db
525
629
  .query(
526
630
  "UPDATE delivery_meta SET bus_snapshot = ?, manual_paused = ? WHERE project_id = ?",
527
631
  )
528
632
  .run(
529
- JSON.stringify(checkedJSON(bus, "bus")),
633
+ encodedBus,
530
634
  JSON.stringify([...new Set(manualPaused)].sort()),
531
635
  this.projectId,
532
636
  );
637
+ this.writeQueueMetadata(checkedBus, revision, trackQueuePressure);
533
638
  this.harden();
534
639
  return revision;
535
- })();
640
+ });
536
641
  }
537
642
  /** Atomically persist a bus snapshot and any fan-out rows created for that snapshot. */
538
643
  persistBusAndDeliveries(
@@ -541,9 +646,10 @@ export class DeliveryJournal {
541
646
  deliveries: Array<
542
647
  Omit<JournalDelivery, "revision" | "updatedAt"> & { updatedAt?: number }
543
648
  >,
649
+ trackQueuePressure = false,
544
650
  ): number {
545
651
  this.ensureOpen();
546
- validateBus(bus);
652
+ bus = this.queueMetadataSnapshot(validateBus(bus), trackQueuePressure);
547
653
  list(manualPaused, "manualPaused", 4096).forEach((id) =>
548
654
  text(id, "manualPaused[]", 256),
549
655
  );
@@ -554,7 +660,7 @@ export class DeliveryJournal {
554
660
  updatedAt: d.updatedAt ?? d.createdAt,
555
661
  }),
556
662
  );
557
- return this.db.transaction(() => {
663
+ return this.queueMetadataTransaction(trackQueuePressure, () => {
558
664
  const revision = this.bump();
559
665
  this.db
560
666
  .query(
@@ -565,6 +671,7 @@ export class DeliveryJournal {
565
671
  JSON.stringify([...new Set(manualPaused)].sort()),
566
672
  this.projectId,
567
673
  );
674
+ this.writeQueueMetadata(bus, revision, trackQueuePressure);
568
675
  for (const d of deliveries)
569
676
  this.db
570
677
  .query(
@@ -586,68 +693,73 @@ export class DeliveryJournal {
586
693
  );
587
694
  this.harden();
588
695
  return revision;
589
- })();
696
+ });
590
697
  }
591
698
  checkpointDelivery(
592
699
  bus: BusSnapshot,
593
700
  delivery: Omit<JournalDelivery, "revision" | "updatedAt"> & {
594
701
  state: "dispatching";
595
702
  },
703
+ trackQueuePressure = false,
596
704
  ): JournalDelivery {
597
- validateBus(bus);
705
+ bus = this.queueMetadataSnapshot(validateBus(bus), trackQueuePressure);
598
706
  validateDelivery({
599
707
  ...delivery,
600
708
  revision: 0,
601
709
  updatedAt: delivery.createdAt,
602
710
  });
603
- return this.db.transaction(() => {
604
- const evidence = this.retryEvidence(delivery.peer, delivery.originals);
605
- if (evidence === "used") {
606
- // Inheritance commits with the handoff, before safe failure or pruning can erase a grouped parent.
607
- this.recordRetryEvidence(delivery.peer, delivery.originals, delivery.id, this.retryWarning(delivery.peer, delivery.originals));
608
- delivery = { ...delivery, attempt: Math.max(1, delivery.attempt ?? 0) };
609
- } else if (evidence === "unknown") this.markRetryUnknown([{ ...delivery, revision: 0, updatedAt: delivery.createdAt }], {});
610
- const ids = new Set(delivery.originals.map((env) => env.id)), immutableIds = new Set(retryOriginals(delivery.originals).map(env => env.id));
611
- for (const row of this.list(delivery.peer)) {
612
- if (row.state !== "queued" || !row.originals.some((env) => ids.has(env.id))) continue;
613
- this.transition(row.id, "discarded", `grouped into delivery ${delivery.id}`);
614
- if (retryOriginals(row.originals).some(env => immutableIds.has(env.id))) {
615
- delivery = { ...delivery, previousId: delivery.previousId ?? row.id };
616
- if ((row.attempt ?? 0) > 0) delivery = { ...delivery, attempt: Math.max(delivery.attempt ?? 0, row.attempt!) };
711
+ return this.queueMetadataTransaction(trackQueuePressure, () => {
712
+ // Grouped parents are not linked until the handoff INSERT.
713
+ return this.withPruningDeferred(() => {
714
+ const evidence = this.retryEvidence(delivery.peer, delivery.originals);
715
+ if (evidence === "used") {
716
+ // Inheritance commits with the handoff, before safe failure or pruning can erase a grouped parent.
717
+ this.recordRetryEvidence(delivery.peer, delivery.originals, delivery.id, this.retryWarning(delivery.peer, delivery.originals));
718
+ delivery = { ...delivery, attempt: Math.max(1, delivery.attempt ?? 0) };
719
+ } else if (evidence === "unknown") this.markRetryUnknown([{ ...delivery, revision: 0, updatedAt: delivery.createdAt }], {});
720
+ const ids = new Set(delivery.originals.map((env) => env.id)), immutableIds = new Set(retryOriginals(delivery.originals).map(env => env.id));
721
+ for (const row of this.list(delivery.peer)) {
722
+ if (row.state !== "queued" || !row.originals.some((env) => ids.has(env.id))) continue;
723
+ this.transition(row.id, "discarded", `grouped into delivery ${delivery.id}`);
724
+ if (retryOriginals(row.originals).some(env => immutableIds.has(env.id))) {
725
+ delivery = { ...delivery, previousId: delivery.previousId ?? row.id };
726
+ if ((row.attempt ?? 0) > 0) delivery = { ...delivery, attempt: Math.max(delivery.attempt ?? 0, row.attempt!) };
727
+ }
617
728
  }
618
- }
619
- const revision = this.bump();
620
- const now = Date.now();
621
- this.db
622
- .query(
623
- "UPDATE delivery_meta SET bus_snapshot = ?, manual_paused = ? WHERE project_id = ?",
624
- )
625
- .run(
626
- JSON.stringify(bus),
627
- JSON.stringify(bus.manualPaused ?? []),
628
- this.projectId,
629
- );
630
- this.db
631
- .query(
632
- "INSERT INTO deliveries (id, project_id, peer, state, revision, created_at, updated_at, originals, out_json, reason, attempt, previous_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
633
- )
634
- .run(
635
- delivery.id,
636
- this.projectId,
637
- delivery.peer,
638
- delivery.state,
639
- revision,
640
- delivery.createdAt,
641
- now,
642
- JSON.stringify(delivery.originals),
643
- JSON.stringify(delivery.out),
644
- delivery.reason ?? null,
645
- delivery.attempt ?? null,
646
- delivery.previousId ?? null,
647
- );
648
- this.harden();
649
- return { ...delivery, revision, updatedAt: now };
650
- })();
729
+ const revision = this.bump();
730
+ const now = Date.now();
731
+ this.db
732
+ .query(
733
+ "UPDATE delivery_meta SET bus_snapshot = ?, manual_paused = ? WHERE project_id = ?",
734
+ )
735
+ .run(
736
+ JSON.stringify(bus),
737
+ JSON.stringify(bus.manualPaused ?? []),
738
+ this.projectId,
739
+ );
740
+ this.writeQueueMetadata(bus, revision, trackQueuePressure);
741
+ this.db
742
+ .query(
743
+ "INSERT INTO deliveries (id, project_id, peer, state, revision, created_at, updated_at, originals, out_json, reason, attempt, previous_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
744
+ )
745
+ .run(
746
+ delivery.id,
747
+ this.projectId,
748
+ delivery.peer,
749
+ delivery.state,
750
+ revision,
751
+ delivery.createdAt,
752
+ now,
753
+ JSON.stringify(delivery.originals),
754
+ JSON.stringify(delivery.out),
755
+ delivery.reason ?? null,
756
+ delivery.attempt ?? null,
757
+ delivery.previousId ?? null,
758
+ );
759
+ this.harden();
760
+ return { ...delivery, revision, updatedAt: now };
761
+ });
762
+ });
651
763
  }
652
764
  createDelivery(
653
765
  input: Omit<JournalDelivery, "revision" | "updatedAt"> & {
@@ -681,7 +793,7 @@ export class DeliveryJournal {
681
793
  input.attempt ?? null,
682
794
  input.previousId ?? null,
683
795
  );
684
- this.pruneTerminal();
796
+ this.pruneTerminal(input.state, input.id);
685
797
  return { ...input, revision, updatedAt };
686
798
  })();
687
799
  }
@@ -698,9 +810,14 @@ export class DeliveryJournal {
698
810
  return this.db.transaction((): JournalDelivery => {
699
811
  const row = this.get(deliveryId);
700
812
  if (!row || row.peer !== peer || !["dispatching", "accepted"].includes(row.state)) throw new Error("delivery is not active for this peer");
701
- const completed = this.transition(deliveryId, "completed", reason, row.revision);
702
- const put = this.db.query("INSERT OR IGNORE INTO task_offer_reads (project_id,peer,envelope_id,delivery_id,source,offer_revision) VALUES (?,?,?,?,?,?)");
703
- for (const env of row.originals) if (env.from === HUB && env.kind === "task" && Number.isSafeInteger(env.refs?.taskOffer) && env.refs!.taskOffer! >= 1) put.run(this.projectId, peer, env.id, deliveryId, source, env.refs!.taskOffer!);
813
+ const completed = this.withPruningDeferred(() => {
814
+ const result = this.transition(deliveryId, "completed", reason, row.revision);
815
+ const put = this.db.query("INSERT OR IGNORE INTO task_offer_reads (project_id,peer,envelope_id,delivery_id,source,offer_revision) VALUES (?,?,?,?,?,?)");
816
+ for (const env of row.originals) if (env.from === HUB && env.kind === "task" && Number.isSafeInteger(env.refs?.taskOffer) && env.refs!.taskOffer! >= 1) put.run(this.projectId, peer, env.id, deliveryId, source, env.refs!.taskOffer!);
817
+ return result;
818
+ });
819
+ // Proof is now durable in this transaction, and the return receipt is captured.
820
+ this.pruneTerminal("completed", "");
704
821
  return completed;
705
822
  })();
706
823
  }
@@ -730,7 +847,7 @@ export class DeliveryJournal {
730
847
  "UPDATE deliveries SET state = ?, revision = ?, updated_at = ?, reason = ? WHERE id = ? AND project_id = ?",
731
848
  )
732
849
  .run(state, revision, Date.now(), reason ?? null, id, this.projectId);
733
- this.pruneTerminal();
850
+ this.pruneTerminal(state, id);
734
851
  return this.get(id)!;
735
852
  })();
736
853
  }
@@ -919,7 +1036,7 @@ export class DeliveryJournal {
919
1036
  JSON.stringify(retryOriginals(current.originals).map(env => env.id)),
920
1037
  );
921
1038
  if (action === "retry") this.recordRetryEvidence(current.peer, current.originals, id, actor === `peer:${current.peer}`);
922
- this.pruneTerminal();
1039
+ this.pruneTerminal(result.state, result.id);
923
1040
  return result;
924
1041
  })();
925
1042
  }
@@ -927,7 +1044,15 @@ export class DeliveryJournal {
927
1044
  const row = this.db.query("SELECT action,reason,observed_revision,actor FROM resolution_history WHERE project_id=? AND delivery_id=? ORDER BY id DESC LIMIT 1").get(this.projectId, id) as { action: JournalResolution; reason: string; observed_revision: number; actor: string } | null;
928
1045
  return row ? { action: row.action, reason: row.reason, observedRevision: row.observed_revision, actor: row.actor } : undefined;
929
1046
  }
930
- private pruneTerminal(): void {
1047
+ private withPruningDeferred<T>(body: () => T): T {
1048
+ this.pruningDeferred++;
1049
+ try { return body(); } finally { this.pruningDeferred--; }
1050
+ }
1051
+ private pruneTerminal(state: JournalDeliveryState, currentId: string): void {
1052
+ if (this.pruningDeferred) return;
1053
+ const terminal = state === "completed" || state === "discarded" || state === "failed";
1054
+ const terminalCap = terminal ? this.terminalHistoryLimit?.() ?? TERMINAL_CAP : TERMINAL_CAP;
1055
+ // Never delete the receipt this mutation/resolve is still using, at any target.
931
1056
  const count = Number(
932
1057
  (
933
1058
  this.db
@@ -937,16 +1062,17 @@ export class DeliveryJournal {
937
1062
  .get(this.projectId) as { n: number }
938
1063
  ).n,
939
1064
  );
940
- if (count <= TERMINAL_CAP) return;
1065
+ if (count <= terminalCap) return;
941
1066
  this.db
942
1067
  .query(
943
- "DELETE FROM deliveries WHERE id IN (SELECT d.id FROM deliveries d WHERE d.project_id = ? AND d.state IN ('completed','discarded','failed') AND NOT EXISTS (SELECT 1 FROM deliveries c WHERE c.project_id = d.project_id AND c.previous_id = d.id) ORDER BY d.updated_at, d.id LIMIT ?)",
1068
+ "DELETE FROM deliveries WHERE id IN (SELECT d.id FROM deliveries d WHERE d.project_id = ? AND d.state IN ('completed','discarded','failed') AND d.id != ? AND NOT EXISTS (SELECT 1 FROM deliveries c WHERE c.project_id = d.project_id AND c.previous_id = d.id) ORDER BY d.updated_at, d.id LIMIT ?)",
944
1069
  )
945
- .run(this.projectId, count - TERMINAL_CAP);
1070
+ .run(this.projectId, currentId, count - terminalCap);
946
1071
  }
947
- importSnapshot(snapshot: JournalSnapshot, operationId: string): boolean {
1072
+ importSnapshot(snapshot: JournalSnapshot, operationId: string, trackQueuePressure = false): boolean {
948
1073
  this.ensureOpen();
949
1074
  validateJournalSnapshot(snapshot);
1075
+ const bus = this.queueMetadataSnapshot(snapshot.bus, trackQueuePressure);
950
1076
  text(operationId, "operationId", 256);
951
1077
  if (this.operationId !== undefined && this.operationId !== operationId)
952
1078
  throw new Error("recovery operation does not own delivery journal");
@@ -963,18 +1089,19 @@ export class DeliveryJournal {
963
1089
  Object.keys(storedBus).length !== 0
964
1090
  )
965
1091
  return false;
966
- return this.db.transaction(() => {
1092
+ return this.queueMetadataTransaction(trackQueuePressure, () => {
967
1093
  this.db
968
1094
  .query(
969
1095
  "UPDATE delivery_meta SET revision = ?, bus_snapshot = ?, manual_paused = ?, operation_id = ? WHERE project_id = ?",
970
1096
  )
971
1097
  .run(
972
1098
  snapshot.revision,
973
- JSON.stringify(snapshot.bus),
1099
+ JSON.stringify(bus),
974
1100
  JSON.stringify(snapshot.manualPaused),
975
1101
  operationId,
976
1102
  this.projectId,
977
1103
  );
1104
+ this.writeQueueMetadata(bus, snapshot.revision, trackQueuePressure);
978
1105
  for (const row of snapshot.deliveries)
979
1106
  this.db
980
1107
  .query(
@@ -995,9 +1122,9 @@ export class DeliveryJournal {
995
1122
  row.previousId ?? null,
996
1123
  );
997
1124
  // Snapshots deliberately omit resolution history; only these imported originals have unknown retry rights.
998
- this.markRetryUnknown(snapshot.deliveries, snapshot.bus);
1125
+ this.markRetryUnknown(snapshot.deliveries, bus);
999
1126
  return true;
1000
- })();
1127
+ });
1001
1128
  }
1002
1129
  close(): void {
1003
1130
  if (!this.closed) {
@@ -8,10 +8,12 @@ import type { StampedEvent } from "./events.ts";
8
8
 
9
9
  export interface ProfileHistory {
10
10
  at: number; event: string; criteria?: string[]; criteriaPrivate?: true; deliverables?: string[];
11
+ /** #901: on the hub's own check entries, the check key and the example check's unresolved-key count; on a `claim verdict` entry, the person's verdict. Closed values only, no claim text. */
12
+ checkKey?: string; missingKeys?: number; claim?: { slot: number; verdict: "supported" | "unsupported" | "unclear"; model?: string; modelVerdict?: "supported" | "unsupported" | "unclear"; list?: string };
11
13
  }
12
14
  export interface ProfileTaskMetadata {
13
15
  profile: ProfileName; state?: string; class: string; alias: string | null; history: ProfileHistory[];
14
- plan?: { sources: number | null; claims: number | null };
16
+ plan?: { sources: number | null; claims: number | null; /** #901: `claimListId` of the plan's claims now (an opaque hash, never their text); absent when the list could not be read. */ claimList?: string };
15
17
  }
16
18
  const complete = (s: Source<unknown>) => s.available && s.complete;
17
19
  const number = (n: unknown): n is number => typeof n === "number" && Number.isFinite(n) && n >= 0;
@@ -24,7 +26,7 @@ const criterion = (s: unknown): s is string => typeof s === "string" && /^[A-Za-
24
26
 
25
27
  // Exhaustive maps make producer union changes a compile-time review point.
26
28
  const taskStates: Record<TaskState,true> = {backlog:true,proposed:true,in_progress:true,in_review:true,approved:true,changes_requested:true,cancelled:true};
27
- const usageSources: Record<Extract<StampedEvent,{type:"usage"}>["source"],true> = {omniroute:true,claude_transcript:true,claude_result:true,codex_appserver:true,kimi_wire:true};
29
+ const usageSources: Record<Extract<StampedEvent,{type:"usage"}>["source"],true> = {omniroute:true,claude_transcript:true,claude_result:true,codex_appserver:true,kimi_wire:true,jcode_acp:true};
28
30
  const attributions: Record<Attribution,true> = {delivery:true,single_open:true,unattributed:true};
29
31
  const screenItems: Record<ScreenItem,true> = {task:true,summary:true,"review note":true,handoff:true,note:true};
30
32
  const screenMisses: Record<ScreenMiss,true> = {"too long":true,"off campus":true,timeout:true,unreadable:true,failed:true};
@@ -228,12 +230,37 @@ export function profileScorecard(input: DogfoodInput) {
228
230
  const count=t.metadata.plan?.[key];
229
231
  return full && declarationCount(count) ? count : null;
230
232
  };
233
+ // #901: claim verdicts and check provenance, read only from entries the hub or the person wrote with closed values. Unknown stays unknown, never zero: a count exists only when its source
234
+ // history is complete, and a row without its basis (no verdict, no model verdict, no recorded check key) is null.
235
+ type Verdict = "supported" | "unsupported" | "unclear";
236
+ // A verdict counts only for the claim list it evaluated: its `list` must equal the plan's current claim-list id. After the owner replaces or reorders the claims, an earlier verdict names no claim
237
+ // of the new list (it is unknown again, not carried over to whatever now sits in its slot), and a verdict from before ids were recorded cannot be bound, so it does not count.
238
+ const lastVerdict = (t: BoundTask, slot: number) => t.metadata.history.filter(h => h.event === "claim verdict" && h.claim?.slot === slot && inWindow(h.at) && t.metadata.plan?.claimList !== undefined && h.claim.list === t.metadata.plan.claimList).sort((a, b) => a.at - b.at).at(-1)?.claim;
239
+ const slotRows = group.tasks.map(t => {
240
+ const count = planCount(t, "claims");
241
+ return { task: t.id, claims: count === null ? null : Array.from({ length: count }, (_, i) => {
242
+ const c = lastVerdict(t, i + 1);
243
+ return { slot: i + 1, modelLabel: c?.model ?? null, personVerdict: c?.verdict ?? null, agreement: c?.modelVerdict ? c.modelVerdict === c.verdict : null };
244
+ }) };
245
+ });
246
+ const slotted = slotRows.flatMap(row => row.claims ?? []);
247
+ const decided = slotted.filter(c => c.personVerdict !== null);
248
+ const checkEntries = history.filter(h => (h.event === "check passed" || h.event === "check failed") && h.checkKey === "fact_check");
249
+ const everyCheckKeyed = full && history.filter(h => h.event === "check passed" || h.event === "check failed").every(h => typeof h.checkKey === "string");
250
+ const compared = decided.filter(c => c.agreement !== null);
231
251
  const research = profile === "research" ? {research:{
232
252
  declarations:{scope:"current_plan",perTask:group.tasks.map(t=>({task:t.id,sources:planCount(t,"sources"),claims:planCount(t,"claims")}))},
233
- claims:{reachedPersonVerdict:null,perTask:group.tasks.map(t=>{const count=planCount(t,"claims");return {task:t.id,claims:count===null?null:Array.from({length:count},(_,i)=>({slot:i+1,modelLabel:null,personVerdict:null,agreement:null}))};})},
253
+ // A verdict is the person's, entered with `ahub task claim`; task approval never supplies one. Null until at least the declarations are known; then the number of slots with a recorded verdict.
254
+ claims:{reachedPersonVerdict:full && slotRows.every(r => r.claims !== null) && decided.length > 0 ? decided.length : null,perTask:slotRows},
234
255
  observedCheckOutcomes:{scope:"task_check_history",passed:full?history.filter(h=>h.event==="check passed").length:null,failed:full?history.filter(h=>h.event==="check failed").length:null},
235
- factCheck:{scope:null,selection:null,passed:null,failed:null,missingKeysPerRun:null,
236
- agreement:["supported","unsupported","unclear"].map(label=>({label,count:null}))}
256
+ // Selection and pass/fail are proven only for entries that carry the hub's own check key, and only when every check entry of the group carries one (an older entry has no key: that is unknown, not "another check").
257
+ factCheck:{scope:everyCheckKeyed && checkEntries.length > 0 ? "task_check_history_with_key" : null,selection:everyCheckKeyed && checkEntries.length > 0 ? "checks.fact_check" : null,
258
+ passed:everyCheckKeyed && checkEntries.length > 0 ? checkEntries.filter(h => h.event === "check passed").length : null,
259
+ failed:everyCheckKeyed && checkEntries.length > 0 ? checkEntries.filter(h => h.event === "check failed").length : null,
260
+ missingKeysPerRun:everyCheckKeyed && checkEntries.length > 0 && checkEntries.every(h => h.missingKeys !== undefined) ? checkEntries.map(h => h.missingKeys!) : null,
261
+ // Agreement is a count of slots where the model's own verdict was entered and equals the person's, per label. A slot without the model's verdict (agreement null) is unknown and counts for nothing; one where they differ
262
+ // (agreement false) is a disagreement and counts for no label. With no comparable slot every label is null, never zero.
263
+ agreement:(["supported","unsupported","unclear"] as Verdict[]).map(label=>({label,count:compared.length > 0 ? compared.filter(c => c.agreement === true && c.personVerdict === label).length : null}))}
237
264
  }} : {};
238
265
  return {...research,profile, alias:group.alias, baseClass:group.base, tasks:[...ids].sort((a,b)=>a-b),
239
266
  flow:{scope,autoApprovalTransitions:{scope:"recorded_events",value:eventsFull?new Set(autoApprovals.map(e=>e.id)).size:null},proposed:count("proposed"),accepted:count("accepted"),approved:milestoneKnown&&!approvalUnknown?approvalIds.size:null,cancelled:count("cancelled"),proposeToApproveMs:full&&!approvalUnknown?quantiles(leads):{samples:null,p50:null,p90:null},reviewRounds:milestoneKnown?rounds:null,firstPassApproval:full&&!approvalUnknown&&first.length?first.reduce((a,b)=>a+b,0)/first.length:null,escalations:count("escalated")},
@@ -1,3 +1,4 @@
1
+ import { claimListId } from "./board.ts";
1
2
  import { closeSync, constants, fstatSync, openSync, readSync } from "node:fs";
2
3
  import { join } from "node:path";
3
4
  import { Database } from "bun:sqlite";
@@ -96,7 +97,10 @@ export function dogfoodTasks(file: string): Source<DogfoodTask[]> {
96
97
  if (history.some((h:any) => h && (h.unmetCriteria !== undefined && (!Array.isArray(h.unmetCriteria) || h.unmetCriteria.length>32 || new Set(h.unmetCriteria).size!==h.unmetCriteria.length || h.unmetCriteria.some((v:unknown)=>typeof v!=="string" || !/^[A-Za-z][A-Za-z0-9-]{0,47}$/.test(v))) || h.deliverables !== undefined && (!Array.isArray(h.deliverables) || h.deliverables.length>16 || h.deliverables.some((v:any)=>typeof v?.id!=="string" || !/^d-[0-9a-f]{12}$/.test(v.id)))))) complete=false;
97
98
  const projection = history.filter((h:any)=>h && Number.isFinite(h.at) && typeof h.event==="string").map((h:any) => ({at:h.at,event:h.event,
98
99
  ...(Array.isArray(h.unmetCriteria) ? {criteria:h.unmetCriteria.slice(0,32).filter((v:unknown)=>typeof v === "string" && /^[A-Za-z][A-Za-z0-9-]{0,47}$/.test(v)).map((v:string)=>pii || h.withheld ? "withheld" : v), ...(h.withheld ? {criteriaPrivate:true as const} : {})} : {}),
99
- ...(Array.isArray(h.deliverables) ? {deliverables:h.deliverables.slice(0,16).filter((v:any)=>typeof v?.id==="string" && /^d-[0-9a-f]{12}$/.test(v.id)).map((v:any)=>v.id)} : {})}));
100
+ ...(Array.isArray(h.deliverables) ? {deliverables:h.deliverables.slice(0,16).filter((v:any)=>typeof v?.id==="string" && /^d-[0-9a-f]{12}$/.test(v.id)).map((v:any)=>v.id)} : {}),
101
+ // #901: only closed values cross: a check key is a bounded identifier, a count a safe integer, a verdict one of three words and a model label a bounded token. Anything else is dropped (unknown).
102
+ ...(h.event==="check passed" || h.event==="check failed" ? {...(typeof h.checkKey==="string" && /^[a-z][a-z0-9_]{0,47}$/.test(h.checkKey) ? {checkKey:h.checkKey} : {}), ...(Number.isSafeInteger(h.missingKeys) && h.missingKeys>=0 && h.missingKeys<=100000 ? {missingKeys:h.missingKeys} : {})} : {}),
103
+ ...(h.event==="claim verdict" && h.claim && Number.isSafeInteger(h.claim.slot) && h.claim.slot>=1 && h.claim.slot<=50 && ["supported","unsupported","unclear"].includes(h.claim.verdict) ? {claim:{slot:h.claim.slot,verdict:h.claim.verdict,...(typeof h.claim.list==="string" && /^[0-9a-f]{16}$/.test(h.claim.list) ? {list:h.claim.list} : {}),...(typeof h.claim.model==="string" && /^[A-Za-z0-9][A-Za-z0-9._:/@+-]{0,63}$/.test(h.claim.model) && !pii ? {model:h.claim.model} : {}),...(["supported","unsupported","unclear"].includes(h.claim.modelVerdict) ? {modelVerdict:h.claim.modelVerdict} : {})}} : {})}));
100
104
  let declarations: ProfileTaskMetadata["plan"];
101
105
  if (!row.plan_cut && row.plan !== null) {
102
106
  try {
@@ -110,7 +114,7 @@ export function dogfoodTasks(file: string): Source<DogfoodTask[]> {
110
114
  if (!Array.isArray(value) || value.length>50 || value.some((v:unknown)=>typeof v!=="string" || v.length>300)) throw new Error("plan list malformed");
111
115
  return value.length;
112
116
  };
113
- declarations={sources:count("sources"),claims:count("claims")};
117
+ declarations={sources:count("sources"),claims:count("claims"),claimList:claimListId(Array.isArray(rawPlan.claims) ? rawPlan.claims : undefined)};
114
118
  }
115
119
  } catch {planBad=true;}
116
120
  }
@@ -78,6 +78,17 @@ export const STANDING_INSTRUCTION =
78
78
  "with [IMPORTANT] only when the recipient must see it before finishing what it is doing. Unmarked messages are batched. " +
79
79
  HUB_MESSAGE_INSTRUCTION;
80
80
 
81
+ /**
82
+ * #912: Jcode defers MCP tools. Only its built-in tools are listed, and an MCP tool appears once the model searches for it, so a Jcode peer with no hint
83
+ * concludes it has no `hub_*` tools and answers in its final message instead of calling `hub_send`, `hub_task_accept`, `hub_task_done` or `hub_review`.
84
+ * This is the ONE place the guidance is written: `JcodePeer` puts it in front of the session's role contract (sent once, with the first prompt of a session),
85
+ * and no other peer's prompt, the AGENTS block or a profile template repeats it. The server name is `JCODE_MCP_NAME` (`src/cli/jcode-mcp.ts`); a test pins them equal.
86
+ */
87
+ export const JCODE_DEFERRED_TOOLS_INSTRUCTION =
88
+ "[agent-hub] Your hub tools (hub_send, hub_inbox, hub_task_accept, hub_task_done, hub_review and the rest) are deferred MCP tools from the agent-hub-jcode server: " +
89
+ "they are not in your visible tool list until you load them. Before you conclude a hub tool is missing, search for it by name with your tool search and load it, " +
90
+ "then call it. Never report the hub as unavailable without having searched. Answer a hub message with hub_send, not only in your final message.";
91
+
81
92
  const MARKER = /^\s*\[(IMPORTANT|STATUS|FYI)\]\s*/i;
82
93
 
83
94
  /** A leading [IMPORTANT] / [STATUS] / [FYI] sets the priority and is stripped from the body. */