@staix/agent-hub 0.12.45 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +85 -116
  3. package/docs/agent-notes/README.md +6 -0
  4. package/docs/agent-notes/adapters.md +10 -7
  5. package/docs/agent-notes/budget.md +7 -1
  6. package/docs/agent-notes/bus.md +6 -4
  7. package/docs/agent-notes/daemon.md +14 -2
  8. package/docs/agent-notes/local-worker.md +2 -1
  9. package/docs/agent-notes/tasks.md +16 -3
  10. package/docs/agent-notes/tests.md +5 -3
  11. package/docs/dogfood.md +95 -0
  12. package/docs/events.md +14 -1
  13. package/docs/operations.md +290 -27
  14. package/docs/profiles.md +2 -2
  15. package/docs/quickstart.md +13 -3
  16. package/docs/sdlc.md +59 -8
  17. package/docs/security.md +26 -13
  18. package/docs/smoke.md +9 -4
  19. package/docs/specs/2026-09-19-agent-hub-design.md +797 -8
  20. package/docs/verification/2026-10-19-reviewer-isolation.md +2 -0
  21. package/docs/verification/2026-10-22-code-facts.md +78 -0
  22. package/docs/verification/2026-10-22-codex-reviewer-isolation.md +61 -0
  23. package/docs/verification/2026-10-22-kimi-qwen-compat.md +79 -0
  24. package/docs/verification/2026-10-23-0.13.0.md +89 -0
  25. package/docs/verification/2026-10-23-0.14.0.md +69 -0
  26. package/docs/verification/dogfood/2026-10-22.md +3109 -0
  27. package/docs/verified.json +22 -17
  28. package/package.json +1 -1
  29. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  30. package/plugins/agent-hub/server.js +702 -347
  31. package/src/adapters/agy/entry.ts +5 -4
  32. package/src/adapters/claude-channel.ts +20 -9
  33. package/src/adapters/codex-appserver.ts +18 -2
  34. package/src/adapters/codex-review-isolation.ts +29 -11
  35. package/src/adapters/compat.ts +140 -0
  36. package/src/adapters/definitions/builtin-metadata.ts +3 -2
  37. package/src/adapters/definitions/native-cli-doctor.ts +12 -5
  38. package/src/adapters/definitions/native-cli-entry-codex.ts +13 -0
  39. package/src/adapters/definitions/native-cli-entry-jcode.ts +9 -6
  40. package/src/adapters/definitions/native-cli-jcode-mcp.ts +9 -2
  41. package/src/adapters/definitions/native-cli-preview-common.ts +2 -0
  42. package/src/adapters/definitions/native-cli-preview-jcode.ts +8 -2
  43. package/src/adapters/definitions/native-cli-preview-qwen.ts +2 -1
  44. package/src/adapters/definitions/qwen-native.ts +7 -3
  45. package/src/adapters/definitions/qwen.ts +2 -1
  46. package/src/adapters/jcode.ts +72 -31
  47. package/src/adapters/kimi-review.ts +6 -3
  48. package/src/adapters/registry-types.ts +3 -1
  49. package/src/adapters/registry.ts +1 -0
  50. package/src/adapters/reviewer-print.ts +34 -7
  51. package/src/cli/agents.ts +40 -0
  52. package/src/cli/console-review.ts +54 -0
  53. package/src/cli/console-state.ts +124 -29
  54. package/src/cli/console-symbols.ts +74 -0
  55. package/src/cli/console-usage.ts +89 -0
  56. package/src/cli/console.ts +26 -1
  57. package/src/cli/help.ts +17 -6
  58. package/src/cli/identity.ts +29 -6
  59. package/src/cli/init.ts +109 -11
  60. package/src/cli/main.ts +127 -11
  61. package/src/cli/output.ts +91 -3
  62. package/src/cli/preview.ts +1 -1
  63. package/src/cli/sdlc-release-guard.ts +82 -0
  64. package/src/cli/status-lines.ts +1 -1
  65. package/src/hub/action-class.ts +362 -0
  66. package/src/hub/action-policy.ts +106 -0
  67. package/src/hub/board.ts +65 -9
  68. package/src/hub/budget.ts +71 -2
  69. package/src/hub/bus.ts +43 -3
  70. package/src/hub/checks.ts +80 -31
  71. package/src/hub/code-facts.ts +223 -0
  72. package/src/hub/config-apply.ts +10 -1
  73. package/src/hub/config-trust.ts +7 -4
  74. package/src/hub/daemon.ts +319 -34
  75. package/src/hub/dogfood-read.ts +199 -0
  76. package/src/hub/dogfood.ts +163 -0
  77. package/src/hub/envelope.ts +1 -1
  78. package/src/hub/events.ts +11 -2
  79. package/src/hub/hub-tools.ts +26 -3
  80. package/src/hub/inbox-polling.ts +25 -0
  81. package/src/hub/instances.ts +87 -5
  82. package/src/hub/manager.ts +2 -2
  83. package/src/hub/profiles.ts +7 -2
  84. package/src/hub/release-worktree.ts +56 -0
  85. package/src/hub/review-brief.ts +36 -0
  86. package/src/hub/sdlc-metrics.ts +260 -0
  87. package/src/hub/sdlc-queue.ts +31 -9
  88. package/src/hub/sdlc-release-authorization.ts +173 -0
  89. package/src/hub/sdlc.ts +54 -14
  90. package/src/hub/settings.ts +5 -2
  91. package/src/hub/snapshots.ts +93 -8
  92. package/src/hub/spec-read.ts +31 -0
  93. package/src/hub/symbol-normalizer.ts +26 -0
  94. package/src/hub/task-code-group.ts +52 -0
  95. package/src/hub/task-code-symbols.ts +90 -0
  96. package/src/hub/task-sweep.ts +3 -2
  97. package/src/hub/tasks.ts +455 -67
  98. package/src/hub/test-integrity.ts +162 -0
  99. package/src/hub/ui.ts +3 -0
  100. package/src/hub/usage-rollup.ts +419 -0
  101. package/src/local/sandbox.ts +5 -3
  102. package/src/ui/index.html +274 -2
  103. package/templates/AGENTS.block.md +1 -1
  104. package/templates/profiles/dev.json +6 -1
  105. package/templates/profiles/general.json +6 -1
  106. package/templates/profiles/research.json +6 -1
  107. package/templates/profiles/writing.json +6 -1
  108. package/templates/release-guard.block.md +6 -0
