@indigoai-us/hq-cli 5.192.1 → 5.193.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,27 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.193.0] — 2026-09-25
6
+
7
+ ### Fixed
8
+
9
+ - `hq secrets set` now handles a closed stdin descriptor during terminal cleanup.
10
+
11
+ ### Improved
12
+
13
+ - Sentry reports for failed skill-wrapper replacement now include bounded ownership-state tags.
14
+
15
+ ## [5.192.2] — 2026-09-25
16
+
17
+ ### Fixed
18
+
19
+ - Create, resume, automatic repair, and apply now read
20
+ `lanes.claude-account-balance` from hq-flags. `HQ_FLAG_*` remains the local
21
+ override, and a registry read failure keeps the gate off. The
22
+ `accounts balance --json` report includes `balance_gate_enabled`.
23
+ - `hq lanes accounts balance --apply` now honors each lane's company-scoped
24
+ gate, skips disabled company lanes, and rechecks the gate before moving.
25
+
5
26
  ## [5.192.1] — 2026-09-25
6
27
 
7
28
  ### Fixed
@@ -86,8 +86,8 @@ export interface LanesCommandDeps {
86
86
  isSsmOwnerDispatch?: () => boolean;
87
87
  /** Test seam for changing a lane between pre-lock and locked grant reads. */
88
88
  withGrantHireLocks?: typeof withLaneHireLocks;
89
- /** Test seam for the default-off Claude account-balancing gate. */
90
- laneClaudeAccountBalanceEnabled?: () => boolean;
89
+ /** Test seam for the Claude account-balancing gate. */
90
+ laneClaudeAccountBalanceEnabled?: (company?: LaneCompanyRef | null) => boolean | Promise<boolean>;
91
91
  }
92
92
  export declare const LANE_OUTPOST_LOAD_BALANCING_FLAG = "lanes.outpost-load-balancing";
93
93
  export declare function parseSeniorRef(raw: string): SeniorRef;
@@ -57,8 +57,10 @@ export const LANE_OUTPOST_LOAD_BALANCING_FLAG = "lanes.outpost-load-balancing";
57
57
  function defaultLanePlacementEnabled() {
58
58
  return resolveProcessFlagGate(LANE_OUTPOST_LOAD_BALANCING_FLAG, { fallback: false }, () => false);
59
59
  }
