opencastle 0.10.7 → 0.12.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 (132) hide show
  1. package/README.md +4 -0
  2. package/bin/cli.mjs +4 -0
  3. package/dist/cli/convoy/events.d.ts +10 -0
  4. package/dist/cli/convoy/events.d.ts.map +1 -0
  5. package/dist/cli/convoy/events.js +27 -0
  6. package/dist/cli/convoy/events.js.map +1 -0
  7. package/dist/cli/convoy/events.test.d.ts +2 -0
  8. package/dist/cli/convoy/events.test.d.ts.map +1 -0
  9. package/dist/cli/convoy/events.test.js +94 -0
  10. package/dist/cli/convoy/events.test.js.map +1 -0
  11. package/dist/cli/convoy/store.d.ts +23 -0
  12. package/dist/cli/convoy/store.d.ts.map +1 -0
  13. package/dist/cli/convoy/store.js +210 -0
  14. package/dist/cli/convoy/store.js.map +1 -0
  15. package/dist/cli/convoy/store.test.d.ts +2 -0
  16. package/dist/cli/convoy/store.test.d.ts.map +1 -0
  17. package/dist/cli/convoy/store.test.js +387 -0
  18. package/dist/cli/convoy/store.test.js.map +1 -0
  19. package/dist/cli/convoy/types.d.ts +56 -0
  20. package/dist/cli/convoy/types.d.ts.map +1 -0
  21. package/dist/cli/convoy/types.js +2 -0
  22. package/dist/cli/convoy/types.js.map +1 -0
  23. package/dist/cli/dashboard.d.ts.map +1 -1
  24. package/dist/cli/dashboard.js +5 -1
  25. package/dist/cli/dashboard.js.map +1 -1
  26. package/dist/cli/init.test.js +1 -1
  27. package/dist/cli/init.test.js.map +1 -1
  28. package/dist/cli/lesson.d.ts +17 -0
  29. package/dist/cli/lesson.d.ts.map +1 -0
  30. package/dist/cli/lesson.js +294 -0
  31. package/dist/cli/lesson.js.map +1 -0
  32. package/dist/cli/log.d.ts +7 -0
  33. package/dist/cli/log.d.ts.map +1 -0
  34. package/dist/cli/log.js +131 -0
  35. package/dist/cli/log.js.map +1 -0
  36. package/dist/cli/run/executor.js.map +1 -1
  37. package/dist/cli/run/executor.test.js +1 -0
  38. package/dist/cli/run/executor.test.js.map +1 -1
  39. package/dist/cli/run/loop-executor.d.ts +3 -0
  40. package/dist/cli/run/loop-executor.d.ts.map +1 -0
  41. package/dist/cli/run/loop-executor.js +155 -0
  42. package/dist/cli/run/loop-executor.js.map +1 -0
  43. package/dist/cli/run/loop-reporter.d.ts +6 -0
  44. package/dist/cli/run/loop-reporter.d.ts.map +1 -0
  45. package/dist/cli/run/loop-reporter.js +112 -0
  46. package/dist/cli/run/loop-reporter.js.map +1 -0
  47. package/dist/cli/run/reporter.d.ts.map +1 -1
  48. package/dist/cli/run/reporter.js +28 -1
  49. package/dist/cli/run/reporter.js.map +1 -1
  50. package/dist/cli/run/schema.d.ts +4 -0
  51. package/dist/cli/run/schema.d.ts.map +1 -1
  52. package/dist/cli/run/schema.js +178 -50
  53. package/dist/cli/run/schema.js.map +1 -1
  54. package/dist/cli/run/schema.test.js +598 -1
  55. package/dist/cli/run/schema.test.js.map +1 -1
  56. package/dist/cli/run.d.ts.map +1 -1
  57. package/dist/cli/run.js +84 -3
  58. package/dist/cli/run.js.map +1 -1
  59. package/dist/cli/types.d.ts +78 -1
  60. package/dist/cli/types.d.ts.map +1 -1
  61. package/dist/cli/update.d.ts.map +1 -1
  62. package/dist/cli/update.js +54 -1
  63. package/dist/cli/update.js.map +1 -1
  64. package/package.json +3 -2
  65. package/src/cli/convoy/events.test.ts +118 -0
  66. package/src/cli/convoy/events.ts +41 -0
  67. package/src/cli/convoy/store.test.ts +446 -0
  68. package/src/cli/convoy/store.ts +308 -0
  69. package/src/cli/convoy/types.ts +68 -0
  70. package/src/cli/dashboard.ts +5 -1
  71. package/src/cli/init.test.ts +1 -1
  72. package/src/cli/lesson.ts +312 -0
  73. package/src/cli/log.ts +133 -0
  74. package/src/cli/run/executor.test.ts +1 -0
  75. package/src/cli/run/executor.ts +8 -8
  76. package/src/cli/run/loop-executor.ts +199 -0
  77. package/src/cli/run/loop-reporter.ts +125 -0
  78. package/src/cli/run/reporter.ts +30 -1
  79. package/src/cli/run/schema.test.ts +704 -3
  80. package/src/cli/run/schema.ts +206 -56
  81. package/src/cli/run.ts +82 -5
  82. package/src/cli/types.ts +87 -1
  83. package/src/cli/update.ts +62 -1
  84. package/src/dashboard/dist/index.html +14 -15
  85. package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
  86. package/src/dashboard/scripts/generate-seed-data.ts +23 -43
  87. package/src/dashboard/seed-data/events.ndjson +104 -0
  88. package/src/dashboard/src/pages/index.astro +14 -15
  89. package/src/orchestrator/agents/api-designer.agent.md +1 -1
  90. package/src/orchestrator/agents/architect.agent.md +1 -1
  91. package/src/orchestrator/agents/content-engineer.agent.md +1 -1
  92. package/src/orchestrator/agents/copywriter.agent.md +1 -1
  93. package/src/orchestrator/agents/data-expert.agent.md +1 -1
  94. package/src/orchestrator/agents/database-engineer.agent.md +1 -1
  95. package/src/orchestrator/agents/developer.agent.md +1 -1
  96. package/src/orchestrator/agents/devops-expert.agent.md +1 -1
  97. package/src/orchestrator/agents/documentation-writer.agent.md +1 -1
  98. package/src/orchestrator/agents/performance-expert.agent.md +1 -1
  99. package/src/orchestrator/agents/release-manager.agent.md +1 -1
  100. package/src/orchestrator/agents/security-expert.agent.md +1 -1
  101. package/src/orchestrator/agents/seo-specialist.agent.md +1 -1
  102. package/src/orchestrator/agents/session-guard.agent.md +9 -21
  103. package/src/orchestrator/agents/team-lead.agent.md +8 -34
  104. package/src/orchestrator/agents/testing-expert.agent.md +1 -1
  105. package/src/orchestrator/agents/ui-ux-expert.agent.md +1 -1
  106. package/src/orchestrator/customizations/AGENT-PERFORMANCE.md +11 -12
  107. package/src/orchestrator/customizations/DISPUTES.md +2 -2
  108. package/src/orchestrator/customizations/README.md +1 -3
  109. package/src/orchestrator/customizations/logs/README.md +66 -14
  110. package/src/orchestrator/instructions/ai-optimization.instructions.md +21 -132
  111. package/src/orchestrator/instructions/general.instructions.md +35 -181
  112. package/src/orchestrator/plugins/nx/SKILL.md +1 -1
  113. package/src/orchestrator/prompts/bootstrap-customizations.prompt.md +4 -8
  114. package/src/orchestrator/prompts/bug-fix.prompt.md +4 -4
  115. package/src/orchestrator/prompts/implement-feature.prompt.md +3 -3
  116. package/src/orchestrator/prompts/quick-refinement.prompt.md +3 -3
  117. package/src/orchestrator/prompts/resolve-pr-comments.prompt.md +1 -1
  118. package/src/orchestrator/skills/agent-hooks/SKILL.md +11 -11
  119. package/src/orchestrator/skills/decomposition/SKILL.md +1 -1
  120. package/src/orchestrator/skills/fast-review/SKILL.md +4 -19
  121. package/src/orchestrator/skills/git-workflow/SKILL.md +72 -0
  122. package/src/orchestrator/skills/memory-merger/SKILL.md +1 -1
  123. package/src/orchestrator/skills/observability-logging/SKILL.md +129 -0
  124. package/src/orchestrator/skills/orchestration-protocols/SKILL.md +2 -2
  125. package/src/orchestrator/skills/panel-majority-vote/SKILL.md +4 -7
  126. package/src/orchestrator/skills/self-improvement/SKILL.md +13 -26
  127. package/src/orchestrator/skills/team-lead-reference/SKILL.md +2 -2
  128. package/src/orchestrator/customizations/logs/delegations.ndjson +0 -1
  129. package/src/orchestrator/customizations/logs/panels.ndjson +0 -1
  130. package/src/orchestrator/customizations/logs/reviews.ndjson +0 -0
  131. package/src/orchestrator/customizations/logs/sessions.ndjson +0 -1
  132. /package/src/orchestrator/customizations/logs/{disputes.ndjson → events.ndjson} +0 -0
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { parseYaml, parseTimeout, validateSpec, applyDefaults } from './schema.js';
2
+ import { parseYaml, parseTimeout, validateSpec, applyDefaults, isConvoySpec } from './schema.js';
3
3
  // ── parseYaml ──────────────────────────────────────────────────
