forge-orkes 0.75.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.
Files changed (26) 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/README.md +121 -0
  7. package/template/.claude/hooks/forge-state-rollup.sh +431 -0
  8. package/template/.claude/hooks/protect-primary-checkout.sh +149 -0
  9. package/template/.claude/hooks/tests/README.md +20 -0
  10. package/template/.claude/hooks/tests/forge-state-rollup.test.sh +282 -0
  11. package/template/.claude/hooks/tests/protect-primary-checkout.test.sh +147 -0
  12. package/template/.claude/settings.json +20 -0
  13. package/template/.claude/skills/chief-of-staff/SKILL.md +16 -11
  14. package/template/.claude/skills/forge/SKILL.md +11 -7
  15. package/template/.claude/skills/initializing/SKILL.md +20 -1
  16. package/template/.claude/skills/reviewing/SKILL.md +17 -0
  17. package/template/.claude/skills/upgrading/SKILL.md +22 -2
  18. package/template/.forge/FORGE.md +8 -5
  19. package/template/.forge/bin/new-worktree.sh +61 -0
  20. package/template/.forge/bin/tests/new-worktree.test.sh +70 -0
  21. package/template/.forge/git-hooks/pre-commit +57 -0
  22. package/template/.forge/git-hooks/tests/pre-commit.test.sh +97 -0
  23. package/template/.forge/migrations/0.77.0-state-rollup-executable.md +120 -0
  24. package/template/.forge/migrations/0.78.0-weld-primary-checkout.md +66 -0
  25. package/template/.forge/templates/state/desire-path.yml +3 -0
  26. package/experimental/m10/source/hooks/forge-branch-guard.sh +0 -61
@@ -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.75.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"
@@ -113,3 +113,124 @@ primary coordination point.
113
113
  - "internal error at line N" on every edit → corrupt DB or missing tool. Run doctor. Common: `jq` not on PATH.
114
114
  - No collisions detected → confirm `CLAUDE_SESSION_ID` set and `claims.db` exists; otherwise hook fail-opens.
115
115
  - macOS `timeout: command not found` → `brew install coreutils` for `gtimeout`, or skip (DB busy_timeout still applies).
