@cleocode/playbooks 2026.6.12 → 2026.6.14

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,239 @@
1
+ /**
2
+ * T11803 (M4 cantbook done-gate) — prove the vertical slice: `rcasd.cantbook`
3
+ * execution terminates with EXACTLY ONE LAFS `RenderableEnvelope`, and there is
4
+ * NO GenKit import anywhere in the execution call graph.
5
+ *
6
+ * ## What this proves
7
+ *
8
+ * - **AC1** — the REAL starter `rcasd.cantbook` is parsed and driven end-to-end
9
+ * through {@link executePlaybook} (parse → execute → terminal envelope). The
10
+ * five agentic stages are stubbed by a DETERMINISTIC recording dispatcher (no
11
+ * LLM backend authenticates on CI, and the point of this gate is the ENVELOPE
12
+ * SHAPE + the import graph — NOT a live model round-trip).
13
+ * - **AC2** — the terminal result is folded into exactly ONE LAFS envelope (the
14
+ * ADR-077 `RenderableEnvelope<T>` `{ kind: 'single', data }` wrapped by the
15
+ * canonical `createEnvelope` SSoT) and that envelope passes the
16
+ * `@cleocode/lafs` `validateEnvelope` schema validator. Exactly one — the run
17
+ * produces one `ExecutePlaybookResult`, which maps to one envelope.
18
+ * - **AC3** — NO `@genkit-ai` / GenKit import in the execution call graph: a
19
+ * static module-graph walk over every playbooks `src/` file reachable from
20
+ * `runtime.ts` (the executePlaybook entry) via intra-package imports asserts
21
+ * zero GenKit specifiers, and the runtime entry imports none directly.
22
+ * - **AC5** — the test is NOT skipped (no `.skip`, no `it.skipIf`).
23
+ *
24
+ * ## Determinism + isolation
25
+ *
26
+ * - IN-PROCESS only — vitest source, NO subprocess (tsx unresolvable in CI).
27
+ * - daemon-OFF — an in-memory `node:sqlite` DB, no daemon, no live LLM.
28
+ * - The dispatcher emits each stage's `ensures.schema`-satisfying output so the
29
+ * rcasd contract gates pass and the run reaches `completed` — a faithful
30
+ * full-pipeline traversal, deterministically.
31
+ *
32
+ * @epic T11391
33
+ * @task T11803
34
+ * @saga T11387
35
+ */
36
+
37
+ import { readdirSync, readFileSync } from 'node:fs';
38
+ import { createRequire } from 'node:module';
39
+ import { dirname, join, resolve } from 'node:path';
40
+ import type { DatabaseSync as _DatabaseSyncType } from 'node:sqlite';
41
+ import { fileURLToPath } from 'node:url';
42
+ import type { RenderableEnvelope } from '@cleocode/contracts';
43
+ import { createEnvelope, validateEnvelope } from '@cleocode/lafs';
44
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
45
+ import { parsePlaybook } from '../parser.js';
46
+ import {
47
+ type AgentDispatcher,
48
+ type AgentDispatchInput,
49
+ type AgentDispatchResult,
50
+ type ExecutePlaybookResult,
51
+ executePlaybook,
52
+ } from '../runtime.js';
53
+
54
+ const _require = createRequire(import.meta.url);
55
+ type DatabaseSync = _DatabaseSyncType;
56
+ const { DatabaseSync } = _require('node:sqlite') as {
57
+ DatabaseSync: new (...args: ConstructorParameters<typeof _DatabaseSyncType>) => DatabaseSync;
58
+ };
59
+
60
+ const __dirname = dirname(fileURLToPath(import.meta.url));
61
+ const PLAYBOOKS_SRC = resolve(__dirname, '..');
62
+
63
+ const MIGRATION_SQL = resolve(
64
+ __dirname,
65
+ '../../../core/migrations/drizzle-tasks/20260417220000_t889-playbook-tables/migration.sql',
66
+ );
67
+ const RCASD_CANTBOOK = resolve(__dirname, '../../starter/rcasd.cantbook');
68
+
69
+ function applyMigration(db: DatabaseSync, sql: string): void {
70
+ const statements = sql
71
+ .split(/--> statement-breakpoint/)
72
+ .map((s) => s.trim())
73
+ .filter((s) => s.length > 0);
74
+ for (const stmt of statements) {
75
+ const lines = stmt.split('\n');
76
+ const hasSql = lines.some((l) => l.trim().length > 0 && !l.trim().startsWith('--'));
77
+ if (hasSql) db.exec(stmt);
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Deterministic recording dispatcher that emits each rcasd stage's
83
+ * `ensures.schema`-satisfying output + the `requires`/edge fields the next stage
84
+ * needs, so the full five-stage pipeline reaches `completed` with NO LLM. Keyed
85
+ * by `nodeId` (the rcasd stage names).
86
+ */
87
+ function makeRcasdDispatcher(): AgentDispatcher & { nodes: string[] } {
88
+ const nodes: string[] = [];
89
+ // Per-stage outputs satisfying rcasd.cantbook ensures.schema + downstream
90
+ // requires/edge contracts. The first four stages' ensures.schema names
91
+ // (research_summary | consensus_decision | architecture_plan |
92
+ // specification_document) are registered passthrough; the terminal
93
+ // `decomposition` stage's `task_tree` is STRICT — it validates
94
+ // `context['task_tree']` as a non-empty array of { title, acceptance[] }
95
+ // entries — so we emit a conformant `task_tree` to clear the contract gate.
96
+ const STAGE_OUTPUT: Record<string, Record<string, unknown>> = {
97
+ research: {
98
+ summary: 'research summary for the epic',
99
+ risks: ['risk-a', 'risk-b'],
100
+ },
101
+ consensus: {
102
+ decision: 'proceed with approach X',
103
+ },
104
+ architecture: {
105
+ patterns: ['pattern-1', 'pattern-2'],
106
+ adrs: ['ADR-001'],
107
+ },
108
+ specification: {
109
+ acceptance: ['AC1', 'AC2'],
110
+ requirements: ['REQ-1'],
111
+ },
112
+ decomposition: {
113
+ // `children` satisfies the specification→decomposition edge.ensures.
114
+ children: ['T1001', 'T1002'],
115
+ acceptance: ['AC1'],
116
+ requirements: ['REQ-1'],
117
+ // `task_tree` satisfies the strict ensures.schema on the terminal stage.
118
+ task_tree: [
119
+ { id: 'T1001', title: 'Implement feature A', acceptance: ['AC1'] },
120
+ { id: 'T1002', title: 'Implement feature B', acceptance: ['AC2'] },
121
+ ],
122
+ },
123
+ };
124
+ return {
125
+ nodes,
126
+ async dispatch(input: AgentDispatchInput): Promise<AgentDispatchResult> {
127
+ nodes.push(input.nodeId);
128
+ return { status: 'success', output: STAGE_OUTPUT[input.nodeId] ?? {} };
129
+ },
130
+ };
131
+ }
132
+
133
+ /**
134
+ * Recursively collect every `*.ts` source file under `dir` (excluding tests),
135
+ * so the no-GenKit assertion covers the WHOLE playbooks runtime package surface
136
+ * reachable by the execution path — not just the entry file.
137
+ */
138
+ function collectSourceFiles(dir: string): string[] {
139
+ const out: string[] = [];
140
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
141
+ const full = join(dir, entry.name);
142
+ if (entry.isDirectory()) {
143
+ if (entry.name === '__tests__') continue;
144
+ out.push(...collectSourceFiles(full));
145
+ } else if (entry.name.endsWith('.ts')) {
146
+ out.push(full);
147
+ }
148
+ }
149
+ return out;
150
+ }
151
+
152
+ describe('T11803 (M4): rcasd.cantbook → exactly ONE LAFS envelope, no GenKit', () => {
153
+ let db: DatabaseSync;
154
+
155
+ beforeEach(() => {
156
+ db = new DatabaseSync(':memory:');
157
+ db.exec('PRAGMA foreign_keys=ON');
158
+ applyMigration(db, readFileSync(MIGRATION_SQL, 'utf8'));
159
+ });
160
+ afterEach(() => db.close());
161
+
162
+ it('AC1+AC2: parse→execute→envelope yields exactly ONE valid LAFS RenderableEnvelope', async () => {
163
+ const { definition, sourceHash } = parsePlaybook(readFileSync(RCASD_CANTBOOK, 'utf8'));
164
+ expect(definition.name).toBe('rcasd');
165
+ expect(definition.nodes.map((n) => n.id)).toEqual([
166
+ 'research',
167
+ 'consensus',
168
+ 'architecture',
169
+ 'specification',
170
+ 'decomposition',
171
+ ]);
172
+
173
+ const dispatcher = makeRcasdDispatcher();
174
+ const result: ExecutePlaybookResult = await executePlaybook({
175
+ db,
176
+ playbook: definition,
177
+ playbookHash: sourceHash,
178
+ initialContext: { taskId: 'T999', epicId: 'T999', scope: 'global' },
179
+ dispatcher,
180
+ });
181
+
182
+ // The full five-stage pipeline ran deterministically to completion.
183
+ expect(dispatcher.nodes).toEqual([
184
+ 'research',
185
+ 'consensus',
186
+ 'architecture',
187
+ 'specification',
188
+ 'decomposition',
189
+ ]);
190
+ expect(result.terminalStatus).toBe('completed');
191
+ // No silent contract violation hid behind the completion.
192
+ expect(result.finalContext['__ensuresSchemaViolation']).toBeUndefined();
193
+ expect(result.finalContext['__contractViolation']).toBeUndefined();
194
+
195
+ // Fold the single terminal result into ONE ADR-077 RenderableEnvelope, then
196
+ // into ONE canonical LAFS envelope (the SSoT `createEnvelope`).
197
+ const renderable: RenderableEnvelope<ExecutePlaybookResult> = {
198
+ kind: 'single',
199
+ data: result,
200
+ };
201
+ const lafsEnvelope = createEnvelope({
202
+ success: true,
203
+ result: renderable as unknown as Record<string, unknown>,
204
+ meta: { operation: 'playbook.run', requestId: `rcasd-${result.runId}` },
205
+ });
206
+
207
+ // EXACTLY ONE — a single object, not an array of envelopes.
208
+ expect(Array.isArray(lafsEnvelope)).toBe(false);
209
+ expect(lafsEnvelope.success).toBe(true);
210
+ expect(lafsEnvelope.result).toMatchObject({ kind: 'single' });
211
+
212
+ // Validated by the @cleocode/lafs schema validator.
213
+ const validation = validateEnvelope(lafsEnvelope);
214
+ expect(validation.errors).toEqual([]);
215
+ expect(validation.valid).toBe(true);
216
+ });
217
+
218
+ it('AC3: NO @genkit-ai / GenKit import in the execution call graph', () => {
219
+ // The execution call graph for executePlaybook (runtime → state, approval,
220
+ // parser, schema) lives entirely under packages/playbooks/src. Walk every
221
+ // non-test source file and assert zero GenKit specifiers.
222
+ const files = collectSourceFiles(PLAYBOOKS_SRC);
223
+ expect(files.length).toBeGreaterThan(0);
224
+
225
+ const offenders: string[] = [];
226
+ const GENKIT_IMPORT =
227
+ /(?:import|from|require)\s*(?:\(\s*)?['"]@?genkit(?:-ai)?(?:\/[^'"]*)?['"]/;
228
+ for (const file of files) {
229
+ const src = readFileSync(file, 'utf8');
230
+ if (GENKIT_IMPORT.test(src)) offenders.push(file);
231
+ }
232
+ expect(offenders).toEqual([]);
233
+
234
+ // And the runtime entry — the executePlaybook module itself — imports no
235
+ // GenKit specifier directly (belt-and-suspenders against a future edit).
236
+ const runtimeSrc = readFileSync(resolve(PLAYBOOKS_SRC, 'runtime.ts'), 'utf8');
237
+ expect(runtimeSrc).not.toMatch(/@?genkit/i);
238
+ });
239
+ });