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.
- package/bin/create-forge.js +27 -0
- package/experimental/m10/README.md +4 -4
- package/experimental/m10/install.sh +10 -9
- package/experimental/m10/source/hooks/protect-primary-checkout.sh +149 -0
- package/package.json +1 -1
- package/template/.claude/hooks/README.md +121 -0
- package/template/.claude/hooks/forge-state-rollup.sh +431 -0
- package/template/.claude/hooks/protect-primary-checkout.sh +149 -0
- package/template/.claude/hooks/tests/README.md +20 -0
- package/template/.claude/hooks/tests/forge-state-rollup.test.sh +282 -0
- package/template/.claude/hooks/tests/protect-primary-checkout.test.sh +147 -0
- package/template/.claude/settings.json +20 -0
- package/template/.claude/skills/chief-of-staff/SKILL.md +16 -11
- package/template/.claude/skills/forge/SKILL.md +11 -7
- package/template/.claude/skills/initializing/SKILL.md +20 -1
- package/template/.claude/skills/reviewing/SKILL.md +17 -0
- package/template/.claude/skills/upgrading/SKILL.md +22 -2
- package/template/.forge/FORGE.md +8 -5
- package/template/.forge/bin/new-worktree.sh +61 -0
- package/template/.forge/bin/tests/new-worktree.test.sh +70 -0
- package/template/.forge/git-hooks/pre-commit +57 -0
- package/template/.forge/git-hooks/tests/pre-commit.test.sh +97 -0
- package/template/.forge/migrations/0.77.0-state-rollup-executable.md +120 -0
- package/template/.forge/migrations/0.78.0-weld-primary-checkout.md +66 -0
- package/template/.forge/templates/state/desire-path.yml +3 -0
- package/experimental/m10/source/hooks/forge-branch-guard.sh +0 -61
package/bin/create-forge.js
CHANGED
|
@@ -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/
|
|
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
|
|
74
|
+
Also add the primary-checkout guardrail under `PreToolUse`:
|
|
75
75
|
```json
|
|
76
76
|
{ "matcher": "Bash",
|
|
77
|
-
"hooks": [{ "type": "command", "command": ".claude/hooks/
|
|
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
|
|
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
|
|
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
|
|
165
|
-
#
|
|
166
|
-
|
|
167
|
-
|
|
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 —
|
|
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:
|
|
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/
|
|
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
|
|
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
|
@@ -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`.
|