clearhand 0.1.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 (38) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +146 -0
  3. package/SECURITY.md +77 -0
  4. package/bin/clearhand.js +230 -0
  5. package/dist/mcp/index.js +159 -0
  6. package/dist/server/connectors/crawl.js +52 -0
  7. package/dist/server/connectors/github.js +45 -0
  8. package/dist/server/connectors/mailbox.js +257 -0
  9. package/dist/server/connectors/stripe.js +252 -0
  10. package/dist/server/context.js +83 -0
  11. package/dist/server/db/index.js +324 -0
  12. package/dist/server/demo.js +194 -0
  13. package/dist/server/index.js +51 -0
  14. package/dist/server/routes/api.js +704 -0
  15. package/dist/server/secrets/redaction.js +62 -0
  16. package/dist/server/secrets/store.js +238 -0
  17. package/dist/server/services/approval.js +206 -0
  18. package/dist/server/services/connections.js +99 -0
  19. package/dist/server/services/executor.js +417 -0
  20. package/dist/server/services/lint.js +129 -0
  21. package/dist/server/services/onboarding.js +168 -0
  22. package/dist/server/services/overlay.js +65 -0
  23. package/dist/server/trust/egress.js +12 -0
  24. package/dist/shared/actions.js +136 -0
  25. package/dist/shared/config.js +33 -0
  26. package/dist/shared/paths.js +34 -0
  27. package/package.json +73 -0
  28. package/public/assets/index-BicJ8AKo.css +1 -0
  29. package/public/assets/index-DZSNZW8-.js +43 -0
  30. package/public/index.html +17 -0
  31. package/templates/CLEARHAND.md +79 -0
  32. package/templates/overlay/escalation.md +21 -0
  33. package/templates/overlay/policies.md +30 -0
  34. package/templates/overlay/products.md +25 -0
  35. package/templates/overlay/tone-lint.json +14 -0
  36. package/templates/overlay/voice.md +24 -0
  37. package/templates/skills/clearhand-cs/SKILL.md +75 -0
  38. package/templates/skills/clearhand-onboard/SKILL.md +126 -0
