@indigoai-us/hq-cli 5.117.2 → 5.117.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/dist/lib/doctor/__testing__/fake-hq-tree.d.ts +1 -1
- package/dist/lib/doctor/__testing__/fake-hq-tree.js +1 -1
- package/dist/lib/doctor/checks/claude-wiring.js +61 -1
- package/dist/lib/doctor/compat.js +1 -0
- package/dist/lib/doctor/fix/apply.js +21 -22
- package/dist/lib/doctor/fix/remediation.d.ts +7 -4
- package/dist/lib/doctor/fix/remediation.js +15 -7
- package/dist/lib/doctor/registry.js +43 -0
- package/dist/lib/doctor/stray-gate-entries.d.ts +35 -0
- package/dist/lib/doctor/stray-gate-entries.js +83 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.117.3] — 2026-09-16
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- `hq doctor --fix` no longer breaks Claude Code sessions. Since HQ moved to a single master hook, the doctor saw every hook script as unregistered. It then added one hook entry per script with no tool filter, so guards such as the Glob path check and core protection ran on every tool call and blocked Bash, Skill and Read with "Glob needs a path" or "Edit to locked path is not allowed". An HQ update could then move those entries into `.claude/settings.local.json`. The doctor no longer auto-registers orphan scripts. It now detects the stray entries in both settings files and `hq doctor --fix` removes them, keeping your permissions and any hooks you added yourself. Scripts listed in `.claude/hooks/hook-registry.json` also no longer show as orphans.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- `hq doctor` now keeps its hook-health compatibility verdict in step with
|
|
14
|
+
`check-hq-hooks.sh` when either required command hook is missing.
|
|
15
|
+
|
|
5
16
|
## [5.117.2] — 2026-09-16
|
|
6
17
|
|
|
7
18
|
### Fixed
|
|
@@ -26,6 +37,12 @@
|
|
|
26
37
|
Workforce now. The free plan is called Starter rather than "free plan", to
|
|
27
38
|
match the pricing page.
|
|
28
39
|
|
|
40
|
+
### Fixed
|
|
41
|
+
|
|
42
|
+
- `hq doctor` now reports failed hook wiring when `.claude/settings.json` has
|
|
43
|
+
no usable command hook for `SessionStart` or `PreToolUse`. The file can still
|
|
44
|
+
be present and valid JSON, but it no longer reads as healthy when nothing runs.
|
|
45
|
+
|
|
29
46
|
## [5.117.1] — 2026-09-16
|
|
30
47
|
|
|
31
48
|
### Fixed
|
|
@@ -74,7 +74,7 @@ export interface FakeHookSpec {
|
|
|
74
74
|
mode?: number;
|
|
75
75
|
/** Whether the hook is registered in `.claude/settings.json`. Default: true. */
|
|
76
76
|
registered?: boolean;
|
|
77
|
-
/** Events the hook registers against. Default:
|
|
77
|
+
/** Events the hook registers against. Default: SessionStart and PreToolUse. */
|
|
78
78
|
events?: HookEventName[];
|
|
79
79
|
/** Optional settings matcher (e.g. "Bash", "Glob"). */
|
|
80
80
|
matcher?: string;
|
|
@@ -166,7 +166,7 @@ function writeHook(claudeHooksDir, codexHooksDir, hook, defaultMirror) {
|
|
|
166
166
|
const claudeBody = hook.body ?? DEFAULT_HOOK_BODY;
|
|
167
167
|
const present = hook.present !== false;
|
|
168
168
|
const registered = hook.registered !== false;
|
|
169
|
-
const events = hook.events ?? ["PreToolUse"];
|
|
169
|
+
const events = hook.events ?? ["SessionStart", "PreToolUse"];
|
|
170
170
|
const profiles = hook.profiles ?? [...GATE_PROFILES];
|
|
171
171
|
const scriptPath = path.join(claudeHooksDir, `${hook.id}.sh`);
|
|
172
172
|
let mode = null;
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
import * as fs from "node:fs";
|
|
25
25
|
import * as path from "node:path";
|
|
26
26
|
import { GATE_PROFILES, gateMembership, parseHookGateProfiles, } from "../hook-gate-profiles.js";
|
|
27
|
+
import { countStrayGateEntries } from "../stray-gate-entries.js";
|
|
27
28
|
/** Common id prefix for every result this check family emits. */
|
|
28
29
|
export const CLAUDE_WIRING_PREFIX = "hooks.claude";
|
|
29
30
|
/**
|
|
@@ -57,6 +58,35 @@ export function checkClaudeWiring(context) {
|
|
|
57
58
|
else if (local.present) {
|
|
58
59
|
registrations.push(...enumerateRegistrations(local.value, ".claude/settings.local.json"));
|
|
59
60
|
}
|
|
61
|
+
// --- Stray matcher-less gate entries (written by old `hq doctor --fix`) -------
|
|
62
|
+
// Only on master-hook trees: there, per-hook gate entries are never shipped,
|
|
63
|
+
// so a matcher-less one can only be the doctor-generated stray. A legacy tree
|
|
64
|
+
// that still registers hooks one by one is left alone.
|
|
65
|
+
const usesMasterHook = registrations.some((reg) => /\/master-hook\.sh\b/.test(reg.command));
|
|
66
|
+
for (const [file, parsed] of usesMasterHook ? [
|
|
67
|
+
["settings.json", base],
|
|
68
|
+
["settings.local.json", local],
|
|
69
|
+
] : []) {
|
|
70
|
+
if (!parsed.present || parsed.invalid)
|
|
71
|
+
continue;
|
|
72
|
+
const abs = path.join(claudeDir, file);
|
|
73
|
+
const stray = countStrayGateEntries(parsed.value);
|
|
74
|
+
results.push(stray > 0
|
|
75
|
+
? {
|
|
76
|
+
status: "FAIL",
|
|
77
|
+
checkId: `${CLAUDE_WIRING_PREFIX}.stray-gate-registration`,
|
|
78
|
+
target: abs,
|
|
79
|
+
message: `.claude/${file} has ${stray} hook-gate registration${stray === 1 ? "" : "s"} with no matcher. ` +
|
|
80
|
+
"They run on every tool call and can block Bash, Skill and Read. An older `hq doctor --fix` wrote them.",
|
|
81
|
+
remediation: `Remove the matcher-less hook-gate.sh entries from .claude/${file} (hq doctor --fix does this).`,
|
|
82
|
+
}
|
|
83
|
+
: {
|
|
84
|
+
status: "PASS",
|
|
85
|
+
checkId: `${CLAUDE_WIRING_PREFIX}.stray-gate-registration`,
|
|
86
|
+
target: abs,
|
|
87
|
+
message: `.claude/${file} has no stray matcher-less hook-gate registrations.`,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
60
90
|
const scans = registrations.map((reg) => ({
|
|
61
91
|
reg,
|
|
62
92
|
scan: scanHookCommand(reg.command),
|
|
@@ -116,6 +146,11 @@ export function checkClaudeWiring(context) {
|
|
|
116
146
|
for (const rp of scan.requiredRelpaths)
|
|
117
147
|
referencedBasenames.add(baseName(rp));
|
|
118
148
|
}
|
|
149
|
+
// master-hook.sh dispatches the gated hooks listed in hook-registry.json, so
|
|
150
|
+
// those scripts are registered even though no settings command names them.
|
|
151
|
+
for (const script of hookRegistryScripts(path.join(hooksDir, "hook-registry.json"))) {
|
|
152
|
+
referencedBasenames.add(baseName(script));
|
|
153
|
+
}
|
|
119
154
|
for (const file of listShellScripts(hooksDir)) {
|
|
120
155
|
if (referencedBasenames.has(file))
|
|
121
156
|
continue;
|
|
@@ -124,7 +159,7 @@ export function checkClaudeWiring(context) {
|
|
|
124
159
|
checkId: `${CLAUDE_WIRING_PREFIX}.orphan`,
|
|
125
160
|
target: path.join(hooksDir, file),
|
|
126
161
|
message: `Hook script ${file} is present in .claude/hooks/ but is not registered in any settings file.`,
|
|
127
|
-
remediation: `
|
|
162
|
+
remediation: `Add ${file} to .claude/hooks/hook-registry.json with the right event and matcher, or delete it.`,
|
|
128
163
|
});
|
|
129
164
|
}
|
|
130
165
|
// --- AC4: every gated hook id is in ALL THREE profiles -----------------------
|
|
@@ -234,6 +269,31 @@ export function checkClaudeWiring(context) {
|
|
|
234
269
|
* event in a parsed settings object. Tolerant of malformed shapes — any
|
|
235
270
|
* non-conforming branch contributes nothing rather than throwing.
|
|
236
271
|
*/
|
|
272
|
+
/** Every `script` path listed in .claude/hooks/hook-registry.json (empty when absent). */
|
|
273
|
+
function hookRegistryScripts(registryPath) {
|
|
274
|
+
const parsed = readJsonFile(registryPath);
|
|
275
|
+
if (!parsed.present || parsed.invalid)
|
|
276
|
+
return [];
|
|
277
|
+
const hooks = parsed.value?.hooks;
|
|
278
|
+
if (!hooks || typeof hooks !== "object")
|
|
279
|
+
return [];
|
|
280
|
+
const out = [];
|
|
281
|
+
for (const entries of Object.values(hooks)) {
|
|
282
|
+
if (!Array.isArray(entries))
|
|
283
|
+
continue;
|
|
284
|
+
for (const entry of entries) {
|
|
285
|
+
const inner = entry?.hooks;
|
|
286
|
+
if (!Array.isArray(inner))
|
|
287
|
+
continue;
|
|
288
|
+
for (const item of inner) {
|
|
289
|
+
const script = item?.script;
|
|
290
|
+
if (typeof script === "string" && script)
|
|
291
|
+
out.push(script);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return out;
|
|
296
|
+
}
|
|
237
297
|
function enumerateRegistrations(settings, source) {
|
|
238
298
|
const out = [];
|
|
239
299
|
if (!settings || typeof settings !== "object")
|
|
@@ -47,6 +47,7 @@ export const RUNTIME_ENFORCEMENT_CHECK_ID = "hooks.runtime.enforcement";
|
|
|
47
47
|
*/
|
|
48
48
|
export const CHECK_HQ_HOOKS_SETTINGS_SCOPE = [
|
|
49
49
|
"hooks.settings-present",
|
|
50
|
+
"hooks.settings-required-command-hooks",
|
|
50
51
|
"hooks.settings-valid-json",
|
|
51
52
|
"hooks.claude.settings-local-valid-json",
|
|
52
53
|
"hooks.claude.unquoted-project-dir",
|
|
@@ -35,6 +35,7 @@ import { createDefaultRegistry } from "../registry.js";
|
|
|
35
35
|
import { flattenFamilies } from "../report.js";
|
|
36
36
|
import { gateMembership, parseHookGateProfiles, } from "../hook-gate-profiles.js";
|
|
37
37
|
import { createBackup } from "./backup.js";
|
|
38
|
+
import { removeStrayGateEntries } from "../stray-gate-entries.js";
|
|
38
39
|
import { deriveRemediation } from "./remediation.js";
|
|
39
40
|
/** The tree subtrees whose uncommitted changes block a `--fix` run. */
|
|
40
41
|
export const HOOK_CONFIG_DIRS = [".claude", ".codex", ".grok"];
|
|
@@ -237,8 +238,8 @@ function planFix(hqRoot, result, rem, refreshSync) {
|
|
|
237
238
|
return planExecutableBit(hqRoot, result, rem);
|
|
238
239
|
case "gate-profile":
|
|
239
240
|
return planGateProfile(hqRoot, result, rem);
|
|
240
|
-
case "
|
|
241
|
-
return
|
|
241
|
+
case "remove-stray-gate-entries":
|
|
242
|
+
return planRemoveStrayGateEntries(hqRoot, result, rem);
|
|
242
243
|
case "refresh-sync":
|
|
243
244
|
return planRefreshSync(hqRoot, result, rem, refreshSync);
|
|
244
245
|
default:
|
|
@@ -328,34 +329,32 @@ function planGateProfile(hqRoot, result, rem) {
|
|
|
328
329
|
postStatus: (results) => statusForTarget(results, ".gate-profiles", hookId),
|
|
329
330
|
};
|
|
330
331
|
}
|
|
331
|
-
/**
|
|
332
|
-
function
|
|
333
|
-
const
|
|
334
|
-
if (!
|
|
332
|
+
/** Remove matcher-less hook-gate.sh registrations an older doctor wrote. */
|
|
333
|
+
function planRemoveStrayGateEntries(hqRoot, result, rem) {
|
|
334
|
+
const settingsPath = rem.fixTarget;
|
|
335
|
+
if (!settingsPath)
|
|
336
|
+
return null;
|
|
337
|
+
const current = readJson(settingsPath);
|
|
338
|
+
if (!current)
|
|
339
|
+
return null;
|
|
340
|
+
const { removed } = removeStrayGateEntries(current);
|
|
341
|
+
if (removed === 0)
|
|
335
342
|
return null;
|
|
336
|
-
const settingsPath = path.join(hqRoot, ".claude", "settings.json");
|
|
337
|
-
const hookId = path.basename(scriptAbs).replace(/\.sh$/, "");
|
|
338
|
-
const event = "PreToolUse";
|
|
339
|
-
const command = `bash "$CLAUDE_PROJECT_DIR/.claude/hooks/hook-gate.sh" ${hookId} ` +
|
|
340
|
-
`"$CLAUDE_PROJECT_DIR/.claude/hooks/${hookId}.sh"`;
|
|
341
343
|
const relpath = toRelpath(hqRoot, settingsPath);
|
|
342
344
|
return {
|
|
343
345
|
checkId: result.checkId,
|
|
344
|
-
fixClass: "
|
|
345
|
-
target:
|
|
346
|
+
fixClass: "remove-stray-gate-entries",
|
|
347
|
+
target: settingsPath,
|
|
346
348
|
relpath,
|
|
347
349
|
preview: ` ${relpath}\n` +
|
|
348
|
-
`
|
|
349
|
-
|
|
350
|
-
summary: `register ${hookId} on ${event}`,
|
|
350
|
+
` - ${removed} matcher-less hook-gate.sh registration${removed === 1 ? "" : "s"} (they fire on every tool call)`,
|
|
351
|
+
summary: `remove ${removed} stray hook registration${removed === 1 ? "" : "s"} from ${relpath}`,
|
|
351
352
|
apply: () => {
|
|
352
|
-
const
|
|
353
|
-
const
|
|
354
|
-
fs.writeFileSync(settingsPath, JSON.stringify(
|
|
353
|
+
const latest = readJson(settingsPath) ?? {};
|
|
354
|
+
const cleaned = removeStrayGateEntries(latest).settings;
|
|
355
|
+
fs.writeFileSync(settingsPath, JSON.stringify(cleaned, null, 2) + "\n");
|
|
355
356
|
},
|
|
356
|
-
|
|
357
|
-
// by the Claude wiring tier's `…script` PASS keyed on the same path.
|
|
358
|
-
postStatus: (results) => statusForPath(results, scriptAbs),
|
|
357
|
+
postStatus: (results) => statusForTarget(results, ".stray-gate-registration", settingsPath),
|
|
359
358
|
};
|
|
360
359
|
}
|
|
361
360
|
// --- Shell / JSON edit primitives ---------------------------------------------
|
|
@@ -17,8 +17,11 @@
|
|
|
17
17
|
* - `gate-profile` — add a gated hook id to the `hook-gate.sh` profile
|
|
18
18
|
* allowlists it is missing from. Detected by the
|
|
19
19
|
* `…gate-profiles` FAIL from the Claude wiring tier.
|
|
20
|
-
* - `
|
|
21
|
-
*
|
|
20
|
+
* - `remove-stray-gate-entries` — delete matcher-less hook-gate.sh
|
|
21
|
+
* registrations an older `--fix` wrote into
|
|
22
|
+
* settings.json / settings.local.json. Detected by the
|
|
23
|
+
* `…stray-gate-registration` FAIL. (An orphan script is
|
|
24
|
+
* never auto-registered: that is what created them.)
|
|
22
25
|
* - `refresh-sync` — run a targeted pull for a company journal that is
|
|
23
26
|
* stale or never-synced AND has a local
|
|
24
27
|
* `companies/<slug>` tree. Pull-only, `--on-conflict
|
|
@@ -35,7 +38,7 @@
|
|
|
35
38
|
*/
|
|
36
39
|
import type { CheckResult } from "../types.js";
|
|
37
40
|
/** The allowlisted safe repair classes `--fix` is permitted to apply. */
|
|
38
|
-
export type FixClass = "executable-bit" | "gate-profile" | "
|
|
41
|
+
export type FixClass = "executable-bit" | "gate-profile" | "remove-stray-gate-entries" | "refresh-sync";
|
|
39
42
|
/**
|
|
40
43
|
* A finding's structured remediation. `autoFixable`, `action`, and `command`
|
|
41
44
|
* are the `--json` surface (AC1); `fixTarget`/`fixClass` are the internal handle
|
|
@@ -51,7 +54,7 @@ export interface Remediation {
|
|
|
51
54
|
/** The safe class when {@link autoFixable}, else null (manual-only). */
|
|
52
55
|
fixClass: FixClass | null;
|
|
53
56
|
/**
|
|
54
|
-
* The concrete file path (exec-bit,
|
|
57
|
+
* The concrete file path (exec-bit, remove-stray-gate-entries) or hook id (gate-profile)
|
|
55
58
|
* the auto-fix operates on. Present only when {@link autoFixable}. Internal —
|
|
56
59
|
* deliberately omitted from the `--json` document.
|
|
57
60
|
*/
|
|
@@ -17,8 +17,11 @@
|
|
|
17
17
|
* - `gate-profile` — add a gated hook id to the `hook-gate.sh` profile
|
|
18
18
|
* allowlists it is missing from. Detected by the
|
|
19
19
|
* `…gate-profiles` FAIL from the Claude wiring tier.
|
|
20
|
-
* - `
|
|
21
|
-
*
|
|
20
|
+
* - `remove-stray-gate-entries` — delete matcher-less hook-gate.sh
|
|
21
|
+
* registrations an older `--fix` wrote into
|
|
22
|
+
* settings.json / settings.local.json. Detected by the
|
|
23
|
+
* `…stray-gate-registration` FAIL. (An orphan script is
|
|
24
|
+
* never auto-registered: that is what created them.)
|
|
22
25
|
* - `refresh-sync` — run a targeted pull for a company journal that is
|
|
23
26
|
* stale or never-synced AND has a local
|
|
24
27
|
* `companies/<slug>` tree. Pull-only, `--on-conflict
|
|
@@ -86,14 +89,19 @@ export function deriveRemediation(result) {
|
|
|
86
89
|
command: remediation ?? `Add ${target} to the missing hook-gate.sh profiles.`,
|
|
87
90
|
};
|
|
88
91
|
}
|
|
89
|
-
//
|
|
90
|
-
|
|
92
|
+
// remove-stray-gate-entries — matcher-less hook-gate.sh registrations that an
|
|
93
|
+
// older doctor wrote. They fire on every tool call and block the session.
|
|
94
|
+
//
|
|
95
|
+
// There is deliberately NO auto-fix for an orphan script. Registering it with
|
|
96
|
+
// no matcher is exactly what created the stray entries; the right event and
|
|
97
|
+
// matcher can only be chosen by a person (hook-registry.json).
|
|
98
|
+
if (checkId.endsWith(".stray-gate-registration") && result.status === "FAIL" && target) {
|
|
91
99
|
return {
|
|
92
100
|
autoFixable: true,
|
|
93
|
-
fixClass: "
|
|
101
|
+
fixClass: "remove-stray-gate-entries",
|
|
94
102
|
fixTarget: target,
|
|
95
|
-
action: `
|
|
96
|
-
command: remediation ?? `
|
|
103
|
+
action: `Remove the matcher-less hook-gate.sh registrations from ${target}.`,
|
|
104
|
+
command: remediation ?? `Remove the matcher-less hook-gate.sh entries from ${target}.`,
|
|
97
105
|
};
|
|
98
106
|
}
|
|
99
107
|
// refresh-sync — a company journal that is stale or never-synced. Only
|
|
@@ -147,6 +147,7 @@ function runClaudeSettingsCheck(context) {
|
|
|
147
147
|
];
|
|
148
148
|
}
|
|
149
149
|
const count = countHookRegistrations(parsed);
|
|
150
|
+
const missingRequiredEvents = missingRequiredCommandHookEvents(parsed);
|
|
150
151
|
return [
|
|
151
152
|
{
|
|
152
153
|
status: "PASS",
|
|
@@ -154,6 +155,17 @@ function runClaudeSettingsCheck(context) {
|
|
|
154
155
|
target: settingsPath,
|
|
155
156
|
message: `.claude/settings.json is present and valid (${count} hook registration${count === 1 ? "" : "s"}).`,
|
|
156
157
|
},
|
|
158
|
+
{
|
|
159
|
+
status: missingRequiredEvents.length === 0 ? "PASS" : "FAIL",
|
|
160
|
+
checkId: "hooks.settings-required-command-hooks",
|
|
161
|
+
target: settingsPath,
|
|
162
|
+
message: missingRequiredEvents.length === 0
|
|
163
|
+
? ".claude/settings.json registers command hooks for SessionStart and PreToolUse."
|
|
164
|
+
: `.claude/settings.json has no command hook for ${missingRequiredEvents.join(" or ")}.`,
|
|
165
|
+
remediation: missingRequiredEvents.length === 0
|
|
166
|
+
? undefined
|
|
167
|
+
: "Restore the canonical hook registrations, e.g. `hq rescue -y --paths .claude`.",
|
|
168
|
+
},
|
|
157
169
|
// US-004+: the settings file is present and valid, so the full Claude wiring
|
|
158
170
|
// checks (script existence and executability, orphaned scripts, three-profile
|
|
159
171
|
// membership, unquoted $CLAUDE_PROJECT_DIR, and core/hooks/<Event>/ executable
|
|
@@ -184,6 +196,37 @@ function countHookRegistrations(settings) {
|
|
|
184
196
|
}
|
|
185
197
|
return count;
|
|
186
198
|
}
|
|
199
|
+
/** The lifecycle events whose command hooks make the settings file operational. */
|
|
200
|
+
const REQUIRED_COMMAND_HOOK_EVENTS = ["SessionStart", "PreToolUse"];
|
|
201
|
+
/**
|
|
202
|
+
* Return each required lifecycle event that has no command hook with a non-empty
|
|
203
|
+
* command string. This deliberately mirrors check-hq-hooks.sh's jq predicate.
|
|
204
|
+
*/
|
|
205
|
+
function missingRequiredCommandHookEvents(settings) {
|
|
206
|
+
return REQUIRED_COMMAND_HOOK_EVENTS.filter((event) => !hasCommandHook(settings, event));
|
|
207
|
+
}
|
|
208
|
+
function hasCommandHook(settings, event) {
|
|
209
|
+
if (!settings || typeof settings !== "object")
|
|
210
|
+
return false;
|
|
211
|
+
const hooks = settings.hooks;
|
|
212
|
+
if (!hooks || typeof hooks !== "object")
|
|
213
|
+
return false;
|
|
214
|
+
const eventEntries = hooks[event];
|
|
215
|
+
if (!Array.isArray(eventEntries))
|
|
216
|
+
return false;
|
|
217
|
+
return eventEntries.some((entry) => {
|
|
218
|
+
if (!entry || typeof entry !== "object")
|
|
219
|
+
return false;
|
|
220
|
+
const commandHooks = entry.hooks;
|
|
221
|
+
if (!Array.isArray(commandHooks))
|
|
222
|
+
return false;
|
|
223
|
+
return commandHooks.some((hook) => !!hook &&
|
|
224
|
+
typeof hook === "object" &&
|
|
225
|
+
hook.type === "command" &&
|
|
226
|
+
typeof hook.command === "string" &&
|
|
227
|
+
hook.command.length > 0);
|
|
228
|
+
});
|
|
229
|
+
}
|
|
187
230
|
/**
|
|
188
231
|
* Build a registry pre-loaded with the default families. Hooks is the first
|
|
189
232
|
* family; later families are registered here as they are implemented, each
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stray gate registrations written by an old `hq doctor --fix`.
|
|
3
|
+
*
|
|
4
|
+
* hq-cli v5.99.0 through v5.118.x "re-registered" every hook script that no
|
|
5
|
+
* settings command named. Since HQ moved to a single master-hook.sh entry that
|
|
6
|
+
* dispatches from .claude/hooks/hook-registry.json, EVERY script looked
|
|
7
|
+
* unregistered, so doctor appended one PreToolUse entry per script with no
|
|
8
|
+
* matcher:
|
|
9
|
+
*
|
|
10
|
+
* { "hooks": [{ "type": "command", "timeout": 5,
|
|
11
|
+
* "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/hook-gate.sh\" <id> \"$CLAUDE_PROJECT_DIR/.claude/hooks/<id>.sh\"" }] }
|
|
12
|
+
*
|
|
13
|
+
* With no matcher Claude Code runs each of them on every tool call, so guards
|
|
14
|
+
* such as block-hq-glob and protect-core block Bash, Skill and Read. An HQ
|
|
15
|
+
* update later moves them from settings.json into settings.local.json as
|
|
16
|
+
* "user customizations", where the release reset no longer clears them.
|
|
17
|
+
*
|
|
18
|
+
* Detection is deliberately exact: only an entry with no matcher, exactly one
|
|
19
|
+
* hook, timeout 5, and the doctor-generated command for a matching id counts.
|
|
20
|
+
* A hand-written hook is never touched.
|
|
21
|
+
*/
|
|
22
|
+
export declare function isStrayGateEntry(entry: unknown): boolean;
|
|
23
|
+
/** Count stray entries across every hook event in a parsed settings object. */
|
|
24
|
+
export declare function countStrayGateEntries(settings: unknown): number;
|
|
25
|
+
/**
|
|
26
|
+
* Return a copy of `settings` with every stray entry removed. An event array
|
|
27
|
+
* left empty is dropped, and an empty `hooks` map is dropped, so a file whose
|
|
28
|
+
* only hooks were strays goes back to its original shape. Everything else,
|
|
29
|
+
* including permissions and real hooks, is preserved.
|
|
30
|
+
*/
|
|
31
|
+
export declare function removeStrayGateEntries(settings: unknown): {
|
|
32
|
+
settings: unknown;
|
|
33
|
+
removed: number;
|
|
34
|
+
};
|
|
35
|
+
//# sourceMappingURL=stray-gate-entries.d.ts.map
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stray gate registrations written by an old `hq doctor --fix`.
|
|
3
|
+
*
|
|
4
|
+
* hq-cli v5.99.0 through v5.118.x "re-registered" every hook script that no
|
|
5
|
+
* settings command named. Since HQ moved to a single master-hook.sh entry that
|
|
6
|
+
* dispatches from .claude/hooks/hook-registry.json, EVERY script looked
|
|
7
|
+
* unregistered, so doctor appended one PreToolUse entry per script with no
|
|
8
|
+
* matcher:
|
|
9
|
+
*
|
|
10
|
+
* { "hooks": [{ "type": "command", "timeout": 5,
|
|
11
|
+
* "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/hook-gate.sh\" <id> \"$CLAUDE_PROJECT_DIR/.claude/hooks/<id>.sh\"" }] }
|
|
12
|
+
*
|
|
13
|
+
* With no matcher Claude Code runs each of them on every tool call, so guards
|
|
14
|
+
* such as block-hq-glob and protect-core block Bash, Skill and Read. An HQ
|
|
15
|
+
* update later moves them from settings.json into settings.local.json as
|
|
16
|
+
* "user customizations", where the release reset no longer clears them.
|
|
17
|
+
*
|
|
18
|
+
* Detection is deliberately exact: only an entry with no matcher, exactly one
|
|
19
|
+
* hook, timeout 5, and the doctor-generated command for a matching id counts.
|
|
20
|
+
* A hand-written hook is never touched.
|
|
21
|
+
*/
|
|
22
|
+
const GENERATED_COMMAND = /^bash "\$CLAUDE_PROJECT_DIR\/\.claude\/hooks\/hook-gate\.sh" ([A-Za-z0-9._-]+) "\$CLAUDE_PROJECT_DIR\/\.claude\/hooks\/([A-Za-z0-9._-]+)\.sh"$/;
|
|
23
|
+
export function isStrayGateEntry(entry) {
|
|
24
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry))
|
|
25
|
+
return false;
|
|
26
|
+
const e = entry;
|
|
27
|
+
if (e.matcher !== undefined)
|
|
28
|
+
return false;
|
|
29
|
+
const keys = Object.keys(e);
|
|
30
|
+
if (keys.length !== 1 || keys[0] !== "hooks")
|
|
31
|
+
return false;
|
|
32
|
+
if (!Array.isArray(e.hooks) || e.hooks.length !== 1)
|
|
33
|
+
return false;
|
|
34
|
+
const hook = e.hooks[0];
|
|
35
|
+
if (!hook || hook.type !== "command" || hook.timeout !== 5 || typeof hook.command !== "string")
|
|
36
|
+
return false;
|
|
37
|
+
const m = GENERATED_COMMAND.exec(hook.command);
|
|
38
|
+
return !!m && m[1] === m[2];
|
|
39
|
+
}
|
|
40
|
+
/** Count stray entries across every hook event in a parsed settings object. */
|
|
41
|
+
export function countStrayGateEntries(settings) {
|
|
42
|
+
const hooks = settings?.hooks;
|
|
43
|
+
if (!hooks || typeof hooks !== "object")
|
|
44
|
+
return 0;
|
|
45
|
+
let n = 0;
|
|
46
|
+
for (const entries of Object.values(hooks)) {
|
|
47
|
+
if (Array.isArray(entries))
|
|
48
|
+
n += entries.filter(isStrayGateEntry).length;
|
|
49
|
+
}
|
|
50
|
+
return n;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Return a copy of `settings` with every stray entry removed. An event array
|
|
54
|
+
* left empty is dropped, and an empty `hooks` map is dropped, so a file whose
|
|
55
|
+
* only hooks were strays goes back to its original shape. Everything else,
|
|
56
|
+
* including permissions and real hooks, is preserved.
|
|
57
|
+
*/
|
|
58
|
+
export function removeStrayGateEntries(settings) {
|
|
59
|
+
if (!settings || typeof settings !== "object" || Array.isArray(settings))
|
|
60
|
+
return { settings, removed: 0 };
|
|
61
|
+
const root = { ...settings };
|
|
62
|
+
const hooks = root.hooks;
|
|
63
|
+
if (!hooks || typeof hooks !== "object" || Array.isArray(hooks))
|
|
64
|
+
return { settings, removed: 0 };
|
|
65
|
+
let removed = 0;
|
|
66
|
+
const next = {};
|
|
67
|
+
for (const [event, entries] of Object.entries(hooks)) {
|
|
68
|
+
if (!Array.isArray(entries)) {
|
|
69
|
+
next[event] = entries;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const kept = entries.filter((e) => !isStrayGateEntry(e));
|
|
73
|
+
removed += entries.length - kept.length;
|
|
74
|
+
if (kept.length > 0 || entries.length === 0)
|
|
75
|
+
next[event] = kept;
|
|
76
|
+
}
|
|
77
|
+
if (Object.keys(next).length > 0)
|
|
78
|
+
root.hooks = next;
|
|
79
|
+
else
|
|
80
|
+
delete root.hooks;
|
|
81
|
+
return { settings: root, removed };
|
|
82
|
+
}
|
|
83
|
+
//# sourceMappingURL=stray-gate-entries.js.map
|