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 +50 -17
- package/bin/install.test.js +206 -167
- package/bin/plan-consolidator.js +19 -1
- package/bin/plan-consolidator.test.js +150 -0
- package/bin/ratchet.js +5 -5
- package/bin/ratchet.test.js +172 -0
- package/bin/worktree-deps.js +127 -0
- package/hooks/df-spec-lint.js +13 -2
- package/hooks/df-spec-lint.test.js +133 -0
- package/package.json +1 -1
- package/src/commands/df/execute.md +53 -2
- package/src/commands/df/plan.md +244 -16
- package/src/commands/df/verify.md +45 -7
- package/templates/explore-protocol.md.bak +69 -0
- package/templates/plan-template.md +11 -0
- package/templates/spec-template.md +15 -0
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
path.join(
|
|
163
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
711
|
-
|
|
712
|
-
|
|
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
|
+
}
|
package/bin/install.test.js
CHANGED
|
@@ -590,217 +590,143 @@ describe('Uninstaller — file removal and settings cleanup', () => {
|
|
|
590
590
|
});
|
|
591
591
|
|
|
592
592
|
// ---------------------------------------------------------------------------
|
|
593
|
-
// T4. command-usage hook registration
|
|
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
|
-
// --
|
|
598
|
+
// -- @hook-event tag: verify df-command-usage.js declares correct events --
|
|
599
599
|
|
|
600
|
-
test('
|
|
601
|
-
const
|
|
602
|
-
const
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
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
|
-
|
|
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('
|
|
621
|
-
const
|
|
622
|
-
|
|
623
|
-
const
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
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
|
-
|
|
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
|
-
|
|
634
|
-
|
|
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
|
|
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(
|
|
646
|
-
'
|
|
638
|
+
src.includes('if (!settings.hooks[event]) settings.hooks[event] = [];'),
|
|
639
|
+
'Should initialize event array dynamically'
|
|
647
640
|
);
|
|
648
641
|
});
|
|
649
642
|
|
|
650
|
-
// --
|
|
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
|
-
|
|
664
|
-
|
|
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('
|
|
668
|
-
const
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
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('
|
|
690
|
-
const
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
730
|
-
'
|
|
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
|
|
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
|
-
|
|
741
|
-
'Uninstall
|
|
717
|
+
uninstallSection[0].includes('removeDeepflowHooks'),
|
|
718
|
+
'Uninstall should use removeDeepflowHooks for generic cleanup'
|
|
742
719
|
);
|
|
743
720
|
});
|
|
744
721
|
|
|
745
|
-
test('uninstall
|
|
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(
|
|
750
|
-
'Uninstall
|
|
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
|
+
});
|
package/bin/plan-consolidator.js
CHANGED
|
@@ -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);
|