116
+
117
+ ## `forge-state-rollup.sh` — SessionStart state rollup
118
+
119
+ Makes the boot rollup mechanical (issue #22). Ports **State Rollup 1.0** and the
120
+ **Stream Rollup** (ADR-015 addendum) from advisory skill prose into an
121
+ executable that a SessionStart hook runs on every boot, so a session can no
122
+ longer skip the render and work from a stale cache or hand-reconstruct the
123
+ registries — the failure mode filed against #22.
124
+
125
+ ### Behavior
126
+
127
+ Renders two git-ignored render-on-read caches (0.53.0) and never commits
128
+ either:
129
+
130
+ | Renders | From | Notes |
131
+ |---|---|---|
132
+ | `.forge/state/index.yml` | `.forge/state/milestone-*.yml` | Derived registry status (`deferred`/`complete`/`not_started`/`active`); `last_updated` falls back to legacy `progress.last_update`; per-row `release_state` is fetched by shelling to `forge-release-fold.sh` — the fold is the **only** source, never re-derived here |
133
+ | `.forge/streams/active.yml` | `.forge/streams/*.yml` + active milestones | Stream Rollup join: per-stream coordination rows, milestone-derived `phase`, `implicit` coverage rows for active milestones with no stream file, derived `merge_queue` (only rendered if `.forge/streams/` exists) |
134
+
135
+ A **step-0 completed-stream auto-close sweep** runs before the stream join and
136
+ edits committed stream files (`streams/{stream}.yml`) — a milestone-backed
137
+ stream whose milestone is `complete`, not already `closed`, with no live
138
+ worktree gets auto-closed. This step is **main-checkout only**
139
+ (`git rev-parse --git-dir` == `--git-common-dir`); a linked worktree skips the
140
+ sweep but still renders both caches locally.
141
+
142
+ Deterministic and idempotent — two runs against the same sources produce
143
+ byte-identical output.
144
+
145
+ **Fold dependency and degrade.** `release_state` comes exclusively from
146
+ `.claude/hooks/forge-release-fold.sh`. If the fold script is missing or exits
147
+ non-zero for a unit, that row gets `release_state: unknown` plus one advisory
148
+ stderr line (`forge-state-rollup: release fold unavailable for m-N —
149
+ release_state: unknown`) — a broken fold degrades a single row, it never
150
+ breaks boot.
151
+
152
+ **Non-blocking contract.** Exit `0` **always** — SessionStart hooks are
153
+ non-blocking, so every failure path (not a git repo, no `.forge/`, no
154
+ `.forge/state/`) degrades to a silent no-op rather than surfacing an error.
155
+ stdout carries one terse summary line (unless `--quiet`) that is injected into
156
+ the model's context — e.g. `[forge-state-rollup] rendered index.yml (3
157
+ milestones) + active.yml (2 stream rows). Registries are fresh — READ them,
158
+ do not regenerate by hand.` stderr carries advisories (auto-close, fold
159
+ degrade) and is visible in the transcript but not injected into context.
160
+
161
+ ### Why `cwd`, not `$CLAUDE_PROJECT_DIR`
162
+
163
+ `$CLAUDE_PROJECT_DIR` resolves to the repo **root** (main checkout) even when
164
+ the session started inside a linked worktree. Rendering against it from a
165
+ worktree session would write the caches into the wrong tree. The hook instead
166
+ resolves the target repo from the SessionStart payload's `cwd` (read from
167
+ stdin JSON), falling back to `$PWD` if `--project` isn't passed and stdin/`jq`
168
+ aren't available. Each worktree therefore renders **its own** gitignored
169
+ caches; a worktree's boot never touches main's.
170
+
171
+ ### Prerequisites
172
+
173
+ - POSIX `sh` + `git` + `awk`/`sed` (mirrors `forge-release-fold.sh`) — no other
174
+ runtime dependency.
175
+ - `jq` — used only to parse the SessionStart stdin JSON (`cwd`). Absent `jq`
176
+ degrades to a no-stdin path (falls back to `--project` or `$PWD`); it does
177
+ not disable the hook.
178
+ - No network calls.
179
+
180
+ ### CLI usage
181
+
182
+ ```bash
183
+ .claude/hooks/forge-state-rollup.sh [--project <path>] [--quiet]
184
+ ```
185
+
186
+ - `--project <path>` — operate on the repo containing `<path>` instead of the
187
+ SessionStart payload's `cwd`. Useful for manually re-rendering a specific
188
+ worktree or repo outside a session.
189
+ - `--quiet` — suppress the stdout summary line; caches are still written.
190
+
191
+ Run it manually any time the caches look stale — it is safe to re-run and
192
+ always produces the same output for the same sources.
193
+
194
+ ### Registration
195
+
196
+ Wired as a `SessionStart` hook in `.claude/settings.json` (and the
197
+ `create-forge` template copy), matcher `startup|resume|clear`:
198
+
199
+ ```json
200
+ {
201
+ "hooks": {
202
+ "SessionStart": [
203
+ {
204
+ "matcher": "startup|resume|clear",
205
+ "hooks": [
206
+ {
207
+ "type": "command",
208
+ "command": "[ -x \"$CLAUDE_PROJECT_DIR/.claude/hooks/forge-state-rollup.sh\" ] && \"$CLAUDE_PROJECT_DIR/.claude/hooks/forge-state-rollup.sh\" || true"
209
+ }
210
+ ]
211
+ }
212
+ ]
213
+ }
214
+ }
215
+ ```
216
+
217
+ The `[ -x ... ] && ... || true` guard means a repo without the hook file (or
218
+ with it non-executable) boots exactly as before — the hook is additive, not a
219
+ new hard dependency.
220
+
221
+ ### Disabling
222
+
223
+ Remove the `SessionStart` block above from `.claude/settings.json`, or make
224
+ the script non-executable: `chmod -x .claude/hooks/forge-state-rollup.sh`.
225
+ Without it, boot falls back to the advisory skill-prose rollup (`forge` /
226
+ `chief-of-staff` render-then-read) — correct but no longer mechanically
227
+ enforced.
228
+
229
+ ### Tests
230
+
231
+ `.claude/hooks/tests/forge-state-rollup.test.sh` — a standalone git-fixture
232
+ suite (36 cases) run directly (not through `tests/run.sh`), building throwaway
233
+ repos in `mktemp` dirs. Covers the #22 acceptance shape (a stale cache is
234
+ regenerated regardless), fold-degrade → `release_state: unknown`, the
235
+ main-only auto-close sweep, the linked-worktree guard (sweep skipped, caches
236
+ still rendered), and the SessionStart stdin path. See `tests/README.md`.