@entelligentsia/forgecli 0.11.2 → 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 (89) hide show
  1. package/CHANGELOG.md +324 -0
  2. package/README.md +2 -1
  3. package/dist/CHANGELOG-forge-plugin.md +210 -0
  4. package/dist/bin/forge.js +20 -1
  5. package/dist/bin/forge.js.map +1 -1
  6. package/dist/extensions/forgecli/ask-user-tool.js +32 -20
  7. package/dist/extensions/forgecli/ask-user-tool.js.map +1 -1
  8. package/dist/extensions/forgecli/config-layer.d.ts +15 -0
  9. package/dist/extensions/forgecli/config-layer.js +4 -1
  10. package/dist/extensions/forgecli/config-layer.js.map +1 -1
  11. package/dist/extensions/forgecli/config-writer.js +4 -1
  12. package/dist/extensions/forgecli/config-writer.js.map +1 -1
  13. package/dist/extensions/forgecli/enhance.js +1 -1
  14. package/dist/extensions/forgecli/enhance.js.map +1 -1
  15. package/dist/extensions/forgecli/fix-bug.js +31 -1
  16. package/dist/extensions/forgecli/fix-bug.js.map +1 -1
  17. package/dist/extensions/forgecli/forge-cli-schema.json +19 -0
  18. package/dist/extensions/forgecli/forge-tools.js +80 -0
  19. package/dist/extensions/forgecli/forge-tools.js.map +1 -1
  20. package/dist/extensions/forgecli/forge-update-command.js +24 -18
  21. package/dist/extensions/forgecli/forge-update-command.js.map +1 -1
  22. package/dist/extensions/forgecli/friction-emit.d.ts +97 -0
  23. package/dist/extensions/forgecli/friction-emit.js +246 -0
  24. package/dist/extensions/forgecli/friction-emit.js.map +1 -0
  25. package/dist/extensions/forgecli/health-check.d.ts +10 -0
  26. package/dist/extensions/forgecli/health-check.js +160 -8
  27. package/dist/extensions/forgecli/health-check.js.map +1 -1
  28. package/dist/extensions/forgecli/hook-dispatcher.js +24 -2
  29. package/dist/extensions/forgecli/hook-dispatcher.js.map +1 -1
  30. package/dist/extensions/forgecli/hooks/write-guard.js +5 -1
  31. package/dist/extensions/forgecli/hooks/write-guard.js.map +1 -1
  32. package/dist/extensions/forgecli/index.js +29 -5
  33. package/dist/extensions/forgecli/index.js.map +1 -1
  34. package/dist/extensions/forgecli/lib/store-error-remediation.d.ts +65 -0
  35. package/dist/extensions/forgecli/lib/store-error-remediation.js +298 -0
  36. package/dist/extensions/forgecli/lib/store-error-remediation.js.map +1 -0
  37. package/dist/extensions/forgecli/regenerate.d.ts +22 -0
  38. package/dist/extensions/forgecli/regenerate.js +133 -3
  39. package/dist/extensions/forgecli/regenerate.js.map +1 -1
  40. package/dist/extensions/forgecli/run-sprint.js +16 -1
  41. package/dist/extensions/forgecli/run-sprint.js.map +1 -1
  42. package/dist/extensions/forgecli/run-task.js +30 -8
  43. package/dist/extensions/forgecli/run-task.js.map +1 -1
  44. package/dist/extensions/forgecli/skill-curation-flag.d.ts +21 -0
  45. package/dist/extensions/forgecli/skill-curation-flag.js +71 -0
  46. package/dist/extensions/forgecli/skill-curation-flag.js.map +1 -0
  47. package/dist/extensions/forgecli/skill-curator-subagent.d.ts +101 -0
  48. package/dist/extensions/forgecli/skill-curator-subagent.js +342 -0
  49. package/dist/extensions/forgecli/skill-curator-subagent.js.map +1 -0
  50. package/dist/extensions/forgecli/skill-retriever.d.ts +84 -0
  51. package/dist/extensions/forgecli/skill-retriever.js +246 -0
  52. package/dist/extensions/forgecli/skill-retriever.js.map +1 -0
  53. package/dist/extensions/forgecli/skill-usage-tracker.d.ts +91 -0
  54. package/dist/extensions/forgecli/skill-usage-tracker.js +224 -0
  55. package/dist/extensions/forgecli/skill-usage-tracker.js.map +1 -0
  56. package/dist/extensions/forgecli/store-resolver.d.ts +18 -0
  57. package/dist/extensions/forgecli/store-resolver.js +44 -4
  58. package/dist/extensions/forgecli/store-resolver.js.map +1 -1
  59. package/dist/extensions/forgecli/store-validator.d.ts +3 -0
  60. package/dist/extensions/forgecli/store-validator.js +4 -2
  61. package/dist/extensions/forgecli/store-validator.js.map +1 -1
  62. package/dist/forge-payload/.base-pack/personas/supervisor.md +9 -0
  63. package/dist/forge-payload/.base-pack/workflows/enhance.md +344 -18
  64. package/dist/forge-payload/.claude-plugin/plugin.json +1 -1
  65. package/dist/forge-payload/.schemas/event.schema.json +20 -2
  66. package/dist/forge-payload/.schemas/migrations.json +112 -0
  67. package/dist/forge-payload/.schemas/proposal.schema.json +40 -0
  68. package/dist/forge-payload/agents/store-query-validator.md +103 -0
  69. package/dist/forge-payload/agents/tomoshibi.md +185 -0
  70. package/dist/forge-payload/commands/regenerate.md +109 -20
  71. package/dist/forge-payload/hooks/check-update.js +378 -0
  72. package/dist/forge-payload/hooks/forge-permissions.js +158 -0
  73. package/dist/forge-payload/hooks/triage-error.js +71 -0
  74. package/dist/forge-payload/hooks/validate-write.js +236 -0
  75. package/dist/forge-payload/integrity.json +32 -0
  76. package/dist/forge-payload/meta/workflows/meta-enhance.md +344 -18
  77. package/dist/forge-payload/schemas/structure-manifest.json +511 -0
  78. package/dist/forge-payload/tools/build-persona-pack.cjs +120 -11
  79. package/dist/forge-payload/tools/compression-gate.cjs +192 -0
  80. package/dist/forge-payload/tools/delete-candidate-detector.cjs +114 -0
  81. package/dist/forge-payload/tools/judge-proposal.cjs +177 -0
  82. package/dist/forge-payload/tools/manage-versions.cjs +132 -4
  83. package/dist/forge-payload/tools/queue-drain.cjs +152 -0
  84. package/dist/forge-payload/tools/replay-scoring.cjs +117 -0
  85. package/node_modules/@mariozechner/clipboard/package.json +2 -1
  86. package/node_modules/@mariozechner/clipboard-linux-x64-musl/README.md +3 -0
  87. package/node_modules/@mariozechner/clipboard-linux-x64-musl/clipboard.linux-x64-musl.node +0 -0
  88. package/node_modules/@mariozechner/clipboard-linux-x64-musl/package.json +25 -0
  89. package/package.json +4 -2
