@indigoai-us/hq-cli 5.262.0 → 5.264.0

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,20 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.264.0] — 2026-09-28
6
+
7
+ ### Removed
8
+
9
+ - The `checkpoint.ontology.entity-hints` flag and its per-input `known_entities`
10
+ list. It was never turned on outside measurement runs.
11
+
12
+ ## [5.263.0] — 2026-09-28
13
+
14
+ ### Fixed
15
+
16
+ - The version gate compares offers with the running CLI and recognizes
17
+ generated global shims before reporting a PATH shadow.
18
+
5
19
  ## [5.262.0] — 2026-09-28
6
20
 
7
21
  ### Added
@@ -13,6 +27,8 @@
13
27
  ### Fixed
14
28
 
15
29
  - `hq doctor --live-runtimes` uses a Windows `.cmd` shim only when the sibling file exists.
30
+ - Flag reads validate company UIDs before registry requests while preserving local
31
+ override precedence; Work Mesh ignores malformed UID overrides.
16
32
 
17
33
  ## [5.261.0] — 2026-09-28
18
34
 
@@ -10,7 +10,6 @@
10
10
  import { type CompanyBriefResult } from "./company-brief.js";
11
11
  import { type OntologyCompany } from "./company.js";
12
12
  import { type MultiWriterStart } from "./multi-writer.js";
13
- import { type EntityHint } from "./hints.js";
14
13
  import { type SyncClientVersions } from "./sync-client.js";
15
14
  export declare const ONTOLOGY_FLAG = "checkpoint.ontology";
16
15
  /**
@@ -41,13 +40,6 @@ export declare const GROK_BACKEND_FLAG = "checkpoint.ontology.grok-backend";
41
40
  * duplicate entities.
42
41
  */
43
42
  export declare const MULTI_WRITER_FLAG = "checkpoint.ontology.multi-writer";
44
- /**
45
- * Each knowledge input lists the existing entities its text names
46
- * (`known_entities`), and the model is told to use those names and to skip
47
- * table rows, paths and slugs. CO-41: 11 of 93 creates in the CO-39 grok-4.6
48
- * runs were model errors, 5 of them duplicates of an existing entity.
49
- */
50
- export declare const ENTITY_HINTS_FLAG = "checkpoint.ontology.entity-hints";
51
43
  /**
52
44
  * A candidate that links to an existing entity adds no aliases to it; the plan
53
45
  * carries `link_aliases: false` and apply honours it. CO-42: most aliases
@@ -138,8 +130,6 @@ export interface PlanInput {
138
130
  missing_refs?: string[];
139
131
  /** Run-directory-relative copy of `path` with annotator entity lists removed; same line numbers. */
140
132
  read_path?: string;
141
- /** Existing entities this input's text names; only when `checkpoint.ontology.entity-hints` is on. */
142
- known_entities?: EntityHint[];
143
133
  }
