@claude-flow/cli 3.64.0 → 3.65.1

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 (93) hide show
  1. package/.claude/helpers/helpers.manifest.json +2 -2
  2. package/catalog-manifest.json +2 -2
  3. package/dist/src/commands/cost.d.ts +18 -0
  4. package/dist/src/commands/cost.js +195 -0
  5. package/dist/src/commands/doctor-auto-approve.d.ts +9 -0
  6. package/dist/src/commands/doctor-auto-approve.js +32 -0
  7. package/dist/src/commands/doctor.js +21 -3
  8. package/dist/src/commands/hive-mind-worker.d.ts +2 -0
  9. package/dist/src/commands/hive-mind-worker.js +9 -3
  10. package/dist/src/commands/hive-mind.js +15 -1
  11. package/dist/src/commands/hooks.js +8 -2
  12. package/dist/src/commands/index.js +2 -0
  13. package/dist/src/commands/init.js +76 -2
  14. package/dist/src/commands/mods.js +13 -0
  15. package/dist/src/commands/plugins-paths.d.ts +13 -0
  16. package/dist/src/commands/plugins-paths.js +55 -0
  17. package/dist/src/commands/plugins-verify.d.ts +9 -0
  18. package/dist/src/commands/plugins-verify.js +92 -0
  19. package/dist/src/commands/plugins.js +13 -1
  20. package/dist/src/cost/attribution.d.ts +35 -0
  21. package/dist/src/cost/attribution.js +101 -0
  22. package/dist/src/cost/budget-file.d.ts +57 -0
  23. package/dist/src/cost/budget-file.js +113 -0
  24. package/dist/src/cost/litellm.d.ts +69 -0
  25. package/dist/src/cost/litellm.js +135 -0
  26. package/dist/src/init/executor.d.ts +7 -2
  27. package/dist/src/init/executor.js +64 -10
  28. package/dist/src/init/helper-commands.d.ts +16 -0
  29. package/dist/src/init/helper-commands.js +73 -0
  30. package/dist/src/init/risky-settings-gate.d.ts +39 -0
  31. package/dist/src/init/risky-settings-gate.js +130 -0
  32. package/dist/src/init/settings-risk-scanner.d.ts +35 -0
  33. package/dist/src/init/settings-risk-scanner.js +85 -0
  34. package/dist/src/init/types.d.ts +12 -0
  35. package/dist/src/mcp-tools/capability-brain.d.ts +1 -1
  36. package/dist/src/mcp-tools/capability-brain.js +1 -1
  37. package/dist/src/mcp-tools/hooks-tools.js +2 -1
  38. package/dist/src/mcp-tools/http-fetch-pinned.d.ts +44 -0
  39. package/dist/src/mcp-tools/http-fetch-pinned.js +148 -0
  40. package/dist/src/mcp-tools/http-fetch-tools.d.ts +11 -2
  41. package/dist/src/mcp-tools/http-fetch-tools.js +19 -70
  42. package/dist/src/memory/verified-guards.d.ts +70 -0
  43. package/dist/src/memory/verified-guards.js +101 -0
  44. package/dist/src/memory/verified-outcomes.d.ts +68 -3
  45. package/dist/src/memory/verified-outcomes.js +88 -19
  46. package/dist/src/memory/verified-reverify.d.ts +63 -0
  47. package/dist/src/memory/verified-reverify.js +174 -0
  48. package/dist/src/missions/schemas.d.ts +4 -4
  49. package/dist/src/mods/apply.d.ts +2 -0
  50. package/dist/src/mods/apply.js +68 -8
  51. package/dist/src/mods/command-registry/schema.d.ts +36 -36
  52. package/dist/src/mods/compact-snapshots.d.ts +21 -0
  53. package/dist/src/mods/compact-snapshots.js +45 -0
  54. package/dist/src/mods/headless-check.d.ts +58 -0
  55. package/dist/src/mods/headless-check.js +137 -0
  56. package/dist/src/mods/headless-plugins.d.ts +44 -0
  57. package/dist/src/mods/headless-plugins.js +92 -0
  58. package/dist/src/mods/install-trust.d.ts +28 -0
  59. package/dist/src/mods/install-trust.js +127 -0
  60. package/dist/src/mods/install.d.ts +19 -0
  61. package/dist/src/mods/install.js +38 -1
  62. package/dist/src/mods/plugin-paths.d.ts +60 -0
  63. package/dist/src/mods/plugin-paths.js +131 -0
  64. package/dist/src/mods/plugin-repair.d.ts +8 -0
  65. package/dist/src/mods/plugin-repair.js +7 -0
  66. package/dist/src/plugins/marketplace-trust/scan.d.ts +23 -0
  67. package/dist/src/plugins/marketplace-trust/scan.js +255 -0
  68. package/dist/src/plugins/marketplace-trust/signature.d.ts +53 -0
  69. package/dist/src/plugins/marketplace-trust/signature.js +140 -0
  70. package/dist/src/plugins/marketplace-trust/verify-status.d.ts +42 -0
  71. package/dist/src/plugins/marketplace-trust/verify-status.js +110 -0
  72. package/dist/src/services/headless-worker-executor.d.ts +2 -0
  73. package/dist/src/services/headless-worker-executor.js +13 -3
  74. package/package.json +2 -1
  75. package/plugins/ruflo-metaharness/.claude-plugin/plugin.json +8 -1
  76. package/plugins/ruflo-metaharness/CHANGELOG.md +6 -0
  77. package/plugins/ruflo-metaharness/README.md +14 -1
  78. package/plugins/ruflo-metaharness/agents/metaharness-architect.md +2 -0
  79. package/plugins/ruflo-metaharness/hooks/hooks.json +16 -2
  80. package/plugins/ruflo-metaharness/scripts/_redblue.mjs +12 -2
  81. package/plugins/ruflo-metaharness/scripts/_secret-env.cjs +50 -0
  82. package/plugins/ruflo-metaharness/scripts/_secret.mjs +35 -0
  83. package/plugins/ruflo-metaharness/scripts/smoke.sh +7 -7
  84. package/plugins/ruflo-metaharness/skills/harness-bench/SKILL.md +2 -1
  85. package/plugins/ruflo-metaharness/skills/harness-drift-from-history/SKILL.md +1 -1
  86. package/plugins/ruflo-metaharness/skills/harness-evolve/SKILL.md +2 -1
  87. package/plugins/ruflo-metaharness/skills/harness-gepa/SKILL.md +1 -1
  88. package/plugins/ruflo-metaharness/skills/harness-learn/SKILL.md +2 -1
  89. package/plugins/ruflo-metaharness/skills/harness-mint/SKILL.md +2 -1
  90. package/plugins/ruflo-metaharness/skills/harness-oia-audit/SKILL.md +1 -0
  91. package/plugins/ruflo-metaharness/skills/harness-score/SKILL.md +1 -1
  92. package/plugins/ruflo-metaharness/skills/harness-security-bench/SKILL.md +2 -1
  93. package/plugins/ruflo-metaharness/skills/harness-similarity/SKILL.md +1 -1
@@ -0,0 +1,92 @@
1
+ /**
2
+ * ADR-510 §2.2 — give a headless `claude -p` worker its ruflo plugins.
3
+ *
4
+ * Every place ruflo builds a `claude -p` command line passes its argv and env
5
+ * through `withHeadlessPlugins`. It appends `--plugin-dir <path>` per plugin
6
+ * in the worker profile (default: ruflo-mods) and sets
7
+ * `CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1` unless the caller set it (a caller's
8
+ * `0` stays `0`). A caller's own `--plugin-dir` for the same plugin is never
9
+ * duplicated.
10
+ *
11
+ * A plugin that cannot be resolved does not stop the worker: it is logged once
12
+ * per process ("plugin <name> not found; worker runs without it") and returned
13
+ * in `missing` for the worker's status.
14
+ *
15
+ * Profile override: `RUFLO_HEADLESS_PLUGINS` is a comma list of plugin names;
16
+ * `none` (or an empty value) turns plugin loading off.
17
+ */
18
+ import { realpathSync } from 'node:fs';
19
+ import { basename, resolve } from 'node:path';
20
+ import { ENABLE_ENV } from './install.js';
21
+ import { resolvePluginPaths } from './plugin-paths.js';
22
+ export const DEFAULT_WORKER_PLUGINS = ['ruflo-mods'];
23
+ export const PROFILE_ENV = 'RUFLO_HEADLESS_PLUGINS';
24
+ const warned = new Set();
25
+ /** For tests: forget which missing plugins were already logged. */
26
+ export function resetHeadlessPluginWarnings() {
27
+ warned.clear();
28
+ }
29
+ /** The worker profile: explicit, else RUFLO_HEADLESS_PLUGINS, else ruflo-mods. */
30
+ export function workerProfile(env = process.env, explicit) {
31
+ if (explicit)
32
+ return [...explicit];
33
+ const raw = env[PROFILE_ENV];
34
+ if (raw === undefined)
35
+ return [...DEFAULT_WORKER_PLUGINS];
36
+ const names = raw.split(',').map((s) => s.trim()).filter(Boolean);
37
+ return names.length === 1 && names[0].toLowerCase() === 'none' ? [] : names;
38
+ }
39
+ function canonical(p) {
40
+ try {
41
+ return realpathSync(p);
42
+ }
43
+ catch {
44
+ return resolve(p);
45
+ }
46
+ }
47
+ /** Every `--plugin-dir` value already in argv (both `--plugin-dir x` and `--plugin-dir=x`). */
48
+ export function existingPluginDirs(argv) {
49
+ const dirs = [];
50
+ for (let i = 0; i < argv.length; i++) {
51
+ const a = argv[i];
52
+ if (a === '--plugin-dir' && i + 1 < argv.length)
53
+ dirs.push(argv[++i]);
54
+ else if (a.startsWith('--plugin-dir='))
55
+ dirs.push(a.slice('--plugin-dir='.length));
56
+ }
57
+ return dirs;
58
+ }
59
+ export function withHeadlessPlugins(argv, env, opts = {}) {
60
+ const profile = workerProfile(opts.env ?? env, opts.profile);
61
+ const outArgv = [...argv];
62
+ const outEnv = { ...env };
63
+ if (profile.length === 0)
64
+ return { argv: outArgv, env: outEnv, added: [], missing: [] };
65
+ const resolver = opts.resolver ?? resolvePluginPaths;
66
+ const { resolved, missing } = resolver(profile, { ...opts, env: opts.env ?? env });
67
+ const own = existingPluginDirs(argv);
68
+ const ownPaths = new Set(own.map(canonical));
69
+ const ownNames = new Set(own.map((d) => basename(resolve(d))));
70
+ const added = [];
71
+ for (const plugin of resolved) {
72
+ // The caller already passes this plugin (same folder, or its own copy of it).
73
+ if (ownPaths.has(canonical(plugin.path)) || ownNames.has(plugin.name))
74
+ continue;
75
+ outArgv.push('--plugin-dir', plugin.path);
76
+ added.push(plugin.name);
77
+ }
78
+ // A caller-supplied copy of a "missing" plugin is not missing.
79
+ const stillMissing = missing.filter((m) => !ownNames.has(m.name)).map((m) => m.name);
80
+ if ((added.length > 0 || own.length > 0) && outEnv[ENABLE_ENV] === undefined) {
81
+ outEnv[ENABLE_ENV] = '1';
82
+ }
83
+ const warn = opts.warn ?? ((line) => process.stderr.write(`${line}\n`));
84
+ for (const name of stillMissing) {
85
+ if (warned.has(name))
86
+ continue;
87
+ warned.add(name);
88
+ warn(`plugin ${name} not found; worker runs without it`);
89
+ }
90
+ return { argv: outArgv, env: outEnv, added, missing: stillMissing };
91
+ }
92
+ //# sourceMappingURL=headless-plugins.js.map
@@ -0,0 +1,28 @@
1
+ import { type PluginVerification } from '../plugins/marketplace-trust/signature.js';
2
+ import { type ModPlugin } from './install.js';
3
+ import { type ReadFs } from './plugin-resolve.js';
4
+ /**
5
+ * Scan each plugin the clone carries. Returns the plugin ids whose install must
6
+ * not run, each with the reason; prints every finding; records --accept-risk.
7
+ */
8
+ export declare function scanBeforeInstall(root: string, clone: string | null, plugins: readonly ModPlugin[], acceptRisk: ReadonlySet<string>, fs?: ReadFs,
9
+ /** Lines already printed / overrides already recorded by an earlier scan in the same run (scanned before enabling, then again after the clone refresh). */
10
+ seen?: Set<string>): {
11
+ blocked: Map<string, string>;
12
+ };
13
+ /** Verify the clone's ruflo plugins and write the status file; prints one line per non-verified plugin. */
14
+ export declare function verifyAndRecord(configDir: string, clone: string | null, names: readonly string[], home?: string): PluginVerification[];
15
+ export interface SignatureFinding {
16
+ name: string;
17
+ status: 'pass' | 'warn';
18
+ message: string;
19
+ fix?: string;
20
+ }
21
+ /**
22
+ * `ruflo mods doctor` (§2.1): one finding per ruflo mod plugin from its clone
23
+ * and install-cache signatures; writes the status file the mod's trust gate
24
+ * reads. `modified` / `bad-signature` warn: they are enforced only under
25
+ * modTrust: refuse-risky, since a local checkout is always modified.
26
+ */
27
+ export declare function signatureFindings(configDir: string, clone: string | null, names: readonly string[], home?: string): SignatureFinding[];
28
+ //# sourceMappingURL=install-trust.d.ts.map
@@ -0,0 +1,127 @@
1
+ /**
2
+ * ADR-509 on the `ruflo mods install` / `ruflo init` (mods) path: scan each
3
+ * plugin in the refreshed marketplace clone before `claude plugin install`
4
+ * (§2.2), and after the install verify the clone against its signatures and
5
+ * write the status file the ruflo-mods trust gate reads (§2.1): per plugin the
6
+ * worst of its clone and install-cache copies, and each copy by its directory.
7
+ */
8
+ import { homedir } from 'node:os';
9
+ import { dirname } from 'node:path';
10
+ import { output } from '../output.js';
11
+ import { formatScanFinding, gateScan, scanPluginDir } from '../plugins/marketplace-trust/scan.js';
12
+ import { describeVerification } from '../plugins/marketplace-trust/signature.js';
13
+ import { verifyClone, verifyInstallCache, writeVerifyStatus } from '../plugins/marketplace-trust/verify-status.js';
14
+ import { recordAcceptedRisk } from './install.js';
15
+ import { pluginManifestIn, nodeReadFs } from './plugin-resolve.js';
16
+ const nameOf = (id) => id.split('@')[0];
17
+ /**
18
+ * Scan each plugin the clone carries. Returns the plugin ids whose install must
19
+ * not run, each with the reason; prints every finding; records --accept-risk.
20
+ */
21
+ export function scanBeforeInstall(root, clone, plugins, acceptRisk, fs = nodeReadFs,
22
+ /** Lines already printed / overrides already recorded by an earlier scan in the same run (scanned before enabling, then again after the clone refresh). */
23
+ seen = new Set()) {
24
+ const blocked = new Map();
25
+ if (!clone)
26
+ return { blocked };
27
+ const byName = new Map();
28
+ const findings = plugins.flatMap((p) => {
29
+ const manifest = pluginManifestIn(clone, p.id, fs);
30
+ if (!fs.exists(manifest))
31
+ return [];
32
+ byName.set(nameOf(p.id), p.id);
33
+ return scanPluginDir(nameOf(p.id), dirname(dirname(manifest)));
34
+ });
35
+ // --accept-risk takes the plugin name or its id (name@ruflo).
36
+ const accept = new Set([...acceptRisk].map(nameOf));
37
+ const gate = gateScan(findings, accept);
38
+ for (const f of findings) {
39
+ const line = formatScanFinding(f);
40
+ if (seen.has(line))
41
+ continue;
42
+ seen.add(line);
43
+ if (f.severity === 'high')
44
+ output.writeln(`${output.error('✗')} scan ${line}`);
45
+ else
46
+ output.writeln(output.dim(` scan ${line}`));
47
+ }
48
+ for (const name of gate.blocked) {
49
+ const id = byName.get(name) ?? name;
50
+ const first = gate.findings.find((f) => f.plugin === name && f.severity === 'high');
51
+ blocked.set(id, `not installed: high scan finding in ${first.file} (${first.rule}); review it, then re-run with --accept-risk ${name}`);
52
+ }
53
+ const at = new Date().toISOString();
54
+ const accepted = gate.accepted.filter((plugin) => !seen.has(`accepted:${plugin}`));
55
+ for (const plugin of accepted)
56
+ seen.add(`accepted:${plugin}`);
57
+ recordAcceptedRisk(root, accepted.map((plugin) => ({
58
+ plugin,
59
+ at,
60
+ findings: gate.findings.filter((f) => f.plugin === plugin && f.severity === 'high').map(formatScanFinding),
61
+ })));
62
+ for (const plugin of accepted)
63
+ output.printWarning(`--accept-risk ${plugin}: installing past its high scan finding(s); recorded in .claude-flow/mods/install.json`);
64
+ return { blocked };
65
+ }
66
+ /** Verify the clone's ruflo plugins and write the status file; prints one line per non-verified plugin. */
67
+ export function verifyAndRecord(configDir, clone, names, home = homedir()) {
68
+ if (!clone)
69
+ return [];
70
+ let results;
71
+ let cache;
72
+ try {
73
+ results = verifyClone(clone, names);
74
+ // The install cache is what Claude Code loads an installed plugin from: it is recorded too (worst of both).
75
+ cache = verifyInstallCache(configDir, 'ruflo', names);
76
+ writeVerifyStatus(configDir, clone, results, home, cache);
77
+ }
78
+ catch (error) {
79
+ output.printWarning(`plugin signature check skipped: ${error.message}`);
80
+ return [];
81
+ }
82
+ for (const r of cache)
83
+ if (r.result === 'modified' || r.result === 'bad-signature')
84
+ output.writeln(`${output.error('✗')} signature (install cache) ${describeVerification(r)}`);
85
+ for (const r of results) {
86
+ if (r.result === 'verified')
87
+ output.writeln(`${output.success('✓')} signature ${r.plugin}: verified`);
88
+ else if (r.result !== 'unsigned')
89
+ output.writeln(`${output.error('✗')} signature ${describeVerification(r)}`);
90
+ }
91
+ const unsigned = results.filter((r) => r.result === 'unsigned').map((r) => r.plugin);
92
+ if (unsigned.length)
93
+ output.writeln(output.dim(` signature: unsigned (no .claude-plugin/signature.json): ${unsigned.join(', ')}`));
94
+ return results;
95
+ }
96
+ /**
97
+ * `ruflo mods doctor` (§2.1): one finding per ruflo mod plugin from its clone
98
+ * and install-cache signatures; writes the status file the mod's trust gate
99
+ * reads. `modified` / `bad-signature` warn: they are enforced only under
100
+ * modTrust: refuse-risky, since a local checkout is always modified.
101
+ */
102
+ export function signatureFindings(configDir, clone, names, home = homedir()) {
103
+ if (!clone)
104
+ return [];
105
+ let results;
106
+ let cache;
107
+ try {
108
+ results = verifyClone(clone, names);
109
+ cache = verifyInstallCache(configDir, 'ruflo', names);
110
+ writeVerifyStatus(configDir, clone, results, home, cache);
111
+ }
112
+ catch (error) {
113
+ return [{ name: 'plugin signatures', status: 'warn', message: `could not check: ${error.message}` }];
114
+ }
115
+ const rows = [...results.map((r) => ({ r, where: 'clone' })), ...cache.map((r) => ({ r, where: 'install cache' }))];
116
+ return rows.map(({ r, where }) => ({
117
+ name: `signature ${r.plugin} (${where})`,
118
+ // `unsigned` is reported, not warned: a clone from before the first signed release has no signature.json,
119
+ // and a --strict doctor gate must not fail on that alone. modified / bad-signature warn.
120
+ status: r.result === 'verified' || r.result === 'unsigned' ? 'pass' : 'warn',
121
+ message: r.result === 'unsigned' ? `${r.plugin}: unsigned (no .claude-plugin/signature.json in this release)` : describeVerification(r),
122
+ ...(r.result === 'modified' || r.result === 'bad-signature'
123
+ ? { fix: 'claude plugin marketplace update ruflo (a local checkout is always modified; enforced only with modTrust: refuse-risky)' }
124
+ : {}),
125
+ }));
126
+ }
127
+ //# sourceMappingURL=install-trust.js.map
@@ -57,11 +57,20 @@ export interface Added {
57
57
  */
58
58
  claudeInstalled?: string[];
59
59
  }
60
+ /** ADR-509 §2.2: an install that went ahead past a high scan finding because of --accept-risk <plugin>. */
61
+ export interface AcceptedRisk {
62
+ plugin: string;
63
+ at: string;
64
+ /** The high findings accepted, as printed (`[high] plugin: file (rule) detail`). */
65
+ findings: string[];
66
+ }
60
67
  export interface InstallRecord {
61
68
  version: 2;
62
69
  installedAt: string;
63
70
  /** What install added, per settings file it wrote. */
64
71
  files: Record<string, Added>;
72
+ /** ADR-509 §2.2: every --accept-risk override, newest last. */
73
+ acceptedRisk?: AcceptedRisk[];
65
74
  }
66
75
  type Settings = Record<string, unknown> & {
67
76
  enabledPlugins?: Record<string, unknown>;
@@ -89,8 +98,18 @@ export interface InstallResult {
89
98
  export declare function installMod(projectRoot: string, scope: Scope, dryRun?: boolean, plugins?: readonly string[], marketplace?: MarketplaceEntry): InstallResult;
90
99
  /** The install record, v1 (3.50.0: one file, ruflo-mods only) read as v2. */
91
100
  export declare function readRecord(projectRoot: string): InstallRecord | null;
101
+ /** ADR-509 §2.2: record an --accept-risk override in .claude-flow/mods/install.json. */
102
+ export declare function recordAcceptedRisk(projectRoot: string, entries: readonly AcceptedRisk[]): void;
92
103
  /** Note plugins ruflo installed with `claude plugin install` for one settings file. */
93
104
  export declare function recordClaudeInstalled(projectRoot: string, settingsFile: string, ids: readonly string[]): void;
105
+ /**
106
+ * ADR-509 §2.2: a plugin whose scan blocked it must not stay enabled — Claude
107
+ * Code loads an enabled plugin straight from the marketplace clone, with or
108
+ * without an install. Removes `enabledPlugins[id]` (only when it is `true`: a
109
+ * `false` someone set is already off) and drops the id from what the record
110
+ * says ruflo added. Returns the ids it turned off.
111
+ */
112
+ export declare function disableBlockedPlugins(projectRoot: string, settingsFile: string, ids: readonly string[]): string[];
94
113
  /** The scope a recorded settings file was written at. */
95
114
  export declare function scopeOfSettingsFile(file: string): Scope;
96
115
  export declare function uninstallMod(projectRoot: string, dryRun?: boolean): {
@@ -156,7 +156,10 @@ export function readRecord(projectRoot) {
156
156
  const strings = (v) => (Array.isArray(v) ? v.filter((p) => typeof p === 'string') : []);
157
157
  files[file] = { plugins: strings(a.plugins), marketplace: a.marketplace === true, env: a.env === true, claudeInstalled: strings(a.claudeInstalled) };
158
158
  }
159
- return { version: 2, installedAt: String(parsed.installedAt ?? ''), files };
159
+ const accepted = Array.isArray(parsed.acceptedRisk)
160
+ ? parsed.acceptedRisk.filter(isRecord).map((r) => ({ plugin: String(r.plugin ?? ''), at: String(r.at ?? ''), findings: Array.isArray(r.findings) ? r.findings.map(String) : [] }))
161
+ : undefined;
162
+ return { version: 2, installedAt: String(parsed.installedAt ?? ''), files, ...(accepted?.length ? { acceptedRisk: accepted } : {}) };
160
163
  }
161
164
  if (parsed.version === 1 && typeof parsed.settingsFile === 'string' && isRecord(parsed.added)) {
162
165
  const a = parsed.added;
@@ -168,6 +171,14 @@ export function readRecord(projectRoot) {
168
171
  return null;
169
172
  }
170
173
  }
174
+ /** ADR-509 §2.2: record an --accept-risk override in .claude-flow/mods/install.json. */
175
+ export function recordAcceptedRisk(projectRoot, entries) {
176
+ if (entries.length === 0)
177
+ return;
178
+ const record = readRecord(projectRoot) ?? { version: 2, installedAt: new Date().toISOString(), files: {} };
179
+ record.acceptedRisk = [...(record.acceptedRisk ?? []), ...entries];
180
+ writeJson(join(resolve(projectRoot), INSTALL_RECORD), record);
181
+ }
171
182
  /** Note plugins ruflo installed with `claude plugin install` for one settings file. */
172
183
  export function recordClaudeInstalled(projectRoot, settingsFile, ids) {
173
184
  const record = readRecord(projectRoot);
@@ -177,6 +188,32 @@ export function recordClaudeInstalled(projectRoot, settingsFile, ids) {
177
188
  entry.claudeInstalled = [...new Set([...(entry.claudeInstalled ?? []), ...ids])];
178
189
  writeJson(join(resolve(projectRoot), INSTALL_RECORD), record);
179
190
  }
191
+ /**
192
+ * ADR-509 §2.2: a plugin whose scan blocked it must not stay enabled — Claude
193
+ * Code loads an enabled plugin straight from the marketplace clone, with or
194
+ * without an install. Removes `enabledPlugins[id]` (only when it is `true`: a
195
+ * `false` someone set is already off) and drops the id from what the record
196
+ * says ruflo added. Returns the ids it turned off.
197
+ */
198
+ export function disableBlockedPlugins(projectRoot, settingsFile, ids) {
199
+ if (ids.length === 0 || !existsSync(settingsFile))
200
+ return [];
201
+ const settings = readSettingsFile(settingsFile);
202
+ const enabled = isRecord(settings.enabledPlugins) ? { ...settings.enabledPlugins } : {};
203
+ const off = ids.filter((id) => enabled[id] === true);
204
+ if (off.length) {
205
+ for (const id of off)
206
+ delete enabled[id];
207
+ writeJson(settingsFile, { ...settings, enabledPlugins: enabled });
208
+ }
209
+ const record = readRecord(projectRoot);
210
+ const entry = record?.files[settingsFile];
211
+ if (record && entry && entry.plugins.some((p) => ids.includes(p))) {
212
+ entry.plugins = entry.plugins.filter((p) => !ids.includes(p));
213
+ writeJson(join(resolve(projectRoot), INSTALL_RECORD), record);
214
+ }
215
+ return off;
216
+ }
180
217
  /** The scope a recorded settings file was written at. */
181
218
  export function scopeOfSettingsFile(file) {
182
219
  return file.endsWith('settings.local.json') ? 'local' : 'project';
@@ -0,0 +1,60 @@
1
+ /**
2
+ * ADR-510 — where a ruflo plugin lives on disk, for a headless `claude -p`
3
+ * worker (`--plugin-dir <path>`) or an Agent SDK session
4
+ * (`plugins: [{ type: "local", path }]`).
5
+ *
6
+ * `claude -p` on a fresh config loads no marketplace plugins: the marketplace
7
+ * is cloned only by an interactive, trusted start. So a worker has to be told
8
+ * where each plugin is. Sources, in order:
9
+ *
10
+ * 1. a `--source local` directory marketplace (a ruflo checkout) that Claude
11
+ * Code itself records in the user-level `known_marketplaces.json`, i.e.
12
+ * one the user added through an interactive, trusted start;
13
+ * 2. the marketplace clone (`<config>/plugins/marketplaces/ruflo/...`);
14
+ * 3. the install cache (`<config>/plugins/cache/ruflo/<name>/<version>`).
15
+ *
16
+ * A folder without `.claude-plugin/plugin.json` is never returned: the plugin
17
+ * is reported missing instead. Read-only; the filesystem is injectable.
18
+ *
19
+ * Trust: nothing inside the project decides the path. A repository's own
20
+ * `.claude/settings.json` / `settings.local.json` (`extraKnownMarketplaces`)
21
+ * is never read here: interactive Claude Code gates a project-declared
22
+ * marketplace behind workspace trust, but `--plugin-dir` loads with no prompt
23
+ * and replaces the user's installed copy of the same name. Letting a cloned
24
+ * repo point the headless worker at its own folder would run repo-supplied
25
+ * function hooks in every daemon worker, in place of the user's guard.
26
+ */
27
+ import { type ReadFs } from './plugin-resolve.js';
28
+ export type PluginSource = 'local' | 'clone' | 'cache';
29
+ export interface ResolvedPlugin {
30
+ name: string;
31
+ /** Absolute plugin folder (the one holding `.claude-plugin/plugin.json`). */
32
+ path: string;
33
+ source: PluginSource;
34
+ }
35
+ export interface MissingPlugin {
36
+ name: string;
37
+ reason: string;
38
+ }
39
+ export interface PluginPaths {
40
+ resolved: ResolvedPlugin[];
41
+ missing: MissingPlugin[];
42
+ }
43
+ /** ReadFs plus a directory listing (for the install cache's version folders). */
44
+ export interface PluginFs extends ReadFs {
45
+ list(path: string): string[];
46
+ }
47
+ export declare const nodePluginFs: PluginFs;
48
+ export interface ResolveOptions {
49
+ /**
50
+ * Accepted for callers' convenience but deliberately not consulted: the
51
+ * project's own settings never choose a headless plugin folder (see "Trust").
52
+ */
53
+ projectRoot?: string;
54
+ env?: NodeJS.ProcessEnv;
55
+ home?: string;
56
+ fs?: PluginFs;
57
+ }
58
+ /** Resolve each plugin name (e.g. `ruflo-mods`) to an absolute folder, or report it missing. */
59
+ export declare function resolvePluginPaths(names: readonly string[], opts?: ResolveOptions): PluginPaths;
60
+ //# sourceMappingURL=plugin-paths.d.ts.map
@@ -0,0 +1,131 @@
1
+ /**
2
+ * ADR-510 — where a ruflo plugin lives on disk, for a headless `claude -p`
3
+ * worker (`--plugin-dir <path>`) or an Agent SDK session
4
+ * (`plugins: [{ type: "local", path }]`).
5
+ *
6
+ * `claude -p` on a fresh config loads no marketplace plugins: the marketplace
7
+ * is cloned only by an interactive, trusted start. So a worker has to be told
8
+ * where each plugin is. Sources, in order:
9
+ *
10
+ * 1. a `--source local` directory marketplace (a ruflo checkout) that Claude
11
+ * Code itself records in the user-level `known_marketplaces.json`, i.e.
12
+ * one the user added through an interactive, trusted start;
13
+ * 2. the marketplace clone (`<config>/plugins/marketplaces/ruflo/...`);
14
+ * 3. the install cache (`<config>/plugins/cache/ruflo/<name>/<version>`).
15
+ *
16
+ * A folder without `.claude-plugin/plugin.json` is never returned: the plugin
17
+ * is reported missing instead. Read-only; the filesystem is injectable.
18
+ *
19
+ * Trust: nothing inside the project decides the path. A repository's own
20
+ * `.claude/settings.json` / `settings.local.json` (`extraKnownMarketplaces`)
21
+ * is never read here: interactive Claude Code gates a project-declared
22
+ * marketplace behind workspace trust, but `--plugin-dir` loads with no prompt
23
+ * and replaces the user's installed copy of the same name. Letting a cloned
24
+ * repo point the headless worker at its own folder would run repo-supplied
25
+ * function hooks in every daemon worker, in place of the user's guard.
26
+ */
27
+ import { existsSync, readdirSync } from 'node:fs';
28
+ import { homedir } from 'node:os';
29
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
30
+ import { MARKETPLACE_NAME } from './install.js';
31
+ import { claudeConfigDir, marketplaceState, nodeReadFs, pluginManifestIn } from './plugin-resolve.js';
32
+ export const nodePluginFs = {
33
+ ...nodeReadFs,
34
+ list: (p) => {
35
+ try {
36
+ return existsSync(p) ? readdirSync(p) : [];
37
+ }
38
+ catch {
39
+ return [];
40
+ }
41
+ },
42
+ };
43
+ const MANIFEST = join('.claude-plugin', 'plugin.json');
44
+ function readJson(fs, path) {
45
+ const text = fs.readText(path);
46
+ if (text === undefined)
47
+ return undefined;
48
+ try {
49
+ return JSON.parse(text);
50
+ }
51
+ catch {
52
+ return undefined;
53
+ }
54
+ }
55
+ const isRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
56
+ /**
57
+ * The directory marketplace Claude Code knows as `ruflo`, from the user-level
58
+ * `known_marketplaces.json` only (see "Trust" above). A relative path is
59
+ * rejected: Claude Code records absolute ones, and a relative path would
60
+ * resolve against whatever directory the worker happens to start in.
61
+ */
62
+ function localMarketplaceDir(configDir, fs) {
63
+ const market = marketplaceState(configDir, fs);
64
+ if (market.source?.source !== 'directory')
65
+ return null;
66
+ const declared = typeof market.source.path === 'string' ? market.source.path : null;
67
+ const dir = market.location ?? declared;
68
+ return dir && isAbsolute(dir) && (!declared || isAbsolute(declared)) ? dir : null;
69
+ }
70
+ /** Numeric-aware version compare ("0.10.0" > "0.9.3"); non-numeric parts compare as text. */
71
+ function compareVersions(a, b) {
72
+ const pa = a.split(/[.-]/);
73
+ const pb = b.split(/[.-]/);
74
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
75
+ const x = pa[i] ?? '';
76
+ const y = pb[i] ?? '';
77
+ const nx = /^\d+$/.test(x) ? Number(x) : NaN;
78
+ const ny = /^\d+$/.test(y) ? Number(y) : NaN;
79
+ const d = !Number.isNaN(nx) && !Number.isNaN(ny) ? nx - ny : x.localeCompare(y);
80
+ if (d !== 0)
81
+ return d;
82
+ }
83
+ return 0;
84
+ }
85
+ /** The cached install: installed_plugins.json's path first, else the highest version folder. */
86
+ function cachedPluginDir(configDir, name, fs) {
87
+ const record = readJson(fs, join(configDir, 'plugins', 'installed_plugins.json'));
88
+ const plugins = isRecord(record) && isRecord(record.plugins) ? record.plugins : undefined;
89
+ const entries = plugins && Array.isArray(plugins[`${name}@${MARKETPLACE_NAME}`]) ? plugins[`${name}@${MARKETPLACE_NAME}`] : [];
90
+ for (const e of entries) {
91
+ if (isRecord(e) && typeof e.installPath === 'string' && fs.exists(join(e.installPath, MANIFEST)))
92
+ return e.installPath;
93
+ }
94
+ const base = join(configDir, 'plugins', 'cache', MARKETPLACE_NAME, name);
95
+ const versions = fs.list(base).filter((v) => fs.exists(join(base, v, MANIFEST))).sort(compareVersions);
96
+ return versions.length > 0 ? join(base, versions[versions.length - 1]) : null;
97
+ }
98
+ /** Plugin folder from a marketplace root (its marketplace.json `source`, else plugins/<name>). */
99
+ function pluginDirIn(root, name, fs) {
100
+ const manifest = pluginManifestIn(root, `${name}@${MARKETPLACE_NAME}`, fs);
101
+ return fs.exists(manifest) ? dirname(dirname(manifest)) : null;
102
+ }
103
+ /** Resolve each plugin name (e.g. `ruflo-mods`) to an absolute folder, or report it missing. */
104
+ export function resolvePluginPaths(names, opts = {}) {
105
+ const env = opts.env ?? process.env;
106
+ const fs = opts.fs ?? nodePluginFs;
107
+ const configDir = claudeConfigDir(env, opts.home ?? homedir());
108
+ const local = localMarketplaceDir(configDir, fs);
109
+ const market = marketplaceState(configDir, fs);
110
+ const clone = market.source?.source === 'directory' ? null : market.location;
111
+ const out = { resolved: [], missing: [] };
112
+ for (const raw of names) {
113
+ const name = raw.split('@')[0].trim();
114
+ if (!name)
115
+ continue;
116
+ const fromLocal = local ? pluginDirIn(local, name, fs) : null;
117
+ const fromClone = !fromLocal && clone ? pluginDirIn(clone, name, fs) : null;
118
+ const fromCache = !fromLocal && !fromClone ? cachedPluginDir(configDir, name, fs) : null;
119
+ const hit = [[fromLocal, 'local'], [fromClone, 'clone'], [fromCache, 'cache']];
120
+ const found = hit.find(([p]) => p !== null);
121
+ if (found) {
122
+ out.resolved.push({ name, path: resolve(found[0]), source: found[1] });
123
+ }
124
+ else {
125
+ const looked = [local && `local marketplace ${local}`, clone && `clone ${clone}`, `cache ${join(configDir, 'plugins', 'cache', MARKETPLACE_NAME, name)}`].filter(Boolean);
126
+ out.missing.push({ name, reason: `no ${MANIFEST} in ${looked.join(', ')}` });
127
+ }
128
+ }
129
+ return out;
130
+ }
131
+ //# sourceMappingURL=plugin-paths.js.map
@@ -47,6 +47,14 @@ export interface RepairOptions {
47
47
  exec?: Exec;
48
48
  env?: NodeJS.ProcessEnv;
49
49
  fs?: ReadFs;
50
+ /**
51
+ * ADR-509 §2.2: called once the marketplace step has refreshed the clone and
52
+ * before any `claude plugin install`; a plugin id it returns is not installed
53
+ * (its step is a failure naming the reason).
54
+ */
55
+ beforeInstall?: (clone: string | null) => {
56
+ blocked: ReadonlyMap<string, string>;
57
+ };
50
58
  }
51
59
  /**
52
60
  * `update` when Claude Code already knows ruflo by the wanted source; else
@@ -88,9 +88,16 @@ export async function repairPluginInstall(opts) {
88
88
  steps.push({ argv: installArgv(p.id, opts.scope), state: 'failed', output: `${p.id} is not in ${wanted.source.path}` });
89
89
  return { ok: missing.length === 0, steps };
90
90
  }
91
+ const gate = opts.beforeInstall?.(market.location);
91
92
  let ok = true;
92
93
  for (const plugin of plugins) {
93
94
  const argv = installArgv(plugin.id, opts.scope);
95
+ const blocked = gate?.blocked.get(plugin.id);
96
+ if (blocked) {
97
+ steps.push({ argv, state: 'failed', output: blocked });
98
+ ok = false;
99
+ continue;
100
+ }
94
101
  if (!cloneHas(market, plugin.id, fs)) {
95
102
  steps.push({ argv, state: plugin.required ? 'failed' : 'pending', output: `${plugin.id} is not in the ruflo marketplace${plugin.required ? ' even after the update' : ' yet (pending)'}` });
96
103
  ok &&= !plugin.required;
@@ -0,0 +1,23 @@
1
+ export type ScanSeverity = 'high' | 'medium';
2
+ export interface ScanFinding {
3
+ plugin: string;
4
+ severity: ScanSeverity;
5
+ file: string;
6
+ rule: string;
7
+ detail: string;
8
+ }
9
+ /** Every finding for one plugin directory; empty when the plugin runs nothing on install. */
10
+ export declare function scanPluginDir(plugin: string, root: string): ScanFinding[];
11
+ export declare const formatScanFinding: (f: ScanFinding) => string;
12
+ export interface ScanGate {
13
+ /** Plugins with a high finding that were not accepted: their install must not run. */
14
+ blocked: string[];
15
+ /** Plugins with a high finding that --accept-risk let through. */
16
+ accepted: string[];
17
+ findings: ScanFinding[];
18
+ }
19
+ /** Applies §2.2: a high finding blocks, unless the plugin is named in acceptRisk. */
20
+ export declare function gateScan(findings: readonly ScanFinding[], acceptRisk: ReadonlySet<string>): ScanGate;
21
+ /** `--accept-risk a,b` or repeated: plugin names (or ids) to install past a high scan finding (ADR-509 §2.2). */
22
+ export declare function acceptRiskFrom(flags: Record<string, unknown>): string[];
23
+ //# sourceMappingURL=scan.d.ts.map