@claude-flow/cli 3.56.6 → 3.56.8

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 (59) hide show
  1. package/.claude/helpers/auto-memory-hook.mjs +109 -10
  2. package/.claude/helpers/helpers.manifest.json +4 -4
  3. package/.claude/helpers/intelligence.cjs +57 -4
  4. package/README.md +9 -3
  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/coverage-reader.d.ts +29 -0
  9. package/dist/src/commands/coverage-reader.js +247 -0
  10. package/dist/src/commands/doctor-native-addon.d.ts +23 -0
  11. package/dist/src/commands/doctor-native-addon.js +135 -0
  12. package/dist/src/commands/doctor.js +3 -0
  13. package/dist/src/commands/hooks.js +17 -181
  14. package/dist/src/commands/index.js +2 -0
  15. package/dist/src/commands/init.d.ts +2 -0
  16. package/dist/src/commands/init.js +37 -5
  17. package/dist/src/commands/mods.d.ts +3 -0
  18. package/dist/src/commands/mods.js +19 -5
  19. package/dist/src/commands/tui.d.ts +12 -0
  20. package/dist/src/commands/tui.js +98 -0
  21. package/dist/src/fs-secure.d.ts +25 -0
  22. package/dist/src/fs-secure.js +51 -2
  23. package/dist/src/init/executor.js +11 -14
  24. package/dist/src/init/helper-commands.d.ts +2 -0
  25. package/dist/src/init/helper-commands.js +2 -0
  26. package/dist/src/init/helpers-generator.js +4 -1
  27. package/dist/src/init/settings-generator.js +3 -3
  28. package/dist/src/mcp-server.js +10 -2
  29. package/dist/src/mcp-tools/capability-brain.d.ts +1 -1
  30. package/dist/src/mcp-tools/capability-brain.js +1 -1
  31. package/dist/src/mcp-tools/hooks-tools.js +26 -4
  32. package/dist/src/mcp-tools/memory-tools.js +2 -1
  33. package/dist/src/memory/live-memory-row.d.ts +3 -1
  34. package/dist/src/memory/live-memory-row.js +3 -1
  35. package/dist/src/memory/memory-bridge.d.ts +4 -0
  36. package/dist/src/memory/memory-bridge.js +3 -2
  37. package/dist/src/memory/memory-initializer.d.ts +4 -0
  38. package/dist/src/memory/memory-initializer.js +12 -11
  39. package/dist/src/mods/claude-installs.js +7 -3
  40. package/dist/src/mods/install.js +32 -12
  41. package/dist/src/mods/plugin-resolve.js +3 -1
  42. package/dist/src/mods/probe.js +92 -4
  43. package/dist/src/services/command-recording.d.ts +17 -0
  44. package/dist/src/services/command-recording.js +91 -0
  45. package/dist/src/services/policy-ledger-anchor.js +20 -2
  46. package/node_modules/@claude-flow/codex/README.md +20 -3
  47. package/node_modules/@claude-flow/codex/dist/cli.js +7 -0
  48. package/node_modules/@claude-flow/codex/dist/cli.js.map +1 -1
  49. package/node_modules/@claude-flow/codex/dist/index.d.ts +3 -3
  50. package/node_modules/@claude-flow/codex/dist/index.js +1 -1
  51. package/node_modules/@claude-flow/codex/dist/initializer.d.ts +8 -3
  52. package/node_modules/@claude-flow/codex/dist/initializer.d.ts.map +1 -1
  53. package/node_modules/@claude-flow/codex/dist/initializer.js +75 -13
  54. package/node_modules/@claude-flow/codex/dist/initializer.js.map +1 -1
  55. package/node_modules/@claude-flow/codex/dist/types.d.ts +8 -0
  56. package/node_modules/@claude-flow/codex/dist/types.d.ts.map +1 -1
  57. package/node_modules/@claude-flow/codex/package.json +1 -1
  58. package/package.json +3 -3
  59. package/plugins/ruflo-console/dist/tui.mjs +279 -0
@@ -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}` });
@@ -8,8 +8,8 @@ import { existsSync, readFileSync } from 'node:fs';
8
8
  import { homedir, platform } from 'node:os';
9
9
  import { join, resolve } from 'node:path';
10
10
  import { findClaudeInstalls, judgeInstalls } from './claude-installs.js';
11
- import { ENABLE_ENV, MOD_PLUGIN_ID, MOD_PLUGIN_IDS, readRecord, readSettingsFile, settingsFileFor } from './install.js';
12
- import { claudeConfigDir, resolveFindings } from './plugin-resolve.js';
11
+ import { ENABLE_ENV, MARKETPLACE_NAME, MOD_PLUGIN_ID, MOD_PLUGIN_IDS, readRecord, readSettingsFile, settingsFileFor } from './install.js';
12
+ import { claudeConfigDir, installedState, resolveFindings } from './plugin-resolve.js';
13
13
  import { PROJECTION_RELATIVE } from './policy-projection.js';
14
14
  export const HANDSHAKE_MARKER = 'RUFLO_MODS_OWNS';
15
15
  export const HEARTBEAT_RELATIVE = join('.claude-flow', 'mods', 'session.json');
@@ -53,6 +53,65 @@ export function legacyCliHooks(files) {
53
53
  return commands(LEGACY_EVENTS[sub]).some((c) => word.test(c));
54
54
  });
55
55
  }
56
+ /** Settings files (in the order given) where `enabledPlugins[id]` is exactly `value`. */
57
+ function enabledValues(files, id) {
58
+ const out = new Map();
59
+ for (const f of files) {
60
+ try {
61
+ const v = get(readSettingsFile(f), 'enabledPlugins', id);
62
+ if (v !== undefined)
63
+ out.set(f, v);
64
+ }
65
+ catch {
66
+ // an unparseable file is reported elsewhere; never overwritten
67
+ }
68
+ }
69
+ return out;
70
+ }
71
+ function partialInstallFindings(root, files, _inputs) {
72
+ const enabled = new Set(MOD_PLUGIN_IDS.filter((id) => [...enabledValues(files, id).values()].includes(true)));
73
+ const out = [];
74
+ const parts = [];
75
+ if (enabled.size > 0 && enabled.size < MOD_PLUGIN_IDS.length) {
76
+ const missing = MOD_PLUGIN_IDS.filter((id) => !enabled.has(id) && ![...enabledValues(files, id).values()].includes(false));
77
+ if (missing.length)
78
+ parts.push(`enabled ${[...enabled].join(', ')} but not ${missing.join(', ')}`);
79
+ }
80
+ if (enabled.size > 0) {
81
+ const declared = files.some((f) => {
82
+ try {
83
+ return get(readSettingsFile(f), 'extraKnownMarketplaces', MARKETPLACE_NAME) !== undefined;
84
+ }
85
+ catch {
86
+ return false;
87
+ }
88
+ });
89
+ if (!declared)
90
+ parts.push(`no \`${MARKETPLACE_NAME}\` marketplace is declared in settings for Claude Code to load them from`);
91
+ }
92
+ const record = readRecord(root);
93
+ if (record) {
94
+ const lost = [];
95
+ for (const [file, added] of Object.entries(record.files)) {
96
+ try {
97
+ const now = readSettingsFile(file);
98
+ for (const id of added.plugins)
99
+ if (get(now, 'enabledPlugins', id) !== true && get(now, 'enabledPlugins', id) !== false)
100
+ lost.push(id);
101
+ if (added.marketplace && get(now, 'extraKnownMarketplaces', MARKETPLACE_NAME) === undefined)
102
+ lost.push(`${MARKETPLACE_NAME} marketplace`);
103
+ }
104
+ catch {
105
+ // unreadable settings: nothing to compare
106
+ }
107
+ }
108
+ if (lost.length)
109
+ parts.push(`the install record says ruflo added ${[...new Set(lost)].join(', ')}, which the settings no longer have`);
110
+ }
111
+ if (parts.length)
112
+ out.push({ name: 'partial install', status: 'warn', message: parts.join('; '), fix: 'ruflo mods install' });
113
+ return out;
114
+ }
56
115
  export function probeMods(inputs) {
57
116
  const root = resolve(inputs.projectRoot);
58
117
  const home = inputs.home ?? homedir();
@@ -69,9 +128,38 @@ export function probeMods(inputs) {
69
128
  return false;
70
129
  }
71
130
  });
