Skip to content
Merged
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,14 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- **Disclose the global settings merge before it happens (ME-22).** `forge init` now prints the
global `~/.claude/settings.json` merge disclosure before the merge mutates the file, not after.

- **Hooks install in exec form (ME-23).** Hooks and the statusline now use Claude Code's exec
form (`"command": "bash", "args": [...]`) instead of a shell string, so a guard path containing
spaces works with no quoting — removing the shell-quoting hazard the earlier RA-12 fix worked
around. Upgrades are seamless: an existing install written in the old shell-string form (or the
`${CLAUDE_PLUGIN_ROOT}` plugin spelling) is deduped by guard identity and healed in place to
exec form on the next merge, and ownership tracking / uninstall are form-agnostic — so a
re-install never duplicates a hook and uninstall still reverses exactly Forge's own additions.

- **Honest positioning (ME-24).** README no longer claims "one brain for every AI coding
agent", "enforced guardrails", or that every task passes a deterministic gate: automatic
hooks are Claude Code-specific (advisory by default, enforcement opt-in via
Expand Down
51 changes: 34 additions & 17 deletions global/settings.template.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
"cleanupPeriodDays": 30,
"statusLine": {
"type": "command",
"command": "bash ~/.forge/statusline.sh"
"command": "bash",
"args": ["~/.forge/statusline.sh"]
},
"permissions": {
"defaultMode": "default",
Expand Down Expand Up @@ -76,11 +77,13 @@
"hooks": [
{
"type": "command",
"command": "bash ~/.forge/guards/cortex.sh prompt"
"command": "bash",
"args": ["~/.forge/guards/cortex.sh", "prompt"]
},
{
"type": "command",
"command": "bash ~/.forge/guards/cortex.sh preflight"
"command": "bash",
"args": ["~/.forge/guards/cortex.sh", "preflight"]
}
]
}
Expand All @@ -91,11 +94,13 @@
"hooks": [
{
"type": "command",
"command": "bash ~/.forge/guards/recall-load.sh"
"command": "bash",
"args": ["~/.forge/guards/recall-load.sh"]
},
{
"type": "command",
"command": "bash ~/.forge/guards/cortex.sh session-start"
"command": "bash",
"args": ["~/.forge/guards/cortex.sh", "session-start"]
}
]
}
Expand All @@ -106,15 +111,18 @@
"hooks": [
{
"type": "command",
"command": "bash ~/.forge/guards/protect-paths.sh"
"command": "bash",
"args": ["~/.forge/guards/protect-paths.sh"]
},
{
"type": "command",
"command": "bash ~/.forge/guards/cost-budget.sh"
"command": "bash",
"args": ["~/.forge/guards/cost-budget.sh"]
},
{
"type": "command",
"command": "bash ~/.forge/guards/doom-loop.sh"
"command": "bash",
"args": ["~/.forge/guards/doom-loop.sh"]
}
]
},
Expand All @@ -123,7 +131,8 @@
"hooks": [
{
"type": "command",
"command": "bash ~/.forge/guards/cortex.sh pre-edit"
"command": "bash",
"args": ["~/.forge/guards/cortex.sh", "pre-edit"]
}
]
}
Expand All @@ -134,11 +143,13 @@
"hooks": [
{
"type": "command",
"command": "bash ~/.forge/guards/format-on-edit.sh"
"command": "bash",
"args": ["~/.forge/guards/format-on-edit.sh"]
},
{
"type": "command",
"command": "bash ~/.forge/guards/cortex.sh capture"
"command": "bash",
"args": ["~/.forge/guards/cortex.sh", "capture"]
}
]
},
Expand All @@ -147,11 +158,13 @@
"hooks": [
{
"type": "command",
"command": "bash ~/.forge/guards/secret-redact.sh"
"command": "bash",
"args": ["~/.forge/guards/secret-redact.sh"]
},
{
"type": "command",
"command": "bash ~/.forge/guards/cortex.sh capture"
"command": "bash",
"args": ["~/.forge/guards/cortex.sh", "capture"]
}
]
}
Expand All @@ -161,19 +174,23 @@
"hooks": [
{
"type": "command",
"command": "bash ~/.forge/guards/completion-gate.sh"
"command": "bash",
"args": ["~/.forge/guards/completion-gate.sh"]
},
{
"type": "command",
"command": "bash ~/.forge/guards/lean-guard.sh"
"command": "bash",
"args": ["~/.forge/guards/lean-guard.sh"]
},
{
"type": "command",
"command": "bash ~/.forge/guards/session-learner.sh"
"command": "bash",
"args": ["~/.forge/guards/session-learner.sh"]
},
{
"type": "command",
"command": "bash ~/.forge/guards/cortex.sh stop"
"command": "bash",
"args": ["~/.forge/guards/cortex.sh", "stop"]
}
]
}
Expand Down
57 changes: 41 additions & 16 deletions hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,16 @@
"hooks": [
{
"type": "command",
"command": "\"${CLAUDE_PLUGIN_ROOT}\"/global/guards/recall-load.sh"
"command": "bash",
"args": ["${CLAUDE_PLUGIN_ROOT}/global/guards/recall-load.sh"]
},
{
"type": "command",
"command": "\"${CLAUDE_PLUGIN_ROOT}\"/global/guards/cortex.sh session-start"
"command": "bash",
"args": [
"${CLAUDE_PLUGIN_ROOT}/global/guards/cortex.sh",
"session-start"
]
}
]
}
Expand All @@ -20,11 +25,16 @@
"hooks": [
{
"type": "command",
"command": "\"${CLAUDE_PLUGIN_ROOT}\"/global/guards/cortex.sh prompt"
"command": "bash",
"args": ["${CLAUDE_PLUGIN_ROOT}/global/guards/cortex.sh", "prompt"]
},
{
"type": "command",
"command": "\"${CLAUDE_PLUGIN_ROOT}\"/global/guards/cortex.sh preflight"
"command": "bash",
"args": [
"${CLAUDE_PLUGIN_ROOT}/global/guards/cortex.sh",
"preflight"
]
}
]
}
Expand All @@ -35,15 +45,18 @@
"hooks": [
{
"type": "command",
"command": "\"${CLAUDE_PLUGIN_ROOT}\"/global/guards/protect-paths.sh"
"command": "bash",
"args": ["${CLAUDE_PLUGIN_ROOT}/global/guards/protect-paths.sh"]
},
{
"type": "command",
"command": "\"${CLAUDE_PLUGIN_ROOT}\"/global/guards/cost-budget.sh"
"command": "bash",
"args": ["${CLAUDE_PLUGIN_ROOT}/global/guards/cost-budget.sh"]
},
{
"type": "command",
"command": "\"${CLAUDE_PLUGIN_ROOT}\"/global/guards/doom-loop.sh"
"command": "bash",
"args": ["${CLAUDE_PLUGIN_ROOT}/global/guards/doom-loop.sh"]
}
]
},
Expand All @@ -52,7 +65,11 @@
"hooks": [
{
"type": "command",
"command": "\"${CLAUDE_PLUGIN_ROOT}\"/global/guards/cortex.sh pre-edit"
"command": "bash",
"args": [
"${CLAUDE_PLUGIN_ROOT}/global/guards/cortex.sh",
"pre-edit"
]
}
]
}
Expand All @@ -63,11 +80,13 @@
"hooks": [
{
"type": "command",
"command": "\"${CLAUDE_PLUGIN_ROOT}\"/global/guards/format-on-edit.sh"
"command": "bash",
"args": ["${CLAUDE_PLUGIN_ROOT}/global/guards/format-on-edit.sh"]
},
{
"type": "command",
"command": "\"${CLAUDE_PLUGIN_ROOT}\"/global/guards/cortex.sh capture"
"command": "bash",
"args": ["${CLAUDE_PLUGIN_ROOT}/global/guards/cortex.sh", "capture"]
}
]
},
Expand All @@ -76,11 +95,13 @@
"hooks": [
{
"type": "command",
"command": "\"${CLAUDE_PLUGIN_ROOT}\"/global/guards/secret-redact.sh"
"command": "bash",
"args": ["${CLAUDE_PLUGIN_ROOT}/global/guards/secret-redact.sh"]
},
{
"type": "command",
"command": "\"${CLAUDE_PLUGIN_ROOT}\"/global/guards/cortex.sh capture"
"command": "bash",
"args": ["${CLAUDE_PLUGIN_ROOT}/global/guards/cortex.sh", "capture"]
}
]
}
Expand All @@ -90,19 +111,23 @@
"hooks": [
{
"type": "command",
"command": "\"${CLAUDE_PLUGIN_ROOT}\"/global/guards/completion-gate.sh"
"command": "bash",
"args": ["${CLAUDE_PLUGIN_ROOT}/global/guards/completion-gate.sh"]
},
{
"type": "command",
"command": "\"${CLAUDE_PLUGIN_ROOT}\"/global/guards/lean-guard.sh"
"command": "bash",
"args": ["${CLAUDE_PLUGIN_ROOT}/global/guards/lean-guard.sh"]
},
{
"type": "command",
"command": "\"${CLAUDE_PLUGIN_ROOT}\"/global/guards/session-learner.sh"
"command": "bash",
"args": ["${CLAUDE_PLUGIN_ROOT}/global/guards/session-learner.sh"]
},
{
"type": "command",
"command": "\"${CLAUDE_PLUGIN_ROOT}\"/global/guards/cortex.sh stop"
"command": "bash",
"args": ["${CLAUDE_PLUGIN_ROOT}/global/guards/cortex.sh", "stop"]
}
]
}
Expand Down
48 changes: 31 additions & 17 deletions src/doctor.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,18 +46,20 @@ const readJsonSafe = (p) => {
};

// The REQUIRED Forge hook guard identities, derived from the settings template — the SAME
// keys `mergeSettings` installs. `guardKey` normalizes every command (quoting + the legacy
// `~/.forge/` prefix) down to `basename.sh args`, so the template's `~/.forge/guards/cortex.sh
// prompt` and an INSTALLED, path-resolved `bash '/x/global/guards/cortex.sh' prompt` both
// reduce to `cortex.sh prompt` — the raw template compares cleanly against a resolved install
// without re-running resolveManagedPaths. A failed template load returns [] (fail-open).
// keys `mergeSettings` installs. `guardKey` reduces every hook — exec form (`command`+`args`,
// ME-23) or legacy shell string — down to `basename.sh args`, so the template's exec-form
// `{command:"bash", args:["~/.forge/guards/cortex.sh","prompt"]}` and an INSTALLED, path-resolved
// hook (exec form OR a pre-ME-23 `bash '/x/global/guards/cortex.sh' prompt`) both reduce to
// `cortex.sh prompt` — the raw template compares cleanly against a resolved install without
// re-running resolveManagedPaths. A failed template load returns [] (fail-open).
function templateGuardKeys() {
try {
const tpl = readJson(join(BRAND.root, "global", "settings.template.json"));
const keys = new Set();
for (const entries of Object.values(tpl.hooks || {})) {
for (const entry of Array.isArray(entries) ? entries : []) {
for (const h of entry?.hooks || []) if (h?.command) keys.add(guardKey(h.command));
for (const h of entry?.hooks || [])
if (h?.command || Array.isArray(h?.args)) keys.add(guardKey(h));
}
}
return [...keys];
Expand All @@ -73,7 +75,7 @@ function installedGuardKeys(hooks) {
for (const entries of Object.values(hooks)) {
for (const entry of Array.isArray(entries) ? entries : []) {
for (const h of entry?.hooks || [])
if (typeof h?.command === "string") keys.add(guardKey(h.command));
if (typeof h?.command === "string" || Array.isArray(h?.args)) keys.add(guardKey(h));
}
}
}
Expand Down Expand Up @@ -261,13 +263,26 @@ function checkLayers(out) {
}
}

function commandScriptFromPluginRoot(command) {
const marker = '"$' + '{CLAUDE_PLUGIN_ROOT}"/';
const i = command.indexOf(marker);
if (i === -1) return null;
const rest = command.slice(i + marker.length);
const script = rest.split(/\s+/)[0]?.replace(/^['"]|['"]$/g, "");
return script || null;
// The guard script a plugin hook references, relative to CLAUDE_PLUGIN_ROOT. Reads BOTH forms:
// an exec-form hook (path in an `args[]` element, ME-23) and a legacy shell-string `command`.
// Quotes are stripped first so `"${CLAUDE_PLUGIN_ROOT}"/…` and the unquoted `${CLAUDE_PLUGIN_ROOT}/…`
// both resolve.
function commandScriptFromPluginRoot(hook) {
// Concatenated so biome's noTemplateCurlyInString doesn't flag this literal search string.
const marker = "$" + "{CLAUDE_PLUGIN_ROOT}/";
const candidates = [];
if (hook && Array.isArray(hook.args)) for (const a of hook.args) candidates.push(String(a));
const cmd = typeof hook === "string" ? hook : hook?.command;
if (typeof cmd === "string") candidates.push(cmd);
for (const raw of candidates) {
const c = raw.replace(/["']/g, "");
const i = c.indexOf(marker);
if (i === -1) continue;
const rest = c.slice(i + marker.length);
const script = rest.split(/\s+/)[0];
if (script) return script;
}
return null;
}

// Plugin/hook compatibility: Forge should be additive and self-contained. Claude Code
Expand All @@ -286,12 +301,11 @@ function checkPluginCompatibility(out) {
const commands = Object.values(hooks)
.flatMap((entries) => (Array.isArray(entries) ? entries : []))
.flatMap((entry) => (Array.isArray(entry.hooks) ? entry.hooks : []))
.map((h) => h.command)
.filter(Boolean);
const missing = [];
const notExec = [];
for (const command of commands) {
const rel = commandScriptFromPluginRoot(command);
for (const hook of commands) {
const rel = commandScriptFromPluginRoot(hook);
if (!rel) continue;
const abs = join(BRAND.root, rel);
if (!existsSync(abs)) {
Expand Down
Loading
Loading