@staix/agent-hub 0.12.45 → 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 +18 -0
- package/README.md +83 -116
- package/docs/agent-notes/README.md +6 -0
- package/docs/agent-notes/adapters.md +10 -7
- package/docs/agent-notes/budget.md +6 -1
- package/docs/agent-notes/bus.md +6 -4
- package/docs/agent-notes/daemon.md +6 -1
- package/docs/agent-notes/local-worker.md +2 -1
- package/docs/agent-notes/tasks.md +10 -0
- package/docs/agent-notes/tests.md +4 -2
- package/docs/dogfood.md +92 -0
- package/docs/events.md +14 -1
- package/docs/operations.md +250 -19
- package/docs/profiles.md +2 -2
- package/docs/quickstart.md +8 -2
- package/docs/sdlc.md +36 -6
- package/docs/security.md +20 -11
- package/docs/smoke.md +9 -4
- package/docs/specs/2026-09-19-agent-hub-design.md +759 -8
- package/docs/verification/2026-10-19-reviewer-isolation.md +2 -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/dogfood/2026-10-22.md +3109 -0
- package/docs/verified.json +14 -14
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +613 -329
- package/src/adapters/agy/entry.ts +5 -4
- package/src/adapters/claude-channel.ts +20 -9
- package/src/adapters/codex-appserver.ts +18 -2
- package/src/adapters/codex-review-isolation.ts +12 -5
- package/src/adapters/compat.ts +140 -0
- package/src/adapters/definitions/builtin-metadata.ts +2 -1
- 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-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/qwen-native.ts +7 -3
- package/src/adapters/definitions/qwen.ts +2 -1
- package/src/adapters/jcode.ts +72 -31
- package/src/adapters/kimi-review.ts +6 -3
- package/src/adapters/registry-types.ts +3 -1
- package/src/adapters/registry.ts +1 -0
- 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 +16 -6
- package/src/cli/identity.ts +29 -6
- package/src/cli/init.ts +109 -11
- package/src/cli/main.ts +122 -11
- 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/hub/action-class.ts +362 -0
- package/src/hub/action-policy.ts +106 -0
- package/src/hub/board.ts +43 -8
- 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 +10 -1
- package/src/hub/config-trust.ts +7 -4
- package/src/hub/daemon.ts +237 -22
- 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/hub-tools.ts +24 -2
- package/src/hub/inbox-polling.ts +25 -0
- package/src/hub/instances.ts +73 -1
- 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/sdlc-queue.ts +31 -9
- package/src/hub/sdlc-release-authorization.ts +173 -0
- package/src/hub/sdlc.ts +49 -11
- package/src/hub/settings.ts +5 -2
- package/src/hub/snapshots.ts +93 -8
- 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 +292 -54
- package/src/hub/test-integrity.ts +162 -0
- package/src/hub/usage-rollup.ts +361 -0
- package/src/local/sandbox.ts +5 -3
- package/src/ui/index.html +1 -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/hub-tools.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { redFirstSchema } from "./test-integrity.ts";
|
|
1
2
|
import { SDLC_STAGES } from "./sdlc.ts";
|
|
2
3
|
import { projectProfile, profileData, PROFILE_CLASS_NAMES, type ProfileName, type ProfileSelection } from "./profiles.ts";
|
|
3
4
|
import { catalogSection, type CatalogRole } from "./roles.ts";
|
|
@@ -191,12 +192,21 @@ export function profileTaskTools(profile: ProfileSelection = "dev", stages?: rea
|
|
|
191
192
|
if (criteria) {
|
|
192
193
|
const { phase, ...current } = fields as { phase: { properties: Record<string, unknown> } } & Record<string, unknown>;
|
|
193
194
|
const { tasks: _retiredImport, ...checklist } = phase.properties;
|
|
194
|
-
fields = { ...current, criteria: { ...phase, properties: checklist, required: ["id", "criteria"] } };
|
|
195
|
+
fields = { ...current, redFirst: redFirstSchema, criteria: { ...phase, properties: checklist, required: ["id", "criteria"] } };
|
|
195
196
|
}
|
|
196
197
|
properties.receipt = { ...receipt, properties: fields };
|
|
197
198
|
}
|
|
198
199
|
if (customProfile && tool.name === "hub_task_propose") properties.class = { type: "string", enum: [...new Set([...Object.keys(data.classes), ...PROFILE_CLASS_NAMES.filter(v => projectProfile("dev").classes[v])])] };
|
|
199
200
|
if (customProfile && properties.plan && tool.name !== "hub_task_accept") properties.plan = { ...plan, properties: { ...Object.fromEntries(data.plan_keys.map(key => [key, plan.properties[key as keyof typeof plan.properties] ?? list(key === "sections" ? "document heading anchors, such as report.md#methods" : key === "sources" ? "citation ids, DOI or source URL ids" : "short claim ids to verify")])) } };
|
|
201
|
+
if (customProfile && tool.name === "hub_task_done" && data.name !== "dev") {
|
|
202
|
+
const summaryProp = properties.summary as { type: string; description: string };
|
|
203
|
+
properties.summary = {
|
|
204
|
+
...summaryProp,
|
|
205
|
+
description: data.name === "research" || data.name === "writing"
|
|
206
|
+
? "changed deliverables, provenance, checks and unknowns"
|
|
207
|
+
: "what changed, why, and the check or verification you ran with its result",
|
|
208
|
+
};
|
|
209
|
+
}
|
|
200
210
|
if (customProfile && tool.name === "hub_backlog_split") {
|
|
201
211
|
const parts = properties.parts as { items: { properties: Record<string, unknown> } };
|
|
202
212
|
const selectedPlan = { ...plan, properties: { ...Object.fromEntries(data.plan_keys.map(key => [key, plan.properties[key as keyof typeof plan.properties] ?? list(key === "sections" ? "document heading anchors, such as report.md#methods" : key === "sources" ? "citation ids, DOI or source URL ids" : "short claim ids to verify")])) } };
|
|
@@ -210,6 +220,18 @@ export function profileTaskTools(profile: ProfileSelection = "dev", stages?: rea
|
|
|
210
220
|
const parts = properties.parts as { items: { properties: { plan: { properties: Record<string, unknown> } } } };
|
|
211
221
|
properties.parts = { ...parts, items: { ...parts.items, properties: { ...parts.items.properties, plan: { ...parts.items.properties.plan, properties: { ...parts.items.properties.plan.properties, ...costPlanProperties, ...evidencePlanProperties } } } } };
|
|
212
222
|
}
|
|
213
|
-
|
|
223
|
+
let description = tool.description;
|
|
224
|
+
if (customProfile && tool.name === "hub_task_propose") {
|
|
225
|
+
description = tool.description + ` Selected profile ${data.name}: ${Object.keys(data.classes).join(", ")}. Routing uses their existing base classes.`;
|
|
226
|
+
} else if (customProfile && tool.name === "hub_review" && data.name !== "dev") {
|
|
227
|
+
const reviewAction = data.name === "research" || data.name === "writing"
|
|
228
|
+
? "assess scope, provenance and unsupported claims against the task's plan or detail"
|
|
229
|
+
: "assess the deliverables against the task's plan or detail";
|
|
230
|
+
description = `Give your verdict on a task you were asked to review: ${reviewAction}, read the check result (hub_task_show has it, with the done summary), and list what is unmet. The verdict notifies the task owner; no duplicate hub_send chat is needed. Consecutive changes_requested verdicts (three by default, \`tasks.escalate_after\`) move the task to another peer.`;
|
|
231
|
+
} else if (customProfile && tool.name === "hub_task_done" && data.name !== "dev") {
|
|
232
|
+
const summaryLabel = data.name === "research" || data.name === "writing" ? "deliverables, provenance and checks" : "deliverables and checks";
|
|
233
|
+
description = `Mark your task finished. It goes to its reviewer with your summary (${summaryLabel}) and refs; no duplicate hub_send chat is needed. When the project configures a check for its class, the hub runs it first and the result comes as a task message: a failed check keeps the task with you.`;
|
|
234
|
+
}
|
|
235
|
+
return { ...tool, description, inputSchema: { ...tool.inputSchema, properties } };
|
|
214
236
|
});
|
|
215
237
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Advisory only: reading an inbox is never refused. */
|
|
2
|
+
export class InboxPolling {
|
|
3
|
+
// ponytail: counters reset on restart; persist only if restarts hide real polling.
|
|
4
|
+
private readonly streaks = new Map<string, { reads: number[]; noticed: boolean }>();
|
|
5
|
+
|
|
6
|
+
reset(peer: string): void { this.streaks.delete(peer); }
|
|
7
|
+
|
|
8
|
+
read(peer: string, empty: boolean, assigned: boolean, now: number): { count: number; minutes: number; notice: boolean } | undefined {
|
|
9
|
+
if (!empty || assigned) { this.reset(peer); return; }
|
|
10
|
+
let streak = this.streaks.get(peer);
|
|
11
|
+
const last = streak?.reads.at(-1);
|
|
12
|
+
if (!streak || last === undefined || now < last || now - last > 10 * 60_000) {
|
|
13
|
+
streak = { reads: [], noticed: false };
|
|
14
|
+
this.streaks.set(peer, streak);
|
|
15
|
+
}
|
|
16
|
+
streak.reads = streak.reads.filter(at => now - at <= 10 * 60_000);
|
|
17
|
+
// ponytail: advice counts at most 64 recent reads; use time buckets if full high-rate counts become useful.
|
|
18
|
+
if (streak.reads.length === 64) streak.reads.shift();
|
|
19
|
+
streak.reads.push(now);
|
|
20
|
+
if (streak.reads.length < 3) return;
|
|
21
|
+
const notice = !streak.noticed;
|
|
22
|
+
streak.noticed = true;
|
|
23
|
+
return { count: streak.reads.length, minutes: Math.floor((now - streak.reads[0]!) / 60_000), notice };
|
|
24
|
+
}
|
|
25
|
+
}
|
package/src/hub/instances.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { types } from "node:util";
|
|
2
|
+
import { statSync } from "node:fs";
|
|
3
|
+
import { dirname, isAbsolute, resolve, sep } from "node:path";
|
|
4
|
+
import { canonicalPath, realPath } from "./project.ts";
|
|
2
5
|
import { defaultPeerRoles, definitionOf, type AdapterDefinition, type StartForm, type ProjectAdapterCatalog } from "../adapters/registry.ts";
|
|
3
6
|
import { isPermissionMode, type PermissionMode } from "./permission-mode.ts";
|
|
4
7
|
|
|
@@ -10,7 +13,17 @@ export interface InstanceConfig {
|
|
|
10
13
|
review_mode?: "fresh";
|
|
11
14
|
worktree?: string;
|
|
12
15
|
readOnly?: boolean;
|
|
16
|
+
/** Jcode instances only (#685): their own native data home, and optional model selection. Machine-local, never editable from settings. */
|
|
17
|
+
jcode_home?: string;
|
|
18
|
+
provider?: string;
|
|
19
|
+
model?: string;
|
|
20
|
+
provider_profile?: string;
|
|
13
21
|
}
|
|
22
|
+
const JCODE_FIELDS = ["jcode_home", "provider", "model", "provider_profile"] as const;
|
|
23
|
+
/** At most this many extra Jcode instances per project (#685). */
|
|
24
|
+
export const MAX_JCODE_INSTANCES = 8;
|
|
25
|
+
/** A flag value jcode takes as one argv word: bounded, printable, no spaces. */
|
|
26
|
+
const NATIVE_WORD = /^[A-Za-z0-9][A-Za-z0-9._:/@+-]{0,127}$/;
|
|
14
27
|
export type InstancesConfig = Readonly<Record<string, InstanceConfig>>;
|
|
15
28
|
export interface InstanceCatalogEntry { id: string; kind: string; definition: AdapterDefinition; config?: InstanceConfig }
|
|
16
29
|
const MAX_INSTANCES = 64;
|
|
@@ -41,6 +54,58 @@ function roles(value: unknown, field: string): string[] {
|
|
|
41
54
|
return out;
|
|
42
55
|
}
|
|
43
56
|
|
|
57
|
+
/** The Jcode-only fields of one declaration (#685). Only the validated values are copied; nothing is read twice. */
|
|
58
|
+
function jcodeInstanceFields(entry: Record<string, PropertyDescriptor>, path: string): Partial<InstanceConfig> {
|
|
59
|
+
const home: unknown = entry.jcode_home?.value;
|
|
60
|
+
if (typeof home !== "string" || !isAbsolute(home) || !home.trim() || Buffer.byteLength(home) > 4096 || /\p{Cc}/u.test(home)) return fail(`${path}.jcode_home`, "is required: a nonempty control-free absolute path of at most 4096 UTF-8 bytes, other than the base Jcode's home");
|
|
61
|
+
const out: Partial<InstanceConfig> = { jcode_home: home };
|
|
62
|
+
for (const key of ["provider", "model", "provider_profile"] as const) {
|
|
63
|
+
const present = entry[key];
|
|
64
|
+
if (!present) continue;
|
|
65
|
+
const word: unknown = present.value;
|
|
66
|
+
if (typeof word !== "string" || !NATIVE_WORD.test(word)) return fail(`${path}.${key}`, "must be one bounded printable word without spaces");
|
|
67
|
+
out[key] = word;
|
|
68
|
+
}
|
|
69
|
+
if (entry.start_mode && entry.start_mode.value !== "headless") return fail(`${path}.start_mode`, "a Jcode instance starts headless");
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The directory a home names, as the disk sees it (#685 review): symlinks and system aliases (`/tmp` and `/private/tmp`) resolved with
|
|
75
|
+
* `realPath` (never `realpathSync`, which Bun fails on a backslash path), and for a home that does not exist yet the nearest existing
|
|
76
|
+
* ancestor resolved the same way. Compared case-folded when the volume folds case, so a case-only alias is the same home.
|
|
77
|
+
*/
|
|
78
|
+
function canonicalHome(home: string): { path: string; key: string; identity?: string } {
|
|
79
|
+
let path: string;
|
|
80
|
+
try { path = realPath(home); } catch { path = canonicalPath(home); }
|
|
81
|
+
const stat = (target: string) => { try { return statSync(target); } catch { return undefined; } };
|
|
82
|
+
let existing = path;
|
|
83
|
+
while (!stat(existing) && dirname(existing) !== existing) existing = dirname(existing);
|
|
84
|
+
const info = stat(path), anchor = stat(existing);
|
|
85
|
+
const swapped = existing.replace(/[A-Za-z]/g, letter => letter === letter.toLowerCase() ? letter.toUpperCase() : letter.toLowerCase());
|
|
86
|
+
const alias = swapped !== existing ? stat(swapped) : undefined;
|
|
87
|
+
const folds = !!anchor && !!alias && alias.ino === anchor.ino && alias.dev === anchor.dev;
|
|
88
|
+
return { path, key: folds ? path.toLowerCase() : path, ...(info ? { identity: `${info.dev}:${info.ino}` } : {}) };
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* One native data home per Jcode (#685): an instance may not use the base Jcode's home, another instance's, or one nested
|
|
92
|
+
* inside either (their stores would overlap), however the path is spelled: a symlink, a system alias or a case-only alias of one
|
|
93
|
+
* is the same home. Run once with the loaded base `jcode_home`.
|
|
94
|
+
*/
|
|
95
|
+
export function checkJcodeInstanceHomes(declarations: InstancesConfig | undefined, baseHome: string): void {
|
|
96
|
+
const homes: { id: string; key: string; identity?: string }[] = [{ id: "the base Jcode", ...canonicalHome(baseHome) }];
|
|
97
|
+
for (const [id, config] of Object.entries(declarations ?? {})) {
|
|
98
|
+
if (config.jcode_home === undefined) continue; // only a native-home kind carries one
|
|
99
|
+
const home = canonicalHome(config.jcode_home);
|
|
100
|
+
for (const other of homes) {
|
|
101
|
+
const label = other.id === "the base Jcode" ? "base" : other.id;
|
|
102
|
+
if (home.key === other.key || (home.identity !== undefined && home.identity === other.identity)) return fail(`instances.${id}.jcode_home`, other.id === "the base Jcode" ? "must differ from the base Jcode's home" : `is the same home as ${label}`);
|
|
103
|
+
if (home.key.startsWith(`${other.key}${sep}`) || other.key.startsWith(`${home.key}${sep}`)) return fail(`instances.${id}.jcode_home`, `is nested inside or around ${other.id === "the base Jcode" ? "the base Jcode's home" : `${label}'s home`}`);
|
|
104
|
+
}
|
|
105
|
+
homes.push({ id, ...home });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
44
109
|
/** Declaration support is explicit policy, independent of the native MCP binding mechanism. */
|
|
45
110
|
export function supportsHeadlessInstances(definition: AdapterDefinition): boolean {
|
|
46
111
|
return definition.instances?.mode === "headless" && !!definition.start.create && definition.start.forms.includes("headless");
|
|
@@ -57,11 +122,14 @@ export function validateInstances(raw: unknown, catalog: ProjectAdapterCatalog):
|
|
|
57
122
|
const result: Record<string, InstanceConfig> = Object.create(null);
|
|
58
123
|
if (raw === undefined) return Object.freeze(result);
|
|
59
124
|
const declarations = fields(raw, "instances");
|
|
125
|
+
let jcodeCount = 0;
|
|
60
126
|
if (Object.keys(declarations).length > MAX_INSTANCES) return fail("instances", "at most 64 extra instances are supported");
|
|
61
127
|
for (const [id, descriptor] of Object.entries(declarations)) {
|
|
62
128
|
if (!PEER_ID.test(id) || RESERVED.has(id) || resolve(id)) return fail("instances", "ids must be nonreserved extra instance ids, not builtin ids");
|
|
63
129
|
const path = `instances.${id}`, entry = fields(descriptor.value, path);
|
|
64
|
-
|
|
130
|
+
const declaredKind: unknown = entry.kind?.value;
|
|
131
|
+
const jcodeFields = typeof declaredKind === "string" && resolve(declaredKind)?.instances?.nativeHome === true;
|
|
132
|
+
if (Object.keys(entry).some(key => !["kind", "roles", "start_mode", "permission_mode", "worktree", "review_mode", "readOnly", ...(jcodeFields ? JCODE_FIELDS : [])].includes(key))) return fail(path, "unsupported field");
|
|
65
133
|
const kind: unknown = entry.kind?.value;
|
|
66
134
|
if (typeof kind !== "string") return fail(`${path}.kind`, "must name a native kind");
|
|
67
135
|
const definition = resolve(kind), prefix = `${kind}-`;
|
|
@@ -102,6 +170,10 @@ export function validateInstances(raw: unknown, catalog: ProjectAdapterCatalog):
|
|
|
102
170
|
if (entry.readOnly.value && entry.permission_mode) return fail(`${path}.permission_mode`, "read-only instances cannot select a permission mode");
|
|
103
171
|
}
|
|
104
172
|
const value: InstanceConfig = { kind, ...(entry.review_mode ? { review_mode: "fresh" as const } : {}) };
|
|
173
|
+
if (jcodeFields) {
|
|
174
|
+
if (++jcodeCount > MAX_JCODE_INSTANCES) return fail("instances", `at most ${MAX_JCODE_INSTANCES} extra Jcode instances are supported per project`);
|
|
175
|
+
Object.assign(value, jcodeInstanceFields(entry, path));
|
|
176
|
+
}
|
|
105
177
|
if (entry.readOnly) value.readOnly = entry.readOnly.value as boolean;
|
|
106
178
|
if (entry.roles) { value.roles = roles(entry.roles.value, `${path}.roles`); Object.freeze(value.roles); }
|
|
107
179
|
if (entry.start_mode) {
|