@indigoai-us/hq-cli 5.345.169 → 5.345.170

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/command-catalog.generated.d.ts +49 -4
  3. package/dist/command-catalog.generated.js +60 -4
  4. package/dist/commands/access.js +7 -2
  5. package/dist/commands/bot.js +4 -0
  6. package/dist/commands/cloud.d.ts +20 -0
  7. package/dist/commands/cloud.js +55 -4
  8. package/dist/commands/feedback-triage.d.ts +43 -1
  9. package/dist/commands/feedback-triage.js +123 -23
  10. package/dist/commands/feedback.d.ts +40 -1
  11. package/dist/commands/feedback.js +184 -4
  12. package/dist/commands/files.d.ts +42 -0
  13. package/dist/commands/files.js +223 -30
  14. package/dist/commands/mesh.js +6 -0
  15. package/dist/commands/sync-manifest.d.ts +16 -0
  16. package/dist/commands/sync-manifest.js +17 -1
  17. package/dist/lib/bot/decisions.d.ts +62 -0
  18. package/dist/lib/bot/decisions.js +228 -0
  19. package/dist/lib/bot/index.d.ts +1 -0
  20. package/dist/lib/bot/index.js +1 -0
  21. package/dist/lib/bot/run.d.ts +7 -0
  22. package/dist/lib/bot/run.js +104 -1
  23. package/dist/lib/daemon/catalog.d.ts +4 -0
  24. package/dist/lib/daemon/catalog.js +60 -29
  25. package/dist/lib/daemon/config.d.ts +7 -0
  26. package/dist/lib/daemon/config.js +32 -0
  27. package/dist/lib/daemon/run.d.ts +3 -2
  28. package/dist/lib/daemon/run.js +23 -3
  29. package/dist/lib/daemon/supervisor.d.ts +1 -0
  30. package/dist/lib/daemon/supervisor.js +4 -0
  31. package/dist/lib/daemon/sync-core-canary.d.ts +9 -0
  32. package/dist/lib/daemon/sync-core-canary.js +56 -0
  33. package/dist/lib/doctor/checks/sync-health.d.ts +32 -0
  34. package/dist/lib/doctor/checks/sync-health.js +54 -1
  35. package/dist/lib/doctor/fix/apply.d.ts +26 -4
  36. package/dist/lib/doctor/fix/apply.js +95 -11
  37. package/dist/lib/doctor/types.d.ts +1 -1
  38. package/dist/lib/doctor/types.js +1 -0
  39. package/dist/lib/mesh/live/daemon/work-subscribe.js +31 -0
  40. package/dist/lib/work-context/organize.d.ts +7 -0
  41. package/dist/lib/work-context/organize.js +21 -1
  42. package/dist/sentry-before-send.js +1 -0
  43. package/package.json +7 -7
@@ -22,6 +22,7 @@ import { readBotConfig } from "../bot/config.js";
22
22
  import { botsRoot } from "../bot/paths.js";
23
23
  import { CLAUDE_RUNNER_SCRIPT, CODEX_RUNNER_SCRIPT, enabledServices, planUnits } from "./catalog.js";
24
24
  import { effectiveSyncOwner, readDaemonConfig } from "./config.js";
25
+ import { inspectSyncCoreCanary } from "./sync-core-canary.js";
25
26
  import { assertPersonalServiceSessionCapability, readRemoteControlPoolsConfig, remoteControlPoolSecretsEnv, } from "./remote-control-pools.js";
26
27
  import { loadLanesConfig } from "../lanes/config.js";
27
28
  import { daemonLanesReconcileHandler } from "../lanes/daemon-reconcile.js";
@@ -54,7 +55,7 @@ import { stageHqChildBootstrap } from "./hq-child-bootstrap-stage.js";
54
55
  import { runnerSessionName } from "./remote-control.js";
55
56
  import { adoptChild, createChildTracker, finishSuccession, handoffEligible, readProcStart, stopOrphans, successionPaths, systemdKillMode, takeSuccession, writeHandoff, } from "./succession.js";
56
57
  import { listSyncRunnerProcesses, readWatchRunnerOwnerStatus, syncHeartbeatTimeoutMs, syncRunnerNodeOptions, watchRunnerOwnerStatusPath, } from "./sync-runner.js";
57
- import { STOP_ESCALATION_MS, SYNC_RUNNER_PERSIST_GRACE_MS, createSupervisor, } from "./supervisor.js";
58
+ import { STOP_ESCALATION_MS, SYNC_RUNNER_PERSIST_GRACE_MS, createSupervisor, planKey, } from "./supervisor.js";
58
59
  import { userToolDirs } from "./tools.js";
59
60
  import { logOffset, readLogSince, readUnitHolds, writeUnitHolds } from "./unit-holds.js";
60
61
  import { daemonRunGuard } from "./lanes-reconcile-guard.js";
@@ -63,6 +64,19 @@ export const LOG_ROTATE_BYTES = 5 * 1024 * 1024;
63
64
  export const RESTART_EXIT_CODE = 75;
64
65
  const NON_SYNC_SHUTDOWN_CAP_MS = STOP_ESCALATION_MS + 5_000;
65
66
  const SYNC_RUNNER_SHUTDOWN_CAP_MS = STOP_ESCALATION_MS + SYNC_RUNNER_PERSIST_GRACE_MS + 5_000;
67
+ export function planUnitsWithSyncCoreInterlock(services, context, supervisor) {
68
+ const syncCoreCanaryRunning = supervisor?.runningPlanKey("sync-core-canary") !== undefined;
69
+ const transitionContext = {
70
+ ...context,
71
+ syncCoreCanaryRunning,
72
+ legacySyncReadyForCore: false,
73
+ };
74
+ const waiting = planUnits(services, transitionContext);
75
+ const legacy = waiting.find((unit) => unit.id === "sync");
76
+ const legacySyncReadyForCore = legacy !== undefined
77
+ && supervisor?.runningPlanKey("sync") === planKey(legacy, CLI_VERSION);
78
+ return planUnits(services, { ...transitionContext, legacySyncReadyForCore });
79
+ }
66
80
  /** A bounded shutdown deadline that can account for sync push persistence. */
67
81
  export function createShutdownCap() {
68
82
  const startedAt = Date.now();
@@ -939,7 +953,7 @@ export async function runDaemon(opts) {
939
953
  legacy,
940
954
  appRunning: () => desktopAppRunning(platform),
941
955
  });
942
- const plans = planUnits(services, {
956
+ const context = {
943
957
  machineType,
944
958
  platform,
945
959
  home,
@@ -964,6 +978,11 @@ export async function runDaemon(opts) {
964
978
  return switches;
965
979
  })(),
966
980
  syncOwner,
981
+ syncCoreCanary: config.syncCoreCanaryError
982
+ ? { blockedReason: config.syncCoreCanaryError }
983
+ : config.syncCoreCanary
984
+ ? inspectSyncCoreCanary(home, hqRoot, config.syncCoreCanary, platform)
985
+ : undefined,
967
986
  desktopAppManagesSync: appManagesSync,
968
987
  syncRunnerProcesses,
969
988
  syncRunnerOwnerPid,
@@ -991,7 +1010,8 @@ export async function runDaemon(opts) {
991
1010
  invalidPoolServiceSessions,
992
1011
  availablePoolFrontends,
993
1012
  unverifiablePoolFrontends,
994
- });
1013
+ };
1014
+ const plans = planUnitsWithSyncCoreInterlock(services, context, supervisor);
995
1015
  if (startupPlanOpen) {
996
1016
  const replaceablePackageRoots = cliPackageRoot ? [cliPackageRoot] : [];
997
1017
  if (hqCloud?.source === "bundled")
@@ -179,6 +179,7 @@ export interface Supervisor {
179
179
  stop(): Promise<void>;
180
180
  reconcile(): void;
181
181
  state(): DaemonState;
182
+ runningPlanKey(unitId: string): string | undefined;
182
183
  /** True while a live sync runner has reported that it is persisting pushes. */
183
184
  hasStoppingSyncRunner(): boolean;
184
185
  /** Stop supervising without signalling any child; returns the long-running units left running. */
@@ -1399,6 +1399,10 @@ export function createSupervisor(deps) {
1399
1399
  },
1400
1400
  reconcile,
1401
1401
  state: snapshot,
1402
+ runningPlanKey(unitId) {
1403
+ const slot = slots.get(unitId);
1404
+ return slot?.child ? slot.childKey : undefined;
1405
+ },
1402
1406
  hasStoppingSyncRunner: () => [...liveOwners.values()].some(syncRunnerIsStopping),
1403
1407
  release() {
1404
1408
  const released = [];
@@ -0,0 +1,9 @@
1
+ import type { SyncCoreCanaryConfig } from "./config.js";
2
+ export interface SyncCoreCanaryPlan {
3
+ binaryPath?: string;
4
+ vaultUid?: string;
5
+ canaryPath?: string;
6
+ blockedReason?: string;
7
+ }
8
+ export declare function inspectSyncCoreCanary(home: string, hqRoot: string | undefined, config: SyncCoreCanaryConfig, platform: NodeJS.Platform): SyncCoreCanaryPlan;
9
+ //# sourceMappingURL=sync-core-canary.d.ts.map
@@ -0,0 +1,56 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { parse as parseToml } from "smol-toml";
4
+ function blocked(config, blockedReason) {
5
+ return { ...config, blockedReason };
6
+ }
7
+ function record(value) {
8
+ return value && typeof value === "object" && !Array.isArray(value)
9
+ ? value
10
+ : undefined;
11
+ }
12
+ export function inspectSyncCoreCanary(home, hqRoot, config, platform) {
13
+ if (platform !== "darwin" && platform !== "linux") {
14
+ return blocked(config, "the sync-core canary requires macOS or Linux");
15
+ }
16
+ try {
17
+ if (!fs.statSync(config.binaryPath).isFile())
18
+ return blocked(config, "the sync-core binary is missing");
19
+ fs.accessSync(config.binaryPath, fs.constants.X_OK);
20
+ }
21
+ catch {
22
+ return blocked(config, "the sync-core binary is missing or not executable");
23
+ }
24
+ const configPath = path.join(home, ".hq", "sync-core", "config.toml");
25
+ let sync;
26
+ try {
27
+ sync = record(record(parseToml(fs.readFileSync(configPath, "utf8")))?.sync);
28
+ }
29
+ catch {
30
+ return blocked(config, `the sync-core config at ${configPath} is missing or invalid`);
31
+ }
32
+ if (sync?.engine !== "rust-test" || sync.mode !== "full") {
33
+ return blocked(config, "the sync-core config must select engine rust-test in full mode");
34
+ }
35
+ const vaults = sync.vaults;
36
+ if (!Array.isArray(vaults) || vaults.length !== 1 || vaults[0] !== config.vaultUid) {
37
+ return blocked(config, `the sync-core config must name exactly ${config.vaultUid}`);
38
+ }
39
+ if (!hqRoot || typeof sync.hq_root !== "string" || path.resolve(sync.hq_root) !== path.resolve(hqRoot)) {
40
+ return blocked(config, "the sync-core config must use the daemon's HQ folder");
41
+ }
42
+ const canaryPath = typeof sync.canary_path === "string" ? sync.canary_path.trim() : "";
43
+ if (!canaryPath)
44
+ return blocked(config, "the sync-core config must name one canary path");
45
+ const ownedPath = path.join(home, ".hq", "sync-core", "owned", `${config.vaultUid}.json`);
46
+ try {
47
+ const owned = record(JSON.parse(fs.readFileSync(ownedPath, "utf8")));
48
+ if (owned?.vault !== config.vaultUid)
49
+ throw new Error("mismatch");
50
+ }
51
+ catch {
52
+ return blocked(config, `the sync-core ownership record for ${config.vaultUid} is missing or invalid`);
53
+ }
54
+ return { ...config, canaryPath };
55
+ }
56
+ //# sourceMappingURL=sync-core-canary.js.map
@@ -61,7 +61,39 @@ export interface SyncManifestUploadStatus {
61
61
  baseUsable: boolean;
62
62
  /** Local company slug for company-scoped remediation; absent for personal. */
63
63
  companySlug?: string;
64
+ /**
65
+ * Last failed pass, from hq-cloud >= 6.18.102 (`readManifestUploadStatus`,
66
+ * hq-cloud #1050). Fixed tokens only. Absent on older hq-cloud and after a
67
+ * later pass succeeded.
68
+ */
69
+ lastAttemptAt?: string;
70
+ consecutiveFailures?: number;
71
+ lastFailureReason?: string;
72
+ lastFailureKind?: string;
73
+ lastFailureStatus?: number;
64
74
  }
75
+ /**
76
+ * The last failed pass for a stale scope, when hq-cloud recorded one after the
77
+ * last successful upload. Undefined means "no evidence a pass ran", which is
78
+ * the only case where "the daily audit pass is not running" is accurate.
79
+ */
80
+ export declare function manifestLastFailure(entry: SyncManifestUploadStatus): {
81
+ reason: string;
82
+ status?: number;
83
+ attempts: number;
84
+ at: string;
85
+ } | undefined;
86
+ /**
87
+ * Failures a fresh `hq sync manifest` from this machine cannot clear: the
88
+ * upload route is not deployed (404 soft skip), the server rejected this
89
+ * identity, or it refused access (403). A 401 is not listed: it can be an
90
+ * expired token that a fresh run refreshes. Offering `--fix` for these only makes the
91
+ * health hook report the same row as "repair did not clear" every day.
92
+ */
93
+ export declare function manifestFailureNotFixable(failure: {
94
+ reason: string;
95
+ status?: number;
96
+ }): boolean;
65
97
  /** Injectable dependencies — defaults are the real collectors. */
66
98
  export interface SyncHealthDeps {
67
99
  /** Runner log and conflict signals; omitted in unit tests of other checks. */
@@ -52,6 +52,45 @@ const SYNC_FAMILY_TITLE = "Versions & sync";
52
52
  export const STALE_JOURNAL_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;
53
53
  /** The offline update cache written by the check-hq-update SessionStart hook. */
54
54
  export const UPDATE_CACHE_RELPATH = path.join("workspace", ".hq-update-check", "last-check.json");
55
+ /** Failure tokens hq-cloud stores, restricted the same way hq-cloud reads them. */
56
+ const MANIFEST_FAILURE_TOKEN = /^[a-z][a-z0-9_]{0,39}$/;
57
+ /**
58
+ * The last failed pass for a stale scope, when hq-cloud recorded one after the
59
+ * last successful upload. Undefined means "no evidence a pass ran", which is
60
+ * the only case where "the daily audit pass is not running" is accurate.
61
+ */
62
+ export function manifestLastFailure(entry) {
63
+ const reason = entry.lastFailureReason;
64
+ const at = entry.lastAttemptAt;
65
+ if (!reason || !MANIFEST_FAILURE_TOKEN.test(reason) || !at)
66
+ return undefined;
67
+ const attempts = entry.consecutiveFailures ?? 0;
68
+ if (!Number.isFinite(attempts) || attempts < 1)
69
+ return undefined;
70
+ const atMs = Date.parse(at);
71
+ if (!Number.isFinite(atMs))
72
+ return undefined;
73
+ const uploadedMs = entry.lastUploadAt ? Date.parse(entry.lastUploadAt) : Number.NaN;
74
+ if (Number.isFinite(uploadedMs) && atMs <= uploadedMs)
75
+ return undefined;
76
+ const status = typeof entry.lastFailureStatus === "number" && Number.isInteger(entry.lastFailureStatus)
77
+ ? entry.lastFailureStatus
78
+ : undefined;
79
+ return { reason, attempts: Math.floor(attempts), at, ...(status !== undefined ? { status } : {}) };
80
+ }
81
+ /**
82
+ * Failures a fresh `hq sync manifest` from this machine cannot clear: the
83
+ * upload route is not deployed (404 soft skip), the server rejected this
84
+ * identity, or it refused access (403). A 401 is not listed: it can be an
85
+ * expired token that a fresh run refreshes. Offering `--fix` for these only makes the
86
+ * health hook report the same row as "repair did not clear" every day.
87
+ */
88
+ export function manifestFailureNotFixable(failure) {
89
+ if (failure.reason === "soft_skip" || failure.reason === "rejected_identity")
90
+ return true;
91
+ // 401 is left fixable: an expired token is refreshed by the next run.
92
+ return failure.status === 403 || failure.status === 404;
93
+ }
55
94
  /** Fill in the optional dependencies so call sites never branch on undefined. */
56
95
  function withDefaults(deps, hqRoot) {
57
96
  return {
@@ -782,7 +821,21 @@ function manifestResults(deps, journals) {
782
821
  }
783
822
  }
784
823
  const command = `hq sync manifest${scope ? ` --scope ${scope}` : ""}`;
785
- const message = `Scope '${entry.scopeKey}' last uploaded a sync manifest ${days} day${days === 1 ? "" : "s"} ago (${entry.lastUploadAt}) — the daily audit pass is not running.`;
824
+ const failure = manifestLastFailure(entry);
825
+ const age = `Scope '${entry.scopeKey}' last uploaded a sync manifest ${days} day${days === 1 ? "" : "s"} ago (${entry.lastUploadAt})`;
826
+ // A pass that ran and failed is not "not running": name the stored reason.
827
+ const message = failure
828
+ ? `${age}. The last pass (${failure.at}) failed with ${failure.reason}${failure.status !== undefined ? ` (HTTP ${failure.status})` : ""}, ${failure.attempts} failed pass${failure.attempts === 1 ? "" : "es"} in a row.`
829
+ : `${age} — the daily audit pass is not running.`;
830
+ if (failure && manifestFailureNotFixable(failure)) {
831
+ return {
832
+ status: "WARN",
833
+ checkId,
834
+ reasonCode: "manifest-upload-rejected",
835
+ message,
836
+ remediation: `--fix cannot clear this from this machine. Check you are signed in to the right HQ account (/hq-whoami), then run \`${command}\` and read its error.`,
837
+ };
838
+ }
786
839
  // With the kill switch set the upload exits 0 and sends nothing, so the
787
840
  // row carries no `--fix` target and names the switch instead.
788
841
  if (deps.manifestUploadsDisabled()) {
@@ -157,8 +157,13 @@ export interface RefreshManifestTarget {
157
157
  }
158
158
  /** Outcome of one {@link ApplyFixesOptions.refreshManifest} call. */
159
159
  export interface RefreshManifestResult {
160
- /** Whether the upload command exited 0. The re-check is the real verdict. */
160
+ /**
161
+ * Whether the upload command exited 0 and, when it printed a `--json`
162
+ * outcome, that outcome was `uploaded`. The re-check is the real verdict.
163
+ */
161
164
  ok: boolean;
165
+ /** The `hq sync manifest --json` status token, when the pass did not upload. */
166
+ passStatus?: string;
162
167
  /** One-line summary. */
163
168
  message: string;
164
169
  /**
@@ -333,11 +338,28 @@ export interface RefreshSyncSpawnDeps {
333
338
  export declare const REFRESH_SYNC_LOCK_TIMEOUT_SEC = 30;
334
339
  /**
335
340
  * Default `refresh-manifest` repair: a bounded `hq sync manifest --scope <s>`
336
- * against the tree `--fix` is repairing. Exit 0 also covers throttled,
337
- * disabled, and skipped passes, so the post-fix re-check decides whether the
338
- * row cleared. A timeout or non-zero exit is reported, not thrown.
341
+ * against the tree `--fix` is repairing, with `--json` so a pass that exits 0
342
+ * without uploading (soft_skipped, throttled, disabled, locked,
343
+ * resend_full_scheduled) is reported as not uploaded. A `resend_full` answer
344
+ * is retried once with `--full`. The post-fix re-check still decides whether
345
+ * the row cleared. A timeout or non-zero exit is reported, not thrown.
339
346
  */
340
347
  export declare function defaultRefreshManifest(target: RefreshManifestTarget): Promise<RefreshManifestResult>;
348
+ type ManifestSpawnResult = Pick<SpawnSyncReturns<string>, "error" | "status" | "signal"> & Partial<Pick<SpawnSyncReturns<string>, "stdout" | "stderr">>;
349
+ /**
350
+ * Run one upload and, when the server answered `resend_full` (it dropped or
351
+ * refused the delta base), run it once more with `--full`. Without the retry
352
+ * the scope stays stale until the next day's --fix run, and the health hook
353
+ * reports it as unrepaired in between (feedback_84d7a862).
354
+ */
355
+ export declare function refreshManifestWithRetry(scope: string, run: (full: boolean, json: boolean) => ManifestSpawnResult): RefreshManifestResult;
356
+ /** Commander's rejection of `--json` by a binary that predates it. */
357
+ export declare function manifestJsonUnsupported(result: ManifestSpawnResult): boolean;
358
+ /**
359
+ * The status token from the `hq sync manifest --json` line, or undefined when
360
+ * the output has none (an older binary, or a pass that printed only a summary).
361
+ */
362
+ export declare function manifestPassStatus(stdout: string | undefined): string | undefined;
341
363
  /**
342
364
  * Map one `hq sync manifest` spawn result to a {@link RefreshManifestResult}.
343
365
  * A spawn killed by the timeout has a null status, so it is reported as a
@@ -735,20 +735,95 @@ function defaultLocksDir() {
735
735
  export const REFRESH_SYNC_LOCK_TIMEOUT_SEC = 30;
736
736
  /**
737
737
  * Default `refresh-manifest` repair: a bounded `hq sync manifest --scope <s>`
738
- * against the tree `--fix` is repairing. Exit 0 also covers throttled,
739
- * disabled, and skipped passes, so the post-fix re-check decides whether the
740
- * row cleared. A timeout or non-zero exit is reported, not thrown.
738
+ * against the tree `--fix` is repairing, with `--json` so a pass that exits 0
739
+ * without uploading (soft_skipped, throttled, disabled, locked,
740
+ * resend_full_scheduled) is reported as not uploaded. A `resend_full` answer
741
+ * is retried once with `--full`. The post-fix re-check still decides whether
742
+ * the row cleared. A timeout or non-zero exit is reported, not thrown.
741
743
  */
742
744
  export function defaultRefreshManifest(target) {
743
- const hqBin = process.argv[1];
744
- const args = ["sync", "manifest", "--scope", target.scope, "--hq-root", target.hqRoot];
745
- const result = spawnSync(hqBin ? process.execPath : "hq", hqBin ? [hqBin, ...args] : args, {
746
- encoding: "utf8",
747
- timeout: REFRESH_SYNC_TIMEOUT_MS,
748
- cwd: target.hqRoot,
749
- });
750
- return Promise.resolve(manifestUploadResult(target.scope, result));
745
+ return Promise.resolve(refreshManifestWithRetry(target.scope, (full, json) => {
746
+ const hqBin = process.argv[1];
747
+ const args = [
748
+ "sync",
749
+ "manifest",
750
+ "--scope",
751
+ target.scope,
752
+ "--hq-root",
753
+ target.hqRoot,
754
+ ...(json ? ["--json"] : []),
755
+ ...(full ? ["--full"] : []),
756
+ ];
757
+ return spawnSync(hqBin ? process.execPath : "hq", hqBin ? [hqBin, ...args] : args, {
758
+ encoding: "utf8",
759
+ timeout: REFRESH_SYNC_TIMEOUT_MS,
760
+ cwd: target.hqRoot,
761
+ });
762
+ }));
763
+ }
764
+ /**
765
+ * Run one upload and, when the server answered `resend_full` (it dropped or
766
+ * refused the delta base), run it once more with `--full`. Without the retry
767
+ * the scope stays stale until the next day's --fix run, and the health hook
768
+ * reports it as unrepaired in between (feedback_84d7a862).
769
+ */
770
+ export function refreshManifestWithRetry(scope, run) {
771
+ const firstRaw = run(false, true);
772
+ // `hq` from PATH can be an older binary without `--json`. Fall back to the
773
+ // exit-code handling it always had instead of reporting a failure.
774
+ if (manifestJsonUnsupported(firstRaw))
775
+ return manifestUploadResult(scope, run(false, false));
776
+ const first = manifestUploadResult(scope, firstRaw);
777
+ if (first.passStatus !== "resend_full_scheduled")
778
+ return first;
779
+ const retry = manifestUploadResult(scope, run(true, true));
780
+ if (retry.passStatus !== "resend_full_scheduled")
781
+ return retry;
782
+ return {
783
+ ...retry,
784
+ reason: "the server asked for a full manifest again after a --full retry; a full resend is queued, so run `hq sync` again later",
785
+ };
786
+ }
787
+ /** Commander's rejection of `--json` by a binary that predates it. */
788
+ export function manifestJsonUnsupported(result) {
789
+ if (result.status === 0 || result.status === null)
790
+ return false;
791
+ return /unknown option ['"]?--json/i.test(`${result.stderr ?? ""}\n${result.stdout ?? ""}`);
792
+ }
793
+ /**
794
+ * The status token from the `hq sync manifest --json` line, or undefined when
795
+ * the output has none (an older binary, or a pass that printed only a summary).
796
+ */
797
+ export function manifestPassStatus(stdout) {
798
+ const lines = (stdout ?? "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
799
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
800
+ const line = lines[index];
801
+ if (!line.startsWith("{"))
802
+ continue;
803
+ try {
804
+ const parsed = JSON.parse(line);
805
+ if (typeof parsed.status === "string" && /^[a-z_]{1,40}$/.test(parsed.status)) {
806
+ return parsed.status;
807
+ }
808
+ }
809
+ catch {
810
+ // Not the outcome line.
811
+ }
812
+ }
813
+ return undefined;
751
814
  }
815
+ /**
816
+ * Plain-words reason for a pass that exited 0 without uploading. Each one
817
+ * leaves `lastUploadAt` unchanged, so the stale row cannot clear.
818
+ */
819
+ const MANIFEST_NOT_UPLOADED_REASONS = {
820
+ soft_skipped: "the sync service did not accept manifest uploads (the pass was skipped)",
821
+ throttled: "the pass was throttled and sent nothing",
822
+ disabled: "manifest uploads are turned off on this machine",
823
+ locked: "another manifest pass for this scope was already running",
824
+ resend_full_scheduled: "the server asked for a fresh full manifest and the upload did not finish",
825
+ printed: "the pass only printed the manifest",
826
+ };
752
827
  /**
753
828
  * Map one `hq sync manifest` spawn result to a {@link RefreshManifestResult}.
754
829
  * A spawn killed by the timeout has a null status, so it is reported as a
@@ -778,6 +853,15 @@ export function manifestUploadResult(scope, result) {
778
853
  : {}),
779
854
  };
780
855
  }
856
+ const passStatus = manifestPassStatus(result.stdout);
857
+ if (passStatus && passStatus !== "uploaded") {
858
+ return {
859
+ ok: false,
860
+ passStatus,
861
+ reason: MANIFEST_NOT_UPLOADED_REASONS[passStatus] ?? `the pass ended '${passStatus}' without uploading`,
862
+ message: `manifest upload for '${scope}' did not upload (${passStatus})`,
863
+ };
864
+ }
781
865
  return { ok: true, message: `uploaded the manifest for '${scope}'` };
782
866
  }
783
867
  /** `hq sync manifest` output for a company this identity is not a member of. */
@@ -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", "company-not-resolved", "legacy-personal-journal", "never-synced", "invalid-last-sync", "stale-threshold", "refresh-in-progress", "refresh-lock-held", "manifest-unavailable", "manifest-state-unavailable", "no-manifest-scopes", "never-uploaded", "invalid-upload-time", "unresolved-company-uid", "realtime-lag-stuck", "receiver-permission-denied", "pull-access-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", "company-not-resolved", "legacy-personal-journal", "never-synced", "invalid-last-sync", "stale-threshold", "refresh-in-progress", "refresh-lock-held", "manifest-unavailable", "manifest-state-unavailable", "no-manifest-scopes", "never-uploaded", "invalid-upload-time", "manifest-upload-rejected", "unresolved-company-uid", "realtime-lag-stuck", "receiver-permission-denied", "pull-access-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;
@@ -38,6 +38,7 @@ export const SYNC_REASON_CODES = [
38
38
  "no-manifest-scopes",
39
39
  "never-uploaded",
40
40
  "invalid-upload-time",
41
+ "manifest-upload-rejected",
41
42
  "unresolved-company-uid",
42
43
  "realtime-lag-stuck",
43
44
  "receiver-permission-denied",
@@ -12,6 +12,33 @@ import { backoffDelayMs, RECONNECT_MIN_DELAY_MS } from "./presence.js";
12
12
  import { AGENT_INBOX_PUSH_STATE_BASENAME, SYNC_WAKE_STATE_BASENAME } from "../paths.js";
13
13
  export { AGENT_INBOX_PUSH_STATE_BASENAME, SYNC_WAKE_STATE_BASENAME } from "../paths.js";
14
14
  const INBOX_PUSH_HEARTBEAT_MS = 5_000;
15
+ const SYNC_WAKE_KEYS = new Set(["v", "type", "vault", "head", "rootId"]);
16
+ const SYNC_WAKE_VAULT = /^(?:prs|cmp|agt)_[0-9A-Za-z]+$/;
17
+ const SYNC_WAKE_ROOT = /^[0-9a-f]{64}$/i;
18
+ function isSyncWakeEnvelope(payload) {
19
+ let parsed;
20
+ try {
21
+ parsed = JSON.parse(Buffer.isBuffer(payload) ? payload.toString("utf8") : payload);
22
+ }
23
+ catch {
24
+ return false;
25
+ }
26
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
27
+ return false;
28
+ const message = parsed;
29
+ const keys = Object.keys(message);
30
+ if (keys.length !== SYNC_WAKE_KEYS.size || keys.some((key) => !SYNC_WAKE_KEYS.has(key))) {
31
+ return false;
32
+ }
33
+ return message.v === 1 &&
34
+ message.type === "vault.head" &&
35
+ typeof message.vault === "string" &&
36
+ SYNC_WAKE_VAULT.test(message.vault) &&
37
+ Number.isSafeInteger(message.head) &&
38
+ Number(message.head) > 0 &&
39
+ (message.rootId === null ||
40
+ (typeof message.rootId === "string" && SYNC_WAKE_ROOT.test(message.rootId)));
41
+ }
15
42
  function readWakeSequence(meshRoot, basename, label, onInfo) {
16
43
  const target = path.join(meshRoot, basename);
17
44
  try {
@@ -260,6 +287,10 @@ export async function startWorkCacheSubscriber(opts) {
260
287
  return;
261
288
  }
262
289
  if (syncTopic && _topic === syncTopic && syncSubscribed) {
290
+ if (!isSyncWakeEnvelope(payload)) {
291
+ opts.onInfo?.("sync wake ignored (invalid envelope)");
292
+ return;
293
+ }
263
294
  syncWakeSequence += 1;
264
295
  persistSyncState(true);
265
296
  return;
@@ -91,6 +91,13 @@ export declare function prepareOrganizeDecision(input: {
91
91
  fetchCandidates?: FetchCandidatesFn;
92
92
  /** Skip network; leave unresolved without candidates. */
93
93
  offline?: boolean;
94
+ /**
95
+ * Membership-validated slug -> companyUid. Reconcile can persist a session
96
+ * with only `companySlug` (for example a local-only project bound while the
97
+ * membership lookup was offline or failed). Online organize uses this to
98
+ * recover the uid instead of reporting `candidates_unavailable`.
99
+ */
100
+ resolveCompanyUid?: (companySlug: string) => Promise<string | null | undefined>;
94
101
  }): Promise<OrganizeListResult>;
95
102
  /** True when a local decision's server window has passed (or is unparsable). */
96
103
  export declare function isLocalDecisionExpired(expiresAt: string | undefined, now?: Date): boolean;
@@ -76,7 +76,7 @@ export function decisionFromCandidates(sessionId, response) {
76
76
  */
77
77
  export async function prepareOrganizeDecision(input) {
78
78
  const nowFn = input.now ?? (() => new Date());
79
- const state = readSessionState(input.sessionId, input.root);
79
+ let state = readSessionState(input.sessionId, input.root);
80
80
  if (!state) {
81
81
  return {
82
82
  sessionId: input.sessionId,
@@ -114,6 +114,26 @@ export async function prepareOrganizeDecision(input) {
114
114
  reason: state.contextStatus,
115
115
  };
116
116
  }
117
+ // Recover the uid after the status gates, before refreshExpired and the
118
+ // candidates_unavailable gate both read it.
119
+ if (!state.companyUid &&
120
+ state.companySlug &&
121
+ !input.offline &&
122
+ input.fetchCandidates &&
123
+ input.resolveCompanyUid) {
124
+ let recovered;
125
+ try {
126
+ recovered = await input.resolveCompanyUid(state.companySlug);
127
+ }
128
+ catch {
129
+ recovered = undefined;
130
+ }
131
+ if (typeof recovered === "string" && recovered.startsWith("cmp_")) {
132
+ const withUid = mergeLocalSessionFields({ ...state, companyUid: recovered, updatedAt: nowFn().toISOString() }, state);
133
+ writeSessionState(withUid, input.root);
134
+ state = withUid;
135
+ }
136
+ }
117
137
  // Ask-once: an existing decision is never replaced or re-armed — unless its
118
138
  // server decision window has expired. The server stamps a fresh window only
119
139
  // when candidates are listed, so returning the stale decision would make
@@ -215,6 +215,7 @@ export function beforeSend(event, _hint) {
215
215
  "no-manifest-scopes",
216
216
  "never-uploaded",
217
217
  "invalid-upload-time",
218
+ "manifest-upload-rejected",
218
219
  "unresolved-company-uid",
219
220
  ]);
220
221
  const platforms = new Set([
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.345.169",
3
+ "version": "5.345.170",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -114,11 +114,11 @@
114
114
  "*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}": "eslint --cache --no-error-on-unmatched-pattern"
115
115
  },
116
116
  "optionalDependencies": {
117
- "@indigoai-us/hq-hook-win32-x64": "5.345.169",
118
- "@indigoai-us/hq-hook-win32-arm64": "5.345.169",
119
- "@indigoai-us/hq-hook-darwin-x64": "5.345.169",
120
- "@indigoai-us/hq-hook-darwin-arm64": "5.345.169",
121
- "@indigoai-us/hq-hook-linux-x64": "5.345.169",
122
- "@indigoai-us/hq-hook-linux-arm64": "5.345.169"
117
+ "@indigoai-us/hq-hook-win32-x64": "5.345.170",
118
+ "@indigoai-us/hq-hook-win32-arm64": "5.345.170",
119
+ "@indigoai-us/hq-hook-darwin-x64": "5.345.170",
120
+ "@indigoai-us/hq-hook-darwin-arm64": "5.345.170",
121
+ "@indigoai-us/hq-hook-linux-x64": "5.345.170",
122
+ "@indigoai-us/hq-hook-linux-arm64": "5.345.170"
123
123
  }
124
124
  }