@staix/agent-hub 0.12.44 → 0.13.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 (197) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +83 -116
  3. package/THIRD_PARTY_NOTICES.md +29 -0
  4. package/docs/adding-an-ai-tool.md +102 -14
  5. package/docs/agent-notes/README.md +15 -0
  6. package/docs/agent-notes/adapters.md +63 -46
  7. package/docs/agent-notes/budget.md +7 -2
  8. package/docs/agent-notes/bus.md +6 -4
  9. package/docs/agent-notes/daemon.md +15 -8
  10. package/docs/agent-notes/local-worker.md +6 -4
  11. package/docs/agent-notes/tasks.md +18 -5
  12. package/docs/agent-notes/tests.md +11 -9
  13. package/docs/dogfood.md +92 -0
  14. package/docs/events.md +14 -1
  15. package/docs/operations.md +349 -26
  16. package/docs/profiles.md +2 -2
  17. package/docs/quickstart.md +12 -2
  18. package/docs/sdlc.md +61 -13
  19. package/docs/security.md +24 -12
  20. package/docs/smoke.md +9 -4
  21. package/docs/specs/2026-09-19-agent-hub-design.md +873 -13
  22. package/docs/verification/2026-10-19-reviewer-isolation.md +2 -0
  23. package/docs/verification/2026-10-21-469-r1-dashboard-tokens.md +43 -0
  24. package/docs/verification/2026-10-21-469-r2-dashboard-shell.md +54 -0
  25. package/docs/verification/2026-10-21-469-r3-dashboard-board.md +51 -0
  26. package/docs/verification/2026-10-21-469-r4-dashboard-views.md +77 -0
  27. package/docs/verification/2026-10-21-469-r6-dashboard-motion-states.md +82 -0
  28. package/docs/verification/2026-10-22-0.12.45.md +60 -0
  29. package/docs/verification/2026-10-22-code-facts.md +78 -0
  30. package/docs/verification/2026-10-22-codex-reviewer-isolation.md +47 -0
  31. package/docs/verification/2026-10-22-kimi-qwen-compat.md +79 -0
  32. package/docs/verification/2026-10-23-0.13.0.md +89 -0
  33. package/docs/verification/assets/469-r1-board-1440-dark.png +0 -0
  34. package/docs/verification/assets/469-r1-board-1440-light.png +0 -0
  35. package/docs/verification/assets/469-r1-board-390-dark.png +0 -0
  36. package/docs/verification/assets/469-r1-board-390-light.png +0 -0
  37. package/docs/verification/assets/469-r2-agents-1440-dark.png +0 -0
  38. package/docs/verification/assets/469-r2-agents-768-dark.png +0 -0
  39. package/docs/verification/assets/469-r2-board-1024-light.png +0 -0
  40. package/docs/verification/assets/469-r2-board-1440-dark.png +0 -0
  41. package/docs/verification/assets/469-r2-board-1440-light.png +0 -0
  42. package/docs/verification/assets/469-r2-board-390-dark.png +0 -0
  43. package/docs/verification/assets/469-r2-board-390-light.png +0 -0
  44. package/docs/verification/assets/469-r2-board-768-light.png +0 -0
  45. package/docs/verification/assets/469-r3-board-1440-dark.png +0 -0
  46. package/docs/verification/assets/469-r3-board-1440-light.png +0 -0
  47. package/docs/verification/assets/469-r3-board-390-light.png +0 -0
  48. package/docs/verification/assets/469-r3-detail-1440-dark.png +0 -0
  49. package/docs/verification/assets/469-r3-detail-1440-light.png +0 -0
  50. package/docs/verification/assets/469-r3-detail-390-dark.png +0 -0
  51. package/docs/verification/assets/469-r3-plan-1440-light.png +0 -0
  52. package/docs/verification/assets/469-r3-plan-390-light.png +0 -0
  53. package/docs/verification/assets/469-r4-agents-1440-dark.png +0 -0
  54. package/docs/verification/assets/469-r4-agents-1440-light.png +0 -0
  55. package/docs/verification/assets/469-r4-agents-390-dark.png +0 -0
  56. package/docs/verification/assets/469-r4-memory-1440-dark.png +0 -0
  57. package/docs/verification/assets/469-r4-memory-1440-light.png +0 -0
  58. package/docs/verification/assets/469-r4-memory-390-dark.png +0 -0
  59. package/docs/verification/assets/469-r4-message-1440-light.png +0 -0
  60. package/docs/verification/assets/469-r4-office-1440-dark.png +0 -0
  61. package/docs/verification/assets/469-r4-office-1440-light.png +0 -0
  62. package/docs/verification/assets/469-r4-settings-1440-dark.png +0 -0
  63. package/docs/verification/assets/469-r4-settings-1440-light.png +0 -0
  64. package/docs/verification/assets/469-r4-stream-1440-light.png +0 -0
  65. package/docs/verification/assets/469-r4-stream-390-light.png +0 -0
  66. package/docs/verification/assets/469-r6-agents-empty-390-dark.png +0 -0
  67. package/docs/verification/assets/469-r6-agents-empty-390-light.png +0 -0
  68. package/docs/verification/assets/469-r6-board-1440-light.png +0 -0
  69. package/docs/verification/assets/469-r6-board-390-dark.png +0 -0
  70. package/docs/verification/assets/469-r6-board-empty-1440-dark.png +0 -0
  71. package/docs/verification/assets/469-r6-board-empty-1440-light.png +0 -0
  72. package/docs/verification/assets/469-r6-disconnected-1440-dark.png +0 -0
  73. package/docs/verification/assets/469-r6-disconnected-1440-light.png +0 -0
  74. package/docs/verification/assets/469-r6-disconnected-390-dark.png +0 -0
  75. package/docs/verification/assets/469-r6-skeleton-1440-light.png +0 -0
  76. package/docs/verification/assets/469-r6-skeleton-390-dark.png +0 -0
  77. package/docs/verification/assets/469-r6-toasts-1440-dark.png +0 -0
  78. package/docs/verification/assets/469-r6-toasts-1440-light.png +0 -0
  79. package/docs/verification/assets/469-r6-toasts-390-light.png +0 -0
  80. package/docs/verification/dogfood/2026-10-22.md +3109 -0
  81. package/docs/verified.json +19 -16
  82. package/package.json +5 -1
  83. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  84. package/plugins/agent-hub/server.js +10120 -9623
  85. package/src/adapters/adapter-api.ts +2 -0
  86. package/src/adapters/agy/definition.ts +3 -1
  87. package/src/adapters/agy/entry.ts +5 -4
  88. package/src/adapters/claude-channel.ts +32 -12
  89. package/src/adapters/codex-appserver.ts +19 -2
  90. package/src/adapters/codex-review-isolation.ts +12 -5
  91. package/src/adapters/compat.ts +140 -0
  92. package/src/adapters/conformance/acp.ts +114 -0
  93. package/src/adapters/conformance/codex.ts +191 -0
  94. package/src/adapters/conformance/enrollment.ts +43 -0
  95. package/src/adapters/conformance/factories.ts +17 -0
  96. package/src/adapters/conformance/fake-paths.ts +12 -0
  97. package/src/adapters/conformance/fakes/acp-server.ts +267 -0
  98. package/src/adapters/conformance/fakes/acp-watchdog-agent.ts +10 -0
  99. package/src/adapters/conformance/fakes/app-server.ts +119 -0
  100. package/src/adapters/conformance/fakes/codex-bin.ts +15 -0
  101. package/src/adapters/conformance/fakes/jcode-agent.ts +91 -0
  102. package/src/adapters/conformance/fakes/model-server.ts +39 -0
  103. package/src/adapters/conformance/fakes/pi-rpc.ts +32 -0
  104. package/src/adapters/conformance/index.ts +11 -0
  105. package/src/adapters/conformance/kit.ts +50 -0
  106. package/src/adapters/conformance/local-pi.ts +100 -0
  107. package/src/adapters/conformance/owned-acp.ts +44 -0
  108. package/src/adapters/definitions/builtin-metadata.ts +25 -7
  109. package/src/adapters/definitions/native-cli-doctor.ts +12 -5
  110. package/src/adapters/definitions/native-cli-entry-jcode.ts +9 -6
  111. package/src/adapters/definitions/native-cli-entry-kimi.ts +7 -1
  112. package/src/adapters/definitions/native-cli-entry.ts +7 -19
  113. package/src/adapters/definitions/native-cli-jcode-mcp.ts +9 -2
  114. package/src/adapters/definitions/native-cli-preview-common.ts +2 -0
  115. package/src/adapters/definitions/native-cli-preview-jcode.ts +8 -2
  116. package/src/adapters/definitions/native-cli-preview-qwen.ts +2 -1
  117. package/src/adapters/definitions/native-cli-preview.ts +2 -11
  118. package/src/adapters/definitions/qwen-native.ts +7 -3
  119. package/src/adapters/definitions/qwen.ts +5 -1
  120. package/src/adapters/external-metadata.ts +48 -0
  121. package/src/adapters/external-module-scan.ts +126 -0
  122. package/src/adapters/external-modules.ts +240 -0
  123. package/src/adapters/jcode.ts +72 -31
  124. package/src/adapters/kimi-review.ts +6 -3
  125. package/src/adapters/pi.ts +2 -0
  126. package/src/adapters/public.ts +7 -0
  127. package/src/adapters/registry-types.ts +13 -1
  128. package/src/adapters/registry.ts +52 -15
  129. package/src/cli/agents.ts +40 -0
  130. package/src/cli/console-review.ts +54 -0
  131. package/src/cli/console-state.ts +124 -29
  132. package/src/cli/console-symbols.ts +74 -0
  133. package/src/cli/console-usage.ts +89 -0
  134. package/src/cli/console.ts +26 -1
  135. package/src/cli/help.ts +36 -21
  136. package/src/cli/identity.ts +30 -7
  137. package/src/cli/init.ts +109 -11
  138. package/src/cli/main.ts +145 -16
  139. package/src/cli/output.ts +44 -1
  140. package/src/cli/preview.ts +1 -1
  141. package/src/cli/sdlc-release-guard.ts +82 -0
  142. package/src/cli/upgrade-labels.ts +1 -1
  143. package/src/cli/upgrade-presentation.ts +1 -1
  144. package/src/cli/upgrade-restore-state.ts +3 -2
  145. package/src/cli/upgrade-runtime.ts +4 -1
  146. package/src/hub/action-class.ts +362 -0
  147. package/src/hub/action-policy.ts +106 -0
  148. package/src/hub/board.ts +72 -11
  149. package/src/hub/budget.ts +71 -2
  150. package/src/hub/bus.ts +43 -3
  151. package/src/hub/checks.ts +80 -31
  152. package/src/hub/code-facts.ts +223 -0
  153. package/src/hub/config-apply.ts +28 -11
  154. package/src/hub/config-reload.ts +8 -5
  155. package/src/hub/config-trust.ts +7 -4
  156. package/src/hub/control-client.ts +4 -1
  157. package/src/hub/daemon.ts +332 -61
  158. package/src/hub/dogfood-read.ts +199 -0
  159. package/src/hub/dogfood.ts +163 -0
  160. package/src/hub/envelope.ts +1 -1
  161. package/src/hub/events.ts +11 -2
  162. package/src/hub/facts.ts +76 -13
  163. package/src/hub/hub-tools.ts +45 -7
  164. package/src/hub/inbox-polling.ts +25 -0
  165. package/src/hub/instance-config.ts +17 -5
  166. package/src/hub/instance-execution.ts +20 -8
  167. package/src/hub/instances.ts +75 -2
  168. package/src/hub/peers.ts +9 -0
  169. package/src/hub/plan-conformance.ts +83 -0
  170. package/src/hub/profiles.ts +7 -2
  171. package/src/hub/release-worktree.ts +56 -0
  172. package/src/hub/review-brief.ts +36 -0
  173. package/src/hub/review-findings.ts +104 -0
  174. package/src/hub/roster.ts +31 -5
  175. package/src/hub/sdlc-queue.ts +48 -11
  176. package/src/hub/sdlc-release-authorization.ts +173 -0
  177. package/src/hub/sdlc-release.ts +16 -1
  178. package/src/hub/sdlc.ts +71 -18
  179. package/src/hub/settings.ts +10 -3
  180. package/src/hub/snapshots.ts +101 -14
  181. package/src/hub/symbol-normalizer.ts +26 -0
  182. package/src/hub/task-code-symbols.ts +90 -0
  183. package/src/hub/task-sweep.ts +3 -2
  184. package/src/hub/tasks.ts +556 -88
  185. package/src/hub/test-integrity.ts +162 -0
  186. package/src/hub/ui.ts +2 -2
  187. package/src/hub/usage-rollup.ts +361 -0
  188. package/src/local/sandbox.ts +5 -3
  189. package/src/ui/index.html +560 -78
  190. package/src/ui/office-model.ts +3 -1
  191. package/src/ui/peer-palette.ts +44 -0
  192. package/templates/AGENTS.block.md +1 -1
  193. package/templates/profiles/dev.json +6 -1
  194. package/templates/profiles/general.json +6 -1
  195. package/templates/profiles/research.json +6 -1
  196. package/templates/profiles/writing.json +6 -1
  197. package/templates/release-guard.block.md +6 -0