@@ -0,0 +1,252 @@
1
+ import Stripe from 'stripe';
2
+ import { recordEgress } from '../trust/egress.js';
3
+ export const STRIPE_SECRET_NAME = 'stripe_api_key';
4
+ const STRIPE_HOST = 'api.stripe.com';
5
+ const API_VERSION = '2025-05-28.basil';
6
+ /**
7
+ * Every Stripe call goes through here: the key is decrypted just-in-time with
8
+ * a stated purpose (which lands in the secret access log) and the egress
9
+ * ledger records the host before the call is made.
10
+ */
11
+ function client(db, secrets, purpose, ref) {
12
+ const key = secrets.use(STRIPE_SECRET_NAME, purpose, ref);
13
+ recordEgress(db, STRIPE_HOST, purpose);
14
+ return new Stripe(key, { apiVersion: API_VERSION });
15
+ }
16
+ /**
17
+ * Entry-time validation. Least privilege is policy: a full-access live key is
18
+ * accepted only with a loud warning; the recommended shape is a restricted
19
+ * key (rk_live_…) with customers:read, charges:read, refunds:write,
20
+ * subscriptions:write, invoices:read.
21
+ */
22
+ export async function verifyStripeKey(db, rawKey) {
23
+ const keyType = rawKey.startsWith('rk_')
24
+ ? 'restricted'
25
+ : rawKey.includes('_test_')
26
+ ? 'test'
27
+ : rawKey.startsWith('sk_')
28
+ ? 'full'
29
+ : 'unknown';
30
+ try {
31
+ recordEgress(db, STRIPE_HOST, 'verify stripe key');
32
+ const probe = new Stripe(rawKey, { apiVersion: API_VERSION });
33
+ await probe.balance.retrieve();
34
+ const check = { ok: true, keyType, accountHint: `…${rawKey.slice(-4)}` };
35
+ if (keyType === 'full') {
36
+ check.warning =
37
+ 'This is a FULL-ACCESS live key. ClearHand works with a restricted key and recommends one: ' +
38
+ 'create it at https://dashboard.stripe.com/apikeys/create with customers:read, charges:read, ' +
39
+ 'refunds:write, subscriptions:write, invoices:read.';
40
+ }
41
+ return check;
42
+ }
43
+ catch (err) {
44
+ return { ok: false, keyType, error: err instanceof Error ? err.message : String(err) };
45
+ }
46
+ }
47
+ function subView(s) {
48
+ return {
49
+ id: s.id,
50
+ status: s.status,
51
+ cancel_at_period_end: s.cancel_at_period_end,
52
+ cancel_at: s.cancel_at,
53
+ };
54
+ }
55
+ /**
56
+ * The executor's Stripe ops. Every mutation returns a FRESH RETRIEVED view —
57
+ * not the mutation response — so the executor's confirm-before-success checks
58
+ * assert real post-mutation state (a 200 with an unchanged object must fail
59
+ * the check, not pass it).
60
+ */
61
+ export function makeStripeExecOps(db, secrets) {
62
+ return {
63
+ async retrieveSubscription(subId, ref) {
64
+ const s = client(db, secrets, `pre-flight retrieve ${subId}`, ref);
65
+ return subView(await s.subscriptions.retrieve(subId));
66
+ },
67
+ async cancelNow(subId, ref) {
68
+ const s = client(db, secrets, `cancel subscription now ${subId}`, ref);
69
+ await s.subscriptions.cancel(subId, { invoice_now: false, prorate: false });
70
+ return subView(await s.subscriptions.retrieve(subId));
71
+ },
72
+ async cancelAtPeriodEnd(subId, ref) {
73
+ const s = client(db, secrets, `cancel at period end ${subId}`, ref);
74
+ await s.subscriptions.update(subId, { cancel_at_period_end: true });
75
+ return subView(await s.subscriptions.retrieve(subId));
76
+ },
77
+ async cancelAt(subId, epochSeconds, ref) {
78
+ const s = client(db, secrets, `schedule cancel ${subId}`, ref);
79
+ // proration_behavior 'none' is mandatory: omitting it generated a
80
+ // wrongful proration charge in the ancestor system (Incident A).
81
+ await s.subscriptions.update(subId, {
82
+ cancel_at: epochSeconds,
83
+ proration_behavior: 'none',
84
+ });
85
+ return subView(await s.subscriptions.retrieve(subId));
86
+ },
87
+ async retrieveCharge(chargeId, ref) {
88
+ const s = client(db, secrets, `retrieve charge ${chargeId}`, ref);
89
+ const ch = await s.charges.retrieve(chargeId);
90
+ return {
91
+ id: ch.id,
92
+ amount: ch.amount,
93
+ currency: ch.currency,
94
+ refunded: ch.refunded,
95
+ amount_refunded: ch.amount_refunded,
96
+ };
97
+ },
98
+ async createRefund(params, ref) {
99
+ const s = client(db, secrets, `execute refund on ${params.chargeId}`, ref);
100
+ const created = await s.refunds.create({
101
+ charge: params.chargeId,
102
+ ...(params.amountCents !== undefined ? { amount: params.amountCents } : {}),
103
+ reason: 'requested_by_customer',
104
+ });
105
+ // Confirm-before-success: re-read the refund.
106
+ const confirmed = await s.refunds.retrieve(created.id);
107
+ if (confirmed.status !== 'succeeded' && confirmed.status !== 'pending') {
108
+ throw new Error(`refund ${created.id} did not confirm: status=${confirmed.status}`);
109
+ }
110
+ return { id: confirmed.id, status: confirmed.status ?? 'unknown', amount: confirmed.amount };
111
+ },
112
+ async latestPaidInvoiceCharge(subId, ref) {
113
+ const s = client(db, secrets, `resolve charge from ${subId}`, ref);
114
+ const invoices = await s.invoices.list({ subscription: subId, status: 'paid', limit: 1 });
115
+ const inv = invoices.data[0];
116
+ if (!inv)
117
+ return null;
118
+ if (typeof inv.charge === 'string')
119
+ return inv.charge;
120
+ if (inv.charge && typeof inv.charge === 'object')
121
+ return inv.charge.id;
122
+ // Newer API shapes hang the charge off the payment intent.
123
+ const pi = inv.payment_intent;
124
+ if (typeof pi === 'string') {
125
+ const intent = await s.paymentIntents.retrieve(pi);
126
+ const latest = intent.latest_charge;
127
+ return typeof latest === 'string' ? latest : (latest?.id ?? null);
128
+ }
129
+ return null;
130
+ },
131
+ };
132
+ }
133
+ /** Counts for the connect micro-payoff. Sampled lists; "+" marks a full page. */
134
+ export async function stripeInventory(db, secrets) {
135
+ const stripe = client(db, secrets, 'connect inventory (counts)');
136
+ const [products, prices, charges, refunds, disputes] = await Promise.all([
137
+ stripe.products.list({ active: true, limit: 100 }),
138
+ stripe.prices.list({ active: true, limit: 100 }),
139
+ stripe.charges.list({ limit: 100 }),
140
+ stripe.refunds.list({ limit: 100 }),
141
+ stripe.disputes.list({ limit: 100 }),
142
+ ]);
143
+ const count = (l) => l.has_more ? `${l.data.length}+` : String(l.data.length);
144
+ return {
145
+ products: products.data.length,
146
+ prices: prices.data.length,
147
+ charges: count(charges),
148
+ refunds: count(refunds),
149
+ disputes: count(disputes),
150
+ };
151
+ }
152
+ export async function enrichFromStripe(db, secrets, customerEmail, ref) {
153
+ const stripe = client(db, secrets, 'enrich case (customer lookup)', ref);
154
+ const found = await stripe.customers.list({ email: customerEmail, limit: 5 });
155
+ const enrichment = {
156
+ found: found.data.length > 0,
157
+ fetchedAt: new Date().toISOString(),
158
+ customers: [],
159
+ };
160
+ for (const cust of found.data) {
161
+ const [subs, charges] = await Promise.all([
162
+ stripe.subscriptions.list({ customer: cust.id, status: 'all', limit: 10 }),
163
+ stripe.charges.list({ customer: cust.id, limit: 20 }),
164
+ ]);
165
+ let ltv = 0;
166
+ let refundCount = 0;
167
+ let refundTotal = 0;
168
+ const recentCharges = charges.data.map((ch) => {
169
+ if (ch.status === 'succeeded')
170
+ ltv += ch.amount - ch.amount_refunded;
171
+ if (ch.amount_refunded > 0) {
172
+ refundCount += 1;
173
+ refundTotal += ch.amount_refunded;
174
+ }
175
+ return {
176
+ id: ch.id,
177
+ amountCents: ch.amount,
178
+ currency: ch.currency,
179
+ created: new Date(ch.created * 1000).toISOString(),
180
+ status: ch.status,
181
+ refunded: ch.refunded,
182
+ amountRefundedCents: ch.amount_refunded,
183
+ };
184
+ });
185
+ enrichment.customers.push({
186
+ customerId: cust.id,
187
+ created: new Date(cust.created * 1000).toISOString(),
188
+ currency: cust.currency ?? null,
189
+ ltvCents: ltv,
190
+ subscriptions: subs.data.map((s) => {
191
+ const item = s.items.data[0];
192
+ return {
193
+ id: s.id,
194
+ status: s.status,
195
+ priceNickname: item?.price.nickname ?? null,
196
+ priceId: item?.price.id ?? '',
197
+ amountCents: item?.price.unit_amount ?? null,
198
+ interval: item?.price.recurring?.interval ?? null,
199
+ currentPeriodEnd: item?.current_period_end
200
+ ? new Date(item.current_period_end * 1000).toISOString()
201
+ : null,
202
+ cancelAtPeriodEnd: s.cancel_at_period_end,
203
+ };
204
+ }),
205
+ recentCharges,
206
+ refunds: { count: refundCount, totalCents: refundTotal },
207
+ });
208
+ }
209
+ return enrichment;
210
+ }
211
+ /**
212
+ * Aggregate stats used by onboarding extraction: catalog + actual refund
213
+ * practice ("you refunded N% of requests, median full") so the policy
214
+ * interview can ask evidence-based questions instead of blank forms.
215
+ */
216
+ export async function stripeBusinessSummary(db, secrets) {
217
+ const stripe = client(db, secrets, 'onboarding extraction (catalog + refund stats)');
218
+ const [products, prices, charges, refunds] = await Promise.all([
219
+ stripe.products.list({ active: true, limit: 50 }),
220
+ stripe.prices.list({ active: true, limit: 100 }),
221
+ stripe.charges.list({ limit: 100 }),
222
+ stripe.refunds.list({ limit: 100 }),
223
+ ]);
224
+ const succeeded = charges.data.filter((c) => c.status === 'succeeded');
225
+ const fullRefunds = refunds.data.filter((r) => {
226
+ const ch = charges.data.find((c) => c.id === r.charge);
227
+ return ch ? r.amount >= ch.amount : false;
228
+ });
229
+ return {
230
+ fetchedAt: new Date().toISOString(),
231
+ catalog: products.data.map((p) => ({
232
+ id: p.id,
233
+ name: p.name,
234
+ description: p.description,
235
+ prices: prices.data
236
+ .filter((pr) => pr.product === p.id)
237
+ .map((pr) => ({
238
+ id: pr.id,
239
+ nickname: pr.nickname,
240
+ amountCents: pr.unit_amount,
241
+ currency: pr.currency,
242
+ interval: pr.recurring?.interval ?? 'one_time',
243
+ })),
244
+ })),
245
+ refundPractice: {
246
+ sampledCharges: succeeded.length,
247
+ sampledRefunds: refunds.data.length,
248
+ fullRefundShare: refunds.data.length > 0 ? fullRefunds.length / refunds.data.length : null,
249
+ totalRefundedCents: refunds.data.reduce((s, r) => s + r.amount, 0),
250
+ },
251
+ };
252
+ }
@@ -0,0 +1,83 @@
1
+ import { openDb, logSecretAccess } from './db/index.js';
2
+ import { SecretStore } from './secrets/store.js';
3
+ import { loadConfig } from '../shared/config.js';
4
+ import { makeStripeExecOps, enrichFromStripe } from './connectors/stripe.js';
5
+ import { makeMailer } from './connectors/mailbox.js';
6
+ import { makeGithubOps } from './connectors/github.js';
7
+ import { mailboxConfig, githubConfig } from './services/connections.js';
8
+ import { makeDemoExecutorOps, seedDemoData } from './demo.js';
9
+ import { now } from './db/index.js';
10
+ /**
11
+ * Wires the deterministic core. Connector configs are resolved at CALL time
12
+ * (not boot time) so connecting Stripe or a mailbox in the dashboard takes
13
+ * effect on the next action without a server restart.
14
+ */
15
+ export async function createAppContext(opts = {}) {
16
+ const config = loadConfig();
17
+ const db = openDb(opts.dbFile);
18
+ const secrets = await SecretStore.create({
19
+ storage: config.secretStorage,
20
+ onAccess: (a) => logSecretAccess(db, a),
21
+ });
22
+ const demo = config.demoMode;
23
+ if (demo)
24
+ seedDemoData(db);
25
+ const executorDeps = makeExecutorDeps(db, secrets, demo);
26
+ const approval = {
27
+ db,
28
+ executorDeps,
29
+ enrichCase: demo ? undefined : (caseId) => enrichCaseFromStripe(db, secrets, caseId),
30
+ };
31
+ return { db, secrets, config, approval, demo };
32
+ }
33
+ function makeExecutorDeps(db, secrets, demo) {
34
+ if (demo) {
35
+ const ops = makeDemoExecutorOps();
36
+ return { db, ...ops };
37
+ }
38
+ const stripe = makeStripeExecOps(db, secrets);
39
+ return {
40
+ db,
41
+ stripe,
42
+ mailer: {
43
+ async sendReply(params) {
44
+ const cfg = mailboxConfig(db);
45
+ if (!cfg)
46
+ throw new Error('mailbox not connected — connect it on the dashboard Connections page');
47
+ return makeMailer(db, secrets, cfg).sendReply(params);
48
+ },
49
+ },
50
+ github: {
51
+ async createIssue(params, ref) {
52
+ const cfg = githubConfig(db);
53
+ if (!cfg)
54
+ throw new Error('GitHub not connected — connect it on the dashboard Connections page');
55
+ return makeGithubOps(db, secrets, cfg).createIssue(params, ref);
56
+ },
57
+ },
58
+ };
59
+ }
60
+ /**
61
+ * Case enrichment: server-owned. The `stripe` slice of prepared_context is
62
+ * written only here (agents may add notes/customer_history via PATCH
63
+ * /context, never Stripe-shaped data — LLM-supplied money data is
64
+ * radioactive at the trust boundary).
65
+ */
66
+ export async function enrichCaseFromStripe(db, secrets, caseId) {
67
+ const row = db
68
+ .prepare(`SELECT c.id, cu.email FROM cases c JOIN customers cu ON cu.id = c.customer_id WHERE c.id = ?`)
69
+ .get(caseId);
70
+ if (!row)
71
+ throw new Error(`case ${caseId} not found or has no customer`);
72
+ const enrichment = await enrichFromStripe(db, secrets, row.email, `case:${caseId}`);
73
+ const existingRaw = db.prepare(`SELECT prepared_context_json FROM cases WHERE id = ?`).get(caseId).prepared_context_json;
74
+ let ctx = {};
75
+ try {
76
+ ctx = existingRaw ? JSON.parse(existingRaw) : {};
77
+ }
78
+ catch {
79
+ ctx = {};
80
+ }
81
+ ctx.stripe = enrichment;
82
+ db.prepare(`UPDATE cases SET prepared_context_json = ?, enriched_at = ?, updated_at = ? WHERE id = ?`).run(JSON.stringify(ctx), now(), now(), caseId);
83
+ }
@@ -0,0 +1,324 @@
1
+ import Database from 'better-sqlite3';
2
+ import path from 'node:path';
3
+ import { dbPath, ensureDir } from '../../shared/paths.js';
4
+ import { FINANCIAL_ACTION_TYPES } from '../../shared/actions.js';
5
+ /**
6
+ * SQLite via better-sqlite3, deliberately synchronous: the no-double-execute
7
+ * guarantee in the executor rests on single-writer synchronous transactions
8
+ * (the CAS claim in services/executor.ts). Do not swap this for an async
9
+ * driver or another store without re-proving that invariant under the new
10
+ * concurrency model (see docs/PORT_SPEC.md, porting note 1).
11
+ */
12
+ const MIGRATIONS = [
13
+ // v1 — initial schema
14
+ `
15
+ CREATE TABLE customers (
16
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
17
+ email TEXT NOT NULL UNIQUE,
18
+ name TEXT,
19
+ stripe_customer_id TEXT,
20
+ language TEXT,
21
+ notes TEXT,
22
+ created_at TEXT NOT NULL,
23
+ updated_at TEXT NOT NULL
24
+ );
25
+
26
+ CREATE TABLE cases (
27
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
28
+ customer_id INTEGER REFERENCES customers(id),
29
+ source TEXT NOT NULL DEFAULT 'imap',
30
+ -- provider thread/conversation key. NOT unique: follow-ups on a closed
31
+ -- thread open a NEW case sharing the conversation (see newestCaseForConversation).
32
+ conversation_key TEXT,
33
+ subject TEXT,
34
+ status TEXT NOT NULL DEFAULT 'new' CHECK (status IN
35
+ ('new','processed','reviewing','pending','resolved','escalated','ignored')),
36
+ priority TEXT NOT NULL DEFAULT 'normal' CHECK (priority IN ('low','normal','high','urgent')),
37
+ category TEXT,
38
+ language TEXT,
39
+ summary TEXT,
40
+ prepared_context_json TEXT,
41
+ enriched_at TEXT,
42
+ parent_case_id INTEGER REFERENCES cases(id),
43
+ merged_from_case_ids TEXT,
44
+ created_at TEXT NOT NULL,
45
+ updated_at TEXT NOT NULL
46
+ );
47
+ CREATE INDEX cases_conversation ON cases(source, conversation_key, id);
48
+ CREATE INDEX cases_status ON cases(status, updated_at);
49
+
50
+ CREATE TABLE emails (
51
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
52
+ case_id INTEGER NOT NULL REFERENCES cases(id),
53
+ direction TEXT NOT NULL CHECK (direction IN ('inbound','outbound')),
54
+ from_addr TEXT,
55
+ to_addr TEXT,
56
+ subject TEXT,
57
+ body_text TEXT,
58
+ body_html TEXT,
59
+ body_translated TEXT,
60
+ language TEXT,
61
+ provider_message_id TEXT,
62
+ in_reply_to TEXT,
63
+ received_at TEXT,
64
+ created_at TEXT NOT NULL
65
+ );
66
+ CREATE INDEX emails_case ON emails(case_id, received_at);
67
+ CREATE UNIQUE INDEX emails_provider_id ON emails(provider_message_id)
68
+ WHERE provider_message_id IS NOT NULL;
69
+
70
+ CREATE TABLE drafts (
71
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
72
+ case_id INTEGER NOT NULL REFERENCES cases(id),
73
+ version INTEGER NOT NULL DEFAULT 1,
74
+ body_english TEXT NOT NULL,
75
+ body_translated TEXT,
76
+ target_language TEXT,
77
+ is_current INTEGER NOT NULL DEFAULT 1,
78
+ created_by TEXT NOT NULL DEFAULT 'agent' CHECK (created_by IN ('agent','human')),
79
+ created_at TEXT NOT NULL
80
+ );
81
+ CREATE INDEX drafts_case ON drafts(case_id, is_current);
82
+
83
+ CREATE TABLE actions (
84
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
85
+ case_id INTEGER NOT NULL REFERENCES cases(id),
86
+ action_type TEXT NOT NULL,
87
+ status TEXT NOT NULL DEFAULT 'proposed' CHECK (status IN
88
+ ('proposed','approved','executing','executed','failed','rejected','superseded')),
89
+ execution_order INTEGER NOT NULL DEFAULT 0,
90
+ proposal_json TEXT NOT NULL,
91
+ modified_json TEXT,
92
+ confidence REAL,
93
+ policy_matched TEXT,
94
+ approved_by TEXT,
95
+ approved_at TEXT,
96
+ error_message TEXT,
97
+ result_json TEXT,
98
+ executed_at TEXT,
99
+ superseded_by_action_id INTEGER REFERENCES actions(id),
100
+ created_at TEXT NOT NULL
101
+ );
102
+ CREATE INDEX actions_case ON actions(case_id, status);
103
+
104
+ CREATE TABLE feedback_events (
105
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
106
+ case_id INTEGER REFERENCES cases(id),
107
+ action_id INTEGER,
108
+ event_type TEXT NOT NULL,
109
+ original_json TEXT,
110
+ modified_json TEXT,
111
+ diff_summary TEXT,
112
+ case_category TEXT,
113
+ created_at TEXT NOT NULL
114
+ );
115
+
116
+ CREATE TABLE learning_reviews (
117
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
118
+ case_id INTEGER REFERENCES cases(id),
119
+ category TEXT,
120
+ proposed_rule TEXT NOT NULL,
121
+ target_file TEXT NOT NULL,
122
+ target_section TEXT,
123
+ status TEXT NOT NULL DEFAULT 'proposed' CHECK (status IN ('proposed','approved','rejected')),
124
+ created_at TEXT NOT NULL,
125
+ resolved_at TEXT
126
+ );
127
+
128
+ CREATE TABLE calibrations (
129
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
130
+ customer_message TEXT NOT NULL,
131
+ context_note TEXT,
132
+ clearhand_draft TEXT NOT NULL,
133
+ actual_reply TEXT,
134
+ verdict TEXT CHECK (verdict IN (NULL,'fine','edited','wrong_policy','wrong_facts','wrong_tone')),
135
+ tags TEXT,
136
+ note TEXT,
137
+ created_at TEXT NOT NULL,
138
+ reviewed_at TEXT
139
+ );
140
+
141
+ CREATE TABLE audit_log (
142
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
143
+ at TEXT NOT NULL,
144
+ actor TEXT NOT NULL,
145
+ event TEXT NOT NULL,
146
+ case_id INTEGER,
147
+ action_id INTEGER,
148
+ phase TEXT,
149
+ detail TEXT
150
+ );
151
+ CREATE INDEX audit_case ON audit_log(case_id, at);
152
+ CREATE INDEX audit_action ON audit_log(action_id, at);
153
+
154
+ CREATE TABLE secret_access_log (
155
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
156
+ at TEXT NOT NULL,
157
+ secret_key TEXT NOT NULL,
158
+ purpose TEXT NOT NULL,
159
+ ref TEXT,
160
+ backend TEXT NOT NULL
161
+ );
162
+ CREATE INDEX secret_access_at ON secret_access_log(at);
163
+
164
+ CREATE TABLE egress_log (
165
+ host TEXT NOT NULL,
166
+ purpose TEXT NOT NULL,
167
+ first_at TEXT NOT NULL,
168
+ last_at TEXT NOT NULL,
169
+ count INTEGER NOT NULL DEFAULT 0,
170
+ PRIMARY KEY (host, purpose)
171
+ );
172
+
173
+ CREATE TABLE connections (
174
+ id TEXT PRIMARY KEY,
175
+ status TEXT NOT NULL DEFAULT 'disconnected'
176
+ CHECK (status IN ('connected','disconnected','error','needs_reauth')),
177
+ config TEXT,
178
+ last_verified TEXT,
179
+ last_used TEXT,
180
+ last_error TEXT,
181
+ updated_at TEXT NOT NULL
182
+ );
183
+ `,
184
+ // v2 — onboarding state machine + progress event stream
185
+ `
186
+ CREATE TABLE onboarding (
187
+ id INTEGER PRIMARY KEY CHECK (id = 1),
188
+ state_json TEXT NOT NULL,
189
+ updated_at TEXT NOT NULL
190
+ );
191
+
192
+ CREATE TABLE onboarding_events (
193
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
194
+ at TEXT NOT NULL,
195
+ stage TEXT NOT NULL,
196
+ kind TEXT NOT NULL,
197
+ payload TEXT
198
+ );
199
+ `,
200
+ // v3 — learning_reviews gains the 'applied' terminal state (the agent marks
201
+ // a review applied only after the overlay edit actually landed). SQLite
202
+ // can't alter a CHECK, so rebuild the table.
203
+ `
204
+ CREATE TABLE learning_reviews_v3 (
205
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
206
+ case_id INTEGER REFERENCES cases(id),
207
+ category TEXT,
208
+ proposed_rule TEXT NOT NULL,
209
+ target_file TEXT NOT NULL,
210
+ target_section TEXT,
211
+ status TEXT NOT NULL DEFAULT 'proposed'
212
+ CHECK (status IN ('proposed','approved','rejected','applied')),
213
+ created_at TEXT NOT NULL,
214
+ resolved_at TEXT
215
+ );
216
+ INSERT INTO learning_reviews_v3 SELECT * FROM learning_reviews;
217
+ DROP TABLE learning_reviews;
218
+ ALTER TABLE learning_reviews_v3 RENAME TO learning_reviews;
219
+ `,
220
+ ];
221
+ export function openDb(file = dbPath()) {
222
+ ensureDir(path.dirname(file));
223
+ const db = new Database(file);
224
+ db.pragma('journal_mode = WAL');
225
+ db.pragma('foreign_keys = ON');
226
+ migrate(db);
227
+ return db;
228
+ }
229
+ /** In-memory database for tests. */
230
+ export function openMemoryDb() {
231
+ const db = new Database(':memory:');
232
+ db.pragma('foreign_keys = ON');
233
+ migrate(db);
234
+ return db;
235
+ }
236
+ function migrate(db) {
237
+ const current = db.pragma('user_version', { simple: true });
238
+ for (let v = current; v < MIGRATIONS.length; v++) {
239
+ const sql = MIGRATIONS[v];
240
+ if (!sql)
241
+ continue;
242
+ db.transaction(() => {
243
+ db.exec(sql);
244
+ db.pragma(`user_version = ${v + 1}`);
245
+ })();
246
+ }
247
+ }
248
+ export function now() {
249
+ return new Date().toISOString();
250
+ }
251
+ export function audit(db, actor, event, opts = {}) {
252
+ db.prepare(`INSERT INTO audit_log (at, actor, event, case_id, action_id, phase, detail)
253
+ VALUES (?, ?, ?, ?, ?, ?, ?)`).run(now(), actor, event, opts.caseId ?? null, opts.actionId ?? null, opts.phase ?? null, opts.detail !== undefined ? JSON.stringify(opts.detail) : null);
254
+ }
255
+ export function logSecretAccess(db, access) {
256
+ db.prepare(`INSERT INTO secret_access_log (at, secret_key, purpose, ref, backend) VALUES (?, ?, ?, ?, ?)`).run(access.at, access.key, access.purpose, access.ref ?? null, access.backend);
257
+ }
258
+ /**
259
+ * THE financial-guard counter (strict variant — do not regress).
260
+ * `executed` = done; `rejected`/`superseded` = intentionally not doing it;
261
+ * everything else — including `failed` — still owes the customer. An earlier
262
+ * proposed-only counter in the ancestor system missed an approved-then-FAILED
263
+ * refund; `failed` stays in this list.
264
+ */
265
+ export function countPendingFinancialActions(db, caseId) {
266
+ const row = db
267
+ .prepare(`SELECT COUNT(*) AS n FROM actions
268
+ WHERE case_id = ?
269
+ AND action_type IN (${[...FINANCIAL_ACTION_TYPES].map(() => '?').join(',')})
270
+ AND status IN ('proposed','approved','executing','failed')`)
271
+ .get(caseId, ...FINANCIAL_ACTION_TYPES);
272
+ return row.n;
273
+ }
274
+ /**
275
+ * Claim-on-first-touch: any working write to a `new` case promotes it to
276
+ * `reviewing` — the signal that a session already picked it up. In the
277
+ * ancestor system this stopped an in-server batch processor from
278
+ * double-processing; here it stops two concurrent agent sessions (a
279
+ * scheduled morning run plus a manual one) from both triaging the same
280
+ * case. Returns true when this call performed the claim.
281
+ */
282
+ export function claimCaseIfNew(db, caseId) {
283
+ const res = db
284
+ .prepare(`UPDATE cases SET status = 'reviewing', updated_at = ? WHERE id = ? AND status = 'new'`)
285
+ .run(now(), caseId);
286
+ return res.changes === 1;
287
+ }
288
+ /**
289
+ * A helpdesk conversation can back multiple cases over time (follow-ups on
290
+ * closed threads open fresh cases). New activity always attaches to the
291
+ * NEWEST case of the conversation — never the original.
292
+ */
293
+ export function newestCaseForConversation(db, source, conversationKey) {
294
+ return db
295
+ .prepare(`SELECT id, status FROM cases
296
+ WHERE source = ? AND conversation_key = ?
297
+ ORDER BY id DESC LIMIT 1`)
298
+ .get(source, conversationKey);
299
+ }
300
+ /**
301
+ * Soft-supersede: replacing proposals never deletes them. Old rows flip to
302
+ * `superseded` with a pointer to the replacement and a feedback event, so
303
+ * dashboards and the learning loop keep the trail. The just-created
304
+ * replacement is excluded from the target set.
305
+ */
306
+ export function softSupersedeProposals(db, caseId, actionType, newActionId, reason) {
307
+ const tx = db.transaction(() => {
308
+ const rows = db
309
+ .prepare(`SELECT id, proposal_json FROM actions
310
+ WHERE case_id = ? AND status = 'proposed' AND id != ?
311
+ ${actionType ? 'AND action_type = ?' : ''}`)
312
+ .all(...(actionType ? [caseId, newActionId, actionType] : [caseId, newActionId]));
313
+ const upd = db.prepare(`UPDATE actions SET status = 'superseded', superseded_by_action_id = ?, error_message = ?
314
+ WHERE id = ?`);
315
+ const fb = db.prepare(`INSERT INTO feedback_events (case_id, action_id, event_type, original_json, diff_summary, created_at)
316
+ VALUES (?, ?, 'action_superseded', ?, ?, ?)`);
317
+ for (const row of rows) {
318
+ upd.run(newActionId, reason, row.id);
319
+ fb.run(caseId, row.id, row.proposal_json, `superseded by action ${newActionId}: ${reason}`, now());
320
+ }
321
+ return rows.length;
322
+ });
323
+ return tx();
324
+ }