@indigoai-us/hq-cli 5.108.4 → 5.108.5

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,15 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.108.5] — 2026-09-04
6
+
7
+ ### Added
8
+
9
+ - `hq meetings import <file>` imports normalized historical meeting transcripts
10
+ through HQ's managed source pipeline, with immutable idempotent replay and
11
+ explicit conflict detection when an external source ID is reused with
12
+ different content.
13
+
5
14
  ## [5.108.4] — 2026-09-04
6
15
 
7
16
  ### Fixed
@@ -1,6 +1,7 @@
1
1
  import chalk from "chalk";
2
+ import { readFile } from "node:fs/promises";
2
3
  import { ensureCognitoToken } from "../utils/cognito-session.js";
3
- import { vaultApiFetch, getCompanyUid } from "../utils/vault-api.js";
4
+ import { vaultApiFetch, getCompanyUid, resolveCallerPersonUid, } from "../utils/vault-api.js";
4
5
  function formatDuration(seconds) {
5
6
  const h = Math.floor(seconds / 3600);
6
7
  const m = Math.floor((seconds % 3600) / 60);
@@ -216,6 +217,59 @@ export function registerMeetingsCommand(program) {
216
217
  }
217
218
  });
218
219
  // ── hq meetings invite <meeting-url> ──────────────────────────────
220
+ meetings
221
+ .command("import <file>")
222
+ .description("Import a normalized historical transcript JSON file")
223
+ .action(async (file) => {
224
+ try {
225
+ const companySlug = meetings.opts().company;
226
+ if (!companySlug) {
227
+ throw new Error("--company <slug> is required for historical imports");
228
+ }
229
+ const bytes = await readFile(file);
230
+ if (bytes.byteLength > 5 * 1024 * 1024) {
231
+ throw new Error("Historical meeting import exceeds 5 MiB");
232
+ }
233
+ let parsed;
234
+ try {
235
+ parsed = JSON.parse(bytes.toString("utf8"));
236
+ }
237
+ catch {
238
+ throw new Error("Historical meeting import file must contain valid JSON");
239
+ }
240
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
241
+ throw new Error("Historical meeting import file must contain a JSON object");
242
+ }
243
+ const token = await ensureCognitoToken();
244
+ const companyId = await getCompanyUid(token, companySlug);
245
+ const recorderPersonUid = await resolveCallerPersonUid(token);
246
+ const res = await vaultApiFetch({
247
+ token,
248
+ method: "POST",
249
+ path: "/v1/meetings/import",
250
+ body: {
251
+ ...parsed,
252
+ companyId,
253
+ recorderPersonUid,
254
+ },
255
+ });
256
+ if (!res.ok)
257
+ await handleApiError(res, meetings.opts().json);
258
+ const result = (await res.json());
259
+ if (meetings.opts().json) {
260
+ console.log(JSON.stringify(result, null, 2));
261
+ return;
262
+ }
263
+ console.log(chalk.green(`\n✓ Historical meeting ${result.outcome}: ${result.meetingId}`));
264
+ if (result.state)
265
+ console.log(chalk.dim(` State: ${result.state}`));
266
+ console.log();
267
+ }
268
+ catch (err) {
269
+ console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
270
+ process.exit(1);
271
+ }
272
+ });
219
273
  meetings
220
274
  .command("invite <meetingUrl>")
221
275
  .description("Invite the meeting bot to a Google Meet, Zoom, or Teams URL")
@@ -345,7 +345,23 @@ export function buildCliHeartbeat(input) {
345
345
  syncState = "error";
346
346
  break;
347
347
  default:
348
- syncState = lastSyncSuccessAt ? "idle" : "never_synced";
348
+ // An UNRESOLVED failure streak is a live error state, not idleness.
349
+ // Reporting `idle` here is what put "BROKEN — Runner failed" next to
350
+ // "Sync state: idle" on the support view: every later `hq` invocation
351
+ // overwrote the `error` state the failing sync had reported while
352
+ // leaving the streak that earned it untouched.
353
+ //
354
+ // Only this CLI's own `sync_success` clears the streak. The journal
355
+ // `lastSync` folded into `lastSyncSuccessAt` below deliberately does
356
+ // NOT: the engine stamps it per FILE update (hq-cloud journal.ts
357
+ // `updateEntry`), and a push stamps it before throwing its upload worker
358
+ // errors — so it means "some file moved", not "a run succeeded", and
359
+ // must never clear an alarm counter. That is why this branch keys off
360
+ // the streak rather than off the success timestamp.
361
+ if (input.state.consecutiveFailures > 0)
362
+ syncState = "error";
363
+ else
364
+ syncState = lastSyncSuccessAt ? "idle" : "never_synced";
349
365
  break;
350
366
  }
351
367
  const heartbeat = {
@@ -71,6 +71,15 @@ export type SelfUpdateAction =
71
71
  | "update-failed"
72
72
  /** Updated, but the re-exec couldn't start; continue on the current (in-memory) version. */
73
73
  | "updated-no-reexec"
74
+ /**
75
+ * The install reported success but the `hq` on PATH still resolves the old
76
+ * version (a shadowing/ghost install, a prefix/PATH mismatch, or a stale
77
+ * tarball). Re-installing the same target can never converge, so we do NOT
78
+ * re-exec, we record the target as ineffective so the startup path stops
79
+ * auto-retrying it, and the command runs on the current version. This is the
80
+ * guard against the self-update loop.
81
+ */
82
+ | "update-ineffective"
74
83
  /** Updated and the command re-ran on the new version; exit with `reexecStatus`. */
75
84
  | "reexec"
76
85
  /**
@@ -127,6 +136,17 @@ export interface SelfUpdateDeps {
127
136
  runner?: (cmd: string, args: string[], env?: NodeJS.ProcessEnv) => UpdateResult;
128
137
  reexec?: (argv: string[], env: NodeJS.ProcessEnv) => number | null;
129
138
  acquireLock?: () => (() => void) | null;
139
+ /**
140
+ * Read-your-writes convergence check run after a "successful" install:
141
+ * returns false only when the `hq` on PATH still resolves a build older than
142
+ * the target we just installed. Defaults to {@link checkUpdateConvergence}.
143
+ */
144
+ checkConvergence?: (targetVersion: string) => boolean;
145
+ /**
146
+ * Persist that `version` installed but never took effect, so the startup path
147
+ * stops auto-retrying it. Defaults to {@link markLatestIneffective}.
148
+ */
149
+ markIneffective?: (version: string) => void;
130
150
  /**
131
151
  * Whether a human is watching this invocation. Defaults to "stderr is a TTY",
132
152
  * which is false for exactly the callers that must not replace the CLI
@@ -60,8 +60,9 @@ 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, inOwnProcessGroup, isLocalDependencyInstall, openInstallOutput, pnpmUpdateEnv, resolveRunningInstall, runUpdateCommand, } from "./version-gate.js";
63
+ import { buildBunInstallArgv, buildPnpmInstallArgv, buildPrefixedInstallArgv, buildSpawnPlan, checkUpdateConvergence, inOwnProcessGroup, isLocalDependencyInstall, openInstallOutput, pnpmUpdateEnv, resolveRunningInstall, runUpdateCommand, } from "./version-gate.js";
64
64
  import { acquireUpdateLock as acquireSharedUpdateLock } from "./update-lock.js";
65
+ import { markLatestIneffective } from "./version-check.js";
65
66
  /**
66
67
  * Set on the re-exec'd child so it can never self-update (and re-exec) again.
67
68
  * One update + one re-exec per user invocation, ever.
@@ -278,6 +279,26 @@ async function updateAndReexec(argv, flavor, known, deps) {
278
279
  console.error(chalk.dim(` Continuing the ${flavor.noun} on ${current}.`));
279
280
  return { action: "update-failed", latest };
280
281
  }
282
+ // Read-your-writes: a "successful" `npm install -g` proves only that the
283
+ // prefix was rewritten, NOT that the `hq` this user's PATH resolves is the
284
+ // copy we just wrote. When a ghost/shadowing install keeps winning PATH
285
+ // resolution (or a prefix/PATH mismatch or stale tarball leaves the running
286
+ // copy behind), re-exec'ing here runs the command on the SAME stale version,
287
+ // and — because `staleAgainstCachedLatest` still sees the baked CLI_VERSION
288
+ // as behind `latest` — the next invocation installs and re-execs again,
289
+ // forever. The hard version gate already verifies convergence; the soft
290
+ // startup path did not, which is the 5.105.0 → 5.105.1 self-update loop.
291
+ //
292
+ // So verify before announcing success. On non-convergence: mark the target
293
+ // ineffective (suppresses auto-retry for a cooldown), skip the pointless
294
+ // re-exec into a stale copy, and let the command run on the current version.
295
+ const checkConvergence = deps.checkConvergence ?? checkUpdateConvergence;
296
+ const converged = checkConvergence(latestValid);
297
+ if (converged === false) {
298
+ (deps.markIneffective ?? markLatestIneffective)(latest);
299
+ console.error(chalk.dim(` Continuing the ${flavor.noun} on ${current}.`));
300
+ return { action: "update-ineffective", latest };
301
+ }
281
302
  const childEnv = { ...env, [REEXEC_GUARD_ENV]: "1" };
282
303
  const status = (deps.reexec ?? reexecHq)([...argv.slice(2)], childEnv);
283
304
  if (status === null) {
@@ -10,6 +10,13 @@ declare function isKnownNoninteractiveStatusProbe(argv?: readonly string[]): boo
10
10
  * command, and warns only if that fails.
11
11
  */
12
12
  export declare function staleAgainstCachedLatest(now?: number): string | null;
13
+ /**
14
+ * Record that installing `version` did not move the on-PATH `hq` forward, so
15
+ * {@link staleAgainstCachedLatest} stops auto-retrying it for a cooldown. Called
16
+ * by the self-updater's convergence check. Best-effort: a write failure only
17
+ * means the loop guard is skipped this once, never a broken CLI.
18
+ */
19
+ export declare function markLatestIneffective(version: string, now?: number): void;
13
20
  export declare function refreshVersionCache(): Promise<void>;
14
21
  export declare const __test__: {
15
22
  CACHE_TTL_MS: number;
@@ -9,6 +9,12 @@ const CACHE_TTL_MS = 60 * 60 * 1000; // 1h — catch fresh releases within the h
9
9
  const CACHE_TTL_JITTER_MS = 5 * 60 * 1000; // up to 5m early, to spread a fleet's refreshes off a single instant
10
10
  const FETCH_TIMEOUT_MS = 3_000;
11
11
  const REFRESH_LOCK_STALE_MS = 10 * 60 * 1000;
12
+ /**
13
+ * How long to stop auto-retrying an `ineffectiveLatest` target. Long enough to
14
+ * break the per-invocation loop on a busy agent box, short enough that a box
15
+ * whose PATH is later fixed recovers on its own without a manual `hq rescue`.
16
+ */
17
+ const INEFFECTIVE_COOLDOWN_MS = 6 * 60 * 60 * 1000; // 6h
12
18
  function cachePath() {
13
19
  return path.join(os.homedir(), ".hq", "version-check.json");
14
20
  }
@@ -26,7 +32,16 @@ function readCache() {
26
32
  typeof parsed.fetchedAt !== "number") {
27
33
  return null;
28
34
  }
29
- return { latest: parsed.latest, fetchedAt: parsed.fetchedAt };
35
+ return {
36
+ latest: parsed.latest,
37
+ fetchedAt: parsed.fetchedAt,
38
+ ...(typeof parsed.ineffectiveLatest === "string"
39
+ ? { ineffectiveLatest: parsed.ineffectiveLatest }
40
+ : {}),
41
+ ...(typeof parsed.ineffectiveAt === "number"
42
+ ? { ineffectiveAt: parsed.ineffectiveAt }
43
+ : {}),
44
+ };
30
45
  }
31
46
  catch {
32
47
  return null;
@@ -119,8 +134,38 @@ export function staleAgainstCachedLatest(now = Date.now()) {
119
134
  return null;
120
135
  if (!semver.gt(latest, current))
121
136
  return null;
137
+ // Loop guard: a previous self-update installed this exact `latest` but the
138
+ // on-PATH `hq` never converged to it (see markLatestIneffective). Retrying
139
+ // the same target re-installs it and re-execs into the same stale copy on
140
+ // every invocation without ever making progress, so suppress it for a
141
+ // cooldown. A newer `latest` (different target) is never suppressed.
142
+ if (entry.ineffectiveLatest === entry.latest &&
143
+ typeof entry.ineffectiveAt === "number" &&
144
+ now - entry.ineffectiveAt <= INEFFECTIVE_COOLDOWN_MS) {
145
+ return null;
146
+ }
122
147
  return entry.latest;
123
148
  }
149
+ /**
150
+ * Record that installing `version` did not move the on-PATH `hq` forward, so
151
+ * {@link staleAgainstCachedLatest} stops auto-retrying it for a cooldown. Called
152
+ * by the self-updater's convergence check. Best-effort: a write failure only
153
+ * means the loop guard is skipped this once, never a broken CLI.
154
+ */
155
+ export function markLatestIneffective(version, now = Date.now()) {
156
+ const entry = readCache();
157
+ if (!entry)
158
+ return;
159
+ // Only mark the target we actually believe is `latest`; marking anything else
160
+ // would risk suppressing a legitimately newer release.
161
+ if (entry.latest !== version)
162
+ return;
163
+ writeCache({
164
+ ...entry,
165
+ ineffectiveLatest: version,
166
+ ineffectiveAt: now,
167
+ });
168
+ }
124
169
  export async function refreshVersionCache() {
125
170
  if (isOptedOut())
126
171
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.108.4",
3
+ "version": "5.108.5",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {