@indigoai-us/hq-cli 5.345.152 → 5.345.154

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 (82) hide show
  1. package/CHANGELOG.md +96 -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/cloud.d.ts +17 -0
  11. package/dist/commands/cloud.js +48 -1
  12. package/dist/commands/core-policy.js +4 -4
  13. package/dist/commands/core-session-project-native.js +19 -4
  14. package/dist/commands/decide.d.ts +13 -0
  15. package/dist/commands/decide.js +74 -0
  16. package/dist/commands/integrations-connect.js +24 -1
  17. package/dist/commands/integrations-core.d.ts +14 -0
  18. package/dist/commands/integrations-core.js +19 -1
  19. package/dist/commands/integrations-import.js +22 -1
  20. package/dist/commands/integrations.js +43 -2
  21. package/dist/commands/lanes-message.js +4 -11
  22. package/dist/commands/lanes-questions.js +3 -0
  23. package/dist/commands/lanes-resume.js +4 -11
  24. package/dist/commands/lanes-stories.js +3 -0
  25. package/dist/commands/lanes.js +7 -1
  26. package/dist/commands/run.js +14 -5
  27. package/dist/lib/agent-kit/adopt-identity.d.ts +7 -1
  28. package/dist/lib/agent-kit/adopt-identity.js +18 -3
  29. package/dist/lib/agent-kit/services.d.ts +3 -1
  30. package/dist/lib/agent-kit/services.js +5 -1
  31. package/dist/lib/daemon/ops/policy.d.ts +9 -1
  32. package/dist/lib/daemon/ops/policy.js +37 -2
  33. package/dist/lib/decisions/client.d.ts +67 -0
  34. package/dist/lib/decisions/client.js +120 -0
  35. package/dist/lib/decisions/expiry.d.ts +2 -0
  36. package/dist/lib/decisions/expiry.js +12 -0
  37. package/dist/lib/decisions/fixtures/us001.d.ts +137 -0
  38. package/dist/lib/decisions/fixtures/us001.js +21 -0
  39. package/dist/lib/decisions/import-lanes.d.ts +16 -0
  40. package/dist/lib/decisions/import-lanes.js +152 -0
  41. package/dist/lib/decisions/principal.d.ts +11 -0
  42. package/dist/lib/decisions/principal.js +52 -0
  43. package/dist/lib/doctor/checks/claude-wiring.d.ts +2 -2
  44. package/dist/lib/doctor/checks/claude-wiring.js +21 -10
  45. package/dist/lib/doctor/checks/work-context.js +7 -2
  46. package/dist/lib/doctor/fix/apply.d.ts +3 -2
  47. package/dist/lib/doctor/fix/apply.js +19 -15
  48. package/dist/lib/doctor/fix/refresh-sync-progress.d.ts +14 -0
  49. package/dist/lib/doctor/fix/refresh-sync-progress.js +30 -2
  50. package/dist/lib/doctor/fix/remediation.js +4 -2
  51. package/dist/lib/doctor/hook-gate-profiles.d.ts +19 -0
  52. package/dist/lib/doctor/hook-gate-profiles.js +34 -0
  53. package/dist/lib/doctor/types.d.ts +5 -0
  54. package/dist/lib/install/provider-wrappers.js +5 -1
  55. package/dist/lib/lanes/autopilot.js +4 -1
  56. package/dist/lib/lanes/imported-question.d.ts +5 -0
  57. package/dist/lib/lanes/imported-question.js +17 -0
  58. package/dist/lib/lanes/questions.d.ts +0 -5
  59. package/dist/lib/lanes/questions.js +2 -0
  60. package/dist/lib/lanes/reconcile.js +21 -10
  61. package/dist/lib/lanes/store.d.ts +0 -8
  62. package/dist/lib/lanes/store.js +2 -1
  63. package/dist/lib/lanes/stories.d.ts +15 -0
  64. package/dist/lib/lanes/stories.js +25 -0
  65. package/dist/lib/mesh/client.d.ts +8 -0
  66. package/dist/lib/mesh/client.js +44 -3
  67. package/dist/lib/mesh/denial-codes.js +1 -1
  68. package/dist/lib/mesh/live/daemon/run.d.ts +9 -1
  69. package/dist/lib/mesh/live/daemon/run.js +30 -1
  70. package/dist/lib/mesh/live/flush.d.ts +7 -0
  71. package/dist/lib/mesh/live/flush.js +53 -10
  72. package/dist/lib/registry/registry.js +6 -0
  73. package/dist/lib/scan-packages/index.js +7 -3
  74. package/dist/lib/sync/read-only-local-only.d.ts +45 -0
  75. package/dist/lib/sync/read-only-local-only.js +122 -0
  76. package/dist/lib/work-context/organize.d.ts +2 -0
  77. package/dist/lib/work-context/organize.js +22 -4
  78. package/dist/lib/workers-registry/index.d.ts +12 -1
  79. package/dist/lib/workers-registry/index.js +39 -1
  80. package/dist/utils/cognito-session.d.ts +4 -1
  81. package/dist/utils/cognito-session.js +11 -1
  82. 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,
@@ -401,4 +401,21 @@ export declare function registerCloudCommands(program: Command): void;
401
401
  export declare function runSyncDoctorReconcileConflicts(hqRoot: string, yes: boolean): Promise<void>;
402
402
  /** Exported for the US-003 sync-health exit-path regression test. */
403
403
  export declare function runPullPersonal(hqRoot: string, onConflict?: ConflictStrategy): Promise<void>;
404
+ /**
405
+ * Human-friendly label for a journal slug in `hq sync status`. The
406
+ * personal-vault fanout slot and the legacy `companies/personal` company both
407
+ * carry opaque slugs; everything else is a real company slug shown as-is.
408
+ */
409
+ /**
410
+ * `hq sync status`: list local files under read-only vault prefixes that are
411
+ * not in the sync journal. Push never uploads them (feedback_510661bc,
412
+ * Shepherd #2813). Uses only the local vault-access manifest and the journals
413
+ * already loaded, with no network calls. Prints nothing when nothing applies.
414
+ */
415
+ export declare function printReadOnlyLocalOnly(hqRoot: string, journals: ReadonlyArray<{
416
+ slug: string;
417
+ journal: {
418
+ files?: Record<string, unknown>;
419
+ };
420
+ }>, log?: (line: string) => void): number;
404
421
  //# sourceMappingURL=cloud.d.ts.map
@@ -13,10 +13,11 @@
13
13
  * hq sync status — show local journal summary
14
14
  */
15
15
  import { isAccessDeniedError, accessDeniedKeyOf, formatAccessDenied, } from "../utils/access-denied-hint.js";
16
+ import { findReadOnlyLocalOnly, isGrantEnforced, readVaultAccessManifest, } from "../lib/sync/read-only-local-only.js";
16
17
  import chalk from "chalk";
17
18
  import * as fs from "fs";
18
19
  import * as path from "path";
19
- import { share, getStateDir, listJournals, VaultClient, computePersonalVaultPaths, PERSONAL_VAULT_JOURNAL_SLUG, resolvePullScope, syncDoctor, } from "@indigoai-us/hq-cloud";
20
+ import { share, getStateDir, listJournals, createIgnoreFilter, VaultClient, computePersonalVaultPaths, PERSONAL_VAULT_JOURNAL_SLUG, resolvePullScope, syncDoctor, } from "@indigoai-us/hq-cloud";
20
21
  import { syncLeg } from "../utils/sync-operation-lock.js";
21
22
  import { DEFAULT_HQ_ROOT, ensureCognitoToken, buildVaultConfig, loadCachedTokens, } from "../utils/cognito-session.js";
22
23
  import { resolveUploadAuthor } from "../utils/upload-author.js";
@@ -1353,6 +1354,7 @@ export function registerCloudCommands(program) {
1353
1354
  console.log(` Total size: ${formatBytes(grandBytes)}`);
1354
1355
  console.log(` Last sync: ${grandLastSync || "never"}`);
1355
1356
  }
1357
+ printReadOnlyLocalOnly(hqRoot, journals);
1356
1358
  }
1357
1359
  catch (err) {
1358
1360
  console.error(chalk.red("✗ Status failed:"), err instanceof Error ? err.message : String(err));
@@ -2094,6 +2096,51 @@ function readActiveCompanySlug(hqRoot) {
2094
2096
  * personal-vault fanout slot and the legacy `companies/personal` company both
2095
2097
  * carry opaque slugs; everything else is a real company slug shown as-is.
2096
2098
  */
2099
+ /**
2100
+ * `hq sync status`: list local files under read-only vault prefixes that are
2101
+ * not in the sync journal. Push never uploads them (feedback_510661bc,
2102
+ * Shepherd #2813). Uses only the local vault-access manifest and the journals
2103
+ * already loaded, with no network calls. Prints nothing when nothing applies.
2104
+ */
2105
+ export function printReadOnlyLocalOnly(hqRoot, journals, log = (line) => console.log(line)) {
2106
+ const companies = readVaultAccessManifest(hqRoot)?.companies;
2107
+ if (!companies)
2108
+ return 0;
2109
+ let shouldSync;
2110
+ let grand = 0;
2111
+ for (const [slug, company] of Object.entries(companies)) {
2112
+ if (!isGrantEnforced(company))
2113
+ continue;
2114
+ if (!fs.existsSync(path.join(hqRoot, "companies", slug)))
2115
+ continue;
2116
+ if (!shouldSync) {
2117
+ try {
2118
+ shouldSync = createIgnoreFilter(hqRoot);
2119
+ }
2120
+ catch {
2121
+ shouldSync = () => true;
2122
+ }
2123
+ }
2124
+ const journal = journals.find((j) => j.slug === slug)?.journal;
2125
+ const journalKeys = new Set(Object.keys(journal?.files ?? {}));
2126
+ const found = findReadOnlyLocalOnly({ hqRoot, slug, company, journalKeys, shouldSync });
2127
+ if (found.total === 0)
2128
+ continue;
2129
+ grand += found.total;
2130
+ log(chalk.yellow(`\n ! ${slug}: ${found.total} local-only file(s) under read-only access will not upload`));
2131
+ for (const f of found.files) {
2132
+ log(` ${f.path}${f.permission === "none" ? chalk.dim(" (no access)") : ""}`);
2133
+ }
2134
+ if (found.total > found.files.length) {
2135
+ log(chalk.dim(` ... and ${found.total - found.files.length} more`));
2136
+ }
2137
+ }
2138
+ if (grand > 0) {
2139
+ log(chalk.dim(" Copy them somewhere you can write, or ask a company admin for write access. " +
2140
+ "Access data: .hq/vault-access.json (refresh: bash core/scripts/refresh-vault-access.sh)."));
2141
+ }
2142
+ return grand;
2143
+ }
2097
2144
  function scopeLabel(slug) {
2098
2145
  if (slug === PERSONAL_VAULT_JOURNAL_SLUG)
2099
2146
  return "personal vault";
@@ -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
  });