deepflow 0.1.111 → 0.1.113

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
@@ -9,6 +9,18 @@ const path = require('path');
9
9
  const os = require('os');
10
10
  const readline = require('readline');
11
11
  const { execFileSync } = require('child_process');
12
+ const { scanHookEvents, removeDeepflowHooks } = require('../hooks/lib/installer-utils');
13
+
14
+ function atomicWriteFileSync(targetPath, data) {
15
+ const tmpPath = targetPath + '.tmp';
16
+ try {
17
+ fs.writeFileSync(tmpPath, data);
18
+ fs.renameSync(tmpPath, targetPath);
19
+ } catch (err) {
20
+ try { fs.unlinkSync(tmpPath); } catch (_) {}
21
+ throw err;
22
+ }
23
+ }
12
24
 
13
25
  // Legacy subcommand: `deepflow auto` is now `/df:auto` inside Claude Code
14
26
  if (process.argv[2] === 'auto') {
@@ -35,23 +47,6 @@ const GLOBAL_DIR = path.join(os.homedir(), '.claude');
35
47
  const PROJECT_DIR = path.join(process.cwd(), '.claude');
36
48
  const PACKAGE_DIR = path.resolve(__dirname, '..');
37
49
 
38
- /**
39
- * Atomically write data to targetPath using a write-to-temp + rename pattern.
40
- * If the write fails, the original file is left untouched and the temp file is
41
- * cleaned up. Temp file is created in the same directory as the target so the
42
- * rename is within the same filesystem (atomic on POSIX).
43
- */
44
- function atomicWriteFileSync(targetPath, data) {
45
- const tmpPath = targetPath + '.tmp';
46
- try {
47
- fs.writeFileSync(tmpPath, data);
48
- fs.renameSync(tmpPath, targetPath);
49
- } catch (err) {
50
- try { fs.unlinkSync(tmpPath); } catch (_) {}
51
- throw err;
52
- }
53
- }
54
-
55
50
  function updateGlobalPackage() {
56
51
  const currentVersion = require(path.join(PACKAGE_DIR, 'package.json')).version;
57
52
  try {
@@ -297,77 +292,46 @@ function copyDir(src, dest) {
297
292
  }
298
293
  }
299
294
 
300
- // Valid hook events (settings.hooks keys + special "statusLine")
301
- const VALID_HOOK_EVENTS = new Set([
302
- 'SessionStart', 'SessionEnd', 'PreToolUse', 'PostToolUse', 'SubagentStop', 'statusLine'
303
- ]);
304
-
305
295
  /**
306
- * Scan hook source files for @hook-event tags. Returns:
307
- * { eventMap: Map<event, [filename, ...]>, untagged: [filename, ...] }
296
+ * Returns true if settings.json contains any hook commands that reference a
297
+ * dashboard-owned hook file (identified by @hook-owner: dashboard in its source).
298
+ * Checks both settings.hooks.* entries and settings.statusLine.
308
299
  */
309
- function scanHookEvents(hooksSourceDir) {
310
- const eventMap = new Map(); // event → [filenames]
311
- const untagged = [];
300
+ function detectDashboardHooks(settings, claudeDir) {
301
+ const hooksInstallDir = path.join(claudeDir, 'hooks');
302
+ if (!fs.existsSync(hooksInstallDir)) return false;
312
303
 
313
- if (!fs.existsSync(hooksSourceDir)) return { eventMap, untagged };
314
-
315
- for (const file of fs.readdirSync(hooksSourceDir)) {
316
- if (!file.endsWith('.js') || file.endsWith('.test.js')) continue;
317
-
318
- const content = fs.readFileSync(path.join(hooksSourceDir, file), 'utf8');
319
- const firstLines = content.split('\n').slice(0, 10).join('\n');
320
- const match = firstLines.match(/\/\/\s*@hook-event:\s*(.+)/);
321
-
322
- if (!match) {
323
- untagged.push(file);
324
- continue;
325
- }
326
-
327
- const events = match[1].split(',').map(e => e.trim()).filter(Boolean);
328
- let hasValidEvent = false;
329
-
330
- for (const event of events) {
331
- if (!VALID_HOOK_EVENTS.has(event)) {
332
- console.log(` ${c.yellow}!${c.reset} Warning: unknown event "${event}" in ${file} — skipped`);
333
- continue;
304
+ // Collect all command strings currently wired in settings
305
+ const wiredCommands = [];
306
+ if (settings.hooks) {
307
+ for (const entries of Object.values(settings.hooks)) {
308
+ for (const hook of entries) {
309
+ const cmd = hook.hooks?.[0]?.command;
310
+ if (cmd) wiredCommands.push(cmd);
334
311
  }
335
- hasValidEvent = true;
336
- if (!eventMap.has(event)) eventMap.set(event, []);
337
- eventMap.get(event).push(file);
338
- }
339
-
340
- if (!hasValidEvent) {
341
- untagged.push(file);
342
312
  }
343
313
  }
344
-
345
- return { eventMap, untagged };
346
- }
347
-
348
- /**
349
- * Remove all deepflow hook entries (commands containing /hooks/df-) from settings.
350
- * Preserves non-deepflow hooks.
351
- */
352
- function removeDeepflowHooks(settings) {
353
- const isDeepflow = (hook) => {
354
- const cmd = hook.hooks?.[0]?.command || '';
355
- return cmd.includes('/hooks/df-');
356
- };
357
-
358
- // Clean settings.hooks.*
359
- if (settings.hooks) {
360
- for (const event of Object.keys(settings.hooks)) {
361
- settings.hooks[event] = settings.hooks[event].filter(h => !isDeepflow(h));
362
- if (settings.hooks[event].length === 0) delete settings.hooks[event];
363
- }
364
- if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
314
+ if (settings.statusLine?.command) {
315
+ wiredCommands.push(settings.statusLine.command);
365
316
  }
366
317
 
367
- // Clean settings.statusLine if it's a deepflow hook
368
- if (settings.statusLine?.command && settings.statusLine.command.includes('/hooks/df-')) {
369
- delete settings.statusLine;
318
+ // For each wired command, resolve the hook filename and check its @hook-owner
319
+ for (const cmd of wiredCommands) {
320
+ // Commands look like: node "/path/to/.claude/hooks/df-foo.js"
321
+ const match = cmd.match(/["']?([^"'\s]+\.js)["']?\s*$/);
322
+ if (!match) continue;
323
+ const hookPath = match[1];
324
+ if (!fs.existsSync(hookPath)) continue;
325
+ try {
326
+ const content = fs.readFileSync(hookPath, 'utf8');
327
+ const firstLines = content.split('\n').slice(0, 10).join('\n');
328
+ const ownerMatch = firstLines.match(/\/\/\s*@hook-owner:\s*(.+)/);
329
+ if (ownerMatch && ownerMatch[1].trim() === 'dashboard') return true;
330
+ } catch (_) {
331
+ // Skip unreadable files
332
+ }
370
333
  }
334
+ return false;
371
335
  }
372
336
 
373
337
  async function configureHooks(claudeDir) {
@@ -393,8 +357,8 @@ async function configureHooks(claudeDir) {
393
357
  configurePermissions(settings);
394
358
  log('Agent permissions configured');
395
359
 
396
- // Scan hook files for @hook-event tags
397
- const { eventMap, untagged } = scanHookEvents(hooksSourceDir);
360
+ // Scan hook files for @hook-event tags — only deepflow-owned hooks
361
+ const { eventMap, untagged } = scanHookEvents(hooksSourceDir, 'deepflow');
398
362
 
399
363
  // Remember if there was a pre-existing non-deepflow statusLine
400
364
  const hadExternalStatusLine = settings.statusLine &&
@@ -403,6 +367,15 @@ async function configureHooks(claudeDir) {
403
367
  // Remove all existing deepflow hooks (orphan cleanup + idempotency)
404
368
  removeDeepflowHooks(settings);
405
369
 
370
+ // Migration warning: detect dashboard-owned hooks already wired in settings.json
371
+ // (they were installed by an older deepflow version that didn't distinguish owners)
372
+ const hasDashboardHooks = detectDashboardHooks(settings, claudeDir);
373
+ if (hasDashboardHooks) {
374
+ console.log('');
375
+ console.log(` ${c.yellow}!${c.reset} Dashboard hooks detected — run \`npx deepflow-dashboard install\` to manage them separately.`);
376
+ console.log('');
377
+ }
378
+
406
379
  // Wire hooks by event
407
380
  if (!settings.hooks) settings.hooks = {};
408
381
 
@@ -636,12 +609,23 @@ async function uninstall() {
636
609
  ];
637
610
 
638
611
  if (level === 'global') {
639
- // Dynamically find all df-*.js hook files to remove
612
+ // Dynamically find deepflow-owned hook files to remove.
613
+ // Check @hook-owner tag from the installed file; skip dashboard-owned hooks.
640
614
  const hooksDir = path.join(CLAUDE_DIR, 'hooks');
641
615
  if (fs.existsSync(hooksDir)) {
642
616
  for (const file of fs.readdirSync(hooksDir)) {
643
- if (file.startsWith('df-') && file.endsWith('.js')) {
644
- toRemove.push(`hooks/${file}`);
617
+ if (!file.startsWith('df-') || !file.endsWith('.js') || file.endsWith('.test.js')) continue;
618
+ const filePath = path.join(hooksDir, file);
619
+ try {
620
+ const content = fs.readFileSync(filePath, 'utf8');
621
+ const firstLines = content.split('\n').slice(0, 10).join('\n');
622
+ const ownerMatch = firstLines.match(/\/\/\s*@hook-owner:\s*(.+)/);
623
+ if (ownerMatch && ownerMatch[1].trim() === 'deepflow') {
624
+ toRemove.push(`hooks/${file}`);
625
+ }
626
+ // dashboard-owned hooks are intentionally left in place
627
+ } catch (_) {
628
+ // Skip unreadable files
645
629
  }
646
630
  }
647
631
  }
@@ -333,6 +333,7 @@ describe('Uninstaller — file removal and settings cleanup', () => {
333
333
  'df-dashboard-push.js',
334
334
  'df-execution-history.js',
335
335
  'df-worktree-guard.js',
336
+ 'df-harness-score.js',
336
337
  ]) {
337
338
  fs.writeFileSync(path.join(hookDir, hook), '// hook');
338
339
  }
package/bin/ratchet.js CHANGED
@@ -273,10 +273,157 @@ function autoRevert(cwd) {
273
273
  // Health-check stages in order
274
274
  // ---------------------------------------------------------------------------
275
275
 
276
- const STAGE_ORDER = ['build', 'test', 'typecheck', 'lint'];
276
+ const STAGE_ORDER = ['build', 'test', 'typecheck', 'lint', 'contract'];
277
277
 
278
278
  // Stages where failure is SALVAGEABLE (not FAIL)
279
- const SALVAGEABLE_STAGES = new Set(['lint']);
279
+ const SALVAGEABLE_STAGES = new Set(['lint', 'contract']);
280
+
281
+ // ---------------------------------------------------------------------------
282
+ // REQ-1 + REQ-8: Contract stage
283
+ // ---------------------------------------------------------------------------
284
+
285
+ /**
286
+ * Parse `Produces: path::Symbol` entries from PLAN.md.
287
+ * Returns array of { file, symbol } entries.
288
+ */
289
+ function parseProducesFromPlan(planPath) {
290
+ if (!fs.existsSync(planPath)) return [];
291
+ const text = fs.readFileSync(planPath, 'utf8');
292
+ const entries = [];
293
+ // Match: Produces: <path>::<Symbol> (tolerant to leading whitespace & markdown bullets)
294
+ const re = /Produces:\s*([^\s:`]+)::([A-Za-z_$][A-Za-z0-9_$]*)/g;
295
+ let m;
296
+ while ((m = re.exec(text)) !== null) {
297
+ entries.push({ file: m[1].trim(), symbol: m[2].trim() });
298
+ }
299
+ return entries;
300
+ }
301
+
302
+ /**
303
+ * Verify that a symbol exists in a file.
304
+ * Tries LSP documentSymbols first (if available); falls back to regex grep.
305
+ * Returns true if found, false otherwise.
306
+ */
307
+ async function verifySymbolExists(absFilePath, symbol, projectRoot) {
308
+ // LSP-first — try to use queryLsp from df-invariant-check if present
309
+ try {
310
+ const invariantPath = path.join(projectRoot, 'hooks', 'df-invariant-check.js');
311
+ if (fs.existsSync(invariantPath)) {
312
+ // eslint-disable-next-line global-require
313
+ const inv = require(invariantPath);
314
+ if (typeof inv.queryLsp === 'function' && typeof inv.detectLanguageServer === 'function') {
315
+ const detected = inv.detectLanguageServer(projectRoot, [absFilePath]);
316
+ if (detected && detected.binary) {
317
+ const fileUri = 'file://' + absFilePath;
318
+ const res = await inv.queryLsp(
319
+ detected.binary,
320
+ projectRoot,
321
+ fileUri,
322
+ 'textDocument/documentSymbol',
323
+ { textDocument: { uri: fileUri } }
324
+ );
325
+ if (res && res.ok && Array.isArray(res.result)) {
326
+ const names = flattenSymbolNames(res.result);
327
+ if (names.includes(symbol)) return true;
328
+ // LSP gave a definitive (empty/non-matching) result — do NOT fall
329
+ // back to regex when we have an authoritative answer. But if
330
+ // the list is empty, treat as inconclusive → fallback.
331
+ if (names.length > 0) return false;
332
+ }
333
+ }
334
+ }
335
+ }
336
+ } catch (_) {
337
+ // LSP unavailable — fall back
338
+ }
339
+
340
+ // Regex fallback: scan file for \bsymbol\b
341
+ try {
342
+ if (!fs.existsSync(absFilePath)) return false;
343
+ const src = fs.readFileSync(absFilePath, 'utf8');
344
+ const safe = escapeRegExp(symbol);
345
+ return new RegExp(`\\b${safe}\\b`).test(src);
346
+ } catch (_) {
347
+ return false;
348
+ }
349
+ }
350
+
351
+ function flattenSymbolNames(symbols) {
352
+ const names = [];
353
+ const walk = (arr) => {
354
+ for (const s of arr || []) {
355
+ if (s && typeof s.name === 'string') names.push(s.name);
356
+ if (s && Array.isArray(s.children)) walk(s.children);
357
+ }
358
+ };
359
+ walk(symbols);
360
+ return names;
361
+ }
362
+
363
+ /**
364
+ * Count AC-N references across snapshot test files.
365
+ */
366
+ function countAcRefsInSnapshot(snapshotFiles) {
367
+ let count = 0;
368
+ const pattern = /\bAC-\d+\b/g;
369
+ for (const f of snapshotFiles) {
370
+ try {
371
+ const src = fs.readFileSync(f, 'utf8');
372
+ const matches = src.match(pattern);
373
+ if (matches) count += matches.length;
374
+ } catch (_) {
375
+ // skip unreadable
376
+ }
377
+ }
378
+ return count;
379
+ }
380
+
381
+ /**
382
+ * Run the contract stage.
383
+ * @returns {Promise<{ ok: boolean, salvageable?: boolean, log: string }>}
384
+ * - ok:true → PASS (continue / exit success)
385
+ * - ok:false, salvageable:true → SALVAGEABLE (exit 2, no revert)
386
+ * - ok:false → FAIL
387
+ * - If no Produces: entries at all, returns ok:true (no-op).
388
+ */
389
+ async function runContractStage(repoRoot, cwd, snapshotFiles) {
390
+ // Locate PLAN.md — prefer worktree cwd, fall back to repo root
391
+ let planPath = path.join(cwd, 'PLAN.md');
392
+ if (!fs.existsSync(planPath)) planPath = path.join(repoRoot, 'PLAN.md');
393
+
394
+ const entries = parseProducesFromPlan(planPath);
395
+ if (entries.length === 0) {
396
+ return { ok: true, log: 'contract: no Produces: entries — skipped' };
397
+ }
398
+
399
+ // Verify each declared symbol exists
400
+ const missing = [];
401
+ for (const { file, symbol } of entries) {
402
+ const absPath = path.isAbsolute(file) ? file : path.join(cwd, file);
403
+ const found = await verifySymbolExists(absPath, symbol, repoRoot);
404
+ if (!found) missing.push(`${file}::${symbol}`);
405
+ }
406
+
407
+ if (missing.length > 0) {
408
+ return {
409
+ ok: false,
410
+ salvageable: true,
411
+ log: `contract: declared symbols not found: ${missing.join(', ')}`,
412
+ };
413
+ }
414
+
415
+ // Ratchet PASS + zero AC test references in snapshot → SALVAGEABLE
416
+ const acRefs = countAcRefsInSnapshot(snapshotFiles);
417
+ if (acRefs === 0) {
418
+ return {
419
+ ok: false,
420
+ salvageable: true,
421
+ log: 'contract: zero AC-N references found in ratchet snapshot test files',
422
+ };
423
+ }
424
+
425
+ return { ok: true, log: `contract: ${entries.length} symbols verified, ${acRefs} AC refs` };
426
+ }
280
427
 
281
428
  // ---------------------------------------------------------------------------
282
429
  // CLI argument parser
@@ -287,7 +434,7 @@ function escapeRegExp(value) {
287
434
  }
288
435
 
289
436
  function parseArgs(argv) {
290
- const args = { task: null, worktree: null, snapshot: null };
437
+ const args = { task: null, worktree: null, snapshot: null, stage: null };
291
438
  for (let i = 0; i < argv.length; i++) {
292
439
  if (argv[i] === '--task' && argv[i + 1]) {
293
440
  args.task = argv[++i];
@@ -295,6 +442,8 @@ function parseArgs(argv) {
295
442
  args.worktree = argv[++i];
296
443
  } else if (argv[i] === '--snapshot' && argv[i + 1]) {
297
444
  args.snapshot = argv[++i];
445
+ } else if (argv[i] === '--stage' && argv[i + 1]) {
446
+ args.stage = argv[++i];
298
447
  }
299
448
  }
300
449
  return args;
@@ -338,26 +487,51 @@ function updatePlanMd(repoRoot, taskId, cwd) {
338
487
  // Main
339
488
  // ---------------------------------------------------------------------------
340
489
 
341
- function main() {
490
+ async function main() {
342
491
  const cliArgs = parseArgs(process.argv.slice(2));
343
492
  const cwd = cliArgs.worktree || process.cwd();
344
493
  const repoRoot = mainRepoRoot(cwd);
345
494
 
346
495
  const cfg = loadConfig(repoRoot);
347
496
  const projectType = detectProjectType(repoRoot);
348
- const snapshotFiles = loadSnapshotFiles(repoRoot, cwd);
497
+ let snapshotFiles = loadSnapshotFiles(repoRoot, cwd);
349
498
  const cmds = buildCommands(repoRoot, projectType, snapshotFiles, cfg);
350
499
  // --snapshot flag overrides the snapshot-derived test command
351
500
  if (cliArgs.snapshot && fs.existsSync(cliArgs.snapshot)) {
352
501
  const snapFiles = fs.readFileSync(cliArgs.snapshot, 'utf8')
353
502
  .split('\n').map(l => l.trim()).filter(l => l.length > 0)
354
503
  .map(rel => path.isAbsolute(rel) ? rel : path.join(cwd, rel));
355
- if (snapFiles.length > 0 && projectType === 'node' && !cfg.test_command) {
356
- cmds.test = ['node', '--test', ...snapFiles];
504
+ if (snapFiles.length > 0) {
505
+ snapshotFiles = snapFiles;
506
+ if (projectType === 'node' && !cfg.test_command) {
507
+ cmds.test = ['node', '--test', ...snapFiles];
508
+ }
357
509
  }
358
510
  }
359
511
 
512
+ // --stage filter: run only the specified stage
513
+ const stageFilter = cliArgs.stage;
514
+ if (stageFilter && !STAGE_ORDER.includes(stageFilter)) {
515
+ process.stdout.write(JSON.stringify({ result: 'FAIL', stage: stageFilter, log: `unknown stage: ${stageFilter}` }) + '\n');
516
+ process.exit(1);
517
+ }
518
+
360
519
  for (const stage of STAGE_ORDER) {
520
+ if (stageFilter && stage !== stageFilter) continue;
521
+
522
+ // Contract stage is implemented in-process (no external command)
523
+ if (stage === 'contract') {
524
+ const res = await runContractStage(repoRoot, cwd, snapshotFiles);
525
+ if (res.ok) continue;
526
+ if (res.salvageable) {
527
+ process.stdout.write(JSON.stringify({ result: 'SALVAGEABLE', stage, log: res.log }) + '\n');
528
+ process.exit(2);
529
+ }
530
+ autoRevert(cwd);
531
+ process.stdout.write(JSON.stringify({ result: 'FAIL', stage, log: res.log }) + '\n');
532
+ process.exit(1);
533
+ }
534
+
361
535
  const cmd = cmds[stage];
362
536
  if (!cmd) continue; // stage not applicable
363
537
 
@@ -394,4 +568,7 @@ function main() {
394
568
  process.exit(0);
395
569
  }
396
570
 
397
- main();
571
+ main().catch((err) => {
572
+ process.stdout.write(JSON.stringify({ result: 'FAIL', stage: 'internal', log: String(err && err.stack || err) }) + '\n');
573
+ process.exit(1);
574
+ });
@@ -570,19 +570,19 @@ describe('buildCommands — unknown project type', () => {
570
570
  // 7. Health check stage ordering — source assertions
571
571
  // ---------------------------------------------------------------------------
572
572
 
573
- describe('STAGE_ORDER — build, test, typecheck, lint', () => {
573
+ describe('STAGE_ORDER — build, test, typecheck, lint, contract', () => {
574
574
  test('source defines stages in correct order', () => {
575
575
  const match = RATCHET_SRC.match(/STAGE_ORDER\s*=\s*\[([^\]]+)\]/);
576
576
  assert.ok(match, 'STAGE_ORDER constant should exist in source');
577
577
  const stages = match[1].replace(/['"]/g, '').split(',').map(s => s.trim());
578
- assert.deepEqual(stages, ['build', 'test', 'typecheck', 'lint']);
578
+ assert.deepEqual(stages, ['build', 'test', 'typecheck', 'lint', 'contract']);
579
579
  });
580
580
 
581
- test('only lint is SALVAGEABLE', () => {
581
+ test('lint and contract are SALVAGEABLE', () => {
582
582
  const match = RATCHET_SRC.match(/SALVAGEABLE_STAGES\s*=\s*new Set\(\[([^\]]+)\]\)/);
583
583
  assert.ok(match, 'SALVAGEABLE_STAGES constant should exist in source');
584
584
  const stages = match[1].replace(/['"]/g, '').split(',').map(s => s.trim());
585
- assert.deepEqual(stages, ['lint']);
585
+ assert.deepEqual(stages, ['lint', 'contract']);
586
586
  });
587
587
  });
588
588