From b97172c466a79e0a9bc2957d73efe5845846c151 Mon Sep 17 00:00:00 2001 From: Mike Key Date: Wed, 15 Jul 2026 23:25:14 -0600 Subject: [PATCH] feat(claude-code): vendor adapter into projects with global-first fallback Projects now carry their own Claude Code rails: init/update vendor adapters/claude-code/guard.js as an engine file and merge SessionStart/Stop hooks into the project's committed .claude/settings.json. Each project hook defers to a machine-global install (grep sentinel on ~/.claude/settings.json) so nothing double-fires; without one, the repo is self-sufficient for collaborators who never ran scaffold setup. The global Stop hook now prefers the project's vendored guard over the canonical checkout, so the gate a session runs is the one the repo shipped. Drops the legacy-adapters warning and the template settings.json variant (the CLI generates project settings now). --- bin/scaffold | 45 +++++++++++++++---- docs/adapters.md | 10 +++-- docs/architecture.md | 2 +- docs/cli.md | 6 +-- .../adapters/claude-code/settings.json | 24 ---------- 5 files changed, 47 insertions(+), 40 deletions(-) delete mode 100644 template/.scaffold/adapters/claude-code/settings.json diff --git a/bin/scaffold b/bin/scaffold index 7fa16a8..ddbd9c3 100755 --- a/bin/scaffold +++ b/bin/scaffold @@ -2,9 +2,11 @@ // scaffold — deploy/update the scaffold-code engine into project repos. // Engine files (BOOT, cookbook) are stock and overwritten by `update`; // rails/ and memory/ are project-owned and never touched. -// Adapters are NOT copied into projects — `scaffold setup` installs them -// globally per machine (Pi extension, Claude Code hooks, Codex hooks, and -// an opencode plugin). +// Adapters install machine-globally via `scaffold setup` (Pi extension, +// Claude Code hooks, Codex hooks, an opencode plugin). The Claude Code +// adapter is ALSO vendored into each project (guard.js + committed +// .claude/settings.json) so collaborators without a global install get the +// rails; the project hooks defer to the global install when one exists. const fs = require("fs"); const path = require("path"); const crypto = require("crypto"); @@ -21,6 +23,7 @@ const ENGINE = [ "cookbook/closeout.md", "cookbook/closeout-check.sh", "hooks/pre-push", + "adapters/claude-code/guard.js", ]; const STATE_TEMPLATES = [ "rails/standards.md", @@ -76,6 +79,30 @@ function installGitHook(dir) { fs.chmodSync(dest, 0o755); } +// Portable Claude Code adapter: committed project settings that fire the vendored +// guard/BOOT only when no machine-global install (`scaffold setup`) is present. +function installClaudeProjectSettings(dir) { + const deferToGlobal = `grep -qsF '.scaffold/BOOT.md' "$HOME/.claude/settings.json" || `; + const wanted = { + SessionStart: [{ hooks: [ + { type: "command", command: deferToGlobal + 'cat "$CLAUDE_PROJECT_DIR/.scaffold/BOOT.md"' }, + ] }], + Stop: [{ hooks: [ + { type: "command", command: deferToGlobal + 'node "$CLAUDE_PROJECT_DIR/.scaffold/adapters/claude-code/guard.js"' }, + ] }], + }; + const claudeDir = path.join(dir, ".claude"); + const settingsPath = path.join(claudeDir, "settings.json"); + const settings = fs.existsSync(settingsPath) + ? JSON.parse(fs.readFileSync(settingsPath, "utf8")) + : {}; + if (mergeHooks(settings, wanted)) { + fs.mkdirSync(claudeDir, { recursive: true }); + fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n"); + console.log(`claude: project hooks written to ${settingsPath} — commit it`); + } +} + const stamp = (dest) => fs.writeFileSync( path.join(dest, ".scaffold", "VERSION"), @@ -99,6 +126,7 @@ function init(dir) { for (const f of ENGINE) copy(f, dir, true); for (const f of STATE_TEMPLATES) copy(f, dir, false); installGitHook(dir); + installClaudeProjectSettings(dir); stamp(dir); register(dir); console.log(`initialized ${dir}/.scaffold (${version()})`); @@ -111,13 +139,10 @@ function update(dir) { for (const f of ENGINE) copy(f, dir, true); for (const f of STATE_TEMPLATES) copy(f, dir, false); // fill gaps, never overwrite installGitHook(dir); + installClaudeProjectSettings(dir); stamp(dir); register(dir); console.log(`updated ${dir}/.scaffold engine (${version()})`); - if (fs.existsSync(path.join(dir, ".scaffold", "adapters"))) - console.log( - "warn: legacy .scaffold/adapters/ copy in project — adapters are machine-global now (`scaffold setup`); safe to delete", - ); } function status(dir) { @@ -173,7 +198,11 @@ function setup() { SessionStart: [{ hooks: [ { type: "command", command: guarded('cat "$CLAUDE_PROJECT_DIR/.scaffold/BOOT.md"') }, ] }], - Stop: [{ hooks: [{ type: "command", command: guarded(`node "${guard}"`) }] }], + // prefer the project's vendored guard (kept current by `scaffold update`), + // fall back to the canonical checkout for repos that predate vendoring + Stop: [{ hooks: [{ type: "command", command: guarded( + `g="$CLAUDE_PROJECT_DIR/.scaffold/adapters/claude-code/guard.js"; [ -f "$g" ] || g="${guard}"; node "$g"`, + ) }] }], }; const changed = mergeHooks(settings, wanted); if (changed) { diff --git a/docs/adapters.md b/docs/adapters.md index f167b45..efdfab2 100644 --- a/docs/adapters.md +++ b/docs/adapters.md @@ -2,16 +2,18 @@ An adapter does exactly two things: inject `.scaffold/BOOT.md` at session start (the **keystone**) and relay the [closeout gate](closeout-gate.md)'s verdict at session end. No rail logic lives in any adapter; enforcement stays in the git pre-push hook and `closeout-check.sh`, which travel with the repo (see [architecture](architecture.md)). -Adapters install once per machine with `scaffold setup` and are never copied into projects. Each activates only when the project contains `.scaffold/BOOT.md`, and each honors `SCAFFOLD_OFF=1` by doing nothing at all. Sources live in `template/.scaffold/adapters/` in the canonical repo. +Adapters install once per machine with `scaffold setup`. Each activates only when the project contains `.scaffold/BOOT.md`, and each honors `SCAFFOLD_OFF=1` by doing nothing at all. Sources live in `template/.scaffold/adapters/` in the canonical repo. Claude Code is the exception to machine-only: its adapter also travels with the repo, so collaborators who never ran `scaffold setup` still get the rails. ## Claude Code -Installed as global hooks merged into `~/.claude/settings.json` (the old file is backed up as `settings.json.bak-scaffold`). Both hook commands are wrapped in a shell guard, `if [ -f "$CLAUDE_PROJECT_DIR/.scaffold/BOOT.md" ]`, so they no-op outside scaffold repos. +Two layers, and exactly one fires per session: + +**Machine-global** (`scaffold setup`): hooks merged into `~/.claude/settings.json` (the old file is backed up as `settings.json.bak-scaffold`). Both hook commands are wrapped in a shell guard, `if [ -f "$CLAUDE_PROJECT_DIR/.scaffold/BOOT.md" ]`, so they no-op outside scaffold repos. - **SessionStart**: `cat`s BOOT.md into the session context. -- **Stop**: runs `adapters/claude-code/guard.js` from the canonical checkout. It runs the gate in the session's cwd; on failure it emits `{"decision": "block", "reason": "scaffold closeout gate: FAIL: ..."}`, which blocks the stop until closeout is done. It exits silently when `stop_hook_active` is set, so a blocked stop cannot loop forever. +- **Stop**: runs the project's vendored `.scaffold/adapters/claude-code/guard.js` (falling back to the canonical checkout's copy for repos that predate vendoring). It runs the gate in the session's cwd; on failure it emits `{"decision": "block", "reason": "scaffold closeout gate: FAIL: ..."}`, which blocks the stop until closeout is done. It exits silently when `stop_hook_active` is set, so a blocked stop cannot loop forever. -`adapters/claude-code/settings.json` remains as a per-project variant for repos that want in-repo enforcement for collaborators; it references guard.js inside the project rather than the global checkout. +**In-repo** (`scaffold init`/`update`): guard.js is vendored at `.scaffold/adapters/claude-code/guard.js` and the same SessionStart/Stop hooks are merged into the project's committed `.claude/settings.json`. Each project hook first checks `~/.claude/settings.json` for a scaffold install and defers to it, so machines with the global adapter never double-fire; everywhere else the repo is self-sufficient. Claude Code prompts collaborators once to trust the project hooks. ## Codex diff --git a/docs/architecture.md b/docs/architecture.md index 212180f..cc3abe8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -9,7 +9,7 @@ Every file in a deployed `.scaffold/` belongs to one of two classes, and the spl - **Engine** files are stock. `scaffold update` overwrites them freely: `BOOT.md`, `cookbook/orient.md`, `cookbook/loop.md`, `cookbook/closeout.md`, `cookbook/closeout-check.sh`, and `hooks/pre-push`. - **State** files belong to the project. They are copied once as templates and never overwritten: `rails/standards.md` (the project's enforceable standards) and `memory/` (the `STATE.md` snapshot plus the `log/` of per-intent closeouts). -**Adapters** are the third piece, and they live nowhere in the project. `scaffold setup` installs them machine-globally per runtime (Claude Code, Codex, Pi, opencode). The presence of `.scaffold/BOOT.md` in a repo is what activates them; everywhere else they no-op. See [adapters](adapters.md). +**Adapters** are the third piece. `scaffold setup` installs them machine-globally per runtime (Claude Code, Codex, Pi, opencode); the Claude Code adapter is additionally vendored into each project so the repo carries its own rails for collaborators without a global install. The presence of `.scaffold/BOOT.md` in a repo is what activates them; everywhere else they no-op. See [adapters](adapters.md). ``` project/ diff --git a/docs/cli.md b/docs/cli.md index b98c24b..4d032c2 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -16,18 +16,18 @@ scaffold list print registered projects The commands treat two file sets differently (see [architecture](architecture.md)): -- **Engine** (always overwritten): `BOOT.md`, `cookbook/orient.md`, `cookbook/loop.md`, `cookbook/closeout.md`, `cookbook/closeout-check.sh`, `hooks/pre-push`. +- **Engine** (always overwritten): `BOOT.md`, `cookbook/orient.md`, `cookbook/loop.md`, `cookbook/closeout.md`, `cookbook/closeout-check.sh`, `hooks/pre-push`, `adapters/claude-code/guard.js`. - **State templates** (copied only if missing, never overwritten): `rails/standards.md`, `memory/STATE.md`, `memory/log/*.example`. ## scaffold init -Creates `` if needed, runs `git init` if it is not a repo, and refuses to run anywhere but the repo root. Fails if `.scaffold/BOOT.md` already exists (use `update`). Then it copies engine files and state templates, installs the pre-push hook into `.git/hooks/`, writes the VERSION stamp, and registers the project. +Creates `` if needed, runs `git init` if it is not a repo, and refuses to run anywhere but the repo root. Fails if `.scaffold/BOOT.md` already exists (use `update`). Then it copies engine files and state templates, installs the pre-push hook into `.git/hooks/`, merges the Claude Code hooks into the project's committed `.claude/settings.json` (see [adapters](adapters.md)), writes the VERSION stamp, and registers the project. If `.git/hooks/pre-push` already exists and does not contain the string `scaffold-code`, init leaves it alone and prints a warning; chain `.scaffold/hooks/pre-push` into your hook manually. ## scaffold update -Overwrites engine files, fills in any missing state templates, reinstalls the pre-push hook, restamps VERSION, and re-registers the project. It never overwrites `rails/` or `memory/` content that exists. Fails if the project has no `.scaffold/`. Warns if it finds a legacy `.scaffold/adapters/` copy in the project (adapters are machine-global now; delete it). +Overwrites engine files (including the vendored Claude Code guard), fills in any missing state templates, reinstalls the pre-push hook, refreshes the project's committed `.claude/settings.json` hooks, restamps VERSION, and re-registers the project. It never overwrites `rails/` or `memory/` content that exists. Fails if the project has no `.scaffold/`. ## scaffold status diff --git a/template/.scaffold/adapters/claude-code/settings.json b/template/.scaffold/adapters/claude-code/settings.json deleted file mode 100644 index 3bca954..0000000 --- a/template/.scaffold/adapters/claude-code/settings.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "hooks": { - "SessionStart": [ - { - "hooks": [ - { - "type": "command", - "command": "cat \"$CLAUDE_PROJECT_DIR/.scaffold/BOOT.md\"" - } - ] - } - ], - "Stop": [ - { - "hooks": [ - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR/.scaffold/adapters/claude-code/guard.js\"" - } - ] - } - ] - } -}