Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 37 additions & 8 deletions bin/scaffold
Original file line numberDiff line numberDiff line change
Expand Up@@ -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");
Expand All@@ -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",
Expand DownExpand Up@@ -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"),
Expand All@@ -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()})`);
Expand All@@ -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) {
Expand DownExpand Up@@ -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) {
Expand Down
10 changes: 6 additions & 4 deletions docs/adapters.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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/
Expand Down
6 changes: 3 additions & 3 deletions docs/cli.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 `<dir>` 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 `<dir>` 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

Expand Down
24 changes: 0 additions & 24 deletions template/.scaffold/adapters/claude-code/settings.json

This file was deleted.