deepflow 0.1.87 → 0.1.89
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 +73 -7
- package/hooks/df-dashboard-push.js +170 -0
- package/hooks/df-execution-history.js +120 -0
- package/hooks/df-invariant-check.js +126 -0
- package/hooks/df-spec-lint.js +78 -4
- package/hooks/df-statusline.js +77 -5
- package/hooks/df-tool-usage-spike.js +41 -0
- package/hooks/df-tool-usage.js +86 -0
- package/hooks/df-worktree-guard.js +101 -0
- package/package.json +1 -1
- package/src/commands/df/auto-cycle.md +75 -558
- package/src/commands/df/auto.md +9 -48
- package/src/commands/df/consolidate.md +14 -38
- package/src/commands/df/dashboard.md +35 -0
- package/src/commands/df/debate.md +27 -156
- package/src/commands/df/discover.md +35 -181
- package/src/commands/df/execute.md +283 -563
- package/src/commands/df/note.md +37 -176
- package/src/commands/df/plan.md +80 -210
- package/src/commands/df/report.md +29 -184
- package/src/commands/df/resume.md +18 -101
- package/src/commands/df/spec.md +49 -145
- package/src/commands/df/verify.md +59 -606
- package/src/skills/browse-fetch/SKILL.md +32 -257
- package/src/skills/browse-verify/SKILL.md +40 -174
- package/src/skills/code-completeness/SKILL.md +2 -9
- package/src/skills/gap-discovery/SKILL.md +19 -86
- package/templates/config-template.yaml +10 -0
- package/templates/spec-template.md +12 -1
package/bin/install.js
CHANGED
|
@@ -187,7 +187,7 @@ async function main() {
|
|
|
187
187
|
console.log(' skills/ — gap-discovery, atomic-commits, code-completeness, browse-fetch, browse-verify');
|
|
188
188
|
console.log(' agents/ — reasoner (/df:auto — autonomous execution via /loop)');
|
|
189
189
|
if (level === 'global') {
|
|
190
|
-
console.log(' hooks/ — statusline, update checker, invariant checker');
|
|
190
|
+
console.log(' hooks/ — statusline, update checker, invariant checker, worktree guard');
|
|
191
191
|
}
|
|
192
192
|
console.log(' hooks/df-spec-* — spec validation (auto-enforced by /df:spec and /df:plan)');
|
|
193
193
|
console.log(' env/ — ENABLE_LSP_TOOL (code navigation via goToDefinition, findReferences, workspaceSymbol)');
|
|
@@ -238,6 +238,11 @@ async function configureHooks(claudeDir) {
|
|
|
238
238
|
const updateCheckCmd = `node "${path.join(claudeDir, 'hooks', 'df-check-update.js')}"`;
|
|
239
239
|
const consolidationCheckCmd = `node "${path.join(claudeDir, 'hooks', 'df-consolidation-check.js')}"`;
|
|
240
240
|
const quotaLoggerCmd = `node "${path.join(claudeDir, 'hooks', 'df-quota-logger.js')}"`;
|
|
241
|
+
const toolUsageCmd = `node "${path.join(claudeDir, 'hooks', 'df-tool-usage.js')}"`;
|
|
242
|
+
const dashboardPushCmd = `node "${path.join(claudeDir, 'hooks', 'df-dashboard-push.js')}"`;
|
|
243
|
+
const executionHistoryCmd = `node "${path.join(claudeDir, 'hooks', 'df-execution-history.js')}"`;
|
|
244
|
+
const worktreeGuardCmd = `node "${path.join(claudeDir, 'hooks', 'df-worktree-guard.js')}"`;
|
|
245
|
+
const invariantCheckCmd = `node "${path.join(claudeDir, 'hooks', 'df-invariant-check.js')}"`;
|
|
241
246
|
|
|
242
247
|
let settings = {};
|
|
243
248
|
|
|
@@ -323,10 +328,10 @@ async function configureHooks(claudeDir) {
|
|
|
323
328
|
settings.hooks.SessionEnd = [];
|
|
324
329
|
}
|
|
325
330
|
|
|
326
|
-
// Remove any existing quota logger from SessionEnd
|
|
331
|
+
// Remove any existing quota logger / dashboard push from SessionEnd
|
|
327
332
|
settings.hooks.SessionEnd = settings.hooks.SessionEnd.filter(hook => {
|
|
328
333
|
const cmd = hook.hooks?.[0]?.command || '';
|
|
329
|
-
return !cmd.includes('df-quota-logger');
|
|
334
|
+
return !cmd.includes('df-quota-logger') && !cmd.includes('df-dashboard-push');
|
|
330
335
|
});
|
|
331
336
|
|
|
332
337
|
// Add quota logger to SessionEnd
|
|
@@ -336,7 +341,59 @@ async function configureHooks(claudeDir) {
|
|
|
336
341
|
command: quotaLoggerCmd
|
|
337
342
|
}]
|
|
338
343
|
});
|
|
339
|
-
|
|
344
|
+
|
|
345
|
+
// Add dashboard push to SessionEnd (fire-and-forget, skips when dashboard_url unset)
|
|
346
|
+
settings.hooks.SessionEnd.push({
|
|
347
|
+
hooks: [{
|
|
348
|
+
type: 'command',
|
|
349
|
+
command: dashboardPushCmd
|
|
350
|
+
}]
|
|
351
|
+
});
|
|
352
|
+
log('Quota logger + dashboard push configured (SessionEnd)');
|
|
353
|
+
|
|
354
|
+
// Configure PostToolUse hook for tool usage instrumentation
|
|
355
|
+
if (!settings.hooks.PostToolUse) {
|
|
356
|
+
settings.hooks.PostToolUse = [];
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Remove any existing deepflow tool usage / execution history / worktree guard / invariant check hooks from PostToolUse
|
|
360
|
+
settings.hooks.PostToolUse = settings.hooks.PostToolUse.filter(hook => {
|
|
361
|
+
const cmd = hook.hooks?.[0]?.command || '';
|
|
362
|
+
return !cmd.includes('df-tool-usage') && !cmd.includes('df-execution-history') && !cmd.includes('df-worktree-guard') && !cmd.includes('df-invariant-check');
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
// Add tool usage hook
|
|
366
|
+
settings.hooks.PostToolUse.push({
|
|
367
|
+
hooks: [{
|
|
368
|
+
type: 'command',
|
|
369
|
+
command: toolUsageCmd
|
|
370
|
+
}]
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
// Add execution history hook
|
|
374
|
+
settings.hooks.PostToolUse.push({
|
|
375
|
+
hooks: [{
|
|
376
|
+
type: 'command',
|
|
377
|
+
command: executionHistoryCmd
|
|
378
|
+
}]
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
// Add worktree guard hook (blocks Write/Edit to main-branch files when df/* worktree exists)
|
|
382
|
+
settings.hooks.PostToolUse.push({
|
|
383
|
+
hooks: [{
|
|
384
|
+
type: 'command',
|
|
385
|
+
command: worktreeGuardCmd
|
|
386
|
+
}]
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
// Add invariant check hook (exits 1 on hard failures after git commit)
|
|
390
|
+
settings.hooks.PostToolUse.push({
|
|
391
|
+
hooks: [{
|
|
392
|
+
type: 'command',
|
|
393
|
+
command: invariantCheckCmd
|
|
394
|
+
}]
|
|
395
|
+
});
|
|
396
|
+
log('PostToolUse hook configured');
|
|
340
397
|
|
|
341
398
|
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
|
342
399
|
}
|
|
@@ -518,7 +575,7 @@ async function uninstall() {
|
|
|
518
575
|
];
|
|
519
576
|
|
|
520
577
|
if (level === 'global') {
|
|
521
|
-
toRemove.push('hooks/df-statusline.js', 'hooks/df-check-update.js', 'hooks/df-consolidation-check.js', 'hooks/df-invariant-check.js', 'hooks/df-quota-logger.js');
|
|
578
|
+
toRemove.push('hooks/df-statusline.js', 'hooks/df-check-update.js', 'hooks/df-consolidation-check.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');
|
|
522
579
|
}
|
|
523
580
|
|
|
524
581
|
for (const item of toRemove) {
|
|
@@ -556,17 +613,26 @@ async function uninstall() {
|
|
|
556
613
|
if (settings.hooks?.SessionEnd) {
|
|
557
614
|
settings.hooks.SessionEnd = settings.hooks.SessionEnd.filter(hook => {
|
|
558
615
|
const cmd = hook.hooks?.[0]?.command || '';
|
|
559
|
-
return !cmd.includes('df-quota-logger');
|
|
616
|
+
return !cmd.includes('df-quota-logger') && !cmd.includes('df-dashboard-push');
|
|
560
617
|
});
|
|
561
618
|
if (settings.hooks.SessionEnd.length === 0) {
|
|
562
619
|
delete settings.hooks.SessionEnd;
|
|
563
620
|
}
|
|
564
621
|
}
|
|
622
|
+
if (settings.hooks?.PostToolUse) {
|
|
623
|
+
settings.hooks.PostToolUse = settings.hooks.PostToolUse.filter(hook => {
|
|
624
|
+
const cmd = hook.hooks?.[0]?.command || '';
|
|
625
|
+
return !cmd.includes('df-tool-usage') && !cmd.includes('df-execution-history') && !cmd.includes('df-worktree-guard') && !cmd.includes('df-invariant-check');
|
|
626
|
+
});
|
|
627
|
+
if (settings.hooks.PostToolUse.length === 0) {
|
|
628
|
+
delete settings.hooks.PostToolUse;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
565
631
|
if (settings.hooks && Object.keys(settings.hooks).length === 0) {
|
|
566
632
|
delete settings.hooks;
|
|
567
633
|
}
|
|
568
634
|
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
|
569
|
-
console.log(` ${c.green}✓${c.reset} Removed SessionStart/SessionEnd hooks`);
|
|
635
|
+
console.log(` ${c.green}✓${c.reset} Removed SessionStart/SessionEnd/PostToolUse hooks`);
|
|
570
636
|
} catch (e) {
|
|
571
637
|
// Fail silently
|
|
572
638
|
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* deepflow dashboard push — SessionEnd hook
|
|
4
|
+
* Collects session summary (tokens, duration, tool calls, model), gets
|
|
5
|
+
* git user.name, and POSTs to dashboard_url from .deepflow/config.yaml.
|
|
6
|
+
* Silently skips if dashboard_url is not configured.
|
|
7
|
+
* Fire-and-forget: exits immediately after spawning background worker.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
// Spawn background process so the hook returns immediately
|
|
13
|
+
if (process.argv[2] !== '--background') {
|
|
14
|
+
const { spawn } = require('child_process');
|
|
15
|
+
const child = spawn(process.execPath, [__filename, '--background'], {
|
|
16
|
+
detached: true,
|
|
17
|
+
stdio: 'ignore',
|
|
18
|
+
// Pass stdin data through env so background process can read it
|
|
19
|
+
env: { ...process.env, _DF_HOOK_INPUT: getStdinSync() }
|
|
20
|
+
});
|
|
21
|
+
child.unref();
|
|
22
|
+
process.exit(0);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// --- Background process ---
|
|
26
|
+
|
|
27
|
+
const fs = require('fs');
|
|
28
|
+
const path = require('path');
|
|
29
|
+
const os = require('os');
|
|
30
|
+
const { execFileSync } = require('child_process');
|
|
31
|
+
const https = require('https');
|
|
32
|
+
const http = require('http');
|
|
33
|
+
|
|
34
|
+
function getStdinSync() {
|
|
35
|
+
// Non-blocking stdin read for the parent process (limited buffer)
|
|
36
|
+
try {
|
|
37
|
+
return fs.readFileSync('/dev/stdin', { encoding: 'utf8', flag: 'rs' }) || '';
|
|
38
|
+
} catch (_e) {
|
|
39
|
+
return '';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
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');
|
|
46
|
+
if (!fs.existsSync(configPath)) return null;
|
|
47
|
+
try {
|
|
48
|
+
const content = fs.readFileSync(configPath, 'utf8');
|
|
49
|
+
const match = content.match(/^\s*dashboard_url\s*:\s*(.+)$/m);
|
|
50
|
+
if (!match) return null;
|
|
51
|
+
const val = match[1].trim().replace(/^['"]|['"]$/g, '');
|
|
52
|
+
return val || null;
|
|
53
|
+
} catch (_e) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Get git user.name in the given directory. Returns 'unknown' on failure. */
|
|
59
|
+
function getGitUser(cwd) {
|
|
60
|
+
try {
|
|
61
|
+
return execFileSync('git', ['config', 'user.name'], {
|
|
62
|
+
cwd,
|
|
63
|
+
encoding: 'utf8',
|
|
64
|
+
timeout: 3000,
|
|
65
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
66
|
+
}).trim() || 'unknown';
|
|
67
|
+
} catch (_e) {
|
|
68
|
+
return process.env.USER || 'unknown';
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** POST JSON payload to url. Returns true on 200. */
|
|
73
|
+
function postJson(url, payload) {
|
|
74
|
+
return new Promise((resolve) => {
|
|
75
|
+
let parsed;
|
|
76
|
+
try {
|
|
77
|
+
parsed = new URL(url);
|
|
78
|
+
} catch (_e) {
|
|
79
|
+
resolve(false);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const body = JSON.stringify(payload);
|
|
84
|
+
const isHttps = parsed.protocol === 'https:';
|
|
85
|
+
const lib = isHttps ? https : http;
|
|
86
|
+
|
|
87
|
+
const options = {
|
|
88
|
+
hostname: parsed.hostname,
|
|
89
|
+
port: parsed.port || (isHttps ? 443 : 80),
|
|
90
|
+
path: parsed.pathname,
|
|
91
|
+
method: 'POST',
|
|
92
|
+
headers: {
|
|
93
|
+
'Content-Type': 'application/json',
|
|
94
|
+
'Content-Length': Buffer.byteLength(body)
|
|
95
|
+
},
|
|
96
|
+
timeout: 10000
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const req = lib.request(options, (res) => {
|
|
100
|
+
res.resume();
|
|
101
|
+
res.on('end', () => resolve(res.statusCode === 200));
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
req.on('error', () => resolve(false));
|
|
105
|
+
req.on('timeout', () => { req.destroy(); resolve(false); });
|
|
106
|
+
req.write(body);
|
|
107
|
+
req.end();
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function main() {
|
|
112
|
+
try {
|
|
113
|
+
const cwd = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
114
|
+
const dashboardUrl = getDashboardUrl(cwd);
|
|
115
|
+
|
|
116
|
+
// Silently skip if not configured
|
|
117
|
+
if (!dashboardUrl) process.exit(0);
|
|
118
|
+
|
|
119
|
+
// Parse session data from hook input (passed via env)
|
|
120
|
+
let hookData = {};
|
|
121
|
+
try {
|
|
122
|
+
const raw = process.env._DF_HOOK_INPUT || '';
|
|
123
|
+
if (raw) hookData = JSON.parse(raw);
|
|
124
|
+
} catch (_e) {
|
|
125
|
+
// fallback: empty data, we'll still send what we know
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const gitUser = getGitUser(cwd);
|
|
129
|
+
const projectName = path.basename(cwd);
|
|
130
|
+
const ts = new Date().toISOString();
|
|
131
|
+
|
|
132
|
+
// Extract token fields from hook data (Claude Code SessionEnd format)
|
|
133
|
+
const usage = hookData.usage || hookData.context_window?.current_usage || {};
|
|
134
|
+
const inputTokens = usage.input_tokens || 0;
|
|
135
|
+
const outputTokens = usage.output_tokens || 0;
|
|
136
|
+
const cacheReadTokens = usage.cache_read_input_tokens || usage.cache_read_tokens || 0;
|
|
137
|
+
const cacheCreationTokens = usage.cache_creation_input_tokens || usage.cache_creation_tokens || 0;
|
|
138
|
+
const model = hookData.model?.id || hookData.model?.display_name || hookData.model || 'unknown';
|
|
139
|
+
const sessionId = hookData.session_id || hookData.sessionId || `${gitUser}:${projectName}:${ts}`;
|
|
140
|
+
const durationMs = hookData.duration_ms || null;
|
|
141
|
+
const toolCalls = hookData.tool_calls || hookData.tool_use_count || 0;
|
|
142
|
+
|
|
143
|
+
const payload = {
|
|
144
|
+
user: gitUser,
|
|
145
|
+
project: projectName,
|
|
146
|
+
session_id: sessionId,
|
|
147
|
+
model,
|
|
148
|
+
tokens: {
|
|
149
|
+
[model]: {
|
|
150
|
+
input: inputTokens,
|
|
151
|
+
output: outputTokens,
|
|
152
|
+
cache_read: cacheReadTokens,
|
|
153
|
+
cache_creation: cacheCreationTokens
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
started_at: hookData.started_at || ts,
|
|
157
|
+
ended_at: ts,
|
|
158
|
+
duration_ms: durationMs,
|
|
159
|
+
tool_calls: toolCalls
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const ingestUrl = dashboardUrl.replace(/\/$/, '') + '/api/ingest';
|
|
163
|
+
await postJson(ingestUrl, payload);
|
|
164
|
+
} catch (_e) {
|
|
165
|
+
// Never break session end
|
|
166
|
+
}
|
|
167
|
+
process.exit(0);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
main();
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* deepflow execution history recorder
|
|
4
|
+
* PostToolUse hook: fires when the Agent tool completes.
|
|
5
|
+
* Appends task_start + task_end records to {cwd}/.deepflow/execution-history.jsonl.
|
|
6
|
+
* Exits silently (code 0) on all errors — never blocks tool execution (REQ-8).
|
|
7
|
+
*
|
|
8
|
+
* Output record fields:
|
|
9
|
+
* type, task_id, spec, session_id, timestamp, status
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
'use strict';
|
|
13
|
+
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Extract task_id from Agent prompt.
|
|
19
|
+
* Pattern: T{n} anywhere in the prompt, e.g. "T21: fix bug" → "T21"
|
|
20
|
+
* Falls back to DEEPFLOW_TASK_ID env var (C-6).
|
|
21
|
+
*/
|
|
22
|
+
function extractTaskId(prompt) {
|
|
23
|
+
if (prompt) {
|
|
24
|
+
const match = prompt.match(/T(\d+)/);
|
|
25
|
+
if (match) return `T${match[1]}`;
|
|
26
|
+
}
|
|
27
|
+
return process.env.DEEPFLOW_TASK_ID || null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Extract spec name from Agent prompt.
|
|
32
|
+
* Looks for pattern: "spec: {name}" or "spec:{name}"
|
|
33
|
+
*/
|
|
34
|
+
function extractSpec(prompt) {
|
|
35
|
+
if (!prompt) return null;
|
|
36
|
+
const match = prompt.match(/spec:\s*(\S+)/i);
|
|
37
|
+
return match ? match[1] : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Parse task status from tool_response content.
|
|
42
|
+
* Looks for TASK_STATUS:{pass|revert|fail} in the response text.
|
|
43
|
+
* Defaults to "unknown" if not found (REQ-2).
|
|
44
|
+
*/
|
|
45
|
+
function extractStatus(toolResponse) {
|
|
46
|
+
const responseStr = JSON.stringify(toolResponse || '');
|
|
47
|
+
const match = responseStr.match(/TASK_STATUS:(pass|revert|fail)/);
|
|
48
|
+
return match ? match[1] : 'unknown';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Resolve the project root from cwd.
|
|
53
|
+
* Walks up to find the .deepflow directory, or falls back to cwd itself.
|
|
54
|
+
*/
|
|
55
|
+
function resolveProjectRoot(cwd) {
|
|
56
|
+
if (!cwd) return process.cwd();
|
|
57
|
+
// If inside a worktree, strip down to the project root
|
|
58
|
+
const worktreeMatch = cwd.match(/^(.*?)(?:\/\.deepflow\/worktrees\/[^/]+)/);
|
|
59
|
+
if (worktreeMatch) return worktreeMatch[1];
|
|
60
|
+
return cwd;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Read all stdin, then process
|
|
64
|
+
let raw = '';
|
|
65
|
+
process.stdin.setEncoding('utf8');
|
|
66
|
+
process.stdin.on('data', chunk => { raw += chunk; });
|
|
67
|
+
process.stdin.on('end', () => {
|
|
68
|
+
try {
|
|
69
|
+
const data = JSON.parse(raw);
|
|
70
|
+
|
|
71
|
+
// Only fire for Agent tool calls
|
|
72
|
+
if (data.tool_name !== 'Agent') {
|
|
73
|
+
process.exit(0);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const prompt = (data.tool_input && data.tool_input.prompt) || '';
|
|
77
|
+
const taskId = extractTaskId(prompt);
|
|
78
|
+
|
|
79
|
+
// Only record if we have a task_id
|
|
80
|
+
if (!taskId) {
|
|
81
|
+
process.exit(0);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const cwd = data.cwd || process.cwd();
|
|
85
|
+
const projectRoot = resolveProjectRoot(cwd);
|
|
86
|
+
const historyFile = path.join(projectRoot, '.deepflow', 'execution-history.jsonl');
|
|
87
|
+
|
|
88
|
+
const timestamp = new Date().toISOString();
|
|
89
|
+
const sessionId = data.session_id || null;
|
|
90
|
+
const spec = extractSpec(prompt);
|
|
91
|
+
const status = extractStatus(data.tool_response);
|
|
92
|
+
|
|
93
|
+
const startRecord = {
|
|
94
|
+
type: 'task_start',
|
|
95
|
+
task_id: taskId,
|
|
96
|
+
spec,
|
|
97
|
+
session_id: sessionId,
|
|
98
|
+
timestamp,
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const endRecord = {
|
|
102
|
+
type: 'task_end',
|
|
103
|
+
task_id: taskId,
|
|
104
|
+
session_id: sessionId,
|
|
105
|
+
status,
|
|
106
|
+
timestamp,
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const logDir = path.dirname(historyFile);
|
|
110
|
+
if (!fs.existsSync(logDir)) {
|
|
111
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
fs.appendFileSync(historyFile, JSON.stringify(startRecord) + '\n');
|
|
115
|
+
fs.appendFileSync(historyFile, JSON.stringify(endRecord) + '\n');
|
|
116
|
+
} catch (_e) {
|
|
117
|
+
// Fail silently — never break tool execution (REQ-8)
|
|
118
|
+
}
|
|
119
|
+
process.exit(0);
|
|
120
|
+
});
|
|
@@ -1020,6 +1020,130 @@ function checkInvariants(diff, specContent, opts = {}) {
|
|
|
1020
1020
|
return { hard, advisory };
|
|
1021
1021
|
}
|
|
1022
1022
|
|
|
1023
|
+
// ── Hook entry point (REQ-5 AC-7) ────────────────────────────────────────────
|
|
1024
|
+
//
|
|
1025
|
+
// When called as a PostToolUse hook, Claude Code pipes a JSON payload on stdin:
|
|
1026
|
+
// { tool_name, tool_input, tool_response, cwd, ... }
|
|
1027
|
+
//
|
|
1028
|
+
// We fire after any Bash call that looks like a git commit, extract the diff
|
|
1029
|
+
// from HEAD~1, load the active spec from .deepflow/, and exit(1) on hard failures.
|
|
1030
|
+
//
|
|
1031
|
+
// Detection: if stdin is not a TTY we treat it as hook mode and attempt JSON parse.
|
|
1032
|
+
// If the payload is not a git-commit Bash call we exit(0) silently.
|
|
1033
|
+
|
|
1034
|
+
function loadActiveSpec(cwd) {
|
|
1035
|
+
const deepflowDir = path.join(cwd, '.deepflow');
|
|
1036
|
+
let specContent = null;
|
|
1037
|
+
|
|
1038
|
+
try {
|
|
1039
|
+
// Look for doing-*.md specs first (in-progress)
|
|
1040
|
+
const entries = fs.readdirSync(deepflowDir);
|
|
1041
|
+
const doingSpec = entries.find((e) => e.startsWith('doing-') && e.endsWith('.md'));
|
|
1042
|
+
if (doingSpec) {
|
|
1043
|
+
specContent = fs.readFileSync(path.join(deepflowDir, doingSpec), 'utf8');
|
|
1044
|
+
return specContent;
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
// Fall back to specs/ subdirectory
|
|
1048
|
+
const specsDir = path.join(cwd, 'specs');
|
|
1049
|
+
if (fs.existsSync(specsDir)) {
|
|
1050
|
+
const specEntries = fs.readdirSync(specsDir);
|
|
1051
|
+
const doingInSpecs = specEntries.find((e) => e.startsWith('doing-') && e.endsWith('.md'));
|
|
1052
|
+
if (doingInSpecs) {
|
|
1053
|
+
specContent = fs.readFileSync(path.join(specsDir, doingInSpecs), 'utf8');
|
|
1054
|
+
return specContent;
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
} catch (_) {
|
|
1058
|
+
// Cannot read .deepflow or specs dir — return null
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
return null;
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
function extractDiffFromLastCommit(cwd) {
|
|
1065
|
+
try {
|
|
1066
|
+
return execSync('git diff HEAD~1 HEAD', {
|
|
1067
|
+
encoding: 'utf8',
|
|
1068
|
+
cwd,
|
|
1069
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
1070
|
+
});
|
|
1071
|
+
} catch (_) {
|
|
1072
|
+
return null;
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
function isGitCommitBash(toolName, toolInput) {
|
|
1077
|
+
if (toolName !== 'Bash') return false;
|
|
1078
|
+
const cmd = (toolInput && (toolInput.command || toolInput.cmd || '')) || '';
|
|
1079
|
+
return /git\s+commit\b/.test(cmd);
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
// Run hook mode when stdin is not a TTY (i.e., piped payload from Claude Code)
|
|
1083
|
+
if (!process.stdin.isTTY) {
|
|
1084
|
+
let raw = '';
|
|
1085
|
+
process.stdin.setEncoding('utf8');
|
|
1086
|
+
process.stdin.on('data', (chunk) => { raw += chunk; });
|
|
1087
|
+
process.stdin.on('end', () => {
|
|
1088
|
+
let data;
|
|
1089
|
+
try {
|
|
1090
|
+
data = JSON.parse(raw);
|
|
1091
|
+
} catch (_) {
|
|
1092
|
+
// Not valid JSON — not a hook payload, exit silently
|
|
1093
|
+
process.exit(0);
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
try {
|
|
1097
|
+
const toolName = data.tool_name || '';
|
|
1098
|
+
const toolInput = data.tool_input || {};
|
|
1099
|
+
|
|
1100
|
+
// Only run after a git commit bash call
|
|
1101
|
+
if (!isGitCommitBash(toolName, toolInput)) {
|
|
1102
|
+
process.exit(0);
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
const cwd = data.cwd || process.cwd();
|
|
1106
|
+
|
|
1107
|
+
const diff = extractDiffFromLastCommit(cwd);
|
|
1108
|
+
if (!diff) {
|
|
1109
|
+
// No diff available (e.g. initial commit) — pass through
|
|
1110
|
+
process.exit(0);
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
const specContent = loadActiveSpec(cwd);
|
|
1114
|
+
if (!specContent) {
|
|
1115
|
+
// No active spec found — not a deepflow project or no spec in progress
|
|
1116
|
+
process.exit(0);
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
const results = checkInvariants(diff, specContent, { mode: 'auto', taskType: 'implementation', projectRoot: cwd });
|
|
1120
|
+
|
|
1121
|
+
if (results.hard.length > 0) {
|
|
1122
|
+
console.error('[df-invariant-check] Hard invariant failures detected:');
|
|
1123
|
+
const outputLines = formatOutput(results);
|
|
1124
|
+
for (const line of outputLines) {
|
|
1125
|
+
if (results.hard.some((v) => formatViolation(v) === line)) {
|
|
1126
|
+
console.error(` ${line}`);
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
process.exit(1);
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
if (results.advisory.length > 0) {
|
|
1133
|
+
console.warn('[df-invariant-check] Advisory warnings:');
|
|
1134
|
+
for (const v of results.advisory) {
|
|
1135
|
+
console.warn(` ${formatViolation(v)}`);
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
process.exit(0);
|
|
1140
|
+
} catch (_err) {
|
|
1141
|
+
// Unexpected error — fail open so we never break non-deepflow projects
|
|
1142
|
+
process.exit(0);
|
|
1143
|
+
}
|
|
1144
|
+
});
|
|
1145
|
+
} else {
|
|
1146
|
+
|
|
1023
1147
|
// ── CLI entry point (REQ-6) ───────────────────────────────────────────────────
|
|
1024
1148
|
if (require.main === module) {
|
|
1025
1149
|
const args = process.argv.slice(2);
|
|
@@ -1091,6 +1215,8 @@ if (require.main === module) {
|
|
|
1091
1215
|
process.exit(results.hard.length > 0 ? 1 : 0);
|
|
1092
1216
|
}
|
|
1093
1217
|
|
|
1218
|
+
} // end else (TTY / CLI mode)
|
|
1219
|
+
|
|
1094
1220
|
module.exports = {
|
|
1095
1221
|
checkInvariants,
|
|
1096
1222
|
checkLspAvailability,
|
package/hooks/df-spec-lint.js
CHANGED
|
@@ -22,6 +22,62 @@ const REQUIRED_SECTIONS = [
|
|
|
22
22
|
['Technical Notes', 'architecture notes', 'architecture', 'tech notes', 'implementation notes'],
|
|
23
23
|
];
|
|
24
24
|
|
|
25
|
+
// ── Spec layers (onion model) ───────────────────────────────────────────
|
|
26
|
+
// Each layer defines sections that must ALL be present (cumulative with prior layers).
|
|
27
|
+
// The computed layer is the highest where all cumulative sections exist.
|
|
28
|
+
//
|
|
29
|
+
// L0: Problem defined → spikes only
|
|
30
|
+
// L1: Requirements known → targeted spikes
|
|
31
|
+
// L2: Verifiable → implementation tasks
|
|
32
|
+
// L3: Fully constrained → full impact analysis + optimize tasks
|
|
33
|
+
const LAYER_DEFINITIONS = [
|
|
34
|
+
{ layer: 0, sections: ['Objective'] },
|
|
35
|
+
{ layer: 1, sections: ['Requirements'] },
|
|
36
|
+
{ layer: 2, sections: ['Acceptance Criteria'] },
|
|
37
|
+
{ layer: 3, sections: ['Constraints', 'Out of Scope', 'Technical Notes'] },
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Compute the spec layer from its content.
|
|
42
|
+
* Returns the highest layer (0–3) where ALL cumulative required sections are present.
|
|
43
|
+
* Returns -1 if not even L0 (no Objective).
|
|
44
|
+
*/
|
|
45
|
+
function computeLayer(content) {
|
|
46
|
+
const headersFound = [];
|
|
47
|
+
for (const line of content.split('\n')) {
|
|
48
|
+
const m = line.match(/^##\s+(.+)/i);
|
|
49
|
+
if (m) {
|
|
50
|
+
const raw = m[1].trim().replace(/^\d+\.\s*/, '');
|
|
51
|
+
headersFound.push(raw.toLowerCase());
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Inline *AC: lines satisfy the Acceptance Criteria requirement
|
|
56
|
+
const hasInlineAC = /\*AC[:.]/.test(content);
|
|
57
|
+
|
|
58
|
+
let currentLayer = -1;
|
|
59
|
+
for (const { layer, sections } of LAYER_DEFINITIONS) {
|
|
60
|
+
const allPresent = sections.every((section) => {
|
|
61
|
+
// Find the REQUIRED_SECTIONS entry for aliases
|
|
62
|
+
const entry = REQUIRED_SECTIONS.find(
|
|
63
|
+
([canonical]) => canonical.toLowerCase() === section.toLowerCase()
|
|
64
|
+
);
|
|
65
|
+
const allNames = entry
|
|
66
|
+
? [entry[0], ...entry.slice(1)].map((n) => n.toLowerCase())
|
|
67
|
+
: [section.toLowerCase()];
|
|
68
|
+
|
|
69
|
+
if (section === 'Acceptance Criteria' && hasInlineAC) return true;
|
|
70
|
+
return headersFound.some((h) => allNames.includes(h));
|
|
71
|
+
});
|
|
72
|
+
if (allPresent) {
|
|
73
|
+
currentLayer = layer;
|
|
74
|
+
} else {
|
|
75
|
+
break; // layers are cumulative — can't skip
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return currentLayer;
|
|
79
|
+
}
|
|
80
|
+
|
|
25
81
|
/**
|
|
26
82
|
* Validate a spec's content against hard invariants and advisory checks.
|
|
27
83
|
*
|
|
@@ -34,7 +90,11 @@ function validateSpec(content, { mode = 'interactive', specsDir = null } = {}) {
|
|
|
34
90
|
const hard = [];
|
|
35
91
|
const advisory = [];
|
|
36
92
|
|
|
37
|
-
|
|
93
|
+
const layer = computeLayer(content);
|
|
94
|
+
|
|
95
|
+
// ── (a) Required sections (layer-aware) ──────────────────────────────
|
|
96
|
+
// Hard-fail only for sections required by the CURRENT layer.
|
|
97
|
+
// Missing sections beyond the current layer are advisory (hints to deepen).
|
|
38
98
|
const headersFound = [];
|
|
39
99
|
for (const line of content.split('\n')) {
|
|
40
100
|
const m = line.match(/^##\s+(.+)/i);
|
|
@@ -45,13 +105,25 @@ function validateSpec(content, { mode = 'interactive', specsDir = null } = {}) {
|
|
|
45
105
|
}
|
|
46
106
|
}
|
|
47
107
|
|
|
108
|
+
// Collect all sections required up to the current layer
|
|
109
|
+
const layerRequiredSections = new Set();
|
|
110
|
+
for (const { layer: l, sections } of LAYER_DEFINITIONS) {
|
|
111
|
+
if (l <= layer) {
|
|
112
|
+
for (const s of sections) layerRequiredSections.add(s.toLowerCase());
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
48
116
|
for (const [canonical, ...aliases] of REQUIRED_SECTIONS) {
|
|
49
117
|
const allNames = [canonical, ...aliases].map((n) => n.toLowerCase());
|
|
50
118
|
const found = headersFound.some((h) => allNames.includes(h.toLowerCase()));
|
|
51
119
|
if (!found) {
|
|
52
120
|
// Inline *AC: lines satisfy the Acceptance Criteria requirement
|
|
53
121
|
if (canonical === 'Acceptance Criteria' && /\*AC[:.]/.test(content)) continue;
|
|
54
|
-
|
|
122
|
+
if (layerRequiredSections.has(canonical.toLowerCase())) {
|
|
123
|
+
hard.push(`Missing required section: "## ${canonical}"`);
|
|
124
|
+
} else {
|
|
125
|
+
advisory.push(`Missing section for deeper layer: "## ${canonical}"`);
|
|
126
|
+
}
|
|
55
127
|
}
|
|
56
128
|
}
|
|
57
129
|
|
|
@@ -161,7 +233,7 @@ function validateSpec(content, { mode = 'interactive', specsDir = null } = {}) {
|
|
|
161
233
|
hard.push(...advisory.splice(0, advisory.length));
|
|
162
234
|
}
|
|
163
235
|
|
|
164
|
-
return { hard, advisory };
|
|
236
|
+
return { layer, hard, advisory };
|
|
165
237
|
}
|
|
166
238
|
|
|
167
239
|
/**
|
|
@@ -230,7 +302,9 @@ if (require.main === module) {
|
|
|
230
302
|
console.log('All checks passed.');
|
|
231
303
|
}
|
|
232
304
|
|
|
305
|
+
console.log(`Spec layer: L${result.layer} (${['problem defined', 'requirements known', 'verifiable', 'fully constrained'][result.layer] || 'incomplete'})`);
|
|
306
|
+
|
|
233
307
|
process.exit(result.hard.length > 0 ? 1 : 0);
|
|
234
308
|
}
|
|
235
309
|
|
|
236
|
-
module.exports = { validateSpec, extractSection };
|
|
310
|
+
module.exports = { validateSpec, extractSection, computeLayer };
|