@jmtrin/opencode-kevin 1.1.0 → 1.2.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.
@@ -0,0 +1,158 @@
1
+ // v1.2.0 (K12-003 / plan §4.2, D12-05) — snapshot flush (pure serialization + atomic write).
2
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ const CAP_BYTES = 512 * 1024;
5
+ const SNAP_FILES = [
6
+ "proposals.json",
7
+ "conflicts.json",
8
+ "health.json",
9
+ "meta.json",
10
+ ];
11
+ function byteLen(s) {
12
+ return Buffer.byteLength(s, "utf8");
13
+ }
14
+ function atomicWrite(target, content) {
15
+ const tmp = `${target}.tmp`;
16
+ writeFileSync(tmp, content, "utf8");
17
+ renameSync(tmp, target);
18
+ }
19
+ function truncateProposals(proposals, cap) {
20
+ // Estimate JSON overhead without diffs to compute available budget.
21
+ // We truncate diff fields proportionally to fit cap.
22
+ const serialized = JSON.stringify(proposals);
23
+ if (byteLen(serialized) <= cap)
24
+ return proposals;
25
+ // Compute total diff length
26
+ const totalDiff = proposals.reduce((acc, p) => acc + byteLen(p.diff), 0);
27
+ if (totalDiff === 0)
28
+ return proposals;
29
+ const overhead = byteLen(serialized) - totalDiff;
30
+ const budget = Math.max(0, cap - overhead - 1024); // leave margin
31
+ // Distribute budget proportionally
32
+ const out = [];
33
+ for (let i = 0; i < proposals.length; i++) {
34
+ const p = proposals[i];
35
+ const diffBytes = byteLen(p.diff);
36
+ const share = Math.floor((diffBytes / totalDiff) * budget);
37
+ // Ensure at least 100 bytes per entry if possible, else share
38
+ const sliceBytes = Math.min(diffBytes, Math.max(share, 100));
39
+ // Slice diff by bytes approximated via string length; diff is ascii mostly
40
+ // so byte length ~ char length. Use char slice.
41
+ const approxChars = Math.floor((sliceBytes / Math.max(diffBytes, 1)) * p.diff.length);
42
+ const truncatedDiff = p.diff.slice(0, Math.max(0, approxChars));
43
+ const needsTrunc = truncatedDiff.length < p.diff.length;
44
+ out.push({
45
+ ...p,
46
+ diff: needsTrunc ? `${truncatedDiff}\n…[truncated]` : p.diff,
47
+ truncated: needsTrunc ? true : p.truncated,
48
+ });
49
+ }
50
+ // If still over cap after proportional truncation, iteratively trim more
51
+ let result = out;
52
+ let ser = JSON.stringify(result);
53
+ let iter = 0;
54
+ while (byteLen(ser) > cap && iter < 5) {
55
+ result = result.map((p) => {
56
+ if (!p.diff || p.diff.length < 200)
57
+ return p;
58
+ const half = Math.floor(p.diff.length / 2);
59
+ return {
60
+ ...p,
61
+ diff: `${p.diff.slice(0, half)}\n…[truncated]`,
62
+ truncated: true,
63
+ };
64
+ });
65
+ ser = JSON.stringify(result);
66
+ iter++;
67
+ }
68
+ return result;
69
+ }
70
+ export function flushSnapshots(input) {
71
+ const { root, proposals, conflicts, health, metrics, version } = input;
72
+ const dir = join(root, "tui");
73
+ mkdirSync(dir, { recursive: true });
74
+ const generatedAt = new Date().toISOString();
75
+ const written = [];
76
+ const skipped = [];
77
+ // Proposals — cap with truncation
78
+ let propToWrite = proposals;
79
+ let propJson = JSON.stringify(propToWrite, null, 2);
80
+ if (byteLen(propJson) > CAP_BYTES) {
81
+ propToWrite = truncateProposals(proposals, CAP_BYTES - 1024);
82
+ propJson = JSON.stringify(propToWrite, null, 2);
83
+ if (byteLen(propJson) > CAP_BYTES) {
84
+ // Still over after truncation: truncate further by dropping diffs entirely
85
+ const minimal = propToWrite.map((p) => ({
86
+ ...p,
87
+ diff: `${p.diff.slice(0, 500)}\n…[truncated]`,
88
+ truncated: true,
89
+ }));
90
+ propJson = JSON.stringify(minimal, null, 2);
91
+ }
92
+ }
93
+ atomicWrite(join(dir, "proposals.json"), propJson);
94
+ written.push("proposals.json");
95
+ // Conflicts
96
+ let conflictsJson = JSON.stringify(conflicts, null, 2);
97
+ if (byteLen(conflictsJson) > CAP_BYTES) {
98
+ // Truncate summaries if needed
99
+ const truncated = conflicts.map((c) => ({
100
+ ...c,
101
+ a_summary: c.a_summary.slice(0, 500),
102
+ b_summary: c.b_summary.slice(0, 500),
103
+ }));
104
+ conflictsJson = JSON.stringify(truncated, null, 2);
105
+ }
106
+ atomicWrite(join(dir, "conflicts.json"), conflictsJson);
107
+ written.push("conflicts.json");
108
+ // Health
109
+ let healthJson = JSON.stringify(health, null, 2);
110
+ if (byteLen(healthJson) > CAP_BYTES) {
111
+ // Health should never exceed cap, but truncate counters if it does
112
+ const truncatedHealth = {
113
+ ...health,
114
+ counters: {},
115
+ };
116
+ healthJson = JSON.stringify(truncatedHealth, null, 2);
117
+ }
118
+ atomicWrite(join(dir, "health.json"), healthJson);
119
+ written.push("health.json");
120
+ // Meta
121
+ const meta = {
122
+ generatedAt,
123
+ version: version ?? "1.2.0",
124
+ files: SNAP_FILES.slice(0, 3),
125
+ };
126
+ atomicWrite(join(dir, "meta.json"), JSON.stringify(meta, null, 2));
127
+ written.push("meta.json");
128
+ if (metrics) {
129
+ try {
130
+ metrics.incr("tui_snapshots_flushed");
131
+ }
132
+ catch {
133
+ // best-effort
134
+ }
135
+ }
136
+ return { written, skipped };
137
+ }
138
+ /**
139
+ * Tolerant JSON reader used by tests and (duplicated) by the TUI.
140
+ * Returns {data} on success, {error} on missing/corrupt.
141
+ */
142
+ export function readJsonSafe(path) {
143
+ try {
144
+ const raw = readFileSync(path, "utf8");
145
+ try {
146
+ return { data: JSON.parse(raw) };
147
+ }
148
+ catch {
149
+ return { error: "corrupt" };
150
+ }
151
+ }
152
+ catch (err) {
153
+ const code = err?.code;
154
+ if (code === "ENOENT")
155
+ return { error: "missing" };
156
+ return { error: "corrupt" };
157
+ }
158
+ }
@@ -11,5 +11,7 @@ export interface Capabilities {
11
11
  readonly skills: boolean;
12
12
  readonly references: boolean;
13
13
  readonly apiVersion: string | null;
14
+ /** v1.2.0 (K12-012 / plan D12-03) — additive probe for permission.ask. */
15
+ readonly permissionAsk?: boolean;
14
16
  }
