@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,225 @@
1
+ /**
2
+ * T11802 (M4 cantbook done-gate) — every `deterministic` (shell) node dispatch in
3
+ * `executePlaybook` routes through the `guard.ts` deny-first chokepoint.
4
+ *
5
+ * ## What this proves
6
+ *
7
+ * - **AC1 / AC2** — a minimal `.cantbook` with a `deterministic` shell step,
8
+ * executed through the REAL {@link executePlaybook} runtime with the
9
+ * production-shaped {@link createGuardedDeterministicRunner} wired to a real
10
+ * {@link createToolGuard}, reaches the guard: the spawned command actually runs
11
+ * THROUGH `guard.executeShell` (proven by captured stdout in the merged run
12
+ * context). There is NO `child_process` import in the runtime call graph — the
13
+ * guard owns the spawn.
14
+ * - **AC3** — when the SAME runtime dispatches a `deterministic` node whose
15
+ * command is on the guard's denylist (`enforce` mode), the guard REJECTS it
16
+ * BEFORE any process is spawned; the node terminates as a contained failure
17
+ * (the runtime contract is non-throwing) carrying the guard's
18
+ * `E_TOOL_GUARD_DENIED` message.
19
+ * - **AC5** — no new state machine: the runtime is unchanged; only the injected
20
+ * `deterministicRunner` is the guard-routed one.
21
+ *
22
+ * ## Determinism + isolation
23
+ *
24
+ * - IN-PROCESS only — vitest source, NO subprocess driver (tsx unresolvable in CI).
25
+ * - daemon-OFF — an in-memory `node:sqlite` DB, no daemon.
26
+ * - The allowed command is `node -e <print>` (always present on the test box, no
27
+ * network, no filesystem mutation) so the "guard actually spawned" assertion is
28
+ * portable and side-effect-free.
29
+ * - A temp project root scopes the guard's `allowedRoots`; `cwd` is pinned to it.
30
+ *
31
+ * @epic T11391
32
+ * @task T11802
33
+ * @saga T11387
34
+ */
35
+
36
+ import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
37
+ import { createRequire } from 'node:module';
38
+ import { tmpdir } from 'node:os';
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 { PlaybookDefinition, PlaybookDeterministicNode } from '@cleocode/contracts';
43
+ import {
44
+ createGuardedDeterministicRunner,
45
+ createToolGuard,
46
+ DEFAULT_DETERMINISTIC_DENIED_COMMANDS,
47
+ } from '@cleocode/core/internal';
48
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
49
+ import { type DeterministicRunner, executePlaybook } from '../runtime.js';
50
+
51
+ const _require = createRequire(import.meta.url);
52
+ type DatabaseSync = _DatabaseSyncType;
53
+ const { DatabaseSync } = _require('node:sqlite') as {
54
+ DatabaseSync: new (...args: ConstructorParameters<typeof _DatabaseSyncType>) => DatabaseSync;
55
+ };
56
+
57
+ const __dirname = dirname(fileURLToPath(import.meta.url));
58
+
59
+ /** 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
+
65
+ /** Apply a multi-statement Drizzle migration (split on the breakpoint token). */
66
+ function applyMigration(db: DatabaseSync, sql: string): void {
67
+ const statements = sql
68
+ .split(/--> statement-breakpoint/)
69
+ .map((s) => s.trim())
70
+ .filter((s) => s.length > 0);
71
+ for (const stmt of statements) {
72
+ const lines = stmt.split('\n');
73
+ const hasSql = lines.some((l) => l.trim().length > 0 && !l.trim().startsWith('--'));
74
+ if (hasSql) db.exec(stmt);
75
+ }
76
+ }
77
+
78
+ /** Build a single-node `deterministic` playbook (the entry node is terminal). */
79
+ function singleDeterministicPlaybook(node: PlaybookDeterministicNode): PlaybookDefinition {
80
+ return { version: '1.0', name: 'guarded-shell', nodes: [node], edges: [] };
81
+ }
82
+
83
+ describe('T11802 (M4): deterministic node dispatch routes through guard.ts', () => {
84
+ let db: DatabaseSync;
85
+ let projectRoot: string;
86
+
87
+ beforeEach(() => {
88
+ db = new DatabaseSync(':memory:');
89
+ db.exec('PRAGMA foreign_keys=ON');
90
+ applyMigration(db, readFileSync(MIGRATION_SQL, 'utf8'));
91
+ projectRoot = mkdtempSync(join(tmpdir(), 't11802-guarded-shell-'));
92
+ });
93
+
94
+ afterEach(() => {
95
+ db.close();
96
+ rmSync(projectRoot, { recursive: true, force: true });
97
+ });
98
+
99
+ /**
100
+ * Build the production-shaped deterministic runner: the SAME guard the CLI's
101
+ * `buildDefaultDeterministicRunner` constructs (allowedRoots + denylist +
102
+ * enforce), wrapped by `createGuardedDeterministicRunner`.
103
+ */
104
+ function buildGuardedRunner(): DeterministicRunner {
105
+ const tools = createToolGuard({
106
+ allowedRoots: [projectRoot],
107
+ deniedCommands: DEFAULT_DETERMINISTIC_DENIED_COMMANDS,
108
+ mode: 'enforce',
109
+ });
110
+ // Structurally assignable to the runtime's DeterministicRunner.
111
+ return createGuardedDeterministicRunner({ tools });
112
+ }
113
+
114
+ it('AC2: a shell step is intercepted by the guard — the command runs THROUGH guard.executeShell', async () => {
115
+ // `node -e` prints a sentinel; capturing it proves the guard actually
116
+ // spawned the process (not a mock).
117
+ const SENTINEL = 'guard-intercepted-T11802';
118
+ const node: PlaybookDeterministicNode = {
119
+ id: 'shell',
120
+ type: 'deterministic',
121
+ command: process.execPath, // node — always present, never on the denylist
122
+ args: ['-e', `process.stdout.write(${JSON.stringify(SENTINEL)})`],
123
+ cwd: projectRoot,
124
+ };
125
+
126
+ const result = await executePlaybook({
127
+ db,
128
+ playbook: singleDeterministicPlaybook(node),
129
+ playbookHash: 'hash-allowed',
130
+ initialContext: { taskId: 'T11802' },
131
+ dispatcher: {
132
+ // The agentic dispatcher must NEVER be reached for a deterministic node.
133
+ async dispatch() {
134
+ return {
135
+ status: 'failure',
136
+ output: {},
137
+ error: 'agentic dispatcher reached for a deterministic node — guard bypassed!',
138
+ };
139
+ },
140
+ },
141
+ deterministicRunner: buildGuardedRunner(),
142
+ });
143
+
144
+ expect(result.terminalStatus).toBe('completed');
145
+ // The guard's executeShell captured the child's stdout into the merged
146
+ // context — dispositive that the command was spawned THROUGH the guard.
147
+ expect(result.finalContext['shell_stdout']).toBe(SENTINEL);
148
+ expect(result.finalContext['shell_exitCode']).toBe(0);
149
+ });
150
+
151
+ it('AC3: a denied command is rejected by the guard denylist when dispatched from executePlaybook', async () => {
152
+ // `rm` is on DEFAULT_DETERMINISTIC_DENIED_COMMANDS — the guard rejects it
153
+ // BEFORE any process is spawned (enforce mode).
154
+ expect(DEFAULT_DETERMINISTIC_DENIED_COMMANDS).toContain('rm');
155
+
156
+ const node: PlaybookDeterministicNode = {
157
+ id: 'danger',
158
+ type: 'deterministic',
159
+ command: 'rm',
160
+ args: ['-rf', join(projectRoot, 'anything')],
161
+ cwd: projectRoot,
162
+ // One attempt → first denial is terminal (no retry storms).
163
+ on_failure: { max_iterations: 1 },
164
+ };
165
+
166
+ const result = await executePlaybook({
167
+ db,
168
+ playbook: singleDeterministicPlaybook(node),
169
+ playbookHash: 'hash-denied',
170
+ initialContext: { taskId: 'T11802' },
171
+ dispatcher: {
172
+ async dispatch() {
173
+ return {
174
+ status: 'failure',
175
+ output: {},
176
+ error: 'agentic dispatcher reached for a deterministic node — guard bypassed!',
177
+ };
178
+ },
179
+ },
180
+ deterministicRunner: buildGuardedRunner(),
181
+ maxIterationsDefault: 1,
182
+ });
183
+
184
+ // The denial is a contained node failure (the runtime never throws).
185
+ expect(result.terminalStatus).toBe('exceeded_iteration_cap');
186
+ expect(result.exceededNodeId).toBe('danger');
187
+ // The guard's enforce-mode rejection message reached the runtime.
188
+ expect(result.errorContext ?? '').toMatch(/denylist|E_TOOL_GUARD_DENIED|"rm"/);
189
+ });
190
+
191
+ it('AC1: the guard is the ONLY spawn path — a denied command never spawns rm', async () => {
192
+ // Targeting a path INSIDE the project root so, IF the guard were bypassed,
193
+ // rm would actually delete it. The guard must stop it first. We assert the
194
+ // failure shape (no rm executed) rather than filesystem side effects so the
195
+ // test is hermetic even on a box without `rm`.
196
+ const node: PlaybookDeterministicNode = {
197
+ id: 'danger',
198
+ type: 'deterministic',
199
+ command: '/bin/rm', // absolute path — basename `rm` is still denied
200
+ args: ['-f', join(projectRoot, 'sentinel')],
201
+ cwd: projectRoot,
202
+ on_failure: { max_iterations: 1 },
203
+ };
204
+
205
+ let agenticReached = false;
206
+ const result = await executePlaybook({
207
+ db,
208
+ playbook: singleDeterministicPlaybook(node),
209
+ playbookHash: 'hash-abs-denied',
210
+ initialContext: { taskId: 'T11802' },
211
+ dispatcher: {
212
+ async dispatch() {
213
+ agenticReached = true;
214
+ return { status: 'success', output: { bypassed: true } };
215
+ },
216
+ },
217
+ deterministicRunner: buildGuardedRunner(),
218
+ maxIterationsDefault: 1,
219
+ });
220
+
221
+ expect(agenticReached).toBe(false); // never fell back to the agentic path
222
+ expect(result.terminalStatus).toBe('exceeded_iteration_cap');
223
+ expect(result.errorContext ?? '').toMatch(/denylist|E_TOOL_GUARD_DENIED|"rm"/);
224
+ });
225
+ });
@@ -0,0 +1,250 @@
1
+ /**
2
+ * T1944 (M4 cantbook done-gate) — `PlaybookAgenticNode.inputs` →
3
+ * `AgentDispatchInput.bindings` wiring + mustache resolution at the dispatch
4
+ * boundary.
5
+ *
6
+ * Two layers of coverage:
7
+ *
8
+ * 1. Pure-unit tests of {@link resolveStepBindings} — precedence, shadowing,
9
+ * dot-path resolution, and the lenient missing-key rule, exercised in
10
+ * isolation with no DB.
11
+ * 2. In-process `executePlaybook` end-to-end — a `.cantbook`-equivalent node
12
+ * with `inputs: { topic: '{{inputs.epicId}}' }` resolves and the bound
13
+ * value reaches {@link AgentDispatchInput.bindings} at dispatch time.
14
+ *
15
+ * No `@cleocode/*` module is mocked — the resolver is the real canonical
16
+ * T1238 engine and the runtime runs against an in-memory `node:sqlite` DB.
17
+ *
18
+ * @task T1944 — wire PlaybookAgenticNode.inputs → AgentDispatchInput.bindings
19
+ */
20
+
21
+ import { readFileSync } from 'node:fs';
22
+ import { createRequire } from 'node:module';
23
+ import { dirname, resolve } from 'node:path';
24
+ import type { DatabaseSync as _DatabaseSyncType } from 'node:sqlite';
25
+ import { fileURLToPath } from 'node:url';
26
+ import type { PlaybookAgenticNode, PlaybookDefinition } from '@cleocode/contracts';
27
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
28
+ import {
29
+ type AgentDispatcher,
30
+ type AgentDispatchInput,
31
+ type AgentDispatchResult,
32
+ executePlaybook,
33
+ resolveStepBindings,
34
+ } from '../runtime.js';
35
+
36
+ const _require = createRequire(import.meta.url);
37
+ type DatabaseSync = _DatabaseSyncType;
38
+ const { DatabaseSync } = _require('node:sqlite') as {
39
+ DatabaseSync: new (...args: ConstructorParameters<typeof _DatabaseSyncType>) => DatabaseSync;
40
+ };
41
+
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
+ function applyMigration(db: DatabaseSync, sql: string): void {
49
+ const statements = sql
50
+ .split(/--> statement-breakpoint/)
51
+ .map((s) => s.trim())
52
+ .filter((s) => s.length > 0);
53
+ for (const stmt of statements) {
54
+ const lines = stmt.split('\n');
55
+ const hasSql = lines.some((l) => l.trim().length > 0 && !l.trim().startsWith('--'));
56
+ if (hasSql) db.exec(stmt);
57
+ }
58
+ }
59
+
60
+ function agenticNode(
61
+ id: string,
62
+ overrides: Partial<PlaybookAgenticNode> = {},
63
+ ): PlaybookAgenticNode {
64
+ return { id, type: 'agentic', skill: `skill-${id}`, ...overrides };
65
+ }
66
+
67
+ /** Dispatcher that records the full {@link AgentDispatchInput} per call. */
68
+ function makeBindingRecorder(
69
+ handler: (input: AgentDispatchInput) => AgentDispatchResult,
70
+ ): AgentDispatcher & { calls: AgentDispatchInput[] } {
71
+ const calls: AgentDispatchInput[] = [];
72
+ return {
73
+ calls,
74
+ async dispatch(input: AgentDispatchInput): Promise<AgentDispatchResult> {
75
+ // Snapshot bindings/context so later mutation cannot taint the assertion.
76
+ calls.push({
77
+ ...input,
78
+ context: { ...input.context },
79
+ bindings: input.bindings === undefined ? undefined : { ...input.bindings },
80
+ });
81
+ return handler(input);
82
+ },
83
+ };
84
+ }
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // resolveStepBindings — pure unit
88
+ // ---------------------------------------------------------------------------
89
+
90
+ describe('T1944: resolveStepBindings (pure)', () => {
91
+ it('AC5: resolves a {{inputs.epicId}} template from the run context', () => {
92
+ const node = agenticNode('research', { inputs: { topic: '{{inputs.epicId}}' } });
93
+ const context = { inputs: { epicId: 'T1942' } };
94
+
95
+ const bound = resolveStepBindings(node, context);
96
+
97
+ expect(bound['topic']).toBe('T1942');
98
+ // Original context keys remain present.
99
+ expect(bound['inputs']).toEqual({ epicId: 'T1942' });
100
+ });
101
+
102
+ it('AC5: resolves a flat {{epicId}} single-segment template', () => {
103
+ const node = agenticNode('research', { inputs: { topic: '{{epicId}}' } });
104
+ const bound = resolveStepBindings(node, { epicId: 'T1942' });
105
+ expect(bound['topic']).toBe('T1942');
106
+ });
107
+
108
+ it('AC6: step bindings shadow playbook bindings for the same key', () => {
109
+ // The run context already carries `topic`; the step input MUST win.
110
+ const node = agenticNode('research', { inputs: { topic: '{{inputs.epicId}}' } });
111
+ const context = { topic: 'stale-context-value', inputs: { epicId: 'T1942' } };
112
+
113
+ const bound = resolveStepBindings(node, context);
114
+
115
+ expect(bound['topic']).toBe('T1942');
116
+ expect(bound['topic']).not.toBe('stale-context-value');
117
+ });
118
+
119
+ it('missing-key rule: unresolved {{path}} is left as a literal placeholder', () => {
120
+ const node = agenticNode('research', { inputs: { topic: '{{inputs.nope}}' } });
121
+ const bound = resolveStepBindings(node, { inputs: { epicId: 'T1942' } });
122
+ expect(bound['topic']).toBe('{{inputs.nope}}');
123
+ });
124
+
125
+ it('resolves dot-path nesting deeper than one level', () => {
126
+ const node = agenticNode('research', { inputs: { fw: '{{project.testing.framework}}' } });
127
+ const bound = resolveStepBindings(node, {
128
+ project: { testing: { framework: 'vitest' } },
129
+ });
130
+ expect(bound['fw']).toBe('vitest');
131
+ });
132
+
133
+ it('passes literal (non-template) input values through unchanged', () => {
134
+ const node = agenticNode('research', { inputs: { mode: 'fast' } });
135
+ const bound = resolveStepBindings(node, { x: 1 });
136
+ expect(bound['mode']).toBe('fast');
137
+ });
138
+
139
+ it('returns a shallow copy of context when the node declares no inputs', () => {
140
+ const node = agenticNode('research');
141
+ const context = { taskId: 'T123', a: 1 };
142
+ const bound = resolveStepBindings(node, context);
143
+ expect(bound).toEqual(context);
144
+ expect(bound).not.toBe(context);
145
+ });
146
+
147
+ it('returns a shallow copy of context for an empty inputs map', () => {
148
+ const node = agenticNode('research', { inputs: {} });
149
+ const context = { taskId: 'T123' };
150
+ expect(resolveStepBindings(node, context)).toEqual(context);
151
+ });
152
+
153
+ it('does not mutate the source context', () => {
154
+ const node = agenticNode('research', { inputs: { topic: '{{epicId}}' } });
155
+ const context = { epicId: 'T1942' };
156
+ resolveStepBindings(node, context);
157
+ expect(context).toEqual({ epicId: 'T1942' });
158
+ expect(context).not.toHaveProperty('topic');
159
+ });
160
+ });
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // executePlaybook — bindings reach the dispatch boundary (in-process e2e)
164
+ // ---------------------------------------------------------------------------
165
+
166
+ describe('T1944: AgentDispatchInput.bindings (in-process)', () => {
167
+ let db: DatabaseSync;
168
+
169
+ beforeEach(() => {
170
+ db = new DatabaseSync(':memory:');
171
+ db.exec('PRAGMA foreign_keys=ON');
172
+ applyMigration(db, readFileSync(MIGRATION_SQL, 'utf8'));
173
+ });
174
+ afterEach(() => db.close());
175
+
176
+ it('AC2: resolved step input reaches AgentDispatchInput.bindings at dispatch', async () => {
177
+ const playbook: PlaybookDefinition = {
178
+ version: '1.0',
179
+ name: 'inputs-bindings',
180
+ nodes: [agenticNode('research', { inputs: { topic: '{{inputs.epicId}}' } })],
181
+ edges: [],
182
+ };
183
+ const dispatcher = makeBindingRecorder(() => ({ status: 'success', output: {} }));
184
+
185
+ const result = await executePlaybook({
186
+ db,
187
+ playbook,
188
+ playbookHash: 'hash-t1944',
189
+ initialContext: { inputs: { epicId: 'T1942' } },
190
+ dispatcher,
191
+ });
192
+
193
+ expect(result.terminalStatus).toBe('completed');
194
+ expect(dispatcher.calls).toHaveLength(1);
195
+ // The resolved binding (NOT the raw template) reaches the dispatcher.
196
+ expect(dispatcher.calls[0]?.bindings?.['topic']).toBe('T1942');
197
+ // The raw accumulated context still carries the source object.
198
+ expect(dispatcher.calls[0]?.context['inputs']).toEqual({ epicId: 'T1942' });
199
+ });
200
+
201
+ it('AC2: bindings is always populated even when the node declares no inputs', async () => {
202
+ const playbook: PlaybookDefinition = {
203
+ version: '1.0',
204
+ name: 'no-inputs',
205
+ nodes: [agenticNode('plain')],
206
+ edges: [],
207
+ };
208
+ const dispatcher = makeBindingRecorder(() => ({ status: 'success', output: {} }));
209
+
210
+ await executePlaybook({
211
+ db,
212
+ playbook,
213
+ playbookHash: 'hash-t1944-b',
214
+ initialContext: { taskId: 'T123' },
215
+ dispatcher,
216
+ });
217
+
218
+ expect(dispatcher.calls[0]?.bindings).toMatchObject({ taskId: 'T123' });
219
+ });
220
+
221
+ it('AC6: step binding shadows a same-named accumulated context key end-to-end', async () => {
222
+ // Node `a` emits `topic`; node `b` declares an `inputs.topic` template that
223
+ // MUST shadow the inherited context value when dispatched.
224
+ const playbook: PlaybookDefinition = {
225
+ version: '1.0',
226
+ name: 'shadowing',
227
+ nodes: [agenticNode('a'), agenticNode('b', { inputs: { topic: '{{inputs.epicId}}' } })],
228
+ edges: [{ from: 'a', to: 'b' }],
229
+ };
230
+ const dispatcher = makeBindingRecorder((input) =>
231
+ input.nodeId === 'a'
232
+ ? { status: 'success', output: { topic: 'from-node-a' } }
233
+ : { status: 'success', output: {} },
234
+ );
235
+
236
+ await executePlaybook({
237
+ db,
238
+ playbook,
239
+ playbookHash: 'hash-t1944-c',
240
+ initialContext: { inputs: { epicId: 'T1942' } },
241
+ dispatcher,
242
+ });
243
+
244
+ const callB = dispatcher.calls.find((c) => c.nodeId === 'b');
245
+ // Accumulated context still has node-a's value...
246
+ expect(callB?.context['topic']).toBe('from-node-a');
247
+ // ...but the step binding shadows it.
248
+ expect(callB?.bindings?.['topic']).toBe('T1942');
249
+ });
250
+ });
@@ -0,0 +1,186 @@
1
+ /**
2
+ * T1947 (M4 cantbook done-gate, Gap E/F) — `PlaybookAgenticNode.allowed_skills`
3
+ * / `.allowed_tools` are parsed (AC1) and reach the dispatch boundary as
4
+ * `AgentDispatchInput.allowedSkills` / `.allowedTools` (AC5) so the dispatcher
5
+ * can intersect them against the Tier-0/1/2 baseline at spawn time.
6
+ *
7
+ * Two layers:
8
+ * 1. Parser → runtime forwarding: a `.cantbook` node with `allowed_skills:` /
9
+ * `allowed_tools:` is parsed and the lists reach {@link AgentDispatchInput}
10
+ * at dispatch time — asserted with a recorder dispatcher (no live backend).
11
+ * 2. Default (un-restricted) nodes carry NO allowlist — the dispatch path is
12
+ * unchanged (additive).
13
+ *
14
+ * The intersection enforcement itself lives in `@cleocode/core`'s
15
+ * `composeSpawnPayload` and is covered by `spawn.test.ts`; this suite proves the
16
+ * runtime hands the allowlists to the dispatcher so that enforcement can occur.
17
+ * No `@cleocode/*` module is mocked.
18
+ *
19
+ * @task T1947
20
+ */
21
+
22
+ import { readFileSync } from 'node:fs';
23
+ import { createRequire } from 'node:module';
24
+ import { dirname, resolve } from 'node:path';
25
+ import type { DatabaseSync as _DatabaseSyncType } from 'node:sqlite';
26
+ import { fileURLToPath } from 'node:url';
27
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
28
+ import { parsePlaybook } from '../parser.js';
29
+ import {
30
+ type AgentDispatcher,
31
+ type AgentDispatchInput,
32
+ type AgentDispatchResult,
33
+ executePlaybook,
34
+ } from '../runtime.js';
35
+
36
+ const _require = createRequire(import.meta.url);
37
+ type DatabaseSync = _DatabaseSyncType;
38
+ const { DatabaseSync } = _require('node:sqlite') as {
39
+ DatabaseSync: new (...args: ConstructorParameters<typeof _DatabaseSyncType>) => DatabaseSync;
40
+ };
41
+
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
+ function applyMigration(db: DatabaseSync, sql: string): void {
49
+ const statements = sql
50
+ .split(/--> statement-breakpoint/)
51
+ .map((s) => s.trim())
52
+ .filter((s) => s.length > 0);
53
+ for (const stmt of statements) {
54
+ const lines = stmt.split('\n');
55
+ const hasSql = lines.some((l) => l.trim().length > 0 && !l.trim().startsWith('--'));
56
+ if (hasSql) db.exec(stmt);
57
+ }
58
+ }
59
+
60
+ /** Records the full {@link AgentDispatchInput} per call. */
61
+ function makeRecorder(): AgentDispatcher & { calls: AgentDispatchInput[] } {
62
+ const calls: AgentDispatchInput[] = [];
63
+ return {
64
+ calls,
65
+ async dispatch(input: AgentDispatchInput): Promise<AgentDispatchResult> {
66
+ calls.push({ ...input, context: { ...input.context } });
67
+ return { status: 'success', output: {} };
68
+ },
69
+ };
70
+ }
71
+
72
+ describe('T1947: AC1 — parser accepts allowed_skills / allowed_tools', () => {
73
+ it('parses string[] allowlists onto the agentic node', () => {
74
+ const yaml = `
75
+ version: "1.0"
76
+ name: gated-flow
77
+ nodes:
78
+ - id: review
79
+ type: agentic
80
+ skill: ct-validator
81
+ allowed_skills: [ct-validator, ct-cleo]
82
+ allowed_tools: [Read, Grep]
83
+ `;
84
+ const { definition } = parsePlaybook(yaml);
85
+ const node = definition.nodes[0];
86
+ expect(node?.type).toBe('agentic');
87
+ if (node?.type !== 'agentic') throw new Error('expected agentic node');
88
+ expect(node.allowed_skills).toEqual(['ct-validator', 'ct-cleo']);
89
+ expect(node.allowed_tools).toEqual(['Read', 'Grep']);
90
+ });
91
+
92
+ it('omits both fields when the node declares neither (additive)', () => {
93
+ const yaml = `
94
+ version: "1.0"
95
+ name: plain-flow
96
+ nodes:
97
+ - id: research
98
+ type: agentic
99
+ skill: ct-research-agent
100
+ `;
101
+ const { definition } = parsePlaybook(yaml);
102
+ const node = definition.nodes[0];
103
+ if (node?.type !== 'agentic') throw new Error('expected agentic node');
104
+ expect(node.allowed_skills).toBeUndefined();
105
+ expect(node.allowed_tools).toBeUndefined();
106
+ });
107
+
108
+ it('rejects a non-string entry in allowed_skills', () => {
109
+ const yaml = `
110
+ version: "1.0"
111
+ name: bad-flow
112
+ nodes:
113
+ - id: review
114
+ type: agentic
115
+ skill: ct-validator
116
+ allowed_skills: [ct-validator, 42]
117
+ `;
118
+ expect(() => parsePlaybook(yaml)).toThrow(/allowed_skills/);
119
+ });
120
+ });
121
+
122
+ describe('T1947: AC5 — allowlists reach the dispatch boundary', () => {
123
+ let db: DatabaseSync;
124
+
125
+ beforeEach(() => {
126
+ db = new DatabaseSync(':memory:');
127
+ db.exec('PRAGMA foreign_keys=ON');
128
+ applyMigration(db, readFileSync(MIGRATION_SQL, 'utf8'));
129
+ });
130
+ afterEach(() => db.close());
131
+
132
+ it('forwards allowed_skills/allowed_tools as allowedSkills/allowedTools', async () => {
133
+ const yaml = `
134
+ version: "1.0"
135
+ name: gated-flow
136
+ nodes:
137
+ - id: review
138
+ type: agentic
139
+ skill: ct-validator
140
+ allowed_skills: [ct-validator]
141
+ allowed_tools: [Read, Grep]
142
+ `;
143
+ const { definition } = parsePlaybook(yaml);
144
+ const dispatcher = makeRecorder();
145
+
146
+ const result = await executePlaybook({
147
+ db,
148
+ playbook: definition,
149
+ playbookHash: 'hash-t1947',
150
+ initialContext: { taskId: 'T1947' },
151
+ dispatcher,
152
+ });
153
+
154
+ expect(result.terminalStatus).toBe('completed');
155
+ expect(dispatcher.calls).toHaveLength(1);
156
+ const call = dispatcher.calls[0];
157
+ expect(call?.nodeId).toBe('review');
158
+ expect(call?.allowedSkills).toEqual(['ct-validator']);
159
+ expect(call?.allowedTools).toEqual(['Read', 'Grep']);
160
+ });
161
+
162
+ it('an un-restricted stage carries NO allowedSkills/allowedTools (additive)', async () => {
163
+ const yaml = `
164
+ version: "1.0"
165
+ name: plain-flow
166
+ nodes:
167
+ - id: research
168
+ type: agentic
169
+ skill: ct-research-agent
170
+ `;
171
+ const { definition } = parsePlaybook(yaml);
172
+ const dispatcher = makeRecorder();
173
+
174
+ await executePlaybook({
175
+ db,
176
+ playbook: definition,
177
+ playbookHash: 'hash-t1947-b',
178
+ initialContext: { taskId: 'T123' },
179
+ dispatcher,
180
+ });
181
+
182
+ const call = dispatcher.calls[0];
183
+ expect(call?.allowedSkills).toBeUndefined();
184
+ expect(call?.allowedTools).toBeUndefined();
185
+ });
186
+ });