@indigoai-us/hq-cli 5.345.152 → 5.345.153

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 (77) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/dist/bin/hq-auth-refresh.d.ts +4 -0
  3. package/dist/bin/hq-auth-refresh.js +9 -1
  4. package/dist/command-catalog.generated.d.ts +185 -0
  5. package/dist/command-catalog.generated.js +227 -0
  6. package/dist/command-registration-plan.d.ts +6 -0
  7. package/dist/command-registration-plan.js +1 -0
  8. package/dist/commands/agent-enroll.d.ts +12 -0
  9. package/dist/commands/agent-enroll.js +37 -4
  10. package/dist/commands/core-policy.js +4 -4
  11. package/dist/commands/core-session-project-native.js +19 -4
  12. package/dist/commands/decide.d.ts +13 -0
  13. package/dist/commands/decide.js +74 -0
  14. package/dist/commands/integrations-connect.js +24 -1
  15. package/dist/commands/integrations-core.d.ts +14 -0
  16. package/dist/commands/integrations-core.js +19 -1
  17. package/dist/commands/integrations-import.js +22 -1
  18. package/dist/commands/integrations.js +43 -2
  19. package/dist/commands/lanes-message.js +4 -11
  20. package/dist/commands/lanes-questions.js +3 -0
  21. package/dist/commands/lanes-resume.js +4 -11
  22. package/dist/commands/lanes-stories.js +3 -0
  23. package/dist/commands/run.js +14 -5
  24. package/dist/lib/agent-kit/adopt-identity.d.ts +7 -1
  25. package/dist/lib/agent-kit/adopt-identity.js +18 -3
  26. package/dist/lib/agent-kit/services.d.ts +3 -1
  27. package/dist/lib/agent-kit/services.js +5 -1
  28. package/dist/lib/daemon/ops/policy.d.ts +9 -1
  29. package/dist/lib/daemon/ops/policy.js +37 -2
  30. package/dist/lib/decisions/client.d.ts +67 -0
  31. package/dist/lib/decisions/client.js +120 -0
  32. package/dist/lib/decisions/expiry.d.ts +2 -0
  33. package/dist/lib/decisions/expiry.js +12 -0
  34. package/dist/lib/decisions/fixtures/us001.d.ts +137 -0
  35. package/dist/lib/decisions/fixtures/us001.js +21 -0
  36. package/dist/lib/decisions/import-lanes.d.ts +16 -0
  37. package/dist/lib/decisions/import-lanes.js +152 -0
  38. package/dist/lib/decisions/principal.d.ts +11 -0
  39. package/dist/lib/decisions/principal.js +52 -0
  40. package/dist/lib/doctor/checks/claude-wiring.d.ts +2 -2
  41. package/dist/lib/doctor/checks/claude-wiring.js +21 -10
  42. package/dist/lib/doctor/checks/work-context.js +7 -2
  43. package/dist/lib/doctor/fix/apply.d.ts +3 -2
  44. package/dist/lib/doctor/fix/apply.js +19 -15
  45. package/dist/lib/doctor/fix/refresh-sync-progress.d.ts +14 -0
  46. package/dist/lib/doctor/fix/refresh-sync-progress.js +30 -2
  47. package/dist/lib/doctor/fix/remediation.js +4 -2
  48. package/dist/lib/doctor/hook-gate-profiles.d.ts +19 -0
  49. package/dist/lib/doctor/hook-gate-profiles.js +34 -0
  50. package/dist/lib/doctor/types.d.ts +5 -0
  51. package/dist/lib/install/provider-wrappers.js +5 -1
  52. package/dist/lib/lanes/autopilot.js +4 -1
  53. package/dist/lib/lanes/imported-question.d.ts +5 -0
  54. package/dist/lib/lanes/imported-question.js +17 -0
  55. package/dist/lib/lanes/questions.d.ts +0 -5
  56. package/dist/lib/lanes/questions.js +2 -0
  57. package/dist/lib/lanes/reconcile.js +21 -10
  58. package/dist/lib/lanes/store.d.ts +0 -8
  59. package/dist/lib/lanes/store.js +2 -1
  60. package/dist/lib/lanes/stories.d.ts +15 -0
  61. package/dist/lib/lanes/stories.js +25 -0
  62. package/dist/lib/mesh/client.d.ts +8 -0
  63. package/dist/lib/mesh/client.js +44 -3
  64. package/dist/lib/mesh/denial-codes.js +1 -1
  65. package/dist/lib/mesh/live/daemon/run.d.ts +9 -1
  66. package/dist/lib/mesh/live/daemon/run.js +30 -1
  67. package/dist/lib/mesh/live/flush.d.ts +7 -0
  68. package/dist/lib/mesh/live/flush.js +53 -10
  69. package/dist/lib/registry/registry.js +6 -0
  70. package/dist/lib/scan-packages/index.js +7 -3
  71. package/dist/lib/work-context/organize.d.ts +2 -0
  72. package/dist/lib/work-context/organize.js +22 -4
  73. package/dist/lib/workers-registry/index.d.ts +12 -1
  74. package/dist/lib/workers-registry/index.js +39 -1
  75. package/dist/utils/cognito-session.d.ts +4 -1
  76. package/dist/utils/cognito-session.js +11 -1
  77. package/package.json +7 -7
@@ -17,6 +17,7 @@
17
17
  import chalk from "chalk";
18
18
  import * as fs from "node:fs";
19
19
  import * as os from "node:os";
20
+ import * as path from "node:path";
20
21
  import { CLI_VERSION } from "../cli-version.js";