15
17
  export declare function probe(input: unknown): Capabilities;
@@ -11,6 +11,7 @@ const ALL_FALSE = {
11
11
  skills: false,
12
12
  references: false,
13
13
  apiVersion: null,
14
+ permissionAsk: false,
14
15
  };
15
16
  function hasCallable(input, domainKey, memberKey) {
16
17
  const domain = input[domainKey];
@@ -27,10 +28,12 @@ export function probe(input) {
27
28
  const apiVersion = typeof record.apiVersion === "string"
28
29
  ? record.apiVersion
29
30
  : null;
31
+ const permissionAsk = hasCallable(record, "permission", "ask");
30
32
  return {
31
33
  skills: hasCallable(record, "skill", "source"),
32
34
  references: hasCallable(record, "reference", "add"),
33
35
  apiVersion,
36
+ permissionAsk,
34
37
  };
35
38
  }
36
39
  catch {
@@ -53,6 +53,9 @@ export const CONTRACT_METRIC_ADDITIONS = [
53
53
  { name: "bench_regression_failures", since: "1.1.0" },
54
54
  { name: "forget_requests_total", since: "1.1.0" },
55
55
  { name: "forget_tombstones_published", since: "1.1.0" },
56
+ // v1.2.0 (K12-001 / plan §4, D12-??) — surface metrics (no migration this release)
57
+ { name: "tui_snapshots_flushed", since: "1.2.0" },
58
+ { name: "tui_actions_invoked", since: "1.2.0" },
56
59
  ];
57
60
  /**
58
61
  * v1.0.0 (K10-027 / plan §5.7) — the C-09 boundary addition. Stored is
@@ -16,10 +16,10 @@ export interface KevinPluginOptions {
16
16
  * v0.4.0 (K4-021) — settings surfaced by `kevin_config` (plan §8.8).
17
17
  * Unknown keys are rejected on `set` unless `strict: false`.
18
18
  */
19
- export declare const KEVIN_CONFIG_KEYS: readonly ["quality_gate_enabled", "lesson_snippet_injection", "patternminer_enabled", "cross_project_enabled", "llm_reflection_enabled", "tool_calls_dedup_enabled", "deterministic_retrieval", "pre_prompt_budget_tokens", "archive_after_days", "curation_enabled", "agents_md_path", "skill_emission_enabled", "reference_emission_enabled", "injection_confidence_floor", "repo_truth_enabled", "convention_mining_enabled", "conflict_detection_enabled", "error_lesson_mode", "shared_layer_enabled", "okf_path", "share_requires_approval", "author_identity_mode", "shared_confidence_floor", "hook_liveness_enabled", "native_registration_enabled", "host_probe_history_enabled", "dead_hook_report_threshold", "perf_enabled", "perf_ring_capacity", "perf_flush_on_idle", "contract_report_enabled"];
19
+ export declare const KEVIN_CONFIG_KEYS: readonly ["quality_gate_enabled", "lesson_snippet_injection", "patternminer_enabled", "cross_project_enabled", "llm_reflection_enabled", "tool_calls_dedup_enabled", "deterministic_retrieval", "pre_prompt_budget_tokens", "archive_after_days", "curation_enabled", "agents_md_path", "skill_emission_enabled", "reference_emission_enabled", "injection_confidence_floor", "repo_truth_enabled", "convention_mining_enabled", "conflict_detection_enabled", "error_lesson_mode", "shared_layer_enabled", "okf_path", "share_requires_approval", "author_identity_mode", "shared_confidence_floor", "hook_liveness_enabled", "native_registration_enabled", "host_probe_history_enabled", "dead_hook_report_threshold", "perf_enabled", "perf_ring_capacity", "perf_flush_on_idle", "contract_report_enabled", "tui_snapshots_enabled"];
20
20
  export declare const ERROR_LESSON_MODE_VALUES: readonly ["all", "triage_only"];
21
21
  /** Plugin release version — stamped into generated files (K8-021/027). */
22
- export declare const KEVIN_VERSION = "1.1.0";
22
+ export declare const KEVIN_VERSION = "1.2.0";
23
23
  export interface RekeyCounts {
24
24
  memories: number;
25
25
  shared_entries: number;
@@ -6,10 +6,12 @@ import { tool } from "@opencode-ai/plugin";
6
6
  import { Archiver } from "./Archiver.js";
7
7
  import { ArtifactWriter } from "./ArtifactWriter.js";
8
8
  import { CausalChain } from "./CausalChain.js";
9
+ import { handleBridgeCommand } from "./ChatBridge.js";
9
10
  import { ConflictDetector } from "./ConflictDetector.js";
10
11
  import { ContextInjector } from "./ContextInjector.js";
11
12
  import { ConventionMiner } from "./ConventionMiner.js";
12
13
  import { Curator } from "./Curator.js";
14
+ import { writeDashboard } from "./DashboardHtml.js";
13
15
  import { Feedback } from "./Feedback.js";
14
16
  import { HookLiveness } from "./HookLiveness.js";
15
17
  import { InjectionLedger } from "./InjectionLedger.js";
@@ -24,8 +26,11 @@ import { Retrospective } from "./Retrospective.js";
24
26
  import { SharedLayer } from "./SharedLayer.js";
25
27
  import { Store } from "./Store.js";
26
28
  import { ToolCallObserver } from "./ToolCallObserver.js";
29
+ import { deleteMailbox, processActions, readMailbox, writeResults, } from "./TuiActions.js";
30
+ import { flushSnapshots } from "./TuiSnapshots.js";
27
31
  import { probe } from "./capabilities.js";
28
32
  import { computeConfidence } from "./confidence.js";
33
+ import { contractDigest, describeContract } from "./contract.js";
29
34
  import { probeHost, summarize } from "./host.js";
30
35
  import { kevinApprove } from "./kevin_approve.js";
31
36
  import { buildAudit } from "./kevin_audit.js";
@@ -112,6 +117,11 @@ export const KEVIN_CONFIG_KEYS = [
112
117
  "perf_ring_capacity",
113
118
  "perf_flush_on_idle",
114
119
  "contract_report_enabled",
120
+ // v1.2.0 (K12-001 / plan §4) — the single setting seeded by runtime
121
+ // (no migration this release). Omitting makes `kevin_config set`
122
+ // return { error: "unknown_key" } while `kevin_config list` still
123
+ // shows it.
124
+ "tui_snapshots_enabled",
115
125
  ];
116
126
  // v1.1.0 (K11-007 / D11-08) — no new settings in 1.1.0; thresholds are constants (D11-03)
117
127
  // v0.7.0 (K7-003 / plan §5.6, D7-12) — the explicit VALUE domain for
@@ -122,7 +132,7 @@ export const KEVIN_CONFIG_KEYS = [
122
132
  // behaviour on the next reflection.
123
133
  export const ERROR_LESSON_MODE_VALUES = ["all", "triage_only"];
124
134
  /** Plugin release version — stamped into generated files (K8-021/027). */
125
- export const KEVIN_VERSION = "1.1.0";
135
+ export const KEVIN_VERSION = "1.2.0";
126
136
  function resolveMigrationsDir() {
127
137
  const here = dirname(fileURLToPath(import.meta.url));
128
138
  return join(here, "..", "migrations");
@@ -258,6 +268,17 @@ export const KevinPlugin = async (input, options) => {
258
268
  const store = new Store({ path: dbPath });
259
269
  const migrationsDir = opts.migrationsDir ?? resolveMigrationsDir();
260
270
  await new Migrate(store, migrationsDir).run();
271
+ // v1.2.0 (K12-001 / D12-??) — no migration this release: ensure the
272
+ // new setting exists with its default so `kevin_config list` shows it
273
+ // on a database that was already at 012.
274
+ try {
275
+ store
276
+ .prepare("INSERT OR IGNORE INTO kevin_settings (key, value) VALUES (?, ?)")
277
+ .run("tui_snapshots_enabled", "1");
278
+ }
279
+ catch {
280
+ // pre-003 DB without kevin_settings — nothing to seed
281
+ }
261
282
  const metrics = new Metrics(store);
262
283
  // v0.4.0 (K4-019): the plugin hooks expose no project field, so the
263
284
  // project id is derived once from the plugin host's working directory
@@ -397,6 +418,10 @@ export const KevinPlugin = async (input, options) => {
397
418
  mkdirSync(join(materializerRoot, "skills"), { recursive: true });
398
419
  mkdirSync(join(materializerRoot, "refs"), { recursive: true });
399
420
  const capabilities = probe(input);
421
+ // v1.2.0 (K12-012 / D12-03) — permission.ask probe (best-effort, additive).
422
+ // When the host exposes permission.ask, the presence is noted; absence is silent no-op.
423
+ // No new setting — bounded to tui_snapshots_enabled host-support check per spec.
424
+ void capabilities.permissionAsk;
400
425
  const materializer = new Materializer(store, { root: materializerRoot });
401
426
  // v0.9.0 (K9-016 / plan §5.4, D9-10) — native registration replaces
402
427
  // file emission. When attachNative returns a registration for a
@@ -1675,15 +1700,80 @@ export const KevinPlugin = async (input, options) => {
1675
1700
  });
1676
1701
  },
1677
1702
  "chat.message": async (hookInput, output) => {
1703
+ const rawText = output.parts
1704
+ .map((p) => p)
1705
+ .filter((p) => p.type === "text")
1706
+ .map((p) => p.text ?? "")
1707
+ .join(" ");
1708
+ const trimmed = rawText.trim();
1709
+ // v1.2.0 (K12-018 / D12-09) — chat-command bridge BEFORE deriveQuery.
1710
+ // Valid commands are SWALLOWED (parts cleared) and never reach the model.
1711
+ // Must run outside perf.measure so early return actually exits the hook.
1712
+ if (trimmed.length > 0) {
1713
+ try {
1714
+ const bridgeDeps = {
1715
+ getPending: () => {
1716
+ try {
1717
+ const maybe = curator.pending;
1718
+ if (typeof maybe === "function") {
1719
+ const rows = maybe.call(curator);
1720
+ return rows.map((p) => ({
1721
+ id: p.id,
1722
+ proposedText: String(p.proposed_text ?? p.proposedText ?? ""),
1723
+ }));
1724
+ }
1725
+ }
1726
+ catch { }
1727
+ try {
1728
+ const rows = store
1729
+ .prepare("SELECT id, proposed_text FROM curation_proposals WHERE status = 'pending'")
1730
+ .all();
1731
+ return rows.map((r) => ({
1732
+ id: r.id,
1733
+ proposedText: r.proposed_text,
1734
+ }));
1735
+ }
1736
+ catch {
1737
+ return [];
1738
+ }
1739
+ },
1740
+ approve: (id) => kevinApprove(store, memoryService, curator, writer, metrics, {
1741
+ proposalId: id,
1742
+ decision: "approve",
1743
+ }),
1744
+ reject: (id, note) => kevinApprove(store, memoryService, curator, writer, metrics, {
1745
+ proposalId: id,
1746
+ decision: "reject",
1747
+ }) && void note,
1748
+ acknowledge: (conflictId) => {
1749
+ try {
1750
+ const cd = conflictDetector;
1751
+ if (typeof cd.acknowledge === "function")
1752
+ cd.acknowledge(conflictId);
1753
+ else if (typeof cd.resolve === "function")
1754
+ cd.resolve(conflictId, "a");
1755
+ }
1756
+ catch { }
1757
+ },
1758
+ metrics,
1759
+ };
1760
+ const br = handleBridgeCommand(trimmed, bridgeDeps);
1761
+ if (br.handled) {
1762
+ try {
1763
+ output.parts = [];
1764
+ }
1765
+ catch { }
1766
+ return;
1767
+ }
1768
+ }
1769
+ catch {
1770
+ // best-effort — bridge failure must not break chat flow
1771
+ }
1772
+ }
1678
1773
  perf.measure("chat.message", () => {
1679
- const text = output.parts
1680
- .map((p) => p)
1681
- .filter((p) => p.type === "text")
1682
- .map((p) => p.text ?? "")
1683
- .join(" ");
1684
- if (text.trim()) {
1774
+ if (trimmed.length > 0) {
1685
1775
  const derived = injector.deriveQuery([
1686
- { role: "user", content: text },
1776
+ { role: "user", content: rawText },
1687
1777
  ]);
1688
1778
  lastUserQuery = derived.length > 0 ? derived : null;
1689
1779
  if (hookInput.sessionID && lastUserQuery) {
@@ -1809,6 +1899,61 @@ export const KevinPlugin = async (input, options) => {
1809
1899
  // (legacy DBs pre-005 lack the recurrence_count
1810
1900
  // column)
1811
1901
  .catch(() => { }));
1902
+ // v1.2.0 (K12-007/K12-011 / D12-05) — TUI mailbox: actions→curate ordering.
1903
+ // Process mailbox BEFORE curator.propose so a fresh proposal created this idle
1904
+ // is NOT visible to a stale token (D12-04). Best-effort, never breaks idle.
1905
+ try {
1906
+ const mb = readMailbox(materializerRoot);
1907
+ if (mb.actions.length) {
1908
+ const tuiDeps = {
1909
+ getPending: () => {
1910
+ try {
1911
+ const maybe = curator.pending;
1912
+ if (typeof maybe === "function") {
1913
+ const rows = maybe.call(curator);
1914
+ return rows.map((p) => ({
1915
+ id: p.id,
1916
+ proposedText: String(p.proposed_text ?? p.proposedText ?? ""),
1917
+ }));
1918
+ }
1919
+ }
1920
+ catch { }
1921
+ try {
1922
+ const rows = store
1923
+ .prepare("SELECT id, proposed_text FROM curation_proposals WHERE status = 'pending'")
1924
+ .all();
1925
+ return rows.map((r) => ({
1926
+ id: r.id,
1927
+ proposedText: r.proposed_text,
1928
+ }));
1929
+ }
1930
+ catch {
1931
+ return [];
1932
+ }
1933
+ },
1934
+ approve: (id) => kevinApprove(store, memoryService, curator, writer, metrics, {
1935
+ proposalId: id,
1936
+ decision: "approve",
1937
+ }),
1938
+ reject: (id, _note) => kevinApprove(store, memoryService, curator, writer, metrics, {
1939
+ proposalId: id,
1940
+ decision: "reject",
1941
+ }),
1942
+ acknowledge: (conflictId) => {
1943
+ const cd = conflictDetector;
1944
+ if (typeof cd.acknowledge === "function")
1945
+ cd.acknowledge(conflictId);
1946
+ },
1947
+ metrics,
1948
+ };
1949
+ const tuiResults = processActions(mb.actions, tuiDeps);
1950
+ writeResults(materializerRoot, tuiResults);
1951
+ deleteMailbox(materializerRoot);
1952
+ }
1953
+ }
1954
+ catch {
1955
+ // best-effort
1956
+ }
1812
1957
  // v0.6.0 (K6-015 / plan §8.14) — session-idle curation
1813
1958
  // generation. Dry-run only: `propose()` calls `plan()`
1814
1959
  // and never `apply()`, so nothing here can touch disk
@@ -1850,6 +1995,145 @@ export const KevinPlugin = async (input, options) => {
1850
1995
  // best-effort, same pattern as ledger.settle — a
1851
1996
  // sync failure must not break the idle path
1852
1997
  }
1998
+ // v1.2.0 (K12-003/K12-011/K12-017 / D12-05) — snapshot flush gated by tui_snapshots_enabled.
1999
+ // Order: actions→curate→syncSharedLayer→snapshots (D12-05) — snapshots reflect post-action truth.
2000
+ try {
2001
+ if (memoryService.getSetting("tui_snapshots_enabled", "1") === "1") {
2002
+ // Proposals
2003
+ let proposals = [];
2004
+ try {
2005
+ const rawPending = (() => {
2006
+ try {
2007
+ const maybe = curator.pending;
2008
+ if (typeof maybe === "function")
2009
+ return maybe.call(curator);
2010
+ }
2011
+ catch { }
2012
+ return store
2013
+ .prepare("SELECT id, kind, target_path, proposed_text, diff, memory_id, created_at FROM curation_proposals WHERE status = 'pending' ORDER BY created_at")
2014
+ .all();
2015
+ })();
2016
+ const { proposalToken } = await import("./TuiActions.js");
2017
+ proposals = rawPending.map((r) => {
2018
+ const row = r;
2019
+ const id = String(row.id ?? "");
2020
+ const proposed = String(row.proposed_text ?? row.proposedText ?? "");
2021
+ return {
2022
+ id,
2023
+ kind: String(row.kind ?? "agents_md"),
2024
+ target_path: String(row.target_path ?? row.targetPath ?? "AGENTS.md"),
2025
+ diff: String(row.diff ?? ""),
2026
+ memory_ids: String(row.memory_id ?? row.memoryIds ?? "")
2027
+ .split(",")
2028
+ .filter((s) => s.length > 0),
2029
+ created_at: String(row.created_at ??
2030
+ row.createdAt ??
2031
+ new Date().toISOString()),
2032
+ token: proposalToken(id, proposed),
2033
+ };
2034
+ });
2035
+ }
2036
+ catch { }
2037
+ // Conflicts
2038
+ let conflicts = [];
2039
+ try {
2040
+ const maybe = conflictDetector.openConflicts;
2041
+ if (typeof maybe === "function") {
2042
+ const raw = maybe.call(conflictDetector);
2043
+ conflicts = raw.map((c) => {
2044
+ const row = c;
2045
+ return {
2046
+ id: String(row.id ?? ""),
2047
+ kind: String(row.kind ?? ""),
2048
+ a_summary: String(row.a_summary ?? row.aSummary ?? ""),
2049
+ b_summary: String(row.b_summary ?? row.bSummary ?? ""),
2050
+ opened_at: String(row.opened_at ??
2051
+ row.openedAt ??
2052
+ new Date().toISOString()),
2053
+ };
2054
+ });
2055
+ }
2056
+ }
2057
+ catch {
2058
+ conflicts = [];
2059
+ }
2060
+ // Health
2061
+ let health;
2062
+ try {
2063
+ const doc = buildDoctor(store, host, memoryService);
2064
+ let perfRows = [];
2065
+ try {
2066
+ const stats = perf.stats?.();
2067
+ if (Array.isArray(stats)) {
2068
+ perfRows = stats.map((p) => {
2069
+ const row = p;
2070
+ const budget = row.budget;
2071
+ return {
2072
+ scope: String(row.scope ?? ""),
2073
+ p95: Number(row.p95 ?? 0),
2074
+ budget_p95: Number(budget?.p95Ms ??
2075
+ row.budget_p95 ??
2076
+ row.budgetP95 ??
2077
+ 0),
2078
+ within_budget: Boolean(row.withinBudget ?? row.within_budget ?? true),
2079
+ };
2080
+ });
2081
+ }
2082
+ }
2083
+ catch {
2084
+ perfRows = [];
2085
+ }
2086
+ let digest = "unknown";
2087
+ try {
2088
+ digest = contractDigest(describeContract());
2089
+ }
2090
+ catch { }
2091
+ health = {
2092
+ verdict: doc.verdict,
2093
+ reason: doc.reason,
2094
+ hooks: doc.hooks.map((h) => ({
2095
+ hook: h.hook,
2096
+ state: h.state,
2097
+ fire_count: h.fire_count,
2098
+ expected_count: h.expected_count,
2099
+ })),
2100
+ perf: perfRows,
2101
+ contract_digest: digest,
2102
+ counters: metrics.snapshot(),
2103
+ };
2104
+ }
2105
+ catch {
2106
+ health = {
2107
+ verdict: "unknown",
2108
+ reason: "health unavailable",
2109
+ hooks: [],
2110
+ perf: [],
2111
+ contract_digest: "unknown",
2112
+ counters: {},
2113
+ };
2114
+ }
2115
+ flushSnapshots({
2116
+ root: materializerRoot,
2117
+ proposals,
2118
+ conflicts,
2119
+ health,
2120
+ metrics,
2121
+ version: KEVIN_VERSION,
2122
+ });
2123
+ try {
2124
+ writeDashboard(materializerRoot, {
2125
+ generatedAt: new Date().toISOString(),
2126
+ proposals,
2127
+ conflicts,
2128
+ health,
2129
+ });
2130
+ }
2131
+ catch { }
2132
+ }
2133
+ }
2134
+ catch {
2135
+ // best-effort — snapshot failure must not break idle
2136
+ }
1853
2137
  // v1.0.0 (K10-013 / D10-08) — the session recorded work:
1854
2138
  // arm the deferred dispose settlement. The ISO timestamp
1855
2139
  // lets the next session.start compare it against
@@ -153,6 +153,22 @@ export interface AuditReport {
153
153
  clause_count: number;
154
154
  deprecated_count: number;
155
155
  };
156
+ /**
157
+ * v1.2.0 (K12-014 / D12-??) — TUI surface block. Read-only introspection:
158
+ * enabled_setting is the kevin_settings value, last_flush_age_s is seconds
159
+ * since meta.json generatedAt (null when absent/corrupt), mailbox_depth is
160
+ * queue length (null when file missing), last_results is the parsed
161
+ * results.json array (null when absent). Dashboard age and bridge counters
162
+ * added in K12-019.
163
+ */
164
+ tui?: {
165
+ enabled_setting: string;
166
+ last_flush_age_s: number | null;
167
+ mailbox_depth: number | null;
168
+ last_results: unknown | null;
169
+ dashboard_last_write_age_s: number | null;
170
+ bridge_interceptions: number;
171
+ };
156
172
  partial: boolean;
157
173
  }
158
174
  /**
@@ -210,4 +226,4 @@ export interface TruthReport {
210
226
  is_truncated: false;
211
227
  };
212
228
  }
213
- export declare function buildAudit(store: Store, metrics: Metrics, capabilities?: Capabilities, projectId?: string, repoId?: string | null): AuditReport;
229
+ export declare function buildAudit(store: Store, metrics: Metrics, capabilities?: Capabilities, projectId?: string, repoId?: string | null, tuiRoot?: string): AuditReport;