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
|
@@ -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)–(
|
|
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.
|
package/template/.forge/FORGE.md
CHANGED
|
@@ -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,18 +197,19 @@ 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
|
|
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
|
-
- `streams/active.yml` — DERIVED Project Chief traffic map, git-ignored render-on-read cache — see Stream Rollup below ([ADR-015](../docs/decisions/ADR-015-derived-stream-registry.md))
|
|
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)
|
|
205
207
|
- `research/milestone-{id}.md` — dated immutable findings snapshot
|
|
206
208
|
- `phases/milestone-{id}/{phase}-{name}/plan-{NN}.md` — task plans with must_haves frontmatter (`{id}`=milestone, `{phase}`=phase# verbatim, `{name}`=kebab, `{NN}`=seq)
|
|
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
|
|
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,120 @@
|
|
|
1
|
+
# Migration Guide: State rollup made mechanical (Forge 0.77.0)
|
|
2
|
+
|
|
3
|
+
Applies to projects upgrading to 0.77.0 or later. State Rollup 1.0 and the
|
|
4
|
+
Stream Rollup (ADR-015 addendum) — previously advisory skill prose that an
|
|
5
|
+
agent had to remember to run at boot — are now an executable,
|
|
6
|
+
`.claude/hooks/forge-state-rollup.sh`, invoked mechanically by a `SessionStart`
|
|
7
|
+
hook. A boot session can no longer skip the render and fall back to reading a
|
|
8
|
+
stale `.forge/state/index.yml` / `.forge/streams/active.yml` cache, or
|
|
9
|
+
hand-reconstruct the registries from memory — the exact failure mode filed as
|
|
10
|
+
issue #22.
|
|
11
|
+
|
|
12
|
+
`/upgrading` ships the new hook file (`.claude/hooks/forge-state-rollup.sh`)
|
|
13
|
+
like any other framework file. It does **not** wire the `SessionStart`
|
|
14
|
+
registration, because `.claude/settings.json` is a **user-owned file** —
|
|
15
|
+
upgrades never overwrite it (FORGE.md → State Ownership, "Stable shared"
|
|
16
|
+
class notes settings.json is gitignored-carve-out territory, propagated only
|
|
17
|
+
via explicit installer steps, never blanket overwrite). Without this
|
|
18
|
+
migration's one manual step, the hook ships but is never invoked: the
|
|
19
|
+
executable exists, nothing mechanically triggers it.
|
|
20
|
+
|
|
21
|
+
Projects that ran `initializing` fresh on 0.77.0+ (or already carry the
|
|
22
|
+
`SessionStart` block from an earlier partial adoption) need nothing further.
|
|
23
|
+
|
|
24
|
+
## Prerequisites
|
|
25
|
+
|
|
26
|
+
1. On the new version's framework files (`npx forge-orkes upgrade`, or the
|
|
27
|
+
`upgrading` skill) — so `.claude/hooks/forge-state-rollup.sh` and
|
|
28
|
+
`.claude/hooks/forge-release-fold.sh` are present and executable.
|
|
29
|
+
2. Working tree clean or changes committed — this migration edits
|
|
30
|
+
`.claude/settings.json`, a tracked file.
|
|
31
|
+
|
|
32
|
+
## Detection
|
|
33
|
+
|
|
34
|
+
Keyed on **the executable existing (or the version already at/above 0.77.0)
|
|
35
|
+
while the SessionStart wiring is absent** — the shipped-but-unwired gap. Uses
|
|
36
|
+
`jq` to inspect `.claude/settings.json`'s `hooks.SessionStart` array for an
|
|
37
|
+
entry referencing `forge-state-rollup`; degrades to a plain-text grep if `jq`
|
|
38
|
+
is not installed. Silent in a non-Forge / non-git tree, silent once the hook
|
|
39
|
+
is wired:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
git rev-parse --is-inside-work-tree >/dev/null 2>&1 || exit 0
|
|
43
|
+
[ -x .claude/hooks/forge-state-rollup.sh ] || exit 0
|
|
44
|
+
|
|
45
|
+
settings=".claude/settings.json"
|
|
46
|
+
[ -f "$settings" ] || {
|
|
47
|
+
echo "MIGRATE — forge-state-rollup.sh is present but $settings does not exist; add it with the SessionStart hook below"
|
|
48
|
+
exit 0
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if command -v jq >/dev/null 2>&1; then
|
|
52
|
+
wired="$(jq -r '
|
|
53
|
+
(.hooks.SessionStart // []) as $entries
|
|
54
|
+
| [$entries[].hooks[]?.command // "" | select(contains("forge-state-rollup"))]
|
|
55
|
+
| length > 0
|
|
56
|
+
' "$settings" 2>/dev/null)"
|
|
57
|
+
if [ "$wired" != "true" ]; then
|
|
58
|
+
echo "MIGRATE — $settings has no SessionStart hook referencing forge-state-rollup.sh; the executable shipped but nothing mechanically triggers it"
|
|
59
|
+
fi
|
|
60
|
+
else
|
|
61
|
+
if ! grep -q "forge-state-rollup" "$settings" 2>/dev/null; then
|
|
62
|
+
echo "MIGRATE — $settings has no reference to forge-state-rollup.sh (jq unavailable, ran plain-text check); add the SessionStart hook below"
|
|
63
|
+
fi
|
|
64
|
+
fi
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Migration steps
|
|
68
|
+
|
|
69
|
+
### 1. Add the SessionStart hook block
|
|
70
|
+
|
|
71
|
+
Open `.claude/settings.json` and add (or merge into) a `hooks.SessionStart`
|
|
72
|
+
array entry:
|
|
73
|
+
|
|
74
|
+
```json
|
|
75
|
+
{
|
|
76
|
+
"hooks": {
|
|
77
|
+
"SessionStart": [
|
|
78
|
+
{
|
|
79
|
+
"matcher": "startup|resume|clear",
|
|
80
|
+
"hooks": [
|
|
81
|
+
{
|
|
82
|
+
"type": "command",
|
|
83
|
+
"command": "[ -x \"$CLAUDE_PROJECT_DIR/.claude/hooks/forge-state-rollup.sh\" ] && \"$CLAUDE_PROJECT_DIR/.claude/hooks/forge-state-rollup.sh\" || true"
|
|
84
|
+
}
|
|
85
|
+
]
|
|
86
|
+
}
|
|
87
|
+
]
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
If `hooks.SessionStart` already has other entries (e.g. from a project-local
|
|
93
|
+
customization), append this object to the existing array rather than
|
|
94
|
+
replacing it. If `hooks` exists but has no `SessionStart` key yet, add the key
|
|
95
|
+
alongside the existing `PreToolUse`/`PostToolUse` blocks.
|
|
96
|
+
|
|
97
|
+
The `[ -x ... ] && ... || true` guard means the hook is a no-op — never a
|
|
98
|
+
boot failure — on any checkout where the script is absent or not executable
|
|
99
|
+
(SessionStart hooks are non-blocking; `forge-state-rollup.sh` itself also
|
|
100
|
+
always exits 0).
|
|
101
|
+
|
|
102
|
+
### 2. Commit the settings change
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
git add .claude/settings.json
|
|
106
|
+
git commit -m "chore(forge): wire forge-state-rollup SessionStart hook (0.77.0)"
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Validation
|
|
110
|
+
|
|
111
|
+
- Start a new session, or run `/clear` — the boot summary line should include
|
|
112
|
+
`[forge-state-rollup] rendered index.yml (…) …` (plus an `active.yml` clause
|
|
113
|
+
if `.forge/streams/` exists), confirming the hook actually ran.
|
|
114
|
+
- `jq '.hooks.SessionStart' .claude/settings.json` prints a non-null array
|
|
115
|
+
containing the `forge-state-rollup` command, not `null`.
|
|
116
|
+
- Re-running the Detection block above now prints nothing (exits silently) —
|
|
117
|
+
the gap is closed.
|
|
118
|
+
- `.forge/state/index.yml` and `.forge/streams/active.yml` (if applicable)
|
|
119
|
+
exist on disk and are fresh — their mtimes update on every boot, since the
|
|
120
|
+
hook re-renders unconditionally.
|
|
@@ -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
|