claude-flow 3.50.0 → 3.51.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 (60) hide show
  1. package/.claude/proven-config.json +1 -1
  2. package/.claude/settings.json +11 -2
  3. package/.claude-plugin/marketplace.json +5 -0
  4. package/package.json +1 -1
  5. package/v3/@claude-flow/cli/README.md +0 -2
  6. package/v3/@claude-flow/cli/catalog-manifest.json +4 -4
  7. package/v3/@claude-flow/cli/dist/src/commands/catalog.d.ts +18 -0
  8. package/v3/@claude-flow/cli/dist/src/commands/catalog.js +89 -0
  9. package/v3/@claude-flow/cli/dist/src/commands/doctor.js +8 -3
  10. package/v3/@claude-flow/cli/dist/src/commands/index.js +3 -0
  11. package/v3/@claude-flow/cli/dist/src/commands/init.js +50 -11
  12. package/v3/@claude-flow/cli/dist/src/commands/mission.d.ts +24 -0
  13. package/v3/@claude-flow/cli/dist/src/commands/mission.js +116 -0
  14. package/v3/@claude-flow/cli/dist/src/commands/mods.d.ts +1 -1
  15. package/v3/@claude-flow/cli/dist/src/commands/mods.js +67 -23
  16. package/v3/@claude-flow/cli/dist/src/mcp-client.js +3 -0
  17. package/v3/@claude-flow/cli/dist/src/mcp-tools/capability-brain.js +1 -1
  18. package/v3/@claude-flow/cli/dist/src/mcp-tools/index.d.ts +1 -0
  19. package/v3/@claude-flow/cli/dist/src/mcp-tools/index.js +2 -0
  20. package/v3/@claude-flow/cli/dist/src/mcp-tools/mission-tools.d.ts +22 -0
  21. package/v3/@claude-flow/cli/dist/src/mcp-tools/mission-tools.js +75 -0
  22. package/v3/@claude-flow/cli/dist/src/missions/fold.d.ts +19 -0
  23. package/v3/@claude-flow/cli/dist/src/missions/fold.js +238 -0
  24. package/v3/@claude-flow/cli/dist/src/missions/index.d.ts +10 -0
  25. package/v3/@claude-flow/cli/dist/src/missions/index.js +10 -0
  26. package/v3/@claude-flow/cli/dist/src/missions/observation.d.ts +70 -0
  27. package/v3/@claude-flow/cli/dist/src/missions/observation.js +77 -0
  28. package/v3/@claude-flow/cli/dist/src/missions/schemas.d.ts +735 -0
  29. package/v3/@claude-flow/cli/dist/src/missions/schemas.js +150 -0
  30. package/v3/@claude-flow/cli/dist/src/missions/service.d.ts +88 -0
  31. package/v3/@claude-flow/cli/dist/src/missions/service.js +278 -0
  32. package/v3/@claude-flow/cli/dist/src/missions/store.d.ts +68 -0
  33. package/v3/@claude-flow/cli/dist/src/missions/store.js +209 -0
  34. package/v3/@claude-flow/cli/dist/src/missions/transitions.d.ts +81 -0
  35. package/v3/@claude-flow/cli/dist/src/missions/transitions.js +178 -0
  36. package/v3/@claude-flow/cli/dist/src/mods/apply.d.ts +50 -0
  37. package/v3/@claude-flow/cli/dist/src/mods/apply.js +186 -0
  38. package/v3/@claude-flow/cli/dist/src/mods/command-registry/catalog.d.ts +45 -0
  39. package/v3/@claude-flow/cli/dist/src/mods/command-registry/catalog.js +234 -0
  40. package/v3/@claude-flow/cli/dist/src/mods/command-registry/engine-report.d.ts +66 -0
  41. package/v3/@claude-flow/cli/dist/src/mods/command-registry/engine-report.js +119 -0
  42. package/v3/@claude-flow/cli/dist/src/mods/command-registry/generate.d.ts +51 -0
  43. package/v3/@claude-flow/cli/dist/src/mods/command-registry/generate.js +166 -0
  44. package/v3/@claude-flow/cli/dist/src/mods/command-registry/index.d.ts +9 -0
  45. package/v3/@claude-flow/cli/dist/src/mods/command-registry/index.js +9 -0
  46. package/v3/@claude-flow/cli/dist/src/mods/command-registry/inventory.d.ts +63 -0
  47. package/v3/@claude-flow/cli/dist/src/mods/command-registry/inventory.js +230 -0
  48. package/v3/@claude-flow/cli/dist/src/mods/command-registry/schema.d.ts +836 -0
  49. package/v3/@claude-flow/cli/dist/src/mods/command-registry/schema.js +137 -0
  50. package/v3/@claude-flow/cli/dist/src/mods/install.d.ts +60 -22
  51. package/v3/@claude-flow/cli/dist/src/mods/install.js +91 -41
  52. package/v3/@claude-flow/cli/dist/src/mods/plugin-repair.d.ts +81 -0
  53. package/v3/@claude-flow/cli/dist/src/mods/plugin-repair.js +120 -0
  54. package/v3/@claude-flow/cli/dist/src/mods/plugin-resolve.d.ts +63 -0
  55. package/v3/@claude-flow/cli/dist/src/mods/plugin-resolve.js +182 -0
  56. package/v3/@claude-flow/cli/dist/src/mods/probe.d.ts +5 -0
  57. package/v3/@claude-flow/cli/dist/src/mods/probe.js +28 -3
  58. package/v3/@claude-flow/cli/dist/src/services/policy-runtime.d.ts +5 -0
  59. package/v3/@claude-flow/cli/dist/src/services/policy-runtime.js +5 -1
  60. package/v3/@claude-flow/cli/package.json +1 -1
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Turn the ruflo mods on (or off) for a project (ADR-404): merge the
3
+ * settings, sync the policy projection, then make the plugins loadable with
4
+ * `claude`. One path for `ruflo mods install`, `ruflo init` (and its wizard)
5
+ * and `ruflo init upgrade --mods`; `removeMods` is `ruflo mods uninstall`.
6
+ */
7
+ import { readFileSync } from 'node:fs';
8
+ import { homedir } from 'node:os';
9
+ import { output } from '../output.js';
10
+ import { installMod, MARKETPLACE_NAME, MARKETPLACE_SOURCE, MOD_PLUGINS, readRecord, recordClaudeInstalled, scopeOfSettingsFile, uninstallMod, } from './install.js';
11
+ import { findClaudeBinary, installArgv, marketplaceArgv, repairPluginInstall, uninstallArgv, uninstallPlugins } from './plugin-repair.js';
12
+ import { claudeConfigDir, describeSource, installedState, marketplaceState, repairCommands, resolveFindings, sameSource } from './plugin-resolve.js';
13
+ /** One line per thing a run added; empty on a re-run. */
14
+ export function describeAdded(added) {
15
+ return [
16
+ ...added.plugins.map((id) => `enabledPlugins["${id}"] = true`),
17
+ ...(added.marketplace ? ['extraKnownMarketplaces.ruflo'] : []),
18
+ ...(added.env ? ['env.CLAUDE_CODE_ENABLE_FUNCTION_HOOKS = "1"'] : []),
19
+ ];
20
+ }
21
+ /** What default-on means, said exactly (ADR-404 Amendment 1). */
22
+ export const LOAD_NOTE = [
23
+ 'ruflo-mods and ruflo-console run only where Claude Code function hooks are on: with them off, or the rollout switch off, they do nothing and the classic hooks keep every event.',
24
+ "ruflo-swarm's commands, skills and agents load without function hooks; with them on, ruflo-swarm is also a mod that can run host commands (pane actions you start).",
25
+ "Each teammate's first trusted interactive Claude Code start clones github.com/ruvnet/ruflo; a headless claude -p on a fresh config loads nothing.",
26
+ 'Optional hardening: pluginConfigs["ruflo-mods@ruflo"].options.modTrust = "refuse-risky" with modTrustAllow, in user or managed settings (project settings cannot set it).',
27
+ ];
28
+ async function syncPolicyQuietly(root) {
29
+ try {
30
+ const { loadPolicyState } = await import('../services/policy-runtime.js');
31
+ const { syncPolicyProjection } = await import('./policy-projection.js');
32
+ const result = syncPolicyProjection(root, loadPolicyState(root));
33
+ if (result.action !== 'unchanged')
34
+ output.writeln(`policy projection: ${result.action} (${result.path})`);
35
+ }
36
+ catch (error) {
37
+ output.printWarning(`policy projection not synced: ${error.message}`);
38
+ }
39
+ }
40
+ function printSteps(steps) {
41
+ for (const step of steps) {
42
+ const mark = step.state === 'ok' ? output.success('✓') : step.state === 'pending' ? output.warning('…') : output.error('✗');
43
+ output.writeln(`${mark} claude ${step.argv.join(' ')}${step.state === 'pending' ? ' (pending: not in the ruflo marketplace yet)' : ''}`);
44
+ if (step.state === 'failed' && step.output)
45
+ output.writeln(output.dim(` ${step.output.split('\n').slice(-3).join('\n ')}`));
46
+ }
47
+ }
48
+ const canon = (v) => Array.isArray(v) ? v.map(canon) : v && typeof v === 'object' ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, canon(v[k])])) : v;
49
+ function readText(path) {
50
+ try {
51
+ return readFileSync(path, 'utf8');
52
+ }
53
+ catch {
54
+ return undefined;
55
+ }
56
+ }
57
+ /** Claude Code re-serializes settings it touches; say so, never fight it. */
58
+ function noteRewrite(file, before) {
59
+ const after = readText(file);
60
+ if (before === undefined || after === undefined || before === after)
61
+ return;
62
+ try {
63
+ output.writeln(output.dim(JSON.stringify(canon(JSON.parse(before))) === JSON.stringify(canon(JSON.parse(after)))
64
+ ? `Claude Code reformatted ${file} (key order and layout only; content unchanged).`
65
+ : `Claude Code also changed ${file}; review it before committing.`));
66
+ }
67
+ catch {
68
+ // Not ours to judge further.
69
+ }
70
+ }
71
+ export async function applyMods(root, opts) {
72
+ const install = installMod(root, opts.scope, opts.dryRun === true, undefined, opts.marketplace ?? MARKETPLACE_SOURCE);
73
+ const configDir = claudeConfigDir(process.env, homedir());
74
+ const lines = describeAdded(install.added);
75
+ // Only what is enabled in the file: `claude plugin install` would flip a
76
+ // plugin someone set to false back to true.
77
+ const enabled = (install.next.enabledPlugins ?? {});
78
+ const plugins = MOD_PLUGINS.filter((p) => enabled[p.id] === true);
79
+ // The marketplace the file declares wins: a person's own declaration is kept and used.
80
+ const declared = install.next.extraKnownMarketplaces?.[MARKETPLACE_NAME] ?? MARKETPLACE_SOURCE;
81
+ const market = marketplaceState(configDir);
82
+ const known = market.known && sameSource(market.source, declared.source);
83
+ if (install.dryRun) {
84
+ output.writeln(`Would write ${install.settingsFile}:`);
85
+ output.printJson(install.next);
86
+ if (opts.pluginInstall !== false) {
87
+ output.writeln('Would run:');
88
+ const installs = declared.source.source === 'directory' ? [] : plugins.map((p) => installArgv(p.id, opts.scope));
89
+ for (const argv of [marketplaceArgv(opts.scope, known, declared), ...installs])
90
+ output.writeln(` claude ${argv.join(' ')}`);
91
+ }
92
+ return { install, resolvable: true };
93
+ }
94
+ await syncPolicyQuietly(root);
95
+ if (lines.length) {
96
+ output.printSuccess(`ruflo mods enabled in ${install.settingsFile}${install.backup ? ` (backup: ${install.backup})` : ''}`);
97
+ for (const line of lines)
98
+ output.writeln(` + ${line}`);
99
+ }
100
+ else {
101
+ output.printSuccess(`ruflo mods already enabled in ${install.settingsFile} (nothing changed)`);
102
+ }
103
+ output.writeln(` marketplace: ${describeSource(declared.source)}`);
104
+ if (opts.marketplace && !sameSource(opts.marketplace.source, declared.source)) {
105
+ output.printWarning(`extraKnownMarketplaces.ruflo was already set (${describeSource(declared.source)}); left as it is.`);
106
+ }
107
+ for (const line of LOAD_NOTE)
108
+ output.writeln(output.dim(line));
109
+ const manual = (k = known) => {
110
+ output.writeln('Run these to make the plugins loadable:');
111
+ for (const line of repairCommands(root, opts.scope, k, plugins, declared))
112
+ output.writeln(` ${line}`);
113
+ };
114
+ if (opts.pluginInstall === false)
115
+ return { install, resolvable: true, skipped: '--no-plugin-install' };
116
+ if (opts.skipInTestEnv && (process.env.VITEST || process.env.CI)) {
117
+ output.writeln(output.dim(`claude plugin step skipped (${process.env.VITEST ? 'VITEST' : 'CI'} set).`));
118
+ manual();
119
+ return { install, resolvable: false, skipped: 'test or CI environment' };
120
+ }
121
+ const claude = findClaudeBinary(process.env, homedir());
122
+ if (!claude) {
123
+ output.printWarning('No runnable claude binary on PATH: the plugins are enabled in settings, not installed.');
124
+ manual();
125
+ return { install, resolvable: false, skipped: 'no claude on PATH' };
126
+ }
127
+ if (declared.source.source === 'directory' && market.known && !sameSource(market.source, declared.source)) {
128
+ output.printWarning(`Claude Code keeps one ruflo marketplace per config dir: it now switches from ${describeSource(market.source)} to ${describeSource(declared.source)} for every project on this machine. Switch back with: claude plugin marketplace add ruvnet/ruflo`);
129
+ }
130
+ // Never claim (and so never uninstall) a plugin that was installed before, or one ruflo did not enable.
131
+ const before = new Set(plugins.filter((p) => installedState(root, configDir, p.id).installed).map((p) => p.id));
132
+ const textBefore = readText(install.settingsFile);
133
+ const repair = await repairPluginInstall({ projectRoot: root, scope: opts.scope, configDir, claude, plugins, marketplace: declared, exec: opts.exec });
134
+ printSteps(repair.steps);
135
+ if (declared.source.source === 'directory' && repair.ok)
136
+ output.writeln(output.dim(`Directory marketplace: plugins load live from ${declared.source.path} (no clone, no install).`));
137
+ const enabledByRuflo = new Set(readRecord(root)?.files[install.settingsFile]?.plugins ?? []);
138
+ const ours = repair.steps
139
+ .filter((st) => st.state === 'ok' && st.argv[1] === 'install')
140
+ .map((st) => st.argv[2])
141
+ .filter((id) => !before.has(id) && enabledByRuflo.has(id));
142
+ recordClaudeInstalled(root, install.settingsFile, ours);
143
+ noteRewrite(install.settingsFile, textBefore);
144
+ const failed = resolveFindings(root, opts.scope, configDir, undefined, plugins.map((p) => p.id), declared).filter((f) => f.status === 'fail');
145
+ if (repair.ok && failed.length === 0)
146
+ return { install, resolvable: true };
147
+ for (const f of failed)
148
+ output.writeln(`${output.error('✗')} ${f.name}: ${f.message}`);
149
+ const now = marketplaceState(configDir);
150
+ manual(now.known && sameSource(now.source, declared.source));
151
+ return { install, resolvable: false };
152
+ }
153
+ /**
154
+ * `ruflo mods uninstall`: `claude plugin uninstall` exactly the plugins ruflo
155
+ * installed, then remove exactly the settings keys it added.
156
+ */
157
+ export async function removeMods(root, opts = {}) {
158
+ const record = readRecord(root);
159
+ const plan = Object.entries(record?.files ?? {}).map(([file, added]) => ({ file, scope: scopeOfSettingsFile(file), ids: added.claudeInstalled ?? [] }));
160
+ const uninstalled = [];
161
+ const failed = [];
162
+ if (opts.dryRun) {
163
+ for (const p of plan)
164
+ for (const id of p.ids)
165
+ output.writeln(`Would run: claude ${uninstallArgv(id, p.scope).join(' ')}`);
166
+ }
167
+ else if (plan.some((p) => p.ids.length)) {
168
+ const claude = findClaudeBinary(process.env, homedir());
169
+ for (const p of plan.filter((x) => x.ids.length)) {
170
+ if (!claude) {
171
+ output.printWarning('No runnable claude binary on PATH; run these to remove what ruflo installed:');
172
+ for (const id of p.ids)
173
+ output.writeln(` cd ${JSON.stringify(root)} && claude ${uninstallArgv(id, p.scope).join(' ')}`);
174
+ failed.push(...p.ids);
175
+ continue;
176
+ }
177
+ const result = await uninstallPlugins({ projectRoot: root, scope: p.scope, ids: p.ids, claude, exec: opts.exec });
178
+ printSteps(result.steps);
179
+ for (const st of result.steps)
180
+ (st.state === 'ok' ? uninstalled : failed).push(st.argv[2]);
181
+ }
182
+ }
183
+ const result = uninstallMod(root, opts.dryRun === true);
184
+ return { ...result, uninstalled, failed };
185
+ }
186
+ //# sourceMappingURL=apply.js.map
@@ -0,0 +1,45 @@
1
+ /**
2
+ * ADR-406 P1 — classify the inventory into `CommandDefinition`s, validate
3
+ * them, and emit a deterministic catalog.
4
+ *
5
+ * Determinism: entries sorted by id, object keys sorted recursively, no
6
+ * generation timestamp, and `source.commit` is the last commit that touched
7
+ * the source file (not the generator's HEAD), so regenerating an unchanged
8
+ * tree yields the same bytes.
9
+ */
10
+ import { type CatalogEntry, type CommandCatalog } from './schema.js';
11
+ import { type RawCommand } from './inventory.js';
12
+ export declare const REPOSITORY = "ruvnet/ruflo";
13
+ export interface CatalogOptions {
14
+ /** Repository-relative path -> last commit sha touching it. Missing = `uncommitted`. */
15
+ readonly commits?: Readonly<Record<string, string>>;
16
+ /** True when `packedFiles` was supplied to the inventory, so `shippedIn` is meaningful. */
17
+ readonly packageContentsInspected: boolean;
18
+ /** Last commit touching any command source (stable across unrelated commits). */
19
+ readonly sourceCommit?: string;
20
+ }
21
+ /** JSON with object keys sorted at every depth and no whitespace. */
22
+ export declare function canonicalJson(value: unknown): string;
23
+ export declare function catalogDigest(entries: readonly CatalogEntry[]): string;
24
+ /** Merge the occurrences of one invocation spelling into one catalog entry. */
25
+ export declare function classify(key: string, occurrences: readonly RawCommand[], options: CatalogOptions): CatalogEntry;
26
+ export interface ValidationResult {
27
+ readonly valid: boolean;
28
+ readonly errors: readonly string[];
29
+ }
30
+ /** §14 P1 gate: schema-valid definitions, no duplicate ids, no duplicate names. */
31
+ export declare function validateEntries(entries: readonly unknown[]): ValidationResult;
32
+ export declare function buildCatalog(inventory: readonly RawCommand[], options: CatalogOptions): CommandCatalog;
33
+ /** Parse and re-verify a catalog read from disk (the parity check a consumer runs). */
34
+ export declare function verifyCatalog(raw: unknown): ValidationResult;
35
+ export declare function serializeCatalog(catalog: CommandCatalog): string;
36
+ /**
37
+ * Write the catalog atomically. `expectedSha256` is the digest of the file the
38
+ * caller last read (or `null` for "must not exist"); a mismatch means someone
39
+ * else changed it and the write is refused (§6 concurrent-edit detection).
40
+ */
41
+ export declare function writeCatalog(path: string, catalog: CommandCatalog, expectedSha256?: string | null): {
42
+ written: boolean;
43
+ sha256: string;
44
+ };
45
+ //# sourceMappingURL=catalog.d.ts.map
@@ -0,0 +1,234 @@
1
+ /**
2
+ * ADR-406 P1 — classify the inventory into `CommandDefinition`s, validate
3
+ * them, and emit a deterministic catalog.
4
+ *
5
+ * Determinism: entries sorted by id, object keys sorted recursively, no
6
+ * generation timestamp, and `source.commit` is the last commit that touched
7
+ * the source file (not the generator's HEAD), so regenerating an unchanged
8
+ * tree yields the same bytes.
9
+ */
10
+ import { existsSync, readFileSync, renameSync, writeFileSync, mkdirSync, openSync, fsyncSync, closeSync } from 'node:fs';
11
+ import { dirname, basename } from 'node:path';
12
+ import { COMMAND_CATALOG_CONTRACT, LIMITS, catalogEntrySchema, commandCatalogSchema, } from './schema.js';
13
+ import { sha256Hex } from './inventory.js';
14
+ export const REPOSITORY = 'ruvnet/ruflo';
15
+ // Loader-exposed documentation is named in capitals (README, CHANGELOG,
16
+ // COMMAND_COMPLIANCE_REPORT). Lower-case names such as `performance-report`
17
+ // are real workflows and must not be matched by content words.
18
+ const DOC_NAME = /^[A-Z][A-Z0-9_]+$/;
19
+ const CHANNEL_PRIORITY = {
20
+ 'repo-project-commands': 0,
21
+ 'cli-init-template': 1,
22
+ 'marketplace-plugin': 2,
23
+ 'source-plugin': 3,
24
+ 'mod-registration': 4,
25
+ };
26
+ /** JSON with object keys sorted at every depth and no whitespace. */
27
+ export function canonicalJson(value) {
28
+ return JSON.stringify(value, (_key, v) => {
29
+ if (v && typeof v === 'object' && !Array.isArray(v)) {
30
+ return Object.fromEntries(Object.entries(v).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
31
+ }
32
+ return v;
33
+ });
34
+ }
35
+ export function catalogDigest(entries) {
36
+ return `sha256:${sha256Hex(canonicalJson(entries))}`;
37
+ }
38
+ function frontmatter(content) {
39
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
40
+ if (!match)
41
+ return {};
42
+ const out = {};
43
+ for (const line of match[1].split(/\r?\n/)) {
44
+ const kv = line.match(/^([A-Za-z][\w-]*):\s*(.*)$/);
45
+ if (kv)
46
+ out[kv[1].toLowerCase()] = kv[2].trim().replace(/^["']|["']$/g, '');
47
+ }
48
+ return out;
49
+ }
50
+ function substitutionOf(content) {
51
+ if (content.includes('$ARGUMENTS'))
52
+ return '$ARGUMENTS';
53
+ if (/\$[1-9]\b/.test(content))
54
+ return 'positional';
55
+ return 'none';
56
+ }
57
+ function allowedTools(meta) {
58
+ const raw = meta['allowed-tools'];
59
+ if (!raw)
60
+ return [];
61
+ return [...new Set(raw.replace(/^\[|\]$/g, '').split(',').map((t) => t.trim().replace(/^["']|["']$/g, '')).filter(Boolean))]
62
+ .map((t) => t.slice(0, LIMITS.capability))
63
+ .slice(0, LIMITS.capabilities)
64
+ .sort();
65
+ }
66
+ function isDocumentation(source) {
67
+ return DOC_NAME.test(basename(source.sourcePath, '.md'));
68
+ }
69
+ /** Merge the occurrences of one invocation spelling into one catalog entry. */
70
+ export function classify(key, occurrences, options) {
71
+ const ordered = [...occurrences].sort((a, b) => CHANNEL_PRIORITY[a.source.channel] - CHANNEL_PRIORITY[b.source.channel]
72
+ || a.source.sourcePath.localeCompare(b.source.sourcePath));
73
+ const markdown = ordered.filter((o) => o.source.binding === 'markdown-loader');
74
+ const registered = ordered.filter((o) => o.source.binding === 'mod-register');
75
+ const middleware = ordered.filter((o) => o.source.binding === 'mod-middleware-on-markdown');
76
+ const primary = markdown[0] ?? ordered[0];
77
+ const notes = [];
78
+ const commit = (path) => options.commits?.[path] ?? 'uncommitted';
79
+ let definition;
80
+ const base = {
81
+ schemaVersion: 1,
82
+ id: `${primary.ownerPlugin}/${key}`.toLowerCase(),
83
+ revision: 1,
84
+ ownerPlugin: primary.ownerPlugin,
85
+ modelVisibility: 'same-as-legacy',
86
+ source: { repository: REPOSITORY, commit: commit(primary.source.sourcePath), path: primary.source.sourcePath },
87
+ };
88
+ const documentation = markdown.length > 0 && isDocumentation(primary.source);
89
+ if (markdown.length > 0) {
90
+ const content = primary.content ?? '';
91
+ const meta = frontmatter(content);
92
+ const hint = (meta['argument-hint'] ?? '').slice(0, 200);
93
+ definition = {
94
+ ...base,
95
+ legacyNames: [key],
96
+ modNames: [],
97
+ kind: documentation ? 'help' : 'prompt',
98
+ argumentsSchema: hint
99
+ ? { type: 'string', maxLength: 8_192, description: hint }
100
+ : { type: 'string', maxLength: 8_192 },
101
+ prompt: { path: primary.source.sourcePath, sha256: primary.source.digest.slice(7), substitution: substitutionOf(content) },
102
+ requiredCapabilities: allowedTools(meta),
103
+ requiredHostFeatures: [],
104
+ // §7: no engine path to invoke a prompt workflow with its semantics is
105
+ // verified, so the workbench may show or fill it but never run it.
106
+ disposition: documentation ? 'legacy' : 'delegate',
107
+ };
108
+ if (documentation)
109
+ notes.push('documentation file exposed by the engine command loader; not an executable workflow');
110
+ if (middleware.length > 0) {
111
+ notes.push(`mod middleware attached by ${[...new Set(middleware.map((m) => m.ownerPlugin))].join(', ')} (command.run match)`);
112
+ }
113
+ }
114
+ else {
115
+ definition = {
116
+ ...base,
117
+ legacyNames: [],
118
+ modNames: [key],
119
+ kind: 'query',
120
+ argumentsSchema: { type: 'string', maxLength: 1_024 },
121
+ requiredCapabilities: [],
122
+ requiredHostFeatures: ['command.register'],
123
+ disposition: 'view',
124
+ viewId: `${primary.ownerPlugin}/${key}`.toLowerCase(),
125
+ };
126
+ notes.push('registered by a function-hook mod; exists only while that mod is loaded');
127
+ }
128
+ if (registered.length > 0 && markdown.length > 0)
129
+ notes.push('also registered by a mod under the same spelling');
130
+ const markdownDigests = new Set(markdown.map((m) => m.source.digest));
131
+ const divergent = markdownDigests.size > 1;
132
+ if (divergent)
133
+ notes.push('copies differ between delivery channels; see sources[].digest');
134
+ const shipped = ordered.some((o) => o.source.shippedIn.length > 0
135
+ || o.source.channel === 'marketplace-plugin'
136
+ || (o.source.channel === 'mod-registration' && o.source.package.endsWith('@ruflo')));
137
+ if (!options.packageContentsInspected)
138
+ notes.push('npm package contents not inspected for this catalog');
139
+ return catalogEntrySchema.parse({
140
+ definition,
141
+ status: documentation ? 'documentation' : shipped ? 'shipped' : 'source-only',
142
+ divergent,
143
+ sources: ordered.map((o) => o.source).slice(0, 8),
144
+ notes: notes.map((n) => n.slice(0, LIMITS.note)),
145
+ });
146
+ }
147
+ /** §14 P1 gate: schema-valid definitions, no duplicate ids, no duplicate names. */
148
+ export function validateEntries(entries) {
149
+ const errors = [];
150
+ const ids = new Map();
151
+ const names = new Map();
152
+ entries.forEach((raw, index) => {
153
+ const parsed = catalogEntrySchema.safeParse(raw);
154
+ if (!parsed.success) {
155
+ errors.push(`entry ${index}: ${parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; ')}`);
156
+ return;
157
+ }
158
+ const def = parsed.data.definition;
159
+ if (ids.has(def.id))
160
+ errors.push(`duplicate id ${def.id} (entries ${ids.get(def.id)} and ${index})`);
161
+ else
162
+ ids.set(def.id, index);
163
+ for (const name of new Set([...def.legacyNames, ...def.modNames])) {
164
+ const owner = names.get(name.toLowerCase());
165
+ if (owner && owner !== def.id)
166
+ errors.push(`duplicate name /${name} (${owner} and ${def.id})`);
167
+ else
168
+ names.set(name.toLowerCase(), def.id);
169
+ }
170
+ });
171
+ return { valid: errors.length === 0, errors };
172
+ }
173
+ export function buildCatalog(inventory, options) {
174
+ const groups = new Map();
175
+ for (const item of inventory) {
176
+ const list = groups.get(item.key) ?? [];
177
+ list.push(item);
178
+ groups.set(item.key, list);
179
+ }
180
+ const entries = [...groups.entries()]
181
+ .map(([key, list]) => classify(key, list, options))
182
+ .sort((a, b) => (a.definition.id < b.definition.id ? -1 : a.definition.id > b.definition.id ? 1 : 0));
183
+ const validation = validateEntries(entries);
184
+ if (!validation.valid)
185
+ throw new Error(`command-registry-invalid:\n${validation.errors.join('\n')}`);
186
+ const sourceCommit = options.sourceCommit ?? 'uncommitted';
187
+ return commandCatalogSchema.parse({
188
+ contractVersion: COMMAND_CATALOG_CONTRACT,
189
+ sourceCommit,
190
+ sourceDigest: catalogDigest(entries),
191
+ entries,
192
+ });
193
+ }
194
+ /** Parse and re-verify a catalog read from disk (the parity check a consumer runs). */
195
+ export function verifyCatalog(raw) {
196
+ const parsed = commandCatalogSchema.safeParse(raw);
197
+ if (!parsed.success)
198
+ return { valid: false, errors: parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`) };
199
+ const errors = [...validateEntries(parsed.data.entries).errors];
200
+ if (catalogDigest(parsed.data.entries) !== parsed.data.sourceDigest)
201
+ errors.push('sourceDigest does not match entries');
202
+ return { valid: errors.length === 0, errors };
203
+ }
204
+ export function serializeCatalog(catalog) {
205
+ return `${JSON.stringify(JSON.parse(canonicalJson(catalog)), null, 2)}\n`;
206
+ }
207
+ /**
208
+ * Write the catalog atomically. `expectedSha256` is the digest of the file the
209
+ * caller last read (or `null` for "must not exist"); a mismatch means someone
210
+ * else changed it and the write is refused (§6 concurrent-edit detection).
211
+ */
212
+ export function writeCatalog(path, catalog, expectedSha256) {
213
+ const text = serializeCatalog(catalog);
214
+ const next = sha256Hex(text);
215
+ const current = existsSync(path) ? sha256Hex(readFileSync(path)) : null;
216
+ if (expectedSha256 !== undefined && expectedSha256 !== current) {
217
+ throw new Error(`catalog-changed-on-disk: expected ${expectedSha256 ?? 'absent'}, found ${current ?? 'absent'}`);
218
+ }
219
+ if (current === next)
220
+ return { written: false, sha256: next };
221
+ mkdirSync(dirname(path), { recursive: true });
222
+ const tmp = `${path}.${process.pid}.tmp`;
223
+ writeFileSync(tmp, text, { mode: 0o644 });
224
+ const fd = openSync(tmp, 'r');
225
+ try {
226
+ fsyncSync(fd);
227
+ }
228
+ finally {
229
+ closeSync(fd);
230
+ }
231
+ renameSync(tmp, path);
232
+ return { written: true, sha256: next };
233
+ }
234
+ //# sourceMappingURL=catalog.js.map
@@ -0,0 +1,66 @@
1
+ /**
2
+ * ADR-406 P0 — engine capability report.
3
+ *
4
+ * Every probe runs a fixed argv through an injected runner (never a shell),
5
+ * with `CLAUDE_CONFIG_DIR` pointed at an isolated directory so the probe
6
+ * never reads or writes the person's real `~/.claude`. Anything that could
7
+ * not be observed is reported as `unknown` with the reason, never assumed.
8
+ */
9
+ import type { ModCommandHook } from './inventory.js';
10
+ export type Runner = (file: string, args: readonly string[], env: NodeJS.ProcessEnv) => {
11
+ status: number | null;
12
+ stdout: string;
13
+ stderr: string;
14
+ };
15
+ export interface PluginValidation {
16
+ readonly plugin: string;
17
+ readonly passed: boolean | 'unknown';
18
+ readonly commandHooks: readonly string[];
19
+ readonly hostCalls: readonly string[];
20
+ /** A `command.run{command=?}` matcher: names chosen at run time, which only the source scan can list. */
21
+ readonly dynamicCommandHook?: true;
22
+ readonly reason?: string;
23
+ }
24
+ export interface EngineReport {
25
+ readonly schemaVersion: 1;
26
+ readonly engine: {
27
+ readonly executable: string | null;
28
+ readonly version: string | null;
29
+ readonly binarySha256: string | null;
30
+ };
31
+ readonly typeGeneration: 'none-in-engine-cli' | 'unknown';
32
+ readonly vendoredTypes: readonly {
33
+ readonly path: string;
34
+ readonly sha256: string;
35
+ }[];
36
+ readonly validations: readonly PluginValidation[];
37
+ readonly affordances: {
38
+ readonly promptFill: boolean | 'unknown';
39
+ readonly processRun: boolean | 'unknown';
40
+ };
41
+ readonly collisions: readonly {
42
+ readonly name: string;
43
+ readonly builtin: boolean | 'unknown';
44
+ readonly repoCommand: boolean;
45
+ readonly verdict: 'free' | 'taken' | 'unknown';
46
+ }[];
47
+ readonly limits: readonly string[];
48
+ }
49
+ /** Parse `claude plugin validate` text: hooks and `$.` calls the static scanner found. */
50
+ export declare function parseValidateOutput(plugin: string, status: number | null, text: string): PluginValidation;
51
+ /** Plugins that are function-hook mods (have `hooks/register.ts`). */
52
+ export declare function modPlugins(repoRoot: string): string[];
53
+ export interface EngineProbeInput {
54
+ readonly repoRoot: string;
55
+ readonly executable: string | null;
56
+ readonly isolatedConfigDir: string;
57
+ readonly run: Runner;
58
+ /** Names to check for collisions, e.g. `ruflo`, `ruflo-workbench`. */
59
+ readonly proposedNames: readonly string[];
60
+ /** Every name the repository already exposes (legacy + mod names). */
61
+ readonly existingNames: ReadonlySet<string>;
62
+ }
63
+ export declare function probeEngine(input: EngineProbeInput): EngineReport;
64
+ /** Engine-observed command hooks, in the inventory's `ModCommandHook` shape. */
65
+ export declare function hooksFromValidations(validations: readonly PluginValidation[]): ModCommandHook[];
66
+ //# sourceMappingURL=engine-report.d.ts.map
@@ -0,0 +1,119 @@
1
+ /**
2
+ * ADR-406 P0 — engine capability report.
3
+ *
4
+ * Every probe runs a fixed argv through an injected runner (never a shell),
5
+ * with `CLAUDE_CONFIG_DIR` pointed at an isolated directory so the probe
6
+ * never reads or writes the person's real `~/.claude`. Anything that could
7
+ * not be observed is reported as `unknown` with the reason, never assumed.
8
+ */
9
+ import { createHash } from 'node:crypto';
10
+ import { existsSync, readdirSync, readFileSync, realpathSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ /** Parse `claude plugin validate` text: hooks and `$.` calls the static scanner found. */
13
+ export function parseValidateOutput(plugin, status, text) {
14
+ // Per-file lines read `<file> hooks: ...` and `<file> calls: ...`; a mod can
15
+ // span several files, so collect across every such line, not the first.
16
+ const lines = text.split('\n');
17
+ const hooksText = lines.filter((l) => /\.(?:ts|js) hooks:\s/.test(l)).join('\n');
18
+ const callsText = lines.filter((l) => /\.(?:ts|js) calls:\s/.test(l)).join('\n');
19
+ // `command=a|b` is one matcher on two names; `command=?` is computed at run time, so it names nothing.
20
+ const commandHooks = [...new Set([...hooksText.matchAll(/command\.run\{command=([^}]+)\}/g)]
21
+ .flatMap((m) => m[1].split('|'))
22
+ .filter((name) => name !== '?'))].sort();
23
+ const dynamicCommandHook = /command\.run\{command=\?\}/.test(hooksText);
24
+ const hostCalls = [...new Set([...callsText.matchAll(/\$\.([a-zA-Z]+\.[a-zA-Z]+)/g)].map((m) => m[1]))].sort();
25
+ const passed = /Validation passed/.test(text) && status === 0;
26
+ return { plugin, passed, commandHooks, hostCalls, ...(dynamicCommandHook ? { dynamicCommandHook: true } : {}), ...(passed ? {} : { reason: text.trim().split('\n').slice(-3).join(' | ').slice(0, 300) }) };
27
+ }
28
+ function sha256File(path) {
29
+ return createHash('sha256').update(readFileSync(path)).digest('hex');
30
+ }
31
+ function vendoredTypes(repoRoot) {
32
+ const out = [];
33
+ const pluginsRoot = join(repoRoot, 'plugins');
34
+ if (!existsSync(pluginsRoot))
35
+ return out;
36
+ for (const plugin of readdirSync(pluginsRoot).sort()) {
37
+ const dir = join(pluginsRoot, plugin, 'types');
38
+ if (!existsSync(dir))
39
+ continue;
40
+ for (const name of readdirSync(dir).sort()) {
41
+ if (name.endsWith('.d.ts'))
42
+ out.push({ path: `plugins/${plugin}/types/${name}`, sha256: sha256File(join(dir, name)) });
43
+ }
44
+ }
45
+ return out;
46
+ }
47
+ /** Plugins that are function-hook mods (have `hooks/register.ts`). */
48
+ export function modPlugins(repoRoot) {
49
+ const root = join(repoRoot, 'plugins');
50
+ if (!existsSync(root))
51
+ return [];
52
+ return readdirSync(root).filter((p) => existsSync(join(root, p, 'hooks', 'register.ts'))).sort();
53
+ }
54
+ /**
55
+ * A builtin slash command is compiled into the engine binary as `name:"<cmd>"`
56
+ * (checked: `compact` and `clear` match, unrelated words do not). Absence of
57
+ * the literal is evidence a name is not a builtin, not proof.
58
+ */
59
+ function builtinPresence(binary, name) {
60
+ if (!binary)
61
+ return 'unknown';
62
+ return binary.includes(Buffer.from(`name:"${name}"`));
63
+ }
64
+ export function probeEngine(input) {
65
+ const env = { ...process.env, CLAUDE_CONFIG_DIR: input.isolatedConfigDir };
66
+ const limits = [];
67
+ let version = null;
68
+ let binary = null;
69
+ let resolved = null;
70
+ if (input.executable && existsSync(input.executable)) {
71
+ resolved = realpathSync(input.executable);
72
+ const v = input.run(input.executable, ['--version'], env);
73
+ version = v.status === 0 ? v.stdout.trim().split('\n')[0] : null;
74
+ try {
75
+ binary = readFileSync(resolved);
76
+ }
77
+ catch {
78
+ limits.push('engine binary unreadable; builtin collision check unknown');
79
+ }
80
+ }
81
+ else {
82
+ limits.push('engine executable not found; validation and collision checks are unknown');
83
+ }
84
+ const validations = modPlugins(input.repoRoot).map((plugin) => {
85
+ if (!input.executable || version === null)
86
+ return { plugin, passed: 'unknown', commandHooks: [], hostCalls: [], reason: 'engine unavailable' };
87
+ const r = input.run(input.executable, ['plugin', 'validate', join(input.repoRoot, 'plugins', plugin)], env);
88
+ return parseValidateOutput(plugin, r.status, `${r.stdout}\n${r.stderr}`);
89
+ });
90
+ // Seen in a validated plugin = the scanner accepts it; not seen = unknown,
91
+ // because no plugin here may have tried it.
92
+ const has = (call) => validations.some((v) => v.passed === true && v.hostCalls.includes(call)) ? true : 'unknown';
93
+ limits.push('a static validate scan proves an affordance is accepted by the scanner, not that it behaves as expected at runtime');
94
+ limits.push('builtin detection searches the engine binary for name:"<cmd>"; absence is evidence, not proof');
95
+ return {
96
+ schemaVersion: 1,
97
+ engine: { executable: resolved, version, binarySha256: binary ? createHash('sha256').update(binary).digest('hex') : null },
98
+ typeGeneration: version ? 'none-in-engine-cli' : 'unknown',
99
+ vendoredTypes: vendoredTypes(input.repoRoot),
100
+ validations,
101
+ affordances: { promptFill: has('prompt.fill'), processRun: has('process.run') },
102
+ collisions: input.proposedNames.map((name) => {
103
+ const builtin = builtinPresence(binary, name);
104
+ const repoCommand = input.existingNames.has(name);
105
+ const verdict = builtin === 'unknown' ? 'unknown' : builtin || repoCommand ? 'taken' : 'free';
106
+ return { name, builtin, repoCommand, verdict };
107
+ }),
108
+ limits,
109
+ };
110
+ }
111
+ /** Engine-observed command hooks, in the inventory's `ModCommandHook` shape. */
112
+ export function hooksFromValidations(validations) {
113
+ return validations.flatMap((v) => v.commandHooks.map((command) => ({
114
+ plugin: v.plugin,
115
+ command,
116
+ sourcePath: `plugins/${v.plugin}/hooks/register.ts`,
117
+ })));
118
+ }
119
+ //# sourceMappingURL=engine-report.js.map