@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 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: ["PreToolUse"]. */
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: `Register ${file} in .claude/settings.json or delete it.`,
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 "register-hook":
241
- return planRegisterHook(hqRoot, result, rem);
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
- /** Re-register a script that is present on disk but wired in no settings file. */
332
- function planRegisterHook(hqRoot, result, rem) {
333
- const scriptAbs = rem.fixTarget;
334
- if (!scriptAbs)
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: "register-hook",
345
- target: scriptAbs,
346
+ fixClass: "remove-stray-gate-entries",
347
+ target: settingsPath,
346
348
  relpath,
347
349
  preview: ` ${relpath}\n` +
348
- ` + hooks.${event}: register ${hookId} via hook-gate.sh\n` +
349
- ` ${command}`,
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 settings = readJson(settingsPath) ?? {};
353
- const updated = registerHookInSettings(settings, event, command);
354
- fs.writeFileSync(settingsPath, JSON.stringify(updated, null, 2) + "\n");
353
+ const latest = readJson(settingsPath) ?? {};
354
+ const cleaned = removeStrayGateEntries(latest).settings;
355
+ fs.writeFileSync(settingsPath, JSON.stringify(cleaned, null, 2) + "\n");
355
356
  },
356
- // After registration the script becomes a wired, executable file, reported
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
- * - `register-hook` re-register a script that is present on disk but wired
21
- * nowhere. Detected by the `…orphan` WARN.
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" | "register-hook" | "refresh-sync";
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, register-hook) or hook id (gate-profile)
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
- * - `register-hook` re-register a script that is present on disk but wired
21
- * nowhere. Detected by the `…orphan` WARN.
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
- // register-hooka script on disk that no settings file references.
90
- if (checkId.endsWith(".orphan") && target) {
92
+ // remove-stray-gate-entriesmatcher-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: "register-hook",
101
+ fixClass: "remove-stray-gate-entries",
94
102
  fixTarget: target,
95
- action: `Re-register ${target} in .claude/settings.json.`,
96
- command: remediation ?? `Register ${target} in .claude/settings.json.`,
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.117.2",
3
+ "version": "5.117.3",
4
4
  "description": "HQ by Indigo management CLI \u2014 modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {