forge-orkes 0.77.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/bin/create-forge.js +27 -0
- package/experimental/m10/README.md +4 -4
- package/experimental/m10/install.sh +10 -9
- package/experimental/m10/source/hooks/protect-primary-checkout.sh +149 -0
- 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/protect-primary-checkout.sh +149 -0
- 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/hooks/tests/protect-primary-checkout.test.sh +147 -0
- package/template/.claude/settings.json +14 -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/initializing/SKILL.md +20 -1
- package/template/.claude/skills/planning/SKILL.md +11 -9
- package/template/.claude/skills/researching/SKILL.md +2 -0
- package/template/.claude/skills/reviewing/SKILL.md +17 -0
- package/template/.claude/skills/upgrading/SKILL.md +22 -2
- package/template/.claude/skills/verifying/SKILL.md +2 -2
- package/template/.forge/FORGE.md +81 -90
- package/template/.forge/bin/new-worktree.sh +61 -0
- package/template/.forge/bin/tests/new-worktree.test.sh +70 -0
- 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/git-hooks/pre-commit +57 -0
- package/template/.forge/git-hooks/tests/pre-commit.test.sh +97 -0
- package/template/.forge/migrations/0.78.0-weld-primary-checkout.md +66 -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/template/.forge/templates/state/desire-path.yml +3 -0
- package/experimental/m10/source/hooks/forge-branch-guard.sh +0 -61
|
@@ -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,149 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Weld the primary checkout to the default branch — deny branch create/switch there.
|
|
3
|
+
#
|
|
4
|
+
# PreToolUse(Bash) hook. Exit 2 (+ JSON permissionDecision:deny) = block. Exit 0 = allow.
|
|
5
|
+
#
|
|
6
|
+
# WHY: the primary checkout's HEAD is shared mutable state across every concurrent
|
|
7
|
+
# Claude session running in that directory. One session running `git checkout -b`
|
|
8
|
+
# silently redirects every OTHER session's subsequent commits onto its branch — an
|
|
9
|
+
# invisible clobber that surfaces later as wrong-branch commits, contaminated PRs,
|
|
10
|
+
# stranded milestone state, or dropped history (issue #21: 3 confirmed incidents —
|
|
11
|
+
# m85's 12-commit drop, m62's stranded phase commits, a Quick-tier checkout -b that
|
|
12
|
+
# contaminated a concurrent PR). Branch work belongs in worktrees, which each own
|
|
13
|
+
# their own HEAD.
|
|
14
|
+
#
|
|
15
|
+
# Scope: fires ONLY in the primary checkout (git-dir == git-common-dir). Linked
|
|
16
|
+
# worktrees are unaffected (their git-dir differs). Unlike the dormant
|
|
17
|
+
# forge-branch-guard.sh this supersedes, the branch weld is UNCONDITIONAL — it fires
|
|
18
|
+
# even when no sibling worktree exists yet, because the second session's worktree is
|
|
19
|
+
# typically created AFTER the offending `checkout -b`. This hook also folds in the
|
|
20
|
+
# one distinct protection the old guard provided: denying a hard-reset in the primary
|
|
21
|
+
# checkout while sibling worktrees are branched off it (the history-rewrite class).
|
|
22
|
+
#
|
|
23
|
+
# Default branch is resolved dynamically (main/master + init.defaultBranch), never
|
|
24
|
+
# hardcoded — Forge ships to repos on any default branch.
|
|
25
|
+
#
|
|
26
|
+
# Fails OPEN (exit 0 / allow) on any malfunction (no jq, empty command, non-git cwd,
|
|
27
|
+
# git probe error) — a guard must never wedge the agent on its own failure.
|
|
28
|
+
#
|
|
29
|
+
# Escape hatch (deliberate, human use): ALLOW_MAIN_BRANCHING=1
|
|
30
|
+
|
|
31
|
+
set -uo pipefail
|
|
32
|
+
|
|
33
|
+
emit_deny() {
|
|
34
|
+
local reason="${1//\"/\\\"}"
|
|
35
|
+
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"%s"}}\n' "$reason"
|
|
36
|
+
exit 2
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
[ "${ALLOW_MAIN_BRANCHING:-0}" = "1" ] && exit 0
|
|
40
|
+
|
|
41
|
+
command -v jq >/dev/null 2>&1 || exit 0
|
|
42
|
+
|
|
43
|
+
INPUT=$(cat)
|
|
44
|
+
COMMAND=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null || true)
|
|
45
|
+
[ -z "$COMMAND" ] && exit 0
|
|
46
|
+
|
|
47
|
+
# Normalize the cheapest whitespace-substitution evasion (`git checkout$IFS-b`) to a
|
|
48
|
+
# real space before matching. A string matcher on an un-expanded shell command can
|
|
49
|
+
# never be complete against expansion — the pre-commit backstop is the sound layer —
|
|
50
|
+
# but closing the trivial $IFS trick keeps the PreToolUse layer honest.
|
|
51
|
+
COMMAND=$(printf '%s' "$COMMAND" | sed -E 's/\$\{?IFS\}?/ /g')
|
|
52
|
+
|
|
53
|
+
# Git allows global options BEFORE the subcommand: `git -c k=v checkout`,
|
|
54
|
+
# `git --no-pager switch`, `git -C <path> -p checkout`, etc. The guard must tolerate
|
|
55
|
+
# ANY run of them, not just a single `-C <path>` — otherwise `git -c x=y checkout -b`
|
|
56
|
+
# sails past (issue #21 regression). GITPRE matches `git` + zero-or-more global options
|
|
57
|
+
# (a `-x`/`--long`/`--long=val`/`-C <path>`/`-c <kv>` token, each optionally taking the
|
|
58
|
+
# next word as its value) up to the subcommand.
|
|
59
|
+
GITPRE='git[[:space:]]+((-[A-Za-z]|--[A-Za-z-]+)(=[^[:space:]]+)?[[:space:]]+([^-][^[:space:]]*[[:space:]]+)?)*'
|
|
60
|
+
|
|
61
|
+
# Fast path: only inspect commands that touch checkout/switch/reset at all. Anything
|
|
62
|
+
# else exits immediately WITHOUT any git probe (keeps the hook cheap on every Bash call).
|
|
63
|
+
printf '%s' "$COMMAND" | grep -qE "${GITPRE}(checkout|switch|reset)([[:space:]]|\$)" || exit 0
|
|
64
|
+
|
|
65
|
+
# Resolve which repo dir the command targets: honor `git -C <path>`, else the hook cwd.
|
|
66
|
+
CWD=$(printf '%s' "$INPUT" | jq -r '.cwd // empty' 2>/dev/null || true)
|
|
67
|
+
[ -z "$CWD" ] && CWD=$(pwd)
|
|
68
|
+
# Honor a GLOBAL `git -C <path>` (before the subcommand) only. Isolate the pre-subcommand
|
|
69
|
+
# segment first so `switch -C`/`checkout -C` (create-force, AFTER the subcommand) is never
|
|
70
|
+
# mistaken for a target-dir option.
|
|
71
|
+
PRE=$(printf '%s' "$COMMAND" | sed -E 's/[[:space:]](checkout|switch|reset)[[:space:]].*$//')
|
|
72
|
+
GC_PATH=$(printf '%s' "$PRE" | grep -oE '(^|[[:space:]])-C[[:space:]]+[^[:space:]]+' | head -1 | awk '{print $2}' || true)
|
|
73
|
+
# A flag-shaped -C value (e.g. `-C --foo`) is not a real target dir → ignore it.
|
|
74
|
+
case "$GC_PATH" in -*) GC_PATH="" ;; esac
|
|
75
|
+
TARGET_DIR="${GC_PATH:-$CWD}"
|
|
76
|
+
|
|
77
|
+
GIT_DIR=$(git -C "$TARGET_DIR" rev-parse --git-dir 2>/dev/null) || exit 0
|
|
78
|
+
COMMON_DIR=$(git -C "$TARGET_DIR" rev-parse --git-common-dir 2>/dev/null) || exit 0
|
|
79
|
+
GIT_DIR_ABS=$(cd "$TARGET_DIR" 2>/dev/null && cd "$GIT_DIR" 2>/dev/null && pwd) || exit 0
|
|
80
|
+
COMMON_DIR_ABS=$(cd "$TARGET_DIR" 2>/dev/null && cd "$COMMON_DIR" 2>/dev/null && pwd) || exit 0
|
|
81
|
+
|
|
82
|
+
# In a linked worktree git-dir != git-common-dir → branch ops are fine there.
|
|
83
|
+
[ "$GIT_DIR_ABS" != "$COMMON_DIR_ABS" ] && exit 0
|
|
84
|
+
|
|
85
|
+
# ── Default-branch set (dynamic — mirrors block-dangerous-commands.sh) ───────────
|
|
86
|
+
# Seed main+master, add the configured init.defaultBranch. A switch/checkout TO any
|
|
87
|
+
# of these names is "returning home" and always allowed.
|
|
88
|
+
DEFAULT_BRANCHES="main master"
|
|
89
|
+
GIT_DEFAULT=$(git -C "$TARGET_DIR" config --get init.defaultBranch 2>/dev/null || true)
|
|
90
|
+
[ -n "$GIT_DEFAULT" ] && DEFAULT_BRANCHES="$DEFAULT_BRANCHES $GIT_DEFAULT"
|
|
91
|
+
is_default_branch() {
|
|
92
|
+
for b in $DEFAULT_BRANCHES; do [ "$1" = "$b" ] && return 0; done
|
|
93
|
+
return 1
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
DENY_MSG="Blocked: the primary checkout's HEAD stays on the default branch — branch create/switch here silently redirects every other concurrent session's commits onto your branch (recurring clobber, issue #21). Do branch work in a worktree: bash .forge/bin/new-worktree.sh <slug> (or git worktree add). To operate on an existing worktree from here, use git -C <worktree-path>. Human escape hatch: ALLOW_MAIN_BRANCHING=1."
|
|
97
|
+
|
|
98
|
+
# ── 1) Branch creation / orphan / detach / track: always deny in primary checkout ──
|
|
99
|
+
if printf '%s' "$COMMAND" | grep -qE "${GITPRE}checkout[[:space:]]+((-[a-zA-Z]*[bB][a-zA-Z]*|--orphan|--detach|--track)([[:space:]=]|\$))"; then
|
|
100
|
+
emit_deny "$DENY_MSG"
|
|
101
|
+
fi
|
|
102
|
+
if printf '%s' "$COMMAND" | grep -qE "${GITPRE}switch[[:space:]]+((-[cC]|--create|--force-create|--orphan|--detach|--track)([[:space:]=]|\$))"; then
|
|
103
|
+
emit_deny "$DENY_MSG"
|
|
104
|
+
fi
|
|
105
|
+
|
|
106
|
+
# ── 2) hard/keep/merge reset in the primary checkout while worktrees are branched ──
|
|
107
|
+
# Folded from the superseded forge-branch-guard.sh: a reset that rewrites HEAD +
|
|
108
|
+
# working tree here can drop verified work committed under sibling worktrees. Gated
|
|
109
|
+
# on >1 worktree (unlike the branch weld, which is unconditional).
|
|
110
|
+
if printf '%s' "$COMMAND" | grep -qE "${GITPRE}reset[[:space:]]+(--hard|--keep|--merge)([[:space:]]|\$)"; then
|
|
111
|
+
wt_count=$(git -C "$TARGET_DIR" worktree list 2>/dev/null | wc -l | tr -d ' ')
|
|
112
|
+
if [ "${wt_count:-1}" -gt 1 ]; then
|
|
113
|
+
emit_deny "Refusing to move the shared primary HEAD via reset — $wt_count worktrees are branched off it (the 'burned twice' history-rewrite failure, issue #21). Reset inside a worktree, or stop the other sessions first. Human escape hatch: ALLOW_MAIN_BRANCHING=1."
|
|
114
|
+
fi
|
|
115
|
+
exit 0 # single-worktree hard-reset is the operator's own business — allow.
|
|
116
|
+
fi
|
|
117
|
+
|
|
118
|
+
# ── 3) checkout/switch to a ref other than the default branch ───────────────────
|
|
119
|
+
# Only checkout/switch move HEAD to a ref. A reset that reached this far (--soft /
|
|
120
|
+
# --mixed, or a hard/keep/merge reset in a single-worktree repo) is not a branch move
|
|
121
|
+
# off the default → allow it and stop.
|
|
122
|
+
printf '%s' "$COMMAND" | grep -qE "${GITPRE}(checkout|switch)([[:space:]]|\$)" || exit 0
|
|
123
|
+
|
|
124
|
+
# `git checkout <ref> -- <paths>` and `git checkout -- <paths>` never move HEAD →
|
|
125
|
+
# allow anything containing a ` -- ` pathspec separator.
|
|
126
|
+
printf '%s' "$COMMAND" | grep -qE '[[:space:]]--([[:space:]]|$)' && exit 0
|
|
127
|
+
|
|
128
|
+
# Extract the first non-flag argument after checkout/switch. Strip everything up to and
|
|
129
|
+
# including the subcommand keyword — whatever git global options preceded it (`-c`,
|
|
130
|
+
# `--no-pager`, `-C <path>`, …) are consumed by the greedy prefix. The trailing separator
|
|
131
|
+
# is optional so a BARE `git checkout` (no target) yields empty ARGS → allowed below.
|
|
132
|
+
ARGS=$(printf '%s' "$COMMAND" | sed -E 's/.*[[:space:]](checkout|switch)([[:space:]]+|$)/ /' | head -1)
|
|
133
|
+
FIRST_REF=""
|
|
134
|
+
for word in $ARGS; do
|
|
135
|
+
case "$word" in
|
|
136
|
+
-*) continue ;; # flags (creation flags already denied above)
|
|
137
|
+
\;|\&\&|\|\||\||\&) break ;; # end of this git command
|
|
138
|
+
*) FIRST_REF="$word"; break ;;
|
|
139
|
+
esac
|
|
140
|
+
done
|
|
141
|
+
|
|
142
|
+
# Bare `git checkout`/`git switch` (no target) is a no-op/error → allow.
|
|
143
|
+
[ -z "$FIRST_REF" ] && exit 0
|
|
144
|
+
|
|
145
|
+
# Returning home (default branch) or file-restore forms → allow. Any other ref → deny.
|
|
146
|
+
case "$FIRST_REF" in
|
|
147
|
+
HEAD|.) exit 0 ;; # file restore forms
|
|
148
|
+
*) is_default_branch "$FIRST_REF" && exit 0 || emit_deny "$DENY_MSG" ;;
|
|
149
|
+
esac
|
|
@@ -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
|