@indigoai-us/hq-cli 5.108.10 → 5.108.11

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,8 +2,24 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.108.11] — 2026-09-05
6
+
5
7
  ## [5.108.10] — 2026-09-05
6
8
 
9
+ ### Fixed
10
+
11
+ - `hq doctor` now self-attests hook enforcement on agents-v2 (hermes) fleet
12
+ hosts, which host detection leaves platform-unknown. When the runtime is
13
+ agents-v2 (the runtime marker reports `agents-v2`, or the on-box hook adapter
14
+ is installed under the tree), `.claude/settings.json` wires that on-box
15
+ adapter, and a policy-trigger ledger evidences a live turn (the exact
16
+ session's ledger under `--session-id`, otherwise any ledger fresh within the
17
+ freshness window), the runtime probe reports platform `agents-v2` and PASS
18
+ instead of UNKNOWN — because the on-box adapter provably wrote the ledger
19
+ through the same `.claude` hooks. This is the TypeScript twin of
20
+ `agents_v2_attested` in `check-hq-hooks.sh`; all three signals are required, so
21
+ no non-agents-v2 host's verdict changes.
22
+
7
23
  ## [5.108.9] — 2026-09-05
8
24
 
9
25
  ## [5.108.8] — 2026-09-05
@@ -129,11 +129,36 @@ export interface AgentsV2AttestationOptions {
129
129
  */
130
130
  export declare function isAgentsV2Runtime(hqRoot: string, env?: NodeJS.ProcessEnv): boolean;
131
131
  /**
132
- * Whether `.claude/settings.json` wires the on-box agents-v2 hook adapter a
133
- * raw substring match on the file, exactly like the shell's
134
- * `grep -q 'hq-agents-v2-hook-adapter\.sh'` in `hq_settings_wires_v2_adapter`.
132
+ * Env var overriding the agents-v2 runtime (hermes) config path (chiefly for
133
+ * tests), mirroring `HQ_RUNTIME_MARKER_FILE` and the shell's
134
+ * `HQ_HERMES_CONFIG_FILE`.
135
135
  */
136
- export declare function settingsWireV2Adapter(hqRoot: string): boolean;
136
+ export declare const HQ_HERMES_CONFIG_ENV = "HQ_HERMES_CONFIG_FILE";
137
+ /**
138
+ * The agents-v2 runtime (hermes) config whose `hooks:` block wires the on-box
139
+ * adapter into every lifecycle event — the env override, else `~/.hermes/config.yaml`
140
+ * (where `provision/render-config.sh` renders it). Mirrors the shell's
141
+ * `HQ_HERMES_CONFIG_FILE="${HQ_HERMES_CONFIG_FILE:-$HOME/.hermes/config.yaml}"`.
142
+ */
143
+ export declare function resolveHermesConfigPath(env?: NodeJS.ProcessEnv): string;
144
+ /**
145
+ * Whether the agents-v2 runtime actually wires the on-box hook adapter.
146
+ *
147
+ * The wiring lives in the RUNTIME's hook config, not `.claude/settings.json`. On
148
+ * a real box the v2 runtime's shell-hook dispatcher (`agent/shell_hooks.py`)
149
+ * reads a `hooks:` block from `~/.hermes/config.yaml` with one entry per
150
+ * lifecycle event, each invoking `hq-agents-v2-hook-adapter.sh`; the adapter in
151
+ * turn READS `.claude/settings.json` (via `hook-adapter-core.sh`) to fan out to
152
+ * the classic `.claude/hooks` set. So `.claude/settings.json` never NAMES the
153
+ * adapter — it wires the classic `hook-gate.sh` hooks — and grepping it for the
154
+ * adapter always fails on a real box (verified on the v2.17 canary
155
+ * i-0277243ad3aed8109, 2026-09-05: settings.json had 0 adapter refs / 94
156
+ * hook-gate.sh refs, while ~/.hermes/config.yaml wired the adapter across 7
157
+ * events). Require BOTH the adapter installed under the tree at
158
+ * {@link AGENTS_V2_ADAPTER_RELPATH} AND the runtime config invoking it. Mirrors
159
+ * `hq_runtime_config_wires_v2_adapter` in `check-hq-hooks.sh`.
160
+ */
161
+ export declare function runtimeConfigWiresV2Adapter(hqRoot: string, env?: NodeJS.ProcessEnv): boolean;
137
162
  /**
138
163
  * Whether a policy-trigger ledger evidencing a live agents-v2 turn is present:
139
164
  * the exact session's ledger when a session id is given (session identity
@@ -38,6 +38,7 @@
38
38
  * very evidence it is looking for.
39
39
  */
40
40
  import * as fs from "node:fs";
41
+ import * as os from "node:os";
41
42
  import * as path from "node:path";
42
43
  import { scanHookCommand } from "./claude-wiring.js";
43
44
  /** Common id prefix for every result the runtime probe emits. */
@@ -160,14 +161,47 @@ function readRuntimeMarkerMode(markerPath) {
160
161
  }
161
162
  }
162
163
  /**
163
- * Whether `.claude/settings.json` wires the on-box agents-v2 hook adapter a
164
- * raw substring match on the file, exactly like the shell's
165
- * `grep -q 'hq-agents-v2-hook-adapter\.sh'` in `hq_settings_wires_v2_adapter`.
164
+ * Env var overriding the agents-v2 runtime (hermes) config path (chiefly for
165
+ * tests), mirroring `HQ_RUNTIME_MARKER_FILE` and the shell's
166
+ * `HQ_HERMES_CONFIG_FILE`.
166
167
  */
167
- export function settingsWireV2Adapter(hqRoot) {
168
+ export const HQ_HERMES_CONFIG_ENV = "HQ_HERMES_CONFIG_FILE";
169
+ /**
170
+ * The agents-v2 runtime (hermes) config whose `hooks:` block wires the on-box
171
+ * adapter into every lifecycle event — the env override, else `~/.hermes/config.yaml`
172
+ * (where `provision/render-config.sh` renders it). Mirrors the shell's
173
+ * `HQ_HERMES_CONFIG_FILE="${HQ_HERMES_CONFIG_FILE:-$HOME/.hermes/config.yaml}"`.
174
+ */
175
+ export function resolveHermesConfigPath(env = process.env) {
176
+ const override = env[HQ_HERMES_CONFIG_ENV]?.trim();
177
+ if (override)
178
+ return override;
179
+ return path.join(os.homedir(), ".hermes", "config.yaml");
180
+ }
181
+ /**
182
+ * Whether the agents-v2 runtime actually wires the on-box hook adapter.
183
+ *
184
+ * The wiring lives in the RUNTIME's hook config, not `.claude/settings.json`. On
185
+ * a real box the v2 runtime's shell-hook dispatcher (`agent/shell_hooks.py`)
186
+ * reads a `hooks:` block from `~/.hermes/config.yaml` with one entry per
187
+ * lifecycle event, each invoking `hq-agents-v2-hook-adapter.sh`; the adapter in
188
+ * turn READS `.claude/settings.json` (via `hook-adapter-core.sh`) to fan out to
189
+ * the classic `.claude/hooks` set. So `.claude/settings.json` never NAMES the
190
+ * adapter — it wires the classic `hook-gate.sh` hooks — and grepping it for the
191
+ * adapter always fails on a real box (verified on the v2.17 canary
192
+ * i-0277243ad3aed8109, 2026-09-05: settings.json had 0 adapter refs / 94
193
+ * hook-gate.sh refs, while ~/.hermes/config.yaml wired the adapter across 7
194
+ * events). Require BOTH the adapter installed under the tree at
195
+ * {@link AGENTS_V2_ADAPTER_RELPATH} AND the runtime config invoking it. Mirrors
196
+ * `hq_runtime_config_wires_v2_adapter` in `check-hq-hooks.sh`.
197
+ */
198
+ export function runtimeConfigWiresV2Adapter(hqRoot, env = process.env) {
199
+ const adapter = path.join(hqRoot, ...AGENTS_V2_ADAPTER_RELPATH.split("/"));
200
+ if (!isFile(adapter))
201
+ return false;
168
202
  let raw;
169
203
  try {
170
- raw = fs.readFileSync(path.join(hqRoot, ".claude", "settings.json"), "utf8");
204
+ raw = fs.readFileSync(resolveHermesConfigPath(env), "utf8");
171
205
  }
172
206
  catch {
173
207
  return false;
@@ -237,7 +271,7 @@ function ledgerDirHasFreshTxt(dir, cutoffMs) {
237
271
  export function agentsV2Attested(opts) {
238
272
  const env = opts.env ?? process.env;
239
273
  return (isAgentsV2Runtime(opts.hqRoot, env) &&
240
- settingsWireV2Adapter(opts.hqRoot) &&
274
+ runtimeConfigWiresV2Adapter(opts.hqRoot, env) &&
241
275
  v2LedgerPresent({ ...opts, env }));
242
276
  }
243
277
  /**
@@ -256,7 +290,7 @@ export function checkRuntimeProbe(context) {
256
290
  // platform-unknown and, on an unknown host, the probe would report UNKNOWN
257
291
  // below. But the on-box adapter provably wrote the ledger through the same
258
292
  // .claude hooks, so grant PASS — and report platform "agents-v2" — when, and
259
- // only when, the runtime is agents-v2, settings wire the on-box adapter, and a
293
+ // only when, the runtime is agents-v2, the runtime config wires the on-box adapter, and a
260
294
  // ledger exists (the exact session's under --session-id; otherwise any ledger
261
295
  // fresh within the window). Requires the on-box marker/adapter, so this never
262
296
  // changes the verdict for any other host. See agentsV2Attested().
@@ -60,7 +60,7 @@ import { spawnSync } from "node:child_process";
60
60
  import semver from "semver";
61
61
  import chalk from "chalk";
62
62
  import { CLI_NAME, CLI_VERSION } from "../cli-version.js";
63
- import { buildBunInstallArgv, buildPnpmInstallArgv, buildPrefixedInstallArgv, buildSpawnPlan, checkUpdateConvergence, inOwnProcessGroup, isLocalDependencyInstall, openInstallOutput, pnpmUpdateEnv, resolveRunningInstall, runUpdateCommand, } from "./version-gate.js";
63
+ import { buildBunInstallArgv, buildPnpmInstallArgv, buildPrefixedInstallArgv, buildSpawnPlan, checkUpdateConvergence, inOwnProcessGroup, isLocalDependencyInstall, isPrefixWritable, nonWritablePrefixNote, openInstallOutput, pnpmUpdateEnv, resolveRunningInstall, runUpdateCommand, } from "./version-gate.js";
64
64
  import { acquireUpdateLock as acquireSharedUpdateLock } from "./update-lock.js";
65
65
  import { markLatestIneffective } from "./version-check.js";
66
66
  /**
@@ -268,6 +268,13 @@ async function updateAndReexec(argv, flavor, known, deps) {
268
268
  console.error(chalk.yellow(`⚠ hq-cli ${latest} is available but the update failed` +
269
269
  `${result.detail ? `: ${result.detail}` : ""}`));
270
270
  console.error(chalk.dim(` Try manually: ${plan.cmd} ${plan.args.join(" ")}`));
271
+ // A root-owned npm prefix the running user cannot write is the agent-box
272
+ // case (the CLI is a /usr global install and the runtime is unprivileged),
273
+ // and the startup path has no sudo fallback — say so plainly so a failed
274
+ // update on a box reads as the permission wall it is, not a transient error.
275
+ if (install.manager === "npm" && install.prefix && !isPrefixWritable(install.prefix)) {
276
+ console.error(chalk.yellow(` ${nonWritablePrefixNote(install.prefix)}`));
277
+ }
271
278
  // A manager-level failure often means the install layout itself is broken
272
279
  // (e.g. a hand-rolled pnpm store nested inside the app's bin dir). The
273
280
  // manual retry above hits the same layout and fails the same way; point at
@@ -150,6 +150,30 @@ export declare function resolveRunningInstall(): RunningInstall;
150
150
  export declare function resolveRunningManager(): InstallManager;
151
151
  /** Convenience view of {@link resolveRunningInstall} for callers needing one field. */
152
152
  export declare function resolveRunningPrefix(): string | null;
153
+ /**
154
+ * Whether the current process can write the npm global `prefix` — i.e. whether
155
+ * an `npm install -g --prefix <prefix>` could actually replace the installed
156
+ * CLI, or would fail with EACCES.
157
+ *
158
+ * The path npm rewrites is the prefix's `bin` dir (`<prefix>/bin/hq` on unix
159
+ * globals — the `rename /usr/bin/hq` EACCES the agent boxes hit), so that is
160
+ * checked first; the prefix itself is the fallback for `--prefix` layouts that
161
+ * keep the bin beside `node_modules`. A missing dir (ENOENT) is treated as
162
+ * writable: npm would create it, and this check exists to explain a permission
163
+ * wall, not to second-guess a not-yet-created prefix.
164
+ *
165
+ * `access` is injected so the classification is unit-testable without a real
166
+ * root-owned prefix.
167
+ */
168
+ export declare function isPrefixWritable(prefix: string, access?: (target: string, mode: number) => void): boolean;
169
+ /**
170
+ * One-line operator explanation for a failed global update whose prefix the
171
+ * running user cannot write. This is the agent-box case: the CLI is a
172
+ * root-owned `/usr` global install and the runtime is unprivileged, so the
173
+ * update genuinely cannot converge from here and re-trying it silently would
174
+ * loop. Says so plainly and points at the paths that CAN update it.
175
+ */
176
+ export declare function nonWritablePrefixNote(prefix: string): string;
153
177
  /**
154
178
  * Derive `PNPM_HOME` from a pnpm-managed install's own path. pnpm resolves its
155
179
  * global bin directory from `PNPM_HOME` (or an explicit `global-bin-dir`), and
@@ -409,6 +433,8 @@ export declare const __test__: {
409
433
  isNewerVersion: typeof isNewerVersion;
410
434
  isPnpmManagedPackageDir: typeof isPnpmManagedPackageDir;
411
435
  isPnpmVirtualStorePackageDir: typeof isPnpmVirtualStorePackageDir;
436
+ isPrefixWritable: typeof isPrefixWritable;
437
+ nonWritablePrefixNote: typeof nonWritablePrefixNote;
412
438
  npmPrefixFromPackageDir: typeof npmPrefixFromPackageDir;
413
439
  nudgeUpdateRecommended: typeof nudgeUpdateRecommended;
414
440
  performUpdate: typeof performUpdate;
@@ -29,7 +29,7 @@
29
29
  */
30
30
  import { spawnSync } from "node:child_process";
31
31
  import { buildSpawnPlan, quoteForWindowsShell } from "./windows-spawn.js";
32
- import { closeSync, existsSync, mkdtempSync, openSync, readdirSync, readFileSync, rmSync, } from "node:fs";
32
+ import { accessSync, closeSync, constants as fsConstants, existsSync, mkdtempSync, openSync, readdirSync, readFileSync, rmSync, } from "node:fs";
33
33
  import os from "node:os";
34
34
  import path from "node:path";
35
35
  import { fileURLToPath } from "node:url";
@@ -277,6 +277,50 @@ export function resolveRunningManager() {
277
277
  export function resolveRunningPrefix() {
278
278
  return resolveRunningInstall().prefix;
279
279
  }
280
+ /**
281
+ * Whether the current process can write the npm global `prefix` — i.e. whether
282
+ * an `npm install -g --prefix <prefix>` could actually replace the installed
283
+ * CLI, or would fail with EACCES.
284
+ *
285
+ * The path npm rewrites is the prefix's `bin` dir (`<prefix>/bin/hq` on unix
286
+ * globals — the `rename /usr/bin/hq` EACCES the agent boxes hit), so that is
287
+ * checked first; the prefix itself is the fallback for `--prefix` layouts that
288
+ * keep the bin beside `node_modules`. A missing dir (ENOENT) is treated as
289
+ * writable: npm would create it, and this check exists to explain a permission
290
+ * wall, not to second-guess a not-yet-created prefix.
291
+ *
292
+ * `access` is injected so the classification is unit-testable without a real
293
+ * root-owned prefix.
294
+ */
295
+ export function isPrefixWritable(prefix, access = (target, mode) => accessSync(target, mode)) {
296
+ for (const dir of [path.join(prefix, "bin"), prefix]) {
297
+ try {
298
+ access(dir, fsConstants.W_OK);
299
+ return true;
300
+ }
301
+ catch (err) {
302
+ // A dir that does not exist yet is not a permission wall — npm creates it.
303
+ if (err?.code === "ENOENT") {
304
+ return true;
305
+ }
306
+ // Any other error (EACCES/EPERM/EROFS) on this candidate: try the next.
307
+ }
308
+ }
309
+ return false;
310
+ }
311
+ /**
312
+ * One-line operator explanation for a failed global update whose prefix the
313
+ * running user cannot write. This is the agent-box case: the CLI is a
314
+ * root-owned `/usr` global install and the runtime is unprivileged, so the
315
+ * update genuinely cannot converge from here and re-trying it silently would
316
+ * loop. Says so plainly and points at the paths that CAN update it.
317
+ */
318
+ export function nonWritablePrefixNote(prefix) {
319
+ return (`The npm global prefix ${prefix} is not writable by the current user, so this update cannot ` +
320
+ `take effect here. On an agent box this is expected: the CLI is a root-owned global install and ` +
321
+ `updates land as root — via the box's hq-cli-update timer, or \`sudo npm install -g ${CLI_NAME}@latest\` — ` +
322
+ `not from the unprivileged runtime.`);
323
+ }
280
324
  /**
281
325
  * Derive `PNPM_HOME` from a pnpm-managed install's own path. pnpm resolves its
282
326
  * global bin directory from `PNPM_HOME` (or an explicit `global-bin-dir`), and
@@ -907,6 +951,13 @@ function attemptRequiredUpdate(decision, deps, install) {
907
951
  }
908
952
  if (!result.ok) {
909
953
  console.error(chalk.red(`✗ Update failed${result.detail ? `: ${result.detail}` : ""}.`));
954
+ // A root-owned global prefix the running user cannot write is the agent-box
955
+ // case: even the `sudo -n` retry above cannot help without passwordless
956
+ // sudo, so name the wall plainly rather than leaving `exit 75` to read as a
957
+ // generic failure. Only for npm-prefix installs (pnpm/Bun route elsewhere).
958
+ if (!isManagedOutsideNpm && prefix && !isPrefixWritable(prefix)) {
959
+ console.error(chalk.yellow(` ${nonWritablePrefixNote(prefix)}`));
960
+ }
910
961
  // The package manager itself is missing from this environment — the usual
911
962
  // cause is a minimal-PATH parent (launchd, cron, a bare systemd unit) that
912
963
  // never sourced the shell profile which puts PNPM_HOME (or nvm's npm) on
@@ -996,6 +1047,8 @@ export const __test__ = {
996
1047
  isNewerVersion,
997
1048
  isPnpmManagedPackageDir,
998
1049
  isPnpmVirtualStorePackageDir,
1050
+ isPrefixWritable,
1051
+ nonWritablePrefixNote,
999
1052
  npmPrefixFromPackageDir,
1000
1053
  nudgeUpdateRecommended,
1001
1054
  performUpdate,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.108.10",
3
+ "version": "5.108.11",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {