@staix/agent-hub 0.34.0 → 0.35.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 (161) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +35 -6
  3. package/docs/adding-an-ai-tool.md +2 -2
  4. package/docs/addons/claude-mem.md +4 -4
  5. package/docs/addons/open-code-review.md +1 -1
  6. package/docs/agent-notes/README.md +6 -0
  7. package/docs/agent-notes/adapters.md +9 -5
  8. package/docs/agent-notes/budget.md +1 -1
  9. package/docs/agent-notes/bus.md +1 -1
  10. package/docs/agent-notes/daemon.md +4 -3
  11. package/docs/agent-notes/local-worker.md +1 -1
  12. package/docs/agent-notes/models.md +2 -0
  13. package/docs/agent-notes/tasks.md +1 -1
  14. package/docs/agent-notes/tests.md +5 -0
  15. package/docs/design/adapters.md +110 -2
  16. package/docs/design/agent-hub-architecture.md +63 -1
  17. package/docs/design/agent-hub.md +6 -0
  18. package/docs/design/bus.md +18 -0
  19. package/docs/design/execution-reference.md +146 -0
  20. package/docs/design/task-board.md +10 -0
  21. package/docs/dogfood.md +33 -12
  22. package/docs/events.md +38 -20
  23. package/docs/memory.md +21 -7
  24. package/docs/operations.md +539 -24
  25. package/docs/quickstart.md +18 -4
  26. package/docs/research.md +71 -13
  27. package/docs/roles.md +1 -1
  28. package/docs/sdlc.md +9 -7
  29. package/docs/security.md +87 -11
  30. package/docs/smoke.md +14 -0
  31. package/docs/specs/2026-09-19-agent-hub-design.md +8 -3
  32. package/docs/verification/2026-11-07-0.34.1.md +54 -0
  33. package/docs/verification/2026-11-07-compat-python-startup-1248.md +51 -0
  34. package/docs/verification/2026-11-07-compat-receipt-harness-1248.md +105 -0
  35. package/docs/verification/2026-11-07-compat-receipt-producers-1248.md +96 -0
  36. package/docs/verification/2026-11-07-compat-workflow-1248.md +94 -0
  37. package/docs/verification/2026-11-07-daemon-startup-1232.md +169 -0
  38. package/docs/verification/2026-11-07-jcode-receipt-reconstruction-1248.md +118 -0
  39. package/docs/verification/2026-11-07-kimi-delayed-usage.md +34 -0
  40. package/docs/verification/2026-11-07-kimi-review-watch-1284.md +115 -0
  41. package/docs/verification/2026-11-08-agy-1.5.3.md +96 -0
  42. package/docs/verification/2026-11-08-agy-acp-t0.md +56 -0
  43. package/docs/verification/2026-11-08-hub-visual-1058.md +161 -0
  44. package/docs/verification/2026-11-08-jcode-0991-model-setter-1317.md +143 -0
  45. package/docs/verification/2026-11-08-kimi-252-model-setter-1317.md +137 -0
  46. package/docs/verification/2026-11-08-kimi-tui-completion-1282.md +45 -0
  47. package/docs/verification/2026-11-08-kimi-wire-yield-1283.md +31 -0
  48. package/docs/verification/2026-11-08-live-model-override-1317.md +250 -0
  49. package/docs/verification/2026-11-08-qwen-0269-acp.md +138 -0
  50. package/docs/verification/2026-11-08-qwen-0269-model-setter-1317.md +91 -0
  51. package/docs/verification/2026-11-09-0.35.0.md +129 -0
  52. package/docs/verification/2026-11-09-headless-records-1320.md +157 -0
  53. package/docs/verification/claude-permission-transport-native.py +10 -3
  54. package/docs/verification/claude-shell-wrapper-native.py +8 -8
  55. package/docs/verified.json +245 -15
  56. package/package.json +3 -3
  57. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  58. package/plugins/agent-hub/kimi-hook.js +20397 -0
  59. package/plugins/agent-hub/kimi-review-policy-worker.js +217 -0
  60. package/plugins/agent-hub/server.js +2701 -664
  61. package/scripts/check-docs.mjs +12 -1
  62. package/src/adapters/acp-model-policy.ts +56 -0
  63. package/src/adapters/acp.ts +175 -5
  64. package/src/adapters/agy/entry.ts +8 -5
  65. package/src/adapters/agy/hooks.ts +65 -15
  66. package/src/adapters/channel-peer.ts +33 -1
  67. package/src/adapters/claude-implementer.ts +16 -3
  68. package/src/adapters/codex-appserver.ts +118 -8
  69. package/src/adapters/codex-review-isolation.ts +3 -1
  70. package/src/adapters/compat.ts +11 -6
  71. package/src/adapters/definitions/builtin-metadata.ts +8 -1
  72. package/src/adapters/definitions/native-cli-entry-kimi.ts +76 -7
  73. package/src/adapters/definitions/native-cli-preview-common.ts +2 -2
  74. package/src/adapters/definitions/native-cli-preview-kimi.ts +9 -2
  75. package/src/adapters/definitions/reviewer-variants.ts +3 -1
  76. package/src/adapters/kimi-review-policy-worker.js +2 -0
  77. package/src/adapters/kimi-review-policy-worker.ts +85 -0
  78. package/src/adapters/kimi-review-policy.ts +68 -0
  79. package/src/adapters/kimi-review.ts +41 -41
  80. package/src/adapters/kimi-tui-facts.ts +153 -0
  81. package/src/adapters/kimi-wire-usage.ts +33 -8
  82. package/src/adapters/local-worker.ts +59 -20
  83. package/src/adapters/model-token.ts +3 -0
  84. package/src/adapters/pi.ts +27 -5
  85. package/src/adapters/registry-types.ts +34 -3
  86. package/src/adapters/registry.ts +1 -0
  87. package/src/adapters/reviewer-policy-watch.ts +86 -0
  88. package/src/adapters/reviewer-print.ts +80 -18
  89. package/src/cli/agents.ts +1 -1
  90. package/src/cli/facts-hook.ts +4 -2
  91. package/src/cli/help.ts +3 -2
  92. package/src/cli/identity.ts +3 -0
  93. package/src/cli/kimi-facts-hook.ts +11 -0
  94. package/src/cli/kimi-plugin.ts +157 -0
  95. package/src/cli/main.ts +48 -9
  96. package/src/cli/model.ts +52 -0
  97. package/src/cli/output.ts +23 -1
  98. package/src/cli/recovery-package.ts +15 -7
  99. package/src/cli/reset.ts +1 -1
  100. package/src/cli/status-lines.ts +8 -4
  101. package/src/hub/budget.ts +1 -1
  102. package/src/hub/bus.ts +40 -2
  103. package/src/hub/code-facts.ts +10 -3
  104. package/src/hub/completions.ts +3 -1
  105. package/src/hub/config-apply.ts +2 -1
  106. package/src/hub/control-client.ts +1 -1
  107. package/src/hub/daemon.ts +1050 -106
  108. package/src/hub/deliverable-files.ts +38 -5
  109. package/src/hub/dogfood-profile.ts +1 -1
  110. package/src/hub/envelope.ts +1 -1
  111. package/src/hub/events.ts +28 -18
  112. package/src/hub/execution-lineage.ts +120 -0
  113. package/src/hub/execution-reference.ts +36 -0
  114. package/src/hub/hub-tools.ts +21 -2
  115. package/src/hub/instances.ts +4 -3
  116. package/src/hub/kimi-events.ts +46 -0
  117. package/src/hub/kimi-usage.ts +132 -29
  118. package/src/hub/lifecycle.ts +31 -15
  119. package/src/hub/limits.ts +13 -4
  120. package/src/hub/manager.ts +15 -2
  121. package/src/hub/memory-policy.ts +2 -1
  122. package/src/hub/memory-sync-attestation.ts +54 -0
  123. package/src/hub/memory-sync-state.ts +20 -5
  124. package/src/hub/model-overrides.ts +115 -0
  125. package/src/hub/native-hook-launch.ts +12 -3
  126. package/src/hub/operational-view.ts +2 -2
  127. package/src/hub/peers.ts +27 -0
  128. package/src/hub/permission-mode.ts +1 -1
  129. package/src/hub/person-figures.ts +88 -0
  130. package/src/hub/report.ts +38 -4
  131. package/src/hub/research-dataset.ts +158 -0
  132. package/src/hub/research-memory.ts +68 -0
  133. package/src/hub/research-quality.ts +118 -0
  134. package/src/hub/research-record.ts +279 -0
  135. package/src/hub/research.ts +126 -27
  136. package/src/hub/restart.ts +14 -0
  137. package/src/hub/settings.ts +3 -0
  138. package/src/hub/startup-event-seeds.ts +51 -0
  139. package/src/hub/startup-readiness.ts +65 -0
  140. package/src/hub/startup-timing.ts +38 -0
  141. package/src/hub/tasks.ts +29 -9
  142. package/src/hub/ui.ts +9 -5
  143. package/src/hub/visual-assets.ts +50 -0
  144. package/src/hub/visual.ts +257 -0
  145. package/src/memory/brief.ts +169 -14
  146. package/src/memory/capture.ts +2 -1
  147. package/src/memory/recall.ts +46 -5
  148. package/src/memory/record-provenance.ts +82 -0
  149. package/src/memory/records.ts +116 -0
  150. package/src/models/route/adec.ts +190 -0
  151. package/src/models/route/config.ts +37 -3
  152. package/src/models/route/decision-evidence.ts +83 -0
  153. package/src/models/route/decision.ts +143 -0
  154. package/src/models/route/runtime.ts +13 -6
  155. package/src/ui/index.html +134 -6
  156. package/src/ui/plugin.ts +65 -4
  157. package/templates/AGENTS.block.md +1 -1
  158. package/templates/config.json +1 -1
  159. package/templates/profiles/general.json +1 -1
  160. package/templates/profiles/research.json +1 -1
  161. package/templates/profiles/writing.json +1 -1
