@indigoai-us/hq-cli 5.345.155 → 5.345.159

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 (169) hide show
  1. package/CHANGELOG.md +289 -0
  2. package/assets/scaffold/core/scripts/rebuild-company-knowledge-index.sh +110 -2
  3. package/assets/scaffold/core/scripts/rebuild-projects-index.sh +101 -5
  4. package/assets/scaffold/core/scripts/worktree.sh +4 -0
  5. package/dist/command-catalog.generated.d.ts +536 -7
  6. package/dist/command-catalog.generated.js +676 -24
  7. package/dist/command-registration-plan.d.ts +18 -0
  8. package/dist/command-registration-plan.js +4 -0
  9. package/dist/commands/_patterns.d.ts +17 -0
  10. package/dist/commands/_patterns.js +22 -0
  11. package/dist/commands/agent-inbox.js +2 -2
  12. package/dist/commands/agent-kit.js +8 -6
  13. package/dist/commands/bot.js +7 -4
  14. package/dist/commands/channel-suggest.d.ts +56 -0
  15. package/dist/commands/channel-suggest.js +170 -0
  16. package/dist/commands/channels.js +7 -1
  17. package/dist/commands/cloud.d.ts +45 -0
  18. package/dist/commands/cloud.js +150 -10
  19. package/dist/commands/company.js +4 -3
  20. package/dist/commands/core-checkpoint-native.d.ts +18 -0
  21. package/dist/commands/core-checkpoint-native.js +67 -2
  22. package/dist/commands/core-pipeline-driver-native.d.ts +12 -1
  23. package/dist/commands/core-pipeline-driver-native.js +131 -5
  24. package/dist/commands/core.js +13 -1
  25. package/dist/commands/crm.js +83 -6
  26. package/dist/commands/deploy-access.d.ts +33 -0
  27. package/dist/commands/deploy-access.js +322 -0
  28. package/dist/commands/dm-attach.d.ts +106 -0
  29. package/dist/commands/dm-attach.js +184 -0
  30. package/dist/commands/dm.d.ts +37 -0
  31. package/dist/commands/dm.js +172 -10
  32. package/dist/commands/feedback-show.d.ts +40 -0
  33. package/dist/commands/feedback-show.js +261 -0
  34. package/dist/commands/feedback.d.ts +21 -0
  35. package/dist/commands/feedback.js +152 -2
  36. package/dist/commands/files-browse.d.ts +16 -0
  37. package/dist/commands/files-browse.js +52 -1
  38. package/dist/commands/files.d.ts +24 -0
  39. package/dist/commands/files.js +52 -1
  40. package/dist/commands/integrations-connect.js +7 -3
  41. package/dist/commands/integrations-core.d.ts +13 -1
  42. package/dist/commands/integrations-core.js +49 -4
  43. package/dist/commands/integrations.js +4 -2
  44. package/dist/commands/lanes-accounts.js +16 -1
  45. package/dist/commands/lanes-loop.js +14 -4
  46. package/dist/commands/lanes-message.js +6 -4
  47. package/dist/commands/lanes-resume.js +36 -13
  48. package/dist/commands/lanes-shared.d.ts +5 -0
  49. package/dist/commands/lanes-shared.js +13 -3
  50. package/dist/commands/lanes-watch.js +1 -0
  51. package/dist/commands/lanes.d.ts +20 -1
  52. package/dist/commands/lanes.js +201 -51
  53. package/dist/commands/mesh.js +149 -1
  54. package/dist/commands/monitor.d.ts +1 -0
  55. package/dist/commands/monitor.js +4 -1
  56. package/dist/commands/repo.d.ts +41 -0
  57. package/dist/commands/repo.js +331 -0
  58. package/dist/commands/scan.d.ts +3 -1
  59. package/dist/commands/scan.js +7 -3
  60. package/dist/commands/secrets.d.ts +37 -0
  61. package/dist/commands/secrets.js +185 -16
  62. package/dist/commands/signals.js +17 -5
  63. package/dist/commands/staff/listings.d.ts +41 -0
  64. package/dist/commands/staff/listings.js +160 -0
  65. package/dist/commands/staff/reset-test-account.d.ts +3 -2
  66. package/dist/commands/staff/reset-test-account.js +4 -3
  67. package/dist/hq-cloud-sync-runner/bin/sync-runner.js +2629 -1036
  68. package/dist/hq-cloud-sync-runner/package.json +1 -1
  69. package/dist/lib/agent-kit/inbox-state.d.ts +7 -0
  70. package/dist/lib/agent-kit/inbox-state.js +5 -1
  71. package/dist/lib/agent-kit/mcp/tools.js +2 -1
  72. package/dist/lib/agent-kit/run/inbox.js +2 -1
  73. package/dist/lib/agent-kit/wake-secrets.d.ts +7 -0
  74. package/dist/lib/agent-kit/wake-secrets.js +32 -0
  75. package/dist/lib/agent-kit/wake.d.ts +4 -0
  76. package/dist/lib/agent-kit/wake.js +49 -3
  77. package/dist/lib/bot/inflight.d.ts +7 -1
  78. package/dist/lib/bot/inflight.js +10 -2
  79. package/dist/lib/bot/prompt.js +3 -0
  80. package/dist/lib/bot/run.js +72 -20
  81. package/dist/lib/bot/session.d.ts +14 -0
  82. package/dist/lib/bot/session.js +70 -0
  83. package/dist/lib/capacity/collectors/claude-oauth.d.ts +6 -0
  84. package/dist/lib/capacity/collectors/claude-oauth.js +12 -0
  85. package/dist/lib/capacity/lane-login.d.ts +6 -0
  86. package/dist/lib/capacity/lane-login.js +5 -0
  87. package/dist/lib/core-utils/worktree.js +2 -0
  88. package/dist/lib/daemon/exit-cause.d.ts +43 -0
  89. package/dist/lib/daemon/exit-cause.js +88 -0
  90. package/dist/lib/daemon/handoff.d.ts +2 -0
  91. package/dist/lib/daemon/handoff.js +6 -1
  92. package/dist/lib/daemon/hq-child-bootstrap.mjs +20 -3
  93. package/dist/lib/daemon/paths.d.ts +2 -0
  94. package/dist/lib/daemon/paths.js +1 -0
  95. package/dist/lib/daemon/run.js +13 -0
  96. package/dist/lib/daemon/supervisor.d.ts +54 -1
  97. package/dist/lib/daemon/supervisor.js +297 -21
  98. package/dist/lib/daemon/unit-holds.d.ts +22 -0
  99. package/dist/lib/daemon/unit-holds.js +86 -0
  100. package/dist/lib/doctor/checks/sync-health.d.ts +9 -0
  101. package/dist/lib/doctor/checks/sync-health.js +66 -1
  102. package/dist/lib/doctor/company-resolution-state.d.ts +30 -0
  103. package/dist/lib/doctor/company-resolution-state.js +89 -0
  104. package/dist/lib/doctor/fix/apply.d.ts +36 -6
  105. package/dist/lib/doctor/fix/apply.js +128 -12
  106. package/dist/lib/doctor/types.d.ts +1 -1
  107. package/dist/lib/doctor/types.js +1 -0
  108. package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
  109. package/dist/lib/flag-registry-worker-source.generated.js +1 -1
  110. package/dist/lib/hook-engine/resolve.js +6 -2
  111. package/dist/lib/index-render/company-knowledge.d.ts +1 -1
  112. package/dist/lib/index-render/company-knowledge.js +4 -5
  113. package/dist/lib/index-render/index.js +44 -3
  114. package/dist/lib/index-render/projects.d.ts +1 -1
  115. package/dist/lib/index-render/projects.js +5 -5
  116. package/dist/lib/lanes/account-company.d.ts +28 -0
  117. package/dist/lib/lanes/account-company.js +53 -0
  118. package/dist/lib/lanes/accounts.d.ts +22 -3
  119. package/dist/lib/lanes/accounts.js +38 -2
  120. package/dist/lib/lanes/claude-balance.d.ts +6 -1
  121. package/dist/lib/lanes/claude-balance.js +6 -1
  122. package/dist/lib/lanes/claude-credential.d.ts +78 -0
  123. package/dist/lib/lanes/claude-credential.js +163 -0
  124. package/dist/lib/lanes/compaction.d.ts +2 -0
  125. package/dist/lib/lanes/compaction.js +9 -0
  126. package/dist/lib/lanes/config.js +6 -0
  127. package/dist/lib/lanes/envelope-repair-dispatch.js +2 -1
  128. package/dist/lib/lanes/hooks-install.js +2 -1
  129. package/dist/lib/lanes/host-capacity.d.ts +2 -0
  130. package/dist/lib/lanes/host-capacity.js +1 -0
  131. package/dist/lib/lanes/keep-alive.d.ts +1 -1
  132. package/dist/lib/lanes/keep-alive.js +3 -1
  133. package/dist/lib/lanes/links.js +10 -4
  134. package/dist/lib/lanes/loop.js +16 -1
  135. package/dist/lib/lanes/node-binary.d.ts +17 -0
  136. package/dist/lib/lanes/node-binary.js +30 -0
  137. package/dist/lib/lanes/outpost-dispatch.d.ts +2 -1
  138. package/dist/lib/lanes/outpost-dispatch.js +1 -0
  139. package/dist/lib/lanes/reconcile.js +4 -0
  140. package/dist/lib/lanes/remote-control-lane.js +2 -0
  141. package/dist/lib/lanes/spawn.d.ts +3 -0
  142. package/dist/lib/lanes/spawn.js +119 -9
  143. package/dist/lib/lanes/store.d.ts +6 -0
  144. package/dist/lib/lanes/store.js +107 -18
  145. package/dist/lib/lanes/types.d.ts +2 -0
  146. package/dist/lib/lanes/watch.js +28 -5
  147. package/dist/lib/lanes/watcher-main.js +4 -3
  148. package/dist/lib/lanes/watcher.d.ts +4 -0
  149. package/dist/lib/lanes/watcher.js +110 -21
  150. package/dist/lib/mesh/api.d.ts +32 -0
  151. package/dist/lib/mesh/api.js +75 -0
  152. package/dist/lib/secret-scan/detect.js +9 -0
  153. package/dist/lib/secret-scan/repo-config.d.ts +41 -0
  154. package/dist/lib/secret-scan/repo-config.js +237 -0
  155. package/dist/lib/secret-scan/rules.d.ts +7 -1
  156. package/dist/lib/secret-scan/rules.js +3 -1
  157. package/dist/lib/secret-scan/staged.d.ts +5 -0
  158. package/dist/lib/secret-scan/staged.js +19 -2
  159. package/dist/lib/secret-scan/types.d.ts +2 -0
  160. package/dist/lib/sync/server-owned-local-only.d.ts +29 -0
  161. package/dist/lib/sync/server-owned-local-only.js +75 -0
  162. package/dist/sentry-before-send.js +1 -0
  163. package/dist/utils/access-denied-hint.js +6 -0
  164. package/dist/utils/client-health.d.ts +34 -0
  165. package/dist/utils/client-health.js +96 -3
  166. package/dist/utils/package-use-lease.js +27 -9
  167. package/dist/utils/version-gate.d.ts +7 -0
  168. package/dist/utils/version-gate.js +66 -0
  169. package/package.json +8 -8
