@claude-flow/cli 3.56.5 → 3.56.7

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.
Files changed (58) hide show
  1. package/.claude/helpers/auto-memory-hook.mjs +109 -10
  2. package/.claude/helpers/helpers.manifest.json +5 -5
  3. package/.claude/helpers/hook-handler.cjs +198 -1
  4. package/.claude/helpers/intelligence.cjs +57 -4
  5. package/bin/cli.js +11 -1
  6. package/bin/mcp-server.js +11 -1
  7. package/catalog-manifest.json +2 -2
  8. package/dist/src/commands/doctor-native-addon.d.ts +23 -0
  9. package/dist/src/commands/doctor-native-addon.js +135 -0
  10. package/dist/src/commands/doctor.js +3 -0
  11. package/dist/src/commands/hooks.js +8 -1
  12. package/dist/src/commands/index.js +2 -0
  13. package/dist/src/commands/init.d.ts +2 -0
  14. package/dist/src/commands/init.js +37 -5
  15. package/dist/src/commands/mods.d.ts +3 -0
  16. package/dist/src/commands/mods.js +19 -5
  17. package/dist/src/commands/tui.d.ts +12 -0
  18. package/dist/src/commands/tui.js +98 -0
  19. package/dist/src/fs-secure.d.ts +25 -0
  20. package/dist/src/fs-secure.js +51 -2
  21. package/dist/src/init/executor.js +11 -14
  22. package/dist/src/init/helper-commands.d.ts +2 -0
  23. package/dist/src/init/helper-commands.js +42 -11
  24. package/dist/src/init/helpers-generator.js +4 -1
  25. package/dist/src/init/settings-generator.js +3 -3
  26. package/dist/src/init/settings-risk-scanner.js +25 -6
  27. package/dist/src/mcp-server.js +10 -2
  28. package/dist/src/mcp-tools/capability-brain.d.ts +1 -1
  29. package/dist/src/mcp-tools/capability-brain.js +1 -1
  30. package/dist/src/mcp-tools/hooks-tools.js +41 -15
  31. package/dist/src/mcp-tools/memory-tools.js +2 -1
  32. package/dist/src/memory/live-memory-row.d.ts +3 -1
  33. package/dist/src/memory/live-memory-row.js +3 -1
  34. package/dist/src/memory/memory-bridge.d.ts +4 -0
  35. package/dist/src/memory/memory-bridge.js +3 -2
  36. package/dist/src/memory/memory-initializer.d.ts +4 -0
  37. package/dist/src/memory/memory-initializer.js +12 -11
  38. package/dist/src/mods/claude-installs.js +7 -3
  39. package/dist/src/mods/install.js +32 -12
  40. package/dist/src/mods/plugin-resolve.js +3 -1
  41. package/dist/src/mods/probe.js +92 -4
  42. package/dist/src/services/command-recording.d.ts +17 -0
  43. package/dist/src/services/command-recording.js +91 -0
  44. package/node_modules/@claude-flow/codex/README.md +20 -3
  45. package/node_modules/@claude-flow/codex/dist/cli.js +7 -0
  46. package/node_modules/@claude-flow/codex/dist/cli.js.map +1 -1
  47. package/node_modules/@claude-flow/codex/dist/index.d.ts +3 -3
  48. package/node_modules/@claude-flow/codex/dist/index.js +1 -1
  49. package/node_modules/@claude-flow/codex/dist/initializer.d.ts +8 -3
  50. package/node_modules/@claude-flow/codex/dist/initializer.d.ts.map +1 -1
  51. package/node_modules/@claude-flow/codex/dist/initializer.js +75 -13
  52. package/node_modules/@claude-flow/codex/dist/initializer.js.map +1 -1
  53. package/node_modules/@claude-flow/codex/dist/types.d.ts +8 -0
  54. package/node_modules/@claude-flow/codex/dist/types.d.ts.map +1 -1
  55. package/node_modules/@claude-flow/codex/package.json +1 -1
  56. package/node_modules/@claude-flow/plugin-agent-federation/dist/bin.js +0 -0
  57. package/package.json +4 -4
  58. package/plugins/ruflo-console/dist/tui.mjs +271 -0
@@ -1,4 +1,6 @@
1
1
  export type HelperScope = 'project' | 'user';
2
+ /** Claude Code reads a hook's `timeout` in SECONDS; the auto-memory hooks wrote 6000-10000, i.e. up to 100 minutes (#4058). */
3
+ export declare const AUTO_MEMORY_HOOK_TIMEOUT_S = 30;
2
4
  /** Settings written into the user's home directory are user-level settings. */
3
5
  export declare function helperScopeFor(targetDir: string | undefined): HelperScope;
4
6
  /** Command that runs `node <script> <subcommand>` for a hook. */
@@ -14,6 +14,8 @@
14
14
  import * as fs from 'fs';
15
15
  import * as os from 'os';
16
16
  import * as path from 'path';
17
+ /** Claude Code reads a hook's `timeout` in SECONDS; the auto-memory hooks wrote 6000-10000, i.e. up to 100 minutes (#4058). */
18
+ export const AUTO_MEMORY_HOOK_TIMEOUT_S = 30;
17
19
  const IS_WINDOWS = process.platform === 'win32';
18
20
  const STATUSLINE_SCRIPT = '.claude/helpers/statusline.cjs';
19
21
  function canonical(dir) {
@@ -30,20 +32,38 @@ export function helperScopeFor(targetDir) {
30
32
  return 'project';
31
33
  return canonical(targetDir) === canonical(os.homedir()) ? 'user' : 'project';
32
34
  }
35
+ /**
36
+ * Windows hook command (#4045). It must not go through `cmd /c`: under Git Bash
37
+ * MSYS rewrites `/c` into a path, cmd starts interactively and executes the
38
+ * hook's stdin JSON, and any `>` in that text creates a file. A single
39
+ * `node -e` has no shell syntax to misread (no cmd, no `%VAR%`, no `<>|&$` and
40
+ * no inner double quotes), so cmd, PowerShell and Git Bash pass it through
41
+ * unchanged. The script and its subcommand stay in one string, because the
42
+ * ruflo-mods ownership rule matches `hook-handler.cjs <subcommand>`. Project
43
+ * scope prefers the project's helper and falls back to the user profile's
44
+ * (#1943); user scope never looks at the project. The helper runs as a child
45
+ * with inherited stdio so stdin, stdout and the exit status pass through (a
46
+ * `require` would skip the helper's `require.main === module` entry point).
47
+ */
48
+ function windowsHookCommand(script, subcommand, scope) {
49
+ // Ternaries, not `||`: the command carries no pipe or ampersand at all.
50
+ const home = "(e.USERPROFILE?e.USERPROFILE:e.HOME?e.HOME:'.')";
51
+ const pick = scope === 'user'
52
+ ? `f=p.join(${home},a[0]);`
53
+ : "f=p.join(e.CLAUDE_PROJECT_DIR?e.CLAUDE_PROJECT_DIR:'.',a[0]);" +
54
+ `if(!fs.existsSync(f))f=p.join(${home},a[0]);`;
55
+ const js = `var a='${[script, subcommand].filter(Boolean).join(' ')}'.split(' '),e=process.env,fs=require('fs'),p=require('path'),f;` + pick +
56
+ "var r=require('child_process').spawnSync(process.execPath,[f].concat(a.slice(1)),{stdio:'inherit'});" +
57
+ 'process.exit(r.status===null?1:r.status)';
58
+ return `node -e "${js}"`;
59
+ }
33
60
  /** Command that runs `node <script> <subcommand>` for a hook. */
34
61
  export function helperHookCommand(script, subcommand, scope, windows = IS_WINDOWS) {
35
- const winScript = script.replace(/\//g, '\\');
36
62
  const arg = subcommand ? ` ${subcommand}` : '';
37
- if (scope === 'user') {
38
- return windows
39
- ? `cmd /c "node "%USERPROFILE%\\${winScript}"${arg}"`
40
- : `sh -c 'exec node "\${HOME}/${script}"${arg}'`;
41
- }
42
- if (windows) {
43
- // cmd.exe equivalent of the sh probe below. `IF EXIST` checks the
44
- // project-local path; falls back to %USERPROFILE% if missing.
45
- return `cmd /c "IF EXIST \"%CLAUDE_PROJECT_DIR%\\${winScript}\" (node \"%CLAUDE_PROJECT_DIR%\\${winScript}\"${arg}) ELSE (node \"%USERPROFILE%\\${winScript}\"${arg})"`;
46
- }
63
+ if (windows)
64
+ return windowsHookCommand(script, subcommand, scope);
65
+ if (scope === 'user')
66
+ return `sh -c 'exec node "\${HOME}/${script}"${arg}'`;
47
67
  // POSIX sh: prefer project-local helpers, fall back to $HOME/.claude/.
48
68
  // eslint-disable-next-line no-template-curly-in-string
49
69
  const projVar = '${CLAUDE_PROJECT_DIR:-.}';
@@ -102,6 +122,17 @@ const IF_EXIST_PREAMBLE = /"IF EXIST "%CLAUDE_PROJECT_DIR%\\\.claude\\helpers\\[
102
122
  * commands (other scripts, redirects, chains) are left alone.
103
123
  */
104
124
  export function pinToUserHelpers(command, windows = IS_WINDOWS) {
125
+ // The #4045 Windows form: recognised only when it is exactly what ruflo generates.
126
+ const generated = /^node -e "var a='\.claude\/helpers\/([\w.-]+)((?: [\w:.=-]+)*)'\.split\(' '\)/.exec(command);
127
+ if (generated) {
128
+ if (!RUFLO_HELPERS.includes(generated[1]))
129
+ return null;
130
+ const script = `.claude/helpers/${generated[1]}`;
131
+ const subcommand = generated[2].trim();
132
+ return command === helperHookCommand(script, subcommand, 'project', true)
133
+ ? helperHookCommand(script, subcommand, 'user', true)
134
+ : null;
135
+ }
105
136
  const refs = [...command.matchAll(HELPER_REF)];
106
137
  if (refs.length === 0)
107
138
  return null;
@@ -1306,7 +1306,10 @@ function doStatus() {
1306
1306
  // Suppress unhandled rejection warnings from dynamic import() failures
1307
1307
  process.on('unhandledRejection', () => {});
1308
1308
 
1309
- const command = process.argv[2] || 'status';
1309
+ // Older settings.json files ran node -e with the helper imported, which puts the
1310
+ // subcommand in argv[1] (#4058); accept that shape as well as script import.
1311
+ const COMMANDS = ['import', 'sync', 'status'];
1312
+ const command = process.argv[2] || (COMMANDS.includes(process.argv[1]) ? process.argv[1] : 'status');
1310
1313
 
1311
1314
  try {
1312
1315
  switch (command) {
@@ -3,7 +3,7 @@
3
3
  * Creates .claude/settings.json with V3-optimized hook configurations
4
4
  */
5
5
  import { detectPlatform } from './types.js';
6
- import { helperHookCommand, helperScopeFor, helperStatusLineCommand } from './helper-commands.js';
6
+ import { AUTO_MEMORY_HOOK_TIMEOUT_S, helperHookCommand, helperScopeFor, helperStatusLineCommand } from './helper-commands.js';
7
7
  /**
8
8
  * Generate the complete settings.json content
9
9
  */
@@ -278,7 +278,7 @@ function generateHooksConfig(config, scope) {
278
278
  {
279
279
  type: 'command',
280
280
  command: autoMemoryCmd('import', scope),
281
- timeout: 8000,
281
+ timeout: AUTO_MEMORY_HOOK_TIMEOUT_S,
282
282
  },
283
283
  ],
284
284
  },
@@ -306,7 +306,7 @@ function generateHooksConfig(config, scope) {
306
306
  {
307
307
  type: 'command',
308
308
  command: autoMemoryCmd('sync', scope),
309
- timeout: 10000,
309
+ timeout: AUTO_MEMORY_HOOK_TIMEOUT_S,
310
310
  },
311
311
  ],
312
312
  },
@@ -44,7 +44,6 @@ const DOWNLOADER_WORDS = ['curl', 'wget'];
44
44
  const STANDALONE_RISK_PATTERNS = [
45
45
  /\bInvoke-Expression\b/i,
46
46
  /\biex\s*\(/i,
47
- /powershell(\.exe)?\s+.*-e(nc(odedcommand)?)?\s+\S/i,
48
47
  /\/dev\/tcp\//i,
49
48
  /\bnc\s+-e\b/i,
50
49
  /base64\s+(-d|--decode)\b/i,
@@ -54,17 +53,37 @@ const STANDALONE_RISK_PATTERNS = [
54
53
  // own the way `rm`/`chmod` are — pre-approving them removes the human
55
54
  // review step for exactly the commands download-and-execute attacks use.
56
55
  const RISKY_PREAPPROVE_WORDS = [...DANGEROUS_COMMAND_WORDS, ...SHELL_EXEC_WORDS, ...DOWNLOADER_WORDS];
56
+ // Encoded PowerShell (`powershell[.exe] ... -e|-enc|-encodedcommand <payload>`),
57
+ // matched on whitespace-separated tokens in ONE linear pass. The earlier regex
58
+ // (`powershell\\s+(?:\\S+\\s+)*?-e...`) is quadratic on repeated "powershell "
59
+ // tokens (every start rescans the tail: 256KB measured ~2.5s, 1MB ~40s) because
60
+ // the lazy token loop re-runs from each start. Same semantics: a token ending in
61
+ // powershell/powershell.exe, then LATER a token that is exactly -e/-enc/
62
+ // -encodedcommand, then at least one more token.
63
+ function hasEncodedPowershell(command) {
64
+ let seenPs = false;
65
+ let pendingEnc = false;
66
+ for (const raw of command.split(/\s+/)) {
67
+ if (!raw)
68
+ continue;
69
+ if (pendingEnc)
70
+ return true;
71
+ const t = raw.toLowerCase();
72
+ if (seenPs && (t === '-e' || t === '-enc' || t === '-encodedcommand'))
73
+ pendingEnc = true;
74
+ else if (t.endsWith('powershell') || t.endsWith('powershell.exe'))
75
+ seenPs = true;
76
+ }
77
+ return false;
78
+ }
57
79
  function wordBoundaryMatches(text, word) {
58
80
  return new RegExp(`\\b${word}\\b`, 'i').test(text);
59
81
  }
60
82
  /** Reasons a single hook `command` string looks risky (empty = clean). */
61
83
  export function scanCommandStringForRisk(command) {
62
84
  const reasons = [];
63
- for (const pattern of STANDALONE_RISK_PATTERNS) {
64
- if (pattern.test(command)) {
65
- reasons.push('matches a known-dangerous execution pattern (encoded/remote command execution)');
66
- break;
67
- }
85
+ if (hasEncodedPowershell(command) || STANDALONE_RISK_PATTERNS.some((pattern) => pattern.test(command))) {
86
+ reasons.push('matches a known-dangerous execution pattern (encoded/remote command execution)');
68
87
  }
69
88
  const hasDownloader = DOWNLOADER_WORDS.some((w) => wordBoundaryMatches(command, w));
70
89
  const hasShellExec = SHELL_EXEC_WORDS.some((w) => wordBoundaryMatches(command, w));
@@ -619,7 +619,7 @@ export class MCPServerManager extends EventEmitter {
619
619
  version: VERSION,
620
620
  capabilities: {
621
621
  tools: { listChanged: true },
622
- resources: { subscribe: true, listChanged: true },
622
+ resources: {},
623
623
  },
624
624
  },
625
625
  },
@@ -696,7 +696,7 @@ export class MCPServerManager extends EventEmitter {
696
696
  serverInfo: { name: 'ruflo', version: '3.0.0' },
697
697
  capabilities: {
698
698
  tools: { listChanged: true },
699
- resources: { subscribe: true, listChanged: true },
699
+ resources: {},
700
700
  },
701
701
  },
702
702
  };
@@ -755,6 +755,14 @@ export class MCPServerManager extends EventEmitter {
755
755
  },
756
756
  };
757
757
  }
758
+ // The server advertises `resources` (so clients such as Codex offer list_mcp_resources), and a declared capability must
759
+ // be served: it exposes no resources, so the answers are empty, and a read is the spec's "resource not found" (ADR-490).
760
+ case 'resources/list':
761
+ return { jsonrpc: '2.0', id: message.id, result: { resources: [] } };
762
+ case 'resources/templates/list':
763
+ return { jsonrpc: '2.0', id: message.id, result: { resourceTemplates: [] } };
764
+ case 'resources/read':
765
+ return { jsonrpc: '2.0', id: message.id, error: { code: -32002, message: 'Resource not found' } };
758
766
  case 'notifications/initialized':
759
767
  // Client notification - no response needed
760
768
  console.error(`[${new Date().toISOString()}] INFO [claude-flow-mcp] (${sessionId}) Client initialized`);
@@ -97,7 +97,7 @@ export interface CapabilityBrain {
97
97
  };
98
98
  }
99
99
  /** Top-level commands from the CLI command loader. Kept as a checked manifest. */
100
- export declare const RUFLO_CLI_COMMANDS: readonly ["init", "start", "status", "task", "session", "agent", "swarm", "memory", "mcp", "config", "migrate", "hooks", "workflow", "hive-mind", "process", "daemon", "version", "neural", "security", "performance", "providers", "plugins", "deployment", "claims", "policy", "embeddings", "completions", "doctor", "verify", "analyze", "route", "progress", "issues", "update", "ruvector", "benchmark", "guidance", "appliance", "appliance-advanced", "transfer-store", "cleanup", "autopilot", "gaia-bench", "metaharness", "eject", "funnel", "settings", "auth", "proxy", "advisor", "spinner", "announcements"];
100
+ export declare const RUFLO_CLI_COMMANDS: readonly ["init", "start", "status", "task", "session", "agent", "swarm", "memory", "mcp", "config", "migrate", "hooks", "workflow", "hive-mind", "process", "daemon", "version", "neural", "security", "performance", "providers", "plugins", "deployment", "claims", "policy", "embeddings", "completions", "doctor", "verify", "analyze", "route", "progress", "issues", "update", "ruvector", "benchmark", "guidance", "appliance", "appliance-advanced", "transfer-store", "cleanup", "autopilot", "gaia-bench", "metaharness", "eject", "funnel", "settings", "auth", "proxy", "advisor", "spinner", "announcements", "tui"];
101
101
  export interface ImplementationLoopStep {
102
102
  id: string;
103
103
  name: string;
@@ -16,7 +16,7 @@ export const RUFLO_CLI_COMMANDS = [
16
16
  'ruvector', 'benchmark', 'guidance', 'appliance', 'appliance-advanced',
17
17
  'transfer-store', 'cleanup', 'autopilot', 'gaia-bench', 'metaharness',
18
18
  'eject', 'funnel', 'settings', 'auth', 'proxy', 'advisor', 'spinner',
19
- 'announcements',
19
+ 'announcements', 'tui',
20
20
  ];
21
21
  export const IMPLEMENTATION_LOOP = [
22
22
  {
@@ -8,6 +8,7 @@ import * as nodeFs from 'fs';
8
8
  import { dirname, join, resolve } from 'path';
9
9
  import { getProjectCwd } from './types.js';
10
10
  import { validateIdentifier, validateText, validatePath } from './validate-input.js';
11
+ import { commandRecordingDisabled, redactCommandForStorage } from '../services/command-recording.js';
11
12
  import { checkCommandLoop, recordCommandOutcome } from './tool-loop-guardrail.js';
12
13
  import { buildLearnedRoutingPatterns, } from '../services/learned-routing.js';
13
14
  import { applyTypesafeRouting, getTypesafeRouter } from '../ruvector/typesafe-router.js';
@@ -1014,13 +1015,34 @@ export const hooksPostCommand = {
1014
1015
  // #6: feed the tool-loop circuit breaker so pre-command can warn/block on
1015
1016
  // repeated consecutive failures of the same command.
1016
1017
  recordCommandOutcome(command, success);
1018
+ // #4056: command history is opt-out (CLAUDE_FLOW_HOOKS_RECORD_COMMANDS=0,
1019
+ // CLAUDE_FLOW_HOOKS_ENABLED=false, or hooks.recordCommands=false in the
1020
+ // config). The circuit breaker above is in-memory only; nothing below
1021
+ // runs when recording is off, so no command text reaches the store or
1022
+ // the trajectory pipeline.
1023
+ if (commandRecordingDisabled(process.env, getProjectCwd())) {
1024
+ return {
1025
+ recorded: false,
1026
+ disabled: true,
1027
+ command: undefined,
1028
+ exitCode,
1029
+ success,
1030
+ timestamp: new Date().toISOString(),
1031
+ _storedIn: 'none',
1032
+ learningPath: 'recorded-only',
1033
+ trajectoriesDelta: 0,
1034
+ note: 'Command recording is switched off (CLAUDE_FLOW_HOOKS_RECORD_COMMANDS / CLAUDE_FLOW_HOOKS_ENABLED / hooks.recordCommands); nothing was stored.',
1035
+ };
1036
+ }
1037
+ // What is stored is the redacted, length-capped form, never the raw text.
1038
+ const storedCommand = redactCommandForStorage(command);
1017
1039
  // Persist command outcome via AgentDB
1018
1040
  let _storedIn = 'none';
1019
1041
  try {
1020
1042
  const bridge = await import('../memory/memory-bridge.js');
1021
1043
  const stored = await bridge.bridgeStoreEntry({
1022
1044
  key: `cmd-${Date.now()}`,
1023
- value: JSON.stringify({ command, exitCode, success }),
1045
+ value: JSON.stringify({ command: storedCommand, exitCode, success }),
1024
1046
  namespace: 'commands',
1025
1047
  tags: [success ? 'success' : 'error'],
1026
1048
  ttl,
@@ -1042,7 +1064,7 @@ export const hooksPostCommand = {
1042
1064
  }
1043
1065
  const key = `cmd-${now}`;
1044
1066
  store.entries[key] = {
1045
- key, value: JSON.stringify({ command, exitCode, success }), namespace: 'commands',
1067
+ key, value: JSON.stringify({ command: storedCommand, exitCode, success }), namespace: 'commands',
1046
1068
  storedAt: new Date(now).toISOString(), lastAccessed: new Date(now).toISOString(), accessCount: 0,
1047
1069
  expiresAt: new Date(now + ttl * 1000).toISOString(),
1048
1070
  };
@@ -1063,8 +1085,8 @@ export const hooksPostCommand = {
1063
1085
  const before = intel.getIntelligenceStats().trajectoriesRecorded;
1064
1086
  await intel.recordTrajectory([{
1065
1087
  type: 'action',
1066
- content: `Command \`${command.slice(0, 200)}\` exited ${exitCode} (${success ? 'success' : 'failure'})`,
1067
- metadata: { hook: 'post-command', command: command.slice(0, 500), exitCode, success },
1088
+ content: `Command \`${storedCommand.slice(0, 200)}\` exited ${exitCode} (${success ? 'success' : 'failure'})`,
1089
+ metadata: { hook: 'post-command', command: storedCommand.slice(0, 500), exitCode, success },
1068
1090
  timestamp: Date.now(),
1069
1091
  }], success ? 'success' : 'failure');
1070
1092
  trajectoriesDelta = intel.getIntelligenceStats().trajectoriesRecorded - before;
@@ -3305,19 +3327,23 @@ export const hooksTrajectoryEnd = {
3305
3327
  }
3306
3328
  catch { /* intelligence module not loadable — keep sona-only behaviour */ }
3307
3329
  }
3308
- // #2351: when an agent calls trajectory-end with no recorded steps but a
3309
- // non-empty `feedback` string, the feedback was previously dropped on the
3310
- // floor — `patternsExtracted` reported 0 and `pattern-search` never
3311
- // surfaced it. Step-less trajectories are the common case for LLM agents
3312
- // (nothing forces step logging mid-task), and feedback is often the most
3313
- // distilled lesson available. Route it through the same store + embed
3314
- // path that pattern-store uses so it becomes searchable. Best-effort:
3315
- // failures here must not turn the trajectory-end call itself into a
3316
- // failure — the trajectory record was already persisted above.
3330
+ // #2351: when an agent calls trajectory-end with a non-empty `feedback`
3331
+ // string, the feedback was previously dropped on the floor —
3332
+ // `patternsExtracted` reported 0 and `pattern-search` never surfaced it.
3333
+ // Route it through the same store + embed path that pattern-store uses
3334
+ // so it becomes searchable. Best-effort: failures here must not turn the
3335
+ // trajectory-end call itself into a failure — the trajectory record was
3336
+ // already persisted above.
3337
+ //
3338
+ // #3662: #2351's original fix gated this on `!hasSteps` (step-less
3339
+ // trajectories only). The common start -> step -> end path (the one real
3340
+ // agents actually use) therefore recorded SONA state while leaving the
3341
+ // feedback unindexed. Non-empty feedback is promoted regardless of
3342
+ // whether steps were recorded — a trajectory's steps and its distilled
3343
+ // feedback are not mutually exclusive evidence.
3317
3344
  let feedbackDistilled = { stored: false };
3318
- const hasSteps = !!trajectory && trajectory.steps.length > 0;
3319
3345
  const trimmedFeedback = typeof feedback === 'string' ? feedback.trim() : '';
3320
- if (trajectory && !hasSteps && trimmedFeedback.length > 0) {
3346
+ if (trajectory && trimmedFeedback.length > 0) {
3321
3347
  const distilledPatternId = `pattern-feedback-${trajectoryId}-${Date.now()}`;
3322
3348
  const patternMetadata = {
3323
3349
  sourceTrajectoryId: trajectoryId,
@@ -1402,7 +1402,8 @@ export const memoryTools = [
1402
1402
  if (!v.valid)
1403
1403
  throw new Error(v.error);
1404
1404
  }
1405
- const all = await listEntries({ limit: 100000, namespace });
1405
+ // #4056: TTL hides expired rows from normal reads, so the cleanup has to ask for them.
1406
+ const all = await listEntries({ limit: 100000, namespace, includeExpired: true });
1406
1407
  const now = Date.now();
1407
1408
  const expired = all.entries.filter(e => {
1408
1409
  const exp = e.expiresAt;
@@ -1,3 +1,5 @@
1
1
  /** TTL is a read-time visibility rule, independent of background cleanup. */
2
- export declare function liveMemoryRowSql(): string;
2
+ export declare function liveMemoryRowSql(opts?: {
3
+ includeExpired?: boolean;
4
+ }): string;
3
5
  //# sourceMappingURL=live-memory-row.d.ts.map
@@ -1,5 +1,7 @@
1
1
  /** TTL is a read-time visibility rule, independent of background cleanup. */
2
- export function liveMemoryRowSql() {
2
+ export function liveMemoryRowSql(opts = {}) {
3
+ if (opts.includeExpired)
4
+ return `(status = 'active' OR status IS NULL)`;
3
5
  return `(status = 'active' OR status IS NULL) AND (expires_at IS NULL OR expires_at > ${Date.now()})`;
4
6
  }
5
7
  //# sourceMappingURL=live-memory-row.js.map
@@ -142,6 +142,8 @@ export declare function bridgeListEntries(options: {
142
142
  provenanceFilter?: string[];
143
143
  /** ADR-472: include each entry's embedding as int8+scale (`embeddingQ8`); at most MAX_LIST_EMBEDDINGS rows. */
144
144
  includeEmbedding?: boolean;
145
+ /** #4056: also return rows whose TTL has passed (with `expiresAt`). For cleanup only. */
146
+ includeExpired?: boolean;
145
147
  }): Promise<{
146
148
  success: boolean;
147
149
  entries: {
@@ -152,6 +154,8 @@ export declare function bridgeListEntries(options: {
152
154
  accessCount: number;
153
155
  createdAt: string;
154
156
  updatedAt: string;
157
+ /** #4056: epoch ms the row expires at; null when it never does. */
158
+ expiresAt?: number | null;
155
159
  hasEmbedding: boolean;
156
160
  /** #2073: Present when `includeContent: true` was requested. */
157
161
  content?: string;
@@ -1453,7 +1453,7 @@ export async function bridgeListEntries(options) {
1453
1453
  // the `status = 'active'` filter matched zero. Treat NULL as
1454
1454
  // "legacy-active" — the safe default for any entry that predates the
1455
1455
  // status column.
1456
- const statusFilter = liveMemoryRowSql();
1456
+ const statusFilter = liveMemoryRowSql({ includeExpired: options.includeExpired });
1457
1457
  // Count
1458
1458
  let total = 0;
1459
1459
  try {
@@ -1468,7 +1468,7 @@ export async function bridgeListEntries(options) {
1468
1468
  const entries = [];
1469
1469
  try {
1470
1470
  const stmt = ctx.db.prepare(`
1471
- SELECT id, key, namespace, content, embedding, access_count, created_at, updated_at, provenance_type
1471
+ SELECT id, key, namespace, content, embedding, access_count, created_at, updated_at, provenance_type, expires_at
1472
1472
  FROM memory_entries
1473
1473
  WHERE ${statusFilter} ${extraFilter}
1474
1474
  ORDER BY updated_at DESC
@@ -1486,6 +1486,7 @@ export async function bridgeListEntries(options) {
1486
1486
  accessCount: row.access_count ?? 0,
1487
1487
  createdAt: row.created_at || new Date().toISOString(),
1488
1488
  updatedAt: row.updated_at || new Date().toISOString(),
1489
+ expiresAt: row.expires_at ?? null,
1489
1490
  hasEmbedding: !!(row.embedding && String(row.embedding).length > 10),
1490
1491
  provenanceType: row.provenance_type || 'unknown',
1491
1492
  };
@@ -511,6 +511,8 @@ export declare function listEntries(options: {
511
511
  provenanceFilter?: string[];
512
512
  /** ADR-472: include each entry's embedding as int8+scale (`embeddingQ8`); at most MAX_LIST_EMBEDDINGS rows. Read-only. */
513
513
  includeEmbedding?: boolean;
514
+ /** #4056: also return rows whose TTL has passed (with `expiresAt`). For cleanup only; normal reads hide them. */
515
+ includeExpired?: boolean;
514
516
  }): Promise<{
515
517
  success: boolean;
516
518
  entries: {
@@ -521,6 +523,8 @@ export declare function listEntries(options: {
521
523
  accessCount: number;
522
524
  createdAt: string;
523
525
  updatedAt: string;
526
+ /** #4056: epoch ms the row expires at; null when it never does. */
527
+ expiresAt?: number | null;
524
528
  hasEmbedding: boolean;
525
529
  /** #2073: Present when `includeContent: true` was requested. */
526
530
  content?: string;
@@ -6,7 +6,7 @@ import * as fs from 'fs';
6
6
  import * as path from 'path';
7
7
  import { AsyncLocalStorage } from 'node:async_hooks';
8
8
  import { createRequire } from 'node:module';
9
- import { readFileMaybeEncrypted, writeFileAtomic, writeFileRestricted } from '../fs-secure.js';
9
+ import { readFileMaybeEncrypted, writeDbImagePreservingFormat, writeFileRestricted } from '../fs-secure.js';
10
10
  import { restoreMemoryDbFromBackup } from '../services/memory-backup.js';
11
11
  import { validateIdentifier } from '../mcp-tools/validate-input.js';
12
12
  /**
@@ -1248,7 +1248,7 @@ export async function ensureSchemaColumns(dbPath, options = {}) {
1248
1248
  if (modified) {
1249
1249
  // Save updated database
1250
1250
  const data = db.export();
1251
- writeFileRestricted(dbPath, Buffer.from(data), { encrypt: options.encryptWrites ?? true });
1251
+ writeDbImagePreservingFormat(dbPath, data, { encrypt: options.encryptWrites ?? true });
1252
1252
  }
1253
1253
  db.close();
1254
1254
  return { success: true, columnsAdded };
@@ -1975,7 +1975,7 @@ export async function applyTemporalDecay(dbPath) {
1975
1975
  return await withMemoryDbLock(path_, async () => {
1976
1976
  const initSqlJs = (await import('sql.js')).default;
1977
1977
  const SQL = await initSqlJs();
1978
- const fileBuffer = fs.readFileSync(path_);
1978
+ const fileBuffer = readFileMaybeEncrypted(path_, null);
1979
1979
  const db = new SQL.Database(fileBuffer);
1980
1980
  // Apply decay: confidence *= exp(-decay_rate * days_since_last_use)
1981
1981
  const now = Date.now();
@@ -1992,7 +1992,7 @@ export async function applyTemporalDecay(dbPath) {
1992
1992
  const changes = db.getRowsModified();
1993
1993
  // Save (atomic — issue #2584: a torn full-image flush corrupts the store)
1994
1994
  const data = db.export();
1995
- writeFileAtomic(path_, Buffer.from(data));
1995
+ writeDbImagePreservingFormat(path_, data);
1996
1996
  db.close();
1997
1997
  return {
1998
1998
  success: true,
@@ -2692,7 +2692,7 @@ export async function storeEntry(options) {
2692
2692
  }
2693
2693
  // Save
2694
2694
  const data = db.export();
2695
- writeFileRestricted(dbPath, Buffer.from(data), { encrypt: true });
2695
+ writeDbImagePreservingFormat(dbPath, data);
2696
2696
  db.close();
2697
2697
  });
2698
2698
  // Add to HNSW index for faster future searches
@@ -3019,7 +3019,7 @@ export async function listEntries(options) {
3019
3019
  // that predate the status column may have NULL after migration.
3020
3020
  // See memory-bridge.ts:bridgeListEntries for full context.
3021
3021
  // Get total count
3022
- const whereClauses = [liveMemoryRowSql()];
3022
+ const whereClauses = [liveMemoryRowSql({ includeExpired: options.includeExpired })];
3023
3023
  const whereParams = [];
3024
3024
  if (namespace) {
3025
3025
  whereClauses.push('namespace = ?');
@@ -3044,7 +3044,7 @@ export async function listEntries(options) {
3044
3044
  const safeLimit = Math.min(parseInt(String(limit), 10) || 100, options.includeEmbedding ? MAX_LIST_EMBEDDINGS : Number.MAX_SAFE_INTEGER);
3045
3045
  const safeOffset = parseInt(String(offset), 10) || 0;
3046
3046
  // #2120 — same NULL-as-active acceptance as the count above.
3047
- const listStmt = db.prepare(`SELECT id, key, namespace, content, embedding, access_count, created_at, updated_at, provenance_type
3047
+ const listStmt = db.prepare(`SELECT id, key, namespace, content, embedding, access_count, created_at, updated_at, provenance_type, expires_at
3048
3048
  FROM memory_entries WHERE ${whereSql}
3049
3049
  ORDER BY updated_at DESC LIMIT ? OFFSET ?`);
3050
3050
  listStmt.bind([...whereParams, safeLimit, safeOffset]);
@@ -3057,7 +3057,7 @@ export async function listEntries(options) {
3057
3057
  const entries = [];
3058
3058
  if (result[0]?.values) {
3059
3059
  for (const row of result[0].values) {
3060
- const [id, key, ns, content, embedding, accessCount, createdAt, updatedAt, provenanceTypeVal] = row;
3060
+ const [id, key, ns, content, embedding, accessCount, createdAt, updatedAt, provenanceTypeVal, expiresAtVal] = row;
3061
3061
  const entry = {
3062
3062
  // #2073: don't truncate id when content is requested — callers
3063
3063
  // (notably memory_export) need the full id to round-trip via import.
@@ -3068,6 +3068,7 @@ export async function listEntries(options) {
3068
3068
  accessCount: accessCount || 0,
3069
3069
  createdAt: createdAt || new Date().toISOString(),
3070
3070
  updatedAt: updatedAt || new Date().toISOString(),
3071
+ expiresAt: expiresAtVal ?? null,
3071
3072
  hasEmbedding: !!embedding && embedding.length > 10,
3072
3073
  provenanceType: provenanceTypeVal || 'unknown'
3073
3074
  };
@@ -3166,7 +3167,7 @@ export async function getEntry(options) {
3166
3167
  `, [String(id)]);
3167
3168
  // Save updated database
3168
3169
  const data = db.export();
3169
- writeFileRestricted(dbPath, Buffer.from(data), { encrypt: true });
3170
+ writeDbImagePreservingFormat(dbPath, data);
3170
3171
  db.close();
3171
3172
  let tags = [];
3172
3173
  if (tagsJson) {
@@ -3302,7 +3303,7 @@ export async function deleteEntry(options) {
3302
3303
  const remainingEntries = countResult[0]?.values?.[0]?.[0] || 0;
3303
3304
  // Save updated database
3304
3305
  const data = db.export();
3305
- writeFileRestricted(dbPath, Buffer.from(data), { encrypt: options.encryptWrites ?? true });
3306
+ writeDbImagePreservingFormat(dbPath, data, { encrypt: options.encryptWrites ?? true });
3306
3307
  db.close();
3307
3308
  // Clean up in-memory HNSW index so ghost vectors don't appear in searches.
3308
3309
  // Remove the entry from the HNSW entries map and invalidate the index.
@@ -3472,7 +3473,7 @@ export async function purgeNamespace(options) {
3472
3473
  const countResult = db.exec(`SELECT COUNT(*) FROM memory_entries WHERE status = 'active'`);
3473
3474
  const remainingEntries = countResult[0]?.values?.[0]?.[0] || 0;
3474
3475
  const data = db.export();
3475
- writeFileRestricted(dbPath, Buffer.from(data), { encrypt: options.encryptWrites ?? true });
3476
+ writeDbImagePreservingFormat(dbPath, data, { encrypt: options.encryptWrites ?? true });
3476
3477
  db.close();
3477
3478
  if (deletedCount > 0 && hnswIndex?.entries) {
3478
3479
  for (const [id, entry] of hnswIndex.entries) {
@@ -69,13 +69,17 @@ export function judgeInstalls(installs, enableEnvSet) {
69
69
  : '';
70
70
  const runs = `${first.path} (${first.version ?? 'version unknown'})`;
71
71
  if (!first.version)
72
- return { status: 'warn', message: `claude resolves to ${runs}${mixed}` };
72
+ return { status: 'warn', message: `claude resolves to ${runs}${mixed}`, fix: `run \`${first.path} --version\` and reinstall Claude Code if it prints nothing` };
73
73
  if (!versionLess(first.version, MODS_DEFAULT_ON)) {
74
- return { status: others.length ? 'warn' : 'pass', message: `claude resolves to ${runs}: mods on by default${mixed}` };
74
+ return others.length
75
+ ? { status: 'warn', message: `claude resolves to ${runs}: mods on by default${mixed}`, fix: `put the claude you want first on PATH, or remove: ${others.map((i) => i.path).join(' ')}` }
76
+ : { status: 'pass', message: `claude resolves to ${runs}: mods on by default` };
75
77
  }
76
78
  if (!versionLess(first.version, MODS_FIRST)) {
77
79
  return enableEnvSet
78
- ? { status: others.length ? 'warn' : 'pass', message: `claude resolves to ${runs}: mods load with CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 (set)${mixed}` }
80
+ ? (others.length
81
+ ? { status: 'warn', message: `claude resolves to ${runs}: mods load with CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 (set)${mixed}`, fix: `put the claude you want first on PATH, or remove: ${others.map((i) => i.path).join(' ')}` }
82
+ : { status: 'pass', message: `claude resolves to ${runs}: mods load with CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 (set)` })
79
83
  : { status: 'warn', message: `claude resolves to ${runs}: below ${MODS_DEFAULT_ON}, mods need CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 (not set)${mixed}`, fix: `upgrade to >= ${MODS_DEFAULT_ON}, or export CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1` };
80
84
  }
81
85
  return { status: 'warn', message: `claude resolves to ${runs}: predates mods (< ${MODS_FIRST}); classic hooks handle every event${mixed}`, fix: `upgrade to >= ${MODS_DEFAULT_ON} and remove the stale install from PATH` };
@@ -107,18 +107,38 @@ export function installMod(projectRoot, scope, dryRun = false, plugins = MOD_PLU
107
107
  backup = `${settingsFile}.bak-ruflo-mods-${Date.now()}`;
108
108
  copyFileSync(settingsFile, backup);
109
109
  }
110
- writeJson(settingsFile, next);
111
- // What ruflo added once is still ruflo's to remove: merge into the record.
112
- const record = readRecord(projectRoot) ?? { version: 2, installedAt: '', files: {} };
113
- const before = record.files[settingsFile] ?? { plugins: [], marketplace: false, env: false };
114
- record.files[settingsFile] = {
115
- plugins: [...new Set([...before.plugins, ...added.plugins])],
116
- marketplace: before.marketplace || added.marketplace,
117
- env: before.env || added.env,
118
- claudeInstalled: before.claudeInstalled ?? [],
119
- };
120
- record.installedAt = new Date().toISOString();
121
- writeJson(join(resolve(projectRoot), INSTALL_RECORD), record);
110
+ // One operation: the settings and the record that lets uninstall take them back are written together.
111
+ // If the record cannot be written, the settings go back as they were (or the file ruflo created goes
112
+ // away), so nothing is left half-installed with no record to remove it by.
113
+ try {
114
+ writeJson(settingsFile, next);
115
+ // What ruflo added once is still ruflo's to remove: merge into the record.
116
+ const record = readRecord(projectRoot) ?? { version: 2, installedAt: '', files: {} };
117
+ const before = record.files[settingsFile] ?? { plugins: [], marketplace: false, env: false };
118
+ record.files[settingsFile] = {
119
+ plugins: [...new Set([...before.plugins, ...added.plugins])],
120
+ marketplace: before.marketplace || added.marketplace,
121
+ env: before.env || added.env,
122
+ claudeInstalled: before.claudeInstalled ?? [],
123
+ };
124
+ record.installedAt = new Date().toISOString();
125
+ writeJson(join(resolve(projectRoot), INSTALL_RECORD), record);
126
+ }
127
+ catch (error) {
128
+ try {
129
+ if (backup) {
130
+ copyFileSync(backup, settingsFile);
131
+ unlinkSync(backup);
132
+ }
133
+ else if (existsSync(settingsFile)) {
134
+ unlinkSync(settingsFile);
135
+ }
136
+ }
137
+ catch {
138
+ // the restore itself failed: the original error below is the one to report
139
+ }
140
+ throw error;
141
+ }
122
142
  return { settingsFile, backup, added, dryRun, next };
123
143
  }
124
144
  /** The install record, v1 (3.50.0: one file, ruflo-mods only) read as v2. */
@@ -160,7 +160,9 @@ export function resolveFindings(projectRoot, scope, configDir, fs = nodeReadFs,
160
160
  else if (!market.location) {
161
161
  findings.push(installed.installed
162
162
  ? { name, status: 'pass', message: `installed (${installed.scope} scope, ${installed.installPath})` }
163
- : { name, status: 'warn', message: plugin.required ? 'loads once Claude Code has cloned the marketplace' : 'pending: not released in the ruflo marketplace yet' });
163
+ : plugin.required
164
+ ? { name, status: 'warn', message: 'loads once Claude Code has cloned the marketplace (a headless `claude -p` run on a fresh config does not clone it, so the mod is absent there)', fix }
165
+ : { name, status: 'warn', message: 'pending: not released in the ruflo marketplace yet' });
164
166
  }
165
167
  else if (!plugin.required) {
166
168
  findings.push({ name, status: 'warn', message: `pending: not in the ruflo marketplace yet (${market.location})${also}` });