forge-orkes 0.77.0 → 0.80.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 (41) hide show
  1. package/bin/create-forge.js +27 -0
  2. package/experimental/m10/README.md +4 -4
  3. package/experimental/m10/install.sh +10 -9
  4. package/experimental/m10/source/hooks/protect-primary-checkout.sh +149 -0
  5. package/package.json +1 -1
  6. package/template/.claude/hooks/forge-context-migrate.sh +297 -0
  7. package/template/.claude/hooks/forge-size-gate.sh +175 -0
  8. package/template/.claude/hooks/forge-state-rollup.sh +135 -2
  9. package/template/.claude/hooks/protect-primary-checkout.sh +149 -0
  10. package/template/.claude/hooks/skill-gate-tiers.txt +6 -0
  11. package/template/.claude/hooks/tests/forge-context-migrate.test.sh +166 -0
  12. package/template/.claude/hooks/tests/forge-size-gate.test.sh +132 -0
  13. package/template/.claude/hooks/tests/protect-primary-checkout.test.sh +147 -0
  14. package/template/.claude/settings.json +14 -1
  15. package/template/.claude/skills/discussing/SKILL.md +30 -31
  16. package/template/.claude/skills/executing/SKILL.md +3 -3
  17. package/template/.claude/skills/forge/SKILL.md +112 -170
  18. package/template/.claude/skills/forge/references/manual-fallback-procedures.md +54 -0
  19. package/template/.claude/skills/initializing/SKILL.md +20 -1
  20. package/template/.claude/skills/planning/SKILL.md +11 -9
  21. package/template/.claude/skills/researching/SKILL.md +2 -0
  22. package/template/.claude/skills/reviewing/SKILL.md +17 -0
  23. package/template/.claude/skills/upgrading/SKILL.md +22 -2
  24. package/template/.claude/skills/verifying/SKILL.md +2 -2
  25. package/template/.forge/FORGE.md +81 -90
  26. package/template/.forge/bin/new-worktree.sh +61 -0
  27. package/template/.forge/bin/tests/new-worktree.test.sh +70 -0
  28. package/template/.forge/checks/forge-jarvis-awareness.sh +46 -2
  29. package/template/.forge/checks/forge-jarvis-relay.sh +64 -16
  30. package/template/.forge/checks/tests/forge-jarvis-awareness.test.sh +58 -0
  31. package/template/.forge/checks/tests/forge-jarvis-relay.test.sh +50 -0
  32. package/template/.forge/git-hooks/pre-commit +57 -0
  33. package/template/.forge/git-hooks/tests/pre-commit.test.sh +97 -0
  34. package/template/.forge/migrations/0.78.0-weld-primary-checkout.md +66 -0
  35. package/template/.forge/migrations/0.80.0-context-md-sharding.md +158 -0
  36. package/template/.forge/migrations/0.80.0-size-gate-wiring.md +145 -0
  37. package/template/.forge/templates/constitution.md +1 -1
  38. package/template/.forge/templates/context-milestone.md +30 -0
  39. package/template/.forge/templates/slice-runner/config.yml +7 -1
  40. package/template/.forge/templates/state/desire-path.yml +3 -0
  41. package/experimental/m10/source/hooks/forge-branch-guard.sh +0 -61
@@ -66,23 +66,71 @@ payload_claims_landed() { # payload_claims_landed <payload>
66
66
  printf '%s' "${1:-}" | grep -qiE 'merged|landed|validated|promoted|on main|is live|shipped|deployed'
67
67
  }
68
68
 
69
+ # ── milestone name lookup (issue #34 — an operator with 40+ milestones in flight can't tell
70
+ # which slice a bare buzz is about). The awk extraction is kept textually aligned with
71
+ # milestone_name in forge-jarvis-awareness.sh — same guard, same nested `milestone: / name:`
72
+ # parse, same degrade-to-empty on any miss. <forge-dir> here is the slice's `.forge` directory
73
+ # (NOT the worktree root — see the two-dirname derivation in build_relay_text below); the state
74
+ # file lives at <forge-dir>/state/milestone-<id>.yml. ────────────────────────────────────────
75
+ milestone_name() { # milestone_name <milestone-id> <forge-dir>
76
+ _mid="${1:-}"; _fd="${2:-}"
77
+ [ -n "$_mid" ] && [ -n "$_fd" ] || { printf ''; return 0; }
78
+ _id="${_mid#m-}"
79
+ _f="$_fd/state/milestone-$_id.yml"
80
+ [ -f "$_f" ] || { printf ''; return 0; }
81
+ awk '
82
+ /^milestone:/ { inp=1; next }
83
+ inp && /^[A-Za-z]/ { inp=0 }
84
+ inp && /^[[:space:]]+name:/ {
85
+ sub(/^[[:space:]]*[^:]+:[[:space:]]*/, "")
86
+ sub(/[[:space:]]*#.*/, "")
87
+ gsub(/^["'"'"']|["'"'"']$/, "")
88
+ print; exit
89
+ }
90
+ ' "$_f"
91
+ }
92
+
93
+ # ── "m-<id> — <name>", truncated ~60 chars; degrades to the bare id (never a blank "m- — "
94
+ # prefix) when the name can't be resolved or the id itself is empty. ─────────────────────────
95
+ named_milestone() { # named_milestone <milestone-id> <forge-dir>
96
+ _mid="${1:-}"; _fd="${2:-}"
97
+ [ -n "$_mid" ] || { printf ''; return 0; }
98
+ _nm="$(milestone_name "$_mid" "$_fd")"
99
+ if [ -n "$_nm" ]; then
100
+ printf '%s' "$_mid — $_nm" | cut -c1-60
101
+ else
102
+ printf '%s' "$_mid"
103
+ fi
104
+ }
105
+
69
106
  # ── build the relay text for ONE parsed ping (the pure host-truth text-builder) ───────────────
70
- # Reads: $2=type $3=payload ; $MILESTONE (optional, for fold). Prints one line to stdout.
71
- build_relay_text() { # build_relay_text <type> <payload>
72
- _type="${1:-note}"; _payload="${2:-}"
107
+ # Reads: $2=type $3=payload $4=log-path (optional) ; $MILESTONE (optional, for fold + naming).
108
+ # Prints one line to stdout, prefixed "m-<id> <name>: " when a name can be resolved (issue #34)
109
+ # — degrades to a bare "m-<id>: " prefix, or no prefix at all when $MILESTONE is also empty.
110
+ build_relay_text() { # build_relay_text <type> <payload> [<log-path>]
111
+ _type="${1:-note}"; _payload="${2:-}"; _LOG="${3:-}"
112
+ if [ -n "$_LOG" ]; then
113
+ # mode_scan's log path is always <worktree>/.forge/runner-work/notify.log — two dirnames
114
+ # land on <worktree>/.forge (the forge-dir milestone_name expects), not the worktree root.
115
+ _fd="$(dirname "$(dirname "$_LOG")")"
116
+ _named="$(named_milestone "${MILESTONE:-}" "$_fd")"
117
+ else
118
+ _named="${MILESTONE:-}"
119
+ fi
120
+ if [ -n "$_named" ]; then _prefix="$_named: "; else _prefix=""; fi
73
121
  case "$_type" in
74
122
  ambiguity)
75
123
  # A QUESTION — relay verbatim, named. Legal: it is not a state claim (acceptance #2).
76
- printf '❓ Needs you (ambiguity): %s' "$_payload" ;;
124
+ printf '%s❓ Needs you (ambiguity): %s' "$_prefix" "$_payload" ;;
77
125
  fork)
78
126
  # payload is a PATH to a sketch the operator LOOKS AT (§8) — name it.
79
- printf '🎨 Design fork — review the sketch: %s' "$_payload" ;;
127
+ printf '%s🎨 Design fork — review the sketch: %s' "$_prefix" "$_payload" ;;
80
128
  irreversible)
81
129
  # a gate on an irreversible act — your call; relay verbatim, named.
82
- printf '⚠️ Irreversible action — your call: %s' "$_payload" ;;
130
+ printf '%s⚠️ Irreversible action — your call: %s' "$_prefix" "$_payload" ;;
83
131
  budget)
84
132
  # the runner's OWN spend tally — no host source to recompute against; relay verbatim, named.
85
- printf '💸 Budget cap reached: %s' "$_payload" ;;
133
+ printf '%s💸 Budget cap reached: %s' "$_prefix" "$_payload" ;;
86
134
  done|halt)
87
135
  # STATE-CLAIM territory. Recompute host truth FIRST; build the claim from it, not the payload.
88
136
  _rs="$(host_release_state "${MILESTONE:-}")"
@@ -90,25 +138,25 @@ build_relay_text() { # build_relay_text <type> <payload>
90
138
  if payload_claims_landed "$_payload"; then
91
139
  if [ "$_rs" = unmerged ]; then
92
140
  # The standing lesson: payload says merged, host says not. Relay the host, loudly.
93
- printf '⚠️ DISCREPANCY — payload claims landed/merged, but host shows release_state=unmerged. %s (host truth wins). Payload said: %s' "$_base" "$_payload"
141
+ printf '%s⚠️ DISCREPANCY — payload claims landed/merged, but host shows release_state=unmerged. %s (host truth wins). Payload said: %s' "$_prefix" "$_base" "$_payload"
94
142
  elif [ "$_rs" = unknown ]; then
95
- printf '⚠️ %s — payload claims landed/merged but the host could NOT be verified (release_state=unknown). Payload said: %s' "$_base" "$_payload"
143
+ printf '%s⚠️ %s — payload claims landed/merged but the host could NOT be verified (release_state=unknown). Payload said: %s' "$_prefix" "$_base" "$_payload"
96
144
  else
97
145
  # payload claims landed AND host agrees (merged/validated) — no discrepancy.
98
- printf '%s — host confirms: release_state=%s. %s' "$_base" "$_rs" "$_payload"
146
+ printf '%s%s — host confirms: release_state=%s. %s' "$_prefix" "$_base" "$_rs" "$_payload"
99
147
  fi
100
148
  else
101
- printf '%s — host: release_state=%s. %s' "$_base" "$_rs" "$_payload"
149
+ printf '%s%s — host: release_state=%s. %s' "$_prefix" "$_base" "$_rs" "$_payload"
102
150
  fi ;;
103
151
  *)
104
152
  # unknown type — relay raw, named (never silently drop).
105
- printf '(%s): %s' "$_type" "$_payload" ;;
153
+ printf '%s(%s): %s' "$_prefix" "$_type" "$_payload" ;;
106
154
  esac
107
155
  }
108
156
 
109
157
  # Parse a TSV log line (ts<TAB>type<TAB>payload) then build text. Prints one line.
110
- relay_one_line() { # relay_one_line <tsv-line>
111
- _line="$1"
158
+ relay_one_line() { # relay_one_line <tsv-line> [<log-path>]
159
+ _line="$1"; _LOG="${2:-}"
112
160
  _type="$(printf '%s' "$_line" | cut -f2)"
113
161
  _payload="$(printf '%s' "$_line" | cut -f3-)"
114
162
  # A malformed line with no tabs → cut returns the whole line for f2/f3; treat as a raw note.
@@ -116,7 +164,7 @@ relay_one_line() { # relay_one_line <tsv-line>
116
164
  *" "*) : ;; # has a tab — well-formed
117
165
  *) _type='note'; _payload="$_line" ;;
118
166
  esac
119
- build_relay_text "$_type" "$_payload"
167
+ build_relay_text "$_type" "$_payload" "$_LOG"
120
168
  }
121
169
 
122
170
  # ── modes — one function per mode, each owning its own arg loop ───────────────────────────────
@@ -211,7 +259,7 @@ mode_scan() {
211
259
  _n=$((_n + 1))
212
260
  _marker="$MARKER_DIR/$_n.relayed"
213
261
  [ -f "$_marker" ] && continue # at-most-once: already relayed, never re-buzz
214
- _text="$(relay_one_line "$_l")"
262
+ _text="$(relay_one_line "$_l" "$LOG")"
215
263
  # Write the marker BEFORE emitting — a crash after emit must not double-buzz; a crash
216
264
  # before emit leaves it unmarked and it re-surfaces as while-you-were-away (no silent loss).
217
265
  # A FAILED marker write must not emit either: emitting unmarked would re-buzz on every
@@ -167,5 +167,63 @@ else
167
167
  bad 'guard: bare-number branch fix-issue-2 (no m- token) is not discovered' "$OUT"
168
168
  fi
169
169
 
170
+ # --- 13-16. NAMED MILESTONES (issue #34): "m-<id> — <name>" at both print sites, degrading to
171
+ # the bare id when the name can't be resolved — no crash either way. ------------------------
172
+ WTN="$ROOT/worktrees-named"; mkdir -p "$WTN/m-50/.forge/state" "$WTN/m-51"
173
+ cat > "$WTN/m-50/.forge/state/milestone-50.yml" <<'EOF'
174
+ milestone:
175
+ id: m-50
176
+ name: "Named milestone fixture"
177
+ origin: null
178
+
179
+ current:
180
+ status: executing
181
+ EOF
182
+ # m-51 has NO .forge/state/milestone-51.yml — the no-file degrade case.
183
+
184
+ OUT="$(FORGE_JARVIS_FOLD="$FOLDSTUB" FORGE_JARVIS_BOARD="$BOARDSTUB" \
185
+ sh "$AWARE" --dry-run --worktree-root "$WTN" 2>&1)"
186
+
187
+ # 13. slice-list row WITH a resolvable milestone.name shows "m-<id> — <name>"
188
+ if printf '%s' "$OUT" | grep -q 'm-50 — Named milestone fixture'; then
189
+ ok 'slice-list: milestone with a resolvable name renders "m-<id> — <name>"'
190
+ else
191
+ bad 'slice-list: milestone with a resolvable name renders "m-<id> — <name>"' "$OUT"
192
+ fi
193
+
194
+ # 14. slice-list row with NO milestone state file still renders — degrades to the bare id
195
+ if printf '%s' "$OUT" | grep -qE '(^|[[:space:]])m-51([[:space:]]|$)'; then
196
+ ok 'slice-list: no state file degrades to the bare id, no crash'
197
+ else
198
+ bad 'slice-list: no state file degrades to the bare id, no crash' "$OUT"
199
+ fi
200
+
201
+ # stub board: one row WITH a "name" field, one row WITHOUT — pins the board-side degrade
202
+ BOARDSTUB_NAMED="$ROOT/board-named.sh"
203
+ cat > "$BOARDSTUB_NAMED" <<'EOF'
204
+ #!/bin/sh
205
+ [ "$1" = "--emit" ] || exit 2
206
+ printf '{"forge_id": "m-60", "status": "executing", "release_state": "unmerged", "name": "Board-named unit"}\n'
207
+ printf '{"forge_id": "m-61", "status": "executing", "release_state": "unmerged"}\n'
208
+ EOF
209
+ chmod +x "$BOARDSTUB_NAMED"
210
+
211
+ OUT="$(FORGE_JARVIS_FOLD="$FOLDSTUB" FORGE_JARVIS_BOARD="$BOARDSTUB_NAMED" \
212
+ sh "$AWARE" --worktree-root "$EMPTY" 2>&1)"
213
+
214
+ # 15. board-projection row WITH a "name" field shows "m-<id> — <name>"
215
+ if printf '%s' "$OUT" | grep -q 'm-60 — Board-named unit status=executing'; then
216
+ ok 'board projection: a row with a name field renders "m-<id> — <name>"'
217
+ else
218
+ bad 'board projection: a row with a name field renders "m-<id> — <name>"' "$OUT"
219
+ fi
220
+
221
+ # 16. board-projection row missing the "name" field degrades to the bare id (no crash)
222
+ if printf '%s' "$OUT" | grep -q 'm-61 status=executing' && ! printf '%s' "$OUT" | grep -q 'm-61 — '; then
223
+ ok 'board projection: no name field degrades to the bare id, no crash'
224
+ else
225
+ bad 'board projection: no name field degrades to the bare id, no crash' "$OUT"
226
+ fi
227
+
170
228
  printf '\nforge-jarvis-awareness.test.sh: %s passed, %s failed\n' "$PASSED" "$FAILED"
