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,417 @@
1
+ import { audit, now } from '../db/index.js';
2
+ import { effectiveProposal, isValidStripeId } from '../../shared/actions.js';
3
+ /**
4
+ * The CAS claim — the double-execute guard. Safe because better-sqlite3 is
5
+ * synchronous single-writer: the conditional UPDATE and its `changes` count
6
+ * are one atomic step, and no `await` can interleave between claim and check.
7
+ *
8
+ * `failed` is claimable on purpose: operator retry of a failed action is
9
+ * intentional, made safe by the idempotent-error mappings below.
10
+ * (Deviation from the ancestor: `proposed` is NOT claimable here — execution
11
+ * strictly requires prior approval through the pipeline.)
12
+ */
13
+ export function claimActionForExecution(db, actionId) {
14
+ const res = db
15
+ .prepare(`UPDATE actions SET status = 'executing'
16
+ WHERE id = ? AND status IN ('approved','failed')`)
17
+ .run(actionId);
18
+ return res.changes === 1;
19
+ }
20
+ function getAction(db, actionId) {
21
+ return db.prepare(`SELECT * FROM actions WHERE id = ?`).get(actionId);
22
+ }
23
+ function finish(db, action, result) {
24
+ if (result.success) {
25
+ db.prepare(`UPDATE actions SET status = 'executed', executed_at = ?, result_json = ?, error_message = NULL
26
+ WHERE id = ?`).run(now(), JSON.stringify(result.details ?? {}), action.id);
27
+ }
28
+ else {
29
+ db.prepare(`UPDATE actions SET status = 'failed', error_message = ? WHERE id = ?`).run(result.message, action.id);
30
+ }
31
+ audit(db, 'system', 'action_execution', {
32
+ caseId: action.case_id,
33
+ actionId: action.id,
34
+ phase: result.success ? 'success' : 'failure',
35
+ detail: { message: result.message, ...result.details },
36
+ });
37
+ return result;
38
+ }
39
+ export async function executeAction(deps, actionId, opts = {}) {
40
+ const { db } = deps;
41
+ const pre = getAction(db, actionId);
42
+ if (!pre)
43
+ return { success: false, message: `action ${actionId} not found` };
44
+ if (!claimActionForExecution(db, actionId)) {
45
+ // Lost the race (or the action is not in a claimable state). Idempotent
46
+ // no-op — success only if the work is genuinely done.
47
+ const current = getAction(db, actionId);
48
+ const status = current?.status ?? 'missing';
49
+ audit(db, 'system', 'action_execution', {
50
+ caseId: pre.case_id,
51
+ actionId,
52
+ phase: 'start',
53
+ detail: { skipped: true, reason: `not claimable from status '${status}'` },
54
+ });
55
+ return {
56
+ success: status === 'executed',
57
+ message: `already '${status}' — not re-executed`,
58
+ details: { idempotent_noop: true },
59
+ };
60
+ }
61
+ const action = getAction(db, actionId);
62
+ const proposal = effectiveProposal(action);
63
+ const ref = `action:${action.id}`;
64
+ audit(db, 'system', 'action_execution', {
65
+ caseId: action.case_id,
66
+ actionId: action.id,
67
+ phase: 'start',
68
+ detail: { action_type: action.action_type },
69
+ });
70
+ try {
71
+ let result;
72
+ switch (action.action_type) {
73
+ case 'refund':
74
+ result = await runRefund(deps, action, proposal, ref);
75
+ break;
76
+ case 'cancel_subscription':
77
+ result = await runCancel(deps, action, proposal, ref);
78
+ break;
79
+ case 'send_reply':
80
+ result = await runSendReply(deps, action, proposal, opts.sendMode ?? 'translated', ref);
81
+ break;
82
+ case 'create_github_issue':
83
+ result = await runGithubIssue(deps, action, proposal, ref);
84
+ break;
85
+ default:
86
+ result = {
87
+ success: false,
88
+ message: `Unsupported action_type "${action.action_type}" — status actions execute inline in the approval pipeline`,
89
+ };
90
+ }
91
+ return finish(db, action, result);
92
+ }
93
+ catch (err) {
94
+ const message = err instanceof Error ? err.message : String(err);
95
+ audit(db, 'system', 'action_execution', {
96
+ caseId: action.case_id,
97
+ actionId: action.id,
98
+ phase: 'error',
99
+ detail: { message },
100
+ });
101
+ return finish(db, action, { success: false, message });
102
+ }
103
+ }
104
+ // ---------------------------------------------------------------- refund --
105
+ async function runRefund(deps, action, proposal, ref) {
106
+ const { db } = deps;
107
+ // Rescue: a sub_… mistakenly placed in charge_id becomes subscription_id.
108
+ let chargeId = typeof proposal.charge_id === 'string' ? proposal.charge_id : undefined;
109
+ let subscriptionId = typeof proposal.subscription_id === 'string' ? proposal.subscription_id : undefined;
110
+ if (chargeId && isValidStripeId(chargeId, 'sub_')) {
111
+ subscriptionId = chargeId;
112
+ chargeId = undefined;
113
+ }
114
+ const chargeValid = chargeId !== undefined &&
115
+ (isValidStripeId(chargeId, 'ch_') || isValidStripeId(chargeId, 'py_'));
116
+ if (!chargeValid)
117
+ chargeId = undefined;
118
+ // Tiered resolution: enrichment snapshot first, then live latest-paid-invoice.
119
+ if (!chargeId) {
120
+ chargeId = resolveChargeFromContext(db, action.case_id, proposal) ?? undefined;
121
+ }
122
+ if (!chargeId && subscriptionId && isValidStripeId(subscriptionId, 'sub_')) {
123
+ audit(db, 'system', 'action_execution', {
124
+ caseId: action.case_id, actionId: action.id, phase: 'api_call',
125
+ detail: { op: 'latestPaidInvoiceCharge', subscriptionId },
126
+ });
127
+ chargeId = (await deps.stripe.latestPaidInvoiceCharge(subscriptionId, ref)) ?? undefined;
128
+ }
129
+ if (!chargeId) {
130
+ return {
131
+ success: false,
132
+ message: 'no valid charge could be resolved — enrich the case and propose again with a real charge_id',
133
+ };
134
+ }
135
+ // Absolute amount guard: never refund more than the charge. amount_cents is
136
+ // explicit cents by contract (lint rejects the ambiguous `amount` field).
137
+ const amountCents = typeof proposal.amount_cents === 'number' ? Math.trunc(proposal.amount_cents) : undefined;
138
+ const charge = await deps.stripe.retrieveCharge(chargeId, ref);
139
+ if (charge.refunded) {
140
+ return {
141
+ success: true,
142
+ message: `charge ${chargeId} already fully refunded`,
143
+ details: { idempotent_noop: true, charge_id: chargeId },
144
+ };
145
+ }
146
+ if (amountCents !== undefined && amountCents > charge.amount) {
147
+ return {
148
+ success: false,
149
+ message: `amount_cents ${amountCents} exceeds charge amount ${charge.amount} — aborting to prevent overcharge`,
150
+ };
151
+ }
152
+ audit(db, 'system', 'action_execution', {
153
+ caseId: action.case_id, actionId: action.id, phase: 'api_call',
154
+ detail: { op: 'createRefund', charge_id: chargeId, amount_cents: amountCents ?? 'FULL' },
155
+ });
156
+ try {
157
+ // Single shot — money mutations never auto-retry (an ambiguous timeout
158
+ // must not double-charge; retry is a human decision made safe by CAS +
159
+ // the already-refunded mapping).
160
+ const refund = await deps.stripe.createRefund({ chargeId, ...(amountCents !== undefined ? { amountCents } : {}) }, ref);
161
+ audit(db, 'system', 'action_execution', {
162
+ caseId: action.case_id, actionId: action.id, phase: 'api_response',
163
+ detail: { refund_id: refund.id, status: refund.status, amount: refund.amount },
164
+ });
165
+ return {
166
+ success: true,
167
+ message: `refunded ${refund.amount} cents on ${chargeId} (refund ${refund.id}, status ${refund.status})`,
168
+ details: { refund_id: refund.id, amount_cents: refund.amount, charge_id: chargeId },
169
+ };
170
+ }
171
+ catch (err) {
172
+ const msg = err instanceof Error ? err.message : String(err);
173
+ if (/charge_already_refunded|already been refunded/i.test(msg)) {
174
+ return {
175
+ success: true,
176
+ message: `charge ${chargeId} was already refunded`,
177
+ details: { idempotent_noop: true, charge_id: chargeId },
178
+ };
179
+ }
180
+ return { success: false, message: `refund failed: ${msg}` };
181
+ }
182
+ }
183
+ function resolveChargeFromContext(db, caseId, proposal) {
184
+ const row = db.prepare(`SELECT prepared_context_json FROM cases WHERE id = ?`).get(caseId);
185
+ if (!row?.prepared_context_json)
186
+ return null;
187
+ try {
188
+ const ctx = JSON.parse(row.prepared_context_json);
189
+ const charges = (ctx.stripe?.customers ?? []).flatMap((c) => c.recentCharges ?? []);
190
+ const succeeded = charges.filter((c) => c.status === 'succeeded' &&
191
+ typeof c.id === 'string' &&
192
+ (isValidStripeId(c.id, 'ch_') || isValidStripeId(c.id, 'py_')) &&
193
+ c.refunded !== true);
194
+ const wanted = typeof proposal.amount_cents === 'number' ? proposal.amount_cents : undefined;
195
+ if (wanted !== undefined) {
196
+ const byAmount = succeeded.find((c) => c.amountCents === wanted);
197
+ if (byAmount)
198
+ return byAmount.id;
199
+ }
200
+ return succeeded[0]?.id ?? null;
201
+ }
202
+ catch {
203
+ return null;
204
+ }
205
+ }
206
+ // ---------------------------------------------------------------- cancel --
207
+ async function runCancel(deps, action, proposal, ref) {
208
+ const { db } = deps;
209
+ const subId = typeof proposal.subscription_id === 'string' ? proposal.subscription_id : '';
210
+ if (!isValidStripeId(subId, 'sub_')) {
211
+ return {
212
+ success: false,
213
+ message: 'no valid subscription_id — enrich the case and propose again with a real sub_… id',
214
+ };
215
+ }
216
+ const mode = proposal.cancel_mode === 'end_of_period' || proposal.cancel_mode === 'scheduled'
217
+ ? proposal.cancel_mode
218
+ : 'immediate';
219
+ // Pre-flight retrieve.
220
+ let sub;
221
+ try {
222
+ sub = await deps.stripe.retrieveSubscription(subId, ref);
223
+ }
224
+ catch (err) {
225
+ const msg = err instanceof Error ? err.message : String(err);
226
+ if (/resource_missing|No such subscription/i.test(msg)) {
227
+ return {
228
+ success: true,
229
+ message: `subscription ${subId} no longer exists — desired end-state already true`,
230
+ details: { idempotent_noop: true },
231
+ };
232
+ }
233
+ return { success: false, message: `pre-flight retrieve failed: ${msg}` };
234
+ }
235
+ if (sub.status === 'canceled') {
236
+ return {
237
+ success: true,
238
+ message: `subscription ${subId} already canceled`,
239
+ details: { idempotent_noop: true },
240
+ };
241
+ }
242
+ // PRORATION GUARD (Incident A): re-writing cancel state on an already-
243
+ // scheduled subscription churns the billing period and can generate a
244
+ // wrongful proration charge. Only an explicit `immediate` may act on an
245
+ // already-scheduled sub (operator forcing early termination).
246
+ const alreadyScheduled = sub.cancel_at_period_end === true || sub.cancel_at !== null;
247
+ if (alreadyScheduled && mode !== 'immediate') {
248
+ return {
249
+ success: true,
250
+ message: `subscription ${subId} already has a scheduled cancellation — not re-applied`,
251
+ details: { idempotent_noop: true, proration_guard: true },
252
+ };
253
+ }
254
+ audit(db, 'system', 'action_execution', {
255
+ caseId: action.case_id, actionId: action.id, phase: 'api_call',
256
+ detail: { op: `cancel:${mode}`, subscription_id: subId, cancel_date: proposal.cancel_date },
257
+ });
258
+ try {
259
+ // Confirm-before-success (Incident C): the API can return 200 with an
260
+ // unchanged object; verify the mutation actually took.
261
+ if (mode === 'immediate') {
262
+ const res = await deps.stripe.cancelNow(subId, ref);
263
+ if (res.status !== 'canceled') {
264
+ return {
265
+ success: false,
266
+ message: `cancellation of ${subId} did not confirm (status=${res.status}) — not marking as done; retry or check the Stripe dashboard`,
267
+ };
268
+ }
269
+ return { success: true, message: `subscription ${subId} canceled immediately`, details: { status: res.status } };
270
+ }
271
+ if (mode === 'end_of_period') {
272
+ const res = await deps.stripe.cancelAtPeriodEnd(subId, ref);
273
+ if (res.cancel_at_period_end !== true) {
274
+ return {
275
+ success: false,
276
+ message: `cancel_at_period_end did not confirm on ${subId} — not marking as done`,
277
+ };
278
+ }
279
+ return {
280
+ success: true,
281
+ message: `subscription ${subId} will cancel at period end`,
282
+ details: { cancel_at_period_end: true },
283
+ };
284
+ }
285
+ // scheduled
286
+ const date = typeof proposal.cancel_date === 'string' ? proposal.cancel_date : '';
287
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
288
+ return { success: false, message: 'scheduled cancel requires cancel_date (YYYY-MM-DD)' };
289
+ }
290
+ const epoch = Math.floor(Date.parse(`${date}T00:00:00Z`) / 1000);
291
+ const res = await deps.stripe.cancelAt(subId, epoch, ref);
292
+ if (res.cancel_at === null) {
293
+ return { success: false, message: `scheduled cancel did not confirm on ${subId} — not marking as done` };
294
+ }
295
+ return {
296
+ success: true,
297
+ message: `subscription ${subId} scheduled to cancel on ${date}`,
298
+ details: { cancel_at: res.cancel_at },
299
+ };
300
+ }
301
+ catch (err) {
302
+ const msg = err instanceof Error ? err.message : String(err);
303
+ if (/resource_missing|No such subscription/i.test(msg)) {
304
+ return {
305
+ success: true,
306
+ message: `subscription ${subId} no longer exists — desired end-state already true`,
307
+ details: { idempotent_noop: true },
308
+ };
309
+ }
310
+ return { success: false, message: `cancel failed: ${msg}` };
311
+ }
312
+ }
313
+ // ------------------------------------------------------------ send_reply --
314
+ const RETRYABLE = /429|rate.?limit|fetch failed|ECONNRESET|ETIMEDOUT|ECONNREFUSED|socket hang up|^5\d\d|\b5\d\d\b/i;
315
+ const MAX_SEND_RETRIES = 3;
316
+ async function runSendReply(deps, action, proposal, sendMode, ref) {
317
+ const { db } = deps;
318
+ // Content resolution: the current draft row wins over the proposal snapshot
319
+ // (the operator may have edited after the proposal was created). Fallback
320
+ // to proposal bodies. NEVER proposal.message — that is the approval-card
321
+ // summary, not customer content.
322
+ const draft = db
323
+ .prepare(`SELECT body_english, body_translated FROM drafts
324
+ WHERE case_id = ? AND is_current = 1 ORDER BY version DESC LIMIT 1`)
325
+ .get(action.case_id);
326
+ let body = null;
327
+ if (draft) {
328
+ const english = draft.body_english;
329
+ const translated = draft.body_translated;
330
+ if (sendMode === 'both' && translated)
331
+ body = `${translated}\n\n---\n\n${english}`;
332
+ else if (sendMode === 'english')
333
+ body = english;
334
+ else
335
+ body = translated ?? english; // 'translated' falls back to english
336
+ }
337
+ if (!body) {
338
+ for (const field of ['body_translated', 'body_english', 'body_text', 'content', 'reply_text']) {
339
+ const v = proposal[field];
340
+ if (typeof v === 'string' && v.trim() !== '') {
341
+ body = v;
342
+ break;
343
+ }
344
+ }
345
+ }
346
+ if (!body)
347
+ return { success: false, message: 'no reply content: no current draft and no usable proposal body' };
348
+ const caseRow = db
349
+ .prepare(`SELECT c.id, cu.email AS customer_email FROM cases c
350
+ LEFT JOIN customers cu ON cu.id = c.customer_id WHERE c.id = ?`)
351
+ .get(action.case_id);
352
+ if (!caseRow?.customer_email) {
353
+ return { success: false, message: 'case has no customer email to reply to' };
354
+ }
355
+ // Retry wraps ONLY the customer-facing send; bookkeeping never re-runs.
356
+ const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
357
+ const baseDelay = deps.sendRetryBaseDelayMs ?? 30_000;
358
+ let lastErr = '';
359
+ for (let attempt = 0; attempt < MAX_SEND_RETRIES; attempt++) {
360
+ try {
361
+ audit(db, 'system', 'action_execution', {
362
+ caseId: action.case_id, actionId: action.id, phase: 'api_call',
363
+ detail: { op: 'sendReply', to: caseRow.customer_email, attempt },
364
+ });
365
+ const sent = await deps.mailer.sendReply({
366
+ caseId: action.case_id,
367
+ to: caseRow.customer_email,
368
+ body,
369
+ ref,
370
+ });
371
+ // Bookkeeping (outside the retry loop by construction — we return).
372
+ // The outbound row is the dedupe key preventing the next mailbox poll
373
+ // from re-ingesting our own reply.
374
+ db.prepare(`INSERT INTO emails (case_id, direction, to_addr, body_text, provider_message_id, received_at, created_at)
375
+ VALUES (?, 'outbound', ?, ?, ?, ?, ?)`).run(action.case_id, caseRow.customer_email, body, sent.providerMessageId, now(), now());
376
+ return {
377
+ success: true,
378
+ message: `reply sent to ${caseRow.customer_email}`,
379
+ details: { provider_message_id: sent.providerMessageId, send_mode: sendMode },
380
+ };
381
+ }
382
+ catch (err) {
383
+ lastErr = err instanceof Error ? err.message : String(err);
384
+ const retryable = RETRYABLE.test(lastErr) && attempt < MAX_SEND_RETRIES - 1;
385
+ audit(db, 'system', 'action_execution', {
386
+ caseId: action.case_id, actionId: action.id, phase: 'error',
387
+ detail: { op: 'sendReply', attempt, error: lastErr, willRetry: retryable },
388
+ });
389
+ if (!retryable)
390
+ break;
391
+ await sleep(baseDelay * 2 ** attempt);
392
+ }
393
+ }
394
+ return { success: false, message: `send failed after retries: ${lastErr}` };
395
+ }
396
+ // ---------------------------------------------------- create_github_issue --
397
+ async function runGithubIssue(deps, action, proposal, ref) {
398
+ const title = typeof proposal.title === 'string' ? proposal.title.trim() : '';
399
+ const body = typeof proposal.body === 'string' ? proposal.body.trim() : '';
400
+ if (!title || !body) {
401
+ // Executor guard mirrors the lint rule (the forever rule: both land together).
402
+ return { success: false, message: 'create_github_issue requires non-empty title and body' };
403
+ }
404
+ audit(deps.db, 'system', 'action_execution', {
405
+ caseId: action.case_id, actionId: action.id, phase: 'api_call',
406
+ detail: { op: 'createIssue', title },
407
+ });
408
+ const issue = await deps.github.createIssue({ title, body, labels: Array.isArray(proposal.labels) ? proposal.labels : undefined }, ref);
409
+ if (!issue.number || issue.number <= 0) {
410
+ return { success: false, message: 'GitHub did not confirm issue creation — not marking as done' };
411
+ }
412
+ return {
413
+ success: true,
414
+ message: `created GitHub issue #${issue.number}`,
415
+ details: { issue_number: issue.number, url: issue.url },
416
+ };
417
+ }
@@ -0,0 +1,129 @@
1
+ import { PROPOSAL_SCHEMAS, ACTION_TYPES, } from '../../shared/actions.js';
2
+ import { loadToneLintConfig } from './overlay.js';
3
+ export function lintDraft(text, cfg = loadToneLintConfig()) {
4
+ const warnings = [];
5
+ for (const pattern of cfg.bannedGreetings) {
6
+ if (new RegExp(pattern, 'm').test(text)) {
7
+ warnings.push(`banned greeting form (/${pattern}/) — prefer "${cfg.suggestions.greeting}"`);
8
+ }
9
+ }
10
+ for (const phrase of cfg.bannedOpeners) {
11
+ if (text.toLowerCase().includes(phrase.toLowerCase())) {
12
+ warnings.push(`banned opener "${phrase}" — prefer "${cfg.suggestions.opener}"`);
13
+ }
14
+ }
15
+ for (const pattern of cfg.bannedSignoffs) {
16
+ if (new RegExp(pattern, 'im').test(text)) {
17
+ warnings.push(`banned sign-off form (/${pattern}/) — prefer "${cfg.suggestions.signoff}"`);
18
+ }
19
+ }
20
+ return warnings;
21
+ }
22
+ export function lintAndNormalizeAction(actionType, rawProposal, toneCfg) {
23
+ const errors = [];
24
+ const warnings = [];
25
+ const proposal = { ...rawProposal };
26
+ if (!ACTION_TYPES.includes(actionType)) {
27
+ return { proposal, errors: [`unknown action_type "${actionType}"`], warnings };
28
+ }
29
+ const type = actionType;
30
+ // Retired mode from a bundled-downgrade over-refund incident. Checked
31
+ // BEFORE schema validation so the brain gets the instructive replacement
32
+ // recipe instead of a generic enum error.
33
+ if (type === 'cancel_subscription' && proposal.cancel_mode === 'downgrade_to_monthly') {
34
+ errors.push('cancel_mode "downgrade_to_monthly" is retired: propose a `refund` (execution_order 0) ' +
35
+ 'for total_paid minus the verified monthly price, plus a `cancel_subscription` with ' +
36
+ 'cancel_mode "scheduled" (execution_order 5).');
37
+ return { proposal, errors, warnings };
38
+ }
39
+ const schema = PROPOSAL_SCHEMAS[type];
40
+ const parsed = schema.safeParse(proposal);
41
+ if (!parsed.success) {
42
+ for (const issue of parsed.error.issues) {
43
+ errors.push(`${issue.path.join('.') || '(root)'}: ${issue.message}`);
44
+ }
45
+ return { proposal, errors, warnings };
46
+ }
47
+ switch (type) {
48
+ case 'cancel_subscription': {
49
+ // Operators stripped time components on every single proposal.
50
+ if (typeof proposal.cancel_date === 'string' && /T|\s\d{2}:/.test(proposal.cancel_date)) {
51
+ proposal.cancel_date = proposal.cancel_date.slice(0, 10);
52
+ warnings.push('cancel_date had a time component — truncated to YYYY-MM-DD (auto-fixed)');
53
+ }
54
+ if (typeof proposal.cancel_date === 'string' &&
55
+ proposal.cancel_date !== '' &&
56
+ !/^\d{4}-\d{2}-\d{2}$/.test(proposal.cancel_date)) {
57
+ errors.push(`cancel_date "${proposal.cancel_date}" is not YYYY-MM-DD`);
58
+ }
59
+ if (proposal.cancel_mode === 'scheduled' && !proposal.cancel_date) {
60
+ proposal.cancel_mode = 'end_of_period';
61
+ warnings.push('cancel_mode "scheduled" without cancel_date — downgraded to end_of_period (auto-fixed)');
62
+ }
63
+ // The brain must never control this flag; always derived from intent.
64
+ const derived = proposal.cancel_mode === 'end_of_period';
65
+ if (proposal.cancel_at_period_end !== undefined && proposal.cancel_at_period_end !== derived) {
66
+ warnings.push(`cancel_at_period_end coerced to ${derived} to match cancel_mode (never trusted from the brain)`);
67
+ }
68
+ proposal.cancel_at_period_end = derived;
69
+ if (!proposal.customer_id) {
70
+ warnings.push('customer_id missing — expected explicitly for duplicate-subscription disambiguation');
71
+ }
72
+ break;
73
+ }
74
+ case 'refund': {
75
+ // The ancestor system's ambiguous `amount` (dollars? cents?) carried a
76
+ // heuristic that could 100x a small partial refund. ClearHand accepts
77
+ // only an explicit amount_cents.
78
+ if (proposal.amount !== undefined) {
79
+ errors.push('`amount` is ambiguous (dollars vs cents) and not accepted: use `amount_cents` ' +
80
+ '(integer cents), or omit it entirely for a full refund of the charge.');
81
+ }
82
+ if (proposal.refund_amount !== undefined) {
83
+ warnings.push('`refund_amount` is retired — use `amount_cents` (integer cents)');
84
+ }
85
+ if (!proposal.currency) {
86
+ proposal.currency = 'usd';
87
+ warnings.push('currency defaulted to "usd" (auto-fixed)');
88
+ }
89
+ break;
90
+ }
91
+ case 'send_reply': {
92
+ const body = typeof proposal.body_text === 'string' ? proposal.body_text.trim() : '';
93
+ const placeholder = /^see draft\.?$/i.test(body);
94
+ if (!body || placeholder) {
95
+ errors.push('send_reply.body_text must carry the real reply body at creation time ' +
96
+ '(empty or "see draft" placeholders are rejected)');
97
+ break;
98
+ }
99
+ for (const w of lintDraft(body, toneCfg))
100
+ warnings.push(`send_reply body: ${w}`);
101
+ break;
102
+ }
103
+ case 'escalate': {
104
+ if (typeof proposal.reason !== 'string' || proposal.reason.trim() === '') {
105
+ errors.push('escalate.reason is required — the operator needs to know why');
106
+ }
107
+ break;
108
+ }
109
+ case 'set_case_status': {
110
+ // zod already restricts target_status to resolved|pending|ignored;
111
+ // 'processed' is the "AI finished a pass" marker, never a closing state.
112
+ break;
113
+ }
114
+ case 'create_github_issue': {
115
+ // New action type in ClearHand — lint rule + executor guard land in the
116
+ // same change (the forever rule).
117
+ const title = typeof proposal.title === 'string' ? proposal.title.trim() : '';
118
+ const body = typeof proposal.body === 'string' ? proposal.body.trim() : '';
119
+ if (!title)
120
+ errors.push('create_github_issue.title is required');
121
+ if (!body)
122
+ errors.push('create_github_issue.body is required (include repro + case link)');
123
+ if (title.length > 200)
124
+ warnings.push('title longer than 200 chars — consider shortening');
125
+ break;
126
+ }
127
+ }
128
+ return { proposal, errors, warnings };
129
+ }