@entelligentsia/forgecli 0.11.3 → 0.15.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 (62) hide show
  1. package/CHANGELOG.md +314 -0
  2. package/README.md +2 -1
  3. package/dist/CHANGELOG-forge-plugin.md +183 -0
  4. package/dist/bin/forge.js +20 -1
  5. package/dist/bin/forge.js.map +1 -1
  6. package/dist/extensions/forgecli/config-layer.d.ts +15 -0
  7. package/dist/extensions/forgecli/config-layer.js.map +1 -1
  8. package/dist/extensions/forgecli/enhance.js +1 -1
  9. package/dist/extensions/forgecli/enhance.js.map +1 -1
  10. package/dist/extensions/forgecli/forge-cli-schema.json +19 -0
  11. package/dist/extensions/forgecli/forge-tools.js +80 -0
  12. package/dist/extensions/forgecli/forge-tools.js.map +1 -1
  13. package/dist/extensions/forgecli/forge-update-command.js +24 -18
  14. package/dist/extensions/forgecli/forge-update-command.js.map +1 -1
  15. package/dist/extensions/forgecli/friction-emit.d.ts +97 -0
  16. package/dist/extensions/forgecli/friction-emit.js +246 -0
  17. package/dist/extensions/forgecli/friction-emit.js.map +1 -0
  18. package/dist/extensions/forgecli/hook-dispatcher.js +20 -0
  19. package/dist/extensions/forgecli/hook-dispatcher.js.map +1 -1
  20. package/dist/extensions/forgecli/index.js +29 -5
  21. package/dist/extensions/forgecli/index.js.map +1 -1
  22. package/dist/extensions/forgecli/regenerate.d.ts +22 -0
  23. package/dist/extensions/forgecli/regenerate.js +133 -3
  24. package/dist/extensions/forgecli/regenerate.js.map +1 -1
  25. package/dist/extensions/forgecli/skill-curation-flag.d.ts +21 -0
  26. package/dist/extensions/forgecli/skill-curation-flag.js +71 -0
  27. package/dist/extensions/forgecli/skill-curation-flag.js.map +1 -0
  28. package/dist/extensions/forgecli/skill-curator-subagent.d.ts +101 -0
  29. package/dist/extensions/forgecli/skill-curator-subagent.js +342 -0
  30. package/dist/extensions/forgecli/skill-curator-subagent.js.map +1 -0
  31. package/dist/extensions/forgecli/skill-retriever.d.ts +84 -0
  32. package/dist/extensions/forgecli/skill-retriever.js +246 -0
  33. package/dist/extensions/forgecli/skill-retriever.js.map +1 -0
  34. package/dist/extensions/forgecli/skill-usage-tracker.d.ts +91 -0
  35. package/dist/extensions/forgecli/skill-usage-tracker.js +224 -0
  36. package/dist/extensions/forgecli/skill-usage-tracker.js.map +1 -0
  37. package/dist/forge-payload/.base-pack/workflows/enhance.md +331 -11
  38. package/dist/forge-payload/.claude-plugin/plugin.json +1 -1
  39. package/dist/forge-payload/.schemas/event.schema.json +20 -2
  40. package/dist/forge-payload/.schemas/migrations.json +96 -0
  41. package/dist/forge-payload/.schemas/proposal.schema.json +40 -0
  42. package/dist/forge-payload/agents/store-query-validator.md +103 -0
  43. package/dist/forge-payload/agents/tomoshibi.md +185 -0
  44. package/dist/forge-payload/commands/regenerate.md +109 -20
  45. package/dist/forge-payload/hooks/check-update.js +378 -0
  46. package/dist/forge-payload/hooks/forge-permissions.js +158 -0
  47. package/dist/forge-payload/hooks/triage-error.js +71 -0
  48. package/dist/forge-payload/hooks/validate-write.js +236 -0
  49. package/dist/forge-payload/integrity.json +32 -0
  50. package/dist/forge-payload/meta/workflows/meta-enhance.md +331 -11
  51. package/dist/forge-payload/schemas/structure-manifest.json +511 -0
  52. package/dist/forge-payload/tools/compression-gate.cjs +192 -0
  53. package/dist/forge-payload/tools/delete-candidate-detector.cjs +114 -0
  54. package/dist/forge-payload/tools/judge-proposal.cjs +177 -0
  55. package/dist/forge-payload/tools/manage-versions.cjs +132 -4
  56. package/dist/forge-payload/tools/queue-drain.cjs +152 -0
  57. package/dist/forge-payload/tools/replay-scoring.cjs +117 -0
  58. package/node_modules/@mariozechner/clipboard/package.json +2 -1
  59. package/node_modules/@mariozechner/clipboard-linux-x64-musl/README.md +3 -0
  60. package/node_modules/@mariozechner/clipboard-linux-x64-musl/clipboard.linux-x64-musl.node +0 -0
  61. package/node_modules/@mariozechner/clipboard-linux-x64-musl/package.json +25 -0
  62. package/package.json +4 -2
