JuliusBrussee/caveman · error

Caveman hook changed after enable; refusing destructive…

Error message

${agent} ${event} Caveman hook changed after enable; refusing destructive disable

What it means

During a destructive disable of a native integration, the CLI walks the agent's hooks for each event and verifies every Caveman-managed hook entry still matches what enable recorded (`allowedManaged`) or is the agent's native hook (`native-hook:<agent>`). If a managed hook command was changed after enable, the CLI cannot safely decide what to remove, so it refuses the destructive disable rather than deleting a user-modified hook.

Solutions

  1. Restore the hook entry to the exact form recorded at enable time (check the journal's `allowedManaged` entries), then retry disable.
  2. Re-run `caveman doctor <agent> --fix` to reconcile the hook entries, then disable.
  3. Manually remove the changed hook entry from the agent's settings file and re-run disable.
  4. If the modification is intentional, delete the corresponding journal/operation so disable no longer treats it as owned.

Example fix

// before (settings.json, changed command)
{ "hooks": { "PostToolUse": [{ "command": "caveman hook --mode=fast" }] } }

// after (restore original recorded command)
{ "hooks": { "PostToolUse": [{ "command": "caveman hook" }] } }
Defensive patterns

Strategy: validation

Validate before calling

import { readFileSync } from "node:fs";
const settings = JSON.parse(readFileSync("~/.claude/settings.json", "utf8"));
const journalAllowed = new Set(journal.allowedManaged);
for (const [event, list] of Object.entries(settings.hooks ?? {})) {
  for (const entry of list) {
    const cmd = entry.command ?? "";
    if (cmd.includes("caveman") && !journalAllowed.has(cmd)) {
      console.warn(`hook ${event} drifts from journal: ${cmd}`);
    }
  }
}

Prevention

When it happens

Trigger: Disable runs `restoreNativeOperation` hook cleanup when an entry's `managedHookIdentity(hookEntryCommand(entry))` is non-null, not `native-hook:${agent}`, and its `canonicalManagedHookEntry` is not in the `allowedManaged` set captured at enable time.

Common situations: The user hand-edited a caveman hook command in Claude/Codex/Gemini settings; a caveman upgrade changed the hook command so it no longer matches the enabled journal; a wrapper script wraps the hook differently.

Understand the failure class

Background: "Invalid state transition" errors: "status must be X, actually Y", "already rejected/charging/uninstalled", "cannot ... while running" — what they mean when a library rejects your call — this error's family across 31 libraries.

Related errors


AI-assisted analysis of JuliusBrussee/caveman@3ee70a1026 (2026-09-20). Data as JSON: /api/errors/3f1a2c14a23a6f2c. Report an issue: GitHub.

Appendix: source

Thrown at packages/cli/src/index.ts:8230

  const expected = nativeHooksDocument(agent, true).hooks as Record<string, unknown>;
  const allowedManaged = new Set(
    Object.values(expected)
      .flatMap((raw) => Array.isArray(raw) ? raw as Array<Record<string, unknown>> : [])
      .map(canonicalManagedHookEntry)
      .filter(Boolean),
  );
  for (const event of Object.keys(hooks)) {
    const expectedRaw = expected[event];
    const list = Array.isArray(hooks[event]) ? hooks[event] as Array<Record<string, unknown>> : [];
    const expectedEntries = Array.isArray(expectedRaw) ? expectedRaw as Array<Record<string, unknown>> : [];
    const expectedStrings = new Set(expectedEntries.map(canonicalManagedHookEntry).filter(Boolean));
    for (const entry of list) {
      const command = hookEntryCommand(entry) ?? "";
      const identity = managedHookIdentity(command);
      const canonical = canonicalManagedHookEntry(entry);
      const ownedNative = identity === `native-hook:${agent}`;
      if (identity && !ownedNative && !allowedManaged.has(canonical)) {
        throw new Error(`${agent} ${event} Caveman hook changed after enable; refusing destructive disable`);
      }
    }
    const kept = list.filter((entry) => {
      const identity = managedHookIdentity(hookEntryCommand(entry) ?? "");
      if (!identity) return true;
      if (identity === `native-hook:${agent}`) return false;
      return !expectedStrings.has(canonicalManagedHookEntry(entry));
    });
    if (kept.length > 0) hooks[event] = kept;
    else delete hooks[event];
  }
  if (Object.keys(hooks).length === 0) delete root.hooks;
  return root;
}

function jsonBytes(root: Record<string, unknown>): Buffer {
  return Buffer.from(JSON.stringify(root, null, 2) + "\n");
}

View on GitHub (pinned to 3ee70a1026)