@indigoai-us/hq-cli 5.345.78 → 5.345.80

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,28 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.345.80] — 2026-10-11
6
+ - The version gate no longer treats a pnpm global install that Node resolved
7
+ into `store/v11/links` as a local dependency, so it can self-update instead
8
+ of blocking commands such as `hq secrets exec`. First-party `hq-cli`
9
+ self-updates skip the 24h package-age quarantine; if that guard still
10
+ refuses the required build, the current CLI keeps running rather than
11
+ locking you out for the rest of the day.
12
+ - `hq doctor --fix` now refreshes a stale personal-files sync journal with
13
+ the same keep-conflicts pull already used for company journals. An old
14
+ leftover personal journal beside the current one is no longer reported
15
+ as an unrepaired health check.
16
+
17
+ ## [5.345.79] — 2026-10-11
18
+ - `hq lanes` no longer fails every list, message, or admission command when
19
+ one lane still has leftover queue details from an older worker-profile wait.
20
+ Those leftover details are dropped on read, cleared when the queue reason
21
+ changes, and one unreadable lane is skipped so the rest of the host stays
22
+ usable.
23
+ - `hq doctor --fix` still refreshes a stale company sync journal when
24
+ `.claude/`, `.codex/`, or `.grok/` has uncommitted changes. Those dirty
25
+ files still block hook-file repairs unless you pass `--force`.
26
+
5
27
  ## [5.345.78] — 2026-10-10
6
28
  - `hq lanes` no longer fails every list, message, or admission command when
7
29
  one lane still has leftover queue details from an older worker-profile wait.
@@ -109,6 +109,8 @@ export interface SyncHealthDeps {
109
109
  export declare function isSafeCompanySlug(slug: string): boolean;
110
110
  /** True when this journal slug names a company rather than the personal tree. */
111
111
  export declare function isCompanyJournalSlug(slug: string): boolean;
112
+ /** True when this journal slug names the personal tree or personal vault. */
113
+ export declare function isPersonalJournalSlug(slug: string): boolean;
112
114
  /** Company slug from a `sync.journal.<slug>` check id, or null if not one. */
113
115
  export declare function journalSlugFromCheckId(checkId: string): string | null;
114
116
  /** The versions/sync check family. Registered in `createDefaultRegistry`. */
@@ -206,6 +206,10 @@ function journalSlugForCheckId(slug) {
206
206
  export function isCompanyJournalSlug(slug) {
207
207
  return !NON_COMPANY_JOURNAL_SLUGS.has(slug);
208
208
  }
209
+ /** True when this journal slug names the personal tree or personal vault. */
210
+ export function isPersonalJournalSlug(slug) {
211
+ return NON_COMPANY_JOURNAL_SLUGS.has(slug);
212
+ }
209
213
  /** Company slug from a `sync.journal.<slug>` check id, or null if not one. */
210
214
  export function journalSlugFromCheckId(checkId) {
211
215
  const match = /^sync\.journal\.(.+)$/.exec(checkId);
@@ -390,9 +394,24 @@ function journalAndManifestResults(deps) {
390
394
  }
391
395
  function journalResults(deps, journals) {
392
396
  const nowMs = deps.now().getTime();
397
+ const hasPersonalVaultJournal = journals.some((entry) => entry.slug === PERSONAL_VAULT_SCOPE_SLUG);
393
398
  return journals.map((entry) => {
394
399
  const lastSync = entry.journal?.lastSync;
395
400
  const checkId = `sync.journal.${journalSlugForCheckId(entry.slug)}`;
401
+ // The legacy `personal` shard and `__hq_personal_vault__` name the same
402
+ // tree. Once the vault journal exists, the old file is leftover
403
+ // bookkeeping: WARN would make `hq doctor --fix` look like it failed to
404
+ // repair a row it cannot refresh (a personal pull writes the vault
405
+ // journal, not this one).
406
+ if (entry.slug === PERSONAL_SCOPE_SLUG && hasPersonalVaultJournal) {
407
+ return {
408
+ status: "NA",
409
+ checkId,
410
+ reasonCode: "legacy-personal-journal",
411
+ target: entry.path,
412
+ message: "Sync journal 'personal' is unused on this machine — personal files now sync through the personal vault journal, so this leftover shard is not a live sync target.",
413
+ };
414
+ }
396
415
  // A company journal without a local tree is leftover membership state,
397
416
  // not a live sync target. WARN would make `hq doctor --fix` look like it
398
417
  // failed to repair something it was never going to touch (US-015 health
@@ -5,14 +5,18 @@
5
5
  * wrapped in guardrails that are NOT polish — each is a PRD acceptance criterion:
6
6
  *
7
7
  * 1. Dirty-tree refusal. If the working tree has uncommitted changes under
8
- * `.claude/`, `.codex/`, or `.grok/`, `--fix` refuses and exits non-zero
9
- * unless `--force`, so a repair can never be tangled up with unrelated
10
- * in-flight edits to the security layer.
8
+ * `.claude/`, `.codex/`, or `.grok/`, `--fix` refuses hook-file repairs
9
+ * and exits non-zero unless `--force`, so those writes can never be
10
+ * tangled up with unrelated in-flight edits to the security layer.
11
+ * Repairs that do not write those trees (a stale-company `refresh-sync`
12
+ * pull) still run: the SessionStart health hook never passes `--force`,
13
+ * and blocking the pull left `sync.journal.<slug>` WARN after `--fix`.
11
14
  * 2. Allowlist only. It repairs the classified safe classes — restore an
12
15
  * execute bit, add a hook id to the gate profiles it is missing from,
13
- * re-register a script present on disk, or pull a company whose local
14
- * tree exists but whose journal is stale — and NEVER rewrites a hook body
15
- * or deletes a file. Classification is owned by {@link deriveRemediation};
16
+ * re-register a script present on disk, or pull a company or personal
17
+ * vault whose local tree exists but whose journal is stale — and NEVER
18
+ * rewrites a hook body or deletes a file. Classification is owned by
19
+ * {@link deriveRemediation};
16
20
  * a content-drift finding is manual-only and simply never appears in the
17
21
  * fixable set.
18
22
  * 3. Preview + confirmation. Every change is shown diff-style and requires
@@ -67,8 +71,9 @@ export interface ApplyFixesOptions {
67
71
  runChecks?: (hqRoot: string) => Promise<CheckResult[]>;
68
72
  /**
69
73
  * Targeted pull used by the `refresh-sync` class. Default shells out to
70
- * `hq sync pull --company <slug> --on-conflict keep`. Tests inject a stub
71
- * so `--fix` never hits the network.
74
+ * `hq sync pull --company <slug> --on-conflict keep`, or
75
+ * `hq sync pull --personal --on-conflict keep` for personal journals.
76
+ * Tests inject a stub so `--fix` never hits the network.
72
77
  */
73
78
  refreshSync?: (target: RefreshSyncTarget) => Promise<RefreshSyncResult>;
74
79
  /**
@@ -81,12 +86,14 @@ export interface ApplyFixesOptions {
81
86
  to: string;
82
87
  } | null;
83
88
  }
84
- /** One company the `refresh-sync` class will pull. */
89
+ /** One company or personal vault the `refresh-sync` class will pull. */
85
90
  export interface RefreshSyncTarget {
86
- /** Company slug from the `sync.journal.<slug>` check id. */
91
+ /** Company slug, or `personal` for the personal vault. */
87
92
  slug: string;
88
93
  /** HQ root the pull writes into. */
89
94
  hqRoot: string;
95
+ /** Pull the personal vault instead of `companies/<slug>`. */
96
+ personal?: boolean;
90
97
  }
91
98
  /** Outcome of one {@link ApplyFixesOptions.refreshSync} call. */
92
99
  export interface RefreshSyncResult {
@@ -157,9 +164,9 @@ export interface ApplyFixesResult {
157
164
  export declare function applyFixes(options: ApplyFixesOptions): Promise<ApplyFixesResult>;
158
165
  /**
159
166
  * Default `refresh-sync` repair: a bounded, pull-only, keep-conflicts
160
- * `hq sync pull --company <slug>` against the tree `--fix` is repairing.
161
- * Never a push, never interactive. A timeout or non-zero exit is reported
162
- * rather than thrown so later plans still run.
167
+ * `hq sync pull --company <slug>` (or `--personal`) against the tree
168
+ * `--fix` is repairing. Never a push, never interactive. A timeout or
169
+ * non-zero exit is reported rather than thrown so later plans still run.
163
170
  */
164
171
  export declare function defaultRefreshSync(target: RefreshSyncTarget): Promise<RefreshSyncResult>;
165
172
  /**
@@ -5,14 +5,18 @@
5
5
  * wrapped in guardrails that are NOT polish — each is a PRD acceptance criterion:
6
6
  *
7
7
  * 1. Dirty-tree refusal. If the working tree has uncommitted changes under
8
- * `.claude/`, `.codex/`, or `.grok/`, `--fix` refuses and exits non-zero
9
- * unless `--force`, so a repair can never be tangled up with unrelated
10
- * in-flight edits to the security layer.
8
+ * `.claude/`, `.codex/`, or `.grok/`, `--fix` refuses hook-file repairs
9
+ * and exits non-zero unless `--force`, so those writes can never be
10
+ * tangled up with unrelated in-flight edits to the security layer.
11
+ * Repairs that do not write those trees (a stale-company `refresh-sync`
12
+ * pull) still run: the SessionStart health hook never passes `--force`,
13
+ * and blocking the pull left `sync.journal.<slug>` WARN after `--fix`.
11
14
  * 2. Allowlist only. It repairs the classified safe classes — restore an
12
15
  * execute bit, add a hook id to the gate profiles it is missing from,
13
- * re-register a script present on disk, or pull a company whose local
14
- * tree exists but whose journal is stale — and NEVER rewrites a hook body
15
- * or deletes a file. Classification is owned by {@link deriveRemediation};
16
+ * re-register a script present on disk, or pull a company or personal
17
+ * vault whose local tree exists but whose journal is stale — and NEVER
18
+ * rewrites a hook body or deletes a file. Classification is owned by
19
+ * {@link deriveRemediation};
16
20
  * a content-drift finding is manual-only and simply never appears in the
17
21
  * fixable set.
18
22
  * 3. Preview + confirmation. Every change is shown diff-style and requires
@@ -31,6 +35,7 @@
31
35
  import { spawnSync } from "node:child_process";
32
36
  import * as fs from "node:fs";
33
37
  import * as path from "node:path";
38
+ import { isPersonalJournalSlug } from "../checks/sync-health.js";
34
39
  import { createDefaultRegistry } from "../registry.js";
35
40
  import { flattenFamilies } from "../report.js";
36
41
  import { gateMembership, parseHookGateProfiles, } from "../hook-gate-profiles.js";
@@ -107,20 +112,34 @@ export async function applyFixes(options) {
107
112
  ...over,
108
113
  });
109
114
  // 1. Dirty-tree refusal (AC8). Never write over uncommitted security-layer
110
- // edits unless the user forces it.
111
- if (!options.force) {
112
- const dirty = (options.dirtyCheck ?? uncommittedHookConfigChanges)(hqRoot);
113
- if (dirty.length > 0) {
114
- writeErr(`hq doctor --fix: refusing to run — uncommitted changes under ` +
115
- `${HOOK_CONFIG_DIRS.join(", ")}:\n` +
116
- dirty.map((line) => ` ${line}`).join("\n") +
117
- `\nCommit or stash them, or re-run with --force.\n`);
118
- return empty({ exitCode: 1, refused: "dirty-tree" });
119
- }
120
- }
115
+ // edits unless the user forces it. Probe once; hook-file plans are
116
+ // dropped per pass so a stale-journal pull can still run.
117
+ const dirty = options.force
118
+ ? []
119
+ : (options.dirtyCheck ?? uncommittedHookConfigChanges)(hqRoot);
120
+ const refuseDirtyHookWrites = (plans) => {
121
+ if (dirty.length === 0)
122
+ return plans;
123
+ const allowed = plans.filter((plan) => plan.relpath.length === 0);
124
+ if (allowed.length === plans.length)
125
+ return plans;
126
+ if (allowed.length === 0)
127
+ return "refuse";
128
+ writeErr(`hq doctor --fix: skipping hook-file repairs — uncommitted changes under ` +
129
+ `${HOOK_CONFIG_DIRS.join(", ")}. Refreshing sync journals still runs; ` +
130
+ `re-run with --force to repair hook files.\n`);
131
+ return allowed;
132
+ };
133
+ const refuseDirtyTree = () => {
134
+ writeErr(`hq doctor --fix: refusing to run — uncommitted changes under ` +
135
+ `${HOOK_CONFIG_DIRS.join(", ")}:\n` +
136
+ dirty.map((line) => ` ${line}`).join("\n") +
137
+ `\nCommit or stash them, or re-run with --force.\n`);
138
+ return empty({ exitCode: 1, refused: "dirty-tree" });
139
+ };
121
140
  // Resolve the customer-visible manual-only output gate once per fix run.
122
- // Keep it behind the dirty-tree guard, and fail open to the pre-change
123
- // output if the identity is absent or the registry lookup fails.
141
+ // Fail open to the pre-change output if the identity is absent or the
142
+ // registry lookup fails.
124
143
  let showManualOnlyRemediations = false;
125
144
  try {
126
145
  showManualOnlyRemediations =
@@ -165,14 +184,29 @@ export async function applyFixes(options) {
165
184
  keysThisPass.add(key);
166
185
  plans.push(plan);
167
186
  }
168
- const refreshPlans = rotateRefreshSyncPlans(plans.filter((plan) => plan.fixClass === "refresh-sync"), lastAttemptedRefreshSyncSlug).slice(0, Math.max(0, MAX_REFRESH_SYNC_PLANS_PER_RUN - refreshSyncAppliedThisRun));
187
+ const seenRefreshTargets = new Set();
188
+ const refreshPlans = rotateRefreshSyncPlans(plans.filter((plan) => plan.fixClass === "refresh-sync"), lastAttemptedRefreshSyncSlug).filter((plan) => {
189
+ if (seenRefreshTargets.has(plan.target))
190
+ return false;
191
+ seenRefreshTargets.add(plan.target);
192
+ return true;
193
+ }).slice(0, Math.max(0, MAX_REFRESH_SYNC_PLANS_PER_RUN - refreshSyncAppliedThisRun));
169
194
  return [
170
195
  ...plans.filter((plan) => plan.fixClass !== "refresh-sync"),
171
196
  ...refreshPlans,
172
197
  ];
173
198
  };
174
199
  for (let pass = 1; pass <= MAX_FIX_PASSES; pass += 1) {
175
- const plans = buildPlans(findings);
200
+ const plannedThisPass = refuseDirtyHookWrites(buildPlans(findings));
201
+ if (plannedThisPass === "refuse") {
202
+ if (planned.length === 0)
203
+ return refuseDirtyTree();
204
+ write(`Pass ${pass}/${MAX_FIX_PASSES}: remaining repairs write hook files; ` +
205
+ `stopped because the tree is dirty.\n`);
206
+ refused = "dirty-tree";
207
+ break;
208
+ }
209
+ const plans = plannedThisPass;
176
210
  if (plans.length === 0)
177
211
  break;
178
212
  fixableCount += plans.length;
@@ -304,19 +338,21 @@ async function defaultRunChecks(hqRoot) {
304
338
  }
305
339
  /**
306
340
  * Default `refresh-sync` repair: a bounded, pull-only, keep-conflicts
307
- * `hq sync pull --company <slug>` against the tree `--fix` is repairing.
308
- * Never a push, never interactive. A timeout or non-zero exit is reported
309
- * rather than thrown so later plans still run.
341
+ * `hq sync pull --company <slug>` (or `--personal`) against the tree
342
+ * `--fix` is repairing. Never a push, never interactive. A timeout or
343
+ * non-zero exit is reported rather than thrown so later plans still run.
310
344
  */
311
345
  export function defaultRefreshSync(target) {
312
346
  const hqBin = process.argv[1];
347
+ const selector = target.personal || isPersonalJournalSlug(target.slug)
348
+ ? ["--personal"]
349
+ : ["--company", target.slug];
313
350
  const args = hqBin
314
351
  ? [
315
352
  hqBin,
316
353
  "sync",
317
354
  "pull",
318
- "--company",
319
- target.slug,
355
+ ...selector,
320
356
  "--hq-root",
321
357
  target.hqRoot,
322
358
  "--on-conflict",
@@ -327,8 +363,7 @@ export function defaultRefreshSync(target) {
327
363
  : [
328
364
  "sync",
329
365
  "pull",
330
- "--company",
331
- target.slug,
366
+ ...selector,
332
367
  "--hq-root",
333
368
  target.hqRoot,
334
369
  "--on-conflict",
@@ -445,23 +480,28 @@ export function defaultRewriteBotNodePath(name) {
445
480
  return null;
446
481
  return rewriteBotDaemonNodePath(buildBotDaemonPaths({ name, botDir: dir, hqRoot: config.hqRoot, hqBinary: "hq" }), { platform: "darwin" });
447
482
  }
448
- /** Pull one company to refresh a stale or never-synced journal. */
483
+ /** Pull one company or the personal vault to refresh a stale journal. */
449
484
  function planRefreshSync(hqRoot, result, rem, refreshSync) {
450
485
  const slug = rem.fixTarget;
451
486
  if (!slug)
452
487
  return null;
488
+ const personal = isPersonalJournalSlug(slug);
453
489
  let outcome;
454
490
  return {
455
491
  checkId: result.checkId,
456
492
  fixClass: "refresh-sync",
457
493
  target: slug,
458
494
  relpath: "",
459
- preview: ` companies/${slug}\n` +
460
- ` ~ hq sync pull --company ${slug} --on-conflict keep\n` +
461
- ` refresh the stale sync journal (keeps local conflict copies)`,
495
+ preview: personal
496
+ ? ` personal vault\n` +
497
+ ` ~ hq sync pull --personal --on-conflict keep\n` +
498
+ ` refresh the stale personal sync journal (keeps local conflict copies)`
499
+ : ` companies/${slug}\n` +
500
+ ` ~ hq sync pull --company ${slug} --on-conflict keep\n` +
501
+ ` refresh the stale sync journal (keeps local conflict copies)`,
462
502
  summary: `pull ${slug} to refresh its sync journal`,
463
503
  apply: async () => {
464
- const result = await refreshSync({ slug, hqRoot });
504
+ const result = await refreshSync({ slug, hqRoot, personal });
465
505
  outcome = result.outcome;
466
506
  },
467
507
  refreshSyncOutcome: () => outcome,
@@ -24,9 +24,11 @@
24
24
  * never auto-registered: that is what created them.)
25
25
  * - `refresh-sync` — run a targeted pull for a company journal that is
26
26
  * stale or never-synced AND has a local
27
- * `companies/<slug>` tree. Pull-only, `--on-conflict
28
- * keep`, no hook-file rewrite. Personal journals and
29
- * leftover shards without a local tree stay manual.
27
+ * `companies/<slug>` tree, or for a live personal /
28
+ * personal-vault journal. Pull-only, `--on-conflict
29
+ * keep`, no hook-file rewrite. Leftover shards
30
+ * without a local tree stay unfixed (the check reports
31
+ * NA).
30
32
  *
31
33
  * EVERYTHING else — content drift between platform copies, a missing script, a
32
34
  * missing Codex counterpart, an unquoted `$CLAUDE_PROJECT_DIR`, a stale
@@ -24,9 +24,11 @@
24
24
  * never auto-registered: that is what created them.)
25
25
  * - `refresh-sync` — run a targeted pull for a company journal that is
26
26
  * stale or never-synced AND has a local
27
- * `companies/<slug>` tree. Pull-only, `--on-conflict
28
- * keep`, no hook-file rewrite. Personal journals and
29
- * leftover shards without a local tree stay manual.
27
+ * `companies/<slug>` tree, or for a live personal /
28
+ * personal-vault journal. Pull-only, `--on-conflict
29
+ * keep`, no hook-file rewrite. Leftover shards
30
+ * without a local tree stay unfixed (the check reports
31
+ * NA).
30
32
  *
31
33
  * EVERYTHING else — content drift between platform copies, a missing script, a
32
34
  * missing Codex counterpart, an unquoted `$CLAUDE_PROJECT_DIR`, a stale
@@ -36,7 +38,7 @@
36
38
  * content-drift finding is manual-only, because that boundary is load-bearing:
37
39
  * deciding which of two diverged copies is correct needs human judgement.
38
40
  */
39
- import { isCompanyJournalSlug, journalSlugFromCheckId, } from "../checks/sync-health.js";
41
+ import { isCompanyJournalSlug, isPersonalJournalSlug, journalSlugFromCheckId, } from "../checks/sync-health.js";
40
42
  /** Matches an exec-bit remediation command, quoted or not: `chmod +x <path>`. */
41
43
  const CHMOD_EXEC = /^chmod\s+\+x\s+(.+)$/;
42
44
  /**
@@ -117,10 +119,22 @@ export function deriveRemediation(result) {
117
119
  command: remediation ?? `hq bot start ${target}`,
118
120
  };
119
121
  }
120
- // refresh-sync — a company journal that is stale or never-synced. Only
121
- // company slugs: personal shards have no companies/<slug> tree and a
122
- // SessionStart `--fix --yes` must not pull the personal vault unprompted.
122
+ // refresh-sync — a company journal that is stale or never-synced, or a
123
+ // live personal / personal-vault journal. Leftover shards are NA at the
124
+ // check layer, so they never reach this classifier. A SessionStart
125
+ // `--fix --yes` pull is keep-conflicts and pull-only, matching the
126
+ // company repair: leaving personal journals WARN made the health hook
127
+ // report an unrepaired failure for a row doctor refused to touch.
123
128
  const journalSlug = journalSlugFromCheckId(checkId);
129
+ if (journalSlug && isPersonalJournalSlug(journalSlug)) {
130
+ return {
131
+ autoFixable: true,
132
+ fixClass: "refresh-sync",
133
+ fixTarget: "personal",
134
+ action: "Pull the personal vault to refresh its sync journal (keeps local conflict copies).",
135
+ command: "hq sync pull --personal --on-conflict keep",
136
+ };
137
+ }
124
138
  if (journalSlug && isCompanyJournalSlug(journalSlug)) {
125
139
  return {
126
140
  autoFixable: true,
@@ -19,7 +19,7 @@ export declare const DOCTOR_STATUSES: readonly ["PASS", "FAIL", "WARN", "UNTESTE
19
19
  */
20
20
  export type DoctorStatus = (typeof DOCTOR_STATUSES)[number];
21
21
  /** Stable, privacy-safe diagnostics emitted by the sync health family. */
22
- export declare const SYNC_REASON_CODES: readonly ["core-version-unavailable", "core-update-available", "no-local-tree", "never-synced", "invalid-last-sync", "stale-threshold", "manifest-unavailable", "manifest-state-unavailable", "no-manifest-scopes", "never-uploaded", "invalid-upload-time", "unresolved-company-uid", "realtime-lag-stuck", "receiver-permission-denied", "runner-restart-loop", "device-limit-backoff", "repeated-conflicts"];
22
+ export declare const SYNC_REASON_CODES: readonly ["core-version-unavailable", "core-update-available", "no-local-tree", "legacy-personal-journal", "never-synced", "invalid-last-sync", "stale-threshold", "manifest-unavailable", "manifest-state-unavailable", "no-manifest-scopes", "never-uploaded", "invalid-upload-time", "unresolved-company-uid", "realtime-lag-stuck", "receiver-permission-denied", "runner-restart-loop", "device-limit-backoff", "repeated-conflicts"];
23
23
  export type SyncReasonCode = (typeof SYNC_REASON_CODES)[number];
24
24
  /** Runtime type guard: true only for a member of the fixed vocabulary. */
25
25
  export declare function isDoctorStatus(value: unknown): value is DoctorStatus;
@@ -26,6 +26,7 @@ export const SYNC_REASON_CODES = [
26
26
  "core-version-unavailable",
27
27
  "core-update-available",
28
28
  "no-local-tree",
29
+ "legacy-personal-journal",
29
30
  "never-synced",
30
31
  "invalid-last-sync",
31
32
  "stale-threshold",
@@ -192,6 +192,7 @@ export function beforeSend(event, _hint) {
192
192
  "core-version-unavailable",
193
193
  "core-update-available",
194
194
  "no-local-tree",
195
+ "legacy-personal-journal",
195
196
  "never-synced",
196
197
  "invalid-last-sync",
197
198
  "stale-threshold",
@@ -65,7 +65,7 @@ import chalk from "chalk";
65
65
  import { Sentry } from "../sentry.js";
66
66
  import { isCliTelemetryDisabled } from "./cli-telemetry.js";
67
67
  import { CLI_NAME, CLI_VERSION } from "../cli-version.js";
68
- import { buildBunInstallArgv, buildPnpmInstallArgv, buildPrefixedInstallArgv, buildSpawnPlan, canWriteNpmInstall, captureNonWritableNpmPrefixNotice, claimNonWritableNpmPrefixNotice, checkUpdateConvergence, derivePnpmHome, detachUpdateSupervisor, isBunManagedPackageDir, isLocalDependencyInstall, isPnpmManagedPackageDir, nonWritablePrefixNote, openInstallOutput, resolvePnpmPathInstall, pnpmUpdateEnv, pnpmInstalledVersion, performPnpmUpdate, resolveRunningInstall, runUpdateCommand, runSupervisedUpdateCommand, UPDATE_RECURSION_GUARD_ENV, } from "./version-gate.js";
68
+ import { buildBunInstallArgv, buildPnpmInstallArgv, buildPrefixedInstallArgv, buildSpawnPlan, canWriteNpmInstall, captureNonWritableNpmPrefixNotice, claimNonWritableNpmPrefixNotice, checkUpdateConvergence, derivePnpmHome, detachUpdateSupervisor, isBunManagedPackageDir, isLocalDependencyInstall, isPnpmManagedPackageDir, normalizeRunningInstall, nonWritablePrefixNote, openInstallOutput, resolvePnpmPathInstall, pnpmUpdateEnv, pnpmInstalledVersion, performPnpmUpdate, resolveRunningInstall, runUpdateCommand, runSupervisedUpdateCommand, UPDATE_RECURSION_GUARD_ENV, } from "./version-gate.js";
69
69
  import { canStageInstall, stagedNpmInstall } from "./staged-install.js";
70
70
  import { acquireUpdateLock as acquireSharedUpdateLock } from "./update-lock.js";
71
71
  import { markLatestIneffective } from "./version-check.js";
@@ -926,7 +926,7 @@ async function attemptUpdateAndReexec(argv, flavor, known, deps, env) {
926
926
  // actually running would stay exactly as stale as it started while the CLI
927
927
  // reported success and re-exec'd into the same build. That is the shape of
928
928
  // the 2026-09-02 gate loop — see isLocalDependencyInstall in version-gate.ts.
929
- let install = (deps.resolveInstall ?? resolveRunningInstall)();
929
+ let install = normalizeRunningInstall((deps.resolveInstall ?? resolveRunningInstall)());
930
930
  if (isLocalDependencyInstall(install)) {
931
931
  console.error(chalk.dim(`hq-cli ${latest} is available, but this copy is a local dependency (${install.packageRoot}) — update the project that owns it.`));
932
932
  return { action: "skipped", latest };
@@ -31,6 +31,9 @@ import { buildSpawnPlan, quoteForWindowsShell } from "./windows-spawn.js";
31
31
  import { type UpdateLockHandle } from "./update-lock.js";
32
32
  /** Prevent an update package manager's lifecycle child from starting another updater. */
33
33
  export declare const UPDATE_RECURSION_GUARD_ENV = "HQ_RESCUE_SELF_UPDATED";
34
+ /** First-party hq-cli is published by this org; the 24h pnpm quarantine must not
35
+ * block the version-gate from installing the server-required build. */
36
+ export declare const PNPM_FIRST_PARTY_RELEASE_AGE_EXCLUDE: string;
34
37
  export type VersionGatePhase = "preflight" | "lock_acquired" | "installer_running" | "verifying" | "rollback" | "complete" | "failed" | "unknown";
35
38
  /** The last closed version-gate phase observed in this process. */
36
39
  export declare function getVersionGatePhase(): VersionGatePhase;
@@ -86,6 +89,8 @@ export declare function npmPrefixFromPackageDir(pkgDir: string): string | null;
86
89
  * Those layouts fall through to the ordinary npm-prefix/manual handling.
87
90
  */
88
91
  export declare function isPnpmManagedPackageDir(pkgDir: string): boolean;
92
+ /** pnpm's content-addressed `store/vN/links/…` layout (not `store/vN/tmp` dlx). */
93
+ export declare function isPnpmContentStoreLinksPackageDir(pkgDir: string): boolean;
89
94
  /** Whether the running package lives inside Bun's global install tree. */
90
95
  export declare function isBunManagedPackageDir(pkgDir: string): boolean;
91
96
  /**
@@ -164,6 +169,13 @@ export interface RunningInstall {
164
169
  packageRoot: string | null;
165
170
  }
166
171
  export declare function resolveRunningInstall(): RunningInstall;
172
+ /**
173
+ * Repair a RunningInstall whose package root is a pnpm content store but whose
174
+ * manager was recorded as npm (the pre-fix classification of a realpath'd
175
+ * pnpm-global shim). Callers that inject an install snapshot must run this
176
+ * before the local-dependency hard stop, or they replay the 2420 lockout.
177
+ */
178
+ export declare function normalizeRunningInstall(install: RunningInstall): RunningInstall;
167
179
  /** Convenience view of {@link resolveRunningInstall} for callers needing one field. */
168
180
  export declare function resolveRunningManager(): InstallManager;
169
181
  /** Convenience view of {@link resolveRunningInstall} for callers needing one field. */
@@ -564,8 +576,11 @@ export declare const __test__: {
564
576
  isLocalDependencyInstall: typeof isLocalDependencyInstall;
565
577
  isLocalDependencyPackageDir: typeof isLocalDependencyPackageDir;
566
578
  isNewerVersion: typeof isNewerVersion;
579
+ isPnpmContentStoreLinksPackageDir: typeof isPnpmContentStoreLinksPackageDir;
567
580
  isPnpmManagedPackageDir: typeof isPnpmManagedPackageDir;
568
581
  isPnpmVirtualStorePackageDir: typeof isPnpmVirtualStorePackageDir;
582
+ normalizeRunningInstall: typeof normalizeRunningInstall;
583
+ PNPM_FIRST_PARTY_RELEASE_AGE_EXCLUDE: string;
569
584
  isPrefixWritable: typeof isPrefixWritable;
570
585
  nonWritablePrefixNote: typeof nonWritablePrefixNote;
571
586
  npmPrefixFromPackageDir: typeof npmPrefixFromPackageDir;
@@ -49,6 +49,9 @@ const CLIENT_ID = "hq-cli";
49
49
  const ENDPOINT_PATH = "/v1/client-version/check";
50
50
  const FETCH_TIMEOUT_MS = 3_000;
51
51
  const LATEST_PACKAGE_SPEC = `${CLI_NAME}@latest`;
52
+ /** First-party hq-cli is published by this org; the 24h pnpm quarantine must not
53
+ * block the version-gate from installing the server-required build. */
54
+ export const PNPM_FIRST_PARTY_RELEASE_AGE_EXCLUDE = `--config.minimumReleaseAgeExclude=${CLI_NAME}`;
52
55
  let currentVersionGatePhase = "unknown";
53
56
  /** The last closed version-gate phase observed in this process. */
54
57
  export function getVersionGatePhase() {
@@ -294,6 +297,29 @@ export function isPnpmManagedPackageDir(pkgDir) {
294
297
  return true;
295
298
  }
296
299
  }
300
+ // Node realpaths pnpm's generated shim into the content-addressed store:
301
+ // $PNPM_HOME/store/v11/links/@indigoai-us/hq-cli/<ver>/<hash>/node_modules/@indigoai-us/hq-cli
302
+ // There is no `global/` segment after realpath, so the checks above miss it
303
+ // and the gate used to label this a "local dependency" (feedback 2420).
304
+ // `store/vN/tmp/` is pnpm dlx and must stay non-global.
305
+ if (isPnpmContentStoreLinksPackageDir(normalized))
306
+ return true;
307
+ return false;
308
+ }
309
+ /** pnpm's content-addressed `store/vN/links/…` layout (not `store/vN/tmp` dlx). */
310
+ export function isPnpmContentStoreLinksPackageDir(pkgDir) {
311
+ const normalized = pkgDir.replace(/\\/g, "/").replace(/\/+$/, "");
312
+ const segments = normalized.split("/").filter(Boolean);
313
+ for (let i = 0; i < segments.length - 2; i += 1) {
314
+ if (segments[i] !== "store")
315
+ continue;
316
+ if (!/^v\d+$/.test(segments[i + 1]))
317
+ continue;
318
+ if (segments[i + 2] === "tmp")
319
+ return false;
320
+ if (segments[i + 2] === "links")
321
+ return true;
322
+ }
297
323
  return false;
298
324
  }
299
325
  /** Whether the running package lives inside Bun's global install tree. */
@@ -449,6 +475,21 @@ export function resolveRunningInstall() {
449
475
  return { manager: "npm", prefix: null, packageRoot: null };
450
476
  }
451
477
  }
478
+ /**
479
+ * Repair a RunningInstall whose package root is a pnpm content store but whose
480
+ * manager was recorded as npm (the pre-fix classification of a realpath'd
481
+ * pnpm-global shim). Callers that inject an install snapshot must run this
482
+ * before the local-dependency hard stop, or they replay the 2420 lockout.
483
+ */
484
+ export function normalizeRunningInstall(install) {
485
+ if (!install.packageRoot)
486
+ return install;
487
+ if (install.manager === "pnpm")
488
+ return install;
489
+ if (!isPnpmManagedPackageDir(install.packageRoot))
490
+ return install;
491
+ return { manager: "pnpm", prefix: null, packageRoot: install.packageRoot };
492
+ }
452
493
  /** Convenience view of {@link resolveRunningInstall} for callers needing one field. */
453
494
  export function resolveRunningManager() {
454
495
  return resolveRunningInstall().manager;
@@ -652,11 +693,15 @@ export function derivePnpmHome(install) {
652
693
  if (install.manager !== "pnpm" || !install.packageRoot)
653
694
  return null;
654
695
  const normalized = install.packageRoot.replace(/\\/g, "/");
655
- const marker = normalized.indexOf("/global/");
656
- if (marker === -1)
657
- return null;
658
- const home = normalized.slice(0, marker);
659
- return home || null;
696
+ const globalMarker = normalized.indexOf("/global/");
697
+ if (globalMarker !== -1) {
698
+ const home = normalized.slice(0, globalMarker);
699
+ return home || null;
700
+ }
701
+ const storeMatch = normalized.match(/^(.*)\/store\/v\d+\/links\//);
702
+ if (storeMatch)
703
+ return storeMatch[1] || null;
704
+ return null;
660
705
  }
661
706
  /**
662
707
  * Environment for the pnpm self-update spawn. pnpm requires its configured
@@ -713,7 +758,7 @@ export function buildPrefixedInstallArgv(prefix) {
713
758
  * the new version — which is the whole point of routing here instead of npm.
714
759
  */
715
760
  export function buildPnpmInstallArgv(install) {
716
- const args = ["add", "-g", LATEST_PACKAGE_SPEC];
761
+ const args = ["add", "-g", LATEST_PACKAGE_SPEC, PNPM_FIRST_PARTY_RELEASE_AGE_EXCLUDE];
717
762
  const dirs = install ? pnpmInstallDirs(install) : null;
718
763
  if (dirs) {
719
764
  args.push("--global-dir", dirs.globalDir, "--store-dir", dirs.storeDir);
@@ -732,8 +777,18 @@ export function pnpmInstallDirs(install) {
732
777
  const normalized = install.packageRoot.replace(/\\/g, "/");
733
778
  const global = normalized.match(/^(.*\/global\/\d+\/node_modules)(?:\/|$)|^(.*\/global\/v\d+\/[^/]+\/node_modules)(?:\/|$)/);
734
779
  const globalNodeModules = global?.[1] ?? global?.[2];
735
- if (!globalNodeModules)
736
- return null;
780
+ if (!globalNodeModules) {
781
+ const storeMatch = normalized.match(/^(.*)\/store\/(v\d+)\/links\//);
782
+ if (!storeMatch)
783
+ return null;
784
+ const pnpmHome = storeMatch[1];
785
+ const storeVersion = storeMatch[2];
786
+ return {
787
+ globalDir: path.join(pnpmHome, "global"),
788
+ storeDir: path.join(pnpmHome, "store", storeVersion),
789
+ virtualStoreDir: path.join(pnpmHome, "global", storeVersion, "node_modules", ".pnpm"),
790
+ };
791
+ }
737
792
  const metadataPath = path.join(globalNodeModules, ".modules.yaml");
738
793
  try {
739
794
  const metadata = yaml.load(readFileSync(metadataPath, "utf8"));
@@ -966,6 +1021,10 @@ async function fetchVersionDecision(timeoutMs = FETCH_TIMEOUT_MS) {
966
1021
  return null;
967
1022
  }
968
1023
  }
1024
+ function isReleaseAgeInstallFailure(result) {
1025
+ const text = `${result.detail ?? ""}\n${result.stdout ?? ""}`;
1026
+ return /minimumReleaseAge|minimum-release-age|published less than/i.test(text);
1027
+ }
969
1028
  // The Windows spawn recipe (`buildSpawnPlan` + `quoteForWindowsShell`) lives in
970
1029
  // ./windows-spawn.ts so the self-update path and the qmd spawn path share one
971
1030
  // implementation. Imported at the top and re-exported here to keep this module's
@@ -1353,6 +1412,7 @@ function manualUpdateCommand(install, decision) {
1353
1412
  * remains for callers that pass no handler.
1354
1413
  */
1355
1414
  function nudgeUpdateRecommended(decision, install = resolveRunningInstall()) {
1415
+ install = normalizeRunningInstall(install);
1356
1416
  if (!isNewerVersion(decision.latestVersion, CLI_VERSION))
1357
1417
  return;
1358
1418
  const msg = chalk.yellow(`⚠ A new version of hq-cli is available: ${decision.latestVersion} (current: ${CLI_VERSION}).`);
@@ -1663,7 +1723,7 @@ function enforceUpdateRequired(decision, deps = {}) {
1663
1723
  // server's `updateCommand` and any npm prefix are wrong for those layouts, so
1664
1724
  // `resolveRunningInstall`
1665
1725
  // already reports `prefix: null` there and neither is consulted below.
1666
- const install = (deps.resolveInstall ?? resolveRunningInstall)();
1726
+ const install = normalizeRunningInstall((deps.resolveInstall ?? resolveRunningInstall)());
1667
1727
  // Loop protection #1 — nothing here can update a copy this process does not
1668
1728
  // own. Installing globally would leave the local copy that is actually
1669
1729
  // running stale, so the gate would fire again on the very next invocation.
@@ -1886,6 +1946,13 @@ function attemptRequiredUpdate(decision, deps, install) {
1886
1946
  console.error(chalk.dim(" Continuing on the current hq-cli version."));
1887
1947
  return "continue";
1888
1948
  }
1949
+ // A 24h pnpm quarantine that still blocks first-party hq-cli must not
1950
+ // lock the user out of secrets (or any other command) for the rest of
1951
+ // that window. Warn and run the current CLI — same as a timed-out updater.
1952
+ if (isReleaseAgeInstallFailure(result)) {
1953
+ console.error(chalk.dim(" The package manager refused a build newer than its 24h install guard; continuing on the current hq-cli version."));
1954
+ return "continue";
1955
+ }
1889
1956
  return noticeFlush ? noticeFlush.then(() => 75) : 75;
1890
1957
  }
1891
1958
  // Read-your-writes: a "successful" install into the npm prefix does not
@@ -1914,7 +1981,7 @@ export async function enforceVersionGate(onUpdateRecommended, options = {}) {
1914
1981
  return "continue";
1915
1982
  // Resolved once, here, so the up-to-date case never pays for the walk and
1916
1983
  // neither downstream path repeats it.
1917
- const install = resolveRunningInstall();
1984
+ const install = normalizeRunningInstall(resolveRunningInstall());
1918
1985
  if (decision.updateRequired) {
1919
1986
  // Load the staged installer only when an npm prefix is resolved. The version
1920
1987
  // gate is imported during every CLI startup, so keep the filesystem-heavy
@@ -1985,8 +2052,11 @@ export const __test__ = {
1985
2052
  isLocalDependencyInstall,
1986
2053
  isLocalDependencyPackageDir,
1987
2054
  isNewerVersion,
2055
+ isPnpmContentStoreLinksPackageDir,
1988
2056
  isPnpmManagedPackageDir,
1989
2057
  isPnpmVirtualStorePackageDir,
2058
+ normalizeRunningInstall,
2059
+ PNPM_FIRST_PARTY_RELEASE_AGE_EXCLUDE,
1990
2060
  isPrefixWritable,
1991
2061
  nonWritablePrefixNote,
1992
2062
  npmPrefixFromPackageDir,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.345.78",
3
+ "version": "5.345.80",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {