Skip to content
17 changes: 12 additions & 5 deletions cli/bin/commands/skills.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { get } from 'node:https';
import { createHash } from 'node:crypto';
import { tmpdir, homedir } from 'node:os';
import { unzipSync } from 'fflate';
import { getHookConsent, setHookConsent } from '../../lib/impeccable-config.mjs';
import { getHookConsent, setHookConsent, setHookEnabled } from '../../lib/impeccable-config.mjs';

const __dirname = dirname(fileURLToPath(import.meta.url));
const API_BASE = 'https://impeccable.style';
Expand Down Expand Up @@ -1658,24 +1658,31 @@ const HOOK_EXPLAINER = [
// first time, records the answer in .impeccable/config.local.json, and never
// re-asks: a recorded decision or an already-installed hook short-circuits, and
// non-interactive runs keep the historical install-by-default behavior.
// Every `true` return also affirms `hook.enabled: true` in the shared
// config.json (issue #512): the hook runtime's DEFAULT_CONFIG.enabled
// defaults to false, and this install path -- unlike `/impeccable hooks on`
// -- never otherwise writes that key. Without it, an install done here wires
// up manifests that never actually fire; idempotent to re-affirm on every run,
// including for consent recorded before this fix existed.
async function decideHookInstall(root, targets, { yes } = {}) {
if (targets.length === 0) return false;
const enable = () => { setHookEnabled(root, true); return true; };
const consent = getHookConsent(root);
if (consent === 'declined') return false;
if (consent === 'accepted') return true;
if (consent === 'accepted') return enable();
// Existing hook users (hook already wired up) are never nagged.
if (targets.length > 0 && targets.every(provider => hookInstalledForProvider(root, provider))) {
return true;
return enable();
}
// Undecided and not yet installed. Non-interactive (-y or no TTY) keeps the
// historical default-on behavior without recording a (re-promptable) decision.
if (yes || !process.stdin.isTTY) return true;
if (yes || !process.stdin.isTTY) return enable();

process.stdout.write(HOOK_EXPLAINER);
const ans = await ask('Install the design hook? (Y/n) ');
const accepted = !(ans === 'n' || ans === 'no');
setHookConsent(root, accepted ? 'accepted' : 'declined');
return accepted;
return accepted ? enable() : false;
Comment thread
cursor[bot] marked this conversation as resolved.
}

function resolveLinkSource(sourceValue, root) {
Expand Down
19 changes: 19 additions & 0 deletions cli/lib/impeccable-config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -584,6 +584,25 @@ export function setHookConsent(root, value) {
return filePath;
}

/**
* Persist `hook.enabled` to the shared config.json, preserving any sibling
* keys. Mirrors skill/scripts/hook-admin.mjs's setEnabled(cwd, true): the
* hook runtime's DEFAULT_CONFIG.enabled defaults to false (absence of
* consent must not mean "on"), so this CLI install path -- the only other
* place manifests get written -- must explicitly opt in, or an install done
* through `npx impeccable skills install` would wire up manifests that never
* actually fire.
*/
export function setHookEnabled(root, value) {
const filePath = getConfigPath(root);
const existing = safeReadJson(filePath) || {};
const hook = hookSection(existing) || {};
const next = { ...existing, hook: { ...hook, enabled: value } };
mkdirSync(dirname(filePath), { recursive: true });
writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`);
return filePath;
}

const EXCLUDE_OPEN = '# impeccable-config-ignore-start';
const EXCLUDE_CLOSE = '# impeccable-config-ignore-end';
const EXCLUDE_PATTERNS = ['.impeccable/config.local.json'];
Expand Down
172 changes: 172 additions & 0 deletions docs/plans/2026-08-10-001-fix-hooks-reset-rearms-disabled-hook-plan.md

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions docs/residual-review-findings/441bf5b0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Residual Review Findings

Source: `ce-code-review` (correctness, testing, adversarial, project-standards) on branch `fix/512-hooks-reset-rearms-disabled-hook`, head `441bf5b0`.

Applied findings are in the commit history (see `ee68b52a`, `c41bf797`, `441bf5b0`). The following were surfaced but consciously **not** applied in this PR — each is either pre-existing behavior this fix did not introduce, or disproportionate scope for a targeted bug fix.

## Residual Review Findings

- **P2 (adversarial, confidence 70, pre-existing)** — `skill/scripts/hook-admin.mjs:489` `stripImpeccableHookEntry` uses substring matching (`valueHasImpeccableHookMarker`) to decide whether a manifest hook entry belongs to Impeccable, and nulls the *entire* entry on a match. A hand-authored entry that chains Impeccable's hook with the user's own command in the same `command`/`args` string (e.g. `node "a.mjs" && node ".claude/skills/impeccable/scripts/hook.mjs"`) would have the user's portion silently deleted along with Impeccable's. This behavior predates this PR (both `repairHookManifests()`'s existing prune-on-reinstall path and the new `reset()` path share it) — not introduced here, and fixing it correctly (partial-string editing instead of whole-entry deletion) is a larger, separate change.
- **P2 (adversarial, confidence 60)** — `reset()`, `setEnabled()` (`on`/`off`) all perform multiple non-atomic file reads/writes across config, cache, and manifest files with no locking. Two concurrent `hooks` invocations on the same project (two agents, or an agent and a human) could interleave and leave config/manifest state inconsistent. This is a pre-existing architectural property of the whole `hook-admin.mjs` module (not unique to `reset()`), and introducing a lock file is out of scope for this targeted fix.
- **Advisory (adversarial residual risk)** — `fileHasImpeccableHookMarker` scans every string value under a manifest's `hooks` key for the marker substring, while `stripImpeccableHookEntry` only nulls entries whose `command`/`args`/`bash`/`powershell` fields match. A marker substring living in an unrelated field (e.g. a custom `statusMessage`) would make the prune function report `true` (rewrite the file, claim success) without actually removing anything. Low likelihood given how manifests are actually generated; noted for awareness, not actioned.
- **Advisory (adversarial residual risk)** — `pruneImpeccableHookFromManifest` silently no-ops on a manifest that fails `JSON.parse`, with no user-visible signal. If a provider's own hook loader is more lenient (tolerates trailing commas/comments) than this admin tool's strict parser, a manifest could stay live and un-prunable with no warning. Not actioned — matches this function's pre-existing error-handling posture everywhere else it's called.
8 changes: 7 additions & 1 deletion skill/scripts/context.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1237,7 +1237,13 @@ function valueHasHookMarker(value) {

function hookEnabledAt(root) {
if (truthyEnv(process.env.IMPECCABLE_HOOK_DISABLED)) return false;
let enabled = true;
// Matches hook-lib.mjs's DEFAULT_CONFIG.enabled (issue #512): absence of an
// explicit `hook.enabled` key means disabled, not enabled. Getting this
// wrong here is doubly silent -- MANUAL_DETECTOR_REQUIRED (below) only
// fires when this function reports the hook inactive, so a stale `true`
// default would both skip the real hook (per hook-lib.mjs) AND suppress
// the fallback warning that would otherwise tell the agent to scan by hand.
let enabled = false;
for (const name of ['.impeccable/config.json', '.impeccable/config.local.json']) {
const raw = readJson(path.join(root, name));
if (raw?.hook && Object.prototype.hasOwnProperty.call(raw.hook, 'enabled')) {
Expand Down
48 changes: 43 additions & 5 deletions skill/scripts/hook-admin.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,11 @@ function writeDetectorConfig(cwd, detectorConfig, opts = {}) {
function mergeHookConfig(existing) {
const base = existing && typeof existing === 'object' ? existing : {};
return {
enabled: base.enabled === false ? false : true,
// Match DEFAULT_CONFIG.enabled (issue #512): absence of an explicit value
// means disabled, not enabled. setEnabled() overwrites this immediately
// for its own call site, but a future caller relying on this default
// alone must not silently re-arm.
enabled: base.enabled === true,
limits: {
maxFindings: Number.isFinite(base?.limits?.maxFindings) ? base.limits.maxFindings : DEFAULT_CONFIG.limits.maxFindings,
maxChars: Number.isFinite(base?.limits?.maxChars) ? base.limits.maxChars : DEFAULT_CONFIG.limits.maxChars,
Expand Down Expand Up @@ -741,6 +745,7 @@ function addIgnoreValue(cwd, args) {

function reset(cwd) {
const removed = [];
const configFailures = [];
// Unified files may hold non-hook keys (e.g. updateCheck); strip only the
// hook/detector subtrees and keep the rest, deleting the file only if nothing remains.
for (const filePath of [getConfigPath(cwd), getLocalConfigPath(cwd)]) {
Expand All @@ -754,7 +759,17 @@ function reset(cwd) {
fs.writeFileSync(filePath, JSON.stringify(rest, null, 2) + '\n');
}
removed.push(path.relative(cwd, filePath) || filePath);
} catch { /* ignore */ }
} catch (err) {
// A write/unlink failure here (permissions, disk full) must not be
// silently swallowed: this file may still carry `hook.enabled: true`,
// so pruning manifests below would leave `status` reporting enabled
// while nothing actually invokes the hook -- reset()'s own version of
// the exact config/manifest mismatch issue #512 was about.
configFailures.push(`${path.relative(cwd, filePath) || filePath} (${err.message || err})`);
}
}
if (configFailures.length) {
throw new Error(`Could not reset hook config, so leaving manifests untouched to avoid reporting a stale enabled state: ${configFailures.join(', ')}`);
}
// State files are wholly ours; delete outright.
for (const filePath of [getCachePath(cwd), getPendingPath(cwd)]) {
Expand All @@ -765,9 +780,32 @@ function reset(cwd) {
}
} catch { /* ignore */ }
}
return removed.length
? `Reset design hook config and cache (removed: ${removed.join(', ')}).`
: 'No hook config or cache to remove. Already at defaults.';
// Manifests written by `hooks on` outlive config/cache removal (issue #512):
// with DEFAULT_CONFIG.enabled now false, a surviving manifest entry would
// still invoke a hook whose config no longer opts in. Prune every installed
// target regardless of whether its skill folder still exists on disk -- a
// reset mid-uninstall (skill files gone, manifest not yet cleaned) is
// exactly the case that most needs this, and pruneImpeccableHookFromManifest
// already no-ops safely on a missing or markerless file.
//
// destRel only, never sharedDestRel: `on`/repairHookManifests() only ever
// reads sharedDestRel (e.g. a team-committed .claude/settings.json) to
// decide whether it already covers the install -- it never writes there.
// Pruning sharedDestRel would make a single developer's local `reset` rip
// the hook out of a file the whole team shares, which is a materially
// larger blast radius than the local revocation this fix is about.
const prunedManifests = [];
for (const target of HOOK_MANIFEST_TARGETS) {
if (pruneImpeccableHookFromManifest(path.join(cwd, target.destRel))) {
prunedManifests.push(target.provider);
}
}

const parts = [];
if (removed.length) parts.push(`Reset design hook config and cache (removed: ${removed.join(', ')}).`);
if (prunedManifests.length) parts.push(`Removed hook entries from: ${prunedManifests.join(', ')}.`);
if (!parts.length) return 'No hook config, cache, or manifest entries to remove. Already at defaults.';
return parts.join(' ');
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}

function main() {
Expand Down
2 changes: 1 addition & 1 deletion skill/scripts/hook-lib.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ export function isAdvisoryFinding(finding) {
}

export const DEFAULT_CONFIG = Object.freeze({
enabled: true,
enabled: false,
Comment thread
cursor[bot] marked this conversation as resolved.
quiet: false,
auditLog: null,
designSystem: { enabled: true },
Expand Down
8 changes: 8 additions & 0 deletions tests/context.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1066,10 +1066,15 @@ describe('context.mjs CLI', () => {

const project = path.join(scratch, 'project');
fs.mkdirSync(path.join(project, '.codex'), { recursive: true });
fs.mkdirSync(path.join(project, '.impeccable'), { recursive: true });
fs.writeFileSync(path.join(project, 'PRODUCT.md'), '# Acme\n');
fs.writeFileSync(path.join(project, '.codex', 'hooks.json'), JSON.stringify({
hooks: { Stop: [{ hooks: [{ command: 'node .agents/skills/impeccable/scripts/hook.mjs' }] }] },
}));
// hookEnabledAt() defaults to disabled absent explicit consent (#512); a
// manifest alone -- with no config recording that consent -- is no
// longer sufficient to count the hook as active.
fs.writeFileSync(path.join(project, '.impeccable', 'config.json'), JSON.stringify({ hook: { enabled: true } }));

const res = spawnSync(process.execPath, [path.join(scripts, 'context.mjs')], {
cwd: project,
Expand Down Expand Up @@ -1097,10 +1102,13 @@ describe('context.mjs CLI', () => {

const project = path.join(scratch, 'project');
fs.mkdirSync(path.join(project, '.cursor'), { recursive: true });
fs.mkdirSync(path.join(project, '.impeccable'), { recursive: true });
fs.writeFileSync(path.join(project, 'PRODUCT.md'), '# Acme\n');
fs.writeFileSync(path.join(project, '.cursor', 'hooks.json'), JSON.stringify({
hooks: { preToolUse: [{ command: 'node .cursor/skills/impeccable/scripts/hook-before-edit.mjs' }] },
}));
// hookEnabledAt() defaults to disabled absent explicit consent (#512).
fs.writeFileSync(path.join(project, '.impeccable', 'config.json'), JSON.stringify({ hook: { enabled: true } }));

const res = spawnSync(process.execPath, [path.join(scripts, 'context.mjs')], {
cwd: project,
Expand Down
Loading