21
22
  import { DEFAULT_VAULT_API_URL, personTokenCacheFile, } from "../utils/cognito-session.js";
22
23
  import { HQ_CLIENT_NAME } from "../utils/vault-api.js";
@@ -25,7 +26,7 @@ import { queueCliLimitNoticeEngagement, queueCliLimitNoticeExposure, } from "../
25
26
  import { networkTransportErrorCode } from "../utils/network-transport-error.js";
26
27
  import { canOfferTeamUpgrade, formatUpgradeRefusal, isPlanGateError, offerUpgradeLink, planGateErrorFromPayload, } from "../utils/plan-gate-error.js";
27
28
  import { generateHostKeyPair, machineCredsFileExists, readExternalMachineCreds, writeHostKeyPair, writeMachineCreds, } from "../lib/agent-kit/creds.js";
28
- import { DEFAULT_LOCAL_AGENT_NAME, agentKitPaths, localAgentDir, } from "../lib/agent-kit/paths.js";
29
+ import { DEFAULT_LOCAL_AGENT_NAME, MACHINE_CREDS_NAME, agentKitPaths, agentRootDir, listLocalAgentDirs, localAgentDir, } from "../lib/agent-kit/paths.js";
29
30
  const ENROLL_PATH = "/v1/agents/enroll";
30
31
  /** Canonical code: un-grouped, uppercase (server hashes exactly this). */
31
32
  export function normalizeEnrollmentCode(raw) {
@@ -88,8 +89,10 @@ export function refusalMessage(existing) {
88
89
  `file is left untouched and never read.`);
89
90
  case "machine":
90
91
  return (`This host already has a machine identity at ${existing.file}` +
91
- `${existing.agentUid ? ` (${existing.agentUid})` : ""}. Re-run with --replace ` +
92
- `to enroll as a new agent; the old creds and host key will be overwritten.`);
92
+ `${existing.agentUid ? ` (${existing.agentUid})` : ""}. To add another bot beside ` +
93
+ `it, re-run with --name <bot>: each bot gets its own directory and its own agent ` +
94
+ `identity. Re-run with --replace only to swap this agent out; the old creds and ` +
95
+ `host key will be overwritten.`);
93
96
  }
94
97
  }
95
98
  export class EnrollError extends Error {
@@ -239,6 +242,24 @@ export function validateLocalAgentName(name) {
239
242
  }
240
243
  return trimmed;
241
244
  }
245
+ /**
246
+ * Agent trees under `home` holding an identity, other than `self`. Existence
247
+ * checks only; no creds file is opened.
248
+ */
249
+ export function otherAgentDirsOnHost(self, home = os.homedir()) {
250
+ const resolvedSelf = path.resolve(self);
251
+ const dirs = [];
252
+ const root = agentRootDir(home);
253
+ try {
254
+ if (fs.statSync(path.join(root, MACHINE_CREDS_NAME)).isFile())
255
+ dirs.push(root);
256
+ }
257
+ catch {
258
+ /* no identity in the root */
259
+ }
260
+ dirs.push(...listLocalAgentDirs(home));
261
+ return dirs.filter((dir) => path.resolve(dir) !== resolvedSelf);
262
+ }
242
263
  /** Pure-ish orchestration so tests can drive it without a TTY or network. */
243
264
  export async function enrollHost(opts, deps = {}) {
244
265
  const env = deps.env ?? process.env;
@@ -310,6 +331,7 @@ export async function enrollHost(opts, deps = {}) {
310
331
  ...(permissionsError ? { permissionsError } : {}),
311
332
  local: target.local,
312
333
  agentDir: paths.agentDir,
334
+ otherAgentDirs: otherAgentDirsOnHost(paths.agentDir, deps.home ?? paths.home),
313
335
  agentUid: parsed.agentUid,
314
336
  companySlug: parsed.companySlug,
315
337
  companyUid: parsed.companyUid,
@@ -343,12 +365,23 @@ export function registerAgentEnrollCommand(agent) {
343
365
  name: opts.name,
344
366
  });
345
367
  console.log(chalk.green(`Enrolled as ${result.agentUid} in ${result.companySlug}.`));
368
+ const shared = result.otherAgentDirs.length > 0;
346
369
  if (result.local) {
347
370
  // Say it plainly: this host belongs to a person, and the bot is a
348
371
  // guest on it. Nothing of theirs was read, moved or overwritten.
349
372
  console.log(chalk.dim(` This computer already has a person signed in, so this agent lives in its own\n` +
350
373
  ` directory beside them: ${result.agentDir}. Their session was not read or changed.\n` +
351
- ` Every hq agent command finds this tree on its own — no environment to set.`));
374
+ (shared
375
+ ? ``
376
+ : ` Every hq agent command finds this tree on its own — no environment to set.`)));
377
+ }
378
+ if (shared) {
379
+ // Feedback ce0b9c36: a second bot that ran without this variable
380
+ // signed in as the first bot. Say exactly what keeps them apart.
381
+ console.log(chalk.yellow(` This computer has more than one agent (${result.otherAgentDirs.join(", ")}).\n` +
382
+ ` Run this bot (its MCP server, kit install and every hq command) with\n` +
383
+ ` HQ_AGENT_DIR=${result.agentDir}\n` +
384
+ ` or it will act as one of the other agents. Each bot needs its own HQ_AGENT_DIR.`));
352
385
  }
