@crewhaus/spec 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/package.json +43 -0
  2. package/src/index.test.ts +1011 -0
  3. package/src/index.ts +1035 -0
@@ -0,0 +1,1011 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { Spec, SpecParseError, parseSpec } from "./index";
3
+
4
+ describe("parseSpec", () => {
5
+ test("parses a minimal valid CLI spec", () => {
6
+ const spec = parseSpec(`
7
+ name: hello
8
+ target: cli
9
+ agent:
10
+ model: claude-sonnet-4-6
11
+ instructions: be helpful
12
+ `);
13
+ expect(spec.name).toBe("hello");
14
+ expect(spec.target).toBe("cli");
15
+ if (spec.target !== "cli") expect.unreachable();
16
+ expect(spec.agent.model).toBe("claude-sonnet-4-6");
17
+ expect(spec.agent.instructions).toBe("be helpful");
18
+ });
19
+
20
+ test("preserves multi-line block-scalar instructions", () => {
21
+ const spec = parseSpec(`
22
+ name: hello
23
+ target: cli
24
+ agent:
25
+ model: m
26
+ instructions: |
27
+ line one.
28
+ line two.
29
+ `);
30
+ if (spec.target !== "cli") expect.unreachable();
31
+ expect(spec.agent.instructions).toBe("line one.\nline two.\n");
32
+ });
33
+
34
+ test("rejects spec with missing required fields", () => {
35
+ expect(() => parseSpec("name: hello")).toThrow(SpecParseError);
36
+ });
37
+
38
+ test("rejects spec with unknown top-level fields (strict mode)", () => {
39
+ expect(() =>
40
+ parseSpec(`
41
+ name: hello
42
+ target: cli
43
+ agent:
44
+ model: m
45
+ instructions: i
46
+ extra: nope
47
+ `),
48
+ ).toThrow(SpecParseError);
49
+ });
50
+
51
+ test("rejects an unsupported target", () => {
52
+ expect(() =>
53
+ parseSpec(`
54
+ name: hello
55
+ target: voice
56
+ agent:
57
+ model: m
58
+ instructions: i
59
+ `),
60
+ ).toThrow(SpecParseError);
61
+ });
62
+
63
+ test("rejects malformed YAML", () => {
64
+ expect(() => parseSpec("{[\nname: oops")).toThrow(SpecParseError);
65
+ });
66
+
67
+ test("error message points at the failing path", () => {
68
+ try {
69
+ parseSpec(`
70
+ name: hello
71
+ target: cli
72
+ agent:
73
+ model: ""
74
+ instructions: ok
75
+ `);
76
+ expect.unreachable();
77
+ } catch (err) {
78
+ expect(err).toBeInstanceOf(SpecParseError);
79
+ expect((err as Error).message).toContain("agent.model");
80
+ }
81
+ });
82
+ });
83
+
84
+ describe("parseSpec tools field", () => {
85
+ test("parses a CLI spec with a tools array", () => {
86
+ const spec = parseSpec(`
87
+ name: hello
88
+ target: cli
89
+ agent:
90
+ model: m
91
+ instructions: i
92
+ tools:
93
+ - read
94
+ - write
95
+ `);
96
+ if (spec.target !== "cli") expect.unreachable();
97
+ expect(spec.tools).toEqual(["read", "write"]);
98
+ });
99
+
100
+ test("tools field is optional (omitted means undefined)", () => {
101
+ const spec = parseSpec(`
102
+ name: hello
103
+ target: cli
104
+ agent:
105
+ model: m
106
+ instructions: i
107
+ `);
108
+ if (spec.target !== "cli") expect.unreachable();
109
+ expect(spec.tools).toBeUndefined();
110
+ });
111
+
112
+ test("rejects non-string tool entries", () => {
113
+ expect(() =>
114
+ parseSpec(`
115
+ name: hello
116
+ target: cli
117
+ agent:
118
+ model: m
119
+ instructions: i
120
+ tools:
121
+ - 123
122
+ `),
123
+ ).toThrow(SpecParseError);
124
+ });
125
+
126
+ test("rejects empty-string tool names", () => {
127
+ expect(() =>
128
+ parseSpec(`
129
+ name: hello
130
+ target: cli
131
+ agent:
132
+ model: m
133
+ instructions: i
134
+ tools:
135
+ - ""
136
+ `),
137
+ ).toThrow(SpecParseError);
138
+ });
139
+ });
140
+
141
+ describe("Spec schema", () => {
142
+ test("schema is exported as a runtime value (Zod)", () => {
143
+ expect(typeof Spec.safeParse).toBe("function");
144
+ });
145
+ });
146
+
147
+ describe("parseSpec workflow target", () => {
148
+ test("parses a minimal valid workflow spec", () => {
149
+ const spec = parseSpec(`
150
+ name: hello-workflow
151
+ target: workflow
152
+ model: claude-sonnet-4-6
153
+ steps:
154
+ - name: only-step
155
+ instructions: do the thing
156
+ `);
157
+ expect(spec.target).toBe("workflow");
158
+ if (spec.target !== "workflow") expect.unreachable();
159
+ expect(spec.name).toBe("hello-workflow");
160
+ expect(spec.model).toBe("claude-sonnet-4-6");
161
+ expect(spec.steps).toHaveLength(1);
162
+ expect(spec.steps[0]?.name).toBe("only-step");
163
+ expect(spec.steps[0]?.instructions).toBe("do the thing");
164
+ expect(spec.steps[0]?.model).toBeUndefined();
165
+ expect(spec.steps[0]?.tools).toBeUndefined();
166
+ });
167
+
168
+ test("parses a workflow spec with multiple steps and per-step tools", () => {
169
+ const spec = parseSpec(`
170
+ name: w
171
+ target: workflow
172
+ model: m
173
+ steps:
174
+ - name: a
175
+ instructions: ai
176
+ tools:
177
+ - bash
178
+ - name: b
179
+ instructions: bi
180
+ `);
181
+ if (spec.target !== "workflow") expect.unreachable();
182
+ expect(spec.steps).toHaveLength(2);
183
+ expect(spec.steps[0]?.tools).toEqual(["bash"]);
184
+ expect(spec.steps[1]?.tools).toBeUndefined();
185
+ });
186
+
187
+ test("parses a workflow spec with per-step model override", () => {
188
+ const spec = parseSpec(`
189
+ name: w
190
+ target: workflow
191
+ model: default-model
192
+ steps:
193
+ - name: a
194
+ instructions: ai
195
+ model: override-model
196
+ - name: b
197
+ instructions: bi
198
+ `);
199
+ if (spec.target !== "workflow") expect.unreachable();
200
+ expect(spec.steps[0]?.model).toBe("override-model");
201
+ expect(spec.steps[1]?.model).toBeUndefined();
202
+ });
203
+
204
+ test("rejects a workflow spec with no steps", () => {
205
+ expect(() =>
206
+ parseSpec(`
207
+ name: w
208
+ target: workflow
209
+ model: m
210
+ steps: []
211
+ `),
212
+ ).toThrow(SpecParseError);
213
+ });
214
+
215
+ test("rejects a workflow step with empty instructions", () => {
216
+ expect(() =>
217
+ parseSpec(`
218
+ name: w
219
+ target: workflow
220
+ model: m
221
+ steps:
222
+ - name: a
223
+ instructions: ""
224
+ `),
225
+ ).toThrow(SpecParseError);
226
+ });
227
+
228
+ test("rejects a workflow step with empty name", () => {
229
+ expect(() =>
230
+ parseSpec(`
231
+ name: w
232
+ target: workflow
233
+ model: m
234
+ steps:
235
+ - name: ""
236
+ instructions: ai
237
+ `),
238
+ ).toThrow(SpecParseError);
239
+ });
240
+
241
+ test("rejects a workflow spec missing top-level model", () => {
242
+ expect(() =>
243
+ parseSpec(`
244
+ name: w
245
+ target: workflow
246
+ steps:
247
+ - name: a
248
+ instructions: ai
249
+ `),
250
+ ).toThrow(SpecParseError);
251
+ });
252
+
253
+ test("rejects a workflow spec with extra top-level field (strict)", () => {
254
+ expect(() =>
255
+ parseSpec(`
256
+ name: w
257
+ target: workflow
258
+ model: m
259
+ extra: nope
260
+ steps:
261
+ - name: a
262
+ instructions: ai
263
+ `),
264
+ ).toThrow(SpecParseError);
265
+ });
266
+
267
+ test("rejects a workflow step with unknown field (strict)", () => {
268
+ expect(() =>
269
+ parseSpec(`
270
+ name: w
271
+ target: workflow
272
+ model: m
273
+ steps:
274
+ - name: a
275
+ instructions: ai
276
+ bogus: 1
277
+ `),
278
+ ).toThrow(SpecParseError);
279
+ });
280
+
281
+ describe("permissions block", () => {
282
+ test("accepts a cli spec with permissions: mode + rules", () => {
283
+ const spec = parseSpec(`
284
+ name: hello
285
+ target: cli
286
+ agent:
287
+ model: m
288
+ instructions: i
289
+ permissions:
290
+ mode: auto
291
+ rules:
292
+ - type: alwaysAllow
293
+ pattern: Read
294
+ - type: alwaysDeny
295
+ pattern: Bash(rm**)
296
+ `);
297
+ expect(spec.target).toBe("cli");
298
+ if (spec.target !== "cli") return;
299
+ expect(spec.permissions?.mode).toBe("auto");
300
+ expect(spec.permissions?.rules).toHaveLength(2);
301
+ });
302
+
303
+ test("accepts a workflow spec with permissions block", () => {
304
+ const spec = parseSpec(`
305
+ name: w
306
+ target: workflow
307
+ model: m
308
+ steps:
309
+ - name: a
310
+ instructions: ai
311
+ permissions:
312
+ mode: plan
313
+ `);
314
+ expect(spec.target).toBe("workflow");
315
+ if (spec.target !== "workflow") return;
316
+ expect(spec.permissions?.mode).toBe("plan");
317
+ });
318
+
319
+ test("rejects mode: bypass in cli spec with a friendly security message", () => {
320
+ expect(() =>
321
+ parseSpec(`
322
+ name: hello
323
+ target: cli
324
+ agent:
325
+ model: m
326
+ instructions: i
327
+ permissions:
328
+ mode: bypass
329
+ `),
330
+ ).toThrow(SpecParseError);
331
+ expect(() =>
332
+ parseSpec(`
333
+ name: hello
334
+ target: cli
335
+ agent:
336
+ model: m
337
+ instructions: i
338
+ permissions:
339
+ mode: bypass
340
+ `),
341
+ ).toThrow(/bypass mode is only available via the --permission-mode CLI flag/);
342
+ });
343
+
344
+ test("rejects mode: bypass in workflow spec", () => {
345
+ expect(() =>
346
+ parseSpec(`
347
+ name: w
348
+ target: workflow
349
+ model: m
350
+ steps:
351
+ - name: a
352
+ instructions: ai
353
+ permissions:
354
+ mode: bypass
355
+ `),
356
+ ).toThrow(SpecParseError);
357
+ });
358
+
359
+ test("rejects unknown rule type", () => {
360
+ expect(() =>
361
+ parseSpec(`
362
+ name: hello
363
+ target: cli
364
+ agent:
365
+ model: m
366
+ instructions: i
367
+ permissions:
368
+ rules:
369
+ - type: neverAllow
370
+ pattern: Read
371
+ `),
372
+ ).toThrow(SpecParseError);
373
+ });
374
+ });
375
+ });
376
+
377
+ describe("parseSpec channel target (Section 12)", () => {
378
+ test("parses a minimal valid channel spec", () => {
379
+ const spec = parseSpec(`
380
+ name: hello-channel
381
+ target: channel
382
+ agent:
383
+ model: claude-sonnet-4-6
384
+ instructions: be a good bot
385
+ channels:
386
+ slack:
387
+ botToken: xoxb-test
388
+ signingSecret: shh
389
+ routing:
390
+ sessionKey: thread
391
+ `);
392
+ expect(spec.target).toBe("channel");
393
+ if (spec.target !== "channel") expect.unreachable();
394
+ expect(spec.agent.model).toBe("claude-sonnet-4-6");
395
+ expect(spec.channels.slack?.botToken).toBe("xoxb-test");
396
+ expect(spec.routing.sessionKey).toBe("thread");
397
+ expect(spec.agent.tools).toBeUndefined();
398
+ });
399
+
400
+ test("parses a channel spec with agent.tools and permissions", () => {
401
+ const spec = parseSpec(`
402
+ name: hello-channel
403
+ target: channel
404
+ agent:
405
+ model: m
406
+ instructions: i
407
+ tools:
408
+ - read
409
+ - bash
410
+ channels:
411
+ slack:
412
+ botToken: $SLACK_BOT_TOKEN
413
+ signingSecret: $SLACK_SIGNING_SECRET
414
+ appToken: $SLACK_APP_TOKEN
415
+ routing:
416
+ sessionKey: user
417
+ permissions:
418
+ rules:
419
+ - type: alwaysAllow
420
+ pattern: Read
421
+ `);
422
+ if (spec.target !== "channel") expect.unreachable();
423
+ expect(spec.agent.tools).toEqual(["read", "bash"]);
424
+ expect(spec.channels.slack?.appToken).toBe("$SLACK_APP_TOKEN");
425
+ expect(spec.routing.sessionKey).toBe("user");
426
+ expect(spec.permissions?.rules).toHaveLength(1);
427
+ });
428
+
429
+ test("rejects a channel spec missing the channels block", () => {
430
+ expect(() =>
431
+ parseSpec(`
432
+ name: hello-channel
433
+ target: channel
434
+ agent:
435
+ model: m
436
+ instructions: i
437
+ routing:
438
+ sessionKey: thread
439
+ `),
440
+ ).toThrow(SpecParseError);
441
+ });
442
+
443
+ test("rejects a channel spec with empty channels block (no slack)", () => {
444
+ expect(() =>
445
+ parseSpec(`
446
+ name: hello-channel
447
+ target: channel
448
+ agent:
449
+ model: m
450
+ instructions: i
451
+ channels: {}
452
+ routing:
453
+ sessionKey: thread
454
+ `),
455
+ ).toThrow(/at least one channel/);
456
+ });
457
+
458
+ test("rejects a channel spec missing routing", () => {
459
+ expect(() =>
460
+ parseSpec(`
461
+ name: hello-channel
462
+ target: channel
463
+ agent:
464
+ model: m
465
+ instructions: i
466
+ channels:
467
+ slack:
468
+ botToken: x
469
+ signingSecret: y
470
+ `),
471
+ ).toThrow(SpecParseError);
472
+ });
473
+
474
+ test("rejects an invalid sessionKey", () => {
475
+ expect(() =>
476
+ parseSpec(`
477
+ name: hello-channel
478
+ target: channel
479
+ agent:
480
+ model: m
481
+ instructions: i
482
+ channels:
483
+ slack:
484
+ botToken: x
485
+ signingSecret: y
486
+ routing:
487
+ sessionKey: workspace
488
+ `),
489
+ ).toThrow(SpecParseError);
490
+ });
491
+
492
+ test("rejects an unknown channel adapter (strict)", () => {
493
+ expect(() =>
494
+ parseSpec(`
495
+ name: hello-channel
496
+ target: channel
497
+ agent:
498
+ model: m
499
+ instructions: i
500
+ channels:
501
+ telegram:
502
+ botToken: x
503
+ routing:
504
+ sessionKey: thread
505
+ `),
506
+ ).toThrow(SpecParseError);
507
+ });
508
+
509
+ test("rejects mode: bypass in channel spec", () => {
510
+ expect(() =>
511
+ parseSpec(`
512
+ name: hello-channel
513
+ target: channel
514
+ agent:
515
+ model: m
516
+ instructions: i
517
+ channels:
518
+ slack:
519
+ botToken: x
520
+ signingSecret: y
521
+ routing:
522
+ sessionKey: thread
523
+ permissions:
524
+ mode: bypass
525
+ `),
526
+ ).toThrow(SpecParseError);
527
+ });
528
+ });
529
+
530
+ describe("parseSpec mcp_servers block (Section 9)", () => {
531
+ test("parses a CLI spec with a stdio MCP server", () => {
532
+ const spec = parseSpec(`
533
+ name: hello
534
+ target: cli
535
+ agent:
536
+ model: m
537
+ instructions: i
538
+ mcp_servers:
539
+ fs:
540
+ transport: stdio
541
+ command: npx
542
+ args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
543
+ env:
544
+ DEBUG: "1"
545
+ `);
546
+ if (spec.target !== "cli") expect.unreachable();
547
+ expect(spec.mcp_servers).toBeDefined();
548
+ const fs = spec.mcp_servers?.["fs"];
549
+ expect(fs?.transport).toBe("stdio");
550
+ if (fs?.transport !== "stdio") expect.unreachable();
551
+ expect(fs.command).toBe("npx");
552
+ expect(fs.args).toEqual(["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]);
553
+ expect(fs.env).toEqual({ DEBUG: "1" });
554
+ });
555
+
556
+ test("parses a CLI spec with an SSE MCP server", () => {
557
+ const spec = parseSpec(`
558
+ name: hello
559
+ target: cli
560
+ agent:
561
+ model: m
562
+ instructions: i
563
+ mcp_servers:
564
+ remote:
565
+ transport: sse
566
+ url: https://example.com/sse
567
+ headers:
568
+ Authorization: "Bearer x"
569
+ `);
570
+ if (spec.target !== "cli") expect.unreachable();
571
+ const remote = spec.mcp_servers?.["remote"];
572
+ expect(remote?.transport).toBe("sse");
573
+ if (remote?.transport !== "sse") expect.unreachable();
574
+ expect(remote.url).toBe("https://example.com/sse");
575
+ expect(remote.headers).toEqual({ Authorization: "Bearer x" });
576
+ });
577
+
578
+ test("parses a workflow spec with mcp_servers", () => {
579
+ const spec = parseSpec(`
580
+ name: w
581
+ target: workflow
582
+ model: m
583
+ mcp_servers:
584
+ fs:
585
+ transport: stdio
586
+ command: foo
587
+ steps:
588
+ - name: a
589
+ instructions: ai
590
+ `);
591
+ if (spec.target !== "workflow") expect.unreachable();
592
+ expect(spec.mcp_servers?.["fs"]).toBeDefined();
593
+ });
594
+
595
+ test("mcp_servers field is optional", () => {
596
+ const spec = parseSpec(`
597
+ name: hello
598
+ target: cli
599
+ agent:
600
+ model: m
601
+ instructions: i
602
+ `);
603
+ if (spec.target !== "cli") expect.unreachable();
604
+ expect(spec.mcp_servers).toBeUndefined();
605
+ });
606
+
607
+ test("rejects an MCP config missing the discriminator", () => {
608
+ expect(() =>
609
+ parseSpec(`
610
+ name: hello
611
+ target: cli
612
+ agent:
613
+ model: m
614
+ instructions: i
615
+ mcp_servers:
616
+ fs:
617
+ command: npx
618
+ `),
619
+ ).toThrow(SpecParseError);
620
+ });
621
+
622
+ test("rejects an unknown transport value", () => {
623
+ expect(() =>
624
+ parseSpec(`
625
+ name: hello
626
+ target: cli
627
+ agent:
628
+ model: m
629
+ instructions: i
630
+ mcp_servers:
631
+ fs:
632
+ transport: ftp
633
+ command: x
634
+ `),
635
+ ).toThrow(SpecParseError);
636
+ });
637
+
638
+ test("rejects an stdio config with stray sse fields (strict mode)", () => {
639
+ expect(() =>
640
+ parseSpec(`
641
+ name: hello
642
+ target: cli
643
+ agent:
644
+ model: m
645
+ instructions: i
646
+ mcp_servers:
647
+ fs:
648
+ transport: stdio
649
+ command: x
650
+ url: https://nope
651
+ `),
652
+ ).toThrow(SpecParseError);
653
+ });
654
+
655
+ test("rejects an SSE config with non-URL url", () => {
656
+ expect(() =>
657
+ parseSpec(`
658
+ name: hello
659
+ target: cli
660
+ agent:
661
+ model: m
662
+ instructions: i
663
+ mcp_servers:
664
+ fs:
665
+ transport: sse
666
+ url: not-a-url
667
+ `),
668
+ ).toThrow(SpecParseError);
669
+ });
670
+ });
671
+
672
+ describe("parseSpec — CLI banner (Phase 3 §3.3)", () => {
673
+ test("accepts a banner block with taglineMode and taglines", () => {
674
+ const spec = parseSpec(`
675
+ name: hello
676
+ target: cli
677
+ agent:
678
+ model: claude-sonnet-4-6
679
+ instructions: be helpful
680
+ cli:
681
+ banner:
682
+ taglineMode: random
683
+ taglines:
684
+ - "🦞 first"
685
+ - "🦞 second"
686
+ `);
687
+ if (spec.target !== "cli") throw new Error("unexpected target");
688
+ expect(spec.cli?.banner?.taglineMode).toBe("random");
689
+ expect(spec.cli?.banner?.taglines).toEqual(["🦞 first", "🦞 second"]);
690
+ });
691
+
692
+ test("defaults taglineMode to 'static' when omitted", () => {
693
+ const spec = parseSpec(`
694
+ name: hello
695
+ target: cli
696
+ agent:
697
+ model: claude-sonnet-4-6
698
+ instructions: be helpful
699
+ cli:
700
+ banner:
701
+ taglines: ["only one"]
702
+ `);
703
+ if (spec.target !== "cli") throw new Error("unexpected target");
704
+ expect(spec.cli?.banner?.taglineMode).toBe("static");
705
+ });
706
+
707
+ test("rejects empty taglines array", () => {
708
+ expect(() =>
709
+ parseSpec(`
710
+ name: hello
711
+ target: cli
712
+ agent:
713
+ model: claude-sonnet-4-6
714
+ instructions: be helpful
715
+ cli:
716
+ banner:
717
+ taglines: []
718
+ `),
719
+ ).toThrow(SpecParseError);
720
+ });
721
+
722
+ test("rejects invalid taglineMode", () => {
723
+ expect(() =>
724
+ parseSpec(`
725
+ name: hello
726
+ target: cli
727
+ agent:
728
+ model: claude-sonnet-4-6
729
+ instructions: be helpful
730
+ cli:
731
+ banner:
732
+ taglineMode: invalid
733
+ taglines: ["t"]
734
+ `),
735
+ ).toThrow(SpecParseError);
736
+ });
737
+ });
738
+
739
+ describe("parseSpec — gateway (Phase 3 §3.4)", () => {
740
+ test("accepts a gateway block with port + ui", () => {
741
+ const spec = parseSpec(`
742
+ name: hello
743
+ target: channel
744
+ agent:
745
+ model: claude-sonnet-4-6
746
+ instructions: be helpful
747
+ channels:
748
+ slack:
749
+ botToken: $SLACK_BOT_TOKEN
750
+ signingSecret: $SLACK_SIGNING_SECRET
751
+ routing:
752
+ sessionKey: thread
753
+ gateway:
754
+ port: 19001
755
+ ui: true
756
+ `);
757
+ if (spec.target !== "channel") throw new Error("unexpected target");
758
+ expect(spec.gateway?.port).toBe(19001);
759
+ expect(spec.gateway?.ui).toBe(true);
760
+ });
761
+
762
+ test("ui defaults to false when omitted", () => {
763
+ const spec = parseSpec(`
764
+ name: hello
765
+ target: channel
766
+ agent:
767
+ model: claude-sonnet-4-6
768
+ instructions: be helpful
769
+ channels:
770
+ slack:
771
+ botToken: $SLACK_BOT_TOKEN
772
+ signingSecret: $SLACK_SIGNING_SECRET
773
+ routing:
774
+ sessionKey: thread
775
+ gateway:
776
+ port: 8080
777
+ `);
778
+ if (spec.target !== "channel") throw new Error("unexpected target");
779
+ expect(spec.gateway?.ui).toBe(false);
780
+ });
781
+
782
+ test("rejects invalid port (out of range)", () => {
783
+ expect(() =>
784
+ parseSpec(`
785
+ name: hello
786
+ target: channel
787
+ agent:
788
+ model: claude-sonnet-4-6
789
+ instructions: be helpful
790
+ channels:
791
+ slack:
792
+ botToken: $SLACK_BOT_TOKEN
793
+ signingSecret: $SLACK_SIGNING_SECRET
794
+ routing:
795
+ sessionKey: thread
796
+ gateway:
797
+ port: 99999
798
+ `),
799
+ ).toThrow(SpecParseError);
800
+ });
801
+
802
+ test("gateway is optional", () => {
803
+ const spec = parseSpec(`
804
+ name: hello
805
+ target: channel
806
+ agent:
807
+ model: claude-sonnet-4-6
808
+ instructions: be helpful
809
+ channels:
810
+ slack:
811
+ botToken: $SLACK_BOT_TOKEN
812
+ signingSecret: $SLACK_SIGNING_SECRET
813
+ routing:
814
+ sessionKey: thread
815
+ `);
816
+ if (spec.target !== "channel") throw new Error("unexpected target");
817
+ expect(spec.gateway).toBeUndefined();
818
+ });
819
+ });
820
+
821
+ describe("parseSpec — heartbeat (Phase 3 §3.1)", () => {
822
+ test("accepts a heartbeat block with duration and instructions", () => {
823
+ const spec = parseSpec(`
824
+ name: hello
825
+ target: channel
826
+ agent:
827
+ model: claude-sonnet-4-6
828
+ instructions: be helpful
829
+ channels:
830
+ slack:
831
+ botToken: $SLACK_BOT_TOKEN
832
+ signingSecret: $SLACK_SIGNING_SECRET
833
+ routing:
834
+ sessionKey: thread
835
+ heartbeat:
836
+ every: 2h
837
+ instructions: wake and decide
838
+ `);
839
+ if (spec.target !== "channel") throw new Error("unexpected target");
840
+ expect(spec.heartbeat?.every).toBe("2h");
841
+ expect(spec.heartbeat?.instructions).toBe("wake and decide");
842
+ });
843
+
844
+ test.each(["2h", "30m", "60s", "500ms"])("accepts duration string %s", (every: string) => {
845
+ const spec = parseSpec(`
846
+ name: hello
847
+ target: channel
848
+ agent:
849
+ model: claude-sonnet-4-6
850
+ instructions: be helpful
851
+ channels:
852
+ slack:
853
+ botToken: $SLACK_BOT_TOKEN
854
+ signingSecret: $SLACK_SIGNING_SECRET
855
+ routing:
856
+ sessionKey: thread
857
+ heartbeat:
858
+ every: ${every}
859
+ instructions: tick
860
+ `);
861
+ if (spec.target !== "channel") throw new Error("unexpected target");
862
+ expect(spec.heartbeat?.every).toBe(every);
863
+ });
864
+
865
+ test("rejects invalid duration format", () => {
866
+ expect(() =>
867
+ parseSpec(`
868
+ name: hello
869
+ target: channel
870
+ agent:
871
+ model: claude-sonnet-4-6
872
+ instructions: be helpful
873
+ channels:
874
+ slack:
875
+ botToken: $SLACK_BOT_TOKEN
876
+ signingSecret: $SLACK_SIGNING_SECRET
877
+ routing:
878
+ sessionKey: thread
879
+ heartbeat:
880
+ every: "2 hours"
881
+ instructions: tick
882
+ `),
883
+ ).toThrow(SpecParseError);
884
+ });
885
+
886
+ test("heartbeat is optional", () => {
887
+ const spec = parseSpec(`
888
+ name: hello
889
+ target: channel
890
+ agent:
891
+ model: claude-sonnet-4-6
892
+ instructions: be helpful
893
+ channels:
894
+ slack:
895
+ botToken: $SLACK_BOT_TOKEN
896
+ signingSecret: $SLACK_SIGNING_SECRET
897
+ routing:
898
+ sessionKey: thread
899
+ `);
900
+ if (spec.target !== "channel") throw new Error("unexpected target");
901
+ expect(spec.heartbeat).toBeUndefined();
902
+ });
903
+ });
904
+
905
+ describe("parseSpec — compaction block (Section 17 + Pillar 2 curator)", () => {
906
+ test("accepts the curator opt-in + tuning knobs", () => {
907
+ const spec = parseSpec(`
908
+ name: hello
909
+ target: cli
910
+ agent:
911
+ model: claude-sonnet-4-6
912
+ instructions: be helpful
913
+ compaction:
914
+ model: claude-haiku-4
915
+ curate: true
916
+ dedupeThreshold: 0.88
917
+ relevanceTopK: 5
918
+ `);
919
+ if (spec.target !== "cli") throw new Error("unexpected target");
920
+ expect(spec.compaction).toEqual({
921
+ model: "claude-haiku-4",
922
+ curate: true,
923
+ dedupeThreshold: 0.88,
924
+ relevanceTopK: 5,
925
+ });
926
+ });
927
+
928
+ test("each curator field is independently optional", () => {
929
+ const spec = parseSpec(`
930
+ name: hello
931
+ target: cli
932
+ agent:
933
+ model: m
934
+ instructions: i
935
+ compaction:
936
+ curate: true
937
+ `);
938
+ if (spec.target !== "cli") throw new Error("unexpected target");
939
+ expect(spec.compaction).toEqual({ curate: true });
940
+ });
941
+
942
+ test("rejects dedupeThreshold > 1 (cosine outputs cap at 1)", () => {
943
+ expect(() =>
944
+ parseSpec(`
945
+ name: hello
946
+ target: cli
947
+ agent:
948
+ model: m
949
+ instructions: i
950
+ compaction:
951
+ dedupeThreshold: 1.5
952
+ `),
953
+ ).toThrow(SpecParseError);
954
+ });
955
+
956
+ test("rejects dedupeThreshold <= 0", () => {
957
+ expect(() =>
958
+ parseSpec(`
959
+ name: hello
960
+ target: cli
961
+ agent:
962
+ model: m
963
+ instructions: i
964
+ compaction:
965
+ dedupeThreshold: 0
966
+ `),
967
+ ).toThrow(SpecParseError);
968
+ });
969
+
970
+ test("rejects non-integer relevanceTopK", () => {
971
+ expect(() =>
972
+ parseSpec(`
973
+ name: hello
974
+ target: cli
975
+ agent:
976
+ model: m
977
+ instructions: i
978
+ compaction:
979
+ relevanceTopK: 3.5
980
+ `),
981
+ ).toThrow(SpecParseError);
982
+ });
983
+
984
+ test("rejects relevanceTopK <= 0", () => {
985
+ expect(() =>
986
+ parseSpec(`
987
+ name: hello
988
+ target: cli
989
+ agent:
990
+ model: m
991
+ instructions: i
992
+ compaction:
993
+ relevanceTopK: 0
994
+ `),
995
+ ).toThrow(SpecParseError);
996
+ });
997
+
998
+ test("rejects unknown keys inside the compaction block (strict)", () => {
999
+ expect(() =>
1000
+ parseSpec(`
1001
+ name: hello
1002
+ target: cli
1003
+ agent:
1004
+ model: m
1005
+ instructions: i
1006
+ compaction:
1007
+ enableMagic: true
1008
+ `),
1009
+ ).toThrow(SpecParseError);
1010
+ });
1011
+ });