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
@@ -885,6 +885,12 @@ async function upgrade() {
885
885
  // initializing/upgrading skills' job, NOT the installer's — see makeGitHooksExecutable.
886
886
  upgradeAdditiveDir('.forge/git-hooks', results);
887
887
  makeGitHooksExecutable(path.join(targetDir, '.forge'));
888
+ // 2-bin. Sync the tracked worktree helper (0.78.0, ADR-031) — omitted from the
889
+ // additive-sync category set until issue #41, so upgraded projects never
890
+ // received .forge/bin/new-worktree.sh even though the weld's deny message
891
+ // and migration guide both point at it. Additive, same reasoning as the
892
+ // other .forge/ categories above.
893
+ upgradeAdditiveDir('.forge/bin', results);
888
894
 
889
895
  // 2a. Confirm stale framework-file removals before deleting (R066a). Defaults
890
896
  // to yes (cleanup is the normal case); auto-confirms when non-interactive
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forge-orkes",
3
- "version": "0.79.0",
3
+ "version": "0.80.1",
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,317 @@
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}`; or a
30
+ # labelled id like `M-EVT01`, `m-SEQPRIM01` — normalized to lowercase with
31
+ # the label preserved, e.g. `m-evt01`, `m-seqprim01`, never digit-stripped)
32
+ # each become `context/{id}.md`. A heading with no resolvable id (e.g. a
33
+ # drafting block, or a pointer-stub heading like "Historical Decisions")
34
+ # is NOT sharded — it lands in context-log.md verbatim.
35
+ # - `## Deferred Ideas` / `## Discretion Areas` top-level bullets whose text
36
+ # starts with a resolvable milestone-id prefix append to that milestone's
37
+ # context/{id}.md (created if absent) under a `## Deferred` / `##
38
+ # Discretion` heading. An entry with no prefix is genuinely cross-cutting
39
+ # and goes to context/_shared.md instead — never guessed onto a milestone.
40
+ # - `## Needs Resolution` and `## Amendment Log` are never milestone-scoped
41
+ # — copied verbatim into context-log.md.
42
+ # - Anything else (top-of-file preamble, unrecognized `## ` sections) is
43
+ # dropped or logged to context-log.md's "Other Sections" — never silently
44
+ # lost, but never invented a milestone home either.
45
+ #
46
+ # Idempotency: re-running against an already-migrated tree is a no-op per id
47
+ # — if `<dest>/context/{id}.md` already exists on disk, that id is SKIPPED
48
+ # entirely (Locked + Deferred + Discretion content for that id alike), one
49
+ # skip line printed per id, and the existing file is never touched. Likewise
50
+ # context-log.md and context/_shared.md are left alone if they already exist.
51
+ #
52
+ # Portable: POSIX sh + awk. No network calls.
53
+
54
+ set -u
55
+
56
+ SCRIPT_NAME="forge-context-migrate"
57
+
58
+ source=""
59
+ dest=""
60
+ dry_run=0
61
+
62
+ while [ $# -gt 0 ]; do
63
+ case "$1" in
64
+ --source) shift; source="${1:-}"; [ $# -gt 0 ] && shift || true ;;
65
+ --source=*) source="${1#*=}"; shift ;;
66
+ --dest) shift; dest="${1:-}"; [ $# -gt 0 ] && shift || true ;;
67
+ --dest=*) dest="${1#*=}"; shift ;;
68
+ --dry-run) dry_run=1; shift ;;
69
+ -h|--help)
70
+ printf 'Usage: %s [--source <path>] [--dest <path>] [--dry-run]\n' "$SCRIPT_NAME"
71
+ exit 0
72
+ ;;
73
+ *) printf '%s: unknown argument: %s\n' "$SCRIPT_NAME" "$1" >&2; exit 2 ;;
74
+ esac
75
+ done
76
+
77
+ if [ -z "$source" ] || [ -z "$dest" ]; then
78
+ root="$(git rev-parse --show-toplevel 2>/dev/null)"
79
+ if [ -z "$root" ]; then
80
+ printf '%s: not in a git repo — pass --source and --dest explicitly\n' "$SCRIPT_NAME" >&2
81
+ exit 1
82
+ fi
83
+ [ -z "$source" ] && source="$root/.forge/context.md"
84
+ [ -z "$dest" ] && dest="$root/.forge"
85
+ fi
86
+
87
+ if [ ! -f "$source" ]; then
88
+ printf '%s: source file not found: %s\n' "$SCRIPT_NAME" "$source" >&2
89
+ exit 1
90
+ fi
91
+
92
+ # --- idempotency inputs: which ids/artifacts already exist at dest ----------
93
+
94
+ existing_ids=" "
95
+ if [ -d "$dest/context" ]; then
96
+ for f in "$dest"/context/*.md; do
97
+ [ -f "$f" ] || continue
98
+ base="$(basename "$f" .md)"
99
+ [ "$base" = "_shared" ] && continue
100
+ existing_ids="$existing_ids$base "
101
+ done
102
+ fi
103
+
104
+ skip_log=0
105
+ [ -f "$dest/context-log.md" ] && skip_log=1
106
+
107
+ skip_shared=0
108
+ [ -f "$dest/context/_shared.md" ] && skip_shared=1
109
+
110
+ # --- write target dirs (real run only — dry-run touches nothing) -----------
111
+
112
+ if [ "$dry_run" -eq 0 ]; then
113
+ mkdir -p "$dest/context"
114
+ fi
115
+
116
+ # --- awk parser/writer -------------------------------------------------------
117
+ # Buffers everything in memory (context.md is well under a few hundred KB)
118
+ # then writes/report at END. Program is written to a scratch file so the
119
+ # shell wrapper stays a plain single-quoted awk invocation.
120
+
121
+ awk_prog="$(mktemp 2>/dev/null || mktemp -t forge-context-migrate)"
122
+ trap 'rm -f "$awk_prog"' EXIT INT TERM
123
+
124
+ cat > "$awk_prog" <<'AWKEOF'
125
+ function extract_id(line, w, arr, n, tok, digits, suffix) {
126
+ w = line
127
+ sub(/^### /, "", w)
128
+ sub(/^- /, "", w)
129
+ sub(/^~~/, "", w)
130
+ n = split(w, arr, /[ \t]/)
131
+ if (n < 1) return ""
132
+ tok = arr[1]
133
+ gsub(/[:,.]+$/, "", tok)
134
+ # Purely-numeric ids (M53, m-53, m53) keep the original digit-only
135
+ # normalization. Labelled ids (M-EVT01, m-SEQPRIM01) must NOT be digit-
136
+ # stripped — that would collapse distinct labelled ids onto the same
137
+ # numeric suffix (m-EVT01 and m-SEQPRIM01 would both become m-01) — so
138
+ # their alphanumeric suffix is preserved verbatim (lowercased) instead.
139
+ if (tok !~ /^[Mm]-?[A-Za-z0-9]+$/) return ""
140
+ suffix = tok
141
+ sub(/^[Mm]-?/, "", suffix)
142
+ if (suffix == "") return ""
143
+ if (suffix ~ /^[0-9]+$/) {
144
+ digits = suffix
145
+ gsub(/[^0-9]/, "", digits)
146
+ return "m-" digits
147
+ }
148
+ return "m-" tolower(suffix)
149
+ }
150
+
151
+ function add_id(id) {
152
+ if (!(id in seen_id)) {
153
+ seen_id[id] = 1
154
+ n_ids++
155
+ id_order[n_ids] = id
156
+ }
157
+ }
158
+
159
+ BEGIN {
160
+ section = ""
161
+ cur_state = ""
162
+ cur_id = ""
163
+ cur_noid_key = ""
164
+ n_ids = 0
165
+ n_noid = 0
166
+ }
167
+
168
+ /^## / {
169
+ cur_state = ""
170
+ cur_id = ""
171
+ if ($0 ~ /^## Locked Decisions/) { section = "LOCKED"; cur_state = "LOCKED_PRE"; next }
172
+ if ($0 ~ /^## Deferred Ideas/) { section = "DEFERRED"; next }
173
+ if ($0 ~ /^## Discretion Areas/) { section = "DISCRETION"; next }
174
+ if ($0 ~ /^## Needs Resolution/) { section = "NEEDS_RESOLUTION"; next }
175
+ if ($0 ~ /^## Amendment Log/) { section = "AMENDMENT"; next }
176
+ section = "OTHER"
177
+ other_buf = other_buf $0 "\n"
178
+ next
179
+ }
180
+
181
+ section == "" { next }
182
+
183
+ section == "LOCKED" {
184
+ if ($0 ~ /^### /) {
185
+ id = extract_id($0)
186
+ if (id == "") {
187
+ n_noid++
188
+ cur_noid_key = "NOID" n_noid
189
+ noid_order[n_noid] = cur_noid_key
190
+ noid_body[cur_noid_key] = $0 "\n"
191
+ cur_state = "LOCKED_NOID"
192
+ cur_id = ""
193
+ } else {
194
+ add_id(id)
195
+ locked_body[id] = $0 "\n"
196
+ cur_state = "LOCKED_BLOCK"
197
+ cur_id = id
198
+ }
199
+ next
200
+ }
201
+ if (cur_state == "LOCKED_PRE") { pre_locked_buf = pre_locked_buf $0 "\n"; next }
202
+ if (cur_state == "LOCKED_BLOCK") { locked_body[cur_id] = locked_body[cur_id] $0 "\n"; next }
203
+ if (cur_state == "LOCKED_NOID") { noid_body[cur_noid_key] = noid_body[cur_noid_key] $0 "\n"; next }
204
+ next
205
+ }
206
+
207
+ section == "DEFERRED" || section == "DISCRETION" {
208
+ if ($0 ~ /^- /) {
209
+ id = extract_id($0)
210
+ if (id == "") {
211
+ if (section == "DEFERRED") { cur_state = "SHARED_DEF"; shared_deferred = shared_deferred $0 "\n" }
212
+ else { cur_state = "SHARED_DISC"; shared_discretion = shared_discretion $0 "\n" }
213
+ cur_id = ""
214
+ } else {
215
+ add_id(id)
216
+ if (section == "DEFERRED") { deferred_body[id] = deferred_body[id] $0 "\n"; cur_state = "DEF_ID" }
217
+ else { discretion_body[id] = discretion_body[id] $0 "\n"; cur_state = "DISC_ID" }
218
+ cur_id = id
219
+ }
220
+ next
221
+ }
222
+ if (cur_state == "DEF_ID") { deferred_body[cur_id] = deferred_body[cur_id] $0 "\n"; next }
223
+ if (cur_state == "DISC_ID") { discretion_body[cur_id] = discretion_body[cur_id] $0 "\n"; next }
224
+ if (cur_state == "SHARED_DEF") { shared_deferred = shared_deferred $0 "\n"; next }
225
+ if (cur_state == "SHARED_DISC") { shared_discretion = shared_discretion $0 "\n"; next }
226
+ next
227
+ }
228
+
229
+ section == "NEEDS_RESOLUTION" { needs_resolution_buf = needs_resolution_buf $0 "\n"; next }
230
+ section == "AMENDMENT" { amendment_buf = amendment_buf $0 "\n"; next }
231
+ section == "OTHER" { other_buf = other_buf $0 "\n"; next }
232
+
233
+ END {
234
+ for (i = 1; i <= n_ids; i++) {
235
+ id = id_order[i]
236
+ if (index(existing, " " id " ") > 0) {
237
+ print "SKIP: " id " — context/" id ".md already exists, not overwritten" > "/dev/stderr"
238
+ continue
239
+ }
240
+ content = ""
241
+ if (id in locked_body) content = content locked_body[id]
242
+ if (id in deferred_body) content = content "\n## Deferred\n\n" deferred_body[id]
243
+ if (id in discretion_body) content = content "\n## Discretion\n\n" discretion_body[id]
244
+ fname = dest "/context/" id ".md"
245
+ if (dryrun == "1") {
246
+ print "DRY-RUN: would create " fname
247
+ } else {
248
+ printf "%s", content > fname
249
+ close(fname)
250
+ print "CREATED: " fname
251
+ }
252
+ }
253
+
254
+ if (skip_log == "1") {
255
+ print "SKIP: context-log.md already exists, not overwritten" > "/dev/stderr"
256
+ } else {
257
+ log_out = "# Context Log\n\n"
258
+ log_out = log_out "Migrated from the monolithic `.forge/context.md` (ADR-033). Per-milestone\n"
259
+ log_out = log_out "decisions now live in `.forge/context/{id}.md`; the rendered `.forge/context.md`\n"
260
+ log_out = log_out "is a derived index (forge-state-rollup.sh) — this file carries the content\n"
261
+ log_out = log_out "that never was milestone-scoped.\n\n---\n\n"
262
+ if (pre_locked_buf != "" || n_noid > 0) {
263
+ log_out = log_out "## Locked Decisions (No Milestone ID)\n\n"
264
+ if (pre_locked_buf != "") log_out = log_out pre_locked_buf "\n"
265
+ for (i = 1; i <= n_noid; i++) log_out = log_out noid_body[noid_order[i]] "\n"
266
+ }
267
+ if (other_buf != "") log_out = log_out "## Other Sections\n\n" other_buf "\n"
268
+ log_out = log_out "## Needs Resolution\n\n"
269
+ log_out = log_out (needs_resolution_buf != "" ? needs_resolution_buf : "(none)\n")
270
+ log_out = log_out "\n---\n\n## Amendment Log\n\n" amendment_buf
271
+ fname = dest "/context-log.md"
272
+ if (dryrun == "1") {
273
+ print "DRY-RUN: would create " fname
274
+ } else {
275
+ printf "%s", log_out > fname
276
+ close(fname)
277
+ print "CREATED: " fname
278
+ }
279
+ }
280
+
281
+ if (shared_deferred == "" && shared_discretion == "") {
282
+ # nothing genuinely cross-cutting — do not create an empty file
283
+ } else if (skip_shared == "1") {
284
+ print "SKIP: context/_shared.md already exists, not overwritten" > "/dev/stderr"
285
+ } else {
286
+ shared_out = "# Shared / Cross-Cutting Context\n\n"
287
+ shared_out = shared_out "Deferred Ideas and Discretion Areas entries from `.forge/context.md` with no\n"
288
+ shared_out = shared_out "resolvable milestone-id prefix — genuinely cross-cutting, not owned by one\n"
289
+ shared_out = shared_out "milestone.\n\n---\n\n"
290
+ if (shared_deferred != "") shared_out = shared_out "## Deferred Ideas\n\n" shared_deferred "\n"
291
+ if (shared_discretion != "") shared_out = shared_out "## Discretion Areas\n\n" shared_discretion "\n"
292
+ fname = dest "/context/_shared.md"
293
+ if (dryrun == "1") {
294
+ print "DRY-RUN: would create " fname
295
+ } else {
296
+ printf "%s", shared_out > fname
297
+ close(fname)
298
+ print "CREATED: " fname
299
+ }
300
+ }
301
+
302
+ if (n_ids == 0 && n_noid > 0) {
303
+ print "forge-context-migrate: ERROR — 0 milestone id(s) resolved but " n_noid \
304
+ " Locked Decision heading(s) found with no resolvable id; this usually means the" \
305
+ " heading's id scheme doesn't match m-<number> or a labelled m-<label> form" \
306
+ " (check context.md's heading format before proceeding)" > "/dev/stderr"
307
+ print "forge-context-migrate: " n_ids " milestone id(s) processed"
308
+ exit 1
309
+ }
310
+
311
+ print "forge-context-migrate: " n_ids " milestone id(s) processed"
312
+ }
313
+ AWKEOF
314
+
315
+ awk -v dest="$dest" -v dryrun="$dry_run" -v existing="$existing_ids" \
316
+ -v skip_log="$skip_log" -v skip_shared="$skip_shared" \
317
+ -f "$awk_prog" "$source"
@@ -28,7 +28,13 @@
28
28
  # (3) unknown in both → exit 2 — the fold cannot ask the question, so it
29
29
  # never emits a false verdict.
30
30
  #
31
- # merged = `git merge-base --is-ancestor <sha> <main-ref>`.
31
+ # merged = `git merge-base --is-ancestor <sha> <main-ref>`, OR — when that
32
+ # fails — a squash-merge equivalence check (see is_merged() below). GitHub's
33
+ # squash-and-merge creates a brand-new commit on <main-ref> with no ancestor
34
+ # relationship to the source branch, so the direct check alone is a permanent
35
+ # false negative for any squash-merged unit whose branch/stream ref is still
36
+ # live (issue #39, m-55; reproduced live via m-53/PR#38). The fallback stays
37
+ # git-only, no network — NFR-030 unaffected.
32
38
  #
33
39
  # Validation is dispatched on forge.validation_event from the project's
34
40
  # .forge/project.yml (default: in_session_signoff; promotion_approval is the
@@ -171,6 +177,33 @@ hv_is_set() {
171
177
  '
172
178
  }
173
179
 
180
+ # is_merged <sha> — merged rung of the fold. Direct ancestor check first
181
+ # (unchanged cost/behavior for fast-forward / true-merge-commit units). On
182
+ # failure, falls back to a squash-merge equivalence check: build an
183
+ # unreferenced synthetic commit representing <sha>'s cumulative diff since it
184
+ # diverged from $main_ref, then ask `git cherry` (patch-id based — the same
185
+ # primitive git itself uses to detect already-applied/rebased/squashed
186
+ # commits) whether an equivalent patch already landed on $main_ref. Squash-
187
+ # and-merge creates a brand-new commit with no ancestor relationship to the
188
+ # source branch, so the direct check alone is a permanent false negative for
189
+ # every squash-merged unit whose branch/stream ref is still live (m-53/PR#38,
190
+ # issue #39). Git-only, no network (NFR-030 preserved) — the synthetic commit
191
+ # is never attached to a ref, so it's naturally garbage-collectable.
192
+ is_merged() {
193
+ target="$1"
194
+ if git -C "$root" merge-base --is-ancestor "$target" "$main_ref" 2>/dev/null; then
195
+ return 0
196
+ fi
197
+ mb="$(git -C "$root" merge-base "$main_ref" "$target" 2>/dev/null)" || return 1
198
+ [ -n "$mb" ] || return 1
199
+ tgt_tree="$(git -C "$root" rev-parse -q --verify "$target^{tree}" 2>/dev/null)" || return 1
200
+ synth="$(git -C "$root" commit-tree "$tgt_tree" -p "$mb" -m squash-probe 2>/dev/null)" || return 1
201
+ case "$(git -C "$root" cherry "$main_ref" "$synth" 2>/dev/null)" in
202
+ -*) return 0 ;;
203
+ *) return 1 ;;
204
+ esac
205
+ }
206
+
174
207
  # --- argument parsing ------------------------------------------------------
175
208
 
176
209
  unit=""
@@ -281,7 +314,7 @@ fi
281
314
  # validated REQUIRES merged — checked first, always. An unmerged unit is
282
315
  # unmerged no matter what any approval artifact says.
283
316
 
284
- if ! git -C "$root" merge-base --is-ancestor "$sha" "$main_ref" 2>/dev/null; then
317
+ if ! is_merged "$sha"; then
285
318
  printf 'release_state=unmerged reason=not-on-main\n'
286
319
  exit 0
287
320
  fi
@@ -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