@@ -0,0 +1,199 @@
1
+ import { closeSync, constants, fstatSync, openSync, readSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { Database } from "bun:sqlite";
4
+ import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
5
+ import { readPrCostHistory, sdlcGh, type SdlcGh } from "./sdlc.ts";
6
+ import { dogfoodScorecard, missing, source, type DogfoodTask, type DogfoodPr, type FeatureEvidence, type Source } from "./dogfood.ts";
7
+ import type { HistoryEntry } from "./board.ts";
8
+ import type { StampedEvent } from "./events.ts";
9
+
10
+ export const DOGFOOD_BYTE_CAP = 16 * 1024 * 1024;
11
+ const MAX_TASKS = 500, MAX_PRS = 16, MAX_CI_HEADS = 8, MAX_CI_READS = 32;
12
+ const date = (value: unknown): number | undefined => typeof value === "string" && Number.isFinite(Date.parse(value)) ? Date.parse(value) : undefined;
13
+ const id = (value: unknown): value is number => Number.isSafeInteger(value) && (value as number) > 0;
14
+ const repoName = (value: unknown): value is string => typeof value === "string" && /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value) && !value.startsWith("-");
15
+
16
+ /** Read at most cap bytes; keep complete lines from the tail, never follow a FIFO or grow with an appender. */
17
+ export function dogfoodLines(file: string, cap = DOGFOOD_BYTE_CAP): Source<string[]> {
18
+ let fd: number | undefined;
19
+ try {
20
+ if (!Number.isSafeInteger(cap) || cap < 1 || cap > DOGFOOD_BYTE_CAP) return missing([]);
21
+ fd = openSync(file, constants.O_RDONLY | constants.O_NONBLOCK);
22
+ const stat = fstatSync(fd);
23
+ if (!stat.isFile()) return missing([]);
24
+ const start = Math.max(0, stat.size - cap), bytes = Buffer.alloc(Math.min(cap, stat.size));
25
+ const n = readSync(fd, bytes, 0, bytes.length, start);
26
+ let text = bytes.subarray(0,n).toString("utf8");
27
+ if (start) { const newline = text.indexOf("\n"); text = newline < 0 ? "" : text.slice(newline + 1); }
28
+ const whole = text.endsWith("\n") || !text;
29
+ if (!whole) text = text.slice(0,Math.max(0,text.lastIndexOf("\n") + 1));
30
+ return source(text.split("\n").filter(Boolean), !start && n === stat.size && whole);
31
+ } catch { return missing([]); }
32
+ finally { if (fd !== undefined) closeSync(fd); }
33
+ }
34
+ export function dogfoodEvents(lines: Source<string[]>, since: number, until: number): Source<StampedEvent[]> {
35
+ const out: StampedEvent[] = []; let complete = lines.complete;
36
+ for (const line of lines.data) {
37
+ try {
38
+ const e = JSON.parse(line);
39
+ if (!e || typeof e !== "object" || e.v !== 1 || typeof e.type !== "string" || date(e.at) === undefined) { complete = false; continue; }
40
+ const number = (n:unknown) => typeof n === "number" && Number.isFinite(n) && n >= 0;
41
+ const peer = typeof e.peer === "string" && /^[a-z][a-z0-9-]{0,63}$/.test(e.peer) && !Object.hasOwn(Object.prototype,e.peer);
42
+ const malformed = ["tokens","usage","turn_end","task","overflow","undeliverable"].includes(e.type) &&
43
+ (e.type === "task" ? !id(e.id) || typeof e.class !== "string" || typeof e.event !== "string" || typeof e.state !== "string" : !peer ||
44
+ e.type === "tokens" && !number(e.n) || e.type === "turn_end" && !number(e.ms) ||
45
+ e.type === "usage" && (typeof e.id !== "string" || typeof e.source !== "string") ||
46
+ ["overflow","undeliverable"].includes(e.type) && typeof e.id !== "string");
47
+ if (malformed) {complete=false;continue;}
48
+ const at = e.type === "usage" && date(e.measuredAt) !== undefined ? date(e.measuredAt)! : date(e.at)!;
49
+ if (at >= since && at <= until) out.push({ ...e, at: new Date(at).toISOString() });
50
+ } catch { complete = false; }
51
+ }
52
+ return { data: out, available: lines.available, complete };
53
+ }
54
+ /** Bounded read-only board snapshot. Select no task title/detail or history note into the result. */
55
+ export function dogfoodTasks(file: string): Source<DogfoodTask[]> {
56
+ let db: Database | undefined;
57
+ try {
58
+ db = new Database(file, { readonly: true });
59
+ db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
60
+ const rows = db.query(`SELECT id, (sdlc IS NOT NULL AND length(sdlc) > 65536) AS receipt_cut, CASE WHEN length(sdlc) <= 65536 THEN sdlc END AS sdlc,
61
+ CASE WHEN length(signals) <= 65536 THEN signals END AS signals,
62
+ CASE WHEN length(history) <= 131072 THEN history END AS history FROM tasks ORDER BY id DESC LIMIT ?`).all(MAX_TASKS + 1) as { id: number; receipt_cut: number; sdlc: string | null; signals: string | null; history: string | null }[];
63
+ let complete = rows.length <= MAX_TASKS, bytes = 0;
64
+ const tasks: DogfoodTask[] = [];
65
+ for (const row of rows.slice(0,MAX_TASKS)) {
66
+ if (row.receipt_cut) complete = false;
67
+ bytes += (row.sdlc?.length ?? 0) + (row.signals?.length ?? 0) + (row.history?.length ?? 0);
68
+ if (bytes > DOGFOOD_BYTE_CAP) { complete = false; break; }
69
+ try {
70
+ const signals = JSON.parse(row.signals ?? "null"), history = JSON.parse(row.history ?? "null"), receipt = JSON.parse(row.sdlc ?? "{}");
71
+ if (!id(row.id) || !Array.isArray(signals) || !Array.isArray(history)) { complete = false; continue; }
72
+ if (history.length > 4096) { complete = false; continue; }
73
+ const clean: HistoryEntry[] = [];
74
+ for (const h of history) {
75
+ if (!h || !Number.isFinite(h.at) || typeof h.event !== "string") { complete = false; continue; }
76
+ clean.push({ at: h.at, by: "hub", event: h.event, ...(h.reviewCost && typeof h.reviewCost === "object" ? { reviewCost: { ...(h.reviewCost.review && ["none","delta","full"].includes(h.reviewCost.review.scope) ? {review:{scope:h.reviewCost.review.scope,reviewers:1}} : {}) } } : {}), ...(Array.isArray(h.reviewFindings?.items) ? { reviewFindings: { items:h.reviewFindings.items.filter((f:any)=>f && typeof f.id === "string" && ["important","nit"].includes(f.severity)).map((f:any)=>({id:f.id,severity:f.severity,pass:"bugs",disposition:"open"})) } } : {}) });
77
+ }
78
+ tasks.push({ id: row.id, pii: signals.includes("pii") || signals.includes("pii_path"), ...(repoName(receipt?.repo) ? { repo: receipt.repo } : {}), ...(id(receipt?.pr) ? { pr: receipt.pr } : {}), history: clean });
79
+ } catch { complete = false; }
80
+ }
81
+ return source(tasks, complete);
82
+ } catch { return missing([]); }
83
+ finally { db?.close(); }
84
+ }
85
+ /** Journal terminal retention is finite: these are retained rows, never whole-window delivery totals. */
86
+ export function dogfoodJournal(file: string, since: number, until: number): Source<{digests:number;held:number;rows:number}> {
87
+ let db: Database | undefined;
88
+ try {
89
+ db = new Database(file, { readonly: true });
90
+ db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
91
+ const rows = db.query(`SELECT state, CASE WHEN length(out_json) <= 262144 THEN
92
+ (SELECT COUNT(*) FROM json_each(out_json) WHERE json_extract(value,'$.from')='digest') END AS digests
93
+ FROM deliveries WHERE created_at >= ? AND created_at <= ? ORDER BY created_at DESC LIMIT 10001`).all(since,until) as {state:string;digests:number|null}[];
94
+ return source({rows:Math.min(rows.length,10000),digests:rows.slice(0,10000).reduce((n,r)=>n+(r.digests??0),0),held:rows.slice(0,10000).filter(r=>r.state === "needs_review").length},rows.length<=10000 && rows.every(r=>r.digests!==null));
95
+ } catch { return missing({digests:0,held:0,rows:0}); }
96
+ finally {db?.close();}
97
+ }
98
+ const connection = (value: any): any[] | undefined => value?.pageInfo?.hasNextPage === false && Array.isArray(value.nodes) && value.nodes.length <= 100 ? value.nodes : undefined;
99
+ /** Reuse S10 cost readback; supplementary reads carry timestamps/ids only, never comment or issue bodies. */
100
+ export async function dogfoodPrs(repo: string, tasks: Source<DogfoodTask[]>, since: number, until: number, run: SdlcGh = sdlcGh): Promise<Source<DogfoodPr[]>> {
101
+ if (!repoName(repo) || !tasks.available || !tasks.complete) return missing([]); // unknown privacy inventory forbids remote per-PR reads
102
+ const deadline = performance.now() + 20000;
103
+ const gh: SdlcGh = (args, timeout, cwd, cap) => performance.now() < deadline
104
+ ? run(args, Math.max(1,Math.min(timeout ?? 5000,deadline-performance.now())),cwd,Math.min(cap ?? 128*1024,128*1024)) : Promise.resolve({code:1,stdout:""});
105
+ const parse = async (args: string[]) => { try { const r = await gh(args); return r.code === 0 ? JSON.parse(r.stdout) : undefined; } catch { return undefined; } };
106
+ const list = await parse(["pr","list","--repo",repo,"--state","merged","--search",`merged:>=${new Date(since).toISOString()}`,"--limit","100","--json","number,mergedAt,createdAt"]);
107
+ if (!Array.isArray(list)) return missing([]);
108
+ const hidden = new Set(tasks.data.filter(t => t.pii && (!t.repo || t.repo.toLowerCase() === repo.toLowerCase())).map(t => t.pr));
109
+ const visible = list.filter(p => id(p?.number) && date(p.mergedAt) !== undefined && date(p.mergedAt)! <= until && !hidden.has(p.number));
110
+ let complete = list.length < 100 && list.every(p => id(p?.number) && date(p.mergedAt) !== undefined) && visible.length <= MAX_PRS;
111
+ // Read recent PRs first: their complete recorded head sets usually fit before
112
+ // the shared deadline. Repository-wide CI volume never consumes this budget.
113
+ visible.sort((a,b)=>date(b.mergedAt)!-date(a.mergedAt)!);
114
+ const projection = "{total_count,workflow_runs:[.workflow_runs[]|{id,run_attempt,event,head_sha,created_at,conclusion,pull_requests:[.pull_requests[]|{number}]}]}";
115
+ const validRun = (r:any) => r?.event === "pull_request" && id(r?.id) && id(r?.run_attempt) && typeof r?.head_sha === "string" && /^[a-f0-9]{40}$/.test(r.head_sha)
116
+ && date(r.created_at) !== undefined && (r.conclusion === null || typeof r.conclusion === "string")
117
+ && Array.isArray(r.pull_requests) && r.pull_requests.length <= 100 && r.pull_requests.every((p:any)=>id(p?.number));
118
+ const sha = (v:unknown):v is string => typeof v === "string" && /^[a-f0-9]{40}$/.test(v);
119
+ let ciReads = 0;
120
+ const rows: DogfoodPr[] = [];
121
+ for (const item of visible.slice(0,MAX_PRS)) {
122
+ let heads: string[] | undefined, runs: any[] = [], runsComplete = false;
123
+ const cost = await readPrCostHistory(repo,item.number,async(args,t,c,cap) => {
124
+ if (args.some(arg => arg.startsWith(`repos/${repo}/actions/workflows/check.yml/runs?`))) {
125
+ if (!heads || heads.length>MAX_CI_HEADS || ciReads+heads.length>MAX_CI_READS) return {code:1,stdout:""};
126
+ const collected:any[] = [], seen = new Set<number>();
127
+ for (const head of heads) {
128
+ ciReads++;
129
+ const endpoint=`repos/${repo}/actions/workflows/check.yml/runs?per_page=100&event=pull_request&head_sha=${head}&created=${new Date(since).toISOString()}..${new Date(until).toISOString()}`;
130
+ const part = await parse(["api",endpoint,"--jq",projection]);
131
+ if (!part || !Number.isSafeInteger(part.total_count) || part.total_count<0 || !Array.isArray(part.workflow_runs)
132
+ || part.total_count!==part.workflow_runs.length || part.workflow_runs.length>100
133
+ || !part.workflow_runs.every((r:any)=>validRun(r) && r.head_sha===head)) return {code:1,stdout:""};
134
+ for (const row of part.workflow_runs) {
135
+ if (seen.has(row.id)) return {code:1,stdout:""};
136
+ seen.add(row.id);collected.push(row);
137
+ }
138
+ if (collected.length>100) return {code:1,stdout:""};
139
+ }
140
+ runs=collected;runsComplete=true;
141
+ return {code:0,stdout:JSON.stringify({total_count:runs.length,workflow_runs:runs.map((r:any)=>({id:r.id,run_attempt:r.run_attempt,head_sha:r.head_sha,pull_requests:r.pull_requests.map((p:any)=>p.number)}))})};
142
+ }
143
+ const reply = await gh(args,t,c,cap);
144
+ try {
145
+ const body = JSON.parse(reply.stdout), pr=body?.data?.repository?.pullRequest;
146
+ const commits=connection(pr?.commits),reviews=connection(pr?.reviews);
147
+ if (reply.code===0 && !body.errors && pr?.number===item.number && sha(pr.headRefOid) && commits && reviews
148
+ && commits.every(n=>sha(n?.commit?.oid)) && reviews.every(n=>id(n?.databaseId) && sha(n?.commit?.oid))) {
149
+ heads=[...new Set<string>([pr.headRefOid,...commits.map(n=>n.commit.oid),...reviews.map(n=>n.commit.oid)])];
150
+ }
151
+ } catch { /* Missing or incomplete recorded heads cannot certify CI. */ }
152
+ return reply;
153
+ });
154
+ const row: DogfoodPr = cost ?? {repo,pr:item.number,state:"MERGED",mergedAt:date(item.mergedAt),complete:false};
155
+ const linked = runsComplete ? runs.filter((r:any)=>date(r.created_at)!<=row.mergedAt!) : [];
156
+ if (runsComplete) {
157
+ const earliest=[...linked].sort((a,b)=>date(a.created_at)!-date(b.created_at)!)[0];
158
+ if (date(item.createdAt)!>=since && date(item.createdAt)!<=until && earliest?.run_attempt===1
159
+ && ["success","failure","cancelled","timed_out"].includes(earliest.conclusion)) row.firstPassCi=earliest.conclusion==="success";
160
+ row.sameHeadReruns=linked.reduce((n,r)=>n+r.run_attempt-1,0);
161
+ }
162
+ const [owner,name] = repo.split("/");
163
+ const detail = await parse(["api","graphql","-f",`query=query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){pullRequest(number:$number){reviewThreads(first:100){pageInfo{hasNextPage} nodes{comments(first:1){nodes{createdAt author{login}}}}} timelineItems(first:100,itemTypes:[CROSS_REFERENCED_EVENT]){pageInfo{hasNextPage} nodes{... on CrossReferencedEvent{source{... on Issue{number createdAt labels(first:100){pageInfo{hasNextPage} nodes{name}}}}}}}}}}`,"-f",`owner=${owner}`,"-f",`name=${name}`,"-F",`number=${item.number}`]);
164
+ const pr = detail?.errors ? undefined : detail?.data?.repository?.pullRequest;
165
+ const threads = connection(pr?.reviewThreads), references = connection(pr?.timelineItems);
166
+ if (threads && threads.every(t => date(t?.comments?.nodes?.[0]?.createdAt) !== undefined && typeof t.comments.nodes[0].author?.login === "string")) row.connectorThreads = threads.flatMap(t => /^(?:chatgpt-codex-connector|chatgpt-codex-connector\[bot\])$/.test(t.comments.nodes[0].author.login) ? [date(t.comments.nodes[0].createdAt)!] : []);
167
+ if (references && references.every(r => id(r?.source?.number) && date(r.source.createdAt) !== undefined && connection(r.source.labels)?.every(label=>typeof label?.name === "string"))) row.bugIssues = [...new Map(references.flatMap(r => connection(r.source.labels)!.some(label => label.name === "bug") ? [[r.source.number,{id:r.source.number,at:date(r.source.createdAt)!}] as const] : [])).values()];
168
+ if (!row.complete || !pr) complete = false;
169
+ rows.push(row);
170
+ }
171
+ return source(rows,complete);
172
+ }
173
+ /** Row ordinals bind to this invocation's docs file; titles/state prose never enter the exported receipt. */
174
+ export function dogfoodFeatures(file: string): Source<FeatureEvidence[]> {
175
+ const text = dogfoodLines(file,128*1024);
176
+ const mappings: [RegExp,string[],string[]?][] = [
177
+ [/Message bus/,["envelope","overflow"]], [/Task board/,["task"]], [/Overlap/,["overlap"]],
178
+ [/WIP caps/,["task"],["ready held"]], [/Task sweep/,["task"],["idle sweep","escalated"]], [/Turn-free/,["cohort"]],
179
+ [/Shared memory/,["memory_brief"]],
180
+ [/Quota budgets/,["quota","budget_pause","budget_resume","quota_reset"]], [/Token usage/,["tokens","usage"]],
181
+ [/Context gate/,["context_pressure"]], [/Permission modes/,["permission"]], [/PII routing/,["pii_screen"]],
182
+ [/Settings reload/,["settings"]], [/Release authorization/,["release_authorization"]],
183
+ ];
184
+ const rows = text.data.filter(line=>line.startsWith("| ") && !line.startsWith("| Feature ") && !line.startsWith("| ---"));
185
+ return {available:text.available,complete:text.complete,data:rows.map((row,index)=>{const rule=mappings.find(([re])=>re.test(row.split("|")[1] ?? ""));return {id:index+1,types:rule?.[1] ?? null,...(rule?.[2] ? {taskEvents:rule[2]} : {})};})};
186
+ }
187
+ export async function readDogfood(root: string, stateDir: string, since: number, until = Date.now(), run: SdlcGh = sdlcGh) {
188
+ const tasks = dogfoodTasks(join(stateDir,"hub.db"));
189
+ let repo = "";
190
+ try { const r = await run(["repo","view","--json","nameWithOwner"],5000,root,8192); if (r.code === 0) repo = JSON.parse(r.stdout).nameWithOwner; } catch { /* unknown */ }
191
+ const events = dogfoodEvents(dogfoodLines(join(stateDir,"events.jsonl")),since,until);
192
+ const log = dogfoodLines(join(stateDir,"hub.log"));
193
+ log.data = log.data.filter(line => {
194
+ const at=date(line.slice(0,24));
195
+ if (at === undefined) { log.complete = false; return false; }
196
+ return at >= since && at <= until;
197
+ });
198
+ return dogfoodScorecard({since,until,tasks,events,log,journal:dogfoodJournal(join(stateDir,"hub.db"),since,until),prs:await dogfoodPrs(repo,tasks,since,until,run),features:dogfoodFeatures(join(root,"docs/dogfood.md"))});
199
+ }
@@ -0,0 +1,163 @@
1
+ import { costMetrics, type CostPr, type CostTask } from "./cost-governance.ts";
2
+ import type { HistoryEntry } from "./board.ts";
3
+ import { parseSince } from "./events.ts";
4
+ import type { StampedEvent } from "./events.ts";
5
+ import { summarize, summarizeByTask, type TaskPeerReport } from "./report.ts";
6
+
7
+
8
+ export function dogfoodSince(args: string[], now = Date.now()): number {
9
+ let since: number | undefined, dogfood = false, json = false;
10
+ const fail = (): never => {throw new Error("usage: ahub report --dogfood [--since 7d|<iso>] [--json]");};
11
+ for(let i=0;i<args.length;i++) {
12
+ const arg=args[i];
13
+ if(arg==="--dogfood" && !dogfood) dogfood=true;
14
+ else if(arg==="--json" && !json) json=true;
15
+ else if(arg==="--since" && since===undefined) {const value=parseSince(args[++i] ?? "",now);if(value===undefined)fail();since=value;}
16
+ else fail();
17
+ }
18
+ if(!dogfood)fail();
19
+ return since ?? now-7*86400000;
20
+ }
21
+
22
+ export interface Source<T> { data: T; complete: boolean; available: boolean }
23
+ export const source = <T>(data: T, complete = true): Source<T> => ({ data, complete, available: true });
24
+ export const missing = <T>(data: T): Source<T> => ({ data, complete: false, available: false });
25
+ export interface DogfoodTask { id: number; repo?: string; pr?: number; pii: boolean; history: HistoryEntry[] }
26
+ export interface DogfoodPr extends CostPr {
27
+ firstPassCi?: boolean;
28
+ sameHeadReruns?: number;
29
+ connectorThreads?: number[];
30
+ bugIssues?: { id: number; at: number }[];
31
+ }
32
+ export interface FeatureEvidence { id: number; types: string[] | null; taskEvents?: string[] }
33
+ export interface DogfoodInput {
34
+ since: number; until: number;
35
+ events: Source<StampedEvent[]>; tasks: Source<DogfoodTask[]>; prs: Source<DogfoodPr[]>;
36
+ journal?: Source<{ digests: number; held: number; rows: number }>;
37
+ log: Source<string[]>; features: Source<FeatureEvidence[]>;
38
+ }
39
+ export interface Metric { value: number | null; samples: number }
40
+ const metric = (value: number, known: boolean, samples = 1): Metric => ({ value: known ? value : null, samples });
41
+ const known = (s: Source<unknown>) => s.available && s.complete;
42
+ const positive = (n: unknown): n is number => Number.isSafeInteger(n) && (n as number) > 0;
43
+ const finite = (n: unknown): n is number => typeof n === "number" && Number.isFinite(n) && n >= 0;
44
+ const identity = (repo: string, pr: number) => `${repo.toLowerCase()}#${pr}`;
45
+ const validPeer = (peer: string) => /^[a-z][a-z0-9-]{0,63}$/.test(peer) && !Object.hasOwn(Object.prototype, peer);
46
+ function peers(input: Record<string, TaskPeerReport>) {
47
+ return Object.fromEntries(Object.entries(input).filter(([id]) => validPeer(id)).map(([id, p]) => [id, {
48
+ tokens: p.tokens, input: p.usage.inputTokens, output: p.usage.outputTokens,
49
+ cacheRead: p.usage.cacheReadTokens, cacheWrite: p.usage.cacheWriteTokens, total: p.usage.totalTokens,
50
+ records: p.usage.records, inputRecords: p.usage.inputRecords, outputRecords: p.usage.outputRecords,
51
+ cacheReadRecords: p.usage.cacheReadRecords, cacheWriteRecords: p.usage.cacheWriteRecords, totalRecords: p.usage.totalRecords,
52
+ }]));
53
+ }
54
+ function percentiles(values: number[]) {
55
+ const sorted = values.filter(finite).sort((a, b) => a - b);
56
+ const at = (q: number) => sorted.length ? sorted[Math.max(0, Math.ceil(q * sorted.length) - 1)]! : null;
57
+ return { samples: sorted.length, p50: at(.5), p95: at(.95), p99: at(.99) };
58
+ }
59
+
60
+ /** Metadata projections only: never return task titles, notes, model/provider names or log fragments. */
61
+ export function dogfoodScorecard(input: DogfoodInput) {
62
+ const events = input.events.data.filter(e => Date.parse(e.at) >= input.since && Date.parse(e.at) <= input.until);
63
+ const inWindow = (at: number) => at >= input.since && at <= input.until;
64
+ const tasks = input.tasks.data;
65
+ const hidden = new Set(tasks.filter(t => t.pii && t.repo && positive(t.pr)).map(t => identity(t.repo!, t.pr!)));
66
+ const prs = input.prs.data.filter(p => p.state === "MERGED" && finite(p.mergedAt) && inWindow(p.mergedAt)
67
+ && /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(p.repo) && positive(p.pr) && !hidden.has(identity(p.repo, p.pr)));
68
+ const costTasks: CostTask[] = tasks.flatMap(t => t.repo && positive(t.pr) ? [{ repo: t.repo, pr: t.pr, private: t.pii, history: t.history }] : []);
69
+ const costs = costMetrics(costTasks, prs);
70
+ const rows = prs.map(pr => {
71
+ const linked = tasks.filter(t => !t.pii && t.repo && positive(t.pr) && identity(t.repo, t.pr!) === identity(pr.repo, pr.pr));
72
+ const histories = linked.flatMap(t => t.history).filter(h => h.at <= pr.mergedAt!).sort((a,b) => a.at - b.at);
73
+ const timelines = linked.map(t => {
74
+ const history=t.history.filter(h=>h.at<=pr.mergedAt!).sort((a,b)=>a.at-b.at);
75
+ return {approval:history.findLast(h=>h.event==="approved")?.at,
76
+ proposed:history.find(h=>h.event==="proposed" || h.event==="backlog pulled")?.at,
77
+ done:history.findLast(h=>h.event==="done")?.at,
78
+ first:history.find(h=>h.event==="approved" || h.event==="changes_requested")?.event};
79
+ });
80
+ // PR readiness requires every linked task; an earlier member's approval is not the PR approval.
81
+ const all = (key:"approval"|"proposed"|"done") => timelines.length && timelines.every(t=>t[key]!==undefined);
82
+ const firstApproval = all("approval") ? Math.max(...timelines.map(t=>t.approval!)) : undefined;
83
+ const proposed = all("proposed") ? Math.min(...timelines.map(t=>t.proposed!)) : undefined;
84
+ const done = all("done") ? Math.max(...timelines.map(t=>t.done!)) : undefined;
85
+ const findings = new Set(histories.flatMap(h => h.reviewFindings?.items.filter(f => f.severity === "important").map(f => f.id) ?? []));
86
+ const cost = costs.rows.find(c => identity(c.repo, c.pr) === identity(pr.repo, pr.pr));
87
+ const ciCost = cost ?? costMetrics([{repo:pr.repo,pr:pr.pr,history:[]}],[pr]).rows[0];
88
+ const boardKnown = known(input.tasks) && linked.length > 0;
89
+ const delta = (end: number | undefined, start: number | undefined) => end !== undefined && start !== undefined && end >= start ? end - start : null;
90
+ return { repo: pr.repo.toLowerCase(), pr: pr.pr, tasks: linked.map(t => t.id).sort((a,b) => a-b), mergedAt: pr.mergedAt!,
91
+ proposeToApproveMs: boardKnown ? delta(firstApproval, proposed) : null,
92
+ doneToMergeMs: boardKnown ? delta(pr.mergedAt, done) : null,
93
+ reviewRounds: boardKnown ? cost?.reviewRounds ?? null : null,
94
+ firstPassApproval: boardKnown && timelines.every(t=>t.first) ? Number(timelines.every(t=>t.first === "approved")) : null,
95
+ escalations: boardKnown ? histories.filter(h => h.event === "escalated").length : null,
96
+ importantFindings: boardKnown ? findings.size : null,
97
+ connectorThreadsAfterApproval: firstApproval !== undefined && pr.connectorThreads !== undefined ? pr.connectorThreads.filter(at => at > firstApproval && at <= input.until).length : null,
98
+ bugIssuesWithin14d: pr.bugIssues?.filter(issue => issue.at >= pr.mergedAt! && issue.at <= Math.min(input.until,pr.mergedAt! + 14 * 86400000)).length ?? null,
99
+ bugObservationDays: Math.min(14, Math.max(0, (input.until - pr.mergedAt!) / 86400000)),
100
+ firstPassCi: pr.firstPassCi === undefined ? null : Number(pr.firstPassCi),
101
+ ciRuns: pr.complete ? ciCost?.ciRuns ?? null : null,
102
+ ciReruns: pr.complete ? ciCost?.ciReruns ?? null : null,
103
+ sameHeadReruns: pr.sameHeadReruns ?? null,
104
+ };
105
+ });
106
+ const accounting = events.filter(e => {
107
+ if (e.type === "task") return positive(e.id) && /^[a-z_]{1,32}$/.test(e.class) && !Object.hasOwn(Object.prototype,e.class);
108
+ if (e.type !== "tokens" && e.type !== "usage" && e.type !== "turn_end") return false;
109
+ if (!validPeer(e.peer) || e.task !== undefined && !positive(e.task)) return false;
110
+ if (e.type === "tokens") return finite(e.n);
111
+ if (e.type === "turn_end") return finite(e.ms);
112
+ return typeof e.id === "string" && typeof e.source === "string";
113
+ });
114
+ const taskReport = summarizeByTask(accounting);
115
+ const costPerTask = Object.entries(taskReport.tasks).filter(([id]) => positive(Number(id))).map(([id,t]) => ({ task: Number(id), pii: Number(!!t.pii || !!tasks.find(x => x.id === Number(id))?.pii), peers: peers(t.peers) }));
116
+ const costPerPr = rows.map(row => {
117
+ const linked = new Set(row.tasks);
118
+ const selected = accounting.filter(e => "task" in e && typeof e.task === "number" && linked.has(e.task));
119
+ const summary = summarizeByTask(selected);
120
+ const merged: Record<string, TaskPeerReport> = {};
121
+ // Reuse the report's class grouping solely as an accumulator, never expose the class text.
122
+ for (const [id,p] of Object.entries(summary.classes).flatMap(([,c]) => Object.entries(c.peers))) {
123
+ const previous = merged[id];
124
+ if (!previous) merged[id] = structuredClone(p);
125
+ else {
126
+ previous.tokens = previous.tokens === null || p.tokens === null ? null : previous.tokens + p.tokens;
127
+ for (const k of Object.keys(previous.usage) as (keyof TaskPeerReport["usage"])[]) {
128
+ const a = previous.usage[k], b = p.usage[k];
129
+ previous.usage[k] = (a === null || b === null ? null : a + b) as never;
130
+ }
131
+ }
132
+ }
133
+ return { repo: row.repo, pr: row.pr, peers: row.tasks.length && known(input.tasks) ? peers(merged) : null };
134
+ });
135
+ const logs = input.log.data;
136
+ const countLog = (pattern: RegExp) => logs.filter(line => pattern.test(line)).length;
137
+ const eventCount = (type: string) => events.filter(e => e.type === type).length;
138
+ const eventMetric = (type: string) => metric(eventCount(type), known(input.events), events.length);
139
+ const logMetric = (pattern: RegExp) => metric(countLog(pattern), known(input.log), logs.length);
140
+ const gates = {
141
+ S1: /spec acceptance|spec.*(?:missing|changed|unknown|accepted|edited)/i, S2: /plan conformance/i,
142
+ S3: /important finding/i, S4: /test integrity:/i, S9: /plan not reviewed|plan review issue|planner plan/i,
143
+ };
144
+ const advice = logs.filter(line => /SDLC .* advice:|premature review:|plan.*advice:|task .*: (?:plan not reviewed since |plan review issue )/.test(line));
145
+ return {
146
+ schemaVersion: 1, since: input.since, until: input.until,
147
+ sources: Object.fromEntries((["events","tasks","prs","log","features"] as const).map(key => [key, { available: input[key].available, complete: input[key].complete, records: input[key].data.length }])),
148
+ flow: { prs: rows, coverage: { mergedPrs: rows.length, linkedPrs: rows.filter(r => r.tasks.length).length } },
149
+ quality: { adviseBlockers: Object.fromEntries(Object.entries(gates).map(([gate,re]) => [gate, metric(advice.filter(line => re.test(line)).length, known(input.log), advice.length)])), realBlockers: null },
150
+ cost: { eventSourceComplete: known(input.events), usageComplete: known(input.events) && accounting.some(e=>e.type === "usage") && summarize(accounting).usage.completeCoverage, attributionComplete: known(input.events) && accounting.filter(e=>e.type === "tokens" || e.type === "usage").every(e=>e.attribution !== undefined && e.attribution !== "unattributed" && e.task !== undefined), perTask: costPerTask, perPr: costPerPr, unattributed: peers(taskReport.unattributed.peers), beforeAttribution: peers(taskReport.beforeAttribution.peers) },
151
+ delivery: { envelopeToDeliveryMs: percentiles([]), digests: {value: input.journal && known(input.journal) ? input.journal.data.digests : null, scope: "retained_journal"}, overflow: eventMetric("overflow"), undeliverable: eventMetric("undeliverable"), held: {value: input.journal && known(input.journal) ? input.journal.data.held : null, scope: "retained_journal"}, emptyInboxPollingStreaks: logMetric(/is polling an empty inbox with no task \(\d+ empty reads in \d+ min\)/) },
152
+ performance: { controlRequestMs: percentiles([]), connectionTimeouts: null, snapshotBudgetExhaustions: null, snapshotBudgetNotices: logMetric(/snapshot unavailable:.*budget/i), cpu: percentiles([]), rss: percentiles([]),
153
+ errors: { database: logMetric(/database contention|SQLITE_BUSY/), storage: logMetric(/delivery storage|journal unavailable/), transport: logMetric(/transport.*(?:failed|error)|connection.*(?:failed|error)/i) } },
154
+ adoption: input.features.data.map(feature => ({ id: feature.id, events: feature.types && known(input.events) ? events.filter(e => feature.types!.includes(e.type) && (!feature.taskEvents || e.type === "task" && feature.taskEvents.includes(e.event))).length : null })),
155
+ journalSource: input.journal ? {available:input.journal.available,complete:input.journal.complete,rows:input.journal.data.rows} : {available:false,complete:false,rows:0},
156
+ piiTasks: known(input.tasks) ? tasks.filter(t => t.pii).length : null,
157
+ };
158
+ }
159
+ export type DogfoodScorecard = ReturnType<typeof dogfoodScorecard>;
160
+ /** A stable readable receipt; null is explicitly unknown in both formats. */
161
+ export function formatDogfood(report: DogfoodScorecard): string[] {
162
+ return JSON.stringify(report, (_key,value) => value === null ? "unknown" : value, 2).split("\n");
163
+ }
@@ -14,7 +14,7 @@ export interface Envelope {
14
14
  kind: Kind;
15
15
  priority: Priority;
16
16
  body: string; // agent conclusions only, never tool noise
17
- refs?: { repo?: string; branch?: string; commit?: string; paths?: string[]; task?: string; reviewRevision?: string; taskOffer?: number; taskCancelled?: number; taskRecipient?: "owner" | "reviewer" | "planner"; backlog?: number[]; supervision?: boolean; supervisionKey?: string; boundedCheck?: string; budgetResume?: string; budgetResumeMoves?: { id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]; budgetResumeEarlier?: string[] };
17
+ refs?: { repo?: string; branch?: string; commit?: string; paths?: string[]; task?: string; reviewRevision?: string; taskOffer?: number; taskCancelled?: number; taskRecipient?: "owner" | "reviewer" | "planner"; backlog?: number[]; supervision?: boolean; supervisionKey?: string; completedFor?: number; boundedCheck?: string; budgetResume?: string; budgetResumeMoves?: { id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]; budgetResumeEarlier?: string[] };
18
18
  /** The body must not be shown outside its recipients: console tail and log print a stub instead (PII tasks). */
19
19
  private?: boolean;
20
20
  ts: number;
package/src/hub/events.ts CHANGED
@@ -13,6 +13,7 @@ export const EVENTS_SCHEMA = 1;
13
13
  * ids, routing and sizes, tasks carry ids and states, so the file can be exported without leaking what agents wrote.
14
14
  */
15
15
  export type HubEvent =
16
+ | { type: "release_authorization"; by: "user"; authorization: string; commit: string; version: string; docsDigest: string; documents: number }
16
17
  | { type: "permission_mode"; peer: string; from: import("./permission-mode.ts").PermissionMode; to: import("./permission-mode.ts").PermissionMode }
17
18
  | { type: "role"; peer?: string; task?: number; actor: string; stage: "prepared" | "assigned"; roles: { id: string; layer: "shipped" | "project"; hash: string }[] }
18
19
  /** A settings change (#269): the registry key, the bounded registry values before/after and their source; an optional person reason is sanitized and capped at 400 UTF-8 bytes. */
@@ -50,7 +51,7 @@ export type HubEvent =
50
51
  | { type: "hook_stats"; peer: string; n: number; startupMs: number; hubMs: number; maxStartupMs: number }
51
52
  | { type: "cohort"; id: number; event: "formed" | "joined" | "lifted"; silent: boolean; tasks: number[]; owners: string[] }
52
53
  | ({ type: "tokens"; peer: string; n: number } & Partial<TaskAttribution>)
53
- | ({ type: "usage"; peer: string; source: "omniroute" | "claude_transcript"; id: string; measuredAt?: string; requestedModel?: string; servedModel?: string; provider?: string; inputTokens?: number; outputTokens?: number; cacheReadTokens?: number; cacheWriteTokens?: number; totalTokens?: number } & Partial<TaskAttribution>)
54
+ | ({ type: "usage"; peer: string; source: "omniroute" | "claude_transcript" | "codex_appserver"; id: string; reasoningTokens?: number; measuredAt?: string; requestedModel?: string; servedModel?: string; provider?: string; inputTokens?: number; outputTokens?: number; cacheReadTokens?: number; cacheWriteTokens?: number; totalTokens?: number } & Partial<TaskAttribution>)
54
55
  | { type: "planning"; by: string; task?: number; release?: number; fields: string[]; revision: number }
55
56
  | { 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 }
56
57
  | { type: "overlap"; task: number; owner: string; others: { task: number; owner: string; paths: string[]; symbols?: string[] }[] }
@@ -58,7 +59,15 @@ export type HubEvent =
58
59
  | ({ type: "pii_screen" } & ScreenRecord)
59
60
  | { type: "conflict"; peer: string; task?: number; other: number; owner: string; paths: string[]; concurrent: boolean; turns?: [string, string] }
60
61
  | { type: "context_pressure"; peer: string; source: string; measuredAt: number; used: number; window: number | null }
61
- | { type: "quota"; peer: string; windows: { id: string; used: number; resetsAt?: number }[]; hard: boolean; measuredAt?: string };
62
+ | { type: "quota"; peer: string; windows: { id: string; used: number; resetsAt?: number }[]; hard: boolean; measuredAt?: string }
63
+ /**
64
+ * The limit ledger (#683 U1): additive, ids and numbers only, no PROTOCOL bump (a reader that does not know a type ignores it).
65
+ * `by` is who ended or began the pause: the coordinator (`hub`) or the console user (`person`, an override).
66
+ */
67
+ | { type: "budget_pause"; peer: string; reason: string; window: string; resetsAt: number; by: "hub" | "person" }
68
+ | { type: "budget_resume"; peer: string; reason: string; window: string; resetsAt: number; by: "hub" | "person" }
69
+ /** A window's reset, derived from two readings of one window id: `resetsAt` moved forward, or `used` fell by 0.5 or more. */
70
+ | { type: "quota_reset"; peer: string; window: string; cause: "resets_at_moved" | "used_dropped"; resetsAt?: number; previousResetsAt?: number; used: number; previousUsed: number; measuredAt?: string };
62
71
 
63
72
  export type StampedEvent = HubEvent & { v: number; at: string };
64
73
 
package/src/hub/facts.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { repoGitArgs, repoGitEnv, type Repo } from "./snapshots.ts";
2
2
  import { isHubWritePath } from "./permission-mode.ts";
3
3
  import { createHash, randomUUID } from "node:crypto";
4
- import { closeSync, constants, fstatSync, mkdirSync, openSync, readSync, rmSync, statSync, writeFileSync } from "node:fs";
4
+ import { closeSync, constants, fstatSync, mkdirSync, openSync, readSync, rmSync, statSync, writeFileSync, type Stats } from "node:fs";
5
5
  import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
6
6
  import { sanitize, type PeerId } from "./envelope.ts";
7
7
  import { isDenied } from "../local/deny.ts";
@@ -24,6 +24,22 @@ import { realPath } from "./project.ts";
24
24
  const MAX_BYTES = 256 * 1024;
25
25
  /** Larger files that are not read are hashed for the HEAD comparison while all of them in one comparison total this much or less; beyond it, none is compared. */
26
26
  const HASHED_BYTES = 64 * 1024 * 1024;
27
+ /**
28
+ * A large file whose last change (ctime) is this recent may still be rewritten with the same stat: Linux before 6.13
29
+ * stamps files from a coarse clock, so a same-size rewrite within one tick keeps size, mtime, ctime and inode (#428).
30
+ * Such a file is named by its bytes, as git treats a racily clean entry: at most RACY_HASH_MAX_BYTES each, hashed once
31
+ * per version in a public entry point, RACY_BURST_BYTES in all per store (Facts instance) per event-loop turn (the hooks
32
+ * run on the daemon's loop). The HEAD comparison keeps its own bound (HASHED_BYTES) and is not limited by these.
33
+ * ponytail: ceilings, each falling back to the stat-only stamp (no claim either way): a racy file over the per-file cap,
34
+ * or past the turn's budget, misses a same-size rewrite within one timestamp tick; a racy file first seen past the
35
+ * budget (kept stat-only) reads as changed once when a later turn hashes it with the same bytes; a file written continuously never settles and is hashed at every look, within that budget; FAT's 2 s mtime
36
+ * is the widest covered; a settled file made racy with the same bytes (chmod, `cp -p`, touch) reads as changed once; a
37
+ * version first seen racy costs an open and a stat at every later look (largeBlob's cache answers). A per-file history
38
+ * of racy versions if any of these matters.
39
+ */
40
+ const RACY_MS = 2000;
41
+ const RACY_HASH_MAX_BYTES = 4 * 1024 * 1024;
42
+ const RACY_BURST_BYTES = 16 * 1024 * 1024;
27
43
  /** Changed lines shown in one fact, all files together; the rest is counted and the files named. */
28
44
  export const MAX_LINES = 60;
29
45
  /** What Claude Code's Read returns by default: the first 2000 lines, each cut at 2000 characters. */
@@ -145,6 +161,10 @@ export class Facts {
145
161
  private readonly seenDropped = new Map<PeerId, Set<string>>();
146
162
  /** Git blob ids of large files, by path, with the stat they were taken at. */
147
163
  private readonly largeBlobs = new Map<string, { stamp: string; blob: string }>();
164
+ /** Blob ids of racy large files hashed in the current boundary, by path and stat (#428). */
165
+ private racyMemo = new Map<string, string>();
166
+ /** Bytes of racy large files left to hash in this event-loop turn. */
167
+ private racyLeft?: { bytes: number };
148
168
  /** Directories each peer acknowledged being told were cut: once per session, not at every boundary. */
149
169
  private readonly cutTold = new Map<PeerId, Set<string>>();
150
170
  /**
@@ -217,7 +237,7 @@ export class Facts {
217
237
  * ponytail: a directory swapped between the containment check and the open is followed once; an fd-relative walk
218
238
  * (openat per component) closes that if it matters.
219
239
  */
220
- private load(file: string): Omit<Version, "seq"> {
240
+ private load(file: string, statOnly = false): Omit<Version, "seq"> {
221
241
  if (this.rel(join(this.root, file)) !== file) return { hash: "missing" };
222
242
  const projected = this.o.projection?.(join(this.root, file));
223
243
  if (projected) return { hash: projected.rawHash, blob: projected.rawBlob, text: projected.text, projected: true };
@@ -226,7 +246,10 @@ export class Facts {
226
246
  fd = openSync(join(this.root, file), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
227
247
  const st = fstatSync(fd);
228
248
  if (!st.isFile()) return { hash: "missing" }; // a directory, a device or a fifo: never read
229
- if (st.size > MAX_BYTES) return { hash: `large:${st.size}:${st.mtimeMs}` };
249
+ if (st.size > MAX_BYTES) {
250
+ const blob = statOnly ? undefined : this.racyBlob(file, fd, st);
251
+ return blob ? { hash: `large:${st.size}:${st.mtimeMs}:${blob}`, blob } : { hash: `large:${st.size}:${st.mtimeMs}` };
252
+ }
230
253
  const buf = Buffer.alloc(st.size);
231
254
  let got = 0;
232
255
  while (got < buf.length) {
@@ -317,13 +340,17 @@ export class Facts {
317
340
  const seen = files.filter((f) => head.has(f)).map((f) => [f, version(f)] as const);
318
341
  const large = seen.reduce((n, [, v]) => n + (v?.hash.startsWith("large:") ? Number(v.hash.split(":")[1]) : 0), 0);
319
342
  return new Set(seen.filter(([f, v]) => {
320
- const blob = v?.blob ?? (v?.hash.startsWith("large:") && large <= HASHED_BYTES ? this.largeBlob(f, v.hash) : undefined);
343
+ const blob = !v?.hash.startsWith("large:") ? v?.blob : large <= HASHED_BYTES ? v.blob ?? this.largeBlob(f, v.hash, true) : undefined;
321
344
  return blob !== undefined && blob === head.get(f);
322
345
  }).map(([f]) => f));
323
346
  }
324
347
 
325
348
  /** Git's blob id of a file too large to load, streamed; undefined for anything that is not a small enough regular file. */
326
- private largeBlob(file: string, observed: string): string | undefined {
349
+ /**
350
+ * `head`: for the HEAD comparison, which bounds its own hashing (HASHED_BYTES): a racy file is streamed whatever the
351
+ * racy cap and the turn's budget, uncached. Otherwise a racy file goes through racyBlob's limits.
352
+ */
353
+ private largeBlob(file: string, observed: string, head = false): string | undefined {
327
354
  if (this.rel(join(this.root, file)) !== file) return undefined;
328
355
  let fd: number | undefined;
329
356
  try {
@@ -333,13 +360,10 @@ export class Facts {
333
360
  // A file that stays stat-dirty but equal to HEAD is looked at again at every boundary: hashed once per version.
334
361
  const stamp = `${st.ino}:${st.size}:${st.mtimeMs}:${st.ctimeMs}`, known = this.largeBlobs.get(file);
335
362
  if (known?.stamp === stamp) return known.blob;
336
- const h = createHash("sha1").update(`blob ${st.size}\0`);
337
- const buf = Buffer.alloc(1024 * 1024);
338
- let got = 0;
339
- for (let n = 0; got < st.size && (n = readSync(fd, buf, 0, buf.length, got)) > 0; got += n) h.update(buf.subarray(0, n));
340
- if (got !== st.size) return undefined; // changed while read: no claim
341
- const blob = h.digest("hex");
342
- this.largeBlobs.set(file, { stamp, blob });
363
+ // A racy stamp may name other bytes later: never cached.
364
+ if (Date.now() - st.ctimeMs < RACY_MS) return head ? this.racyMemo.get(racyKey(file, st)) ?? streamBlob(fd, st.size) : this.racyBlob(file, fd, st);
365
+ const blob = streamBlob(fd, st.size);
366
+ if (blob) this.largeBlobs.set(file, { stamp, blob });
343
367
  return blob;
344
368
  } catch {
345
369
  return undefined;
@@ -348,6 +372,22 @@ export class Facts {
348
372
  }
349
373
  }
350
374
 
375
+ /** A racy large file's blob id (#428), or undefined when it is settled, over the per-file cap or past the turn's budget. */
376
+ private racyBlob(file: string, fd: number, st: Stats): string | undefined {
377
+ if (Date.now() - st.ctimeMs >= RACY_MS || st.size > RACY_HASH_MAX_BYTES) return undefined;
378
+ const key = racyKey(file, st), known = this.racyMemo.get(key);
379
+ if (known) return known;
380
+ if (!this.racyLeft) {
381
+ const turn = this.racyLeft = { bytes: RACY_BURST_BYTES };
382
+ setImmediate(() => { if (this.racyLeft === turn) this.racyLeft = undefined; });
383
+ }
384
+ if (st.size > this.racyLeft.bytes) return undefined;
385
+ this.racyLeft.bytes -= st.size;
386
+ const blob = streamBlob(fd, st.size);
387
+ if (blob) this.racyMemo.set(key, blob);
388
+ return blob;
389
+ }
390
+
351
391
  /** `file` is covered again: an offer still unread that named it as dropped must not spend the notice of a later drop. */
352
392
  private coveredAgain(peer: PeerId, notice: "untracked" | "unfollowed", file: string): void {
353
393
  for (const o of this.offers.get(peer) ?? []) if (o[notice]?.includes(file)) o[notice] = o[notice]!.filter((f) => f !== file);
@@ -357,6 +397,7 @@ export class Facts {
357
397
  private boundary(): void {
358
398
  this.expanded = new Map();
359
399
  this.expandedCut = new Set();
400
+ this.racyMemo = new Map(); // not per turn: a rewrite between two entry points of one turn is another version
360
401
  }
361
402
 
362
403
  /** `peer` worked on `file`; `seen` is the file as it was when it did, the baseline if it has no view of it yet. */
@@ -463,6 +504,12 @@ export class Facts {
463
504
  const now = this.load(file);
464
505
  const was = this.latest.get(file);
465
506
  if (was && was.hash === now.hash) return was;
507
+ // Seen while racy (named by its bytes), now with the same stat but no blob (settled, or past the turn's budget): the
508
+ // same version if the bytes are, or when they cannot be hashed now (no claim).
509
+ if (was?.blob && was.hash === `${now.hash}:${was.blob}`) {
510
+ const blob = this.largeBlob(file, now.hash);
511
+ if (blob === undefined || blob === was.blob) return was;
512
+ }
466
513
  const version = { ...now, seq: ++this.seq };
467
514
  this.latest.set(file, version);
468
515
  if (was) {
@@ -720,7 +767,9 @@ export class Facts {
720
767
  // does, nor what they wrote before or after (a member settling keeps its files in the set).
721
768
  const written = windows.flatMap((w) => [...(this.writes.get(w.peer) ?? [])].filter(([, times]) => times.some((t) => t >= w.since && (w.until === undefined || t <= w.until))).map(([file]) => file));
722
769
  const files = [...new Set([...this.expand(paths.flatMap((p) => { const r = this.rel(p); return r ? [r] : []; }), true), ...written])].sort();
723
- for (const file of files) h.update(file).update("\0").update(this.load(file).hash).update("\0");
770
+ // ponytail: a large file counts by its stat, racy or not, so the target does not move when it settles; a same-size
771
+ // rewrite within one timestamp tick does not move it either (observe() sees that one).
772
+ for (const file of files) h.update(file).update("\0").update(this.load(file, true).hash).update("\0");
724
773
  return h.digest("hex");
725
774
  }
726
775
 
@@ -871,3 +920,17 @@ export function codexEffect(before: string | undefined, now: { hash: string; tex
871
920
  const observed = changedLines(diff(before, now.text));
872
921
  return reported.length > 0 && reported.length === observed.length && reported.every((l, i) => l === observed[i]);
873
922
  }
923
+
924
+ /** Git's blob id of the `size` bytes `fd` reads, streamed; undefined when fewer were read (changed while read). */
925
+ function streamBlob(fd: number, size: number): string | undefined {
926
+ const h = createHash("sha1").update(`blob ${size}\0`);
927
+ const buf = Buffer.alloc(1024 * 1024);
928
+ let got = 0;
929
+ for (let n = 0; got < size && (n = readSync(fd, buf, 0, buf.length, got)) > 0; got += n) h.update(buf.subarray(0, n));
930
+ return got === size ? h.digest("hex") : undefined;
931
+ }
932
+
933
+ /** A racy large file's version within one boundary: its path and every stat field. */
934
+ function racyKey(file: string, st: Stats): string {
935
+ return `${file}\0${st.ino}:${st.size}:${st.mtimeMs}:${st.ctimeMs}`;
936
+ }