144
134
  export interface OntologyPlan {
145
135
  schema: typeof PLAN_SCHEMA;
@@ -208,7 +198,6 @@ export type PlanOutcome = {
208
198
  companyBrief?: CompanyBriefResult;
209
199
  };
210
200
  export declare const READ_PATH_INSTRUCTION = "When an input has read_path, read that file (relative to the run directory) instead of path, with the same line range. It is the same text with an older annotator's entity lists and entity links removed.";
211
- export declare const ENTITY_HINTS_INSTRUCTIONS: string[];
212
201
  export declare const PERSON_NAME_INSTRUCTION = "Name a person by the full name the input or the company's entities give, not a first name alone.";
213
202
  export declare const FIRST_NAME_PEOPLE_INSTRUCTION = "Name a person by the full name the input or the company's entities give. When neither gives more than a first name, name the person by that first name; do not leave them out.";
214
203
  export declare function ledgerPath(hqRoot: string, company: OntologyCompany): string;
@@ -13,7 +13,6 @@ import * as path from "node:path";
13
13
  import { writeCompanyBrief } from "./company-brief.js";
14
14
  import { resolveOntologyCompany, safeWriteFile, stateRoot } from "./company.js";
15
15
  import { startMultiWriterRun } from "./multi-writer.js";
16
- import { entityHints, wordWeights } from "./hints.js";
17
16
  import { checkSyncClient, readSyncClientVersions } from "./sync-client.js";
18
17
  import { buildEntityIndex, listKnowledgeFiles, listSignalFiles, readSignal } from "./tree.js";
19
18
  export const ONTOLOGY_FLAG = "checkpoint.ontology";
@@ -45,13 +44,6 @@ export const GROK_BACKEND_FLAG = "checkpoint.ontology.grok-backend";
45
44
  * duplicate entities.
46
45
  */
47
46
  export const MULTI_WRITER_FLAG = "checkpoint.ontology.multi-writer";
48
- /**
49
- * Each knowledge input lists the existing entities its text names
50
- * (`known_entities`), and the model is told to use those names and to skip
51
- * table rows, paths and slugs. CO-41: 11 of 93 creates in the CO-39 grok-4.6
52
- * runs were model errors, 5 of them duplicates of an existing entity.
53
- */
54
- export const ENTITY_HINTS_FLAG = "checkpoint.ontology.entity-hints";
55
47
  /**
56
48
  * A candidate that links to an existing entity adds no aliases to it; the plan
57
49
  * carries `link_aliases: false` and apply honours it. CO-42: most aliases
@@ -105,10 +97,6 @@ export const BUDGET = {
105
97
  chunkTokens: 12_000,
106
98
  };
107
99
  export const READ_PATH_INSTRUCTION = "When an input has read_path, read that file (relative to the run directory) instead of path, with the same line range. It is the same text with an older annotator's entity lists and entity links removed.";
108
- export const ENTITY_HINTS_INSTRUCTIONS = [
109
- "A knowledge input's known_entities lists entities that already exist in the company and whose names appear in its text. When the input refers to one of them, under that name or another, use that entity's name and type exactly and do not name a new entity for it.",
110
- "Skip rows of a glossary or translation table, file and directory paths, and names built from tags or slugs.",
111
- ];
112
100
  export const PERSON_NAME_INSTRUCTION = "Name a person by the full name the input or the company's entities give, not a first name alone.";
113
101
  export const FIRST_NAME_PEOPLE_INSTRUCTION = "Name a person by the full name the input or the company's entities give. When neither gives more than a first name, name the person by that first name; do not leave them out.";
114
102
  const CANDIDATE_INSTRUCTIONS = [
@@ -272,7 +260,6 @@ export async function planOntologyRun(options) {
272
260
  const ignoreAnnotations = (await options.readFlag(IGNORE_ANNOTATIONS_FLAG, company)) && payload.backend === "grok";
273
261
  recordBackendPreference(hqRoot, company, await options.readFlag(GROK_BACKEND_FLAG, company));
274
262
  const multiWriterOn = await options.readFlag(MULTI_WRITER_FLAG, company);
275
- const hintsOn = await options.readFlag(ENTITY_HINTS_FLAG, company);
276
263
  const noLinkAliases = await options.readFlag(NO_LINK_ALIASES_FLAG, company);
277
264
  const firstNamePeople = await options.readFlag(FIRST_NAME_PEOPLE_FLAG, company);
278
265
  const skipAmbiguousConceptLinks = await options.readFlag(SKIP_AMBIGUOUS_CONCEPT_LINKS_FLAG, company);
@@ -289,15 +276,6 @@ export async function planOntologyRun(options) {
289
276
  const pending = Object.entries(ledger.entries)
290
277
  .filter(([, entry]) => entry.state === "pending" && entry.reason === "session-touched")
291
278
  .sort(([aRel, a], [bRel, b]) => a.mtime_ms - b.mtime_ms || aRel.localeCompare(bRel));
292
- // Built after merge-back, so merged entities are hinted too.
293
- let hintIndex = null;
294
- const hintsFor = (text) => {
295
- if (!hintIndex) {
296
- const index = buildEntityIndex(company.dir);
297
- hintIndex = { index, weights: wordWeights(index) };
298
- }
299
- return entityHints(hintIndex.index, hintIndex.weights, text);
300
- };
301
279
  const inputs = [];
302
280
  let tokens = 0;
303
281
  let remaining = 0;
@@ -322,10 +300,6 @@ export async function planOntologyRun(options) {
322
300
  const cost = estTokens(Buffer.byteLength(lines.slice(start - 1, end).join("\n"), "utf8"));
323
301
  if (inputs.length >= BUDGET.maxInputs || tokens + cost > BUDGET.maxTokens)
324
302
  break;
325
- // The model reads the stripped copy when there is one; its line numbers match.
326
- const knownEntities = hintsOn && kind === "knowledge"
327
- ? hintsFor((cleaned ?? text).split("\n").slice(start - 1, end).join("\n"))
328
- : null;
329
303
  inputs.push({
330
304
  id: chunks.length > 1 ? `${rel}#${index}` : rel,
331
305
  kind,
@@ -338,7 +312,6 @@ export async function planOntologyRun(options) {
338
312
  chunk: chunks.length > 1 ? { index, count: chunks.length } : null,
339
313
  ...(kind === "signal" ? { missing_refs: missingRefs(company.dir, rel) } : {}),
340
314
  ...(readPath !== null ? { read_path: readPath } : {}),
341
- ...(knownEntities !== null ? { known_entities: knownEntities } : {}),
342
315
  });
343
316
  tokens += cost;
344
317
  }
@@ -407,7 +380,6 @@ export async function planOntologyRun(options) {
407
380
  ...CANDIDATE_INSTRUCTIONS.slice(0, 3),
408
381
  ...(inputs.some((input) => input.read_path) ? [READ_PATH_INSTRUCTION] : []),
409
382
  `${CANDIDATE_INSTRUCTIONS[3]} ${firstNamePeople ? FIRST_NAME_PEOPLE_INSTRUCTION : PERSON_NAME_INSTRUCTION}`,
410
- ...(inputs.some((input) => input.known_entities) ? ENTITY_HINTS_INSTRUCTIONS : []),
411
383
  ...CANDIDATE_INSTRUCTIONS.slice(4),
412
384
  ],
413
385
  };
@@ -96,8 +96,12 @@ export async function resolveManagerStatusGateForLane(root, lane, options = {})
96
96
  const reportError = options.onError ?? ((err) => logManagerStatusGateFailure(lane, err));
97
97
  let enabled;
98
98
  try {
99
+ const company = lane.company ?? lane.mesh_company ?? undefined;
99
100
  enabled = await (options.readFlag ?? readLanesRegistryFlag)(MANAGER_STATUS_GATE_FLAG, {
100
- company: lane.company ?? lane.mesh_company ?? undefined,
101
+ company,
102
+ ...(company ? {
103
+ companyUidSource: lane.company ? "lane.company.uid" : "lane.mesh_company.uid",
104
+ } : {}),
101
105
  env: options.env,
102
106
  });
103
107
  }
@@ -161,11 +161,22 @@ export function installOutpostPrunerTimers(options) {
161
161
  const nodeBinary = options.nodeBinary ?? resolveNodeBinary();
162
162
  const hqBinary = options.hqBinary ?? resolveHqBinary();
163
163
  const home = options.home ?? os.homedir();
164
- const flagApiUrl = resolveFlagRegistryEndpoint(options.flagApiUrl);
165
164
  const companyUid = (options.companyUid ?? process.env.HQ_COMPANY_UID ?? "").trim();
165
+ const companyUidSource = options.companyUid !== undefined
166
+ ? "install option companyUid"
167
+ : "HQ_COMPANY_UID";
166
168
  if (!companyUid) {
167
169
  throw new TypeError("install requires HQ_COMPANY_UID for the timer services");
168
170
  }
171
+ if (!companyUid.startsWith("cmp_")) {
172
+ process.stderr.write(`${JSON.stringify({
173
+ event: "outpost.worktree_pruner.install_invalid_company_uid",
174
+ source: companyUidSource,
175
+ expected_prefix: "cmp_",
176
+ })}\n`);
177
+ throw new TypeError(`${companyUidSource} must start with cmp_ before installing the timer services`);
178
+ }
179
+ const flagApiUrl = resolveFlagRegistryEndpoint(options.flagApiUrl);
169
180
  const flagEnvironment = [
170
181
  systemdEnvironment("HQ_FLAGS_API_URL", flagApiUrl),
171
182
  systemdEnvironment("HQ_COMPANY_UID", companyUid),
@@ -22,7 +22,6 @@ function reportFlagFailure(error) {
22
22
  }
23
23
  export async function outpostWorktreePrunerEnabled(dependencies = {}) {
24
24
  const env = dependencies.env ?? process.env;
25
- const endpoint = resolveFlagRegistryEndpoint(undefined, env);
26
25
  const companyUid = env.HQ_COMPANY_UID?.trim() || "";
27
26
  if (!companyUid) {
28
27
  process.stderr.write(`${JSON.stringify({
@@ -35,6 +34,17 @@ export async function outpostWorktreePrunerEnabled(dependencies = {}) {
35
34
  })}\n`);
36
35
  return OUTPOST_WORKTREE_PRUNER_FLAG_FALLBACK;
37
36
  }
37
+ if (!companyUid.startsWith("cmp_")) {
38
+ process.stderr.write(`${JSON.stringify({
39
+ event: "outpost.worktree_pruner.invalid_company_uid",
40
+ flag: OUTPOST_WORKTREE_PRUNER_FLAG,
41
+ source: "HQ_COMPANY_UID",
42
+ expected_prefix: "cmp_",
43
+ fallback: OUTPOST_WORKTREE_PRUNER_FLAG_FALLBACK,
44
+ })}\n`);
45
+ return OUTPOST_WORKTREE_PRUNER_FLAG_FALLBACK;
46
+ }
47
+ const endpoint = resolveFlagRegistryEndpoint(undefined, env);
38
48
  const companySlug = env.HQ_COMPANY_SLUG?.trim() || "";
39
49
  let client;
40
50
  let enabled = OUTPOST_WORKTREE_PRUNER_FLAG_FALLBACK;
@@ -12,6 +12,7 @@ export interface LanesFlagClient {
12
12
  export type LanesFlagClientFactory = (options: FlagClientOptions) => LanesFlagClient;
13
13
  export interface ReadLanesFlagOptions {
14
14
  company?: LaneCompanyRef | null;
15
+ companyUidSource?: string;
15
16
  env?: NodeJS.ProcessEnv;
16
17
  createClient?: LanesFlagClientFactory;
17
18
  getToken?: () => string | Promise<string>;
@@ -25,6 +25,16 @@ function reportReadFailure(flagKey, error) {
25
25
  fallback: false,
26
26
  })}\n`);
27
27
  }
28
+ function reportInvalidCompanyUid(flagKey, source) {
29
+ const suffix = flagKey.replace(/^lanes\./, "").replaceAll(/[^a-zA-Z0-9]+/g, "_");
30
+ process.stderr.write(`${JSON.stringify({
31
+ event: `lanes.${suffix}.invalid_company_uid`,
32
+ flag: flagKey,
33
+ source,
34
+ expected_prefix: "cmp_",
35
+ fallback: false,
36
+ })}\n`);
37
+ }
28
38
  function cacheKey(flagKey, company, endpoint) {
29
39
  return JSON.stringify([
30
40
  flagKey,
@@ -43,6 +53,11 @@ export async function readLanesRegistryFlag(flagKey, options = {}) {
43
53
  const localOverride = readLanesFlagLocalOverride(flagKey, env);
44
54
  if (localOverride !== null)
45
55
  return localOverride;
56
+ const companyUid = options.company?.uid?.trim();
57
+ if (companyUid && !companyUid.startsWith("cmp_")) {
58
+ reportInvalidCompanyUid(flagKey, options.companyUidSource ?? "company.uid");
59
+ return false;
60
+ }
46
61
  const endpoint = resolveFlagRegistryEndpoint(undefined, env);
47
62
  const key = cacheKey(flagKey, options.company, endpoint);
48
63
  const cached = registryReads.get(key);
@@ -646,8 +646,12 @@ async function settleManagerStatusGate(hqRoot, lane, manifestTurnId, at, deps, l
646
646
  const readFlag = deps.readManagerStatusGateFlag ?? readLanesRegistryFlag;
647
647
  let enabled = false;
648
648
  try {
649
+ const company = lane.company ?? lane.mesh_company ?? undefined;
649
650
  enabled = await readFlag(MANAGER_STATUS_GATE_FLAG, {
650
- company: lane.company ?? lane.mesh_company ?? undefined,
651
+ company,
652
+ ...(company ? {
653
+ companyUidSource: lane.company ? "lane.company.uid" : "lane.mesh_company.uid",
654
+ } : {}),
651
655
  });
652
656
  }
653
657
  catch (err) {
@@ -94,8 +94,8 @@ export declare function listActiveMembershipCompanies(token: string): Promise<Me
94
94
  *
95
95
  * Lists the caller's active companies via GET /membership/me and matches the
96
96
  * requested slug (case-insensitive). Does **not** honour HQ_COMPANY_UID /
97
- * HQ_WORK_MESH_COMPANY_UID env overrides or treat a raw `cmp_`/`co_` ref as
98
- * proof of membership without an active membership row.
97
+ * HQ_WORK_MESH_COMPANY_UID env overrides or treat a raw `cmp_` ref as proof
98
+ * of membership without an active membership row.
99
99
  *
100
100
  * Returns null when the caller is not an active member. Throws on transport /
101
101
  * auth failures so the caller can surface DEFAULT_COMPANY_UNAVAILABLE.
@@ -63,14 +63,27 @@ export async function meshJson(token, path, init = {}) {
63
63
  return data;
64
64
  }
65
65
  export async function resolveMeshCompany(token, company) {
66
- const explicit = process.env.HQ_WORK_MESH_COMPANY_UID || process.env.HQ_COMPANY_UID;
67
- if (explicit?.trim()) {
68
- return { companyUid: explicit.trim(), companySlug: company || explicit.trim() };
69
- }
70
- if (company && /^(cmp|co)_/.test(company)) {
66
+ const meshOverride = process.env.HQ_WORK_MESH_COMPANY_UID?.trim();
67
+ const companyOverride = process.env.HQ_COMPANY_UID?.trim();
68
+ const explicit = meshOverride || companyOverride;
69
+ const explicitSource = meshOverride ? "HQ_WORK_MESH_COMPANY_UID" : "HQ_COMPANY_UID";
70
+ if (explicit && explicit.startsWith("cmp_")) {
71
+ return { companyUid: explicit, companySlug: company || explicit };
72
+ }
73
+ if (explicit) {
74
+ // hq-pro accepts companyUid only with the cmp_ prefix (flags-resolve.ts:145-150).
75
+ // Ignore a malformed UID override so it cannot become a request identity.
76
+ process.stderr.write(`${JSON.stringify({
77
+ event: "work_mesh.invalid_company_uid_override",
78
+ source: explicitSource,
79
+ expected_prefix: "cmp_",
80
+ })}\n`);
81
+ }
82
+ if (company?.startsWith("cmp_")) {
71
83
  return { companyUid: company, companySlug: company };
72
84
  }
73
- const uid = await getCompanyUid(token, company);
85
+ const companySlug = company?.trim();
86
+ const uid = await getCompanyUid(token, companySlug || undefined);
74
87
  return { companyUid: uid, companySlug: company || uid };
75
88
  }
76
89
  /**
@@ -107,8 +120,8 @@ export async function listActiveMembershipCompanies(token) {
107
120
  *
108
121
  * Lists the caller's active companies via GET /membership/me and matches the
109
122
  * requested slug (case-insensitive). Does **not** honour HQ_COMPANY_UID /
110
- * HQ_WORK_MESH_COMPANY_UID env overrides or treat a raw `cmp_`/`co_` ref as
111
- * proof of membership without an active membership row.
123
+ * HQ_WORK_MESH_COMPANY_UID env overrides or treat a raw `cmp_` ref as proof
124
+ * of membership without an active membership row.
112
125
  *
113
126
  * Returns null when the caller is not an active member. Throws on transport /
114
127
  * auth failures so the caller can surface DEFAULT_COMPANY_UNAVAILABLE.
@@ -371,9 +371,10 @@ export declare function probeCliVersion(bin: string): string | null;
371
371
  * pnpm-global shim at `~/Library/pnpm/bin/hq` shadowing the npm bin dir),
372
372
  * every updater on the box probes the ghost's version, sees it stale, installs
373
373
  * into the npm prefix, reports success, and repeats forever — the production
374
- * updater-war this fix exists for. Detect the divergence here, warn ONCE with
375
- * the exact shadowing path, and never retry: no number of retries into the
376
- * same prefix can change what PATH resolves.
374
+ * updater-war this fix exists for. Detect a stale PATH result here, identify a
375
+ * distinct package managed by a supported global manager before calling it a
376
+ * shadow, and never retry: no number of retries into the same prefix can change
377
+ * what PATH resolves.
377
378
  *
378
379
  * Advisory only — resolution failure warns once and continues; nothing in
379
380
  * here may crash or block the CLI.
@@ -381,6 +382,8 @@ export declare function probeCliVersion(bin: string): string | null;
381
382
  export declare function checkUpdateConvergence(targetVersion: string, deps?: {
382
383
  resolveBin?: () => string | null;
383
384
  probeVersion?: (bin: string) => string | null;
385
+ currentInstall?: () => RunningInstall;
386
+ packageRootForBin?: (bin: string) => string | null;
384
387
  }): boolean;
385
388
  /** Injectable surface for {@link enforceUpdateRequired} (unit tests). */
386
389
  interface EnforceUpdateDeps {
@@ -30,7 +30,7 @@
30
30
  import { spawnSync } from "node:child_process";
31
31
  import { installPathCompatibilityEnabled } from "../lib/install-path-compatibility-flag.js";
32
32
  import { buildSpawnPlan, quoteForWindowsShell } from "./windows-spawn.js";
33
- import { accessSync, closeSync, constants as fsConstants, existsSync, mkdtempSync, openSync, readdirSync, readFileSync, rmSync, } from "node:fs";
33
+ import { accessSync, closeSync, constants as fsConstants, existsSync, mkdtempSync, openSync, readdirSync, realpathSync, readFileSync, rmSync, } from "node:fs";
34
34
  import os from "node:os";
35
35
  import path from "node:path";
36
36
  import { fileURLToPath } from "node:url";
@@ -46,8 +46,8 @@ const LATEST_PACKAGE_SPEC = `${CLI_NAME}@latest`;
46
46
  function isOptedOut() {
47
47
  return process.env.HQ_NO_UPDATE_CHECK === "1";
48
48
  }
49
- function findRunningPackageRoot() {
50
- let dir = path.dirname(fileURLToPath(import.meta.url));
49
+ function findCliPackageRoot(startDir) {
50
+ let dir = startDir;
51
51
  while (true) {
52
52
  try {
53
53
  const pkg = JSON.parse(readFileSync(path.join(dir, "package.json"), "utf-8"));
@@ -63,6 +63,106 @@ function findRunningPackageRoot() {
63
63
  dir = parent;
64
64
  }
65
65
  }
66
+ function findRunningPackageRoot() {
67
+ return findCliPackageRoot(path.dirname(fileURLToPath(import.meta.url)));
68
+ }
69
+ /** Resolve an on-PATH executable back to the installed hq-cli package it loads. */
70
+ function findPackageRootForExecutable(bin) {
71
+ try {
72
+ const packageRoot = findCliPackageRoot(path.dirname(realpathSync(bin)));
73
+ if (packageRoot)
74
+ return packageRoot;
75
+ }
76
+ catch {
77
+ // A missing or inaccessible executable may still be recoverable from the
78
+ // configured pnpm global layout below.
79
+ }
80
+ // pnpm emits shell/.cmd shims into its global bin directory. realpathSync()
81
+ // resolves symlinks, but a generated shim is a regular file, so walking up
82
+ // from its real path never reaches the package it invokes. Look up only the
83
+ // hq-cli package under the matching PNPM_HOME/global layout instead.
84
+ const binDir = path.dirname(bin);
85
+ const configuredHome = process.env.PNPM_HOME;
86
+ const inferredHome = path.basename(binDir).toLowerCase() === "bin"
87
+ ? path.dirname(binDir)
88
+ : binDir;
89
+ const candidateHomes = [configuredHome, inferredHome].filter((home, index, homes) => Boolean(home) && homes.findIndex((candidate) => candidate === home) === index);
90
+ const home = candidateHomes.find((candidate) => samePath(binDir, candidate) ||
91
+ samePath(binDir, path.join(candidate, "bin")));
92
+ if (!home)
93
+ return null;
94
+ const globalRoot = path.join(home, "global");
95
+ let storeVersions;
96
+ try {
97
+ storeVersions = readdirSync(globalRoot);
98
+ }
99
+ catch {
100
+ return null;
101
+ }
102
+ const packageSegments = CLI_NAME.split("/");
103
+ for (const storeVersion of storeVersions) {
104
+ const storeRoot = path.join(globalRoot, storeVersion);
105
+ const packageRoots = [
106
+ path.join(storeRoot, "node_modules", ...packageSegments),
107
+ ];
108
+ // pnpm v11 adds a hash between its global store version and node_modules.
109
+ // Its `vN` layout remains subject to the existing compatibility gate unless
110
+ // PNPM_HOME itself authorizes the global root via isPnpmManagedPackageDir.
111
+ if (/^v\d+$/.test(storeVersion)) {
112
+ try {
113
+ for (const hash of readdirSync(storeRoot)) {
114
+ packageRoots.push(path.join(storeRoot, hash, "node_modules", ...packageSegments));
115
+ }
116
+ }
117
+ catch {
118
+ // A missing or unreadable version directory is simply not a candidate.
119
+ }
120
+ }
121
+ for (const candidate of packageRoots) {
122
+ const packageRoot = findCliPackageRoot(candidate);
123
+ if (packageRoot &&
124
+ samePath(packageRoot, candidate) &&
125
+ isPnpmManagedPackageDir(packageRoot, installPathCompatibilityEnabled())) {
126
+ return packageRoot;
127
+ }
128
+ }
129
+ }
130
+ return null;
131
+ }
132
+ function samePath(left, right) {
133
+ const canonical = (value) => {
134
+ try {
135
+ return realpathSync(value);
136
+ }
137
+ catch {
138
+ return path.resolve(value);
139
+ }
140
+ };
141
+ const a = canonical(left);
142
+ const b = canonical(right);
143
+ return process.platform === "win32"
144
+ ? a.toLowerCase() === b.toLowerCase()
145
+ : a === b;
146
+ }
147
+ function globalInstallManagerForPath(bin, packageRoot) {
148
+ if (isPnpmManagedPackageDir(packageRoot, installPathCompatibilityEnabled())) {
149
+ return "pnpm";
150
+ }
151
+ if (isBunManagedPackageDir(packageRoot))
152
+ return "bun";
153
+ const prefix = npmPrefixFromPackageDir(packageRoot);
154
+ if (!prefix)
155
+ return null;
156
+ const normalizedRoot = packageRoot.replace(/\\/g, "/");
157
+ const normalizedPrefix = prefix.replace(/\\/g, "/").replace(/\/$/, "");
158
+ // POSIX global npm packages live under <prefix>/lib/node_modules. On
159
+ // Windows npm puts hq.cmd directly in <prefix>, so also verify the bin's
160
+ // directory against the prefix-derived shim location there.
161
+ const posixGlobal = normalizedRoot.includes("/lib/node_modules/");
162
+ const windowsGlobal = process.platform === "win32" &&
163
+ samePath(path.dirname(bin), normalizedPrefix);
164
+ return posixGlobal || windowsGlobal ? "npm" : null;
165
+ }
66
166
  export function npmPrefixFromPackageDir(pkgDir) {
67
167
  const normalized = pkgDir.replace(/\\/g, "/").replace(/\/+$/, "");
68
168
  const segments = normalized.split("/");
@@ -768,7 +868,9 @@ function manualUpdateCommand(install, decision) {
768
868
  * remains for callers that pass no handler.
769
869
  */
770
870
  function nudgeUpdateRecommended(decision, install = resolveRunningInstall()) {
771
- const msg = chalk.yellow(`⚠ A new version of hq-cli is available: ${decision.latestVersion} (current: ${decision.currentVersion}).`);
871
+ if (!isNewerVersion(decision.latestVersion, CLI_VERSION))
872
+ return;
873
+ const msg = chalk.yellow(`⚠ A new version of hq-cli is available: ${decision.latestVersion} (current: ${CLI_VERSION}).`);
772
874
  console.error(msg);
773
875
  // A local copy is not updated by any global command. Printing the server's
774
876
  // npm-shaped one would send the user to install a second copy that this
@@ -881,9 +983,10 @@ export function probeCliVersion(bin) {
881
983
  * pnpm-global shim at `~/Library/pnpm/bin/hq` shadowing the npm bin dir),
882
984
  * every updater on the box probes the ghost's version, sees it stale, installs
883
985
  * into the npm prefix, reports success, and repeats forever — the production
884
- * updater-war this fix exists for. Detect the divergence here, warn ONCE with
885
- * the exact shadowing path, and never retry: no number of retries into the
886
- * same prefix can change what PATH resolves.
986
+ * updater-war this fix exists for. Detect a stale PATH result here, identify a
987
+ * distinct package managed by a supported global manager before calling it a
988
+ * shadow, and never retry: no number of retries into the same prefix can change
989
+ * what PATH resolves.
887
990
  *
888
991
  * Advisory only — resolution failure warns once and continues; nothing in
889
992
  * here may crash or block the CLI.
@@ -903,12 +1006,6 @@ export function checkUpdateConvergence(targetVersion, deps = {}) {
903
1006
  }
904
1007
  if (reported === targetVersion)
905
1008
  return true; // converged — the normal case
906
- console.error(chalk.yellow(`⚠ hq updated to ${targetVersion} but PATH still resolves ${bin} at version ${reported} — ` +
907
- `a second install is shadowing the managed one. Remove it (e.g. \`pnpm remove -g ${CLI_NAME}\`) ` +
908
- "or the updater will loop forever."));
909
- // The warning above fires for ANY mismatch — something other than the copy
910
- // we just wrote is winning PATH resolution, which is worth saying either
911
- // way. But only a STALE result disproves convergence.
912
1009
  //
913
1010
  // The install runs `@latest`, not `@<targetVersion>`, so it can legitimately
914
1011
  // land a version NEWER than the `latestVersion` the gate was handed: the npm
@@ -923,6 +1020,36 @@ export function checkUpdateConvergence(targetVersion, deps = {}) {
923
1020
  semver.gte(reportedSemver, targetSemver)) {
924
1021
  return true;
925
1022
  }
1023
+ const runningInstall = (deps.currentInstall ?? resolveRunningInstall)();
1024
+ const packageRoot = (deps.packageRootForBin ?? findPackageRootForExecutable)(bin);
1025
+ const manager = packageRoot &&
1026
+ runningInstall.packageRoot &&
1027
+ !samePath(packageRoot, runningInstall.packageRoot)
1028
+ ? globalInstallManagerForPath(bin, packageRoot)
1029
+ : null;
1030
+ let detail;
1031
+ if (manager) {
1032
+ const removeCommand = manager === "pnpm"
1033
+ ? `pnpm remove -g ${CLI_NAME}`
1034
+ : manager === "bun"
1035
+ ? `bun remove -g ${CLI_NAME}`
1036
+ : `npm uninstall -g ${CLI_NAME}`;
1037
+ let realPath = bin;
1038
+ try {
1039
+ realPath = realpathSync(bin);
1040
+ }
1041
+ catch {
1042
+ // Keep the shell-resolved path when a platform shim is not canonicalizable.
1043
+ }
1044
+ detail =
1045
+ `PATH resolves ${realPath} at version ${reported} (manager: ${manager}); ` +
1046
+ `that second hq-cli install is shadowing the managed one. Remove it with \`${removeCommand}\`.`;
1047
+ }
1048
+ else {
1049
+ detail =
1050
+ `PATH resolves ${bin} at version ${reported}, but no second managed hq-cli install was confirmed.`;
1051
+ }
1052
+ console.error(chalk.yellow(`⚠ hq updated to ${targetVersion} but did not converge: ${detail}`));
926
1053
  return false;
927
1054
  }
928
1055
  catch {
@@ -950,7 +1077,7 @@ export function checkUpdateConvergence(targetVersion, deps = {}) {
950
1077
  * gate returns and lets the user's command run on the current version.
951
1078
  */
952
1079
  function enforceUpdateRequired(decision, deps = {}) {
953
- const banner = chalk.red.bold(`✗ hq-cli ${decision.currentVersion} is below the minimum required version (${decision.minVersion}).`);
1080
+ const banner = chalk.red.bold(`✗ hq-cli ${CLI_VERSION} is below the minimum required version (${decision.minVersion}).`);
954
1081
  console.error(banner);
955
1082
  if (decision.message)
956
1083
  console.error(chalk.dim(` ${decision.message}`));
@@ -976,8 +1103,8 @@ function enforceUpdateRequired(decision, deps = {}) {
976
1103
  // stale dist-tag, a bad cache, a misconfigured pin), reinstalling it on every
977
1104
  // invocation and announcing success each time is strictly worse than saying
978
1105
  // so once.
979
- if (!isNewerVersion(decision.latestVersion, decision.currentVersion)) {
980
- console.error(chalk.red(` Refusing to update: the offered version ${decision.latestVersion} is not newer than the installed ${decision.currentVersion}.`));
1106
+ if (!isNewerVersion(decision.latestVersion, CLI_VERSION)) {
1107
+ console.error(chalk.red(` Refusing to update: the offered version ${decision.latestVersion} is not newer than the running ${CLI_VERSION}.`));
981
1108
  console.error(chalk.dim(" This is a server-side or registry problem, not a local one — reinstalling would loop without ever converging."));
982
1109
  process.exit(75);
983
1110
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.262.0",
3
+ "version": "5.264.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -1,41 +0,0 @@
1
- /**
2
- * Existing entities an input names, for `checkpoint.ontology.entity-hints`.
3
- *
4
- * The model never sees the company's entities, so it can only link one when it
5
- * happens to pick a name or alias the index holds. On the CO-39 fixtures it
6
- * created `SpaceXAI` beside `xAI`, `Indigo desktop app` beside `indigo-nx`
7
- * (alias `Indigo desktop repo`) and `hq-work-site` beside `Indigo Workspace`
8
- * (workspace/checkpoint-ontology/analysis/co41-wrong-creates.json). Each
9
- * input's plan entry lists the existing entities whose name the input text
10
- * contains, so the model can use that name.
11
- */
12
- import type { EntityIndex } from "./tree.js";
13
- export declare const ENTITY_HINTS: {
14
- /** Entities listed per input. */
15
- readonly perInput: 25;
16
- /** An alias match counts this fraction of a canonical-name match with the same words. */
17
- readonly aliasWeight: 0.5;
18
- /** Aliases shown per entity. */
19
- readonly aliases: 3;
20
- /** Longest name, in words, matched through the n-gram set. Longer names use a phrase search. */
21
- readonly maxGram: 4;
22
- };
23
- export interface EntityHint {
24
- type: string;
25
- name: string;
26
- aliases: string[];
27
- }
28
- /**
29
- * Inverse document frequency of each word over the entities' match names, so
30
- * `Sentry` outranks `HQ Build` in a document that names both.
31
- */
32
- export declare function wordWeights(index: EntityIndex): Map<string, number>;
33
- /**
34
- * Up to `ENTITY_HINTS.perInput` writable entities whose canonical name or
35
- * qualifying alias appears in `text` as whole words within one sentence, table
36
- * cell or markdown block, highest weight first (ties by path). A match scores the summed word weights, halved for an alias.
37
- * Entities with the same type and canonical name are listed once. Only
38
- * entities from `index`, which is the plan's own company.
39
- */
40
- export declare function entityHints(index: EntityIndex, weights: Map<string, number>, text: string): EntityHint[];
41
- //# sourceMappingURL=hints.d.ts.map
@@ -1,146 +0,0 @@
1
- /**
2
- * Existing entities an input names, for `checkpoint.ontology.entity-hints`.
3
- *
4
- * The model never sees the company's entities, so it can only link one when it
5
- * happens to pick a name or alias the index holds. On the CO-39 fixtures it
6
- * created `SpaceXAI` beside `xAI`, `Indigo desktop app` beside `indigo-nx`
7
- * (alias `Indigo desktop repo`) and `hq-work-site` beside `Indigo Workspace`
8
- * (workspace/checkpoint-ontology/analysis/co41-wrong-creates.json). Each
9
- * input's plan entry lists the existing entities whose name the input text
10
- * contains, so the model can use that name.
11
- */
12
- export const ENTITY_HINTS = {
13
- /** Entities listed per input. */
14
- perInput: 25,
15
- /** An alias match counts this fraction of a canonical-name match with the same words. */
16
- aliasWeight: 0.5,
17
- /** Aliases shown per entity. */
18
- aliases: 3,
19
- /** Longest name, in words, matched through the n-gram set. Longer names use a phrase search. */
20
- maxGram: 4,
21
- };
22
- const HINT_TYPES = new Set(["person", "project", "company", "concept"]);
23
- /** Token that stands for a sentence, cell or block boundary. A name never matches across one it does not contain. */
24
- const BOUNDARY = "\n";
25
- const TOKEN = /[\p{L}\p{N}]+|[.!?;:](?=\s|$)|\|/gu;
26
- /**
27
- * A line that continues the previous one when the previous one is prose or a
28
- * list item: hard-wrapped markdown keeps a name that wraps across the line
29
- * break. Blank lines, headings, list items, table rows, quotes and fences start
30
- * a new block.
31
- */
32
- function continuesProse(line) {
33
- return line.trim() !== "" && !/^\s*(#|[-*+]\s|\d+[.)]\s|\||>|```|~~~)/.test(line);
34
- }
35
- /**
36
- * Lowercased words, with `BOUNDARY` between sentences (`.`, `!`, `?`, `;` or
37
- * `:` before whitespace), table cells and markdown blocks. Names and text are
38
- * split the same way, so a name that holds a boundary, such as `Tip 001:
39
- * checkpoint`, still matches its own text.
40
- */
41
- function tokens(text) {
42
- const out = [];
43
- const push = (token) => {
44
- if (token !== BOUNDARY || (out.length > 0 && out[out.length - 1] !== BOUNDARY))
45
- out.push(token);
46
- };
47
- let prevProse = null;
48
- for (const line of text.toLowerCase().split("\n")) {
49
- const prose = continuesProse(line);
50
- if (prevProse !== null && !(prose && prevProse))
51
- push(BOUNDARY);
52
- for (const match of line.matchAll(TOKEN))
53
- push(/^[\p{L}\p{N}]/u.test(match[0]) ? match[0] : BOUNDARY);
54
- prevProse = prose || /^\s*([-*+]|\d+[.)])\s/.test(line);
55
- }
56
- while (out[0] === BOUNDARY)
57
- out.shift();
58
- while (out[out.length - 1] === BOUNDARY)
59
- out.pop();
60
- return out;
61
- }
62
- function words(name) {
63
- return name.filter((token) => token !== BOUNDARY);
64
- }
65
- function matchNames(entity) {
66
- const out = [];
67
- const canonical = tokens(entity.canonical_name);
68
- if (words(canonical).length > 0)
69
- out.push({ tokens: canonical, weight: 1 });
70
- for (const alias of entity.aliases) {
71
- const aliasTokens = tokens(alias);
72
- const count = words(aliasTokens).length;
73
- if (count >= 2 || (count === 1 && /[-_./0-9]/.test(alias))) {
74
- out.push({ tokens: aliasTokens, weight: ENTITY_HINTS.aliasWeight, alias });
75
- }
76
- }
77
- return out;
78
- }
79
- /**
80
- * Inverse document frequency of each word over the entities' match names, so
81
- * `Sentry` outranks `HQ Build` in a document that names both.
82
- */
83
- export function wordWeights(index) {
84
- const counts = new Map();
85
- let total = 0;
86
- for (const entity of index.byRel.values()) {
87
- if (!HINT_TYPES.has(entity.type))
88
- continue;
89
- total += 1;
90
- for (const word of new Set(matchNames(entity).flatMap((name) => words(name.tokens))))
91
- counts.set(word, (counts.get(word) ?? 0) + 1);
92
- }
93
- const weights = new Map();
94
- for (const [word, count] of counts)
95
- weights.set(word, Math.log((total + 1) / count));
96
- return weights;
97
- }
98
- /**
99
- * Up to `ENTITY_HINTS.perInput` writable entities whose canonical name or
100
- * qualifying alias appears in `text` as whole words within one sentence, table
101
- * cell or markdown block, highest weight first (ties by path). A match scores the summed word weights, halved for an alias.
102
- * Entities with the same type and canonical name are listed once. Only
103
- * entities from `index`, which is the plan's own company.
104
- */
105
- export function entityHints(index, weights, text) {
106
- const textTokens = tokens(text);
107
- const grams = new Set();
108
- for (let i = 0; i < textTokens.length; i += 1) {
109
- for (let n = 1; n <= ENTITY_HINTS.maxGram && i + n <= textTokens.length; n += 1) {
110
- grams.add(textTokens.slice(i, i + n).join(" "));
111
- }
112
- }
113
- const padded = ` ${textTokens.join(" ")} `;
114
- const best = new Map();
115
- for (const entity of index.byRel.values()) {
116
- if (!HINT_TYPES.has(entity.type))
117
- continue;
118
- let score = 0;
119
- const matched = [];
120
- for (const name of matchNames(entity)) {
121
- const phrase = name.tokens.join(" ");
122
- const found = name.tokens.length <= ENTITY_HINTS.maxGram ? grams.has(phrase) : padded.includes(` ${phrase} `);
123
- if (!found)
124
- continue;
125
- if (name.alias !== undefined)
126
- matched.push(name.alias);
127
- score = Math.max(score, name.weight * words(name.tokens).reduce((sum, word) => sum + (weights.get(word) ?? 0), 0));
128
- }
129
- if (score <= 0)
130
- continue;
131
- const key = `${entity.type}\0${entity.canonical_name.toLowerCase()}`;
132
- const held = best.get(key);
133
- if (!held || score > held.score || (score === held.score && entity.rel < held.entity.rel))
134
- best.set(key, { entity, score, matched });
135
- }
136
- return [...best.values()]
137
- .sort((a, b) => b.score - a.score || (a.entity.rel < b.entity.rel ? -1 : a.entity.rel > b.entity.rel ? 1 : 0))
138
- .slice(0, ENTITY_HINTS.perInput)
139
- .map(({ entity, matched }) => ({
140
- type: entity.type,
141
- name: entity.canonical_name,
142
- // Aliases the text used come first, so the model sees the name it read.
143
- aliases: [...matched, ...entity.aliases.filter((alias) => !matched.includes(alias))].slice(0, ENTITY_HINTS.aliases),
144
- }));
145
- }
146
- //# sourceMappingURL=hints.js.map