@@ -0,0 +1,378 @@
1
+ #!/usr/bin/env node
2
+ // Forge session-start hook — runs on SessionStart
3
+ // 1. Injects Forge-awareness context if this project has a .forge/ directory.
4
+ // 2. Checks once per day whether a newer version is available.
5
+ // 3. Detects distribution switches (forge@forge ↔ forge@skillforge) and
6
+ // refreshes paths.forgeRoot in .forge/config.json so subagents always
7
+ // reference the correct installed plugin path.
8
+ //
9
+ // Uses only Node.js built-ins — no npm dependencies required.
10
+ // Works on Linux, macOS, and Windows wherever Claude Code runs.
11
+
12
+ 'use strict';
13
+
14
+ // This hook must never exit non-zero — a hook failure surfaces as noise to the
15
+ // user and blocks session start context. Any uncaught exception exits 0.
16
+ process.on('uncaughtException', () => process.exit(0));
17
+
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+ const os = require('os');
21
+ const https = require('https');
22
+
23
+ const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT || '.';
24
+ const dataDir = process.env.CLAUDE_PLUGIN_DATA || path.join(os.tmpdir(), 'forge-plugin-data');
25
+ // Plugin-level cache: throttle only (lastCheck, remoteVersion) — shared across all projects.
26
+ const pluginCacheFile = path.join(dataDir, 'update-check-cache.json');
27
+ // Project-level cache: migration state (migratedFrom, localVersion, distribution, forgeRoot) — per project.
28
+ const forgeDir = '.forge';
29
+ const hasForge = fs.existsSync(forgeDir) && fs.existsSync(path.join(forgeDir, 'config.json'));
30
+ const projectCacheFile = path.join(forgeDir, 'update-check-cache.json');
31
+
32
+ // Distribution detection — derived from plugin path at runtime.
33
+ // The cache path encodes the marketplace name, making this more reliable than
34
+ // reading fields from plugin.json (which may be stale after a switch).
35
+ function detectDistribution(root) {
36
+ return root.includes('/cache/skillforge/forge/') || root.includes('/marketplaces/skillforge/forge/')
37
+ ? 'forge@skillforge' : 'forge@forge';
38
+ }
39
+ const currentDistribution = detectDistribution(pluginRoot);
40
+
41
+ // --- Multi-plugin scanning ---
42
+ // Scans all known plugin locations to detect multiple Forge installations.
43
+ // Returns array of installation records with version, distribution, scope, enabled status.
44
+ // Optional parameters for dependency injection (testing).
45
+ function scanPluginInstallations(options) {
46
+ const installations = [];
47
+ const homeDir = (options && options.homeDir) || os.homedir();
48
+ const cwd = (options && options.cwd) || process.cwd();
49
+
50
+ // Candidate paths — user scope (global) and project scope (local)
51
+ // Also scan skillforge subdirectory variant (skillforge/forge/forge)
52
+ const basePaths = [
53
+ path.join(homeDir, '.claude', 'plugins'),
54
+ path.join(cwd, '.claude', 'plugins'),
55
+ ];
56
+ const variants = ['cache', 'marketplaces'];
57
+ const pluginNames = ['forge/forge', 'skillforge/forge/forge'];
58
+
59
+ const candidates = [];
60
+ for (const basePath of basePaths) {
61
+ for (const variant of variants) {
62
+ for (const pluginName of pluginNames) {
63
+ candidates.push(path.join(basePath, variant, pluginName));
64
+ }
65
+ }
66
+ }
67
+
68
+ for (const candidate of candidates) {
69
+ try {
70
+ const pluginJsonPath = path.join(candidate, '.claude-plugin', 'plugin.json');
71
+ if (!fs.existsSync(pluginJsonPath)) continue;
72
+
73
+ const manifest = JSON.parse(fs.readFileSync(pluginJsonPath, 'utf8'));
74
+ // Determine scope: user-scope paths start with homeDir/.claude, project-scope start with cwd/.claude
75
+ // Use cwd-relative check first to avoid false positives when cwd is subdir of homeDir
76
+ const isProjectScope = candidate.startsWith(path.join(cwd, '.claude'));
77
+ const isUserScope = candidate.startsWith(path.join(homeDir, '.claude'));
78
+ const scope = isProjectScope ? 'project' : (isUserScope ? 'user' : 'unknown');
79
+ const enabled = isPluginEnabled(candidate, scope, homeDir, cwd);
80
+
81
+ // Avoid duplicates — skip if same path already recorded
82
+ if (installations.some(i => i.path === candidate)) continue;
83
+
84
+ installations.push({
85
+ path: candidate,
86
+ version: manifest.version || 'unknown',
87
+ distribution: detectDistribution(candidate),
88
+ scope: scope,
89
+ enabled: enabled,
90
+ });
91
+ } catch (e) {
92
+ // Non-fatal — skip broken installations silently
93
+ }
94
+ }
95
+
96
+ return installations;
97
+ }
98
+
99
+ // Check if forge plugin is enabled in settings files.
100
+ // Returns true if no explicit disable found, false if disabled.
101
+ function isPluginEnabled(pluginPath, scope, homeDir, cwd) {
102
+ try {
103
+ // Check user settings: ~/.claude/settings.json
104
+ const userSettingsPath = path.join(homeDir, '.claude', 'settings.json');
105
+ if (fs.existsSync(userSettingsPath)) {
106
+ const userSettings = JSON.parse(fs.readFileSync(userSettingsPath, 'utf8'));
107
+ if (userSettings.disablePlugin === true) return false;
108
+ // Check for per-plugin disable (if supported in future)
109
+ if (userSettings.plugins && userSettings.plugins.forge === false) return false;
110
+ }
111
+
112
+ // Check project settings: ./.claude/settings.local.json
113
+ const projectSettingsPath = path.join(cwd, '.claude', 'settings.local.json');
114
+ if (fs.existsSync(projectSettingsPath)) {
115
+ const projectSettings = JSON.parse(fs.readFileSync(projectSettingsPath, 'utf8'));
116
+ if (projectSettings.disablePlugin === true) return false;
117
+ if (projectSettings.plugins && projectSettings.plugins.forge === false) return false;
118
+ }
119
+
120
+ return true; // Default: enabled
121
+ } catch (e) {
122
+ return true; // Non-fatal — assume enabled if cannot read settings
123
+ }
124
+ }
125
+
126
+ // Determine the correct update-check URL for this distribution.
127
+ // Each distribution's plugin.json carries its own updateUrl pointing at the
128
+ // branch it was installed from (main for forge@forge, release for forge@skillforge),
129
+ // so we read it directly — no hardcoded per-distribution URLs needed.
130
+ const FALLBACK_UPDATE_URL = 'https://raw.githubusercontent.com/Entelligentsia/forge/main/forge/.claude-plugin/plugin.json';
131
+
132
+ const ALLOWED_DOMAINS = ['raw.githubusercontent.com'];
133
+
134
+ function validateUpdateUrl(url) {
135
+ try {
136
+ const parsed = new URL(url);
137
+ const hostname = parsed.hostname.toLowerCase();
138
+ if (!ALLOWED_DOMAINS.some(d => hostname === d || hostname.endsWith('.' + d))) {
139
+ process.stderr.write(`forge-update: rejected update URL with disallowed domain '${hostname}', falling back\n`);
140
+ return FALLBACK_UPDATE_URL;
141
+ }
142
+ return url;
143
+ } catch {
144
+ return FALLBACK_UPDATE_URL;
145
+ }
146
+ }
147
+
148
+ function getUpdateUrl() {
149
+ try {
150
+ const manifest = JSON.parse(fs.readFileSync(path.join(pluginRoot, '.claude-plugin', 'plugin.json'), 'utf8'));
151
+ return validateUpdateUrl(manifest.updateUrl || FALLBACK_UPDATE_URL);
152
+ } catch { return FALLBACK_UPDATE_URL; }
153
+ }
154
+ const remoteUrl = getUpdateUrl();
155
+ const checkInterval = 86400; // 24 hours in seconds
156
+
157
+ fs.mkdirSync(dataDir, { recursive: true });
158
+
159
+ // --- Forge-awareness context injection ---
160
+ let forgeContext = '';
161
+ if (fs.existsSync('.forge') && fs.existsSync(path.join('.forge', 'config.json'))) {
162
+ forgeContext =
163
+ 'This project uses Forge AI-SDLC. Engineering knowledge base: engineering/. ' +
164
+ 'Generated workflows: .forge/workflows/. Sprint and task store: .forge/store/. ' +
165
+ 'Use the project slash commands (/plan, /implement, /sprint-plan) to drive development. ' +
166
+ 'Run /forge:health to check knowledge base currency.';
167
+ }
168
+
169
+ // --- Update check helpers ---
170
+ function localVersion() {
171
+ try {
172
+ const p = path.join(pluginRoot, '.claude-plugin', 'plugin.json');
173
+ return JSON.parse(fs.readFileSync(p, 'utf8')).version || '0.0.0';
174
+ } catch {
175
+ return '0.0.0';
176
+ }
177
+ }
178
+
179
+ function fetchRemoteVersion(cb) {
180
+ https.get(remoteUrl, { timeout: 5000 }, (res) => {
181
+ let body = '';
182
+ res.on('data', chunk => { body += chunk; });
183
+ res.on('end', () => {
184
+ try { cb(JSON.parse(body).version || ''); } catch { cb(''); }
185
+ });
186
+ }).on('error', () => cb(''))
187
+ .on('timeout', function() { this.destroy(); cb(''); });
188
+ }
189
+
190
+ function buildUpdateMsg(remoteVersion, local) {
191
+ return remoteVersion && remoteVersion !== local
192
+ ? `Forge ${remoteVersion} available (you have ${local}). Run /forge:update to review changes and update.`
193
+ : '';
194
+ }
195
+
196
+ function emit(forgeCtx, updateMsg) {
197
+ if (!forgeCtx && !updateMsg) return;
198
+ const combined = [forgeCtx, updateMsg].filter(Boolean).join(' ');
199
+ const escaped = combined.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, ' ');
200
+ process.stdout.write(`{"additionalContext":"${escaped}"}\n`);
201
+ }
202
+
203
+ // --- Main logic (only runs when executed as script, not when required as module) ---
204
+ if (require.main === module) {
205
+ const local = localVersion();
206
+ const now = Math.floor(Date.now() / 1000);
207
+
208
+ // Scan for all plugin installations — builds inventory for multi-plugin awareness.
209
+ const allInstallations = scanPluginInstallations();
210
+
211
+ // Plugin-level cache: throttle (lastCheck, remoteVersion) — shared, not migration state.
212
+ let pluginCache = null;
213
+ if (fs.existsSync(pluginCacheFile)) {
214
+ try { pluginCache = JSON.parse(fs.readFileSync(pluginCacheFile, 'utf8')); } catch { pluginCache = null; }
215
+ }
216
+
217
+ // Project-level cache: migration state — per project.
218
+ let projectCache = null;
219
+ if (hasForge && fs.existsSync(projectCacheFile)) {
220
+ try { projectCache = JSON.parse(fs.readFileSync(projectCacheFile, 'utf8')); } catch { projectCache = null; }
221
+ }
222
+
223
+ // FR-010: Backfill forgeRef from localVersion if missing in existing cache.
224
+ if (projectCache && !projectCache.forgeRef && projectCache.localVersion) {
225
+ projectCache.forgeRef = projectCache.localVersion;
226
+ }
227
+
228
+ // --- Distribution + forgeRoot/forgeRef sync (always runs before update-check logic) ---
229
+ // Refreshes paths.forgeRoot and paths.forgeRef in config.json and the distribution/
230
+ // forgeRoot/forgeRef fields in the project cache. Handles distribution switches
231
+ // transparently — the user gets a clear message and all path references are
232
+ // corrected before any command runs.
233
+ let distributionSwitchMsg = '';
234
+ if (hasForge && pluginRoot !== '.') {
235
+ // Keep paths.forgeRoot and paths.forgeRef in .forge/config.json in sync.
236
+ // Generated workflows read forgeRoot to invoke tools without needing CLAUDE_PLUGIN_ROOT.
237
+ // forgeRef is the version-based portable field (FR-010).
238
+ try {
239
+ const configPath = path.join(forgeDir, 'config.json');
240
+ const cfg = JSON.parse(fs.readFileSync(configPath, 'utf8'));
241
+ if (!cfg.paths) cfg.paths = {};
242
+ let configChanged = false;
243
+ if (cfg.paths.forgeRoot !== pluginRoot) {
244
+ cfg.paths.forgeRoot = pluginRoot;
245
+ configChanged = true;
246
+ }
247
+ // FR-010: Write forgeRef from the local plugin version.
248
+ if (!cfg.paths.forgeRef || cfg.paths.forgeRef !== local) {
249
+ cfg.paths.forgeRef = local;
250
+ configChanged = true;
251
+ }
252
+ if (configChanged) {
253
+ fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2) + '\n');
254
+ }
255
+ } catch { /* non-fatal */ }
256
+
257
+ if (projectCache) {
258
+ const storedRoot = projectCache.forgeRoot;
259
+ const storedDist = projectCache.distribution;
260
+ const switched = storedRoot && storedRoot !== pluginRoot;
261
+
262
+ // Build distribution switch message when the active plugin path changed and
263
+ // the distribution name is different (e.g. forge@skillforge → forge@forge).
264
+ if (switched && storedDist && storedDist !== currentDistribution) {
265
+ const versionNote = projectCache.localVersion && projectCache.localVersion !== local
266
+ ? ` Version: ${projectCache.localVersion} → ${local}.`
267
+ : '';
268
+ distributionSwitchMsg =
269
+ `Plugin distribution switched from ${storedDist} to ${currentDistribution}.${versionNote}` +
270
+ ` paths.forgeRoot updated. Run /forge:update to verify migration state.`;
271
+ }
272
+
273
+ // Sync distribution + forgeRoot + forgeRef into the project cache whenever they drift.
274
+ // FR-002: Preserve updateStatus, pendingReason, pendingMigrations if present.
275
+ if (storedRoot !== pluginRoot || storedDist !== currentDistribution) {
276
+ try {
277
+ const updated = { ...projectCache, distribution: currentDistribution, forgeRoot: pluginRoot, forgeRef: local };
278
+ fs.writeFileSync(projectCacheFile, JSON.stringify(updated, null, 2) + '\n');
279
+ projectCache = updated; // keep in-memory copy consistent
280
+ } catch { /* non-fatal */ }
281
+ }
282
+ }
283
+ }
284
+
285
+ // FR-002: Surface pending-state message to the user if update is incomplete.
286
+ let pendingStateMsg = '';
287
+ if (hasForge && projectCache && projectCache.updateStatus === 'pending') {
288
+ const pendingMigrations = Array.isArray(projectCache.pendingMigrations)
289
+ ? projectCache.pendingMigrations.join(', ')
290
+ : '(unknown)';
291
+ pendingStateMsg =
292
+ `Forge update is incomplete — pending migration(s): ${pendingMigrations}.` +
293
+ ` Run /forge:update to continue or /forge:migrate to complete.`;
294
+ }
295
+
296
+ const elapsed = pluginCache ? now - (pluginCache.lastCheck || 0) : Infinity;
297
+
298
+ // Build multi-plugin awareness message if multiple installations found.
299
+ let multiPluginMsg = '';
300
+ if (allInstallations.length >= 2) {
301
+ const activeInst = allInstallations.find(i => i.path === pluginRoot) || allInstallations[0];
302
+ const otherInsts = allInstallations.filter(i => i.path !== activeInst.path);
303
+ if (otherInsts.length > 0) {
304
+ const otherDesc = otherInsts.map(i => `${i.version} (${i.distribution}, ${i.scope})`).join(', ');
305
+ multiPluginMsg = `Also installed: ${otherDesc}. `;
306
+ }
307
+ }
308
+
309
+ if (elapsed < checkInterval) {
310
+ // Plugin cache still fresh — use stored remote version.
311
+ // Detect post-install: if the project's recorded localVersion differs from
312
+ // the running plugin version, the plugin was updated since last migration.
313
+ let postInstallMsg = '';
314
+ if (hasForge && projectCache && projectCache.localVersion && projectCache.localVersion !== local) {
315
+ // Record the pre-install version as baseline, update localVersion.
316
+ // FR-010: Include forgeRef. FR-002: updateStatus/pendingReason/pendingMigrations
317
+ // are preserved via spread.
318
+ const updated = {
319
+ ...projectCache,
320
+ migratedFrom: projectCache.localVersion,
321
+ localVersion: local,
322
+ distribution: currentDistribution,
323
+ forgeRoot: pluginRoot,
324
+ forgeRef: local,
325
+ };
326
+ try { fs.writeFileSync(projectCacheFile, JSON.stringify(updated, null, 2) + '\n'); } catch { /* non-fatal */ }
327
+ // Reset plugin cache lastCheck so we fetch a fresh remote version next session.
328
+ try { fs.writeFileSync(pluginCacheFile, JSON.stringify({ ...pluginCache, lastCheck: 0 }, null, 2) + '\n'); } catch { /* non-fatal */ }
329
+ // Suppress post-install message when a distribution switch message already covers the event.
330
+ if (!distributionSwitchMsg) {
331
+ postInstallMsg = `Forge was updated to ${local} (was ${projectCache.localVersion}). Run /forge:update to review changes and update.`;
332
+ }
333
+ }
334
+ const baseMsg = distributionSwitchMsg || postInstallMsg || buildUpdateMsg((pluginCache && pluginCache.remoteVersion) || '', local);
335
+ // FR-002: Append pending-state message if present.
336
+ const updateMsg = baseMsg ? multiPluginMsg + baseMsg : baseMsg;
337
+ const pendingMsg = pendingStateMsg ? ' ' + pendingStateMsg : '';
338
+ emit(forgeContext, (updateMsg + pendingMsg).trim());
339
+ } else {
340
+ // Plugin cache expired or missing — fetch fresh remote version.
341
+ fetchRemoteVersion((remoteVersion) => {
342
+ if (remoteVersion) {
343
+ // Update plugin-level throttle cache.
344
+ try { fs.writeFileSync(pluginCacheFile, JSON.stringify({ lastCheck: now, remoteVersion }, null, 2) + '\n'); } catch { /* non-fatal */ }
345
+ // Seed project-level cache on first run if not yet present.
346
+ // FR-010: Include forgeRef alongside forgeRoot.
347
+ if (hasForge && !projectCache) {
348
+ try {
349
+ fs.writeFileSync(projectCacheFile, JSON.stringify({
350
+ migratedFrom: local, localVersion: local,
351
+ distribution: currentDistribution, forgeRoot: pluginRoot,
352
+ forgeRef: local,
353
+ updateStatus: 'complete', pendingReason: null, pendingMigrations: [],
354
+ }, null, 2) + '\n');
355
+ } catch { /* non-fatal */ }
356
+ } else if (hasForge && projectCache && !projectCache.localVersion) {
357
+ // Backfill localVersion (and distribution/forgeRoot/forgeRef) if missing.
358
+ // FR-002: Preserve updateStatus/pendingReason/pendingMigrations via spread.
359
+ try {
360
+ fs.writeFileSync(projectCacheFile, JSON.stringify({
361
+ ...projectCache, localVersion: local,
362
+ distribution: currentDistribution, forgeRoot: pluginRoot,
363
+ forgeRef: local,
364
+ }, null, 2) + '\n');
365
+ } catch { /* non-fatal */ }
366
+ }
367
+ }
368
+ const baseMsg = distributionSwitchMsg || buildUpdateMsg(remoteVersion, local);
369
+ // FR-002: Append pending-state message if present.
370
+ const updateMsg = baseMsg ? multiPluginMsg + baseMsg : baseMsg;
371
+ const pendingMsg = pendingStateMsg ? ' ' + pendingStateMsg : '';
372
+ emit(forgeContext, (updateMsg + pendingMsg).trim());
373
+ });
374
+ }
375
+ }
376
+
377
+ // Export functions for testing
378
+ module.exports = { scanPluginInstallations, isPluginEnabled, detectDistribution, validateUpdateUrl };
@@ -0,0 +1,158 @@
1
+ #!/usr/bin/env node
2
+ // Forge permission auto-approver — runs on PermissionRequest events.
3
+ //
4
+ // Purpose: eliminate the permission prompt storm (BUG-014) by auto-approving
5
+ // known Forge tool patterns and persisting allow rules to localSettings.
6
+ //
7
+ // Protocol (Claude Code PermissionRequest hook):
8
+ // - stdin: JSON envelope { tool_name, tool_input, permission_suggestions }
9
+ // - stdout: { hookSpecificOutput: { hookEventName, decision: { behavior,
10
+ // updatedPermissions } } } to allow and persist rules
11
+ // - exit 0 with no output: let normal permission flow proceed
12
+ // - exit 2 with stderr: block the tool call
13
+ //
14
+ // Security model:
15
+ // - This hook can only ALLOW, never DENY
16
+ // - User deny rules always take precedence over hook allows
17
+ // - Rules persist to .claude/settings.local.json (gitignored, per-project)
18
+ // - Users can inspect/remove rules via /permissions
19
+
20
+ 'use strict';
21
+
22
+ process.on('uncaughtException', (err) => {
23
+ try { process.stderr.write(`forge-permissions: internal error (fail-open): ${err.message}\n`); } catch (_) {}
24
+ process.exit(0);
25
+ });
26
+
27
+ // ── Pattern registry ──────────────────────────────────────────────
28
+ // Each entry: { pattern: RegExp, rule: string }
29
+ // pattern matches against the tool input string (Bash command, file path, or URL)
30
+ // rule is the allow rule content to persist via updatedPermissions
31
+
32
+ const BASH_PATTERNS = [
33
+ // Node tool invocations — covers $FORGE_ROOT/tools/*.cjs and $CLAUDE_PLUGIN_ROOT
34
+ { pattern: /^node\s+.*\/tools\/[\w-]+\.(cjs|js)\b/, rule: 'node ~/.claude/plugins/cache/forge/forge/*/tools/*' },
35
+ // NOTE: node -e and node -p removed — arbitrary code execution must not be auto-approved.
36
+ // Forge workflows use node .../tools/*.cjs for tool invocations; inline node -e/p requires
37
+ // explicit user approval each time.
38
+ // Shell commands used by Forge workflows
39
+ { pattern: /^mkdir\s+-p\s+/, rule: 'mkdir -p .forge/*' },
40
+ { pattern: /^mkdir\s+-p\s+\S+/, rule: 'mkdir -p .forge/*' },
41
+ { pattern: /^cp\s+/, rule: 'cp */schemas/*.schema.json .forge/schemas/' },
42
+ { pattern: /^ls\s+/, rule: 'ls *' },
43
+ { pattern: /^cat\s+/, rule: 'cat .forge/*' },
44
+ { pattern: /^date\s+-u\s+/, rule: 'date -u *' },
45
+ { pattern: /^date\s+/, rule: 'date -u *' },
46
+ { pattern: /^jq\s+/, rule: 'jq *' },
47
+ { pattern: /^touch\s+/, rule: 'touch .forge/*' },
48
+ { pattern: /^uname\s+/, rule: 'uname *' },
49
+ { pattern: /^rm\s+\.forge/, rule: 'rm .forge/*' },
50
+ { pattern: /^rm\s+-rf\s+\.forge/, rule: 'rm -rf .forge/*' },
51
+ { pattern: /^rmdir\s+/, rule: 'rmdir .forge/*' },
52
+ { pattern: /^gh\s+auth\s+/, rule: 'gh auth status *' },
53
+ { pattern: /^gh\s+issue\s+/, rule: 'gh issue create *' },
54
+ // git read-only commands (already auto-approved by Claude Code, but belt-and-suspenders)
55
+ { pattern: /^git\s+status\b/, rule: 'git status *' },
56
+ { pattern: /^git\s+log\b/, rule: 'git log *' },
57
+ { pattern: /^git\s+diff\b/, rule: 'git diff *' },
58
+ { pattern: /^git\s+add\s+/, rule: 'git add *' },
59
+ { pattern: /^git\s+commit\s+-m\s+/, rule: 'git commit -m *' },
60
+ { pattern: /^git\s+push\b/, rule: 'git push *' },
61
+ { pattern: /^git\s+checkout\s+/, rule: 'git checkout *' },
62
+ { pattern: /^git\s+branch\s+/, rule: 'git branch *' },
63
+ { pattern: /^git\s+stash\b/, rule: 'git stash *' },
64
+ { pattern: /^git\s+worktree\s+/, rule: 'git worktree *' },
65
+ ];
66
+
67
+ const WRITE_PATTERNS = [
68
+ { pattern: /^\.forge\//, rule: '.forge/**' },
69
+ { pattern: /^\.claude\/commands\//, rule: '.claude/commands/**' },
70
+ { pattern: /^engineering\//, rule: 'engineering/**' },
71
+ { pattern: /^CLAUDE\.md$/i, rule: 'CLAUDE.md' },
72
+ { pattern: /^AGENTS\.md$/i, rule: 'AGENTS.md' },
73
+ { pattern: /^\.gitignore$/, rule: '.gitignore' },
74
+ ];
75
+
76
+ const EDIT_PATTERNS = [
77
+ { pattern: /^\.forge\//, rule: '.forge/**' },
78
+ { pattern: /^\.claude\/commands\//, rule: '.claude/commands/**' },
79
+ { pattern: /^engineering\//, rule: 'engineering/**' },
80
+ { pattern: /^CLAUDE\.md$/i, rule: 'CLAUDE.md' },
81
+ { pattern: /^AGENTS\.md$/i, rule: 'AGENTS.md' },
82
+ ];
83
+
84
+ const WEBFETCH_PATTERNS = [
85
+ { pattern: /^https:\/\/raw\.githubusercontent\.com\/Entelligentsia\/forge\//, rule: 'domain:raw.githubusercontent.com' },
86
+ ];
87
+
88
+ const PATTERN_MAP = {
89
+ Bash: BASH_PATTERNS,
90
+ Write: WRITE_PATTERNS,
91
+ Edit: EDIT_PATTERNS,
92
+ MultiEdit: EDIT_PATTERNS,
93
+ WebFetch: WEBFETCH_PATTERNS,
94
+ };
95
+
96
+ // ── Core logic ─────────────────────────────────────────────────────
97
+
98
+ function matchTool(toolName, toolInput) {
99
+ const patterns = PATTERN_MAP[toolName];
100
+ if (!patterns) return null;
101
+
102
+ const input = toolName === 'Bash' ? (toolInput.command || '')
103
+ : (toolName === 'Write' || toolName === 'Edit' || toolName === 'MultiEdit') ? (toolInput.file_path || '')
104
+ : toolName === 'WebFetch' ? (toolInput.url || '')
105
+ : '';
106
+
107
+ for (const { pattern, rule } of patterns) {
108
+ if (pattern.test(input)) return rule;
109
+ }
110
+ return null;
111
+ }
112
+
113
+ // ── Export for testing ─────────────────────────────────────────────
114
+ module.exports = { matchTool, BASH_PATTERNS, WRITE_PATTERNS, EDIT_PATTERNS, WEBFETCH_PATTERNS };
115
+
116
+ // ── Main (hook runner) ────────────────────────────────────────────
117
+ if (require.main === module) {
118
+ let input = '';
119
+ process.stdin.on('data', (d) => { input += d; });
120
+ process.stdin.on('end', () => {
121
+ let event;
122
+ try {
123
+ event = JSON.parse(input);
124
+ } catch (_) {
125
+ // Unparseable input — fail open, let normal permission flow handle it
126
+ process.exit(0);
127
+ }
128
+
129
+ const { tool_name, tool_input } = event;
130
+ if (!tool_name || !tool_input) {
131
+ process.exit(0);
132
+ }
133
+
134
+ const matchedRule = matchTool(tool_name, tool_input || {});
135
+ if (matchedRule) {
136
+ // Persist only the matched rule — never bulk-approve all rules at once.
137
+ const response = {
138
+ hookSpecificOutput: {
139
+ hookEventName: 'PermissionRequest',
140
+ decision: {
141
+ behavior: 'allow',
142
+ updatedPermissions: [{
143
+ type: 'addRules',
144
+ rules: [{ toolName: tool_name, ruleContent: matchedRule }],
145
+ behavior: 'allow',
146
+ destination: 'localSettings',
147
+ }],
148
+ },
149
+ },
150
+ };
151
+ process.stdout.write(JSON.stringify(response));
152
+ process.exit(0);
153
+ }
154
+
155
+ // Not a Forge pattern — exit 0 with no output to let normal permission flow proceed
156
+ process.exit(0);
157
+ });
158
+ } // end require.main === module
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env node
2
+ // Forge PostToolUse hook — error triage
3
+ //
4
+ // Fires after every Bash tool call. If the command is Forge-related and
5
+ // exits non-zero, injects an additionalContext prompt asking Claude to offer
6
+ // the user the option to file a bug via /forge:report-bug.
7
+ //
8
+ // Uses only Node.js built-ins — no npm dependencies required.
9
+
10
+ 'use strict';
11
+
12
+ const FORGE_PATTERNS = [
13
+ /manage-config/,
14
+ /\.forge\//,
15
+ /CLAUDE_PLUGIN_ROOT/,
16
+ /FORGE_ROOT/,
17
+ /MANAGE_CONFIG/,
18
+ /engineering\/tools\//,
19
+ /forge:init/,
20
+ /forge:health/,
21
+ /forge:regenerate/,
22
+ /forge:update/,
23
+ /forge:add-pipeline/,
24
+ ];
25
+
26
+ function isForgeRelated(command) {
27
+ return FORGE_PATTERNS.some(p => p.test(command));
28
+ }
29
+
30
+ // The hook must never exit non-zero — a hook failure would surface as noise to
31
+ // the user and defeat the purpose of silent triage. Wrap everything so that any
32
+ // unexpected error causes a clean no-op exit instead of a crash report.
33
+ process.on('uncaughtException', () => process.exit(0));
34
+
35
+ let raw = '';
36
+ process.stdin.setEncoding('utf8');
37
+ process.stdin.on('data', chunk => { raw += chunk; });
38
+ process.stdin.on('end', () => {
39
+ try {
40
+ const event = JSON.parse(raw);
41
+
42
+ if (event.tool_name !== 'Bash') return;
43
+
44
+ const command = event.tool_input?.command || '';
45
+ const exitCode = event.tool_response?.exitCode;
46
+
47
+ // Only act on non-zero exits for Forge-related commands.
48
+ if (!isForgeRelated(command)) return;
49
+ if (exitCode === 0 || exitCode === undefined) return;
50
+
51
+ const stderr = event.tool_response?.stderr || '';
52
+ const output = event.tool_response?.output || '';
53
+ const errorSnippet = (stderr || output).split('\n').slice(0, 3).join(' ').trim();
54
+
55
+ const context =
56
+ `FORGE_ERROR_TRIAGE: A Forge command just failed (exit ${exitCode}). ` +
57
+ (errorSnippet ? `First error line: "${errorSnippet}". ` : '') +
58
+ `Tell the user what went wrong, then ask: ` +
59
+ `"Would you like to file this as a Forge bug to help improve the tool? ` +
60
+ `Run /forge:report-bug and I will pre-fill the report from this conversation."`;
61
+
62
+ process.stdout.write(JSON.stringify({
63
+ hookSpecificOutput: {
64
+ hookEventName: 'PostToolUse',
65
+ additionalContext: context,
66
+ },
67
+ }) + '\n');
68
+ } catch {
69
+ // Swallow all errors — this hook must never become the problem.
70
+ }
71
+ });