4
4
  describe('parseYaml', () => {
5
5
  it('parses valid YAML mapping', () => {
@@ -299,4 +299,601 @@ describe('applyDefaults', () => {
299
299
  expect(task.files).toEqual(['src/']);
300
300
  });
301
301
  });
302
+ // ── loop mode — validateSpec ───────────────────────────────────
303
+ describe('validateSpec — loop mode', () => {
304
+ const validLoopSpec = {
305
+ name: 'build-auth',
306
+ mode: 'loop',
307
+ adapter: 'copilot',
308
+ loop: {
309
+ prompt: 'PROMPT_build.md',
310
+ plan_file: 'IMPLEMENTATION_PLAN.md',
311
+ max_iterations: 20,
312
+ timeout: '10m',
313
+ model: 'gpt-5.1',
314
+ backpressure: ['npm test', 'npx tsc --noEmit'],
315
+ },
316
+ };
317
+ it('accepts a valid minimal loop spec (only prompt required)', () => {
318
+ const result = validateSpec({
319
+ name: 'build-auth',
320
+ mode: 'loop',
321
+ loop: { prompt: 'PROMPT_build.md' },
322
+ });
323
+ expect(result.valid).toBe(true);
324
+ expect(result.errors).toHaveLength(0);
325
+ });
326
+ it('accepts a full loop spec', () => {
327
+ const result = validateSpec(validLoopSpec);
328
+ expect(result.valid).toBe(true);
329
+ expect(result.errors).toHaveLength(0);
330
+ });
331
+ it('does not require tasks array in loop mode', () => {
332
+ const result = validateSpec({
333
+ name: 'build-auth',
334
+ mode: 'loop',
335
+ loop: { prompt: 'PROMPT_build.md' },
336
+ });
337
+ expect(result.valid).toBe(true);
338
+ });
339
+ it('fails when loop object is missing', () => {
340
+ const result = validateSpec({ name: 'build-auth', mode: 'loop' });
341
+ expect(result.valid).toBe(false);
342
+ expect(result.errors).toContainEqual(expect.stringContaining('`loop` is required'));
343
+ });
344
+ it('fails when loop.prompt is missing', () => {
345
+ const result = validateSpec({
346
+ name: 'build-auth',
347
+ mode: 'loop',
348
+ loop: { max_iterations: 10 },
349
+ });
350
+ expect(result.valid).toBe(false);
351
+ expect(result.errors).toContainEqual(expect.stringContaining('loop.prompt'));
352
+ });
353
+ it('fails when loop.prompt is not a string', () => {
354
+ const result = validateSpec({
355
+ name: 'build-auth',
356
+ mode: 'loop',
357
+ loop: { prompt: 123 },
358
+ });
359
+ expect(result.valid).toBe(false);
360
+ expect(result.errors).toContainEqual(expect.stringContaining('loop.prompt'));
361
+ });
362
+ it('fails when loop.max_iterations is 0', () => {
363
+ const result = validateSpec({
364
+ name: 'build-auth',
365
+ mode: 'loop',
366
+ loop: { prompt: 'PROMPT.md', max_iterations: 0 },
367
+ });
368
+ expect(result.valid).toBe(false);
369
+ expect(result.errors).toContainEqual(expect.stringContaining('loop.max_iterations'));
370
+ });
371
+ it('fails when loop.max_iterations is a float', () => {
372
+ const result = validateSpec({
373
+ name: 'build-auth',
374
+ mode: 'loop',
375
+ loop: { prompt: 'PROMPT.md', max_iterations: 1.5 },
376
+ });
377
+ expect(result.valid).toBe(false);
378
+ expect(result.errors).toContainEqual(expect.stringContaining('loop.max_iterations'));
379
+ });
380
+ it('fails when loop.timeout has invalid format', () => {
381
+ const result = validateSpec({
382
+ name: 'build-auth',
383
+ mode: 'loop',
384
+ loop: { prompt: 'PROMPT.md', timeout: 'bad' },
385
+ });
386
+ expect(result.valid).toBe(false);
387
+ expect(result.errors).toContainEqual(expect.stringContaining('loop.timeout'));
388
+ });
389
+ it('accepts valid loop.timeout formats', () => {
390
+ for (const t of ['5s', '10m', '2h']) {
391
+ const result = validateSpec({
392
+ name: 'build-auth',
393
+ mode: 'loop',
394
+ loop: { prompt: 'PROMPT.md', timeout: t },
395
+ });
396
+ expect(result.valid).toBe(true);
397
+ }
398
+ });
399
+ it('fails when loop.backpressure is not an array', () => {
400
+ const result = validateSpec({
401
+ name: 'build-auth',
402
+ mode: 'loop',
403
+ loop: { prompt: 'PROMPT.md', backpressure: 'npm test' },
404
+ });
405
+ expect(result.valid).toBe(false);
406
+ expect(result.errors).toContainEqual(expect.stringContaining('loop.backpressure'));
407
+ });
408
+ it('fails when loop.backpressure contains non-strings', () => {
409
+ const result = validateSpec({
410
+ name: 'build-auth',
411
+ mode: 'loop',
412
+ loop: { prompt: 'PROMPT.md', backpressure: ['npm test', 42] },
413
+ });
414
+ expect(result.valid).toBe(false);
415
+ expect(result.errors).toContainEqual(expect.stringContaining('loop.backpressure'));
416
+ });
417
+ it('fails when loop.plan_file is not a string', () => {
418
+ const result = validateSpec({
419
+ name: 'build-auth',
420
+ mode: 'loop',
421
+ loop: { prompt: 'PROMPT.md', plan_file: true },
422
+ });
423
+ expect(result.valid).toBe(false);
424
+ expect(result.errors).toContainEqual(expect.stringContaining('loop.plan_file'));
425
+ });
426
+ it('fails when loop.model is not a string', () => {
427
+ const result = validateSpec({
428
+ name: 'build-auth',
429
+ mode: 'loop',
430
+ loop: { prompt: 'PROMPT.md', model: 99 },
431
+ });
432
+ expect(result.valid).toBe(false);
433
+ expect(result.errors).toContainEqual(expect.stringContaining('loop.model'));
434
+ });
435
+ it('rejects unknown mode value', () => {
436
+ const result = validateSpec({
437
+ name: 'build-auth',
438
+ mode: 'parallel',
439
+ tasks: [{ id: 'a', prompt: 'x' }],
440
+ });
441
+ expect(result.valid).toBe(false);
442
+ expect(result.errors).toContainEqual(expect.stringContaining('mode'));
443
+ });
444
+ it('mode: tasks still requires tasks array', () => {
445
+ const result = validateSpec({ name: 'build-auth', mode: 'tasks' });
446
+ expect(result.valid).toBe(false);
447
+ expect(result.errors).toContainEqual(expect.stringContaining('tasks'));
448
+ });
449
+ it('spec without mode field defaults to tasks behavior (requires tasks)', () => {
450
+ const result = validateSpec({ name: 'build-auth' });
451
+ expect(result.valid).toBe(false);
452
+ expect(result.errors).toContainEqual(expect.stringContaining('tasks'));
453
+ });
454
+ });
455
+ // ── loop mode — applyDefaults ──────────────────────────────────
456
+ describe('applyDefaults — loop mode', () => {
457
+ it('applies default max_iterations', () => {
458
+ const spec = applyDefaults({
459
+ name: 'build-auth',
460
+ mode: 'loop',
461
+ loop: { prompt: 'PROMPT.md' },
462
+ });
463
+ expect(spec.loop?.max_iterations).toBe(20);
464
+ });
465
+ it('applies default plan_file', () => {
466
+ const spec = applyDefaults({
467
+ name: 'build-auth',
468
+ mode: 'loop',
469
+ loop: { prompt: 'PROMPT.md' },
470
+ });
471
+ expect(spec.loop?.plan_file).toBe('IMPLEMENTATION_PLAN.md');
472
+ });
473
+ it('applies default timeout', () => {
474
+ const spec = applyDefaults({
475
+ name: 'build-auth',
476
+ mode: 'loop',
477
+ loop: { prompt: 'PROMPT.md' },
478
+ });
479
+ expect(spec.loop?.timeout).toBe('10m');
480
+ });
481
+ it('preserves user-specified loop values', () => {
482
+ const spec = applyDefaults({
483
+ name: 'build-auth',
484
+ mode: 'loop',
485
+ loop: {
486
+ prompt: 'PROMPT.md',
487
+ max_iterations: 5,
488
+ plan_file: 'MY_PLAN.md',
489
+ timeout: '30m',
490
+ model: 'gpt-5.1',
491
+ backpressure: ['npm test'],
492
+ },
493
+ });
494
+ expect(spec.loop?.max_iterations).toBe(5);
495
+ expect(spec.loop?.plan_file).toBe('MY_PLAN.md');
496
+ expect(spec.loop?.timeout).toBe('30m');
497
+ expect(spec.loop?.model).toBe('gpt-5.1');
498
+ expect(spec.loop?.backpressure).toEqual(['npm test']);
499
+ });
500
+ it('sets mode to tasks when not specified', () => {
501
+ const spec = applyDefaults({
502
+ name: 'test',
503
+ tasks: [{ id: 'a', prompt: 'x' }],
504
+ });
505
+ expect(spec.mode).toBe('tasks');
506
+ });
507
+ it('preserves mode: loop', () => {
508
+ const spec = applyDefaults({
509
+ name: 'build-auth',
510
+ mode: 'loop',
511
+ loop: { prompt: 'PROMPT.md' },
512
+ });
513
+ expect(spec.mode).toBe('loop');
514
+ });
515
+ });
516
+ // ── validateSpec — version field ───────────────────────────────
517
+ describe('validateSpec — version field', () => {
518
+ const validSpec = {
519
+ name: 'test-run',
520
+ tasks: [{ id: 'task-1', prompt: 'Do something' }],
521
+ };
522
+ it('accepts version 1', () => {
523
+ const result = validateSpec({ ...validSpec, version: 1 });
524
+ expect(result.valid).toBe(true);
525
+ expect(result.errors).toHaveLength(0);
526
+ });
527
+ it('rejects version 2', () => {
528
+ const result = validateSpec({ ...validSpec, version: 2 });
529
+ expect(result.valid).toBe(false);
530
+ expect(result.errors).toContainEqual(expect.stringContaining('version'));
531
+ });
532
+ it('rejects non-integer version', () => {
533
+ const result = validateSpec({ ...validSpec, version: 1.5 });
534
+ expect(result.valid).toBe(false);
535
+ expect(result.errors).toContainEqual(expect.stringContaining('version'));
536
+ });
537
+ it('rejects string version', () => {
538
+ const result = validateSpec({ ...validSpec, version: '1' });
539
+ expect(result.valid).toBe(false);
540
+ expect(result.errors).toContainEqual(expect.stringContaining('version'));
541
+ });
542
+ it('omitting version is valid (legacy spec)', () => {
543
+ const result = validateSpec(validSpec);
544
+ expect(result.valid).toBe(true);
545
+ });
546
+ });
547
+ // ── validateSpec — defaults block ──────────────────────────────
548
+ describe('validateSpec — defaults block', () => {
549
+ const validSpec = {
550
+ name: 'test-run',
551
+ version: 1,
552
+ tasks: [{ id: 'task-1', prompt: 'Do something' }],
553
+ };
554
+ it('accepts a fully specified defaults block', () => {
555
+ const result = validateSpec({
556
+ ...validSpec,
557
+ defaults: { timeout: '10m', model: 'gpt-4', max_retries: 2, agent: 'developer' },
558
+ });
559
+ expect(result.valid).toBe(true);
560
+ });
561
+ it('accepts partial defaults block', () => {
562
+ const result = validateSpec({ ...validSpec, defaults: { timeout: '5m' } });
563
+ expect(result.valid).toBe(true);
564
+ });
565
+ it('rejects defaults as a non-object', () => {
566
+ const result = validateSpec({ ...validSpec, defaults: 'invalid' });
567
+ expect(result.valid).toBe(false);
568
+ expect(result.errors).toContainEqual(expect.stringContaining('defaults'));
569
+ });
570
+ it('rejects defaults as an array', () => {
571
+ const result = validateSpec({ ...validSpec, defaults: ['timeout', '10m'] });
572
+ expect(result.valid).toBe(false);
573
+ expect(result.errors).toContainEqual(expect.stringContaining('defaults'));
574
+ });
575
+ it('rejects defaults with invalid timeout format', () => {
576
+ const result = validateSpec({ ...validSpec, defaults: { timeout: 'bad' } });
577
+ expect(result.valid).toBe(false);
578
+ expect(result.errors).toContainEqual(expect.stringContaining('defaults.timeout'));
579
+ });
580
+ it('rejects defaults with non-string model', () => {
581
+ const result = validateSpec({ ...validSpec, defaults: { model: 42 } });
582
+ expect(result.valid).toBe(false);
583
+ expect(result.errors).toContainEqual(expect.stringContaining('defaults.model'));
584
+ });
585
+ it('rejects defaults with negative max_retries', () => {
586
+ const result = validateSpec({ ...validSpec, defaults: { max_retries: -1 } });
587
+ expect(result.valid).toBe(false);
588
+ expect(result.errors).toContainEqual(expect.stringContaining('defaults.max_retries'));
589
+ });
590
+ it('rejects defaults with non-integer max_retries', () => {
591
+ const result = validateSpec({ ...validSpec, defaults: { max_retries: 1.5 } });
592
+ expect(result.valid).toBe(false);
593
+ expect(result.errors).toContainEqual(expect.stringContaining('defaults.max_retries'));
594
+ });
595
+ it('accepts defaults.max_retries of 0', () => {
596
+ const result = validateSpec({ ...validSpec, defaults: { max_retries: 0 } });
597
+ expect(result.valid).toBe(true);
598
+ });
599
+ it('rejects defaults with non-string agent', () => {
600
+ const result = validateSpec({ ...validSpec, defaults: { agent: 99 } });
601
+ expect(result.valid).toBe(false);
602
+ expect(result.errors).toContainEqual(expect.stringContaining('defaults.agent'));
603
+ });
604
+ });
605
+ // ── validateSpec — gates field ─────────────────────────────────
606
+ describe('validateSpec — gates field', () => {
607
+ const validSpec = {
608
+ name: 'test-run',
609
+ tasks: [{ id: 'task-1', prompt: 'Do something' }],
610
+ };
611
+ it('accepts a valid gates array', () => {
612
+ const result = validateSpec({
613
+ ...validSpec,
614
+ gates: ['npm test', 'npx tsc --noEmit'],
615
+ });
616
+ expect(result.valid).toBe(true);
617
+ });
618
+ it('accepts an empty gates array', () => {
619
+ const result = validateSpec({ ...validSpec, gates: [] });
620
+ expect(result.valid).toBe(true);
621
+ });
622
+ it('rejects gates as a string', () => {
623
+ const result = validateSpec({ ...validSpec, gates: 'npm test' });
624
+ expect(result.valid).toBe(false);
625
+ expect(result.errors).toContainEqual(expect.stringContaining('gates'));
626
+ });
627
+ it('rejects gates with non-string items', () => {
628
+ const result = validateSpec({ ...validSpec, gates: ['npm test', 42] });
629
+ expect(result.valid).toBe(false);
630
+ expect(result.errors).toContainEqual(expect.stringContaining('gates'));
631
+ });
632
+ });
633
+ // ── validateSpec — branch field ────────────────────────────────
634
+ describe('validateSpec — branch field', () => {
635
+ const validSpec = {
636
+ name: 'test-run',
637
+ tasks: [{ id: 'task-1', prompt: 'Do something' }],
638
+ };
639
+ it('accepts a valid branch string', () => {
640
+ const result = validateSpec({ ...validSpec, branch: 'feat/my-feature' });
641
+ expect(result.valid).toBe(true);
642
+ });
643
+ it('rejects non-string branch', () => {
644
+ const result = validateSpec({ ...validSpec, branch: 123 });
645
+ expect(result.valid).toBe(false);
646
+ expect(result.errors).toContainEqual(expect.stringContaining('branch'));
647
+ });
648
+ it('rejects boolean branch', () => {
649
+ const result = validateSpec({ ...validSpec, branch: true });
650
+ expect(result.valid).toBe(false);
651
+ expect(result.errors).toContainEqual(expect.stringContaining('branch'));
652
+ });
653
+ });
654
+ // ── validateSpec — per-task model and max_retries ──────────────
655
+ describe('validateSpec — per-task model and max_retries', () => {
656
+ it('accepts valid task model string', () => {
657
+ const result = validateSpec({
658
+ name: 'test',
659
+ tasks: [{ id: 'a', prompt: 'x', model: 'claude-3.5-sonnet' }],
660
+ });
661
+ expect(result.valid).toBe(true);
662
+ });
663
+ it('rejects non-string task model', () => {
664
+ const result = validateSpec({
665
+ name: 'test',
666
+ tasks: [{ id: 'a', prompt: 'x', model: 123 }],
667
+ });
668
+ expect(result.valid).toBe(false);
669
+ expect(result.errors).toContainEqual(expect.stringContaining('model'));
670
+ });
671
+ it('accepts valid task max_retries', () => {
672
+ const result = validateSpec({
673
+ name: 'test',
674
+ tasks: [{ id: 'a', prompt: 'x', max_retries: 3 }],
675
+ });
676
+ expect(result.valid).toBe(true);
677
+ });
678
+ it('accepts zero max_retries', () => {
679
+ const result = validateSpec({
680
+ name: 'test',
681
+ tasks: [{ id: 'a', prompt: 'x', max_retries: 0 }],
682
+ });
683
+ expect(result.valid).toBe(true);
684
+ });
685
+ it('rejects negative max_retries', () => {
686
+ const result = validateSpec({
687
+ name: 'test',
688
+ tasks: [{ id: 'a', prompt: 'x', max_retries: -1 }],
689
+ });
690
+ expect(result.valid).toBe(false);
691
+ expect(result.errors).toContainEqual(expect.stringContaining('max_retries'));
692
+ });
693
+ it('rejects non-integer max_retries', () => {
694
+ const result = validateSpec({
695
+ name: 'test',
696
+ tasks: [{ id: 'a', prompt: 'x', max_retries: 1.5 }],
697
+ });
698
+ expect(result.valid).toBe(false);
699
+ expect(result.errors).toContainEqual(expect.stringContaining('max_retries'));
700
+ });
701
+ });
702
+ // ── isConvoySpec ───────────────────────────────────────────────
703
+ describe('isConvoySpec', () => {
704
+ it('returns true for version 1 spec', () => {
705
+ expect(isConvoySpec({ name: 'test', version: 1, tasks: [{ id: 'a', prompt: 'x' }] })).toBe(true);
706
+ });
707
+ it('returns false for legacy spec without version', () => {
708
+ expect(isConvoySpec({ name: 'test', tasks: [{ id: 'a', prompt: 'x' }] })).toBe(false);
709
+ });
710
+ it('returns false for version 2', () => {
711
+ expect(isConvoySpec({ name: 'test', version: 2 })).toBe(false);
712
+ });
713
+ it('returns false for null input', () => {
714
+ expect(isConvoySpec(null)).toBe(false);
715
+ });
716
+ it('returns false for non-object input', () => {
717
+ expect(isConvoySpec('string')).toBe(false);
718
+ });
719
+ it('returns false for undefined', () => {
720
+ expect(isConvoySpec(undefined)).toBe(false);
721
+ });
722
+ });
723
+ // ── applyDefaults — convoy spec (version: 1) ───────────────────
724
+ describe('applyDefaults — convoy spec (version: 1)', () => {
725
+ it('merges defaults.agent into tasks when not specified', () => {
726
+ const spec = applyDefaults({
727
+ name: 'test',
728
+ version: 1,
729
+ defaults: { agent: 'ui-ux-expert' },
730
+ tasks: [{ id: 'a', prompt: 'x' }],
731
+ });
732
+ expect(spec.tasks[0].agent).toBe('ui-ux-expert');
733
+ });
734
+ it('task-level agent overrides default', () => {
735
+ const spec = applyDefaults({
736
+ name: 'test',
737
+ version: 1,
738
+ defaults: { agent: 'ui-ux-expert' },
739
+ tasks: [{ id: 'a', prompt: 'x', agent: 'api-designer' }],
740
+ });
741
+ expect(spec.tasks[0].agent).toBe('api-designer');
742
+ });
743
+ it('merges defaults.timeout into tasks', () => {
744
+ const spec = applyDefaults({
745
+ name: 'test',
746
+ version: 1,
747
+ defaults: { timeout: '15m' },
748
+ tasks: [{ id: 'a', prompt: 'x' }],
749
+ });
750
+ expect(spec.tasks[0].timeout).toBe('15m');
751
+ });
752
+ it('task-level timeout overrides defaults.timeout', () => {
753
+ const spec = applyDefaults({
754
+ name: 'test',
755
+ version: 1,
756
+ defaults: { timeout: '15m' },
757
+ tasks: [{ id: 'a', prompt: 'x', timeout: '5m' }],
758
+ });
759
+ expect(spec.tasks[0].timeout).toBe('5m');
760
+ });
761
+ it('merges defaults.model into tasks', () => {
762
+ const spec = applyDefaults({
763
+ name: 'test',
764
+ version: 1,
765
+ defaults: { model: 'gpt-4' },
766
+ tasks: [{ id: 'a', prompt: 'x' }],
767
+ });
768
+ expect(spec.tasks[0].model).toBe('gpt-4');
769
+ });
770
+ it('task-level model overrides defaults.model', () => {
771
+ const spec = applyDefaults({
772
+ name: 'test',
773
+ version: 1,
774
+ defaults: { model: 'gpt-4' },
775
+ tasks: [{ id: 'a', prompt: 'x', model: 'claude-3.5-sonnet' }],
776
+ });
777
+ expect(spec.tasks[0].model).toBe('claude-3.5-sonnet');
778
+ });
779
+ it('no model set when no defaults.model and no task model', () => {
780
+ const spec = applyDefaults({
781
+ name: 'test',
782
+ version: 1,
783
+ tasks: [{ id: 'a', prompt: 'x' }],
784
+ });
785
+ expect(spec.tasks[0].model).toBeUndefined();
786
+ });
787
+ it('merges defaults.max_retries into tasks', () => {
788
+ const spec = applyDefaults({
789
+ name: 'test',
790
+ version: 1,
791
+ defaults: { max_retries: 3 },
792
+ tasks: [{ id: 'a', prompt: 'x' }],
793
+ });
794
+ expect(spec.tasks[0].max_retries).toBe(3);
795
+ });
796
+ it('task-level max_retries overrides defaults.max_retries', () => {
797
+ const spec = applyDefaults({
798
+ name: 'test',
799
+ version: 1,
800
+ defaults: { max_retries: 3 },
801
+ tasks: [{ id: 'a', prompt: 'x', max_retries: 0 }],
802
+ });
803
+ expect(spec.tasks[0].max_retries).toBe(0);
804
+ });
805
+ it('propagates version and defaults fields through to spec', () => {
806
+ const spec = applyDefaults({
807
+ name: 'test',
808
+ version: 1,
809
+ defaults: { model: 'gpt-4' },
810
+ gates: ['npm test'],
811
+ branch: 'feat/convoy',
812
+ tasks: [{ id: 'a', prompt: 'x' }],
813
+ });
814
+ expect(spec.version).toBe(1);
815
+ expect(spec.gates).toEqual(['npm test']);
816
+ expect(spec.branch).toBe('feat/convoy');
817
+ });
818
+ });
819
+ // ── applyDefaults — max_retries default always applied ─────────
820
+ describe('applyDefaults — max_retries always applied', () => {
821
+ it('applies max_retries default of 1 for legacy spec', () => {
822
+ const spec = applyDefaults({
823
+ name: 'test',
824
+ tasks: [{ id: 'a', prompt: 'x' }],
825
+ });
826
+ expect(spec.tasks[0].max_retries).toBe(1);
827
+ });
828
+ it('applies max_retries default of 1 when version:1 has no defaults block', () => {
829
+ const spec = applyDefaults({
830
+ name: 'test',
831
+ version: 1,
832
+ tasks: [{ id: 'a', prompt: 'x' }],
833
+ });
834
+ expect(spec.tasks[0].max_retries).toBe(1);
835
+ });
836
+ it('preserves explicit task max_retries in legacy spec', () => {
837
+ const spec = applyDefaults({
838
+ name: 'test',
839
+ tasks: [{ id: 'a', prompt: 'x', max_retries: 5 }],
840
+ });
841
+ expect(spec.tasks[0].max_retries).toBe(5);
842
+ });
843
+ });
844
+ // ── backward compatibility ─────────────────────────────────────
845
+ describe('backward compatibility — legacy specs', () => {
846
+ it('legacy spec validates identically without version field', () => {
847
+ const result = validateSpec({
848
+ name: 'test-run',
849
+ tasks: [
850
+ { id: 'task-1', prompt: 'Do something' },
851
+ { id: 'task-2', prompt: 'Do another thing', depends_on: ['task-1'] },
852
+ ],
853
+ });
854
+ expect(result.valid).toBe(true);
855
+ expect(result.errors).toHaveLength(0);
856
+ });
857
+ it('legacy spec applyDefaults produces same agent/timeout/depends_on/files as before', () => {
858
+ const spec = applyDefaults({
859
+ name: 'test',
860
+ tasks: [{ id: 'a', prompt: 'x' }],
861
+ });
862
+ const task = spec.tasks[0];
863
+ expect(task.agent).toBe('developer');
864
+ expect(task.timeout).toBe('30m');
865
+ expect(task.depends_on).toEqual([]);
866
+ expect(task.files).toEqual([]);
867
+ });
868
+ it('user-specified legacy task values are preserved', () => {
869
+ const spec = applyDefaults({
870
+ name: 'test',
871
+ tasks: [{
872
+ id: 'a',
873
+ prompt: 'x',
874
+ agent: 'ui-ux-expert',
875
+ timeout: '5m',
876
+ depends_on: ['b'],
877
+ files: ['src/'],
878
+ }],
879
+ });
880
+ const task = spec.tasks[0];
881
+ expect(task.agent).toBe('ui-ux-expert');
882
+ expect(task.timeout).toBe('5m');
883
+ expect(task.depends_on).toEqual(['b']);
884
+ expect(task.files).toEqual(['src/']);
885
+ });
886
+ it('defaults block is ignored without version:1', () => {
887
+ // Without version:1, the defaults block should not be merged
888
+ const spec = applyDefaults({
889
+ name: 'test',
890
+ defaults: { agent: 'ui-ux-expert', model: 'gpt-4' },
891
+ tasks: [{ id: 'a', prompt: 'x' }],
892
+ });
893
+ // agent falls back to hardcoded 'developer', not defaults.agent
894
+ expect(spec.tasks[0].agent).toBe('developer');
895
+ // model is not set
896
+ expect(spec.tasks[0].model).toBeUndefined();
897
+ });
898
+ });
302
899
  //# sourceMappingURL=schema.test.js.map