@claude-flow/cli 3.26.1 → 3.27.0

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "manifest": {
3
- "version": "3.26.1",
3
+ "version": "3.27.0",
4
4
  "files": {
5
5
  "auto-memory-hook.mjs": "68be7e9a9eba7bf9c4e8a230db7bf61a243b965639f8504842799d6c6ca28762",
6
6
  "hook-handler.cjs": "2a18a761f0dc4839df8b6eeadf8f0ceb6badcc5bf14414c91555711a5e9d4bdc",
@@ -8,6 +8,6 @@
8
8
  "statusline.cjs": "8abc67b7512e66fe5f8fe6cc5f099f827de6f7e2023a493f920090248d326e01"
9
9
  }
10
10
  },
11
- "signature": "WJv7lzA3G5RhBcD8z7v1+uGwPHY6aIEoIPgOW5QjH3fRHvL0vMC7tVR+1QviVQtQY0hblBYrK4bpvFqzoFukAw==",
11
+ "signature": "f0oa4b6TIdjXxPc8TFjFEXODgr4+gNsdQXc54NszbvhpR066pXyaCgxrYTsN9iRwFFY1dp6RtaUCuaMpN4NqDg==",
12
12
  "algorithm": "ed25519"
13
13
  }
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "generation": 1,
4
- "generatedAt": "2026-07-13T17:21:15.123Z",
5
- "gitSha": "eabf9530",
4
+ "generatedAt": "2026-07-14T03:21:11.654Z",
5
+ "gitSha": "87212ddb",
6
6
  "catalog": {
7
7
  "agents": 164,
8
8
  "tools": 387,
@@ -23,7 +23,11 @@ const startCommand = {
23
23
  { name: 'quiet', short: 'Q', type: 'boolean', description: 'Suppress output' },
24
24
  { name: 'background', short: 'b', type: 'boolean', description: 'Run daemon in background (detached process)', default: true },
25
25
  { name: 'foreground', short: 'f', type: 'boolean', description: 'Run daemon in foreground (blocks terminal)' },
26
- { name: 'headless', type: 'boolean', description: 'Enable headless worker execution (E2B sandbox)' },
26
+ // #2661: --headless is the explicit consent gate for scheduled AI workers.
27
+ // Without it (or daemon.aiWorkers.enabled / RUFLO_DAEMON_AI_WORKERS=1),
28
+ // every worker runs its $0 local path — the daemon never spawns
29
+ // `claude --print` merely because the Claude CLI is on PATH.
30
+ { name: 'headless', type: 'boolean', description: 'Enable AI workers (scheduled `claude --print` execution, governed by the user-global AI budget). Default: off — workers run local-only' },
27
31
  { name: 'sandbox', type: 'string', description: 'Default sandbox mode for headless workers', choices: ['strict', 'permissive', 'disabled'] },
28
32
  { name: 'max-cpu-load', type: 'string', description: 'Override maxCpuLoad resource threshold (e.g. 4.0)' },
29
33
  { name: 'min-free-memory', type: 'string', description: 'Override minFreeMemoryPercent resource threshold (e.g. 15)' },
@@ -60,6 +64,14 @@ const startCommand = {
60
64
  const isDaemonProcess = process.env.CLAUDE_FLOW_DAEMON === '1';
61
65
  // Parse resource threshold overrides from CLI flags
62
66
  const config = {};
67
+ // #2661: thread --headless into DaemonConfig so it actually gates the
68
+ // headless executor. Previously the flag was forwarded to the forked
69
+ // child but never consumed — AI workers auto-enabled whenever the
70
+ // Claude CLI was detected. Only set when true so config.json/env can
71
+ // still opt in when the flag is absent.
72
+ if (ctx.flags.headless === true) {
73
+ config.aiWorkersEnabled = true;
74
+ }
63
75
  const rawMaxCpu = ctx.flags['max-cpu-load'];
64
76
  const rawMinMem = ctx.flags['min-free-memory'];
65
77
  // Strict numeric pattern to prevent command injection when forwarding to subprocess (S1)
@@ -296,6 +308,7 @@ const startCommand = {
296
308
  status.config.ttlMs > 0
297
309
  ? `TTL: ${Math.round(status.config.ttlMs / 3600000)}h (self-shutdown)`
298
310
  : `TTL: off (runs until stopped)`,
311
+ `AI Workers: ${status.config.aiWorkersEnabled ? 'enabled (budget-capped)' : 'off (local-only, default)'}`,
299
312
  `Workers: ${status.config.workers.filter(w => w.enabled).length} enabled`,
300
313
  `Max Concurrent: ${status.config.maxConcurrent}`,
301
314
  `Max CPU Load: ${status.config.resourceThresholds.maxCpuLoad}`,
@@ -552,6 +565,21 @@ async function startBackgroundDaemon(projectRoot, quiet, forwarded = {}) {
552
565
  output.printSuccess(`Daemon started in background (PID: ${pid})`);
553
566
  output.printInfo(`Logs: ${logFile}`);
554
567
  output.printInfo(`Stop with: claude-flow daemon stop`);
568
+ // #2661: worktree-fanout warning. Each Git worktree gets its own daemon
569
+ // (per-workspace scope, #1914), so `init --start-daemon` across N
570
+ // worktrees quietly accumulates N daemons. Surface the fleet size at
571
+ // start time so the accumulation is visible where it happens.
572
+ try {
573
+ const fleet = await scanRunningDaemons();
574
+ if (fleet.length > 1) {
575
+ output.writeln();
576
+ output.printWarning(`Found ${fleet.length} ruflo daemons running across workspaces/worktrees.`);
577
+ output.printInfo('Scheduled AI workers are off by default and every AI launch is capped by the user-global budget.');
578
+ output.printInfo('Inspect: ruflo daemon status --all');
579
+ output.printInfo('Stop all: ruflo daemon stop --all');
580
+ }
581
+ }
582
+ catch { /* best-effort visibility — never fail the start */ }
555
583
  }
556
584
  return { success: true };
557
585
  }
@@ -561,13 +589,24 @@ const stopCommand = {
561
589
  description: 'Stop the worker daemon and all background workers',
562
590
  options: [
563
591
  { name: 'quiet', short: 'Q', type: 'boolean', description: 'Suppress output' },
592
+ // #2661: emergency stop for worktree-daemon fleets. Stops every ruflo
593
+ // daemon owned by the current user across ALL workspaces/worktrees.
594
+ { name: 'all', short: 'a', type: 'boolean', description: 'Stop ruflo daemons in ALL workspaces/worktrees (not just the current one)' },
564
595
  ],
565
596
  examples: [
566
- { command: 'claude-flow daemon stop', description: 'Stop the daemon' },
597
+ { command: 'claude-flow daemon stop', description: 'Stop the daemon in this workspace' },
598
+ { command: 'claude-flow daemon stop --all', description: 'Stop ruflo daemons in every workspace/worktree' },
567
599
  ],
568
600
  action: async (ctx) => {
569
601
  const quiet = ctx.flags.quiet;
570
602
  const projectRoot = process.cwd();
603
+ // #2661: `stop --all` — the containment lever for daemon fanout across
604
+ // Git worktrees. Only processes positively identified as ruflo daemons
605
+ // (via their self-identifying argv) are touched; each receives SIGTERM
606
+ // so its own shutdown path reaps in-flight Claude process groups.
607
+ if (ctx.flags.all) {
608
+ return stopAllDaemons(quiet);
609
+ }
571
610
  try {
572
611
  if (!quiet) {
573
612
  const spinner = output.createSpinner({ text: 'Stopping worker daemon...', spinner: 'dots' });
@@ -593,6 +632,81 @@ const stopCommand = {
593
632
  }
594
633
  },
595
634
  };
635
+ /**
636
+ * #2661: stop every running ruflo daemon across all workspaces/worktrees.
637
+ *
638
+ * Reuses the same positive identification as `daemon status --all`
639
+ * (scanRunningDaemons): a process is only touched when its command line is
640
+ * self-identifying as a ruflo daemon (`daemon start --foreground` +
641
+ * claude-flow markers). Interactive Claude sessions and non-ruflo processes
642
+ * are never candidates. Each daemon gets SIGTERM first — its own shutdown
643
+ * handler cancels in-flight headless Claude process groups and removes its
644
+ * PID file — with a SIGKILL fallback for daemons that don't exit within 2s.
645
+ * Only ruflo-owned registry entries (each workspace's daemon.pid) are removed.
646
+ */
647
+ async function stopAllDaemons(quiet) {
648
+ // Stop any in-process daemon plus this workspace's tracked daemon first,
649
+ // matching plain `daemon stop` semantics for the current directory.
650
+ try {
651
+ await stopDaemon();
652
+ }
653
+ catch { /* not running in-process */ }
654
+ await killBackgroundDaemon(process.cwd());
655
+ const daemons = await scanRunningDaemons();
656
+ if (daemons.length === 0) {
657
+ if (!quiet) {
658
+ output.printInfo('No ruflo daemons are running in any workspace.');
659
+ }
660
+ return { success: true, data: { stopped: 0 } };
661
+ }
662
+ const isWin = process.platform === 'win32';
663
+ let stopped = 0;
664
+ for (const d of daemons) {
665
+ try {
666
+ if (isWin) {
667
+ const { execFileSync } = await import('child_process');
668
+ // /t terminates the daemon's child tree too (no /f: graceful first).
669
+ execFileSync('taskkill', ['/pid', String(d.pid), '/t'], { encoding: 'utf-8', timeout: 5000 });
670
+ }
671
+ else {
672
+ process.kill(d.pid, 'SIGTERM');
673
+ }
674
+ stopped++;
675
+ if (!quiet) {
676
+ output.printInfo(`Stopping daemon PID ${d.pid}${d.workspace ? ` (${d.workspace})` : ''}`);
677
+ }
678
+ }
679
+ catch { /* exited between scan and kill */ }
680
+ }
681
+ // Give SIGTERM handlers time to reap children and clean up, then
682
+ // force-kill anything still alive (POSIX; taskkill /t already recursed).
683
+ await new Promise((r) => setTimeout(r, 2000));
684
+ for (const d of daemons) {
685
+ if (!isWin && isProcessRunning(d.pid)) {
686
+ try {
687
+ process.kill(d.pid, 'SIGKILL');
688
+ }
689
+ catch { /* already dead */ }
690
+ }
691
+ // Remove the ruflo-owned PID file for that workspace — but only when it
692
+ // still points at the daemon we just stopped (never clobber a newer one).
693
+ if (d.workspace) {
694
+ try {
695
+ const pidFile = join(d.workspace, '.claude-flow', 'daemon.pid');
696
+ if (fs.existsSync(pidFile)) {
697
+ const filePid = parseInt(fs.readFileSync(pidFile, 'utf-8').trim(), 10);
698
+ if (filePid === d.pid)
699
+ fs.unlinkSync(pidFile);
700
+ }
701
+ }
702
+ catch { /* workspace removed or unreadable — nothing to clean */ }
703
+ }
704
+ }
705
+ if (!quiet) {
706
+ output.printSuccess(`Stopped ${stopped} ruflo daemon(s) across all workspaces.`);
707
+ }
708
+ return { success: true, data: { stopped } };
709
+ }
596
710
  /**
597
711
  * Kill background daemon process using PID file
598
712
  */
@@ -893,6 +1007,31 @@ async function renderAllDaemonsStatus() {
893
1007
  else {
894
1008
  output.printInfo(`${daemons.length} daemon(s) running, all within their TTL.`);
895
1009
  }
1010
+ if (daemons.length > 1) {
1011
+ output.printInfo('Stop all daemons across workspaces with: ruflo daemon stop --all');
1012
+ }
1013
+ // #2661: user-global AI launch usage — the shared budget every daemon
1014
+ // draws from, independent of worktree count.
1015
+ try {
1016
+ const { getGlobalAiBudget } = await import('../services/global-ai-budget.js');
1017
+ const budget = getGlobalAiBudget();
1018
+ const usage = budget.getUsage();
1019
+ const limits = budget.getLimits();
1020
+ output.writeln();
1021
+ // #2661: per-workspace 24h launch attribution — which worktree is
1022
+ // actually spending the shared budget.
1023
+ const byWs = usage.byWorkspace.slice(0, 5).map((w) => ` ${w.launches}× ${w.workspace}`);
1024
+ output.printBox([
1025
+ `Launches (last hour): ${usage.lastHour}/${limits.maxLaunchesPerHour}`,
1026
+ `Launches (last 24h): ${usage.lastDay}/${limits.maxLaunchesPerDay}`,
1027
+ `Active Claude children: ${usage.active}/${limits.maxConcurrentGlobal}`,
1028
+ usage.pausedUntil
1029
+ ? output.warning(`PAUSED until ${new Date(usage.pausedUntil).toISOString()} (${usage.pauseReason ?? 'quota error'})`)
1030
+ : `Circuit breaker: ${output.dim('closed (normal)')}`,
1031
+ ...(byWs.length > 0 ? ['Launches by workspace (24h):', ...byWs] : []),
1032
+ ].join('\n'), 'Global AI Budget (all workspaces)');
1033
+ }
1034
+ catch { /* budget ledger unavailable — skip the panel */ }
896
1035
  return { success: true, data: { daemons: rows.length } };
897
1036
  }
898
1037
  // Status subcommand
@@ -929,6 +1068,21 @@ const statusCommand = {
929
1068
  const bgRunning = bgPid ? isProcessRunning(bgPid) : false;
930
1069
  const isRunning = status.running || bgRunning;
931
1070
  const displayPid = bgPid || status.pid;
1071
+ // #2661: this CLI process constructs its own (default-config) daemon
1072
+ // instance, so status.config.aiWorkersEnabled reflects THIS process,
1073
+ // not the running background daemon. The background daemon persists
1074
+ // its real consent state into daemon-state.json — prefer that when it
1075
+ // is the one running.
1076
+ let aiWorkersEnabled = status.config.aiWorkersEnabled;
1077
+ if (bgRunning) {
1078
+ try {
1079
+ const st = JSON.parse(fs.readFileSync(join(projectRoot, '.claude-flow', 'daemon-state.json'), 'utf-8'));
1080
+ if (typeof st?.config?.aiWorkersEnabled === 'boolean') {
1081
+ aiWorkersEnabled = st.config.aiWorkersEnabled;
1082
+ }
1083
+ }
1084
+ catch { /* no/partial state — fall back to in-process config */ }
1085
+ }
932
1086
  output.writeln();
933
1087
  // Daemon status box
934
1088
  const statusIcon = isRunning ? output.success('●') : output.error('○');
@@ -941,6 +1095,9 @@ const statusCommand = {
941
1095
  status.config.ttlMs > 0
942
1096
  ? `TTL: ${Math.round(status.config.ttlMs / 3600000)}h (self-shutdown)`
943
1097
  : `TTL: ${output.dim('off (runs until stopped)')}`,
1098
+ // #2661: surface the AI-consent gate so "why is audit local-only?"
1099
+ // is answerable from `daemon status` alone.
1100
+ `AI Workers: ${aiWorkersEnabled ? output.warning('enabled (budget-capped)') : output.dim('off (local-only, default)')}`,
944
1101
  `Workers Enabled: ${status.config.workers.filter(w => w.enabled).length}`,
945
1102
  `Max Concurrent: ${status.config.maxConcurrent}`,
946
1103
  `Max CPU Load: ${status.config.resourceThresholds.maxCpuLoad}`,
@@ -1043,7 +1200,10 @@ const triggerCommand = {
1043
1200
  return { success: false, exitCode: 1 };
1044
1201
  }
1045
1202
  try {
1046
- const daemon = getDaemon(process.cwd());
1203
+ // #2661: an explicit `trigger --headless` is user consent for AI
1204
+ // execution of THIS run (still governed by the global AI budget).
1205
+ // Without the flag, config.json / env opt-in still applies.
1206
+ const daemon = getDaemon(process.cwd(), ctx.flags.headless === true ? { aiWorkersEnabled: true } : undefined);
1047
1207
  const spinner = output.createSpinner({ text: `Running ${workerType} worker...`, spinner: 'dots' });
1048
1208
  spinner.start();
1049
1209
  const result = await daemon.triggerWorker(workerType);
@@ -449,9 +449,39 @@ export function generateHookHandler() {
449
449
  "const path = require('path');",
450
450
  "const fs = require('fs');",
451
451
  "const os = require('os');",
452
+ "const { spawn } = require('child_process');",
452
453
  '',
453
454
  'const helpersDir = __dirname;',
454
455
  '',
456
+ // #2661-adjacent fix: `refreshRemoteMessages()` (the funnel promo/disclosure
457
+ // pool) is fire-and-forget by design so the statusline's own short-lived
458
+ // per-render subprocess never blocks on a network call — but that also
459
+ // means it NEVER gets a chance to finish there (confirmed live: two
460
+ // consecutive cold-cache statusline renders returned promo:null and no
461
+ // cache file was ever written). `refresh-funnel` exists specifically to
462
+ // be spawned from a longer-lived context; wire that spawn here, once per
463
+ // session, detached so it survives this hook process exiting and isn't
464
+ // awaited so it never adds to the hook's own timeout budget.
465
+ //
466
+ // Deliberately always via npx (--prefer-offline avoids a registry round
467
+ // trip when already cached), never a locally-resolved bin/cli.js path:
468
+ // a fire-and-forget detached spawn has no way to recover if the first
469
+ // candidate is a broken/unbuilt local install (confirmed live — a stale
470
+ // marketplace checkout with a bin/cli.js that exists but throws
471
+ // MODULE_NOT_FOUND on its own dist/ silently ate the spawn with no
472
+ // fallback and no visible error, since stdio is intentionally ignored).
473
+ // npx resolves a real, structurally-valid published package every time.
474
+ 'function spawnFunnelRefresh() {',
475
+ ' try {',
476
+ " var cmd = process.platform === 'win32' ? 'npx.cmd' : 'npx';",
477
+ " var args = ['--prefer-offline', '@claude-flow/cli', 'hooks', 'refresh-funnel', '--quiet'];",
478
+ ' var child = spawn(cmd, args, {',
479
+ " detached: true, stdio: 'ignore', env: Object.assign({}, process.env),",
480
+ ' });',
481
+ ' child.unref();',
482
+ ' } catch (e) { /* best-effort — the statusline\'s own fallback still renders */ }',
483
+ '}',
484
+ '',
455
485
  'function safeRequire(modulePath) {',
456
486
  ' try {',
457
487
  ' if (fs.existsSync(modulePath)) {',
@@ -617,6 +647,7 @@ export function generateHookHandler() {
617
647
  ' },',
618
648
  '',
619
649
  " 'session-restore': () => {",
650
+ ' spawnFunnelRefresh();',
620
651
  ' if (session) {',
621
652
  ' var existing = session.restore && session.restore();',
622
653
  ' if (!existing) {',
@@ -0,0 +1,61 @@
1
+ /**
2
+ * #2661 — Cross-worktree AI job dedup (issue invariant 5).
3
+ *
4
+ * N worktrees of one repository checked out at the same HEAD schedule the
5
+ * same analyses independently: N audits of identical content, N optimize
6
+ * passes, N testgap sweeps. Before a model launch, callers compute
7
+ *
8
+ * jobKey = sha256(repositoryId, head, workerType, workerConfigHash)
9
+ *
10
+ * and skip the launch when the same job succeeded within the freshness
11
+ * window. HEAD moves → new key → the job runs again.
12
+ *
13
+ * The registry lives next to the AI budget ledger under the user's home
14
+ * directory (owner-only, symlink-rejecting) so all daemons share it:
15
+ *
16
+ * ~/.claude-flow/ai-jobs.json
17
+ *
18
+ * This is a best-effort OPTIMIZATION layered under the budget: two daemons
19
+ * racing the same key may both miss and both attempt a launch, but the
20
+ * budget's atomic reservation (maxConcurrentGlobal / hourly cap) is the hard
21
+ * invariant that bounds actual launches. Only operational metadata is
22
+ * persisted — never prompts, outputs, or source content.
23
+ */
24
+ export interface AiJobKeyParts {
25
+ repositoryId: string;
26
+ head: string;
27
+ workerType: string;
28
+ /** Hash of the effective worker config (prompt/model/sandbox/patterns). */
29
+ configHash: string;
30
+ }
31
+ export declare function computeAiJobKey(parts: AiJobKeyParts): string;
32
+ /** Stable hash of an arbitrary config object (key-sorted JSON). */
33
+ export declare function hashWorkerConfig(config: unknown): string;
34
+ export declare class AiJobDedupRegistry {
35
+ private readonly dir;
36
+ private readonly file;
37
+ constructor(options?: {
38
+ baseDir?: string;
39
+ });
40
+ /**
41
+ * True when the job succeeded within `freshnessMs`. Any registry error
42
+ * reads as "not fresh" — dedup failing open only costs a (budget-capped)
43
+ * launch, never correctness.
44
+ */
45
+ isFresh(jobKey: string, freshnessMs: number): {
46
+ fresh: boolean;
47
+ lastRunAt?: number;
48
+ };
49
+ /** Record a successful run of a job. Best-effort. */
50
+ recordSuccess(jobKey: string, meta: {
51
+ workerType: string;
52
+ repositoryId: string;
53
+ workspace: string;
54
+ }): void;
55
+ private read;
56
+ private write;
57
+ }
58
+ export declare function getAiJobDedupRegistry(): AiJobDedupRegistry;
59
+ /** Test hook: reset the singleton (e.g. after changing RUFLO_AI_BUDGET_DIR). */
60
+ export declare function resetAiJobDedupRegistryForTests(): void;
61
+ //# sourceMappingURL=ai-job-dedup.d.ts.map
@@ -0,0 +1,136 @@
1
+ /**
2
+ * #2661 — Cross-worktree AI job dedup (issue invariant 5).
3
+ *
4
+ * N worktrees of one repository checked out at the same HEAD schedule the
5
+ * same analyses independently: N audits of identical content, N optimize
6
+ * passes, N testgap sweeps. Before a model launch, callers compute
7
+ *
8
+ * jobKey = sha256(repositoryId, head, workerType, workerConfigHash)
9
+ *
10
+ * and skip the launch when the same job succeeded within the freshness
11
+ * window. HEAD moves → new key → the job runs again.
12
+ *
13
+ * The registry lives next to the AI budget ledger under the user's home
14
+ * directory (owner-only, symlink-rejecting) so all daemons share it:
15
+ *
16
+ * ~/.claude-flow/ai-jobs.json
17
+ *
18
+ * This is a best-effort OPTIMIZATION layered under the budget: two daemons
19
+ * racing the same key may both miss and both attempt a launch, but the
20
+ * budget's atomic reservation (maxConcurrentGlobal / hourly cap) is the hard
21
+ * invariant that bounds actual launches. Only operational metadata is
22
+ * persisted — never prompts, outputs, or source content.
23
+ */
24
+ import * as fs from 'fs';
25
+ import { join } from 'path';
26
+ import { homedir } from 'os';
27
+ import { createHash } from 'crypto';
28
+ const DAY_MS = 24 * 60 * 60 * 1000;
29
+ export function computeAiJobKey(parts) {
30
+ return createHash('sha256')
31
+ .update([parts.repositoryId, parts.head, parts.workerType, parts.configHash].join('\n'))
32
+ .digest('hex');
33
+ }
34
+ /** Stable hash of an arbitrary config object (key-sorted JSON). */
35
+ export function hashWorkerConfig(config) {
36
+ const canonical = JSON.stringify(config, (_k, v) => {
37
+ if (v && typeof v === 'object' && !Array.isArray(v)) {
38
+ return Object.fromEntries(Object.entries(v).sort(([a], [b]) => a.localeCompare(b)));
39
+ }
40
+ return v;
41
+ });
42
+ return createHash('sha256').update(canonical ?? 'null').digest('hex');
43
+ }
44
+ /** Invariant 9: registry files must never be symlinks. */
45
+ function assertNotSymlink(path) {
46
+ try {
47
+ const st = fs.lstatSync(path);
48
+ if (st.isSymbolicLink()) {
49
+ throw new Error(`AI job registry is a symlink (refusing): ${path}`);
50
+ }
51
+ }
52
+ catch (e) {
53
+ if (e.code === 'ENOENT')
54
+ return;
55
+ throw e;
56
+ }
57
+ }
58
+ export class AiJobDedupRegistry {
59
+ dir;
60
+ file;
61
+ constructor(options) {
62
+ this.dir = options?.baseDir
63
+ ?? process.env.RUFLO_AI_BUDGET_DIR
64
+ ?? join(homedir(), '.claude-flow');
65
+ this.file = join(this.dir, 'ai-jobs.json');
66
+ }
67
+ /**
68
+ * True when the job succeeded within `freshnessMs`. Any registry error
69
+ * reads as "not fresh" — dedup failing open only costs a (budget-capped)
70
+ * launch, never correctness.
71
+ */
72
+ isFresh(jobKey, freshnessMs) {
73
+ if (process.env.RUFLO_AI_DEDUP_DISABLE === '1')
74
+ return { fresh: false };
75
+ try {
76
+ const records = this.read();
77
+ const rec = records[jobKey];
78
+ if (rec && Date.now() - rec.at < freshnessMs) {
79
+ return { fresh: true, lastRunAt: rec.at };
80
+ }
81
+ return { fresh: false, lastRunAt: rec?.at };
82
+ }
83
+ catch {
84
+ return { fresh: false };
85
+ }
86
+ }
87
+ /** Record a successful run of a job. Best-effort. */
88
+ recordSuccess(jobKey, meta) {
89
+ try {
90
+ const records = this.read();
91
+ records[jobKey] = { at: Date.now(), ...meta };
92
+ this.write(records);
93
+ }
94
+ catch { /* dedup is an optimization — never block on it */ }
95
+ }
96
+ read() {
97
+ assertNotSymlink(this.file);
98
+ if (!fs.existsSync(this.file))
99
+ return {};
100
+ const raw = JSON.parse(fs.readFileSync(this.file, 'utf-8'));
101
+ if (!raw || typeof raw !== 'object')
102
+ return {};
103
+ // Prune anything older than 24h — freshness windows are far shorter,
104
+ // and HEAD churn would otherwise grow the file without bound.
105
+ const now = Date.now();
106
+ const out = {};
107
+ for (const [key, rec] of Object.entries(raw)) {
108
+ if (rec && typeof rec.at === 'number' && now - rec.at < DAY_MS) {
109
+ out[key] = rec;
110
+ }
111
+ }
112
+ return out;
113
+ }
114
+ write(records) {
115
+ if (!fs.existsSync(this.dir)) {
116
+ fs.mkdirSync(this.dir, { recursive: true, mode: 0o700 });
117
+ }
118
+ assertNotSymlink(this.file);
119
+ const tmp = `${this.file}.tmp.${process.pid}`;
120
+ fs.writeFileSync(tmp, JSON.stringify(records), { mode: 0o600 });
121
+ fs.renameSync(tmp, this.file);
122
+ }
123
+ }
124
+ // Singleton — one registry per process.
125
+ let registryInstance = null;
126
+ export function getAiJobDedupRegistry() {
127
+ if (!registryInstance) {
128
+ registryInstance = new AiJobDedupRegistry();
129
+ }
130
+ return registryInstance;
131
+ }
132
+ /** Test hook: reset the singleton (e.g. after changing RUFLO_AI_BUDGET_DIR). */
133
+ export function resetAiJobDedupRegistryForTests() {
134
+ registryInstance = null;
135
+ }
136
+ //# sourceMappingURL=ai-job-dedup.js.map
@@ -0,0 +1,42 @@
1
+ /**
2
+ * #2661 — Git workspace identity: separate WORKTREE identity from
3
+ * REPOSITORY identity.
4
+ *
5
+ * Daemon dedup, state, and scheduling have historically been keyed on the
6
+ * worktree path (`process.cwd()`), so N Git worktrees of the same repository
7
+ * behave as N unrelated projects — the cardinality bug behind the worktree
8
+ * daemon fanout. This service resolves the identity that is SHARED across
9
+ * worktrees:
10
+ *
11
+ * worktreeRoot `git rev-parse --show-toplevel` — per-worktree
12
+ * commonGitDir `git rev-parse --git-common-dir` — shared by all worktrees
13
+ * repositoryId sha256(canonical commonGitDir) — stable repo key
14
+ * head `git rev-parse HEAD` — current commit
15
+ *
16
+ * Two worktrees of one repository resolve to the SAME repositoryId (and,
17
+ * when checked out at the same commit, the same head) — the key ingredient
18
+ * for cross-worktree job dedup (issue invariant 5).
19
+ *
20
+ * Non-git directories degrade gracefully: repositoryId falls back to a hash
21
+ * of the resolved directory path (prefixed `dir:`-style via isGit=false), so
22
+ * callers never need a special case.
23
+ */
24
+ export interface GitWorkspaceIdentity {
25
+ /** Absolute root of this worktree (or the input dir when not a git repo). */
26
+ worktreeRoot: string;
27
+ /** Absolute path of the shared .git directory (equals worktreeRoot/.git for non-worktree clones). */
28
+ commonGitDir: string;
29
+ /** Stable id shared by ALL worktrees of one repository. */
30
+ repositoryId: string;
31
+ /** Current HEAD commit sha ('' when not a git repo or unborn HEAD). */
32
+ head: string;
33
+ /** False when the directory is not inside a git repository. */
34
+ isGit: boolean;
35
+ }
36
+ /**
37
+ * Resolve the git workspace identity for a directory. Never throws.
38
+ */
39
+ export declare function resolveGitWorkspaceIdentity(dir: string): GitWorkspaceIdentity;
40
+ /** Test hook: clear the per-process identity cache. */
41
+ export declare function resetGitIdentityCacheForTests(): void;
42
+ //# sourceMappingURL=git-workspace-identity.d.ts.map
@@ -0,0 +1,99 @@
1
+ /**
2
+ * #2661 — Git workspace identity: separate WORKTREE identity from
3
+ * REPOSITORY identity.
4
+ *
5
+ * Daemon dedup, state, and scheduling have historically been keyed on the
6
+ * worktree path (`process.cwd()`), so N Git worktrees of the same repository
7
+ * behave as N unrelated projects — the cardinality bug behind the worktree
8
+ * daemon fanout. This service resolves the identity that is SHARED across
9
+ * worktrees:
10
+ *
11
+ * worktreeRoot `git rev-parse --show-toplevel` — per-worktree
12
+ * commonGitDir `git rev-parse --git-common-dir` — shared by all worktrees
13
+ * repositoryId sha256(canonical commonGitDir) — stable repo key
14
+ * head `git rev-parse HEAD` — current commit
15
+ *
16
+ * Two worktrees of one repository resolve to the SAME repositoryId (and,
17
+ * when checked out at the same commit, the same head) — the key ingredient
18
+ * for cross-worktree job dedup (issue invariant 5).
19
+ *
20
+ * Non-git directories degrade gracefully: repositoryId falls back to a hash
21
+ * of the resolved directory path (prefixed `dir:`-style via isGit=false), so
22
+ * callers never need a special case.
23
+ */
24
+ import { execFileSync } from 'child_process';
25
+ import { createHash } from 'crypto';
26
+ import { resolve } from 'path';
27
+ import * as fs from 'fs';
28
+ const GIT_TIMEOUT_MS = 3000;
29
+ function git(cwd, ...args) {
30
+ try {
31
+ return execFileSync('git', args, {
32
+ cwd,
33
+ encoding: 'utf-8',
34
+ timeout: GIT_TIMEOUT_MS,
35
+ stdio: ['ignore', 'pipe', 'ignore'],
36
+ windowsHide: true,
37
+ }).trim();
38
+ }
39
+ catch {
40
+ return null;
41
+ }
42
+ }
43
+ function sha256(input) {
44
+ return createHash('sha256').update(input).digest('hex');
45
+ }
46
+ // Identity is stable for the life of a process (repo location doesn't move),
47
+ // but HEAD is not — cache only the expensive, stable parts per directory.
48
+ const identityCache = new Map();
49
+ /**
50
+ * Resolve the git workspace identity for a directory. Never throws.
51
+ */
52
+ export function resolveGitWorkspaceIdentity(dir) {
53
+ const resolved = resolve(dir);
54
+ let stable = identityCache.get(resolved);
55
+ if (!stable) {
56
+ const worktreeRoot = git(resolved, 'rev-parse', '--show-toplevel');
57
+ if (!worktreeRoot) {
58
+ stable = {
59
+ worktreeRoot: resolved,
60
+ commonGitDir: '',
61
+ // 'dir:' prefix keeps non-git ids from ever colliding with repo ids.
62
+ repositoryId: sha256(`dir:${canonicalPath(resolved)}`),
63
+ isGit: false,
64
+ };
65
+ }
66
+ else {
67
+ // --git-common-dir may be relative to the worktree root (git < 2.31
68
+ // and some invocation contexts) — resolve against it.
69
+ const rawCommon = git(worktreeRoot, 'rev-parse', '--git-common-dir') ?? '.git';
70
+ const commonGitDir = resolve(worktreeRoot, rawCommon);
71
+ stable = {
72
+ worktreeRoot,
73
+ commonGitDir,
74
+ repositoryId: sha256(`git:${canonicalPath(commonGitDir)}`),
75
+ isGit: true,
76
+ };
77
+ }
78
+ identityCache.set(resolved, stable);
79
+ }
80
+ const head = stable.isGit ? (git(stable.worktreeRoot, 'rev-parse', 'HEAD') ?? '') : '';
81
+ return { ...stable, head };
82
+ }
83
+ /**
84
+ * Canonicalize a path so the same repository yields the same repositoryId
85
+ * regardless of symlinked prefixes (/tmp vs /private/tmp on macOS, etc.).
86
+ */
87
+ function canonicalPath(p) {
88
+ try {
89
+ return fs.realpathSync(p);
90
+ }
91
+ catch {
92
+ return p;
93
+ }
94
+ }
95
+ /** Test hook: clear the per-process identity cache. */
96
+ export function resetGitIdentityCacheForTests() {
97
+ identityCache.clear();
98
+ }
99
+ //# sourceMappingURL=git-workspace-identity.js.map