deepflow 0.1.103 → 0.1.105

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 (62) hide show
  1. package/bin/install-dynamic-hooks.test.js +461 -0
  2. package/bin/install.js +171 -250
  3. package/bin/install.test.js +205 -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 +18 -0
  7. package/hooks/df-dashboard-push.js +5 -4
  8. package/hooks/df-dashboard-push.test.js +256 -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 +4 -3
  14. package/hooks/df-invariant-check.test.js +141 -0
  15. package/hooks/df-quota-logger.js +12 -23
  16. package/hooks/df-quota-logger.test.js +324 -0
  17. package/hooks/df-snapshot-guard.js +1 -0
  18. package/hooks/df-spec-lint.js +58 -1
  19. package/hooks/df-spec-lint.test.js +412 -0
  20. package/hooks/df-statusline.js +1 -0
  21. package/hooks/df-subagent-registry.js +1 -0
  22. package/hooks/df-tool-usage.js +13 -3
  23. package/hooks/df-worktree-guard.js +1 -0
  24. package/package.json +1 -1
  25. package/src/commands/df/debate.md +1 -1
  26. package/src/commands/df/eval.md +117 -0
  27. package/src/commands/df/execute.md +1 -1
  28. package/src/commands/df/fix.md +104 -0
  29. package/src/eval/git-memory.js +159 -0
  30. package/src/eval/git-memory.test.js +439 -0
  31. package/src/eval/hypothesis.js +80 -0
  32. package/src/eval/hypothesis.test.js +169 -0
  33. package/src/eval/loop.js +378 -0
  34. package/src/eval/loop.test.js +306 -0
  35. package/src/eval/metric-collector.js +163 -0
  36. package/src/eval/metric-collector.test.js +369 -0
  37. package/src/eval/metric-pivot.js +119 -0
  38. package/src/eval/metric-pivot.test.js +350 -0
  39. package/src/eval/mutator-prompt.js +106 -0
  40. package/src/eval/mutator-prompt.test.js +180 -0
  41. package/templates/config-template.yaml +5 -6
  42. package/templates/eval-fixture-template/config.yaml +39 -0
  43. package/templates/eval-fixture-template/fixture/.deepflow/decisions.md +5 -0
  44. package/templates/eval-fixture-template/fixture/hooks/invariant.js +28 -0
  45. package/templates/eval-fixture-template/fixture/package.json +12 -0
  46. package/templates/eval-fixture-template/fixture/specs/doing-example-task.md +18 -0
  47. package/templates/eval-fixture-template/fixture/src/commands/df/example.md +18 -0
  48. package/templates/eval-fixture-template/fixture/src/config.js +40 -0
  49. package/templates/eval-fixture-template/fixture/src/index.js +19 -0
  50. package/templates/eval-fixture-template/fixture/src/pipeline.js +40 -0
  51. package/templates/eval-fixture-template/fixture/src/skills/example-skill/SKILL.md +32 -0
  52. package/templates/eval-fixture-template/fixture/src/spec-loader.js +35 -0
  53. package/templates/eval-fixture-template/fixture/src/task-runner.js +32 -0
  54. package/templates/eval-fixture-template/fixture/src/verifier.js +37 -0
  55. package/templates/eval-fixture-template/hypotheses.md +14 -0
  56. package/templates/eval-fixture-template/spec.md +34 -0
  57. package/templates/eval-fixture-template/tests/behavior.test.js +69 -0
  58. package/templates/eval-fixture-template/tests/guard.test.js +108 -0
  59. package/templates/eval-fixture-template.test.js +318 -0
  60. package/templates/explore-agent.md +5 -74
  61. package/templates/explore-protocol.md +44 -0
  62. package/templates/spec-template.md +4 -0
@@ -909,3 +909,208 @@ describe('copyDir logic', () => {
909
909
  assert.ok(fs.existsSync(path.join(newDest, 'a.md')));
910
910
  });
911
911
  });
