deepflow 0.1.106 → 0.1.108

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/install.js CHANGED
@@ -35,6 +35,23 @@ const GLOBAL_DIR = path.join(os.homedir(), '.claude');
35
35
  const PROJECT_DIR = path.join(process.cwd(), '.claude');
36
36
  const PACKAGE_DIR = path.resolve(__dirname, '..');
37
37
 
38
+ /**
39
+ * Atomically write data to targetPath using a write-to-temp + rename pattern.
40
+ * If the write fails, the original file is left untouched and the temp file is
41
+ * cleaned up. Temp file is created in the same directory as the target so the
42
+ * rename is within the same filesystem (atomic on POSIX).
43
+ */
44
+ function atomicWriteFileSync(targetPath, data) {
45
+ const tmpPath = targetPath + '.tmp';
46
+ try {
47
+ fs.writeFileSync(tmpPath, data);
48
+ fs.renameSync(tmpPath, targetPath);
49
+ } catch (err) {
50
+ try { fs.unlinkSync(tmpPath); } catch (_) {}
51
+ throw err;
52
+ }
53
+ }
54
+
38
55
  function updateGlobalPackage() {
39
56
  const currentVersion = require(path.join(PACKAGE_DIR, 'package.json')).version;
40
57
  try {
@@ -144,7 +161,7 @@ async function main() {
144
161
  // Copy bin utilities (plan-consolidator, wave-runner, ratchet)
145
162
  const binDest = path.join(CLAUDE_DIR, 'bin');
146
163
  fs.mkdirSync(binDest, { recursive: true });
147
- for (const script of ['plan-consolidator.js', 'wave-runner.js', 'ratchet.js']) {
164
+ for (const script of ['plan-consolidator.js', 'wave-runner.js', 'ratchet.js', 'worktree-deps.js']) {
148
165
  const src = path.join(PACKAGE_DIR, 'bin', script);
149
166
  if (fs.existsSync(src)) {
150
167
  fs.copyFileSync(src, path.join(binDest, script));
@@ -156,14 +173,21 @@ async function main() {
156
173
  if (level === 'global') {
157
174
  const hooksDir = path.join(PACKAGE_DIR, 'hooks');
158
175
  if (fs.existsSync(hooksDir)) {
159
- for (const file of fs.readdirSync(hooksDir)) {
160
- if (file.endsWith('.js')) {
161
- fs.copyFileSync(
162
- path.join(hooksDir, file),
163
- path.join(CLAUDE_DIR, 'hooks', file)
164
- );
176
+ const copyDirRecursive = (srcDir, destDir) => {
177
+ for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) {
178
+ if (entry.isDirectory()) {
179
+ const subDest = path.join(destDir, entry.name);
180
+ fs.mkdirSync(subDest, { recursive: true });
181
+ copyDirRecursive(path.join(srcDir, entry.name), subDest);
182
+ } else if (entry.name.endsWith('.js')) {
183
+ fs.copyFileSync(
184
+ path.join(srcDir, entry.name),
185
+ path.join(destDir, entry.name)
186
+ );
187
+ }
165
188
  }
166
- }
189
+ };
190
+ copyDirRecursive(hooksDir, path.join(CLAUDE_DIR, 'hooks'));
167
191
  log('Hooks installed');
168
192
  }
169
193
  }
@@ -204,7 +228,7 @@ async function main() {
204
228
  console.log(' commands/df/ — /df:discover, /df:debate, /df:spec, /df:plan, /df:execute, /df:verify, /df:auto, /df:update');
205
229
  console.log(' skills/ — gap-discovery, atomic-commits, code-completeness, browse-fetch, browse-verify, auto-cycle');
206
230
  console.log(' agents/ — reasoner (/df:auto — autonomous execution via /loop)');
207
- console.log(' bin/ — plan-consolidator, wave-runner, ratchet');
231
+ console.log(' bin/ — plan-consolidator, wave-runner, ratchet, worktree-deps');
208
232
  console.log(' templates/ — explore-protocol (auto-injected into Explore agents via hook)');
209
233
  if (level === 'global') {
210
234
  console.log(' hooks/ — statusline, update checker, invariant checker, worktree guard, explore protocol');
@@ -427,7 +451,7 @@ async function configureHooks(claudeDir) {
427
451
  console.log(` ${c.dim}${file} copied (no @hook-event tag — not wired)${c.reset}`);
428
452
  }
429
453
 
430
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
454
+ atomicWriteFileSync(settingsPath, JSON.stringify(settings, null, 2));
431
455
  }
432
456
 
433
457
  function configureProjectSettings(claudeDir) {
@@ -450,7 +474,7 @@ function configureProjectSettings(claudeDir) {
450
474
  // Configure permissions for background agents
451
475
  configurePermissions(settings);
452
476
 
453
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
477
+ atomicWriteFileSync(settingsPath, JSON.stringify(settings, null, 2));
454
478
  log('LSP tool enabled + agent permissions configured (project)');
455
479
  }
456
480
 
@@ -607,6 +631,7 @@ async function uninstall() {
607
631
  'bin/plan-consolidator.js',
608
632
  'bin/wave-runner.js',
609
633
  'bin/ratchet.js',
634
+ 'bin/worktree-deps.js',
610
635
  'templates'
611
636
  ];
612
637
 
@@ -620,6 +645,8 @@ async function uninstall() {
620
645
  }
621
646
  }
622
647
  }
648
+ // Remove hooks/lib (shared hook utilities)
649
+ toRemove.push('hooks/lib');
623
650
  }
624
651
 
625
652
  for (const item of toRemove) {
@@ -666,7 +693,7 @@ async function uninstall() {
666
693
  console.log(` ${c.green}✓${c.reset} Removed deepflow permissions from settings`);
667
694
  }
668
695
 
669
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
696
+ atomicWriteFileSync(settingsPath, JSON.stringify(settings, null, 2));
670
697
  } catch (e) {
671
698
  // Fail silently
672
699
  }
@@ -693,7 +720,7 @@ async function uninstall() {
693
720
  fs.unlinkSync(localSettingsPath);
694
721
  console.log(` ${c.green}✓${c.reset} Removed settings.local.json (empty after cleanup)`);
695
722
  } else {
696
- fs.writeFileSync(localSettingsPath, JSON.stringify(localSettings, null, 2));
723
+ atomicWriteFileSync(localSettingsPath, JSON.stringify(localSettings, null, 2));
697
724
  console.log(` ${c.green}✓${c.reset} Removed deepflow settings from settings.local.json`);
698
725
  }
699
726
  } catch (e) {
@@ -707,7 +734,13 @@ async function uninstall() {
707
734
  console.log('');
708
735
  }
709
736
 
710
- main().catch(err => {
711
- console.error('Installation failed:', err.message);
712
- process.exit(1);
713
- });
737
+ // Export for testing
738
+ module.exports = { scanHookEvents, removeDeepflowHooks, atomicWriteFileSync };
739
+
740
+ // Only run main when executed directly (not when required by tests)
741
+ if (require.main === module) {
742
+ main().catch(err => {
743
+ console.error('Installation failed:', err.message);
744
+ process.exit(1);
745
+ });
746
+ }
@@ -590,217 +590,143 @@ describe('Uninstaller — file removal and settings cleanup', () => {
590
590
  });
591
591
 
592
592
  // ---------------------------------------------------------------------------
593
- // T4. command-usage hook registration (PreToolUse, PostToolUse, SessionEnd)
593
+ // T4. command-usage hook registration via dynamic @hook-event tags
594
594
  // ---------------------------------------------------------------------------
595
595
 
596
596
  describe('T4 — command-usage hook registration in install.js', () => {
597
597
 
598
- // -- Source-level checks: verify install.js registers df-command-usage.js --
598
+ // -- @hook-event tag: verify df-command-usage.js declares correct events --
599
599
 
600
- test('source defines commandUsageCmd variable', () => {
601
- const src = fs.readFileSync(path.resolve(__dirname, 'install.js'), 'utf8');
602
- const pattern = /commandUsageCmd\s*=\s*`node.*df-command-usage\.js/;
603
- assert.ok(
604
- pattern.test(src),
605
- 'install.js should define commandUsageCmd pointing to df-command-usage.js'
606
- );
600
+ test('df-command-usage.js has @hook-event tag for PreToolUse, PostToolUse, SessionEnd', () => {
601
+ const hookPath = path.resolve(__dirname, '..', 'hooks', 'df-command-usage.js');
602
+ const content = fs.readFileSync(hookPath, 'utf8');
603
+ const firstLines = content.split('\n').slice(0, 10).join('\n');
604
+ const match = firstLines.match(/\/\/\s*@hook-event:\s*(.+)/);
605
+ assert.ok(match, 'df-command-usage.js should have @hook-event tag in first 10 lines');
606
+ const events = match[1].split(',').map(e => e.trim());
607
+ assert.ok(events.includes('PreToolUse'), 'Should declare PreToolUse event');
608
+ assert.ok(events.includes('PostToolUse'), 'Should declare PostToolUse event');
609
+ assert.ok(events.includes('SessionEnd'), 'Should declare SessionEnd event');
607
610
  });
608
611
 
609
- test('source pushes command-usage hook to PreToolUse', () => {
610
- const src = fs.readFileSync(path.resolve(__dirname, 'install.js'), 'utf8');
611
- // Find PreToolUse section — should contain a push with commandUsageCmd
612
- const preToolUseSection = src.match(/PreToolUse[\s\S]*?log\('PreToolUse hook configured'\)/);
613
- assert.ok(preToolUseSection, 'Should have a PreToolUse configuration section');
614
- assert.ok(
615
- preToolUseSection[0].includes('commandUsageCmd'),
616
- 'PreToolUse section should push commandUsageCmd'
617
- );
618
- });
612
+ // -- scanHookEvents: verify dynamic hook scanning maps events correctly --
619
613
 
620
- test('source pushes command-usage hook to PostToolUse', () => {
621
- const src = fs.readFileSync(path.resolve(__dirname, 'install.js'), 'utf8');
622
- // Find PostToolUse section
623
- const postToolUseSection = src.match(/PostToolUse[\s\S]*?log\('PostToolUse hook configured'\)/);
624
- assert.ok(postToolUseSection, 'Should have a PostToolUse configuration section');
625
- assert.ok(
626
- postToolUseSection[0].includes('commandUsageCmd'),
627
- 'PostToolUse section should push commandUsageCmd'
628
- );
614
+ test('scanHookEvents maps df-command-usage.js to all three events', () => {
615
+ const { scanHookEvents } = require('./install.js');
616
+ const hooksDir = path.resolve(__dirname, '..', 'hooks');
617
+ const { eventMap } = scanHookEvents(hooksDir);
618
+ for (const event of ['PreToolUse', 'PostToolUse', 'SessionEnd']) {
619
+ assert.ok(eventMap.has(event), `eventMap should have ${event}`);
620
+ assert.ok(
621
+ eventMap.get(event).includes('df-command-usage.js'),
622
+ `${event} should include df-command-usage.js`
623
+ );
624
+ }
629
625
  });
630
626
 
631
- test('source pushes command-usage hook to SessionEnd', () => {
627
+ // -- configureHooks uses dynamic wiring (no hardcoded per-hook variables) --
628
+
629
+ test('source uses scanHookEvents for dynamic hook wiring', () => {
632
630
  const src = fs.readFileSync(path.resolve(__dirname, 'install.js'), 'utf8');
633
- // Find SessionEnd section — should include command-usage alongside quota-logger + dashboard-push
634
- const sessionEndSection = src.match(/SessionEnd[\s\S]*?log\('Quota logger.*configured.*SessionEnd/);
635
- assert.ok(sessionEndSection, 'Should have a SessionEnd configuration section');
636
- assert.ok(
637
- sessionEndSection[0].includes('commandUsageCmd'),
638
- 'SessionEnd section should push commandUsageCmd'
639
- );
631
+ assert.ok(src.includes('scanHookEvents('), 'Should call scanHookEvents');
632
+ assert.ok(src.includes('for (const [event, files] of eventMap)'), 'Should iterate eventMap to wire hooks');
640
633
  });
641
634
 
642
- test('source creates PreToolUse array if missing', () => {
635
+ test('source initializes event array if missing', () => {
643
636
  const src = fs.readFileSync(path.resolve(__dirname, 'install.js'), 'utf8');
644
637
  assert.ok(
645
- src.includes("if (!settings.hooks.PreToolUse)"),
646
- 'install.js should initialize PreToolUse array if not present'
638
+ src.includes('if (!settings.hooks[event]) settings.hooks[event] = [];'),
639
+ 'Should initialize event array dynamically'
647
640
  );
648
641
  });
649
642
 
650
- // -- Dedup logic: filter removes existing command-usage before re-adding --
651
-
652
- test('PreToolUse dedup filter removes existing df-command-usage entries', () => {
653
- const preToolUse = [
654
- { hooks: [{ type: 'command', command: 'node /home/.claude/hooks/df-command-usage.js' }] },
655
- { hooks: [{ type: 'command', command: 'node /usr/local/my-custom.js' }] },
656
- ];
657
-
658
- const filtered = preToolUse.filter(hook => {
659
- const cmd = hook.hooks?.[0]?.command || '';
660
- return !cmd.includes('df-command-usage');
661
- });
643
+ // -- removeDeepflowHooks: generic removal of all /hooks/df- entries --
662
644
 
663
- assert.equal(filtered.length, 1, 'Should remove existing df-command-usage hook');
664
- assert.ok(filtered[0].hooks[0].command.includes('my-custom.js'), 'Should keep non-deepflow hooks');
645
+ test('removeDeepflowHooks removes df-command-usage from all events', () => {
646
+ const { removeDeepflowHooks } = require('./install.js');
647
+ const settings = {
648
+ hooks: {
649
+ PreToolUse: [
650
+ { hooks: [{ type: 'command', command: 'node /home/.claude/hooks/df-command-usage.js' }] },
651
+ { hooks: [{ type: 'command', command: 'node /usr/local/my-custom.js' }] },
652
+ ],
653
+ PostToolUse: [
654
+ { hooks: [{ type: 'command', command: 'node /home/.claude/hooks/df-command-usage.js' }] },
655
+ { hooks: [{ type: 'command', command: 'node /home/.claude/hooks/df-tool-usage.js' }] },
656
+ ],
657
+ SessionEnd: [
658
+ { hooks: [{ type: 'command', command: 'node /home/.claude/hooks/df-command-usage.js' }] },
659
+ { hooks: [{ type: 'command', command: 'node /usr/local/keep.js' }] },
660
+ ],
661
+ }
662
+ };
663
+ removeDeepflowHooks(settings);
664
+ assert.equal(settings.hooks.PreToolUse.length, 1);
665
+ assert.ok(settings.hooks.PreToolUse[0].hooks[0].command.includes('my-custom.js'));
666
+ assert.ok(!('PostToolUse' in settings.hooks), 'PostToolUse should be deleted when only deepflow hooks');
667
+ assert.equal(settings.hooks.SessionEnd.length, 1);
668
+ assert.ok(settings.hooks.SessionEnd[0].hooks[0].command.includes('keep.js'));
665
669
  });
666
670
 
667
- test('PostToolUse dedup filter removes df-command-usage alongside other deepflow hooks', () => {
668
- const postToolUse = [
669
- { hooks: [{ type: 'command', command: 'node /home/.claude/hooks/df-tool-usage.js' }] },
670
- { hooks: [{ type: 'command', command: 'node /home/.claude/hooks/df-command-usage.js' }] },
671
- { hooks: [{ type: 'command', command: 'node /home/.claude/hooks/df-worktree-guard.js' }] },
672
- { hooks: [{ type: 'command', command: 'node /usr/local/keep-me.js' }] },
673
- ];
674
-
675
- const filtered = postToolUse.filter(hook => {
676
- const cmd = hook.hooks?.[0]?.command || '';
677
- return !cmd.includes('df-tool-usage') &&
678
- !cmd.includes('df-execution-history') &&
679
- !cmd.includes('df-worktree-guard') &&
680
- !cmd.includes('df-snapshot-guard') &&
681
- !cmd.includes('df-invariant-check') &&
682
- !cmd.includes('df-command-usage');
683
- });
684
-
685
- assert.equal(filtered.length, 1);
686
- assert.ok(filtered[0].hooks[0].command.includes('keep-me.js'));
671
+ test('removeDeepflowHooks deletes event key when array becomes empty', () => {
672
+ const { removeDeepflowHooks } = require('./install.js');
673
+ const settings = {
674
+ hooks: {
675
+ PreToolUse: [
676
+ { hooks: [{ type: 'command', command: 'node /home/.claude/hooks/df-command-usage.js' }] },
677
+ ],
678
+ }
679
+ };
680
+ removeDeepflowHooks(settings);
681
+ assert.ok(!('PreToolUse' in (settings.hooks || {})), 'PreToolUse should be deleted when empty');
687
682
  });
688
683
 
689
- test('SessionEnd dedup filter removes df-command-usage alongside quota-logger and dashboard-push', () => {
690
- const sessionEnd = [
691
- { hooks: [{ type: 'command', command: 'node /home/.claude/hooks/df-quota-logger.js' }] },
692
- { hooks: [{ type: 'command', command: 'node /home/.claude/hooks/df-dashboard-push.js' }] },
693
- { hooks: [{ type: 'command', command: 'node /home/.claude/hooks/df-command-usage.js' }] },
694
- { hooks: [{ type: 'command', command: 'node /usr/local/keep.js' }] },
695
- ];
696
-
697
- const filtered = sessionEnd.filter(hook => {
698
- const cmd = hook.hooks?.[0]?.command || '';
699
- return !cmd.includes('df-quota-logger') &&
700
- !cmd.includes('df-dashboard-push') &&
701
- !cmd.includes('df-command-usage');
702
- });
703
-
704
- assert.equal(filtered.length, 1);
705
- assert.ok(filtered[0].hooks[0].command.includes('keep.js'));
684
+ test('removeDeepflowHooks keeps non-deepflow hooks intact', () => {
685
+ const { removeDeepflowHooks } = require('./install.js');
686
+ const settings = {
687
+ hooks: {
688
+ PreToolUse: [
689
+ { hooks: [{ type: 'command', command: 'node /home/.claude/hooks/df-command-usage.js' }] },
690
+ { hooks: [{ type: 'command', command: 'node /usr/local/custom-pre-hook.js' }] },
691
+ ],
692
+ }
693
+ };
694
+ removeDeepflowHooks(settings);
695
+ assert.ok('PreToolUse' in settings.hooks, 'PreToolUse should be kept when custom hooks remain');
696
+ assert.equal(settings.hooks.PreToolUse.length, 1);
697
+ assert.ok(settings.hooks.PreToolUse[0].hooks[0].command.includes('custom-pre-hook.js'));
706
698
  });
707
699
 
708
- // -- Uninstall cleanup --
709
-
710
- test('uninstall toRemove includes df-command-usage.js', () => {
711
- const src = fs.readFileSync(path.resolve(__dirname, 'install.js'), 'utf8');
712
- // Find the toRemove.push(...) line for hooks in uninstall
713
- assert.ok(
714
- src.includes("'hooks/df-command-usage.js'"),
715
- 'toRemove should include hooks/df-command-usage.js for uninstall'
716
- );
717
- });
700
+ // -- Uninstall: dynamic df-*.js discovery --
718
701
 
719
- test('uninstall SessionEnd filter removes df-command-usage', () => {
702
+ test('uninstall dynamically discovers df-*.js hooks to remove', () => {
720
703
  const src = fs.readFileSync(path.resolve(__dirname, 'install.js'), 'utf8');
721
- // In the uninstall function, the SessionEnd filter should include df-command-usage
722
- // Find the uninstall section's SessionEnd filter
723
704
  const uninstallSection = src.match(/async function uninstall[\s\S]+$/);
724
705
  assert.ok(uninstallSection, 'Should have uninstall function');
725
- // Check SessionEnd filter in uninstall includes command-usage
726
- const sessionEndFilter = uninstallSection[0].match(/SessionEnd[\s\S]*?\.filter[\s\S]*?\);/);
727
- assert.ok(sessionEndFilter, 'Should have SessionEnd filter in uninstall');
728
706
  assert.ok(
729
- sessionEndFilter[0].includes('df-command-usage'),
730
- 'Uninstall SessionEnd filter should remove df-command-usage hooks'
707
+ uninstallSection[0].includes("file.startsWith('df-')") &&
708
+ uninstallSection[0].includes("file.endsWith('.js')"),
709
+ 'Uninstall should dynamically find df-*.js hook files'
731
710
  );
732
711
  });
733
712
 
734
- test('uninstall PostToolUse filter removes df-command-usage', () => {
713
+ test('uninstall uses removeDeepflowHooks for settings cleanup', () => {
735
714
  const src = fs.readFileSync(path.resolve(__dirname, 'install.js'), 'utf8');
736
715
  const uninstallSection = src.match(/async function uninstall[\s\S]+$/);
737
- const postToolUseFilter = uninstallSection[0].match(/PostToolUse[\s\S]*?\.filter[\s\S]*?\);/);
738
- assert.ok(postToolUseFilter, 'Should have PostToolUse filter in uninstall');
739
716
  assert.ok(
740
- postToolUseFilter[0].includes('df-command-usage'),
741
- 'Uninstall PostToolUse filter should remove df-command-usage hooks'
717
+ uninstallSection[0].includes('removeDeepflowHooks'),
718
+ 'Uninstall should use removeDeepflowHooks for generic cleanup'
742
719
  );
743
720
  });
744
721
 
745
- test('uninstall cleans up PreToolUse hooks', () => {
722
+ test('uninstall removes hooks/lib directory', () => {
746
723
  const src = fs.readFileSync(path.resolve(__dirname, 'install.js'), 'utf8');
747
724
  const uninstallSection = src.match(/async function uninstall[\s\S]+$/);
748
725
  assert.ok(
749
- uninstallSection[0].includes('PreToolUse'),
750
- 'Uninstall function should handle PreToolUse cleanup'
751
- );
752
- // Verify it filters out df-command-usage from PreToolUse
753
- const preToolUseFilter = uninstallSection[0].match(/PreToolUse[\s\S]*?\.filter[\s\S]*?\);/);
754
- assert.ok(preToolUseFilter, 'Should have PreToolUse filter in uninstall');
755
- assert.ok(
756
- preToolUseFilter[0].includes('df-command-usage'),
757
- 'Uninstall PreToolUse filter should remove df-command-usage hooks'
726
+ uninstallSection[0].includes("hooks/lib"),
727
+ 'Uninstall should remove hooks/lib directory'
758
728
  );
759
729
  });
760
-
761
- test('uninstall deletes PreToolUse key when array becomes empty', () => {
762
- // Reproduce the uninstall logic for PreToolUse
763
- const settings = {
764
- hooks: {
765
- PreToolUse: [
766
- { hooks: [{ type: 'command', command: 'node /home/.claude/hooks/df-command-usage.js' }] },
767
- ],
768
- }
769
- };
770
-
771
- settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter(hook => {
772
- const cmd = hook.hooks?.[0]?.command || '';
773
- return !cmd.includes('df-command-usage');
774
- });
775
- if (settings.hooks.PreToolUse.length === 0) {
776
- delete settings.hooks.PreToolUse;
777
- }
778
-
779
- assert.ok(!('PreToolUse' in settings.hooks), 'PreToolUse should be deleted when empty after filtering');
780
- });
781
-
782
- test('uninstall keeps PreToolUse when non-deepflow hooks remain', () => {
783
- const settings = {
784
- hooks: {
785
- PreToolUse: [
786
- { hooks: [{ type: 'command', command: 'node /home/.claude/hooks/df-command-usage.js' }] },
787
- { hooks: [{ type: 'command', command: 'node /usr/local/custom-pre-hook.js' }] },
788
- ],
789
- }
790
- };
791
-
792
- settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter(hook => {
793
- const cmd = hook.hooks?.[0]?.command || '';
794
- return !cmd.includes('df-command-usage');
795
- });
796
- if (settings.hooks.PreToolUse.length === 0) {
797
- delete settings.hooks.PreToolUse;
798
- }
799
-
800
- assert.ok('PreToolUse' in settings.hooks, 'PreToolUse should be kept when custom hooks remain');
801
- assert.equal(settings.hooks.PreToolUse.length, 1);
802
- assert.ok(settings.hooks.PreToolUse[0].hooks[0].command.includes('custom-pre-hook.js'));
803
- });
804
730
  });
805
731
 
806
732
  // ---------------------------------------------------------------------------
@@ -1114,3 +1040,116 @@ describe('copyDir security hardening (symlink & path traversal)', () => {
1114
1040
  );
1115
1041
  });
1116
1042
  });
1043
+
1044
+ // ---------------------------------------------------------------------------
1045
+ // 7. atomicWriteFileSync — write-to-temp + rename pattern
1046
+ // ---------------------------------------------------------------------------
1047
+
1048
+ describe('atomicWriteFileSync', () => {
1049
+ const { atomicWriteFileSync } = require('./install.js');
1050
+ let tmpDir;
1051
+
1052
+ beforeEach(() => {
1053
+ tmpDir = makeTmpDir();
1054
+ });
1055
+
1056
+ afterEach(() => {
1057
+ rmrf(tmpDir);
1058
+ });
1059
+
1060
+ test('writes data to target file', () => {
1061
+ const target = path.join(tmpDir, 'settings.json');
1062
+ atomicWriteFileSync(target, '{"key":"value"}');
1063
+ assert.equal(fs.readFileSync(target, 'utf8'), '{"key":"value"}');
1064
+ });
1065
+
1066
+ test('leaves no .tmp artifact on success', () => {
1067
+ const target = path.join(tmpDir, 'settings.json');
1068
+ atomicWriteFileSync(target, 'data');
1069
+ assert.ok(!fs.existsSync(target + '.tmp'), 'No .tmp file should remain after successful write');
1070
+ });
1071
+
1072
+ test('overwrites existing target with new content', () => {
1073
+ const target = path.join(tmpDir, 'settings.json');
1074
+ fs.writeFileSync(target, 'original');
1075
+ atomicWriteFileSync(target, 'updated');
1076
+ assert.equal(fs.readFileSync(target, 'utf8'), 'updated');
1077
+ });
1078
+
1079
+ test('leaves original untouched when write to temp fails', () => {
1080
+ const target = path.join(tmpDir, 'settings.json');
1081
+ fs.writeFileSync(target, 'safe-original');
1082
+
1083
+ // Force writeFileSync to fail by passing a directory path as the tmpPath target
1084
+ // We do this by making the .tmp path a directory so writeFileSync throws EISDIR
1085
+ const tmpPath = target + '.tmp';
1086
+ fs.mkdirSync(tmpPath);
1087
+
1088
+ let threw = false;
1089
+ try {
1090
+ atomicWriteFileSync(target, 'should-not-overwrite');
1091
+ } catch (_) {
1092
+ threw = true;
1093
+ }
1094
+
1095
+ assert.ok(threw, 'atomicWriteFileSync should rethrow write errors');
1096
+ assert.equal(
1097
+ fs.readFileSync(target, 'utf8'),
1098
+ 'safe-original',
1099
+ 'Original file must be untouched when temp write fails'
1100
+ );
1101
+ });
1102
+
1103
+ test('cleans up .tmp artifact when write fails', () => {
1104
+ const target = path.join(tmpDir, 'settings.json');
1105
+ const tmpPath = target + '.tmp';
1106
+
1107
+ // Intercept: write succeeds but rename fails
1108
+ // We simulate this by making the target's parent dir read-only after the temp write
1109
+ // Instead, test cleanup via the EISDIR approach (tmpPath is a dir — can't write into it)
1110
+ // After EISDIR on writeFileSync(tmpPath), unlinkSync should clean it up.
1111
+ // Since tmpPath was created as a dir in this test, unlinkSync would fail silently,
1112
+ // but the dir itself was pre-existing. Let's use a simpler approach:
1113
+ // patch by making target a directory, which causes renameSync to fail after temp write.
1114
+
1115
+ // Create a target that is a directory so renameSync(tmp, target) fails
1116
+ fs.mkdirSync(target);
1117
+ fs.writeFileSync(path.join(target, 'dummy'), 'x'); // non-empty so unlinkSync fails cleanly
1118
+
1119
+ let threw = false;
1120
+ try {
1121
+ atomicWriteFileSync(target, 'data');
1122
+ } catch (_) {
1123
+ threw = true;
1124
+ }
1125
+
1126
+ assert.ok(threw, 'Should throw when rename fails');
1127
+ // .tmp should be cleaned up
1128
+ assert.ok(!fs.existsSync(tmpPath), '.tmp file should be cleaned up after rename failure');
1129
+ });
1130
+
1131
+ test('source uses atomicWriteFileSync for all 4 settings writes', () => {
1132
+ const src = fs.readFileSync(path.resolve(__dirname, 'install.js'), 'utf8');
1133
+ // Count occurrences of atomicWriteFileSync calls (excluding the definition)
1134
+ const calls = src.match(/atomicWriteFileSync\(/g) || [];
1135
+ // 1 definition + 4 call sites = 5 total occurrences minimum
1136
+ assert.ok(
1137
+ calls.length >= 5,
1138
+ `Expected at least 5 occurrences of atomicWriteFileSync (1 def + 4 calls), found ${calls.length}`
1139
+ );
1140
+ });
1141
+
1142
+ test('source exports atomicWriteFileSync for testing', () => {
1143
+ const src = fs.readFileSync(path.resolve(__dirname, 'install.js'), 'utf8');
1144
+ assert.ok(
1145
+ src.includes('atomicWriteFileSync') && src.includes('module.exports'),
1146
+ 'install.js should export atomicWriteFileSync'
1147
+ );
1148
+ const exportLine = src.match(/module\.exports\s*=\s*\{([^}]+)\}/);
1149
+ assert.ok(exportLine, 'module.exports should be a plain object');
1150
+ assert.ok(
1151
+ exportLine[1].includes('atomicWriteFileSync'),
1152
+ 'module.exports should include atomicWriteFileSync'
1153
+ );
1154
+ });
1155
+ });
@@ -23,12 +23,14 @@ const path = require('path');
23
23
  // ---------------------------------------------------------------------------
24
24
 
25
25
  function parseArgs(argv) {
26
- const args = { plansDir: null };
26
+ const args = { plansDir: null, specsDir: null };
27
27
  let i = 2;
28
28
  while (i < argv.length) {
29
29
  const arg = argv[i];
30
30
  if (arg === '--plans-dir' && argv[i + 1]) {
31
31
  args.plansDir = argv[++i];
32
+ } else if (arg === '--specs-dir' && argv[i + 1]) {
33
+ args.specsDir = argv[++i];
32
34
  }
33
35
  i++;
34
36
  }
@@ -282,6 +284,22 @@ function main() {
282
284
  process.exit(1);
283
285
  }
284
286
 
287
+ // Stale-filter: when --specs-dir is set, remove mini-plans whose corresponding
288
+ // spec file does not exist in specsDir
289
+ if (args.specsDir) {
290
+ const specsDir = path.resolve(process.cwd(), args.specsDir);
291
+ entries = entries.filter(filename => {
292
+ const specPath = path.join(specsDir, filename);
293
+ if (!fs.existsSync(specPath)) {
294
+ process.stderr.write(
295
+ `plan-consolidator: skipping stale mini-plan ${filename} (no matching spec in ${args.specsDir})\n`
296
+ );
297
+ return false;
298
+ }
299
+ return true;
300
+ });
301
+ }
302
+
285
303
  if (entries.length === 0) {
286
304
  process.stdout.write('## Tasks\n\n(no mini-plan files found in ' + plansDir + ')\n');
287
305
  process.exit(0);