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.
Files changed (41) hide show
  1. package/bin/create-forge.js +27 -0
  2. package/experimental/m10/README.md +4 -4
  3. package/experimental/m10/install.sh +10 -9
  4. package/experimental/m10/source/hooks/protect-primary-checkout.sh +149 -0
  5. package/package.json +1 -1
  6. package/template/.claude/hooks/forge-context-migrate.sh +297 -0
  7. package/template/.claude/hooks/forge-size-gate.sh +175 -0
  8. package/template/.claude/hooks/forge-state-rollup.sh +135 -2
  9. package/template/.claude/hooks/protect-primary-checkout.sh +149 -0
  10. package/template/.claude/hooks/skill-gate-tiers.txt +6 -0
  11. package/template/.claude/hooks/tests/forge-context-migrate.test.sh +166 -0
  12. package/template/.claude/hooks/tests/forge-size-gate.test.sh +132 -0
  13. package/template/.claude/hooks/tests/protect-primary-checkout.test.sh +147 -0
  14. package/template/.claude/settings.json +14 -1
  15. package/template/.claude/skills/discussing/SKILL.md +30 -31
  16. package/template/.claude/skills/executing/SKILL.md +3 -3
  17. package/template/.claude/skills/forge/SKILL.md +112 -170
  18. package/template/.claude/skills/forge/references/manual-fallback-procedures.md +54 -0
  19. package/template/.claude/skills/initializing/SKILL.md +20 -1
  20. package/template/.claude/skills/planning/SKILL.md +11 -9
  21. package/template/.claude/skills/researching/SKILL.md +2 -0
  22. package/template/.claude/skills/reviewing/SKILL.md +17 -0
  23. package/template/.claude/skills/upgrading/SKILL.md +22 -2
  24. package/template/.claude/skills/verifying/SKILL.md +2 -2
  25. package/template/.forge/FORGE.md +81 -90
  26. package/template/.forge/bin/new-worktree.sh +61 -0
  27. package/template/.forge/bin/tests/new-worktree.test.sh +70 -0
  28. package/template/.forge/checks/forge-jarvis-awareness.sh +46 -2
  29. package/template/.forge/checks/forge-jarvis-relay.sh +64 -16
  30. package/template/.forge/checks/tests/forge-jarvis-awareness.test.sh +58 -0
  31. package/template/.forge/checks/tests/forge-jarvis-relay.test.sh +50 -0
  32. package/template/.forge/git-hooks/pre-commit +57 -0
  33. package/template/.forge/git-hooks/tests/pre-commit.test.sh +97 -0
  34. package/template/.forge/migrations/0.78.0-weld-primary-checkout.md +66 -0
  35. package/template/.forge/migrations/0.80.0-context-md-sharding.md +158 -0
  36. package/template/.forge/migrations/0.80.0-size-gate-wiring.md +145 -0
  37. package/template/.forge/templates/constitution.md +1 -1
  38. package/template/.forge/templates/context-milestone.md +30 -0
  39. package/template/.forge/templates/slice-runner/config.yml +7 -1
  40. package/template/.forge/templates/state/desire-path.yml +3 -0
  41. 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 (both are git-ignored render-on-read caches since 0.53.0):
25
+ # What it writes (all three are git-ignored render-on-read caches since
26
+ # 0.53.0 / this addendum for context.md):
21
27
  # .forge/state/index.yml — derived milestone registry
22
28
  # .forge/streams/active.yml — derived stream registry (if streams/ exists)
29
+ # .forge/context.md — derived context index (if context/ exists)
23
30
  # It NEVER commits them and NEVER runs git add/commit. Rendering a gitignored
24
31
  # cache is a local write that never lands on main, so any checkout (main OR
25
32
  # worktree) may render its own copy (FORGE.md → State Ownership, Derived class).
@@ -398,15 +405,136 @@ render_active() {
398
405
  STREAM_ROW_COUNT="$sn"
399
406
  }
400
407
 
408
+ # --- Context Index (ADR-033 addendum) ----------------------------------------
409
+ # Third join: .forge/context/*.md (one file per milestone, produced by
410
+ # forge-context-migrate.sh) ⋈ the index.yml rows this same pass already
411
+ # rendered above — no second fold invocation, reuse what render_index wrote.
412
+
413
+ # context_index_lookup <norm_id> — prints "name\tstatus\trelease_state" for
414
+ # the index.yml row matching <norm_id> (already normalized, no leading "m-").
415
+ # Empty output when no row matches — degrade gracefully, never error.
416
+ context_index_lookup() {
417
+ awk -v want="$1" '
418
+ /^ - id:/ { id=$3; gsub(/["\x27]/,"",id); sub(/^m-/,"",id); inrow=(id==want); name=""; status=""; rel="" }
419
+ inrow && /^ name:/ { name=$0; sub(/^ name: /,"",name); gsub(/^["\x27]|["\x27]$/,"",name) }
420
+ inrow && /^ status:/ { status=$2 }
421
+ inrow && /^ release_state:/ { rel=$2; print name "\t" status "\t" rel; exit }
422
+ ' "$root/.forge/state/index.yml" 2>/dev/null
423
+ }
424
+
425
+ # context_summary <file> — a <!-- summary: ... --> marker wins over the
426
+ # first "- " bullet; empty when the file has neither (row still renders,
427
+ # just with an empty summary — never a crash).
428
+ context_summary() {
429
+ f="$1"
430
+ s="$(awk '/<!-- summary:/ { line=$0; sub(/^.*<!-- summary: */,"",line); sub(/ *-->.*$/,"",line); print line; exit }' "$f" 2>/dev/null)"
431
+ if [ -z "$s" ]; then
432
+ s="$(awk '/^- / { line=$0; sub(/^- /,"",line); print line; exit }' "$f" 2>/dev/null)"
433
+ fi
434
+ printf '%s\n' "$s"
435
+ }
436
+
437
+ CONTEXT_ROW_COUNT=0
438
+
439
+ render_context_index() {
440
+ context_path="$root/.forge/context.md"
441
+ ctx_rows="" # "id\tname\tstatus\trelease_state\tsummary\tpath"
442
+ cn=0
443
+
444
+ for cfile in "$root"/.forge/context/*.md; do
445
+ [ -f "$cfile" ] || continue
446
+ case "$(basename "$cfile")" in _shared.md) continue ;; esac
447
+ c_id="$(basename "$cfile" .md)"
448
+ [ -z "$c_id" ] && continue
449
+ rel_path=".forge/context/$(basename "$cfile")"
450
+
451
+ fields="$(context_index_lookup "$(norm_id "$c_id")")"
452
+ if [ -n "$fields" ]; then
453
+ c_name="$(printf '%s' "$fields" | awk -F'\t' '{print $1}')"
454
+ c_status="$(printf '%s' "$fields" | awk -F'\t' '{print $2}')"
455
+ c_rel="$(printf '%s' "$fields" | awk -F'\t' '{print $3}')"
456
+ else
457
+ c_name=""
458
+ c_status="unknown"
459
+ c_rel="unknown"
460
+ fi
461
+ [ -z "$c_status" ] && c_status="unknown"
462
+ [ -z "$c_rel" ] && c_rel="unknown"
463
+ c_summary="$(context_summary "$cfile")"
464
+
465
+ # A tab-delimited empty field is unsafe: `read` with IFS set to a plain
466
+ # tab still treats it as IFS-whitespace, so consecutive/empty fields get
467
+ # collapsed and every column after them shifts left. name/summary are the
468
+ # only fields that can legitimately be empty — substitute a sentinel and
469
+ # translate it back to "" after the read loop below.
470
+ [ -z "$c_name" ] && c_name="@@EMPTY@@"
471
+ [ -z "$c_summary" ] && c_summary="@@EMPTY@@"
472
+
473
+ ctx_rows="$ctx_rows$c_id $c_name $c_status $c_rel $c_summary $rel_path
474
+ "
475
+ cn=$((cn + 1))
476
+ done
477
+
478
+ sorted="$(printf '%s' "$ctx_rows" | LC_ALL=C sort -t' ' -k1,1)"
479
+
480
+ {
481
+ printf '# Locked Context — Index (DERIVED — do not hand-edit)\n'
482
+ printf '# Regenerated at boot by .claude/hooks/forge-state-rollup.sh: one row per\n'
483
+ printf '# .forge/context/{id}.md, joined with state/index.yml (status/release_state\n'
484
+ printf '# already resolved by this same pass). Git-ignored render-on-read cache —\n'
485
+ printf '# never committed. Full decision text lives in .forge/context/{id}.md, not\n'
486
+ printf '# here. See .forge/context-log.md (Amendment Log + Needs Resolution + any\n'
487
+ printf '# content with no resolvable milestone id) and .forge/context/_shared.md\n'
488
+ printf '# (cross-cutting Deferred Ideas / Discretion Areas, if present).\n'
489
+ printf '#\n'
490
+ printf '# Rules (apply to every context/{id}.md this index points at):\n'
491
+ printf '# - Locked Decisions -> MUST be implemented exactly as stated.\n'
492
+ printf '# - Deferred Ideas -> MUST NOT appear in plans, tasks, or code.\n'
493
+ printf '# - Discretion Areas -> Agent uses judgment; research, pick, document why.\n'
494
+ printf '#\n'
495
+ printf '# A milestone that is complete AND validated renders as a thin row\n'
496
+ printf '# (id, name, path only) — content never moves, only the render shortens.\n'
497
+ printf 'context:\n'
498
+ if [ "$cn" -eq 0 ]; then
499
+ printf ' []\n'
500
+ else
501
+ printf '%s\n' "$sorted" | while IFS=' ' read -r r_id r_name r_status r_rel r_summary r_path; do
502
+ [ -z "$r_id" ] && continue
503
+ [ "$r_name" = "@@EMPTY@@" ] && r_name=""
504
+ [ "$r_summary" = "@@EMPTY@@" ] && r_summary=""
505
+ printf ' - id: %s\n' "$r_id"
506
+ printf ' name: %s\n' "$(yaml_quote "$r_name")"
507
+ if [ "$r_status" = "complete" ] && [ "$r_rel" = "validated" ]; then
508
+ printf ' path: %s\n' "$r_path"
509
+ else
510
+ printf ' status: %s\n' "$r_status"
511
+ printf ' release_state: %s\n' "$r_rel"
512
+ [ -n "$r_summary" ] && printf ' summary: %s\n' "$(yaml_quote "$r_summary")"
513
+ printf ' path: %s\n' "$r_path"
514
+ fi
515
+ done
516
+ fi
517
+ printf 'see_also:\n'
518
+ printf ' - .forge/context-log.md\n'
519
+ printf ' - .forge/context/_shared.md\n'
520
+ } > "$context_path.tmp.$$" && mv -f "$context_path.tmp.$$" "$context_path"
521
+
522
+ CONTEXT_ROW_COUNT="$cn"
523
+ }
524
+
401
525
  # --- run ---------------------------------------------------------------------
402
526
 
403
- render_index # index.yml first — stream rollup reads it
527
+ render_index # index.yml first — stream rollup + context index read it
404
528
 
405
529
  if [ -d "$root/.forge/streams" ]; then
406
530
  stream_autoclose_sweep # step 0 (main checkout only)
407
531
  render_active # then the join
408
532
  fi
409
533
 
534
+ if [ -d "$root/.forge/context" ]; then
535
+ render_context_index # third join
536
+ fi
537
+
410
538
  # --- summary (stdout → injected into boot context) ---------------------------
411
539
 
412
540
  if [ "$quiet" -eq 0 ]; then
@@ -418,6 +546,11 @@ if [ "$quiet" -eq 0 ]; then
418
546
  [ "${STREAM_ROW_COUNT:-0}" = "1" ] || summary="${summary}s"
419
547
  summary="$summary)"
420
548
  fi
549
+ if [ -d "$root/.forge/context" ]; then
550
+ summary="$summary + context.md (${CONTEXT_ROW_COUNT:-0} context row"
551
+ [ "${CONTEXT_ROW_COUNT:-0}" = "1" ] || summary="${summary}s"
552
+ summary="$summary)"
553
+ fi
421
554
  [ -n "$SWEEP_CLOSED" ] && summary="$summary; auto-closed:${SWEEP_CLOSED}"
422
555
  [ "${fold_unavailable:-0}" -gt 0 ] && summary="$summary; ${fold_unavailable} release_state unknown (fold unavailable)"
423
556
  if [ "$in_main_checkout" -eq 1 ]; then
@@ -0,0 +1,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