@indigoai-us/hq-cli 5.345.148 → 5.345.149

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.
Files changed (65) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +2 -0
  3. package/dist/command-catalog.generated.d.ts +44 -8
  4. package/dist/command-catalog.generated.js +53 -8
  5. package/dist/command-help.js +2 -1
  6. package/dist/commands/access.d.ts +45 -2
  7. package/dist/commands/access.js +537 -223
  8. package/dist/commands/billing-usage.d.ts +33 -2
  9. package/dist/commands/billing-usage.js +75 -4
  10. package/dist/commands/bot.js +2 -2
  11. package/dist/commands/channels.d.ts +27 -0
  12. package/dist/commands/channels.js +108 -4
  13. package/dist/commands/core-refresh-vault-access-native.d.ts +16 -0
  14. package/dist/commands/core-refresh-vault-access-native.js +142 -9
  15. package/dist/commands/core.js +4 -1
  16. package/dist/commands/dm.js +9 -1
  17. package/dist/commands/install-global.d.ts +1 -8
  18. package/dist/commands/install-global.js +11 -27
  19. package/dist/commands/library-add.d.ts +31 -1
  20. package/dist/commands/library-add.js +69 -2
  21. package/dist/commands/library.js +37 -3
  22. package/dist/commands/reindex.js +19 -0
  23. package/dist/hook-engine-path.js +10 -3
  24. package/dist/index.d.ts +10 -0
  25. package/dist/index.js +70 -5
  26. package/dist/lazy-commands.js +2 -0
  27. package/dist/lib/billing/usage-cost.d.ts +31 -1
  28. package/dist/lib/billing/usage-cost.js +46 -0
  29. package/dist/lib/db/local.d.ts +2 -2
  30. package/dist/lib/db/local.js +2 -2
  31. package/dist/lib/db/sql.js +2 -2
  32. package/dist/lib/db/sqlite-binding.d.ts +22 -0
  33. package/dist/lib/db/sqlite-binding.js +168 -0
  34. package/dist/lib/doctor/checks/anywhere.d.ts +12 -0
  35. package/dist/lib/doctor/checks/anywhere.js +63 -12
  36. package/dist/lib/doctor/checks/cli-path.d.ts +2 -1
  37. package/dist/lib/doctor/checks/cli-path.js +2 -1
  38. package/dist/lib/doctor/checks/hook-engine.js +2 -1
  39. package/dist/lib/hook-engine/reconcile.d.ts +9 -0
  40. package/dist/lib/hook-engine/reconcile.js +134 -0
  41. package/dist/lib/hook-engine/registration.d.ts +17 -0
  42. package/dist/lib/hook-engine/registration.js +50 -0
  43. package/dist/lib/install/claude-targets.d.ts +2 -1
  44. package/dist/lib/install/claude-targets.js +8 -3
  45. package/dist/lib/install/codex-targets.d.ts +3 -0
  46. package/dist/lib/install/codex-targets.js +15 -4
  47. package/dist/lib/install/hook-target.d.ts +9 -0
  48. package/dist/lib/install/hook-target.js +39 -0
  49. package/dist/lib/windows/console-encoding.d.ts +24 -0
  50. package/dist/lib/windows/console-encoding.js +83 -0
  51. package/dist/lib/windows/install-followup.d.ts +35 -0
  52. package/dist/lib/windows/install-followup.js +142 -0
  53. package/dist/lib/windows/user-path.d.ts +80 -3
  54. package/dist/lib/windows/user-path.js +187 -26
  55. package/dist/main.js +2 -1
  56. package/dist/unknown-command.d.ts +18 -0
  57. package/dist/unknown-command.js +88 -0
  58. package/dist/utils/cognito-session.d.ts +8 -0
  59. package/dist/utils/cognito-session.js +14 -2
  60. package/dist/utils/hook-trust.d.ts +9 -0
  61. package/dist/utils/hook-trust.js +30 -2
  62. package/dist/utils/qmd-native-binding-error.js +3 -1
  63. package/dist/utils/resolve-executable.d.ts +2 -0
  64. package/dist/utils/resolve-executable.js +6 -2
  65. package/package.json +8 -8
@@ -5,7 +5,7 @@
5
5
  * resolved company slug from HQ session/flags. Path overrides that open
6
6
  * another company's DB are denied unless an explicit dangerous flag is set.
7
7
  */
8
- import Database from "better-sqlite3";
8
+ import { loadBetterSqlite3 } from "./sqlite-binding.js";
9
9
  import { openLocalDb } from "./local.js";
10
10
  import { normalizeCompanySlugForLocalDb, resolveLocalDbPath, } from "./paths.js";
11
11
  const WRITE_PATTERN = /^\s*(INSERT|UPDATE|DELETE|REPLACE|CREATE|DROP|ALTER|TRUNCATE|REINDEX|VACUUM|BEGIN|COMMIT|ROLLBACK)\b/i;
@@ -49,7 +49,7 @@ export function resolveSqlDbPath(opts) {
49
49
  return opts.dbPathOverride;
50
50
  }
