deepflow 0.1.88 → 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 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)');
@@ -239,6 +239,10 @@ async function configureHooks(claudeDir) {
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
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')}"`;
242
246
 
243
247
  let settings = {};
244
248
 
@@ -324,10 +328,10 @@ async function configureHooks(claudeDir) {
324
328
  settings.hooks.SessionEnd = [];
325
329
  }
326
330
 
327
- // Remove any existing quota logger from SessionEnd
331
+ // Remove any existing quota logger / dashboard push from SessionEnd
328
332
  settings.hooks.SessionEnd = settings.hooks.SessionEnd.filter(hook => {
329
333
  const cmd = hook.hooks?.[0]?.command || '';
330
- return !cmd.includes('df-quota-logger');
334
+ return !cmd.includes('df-quota-logger') && !cmd.includes('df-dashboard-push');
331
335
  });
332
336
 
333
337
  // Add quota logger to SessionEnd
@@ -337,17 +341,25 @@ async function configureHooks(claudeDir) {
337
341
  command: quotaLoggerCmd
338
342
  }]
339
343
  });
340
- log('Quota logger configured');
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)');
341
353
 
342
354
  // Configure PostToolUse hook for tool usage instrumentation
343
355
  if (!settings.hooks.PostToolUse) {
344
356
  settings.hooks.PostToolUse = [];
345
357
  }
346
358
 
347
- // Remove any existing deepflow tool usage hooks from PostToolUse
359
+ // Remove any existing deepflow tool usage / execution history / worktree guard / invariant check hooks from PostToolUse
348
360
  settings.hooks.PostToolUse = settings.hooks.PostToolUse.filter(hook => {
349
361
  const cmd = hook.hooks?.[0]?.command || '';
350
- return !cmd.includes('df-tool-usage');
362
+ return !cmd.includes('df-tool-usage') && !cmd.includes('df-execution-history') && !cmd.includes('df-worktree-guard') && !cmd.includes('df-invariant-check');
351
363
  });
352
364
 
353
365
  // Add tool usage hook
@@ -357,6 +369,30 @@ async function configureHooks(claudeDir) {
357
369
  command: toolUsageCmd
358
370
  }]
359
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
+ });
360
396
  log('PostToolUse hook configured');
361
397
 
362
398
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
@@ -539,7 +575,7 @@ async function uninstall() {
539
575
  ];
540
576
 
541
577
  if (level === 'global') {
542
- 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');
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');
543
579
  }
544
580
 