353
386
  if (!result.permissionsEnforced) {
354
387
  // Not a warning to bury: the credential is on disk and working,
@@ -311,7 +311,7 @@ function epochFromDate(value) {
311
311
  return 0;
312
312
  return Math.floor(parsed.getTime() / 1000);
313
313
  }
314
- function countLedgerRows(directory, withMtime, recursive, awkPath) {
314
+ function countLedgerRows(directory, withMtime, recursive, awkPath, platform) {
315
315
  const counts = new Map();
316
316
  const newest = new Map();
317
317
  let anyFiles = false;
@@ -344,7 +344,7 @@ function countLedgerRows(directory, withMtime, recursive, awkPath) {
344
344
  catch (error) {
345
345
  const code = error.code;
346
346
  if (withMtime && (code === 'EACCES' || code === 'EPERM')) {
347
- stderr += awkCannotOpenFileDiagnostic(full, 'Permission denied', 1, process.platform, awkPath);
347
+ stderr += awkCannotOpenFileDiagnostic(full, 'Permission denied', 1, platform, awkPath);
348
348
  continue;
349
349
  }
350
350
  if (expectedFsMiss(error))
@@ -449,8 +449,8 @@ export function runPolicyAgeReport(args, root, env = process.env, nowEpoch = Mat
449
449
  const ledgerDir = env.HQ_POLICY_LEDGER_DIR || path.join(root, 'workspace/orchestrator/policy-trigger-state');
450
450
  const retrievalDir = env.HQ_POLICY_RETRIEVAL_LEDGER_DIR || path.join(root, 'workspace/orchestrator/policy-retrieval-state');
451
451
  const awkPath = env.PATH ?? '/usr/bin:/bin';
452
- const usage = countLedgerRows(ledgerDir, true, true, awkPath);
453
- const retrieval = countLedgerRows(retrievalDir, false, false, awkPath);
452
+ const usage = countLedgerRows(ledgerDir, true, true, awkPath, platform);
453
+ const retrieval = countLedgerRows(retrievalDir, false, false, awkPath, platform);
454
454
  const dirs = parsed.dirs.length > 0 ? parsed.dirs : [path.join(root, 'personal/policies'), path.join(root, 'core/policies')];
455
455
  const rows = [];
456
456
  let stderr = usage.stderr + retrieval.stderr;
@@ -249,6 +249,13 @@ export function runSessionProjectFast(argv, stdin, cwd, root, dependencies = {})
249
249
  return error?.code !== "ESRCH";
250
250
  }
251
251
  }
252
+ // Windows keeps a just-released lock directory or pid file in a delete-pending
253
+ // state while another waiter still holds a handle on it. Creating the same
254
+ // name in that window fails with EPERM, EACCES or EBUSY instead of EEXIST, so
255
+ // it is lock contention and the waiter keeps polling until its deadline.
256
+ function isTransientWindowsLockError(error) {
257
+ return process.platform === "win32" && ["EPERM", "EACCES", "EBUSY"].includes(error?.code);
258
+ }
252
259
  function acquireProjectWriteLock(prdPath) {
253
260
  const lock = `${prdPath}.write-lock`;
254
261
  const ownerFile = path.join(lock, "pid");
@@ -260,14 +267,21 @@ export function runSessionProjectFast(argv, stdin, cwd, root, dependencies = {})
260
267
  try {
261
268
  fs.writeFileSync(ownerFile, `${process.pid}\n`, { flag: "wx" });
262
269
  }
263
- catch {
264
- fs.rmSync(lock, { recursive: true, force: true });
265
- return undefined;
270
+ catch (error) {
271
+ fs.rmSync(lock, { recursive: true, force: true, maxRetries: 10, retryDelay: 10 });
272
+ if (!isTransientWindowsLockError(error))
273
+ return undefined;
274
+ sleepSync(20);
275
+ continue;
266
276
  }
267
277
  process.once("exit", () => releaseProjectWriteLock(lock));
268
278
  return lock;
269
279
  }
270
280
  catch (error) {
281
+ if (isTransientWindowsLockError(error)) {
282
+ sleepSync(20);
283
+ continue;
284
+ }
271
285
  if (error?.code !== "EEXIST")
272
286
  return undefined;
273
287
  let owner = 0;
@@ -299,8 +313,9 @@ export function runSessionProjectFast(argv, stdin, cwd, root, dependencies = {})
299
313
  function releaseProjectWriteLock(lock) {
300
314
  try {
301
315
  const owner = Number.parseInt(fs.readFileSync(path.join(lock, "pid"), "utf8"), 10);
316
+ // Retry so a waiter's open handle on the pid file cannot leave an ownerless lock directory behind.
302
317
  if (owner === process.pid)
303
- fs.rmSync(lock, { recursive: true, force: true });
318
+ fs.rmSync(lock, { recursive: true, force: true, maxRetries: 10, retryDelay: 10 });
304
319
  }
305
320
  catch { /* lock cleanup is best-effort; stale owners are reclaimed */ }
306
321
  }
@@ -0,0 +1,13 @@
1
+ import { Command } from "commander";
2
+ import { DecisionsClient } from "../lib/decisions/client.js";
3
+ export interface DecideDeps {
4
+ client: DecisionsClient;
5
+ audience: () => Promise<string>;
6
+ importAudience: () => Promise<string>;
7
+ root: () => string;
8
+ company: (ref?: string) => Promise<string>;
9
+ now: () => number;
10
+ print: (value: string) => void;
11
+ }
12
+ export declare function registerDecideCommand(program: Command, overrides?: Partial<DecideDeps>): void;
13
+ //# sourceMappingURL=decide.d.ts.map
@@ -0,0 +1,74 @@
1
+ import { getCompanyUid } from "../utils/vault-api.js";
2
+ import { resolveActiveCompany } from "./workers.js";
3
+ import { decisionExpiry } from "../lib/decisions/expiry.js";
4
+ import { Option } from "commander";
5
+ import { ensureCognitoToken, resolveDefaultHqRoot } from "../utils/cognito-session.js";
6
+ import { DecisionsClient, DecisionError, parseOptions } from "../lib/decisions/client.js";
7
+ import { askAudience, importAudience } from "../lib/decisions/principal.js";
8
+ import { importLaneQuestions } from "../lib/decisions/import-lanes.js";
9
+ export function registerDecideCommand(program, overrides = {}) {
10
+ const token = () => ensureCognitoToken({ interactive: false });
11
+ const deps = {
12
+ client: new DecisionsClient(token), audience: async () => askAudience(await token()),
13
+ importAudience: async () => importAudience(await token()),
14
+ company: async (ref) => getCompanyUid(await token(), ref ?? resolveActiveCompany(deps.root())),
15
+ now: () => Date.now(),
16
+ root: () => resolveDefaultHqRoot(), print: value => console.log(value), ...overrides,
17
+ };
18
+ const decide = program.command("decide").description("Ask and answer owner decisions in the HQ Queue");
19
+ const repeat = (value, previous) => [...previous, value];
20
+ decide.command("ask").description("Create an option-choice owner decision as the personal local bot").argument("<statement>")
21
+ .addOption(new Option("--to <audience>", "Decision audience").choices(["me"]).default("me"))
22
+ .option("--option <id|label>", "Choice, repeatable", repeat, [])
23
+ .option("--recommend <id>", "Recommended option id")
24
+ .option("--context-link <url>", "Context link, repeatable", repeat, [])
25
+ .option("--context <text>", "Short context brief", "")
26
+ .option("--company <slug|uid>", "Company (defaults to the active company)")
27
+ .option("--expires <duration|iso>", "Expiry duration (m, h, d) or ISO date-time", "72h")
28
+ .option("--commitment <id>", "Related commitment")
29
+ .option("--json", "Print the decision record")
30
+ .action(async (statement, opts) => {
31
+ if (!statement.trim())
32
+ throw new DecisionError("invalid_statement", "Statement must not be empty");
33
+ const options = parseOptions(opts.option, opts.recommend);
34
+ for (const link of opts.contextLink) {
35
+ let valid = false;
36
+ try {
37
+ valid = ["https:", "http:"].includes(new URL(link).protocol);
38
+ }
39
+ catch { /* invalid URL */ }
40
+ if (!valid)
41
+ throw new DecisionError("invalid_context_link", "Context links must be HTTP(S) URLs");
42
+ }
43
+ const expiresAt = decisionExpiry(opts.expires, deps.now());
44
+ const owner = await deps.audience();
45
+ const companyUid = await deps.company(opts.company);
46
+ const record = await deps.client.ask({ companyUid, expiresAt, audience: { kind: "person", personUid: owner }, statement, options, context: {
47
+ links: opts.contextLink, brief: opts.context, commitmentId: opts.commitment ?? null,
48
+ } });
49
+ deps.print(opts.json ? JSON.stringify(record, null, 2) : record.id);
50
+ });
51
+ decide.command("list").description("Read your decisions across companies, oldest first").option("--open", "List open decisions").option("--json", "Print JSON")
52
+ .action(async (opts) => {
53
+ const decisions = await deps.client.list(opts.open);
54
+ deps.print(opts.json ? JSON.stringify(decisions, null, 2) : decisions.map(record => `${record.id}\t${record.state}\t${record.statement}`).join("\n"));
55
+ });
56
+ decide.command("show").description("Show a visible decision, including its options and answer").argument("<id>").action(async (id) => deps.print(JSON.stringify(await deps.client.show(id), null, 2)));
57
+ decide.command("answer").description("Answer with an option ID; the server enforces expiry and first answer").argument("<id>").requiredOption("--choice <id>", "Option id")
58
+ .addOption(new Option("--source <source>", "Answer surface").choices(["desktop", "thread"]).default("desktop"))
59
+ .action(async (id, opts) => {
60
+ if (!opts.choice.trim())
61
+ throw new DecisionError("invalid_choice", "Choice must not be empty");
62
+ deps.print((await deps.client.answer(id, opts.choice, opts.source)).state);
63
+ });
64
+ for (const action of ["talk", "withdraw"]) {
65
+ decide.command(action).description(action === "talk" ? "Open the decision's bot conversation" : "Withdraw a decision you asked").argument("<id>").action(async (id) => deps.print((await deps.client.transition(id, action)).state));
66
+ }
67
+ decide.command("import-lanes").description("Import option-style legacy questions once; report skipped advanced questions")
68
+ .option("--json", "Print import counts and skipped question reasons")
69
+ .action(async (opts) => {
70
+ const counts = await importLaneQuestions(deps.root(), await deps.importAudience(), deps.client);
71
+ deps.print(opts.json ? JSON.stringify(counts, null, 2) : `Imported ${counts.imported}; already imported ${counts.alreadyImported}; skipped ${counts.skipped}`);
72
+ });
73
+ }
74
+ //# sourceMappingURL=decide.js.map
@@ -1228,6 +1228,18 @@ export function findAlreadyConnected(connections, app) {
1228
1228
  return wantSlug !== "" && displayNameSlug(displayName) === wantSlug;
1229
1229
  });
1230
1230
  }
1231
+ /**
1232
+ * True when a member (not an owner or admin) is looking at a connection another
1233
+ * person created. An unknown viewer or creator is never treated as someone
1234
+ * else's, so older servers keep the ordinary hints.
1235
+ */
1236
+ function connectedBySomeoneElse(connection, viewer) {
1237
+ if (!viewer?.personUid || !connection.createdBy)
1238
+ return false;
1239
+ if (viewer.role === "owner" || viewer.role === "admin")
1240
+ return false;
1241
+ return connection.createdBy !== viewer.personUid;
1242
+ }
1231
1243
  /**
1232
1244
  * Report an app as already connected instead of opening a second sign-in.
1233
1245
  * Returns true when the connect should stop here.
@@ -1239,8 +1251,11 @@ async function refuseIfAlreadyConnected(token, companyUid, app, opts) {
1239
1251
  if (!app || opts.force || opts.mcpUrl || opts.docsUrl || opts.entryId)
1240
1252
  return false;
1241
1253
  let connections;
1254
+ let viewer;
1242
1255
  try {
1243
- connections = (await fetchAdminSurface(token, companyUid)).connections ?? [];
1256
+ const surface = await fetchAdminSurface(token, companyUid);
1257
+ connections = surface.connections ?? [];
1258
+ viewer = surface.viewer;
1244
1259
  }
1245
1260
  catch {
1246
1261
  // Never let this convenience check block a connect. If the list cannot be
@@ -1263,6 +1278,14 @@ async function refuseIfAlreadyConnected(token, companyUid, app, opts) {
1263
1278
  }
1264
1279
  const label = redactUrlsInText(existing.installation?.displayName ?? slug);
1265
1280
  console.log(chalk.green(`${label} is already connected.`));
1281
+ if (connectedBySomeoneElse(existing, viewer)) {
1282
+ // Members may add apps and reconnect their own, but replacing a connection
1283
+ // someone else created stays with an owner or admin: the server refuses
1284
+ // both the reconnect and the --force re-add with INTEGRATION_RECONNECT_FORBIDDEN.
1285
+ console.log(chalk.dim(" Someone else connected it. To sign it in again, ask a company owner or admin to run:\n" +
1286
+ ` hq integrations reconnect --provider ${slug}`));
1287
+ return true;
1288
+ }
1266
1289
  console.log(chalk.dim(` Sign in again: hq integrations reconnect --provider ${slug}\n` +
1267
1290
  ` Add a second: hq integrations connect ${shellQuote(app)} --force`));
1268
1291
  return true;
@@ -104,6 +104,12 @@ export interface AdminSurface {
104
104
  role: string;
105
105
  canManageGovernance: boolean;
106
106
  canManageIntegrations?: boolean;
107
+ /**
108
+ * Owners, admins, and members may add apps; guests may not. Removing and
109
+ * governing apps stays with `canManageIntegrations` / `canManageGovernance`.
110
+ * Absent on servers that predate members connecting apps.
111
+ */
112
+ canAddIntegrations?: boolean;
107
113
  };
108
114
  connections: AdminConnection[];
109
115
  audit: AdminAuditEvent[];
@@ -207,6 +213,14 @@ export declare function upstreamUnavailableMessage(status: number): string;
207
213
  */
208
214
  export declare function raiseIfUpstreamUnavailable(res: Response): void;
209
215
  export declare function raiseIfUnauthorized(res: Response): void;
216
+ /**
217
+ * A member connected, reinstalled, or signed in to an app that someone else
218
+ * already connected for the company. Members may add apps and reconnect their
219
+ * own, but replacing another person's company connection stays with an owner
220
+ * or admin, so the server refuses it with this 403.
221
+ */
222
+ export declare const INTEGRATION_RECONNECT_FORBIDDEN_CODE = "INTEGRATION_RECONNECT_FORBIDDEN";
223
+ export declare const INTEGRATION_RECONNECT_FORBIDDEN_MESSAGE: string;
210
224
  /**
211
225
  * Single non-2xx funnel for the REST (non-JSON-RPC) integration routes. Applies
212
226
  * the auth and upstream-availability guards in order, then raises the server's
@@ -259,6 +259,23 @@ export function raiseIfUnauthorized(res) {
259
259
  if (res.status === 401)
260
260
  throw new AuthError();
261
261
  }
262
+ /**
263
+ * A member connected, reinstalled, or signed in to an app that someone else
264
+ * already connected for the company. Members may add apps and reconnect their
265
+ * own, but replacing another person's company connection stays with an owner
266
+ * or admin, so the server refuses it with this 403.
267
+ */
268
+ export const INTEGRATION_RECONNECT_FORBIDDEN_CODE = "INTEGRATION_RECONNECT_FORBIDDEN";
269
+ export const INTEGRATION_RECONNECT_FORBIDDEN_MESSAGE = "This app is already connected for the company by someone else, so you can't reconnect or replace it. " +
270
+ "Ask a company owner or admin to reconnect it, or ask them to share the existing connection with you " +
271
+ "(`hq integrations list` shows the apps you can use).";
272
+ /**
273
+ * CLI wording for server error codes whose response text alone does not tell
274
+ * the person what to run next. Unlisted codes keep the server's own text.
275
+ */
276
+ const CLI_ERROR_MESSAGES = new Map([
277
+ [INTEGRATION_RECONNECT_FORBIDDEN_CODE, INTEGRATION_RECONNECT_FORBIDDEN_MESSAGE],
278
+ ]);
262
279
  /**
263
280
  * Single non-2xx funnel for the REST (non-JSON-RPC) integration routes. Applies
264
281
  * the auth and upstream-availability guards in order, then raises the server's
@@ -279,7 +296,8 @@ export async function raiseForResponse(res, fallback) {
279
296
  // hq-pro mints this error text, so it is untrusted input to a message we
280
297
  // print verbatim — scrub credentials out before it can reach a terminal or a
281
298
  // Sentry crash report (same reasoning as the gateway path below).
282
- const detail = redactErrorText(body.error ?? "");
299
+ const detail = (body.code ? CLI_ERROR_MESSAGES.get(body.code) : undefined) ??
300
+ redactErrorText(body.error ?? "");
283
301
  const redirectUri = httpsUrlOrNull(body.redirectUri);
284
302
  throw new IntegrationsCliError(detail || `${fallback} (HTTP ${res.status})`, {
285
303
  expected: isClientError(res.status),
@@ -11,7 +11,7 @@ import chalk from "chalk";
11
11
  import { ensureCognitoIdToken, ensureCognitoToken, resolveDefaultHqRoot } from "../utils/cognito-session.js";
12
12
  import { getCompanyUid, vaultApiFetch } from "../utils/vault-api.js";
13
13
  import { redactSensitiveUrl, redactUrlsInText } from "../utils/redact-url.js";
14
- import { bareProvider, IntegrationsCliError, printJson } from "./integrations-core.js";
14
+ import { bareProvider, INTEGRATION_RECONNECT_FORBIDDEN_CODE, IntegrationsCliError, printJson, } from "./integrations-core.js";
15
15
  import { installIntegration, startOAuth } from "./integrations-api.js";
16
16
  import { loadRevealedSecrets } from "./secrets.js";
17
17
  import { registerServer } from "./mcp-registration.js";
@@ -147,6 +147,9 @@ function oauthRequired(error) {
147
147
  return error instanceof IntegrationsCliError &&
148
148
  (error.code === OAUTH_REQUIRED_CODE || error.oauthProtected === true);
149
149
  }
150
+ function reconnectForbidden(error) {
151
+ return error instanceof IntegrationsCliError && error.code === INTEGRATION_RECONNECT_FORBIDDEN_CODE;
152
+ }
150
153
  function reconnectCommand(name, provider) {
151
154
  return `hq integrations reconnect ${name} --provider ${bareProvider(provider)}`;
152
155
  }
@@ -190,6 +193,24 @@ async function importRemote(token, companyUid, name, entry, dryRun) {
190
193
  if (dryRun)
191
194
  return { name, status: "imported", reason: "would connect remote MCP endpoint", provider };
192
195
  const credential = staticCredential(entry);
196
+ try {
197
+ return await installRemote(token, companyUid, name, provider, entry, credential);
198
+ }
199
+ catch (error) {
200
+ // Members may import connectors, but not over an app someone else already
201
+ // connected for the company. Skip that one connector and keep importing
202
+ // the rest rather than aborting the whole run.
203
+ if (!reconnectForbidden(error))
204
+ throw error;
205
+ return {
206
+ name,
207
+ status: "skipped",
208
+ provider,
209
+ reason: "already connected by someone else; ask a company owner or admin to reconnect it",
210
+ };
211
+ }
212
+ }
213
+ async function installRemote(token, companyUid, name, provider, entry, credential) {
193
214
  try {
194
215
  const result = await installIntegration(token, companyUid, {
195
216
  mcpUrl: entry.url,
@@ -297,6 +297,31 @@ export async function waitForApprovalTerminal(token, companyUid, queueId, option
297
297
  }
298
298
  return null;
299
299
  }
300
+ /**
301
+ * Read the stored result of an approved call that already ran. The confirm
302
+ * route answers an approve for a `resumed` row from the queue row
303
+ * (alreadyResumed) without calling the provider again. Returns undefined when
304
+ * the result cannot be read; that never fails the approve.
305
+ */
306
+ async function fetchStoredApprovalResult(token, companyUid, connectionId, queueId) {
307
+ try {
308
+ const res = await vaultApiFetch({
309
+ token,
310
+ path: `/v1/integrations/confirm/${encodeURIComponent(queueId)}/approve`,
311
+ method: "POST",
312
+ body: { companyUid, connectionId },
313
+ });
314
+ if (!res.ok)
315
+ return undefined;
316
+ const body = (await res.json().catch(() => ({})));
317
+ if (body.status !== "resumed" || body.alreadyResumed !== true)
318
+ return undefined;
319
+ return { result: body.result };
320
+ }
321
+ catch {
322
+ return undefined;
323
+ }
324
+ }
300
325
  function isPendingApprovalResponse(status) {
301
326
  return ["pending", "approved", "approved_pending_run", "in_progress"].includes(status ?? "");
302
327
  }
@@ -563,8 +588,18 @@ export function registerIntegrationsCommand(program) {
563
588
  }
564
589
  return;
565
590
  }
566
- if (opts.json)
567
- printJson(terminal);
591
+ // feedback_3bf0f73f-1537-4d32-a143-9dfc43bc293f: the async worker
592
+ // stores the provider result on the queue row, but the history
593
+ // rows this poll reads carry no result. Re-posting approve for a
594
+ // `resumed` row does not run the call again: the gateway answers
595
+ // it from the stored row with alreadyResumed and the cached result.
596
+ const ran = terminal.status === "resumed";
597
+ const stored = ran
598
+ ? await fetchStoredApprovalResult(token, companyUid, connection.id, queueId)
599
+ : undefined;
600
+ if (opts.json) {
601
+ printJson(stored && stored.result !== undefined ? { ...terminal, result: stored.result } : terminal);
602
+ }
568
603
  else if (approvalOutcomeFailed(terminal)) {
569
604
  const unknown = terminal.status === "write_unknown" || terminal.outcome === "unknown";
570
605
  console.log(chalk.red(unknown
@@ -573,6 +608,12 @@ export function registerIntegrationsCommand(program) {
573
608
  }
574
609
  else if (terminal.status === "resumed" || terminal.status === "completed" || terminal.outcome === "ran") {
575
610
  console.log(chalk.green("Approved — the call completed."));
611
+ if (stored && stored.result !== undefined && stored.result !== null) {
612
+ printJson(unwrapGatewayResult(stored.result));
613
+ }
614
+ else if (ran) {
615
+ console.log(chalk.yellow(`The call's result could not be read back. Run \`hq integrations approve ${queueId}\` again to fetch it; the call will not run twice.`));
616
+ }
576
617
  }
577
618
  else {
578
619
  console.log(chalk.yellow(`Approval ended with status ${terminal.status ?? terminal.outcome ?? "unknown"}.`));
@@ -8,7 +8,7 @@ import { parseOutpostHireGrantContext, sameHireGrantSenior } from "../lib/lanes/
8
8
  import { dispatchLaneDelivery } from "../lib/lanes/delivery-core.js";
9
9
  import { dropboxInboxHasMessages, drainDropbox, formatDropboxMessages, formatMonitorEvent, requeueDropboxMessages } from "../lib/lanes/dropbox.js";
10
10
  import { isPidAlive, runWatcherLoop, WATCHER_OUT_MAX_BYTES, watcherOutLogPath, writeWatcherDecision } from "../lib/lanes/watcher.js";
11
- import { laneStories, laneStory, storyWarning } from "../lib/lanes/stories.js";
11
+ import { attributedStoryRow, laneStories, storiesAfterAttribution, storyWarning } from "../lib/lanes/stories.js";
12
12
  import { artifactIdentityKeys, rollbackStorySwitchAttribution, storySwitchNotice } from "../lib/lanes/story-switch.js";
13
13
  import { laneRunDir, probePidRecord, readPidRecord, readProcStarttime, spawnLaneWorker } from "../lib/lanes/spawn.js";
14
14
  import { findRetiredLaneEntry, keepAliveExclusion, keepAliveSupersededBy, LANE_RUNNING_SUPERSEDED_MESSAGE_DELIVERY_FLAG, LANE_SUPERSESSION_LINEAGE_AWARE_FLAG, loadRetiredLaneIds } from "../lib/lanes/keep-alive.js";
@@ -630,15 +630,13 @@ export function createLaneMessageHandlers(deps) {
630
630
  if (attributionChanged) {
631
631
  const already = current.attribution_history.some((entry) => entry.message_id === messageId);
632
632
  if (!already) {
633
- const existingStory = laneStory(current, projectId, storyId);
633
+ const addedStory = attributedStoryRow(current, projectId, storyId, at);
634
634
  storySwitchRollback = {
635
635
  message_id: messageId,
636
636
  project_id: current.project_id,
637
637
  story_id: current.story_id,
638
638
  no_story_reason: current.no_story_reason,
639
- ...(!existingStory ? {
640
- added_story: { project_id: projectId, story_id: storyId, status: "in_progress", added_at: at, updated_at: at },
641
- } : {}),
639
+ ...(addedStory ? { added_story: addedStory } : {}),
642
640
  story_switch_unacknowledged: current.story_switch_unacknowledged,
643
641
  };
644
642
  next = {
@@ -646,12 +644,7 @@ export function createLaneMessageHandlers(deps) {
646
644
  project_id: projectId,
647
645
  story_id: storyId,
648
646
  no_story_reason: storyId ? undefined : current.no_story_reason,
649
- stories: laneStory(current, projectId, storyId)
650
- ? laneStories(current)
651
- : [
652
- ...laneStories(current),
653
- { project_id: projectId, story_id: storyId, status: "in_progress", added_at: at, updated_at: at },
654
- ],
647
+ stories: storiesAfterAttribution(current, projectId, storyId, at),
655
648
  attribution_history: appendCappedHistory(current.attribution_history, {
656
649
  project_id: projectId,
657
650
  story_id: storyId,
@@ -1,4 +1,5 @@
1
1
  import * as path from "node:path";
2
+ import { isQuestionImported } from "../lib/lanes/imported-question.js";
2
3
  import { buildOutpostQuestionAnswerCommand, buildOutpostQuestionCancelCommand, buildOutpostQuestionShowCommand, dispatchErrorMessage } from "../lib/lanes/outpost-dispatch.js";
3
4
  import { assertNoSecretText } from "../lib/lanes/text-scan.js";
4
5
  import { sessionIdFromEnv } from "../lib/mesh/live/session-identity.js";
@@ -639,6 +640,7 @@ export function createLaneQuestionsHandlers(deps) {
639
640
  const visibleQuestion = deliveredLocalQuestions.get(q.question_id) ?? q;
640
641
  const row = {
641
642
  ...visibleQuestion,
643
+ ...(isQuestionImported(hqRoot, q.question_id) ? { status_display: "moved to hq decide", moved_to: "hq decide" } : {}),
642
644
  priority: visibleQuestion.priority ?? null,
643
645
  priority_display: visibleQuestion.priority ?? "-",
644
646
  audience: resolveAudience(hqRoot, visibleQuestion, ctx),
@@ -741,6 +743,7 @@ export function createLaneQuestionsHandlers(deps) {
741
743
  ok: true,
742
744
  action: "questions.show",
743
745
  question,
746
+ ...(isQuestionImported(hqRoot, question.question_id) ? { status_display: "moved to hq decide", moved_to: "hq decide" } : {}),
744
747
  audience: resolveAudience(hqRoot, question, ctx),
745
748
  body,
746
749
  });
@@ -4,7 +4,7 @@ import { appendCappedHistory, isFailedQuotaKeepAliveLane, providerSessionPortabl
4
4
  import { parseCliEffort } from "../lib/lanes/effort.js";
5
5
  import { parseOutpostHireGrantContext } from "../lib/lanes/hire-grant-dispatch.js";
6
6
  import { assertNoSecretText } from "../lib/lanes/text-scan.js";
7
- import { laneStories, laneStory, storyWarning } from "../lib/lanes/stories.js";
7
+ import { attributedStoryRow, laneStories, storiesAfterAttribution, storyWarning } from "../lib/lanes/stories.js";
8
8
  import { artifactIdentityKeys, rollbackStorySwitchAttribution, storySwitchNotice } from "../lib/lanes/story-switch.js";
9
9
  import { markSpawnFailure, spawnLaneWorker } from "../lib/lanes/spawn.js";
10
10
  import { appendProjectFocusOverride, removeProjectFocusOverride } from "../lib/lanes/story-ledger.js";
@@ -359,15 +359,13 @@ export function createLaneResumeHandlers(deps) {
359
359
  const already = current.attribution_history.some((entry) => entry.message_id === messageId);
360
360
  if (already)
361
361
  return current;
362
- const existingStory = laneStory(current, projectId, storyId);
362
+ const addedStory = attributedStoryRow(current, projectId, storyId, at);
363
363
  storySwitchRollback = {
364
364
  message_id: messageId,
365
365
  project_id: current.project_id,
366
366
  story_id: current.story_id,
367
367
  no_story_reason: current.no_story_reason,
368
- ...(!existingStory ? {
369
- added_story: { project_id: projectId, story_id: storyId, status: "in_progress", added_at: at, updated_at: at },
370
- } : {}),
368
+ ...(addedStory ? { added_story: addedStory } : {}),
371
369
  story_switch_unacknowledged: current.story_switch_unacknowledged,
372
370
  };
373
371
  return {
@@ -375,12 +373,7 @@ export function createLaneResumeHandlers(deps) {
375
373
  project_id: projectId,
376
374
  story_id: storyId,
377
375
  no_story_reason: storyId ? undefined : current.no_story_reason,
378
- stories: laneStory(current, projectId, storyId)
379
- ? laneStories(current)
380
- : [
381
- ...laneStories(current),
382
- { project_id: projectId, story_id: storyId, status: "in_progress", added_at: at, updated_at: at },
383
- ],
376
+ stories: storiesAfterAttribution(current, projectId, storyId, at),
384
377
  attribution_history: appendCappedHistory(current.attribution_history, {
385
378
  project_id: projectId,
386
379
  story_id: storyId,
@@ -44,6 +44,9 @@ export function createLaneStoryHandlers(deps) {
44
44
  };
45
45
  }
46
46
  function addCarriedStory(lane, projectId, storyId, at) {
47
+ if (!storyId.trim()) {
48
+ throw new LanesError("missing_story", "A story id is required to add a story to a lane", { lane_id: lane.lane_id });
49
+ }
47
50
  if (laneStory(lane, projectId, storyId)) {
48
51
  throw new LanesError("story_already_on_lane", `Story ${projectId}/${storyId} is already carried by lane ${lane.lane_id}`, {
49
52
  lane_id: lane.lane_id,
@@ -149,11 +149,20 @@ export function registerRunCommand(program) {
149
149
  // error instead of hanging on varlock's never-settling promise, in case
150
150
  // the shape-dependent guard above ever silently degrades.
151
151
  await resolveEnvValuesOrThrow(graph);
152
- const schemaErrors = Object.entries(graph.configSchema)
153
- .filter(([, item]) => item.errors?.length > 0);
154
- if (schemaErrors.length > 0) {
155
- const msgs = schemaErrors.flatMap(([k, item]) => item.errors.map((e) => ` ${k}: ${e.message ?? String(e)}`));
156
- process.stderr.write(`Error: failed to resolve env vars:\n${msgs.join('\n')}\n`);
152
+ // varlock's item.errors includes warning-severity entries (isWarning),
153
+ // e.g. "Value is very short! Is it actually sensitive?" for a short
154
+ // sensitive value. Print those and carry on; only real errors are fatal.
155
+ const schemaItems = Object.entries(graph.configSchema);
156
+ const itemMessages = (wantWarnings) => schemaItems.flatMap(([k, item]) => (item.errors ?? [])
157
+ .filter((e) => Boolean(e?.isWarning) === wantWarnings)
158
+ .map((e) => ` ${k}: ${e?.message ?? String(e)}`));
159
+ const warningMsgs = itemMessages(true);
160
+ if (warningMsgs.length > 0) {
161
+ process.stderr.write(`Warning: env var checks reported warnings:\n${warningMsgs.join('\n')}\n`);
162
+ }
163
+ const errorMsgs = itemMessages(false);
164
+ if (errorMsgs.length > 0) {
165
+ process.stderr.write(`Error: failed to resolve env vars:\n${errorMsgs.join('\n')}\n`);
157
166
  process.exit(1);
158
167
  }
159
168
  const resolvedEnv = graph.getResolvedEnvObject();