@claude-flow/cli 3.63.9 → 3.65.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/.claude/helpers/helpers.manifest.json +2 -2
  2. package/catalog-manifest.json +3 -3
  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 +28 -2
  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 +45 -0
  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.d.ts +1 -0
  38. package/dist/src/mcp-tools/hooks-tools.js +38 -0
  39. package/dist/src/memory/verified-guards.d.ts +70 -0
  40. package/dist/src/memory/verified-guards.js +101 -0
  41. package/dist/src/memory/verified-outcomes.d.ts +228 -0
  42. package/dist/src/memory/verified-outcomes.js +372 -0
  43. package/dist/src/memory/verified-reverify.d.ts +63 -0
  44. package/dist/src/memory/verified-reverify.js +174 -0
  45. package/dist/src/missions/schemas.d.ts +36 -36
  46. package/dist/src/mods/apply.d.ts +2 -0
  47. package/dist/src/mods/apply.js +68 -8
  48. package/dist/src/mods/command-registry/schema.d.ts +222 -222
  49. package/dist/src/mods/compact-snapshots.d.ts +21 -0
  50. package/dist/src/mods/compact-snapshots.js +45 -0
  51. package/dist/src/mods/headless-check.d.ts +58 -0
  52. package/dist/src/mods/headless-check.js +137 -0
  53. package/dist/src/mods/headless-plugins.d.ts +44 -0
  54. package/dist/src/mods/headless-plugins.js +92 -0
  55. package/dist/src/mods/install-trust.d.ts +28 -0
  56. package/dist/src/mods/install-trust.js +127 -0
  57. package/dist/src/mods/install.d.ts +19 -0
  58. package/dist/src/mods/install.js +38 -1
  59. package/dist/src/mods/plugin-paths.d.ts +60 -0
  60. package/dist/src/mods/plugin-paths.js +131 -0
  61. package/dist/src/mods/plugin-repair.d.ts +8 -0
  62. package/dist/src/mods/plugin-repair.js +7 -0
  63. package/dist/src/plugins/marketplace-trust/scan.d.ts +23 -0
  64. package/dist/src/plugins/marketplace-trust/scan.js +255 -0
  65. package/dist/src/plugins/marketplace-trust/signature.d.ts +53 -0
  66. package/dist/src/plugins/marketplace-trust/signature.js +140 -0
  67. package/dist/src/plugins/marketplace-trust/verify-status.d.ts +42 -0
  68. package/dist/src/plugins/marketplace-trust/verify-status.js +110 -0
  69. package/dist/src/services/headless-worker-executor.d.ts +2 -0
  70. package/dist/src/services/headless-worker-executor.js +13 -3
  71. package/package.json +2 -1
  72. package/plugins/ruflo-metaharness/.claude-plugin/plugin.json +8 -1
  73. package/plugins/ruflo-metaharness/CHANGELOG.md +6 -0
  74. package/plugins/ruflo-metaharness/README.md +14 -1
  75. package/plugins/ruflo-metaharness/agents/metaharness-architect.md +2 -0
  76. package/plugins/ruflo-metaharness/hooks/hooks.json +16 -2
  77. package/plugins/ruflo-metaharness/scripts/_redblue.mjs +12 -2
  78. package/plugins/ruflo-metaharness/scripts/_secret-env.cjs +50 -0
  79. package/plugins/ruflo-metaharness/scripts/_secret.mjs +35 -0
  80. package/plugins/ruflo-metaharness/scripts/smoke.sh +7 -7
  81. package/plugins/ruflo-metaharness/skills/harness-bench/SKILL.md +2 -1
  82. package/plugins/ruflo-metaharness/skills/harness-drift-from-history/SKILL.md +1 -1
  83. package/plugins/ruflo-metaharness/skills/harness-evolve/SKILL.md +2 -1
  84. package/plugins/ruflo-metaharness/skills/harness-gepa/SKILL.md +1 -1
  85. package/plugins/ruflo-metaharness/skills/harness-learn/SKILL.md +2 -1
  86. package/plugins/ruflo-metaharness/skills/harness-mint/SKILL.md +2 -1
  87. package/plugins/ruflo-metaharness/skills/harness-oia-audit/SKILL.md +1 -0
  88. package/plugins/ruflo-metaharness/skills/harness-score/SKILL.md +1 -1
  89. package/plugins/ruflo-metaharness/skills/harness-security-bench/SKILL.md +2 -1
  90. package/plugins/ruflo-metaharness/skills/harness-similarity/SKILL.md +1 -1
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Signed plugin manifests for the ruflo marketplace (ADR-509 §2.1).
3
+ *
4
+ * At release, scripts/sign-plugins.mjs writes `<plugin>/.claude-plugin/signature.json`
5
+ * for every plugin listed in `.claude-plugin/marketplace.json`: a sorted list of
6
+ * every file's path and SHA-256, the plugin's version, and an Ed25519 signature
7
+ * over the canonical JSON of both. The key pair is the existing helpers signing
8
+ * key (ADR-174): the public half is RUFLO_HELPERS_PUBKEY, already pinned here.
9
+ *
10
+ * This module is the verifier. The canonical bytes MUST match the signer
11
+ * byte-for-byte; a round-trip test (signer script → this verifier) pins that.
12
+ */
13
+ import { createHash, verify as edVerify } from 'node:crypto';
14
+ import { existsSync, lstatSync, readFileSync, readdirSync, readlinkSync } from 'node:fs';
15
+ import { join } from 'node:path';
16
+ import { RUFLO_HELPERS_PUBKEY } from '../../init/helper-signing.js';
17
+ export const SIGNATURE_RELATIVE = '.claude-plugin/signature.json';
18
+ /** Never part of a plugin's signed content: VCS metadata and installed dependencies. */
19
+ const SKIP_DIRS = new Set(['.git', 'node_modules']);
20
+ const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
21
+ /** Canonical bytes: fixed key order, files sorted by path. MUST match scripts/sign-plugins.mjs. */
22
+ export function canonicalPluginManifestBytes(m) {
23
+ const files = [...m.files]
24
+ .map((f) => ({ path: f.path, sha256: f.sha256 }))
25
+ .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
26
+ return Buffer.from(JSON.stringify({ plugin: m.plugin, version: m.version, files }), 'utf-8');
27
+ }
28
+ /**
29
+ * Every file under a plugin root, as posix paths, excluding the signature
30
+ * itself. A symlink is an entry of its own (digest of `link:<target>`), never
31
+ * followed: following it would let a link swap in content the digest never saw.
32
+ */
33
+ export function digestPluginDir(root) {
34
+ const out = [];
35
+ const walk = (rel) => {
36
+ const abs = rel ? join(root, rel) : root;
37
+ for (const name of readdirSync(abs).sort()) {
38
+ const childRel = rel ? `${rel}/${name}` : name;
39
+ if (childRel === SIGNATURE_RELATIVE)
40
+ continue;
41
+ const st = lstatSync(join(abs, name));
42
+ if (st.isSymbolicLink())
43
+ out.push({ path: childRel, sha256: sha256(`link:${readlinkSync(join(abs, name))}`) });
44
+ else if (st.isDirectory()) {
45
+ if (!SKIP_DIRS.has(name))
46
+ walk(childRel);
47
+ }
48
+ else if (st.isFile())
49
+ out.push({ path: childRel, sha256: sha256(readFileSync(join(abs, name))) });
50
+ }
51
+ };
52
+ walk('');
53
+ return out;
54
+ }
55
+ function parseSigned(text) {
56
+ try {
57
+ const s = JSON.parse(text);
58
+ if (!s || s.algorithm !== 'ed25519' || typeof s.signature !== 'string' || !s.manifest)
59
+ return null;
60
+ const m = s.manifest;
61
+ if (typeof m.plugin !== 'string' || typeof m.version !== 'string' || !Array.isArray(m.files))
62
+ return null;
63
+ if (!m.files.every((f) => f && typeof f.path === 'string' && typeof f.sha256 === 'string'))
64
+ return null;
65
+ return s;
66
+ }
67
+ catch {
68
+ return null;
69
+ }
70
+ }
71
+ /** Signature check only: true when `signed.signature` verifies over its manifest. */
72
+ export function signatureValid(signed, pubkeyPem = RUFLO_HELPERS_PUBKEY) {
73
+ try {
74
+ return edVerify(null, canonicalPluginManifestBytes(signed.manifest), pubkeyPem, Buffer.from(signed.signature, 'base64'));
75
+ }
76
+ catch {
77
+ return false;
78
+ }
79
+ }
80
+ /**
81
+ * Check a plugin directory against its signature.json. `expectName` (the
82
+ * marketplace name) must match the signed plugin name: a valid signature
83
+ * copied from another plugin is a bad signature here, not a pass.
84
+ */
85
+ export function verifyPluginDir(dir, expectName, pubkeyPem = RUFLO_HELPERS_PUBKEY) {
86
+ const base = { plugin: expectName, dir, result: 'unsigned', changed: [], missing: [], extra: [] };
87
+ const sigPath = join(dir, SIGNATURE_RELATIVE);
88
+ if (!existsSync(sigPath))
89
+ return base;
90
+ const signed = parseSigned(readFileSync(sigPath, 'utf-8'));
91
+ if (!signed || signed.manifest.plugin !== expectName || !signatureValid(signed, pubkeyPem)) {
92
+ return { ...base, result: 'bad-signature', version: signed?.manifest.version };
93
+ }
94
+ const signedFiles = new Map(signed.manifest.files.map((f) => [f.path, f.sha256]));
95
+ const onDisk = new Map(digestPluginDir(dir).map((f) => [f.path, f.sha256]));
96
+ const changed = [];
97
+ const missing = [];
98
+ const extra = [];
99
+ for (const [path, digest] of signedFiles) {
100
+ const now = onDisk.get(path);
101
+ if (now === undefined)
102
+ missing.push(path);
103
+ else if (now !== digest)
104
+ changed.push(path);
105
+ }
106
+ for (const path of onDisk.keys())
107
+ if (!signedFiles.has(path))
108
+ extra.push(path);
109
+ const clean = changed.length === 0 && missing.length === 0 && extra.length === 0;
110
+ return { ...base, result: clean ? 'verified' : 'modified', changed, missing, extra, version: signed.manifest.version };
111
+ }
112
+ /** The plugins a marketplace clone lists, each with its directory (its `source`, else plugins/<name>). */
113
+ export function listMarketplacePlugins(clone) {
114
+ const catalog = JSON.parse(readFileSync(join(clone, '.claude-plugin', 'marketplace.json'), 'utf-8'));
115
+ const listed = Array.isArray(catalog.plugins) ? catalog.plugins : [];
116
+ const out = [];
117
+ for (const p of listed) {
118
+ if (!p || typeof p.name !== 'string' || !/^[A-Za-z0-9._-]{1,100}$/.test(p.name))
119
+ continue;
120
+ const source = typeof p.source === 'string' ? p.source : `./plugins/${p.name}`;
121
+ // A source outside the clone (absolute, `..`, or a remote object) is not this clone's to verify.
122
+ if (source.startsWith('/') || source.split(/[\\/]/).includes('..'))
123
+ continue;
124
+ out.push({ name: p.name, dir: join(clone, source) });
125
+ }
126
+ return out;
127
+ }
128
+ /** One line per verification, with the differing files named (capped). */
129
+ export function describeVerification(v, cap = 5) {
130
+ if (v.result !== 'modified')
131
+ return `${v.plugin}: ${v.result}`;
132
+ const parts = [
133
+ ...v.changed.map((p) => `changed ${p}`),
134
+ ...v.missing.map((p) => `missing ${p}`),
135
+ ...v.extra.map((p) => `extra ${p}`),
136
+ ];
137
+ const shown = parts.slice(0, cap).join(', ');
138
+ return `${v.plugin}: modified (${shown}${parts.length > cap ? `, … ${parts.length - cap} more` : ''})`;
139
+ }
140
+ //# sourceMappingURL=signature.js.map
@@ -0,0 +1,42 @@
1
+ import { type PluginVerification, type VerifyResult } from './signature.js';
2
+ export declare const VERIFY_STATUS_RELATIVE: string;
3
+ export interface VerifyStatusEntry {
4
+ writtenAt: string;
5
+ clone: string;
6
+ /**
7
+ * Plugin name → the WORST result across its clone copy and every install-cache
8
+ * copy: Claude Code loads an installed plugin from the cache, so a clean clone
9
+ * next to a tampered cache must not read as verified.
10
+ */
11
+ plugins: Record<string, VerifyResult>;
12
+ /** Plugin directory (normalized, see normalizeTrustPath) → that copy's own result: the mod looks up the dir it is loading. */
13
+ paths?: Record<string, VerifyResult>;
14
+ }
15
+ export interface VerifyStatusFile {
16
+ version: 1;
17
+ byConfigDir: Record<string, VerifyStatusEntry>;
18
+ }
19
+ /**
20
+ * The one spelling of a path both sides key on (the CLI writing, the mod
21
+ * reading): forward slashes, no repeated or trailing slash, and a Windows
22
+ * drive path lower-cased (Windows paths are case-insensitive, and HOME /
23
+ * USERPROFILE / os.homedir() disagree on separators). Kept byte-identical to
24
+ * plugins/ruflo-mods/hooks/trust.ts normalizeTrustPath.
25
+ */
26
+ export declare function normalizeTrustPath(p: string): string;
27
+ /** The worse of two results (bad-signature > modified > unsigned > verified). */
28
+ export declare const worseResult: (a: VerifyResult | undefined, b: VerifyResult) => VerifyResult;
29
+ export declare const verifyStatusPath: (home?: string) => string;
30
+ export declare function readVerifyStatus(home?: string): VerifyStatusFile;
31
+ /**
32
+ * Record the clone's results and the install-cache copies' (`cache`): per plugin
33
+ * the worst of all its copies, and per copy its own result keyed by directory.
34
+ */
35
+ export declare function writeVerifyStatus(configDir: string, clone: string, results: readonly PluginVerification[], home?: string, cache?: readonly PluginVerification[]): string;
36
+ /** Verify every plugin a clone lists (or only `names`). A plugin not in the clone is skipped. */
37
+ export declare function verifyClone(clone: string, names?: readonly string[]): PluginVerification[];
38
+ /** Install-cache copies of ruflo plugins (installed_plugins.json), each verified as its own row. */
39
+ export declare function verifyInstallCache(configDir: string, marketplace: string, names?: readonly string[]): PluginVerification[];
40
+ /** The results that §2.1 makes enforceable under refuse-risky. */
41
+ export declare const isUntrusted: (r: VerifyResult) => r is "modified" | "bad-signature";
42
+ //# sourceMappingURL=verify-status.d.ts.map
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Where the ruflo marketplace verification lands (ADR-509 §2.1): `ruflo mods
3
+ * install`, `ruflo mods doctor` and `ruflo plugins verify` check the clone
4
+ * Claude Code loads from (and the install cache) and write the result to a
5
+ * status file that the ruflo-mods `plugin.register` gate reads under
6
+ * `modTrust: refuse-risky`.
7
+ *
8
+ * The file lives under the user's home (~/.claude-flow/mods/plugin-verify.json),
9
+ * not in a project: the clone is per Claude Code config dir, and a repository
10
+ * must not be able to ship a file that says its own tampered clone verified.
11
+ * It is keyed by config dir, so two config dirs never overwrite each other.
12
+ */
13
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
14
+ import { homedir } from 'node:os';
15
+ import { dirname, join } from 'node:path';
16
+ import { listMarketplacePlugins, verifyPluginDir } from './signature.js';
17
+ export const VERIFY_STATUS_RELATIVE = join('.claude-flow', 'mods', 'plugin-verify.json');
18
+ /**
19
+ * The one spelling of a path both sides key on (the CLI writing, the mod
20
+ * reading): forward slashes, no repeated or trailing slash, and a Windows
21
+ * drive path lower-cased (Windows paths are case-insensitive, and HOME /
22
+ * USERPROFILE / os.homedir() disagree on separators). Kept byte-identical to
23
+ * plugins/ruflo-mods/hooks/trust.ts normalizeTrustPath.
24
+ */
25
+ export function normalizeTrustPath(p) {
26
+ let out = p.replace(/\\/g, '/').replace(/\/{2,}/g, '/');
27
+ if (out.length > 1)
28
+ out = out.replace(/\/+$/, '');
29
+ return /^[A-Za-z]:(\/|$)/.test(out) ? out.toLowerCase() : out;
30
+ }
31
+ const RANK = { verified: 0, unsigned: 1, modified: 2, 'bad-signature': 3 };
32
+ /** The worse of two results (bad-signature > modified > unsigned > verified). */
33
+ export const worseResult = (a, b) => (a === undefined || RANK[b] > RANK[a] ? b : a);
34
+ export const verifyStatusPath = (home = homedir()) => join(home, VERIFY_STATUS_RELATIVE);
35
+ export function readVerifyStatus(home = homedir()) {
36
+ try {
37
+ const parsed = JSON.parse(readFileSync(verifyStatusPath(home), 'utf-8'));
38
+ if (parsed && parsed.version === 1 && parsed.byConfigDir && typeof parsed.byConfigDir === 'object')
39
+ return parsed;
40
+ }
41
+ catch {
42
+ // absent or unreadable: nothing verified yet
43
+ }
44
+ return { version: 1, byConfigDir: {} };
45
+ }
46
+ /**
47
+ * Record the clone's results and the install-cache copies' (`cache`): per plugin
48
+ * the worst of all its copies, and per copy its own result keyed by directory.
49
+ */
50
+ export function writeVerifyStatus(configDir, clone, results, home = homedir(), cache = []) {
51
+ const file = readVerifyStatus(home);
52
+ const plugins = {};
53
+ const paths = {};
54
+ for (const r of [...results, ...cache]) {
55
+ plugins[r.plugin] = worseResult(plugins[r.plugin], r.result);
56
+ const key = normalizeTrustPath(r.dir);
57
+ paths[key] = worseResult(paths[key], r.result);
58
+ }
59
+ const key = normalizeTrustPath(configDir);
60
+ // An entry an older writer keyed with another spelling of the same dir is replaced, not left beside it.
61
+ for (const k of Object.keys(file.byConfigDir))
62
+ if (k !== key && normalizeTrustPath(k) === key)
63
+ delete file.byConfigDir[k];
64
+ file.byConfigDir[key] = { writtenAt: new Date().toISOString(), clone, plugins, paths };
65
+ const path = verifyStatusPath(home);
66
+ mkdirSync(dirname(path), { recursive: true });
67
+ const tmp = `${path}.${process.pid}.tmp`;
68
+ writeFileSync(tmp, JSON.stringify(file, null, 2) + '\n', 'utf-8');
69
+ renameSync(tmp, path);
70
+ return path;
71
+ }
72
+ /** Verify every plugin a clone lists (or only `names`). A plugin not in the clone is skipped. */
73
+ export function verifyClone(clone, names) {
74
+ const want = names && names.length ? new Set(names) : null;
75
+ return listMarketplacePlugins(clone)
76
+ .filter((p) => !want || want.has(p.name))
77
+ .filter((p) => existsSync(join(p.dir, '.claude-plugin', 'plugin.json')))
78
+ .map((p) => verifyPluginDir(p.dir, p.name));
79
+ }
80
+ /** Install-cache copies of ruflo plugins (installed_plugins.json), each verified as its own row. */
81
+ export function verifyInstallCache(configDir, marketplace, names) {
82
+ let installed;
83
+ try {
84
+ installed = JSON.parse(readFileSync(join(configDir, 'plugins', 'installed_plugins.json'), 'utf-8'));
85
+ }
86
+ catch {
87
+ return [];
88
+ }
89
+ const plugins = installed?.plugins;
90
+ if (!plugins || typeof plugins !== 'object')
91
+ return [];
92
+ const out = [];
93
+ for (const [id, entries] of Object.entries(plugins)) {
94
+ const at = id.lastIndexOf('@');
95
+ if (at <= 0 || id.slice(at + 1) !== marketplace)
96
+ continue;
97
+ const name = id.slice(0, at);
98
+ if (names && names.length && !names.includes(name))
99
+ continue;
100
+ for (const e of Array.isArray(entries) ? entries : []) {
101
+ const path = e?.installPath;
102
+ if (typeof path === 'string' && existsSync(join(path, '.claude-plugin', 'plugin.json')))
103
+ out.push(verifyPluginDir(path, name));
104
+ }
105
+ }
106
+ return out;
107
+ }
108
+ /** The results that §2.1 makes enforceable under refuse-risky. */
109
+ export const isUntrusted = (r) => r === 'modified' || r === 'bad-signature';
110
+ //# sourceMappingURL=verify-status.js.map
@@ -135,6 +135,8 @@ export interface HeadlessExecutionResult {
135
135
  error?: string;
136
136
  /** Execution ID for tracking */
137
137
  executionId: string;
138
+ /** ADR-510 — plugins in the worker profile that could not be found; the worker ran without them. */
139
+ missingPlugins?: string[];
138
140
  /**
139
141
  * #2661 — true when the launch was skipped because the same job
140
142
  * (repositoryId + HEAD + worker + config) succeeded within the freshness
@@ -25,6 +25,7 @@ import { join } from 'path';
25
25
  import { getGlobalAiBudget, isQuotaErrorText } from './global-ai-budget.js';
26
26
  import { resolveGitWorkspaceIdentity } from './git-workspace-identity.js';
27
27
  import { getAiJobDedupRegistry, computeAiJobKey, hashWorkerConfig } from './ai-job-dedup.js';
28
+ import { withHeadlessPlugins } from '../mods/headless-plugins.js';
28
29
  // ============================================
29
30
  // Constants
30
31
  // ============================================
@@ -777,6 +778,7 @@ export class HeadlessWorkerExecutor extends EventEmitter {
777
778
  timestamp: new Date(),
778
779
  executionId,
779
780
  error: result.error,
781
+ ...(result.missingPlugins ? { missingPlugins: result.missingPlugins } : {}),
780
782
  };
781
783
  // Log result
782
784
  this.logExecution(executionId, 'result', JSON.stringify(executionResult, null, 2));
@@ -1011,7 +1013,10 @@ Analyze the above codebase context and provide your response following the forma
1011
1013
  * Execute Claude Code in headless mode
1012
1014
  */
1013
1015
  executeClaudeCode(prompt, options) {
1014
- return new Promise((resolve) => {
1016
+ return new Promise((resolveRaw) => {
1017
+ // ADR-510: a plugin the worker profile wants but cannot find is recorded on the result.
1018
+ let missingPlugins = [];
1019
+ const resolve = (r) => resolveRaw(missingPlugins.length > 0 ? { ...r, missingPlugins } : r);
1015
1020
  const env = {
1016
1021
  ...process.env,
1017
1022
  CLAUDE_CODE_HEADLESS: 'true',
@@ -1057,9 +1062,14 @@ Analyze the above codebase context and provide your response following the forma
1057
1062
  // and ALWAYS falls back to the raw text on any mismatch — a schema
1058
1063
  // surprise from an older/newer `claude` CLI must degrade to exactly
1059
1064
  // today's behavior (no usage captured), never break analysis output.
1060
- const child = spawn('claude', ['--print', '--output-format', 'json'], {
1065
+ // ADR-510: `claude -p` on a fresh config loads no marketplace plugins, so
1066
+ // pass the worker profile (default ruflo-mods) as --plugin-dir and turn
1067
+ // function hooks on unless the caller set CLAUDE_CODE_ENABLE_FUNCTION_HOOKS.
1068
+ const staged = withHeadlessPlugins(['--print', '--output-format', 'json'], env, { projectRoot: this.projectRoot });
1069
+ missingPlugins = staged.missing;
1070
+ const child = spawn('claude', staged.argv, {
1061
1071
  cwd: this.projectRoot,
1062
- env,
1072
+ env: staged.env,
1063
1073
  stdio: ['pipe', 'pipe', 'pipe'],
1064
1074
  windowsHide: true, // Prevent phantom console windows on Windows
1065
1075
  detached: process.platform !== 'win32',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@claude-flow/cli",
3
- "version": "3.63.9",
3
+ "version": "3.65.0",
4
4
  "type": "module",
5
5
  "description": "Ruflo CLI - Enterprise AI agent orchestration with 60+ specialized agents, swarm coordination, MCP server, self-learning hooks, and vector memory for Claude Code",
6
6
  "main": "dist/src/index.js",
@@ -86,6 +86,7 @@
86
86
  "build": "tsc",
87
87
  "check:metaharness-pins": "node ../../../scripts/check-metaharness-pins.mjs",
88
88
  "test": "vitest run",
89
+ "test:real-binary": "vitest run --config vitest.real-binary.config.ts",
89
90
  "test:plugin-store": "npx tsx src/plugins/tests/standalone-test.ts",
90
91
  "test:pattern-store": "npx tsx src/transfer/store/tests/standalone-test.ts",
91
92
  "postinstall": "node ./scripts/postinstall.cjs",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ruflo-metaharness",
3
3
  "description": "MetaHarness integration for ruflo — surfaces score/genome/mint/mcp-scan/threat-model via skills; pairs with @metaharness/router (ADR-148/149) for cost-optimal model routing; honors ADR-150's architectural constraint that MetaHarness remains an optional augmentation, never a required runtime dependency. As a mod (ADR-445): confirms flywheel promotion, keeps secrets out of inputs, /metaharness-mod, and a status file the console shows",
4
- "version": "0.2.6",
4
+ "version": "0.4.0",
5
5
  "author": {
6
6
  "name": "ruvnet",
7
7
  "url": "https://github.com/ruvnet"
@@ -43,6 +43,13 @@
43
43
  "on",
44
44
  "off"
45
45
  ]
46
+ },
47
+ "openrouterApiKey": {
48
+ "type": "string",
49
+ "title": "OpenRouter API key",
50
+ "description": "Key for the real model judge in redblue runs (--mock-judge needs none). Kept in the credential store, never in settings; OPENROUTER_API_KEY still works when this is empty.",
51
+ "required": false,
52
+ "sensitive": true
46
53
  }
47
54
  }
48
55
  }
@@ -2,6 +2,12 @@
2
2
 
3
3
  Newest first. One `## <version> — <date>` heading per version, then `feat:`, `fix:`, `breaking:` and `chore:` bullets (ADR-478). Built from git history; this is the whole history.
4
4
 
5
+ ## 0.4.0 — 2026-10-29
6
+ - chore: operational skills are user-invoked only (`disable-model-invocation: true`) and long skill/command descriptions are cut to one sentence, so they cost less prompt on every turn (ADR-507, #4588). Every skill is still available as `/<skill>`.
7
+
8
+ ## 0.3.0 — 2026-10-29
9
+ - feat: `openrouterApiKey` is a `userConfig` option with `sensitive: true` (ADR-506, #4587), read through `readSecret` before the `OPENROUTER_API_KEY` environment fallback.
10
+
5
11
  ## 0.2.6 — 2026-10-12
6
12
  - fix: the shared text screen now catches `.env` and e-mailed credentials, no longer flags a model's token talk (`--max-tokens`, `/oauth/token`, "token usage"), and no longer flags values that only name where a secret lives (`./service-account.json`, `--set-secrets=NAME=NAME:latest`); every rule stays linear (#4094, thanks @proffesor-for-testing)
7
13
 
@@ -29,6 +29,19 @@ MetaHarness integration plugin for ruflo. Surfaces the upstream `metaharness` /
29
29
  | `harness-learn` | `/harness-learn --host <h> --model <m> --slice <manifest> [--repo <checkout>] [--run]` | metaharness@0.3.0 / upstream ADR-235 — GEPA learning run; $0 dry-run default, `--run` to spend; needs a metaharness repo checkout |
30
30
  | `harness-gepa` | `/harness-gepa --op genome\|validate\|render\|analyze [--path <genome.json>]` | darwin@0.8.0 GEPA library surface — genome load/validate/render + transcript failure analysis; `gepaOptimize` stays library-only |
31
31
 
32
+ ## User-invoked skills (ADR-507)
33
+
34
+ These skills carry `disable-model-invocation: true`: Claude does not pick them on its own, and they cost nothing in the per-turn skill listing. Type them to run them. They run benchmarks, spend-gated learning or evolution runs, scheduled audits, or a one-shot scaffold.
35
+
36
+ | Skill | Run it with |
37
+ |---|---|
38
+ | harness-bench | `/ruflo-metaharness:harness-bench` |
39
+ | harness-evolve | `/ruflo-metaharness:harness-evolve` |
40
+ | harness-learn | `/ruflo-metaharness:harness-learn` |
41
+ | harness-mint | `/ruflo-metaharness:harness-mint` |
42
+ | harness-oia-audit | `/ruflo-metaharness:harness-oia-audit` |
43
+ | harness-security-bench | `/ruflo-metaharness:harness-security-bench` |
44
+
32
45
  ## Phase-0 baseline (ruflo itself, 2026-06-16)
33
46
 
34
47
  ```json
@@ -79,6 +92,6 @@ This plugin ships a function-hook mod (ADR-445, `hooks/register.ts`). It makes n
79
92
  - **`/metaharness-mod`**: `status`, `recent`, `tools` (which of this plugin's tools are connected) and `scan <text>` (would the guard refuse it). Answered locally.
80
93
  - **Status file**: `.claude-flow/metaharness-mod/status.json` (`{version: 1, updatedMs, guard, calls, total, blocked, recent}`, counters only, never tool input), written at session start and after every call to this plugin's tools.
81
94
 
82
- Options (`userConfig`): `guard` (on).
95
+ Options (`userConfig`): `guard` (on); `openrouterApiKey` (sensitive, empty by default): the key for redblue's real model judge, kept in the credential store. A classic SessionStart hook exports it as `OPENROUTER_API_KEY` to the session's Bash commands when that variable is unset, so a redblue run started from a skill uses it; it never goes in argv. The `metaharness_redblue` MCP tool runs inside ruflo-core's MCP server, which does not see it: export `OPENROUTER_API_KEY` before starting Claude Code for that path (ADR-506).
83
96
 
84
97
  Test it: `claude plugin validate plugins/ruflo-metaharness`, `claude plugin test plugins/ruflo-metaharness`, `bash plugins/ruflo-metaharness/scripts/smoke.sh`.
@@ -36,6 +36,8 @@ needs its own ADR.
36
36
  | `harness-oia-audit` | Composite weekly audit worker (iter 7) | Cron-scheduled; bundles oia+threat+mcp into one timestamped record in `metaharness-audit` namespace |
37
37
  | `harness-mint` | Scaffold a custom harness | User wants to fork; ALWAYS dry-run first, never write to project root |
38
38
 
39
+ `harness-oia-audit` and `harness-mint` are user-invoked (ADR-507), as are `harness-bench`, `harness-evolve`, `harness-learn` and `harness-security-bench`: the Skill tool refuses them. Tell the user which `/ruflo-metaharness:<skill>` to run rather than running it yourself.
40
+
39
41
  ## Tools (subprocess-only, no library imports)
40
42
 
41
43
  - All skills shell out to the pinned `metaharness` / `harness` binaries (`metaharness@~0.4.1`, local install or one-time versioned cache — never `@latest`) via the `_harness.mjs` shared helper.
@@ -1,4 +1,18 @@
1
1
  {
2
- "description": "MetaHarness as a mod (ADR-445): a tighten-only guard on the metaharness tools (promotion needs an explicit confirm, no secrets in inputs), a status file the console reads, and /metaharness-mod.",
3
- "modules": ["./register.ts"]
2
+ "description": "MetaHarness as a mod (ADR-445): a tighten-only guard on the metaharness tools (promotion needs an explicit confirm, no secrets in inputs), a status file the console reads, and /metaharness-mod. The classic SessionStart hook hands the sensitive option openrouterApiKey to the Bash tool as OPENROUTER_API_KEY through CLAUDE_ENV_FILE, only when that variable is not already set, so a redblue run started from a skill can use a key kept in the credential store (scripts/_secret-env.cjs, ADR-506).",
3
+ "hooks": {
4
+ "SessionStart": [
5
+ {
6
+ "hooks": [
7
+ {
8
+ "type": "command",
9
+ "command": "node -e \"require(require('path').join(process.env.CLAUDE_PLUGIN_ROOT,'scripts','_secret-env.cjs')).bridge({openrouterApiKey:'OPENROUTER_API_KEY'})\""
10
+ }
11
+ ]
12
+ }
13
+ ]
14
+ },
15
+ "modules": [
16
+ "./register.ts"
17
+ ]
4
18
  }
@@ -54,7 +54,9 @@
54
54
  // (no real creds, no live targets, no shell, no eval, no arbitrary network).
55
55
  // This wrapper does NOT relax those — it only forwards argv with shell:false.
56
56
  // `--mock-judge` is the $0 CI path; the real model judge gates on
57
- // $OPENROUTER_API_KEY which we never inject.
57
+ // $OPENROUTER_API_KEY. The wrapper never puts a key in argv. When that
58
+ // variable is unset but the plugin option `openrouterApiKey` (sensitive,
59
+ // ADR-506) is, the child gets it as OPENROUTER_API_KEY in its environment.
58
60
 
59
61
  import { spawnSync } from 'node:child_process';
60
62
  import { realpathSync } from 'node:fs';
@@ -67,6 +69,7 @@ import {
67
69
  makeDegradedEmitter,
68
70
  resolvePackageBin,
69
71
  } from './_invoke.mjs';
72
+ import { readSecret } from './_secret.mjs';
70
73
 
71
74
  const DEFAULT_TIMEOUT_MS = 120_000;
72
75
 
@@ -78,6 +81,13 @@ const REDBLUE_PIN = `${REDBLUE_PKG}@${REDBLUE_PIN_VERSION}`;
78
81
 
79
82
  const REASON_PREFIX = 'metaharness-redblue';
80
83
 
84
+ /** The child environment, with OPENROUTER_API_KEY filled from the plugin option when the variable is unset (ADR-506). */
85
+ export function judgeEnv(env) {
86
+ if (env.OPENROUTER_API_KEY) return env;
87
+ const key = readSecret('openrouterApiKey', [], env);
88
+ return key ? { ...env, OPENROUTER_API_KEY: key } : env;
89
+ }
90
+
81
91
  // Cache dir is versioned by the pin so bumping REDBLUE_PIN_VERSION
82
92
  // invalidates stale installs (handled inside ensureCachedInstall). The
83
93
  // resolved layout matches the pre-consolidation helper so existing caches
@@ -133,7 +143,7 @@ export function runRedblue(args, opts = {}) {
133
143
  encoding: 'utf-8',
134
144
  timeout: timeoutMs,
135
145
  cwd: opts.cwd,
136
- env: { ...process.env, ...(opts.env || {}) },
146
+ env: judgeEnv({ ...process.env, ...(opts.env || {}) }),
137
147
  shell: false,
138
148
  });
139
149
  const durationMs = Date.now() - start;
@@ -0,0 +1,50 @@
1
+ // Secret bridge (ADR-506): hand a sensitive `userConfig` option to the scripts a skill or command runs
2
+ // through the Bash tool. Copied verbatim into each plugin that needs it (a test keeps the copies identical).
3
+ //
4
+ // Claude Code gives a plugin's option only to that plugin's hooks, as CLAUDE_PLUGIN_OPTION_<KEY>; the Bash
5
+ // tool never sees it. A SessionStart hook, though, gets CLAUDE_ENV_FILE, and the lines it appends there are
6
+ // loaded into every later Bash tool command of the session. So the plugin's SessionStart hook calls
7
+ // bridge({ option: 'VARIABLE' }) and the script reads VARIABLE as it always did.
8
+ //
9
+ // Exposure: the value then sits in the session's environment file (under ~/.claude, owner-only) and in the
10
+ // environment of the session's Bash commands, the same as exporting it in a shell profile. What the option
11
+ // adds is masked entry and credential-store storage instead of a plain-text profile or .env file.
12
+ //
13
+ // Rules: a variable the user already exported wins and is never overwritten; the value is never printed,
14
+ // logged or put in argv; a value with a newline or NUL is refused; any failure is silent and never blocks
15
+ // the session.
16
+ 'use strict'
17
+ const fs = require('fs')
18
+
19
+ const optionEnvName = (name) => `CLAUDE_PLUGIN_OPTION_${String(name).replace(/[^A-Za-z0-9_]/g, '_').toUpperCase()}`
20
+ const VARIABLE = /^[A-Za-z_][A-Za-z0-9_]*$/
21
+ const quote = (value) => `'${value.replace(/'/g, `'\\''`)}'`
22
+
23
+ /** The env-file lines for the options that are set and whose variable is not. Never contains a value that was not asked for. */
24
+ function bridgeLines(map, env = process.env) {
25
+ const lines = []
26
+ for (const [option, variable] of Object.entries(map || {})) {
27
+ if (!VARIABLE.test(variable)) continue
28
+ if (typeof env[variable] === 'string' && env[variable].trim() !== '') continue
29
+ const value = env[optionEnvName(option)]
30
+ if (typeof value !== 'string' || value.trim() === '' || /[\n\r\0]/.test(value)) continue
31
+ lines.push(`export ${variable}=${quote(value)}`)
32
+ }
33
+ return lines
34
+ }
35
+
36
+ /** Append the lines to CLAUDE_ENV_FILE (owner-only). Returns the variable NAMES written, never values. */
37
+ function bridge(map, env = process.env) {
38
+ try {
39
+ const file = env.CLAUDE_ENV_FILE
40
+ if (!file) return []
41
+ const lines = bridgeLines(map, env)
42
+ if (lines.length === 0) return []
43
+ fs.appendFileSync(file, lines.join('\n') + '\n', { mode: 0o600 })
44
+ return lines.map((line) => line.slice('export '.length, line.indexOf('=')))
45
+ } catch {
46
+ return []
47
+ }
48
+ }
49
+
50
+ module.exports = { bridge, bridgeLines, optionEnvName }
@@ -0,0 +1,35 @@
1
+ // readSecret: one way to read a plugin credential (ADR-506). Copied verbatim into each plugin that
2
+ // needs it, since a plugin may not import from another plugin's folder; keep the copies identical
3
+ // (a test compares them).
4
+ //
5
+ // Order: CLAUDE_PLUGIN_OPTION_<NAME>, which Claude Code sets for a plugin's hooks when the
6
+ // `userConfig` option (declared with "sensitive": true, kept in the credential store) is filled in;
7
+ // then the environment variables the plugin always read, for standalone CLI use and backward
8
+ // compatibility. The value is never logged, echoed, or placed in an error message.
9
+
10
+ /** The environment variable Claude Code sets for a `userConfig` key (non-identifier characters to `_`, upper-cased). */
11
+ export const optionEnvName = (name) => `CLAUDE_PLUGIN_OPTION_${String(name).replace(/[^A-Za-z0-9_]/g, '_').toUpperCase()}`
12
+
13
+ /** The secret, or undefined when neither the option nor any fallback variable holds a non-empty value. Read at call time. */
14
+ export function readSecret(name, envFallbacks = [], env = process.env) {
15
+ for (const variable of [optionEnvName(name), ...envFallbacks]) {
16
+ const value = env[variable]
17
+ if (typeof value === 'string' && value.trim() !== '') return value
18
+ }
19
+ return undefined
20
+ }
21
+
22
+ /** Where the secret was found (a variable NAME, never the value), or undefined. */
23
+ export function secretSource(name, envFallbacks = [], env = process.env) {
24
+ for (const variable of [optionEnvName(name), ...envFallbacks]) {
25
+ const value = env[variable]
26
+ if (typeof value === 'string' && value.trim() !== '') return variable
27
+ }
28
+ return undefined
29
+ }
30
+
31
+ /** Error text for a missing secret: names the option and the variables, never a value. */
32
+ export function secretHint(name, envFallbacks = []) {
33
+ const vars = envFallbacks.length ? ` or export ${envFallbacks.join(' / ')} in your shell` : ''
34
+ return `no ${name} configured: set the plugin option "${name}" with /plugin (it is stored in the credential store)${vars}. Never paste a key into a prompt or a file.`
35
+ }
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bash
2
- # Structural smoke test for ruflo-metaharness v0.2.6 (ADR-150 Phase 1).
2
+ # Structural smoke test for ruflo-metaharness v0.4.0 (ADR-150 Phase 1).
3
3
  set -u
4
4
  ROOT="$(cd "$(dirname "$0")/.." && pwd)"
5
5
  PASS=0
@@ -32,10 +32,10 @@ else
32
32
  bad "extraction-regex-rot: EXPECTED_TOOLS=$EXPECTED_TOOLS EXPECTED_SUBS=$EXPECTED_SUBS"
33
33
  fi
34
34
 
35
- step "1. plugin.json declares 0.2.6 with adr-150 keywords"
35
+ step "1. plugin.json declares 0.4.0 with adr-150 keywords"
36
36
  v=$(grep -E '"version"' "$ROOT/.claude-plugin/plugin.json" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1)
37
- if [[ "$v" != "0.2.6" ]]; then
38
- bad "expected 0.2.6, got '$v'"
37
+ if [[ "$v" != "0.4.0" ]]; then
38
+ bad "expected 0.4.0, got '$v'"
39
39
  else
40
40
  miss=""
41
41
  for k in ruflo metaharness harness scorecard genome mcp-scan threat-model router adr-150 adr-148 adr-149 optional-dependency graceful-degradation subprocess phase-1-mvp; do
@@ -483,7 +483,7 @@ step "17z74a. plugin helper pins accept the CLI-declared ranges — no tool-time
483
483
  miss=""
484
484
  # 17z74 compares package.json files with each other; the helpers' own
485
485
  # *_PIN_VERSION constants (what findLocalPackageDir accepts) were never
486
- # compared with them and drifted (~0.3.0 / ~0.8.0 vs declared ^0.4.1 /
486
+ # compared with them and drifted (~0.4.0 / ~0.8.0 vs declared ^0.4.1 /
487
487
  # ~0.10.2), so every metaharness_* call npm-installed an older release next
488
488
  # to the one ruflo ships. Hermetic runtime gate: stub packages at the declared
489
489
  # versions, published layout, npm/npx trap.
@@ -1287,7 +1287,7 @@ miss=""
1287
1287
  DISP="$ROOT/../../v3/@claude-flow/cli/src/commands/metaharness.ts"
1288
1288
  WRAPPER="$ROOT/../../v3/@claude-flow/cli/src/mcp-tools/metaharness-tools.ts"
1289
1289
  # All 13 dispatchable subcommands present (iter-73 had 10; @metaharness/redblue
1290
- # added redblue.mjs; metaharness@0.3.0/darwin@0.8.0 added learn.mjs + gepa.mjs).
1290
+ # added redblue.mjs; metaharness@0.4.0/darwin@0.8.0 added learn.mjs + gepa.mjs).
1291
1291
  for sub in score genome mcp-scan threat-model oia-audit audit-list audit-trend similarity drift-from-history mint redblue learn gepa; do
1292
1292
  grep -q "${sub}" "$DISP" 2>/dev/null || miss="$miss subcommand-${sub}-not-listed"
1293
1293
  done
@@ -2199,7 +2199,7 @@ grep -q "result has 'degraded'" "$F" || miss="$miss no-degraded-assertion"
2199
2199
  grep -q "result has 'exitCode'" "$F" || miss="$miss no-exitcode-assertion"
2200
2200
  # All tool names enumerated (similarity iter 36, drift_from_history iter 54,
2201
2201
  # ADR-153 added bench/evolve/security_bench, @metaharness/redblue added redblue,
2202
- # metaharness@0.3.0/darwin@0.8.0 added learn + gepa, ADR-322 added flywheel)
2202
+ # metaharness@0.4.0/darwin@0.8.0 added learn + gepa, ADR-322 added flywheel)
2203
2203
  for tool in metaharness_score metaharness_genome metaharness_mcp_scan metaharness_threat_model metaharness_oia_audit metaharness_audit_list metaharness_audit_trend metaharness_similarity metaharness_drift_from_history metaharness_bench metaharness_evolve metaharness_security_bench metaharness_redblue metaharness_learn metaharness_gepa metaharness_flywheel; do
2204
2204
  grep -q "${tool}" "$F" || miss="$miss missing-${tool}"
2205
2205
  done