@jualopezmo/codeforge 0.4.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 (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +368 -0
  3. package/VERSION +1 -0
  4. package/bin/codeforge.mjs +64 -0
  5. package/install.ps1 +317 -0
  6. package/install.sh +326 -0
  7. package/package.json +39 -0
  8. package/src/CLAUDE.md +88 -0
  9. package/src/CONTINUITY.template.md +16 -0
  10. package/src/PROJECT.template.md +27 -0
  11. package/src/configs/claude/settings.json +6 -0
  12. package/src/configs/codex/config.toml +17 -0
  13. package/src/configs/opencode.json +14 -0
  14. package/src/docs/CHANGELOG.md +9 -0
  15. package/src/docs/adr/.gitkeep +0 -0
  16. package/src/docs/e2e/reports/.gitkeep +0 -0
  17. package/src/docs/e2e/use-cases/.gitkeep +0 -0
  18. package/src/docs/extending.md +134 -0
  19. package/src/docs/plans/.gitkeep +0 -0
  20. package/src/docs/prds/.gitkeep +0 -0
  21. package/src/docs/research/.gitkeep +0 -0
  22. package/src/docs/solutions/.gitkeep +0 -0
  23. package/src/shared/rules/approach-comparison.md +29 -0
  24. package/src/shared/rules/continuity.md +32 -0
  25. package/src/shared/rules/docs-layout.md +23 -0
  26. package/src/shared/rules/memory.md +34 -0
  27. package/src/shared/rules/models.md +59 -0
  28. package/src/shared/rules/project-rules.md +31 -0
  29. package/src/shared/rules/research.md +30 -0
  30. package/src/shared/rules/severity.md +17 -0
  31. package/src/shared/rules/ship-gates.md +164 -0
  32. package/src/shared/rules/tdd.md +20 -0
  33. package/src/shared/rules/workflow.md +38 -0
  34. package/src/shared/scripts/check-gates.ps1 +197 -0
  35. package/src/shared/scripts/check-gates.sh +201 -0
  36. package/src/shared/scripts/claude-gate-hook.ps1 +35 -0
  37. package/src/shared/scripts/claude-gate-hook.sh +43 -0
  38. package/src/shared/state.template.md +33 -0
  39. package/src/skills/adr/SKILL.md +63 -0
  40. package/src/skills/checkpoint/SKILL.md +50 -0
  41. package/src/skills/council/SKILL.md +87 -0
  42. package/src/skills/finish-branch/SKILL.md +85 -0
  43. package/src/skills/fix-bug/SKILL.md +84 -0
  44. package/src/skills/index/SKILL.md +52 -0
  45. package/src/skills/new-feature/SKILL.md +84 -0
  46. package/src/skills/plan/SKILL.md +63 -0
  47. package/src/skills/prd/SKILL.md +62 -0
  48. package/src/skills/quick-fix/SKILL.md +60 -0
  49. package/src/skills/research/SKILL.md +61 -0
  50. package/src/skills/review/SKILL.md +66 -0
  51. package/src/skills/simplify/SKILL.md +59 -0
  52. package/src/skills/verify-e2e/SKILL.md +102 -0
  53. package/src/sync.ps1 +69 -0
  54. package/src/sync.sh +69 -0
package/install.ps1 ADDED
@@ -0,0 +1,317 @@
1
+ #!/usr/bin/env pwsh
2
+ #
3
+ # codeforge installer (Windows / PowerShell) — copy the workflow discipline into a target
4
+ # project. Mirror of install.sh.
5
+ #
6
+ # pwsh ./install.ps1 [target-dir] [-Upgrade] [-WithHooks] [-GitInit] [-NoIsolate]
7
+ #
8
+ # With no target-dir, installs into the current working directory.
9
+ #
10
+ # THIN INSTALL: the target receives only what the agent needs at RUNTIME. All framework
11
+ # machinery (the neutral source in ./src/, the generators sync.sh/ps1, the generation
12
+ # inputs in configs/, and the seed templates) stays in the codeforge repo — never copied
13
+ # into the target. To customize or upgrade, edit the codeforge source and re-run this
14
+ # installer against the target (-Upgrade, or a bare re-run from inside the project).
15
+ #
16
+ # This installer copies the runtime subset into the target, then runs `sync.ps1 -Out <target>`
17
+ # to GENERATE each engine's config + skills straight into the target. No symlinks. The
18
+ # generated engine artifacts are COMMITTED with the target so a fresh clone works with no
19
+ # codeforge dependency at runtime.
20
+ #
21
+ # LANDS IN THE TARGET (runtime only): CLAUDE.md, AGENTS.md, opencode.json, .claude/,
22
+ # .agents/, .codex/ (generated), shared/rules/*.md + shared/state.template.md + shared/scripts/* (managed),
23
+ # docs/ scaffolding + CHANGELOG, PROJECT.md + CONTINUITY.md (project-owned, seeded).
24
+ # STAYS IN codeforge (never copied): src/skills (neutral), configs/, sync.sh, sync.ps1,
25
+ # *.template.md, docs/extending.md.
26
+ #
27
+ param(
28
+ [Parameter(Mandatory = $false)][string]$Target,
29
+ [switch]$Upgrade,
30
+ [switch]$WithHooks,
31
+ [switch]$GitInit,
32
+ [switch]$NoIsolate
33
+ )
34
+ $ErrorActionPreference = 'Stop'
35
+
36
+ $Src = Split-Path -Parent $MyInvocation.MyCommand.Path
37
+ $Payload = Join-Path $Src 'src'
38
+ $forgeVersion = "unknown"
39
+ $versionFile = Join-Path $Src 'VERSION'
40
+ if (Test-Path -LiteralPath $versionFile -PathType Leaf) {
41
+ $v = (Get-Content -LiteralPath $versionFile -TotalCount 1)
42
+ if ($v) { $forgeVersion = $v.Trim() }
43
+ }
44
+ $Mode = if ($Upgrade) { 'upgrade' } else { 'install' }
45
+ if (-not $Target) { $Target = (Get-Location).Path }
46
+
47
+ if (-not (Test-Path -PathType Container $Target)) { Write-Error "target dir not found: $Target"; exit 2 }
48
+ $Target = (Resolve-Path $Target).Path
49
+ # Did a prior forge install own .claude/settings.local.json? (read before the manifest is rewritten)
50
+ $priorLocalManaged = $false
51
+ $mf = Join-Path $Target '.forge-manifest'
52
+ if ((Test-Path -LiteralPath $mf -PathType Leaf) -and (Select-String -LiteralPath $mf -Pattern '^localsettings:managed$' -Quiet)) {
53
+ $priorLocalManaged = $true
54
+ }
55
+ if (-not ((Test-Path (Join-Path $Payload 'CLAUDE.md')) -and (Test-Path (Join-Path $Payload 'skills')))) {
56
+ Write-Error "payload not found — run this from the codeforge repo"; exit 2
57
+ }
58
+ if ($Target -eq $Src) { Write-Error "refusing to install into codeforge itself"; exit 2 }
59
+ if ($Target -eq $Payload) { Write-Error "refusing to install into the codeforge payload dir (src/)"; exit 2 }
60
+
61
+ Write-Host "codeforge $forgeVersion -> installing into: $Target (mode: $Mode)"
62
+
63
+ # --- version drift advisory (informational only, never blocks) ---
64
+ $priorVersion = ""
65
+ $fvFile = Join-Path $Target '.forge-version'
66
+ if (Test-Path -LiteralPath $fvFile -PathType Leaf) {
67
+ $pv = (Get-Content -LiteralPath $fvFile -TotalCount 1)
68
+ if ($pv) { $priorVersion = $pv.Trim() }
69
+ }
70
+ if ($priorVersion -and $priorVersion -ne $forgeVersion -and $forgeVersion -ne 'unknown' -and $priorVersion -ne 'unknown') {
71
+ try { $isUpgrade = ([version]$priorVersion -lt [version]$forgeVersion) }
72
+ catch { $isUpgrade = ($priorVersion -lt $forgeVersion) }
73
+ if ($isUpgrade) {
74
+ Write-Host " ~ upgrading this target: codeforge $priorVersion -> $forgeVersion"
75
+ } else {
76
+ Write-Host " ! this target was installed by a NEWER codeforge ($priorVersion) than you're running ($forgeVersion)."
77
+ Write-Host " You may be downgrading it; teammates pinned to $priorVersion could see drift. (advisory only)"
78
+ }
79
+ }
80
+
81
+ function Has-ForgeMarker([string]$file) {
82
+ return (Test-Path $file) -and (Select-String -Quiet -SimpleMatch 'Workflow discipline for Claude Code' $file)
83
+ }
84
+
85
+ # --- self-healing: drop machinery this version no longer installs into the target ---
86
+ # (thin model — migrates a target from an older, bloated install.) Gated on a prior forge
87
+ # install (.forge-manifest present) so a FIRST install never touches an unrelated project's
88
+ # own configs/ or skills/ dirs.
89
+ if (Test-Path (Join-Path $Target '.forge-manifest')) {
90
+ # Detect a genuinely OLD (pre-thin) forge install by its machinery; only then migrate
91
+ # configs/skills, so a routine re-install never relocates an app's own top-level dirs.
92
+ $oldInstall = $false
93
+ foreach ($f in 'sync.sh', 'sync.ps1', 'state.template.md', 'PROJECT.template.md', 'CONTINUITY.template.md', 'docs/extending.md') {
94
+ if (Test-Path (Join-Path $Target $f)) { $oldInstall = $true }
95
+ }
96
+ foreach ($f in 'sync.sh', 'sync.ps1', 'state.template.md', 'PROJECT.template.md', 'CONTINUITY.template.md', 'docs/extending.md') {
97
+ $p = Join-Path $Target $f
98
+ if (Test-Path $p) { Remove-Item -Force $p; Write-Host " - removed obsolete framework file: $f" }
99
+ }
100
+ # configs/ and neutral skills/ may hold pre-forge user edits — back up rather than delete.
101
+ $tConfigs = Join-Path $Target 'configs'
102
+ if ($oldInstall -and (Test-Path -PathType Container $tConfigs)) {
103
+ $bak = Join-Path $Target 'configs.pre-forge.bak'
104
+ if (Test-Path $bak) { Remove-Item -Recurse -Force $bak }
105
+ Move-Item $tConfigs $bak
106
+ Write-Host " ! configs/ is obsolete (engine configs are generated now) -> configs.pre-forge.bak; per-project Claude tweaks go in .claude/settings.local.json"
107
+ }
108
+ $tSkills = Join-Path $Target 'skills'
109
+ if ($oldInstall -and (Test-Path -PathType Container $tSkills)) {
110
+ $bak = Join-Path $Target 'skills.pre-forge.bak'
111
+ if (Test-Path $bak) { Remove-Item -Recurse -Force $bak }
112
+ Move-Item $tSkills $bak
113
+ Write-Host " ! neutral skills/ is obsolete (skills are generated now) -> skills.pre-forge.bak; add custom skills to the codeforge repo"
114
+ }
115
+ }
116
+
117
+ # --- MANAGED: CLAUDE.md (back up a pre-existing, non-forge one) ---
118
+ $tClaude = Join-Path $Target 'CLAUDE.md'
119
+ if ((Test-Path $tClaude) -and -not (Has-ForgeMarker $tClaude)) {
120
+ Copy-Item $tClaude "$tClaude.pre-forge.bak" -Force
121
+ Write-Host " ! backed up existing CLAUDE.md -> CLAUDE.md.pre-forge.bak (move project-specifics into PROJECT.md)"
122
+ }
123
+ Copy-Item (Join-Path $Payload 'CLAUDE.md') $tClaude -Force
124
+
125
+ # --- MANAGED: framework shared/rules/ (per-entry overwrite by name) ---
126
+ New-Item -ItemType Directory -Force -Path (Join-Path $Target 'shared/rules') | Out-Null
127
+ $newRules = @(Get-ChildItem -File (Join-Path $Payload 'shared/rules') -Filter *.md).Name
128
+
129
+ # Prune framework rules removed upstream (see install.sh for rationale). Project-owned rules
130
+ # aren't in the manifest, so they're untouched. No manifest yet = skip prune.
131
+ $manifest = Join-Path $Target '.forge-manifest'
132
+ if (Test-Path $manifest) {
133
+ foreach ($line in Get-Content $manifest) {
134
+ if ($line -like 'rule:*') {
135
+ $n = $line.Substring(5)
136
+ # Treat the committed manifest as untrusted: a prune target must be a bare *.md filename,
137
+ # never a path/traversal, so a tampered entry can't delete outside shared/rules/.
138
+ if ($n -match '[\\/]' -or $n -match '\.\.' -or $n -eq '' -or $n -notmatch '\.md$') {
139
+ [Console]::Error.WriteLine(" ! ignoring unsafe manifest rule entry: $n")
140
+ } elseif ($newRules -notcontains $n) {
141
+ Remove-Item -Force (Join-Path $Target "shared/rules/$n") -ErrorAction SilentlyContinue
142
+ Write-Host " - pruned framework rule removed upstream: $n"
143
+ }
144
+ }
145
+ }
146
+ }
147
+
148
+ foreach ($f in Get-ChildItem -File (Join-Path $Payload 'shared/rules') -Filter *.md) {
149
+ Copy-Item $f.FullName (Join-Path $Target "shared/rules/$($f.Name)") -Force
150
+ }
151
+
152
+ # Record the framework-owned manifest for the next upgrade's prune (rules only).
153
+ Set-Content -Path $manifest -Value (@($newRules | ForEach-Object { "rule:$_" }))
154
+
155
+ # Stamp the version that produced this install, for drift detection on the next run.
156
+ Set-Content -Path (Join-Path $Target '.forge-version') -Value $forgeVersion
157
+
158
+ # --- MANAGED: workflow state template (in shared/) ---
159
+ Copy-Item (Join-Path $Payload 'shared/state.template.md') (Join-Path $Target 'shared/state.template.md') -Force
160
+
161
+ # --- MANAGED: framework shared/scripts/ (agent-invoked Tier-B helpers, e.g. check-gates) ---
162
+ $scriptsSrc = Join-Path $Payload 'shared/scripts'
163
+ if (Test-Path -LiteralPath $scriptsSrc -PathType Container) {
164
+ New-Item -ItemType Directory -Force -Path (Join-Path $Target 'shared/scripts') | Out-Null
165
+ foreach ($f in Get-ChildItem -File $scriptsSrc) {
166
+ Copy-Item $f.FullName (Join-Path $Target "shared/scripts/$($f.Name)") -Force
167
+ }
168
+ }
169
+
170
+ # --- MANAGED: docs/ scaffolding ---
171
+ New-Item -ItemType Directory -Force -Path (Join-Path $Target 'docs') | Out-Null
172
+ foreach ($d in 'prds', 'plans', 'research', 'solutions', 'adr', 'e2e/reports', 'e2e/use-cases') {
173
+ $dd = Join-Path $Target "docs/$d"
174
+ New-Item -ItemType Directory -Force -Path $dd | Out-Null
175
+ $gk = Join-Path $dd '.gitkeep'
176
+ if (-not (Test-Path $gk)) { New-Item -ItemType File -Path $gk | Out-Null }
177
+ }
178
+
179
+ # --- PROJECT-OWNED: PROJECT.md / CONTINUITY.md / docs/CHANGELOG.md (create only if missing) ---
180
+ $tProject = Join-Path $Target 'PROJECT.md'
181
+ if (-not (Test-Path $tProject)) {
182
+ Copy-Item (Join-Path $Payload 'PROJECT.template.md') $tProject
183
+ Write-Host " + created PROJECT.md (fill in persona/info/variables/special rules)"
184
+ }
185
+ $tCont = Join-Path $Target 'CONTINUITY.md'
186
+ if (-not (Test-Path $tCont)) { Copy-Item (Join-Path $Payload 'CONTINUITY.template.md') $tCont }
187
+ $tChangelog = Join-Path $Target 'docs/CHANGELOG.md'
188
+ if (-not (Test-Path $tChangelog)) { Copy-Item (Join-Path $Payload 'docs/CHANGELOG.md') $tChangelog }
189
+
190
+ # --- back up any pre-existing, NON-forge per-engine skills dir before sync overwrites it ---
191
+ foreach ($eng in '.claude', '.agents') {
192
+ $sd = Join-Path $Target "$eng/skills"
193
+ if ((Test-Path $sd) -and -not (Test-Path (Join-Path $sd '.forge-generated'))) {
194
+ Move-Item $sd "$sd.pre-forge.bak"
195
+ Write-Host " ! backed up existing $eng/skills -> $eng/skills.pre-forge.bak (add custom skills to the codeforge repo)"
196
+ }
197
+ }
198
+ # back up a real, non-forge AGENTS.md before sync overwrites it
199
+ $tAgents = Join-Path $Target 'AGENTS.md'
200
+ if ((Test-Path $tAgents) -and -not (Has-ForgeMarker $tAgents)) {
201
+ Copy-Item $tAgents "$tAgents.pre-forge.bak" -Force
202
+ Write-Host " ! backed up existing AGENTS.md -> AGENTS.md.pre-forge.bak"
203
+ }
204
+
205
+ # --- GENERATE engine dirs + AGENTS.md + opencode.json via sync (reads the codeforge source,
206
+ # writes straight into the target) ---
207
+ & (Join-Path $Src 'src/sync.ps1') -Out $Target | Out-Null
208
+
209
+ # --- Claude Code .claude/settings.local.json: auto-isolation + opt-in gate hook ---
210
+ # Auto-isolation (default; -NoIsolate to keep inheritance) adds claudeMdExcludes so Claude Code
211
+ # doesn't blend ancestor CLAUDE.md/.claude/rules into this project (Codex/OpenCode already scope
212
+ # to the project root). -WithHooks adds the Tier-C PreToolUse gate. Both land in the one
213
+ # gitignored file; codeforge only (re)writes it when absent or a prior install owned it.
214
+ $excludes = @()
215
+ if (-not $NoIsolate) {
216
+ $d = Split-Path -Parent $Target
217
+ $homeDir = [System.IO.Path]::GetFullPath($HOME)
218
+ while ($d -and $d -ne (Split-Path -Parent $d)) {
219
+ if (Test-Path -LiteralPath (Join-Path $d 'CLAUDE.md') -PathType Leaf) { $excludes += (Join-Path $d 'CLAUDE.md') }
220
+ if (Test-Path -LiteralPath (Join-Path $d 'CLAUDE.local.md') -PathType Leaf) { $excludes += (Join-Path $d 'CLAUDE.local.md') }
221
+ if ((Test-Path -LiteralPath (Join-Path $d '.claude/rules') -PathType Container) -and ($d -ne $homeDir)) { $excludes += (Join-Path $d '.claude/rules/**') }
222
+ $d = Split-Path -Parent $d
223
+ }
224
+ }
225
+
226
+ $sl = Join-Path $Target '.claude/settings.local.json'
227
+ if ($excludes.Count -gt 0 -or $WithHooks) {
228
+ if ((Test-Path -LiteralPath $sl -PathType Leaf) -and (-not $priorLocalManaged)) {
229
+ Write-Host " ! .claude/settings.local.json exists and isn't codeforge-managed — not touching it."
230
+ Write-Host " (skipped auto-isolation / gate hook; remove that file and re-run, or edit it by hand.)"
231
+ } else {
232
+ $settings = [ordered]@{}
233
+ if ($excludes.Count -gt 0) { $settings['claudeMdExcludes'] = $excludes }
234
+ if ($WithHooks) {
235
+ $settings['hooks'] = [ordered]@{
236
+ PreToolUse = @(
237
+ [ordered]@{
238
+ matcher = 'Bash'
239
+ hooks = @([ordered]@{ type = 'command'; command = 'pwsh -NoProfile -File "$env:CLAUDE_PROJECT_DIR/shared/scripts/claude-gate-hook.ps1"' })
240
+ }
241
+ )
242
+ }
243
+ }
244
+ $settings | ConvertTo-Json -Depth 10 | Set-Content -Path $sl
245
+ if (-not (Select-String -LiteralPath $mf -Pattern '^localsettings:managed$' -Quiet)) { Add-Content -Path $mf -Value 'localsettings:managed' }
246
+ if ($excludes.Count -gt 0) { Write-Host " + auto-isolated Claude Code from $($excludes.Count) ancestor instruction path(s) -> .claude/settings.local.json (-NoIsolate to keep inheritance)" }
247
+ if ($WithHooks) { Write-Host " + Claude gate hook -> .claude/settings.local.json (opt-in, hard-blocks ship on incomplete gates)" }
248
+ }
249
+ } elseif ($priorLocalManaged -and (Test-Path -LiteralPath $sl -PathType Leaf)) {
250
+ Remove-Item -LiteralPath $sl -Force
251
+ Write-Host " - removed codeforge-managed .claude/settings.local.json (nothing to configure now)"
252
+ }
253
+
254
+ # --- .gitignore (merge, don't clobber): ONLY local state (generated files are committed) ---
255
+ $gi = Join-Path $Target '.gitignore'
256
+ if (-not (Test-Path $gi)) { New-Item -ItemType File -Path $gi | Out-Null }
257
+ $marker = '# codeforge (local state — do not commit)'
258
+ if (-not (Select-String -Quiet -SimpleMatch $marker $gi)) {
259
+ $block = @"
260
+
261
+ # codeforge (local state — do not commit)
262
+ .DS_Store
263
+ .workflow/
264
+ .claude/settings.local.json
265
+ "@
266
+ Add-Content -Path $gi -Value $block
267
+ }
268
+
269
+ # --- warn if the generated config lacks the forge push/PR gate ---
270
+ function Warn-Gate([string]$rel, [string]$needle, [string]$hint) {
271
+ $f = Join-Path $Target $rel
272
+ if ((Test-Path $f) -and -not (Select-String -Quiet -SimpleMatch $needle $f)) {
273
+ Write-Host " ! $rel has no forge push/PR gate ($hint) — add it to the codeforge source, then re-run."
274
+ }
275
+ }
276
+ Warn-Gate '.claude/settings.json' 'git push' 'ask-tier on git push / gh pr create'
277
+ Warn-Gate '.codex/config.toml' 'approval_policy' 'approval_policy'
278
+ Warn-Gate 'opencode.json' 'git push' 'permission.bash git push* / gh pr create*'
279
+
280
+ # --- post-install validation: generated skill copies + AGENTS.md must exist ---
281
+ $ok = $true
282
+ foreach ($p in '.claude/skills', '.agents/skills') {
283
+ if (-not (Test-Path (Join-Path $Target "$p/new-feature/SKILL.md"))) {
284
+ Write-Host " ! discovery FAILED: $p was not generated"; $ok = $false
285
+ }
286
+ }
287
+ foreach ($f in 'AGENTS.md', '.claude/settings.json', '.codex/config.toml', 'opencode.json', 'shared/state.template.md') {
288
+ if (-not (Test-Path (Join-Path $Target $f))) { Write-Host " ! FAILED: $f was not generated"; $ok = $false }
289
+ }
290
+ if (-not $ok) {
291
+ Write-Host " x install INCOMPLETE — issues above; NOT marking as installed"; exit 1
292
+ }
293
+ Write-Host " + validation: skills (.claude + .agents), AGENTS.md, and engine configs generated"
294
+
295
+ # --- git: the workflow (branches/commits) and ship gates operate on git ---
296
+ & git -C $Target rev-parse --is-inside-work-tree *> $null
297
+ if ($LASTEXITCODE -eq 0) {
298
+ # already a git repo — the workflow uses it
299
+ } elseif ($GitInit) {
300
+ & git -C $Target init -q
301
+ & git -C $Target add -A
302
+ & git -C $Target commit -q -m "chore: adopt codeforge" 2>$null
303
+ if ($LASTEXITCODE -eq 0) {
304
+ Write-Host " + initialized a git repo + baseline commit (chore: adopt codeforge)"
305
+ } else {
306
+ Write-Host " + initialized a git repo (baseline commit skipped — set git user.name/email, then commit)"
307
+ }
308
+ } else {
309
+ Write-Host " ! not a git repo — codeforge's workflow (branches, commits) and the ship gates assume git."
310
+ Write-Host " Run 'git init' here, or re-run the installer with -GitInit."
311
+ }
312
+
313
+ Write-Host "codeforge installed."
314
+ Write-Host " next: (1) fill PROJECT.md (2) in Codex, trust the project when prompted"
315
+ Write-Host " (3) open the project in any of Claude Code / Codex / OpenCode"
316
+ Write-Host " to customize or upgrade: edit the codeforge source, then re-run this installer"
317
+ Write-Host " against the project (-Upgrade, or a bare re-run from inside it)."
package/install.sh ADDED
@@ -0,0 +1,326 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # codeforge installer — copy the workflow discipline into a target project.
4
+ #
5
+ # ./install.sh [target-dir] [--upgrade] [--with-hooks] [--git-init] [--no-isolate]
6
+ #
7
+ # With no target-dir, installs into the current working directory. So the common flow is:
8
+ # cd my-project && /path/to/codeforge/install.sh
9
+ #
10
+ # THIN INSTALL: the target receives only what the agent needs at RUNTIME. All framework
11
+ # machinery (the neutral source in ./src/, the generators sync.sh/ps1, the generation
12
+ # inputs in configs/, and the seed templates) stays in the codeforge repo — never copied
13
+ # into the target. To customize or upgrade, edit the codeforge source and re-run this
14
+ # installer against the target (`--upgrade`, or a bare re-run from inside the project).
15
+ #
16
+ # The shippable payload is the NEUTRAL source in ./src/ (CLAUDE.md, skills/, shared/,
17
+ # configs/, docs/, *.template.md). This installer copies the runtime subset into the
18
+ # target, then runs `sync.sh --out <target>` to GENERATE each engine's config + skills
19
+ # (.claude/ + .agents/skills + .codex/config.toml + AGENTS.md + opencode.json) straight
20
+ # into the target. No symlinks (Windows-safe). The generated engine artifacts are COMMITTED
21
+ # with the target project so a fresh clone works immediately — no codeforge dependency at
22
+ # runtime; re-run the installer after editing the source to regenerate.
23
+ #
24
+ # LANDS IN THE TARGET (runtime only):
25
+ # CLAUDE.md, AGENTS.md, opencode.json, .claude/, .agents/, .codex/ (generated),
26
+ # shared/rules/*.md + shared/state.template.md + shared/scripts/* (managed), docs/ scaffolding + CHANGELOG,
27
+ # PROJECT.md + CONTINUITY.md (project-owned, seeded if missing).
28
+ # STAYS IN codeforge (never copied): src/skills (neutral), configs/, sync.sh, sync.ps1,
29
+ # *.template.md, docs/extending.md.
30
+ #
31
+ # MANAGED (framework baseline — OVERWRITTEN on install/upgrade): CLAUDE.md, the framework's
32
+ # OWN entries in shared/rules/, shared/state.template.md. Your own rules dropped into
33
+ # shared/rules/ survive upgrades (selective, per-entry by name).
34
+ # PROJECT-OWNED (created only if missing — NEVER clobbered): PROJECT.md, CONTINUITY.md,
35
+ # docs/CHANGELOG.md. Per-project Claude overrides go in .claude/settings.local.json.
36
+ #
37
+ set -euo pipefail
38
+
39
+ SRC="$(cd "$(dirname "$0")" && pwd)"
40
+ PAYLOAD="$SRC/src"
41
+ FORGE_VERSION="unknown"
42
+ [ -f "$SRC/VERSION" ] && FORGE_VERSION="$(head -n1 "$SRC/VERSION" | tr -d '[:space:]')"
43
+ [ -n "$FORGE_VERSION" ] || FORGE_VERSION="unknown"
44
+ MODE="install"
45
+ WITH_HOOKS=0
46
+ GIT_INIT=0
47
+ ISOLATE=1 # auto-isolate Claude Code from ancestor CLAUDE.md by default (--no-isolate to keep inheritance)
48
+ TARGET=""
49
+ usage="usage: $0 [target-dir] [--upgrade] [--with-hooks] [--git-init] [--no-isolate]"
50
+ while [ $# -gt 0 ]; do
51
+ case "$1" in
52
+ --upgrade) MODE="upgrade" ;;
53
+ --with-hooks) WITH_HOOKS=1 ;;
54
+ --git-init) GIT_INIT=1 ;;
55
+ --no-isolate) ISOLATE=0 ;;
56
+ -*) echo "$usage (unknown arg: $1)" >&2; exit 2 ;;
57
+ *) if [ -z "$TARGET" ]; then TARGET="$1"; else echo "$usage (unexpected arg: $1)" >&2; exit 2; fi ;;
58
+ esac
59
+ shift
60
+ done
61
+ TARGET="${TARGET:-$PWD}"
62
+
63
+ [ -d "$TARGET" ] || { echo "error: target dir not found: $TARGET" >&2; exit 2; }
64
+ TARGET="$(cd "$TARGET" && pwd)"
65
+ # Did a prior forge install own .claude/settings.local.json? (read before the manifest is
66
+ # rewritten below, so the settings writer knows whether it may safely regenerate the file.)
67
+ PRIOR_LOCAL_MANAGED=0
68
+ grep -q '^localsettings:managed$' "$TARGET/.forge-manifest" 2>/dev/null && PRIOR_LOCAL_MANAGED=1
69
+ { [ -f "$PAYLOAD/CLAUDE.md" ] && [ -d "$PAYLOAD/skills" ]; } || { echo "error: payload not found — run this from the codeforge repo" >&2; exit 2; }
70
+ [ "$TARGET" != "$SRC" ] || { echo "error: refusing to install into codeforge itself" >&2; exit 2; }
71
+ [ "$TARGET" != "$PAYLOAD" ] || { echo "error: refusing to install into the codeforge payload dir (src/)" >&2; exit 2; }
72
+
73
+ echo "codeforge $FORGE_VERSION → installing into: $TARGET (mode: $MODE)"
74
+
75
+ # --- version drift advisory (informational only, never blocks) ---
76
+ # Compare the version that last stamped this target against the one we're installing.
77
+ PRIOR_VERSION=""
78
+ [ -f "$TARGET/.forge-version" ] && PRIOR_VERSION="$(head -n1 "$TARGET/.forge-version" | tr -d '[:space:]')"
79
+ if [ -n "$PRIOR_VERSION" ] && [ "$PRIOR_VERSION" != "$FORGE_VERSION" ] \
80
+ && [ "$FORGE_VERSION" != "unknown" ] && [ "$PRIOR_VERSION" != "unknown" ]; then
81
+ lower="$(printf '%s\n%s\n' "$PRIOR_VERSION" "$FORGE_VERSION" | sort -V | head -n1)"
82
+ if [ "$lower" = "$PRIOR_VERSION" ]; then
83
+ echo " ~ upgrading this target: codeforge $PRIOR_VERSION -> $FORGE_VERSION"
84
+ else
85
+ echo " ! this target was installed by a NEWER codeforge ($PRIOR_VERSION) than you're running ($FORGE_VERSION)."
86
+ echo " You may be downgrading it; teammates pinned to $PRIOR_VERSION could see drift. (advisory only)"
87
+ fi
88
+ fi
89
+
90
+ # --- self-healing: drop machinery this version no longer installs into the target ---
91
+ # (thin model — machinery lives in the codeforge repo; the target gets runtime only.) This
92
+ # migrates a target from an older, bloated install. Gated on a prior forge install
93
+ # (.forge-manifest present) so a FIRST install never touches an unrelated project's own
94
+ # configs/ or skills/ dirs.
95
+ if [ -f "$TARGET/.forge-manifest" ]; then
96
+ # Detect a genuinely OLD (pre-thin) forge install: it left this machinery at the target root.
97
+ # A modern thin install — or an unrelated app that happens to keep its own top-level configs/
98
+ # or skills/ — does NOT. Gate the configs/skills migration on this signal so a routine
99
+ # re-install never relocates a project's own configs/ or skills/ just because a manifest exists.
100
+ old_install=0
101
+ for f in sync.sh sync.ps1 state.template.md PROJECT.template.md CONTINUITY.template.md docs/extending.md; do
102
+ [ -e "$TARGET/$f" ] && old_install=1
103
+ done
104
+ # Framework-owned machinery, no user content — removed outright.
105
+ for f in sync.sh sync.ps1 state.template.md PROJECT.template.md CONTINUITY.template.md docs/extending.md; do
106
+ [ -e "$TARGET/$f" ] && { rm -f "$TARGET/$f"; echo " - removed obsolete framework file: $f"; }
107
+ done
108
+ # Only migrate configs/ and the neutral skills/ when this was actually an old bloated install
109
+ # (back them up rather than delete, so nothing is lost).
110
+ if [ "$old_install" = 1 ] && [ -d "$TARGET/configs" ]; then
111
+ rm -rf "$TARGET/configs.pre-forge.bak"; mv "$TARGET/configs" "$TARGET/configs.pre-forge.bak"
112
+ echo " ! configs/ is obsolete (engine configs are generated now) -> configs.pre-forge.bak; per-project Claude tweaks go in .claude/settings.local.json"
113
+ fi
114
+ if [ "$old_install" = 1 ] && [ -d "$TARGET/skills" ]; then
115
+ rm -rf "$TARGET/skills.pre-forge.bak"; mv "$TARGET/skills" "$TARGET/skills.pre-forge.bak"
116
+ echo " ! neutral skills/ is obsolete (skills are generated now) -> skills.pre-forge.bak; add custom skills to the codeforge repo"
117
+ fi
118
+ fi
119
+
120
+ # --- MANAGED: CLAUDE.md (back up a pre-existing, non-forge one on first install) ---
121
+ if [ -f "$TARGET/CLAUDE.md" ] && ! grep -q "Workflow discipline for Claude Code" "$TARGET/CLAUDE.md" 2>/dev/null; then
122
+ cp "$TARGET/CLAUDE.md" "$TARGET/CLAUDE.md.pre-forge.bak"
123
+ echo " ! backed up existing CLAUDE.md -> CLAUDE.md.pre-forge.bak (move project-specifics into PROJECT.md)"
124
+ fi
125
+ cp "$PAYLOAD/CLAUDE.md" "$TARGET/CLAUDE.md"
126
+
127
+ # --- MANAGED: framework shared/rules/ (per-entry overwrite by name) ---
128
+ # Refresh only the framework's own rule entries; anything else in shared/rules/ (your
129
+ # project's own rules) is left untouched, so it survives --upgrade.
130
+ mkdir -p "$TARGET/shared/rules"
131
+ new_rules="$(cd "$PAYLOAD/shared/rules" && ls *.md 2>/dev/null)"
132
+
133
+ # Prune framework rules removed upstream: anything in the last-install manifest that is no
134
+ # longer in the current payload is a framework rule deleted upstream — remove it. Project-owned
135
+ # rules are never in the manifest, so they are untouched. (No manifest yet = skip prune.)
136
+ manifest="$TARGET/.forge-manifest"
137
+ if [ -f "$manifest" ]; then
138
+ while IFS= read -r line; do
139
+ case "$line" in
140
+ rule:*)
141
+ n="${line#rule:}"
142
+ # The manifest is committed (and with --git-init, git-added), so treat it as untrusted:
143
+ # a prune target must be a bare *.md filename — never a path or traversal — so a tampered
144
+ # entry can't delete outside shared/rules/.
145
+ case "$n" in
146
+ */*|*..*|"") echo " ! ignoring unsafe manifest rule entry: $n" >&2 ;;
147
+ *.md) printf '%s\n' "$new_rules" | grep -qxF "$n" || { rm -f "$TARGET/shared/rules/$n"; echo " - pruned framework rule removed upstream: $n"; } ;;
148
+ *) echo " ! ignoring non-.md manifest rule entry: $n" >&2 ;;
149
+ esac ;;
150
+ esac
151
+ done < "$manifest"
152
+ fi
153
+
154
+ for f in "$PAYLOAD"/shared/rules/*.md; do
155
+ cp "$f" "$TARGET/shared/rules/$(basename "$f")"
156
+ done
157
+
158
+ # Record the framework-owned manifest for the next upgrade's prune (rules only; skills are
159
+ # fully generated by sync, so there is nothing skill-level to selectively prune).
160
+ { printf '%s\n' "$new_rules" | sed 's/^/rule:/'; } > "$manifest"
161
+
162
+ # Stamp the version that produced this install, for drift detection on the next run.
163
+ printf '%s\n' "$FORGE_VERSION" > "$TARGET/.forge-version"
164
+
165
+ # --- MANAGED: workflow state template (lives in shared/, copied to .workflow/state.md at
166
+ # workflow start by the skills) ---
167
+ cp "$PAYLOAD/shared/state.template.md" "$TARGET/shared/state.template.md"
168
+
169
+ # --- MANAGED: framework shared/scripts/ (agent-invoked Tier-B helpers, e.g. check-gates) ---
170
+ if [ -d "$PAYLOAD/shared/scripts" ]; then
171
+ mkdir -p "$TARGET/shared/scripts"
172
+ for f in "$PAYLOAD"/shared/scripts/*; do
173
+ [ -e "$f" ] || continue
174
+ cp "$f" "$TARGET/shared/scripts/$(basename "$f")"
175
+ done
176
+ chmod +x "$TARGET"/shared/scripts/*.sh 2>/dev/null || true
177
+ fi
178
+
179
+ # --- MANAGED: docs/ scaffolding ---
180
+ mkdir -p "$TARGET/docs"
181
+ for d in prds plans research solutions adr e2e/reports e2e/use-cases; do
182
+ mkdir -p "$TARGET/docs/$d"
183
+ [ -e "$TARGET/docs/$d/.gitkeep" ] || touch "$TARGET/docs/$d/.gitkeep"
184
+ done
185
+
186
+ # --- PROJECT-OWNED: PROJECT.md / CONTINUITY.md / docs/CHANGELOG.md (create only if missing) ---
187
+ [ -f "$TARGET/PROJECT.md" ] || { cp "$PAYLOAD/PROJECT.template.md" "$TARGET/PROJECT.md"; echo " + created PROJECT.md (fill in persona/info/variables/special rules)"; }
188
+ [ -f "$TARGET/CONTINUITY.md" ] || cp "$PAYLOAD/CONTINUITY.template.md" "$TARGET/CONTINUITY.md"
189
+ [ -f "$TARGET/docs/CHANGELOG.md" ] || cp "$PAYLOAD/docs/CHANGELOG.md" "$TARGET/docs/CHANGELOG.md"
190
+
191
+ # --- back up any pre-existing, NON-forge per-engine skills dir before sync overwrites it ---
192
+ # (forge-generated dirs carry a .forge-generated marker; a dir without it is the user's own,
193
+ # so we never wipe a user's skills — even one coincidentally named new-feature.)
194
+ for eng in .claude .agents; do
195
+ sd="$TARGET/$eng/skills"
196
+ if [ -e "$sd" ] && [ ! -e "$sd/.forge-generated" ]; then
197
+ mv "$sd" "$sd.pre-forge.bak"
198
+ echo " ! backed up existing $eng/skills -> $eng/skills.pre-forge.bak (add custom skills to the codeforge repo)"
199
+ fi
200
+ done
201
+ # back up a real, non-forge AGENTS.md before sync overwrites it
202
+ if [ -f "$TARGET/AGENTS.md" ] && ! grep -q "Workflow discipline for Claude Code" "$TARGET/AGENTS.md" 2>/dev/null; then
203
+ cp "$TARGET/AGENTS.md" "$TARGET/AGENTS.md.pre-forge.bak"
204
+ echo " ! backed up existing AGENTS.md -> AGENTS.md.pre-forge.bak"
205
+ fi
206
+
207
+ # --- GENERATE engine dirs + AGENTS.md + opencode.json via sync (reads the codeforge source,
208
+ # writes straight into the target — no source or sync script copied there) ---
209
+ bash "$PAYLOAD/sync.sh" --out "$TARGET" >/dev/null
210
+
211
+ # --- Claude Code .claude/settings.local.json: auto-isolation + opt-in gate hook ---
212
+ # Both features land in this one gitignored, per-developer, machine-specific file. Auto-isolation
213
+ # (default; --no-isolate to keep inheritance) adds `claudeMdExcludes` so Claude Code does NOT
214
+ # blend ancestor CLAUDE.md / .claude/rules into this project — Codex and OpenCode already scope
215
+ # to the project root, Claude Code walks to the filesystem root. --with-hooks adds the Tier-C
216
+ # PreToolUse gate. codeforge only (re)writes this file when it is absent or a prior forge install
217
+ # owned it (tracked as `localsettings:managed` in .forge-manifest); a file it doesn't own is left
218
+ # alone. The hook's $CLAUDE_PROJECT_DIR is resolved by Claude Code at runtime, not now.
219
+ excludes=""
220
+ if [ "$ISOLATE" = "1" ]; then
221
+ d="$(dirname "$TARGET")"
222
+ while [ -n "$d" ] && [ "$d" != "/" ]; do
223
+ [ -f "$d/CLAUDE.md" ] && excludes="$excludes$d/CLAUDE.md
224
+ "
225
+ [ -f "$d/CLAUDE.local.md" ] && excludes="$excludes$d/CLAUDE.local.md
226
+ "
227
+ { [ -d "$d/.claude/rules" ] && [ "$d" != "$HOME" ]; } && excludes="$excludes$d/.claude/rules/**
228
+ "
229
+ nd="$(dirname "$d")"; [ "$nd" = "$d" ] && break; d="$nd"
230
+ done
231
+ fi
232
+ n_excl=$(printf '%s' "$excludes" | grep -c . || true)
233
+
234
+ sl="$TARGET/.claude/settings.local.json"
235
+ if [ "$n_excl" -gt 0 ] || [ "$WITH_HOOKS" = "1" ]; then
236
+ if [ -f "$sl" ] && [ "$PRIOR_LOCAL_MANAGED" != "1" ]; then
237
+ echo " ! .claude/settings.local.json exists and isn't codeforge-managed — not touching it."
238
+ echo " (skipped auto-isolation / gate hook; remove that file and re-run, or edit it by hand.)"
239
+ else
240
+ excl_json=""
241
+ while IFS= read -r p; do
242
+ [ -n "$p" ] || continue
243
+ if [ -z "$excl_json" ]; then excl_json="$(printf '\n "%s"' "$p")"
244
+ else excl_json="$excl_json$(printf ',\n "%s"' "$p")"; fi
245
+ done <<EOF
246
+ $excludes
247
+ EOF
248
+ hook_block=' "hooks": {
249
+ "PreToolUse": [
250
+ { "matcher": "Bash", "hooks": [ { "type": "command", "command": "sh \"$CLAUDE_PROJECT_DIR/shared/scripts/claude-gate-hook.sh\"" } ] }
251
+ ]
252
+ }'
253
+ {
254
+ printf '{'
255
+ [ "$n_excl" -gt 0 ] && printf '\n "claudeMdExcludes": [%s\n ]' "$excl_json"
256
+ { [ "$n_excl" -gt 0 ] && [ "$WITH_HOOKS" = "1" ]; } && printf ','
257
+ [ "$WITH_HOOKS" = "1" ] && printf '\n%s' "$hook_block"
258
+ printf '\n}\n'
259
+ } > "$sl"
260
+ grep -q '^localsettings:managed$' "$manifest" 2>/dev/null || printf 'localsettings:managed\n' >> "$manifest"
261
+ [ "$n_excl" -gt 0 ] && echo " + auto-isolated Claude Code from $n_excl ancestor instruction path(s) -> .claude/settings.local.json (--no-isolate to keep inheritance)"
262
+ [ "$WITH_HOOKS" = "1" ] && echo " + Claude gate hook -> .claude/settings.local.json (opt-in, hard-blocks ship on incomplete gates)"
263
+ fi
264
+ elif [ "$PRIOR_LOCAL_MANAGED" = "1" ] && [ -f "$sl" ]; then
265
+ rm -f "$sl"
266
+ echo " - removed codeforge-managed .claude/settings.local.json (nothing to configure now)"
267
+ fi
268
+
269
+ # --- .gitignore (merge, don't clobber): ONLY local state ---
270
+ # The generated engine artifacts (.claude/, .agents/, .codex/, AGENTS.md, opencode.json)
271
+ # are COMMITTED with the project so a fresh clone works immediately — no post-clone step,
272
+ # no dependency on codeforge. Only genuinely local/transient state is ignored here.
273
+ touch "$TARGET/.gitignore"
274
+ if ! grep -qx '# codeforge (local state — do not commit)' "$TARGET/.gitignore"; then
275
+ {
276
+ printf '\n# codeforge (local state — do not commit)\n'
277
+ printf '.DS_Store\n.workflow/\n.claude/settings.local.json\n'
278
+ } >> "$TARGET/.gitignore"
279
+ fi
280
+
281
+ # --- warn if the generated config lacks the forge push/PR gate (points at the codeforge
282
+ # source baseline that produced it) ---
283
+ warn_gate() { # $1 = generated file in target, $2 = grep needle, $3 = hint
284
+ if [ -f "$TARGET/$1" ] && ! grep -q "$2" "$TARGET/$1" 2>/dev/null; then
285
+ echo " ! $1 has no forge push/PR gate ($3) — add it to the codeforge source, then re-run."
286
+ fi
287
+ }
288
+ warn_gate ".claude/settings.json" "git push" "ask-tier on git push / gh pr create"
289
+ warn_gate ".codex/config.toml" "approval_policy" "approval_policy"
290
+ warn_gate "opencode.json" "git push" "permission.bash git push* / gh pr create*"
291
+
292
+ # --- post-install validation: generated skills + AGENTS.md + engine configs must exist ---
293
+ ok=1
294
+ for p in .claude/skills .agents/skills; do
295
+ [ -e "$TARGET/$p/new-feature/SKILL.md" ] || { echo " ! discovery FAILED: $p was not generated"; ok=0; }
296
+ done
297
+ for f in AGENTS.md .claude/settings.json .codex/config.toml opencode.json shared/state.template.md; do
298
+ [ -f "$TARGET/$f" ] || { echo " ! FAILED: $f was not generated"; ok=0; }
299
+ done
300
+ if [ "$ok" != 1 ]; then
301
+ echo " ✗ install INCOMPLETE — issues above; NOT marking as installed" >&2
302
+ exit 1
303
+ fi
304
+ echo " ✓ validation: skills (.claude + .agents), AGENTS.md, and engine configs generated"
305
+
306
+ # --- git: the workflow (branches/commits) and ship gates operate on git ---
307
+ if git -C "$TARGET" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
308
+ : # already a git repo — the workflow uses it
309
+ elif [ "$GIT_INIT" = "1" ]; then
310
+ git -C "$TARGET" init -q
311
+ git -C "$TARGET" add -A
312
+ if git -C "$TARGET" commit -q -m "chore: adopt codeforge" 2>/dev/null; then
313
+ echo " + initialized a git repo + baseline commit (chore: adopt codeforge)"
314
+ else
315
+ echo " + initialized a git repo (baseline commit skipped — set git user.name/email, then commit)"
316
+ fi
317
+ else
318
+ echo " ! not a git repo — codeforge's workflow (branches, commits) and the ship gates assume git."
319
+ echo " Run 'git init' here, or re-run the installer with --git-init."
320
+ fi
321
+
322
+ echo "codeforge installed."
323
+ echo " next: (1) fill PROJECT.md (2) in Codex, trust the project when prompted"
324
+ echo " (3) open the project in any of Claude Code / Codex / OpenCode"
325
+ echo " to customize or upgrade: edit the codeforge source, then re-run this installer"
326
+ echo " against the project (--upgrade, or a bare re-run from inside it)."