deepflow 0.1.102 → 0.1.104

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/bin/install-dynamic-hooks.test.js +461 -0
  2. package/bin/install.js +150 -204
  3. package/bin/install.test.js +214 -0
  4. package/bin/lineage-ingest.js +70 -0
  5. package/hooks/df-check-update.js +1 -0
  6. package/hooks/df-command-usage.js +305 -0
  7. package/hooks/df-command-usage.test.js +1019 -0
  8. package/hooks/df-dashboard-push.js +1 -0
  9. package/hooks/df-execution-history.js +1 -0
  10. package/hooks/df-explore-protocol.js +83 -0
  11. package/hooks/df-explore-protocol.test.js +228 -0
  12. package/hooks/df-hook-event-tags.test.js +127 -0
  13. package/hooks/df-invariant-check.js +1 -0
  14. package/hooks/df-quota-logger.js +1 -0
  15. package/hooks/df-snapshot-guard.js +1 -0
  16. package/hooks/df-spec-lint.js +58 -1
  17. package/hooks/df-spec-lint.test.js +412 -0
  18. package/hooks/df-statusline.js +1 -0
  19. package/hooks/df-subagent-registry.js +34 -14
  20. package/hooks/df-tool-usage.js +21 -3
  21. package/hooks/df-tool-usage.test.js +200 -0
  22. package/hooks/df-worktree-guard.js +1 -0
  23. package/package.json +1 -1
  24. package/src/commands/df/debate.md +1 -1
  25. package/src/commands/df/eval.md +117 -0
  26. package/src/commands/df/execute.md +1 -1
  27. package/src/commands/df/fix.md +104 -0
  28. package/src/eval/git-memory.js +159 -0
  29. package/src/eval/git-memory.test.js +439 -0
  30. package/src/eval/hypothesis.js +80 -0
  31. package/src/eval/hypothesis.test.js +169 -0
  32. package/src/eval/loop.js +378 -0
  33. package/src/eval/loop.test.js +306 -0
  34. package/src/eval/metric-collector.js +163 -0
  35. package/src/eval/metric-collector.test.js +369 -0
  36. package/src/eval/metric-pivot.js +119 -0
  37. package/src/eval/metric-pivot.test.js +350 -0
  38. package/src/eval/mutator-prompt.js +106 -0
  39. package/src/eval/mutator-prompt.test.js +180 -0
  40. package/templates/config-template.yaml +5 -0
  41. package/templates/eval-fixture-template/config.yaml +39 -0
  42. package/templates/eval-fixture-template/fixture/.deepflow/decisions.md +5 -0
  43. package/templates/eval-fixture-template/fixture/hooks/invariant.js +28 -0
  44. package/templates/eval-fixture-template/fixture/package.json +12 -0
  45. package/templates/eval-fixture-template/fixture/specs/doing-example-task.md +18 -0
  46. package/templates/eval-fixture-template/fixture/src/commands/df/example.md +18 -0
  47. package/templates/eval-fixture-template/fixture/src/config.js +40 -0
  48. package/templates/eval-fixture-template/fixture/src/index.js +19 -0
  49. package/templates/eval-fixture-template/fixture/src/pipeline.js +40 -0
  50. package/templates/eval-fixture-template/fixture/src/skills/example-skill/SKILL.md +32 -0
  51. package/templates/eval-fixture-template/fixture/src/spec-loader.js +35 -0
  52. package/templates/eval-fixture-template/fixture/src/task-runner.js +32 -0
  53. package/templates/eval-fixture-template/fixture/src/verifier.js +37 -0
  54. package/templates/eval-fixture-template/hypotheses.md +14 -0
  55. package/templates/eval-fixture-template/spec.md +34 -0
  56. package/templates/eval-fixture-template/tests/behavior.test.js +69 -0
  57. package/templates/eval-fixture-template/tests/guard.test.js +108 -0
  58. package/templates/eval-fixture-template.test.js +318 -0
  59. package/templates/explore-agent.md +5 -74
  60. package/templates/explore-protocol.md +44 -0
  61. package/templates/spec-template.md +4 -0
package/bin/install.js CHANGED
@@ -134,6 +134,13 @@ async function main() {
134
134
  );
135
135
  log('Agents installed');
136
136
 
137
+ // Copy templates (explore-protocol, explore-agent, etc.)
138
+ copyDir(
139
+ path.join(PACKAGE_DIR, 'templates'),
140
+ path.join(CLAUDE_DIR, 'templates')
141
+ );
142
+ log('Templates installed');
143
+
137
144
  // Copy bin utilities (plan-consolidator, wave-runner, ratchet)
138
145
  const binDest = path.join(CLAUDE_DIR, 'bin');
139
146
  fs.mkdirSync(binDest, { recursive: true });
@@ -198,8 +205,9 @@ async function main() {
198
205
  console.log(' skills/ — gap-discovery, atomic-commits, code-completeness, browse-fetch, browse-verify, auto-cycle');
199
206
  console.log(' agents/ — reasoner (/df:auto — autonomous execution via /loop)');
200
207
  console.log(' bin/ — plan-consolidator, wave-runner, ratchet');
208
+ console.log(' templates/ — explore-protocol (auto-injected into Explore agents via hook)');
201
209
  if (level === 'global') {
202
- console.log(' hooks/ — statusline, update checker, invariant checker, worktree guard');
210
+ console.log(' hooks/ — statusline, update checker, invariant checker, worktree guard, explore protocol');
203
211
  }
204
212
  console.log(' hooks/df-spec-* — spec validation (auto-enforced by /df:spec and /df:plan)');
205
213
  console.log(' env/ — ENABLE_LSP_TOOL (code navigation via goToDefinition, findReferences, workspaceSymbol)');
@@ -244,18 +252,82 @@ function copyDir(src, dest) {
244
252
  }
245
253
  }
246
254
 
255
+ // Valid hook events (settings.hooks keys + special "statusLine")
256
+ const VALID_HOOK_EVENTS = new Set([
257
+ 'SessionStart', 'SessionEnd', 'PreToolUse', 'PostToolUse', 'SubagentStop', 'statusLine'
258
+ ]);
259
+
260
+ /**
261
+ * Scan hook source files for @hook-event tags. Returns:
262
+ * { eventMap: Map<event, [filename, ...]>, untagged: [filename, ...] }
263
+ */
264
+ function scanHookEvents(hooksSourceDir) {
265
+ const eventMap = new Map(); // event → [filenames]
266
+ const untagged = [];
267
+
268
+ if (!fs.existsSync(hooksSourceDir)) return { eventMap, untagged };
269
+
270
+ for (const file of fs.readdirSync(hooksSourceDir)) {
271
+ if (!file.endsWith('.js') || file.endsWith('.test.js')) continue;
272
+
273
+ const content = fs.readFileSync(path.join(hooksSourceDir, file), 'utf8');
274
+ const firstLines = content.split('\n').slice(0, 10).join('\n');
275
+ const match = firstLines.match(/\/\/\s*@hook-event:\s*(.+)/);
276
+
277
+ if (!match) {
278
+ untagged.push(file);
279
+ continue;
280
+ }
281
+
282
+ const events = match[1].split(',').map(e => e.trim()).filter(Boolean);
283
+ let hasValidEvent = false;
284
+
285
+ for (const event of events) {
286
+ if (!VALID_HOOK_EVENTS.has(event)) {
287
+ console.log(` ${c.yellow}!${c.reset} Warning: unknown event "${event}" in ${file} — skipped`);
288
+ continue;
289
+ }
290
+ hasValidEvent = true;
291
+ if (!eventMap.has(event)) eventMap.set(event, []);
292
+ eventMap.get(event).push(file);
293
+ }
294
+
295
+ if (!hasValidEvent) {
296
+ untagged.push(file);
297
+ }
298
+ }
299
+
300
+ return { eventMap, untagged };
301
+ }
302
+
303
+ /**
304
+ * Remove all deepflow hook entries (commands containing /hooks/df-) from settings.
305
+ * Preserves non-deepflow hooks.
306
+ */
307
+ function removeDeepflowHooks(settings) {
308
+ const isDeepflow = (hook) => {
309
+ const cmd = hook.hooks?.[0]?.command || '';
310
+ return cmd.includes('/hooks/df-');
311
+ };
312
+
313
+ // Clean settings.hooks.*
314
+ if (settings.hooks) {
315
+ for (const event of Object.keys(settings.hooks)) {
316
+ settings.hooks[event] = settings.hooks[event].filter(h => !isDeepflow(h));
317
+ if (settings.hooks[event].length === 0) delete settings.hooks[event];
318
+ }
319
+ if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
320
+ }
321
+
322
+ // Clean settings.statusLine if it's a deepflow hook
323
+ if (settings.statusLine?.command && settings.statusLine.command.includes('/hooks/df-')) {
324
+ delete settings.statusLine;
325
+ }
326
+ }
327
+
247
328
  async function configureHooks(claudeDir) {
248
329
  const settingsPath = path.join(claudeDir, 'settings.json');
249
- const statuslineCmd = `node "${path.join(claudeDir, 'hooks', 'df-statusline.js')}"`;
250
- const updateCheckCmd = `node "${path.join(claudeDir, 'hooks', 'df-check-update.js')}"`;
251
- const quotaLoggerCmd = `node "${path.join(claudeDir, 'hooks', 'df-quota-logger.js')}"`;
252
- const toolUsageCmd = `node "${path.join(claudeDir, 'hooks', 'df-tool-usage.js')}"`;
253
- const dashboardPushCmd = `node "${path.join(claudeDir, 'hooks', 'df-dashboard-push.js')}"`;
254
- const executionHistoryCmd = `node "${path.join(claudeDir, 'hooks', 'df-execution-history.js')}"`;
255
- const worktreeGuardCmd = `node "${path.join(claudeDir, 'hooks', 'df-worktree-guard.js')}"`;
256
- const snapshotGuardCmd = `node "${path.join(claudeDir, 'hooks', 'df-snapshot-guard.js')}"`;
257
- const invariantCheckCmd = `node "${path.join(claudeDir, 'hooks', 'df-invariant-check.js')}"`;
258
- const subagentRegistryCmd = `node "${path.join(claudeDir, 'hooks', 'df-subagent-registry.js')}"`;
330
+ const hooksSourceDir = path.join(PACKAGE_DIR, 'hooks');
259
331
 
260
332
  let settings = {};
261
333
 
@@ -276,158 +348,64 @@ async function configureHooks(claudeDir) {
276
348
  configurePermissions(settings);
277
349
  log('Agent permissions configured');
278
350
 
279
- // Configure statusline
280
- if (settings.statusLine) {
281
- if (process.stdin.isTTY) {
282
- const answer = await ask(
283
- ` ${c.yellow}!${c.reset} Existing statusLine found. Replace with deepflow? [y/N] `
284
- );
285
- if (answer.toLowerCase() === 'y') {
286
- settings.statusLine = { type: 'command', command: statuslineCmd };
287
- log('Statusline configured');
288
- } else {
289
- console.log(` ${c.yellow}!${c.reset} Skipped statusline configuration`);
290
- }
291
- } else {
292
- // Non-interactive (e.g. Claude Code bash tool) — skip prompt, keep existing
293
- console.log(` ${c.yellow}!${c.reset} Existing statusLine found — kept (non-interactive mode)`);
294
- }
295
- } else {
296
- settings.statusLine = { type: 'command', command: statuslineCmd };
297
- log('Statusline configured');
298
- }
351
+ // Scan hook files for @hook-event tags
352
+ const { eventMap, untagged } = scanHookEvents(hooksSourceDir);
299
353
 
300
- // Configure SessionStart hook for update checking
301
- if (!settings.hooks) {
302
- settings.hooks = {};
303
- }
304
- if (!settings.hooks.SessionStart) {
305
- settings.hooks.SessionStart = [];
306
- }
354
+ // Remember if there was a pre-existing non-deepflow statusLine
355
+ const hadExternalStatusLine = settings.statusLine &&
356
+ !settings.statusLine.command?.includes('/hooks/df-');
307
357
 
308
- // Remove any existing deepflow update check / quota logger hooks from SessionStart
309
- settings.hooks.SessionStart = settings.hooks.SessionStart.filter(hook => {
310
- const cmd = hook.hooks?.[0]?.command || '';
311
- return !cmd.includes('df-check-update') && !cmd.includes('df-quota-logger');
312
- });
313
-
314
- // Add update check hook
315
- settings.hooks.SessionStart.push({
316
- hooks: [{
317
- type: 'command',
318
- command: updateCheckCmd
319
- }]
320
- });
321
-
322
- // Add quota logger to SessionStart
323
- settings.hooks.SessionStart.push({
324
- hooks: [{
325
- type: 'command',
326
- command: quotaLoggerCmd
327
- }]
328
- });
329
- log('SessionStart hook configured');
358
+ // Remove all existing deepflow hooks (orphan cleanup + idempotency)
359
+ removeDeepflowHooks(settings);
330
360
 
331
- // Configure SessionEnd hook for quota logging
332
- if (!settings.hooks.SessionEnd) {
333
- settings.hooks.SessionEnd = [];
334
- }
361
+ // Wire hooks by event
362
+ if (!settings.hooks) settings.hooks = {};
335
363
 
336
- // Remove any existing quota logger / dashboard push from SessionEnd
337
- settings.hooks.SessionEnd = settings.hooks.SessionEnd.filter(hook => {
338
- const cmd = hook.hooks?.[0]?.command || '';
339
- return !cmd.includes('df-quota-logger') && !cmd.includes('df-dashboard-push');
340
- });
364
+ for (const [event, files] of eventMap) {
365
+ if (event === 'statusLine') {
366
+ // Handle statusLine separately — it's settings.statusLine, not settings.hooks
367
+ const statusFile = files[0]; // Only one statusline hook expected
368
+ const statusCmd = `node "${path.join(claudeDir, 'hooks', statusFile)}"`;
341
369
 
342
- // Add quota logger to SessionEnd
343
- settings.hooks.SessionEnd.push({
344
- hooks: [{
345
- type: 'command',
346
- command: quotaLoggerCmd
347
- }]
348
- });
370
+ if (hadExternalStatusLine) {
371
+ if (process.stdin.isTTY) {
372
+ const answer = await ask(
373
+ ` ${c.yellow}!${c.reset} Existing statusLine found. Replace with deepflow? [y/N] `
374
+ );
375
+ if (answer.toLowerCase() === 'y') {
376
+ settings.statusLine = { type: 'command', command: statusCmd };
377
+ log('Statusline configured');
378
+ } else {
379
+ console.log(` ${c.yellow}!${c.reset} Skipped statusline configuration`);
380
+ }
381
+ } else {
382
+ // Non-interactive (e.g. Claude Code bash tool) — skip prompt, keep existing
383
+ console.log(` ${c.yellow}!${c.reset} Existing statusLine found — kept (non-interactive mode)`);
384
+ }
385
+ } else {
386
+ settings.statusLine = { type: 'command', command: statusCmd };
387
+ log('Statusline configured');
388
+ }
389
+ continue;
390
+ }
349
391
 
350
- // Add dashboard push to SessionEnd (fire-and-forget, skips when dashboard_url unset)
351
- settings.hooks.SessionEnd.push({
352
- hooks: [{
353
- type: 'command',
354
- command: dashboardPushCmd
355
- }]
356
- });
357
- log('Quota logger + dashboard push configured (SessionEnd)');
392
+ // Regular hook events
393
+ if (!settings.hooks[event]) settings.hooks[event] = [];
358
394
 
359
- // Configure PostToolUse hook for tool usage instrumentation
360
- if (!settings.hooks.PostToolUse) {
361
- settings.hooks.PostToolUse = [];
395
+ for (const file of files) {
396
+ const cmd = `node "${path.join(claudeDir, 'hooks', file)}"`;
397
+ settings.hooks[event].push({
398
+ hooks: [{ type: 'command', command: cmd }]
399
+ });
400
+ }
401
+ log(`${event} hook configured`);
362
402
  }
363
403
 
364
- // Remove any existing deepflow tool usage / execution history / worktree guard / snapshot guard / invariant check hooks from PostToolUse
365
- settings.hooks.PostToolUse = settings.hooks.PostToolUse.filter(hook => {
366
- const cmd = hook.hooks?.[0]?.command || '';
367
- return !cmd.includes('df-tool-usage') && !cmd.includes('df-execution-history') && !cmd.includes('df-worktree-guard') && !cmd.includes('df-snapshot-guard') && !cmd.includes('df-invariant-check');
368
- });
369
-
370
- // Add tool usage hook
371
- settings.hooks.PostToolUse.push({
372
- hooks: [{
373
- type: 'command',
374
- command: toolUsageCmd
375
- }]
376
- });
377
-
378
- // Add execution history hook
379
- settings.hooks.PostToolUse.push({
380
- hooks: [{
381
- type: 'command',
382
- command: executionHistoryCmd
383
- }]
384
- });
385
-
386
- // Add worktree guard hook (blocks Write/Edit to main-branch files when df/* worktree exists)
387
- settings.hooks.PostToolUse.push({
388
- hooks: [{
389
- type: 'command',
390
- command: worktreeGuardCmd
391
- }]
392
- });
393
-
394
- // Add snapshot guard hook (blocks Write/Edit to ratchet-baseline files in auto-snapshot.txt)
395
- settings.hooks.PostToolUse.push({
396
- hooks: [{
397
- type: 'command',
398
- command: snapshotGuardCmd
399
- }]
400
- });
401
-
402
- // Add invariant check hook (exits 1 on hard failures after git commit)
403
- settings.hooks.PostToolUse.push({
404
- hooks: [{
405
- type: 'command',
406
- command: invariantCheckCmd
407
- }]
408
- });
409
- log('PostToolUse hook configured');
410
-
411
- // Configure SubagentStop hook for subagent registry
412
- if (!settings.hooks.SubagentStop) {
413
- settings.hooks.SubagentStop = [];
404
+ // Log untagged files (copied but not wired)
405
+ for (const file of untagged) {
406
+ console.log(` ${c.dim}${file} copied (no @hook-event tag — not wired)${c.reset}`);
414
407
  }
415
408
 
416
- // Remove any existing subagent registry hooks
417
- settings.hooks.SubagentStop = settings.hooks.SubagentStop.filter(hook => {
418
- const cmd = hook.hooks?.[0]?.command || '';
419
- return !cmd.includes('df-subagent-registry');
420
- });
421
-
422
- // Add subagent registry hook
423
- settings.hooks.SubagentStop.push({
424
- hooks: [{
425
- type: 'command',
426
- command: subagentRegistryCmd
427
- }]
428
- });
429
- log('SubagentStop hook configured');
430
-
431
409
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
432
410
  }
433
411
 
@@ -607,11 +585,20 @@ async function uninstall() {
607
585
  'agents/reasoner.md',
608
586
  'bin/plan-consolidator.js',
609
587
  'bin/wave-runner.js',
610
- 'bin/ratchet.js'
588
+ 'bin/ratchet.js',
589
+ 'templates'
611
590
  ];
612
591
 
613
592
  if (level === 'global') {
614
- toRemove.push('hooks/df-statusline.js', 'hooks/df-check-update.js', 'hooks/df-invariant-check.js', 'hooks/df-quota-logger.js', 'hooks/df-tool-usage.js', 'hooks/df-dashboard-push.js', 'hooks/df-execution-history.js', 'hooks/df-worktree-guard.js', 'hooks/df-snapshot-guard.js', 'hooks/df-subagent-registry.js');
593
+ // Dynamically find all df-*.js hook files to remove
594
+ const hooksDir = path.join(CLAUDE_DIR, 'hooks');
595
+ if (fs.existsSync(hooksDir)) {
596
+ for (const file of fs.readdirSync(hooksDir)) {
597
+ if (file.startsWith('df-') && file.endsWith('.js')) {
598
+ toRemove.push(`hooks/${file}`);
599
+ }
600
+ }
601
+ }
615
602
  }
616
603
 
617
604
  for (const item of toRemove) {
@@ -631,67 +618,25 @@ async function uninstall() {
631
618
  }
632
619
  }
633
620
 
634
- // Remove SessionStart hook from settings
621
+ // Remove hook entries and settings from global settings.json
635
622
  if (level === 'global') {
636
623
  const settingsPath = path.join(CLAUDE_DIR, 'settings.json');
637
624
  if (fs.existsSync(settingsPath)) {
638
625
  try {
639
626
  const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
640
- if (settings.hooks?.SessionStart) {
641
- settings.hooks.SessionStart = settings.hooks.SessionStart.filter(hook => {
642
- const cmd = hook.hooks?.[0]?.command || '';
643
- return !cmd.includes('df-check-update') && !cmd.includes('df-quota-logger');
644
- });
645
- if (settings.hooks.SessionStart.length === 0) {
646
- delete settings.hooks.SessionStart;
647
- }
648
- }
649
- if (settings.hooks?.SessionEnd) {
650
- settings.hooks.SessionEnd = settings.hooks.SessionEnd.filter(hook => {
651
- const cmd = hook.hooks?.[0]?.command || '';
652
- return !cmd.includes('df-quota-logger') && !cmd.includes('df-dashboard-push');
653
- });
654
- if (settings.hooks.SessionEnd.length === 0) {
655
- delete settings.hooks.SessionEnd;
656
- }
657
- }
658
- if (settings.hooks?.PostToolUse) {
659
- settings.hooks.PostToolUse = settings.hooks.PostToolUse.filter(hook => {
660
- const cmd = hook.hooks?.[0]?.command || '';
661
- return !cmd.includes('df-tool-usage') && !cmd.includes('df-execution-history') && !cmd.includes('df-worktree-guard') && !cmd.includes('df-snapshot-guard') && !cmd.includes('df-invariant-check');
662
- });
663
- if (settings.hooks.PostToolUse.length === 0) {
664
- delete settings.hooks.PostToolUse;
665
- }
666
- }
667
- if (settings.hooks?.SubagentStop) {
668
- settings.hooks.SubagentStop = settings.hooks.SubagentStop.filter(hook => {
669
- const cmd = hook.hooks?.[0]?.command || '';
670
- return !cmd.includes('df-subagent-registry');
671
- });
672
- if (settings.hooks.SubagentStop.length === 0) {
673
- delete settings.hooks.SubagentStop;
674
- }
675
- }
676
- if (settings.hooks && Object.keys(settings.hooks).length === 0) {
677
- delete settings.hooks;
678
- }
679
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
680
- console.log(` ${c.green}✓${c.reset} Removed SessionStart/SessionEnd/PostToolUse/SubagentStop hooks`);
681
- } catch (e) {
682
- // Fail silently
683
- }
684
- }
685
627
 
686
- // Remove ENABLE_LSP_TOOL and deepflow permissions from global settings
687
- if (fs.existsSync(settingsPath)) {
688
- try {
689
- const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
628
+ // Remove all deepflow hook wiring dynamically
629
+ removeDeepflowHooks(settings);
630
+ console.log(` ${c.green}✓${c.reset} Removed deepflow hooks from settings`);
631
+
632
+ // Remove ENABLE_LSP_TOOL
690
633
  if (settings.env?.ENABLE_LSP_TOOL) {
691
634
  delete settings.env.ENABLE_LSP_TOOL;
692
635
  if (settings.env && Object.keys(settings.env).length === 0) delete settings.env;
693
636
  console.log(` ${c.green}✓${c.reset} Removed ENABLE_LSP_TOOL from settings`);
694
637
  }
638
+
639
+ // Remove deepflow permissions
695
640
  if (settings.permissions?.allow) {
696
641
  const dfPerms = new Set(DEEPFLOW_PERMISSIONS);
697
642
  settings.permissions.allow = settings.permissions.allow.filter(p => !dfPerms.has(p));
@@ -699,6 +644,7 @@ async function uninstall() {
699
644
  if (settings.permissions && Object.keys(settings.permissions).length === 0) delete settings.permissions;
700
645
  console.log(` ${c.green}✓${c.reset} Removed deepflow permissions from settings`);
701
646
  }
647
+
702
648
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
703
649
  } catch (e) {
704
650
  // Fail silently
@@ -589,6 +589,220 @@ describe('Uninstaller — file removal and settings cleanup', () => {
589
589
  });
590
590
  });
591
591
 
592
+ // ---------------------------------------------------------------------------
593
+ // T4. command-usage hook registration (PreToolUse, PostToolUse, SessionEnd)
594
+ // ---------------------------------------------------------------------------
595
+
596
+ describe('T4 — command-usage hook registration in install.js', () => {
597
+
598
+ // -- Source-level checks: verify install.js registers df-command-usage.js --
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
+ );
607
+ });
608
+
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
+ });
619
+
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
+ );
629
+ });
630
+
631
+ test('source pushes command-usage hook to SessionEnd', () => {
632
+ 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
+ );
640
+ });
641
+
642
+ test('source creates PreToolUse array if missing', () => {
643
+ const src = fs.readFileSync(path.resolve(__dirname, 'install.js'), 'utf8');
644
+ assert.ok(
645
+ src.includes("if (!settings.hooks.PreToolUse)"),
646
+ 'install.js should initialize PreToolUse array if not present'
647
+ );
648
+ });
649
+
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
+ });
662
+
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');
665
+ });
666
+
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'));
687
+ });
688
+
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'));
706
+ });
707
+
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
+ });
718
+
719
+ test('uninstall SessionEnd filter removes df-command-usage', () => {
720
+ 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
+ const uninstallSection = src.match(/async function uninstall[\s\S]+$/);
724
+ 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
+ assert.ok(
729
+ sessionEndFilter[0].includes('df-command-usage'),
730
+ 'Uninstall SessionEnd filter should remove df-command-usage hooks'
731
+ );
732
+ });
733
+
734
+ test('uninstall PostToolUse filter removes df-command-usage', () => {
735
+ const src = fs.readFileSync(path.resolve(__dirname, 'install.js'), 'utf8');
736
+ 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
+ assert.ok(
740
+ postToolUseFilter[0].includes('df-command-usage'),
741
+ 'Uninstall PostToolUse filter should remove df-command-usage hooks'
742
+ );
743
+ });
744
+
745
+ test('uninstall cleans up PreToolUse hooks', () => {
746
+ const src = fs.readFileSync(path.resolve(__dirname, 'install.js'), 'utf8');
747
+ const uninstallSection = src.match(/async function uninstall[\s\S]+$/);
748
+ 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'
758
+ );
759
+ });
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
+ });
805
+
592
806
  // ---------------------------------------------------------------------------
593
807
  // 4. isInstalled helper logic
594
808
  // ---------------------------------------------------------------------------