deepflow 0.1.105 → 0.1.106
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/hooks/df-command-usage.js +17 -26
- package/hooks/df-execution-history.js +43 -53
- package/hooks/df-explore-protocol.js +30 -39
- package/hooks/df-invariant-check.js +85 -58
- package/hooks/df-invariant-check.test.js +175 -1
- package/hooks/df-snapshot-guard.js +32 -40
- package/hooks/df-statusline.js +3 -12
- package/hooks/df-stdin-migration.test.js +106 -0
- package/hooks/df-subagent-registry.js +42 -48
- package/hooks/df-tool-usage-spike.js +15 -26
- package/hooks/df-tool-usage.js +37 -47
- package/hooks/df-worktree-guard.js +28 -36
- package/hooks/lib/hook-stdin.js +47 -0
- package/hooks/lib/hook-stdin.test.js +200 -0
- package/hooks/lib/lint-no-bare-stdin.js +68 -0
- package/hooks/lib/lint-no-bare-stdin.test.js +82 -0
- package/package.json +1 -1
- package/src/commands/df/execute.md +25 -174
- package/src/eval/git-memory.js +8 -8
- package/src/eval/git-memory.test.js +128 -1
- package/src/eval/loop.js +3 -3
- package/src/eval/loop.test.js +158 -0
|
@@ -15,7 +15,7 @@ const assert = require('node:assert/strict');
|
|
|
15
15
|
const fs = require('node:fs');
|
|
16
16
|
const path = require('node:path');
|
|
17
17
|
|
|
18
|
-
const { isBinaryAvailable } = require('./df-invariant-check');
|
|
18
|
+
const { isBinaryAvailable, checkConfigYamlGuard } = require('./df-invariant-check');
|
|
19
19
|
|
|
20
20
|
const HOOK_SOURCE = fs.readFileSync(
|
|
21
21
|
path.resolve(__dirname, 'df-invariant-check.js'),
|
|
@@ -139,3 +139,177 @@ describe('extractDiffFromLastCommit implementation', () => {
|
|
|
139
139
|
);
|
|
140
140
|
});
|
|
141
141
|
});
|
|
142
|
+
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
// 4. checkConfigYamlGuard — config.yaml/yml modification detection
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
describe('checkConfigYamlGuard', () => {
|
|
148
|
+
// Helper: build a minimal parsed-file object matching the shape used by check functions
|
|
149
|
+
function makeFiles(...paths) {
|
|
150
|
+
return paths.map((p) => ({ file: p, chunks: [] }));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
test('detects .deepflow/config.yaml modification as HARD violation', () => {
|
|
154
|
+
const files = makeFiles('.deepflow/config.yaml');
|
|
155
|
+
const violations = checkConfigYamlGuard(files, '', 'implementation');
|
|
156
|
+
|
|
157
|
+
assert.equal(violations.length, 1);
|
|
158
|
+
assert.equal(violations[0].tag, 'CONFIG_GUARD');
|
|
159
|
+
assert.equal(violations[0].file, '.deepflow/config.yaml');
|
|
160
|
+
assert.equal(violations[0].line, 1);
|
|
161
|
+
assert.ok(violations[0].description.includes('[CONFIG_GUARD]'));
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('detects .deepflow/config.yml variant as HARD violation', () => {
|
|
165
|
+
const files = makeFiles('.deepflow/config.yml');
|
|
166
|
+
const violations = checkConfigYamlGuard(files, '', 'implementation');
|
|
167
|
+
|
|
168
|
+
assert.equal(violations.length, 1);
|
|
169
|
+
assert.equal(violations[0].tag, 'CONFIG_GUARD');
|
|
170
|
+
assert.equal(violations[0].file, '.deepflow/config.yml');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('detects config.yaml inside a worktree sub-path', () => {
|
|
174
|
+
const worktreePath = '.claude/worktrees/agent-abc123/.deepflow/config.yaml';
|
|
175
|
+
const files = makeFiles(worktreePath);
|
|
176
|
+
const violations = checkConfigYamlGuard(files, '', 'implementation');
|
|
177
|
+
|
|
178
|
+
assert.equal(violations.length, 1);
|
|
179
|
+
assert.equal(violations[0].tag, 'CONFIG_GUARD');
|
|
180
|
+
assert.equal(violations[0].file, worktreePath);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('detects config.yml inside a deeply nested worktree path', () => {
|
|
184
|
+
const deepPath = 'some/deep/path/.deepflow/config.yml';
|
|
185
|
+
const files = makeFiles(deepPath);
|
|
186
|
+
const violations = checkConfigYamlGuard(files, '', 'implementation');
|
|
187
|
+
|
|
188
|
+
assert.equal(violations.length, 1);
|
|
189
|
+
assert.equal(violations[0].tag, 'CONFIG_GUARD');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('returns no violations for non-config files', () => {
|
|
193
|
+
const files = makeFiles(
|
|
194
|
+
'src/index.js',
|
|
195
|
+
'hooks/df-invariant-check.js',
|
|
196
|
+
'package.json',
|
|
197
|
+
'.deepflow/decisions.md'
|
|
198
|
+
);
|
|
199
|
+
const violations = checkConfigYamlGuard(files, '', 'implementation');
|
|
200
|
+
|
|
201
|
+
assert.equal(violations.length, 0);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('ignores files that partially match but are not config.yaml/yml', () => {
|
|
205
|
+
const files = makeFiles(
|
|
206
|
+
'.deepflow/config.yaml.bak',
|
|
207
|
+
'.deepflow/config.yamls',
|
|
208
|
+
'.deepflow/my-config.yaml',
|
|
209
|
+
'config.yaml' // not under .deepflow/
|
|
210
|
+
);
|
|
211
|
+
const violations = checkConfigYamlGuard(files, '', 'implementation');
|
|
212
|
+
|
|
213
|
+
assert.equal(violations.length, 0);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test('returns one violation per matching file when multiple configs present', () => {
|
|
217
|
+
const files = makeFiles(
|
|
218
|
+
'.deepflow/config.yaml',
|
|
219
|
+
'.claude/worktrees/agent-xyz/.deepflow/config.yml'
|
|
220
|
+
);
|
|
221
|
+
const violations = checkConfigYamlGuard(files, '', 'implementation');
|
|
222
|
+
|
|
223
|
+
assert.equal(violations.length, 2);
|
|
224
|
+
assert.equal(violations[0].tag, 'CONFIG_GUARD');
|
|
225
|
+
assert.equal(violations[1].tag, 'CONFIG_GUARD');
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test('returns empty array when files list is empty', () => {
|
|
229
|
+
const violations = checkConfigYamlGuard([], '', 'implementation');
|
|
230
|
+
assert.equal(violations.length, 0);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test('works regardless of specContent and taskType arguments', () => {
|
|
234
|
+
const files = makeFiles('.deepflow/config.yaml');
|
|
235
|
+
|
|
236
|
+
// Different specContent and taskType should not affect the result
|
|
237
|
+
const v1 = checkConfigYamlGuard(files, 'some spec content', 'spike');
|
|
238
|
+
const v2 = checkConfigYamlGuard(files, '', 'bootstrap');
|
|
239
|
+
|
|
240
|
+
assert.equal(v1.length, 1);
|
|
241
|
+
assert.equal(v2.length, 1);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test('violation description mentions the offending file path', () => {
|
|
245
|
+
const targetPath = '.claude/worktrees/agent-foo/.deepflow/config.yaml';
|
|
246
|
+
const files = makeFiles(targetPath);
|
|
247
|
+
const violations = checkConfigYamlGuard(files, '', 'implementation');
|
|
248
|
+
|
|
249
|
+
assert.ok(
|
|
250
|
+
violations[0].description.includes(targetPath),
|
|
251
|
+
'description should include the exact file path'
|
|
252
|
+
);
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
// 5. T3 — stdin hang fix: readStdinIfMain guard and no raw stdin listeners
|
|
258
|
+
// ---------------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
describe('stdin hang fix (T3)', () => {
|
|
261
|
+
test('source does not contain process.stdin.on calls', () => {
|
|
262
|
+
// The old code used process.stdin.on('data') directly, which caused hangs
|
|
263
|
+
// when the file was required by tests. readStdinIfMain replaces this.
|
|
264
|
+
const lines = HOOK_SOURCE.split('\n');
|
|
265
|
+
const offendingLines = lines.filter((line) => {
|
|
266
|
+
if (line.trimStart().startsWith('//') || line.trimStart().startsWith('*')) return false;
|
|
267
|
+
return /process\.stdin\.on\b/.test(line);
|
|
268
|
+
});
|
|
269
|
+
assert.equal(
|
|
270
|
+
offendingLines.length,
|
|
271
|
+
0,
|
|
272
|
+
`Found process.stdin.on in source: ${offendingLines.map((l) => l.trim()).join('; ')}`
|
|
273
|
+
);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test('source calls readStdinIfMain', () => {
|
|
277
|
+
// readStdinIfMain(module, callback) should be the entry point for stdin reading
|
|
278
|
+
assert.ok(
|
|
279
|
+
/readStdinIfMain\s*\(\s*module\b/.test(HOOK_SOURCE),
|
|
280
|
+
'source should call readStdinIfMain(module, ...) to guard stdin reading'
|
|
281
|
+
);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test('source imports readStdinIfMain from hook-stdin', () => {
|
|
285
|
+
assert.ok(
|
|
286
|
+
/require\(['"]\.\/lib\/hook-stdin['"]\)/.test(HOOK_SOURCE),
|
|
287
|
+
'source should require ./lib/hook-stdin'
|
|
288
|
+
);
|
|
289
|
+
assert.ok(
|
|
290
|
+
/readStdinIfMain/.test(HOOK_SOURCE),
|
|
291
|
+
'readStdinIfMain should be destructured from the import'
|
|
292
|
+
);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test('--invariants CLI entry point is preserved', () => {
|
|
296
|
+
// The CLI path must still exist: require.main === module && --invariants
|
|
297
|
+
assert.ok(
|
|
298
|
+
/require\.main\s*===\s*module/.test(HOOK_SOURCE),
|
|
299
|
+
'source should have require.main === module guard for CLI mode'
|
|
300
|
+
);
|
|
301
|
+
assert.ok(
|
|
302
|
+
/--invariants/.test(HOOK_SOURCE),
|
|
303
|
+
'source should reference --invariants flag'
|
|
304
|
+
);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test('requiring the module does not hang (exits immediately)', () => {
|
|
308
|
+
// AC-5: node -e "require('./hooks/df-invariant-check.js')" should exit fast.
|
|
309
|
+
// We already required it at the top of this file without hanging,
|
|
310
|
+
// so reaching this test at all proves require() does not block.
|
|
311
|
+
// Additionally, verify the exported API is still accessible.
|
|
312
|
+
assert.equal(typeof isBinaryAvailable, 'function');
|
|
313
|
+
assert.equal(typeof checkConfigYamlGuard, 'function');
|
|
314
|
+
});
|
|
315
|
+
});
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
const fs = require('fs');
|
|
21
21
|
const path = require('path');
|
|
22
|
+
const { readStdinIfMain } = require('./lib/hook-stdin');
|
|
22
23
|
|
|
23
24
|
function loadSnapshotPaths(cwd) {
|
|
24
25
|
try {
|
|
@@ -55,52 +56,43 @@ function isSnapshotFile(filePath, snapshotPaths, cwd) {
|
|
|
55
56
|
return false;
|
|
56
57
|
}
|
|
57
58
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
process.stdin.on('data', chunk => { raw += chunk; });
|
|
61
|
-
process.stdin.on('end', () => {
|
|
62
|
-
try {
|
|
63
|
-
const data = JSON.parse(raw);
|
|
64
|
-
const toolName = data.tool_name || '';
|
|
65
|
-
|
|
66
|
-
// Only guard Write and Edit
|
|
67
|
-
if (toolName !== 'Write' && toolName !== 'Edit') {
|
|
68
|
-
process.exit(0);
|
|
69
|
-
}
|
|
59
|
+
readStdinIfMain(module, (data) => {
|
|
60
|
+
const toolName = data.tool_name || '';
|
|
70
61
|
|
|
71
|
-
|
|
72
|
-
|
|
62
|
+
// Only guard Write and Edit
|
|
63
|
+
if (toolName !== 'Write' && toolName !== 'Edit') {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
73
66
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
}
|
|
67
|
+
const filePath = (data.tool_input && data.tool_input.file_path) || '';
|
|
68
|
+
const cwd = data.cwd || process.cwd();
|
|
77
69
|
|
|
78
|
-
|
|
70
|
+
if (!filePath) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
79
73
|
|
|
80
|
-
|
|
81
|
-
if (snapshotPaths === null) {
|
|
82
|
-
process.exit(0);
|
|
83
|
-
}
|
|
74
|
+
const snapshotPaths = loadSnapshotPaths(cwd);
|
|
84
75
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
76
|
+
// No snapshot file present — not a deepflow project or ratchet not initialized
|
|
77
|
+
if (snapshotPaths === null) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
89
80
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
81
|
+
// Empty snapshot — nothing to protect
|
|
82
|
+
if (snapshotPaths.length === 0) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
93
85
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
`[df-snapshot-guard] Blocked ${toolName} to "${filePath}" — this file is listed in ` +
|
|
97
|
-
`.deepflow/auto-snapshot.txt (ratchet baseline). ` +
|
|
98
|
-
`Pre-existing test files must not be modified by agents. ` +
|
|
99
|
-
`If you need to update this file, do so manually outside the autonomous loop.`
|
|
100
|
-
);
|
|
101
|
-
process.exit(1);
|
|
102
|
-
} catch (_e) {
|
|
103
|
-
// Parse or unexpected error — fail open so we never break non-deepflow projects
|
|
104
|
-
process.exit(0);
|
|
86
|
+
if (!isSnapshotFile(filePath, snapshotPaths, cwd)) {
|
|
87
|
+
return;
|
|
105
88
|
}
|
|
89
|
+
|
|
90
|
+
// File is in the snapshot — block the write
|
|
91
|
+
console.error(
|
|
92
|
+
`[df-snapshot-guard] Blocked ${toolName} to "${filePath}" — this file is listed in ` +
|
|
93
|
+
`.deepflow/auto-snapshot.txt (ratchet baseline). ` +
|
|
94
|
+
`Pre-existing test files must not be modified by agents. ` +
|
|
95
|
+
`If you need to update this file, do so manually outside the autonomous loop.`
|
|
96
|
+
);
|
|
97
|
+
process.exit(1);
|
|
106
98
|
});
|
package/hooks/df-statusline.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
const fs = require('fs');
|
|
9
9
|
const path = require('path');
|
|
10
10
|
const os = require('os');
|
|
11
|
+
const { readStdinIfMain } = require('./lib/hook-stdin');
|
|
11
12
|
|
|
12
13
|
// ANSI colors
|
|
13
14
|
const colors = {
|
|
@@ -21,18 +22,8 @@ const colors = {
|
|
|
21
22
|
cyan: '\x1b[36m'
|
|
22
23
|
};
|
|
23
24
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
process.stdin.setEncoding('utf8');
|
|
27
|
-
process.stdin.on('data', chunk => input += chunk);
|
|
28
|
-
process.stdin.on('end', () => {
|
|
29
|
-
try {
|
|
30
|
-
const data = JSON.parse(input);
|
|
31
|
-
console.log(buildStatusLine(data));
|
|
32
|
-
} catch (e) {
|
|
33
|
-
// Fail silently to avoid breaking statusline
|
|
34
|
-
console.log('');
|
|
35
|
-
}
|
|
25
|
+
readStdinIfMain(module, (data) => {
|
|
26
|
+
console.log(buildStatusLine(data));
|
|
36
27
|
});
|
|
37
28
|
|
|
38
29
|
function buildStatusLine(data) {
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for stdin migration (test-hang-fix T2)
|
|
3
|
+
*
|
|
4
|
+
* Verifies that all 9 migrated hooks use readStdinIfMain instead of inline
|
|
5
|
+
* process.stdin.on listeners. The core behavioral guarantee: require()'ing
|
|
6
|
+
* any hook no longer hangs the process waiting for stdin.
|
|
7
|
+
*
|
|
8
|
+
* Detailed behavioral tests for individual hooks live in their own test files.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
const { test, describe, after } = require('node:test');
|
|
14
|
+
const assert = require('node:assert/strict');
|
|
15
|
+
const fs = require('node:fs');
|
|
16
|
+
const path = require('node:path');
|
|
17
|
+
const { execFileSync } = require('node:child_process');
|
|
18
|
+
|
|
19
|
+
// Prevent the test runner from hanging on stdin after all tests complete.
|
|
20
|
+
// The execFileSync calls inherit pipe handles that can keep the event loop alive.
|
|
21
|
+
after(() => { process.stdin.destroy(); });
|
|
22
|
+
|
|
23
|
+
const HOOKS_DIR = path.resolve(__dirname);
|
|
24
|
+
|
|
25
|
+
/** All hooks that were migrated to readStdinIfMain */
|
|
26
|
+
const MIGRATED_HOOKS = [
|
|
27
|
+
'df-command-usage.js',
|
|
28
|
+
'df-execution-history.js',
|
|
29
|
+
'df-explore-protocol.js',
|
|
30
|
+
'df-snapshot-guard.js',
|
|
31
|
+
'df-statusline.js',
|
|
32
|
+
'df-subagent-registry.js',
|
|
33
|
+
'df-tool-usage.js',
|
|
34
|
+
'df-tool-usage-spike.js',
|
|
35
|
+
'df-worktree-guard.js',
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
describe('stdin migration — no-hang on require()', () => {
|
|
39
|
+
for (const hookFile of MIGRATED_HOOKS) {
|
|
40
|
+
const hookPath = path.join(HOOKS_DIR, hookFile);
|
|
41
|
+
|
|
42
|
+
test(`require("${hookFile}") completes without hanging`, () => {
|
|
43
|
+
// Spawn a child that require()s the hook and exits.
|
|
44
|
+
// If stdin listeners are still inline, this will hang until timeout.
|
|
45
|
+
const script = `require(${JSON.stringify(hookPath)}); process.exit(0);`;
|
|
46
|
+
execFileSync(
|
|
47
|
+
process.execPath,
|
|
48
|
+
['-e', script],
|
|
49
|
+
{
|
|
50
|
+
encoding: 'utf8',
|
|
51
|
+
timeout: 3000, // 3s is generous — require should be <100ms
|
|
52
|
+
}
|
|
53
|
+
);
|
|
54
|
+
// If we reach here, require() didn't hang — that's the assertion.
|
|
55
|
+
assert.ok(true, `${hookFile} require() completed without timeout`);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('stdin migration — no inline process.stdin.on', () => {
|
|
61
|
+
for (const hookFile of MIGRATED_HOOKS) {
|
|
62
|
+
const hookPath = path.join(HOOKS_DIR, hookFile);
|
|
63
|
+
|
|
64
|
+
test(`${hookFile} has no direct process.stdin.on calls`, () => {
|
|
65
|
+
const content = fs.readFileSync(hookPath, 'utf8');
|
|
66
|
+
const matches = content.match(/process\.stdin\.on\s*\(/g);
|
|
67
|
+
assert.equal(
|
|
68
|
+
matches,
|
|
69
|
+
null,
|
|
70
|
+
`${hookFile} still contains process.stdin.on — migration incomplete`
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe('stdin migration — uses readStdinIfMain', () => {
|
|
77
|
+
for (const hookFile of MIGRATED_HOOKS) {
|
|
78
|
+
const hookPath = path.join(HOOKS_DIR, hookFile);
|
|
79
|
+
|
|
80
|
+
test(`${hookFile} imports readStdinIfMain`, () => {
|
|
81
|
+
const content = fs.readFileSync(hookPath, 'utf8');
|
|
82
|
+
assert.match(
|
|
83
|
+
content,
|
|
84
|
+
/readStdinIfMain/,
|
|
85
|
+
`${hookFile} does not reference readStdinIfMain`
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('stdin migration — hook-stdin.js helper', () => {
|
|
92
|
+
test('hook-stdin.js exports readStdinIfMain function', () => {
|
|
93
|
+
const lib = require(path.join(HOOKS_DIR, 'lib', 'hook-stdin.js'));
|
|
94
|
+
assert.equal(typeof lib.readStdinIfMain, 'function');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('readStdinIfMain is a no-op when caller is not main module', () => {
|
|
98
|
+
const lib = require(path.join(HOOKS_DIR, 'lib', 'hook-stdin.js'));
|
|
99
|
+
// When we require() hook-stdin from a test, module !== require.main
|
|
100
|
+
// so calling readStdinIfMain with our own module should be a no-op.
|
|
101
|
+
let callbackInvoked = false;
|
|
102
|
+
lib.readStdinIfMain(module, () => { callbackInvoked = true; });
|
|
103
|
+
// Give a tick for any async listener to fire (there shouldn't be one).
|
|
104
|
+
assert.equal(callbackInvoked, false, 'callback should not be invoked when not main module');
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -4,59 +4,53 @@
|
|
|
4
4
|
const fs = require('fs');
|
|
5
5
|
const path = require('path');
|
|
6
6
|
const os = require('os');
|
|
7
|
+
const { readStdinIfMain } = require('./lib/hook-stdin');
|
|
7
8
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
process.stdin.on('end', () => {
|
|
11
|
-
try {
|
|
12
|
-
const event = JSON.parse(raw);
|
|
13
|
-
const { session_id, agent_type, agent_id, agent_transcript_path } = event;
|
|
9
|
+
readStdinIfMain(module, (event) => {
|
|
10
|
+
const { session_id, agent_type, agent_id, agent_transcript_path } = event;
|
|
14
11
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
12
|
+
// Parse subagent transcript to extract real model and token usage
|
|
13
|
+
let model = 'unknown';
|
|
14
|
+
let tokens_in = 0, tokens_out = 0, cache_read = 0, cache_creation = 0;
|
|
18
15
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
}
|
|
16
|
+
if (agent_transcript_path && fs.existsSync(agent_transcript_path)) {
|
|
17
|
+
const lines = fs.readFileSync(agent_transcript_path, 'utf-8').split('\n');
|
|
18
|
+
for (const line of lines) {
|
|
19
|
+
const trimmed = line.trim();
|
|
20
|
+
if (!trimmed) continue;
|
|
21
|
+
try {
|
|
22
|
+
const evt = JSON.parse(trimmed);
|
|
23
|
+
const msg = evt.message || {};
|
|
24
|
+
const usage = msg.usage || evt.usage;
|
|
25
|
+
// Extract model from assistant messages
|
|
26
|
+
const m = msg.model || evt.model;
|
|
27
|
+
if (m && m !== 'unknown') model = m;
|
|
28
|
+
// Accumulate tokens
|
|
29
|
+
if (usage) {
|
|
30
|
+
tokens_in += usage.input_tokens || 0;
|
|
31
|
+
tokens_out += usage.output_tokens || 0;
|
|
32
|
+
cache_read += usage.cache_read_input_tokens || usage.cache_read_tokens || 0;
|
|
33
|
+
cache_creation += usage.cache_creation_input_tokens || usage.cache_creation_tokens || 0;
|
|
34
|
+
}
|
|
35
|
+
} catch { /* skip malformed lines */ }
|
|
40
36
|
}
|
|
37
|
+
}
|
|
41
38
|
|
|
42
|
-
|
|
43
|
-
|
|
39
|
+
// Strip version suffix from model (e.g. claude-haiku-4-5-20251001 → claude-haiku-4-5)
|
|
40
|
+
model = model.replace(/-\d{8}$/, '').replace(/\[\d+[km]\]$/i, '');
|
|
44
41
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
42
|
+
const entry = {
|
|
43
|
+
session_id,
|
|
44
|
+
agent_type,
|
|
45
|
+
agent_id,
|
|
46
|
+
model,
|
|
47
|
+
tokens_in,
|
|
48
|
+
tokens_out,
|
|
49
|
+
cache_read,
|
|
50
|
+
cache_creation,
|
|
51
|
+
timestamp: new Date().toISOString()
|
|
52
|
+
};
|
|
56
53
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
} catch {
|
|
60
|
-
process.exit(0);
|
|
61
|
-
}
|
|
54
|
+
const registryPath = path.join(os.homedir(), '.claude', 'subagent-sessions.jsonl');
|
|
55
|
+
fs.appendFileSync(registryPath, JSON.stringify(entry) + '\n');
|
|
62
56
|
});
|
|
@@ -11,31 +11,20 @@
|
|
|
11
11
|
'use strict';
|
|
12
12
|
|
|
13
13
|
const fs = require('fs');
|
|
14
|
+
const { readStdinIfMain } = require('./lib/hook-stdin');
|
|
14
15
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
session_id: data.session_id,
|
|
30
|
-
cwd: data.cwd,
|
|
31
|
-
permission_mode: data.permission_mode,
|
|
32
|
-
tool_input_keys: data.tool_input ? Object.keys(data.tool_input) : [],
|
|
33
|
-
tool_response_keys: data.tool_response ? Object.keys(data.tool_response) : [],
|
|
34
|
-
transcript_path: data.transcript_path,
|
|
35
|
-
};
|
|
36
|
-
fs.appendFileSync('/tmp/df-posttooluse-summary.jsonl', JSON.stringify(summary) + '\n');
|
|
37
|
-
} catch (_e) {
|
|
38
|
-
// Fail silently — never break tool execution
|
|
39
|
-
}
|
|
40
|
-
process.exit(0);
|
|
16
|
+
readStdinIfMain(module, (data) => {
|
|
17
|
+
// Also append a minimal summary line for quick review
|
|
18
|
+
const summary = {
|
|
19
|
+
hook_event_name: data.hook_event_name,
|
|
20
|
+
tool_name: data.tool_name,
|
|
21
|
+
tool_use_id: data.tool_use_id,
|
|
22
|
+
session_id: data.session_id,
|
|
23
|
+
cwd: data.cwd,
|
|
24
|
+
permission_mode: data.permission_mode,
|
|
25
|
+
tool_input_keys: data.tool_input ? Object.keys(data.tool_input) : [],
|
|
26
|
+
tool_response_keys: data.tool_response ? Object.keys(data.tool_response) : [],
|
|
27
|
+
transcript_path: data.transcript_path,
|
|
28
|
+
};
|
|
29
|
+
fs.appendFileSync('/tmp/df-posttooluse-summary.jsonl', JSON.stringify(summary) + '\n');
|
|
41
30
|
});
|
package/hooks/df-tool-usage.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
const fs = require('fs');
|
|
16
16
|
const path = require('path');
|
|
17
17
|
const os = require('os');
|
|
18
|
+
const { readStdinIfMain } = require('./lib/hook-stdin');
|
|
18
19
|
|
|
19
20
|
const TOOL_USAGE_LOG = path.join(os.homedir(), '.claude', 'tool-usage.jsonl');
|
|
20
21
|
|
|
@@ -49,56 +50,45 @@ function extractTaskId(cwd) {
|
|
|
49
50
|
return taskMatch ? taskMatch[1].toUpperCase() : null;
|
|
50
51
|
}
|
|
51
52
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
process.stdin.on('end', () => {
|
|
57
|
-
try {
|
|
58
|
-
const data = JSON.parse(raw);
|
|
59
|
-
|
|
60
|
-
const toolName = data.tool_name || null;
|
|
61
|
-
const toolResponse = data.tool_response;
|
|
62
|
-
const cwd = data.cwd || '';
|
|
63
|
-
|
|
64
|
-
let activeCommand = null;
|
|
65
|
-
try {
|
|
66
|
-
const markerPath = path.join(cwd || process.cwd(), '.deepflow', 'active-command.json');
|
|
67
|
-
const markerRaw = fs.readFileSync(markerPath, 'utf8');
|
|
68
|
-
activeCommand = JSON.parse(markerRaw).command || null;
|
|
69
|
-
} catch (_e) { /* no marker or unreadable — null */ }
|
|
53
|
+
readStdinIfMain(module, (data) => {
|
|
54
|
+
const toolName = data.tool_name || null;
|
|
55
|
+
const toolResponse = data.tool_response;
|
|
56
|
+
const cwd = data.cwd || '';
|
|
70
57
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
else if (toolName === 'Grep') inputSummary = ti.pattern || null;
|
|
78
|
-
else if (toolName === 'Glob') inputSummary = ti.pattern || null;
|
|
79
|
-
else if (toolName === 'Agent') inputSummary = `${ti.subagent_type || '?'}/${ti.model || '?'}`;
|
|
80
|
-
else if (toolName === 'Edit' || toolName === 'Write') inputSummary = (ti.file_path || '').split('/').pop();
|
|
58
|
+
let activeCommand = null;
|
|
59
|
+
try {
|
|
60
|
+
const markerPath = path.join(cwd || process.cwd(), '.deepflow', 'active-command.json');
|
|
61
|
+
const markerRaw = fs.readFileSync(markerPath, 'utf8');
|
|
62
|
+
activeCommand = JSON.parse(markerRaw).command || null;
|
|
63
|
+
} catch (_e) { /* no marker or unreadable — null */ }
|
|
81
64
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
};
|
|
65
|
+
// Extract a compact tool_input summary per tool type
|
|
66
|
+
const ti = data.tool_input || {};
|
|
67
|
+
let inputSummary = null;
|
|
68
|
+
if (toolName === 'Bash') inputSummary = ti.command || null;
|
|
69
|
+
else if (toolName === 'LSP') inputSummary = `${ti.operation || '?'}:${(ti.filePath || '').split('/').pop()}:${ti.line || '?'}`;
|
|
70
|
+
else if (toolName === 'Read') inputSummary = (ti.file_path || '').split('/').pop() + (ti.offset ? `:${ti.offset}-${ti.offset + (ti.limit || 0)}` : '');
|
|
71
|
+
else if (toolName === 'Grep') inputSummary = ti.pattern || null;
|
|
72
|
+
else if (toolName === 'Glob') inputSummary = ti.pattern || null;
|
|
73
|
+
else if (toolName === 'Agent') inputSummary = `${ti.subagent_type || '?'}/${ti.model || '?'}`;
|
|
74
|
+
else if (toolName === 'Edit' || toolName === 'Write') inputSummary = (ti.file_path || '').split('/').pop();
|
|
93
75
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
76
|
+
const record = {
|
|
77
|
+
timestamp: new Date().toISOString(),
|
|
78
|
+
session_id: data.session_id || null,
|
|
79
|
+
tool_name: toolName,
|
|
80
|
+
input: inputSummary,
|
|
81
|
+
output_size_est_tokens: Math.ceil(JSON.stringify(toolResponse).length / 4),
|
|
82
|
+
project: cwd ? path.basename(cwd) : null,
|
|
83
|
+
phase: inferPhase(cwd),
|
|
84
|
+
task_id: extractTaskId(cwd),
|
|
85
|
+
active_command: activeCommand,
|
|
86
|
+
};
|
|
98
87
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
88
|
+
const logDir = path.dirname(TOOL_USAGE_LOG);
|
|
89
|
+
if (!fs.existsSync(logDir)) {
|
|
90
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
102
91
|
}
|
|
103
|
-
|
|
92
|
+
|
|
93
|
+
fs.appendFileSync(TOOL_USAGE_LOG, JSON.stringify(record) + '\n');
|
|
104
94
|
});
|