@indigoai-us/hq-cli 5.345.169 → 5.345.171

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 (140) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/assets/scaffold/core/scripts/hq-delegate-send.sh +1 -1
  3. package/dist/command-catalog.generated.d.ts +181 -9
  4. package/dist/command-catalog.generated.js +224 -10
  5. package/dist/commands/access.d.ts +32 -1
  6. package/dist/commands/access.js +119 -5
  7. package/dist/commands/agent-kit.d.ts +1 -1
  8. package/dist/commands/agent-kit.js +3 -1
  9. package/dist/commands/agents.js +2 -1
  10. package/dist/commands/bot.d.ts +3 -0
  11. package/dist/commands/bot.js +141 -14
  12. package/dist/commands/cloud.d.ts +78 -1
  13. package/dist/commands/cloud.js +274 -9
  14. package/dist/commands/core-company-index-scope-flag.d.ts +8 -1
  15. package/dist/commands/core-company-index-scope-flag.js +9 -2
  16. package/dist/commands/core-delegate-w1b.js +5 -2
  17. package/dist/commands/core-dm-bind-native.js +19 -4
  18. package/dist/commands/core-rebuild-index-fast.js +24 -6
  19. package/dist/commands/core.js +18 -6
  20. package/dist/commands/dm.d.ts +17 -3
  21. package/dist/commands/dm.js +121 -26
  22. package/dist/commands/feedback-triage.d.ts +43 -1
  23. package/dist/commands/feedback-triage.js +123 -23
  24. package/dist/commands/feedback.d.ts +40 -1
  25. package/dist/commands/feedback.js +184 -4
  26. package/dist/commands/files.d.ts +42 -0
  27. package/dist/commands/files.js +223 -30
  28. package/dist/commands/integrations-api.d.ts +4 -0
  29. package/dist/commands/integrations-api.js +18 -2
  30. package/dist/commands/integrations-connect.d.ts +10 -3
  31. package/dist/commands/integrations-connect.js +70 -6
  32. package/dist/commands/integrations-core.d.ts +23 -0
  33. package/dist/commands/integrations-core.js +100 -0
  34. package/dist/commands/integrations-manage.js +8 -1
  35. package/dist/commands/integrations.js +49 -5
  36. package/dist/commands/lanes-loop.d.ts +20 -0
  37. package/dist/commands/lanes-loop.js +69 -11
  38. package/dist/commands/lanes.js +9 -1
  39. package/dist/commands/mesh.js +6 -0
  40. package/dist/commands/rescue.d.ts +9 -0
  41. package/dist/commands/rescue.js +31 -0
  42. package/dist/commands/run.js +115 -40
  43. package/dist/commands/scan.d.ts +5 -1
  44. package/dist/commands/scan.js +16 -4
  45. package/dist/commands/secrets.js +50 -9
  46. package/dist/commands/sync-manifest.d.ts +16 -0
  47. package/dist/commands/sync-manifest.js +17 -1
  48. package/dist/hq-cloud-sync-runner/bin/sync-runner.js +2367 -2109
  49. package/dist/hq-cloud-sync-runner/package.json +1 -1
  50. package/dist/lib/agent-kit/inbox-state.d.ts +17 -0
  51. package/dist/lib/agent-kit/inbox-state.js +41 -20
  52. package/dist/lib/agent-kit/kit-config.d.ts +2 -0
  53. package/dist/lib/agent-kit/kit-config.js +6 -0
  54. package/dist/lib/agent-kit/mcp/tools.js +12 -5
  55. package/dist/lib/agent-kit/run/heartbeat.d.ts +6 -3
  56. package/dist/lib/agent-kit/run/heartbeat.js +6 -3
  57. package/dist/lib/agent-kit/run/inbox.d.ts +8 -3
  58. package/dist/lib/agent-kit/run/inbox.js +42 -10
  59. package/dist/lib/agent-kit/run/request-timeout.d.ts +26 -0
  60. package/dist/lib/agent-kit/run/request-timeout.js +51 -0
  61. package/dist/lib/bot/api.d.ts +1 -1
  62. package/dist/lib/bot/config.d.ts +10 -0
  63. package/dist/lib/bot/config.js +17 -1
  64. package/dist/lib/bot/decisions.d.ts +62 -0
  65. package/dist/lib/bot/decisions.js +228 -0
  66. package/dist/lib/bot/fleet-local.d.ts +127 -0
  67. package/dist/lib/bot/fleet-local.js +638 -0
  68. package/dist/lib/bot/index.d.ts +2 -0
  69. package/dist/lib/bot/index.js +2 -0
  70. package/dist/lib/bot/local-config.d.ts +3 -2
  71. package/dist/lib/bot/local-config.js +5 -2
  72. package/dist/lib/bot/promote.d.ts +10 -0
  73. package/dist/lib/bot/promote.js +3 -2
  74. package/dist/lib/bot/run.d.ts +7 -0
  75. package/dist/lib/bot/run.js +104 -1
  76. package/dist/lib/bot/runtime/index.js +8 -4
  77. package/dist/lib/bot/runtime/scratch-dir.d.ts +24 -0
  78. package/dist/lib/bot/runtime/scratch-dir.js +41 -0
  79. package/dist/lib/caller-principal.d.ts +4 -0
  80. package/dist/lib/caller-principal.js +40 -0
  81. package/dist/lib/capacity/collectors/grok.js +8 -2
  82. package/dist/lib/cli-flag-keys.d.ts +2 -0
  83. package/dist/lib/cli-flag-keys.js +2 -0
  84. package/dist/lib/daemon/catalog.d.ts +4 -0
  85. package/dist/lib/daemon/catalog.js +63 -29
  86. package/dist/lib/daemon/config.d.ts +7 -0
  87. package/dist/lib/daemon/config.js +32 -0
  88. package/dist/lib/daemon/run.d.ts +3 -2
  89. package/dist/lib/daemon/run.js +23 -3
  90. package/dist/lib/daemon/supervisor.d.ts +1 -0
  91. package/dist/lib/daemon/supervisor.js +4 -0
  92. package/dist/lib/daemon/sync-core-canary.d.ts +12 -0
  93. package/dist/lib/daemon/sync-core-canary.js +61 -0
  94. package/dist/lib/doctor/checks/release-links.d.ts +62 -1
  95. package/dist/lib/doctor/checks/release-links.js +188 -2
  96. package/dist/lib/doctor/checks/sync-health.d.ts +32 -0
  97. package/dist/lib/doctor/checks/sync-health.js +54 -1
  98. package/dist/lib/doctor/fix/apply.d.ts +26 -4
  99. package/dist/lib/doctor/fix/apply.js +149 -11
  100. package/dist/lib/doctor/fix/remediation.d.ts +8 -1
  101. package/dist/lib/doctor/fix/remediation.js +22 -0
  102. package/dist/lib/doctor/types.d.ts +1 -1
  103. package/dist/lib/doctor/types.js +1 -0
  104. package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
  105. package/dist/lib/flag-registry-worker-source.generated.js +1 -1
  106. package/dist/lib/index-render/index.d.ts +9 -0
  107. package/dist/lib/index-render/index.js +65 -5
  108. package/dist/lib/index-render/session-scope.d.ts +69 -0
  109. package/dist/lib/index-render/session-scope.js +279 -0
  110. package/dist/lib/lanes/admission.js +7 -20
  111. package/dist/lib/lanes/reconcile.js +13 -15
  112. package/dist/lib/lanes/stale-lock.d.ts +50 -0
  113. package/dist/lib/lanes/stale-lock.js +105 -0
  114. package/dist/lib/lanes/store.js +11 -76
  115. package/dist/lib/mesh/live/daemon/work-subscribe.js +31 -0
  116. package/dist/lib/notify-recipient.d.ts +10 -4
  117. package/dist/lib/notify-recipient.js +16 -4
  118. package/dist/lib/secret-input-link.d.ts +42 -0
  119. package/dist/lib/secret-input-link.js +93 -0
  120. package/dist/lib/secret-scan/range.d.ts +57 -0
  121. package/dist/lib/secret-scan/range.js +266 -0
  122. package/dist/lib/secret-scan/repo-config.d.ts +18 -3
  123. package/dist/lib/secret-scan/repo-config.js +135 -2
  124. package/dist/lib/work-context/organize.d.ts +7 -0
  125. package/dist/lib/work-context/organize.js +21 -1
  126. package/dist/run/script-bound-schema.d.ts +27 -0
  127. package/dist/run/script-bound-schema.js +97 -0
  128. package/dist/secrets-env-action.d.ts +2 -0
  129. package/dist/secrets-env-action.js +7 -2
  130. package/dist/sentry-before-send.js +1 -0
  131. package/dist/utils/cognito-session.d.ts +3 -30
  132. package/dist/utils/cognito-session.js +2 -100
  133. package/dist/utils/machine-principal-state.d.ts +38 -0
  134. package/dist/utils/machine-principal-state.js +110 -0
  135. package/dist/utils/safe-link.js +9 -0
  136. package/dist/utils/script-bound-exec.d.ts +158 -0
  137. package/dist/utils/script-bound-exec.js +568 -0
  138. package/dist/utils/sync-watchdog.d.ts +92 -0
  139. package/dist/utils/sync-watchdog.js +250 -0
  140. package/package.json +8 -8
@@ -5,15 +5,16 @@
5
5
  * legacy units, the desktop app), so enabling a service, creating a bot, or
6
6
  * signing in takes effect without restarting the daemon.
7
7
  */
8
- import { type UnitPlan } from "./catalog.js";
8
+ import { type PlanContext, type ServiceName, type UnitPlan } from "./catalog.js";
9
9
  import { type LegacyUnit } from "./legacy.js";
10
10
  import { type DaemonLockResult } from "./lock.js";
11
11
  import { type MachineType } from "./machine-type.js";
12
12
  import { type PickedHqCloud } from "./hq-cloud.js";
13
- import { type HostApp } from "./supervisor.js";
13
+ import { type HostApp, type Supervisor } from "./supervisor.js";
14
14
  export declare const LOG_ROTATE_BYTES: number;
15
15
  /** Exit code for "restart me": non-zero so every service manager relaunches (Windows tasks only restart on failure). */
16
16
  export declare const RESTART_EXIT_CODE = 75;
17
+ export declare function planUnitsWithSyncCoreInterlock(services: readonly ServiceName[], context: PlanContext, supervisor: Pick<Supervisor, "runningPlanKey"> | null | undefined): UnitPlan[];
17
18
  /** A bounded shutdown deadline that can account for sync push persistence. */
18
19
  export declare function createShutdownCap(): {
19
20
  promise: Promise<"timeout">;
@@ -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,12 @@
1
+ import type { SyncCoreCanaryConfig } from "./config.js";
2
+ export interface SyncCoreCanaryPlan {
3
+ binaryPath?: string;
4
+ vaultUid?: string;
5
+ /** The one file the engine may change; absent when `fullVault` is set. */
6
+ canaryPath?: string;
7
+ /** The engine applies every planned small file in the vault (`sync.full_vault = true`). */
8
+ fullVault?: boolean;
9
+ blockedReason?: string;
10
+ }
11
+ export declare function inspectSyncCoreCanary(home: string, hqRoot: string | undefined, config: SyncCoreCanaryConfig, platform: NodeJS.Platform): SyncCoreCanaryPlan;
12
+ //# sourceMappingURL=sync-core-canary.d.ts.map
@@ -0,0 +1,61 @@
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
+ const fullVault = sync.full_vault === true;
44
+ if (fullVault && canaryPath) {
45
+ return blocked(config, "the sync-core config must set either full_vault = true or canary_path, not both");
46
+ }
47
+ if (!fullVault && !canaryPath) {
48
+ return blocked(config, "the sync-core config must name one canary path or set full_vault = true");
49
+ }
50
+ const ownedPath = path.join(home, ".hq", "sync-core", "owned", `${config.vaultUid}.json`);
51
+ try {
52
+ const owned = record(JSON.parse(fs.readFileSync(ownedPath, "utf8")));
53
+ if (owned?.vault !== config.vaultUid)
54
+ throw new Error("mismatch");
55
+ }
56
+ catch {
57
+ return blocked(config, `the sync-core ownership record for ${config.vaultUid} is missing or invalid`);
58
+ }
59
+ return fullVault ? { ...config, fullVault: true } : { ...config, canaryPath };
60
+ }
61
+ //# sourceMappingURL=sync-core-canary.js.map
@@ -9,7 +9,9 @@ import type { CheckContext, CheckFamily, CheckResult } from "../types.js";
9
9
  export declare const RELEASE_LINKS_FAMILY_ID = "release-links";
10
10
  export declare const RELEASE_LINKS_FAMILY_TITLE = "Release links";
11
11
  export declare const RELEASE_LINKS_CHECK_ID = "release-links.resolve";
12
- export declare const RELEASE_LINK_REMEDIATION = "Run `hq rescue -y` again. On Windows without Developer Mode, a directory link is written as a junction and a file link as a copy.";
12
+ export declare const RELEASE_LINK_REMEDIATION = "Run `hq doctor --fix` (or `hq rescue -y`) to restore the shipped link. A dangling link that points outside the HQ root and the temp folder is treated as your own and left alone. On Windows, a directory link is written as a junction and a file link as a copy when symlinks are not allowed.";
13
+ /** Message prefix for a link whose target is missing; `--fix` keys off it. */
14
+ export declare const DANGLING_RELEASE_LINK_PROBLEM = "does not resolve.";
13
15
  interface ReleaseLink {
14
16
  rel: string;
15
17
  linkText: string;
@@ -19,5 +21,64 @@ interface ReleaseLink {
19
21
  export declare const RELEASE_LINKS: readonly ReleaseLink[];
20
22
  export declare const releaseLinksFamily: CheckFamily;
21
23
  export declare function checkReleaseLinks(context: CheckContext): CheckResult[];
24
+ /** Where a rescue may stage its extraction folder. */
25
+ export interface RescueStagingDeps {
26
+ /** The system temp folder. Default `os.tmpdir()`. */
27
+ tmpDir?: string;
28
+ /** The rescue backup root. Default `$HQ_BACKUP_DIR` or `~/.hq/backups`. */
29
+ backupRoot?: string;
30
+ }
31
+ /** What {@link repairDanglingReleaseLinks} did to one release link. */
32
+ export interface ReleaseLinkRepair {
33
+ rel: string;
34
+ /**
35
+ * `repointed`: the link was rewritten (see `linkText`). `removed`: the
36
+ * shipped target is missing too, so the dangling link was deleted.
37
+ * `skipped`: the link points outside the HQ root and the temp folder, so it
38
+ * is treated as the user's own link and left alone (see `reason`).
39
+ * `failed`: the repair threw for this link only (see `reason`).
40
+ */
41
+ action: "repointed" | "removed" | "skipped" | "failed";
42
+ /** The absolute target the link now resolves to (repointed only). */
43
+ target?: string;
44
+ /** The text written into the link (repointed only). */
45
+ linkText?: string;
46
+ reason?: string;
47
+ }
48
+ export interface RepairReleaseLinksDeps {
49
+ platform?: NodeJS.Platform;
50
+ /** Repair only this release link (a `RELEASE_LINKS` rel). */
51
+ only?: string;
52
+ /** The temp folder a rescue extracts into. Default `os.tmpdir()`. */
53
+ tmpDir?: string;
54
+ /** The rescue backup root. Default `$HQ_BACKUP_DIR` or `~/.hq/backups`. */
55
+ backupRoot?: string;
56
+ }
57
+ /**
58
+ * Repair HQ-managed release links whose target no longer exists, such as a
59
+ * `.agents/skills` or `.codex/claude` link an earlier rescue pointed at its
60
+ * deleted temp extraction folder (feedback_2ab63e50).
61
+ *
62
+ * Only the fixed {@link RELEASE_LINKS} paths are considered, and only when the
63
+ * entry itself is a symlink or junction that does not resolve. A real file or
64
+ * directory is never touched, a link that resolves anywhere is left alone, and
65
+ * the link's parent folder must sit inside the HQ root, so a `.agents` folder
66
+ * that is itself a link out of the tree is skipped.
67
+ *
68
+ * Ownership rule: a dangling link is repaired only when its current target is
69
+ * under the HQ root or under the system temp folder (where a rescue extracts
70
+ * its staging copy). A dangling link anywhere else, such as `/Volumes/X`, is
71
+ * the user's own link to a drive that is not mounted; it is left alone and
72
+ * reported as `skipped`.
73
+ *
74
+ * The repaired link carries the text HQ ships (`../.claude/skills`), so the
75
+ * git-tracked link is unchanged. On Windows a directory link is an absolute
76
+ * junction and a file link an absolute symlink, falling back to a copy. When
77
+ * the shipped target is missing too, the dangling link is removed and nothing
78
+ * is created. The old entry is removed with {@link removeLinkOnly}, which never
79
+ * follows it. A failure on one link is reported as `failed` and the remaining
80
+ * links are still repaired.
81
+ */
82
+ export declare function repairDanglingReleaseLinks(hqRoot: string, deps?: RepairReleaseLinksDeps): ReleaseLinkRepair[];
22
83
  export {};
23
84
  //# sourceMappingURL=release-links.d.ts.map
@@ -6,11 +6,15 @@
6
6
  * user customization and is left alone.
7
7
  */
8
8
  import * as fs from "node:fs";
9
+ import * as os from "node:os";
9
10
  import * as path from "node:path";
11
+ import { removeLinkOnly } from "../../../utils/safe-link.js";
10
12
  export const RELEASE_LINKS_FAMILY_ID = "release-links";
11
13
  export const RELEASE_LINKS_FAMILY_TITLE = "Release links";
12
14
  export const RELEASE_LINKS_CHECK_ID = "release-links.resolve";
13
- export const RELEASE_LINK_REMEDIATION = "Run `hq rescue -y` again. On Windows without Developer Mode, a directory link is written as a junction and a file link as a copy.";
15
+ export const RELEASE_LINK_REMEDIATION = "Run `hq doctor --fix` (or `hq rescue -y`) to restore the shipped link. A dangling link that points outside the HQ root and the temp folder is treated as your own and left alone. On Windows, a directory link is written as a junction and a file link as a copy when symlinks are not allowed.";
16
+ /** Message prefix for a link whose target is missing; `--fix` keys off it. */
17
+ export const DANGLING_RELEASE_LINK_PROBLEM = "does not resolve.";
14
18
  const CYGWIN_SYMLINK_COOKIE = Buffer.from("!<symlink>", "latin1");
15
19
  /** Paths git records as symlinks in the hq-core release. */
16
20
  export const RELEASE_LINKS = [
@@ -80,7 +84,11 @@ function releaseLinkProblem(hqRoot, entry) {
80
84
  resolved = fs.realpathSync.native(destination);
81
85
  }
82
86
  catch {
83
- return "does not resolve. Its target is missing, often because an update linked it at a staging folder that was then deleted.";
87
+ const current = readlinkAbsOrNull(destination);
88
+ if (current !== null && !isRepairableDanglingTarget(hqRoot, current)) {
89
+ return `points at ${current}, which is missing and outside this HQ root. It is treated as your own link and left alone; fix or remove it yourself.`;
90
+ }
91
+ return `${DANGLING_RELEASE_LINK_PROBLEM} Its target is missing, often because an update linked it at a staging folder that was then deleted.`;
84
92
  }
85
93
  if (!pathIsInside(hqRoot, resolved)) {
86
94
  return "points outside this HQ root, so it does not resolve to the shipped target.";
@@ -108,6 +116,42 @@ function releaseLinkProblem(hqRoot, entry) {
108
116
  // A real directory in place of a directory link is a user replacement.
109
117
  return null;
110
118
  }
119
+ function readlinkAbsOrNull(destination) {
120
+ try {
121
+ return path.resolve(path.dirname(destination), fs.readlinkSync(destination));
122
+ }
123
+ catch {
124
+ return null;
125
+ }
126
+ }
127
+ /** Prefix of the folder hq-cloud's rescue creates with mkdtemp. */
128
+ const RESCUE_STAGING_SEGMENT_PREFIX = "hq-replace-rescue-";
129
+ /**
130
+ * The rescue staging roots, mirroring hq-cloud's `resolveRescueStagingRoot`:
131
+ * the system temp folder, or `tmp` beside the backup root (`~/.hq/tmp`, or
132
+ * next to `$HQ_BACKUP_DIR`) when the temp folder is on another filesystem.
133
+ */
134
+ function rescueStagingRoots(deps) {
135
+ const backupRoot = deps.backupRoot ?? process.env.HQ_BACKUP_DIR ?? path.join(os.homedir(), ".hq", "backups");
136
+ return [
137
+ ...tempRoots(deps.tmpDir ?? os.tmpdir()),
138
+ ...tempRoots(path.join(path.dirname(path.resolve(backupRoot)), "tmp")),
139
+ ];
140
+ }
141
+ /**
142
+ * A dangling link HQ may repair. Its current target is under the HQ root,
143
+ * under the system temp folder, under the rescue staging root beside the
144
+ * backup root, or has a path segment starting with `hq-replace-rescue-` (the
145
+ * rescue's mkdtemp folder, wherever it was staged). Anything else is the
146
+ * user's own link and is left alone.
147
+ */
148
+ function isRepairableDanglingTarget(hqRoot, current, deps = {}) {
149
+ const segments = path.resolve(current).split(path.sep);
150
+ if (segments.some((segment) => segment.startsWith(RESCUE_STAGING_SEGMENT_PREFIX)))
151
+ return true;
152
+ const roots = [...tempRoots(path.resolve(hqRoot)), ...rescueStagingRoots(deps)];
153
+ return roots.some((root) => isUnder(root, current));
154
+ }
111
155
  function containedTarget(hqRoot, entry) {
112
156
  const destination = path.join(hqRoot, entry.rel);
113
157
  const resolved = path.resolve(path.dirname(destination), entry.linkText);
@@ -149,4 +193,146 @@ function readFileOrNull(target) {
149
193
  return null;
150
194
  }
151
195
  }
196
+ /**
197
+ * Repair HQ-managed release links whose target no longer exists, such as a
198
+ * `.agents/skills` or `.codex/claude` link an earlier rescue pointed at its
199
+ * deleted temp extraction folder (feedback_2ab63e50).
200
+ *
201
+ * Only the fixed {@link RELEASE_LINKS} paths are considered, and only when the
202
+ * entry itself is a symlink or junction that does not resolve. A real file or
203
+ * directory is never touched, a link that resolves anywhere is left alone, and
204
+ * the link's parent folder must sit inside the HQ root, so a `.agents` folder
205
+ * that is itself a link out of the tree is skipped.
206
+ *
207
+ * Ownership rule: a dangling link is repaired only when its current target is
208
+ * under the HQ root or under the system temp folder (where a rescue extracts
209
+ * its staging copy). A dangling link anywhere else, such as `/Volumes/X`, is
210
+ * the user's own link to a drive that is not mounted; it is left alone and
211
+ * reported as `skipped`.
212
+ *
213
+ * The repaired link carries the text HQ ships (`../.claude/skills`), so the
214
+ * git-tracked link is unchanged. On Windows a directory link is an absolute
215
+ * junction and a file link an absolute symlink, falling back to a copy. When
216
+ * the shipped target is missing too, the dangling link is removed and nothing
217
+ * is created. The old entry is removed with {@link removeLinkOnly}, which never
218
+ * follows it. A failure on one link is reported as `failed` and the remaining
219
+ * links are still repaired.
220
+ */
221
+ export function repairDanglingReleaseLinks(hqRoot, deps = {}) {
222
+ const platform = deps.platform ?? process.platform;
223
+ let rootReal;
224
+ try {
225
+ rootReal = fs.realpathSync.native(path.resolve(hqRoot));
226
+ }
227
+ catch {
228
+ return [];
229
+ }
230
+ const repairs = [];
231
+ for (const entry of RELEASE_LINKS) {
232
+ if (deps.only !== undefined && entry.rel !== deps.only)
233
+ continue;
234
+ const destination = path.join(rootReal, entry.rel);
235
+ const lst = lstatOrNull(destination);
236
+ if (!lst || !lst.isSymbolicLink())
237
+ continue;
238
+ if (resolvesOrNull(destination) !== null)
239
+ continue;
240
+ const parentReal = resolvesOrNull(path.dirname(destination));
241
+ if (parentReal === null || !isUnder(rootReal, parentReal))
242
+ continue;
243
+ const target = path.resolve(path.dirname(destination), entry.linkText);
244
+ if (!isUnder(rootReal, target))
245
+ continue;
246
+ let current;
247
+ try {
248
+ current = path.resolve(path.dirname(destination), fs.readlinkSync(destination));
249
+ }
250
+ catch (err) {
251
+ repairs.push({ rel: entry.rel, action: "failed", reason: errorMessage(err) });
252
+ continue;
253
+ }
254
+ if (!isRepairableDanglingTarget(rootReal, current, { tmpDir: deps.tmpDir, backupRoot: deps.backupRoot })) {
255
+ repairs.push({
256
+ rel: entry.rel,
257
+ action: "skipped",
258
+ reason: `it points at ${current}, outside the HQ root, so it is left as your own link`,
259
+ });
260
+ continue;
261
+ }
262
+ const targetStat = statOrNull(target);
263
+ const targetUsable = targetStat !== null &&
264
+ (entry.kind === "dir" ? targetStat.isDirectory() : targetStat.isFile()) &&
265
+ isUnder(rootReal, resolvesOrNull(target) ?? "");
266
+ try {
267
+ removeLinkOnly(destination);
268
+ if (!targetUsable) {
269
+ repairs.push({ rel: entry.rel, action: "removed" });
270
+ continue;
271
+ }
272
+ const linkText = writeReleaseLink(entry, target, destination, platform);
273
+ repairs.push({ rel: entry.rel, action: "repointed", target, linkText });
274
+ }
275
+ catch (err) {
276
+ repairs.push({ rel: entry.rel, action: "failed", reason: errorMessage(err) });
277
+ }
278
+ }
279
+ return repairs;
280
+ }
281
+ /** Write the link and return the text it carries. */
282
+ function writeReleaseLink(entry, target, destination, platform) {
283
+ if (platform !== "win32") {
284
+ fs.symlinkSync(entry.linkText, destination, entry.kind);
285
+ return entry.linkText;
286
+ }
287
+ if (entry.kind === "dir") {
288
+ fs.symlinkSync(target, destination, "junction");
289
+ return target;
290
+ }
291
+ try {
292
+ fs.symlinkSync(target, destination, "file");
293
+ return target;
294
+ }
295
+ catch (err) {
296
+ const code = err.code;
297
+ if (code !== "EPERM" && code !== "EACCES")
298
+ throw err;
299
+ // Windows without the symlink privilege: the check accepts a copy whose
300
+ // bytes equal the target.
301
+ fs.copyFileSync(target, destination);
302
+ return target;
303
+ }
304
+ }
305
+ function tempRoots(tmpDir) {
306
+ const roots = [path.resolve(tmpDir)];
307
+ const real = resolvesOrNull(tmpDir);
308
+ if (real && !roots.includes(real))
309
+ roots.push(real);
310
+ return roots;
311
+ }
312
+ function errorMessage(err) {
313
+ return err instanceof Error ? err.message : String(err);
314
+ }
315
+ function isUnder(rootReal, candidate) {
316
+ if (!candidate)
317
+ return false;
318
+ const abs = path.resolve(candidate);
319
+ const base = rootReal.endsWith(path.sep) ? rootReal.slice(0, -1) : rootReal;
320
+ return abs === base || abs.startsWith(base + path.sep);
321
+ }
322
+ function resolvesOrNull(target) {
323
+ try {
324
+ return fs.realpathSync.native(target);
325
+ }
326
+ catch {
327
+ return null;
328
+ }
329
+ }
330
+ function statOrNull(target) {
331
+ try {
332
+ return fs.statSync(target);
333
+ }
334
+ catch {
335
+ return null;
336
+ }
337
+ }
152
338
  //# sourceMappingURL=release-links.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