@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.
package/README.md CHANGED
@@ -159,7 +159,15 @@ kevin_doctor → health report: hooks, deps, perf, verdict
159
159
  ~/.opencode-kevin/
160
160
  ├── kevin.db ← everything Kevin learns (SQLite, WAL)
161
161
  ├── skills/ ← generated pull channels
162
- └── refs/ ← topic reference bundles
162
+ ├── refs/ ← topic reference bundles
163
+ └── tui/
164
+ ├── proposals.json ← pending proposals projection (512 KiB cap)
165
+ ├── conflicts.json ← open conflicts projection
166
+ ├── health.json ← doctor+perf snapshot
167
+ ├── meta.json ← {generatedAt, versions}
168
+ ├── dashboard.html ← static review surface (file://, zero network)
169
+ ├── actions.json ← mailbox queue (TUI panels → session.idle)
170
+ └── results.json ← last action results (audit)
163
171
 
164
172
  <repo>/.kevin/
165
173
  ├── AGENTS.md ← curated knowledge (marker block, human-approved)
@@ -180,6 +188,26 @@ kevin_doctor → health report: hooks, deps, perf, verdict
180
188
 
181
189
  ---
182
190
 
191
+ ## 🆕 What's new in 1.2.0 — "Surface"
192
+
193
+ > 1.2.0 gives the human-in-the-loop a place to stand: every pending proposal is readable with its diff without opening an editor, and every approval rides the same gated handler.
194
+
195
+ - 🖥️ **Three review surfaces, one projection** — TUI panels (`/kevin` route, `k` to open) where the host renders them, plus a static `dashboard.html` under `~/.opencode-kevin/tui/` that opens via `file://` with zero network, zero fetch, inline CSS/JS and embedded JSON.
196
+ - 💬 **Chat-command bridge `/kevin-*` (universal, immediate)** — `/kevin-approve <id> <token>`, `/kevin-reject <id> <token> [note]`, `/kevin-ack <id>` execute through the existing `kevinApprove` / acknowledge handlers; valid commands are swallowed, invalid/stale ones pass through untouched to the model.
197
+ - 📦 **Mailbox for TUI panels (idle-latency)** — proposals approved from the TUI write `~/.opencode-kevin/tui/actions.json`; the session consumes it at the next `session.idle` before `curator.propose`, then refreshes snapshots (`tui_snapshots_enabled='1'`).
198
+ - ⏱️ **Latency honesty** — chat commands apply this turn; mailbox actions apply at next idle. Both disclosed in-surface (`queued — applies at session idle` toast, copy hint on dashboard).
199
+ - 📦 **Packaging** — new export `opencode-kevin/tui` (`dist/plugin/tui.*`), `engines.opencode ^1.18.0` validated by the host with skip-with-warning.
200
+
201
+ | Surface | Review | Action | Latency |
202
+ |---|---|---|---|
203
+ | TUI panel (`/kevin`) — CLI/TUI host | Proposals / Conflicts / Health tabs, diff dialog, truncation markers | `a` approve / `r` reject / `x` ack → mailbox | next idle |
204
+ | Static dashboard (`dashboard.html`) | Proposals (+escaped diff `<pre>`), conflicts two-column, health banner | Copy `/kevin-*` button → paste into chat input | immediate (chat bridge) |
205
+ | Any client (Desktop, CLI) | — | Type `/kevin-approve …` etc in chat input | immediate |
206
+
207
+ Snapshots + dashboard are capped at 512 KiB (diff truncation with `truncated:true`), written atomically via `tmp`+`rename`, and read best-effort with empty-state explanations (`no snapshots yet — open an opencode session…`) when missing/corrupt/stale-token.
208
+
209
+ ---
210
+
183
211
  ## 🆕 What's new in 1.0.0
184
212
 
185
213
  > 1.0.0 is the **proven release**: the surface is frozen as data, the cost is
@@ -489,6 +517,7 @@ client). All values are TEXT — flags compare with `=== "1"`, never truthiness.
489
517
  | `perf_ring_capacity` | `'512'` | Samples per scope (clamped `[64, 8192]`) |
490
518
  | `perf_flush_on_idle` | `'1'` | Persist samples at idle |
491
519
  | `contract_report_enabled` | `'1'` | Contract block in `kevin_audit` |
520
+ | `tui_snapshots_enabled` | `'1'` | Snapshot + dashboard flush at idle (opencode-kevin/tui) |
492
521
 
