forge-orkes 0.79.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 (25) hide show
  1. package/package.json +1 -1
  2. package/template/.claude/hooks/forge-context-migrate.sh +297 -0
  3. package/template/.claude/hooks/forge-size-gate.sh +175 -0
  4. package/template/.claude/hooks/forge-state-rollup.sh +135 -2
  5. package/template/.claude/hooks/skill-gate-tiers.txt +6 -0
  6. package/template/.claude/hooks/tests/forge-context-migrate.test.sh +166 -0
  7. package/template/.claude/hooks/tests/forge-size-gate.test.sh +132 -0
  8. package/template/.claude/settings.json +5 -1
  9. package/template/.claude/skills/discussing/SKILL.md +30 -31
  10. package/template/.claude/skills/executing/SKILL.md +3 -3
  11. package/template/.claude/skills/forge/SKILL.md +112 -170
  12. package/template/.claude/skills/forge/references/manual-fallback-procedures.md +54 -0
  13. package/template/.claude/skills/planning/SKILL.md +11 -9
  14. package/template/.claude/skills/researching/SKILL.md +2 -0
  15. package/template/.claude/skills/verifying/SKILL.md +2 -2
  16. package/template/.forge/FORGE.md +79 -91
  17. package/template/.forge/checks/forge-jarvis-awareness.sh +46 -2
  18. package/template/.forge/checks/forge-jarvis-relay.sh +64 -16
  19. package/template/.forge/checks/tests/forge-jarvis-awareness.test.sh +58 -0
  20. package/template/.forge/checks/tests/forge-jarvis-relay.test.sh +50 -0
  21. package/template/.forge/migrations/0.80.0-context-md-sharding.md +158 -0
  22. package/template/.forge/migrations/0.80.0-size-gate-wiring.md +145 -0
  23. package/template/.forge/templates/constitution.md +1 -1
  24. package/template/.forge/templates/context-milestone.md +30 -0
  25. package/template/.forge/templates/slice-runner/config.yml +7 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forge-orkes",
3
- "version": "0.79.0",
3
+ "version": "0.80.0",
4
4
  "description": "Set up the Forge meta-prompting framework for Claude Code in your project",
5
5
  "bin": {
6
6
  "create-forge": "./bin/create-forge.js"
@@ -0,0 +1,297 @@
1
+ #!/usr/bin/env sh
2
+ # forge-context-migrate — one-time tool: split a monolithic `.forge/context.md`
3
+ # into `.forge/context/{id}.md` (one file per milestone) + `.forge/context-log.md`
4
+ # (Amendment Log + Needs Resolution + any content with no resolvable milestone
5
+ # id) + `.forge/context/_shared.md` (Deferred/Discretion entries that are
6
+ # genuinely cross-cutting, i.e. carry no milestone-id prefix). Part of the
7
+ # context.md size-gate fix (issue #37, ADR-033) — context.md becomes a
8
+ # derived index (forge-state-rollup.sh renders it); this tool produces the
9
+ # per-milestone source files that index reads.
10
+ #
11
+ # forge-context-migrate.sh [--source <path>] [--dest <path>] [--dry-run]
12
+ #
13
+ # --source <path> the monolithic context.md to read. Default:
14
+ # <repo-root>/.forge/context.md (resolved via git from
15
+ # the CWD).
16
+ # --dest <path> the directory context/ + context-log.md are written
17
+ # under (i.e. the ".forge"-equivalent dir — outputs
18
+ # land at <dest>/context/{id}.md, <dest>/context-log.md,
19
+ # <dest>/context/_shared.md). Default: <repo-root>/.forge.
20
+ # --dry-run parse and report what WOULD be written; touches no
21
+ # files on disk.
22
+ #
23
+ # NEVER touches --source. This tool is a pure "read old, write new" step —
24
+ # deleting/replacing the monolithic file is a separate, explicit step so the
25
+ # tool stays testable without mutating the thing it reads.
26
+ #
27
+ # Classification rules (see phase 95 plan-01 task 1):
28
+ # - `## Locked Decisions` sub-headings (`### ...`) whose first word resolves
29
+ # to a milestone id (`M{N}`, `m-{N}`, `m{N}` — normalized to `m-{N}`) each
30
+ # become `context/{id}.md`. A heading with no resolvable id (e.g. a
31
+ # drafting block, or a pointer-stub heading like "Historical Decisions")
32
+ # is NOT sharded — it lands in context-log.md verbatim.
33
+ # - `## Deferred Ideas` / `## Discretion Areas` top-level bullets whose text
34
+ # starts with a resolvable milestone-id prefix append to that milestone's
35
+ # context/{id}.md (created if absent) under a `## Deferred` / `##
36
+ # Discretion` heading. An entry with no prefix is genuinely cross-cutting
37
+ # and goes to context/_shared.md instead — never guessed onto a milestone.
38
+ # - `## Needs Resolution` and `## Amendment Log` are never milestone-scoped
39
+ # — copied verbatim into context-log.md.
40
+ # - Anything else (top-of-file preamble, unrecognized `## ` sections) is
41
+ # dropped or logged to context-log.md's "Other Sections" — never silently
42
+ # lost, but never invented a milestone home either.
43
+ #
44
+ # Idempotency: re-running against an already-migrated tree is a no-op per id
45
+ # — if `<dest>/context/{id}.md` already exists on disk, that id is SKIPPED
46
+ # entirely (Locked + Deferred + Discretion content for that id alike), one
47
+ # skip line printed per id, and the existing file is never touched. Likewise
48
+ # context-log.md and context/_shared.md are left alone if they already exist.
49
+ #
50
+ # Portable: POSIX sh + awk. No network calls.
51
+
52
+ set -u
53
+
54
+ SCRIPT_NAME="forge-context-migrate"
55
+
56
+ source=""
57
+ dest=""
58
+ dry_run=0
59
+
60
+ while [ $# -gt 0 ]; do
61
+ case "$1" in
62
+ --source) shift; source="${1:-}"; [ $# -gt 0 ] && shift || true ;;
63
+ --source=*) source="${1#*=}"; shift ;;
64
+ --dest) shift; dest="${1:-}"; [ $# -gt 0 ] && shift || true ;;
65
+ --dest=*) dest="${1#*=}"; shift ;;
66
+ --dry-run) dry_run=1; shift ;;
67
+ -h|--help)
68
+ printf 'Usage: %s [--source <path>] [--dest <path>] [--dry-run]\n' "$SCRIPT_NAME"
69
+ exit 0
70
+ ;;
71
+ *) printf '%s: unknown argument: %s\n' "$SCRIPT_NAME" "$1" >&2; exit 2 ;;
72
+ esac
73
+ done
74
+
75
+ if [ -z "$source" ] || [ -z "$dest" ]; then
76
+ root="$(git rev-parse --show-toplevel 2>/dev/null)"
77
+ if [ -z "$root" ]; then
78
+ printf '%s: not in a git repo — pass --source and --dest explicitly\n' "$SCRIPT_NAME" >&2
79
+ exit 1
80
+ fi
81
+ [ -z "$source" ] && source="$root/.forge/context.md"
82
+ [ -z "$dest" ] && dest="$root/.forge"
83
+ fi
84
+
85
+ if [ ! -f "$source" ]; then
86
+ printf '%s: source file not found: %s\n' "$SCRIPT_NAME" "$source" >&2
87
+ exit 1
88
+ fi
89
+
90
+ # --- idempotency inputs: which ids/artifacts already exist at dest ----------
91
+
92
+ existing_ids=" "
93
+ if [ -d "$dest/context" ]; then
94
+ for f in "$dest"/context/*.md; do
95
+ [ -f "$f" ] || continue
96
+ base="$(basename "$f" .md)"
97
+ [ "$base" = "_shared" ] && continue
98
+ existing_ids="$existing_ids$base "
99
+ done
100
+ fi
101
+
102
+ skip_log=0
103
+ [ -f "$dest/context-log.md" ] && skip_log=1
104
+
105
+ skip_shared=0
106
+ [ -f "$dest/context/_shared.md" ] && skip_shared=1
107
+
108
+ # --- write target dirs (real run only — dry-run touches nothing) -----------
109
+
110
+ if [ "$dry_run" -eq 0 ]; then
111
+ mkdir -p "$dest/context"
112
+ fi
113
+
114
+ # --- awk parser/writer -------------------------------------------------------
115
+ # Buffers everything in memory (context.md is well under a few hundred KB)
116
+ # then writes/report at END. Program is written to a scratch file so the
117
+ # shell wrapper stays a plain single-quoted awk invocation.
118
+
119
+ awk_prog="$(mktemp 2>/dev/null || mktemp -t forge-context-migrate)"
120
+ trap 'rm -f "$awk_prog"' EXIT INT TERM
121
+
122
+ cat > "$awk_prog" <<'AWKEOF'
123
+ function extract_id(line, w, arr, n, tok, digits) {
124
+ w = line
125
+ sub(/^### /, "", w)
126
+ sub(/^- /, "", w)
127
+ sub(/^~~/, "", w)
128
+ n = split(w, arr, /[ \t]/)
129
+ if (n < 1) return ""
130
+ tok = arr[1]
131
+ gsub(/[:,.]+$/, "", tok)
132
+ if (tok ~ /^[Mm]-?[0-9]+$/) {
133
+ digits = tok
134
+ gsub(/[^0-9]/, "", digits)
135
+ return "m-" digits
136
+ }
137
+ return ""
138
+ }
139
+
140
+ function add_id(id) {
141
+ if (!(id in seen_id)) {
142
+ seen_id[id] = 1
143
+ n_ids++
144
+ id_order[n_ids] = id
145
+ }
146
+ }
147
+
148
+ BEGIN {
149
+ section = ""
150
+ cur_state = ""
151
+ cur_id = ""
152
+ cur_noid_key = ""
153
+ n_ids = 0
154
+ n_noid = 0
155
+ }
156
+
157
+ /^## / {
158
+ cur_state = ""
159
+ cur_id = ""
160
+ if ($0 ~ /^## Locked Decisions/) { section = "LOCKED"; cur_state = "LOCKED_PRE"; next }
161
+ if ($0 ~ /^## Deferred Ideas/) { section = "DEFERRED"; next }
162
+ if ($0 ~ /^## Discretion Areas/) { section = "DISCRETION"; next }
163
+ if ($0 ~ /^## Needs Resolution/) { section = "NEEDS_RESOLUTION"; next }
164
+ if ($0 ~ /^## Amendment Log/) { section = "AMENDMENT"; next }
165
+ section = "OTHER"
166
+ other_buf = other_buf $0 "\n"
167
+ next
168
+ }
169
+
170
+ section == "" { next }
171
+
172
+ section == "LOCKED" {
173
+ if ($0 ~ /^### /) {
174
+ id = extract_id($0)
175
+ if (id == "") {
176
+ n_noid++
177
+ cur_noid_key = "NOID" n_noid
178
+ noid_order[n_noid] = cur_noid_key
179
+ noid_body[cur_noid_key] = $0 "\n"
180
+ cur_state = "LOCKED_NOID"
181
+ cur_id = ""
182
+ } else {
183
+ add_id(id)
184
+ locked_body[id] = $0 "\n"
185
+ cur_state = "LOCKED_BLOCK"
186
+ cur_id = id
187
+ }
188
+ next
189
+ }
190
+ if (cur_state == "LOCKED_PRE") { pre_locked_buf = pre_locked_buf $0 "\n"; next }
191
+ if (cur_state == "LOCKED_BLOCK") { locked_body[cur_id] = locked_body[cur_id] $0 "\n"; next }
192
+ if (cur_state == "LOCKED_NOID") { noid_body[cur_noid_key] = noid_body[cur_noid_key] $0 "\n"; next }
193
+ next
194
+ }
195
+
196
+ section == "DEFERRED" || section == "DISCRETION" {
197
+ if ($0 ~ /^- /) {
198
+ id = extract_id($0)
199
+ if (id == "") {
200
+ if (section == "DEFERRED") { cur_state = "SHARED_DEF"; shared_deferred = shared_deferred $0 "\n" }
201
+ else { cur_state = "SHARED_DISC"; shared_discretion = shared_discretion $0 "\n" }
202
+ cur_id = ""
203
+ } else {
204
+ add_id(id)
205
+ if (section == "DEFERRED") { deferred_body[id] = deferred_body[id] $0 "\n"; cur_state = "DEF_ID" }
206
+ else { discretion_body[id] = discretion_body[id] $0 "\n"; cur_state = "DISC_ID" }
207
+ cur_id = id
208
+ }
209
+ next
210
+ }
211
+ if (cur_state == "DEF_ID") { deferred_body[cur_id] = deferred_body[cur_id] $0 "\n"; next }
212
+ if (cur_state == "DISC_ID") { discretion_body[cur_id] = discretion_body[cur_id] $0 "\n"; next }
213
+ if (cur_state == "SHARED_DEF") { shared_deferred = shared_deferred $0 "\n"; next }
214
+ if (cur_state == "SHARED_DISC") { shared_discretion = shared_discretion $0 "\n"; next }
215
+ next
216
+ }
217
+
218
+ section == "NEEDS_RESOLUTION" { needs_resolution_buf = needs_resolution_buf $0 "\n"; next }
219
+ section == "AMENDMENT" { amendment_buf = amendment_buf $0 "\n"; next }
220
+ section == "OTHER" { other_buf = other_buf $0 "\n"; next }
221
+
222
+ END {
223
+ for (i = 1; i <= n_ids; i++) {
224
+ id = id_order[i]
225
+ if (index(existing, " " id " ") > 0) {
226
+ print "SKIP: " id " — context/" id ".md already exists, not overwritten" > "/dev/stderr"
227
+ continue
228
+ }
229
+ content = ""
230
+ if (id in locked_body) content = content locked_body[id]
231
+ if (id in deferred_body) content = content "\n## Deferred\n\n" deferred_body[id]
232
+ if (id in discretion_body) content = content "\n## Discretion\n\n" discretion_body[id]
233
+ fname = dest "/context/" id ".md"
234
+ if (dryrun == "1") {
235
+ print "DRY-RUN: would create " fname
236
+ } else {
237
+ printf "%s", content > fname
238
+ close(fname)
239
+ print "CREATED: " fname
240
+ }
241
+ }
242
+
243
+ if (skip_log == "1") {
244
+ print "SKIP: context-log.md already exists, not overwritten" > "/dev/stderr"
245
+ } else {
246
+ log_out = "# Context Log\n\n"
247
+ log_out = log_out "Migrated from the monolithic `.forge/context.md` (ADR-033). Per-milestone\n"
248
+ log_out = log_out "decisions now live in `.forge/context/{id}.md`; the rendered `.forge/context.md`\n"
249
+ log_out = log_out "is a derived index (forge-state-rollup.sh) — this file carries the content\n"
250
+ log_out = log_out "that never was milestone-scoped.\n\n---\n\n"
251
+ if (pre_locked_buf != "" || n_noid > 0) {
252
+ log_out = log_out "## Locked Decisions (No Milestone ID)\n\n"
253
+ if (pre_locked_buf != "") log_out = log_out pre_locked_buf "\n"
254
+ for (i = 1; i <= n_noid; i++) log_out = log_out noid_body[noid_order[i]] "\n"
255
+ }
256
+ if (other_buf != "") log_out = log_out "## Other Sections\n\n" other_buf "\n"
257
+ log_out = log_out "## Needs Resolution\n\n"
258
+ log_out = log_out (needs_resolution_buf != "" ? needs_resolution_buf : "(none)\n")
259
+ log_out = log_out "\n---\n\n## Amendment Log\n\n" amendment_buf
260
+ fname = dest "/context-log.md"
261
+ if (dryrun == "1") {
262
+ print "DRY-RUN: would create " fname
263
+ } else {
264
+ printf "%s", log_out > fname
265
+ close(fname)
266
+ print "CREATED: " fname
267
+ }
268
+ }
269
+
270
+ if (shared_deferred == "" && shared_discretion == "") {
271
+ # nothing genuinely cross-cutting — do not create an empty file
272
+ } else if (skip_shared == "1") {
273
+ print "SKIP: context/_shared.md already exists, not overwritten" > "/dev/stderr"
274
+ } else {
275
+ shared_out = "# Shared / Cross-Cutting Context\n\n"
276
+ shared_out = shared_out "Deferred Ideas and Discretion Areas entries from `.forge/context.md` with no\n"
277
+ shared_out = shared_out "resolvable milestone-id prefix — genuinely cross-cutting, not owned by one\n"
278
+ shared_out = shared_out "milestone.\n\n---\n\n"
279
+ if (shared_deferred != "") shared_out = shared_out "## Deferred Ideas\n\n" shared_deferred "\n"
280
+ if (shared_discretion != "") shared_out = shared_out "## Discretion Areas\n\n" shared_discretion "\n"
281
+ fname = dest "/context/_shared.md"
282
+ if (dryrun == "1") {
283
+ print "DRY-RUN: would create " fname
284
+ } else {
285
+ printf "%s", shared_out > fname
286
+ close(fname)
287
+ print "CREATED: " fname
288
+ }
289
+ }
290
+
291
+ print "forge-context-migrate: " n_ids " milestone id(s) processed"
292
+ }
293
+ AWKEOF
294
+
295
+ awk -v dest="$dest" -v dryrun="$dry_run" -v existing="$existing_ids" \
296
+ -v skip_log="$skip_log" -v skip_shared="$skip_shared" \
297
+ -f "$awk_prog" "$source"
@@ -0,0 +1,175 @@
1
+ #!/usr/bin/env sh
2
+ # forge-size-gate — mechanical enforcement of FORGE.md's Size Gates table
3
+ # (issue #37). Size gates were prose-only: nothing measured an artifact
4
+ # against its declared KB gate, so FORGE.md itself drifted over its own
5
+ # 35 KB gate and context.md drifted ~4x over its 12 KB gate before anyone
6
+ # noticed. This hook is the mechanical check — it runs at every boot and
7
+ # advises, once, when something is over.
8
+ #
9
+ # forge-size-gate.sh [--project <path>]
10
+ # (SessionStart hook) reads {cwd, hook_event_name, source} from stdin JSON.
11
+ #
12
+ # --project <path> operate on the repo containing <path>. Default: the
13
+ # SessionStart payload's `cwd`, else the CWD. NEVER
14
+ # $CLAUDE_PROJECT_DIR — see forge-state-rollup.sh for
15
+ # why (that resolves to the main checkout even inside
16
+ # a worktree).
17
+ #
18
+ # What it checks — every artifact FORGE.md's Size Gates table declares a KB
19
+ # gate for (read the table directly at $root/.forge/FORGE.md, not
20
+ # hallucinated): FORGE.md, project.yml, requirements/m*.yml (each file, not
21
+ # summed), constitution.md, context.md, refactor-backlog.yml,
22
+ # streams/active.yml, streams/*.yml (per file, excluding active.yml itself —
23
+ # a different gate), streams/*/brief.md (per file), reservations.yml,
24
+ # prototypes/*.md (per file — archive/ is a subdirectory so a flat glob never
25
+ # reaches it). plan-{NN}.md instances are deliberately NOT checked here —
26
+ # there are too many to enumerate at boot and no single "current" one;
27
+ # planning's own Step 8 already lints plan size at creation time.
28
+ #
29
+ # Plus a tiered skill-file class this hook introduces: every
30
+ # .claude/skills/*/SKILL.md is checked against a KB gate that depends on
31
+ # whether the skill is an entry point. Entry-point skill names are listed one
32
+ # per line in .claude/hooks/skill-gate-tiers.txt (40 KB tier); every other
33
+ # skill defaults to the stricter 20 KB tier. A missing tier-list file
34
+ # degrades to "no skills are entry-point" — every skill checked at 20 KB —
35
+ # never a crash.
36
+ #
37
+ # KB is decimal (1 KB = 1000 B), matching the numbers FORGE.md's own Size
38
+ # Gates table was measured against (research/milestone-54.md: FORGE.md is
39
+ # "35,834 B against the declared 35 KB gate" — over only under the decimal
40
+ # reading).
41
+ #
42
+ # A declared-gate artifact that does not exist in this repo (e.g. no
43
+ # prototypes/ dir yet) is skipped silently — not an error, not a violation.
44
+ #
45
+ # Contract (SessionStart, mirrors forge-state-rollup.sh — ADR-032):
46
+ # - Exit 0 ALWAYS. SessionStart is non-blocking; this hook must never abort
47
+ # a session.
48
+ # - Read-only. No writes, no cache, nothing to render.
49
+ # - stdout is injected into the model's context. When every checked
50
+ # artifact is within gate, this hook prints NOTHING — a silent pass costs
51
+ # zero context. When one or more are over, it prints exactly ONE terse
52
+ # summary line naming every offender, never a multi-line dump (a
53
+ # multi-line dump would itself be the token-cost problem this hook
54
+ # exists to prevent).
55
+ #
56
+ # Portable: POSIX sh + git + wc. No network calls (NFR-030).
57
+
58
+ set -u
59
+
60
+ # --- argument / stdin parsing ------------------------------------------------
61
+
62
+ project=""
63
+
64
+ while [ $# -gt 0 ]; do
65
+ case "$1" in
66
+ --project) shift; project="${1:-}"; [ $# -gt 0 ] && shift || true ;;
67
+ --project=*) project="${1#*=}"; shift ;;
68
+ -*) shift ;; # unknown flag — ignore, never fail a boot hook
69
+ *) shift ;;
70
+ esac
71
+ done
72
+
73
+ # SessionStart delivers {cwd, source, hook_event_name, ...} on stdin. When no
74
+ # --project was given, prefer the payload's cwd (the actual worktree the
75
+ # session started in) over the process CWD. Read stdin only if it is not a
76
+ # tty and jq is present; never block waiting for input that will not arrive.
77
+ stdin_cwd=""
78
+ if [ -z "$project" ] && [ ! -t 0 ] && command -v jq >/dev/null 2>&1; then
79
+ payload="$(cat 2>/dev/null || true)"
80
+ if [ -n "$payload" ]; then
81
+ stdin_cwd="$(printf '%s' "$payload" | jq -r '.cwd // empty' 2>/dev/null || true)"
82
+ fi
83
+ fi
84
+ [ -z "$project" ] && project="${stdin_cwd:-$PWD}"
85
+
86
+ # --- repo resolution ---------------------------------------------------------
87
+ # Silent no-op when not in a git repo or the repo has no .forge/ — the common
88
+ # case for a non-Forge session, and this hook must be invisible there.
89
+
90
+ root="$(git -C "$project" rev-parse --show-toplevel 2>/dev/null)" || exit 0
91
+ [ -d "$root/.forge" ] || exit 0
92
+
93
+ TIER_FILE="$root/.claude/hooks/skill-gate-tiers.txt"
94
+
95
+ # --- violation bookkeeping ----------------------------------------------------
96
+
97
+ VIOLATIONS=""
98
+ VIOLATION_COUNT=0
99
+
100
+ record_violation() { # path actual_bytes gate_kb
101
+ VIOLATIONS="${VIOLATIONS}${VIOLATIONS:+, }$1 (${2}B/${3}KB)"
102
+ VIOLATION_COUNT=$((VIOLATION_COUNT + 1))
103
+ }
104
+
105
+ # check_gate <abs_path> <gate_kb> — decimal KB (1 KB = 1000 B). Missing file
106
+ # is skipped silently, not a violation.
107
+ check_gate() {
108
+ f="$1"; kb="$2"
109
+ [ -f "$f" ] || return 0
110
+ bytes="$(wc -c < "$f" 2>/dev/null | tr -d ' ')"
111
+ [ -z "$bytes" ] && return 0
112
+ limit=$((kb * 1000))
113
+ if [ "$bytes" -gt "$limit" ]; then
114
+ rel="${f#"$root"/}"
115
+ record_violation "$rel" "$bytes" "$kb"
116
+ fi
117
+ }
118
+
119
+ # --- declared single-file artifacts (FORGE.md Size Gates table) -------------
120
+
121
+ check_gate "$root/.forge/FORGE.md" 35
122
+ check_gate "$root/.forge/project.yml" 5
123
+ check_gate "$root/.forge/constitution.md" 10
124
+ check_gate "$root/.forge/context.md" 12
125
+ check_gate "$root/.forge/refactor-backlog.yml" 150
126
+ check_gate "$root/.forge/streams/active.yml" 20
127
+ check_gate "$root/.forge/reservations.yml" 50
128
+
129
+ # --- declared per-file glob artifacts (each file checked individually) ------
130
+
131
+ for f in "$root"/.forge/requirements/m*.yml; do
132
+ [ -f "$f" ] || continue
133
+ check_gate "$f" 50
134
+ done
135
+
136
+ for f in "$root"/.forge/streams/*.yml; do
137
+ [ -f "$f" ] || continue
138
+ case "$(basename "$f")" in active.yml) continue ;; esac
139
+ check_gate "$f" 15
140
+ done
141
+
142
+ for f in "$root"/.forge/streams/*/brief.md; do
143
+ [ -f "$f" ] || continue
144
+ check_gate "$f" 8
145
+ done
146
+
147
+ for f in "$root"/.forge/prototypes/*.md; do
148
+ [ -f "$f" ] || continue
149
+ check_gate "$f" 8
150
+ done
151
+
152
+ # --- tiered skill-file class --------------------------------------------------
153
+ # Entry-point skills (listed by name in skill-gate-tiers.txt) get 40 KB;
154
+ # every other skill defaults to the stricter 20 KB tier. Missing tier-list
155
+ # file → every skill defaults to 20 KB, never a crash.
156
+
157
+ for f in "$root"/.claude/skills/*/SKILL.md; do
158
+ [ -f "$f" ] || continue
159
+ skill_name="$(basename "$(dirname "$f")")"
160
+ skill_kb=20
161
+ if [ -f "$TIER_FILE" ] && grep -qx "$skill_name" "$TIER_FILE" 2>/dev/null; then
162
+ skill_kb=40
163
+ fi
164
+ check_gate "$f" "$skill_kb"
165
+ done
166
+
167
+ # --- summary (stdout → injected into boot context) ---------------------------
168
+ # Silent when clean. One terse line when not.
169
+
170
+ if [ "$VIOLATION_COUNT" -gt 0 ]; then
171
+ printf '[forge-size-gate] %s artifact(s) over their declared gate: %s — see FORGE.md § Size Gates\n' \
172
+ "$VIOLATION_COUNT" "$VIOLATIONS"
173
+ fi
174
+
175
+ exit 0
@@ -7,6 +7,11 @@
7
7
  # (which a "the index is stale, work around it" project-memory habit can
8
8
  # out-compete — the exact ptnrkit failure #22 was filed against).
9
9
  #
10
+ # A third join (ADR-033, issue #37) renders .forge/context.md itself as a
11
+ # derived index over .forge/context/*.md — the per-milestone decision files
12
+ # forge-context-migrate.sh produces. Same contract as the other two: derived,
13
+ # git-ignored, rendered fresh every boot, never hand-edited.
14
+ #
10
15
  # forge-state-rollup.sh [--project <path>] [--quiet]
11
16
  # (SessionStart hook) reads {cwd, hook_event_name, source} from stdin JSON.
12
17
  #
@@ -17,9 +22,11 @@
17
22
  # render this worktree's caches into the wrong tree.
18
23
  # --quiet suppress the stdout summary line (still writes caches).
19
24
  #
20
- # What it writes (both are git-ignored render-on-read caches since 0.53.0):
25
+ # What it writes (all three are git-ignored render-on-read caches since
26
+ # 0.53.0 / this addendum for context.md):
21
27
  # .forge/state/index.yml — derived milestone registry
22
28
  # .forge/streams/active.yml — derived stream registry (if streams/ exists)
29
+ # .forge/context.md — derived context index (if context/ exists)
23
30
  # It NEVER commits them and NEVER runs git add/commit. Rendering a gitignored
24
31
  # cache is a local write that never lands on main, so any checkout (main OR
25
32
  # worktree) may render its own copy (FORGE.md → State Ownership, Derived class).
@@ -398,15 +405,136 @@ render_active() {
398
405
  STREAM_ROW_COUNT="$sn"
399
406
  }
400
407
 
408
+ # --- Context Index (ADR-033 addendum) ----------------------------------------
409
+ # Third join: .forge/context/*.md (one file per milestone, produced by
410
+ # forge-context-migrate.sh) ⋈ the index.yml rows this same pass already
411
+ # rendered above — no second fold invocation, reuse what render_index wrote.
412
+
413
+ # context_index_lookup <norm_id> — prints "name\tstatus\trelease_state" for
414
+ # the index.yml row matching <norm_id> (already normalized, no leading "m-").
415
+ # Empty output when no row matches — degrade gracefully, never error.
416
+ context_index_lookup() {
417
+ awk -v want="$1" '
418
+ /^ - id:/ { id=$3; gsub(/["\x27]/,"",id); sub(/^m-/,"",id); inrow=(id==want); name=""; status=""; rel="" }
419
+ inrow && /^ name:/ { name=$0; sub(/^ name: /,"",name); gsub(/^["\x27]|["\x27]$/,"",name) }
420
+ inrow && /^ status:/ { status=$2 }
421
+ inrow && /^ release_state:/ { rel=$2; print name "\t" status "\t" rel; exit }
422
+ ' "$root/.forge/state/index.yml" 2>/dev/null
423
+ }
424
+
425
+ # context_summary <file> — a <!-- summary: ... --> marker wins over the
426
+ # first "- " bullet; empty when the file has neither (row still renders,
427
+ # just with an empty summary — never a crash).
428
+ context_summary() {
429
+ f="$1"
430
+ s="$(awk '/<!-- summary:/ { line=$0; sub(/^.*<!-- summary: */,"",line); sub(/ *-->.*$/,"",line); print line; exit }' "$f" 2>/dev/null)"
431
+ if [ -z "$s" ]; then
432
+ s="$(awk '/^- / { line=$0; sub(/^- /,"",line); print line; exit }' "$f" 2>/dev/null)"
433
+ fi
434
+ printf '%s\n' "$s"
435
+ }
436
+
437
+ CONTEXT_ROW_COUNT=0
438
+
439
+ render_context_index() {
440
+ context_path="$root/.forge/context.md"
441
+ ctx_rows="" # "id\tname\tstatus\trelease_state\tsummary\tpath"
442
+ cn=0
443
+
444
+ for cfile in "$root"/.forge/context/*.md; do
445
+ [ -f "$cfile" ] || continue
446
+ case "$(basename "$cfile")" in _shared.md) continue ;; esac
447
+ c_id="$(basename "$cfile" .md)"
448
+ [ -z "$c_id" ] && continue
449
+ rel_path=".forge/context/$(basename "$cfile")"
450
+
451
+ fields="$(context_index_lookup "$(norm_id "$c_id")")"
452
+ if [ -n "$fields" ]; then
453
+ c_name="$(printf '%s' "$fields" | awk -F'\t' '{print $1}')"
454
+ c_status="$(printf '%s' "$fields" | awk -F'\t' '{print $2}')"
455
+ c_rel="$(printf '%s' "$fields" | awk -F'\t' '{print $3}')"
456
+ else
457
+ c_name=""
458
+ c_status="unknown"
459
+ c_rel="unknown"
460
+ fi
461
+ [ -z "$c_status" ] && c_status="unknown"
462
+ [ -z "$c_rel" ] && c_rel="unknown"
463
+ c_summary="$(context_summary "$cfile")"
464
+
465
+ # A tab-delimited empty field is unsafe: `read` with IFS set to a plain
466
+ # tab still treats it as IFS-whitespace, so consecutive/empty fields get
467
+ # collapsed and every column after them shifts left. name/summary are the
468
+ # only fields that can legitimately be empty — substitute a sentinel and
469
+ # translate it back to "" after the read loop below.
470
+ [ -z "$c_name" ] && c_name="@@EMPTY@@"
471
+ [ -z "$c_summary" ] && c_summary="@@EMPTY@@"
472
+
473
+ ctx_rows="$ctx_rows$c_id $c_name $c_status $c_rel $c_summary $rel_path
474
+ "
475
+ cn=$((cn + 1))
476
+ done
477
+
478
+ sorted="$(printf '%s' "$ctx_rows" | LC_ALL=C sort -t' ' -k1,1)"
479
+
480
+ {
481
+ printf '# Locked Context — Index (DERIVED — do not hand-edit)\n'
482
+ printf '# Regenerated at boot by .claude/hooks/forge-state-rollup.sh: one row per\n'
483
+ printf '# .forge/context/{id}.md, joined with state/index.yml (status/release_state\n'
484
+ printf '# already resolved by this same pass). Git-ignored render-on-read cache —\n'
485
+ printf '# never committed. Full decision text lives in .forge/context/{id}.md, not\n'
486
+ printf '# here. See .forge/context-log.md (Amendment Log + Needs Resolution + any\n'
487
+ printf '# content with no resolvable milestone id) and .forge/context/_shared.md\n'
488
+ printf '# (cross-cutting Deferred Ideas / Discretion Areas, if present).\n'
489
+ printf '#\n'
490
+ printf '# Rules (apply to every context/{id}.md this index points at):\n'
491
+ printf '# - Locked Decisions -> MUST be implemented exactly as stated.\n'
492
+ printf '# - Deferred Ideas -> MUST NOT appear in plans, tasks, or code.\n'
493
+ printf '# - Discretion Areas -> Agent uses judgment; research, pick, document why.\n'
494
+ printf '#\n'
495
+ printf '# A milestone that is complete AND validated renders as a thin row\n'
496
+ printf '# (id, name, path only) — content never moves, only the render shortens.\n'
497
+ printf 'context:\n'
498
+ if [ "$cn" -eq 0 ]; then
499
+ printf ' []\n'
500
+ else
501
+ printf '%s\n' "$sorted" | while IFS=' ' read -r r_id r_name r_status r_rel r_summary r_path; do
502
+ [ -z "$r_id" ] && continue
503
+ [ "$r_name" = "@@EMPTY@@" ] && r_name=""
504
+ [ "$r_summary" = "@@EMPTY@@" ] && r_summary=""
505
+ printf ' - id: %s\n' "$r_id"
506
+ printf ' name: %s\n' "$(yaml_quote "$r_name")"
507
+ if [ "$r_status" = "complete" ] && [ "$r_rel" = "validated" ]; then
508
+ printf ' path: %s\n' "$r_path"
509
+ else
510
+ printf ' status: %s\n' "$r_status"
511
+ printf ' release_state: %s\n' "$r_rel"
512
+ [ -n "$r_summary" ] && printf ' summary: %s\n' "$(yaml_quote "$r_summary")"
513
+ printf ' path: %s\n' "$r_path"
514
+ fi
515
+ done
516
+ fi
517
+ printf 'see_also:\n'
518
+ printf ' - .forge/context-log.md\n'
519
+ printf ' - .forge/context/_shared.md\n'
520
+ } > "$context_path.tmp.$$" && mv -f "$context_path.tmp.$$" "$context_path"
521
+
522
+ CONTEXT_ROW_COUNT="$cn"
523
+ }
524
+
401
525
  # --- run ---------------------------------------------------------------------
402
526
 
403
- render_index # index.yml first — stream rollup reads it
527
+ render_index # index.yml first — stream rollup + context index read it
404
528
 
405
529
  if [ -d "$root/.forge/streams" ]; then
406
530
  stream_autoclose_sweep # step 0 (main checkout only)
407
531
  render_active # then the join
408
532
  fi
409
533
 
534
+ if [ -d "$root/.forge/context" ]; then
535
+ render_context_index # third join
536
+ fi
537
+
410
538
  # --- summary (stdout → injected into boot context) ---------------------------
411
539
 
412
540
  if [ "$quiet" -eq 0 ]; then
@@ -418,6 +546,11 @@ if [ "$quiet" -eq 0 ]; then
418
546
  [ "${STREAM_ROW_COUNT:-0}" = "1" ] || summary="${summary}s"
419
547
  summary="$summary)"
420
548
  fi
549
+ if [ -d "$root/.forge/context" ]; then
550
+ summary="$summary + context.md (${CONTEXT_ROW_COUNT:-0} context row"
551
+ [ "${CONTEXT_ROW_COUNT:-0}" = "1" ] || summary="${summary}s"
552
+ summary="$summary)"
553
+ fi
421
554
  [ -n "$SWEEP_CLOSED" ] && summary="$summary; auto-closed:${SWEEP_CLOSED}"
422
555
  [ "${fold_unavailable:-0}" -gt 0 ] && summary="$summary; ${fold_unavailable} release_state unknown (fold unavailable)"
423
556
  if [ "$in_main_checkout" -eq 1 ]; then
@@ -0,0 +1,6 @@
1
+ # Entry-point skills — checked against the 40KB size gate by
2
+ # forge-size-gate.sh instead of the 20KB default every other skill gets.
3
+ # One skill name per line (matches the skill's directory name under
4
+ # .claude/skills/). Lines starting with # are comments.
5
+ forge
6
+ chief-of-staff