Skip to content

Consumer repos double-run every hook: setup template and hooks.json both register lint/test_affected/guard-git-add #224

Description

@robercano-ghbot

Symptom

A freshly-onboarded consumer repo runs PostToolUse(lint), Stop(test_affected) and PreToolUse(guard-git-add) twice per turn.

Observed in reDeploy as ● Ran 2 stop hooks, with one of the two failing as:

Stop hook error: Failed with non-blocking status code: No stderr output

Two concurrent pnpm -r build into the same dist/ — one lost the race and exited non-zero having written only to stdout, hence the empty-stderr report.

Cause

Both of these register the same three hooks:

  • .claude/skills/setup/templates/settings.json — scaffolded into the consumer as .claude/settings.json
  • .claude/hooks/hooks.json — shipped by the plugin and loaded whenever it's enabled
// templates/settings.json
"Stop": [{ "hooks": [{ "command": "bash \"${CLAUDE_PLUGIN_ROOT:-$CLAUDE_PROJECT_DIR/.claude}/scripts/gate.sh\" test_affected" }] }]

// hooks/hooks.json
"Stop": [{ "hooks": [{ "command": "bash ${CLAUDE_PLUGIN_ROOT}/scripts/gate.sh test_affected" }] }]

Since the plugin must stay enabled for everyday sessions (#134), both fire.

log-worker-tool.sh is the one entry that is genuinely template-only, so the template is not wholly redundant.

Why the existing check doesn't catch it

do_hooks_parity (#140) compares this repo's own .claude/settings.json against .claude/hooks/hooks.json, and its allowlist comment frames the distinction as "self-hosted repo" vs "shipped to consumers". It never looks at skills/setup/templates/settings.json, which is the file consumers actually receive. So parity passes while consumers double-run.

The design question (why this isn't just a delete)

Removing the three duplicated hooks from the template is only correct if the plugin is guaranteed enabled. The template's ${CLAUDE_PLUGIN_ROOT:-$CLAUDE_PROJECT_DIR/.claude} fallback reads like a deliberate plugin-disabled escape hatch — if that's the intent, the fix is different (e.g. keep the template hooks and drop hooks.json, or make one conditional). That call is yours, which is why this is an issue rather than a PR.

Worth noting the same tension appears in the other direction: the parity allowlist says log-worker-tool.sh "must never be added to hooks.json" because consumers have no cockpit to feed — yet the consumer-facing template does include it.

Impact

Every gate runs twice per turn in every onboarded repo: double CPU, double wall-clock, and a real race between two builds writing the same output tree. It also doubles the cost of claude remote-control planner sessions, which load the same project hooks via WorkingDirectory=<repo>.

Repro

reDeploy at plugin 0.3.0. .claude/settings.json (scaffolded) + plugin hooks.json both present → Ran 2 stop hooks.


Filed from reDeploy per #223. Labelled backlog — not self-assigning planned, per CONTRIBUTING.md.

Metadata

Metadata

Assignees

No one assigned

    Labels

    backlogFiled, not yet approved by the owner - the loop must NOT pick it upfrom:redeployOrigin: reDeploy rolloutmodule:harnessOrchestrator machinery under .claude

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions