@cleocode/playbooks 2026.10.18 → 2026.10.19

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.
@@ -51,7 +51,7 @@ export declare function generateResumeToken(runId: string, nodeId: string, bindi
51
51
  * Input for {@link createApprovalGate}.
52
52
  */
53
53
  export interface CreateApprovalGateInput {
54
- /** Run identifier (FK to `playbook_runs.run_id`). */
54
+ /** Run identifier (FK to `tasks_playbook_runs.run_id`). */
55
55
  runId: string;
56
56
  /** Node identifier within the run graph. */
57
57
  nodeId: string;
@@ -67,7 +67,7 @@ export interface CreateApprovalGateInput {
67
67
  secret?: string;
68
68
  }
69
69
  /**
70
- * Create an HITL approval gate row in `playbook_approvals`.
70
+ * Create an HITL approval gate row in `tasks_playbook_approvals`.
71
71
  *
72
72
  * If `autoPassed` is true, the gate is written with `status='approved'`
73
73
  * and `auto_passed=1` — used by the policy engine to short-circuit gates
package/dist/approval.js CHANGED
@@ -72,7 +72,7 @@ export function generateResumeToken(runId, nodeId, bindings, secret = getPlayboo
72
72
  function narrowStatus(s) {
73
73
  if (s === 'pending' || s === 'approved' || s === 'rejected')
74
74
  return s;
75
- throw new Error(`invariant: unknown playbook_approvals.status '${s}'`);
75
+ throw new Error(`invariant: unknown tasks_playbook_approvals.status '${s}'`);
76
76
  }
77
77
  /**
78
78
  * Read a required string column from a raw sqlite row.
@@ -115,7 +115,7 @@ function readOptionalString(row, key) {
115
115
  return v;
116
116
  }
117
117
  /**
118
- * Map a raw `playbook_approvals` row to the camelCase {@link PlaybookApproval}
118
+ * Map a raw `tasks_playbook_approvals` row to the camelCase {@link PlaybookApproval}
119
119
  * contract shape. Validates types, converts the `auto_passed` 0/1 integer to
120
120
  * a boolean, and strips nullable fields rather than emitting `null`.
121
121
  *
@@ -143,7 +143,7 @@ function rowToApproval(row) {
143
143
  return approval;
144
144
  }
145
145
  /**
146
- * Create an HITL approval gate row in `playbook_approvals`.
146
+ * Create an HITL approval gate row in `tasks_playbook_approvals`.
147
147
  *
148
148
  * If `autoPassed` is true, the gate is written with `status='approved'`
149
149
  * and `auto_passed=1` — used by the policy engine to short-circuit gates
@@ -160,13 +160,13 @@ export function createApprovalGate(db, input) {
160
160
  const autoPassed = input.autoPassed ?? false;
161
161
  const status = autoPassed ? 'approved' : 'pending';
162
162
  const stmt = db.prepare(`
163
- INSERT INTO playbook_approvals
163
+ INSERT INTO tasks_playbook_approvals
164
164
  (approval_id, run_id, node_id, token, status, auto_passed, approver, reason)
165
165
  VALUES (?, ?, ?, ?, ?, ?, ?, ?)
166
166
  `);
167
167
  stmt.run(approvalId, input.runId, input.nodeId, token, status, autoPassed ? 1 : 0, input.approver ?? null, input.reason ?? null);
168
168
  const row = db
169
- .prepare('SELECT * FROM playbook_approvals WHERE approval_id = ?')
169
+ .prepare('SELECT * FROM tasks_playbook_approvals WHERE approval_id = ?')
170
170
  .get(approvalId);
171
171
  if (row === undefined) {
172
172
  throw new Error(`${E_APPROVAL_NOT_FOUND}: insert did not round-trip (approval_id=${approvalId})`);
@@ -209,7 +209,9 @@ export function rejectGate(db, token, approver, reason) {
209
209
  * @internal
210
210
  */
211
211
  function transitionGate(db, token, next, approver, reason) {
212
- const existing = db.prepare('SELECT * FROM playbook_approvals WHERE token = ?').get(token);
212
+ const existing = db
213
+ .prepare('SELECT * FROM tasks_playbook_approvals WHERE token = ?')
214
+ .get(token);
213
215
  if (existing === undefined) {
214
216
  throw new Error(`${E_APPROVAL_NOT_FOUND}: no approval gate for token`);
215
217
  }
@@ -218,10 +220,10 @@ function transitionGate(db, token, next, approver, reason) {
218
220
  const approvalId = readString(existing, 'approval_id');
219
221
  throw new Error(`${E_APPROVAL_ALREADY_DECIDED}: gate ${approvalId} is already ${existingStatus}`);
220
222
  }
221
- db.prepare(`UPDATE playbook_approvals
223
+ db.prepare(`UPDATE tasks_playbook_approvals
222
224
  SET status = ?, approved_at = datetime('now'), approver = ?, reason = ?
223
225
  WHERE token = ?`).run(next, approver, reason ?? null, token);
224
- const row = db.prepare('SELECT * FROM playbook_approvals WHERE token = ?').get(token);
226
+ const row = db.prepare('SELECT * FROM tasks_playbook_approvals WHERE token = ?').get(token);
225
227
  if (row === undefined) {
226
228
  // Unreachable: UPDATE just succeeded on this token.
227
229
  throw new Error(`${E_APPROVAL_NOT_FOUND}: row vanished after update (token=${token})`);
@@ -236,7 +238,7 @@ function transitionGate(db, token, next, approver, reason) {
236
238
  */
237
239
  export function getPendingApprovals(db) {
238
240
  const rows = db
239
- .prepare(`SELECT * FROM playbook_approvals
241
+ .prepare(`SELECT * FROM tasks_playbook_approvals
240
242
  WHERE status = 'pending'
241
243
  ORDER BY requested_at ASC, approval_id ASC`)
242
244
  .all();
package/dist/state.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * State layer for playbook runtime — CRUD against playbook_runs + playbook_approvals.
2
+ * State layer for playbook runtime — CRUD against tasks_playbook_runs + tasks_playbook_approvals.
3
3
  * Uses node:sqlite DatabaseSync for consistency with rest of CLEO.
4
4
  *
5
5
  * All JSON-shaped columns (`bindings`, `iteration_counts`) are serialized to
@@ -67,9 +67,10 @@ export declare function updatePlaybookRun(db: DatabaseSync, runId: string, patch
67
67
  */
68
68
  export declare function listPlaybookRuns(db: DatabaseSync, opts?: ListPlaybookRunsOptions): PlaybookRun[];
69
69
  /**
70
- * Deletes a playbook run by primary key. Returns `true` if a row was removed.
71
- * CASCADE wipes all associated approvals via the foreign-key constraint on
72
- * `playbook_approvals.run_id`.
70
+ * Deletes a playbook run by primary key, with its approvals, in one
71
+ * transaction. Returns `true` if the run was removed. `tasks_playbook_approvals`
72
+ * has no foreign key to the run (the bare twin cascaded), so the approvals are
73
+ * deleted first, explicitly: each delete is then a captured op of its own.
73
74
  */
74
75
  export declare function deletePlaybookRun(db: DatabaseSync, runId: string): boolean;
75
76
  /**
package/dist/state.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * State layer for playbook runtime — CRUD against playbook_runs + playbook_approvals.
2
+ * State layer for playbook runtime — CRUD against tasks_playbook_runs + tasks_playbook_approvals.
3
3
  * Uses node:sqlite DatabaseSync for consistency with rest of CLEO.
4
4
  *
5
5
  * All JSON-shaped columns (`bindings`, `iteration_counts`) are serialized to
@@ -30,7 +30,7 @@ function parseJsonColumn(raw, column, runId) {
30
30
  }
31
31
  }
32
32
  /**
33
- * Maps a snake_case `playbook_runs` row to the contract-shaped
33
+ * Maps a snake_case `tasks_playbook_runs` row to the contract-shaped
34
34
  * {@link PlaybookRun}. Performs strict JSON parsing and validates the status
35
35
  * column against the enum.
36
36
  */
@@ -53,7 +53,7 @@ function rowToPlaybookRun(row) {
53
53
  };
54
54
  }
55
55
  /**
56
- * Maps a snake_case `playbook_approvals` row to the contract-shaped
56
+ * Maps a snake_case `tasks_playbook_approvals` row to the contract-shaped
57
57
  * {@link PlaybookApproval}. Converts the integer `auto_passed` column to a
58
58
  * boolean at the boundary.
59
59
  */
@@ -83,12 +83,12 @@ function rowToPlaybookApproval(row) {
83
83
  export function createPlaybookRun(db, input) {
84
84
  const runId = randomUUID();
85
85
  const bindingsJson = JSON.stringify(input.initialBindings ?? {});
86
- const insert = db.prepare(`INSERT INTO playbook_runs (
86
+ const insert = db.prepare(`INSERT INTO tasks_playbook_runs (
87
87
  run_id, playbook_name, playbook_hash, bindings, status,
88
88
  iteration_counts, epic_id, session_id
89
89
  ) VALUES (?, ?, ?, ?, 'running', '{}', ?, ?)`);
90
90
  insert.run(runId, input.playbookName, input.playbookHash, bindingsJson, input.epicId ?? null, input.sessionId ?? null);
91
- const row = db.prepare('SELECT * FROM playbook_runs WHERE run_id = ?').get(runId);
91
+ const row = db.prepare('SELECT * FROM tasks_playbook_runs WHERE run_id = ?').get(runId);
92
92
  if (!row) {
93
93
  throw new Error(`playbook state: failed to read back run ${runId} after insert`);
94
94
  }
@@ -99,7 +99,7 @@ export function createPlaybookRun(db, input) {
99
99
  * does not exist. Never throws on missing rows.
100
100
  */
101
101
  export function getPlaybookRun(db, runId) {
102
- const row = db.prepare('SELECT * FROM playbook_runs WHERE run_id = ?').get(runId);
102
+ const row = db.prepare('SELECT * FROM tasks_playbook_runs WHERE run_id = ?').get(runId);
103
103
  return row ? rowToPlaybookRun(row) : null;
104
104
  }
105
105
  /**
@@ -159,7 +159,7 @@ export function updatePlaybookRun(db, runId, patch) {
159
159
  }
160
160
  db.exec('BEGIN');
161
161
  try {
162
- const stmt = db.prepare(`UPDATE playbook_runs SET ${sets.join(', ')} WHERE run_id = ?`);
162
+ const stmt = db.prepare(`UPDATE tasks_playbook_runs SET ${sets.join(', ')} WHERE run_id = ?`);
163
163
  values.push(runId);
164
164
  const result = stmt.run(...values);
165
165
  if (result.changes === 0) {
@@ -171,7 +171,7 @@ export function updatePlaybookRun(db, runId, patch) {
171
171
  db.exec('ROLLBACK');
172
172
  throw err;
173
173
  }
174
- const row = db.prepare('SELECT * FROM playbook_runs WHERE run_id = ?').get(runId);
174
+ const row = db.prepare('SELECT * FROM tasks_playbook_runs WHERE run_id = ?').get(runId);
175
175
  if (!row) {
176
176
  throw new Error(`playbook state: run ${runId} disappeared after update`);
177
177
  }
@@ -196,18 +196,28 @@ export function listPlaybookRuns(db, opts = {}) {
196
196
  const limitClause = typeof opts.limit === 'number' ? 'LIMIT ?' : '';
197
197
  if (limitClause)
198
198
  values.push(opts.limit ?? 0);
199
- const sql = `SELECT * FROM playbook_runs ${where} ORDER BY started_at DESC ${limitClause}`;
199
+ const sql = `SELECT * FROM tasks_playbook_runs ${where} ORDER BY started_at DESC ${limitClause}`;
200
200
  const rows = db.prepare(sql).all(...values);
201
201
  return rows.map(rowToPlaybookRun);
202
202
  }
203
203
  /**
204
- * Deletes a playbook run by primary key. Returns `true` if a row was removed.
205
- * CASCADE wipes all associated approvals via the foreign-key constraint on
206
- * `playbook_approvals.run_id`.
204
+ * Deletes a playbook run by primary key, with its approvals, in one
205
+ * transaction. Returns `true` if the run was removed. `tasks_playbook_approvals`
206
+ * has no foreign key to the run (the bare twin cascaded), so the approvals are
207
+ * deleted first, explicitly: each delete is then a captured op of its own.
207
208
  */
208
209
  export function deletePlaybookRun(db, runId) {
209
- const result = db.prepare('DELETE FROM playbook_runs WHERE run_id = ?').run(runId);
210
- return Number(result.changes) > 0;
210
+ db.exec('BEGIN');
211
+ try {
212
+ db.prepare('DELETE FROM tasks_playbook_approvals WHERE run_id = ?').run(runId);
213
+ const result = db.prepare('DELETE FROM tasks_playbook_runs WHERE run_id = ?').run(runId);
214
+ db.exec('COMMIT');
215
+ return Number(result.changes) > 0;
216
+ }
217
+ catch (err) {
218
+ db.exec('ROLLBACK');
219
+ throw err;
220
+ }
211
221
  }
212
222
  // ---------------------------------------------------------------------------
213
223
  // Playbook approval CRUD
@@ -220,11 +230,11 @@ export function deletePlaybookRun(db, runId) {
220
230
  export function createPlaybookApproval(db, input) {
221
231
  const approvalId = randomUUID();
222
232
  const autoPassed = input.autoPassed ? 1 : 0;
223
- db.prepare(`INSERT INTO playbook_approvals (
233
+ db.prepare(`INSERT INTO tasks_playbook_approvals (
224
234
  approval_id, run_id, node_id, token, status, auto_passed
225
235
  ) VALUES (?, ?, ?, ?, 'pending', ?)`).run(approvalId, input.runId, input.nodeId, input.token, autoPassed);
226
236
  const row = db
227
- .prepare('SELECT * FROM playbook_approvals WHERE approval_id = ?')
237
+ .prepare('SELECT * FROM tasks_playbook_approvals WHERE approval_id = ?')
228
238
  .get(approvalId);
229
239
  if (!row) {
230
240
  throw new Error(`playbook state: failed to read back approval ${approvalId} after insert`);
@@ -236,7 +246,7 @@ export function createPlaybookApproval(db, input) {
236
246
  * The token column carries a UNIQUE constraint so at most one row is returned.
237
247
  */
238
248
  export function getPlaybookApprovalByToken(db, token) {
239
- const row = db.prepare('SELECT * FROM playbook_approvals WHERE token = ?').get(token);
249
+ const row = db.prepare('SELECT * FROM tasks_playbook_approvals WHERE token = ?').get(token);
240
250
  return row ? rowToPlaybookApproval(row) : null;
241
251
  }
242
252
  /**
@@ -276,7 +286,7 @@ export function updatePlaybookApproval(db, approvalId, patch) {
276
286
  // same guard transitionGate applies): a peer would refuse the move, so
277
287
  // the origin refuses it too (T12944).
278
288
  const current = db
279
- .prepare('SELECT status FROM playbook_approvals WHERE approval_id = ?')
289
+ .prepare('SELECT status FROM tasks_playbook_approvals WHERE approval_id = ?')
280
290
  .get(approvalId);
281
291
  if (current && current.status !== 'pending' && current.status !== patch.status) {
282
292
  // @sync-invariant playbook.approval.once a decided gate keeps its decision locally, as every peer's merge keeps it
@@ -291,7 +301,7 @@ export function updatePlaybookApproval(db, approvalId, patch) {
291
301
  }
292
302
  if (sets.length === 0) {
293
303
  const row = db
294
- .prepare('SELECT * FROM playbook_approvals WHERE approval_id = ?')
304
+ .prepare('SELECT * FROM tasks_playbook_approvals WHERE approval_id = ?')
295
305
  .get(approvalId);
296
306
  if (!row) {
297
307
  throw new Error(`playbook state: approval ${approvalId} not found for update`);
@@ -300,7 +310,7 @@ export function updatePlaybookApproval(db, approvalId, patch) {
300
310
  }
301
311
  db.exec('BEGIN');
302
312
  try {
303
- const stmt = db.prepare(`UPDATE playbook_approvals SET ${sets.join(', ')} WHERE approval_id = ?`);
313
+ const stmt = db.prepare(`UPDATE tasks_playbook_approvals SET ${sets.join(', ')} WHERE approval_id = ?`);
304
314
  values.push(approvalId);
305
315
  const result = stmt.run(...values);
306
316
  if (result.changes === 0) {
@@ -313,7 +323,7 @@ export function updatePlaybookApproval(db, approvalId, patch) {
313
323
  throw err;
314
324
  }
315
325
  const row = db
316
- .prepare('SELECT * FROM playbook_approvals WHERE approval_id = ?')
326
+ .prepare('SELECT * FROM tasks_playbook_approvals WHERE approval_id = ?')
317
327
  .get(approvalId);
318
328
  if (!row) {
319
329
  throw new Error(`playbook state: approval ${approvalId} disappeared after update`);
@@ -326,7 +336,7 @@ export function updatePlaybookApproval(db, approvalId, patch) {
326
336
  */
327
337
  export function listPlaybookApprovals(db, runId) {
328
338
  const rows = db
329
- .prepare('SELECT * FROM playbook_approvals WHERE run_id = ? ORDER BY requested_at ASC, approval_id ASC')
339
+ .prepare('SELECT * FROM tasks_playbook_approvals WHERE run_id = ? ORDER BY requested_at ASC, approval_id ASC')
330
340
  .all(runId);
331
341
  return rows.map(rowToPlaybookApproval);
332
342
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cleocode/playbooks",
3
- "version": "2026.10.18",
3
+ "version": "2026.10.19",
4
4
  "description": "Playbook DSL + runtime for CLEO — T889 Orchestration Coherence v3",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -19,8 +19,8 @@
19
19
  "dependencies": {
20
20
  "drizzle-orm": "1.0.0-rc.4",
21
21
  "js-yaml": "^4.1.0",
22
- "@cleocode/core": "2026.10.18",
23
- "@cleocode/contracts": "2026.10.18"
22
+ "@cleocode/contracts": "2026.10.19",
23
+ "@cleocode/core": "2026.10.19"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@types/js-yaml": "^4.0.9",
@@ -7,11 +7,8 @@
7
7
  * @task T889 / T908 / W4-16
8
8
  */
9
9
 
10
- import { readFileSync } from 'node:fs';
11
10
  import { createRequire } from 'node:module';
12
- import { dirname, resolve } from 'node:path';
13
11
  import type { DatabaseSync as _DatabaseSyncType } from 'node:sqlite';
14
- import { fileURLToPath } from 'node:url';
15
12
  import { afterEach, beforeEach, describe, expect, it } from 'vitest';
16
13
  import {
17
14
  approveGate,
@@ -23,6 +20,7 @@ import {
23
20
  getPlaybookSecret,
24
21
  rejectGate,
25
22
  } from '../approval.js';
23
+ import { applyPlaybookTwinSchema } from './twin-schema.js';
26
24
 
27
25
  const _require = createRequire(import.meta.url);
28
26
  type DatabaseSync = _DatabaseSyncType;
@@ -30,27 +28,9 @@ const { DatabaseSync } = _require('node:sqlite') as {
30
28
  DatabaseSync: new (...args: ConstructorParameters<typeof _DatabaseSyncType>) => DatabaseSync;
31
29
  };
32
30
 
33
- const __dirname = dirname(fileURLToPath(import.meta.url));
34
- const MIGRATION_SQL = resolve(
35
- __dirname,
36
- '../../../core/migrations/drizzle-tasks/20260417220000_t889-playbook-tables/migration.sql',
37
- );
38
-
39
- function applyMigration(db: DatabaseSync, sql: string): void {
40
- const statements = sql
41
- .split(/--> statement-breakpoint/)
42
- .map((s) => s.trim())
43
- .filter((s) => s.length > 0);
44
- for (const stmt of statements) {
45
- const lines = stmt.split('\n');
46
- const hasSql = lines.some((l) => l.trim().length > 0 && !l.trim().startsWith('--'));
47
- if (hasSql) db.exec(stmt);
48
- }
49
- }
50
-
51
31
  function seedRun(db: DatabaseSync, runId: string): void {
52
32
  db.prepare(
53
- `INSERT INTO playbook_runs (run_id, playbook_name, playbook_hash)
33
+ `INSERT INTO tasks_playbook_runs (run_id, playbook_name, playbook_hash)
54
34
  VALUES (?, 'rcasd', 'hash')`,
55
35
  ).run(runId);
56
36
  }
@@ -103,7 +83,7 @@ describe('W4-16: Approval DB operations', () => {
103
83
  beforeEach(() => {
104
84
  db = new DatabaseSync(':memory:');
105
85
  db.exec('PRAGMA foreign_keys=ON');
106
- applyMigration(db, readFileSync(MIGRATION_SQL, 'utf8'));
86
+ applyPlaybookTwinSchema(db);
107
87
  seedRun(db, 'run-1');
108
88
  });
109
89
 
@@ -143,7 +123,7 @@ describe('W4-16: Approval DB operations', () => {
143
123
  expect(approval.reason).toBe('auto-approve low-risk deterministic step');
144
124
 
145
125
  const row = db
146
- .prepare('SELECT auto_passed FROM playbook_approvals WHERE approval_id = ?')
126
+ .prepare('SELECT auto_passed FROM tasks_playbook_approvals WHERE approval_id = ?')
147
127
  .get(approval.approvalId) as { auto_passed: number };
148
128
  expect(row.auto_passed).toBe(1);
149
129
  });
@@ -266,7 +246,7 @@ describe('W4-16: Approval DB operations', () => {
266
246
 
267
247
  // Query by token round-trips the same approvalId
268
248
  const lookup = db
269
- .prepare('SELECT approval_id FROM playbook_approvals WHERE token = ?')
249
+ .prepare('SELECT approval_id FROM tasks_playbook_approvals WHERE token = ?')
270
250
  .get(a.token) as { approval_id: string };
271
251
  expect(lookup.approval_id).toBe(a.approvalId);
272
252
 
@@ -33,12 +33,11 @@
33
33
  * @saga T11387
34
34
  */
35
35
 
36
- import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
36
+ import { mkdtempSync, rmSync } from 'node:fs';
37
37
  import { createRequire } from 'node:module';
38
38
  import { tmpdir } from 'node:os';
39
- import { dirname, join, resolve } from 'node:path';
39
+ import { join } from 'node:path';
40
40
  import type { DatabaseSync as _DatabaseSyncType } from 'node:sqlite';
41
- import { fileURLToPath } from 'node:url';
42
41
  import type { PlaybookDefinition, PlaybookDeterministicNode } from '@cleocode/contracts';
43
42
  import {
44
43
  createGuardedDeterministicRunner,
@@ -47,6 +46,7 @@ import {
47
46
  } from '@cleocode/core/internal';
48
47
  import { afterEach, beforeEach, describe, expect, it } from 'vitest';
49
48
  import { type DeterministicRunner, executePlaybook } from '../runtime.js';
49
+ import { playbookTwinSql } from './twin-schema.js';
50
50
 
51
51
  const _require = createRequire(import.meta.url);
52
52
  type DatabaseSync = _DatabaseSyncType;
@@ -54,13 +54,7 @@ const { DatabaseSync } = _require('node:sqlite') as {
54
54
  DatabaseSync: new (...args: ConstructorParameters<typeof _DatabaseSyncType>) => DatabaseSync;
55
55
  };
56
56
 
57
- const __dirname = dirname(fileURLToPath(import.meta.url));
58
-
59
57
  /** Absolute path to the T889 playbook-tables migration SQL. */
60
- const MIGRATION_SQL = resolve(
61
- __dirname,
62
- '../../../core/migrations/drizzle-tasks/20260417220000_t889-playbook-tables/migration.sql',
63
- );
64
58
 
65
59
  /** Apply a multi-statement Drizzle migration (split on the breakpoint token). */
66
60
  function applyMigration(db: DatabaseSync, sql: string): void {
@@ -87,7 +81,7 @@ describe('T11802 (M4): deterministic node dispatch routes through guard.ts', ()
87
81
  beforeEach(() => {
88
82
  db = new DatabaseSync(':memory:');
89
83
  db.exec('PRAGMA foreign_keys=ON');
90
- applyMigration(db, readFileSync(MIGRATION_SQL, 'utf8'));
84
+ applyMigration(db, playbookTwinSql());
91
85
  projectRoot = mkdtempSync(join(tmpdir(), 't11802-guarded-shell-'));
92
86
  });
93
87
 
@@ -18,11 +18,8 @@
18
18
  * @task T1944 — wire PlaybookAgenticNode.inputs → AgentDispatchInput.bindings
19
19
  */
20
20
 
21
- import { readFileSync } from 'node:fs';
22
21
  import { createRequire } from 'node:module';
23
- import { dirname, resolve } from 'node:path';
24
22
  import type { DatabaseSync as _DatabaseSyncType } from 'node:sqlite';
25
- import { fileURLToPath } from 'node:url';
26
23
  import type { PlaybookAgenticNode, PlaybookDefinition } from '@cleocode/contracts';
27
24
  import { afterEach, beforeEach, describe, expect, it } from 'vitest';
28
25
  import {
@@ -32,6 +29,7 @@ import {
32
29
  executePlaybook,
33
30
  resolveStepBindings,
34
31
  } from '../runtime.js';
32
+ import { playbookTwinSql } from './twin-schema.js';
35
33
 
36
34
  const _require = createRequire(import.meta.url);
37
35
  type DatabaseSync = _DatabaseSyncType;
@@ -39,12 +37,6 @@ const { DatabaseSync } = _require('node:sqlite') as {
39
37
  DatabaseSync: new (...args: ConstructorParameters<typeof _DatabaseSyncType>) => DatabaseSync;
40
38
  };
41
39
 
42
- const __dirname = dirname(fileURLToPath(import.meta.url));
43
- const MIGRATION_SQL = resolve(
44
- __dirname,
45
- '../../../core/migrations/drizzle-tasks/20260417220000_t889-playbook-tables/migration.sql',
46
- );
47
-
48
40
  function applyMigration(db: DatabaseSync, sql: string): void {
49
41
  const statements = sql
50
42
  .split(/--> statement-breakpoint/)
@@ -169,7 +161,7 @@ describe('T1944: AgentDispatchInput.bindings (in-process)', () => {
169
161
  beforeEach(() => {
170
162
  db = new DatabaseSync(':memory:');
171
163
  db.exec('PRAGMA foreign_keys=ON');
172
- applyMigration(db, readFileSync(MIGRATION_SQL, 'utf8'));
164
+ applyMigration(db, playbookTwinSql());
173
165
  });
174
166
  afterEach(() => db.close());
175
167
 
@@ -19,11 +19,8 @@
19
19
  * @task T1947
20
20
  */
21
21
 
22
- import { readFileSync } from 'node:fs';
23
22
  import { createRequire } from 'node:module';
24
- import { dirname, resolve } from 'node:path';
25
23
  import type { DatabaseSync as _DatabaseSyncType } from 'node:sqlite';
26
- import { fileURLToPath } from 'node:url';
27
24
  import { afterEach, beforeEach, describe, expect, it } from 'vitest';
28
25
  import { parsePlaybook } from '../parser.js';
29
26
  import {
@@ -32,6 +29,7 @@ import {
32
29
  type AgentDispatchResult,
33
30
  executePlaybook,
34
31
  } from '../runtime.js';
32
+ import { playbookTwinSql } from './twin-schema.js';
35
33
 
36
34
  const _require = createRequire(import.meta.url);
37
35
  type DatabaseSync = _DatabaseSyncType;
@@ -39,12 +37,6 @@ const { DatabaseSync } = _require('node:sqlite') as {
39
37
  DatabaseSync: new (...args: ConstructorParameters<typeof _DatabaseSyncType>) => DatabaseSync;
40
38
  };
41
39
 
42
- const __dirname = dirname(fileURLToPath(import.meta.url));
43
- const MIGRATION_SQL = resolve(
44
- __dirname,
45
- '../../../core/migrations/drizzle-tasks/20260417220000_t889-playbook-tables/migration.sql',
46
- );
47
-
48
40
  function applyMigration(db: DatabaseSync, sql: string): void {
49
41
  const statements = sql
50
42
  .split(/--> statement-breakpoint/)
@@ -125,7 +117,7 @@ describe('T1947: AC5 — allowlists reach the dispatch boundary', () => {
125
117
  beforeEach(() => {
126
118
  db = new DatabaseSync(':memory:');
127
119
  db.exec('PRAGMA foreign_keys=ON');
128
- applyMigration(db, readFileSync(MIGRATION_SQL, 'utf8'));
120
+ applyMigration(db, playbookTwinSql());
129
121
  });
130
122
  afterEach(() => db.close());
131
123
 
@@ -16,11 +16,8 @@
16
16
  * @task T11759
17
17
  */
18
18
 
19
- import { readFileSync } from 'node:fs';
20
19
  import { createRequire } from 'node:module';
21
- import { dirname, resolve } from 'node:path';
22
20
  import type { DatabaseSync as _DatabaseSyncType } from 'node:sqlite';
23
- import { fileURLToPath } from 'node:url';
24
21
  import { afterEach, beforeEach, describe, expect, it } from 'vitest';
25
22
  import { parsePlaybook } from '../parser.js';
26
23
  import {
@@ -29,6 +26,7 @@ import {
29
26
  type AgentDispatchResult,
30
27
  executePlaybook,
31
28
  } from '../runtime.js';
29
+ import { playbookTwinSql } from './twin-schema.js';
32
30
 
33
31
  const _require = createRequire(import.meta.url);
34
32
  type DatabaseSync = _DatabaseSyncType;
@@ -36,12 +34,6 @@ const { DatabaseSync } = _require('node:sqlite') as {
36
34
  DatabaseSync: new (...args: ConstructorParameters<typeof _DatabaseSyncType>) => DatabaseSync;
37
35
  };
38
36
 
39
- const __dirname = dirname(fileURLToPath(import.meta.url));
40
- const MIGRATION_SQL = resolve(
41
- __dirname,
42
- '../../../core/migrations/drizzle-tasks/20260417220000_t889-playbook-tables/migration.sql',
43
- );
44
-
45
37
  function applyMigration(db: DatabaseSync, sql: string): void {
46
38
  const statements = sql
47
39
  .split(/--> statement-breakpoint/)
@@ -72,7 +64,7 @@ describe('T11759: node profile pin reaches the dispatch boundary', () => {
72
64
  beforeEach(() => {
73
65
  db = new DatabaseSync(':memory:');
74
66
  db.exec('PRAGMA foreign_keys=ON');
75
- applyMigration(db, readFileSync(MIGRATION_SQL, 'utf8'));
67
+ applyMigration(db, playbookTwinSql());
76
68
  });
77
69
  afterEach(() => db.close());
78
70
 
@@ -5,9 +5,9 @@
5
5
  *
6
6
  * ## What this proves (the M4 keystone)
7
7
  *
8
- * The two production cantbook dispatchers (`buildDefaultDispatcher` in
9
- * `packages/cleo/src/dispatch/domains/playbook.ts` and `buildGoDispatcher` in
10
- * `packages/cleo/src/cli/commands/go-ivtr-runner.ts`) now pass
8
+ * The production cantbook dispatcher (`buildDefaultDispatcher` in
9
+ * `packages/cleo/src/dispatch/domains/playbook.ts`; `cleo go`'s
10
+ * `buildGoDispatcher` was retired in T13821) passes
11
11
  * `runner: await maybeCreatePiRunner({ system, projectRoot })` into
12
12
  * {@link runSkillNodeOrSpawn}. This test reconstructs that EXACT production
13
13
  * dispatcher shape (`createToolGuard` + `runSkillNodeOrSpawn` + the
@@ -60,6 +60,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
60
60
  import { parsePlaybook } from '../parser.js';
61
61
  import { executePlaybook } from '../runtime.js';
62
62
  import { getPlaybookRun } from '../state.js';
63
+ import { playbookTwinSql } from './twin-schema.js';
63
64
 
64
65
  const _require = createRequire(import.meta.url);
65
66
  type DatabaseSync = _DatabaseSyncType;
@@ -70,10 +71,6 @@ const { DatabaseSync } = _require('node:sqlite') as {
70
71
  const __dirname = dirname(fileURLToPath(import.meta.url));
71
72
 
72
73
  /** Absolute path to the T889 playbook-tables migration SQL. */
73
- const MIGRATION_SQL_PATH = resolve(
74
- __dirname,
75
- '../../../core/migrations/drizzle-tasks/20260417220000_t889-playbook-tables/migration.sql',
76
- );
77
74
 
78
75
  /** The skill-only fixture cantbook materialised for this suite. */
79
76
  const FIXTURE_PATH = resolve(__dirname, 'fixtures/pi-skill-only.cantbook');
@@ -187,7 +184,7 @@ describe('T11945 (M4): skill-only .cantbook routes through the PiAgentAdapter',
187
184
  beforeEach(() => {
188
185
  db = new DatabaseSync(':memory:');
189
186
  db.exec('PRAGMA foreign_keys=ON');
190
- applyMigration(db, readFileSync(MIGRATION_SQL_PATH, 'utf8'));
187
+ applyMigration(db, playbookTwinSql());
191
188
 
192
189
  projectRoot = mkdtempSync(join(tmpdir(), 't11945-pi-e2e-'));
193
190
  writeFixtureSkill(projectRoot);
@@ -50,6 +50,7 @@ import {
50
50
  type ExecutePlaybookResult,
51
51
  executePlaybook,
52
52
  } from '../runtime.js';
53
+ import { playbookTwinSql } from './twin-schema.js';
53
54
 
54
55
  const _require = createRequire(import.meta.url);
55
56
  type DatabaseSync = _DatabaseSyncType;
@@ -60,10 +61,6 @@ const { DatabaseSync } = _require('node:sqlite') as {
60
61
  const __dirname = dirname(fileURLToPath(import.meta.url));
61
62
  const PLAYBOOKS_SRC = resolve(__dirname, '..');
62
63
 
63
- const MIGRATION_SQL = resolve(
64
- __dirname,
65
- '../../../core/migrations/drizzle-tasks/20260417220000_t889-playbook-tables/migration.sql',
66
- );
67
64
  const RCASD_CANTBOOK = resolve(__dirname, '../../starter/rcasd.cantbook');
68
65
 
69
66
  function applyMigration(db: DatabaseSync, sql: string): void {
@@ -155,7 +152,7 @@ describe('T11803 (M4): rcasd.cantbook → exactly ONE LAFS envelope, no GenKit',
155
152
  beforeEach(() => {
156
153
  db = new DatabaseSync(':memory:');
157
154
  db.exec('PRAGMA foreign_keys=ON');
158
- applyMigration(db, readFileSync(MIGRATION_SQL, 'utf8'));
155
+ applyMigration(db, playbookTwinSql());
159
156
  });
160
157
  afterEach(() => db.close());
161
158
 
@@ -35,6 +35,7 @@ import {
35
35
  resumePlaybook,
36
36
  } from '../runtime.js';
37
37
  import { getPlaybookRun, listPlaybookApprovals } from '../state.js';
38
+ import { applyPlaybookTwinSchema } from './twin-schema.js';
38
39
 
39
40
  const _require = createRequire(import.meta.url);
40
41
  type DatabaseSync = _DatabaseSyncType;
@@ -43,24 +44,8 @@ const { DatabaseSync } = _require('node:sqlite') as {
43
44
  };
44
45
 
45
46
  const __dirname = dirname(fileURLToPath(import.meta.url));
46
- const MIGRATION_SQL = resolve(
47
- __dirname,
48
- '../../../core/migrations/drizzle-tasks/20260417220000_t889-playbook-tables/migration.sql',
49
- );
50
47
  const CONDITIONAL_BRANCH_CANTBOOK = resolve(__dirname, 'fixtures/conditional-branch.cantbook');
51
48
 
52
- function applyMigration(db: DatabaseSync, sql: string): void {
53
- const statements = sql
54
- .split(/--> statement-breakpoint/)
55
- .map((s) => s.trim())
56
- .filter((s) => s.length > 0);
57
- for (const stmt of statements) {
58
- const lines = stmt.split('\n');
59
- const hasSql = lines.some((l) => l.trim().length > 0 && !l.trim().startsWith('--'));
60
- if (hasSql) db.exec(stmt);
61
- }
62
- }
63
-
64
49
  // -- Dispatcher stub helpers -------------------------------------------------
65
50
 
66
51
  interface RecordedCall {
@@ -158,7 +143,7 @@ describe('W4-10 / T930: playbook runtime state machine', () => {
158
143
  beforeEach(() => {
159
144
  db = new DatabaseSync(':memory:');
160
145
  db.exec('PRAGMA foreign_keys=ON');
161
- applyMigration(db, readFileSync(MIGRATION_SQL, 'utf8'));
146
+ applyPlaybookTwinSchema(db);
162
147
  });
163
148
  afterEach(() => db.close());
164
149
 
@@ -674,7 +659,7 @@ describe('W4-10 / T930: playbook runtime state machine', () => {
674
659
  // The SQLite migration stamps completedAt via JS, and updatePlaybookRun
675
660
  // writes our supplied timestamp.
676
661
  const runs = db
677
- .prepare("SELECT completed_at FROM playbook_runs WHERE playbook_name = 'clock'")
662
+ .prepare("SELECT completed_at FROM tasks_playbook_runs WHERE playbook_name = 'clock'")
678
663
  .all() as Array<{ completed_at: string }>;
679
664
  expect(runs[0]?.completed_at).toBe('2026-04-17T22:30:00.000Z');
680
665
  });
@@ -690,7 +675,7 @@ describe('T11806: cantbook runtime guarded branching', () => {
690
675
  beforeEach(() => {
691
676
  db = new DatabaseSync(':memory:');
692
677
  db.exec('PRAGMA foreign_keys=ON');
693
- applyMigration(db, readFileSync(MIGRATION_SQL, 'utf8'));
678
+ applyPlaybookTwinSchema(db);
694
679
  });
695
680
  afterEach(() => db.close());
696
681
 
@@ -923,7 +908,7 @@ describe('T11762 ST-2: registry-driven ensures.schema enforcement', () => {
923
908
  beforeEach(() => {
924
909
  db = new DatabaseSync(':memory:');
925
910
  db.exec('PRAGMA foreign_keys=ON');
926
- applyMigration(db, readFileSync(MIGRATION_SQL, 'utf8'));
911
+ applyPlaybookTwinSchema(db);
927
912
  tmpRoot = mkdtempSync(join(tmpdir(), 'cleo-ensures-schema-'));
928
913
  });
929
914
  afterEach(() => {
@@ -33,6 +33,7 @@ import {
33
33
  resumePlaybook,
34
34
  } from '../runtime.js';
35
35
  import { getPlaybookRun, listPlaybookApprovals } from '../state.js';
36
+ import { playbookTwinSql } from './twin-schema.js';
36
37
 
37
38
  const _require = createRequire(import.meta.url);
38
39
  type DatabaseSync = _DatabaseSyncType;
@@ -43,10 +44,6 @@ const { DatabaseSync } = _require('node:sqlite') as {
43
44
  const __dirname = dirname(fileURLToPath(import.meta.url));
44
45
 
45
46
  /** Absolute path to the T889 playbook-tables migration SQL. */
46
- const MIGRATION_SQL_PATH = resolve(
47
- __dirname,
48
- '../../../core/migrations/drizzle-tasks/20260417220000_t889-playbook-tables/migration.sql',
49
- );
50
47
 
51
48
  /** Absolute path to the `starter/` directory shipped with this package. */
52
49
  const STARTER_DIR = resolve(__dirname, '../../starter');
@@ -205,7 +202,7 @@ describe('T934: starter playbooks — E2E against stubbed dispatcher', () => {
205
202
  beforeEach(() => {
206
203
  db = new DatabaseSync(':memory:');
207
204
  db.exec('PRAGMA foreign_keys=ON');
208
- applyMigration(db, readFileSync(MIGRATION_SQL_PATH, 'utf8'));
205
+ applyMigration(db, playbookTwinSql());
209
206
  });
210
207
  afterEach(() => db.close());
211
208
 
@@ -5,11 +5,8 @@
5
5
  * @task T889 / T904 / W4-8
6
6
  */
7
7
 
8
- import { readFileSync } from 'node:fs';
9
8
  import { createRequire } from 'node:module';
10
- import { dirname, resolve } from 'node:path';
11
9
  import type { DatabaseSync as _DatabaseSyncType } from 'node:sqlite';
12
- import { fileURLToPath } from 'node:url';
13
10
  import { afterEach, beforeEach, describe, expect, it } from 'vitest';
14
11
  import {
15
12
  createPlaybookApproval,
@@ -22,6 +19,7 @@ import {
22
19
  updatePlaybookApproval,
23
20
  updatePlaybookRun,
24
21
  } from '../state.js';
22
+ import { applyPlaybookTwinSchema } from './twin-schema.js';
25
23
 
26
24
  const _require = createRequire(import.meta.url);
27
25
  type DatabaseSync = _DatabaseSyncType;
@@ -29,31 +27,13 @@ const { DatabaseSync } = _require('node:sqlite') as {
29
27
  DatabaseSync: new (...args: ConstructorParameters<typeof _DatabaseSyncType>) => DatabaseSync;
30
28
  };
31
29
 
32
- const __dirname = dirname(fileURLToPath(import.meta.url));
33
- const MIGRATION_SQL = resolve(
34
- __dirname,
35
- '../../../core/migrations/drizzle-tasks/20260417220000_t889-playbook-tables/migration.sql',
36
- );
37
-
38
- function applyMigration(db: DatabaseSync, sql: string): void {
39
- const statements = sql
40
- .split(/--> statement-breakpoint/)
41
- .map((s) => s.trim())
42
- .filter((s) => s.length > 0);
43
- for (const stmt of statements) {
44
- const lines = stmt.split('\n');
45
- const hasSql = lines.some((l) => l.trim().length > 0 && !l.trim().startsWith('--'));
46
- if (hasSql) db.exec(stmt);
47
- }
48
- }
49
-
50
30
  describe('W4-8: playbook state CRUD', () => {
51
31
  let db: DatabaseSync;
52
32
 
53
33
  beforeEach(() => {
54
34
  db = new DatabaseSync(':memory:');
55
35
  db.exec('PRAGMA foreign_keys=ON');
56
- applyMigration(db, readFileSync(MIGRATION_SQL, 'utf8'));
36
+ applyPlaybookTwinSchema(db);
57
37
  });
58
38
  afterEach(() => db.close());
59
39
 
@@ -271,7 +251,7 @@ describe('W4-8: playbook state CRUD', () => {
271
251
  playbookName: 'p',
272
252
  playbookHash: 'h',
273
253
  });
274
- db.prepare('UPDATE playbook_runs SET bindings = ? WHERE run_id = ?').run(
254
+ db.prepare('UPDATE tasks_playbook_runs SET bindings = ? WHERE run_id = ?').run(
275
255
  '{not-json',
276
256
  run.runId,
277
257
  );
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Test helper: the playbook twins (`tasks_playbook_runs`,
3
+ * `tasks_playbook_approvals`) exactly as a project store creates them, read
4
+ * from the consolidation migration plus the uid migration, so the state layer
5
+ * is tested against the tables it writes (T12535).
6
+ *
7
+ * @task T12535
8
+ */
9
+
10
+ import { readFileSync } from 'node:fs';
11
+ import { dirname, resolve } from 'node:path';
12
+ import type { DatabaseSync } from 'node:sqlite';
13
+ import { fileURLToPath } from 'node:url';
14
+
15
+ const MIGRATIONS = resolve(
16
+ dirname(fileURLToPath(import.meta.url)),
17
+ '../../../core/migrations/drizzle-cleo-project',
18
+ );
19
+
20
+ /**
21
+ * The DDL of both playbook twins (with their uid columns), as statements
22
+ * separated by `--> statement-breakpoint`.
23
+ */
24
+ export function playbookTwinSql(): string {
25
+ const consolidation = readFileSync(
26
+ resolve(MIGRATIONS, '20260531000001_t11363-consolidation-cleo-project/migration.sql'),
27
+ 'utf8',
28
+ );
29
+ const tables = ['tasks_playbook_runs', 'tasks_playbook_approvals'].map((table) => {
30
+ const ddl = consolidation.match(new RegExp(`CREATE TABLE \`${table}\` \\([\\s\\S]*?\\n\\);`));
31
+ if (!ddl) throw new Error(`no CREATE TABLE for ${table} in the consolidation migration`);
32
+ return ddl[0];
33
+ });
34
+ const uids = readFileSync(
35
+ resolve(MIGRATIONS, '20261016120000_t12535-playbook-row-uids/migration.sql'),
36
+ 'utf8',
37
+ );
38
+ return [...tables, uids].join('\n--> statement-breakpoint\n');
39
+ }
40
+
41
+ /** Create both playbook twins (with their uid columns) on `db`. */
42
+ export function applyPlaybookTwinSchema(db: DatabaseSync): void {
43
+ for (const stmt of playbookTwinSql().split('--> statement-breakpoint')) {
44
+ if (stmt.split('\n').some((l) => l.trim() !== '' && !l.trim().startsWith('--'))) db.exec(stmt);
45
+ }
46
+ }
package/src/approval.ts CHANGED
@@ -82,7 +82,7 @@ export function generateResumeToken(
82
82
  * Input for {@link createApprovalGate}.
83
83
  */
84
84
  export interface CreateApprovalGateInput {
85
- /** Run identifier (FK to `playbook_runs.run_id`). */
85
+ /** Run identifier (FK to `tasks_playbook_runs.run_id`). */
86
86
  runId: string;
87
87
  /** Node identifier within the run graph. */
88
88
  nodeId: string;
@@ -106,7 +106,7 @@ export interface CreateApprovalGateInput {
106
106
  */
107
107
  function narrowStatus(s: string): PlaybookApprovalStatus {
108
108
  if (s === 'pending' || s === 'approved' || s === 'rejected') return s;
109
- throw new Error(`invariant: unknown playbook_approvals.status '${s}'`);
109
+ throw new Error(`invariant: unknown tasks_playbook_approvals.status '${s}'`);
110
110
  }
111
111
 
112
112
  /**
@@ -150,7 +150,7 @@ function readOptionalString(row: Record<string, unknown>, key: string): string |
150
150
  }
151
151
 
152
152
  /**
153
- * Map a raw `playbook_approvals` row to the camelCase {@link PlaybookApproval}
153
+ * Map a raw `tasks_playbook_approvals` row to the camelCase {@link PlaybookApproval}
154
154
  * contract shape. Validates types, converts the `auto_passed` 0/1 integer to
155
155
  * a boolean, and strips nullable fields rather than emitting `null`.
156
156
  *
@@ -176,7 +176,7 @@ function rowToApproval(row: Record<string, unknown>): PlaybookApproval {
176
176
  }
177
177
 
178
178
  /**
179
- * Create an HITL approval gate row in `playbook_approvals`.
179
+ * Create an HITL approval gate row in `tasks_playbook_approvals`.
180
180
  *
181
181
  * If `autoPassed` is true, the gate is written with `status='approved'`
182
182
  * and `auto_passed=1` — used by the policy engine to short-circuit gates
@@ -196,7 +196,7 @@ export function createApprovalGate(
196
196
  const autoPassed = input.autoPassed ?? false;
197
197
  const status: PlaybookApprovalStatus = autoPassed ? 'approved' : 'pending';
198
198
  const stmt = db.prepare(`
199
- INSERT INTO playbook_approvals
199
+ INSERT INTO tasks_playbook_approvals
200
200
  (approval_id, run_id, node_id, token, status, auto_passed, approver, reason)
201
201
  VALUES (?, ?, ?, ?, ?, ?, ?, ?)
202
202
  `);
@@ -211,7 +211,7 @@ export function createApprovalGate(
211
211
  input.reason ?? null,
212
212
  );
213
213
  const row = db
214
- .prepare('SELECT * FROM playbook_approvals WHERE approval_id = ?')
214
+ .prepare('SELECT * FROM tasks_playbook_approvals WHERE approval_id = ?')
215
215
  .get(approvalId) as Record<string, unknown> | undefined;
216
216
  if (row === undefined) {
217
217
  throw new Error(
@@ -275,9 +275,9 @@ function transitionGate(
275
275
  approver: string,
276
276
  reason?: string,
277
277
  ): PlaybookApproval {
278
- const existing = db.prepare('SELECT * FROM playbook_approvals WHERE token = ?').get(token) as
279
- | Record<string, unknown>
280
- | undefined;
278
+ const existing = db
279
+ .prepare('SELECT * FROM tasks_playbook_approvals WHERE token = ?')
280
+ .get(token) as Record<string, unknown> | undefined;
281
281
  if (existing === undefined) {
282
282
  throw new Error(`${E_APPROVAL_NOT_FOUND}: no approval gate for token`);
283
283
  }
@@ -289,11 +289,11 @@ function transitionGate(
289
289
  );
290
290
  }
291
291
  db.prepare(
292
- `UPDATE playbook_approvals
292
+ `UPDATE tasks_playbook_approvals
293
293
  SET status = ?, approved_at = datetime('now'), approver = ?, reason = ?
294
294
  WHERE token = ?`,
295
295
  ).run(next, approver, reason ?? null, token);
296
- const row = db.prepare('SELECT * FROM playbook_approvals WHERE token = ?').get(token) as
296
+ const row = db.prepare('SELECT * FROM tasks_playbook_approvals WHERE token = ?').get(token) as
297
297
  | Record<string, unknown>
298
298
  | undefined;
299
299
  if (row === undefined) {
@@ -312,7 +312,7 @@ function transitionGate(
312
312
  export function getPendingApprovals(db: DatabaseSync): PlaybookApproval[] {
313
313
  const rows = db
314
314
  .prepare(
315
- `SELECT * FROM playbook_approvals
315
+ `SELECT * FROM tasks_playbook_approvals
316
316
  WHERE status = 'pending'
317
317
  ORDER BY requested_at ASC, approval_id ASC`,
318
318
  )
package/src/state.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * State layer for playbook runtime — CRUD against playbook_runs + playbook_approvals.
2
+ * State layer for playbook runtime — CRUD against tasks_playbook_runs + tasks_playbook_approvals.
3
3
  * Uses node:sqlite DatabaseSync for consistency with rest of CLEO.
4
4
  *
5
5
  * All JSON-shaped columns (`bindings`, `iteration_counts`) are serialized to
@@ -111,7 +111,7 @@ function parseJsonColumn<T>(raw: string, column: string, runId: string): T {
111
111
  }
112
112
 
113
113
  /**
114
- * Maps a snake_case `playbook_runs` row to the contract-shaped
114
+ * Maps a snake_case `tasks_playbook_runs` row to the contract-shaped
115
115
  * {@link PlaybookRun}. Performs strict JSON parsing and validates the status
116
116
  * column against the enum.
117
117
  */
@@ -140,7 +140,7 @@ function rowToPlaybookRun(row: PlaybookRunRow): PlaybookRun {
140
140
  }
141
141
 
142
142
  /**
143
- * Maps a snake_case `playbook_approvals` row to the contract-shaped
143
+ * Maps a snake_case `tasks_playbook_approvals` row to the contract-shaped
144
144
  * {@link PlaybookApproval}. Converts the integer `auto_passed` column to a
145
145
  * boolean at the boundary.
146
146
  */
@@ -174,7 +174,7 @@ export function createPlaybookRun(db: DatabaseSync, input: CreatePlaybookRunInpu
174
174
  const bindingsJson = JSON.stringify(input.initialBindings ?? {});
175
175
 
176
176
  const insert = db.prepare(
177
- `INSERT INTO playbook_runs (
177
+ `INSERT INTO tasks_playbook_runs (
178
178
  run_id, playbook_name, playbook_hash, bindings, status,
179
179
  iteration_counts, epic_id, session_id
180
180
  ) VALUES (?, ?, ?, ?, 'running', '{}', ?, ?)`,
@@ -188,7 +188,7 @@ export function createPlaybookRun(db: DatabaseSync, input: CreatePlaybookRunInpu
188
188
  input.sessionId ?? null,
189
189
  );
190
190
 
191
- const row = db.prepare('SELECT * FROM playbook_runs WHERE run_id = ?').get(runId) as
191
+ const row = db.prepare('SELECT * FROM tasks_playbook_runs WHERE run_id = ?').get(runId) as
192
192
  | PlaybookRunRow
193
193
  | undefined;
194
194
 
@@ -203,7 +203,7 @@ export function createPlaybookRun(db: DatabaseSync, input: CreatePlaybookRunInpu
203
203
  * does not exist. Never throws on missing rows.
204
204
  */
205
205
  export function getPlaybookRun(db: DatabaseSync, runId: string): PlaybookRun | null {
206
- const row = db.prepare('SELECT * FROM playbook_runs WHERE run_id = ?').get(runId) as
206
+ const row = db.prepare('SELECT * FROM tasks_playbook_runs WHERE run_id = ?').get(runId) as
207
207
  | PlaybookRunRow
208
208
  | undefined;
209
209
  return row ? rowToPlaybookRun(row) : null;
@@ -273,7 +273,7 @@ export function updatePlaybookRun(
273
273
 
274
274
  db.exec('BEGIN');
275
275
  try {
276
- const stmt = db.prepare(`UPDATE playbook_runs SET ${sets.join(', ')} WHERE run_id = ?`);
276
+ const stmt = db.prepare(`UPDATE tasks_playbook_runs SET ${sets.join(', ')} WHERE run_id = ?`);
277
277
  values.push(runId);
278
278
  const result = stmt.run(...values);
279
279
  if (result.changes === 0) {
@@ -285,7 +285,7 @@ export function updatePlaybookRun(
285
285
  throw err;
286
286
  }
287
287
 
288
- const row = db.prepare('SELECT * FROM playbook_runs WHERE run_id = ?').get(runId) as
288
+ const row = db.prepare('SELECT * FROM tasks_playbook_runs WHERE run_id = ?').get(runId) as
289
289
  | PlaybookRunRow
290
290
  | undefined;
291
291
  if (!row) {
@@ -316,19 +316,28 @@ export function listPlaybookRuns(
316
316
  const limitClause = typeof opts.limit === 'number' ? 'LIMIT ?' : '';
317
317
  if (limitClause) values.push(opts.limit ?? 0);
318
318
 
319
- const sql = `SELECT * FROM playbook_runs ${where} ORDER BY started_at DESC ${limitClause}`;
319
+ const sql = `SELECT * FROM tasks_playbook_runs ${where} ORDER BY started_at DESC ${limitClause}`;
320
320
  const rows = db.prepare(sql).all(...values) as unknown as PlaybookRunRow[];
321
321
  return rows.map(rowToPlaybookRun);
322
322
  }
323
323
 
324
324
  /**
325
- * Deletes a playbook run by primary key. Returns `true` if a row was removed.
326
- * CASCADE wipes all associated approvals via the foreign-key constraint on
327
- * `playbook_approvals.run_id`.
325
+ * Deletes a playbook run by primary key, with its approvals, in one
326
+ * transaction. Returns `true` if the run was removed. `tasks_playbook_approvals`
327
+ * has no foreign key to the run (the bare twin cascaded), so the approvals are
328
+ * deleted first, explicitly: each delete is then a captured op of its own.
328
329
  */
329
330
  export function deletePlaybookRun(db: DatabaseSync, runId: string): boolean {
330
- const result = db.prepare('DELETE FROM playbook_runs WHERE run_id = ?').run(runId);
331
- return Number(result.changes) > 0;
331
+ db.exec('BEGIN');
332
+ try {
333
+ db.prepare('DELETE FROM tasks_playbook_approvals WHERE run_id = ?').run(runId);
334
+ const result = db.prepare('DELETE FROM tasks_playbook_runs WHERE run_id = ?').run(runId);
335
+ db.exec('COMMIT');
336
+ return Number(result.changes) > 0;
337
+ } catch (err) {
338
+ db.exec('ROLLBACK');
339
+ throw err;
340
+ }
332
341
  }
333
342
 
334
343
  // ---------------------------------------------------------------------------
@@ -348,13 +357,13 @@ export function createPlaybookApproval(
348
357
  const autoPassed = input.autoPassed ? 1 : 0;
349
358
 
350
359
  db.prepare(
351
- `INSERT INTO playbook_approvals (
360
+ `INSERT INTO tasks_playbook_approvals (
352
361
  approval_id, run_id, node_id, token, status, auto_passed
353
362
  ) VALUES (?, ?, ?, ?, 'pending', ?)`,
354
363
  ).run(approvalId, input.runId, input.nodeId, input.token, autoPassed);
355
364
 
356
365
  const row = db
357
- .prepare('SELECT * FROM playbook_approvals WHERE approval_id = ?')
366
+ .prepare('SELECT * FROM tasks_playbook_approvals WHERE approval_id = ?')
358
367
  .get(approvalId) as PlaybookApprovalRow | undefined;
359
368
  if (!row) {
360
369
  throw new Error(`playbook state: failed to read back approval ${approvalId} after insert`);
@@ -370,7 +379,7 @@ export function getPlaybookApprovalByToken(
370
379
  db: DatabaseSync,
371
380
  token: string,
372
381
  ): PlaybookApproval | null {
373
- const row = db.prepare('SELECT * FROM playbook_approvals WHERE token = ?').get(token) as
382
+ const row = db.prepare('SELECT * FROM tasks_playbook_approvals WHERE token = ?').get(token) as
374
383
  | PlaybookApprovalRow
375
384
  | undefined;
376
385
  return row ? rowToPlaybookApproval(row) : null;
@@ -418,7 +427,7 @@ export function updatePlaybookApproval(
418
427
  // same guard transitionGate applies): a peer would refuse the move, so
419
428
  // the origin refuses it too (T12944).
420
429
  const current = db
421
- .prepare('SELECT status FROM playbook_approvals WHERE approval_id = ?')
430
+ .prepare('SELECT status FROM tasks_playbook_approvals WHERE approval_id = ?')
422
431
  .get(approvalId) as { status: string } | undefined;
423
432
  if (current && current.status !== 'pending' && current.status !== patch.status) {
424
433
  // @sync-invariant playbook.approval.once a decided gate keeps its decision locally, as every peer's merge keeps it
@@ -436,7 +445,7 @@ export function updatePlaybookApproval(
436
445
 
437
446
  if (sets.length === 0) {
438
447
  const row = db
439
- .prepare('SELECT * FROM playbook_approvals WHERE approval_id = ?')
448
+ .prepare('SELECT * FROM tasks_playbook_approvals WHERE approval_id = ?')
440
449
  .get(approvalId) as PlaybookApprovalRow | undefined;
441
450
  if (!row) {
442
451
  throw new Error(`playbook state: approval ${approvalId} not found for update`);
@@ -447,7 +456,7 @@ export function updatePlaybookApproval(
447
456
  db.exec('BEGIN');
448
457
  try {
449
458
  const stmt = db.prepare(
450
- `UPDATE playbook_approvals SET ${sets.join(', ')} WHERE approval_id = ?`,
459
+ `UPDATE tasks_playbook_approvals SET ${sets.join(', ')} WHERE approval_id = ?`,
451
460
  );
452
461
  values.push(approvalId);
453
462
  const result = stmt.run(...values);
@@ -461,7 +470,7 @@ export function updatePlaybookApproval(
461
470
  }
462
471
 
463
472
  const row = db
464
- .prepare('SELECT * FROM playbook_approvals WHERE approval_id = ?')
473
+ .prepare('SELECT * FROM tasks_playbook_approvals WHERE approval_id = ?')
465
474
  .get(approvalId) as PlaybookApprovalRow | undefined;
466
475
  if (!row) {
467
476
  throw new Error(`playbook state: approval ${approvalId} disappeared after update`);
@@ -476,7 +485,7 @@ export function updatePlaybookApproval(
476
485
  export function listPlaybookApprovals(db: DatabaseSync, runId: string): PlaybookApproval[] {
477
486
  const rows = db
478
487
  .prepare(
479
- 'SELECT * FROM playbook_approvals WHERE run_id = ? ORDER BY requested_at ASC, approval_id ASC',
488
+ 'SELECT * FROM tasks_playbook_approvals WHERE run_id = ? ORDER BY requested_at ASC, approval_id ASC',
480
489
  )
481
490
  .all(runId) as unknown as PlaybookApprovalRow[];
482
491
  return rows.map(rowToPlaybookApproval);