545
581
  for (const item of toRemove) {
@@ -577,7 +613,7 @@ async function uninstall() {
577
613
  if (settings.hooks?.SessionEnd) {
578
614
  settings.hooks.SessionEnd = settings.hooks.SessionEnd.filter(hook => {
579
615
  const cmd = hook.hooks?.[0]?.command || '';
580
- return !cmd.includes('df-quota-logger');
616
+ return !cmd.includes('df-quota-logger') && !cmd.includes('df-dashboard-push');
581
617
  });
582
618
  if (settings.hooks.SessionEnd.length === 0) {
583
619
  delete settings.hooks.SessionEnd;
@@ -586,7 +622,7 @@ async function uninstall() {
586
622
  if (settings.hooks?.PostToolUse) {
587
623
  settings.hooks.PostToolUse = settings.hooks.PostToolUse.filter(hook => {
588
624
  const cmd = hook.hooks?.[0]?.command || '';
589
- return !cmd.includes('df-tool-usage');
625
+ return !cmd.includes('df-tool-usage') && !cmd.includes('df-execution-history') && !cmd.includes('df-worktree-guard') && !cmd.includes('df-invariant-check');
590
626
  });
591
627
  if (settings.hooks.PostToolUse.length === 0) {
592
628
  delete settings.hooks.PostToolUse;
@@ -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,
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * deepflow worktree guard
4
+ * PostToolUse hook: blocks Write/Edit to main-branch files when a df/* worktree exists.
5
+ *
6
+ * REQ-3 AC-4: exit(1) to block the tool call when all conditions hold:
7
+ * 1. tool_name is Write or Edit
8
+ * 2. current branch is main (or master)
9
+ * 3. a df/* worktree branch exists
10
+ * 4. file_path is NOT on the allowlist (.deepflow/, PLAN.md, specs/)
11
+ *
12
+ * REQ-3 AC-5: allowlisted paths always pass through (no false positives).
13
+ *
14
+ * Exits silently (code 0) on parse errors or git failures — never breaks tool
15
+ * execution in non-deepflow projects.
16
+ */
17
+
18
+ 'use strict';
19
+
20
+ const { execFileSync } = require('child_process');
21
+
22
+ // Paths that are always allowed regardless of worktree state
23
+ const ALLOWLIST = [
24
+ /(?:^|\/)\.deepflow\//,
25
+ /(?:^|\/)PLAN\.md$/,
26
+ /(?:^|\/)specs\//,
27
+ ];
28
+
29
+ function isAllowlisted(filePath) {
30
+ return ALLOWLIST.some(re => re.test(filePath));
31
+ }
32
+
33
+ function currentBranch(cwd) {
34
+ try {
35
+ return execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
36
+ encoding: 'utf8',
37
+ cwd,
38
+ stdio: ['ignore', 'pipe', 'ignore'],
39
+ }).trim();
40
+ } catch (_) {
41
+ return null;
42
+ }
43
+ }
44
+
45
+ function dfWorktreeExists(cwd) {
46
+ try {
47
+ const out = execFileSync('git', ['branch', '--list', 'df/*'], {
48
+ encoding: 'utf8',
49
+ cwd,
50
+ stdio: ['ignore', 'pipe', 'ignore'],
51
+ });
52
+ return out.trim().length > 0;
53
+ } catch (_) {
54
+ return false;
55
+ }
56
+ }
57
+
58
+ let raw = '';
59
+ process.stdin.setEncoding('utf8');
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
+ }
70
+
71
+ const filePath = (data.tool_input && data.tool_input.file_path) || '';
72
+ const cwd = data.cwd || process.cwd();
73
+
74
+ // Allowlisted paths always pass
75
+ if (isAllowlisted(filePath)) {
76
+ process.exit(0);
77
+ }
78
+
79
+ const branch = currentBranch(cwd);
80
+
81
+ // Only guard when on main/master
82
+ if (branch !== 'main' && branch !== 'master') {
83
+ process.exit(0);
84
+ }
85
+
86
+ // Block only when a df/* worktree branch exists
87
+ if (!dfWorktreeExists(cwd)) {
88
+ process.exit(0);
89
+ }
90
+
91
+ // All conditions met — block the write
92
+ console.error(
93
+ `[df-worktree-guard] Blocked ${toolName} to "${filePath}" on main branch ` +
94
+ `while df/* worktree exists. Make changes inside the worktree branch instead.`
95
+ );
96
+ process.exit(1);
97
+ } catch (_e) {
98
+ // Parse or unexpected error — fail open so we never break non-deepflow projects
99
+ process.exit(0);
100
+ }
101
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deepflow",
3
- "version": "0.1.88",
3
+ "version": "0.1.89",
4
4
  "description": "Doing reveals what thinking can't predict — spec-driven iterative development for Claude Code",
5
5
  "keywords": [
6
6
  "claude",
@@ -0,0 +1,35 @@
1
+ ---
2
+ name: df:dashboard
3
+ description: View deepflow dashboard in team mode (via URL) or local mode (via CLI server)
4
+ allowed-tools: [Read, Bash]
5
+ ---
6
+
7
+ # /df:dashboard — Deepflow Dashboard
8
+
9
+ View the deepflow dashboard in team or local mode.
10
+
11
+ **NEVER:** Spawn agents, use Task tool, use AskUserQuestion, run git, EnterPlanMode, ExitPlanMode
12
+
13
+ **ONLY:** Read config, run npx deepflow-dashboard, open browser
14
+
15
+ ## Behavior
16
+
17
+ 1. **Check config mode**
18
+ - Read `.deepflow/config.yaml`
19
+ - If `dashboard_url` key exists and is non-empty: TEAM MODE
20
+ - Else: LOCAL MODE
21
+
22
+ 2. **TEAM MODE** (dashboard_url configured)
23
+ - Display: `Dashboard URL configured: {dashboard_url}`
24
+ - Open URL in browser via `open "{dashboard_url}"` (macOS) or appropriate command for OS
25
+
26
+ 3. **LOCAL MODE** (no dashboard_url)
27
+ - Display: `Starting local deepflow dashboard server...`
28
+ - Run: `npx deepflow-dashboard`
29
+ - Instruct user to open http://localhost:3000 (or configured port) in browser
30
+
31
+ ## Rules
32
+
33
+ - Gracefully handle missing config.yaml (treat as LOCAL MODE)
34
+ - If dashboard_url exists but is empty string, treat as LOCAL MODE
35
+ - Always confirm mode and action before executing
@@ -21,10 +21,11 @@ Each task = one background agent. **NEVER use TaskOutput** (100KB+ transcripts e
21
21
  2. STOP. End turn. Do NOT poll.
22
22
  3. On EACH notification:
23
23
  a. Ratchet check (§5.5)
24
- b. Passed → TaskUpdate(status: "completed"), update PLAN.md [x] + commit hash
25
- c. Failed → partial salvage (§5.5). Salvaged → passed. Not → git revert, TaskUpdate(status: "pending")
26
- d. Report ONE line: "✓ T1: ratchet passed (abc123)" or "⚕ T1: salvaged (abc124)" or "✗ T1: reverted"
27
- e. NOT all done end turn, wait | ALL done next wave or finish
24
+ b. Passed → wave test agent (§5.6). Tests pass → re-snapshot (§5.6) → TaskUpdate(status: "completed"), update PLAN.md [x] + commit hash
25
+ c. Failed → partial salvage (§5.5). Salvaged → wave test agent (§5.6). Not → git revert, TaskUpdate(status: "pending")
26
+ d. Wave test agent failed after max attempts revert ALL task commits, TaskUpdate(status: "pending")
27
+ e. Report ONE line: "✓ T1: ratchet+tests passed (abc123)" or "⚕ T1: salvaged+tested (abc124)" or "✗ T1: reverted" or "✗ T1: test agent failed, reverted"
28
+ f. NOT all done → end turn, wait | ALL done → next wave or finish
28
29
  4. Between waves: context ≥50% → checkpoint and exit.
29
30
  5. Repeat until: all done, all blocked, or context ≥50%.
30
31
  ```
@@ -53,7 +54,26 @@ git -C ${WORKTREE_PATH} ls-files | grep -E '\.(test|spec)\.[^/]+$|^test_|_test\.
53
54
 
54
55
  ### 1.7. NO-TESTS BOOTSTRAP
55
56
 
56
- Zero test files spawn ONE bootstrap agent (§6 Bootstrap). Pass → re-snapshot, end cycle. Fail → revert, halt "Bootstrap failed — manual intervention required". Subsequent cycles use bootstrapped tests as baseline.
57
+ <!-- AC-1: zero test files triggers bootstrap before wave 1 -->
58
+ <!-- AC-2: bootstrap success re-snapshots auto-snapshot.txt; subsequent tasks use updated snapshot -->
59
+ <!-- AC-3: bootstrap failure with default model retries with Opus; double failure halts with specific message -->
60
+
61
+ **Gate:** After §1.6 snapshot, check `auto-snapshot.txt`:
62
+ ```bash
63
+ SNAPSHOT_COUNT=$(wc -l < .deepflow/auto-snapshot.txt | tr -d ' ')
64
+ ```
65
+ If `SNAPSHOT_COUNT` is `0` (zero test files found), MUST spawn bootstrap agent before wave 1. No implementation tasks may start until bootstrap completes successfully.
66
+
67
+ **Bootstrap flow:**
68
+ 1. Spawn `Agent(model="{default_model}", ...)` with Bootstrap prompt (§6). End turn, wait for notification.
69
+ 2. **On success (TASK_STATUS:pass):** Re-snapshot immediately:
70
+ ```bash
71
+ git -C ${WORKTREE_PATH} ls-files | grep -E '\.(test|spec)\.[^/]+$|^test_|_test\.[^/]+$|^tests/|__tests__/' > .deepflow/auto-snapshot.txt
72
+ ```
73
+ All subsequent tasks use this updated snapshot as their ratchet baseline. Proceed to wave 1.
74
+ 3. **On failure (TASK_STATUS:fail) with default model:** Retry ONCE with `Agent(model="opus", ...)` using the same Bootstrap prompt.
75
+ - Opus success → re-snapshot (same command above) → proceed to wave 1.
76
+ - Opus failure → halt with message: `"Bootstrap failed with both default and Opus — manual intervention required"`. Do not proceed.
57
77
 
58
78
  ### 2. LOAD PLAN
59
79
 
@@ -115,6 +135,46 @@ Omit if context.json/token-history.jsonl/awk unavailable. Never fail ratchet for
115
135
  1. Lint/typecheck-only (build+tests passed): spawn `Agent(model="haiku")` to fix. Re-ratchet. Fail → revert both.
116
136
  2. Build/test failure → `git revert HEAD --no-edit` (no salvage).
117
137
 
138
+ ### 5.6. WAVE TEST AGENT
139
+
140
+ <!-- AC-8: After wave ratchet passes, Opus test agent spawns and writes unit tests -->
141
+ <!-- AC-9: Test failures trigger implementer re-spawn with failure feedback; max 3 attempts then revert -->
142
+ <!-- AC-12: auto-snapshot.txt re-generated after wave test agent commits; wave N+1 ratchet includes wave N tests -->
143
+
144
+ **Trigger:** After ratchet check passes (or after successful salvage) for a task.
145
+
146
+ **Attempt tracking:** Initialize `attempt_count = 1` and `failure_feedback = ""` per task when first spawned. Max 3 total attempts (1 initial + 2 retries).
147
+
148
+ **Flow:**
149
+ 1. Capture the implementation diff: `git -C ${WORKTREE_PATH} diff HEAD~1` → store as `IMPL_DIFF`.
150
+ 2. Spawn `Agent(model="opus")` with Wave Test prompt (§6). `run_in_background=true`. End turn, wait.
151
+ 3. On notification:
152
+ a. Run ratchet check (§5.5) — all new + pre-existing tests must pass.
153
+ b. **Tests pass** → commit stands. **Re-snapshot** immediately so wave N+1 ratchet includes wave N tests:
154
+ ```bash
155
+ git -C ${WORKTREE_PATH} ls-files | grep -E '\.(test|spec)\.[^/]+$|^test_|_test\.[^/]+$|^tests/|__tests__/' > .deepflow/auto-snapshot.txt
156
+ ```
157
+ Task complete. Report: `"✓ T{n}: ratchet+tests passed ({hash})"`.
158
+ c. **Tests fail** →
159
+ - If `attempt_count < 3`:
160
+ - `git revert HEAD --no-edit` (revert test commit)
161
+ - `git revert HEAD --no-edit` (revert implementation commit)
162
+ - Accumulate failure output: `failure_feedback += "Attempt {N}: {truncated_test_output}\n"`
163
+ - `attempt_count += 1`
164
+ - Re-spawn implementer agent with original prompt + failure feedback appendix:
165
+ ```
166
+ PREVIOUS FAILURES (attempt {N-1} of 3):
167
+ {failure_feedback}
168
+ Fix the issues above. Do NOT repeat the same mistakes.
169
+ ```
170
+ - On implementer notification: ratchet check (§5.5). Passed → goto step 1 (spawn test agent again). Failed → same retry logic.
171
+ - If `attempt_count >= 3`:
172
+ - Revert ALL commits back to pre-task state: `git -C ${WORKTREE_PATH} reset --hard {pre_task_commit}`
173
+ - `TaskUpdate(status: "pending")`
174
+ - Report: `"✗ T{n}: test agent failed after 3 attempts, reverted"`
175
+
176
+ **Output truncation for failure feedback:** Test failures → test names + last 30 lines of output. Build failures → last 15 lines. Cap total `failure_feedback` at 200 lines.
177
+
118
178
  ### 5.7. PARALLEL SPIKE PROBES
119
179
 
120
180
  Trigger: ≥2 [SPIKE] tasks with same blocker or identical hypothesis.
@@ -186,19 +246,48 @@ REPEAT:
186
246
  --- START ---
187
247
  {task_id}: {description} Files: {files} Spec: {spec}
188
248
  {If reverted: DO NOT repeat: - Cycle {N}: "{reason}"}
249
+ {If spike insights exist:
250
+ spike_results:
251
+ hypothesis: {hypothesis from spike_insights}
252
+ outcome: {outcome}
253
+ edge_cases: {edge_cases}
254
+ insight: {insight from probe_learnings}
255
+ }
189
256
  Success criteria: {ACs from spec relevant to this task}
190
257
  --- MIDDLE (omit for low effort; omit deps for medium) ---
191
258
  Impact: Callers: {file} ({why}) | Duplicates: [active→consolidate] [dead→DELETE] | Data flow: {consumers}
192
259
  Prior tasks: {dep_id}: {summary}
193
260
  Steps: 1. chub search/get for APIs 2. LSP findReferences, add unlisted callers 3. Read all Impact files 4. Implement 5. Commit
194
261
  --- END ---
195
- Spike results: {winner learnings}
196
262
  Duplicates: [active]→consolidate [dead]→DELETE. ONLY job: code+commit. No merge/rename/checkout.
263
+ Last line of your response MUST be: TASK_STATUS:pass (if successful) or TASK_STATUS:fail (if failed) or TASK_STATUS:revert (if reverted)
264
+ ```
265
+
266
+ **Bootstrap:** `BOOTSTRAP: Write tests for edit_scope files. Do NOT change implementation. Commit as test({spec}): bootstrap. Last line: TASK_STATUS:pass or TASK_STATUS:fail`
267
+
268
+ **Wave Test** (`Agent(model="opus")`):
197
269
  ```
270
+ --- START ---
271
+ You are a QA engineer. Write unit tests for the following code changes.
272
+ Use {test_framework}. Test behavioral correctness, not implementation details.
273
+ Spec: {spec}. Task: {task_id}.
274
+
275
+ Implementation diff:
276
+ {IMPL_DIFF}
198
277
 
199
- **Bootstrap:** `BOOTSTRAP: Write tests for edit_scope files. Do NOT change implementation. Commit as test({spec}): bootstrap`
278
+ --- MIDDLE ---
279
+ Files changed: {changed_files}
280
+ Existing test patterns: {test_file_examples from auto-snapshot.txt, first 3}
200
281
 
201
- **Spike:** `{task_id} [SPIKE]: {hypothesis}. Files+Spec. {reverted warnings}. Minimal spike. Commit as spike({spec}): {desc}`
282
+ --- END ---
283
+ Write thorough unit tests covering: happy paths, edge cases, error handling.
284
+ Follow existing test conventions in the codebase.
285
+ Commit as: test({spec}): wave-{N} unit tests
286
+ Do NOT modify implementation files. ONLY add/edit test files.
287
+ Last line of your response MUST be: TASK_STATUS:pass or TASK_STATUS:fail
288
+ ```
289
+
290
+ **Spike:** `{task_id} [SPIKE]: {hypothesis}. Files+Spec. {reverted warnings}. Minimal spike. Commit as spike({spec}): {desc}. Last line: TASK_STATUS:pass or TASK_STATUS:fail`
202
291
 
203
292
  **Optimize Task** (`Agent(model="opus")`):
204
293
  ```
@@ -210,6 +299,7 @@ CONSTRAINT: ONE atomic change.
210
299
  Last 5 cycles + failed hypotheses + Impact/deps.
211
300
  --- END ---
212
301
  {Learnings}. ONE change + commit. No metric run, no multiple changes.
302
+ Last line of your response MUST be: TASK_STATUS:pass or TASK_STATUS:fail or TASK_STATUS:revert
213
303
  ```
214
304
 
215
305
  **Optimize Probe** (`Agent(model="opus")`):
@@ -224,11 +314,68 @@ Current/Target. Role instruction:
224
314
  Full history + all failed hypotheses.
225
315
  --- END ---
226
316
  ONE atomic change. Commit. STOP.
317
+ Last line of your response MUST be: TASK_STATUS:pass or TASK_STATUS:fail or TASK_STATUS:revert
318
+ ```
319
+
320
+ **Final Test** (`Agent(model="opus")`):
321
+ ```
322
+ --- START ---
323
+ You are an independent QA engineer. You have ONLY the spec and exported interfaces below.
324
+ You cannot read implementation files — you must treat the system as a black box.
325
+ Write integration tests that verify EACH acceptance criterion from the spec.
326
+
327
+ Spec:
328
+ {SPEC_CONTENT}
329
+
330
+ Exported interfaces:
331
+ {EXPORTED_INTERFACES}
332
+
333
+ --- END ---
334
+ Write integration tests covering every AC in the spec.
335
+ Test through public interfaces only — no internal imports, no implementation details.
336
+ If an AC cannot be tested through exports alone, write a test stub with a TODO comment explaining why.
337
+ Commit as: test({spec}): integration tests
338
+ Do NOT read or modify implementation files. ONLY add/edit test files.
339
+ Last line of your response MUST be: TASK_STATUS:pass or TASK_STATUS:fail
227
340
  ```
228
341
 
229
342
  ### 8. COMPLETE SPECS
230
343
 
344
+ <!-- AC-10: After all waves, Opus black-box test agent spawns with spec + exports only (no implementation) -->
345
+ <!-- AC-11: Final integration tests must all pass before merge proceeds; failure blocks merge -->
346
+
231
347
  All tasks done for `doing-*` spec:
348
+
349
+ **8.1. Final Test Agent (black-box integration tests):**
350
+
351
+ Before merge, spawn an independent Opus QA agent that sees ONLY the spec and exported interfaces — never implementation source.
352
+
353
+ 1. Extract exported interfaces from the worktree (public API surface):
354
+ ```bash
355
+ # Collect exported symbols — adapt pattern to language
356
+ git -C ${WORKTREE_PATH} diff main --name-only | xargs grep -h '^\(export\|pub \|func \|def \)' 2>/dev/null | head -100
357
+ ```
358
+ Store result as `EXPORTED_INTERFACES`. Also load spec content: `cat specs/doing-{name}.md` → `SPEC_CONTENT`.
359
+
360
+ 2. Spawn `Agent(model="opus")` with Final Test prompt (§6). `run_in_background=true`. End turn, wait.
361
+
362
+ 3. On notification:
363
+ a. Run ratchet check (§5.5) — all integration tests must pass.
364
+ b. **Tests pass** → commit stands. Proceed to step 8.2 (merge).
365
+ c. **Tests fail** → **merge is blocked**. Do NOT retry. Report:
366
+ `"✗ Final integration tests failed for {spec} — merge blocked, requires human review"`
367
+ Leave worktree intact. Set all spec tasks back to `TaskUpdate(status: "pending")`.
368
+ Write failure details to `.deepflow/results/final-test-{spec}.yaml`:
369
+ ```yaml
370
+ spec: {spec}
371
+ status: blocked
372
+ reason: "Final integration tests failed"
373
+ output: |
374
+ {truncated test output — last 30 lines}
375
+ ```
376
+ STOP. Do not proceed to merge.
377
+
378
+ **8.2. Merge and cleanup:**
232
379
  1. `skill: "df:verify", args: "doing-{name}"` — runs L0-L4 gates, merges, cleans worktree, renames doing→done, extracts decisions. Fail (fix tasks added) → stop; `--continue` picks them up.
233
380
  2. Remove spec's ENTIRE section from PLAN.md. Recalculate Summary table.
234
381
 
@@ -280,4 +427,6 @@ Reverted task: `TaskUpdate(status: "pending")`, dependents stay blocked. Repeate
280
427
  | Ratchet + metric both required | Keep only if both pass |
281
428
  | Plateau → probes | 3 cycles <1% triggers probes |
282
429
  | Circuit breaker = 3 reverts | Halts, needs human |
430
+ | Wave test after ratchet | Opus writes tests; 3 attempts then revert |
431
+ | Final test before merge | Opus black-box integration tests; failure blocks merge, no retry |
283
432
  | Probe diversity | ≥1 contraditoria + ≥1 ingenua |
@@ -6,6 +6,8 @@ allowed-tools: [Read, Write, Bash]
6
6
 
7
7
  # /df:report — Session Cost Report
8
8
 
9
+ > **DEPRECATED:** Use `/df:dashboard` instead to view deepflow metrics and status.
10
+
9
11
  ## Orchestrator Role
10
12
 
11
13
  Aggregate token usage data and produce a structured report.
@@ -96,6 +96,16 @@ quality:
96
96
  # Timeout in seconds to wait for the dev server to become ready (default: 30)
97
97
  browser_timeout: 30
98
98
 
99
+ # deepflow-dashboard team mode settings
100
+ # dashboard_url: URL of the shared team server for POST ingestion
101
+ # Leave blank (or omit) to use local-only mode (no data is pushed)
102
+ # Example: http://team-server:3334
103
+ dashboard_url: ""
104
+
105
+ # Port for `npx deepflow-dashboard serve` (team server mode)
106
+ # Default: 3334 (3333 is reserved for local mode)
107
+ dashboard_port: 3334
108
+
99
109
  # Recommended .gitignore entries
100
110
  # Add these entries to your .gitignore to exclude instrumentation artifacts
101
111
  gitignore_entries: