deepflow 0.1.90 → 0.1.92

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
@@ -241,6 +241,7 @@ async function configureHooks(claudeDir) {
241
241
  const dashboardPushCmd = `node "${path.join(claudeDir, 'hooks', 'df-dashboard-push.js')}"`;
242
242
  const executionHistoryCmd = `node "${path.join(claudeDir, 'hooks', 'df-execution-history.js')}"`;
243
243
  const worktreeGuardCmd = `node "${path.join(claudeDir, 'hooks', 'df-worktree-guard.js')}"`;
244
+ const snapshotGuardCmd = `node "${path.join(claudeDir, 'hooks', 'df-snapshot-guard.js')}"`;
244
245
  const invariantCheckCmd = `node "${path.join(claudeDir, 'hooks', 'df-invariant-check.js')}"`;
245
246
 
246
247
  let settings = {};
@@ -347,10 +348,10 @@ async function configureHooks(claudeDir) {
347
348
  settings.hooks.PostToolUse = [];
348
349
  }
349
350
 
350
- // Remove any existing deepflow tool usage / execution history / worktree guard / invariant check hooks from PostToolUse
351
+ // Remove any existing deepflow tool usage / execution history / worktree guard / snapshot guard / invariant check hooks from PostToolUse
351
352
  settings.hooks.PostToolUse = settings.hooks.PostToolUse.filter(hook => {
352
353
  const cmd = hook.hooks?.[0]?.command || '';
353
- return !cmd.includes('df-tool-usage') && !cmd.includes('df-execution-history') && !cmd.includes('df-worktree-guard') && !cmd.includes('df-invariant-check');
354
+ return !cmd.includes('df-tool-usage') && !cmd.includes('df-execution-history') && !cmd.includes('df-worktree-guard') && !cmd.includes('df-snapshot-guard') && !cmd.includes('df-invariant-check');
354
355
  });
355
356
 
356
357
  // Add tool usage hook
@@ -377,6 +378,14 @@ async function configureHooks(claudeDir) {
377
378
  }]
378
379
  });
379
380
 
381
+ // Add snapshot guard hook (blocks Write/Edit to ratchet-baseline files in auto-snapshot.txt)
382
+ settings.hooks.PostToolUse.push({
383
+ hooks: [{
384
+ type: 'command',
385
+ command: snapshotGuardCmd
386
+ }]
387
+ });
388
+
380
389
  // Add invariant check hook (exits 1 on hard failures after git commit)
381
390
  settings.hooks.PostToolUse.push({
382
391
  hooks: [{
@@ -566,7 +575,7 @@ async function uninstall() {
566
575
  ];
567
576
 
568
577
  if (level === 'global') {
569
- toRemove.push('hooks/df-statusline.js', 'hooks/df-check-update.js', 'hooks/df-invariant-check.js', 'hooks/df-quota-logger.js', 'hooks/df-tool-usage.js', 'hooks/df-dashboard-push.js', 'hooks/df-execution-history.js', 'hooks/df-worktree-guard.js');
578
+ toRemove.push('hooks/df-statusline.js', 'hooks/df-check-update.js', 'hooks/df-invariant-check.js', 'hooks/df-quota-logger.js', 'hooks/df-tool-usage.js', 'hooks/df-dashboard-push.js', 'hooks/df-execution-history.js', 'hooks/df-worktree-guard.js', 'hooks/df-snapshot-guard.js');
570
579
  }
571
580
 
572
581
  for (const item of toRemove) {
@@ -613,7 +622,7 @@ async function uninstall() {
613
622
  if (settings.hooks?.PostToolUse) {
614
623
  settings.hooks.PostToolUse = settings.hooks.PostToolUse.filter(hook => {
615
624
  const cmd = hook.hooks?.[0]?.command || '';
616
- return !cmd.includes('df-tool-usage') && !cmd.includes('df-execution-history') && !cmd.includes('df-worktree-guard') && !cmd.includes('df-invariant-check');
625
+ return !cmd.includes('df-tool-usage') && !cmd.includes('df-execution-history') && !cmd.includes('df-worktree-guard') && !cmd.includes('df-snapshot-guard') && !cmd.includes('df-invariant-check');
617
626
  });
618
627
  if (settings.hooks.PostToolUse.length === 0) {
619
628
  delete settings.hooks.PostToolUse;
package/bin/ratchet.js ADDED
@@ -0,0 +1,392 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * deepflow ratchet
4
+ * Mechanical health-check gate with auto-revert on failure.
5
+ *
6
+ * Usage: node bin/ratchet.js [--task T{N}] [--worktree PATH] [--snapshot PATH]
7
+ *
8
+ * Outputs exactly one JSON line to stdout:
9
+ * {"result":"PASS"}
10
+ * {"result":"FAIL","stage":"build","log":"..."}
11
+ * {"result":"SALVAGEABLE","stage":"lint","log":"..."}
12
+ *
13
+ * Exit codes: 0=PASS, 1=FAIL, 2=SALVAGEABLE
14
+ * On FAIL: executes `git revert HEAD --no-edit` before exiting.
15
+ * On PASS + --task T{N}: updates PLAN.md [ ] → [x] and appends commit hash.
16
+ */
17
+
18
+ 'use strict';
19
+
20
+ const fs = require('fs');
21
+ const path = require('path');
22
+ const { execFileSync, spawnSync } = require('child_process');
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Git / path helpers
26
+ // ---------------------------------------------------------------------------
27
+
28
+ function gitCommonDir(cwd) {
29
+ try {
30
+ return execFileSync('git', ['rev-parse', '--git-common-dir'], {
31
+ encoding: 'utf8',
32
+ cwd,
33
+ stdio: ['ignore', 'pipe', 'ignore'],
34
+ }).trim();
35
+ } catch (_) {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ function mainRepoRoot(cwd) {
41
+ const commonDir = gitCommonDir(cwd);
42
+ if (!commonDir) return cwd;
43
+ // --git-common-dir returns absolute path for worktrees, relative for normal repos
44
+ const absCommonDir = path.isAbsolute(commonDir)
45
+ ? commonDir
46
+ : path.resolve(cwd, commonDir);
47
+ // common dir is <repo>/.git for normal repos, <repo>/.git/worktrees/<name> for worktrees
48
+ // Walk up until we find a directory that is not inside .git
49
+ return path.resolve(absCommonDir, '..', '..').replace(/[/\\]\.git.*$/, '') ||
50
+ path.dirname(absCommonDir);
51
+ }
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Config loading (simple regex, consistent with bin/install.js style)
55
+ // ---------------------------------------------------------------------------
56
+
57
+ function loadConfig(repoRoot) {
58
+ const configPath = path.join(repoRoot, '.deepflow', 'config.yaml');
59
+ const cfg = {};
60
+ if (!fs.existsSync(configPath)) return cfg;
61
+
62
+ const text = fs.readFileSync(configPath, 'utf8');
63
+
64
+ const keys = ['build_command', 'test_command', 'typecheck_command', 'lint_command'];
65
+ for (const key of keys) {
66
+ const m = text.match(new RegExp(`^${key}:\\s*["']?([^"'\\n]+?)["']?\\s*$`, 'm'));
67
+ if (m) cfg[key] = m[1].trim();
68
+ }
69
+
70
+ return cfg;
71
+ }
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // Snapshot: read auto-snapshot.txt and absolutize paths
75
+ // ---------------------------------------------------------------------------
76
+
77
+ function loadSnapshotFiles(repoRoot) {
78
+ const snapshotPath = path.join(repoRoot, '.deepflow', 'auto-snapshot.txt');
79
+ if (!fs.existsSync(snapshotPath)) return [];
80
+
81
+ return fs.readFileSync(snapshotPath, 'utf8')
82
+ .split('\n')
83
+ .map(l => l.trim())
84
+ .filter(l => l.length > 0)
85
+ .map(rel => path.join(repoRoot, rel));
86
+ }
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // Project type detection
90
+ // ---------------------------------------------------------------------------
91
+
92
+ function detectProjectType(repoRoot) {
93
+ if (fs.existsSync(path.join(repoRoot, 'package.json'))) return 'node';
94
+ if (fs.existsSync(path.join(repoRoot, 'pyproject.toml'))) return 'python';
95
+ if (fs.existsSync(path.join(repoRoot, 'Cargo.toml'))) return 'rust';
96
+ if (fs.existsSync(path.join(repoRoot, 'go.mod'))) return 'go';
97
+ return 'unknown';
98
+ }
99
+
100
+ function hasNpmScript(repoRoot, scriptName) {
101
+ try {
102
+ const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8'));
103
+ return !!(pkg.scripts && pkg.scripts[scriptName]);
104
+ } catch (_) {
105
+ return false;
106
+ }
107
+ }
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // Command builders per project type
111
+ // ---------------------------------------------------------------------------
112
+
113
+ function buildCommands(repoRoot, projectType, snapshotFiles, cfg) {
114
+ const cmds = {};
115
+
116
+ if (projectType === 'node') {
117
+ // build
118
+ if (cfg.build_command) {
119
+ cmds.build = cfg.build_command;
120
+ } else if (hasNpmScript(repoRoot, 'build')) {
121
+ cmds.build = 'npm run build';
122
+ }
123
+ // test — always use snapshot files, never test-discovery flags
124
+ if (cfg.test_command) {
125
+ cmds.test = cfg.test_command;
126
+ } else if (snapshotFiles.length > 0) {
127
+ cmds.test = ['node', '--test', ...snapshotFiles];
128
+ }
129
+ // typecheck
130
+ if (cfg.typecheck_command) {
131
+ cmds.typecheck = cfg.typecheck_command;
132
+ } else {
133
+ // only add if tsc is available
134
+ cmds.typecheck = 'npx tsc --noEmit';
135
+ }
136
+ // lint
137
+ if (cfg.lint_command) {
138
+ cmds.lint = cfg.lint_command;
139
+ } else if (hasNpmScript(repoRoot, 'lint')) {
140
+ cmds.lint = 'npm run lint';
141
+ }
142
+
143
+ } else if (projectType === 'python') {
144
+ // build: skip
145
+ if (cfg.build_command) cmds.build = cfg.build_command;
146
+ // test
147
+ if (cfg.test_command) {
148
+ cmds.test = cfg.test_command;
149
+ } else if (snapshotFiles.length > 0) {
150
+ cmds.test = ['pytest', ...snapshotFiles];
151
+ } else {
152
+ cmds.test = 'pytest';
153
+ }
154
+ // typecheck
155
+ if (cfg.typecheck_command) {
156
+ cmds.typecheck = cfg.typecheck_command;
157
+ } else {
158
+ cmds.typecheck = 'mypy .';
159
+ }
160
+ // lint
161
+ if (cfg.lint_command) {
162
+ cmds.lint = cfg.lint_command;
163
+ } else {
164
+ cmds.lint = 'ruff check .';
165
+ }
166
+
167
+ } else if (projectType === 'rust') {
168
+ cmds.build = cfg.build_command || 'cargo build';
169
+ cmds.test = cfg.test_command || 'cargo test';
170
+ // typecheck: skip (cargo build covers it)
171
+ if (cfg.typecheck_command) cmds.typecheck = cfg.typecheck_command;
172
+ cmds.lint = cfg.lint_command || 'cargo clippy';
173
+
174
+ } else if (projectType === 'go') {
175
+ cmds.build = cfg.build_command || 'go build ./...';
176
+ // go test doesn't work well with individual file paths — use packages
177
+ cmds.test = cfg.test_command || 'go test ./...';
178
+ // typecheck: skip
179
+ if (cfg.typecheck_command) cmds.typecheck = cfg.typecheck_command;
180
+ cmds.lint = cfg.lint_command || 'go vet ./...';
181
+
182
+ } else {
183
+ // unknown: only use config overrides
184
+ if (cfg.build_command) cmds.build = cfg.build_command;
185
+ if (cfg.test_command) cmds.test = cfg.test_command;
186
+ if (cfg.typecheck_command) cmds.typecheck = cfg.typecheck_command;
187
+ if (cfg.lint_command) cmds.lint = cfg.lint_command;
188
+ }
189
+
190
+ return cmds;
191
+ }
192
+
193
+ // ---------------------------------------------------------------------------
194
+ // Command runner
195
+ // ---------------------------------------------------------------------------
196
+
197
+ // Parse a string command into [executable, ...args] safely
198
+ function parseCommand(cmd) {
199
+ // Very simple tokenizer — handles quoted strings and plain tokens
200
+ const tokens = [];
201
+ let current = '';
202
+ let inQuote = null;
203
+
204
+ for (let i = 0; i < cmd.length; i++) {
205
+ const ch = cmd[i];
206
+ if (inQuote) {
207
+ if (ch === inQuote) { inQuote = null; }
208
+ else { current += ch; }
209
+ } else if (ch === '"' || ch === "'") {
210
+ inQuote = ch;
211
+ } else if (ch === ' ') {
212
+ if (current) { tokens.push(current); current = ''; }
213
+ } else {
214
+ current += ch;
215
+ }
216
+ }
217
+ if (current) tokens.push(current);
218
+ return tokens;
219
+ }
220
+
221
+ /**
222
+ * Run a command (string or array). Returns { ok, log }.
223
+ * Captures stdout+stderr combined for the log.
224
+ */
225
+ function runCommand(cmd, cwd) {
226
+ const args = Array.isArray(cmd) ? cmd : parseCommand(cmd);
227
+ const [exe, ...rest] = args;
228
+
229
+ const result = spawnSync(exe, rest, {
230
+ cwd,
231
+ encoding: 'utf8',
232
+ stdio: ['ignore', 'pipe', 'pipe'],
233
+ shell: false,
234
+ });
235
+
236
+ const log = ((result.stdout || '') + (result.stderr || '')).trim();
237
+
238
+ if (result.error) {
239
+ // executable not found / not available — treat as skip
240
+ return { ok: null, log: result.error.message };
241
+ }
242
+
243
+ return { ok: result.status === 0, log };
244
+ }
245
+
246
+ /**
247
+ * Check if an executable is available on PATH.
248
+ */
249
+ function commandExists(exe) {
250
+ const result = spawnSync(process.platform === 'win32' ? 'where' : 'which', [exe], {
251
+ encoding: 'utf8',
252
+ stdio: ['ignore', 'pipe', 'ignore'],
253
+ });
254
+ return result.status === 0;
255
+ }
256
+
257
+ // ---------------------------------------------------------------------------
258
+ // Auto-revert
259
+ // ---------------------------------------------------------------------------
260
+
261
+ function autoRevert(cwd) {
262
+ try {
263
+ spawnSync('git', ['revert', 'HEAD', '--no-edit'], {
264
+ cwd,
265
+ stdio: 'ignore',
266
+ });
267
+ } catch (_) {
268
+ // best-effort
269
+ }
270
+ }
271
+
272
+ // ---------------------------------------------------------------------------
273
+ // Health-check stages in order
274
+ // ---------------------------------------------------------------------------
275
+
276
+ const STAGE_ORDER = ['build', 'test', 'typecheck', 'lint'];
277
+
278
+ // Stages where failure is SALVAGEABLE (not FAIL)
279
+ const SALVAGEABLE_STAGES = new Set(['lint']);
280
+
281
+ // ---------------------------------------------------------------------------
282
+ // CLI argument parser
283
+ // ---------------------------------------------------------------------------
284
+
285
+ function parseArgs(argv) {
286
+ const args = { task: null, worktree: null, snapshot: null };
287
+ for (let i = 0; i < argv.length; i++) {
288
+ if (argv[i] === '--task' && argv[i + 1]) {
289
+ args.task = argv[++i];
290
+ } else if (argv[i] === '--worktree' && argv[i + 1]) {
291
+ args.worktree = argv[++i];
292
+ } else if (argv[i] === '--snapshot' && argv[i + 1]) {
293
+ args.snapshot = argv[++i];
294
+ }
295
+ }
296
+ return args;
297
+ }
298
+
299
+ // ---------------------------------------------------------------------------
300
+ // PLAN.md updater
301
+ // ---------------------------------------------------------------------------
302
+
303
+ function updatePlanMd(repoRoot, taskId, cwd) {
304
+ const planPath = path.join(repoRoot, 'PLAN.md');
305
+ if (!fs.existsSync(planPath)) return;
306
+
307
+ let hash = '';
308
+ try {
309
+ hash = execFileSync('git', ['rev-parse', '--short', 'HEAD'], {
310
+ encoding: 'utf8',
311
+ cwd,
312
+ stdio: ['ignore', 'pipe', 'ignore'],
313
+ }).trim();
314
+ } catch (_) {
315
+ // best-effort
316
+ }
317
+
318
+ const text = fs.readFileSync(planPath, 'utf8');
319
+ // Match lines like: - [ ] **T54** ...
320
+ const re = new RegExp(`(^.*- \\[ \\].*\\*\\*${taskId}\\*\\*.*)`, 'm');
321
+ const updated = text.replace(re, (line) => {
322
+ let result = line.replace('- [ ]', '- [x]');
323
+ if (hash) result += ` (${hash})`;
324
+ return result;
325
+ });
326
+
327
+ if (updated !== text) {
328
+ fs.writeFileSync(planPath, updated, 'utf8');
329
+ }
330
+ }
331
+
332
+ // ---------------------------------------------------------------------------
333
+ // Main
334
+ // ---------------------------------------------------------------------------
335
+
336
+ function main() {
337
+ const cliArgs = parseArgs(process.argv.slice(2));
338
+ const cwd = cliArgs.worktree || process.cwd();
339
+ const repoRoot = mainRepoRoot(cwd);
340
+
341
+ const cfg = loadConfig(repoRoot);
342
+ const projectType = detectProjectType(repoRoot);
343
+ const snapshotFiles = loadSnapshotFiles(repoRoot);
344
+ const cmds = buildCommands(repoRoot, projectType, snapshotFiles, cfg);
345
+ // --snapshot flag overrides the snapshot-derived test command
346
+ if (cliArgs.snapshot && fs.existsSync(cliArgs.snapshot)) {
347
+ const snapFiles = fs.readFileSync(cliArgs.snapshot, 'utf8')
348
+ .split('\n').map(l => l.trim()).filter(l => l.length > 0)
349
+ .map(rel => path.isAbsolute(rel) ? rel : path.join(repoRoot, rel));
350
+ if (snapFiles.length > 0 && projectType === 'node' && !cfg.test_command) {
351
+ cmds.test = ['node', '--test', ...snapFiles];
352
+ }
353
+ }
354
+
355
+ for (const stage of STAGE_ORDER) {
356
+ const cmd = cmds[stage];
357
+ if (!cmd) continue; // stage not applicable
358
+
359
+ // For string commands, check if the primary executable exists
360
+ if (typeof cmd === 'string') {
361
+ const exe = parseCommand(cmd)[0];
362
+ // npx is always available if npm is; skip existence check for npx
363
+ if (exe !== 'npx' && !commandExists(exe)) continue;
364
+ }
365
+
366
+ const { ok, log } = runCommand(cmd, repoRoot);
367
+
368
+ if (ok === null) {
369
+ // executable spawning error — skip stage
370
+ continue;
371
+ }
372
+
373
+ if (!ok) {
374
+ if (SALVAGEABLE_STAGES.has(stage)) {
375
+ process.stdout.write(JSON.stringify({ result: 'SALVAGEABLE', stage, log }) + '\n');
376
+ process.exit(2);
377
+ } else {
378
+ autoRevert(cwd);
379
+ process.stdout.write(JSON.stringify({ result: 'FAIL', stage, log }) + '\n');
380
+ process.exit(1);
381
+ }
382
+ }
383
+ }
384
+
385
+ process.stdout.write(JSON.stringify({ result: 'PASS' }) + '\n');
386
+ if (cliArgs.task) {
387
+ updatePlanMd(repoRoot, cliArgs.task, cwd);
388
+ }
389
+ process.exit(0);
390
+ }
391
+
392
+ main();