deepflow 0.1.97 → 0.1.98

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.
@@ -49,14 +49,14 @@ const extractedFns = (() => {
49
49
 
50
50
  const wrapped = `
51
51
  ${modifiedSrc}
52
- return { parseArgs, parsePlan, buildWaves, formatWaves };
52
+ return { parseArgs, parsePlan, buildWaves, formatWaves, formatWavesJson };
53
53
  `;
54
54
 
55
55
  const factory = new Function('require', 'process', '__dirname', '__filename', 'module', 'exports', wrapped);
56
56
  return factory(require, process, __dirname, __filename, module, exports);
57
57
  })();
58
58
 
59
- const { parseArgs, parsePlan, buildWaves, formatWaves } = extractedFns;
59
+ const { parseArgs, parsePlan, buildWaves, formatWaves, formatWavesJson } = extractedFns;
60
60
 
61
61
  // ---------------------------------------------------------------------------
62
62
  // CLI runner helper
@@ -554,3 +554,530 @@ describe('CLI — wave-runner.js subprocess', () => {
554
554
  }
555
555
  });
556
556
  });
557
+
558
+ // ---------------------------------------------------------------------------
559
+ // 8. parseArgs — --json flag
560
+ // ---------------------------------------------------------------------------
561
+
562
+ describe('parseArgs — --json flag', () => {
563
+ test('--json defaults to false', () => {
564
+ const args = parseArgs(['node', 'wave-runner.js']);
565
+ assert.equal(args.json, false);
566
+ });
567
+
568
+ test('--json flag enables JSON mode', () => {
569
+ const args = parseArgs(['node', 'wave-runner.js', '--json']);
570
+ assert.equal(args.json, true);
571
+ });
572
+
573
+ test('--json combined with other flags', () => {
574
+ const args = parseArgs(['node', 'wave-runner.js', '--recalc', '--failed', 'T1', '--json']);
575
+ assert.equal(args.json, true);
576
+ assert.equal(args.recalc, true);
577
+ assert.deepEqual(args.failed, ['T1']);
578
+ });
579
+
580
+ test('--json before other flags', () => {
581
+ const args = parseArgs(['node', 'wave-runner.js', '--json', '--plan', 'custom.md']);
582
+ assert.equal(args.json, true);
583
+ assert.equal(args.plan, 'custom.md');
584
+ });
585
+ });
586
+
587
+ // ---------------------------------------------------------------------------
588
+ // 9. parsePlan — metadata annotations (model, files, effort, spec)
589
+ // ---------------------------------------------------------------------------
590
+
591
+ describe('parsePlan — metadata annotations', () => {
592
+ test('extracts Model annotation', () => {
593
+ const text = `
594
+ - [ ] **T1**: Build parser
595
+ - Model: opus
596
+ `;
597
+ const tasks = parsePlan(text);
598
+ assert.equal(tasks[0].model, 'opus');
599
+ });
600
+
601
+ test('extracts Files annotation', () => {
602
+ const text = `
603
+ - [ ] **T1**: Build parser
604
+ - Files: src/parser.js, src/util.js
605
+ `;
606
+ const tasks = parsePlan(text);
607
+ assert.equal(tasks[0].files, 'src/parser.js, src/util.js');
608
+ });
609
+
610
+ test('extracts Effort annotation', () => {
611
+ const text = `
612
+ - [ ] **T1**: Build parser
613
+ - Effort: high
614
+ `;
615
+ const tasks = parsePlan(text);
616
+ assert.equal(tasks[0].effort, 'high');
617
+ });
618
+
619
+ test('extracts spec name from ### header', () => {
620
+ const text = `
621
+ ### my-feature
622
+ - [ ] **T1**: Build parser
623
+ - [ ] **T2**: Wire CLI
624
+ `;
625
+ const tasks = parsePlan(text);
626
+ assert.equal(tasks[0].spec, 'my-feature');
627
+ assert.equal(tasks[1].spec, 'my-feature');
628
+ });
629
+
630
+ test('spec changes when a new ### header appears', () => {
631
+ const text = `
632
+ ### feature-a
633
+ - [ ] **T1**: Task in feature-a
634
+
635
+ ### feature-b
636
+ - [ ] **T2**: Task in feature-b
637
+ `;
638
+ const tasks = parsePlan(text);
639
+ assert.equal(tasks[0].spec, 'feature-a');
640
+ assert.equal(tasks[1].spec, 'feature-b');
641
+ });
642
+
643
+ test('spec is null when no ### header precedes a task', () => {
644
+ const text = `
645
+ - [ ] **T1**: No spec header above
646
+ `;
647
+ const tasks = parsePlan(text);
648
+ assert.equal(tasks[0].spec, null);
649
+ });
650
+
651
+ test('metadata defaults to null when annotations are absent', () => {
652
+ const text = `
653
+ - [ ] **T1**: Bare task
654
+ `;
655
+ const tasks = parsePlan(text);
656
+ assert.equal(tasks[0].model, null);
657
+ assert.equal(tasks[0].files, null);
658
+ assert.equal(tasks[0].effort, null);
659
+ assert.equal(tasks[0].spec, null);
660
+ });
661
+
662
+ test('all annotations on one task', () => {
663
+ const text = `
664
+ ### my-spec
665
+ - [ ] **T1**: Full task
666
+ - Blocked by: T0
667
+ - Model: sonnet
668
+ - Files: a.js, b.js
669
+ - Effort: low
670
+ `;
671
+ const tasks = parsePlan(text);
672
+ assert.equal(tasks.length, 1);
673
+ assert.deepEqual(tasks[0].blockedBy, ['T0']);
674
+ assert.equal(tasks[0].model, 'sonnet');
675
+ assert.equal(tasks[0].files, 'a.js, b.js');
676
+ assert.equal(tasks[0].effort, 'low');
677
+ assert.equal(tasks[0].spec, 'my-spec');
678
+ });
679
+
680
+ test('annotations are case-insensitive', () => {
681
+ const text = `
682
+ - [ ] **T1**: Test case
683
+ - model: OPUS
684
+ - files: x.ts
685
+ - effort: Medium
686
+ `;
687
+ const tasks = parsePlan(text);
688
+ assert.equal(tasks[0].model, 'OPUS');
689
+ assert.equal(tasks[0].files, 'x.ts');
690
+ assert.equal(tasks[0].effort, 'Medium');
691
+ });
692
+ });
693
+
694
+ // ---------------------------------------------------------------------------
695
+ // 10. formatWavesJson — JSON output formatting
696
+ // ---------------------------------------------------------------------------
697
+
698
+ describe('formatWavesJson — JSON output formatting', () => {
699
+ test('returns valid JSON array', () => {
700
+ const waves = [
701
+ [{ id: 'T1', description: 'Build parser', model: 'opus', files: 'a.js', effort: 'high', blockedBy: [], spec: 'my-spec' }],
702
+ ];
703
+ const output = formatWavesJson(waves);
704
+ const parsed = JSON.parse(output);
705
+ assert.ok(Array.isArray(parsed));
706
+ });
707
+
708
+ test('includes wave number for each task', () => {
709
+ const waves = [
710
+ [{ id: 'T1', description: 'First', model: null, files: null, effort: null, blockedBy: [], spec: null }],
711
+ [{ id: 'T2', description: 'Second', model: null, files: null, effort: null, blockedBy: ['T1'], spec: null }],
712
+ ];
713
+ const parsed = JSON.parse(formatWavesJson(waves));
714
+ assert.equal(parsed[0].wave, 1);
715
+ assert.equal(parsed[1].wave, 2);
716
+ });
717
+
718
+ test('preserves all metadata fields', () => {
719
+ const waves = [
720
+ [{ id: 'T1', description: 'Do stuff', model: 'opus', files: 'src/a.js', effort: 'high', blockedBy: ['T0'], spec: 'my-spec' }],
721
+ ];
722
+ const parsed = JSON.parse(formatWavesJson(waves));
723
+ const t = parsed[0];
724
+ assert.equal(t.id, 'T1');
725
+ assert.equal(t.description, 'Do stuff');
726
+ assert.equal(t.model, 'opus');
727
+ assert.equal(t.files, 'src/a.js');
728
+ assert.equal(t.effort, 'high');
729
+ assert.deepEqual(t.blockedBy, ['T0']);
730
+ assert.equal(t.spec, 'my-spec');
731
+ assert.equal(t.wave, 1);
732
+ });
733
+
734
+ test('null fields for missing metadata', () => {
735
+ const waves = [
736
+ [{ id: 'T1', description: '', model: null, files: null, effort: null, blockedBy: [], spec: null }],
737
+ ];
738
+ const parsed = JSON.parse(formatWavesJson(waves));
739
+ const t = parsed[0];
740
+ assert.equal(t.description, null); // empty string becomes null
741
+ assert.equal(t.model, null);
742
+ assert.equal(t.files, null);
743
+ assert.equal(t.effort, null);
744
+ assert.equal(t.spec, null);
745
+ });
746
+
747
+ test('multiple tasks across multiple waves', () => {
748
+ const waves = [
749
+ [
750
+ { id: 'T1', description: 'A', model: null, files: null, effort: null, blockedBy: [], spec: null },
751
+ { id: 'T2', description: 'B', model: null, files: null, effort: null, blockedBy: [], spec: null },
752
+ ],
753
+ [
754
+ { id: 'T3', description: 'C', model: null, files: null, effort: null, blockedBy: ['T1'], spec: null },
755
+ ],
756
+ ];
757
+ const parsed = JSON.parse(formatWavesJson(waves));
758
+ assert.equal(parsed.length, 3);
759
+ assert.equal(parsed[0].wave, 1);
760
+ assert.equal(parsed[1].wave, 1);
761
+ assert.equal(parsed[2].wave, 2);
762
+ });
763
+
764
+ test('empty waves returns empty JSON array', () => {
765
+ const parsed = JSON.parse(formatWavesJson([]));
766
+ assert.deepEqual(parsed, []);
767
+ });
768
+
769
+ test('output is pretty-printed with 2-space indent', () => {
770
+ const waves = [
771
+ [{ id: 'T1', description: 'X', model: null, files: null, effort: null, blockedBy: [], spec: null }],
772
+ ];
773
+ const output = formatWavesJson(waves);
774
+ // Pretty-printed JSON starts with [\n {
775
+ assert.ok(output.startsWith('[\n {'));
776
+ });
777
+ });
778
+
779
+ // ---------------------------------------------------------------------------
780
+ // 11. formatWavesJson — additional edge cases
781
+ // ---------------------------------------------------------------------------
782
+
783
+ describe('formatWavesJson — edge cases and field completeness', () => {
784
+ test('output contains exactly the required 8 fields per task', () => {
785
+ const waves = [
786
+ [{ id: 'T1', description: 'Desc', model: 'opus', files: 'a.js', effort: 'low', blockedBy: ['T0'], spec: 'my-spec' }],
787
+ ];
788
+ const parsed = JSON.parse(formatWavesJson(waves));
789
+ const keys = Object.keys(parsed[0]).sort();
790
+ assert.deepEqual(keys, ['blockedBy', 'description', 'effort', 'files', 'id', 'model', 'spec', 'wave']);
791
+ });
792
+
793
+ test('empty string description is coerced to null', () => {
794
+ const waves = [
795
+ [{ id: 'T1', description: '', model: null, files: null, effort: null, blockedBy: [], spec: null }],
796
+ ];
797
+ const parsed = JSON.parse(formatWavesJson(waves));
798
+ assert.equal(parsed[0].description, null);
799
+ });
800
+
801
+ test('non-empty string fields pass through as-is (only empty string and null become null)', () => {
802
+ // formatWavesJson uses `t.X || null` — only falsy values (null, undefined, '') become null
803
+ // Non-empty strings like 'haiku' are preserved
804
+ const waves = [
805
+ [{ id: 'T5', description: 'desc', model: 'haiku', files: 'src/x.js', effort: 'low', blockedBy: [], spec: 'my-spec' }],
806
+ ];
807
+ const parsed = JSON.parse(formatWavesJson(waves));
808
+ assert.equal(parsed[0].model, 'haiku');
809
+ assert.equal(parsed[0].files, 'src/x.js');
810
+ assert.equal(parsed[0].effort, 'low');
811
+ assert.equal(parsed[0].spec, 'my-spec');
812
+ });
813
+
814
+ test('blockedBy array is preserved intact', () => {
815
+ const waves = [
816
+ [{ id: 'T4', description: 'D', model: null, files: null, effort: null, blockedBy: ['T1', 'T2', 'T3'], spec: null }],
817
+ ];
818
+ const parsed = JSON.parse(formatWavesJson(waves));
819
+ assert.deepEqual(parsed[0].blockedBy, ['T1', 'T2', 'T3']);
820
+ });
821
+
822
+ test('wave numbers are 1-indexed and sequential', () => {
823
+ const waves = [
824
+ [{ id: 'T1', description: 'A', model: null, files: null, effort: null, blockedBy: [], spec: null }],
825
+ [{ id: 'T2', description: 'B', model: null, files: null, effort: null, blockedBy: [], spec: null }],
826
+ [{ id: 'T3', description: 'C', model: null, files: null, effort: null, blockedBy: [], spec: null }],
827
+ ];
828
+ const parsed = JSON.parse(formatWavesJson(waves));
829
+ assert.equal(parsed[0].wave, 1);
830
+ assert.equal(parsed[1].wave, 2);
831
+ assert.equal(parsed[2].wave, 3);
832
+ });
833
+ });
834
+
835
+ // ---------------------------------------------------------------------------
836
+ // 12. parsePlan — doing-{name} spec header extraction
837
+ // ---------------------------------------------------------------------------
838
+
839
+ describe('parsePlan — doing-{name} spec header extraction', () => {
840
+ test('extracts spec from ### doing-{name} header verbatim', () => {
841
+ const text = `
842
+ ### doing-plan-fanout-v2
843
+ - [ ] **T1**: First task
844
+ - [ ] **T2**: Second task
845
+ `;
846
+ const tasks = parsePlan(text);
847
+ assert.equal(tasks[0].spec, 'doing-plan-fanout-v2');
848
+ assert.equal(tasks[1].spec, 'doing-plan-fanout-v2');
849
+ });
850
+
851
+ test('extracts spec from ### done-{name} header', () => {
852
+ const text = `
853
+ ### done-my-feature
854
+ - [ ] **T1**: Leftover task
855
+ `;
856
+ const tasks = parsePlan(text);
857
+ assert.equal(tasks[0].spec, 'done-my-feature');
858
+ });
859
+
860
+ test('spec switches from doing- to another spec mid-file', () => {
861
+ const text = `
862
+ ### doing-spec-a
863
+ - [ ] **T1**: In spec-a
864
+
865
+ ### doing-spec-b
866
+ - [ ] **T2**: In spec-b
867
+ `;
868
+ const tasks = parsePlan(text);
869
+ assert.equal(tasks[0].spec, 'doing-spec-a');
870
+ assert.equal(tasks[1].spec, 'doing-spec-b');
871
+ });
872
+
873
+ test('spec header with extra whitespace is trimmed', () => {
874
+ const text = `
875
+ ### my-spec-with-spaces
876
+ - [ ] **T1**: Task
877
+ `;
878
+ const tasks = parsePlan(text);
879
+ assert.equal(tasks[0].spec, 'my-spec-with-spaces');
880
+ });
881
+
882
+ test('mixed pending and completed tasks under same spec header', () => {
883
+ const text = `
884
+ ### doing-mixed
885
+ - [x] **T1**: Done
886
+ - [ ] **T2**: Pending
887
+ `;
888
+ const tasks = parsePlan(text);
889
+ assert.equal(tasks.length, 1);
890
+ assert.equal(tasks[0].id, 'T2');
891
+ assert.equal(tasks[0].spec, 'doing-mixed');
892
+ });
893
+ });
894
+
895
+ // ---------------------------------------------------------------------------
896
+ // 13. parsePlan — metadata not leaked across task boundaries
897
+ // ---------------------------------------------------------------------------
898
+
899
+ describe('parsePlan — metadata isolation across task boundaries', () => {
900
+ test('model annotation not leaked to subsequent task', () => {
901
+ const text = `
902
+ - [ ] **T1**: First
903
+ - Model: opus
904
+ - [ ] **T2**: Second
905
+ `;
906
+ const tasks = parsePlan(text);
907
+ assert.equal(tasks[0].model, 'opus');
908
+ assert.equal(tasks[1].model, null);
909
+ });
910
+
911
+ test('files annotation not leaked to subsequent task', () => {
912
+ const text = `
913
+ - [ ] **T1**: First
914
+ - Files: a.js
915
+ - [ ] **T2**: Second
916
+ `;
917
+ const tasks = parsePlan(text);
918
+ assert.equal(tasks[0].files, 'a.js');
919
+ assert.equal(tasks[1].files, null);
920
+ });
921
+
922
+ test('effort annotation not leaked to subsequent task', () => {
923
+ const text = `
924
+ - [ ] **T1**: First
925
+ - Effort: high
926
+ - [ ] **T2**: Second
927
+ `;
928
+ const tasks = parsePlan(text);
929
+ assert.equal(tasks[0].effort, 'high');
930
+ assert.equal(tasks[1].effort, null);
931
+ });
932
+
933
+ test('completed task between two pending tasks resets annotation context', () => {
934
+ const text = `
935
+ - [ ] **T1**: Pending
936
+ - Model: opus
937
+ - [x] **T2**: Completed with model
938
+ - Model: sonnet
939
+ - [ ] **T3**: Next pending
940
+ `;
941
+ const tasks = parsePlan(text);
942
+ assert.equal(tasks.length, 2);
943
+ assert.equal(tasks[0].id, 'T1');
944
+ assert.equal(tasks[0].model, 'opus');
945
+ assert.equal(tasks[1].id, 'T3');
946
+ assert.equal(tasks[1].model, null);
947
+ });
948
+
949
+ test('annotations in non-standard order are all captured', () => {
950
+ const text = `
951
+ - [ ] **T1**: Task
952
+ - Effort: medium
953
+ - Blocked by: T0
954
+ - Files: x.js
955
+ - Model: haiku
956
+ `;
957
+ const tasks = parsePlan(text);
958
+ assert.equal(tasks[0].effort, 'medium');
959
+ assert.deepEqual(tasks[0].blockedBy, ['T0']);
960
+ assert.equal(tasks[0].files, 'x.js');
961
+ assert.equal(tasks[0].model, 'haiku');
962
+ });
963
+ });
964
+
965
+ // ---------------------------------------------------------------------------
966
+ // 15. CLI — --json subprocess tests
967
+ // ---------------------------------------------------------------------------
968
+
969
+ describe('CLI — --json flag subprocess', () => {
970
+ test('--json outputs valid JSON array', () => {
971
+ const tmpDir = makeTmpDir();
972
+ try {
973
+ fs.writeFileSync(
974
+ path.join(tmpDir, 'PLAN.md'),
975
+ '- [ ] **T1**: First task\n- [ ] **T2**: Second task\n - Blocked by: T1\n'
976
+ );
977
+ const { code, stdout } = runWaveRunner(['--json'], { cwd: tmpDir });
978
+ assert.equal(code, 0);
979
+ const parsed = JSON.parse(stdout);
980
+ assert.ok(Array.isArray(parsed));
981
+ assert.equal(parsed.length, 2);
982
+ } finally {
983
+ rmrf(tmpDir);
984
+ }
985
+ });
986
+
987
+ test('--json includes wave numbers', () => {
988
+ const tmpDir = makeTmpDir();
989
+ try {
990
+ fs.writeFileSync(
991
+ path.join(tmpDir, 'PLAN.md'),
992
+ '- [ ] **T1**: First\n- [ ] **T2**: Second\n - Blocked by: T1\n'
993
+ );
994
+ const { code, stdout } = runWaveRunner(['--json'], { cwd: tmpDir });
995
+ assert.equal(code, 0);
996
+ const parsed = JSON.parse(stdout);
997
+ assert.equal(parsed[0].id, 'T1');
998
+ assert.equal(parsed[0].wave, 1);
999
+ assert.equal(parsed[1].id, 'T2');
1000
+ assert.equal(parsed[1].wave, 2);
1001
+ } finally {
1002
+ rmrf(tmpDir);
1003
+ }
1004
+ });
1005
+
1006
+ test('--json includes metadata annotations', () => {
1007
+ const tmpDir = makeTmpDir();
1008
+ try {
1009
+ fs.writeFileSync(
1010
+ path.join(tmpDir, 'PLAN.md'),
1011
+ '### my-spec\n- [ ] **T1**: Task with metadata\n - Model: opus\n - Files: src/a.js\n - Effort: high\n'
1012
+ );
1013
+ const { code, stdout } = runWaveRunner(['--json'], { cwd: tmpDir });
1014
+ assert.equal(code, 0);
1015
+ const parsed = JSON.parse(stdout);
1016
+ assert.equal(parsed[0].model, 'opus');
1017
+ assert.equal(parsed[0].files, 'src/a.js');
1018
+ assert.equal(parsed[0].effort, 'high');
1019
+ assert.equal(parsed[0].spec, 'my-spec');
1020
+ } finally {
1021
+ rmrf(tmpDir);
1022
+ }
1023
+ });
1024
+
1025
+ test('--json with --recalc --failed excludes stuck tasks', () => {
1026
+ const tmpDir = makeTmpDir();
1027
+ try {
1028
+ fs.writeFileSync(
1029
+ path.join(tmpDir, 'PLAN.md'),
1030
+ '- [ ] **T1**: Base\n- [ ] **T2**: Depends\n - Blocked by: T1\n- [ ] **T3**: Independent\n'
1031
+ );
1032
+ const { code, stdout } = runWaveRunner(['--json', '--recalc', '--failed', 'T1'], { cwd: tmpDir });
1033
+ assert.equal(code, 0);
1034
+ const parsed = JSON.parse(stdout);
1035
+ assert.equal(parsed.length, 1);
1036
+ assert.equal(parsed[0].id, 'T3');
1037
+ } finally {
1038
+ rmrf(tmpDir);
1039
+ }
1040
+ });
1041
+
1042
+ test('--json with no pending tasks returns empty array', () => {
1043
+ const tmpDir = makeTmpDir();
1044
+ try {
1045
+ fs.writeFileSync(
1046
+ path.join(tmpDir, 'PLAN.md'),
1047
+ '- [x] **T1**: Done\n- [x] **T2**: Also done\n'
1048
+ );
1049
+ const { code, stdout } = runWaveRunner(['--json'], { cwd: tmpDir });
1050
+ assert.equal(code, 0);
1051
+ const parsed = JSON.parse(stdout);
1052
+ assert.deepEqual(parsed, []);
1053
+ } finally {
1054
+ rmrf(tmpDir);
1055
+ }
1056
+ });
1057
+
1058
+ test('--json still exits 1 when PLAN.md not found', () => {
1059
+ const tmpDir = makeTmpDir();
1060
+ try {
1061
+ const { code } = runWaveRunner(['--json'], { cwd: tmpDir });
1062
+ assert.equal(code, 1);
1063
+ } finally {
1064
+ rmrf(tmpDir);
1065
+ }
1066
+ });
1067
+
1068
+ test('without --json flag, output is plain text (not JSON)', () => {
1069
+ const tmpDir = makeTmpDir();
1070
+ try {
1071
+ fs.writeFileSync(
1072
+ path.join(tmpDir, 'PLAN.md'),
1073
+ '- [ ] **T1**: Task\n'
1074
+ );
1075
+ const { code, stdout } = runWaveRunner([], { cwd: tmpDir });
1076
+ assert.equal(code, 0);
1077
+ assert.ok(stdout.includes('Wave 1:'));
1078
+ assert.throws(() => JSON.parse(stdout));
1079
+ } finally {
1080
+ rmrf(tmpDir);
1081
+ }
1082
+ });
1083
+ });
@@ -13,6 +13,13 @@ process.stdin.on('end', () => {
13
13
  // Extract required fields from SubagentStop event
14
14
  const { session_id, agent_type, agent_id } = event;
15
15
 
16
+ // Map agent_type to model (case-sensitive)
17
+ const MODEL_MAP = {
18
+ 'reasoner': 'claude-opus-4-6',
19
+ 'Explore': 'claude-haiku-4-5'
20
+ };
21
+ const model = MODEL_MAP[agent_type] ?? 'claude-sonnet-4-6';
22
+
16
23
  // Generate timestamp
17
24
  const timestamp = new Date().toISOString();
18
25
 
@@ -21,6 +28,7 @@ process.stdin.on('end', () => {
21
28
  session_id,
22
29
  agent_type,
23
30
  agent_id,
31
+ model,
24
32
  timestamp
25
33
  };
26
34
 
@@ -143,7 +143,7 @@ describe('df-subagent-registry — valid SubagentStop event', () => {
143
143
  assert.equal(entries[1].session_id, 'sess-2');
144
144
  });
145
145
 
146
- test('entry only contains session_id, agent_type, agent_id, and timestamp', () => {
146
+ test('entry only contains session_id, agent_type, agent_id, model, and timestamp', () => {
147
147
  const event = {
148
148
  session_id: 'sess-fields',
149
149
  agent_type: 'qa',
@@ -158,7 +158,7 @@ describe('df-subagent-registry — valid SubagentStop event', () => {
158
158
  const entries = readRegistry(tmpHome);
159
159
  assert.equal(entries.length, 1);
160
160
  const keys = Object.keys(entries[0]).sort();
161
- assert.deepEqual(keys, ['agent_id', 'agent_type', 'session_id', 'timestamp']);
161
+ assert.deepEqual(keys, ['agent_id', 'agent_type', 'model', 'session_id', 'timestamp']);
162
162
  });
163
163
  });
164
164
 
@@ -285,7 +285,114 @@ describe('df-subagent-registry — invalid JSON stdin', () => {
285
285
  });
286
286
 
287
287
  // ---------------------------------------------------------------------------
288
- // 5. Edge cases
288
+ // 5. Model mapping from agent_type
289
+ // ---------------------------------------------------------------------------
290
+
291
+ describe('df-subagent-registry — model mapping from agent_type', () => {
292
+ let tmpHome;
293
+
294
+ beforeEach(() => {
295
+ tmpHome = makeTmpDir();
296
+ fs.mkdirSync(path.join(tmpHome, '.claude'), { recursive: true });
297
+ });
298
+
299
+ afterEach(() => {
300
+ rmrf(tmpHome);
301
+ });
302
+
303
+ test('agent_type "reasoner" maps to claude-opus-4-6', () => {
304
+ const event = { session_id: 's1', agent_type: 'reasoner', agent_id: 'a1' };
305
+ runHook(event, { home: tmpHome });
306
+
307
+ const entries = readRegistry(tmpHome);
308
+ assert.equal(entries.length, 1);
309
+ assert.equal(entries[0].model, 'claude-opus-4-6');
310
+ });
311
+
312
+ test('agent_type "Explore" maps to claude-haiku-4-5', () => {
313
+ const event = { session_id: 's1', agent_type: 'Explore', agent_id: 'a1' };
314
+ runHook(event, { home: tmpHome });
315
+
316
+ const entries = readRegistry(tmpHome);
317
+ assert.equal(entries.length, 1);
318
+ assert.equal(entries[0].model, 'claude-haiku-4-5');
319
+ });
320
+
321
+ test('unknown agent_type defaults to claude-sonnet-4-6', () => {
322
+ const event = { session_id: 's1', agent_type: 'worker', agent_id: 'a1' };
323
+ runHook(event, { home: tmpHome });
324
+
325
+ const entries = readRegistry(tmpHome);
326
+ assert.equal(entries.length, 1);
327
+ assert.equal(entries[0].model, 'claude-sonnet-4-6');
328
+ });
329
+
330
+ test('mapping is case-sensitive — "Reasoner" is not "reasoner"', () => {
331
+ const event = { session_id: 's1', agent_type: 'Reasoner', agent_id: 'a1' };
332
+ runHook(event, { home: tmpHome });
333
+
334
+ const entries = readRegistry(tmpHome);
335
+ assert.equal(entries.length, 1);
336
+ assert.equal(entries[0].model, 'claude-sonnet-4-6',
337
+ '"Reasoner" (capital R) should not match "reasoner" — should get default');
338
+ });
339
+
340
+ test('mapping is case-sensitive — "explore" is not "Explore"', () => {
341
+ const event = { session_id: 's1', agent_type: 'explore', agent_id: 'a1' };
342
+ runHook(event, { home: tmpHome });
343
+
344
+ const entries = readRegistry(tmpHome);
345
+ assert.equal(entries.length, 1);
346
+ assert.equal(entries[0].model, 'claude-sonnet-4-6',
347
+ '"explore" (lowercase) should not match "Explore" — should get default');
348
+ });
349
+
350
+ test('undefined agent_type gets default model', () => {
351
+ const event = { session_id: 's1', agent_id: 'a1' };
352
+ runHook(event, { home: tmpHome });
353
+
354
+ const entries = readRegistry(tmpHome);
355
+ assert.equal(entries.length, 1);
356
+ assert.equal(entries[0].model, 'claude-sonnet-4-6');
357
+ });
358
+
359
+ test('empty string agent_type gets default model', () => {
360
+ const event = { session_id: 's1', agent_type: '', agent_id: 'a1' };
361
+ runHook(event, { home: tmpHome });
362
+
363
+ const entries = readRegistry(tmpHome);
364
+ assert.equal(entries.length, 1);
365
+ assert.equal(entries[0].model, 'claude-sonnet-4-6');
366
+ });
367
+
368
+ test('model field is present in entry alongside other fields', () => {
369
+ const event = { session_id: 's1', agent_type: 'reasoner', agent_id: 'a1' };
370
+ runHook(event, { home: tmpHome });
371
+
372
+ const entries = readRegistry(tmpHome);
373
+ assert.equal(entries.length, 1);
374
+ assert.equal(entries[0].session_id, 's1');
375
+ assert.equal(entries[0].agent_type, 'reasoner');
376
+ assert.equal(entries[0].agent_id, 'a1');
377
+ assert.equal(entries[0].model, 'claude-opus-4-6');
378
+ assert.ok(entries[0].timestamp);
379
+ });
380
+
381
+ test('each entry gets its own model based on its agent_type', () => {
382
+ runHook({ session_id: 's1', agent_type: 'reasoner', agent_id: 'a1' }, { home: tmpHome });
383
+ runHook({ session_id: 's2', agent_type: 'Explore', agent_id: 'a2' }, { home: tmpHome });
384
+ runHook({ session_id: 's3', agent_type: 'custom', agent_id: 'a3' }, { home: tmpHome });
385
+
386
+ const entries = readRegistry(tmpHome);
387
+ assert.equal(entries.length, 3);
388
+ assert.equal(entries[0].model, 'claude-opus-4-6');
389
+ assert.equal(entries[1].model, 'claude-haiku-4-5');
390
+ assert.equal(entries[2].model, 'claude-sonnet-4-6');
391
+ });
392
+ });
393
+
394
+ // ---------------------------------------------------------------------------
395
+ // 6. Edge cases
289
396
  // ---------------------------------------------------------------------------
290
397
 
291
398
  describe('df-subagent-registry — edge cases', () => {