131
+ // A plugin a person set to false is their decision: `ruflo mods install` never flips it, so it is not the repair.
132
+ const disabledIn = enabledIn ? undefined : files.find((f) => {
133
+ try {
134
+ return get(readSettingsFile(f), 'enabledPlugins', MOD_PLUGIN_ID) === false;
135
+ }
136
+ catch {
137
+ return false;
138
+ }
139
+ });
140
+ const cachedInstall = enabledIn || disabledIn ? { installed: false } : installedState(root, configDir, MOD_PLUGIN_ID, inputs.fs);
141
+ const scopeOf = (f) => (f === files[0] ? 'local' : f === files[1] ? 'project' : 'user');
72
142
  findings.push(enabledIn
73
143
  ? { name: 'ruflo-mods plugin', status: 'pass', message: `enabled in settings: ${enabledIn} (whether Claude Code can load it: see below)` }
74
- : { name: 'ruflo-mods plugin', status: 'warn', message: 'not enabled; classic hooks handle every event', fix: 'ruflo mods install' });
144
+ : disabledIn
145
+ ? {
146
+ name: 'ruflo-mods plugin',
147
+ status: 'warn',
148
+ message: `disabled in ${disabledIn} (enabledPlugins is false): the mod does not load and classic hooks handle every event; \`ruflo mods install\` leaves a plugin you switched off alone`,
149
+ fix: `claude plugin enable ${MOD_PLUGIN_ID} --scope ${scopeOf(disabledIn)}`,
150
+ }
151
+ : cachedInstall.installed
152
+ ? {
153
+ name: 'ruflo-mods plugin',
154
+ status: 'warn',
155
+ message: `installed (${cachedInstall.scope} scope) but not enabled in this project's settings: the mod does not load`,
156
+ fix: `claude plugin enable ${MOD_PLUGIN_ID} --scope ${cachedInstall.scope === 'user' ? 'user' : 'local'}`,
157
+ }
158
+ : { name: 'ruflo-mods plugin', status: 'warn', message: 'not enabled; classic hooks handle every event', fix: 'ruflo mods install' });
159
+ // 1b. A half-finished install: some of the mod plugins enabled and not the rest, a plugin enabled with no
160
+ // marketplace declared for Claude Code to load it from, or keys the install record says ruflo added that
161
+ // the settings file no longer has. `ruflo mods install` is idempotent and adds exactly what is missing.
162
+ findings.push(...partialInstallFindings(root, files, inputs));
75
163
  // 1a. Enabled is a request; Claude Code silently skips a plugin its
76
164
  // marketplace clone does not carry (a clone older than the plugin).
77
165
  if (enabledIn) {
@@ -163,7 +251,7 @@ export function probeMods(inputs) {
163
251
  const owned = get(beat, 'owned');
164
252
  findings.push(typeof startedAt === 'string'
165
253
  ? { name: 'last mod start', status: 'pass', message: `${startedAt}, owning ${Array.isArray(owned) && owned.length ? owned.join(', ') : 'nothing'}` }
166
- : { name: 'last mod start', status: enabledIn ? 'warn' : 'pass', message: 'the mod has not started in this project' + (enabledIn ? ' (restart Claude Code; check function hooks above)' : '') });
254
+ : { name: 'last mod start', status: enabledIn ? 'warn' : 'pass', message: 'the mod has not started in this project' + (enabledIn ? ' (restart Claude Code; check function hooks above)' : ''), ...(enabledIn ? { fix: 'restart Claude Code in this project, then run /ruflo mods' } : {}) });
167
255
  if (readRecord(root))
168
256
  findings.push({ name: 'install record', status: 'pass', message: join(root, '.claude-flow', 'mods', 'install.json') });
169
257
  return findings;
@@ -0,0 +1,17 @@
1
+ /** Longest command text kept per row. */
2
+ export declare const MAX_RECORDED_COMMAND_CHARS = 2000;
3
+ /**
4
+ * True when command recording is switched off. Any one of these turns it off:
5
+ * - `CLAUDE_FLOW_HOOKS_RECORD_COMMANDS` = 0 | false | off | no
6
+ * - `CLAUDE_FLOW_HOOKS_ENABLED` = 0 | false | off | no
7
+ * - `hooks.recordCommands: false` in `claude-flow.config.json` or `.claude-flow/config.json`
8
+ */
9
+ export declare function commandRecordingDisabled(env?: Record<string, string | undefined>, cwd?: string): boolean;
10
+ /**
11
+ * The form of a command that may be written to the store: secrets of the
12
+ * common shapes replaced with `[redacted]`, and the text capped at
13
+ * `MAX_RECORDED_COMMAND_CHARS`. This is best effort, not a guarantee; the off
14
+ * switch is the guarantee.
15
+ */
16
+ export declare function redactCommandForStorage(command: string): string;
17
+ //# sourceMappingURL=command-recording.d.ts.map
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Policy for what the PostToolUse Bash hook may keep (#4056).
3
+ *
4
+ * `hooks post-command` used to store every command in full as
5
+ * `commands/cmd-<ts>`: 27k rows / 285 MB on one project, full command text
6
+ * that can carry tokens, and no way to switch it off. This module answers two
7
+ * questions for that writer:
8
+ *
9
+ * 1. May a command be recorded at all? (`commandRecordingDisabled`)
10
+ * 2. What form of it may be stored? (`redactCommandForStorage`)
11
+ *
12
+ * Every pattern below is bounded, and the input is capped before any pattern
13
+ * runs, so a hostile command cannot stall the hook (the same lesson as the
14
+ * settings-risk scanner, #4053).
15
+ */
16
+ import * as fs from 'node:fs';
17
+ import * as path from 'node:path';
18
+ /** Longest command text kept per row. */
19
+ export const MAX_RECORDED_COMMAND_CHARS = 2000;
20
+ /** Only this much of a command is ever scanned; the stored text is shorter still. */
21
+ const MAX_SCANNED_CHARS = 8000;
22
+ const OFF_VALUES = new Set(['0', 'false', 'off', 'no']);
23
+ const isOff = (v) => v !== undefined && OFF_VALUES.has(v.trim().toLowerCase());
24
+ function configDisablesRecording(cwd) {
25
+ for (const file of [
26
+ path.resolve(cwd, 'claude-flow.config.json'),
27
+ path.resolve(cwd, '.claude-flow', 'config.json'),
28
+ ]) {
29
+ try {
30
+ if (!fs.existsSync(file))
31
+ continue;
32
+ const raw = JSON.parse(fs.readFileSync(file, 'utf-8'));
33
+ if (raw?.hooks?.recordCommands === false)
34
+ return true;
35
+ }
36
+ catch {
37
+ /* malformed config: keep the default (recording on) rather than guess */
38
+ }
39
+ }
40
+ return false;
41
+ }
42
+ /**
43
+ * True when command recording is switched off. Any one of these turns it off:
44
+ * - `CLAUDE_FLOW_HOOKS_RECORD_COMMANDS` = 0 | false | off | no
45
+ * - `CLAUDE_FLOW_HOOKS_ENABLED` = 0 | false | off | no
46
+ * - `hooks.recordCommands: false` in `claude-flow.config.json` or `.claude-flow/config.json`
47
+ */
48
+ export function commandRecordingDisabled(env = process.env, cwd = process.cwd()) {
49
+ if (isOff(env.CLAUDE_FLOW_HOOKS_RECORD_COMMANDS))
50
+ return true;
51
+ if (isOff(env.CLAUDE_FLOW_HOOKS_ENABLED))
52
+ return true;
53
+ return configDisablesRecording(cwd);
54
+ }
55
+ const QUOTED_OR_BARE = `"[^"]{0,512}"|'[^']{0,512}'|[^\\s"']{1,512}`;
56
+ // Order matters little; each rule only ever shortens the text.
57
+ const RULES = [
58
+ // Authorization: Bearer xyz / authorization=Basic xyz
59
+ [/(authorization["']?\s*[:=]\s*)(?:(?:bearer|basic|token)\s+)?[^\s"']{1,512}/gi, '$1[redacted]'],
60
+ // --password x / --token=x / --api-key "x" / -token x
61
+ [new RegExp(`(--?(?:password|passwd|pwd|token|secret|api[-_]?key|access[-_]?token|auth[-_]?token|client[-_]?secret)(?:=|\\s+))(?:${QUOTED_OR_BARE})`, 'gi'), '$1[redacted]'],
62
+ // NPM_TOKEN=x AWS_SECRET_ACCESS_KEY=x DB_PASSWORD=x
63
+ [new RegExp(`\\b([A-Za-z_][A-Za-z0-9_]{0,63}(?:TOKEN|SECRET|PASSWORD|PASSWD|API_?KEY|ACCESS_?KEY|PRIVATE_?KEY|CREDENTIALS?))=(?:${QUOTED_OR_BARE})`, 'gi'), '$1=[redacted]'],
64
+ // user:password@host in a URL
65
+ [/(:\/\/[^\s:@/]{1,128}:)[^\s@/]{1,256}(@)/g, '$1[redacted]$2'],
66
+ // well-known token shapes
67
+ [/\b(?:sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|xox[abprs]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|npm_[A-Za-z0-9]{20,}|eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,})/g, '[redacted]'],
68
+ ];
69
+ /**
70
+ * The form of a command that may be written to the store: secrets of the
71
+ * common shapes replaced with `[redacted]`, and the text capped at
72
+ * `MAX_RECORDED_COMMAND_CHARS`. This is best effort, not a guarantee; the off
73
+ * switch is the guarantee.
74
+ */
75
+ export function redactCommandForStorage(command) {
76
+ let text = String(command);
77
+ if (text.length > MAX_SCANNED_CHARS) {
78
+ // Cut at a token boundary so a half-token at the cut is not left behind.
79
+ const cut = text.slice(0, MAX_SCANNED_CHARS);
80
+ const lastSpace = cut.search(/\s\S*$/);
81
+ text = lastSpace > 0 ? cut.slice(0, lastSpace) : cut;
82
+ text += ' ';
83
+ }
84
+ for (const [re, replacement] of RULES)
85
+ text = text.replace(re, replacement);
86
+ if (text.length > MAX_RECORDED_COMMAND_CHARS) {
87
+ text = `${text.slice(0, MAX_RECORDED_COMMAND_CHARS)} …[truncated]`;
88
+ }
89
+ return text.trimEnd();
90
+ }
91
+ //# sourceMappingURL=command-recording.js.map
@@ -14,10 +14,28 @@
14
14
  import { createHash } from 'node:crypto';
15
15
  import { existsSync, mkdirSync, readFileSync, realpathSync } from 'node:fs';
16
16
  import { dirname, isAbsolute, join, resolve } from 'node:path';
17
- import { homedir } from 'node:os';
17
+ import { homedir, userInfo } from 'node:os';
18
18
  const LOG_FILE = 'ledger-anchors.json';
19
19
  const MIRROR_FILE = 'ledger-anchor-head.json';
20
20
  export const anchorLogPath = (projectRoot) => join(resolve(projectRoot), '.claude-flow', 'policy', LOG_FILE);
21
+ /**
22
+ * The home directory trust material lives under: $HOME when it is an absolute path, else the account's home. An empty or relative
23
+ * $HOME (an unset variable in a wrapper script, `HOME=. cmd`) would make join() yield a path relative to the working directory and
24
+ * drop the HMAC key and anchor mirror into the project; that is never wanted, so it falls back to the account home or refuses.
25
+ */
26
+ function trustHome() {
27
+ const home = homedir();
28
+ if (home && isAbsolute(home))
29
+ return home;
30
+ let account = '';
31
+ try {
32
+ account = userInfo().homedir;
33
+ }
34
+ catch { /* no account entry for this uid */ }
35
+ if (account && isAbsolute(account))
36
+ return account;
37
+ throw new Error('policy-trust-home-unresolved: $HOME is empty or relative and this account has no home directory; set HOME to an absolute path');
38
+ }
21
39
  /**
22
40
  * Directory holding per-project trust material (anchor mirror, HMAC key).
23
41
  * Resolved from the environment at call time (#3919): $XDG_CONFIG_HOME when it
@@ -26,7 +44,7 @@ export const anchorLogPath = (projectRoot) => join(resolve(projectRoot), '.claud
26
44
  * user who already has trust state under the legacy ~/.config keeps using it.
27
45
  */
28
46
  export function policyTrustRoot() {
29
- const legacy = join(homedir(), '.config', 'ruflo', 'policy-trust');
47
+ const legacy = join(trustHome(), '.config', 'ruflo', 'policy-trust');
30
48
  const xdg = process.env.XDG_CONFIG_HOME;
31
49
  if (!xdg || !isAbsolute(xdg))
32
50
  return legacy;
@@ -751,19 +751,36 @@ enabled = true
751
751
 
752
752
  ### .codex/config.toml (Local Overrides)
753
753
 
754
+ `init` creates this gitignored file with **standard** trust by default:
755
+
754
756
  ```toml
755
757
  # Local development overrides (gitignored)
756
758
  # These settings override .agents/config.toml
757
759
 
758
- approval_policy = "never"
759
- sandbox_mode = "danger-full-access"
760
- web_search = "live"
760
+ approval_policy = "on-request"
761
+ sandbox_mode = "workspace-write"
762
+ web_search = "cached"
761
763
 
762
764
  # Disable MCP in local if needed
763
765
  [mcp_servers.claude-flow]
764
766
  enabled = false
765
767
  ```
766
768
 
769
+ Codex can then write inside the workspace only and asks before anything riskier.
770
+ Opt in to the old permissive file (`approval_policy = "never"`,
771
+ `sandbox_mode = "danger-full-access"`, `web_search = "live"`) explicitly:
772
+
773
+ ```bash
774
+ npx ruflo@latest init --dual --codex-trust full # or: RUFLO_CODEX_TRUST=full
775
+ npx @claude-flow/codex init --codex-trust full
776
+ ```
777
+
778
+ `init` prints which level you got. An existing `.codex/config.toml` is never
779
+ rewritten, not even with `--force` (it holds your own sandbox and approval
780
+ choices), so to change the level of an existing file edit those three keys.
781
+ Unknown `--codex-trust` values are rejected on the command line and fall back to
782
+ `standard` (with a warning) from the environment variable.
783
+
767
784
  ### Environment Variables
768
785
 
769
786
  ```bash
@@ -75,6 +75,7 @@ program
75
75
  .option('-s, --skills <skills>', 'Comma-separated list of skills to include')
76
76
  .option('-f, --force', 'Overwrite existing files', false)
77
77
  .option('--dual', 'Generate both Codex and Claude Code configurations', false)
78
+ .option('--codex-trust <level>', 'Trust level of the generated .codex/config.toml: standard (default: workspace-write sandbox, on-request approvals) or full (danger-full-access, no approvals)')
78
79
  .option('-p, --path <path>', 'Project path', process.cwd())
79
80
  .option('-q, --quiet', 'Suppress verbose output', false)
80
81
  .action(async (options) => {
@@ -82,6 +83,11 @@ program
82
83
  if (!options.quiet) {
83
84
  printBanner();
84
85
  }
86
+ if (options.codexTrust !== undefined && !['standard', 'full'].includes(options.codexTrust)) {
87
+ console.error(chalk.red(`Invalid --codex-trust: ${options.codexTrust}`));
88
+ console.log(chalk.gray('Valid levels: standard, full'));
89
+ process.exit(1);
90
+ }
85
91
  // Validate template
86
92
  const validTemplates = ['minimal', 'default', 'full', 'enterprise'];
87
93
  if (!validTemplates.includes(options.template)) {
@@ -120,6 +126,7 @@ program
120
126
  skills,
121
127
  force: options.force,
122
128
  dual: options.dual,
129
+ codexTrust: options.codexTrust,
123
130
  });
124
131
  if (result.success) {
125
132
  console.log(chalk.green.bold('\n Project initialized successfully!'));