@cleocode/playbooks 2026.4.88

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,321 @@
1
+ /**
2
+ * HMAC-SHA256 resume tokens for HITL approval gates.
3
+ *
4
+ * Tokens bind `{runId, nodeId, bindings}` so they cannot be forged or replayed
5
+ * across different executions. The secret defaults to a well-known dev value —
6
+ * production deployments MUST set the `CLEO_PLAYBOOK_SECRET` env var to a
7
+ * high-entropy secret. If the secret rotates, existing tokens are invalidated
8
+ * because the HMAC output changes.
9
+ *
10
+ * Binding canonicalization uses sorted-keys JSON so that `{a:1,b:2}` and
11
+ * `{b:2,a:1}` produce the same token — semantically identical payloads
12
+ * should always yield the same gate identity.
13
+ *
14
+ * @task T889 / T908 / W4-16
15
+ */
16
+
17
+ import { createHmac, randomUUID } from 'node:crypto';
18
+ import type { DatabaseSync } from 'node:sqlite';
19
+ import type { PlaybookApproval, PlaybookApprovalStatus } from '@cleocode/contracts';
20
+
21
+ /**
22
+ * Dev-only fallback secret. Surfaced through {@link getPlaybookSecret} so
23
+ * production code paths can override via `CLEO_PLAYBOOK_SECRET`.
24
+ */
25
+ const DEFAULT_SECRET = 'cleo-playbook-dev-secret-do-not-use-in-production';
26
+
27
+ /**
28
+ * Token length (hex chars). 32 hex chars = 128 bits of HMAC output — enough
29
+ * for collision resistance while keeping tokens URL-safe and log-friendly.
30
+ */
31
+ const TOKEN_LENGTH = 32;
32
+
33
+ /**
34
+ * Error code: approval token not found in the DB.
35
+ * Raised by {@link approveGate} / {@link rejectGate}.
36
+ */
37
+ export const E_APPROVAL_NOT_FOUND = 'E_APPROVAL_NOT_FOUND' as const;
38
+
39
+ /**
40
+ * Error code: approval has already transitioned out of `pending`.
41
+ * Raised by {@link approveGate} / {@link rejectGate} to prevent re-decisions.
42
+ */
43
+ export const E_APPROVAL_ALREADY_DECIDED = 'E_APPROVAL_ALREADY_DECIDED' as const;
44
+
45
+ /**
46
+ * Resolve the HMAC secret for resume-token generation.
47
+ *
48
+ * @param env - Override env source (defaults to `process.env`). Used in tests.
49
+ * @returns The configured secret, or a dev-only fallback if unset.
50
+ */
51
+ export function getPlaybookSecret(env: NodeJS.ProcessEnv = process.env): string {
52
+ return env['CLEO_PLAYBOOK_SECRET'] ?? DEFAULT_SECRET;
53
+ }
54
+
55
+ /**
56
+ * Generate a deterministic 32-char hex HMAC-SHA256 resume token.
57
+ *
58
+ * The token is derived from `HMAC(secret, "runId:nodeId:canonicalBindings")`
59
+ * and truncated to 32 hex chars (128 bits). Determinism is an intentional
60
+ * design choice: the same (runId, nodeId, bindings, secret) tuple always
61
+ * produces the same token, preventing duplicate gates for the same step.
62
+ *
63
+ * @param runId - Playbook run identifier.
64
+ * @param nodeId - Node identifier within the run graph.
65
+ * @param bindings - Current runtime bindings (canonicalized via sorted-keys JSON).
66
+ * @param secret - HMAC secret (defaults to {@link getPlaybookSecret}).
67
+ * @returns A 32-char lowercase hex string.
68
+ */
69
+ export function generateResumeToken(
70
+ runId: string,
71
+ nodeId: string,
72
+ bindings: Record<string, unknown>,
73
+ secret: string = getPlaybookSecret(),
74
+ ): string {
75
+ // Canonicalize bindings via sorted-keys JSON for determinism.
76
+ const canonical = JSON.stringify(bindings, Object.keys(bindings).sort());
77
+ const payload = `${runId}:${nodeId}:${canonical}`;
78
+ return createHmac('sha256', secret).update(payload).digest('hex').slice(0, TOKEN_LENGTH);
79
+ }
80
+
81
+ /**
82
+ * Input for {@link createApprovalGate}.
83
+ */
84
+ export interface CreateApprovalGateInput {
85
+ /** Run identifier (FK to `playbook_runs.run_id`). */
86
+ runId: string;
87
+ /** Node identifier within the run graph. */
88
+ nodeId: string;
89
+ /** Runtime bindings at gate creation time. */
90
+ bindings: Record<string, unknown>;
91
+ /** If true, gate is created pre-approved (policy auto-pass). Default false. */
92
+ autoPassed?: boolean;
93
+ /** Optional approver identity (required if `autoPassed=true` recorded by policy). */
94
+ approver?: string;
95
+ /** Optional human-readable reason (policy name, approval note, etc.). */
96
+ reason?: string;
97
+ /** Override secret for token generation. Defaults to env-resolved secret. */
98
+ secret?: string;
99
+ }
100
+
101
+ /**
102
+ * Narrow a raw status string to {@link PlaybookApprovalStatus}, guarding
103
+ * against unexpected DB values that would otherwise poison downstream types.
104
+ *
105
+ * @internal
106
+ */
107
+ function narrowStatus(s: string): PlaybookApprovalStatus {
108
+ if (s === 'pending' || s === 'approved' || s === 'rejected') return s;
109
+ throw new Error(`invariant: unknown playbook_approvals.status '${s}'`);
110
+ }
111
+
112
+ /**
113
+ * Read a required string column from a raw sqlite row.
114
+ *
115
+ * @internal
116
+ */
117
+ function readString(row: Record<string, unknown>, key: string): string {
118
+ const v = row[key];
119
+ if (typeof v !== 'string') {
120
+ throw new Error(`invariant: expected string for column ${key}, got ${typeof v}`);
121
+ }
122
+ return v;
123
+ }
124
+
125
+ /**
126
+ * Read a required integer column from a raw sqlite row.
127
+ *
128
+ * @internal
129
+ */
130
+ function readInt(row: Record<string, unknown>, key: string): number {
131
+ const v = row[key];
132
+ if (typeof v === 'number') return v;
133
+ if (typeof v === 'bigint') return Number(v);
134
+ throw new Error(`invariant: expected integer for column ${key}, got ${typeof v}`);
135
+ }
136
+
137
+ /**
138
+ * Read an optional string column. Returns `undefined` for both `null`
139
+ * (SQL NULL) and missing keys.
140
+ *
141
+ * @internal
142
+ */
143
+ function readOptionalString(row: Record<string, unknown>, key: string): string | undefined {
144
+ const v = row[key];
145
+ if (v === null || v === undefined) return undefined;
146
+ if (typeof v !== 'string') {
147
+ throw new Error(`invariant: expected string|null for column ${key}, got ${typeof v}`);
148
+ }
149
+ return v;
150
+ }
151
+
152
+ /**
153
+ * Map a raw `playbook_approvals` row to the camelCase {@link PlaybookApproval}
154
+ * contract shape. Validates types, converts the `auto_passed` 0/1 integer to
155
+ * a boolean, and strips nullable fields rather than emitting `null`.
156
+ *
157
+ * @internal
158
+ */
159
+ function rowToApproval(row: Record<string, unknown>): PlaybookApproval {
160
+ const approval: PlaybookApproval = {
161
+ approvalId: readString(row, 'approval_id'),
162
+ runId: readString(row, 'run_id'),
163
+ nodeId: readString(row, 'node_id'),
164
+ token: readString(row, 'token'),
165
+ requestedAt: readString(row, 'requested_at'),
166
+ status: narrowStatus(readString(row, 'status')),
167
+ autoPassed: readInt(row, 'auto_passed') === 1,
168
+ };
169
+ const approvedAt = readOptionalString(row, 'approved_at');
170
+ const approver = readOptionalString(row, 'approver');
171
+ const reason = readOptionalString(row, 'reason');
172
+ if (approvedAt !== undefined) approval.approvedAt = approvedAt;
173
+ if (approver !== undefined) approval.approver = approver;
174
+ if (reason !== undefined) approval.reason = reason;
175
+ return approval;
176
+ }
177
+
178
+ /**
179
+ * Create an HITL approval gate row in `playbook_approvals`.
180
+ *
181
+ * If `autoPassed` is true, the gate is written with `status='approved'`
182
+ * and `auto_passed=1` — used by the policy engine to short-circuit gates
183
+ * that match auto-pass rules. Otherwise status is `'pending'` and the
184
+ * runtime blocks until {@link approveGate} or {@link rejectGate} is called.
185
+ *
186
+ * @param db - Open `node:sqlite` handle with the T889 migration applied.
187
+ * @param input - Gate parameters.
188
+ * @returns The inserted {@link PlaybookApproval}, round-tripped from the DB.
189
+ */
190
+ export function createApprovalGate(
191
+ db: DatabaseSync,
192
+ input: CreateApprovalGateInput,
193
+ ): PlaybookApproval {
194
+ const token = generateResumeToken(input.runId, input.nodeId, input.bindings, input.secret);
195
+ const approvalId = randomUUID();
196
+ const autoPassed = input.autoPassed ?? false;
197
+ const status: PlaybookApprovalStatus = autoPassed ? 'approved' : 'pending';
198
+ const stmt = db.prepare(`
199
+ INSERT INTO playbook_approvals
200
+ (approval_id, run_id, node_id, token, status, auto_passed, approver, reason)
201
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
202
+ `);
203
+ stmt.run(
204
+ approvalId,
205
+ input.runId,
206
+ input.nodeId,
207
+ token,
208
+ status,
209
+ autoPassed ? 1 : 0,
210
+ input.approver ?? null,
211
+ input.reason ?? null,
212
+ );
213
+ const row = db
214
+ .prepare('SELECT * FROM playbook_approvals WHERE approval_id = ?')
215
+ .get(approvalId) as Record<string, unknown> | undefined;
216
+ if (row === undefined) {
217
+ throw new Error(
218
+ `${E_APPROVAL_NOT_FOUND}: insert did not round-trip (approval_id=${approvalId})`,
219
+ );
220
+ }
221
+ return rowToApproval(row);
222
+ }
223
+
224
+ /**
225
+ * Transition an approval gate to `approved` state.
226
+ *
227
+ * @param db - Open sqlite handle.
228
+ * @param token - The resume token returned from {@link createApprovalGate}.
229
+ * @param approver - Identity of the approver (agent id, user email, etc.).
230
+ * @param reason - Optional justification note.
231
+ * @returns The updated {@link PlaybookApproval} record.
232
+ * @throws Error with `E_APPROVAL_NOT_FOUND` code if no gate matches the token.
233
+ * @throws Error with `E_APPROVAL_ALREADY_DECIDED` code if the gate is not pending.
234
+ */
235
+ export function approveGate(
236
+ db: DatabaseSync,
237
+ token: string,
238
+ approver: string,
239
+ reason?: string,
240
+ ): PlaybookApproval {
241
+ return transitionGate(db, token, 'approved', approver, reason);
242
+ }
243
+
244
+ /**
245
+ * Transition an approval gate to `rejected` state. Same semantics as
246
+ * {@link approveGate} but records a rejection — runtime will halt the run.
247
+ *
248
+ * @param db - Open sqlite handle.
249
+ * @param token - The resume token.
250
+ * @param approver - Identity of the rejector.
251
+ * @param reason - Optional justification.
252
+ * @returns The updated {@link PlaybookApproval} record.
253
+ * @throws Error with `E_APPROVAL_NOT_FOUND` if the token is unknown.
254
+ * @throws Error with `E_APPROVAL_ALREADY_DECIDED` if the gate is not pending.
255
+ */
256
+ export function rejectGate(
257
+ db: DatabaseSync,
258
+ token: string,
259
+ approver: string,
260
+ reason?: string,
261
+ ): PlaybookApproval {
262
+ return transitionGate(db, token, 'rejected', approver, reason);
263
+ }
264
+
265
+ /**
266
+ * Internal shared transition logic for approve/reject. Performs row lookup,
267
+ * state validation, update, and round-trip fetch in a single atomic flow.
268
+ *
269
+ * @internal
270
+ */
271
+ function transitionGate(
272
+ db: DatabaseSync,
273
+ token: string,
274
+ next: Exclude<PlaybookApprovalStatus, 'pending'>,
275
+ approver: string,
276
+ reason?: string,
277
+ ): PlaybookApproval {
278
+ const existing = db.prepare('SELECT * FROM playbook_approvals WHERE token = ?').get(token) as
279
+ | Record<string, unknown>
280
+ | undefined;
281
+ if (existing === undefined) {
282
+ throw new Error(`${E_APPROVAL_NOT_FOUND}: no approval gate for token`);
283
+ }
284
+ const existingStatus = narrowStatus(readString(existing, 'status'));
285
+ if (existingStatus !== 'pending') {
286
+ const approvalId = readString(existing, 'approval_id');
287
+ throw new Error(
288
+ `${E_APPROVAL_ALREADY_DECIDED}: gate ${approvalId} is already ${existingStatus}`,
289
+ );
290
+ }
291
+ db.prepare(
292
+ `UPDATE playbook_approvals
293
+ SET status = ?, approved_at = datetime('now'), approver = ?, reason = ?
294
+ WHERE token = ?`,
295
+ ).run(next, approver, reason ?? null, token);
296
+ const row = db.prepare('SELECT * FROM playbook_approvals WHERE token = ?').get(token) as
297
+ | Record<string, unknown>
298
+ | undefined;
299
+ if (row === undefined) {
300
+ // Unreachable: UPDATE just succeeded on this token.
301
+ throw new Error(`${E_APPROVAL_NOT_FOUND}: row vanished after update (token=${token})`);
302
+ }
303
+ return rowToApproval(row);
304
+ }
305
+
306
+ /**
307
+ * List all gates that are still awaiting a decision, oldest first.
308
+ *
309
+ * @param db - Open sqlite handle.
310
+ * @returns Pending {@link PlaybookApproval} records ordered by `requested_at`.
311
+ */
312
+ export function getPendingApprovals(db: DatabaseSync): PlaybookApproval[] {
313
+ const rows = db
314
+ .prepare(
315
+ `SELECT * FROM playbook_approvals
316
+ WHERE status = 'pending'
317
+ ORDER BY requested_at ASC, approval_id ASC`,
318
+ )
319
+ .all() as Array<Record<string, unknown>>;
320
+ return rows.map(rowToApproval);
321
+ }
package/src/index.ts ADDED
@@ -0,0 +1,66 @@
1
+ /**
2
+ * @cleocode/playbooks — Playbook DSL + runtime for T889 Orchestration Coherence v3.
3
+ *
4
+ * This package is scaffolded in Wave 0. Subsequent waves will populate:
5
+ * - `schema.ts` (W4-6) — types + Drizzle table defs
6
+ * - `parser.ts` (W4-7) — .cantbook YAML parser
7
+ * - `state.ts` (W4-8) — DB CRUD for playbook_runs + playbook_approvals
8
+ * - `policy.ts` (W4-9) — HITL auto-policy rules
9
+ * - `runtime.ts` (W4-10) — state machine executor
10
+ * - `approval.ts` (W4-16) — resume token generation + approval ops
11
+ * - `skill-composer.ts` (W4-2..5) — three-source skill bundle composer
12
+ *
13
+ * @remarks
14
+ * Only the {@link PLAYBOOKS_PACKAGE_VERSION} constant is exported from the
15
+ * Wave 0 scaffold. Each follow-up wave adds a named barrel export here.
16
+ *
17
+ * @task T889 Orchestration Coherence v3 — Wave 0 scaffold
18
+ */
19
+
20
+ /**
21
+ * Package version string matching the monorepo's CalVer cadence.
22
+ *
23
+ * Consumers can use this to assert dependency alignment at runtime
24
+ * (e.g. ensuring the `@cleocode/playbooks` runtime matches CLEO core).
25
+ */
26
+ export const PLAYBOOKS_PACKAGE_VERSION: string = '2026.4.85';
27
+
28
+ export {
29
+ approveGate,
30
+ type CreateApprovalGateInput,
31
+ createApprovalGate,
32
+ E_APPROVAL_ALREADY_DECIDED,
33
+ E_APPROVAL_NOT_FOUND,
34
+ generateResumeToken,
35
+ getPendingApprovals,
36
+ getPlaybookSecret,
37
+ rejectGate,
38
+ } from './approval.js';
39
+ // W4-7: .cantbook YAML parser → PlaybookDefinition
40
+ export {
41
+ type ParsePlaybookResult,
42
+ PlaybookParseError,
43
+ parsePlaybook,
44
+ } from './parser.js';
45
+ // W4-9: HITL auto-policy evaluator
46
+ export {
47
+ DEFAULT_POLICY_RULES,
48
+ type EvaluatePolicyResult,
49
+ evaluatePolicy,
50
+ type PolicyRule,
51
+ } from './policy.js';
52
+ // W4-8: state layer CRUD for playbook_runs + playbook_approvals
53
+ export {
54
+ type CreatePlaybookApprovalInput,
55
+ type CreatePlaybookRunInput,
56
+ createPlaybookApproval,
57
+ createPlaybookRun,
58
+ deletePlaybookRun,
59
+ getPlaybookApprovalByToken,
60
+ getPlaybookRun,
61
+ type ListPlaybookRunsOptions,
62
+ listPlaybookApprovals,
63
+ listPlaybookRuns,
64
+ updatePlaybookApproval,
65
+ updatePlaybookRun,
66
+ } from './state.js';