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.
- package/bin/create-forge.js +6 -0
- package/package.json +1 -1
- package/template/.claude/hooks/forge-context-migrate.sh +317 -0
- package/template/.claude/hooks/forge-release-fold.sh +35 -2
- package/template/.claude/hooks/forge-size-gate.sh +175 -0
- package/template/.claude/hooks/forge-state-rollup.sh +135 -2
- package/template/.claude/hooks/skill-gate-tiers.txt +6 -0
- package/template/.claude/hooks/tests/forge-context-migrate.test.sh +248 -0
- package/template/.claude/hooks/tests/forge-release-fold.test.sh +53 -2
- package/template/.claude/hooks/tests/forge-size-gate.test.sh +132 -0
- package/template/.claude/settings.json +5 -1
- package/template/.claude/skills/discussing/SKILL.md +30 -31
- package/template/.claude/skills/executing/SKILL.md +3 -3
- package/template/.claude/skills/forge/SKILL.md +112 -170
- package/template/.claude/skills/forge/references/manual-fallback-procedures.md +54 -0
- package/template/.claude/skills/planning/SKILL.md +11 -9
- package/template/.claude/skills/researching/SKILL.md +2 -0
- package/template/.claude/skills/verifying/SKILL.md +2 -2
- package/template/.forge/FORGE.md +79 -91
- package/template/.forge/checks/forge-jarvis-awareness.sh +46 -2
- package/template/.forge/checks/forge-jarvis-relay.sh +64 -16
- package/template/.forge/checks/tests/forge-jarvis-awareness.test.sh +58 -0
- package/template/.forge/checks/tests/forge-jarvis-relay.test.sh +50 -0
- package/template/.forge/migrations/0.80.0-context-md-sharding.md +175 -0
- package/template/.forge/migrations/0.80.0-size-gate-wiring.md +145 -0
- package/template/.forge/templates/constitution.md +1 -1
- package/template/.forge/templates/context-milestone.md +30 -0
- package/template/.forge/templates/slice-runner/config.yml +7 -1
package/bin/create-forge.js
CHANGED
|
@@ -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
|
@@ -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 !
|
|
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
|