@staix/agent-hub 0.19.0 → 0.21.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 (184) hide show
  1. package/CHANGELOG.md +70 -3
  2. package/README.md +2 -2
  3. package/docs/agent-notes/README.md +11 -0
  4. package/docs/agent-notes/adapters.md +10 -5
  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 +20 -7
  9. package/docs/agent-notes/local-worker.md +5 -0
  10. package/docs/agent-notes/models.md +2 -0
  11. package/docs/agent-notes/tasks.md +20 -10
  12. package/docs/agent-notes/tests.md +13 -5
  13. package/docs/bench.md +1 -1
  14. package/docs/data-policy.md +2 -0
  15. package/docs/dogfood.md +41 -5
  16. package/docs/events.md +2 -1
  17. package/docs/memory.md +9 -3
  18. package/docs/operations.md +598 -63
  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 +66 -10
  23. package/docs/security.md +397 -35
  24. package/docs/smoke.md +127 -0
  25. package/docs/specs/2026-09-19-agent-hub-design.md +1149 -19
  26. package/docs/verification/2026-10-27-0.19.0.md +5 -5
  27. package/docs/verification/2026-10-27-0.20.0.md +75 -0
  28. package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-bash-permission.json +635 -0
  29. package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-permission-transport.json +403 -0
  30. package/docs/verification/2026-10-28-claude-implementer-permission-triage.md +123 -0
  31. package/docs/verification/2026-10-28-jcode-0.99.1-usage.md +41 -0
  32. package/docs/verification/2026-10-28-realpath-name-audit.md +222 -0
  33. package/docs/verification/2026-10-28-routing-f0bb8ad0.md +1000 -0
  34. package/docs/verification/2026-10-29-0.21.0.md +61 -0
  35. package/docs/verification/2026-10-29-model-effort-capabilities.md +123 -0
  36. package/docs/verification/2026-10-29-modelrouting-b152ddb9.md +953 -0
  37. package/docs/verification/claude-implementer-permission-triage.ts +160 -0
  38. package/docs/verification/claude-permission-transport-native.py +71 -14
  39. package/docs/verification/profiles/YYYY-MM-DD-research.md +1 -1
  40. package/docs/verified.json +26 -18
  41. package/package.json +2 -2
  42. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  43. package/plugins/agent-hub/server.js +1116 -284
  44. package/src/adapters/acp.ts +87 -3
  45. package/src/adapters/channel-peer.ts +11 -5
  46. package/src/adapters/claude-channel.ts +13 -6
  47. package/src/adapters/claude-implementer.ts +61 -7
  48. package/src/adapters/codex-appserver.ts +88 -5
  49. package/src/adapters/conformance/enrollment.ts +4 -4
  50. package/src/adapters/conformance/factories.ts +2 -1
  51. package/src/adapters/conformance/fakes/jcode-agent.ts +6 -2
  52. package/src/adapters/conformance/kit.ts +10 -6
  53. package/src/adapters/definitions/builtin-metadata.ts +3 -114
  54. package/src/adapters/definitions/native-config-defaults.ts +1 -1
  55. package/src/adapters/definitions/native-config-policy.ts +3 -2
  56. package/src/adapters/definitions/native-context.ts +12 -0
  57. package/src/adapters/external-metadata.ts +48 -0
  58. package/src/adapters/external-modules.ts +63 -3
  59. package/src/adapters/jcode.ts +6 -2
  60. package/src/adapters/local-worker.ts +42 -25
  61. package/src/adapters/native-proof-ports.ts +10 -0
  62. package/src/adapters/pi.ts +7 -0
  63. package/src/adapters/registry-types.ts +4 -0
  64. package/src/adapters/reviewer-print.ts +1 -0
  65. package/src/broker/protocol.ts +203 -0
  66. package/src/broker/server.ts +314 -0
  67. package/src/broker/store.ts +548 -0
  68. package/src/cli/bench.ts +3 -2
  69. package/src/cli/broker.ts +58 -0
  70. package/src/cli/conductor-recovery.ts +142 -0
  71. package/src/cli/configured-acp-entry.ts +26 -6
  72. package/src/cli/console-state.ts +59 -17
  73. package/src/cli/console.ts +17 -2
  74. package/src/cli/facts-hook.ts +3 -1
  75. package/src/cli/help.ts +28 -16
  76. package/src/cli/identity.ts +23 -10
  77. package/src/cli/init.ts +34 -7
  78. package/src/cli/intake-output.ts +33 -0
  79. package/src/cli/main.ts +314 -71
  80. package/src/cli/output.ts +41 -1
  81. package/src/cli/recovery-package.ts +18 -1
  82. package/src/cli/role-holders.ts +35 -0
  83. package/src/cli/room.ts +304 -0
  84. package/src/cli/say-input.ts +16 -0
  85. package/src/cli/schedule-output.ts +49 -0
  86. package/src/cli/sdlc-output.ts +44 -6
  87. package/src/cli/smoke-pty-bridge.ts +33 -0
  88. package/src/cli/smoke-pty.ts +145 -0
  89. package/src/cli/smoke-run.ts +139 -0
  90. package/src/cli/smoke-target.ts +130 -0
  91. package/src/cli/source-output.ts +29 -0
  92. package/src/cli/task-output.ts +3 -2
  93. package/src/cli/upgrade-runtime.ts +98 -16
  94. package/src/cli/upgrade.ts +66 -7
  95. package/src/hub/ask.ts +76 -12
  96. package/src/hub/board.ts +132 -22
  97. package/src/hub/bus.ts +399 -56
  98. package/src/hub/child-process.ts +2 -0
  99. package/src/hub/ci-watch.ts +162 -0
  100. package/src/hub/claude-failure-stage.ts +85 -0
  101. package/src/hub/claude-launch-proof.ts +67 -2
  102. package/src/hub/claude-shell-wrapper.ts +11 -5
  103. package/src/hub/conductor.ts +17 -4
  104. package/src/hub/config-apply.ts +32 -5
  105. package/src/hub/config-reload.ts +7 -5
  106. package/src/hub/config-trust.ts +8 -1
  107. package/src/hub/context-window.ts +33 -3
  108. package/src/hub/control-client.ts +14 -2
  109. package/src/hub/corpus-config.ts +34 -0
  110. package/src/hub/corpus-evidence.ts +184 -0
  111. package/src/hub/corpus.ts +373 -0
  112. package/src/hub/cost-governance.ts +26 -0
  113. package/src/hub/daemon.ts +1228 -242
  114. package/src/hub/deliverable-files.ts +8 -2
  115. package/src/hub/delivery-journal.ts +123 -17
  116. package/src/hub/dogfood-profile.ts +32 -5
  117. package/src/hub/dogfood-read.ts +6 -2
  118. package/src/hub/envelope.ts +11 -0
  119. package/src/hub/eval-gate.ts +131 -0
  120. package/src/hub/events.ts +1 -1
  121. package/src/hub/external-instance.ts +69 -0
  122. package/src/hub/facts.ts +15 -5
  123. package/src/hub/folder-trigger.ts +223 -0
  124. package/src/hub/hub-tools.ts +2 -1
  125. package/src/hub/inference.ts +20 -5
  126. package/src/hub/instance-config.ts +69 -3
  127. package/src/hub/instance-execution.ts +6 -2
  128. package/src/hub/instances.ts +32 -4
  129. package/src/hub/intake.ts +125 -0
  130. package/src/hub/learning.ts +85 -0
  131. package/src/hub/lifecycle.ts +7 -3
  132. package/src/hub/limits.ts +12 -5
  133. package/src/hub/native-permission-request.ts +77 -0
  134. package/src/hub/operational-authority.ts +63 -5
  135. package/src/hub/operational-settings.ts +76 -0
  136. package/src/hub/operational-view.ts +1 -1
  137. package/src/hub/packs.ts +132 -0
  138. package/src/hub/path-policy.ts +7 -0
  139. package/src/hub/peer-doing.ts +130 -0
  140. package/src/hub/peers.ts +46 -0
  141. package/src/hub/queue-backpressure.ts +58 -0
  142. package/src/hub/read-tools.ts +2 -0
  143. package/src/hub/recovery-authority.ts +151 -0
  144. package/src/hub/recovery-policy.ts +220 -0
  145. package/src/hub/research.ts +24 -14
  146. package/src/hub/restart.ts +7 -0
  147. package/src/hub/routing.ts +54 -11
  148. package/src/hub/schedules.ts +266 -0
  149. package/src/hub/sdlc-merge.ts +38 -14
  150. package/src/hub/sdlc-metrics.ts +32 -10
  151. package/src/hub/sdlc-queue.ts +7 -1
  152. package/src/hub/sdlc-release.ts +1 -1
  153. package/src/hub/sdlc.ts +37 -7
  154. package/src/hub/security-baseline.ts +38 -0
  155. package/src/hub/settings.ts +7 -1
  156. package/src/hub/shipped-packs.ts +8 -0
  157. package/src/hub/snapshots.ts +72 -6
  158. package/src/hub/source-read.ts +160 -0
  159. package/src/hub/sources.ts +311 -0
  160. package/src/hub/task-shell-default.ts +3 -2
  161. package/src/hub/tasks.ts +1047 -115
  162. package/src/hub/workflow.ts +1 -1
  163. package/src/local/deny.ts +21 -0
  164. package/src/local/doc-read.ts +69 -0
  165. package/src/local/proxy.ts +4 -13
  166. package/src/local/sandbox.ts +3 -1
  167. package/src/local/tools.ts +37 -3
  168. package/src/local/web-fetch-config.ts +43 -0
  169. package/src/local/web-fetch.ts +183 -0
  170. package/src/memory/brief.ts +85 -13
  171. package/src/memory/capture.ts +1 -1
  172. package/src/rooms/application.ts +114 -0
  173. package/src/rooms/boundary.ts +103 -0
  174. package/src/rooms/commands.ts +294 -0
  175. package/src/rooms/crypto.ts +371 -0
  176. package/src/rooms/link.ts +584 -0
  177. package/src/rooms/policy.ts +82 -0
  178. package/src/rooms/remote-peer.ts +80 -0
  179. package/src/rooms/service.ts +426 -0
  180. package/src/rooms/status.ts +146 -0
  181. package/src/rooms/store.ts +1210 -0
  182. package/src/rooms/wire.ts +465 -0
  183. package/src/ui/index.html +91 -4
  184. 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 =