@@ -33,6 +33,7 @@ import * as path from "node:path";
33
33
  import * as hqCloud from "@indigoai-us/hq-cloud";
34
34
  import { loadManifestExports, MANIFEST_UNAVAILABLE_REASON, } from "../../hq-cloud-manifest.js";
35
35
  import { readCompaniesManifestMap } from "../../work-context/repo-remote.js";
36
+ import { companyRecordedNotResolved } from "../company-resolution-state.js";
36
37
  import { MANIFEST_DISABLED_ENV, manifestUploadsDisabled, } from "../../manifest-disabled.js";
37
38
  import { CLI_VERSION } from "../../../cli-version.js";
38
39
  import { readSyncVersion } from "../../../utils/feedback-versions.js";
@@ -59,6 +60,8 @@ function withDefaults(deps, hqRoot) {
59
60
  unresolvedScopes: deps.unresolvedScopes ?? ((journals) => defaultUnresolvedScopes(hqRoot, journals)),
60
61
  localCompanyExists: deps.localCompanyExists ??
61
62
  ((slug) => defaultLocalCompanyExists(hqRoot, slug)),
63
+ companySyncResolvable: deps.companySyncResolvable ??
64
+ ((slug, lastSync) => !companyRecordedNotResolved(hqRoot, slug, deps.now().getTime(), lastSync)),
62
65
  manifestUploadsDisabled: deps.manifestUploadsDisabled ?? (() => manifestUploadsDisabled()),
63
66
  };
64
67
  }
@@ -555,10 +558,62 @@ function journalResults(deps, journals) {
555
558
  continue;
556
559
  }
557
560
  }
558
- results.push(journalFreshnessResult(entry, entry.slug, nowMs));
561
+ const freshness = journalFreshnessResult(entry, entry.slug, nowMs);
562
+ // A stale company journal whose company the sync service could not
563
+ // resolve on the last pull would send `--fix` into the same failure, and
564
+ // the health hook would then report it every day (feedback 9b4a5016).
565
+ // Only a WARN is downgraded: a fresh journal stays PASS and an unreadable
566
+ // one stays UNKNOWN.
567
+ if (freshness.status === "WARN" &&
568
+ isCompanyJournalSlug(entry.slug) &&
569
+ !companySyncResolvable(deps, entry.slug, entry.journal.lastSync)) {
570
+ results.push(notOnThisMachineResult(entry, checkId));
571
+ continue;
572
+ }
573
+ results.push(freshness);
559
574
  }
560
575
  return results;
561
576
  }
577
+ function companySyncResolvable(deps, slug, lastSync) {
578
+ try {
579
+ return deps.companySyncResolvable(slug, lastSync);
580
+ }
581
+ catch {
582
+ // Probe failed: keep the WARN rather than hiding a possibly-live company.
583
+ return true;
584
+ }
585
+ }
586
+ function notOnThisMachineResult(entry, checkId) {
587
+ return {
588
+ status: "NA",
589
+ checkId,
590
+ reasonCode: "company-not-resolved",
591
+ target: entry.path,
592
+ message: `Sync journal '${entry.slug}' is not on this machine — the last hq doctor --fix pull could not find '${entry.slug}' for your HQ identity, so --fix will not pull it again for now.`,
593
+ remediation: `If you should still have '${entry.slug}', check you are signed in to the right HQ account (/hq-whoami) and ask its owner to re-invite you, then run \`hq sync pull --company ${entry.slug}\`; if not, delete its old sync journal (sync-journal.${entry.slug}.json in ~/.hq).`,
594
+ };
595
+ }
596
+ function latestTimestamp(...values) {
597
+ let best = null;
598
+ let bestMs = Number.NEGATIVE_INFINITY;
599
+ for (const value of values) {
600
+ const ms = value ? Date.parse(value) : Number.NaN;
601
+ if (Number.isFinite(ms) && ms > bestMs) {
602
+ bestMs = ms;
603
+ best = value;
604
+ }
605
+ }
606
+ return best;
607
+ }
608
+ function manifestNotOnThisMachineResult(entry, checkId, slug) {
609
+ return {
610
+ status: "NA",
611
+ checkId,
612
+ reasonCode: "company-not-resolved",
613
+ message: `Scope '${entry.scopeKey}' (company '${slug}') is not on this machine — the last hq doctor --fix run could not find '${slug}' for your HQ identity, so --fix will not upload its manifest again for now.`,
614
+ remediation: `If you should still have '${slug}', check you are signed in to the right HQ account (/hq-whoami) and ask its owner to re-invite you, then run \`hq sync pull --company ${slug}\`.`,
615
+ };
616
+ }
562
617
  // ─── Manifest upload freshness (sync-reconciliation-audit US-004) ────────────
563
618
  /**
564
619
  * A scope whose last manifest upload is older than this is reported stale.
@@ -651,6 +706,16 @@ function manifestResults(deps, journals) {
651
706
  // Without it the row stays manual-only. Personal must be explicit: a
652
707
  // bare `hq sync manifest` uploads the active company instead.
653
708
  const scope = manifestUploadScope(entry);
709
+ // Same evidence as the journal row: a company a --fix run could not
710
+ // resolve is not on this machine, so its manifest is NA too. The later
711
+ // of the journal sync and this upload counts as a later success.
712
+ if (scope && scope !== "personal") {
713
+ const journalSync = journals.find((j) => j.slug === scope)?.journal.lastSync ?? null;
714
+ const latest = latestTimestamp(journalSync, entry.lastUploadAt);
715
+ if (!companySyncResolvable(deps, scope, latest)) {
716
+ return manifestNotOnThisMachineResult(entry, checkId, scope);
717
+ }
718
+ }
654
719
  const command = `hq sync manifest${scope ? ` --scope ${scope}` : ""}`;
655
720
  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.`;
656
721
  // With the kill switch set the upload exits 0 and sends nothing, so the
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Companies a `hq doctor --fix` pull found the sync service cannot resolve
3
+ * for this machine's signed-in identity (feedback 9b4a5016).
4
+ *
5
+ * The record is evidence, not a guess: it is written only when a targeted
6
+ * `hq sync pull --company <slug>` failed because the service has no company
7
+ * by that slug for the caller, or refused the caller access to it. The sync
8
+ * health check then reports that company's stale journal as "not on this
9
+ * machine" instead of an auto-fixable WARN, so `--fix` stops pulling it and
10
+ * the client-health hook stops reporting it. A successful pull clears the
11
+ * entry, and an entry expires after {@link COMPANY_NOT_RESOLVED_TTL_MS} so a
12
+ * re-joined company is tried again.
13
+ */
14
+ export declare const COMPANY_RESOLUTION_STATE_RELATIVE_PATH: string;
15
+ /** An unresolved-company record is retried after this long. */
16
+ export declare const COMPANY_NOT_RESOLVED_TTL_MS: number;
17
+ /**
18
+ * True when the output of a failed `hq sync pull --company <slug>` shows the
19
+ * service could not resolve the company for this caller: no company by that
20
+ * slug in the caller's namespace, or a 403/404 on the membership-gated
21
+ * credential vend. A bare 403/404 on the slug lookup is not used: a gateway
22
+ * or authorizer fault answers the same way. Network, timeout, and server
23
+ * errors are not evidence.
24
+ */
25
+ export declare function pullOutputShowsCompanyNotResolved(output: string): boolean;
26
+ /** Record that a pull could not resolve `slug`, or clear it after a good pull. */
27
+ export declare function recordCompanyResolution(hqRoot: string, slug: string, resolved: boolean, nowMs: number): void;
28
+ /** True when a pull within the TTL could not resolve `slug`. Never throws. */
29
+ export declare function companyRecordedNotResolved(hqRoot: string, slug: string, nowMs: number, lastSyncedAt?: string | null): boolean;
30
+ //# sourceMappingURL=company-resolution-state.d.ts.map
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Companies a `hq doctor --fix` pull found the sync service cannot resolve
3
+ * for this machine's signed-in identity (feedback 9b4a5016).
4
+ *
5
+ * The record is evidence, not a guess: it is written only when a targeted
6
+ * `hq sync pull --company <slug>` failed because the service has no company
7
+ * by that slug for the caller, or refused the caller access to it. The sync
8
+ * health check then reports that company's stale journal as "not on this
9
+ * machine" instead of an auto-fixable WARN, so `--fix` stops pulling it and
10
+ * the client-health hook stops reporting it. A successful pull clears the
11
+ * entry, and an entry expires after {@link COMPANY_NOT_RESOLVED_TTL_MS} so a
12
+ * re-joined company is tried again.
13
+ */
14
+ import * as fs from "node:fs";
15
+ import * as path from "node:path";
16
+ export const COMPANY_RESOLUTION_STATE_RELATIVE_PATH = path.join("workspace", "doctor", "unresolved-companies.json");
17
+ /** An unresolved-company record is retried after this long. */
18
+ export const COMPANY_NOT_RESOLVED_TTL_MS = 14 * 24 * 60 * 60 * 1000;
19
+ /**
20
+ * True when the output of a failed `hq sync pull --company <slug>` shows the
21
+ * service could not resolve the company for this caller: no company by that
22
+ * slug in the caller's namespace, or a 403/404 on the membership-gated
23
+ * credential vend. A bare 403/404 on the slug lookup is not used: a gateway
24
+ * or authorizer fault answers the same way. Network, timeout, and server
25
+ * errors are not evidence.
26
+ */
27
+ export function pullOutputShowsCompanyNotResolved(output) {
28
+ return (/No entity in caller's namespace matches company\//.test(output) ||
29
+ /STS \/sts\/vend failed: 40[34]\b/.test(output));
30
+ }
31
+ function statePath(hqRoot) {
32
+ return path.join(hqRoot, COMPANY_RESOLUTION_STATE_RELATIVE_PATH);
33
+ }
34
+ function readState(hqRoot) {
35
+ try {
36
+ const parsed = JSON.parse(fs.readFileSync(statePath(hqRoot), "utf8"));
37
+ if (parsed &&
38
+ typeof parsed === "object" &&
39
+ parsed.version === 1 &&
40
+ typeof parsed.notResolved === "object" &&
41
+ parsed.notResolved !== null) {
42
+ const notResolved = Object.create(null);
43
+ for (const [slug, atMs] of Object.entries(parsed.notResolved)) {
44
+ if (typeof atMs === "number" && Number.isFinite(atMs))
45
+ notResolved[slug] = atMs;
46
+ }
47
+ return { version: 1, notResolved };
48
+ }
49
+ }
50
+ catch {
51
+ // Missing or unreadable state is "nothing recorded".
52
+ }
53
+ return { version: 1, notResolved: Object.create(null) };
54
+ }
55
+ function writeState(hqRoot, state) {
56
+ const target = statePath(hqRoot);
57
+ fs.mkdirSync(path.dirname(target), { recursive: true });
58
+ const temporaryPath = `${target}.${process.pid}.tmp`;
59
+ fs.writeFileSync(temporaryPath, `${JSON.stringify(state)}\n`, { mode: 0o600 });
60
+ fs.renameSync(temporaryPath, target);
61
+ }
62
+ /** Record that a pull could not resolve `slug`, or clear it after a good pull. */
63
+ export function recordCompanyResolution(hqRoot, slug, resolved, nowMs) {
64
+ const state = readState(hqRoot);
65
+ if (resolved) {
66
+ if (!Object.hasOwn(state.notResolved, slug))
67
+ return;
68
+ delete state.notResolved[slug];
69
+ }
70
+ else {
71
+ state.notResolved[slug] = nowMs;
72
+ }
73
+ writeState(hqRoot, state);
74
+ }
75
+ /** True when a pull within the TTL could not resolve `slug`. Never throws. */
76
+ export function companyRecordedNotResolved(hqRoot, slug, nowMs, lastSyncedAt) {
77
+ const notResolved = readState(hqRoot).notResolved;
78
+ if (!Object.hasOwn(notResolved, slug))
79
+ return false;
80
+ const atMs = notResolved[slug];
81
+ // A daemon/manual pull after this failure is stronger evidence than the
82
+ // cached failure, even if that successful sync has since become stale.
83
+ const lastSyncMs = lastSyncedAt ? Date.parse(lastSyncedAt) : Number.NaN;
84
+ if (Number.isFinite(lastSyncMs) && lastSyncMs > atMs)
85
+ return false;
86
+ const ageMs = nowMs - atMs;
87
+ return ageMs >= 0 && ageMs <= COMPANY_NOT_RESOLVED_TTL_MS;
88
+ }
89
+ //# sourceMappingURL=company-resolution-state.js.map
@@ -61,6 +61,12 @@ export interface ApplyFixesOptions {
61
61
  confirm?: () => boolean | Promise<boolean>;
62
62
  /** Clock for the backup directory name. Default: `new Date()`. */
63
63
  now?: () => Date;
64
+ /**
65
+ * Monotonic milliseconds for the network-repair time budget. Default:
66
+ * `performance.now()`. Tests inject a fake clock so a slow-pull budget run
67
+ * does not take minutes of real time.
68
+ */
69
+ elapsedMs?: () => number;
64
70
  /**
65
71
  * The dirty-tree probe: the changed paths under {@link HOOK_CONFIG_DIRS}, or
66
72
  * [] when clean or not a git repo. Default shells out to `git status`.
@@ -121,6 +127,12 @@ export interface RefreshSyncResult {
121
127
  outcome: RefreshSyncOutcome;
122
128
  /** One-line summary for the post-fix report. */
123
129
  message: string;
130
+ /**
131
+ * True when the pull failed because the sync service could not resolve
132
+ * the company for this caller (no such company, or access refused).
133
+ * Recorded so the next check reports the journal "not on this machine".
134
+ */
135
+ companyNotResolved?: boolean;
124
136
  }
125
137
  /** Per-company bound for the default `hq sync pull` repair. */
126
138
  export declare const REFRESH_SYNC_TIMEOUT_MS = 90000;
@@ -142,6 +154,12 @@ export interface RefreshManifestResult {
142
154
  * unresolved-check next step. Absent when the upload exited 0.
143
155
  */
144
156
  reason?: string;
157
+ /**
158
+ * The upload failed because the company is not visible to this identity
159
+ * ("No membership found"). Recorded as unresolved-company evidence only
160
+ * after another company succeeds in the same run.
161
+ */
162
+ companyNotResolved?: boolean;
145
163
  }
146
164
  /**
147
165
  * Why a sync check is still FAIL or WARN after `--fix`:
@@ -189,12 +207,22 @@ export interface AppliedFixBackup {
189
207
  /** A newly unlocked safe repair can be picked up for at most this many passes. */
190
208
  export declare const MAX_FIX_PASSES = 3;
191
209
  /**
192
- * Two 90s network repairs leave 120s for startup and check overhead under the
193
- * hook's 300s ceiling. Journal pulls and manifest uploads share this budget.
194
- * When both kinds are pending and no upload has run yet, one slot is held for
195
- * a manifest upload so stale journals cannot starve every manifest.
210
+ * Ceiling on network repairs per run. A run refreshes every resolvable stale
211
+ * journal up to this many; above it, `--fix` says so and the rotating cursor
212
+ * picks up the rest next run. Journal pulls and manifest uploads share this
213
+ * budget. When both kinds are pending and no upload has run yet, one slot is
214
+ * held for a manifest upload so stale journals cannot starve every manifest.
215
+ * Each repair is bounded at 90s, and unattended runs also stop starting new
216
+ * repairs after REFRESH_SYNC_START_BUDGET_MS (feedback 9b4a5016).
217
+ */
218
+ export declare const MAX_REFRESH_SYNC_PLANS_PER_RUN = 10;
219
+ /**
220
+ * No new network repair starts once this much of the run has passed. Health
221
+ * hooks bound `--fix` at 300s (older hooks are still in the field), so the
222
+ * last 90s repair started here still ends by 210s, leaving room for the
223
+ * check passes. Fast pulls reach the count ceiling well before this.
196
224
  */
197
- export declare const MAX_REFRESH_SYNC_PLANS_PER_RUN = 2;
225
+ export declare const REFRESH_SYNC_START_BUDGET_MS = 120000;
198
226
  /** The outcome of an {@link applyFixes} run. */
199
227
  export interface ApplyFixesResult {
200
228
  /** 0 on success, decline, or a clean no-op; 1 when a dirty tree is refused. */
@@ -268,7 +296,9 @@ export declare function defaultRefreshManifest(target: RefreshManifestTarget): P
268
296
  * A spawn killed by the timeout has a null status, so it is reported as a
269
297
  * timeout rather than as an exit code.
270
298
  */
271
- export declare function manifestUploadResult(scope: string, result: Pick<SpawnSyncReturns<string>, "error" | "status" | "signal">): RefreshManifestResult;
299
+ export declare function manifestUploadResult(scope: string, result: Pick<SpawnSyncReturns<string>, "error" | "status" | "signal"> & Partial<Pick<SpawnSyncReturns<string>, "stdout" | "stderr">>): RefreshManifestResult;
300
+ /** `hq sync manifest` output for a company this identity is not a member of. */
301
+ export declare function manifestOutputShowsNoMembership(output: string): boolean;
272
302
  /**
273
303
  * The uncommitted changes under {@link HOOK_CONFIG_DIRS}, one porcelain line
274
304
  * each. Returns [] when the tree is clean OR when `hqRoot` is not a git repo
@@ -48,6 +48,7 @@ import { botDir } from "../../bot/paths.js";
48
48
  import { readBotConfig } from "../../bot/config.js";
49
49
  import { buildBotDaemonPaths, installedDaemonHqBinary, repairBotDaemonIfStale, rewriteBotDaemonNodePath, } from "../../bot/daemon.js";
50
50
  import { classifyRefreshSyncOutcome, } from "./refresh-sync-outcome.js";
51
+ import { pullOutputShowsCompanyNotResolved, recordCompanyResolution, } from "../company-resolution-state.js";
51
52
  import { readRefreshManifestCursor, readRefreshSyncProgress, writeRefreshManifestCursor, writeRefreshSyncCursor, writeRefreshSyncLastRun, } from "./refresh-sync-progress.js";
52
53
  import { clientHealthShowManualOnlyEnabled } from "../client-health-reason-codes-flag.js";
53
54
  /** The tree subtrees whose uncommitted changes block a `--fix` run. */
@@ -57,12 +58,22 @@ export const REFRESH_SYNC_TIMEOUT_MS = 90_000;
57
58
  /** A newly unlocked safe repair can be picked up for at most this many passes. */
58
59
  export const MAX_FIX_PASSES = 3;
59
60
  /**
60
- * Two 90s network repairs leave 120s for startup and check overhead under the
61
- * hook's 300s ceiling. Journal pulls and manifest uploads share this budget.
62
- * When both kinds are pending and no upload has run yet, one slot is held for
63
- * a manifest upload so stale journals cannot starve every manifest.
61
+ * Ceiling on network repairs per run. A run refreshes every resolvable stale
62
+ * journal up to this many; above it, `--fix` says so and the rotating cursor
63
+ * picks up the rest next run. Journal pulls and manifest uploads share this
64
+ * budget. When both kinds are pending and no upload has run yet, one slot is
65
+ * held for a manifest upload so stale journals cannot starve every manifest.
66
+ * Each repair is bounded at 90s, and unattended runs also stop starting new
67
+ * repairs after REFRESH_SYNC_START_BUDGET_MS (feedback 9b4a5016).
64
68
  */
65
- export const MAX_REFRESH_SYNC_PLANS_PER_RUN = 2;
69
+ export const MAX_REFRESH_SYNC_PLANS_PER_RUN = 10;
70
+ /**
71
+ * No new network repair starts once this much of the run has passed. Health
72
+ * hooks bound `--fix` at 300s (older hooks are still in the field), so the
73
+ * last 90s repair started here still ends by 210s, leaving room for the
74
+ * check passes. Fast pulls reach the count ceiling well before this.
75
+ */
76
+ export const REFRESH_SYNC_START_BUDGET_MS = 120_000;
66
77
  function rotateRefreshSyncPlans(plans, afterSlug) {
67
78
  const ordered = [...plans].sort((a, b) => a.target < b.target ? -1 : a.target > b.target ? 1 : 0);
68
79
  if (!afterSlug || ordered.length < 2)
@@ -138,8 +149,52 @@ export async function applyFixes(options) {
138
149
  writeErr("HQ client-health manual-only flag lookup failed; keeping manual-only instructions hidden.\n");
139
150
  }
140
151
  const collect = options.runChecks ?? defaultRunChecks;
141
- const refreshSync = options.refreshSync ?? defaultRefreshSync;
142
- const refreshManifest = options.refreshManifest ?? defaultRefreshManifest;
152
+ const performRefreshSync = options.refreshSync ?? defaultRefreshSync;
153
+ // A namespace error can also be a server response regression. Only mark a
154
+ // missing company when a different company succeeds in this same run; an
155
+ // all-company outage must stay WARN and visible to client health.
156
+ const successfulCompanies = new Set();
157
+ const unresolvedCompanyPulls = new Map();
158
+ const noteCompanyOutcome = (slug, ok, notResolved) => {
159
+ try {
160
+ if (ok) {
161
+ successfulCompanies.add(slug);
162
+ unresolvedCompanyPulls.delete(slug);
163
+ recordCompanyResolution(hqRoot, slug, true, Date.now());
164
+ }
165
+ else if (notResolved) {
166
+ unresolvedCompanyPulls.set(slug, Date.now());
167
+ }
168
+ for (const [pending, atMs] of unresolvedCompanyPulls) {
169
+ // A success for this same company cannot establish peer health:
170
+ // its manifest may be exposing a real membership-path failure.
171
+ if (![...successfulCompanies].some((successful) => successful !== pending))
172
+ continue;
173
+ recordCompanyResolution(hqRoot, pending, false, atMs);
174
+ unresolvedCompanyPulls.delete(pending);
175
+ }
176
+ }
177
+ catch {
178
+ // Best effort: failure to persist evidence keeps the stale WARN visible.
179
+ }
180
+ };
181
+ const refreshSync = async (target) => {
182
+ const result = await performRefreshSync(target);
183
+ if (target.personal)
184
+ return result;
185
+ noteCompanyOutcome(target.slug, result.ok, result.companyNotResolved === true);
186
+ return result;
187
+ };
188
+ const performRefreshManifest = options.refreshManifest ?? defaultRefreshManifest;
189
+ // Manifest uploads feed the same evidence: "No membership found" waits for
190
+ // a peer company to succeed, so an all-company outage stays WARN.
191
+ const refreshManifest = async (target) => {
192
+ const result = await performRefreshManifest(target);
193
+ if (target.scope === "personal")
194
+ return result;
195
+ noteCompanyOutcome(target.scope, result.ok, result.companyNotResolved === true);
196
+ return result;
197
+ };
143
198
  const rewriteBotNodePath = options.rewriteBotNodePath ?? defaultRewriteBotNodePath;
144
199
  const repairBotHqBinary = options.repairBotHqBinary ?? defaultRepairBotHqBinary;
145
200
  // 2. Collect findings, then plan each newly unlocked allowlisted repair.
@@ -149,6 +204,21 @@ export async function applyFixes(options) {
149
204
  let lastAttemptedRefreshSyncSlug = readRefreshSyncProgress(hqRoot)?.lastAttemptedSlug ?? null;
150
205
  let refreshSyncAppliedThisRun = 0;
151
206
  let refreshManifestAppliedThisRun = 0;
207
+ let refreshCeilingSkipped = 0;
208
+ const elapsedMs = options.elapsedMs ?? (() => performance.now());
209
+ const runStartedMs = elapsedMs();
210
+ let networkBudgetSpent = false;
211
+ // Only unattended (--yes) runs have a caller with a deadline. An
212
+ // interactive run waits on the confirmation prompt, which must not use up
213
+ // the budget.
214
+ const networkBudgetLeft = () => {
215
+ if (!options.yes)
216
+ return true;
217
+ if (!networkBudgetSpent && elapsedMs() - runStartedMs >= REFRESH_SYNC_START_BUDGET_MS) {
218
+ networkBudgetSpent = true;
219
+ }
220
+ return !networkBudgetSpent;
221
+ };
152
222
  let lastAttemptedRefreshManifestScope = readRefreshManifestCursor(hqRoot);
153
223
  const appliedKeys = new Set();
154
224
  const planned = [];
@@ -177,20 +247,24 @@ export async function applyFixes(options) {
177
247
  keysThisPass.add(key);
178
248
  plans.push(plan);
179
249
  }
180
- const networkSlotsLeft = Math.max(0, MAX_REFRESH_SYNC_PLANS_PER_RUN - refreshSyncAppliedThisRun - refreshManifestAppliedThisRun);
250
+ const networkSlotsLeft = networkBudgetLeft()
251
+ ? Math.max(0, MAX_REFRESH_SYNC_PLANS_PER_RUN - refreshSyncAppliedThisRun - refreshManifestAppliedThisRun)
252
+ : 0;
181
253
  // Both kinds start after the company attempted last, so a scope that
182
254
  // never clears cannot hold the front of the line on every run.
183
255
  const manifestCandidates = rotateRefreshSyncPlans(plans.filter((plan) => plan.fixClass === "refresh-manifest"), lastAttemptedRefreshManifestScope);
184
256
  // Hold one slot for an upload until one has run this invocation.
185
257
  const manifestReserve = manifestCandidates.length > 0 && refreshManifestAppliedThisRun === 0 ? 1 : 0;
186
258
  const seenRefreshTargets = new Set();
187
- const refreshPlans = rotateRefreshSyncPlans(plans.filter((plan) => plan.fixClass === "refresh-sync"), lastAttemptedRefreshSyncSlug).filter((plan) => {
259
+ const refreshCandidates = rotateRefreshSyncPlans(plans.filter((plan) => plan.fixClass === "refresh-sync"), lastAttemptedRefreshSyncSlug).filter((plan) => {
188
260
  if (seenRefreshTargets.has(plan.target))
189
261
  return false;
190
262
  seenRefreshTargets.add(plan.target);
191
263
  return true;
192
- }).slice(0, Math.max(0, networkSlotsLeft - manifestReserve));
193
- const manifestPlans = manifestCandidates.slice(0, networkSlotsLeft - refreshPlans.length);
264
+ });
265
+ const refreshPlans = refreshCandidates.slice(0, Math.max(0, networkSlotsLeft - manifestReserve));
266
+ refreshCeilingSkipped = refreshCandidates.length - refreshPlans.length;
267
+ const manifestPlans = manifestCandidates.slice(0, Math.max(0, networkSlotsLeft - refreshPlans.length));
194
268
  return [
195
269
  ...plans.filter((plan) => plan.fixClass !== "refresh-sync" && plan.fixClass !== "refresh-manifest"),
196
270
  ...refreshPlans,
@@ -223,6 +297,7 @@ export async function applyFixes(options) {
223
297
  write("Aborted — no changes written.\n");
224
298
  if (showManualOnlyRemediations)
225
299
  writeManualOnlyRemediations(findings, write);
300
+ writeNotOnThisMachine(findings, write);
226
301
  const unresolved = unresolvedSyncChecks(findings, []);
227
302
  writeUnresolvedSyncChecks(unresolved, write);
228
303
  return empty({ refused, fixableCount, unresolved });
@@ -248,6 +323,11 @@ export async function applyFixes(options) {
248
323
  // 5. Apply each plan once. Later findings with this check id + target are
249
324
  // excluded even if this repair did not make its check pass.
250
325
  for (const plan of plans) {
326
+ const network = plan.fixClass === "refresh-sync" || plan.fixClass === "refresh-manifest";
327
+ // Leave the plan unapplied (not in appliedKeys) so it is counted as
328
+ // left over and reported "not attempted" rather than as a failed repair.
329
+ if (network && !networkBudgetLeft())
330
+ continue;
251
331
  if (plan.fixClass === "refresh-sync") {
252
332
  // Persist before starting the bounded pull. If this process is killed,
253
333
  // the next invocation resumes after this company instead of starving
@@ -272,6 +352,7 @@ export async function applyFixes(options) {
272
352
  write("hq doctor --fix: nothing to repair — no auto-fixable findings.\n");
273
353
  if (showManualOnlyRemediations)
274
354
  writeManualOnlyRemediations(findings, write);
355
+ writeNotOnThisMachine(findings, write);
275
356
  const unresolved = unresolvedSyncChecks(findings, []);
276
357
  writeUnresolvedSyncChecks(unresolved, write);
277
358
  return empty({ refused, fixableCount, unresolved });
@@ -308,9 +389,20 @@ export async function applyFixes(options) {
308
389
  : null;
309
390
  if (restoreCommand)
310
391
  write(`To restore all changes: ${restoreCommand}\n`);
311
- if (planned.at(-1)?.pass === MAX_FIX_PASSES && buildPlans(findings).length > 0) {
392
+ // Planning against the final findings also recounts the journals left over
393
+ // the refresh ceiling, so the note below reports what is actually left.
394
+ const remainingPlans = buildPlans(findings);
395
+ if (planned.at(-1)?.pass === MAX_FIX_PASSES && remainingPlans.length > 0) {
312
396
  write(`Stopped after ${MAX_FIX_PASSES} repair passes; new auto-fixable findings remain.\n`);
313
397
  }
398
+ if (refreshCeilingSkipped > 0) {
399
+ const limit = refreshSyncAppliedThisRun >= MAX_REFRESH_SYNC_PLANS_PER_RUN
400
+ ? `--fix refreshes at most ${MAX_REFRESH_SYNC_PLANS_PER_RUN} in one run`
401
+ : `--fix stops starting pulls after ${REFRESH_SYNC_START_BUDGET_MS / 1000}s`;
402
+ write(`\nRefreshed ${refreshSyncAppliedThisRun} stale sync journal${refreshSyncAppliedThisRun === 1 ? "" : "s"}; ${limit}. ` +
403
+ `${refreshCeilingSkipped} more ${refreshCeilingSkipped === 1 ? "is" : "are"} left. Run \`hq doctor --fix\` again to refresh ${refreshCeilingSkipped === 1 ? "it" : "them"}.\n`);
404
+ }
405
+ writeNotOnThisMachine(findings, write);
314
406
  if (showManualOnlyRemediations)
315
407
  writeManualOnlyRemediations(findings, write);
316
408
  const unresolved = unresolvedSyncChecks(findings, planned.map(({ plan }) => plan));
@@ -393,6 +485,19 @@ function writeUnresolvedSyncChecks(unresolved, write) {
393
485
  write(` ${entry.status} ${entry.checkId}\n Next step: ${entry.nextStep}\n`);
394
486
  }
395
487
  }
488
+ /**
489
+ * Print the stale company journals `--fix` skipped because the company is not
490
+ * set up for sync on this machine, each with its one next step.
491
+ */
492
+ function writeNotOnThisMachine(findings, write) {
493
+ const skipped = findings.filter((result) => result.reasonCode === "company-not-resolved");
494
+ if (skipped.length === 0)
495
+ return;
496
+ write("\nSkipped — not on this machine (--fix cannot pull these):\n");
497
+ for (const result of skipped) {
498
+ write(` ${result.checkId}\n Next step: ${result.remediation ?? "Run `hq doctor --only sync` for details."}\n`);
499
+ }
500
+ }
396
501
  /** Print the available user action for findings that `--fix` deliberately skips. */
397
502
  function writeManualOnlyRemediations(findings, write) {
398
503
  const manual = findings.flatMap((result) => {
@@ -497,6 +602,9 @@ export function defaultRefreshSync(target) {
497
602
  ok: false,
498
603
  outcome,
499
604
  message: `pull of '${target.slug}' exited ${result.status}${detail ? `: ${detail}` : ""}`,
605
+ companyNotResolved: !target.personal &&
606
+ !isPersonalJournalSlug(target.slug) &&
607
+ pullOutputShowsCompanyNotResolved(`${result.stdout ?? ""}\n${result.stderr ?? ""}`),
500
608
  });
501
609
  }
502
610
  return Promise.resolve({
@@ -544,10 +652,18 @@ export function manifestUploadResult(scope, result) {
544
652
  ok: false,
545
653
  reason: `the upload exited with code ${result.status}`,
546
654
  message: `manifest upload for '${scope}' exited ${result.status}`,
655
+ ...(scope !== "personal" &&
656
+ manifestOutputShowsNoMembership(`${result.stdout ?? ""}\n${result.stderr ?? ""}`)
657
+ ? { companyNotResolved: true }
658
+ : {}),
547
659
  };
548
660
  }
549
661
  return { ok: true, message: `uploaded the manifest for '${scope}'` };
550
662
  }
663
+ /** `hq sync manifest` output for a company this identity is not a member of. */
664
+ export function manifestOutputShowsNoMembership(output) {
665
+ return /No membership found/.test(output) || pullOutputShowsCompanyNotResolved(output);
666
+ }
551
667
  // --- Dirty-tree probe ----------------------------------------------------------
552
668
  /**
553
669
  * The uncommitted changes under {@link HOOK_CONFIG_DIRS}, one porcelain line
@@ -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", "legacy-personal-journal", "never-synced", "invalid-last-sync", "stale-threshold", "manifest-unavailable", "manifest-state-unavailable", "no-manifest-scopes", "never-uploaded", "invalid-upload-time", "unresolved-company-uid", "realtime-lag-stuck", "receiver-permission-denied", "runner-restart-loop", "device-limit-backoff", "repeated-conflicts"];
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", "manifest-unavailable", "manifest-state-unavailable", "no-manifest-scopes", "never-uploaded", "invalid-upload-time", "unresolved-company-uid", "realtime-lag-stuck", "receiver-permission-denied", "runner-restart-loop", "device-limit-backoff", "repeated-conflicts"];
23
23
  export type SyncReasonCode = (typeof SYNC_REASON_CODES)[number];
24
24
  /** Runtime type guard: true only for a member of the fixed vocabulary. */
25
25
  export declare function isDoctorStatus(value: unknown): value is DoctorStatus;
@@ -26,6 +26,7 @@ export const SYNC_REASON_CODES = [
26
26
  "core-version-unavailable",
27
27
  "core-update-available",
28
28
  "no-local-tree",
29
+ "company-not-resolved",
29
30
  "legacy-personal-journal",
30
31
  "never-synced",
31
32
  "invalid-last-sync",