forge-orkes 0.56.0 → 0.58.3

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.
@@ -0,0 +1,404 @@
1
+ #!/usr/bin/env sh
2
+ # Fixture-repo test suite for .forge/checks/forge-hold-check.sh (m-31, plan 53-01).
3
+ #
4
+ # Contract under test (plan-01 "Locked interface" — do not re-derive):
5
+ # forge-hold-check.sh <base>...<head> [--approvals <file>]
6
+ # stdout: hold=clear (no facts)
7
+ # hold=blocked + fact=<class>:<path> lines (facts, no operator act)
8
+ # hold=waived waived_by=<login>
9
+ # + "fact=<class>:<path> waived" (facts + operator approval)
10
+ # class ∈ irreversible|structural|protected
11
+ # exit: 0 = clear or waived; 1 = blocked; 2 = bad invocation (missing arg,
12
+ # non-three-dot range, unresolvable refs, missing --approvals file).
13
+ # Range MUST be three-dot BASE...HEAD; the fact base is merge-base(BASE, HEAD).
14
+ # Config .forge/hold-config.yml is read from the BASE side only
15
+ # (git show <merge-base>:...) — a PR cannot rewrite the rules it is judged by.
16
+ # Built-ins are hard-coded and config is ADDITIVE only (raise-only):
17
+ # irreversible: */migrations/*, */migrate/*, db/schema*, *.sql (+config irreversible_paths)
18
+ # structural: NEW dependency key in package.json, composer.json, pyproject.toml,
19
+ # requirements*.txt, Gemfile, go.mod, Cargo.toml — version-value
20
+ # changes to an existing key NEVER fire; lockfiles (package-lock.json,
21
+ # yarn.lock, pnpm-lock.yaml, composer.lock, Gemfile.lock, go.sum,
22
+ # Cargo.lock, uv.lock, poetry.lock) ignored entirely (pain 6)
23
+ # protected: .github/workflows/*, .gitlab-ci.yml, .forge/project.yml,
24
+ # .forge/hold-config.yml, .forge/checks/** (scripts + fixture
25
+ # suites) (+config deploy_paths, gating_tests)
26
+ # Waiver: any --approvals login ∈ base-side config operators: → waived. Absent
27
+ # config / empty operators → NO waiver possible (raise-only safe default).
28
+ #
29
+ # Builds throwaway git repos in mktemp dirs with NO remote configured anywhere —
30
+ # offline by construction (NFR-032); any network dependency in the script breaks here.
31
+ #
32
+ # Usage:
33
+ # ./.forge/checks/tests/forge-hold-check.test.sh # all cases
34
+ # ./.forge/checks/tests/forge-hold-check.test.sh pain6 # one tag
35
+ # Filter tags: irreversible structural pain6 waiver self-protect docs-only invocation multi
36
+ #
37
+ # RED-phase friendly (TDD): a missing/non-executable helper does NOT abort the
38
+ # suite — every selected case FAILS with "(helper missing ...)" as the got-value
39
+ # and the suite exits non-zero. Exits 0 iff every selected case passes. Self-cleans.
40
+ set -u
41
+
42
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
43
+ HELPER="$SCRIPT_DIR/../forge-hold-check.sh"
44
+
45
+ [ -x "$HELPER" ] || printf 'NOTE: %s missing or not executable — RED phase, every selected case below should FAIL\n' "$HELPER" >&2
46
+
47
+ ROOT="$(mktemp -d)"
48
+ trap 'rm -rf "$ROOT"' EXIT INT TERM
49
+
50
+ # Isolate from the developer's global/system git config.
51
+ GIT_CONFIG_GLOBAL=/dev/null
52
+ GIT_CONFIG_SYSTEM=/dev/null
53
+ export GIT_CONFIG_GLOBAL GIT_CONFIG_SYSTEM
54
+
55
+ PASSED=0
56
+ FAILED=0
57
+ SELECTED=0
58
+ FILTER="${1:-}"
59
+
60
+ pass() { PASSED=$((PASSED + 1)); printf 'PASS: %s\n' "$1"; }
61
+ fail() { FAILED=$((FAILED + 1)); printf 'FAIL: %s\n' "$1" >&2; }
62
+ check() { # desc, actual, expected (exact match)
63
+ if [ "$2" = "$3" ]; then pass "$1"; else fail "$1 (got '$2', want '$3')"; fi
64
+ }
65
+ check_contains() { # desc, actual, needle
66
+ case "$2" in
67
+ *"$3"*) pass "$1" ;;
68
+ *) fail "$1 (got '$2', must contain '$3')" ;;
69
+ esac
70
+ }
71
+ check_absent() { # desc, actual, forbidden-substring
72
+ case "$2" in
73
+ *"$3"*) fail "$1 (got '$2', must NOT contain '$3')" ;;
74
+ *) pass "$1" ;;
75
+ esac
76
+ }
77
+
78
+ running() { # tag... — 0 if this case block is selected by $FILTER (empty = all)
79
+ if [ -z "$FILTER" ]; then SELECTED=$((SELECTED + 1)); return 0; fi
80
+ for _t in "$@"; do
81
+ if [ "$_t" = "$FILTER" ]; then SELECTED=$((SELECTED + 1)); return 0; fi
82
+ done
83
+ return 1
84
+ }
85
+
86
+ # Run the helper from a repo cwd; sets OUT (stdout) and RC. RED-friendly.
87
+ run_hold() { # cwd, helper-args...
88
+ rh_cwd="$1"; shift
89
+ if [ -x "$HELPER" ]; then
90
+ if OUT="$(cd "$rh_cwd" && "$HELPER" "$@" 2>/dev/null)"; then RC=0; else RC=$?; fi
91
+ else
92
+ OUT="(helper missing or not executable: $HELPER)"
93
+ RC=127
94
+ fi
95
+ }
96
+
97
+ # NO `git remote add` anywhere: fixtures are remote-less by construction (NFR-032).
98
+ new_repo() { # name → prints repo path (main branch, one root commit)
99
+ d="$ROOT/repo.$1"
100
+ mkdir -p "$d"
101
+ git -C "$d" init -q
102
+ git -C "$d" symbolic-ref HEAD refs/heads/main
103
+ git -C "$d" config user.email t@example.com
104
+ git -C "$d" config user.name tester
105
+ git -C "$d" commit --allow-empty -qm init
106
+ printf '%s\n' "$d"
107
+ }
108
+
109
+ put() { # repo, path, content — write + stage + commit on current branch
110
+ p_dir=$(dirname "$1/$2")
111
+ mkdir -p "$p_dir"
112
+ printf '%s\n' "$3" > "$1/$2"
113
+ git -C "$1" add "$2"
114
+ git -C "$1" commit -qm "edit $2"
115
+ }
116
+
117
+ pr() { # repo — create + switch to branch 'pr' off main
118
+ git -C "$1" checkout -qb pr main
119
+ }
120
+
121
+ approvals() { # name, login... → prints file path with one login per line
122
+ a_f="$ROOT/approvals.$1"
123
+ : > "$a_f"
124
+ for _l in "$@"; do [ "$_l" = "$1" ] && continue; printf '%s\n' "$_l" >> "$a_f"; done
125
+ printf '%s\n' "$a_f"
126
+ }
127
+
128
+ CONFIG_OPS='version: 1
129
+ operators:
130
+ - zayne
131
+ irreversible_paths: []
132
+ deploy_paths: []
133
+ gating_tests: []'
134
+
135
+ # ---------------------------------------------------------------------------
136
+ # irreversible — acceptance 1: migration fires naming the fact; neg control clean
137
+ # ---------------------------------------------------------------------------
138
+ if running irreversible; then
139
+ R=$(new_repo irr)
140
+ put "$R" src/app.js 'console.log(1)'
141
+ pr "$R"
142
+ put "$R" app/migrations/001_add_users.py 'create table users'
143
+ put "$R" src/app.js 'console.log(2)'
144
+ run_hold "$R" 'main...pr'
145
+ check "irreversible: migration file blocks (exit 1)" "$RC" "1"
146
+ check_contains "irreversible: verdict line" "$OUT" "hold=blocked"
147
+ check_contains "irreversible: fact names class+path" "$OUT" "fact=irreversible:app/migrations/001_add_users.py"
148
+
149
+ R=$(new_repo irr-neg)
150
+ put "$R" src/app.js 'console.log(1)'
151
+ pr "$R"
152
+ put "$R" src/app.js 'console.log(2)'
153
+ run_hold "$R" 'main...pr'
154
+ check "irreversible neg: same diff minus migration is clean (exit 0)" "$RC" "0"
155
+ check "irreversible neg: silent verdict only" "$OUT" "hold=clear"
156
+
157
+ R=$(new_repo irr-schema)
158
+ pr "$R"
159
+ put "$R" db/schema.rb 'ActiveRecord::Schema.define'
160
+ run_hold "$R" 'main...pr'
161
+ check "irreversible: db/schema* fires" "$RC" "1"
162
+ check_contains "irreversible: schema fact" "$OUT" "fact=irreversible:db/schema.rb"
163
+
164
+ R=$(new_repo irr-sql)
165
+ pr "$R"
166
+ put "$R" scripts/backfill.sql 'UPDATE users SET x=1;'
167
+ run_hold "$R" 'main...pr'
168
+ check "irreversible: *.sql data-mutation script fires" "$RC" "1"
169
+ check_contains "irreversible: sql fact" "$OUT" "fact=irreversible:scripts/backfill.sql"
170
+
171
+ # Config ADDS a glob (additive union with built-ins)
172
+ R=$(new_repo irr-conf)
173
+ put "$R" .forge/hold-config.yml 'version: 1
174
+ operators: []
175
+ irreversible_paths:
176
+ - "dangerzone/*"
177
+ deploy_paths: []
178
+ gating_tests: []'
179
+ pr "$R"
180
+ put "$R" dangerzone/wipe.txt 'boom'
181
+ run_hold "$R" 'main...pr'
182
+ check "irreversible: config-added glob fires" "$RC" "1"
183
+ check_contains "irreversible: config-glob fact" "$OUT" "fact=irreversible:dangerzone/wipe.txt"
184
+ fi
185
+
186
+ # ---------------------------------------------------------------------------
187
+ # pain6 / structural — acceptance 2: bump+lockfile NEVER fire; NEW dep entry fires
188
+ # ---------------------------------------------------------------------------
189
+ if running pain6 structural; then
190
+ R=$(new_repo bump)
191
+ put "$R" package.json '{
192
+ "name": "app",
193
+ "dependencies": {
194
+ "left-pad": "1.0.0",
195
+ "express": "4.17.0"
196
+ }
197
+ }'
198
+ put "$R" package-lock.json '{"lockfileVersion": 2, "v": 1}'
199
+ pr "$R"
200
+ put "$R" package.json '{
201
+ "name": "app",
202
+ "dependencies": {
203
+ "left-pad": "1.3.0",
204
+ "express": "4.18.2"
205
+ }
206
+ }'
207
+ put "$R" package-lock.json '{"lockfileVersion": 2, "v": 2}'
208
+ run_hold "$R" 'main...pr'
209
+ check "pain6: version bump + lockfile churn is clean (exit 0)" "$RC" "0"
210
+ check "pain6: verdict" "$OUT" "hold=clear"
211
+
212
+ R=$(new_repo newdep)
213
+ put "$R" package.json '{
214
+ "name": "app",
215
+ "dependencies": {
216
+ "express": "4.17.0"
217
+ }
218
+ }'
219
+ pr "$R"
220
+ put "$R" package.json '{
221
+ "name": "app",
222
+ "dependencies": {
223
+ "express": "4.17.0",
224
+ "evil-pkg": "0.0.1"
225
+ }
226
+ }'
227
+ run_hold "$R" 'main...pr'
228
+ check "pain6 neg: NEW dependency entry fires structural (exit 1)" "$RC" "1"
229
+ check_contains "pain6 neg: structural fact names manifest" "$OUT" "fact=structural:package.json"
230
+
231
+ R=$(new_repo lockonly)
232
+ put "$R" package-lock.json '{"v": 1}'
233
+ pr "$R"
234
+ put "$R" package-lock.json '{"v": 2}'
235
+ put "$R" yarn.lock 'lock churn'
236
+ run_hold "$R" 'main...pr'
237
+ check "pain6: lockfile-only churn is clean (exit 0)" "$RC" "0"
238
+
239
+ # Second manifest format: requirements.txt
240
+ R=$(new_repo reqs)
241
+ put "$R" requirements.txt 'flask==2.0.0
242
+ requests==2.28.0'
243
+ pr "$R"
244
+ put "$R" requirements.txt 'flask==2.3.0
245
+ requests==2.28.0'
246
+ run_hold "$R" 'main...pr'
247
+ check "pain6: requirements.txt version bump clean" "$RC" "0"
248
+
249
+ R=$(new_repo reqs-new)
250
+ put "$R" requirements.txt 'flask==2.0.0'
251
+ pr "$R"
252
+ put "$R" requirements.txt 'flask==2.0.0
253
+ sketchy-lib==0.1.0'
254
+ run_hold "$R" 'main...pr'
255
+ check "pain6 neg: requirements.txt NEW entry fires" "$RC" "1"
256
+ check_contains "pain6 neg: requirements fact" "$OUT" "fact=structural:requirements.txt"
257
+
258
+ # go.mod: bump clean / new require fires
259
+ R=$(new_repo gomod)
260
+ put "$R" go.mod 'module example.com/app
261
+
262
+ require (
263
+ github.com/pkg/errors v0.8.0
264
+ )'
265
+ pr "$R"
266
+ put "$R" go.mod 'module example.com/app
267
+
268
+ require (
269
+ github.com/pkg/errors v0.9.1
270
+ )'
271
+ put "$R" go.sum 'churn'
272
+ run_hold "$R" 'main...pr'
273
+ check "pain6: go.mod version bump + go.sum churn clean" "$RC" "0"
274
+ fi
275
+
276
+ # ---------------------------------------------------------------------------
277
+ # waiver — acceptance 3: operator approval waives + lists; non-operator does not
278
+ # ---------------------------------------------------------------------------
279
+ if running waiver; then
280
+ R=$(new_repo waive)
281
+ put "$R" .forge/hold-config.yml "$CONFIG_OPS"
282
+ pr "$R"
283
+ put "$R" app/migrations/002_drop_col.py 'drop column'
284
+ AF=$(approvals w1 zayne)
285
+ run_hold "$R" 'main...pr' --approvals "$AF"
286
+ check "waiver: operator approval waives (exit 0)" "$RC" "0"
287
+ check_contains "waiver: verdict names login" "$OUT" "hold=waived waived_by=zayne"
288
+ check_contains "waiver: lists what it waived" "$OUT" "fact=irreversible:app/migrations/002_drop_col.py waived"
289
+
290
+ AF=$(approvals w2 bot-agent)
291
+ run_hold "$R" 'main...pr' --approvals "$AF"
292
+ check "waiver neg: non-operator approval stays blocked (exit 1)" "$RC" "1"
293
+ check_contains "waiver neg: still names the fact" "$OUT" "fact=irreversible:app/migrations/002_drop_col.py"
294
+ check_absent "waiver neg: no waived verdict" "$OUT" "hold=waived"
295
+
296
+ run_hold "$R" 'main...pr'
297
+ check "waiver neg: no approvals input stays blocked (exit 1)" "$RC" "1"
298
+
299
+ # Operators are read from BASE: a PR that adds its approver to operators
300
+ # cannot self-waive — and the config edit itself raises protected.
301
+ R=$(new_repo selfadd)
302
+ put "$R" .forge/hold-config.yml "$CONFIG_OPS"
303
+ pr "$R"
304
+ put "$R" app/migrations/003_evil.py 'drop table'
305
+ put "$R" .forge/hold-config.yml 'version: 1
306
+ operators:
307
+ - zayne
308
+ - mallory
309
+ irreversible_paths: []
310
+ deploy_paths: []
311
+ gating_tests: []'
312
+ AF=$(approvals w3 mallory)
313
+ run_hold "$R" 'main...pr' --approvals "$AF"
314
+ check "waiver F4: head-side operator self-add cannot waive (exit 1)" "$RC" "1"
315
+ check_contains "waiver F4: config edit raises protected fact" "$OUT" "fact=protected:.forge/hold-config.yml"
316
+
317
+ # No config at base → empty operators → approval cannot waive (raise-only default)
318
+ R=$(new_repo noconf)
319
+ pr "$R"
320
+ put "$R" app/migrations/004_x.py 'alter'
321
+ AF=$(approvals w4 zayne)
322
+ run_hold "$R" 'main...pr' --approvals "$AF"
323
+ check "waiver: absent config means NO waiver possible (exit 1)" "$RC" "1"
324
+ fi
325
+
326
+ # ---------------------------------------------------------------------------
327
+ # self-protect — acceptance 4: config + scripts + project.yml + CI defs hard-coded
328
+ # ---------------------------------------------------------------------------
329
+ if running self-protect protected; then
330
+ for f in .forge/hold-config.yml .forge/checks/forge-hold-check.sh .forge/checks/forge-check-lint.sh .forge/checks/tests/forge-hold-check.test.sh .forge/project.yml .github/workflows/ci.yml .gitlab-ci.yml; do
331
+ R=$(new_repo "sp-$(printf '%s' "$f" | tr '/.' '--')")
332
+ pr "$R"
333
+ put "$R" "$f" 'edited'
334
+ run_hold "$R" 'main...pr'
335
+ check "self-protect: $f edit blocks (exit 1)" "$RC" "1"
336
+ check_contains "self-protect: $f named as protected fact" "$OUT" "fact=protected:$f"
337
+ done
338
+
339
+ # Config ADDS deploy path (additive; base side)
340
+ R=$(new_repo sp-deploy)
341
+ put "$R" .forge/hold-config.yml 'version: 1
342
+ operators: []
343
+ irreversible_paths: []
344
+ deploy_paths:
345
+ - "deploy/*"
346
+ gating_tests: []'
347
+ pr "$R"
348
+ put "$R" deploy/prod.tf 'resource {}'
349
+ run_hold "$R" 'main...pr'
350
+ check "self-protect: config-added deploy path fires" "$RC" "1"
351
+ check_contains "self-protect: deploy fact" "$OUT" "fact=protected:deploy/prod.tf"
352
+ fi
353
+
354
+ # ---------------------------------------------------------------------------
355
+ # docs-only — acceptance 6: a status on every PR; clean exits 0 silent
356
+ # ---------------------------------------------------------------------------
357
+ if running docs-only; then
358
+ R=$(new_repo docs)
359
+ put "$R" README.md 'hello'
360
+ pr "$R"
361
+ put "$R" README.md 'hello world'
362
+ put "$R" docs/guide.md 'guide'
363
+ run_hold "$R" 'main...pr'
364
+ check "docs-only: exit 0" "$RC" "0"
365
+ check "docs-only: exactly hold=clear, nothing else" "$OUT" "hold=clear"
366
+ fi
367
+
368
+ # ---------------------------------------------------------------------------
369
+ # multi — a risky PR names EVERY fact, one line each
370
+ # ---------------------------------------------------------------------------
371
+ if running multi; then
372
+ R=$(new_repo multi)
373
+ put "$R" package.json '{"dependencies": {"a": "1.0.0"}}'
374
+ pr "$R"
375
+ put "$R" package.json '{"dependencies": {"a": "1.0.0", "b": "2.0.0"}}'
376
+ put "$R" db/migrations/005.sql 'ALTER TABLE'
377
+ put "$R" .github/workflows/deploy.yml 'on: push'
378
+ run_hold "$R" 'main...pr'
379
+ check "multi: blocked (exit 1)" "$RC" "1"
380
+ check_contains "multi: irreversible named" "$OUT" "fact=irreversible:db/migrations/005.sql"
381
+ check_contains "multi: structural named" "$OUT" "fact=structural:package.json"
382
+ check_contains "multi: protected named" "$OUT" "fact=protected:.github/workflows/deploy.yml"
383
+ fi
384
+
385
+ # ---------------------------------------------------------------------------
386
+ # invocation — exit 2 on bad usage, never a false verdict
387
+ # ---------------------------------------------------------------------------
388
+ if running invocation; then
389
+ R=$(new_repo inv)
390
+ run_hold "$R"
391
+ check "invocation: missing range exits 2" "$RC" "2"
392
+ run_hold "$R" 'main..pr'
393
+ check "invocation: two-dot range rejected (exit 2)" "$RC" "2"
394
+ run_hold "$R" 'nosuch...branches'
395
+ check "invocation: unresolvable refs exit 2" "$RC" "2"
396
+ pr "$R"
397
+ put "$R" src/x.js 'x'
398
+ run_hold "$R" 'main...pr' --approvals "$ROOT/definitely-missing-file"
399
+ check "invocation: missing --approvals file exits 2 (derivation failed = loud)" "$RC" "2"
400
+ fi
401
+
402
+ # ---------------------------------------------------------------------------
403
+ printf '\n%d selected case block(s): %d passed, %d failed\n' "$SELECTED" "$PASSED" "$FAILED"
404
+ [ "$FAILED" -eq 0 ] && [ "$SELECTED" -gt 0 ]
@@ -4,7 +4,12 @@
4
4
  # project state survives machine loss and can be pulled from any clone:
5
5
  # FORGE.md project.yml constitution.md design-system.md context.md roadmap.yml
6
6
  # requirements/ phases/ research/ decisions/ contracts/ audits/
7
- # refactor-backlog.yml state/ templates/ migrations/
7
+ # refactor-backlog.yml state/ templates/ migrations/ checks/
8
+ #
9
+ # checks/ (the merge-floor CI check scripts + fixture suites) is deliberately
10
+ # HERE and not in .claude/hooks/: many projects gitignore .claude/hooks/ as
11
+ # per-developer files, and an ignored script can neither run in CI nor be
12
+ # guarded by the hold check's protected-path self-protection.
8
13
  #
9
14
  # The entries below are the ONLY ephemeral exceptions — session- or
10
15
  # machine-local artifacts that must never be committed.
@@ -0,0 +1,103 @@
1
+ # Migration Guide: Render-on-read state registries (Forge 0.53.0)
2
+
3
+ Applies to projects whose derived registries — `.forge/state/index.yml` and/or
4
+ `.forge/streams/active.yml` — are still **git-tracked**. Both files were already
5
+ `DERIVED / DO NOT HAND-EDIT` projections; 0.53.0 makes them **git-ignored
6
+ render-on-read caches**: regenerated at every `forge` boot from their sources and
7
+ **never committed**. Because the value is computed on read and never stored in
8
+ git, the two-registry drift class — a committed projection going stale against
9
+ its source (e.g. a committed `active.yml` phase reading `researching` while the
10
+ milestone had moved to `discussing`) — can no longer occur.
11
+
12
+ Sources are unchanged: `index.yml` ← `.forge/state/milestone-*.yml` (Rollup 1.0);
13
+ `active.yml` ← `.forge/streams/*.yml` + active milestones (Stream Rollup). Only
14
+ the *storage* changes — tracked file → local cache.
15
+
16
+ Projects that never committed these files (fresh 0.53.0+ installs) need nothing.
17
+
18
+ ## Prerequisites
19
+
20
+ 1. On the new version's framework files (`npx forge-orkes upgrade`, or the
21
+ `upgrading` skill) — so the updated `.gitignore` entries and the render-then-read
22
+ `deferred`/`planning` skills are present.
23
+ 2. Working tree clean or changes committed — the migration stops tracking two
24
+ files and edits `.gitignore`.
25
+
26
+ ## Detection
27
+
28
+ Keyed on **git-tracking state**, not a content grep: the `DERIVED / DO NOT
29
+ HAND-EDIT` header is identical before and after, so only `git ls-files` can tell
30
+ a committed-pre-0.53.0 registry from a git-ignored-post-0.53.0 one. Fires while
31
+ either registry is still tracked; silent once both are untracked (already
32
+ migrated), and silent in a non-Forge / non-git tree:
33
+
34
+ ```bash
35
+ git rev-parse --is-inside-work-tree >/dev/null 2>&1 || exit 0
36
+ migrate=0
37
+ for f in .forge/state/index.yml .forge/streams/active.yml; do
38
+ if git ls-files --error-unmatch "$f" >/dev/null 2>&1; then
39
+ echo "MIGRATE — $f is still git-tracked (pre-0.53.0); render-on-read makes it a git-ignored cache regenerated at boot"
40
+ migrate=1
41
+ fi
42
+ done
43
+ [ "$migrate" -eq 0 ] && exit 0
44
+ ```
45
+
46
+ ## Migration steps
47
+
48
+ ### 1. Stop tracking the two registries (keep them on disk)
49
+
50
+ ```bash
51
+ git rm --cached .forge/state/index.yml .forge/streams/active.yml 2>/dev/null
52
+ ```
53
+
54
+ `--cached` removes them from the index only — the working-copy files stay as a
55
+ local cache and are re-rendered on the next boot. (If either was never tracked,
56
+ the command is a harmless no-op for that path.)
57
+
58
+ ### 2. Git-ignore both derived registries
59
+
60
+ Add to the project's root `.gitignore` (paths relative to the repo root):
61
+
62
+ ```
63
+ .forge/state/index.yml
64
+ .forge/streams/active.yml
65
+ ```
66
+
67
+ Consumer projects created from `create-forge` instead gain these under
68
+ `.forge/.gitignore` (paths relative to `.forge/`):
69
+
70
+ ```
71
+ state/index.yml
72
+ streams/active.yml
73
+ ```
74
+
75
+ Without this, the next `git add .forge/` would re-commit the caches and reopen
76
+ the drift class.
77
+
78
+ ### 3. Confirm the readers render-then-read
79
+
80
+ `forge` boot and `chief-of-staff` already render before reading. In 0.53.0 the
81
+ two direct readers are converted too: `deferred` and `planning` no longer treat a
82
+ missing `index.yml` as "not a Forge project" — the project sentinel moved to the
83
+ committed `.forge/project.yml`, and they derive milestone state from
84
+ `.forge/state/milestone-*.yml` (rendering the cache first) when `index.yml` is
85
+ absent. These ship with the framework upgrade; no per-project edit is needed.
86
+
87
+ ### 4. Regenerate the caches
88
+
89
+ Run `/forge` (boot) — it runs Rollup 1.0 and the Stream Rollup and rewrites both
90
+ files from their sources. From here they are regenerated on read, never committed.
91
+
92
+ ## Validation
93
+
94
+ - `git ls-files --error-unmatch .forge/state/index.yml` exits **non-zero**
95
+ (untracked); same for `.forge/streams/active.yml`.
96
+ - `git check-ignore .forge/state/index.yml` and `git check-ignore
97
+ .forge/streams/active.yml` each exit **0** (ignored).
98
+ - Both files still exist on disk after a `/forge` boot (rendered cache).
99
+ - A fresh clone with no prior boot: `deferred` and `planning` do **not** print
100
+ "No forge state found" (they key the sentinel on `project.yml`).
101
+ - Editing a milestone's phase and booting yields an `active.yml` whose row phase
102
+ matches the milestone — the projection can no longer be stale, because it is
103
+ never stored.
@@ -0,0 +1,61 @@
1
+ # Migration Guide: Merge-floor checks relocate to `.forge/checks/` (Forge 0.58.2)
2
+
3
+ Applies to projects that received the merge-floor check scripts —
4
+ `forge-hold-check.sh`, `forge-check-lint.sh` + their fixture suites — under
5
+ `.claude/hooks/` (shipped there in 0.57.0–0.58.1), and/or wired CI at that path.
6
+
7
+ 0.58.2 moves them to **`.forge/checks/`** (scripts at the top, suites under
8
+ `tests/`). Why: many projects gitignore `.claude/hooks/` as per-developer files
9
+ (host-verified on ptnrkit). A gitignored script never reaches the repo CI
10
+ clones — the *required check can't run* — and, invisible to the PR diff, the
11
+ hold check's self-protection can't guard it either. `.forge/**` is committed in
12
+ installed projects, so `.forge/checks/` is CI-runnable and sits inside the hold
13
+ check's hard-coded protected set (`.forge/checks/**`, replacing the two old
14
+ `.claude/hooks/` entries).
15
+
16
+ The upgrade (npm bin or `/upgrading` Step 4a) **adds** `.forge/checks/` but —
17
+ additive by design — never deletes the old copies. This guide is the cleanup.
18
+
19
+ ## Detection
20
+
21
+ Fires when an old-path copy or an old-path CI reference is still present:
22
+
23
+ ```bash
24
+ migrate=0
25
+ for f in .claude/hooks/forge-hold-check.sh .claude/hooks/forge-check-lint.sh \
26
+ .claude/hooks/tests/forge-hold-check.test.sh .claude/hooks/tests/forge-check-lint.test.sh; do
27
+ [ -f "$f" ] && { echo "MIGRATE — stale relocation leftover: $f"; migrate=1; }
28
+ done
29
+ if grep -rl '\.claude/hooks/forge-\(hold-check\|check-lint\)\.sh' \
30
+ .github/workflows .gitlab-ci.yml 2>/dev/null; then
31
+ echo "MIGRATE — CI still invokes the old path"; migrate=1
32
+ fi
33
+ [ "$migrate" -eq 0 ] && echo "OK — already migrated (or never had the checks)"
34
+ ```
35
+
36
+ ## Steps
37
+
38
+ 1. **Upgrade first** (`npx forge-orkes upgrade`, or `/upgrading`) so
39
+ `.forge/checks/` exists with the 0.58.2 scripts.
40
+ 2. **Commit `.forge/checks/`** — it is *meant to be tracked*, unlike
41
+ `.claude/hooks/`: `git add .forge/checks && git commit -m "chore(forge): track merge-floor checks at .forge/checks/"`.
42
+ 3. **Delete the old copies** listed by the detection block (plus
43
+ `git rm --cached` them first if your project tracks `.claude/hooks/`).
44
+ 4. **Re-point CI** — in `.github/workflows/merge-floor.yml` / `.gitlab-ci.yml`,
45
+ change `./.claude/hooks/forge-{hold-check,check-lint}.sh` →
46
+ `./.forge/checks/forge-{hold-check,check-lint}.sh` (full snippets:
47
+ `docs/merge-floor-checks.md` §5–6). Expect the PR carrying this CI edit to
48
+ raise a **protected-path hold on itself** — that is the self-protection
49
+ working; the operator's PR approval waives it.
50
+ 5. If your `.forge/hold-config.yml` `gating_tests:`/`deploy_paths:` lists name
51
+ the old paths (unusual), update them too.
52
+
53
+ Projects that gitignored `.claude/hooks/` never had the scripts in CI at all —
54
+ for them steps 3–5 reduce to wiring CI at `.forge/checks/` for the first time.
55
+
56
+ ## Verify
57
+
58
+ ```bash
59
+ ./.forge/checks/tests/forge-hold-check.test.sh && ./.forge/checks/tests/forge-check-lint.test.sh
60
+ git ls-files --error-unmatch .forge/checks/forge-hold-check.sh # tracked = ✓
61
+ ```
@@ -0,0 +1,42 @@
1
+ # Migration Guide: Render-on-read registries catch-up (Forge 0.58.3)
2
+
3
+ Applies to projects that upgraded **across 0.53.0 while the 0.53.0 migration
4
+ guide was missing from the shipped template** (missing 0.53.0–0.58.2; restored
5
+ in 0.58.3). For those projects, `/upgrading` Step 7's version-range detection
6
+ (`installed < v <= source`) fired for the crossed range but had no
7
+ `0.53.0-*.md` guide on disk to surface — so the render-on-read registry
8
+ migration (untrack `.forge/state/index.yml` + `.forge/streams/active.yml`) was
9
+ silently skipped, and range detection never looks backward at an
10
+ already-crossed version. Host-verified on ptnrkit: stamped 0.58.2 with both
11
+ derived registries still git-tracked and actively committed.
12
+
13
+ This guide exists solely to re-surface that skipped migration. It re-runs the
14
+ same detection; the actual steps live in the (now-delivered)
15
+ `0.53.0-render-on-read-registries.md` alongside this file.
16
+
17
+ ## Detection
18
+
19
+ Identical to the 0.53.0 guide — keyed on git-tracking state. Silent once both
20
+ registries are untracked (migrated, or a fresh ≥0.53.0 install):
21
+
22
+ ```bash
23
+ git rev-parse --is-inside-work-tree >/dev/null 2>&1 || exit 0
24
+ migrate=0
25
+ for f in .forge/state/index.yml .forge/streams/active.yml; do
26
+ if git ls-files --error-unmatch "$f" >/dev/null 2>&1; then
27
+ echo "MIGRATE — $f is still git-tracked; the 0.53.0 render-on-read migration was skipped"
28
+ migrate=1
29
+ fi
30
+ done
31
+ [ "$migrate" -eq 0 ] && echo "OK — already migrated (or never tracked the registries)"
32
+ ```
33
+
34
+ ## Steps
35
+
36
+ Detection fired → follow `0.53.0-render-on-read-registries.md` (same
37
+ directory) end to end: `git rm --cached` the two registries, gitignore them
38
+ (root `.gitignore`, or `state/index.yml` + `streams/active.yml` in
39
+ `.forge/.gitignore` for create-forge installs), then boot `/forge` to
40
+ regenerate the caches. Its Validation section applies unchanged.
41
+
42
+ Detection silent → nothing to do; this guide is a no-op.
@@ -0,0 +1,34 @@
1
+ # Forge hold-config — per-repo path lists for the merge-floor hold check
2
+ # (.forge/checks/forge-hold-check.sh; see docs/merge-floor-checks.md).
3
+ #
4
+ # ADDITIVE ONLY: every list below UNIONS with the check's hard-coded built-ins —
5
+ # nothing here can remove a hold class (raise-only by construction, D-9/A4d).
6
+ #
7
+ # SELF-PROTECTED: this file's path and the whole .forge/checks/** subtree (both
8
+ # check scripts + their fixture suites) are hard-coded into the check's
9
+ # protected set. A PR that edits any of them raises a protected-path hold on
10
+ # itself — editing the watchdog's rules wakes the watchdog (fold-pass F4/F5
11
+ # lesson). The check reads this file from the BASE side of the diff, so a PR
12
+ # cannot rewrite the rules it is judged by.
13
+ version: 1
14
+
15
+ # Logins whose PR review approval waives a hold (the recorded operator act, B2).
16
+ # Empty ⇒ no waiver is possible — holds block until this is configured.
17
+ operators: []
18
+ # - your-login
19
+
20
+ # Extra irreversible-class paths (migrations, schema, data-mutation scripts)
21
+ # beyond the built-ins (*/migrations/*, */migrate/*, db/schema*, *.sql).
22
+ irreversible_paths: []
23
+ # - "db/seeds/*"
24
+
25
+ # Deploy config paths (protected class) beyond the built-in CI-definition set
26
+ # (.github/workflows/*, .gitlab-ci.yml).
27
+ deploy_paths: []
28
+ # - "deploy/*"
29
+ # - "Dockerfile"
30
+
31
+ # Gating test files (protected class; also narrows the check-quality lint's
32
+ # deleted-assertion scope when set — default there is EVERY test file in the diff).
33
+ gating_tests: []
34
+ # - "tests/e2e/*"