forge-orkes 0.79.0 → 0.80.1

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 (28) hide show
  1. package/bin/create-forge.js +6 -0
  2. package/package.json +1 -1
  3. package/template/.claude/hooks/forge-context-migrate.sh +317 -0
  4. package/template/.claude/hooks/forge-release-fold.sh +35 -2
  5. package/template/.claude/hooks/forge-size-gate.sh +175 -0
  6. package/template/.claude/hooks/forge-state-rollup.sh +135 -2
  7. package/template/.claude/hooks/skill-gate-tiers.txt +6 -0
  8. package/template/.claude/hooks/tests/forge-context-migrate.test.sh +248 -0
  9. package/template/.claude/hooks/tests/forge-release-fold.test.sh +53 -2
  10. package/template/.claude/hooks/tests/forge-size-gate.test.sh +132 -0
  11. package/template/.claude/settings.json +5 -1
  12. package/template/.claude/skills/discussing/SKILL.md +30 -31
  13. package/template/.claude/skills/executing/SKILL.md +3 -3
  14. package/template/.claude/skills/forge/SKILL.md +112 -170
  15. package/template/.claude/skills/forge/references/manual-fallback-procedures.md +54 -0
  16. package/template/.claude/skills/planning/SKILL.md +11 -9
  17. package/template/.claude/skills/researching/SKILL.md +2 -0
  18. package/template/.claude/skills/verifying/SKILL.md +2 -2
  19. package/template/.forge/FORGE.md +79 -91
  20. package/template/.forge/checks/forge-jarvis-awareness.sh +46 -2
  21. package/template/.forge/checks/forge-jarvis-relay.sh +64 -16
  22. package/template/.forge/checks/tests/forge-jarvis-awareness.test.sh +58 -0
  23. package/template/.forge/checks/tests/forge-jarvis-relay.test.sh +50 -0
  24. package/template/.forge/migrations/0.80.0-context-md-sharding.md +175 -0
  25. package/template/.forge/migrations/0.80.0-size-gate-wiring.md +145 -0
  26. package/template/.forge/templates/constitution.md +1 -1
  27. package/template/.forge/templates/context-milestone.md +30 -0
  28. package/template/.forge/templates/slice-runner/config.yml +7 -1
@@ -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,175 @@
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
+ ### 0. Dry-run first and check the processed count
80
+
81
+ ```bash
82
+ .claude/hooks/forge-context-migrate.sh --dry-run
83
+ ```
84
+
85
+ Confirm the `forge-context-migrate: N milestone id(s) processed` line names a
86
+ plausible number of ids for your `context.md`'s actual Locked Decisions
87
+ headings. The tool exits non-zero and prints an `ERROR` line instead of a
88
+ silent `0 milestone id(s) processed` when it finds Locked Decisions headings
89
+ it can't resolve to any milestone id (issue #42) — but a dry-run check is
90
+ still worth doing before the destructive untrack step below, since a partial
91
+ match (some headings resolve, some don't) still exits 0. Milestone ids must
92
+ be `m-<number>` (`M53`, `m-53`) or a labelled `m-<label>` form (`M-EVT01`,
93
+ `m-SEQPRIM01`); anything else won't shard.
94
+
95
+ ### 1. Run the migration tool for real (not dry-run)
96
+
97
+ From the repo root, with no flags — the tool resolves `--source` and `--dest`
98
+ from the current repo via `git rev-parse --show-toplevel` when they're
99
+ omitted, defaulting to `<repo-root>/.forge/context.md` and `<repo-root>/.forge`:
100
+
101
+ ```bash
102
+ .claude/hooks/forge-context-migrate.sh
103
+ ```
104
+
105
+ This reads `.forge/context.md` and writes `.forge/context/{id}.md` (one file
106
+ per resolvable milestone heading, numeric ids normalized to `m-{N}` and
107
+ labelled ids lowercased and preserved, e.g. `m-evt01`),
108
+ `.forge/context-log.md` (Amendment Log, Needs Resolution, and any undated
109
+ drafting block that has no milestone id to shard by), and
110
+ `.forge/context/_shared.md` if any Deferred Ideas / Discretion Areas entry
111
+ has no milestone-id prefix. It does **not** touch or delete the source
112
+ `context.md` — that's step 2, done separately on purpose so the tool itself
113
+ stays a pure read-old/write-new operation.
114
+
115
+ The tool is idempotent per milestone id: if `.forge/context/{id}.md` already
116
+ exists, that id is skipped (never overwritten, never duplicated) and one
117
+ skip line is printed. Re-running this command after the fact is always safe.
118
+ What is **not** repeatable is the untrack step below — do that once.
119
+
120
+ ### 2. Untrack context.md and gitignore it
121
+
122
+ ```bash
123
+ git rm --cached .forge/context.md
124
+ ```
125
+
126
+ Add `.forge/context.md` to whichever gitignore file already lists
127
+ `.forge/state/index.yml` and `.forge/streams/active.yml` — grep for
128
+ `index.yml` to find the right file (in this repo it's the root
129
+ `.gitignore`, under the "Render-on-read derived registries" comment block;
130
+ your project's may differ if it customized gitignore layout):
131
+
132
+ ```bash
133
+ grep -rn "index.yml" .gitignore .forge/.gitignore 2>/dev/null
134
+ ```
135
+
136
+ Add the line next to the other two derived-registry entries, matching their
137
+ existing comment block rather than starting a new one.
138
+
139
+ ### 3. Commit the migration output
140
+
141
+ ```bash
142
+ git add .forge/context/ .forge/context-log.md .gitignore
143
+ git commit -m "chore(forge): shard context.md into per-milestone files (0.80.0)"
144
+ ```
145
+
146
+ Do **not** `git add .forge/context.md` — it is now untracked and gitignored;
147
+ the working copy on disk will be overwritten by the next boot's render into
148
+ a derived index, which is expected.
149
+
150
+ **This is a one-time, per-repo operation.** The `git rm --cached` + gitignore
151
+ step only needs doing once — running `forge-context-migrate.sh` again later
152
+ (e.g. if a new milestone's context needs re-splitting for some reason) is
153
+ safe and idempotent per the tool's own design, but do not repeat the untrack
154
+ step; `.forge/context.md` is already gitignored after step 2 and a second
155
+ `git rm --cached` on an already-untracked path is a no-op error, not harmful
156
+ but not necessary.
157
+
158
+ ## Validation
159
+
160
+ - `.forge/context/` contains one file per milestone heading that existed in
161
+ the old monolithic `context.md` (spot-check the count against what you
162
+ read in Prerequisites step 3).
163
+ - `git status --short .forge/context.md` shows nothing (untracked +
164
+ gitignored paths don't appear in default `git status` output) — confirming
165
+ the untrack + gitignore took effect. `git check-ignore .forge/context.md`
166
+ exits 0.
167
+ - Start a new session, or run `/clear` — the boot summary line now includes
168
+ a `+ context.md (N context rows)` clause, and `.forge/context.md` on disk
169
+ is the rendered index (one row per milestone: id, name, status,
170
+ release_state, path), not the old monolithic prose.
171
+ - Re-running the Detection block above now prints nothing (exits silently)
172
+ — the gap is closed.
173
+ - Spot-check any pre-existing citation into `context.md` (e.g. "LOCKED,
174
+ context.md § M24") still resolves: the rendered index has a row for that
175
+ milestone id, and `.forge/context/{id}.md` contains the cited content.
@@ -0,0 +1,145 @@
1
+ # Migration Guide: Size gate wired to boot (Forge 0.80.0)
2
+
3
+ Applies to projects upgrading to 0.80.0 or later. The size-gate check
4
+ (`.claude/hooks/forge-size-gate.sh`, issue #37) — which warns when a
5
+ context-engineering artifact (FORGE.md, context.md, a plan, a skill file, …)
6
+ crosses its declared size gate — is now invoked mechanically by the same
7
+ `SessionStart` hook that runs `forge-state-rollup.sh`. A boot session can no
8
+ longer skip the check and let an artifact silently grow past its gate until
9
+ someone notices by hand.
10
+
11
+ `/upgrading` ships the new hook file (`.claude/hooks/forge-size-gate.sh`) like
12
+ any other framework file. It does **not** wire the `SessionStart` invocation,
13
+ because `.claude/settings.json` is a **user-owned file** — upgrades never
14
+ overwrite it (FORGE.md → State Ownership, "Stable shared" class notes
15
+ settings.json is propagated only via explicit installer steps, never blanket
16
+ overwrite). Without this migration's one manual step, the hook ships but is
17
+ never invoked: the executable exists, nothing mechanically triggers it.
18
+
19
+ Projects that ran `initializing` fresh on 0.80.0+ (or already carry the
20
+ `forge-size-gate` reference in their `SessionStart` block from an earlier
21
+ partial adoption) need nothing further.
22
+
23
+ ## Prerequisites
24
+
25
+ 1. On the new version's framework files (`npx forge-orkes upgrade`, or the
26
+ `upgrading` skill) — so `.claude/hooks/forge-size-gate.sh` is present and
27
+ executable.
28
+ 2. Already migrated to 0.77.0's `forge-state-rollup.sh` SessionStart wiring
29
+ (`.forge/migrations/0.77.0-state-rollup-executable.md`) — this guide
30
+ assumes that hook block already exists in `.claude/settings.json` and
31
+ extends it, rather than creating a `SessionStart` block from scratch. If
32
+ that migration hasn't run yet, do it first.
33
+ 3. Working tree clean or changes committed — this migration edits
34
+ `.claude/settings.json`, a tracked file.
35
+
36
+ ## Detection
37
+
38
+ Keyed on **the executable existing while the SessionStart wiring is
39
+ absent** — the shipped-but-unwired gap. Uses `jq` to inspect
40
+ `.claude/settings.json`'s `hooks.SessionStart` array for an entry referencing
41
+ `forge-size-gate`; degrades to a plain-text grep if `jq` is not installed.
42
+ Silent in a non-Forge / non-git tree, silent once the hook is wired:
43
+
44
+ ```bash
45
+ git rev-parse --is-inside-work-tree >/dev/null 2>&1 || exit 0
46
+ [ -x .claude/hooks/forge-size-gate.sh ] || exit 0
47
+
48
+ settings=".claude/settings.json"
49
+ [ -f "$settings" ] || {
50
+ echo "MIGRATE — forge-size-gate.sh is present but $settings does not exist; add it with the SessionStart hook below"
51
+ exit 0
52
+ }
53
+
54
+ if command -v jq >/dev/null 2>&1; then
55
+ wired="$(jq -r '
56
+ (.hooks.SessionStart // []) as $entries
57
+ | [$entries[].hooks[]?.command // "" | select(contains("forge-size-gate"))]
58
+ | length > 0
59
+ ' "$settings" 2>/dev/null)"
60
+ if [ "$wired" != "true" ]; then
61
+ echo "MIGRATE — $settings has no SessionStart hook referencing forge-size-gate.sh; the executable shipped but nothing mechanically triggers it"
62
+ fi
63
+ else
64
+ if ! grep -q "forge-size-gate" "$settings" 2>/dev/null; then
65
+ echo "MIGRATE — $settings has no reference to forge-size-gate.sh (jq unavailable, ran plain-text check); add the SessionStart hook below"
66
+ fi
67
+ fi
68
+ ```
69
+
70
+ ## Migration steps
71
+
72
+ ### 1. Add the size-gate command to the SessionStart hook block
73
+
74
+ Open `.claude/settings.json` and find the `hooks.SessionStart` array entry
75
+ (added by the 0.77.0 migration). Append a second command object to that
76
+ entry's `hooks` array, alongside the existing `forge-state-rollup.sh` command
77
+ — **not** a second `SessionStart` array entry:
78
+
79
+ ```json
80
+ {
81
+ "hooks": {
82
+ "SessionStart": [
83
+ {
84
+ "matcher": "startup|resume|clear",
85
+ "hooks": [
86
+ {
87
+ "type": "command",
88
+ "command": "[ -x \"$CLAUDE_PROJECT_DIR/.claude/hooks/forge-state-rollup.sh\" ] && \"$CLAUDE_PROJECT_DIR/.claude/hooks/forge-state-rollup.sh\" || true"
89
+ },
90
+ {
91
+ "type": "command",
92
+ "command": "[ -x \"$CLAUDE_PROJECT_DIR/.claude/hooks/forge-size-gate.sh\" ] && \"$CLAUDE_PROJECT_DIR/.claude/hooks/forge-size-gate.sh\" || true"
93
+ }
94
+ ]
95
+ }
96
+ ]
97
+ }
98
+ }
99
+ ```
100
+
101
+ Both commands run on the same `startup|resume|clear` matcher, in order —
102
+ `forge-state-rollup.sh` first (so caches are fresh), then
103
+ `forge-size-gate.sh` (which only reads files, order doesn't matter
104
+ functionally, but mirroring this repo's own `.claude/settings.json` keeps the
105
+ two guides consistent).
106
+
107
+ If `hooks.SessionStart` already has other entries (e.g. from a project-local
108
+ customization) beyond the one 0.77.0 added, append the `forge-size-gate.sh`
109
+ command object to whichever entry's `hooks` array already contains
110
+ `forge-state-rollup.sh`, rather than creating a new array entry. If no
111
+ `SessionStart` block exists yet at all (0.77.0's migration was skipped), run
112
+ that migration first — this guide only adds a second command, it does not
113
+ construct the block from scratch.
114
+
115
+ The `[ -x ... ] && ... || true` guard means the hook is a no-op — never a
116
+ boot failure — on any checkout where the script is absent or not executable
117
+ (SessionStart hooks are non-blocking; `forge-size-gate.sh` itself also always
118
+ exits 0 — it's an advisory, not a gate that can fail a boot).
119
+
120
+ ### 2. Commit the settings change
121
+
122
+ ```bash
123
+ git add .claude/settings.json
124
+ git commit -m "chore(forge): wire forge-size-gate SessionStart hook (0.80.0)"
125
+ ```
126
+
127
+ ## Validation
128
+
129
+ - Start a new session, or run `/clear` — the boot summary line should include
130
+ a `[forge-size-gate] N artifact(s) over their declared gate: …` line if any
131
+ tracked artifact is currently over gate, or no `[forge-size-gate]` line at
132
+ all if everything is within gate (the hook is silent when there's nothing
133
+ to report — it does not print an "all clear" line). Either outcome means
134
+ the hook ran; the absence of a `[forge-size-gate]` clause is not the same
135
+ as the hook slot producing no output at all (compare to before this
136
+ migration, where the SessionStart summary had no size-gate clause because
137
+ nothing invoked the script).
138
+ - `jq '.hooks.SessionStart' .claude/settings.json` prints the array with both
139
+ the `forge-state-rollup` and `forge-size-gate` commands present in the same
140
+ entry's `hooks` list.
141
+ - Re-running the Detection block above now prints nothing (exits silently) —
142
+ the gap is closed.
143
+ - Manually run `.claude/hooks/forge-size-gate.sh` from the repo root — it
144
+ should produce the same output the boot's SessionStart line showed,
145
+ confirming the wired invocation matches a direct run.
@@ -130,4 +130,4 @@ Minimize framework token footprint. Compress prose, route models by task complex
130
130
 
131
131
  ## Amending the Constitution
132
132
 
133
- Articles can be amended only by explicit user decision during a discuss phase. Amendments are recorded in `.forge/context.md` with rationale. No agent may unilaterally override a constitutional article.
133
+ Articles can be amended only by explicit user decision during a discuss phase. Amendments are recorded in the active milestone's `.forge/context/{id}.md` with rationale (`.forge/context.md` is a derived index, regenerated every boot — never a write target). No agent may unilaterally override a constitutional article.
@@ -0,0 +1,30 @@
1
+ # Per-Milestone Locked Context — `.forge/context/{id}.md`
2
+
3
+ One file per milestone (`m-{N}.md`), holding exactly that milestone's slice
4
+ of what the old monolithic `.forge/context.md` used to carry inline. Same
5
+ append-only convention as before — strike through instead of rewriting when a
6
+ decision changes; add an Amendment Log entry in `.forge/context-log.md`.
7
+
8
+ The rendered `.forge/context.md` (derived index, `forge-state-rollup.sh`)
9
+ prefers a `<!-- summary: ... -->` marker on the line right after the heading
10
+ over the first bullet, when a hand-written one-liner is worth pinning.
11
+
12
+ ---
13
+
14
+ ### m-{N} — {Topic} (locked {date})
15
+
16
+ <!-- summary: one-line hand-written summary for the derived index (optional) -->
17
+
18
+ Context: {why this milestone exists / what triggered it}.
19
+
20
+ - **[Topic]**: [Decision]. Reason: [Why this was chosen].
21
+
22
+ ## Deferred
23
+
24
+ <!-- Deferred Ideas entries whose text starts with this milestone's id prefix
25
+ (e.g. "m-{N}: ...") land here during migration or ongoing work. -->
26
+
27
+ ## Discretion
28
+
29
+ <!-- Discretion Areas entries whose text starts with this milestone's id
30
+ prefix land here during migration or ongoing work. -->
@@ -15,7 +15,13 @@ per_slice_budget_usd: 25
15
15
 
16
16
  # Max agent turns per headless phase (passed to `claude -p --max-turns`). Bounds a
17
17
  # single phase; cross-platform (unlike a `timeout` wall-clock, which macOS lacks).
18
- max_turns_per_phase: 60
18
+ # Raised 60 -> 120 (m-54): three consecutive error_max_turns halts on large
19
+ # content-editing phases (context.md shard/migration, FORGE.md trim), each attempt
20
+ # landing real incremental progress before hitting the wall — the phases themselves
21
+ # weren't stuck, 60 was just too tight for multi-file, iteratively-tested work.
22
+ # Budget (per_slice_budget_usd) already gates cost independently, so this has
23
+ # headroom: each halted attempt spent $2-6 of the $25 slice cap, nowhere near it.
24
+ max_turns_per_phase: 120
19
25
 
20
26
  # RESERVED — not yet enforced. Intended as an optional per-phase wall-clock cap in
21
27
  # seconds (0 = off); the runner does not read this key yet (macOS has no stock