@@ -168,6 +170,32 @@ function validateBus(value: unknown, name = "bus"): BusSnapshot {
168
170
  list(b.manualPaused, `${name}.manualPaused`, 4096).forEach((id) =>
169
171
  text(id, `${name}.manualPaused[]`, 256),
170
172
  );
173
+ if (b.remoteQueueMembers !== undefined) {
174
+ const members = obj(b.remoteQueueMembers, `${name}.remoteQueueMembers`);
175
+ if (Object.keys(members).length > 12800) fail(`${name}.remoteQueueMembers is too large`);
176
+ for (const [peer, member] of Object.entries(members)) {
177
+ if (!parseRemotePeerId(peer) || !Array.isArray(queues[peer]) || typeof member !== "string" || !/^[0-9a-f]{64}$/.test(member)) fail(`${name}.remoteQueueMembers is invalid`);
178
+ }
179
+ }
180
+ if (b.remoteQueueRooms !== undefined) {
181
+ const rooms = obj(b.remoteQueueRooms, `${name}.remoteQueueRooms`);
182
+ if (Object.keys(rooms).length > 12800) fail(`${name}.remoteQueueRooms is too large`);
183
+ for (const [peer, room] of Object.entries(rooms)) {
184
+ if (!parseRemotePeerId(peer) || !Array.isArray(queues[peer])) fail(`${name}.remoteQueueRooms key is invalid`);
185
+ text(room, `${name}.remoteQueueRooms room`, 256);
186
+ }
187
+ }
188
+ if (b.remoteQueueExpires !== undefined) {
189
+ const expiries = obj(b.remoteQueueExpires, `${name}.remoteQueueExpires`);
190
+ if (Object.keys(expiries).length > 4096) fail(`${name}.remoteQueueExpires is too large`);
191
+ let count = 0;
192
+ for (const [peer, values] of Object.entries(expiries)) {
193
+ if (!parseRemotePeerId(peer)) fail(`${name}.remoteQueueExpires key is invalid`);
194
+ const entries = Object.entries(obj(values, `${name}.remoteQueueExpires.${peer}`));
195
+ if (entries.length > 200 || (count += entries.length) > 12800) fail(`${name}.remoteQueueExpires is too large`);
196
+ for (const [id, expiry] of entries) { text(id, `${name}.remoteQueueExpires id`, 256); integer(expiry, `${name}.remoteQueueExpires expiry`); }
197
+ }
198
+ }
171
199
  const occupied = new Set<string>([
172
200
  ...(b.seen as Envelope[]).map(env => env.id),
173
201
  ...Object.values(queues).flatMap(items => (items as Envelope[]).map(env => env.id)),
@@ -241,6 +269,8 @@ export class DeliveryJournal {
241
269
  readonly instanceId: string;
242
270
  private readonly db: Database;
243
271
  private closed = false;
272
+ private queueMetadataTracking = false;
273
+ private queueMetadataInvalidated = false;
244
274
  private readonly operationId?: string;
245
275
  constructor(options: DeliveryJournalOptions) {
246
276
  this.file = options.file;
@@ -323,6 +353,8 @@ export class DeliveryJournal {
323
353
  this.db.run(
324
354
  `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
355
  );
356
+ this.db.run("CREATE TABLE IF NOT EXISTS delivery_queue_meta_state (project_id TEXT PRIMARY KEY, revision INTEGER NOT NULL)");
357
+ 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
358
  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
359
  this.db.run(
328
360
  `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 +506,71 @@ export class DeliveryJournal {
474
506
  ) !== 1
475
507
  )
476
508
  throw new Error("delivery journal instance fence lost");
509
+ // New writers carry unchanged summary provenance across non-queue revisions.
510
+ // An older writer does not, so its next revision makes these rows unknown.
511
+ if (this.queueMetadataTracking) this.db.query("UPDATE delivery_queue_meta_state SET revision=? WHERE project_id=? AND revision=?")
512
+ .run(revision, this.projectId, revision - 1);
477
513
  return revision;
478
514
  }
515
+
516
+ /** Atomically accompanies the bus snapshot, never extracts body JSON on a periodic path. */
517
+ private writeQueueMetadata(bus: BusSnapshot, revision: number, tracking: boolean): void {
518
+ if (!tracking) {
519
+ // Invalidate once per disabled period; ordinary revisions already stale old summaries.
520
+ if (!this.queueMetadataInvalidated) {
521
+ this.db.query("UPDATE delivery_queue_meta_state SET revision=-1 WHERE project_id=? AND revision>=0").run(this.projectId);
522
+ this.queueMetadataInvalidated = true;
523
+ }
524
+ return;
525
+ }
526
+ this.db.query("DELETE FROM delivery_queue_meta WHERE project_id=?").run(this.projectId);
527
+ const put = this.db.query("INSERT INTO delivery_queue_meta(project_id,peer,queued,oldest_at,unknown_age) VALUES(?,?,?,?,?)");
528
+ for (const [peer, queue] of Object.entries(bus.queues)) {
529
+ const provenance = bus.queuedAt;
530
+ const times = provenance && typeof provenance === "object" && !Array.isArray(provenance) && Object.hasOwn(provenance, peer) ? provenance[peer] : undefined;
531
+ let oldest: number | undefined, unknown = false;
532
+ for (const env of queue) {
533
+ const at = times && typeof times === "object" && !Array.isArray(times) && Object.hasOwn(times, env.id) ? times[env.id] : null;
534
+ if (typeof at !== "number" || !Number.isSafeInteger(at) || at <= 0) unknown = true;
535
+ else oldest = oldest === undefined ? at : Math.min(oldest, at);
536
+ }
537
+ put.run(this.projectId, peer, queue.length, oldest ?? null, unknown ? 1 : 0);
538
+ }
539
+ this.db.query("INSERT INTO delivery_queue_meta_state(project_id,revision) VALUES(?,?) ON CONFLICT(project_id) DO UPDATE SET revision=excluded.revision")
540
+ .run(this.projectId, revision);
541
+ this.queueMetadataInvalidated = false;
542
+ }
543
+
544
+ /** Host policy only, refreshed before receipt/reply mutations as well as snapshot writes. No SQL. */
545
+ setQueueMetadataTracking(tracking: boolean): void { this.queueMetadataTracking = tracking === true; }
546
+
547
+ private queueMetadataTransaction<T>(tracking: boolean, fn: () => T): T {
548
+ return this.transaction(() => { this.setQueueMetadataTracking(tracking); return fn(); });
549
+ }
550
+ private queueMetadataSnapshot(bus: BusSnapshot, tracking: boolean): BusSnapshot {
551
+ if (tracking || bus.queuedAt === undefined) return bus;
552
+ const legacy = { ...bus }; delete legacy.queuedAt;
553
+ return legacy;
554
+ }
555
+
556
+ /** Indexed per-peer pending counts/age only, independent of delivery history and payload columns. */
557
+ pendingQueueMetadata(peer: string): QueuePressure | undefined {
558
+ this.ensureOpen();
559
+ 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=?")
560
+ .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;
561
+ if (!row || !Number.isSafeInteger(row.revision) || row.revision < 0 || row.metadata_revision !== row.revision) return undefined;
562
+ if (row.metadata_peer === null) return { queued: 0, oldestQueuedAt: null };
563
+ if (typeof row.queued !== "number" || !Number.isSafeInteger(row.queued) || row.queued < 0 || row.queued > 2048 || ![0, 1].includes(row.unknown_age!)) return undefined;
564
+ if (row.queued === 0) return row.oldest_at === null && row.unknown_age === 0 ? { queued: 0, oldestQueuedAt: null } : undefined;
565
+ if (row.unknown_age === 1) return { queued: row.queued };
566
+ if (!Number.isSafeInteger(row.oldest_at) || row.oldest_at! <= 0) return undefined;
567
+ return { queued: row.queued, oldestQueuedAt: row.oldest_at! };
568
+ }
479
569
  transaction<T>(fn: () => T): T {
480
570
  this.ensureOpen();
481
- return this.db.transaction(fn)();
571
+ const invalidated = this.queueMetadataInvalidated;
572
+ try { return this.db.transaction(fn)(); }
573
+ catch (error) { this.queueMetadataInvalidated = invalidated; throw error; }
482
574
  }
483
575
  snapshot(): JournalSnapshot {
484
576
  this.ensureOpen();
@@ -513,26 +605,34 @@ export class DeliveryJournal {
513
605
  validateJournalSnapshot(result);
514
606
  return result;
515
607
  }
516
- persistBus(bus: unknown, manualPaused: string[]): number {
608
+ persistBus(bus: unknown, manualPaused: string[], trackQueuePressure = false): number {
517
609
  this.ensureOpen();
518
- validateBus(bus);
610
+ let checkedBus = this.queueMetadataSnapshot(validateBus(bus), trackQueuePressure);
611
+ let encodedBus: string;
612
+ try { encodedBus = JSON.stringify(checkedJSON(checkedBus, "bus")); }
613
+ catch (error) {
614
+ if (checkedBus.queuedAt === undefined || !(error instanceof Error) || error.message !== "invalid delivery journal: bus is too large") throw error;
615
+ checkedBus = { ...checkedBus }; delete checkedBus.queuedAt;
616
+ encodedBus = JSON.stringify(checkedJSON(checkedBus, "bus"));
617
+ }
519
618
  list(manualPaused, "manualPaused", 4096).forEach((id) =>
520
619
  text(id, "manualPaused[]", 256),
521
620
  );
522
- return this.db.transaction(() => {
621
+ return this.queueMetadataTransaction(trackQueuePressure, () => {
523
622
  const revision = this.bump();
524
623
  this.db
525
624
  .query(
526
625
  "UPDATE delivery_meta SET bus_snapshot = ?, manual_paused = ? WHERE project_id = ?",
527
626
  )
528
627
  .run(
529
- JSON.stringify(checkedJSON(bus, "bus")),
628
+ encodedBus,
530
629
  JSON.stringify([...new Set(manualPaused)].sort()),
531
630
  this.projectId,
532
631
  );
632
+ this.writeQueueMetadata(checkedBus, revision, trackQueuePressure);
533
633
  this.harden();
534
634
  return revision;
535
- })();
635
+ });
536
636
  }
537
637
  /** Atomically persist a bus snapshot and any fan-out rows created for that snapshot. */
538
638
  persistBusAndDeliveries(
@@ -541,9 +641,10 @@ export class DeliveryJournal {
541
641
  deliveries: Array<
542
642
  Omit<JournalDelivery, "revision" | "updatedAt"> & { updatedAt?: number }
543
643
  >,
644
+ trackQueuePressure = false,
544
645
  ): number {
545
646
  this.ensureOpen();
546
- validateBus(bus);
647
+ bus = this.queueMetadataSnapshot(validateBus(bus), trackQueuePressure);
547
648
  list(manualPaused, "manualPaused", 4096).forEach((id) =>
548
649
  text(id, "manualPaused[]", 256),
549
650
  );
@@ -554,7 +655,7 @@ export class DeliveryJournal {
554
655
  updatedAt: d.updatedAt ?? d.createdAt,
555
656
  }),
556
657
  );
557
- return this.db.transaction(() => {
658
+ return this.queueMetadataTransaction(trackQueuePressure, () => {
558
659
  const revision = this.bump();
559
660
  this.db
560
661
  .query(
@@ -565,6 +666,7 @@ export class DeliveryJournal {
565
666
  JSON.stringify([...new Set(manualPaused)].sort()),
566
667
  this.projectId,
567
668
  );
669
+ this.writeQueueMetadata(bus, revision, trackQueuePressure);
568
670
  for (const d of deliveries)
569
671
  this.db
570
672
  .query(
@@ -586,21 +688,22 @@ export class DeliveryJournal {
586
688
  );
587
689
  this.harden();
588
690
  return revision;
589
- })();
691
+ });
590
692
  }
591
693
  checkpointDelivery(
592
694
  bus: BusSnapshot,
593
695
  delivery: Omit<JournalDelivery, "revision" | "updatedAt"> & {
594
696
  state: "dispatching";
595
697
  },
698
+ trackQueuePressure = false,
596
699
  ): JournalDelivery {
597
- validateBus(bus);
700
+ bus = this.queueMetadataSnapshot(validateBus(bus), trackQueuePressure);
598
701
  validateDelivery({
599
702
  ...delivery,
600
703
  revision: 0,
601
704
  updatedAt: delivery.createdAt,
602
705
  });
603
- return this.db.transaction(() => {
706
+ return this.queueMetadataTransaction(trackQueuePressure, () => {
604
707
  const evidence = this.retryEvidence(delivery.peer, delivery.originals);
605
708
  if (evidence === "used") {
606
709
  // Inheritance commits with the handoff, before safe failure or pruning can erase a grouped parent.
@@ -627,6 +730,7 @@ export class DeliveryJournal {
627
730
  JSON.stringify(bus.manualPaused ?? []),
628
731
  this.projectId,
629
732
  );
733
+ this.writeQueueMetadata(bus, revision, trackQueuePressure);
630
734
  this.db
631
735
  .query(
632
736
  "INSERT INTO deliveries (id, project_id, peer, state, revision, created_at, updated_at, originals, out_json, reason, attempt, previous_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
@@ -647,7 +751,7 @@ export class DeliveryJournal {
647
751
  );
648
752
  this.harden();
649
753
  return { ...delivery, revision, updatedAt: now };
650
- })();
754
+ });
651
755
  }
652
756
  createDelivery(
653
757
  input: Omit<JournalDelivery, "revision" | "updatedAt"> & {
@@ -944,9 +1048,10 @@ export class DeliveryJournal {
944
1048
  )
945
1049
  .run(this.projectId, count - TERMINAL_CAP);
946
1050
  }
947
- importSnapshot(snapshot: JournalSnapshot, operationId: string): boolean {
1051
+ importSnapshot(snapshot: JournalSnapshot, operationId: string, trackQueuePressure = false): boolean {
948
1052
  this.ensureOpen();
949
1053
  validateJournalSnapshot(snapshot);
1054
+ const bus = this.queueMetadataSnapshot(snapshot.bus, trackQueuePressure);
950
1055
  text(operationId, "operationId", 256);
951
1056
  if (this.operationId !== undefined && this.operationId !== operationId)
952
1057
  throw new Error("recovery operation does not own delivery journal");
@@ -963,18 +1068,19 @@ export class DeliveryJournal {
963
1068
  Object.keys(storedBus).length !== 0
964
1069
  )
965
1070
  return false;
966
- return this.db.transaction(() => {
1071
+ return this.queueMetadataTransaction(trackQueuePressure, () => {
967
1072
  this.db
968
1073
  .query(
969
1074
  "UPDATE delivery_meta SET revision = ?, bus_snapshot = ?, manual_paused = ?, operation_id = ? WHERE project_id = ?",
970
1075
  )
971
1076
  .run(
972
1077
  snapshot.revision,
973
- JSON.stringify(snapshot.bus),
1078
+ JSON.stringify(bus),
974
1079
  JSON.stringify(snapshot.manualPaused),
975
1080
  operationId,
976
1081
  this.projectId,
977
1082
  );
1083
+ this.writeQueueMetadata(bus, snapshot.revision, trackQueuePressure);
978
1084
  for (const row of snapshot.deliveries)
979
1085
  this.db
980
1086
  .query(
@@ -995,9 +1101,9 @@ export class DeliveryJournal {
995
1101
  row.previousId ?? null,
996
1102
  );
997
1103
  // Snapshots deliberately omit resolution history; only these imported originals have unknown retry rights.
998
- this.markRetryUnknown(snapshot.deliveries, snapshot.bus);
1104
+ this.markRetryUnknown(snapshot.deliveries, bus);
999
1105
  return true;
1000
- })();
1106
+ });
1001
1107
  }
1002
1108
  close(): void {
1003
1109
  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. */
@@ -0,0 +1,131 @@
1
+ import type { PrEvidence, SdlcConfig, SdlcGh } from "./sdlc.ts";
2
+ import { completePlanInventory } from "./plan-conformance.ts";
3
+
4
+ /**
5
+ * #481 S5: the gate MECHANISM for agent-configuration PRs. A PR whose changed paths match `config_paths` needs the one check named by `eval_check`.
6
+ * The hub chooses, creates and defaults to no suite: `eval_check` has no default, and the person names it (suite choice pending the person).
7
+ */
8
+ export const DEFAULT_CONFIG_PATHS: readonly string[] = [
9
+ "AGENTS.md", "docs/agent-notes/**", "templates/**", ".agenthub/roles/**", ".agenthub/routing.toml", "routing.toml", "src/hub/hub-tools.ts",
10
+ ];
11
+ const MAX_PATTERNS = 32;
12
+
13
+ /** Repository-relative patterns: literal names, `*` and `?` inside one segment, and a whole-segment `**`. Anything else is refused at load. */
14
+ export function configPathList(value: unknown, what: string): string[] {
15
+ const bad = () => new Error(`${what}: expected at most ${MAX_PATTERNS} unique repository-relative patterns (literal names, * and ? inside a segment, whole-segment **)`);
16
+ if (!Array.isArray(value) || value.length > MAX_PATTERNS || new Set(value).size !== value.length) throw bad();
17
+ for (const pattern of value) {
18
+ if (typeof pattern !== "string" || !pattern || pattern.length > 200 || /[\p{Cc}\p{Cf}\\]/u.test(pattern) || pattern.startsWith("/") || /^[A-Za-z]:/.test(pattern)) throw bad();
19
+ const parts = pattern.split("/");
20
+ if (parts.some(part => part === "" || part === "." || part === ".." || (part.includes("**") && part !== "**"))) throw bad();
21
+ }
22
+ return [...value] as string[];
23
+ }
24
+
25
+ const segmentRegex = (part: string) => new RegExp(`^${part.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]")}$`);
26
+ /** Pure, lexical, no filesystem. `dir/**` matches everything under `dir`, never `dir` itself. */
27
+ export function matchConfigPath(pattern: string, path: string): boolean {
28
+ const p = pattern.split("/"), s = path.split("/");
29
+ const walk = (i: number, j: number): boolean => {
30
+ if (i === p.length) return j === s.length;
31
+ if (p[i] === "**") { for (let k = j + 1; k <= s.length; k++) if (walk(i + 1, k)) return true; return false; }
32
+ return j < s.length && segmentRegex(p[i]!).test(s[j]!) && walk(i + 1, j + 1);
33
+ };
34
+ return walk(0, 0);
35
+ }
36
+
37
+ export type EvalApplies = "config" | "code" | "unknown";
38
+ export const configPathsOf = (config: Pick<SdlcConfig, "configPaths">): readonly string[] => config.configPaths ?? DEFAULT_CONFIG_PATHS;
39
+
40
+ /** `unknown` when the file inventory is not complete (never a clean code-only claim); `code` for a PR touching no configured path, an empty `config_paths` included. */
41
+ export function evalApplies(config: Pick<SdlcConfig, "configPaths">, evidence: PrEvidence | undefined): { applies: EvalApplies; matched: string[] } {
42
+ const paths = configPathsOf(config);
43
+ if (!paths.length) return { applies: "code", matched: [] };
44
+ const inventory = completePlanInventory(evidence);
45
+ if (inventory === undefined) return { applies: "unknown", matched: [] };
46
+ const matched = inventory.filter(path => paths.some(pattern => matchConfigPath(pattern, path))).sort();
47
+ return { applies: matched.length ? "config" : "code", matched };
48
+ }
49
+
50
+ /** The extra required check for this reading of the PR: the configured eval check, only for a configuration PR. */
51
+ export function evalRequiredChecks(config: Pick<SdlcConfig, "configPaths" | "evalCheck" | "mode">, evidence: PrEvidence | undefined): string[] {
52
+ if (config.mode === "off" || !config.evalCheck) return [];
53
+ return evalApplies(config, evidence).applies === "config" ? [config.evalCheck] : [];
54
+ }
55
+
56
+ /** What the eval check run reported, never its text: two bounded values read from a fixed marker (`eval_pass_rate=<0..1>`, `eval_suite=<version>`). */
57
+ export interface EvalResult { check: string; head: string; passRate?: number; suite?: string; read: boolean }
58
+ export interface EvalGate {
59
+ applies: "config" | "unknown";
60
+ /** How many changed files matched `config_paths`; names stay out of the view (counts and identities only). */
61
+ files: number;
62
+ check?: string;
63
+ state: "not_configured" | "coverage_unknown" | "passed" | "failed" | "pending" | "missing" | "unknown";
64
+ passRate?: number;
65
+ suite?: string;
66
+ /** Evidence gaps that were required of a configured gate: shown in every enabled mode, blockers only in enforce. */
67
+ gaps: string[];
68
+ /** Shown, never a blocker in any mode: the gate has no suite behind it yet (suite choice pending the person). */
69
+ notes?: string[];
70
+ }
71
+ export const EVAL_NOT_CONFIGURED = "eval check not configured: the person names one with eval_check in sdlc.toml (suite choice pending the person)";
72
+ export const EVAL_COVERAGE_UNKNOWN = "eval gate: PR file coverage incomplete or unknown";
73
+ export const evalReportedGap = (check: string) => `eval gate: pass rate or suite version not reported by ${check}`;
74
+ const reported = evalReportedGap;
75
+
76
+ /** Undefined for off mode and for a code-only PR: those outputs stay byte-identical. */
77
+ export function evalGateView(config: Pick<SdlcConfig, "configPaths" | "evalCheck" | "mode">, evidence: PrEvidence | undefined, result?: EvalResult): EvalGate | undefined {
78
+ if (config.mode === "off") return undefined;
79
+ const { applies, matched } = evalApplies(config, evidence);
80
+ if (applies === "code") return undefined;
81
+ const check = config.evalCheck;
82
+ // With no eval check named there is nothing to require: say so, and never block. Enforce users gain no new refusal until the person names a suite.
83
+ if (!check) return applies === "unknown" ? undefined : { applies, files: matched.length, state: "not_configured", gaps: [], notes: [EVAL_NOT_CONFIGURED] };
84
+ if (applies === "unknown") return { applies, files: 0, state: "coverage_unknown", check, gaps: [EVAL_COVERAGE_UNKNOWN] };
85
+ const found = evidence!.checks.filter(c => c.name === check);
86
+ const state = !found.length ? "missing" : found.some(c => c.state === "failed") ? "failed" : found.some(c => c.state === "pending") ? "pending" : found.every(c => c.state === "passed") ? "passed" : "unknown";
87
+ const view: EvalGate = { applies, files: matched.length, check, state, gaps: [] };
88
+ // The record is read only for a passed check at this head; a result read for another head or check is never shown.
89
+ if (result && result.read && result.check === check && result.head === evidence!.head) {
90
+ if (result.passRate !== undefined) view.passRate = result.passRate;
91
+ if (result.suite !== undefined) view.suite = result.suite;
92
+ }
93
+ if (state === "passed" && result && (view.passRate === undefined || view.suite === undefined)) view.gaps.push(reported(check));
94
+ return view;
95
+ }
96
+ /** Evidence gaps that block in enforce. The state of the check itself is an ordinary required-check blocker. */
97
+ export const evalGapBlockers = (view: EvalGate | undefined): string[] => view?.gaps ?? [];
98
+ /** What advise shows beside the gaps: gaps and notes, each once. */
99
+ export const evalAdvice = (view: EvalGate | undefined): string[] => [...(view?.gaps ?? []), ...(view?.notes ?? [])];
100
+
101
+ const MARK_RATE = /(?:^|\s)eval_pass_rate=(0(?:\.[0-9]{1,6})?|1(?:\.0{1,6})?)(?=\s|$)/;
102
+ const MARK_SUITE = /(?:^|\s)eval_suite=([A-Za-z0-9][A-Za-z0-9._-]{0,39})(?=\s|$)/;
103
+ const SHA = /^[a-f0-9]{40}$/;
104
+ /** Pure: the two markers, or nothing. The rest of the text is never kept. */
105
+ export function parseEvalMarkers(text: string): { passRate?: number; suite?: string } {
106
+ const rate = MARK_RATE.exec(text.slice(0, 2000))?.[1], suite = MARK_SUITE.exec(text.slice(0, 2000))?.[1];
107
+ return { ...(rate === undefined ? {} : { passRate: Number(rate) }), ...(suite === undefined ? {} : { suite }) };
108
+ }
109
+
110
+ /** Read-only, fixed argv, bounded output and time. One completed check run of that exact name at that exact head, else `read: false`. */
111
+ export async function readEvalResult(repository: string, head: string, check: string, run: SdlcGh): Promise<EvalResult> {
112
+ const none: EvalResult = { check, head, read: false };
113
+ if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository) || repository.startsWith("-") || !SHA.test(head) || !check || check.length > 120 || /[\x00-\x1f]/.test(check)) return none;
114
+ try {
115
+ const reply = await run(["api", `repos/${repository}/commits/${head}/check-runs?check_name=${encodeURIComponent(check)}&per_page=10`, "--jq",
116
+ '{total_count,check_runs:[.check_runs[]|{name,status,conclusion,head_sha,text:(((.output.title // "")+" "+(.output.summary // ""))|.[0:1000])}]}'], 10_000, undefined, 32 * 1024);
117
+ if (reply.code !== 0) return none;
118
+ const parsed = JSON.parse(reply.stdout), runs = parsed?.check_runs;
119
+ if (!Array.isArray(runs) || runs.length > 10 || parsed.total_count !== runs.length) return none;
120
+ const same = runs.filter((r: { name?: string }) => r?.name === check);
121
+ const only = same[0];
122
+ if (same.length !== 1 || only.head_sha !== head || only.status !== "completed" || typeof only.text !== "string") return none;
123
+ return { check, head, read: true, ...parseEvalMarkers(only.text) };
124
+ } catch { return none; }
125
+ }
126
+
127
+ /** The read happens only where it can matter: an enabled mode, a configured eval check and a configuration PR. */
128
+ export async function readEvalFor(config: Pick<SdlcConfig, "configPaths" | "evalCheck" | "mode">, evidence: PrEvidence | undefined, repository: string | undefined, run: SdlcGh): Promise<EvalResult | undefined> {
129
+ if (!evidence || !repository || !config.evalCheck || evalRequiredChecks(config, evidence).length === 0) return undefined;
130
+ return readEvalResult(repository, evidence.head, config.evalCheck, run);
131
+ }
package/src/hub/events.ts CHANGED
@@ -53,7 +53,7 @@ export type HubEvent =
53
53
  | { type: "hook_stats"; peer: string; n: number; startupMs: number; hubMs: number; maxStartupMs: number }
54
54
  | { type: "cohort"; id: number; event: "formed" | "joined" | "lifted"; silent: boolean; tasks: number[]; owners: string[] }
55
55
  | ({ type: "tokens"; peer: string; n: number } & Partial<TaskAttribution>)
56
- | ({ type: "usage"; peer: string; source: "omniroute" | "claude_transcript" | "claude_result" | "codex_appserver" | "kimi_wire"; id: string; reasoningTokens?: number; measuredAt?: string; requestedModel?: string; servedModel?: string; provider?: string; inputTokens?: number; outputTokens?: number; cacheReadTokens?: number; cacheWriteTokens?: number; totalTokens?: number } & Partial<TaskAttribution>)
56
+ | ({ type: "usage"; peer: string; source: "omniroute" | "claude_transcript" | "claude_result" | "codex_appserver" | "kimi_wire" | "jcode_acp"; id: string; reasoningTokens?: number; measuredAt?: string; requestedModel?: string; servedModel?: string; provider?: string; inputTokens?: number; outputTokens?: number; cacheReadTokens?: number; cacheWriteTokens?: number; totalTokens?: number } & Partial<TaskAttribution>)
57
57
  | { type: "planning"; by: string; task?: number; release?: number; fields: string[]; revision: number }
58
58
  | { type: "task"; id: number; event: string; by: string; state: string; owner: string | null; reviewer: string | null; class: string; profile?: string; profileClass?: string; role?: string; roleIdentity?: { id: string; layer: "shipped" | "project"; hash: string }; pii: boolean; reason?: string; release?: number | null; priority?: number | null; plannedStart?: number | null; due?: number | null }
59
59
  | { type: "overlap"; task: number; owner: string; others: { task: number; owner: string; paths: string[]; symbols?: string[] }[] }