493
522
  ---
494
523
 
@@ -0,0 +1,41 @@
1
+ import { type PendingProposal } from "./TuiActions.js";
2
+ export type BridgeDeps = {
3
+ readonly getPending: () => readonly PendingProposal[];
4
+ readonly approve: (proposalId: string) => unknown;
5
+ readonly reject: (proposalId: string, note?: string) => unknown;
6
+ readonly acknowledge: (conflictId: string) => unknown;
7
+ readonly metrics?: {
8
+ incr: (key: "tui_actions_invoked", by?: number) => void;
9
+ } | null;
10
+ };
11
+ export type ParsedBridgeCommand = {
12
+ readonly type: "approve";
13
+ readonly proposalId: string;
14
+ readonly token: string;
15
+ readonly note?: string;
16
+ } | {
17
+ readonly type: "reject";
18
+ readonly proposalId: string;
19
+ readonly token: string;
20
+ readonly note?: string;
21
+ } | {
22
+ readonly type: "ack";
23
+ readonly conflictId: string;
24
+ };
25
+ export declare function parseBridgeCommand(text: string): ParsedBridgeCommand | null;
26
+ export declare const KEVIN_COMMAND_RE: RegExp;
27
+ export interface BridgeResult {
28
+ readonly handled: boolean;
29
+ readonly status?: "applied" | "rejected" | "stale_skipped" | "error";
30
+ readonly detail?: string;
31
+ }
32
+ /**
33
+ * Execute a chat message through the bridge.
34
+ * - Non-matching → {handled:false} (byte-identical pass-through)
35
+ * - Matching but stale/invalid → {handled:false, status:"stale_skipped"} (pass-through + counter)
36
+ * - Valid → executes via deps handlers, {handled:true}
37
+ *
38
+ * Valid commands are SWALLOWED — caller must not forward to model.
39
+ * Invalid/stale commands pass through untouched (D12-09).
40
+ */
41
+ export declare function handleBridgeCommand(text: string, deps: BridgeDeps): BridgeResult;
@@ -0,0 +1,103 @@
1
+ // v1.2.0 (K12-018 / plan §4.4 R3, D12-09) — chat-command bridge (universal actions).
2
+ // Hot path: ONE regex test on non-match, allocation-free.
3
+ import { verifyFresh, } from "./TuiActions.js";
4
+ // Exact regex for approve/reject (require 16-hex token, optional note capture).
5
+ // Ack variant is token-free (acknowledge is non-destructive, matches mailbox).
6
+ const APPROVE_REJECT_RE = /^\/kevin-(approve|reject)\s+(\S+)\s+([0-9a-f]{16})(?:\s+([\s\S]+))?$/;
7
+ const ACK_RE = /^\/kevin-ack\s+(\S+)\s*$/;
8
+ // Combined pattern for documentation / static analysis (covers both forms).
9
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
10
+ const _DOC_RE = /^\/kevin-(approve|reject|ack)\s+(\S+)\s+([0-9a-f]{16})(?:\s+([\s\S]+))?$/;
11
+ export function parseBridgeCommand(text) {
12
+ // Approve / reject — require token
13
+ const m = APPROVE_REJECT_RE.exec(text);
14
+ if (m) {
15
+ const type = m[1];
16
+ const proposalId = m[2];
17
+ const token = m[3];
18
+ const note = m[4] !== undefined ? m[4] : undefined;
19
+ if (type === "approve")
20
+ return {
21
+ type: "approve",
22
+ proposalId,
23
+ token,
24
+ ...(note !== undefined ? { note } : {}),
25
+ };
26
+ return {
27
+ type: "reject",
28
+ proposalId,
29
+ token,
30
+ ...(note !== undefined ? { note } : {}),
31
+ };
32
+ }
33
+ const ack = ACK_RE.exec(text);
34
+ if (ack) {
35
+ return { type: "ack", conflictId: ack[1] };
36
+ }
37
+ return null;
38
+ }
39
+ // Backward alias for tests that import by plan name
40
+ export const KEVIN_COMMAND_RE = APPROVE_REJECT_RE;
41
+ /**
42
+ * Execute a chat message through the bridge.
43
+ * - Non-matching → {handled:false} (byte-identical pass-through)
44
+ * - Matching but stale/invalid → {handled:false, status:"stale_skipped"} (pass-through + counter)
45
+ * - Valid → executes via deps handlers, {handled:true}
46
+ *
47
+ * Valid commands are SWALLOWED — caller must not forward to model.
48
+ * Invalid/stale commands pass through untouched (D12-09).
49
+ */
50
+ export function handleBridgeCommand(text, deps) {
51
+ const parsed = parseBridgeCommand(text);
52
+ if (!parsed)
53
+ return { handled: false };
54
+ if (parsed.type === "ack") {
55
+ // Ack is non-destructive, no token verification — matches mailbox acknowledge semantics.
56
+ try {
57
+ deps.acknowledge(parsed.conflictId);
58
+ try {
59
+ deps.metrics?.incr("tui_actions_invoked", 1);
60
+ }
61
+ catch { }
62
+ return { handled: true, status: "applied" };
63
+ }
64
+ catch (err) {
65
+ const msg = err instanceof Error ? err.message : String(err);
66
+ return { handled: true, status: "error", detail: msg };
67
+ }
68
+ }
69
+ // approve / reject — verify token against CURRENT pending state (same as mailbox)
70
+ const pending = deps.getPending();
71
+ const action = {
72
+ type: parsed.type,
73
+ proposalId: parsed.proposalId,
74
+ token: parsed.token,
75
+ ...(parsed.note !== undefined ? { note: parsed.note } : {}),
76
+ };
77
+ const fresh = verifyFresh(action, pending);
78
+ if (!fresh.ok) {
79
+ // Invalid/stale → pass-through untouched (do NOT swallow). Caller should forward byte-identically.
80
+ // Audit counter could be incremented here, but not as contract metric (blockedSnapshot-style internal).
81
+ return { handled: false, status: "stale_skipped", detail: fresh.reason };
82
+ }
83
+ try {
84
+ if (parsed.type === "approve") {
85
+ deps.approve(parsed.proposalId);
86
+ try {
87
+ deps.metrics?.incr("tui_actions_invoked", 1);
88
+ }
89
+ catch { }
90
+ return { handled: true, status: "applied" };
91
+ }
92
+ deps.reject(parsed.proposalId, parsed.note);
93
+ try {
94
+ deps.metrics?.incr("tui_actions_invoked", 1);
95
+ }
96
+ catch { }
97
+ return { handled: true, status: "rejected" };
98
+ }
99
+ catch (err) {
100
+ const msg = err instanceof Error ? err.message : String(err);
101
+ return { handled: true, status: "error", detail: msg };
102
+ }
103
+ }
@@ -0,0 +1,5 @@
1
+ import type { TuiSnapshotSet } from "./tui-types.js";
2
+ export declare function escapeHtml(text: string): string;
3
+ export declare function proposalToken(proposalId: string, proposedText: string): string;
4
+ export declare function renderDashboard(views: TuiSnapshotSet): string;
5
+ export declare function writeDashboard(root: string, views: TuiSnapshotSet): string;
@@ -0,0 +1,180 @@
1
+ // v1.2.0 (K12-017 / plan §4.4 R2, D12-10) — static dashboard generator.
2
+ // Single self-contained file: inline CSS/JS, snapshot data embedded as const DATA.
3
+ // Zero network: no fetch, no XHR, no WebSocket, no external asset.
4
+ import { createHash } from "node:crypto";
5
+ import { mkdirSync, renameSync, writeFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ const CAP_BYTES = 512 * 1024;
8
+ function byteLen(s) {
9
+ return Buffer.byteLength(s, "utf8");
10
+ }
11
+ // HTML escaping — mirrors escapeInjectedText discipline (C-09 family).
12
+ export function escapeHtml(text) {
13
+ return text
14
+ .replace(/&/g, "&amp;")
15
+ .replace(/</g, "&lt;")
16
+ .replace(/>/g, "&gt;")
17
+ .replace(/"/g, "&quot;");
18
+ }
19
+ export function proposalToken(proposalId, proposedText) {
20
+ return createHash("sha256")
21
+ .update(`${proposalId}\0${proposedText}`, "utf8")
22
+ .digest("hex")
23
+ .slice(0, 16);
24
+ }
25
+ function truncateForHtml(views, cap) {
26
+ // Estimate html size; if over cap, truncate diffs progressively.
27
+ let html = renderDashboardInner(views, false);
28
+ if (byteLen(html) <= cap)
29
+ return views;
30
+ // First pass: cap diffs to 2000 chars
31
+ let truncated = {
32
+ ...views,
33
+ proposals: views.proposals.map((p) => {
34
+ if (p.diff.length <= 2000)
35
+ return p;
36
+ return {
37
+ ...p,
38
+ diff: `${p.diff.slice(0, 2000)}\n…[truncated]`,
39
+ truncated: true,
40
+ };
41
+ }),
42
+ };
43
+ html = renderDashboardInner(truncated, false);
44
+ if (byteLen(html) <= cap)
45
+ return truncated;
46
+ // Second: 800 chars
47
+ truncated = {
48
+ ...views,
49
+ proposals: views.proposals.map((p) => ({
50
+ ...p,
51
+ diff: p.diff.length > 800 ? `${p.diff.slice(0, 800)}\n…[truncated]` : p.diff,
52
+ truncated: p.diff.length > 800 ? true : p.truncated,
53
+ })),
54
+ conflicts: views.conflicts.map((c) => ({
55
+ ...c,
56
+ a_summary: c.a_summary.slice(0, 200),
57
+ b_summary: c.b_summary.slice(0, 200),
58
+ })),
59
+ };
60
+ html = renderDashboardInner(truncated, false);
61
+ if (byteLen(html) <= cap)
62
+ return truncated;
63
+ // Third: drop diffs to 400 chars
64
+ truncated = {
65
+ ...views,
66
+ proposals: views.proposals.map((p) => ({
67
+ ...p,
68
+ diff: `${p.diff.slice(0, 400)}\n…[truncated]`,
69
+ truncated: true,
70
+ })),
71
+ };
72
+ return truncated;
73
+ }
74
+ function renderDashboardInner(views, _withTruncation) {
75
+ const css = "*{box-sizing:border-box}body{font-family:ui-monospace,monospace;margin:0;padding:16px;background:#0f1115;color:#e6e6e6}a{color:#8ab4ff}header{border-bottom:1px solid #2a2e39;padding-bottom:12px;margin-bottom:16px}h1{margin:0;font-size:20px}h2{font-size:16px;margin:24px 0 8px;border-bottom:1px solid #222;padding-bottom:4px}.card{border:1px solid #2a2e39;border-radius:8px;padding:12px;margin:8px 0;background:#151821}.muted{color:#9aa0b2;font-size:12px}.badge{display:inline-block;padding:2px 6px;border-radius:999px;font-size:11px;border:1px solid #2a2e39}.badge-healthy{background:#12331a;color:#8ef0a0}.badge-degraded{background:#331a1a;color:#f0a0a0}.badge-unknown{background:#2a2a33;color:#c0c0d0}pre{white-space:pre-wrap;word-break:break-word;background:#0b0d12;padding:8px;border-radius:6px;overflow:auto;max-height:320px;font-size:12px}table{width:100%;border-collapse:collapse;font-size:12px}th,td{border:1px solid #2a2e39;padding:4px 6px;text-align:left}button{cursor:pointer;background:#1f2330;color:#e6e6e6;border:1px solid #2a2e39;border-radius:6px;padding:4px 8px;font-size:12px}button:hover{background:#2a3045}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:8px}.cols{display:grid;grid-template-columns:1fr 1fr;gap:12px}.count{font-size:11px;color:#9aa0b2}";
76
+ // Build proposals HTML server-side
77
+ const proposalsHtml = views.proposals.length === 0
78
+ ? `<div class="muted">No pending proposals.</div>`
79
+ : views.proposals
80
+ .map((p) => {
81
+ const token = p.token ?? proposalToken(p.id, p.diff);
82
+ const approveCmd = `/kevin-approve ${p.id} ${token}`;
83
+ const rejectCmd = `/kevin-reject ${p.id} ${token}`;
84
+ return `<div class="card">
85
+ <div><strong>${escapeHtml(p.kind)}</strong> <span class="muted">${escapeHtml(p.id)}</span> <span class="badge">${escapeHtml(p.target_path)}</span> <span class="muted">${escapeHtml(p.created_at)}</span>${p.truncated ? ` <span class="badge">truncated</span>` : ""}</div>
86
+ <div class="muted">memories: ${escapeHtml(p.memory_ids.join(", "))}</div>
87
+ <pre>${escapeHtml(p.diff)}</pre>
88
+ <div style="display:flex;gap:8px;margin-top:8px">
89
+ <button data-copy="${escapeHtml(approveCmd)}" onclick="copyCmd(this)">Copy approve</button>
90
+ <button data-copy="${escapeHtml(rejectCmd)}" onclick="copyCmd(this)">Copy reject</button>
91
+ </div>
92
+ <div class="muted copy-hint"></div>
93
+ </div>`;
94
+ })
95
+ .join("\n");
96
+ const conflictsHtml = views.conflicts.length === 0
97
+ ? `<div class="muted">No open conflicts.</div>`
98
+ : views.conflicts
99
+ .map((c) => {
100
+ const ackCmd = `/kevin-ack ${c.id}`;
101
+ return `<div class="card">
102
+ <div><strong>${escapeHtml(c.kind)}</strong> <span class="muted">${escapeHtml(c.id)}</span> <span class="muted">${escapeHtml(c.opened_at)}</span></div>
103
+ <div class="cols"><div><div class="muted">A</div><pre>${escapeHtml(c.a_summary)}</pre></div><div><div class="muted">B</div><pre>${escapeHtml(c.b_summary)}</pre></div></div>
104
+ <div style="margin-top:8px"><button data-copy="${escapeHtml(ackCmd)}" onclick="copyCmd(this)">Copy ack</button> <span class="muted copy-hint"></span></div>
105
+ </div>`;
106
+ })
107
+ .join("\n");
108
+ const verdictClass = views.health.verdict === "healthy"
109
+ ? "badge-healthy"
110
+ : views.health.verdict === "degraded"
111
+ ? "badge-degraded"
112
+ : "badge-unknown";
113
+ const hooksRows = views.health.hooks.length === 0
114
+ ? `<tr><td colspan="4" class="muted">No hooks</td></tr>`
115
+ : views.health.hooks
116
+ .map((h) => `<tr><td>${escapeHtml(h.hook)}</td><td>${escapeHtml(h.state)}</td><td>${h.fire_count}</td><td>${h.expected_count}</td></tr>`)
117
+ .join("\n");
118
+ const perfRows = views.health.perf.length === 0
119
+ ? `<tr><td colspan="4" class="muted">No perf data</td></tr>`
120
+ : views.health.perf
121
+ .map((p) => `<tr><td>${escapeHtml(p.scope)}</td><td>${p.p95}</td><td>${p.budget_p95}</td><td>${p.within_budget ? "yes" : "no"}</td></tr>`)
122
+ .join("\n");
123
+ const countersHtml = Object.keys(views.health.counters).length === 0
124
+ ? `<div class="muted">No counters</div>`
125
+ : `<div class="grid">${Object.entries(views.health.counters)
126
+ .map(([k, v]) => `<div class="card"><div class="muted">${escapeHtml(k)}</div><div><strong>${v}</strong></div></div>`)
127
+ .join("\n")}</div>`;
128
+ // Embedded DATA — escape every "<" to \u003c so hostile "<script>" can never appear verbatim inside the <script> block.
129
+ const dataJson = JSON.stringify(views).replace(/</g, "\\u003c");
130
+ const js = `function copyCmd(btn){var cmd=btn.getAttribute('data-copy');var hint=btn.parentElement.nextElementSibling;function done(t){if(hint)hint.textContent=t;setTimeout(function(){if(hint)hint.textContent='';},2500);}if(navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(cmd).then(function(){done('Copied: '+cmd+' — paste into your opencode session');},function(){fallback(cmd,done);});}else{fallback(cmd,done);}}function fallback(cmd,done){var ta=document.createElement('textarea');ta.value=cmd;ta.style.position='fixed';ta.style.opacity='0';document.body.appendChild(ta);ta.select();try{document.execCommand('copy');done('Copied: '+cmd+' — paste into your opencode session');}catch(e){done('Copy failed — manually copy: '+cmd);}document.body.removeChild(ta);}`;
131
+ return `<!doctype html>
132
+ <html lang="en">
133
+ <head>
134
+ <meta charset="utf-8">
135
+ <meta name="viewport" content="width=device-width,initial-scale=1">
136
+ <title>Kevin — Dashboard</title>
137
+ <style>${css}</style>
138
+ </head>
139
+ <body>
140
+ <header>
141
+ <h1>Kevin — Surface Dashboard</h1>
142
+ <div class="muted">Generated at ${escapeHtml(views.generatedAt)} · <span class="badge ${verdictClass}">${escapeHtml(views.health.verdict)}</span> ${escapeHtml(views.health.reason)} · contract ${escapeHtml(views.health.contract_digest)}</div>
143
+ <div class="muted">Proposals ${views.proposals.length} · Conflicts ${views.conflicts.length} · Paste copied <code>/kevin-*</code> commands into your opencode session (Desktop or CLI).</div>
144
+ </header>
145
+
146
+ <section>
147
+ <h2>Proposals</h2>
148
+ ${proposalsHtml}
149
+ </section>
150
+
151
+ <section>
152
+ <h2>Conflicts</h2>
153
+ ${conflictsHtml}
154
+ </section>
155
+
156
+ <section>
157
+ <h2>Health</h2>
158
+ <table><thead><tr><th>hook</th><th>state</th><th>fires</th><th>expected</th></tr></thead><tbody>${hooksRows}</tbody></table>
159
+ <table style="margin-top:12px"><thead><tr><th>scope</th><th>p95 ms</th><th>budget p95</th><th>within</th></tr></thead><tbody>${perfRows}</tbody></table>
160
+ <div style="margin-top:12px">${countersHtml}</div>
161
+ </section>
162
+
163
+ <script>const DATA=${dataJson};${js}</script>
164
+ </body>
165
+ </html>`;
166
+ }
167
+ export function renderDashboard(views) {
168
+ const capped = truncateForHtml(views, CAP_BYTES);
169
+ return renderDashboardInner(capped, true);
170
+ }
171
+ export function writeDashboard(root, views) {
172
+ const dir = join(root, "tui");
173
+ mkdirSync(dir, { recursive: true });
174
+ const html = renderDashboard(views);
175
+ const target = join(dir, "dashboard.html");
176
+ const tmp = `${target}.tmp`;
177
+ writeFileSync(tmp, html, "utf8");
178
+ renameSync(tmp, target);
179
+ return target;
180
+ }
@@ -78,6 +78,9 @@ export const METRIC_KEY_LABELS = {
78
78
  bench_regression_failures: "Fallos de regresion de benchmark",
79
79
  forget_requests_total: "Solicitudes de olvido totales",
80
80
  forget_tombstones_published: "Tombstones publicados por olvido",
81
+ // v1.2.0 (K12-001 / plan §4) — surface metrics; labels required by BUG-014 regression.
82
+ tui_snapshots_flushed: "Snapshots TUI generados",
83
+ tui_actions_invoked: "Acciones TUI invocadas",
81
84
  };
82
85
  function originLabel(origin) {
83
86
  if (origin === "reflector")
@@ -0,0 +1,43 @@
1
+ import type { ActionResult, TuiAction } from "./tui-types.js";
2
+ export interface MailboxReadResult {
3
+ readonly actions: readonly TuiAction[];
4
+ readonly warnings: readonly string[];
5
+ }
6
+ /**
7
+ * Read `join(root,"tui","actions.json")` tolerant.
8
+ * - missing file → {actions:[], warnings:[]}
9
+ * - malformed JSON → {actions:[], warnings:["malformed_json"]}
10
+ * - non-object or missing/non-array actions → {actions:[], warnings:["invalid_shape"]}
11
+ * - unknown type values are dropped with warning per entry
12
+ * Never deletes the file here.
13
+ */
14
+ export declare function readMailbox(root: string): MailboxReadResult;
15
+ export declare function proposalToken(proposalId: string, proposedText: string): string;
16
+ export interface PendingProposal {
17
+ readonly id: string;
18
+ readonly proposedText: string;
19
+ }
20
+ export declare function verifyFresh(action: TuiAction, currentPending: readonly PendingProposal[]): {
21
+ ok: true;
22
+ } | {
23
+ ok: false;
24
+ reason: string;
25
+ };
26
+ export type ActionStatus = ActionResult["status"];
27
+ export interface ProcessDeps {
28
+ readonly getPending: () => readonly PendingProposal[];
29
+ readonly approve: (proposalId: string) => unknown;
30
+ readonly reject: (proposalId: string, note?: string) => unknown;
31
+ readonly acknowledge: (conflictId: string) => unknown;
32
+ readonly metrics?: {
33
+ incr: (key: "tui_actions_invoked", by?: number) => void;
34
+ } | null;
35
+ }
36
+ export declare function processActions(actions: readonly TuiAction[], deps: ProcessDeps): ActionResult[];
37
+ export declare function writeResults(root: string, results: readonly ActionResult[]): void;
38
+ export declare function deleteMailbox(root: string): void;
39
+ /**
40
+ * Convenience: read → process → write results → delete queue.
41
+ * Returns results (empty if no actions). Mirrors idle-chain usage.
42
+ */
43
+ export declare function consumeMailbox(root: string, deps: ProcessDeps): ActionResult[];
@@ -0,0 +1,181 @@
1
+ // v1.2.0 (K12-005 / plan §4.3) — mailbox tolerant parser (phase F1).
2
+ // v1.2.0 (K12-006 / plan §4.3, D12-04) — token scheme + stale detection
3
+ import { createHash } from "node:crypto";
4
+ import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
5
+ import { join } from "node:path";
6
+ function isRecord(v) {
7
+ return typeof v === "object" && v !== null && !Array.isArray(v);
8
+ }
9
+ function isValidAction(raw) {
10
+ if (!isRecord(raw))
11
+ return null;
12
+ const t = raw.type;
13
+ if (t === "approve") {
14
+ if (typeof raw.proposalId === "string" && typeof raw.token === "string") {
15
+ return { type: "approve", proposalId: raw.proposalId, token: raw.token };
16
+ }
17
+ return null;
18
+ }
19
+ if (t === "reject") {
20
+ if (typeof raw.proposalId === "string" && typeof raw.token === "string") {
21
+ const note = typeof raw.note === "string" ? raw.note : undefined;
22
+ return note !== undefined
23
+ ? { type: "reject", proposalId: raw.proposalId, token: raw.token, note }
24
+ : { type: "reject", proposalId: raw.proposalId, token: raw.token };
25
+ }
26
+ return null;
27
+ }
28
+ if (t === "acknowledge") {
29
+ if (typeof raw.conflictId === "string") {
30
+ return { type: "acknowledge", conflictId: raw.conflictId };
31
+ }
32
+ return null;
33
+ }
34
+ return null;
35
+ }
36
+ /**
37
+ * Read `join(root,"tui","actions.json")` tolerant.
38
+ * - missing file → {actions:[], warnings:[]}
39
+ * - malformed JSON → {actions:[], warnings:["malformed_json"]}
40
+ * - non-object or missing/non-array actions → {actions:[], warnings:["invalid_shape"]}
41
+ * - unknown type values are dropped with warning per entry
42
+ * Never deletes the file here.
43
+ */
44
+ export function readMailbox(root) {
45
+ const path = join(root, "tui", "actions.json");
46
+ let raw;
47
+ try {
48
+ raw = readFileSync(path, "utf8");
49
+ }
50
+ catch (err) {
51
+ const code = err?.code;
52
+ if (code === "ENOENT")
53
+ return { actions: [], warnings: [] };
54
+ return { actions: [], warnings: ["read_error"] };
55
+ }
56
+ let parsed;
57
+ try {
58
+ parsed = JSON.parse(raw);
59
+ }
60
+ catch {
61
+ return { actions: [], warnings: ["malformed_json"] };
62
+ }
63
+ if (!isRecord(parsed)) {
64
+ return { actions: [], warnings: ["invalid_shape"] };
65
+ }
66
+ const maybeActions = parsed.actions;
67
+ if (!Array.isArray(maybeActions)) {
68
+ return { actions: [], warnings: ["invalid_shape"] };
69
+ }
70
+ const actions = [];
71
+ const warnings = [];
72
+ for (const entry of maybeActions) {
73
+ if (!isRecord(entry)) {
74
+ warnings.push("dropped_invalid_entry");
75
+ continue;
76
+ }
77
+ const type = entry.type;
78
+ if (type !== "approve" && type !== "reject" && type !== "acknowledge") {
79
+ warnings.push(`dropped_unknown_type:${String(type)}`);
80
+ continue;
81
+ }
82
+ const valid = isValidAction(entry);
83
+ if (!valid) {
84
+ warnings.push(`dropped_invalid_${String(type)}`);
85
+ continue;
86
+ }
87
+ actions.push(valid);
88
+ }
89
+ return { actions, warnings };
90
+ }
91
+ // v1.2.0 (K12-006 / D12-04) — first 16 hex of SHA-256(proposalId + "\0" + proposedText)
92
+ export function proposalToken(proposalId, proposedText) {
93
+ return createHash("sha256")
94
+ .update(`${proposalId}\0${proposedText}`, "utf8")
95
+ .digest("hex")
96
+ .slice(0, 16);
97
+ }
98
+ export function verifyFresh(action, currentPending) {
99
+ if (action.type === "acknowledge")
100
+ return { ok: true };
101
+ const pending = currentPending.find((p) => p.id === action.proposalId);
102
+ if (!pending) {
103
+ return { ok: false, reason: "content_changed_or_absent" };
104
+ }
105
+ const expected = proposalToken(pending.id, pending.proposedText);
106
+ if (expected !== action.token) {
107
+ return { ok: false, reason: "content_changed_or_absent" };
108
+ }
109
+ return { ok: true };
110
+ }
111
+ export function processActions(actions, deps) {
112
+ const results = [];
113
+ const pendingSnapshot = deps.getPending();
114
+ for (const action of actions) {
115
+ // Stale check for approve/reject
116
+ if (action.type === "approve" || action.type === "reject") {
117
+ const fresh = verifyFresh(action, pendingSnapshot);
118
+ if (!fresh.ok) {
119
+ results.push({ action, status: "stale_skipped", detail: fresh.reason });
120
+ try {
121
+ deps.metrics?.incr("tui_actions_invoked", 1);
122
+ }
123
+ catch { }
124
+ continue;
125
+ }
126
+ }
127
+ try {
128
+ if (action.type === "approve") {
129
+ deps.approve(action.proposalId);
130
+ results.push({ action, status: "applied" });
131
+ }
132
+ else if (action.type === "reject") {
133
+ deps.reject(action.proposalId, action.note);
134
+ results.push({ action, status: "rejected" });
135
+ }
136
+ else if (action.type === "acknowledge") {
137
+ deps.acknowledge(action.conflictId);
138
+ results.push({ action, status: "applied" });
139
+ }
140
+ }
141
+ catch (err) {
142
+ const msg = err instanceof Error ? err.message : String(err);
143
+ results.push({ action, status: "error", detail: msg });
144
+ }
145
+ try {
146
+ deps.metrics?.incr("tui_actions_invoked", 1);
147
+ }
148
+ catch { }
149
+ }
150
+ return results;
151
+ }
152
+ export function writeResults(root, results) {
153
+ const dir = join(root, "tui");
154
+ mkdirSync(dir, { recursive: true });
155
+ const payload = JSON.stringify({ generatedAt: new Date().toISOString(), results }, null, 2);
156
+ const target = join(dir, "results.json");
157
+ const tmp = `${target}.tmp`;
158
+ writeFileSync(tmp, payload, "utf8");
159
+ renameSync(tmp, target);
160
+ }
161
+ export function deleteMailbox(root) {
162
+ try {
163
+ unlinkSync(join(root, "tui", "actions.json"));
164
+ }
165
+ catch {
166
+ // missing is fine
167
+ }
168
+ }
169
+ /**
170
+ * Convenience: read → process → write results → delete queue.
171
+ * Returns results (empty if no actions). Mirrors idle-chain usage.
172
+ */
173
+ export function consumeMailbox(root, deps) {
174
+ const { actions } = readMailbox(root);
175
+ if (actions.length === 0)
176
+ return [];
177
+ const results = processActions(actions, deps);
178
+ writeResults(root, results);
179
+ deleteMailbox(root);
180
+ return results;
181
+ }
@@ -0,0 +1,24 @@
1
+ import type { Metrics } from "./metrics.js";
2
+ import type { ConflictView, HealthView, ProposalView } from "./tui-types.js";
3
+ export interface FlushInput {
4
+ readonly root: string;
5
+ readonly proposals: readonly ProposalView[];
6
+ readonly conflicts: readonly ConflictView[];
7
+ readonly health: HealthView;
8
+ readonly metrics?: Metrics | null;
9
+ readonly version?: string;
10
+ }
11
+ export interface FlushResult {
12
+ readonly written: string[];
13
+ readonly skipped: string[];
14
+ }
15
+ export declare function flushSnapshots(input: FlushInput): FlushResult;
16
+ /**
17
+ * Tolerant JSON reader used by tests and (duplicated) by the TUI.
18
+ * Returns {data} on success, {error} on missing/corrupt.
19
+ */
20
+ export declare function readJsonSafe(path: string): {
21
+ data: unknown;
22
+ } | {
23
+ error: "missing" | "corrupt";
24
+ };