@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.
- package/CHANGELOG.md +40 -0
- package/README.md +83 -116
- package/THIRD_PARTY_NOTICES.md +29 -0
- package/docs/adding-an-ai-tool.md +102 -14
- package/docs/agent-notes/README.md +15 -0
- package/docs/agent-notes/adapters.md +63 -46
- package/docs/agent-notes/budget.md +7 -2
- package/docs/agent-notes/bus.md +6 -4
- package/docs/agent-notes/daemon.md +15 -8
- package/docs/agent-notes/local-worker.md +6 -4
- package/docs/agent-notes/tasks.md +18 -5
- package/docs/agent-notes/tests.md +11 -9
- package/docs/dogfood.md +92 -0
- package/docs/events.md +14 -1
- package/docs/operations.md +349 -26
- package/docs/profiles.md +2 -2
- package/docs/quickstart.md +12 -2
- package/docs/sdlc.md +61 -13
- package/docs/security.md +24 -12
- package/docs/smoke.md +9 -4
- package/docs/specs/2026-09-19-agent-hub-design.md +873 -13
- package/docs/verification/2026-10-19-reviewer-isolation.md +2 -0
- package/docs/verification/2026-10-21-469-r1-dashboard-tokens.md +43 -0
- package/docs/verification/2026-10-21-469-r2-dashboard-shell.md +54 -0
- package/docs/verification/2026-10-21-469-r3-dashboard-board.md +51 -0
- package/docs/verification/2026-10-21-469-r4-dashboard-views.md +77 -0
- package/docs/verification/2026-10-21-469-r6-dashboard-motion-states.md +82 -0
- package/docs/verification/2026-10-22-0.12.45.md +60 -0
- package/docs/verification/2026-10-22-code-facts.md +78 -0
- package/docs/verification/2026-10-22-codex-reviewer-isolation.md +47 -0
- package/docs/verification/2026-10-22-kimi-qwen-compat.md +79 -0
- package/docs/verification/2026-10-23-0.13.0.md +89 -0
- package/docs/verification/assets/469-r1-board-1440-dark.png +0 -0
- package/docs/verification/assets/469-r1-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r1-board-390-dark.png +0 -0
- package/docs/verification/assets/469-r1-board-390-light.png +0 -0
- package/docs/verification/assets/469-r2-agents-1440-dark.png +0 -0
- package/docs/verification/assets/469-r2-agents-768-dark.png +0 -0
- package/docs/verification/assets/469-r2-board-1024-light.png +0 -0
- package/docs/verification/assets/469-r2-board-1440-dark.png +0 -0
- package/docs/verification/assets/469-r2-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r2-board-390-dark.png +0 -0
- package/docs/verification/assets/469-r2-board-390-light.png +0 -0
- package/docs/verification/assets/469-r2-board-768-light.png +0 -0
- package/docs/verification/assets/469-r3-board-1440-dark.png +0 -0
- package/docs/verification/assets/469-r3-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r3-board-390-light.png +0 -0
- package/docs/verification/assets/469-r3-detail-1440-dark.png +0 -0
- package/docs/verification/assets/469-r3-detail-1440-light.png +0 -0
- package/docs/verification/assets/469-r3-detail-390-dark.png +0 -0
- package/docs/verification/assets/469-r3-plan-1440-light.png +0 -0
- package/docs/verification/assets/469-r3-plan-390-light.png +0 -0
- package/docs/verification/assets/469-r4-agents-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-agents-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-agents-390-dark.png +0 -0
- package/docs/verification/assets/469-r4-memory-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-memory-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-memory-390-dark.png +0 -0
- package/docs/verification/assets/469-r4-message-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-office-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-office-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-settings-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-settings-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-stream-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-stream-390-light.png +0 -0
- package/docs/verification/assets/469-r6-agents-empty-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-agents-empty-390-light.png +0 -0
- package/docs/verification/assets/469-r6-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-board-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-board-empty-1440-dark.png +0 -0
- package/docs/verification/assets/469-r6-board-empty-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-disconnected-1440-dark.png +0 -0
- package/docs/verification/assets/469-r6-disconnected-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-disconnected-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-skeleton-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-skeleton-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-toasts-1440-dark.png +0 -0
- package/docs/verification/assets/469-r6-toasts-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-toasts-390-light.png +0 -0
- package/docs/verification/dogfood/2026-10-22.md +3109 -0
- package/docs/verified.json +19 -16
- package/package.json +5 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +10120 -9623
- package/src/adapters/adapter-api.ts +2 -0
- package/src/adapters/agy/definition.ts +3 -1
- package/src/adapters/agy/entry.ts +5 -4
- package/src/adapters/claude-channel.ts +32 -12
- package/src/adapters/codex-appserver.ts +19 -2
- package/src/adapters/codex-review-isolation.ts +12 -5
- package/src/adapters/compat.ts +140 -0
- package/src/adapters/conformance/acp.ts +114 -0
- package/src/adapters/conformance/codex.ts +191 -0
- package/src/adapters/conformance/enrollment.ts +43 -0
- package/src/adapters/conformance/factories.ts +17 -0
- package/src/adapters/conformance/fake-paths.ts +12 -0
- package/src/adapters/conformance/fakes/acp-server.ts +267 -0
- package/src/adapters/conformance/fakes/acp-watchdog-agent.ts +10 -0
- package/src/adapters/conformance/fakes/app-server.ts +119 -0
- package/src/adapters/conformance/fakes/codex-bin.ts +15 -0
- package/src/adapters/conformance/fakes/jcode-agent.ts +91 -0
- package/src/adapters/conformance/fakes/model-server.ts +39 -0
- package/src/adapters/conformance/fakes/pi-rpc.ts +32 -0
- package/src/adapters/conformance/index.ts +11 -0
- package/src/adapters/conformance/kit.ts +50 -0
- package/src/adapters/conformance/local-pi.ts +100 -0
- package/src/adapters/conformance/owned-acp.ts +44 -0
- package/src/adapters/definitions/builtin-metadata.ts +25 -7
- package/src/adapters/definitions/native-cli-doctor.ts +12 -5
- package/src/adapters/definitions/native-cli-entry-jcode.ts +9 -6
- package/src/adapters/definitions/native-cli-entry-kimi.ts +7 -1
- package/src/adapters/definitions/native-cli-entry.ts +7 -19
- package/src/adapters/definitions/native-cli-jcode-mcp.ts +9 -2
- package/src/adapters/definitions/native-cli-preview-common.ts +2 -0
- package/src/adapters/definitions/native-cli-preview-jcode.ts +8 -2
- package/src/adapters/definitions/native-cli-preview-qwen.ts +2 -1
- package/src/adapters/definitions/native-cli-preview.ts +2 -11
- package/src/adapters/definitions/qwen-native.ts +7 -3
- package/src/adapters/definitions/qwen.ts +5 -1
- package/src/adapters/external-metadata.ts +48 -0
- package/src/adapters/external-module-scan.ts +126 -0
- package/src/adapters/external-modules.ts +240 -0
- package/src/adapters/jcode.ts +72 -31
- package/src/adapters/kimi-review.ts +6 -3
- package/src/adapters/pi.ts +2 -0
- package/src/adapters/public.ts +7 -0
- package/src/adapters/registry-types.ts +13 -1
- package/src/adapters/registry.ts +52 -15
- package/src/cli/agents.ts +40 -0
- package/src/cli/console-review.ts +54 -0
- package/src/cli/console-state.ts +124 -29
- package/src/cli/console-symbols.ts +74 -0
- package/src/cli/console-usage.ts +89 -0
- package/src/cli/console.ts +26 -1
- package/src/cli/help.ts +36 -21
- package/src/cli/identity.ts +30 -7
- package/src/cli/init.ts +109 -11
- package/src/cli/main.ts +145 -16
- package/src/cli/output.ts +44 -1
- package/src/cli/preview.ts +1 -1
- package/src/cli/sdlc-release-guard.ts +82 -0
- package/src/cli/upgrade-labels.ts +1 -1
- package/src/cli/upgrade-presentation.ts +1 -1
- package/src/cli/upgrade-restore-state.ts +3 -2
- package/src/cli/upgrade-runtime.ts +4 -1
- package/src/hub/action-class.ts +362 -0
- package/src/hub/action-policy.ts +106 -0
- package/src/hub/board.ts +72 -11
- package/src/hub/budget.ts +71 -2
- package/src/hub/bus.ts +43 -3
- package/src/hub/checks.ts +80 -31
- package/src/hub/code-facts.ts +223 -0
- package/src/hub/config-apply.ts +28 -11
- package/src/hub/config-reload.ts +8 -5
- package/src/hub/config-trust.ts +7 -4
- package/src/hub/control-client.ts +4 -1
- package/src/hub/daemon.ts +332 -61
- package/src/hub/dogfood-read.ts +199 -0
- package/src/hub/dogfood.ts +163 -0
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +11 -2
- package/src/hub/facts.ts +76 -13
- package/src/hub/hub-tools.ts +45 -7
- package/src/hub/inbox-polling.ts +25 -0
- package/src/hub/instance-config.ts +17 -5
- package/src/hub/instance-execution.ts +20 -8
- package/src/hub/instances.ts +75 -2
- package/src/hub/peers.ts +9 -0
- package/src/hub/plan-conformance.ts +83 -0
- package/src/hub/profiles.ts +7 -2
- package/src/hub/release-worktree.ts +56 -0
- package/src/hub/review-brief.ts +36 -0
- package/src/hub/review-findings.ts +104 -0
- package/src/hub/roster.ts +31 -5
- package/src/hub/sdlc-queue.ts +48 -11
- package/src/hub/sdlc-release-authorization.ts +173 -0
- package/src/hub/sdlc-release.ts +16 -1
- package/src/hub/sdlc.ts +71 -18
- package/src/hub/settings.ts +10 -3
- package/src/hub/snapshots.ts +101 -14
- package/src/hub/symbol-normalizer.ts +26 -0
- package/src/hub/task-code-symbols.ts +90 -0
- package/src/hub/task-sweep.ts +3 -2
- package/src/hub/tasks.ts +556 -88
- package/src/hub/test-integrity.ts +162 -0
- package/src/hub/ui.ts +2 -2
- package/src/hub/usage-rollup.ts +361 -0
- package/src/local/sandbox.ts +5 -3
- package/src/ui/index.html +560 -78
- package/src/ui/office-model.ts +3 -1
- package/src/ui/peer-palette.ts +44 -0
- package/templates/AGENTS.block.md +1 -1
- package/templates/profiles/dev.json +6 -1
- package/templates/profiles/general.json +6 -1
- package/templates/profiles/research.json +6 -1
- package/templates/profiles/writing.json +6 -1
- 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
|
+
}
|
package/src/hub/envelope.ts
CHANGED
|
@@ -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)
|
|
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?.
|
|
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
|
-
|
|
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
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
-
|
|
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
|
+
}
|