171
229
  [ "$FAILED" -eq 0 ]
@@ -160,6 +160,56 @@ else
160
160
  bad "guard: bare-number branch fix-issue-2 (no m- token) emits no log path" "$out"
161
161
  fi
162
162
 
163
+ # --- 16-19. NAMED MILESTONES (issue #34): build_relay_text prefixes "m-<id> — <name>" onto
164
+ # every buzz type, resolving the name from $LOG's worktree root; degrades to the bare
165
+ # "m-<id>" prefix when the state file can't be resolved — never a crash either way. ----------
166
+ WTN="$ROOT/slice-wt-named"; mkdir -p "$WTN/.forge/state" "$WTN/.forge/runner-work"
167
+ cat > "$WTN/.forge/state/milestone-77.yml" <<'EOF'
168
+ milestone:
169
+ id: m-77
170
+ name: "Named relay fixture"
171
+ origin: null
172
+
173
+ current:
174
+ status: executing
175
+ EOF
176
+ LOGN="$WTN/.forge/runner-work/notify.log"
177
+ printf '2026-07-21T10:00:00Z\tambiguity\tJWT or sessions?\n' >> "$LOGN"
178
+ printf '2026-07-21T10:01:00Z\tdone\tPR opened\n' >> "$LOGN"
179
+ printf '2026-07-21T10:02:00Z\thalt\tstuck\n' >> "$LOGN"
180
+
181
+ scan_named() { FORGE_JARVIS_FOLD="$FOLD_UNMERGED" sh "$RELAY" --scan "$LOGN" --milestone m-77 2>/dev/null; }
182
+ out="$(scan_named)"
183
+
184
+ # 16. ambiguity buzz is prefixed "m-<id> — <name>"
185
+ case "$out" in
186
+ *"m-77 — Named relay fixture: "*"Needs you (ambiguity)"*) ok "ambiguity buzz prefixed with the resolved milestone name" ;;
187
+ *) bad "ambiguity buzz prefixed with the resolved milestone name" "$out" ;;
188
+ esac
189
+
190
+ # 17. done buzz is prefixed "m-<id> — <name>"
191
+ case "$out" in
192
+ *"m-77 — Named relay fixture: "*"Slice done"*) ok "done buzz prefixed with the resolved milestone name" ;;
193
+ *) bad "done buzz prefixed with the resolved milestone name" "$out" ;;
194
+ esac
195
+
196
+ # 18. halt buzz is prefixed "m-<id> — <name>"
197
+ case "$out" in
198
+ *"m-77 — Named relay fixture: "*"Slice HALTED"*) ok "halt buzz prefixed with the resolved milestone name" ;;
199
+ *) bad "halt buzz prefixed with the resolved milestone name" "$out" ;;
200
+ esac
201
+
202
+ # 19. a worktree whose milestone state can't be resolved still fires — degrades to the bare
203
+ # "m-<id>" prefix, no crash.
204
+ WTB="$ROOT/slice-wt-bare"; mkdir -p "$WTB/.forge/runner-work" # no .forge/state at all
205
+ LOGB="$WTB/.forge/runner-work/notify.log"
206
+ printf '2026-07-21T10:00:00Z\tbudget\tcap hit\n' >> "$LOGB"
207
+ out2="$(FORGE_JARVIS_FOLD="$FOLD_UNMERGED" sh "$RELAY" --scan "$LOGB" --milestone m-88 2>/dev/null)"
208
+ case "$out2" in
209
+ *"m-88: "*"Budget cap reached"*) ok "unresolvable milestone state degrades to the bare id prefix, no crash" ;;
210
+ *) bad "unresolvable milestone state degrades to the bare id prefix, no crash" "$out2" ;;
211
+ esac
212
+
163
213
  # --- summary -----------------------------------------------------------------
164
214
  printf '\n%s: %d passed, %d failed\n' "$(basename "$0")" "$PASSED" "$FAILED"
