deepflow 0.1.97 → 0.1.99
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.
- package/bin/plan-consolidator.js +330 -0
- package/bin/plan-consolidator.test.js +882 -0
- package/bin/wave-runner.js +74 -8
- package/bin/wave-runner.test.js +529 -2
- package/hooks/df-subagent-registry.js +8 -0
- package/hooks/df-subagent-registry.test.js +110 -3
- package/package.json +1 -1
- package/src/commands/df/execute.md +38 -7
- package/src/commands/df/plan.md +112 -114
- package/src/commands/df/verify.md +1 -1
package/bin/wave-runner.test.js
CHANGED
|
@@ -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.
|
|
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', () => {
|