@@ -0,0 +1,199 @@
1
+ import { closeSync, constants, fstatSync, openSync, readSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { Database } from "bun:sqlite";
4
+ import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
5
+ import { readPrCostHistory, sdlcGh, type SdlcGh } from "./sdlc.ts";
6
+ import { dogfoodScorecard, missing, source, type DogfoodTask, type DogfoodPr, type FeatureEvidence, type Source } from "./dogfood.ts";
7
+ import type { HistoryEntry } from "./board.ts";
8
+ import type { StampedEvent } from "./events.ts";
9
+
10
+ export const DOGFOOD_BYTE_CAP = 16 * 1024 * 1024;
11
+ const MAX_TASKS = 500, MAX_PRS = 16, MAX_CI_HEADS = 8, MAX_CI_READS = 32;
12
+ const date = (value: unknown): number | undefined => typeof value === "string" && Number.isFinite(Date.parse(value)) ? Date.parse(value) : undefined;
13
+ const id = (value: unknown): value is number => Number.isSafeInteger(value) && (value as number) > 0;
14
+ const repoName = (value: unknown): value is string => typeof value === "string" && /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value) && !value.startsWith("-");
15
+
16
+ /** Read at most cap bytes; keep complete lines from the tail, never follow a FIFO or grow with an appender. */
17
+ export function dogfoodLines(file: string, cap = DOGFOOD_BYTE_CAP): Source<string[]> {
18
+ let fd: number | undefined;
19
+ try {
20
+ if (!Number.isSafeInteger(cap) || cap < 1 || cap > DOGFOOD_BYTE_CAP) return missing([]);
21
+ fd = openSync(file, constants.O_RDONLY | constants.O_NONBLOCK);
22
+ const stat = fstatSync(fd);
23
+ if (!stat.isFile()) return missing([]);
24
+ const start = Math.max(0, stat.size - cap), bytes = Buffer.alloc(Math.min(cap, stat.size));
25
+ const n = readSync(fd, bytes, 0, bytes.length, start);
26
+ let text = bytes.subarray(0,n).toString("utf8");
27
+ if (start) { const newline = text.indexOf("\n"); text = newline < 0 ? "" : text.slice(newline + 1); }
28
+ const whole = text.endsWith("\n") || !text;
29
+ if (!whole) text = text.slice(0,Math.max(0,text.lastIndexOf("\n") + 1));
30
+ return source(text.split("\n").filter(Boolean), !start && n === stat.size && whole);
31
+ } catch { return missing([]); }
32
+ finally { if (fd !== undefined) closeSync(fd); }
33
+ }
34
+ export function dogfoodEvents(lines: Source<string[]>, since: number, until: number): Source<StampedEvent[]> {
35
+ const out: StampedEvent[] = []; let complete = lines.complete;
36
+ for (const line of lines.data) {
37
+ try {
38
+ const e = JSON.parse(line);
39
+ if (!e || typeof e !== "object" || e.v !== 1 || typeof e.type !== "string" || date(e.at) === undefined) { complete = false; continue; }
40
+ const number = (n:unknown) => typeof n === "number" && Number.isFinite(n) && n >= 0;
41
+ const peer = typeof e.peer === "string" && /^[a-z][a-z0-9-]{0,63}$/.test(e.peer) && !Object.hasOwn(Object.prototype,e.peer);
42
+ const malformed = ["tokens","usage","turn_end","task","overflow","undeliverable"].includes(e.type) &&
43
+ (e.type === "task" ? !id(e.id) || typeof e.class !== "string" || typeof e.event !== "string" || typeof e.state !== "string" : !peer ||
44
+ e.type === "tokens" && !number(e.n) || e.type === "turn_end" && !number(e.ms) ||
45
+ e.type === "usage" && (typeof e.id !== "string" || typeof e.source !== "string") ||
46
+ ["overflow","undeliverable"].includes(e.type) && typeof e.id !== "string");
47
+ if (malformed) {complete=false;continue;}
48
+ const at = e.type === "usage" && date(e.measuredAt) !== undefined ? date(e.measuredAt)! : date(e.at)!;
49
+ if (at >= since && at <= until) out.push({ ...e, at: new Date(at).toISOString() });
50
+ } catch { complete = false; }
51
+ }
52
+ return { data: out, available: lines.available, complete };
53
+ }
54
+ /** Bounded read-only board snapshot. Select no task title/detail or history note into the result. */
55
+ export function dogfoodTasks(file: string): Source<DogfoodTask[]> {
56
+ let db: Database | undefined;
57
+ try {
58
+ db = new Database(file, { readonly: true });
59
+ db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
60
+ const rows = db.query(`SELECT id, (sdlc IS NOT NULL AND length(sdlc) > 65536) AS receipt_cut, CASE WHEN length(sdlc) <= 65536 THEN sdlc END AS sdlc,
61
+ CASE WHEN length(signals) <= 65536 THEN signals END AS signals,
62
+ CASE WHEN length(history) <= 131072 THEN history END AS history FROM tasks ORDER BY id DESC LIMIT ?`).all(MAX_TASKS + 1) as { id: number; receipt_cut: number; sdlc: string | null; signals: string | null; history: string | null }[];
63
+ let complete = rows.length <= MAX_TASKS, bytes = 0;
64
+ const tasks: DogfoodTask[] = [];
65
+ for (const row of rows.slice(0,MAX_TASKS)) {
66
+ if (row.receipt_cut) complete = false;
67
+ bytes += (row.sdlc?.length ?? 0) + (row.signals?.length ?? 0) + (row.history?.length ?? 0);
68
+ if (bytes > DOGFOOD_BYTE_CAP) { complete = false; break; }
69
+ try {
70
+ const signals = JSON.parse(row.signals ?? "null"), history = JSON.parse(row.history ?? "null"), receipt = JSON.parse(row.sdlc ?? "{}");
71
+ if (!id(row.id) || !Array.isArray(signals) || !Array.isArray(history)) { complete = false; continue; }
72
+ if (history.length > 4096) { complete = false; continue; }
73
+ const clean: HistoryEntry[] = [];
74
+ for (const h of history) {
75
+ if (!h || !Number.isFinite(h.at) || typeof h.event !== "string") { complete = false; continue; }
76
+ clean.push({ at: h.at, by: "hub", event: h.event, ...(h.reviewCost && typeof h.reviewCost === "object" ? { reviewCost: { ...(h.reviewCost.review && ["none","delta","full"].includes(h.reviewCost.review.scope) ? {review:{scope:h.reviewCost.review.scope,reviewers:1}} : {}) } } : {}), ...(Array.isArray(h.reviewFindings?.items) ? { reviewFindings: { items:h.reviewFindings.items.filter((f:any)=>f && typeof f.id === "string" && ["important","nit"].includes(f.severity)).map((f:any)=>({id:f.id,severity:f.severity,pass:"bugs",disposition:"open"})) } } : {}) });
77
+ }
78
+ tasks.push({ id: row.id, pii: signals.includes("pii") || signals.includes("pii_path"), ...(repoName(receipt?.repo) ? { repo: receipt.repo } : {}), ...(id(receipt?.pr) ? { pr: receipt.pr } : {}), history: clean });
79
+ } catch { complete = false; }
80
+ }
81
+ return source(tasks, complete);
82
+ } catch { return missing([]); }
83
+ finally { db?.close(); }
84
+ }
85
+ /** Journal terminal retention is finite: these are retained rows, never whole-window delivery totals. */
86
+ export function dogfoodJournal(file: string, since: number, until: number): Source<{digests:number;held:number;rows:number}> {
87
+ let db: Database | undefined;
88
+ try {
89
+ db = new Database(file, { readonly: true });
90
+ db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
91
+ const rows = db.query(`SELECT state, CASE WHEN length(out_json) <= 262144 THEN
92
+ (SELECT COUNT(*) FROM json_each(out_json) WHERE json_extract(value,'$.from')='digest') END AS digests
93
+ FROM deliveries WHERE created_at >= ? AND created_at <= ? ORDER BY created_at DESC LIMIT 10001`).all(since,until) as {state:string;digests:number|null}[];
94
+ return source({rows:Math.min(rows.length,10000),digests:rows.slice(0,10000).reduce((n,r)=>n+(r.digests??0),0),held:rows.slice(0,10000).filter(r=>r.state === "needs_review").length},rows.length<=10000 && rows.every(r=>r.digests!==null));
95
+ } catch { return missing({digests:0,held:0,rows:0}); }
96
+ finally {db?.close();}
97
+ }
98
+ const connection = (value: any): any[] | undefined => value?.pageInfo?.hasNextPage === false && Array.isArray(value.nodes) && value.nodes.length <= 100 ? value.nodes : undefined;
99
+ /** Reuse S10 cost readback; supplementary reads carry timestamps/ids only, never comment or issue bodies. */
100
+ export async function dogfoodPrs(repo: string, tasks: Source<DogfoodTask[]>, since: number, until: number, run: SdlcGh = sdlcGh): Promise<Source<DogfoodPr[]>> {
101
+ if (!repoName(repo) || !tasks.available || !tasks.complete) return missing([]); // unknown privacy inventory forbids remote per-PR reads
102
+ const deadline = performance.now() + 20000;
103
+ const gh: SdlcGh = (args, timeout, cwd, cap) => performance.now() < deadline
104
+ ? run(args, Math.max(1,Math.min(timeout ?? 5000,deadline-performance.now())),cwd,Math.min(cap ?? 128*1024,128*1024)) : Promise.resolve({code:1,stdout:""});
105
+ const parse = async (args: string[]) => { try { const r = await gh(args); return r.code === 0 ? JSON.parse(r.stdout) : undefined; } catch { return undefined; } };
106
+ const list = await parse(["pr","list","--repo",repo,"--state","merged","--search",`merged:>=${new Date(since).toISOString()}`,"--limit","100","--json","number,mergedAt,createdAt"]);
107
+ if (!Array.isArray(list)) return missing([]);
108
+ const hidden = new Set(tasks.data.filter(t => t.pii && (!t.repo || t.repo.toLowerCase() === repo.toLowerCase())).map(t => t.pr));
109
+ const visible = list.filter(p => id(p?.number) && date(p.mergedAt) !== undefined && date(p.mergedAt)! <= until && !hidden.has(p.number));
110
+ let complete = list.length < 100 && list.every(p => id(p?.number) && date(p.mergedAt) !== undefined) && visible.length <= MAX_PRS;
111
+ // Read recent PRs first: their complete recorded head sets usually fit before
112
+ // the shared deadline. Repository-wide CI volume never consumes this budget.
113
+ visible.sort((a,b)=>date(b.mergedAt)!-date(a.mergedAt)!);
114
+ const projection = "{total_count,workflow_runs:[.workflow_runs[]|{id,run_attempt,event,head_sha,created_at,conclusion,pull_requests:[.pull_requests[]|{number}]}]}";
115
+ const validRun = (r:any) => r?.event === "pull_request" && id(r?.id) && id(r?.run_attempt) && typeof r?.head_sha === "string" && /^[a-f0-9]{40}$/.test(r.head_sha)
116
+ && date(r.created_at) !== undefined && (r.conclusion === null || typeof r.conclusion === "string")
117
+ && Array.isArray(r.pull_requests) && r.pull_requests.length <= 100 && r.pull_requests.every((p:any)=>id(p?.number));
118
+ const sha = (v:unknown):v is string => typeof v === "string" && /^[a-f0-9]{40}$/.test(v);
119
+ let ciReads = 0;
120
+ const rows: DogfoodPr[] = [];
121
+ for (const item of visible.slice(0,MAX_PRS)) {
122
+ let heads: string[] | undefined, runs: any[] = [], runsComplete = false;
123
+ const cost = await readPrCostHistory(repo,item.number,async(args,t,c,cap) => {
124
+ if (args.some(arg => arg.startsWith(`repos/${repo}/actions/workflows/check.yml/runs?`))) {
125
+ if (!heads || heads.length>MAX_CI_HEADS || ciReads+heads.length>MAX_CI_READS) return {code:1,stdout:""};
126
+ const collected:any[] = [], seen = new Set<number>();
127
+ for (const head of heads) {
128
+ ciReads++;
129
+ const endpoint=`repos/${repo}/actions/workflows/check.yml/runs?per_page=100&event=pull_request&head_sha=${head}&created=${new Date(since).toISOString()}..${new Date(until).toISOString()}`;
130
+ const part = await parse(["api",endpoint,"--jq",projection]);
131
+ if (!part || !Number.isSafeInteger(part.total_count) || part.total_count<0 || !Array.isArray(part.workflow_runs)
132
+ || part.total_count!==part.workflow_runs.length || part.workflow_runs.length>100
133
+ || !part.workflow_runs.every((r:any)=>validRun(r) && r.head_sha===head)) return {code:1,stdout:""};
134
+ for (const row of part.workflow_runs) {
135
+ if (seen.has(row.id)) return {code:1,stdout:""};
136
+ seen.add(row.id);collected.push(row);
137
+ }
138
+ if (collected.length>100) return {code:1,stdout:""};
139
+ }
140
+ runs=collected;runsComplete=true;
141
+ return {code:0,stdout:JSON.stringify({total_count:runs.length,workflow_runs:runs.map((r:any)=>({id:r.id,run_attempt:r.run_attempt,head_sha:r.head_sha,pull_requests:r.pull_requests.map((p:any)=>p.number)}))})};
142
+ }
143
+ const reply = await gh(args,t,c,cap);
144
+ try {
145
+ const body = JSON.parse(reply.stdout), pr=body?.data?.repository?.pullRequest;
146
+ const commits=connection(pr?.commits),reviews=connection(pr?.reviews);
147
+ if (reply.code===0 && !body.errors && pr?.number===item.number && sha(pr.headRefOid) && commits && reviews
148
+ && commits.every(n=>sha(n?.commit?.oid)) && reviews.every(n=>id(n?.databaseId) && sha(n?.commit?.oid))) {
149
+ heads=[...new Set<string>([pr.headRefOid,...commits.map(n=>n.commit.oid),...reviews.map(n=>n.commit.oid)])];
150
+ }
151
+ } catch { /* Missing or incomplete recorded heads cannot certify CI. */ }
152
+ return reply;
153
+ });
154
+ const row: DogfoodPr = cost ?? {repo,pr:item.number,state:"MERGED",mergedAt:date(item.mergedAt),complete:false};
155
+ const linked = runsComplete ? runs.filter((r:any)=>date(r.created_at)!<=row.mergedAt!) : [];
156
+ if (runsComplete) {
157
+ const earliest=[...linked].sort((a,b)=>date(a.created_at)!-date(b.created_at)!)[0];
158
+ if (date(item.createdAt)!>=since && date(item.createdAt)!<=until && earliest?.run_attempt===1
159
+ && ["success","failure","cancelled","timed_out"].includes(earliest.conclusion)) row.firstPassCi=earliest.conclusion==="success";
160
+ row.sameHeadReruns=linked.reduce((n,r)=>n+r.run_attempt-1,0);
161
+ }
162
+ const [owner,name] = repo.split("/");
163
+ const detail = await parse(["api","graphql","-f",`query=query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){pullRequest(number:$number){reviewThreads(first:100){pageInfo{hasNextPage} nodes{comments(first:1){nodes{createdAt author{login}}}}} timelineItems(first:100,itemTypes:[CROSS_REFERENCED_EVENT]){pageInfo{hasNextPage} nodes{... on CrossReferencedEvent{source{... on Issue{number createdAt labels(first:100){pageInfo{hasNextPage} nodes{name}}}}}}}}}}`,"-f",`owner=${owner}`,"-f",`name=${name}`,"-F",`number=${item.number}`]);
164
+ const pr = detail?.errors ? undefined : detail?.data?.repository?.pullRequest;
165
+ const threads = connection(pr?.reviewThreads), references = connection(pr?.timelineItems);
166
+ if (threads && threads.every(t => date(t?.comments?.nodes?.[0]?.createdAt) !== undefined && typeof t.comments.nodes[0].author?.login === "string")) row.connectorThreads = threads.flatMap(t => /^(?:chatgpt-codex-connector|chatgpt-codex-connector\[bot\])$/.test(t.comments.nodes[0].author.login) ? [date(t.comments.nodes[0].createdAt)!] : []);
167
+ if (references && references.every(r => id(r?.source?.number) && date(r.source.createdAt) !== undefined && connection(r.source.labels)?.every(label=>typeof label?.name === "string"))) row.bugIssues = [...new Map(references.flatMap(r => connection(r.source.labels)!.some(label => label.name === "bug") ? [[r.source.number,{id:r.source.number,at:date(r.source.createdAt)!}] as const] : [])).values()];
168
+ if (!row.complete || !pr) complete = false;
169
+ rows.push(row);
170
+ }
171
+ return source(rows,complete);
172
+ }
173
+ /** Row ordinals bind to this invocation's docs file; titles/state prose never enter the exported receipt. */
174
+ export function dogfoodFeatures(file: string): Source<FeatureEvidence[]> {
175
+ const text = dogfoodLines(file,128*1024);
176
+ const mappings: [RegExp,string[],string[]?][] = [
177
+ [/Message bus/,["envelope","overflow"]], [/Task board/,["task"]], [/Overlap/,["overlap"]],
178
+ [/WIP caps/,["task"],["ready held"]], [/Task sweep/,["task"],["idle sweep","escalated"]], [/Turn-free/,["cohort"]],
179
+ [/Shared memory/,["memory_brief"]],
180
+ [/Quota budgets/,["quota","budget_pause","budget_resume","quota_reset"]], [/Token usage/,["tokens","usage"]],
181
+ [/Context gate/,["context_pressure"]], [/Permission modes/,["permission"]], [/PII routing/,["pii_screen"]],
182
+ [/Settings reload/,["settings"]], [/Release authorization/,["release_authorization"]],
183
+ ];
184
+ const rows = text.data.filter(line=>line.startsWith("| ") && !line.startsWith("| Feature ") && !line.startsWith("| ---"));
185
+ return {available:text.available,complete:text.complete,data:rows.map((row,index)=>{const rule=mappings.find(([re])=>re.test(row.split("|")[1] ?? ""));return {id:index+1,types:rule?.[1] ?? null,...(rule?.[2] ? {taskEvents:rule[2]} : {})};})};
186
+ }
187
+ export async function readDogfood(root: string, stateDir: string, since: number, until = Date.now(), run: SdlcGh = sdlcGh) {
188
+ const tasks = dogfoodTasks(join(stateDir,"hub.db"));
189
+ let repo = "";
190
+ try { const r = await run(["repo","view","--json","nameWithOwner"],5000,root,8192); if (r.code === 0) repo = JSON.parse(r.stdout).nameWithOwner; } catch { /* unknown */ }
191
+ const events = dogfoodEvents(dogfoodLines(join(stateDir,"events.jsonl")),since,until);
192
+ const log = dogfoodLines(join(stateDir,"hub.log"));
193
+ log.data = log.data.filter(line => {
194
+ const at=date(line.slice(0,24));
195
+ if (at === undefined) { log.complete = false; return false; }
196
+ return at >= since && at <= until;
197
+ });
198
+ return dogfoodScorecard({since,until,tasks,events,log,journal:dogfoodJournal(join(stateDir,"hub.db"),since,until),prs:await dogfoodPrs(repo,tasks,since,until,run),features:dogfoodFeatures(join(root,"docs/dogfood.md"))});
199
+ }
@@ -0,0 +1,163 @@
1
+ import { costMetrics, type CostPr, type CostTask } from "./cost-governance.ts";
2
+ import type { HistoryEntry } from "./board.ts";
3
+ import { parseSince } from "./events.ts";
4
+ import type { StampedEvent } from "./events.ts";
5
+ import { summarize, summarizeByTask, type TaskPeerReport } from "./report.ts";
6
+
7
+
8
+ export function dogfoodSince(args: string[], now = Date.now()): number {
9
+ let since: number | undefined, dogfood = false, json = false;
10
+ const fail = (): never => {throw new Error("usage: ahub report --dogfood [--since 7d|<iso>] [--json]");};
11
+ for(let i=0;i<args.length;i++) {
12
+ const arg=args[i];
13
+ if(arg==="--dogfood" && !dogfood) dogfood=true;
14
+ else if(arg==="--json" && !json) json=true;
15
+ else if(arg==="--since" && since===undefined) {const value=parseSince(args[++i] ?? "",now);if(value===undefined)fail();since=value;}
16
+ else fail();
17
+ }
18
+ if(!dogfood)fail();
19
+ return since ?? now-7*86400000;
20
+ }
21
+
22
+ export interface Source<T> { data: T; complete: boolean; available: boolean }
23
+ export const source = <T>(data: T, complete = true): Source<T> => ({ data, complete, available: true });
24
+ export const missing = <T>(data: T): Source<T> => ({ data, complete: false, available: false });
25
+ export interface DogfoodTask { id: number; repo?: string; pr?: number; pii: boolean; history: HistoryEntry[] }
26
+ export interface DogfoodPr extends CostPr {
27
+ firstPassCi?: boolean;
28
+ sameHeadReruns?: number;
29
+ connectorThreads?: number[];
30
+ bugIssues?: { id: number; at: number }[];
31
+ }
32
+ export interface FeatureEvidence { id: number; types: string[] | null; taskEvents?: string[] }
33
+ export interface DogfoodInput {
34
+ since: number; until: number;
35
+ events: Source<StampedEvent[]>; tasks: Source<DogfoodTask[]>; prs: Source<DogfoodPr[]>;
36
+ journal?: Source<{ digests: number; held: number; rows: number }>;
37
+ log: Source<string[]>; features: Source<FeatureEvidence[]>;
38
+ }
39
+ export interface Metric { value: number | null; samples: number }
40
+ const metric = (value: number, known: boolean, samples = 1): Metric => ({ value: known ? value : null, samples });
41
+ const known = (s: Source<unknown>) => s.available && s.complete;
42
+ const positive = (n: unknown): n is number => Number.isSafeInteger(n) && (n as number) > 0;
43
+ const finite = (n: unknown): n is number => typeof n === "number" && Number.isFinite(n) && n >= 0;
44
+ const identity = (repo: string, pr: number) => `${repo.toLowerCase()}#${pr}`;
45
+ const validPeer = (peer: string) => /^[a-z][a-z0-9-]{0,63}$/.test(peer) && !Object.hasOwn(Object.prototype, peer);
46
+ function peers(input: Record<string, TaskPeerReport>) {
47
+ return Object.fromEntries(Object.entries(input).filter(([id]) => validPeer(id)).map(([id, p]) => [id, {
48
+ tokens: p.tokens, input: p.usage.inputTokens, output: p.usage.outputTokens,
49
+ cacheRead: p.usage.cacheReadTokens, cacheWrite: p.usage.cacheWriteTokens, total: p.usage.totalTokens,
50
+ records: p.usage.records, inputRecords: p.usage.inputRecords, outputRecords: p.usage.outputRecords,
51
+ cacheReadRecords: p.usage.cacheReadRecords, cacheWriteRecords: p.usage.cacheWriteRecords, totalRecords: p.usage.totalRecords,
52
+ }]));
53
+ }
54
+ function percentiles(values: number[]) {
55
+ const sorted = values.filter(finite).sort((a, b) => a - b);
56
+ const at = (q: number) => sorted.length ? sorted[Math.max(0, Math.ceil(q * sorted.length) - 1)]! : null;
57
+ return { samples: sorted.length, p50: at(.5), p95: at(.95), p99: at(.99) };
58
+ }
59
+
60
+ /** Metadata projections only: never return task titles, notes, model/provider names or log fragments. */
61
+ export function dogfoodScorecard(input: DogfoodInput) {
62
+ const events = input.events.data.filter(e => Date.parse(e.at) >= input.since && Date.parse(e.at) <= input.until);
63
+ const inWindow = (at: number) => at >= input.since && at <= input.until;
64
+ const tasks = input.tasks.data;
65
+ const hidden = new Set(tasks.filter(t => t.pii && t.repo && positive(t.pr)).map(t => identity(t.repo!, t.pr!)));
66
+ const prs = input.prs.data.filter(p => p.state === "MERGED" && finite(p.mergedAt) && inWindow(p.mergedAt)
67
+ && /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(p.repo) && positive(p.pr) && !hidden.has(identity(p.repo, p.pr)));
68
+ const costTasks: CostTask[] = tasks.flatMap(t => t.repo && positive(t.pr) ? [{ repo: t.repo, pr: t.pr, private: t.pii, history: t.history }] : []);
69
+ const costs = costMetrics(costTasks, prs);
70
+ const rows = prs.map(pr => {
71
+ const linked = tasks.filter(t => !t.pii && t.repo && positive(t.pr) && identity(t.repo, t.pr!) === identity(pr.repo, pr.pr));
72
+ const histories = linked.flatMap(t => t.history).filter(h => h.at <= pr.mergedAt!).sort((a,b) => a.at - b.at);
73
+ const timelines = linked.map(t => {
74
+ const history=t.history.filter(h=>h.at<=pr.mergedAt!).sort((a,b)=>a.at-b.at);
75
+ return {approval:history.findLast(h=>h.event==="approved")?.at,
76
+ proposed:history.find(h=>h.event==="proposed" || h.event==="backlog pulled")?.at,
77
+ done:history.findLast(h=>h.event==="done")?.at,
78
+ first:history.find(h=>h.event==="approved" || h.event==="changes_requested")?.event};
79
+ });
80
+ // PR readiness requires every linked task; an earlier member's approval is not the PR approval.
81
+ const all = (key:"approval"|"proposed"|"done") => timelines.length && timelines.every(t=>t[key]!==undefined);
82
+ const firstApproval = all("approval") ? Math.max(...timelines.map(t=>t.approval!)) : undefined;
83
+ const proposed = all("proposed") ? Math.min(...timelines.map(t=>t.proposed!)) : undefined;
84
+ const done = all("done") ? Math.max(...timelines.map(t=>t.done!)) : undefined;
85
+ const findings = new Set(histories.flatMap(h => h.reviewFindings?.items.filter(f => f.severity === "important").map(f => f.id) ?? []));
86
+ const cost = costs.rows.find(c => identity(c.repo, c.pr) === identity(pr.repo, pr.pr));
87
+ const ciCost = cost ?? costMetrics([{repo:pr.repo,pr:pr.pr,history:[]}],[pr]).rows[0];
88
+ const boardKnown = known(input.tasks) && linked.length > 0;
89
+ const delta = (end: number | undefined, start: number | undefined) => end !== undefined && start !== undefined && end >= start ? end - start : null;
90
+ return { repo: pr.repo.toLowerCase(), pr: pr.pr, tasks: linked.map(t => t.id).sort((a,b) => a-b), mergedAt: pr.mergedAt!,
91
+ proposeToApproveMs: boardKnown ? delta(firstApproval, proposed) : null,
92
+ doneToMergeMs: boardKnown ? delta(pr.mergedAt, done) : null,
93
+ reviewRounds: boardKnown ? cost?.reviewRounds ?? null : null,
94
+ firstPassApproval: boardKnown && timelines.every(t=>t.first) ? Number(timelines.every(t=>t.first === "approved")) : null,
95
+ escalations: boardKnown ? histories.filter(h => h.event === "escalated").length : null,
96
+ importantFindings: boardKnown ? findings.size : null,
97
+ connectorThreadsAfterApproval: firstApproval !== undefined && pr.connectorThreads !== undefined ? pr.connectorThreads.filter(at => at > firstApproval && at <= input.until).length : null,
98
+ bugIssuesWithin14d: pr.bugIssues?.filter(issue => issue.at >= pr.mergedAt! && issue.at <= Math.min(input.until,pr.mergedAt! + 14 * 86400000)).length ?? null,
99
+ bugObservationDays: Math.min(14, Math.max(0, (input.until - pr.mergedAt!) / 86400000)),
100
+ firstPassCi: pr.firstPassCi === undefined ? null : Number(pr.firstPassCi),
101
+ ciRuns: pr.complete ? ciCost?.ciRuns ?? null : null,
102
+ ciReruns: pr.complete ? ciCost?.ciReruns ?? null : null,
103
+ sameHeadReruns: pr.sameHeadReruns ?? null,
104
+ };
105
+ });
106
+ const accounting = events.filter(e => {
107
+ if (e.type === "task") return positive(e.id) && /^[a-z_]{1,32}$/.test(e.class) && !Object.hasOwn(Object.prototype,e.class);
108
+ if (e.type !== "tokens" && e.type !== "usage" && e.type !== "turn_end") return false;
109
+ if (!validPeer(e.peer) || e.task !== undefined && !positive(e.task)) return false;
110
+ if (e.type === "tokens") return finite(e.n);
111
+ if (e.type === "turn_end") return finite(e.ms);
112
+ return typeof e.id === "string" && typeof e.source === "string";
113
+ });
114
+ const taskReport = summarizeByTask(accounting);
115
+ const costPerTask = Object.entries(taskReport.tasks).filter(([id]) => positive(Number(id))).map(([id,t]) => ({ task: Number(id), pii: Number(!!t.pii || !!tasks.find(x => x.id === Number(id))?.pii), peers: peers(t.peers) }));
116
+ const costPerPr = rows.map(row => {
117
+ const linked = new Set(row.tasks);
118
+ const selected = accounting.filter(e => "task" in e && typeof e.task === "number" && linked.has(e.task));
119
+ const summary = summarizeByTask(selected);
120
+ const merged: Record<string, TaskPeerReport> = {};
121
+ // Reuse the report's class grouping solely as an accumulator, never expose the class text.
122
+ for (const [id,p] of Object.entries(summary.classes).flatMap(([,c]) => Object.entries(c.peers))) {
123
+ const previous = merged[id];
124
+ if (!previous) merged[id] = structuredClone(p);
125
+ else {
126
+ previous.tokens = previous.tokens === null || p.tokens === null ? null : previous.tokens + p.tokens;
127
+ for (const k of Object.keys(previous.usage) as (keyof TaskPeerReport["usage"])[]) {
128
+ const a = previous.usage[k], b = p.usage[k];
129
+ previous.usage[k] = (a === null || b === null ? null : a + b) as never;
130
+ }
131
+ }
132
+ }
133
+ return { repo: row.repo, pr: row.pr, peers: row.tasks.length && known(input.tasks) ? peers(merged) : null };
134
+ });
135
+ const logs = input.log.data;
136
+ const countLog = (pattern: RegExp) => logs.filter(line => pattern.test(line)).length;
137
+ const eventCount = (type: string) => events.filter(e => e.type === type).length;
138
+ const eventMetric = (type: string) => metric(eventCount(type), known(input.events), events.length);
139
+ const logMetric = (pattern: RegExp) => metric(countLog(pattern), known(input.log), logs.length);
140
+ const gates = {
141
+ S1: /spec acceptance|spec.*(?:missing|changed|unknown|accepted|edited)/i, S2: /plan conformance/i,
142
+ S3: /important finding/i, S4: /test integrity:/i, S9: /plan not reviewed|plan review issue|planner plan/i,
143
+ };
144
+ const advice = logs.filter(line => /SDLC .* advice:|premature review:|plan.*advice:|task .*: (?:plan not reviewed since |plan review issue )/.test(line));
145
+ return {
146
+ schemaVersion: 1, since: input.since, until: input.until,
147
+ sources: Object.fromEntries((["events","tasks","prs","log","features"] as const).map(key => [key, { available: input[key].available, complete: input[key].complete, records: input[key].data.length }])),
148
+ flow: { prs: rows, coverage: { mergedPrs: rows.length, linkedPrs: rows.filter(r => r.tasks.length).length } },
149
+ quality: { adviseBlockers: Object.fromEntries(Object.entries(gates).map(([gate,re]) => [gate, metric(advice.filter(line => re.test(line)).length, known(input.log), advice.length)])), realBlockers: null },
150
+ cost: { eventSourceComplete: known(input.events), usageComplete: known(input.events) && accounting.some(e=>e.type === "usage") && summarize(accounting).usage.completeCoverage, attributionComplete: known(input.events) && accounting.filter(e=>e.type === "tokens" || e.type === "usage").every(e=>e.attribution !== undefined && e.attribution !== "unattributed" && e.task !== undefined), perTask: costPerTask, perPr: costPerPr, unattributed: peers(taskReport.unattributed.peers), beforeAttribution: peers(taskReport.beforeAttribution.peers) },
151
+ delivery: { envelopeToDeliveryMs: percentiles([]), digests: {value: input.journal && known(input.journal) ? input.journal.data.digests : null, scope: "retained_journal"}, overflow: eventMetric("overflow"), undeliverable: eventMetric("undeliverable"), held: {value: input.journal && known(input.journal) ? input.journal.data.held : null, scope: "retained_journal"}, emptyInboxPollingStreaks: logMetric(/is polling an empty inbox with no task \(\d+ empty reads in \d+ min\)/) },
152
+ performance: { controlRequestMs: percentiles([]), connectionTimeouts: null, snapshotBudgetExhaustions: null, snapshotBudgetNotices: logMetric(/snapshot unavailable:.*budget/i), cpu: percentiles([]), rss: percentiles([]),
153
+ errors: { database: logMetric(/database contention|SQLITE_BUSY/), storage: logMetric(/delivery storage|journal unavailable/), transport: logMetric(/transport.*(?:failed|error)|connection.*(?:failed|error)/i) } },
154
+ adoption: input.features.data.map(feature => ({ id: feature.id, events: feature.types && known(input.events) ? events.filter(e => feature.types!.includes(e.type) && (!feature.taskEvents || e.type === "task" && feature.taskEvents.includes(e.event))).length : null })),
155
+ journalSource: input.journal ? {available:input.journal.available,complete:input.journal.complete,rows:input.journal.data.rows} : {available:false,complete:false,rows:0},
156
+ piiTasks: known(input.tasks) ? tasks.filter(t => t.pii).length : null,
157
+ };
158
+ }
159
+ export type DogfoodScorecard = ReturnType<typeof dogfoodScorecard>;
160
+ /** A stable readable receipt; null is explicitly unknown in both formats. */
161
+ export function formatDogfood(report: DogfoodScorecard): string[] {
162
+ return JSON.stringify(report, (_key,value) => value === null ? "unknown" : value, 2).split("\n");
163
+ }
@@ -14,7 +14,7 @@ export interface Envelope {
14
14
  kind: Kind;
15
15
  priority: Priority;
16
16
  body: string; // agent conclusions only, never tool noise
17
- refs?: { repo?: string; branch?: string; commit?: string; paths?: string[]; task?: string; reviewRevision?: string; taskOffer?: number; taskCancelled?: number; taskRecipient?: "owner" | "reviewer" | "planner"; backlog?: number[]; supervision?: boolean; supervisionKey?: string; boundedCheck?: string; budgetResume?: string; budgetResumeMoves?: { id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]; budgetResumeEarlier?: string[] };
17
+ refs?: { repo?: string; branch?: string; commit?: string; paths?: string[]; task?: string; reviewRevision?: string; taskOffer?: number; taskCancelled?: number; taskRecipient?: "owner" | "reviewer" | "planner"; backlog?: number[]; supervision?: boolean; supervisionKey?: string; completedFor?: number; boundedCheck?: string; budgetResume?: string; budgetResumeMoves?: { id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]; budgetResumeEarlier?: string[] };
18
18
  /** The body must not be shown outside its recipients: console tail and log print a stub instead (PII tasks). */
19
19
  private?: boolean;
20
20
  ts: number;
package/src/hub/events.ts CHANGED
@@ -13,6 +13,7 @@ export const EVENTS_SCHEMA = 1;
13
13
  * ids, routing and sizes, tasks carry ids and states, so the file can be exported without leaking what agents wrote.
14
14
  */
15
15
  export type HubEvent =
16
+ | { type: "release_authorization"; by: "user"; authorization: string; commit: string; version: string; docsDigest: string; documents: number }
16
17
  | { type: "permission_mode"; peer: string; from: import("./permission-mode.ts").PermissionMode; to: import("./permission-mode.ts").PermissionMode }
17
18
  | { type: "role"; peer?: string; task?: number; actor: string; stage: "prepared" | "assigned"; roles: { id: string; layer: "shipped" | "project"; hash: string }[] }
18
19
  /** A settings change (#269): the registry key, the bounded registry values before/after and their source; an optional person reason is sanitized and capped at 400 UTF-8 bytes. */
@@ -50,7 +51,7 @@ export type HubEvent =
50
51
  | { type: "hook_stats"; peer: string; n: number; startupMs: number; hubMs: number; maxStartupMs: number }
51
52
  | { type: "cohort"; id: number; event: "formed" | "joined" | "lifted"; silent: boolean; tasks: number[]; owners: string[] }
52
53
  | ({ type: "tokens"; peer: string; n: number } & Partial<TaskAttribution>)
53
- | ({ type: "usage"; peer: string; source: "omniroute" | "claude_transcript"; id: string; measuredAt?: string; requestedModel?: string; servedModel?: string; provider?: string; inputTokens?: number; outputTokens?: number; cacheReadTokens?: number; cacheWriteTokens?: number; totalTokens?: number } & Partial<TaskAttribution>)
54
+ | ({ type: "usage"; peer: string; source: "omniroute" | "claude_transcript" | "codex_appserver"; id: string; reasoningTokens?: number; measuredAt?: string; requestedModel?: string; servedModel?: string; provider?: string; inputTokens?: number; outputTokens?: number; cacheReadTokens?: number; cacheWriteTokens?: number; totalTokens?: number } & Partial<TaskAttribution>)
54
55
  | { type: "planning"; by: string; task?: number; release?: number; fields: string[]; revision: number }
55
56
  | { type: "task"; id: number; event: string; by: string; state: string; owner: string | null; reviewer: string | null; class: string; profile?: string; profileClass?: string; role?: string; roleIdentity?: { id: string; layer: "shipped" | "project"; hash: string }; pii: boolean; reason?: string; release?: number | null; priority?: number | null; plannedStart?: number | null; due?: number | null }
56
57
  | { type: "overlap"; task: number; owner: string; others: { task: number; owner: string; paths: string[]; symbols?: string[] }[] }
@@ -58,7 +59,15 @@ export type HubEvent =
58
59
  | ({ type: "pii_screen" } & ScreenRecord)
59
60
  | { type: "conflict"; peer: string; task?: number; other: number; owner: string; paths: string[]; concurrent: boolean; turns?: [string, string] }
60
61
  | { type: "context_pressure"; peer: string; source: string; measuredAt: number; used: number; window: number | null }
61
- | { type: "quota"; peer: string; windows: { id: string; used: number; resetsAt?: number }[]; hard: boolean; measuredAt?: string };
62
+ | { type: "quota"; peer: string; windows: { id: string; used: number; resetsAt?: number }[]; hard: boolean; measuredAt?: string }
63
+ /**
64
+ * The limit ledger (#683 U1): additive, ids and numbers only, no PROTOCOL bump (a reader that does not know a type ignores it).
65
+ * `by` is who ended or began the pause: the coordinator (`hub`) or the console user (`person`, an override).
66
+ */
67
+ | { type: "budget_pause"; peer: string; reason: string; window: string; resetsAt: number; by: "hub" | "person" }
68
+ | { type: "budget_resume"; peer: string; reason: string; window: string; resetsAt: number; by: "hub" | "person" }
69
+ /** A window's reset, derived from two readings of one window id: `resetsAt` moved forward, or `used` fell by 0.5 or more. */
70
+ | { type: "quota_reset"; peer: string; window: string; cause: "resets_at_moved" | "used_dropped"; resetsAt?: number; previousResetsAt?: number; used: number; previousUsed: number; measuredAt?: string };
62
71
 
63
72
  export type StampedEvent = HubEvent & { v: number; at: string };
64
73
 
@@ -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";
@@ -103,7 +104,8 @@ export const TASK_TOOLS: HubTool[] = [
103
104
  export const PLANNER_REVIEW_TOOLS: HubTool[] = [
104
105
  tool("hub_planner_groom", "Read a bounded planner grooming digest of dependencies, repository-scoped overlaps, WIP and blockers. No board change; unavailable evidence stays unknown.", {}),
105
106
  tool("hub_plan_review", "Record a planner review against the task planning revision and host-read issue edit time. Configured planner or person only; never accepts caller issue timestamps.", { id, revision: { type: "integer", minimum: 0 }, reason: { ...str, maxLength: 1000 }, ...costPlanProperties }, ["id", "revision", "reason"]),
106
- tool("hub_sdlc_costs", "Read bounded review and CI cost metrics for linked merged PRs. Planner/person only; metadata only, no board writes. S7 owns automatic band proposals.", { repo: str }),
107
+ tool("hub_sdlc_costs", "Read bounded review and CI cost metrics for linked merged PRs. Planner/person only; metadata only, no board writes.", { repo: str }),
108
+ tool("hub_sdlc_metrics", "Read SDLC metrics from board history: time from acceptance to plan, first review and merge, first-pass approval and rework rounds, first-pass CI and same-head reruns, stale-head re-reviews, stalls, and the control band tiers when sdlc.toml sets [bands]. Planner/person only; read-only, no model and no GitHub issue.", { since: str }),
107
109
  tool("hub_backlog_return_untouched", "Return accepted but untouched work to the backlog with its history and planning retained. Refuses execution/branch/commit evidence; planner or person only.", { id, revision: { type: "integer", minimum: 0 }, reason: { ...str, maxLength: 1000 } }, ["id", "revision", "reason"]),
108
110
  ];
109
111
  TASK_TOOLS.push(...PLANNER_REVIEW_TOOLS);
@@ -191,12 +193,21 @@ export function profileTaskTools(profile: ProfileSelection = "dev", stages?: rea
191
193
  if (criteria) {
192
194
  const { phase, ...current } = fields as { phase: { properties: Record<string, unknown> } } & Record<string, unknown>;
193
195
  const { tasks: _retiredImport, ...checklist } = phase.properties;
194
- fields = { ...current, criteria: { ...phase, properties: checklist, required: ["id", "criteria"] } };
196
+ fields = { ...current, redFirst: redFirstSchema, criteria: { ...phase, properties: checklist, required: ["id", "criteria"] } };
195
197
  }
196
198
  properties.receipt = { ...receipt, properties: fields };
197
199
  }
198
200
  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
201
  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")])) } };
202
+ if (customProfile && tool.name === "hub_task_done" && data.name !== "dev") {
203
+ const summaryProp = properties.summary as { type: string; description: string };
204
+ properties.summary = {
205
+ ...summaryProp,
206
+ description: data.name === "research" || data.name === "writing"
207
+ ? "changed deliverables, provenance, checks and unknowns"
208
+ : "what changed, why, and the check or verification you ran with its result",
209
+ };
210
+ }
200
211
  if (customProfile && tool.name === "hub_backlog_split") {
201
212
  const parts = properties.parts as { items: { properties: Record<string, unknown> } };
202
213
  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 +221,18 @@ export function profileTaskTools(profile: ProfileSelection = "dev", stages?: rea
210
221
  const parts = properties.parts as { items: { properties: { plan: { properties: Record<string, unknown> } } } };
211
222
  properties.parts = { ...parts, items: { ...parts.items, properties: { ...parts.items.properties, plan: { ...parts.items.properties.plan, properties: { ...parts.items.properties.plan.properties, ...costPlanProperties, ...evidencePlanProperties } } } } };
212
223
  }
213
- return { ...tool, ...(customProfile && tool.name === "hub_task_propose" ? { description: tool.description + ` Selected profile ${data.name}: ${Object.keys(data.classes).join(", ")}. Routing uses their existing base classes.` } : {}), inputSchema: { ...tool.inputSchema, properties } };
224
+ let description = tool.description;
225
+ if (customProfile && tool.name === "hub_task_propose") {
226
+ description = tool.description + ` Selected profile ${data.name}: ${Object.keys(data.classes).join(", ")}. Routing uses their existing base classes.`;
227
+ } else if (customProfile && tool.name === "hub_review" && data.name !== "dev") {
228
+ const reviewAction = data.name === "research" || data.name === "writing"
229
+ ? "assess scope, provenance and unsupported claims against the task's plan or detail"
230
+ : "assess the deliverables against the task's plan or detail";
231
+ 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.`;
232
+ } else if (customProfile && tool.name === "hub_task_done" && data.name !== "dev") {
233
+ const summaryLabel = data.name === "research" || data.name === "writing" ? "deliverables, provenance and checks" : "deliverables and checks";
234
+ 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.`;
235
+ }
236
+ return { ...tool, description, inputSchema: { ...tool.inputSchema, properties } };
214
237
  });
215
238
  }
@@ -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
+ }