@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,174 @@
1
+ /**
2
+ * Re-verify before training (ADR-503 §2.3). Opt-in: `.claude-flow/config.json`
3
+ * `learningLoop.reverify: true`. Off by default because it runs project code.
4
+ *
5
+ * - The command comes only from a fixed allowlist built from the project's own
6
+ * manifest (`package.json` scripts named test/typecheck/build/lint, or
7
+ * `cargo test`/`cargo check` when `Cargo.toml` exists). A record's text is
8
+ * never executed: it only selects, by its verifier *kind*, which allowlisted
9
+ * check NOT to run.
10
+ * - Checks rotate round-robin across ingests, preferring one the record did
11
+ * not use, so no single check is the one that counts.
12
+ * - The real runner works in a temporary detached `git worktree` at HEAD,
13
+ * with a hard timeout; timeout, error or a non-zero exit downgrades the
14
+ * record to `unknown`.
15
+ */
16
+ import { execFile, spawn } from 'node:child_process';
17
+ import { existsSync, mkdtempSync, readFileSync, rmSync, symlinkSync } from 'node:fs';
18
+ import { tmpdir } from 'node:os';
19
+ import { join } from 'node:path';
20
+ /** package.json scripts that may be re-run, in rotation order, and the kind each verifies. */
21
+ const NPM_SCRIPTS = [
22
+ ['test', 'test'],
23
+ ['typecheck', 'typecheck'],
24
+ ['build', 'build'],
25
+ ['lint', 'lint'],
26
+ ];
27
+ /**
28
+ * The allowlist for a project. Only the script NAMES are read from the
29
+ * manifest; the argv is fixed here (`npm run <name>`, `cargo test`, ...).
30
+ */
31
+ export function reverifyChecks(root) {
32
+ const checks = [];
33
+ try {
34
+ const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
35
+ const scripts = pkg && typeof pkg.scripts === 'object' && pkg.scripts !== null ? pkg.scripts : {};
36
+ for (const [name, kind] of NPM_SCRIPTS) {
37
+ if (typeof scripts[name] === 'string' && scripts[name].trim())
38
+ checks.push({ name: `npm:${name}`, kind, argv: ['npm', 'run', name] });
39
+ }
40
+ }
41
+ catch {
42
+ // no or unreadable package.json: no npm checks
43
+ }
44
+ if (existsSync(join(root, 'Cargo.toml'))) {
45
+ checks.push({ name: 'cargo:test', kind: 'test', argv: ['cargo', 'test'] });
46
+ checks.push({ name: 'cargo:check', kind: 'typecheck', argv: ['cargo', 'check'] });
47
+ }
48
+ return checks;
49
+ }
50
+ /**
51
+ * The check to run for a record decided by a `used` verifier: round-robin by
52
+ * `cursor` over the checks of another kind, or over all of them when every
53
+ * check is that kind. Null when the project has none.
54
+ */
55
+ export function pickCheck(checks, used, cursor) {
56
+ if (checks.length === 0)
57
+ return null;
58
+ const others = checks.filter(c => c.kind !== used);
59
+ const pool = others.length > 0 ? others : checks;
60
+ return pool[Math.abs(Math.floor(cursor)) % pool.length];
61
+ }
62
+ const COMMIT = /^[0-9a-f]{40,64}$/;
63
+ function git(args, cwd, timeout = 60_000) {
64
+ return new Promise((resolve, reject) => {
65
+ execFile('git', args, { cwd, timeout, maxBuffer: 1024 * 1024 }, (error, stdout) => (error ? reject(error) : resolve(String(stdout))));
66
+ });
67
+ }
68
+ /** Runs argv (no shell) in `cwd`; the whole process group is killed at the timeout. */
69
+ function runCheck(argv, cwd, timeoutMs) {
70
+ return new Promise(resolve => {
71
+ let settled = false;
72
+ const finish = (status) => {
73
+ if (settled)
74
+ return;
75
+ settled = true;
76
+ clearTimeout(timer);
77
+ resolve(status);
78
+ };
79
+ let child;
80
+ try {
81
+ child = spawn(argv[0], argv.slice(1), { cwd, shell: false, stdio: 'ignore', detached: process.platform !== 'win32', env: { ...process.env, CI: '1' } });
82
+ }
83
+ catch {
84
+ resolve('error');
85
+ return;
86
+ }
87
+ const timer = setTimeout(() => {
88
+ try {
89
+ if (child.pid && process.platform !== 'win32')
90
+ process.kill(-child.pid, 'SIGKILL');
91
+ else
92
+ child.kill('SIGKILL');
93
+ }
94
+ catch {
95
+ // already gone
96
+ }
97
+ finish('timeout');
98
+ }, timeoutMs);
99
+ child.on('error', () => finish('error'));
100
+ child.on('exit', code => finish(code === 0 ? 'pass' : 'fail'));
101
+ });
102
+ }
103
+ /**
104
+ * The real runner: a detached `git worktree` at HEAD in a temp directory, the
105
+ * root's `node_modules` linked in (a fresh worktree has none), the check run,
106
+ * the worktree removed. Any failure along the way is `error`.
107
+ */
108
+ export const gitWorktreeRunner = async (root, check, timeoutMs) => {
109
+ let commit = null;
110
+ const base = mkdtempSync(join(tmpdir(), 'ruflo-reverify-'));
111
+ const dir = join(base, 'wt');
112
+ let added = false;
113
+ try {
114
+ const head = (await git(['rev-parse', 'HEAD'], root, 10_000)).trim();
115
+ if (!COMMIT.test(head))
116
+ return { status: 'error', commit: null };
117
+ commit = head;
118
+ await git(['worktree', 'add', '--detach', dir, head], root);
119
+ added = true;
120
+ if (existsSync(join(root, 'node_modules')) && !existsSync(join(dir, 'node_modules')))
121
+ symlinkSync(join(root, 'node_modules'), join(dir, 'node_modules'), 'dir');
122
+ return { status: await runCheck(check.argv, dir, timeoutMs), commit };
123
+ }
124
+ catch {
125
+ return { status: 'error', commit };
126
+ }
127
+ finally {
128
+ if (added)
129
+ await git(['worktree', 'remove', '--force', dir], root).catch(() => undefined);
130
+ rmSync(base, { recursive: true, force: true });
131
+ if (added)
132
+ await git(['worktree', 'prune'], root).catch(() => undefined);
133
+ }
134
+ };
135
+ /** A runner that never settles still ends: past the deadline its outcome is `timeout`. */
136
+ function withDeadline(run, ms) {
137
+ let timer;
138
+ const deadline = new Promise(resolve => {
139
+ timer = setTimeout(() => resolve({ status: 'timeout', commit: null }), ms);
140
+ timer.unref?.();
141
+ });
142
+ return Promise.race([run, deadline]).finally(() => clearTimeout(timer));
143
+ }
144
+ /** One ingest's re-verification: picks per record, runs each distinct check at most once, never throws. */
145
+ export class ReverifySession {
146
+ root;
147
+ cursor;
148
+ timeoutMs;
149
+ runner;
150
+ results = new Map();
151
+ checks;
152
+ ran = 0;
153
+ constructor(root, cursor, timeoutMs, runner, checks) {
154
+ this.root = root;
155
+ this.cursor = cursor;
156
+ this.timeoutMs = timeoutMs;
157
+ this.runner = runner;
158
+ this.checks = checks ?? reverifyChecks(root);
159
+ }
160
+ /** Re-verifies a success decided by a `used` verifier. The record's own command and task are never passed. */
161
+ async verify(used) {
162
+ const check = pickCheck(this.checks, used, this.cursor);
163
+ if (!check)
164
+ return { check: null, outcome: { status: 'error', commit: null } };
165
+ let pending = this.results.get(check.name);
166
+ if (!pending) {
167
+ this.ran++;
168
+ pending = withDeadline(Promise.resolve().then(() => this.runner(this.root, check, this.timeoutMs)), this.timeoutMs + Math.min(this.timeoutMs, 30_000)).then(o => (o && ['pass', 'fail', 'timeout', 'error'].includes(o.status) ? { status: o.status, commit: typeof o.commit === 'string' && COMMIT.test(o.commit) ? o.commit : null } : { status: 'error', commit: null }), () => ({ status: 'error', commit: null }));
169
+ this.results.set(check.name, pending);
170
+ }
171
+ return { check: check.name, outcome: await pending };
172
+ }
173
+ }
174
+ //# sourceMappingURL=verified-reverify.js.map
@@ -45,12 +45,12 @@ export declare const missionRecordSchema: z.ZodObject<{
45
45
  digest: z.ZodString;
46
46
  taskGraphRef: z.ZodString;
47
47
  }, "strict", z.ZodTypeAny, {
48
- revision: number;
49
48
  digest: string;
49
+ revision: number;
50
50
  taskGraphRef: string;
51
51
  }, {
52
- revision: number;
53
52
  digest: string;
53
+ revision: number;
54
54
  taskGraphRef: string;
55
55
  }>;
56
56
  policyRef: z.ZodString;
@@ -74,54 +74,54 @@ export declare const missionRecordSchema: z.ZodObject<{
74
74
  updatedAt: z.ZodString;
75
75
  }, "strict", z.ZodTypeAny, {
76
76
  createdAt: string;
77
- state: "completed" | "cancelled" | "failed" | "running" | "queued" | "paused" | "blocked" | "draft" | "planned" | "awaitingAuthorization" | "pauseRequested" | "verifying" | "cancelRequested";
77
+ updatedAt: string;
78
78
  schemaVersion: 1;
79
- revision: number;
80
- objective: string;
81
- plan: {
79
+ state: "completed" | "cancelled" | "failed" | "blocked" | "running" | "paused" | "queued" | "draft" | "planned" | "awaitingAuthorization" | "pauseRequested" | "verifying" | "cancelRequested";
80
+ acceptance: {
82
81
  revision: number;
82
+ criteriaDigest: string;
83
+ };
84
+ plan: {
83
85
  digest: string;
86
+ revision: number;
84
87
  taskGraphRef: string;
85
88
  };
89
+ objective: string;
86
90
  missionId: string;
87
- updatedAt: string;
88
91
  tenantId: string;
89
92
  workspaceId: string;
90
93
  ownerPrincipalId: string;
94
+ revision: number;
91
95
  policyRef: string;
92
96
  budgetRef: string;
93
97
  executionMode: "durable-executor" | "session-bound";
94
98
  evidenceRefs: string[];
95
- acceptance: {
96
- revision: number;
97
- criteriaDigest: string;
98
- };
99
99
  lastEventSequence: number;
100
100
  authorizationRef?: string | undefined;
101
101
  }, {
102
102
  createdAt: string;
103
- state: "completed" | "cancelled" | "failed" | "running" | "queued" | "paused" | "blocked" | "draft" | "planned" | "awaitingAuthorization" | "pauseRequested" | "verifying" | "cancelRequested";
103
+ updatedAt: string;
104
104
  schemaVersion: 1;
105
- revision: number;
106
- objective: string;
107
- plan: {
105
+ state: "completed" | "cancelled" | "failed" | "blocked" | "running" | "paused" | "queued" | "draft" | "planned" | "awaitingAuthorization" | "pauseRequested" | "verifying" | "cancelRequested";
106
+ acceptance: {
108
107
  revision: number;
108
+ criteriaDigest: string;
109
+ };
110
+ plan: {
109
111
  digest: string;
112
+ revision: number;
110
113
  taskGraphRef: string;
111
114
  };
115
+ objective: string;
112
116
  missionId: string;
113
- updatedAt: string;
114
117
  tenantId: string;
115
118
  workspaceId: string;
116
119
  ownerPrincipalId: string;
120
+ revision: number;
117
121
  policyRef: string;
118
122
  budgetRef: string;
119
123
  executionMode: "durable-executor" | "session-bound";
120
124
  evidenceRefs: string[];
121
- acceptance: {
122
- revision: number;
123
- criteriaDigest: string;
124
- };
125
125
  lastEventSequence: number;
126
126
  authorizationRef?: string | undefined;
127
127
  }>;
@@ -207,15 +207,15 @@ export declare const budgetSpecSchema: z.ZodObject<{
207
207
  }, "strict", z.ZodTypeAny, {
208
208
  currency: string;
209
209
  ceilingMinor: number;
210
- tokens?: number | undefined;
211
210
  concurrency?: number | undefined;
211
+ tokens?: number | undefined;
212
212
  wallTimeSeconds?: number | undefined;
213
213
  retries?: number | undefined;
214
214
  }, {
215
215
  currency: string;
216
216
  ceilingMinor: number;
217
- tokens?: number | undefined;
218
217
  concurrency?: number | undefined;
218
+ tokens?: number | undefined;
219
219
  wallTimeSeconds?: number | undefined;
220
220
  retries?: number | undefined;
221
221
  }>;
@@ -301,15 +301,15 @@ export declare const planBodySchema: z.ZodObject<{
301
301
  }, "strict", z.ZodTypeAny, {
302
302
  currency: string;
303
303
  ceilingMinor: number;
304
- tokens?: number | undefined;
305
304
  concurrency?: number | undefined;
305
+ tokens?: number | undefined;
306
306
  wallTimeSeconds?: number | undefined;
307
307
  retries?: number | undefined;
308
308
  }, {
309
309
  currency: string;
310
310
  ceilingMinor: number;
311
- tokens?: number | undefined;
312
311
  concurrency?: number | undefined;
312
+ tokens?: number | undefined;
313
313
  wallTimeSeconds?: number | undefined;
314
314
  retries?: number | undefined;
315
315
  }>;
@@ -340,8 +340,8 @@ export declare const planBodySchema: z.ZodObject<{
340
340
  budget: {
341
341
  currency: string;
342
342
  ceilingMinor: number;
343
- tokens?: number | undefined;
344
343
  concurrency?: number | undefined;
344
+ tokens?: number | undefined;
345
345
  wallTimeSeconds?: number | undefined;
346
346
  retries?: number | undefined;
347
347
  };
@@ -372,8 +372,8 @@ export declare const planBodySchema: z.ZodObject<{
372
372
  budget: {
373
373
  currency: string;
374
374
  ceilingMinor: number;
375
- tokens?: number | undefined;
376
375
  concurrency?: number | undefined;
376
+ tokens?: number | undefined;
377
377
  wallTimeSeconds?: number | undefined;
378
378
  retries?: number | undefined;
379
379
  };
@@ -397,11 +397,11 @@ export declare const createInputSchema: z.ZodObject<{
397
397
  requestId: z.ZodString;
398
398
  objective: z.ZodString;
399
399
  }, "strict", z.ZodTypeAny, {
400
- objective: string;
401
400
  requestId: string;
402
- }, {
403
401
  objective: string;
402
+ }, {
404
403
  requestId: string;
404
+ objective: string;
405
405
  }>;
406
406
  export declare const planInputSchema: z.ZodObject<{
407
407
  requestId: z.ZodString;
@@ -489,15 +489,15 @@ export declare const planInputSchema: z.ZodObject<{
489
489
  }, "strict", z.ZodTypeAny, {
490
490
  currency: string;
491
491
  ceilingMinor: number;
492
- tokens?: number | undefined;
493
492
  concurrency?: number | undefined;
493
+ tokens?: number | undefined;
494
494
  wallTimeSeconds?: number | undefined;
495
495
  retries?: number | undefined;
496
496
  }, {
497
497
  currency: string;
498
498
  ceilingMinor: number;
499
- tokens?: number | undefined;
500
499
  concurrency?: number | undefined;
500
+ tokens?: number | undefined;
501
501
  wallTimeSeconds?: number | undefined;
502
502
  retries?: number | undefined;
503
503
  }>;
@@ -528,8 +528,8 @@ export declare const planInputSchema: z.ZodObject<{
528
528
  budget: {
529
529
  currency: string;
530
530
  ceilingMinor: number;
531
- tokens?: number | undefined;
532
531
  concurrency?: number | undefined;
532
+ tokens?: number | undefined;
533
533
  wallTimeSeconds?: number | undefined;
534
534
  retries?: number | undefined;
535
535
  };
@@ -560,8 +560,8 @@ export declare const planInputSchema: z.ZodObject<{
560
560
  budget: {
561
561
  currency: string;
562
562
  ceilingMinor: number;
563
- tokens?: number | undefined;
564
563
  concurrency?: number | undefined;
564
+ tokens?: number | undefined;
565
565
  wallTimeSeconds?: number | undefined;
566
566
  retries?: number | undefined;
567
567
  };
@@ -601,8 +601,8 @@ export declare const planInputSchema: z.ZodObject<{
601
601
  budget: {
602
602
  currency: string;
603
603
  ceilingMinor: number;
604
- tokens?: number | undefined;
605
604
  concurrency?: number | undefined;
605
+ tokens?: number | undefined;
606
606
  wallTimeSeconds?: number | undefined;
607
607
  retries?: number | undefined;
608
608
  };
@@ -638,8 +638,8 @@ export declare const planInputSchema: z.ZodObject<{
638
638
  budget: {
639
639
  currency: string;
640
640
  ceilingMinor: number;
641
- tokens?: number | undefined;
642
641
  concurrency?: number | undefined;
642
+ tokens?: number | undefined;
643
643
  wallTimeSeconds?: number | undefined;
644
644
  retries?: number | undefined;
645
645
  };
@@ -687,13 +687,13 @@ export declare const requestActionInputSchema: z.ZodObject<{
687
687
  action: z.ZodEnum<["pause", "resume", "cancel", "requestAuthorization", "admit"]>;
688
688
  reason: z.ZodOptional<z.ZodString>;
689
689
  }, "strict", z.ZodTypeAny, {
690
- action: "pause" | "resume" | "cancel" | "admit" | "requestAuthorization";
690
+ action: "pause" | "resume" | "cancel" | "requestAuthorization" | "admit";
691
691
  requestId: string;
692
692
  missionId: string;
693
693
  expectedRevision: number;
694
694
  reason?: string | undefined;
695
695
  }, {
696
- action: "pause" | "resume" | "cancel" | "admit" | "requestAuthorization";
696
+ action: "pause" | "resume" | "cancel" | "requestAuthorization" | "admit";
697
697
  requestId: string;
698
698
  missionId: string;
699
699
  expectedRevision: number;
@@ -18,6 +18,8 @@ export interface ApplyOptions {
18
18
  skipInTestEnv?: boolean;
19
19
  /** The marketplace to declare when the settings have none (default github ruvnet/ruflo). */
20
20
  marketplace?: MarketplaceEntry;
21
+ /** ADR-509 §2.2: plugins (name or id) to install past a high scan finding; each override is recorded. */
22
+ acceptRisk?: readonly string[];
21
23
  /** Injectable for tests. */
22
24
  exec?: Exec;
23
25
  }
@@ -7,9 +7,10 @@
7
7
  import { readFileSync } from 'node:fs';
8
8
  import { homedir } from 'node:os';
9
9
  import { output } from '../output.js';
10
- import { installMod, MARKETPLACE_NAME, MARKETPLACE_SOURCE, MOD_PLUGINS, readRecord, recordClaudeInstalled, scopeOfSettingsFile, uninstallMod, } from './install.js';
10
+ import { disableBlockedPlugins, installMod, MARKETPLACE_NAME, MARKETPLACE_SOURCE, MOD_PLUGIN_IDS, MOD_PLUGINS, readRecord, readSettingsFile, settingsFileFor, recordClaudeInstalled, scopeOfSettingsFile, uninstallMod, } from './install.js';
11
11
  import { findClaudeBinary, installArgv, marketplaceArgv, repairPluginInstall, uninstallArgv, uninstallPlugins } from './plugin-repair.js';
12
12
  import { claudeConfigDir, describeSource, installedState, marketplaceState, repairCommands, resolveFindings, sameSource } from './plugin-resolve.js';
13
+ import { scanBeforeInstall, verifyAndRecord } from './install-trust.js';
13
14
  /** One line per thing a run added; empty on a re-run. */
14
15
  export function describeAdded(added) {
15
16
  return [
@@ -68,14 +69,39 @@ function noteRewrite(file, before) {
68
69
  // Not ours to judge further.
69
70
  }
70
71
  }
72
+ /** The marketplace the settings file already declares, else the requested one. */
73
+ function declaredMarketplace(root, scope, requested) {
74
+ try {
75
+ const markets = readSettingsFile(settingsFileFor(root, scope)).extraKnownMarketplaces;
76
+ return markets?.[MARKETPLACE_NAME] ?? requested;
77
+ }
78
+ catch {
79
+ return requested;
80
+ }
81
+ }
71
82
  export async function applyMods(root, opts) {
72
- const install = installMod(root, opts.scope, opts.dryRun === true, undefined, opts.marketplace ?? MARKETPLACE_SOURCE);
73
83
  const configDir = claudeConfigDir(process.env, homedir());
84
+ const acceptRisk = new Set(opts.acceptRisk ?? []);
85
+ const seen = new Set();
86
+ const blocked = new Map();
87
+ const told = new Set();
88
+ // ADR-509 §2.2: Claude Code loads an enabled plugin straight from the
89
+ // marketplace clone, install or not. So where a clone already exists, scan it
90
+ // BEFORE enabling anything from it, and never enable what the scan blocks.
91
+ const preClone = opts.dryRun ? null : marketplaceState(configDir).location;
92
+ const scannable = declaredMarketplace(root, opts.scope, opts.marketplace ?? MARKETPLACE_SOURCE).source.source !== 'directory';
93
+ if (preClone && scannable) {
94
+ for (const [id, why] of scanBeforeInstall(root, preClone, MOD_PLUGINS, acceptRisk, undefined, seen).blocked)
95
+ blocked.set(id, why);
96
+ }
97
+ const install = installMod(root, opts.scope, opts.dryRun === true, MOD_PLUGIN_IDS.filter((id) => !blocked.has(id)), opts.marketplace ?? MARKETPLACE_SOURCE);
98
+ // A blocked plugin enabled by an earlier run (or by hand) is turned off too.
99
+ turnOff(root, install, blocked, told);
74
100
  const lines = describeAdded(install.added);
75
101
  // Only what is enabled in the file: `claude plugin install` would flip a
76
102
  // plugin someone set to false back to true.
77
103
  const enabled = (install.next.enabledPlugins ?? {});
78
- const plugins = MOD_PLUGINS.filter((p) => enabled[p.id] === true);
104
+ const plugins = MOD_PLUGINS.filter((p) => enabled[p.id] === true && !blocked.has(p.id));
79
105
  // The marketplace the file declares wins: a person's own declaration is kept and used.
80
106
  const declared = install.next.extraKnownMarketplaces?.[MARKETPLACE_NAME] ?? MARKETPLACE_SOURCE;
81
107
  const market = marketplaceState(configDir);
@@ -111,16 +137,25 @@ export async function applyMods(root, opts) {
111
137
  for (const line of repairCommands(root, opts.scope, k, plugins, declared))
112
138
  output.writeln(` ${line}`);
113
139
  };
114
- if (opts.pluginInstall === false)
115
- return { install, resolvable: true, skipped: '--no-plugin-install' };
140
+ // The `claude` step may not run below: say what is enabled without a scan.
141
+ const unscanned = () => {
142
+ if (!preClone && scannable && plugins.length)
143
+ output.printWarning(`No ruflo marketplace clone yet, so nothing was scanned (ADR-509): ${plugins.map((p) => p.id).join(', ')} enabled unscanned; Claude Code clones it on the first trusted start. Run ruflo mods install again after that to scan it.`);
144
+ };
145
+ if (opts.pluginInstall === false) {
146
+ unscanned();
147
+ return { install, resolvable: blocked.size === 0, skipped: '--no-plugin-install' };
148
+ }
116
149
  if (opts.skipInTestEnv && (process.env.VITEST || process.env.CI)) {
117
150
  output.writeln(output.dim(`claude plugin step skipped (${process.env.VITEST ? 'VITEST' : 'CI'} set).`));
151
+ unscanned();
118
152
  manual();
119
153
  return { install, resolvable: false, skipped: 'test or CI environment' };
120
154
  }
121
155
  const claude = findClaudeBinary(process.env, homedir());
122
156
  if (!claude) {
123
157
  output.printWarning('No runnable claude binary on PATH: the plugins are enabled in settings, not installed.');
158
+ unscanned();
124
159
  manual();
125
160
  return { install, resolvable: false, skipped: 'no claude on PATH' };
126
161
  }
@@ -130,8 +165,21 @@ export async function applyMods(root, opts) {
130
165
  // Never claim (and so never uninstall) a plugin that was installed before, or one ruflo did not enable.
131
166
  const before = new Set(plugins.filter((p) => installedState(root, configDir, p.id).installed).map((p) => p.id));
132
167
  const textBefore = readText(install.settingsFile);
133
- const repair = await repairPluginInstall({ projectRoot: root, scope: opts.scope, configDir, claude, plugins, marketplace: declared, exec: opts.exec });
168
+ const repair = await repairPluginInstall({
169
+ projectRoot: root, scope: opts.scope, configDir, claude, plugins, marketplace: declared, exec: opts.exec,
170
+ // ADR-509 §2.2: scan the refreshed clone before any install (a directory marketplace has no install step to gate).
171
+ beforeInstall: (clone) => {
172
+ const gate = scanBeforeInstall(root, clone, plugins, acceptRisk, undefined, seen);
173
+ for (const [id, why] of gate.blocked)
174
+ blocked.set(id, why);
175
+ return gate;
176
+ },
177
+ });
134
178
  printSteps(repair.steps);
179
+ // The refresh may have brought a finding the first scan did not see: that plugin is turned off as well.
180
+ turnOff(root, install, blocked, told);
181
+ // ADR-509 §2.1: verify what Claude Code will load and leave the result for the mod's trust gate.
182
+ verifyAndRecord(configDir, marketplaceState(configDir).location, plugins.map((p) => p.id.split('@')[0]));
135
183
  if (declared.source.source === 'directory' && repair.ok)
136
184
  output.writeln(output.dim(`Directory marketplace: plugins load live from ${declared.source.path} (no clone, no install).`));
137
185
  const enabledByRuflo = new Set(readRecord(root)?.files[install.settingsFile]?.plugins ?? []);
@@ -141,8 +189,8 @@ export async function applyMods(root, opts) {
141
189
  .filter((id) => !before.has(id) && enabledByRuflo.has(id));
142
190
  recordClaudeInstalled(root, install.settingsFile, ours);
143
191
  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)
192
+ const failed = resolveFindings(root, opts.scope, configDir, undefined, plugins.filter((p) => !blocked.has(p.id)).map((p) => p.id), declared).filter((f) => f.status === 'fail');
193
+ if (repair.ok && failed.length === 0 && blocked.size === 0)
146
194
  return { install, resolvable: true };
147
195
  for (const f of failed)
148
196
  output.writeln(`${output.error('✗')} ${f.name}: ${f.message}`);
@@ -150,6 +198,18 @@ export async function applyMods(root, opts) {
150
198
  manual(now.known && sameSource(now.source, declared.source));
151
199
  return { install, resolvable: false };
152
200
  }
201
+ /** ADR-509 §2.2: turn off (enabledPlugins + install record) each blocked plugin, and say so. */
202
+ function turnOff(root, install, blocked, told) {
203
+ if (install.dryRun || blocked.size === 0)
204
+ return;
205
+ const off = disableBlockedPlugins(root, install.settingsFile, [...blocked.keys()]);
206
+ for (const [id, why] of blocked) {
207
+ if (told.has(id) && !off.includes(id))
208
+ continue; // already said, and nothing changed since
209
+ told.add(id);
210
+ output.writeln(`${output.error('✗')} ${id}: ${off.includes(id) ? `enabledPlugins["${id}"] removed from ${install.settingsFile}` : 'not enabled'}; ${why}`);
211
+ }
212
+ }
153
213
  /**
154
214
  * `ruflo mods uninstall`: `claude plugin uninstall` exactly the plugins ruflo
155
215
  * installed, then remove exactly the settings keys it added.