Summary
ctx init reports Hooks ✓ / Statusline ✓ but the hooks it writes never execute on current Claude Code. Two independent defects combine:
- Wrong schema —
ctx init writes the flat hook form {"type":"command","command":...} directly into the event array. Current Claude Code requires the nested matcher-group form {"hooks":[{"type":"command","command":...}]} and silently ignores flat entries. (The README §3 "Agent hooks" example shows the same flat form and is likewise affected.)
- Wrong file —
ctx init hardcodes ~/.claude/settings.json. Claude Code honors the CLAUDE_CONFIG_DIR environment variable to relocate its config directory; when it is set, the live settings file is $CLAUDE_CONFIG_DIR/settings.json and ~/.claude/settings.json is not read. ctx init ignores CLAUDE_CONFIG_DIR.
Either defect alone makes the hooks dead; together they produce a silent false-positive (Hooks ✓) while ctx brief/ctx persist never fire.
A third, minor issue: hasHookEntry() only inspects a top-level command key, so it cannot detect already-installed nested entries — re-running ctx init would keep appending duplicates.
The statusline step is affected by defect (2) as well: its JSON shape (statusLine: {type, command}) is correct, but it is written to the same hardcoded ~/.claude/settings.json, so it does not render when CLAUDE_CONFIG_DIR points elsewhere.
Environment
- ctx: v2.4.7 (CLI + daemon)
- Claude Code: v2.1.x (Linux/Unix install with
CLAUDE_CONFIG_DIR set to an XDG-style dir)
- Hook events affected:
SubagentStart, SubagentStop (the events ctx init configures)
Authoritative schema (current Claude Code)
Per the Claude Code hooks docs, every event maps to an array of matcher-group objects — including events that don't support matchers (SubagentStart/SubagentStop/UserPromptSubmit/Stop/…). The matcher key may be omitted or empty for non-tool events, but the hooks wrapper is mandatory. A bare {type,command} without the hooks wrapper is explicitly invalid.
Required:
{
"hooks": {
"SubagentStart": [
{ "hooks": [ { "type": "command", "command": "ctx brief --hook" } ] }
],
"SubagentStop": [
{ "hooks": [ { "type": "command", "command": "ctx persist --hook" } ] }
]
}
}
What ctx init writes today (does not execute):
{
"hooks": {
"SubagentStart": [ { "type": "command", "command": "ctx brief --hook" } ],
"SubagentStop": [ { "type": "command", "command": "ctx persist --hook" } ]
}
}
Reproduction
- On a Claude Code install where
CLAUDE_CONFIG_DIR points at an XDG-style config dir, run ctx init and accept the hooks + statusline prompts.
- Observe
ctx init reports Hooks ✓ and writes them to ~/.claude/settings.json.
- Spawn any subagent. The
SubagentStart/SubagentStop hooks do not run (no ctx brief/ctx persist invocation).
Evidence (deterministic A/B/A)
Pointing the SubagentStart/SubagentStop hooks at a probe script that appends to a logfile when executed, in the correct settings file ($CLAUDE_CONFIG_DIR/settings.json), changing only the schema between runs:
FLAT schema → logfile EMPTY (hooks did NOT execute)
NESTED schema → "FIRED SubagentStart", "FIRED SubagentStop" (both executed)
Same script, same file, same session — the only variable was flat vs nested. Nested firing after a mid-session edit also confirms Claude Code reloads settings live, so the flat "empty" result is genuine (not a reload artifact).
Root cause (go/internal/cli/init.go)
claudeSettingsPath() returns filepath.Join(home, ".claude", "settings.json") unconditionally (Unix branch) — no CLAUDE_CONFIG_DIR check, no awareness of the XDG ~/.config/claude-code/settings.json location used by newer installs.
stepHooks() constructs each entry as {"type":"command","command":...} and appends it directly to the event array — the flat (now-invalid) form.
hasHookEntry() only reads a top-level command; it does not recurse into a nested hooks[], so it misreports correctly-nested entries as "missing" and re-appends on every run.
Proposed fix
claudeSettingsPath() — resolve the settings file as: CLAUDE_CONFIG_DIR/settings.json if the env var is set; else prefer ~/.config/claude-code/settings.json when it already exists; else fall back to ~/.claude/settings.json. (Writing to a file Claude Code does not read is the root of the silent failure.)
stepHooks() — emit the nested matcher-group shape, e.g. a small newHookGroup(cmd) helper returning {"hooks":[{"type":"command","command":cmd}]} (matcher omitted; these events are not tool-scoped).
hasHookEntry() — recurse into a nested hooks[] (in addition to the legacy flat command) so detection is idempotent against both shapes and re-runs don't append duplicates.
- README §3 — update the "Agent hooks" example (and the
statusLine note's surrounding context) to the nested matcher-group form.
I have these changes implemented and verified (gofmt/vet clean; package tests pass; the patched ctx init writes nested entries to the CLAUDE_CONFIG_DIR location, is idempotent on re-run, and the hooks fire under the A/B/A probe above). Happy to open a PR.
Impact
- Silent:
ctx init claims success while subagent briefing/persistence never run, so users believe the integration is active when it is not.
- The flat form was likely valid against an earlier Claude Code hook schema; this is a platform-schema evolution that
ctx init (and the README example) have not yet tracked.
Separate minor observation (not part of the fix above)
During ctx init, the version step prints Version … (parse error) [skip] for both a release build and a dev build — the latest-release comparison in stepVersion() fails to parse. Cosmetic (the step is non-fatal and skips cleanly), filed here only for awareness.
Summary
ctx initreportsHooks ✓/Statusline ✓but the hooks it writes never execute on current Claude Code. Two independent defects combine:ctx initwrites the flat hook form{"type":"command","command":...}directly into the event array. Current Claude Code requires the nested matcher-group form{"hooks":[{"type":"command","command":...}]}and silently ignores flat entries. (The README §3 "Agent hooks" example shows the same flat form and is likewise affected.)ctx inithardcodes~/.claude/settings.json. Claude Code honors theCLAUDE_CONFIG_DIRenvironment variable to relocate its config directory; when it is set, the live settings file is$CLAUDE_CONFIG_DIR/settings.jsonand~/.claude/settings.jsonis not read.ctx initignoresCLAUDE_CONFIG_DIR.Either defect alone makes the hooks dead; together they produce a silent false-positive (
Hooks ✓) whilectx brief/ctx persistnever fire.A third, minor issue:
hasHookEntry()only inspects a top-levelcommandkey, so it cannot detect already-installed nested entries — re-runningctx initwould keep appending duplicates.The statusline step is affected by defect (2) as well: its JSON shape (
statusLine: {type, command}) is correct, but it is written to the same hardcoded~/.claude/settings.json, so it does not render whenCLAUDE_CONFIG_DIRpoints elsewhere.Environment
CLAUDE_CONFIG_DIRset to an XDG-style dir)SubagentStart,SubagentStop(the eventsctx initconfigures)Authoritative schema (current Claude Code)
Per the Claude Code hooks docs, every event maps to an array of matcher-group objects — including events that don't support matchers (
SubagentStart/SubagentStop/UserPromptSubmit/Stop/…). Thematcherkey may be omitted or empty for non-tool events, but thehookswrapper is mandatory. A bare{type,command}without thehookswrapper is explicitly invalid.Required:
{ "hooks": { "SubagentStart": [ { "hooks": [ { "type": "command", "command": "ctx brief --hook" } ] } ], "SubagentStop": [ { "hooks": [ { "type": "command", "command": "ctx persist --hook" } ] } ] } }What
ctx initwrites today (does not execute):{ "hooks": { "SubagentStart": [ { "type": "command", "command": "ctx brief --hook" } ], "SubagentStop": [ { "type": "command", "command": "ctx persist --hook" } ] } }Reproduction
CLAUDE_CONFIG_DIRpoints at an XDG-style config dir, runctx initand accept the hooks + statusline prompts.ctx initreportsHooks ✓and writes them to~/.claude/settings.json.SubagentStart/SubagentStophooks do not run (noctx brief/ctx persistinvocation).Evidence (deterministic A/B/A)
Pointing the
SubagentStart/SubagentStophooks at a probe script that appends to a logfile when executed, in the correct settings file ($CLAUDE_CONFIG_DIR/settings.json), changing only the schema between runs:Same script, same file, same session — the only variable was flat vs nested. Nested firing after a mid-session edit also confirms Claude Code reloads settings live, so the flat "empty" result is genuine (not a reload artifact).
Root cause (
go/internal/cli/init.go)claudeSettingsPath()returnsfilepath.Join(home, ".claude", "settings.json")unconditionally (Unix branch) — noCLAUDE_CONFIG_DIRcheck, no awareness of the XDG~/.config/claude-code/settings.jsonlocation used by newer installs.stepHooks()constructs each entry as{"type":"command","command":...}and appends it directly to the event array — the flat (now-invalid) form.hasHookEntry()only reads a top-levelcommand; it does not recurse into a nestedhooks[], so it misreports correctly-nested entries as "missing" and re-appends on every run.Proposed fix
claudeSettingsPath()— resolve the settings file as:CLAUDE_CONFIG_DIR/settings.jsonif the env var is set; else prefer~/.config/claude-code/settings.jsonwhen it already exists; else fall back to~/.claude/settings.json. (Writing to a file Claude Code does not read is the root of the silent failure.)stepHooks()— emit the nested matcher-group shape, e.g. a smallnewHookGroup(cmd)helper returning{"hooks":[{"type":"command","command":cmd}]}(matcher omitted; these events are not tool-scoped).hasHookEntry()— recurse into a nestedhooks[](in addition to the legacy flatcommand) so detection is idempotent against both shapes and re-runs don't append duplicates.statusLinenote's surrounding context) to the nested matcher-group form.I have these changes implemented and verified (gofmt/vet clean; package tests pass; the patched
ctx initwrites nested entries to theCLAUDE_CONFIG_DIRlocation, is idempotent on re-run, and the hooks fire under the A/B/A probe above). Happy to open a PR.Impact
ctx initclaims success while subagent briefing/persistence never run, so users believe the integration is active when it is not.ctx init(and the README example) have not yet tracked.Separate minor observation (not part of the fix above)
During
ctx init, the version step printsVersion … (parse error) [skip]for both a release build and adevbuild — the latest-release comparison instepVersion()fails to parse. Cosmetic (the step is non-fatal and skips cleanly), filed here only for awareness.