@@ -0,0 +1,68 @@
1
+ import type { Counter, View } from "@staix/agent-mem";
2
+
3
+ /**
4
+ * #1279: the `memory` block's counter figures for one task, derived from agent-mem's own machine-local counter rows (`CandidateStore.counters`) and the entries they name. The hub stores nothing for
5
+ * them: the sidecar already holds `rendered`, `delivered` and `cited` per `{entry, task, delivery}`. A figure is a number of distinct entries, never a note id or text.
6
+ *
7
+ * Unknown is null, never 0:
8
+ * - With no counter row for the task at all, nothing was observed (the sidecar was off or unreadable, the task was private, or the rows aged out), and every figure is null. "None rendered" and
9
+ * "never observed" cannot be told apart from rows alone, so the first is not claimed.
10
+ * - `rendered`: distinct entries put into a delivery attributed to the task (briefs, recall prefaces).
11
+ * - `delivered`: of those, the ones whose delivery completed (journal acknowledgement). Null while none is known to have completed: a brief that never completes leaves it null.
12
+ * - `cited`: of the rendered entries, the ones a hub-screened done summary, message or `hub_remember` text named by `mem:<id>`. An id that was not rendered for the task is not counted. 0 is known
13
+ * once something was rendered, because the citation paths ran over the same task.
14
+ * - `failsExposed`, `failsCited`: the same two over entries of kind `fail`; null when a rendered entry can no longer be read, since its kind is then unknown.
15
+ * - `failRetrySignal`: exposed fail notes whose `about` intersects the task's plan and that were not cited by approval. A signal for a reviewer to read, never an outcome: it does not say the
16
+ * approach was retried.
17
+ * Ceiling: the sidecar keeps 10,000 counters per project for 90 days, so an old task can have lost rows (read as unknown); a citation outside the screened paths is not seen.
18
+ */
19
+ export interface MemoryFigures {
20
+ rendered: number | null; delivered: number | null; cited: number | null;
21
+ failsExposed: number | null; failsCited: number | null; failRetrySignal: number | null;
22
+ }
23
+ export const UNKNOWN_MEMORY_FIGURES: Readonly<MemoryFigures> = Object.freeze({ rendered: null, delivered: null, cited: null, failsExposed: null, failsCited: null, failRetrySignal: null });
24
+
25
+ export interface MemoryFigurePorts {
26
+ /** The counter rows of the project. */
27
+ counters: readonly Pick<Counter, "key" | "rendered" | "delivered" | "cited">[];
28
+ /** The task's key as the rows carry it (`taskKey(project, task)`). */
29
+ taskKey: string;
30
+ /** One entry's view, or undefined when it is hidden, gone or unreadable. */
31
+ view: (entryId: string) => Pick<View, "id" | "kind" | "about" | "supersededBy"> | undefined;
32
+ /** Of these fail notes, the ones whose `about` intersects the task's plan: the library's own `matchingPlanFails`. */
33
+ matchingPlan: <T extends Pick<View, "kind" | "about" | "supersededBy">>(fails: readonly T[]) => readonly T[];
34
+ }
35
+
36
+ const positive = (n: number | null | undefined): boolean => typeof n === "number" && n > 0;
37
+
38
+ export function memoryFigures(ports: MemoryFigurePorts): MemoryFigures {
39
+ const mine = ports.counters.filter(row => row.key.task === ports.taskKey);
40
+ const entries = (field: "rendered" | "delivered" | "cited") => new Set(mine.filter(row => positive(row[field])).map(row => row.key.entryId));
41
+ const rendered = entries("rendered");
42
+ if (!rendered.size) return { ...UNKNOWN_MEMORY_FIGURES };
43
+ // A delivery or a citation of an entry the task was never shown is not part of the task's exposure.
44
+ const delivered = [...entries("delivered")].filter(id => rendered.has(id)).length;
45
+ const cited = new Set([...entries("cited")].filter(id => rendered.has(id)));
46
+ const views = [...rendered].map(id => ({ id, view: ports.view(id) }));
47
+ const readable = views.filter((v): v is { id: string; view: NonNullable<typeof v.view> } => v.view !== undefined);
48
+ const complete = readable.length === views.length;
49
+ const fails = readable.filter(v => v.view.kind === "fail");
50
+ const uncited = fails.filter(v => !cited.has(v.id)).map(v => v.view);
51
+ return {
52
+ rendered: rendered.size,
53
+ delivered: delivered || null,
54
+ cited: cited.size,
55
+ failsExposed: complete ? fails.length : null,
56
+ failsCited: complete ? fails.filter(v => cited.has(v.id)).length : null,
57
+ failRetrySignal: complete ? ports.matchingPlan(uncited).length : null,
58
+ };
59
+ }
60
+
61
+ /**
62
+ * The evidence port of the LIVE writer: it answers for the task being recorded and says unknown for every other task. `taskRecords` builds a record for every terminal task in the log and the live
63
+ * writer keeps one, so without this each approval would compute (terminal tasks in the log) figures, each reading the sidecar and the store, on the daemon thread (#1297 review). `read` is called
64
+ * at most once per port, for `only`. A backfill passes its reader directly: it wants every task.
65
+ */
66
+ export function onlyTaskMemory(only: number, read: (task: number) => MemoryFigures): (task: number) => MemoryFigures {
67
+ return task => task === only ? read(task) : { ...UNKNOWN_MEMORY_FIGURES };
68
+ }
@@ -0,0 +1,118 @@
1
+ import type { StampedEvent } from "./events.ts";
2
+ import { familyOf as builtinFamilyOf } from "../adapters/registry.ts";
3
+ import { nearestRank } from "./research-record.ts";
4
+ import { latestTaskRecords, type ResearchRecord, type TaskRecord } from "./research.ts";
5
+
6
+ /**
7
+ * #1277 (#44 AC0): the telemetry quality report that lets a dataset carry its own coverage. Everything is a count, a share or a duration over the records and the event
8
+ * log of one window; the report holds no peer text, no model prose and no path. Peer ids are reduced to families where a family is known (the caller passes `familyOf`), and a
9
+ * value that cannot be derived is null, never 0, so a reader can tell "no data" from "none happened".
10
+ */
11
+ export const QUALITY_SCHEMA = "agent-hub.research-quality/v1";
12
+ /** A quota gap is a silence between two readings of one peer longer than this. */
13
+ export const QUOTA_GAP_MS = 60 * 60 * 1000;
14
+
15
+ export interface FieldCoverage { n: number; unknown: number }
16
+ export interface QuotaCoverage { readings: number; gaps: number; gapMs: number; stalenessP95Ms: number | null; clock: { event: number; measured: number } }
17
+ export interface OutcomeCounts { approved: number; cancelled: number; firstPass: number; changesRequested: number }
18
+ export interface QualityReport {
19
+ schema: typeof QUALITY_SCHEMA;
20
+ window: { since: string | null; until: string | null };
21
+ records: number;
22
+ /** Per v2 field: how many records carry a value and how many are unknown (null or absent). Blocks count per leaf. */
23
+ fields: Record<string, FieldCoverage>;
24
+ /** Quota readings per peer family: how many, the gaps over an hour, the 95th percentile of the time between readings, and where the reading's time came from. */
25
+ quota: Record<string, QuotaCoverage>;
26
+ /**
27
+ * Pauses and resumes per family, and `handoffs`: the owner moves a budget pause drove (a `reassigned` task event whose board reason is `budget`), counted once per move, so a task handed off twice counts twice.
28
+ * A pause that found no taker moves nothing and counts nothing. Null when the window holds no task event at all, since then the log cannot say whether anything moved (#802).
29
+ */
30
+ budget: { pauses: number; resumes: number; handoffs: number | null; byFamily: Record<string, { pauses: number; resumes: number }> };
31
+ /** Task and review outcomes per (peer family, class), by the family of the task's last owner. */
32
+ outcomes: Record<string, Record<string, OutcomeCounts>>;
33
+ /** Every event type seen in the window with its count, and the types this report knows that did not occur. */
34
+ events: { present: Record<string, number>; absent: string[] };
35
+ /** The share of attributed token increments by the attribution rule (#200) and the total; null when no tokens were counted. */
36
+ tokenAttribution: { total: number; share: Record<"delivery" | "single_open" | "unattributed", number | null> };
37
+ }
38
+
39
+ const FAMILY_FALLBACK = "unknown";
40
+ /** The event types the report reasons about: the ones whose absence in a window means something. */
41
+ export const EXPECTED_EVENTS = ["task", "tokens", "usage", "turn_end", "quota", "budget_pause", "budget_resume", "envelope", "memory_brief", "hub_start"] as const;
42
+
43
+ /** Fields counted as one value although they are objects: the findings tally is known or not as a whole. */
44
+ const WHOLE = new Set(["review.findings"]);
45
+ const leaves = (value: unknown, prefix: string, out: Map<string, unknown>): void => {
46
+ if (value !== null && typeof value === "object" && !Array.isArray(value) && !WHOLE.has(prefix)) { for (const [key, child] of Object.entries(value)) leaves(child, prefix ? `${prefix}.${key}` : key, out); return; }
47
+ out.set(prefix, value);
48
+ };
49
+ /** The v2 blocks whose leaves are counted; v1 fields predate the report and are not unknown by design. */
50
+ const BLOCKS = ["review", "delivery", "merge", "specs", "memory", "person", "handoffs"] as const;
51
+ const SCALARS = ["stalls", "cohort", "declines", "escalations", "cancelCause"] as const;
52
+
53
+ /** `since` and `until` are epoch ms; a record counts when its terminal time falls inside. */
54
+ export function buildResearchQuality(records: readonly ResearchRecord[], events: readonly StampedEvent[], window: { since?: number; until?: number } = {}, familyOf: (peer: string) => string | undefined = () => undefined): QualityReport {
55
+ const from = window.since ?? 0, to = window.until ?? Infinity;
56
+ const family = (peer: string | undefined) => (peer ? familyOf(peer) : undefined) ?? builtinFamilyOf(peer ?? "") ?? FAMILY_FALLBACK;
57
+ const inside = (at: string | undefined | null) => { const t = at ? Date.parse(at) : NaN; return Number.isFinite(t) && t >= from && t <= to; };
58
+ // Outcomes count every task (a v1 row is an approved one); the v2 fields are counted over v2 rows only, since a v1 row never had them.
59
+ const tasks = latestTaskRecords(records).filter(r => inside(r.closedAt ?? r.approvedAt));
60
+ const v2 = tasks.filter(r => r.schema === "agent-hub.research/v2");
61
+ const fields: Record<string, FieldCoverage> = {};
62
+ /** Unknown is null, absent, or a closed vocabulary's own `unknown` member. */
63
+ const note = (name: string, value: unknown) => { const f = fields[name] ??= { n: 0, unknown: 0 }; f.n++; if (value === null || value === undefined || value === "unknown") f.unknown++; };
64
+ for (const r of v2) {
65
+ const seen = new Map<string, unknown>();
66
+ for (const block of BLOCKS) leaves((r as unknown as Record<string, unknown>)[block] ?? null, block, seen);
67
+ // A block that is absent from the record counts as unknown at its own name.
68
+ for (const block of BLOCKS) if (![...seen.keys()].some(k => k.startsWith(`${block}.`) || k === block)) seen.set(block, null);
69
+ for (const key of SCALARS) seen.set(key, (r as unknown as Record<string, unknown>)[key] ?? (key === "cancelCause" && r.outcome !== "cancelled" ? undefined : null));
70
+ for (const [name, value] of seen) {
71
+ if (name === "cancelCause" && r.outcome !== "cancelled") continue;
72
+ note(name, value);
73
+ }
74
+ }
75
+ const windowEvents = events.filter(e => { const t = Date.parse(e.at); return Number.isFinite(t) && t >= from && t <= to; });
76
+ const present: Record<string, number> = {};
77
+ const quota: Record<string, { times: number[]; measured: number; event: number }> = {};
78
+ const budget: QualityReport["budget"] = { pauses: 0, resumes: 0, handoffs: null, byFamily: {} };
79
+ const attribution: Record<string, number> = { delivery: 0, single_open: 0, unattributed: 0 };
80
+ let tokenTotal = 0;
81
+ for (const e of windowEvents) {
82
+ present[e.type] = (present[e.type] ?? 0) + 1;
83
+ if (e.type === "task") {
84
+ budget.handoffs ??= 0;
85
+ if (e.event === "reassigned" && e.reason === "budget") budget.handoffs++;
86
+ } else if (e.type === "quota") {
87
+ const fam = family(e.peer), q = quota[fam] ??= { times: [], measured: 0, event: 0 };
88
+ const measured = typeof e.measuredAt === "string" ? Date.parse(e.measuredAt) : NaN;
89
+ if (Number.isFinite(measured)) { q.measured++; q.times.push(measured); } else { q.event++; q.times.push(Date.parse(e.at)); }
90
+ } else if (e.type === "budget_pause" || e.type === "budget_resume") {
91
+ const fam = family(e.peer), row = budget.byFamily[fam] ??= { pauses: 0, resumes: 0 };
92
+ if (e.type === "budget_pause") { budget.pauses++; row.pauses++; } else { budget.resumes++; row.resumes++; }
93
+ } else if (e.type === "tokens" && Number.isFinite(e.n) && e.n > 0) {
94
+ tokenTotal += e.n; const rule = e.attribution === "delivery" || e.attribution === "single_open" ? e.attribution : "unattributed"; attribution[rule]! += e.n;
95
+ }
96
+ }
97
+ const quotaOut: Record<string, QuotaCoverage> = {};
98
+ for (const [fam, q] of Object.entries(quota)) {
99
+ const times = q.times.filter(Number.isFinite).sort((a, b) => a - b), gaps: number[] = [];
100
+ for (let i = 1; i < times.length; i++) gaps.push(times[i]! - times[i - 1]!);
101
+ const over = gaps.filter(g => g > QUOTA_GAP_MS);
102
+ quotaOut[fam] = { readings: times.length, gaps: over.length, gapMs: over.reduce((sum, g) => sum + g, 0), stalenessP95Ms: nearestRank(gaps, 0.95), clock: { event: q.event, measured: q.measured } };
103
+ }
104
+ const outcomes: QualityReport["outcomes"] = {};
105
+ for (const r of tasks) {
106
+ const fam = family(r.owners.at(-1)), cls = r.class ?? "unknown", row = (outcomes[fam] ??= {})[cls] ??= { approved: 0, cancelled: 0, firstPass: 0, changesRequested: 0 };
107
+ row[r.outcome]++; if (r.firstPass) row.firstPass++; row.changesRequested += r.changesRequested;
108
+ }
109
+ const share = (n: number): number | null => tokenTotal > 0 ? n / tokenTotal : null;
110
+ return {
111
+ schema: QUALITY_SCHEMA,
112
+ window: { since: window.since !== undefined ? new Date(window.since).toISOString() : null, until: window.until !== undefined && Number.isFinite(window.until) ? new Date(window.until).toISOString() : null },
113
+ records: tasks.length, fields: Object.fromEntries(Object.entries(fields).sort(([a], [b]) => a.localeCompare(b))),
114
+ quota: Object.fromEntries(Object.entries(quotaOut).sort(([a], [b]) => a.localeCompare(b))), budget, outcomes,
115
+ events: { present: Object.fromEntries(Object.entries(present).sort(([a], [b]) => a.localeCompare(b))), absent: EXPECTED_EVENTS.filter(type => !(type in present)) },
116
+ tokenAttribution: { total: tokenTotal, share: { delivery: share(attribution.delivery!), single_open: share(attribution.single_open!), unattributed: share(attribution.unattributed!) } },
117
+ };
118
+ }
@@ -0,0 +1,279 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { MemoryFigures } from "./research-memory.ts";
4
+ import type { PersonFigures } from "./person-figures.ts";
5
+ import { cleanMergeCost } from "./cost-governance.ts";
6
+ import { openHubDatabase } from "./db-schema.ts";
7
+ import { taskSample, type MetricTask } from "./sdlc-metrics.ts";
8
+
9
+ /**
10
+ * #1277: the closed, text-free blocks research record v2 adds to a task record, and the evidence they are folded from.
11
+ * Everything here is a count, a duration, a boolean or a member of a closed vocabulary. A value that cannot be derived is `null`
12
+ * and is counted as unknown by the quality report, never 0. Nothing is copied from a title, note, path, rationale, message body or
13
+ * check output: the readers below pick named closed fields out of the board history and the delivery journal and drop the rest.
14
+ */
15
+ export const FINDING_PASSES = ["bugs", "security", "compliance"] as const;
16
+ export type FindingPass = (typeof FINDING_PASSES)[number];
17
+ const SEVERITIES = ["important", "nit"] as const;
18
+ const DISPOSITIONS = ["open", "fixed", "false_positive", "accepted"] as const;
19
+ const PLANNED_SCOPES = ["none", "delta", "full"] as const;
20
+ /** A peer family or any other id-like value from a store: anything else is untrusted text and reads as unknown. */
21
+ const ID = /^[a-z][a-z0-9-]{0,63}$/;
22
+ export const closedId = (value: unknown): string | null => typeof value === "string" && ID.test(value) ? value : null;
23
+ const count = (value: unknown): number | null => typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null;
24
+ const member = <T extends string>(values: readonly T[], value: unknown): T | undefined => (values as readonly string[]).includes(value as string) ? value as T : undefined;
25
+
26
+ export interface FindingTally { important: number; nit: number; open: number; fixed: number; falsePositive: number; accepted: number }
27
+ export type ReviewScope = "code" | "full" | "delta" | "none" | "unknown";
28
+ export interface ReviewBlock {
29
+ /** Substantive verdicts: approvals and change requests that are not evidence-only. (`reviewRounds` on the record counts times the task went to review; this counts what came back.) */
30
+ rounds: number | null;
31
+ firstPass: boolean | null;
32
+ /** Distinct findings by pass, each at its last disposition; null when no verdict carried a structured report. */
33
+ findings: Record<FindingPass, FindingTally> | null;
34
+ reviewerFamily: string | null;
35
+ ownerFamily: string | null;
36
+ scope: ReviewScope;
37
+ evidenceOnly: number | null;
38
+ context: "fresh" | "continuing" | "unknown";
39
+ staleHeadReReviews: number | null;
40
+ }
41
+ export interface Quantiles { p50: number | null; p95: number | null }
42
+ export interface DeliveryBlock {
43
+ /** Envelopes that named the task, from the events. */
44
+ count: number | null;
45
+ firstDeliveryMs: Quantiles;
46
+ needsReview: number | null; failed: number | null; discarded: number | null;
47
+ dropped: { fyi: number | null; hop: number | null };
48
+ }
49
+ export interface MergeBlock { ciRuns: number | null; ciReruns: number | null; githubReviews: number | null; doneToApprovedMs: number | null; approvedToMergedMs: number | null }
50
+ export interface SpecsBlock { covering: number | null; unchanged: number | null; acked: number | null; errors: number | null }
51
+ /** `rendered`, `delivered` and `cited` stay null until #1279 reports them. */
52
+ export interface MemoryBlock {
53
+ briefs: number | null; briefNotes: number | null; fails: number | null; rendered: number | null; delivered: number | null; cited: number | null;
54
+ /** #1279: fail notes among the entries rendered for the task, how many of them were cited, and the exposed ones the plan touches that were not cited. A signal to read, never an outcome. */
55
+ failsExposed: number | null; failsCited: number | null; failRetrySignal: number | null;
56
+ }
57
+ /** #1278: what the person was asked and did for the task (docs/research.md); every part is null when the log does not cover the task, never 0. Shape and rules: `src/hub/person-figures.ts`. */
58
+ export type PersonBlock = PersonFigures;
59
+ export interface HandoffsBlock { in: number | null; out: number | null }
60
+ export type CohortKind = "silent" | "advisory" | "none";
61
+ export const CANCEL_CAUSES = ["cancelled", "declined", "escalated", "stale", "unknown"] as const;
62
+ export type CancelCause = (typeof CANCEL_CAUSES)[number];
63
+
64
+ /** One board history entry, reduced to the closed fields v2 reads. */
65
+ export interface EvidenceEntry {
66
+ at: number; event: string;
67
+ evidenceOnly?: true; withheld?: true; sweep?: true;
68
+ reviewScope?: "code"; reviewContext?: "fresh" | "continuing"; reviewerFamily?: string;
69
+ requestHead?: string; verdictHead?: string; plannedScope?: (typeof PLANNED_SCOPES)[number];
70
+ findings?: { id: string; pass: FindingPass; severity: "important" | "nit"; disposition: (typeof DISPOSITIONS)[number] }[];
71
+ spec?: { complete: boolean; errors: number; unchanged: number; changed: number; acked: number };
72
+ /** An explicit closed cause recorded on a cancellation, never inferred from its note. */
73
+ cause?: CancelCause;
74
+ }
75
+ export interface EvidenceMerge { mergedAt: number | null; ciRuns: number | null; ciReruns: number | null; reviews: number | null }
76
+ export interface EvidenceTask { id: number; history: EvidenceEntry[]; merge?: EvidenceMerge; handoffs?: { in: number; out: number } }
77
+ export interface JournalRow { task: number; state: string; createdAt: number; updatedAt: number }
78
+ export interface ResearchEvidence {
79
+ tasks: ReadonlyMap<number, EvidenceTask>;
80
+ /** Absent: the journal was not read, so every journal-derived field is unknown. */
81
+ /**
82
+ * `coveredAfter`: a delivery created strictly after this time is in `rows` if it ever existed; null when that cannot be established. The journal prunes its oldest-updated terminal rows beyond a
83
+ * cap and keeps unresolved rows, review-package receipts and retry parents, so the oldest surviving row is no watermark (an old protected row survives while newer ones are gone).
84
+ */
85
+ journal?: { rows: readonly JournalRow[]; coveredAfter: number | null };
86
+ /** Whether the board was read at all: without it every board-derived field is unknown, also for a task the map lacks. */
87
+ board: boolean;
88
+ familyOf?: (peer: string) => string | undefined;
89
+ /** #1279: the counter figures of one task from agent-mem's sidecar; absent (backfill, no sidecar) leaves every counter figure unknown. */
90
+ memory?: (task: number) => MemoryFigures;
91
+ }
92
+ export const NO_EVIDENCE: ResearchEvidence = { tasks: new Map(), board: false };
93
+
94
+ const text = (value: unknown): Record<string, unknown> | undefined => value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
95
+
96
+ /**
97
+ * The closed cause a cancelled entry carries, or none. Only an exact member of `CANCEL_CAUSES` in a `cause` field counts. Today the board retains no such field (`Tasks.cancel` stores a free-text note and
98
+ * nothing closed), so every cancelled record reads `unknown`; a cause is never inferred from the note, from an earlier decline or escalation, or from who cancelled (ruling 2026-11-07 on #778).
99
+ */
100
+ export function cancelCauseOf(raw: unknown): CancelCause | undefined {
101
+ const cause = text(raw)?.cause;
102
+ return typeof cause === "string" && (CANCEL_CAUSES as readonly string[]).includes(cause) && cause !== "unknown" ? cause as CancelCause : undefined;
103
+ }
104
+
105
+ /** One history entry (a `HistoryEntry` or its parsed JSON) as closed evidence; undefined when it has no usable time or event. */
106
+ export function evidenceEntry(raw: unknown): EvidenceEntry | undefined {
107
+ const e = text(raw);
108
+ if (!e || typeof e.at !== "number" || !Number.isFinite(e.at) || e.at < 0 || typeof e.event !== "string" || e.event.length > 80) return undefined;
109
+ const out: EvidenceEntry = { at: e.at, event: e.event };
110
+ if (e.evidenceOnly === true) out.evidenceOnly = true;
111
+ if (e.withheld === true) out.withheld = true;
112
+ if (text(e.sweep)) out.sweep = true;
113
+ if (e.reviewScope === "code") out.reviewScope = "code";
114
+ if (e.reviewContext === "fresh" || e.reviewContext === "continuing") out.reviewContext = e.reviewContext;
115
+ const family = closedId(e.reviewerFamily);
116
+ if (family) out.reviewerFamily = family;
117
+ const cost = text(e.reviewCost), findings = text(e.reviewFindings);
118
+ if (typeof cost?.head === "string" && /^[0-9a-f]{40}$/.test(cost.head)) out.requestHead = cost.head;
119
+ const planned = member(PLANNED_SCOPES, text(cost?.review)?.scope);
120
+ if (planned) out.plannedScope = planned;
121
+ if (typeof findings?.head === "string" && /^[0-9a-f]{40}$/.test(findings.head)) out.verdictHead = findings.head;
122
+ if (Array.isArray(findings?.items)) {
123
+ out.findings = (findings.items as unknown[]).flatMap(item => {
124
+ const f = text(item), pass = member(FINDING_PASSES, f?.pass), severity = member(SEVERITIES, f?.severity), disposition = member(DISPOSITIONS, f?.disposition);
125
+ return f && typeof f.id === "string" && f.id.length <= 96 && pass && severity && disposition ? [{ id: f.id, pass, severity, disposition }] : [];
126
+ });
127
+ }
128
+ const spec = text(e.specReport);
129
+ if (spec) {
130
+ const list = (value: unknown) => Array.isArray(value) ? value.map(text).filter((row): row is Record<string, unknown> => !!row) : [];
131
+ const unchanged = list(spec.unchanged), changed = list(spec.changed);
132
+ out.spec = { complete: spec.complete === true, errors: count(spec.errors) ?? 0, unchanged: unchanged.length, changed: changed.length,
133
+ acked: unchanged.filter(row => typeof row.reason === "string" && row.reason.length > 0).length };
134
+ }
135
+ const cause = e.event === "cancelled" ? cancelCauseOf(e) : undefined;
136
+ if (cause) out.cause = cause;
137
+ return out;
138
+ }
139
+
140
+ /** A board task (live `Task` or a parsed row) as closed evidence. The title, detail, plan, refs and every note stay behind. */
141
+ export function evidenceTask(raw: { id: number; history?: unknown; sdlc?: unknown }, handoffs?: { in: number; out: number }): EvidenceTask {
142
+ const history = (Array.isArray(raw.history) ? raw.history : []).flatMap(entry => { const e = evidenceEntry(entry); return e ? [e] : []; }).sort((a, b) => a.at - b.at);
143
+ const merge = text(text(raw.sdlc)?.merge);
144
+ let mergeEvidence: EvidenceMerge | undefined;
145
+ if (merge?.state === "merged") {
146
+ const cost = cleanMergeCost(merge.cost), at = cost?.mergedAt ?? (typeof merge.at === "number" && Number.isSafeInteger(merge.at) ? merge.at : null);
147
+ mergeEvidence = { mergedAt: at, ciRuns: cost?.ciRuns ?? null, ciReruns: cost?.ciReruns ?? null, reviews: cost?.reviews ?? null };
148
+ }
149
+ return { id: raw.id, history, ...(mergeEvidence ? { merge: mergeEvidence } : {}), ...(handoffs ? { handoffs } : {}) };
150
+ }
151
+
152
+ /** Deliveries of one task from the journal rows: only their state and two times, never an envelope body. */
153
+ export function journalRow(task: unknown, state: unknown, createdAt: unknown, updatedAt: unknown): JournalRow | undefined {
154
+ const id = typeof task === "string" && /^[1-9]\d{0,9}$/.test(task) ? Number(task) : typeof task === "number" && Number.isSafeInteger(task) && task > 0 ? task : undefined;
155
+ return id !== undefined && typeof state === "string" && ID.test(state.replaceAll("_", "-")) && typeof createdAt === "number" && typeof updatedAt === "number" && Number.isFinite(createdAt) && Number.isFinite(updatedAt)
156
+ ? { task: id, state, createdAt, updatedAt } : undefined;
157
+ }
158
+
159
+ /**
160
+ * The board and journal of one project, read without writing: `hub.db` opens read-only through the same compatibility check the hub uses, and the journal's envelope bodies are
161
+ * never decoded (SQLite hands back only each original's task reference). A store that cannot be read leaves its part unknown, never an empty one.
162
+ */
163
+ export function readEvidence(stateDir: string, projectId: string, only?: number): ResearchEvidence {
164
+ const file = join(stateDir, "hub.db");
165
+ if (!existsSync(file)) return NO_EVIDENCE;
166
+ let db: ReturnType<typeof openHubDatabase> | undefined;
167
+ try { db = openHubDatabase(file, { readonly: true }); } catch { return NO_EVIDENCE; }
168
+ try {
169
+ const handoffIn = new Set<number>(), handoffOut = new Map<number, number>();
170
+ // `only` (the live writer, one task) narrows each read to that task; a backfill reads them all.
171
+ const one = only === undefined ? "" : " WHERE task_id = " + Number(only);
172
+ try { for (const r of db.query("SELECT task_id FROM task_handoff_origin" + one).all() as { task_id: number }[]) handoffIn.add(r.task_id); } catch { /* an older hub.db has no handoff table */ }
173
+ try { for (const r of db.query("SELECT local_task FROM task_handoff_outbound WHERE local_task IS NOT NULL" + (only === undefined ? "" : " AND local_task = " + Number(only))).all() as { local_task: number }[]) handoffOut.set(r.local_task, (handoffOut.get(r.local_task) ?? 0) + 1); } catch { /* same */ }
174
+ const tasks = new Map<number, EvidenceTask>();
175
+ let board = true;
176
+ try {
177
+ for (const row of db.query("SELECT id, history, sdlc FROM tasks" + (only === undefined ? "" : " WHERE id = " + Number(only))).all() as { id: number; history: string | null; sdlc: string | null }[]) {
178
+ const parse = (s: string | null) => { try { return s ? JSON.parse(s) : undefined; } catch { return undefined; } };
179
+ tasks.set(row.id, evidenceTask({ id: row.id, history: parse(row.history), sdlc: parse(row.sdlc) }, { in: handoffIn.has(row.id) ? 1 : 0, out: handoffOut.get(row.id) ?? 0 }));
180
+ }
181
+ } catch { board = false; }
182
+ let journal: ResearchEvidence["journal"];
183
+ try {
184
+ const rows = (db.query("SELECT d.state AS state, d.created_at AS createdAt, d.updated_at AS updatedAt, json_extract(e.value, '$.refs.task') AS task FROM deliveries d, json_each(d.originals) e WHERE d.project_id = ?" + (only === undefined ? "" : " AND CAST(json_extract(e.value, '$.refs.task') AS TEXT) = '" + Number(only) + "'")).all(projectId) as Record<string, unknown>[])
185
+ .flatMap(r => { const row = journalRow(r.task, r.state, r.createdAt, r.updatedAt); return row ? [row] : []; });
186
+ journal = { rows, coveredAfter: journalCoveredAfter(db, projectId) };
187
+ } catch { /* no journal table: delivery timing is unknown */ }
188
+ return { tasks, board, ...(journal ? { journal } : {}) };
189
+ } finally { db.close(); }
190
+ }
191
+
192
+ /**
193
+ * Why this bound is sound (#1290 review): pruning deletes only terminal rows, oldest `updated_at` first (ties by id), and never a protected one, so every pruned row's `updated_at` is at most the
194
+ * smallest `updated_at` among the terminal, unprotected rows that survive (W). A delivery is created no later than it is last updated, so a delivery created after W cannot be a pruned one.
195
+ * Protected rows are left out of W on purpose: including one (old, kept) would lower W and claim coverage the pruning did not keep. Unresolved rows are never pruned, so they need no bound.
196
+ * Unknown (null) when the journal's own review-input coverage is incomplete (legacy candidates are protected in memory only, where this reader cannot see them) or the tables cannot be read.
197
+ * No terminal row at all: nothing was kept and nothing needs bounding (-1, before any time).
198
+ */
199
+ function journalCoveredAfter(db: ReturnType<typeof openHubDatabase>, projectId: string): number | null {
200
+ try {
201
+ const meta = db.query("SELECT review_input_coverage_complete AS ok FROM delivery_meta WHERE project_id = ?").get(projectId) as { ok: number } | null;
202
+ if (meta?.ok !== 1) return null;
203
+ const terminal = "d.project_id = ? AND d.state IN ('completed','discarded','failed')";
204
+ const any = db.query(`SELECT COUNT(*) AS n FROM deliveries d WHERE ${terminal}`).get(projectId) as { n: number };
205
+ if (any.n === 0) return -1;
206
+ const unprotected = db.query(`SELECT MIN(d.updated_at) AS w FROM deliveries d WHERE ${terminal}
207
+ AND NOT EXISTS (SELECT 1 FROM json_each(d.originals) e WHERE json_extract(e.value,'$.from')='hub' AND json_extract(e.value,'$.kind')='review' AND json_extract(e.value,'$.refs.reviewPackage') GLOB 'review-*')
208
+ AND NOT EXISTS (SELECT 1 FROM deliveries c WHERE c.project_id = d.project_id AND c.previous_id = d.id)`).get(projectId) as { w: number | null };
209
+ return typeof unprotected.w === "number" && Number.isFinite(unprotected.w) ? unprotected.w : null;
210
+ } catch { return null; }
211
+ }
212
+
213
+ /** Nearest-rank quantile of whole numbers, or null for none. */
214
+ export function nearestRank(values: readonly number[], p: number): number | null {
215
+ if (!values.length) return null;
216
+ const sorted = [...values].sort((a, b) => a - b);
217
+ return sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil(p * sorted.length) - 1))]!;
218
+ }
219
+
220
+ const EMPTY_TALLY = (): FindingTally => ({ important: 0, nit: 0, open: 0, fixed: 0, falsePositive: 0, accepted: 0 });
221
+
222
+ /** The review block of a task. Board evidence missing (`task` undefined) leaves every board-derived field null. */
223
+ export function reviewBlock(task: EvidenceTask | undefined, board: boolean, ownerFamily: string | null, pii: boolean): ReviewBlock {
224
+ const blank: ReviewBlock = { rounds: null, firstPass: null, findings: null, reviewerFamily: null, ownerFamily, scope: "unknown", evidenceOnly: null, context: "unknown", staleHeadReReviews: null };
225
+ if (!task) return blank;
226
+ const entries = task.history;
227
+ const verdicts = entries.filter(e => e.event === "approved" || e.event === "changes_requested");
228
+ const view: MetricTask = { id: task.id, history: entries.map(e => ({ at: e.at, event: e.event, ...(e.requestHead ? { requestHead: e.requestHead } : {}), ...(e.verdictHead ? { verdictHead: e.verdictHead } : {}), ...(e.evidenceOnly ? { evidenceOnly: true as const } : {}) })) };
229
+ const sample = taskSample(view);
230
+ let findings: ReviewBlock["findings"] = null;
231
+ const reported = entries.filter(e => !e.withheld && e.findings);
232
+ if (!pii && reported.length) {
233
+ const last = new Map<string, NonNullable<EvidenceEntry["findings"]>[number]>();
234
+ for (const entry of reported) for (const f of entry.findings!) last.set(f.id, f);
235
+ findings = { bugs: EMPTY_TALLY(), security: EMPTY_TALLY(), compliance: EMPTY_TALLY() };
236
+ for (const f of last.values()) {
237
+ const tally = findings[f.pass];
238
+ tally[f.severity]++;
239
+ if (f.disposition === "open") tally.open++; else if (f.disposition === "fixed") tally.fixed++; else if (f.disposition === "false_positive") tally.falsePositive++; else tally.accepted++;
240
+ }
241
+ }
242
+ const lastVerdict = verdicts.at(-1), lastDone = entries.findLast(e => e.event === "done");
243
+ const scope: ReviewScope = lastVerdict?.reviewScope === "code" ? "code" : lastDone?.plannedScope ?? "unknown";
244
+ return {
245
+ rounds: board ? verdicts.filter(e => e.evidenceOnly !== true).length : null,
246
+ firstPass: sample.first_pass_approval === null ? null : sample.first_pass_approval === 1,
247
+ findings,
248
+ reviewerFamily: entries.findLast(e => e.reviewerFamily)?.reviewerFamily ?? null,
249
+ ownerFamily, scope,
250
+ evidenceOnly: board ? verdicts.filter(e => e.evidenceOnly === true).length : null,
251
+ context: lastVerdict?.reviewContext ?? "unknown",
252
+ staleHeadReReviews: sample.stale_head_re_reviews,
253
+ };
254
+ }
255
+
256
+ /** The merge block. `doneToApprovedMs` is from the events' own times, so it needs no board; the CI and merge figures come from the receipt the guarded merge wrote. */
257
+ export function mergeBlock(task: EvidenceTask | undefined, doneAt: number | null, approvedAt: number | null): MergeBlock {
258
+ const merge = task?.merge;
259
+ const gap = (from: number | null, to: number | null) => from !== null && to !== null && to >= from ? to - from : null;
260
+ return { ciRuns: merge?.ciRuns ?? null, ciReruns: merge?.ciReruns ?? null, githubReviews: merge?.reviews ?? null,
261
+ doneToApprovedMs: gap(doneAt, approvedAt), approvedToMergedMs: gap(approvedAt, merge?.mergedAt ?? null) };
262
+ }
263
+
264
+ /** The specs block from the latest done's report; an incomplete report is unknown, never zero. */
265
+ export function specsBlock(task: EvidenceTask | undefined, pii: boolean): SpecsBlock {
266
+ const report = pii ? undefined : task?.history.findLast(e => e.spec)?.spec;
267
+ if (!report || !report.complete) return { covering: null, unchanged: null, acked: null, errors: null };
268
+ return { covering: report.unchanged + report.changed, unchanged: report.unchanged, acked: report.acked, errors: report.errors };
269
+ }
270
+
271
+ /** Delivery states and first-delivery times of one task from the journal rows in `[from, to]` (the task's life plus a settling hour); everything is null when the journal does not reach back that far. */
272
+ export function journalFigures(evidence: ResearchEvidence, task: number, fromMs: number | null, toMs: number): Pick<DeliveryBlock, "firstDeliveryMs" | "needsReview" | "failed" | "discarded"> {
273
+ const journal = evidence.journal;
274
+ if (!journal || journal.coveredAfter === null || fromMs === null || fromMs <= journal.coveredAfter) return { firstDeliveryMs: { p50: null, p95: null }, needsReview: null, failed: null, discarded: null };
275
+ const rows = journal.rows.filter(r => r.task === task && r.createdAt >= fromMs && r.createdAt <= toMs + 3_600_000);
276
+ const latencies = rows.filter(r => r.state === "completed" && r.updatedAt >= r.createdAt).map(r => r.updatedAt - r.createdAt);
277
+ const n = (state: string) => rows.filter(r => r.state === state).length;
278
+ return { firstDeliveryMs: { p50: nearestRank(latencies, 0.5), p95: nearestRank(latencies, 0.95) }, needsReview: n("needs_review"), failed: n("failed"), discarded: n("discarded") };
279
+ }