forge-orkes 0.77.0 → 0.79.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.
@@ -124,6 +124,24 @@ function makeHookScriptsExecutable(hooksDir) {
124
124
  try { walk(hooksDir); } catch { /* best-effort */ }
125
125
  }
126
126
 
127
+ /**
128
+ * Make the tracked git hooks under .forge/git-hooks/ executable (issue #21 / ADR-031).
129
+ * These are git hooks (pre-commit, …) with NO .sh extension, so makeHookScriptsExecutable
130
+ * (which only chmods *.sh) misses them — chmod them explicitly. Best-effort; never throws.
131
+ * NOTE: this function deliberately does NO `git config core.hooksPath` — per-clone wiring
132
+ * (with the Husky/lefthook conflict warning) is owned by the initializing/upgrading skills,
133
+ * not the installer, which stays git-config-free and runs unattended in CI.
134
+ */
135
+ function makeGitHooksExecutable(forgeDir) {
136
+ const gitHooksDir = path.join(forgeDir, 'git-hooks');
137
+ if (!fs.existsSync(gitHooksDir)) return;
138
+ for (const entry of fs.readdirSync(gitHooksDir, { withFileTypes: true })) {
139
+ if (entry.isFile() && !entry.name.startsWith('.')) {
140
+ try { fs.chmodSync(path.join(gitHooksDir, entry.name), 0o755); } catch { /* best-effort */ }
141
+ }
142
+ }
143
+ }
144
+
127
145
  /**
128
146
  * Additively sync a framework-managed `.claude/` dir into an existing project on
129
147
  * upgrade (ADR-017/018). Copies/updates the template's files, but NEVER deletes
@@ -498,6 +516,7 @@ async function install() {
498
516
  const srcForge = path.join(templateDir, '.forge');
499
517
  const destForge = path.join(targetDir, '.forge');
500
518
  const forgeCount = copyDirRecursive(srcForge, destForge);
519
+ makeGitHooksExecutable(destForge); // extensionless git hooks (.forge/git-hooks/pre-commit)
501
520
  console.log(` Installed .forge/templates/ (${forgeCount} files)`);
502
521
 
503
522
  // npm strips files literally named `.gitignore` from the published tarball, so
@@ -858,6 +877,14 @@ async function upgrade() {
858
877
  // protected set (.forge/checks/**) can guard them. Additive for the same
859
878
  // reason as above: never delete a project-local extra check.
860
879
  upgradeAdditiveDir('.forge/checks', results);
880
+ // 2-git-hooks. Sync the tracked git hooks (issue #21 / ADR-031: the primary-checkout
881
+ // weld pre-commit backstop). Tracked at .forge/git-hooks/ for the same reason as
882
+ // .forge/checks/ — .claude/hooks/ is commonly gitignored, and a git hook must be
883
+ // tracked to run for humans/CI. Additive; then chmod the extensionless hooks
884
+ // (upgradeAdditiveDir only +x's *.sh). Per-clone core.hooksPath wiring is the
885
+ // initializing/upgrading skills' job, NOT the installer's — see makeGitHooksExecutable.
886
+ upgradeAdditiveDir('.forge/git-hooks', results);
887
+ makeGitHooksExecutable(path.join(targetDir, '.forge'));
861
888
 
862
889
  // 2a. Confirm stale framework-file removals before deleting (R066a). Defaults
863
890
  // to yes (cleanup is the normal case); auto-confirms when non-interactive
@@ -55,7 +55,7 @@ Idempotent — re-running emits warnings instead of errors.
55
55
  If you'd rather see exactly what changes:
56
56
 
57
57
  1. Copy `source/mcp-server/` → `<target>/.forge/.mcp-server/`
58
- 2. Copy `source/hooks/forge-claim-check.sh`, `source/hooks/forge-claim-check-doctor.sh`, `source/hooks/forge-session-id.sh`, and `source/hooks/forge-branch-guard.sh` → `<target>/.claude/hooks/` (`chmod +x`)
58
+ 2. Copy `source/hooks/forge-claim-check.sh`, `source/hooks/forge-claim-check-doctor.sh`, `source/hooks/forge-session-id.sh`, and `source/hooks/protect-primary-checkout.sh` → `<target>/.claude/hooks/` (`chmod +x`)
59
59
  3. Copy `source/skills/orchestrating/` → `<target>/.claude/skills/orchestrating/`
60
60
  4. Merge `forge-orchestrator` entry into `<target>/.mcp.json`:
61
61
  ```json
@@ -71,12 +71,12 @@ If you'd rather see exactly what changes:
71
71
  { "hooks": [{ "type": "command", "command": ".claude/hooks/forge-session-id.sh" }] }
72
72
  ]
73
73
  ```
74
- Also add the branch-discipline guardrail under `PreToolUse`:
74
+ Also add the primary-checkout guardrail under `PreToolUse`:
75
75
  ```json
76
76
  { "matcher": "Bash",
77
- "hooks": [{ "type": "command", "command": ".claude/hooks/forge-branch-guard.sh" }] }
77
+ "hooks": [{ "type": "command", "command": ".claude/hooks/protect-primary-checkout.sh" }] }
78
78
  ```
79
- The SessionStart hook captures the Claude session id (claim identity); without it the enforcement gate fail-opens (ADR-007). The branch-guard protects the shared main HEAD when worktrees are active (ADR-008) — orchestration-agnostic, independent of the claim layer.
79
+ The SessionStart hook captures the Claude session id (claim identity); without it the enforcement gate fail-opens (ADR-007). The primary-checkout guard welds the primary checkout to the default branch — all branch work happens in worktrees (issue #21, ADR-031) — orchestration-agnostic, independent of the claim layer. It supersedes the old forge-branch-guard.sh (ADR-008) and folds in its hard-reset protection.
80
80
  6. `cd <target>/.forge/.mcp-server && npm install`
81
81
 
82
82
  ## Verify
@@ -72,7 +72,7 @@ echo
72
72
  # Step 3: copy hooks
73
73
  echo "==> Hooks → .claude/hooks/"
74
74
  do_or_say "mkdir -p '$TARGET/.claude/hooks'"
75
- for h in forge-claim-check.sh forge-claim-check-doctor.sh forge-session-id.sh forge-branch-guard.sh; do
75
+ for h in forge-claim-check.sh forge-claim-check-doctor.sh forge-session-id.sh protect-primary-checkout.sh; do
76
76
  if [[ -f "$TARGET/.claude/hooks/$h" ]]; then
77
77
  say "skip: $h already exists"
78
78
  else
@@ -161,21 +161,22 @@ else
161
161
  fi
162
162
  echo
163
163
 
164
- # Step 6c: register branch-discipline guardrail (PreToolUse on Bash). Orchestration-
165
- # agnostic protects the shared main HEAD whenever >1 worktree exists.
166
- echo "==> .claude/settings.json branch-guard hook"
167
- BG_CMD='.claude/hooks/forge-branch-guard.sh'
164
+ # Step 6c: register the primary-checkout branch weld (PreToolUse on Bash). Welds the
165
+ # primary checkout to the default branch all branch work happens in worktrees (issue
166
+ # #21). Orchestration-agnostic; supersedes the old forge-branch-guard.sh.
167
+ echo "==> .claude/settings.json primary-checkout guard hook"
168
+ BG_CMD='.claude/hooks/protect-primary-checkout.sh'
168
169
  if [[ ! -f "$SETTINGS" ]]; then
169
- say "[dry-run] settings.json not yet created — branch-guard would be added alongside the others"
170
+ say "[dry-run] settings.json not yet created — primary-checkout guard would be added alongside the others"
170
171
  elif jq -e --arg cmd "$BG_CMD" '.hooks.PreToolUse[]?.hooks[]? | select(.command == $cmd)' "$SETTINGS" >/dev/null 2>&1; then
171
- say "skip: forge-branch-guard.sh hook already registered"
172
+ say "skip: protect-primary-checkout.sh hook already registered"
172
173
  else
173
174
  if [[ $DRY_RUN -eq 0 ]]; then
174
175
  tmp=$(mktemp)
175
- jq '.hooks.PreToolUse += [{"matcher":"Bash","hooks":[{"type":"command","command":".claude/hooks/forge-branch-guard.sh"}]}]' "$SETTINGS" > "$tmp"
176
+ jq '.hooks.PreToolUse += [{"matcher":"Bash","hooks":[{"type":"command","command":".claude/hooks/protect-primary-checkout.sh"}]}]' "$SETTINGS" > "$tmp"
176
177
  mv "$tmp" "$SETTINGS"
177
178
  fi
178
- say "appended branch-guard PreToolUse(Bash) hook entry"
179
+ say "appended primary-checkout guard PreToolUse(Bash) hook entry"
179
180
  fi
180
181
  echo
181
182
 
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forge-orkes",
3
- "version": "0.77.0",
3
+ "version": "0.79.0",
4
4
  "description": "Set up the Forge meta-prompting framework for Claude Code in your project",
5
5
  "bin": {
6
6
  "create-forge": "./bin/create-forge.js"
@@ -0,0 +1,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,147 @@
1
+ #!/usr/bin/env bash
2
+ # Scratch-repo test suite for .claude/hooks/protect-primary-checkout.sh (issue #21).
3
+ #
4
+ # Each case builds a throwaway git repo (and, where needed, a linked worktree), pipes
5
+ # a PreToolUse JSON payload to the hook, and asserts the exit code. Exit 2 = deny,
6
+ # exit 0 = allow. No network, no fixtures. Self-cleans on exit. Run:
7
+ # ./.claude/hooks/tests/protect-primary-checkout.test.sh
8
+ # Prints `ALL PASS` and exits 0 iff every case passes; non-zero on any failure.
9
+
10
+ set -uo pipefail
11
+
12
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
13
+ HOOK="$SCRIPT_DIR/../protect-primary-checkout.sh"
14
+ [ -x "$HOOK" ] || { printf 'FATAL: hook not executable: %s\n' "$HOOK" >&2; exit 1; }
15
+ command -v jq >/dev/null 2>&1 || { printf 'FATAL: jq required\n' >&2; exit 1; }
16
+
17
+ ROOT="$(mktemp -d)"
18
+ trap 'rm -rf "$ROOT"' EXIT INT TERM
19
+
20
+ PASSED=0
21
+ FAILED=0
22
+
23
+ # new_repo <name> [default-branch] — fresh repo with one commit. Prints its path.
24
+ new_repo() {
25
+ local d="$ROOT/$1" br="${2:-main}"
26
+ mkdir -p "$d"
27
+ git -C "$d" init -q
28
+ git -C "$d" symbolic-ref HEAD "refs/heads/$br"
29
+ [ "$br" != "main" ] && git -C "$d" config init.defaultBranch "$br"
30
+ git -C "$d" config user.email t@example.com
31
+ git -C "$d" config user.name tester
32
+ : > "$d/seed"
33
+ git -C "$d" add -A && git -C "$d" commit -qm init
34
+ printf '%s\n' "$d"
35
+ }
36
+
37
+ # run_hook <dir> <command> [env-assignment] — pipes a payload, returns the hook exit code.
38
+ run_hook() {
39
+ local dir="$1" cmd="$2" envset="${3:-}"
40
+ local payload
41
+ payload=$(jq -nc --arg c "$cmd" --arg d "$dir" '{tool_input:{command:$c},cwd:$d}')
42
+ if [ -n "$envset" ]; then
43
+ printf '%s' "$payload" | env "$envset" "$HOOK" >/dev/null 2>&1
44
+ else
45
+ printf '%s' "$payload" | "$HOOK" >/dev/null 2>&1
46
+ fi
47
+ return $?
48
+ }
49
+
50
+ # assert <desc> <expected-exit> <dir> <command> [env]
51
+ assert() {
52
+ local desc="$1" want="$2" dir="$3" cmd="$4" envset="${5:-}"
53
+ local got
54
+ run_hook "$dir" "$cmd" "$envset"; got=$?
55
+ if [ "$got" -eq "$want" ]; then
56
+ PASSED=$((PASSED + 1))
57
+ # printf ' PASS: %s\n' "$desc"
58
+ else
59
+ FAILED=$((FAILED + 1))
60
+ printf ' FAIL: %s (got exit %s, want %s)\n' "$desc" "$got" "$want" >&2
61
+ fi
62
+ }
63
+
64
+ DENY=2
65
+ ALLOW=0
66
+
67
+ # ── Primary checkout: DENY branch creation / orphan / detach / track ──────────────
68
+ P="$(new_repo primary)"
69
+ assert "primary: checkout -b denies" "$DENY" "$P" "git checkout -b feature"
70
+ assert "primary: checkout -B denies" "$DENY" "$P" "git checkout -B feature"
71
+ assert "primary: switch -c denies" "$DENY" "$P" "git switch -c feature"
72
+ assert "primary: switch -C denies" "$DENY" "$P" "git switch -C feature"
73
+ assert "primary: switch --create denies" "$DENY" "$P" "git switch --create feature"
74
+ assert "primary: checkout --orphan denies" "$DENY" "$P" "git checkout --orphan gh-pages"
75
+ assert "primary: checkout --detach denies" "$DENY" "$P" "git checkout --detach"
76
+ assert "primary: checkout --track denies" "$DENY" "$P" "git checkout --track origin/x"
77
+ assert "primary: checkout other-branch denies" "$DENY" "$P" "git checkout somebranch"
78
+ assert "primary: switch other-branch denies" "$DENY" "$P" "git switch somebranch"
79
+
80
+ # ── Global-option bypass regression (review m-49 F1/F2): git global options before ──
81
+ # the subcommand must NOT slip the guard. These were live bypasses before the fix.
82
+ assert "primary: -c global opt + checkout -b denies" "$DENY" "$P" "git -c core.editor=vi checkout -b evil"
83
+ assert "primary: --no-pager + checkout -b denies" "$DENY" "$P" "git --no-pager checkout -b evil"
84
+ assert "primary: -p + checkout -b denies" "$DENY" "$P" "git -p checkout -b evil"
85
+ assert "primary: multi -c + switch -c denies" "$DENY" "$P" "git -c a=b -c c=d switch -c evil"
86
+ assert "primary: \$IFS substitution + -b denies" "$DENY" "$P" "git checkout\$IFS-b evil"
87
+ assert "primary: -c global opt + checkout main allows" "$ALLOW" "$P" "git -c x=y checkout main"
88
+
89
+ # ── The UNCONDITIONAL delta: deny even with NO sibling worktree (P has exactly 1). ─
90
+ # (P above already has no siblings — the denies above ARE the no-sibling case. Assert
91
+ # explicitly that worktree count is 1 here, so the intent is documented.)
92
+ wt_n=$(git -C "$P" worktree list | wc -l | tr -d ' ')
93
+ if [ "$wt_n" -eq 1 ]; then PASSED=$((PASSED+1)); else FAILED=$((FAILED+1)); printf ' FAIL: expected 1 worktree in fresh primary, got %s\n' "$wt_n" >&2; fi
94
+
95
+ # ── Primary checkout: ALLOW safe forms ───────────────────────────────────────────
96
+ assert "primary: checkout main allows (home)" "$ALLOW" "$P" "git checkout main"
97
+ assert "primary: switch main allows (home)" "$ALLOW" "$P" "git switch main"
98
+ assert "primary: checkout -- file allows (restore)" "$ALLOW" "$P" "git checkout -- seed"
99
+ assert "primary: checkout . allows (restore)" "$ALLOW" "$P" "git checkout ."
100
+ assert "primary: checkout HEAD -- file allows" "$ALLOW" "$P" "git checkout HEAD -- seed"
101
+ assert "primary: bare checkout allows" "$ALLOW" "$P" "git checkout"
102
+ assert "primary: git status allows (fast-path)" "$ALLOW" "$P" "git status"
103
+ assert "primary: git commit allows (fast-path)" "$ALLOW" "$P" "git commit -m x"
104
+ assert "primary: git log allows (fast-path)" "$ALLOW" "$P" "git log --oneline"
105
+
106
+ # ── Worktree pass-through: git-dir != git-common-dir → never blocked ──────────────
107
+ WT="$ROOT/primary-wt"
108
+ git -C "$P" worktree add -q -b wtbranch "$WT" >/dev/null 2>&1
109
+ assert "worktree cwd: checkout -b allows" "$ALLOW" "$WT" "git checkout -b anything"
110
+ assert "worktree cwd: switch -c allows" "$ALLOW" "$WT" "git switch -c anything"
111
+ # From the PRIMARY checkout, `git -C <worktree>` targets the worktree → allow.
112
+ assert "primary + git -C worktree: checkout -b allows" "$ALLOW" "$P" "git -C $WT checkout -b anything"
113
+
114
+ # ── master-default repo: home = master; create still denied ───────────────────────
115
+ M="$(new_repo master-primary master)"
116
+ assert "master repo: checkout master allows (home)" "$ALLOW" "$M" "git checkout master"
117
+ assert "master repo: checkout -b denies" "$DENY" "$M" "git checkout -b x"
118
+ assert "master repo: checkout main denies (main is not this repo's default... but seeded)" "$ALLOW" "$M" "git checkout main"
119
+ # ^ main+master are BOTH always in the seed set, so `checkout main` is allowed even in
120
+ # a master-default repo. That's intentional (both are canonical default names).
121
+
122
+ # ── Escape hatch ─────────────────────────────────────────────────────────────────
123
+ assert "escape hatch: checkout -b allows" "$ALLOW" "$P" "git checkout -b feature" "ALLOW_MAIN_BRANCHING=1"
124
+
125
+ # ── Folded hard-reset guard (worktree-count gated) ───────────────────────────────
126
+ # P now has 1 sibling worktree (WT added above) → count > 1 → hard reset denied.
127
+ assert "primary +wt: reset --hard denies" "$DENY" "$P" "git reset --hard HEAD"
128
+ assert "primary +wt: reset --keep denies" "$DENY" "$P" "git reset --keep HEAD"
129
+ assert "primary +wt: reset --soft allows (not hard)" "$ALLOW" "$P" "git reset --soft HEAD"
130
+ # A repo with a single worktree → hard reset is the operator's business → allow.
131
+ S="$(new_repo solo)"
132
+ assert "solo (1 wt): reset --hard allows" "$ALLOW" "$S" "git reset --hard HEAD"
133
+
134
+ # ── Fail-open cases ──────────────────────────────────────────────────────────────
135
+ assert "fail-open: empty command allows" "$ALLOW" "$P" ""
136
+ assert "fail-open: non-checkout command allows" "$ALLOW" "$P" "ls -la"
137
+ # Non-git cwd → fail open. Use a plain temp dir with a checkout-shaped command.
138
+ NG="$ROOT/notgit"; mkdir -p "$NG"
139
+ assert "fail-open: non-git cwd allows" "$ALLOW" "$NG" "git checkout -b x"
140
+
141
+ # ── Summary ──────────────────────────────────────────────────────────────────────
142
+ printf '\n%d passed, %d failed\n' "$PASSED" "$FAILED"
143
+ if [ "$FAILED" -eq 0 ]; then
144
+ printf 'ALL PASS\n'
145
+ exit 0
146
+ fi
147
+ exit 1
@@ -141,6 +141,15 @@
141
141
  "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/block-dangerous-commands.sh\""
142
142
  }
143
143
  ]
144
+ },
145
+ {
146
+ "matcher": "Bash",
147
+ "hooks": [
148
+ {
149
+ "type": "command",
150
+ "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/protect-primary-checkout.sh\""
151
+ }
152
+ ]
144
153
  }
145
154
  ]
146
155
  }
@@ -390,7 +390,26 @@ User selects per stack.
390
390
 
391
391
  **Confirm with the user first** in brownfield (this edits a user-owned file). In greenfield with no existing `.gitignore`, write it directly. Re-run is idempotent — the carve-out lines `grep` to themselves.
392
392
 
393
- 8. **State-sync commit**: `git add .gitignore .claude/settings.json .claude/skills/ .claude/agents/ .claude/hooks/ .forge/` then `git commit -m "chore(forge): initialize forge state + track product files"` (scoped; never `git add .`) so the new project's state and Forge product files are both in git from the start.
393
+ 8. **Wire the primary-checkout weld (`core.hooksPath`).** Welds the primary checkout to the default branch so all branch work happens in worktrees every tier (issue #21, ADR-031). The tracked `.forge/git-hooks/pre-commit` backstop only fires once `core.hooksPath` points at it. This is per-clone git config, so init owns it. **Detect + warn, never clobber:**
394
+
395
+ ```bash
396
+ want=".forge/git-hooks"
397
+ cur=$(git config --get core.hooksPath 2>/dev/null || true)
398
+ if [ -z "$cur" ] || [ "$cur" = "$want" ]; then
399
+ git config core.hooksPath "$want" # repo-relative → resolves inside every worktree
400
+ echo "[Forge] core.hooksPath → $want (primary-checkout weld active)."
401
+ else
402
+ echo "[Forge] core.hooksPath is already set to '$cur' (Husky/lefthook?). NOT overwriting."
403
+ echo " The primary-checkout pre-commit backstop (.forge/git-hooks/pre-commit) is"
404
+ echo " therefore inactive. To keep the weld, chain it into your existing hooks dir"
405
+ echo " (call .forge/git-hooks/pre-commit from your pre-commit), or accept the"
406
+ echo " PreToolUse-only coverage (Claude sessions are still guarded)."
407
+ fi
408
+ ```
409
+
410
+ **WHY repo-relative** (`.forge/git-hooks`, not an absolute path): an absolute `core.hooksPath` silently skips in linked worktrees (the documented Husky/lefthook worktree hazard — see `orchestrating/bootstrap-checks.md`); a repo-relative path resolves under each worktree checkout. **WHY never clobber:** silently disabling a consumer's existing hook framework is unacceptable.
411
+
412
+ 9. **State-sync commit**: `git add .gitignore .claude/settings.json .claude/skills/ .claude/agents/ .claude/hooks/ .forge/` then `git commit -m "chore(forge): initialize forge state + track product files"` (scoped; never `git add .`) — so the new project's state and Forge product files are both in git from the start. (The tracked `.forge/git-hooks/` is included by the `.forge/` path; `core.hooksPath` itself is local git config, not a tracked file, so it needs no staging.)
394
413
 
395
414
  *"Initialized. Ready?"*
396
415
 
@@ -312,6 +312,22 @@ Deferred issues triage: Glob `.forge/deferred-issues/*.md` (else legacy `.forge/
312
312
  - **Fix-now** → route to `planning` fix mode before completing milestone
313
313
  - **Dismiss** → set `status: dismissed` + `dismissed_reason` in the issue's file (or the legacy file)
314
314
 
315
+ Close-the-loop triage (advisory — never blocks): catches shipped-but-still-open GitHub issues. Forge produces resolution artifacts (ADRs, desire-paths, CHANGELOG/commits) that cite the issue they resolve, but nobody circles back to close the issue — the open list accumulates already-done work. This step cross-references those artifacts against **open** issues and surfaces likely-resolved ones for the operator to close. **Detect-and-surface only — NEVER auto-close** (closing a GitHub issue is outward-facing → operator confirmation, agent safety rules).
316
+
317
+ 1. **Gate / degrade first.** Resolve `forge.upstream_repo` from `project.yml`. If absent, OR `gh` not on PATH, OR `gh auth status` fails, OR offline → **skip the whole step** (at most one log line: *"close-the-loop: gh unavailable — skipped"*). Never blocks reviewing or completion. (Same posture as `forge/desire-paths-review.md`'s resolve→check-gh→offer and notion-integration's advisory-everywhere.)
318
+ 2. **Collect resolution-intent `#N` refs produced THIS milestone** (milestone-scoped default — cheap, fires when the artifact is fresh):
319
+ - **ADRs** authored/touched this milestone whose `**Context source:**` / `resolves` / `closes` line cites `forge#N` (or the `…/issues/N` URL). **Exclude bare supersession / see-also mentions** — a *"Superseded by ADR-021 ([forge#19](…))"* note references #19, it does not resolve it. Only resolution-intent lines count.
320
+ - **desire-path** files with `resolves_issue: N`.
321
+ - **CHANGELOG / commit** lines landed this milestone with `closes|fixes|resolves #N`.
322
+ Scope by this milestone's git range / this milestone's artifacts — prose-directed grep, no executable.
323
+ 3. **Intersect cited ∩ still-open.** `gh issue view N --json state,title` each; **keep only `state == OPEN`**. An `#N` gh can't resolve (nonexistent / transferred / errors on that call) OR reports CLOSED → **no candidate** (surface only gh-confirmed-open — zero false positives). Skip any `#N` with a `.forge/state/issue-loop/declined/{N}.yml` marker (glob first — mirrors the desire-path `declined/` skip).
324
+ 4. **Surface survivors** (existing triage idiom): *"**Close-the-loop** ({N}): 1. #N '{title}' — looks resolved by {artifact} — close it? [y/N]"*.
325
+ - **y** (or "close") → **do NOT close autonomously.** Print the exact command for the operator to run (or to approve Forge running as an explicit per-action step): `gh issue close N --comment "Resolved by {artifact} ({ref}). Closing via Forge close-the-loop."`
326
+ - **N** (or "still open") → write `.forge/state/issue-loop/declined/{N}.yml` (`{issue: N, declined_at: <ISO date>, reason: "<one line>"}`; create the dir if absent) so it is not re-nagged. The marker is inert once the issue actually closes (gh stops returning it as open).
327
+ 5. **Optional full sweep** (operator-invoked, natural language — no new command): if the operator asks *"run a full close-the-loop sweep"* / *"check for orphaned issues"*, run steps 1–4 over **all** open issues × **all** shipped resolution artifacts repo-wide (not just this milestone), same rules + `declined/` markers. This is the periodic-cleanup path; the milestone-scoped default stays quiet.
328
+
329
+ Zero candidates → silent (the common case). This step is **advisory — it never gates milestone completion** (see Gates).
330
+
315
331
  ## Step 7: Backlog + Route
316
332
 
317
333
  ### Backlog
@@ -371,6 +387,7 @@ Idempotent: a backlog with no terminal items and all-canonical statuses is left
371
387
  - **Security/arch critical** -> soft gate (accept risk)
372
388
  - **Warnings** -> advisory
373
389
  - **Refactoring** -> never block
390
+ - **Close-the-loop** -> advisory, never blocks (detect-and-surface only; never auto-closes)
374
391
 
375
392
  Report = audit trail.
376
393
 
@@ -119,6 +119,25 @@ Rebase now? (each / list-only / skip)
119
119
  - **list-only / skip** → print the commands; the operator runs them at a safe stopping point.
120
120
  - Gitignored carve-outs (`.mcp.json`, experimental hooks) are not carried by a rebase — whenever layer (b) re-synced anything, add: each worktree must re-run `experimental/{pkg}/install.sh`.
121
121
 
122
+ ### e. Primary-checkout weld (`core.hooksPath`)
123
+
124
+ Runs on every upgrade (registry and Dev Mode alike) — the bin syncs the `.forge/git-hooks/` body, but `core.hooksPath` is per-clone git config the bin never touches (issue #21, ADR-031). Wire it with the **detect + warn, never clobber** policy — identical to `initializing` Finalize step 8. Main checkout only (`git rev-parse --git-dir` == `--git-common-dir`; from a worktree, skip — the primary checkout owns the config):
125
+
126
+ ```bash
127
+ want=".forge/git-hooks"
128
+ cur=$(git config --get core.hooksPath 2>/dev/null || true)
129
+ if [ -z "$cur" ] || [ "$cur" = "$want" ]; then
130
+ git config core.hooksPath "$want" # repo-relative → resolves inside every worktree
131
+ echo "[Forge] core.hooksPath → $want (primary-checkout weld active)."
132
+ else
133
+ echo "[Forge] core.hooksPath is already '$cur' (Husky/lefthook?). NOT overwriting — the"
134
+ echo " primary-checkout pre-commit backstop is inactive. Chain .forge/git-hooks/pre-commit"
135
+ echo " into your existing hooks dir to keep the weld, or accept PreToolUse-only coverage."
136
+ fi
137
+ ```
138
+
139
+ Idempotent — re-running an upgrade that already wired it is a silent no-op. **WHY repo-relative + never-clobber:** same reasons as `initializing` step 8 (worktree-resolvable path; a consumer's Husky/lefthook must never be silently disabled).
140
+
122
141
  ## Dev Mode (`/upgrading dev` — explicit only)
123
142
 
124
143
  Local-clone sync for developing Forge itself: test framework changes in a project without publishing to npm. Presence of `.forge/dev-source` NEVER selects this mode — only the explicit `dev` argument does.
@@ -141,7 +160,7 @@ Dev mode syncs from a checkout, so it keeps file-classification semantics — **
141
160
 
142
161
  - **Framework-owned** (`.claude/agents/*.md`, `.claude/skills/*/SKILL.md`, `.forge/FORGE.md`) → overwrite when different. Ownership is per top-level **unit** — a skill DIR under `.claude/skills/`, a flat agent FILE under `.claude/agents/`: a unit the template doesn't ship (user-authored, vendored-parity, or an opt-in experimental skill) is **preserved, never deleted**; only stale files *inside* a still-shipped skill dir are cleaned. Experimental skills additionally get the staleness pass below.
143
162
  - **Template-only** (`.forge/templates/**`, `.forge/migrations/**`, `.forge/adapters/**`, `.forge/gitignore` → `.forge/.gitignore`) → overwrite; `.forge/FORGE.md` lands BEFORE the CLAUDE.md pass.
144
- - **Additive** (`.claude/hooks/**` incl. `tests/`, `.claude/rules/**`, `.forge/checks/**`) → add + overwrite template-shipped files, never delete local extras, `chmod +x` synced `*.sh`.
163
+ - **Additive** (`.claude/hooks/**` incl. `tests/`, `.claude/rules/**`, `.forge/checks/**`, `.forge/git-hooks/**`) → add + overwrite template-shipped files, never delete local extras, `chmod +x` synced `*.sh` (and the extensionless git hooks under `.forge/git-hooks/`).
145
164
  - **Import-managed** `CLAUDE.md` → the bin's `ensureClaudeMdImport()` cases verbatim; user content outside a forge section is never modified.
146
165
  - **Merge-owned** `.claude/settings.json` → `forge.*` keys + additive hook-group wiring only; stamp `forge.version` last.
147
166
  - **Never touch** user-generated files: `.forge/project.yml`, `.forge/state/`, `.forge/constitution.md`, `.forge/context.md`, `.forge/requirements/`, `.forge/roadmap.yml`, `.forge/design-system.md`, `.forge/refactor-backlog.yml`.
@@ -150,11 +169,12 @@ Render the same report shape as the default path, with `Source: {path}` instead
150
169
 
151
170
  ### Post-sync layers
152
171
 
153
- Re-use layers (a)–(d) with dev-derived inputs — this is the one mode that computes them itself, since there is no engine JSON:
172
+ Re-use layers (a)–(e) with dev-derived inputs — this is the one mode that computes them itself, since there is no engine JSON:
154
173
 
155
174
  - **(a) Migrations** — the data-driven Detection loop: select freshly-synced guides `.forge/migrations/{v}-*.md` where `installed < v <= source` (semver on the PRE-stamp installed version), run each guide's `## Detection` bash block from the project root; `MIGRATE` on stdout → the layer-(a) prompt with the text after `MIGRATE —` as the reason; no output → `{v}: no action`; block errors → `{v}: detection error (skipped)`, never abort the loop. Range bookkeeping is implicit: once `forge.version` is stamped, crossed guides fall out of the next run's range — no ledger.
156
175
  - **(b) Experimental staleness** — diff installed non-template skill dirs against `{source}/packages/create-forge/experimental/*/source/skills/{name}/`; stale → the layer-(b) offer with `{source}` as the source path.
157
176
  - **(c) Codex** — presence glob (`.agents/` / `.codex/`) → the layer-(c) offer.
158
177
  - **(d) Worktrees** — `git worktree list --porcelain` filtered to `refs/heads/forge/m-*`, main checkout only (`git rev-parse --git-dir` == `--git-common-dir`; from a worktree, skip and say so) → the layer-(d) offer.
178
+ - **(e) Primary-checkout weld** — wire `core.hooksPath` with the detect+warn+never-clobber policy above (main checkout only). Same behavior in Dev Mode as on the registry path.
159
179
 
160
180
  Same consent gates, same report lines as the registry path.
@@ -59,6 +59,8 @@ Where worktrees live and what they're named — single source of truth, base con
59
59
 
60
60
  **Recorded path is authoritative once set** — `lifecycle.worktree_path` (milestone) / `runtime.worktree` (stream) is read verbatim, never re-derived; changing `worktree_root` affects only new worktrees; move a live worktree only with `git worktree move` + update the record. No recorded path → resolve from the convention. `forge` boot and `chief-of-staff` adoption **validate advisorily**: recorded path outside the resolved root → warn (never block, never move); an explicit `worktree_root` is honored.
61
61
 
62
+ **Primary checkout is welded to the default branch — all tiers** ([ADR-031](../docs/decisions/ADR-031-weld-primary-checkout-to-default-branch.md)). Its HEAD is shared across concurrent sessions; a `checkout -b` there silently redirects others' commits. Enforced (`git-dir == git-common-dir` scoped, never touches worktrees): `.claude/hooks/protect-primary-checkout.sh` (PreToolUse — denies branch create/switch/detach + hard-reset) + `.forge/git-hooks/pre-commit` (off-default-commit backstop; wired per-clone by `initializing`/`upgrading` via repo-relative `core.hooksPath`, detect+warn+never-clobber). Branch work: `.forge/bin/new-worktree.sh <slug>`. Escape: `ALLOW_MAIN_BRANCHING=1`.
63
+
62
64
  **`orchestration.worktree_hydrate`** (optional, `project.yml`; forge#18) lists superproject-relative paths to required-but-gitignored artifacts (e.g. `.mcp.json`) a fresh worktree's tracked-only checkout lacks. Copy is guarded `source-exists && dest-absent` — never overwrites, never `git add`s, so the secret stays untracked in both trees. Applied by `orchestrating` at creation and re-asserted idempotently in `executing`'s Workspace Prerequisites, covering worktrees of any origin. Omit ⇒ no propagation. The per-worktree runtime counterpart of the install-time carve-out propagation below (State Ownership § Stable shared).
63
65
 
64
66
  ## Workflow Tiers
@@ -99,7 +101,7 @@ Auto-detects complexity. Override: "Use Quick/Standard/Full tier."
99
101
  | Break work into tasks with gates | `planning` | Standard, Full |
100
102
  | Build with deviation rules + atomic commits | `executing` | All |
101
103
  | Prove work delivers on goals (+ M9 e2e validation gate; captures the Human Verification Gate sign-off) | `verifying` | Standard, Full |
102
- | Audit health + catalog refactoring (+ M9 e2e audits; hard-blocks completion on the Human Verification Gate) | `reviewing` | Standard, Full |
104
+ | Audit health + catalog refactoring (+ M9 e2e audits; hard-blocks completion on the Human Verification Gate) + close-the-loop (surface resolved-but-open issues) | `reviewing` | Standard, Full |
103
105
  | Small scoped fix | `quick-tasking` | Quick |
104
106
  | Rapid disposable UI mockup behind an alpha flag (no verify/review gate) | `prototyping` | Prototyping |
105
107
  | UI with design system | `designing` | When UI |
@@ -195,10 +197,10 @@ State lives in `.forge/`. One line per artifact; protocol detail lives in the na
195
197
  - `design-system.md` — component mapping table
196
198
  - `requirements/m{N}.yml` — per-milestone structured requirements with `[NEEDS CLARIFICATION]` markers. **FR/DEF/NFR ids are globally unique across all milestone files** — reserve via the ID Reservation Protocol before writing. M9 e2e gate fields (`e2e`, `observable_outcome`, `validated`, hash) lazy-migrate; absent ⇒ `false`.
197
199
  - `roadmap.yml` — phases, milestones, dependencies
198
- - `state/index.yml` — DERIVED milestone registry, **git-ignored render-on-read cache** (0.53.0): regenerated at every boot by the **`forge-state-rollup.sh` SessionStart hook** (the mechanical port of Rollup 1.0 — issue #22; skill prose only *reads* it) from the milestone files; never committed, never hand-edited; the hook renders it in any checkout (gitignored local write), but worktree agents still never write the *committed* derived-source state; absent on a fresh clone (the project sentinel is `project.yml`). Rows carry the derived `release_state`.
200
+ - `state/index.yml` — DERIVED milestone registry, **git-ignored render-on-read cache** (0.53.0): regenerated at every boot from the milestone files by the **`forge-state-rollup.sh` SessionStart hook** (issue #22; skills only *read* it); never committed, never hand-edited. Rendered in any checkout (gitignored local write), but worktree agents never write *committed* derived-source state; absent on a fresh clone (sentinel is `project.yml`). Rows carry the derived `release_state`.
199
201
  - **Derived `release_state`** ([ADR-024](../docs/decisions/ADR-024-merged-validated-fold.md)) — `{unmerged, merged, validated}` computed at rollup by `.claude/hooks/forge-release-fold.sh` (git-only, offline; the fold binary is the only source — agent prose never re-derives it), never stored. Validation binding via `forge.validation_event`: `in_session_signoff` (default; absent ⇒ this) or `promotion_approval` (CI-signed `promotion/{sha}` tag). Inert until a repo opts in; full spec in ADR-024 + the fold header.
200
202
  - `state/milestone-{id}.yml` — per-milestone cursor (**single source of truth**): position, decisions, blockers. One owner at a time.
201
- - `state/desire-paths/` — append-only framework-usage observations, one file per observation, `scope: project|framework` (absent ⇒ project); occurrence counts derived by globbing. **`forge` boot is the single review owner** — surfaces groups at 3+, persists declines (no re-nag), routes `framework`-scope signals upstream ([ADR-012](../docs/decisions/ADR-012-upstream-desire-path-feedback-channel.md)); `verifying` captures but never surfaces.
203
+ - `state/desire-paths/` — append-only framework-usage observations, one file per observation, `scope: project|framework` (absent ⇒ project); occurrence counts derived by globbing. **`forge` boot is the single review owner** — surfaces groups at 3+, persists declines (no re-nag), routes `framework`-scope signals upstream ([ADR-012](../docs/decisions/ADR-012-upstream-desire-path-feedback-channel.md)); `verifying` captures but never surfaces. An issue-sourced entry SHOULD carry a back-reference (`resolves_issue: N`; ADRs use `**Context source:** forge#N`) — close-the-loop keys off it.
202
204
  - `context.md` — active locked decisions + deferred ideas (≤12 KB); history archives to `context-archive.md`
203
205
  - `streams/active.yml` — DERIVED Project Chief traffic map, git-ignored render-on-read cache, rendered by the same `forge-state-rollup.sh` hook — see Stream Rollup below ([ADR-015](../docs/decisions/ADR-015-derived-stream-registry.md))
204
206
  - `streams/{stream}.yml` (+ `brief.md`, `packages/{id}.yml`) — per-stream **source of truth**: coordination lifecycle, ownership, shared surfaces, blockers, merge readiness, optional `stream.milestone` link (milestone-backed streams omit workflow phase — the rollup reads it from the milestone)
@@ -207,6 +209,7 @@ State lives in `.forge/`. One line per artifact; protocol detail lives in the na
207
209
  - `refactor-backlog.yml` — refactoring catalog worked via quick-tasking; status vocab `pending | in_progress | done | dismissed | deferred`; terminal items auto-archive to `refactor-backlog-archive.yml` on the next reviewing/quick-tasking write
208
210
  - `releases.yml` — append-only version-reservation registry (Version Reservation Protocol)
209
211
  - `reservations.yml` — append-only id-reservation audit trail + cross-machine floor (ID Reservation Protocol); `forge-reserve compact` (R104) moves dropped rows verbatim to `reservations-archive.yml`, retaining each kind's max + a 14-day tail
212
+ - `state/issue-loop/declined/{N}.yml` — append-only close-the-loop decline markers; `reviewing` writes one when the operator declines a surfaced resolved-but-open issue (no re-nag). Inert once the issue closes.
210
213
  - `archive/milestone-{id}/` — archived milestone artifacts (archive-delete)
211
214
  - `prototypes/{slug}.md` — resumable disposable-mockup state (`exploring | parked | graduated | discarded`); terminal → `prototypes/archive/`. Single-owner, one driver. See `Skill(prototyping)`.
212
215
 
@@ -216,7 +219,7 @@ State lives in `.forge/`. One line per artifact; protocol detail lives in the na
216
219
 
217
220
  ### Stream Rollup
218
221
 
219
- `active.yml` is a pure projection — regenerated, never hand-edited — by a deterministic, idempotent **join**: per-stream files contribute coordination state; active milestones contribute `phase` (any active milestone with no stream file gets a derived `implicit` row — coverage is computed, not maintained); `merge_queue` derives from `merge.readiness: ready`. A **step-0 completed-stream auto-close sweep** runs first: a milestone-backed stream whose milestone is `complete`, not yet `closed`, with no live worktree is auto-closed a done-and-merged milestone can never leave a zombie "ready to merge" row. Rendered mechanically by the **`forge-state-rollup.sh` SessionStart hook** at boot (issue #22 — an executable, not agent prose), and re-run via that same executable at `chief-of-staff` Show/Sync and after any mid-session milestone CRUD; the hook runs the milestone Rollup 1.0 **first** (the join reads the fresh `index.yml`), and confines the committed-source step-0 sweep to the main checkout. **Numbered procedure: the ADR-015 addendum.** Because each field has exactly one source and coverage is computed, two-registry drift cannot occur — supersedes the old advisory Registry Drift Check.
222
+ `active.yml` is a pure projection by a deterministic, idempotent **join**: per-stream files contribute coordination state; active milestones contribute `phase` (any active milestone with no stream file gets a derived `implicit` row — coverage is computed, not maintained); `merge_queue` derives from `merge.readiness: ready`. A **step-0 completed-stream auto-close sweep** runs first: a milestone-backed stream whose milestone is `complete`, not yet `closed`, with no live worktree is auto-closed (no zombie "ready to merge" rows). Rendered by the same `forge-state-rollup.sh` hook (above) at boot and at `chief-of-staff` Show/Sync / mid-session milestone CRUD; it runs Rollup 1.0 **first** (the join reads the fresh `index.yml`), and confines the committed-source sweep to the main checkout. **Numbered procedure: the ADR-015 addendum.** Each field has exactly one source and coverage is computed, so two-registry drift cannot occur.
220
223
 
221
224
  ### State Commit Protocol
222
225
 
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env bash
2
+ # One-command worktree for any commit-producing task (issue #21, ADR-031).
3
+ #
4
+ # bash .forge/bin/new-worktree.sh <slug> [base-ref]
5
+ #
6
+ # Creates {worktree_root}/{slug} on branch forge/{slug} off origin/main (or [base-ref]).
7
+ # The primary checkout's HEAD never moves — that is the whole point of the weld: branch
8
+ # work lives in worktrees, each with its own independent HEAD, so concurrent sessions
9
+ # never clobber each other's commits.
10
+ #
11
+ # worktree_root follows the FORGE.md Worktree Convention: `orchestration.worktree_root`
12
+ # from .forge/project.yml, default `../<repo-basename>-worktrees/`. Relative paths resolve
13
+ # against the repo root; absolute / ~ honored verbatim.
14
+
15
+ set -euo pipefail
16
+
17
+ SLUG=${1:?usage: new-worktree.sh <slug> [base-ref]}
18
+ BASE=${2:-origin/main}
19
+
20
+ REPO_ROOT=$(git rev-parse --show-toplevel)
21
+ REPO_BASE=$(basename "$REPO_ROOT")
22
+
23
+ # Resolve worktree_root from project.yml (light grep — no yaml dependency). The value
24
+ # lives under an `orchestration:` block as `worktree_root: <path>`.
25
+ WT_ROOT_CFG=$(grep -E '^[[:space:]]*worktree_root:' "$REPO_ROOT/.forge/project.yml" 2>/dev/null \
26
+ | head -1 | sed -E 's/^[[:space:]]*worktree_root:[[:space:]]*//; s/^["'\'']//; s/["'\'']$//; s/[[:space:]]*(#.*)?$//' || true)
27
+
28
+ if [ -z "$WT_ROOT_CFG" ]; then
29
+ WORKTREE_ROOT="$(dirname "$REPO_ROOT")/${REPO_BASE}-worktrees"
30
+ else
31
+ case "$WT_ROOT_CFG" in
32
+ /*) WORKTREE_ROOT="$WT_ROOT_CFG" ;; # absolute — verbatim
33
+ "~"*) WORKTREE_ROOT="${WT_ROOT_CFG/#\~/$HOME}" ;; # ~ — expand
34
+ *) WORKTREE_ROOT="$REPO_ROOT/$WT_ROOT_CFG" ;; # relative — against repo root
35
+ esac
36
+ fi
37
+
38
+ # Slashes in the slug become dashes in the directory name; the branch keeps the slash.
39
+ DIR_NAME=$(printf '%s' "$SLUG" | tr '/' '-')
40
+ WT_PATH="$WORKTREE_ROOT/$DIR_NAME"
41
+ BRANCH="forge/$SLUG"
42
+
43
+ if [ -e "$WT_PATH" ]; then
44
+ echo "✋ $WT_PATH already exists. Pick another slug or remove it first (git worktree remove)." >&2
45
+ exit 1
46
+ fi
47
+
48
+ mkdir -p "$WORKTREE_ROOT"
49
+ # Best-effort refresh of origin/main so the worktree starts from current main; offline-safe.
50
+ git fetch origin main --quiet 2>/dev/null || true
51
+ git worktree add -b "$BRANCH" "$WT_PATH" "$BASE"
52
+
53
+ echo ""
54
+ echo "✅ Worktree ready"
55
+ echo " path: $WT_PATH"
56
+ echo " branch: $BRANCH (from $BASE)"
57
+ echo ""
58
+ echo " cd \"$WT_PATH\""
59
+ echo ""
60
+ echo "Reminders: run your install step after entering (worktrees don't share deps);"
61
+ echo "the primary checkout stayed on its default branch — that is intentional."
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env bash
2
+ # Scratch-repo tests for .forge/bin/new-worktree.sh (issue #21, ADR-031).
3
+ #
4
+ # Builds a throwaway repo with a committed HEAD, runs the helper (base ref = HEAD so no
5
+ # network is needed), and asserts the worktree dir + branch it creates. Self-cleans. Run:
6
+ # ./.forge/bin/tests/new-worktree.test.sh
7
+ # Prints `ALL PASS` and exits 0 iff every case passes; non-zero on any failure.
8
+
9
+ set -uo pipefail
10
+
11
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
12
+ HELPER="$SCRIPT_DIR/../new-worktree.sh"
13
+ [ -x "$HELPER" ] || { printf 'FATAL: helper not executable: %s\n' "$HELPER" >&2; exit 1; }
14
+
15
+ ROOT="$(mktemp -d)"
16
+ trap 'rm -rf "$ROOT"' EXIT INT TERM
17
+
18
+ PASSED=0
19
+ FAILED=0
20
+ pass() { PASSED=$((PASSED + 1)); }
21
+ fail() { FAILED=$((FAILED + 1)); printf ' FAIL: %s\n' "$1" >&2; }
22
+
23
+ # A repo named so its default worktree_root is predictable: <base>-worktrees next to it.
24
+ new_repo() {
25
+ local d="$ROOT/$1"
26
+ mkdir -p "$d"
27
+ git -C "$d" init -q
28
+ git -C "$d" symbolic-ref HEAD refs/heads/main
29
+ git -C "$d" config user.email t@example.com
30
+ git -C "$d" config user.name tester
31
+ mkdir -p "$d/.forge"
32
+ : > "$d/seed"
33
+ git -C "$d" add -A && git -C "$d" commit -qm init
34
+ printf '%s\n' "$d"
35
+ }
36
+
37
+ # ── Case 1: default worktree_root, simple slug ───────────────────────────────────
38
+ R="$(new_repo proj)"
39
+ ( cd "$R" && "$HELPER" myslug HEAD ) >/dev/null 2>&1 || true
40
+ WT="$ROOT/proj-worktrees/myslug"
41
+ if [ -d "$WT" ]; then pass; else fail "case1: worktree dir not created at $WT"; fi
42
+ br=$(git -C "$WT" branch --show-current 2>/dev/null || true)
43
+ if [ "$br" = "forge/myslug" ]; then pass; else fail "case1: branch is '$br', want forge/myslug"; fi
44
+
45
+ # ── Case 2: re-running the same slug errors (dir exists) ─────────────────────────
46
+ if ( cd "$R" && "$HELPER" myslug HEAD ) >/dev/null 2>&1; then
47
+ fail "case2: duplicate slug should have errored (exit non-zero)"
48
+ else
49
+ pass
50
+ fi
51
+
52
+ # ── Case 3: slug with a slash → dashed dir, slashed branch ───────────────────────
53
+ R2="$(new_repo proj2)"
54
+ ( cd "$R2" && "$HELPER" feat/thing HEAD ) >/dev/null 2>&1 || true
55
+ WT2="$ROOT/proj2-worktrees/feat-thing"
56
+ if [ -d "$WT2" ]; then pass; else fail "case3: dashed dir not created at $WT2"; fi
57
+ br2=$(git -C "$WT2" branch --show-current 2>/dev/null || true)
58
+ if [ "$br2" = "forge/feat/thing" ]; then pass; else fail "case3: branch is '$br2', want forge/feat/thing"; fi
59
+
60
+ # ── Case 4: configured relative worktree_root honored ────────────────────────────
61
+ R3="$(new_repo proj3)"
62
+ printf 'orchestration:\n worktree_root: .wt\n' > "$R3/.forge/project.yml"
63
+ git -C "$R3" add -A && git -C "$R3" commit -qm cfg
64
+ ( cd "$R3" && "$HELPER" wtslug HEAD ) >/dev/null 2>&1 || true
65
+ WT3="$R3/.wt/wtslug"
66
+ if [ -d "$WT3" ]; then pass; else fail "case4: configured worktree_root .wt not honored (want $WT3)"; fi
67
+
68
+ printf '\n%d passed, %d failed\n' "$PASSED" "$FAILED"
69
+ if [ "$FAILED" -eq 0 ]; then printf 'ALL PASS\n'; exit 0; fi
70
+ exit 1
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env bash
2
+ # Backstop for the primary-checkout branch weld (issue #21, ADR-031).
3
+ #
4
+ # Refuses commits made in the PRIMARY checkout while HEAD is not the default branch
5
+ # (or is detached). The Claude PreToolUse hook (.claude/hooks/protect-primary-checkout.sh)
6
+ # blocks branch create/switch at the source; this git hook catches anything that slips
7
+ # past it — humans, non-agent tools, cd-inside-compound-command races — at the moment
8
+ # damage would occur: a commit landing on a branch some other concurrent session owns.
9
+ #
10
+ # Linked worktrees are unaffected (git-dir != git-common-dir there — their own HEAD is
11
+ # the whole point). Commits ON the default branch in the primary checkout remain allowed
12
+ # by design — Forge's own state-sync commits land there.
13
+ #
14
+ # Default branch is resolved dynamically (main/master + init.defaultBranch), never
15
+ # hardcoded. Fails OPEN on any git-probe error (a hook must not wedge commits on its own
16
+ # malfunction). Exit 0 = allow the commit; exit 1 = abort it.
17
+ #
18
+ # Activate once per clone (done by initializing / upgrading):
19
+ # git config core.hooksPath .forge/git-hooks
20
+ # Escape hatch (deliberate, human use): ALLOW_MAIN_BRANCHING=1 git commit ...
21
+
22
+ set -uo pipefail
23
+
24
+ [ "${ALLOW_MAIN_BRANCHING:-0}" = "1" ] && exit 0
25
+
26
+ GIT_DIR=$(git rev-parse --git-dir 2>/dev/null) || exit 0
27
+ COMMON_DIR=$(git rev-parse --git-common-dir 2>/dev/null) || exit 0
28
+ GIT_DIR_ABS=$(cd "$GIT_DIR" 2>/dev/null && pwd) || exit 0
29
+ COMMON_DIR_ABS=$(cd "$COMMON_DIR" 2>/dev/null && pwd) || exit 0
30
+
31
+ # Linked worktree → its own HEAD; branch commits are the whole point. Allow.
32
+ [ "$GIT_DIR_ABS" != "$COMMON_DIR_ABS" ] && exit 0
33
+
34
+ # Default-branch set (dynamic — mirrors protect-primary-checkout.sh).
35
+ DEFAULT_BRANCHES="main master"
36
+ GIT_DEFAULT=$(git config --get init.defaultBranch 2>/dev/null || true)
37
+ [ -n "$GIT_DEFAULT" ] && DEFAULT_BRANCHES="$DEFAULT_BRANCHES $GIT_DEFAULT"
38
+
39
+ BRANCH=$(git branch --show-current 2>/dev/null || true)
40
+
41
+ if [ -z "$BRANCH" ]; then
42
+ echo "✋ Commit blocked: detached HEAD in the primary checkout." >&2
43
+ echo " Return to the default branch (git checkout main) or move this work to a worktree:" >&2
44
+ echo " bash .forge/bin/new-worktree.sh <slug>" >&2
45
+ echo " Human escape hatch: ALLOW_MAIN_BRANCHING=1 git commit ..." >&2
46
+ exit 1
47
+ fi
48
+
49
+ for b in $DEFAULT_BRANCHES; do
50
+ [ "$BRANCH" = "$b" ] && exit 0 # on the default branch → allowed (state-sync commits land here)
51
+ done
52
+
53
+ echo "✋ Commit blocked: the primary checkout must stay on the default branch (you are on '$BRANCH')." >&2
54
+ echo " Another concurrent session may own this branch — committing here is how work gets clobbered." >&2
55
+ echo " Branch work belongs in a worktree: bash .forge/bin/new-worktree.sh <slug>" >&2
56
+ echo " Human escape hatch: ALLOW_MAIN_BRANCHING=1 git commit ..." >&2
57
+ exit 1
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env bash
2
+ # Scratch-repo scenario tests for .forge/git-hooks/pre-commit (issue #21 backstop).
3
+ #
4
+ # Each scenario builds a throwaway repo (and, where needed, a linked worktree), then
5
+ # invokes the pre-commit hook directly from the target dir and asserts its exit code
6
+ # (0 = commit allowed, 1 = commit aborted). No real `git commit` is needed — invoking
7
+ # the hook is deterministic and fast. Self-cleans on exit. Run:
8
+ # ./.forge/git-hooks/tests/pre-commit.test.sh
9
+ # Prints `ALL PASS` and exits 0 iff every scenario passes; non-zero on any failure.
10
+
11
+ set -uo pipefail
12
+
13
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
14
+ HOOK="$SCRIPT_DIR/../pre-commit"
15
+ [ -x "$HOOK" ] || { printf 'FATAL: pre-commit not executable: %s\n' "$HOOK" >&2; exit 1; }
16
+
17
+ ROOT="$(mktemp -d)"
18
+ trap 'rm -rf "$ROOT"' EXIT INT TERM
19
+
20
+ PASSED=0
21
+ FAILED=0
22
+
23
+ new_repo() {
24
+ local d="$ROOT/$1" br="${2:-main}"
25
+ mkdir -p "$d"
26
+ git -C "$d" init -q
27
+ git -C "$d" symbolic-ref HEAD "refs/heads/$br"
28
+ [ "$br" != "main" ] && git -C "$d" config init.defaultBranch "$br"
29
+ git -C "$d" config user.email t@example.com
30
+ git -C "$d" config user.name tester
31
+ : > "$d/seed"
32
+ git -C "$d" add -A && git -C "$d" commit -qm init
33
+ printf '%s\n' "$d"
34
+ }
35
+
36
+ # run_hook <dir> [env-assignment] — run the pre-commit FROM <dir>, return its exit code.
37
+ run_hook() {
38
+ local dir="$1" envset="${2:-}"
39
+ if [ -n "$envset" ]; then
40
+ ( cd "$dir" && env "$envset" "$HOOK" >/dev/null 2>&1 )
41
+ else
42
+ ( cd "$dir" && "$HOOK" >/dev/null 2>&1 )
43
+ fi
44
+ return $?
45
+ }
46
+
47
+ # assert <desc> <expected-exit> <dir> [env]
48
+ assert() {
49
+ local desc="$1" want="$2" dir="$3" envset="${4:-}"
50
+ local got
51
+ run_hook "$dir" "$envset"; got=$?
52
+ if [ "$got" -eq "$want" ]; then
53
+ PASSED=$((PASSED + 1))
54
+ else
55
+ FAILED=$((FAILED + 1))
56
+ printf ' FAIL: %s (got exit %s, want %s)\n' "$desc" "$got" "$want" >&2
57
+ fi
58
+ }
59
+
60
+ ALLOW=0
61
+ BLOCK=1
62
+
63
+ # ── Scenario 1: primary checkout on the default branch → commit allowed ──────────
64
+ P="$(new_repo primary)"
65
+ assert "primary on main: commit allowed" "$ALLOW" "$P"
66
+
67
+ # ── Scenario 2: primary checkout on a feature branch → commit blocked ────────────
68
+ git -C "$P" checkout -q -b feature
69
+ assert "primary on feature branch: commit blocked" "$BLOCK" "$P"
70
+
71
+ # ── Scenario 5 (escape hatch, on the feature branch): allowed ───────────────────
72
+ assert "escape hatch on feature branch: commit allowed" "$ALLOW" "$P" "ALLOW_MAIN_BRANCHING=1"
73
+ git -C "$P" checkout -q main # back home for later worktree add
74
+
75
+ # ── Scenario 3: detached HEAD in the primary checkout → blocked ─────────────────
76
+ D="$(new_repo detached)"
77
+ git -C "$D" checkout -q --detach
78
+ assert "primary detached HEAD: commit blocked" "$BLOCK" "$D"
79
+
80
+ # ── Scenario 4: linked worktree on a feature branch → allowed ────────────────────
81
+ WT="$ROOT/primary-wt"
82
+ git -C "$P" worktree add -q -b wtbranch "$WT" >/dev/null 2>&1
83
+ assert "linked worktree on feature branch: commit allowed" "$ALLOW" "$WT"
84
+
85
+ # ── Bonus: master-default repo on master → allowed; on a branch → blocked ────────
86
+ M="$(new_repo master-primary master)"
87
+ assert "master-default on master: commit allowed" "$ALLOW" "$M"
88
+ git -C "$M" checkout -q -b feat
89
+ assert "master-default on feature branch: commit blocked" "$BLOCK" "$M"
90
+
91
+ # ── Summary ──────────────────────────────────────────────────────────────────────
92
+ printf '\n%d passed, %d failed\n' "$PASSED" "$FAILED"
93
+ if [ "$FAILED" -eq 0 ]; then
94
+ printf 'ALL PASS (incl. escape hatch)\n'
95
+ exit 0
96
+ fi
97
+ exit 1
@@ -0,0 +1,66 @@
1
+ # Migration Guide: Weld the primary checkout to the default branch (Forge 0.78.0)
2
+
3
+ Applies to projects installed before 0.78.0. This milestone (issue #21, ADR-031) welds the primary checkout to its default branch across ALL tiers: a PreToolUse guard denies branch create/switch there, a tracked git `pre-commit` backstop refuses off-default commits there, and a `new-worktree` helper teaches the right path. The `/upgrading` run that installed this guide already synced the hook bodies (`.claude/hooks/protect-primary-checkout.sh`, `.forge/git-hooks/pre-commit`, `.forge/bin/new-worktree.sh`) and merged the settings.json wiring; the one remaining per-clone step is the `core.hooksPath` wiring, which `/upgrading` applies with a detect+warn+never-clobber policy.
4
+
5
+ ## Prerequisites
6
+
7
+ 1. Nothing to hand-edit — the upgrade sync + the `/upgrading` `core.hooksPath` layer ARE the migration.
8
+ 2. If you use Husky / lefthook / a pre-commit framework (i.e. `core.hooksPath` is already set), the wiring will NOT overwrite it — you'll get a warning and can chain `.forge/git-hooks/pre-commit` into your existing hooks dir, or accept PreToolUse-only coverage.
9
+
10
+ ## Detection
11
+
12
+ Prints `MIGRATE` when the primary-checkout weld is not fully wired: the PreToolUse guard is absent from `settings.json`, OR the tracked pre-commit backstop is missing, OR `core.hooksPath` is not pointing at `.forge/git-hooks`. Silent + exit 0 when fully wired.
13
+
14
+ ```bash
15
+ migrate=""
16
+ # (1) PreToolUse guard wired in settings.json?
17
+ if [ -f .claude/settings.json ]; then
18
+ if ! grep -q 'protect-primary-checkout' .claude/settings.json 2>/dev/null; then
19
+ migrate="guard not wired in settings.json"
20
+ fi
21
+ fi
22
+ # (2) tracked pre-commit backstop present?
23
+ if [ ! -f .forge/git-hooks/pre-commit ]; then
24
+ migrate="${migrate:+$migrate; }pre-commit backstop missing (.forge/git-hooks/pre-commit)"
25
+ fi
26
+ # (3) core.hooksPath wired to our dir? (advisory — /upgrading applies this)
27
+ cur=$(git config --get core.hooksPath 2>/dev/null || true)
28
+ if [ "$cur" != ".forge/git-hooks" ]; then
29
+ migrate="${migrate:+$migrate; }core.hooksPath not '.forge/git-hooks' (currently '${cur:-unset}')"
30
+ fi
31
+ if [ -n "$migrate" ]; then
32
+ echo "MIGRATE — primary-checkout weld incomplete: $migrate"
33
+ fi
34
+ ```
35
+
36
+ ## Migration steps
37
+
38
+ ### 1. Re-run the upgrade / initialize
39
+
40
+ - `/upgrading` on 0.78.0+ framework files syncs the hooks, merges the settings.json guard group, and applies the `core.hooksPath` wiring layer (detect+warn+never-clobber). The run that installed this guide already did the file sync; the `core.hooksPath` layer is what completes the weld.
41
+ - On a brand-new project, `initializing` Finalize wires `core.hooksPath` as part of setup.
42
+
43
+ ### 2. If `core.hooksPath` was already set (Husky/lefthook)
44
+
45
+ The wiring will not overwrite it. To keep the pre-commit weld active, chain the Forge hook into your existing hooks directory — e.g. add to your `pre-commit`:
46
+
47
+ ```bash
48
+ # run the Forge primary-checkout backstop, if present
49
+ [ -x .forge/git-hooks/pre-commit ] && .forge/git-hooks/pre-commit || true
50
+ ```
51
+
52
+ Or accept PreToolUse-only coverage: Claude sessions are still guarded by `protect-primary-checkout.sh`; only the human/CI backstop is inactive.
53
+
54
+ ### 3. Verify
55
+
56
+ From the primary checkout on the default branch:
57
+
58
+ ```bash
59
+ # guard denies branch create (exit 2) — run it directly against a payload:
60
+ printf '{"tool_input":{"command":"git checkout -b probe"},"cwd":"'"$(git rev-parse --show-toplevel)"'"}' \
61
+ | .claude/hooks/protect-primary-checkout.sh; echo "exit=$? (expect 2 in the PRIMARY checkout)"
62
+ # pre-commit allows on the default branch:
63
+ .forge/git-hooks/pre-commit; echo "exit=$? (expect 0 on the default branch)"
64
+ ```
65
+
66
+ All branch work now goes through `bash .forge/bin/new-worktree.sh <slug>`. Escape hatch for deliberate human branching: `ALLOW_MAIN_BRANCHING=1`.
@@ -7,6 +7,9 @@ type: "" # recurring_friction | agent_struggle | us
7
7
  date: "" # ISO 8601 date the observation was made, e.g. "2026-07-23"
8
8
  milestone: "" # milestone id, or a short label for non-milestone work (e.g. "quick-task (...)")
9
9
  scope: project # project | framework — absent ⇒ project (this repo only; framework ⇒ upstream-eligible per ADR-012)
10
+ # resolves_issue: 26 # OPTIONAL — GitHub issue number this observation's fix resolves. The
11
+ # # close-the-loop check (reviewing) keys off it to surface the issue as
12
+ # # likely-closable once the fix ships. Omit when the observation resolves no issue.
10
13
  suggestion_target: "" # the file/skill this observation would change if acted on
11
14
  note: >
12
15
  What was observed — concrete enough that a future review can judge recurrence
@@ -1,61 +0,0 @@
1
- #!/usr/bin/env bash
2
- # Forge PreToolUse(Bash) guardrail — protect the shared main checkout's HEAD when
3
- # parallel git worktrees are active.
4
- #
5
- # Blocks the "moved the shared HEAD under other sessions" catastrophe (canvaz, burned
6
- # twice 2026-06-05): switching branches or hard-resetting in the MAIN checkout while
7
- # sibling worktrees are branched off it. Orchestration-agnostic — depends only on git
8
- # worktree state, so it holds whether coordination is M10, Agent Teams, or manual
9
- # worktrees.
10
- #
11
- # Scope is deliberately narrow to avoid false positives:
12
- # - fires ONLY when >1 worktree exists (single-worktree/solo work is never touched)
13
- # - fires ONLY in the main checkout (linked worktrees own their own HEAD)
14
- # - skips any command containing `cd` (can't reliably tell which tree it targets)
15
- # - allows safe forms: file restore (`git checkout -- <path>`), branch create (`-b`)
16
- #
17
- # exit 0 = allow; exit 2 = deny. Override by running the command yourself outside Claude.
18
-
19
- set -uo pipefail
20
-
21
- PAYLOAD=$(cat)
22
- CMD=$(printf '%s' "$PAYLOAD" | jq -r '.tool_input.command // empty' 2>/dev/null) || exit 0
23
- [ -z "$CMD" ] && exit 0
24
-
25
- # Only git commands are interesting; and bail on `cd` (target tree is ambiguous).
26
- case "$CMD" in
27
- *git*) ;;
28
- *) exit 0 ;;
29
- esac
30
- case "$CMD" in
31
- *"cd "*) exit 0 ;;
32
- esac
33
-
34
- git rev-parse --is-inside-work-tree >/dev/null 2>&1 || exit 0
35
-
36
- # Single worktree → no shared-HEAD hazard.
37
- wt_count=$(git worktree list 2>/dev/null | wc -l | tr -d ' ')
38
- [ "${wt_count:-1}" -le 1 ] && exit 0
39
-
40
- # Main checkout iff --git-dir == --git-common-dir (linked worktrees differ).
41
- [ "$(git rev-parse --git-dir 2>/dev/null)" = "$(git rev-parse --git-common-dir 2>/dev/null)" ] || exit 0
42
-
43
- deny() { echo "[forge-branch-guard] $*" >&2; exit 2; }
44
-
45
- # Branch switch (checkout/switch to a ref), excluding file-restore (`--`) and create (`-b`).
46
- switches_branch=0
47
- if printf '%s' "$CMD" | grep -qE '\bgit[[:space:]]+(checkout|switch)\b'; then
48
- if ! printf '%s' "$CMD" | grep -qE '([[:space:]]--([[:space:]]|$))|(\bgit[[:space:]]+(checkout|switch)[[:space:]]+-[bB]\b)'; then
49
- switches_branch=1
50
- fi
51
- fi
52
-
53
- # Hard/keep/merge reset rewrites HEAD + working tree.
54
- hard_reset=0
55
- printf '%s' "$CMD" | grep -qE '\bgit[[:space:]]+reset[[:space:]]+(--hard|--keep|--merge)\b' && hard_reset=1
56
-
57
- if [ "$switches_branch" = "1" ] || [ "$hard_reset" = "1" ]; then
58
- deny "Refusing to move the shared main HEAD — $wt_count worktrees are branched off it (the 'burned twice' failure). Switch/reset inside a worktree, or stop the other sessions first. Override: run the command yourself outside Claude."
59
- fi
60
-
61
- exit 0