60
- function defaultLaneClaudeAccountBalanceEnabled() {
61
- return resolveProcessFlagGate(CLAUDE_ACCOUNT_BALANCE_FLAG, { fallback: false }, () => false);
60
+ function defaultLaneClaudeAccountBalanceEnabled(company) {
61
+ // Keep the local HQ_FLAG override and the registry reader's fail-closed
62
+ // behavior, matching the lane hire-grants gate.
63
+ return readLanesRegistryFlag(CLAUDE_ACCOUNT_BALANCE_FLAG, { company });
62
64
  }
63
65
  function defaultLaneHireGrantsEnabled(company) {
64
66
  return readLanesRegistryFlag(LANE_HIRE_GRANTS_FLAG, { company });
@@ -2516,7 +2518,7 @@ async function runCreate(ctx, opts) {
2516
2518
  }
2517
2519
  const allowedAccounts = validatedAccounts.size > 0 ? [...validatedAccounts] : profileAccounts;
2518
2520
  let accountSelection;
2519
- if (provider === "claude" && ctx.laneClaudeAccountBalanceEnabled()) {
2521
+ if (provider === "claude" && await ctx.laneClaudeAccountBalanceEnabled(company)) {
2520
2522
  const targetHost = placementHostOverride ?? (outpostId !== undefined
2521
2523
  ? { kind: "outpost", id: outpostId }
2522
2524
  : laneHostFromCtx(ctx));
@@ -4841,7 +4843,7 @@ async function prepareAccountMove(ctx, lane, input) {
4841
4843
  async function selectAutomaticClaudeResumeAccount(ctx, lane) {
4842
4844
  if (lane.provider !== "claude")
4843
4845
  return undefined;
4844
- if (!ctx.laneClaudeAccountBalanceEnabled()) {
4846
+ if (!await ctx.laneClaudeAccountBalanceEnabled(lane.company)) {
4845
4847
  if (lane.account === undefined)
4846
4848
  return undefined;
4847
4849
  const config = loadLanesConfig(ctx.hqRoot());
@@ -5245,6 +5247,7 @@ async function applyClaudeBalanceMove(ctx, suggestion) {
5245
5247
  return { lane_id: suggestion.lane_id, host: suggestion.host, from: suggestion.from, to: suggestion.to, state: suggestion.state };
5246
5248
  }
5247
5249
  const lane = laneWithRecoveredProviderSession(ctx.hqRoot(), readLane(ctx.hqRoot(), suggestion.lane_id));
5250
+ assertBalanceApplyEnabled(await ctx.laneClaudeAccountBalanceEnabled(lane.company));
5248
5251
  if (lane.state === "running") {
5249
5252
  throw new LanesError("lane_running", `Lane ${lane.lane_id} is running; balance never moves a running turn`, {
5250
5253
  lane_id: lane.lane_id,
@@ -5273,8 +5276,8 @@ async function applyClaudeBalanceMove(ctx, suggestion) {
5273
5276
  return { lane_id: moved.lane.lane_id, host: moved.lane.host, from: moved.from ?? suggestion.from, to: moved.to, state: moved.lane.state };
5274
5277
  }
5275
5278
  async function runClaudeBalanceMoveTarget(ctx, laneId, opts) {
5276
- assertBalanceApplyEnabled(ctx.laneClaudeAccountBalanceEnabled());
5277
5279
  const lane = laneWithRecoveredProviderSession(ctx.hqRoot(), readLane(ctx.hqRoot(), laneId));
5280
+ assertBalanceApplyEnabled(await ctx.laneClaudeAccountBalanceEnabled(lane.company));
5278
5281
  assertLaneRunsHereForOperatorMutation(ctx, lane, "balance-move-target");
5279
5282
  if (lane.provider !== "claude") {
5280
5283
  throw new LanesError("claude_account_balance_provider_mismatch", `Lane ${laneId} does not use Claude`, {
@@ -6873,11 +6876,30 @@ async function runAccountsBalance(ctx, opts) {
6873
6876
  accepted: ["claude"],
6874
6877
  });
6875
6878
  }
6876
- if (opts.apply)
6877
- assertBalanceApplyEnabled(ctx.laneClaudeAccountBalanceEnabled());
6878
6879
  const hqRoot = ctx.hqRoot();
6879
6880
  const config = loadLanesConfig(hqRoot);
6880
6881
  const loaded = await loadClaudeBalanceHostSnapshots(ctx, hqRoot, config);
6882
+ const laneBalanceGateEnabled = new Map();
6883
+ const companyBalanceGateEnabled = new Map();
6884
+ const lanes = loaded.snapshots.flatMap((host) => host.lanes);
6885
+ for (const lane of lanes) {
6886
+ const companyKey = JSON.stringify([lane.company?.uid ?? null, lane.company?.slug ?? null]);
6887
+ let enabled = companyBalanceGateEnabled.get(companyKey);
6888
+ if (enabled === undefined) {
6889
+ enabled = await ctx.laneClaudeAccountBalanceEnabled(lane.company);
6890
+ companyBalanceGateEnabled.set(companyKey, enabled);
6891
+ }
6892
+ laneBalanceGateEnabled.set(lane.lane_id, enabled);
6893
+ }
6894
+ // With no lanes there is no tenant scope to resolve, so report and enforce
6895
+ // the unscoped/default gate. Otherwise the report reflects whether any
6896
+ // represented lane company has the gate enabled.
6897
+ if (lanes.length === 0) {
6898
+ companyBalanceGateEnabled.set(JSON.stringify([null, null]), await ctx.laneClaudeAccountBalanceEnabled());
6899
+ }
6900
+ const balanceGateEnabled = [...companyBalanceGateEnabled.values()].some(Boolean);
6901
+ if (opts.apply)
6902
+ assertBalanceApplyEnabled(balanceGateEnabled);
6881
6903
  const loads = accountLoadsForReport({ hosts: loaded.snapshots, config });
6882
6904
  const suggestions = planClaudeBalanceMoves({ hosts: loaded.snapshots, config });
6883
6905
  const selections = loaded.snapshots.map((host) => {
@@ -6939,6 +6961,16 @@ async function runAccountsBalance(ctx, opts) {
6939
6961
  });
6940
6962
  continue;
6941
6963
  }
6964
+ if (laneBalanceGateEnabled.get(suggestion.lane_id) !== true) {
6965
+ skipped.push({
6966
+ lane_id: suggestion.lane_id,
6967
+ host: suggestion.host,
6968
+ from: suggestion.from,
6969
+ to: suggestion.to,
6970
+ reason: "claude_account_balance_disabled",
6971
+ });
6972
+ continue;
6973
+ }
6942
6974
  try {
6943
6975
  moved.push(await applyClaudeBalanceMove(ctx, suggestion));
6944
6976
  }
@@ -6957,6 +6989,7 @@ async function runAccountsBalance(ctx, opts) {
6957
6989
  ok: true,
6958
6990
  action: "accounts.balance",
6959
6991
  provider: "claude",
6992
+ balance_gate_enabled: balanceGateEnabled,
6960
6993
  accounts,
6961
6994
  selections,
6962
6995
  unavailable_hosts: loaded.unavailableHosts,
@@ -188,12 +188,23 @@ function promptSecretInteractively() {
188
188
  let value = "";
189
189
  process.stdin.setEncoding("utf8");
190
190
  const cleanup = () => {
191
- if (process.stdin.isTTY) {
192
- process.stdin.setRawMode(false);
191
+ try {
192
+ if (process.stdin.isTTY) {
193
+ try {
194
+ process.stdin.setRawMode(false);
195
+ }
196
+ catch (error) {
197
+ if (error?.code !== "EBADF") {
198
+ throw error;
199
+ }
200
+ }
201
+ }
202
+ }
203
+ finally {
204
+ process.stdin.removeListener("data", onData);
205
+ process.stdin.removeListener("end", onEnd);
206
+ process.stdin.pause();
193
207
  }
194
- process.stdin.removeListener("data", onData);
195
- process.stdin.removeListener("end", onEnd);
196
- process.stdin.pause();
197
208
  };
198
209
  const onEnd = () => {
199
210
  cleanup();
@@ -1,4 +1,13 @@
1
1
  import * as fs from "node:fs";
2
+ export interface CompanySkillWrapperOwnershipDiagnostics {
3
+ kind: "directory" | "symlink" | "file" | "other";
4
+ bridgeState: "all_live" | "partial" | "none" | "empty" | "not_applicable";
5
+ markerState: "bound" | "missing" | "invalid" | "unbound" | "unavailable" | "not_applicable";
6
+ linkTarget: "company_skills" | "outside_company_skills" | "unavailable" | "not_applicable";
7
+ platform: "win32" | "darwin" | "linux" | "other";
8
+ nameEncoding: "percent_encoded" | "literal";
9
+ }
10
+ export declare function companySkillWrapperSentryTagsFor(error: unknown): Record<string, string> | undefined;
2
11
  export interface SurfaceCompanySkillInput {
3
12
  hqRoot: string;
4
13
  companySlug: string;
@@ -62,20 +62,6 @@ function symlinkTarget(linkPath) {
62
62
  function generatedMarkerPath(wrapperPath) {
63
63
  return path.join(path.dirname(wrapperPath), WRAPPER_MARKER_DIR, `${path.basename(wrapperPath)}.json`);
64
64
  }
65
- function hasBoundGeneratedMarker(wrapperPath, companySlug) {
66
- try {
67
- const marker = JSON.parse(fs.readFileSync(generatedMarkerPath(wrapperPath), "utf8"));
68
- const wrapper = fs.lstatSync(wrapperPath);
69
- return (marker.version === 2 &&
70
- marker.companySlug === companySlug &&
71
- marker.wrapperDev === wrapper.dev &&
72
- marker.wrapperIno === wrapper.ino &&
73
- marker.wrapperBirthtimeMs === wrapper.birthtimeMs);
74
- }
75
- catch {
76
- return false;
77
- }
78
- }
79
65
  function writeGeneratedMarker(wrapperPath, companySlug, skillSlug) {
80
66
  const markerPath = generatedMarkerPath(wrapperPath);
81
67
  try {
@@ -104,21 +90,116 @@ function removeGeneratedMarker(wrapperPath) {
104
90
  // never sufficient to surface content on its own.
105
91
  }
106
92
  }
107
- function isGeneratedNamespacedWrapper(wrapperPath, hqRoot, companySlug) {
93
+ const COMPANY_SKILL_WRAPPER_DIAGNOSTICS = Symbol("company-skill-wrapper-diagnostics");
94
+ function companySkillWrapperOwnershipError(wrapperPath, diagnostics) {
95
+ const error = new Error(`Cannot replace '${wrapperPath}' because it is not an HQ-generated skill wrapper.`);
96
+ Object.defineProperty(error, COMPANY_SKILL_WRAPPER_DIAGNOSTICS, {
97
+ value: diagnostics,
98
+ });
99
+ return error;
100
+ }
101
+ export function companySkillWrapperSentryTagsFor(error) {
102
+ if (!(error instanceof Error))
103
+ return undefined;
104
+ const diagnostics = error[COMPANY_SKILL_WRAPPER_DIAGNOSTICS];
105
+ if (!diagnostics)
106
+ return undefined;
107
+ return {
108
+ hq_cli_skill_wrapper_kind: diagnostics.kind,
109
+ hq_cli_skill_wrapper_bridge_state: diagnostics.bridgeState,
110
+ hq_cli_skill_wrapper_marker_state: diagnostics.markerState,
111
+ hq_cli_skill_wrapper_link_target: diagnostics.linkTarget,
112
+ hq_cli_skill_wrapper_platform: diagnostics.platform,
113
+ hq_cli_skill_wrapper_name_encoding: diagnostics.nameEncoding,
114
+ };
115
+ }
116
+ function inspectGeneratedNamespacedWrapper(wrapperPath, hqRoot, companySlug) {
108
117
  const companySkillsRoot = path.join(hqRoot, "companies", companySlug, "skills");
109
- const wrapperTarget = symlinkTarget(wrapperPath);
110
- if (wrapperTarget)
111
- return isWithin(companySkillsRoot, wrapperTarget);
112
- const wrapper = lstat(wrapperPath);
113
- if (!wrapper?.isDirectory())
114
- return false;
118
+ const wrapperEntry = lstat(wrapperPath);
119
+ if (wrapperEntry?.isSymbolicLink()) {
120
+ const target = symlinkTarget(wrapperPath);
121
+ const linkTarget = target === undefined
122
+ ? "unavailable"
123
+ : isWithin(companySkillsRoot, target)
124
+ ? "company_skills"
125
+ : "outside_company_skills";
126
+ return {
127
+ owned: linkTarget === "company_skills",
128
+ diagnostics: {
129
+ kind: "symlink",
130
+ bridgeState: "not_applicable",
131
+ markerState: "not_applicable",
132
+ linkTarget,
133
+ },
134
+ };
135
+ }
136
+ if (!wrapperEntry?.isDirectory()) {
137
+ return {
138
+ owned: false,
139
+ diagnostics: {
140
+ kind: wrapperEntry?.isFile() ? "file" : "other",
141
+ bridgeState: "not_applicable",
142
+ markerState: "not_applicable",
143
+ linkTarget: "not_applicable",
144
+ },
145
+ };
146
+ }
115
147
  const entries = fs.readdirSync(wrapperPath).filter((entry) => !entry.startsWith("."));
116
- const containsOnlyLiveBridges = (entries.length > 0 &&
117
- entries.every((entry) => {
118
- const target = symlinkTarget(path.join(wrapperPath, entry));
119
- return target !== undefined && isWithin(companySkillsRoot, target);
120
- }));
121
- return containsOnlyLiveBridges || hasBoundGeneratedMarker(wrapperPath, companySlug);
148
+ const liveBridgeCount = entries.reduce((count, entry) => {
149
+ const target = symlinkTarget(path.join(wrapperPath, entry));
150
+ return count + (target !== undefined && isWithin(companySkillsRoot, target) ? 1 : 0);
151
+ }, 0);
152
+ const bridgeState = entries.length === 0
153
+ ? "empty"
154
+ : liveBridgeCount === entries.length
155
+ ? "all_live"
156
+ : liveBridgeCount > 0
157
+ ? "partial"
158
+ : "none";
159
+ const markerState = generatedMarkerState(wrapperPath, companySlug);
160
+ return {
161
+ owned: bridgeState === "all_live" || markerState === "bound",
162
+ diagnostics: {
163
+ kind: "directory",
164
+ bridgeState,
165
+ markerState,
166
+ linkTarget: "not_applicable",
167
+ },
168
+ };
169
+ }
170
+ function isGeneratedNamespacedWrapper(wrapperPath, hqRoot, companySlug) {
171
+ return inspectGeneratedNamespacedWrapper(wrapperPath, hqRoot, companySlug).owned;
172
+ }
173
+ function generatedMarkerState(wrapperPath, companySlug) {
174
+ let markerContents;
175
+ try {
176
+ markerContents = fs.readFileSync(generatedMarkerPath(wrapperPath), "utf8");
177
+ }
178
+ catch (error) {
179
+ return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT"
180
+ ? "missing"
181
+ : "unavailable";
182
+ }
183
+ let marker;
184
+ try {
185
+ marker = JSON.parse(markerContents);
186
+ }
187
+ catch {
188
+ return "invalid";
189
+ }
190
+ try {
191
+ const wrapper = fs.lstatSync(wrapperPath);
192
+ return marker.version === 2 &&
193
+ marker.companySlug === companySlug &&
194
+ marker.wrapperDev === wrapper.dev &&
195
+ marker.wrapperIno === wrapper.ino &&
196
+ marker.wrapperBirthtimeMs === wrapper.birthtimeMs
197
+ ? "bound"
198
+ : "unbound";
199
+ }
200
+ catch {
201
+ return "unavailable";
202
+ }
122
203
  }
123
204
  function isGeneratedLegacyEntry(legacyPath, sourceDir, skillFile) {
124
205
  const target = symlinkTarget(legacyPath);
@@ -224,8 +305,19 @@ export function surfaceCompanySkill(input) {
224
305
  const backupPath = `${stagingPath}.previous`;
225
306
  let previousMoved = false;
226
307
  if (currentWrapper) {
227
- if (!isGeneratedNamespacedWrapper(wrapperPath, input.hqRoot, input.companySlug)) {
228
- throw new Error(`Cannot replace '${wrapperPath}' because it is not an HQ-generated skill wrapper.`);
308
+ const ownership = inspectGeneratedNamespacedWrapper(wrapperPath, input.hqRoot, input.companySlug);
309
+ if (!ownership.owned) {
310
+ const runtimePlatform = process.platform;
311
+ const platform = win32
312
+ ? "win32"
313
+ : runtimePlatform === "darwin" || runtimePlatform === "linux"
314
+ ? runtimePlatform
315
+ : "other";
316
+ throw companySkillWrapperOwnershipError(wrapperPath, {
317
+ ...ownership.diagnostics,
318
+ platform,
319
+ nameEncoding: win32 ? "percent_encoded" : "literal",
320
+ });
229
321
  }
230
322
  renameEntry(wrapperPath, backupPath);
231
323
  previousMoved = true;
package/dist/sentry.js CHANGED
@@ -5,6 +5,7 @@ import { beforeBreadcrumb } from "./utils/breadcrumb-buffer.js";
5
5
  import { CLI_VERSION } from "./cli-version.js";
6
6
  import { getCachedSentryUser } from "./utils/sentry-identity.js";
7
7
  import { isEpipe } from "./utils/epipe.js";
8
+ import { companySkillWrapperSentryTagsFor } from "./lib/company-skill-wrapper.js";
8
9
  import { environmentalFsErrorMessage } from "./utils/environmental-error.js";
9
10
  import { incompleteInstallCaptureContext, incompleteInstallMessage, } from "./utils/incomplete-install-error.js";
10
11
  import { installTreeTornSentryTagsFor, sentryFingerprintFor, sentryMcpCollisionContextFor, } from "./utils/sentry-fingerprint.js";
@@ -84,6 +85,10 @@ export function epipeAwareBeforeSend(event, hint) {
84
85
  if (fingerprint)
85
86
  event.fingerprint = fingerprint;
86
87
  }
88
+ const skillWrapperTags = companySkillWrapperSentryTagsFor(hint?.originalException);
89
+ if (skillWrapperTags) {
90
+ event.tags = { ...event.tags, ...skillWrapperTags };
91
+ }
87
92
  const installTreeTags = installTreeTornSentryTagsFor(hint?.originalException);
88
93
  if (installTreeTags) {
89
94
  event.tags = { ...event.tags, ...installTreeTags };
@@ -99,5 +99,5 @@ export declare const CONTRIBUTION_KEYS: ContributionKey[];
99
99
  */
100
100
  export declare function payloadFor(key: ContributionKey, item: string): string;
101
101
  /** Keys whose contributions are wired by a host symlink (skip `merge` rows). */
102
- export declare const SYMLINK_KEYS: ("workers" | "mcp" | "knowledge" | "skills" | "commands" | "hooks" | "policies" | "scripts")[];
102
+ export declare const SYMLINK_KEYS: ("workers" | "mcp" | "skills" | "knowledge" | "commands" | "hooks" | "policies" | "scripts")[];
103
103
  //# sourceMappingURL=contribution-table.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.192.1",
3
+ "version": "5.193.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {