@devrik-tools/claude-gates 0.4.0 → 0.7.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 (57) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/README.es.md +39 -4
  3. package/README.md +34 -5
  4. package/cli/config.mjs +126 -124
  5. package/cli/init.mjs +303 -276
  6. package/cli/install.mjs +281 -175
  7. package/cli/materialize.mjs +103 -102
  8. package/cli/registry.mjs +139 -136
  9. package/cli/smoke-fixtures.json +65 -0
  10. package/cli/task.mjs +140 -140
  11. package/package.json +1 -1
  12. package/plugins/gates/.claude-plugin/plugin.json +1 -1
  13. package/plugins/gates/hooks/ask-adoption.mjs +147 -147
  14. package/plugins/gates/hooks/doctor.mjs +207 -207
  15. package/plugins/gates/hooks/gates/atomic-commit/index.mjs +229 -0
  16. package/plugins/gates/hooks/gates/audit-before-build/index.mjs +110 -88
  17. package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +50 -50
  18. package/plugins/gates/hooks/gates/bash-commands/index.mjs +215 -215
  19. package/plugins/gates/hooks/gates/brief-approved/index.mjs +216 -0
  20. package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +269 -265
  21. package/plugins/gates/hooks/gates/capability-map/index.mjs +701 -0
  22. package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +527 -501
  23. package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +48 -43
  24. package/plugins/gates/hooks/gates/feature-catalog/index.mjs +83 -83
  25. package/plugins/gates/hooks/gates/force-parallel/index.mjs +134 -119
  26. package/plugins/gates/hooks/gates/forge-flow/index.mjs +134 -134
  27. package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +187 -187
  28. package/plugins/gates/hooks/gates/intent-flow/index.mjs +260 -260
  29. package/plugins/gates/hooks/gates/lint-commit/index.mjs +152 -149
  30. package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +180 -180
  31. package/plugins/gates/hooks/gates/never-assume/index.mjs +59 -58
  32. package/plugins/gates/hooks/gates/no-blocking/index.mjs +163 -148
  33. package/plugins/gates/hooks/gates/no-coauthor/index.mjs +127 -0
  34. package/plugins/gates/hooks/gates/no-lint-suppression/index.mjs +183 -0
  35. package/plugins/gates/hooks/gates/protected-paths/index.mjs +149 -144
  36. package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +91 -89
  37. package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +263 -159
  38. package/plugins/gates/hooks/gates/risk-level/index.mjs +265 -263
  39. package/plugins/gates/hooks/gates/root-cause-first/index.mjs +57 -56
  40. package/plugins/gates/hooks/gates/root-whitelist/index.mjs +211 -131
  41. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +181 -184
  42. package/plugins/gates/hooks/gates/sdd-specs/index.mjs +256 -256
  43. package/plugins/gates/hooks/gates/staged-lint/index.mjs +187 -0
  44. package/plugins/gates/hooks/gates/stop-pending/index.mjs +169 -164
  45. package/plugins/gates/hooks/gates/test-matrix/index.mjs +187 -187
  46. package/plugins/gates/hooks/gates/tool-map/index.mjs +168 -143
  47. package/plugins/gates/hooks/hooks.json +61 -0
  48. package/plugins/gates/hooks/lib/config.mjs +179 -172
  49. package/plugins/gates/hooks/lib/hook-io.mjs +367 -357
  50. package/plugins/gates/hooks/lib/signals.mjs +172 -127
  51. package/plugins/gates/hooks/wiring-check.mjs +227 -227
  52. package/plugins/tasks/.claude-plugin/plugin.json +1 -1
  53. package/plugins/tasks/hooks/hooks.json +26 -26
  54. package/plugins/tasks/hooks/lib/task-store.mjs +217 -197
  55. package/plugins/tasks/hooks/register-requests.mjs +145 -145
  56. package/plugins/tasks/hooks/session-tasks.mjs +108 -108
  57. package/registry.json +192 -1
package/cli/install.mjs CHANGED
@@ -1,175 +1,281 @@
1
- // Installs the gates plugin into Claude Code from the selection `init` just wrote.
2
- // Additive by design: `claude plugin install` merges the plugin's hooks alongside whatever
3
- // the user already has — it never rewrites their settings. If the `claude` binary is not
4
- // reachable, this degrades to printing the manual command, and `init` still succeeds.
5
-
6
- import { execFileSync } from 'node:child_process';
7
- import { readFileSync } from 'node:fs';
8
- import { SCOPES } from './config.mjs';
9
- import { MARKETPLACE_PATH, REPOSITORY_ROOT } from './constants.mjs';
10
-
11
- const CLAUDE_BIN = 'claude';
12
-
13
- // Config scope decides where the plugin is installed: a project selection stays local to
14
- // this project (its .claude/settings.json); a global selection installs for every project.
15
- const PLUGIN_SCOPE = Object.freeze({
16
- [SCOPES.PROJECT]: 'project',
17
- [SCOPES.GLOBAL]: 'user',
18
- });
19
-
20
- function marketplaceManifest() {
21
- return JSON.parse(readFileSync(MARKETPLACE_PATH, 'utf8').replace(/^/, ''));
22
- }
23
-
24
- /**
25
- * The name Claude Code actually registered THIS directory's marketplace under. It is usually
26
- * the manifest's `name`, but not always: if the user added the same directory earlier under a
27
- * different name (e.g. `devrik`), Claude Code keeps that original registration name, and
28
- * `plugin marketplace add` is a no-op that does not rename it. Installing as `plugin@<name>`
29
- * then fails with "not found in marketplace <name>". So we ask Claude Code which registered
30
- * marketplace points at our REPOSITORY_ROOT and use that name; we fall back to the manifest
31
- * name when the listing is unavailable (e.g. no `claude` binary, or a parsing change).
32
- */
33
- function registeredMarketplaceName(runClaude, fallbackName) {
34
- try {
35
- const listing = runClaude(['plugin', 'marketplace', 'list']);
36
- // Each marketplace block prints a name line then a `Source: (<path>)` line. Find the
37
- // block whose source path is our repo root and return its name.
38
- const root = REPOSITORY_ROOT.replace(/[\\/]+$/, '');
39
- const lines = listing.split(/\r?\n/);
40
- let currentName = null;
41
- for (const line of lines) {
42
- const nameMatch = line.match(/^\s*(?:❯\s*)?([A-Za-z0-9_-]+)\s*$/);
43
- if (nameMatch) currentName = nameMatch[1];
44
- const sourceMatch = line.match(/Source:.*\(([^)]+)\)/);
45
- if (sourceMatch && currentName) {
46
- const sourcePath = sourceMatch[1].replace(/[\\/]+$/, '');
47
- if (sourcePath.toLowerCase() === root.toLowerCase()) return currentName;
48
- }
49
- }
50
- } catch {
51
- // Listing unavailable — fall back to the manifest name below.
52
- }
53
- return fallbackName;
54
- }
55
-
56
- /** Every plugin the manifest declares, paired with the marketplace's registered name. */
57
- function marketplaceAndPlugins(runClaude = realClaude) {
58
- const manifest = marketplaceManifest();
59
- const marketplace = registeredMarketplaceName(runClaude, manifest.name);
60
- return manifest.plugins.map((plugin) => ({
61
- marketplace,
62
- plugin: plugin.name,
63
- }));
64
- }
65
-
66
- /** The `claude plugin install …` lines, one per plugin the manifest declares. */
67
- export function pluginInstallCommands() {
68
- return marketplaceAndPlugins().map(
69
- ({ marketplace, plugin }) => `claude plugin install ${plugin}@${marketplace}`,
70
- );
71
- }
72
-
73
- /** Back-compat single-line form: the first plugin's install command. */
74
- export function pluginInstallCommand() {
75
- return pluginInstallCommands()[0];
76
- }
77
-
78
- function realClaude(commandArguments) {
79
- return execFileSync(CLAUDE_BIN, commandArguments, {
80
- encoding: 'utf8',
81
- stdio: 'pipe',
82
- });
83
- }
84
-
85
- function reasonFor(error) {
86
- return error?.code === 'ENOENT'
87
- ? 'the `claude` command was not found on PATH'
88
- : (error?.stderr || error?.message || String(error)).trim().split('\n')[0];
89
- }
90
-
91
- /**
92
- * Best-effort removal of a previously installed version of each manifest plugin, at the
93
- * given scope, before the fresh install runs. `claude plugin uninstall <plugin> --scope
94
- * <scope>` is verified (via `claude plugin uninstall --help`) to take the bare plugin name
95
- * (no `@marketplace`, unlike install) and the same `--scope` values as install. When there
96
- * is no previous install, uninstall fails — that is expected and non-fatal, so failures here
97
- * are swallowed and never stop the install that follows.
98
- */
99
- function removePreviousInstalls(targets, scope, runClaude) {
100
- for (const { plugin } of targets) {
101
- try {
102
- runClaude(['plugin', 'uninstall', plugin, '--scope', scope, '--yes']);
103
- } catch {
104
- // No previous install (or removal failed for some other reason) — non-fatal either way;
105
- // the install below is what actually matters.
106
- }
107
- }
108
- }
109
-
110
- /**
111
- * Registers the marketplace (idempotent: a second add just reports it already exists, which
112
- * is not fatal) once, then installs EVERY plugin the manifest declares at the scope matching
113
- * the config choice. A project that adopts claude-gates gets all of its plugins (gates,
114
- * tasks, …), not just the first a single failed install does not stop the rest from being
115
- * attempted, so one broken plugin never silently blocks another that would have worked.
116
- *
117
- * Returns { installed, scope, results } where `installed` is true only if every plugin
118
- * installed; `results` is a per-plugin { plugin, installed, reason? } list so a caller can
119
- * report exactly which ones need the manual command.
120
- *
121
- * `runClaude` is an injectable seam (defaults to the real `claude` binary) so tests can
122
- * exercise the multi-plugin partial-failure logic without actually invoking the CLI and
123
- * installing plugins on the machine running the test.
124
- *
125
- * `removePrevious` (default true) uninstalls each plugin's previous version at this scope
126
- * before installing, so a stale version never lingers alongside the new one. It is best-effort
127
- * and never fails the overall install.
128
- */
129
- export function installPlugin(
130
- configScope,
131
- { cwd = process.cwd(), runClaude = realClaude, removePrevious = true } = {},
132
- ) {
133
- const scope = PLUGIN_SCOPE[configScope] ?? 'user';
134
-
135
- try {
136
- runClaude(['plugin', 'marketplace', 'add', REPOSITORY_ROOT, '--scope', 'user']);
137
- } catch {
138
- // Already registered, or the marketplace add is a no-op — install can still proceed.
139
- }
140
-
141
- // Resolve the registered marketplace name AFTER the add, so a fresh registration is seen and
142
- // a pre-existing one (under any name) is matched by its source path. Doing it here, not at
143
- // module top, means the name reflects the live registration this run just ensured.
144
- const targets = marketplaceAndPlugins(runClaude);
145
-
146
- if (removePrevious) removePreviousInstalls(targets, scope, runClaude);
147
-
148
- const results = targets.map(({ marketplace, plugin }) => {
149
- try {
150
- runClaude([
151
- 'plugin',
152
- 'install',
153
- `${plugin}@${marketplace}`,
154
- '--yes',
155
- '--scope',
156
- scope,
157
- ]);
158
- return { plugin, installed: true };
159
- } catch (error) {
160
- return { plugin, installed: false, reason: reasonFor(error) };
161
- }
162
- });
163
-
164
- const installed = results.every((result) => result.installed);
165
- if (installed) return { installed: true, scope, results };
166
-
167
- const failed = results.filter((result) => !result.installed);
168
- return {
169
- installed: false,
170
- scope,
171
- results,
172
- reason: failed.map((result) => `${result.plugin}: ${result.reason}`).join('; '),
173
- cwd,
174
- };
175
- }
1
+ // Installs the gates plugin into Claude Code from the selection `init` just wrote.
2
+ // Additive by design: `claude plugin install` merges the plugin's hooks alongside whatever
3
+ // the user already has — it never rewrites their settings. If the `claude` binary is not
4
+ // reachable, this degrades to printing the manual command, and `init` still succeeds.
5
+
6
+ import { execFileSync } from 'node:child_process';
7
+ import { readFileSync } from 'node:fs';
8
+ import { SCOPES } from './config.mjs';
9
+ import { MARKETPLACE_PATH, REPOSITORY_ROOT } from './constants.mjs';
10
+
11
+ const CLAUDE_BIN = 'claude';
12
+
13
+ // Config scope decides where the plugin is installed: a project selection stays local to
14
+ // this project (its .claude/settings.json); a global selection installs for every project.
15
+ const PLUGIN_SCOPE = Object.freeze({
16
+ [SCOPES.PROJECT]: 'project',
17
+ [SCOPES.GLOBAL]: 'user',
18
+ });
19
+
20
+ const BOM_CODE_POINT = 0xfeff;
21
+
22
+ function stripBom(text) {
23
+ return text.charCodeAt(0) === BOM_CODE_POINT ? text.slice(1) : text;
24
+ }
25
+
26
+ function marketplaceManifest() {
27
+ return JSON.parse(stripBom(readFileSync(MARKETPLACE_PATH, 'utf8')));
28
+ }
29
+
30
+ // A marketplace block in `plugin marketplace list` prints a name line (optionally bulleted
31
+ // with ❯) then a `Source: <Kind> (<path>)` line. These match one line each anchored and
32
+ // with bounded, non-overlapping character classes so there is no catastrophic backtracking.
33
+ const MARKETPLACE_NAME_LINE = /^[^\S\n]*(?:❯[^\S\n]*)?([\w-]+)[^\S\n]*$/;
34
+ const MARKETPLACE_DIRECTORY_SOURCE =
35
+ /^[^\S\n]*Source:[^\S\n]*Directory[^\S\n]*\(([^)]+)\)/i;
36
+ // A plain loop, not a regex: `/[\\/]+$/` is flagged as super-linear by the linter even
37
+ // though this use is bounded and safe, and trimming trailing slashes one character at a
38
+ // time from the end needs no backtracking-capable pattern at all.
39
+ function trimTrailingSlashes(text) {
40
+ let end = text.length;
41
+ while (end > 0 && (text[end - 1] === '/' || text[end - 1] === '\\')) end -= 1;
42
+ return text.slice(0, end);
43
+ }
44
+
45
+ /**
46
+ * Every registered marketplace as `{ name, source }`, parsed from `plugin marketplace list`.
47
+ * `source` is the directory path a Directory-source marketplace serves from (trailing slash
48
+ * stripped). Only Directory-source marketplaces are returned (GitHub sources have no local
49
+ * path to compare). Empty on any listing failure.
50
+ */
51
+ function listRegisteredMarketplaces(runClaude) {
52
+ let listing;
53
+ try {
54
+ listing = runClaude(['plugin', 'marketplace', 'list']);
55
+ } catch {
56
+ return [];
57
+ }
58
+ const marketplaces = [];
59
+ let currentName = null;
60
+ for (const line of listing.split(/\r?\n/)) {
61
+ const nameMatch = MARKETPLACE_NAME_LINE.exec(line);
62
+ if (nameMatch) {
63
+ currentName = nameMatch[1];
64
+ continue;
65
+ }
66
+ const sourceMatch = MARKETPLACE_DIRECTORY_SOURCE.exec(line);
67
+ if (sourceMatch && currentName) {
68
+ marketplaces.push({
69
+ name: currentName,
70
+ source: trimTrailingSlashes(sourceMatch[1]),
71
+ });
72
+ currentName = null;
73
+ }
74
+ }
75
+ return marketplaces;
76
+ }
77
+
78
+ /**
79
+ * The name Claude Code actually registered THIS directory's marketplace under. Usually the
80
+ * manifest's `name`, but not always: if the user added the same directory earlier under a
81
+ * different name (e.g. `devrik`), Claude Code keeps that original registration name, and
82
+ * `plugin marketplace add` is a no-op that does not rename it. Installing as `plugin@<name>`
83
+ * then fails with "not found in marketplace <name>". So we find the registered marketplace
84
+ * whose source path is our REPOSITORY_ROOT and use that name; we fall back to the manifest
85
+ * name when the listing is unavailable (e.g. no `claude` binary).
86
+ */
87
+ function registeredMarketplaceName(runClaude, fallbackName) {
88
+ const root = trimTrailingSlashes(REPOSITORY_ROOT);
89
+ const here = listRegisteredMarketplaces(runClaude).find(
90
+ (entry) => entry.source.toLowerCase() === root.toLowerCase(),
91
+ );
92
+ return here ? here.name : fallbackName;
93
+ }
94
+
95
+ /** Every plugin the manifest declares, paired with the marketplace's registered name. */
96
+ function marketplaceAndPlugins(runClaude = realClaude) {
97
+ const manifest = marketplaceManifest();
98
+ const marketplace = registeredMarketplaceName(runClaude, manifest.name);
99
+ return manifest.plugins.map((plugin) => ({
100
+ marketplace,
101
+ plugin: plugin.name,
102
+ }));
103
+ }
104
+
105
+ /**
106
+ * A marketplace registered from a DIRECTORY serves whatever version lives at that path. When
107
+ * the user updates the npm package, the new version lands in a NEW location (a fresh npx cache
108
+ * dir, a global install, a cloned repo), but the marketplace still points at the OLD path and
109
+ * `plugin marketplace add` on the new path is a no-op that does NOT re-point it — so Claude
110
+ * Code keeps serving the stale version, and `plugin update` finds nothing newer. This detects
111
+ * that mismatch and re-points the marketplace (remove + add) at THIS running package's root,
112
+ * so an update actually takes effect.
113
+ *
114
+ * It matches the existing registration by NAME (the manifest name, plus any name that already
115
+ * points at a claude-gates package path resilient to the marketplace having been registered
116
+ * under a different name, the known `devrik` case). No-op when a registration already points
117
+ * here (the common case). Best-effort: any failure is swallowed and the normal add still runs.
118
+ */
119
+ function repointMarketplaceIfStale(runClaude, manifestName) {
120
+ const here = trimTrailingSlashes(REPOSITORY_ROOT);
121
+ const registered = listRegisteredMarketplaces(runClaude);
122
+ if (registered.length === 0) return;
123
+
124
+ // Already pointing here under any name → nothing to do.
125
+ if (
126
+ registered.some(
127
+ (entry) => entry.source.toLowerCase() === here.toLowerCase(),
128
+ )
129
+ )
130
+ return;
131
+
132
+ // A stale registration to re-point: the one named like our manifest, or (fallback) one whose
133
+ // stale source path is itself a claude-gates package directory.
134
+ const stale =
135
+ registered.find((entry) => entry.name === manifestName) ??
136
+ registered.find((entry) =>
137
+ /[\\/]@devrik-tools[\\/]claude-gates$/i.test(entry.source),
138
+ );
139
+ if (!stale) return;
140
+
141
+ try {
142
+ runClaude(['plugin', 'marketplace', 'remove', stale.name]);
143
+ runClaude([
144
+ 'plugin',
145
+ 'marketplace',
146
+ 'add',
147
+ REPOSITORY_ROOT,
148
+ '--scope',
149
+ 'user',
150
+ ]);
151
+ } catch {
152
+ // Re-pointing failed — non-fatal; the caller's own add attempt still follows.
153
+ }
154
+ }
155
+
156
+ /** The `claude plugin install …` lines, one per plugin the manifest declares. */
157
+ export function pluginInstallCommands() {
158
+ return marketplaceAndPlugins().map(
159
+ ({ marketplace, plugin }) =>
160
+ `claude plugin install ${plugin}@${marketplace}`,
161
+ );
162
+ }
163
+
164
+ /** Back-compat single-line form: the first plugin's install command. */
165
+ export function pluginInstallCommand() {
166
+ return pluginInstallCommands()[0];
167
+ }
168
+
169
+ function realClaude(commandArguments) {
170
+ return execFileSync(CLAUDE_BIN, commandArguments, {
171
+ encoding: 'utf8',
172
+ stdio: 'pipe',
173
+ });
174
+ }
175
+
176
+ function reasonFor(error) {
177
+ return error?.code === 'ENOENT'
178
+ ? 'the `claude` command was not found on PATH'
179
+ : (error?.stderr || error?.message || String(error)).trim().split('\n')[0];
180
+ }
181
+
182
+ /**
183
+ * Best-effort removal of a previously installed version of each manifest plugin, at the
184
+ * given scope, before the fresh install runs. `claude plugin uninstall <plugin> --scope
185
+ * <scope>` is verified (via `claude plugin uninstall --help`) to take the bare plugin name
186
+ * (no `@marketplace`, unlike install) and the same `--scope` values as install. When there
187
+ * is no previous install, uninstall fails — that is expected and non-fatal, so failures here
188
+ * are swallowed and never stop the install that follows.
189
+ */
190
+ function removePreviousInstalls(targets, scope, runClaude) {
191
+ for (const { plugin } of targets) {
192
+ try {
193
+ runClaude(['plugin', 'uninstall', plugin, '--scope', scope, '--yes']);
194
+ } catch {
195
+ // No previous install (or removal failed for some other reason) — non-fatal either way;
196
+ // the install below is what actually matters.
197
+ }
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Registers the marketplace (idempotent: a second add just reports it already exists, which
203
+ * is not fatal) once, then installs EVERY plugin the manifest declares at the scope matching
204
+ * the config choice. A project that adopts claude-gates gets all of its plugins (gates,
205
+ * tasks, …), not just the first — a single failed install does not stop the rest from being
206
+ * attempted, so one broken plugin never silently blocks another that would have worked.
207
+ *
208
+ * Returns { installed, scope, results } where `installed` is true only if every plugin
209
+ * installed; `results` is a per-plugin { plugin, installed, reason? } list so a caller can
210
+ * report exactly which ones need the manual command.
211
+ *
212
+ * `runClaude` is an injectable seam (defaults to the real `claude` binary) so tests can
213
+ * exercise the multi-plugin partial-failure logic without actually invoking the CLI and
214
+ * installing plugins on the machine running the test.
215
+ *
216
+ * `removePrevious` (default true) uninstalls each plugin's previous version at this scope
217
+ * before installing, so a stale version never lingers alongside the new one. It is best-effort
218
+ * and never fails the overall install.
219
+ */
220
+ export function installPlugin(
221
+ configScope,
222
+ { cwd = process.cwd(), runClaude = realClaude, removePrevious = true } = {},
223
+ ) {
224
+ const scope = PLUGIN_SCOPE[configScope] ?? 'user';
225
+
226
+ // If a marketplace for our plugins is already registered but points at a STALE location (an
227
+ // older package version in a different npx-cache/global/clone path), re-point it here first
228
+ // — otherwise the add below is a no-op and Claude Code keeps serving the old version. Uses
229
+ // the manifest name to find the existing registration; best-effort, never fatal.
230
+ repointMarketplaceIfStale(runClaude, marketplaceManifest().name);
231
+
232
+ try {
233
+ runClaude([
234
+ 'plugin',
235
+ 'marketplace',
236
+ 'add',
237
+ REPOSITORY_ROOT,
238
+ '--scope',
239
+ 'user',
240
+ ]);
241
+ } catch {
242
+ // Already registered, or the marketplace add is a no-op — install can still proceed.
243
+ }
244
+
245
+ // Resolve the registered marketplace name AFTER the add, so a fresh registration is seen and
246
+ // a pre-existing one (under any name) is matched by its source path. Doing it here, not at
247
+ // module top, means the name reflects the live registration this run just ensured.
248
+ const targets = marketplaceAndPlugins(runClaude);
249
+
250
+ if (removePrevious) removePreviousInstalls(targets, scope, runClaude);
251
+
252
+ const results = targets.map(({ marketplace, plugin }) => {
253
+ try {
254
+ runClaude([
255
+ 'plugin',
256
+ 'install',
257
+ `${plugin}@${marketplace}`,
258
+ '--yes',
259
+ '--scope',
260
+ scope,
261
+ ]);
262
+ return { plugin, installed: true };
263
+ } catch (error) {
264
+ return { plugin, installed: false, reason: reasonFor(error) };
265
+ }
266
+ });
267
+
268
+ const installed = results.every((result) => result.installed);
269
+ if (installed) return { installed: true, scope, results };
270
+
271
+ const failed = results.filter((result) => !result.installed);
272
+ return {
273
+ installed: false,
274
+ scope,
275
+ results,
276
+ reason: failed
277
+ .map((result) => `${result.plugin}: ${result.reason}`)
278
+ .join('; '),
279
+ cwd,
280
+ };
281
+ }