@@ -160,7 +160,18 @@ If `CONFIG_MODE == "fast"`: apply the single-file materialized check
160
160
  not materialized, emit the stub-or-missing message and exit 0. Otherwise
161
161
  proceed.
162
162
 
163
- Before writing, remove any existing manifest entry for this specific file (handles rename case):
163
+ Before writing, check the file for manual modifications (mirrors the workflows
164
+ and templates pre-write guard — FORGE-BUG-037 / forge#106):
165
+ ```sh
166
+ node "$FORGE_ROOT/tools/generation-manifest.cjs" check .forge/personas/<sub-target>.md
167
+ ```
168
+ For exit 1 (modified): warn `△ .forge/personas/<sub-target>.md has been manually
169
+ modified (likely by /forge:enhance). Overwriting will discard your changes.
170
+ Proceed? (yes / no / show diff)`. Collect the answer before proceeding. On
171
+ `no` or `show diff` rejecting overwrite, skip this file and exit cleanly.
172
+ On exit 2 (untracked) or exit 3 (missing): proceed without prompting.
173
+
174
+ Then remove any existing manifest entry for this specific file (handles rename case):
164
175
  ```sh
165
176
  node "$FORGE_ROOT/tools/generation-manifest.cjs" remove .forge/personas/<sub-target>.md 2>/dev/null || true
166
177
  ```
@@ -198,17 +209,46 @@ steps below apply to that single file.
198
209
  node "$FORGE_ROOT/tools/banners.cjs" --badge bloom
199
210
  ```
200
211
  Then emit: `Generating personas (<N> files in parallel)...` — use `N_materialized` in fast mode, `M_total` in full mode.
201
- 4. **Full mode only**: clear stale entries (skip in fast mode see step 2):
212
+ 4. Check each (enumerated, fast-mode-filtered) file for manual modifications
213
+ before any clearing or regeneration (mirrors the workflows + templates
214
+ pre-write guard — FORGE-BUG-037 / forge#106):
215
+ ```sh
216
+ node "$FORGE_ROOT/tools/generation-manifest.cjs" check .forge/personas/<role>.md
217
+ ```
218
+ For any exit 1 (modified): warn `△ .forge/personas/<role>.md has been manually
219
+ modified (likely by /forge:enhance). Overwriting will discard your changes.
220
+ Proceed? (yes / no / show diff)`. Collect answers before proceeding. Files
221
+ the user declines are removed from the regeneration set for this run. Exit
222
+ 2 (untracked) and exit 3 (missing) require no prompt.
223
+ 5. **Full mode only**: clear stale entries (skip in fast mode — see step 2):
202
224
  ```sh
203
225
  node "$FORGE_ROOT/tools/generation-manifest.cjs" clear-namespace .forge/personas/
204
226
  ```
205
- 5. **Spawn the persona subagents in a SINGLE Agent tool message** using
227
+ 6. **Spawn the persona subagents in a SINGLE Agent tool message** using
206
228
  `$FORGE_ROOT/init/generation/generate-persona.md` as the per-subagent rulebook
207
229
  (same fan-out pattern as `/forge:init` Phase 4). Spawn one per filtered
208
230
  entry — every entry in fast mode, every meta source in full mode.
209
- 6. Collect results. For each `done:` result → emit ` 〇 <filename>.md`.
231
+ 7. Collect results. For each `done:` result → emit ` 〇 <filename>.md`.
210
232
  Retry failures once. Any still failing: surface the id list.
211
- 7. Emit ` 〇 personas — <N> files written` (fast mode appends ` (M-N deferred)` when `N < M`).
233
+ 8. **Replay user enhancements** (forge#107 / Approach A layer 3 of the composition
234
+ contract declared at `manage-versions.cjs:13`). After fresh base-pack content
235
+ is written, restore any user-enhanced files captured by `/forge:enhance` Phase 2
236
+ snapshots:
237
+ ```sh
238
+ node "$FORGE_ROOT/tools/manage-versions.cjs" replay --target personas
239
+ ```
240
+ The tool walks all snapshots in `.forge/structure-versions.json`, finds enhanced
241
+ elements whose normalized path starts with `personas/`, and copies them from
242
+ the archive back over the freshly-generated content. Later snapshots win on
243
+ file collision. Files not captured by any snapshot remain at the fresh
244
+ base-pack version.
245
+ 9. Re-record manifest hashes for the (now restored) files so subsequent
246
+ `generation-manifest check` calls reflect current on-disk content:
247
+ ```sh
248
+ for each <role> in the filtered set:
249
+ node "$FORGE_ROOT/tools/generation-manifest.cjs" record .forge/personas/<role>.md
250
+ ```
251
+ 10. Emit ` 〇 personas — <N> files written` (fast mode appends ` (M-N deferred)` when `N < M`).
212
252
 
213
253
  ---
214
254
 
@@ -226,7 +266,19 @@ If `CONFIG_MODE == "fast"`: apply the single-file materialized check
226
266
  If not materialized, emit the stub-or-missing message and exit 0. Otherwise
227
267
  proceed.
228
268
 
229
- Before writing, remove any existing manifest entry for this specific file:
269
+ Before writing, check the file for manual modifications (mirrors the workflows
270
+ and templates pre-write guard — FORGE-BUG-037 / forge#106):
271
+ ```sh
272
+ node "$FORGE_ROOT/tools/generation-manifest.cjs" check .forge/skills/<sub-target>-skills.md
273
+ ```
274
+ For exit 1 (modified): warn `△ .forge/skills/<sub-target>-skills.md has been
275
+ manually modified (likely by /forge:enhance). Overwriting will discard your
276
+ changes. Proceed? (yes / no / show diff)`. Collect the answer before
277
+ proceeding. On `no` or `show diff` rejecting overwrite, skip this file and
278
+ exit cleanly. On exit 2 (untracked) or exit 3 (missing): proceed without
279
+ prompting.
280
+
281
+ Then remove any existing manifest entry for this specific file:
230
282
  ```sh
231
283
  node "$FORGE_ROOT/tools/generation-manifest.cjs" remove .forge/skills/<sub-target>-skills.md 2>/dev/null || true
232
284
  ```
@@ -251,15 +303,38 @@ apply to that single file.
251
303
  node "$FORGE_ROOT/tools/banners.cjs" --badge tide
252
304
  ```
253
305
  Then emit: `Generating skills (<N> files in parallel)...`
254
- 4. **Full mode only**: clear stale entries (skip in fast mode):
306
+ 4. Check each (enumerated, fast-mode-filtered) file for manual modifications
307
+ before any clearing or regeneration (mirrors the workflows + templates
308
+ pre-write guard — FORGE-BUG-037 / forge#106):
309
+ ```sh
310
+ node "$FORGE_ROOT/tools/generation-manifest.cjs" check .forge/skills/<role>-skills.md
311
+ ```
312
+ For any exit 1 (modified): warn `△ .forge/skills/<role>-skills.md has been
313
+ manually modified (likely by /forge:enhance). Overwriting will discard your
314
+ changes. Proceed? (yes / no / show diff)`. Collect answers before proceeding.
315
+ Files the user declines are removed from the regeneration set for this run.
316
+ Exit 2 (untracked) and exit 3 (missing) require no prompt.
317
+ 5. **Full mode only**: clear stale entries (skip in fast mode):
255
318
  ```sh
256
319
  node "$FORGE_ROOT/tools/generation-manifest.cjs" clear-namespace .forge/skills/
257
320
  ```
258
- 5. **Spawn the skill subagents in a SINGLE Agent tool message** using
321
+ 6. **Spawn the skill subagents in a SINGLE Agent tool message** using
259
322
  `$FORGE_ROOT/init/generation/generate-skill.md` as the per-subagent rulebook.
260
- 6. Collect results. Retry failures once. Any still failing: surface the id list.
261
- 7. For each completed file, check manifest (warn on modified), emit ` 〇 <filename>.md`.
262
- Fast mode appends `〇 skills — <N> files written (M-N deferred)` when `N < M`.
323
+ 7. Collect results. Retry failures once. Any still failing: surface the id list.
324
+ 8. **Replay user enhancements** (forge#107 / Approach A):
325
+ ```sh
326
+ node "$FORGE_ROOT/tools/manage-versions.cjs" replay --target skills
327
+ ```
328
+ Walks snapshots; restores any enhanced `skills/<role>-skills.md` files from
329
+ the archive over the freshly-generated content. Later snapshots win on
330
+ collision.
331
+ 9. Re-record manifest hashes for the (now restored) files:
332
+ ```sh
333
+ for each <role> in the filtered set:
334
+ node "$FORGE_ROOT/tools/generation-manifest.cjs" record .forge/skills/<role>-skills.md
335
+ ```
336
+ 10. For each completed file, check manifest (warn on modified), emit ` 〇 <filename>.md`.
337
+ Fast mode appends `〇 skills — <N> files written (M-N deferred)` when `N < M`.
263
338
 
264
339
  ---
265
340
 
@@ -341,7 +416,7 @@ write, record hash.
341
416
  node "$FORGE_ROOT/tools/generation-manifest.cjs" check .forge/workflows/{filename}.md
342
417
  ```
343
418
  For any exit 1 (modified): warn `△ .forge/workflows/{filename}.md has been manually
344
- modified. Overwriting will discard your changes. Proceed? (yes / no / show diff)`
419
+ modified. Overwriting may discard manual edits not captured in any /forge:enhance snapshot. Edits captured by snapshots will be restored automatically via `manage-versions replay` after regeneration. Proceed? (yes / no / show diff)`
345
420
  Collect answers before proceeding.
346
421
  5. **Full mode only**: clear stale entries (skip in fast mode — clearing
347
422
  would drop manifest entries for stubs we are intentionally leaving alone):
@@ -367,8 +442,15 @@ write, record hash.
367
442
  Input: $FORGE_ROOT/meta/workflows/meta-orchestrate.md + .forge/workflows/
368
443
  Output: .forge/workflows/orchestrate_task.md and .forge/workflows/run_sprint.md
369
444
  ```
370
- 9. For each written file: record hash `node "$FORGE_ROOT/tools/generation-manifest.cjs" record .forge/workflows/{filename}.md`
371
- 10. Emit ` 〇 workflows — <N> files written` (full mode: 18; fast mode:
445
+ 9. **Replay user enhancements** (forge#107 / Approach A):
446
+ ```sh
447
+ node "$FORGE_ROOT/tools/manage-versions.cjs" replay --target workflows
448
+ ```
449
+ Walks snapshots; restores enhanced `workflows/<name>.md` files. Later
450
+ snapshots win on collision.
451
+ 10. For each written file: record hash `node "$FORGE_ROOT/tools/generation-manifest.cjs" record .forge/workflows/{filename}.md`
452
+ (this runs AFTER replay so the recorded hash reflects the restored content).
453
+ 11. Emit ` 〇 workflows — <N> files written` (full mode: 18; fast mode:
372
454
  `〇 workflows — N of M files regenerated (others remain as stubs)`).
373
455
 
374
456
  **Do NOT touch:** `.claude/commands/`, `.forge/config.json`, or any knowledge base file.
@@ -464,14 +546,21 @@ Generate the single file (no fan-out needed). Record hash after writing.
464
546
  6. **Spawn the template subagents in a SINGLE Agent tool message** using
465
547
  `$FORGE_ROOT/init/generation/generate-template.md` as the per-subagent rulebook.
466
548
  7. Collect results. Retry failures once. Any still failing: surface the id list.
467
- 8. For each written file: record hash, emit ` 〇 <filename>.md`.
468
- 9. Re-record the one-shot init artifact not regenerated from a meta file:
549
+ 8. **Replay user enhancements** (forge#107 / Approach A):
469
550
  ```sh
470
- if [ -f ".forge/templates/CUSTOM_COMMAND_TEMPLATE.md" ]; then
471
- node "$FORGE_ROOT/tools/generation-manifest.cjs" record .forge/templates/CUSTOM_COMMAND_TEMPLATE.md
472
- fi
551
+ node "$FORGE_ROOT/tools/manage-versions.cjs" replay --target templates
473
552
  ```
474
- Fast-mode footer: emit `〇 templates — <N> files written (M-N deferred)` when `N < M`.
553
+ Walks snapshots; restores enhanced `templates/<STEM>.md` files. Later
554
+ snapshots win on collision.
555
+ 9. For each written file: record hash, emit ` 〇 <filename>.md` (hash reflects
556
+ post-replay content).
557
+ 10. Re-record the one-shot init artifact not regenerated from a meta file:
558
+ ```sh
559
+ if [ -f ".forge/templates/CUSTOM_COMMAND_TEMPLATE.md" ]; then
560
+ node "$FORGE_ROOT/tools/generation-manifest.cjs" record .forge/templates/CUSTOM_COMMAND_TEMPLATE.md
561
+ fi
562
+ ```
563
+ Fast-mode footer: emit `〇 templates — <N> files written (M-N deferred)` when `N < M`.
475
564
 
476
565
  ---
477
566
 
@@ -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 };