912
+
913
+ // ---------------------------------------------------------------------------
914
+ // 6. copyDir security hardening — symlink rejection & path traversal guard
915
+ // ---------------------------------------------------------------------------
916
+
917
+ describe('copyDir security hardening (symlink & path traversal)', () => {
918
+ let tmpSrc;
919
+ let tmpDest;
920
+
921
+ /**
922
+ * Reproduces the hardened copyDir from install.js (commit f3b7f47).
923
+ * Includes isSymbolicLink() check and path traversal guard.
924
+ */
925
+ function copyDir(src, dest) {
926
+ if (!fs.existsSync(src)) return;
927
+
928
+ const resolvedSrcRoot = path.resolve(src);
929
+ const resolvedDestRoot = path.resolve(dest);
930
+
931
+ fs.mkdirSync(dest, { recursive: true });
932
+
933
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
934
+ const srcPath = path.join(src, entry.name);
935
+ const destPath = path.join(dest, entry.name);
936
+
937
+ // Reject symlinks to prevent symlink attacks
938
+ if (entry.isSymbolicLink()) {
939
+ process.stderr.write(`[deepflow] skipping symlink: ${srcPath}\n`);
940
+ continue;
941
+ }
942
+
943
+ // Guard against path traversal — resolved paths must stay under their roots
944
+ const resolvedSrc = path.resolve(srcPath);
945
+ const resolvedDest = path.resolve(destPath);
946
+ if (!resolvedSrc.startsWith(resolvedSrcRoot + path.sep) && resolvedSrc !== resolvedSrcRoot) {
947
+ process.stderr.write(`[deepflow] skipping path traversal attempt (src): ${srcPath}\n`);
948
+ continue;
949
+ }
950
+ if (!resolvedDest.startsWith(resolvedDestRoot + path.sep) && resolvedDest !== resolvedDestRoot) {
951
+ process.stderr.write(`[deepflow] skipping path traversal attempt (dest): ${destPath}\n`);
952
+ continue;
953
+ }
954
+
955
+ if (entry.isDirectory()) {
956
+ copyDir(srcPath, destPath);
957
+ } else {
958
+ fs.copyFileSync(srcPath, destPath);
959
+ }
960
+ }
961
+ }
962
+
963
+ beforeEach(() => {
964
+ tmpSrc = makeTmpDir();
965
+ tmpDest = makeTmpDir();
966
+ });
967
+
968
+ afterEach(() => {
969
+ rmrf(tmpSrc);
970
+ rmrf(tmpDest);
971
+ });
972
+
973
+ // -- Symlink rejection --
974
+
975
+ test('skips symlinked files and does not copy them to dest', () => {
976
+ // Create a real file outside the src tree
977
+ const outsideFile = path.join(os.tmpdir(), `df-symlink-target-${Date.now()}.txt`);
978
+ fs.writeFileSync(outsideFile, 'secret content');
979
+
980
+ // Create a symlink inside the src dir pointing to the outside file
981
+ fs.symlinkSync(outsideFile, path.join(tmpSrc, 'link-to-secret.txt'));
982
+
983
+ // Also create a normal file to ensure it IS copied
984
+ fs.writeFileSync(path.join(tmpSrc, 'normal.md'), '# normal');
985
+
986
+ copyDir(tmpSrc, tmpDest);
987
+
988
+ // The symlink should NOT have been copied
989
+ assert.ok(
990
+ !fs.existsSync(path.join(tmpDest, 'link-to-secret.txt')),
991
+ 'Symlinked file should be skipped and not appear in dest'
992
+ );
993
+ // The normal file should still be copied
994
+ assert.ok(
995
+ fs.existsSync(path.join(tmpDest, 'normal.md')),
996
+ 'Normal files should still be copied alongside skipped symlinks'
997
+ );
998
+
999
+ // Cleanup
1000
+ fs.unlinkSync(outsideFile);
1001
+ });
1002
+
1003
+ test('skips symlinked directories and does not copy them to dest', () => {
1004
+ // Create a real directory outside src
1005
+ const outsideDir = makeTmpDir();
1006
+ fs.writeFileSync(path.join(outsideDir, 'inside.txt'), 'hidden');
1007
+
1008
+ // Create a directory symlink inside src
1009
+ fs.symlinkSync(outsideDir, path.join(tmpSrc, 'link-to-dir'));
1010
+
1011
+ // Normal subdir to verify it copies
1012
+ fs.mkdirSync(path.join(tmpSrc, 'real-sub'));
1013
+ fs.writeFileSync(path.join(tmpSrc, 'real-sub', 'file.md'), '# real');
1014
+
1015
+ copyDir(tmpSrc, tmpDest);
1016
+
1017
+ assert.ok(
1018
+ !fs.existsSync(path.join(tmpDest, 'link-to-dir')),
1019
+ 'Symlinked directory should be skipped'
1020
+ );
1021
+ assert.ok(
1022
+ fs.existsSync(path.join(tmpDest, 'real-sub', 'file.md')),
1023
+ 'Real subdirectories should still be copied'
1024
+ );
1025
+
1026
+ rmrf(outsideDir);
1027
+ });
1028
+
1029
+ test('skips relative symlinks within the source tree', () => {
1030
+ // Create a real file and a relative symlink to it
1031
+ fs.writeFileSync(path.join(tmpSrc, 'real.md'), '# real');
1032
+ fs.symlinkSync('real.md', path.join(tmpSrc, 'relative-link.md'));
1033
+
1034
+ copyDir(tmpSrc, tmpDest);
1035
+
1036
+ assert.ok(
1037
+ fs.existsSync(path.join(tmpDest, 'real.md')),
1038
+ 'Real file should be copied'
1039
+ );
1040
+ assert.ok(
1041
+ !fs.existsSync(path.join(tmpDest, 'relative-link.md')),
1042
+ 'Even relative symlinks should be skipped'
1043
+ );
1044
+ });
1045
+
1046
+ // -- Normal files continue to copy correctly --
1047
+
1048
+ test('copies normal files correctly when no symlinks or traversal present', () => {
1049
+ fs.writeFileSync(path.join(tmpSrc, 'a.md'), '# a');
1050
+ fs.writeFileSync(path.join(tmpSrc, 'b.txt'), 'content b');
1051
+ fs.mkdirSync(path.join(tmpSrc, 'sub'));
1052
+ fs.writeFileSync(path.join(tmpSrc, 'sub', 'c.md'), '# c');
1053
+
1054
+ copyDir(tmpSrc, tmpDest);
1055
+
1056
+ assert.equal(fs.readFileSync(path.join(tmpDest, 'a.md'), 'utf8'), '# a');
1057
+ assert.equal(fs.readFileSync(path.join(tmpDest, 'b.txt'), 'utf8'), 'content b');
1058
+ assert.equal(fs.readFileSync(path.join(tmpDest, 'sub', 'c.md'), 'utf8'), '# c');
1059
+ });
1060
+
1061
+ test('copies deeply nested directories correctly', () => {
1062
+ fs.mkdirSync(path.join(tmpSrc, 'l1', 'l2', 'l3'), { recursive: true });
1063
+ fs.writeFileSync(path.join(tmpSrc, 'l1', 'l2', 'l3', 'deep.md'), '# deep');
1064
+
1065
+ copyDir(tmpSrc, tmpDest);
1066
+
1067
+ assert.ok(fs.existsSync(path.join(tmpDest, 'l1', 'l2', 'l3', 'deep.md')));
1068
+ assert.equal(
1069
+ fs.readFileSync(path.join(tmpDest, 'l1', 'l2', 'l3', 'deep.md'), 'utf8'),
1070
+ '# deep'
1071
+ );
1072
+ });
1073
+
1074
+ // -- Source-level verification that the guards exist in install.js --
1075
+
1076
+ test('install.js copyDir checks isSymbolicLink()', () => {
1077
+ const src = fs.readFileSync(path.resolve(__dirname, 'install.js'), 'utf8');
1078
+ assert.ok(
1079
+ src.includes('entry.isSymbolicLink()'),
1080
+ 'copyDir should check entry.isSymbolicLink() to reject symlinks'
1081
+ );
1082
+ });
1083
+
1084
+ test('install.js copyDir has path traversal guard using startsWith', () => {
1085
+ const src = fs.readFileSync(path.resolve(__dirname, 'install.js'), 'utf8');
1086
+ // The guard resolves src/dest and checks they stay under their root
1087
+ assert.ok(
1088
+ src.includes('resolvedSrc.startsWith(resolvedSrcRoot + path.sep)'),
1089
+ 'copyDir should guard source paths against traversal using startsWith'
1090
+ );
1091
+ assert.ok(
1092
+ src.includes('resolvedDest.startsWith(resolvedDestRoot + path.sep)'),
1093
+ 'copyDir should guard dest paths against traversal using startsWith'
1094
+ );
1095
+ });
1096
+
1097
+ test('install.js copyDir logs stderr warning for skipped symlinks', () => {
1098
+ const src = fs.readFileSync(path.resolve(__dirname, 'install.js'), 'utf8');
1099
+ assert.ok(
1100
+ src.includes('[deepflow] skipping symlink:'),
1101
+ 'copyDir should log a warning to stderr when skipping a symlink'
1102
+ );
1103
+ });
1104
+
1105
+ test('install.js copyDir logs stderr warning for path traversal attempts', () => {
1106
+ const src = fs.readFileSync(path.resolve(__dirname, 'install.js'), 'utf8');
1107
+ assert.ok(
1108
+ src.includes('[deepflow] skipping path traversal attempt (src):'),
1109
+ 'copyDir should log a warning for source path traversal'
1110
+ );
1111
+ assert.ok(
1112
+ src.includes('[deepflow] skipping path traversal attempt (dest):'),
1113
+ 'copyDir should log a warning for dest path traversal'
1114
+ );
1115
+ });
1116
+ });
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Scans specs/*.md for derives-from frontmatter and writes .deepflow/lineage.jsonl
4
+ *
5
+ * Each line: { "child": "fix-auth", "parent": "done-auth", "child_status": "doing", "scanned_at": "ISO" }
6
+ *
7
+ * Usage: node bin/lineage-ingest.js [--specs-dir specs/] [--out .deepflow/lineage.jsonl]
8
+ */
9
+
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const { parseFrontmatter } = require('../hooks/df-spec-lint.js');
13
+
14
+ function main() {
15
+ const args = process.argv.slice(2);
16
+ const specsDir = getArg(args, '--specs-dir') || 'specs';
17
+ const outFile = getArg(args, '--out') || path.join('.deepflow', 'lineage.jsonl');
18
+
19
+ if (!fs.existsSync(specsDir)) {
20
+ process.stderr.write(`lineage-ingest: specs dir not found: ${specsDir}\n`);
21
+ process.exit(1);
22
+ }
23
+
24
+ const files = fs.readdirSync(specsDir).filter(f => f.endsWith('.md'));
25
+ const entries = [];
26
+ const now = new Date().toISOString();
27
+
28
+ for (const file of files) {
29
+ const content = fs.readFileSync(path.join(specsDir, file), 'utf8');
30
+ const { frontmatter } = parseFrontmatter(content);
31
+ const derivesFrom = frontmatter['derives-from'];
32
+
33
+ if (!derivesFrom) continue;
34
+
35
+ const specName = file.replace(/\.md$/, '');
36
+ let childStatus = 'planned';
37
+ if (specName.startsWith('doing-')) childStatus = 'doing';
38
+ else if (specName.startsWith('done-')) childStatus = 'done';
39
+
40
+ entries.push({
41
+ child: specName,
42
+ parent: derivesFrom,
43
+ child_status: childStatus,
44
+ scanned_at: now
45
+ });
46
+ }
47
+
48
+ // Ensure output dir exists
49
+ const outDir = path.dirname(outFile);
50
+ if (outDir && !fs.existsSync(outDir)) {
51
+ fs.mkdirSync(outDir, { recursive: true });
52
+ }
53
+
54
+ // Overwrite — full scan each time, no append
55
+ const lines = entries.map(e => JSON.stringify(e)).join('\n');
56
+ fs.writeFileSync(outFile, lines ? lines + '\n' : '');
57
+
58
+ process.stdout.write(`lineage-ingest: ${entries.length} lineage entries written to ${outFile}\n`);
59
+ }
60
+
61
+ function getArg(args, flag) {
62
+ const idx = args.indexOf(flag);
63
+ return idx >= 0 && idx + 1 < args.length ? args[idx + 1] : null;
64
+ }
65
+
66
+ module.exports = { main };
67
+
68
+ if (require.main === module) {
69
+ main();
70
+ }
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ // @hook-event: SessionStart
2
3
  /**
3
4
  * deepflow update checker
4
5
  * Runs in background, checks npm for newer versions
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ // @hook-event: PreToolUse, PostToolUse, SessionEnd
2
3
  /**
3
4
  * deepflow command usage tracker
4
5
  * Tracks df:* command invocations with token deltas and tool call counts.
@@ -6,6 +7,7 @@
6
7
  * Events:
7
8
  * PreToolUse — detect Skill calls matching df:*, close previous command, open new marker
8
9
  * PostToolUse — increment tool_calls_count on the active marker
10
+ * SessionStart — close orphaned marker on /clear or /compact (context reset)
9
11
  * SessionEnd — close any open marker so the last command gets a record
10
12
  *
11
13
  * Marker: .deepflow/active-command.json
@@ -45,6 +47,8 @@ function main() {
45
47
  handlePreToolUse(deepflowDir, markerPath, usagePath, tokenHistoryPath);
46
48
  } else if (event === 'PostToolUse') {
47
49
  handlePostToolUse(markerPath);
50
+ } else if (event === 'SessionStart') {
51
+ handleSessionStart(markerPath, usagePath, tokenHistoryPath);
48
52
  } else if (event === 'SessionEnd') {
49
53
  handleSessionEnd(deepflowDir, markerPath, usagePath, tokenHistoryPath);
50
54
  }
@@ -111,6 +115,20 @@ function handlePostToolUse(markerPath) {
111
115
  }
112
116
  }
113
117
 
118
+ /**
119
+ * On /clear or /compact, context resets — close any orphaned marker.
120
+ * Only fires for source=clear|compact (not startup/resume).
121
+ */
122
+ function handleSessionStart(markerPath, usagePath, tokenHistoryPath) {
123
+ if (!safeExists(markerPath)) return;
124
+ let payload;
125
+ try { payload = JSON.parse(raw); } catch { return; }
126
+ const source = payload.source || '';
127
+ if (source === 'clear' || source === 'compact') {
128
+ closeCommand(markerPath, usagePath, tokenHistoryPath);
129
+ }
130
+ }
131
+
114
132
  function handleSessionEnd(deepflowDir, markerPath, usagePath, tokenHistoryPath) {
115
133
  if (!safeExists(markerPath)) return;
116
134
  closeCommand(markerPath, usagePath, tokenHistoryPath);
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ // @hook-event: SessionEnd
2
3
  /**
3
4
  * deepflow dashboard push — SessionEnd hook
4
5
  * Collects session summary (tokens, duration, tool calls, model), gets
@@ -40,9 +41,9 @@ function getStdinSync() {
40
41
  }
41
42
  }
42
43
 
43
- /** Read .deepflow/config.yaml and extract dashboard_url (no yaml dep — regex parse). */
44
- function getDashboardUrl(cwd) {
45
- const configPath = path.join(cwd, '.deepflow', 'config.yaml');
44
+ /** Read ~/.deepflow/config.yaml and extract dashboard_url (no yaml dep — regex parse). */
45
+ function getDashboardUrl() {
46
+ const configPath = path.join(os.homedir(), '.deepflow', 'config.yaml');
46
47
  if (!fs.existsSync(configPath)) return null;
47
48
  try {
48
49
  const content = fs.readFileSync(configPath, 'utf8');
@@ -111,7 +112,7 @@ function postJson(url, payload) {
111
112
  async function main() {
112
113
  try {
113
114
  const cwd = process.env.CLAUDE_PROJECT_DIR || process.cwd();
114
- const dashboardUrl = getDashboardUrl(cwd);
115
+ const dashboardUrl = getDashboardUrl();
115
116
 
116
117
  // Silently skip if not configured
117
118
  if (!dashboardUrl) process.exit(0);
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Tests for hooks/df-dashboard-push.js (security-hardening, T2)
3
+ *
4
+ * Validates that the dashboard push hook reads dashboard_url from
5
+ * ~/.deepflow/config.yaml (home directory) rather than the project
6
+ * directory, and silently skips when not configured.
7
+ *
8
+ * Uses Node.js built-in node:test to avoid adding dependencies.
9
+ */
10
+
11
+ 'use strict';
12
+
13
+ const { test, describe, beforeEach, afterEach } = require('node:test');
14
+ const assert = require('node:assert/strict');
15
+ const fs = require('node:fs');
16
+ const path = require('node:path');
17
+ const os = require('os');
18
+ const { execFileSync } = require('node:child_process');
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Helpers
22
+ // ---------------------------------------------------------------------------
23
+
24
+ const HOOK_PATH = path.resolve(__dirname, 'df-dashboard-push.js');
25
+
26
+ function makeTmpDir() {
27
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'df-dashboard-push-test-'));
28
+ }
29
+
30
+ function rmrf(dir) {
31
+ if (fs.existsSync(dir)) {
32
+ fs.rmSync(dir, { recursive: true, force: true });
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Run the dashboard push hook in --background mode as a child process.
38
+ * Overrides HOME so getDashboardUrl reads from our fake home dir.
39
+ * Returns { stdout, stderr, code }.
40
+ */
41
+ function runHook({ home, cwd, hookInput } = {}) {
42
+ const env = { ...process.env };
43
+ if (home) env.HOME = home;
44
+ if (hookInput !== undefined) env._DF_HOOK_INPUT = hookInput;
45
+ // Ensure CLAUDE_PROJECT_DIR points to cwd so main() uses it
46
+ if (cwd) env.CLAUDE_PROJECT_DIR = cwd;
47
+
48
+ try {
49
+ const stdout = execFileSync(
50
+ process.execPath,
51
+ [HOOK_PATH, '--background'],
52
+ {
53
+ cwd: cwd || os.tmpdir(),
54
+ encoding: 'utf8',
55
+ timeout: 10000,
56
+ env,
57
+ stdio: ['pipe', 'pipe', 'pipe'],
58
+ }
59
+ );
60
+ return { stdout, stderr: '', code: 0 };
61
+ } catch (err) {
62
+ return {
63
+ stdout: err.stdout || '',
64
+ stderr: err.stderr || '',
65
+ code: err.status ?? 1,
66
+ };
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Write a config.yaml into {dir}/.deepflow/config.yaml with given content.
72
+ */
73
+ function writeConfig(dir, content) {
74
+ const configDir = path.join(dir, '.deepflow');
75
+ fs.mkdirSync(configDir, { recursive: true });
76
+ fs.writeFileSync(path.join(configDir, 'config.yaml'), content, 'utf8');
77
+ }
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // Tests
81
+ // ---------------------------------------------------------------------------
82
+
83
+ describe('df-dashboard-push hook', () => {
84
+ let fakeHome;
85
+ let fakeCwd;
86
+
87
+ beforeEach(() => {
88
+ fakeHome = makeTmpDir();
89
+ fakeCwd = makeTmpDir();
90
+ });
91
+
92
+ afterEach(() => {
93
+ rmrf(fakeHome);
94
+ rmrf(fakeCwd);
95
+ });
96
+
97
+ // -------------------------------------------------------------------------
98
+ // Config resolution: reads from HOME, not CWD
99
+ // -------------------------------------------------------------------------
100
+
101
+ describe('config resolution', () => {
102
+ test('exits 0 when no config file exists at HOME', () => {
103
+ const result = runHook({ home: fakeHome, cwd: fakeCwd });
104
+ assert.equal(result.code, 0);
105
+ });
106
+
107
+ test('exits 0 when config exists at HOME but has no dashboard_url key', () => {
108
+ writeConfig(fakeHome, 'build_command: "npm run build"\ntest_command: "npm test"\n');
109
+ const result = runHook({ home: fakeHome, cwd: fakeCwd });
110
+ assert.equal(result.code, 0);
111
+ });
112
+
113
+ test('exits 0 when dashboard_url is empty string', () => {
114
+ writeConfig(fakeHome, 'dashboard_url: ""\n');
115
+ const result = runHook({ home: fakeHome, cwd: fakeCwd });
116
+ assert.equal(result.code, 0);
117
+ });
118
+
119
+ test('exits 0 when dashboard_url is bare empty (no quotes)', () => {
120
+ writeConfig(fakeHome, 'dashboard_url: \n');
121
+ const result = runHook({ home: fakeHome, cwd: fakeCwd });
122
+ assert.equal(result.code, 0);
123
+ });
124
+
125
+ test('ignores dashboard_url in project cwd config (reads from HOME only)', () => {
126
+ // Put a valid URL in the project config, but NOT in home config
127
+ writeConfig(fakeCwd, 'dashboard_url: "http://localhost:9999"\n');
128
+ // Home has no config at all
129
+ const result = runHook({ home: fakeHome, cwd: fakeCwd });
130
+ // Should exit 0 without attempting POST because HOME has no config
131
+ assert.equal(result.code, 0);
132
+ });
133
+
134
+ test('reads dashboard_url from HOME config even when project has none', () => {
135
+ // Home has a dashboard_url pointing to an unreachable port
136
+ writeConfig(fakeHome, 'dashboard_url: "http://127.0.0.1:19999"\n');
137
+ // Project has no config
138
+ // Hook should attempt POST, fail silently, exit 0
139
+ const result = runHook({ home: fakeHome, cwd: fakeCwd, hookInput: '{}' });
140
+ assert.equal(result.code, 0);
141
+ });
142
+ });
143
+
144
+ // -------------------------------------------------------------------------
145
+ // YAML parsing edge cases
146
+ // -------------------------------------------------------------------------
147
+
148
+ describe('dashboard_url parsing', () => {
149
+ test('extracts unquoted URL', () => {
150
+ writeConfig(fakeHome, 'dashboard_url: http://127.0.0.1:19999\n');
151
+ const result = runHook({ home: fakeHome, cwd: fakeCwd, hookInput: '{}' });
152
+ // Attempts POST, fails silently on unreachable port, exits 0
153
+ assert.equal(result.code, 0);
154
+ });
155
+
156
+ test('extracts single-quoted URL', () => {
157
+ writeConfig(fakeHome, "dashboard_url: 'http://127.0.0.1:19999'\n");
158
+ const result = runHook({ home: fakeHome, cwd: fakeCwd, hookInput: '{}' });
159
+ assert.equal(result.code, 0);
160
+ });
161
+
162
+ test('extracts double-quoted URL', () => {
163
+ writeConfig(fakeHome, 'dashboard_url: "http://127.0.0.1:19999"\n');
164
+ const result = runHook({ home: fakeHome, cwd: fakeCwd, hookInput: '{}' });
165
+ assert.equal(result.code, 0);
166
+ });
167
+
168
+ test('handles dashboard_url with leading spaces (indented)', () => {
169
+ writeConfig(fakeHome, ' dashboard_url: http://127.0.0.1:19999\n');
170
+ const result = runHook({ home: fakeHome, cwd: fakeCwd, hookInput: '{}' });
171
+ assert.equal(result.code, 0);
172
+ });
173
+
174
+ test('ignores commented-out dashboard_url', () => {
175
+ writeConfig(fakeHome, '# dashboard_url: http://127.0.0.1:19999\n');
176
+ const result = runHook({ home: fakeHome, cwd: fakeCwd });
177
+ // The regex uses ^ so a comment line should not match
178
+ assert.equal(result.code, 0);
179
+ });
180
+ });
181
+
182
+ // -------------------------------------------------------------------------
183
+ // Error handling / resilience
184
+ // -------------------------------------------------------------------------
185
+
186
+ describe('error handling', () => {
187
+ test('exits 0 when config file is unreadable (permissions)', () => {
188
+ writeConfig(fakeHome, 'dashboard_url: http://localhost:9999\n');
189
+ const configPath = path.join(fakeHome, '.deepflow', 'config.yaml');
190
+ fs.chmodSync(configPath, 0o000);
191
+ const result = runHook({ home: fakeHome, cwd: fakeCwd });
192
+ assert.equal(result.code, 0);
193
+ // Restore permissions for cleanup
194
+ fs.chmodSync(configPath, 0o644);
195
+ });
196
+
197
+ test('exits 0 when hook input is invalid JSON', () => {
198
+ writeConfig(fakeHome, 'dashboard_url: http://127.0.0.1:19999\n');
199
+ const result = runHook({
200
+ home: fakeHome,
201
+ cwd: fakeCwd,
202
+ hookInput: '{not valid json',
203
+ });
204
+ assert.equal(result.code, 0);
205
+ });
206
+
207
+ test('exits 0 when hook input is empty', () => {
208
+ writeConfig(fakeHome, 'dashboard_url: http://127.0.0.1:19999\n');
209
+ const result = runHook({
210
+ home: fakeHome,
211
+ cwd: fakeCwd,
212
+ hookInput: '',
213
+ });
214
+ assert.equal(result.code, 0);
215
+ });
216
+
217
+ test('exits 0 when dashboard_url has invalid URL format', () => {
218
+ writeConfig(fakeHome, 'dashboard_url: not-a-url\n');
219
+ const result = runHook({
220
+ home: fakeHome,
221
+ cwd: fakeCwd,
222
+ hookInput: '{}',
223
+ });
224
+ // postJson should handle invalid URL gracefully
225
+ assert.equal(result.code, 0);
226
+ });
227
+ });
228
+
229
+ // -------------------------------------------------------------------------
230
+ // Foreground mode (spawns background and exits immediately)
231
+ // -------------------------------------------------------------------------
232
+
233
+ describe('foreground mode', () => {
234
+ test('exits 0 immediately without --background flag', () => {
235
+ const env = { ...process.env, HOME: fakeHome };
236
+ try {
237
+ const stdout = execFileSync(
238
+ process.execPath,
239
+ [HOOK_PATH],
240
+ {
241
+ cwd: fakeCwd,
242
+ encoding: 'utf8',
243
+ timeout: 5000,
244
+ env,
245
+ stdio: ['pipe', 'pipe', 'pipe'],
246
+ input: '{}',
247
+ }
248
+ );
249
+ assert.equal(typeof stdout, 'string');
250
+ } catch (err) {
251
+ // Even if it errors, check code is 0 (fire-and-forget exit)
252
+ assert.equal(err.status, 0, 'foreground mode should exit 0');
253
+ }
254
+ });
255
+ });
256
+ });
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ // @hook-event: PostToolUse
2
3
  /**
3
4
  * deepflow execution history recorder
4
5
  * PostToolUse hook: fires when the Agent tool completes.