165
215
  [ "$FAILED" = 0 ]
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env bash
2
+ # Backstop for the primary-checkout branch weld (issue #21, ADR-031).
3
+ #
4
+ # Refuses commits made in the PRIMARY checkout while HEAD is not the default branch
5
+ # (or is detached). The Claude PreToolUse hook (.claude/hooks/protect-primary-checkout.sh)
6
+ # blocks branch create/switch at the source; this git hook catches anything that slips
7
+ # past it — humans, non-agent tools, cd-inside-compound-command races — at the moment
8
+ # damage would occur: a commit landing on a branch some other concurrent session owns.
9
+ #
10
+ # Linked worktrees are unaffected (git-dir != git-common-dir there — their own HEAD is
11
+ # the whole point). Commits ON the default branch in the primary checkout remain allowed
12
+ # by design — Forge's own state-sync commits land there.
13
+ #
14
+ # Default branch is resolved dynamically (main/master + init.defaultBranch), never
15
+ # hardcoded. Fails OPEN on any git-probe error (a hook must not wedge commits on its own
16
+ # malfunction). Exit 0 = allow the commit; exit 1 = abort it.
17
+ #
18
+ # Activate once per clone (done by initializing / upgrading):
19
+ # git config core.hooksPath .forge/git-hooks
20
+ # Escape hatch (deliberate, human use): ALLOW_MAIN_BRANCHING=1 git commit ...
21
+
22
+ set -uo pipefail
23
+
24
+ [ "${ALLOW_MAIN_BRANCHING:-0}" = "1" ] && exit 0
25
+
26
+ GIT_DIR=$(git rev-parse --git-dir 2>/dev/null) || exit 0
27
+ COMMON_DIR=$(git rev-parse --git-common-dir 2>/dev/null) || exit 0
28
+ GIT_DIR_ABS=$(cd "$GIT_DIR" 2>/dev/null && pwd) || exit 0
29
+ COMMON_DIR_ABS=$(cd "$COMMON_DIR" 2>/dev/null && pwd) || exit 0
30
+
31
+ # Linked worktree → its own HEAD; branch commits are the whole point. Allow.
32
+ [ "$GIT_DIR_ABS" != "$COMMON_DIR_ABS" ] && exit 0
33
+
34
+ # Default-branch set (dynamic — mirrors protect-primary-checkout.sh).
35
+ DEFAULT_BRANCHES="main master"
36
+ GIT_DEFAULT=$(git config --get init.defaultBranch 2>/dev/null || true)
37
+ [ -n "$GIT_DEFAULT" ] && DEFAULT_BRANCHES="$DEFAULT_BRANCHES $GIT_DEFAULT"
38
+
39
+ BRANCH=$(git branch --show-current 2>/dev/null || true)
40
+
41
+ if [ -z "$BRANCH" ]; then
42
+ echo "✋ Commit blocked: detached HEAD in the primary checkout." >&2
43
+ echo " Return to the default branch (git checkout main) or move this work to a worktree:" >&2
44
+ echo " bash .forge/bin/new-worktree.sh <slug>" >&2
45
+ echo " Human escape hatch: ALLOW_MAIN_BRANCHING=1 git commit ..." >&2
46
+ exit 1
47
+ fi
48
+
49
+ for b in $DEFAULT_BRANCHES; do
50
+ [ "$BRANCH" = "$b" ] && exit 0 # on the default branch → allowed (state-sync commits land here)
51
+ done
52
+
53
+ echo "✋ Commit blocked: the primary checkout must stay on the default branch (you are on '$BRANCH')." >&2
54
+ echo " Another concurrent session may own this branch — committing here is how work gets clobbered." >&2
55
+ echo " Branch work belongs in a worktree: bash .forge/bin/new-worktree.sh <slug>" >&2
56
+ echo " Human escape hatch: ALLOW_MAIN_BRANCHING=1 git commit ..." >&2
57
+ exit 1
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env bash
2
+ # Scratch-repo scenario tests for .forge/git-hooks/pre-commit (issue #21 backstop).
3
+ #
4
+ # Each scenario builds a throwaway repo (and, where needed, a linked worktree), then
5
+ # invokes the pre-commit hook directly from the target dir and asserts its exit code
6
+ # (0 = commit allowed, 1 = commit aborted). No real `git commit` is needed — invoking
7
+ # the hook is deterministic and fast. Self-cleans on exit. Run:
8
+ # ./.forge/git-hooks/tests/pre-commit.test.sh
9
+ # Prints `ALL PASS` and exits 0 iff every scenario passes; non-zero on any failure.
10
+
11
+ set -uo pipefail
12
+
13
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
14
+ HOOK="$SCRIPT_DIR/../pre-commit"
15
+ [ -x "$HOOK" ] || { printf 'FATAL: pre-commit not executable: %s\n' "$HOOK" >&2; exit 1; }
16
+
17
+ ROOT="$(mktemp -d)"
18
+ trap 'rm -rf "$ROOT"' EXIT INT TERM
19
+
20
+ PASSED=0
21
+ FAILED=0
22
+
23
+ new_repo() {
24
+ local d="$ROOT/$1" br="${2:-main}"
25
+ mkdir -p "$d"
26
+ git -C "$d" init -q
27
+ git -C "$d" symbolic-ref HEAD "refs/heads/$br"
28
+ [ "$br" != "main" ] && git -C "$d" config init.defaultBranch "$br"
29
+ git -C "$d" config user.email t@example.com
30
+ git -C "$d" config user.name tester
31
+ : > "$d/seed"
32
+ git -C "$d" add -A && git -C "$d" commit -qm init
33
+ printf '%s\n' "$d"
34
+ }
35
+
36
+ # run_hook <dir> [env-assignment] — run the pre-commit FROM <dir>, return its exit code.
37
+ run_hook() {
38
+ local dir="$1" envset="${2:-}"
39
+ if [ -n "$envset" ]; then
40
+ ( cd "$dir" && env "$envset" "$HOOK" >/dev/null 2>&1 )
41
+ else
42
+ ( cd "$dir" && "$HOOK" >/dev/null 2>&1 )
43
+ fi
44
+ return $?
45
+ }
46
+
47
+ # assert <desc> <expected-exit> <dir> [env]
48
+ assert() {
49
+ local desc="$1" want="$2" dir="$3" envset="${4:-}"
50
+ local got
51
+ run_hook "$dir" "$envset"; got=$?
52
+ if [ "$got" -eq "$want" ]; then
53
+ PASSED=$((PASSED + 1))
54
+ else
55
+ FAILED=$((FAILED + 1))
56
+ printf ' FAIL: %s (got exit %s, want %s)\n' "$desc" "$got" "$want" >&2
57
+ fi
58
+ }
59
+
60
+ ALLOW=0
61
+ BLOCK=1
62
+
63
+ # ── Scenario 1: primary checkout on the default branch → commit allowed ──────────
64
+ P="$(new_repo primary)"
65
+ assert "primary on main: commit allowed" "$ALLOW" "$P"
66
+
67
+ # ── Scenario 2: primary checkout on a feature branch → commit blocked ────────────
68
+ git -C "$P" checkout -q -b feature
69
+ assert "primary on feature branch: commit blocked" "$BLOCK" "$P"
70
+
71
+ # ── Scenario 5 (escape hatch, on the feature branch): allowed ───────────────────
72
+ assert "escape hatch on feature branch: commit allowed" "$ALLOW" "$P" "ALLOW_MAIN_BRANCHING=1"
73
+ git -C "$P" checkout -q main # back home for later worktree add
74
+
75
+ # ── Scenario 3: detached HEAD in the primary checkout → blocked ─────────────────
76
+ D="$(new_repo detached)"
77
+ git -C "$D" checkout -q --detach
78
+ assert "primary detached HEAD: commit blocked" "$BLOCK" "$D"
79
+
80
+ # ── Scenario 4: linked worktree on a feature branch → allowed ────────────────────
81
+ WT="$ROOT/primary-wt"
82
+ git -C "$P" worktree add -q -b wtbranch "$WT" >/dev/null 2>&1
83
+ assert "linked worktree on feature branch: commit allowed" "$ALLOW" "$WT"
84
+
85
+ # ── Bonus: master-default repo on master → allowed; on a branch → blocked ────────
86
+ M="$(new_repo master-primary master)"
87
+ assert "master-default on master: commit allowed" "$ALLOW" "$M"
88
+ git -C "$M" checkout -q -b feat
89
+ assert "master-default on feature branch: commit blocked" "$BLOCK" "$M"
90
+
91
+ # ── Summary ──────────────────────────────────────────────────────────────────────
92
+ printf '\n%d passed, %d failed\n' "$PASSED" "$FAILED"
93
+ if [ "$FAILED" -eq 0 ]; then
94
+ printf 'ALL PASS (incl. escape hatch)\n'
95
+ exit 0
96
+ fi
97
+ exit 1
@@ -0,0 +1,66 @@
1
+ # Migration Guide: Weld the primary checkout to the default branch (Forge 0.78.0)
2
+
3
+ Applies to projects installed before 0.78.0. This milestone (issue #21, ADR-031) welds the primary checkout to its default branch across ALL tiers: a PreToolUse guard denies branch create/switch there, a tracked git `pre-commit` backstop refuses off-default commits there, and a `new-worktree` helper teaches the right path. The `/upgrading` run that installed this guide already synced the hook bodies (`.claude/hooks/protect-primary-checkout.sh`, `.forge/git-hooks/pre-commit`, `.forge/bin/new-worktree.sh`) and merged the settings.json wiring; the one remaining per-clone step is the `core.hooksPath` wiring, which `/upgrading` applies with a detect+warn+never-clobber policy.
4
+
5
+ ## Prerequisites
6
+
7
+ 1. Nothing to hand-edit — the upgrade sync + the `/upgrading` `core.hooksPath` layer ARE the migration.
8
+ 2. If you use Husky / lefthook / a pre-commit framework (i.e. `core.hooksPath` is already set), the wiring will NOT overwrite it — you'll get a warning and can chain `.forge/git-hooks/pre-commit` into your existing hooks dir, or accept PreToolUse-only coverage.
9
+
10
+ ## Detection
11
+
12
+ Prints `MIGRATE` when the primary-checkout weld is not fully wired: the PreToolUse guard is absent from `settings.json`, OR the tracked pre-commit backstop is missing, OR `core.hooksPath` is not pointing at `.forge/git-hooks`. Silent + exit 0 when fully wired.
13
+
14
+ ```bash
15
+ migrate=""
16
+ # (1) PreToolUse guard wired in settings.json?
17
+ if [ -f .claude/settings.json ]; then
18
+ if ! grep -q 'protect-primary-checkout' .claude/settings.json 2>/dev/null; then
19
+ migrate="guard not wired in settings.json"
20
+ fi
21
+ fi
22
+ # (2) tracked pre-commit backstop present?
23
+ if [ ! -f .forge/git-hooks/pre-commit ]; then
24
+ migrate="${migrate:+$migrate; }pre-commit backstop missing (.forge/git-hooks/pre-commit)"
25
+ fi
26
+ # (3) core.hooksPath wired to our dir? (advisory — /upgrading applies this)
27
+ cur=$(git config --get core.hooksPath 2>/dev/null || true)
28
+ if [ "$cur" != ".forge/git-hooks" ]; then
29
+ migrate="${migrate:+$migrate; }core.hooksPath not '.forge/git-hooks' (currently '${cur:-unset}')"
30
+ fi
31
+ if [ -n "$migrate" ]; then
32
+ echo "MIGRATE — primary-checkout weld incomplete: $migrate"
33
+ fi
34
+ ```
35
+
36
+ ## Migration steps
37
+
38
+ ### 1. Re-run the upgrade / initialize
39
+
40
+ - `/upgrading` on 0.78.0+ framework files syncs the hooks, merges the settings.json guard group, and applies the `core.hooksPath` wiring layer (detect+warn+never-clobber). The run that installed this guide already did the file sync; the `core.hooksPath` layer is what completes the weld.
41
+ - On a brand-new project, `initializing` Finalize wires `core.hooksPath` as part of setup.
42
+
43
+ ### 2. If `core.hooksPath` was already set (Husky/lefthook)
44
+
45
+ The wiring will not overwrite it. To keep the pre-commit weld active, chain the Forge hook into your existing hooks directory — e.g. add to your `pre-commit`:
46
+
47
+ ```bash
48
+ # run the Forge primary-checkout backstop, if present
49
+ [ -x .forge/git-hooks/pre-commit ] && .forge/git-hooks/pre-commit || true
50
+ ```
51
+
52
+ Or accept PreToolUse-only coverage: Claude sessions are still guarded by `protect-primary-checkout.sh`; only the human/CI backstop is inactive.
53
+
54
+ ### 3. Verify
55
+
56
+ From the primary checkout on the default branch:
57
+
58
+ ```bash
59
+ # guard denies branch create (exit 2) — run it directly against a payload:
60
+ printf '{"tool_input":{"command":"git checkout -b probe"},"cwd":"'"$(git rev-parse --show-toplevel)"'"}' \
61
+ | .claude/hooks/protect-primary-checkout.sh; echo "exit=$? (expect 2 in the PRIMARY checkout)"
62
+ # pre-commit allows on the default branch:
63
+ .forge/git-hooks/pre-commit; echo "exit=$? (expect 0 on the default branch)"
64
+ ```
65
+
66
+ All branch work now goes through `bash .forge/bin/new-worktree.sh <slug>`. Escape hatch for deliberate human branching: `ALLOW_MAIN_BRANCHING=1`.
@@ -0,0 +1,158 @@
1
+ # Migration Guide: context.md sharded into per-milestone files (Forge 0.80.0)
2
+
3
+ Applies to projects upgrading to 0.80.0 or later. `.forge/context.md` — a
4
+ single monolithic, git-tracked file that accumulated one section per
5
+ milestone's locked decisions — is now a **derived, git-ignored index**
6
+ (ADR-033, issue #37), same class as `.forge/state/index.yml` and
7
+ `.forge/streams/active.yml`. The actual decision content lives in
8
+ `.forge/context/{id}.md`, one file per milestone, produced by a new one-time
9
+ tool: `.claude/hooks/forge-context-migrate.sh`.
10
+
11
+ `/upgrading` ships both new pieces — the migration tool
12
+ (`.claude/hooks/forge-context-migrate.sh`) and the extended
13
+ `.claude/hooks/forge-state-rollup.sh` that renders `context.md` as an index —
14
+ like any other framework file. It does **not** run the migration or touch
15
+ `.forge/context.md`, because both are **user-owned content**: `context.md`'s
16
+ existing prose is a specific repo's real decision history, not something an
17
+ upgrade can safely rewrite or delete on its own.
18
+
19
+ **What actually happens if this migration is skipped.** Read
20
+ `forge-state-rollup.sh`'s render logic directly: the third join
21
+ (`render_context_index`) only runs when `.forge/context/` exists as a
22
+ directory (`if [ -d "$root/.forge/context" ]; then render_context_index; fi`).
23
+ On an unmigrated repo — no `.forge/context/` directory yet — that guard is
24
+ false, so the rollup **never touches `.forge/context.md` at all**: it is not
25
+ overwritten, not cleared, not rendered as an empty index. The old monolithic
26
+ file sits there completely untouched, and the boot summary line simply omits
27
+ the `+ context.md (…)` clause it would otherwise show. There is no
28
+ data-loss risk from the render side; the actual gap is that the file quietly
29
+ stops being what the rest of the framework expects it to be once the repo
30
+ does start sharding (skills read/write `.forge/context/{id}.md` post-0.80.0,
31
+ not the monolithic file) — an unmigrated repo's decision history becomes
32
+ stale and invisible to that new convention until this migration runs.
33
+
34
+ Projects that ran `initializing` fresh on 0.80.0+ start with no
35
+ `.forge/context.md` at all (nothing to migrate). Projects that already ran
36
+ this migration (their own dogfood, or a prior partial adoption) have a
37
+ populated `.forge/context/` directory and need nothing further.
38
+
39
+ ## Prerequisites
40
+
41
+ 1. On the new version's framework files (`npx forge-orkes upgrade`, or the
42
+ `upgrading` skill) — so `.claude/hooks/forge-context-migrate.sh` and the
43
+ extended `.claude/hooks/forge-state-rollup.sh` are present and executable.
44
+ 2. Working tree clean or changes committed — this migration creates new
45
+ tracked files (`context/{id}.md`, `context-log.md`), untracks
46
+ `.forge/context.md`, and edits a gitignore file.
47
+ 3. Read `.forge/context.md` once before running anything, so you know what
48
+ the pre-migration content actually looks like in this repo (heading
49
+ shapes vary historically — `### m-52 — …`, `### M49 — …`, and undated
50
+ drafting blocks with no id all exist in the wild; the migration tool
51
+ handles all three, but it's worth knowing what to expect in the output).
52
+
53
+ ## Detection
54
+
55
+ Keyed on **`.forge/context.md` being git-tracked, non-trivially sized, and
56
+ `.forge/context/` not existing yet** — the pre-migration gap. A byte-count
57
+ floor (500 bytes) guards against false-positiving on a repo that already
58
+ shrank `context.md` down to a near-empty derived index through some other
59
+ path. Silent in a non-Forge / non-git tree, silent on a fresh install (no
60
+ `context.md` at all), silent once `.forge/context/` exists (already
61
+ migrated), silent once `context.md` is untracked (already gitignored, i.e.
62
+ already migrated):
63
+
64
+ ```bash
65
+ git rev-parse --is-inside-work-tree >/dev/null 2>&1 || exit 0
66
+ [ -f .forge/context.md ] || exit 0
67
+ [ -d .forge/context ] && exit 0
68
+
69
+ git ls-files --error-unmatch .forge/context.md >/dev/null 2>&1 || exit 0
70
+
71
+ size="$(wc -c < .forge/context.md 2>/dev/null | tr -d ' ')"
72
+ [ -n "$size" ] && [ "$size" -gt 500 ] || exit 0
73
+
74
+ echo "MIGRATE — .forge/context.md is a tracked, ${size}-byte monolithic file with no .forge/context/ directory; run forge-context-migrate.sh to shard it before the derived-index render starts being relied on"
75
+ ```
76
+
77
+ ## Migration steps
78
+
79
+ ### 1. Run the migration tool for real (not dry-run)
80
+
81
+ From the repo root, with no flags — the tool resolves `--source` and `--dest`
82
+ from the current repo via `git rev-parse --show-toplevel` when they're
83
+ omitted, defaulting to `<repo-root>/.forge/context.md` and `<repo-root>/.forge`:
84
+
85
+ ```bash
86
+ .claude/hooks/forge-context-migrate.sh
87
+ ```
88
+
89
+ This reads `.forge/context.md` and writes `.forge/context/{id}.md` (one file
90
+ per resolvable milestone heading, ids normalized to `m-{N}`),
91
+ `.forge/context-log.md` (Amendment Log, Needs Resolution, and any undated
92
+ drafting block that has no milestone id to shard by), and
93
+ `.forge/context/_shared.md` if any Deferred Ideas / Discretion Areas entry
94
+ has no milestone-id prefix. It does **not** touch or delete the source
95
+ `context.md` — that's step 2, done separately on purpose so the tool itself
96
+ stays a pure read-old/write-new operation.
97
+
98
+ The tool is idempotent per milestone id: if `.forge/context/{id}.md` already
99
+ exists, that id is skipped (never overwritten, never duplicated) and one
100
+ skip line is printed. Re-running this command after the fact is always safe.
101
+ What is **not** repeatable is the untrack step below — do that once.
102
+
103
+ ### 2. Untrack context.md and gitignore it
104
+
105
+ ```bash
106
+ git rm --cached .forge/context.md
107
+ ```
108
+
109
+ Add `.forge/context.md` to whichever gitignore file already lists
110
+ `.forge/state/index.yml` and `.forge/streams/active.yml` — grep for
111
+ `index.yml` to find the right file (in this repo it's the root
112
+ `.gitignore`, under the "Render-on-read derived registries" comment block;
113
+ your project's may differ if it customized gitignore layout):
114
+
115
+ ```bash
116
+ grep -rn "index.yml" .gitignore .forge/.gitignore 2>/dev/null
117
+ ```
118
+
119
+ Add the line next to the other two derived-registry entries, matching their
120
+ existing comment block rather than starting a new one.
121
+
122
+ ### 3. Commit the migration output
123
+
124
+ ```bash
125
+ git add .forge/context/ .forge/context-log.md .gitignore
126
+ git commit -m "chore(forge): shard context.md into per-milestone files (0.80.0)"
127
+ ```
128
+
129
+ Do **not** `git add .forge/context.md` — it is now untracked and gitignored;
130
+ the working copy on disk will be overwritten by the next boot's render into
131
+ a derived index, which is expected.
132
+
133
+ **This is a one-time, per-repo operation.** The `git rm --cached` + gitignore
134
+ step only needs doing once — running `forge-context-migrate.sh` again later
135
+ (e.g. if a new milestone's context needs re-splitting for some reason) is
136
+ safe and idempotent per the tool's own design, but do not repeat the untrack
137
+ step; `.forge/context.md` is already gitignored after step 2 and a second
138
+ `git rm --cached` on an already-untracked path is a no-op error, not harmful
139
+ but not necessary.
140
+
141
+ ## Validation
142
+
143
+ - `.forge/context/` contains one file per milestone heading that existed in
144
+ the old monolithic `context.md` (spot-check the count against what you
145
+ read in Prerequisites step 3).
146
+ - `git status --short .forge/context.md` shows nothing (untracked +
147
+ gitignored paths don't appear in default `git status` output) — confirming
148
+ the untrack + gitignore took effect. `git check-ignore .forge/context.md`
149
+ exits 0.
150
+ - Start a new session, or run `/clear` — the boot summary line now includes
151
+ a `+ context.md (N context rows)` clause, and `.forge/context.md` on disk
152
+ is the rendered index (one row per milestone: id, name, status,
153
+ release_state, path), not the old monolithic prose.
154
+ - Re-running the Detection block above now prints nothing (exits silently)
155
+ — the gap is closed.
156
+ - Spot-check any pre-existing citation into `context.md` (e.g. "LOCKED,
157
+ context.md § M24") still resolves: the rendered index has a row for that
158
+ milestone id, and `.forge/context/{id}.md` contains the cited content.