@indigoai-us/hq-cli 5.117.1 → 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 +41 -0
- package/assets/bot-workers/setup/context/USER-GUIDE.md +44 -3
- package/assets/bot-workers/setup/context/quick-reference.md +1 -1
- package/dist/command-catalog.generated.d.ts +5 -2
- package/dist/command-catalog.generated.js +6 -2
- package/dist/commands/agent-kit.js +16 -1
- package/dist/commands/agent-probe.d.ts +1 -3
- package/dist/commands/agent-probe.js +11 -29
- package/dist/commands/billing.js +1 -1
- package/dist/commands/db-provision.js +4 -4
- package/dist/commands/meetings.js +2 -2
- package/dist/commands/whoami.d.ts +7 -0
- package/dist/commands/whoami.js +55 -1
- package/dist/lib/agent-kit/run/inbox.js +2 -2
- package/dist/lib/agent-kit/run/mesh-listener.js +11 -2
- package/dist/lib/agent-kit/skills.js +2 -2
- package/dist/lib/billing/plan-lock.d.ts +99 -0
- package/dist/lib/billing/plan-lock.js +230 -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/dist/lib/plan-limit-nag.js +1 -1
- package/dist/utils/plan-gate-error.js +9 -9
- package/dist/utils/team-upgrade.d.ts +1 -1
- package/dist/utils/team-upgrade.js +3 -3
- package/package.json +1 -1
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `plan-lock` (starter-plan-hard-limits / US-011) — the CLI-side read + render
|
|
3
|
+
* of the workspace plan lock.
|
|
4
|
+
*
|
|
5
|
+
* Starter (free) workspaces are capped at 5 members and 0 integrations. Going
|
|
6
|
+
* over locks the workspace immediately: it becomes read-only until the owner
|
|
7
|
+
* trims back under the caps or upgrades to HQ Workforce. The lock decision is
|
|
8
|
+
* NOT made here — hq-pro's `src/billing/plan-lock.ts` is the single source of
|
|
9
|
+
* truth and ships the answer on `GET /membership/me` as a per-company
|
|
10
|
+
* `planLock` object. This module only reads that field and renders it.
|
|
11
|
+
*
|
|
12
|
+
* Member counts are decoration, never a second opinion: the count comes from
|
|
13
|
+
* `GET /v1/billing/usage-limits` on a best-effort basis and its absence only
|
|
14
|
+
* removes the "n of 5" detail from the notice. An absent field is UNKNOWN, so
|
|
15
|
+
* nothing here ever infers a lock (or an unlock) from missing data
|
|
16
|
+
* (hq-absent-field-never-means-constraining-value).
|
|
17
|
+
*/
|
|
18
|
+
import chalk from "chalk";
|
|
19
|
+
import { vaultApiFetch } from "../../utils/vault-api.js";
|
|
20
|
+
/** Starter member cap quoted when the server did not send `removeMembersTo`. */
|
|
21
|
+
export const STARTER_MEMBER_TARGET = 5;
|
|
22
|
+
/** Upgrade destination quoted when the server did not send one. */
|
|
23
|
+
export const DEFAULT_UPGRADE_URL = "https://hq.computer/billing";
|
|
24
|
+
/** The paid plan the lock wall sends owners to. Copy lives in ONE place. */
|
|
25
|
+
export const WORKFORCE_PLAN_LABEL = "HQ Workforce ($500/mo)";
|
|
26
|
+
function asRecord(value) {
|
|
27
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Defensively parse a `planLock` payload. Anything malformed returns null —
|
|
34
|
+
* the caller then behaves exactly as if the server had sent nothing, which is
|
|
35
|
+
* "unknown", not "locked".
|
|
36
|
+
*/
|
|
37
|
+
export function parsePlanLock(value) {
|
|
38
|
+
const rec = asRecord(value);
|
|
39
|
+
if (!rec)
|
|
40
|
+
return null;
|
|
41
|
+
if (typeof rec.locked !== "boolean")
|
|
42
|
+
return null;
|
|
43
|
+
const reasons = [];
|
|
44
|
+
if (Array.isArray(rec.reasons)) {
|
|
45
|
+
for (const reason of rec.reasons) {
|
|
46
|
+
if (reason === "users" || reason === "integrations")
|
|
47
|
+
reasons.push(reason);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const fix = asRecord(rec.fixOptions);
|
|
51
|
+
const removeMembersTo = typeof fix?.removeMembersTo === "number" &&
|
|
52
|
+
Number.isFinite(fix.removeMembersTo)
|
|
53
|
+
? fix.removeMembersTo
|
|
54
|
+
: STARTER_MEMBER_TARGET;
|
|
55
|
+
return {
|
|
56
|
+
locked: rec.locked,
|
|
57
|
+
reasons,
|
|
58
|
+
upgradeUrl: typeof rec.upgradeUrl === "string" && rec.upgradeUrl.trim().length > 0
|
|
59
|
+
? rec.upgradeUrl.trim()
|
|
60
|
+
: DEFAULT_UPGRADE_URL,
|
|
61
|
+
fixOptions: {
|
|
62
|
+
removeMembersTo,
|
|
63
|
+
disconnectIntegrations: fix?.disconnectIntegrations === true,
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Pick the membership row for `companySlug` (or `cmp_…` uid) out of a decoded
|
|
69
|
+
* `/membership/me` body and return its parsed lock. No matching row — or no
|
|
70
|
+
* `planLock` on it — is UNKNOWN, so this returns null rather than guessing.
|
|
71
|
+
*/
|
|
72
|
+
export function selectPlanLock(body, companyRef) {
|
|
73
|
+
const rec = asRecord(body);
|
|
74
|
+
const rows = Array.isArray(rec?.memberships) ? rec.memberships : [];
|
|
75
|
+
const ref = companyRef.trim().toLowerCase();
|
|
76
|
+
for (const raw of rows) {
|
|
77
|
+
const row = asRecord(raw);
|
|
78
|
+
if (!row)
|
|
79
|
+
continue;
|
|
80
|
+
if (typeof row.status === "string" && row.status !== "active")
|
|
81
|
+
continue;
|
|
82
|
+
const slug = typeof row.companySlug === "string" ? row.companySlug.toLowerCase() : "";
|
|
83
|
+
const uid = typeof row.companyUid === "string" ? row.companyUid.toLowerCase() : "";
|
|
84
|
+
if (slug !== ref && uid !== ref)
|
|
85
|
+
continue;
|
|
86
|
+
const lock = parsePlanLock(row.planLock);
|
|
87
|
+
if (!lock)
|
|
88
|
+
return null;
|
|
89
|
+
return {
|
|
90
|
+
lock,
|
|
91
|
+
companyUid: typeof row.companyUid === "string" ? row.companyUid : undefined,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
/** Parse the resolved plan id out of a usage-limits body. Absent → null. */
|
|
97
|
+
export function selectPlan(body) {
|
|
98
|
+
const plan = asRecord(body)?.plan;
|
|
99
|
+
return plan === "free" || plan === "paid" || plan === "enterprise"
|
|
100
|
+
? plan
|
|
101
|
+
: null;
|
|
102
|
+
}
|
|
103
|
+
/** Parse the `users` dimension out of a usage-limits body. Absent → null. */
|
|
104
|
+
export function selectMemberUsage(body) {
|
|
105
|
+
const users = asRecord(asRecord(body)?.users);
|
|
106
|
+
if (!users)
|
|
107
|
+
return null;
|
|
108
|
+
if (typeof users.used !== "number" || !Number.isFinite(users.used)) {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
const limit = typeof users.limit === "number" && Number.isFinite(users.limit)
|
|
112
|
+
? users.limit
|
|
113
|
+
: STARTER_MEMBER_TARGET;
|
|
114
|
+
return { used: users.used, limit };
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Read the live lock for one company: `planLock` from `/membership/me`, plus a
|
|
118
|
+
* best-effort member count for the notice. Returns null when the server did not
|
|
119
|
+
* answer with a lock for this company — callers must then say nothing.
|
|
120
|
+
*/
|
|
121
|
+
export async function fetchPlanLockStatus(token, companyRef, opts = {}) {
|
|
122
|
+
const timeoutMs = opts.timeoutMs ?? 8000;
|
|
123
|
+
const res = await vaultApiFetch({
|
|
124
|
+
token,
|
|
125
|
+
path: "/membership/me",
|
|
126
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
127
|
+
});
|
|
128
|
+
if (!res.ok)
|
|
129
|
+
return null;
|
|
130
|
+
const selected = selectPlanLock(await res.json().catch(() => null), companyRef);
|
|
131
|
+
if (!selected)
|
|
132
|
+
return null;
|
|
133
|
+
let members;
|
|
134
|
+
let plan;
|
|
135
|
+
// Decoration only. The lock answer above is authoritative; the usage read
|
|
136
|
+
// adds the member count and the plan id that the notices and the `Plan:`
|
|
137
|
+
// orientation line quote. A failure here must never suppress — or invent —
|
|
138
|
+
// a lock, so every field it feeds stays optional.
|
|
139
|
+
if (selected.companyUid) {
|
|
140
|
+
try {
|
|
141
|
+
const usage = await vaultApiFetch({
|
|
142
|
+
token,
|
|
143
|
+
path: "/v1/billing/usage-limits",
|
|
144
|
+
query: { companyUid: selected.companyUid },
|
|
145
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
146
|
+
});
|
|
147
|
+
if (usage.ok) {
|
|
148
|
+
const body = await usage.json().catch(() => null);
|
|
149
|
+
members = selectMemberUsage(body) ?? undefined;
|
|
150
|
+
plan = selectPlan(body) ?? undefined;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
// Network/timeout: leave both unknown.
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
companySlug: companyRef,
|
|
159
|
+
companyUid: selected.companyUid,
|
|
160
|
+
lock: selected.lock,
|
|
161
|
+
members,
|
|
162
|
+
plan,
|
|
163
|
+
checkedAt: new Date().toISOString(),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
function reasonLabel(reason) {
|
|
167
|
+
return reason === "users"
|
|
168
|
+
? "too many members"
|
|
169
|
+
: "integrations are not included on Starter";
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* The full WORKSPACE LOCKED block: why it locked, where the workspace stands
|
|
173
|
+
* against the cap, and the two fixes. Plain text — colour is applied by the
|
|
174
|
+
* caller so scripts capturing stdout get a clean block.
|
|
175
|
+
*/
|
|
176
|
+
export function renderPlanLockNotice(status) {
|
|
177
|
+
const { lock, members, companySlug } = status;
|
|
178
|
+
const target = lock.fixOptions.removeMembersTo;
|
|
179
|
+
const lines = [];
|
|
180
|
+
lines.push(`WORKSPACE LOCKED — ${companySlug} is over its Starter plan.`);
|
|
181
|
+
const reasons = lock.reasons.length
|
|
182
|
+
? lock.reasons.map(reasonLabel).join("; ")
|
|
183
|
+
: "over the Starter plan limits";
|
|
184
|
+
lines.push(` Why: ${reasons}.`);
|
|
185
|
+
if (members) {
|
|
186
|
+
lines.push(` Members: ${members.used} of ${target}.`);
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
lines.push(` Members allowed on Starter: ${target}.`);
|
|
190
|
+
}
|
|
191
|
+
lines.push(" This workspace is read-only until it is fixed. Two ways out:");
|
|
192
|
+
lines.push(` 1. Remove members until you are at ${target} or fewer${lock.fixOptions.disconnectIntegrations
|
|
193
|
+
? ", and disconnect the workspace's integrations"
|
|
194
|
+
: ""}.`);
|
|
195
|
+
lines.push(` 2. Upgrade to ${WORKFORCE_PLAN_LABEL}.`);
|
|
196
|
+
lines.push(` Upgrade: ${lock.upgradeUrl}`);
|
|
197
|
+
return lines.join("\n");
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* The one-line form injected on every turn while the workspace stays locked.
|
|
201
|
+
* Kept to a single line on purpose — it repeats each turn.
|
|
202
|
+
*/
|
|
203
|
+
export function renderPlanLockLine(status) {
|
|
204
|
+
const target = status.lock.fixOptions.removeMembersTo;
|
|
205
|
+
const count = status.members
|
|
206
|
+
? `${status.members.used} of ${target} members`
|
|
207
|
+
: `over its ${target}-member limit`;
|
|
208
|
+
return (`Company ${status.companySlug} is locked on Starter (${count}). ` +
|
|
209
|
+
`Writes to HQ cloud will fail until fixed: ${status.lock.upgradeUrl}`);
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* The `Plan: …` orientation line. Starter only: a paid or enterprise
|
|
213
|
+
* workspace — and an UNKNOWN plan — gets no line at all.
|
|
214
|
+
*/
|
|
215
|
+
export function renderPlanLine(status) {
|
|
216
|
+
if (status.lock.locked)
|
|
217
|
+
return "Plan: Starter — LOCKED";
|
|
218
|
+
if (status.plan !== "free")
|
|
219
|
+
return null;
|
|
220
|
+
const target = status.lock.fixOptions.removeMembersTo;
|
|
221
|
+
if (status.members) {
|
|
222
|
+
return `Plan: Starter — ${status.members.used} of ${target} members`;
|
|
223
|
+
}
|
|
224
|
+
return `Plan: Starter — ${target} members included`;
|
|
225
|
+
}
|
|
226
|
+
/** Colourised block for interactive output. */
|
|
227
|
+
export function colorizePlanLockNotice(notice) {
|
|
228
|
+
return chalk.red(notice);
|
|
229
|
+
}
|
|
230
|
+
//# sourceMappingURL=plan-lock.js.map
|
|
@@ -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
|