51
51
  function openByPath(dbPath) {
52
- const db = new Database(dbPath);
52
+ const db = new (loadBetterSqlite3())(dbPath);
53
53
  db.pragma("journal_mode = WAL");
54
54
  db.pragma("foreign_keys = ON");
55
55
  return db;
@@ -0,0 +1,22 @@
1
+ import type BetterSqlite3 from "better-sqlite3";
2
+ import { type SpawnPlan } from "../../utils/windows-spawn.js";
3
+ export type BetterSqlite3Constructor = typeof BetterSqlite3;
4
+ export declare class SqliteBindingError extends Error {
5
+ constructor(cause?: unknown);
6
+ }
7
+ export interface LoadBetterSqlite3Options {
8
+ importDatabase?: () => BetterSqlite3Constructor;
9
+ /** Return true when the native binary is ready to load again. */
10
+ repair?: () => boolean;
11
+ }
12
+ export declare function resetSqliteBindingForTests(): void;
13
+ export declare function clearBetterSqlite3RequireCache(): void;
14
+ /**
15
+ * A `.js` bin runs under this Node. A `.cmd` shim goes through the escaped
16
+ * cmd.exe plan, so a profile path with spaces or `&` stays one argument.
17
+ */
18
+ export declare function planRepairToolSpawn(cmd: string, args: readonly string[], env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): SpawnPlan;
19
+ /** Download or build better-sqlite3's native binary inside its own package directory. */
20
+ export declare function repairBetterSqlite3(): boolean;
21
+ export declare function loadBetterSqlite3(options?: LoadBetterSqlite3Options): BetterSqlite3Constructor;
22
+ //# sourceMappingURL=sqlite-binding.d.ts.map
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Load better-sqlite3 without requiring the user to approve npm scripts.
3
+ * npm 11 skips dependency install scripts, so the prebuilt binary is often
4
+ * missing after `npm install -g`. Try a one-shot prebuild download from the
5
+ * installed package, then surface the exact allow-scripts command.
6
+ */
7
+ import { spawnSync } from "node:child_process";
8
+ import fs from "node:fs";
9
+ import { createRequire } from "node:module";
10
+ import path from "node:path";
11
+ import { planResolvedCommandSpawn } from "../../utils/windows-spawn.js";
12
+ const require = createRequire(import.meta.url);
13
+ export class SqliteBindingError extends Error {
14
+ constructor(cause) {
15
+ super("better-sqlite3 did not load. npm 11.16 and later skip its install script unless scripts are allowed, so the prebuilt binary was not downloaded. " +
16
+ "HQ tried to download the prebuild and it still failed. " +
17
+ "Fix: npm install -g @indigoai-us/hq-cli --allow-scripts=better-sqlite3");
18
+ this.name = "SqliteBindingError";
19
+ if (cause !== undefined)
20
+ this.cause = cause;
21
+ }
22
+ }
23
+ let cached;
24
+ let failed;
25
+ let defaultRepairAttempted = false;
26
+ export function resetSqliteBindingForTests() {
27
+ cached = undefined;
28
+ failed = undefined;
29
+ defaultRepairAttempted = false;
30
+ }
31
+ function isNativeBindingError(error) {
32
+ const record = error;
33
+ const text = error instanceof Error ? `${record?.code ?? ""} ${error.message}` : String(error);
34
+ return /better_sqlite3\.node|could not locate the bindings file|NODE_MODULE_VERSION|was compiled against a different node|not a valid win32 application|dlopen|ERR_DLOPEN_FAILED|MODULE_NOT_FOUND/i.test(text);
35
+ }
36
+ export function clearBetterSqlite3RequireCache() {
37
+ for (const id of Object.keys(require.cache)) {
38
+ if (id.includes(`${path.sep}better-sqlite3${path.sep}`))
39
+ delete require.cache[id];
40
+ }
41
+ }
42
+ function defaultImport() {
43
+ return require("better-sqlite3");
44
+ }
45
+ function resolveTool(dir, tool) {
46
+ const names = process.platform === "win32" ? [`${tool}.cmd`, `${tool}.exe`, tool] : [tool];
47
+ const bins = [path.join(path.dirname(dir), ".bin"), path.join(dir, "node_modules", ".bin")];
48
+ for (const bin of bins) {
49
+ for (const name of names) {
50
+ const candidate = path.join(bin, name);
51
+ if (fs.existsSync(candidate))
52
+ return candidate;
53
+ }
54
+ }
55
+ try {
56
+ const localRequire = createRequire(path.join(dir, "package.json"));
57
+ const pkgJson = localRequire.resolve(`${tool}/package.json`);
58
+ const pkg = JSON.parse(fs.readFileSync(pkgJson, "utf8"));
59
+ const rel = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.[tool];
60
+ if (typeof rel === "string")
61
+ return path.join(path.dirname(pkgJson), rel);
62
+ }
63
+ catch {
64
+ // The tool is not installed next to better-sqlite3.
65
+ }
66
+ return null;
67
+ }
68
+ /**
69
+ * A `.js` bin runs under this Node. A `.cmd` shim goes through the escaped
70
+ * cmd.exe plan, so a profile path with spaces or `&` stays one argument.
71
+ */
72
+ export function planRepairToolSpawn(cmd, args, env = process.env, platform = process.platform) {
73
+ if (cmd.endsWith(".js"))
74
+ return { cmd: process.execPath, args: [cmd, ...args], shell: false };
75
+ return planResolvedCommandSpawn(cmd, args, env, platform);
76
+ }
77
+ function runTool(cmd, args, cwd) {
78
+ const plan = planRepairToolSpawn(cmd, args);
79
+ spawnSync(plan.cmd, plan.args, {
80
+ cwd,
81
+ timeout: 60_000,
82
+ windowsHide: true,
83
+ stdio: "ignore",
84
+ shell: plan.shell,
85
+ ...(plan.windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}),
86
+ });
87
+ }
88
+ /** Download or build better-sqlite3's native binary inside its own package directory. */
89
+ export function repairBetterSqlite3() {
90
+ let dir;
91
+ try {
92
+ dir = path.dirname(require.resolve("better-sqlite3/package.json"));
93
+ }
94
+ catch {
95
+ return false;
96
+ }
97
+ const artifact = path.join(dir, "build", "Release", "better_sqlite3.node");
98
+ if (fs.existsSync(artifact))
99
+ return true;
100
+ const prebuild = resolveTool(dir, "prebuild-install");
101
+ if (prebuild)
102
+ runTool(prebuild, [], dir);
103
+ if (fs.existsSync(artifact))
104
+ return true;
105
+ const nodeGyp = resolveTool(dir, "node-gyp");
106
+ if (nodeGyp)
107
+ runTool(nodeGyp, ["rebuild", "--release"], dir);
108
+ return fs.existsSync(artifact);
109
+ }
110
+ function attemptRepair(options) {
111
+ if (options.repair) {
112
+ try {
113
+ return options.repair();
114
+ }
115
+ catch {
116
+ return false;
117
+ }
118
+ }
119
+ if (defaultRepairAttempted)
120
+ return false;
121
+ defaultRepairAttempted = true;
122
+ try {
123
+ return repairBetterSqlite3();
124
+ }
125
+ catch {
126
+ return false;
127
+ }
128
+ }
129
+ export function loadBetterSqlite3(options = {}) {
130
+ const injected = options.importDatabase !== undefined;
131
+ if (!injected && cached)
132
+ return cached;
133
+ if (!injected && failed)
134
+ throw failed;
135
+ const importDatabase = options.importDatabase ?? defaultImport;
136
+ const load = () => {
137
+ const ctor = importDatabase();
138
+ if (!injected)
139
+ cached = ctor;
140
+ return ctor;
141
+ };
142
+ try {
143
+ return load();
144
+ }
145
+ catch (error) {
146
+ if (!isNativeBindingError(error))
147
+ throw error;
148
+ const repaired = attemptRepair(options);
149
+ if (!repaired) {
150
+ const wrapped = new SqliteBindingError(error);
151
+ if (!injected)
152
+ failed = wrapped;
153
+ throw wrapped;
154
+ }
155
+ if (!injected)
156
+ clearBetterSqlite3RequireCache();
157
+ try {
158
+ return load();
159
+ }
160
+ catch (retryError) {
161
+ const wrapped = new SqliteBindingError(retryError);
162
+ if (!injected)
163
+ failed = wrapped;
164
+ throw wrapped;
165
+ }
166
+ }
167
+ }
168
+ //# sourceMappingURL=sqlite-binding.js.map
@@ -48,10 +48,22 @@ export interface AnywhereDeps {
48
48
  serviceState?: () => DaemonServiceState;
49
49
  /** Is an hqd process alive? Default: the daemon lock names a live hqd pid. */
50
50
  daemonRunning?: () => boolean;
51
+ /** Transport to report when health throws. Production uses the platform default. */
52
+ unreachableTransport?: "windows_named_pipe" | "unix_socket";
51
53
  now?: () => Date;
52
54
  }
55
+ export interface PipeConnectFailure {
56
+ code?: string;
57
+ detail?: string;
58
+ }
53
59
  export declare function createAnywhereFamily(deps?: AnywhereDeps): CheckFamily;
54
60
  export declare const anywhereFamily: CheckFamily;
61
+ /**
62
+ * What to say when the Windows named pipe cannot be used. The old wording
63
+ * guessed an ACL refusal whenever any process looked like a daemon. This
64
+ * reports the connect error that was actually observed.
65
+ */
66
+ export declare function windowsNamedPipeUnreachableMessage(socketPath: string, daemonRunning: boolean, failure?: PipeConnectFailure): string;
55
67
  export declare function defaultServiceState(home: string, options?: DaemonServiceProbeOptions): DaemonServiceState;
56
68
  export {};
57
69
  //# sourceMappingURL=anywhere.d.ts.map
@@ -63,6 +63,7 @@ async function runAnywhereChecks(context, deps = {}) {
63
63
  : result(personSetting ? "PASS" : "WARN", "person-setting", `HQ Anywhere person setting is ${personSetting ? "on" : "off"}`);
64
64
  let health;
65
65
  let healthUnsupported = false;
66
+ let connectFailure;
66
67
  try {
67
68
  if (deps.health) {
68
69
  health = await deps.health(cwd);
@@ -107,12 +108,14 @@ async function runAnywhereChecks(context, deps = {}) {
107
108
  };
108
109
  healthUnsupported = true;
109
110
  }
110
- catch {
111
- health = unreachableHealth(socketPath);
111
+ catch (inner) {
112
+ health = unreachableHealth(socketPath, deps.unreachableTransport);
113
+ connectFailure = connectFailureFrom(inner);
112
114
  }
113
115
  }
114
116
  else {
115
- health = unreachableHealth(socketPath);
117
+ health = unreachableHealth(socketPath, deps.unreachableTransport);
118
+ connectFailure = connectFailureFrom(error);
116
119
  }
117
120
  }
118
121
  const claude = claudeTargets(home);
@@ -126,7 +129,7 @@ async function runAnywhereChecks(context, deps = {}) {
126
129
  const daemonRunning = health.reachable
127
130
  ? true
128
131
  : (deps.daemonRunning ?? (() => runningDaemonPid(daemonPaths(home, env).lockPath) !== null))();
129
- const healthCheck = runtimeHealthCheck(health, healthUnsupported, serviceState, daemonRunning);
132
+ const healthCheck = runtimeHealthCheck(health, healthUnsupported, serviceState, daemonRunning, connectFailure);
130
133
  if (!runtimeEnabled)
131
134
  return [healthCheck, settingCheck];
132
135
  const remoteModes = [
@@ -177,17 +180,67 @@ async function runAnywhereChecks(context, deps = {}) {
177
180
  out.push(checkSkillLinks("codex", codex.skillsDir, REMEDIATION.installCodex, REMEDIATION.repairCodexSkills));
178
181
  return out;
179
182
  }
180
- function unreachableHealth(socketPath) {
183
+ function unreachableHealth(socketPath, transport) {
181
184
  return {
182
185
  reachable: false,
183
186
  version: null,
184
187
  uptimeMs: null,
185
188
  flag: { enabled: null, lastSuccessfulReadAgeMs: null, heldThroughReadError: false },
186
189
  resolution: { scope: "unknown", company: null, fallbackReason: null },
187
- socket: { path: socketPath, transport: process.platform === "win32" ? "windows_named_pipe" : "unix_socket", mode: null, ownerMatchesUser: null, userOnly: null },
190
+ socket: {
191
+ path: socketPath,
192
+ transport: transport ?? (process.platform === "win32" ? "windows_named_pipe" : "unix_socket"),
193
+ mode: null,
194
+ ownerMatchesUser: null,
195
+ userOnly: null,
196
+ },
188
197
  };
189
198
  }
190
- function runtimeHealthCheck(health, healthUnsupported = false, serviceState, daemonRunning = true) {
199
+ function connectFailureFrom(error) {
200
+ if (error instanceof HqdClientError)
201
+ return { code: error.code, detail: error.message };
202
+ if (error instanceof Error) {
203
+ const code = error.code;
204
+ return { code: typeof code === "string" ? code : undefined, detail: error.message };
205
+ }
206
+ return { detail: String(error) };
207
+ }
208
+ function failureSuffix(failure) {
209
+ if (!failure)
210
+ return "";
211
+ const parts = [failure.code, failure.detail].filter((part) => Boolean(part && part.trim()));
212
+ return parts.length === 0 ? "" : ` (${parts.join(": ")})`;
213
+ }
214
+ function accessDenied(failure) {
215
+ const code = (failure?.code ?? "").toUpperCase();
216
+ if (code === "EACCES" || code === "EPERM")
217
+ return true;
218
+ return /access is denied|unsafe acl/i.test(failure?.detail ?? "");
219
+ }
220
+ /**
221
+ * What to say when the Windows named pipe cannot be used. The old wording
222
+ * guessed an ACL refusal whenever any process looked like a daemon. This
223
+ * reports the connect error that was actually observed.
224
+ */
225
+ export function windowsNamedPipeUnreachableMessage(socketPath, daemonRunning, failure) {
226
+ const suffix = failureSuffix(failure);
227
+ if (!daemonRunning) {
228
+ return `hqd is not running, so its named pipe ${socketPath} does not exist${suffix}. Run ${REMEDIATION.daemonStatus} to see why it has not started.`;
229
+ }
230
+ if (accessDenied(failure)) {
231
+ return `hqd is running but access to named pipe ${socketPath} was denied${suffix}. The pipe ACL does not allow this user. Run ${REMEDIATION.daemonRestart} to recreate the pipe.`;
232
+ }
233
+ const code = (failure?.code ?? "").toUpperCase();
234
+ if (code === "ENOENT") {
235
+ return `hqd is running but named pipe ${socketPath} does not exist${suffix}. Run ${REMEDIATION.daemonRestart} to recreate the pipe.`;
236
+ }
237
+ if (code === "ETIMEDOUT") {
238
+ return `hqd is running but did not answer on named pipe ${socketPath}${suffix}. Run ${REMEDIATION.daemonStatus}, then ${REMEDIATION.daemonRestart}.`;
239
+ }
240
+ const observed = suffix || " (observed)";
241
+ return `hqd is not reachable on ${socketPath}${observed}. HQ did not see an access-denied error, so this is not a named-pipe ACL refusal. Run ${REMEDIATION.daemonStatus} to see why, then ${REMEDIATION.daemonRestart}.`;
242
+ }
243
+ function runtimeHealthCheck(health, healthUnsupported = false, serviceState, daemonRunning = true, failure) {
191
244
  const { flag, resolution, socket } = health;
192
245
  const age = flag.lastSuccessfulReadAgeMs === null
193
246
  ? "unknown"
@@ -204,11 +257,9 @@ function runtimeHealthCheck(health, healthUnsupported = false, serviceState, dae
204
257
  ? "Windows named pipe (mode and owner are not reported)"
205
258
  : `mode ${socket.mode ?? "unknown"}; owner matches current user: ${socket.ownerMatchesUser === null ? "unknown" : socket.ownerMatchesUser ? "yes" : "no"}; user-only: ${socket.userOnly === null ? "unknown" : socket.userOnly ? "yes" : "no"}`;
206
259
  const pipeWithoutDaemon = socket.transport === "windows_named_pipe" && !daemonRunning;
207
- const unreachableMessage = pipeWithoutDaemon
208
- ? `hqd is not running, so its named pipe ${socket.path} does not exist. Run ${REMEDIATION.daemonStatus} to see why it has not started.`
209
- : socket.transport === "windows_named_pipe"
210
- ? `hqd is not reachable on ${socket.path}; secure Windows named-pipe setup may have refused an unsafe ACL`
211
- : `hqd is not reachable on ${socket.path}; version, uptime, flag state, and cwd binding are unavailable`;
260
+ const unreachableMessage = socket.transport === "windows_named_pipe"
261
+ ? windowsNamedPipeUnreachableMessage(socket.path, daemonRunning, failure)
262
+ : `hqd is not reachable on ${socket.path}; version, uptime, flag state, and cwd binding are unavailable`;
212
263
  const socketNotUserOnly = health.reachable && socket.transport === "unix_socket" && (socket.userOnly !== true || socket.ownerMatchesUser !== true);
213
264
  const warningNotes = [
214
265
  healthUnsupported ? "resident hqd does not support detailed health; restart hqd for full diagnostics" : null,
@@ -4,7 +4,8 @@ export declare const CLI_PATH_FAMILY_ID = "cli-path";
4
4
  export declare const CLI_PATH_CHECK_ID = "cli-path.npm-shim-on-path";
5
5
  /**
6
6
  * Windows: is the folder holding the npm `hq.cmd` shim on the PATH a new
7
- * terminal gets? Reads the registry only; never edits PATH.
7
+ * terminal gets? This check only reads. The CLI adds the folder to HKCU Path
8
+ * on startup; the warning is the fallback when that add did not stick.
8
9
  */
9
10
  export declare const cliPathFamily: CheckFamily;
10
11
  export declare function checkCliPath(_context: CheckContext, check?: () => ShimPathCheck | null): CheckResult[];
@@ -3,7 +3,8 @@ export const CLI_PATH_FAMILY_ID = "cli-path";
3
3
  export const CLI_PATH_CHECK_ID = "cli-path.npm-shim-on-path";
4
4
  /**
5
5
  * Windows: is the folder holding the npm `hq.cmd` shim on the PATH a new
6
- * terminal gets? Reads the registry only; never edits PATH.
6
+ * terminal gets? This check only reads. The CLI adds the folder to HKCU Path
7
+ * on startup; the warning is the fallback when that add did not stick.
7
8
  */
8
9
  export const cliPathFamily = {
9
10
  id: CLI_PATH_FAMILY_ID,
@@ -1,3 +1,4 @@
1
+ import { launcherEnabled } from '../../hook-engine/registration.js';
1
2
  import { findHookEngine, hookEngineRequired, pinnedHookEngineMatches, HOOK_ENGINE_REPAIR, HOOK_ENGINE_REPAIR_PIN } from '../../hook-engine/resolve.js';
2
3
  export function checkHookEngine() {
3
4
  const required = hookEngineRequired();
@@ -5,7 +6,7 @@ export function checkHookEngine() {
5
6
  try {
6
7
  const engine = findHookEngine();
7
8
  if (engine && pinnedHookEngineMatches(engine))
8
- return { checkId: 'hooks.native-engine', status: 'PASS', target: engine.pinned, message: `Native hook engine ${engine.version} installed at its version-pinned path.` };
9
+ return { checkId: 'hooks.native-engine', status: 'PASS', target: engine.pinned, message: `Native hook engine ${engine.version} installed at its version-pinned path. Launcher flag: ${launcherEnabled() ? 'on (run hq reindex to reconcile settings)' : 'off (bash registration)'}.` };
9
10
  return { checkId: 'hooks.native-engine', status: required ? 'FAIL' : 'WARN', message: `Native hook engine is missing or damaged. ${fallback}`, remediation: engine ? `Close HQ sessions, then run ${HOOK_ENGINE_REPAIR_PIN}` : HOOK_ENGINE_REPAIR };
10
11
  }
11
12
  catch (error) {
@@ -0,0 +1,9 @@
1
+ export interface ReconcileResult {
2
+ /** Codex user-scope trust keys for the HQ entries in the global hooks.json. */
3
+ codexUserKeys: string[];
4
+ /** One `<file>: <reason>` line per registration file left unchanged by an error. */
5
+ failures: string[];
6
+ }
7
+ /** Reconcile existing registrations only; reindex must not install a runtime. */
8
+ export declare function reconcileHookRegistrations(root: string, home?: string, env?: NodeJS.ProcessEnv, daemonInstalled?: boolean): ReconcileResult;
9
+ //# sourceMappingURL=reconcile.d.ts.map
@@ -0,0 +1,134 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { writeConfigAtomic, jsonFormat } from '../../commands/mcp-registration.js';
3
+ import { join } from 'node:path';
4
+ import { homedir } from 'node:os';
5
+ import { addHqHooks, removeHqHooks } from '../install/claude-targets.js';
6
+ import { hookTargetFor } from '../install/hook-target.js';
7
+ import { addCodexHooks, removeCodexHooks, codexHookEntry, codexHookTrustKeys } from '../install/codex-targets.js';
8
+ import { engineArgs, isNativeEntry, registrationEngine } from './registration.js';
9
+ /** The project master-hook command shipped in HQ's .claude/settings.json. */
10
+ function projectClaudeCommand(event) {
11
+ return 'env BASH_ENV=/dev/null bash "$CLAUDE_PROJECT_DIR/.claude/hooks/master-hook.sh" ' + event;
12
+ }
13
+ /**
14
+ * Rewrite matching entries where they sit. Groups, order, foreign entries and
15
+ * extra fields such as timeout are kept, so a flag-on then flag-off pass
16
+ * restores the original file.
17
+ */
18
+ function rewriteEntries(doc, convert) {
19
+ let changed = false;
20
+ const hooks = {};
21
+ for (const [event, groups] of Object.entries(doc.hooks)) {
22
+ hooks[event] = !Array.isArray(groups) ? groups : groups.map((group) => {
23
+ if (!Array.isArray(group?.hooks))
24
+ return group;
25
+ return { ...group, hooks: group.hooks.map((entry) => {
26
+ const next = entry && typeof entry === 'object' ? convert(entry, event) : undefined;
27
+ if (!next)
28
+ return entry;
29
+ changed = true;
30
+ return next;
31
+ }) };
32
+ });
33
+ }
34
+ return changed ? { ...doc, hooks } : doc;
35
+ }
36
+ /** Reconcile existing registrations only; reindex must not install a runtime. */
37
+ export function reconcileHookRegistrations(root, home = homedir(), env = process.env, daemonInstalled) {
38
+ const result = { codexUserKeys: [], failures: [] };
39
+ const engine = registrationEngine(home, env);
40
+ // Flag-off rollback restores the same bash target install would choose (the
41
+ // hqd shim when the daemon serves this root). Resolved only when a
42
+ // registration is rewritten, so the default-off path reads no daemon state.
43
+ let target;
44
+ const globalTarget = () => (target ??= engine ? { engine } : hookTargetFor(root, home, daemonInstalled, env));
45
+ const codexBash = (event) => [codexHookEntry(root, event, 'master').command, codexHookEntry(root, event, 'shim').command];
46
+ for (const [file, runtime, global] of [
47
+ [join(root, '.claude/settings.json'), 'claude', false],
48
+ [join(env.CLAUDE_CONFIG_DIR || join(home, '.claude'), 'settings.json'), 'claude', true],
49
+ [join(env.CODEX_HOME || join(home, '.codex'), 'hooks.json'), 'codex', true],
50
+ [join(root, '.codex/hooks.json'), 'codex', false],
51
+ ]) {
52
+ // Each file stands alone: an unreadable or locked project file must not
53
+ // undo or hide the trust keys of a global file already rewritten.
54
+ try {
55
+ if (!existsSync(file))
56
+ continue;
57
+ // Default-off must not acquire a write lock or create backups for a legacy
58
+ // registration. The merge below repeats this check under the lock when a
59
+ // native entry was found, so a concurrent writer still wins safely.
60
+ if (!engine) {
61
+ let current;
62
+ // A file we cannot parse holds no native entry of ours; leave it untouched.
63
+ try {
64
+ current = jsonFormat.parse(readFileSync(file, 'utf8'));
65
+ }
66
+ catch {
67
+ continue;
68
+ }
69
+ const entries = current?.hooks && typeof current.hooks === 'object'
70
+ ? Object.values(current.hooks).flatMap(groups => Array.isArray(groups) ? groups.flatMap(g => Array.isArray(g?.hooks) ? g.hooks : []) : [])
71
+ : [];
72
+ if (!entries.some(e => e && isNativeEntry(e, root)))
73
+ continue;
74
+ }
75
+ let expected;
76
+ let codexKeys;
77
+ writeConfigAtomic({ target: file, pack: 'hq-hook-engine', format: {
78
+ ...jsonFormat,
79
+ assertEntry: (doc) => {
80
+ if (expected !== undefined && JSON.stringify(doc) !== expected)
81
+ throw new Error('Hook registration verification failed');
82
+ },
83
+ }, env: { home }, assertName: 'hooks', merge: (doc) => {
84
+ if (!doc || typeof doc !== 'object' || !doc.hooks || typeof doc.hooks !== 'object')
85
+ return doc;
86
+ const entries = Object.values(doc.hooks).flatMap(groups => Array.isArray(groups) ? groups.flatMap(g => Array.isArray(g?.hooks) ? g.hooks : []) : []);
87
+ if (!engine && !entries.some(e => e && isNativeEntry(e, root)))
88
+ return doc;
89
+ if (global && (runtime === 'codex' ? removeCodexHooks(doc, root) : removeHqHooks(doc, root)) === doc)
90
+ return doc;
91
+ let next;
92
+ if (global)
93
+ next = runtime === 'codex' ? addCodexHooks(doc, root, globalTarget()) : addHqHooks(doc, root, globalTarget());
94
+ else if (runtime === 'codex') {
95
+ // Only an entry install itself writes is converted. HQ's own project
96
+ // file routes through hook-gate.sh and has none, so it stays as is.
97
+ next = rewriteEntries(doc, (entry, event) => {
98
+ if (!isNativeEntry(entry, root) && !codexBash(event).includes(entry.command))
99
+ return undefined;
100
+ const { command: _command, commandWindows: _windows, ...rest } = entry;
101
+ const { command, commandWindows } = codexHookEntry(root, event, globalTarget());
102
+ return { ...rest, command, ...(commandWindows ? { commandWindows } : {}) };
103
+ });
104
+ }
105
+ else {
106
+ // Only the exact shipped master-hook command is converted; a wrapped
107
+ // or extended invocation is someone's edit and is left alone.
108
+ next = rewriteEntries(doc, (entry, event) => {
109
+ if (!isNativeEntry(entry, root) && entry.command !== projectClaudeCommand(event))
110
+ return undefined;
111
+ const { args, commandWindows: _windows, ...rest } = entry;
112
+ if (engine)
113
+ return { ...rest, command: engine, args: engineArgs(root, event, 'claude') };
114
+ return { ...rest, command: projectClaudeCommand(Array.isArray(args) && typeof args[0] === 'string' ? args[0] : event) };
115
+ });
116
+ }
117
+ expected = JSON.stringify(next);
118
+ if (runtime === 'codex' && global)
119
+ codexKeys = codexHookTrustKeys(next, file, root);
120
+ return next;
121
+ } });
122
+ // Keys are positions in the file. Only once the write and its verify have
123
+ // succeeded do they point at HQ's entries; a failed write leaves the old
124
+ // file, where the same position can hold a foreign hook.
125
+ if (codexKeys)
126
+ result.codexUserKeys = codexKeys;
127
+ }
128
+ catch (error) {
129
+ result.failures.push(`${file}: ${error.message}`);
130
+ }
131
+ }
132
+ return result;
133
+ }
134
+ //# sourceMappingURL=reconcile.js.map
@@ -0,0 +1,17 @@
1
+ export declare function launcherEnabled(env?: NodeJS.ProcessEnv): boolean;
2
+ export declare function registrationEngine(home: string, env?: NodeJS.ProcessEnv): string | null;
3
+ export interface NativeTarget {
4
+ engine: string;
5
+ }
6
+ /**
7
+ * Root spelling passed to the launcher. A trailing separator is dropped because
8
+ * Windows PowerShell 5.1 re-quotes a native argument containing a space as
9
+ * "...", and a final backslash would then escape the closing quote.
10
+ */
11
+ export declare function launcherRoot(root: string): string;
12
+ export declare function engineArgs(root: string, event: string, runtime: 'claude' | 'codex', global?: boolean): string[];
13
+ /** Recognize only our pinned executable with the exact root argument. */
14
+ export declare function isNativeEntry(entry: Record<string, unknown>, root: string): boolean;
15
+ export declare function quotePosix(value: string): string;
16
+ export declare function quoteWindows(value: string): string;
17
+ //# sourceMappingURL=registration.d.ts.map
@@ -0,0 +1,50 @@
1
+ /** Offline, default-off registration gate. No network work on the hook path. */
2
+ import { readFlagSnapshotFile, readPersonFlagSnapshotFile } from '../flag-snapshot-file.js';
3
+ import { resolveProcessFlagCompanyContext } from '../process-flag-company-context.js';
4
+ import { installHookEngine } from './resolve.js';
5
+ export function launcherEnabled(env = process.env) {
6
+ const { companyUid: uid, companySlug } = resolveProcessFlagCompanyContext(env);
7
+ if (!uid && companySlug)
8
+ return false;
9
+ const snapshot = uid ? readFlagSnapshotFile(uid, { env }) : readPersonFlagSnapshotFile({ env });
10
+ return snapshot?.flags['hook-engine-launcher'] === true;
11
+ }
12
+ export function registrationEngine(home, env = process.env) {
13
+ if (!launcherEnabled({ ...env, HOME: home, USERPROFILE: home }))
14
+ return null;
15
+ return installHookEngine({ home });
16
+ }
17
+ /**
18
+ * Root spelling passed to the launcher. A trailing separator is dropped because
19
+ * Windows PowerShell 5.1 re-quotes a native argument containing a space as
20
+ * "...", and a final backslash would then escape the closing quote.
21
+ */
22
+ export function launcherRoot(root) {
23
+ const trimmed = root.replace(/[\\/]+$/, '');
24
+ return trimmed === '' || /^[A-Za-z]:$/.test(trimmed) ? root : trimmed;
25
+ }
26
+ export function engineArgs(root, event, runtime, global = false) {
27
+ return [event, '--hq-root', launcherRoot(root), '--runtime', runtime, ...(global && runtime === 'claude' ? ['--global'] : [])];
28
+ }
29
+ /** Recognize only our pinned executable with the exact root argument. */
30
+ export function isNativeEntry(entry, root) {
31
+ if (typeof entry.command !== 'string')
32
+ return false;
33
+ const pinned = /[\\/]\.hq[\\/]bin[\\/]hq-hook[\\/]\d+\.\d+\.\d+[^\\/]*[\\/]/;
34
+ if (!pinned.test(entry.command))
35
+ return false;
36
+ const spelling = launcherRoot(root);
37
+ if (Array.isArray(entry.args)) {
38
+ const i = entry.args.indexOf('--hq-root');
39
+ return i >= 0 && entry.args[i + 1] === spelling;
40
+ }
41
+ return entry.command.includes(quotePosix('--hq-root') + ' ' + quotePosix(spelling));
42
+ }
43
+ export function quotePosix(value) { return "'" + value.replace(/'/g, "'\\''") + "'"; }
44
+ export function quoteWindows(value) {
45
+ // Codex 0.162.1 uses its Windows PowerShell runtime shell for hooks.
46
+ // Single-quoted literals prevent variable, subexpression and wildcard expansion.
47
+ // PowerShell also closes them on the typographic quotes U+2018-U+201B.
48
+ return "'" + value.replace(/['‘’‚‛]/g, '$&$&') + "'";
49
+ }
50
+ //# sourceMappingURL=registration.js.map
@@ -1,3 +1,4 @@
1
+ import { type NativeTarget } from '../hook-engine/registration.js';
1
2
  /** Name of the HQ MCP server entry in ~/.claude.json. */
2
3
  export declare const HQ_MCP_SERVER_NAME = "hq";
3
4
  /** `_hqPack` provenance stamped on the HQ MCP server entry. */
@@ -21,7 +22,7 @@ export declare function masterHookPath(hqRoot: string): string;
21
22
  /** hqd hook shim (US-010): registered instead of master-hook.sh once hqd is installed. */
22
23
  export declare function shimPath(hqRoot: string): string;
23
24
  /** Which HQ hook script the user-scope install registers. */
24
- export type HookTarget = 'master' | 'shim';
25
+ export type HookTarget = 'master' | 'shim' | NativeTarget;
25
26
  export declare function charterPath(hqRoot: string): string;
26
27
  export declare function hqSkillsSource(hqRoot: string): string;
27
28
  /** The exact hook command registered for one event (absolute path, quoted). */
@@ -5,6 +5,7 @@
5
5
  * safe-write substrate.
6
6
  */
7
7
  import * as path from 'path';
8
+ import { engineArgs, isNativeEntry } from '../hook-engine/registration.js';
8
9
  /** Events master-hook.sh is registered for at user scope. */
9
10
  const GLOBAL_HOOK_EVENTS = [
10
11
  'SessionStart',
@@ -98,6 +99,8 @@ export function removeClaudeMdBlock(current) {
98
99
  function isHqHookEntry(entry, hqRoot) {
99
100
  if (!entry || typeof entry !== 'object')
100
101
  return false;
102
+ if (isNativeEntry(entry, hqRoot))
103
+ return true;
101
104
  const cmd = entry.command;
102
105
  if (typeof cmd !== 'string')
103
106
  return false;
@@ -111,13 +114,15 @@ export function addHqHooks(doc, hqRoot, target = 'master') {
111
114
  const hooks = { ...(doc.hooks ?? {}) };
112
115
  let changed = false;
113
116
  for (const event of GLOBAL_HOOK_EVENTS) {
114
- const want = hookCommand(hqRoot, event, target);
117
+ const want = typeof target === 'object'
118
+ ? { type: 'command', command: target.engine, args: engineArgs(hqRoot, event, 'claude', true), timeout: 300 }
119
+ : { type: 'command', command: hookCommand(hqRoot, event, target), timeout: 300 };
115
120
  let groups = Array.isArray(hooks[event]) ? [...hooks[event]] : [];
116
- const present = groups.some((g) => Array.isArray(g?.hooks) && g.hooks.some((h) => h.command === want));
121
+ const present = groups.some((g) => Array.isArray(g?.hooks) && g.hooks.some((h) => h.command === want.command && JSON.stringify(h.args) === JSON.stringify('args' in want ? want.args : undefined)));
117
122
  if (present)
118
123
  continue;
119
124
  groups = removeHqHooks({ hooks: { [event]: groups } }, hqRoot).hooks?.[event] ?? [];
120
- groups.push({ hooks: [{ type: 'command', command: want, timeout: 300 }] });
125
+ groups.push({ hooks: [want] });
121
126
  hooks[event] = groups;
122
127
  changed = true;
123
128
  }