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.
- package/package.json +1 -1
- package/template/.claude/hooks/forge-context-migrate.sh +297 -0
- 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 +166 -0
- 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 +158 -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/package.json
CHANGED
|
@@ -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 (
|
|
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
|
|
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
|