@indigoai-us/hq-cli 5.345.156 → 5.345.159

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/CHANGELOG.md +231 -0
  2. package/assets/scaffold/core/scripts/rebuild-company-knowledge-index.sh +110 -2
  3. package/assets/scaffold/core/scripts/rebuild-projects-index.sh +101 -5
  4. package/assets/scaffold/core/scripts/worktree.sh +4 -0
  5. package/dist/command-catalog.generated.d.ts +531 -6
  6. package/dist/command-catalog.generated.js +674 -27
  7. package/dist/command-registration-plan.d.ts +18 -0
  8. package/dist/command-registration-plan.js +4 -0
  9. package/dist/commands/_patterns.d.ts +17 -0
  10. package/dist/commands/_patterns.js +22 -0
  11. package/dist/commands/agent-kit.js +8 -6
  12. package/dist/commands/bot.js +7 -4
  13. package/dist/commands/channel-suggest.d.ts +56 -0
  14. package/dist/commands/channel-suggest.js +170 -0
  15. package/dist/commands/channels.js +7 -1
  16. package/dist/commands/cloud.d.ts +45 -0
  17. package/dist/commands/cloud.js +150 -10
  18. package/dist/commands/company.js +4 -3
  19. package/dist/commands/core-checkpoint-native.d.ts +18 -0
  20. package/dist/commands/core-checkpoint-native.js +67 -2
  21. package/dist/commands/core-pipeline-driver-native.d.ts +12 -1
  22. package/dist/commands/core-pipeline-driver-native.js +131 -5
  23. package/dist/commands/core.js +12 -0
  24. package/dist/commands/crm.js +83 -6
  25. package/dist/commands/deploy-access.d.ts +33 -0
  26. package/dist/commands/deploy-access.js +322 -0
  27. package/dist/commands/dm-attach.d.ts +106 -0
  28. package/dist/commands/dm-attach.js +184 -0
  29. package/dist/commands/dm.d.ts +37 -0
  30. package/dist/commands/dm.js +172 -10
  31. package/dist/commands/feedback-show.d.ts +40 -0
  32. package/dist/commands/feedback-show.js +261 -0
  33. package/dist/commands/feedback.d.ts +21 -0
  34. package/dist/commands/feedback.js +152 -2
  35. package/dist/commands/files.d.ts +24 -0
  36. package/dist/commands/files.js +52 -1
  37. package/dist/commands/integrations-connect.js +7 -3
  38. package/dist/commands/lanes-accounts.js +16 -1
  39. package/dist/commands/lanes-loop.js +14 -4
  40. package/dist/commands/lanes-message.js +6 -4
  41. package/dist/commands/lanes-resume.js +36 -13
  42. package/dist/commands/lanes-shared.d.ts +5 -0
  43. package/dist/commands/lanes-shared.js +13 -3
  44. package/dist/commands/lanes-watch.js +1 -0
  45. package/dist/commands/lanes.d.ts +20 -1
  46. package/dist/commands/lanes.js +201 -51
  47. package/dist/commands/mesh.js +149 -1
  48. package/dist/commands/monitor.d.ts +1 -0
  49. package/dist/commands/monitor.js +4 -1
  50. package/dist/commands/repo.d.ts +41 -0
  51. package/dist/commands/repo.js +331 -0
  52. package/dist/commands/scan.d.ts +3 -1
  53. package/dist/commands/scan.js +7 -3
  54. package/dist/commands/secrets.d.ts +21 -0
  55. package/dist/commands/secrets.js +114 -14
  56. package/dist/commands/signals.js +17 -5
  57. package/dist/commands/staff/listings.d.ts +41 -0
  58. package/dist/commands/staff/listings.js +160 -0
  59. package/dist/commands/staff/reset-test-account.d.ts +3 -2
  60. package/dist/commands/staff/reset-test-account.js +4 -3
  61. package/dist/hq-cloud-sync-runner/bin/sync-runner.js +2629 -1036
  62. package/dist/hq-cloud-sync-runner/package.json +1 -1
  63. package/dist/lib/agent-kit/run/inbox.js +2 -1
  64. package/dist/lib/agent-kit/wake-secrets.d.ts +7 -0
  65. package/dist/lib/agent-kit/wake-secrets.js +32 -0
  66. package/dist/lib/agent-kit/wake.d.ts +4 -0
  67. package/dist/lib/agent-kit/wake.js +49 -3
  68. package/dist/lib/bot/inflight.d.ts +7 -1
  69. package/dist/lib/bot/inflight.js +10 -2
  70. package/dist/lib/bot/prompt.js +3 -0
  71. package/dist/lib/bot/run.js +72 -20
  72. package/dist/lib/bot/session.d.ts +14 -0
  73. package/dist/lib/bot/session.js +70 -0
  74. package/dist/lib/capacity/collectors/claude-oauth.d.ts +6 -0
  75. package/dist/lib/capacity/collectors/claude-oauth.js +12 -0
  76. package/dist/lib/capacity/lane-login.d.ts +6 -0
  77. package/dist/lib/capacity/lane-login.js +5 -0
  78. package/dist/lib/core-utils/worktree.js +2 -0
  79. package/dist/lib/daemon/exit-cause.d.ts +43 -0
  80. package/dist/lib/daemon/exit-cause.js +88 -0
  81. package/dist/lib/daemon/handoff.d.ts +2 -0
  82. package/dist/lib/daemon/handoff.js +6 -1
  83. package/dist/lib/daemon/hq-child-bootstrap.mjs +20 -3
  84. package/dist/lib/daemon/paths.d.ts +2 -0
  85. package/dist/lib/daemon/paths.js +1 -0
  86. package/dist/lib/daemon/run.js +13 -0
  87. package/dist/lib/daemon/supervisor.d.ts +54 -1
  88. package/dist/lib/daemon/supervisor.js +297 -21
  89. package/dist/lib/daemon/unit-holds.d.ts +22 -0
  90. package/dist/lib/daemon/unit-holds.js +86 -0
  91. package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
  92. package/dist/lib/flag-registry-worker-source.generated.js +1 -1
  93. package/dist/lib/index-render/company-knowledge.js +1 -3
  94. package/dist/lib/index-render/projects.js +1 -3
  95. package/dist/lib/lanes/account-company.d.ts +28 -0
  96. package/dist/lib/lanes/account-company.js +53 -0
  97. package/dist/lib/lanes/accounts.d.ts +22 -3
  98. package/dist/lib/lanes/accounts.js +38 -2
  99. package/dist/lib/lanes/claude-balance.d.ts +6 -1
  100. package/dist/lib/lanes/claude-balance.js +6 -1
  101. package/dist/lib/lanes/claude-credential.d.ts +78 -0
  102. package/dist/lib/lanes/claude-credential.js +163 -0
  103. package/dist/lib/lanes/compaction.d.ts +2 -0
  104. package/dist/lib/lanes/compaction.js +9 -0
  105. package/dist/lib/lanes/config.js +6 -0
  106. package/dist/lib/lanes/envelope-repair-dispatch.js +2 -1
  107. package/dist/lib/lanes/hooks-install.js +2 -1
  108. package/dist/lib/lanes/host-capacity.d.ts +2 -0
  109. package/dist/lib/lanes/host-capacity.js +1 -0
  110. package/dist/lib/lanes/keep-alive.d.ts +1 -1
  111. package/dist/lib/lanes/keep-alive.js +3 -1
  112. package/dist/lib/lanes/links.js +10 -4
  113. package/dist/lib/lanes/loop.js +16 -1
  114. package/dist/lib/lanes/node-binary.d.ts +17 -0
  115. package/dist/lib/lanes/node-binary.js +30 -0
  116. package/dist/lib/lanes/outpost-dispatch.d.ts +2 -1
  117. package/dist/lib/lanes/outpost-dispatch.js +1 -0
  118. package/dist/lib/lanes/reconcile.js +4 -0
  119. package/dist/lib/lanes/remote-control-lane.js +2 -0
  120. package/dist/lib/lanes/spawn.d.ts +3 -0
  121. package/dist/lib/lanes/spawn.js +119 -9
  122. package/dist/lib/lanes/store.d.ts +6 -0
  123. package/dist/lib/lanes/store.js +107 -18
  124. package/dist/lib/lanes/types.d.ts +2 -0
  125. package/dist/lib/lanes/watch.js +28 -5
  126. package/dist/lib/lanes/watcher-main.js +4 -3
  127. package/dist/lib/lanes/watcher.d.ts +4 -0
  128. package/dist/lib/lanes/watcher.js +110 -21
  129. package/dist/lib/mesh/api.d.ts +32 -0
  130. package/dist/lib/mesh/api.js +75 -0
  131. package/dist/lib/secret-scan/detect.js +9 -0
  132. package/dist/lib/secret-scan/repo-config.d.ts +41 -0
  133. package/dist/lib/secret-scan/repo-config.js +237 -0
  134. package/dist/lib/secret-scan/rules.d.ts +7 -1
  135. package/dist/lib/secret-scan/rules.js +3 -1
  136. package/dist/lib/secret-scan/staged.d.ts +5 -0
  137. package/dist/lib/secret-scan/staged.js +19 -2
  138. package/dist/lib/secret-scan/types.d.ts +2 -0
  139. package/dist/lib/sync/server-owned-local-only.d.ts +29 -0
  140. package/dist/lib/sync/server-owned-local-only.js +75 -0
  141. package/dist/utils/package-use-lease.js +27 -9
  142. package/dist/utils/version-gate.d.ts +7 -0
  143. package/dist/utils/version-gate.js +66 -0
  144. package/package.json +8 -8
@@ -4,7 +4,15 @@ import * as path from "node:path";
4
4
  import { inflateRawSync } from "node:zlib";
5
5
  import { isPipelineDriverProcess } from "./core-pipeline-driver-process.js";
6
6
  /* eslint-disable @typescript-eslint/no-explicit-any */
7
- export function scanDriverState(stateDir, maxFails, hqBin, run = spawnSync) {
7
+ // Longest time a routed phase may wait in its lane's queue before the driver
8
+ // gives up on it (PC_QUEUE_MAX_MIN minutes, default 240: twice the default
9
+ // PC_DEADLINE_MIN). Queue time does not count toward the deadline or the stall
10
+ // window, so this ceiling is what ends a run whose lane is never admitted.
11
+ export function queueMaxSeconds(env = process.env) {
12
+ const minutes = Number.parseInt(String(env.PC_QUEUE_MAX_MIN ?? ''), 10);
13
+ return (Number.isFinite(minutes) && minutes > 0 ? minutes : 240) * 60;
14
+ }
15
+ export function scanDriverState(stateDir, maxFails, hqBin, run = spawnSync, queueMax = queueMaxSeconds()) {
8
16
  const S = stateDir, MAX = maxFails, HQ = hqBin;
9
17
  const TERMINAL = ['passed', 'failed', 'blocked'];
10
18
  const EARLY = 'engine_exited_early';
@@ -78,6 +86,59 @@ export function scanDriverState(stateDir, maxFails, hqBin, run = spawnSync) {
78
86
  return { reason: 'loop_stalled', phase: ph, story_id: sid };
79
87
  return state === 'failed' ? { reason: state, phase: ph, story_id: sid } : null;
80
88
  }
89
+ // The deadline bounds work time, not queue time (ported from hq-core-staging
90
+ // #1357). An envelope still waiting in its loop lane's queue (the lane is queued
91
+ // for admission behind the host throttle, or busy on another envelope) is not
92
+ // past its deadline yet. With no lane row or no loop status the deadline applies.
93
+ // An admitted lane counts as holding the envelope only while its queue is not
94
+ // empty: a lane with queue_depth 0 that is not working on this envelope has lost
95
+ // or dropped it, and the deadline applies.
96
+ // Known limit: while the lane is busy on an envelope from another run, this phase
97
+ // waits on that envelope's own watchdog; PC_QUEUE_MAX_MIN still bounds the wait.
98
+ function waitingInQueue(worker, envId) {
99
+ if (typeof envId !== 'string' || !envId)
100
+ return false;
101
+ const row = laneRow(worker);
102
+ if (isObj(row) && get(row, 'state', null) === 'queued')
103
+ return true;
104
+ const loop = get(row, 'loop', null);
105
+ if (!isObj(loop) || !Object.prototype.hasOwnProperty.call(loop, 'active_envelope_id'))
106
+ return false;
107
+ if (get(loop, 'active_envelope_id', null) === envId)
108
+ return false;
109
+ const depth = get(loop, 'queue_depth', 0);
110
+ return isNum(depth) && depth > 0;
111
+ }
112
+ const queuedMark = (sid, ph) => path.join(S, 'driver', `queued-${sid}-${ph}`);
113
+ function clearQueuedMark(sid, ph) {
114
+ try {
115
+ fs.rmSync(queuedMark(sid, ph), { force: true });
116
+ }
117
+ catch (e) { /* best effort */ }
118
+ }
119
+ // The deadline to enforce, or null while the envelope still waits. The envelope
120
+ // deadline is stamped at routing, so the time the envelope was last seen waiting
121
+ // is added back: a phase gets its full budget once the lane picks it up.
122
+ // driver/queued-<sid>-<phase> holds that time (epoch seconds).
123
+ function workDeadline(sid, ph, dl, since, waiting) {
124
+ const mark = queuedMark(sid, ph);
125
+ if (waiting) {
126
+ try {
127
+ fs.writeFileSync(mark, String(Math.trunc(now())) + '\n');
128
+ }
129
+ catch (e) { /* best effort */ }
130
+ return null;
131
+ }
132
+ let seen = NaN;
133
+ try {
134
+ seen = parseInt(fs.readFileSync(mark, 'utf8'), 10);
135
+ }
136
+ catch (e) { /* never queued */ }
137
+ const base = epoch(dl);
138
+ if (!isNum(since) || !Number.isFinite(seen) || seen <= since || base === null)
139
+ return dl;
140
+ return new Date((base + (seen - Math.trunc(since))) * 1000).toISOString().slice(0, 19) + 'Z';
141
+ }
81
142
  function mtime(p) {
82
143
  try {
83
144
  return fs.statSync(p).mtimeMs / 1000;
@@ -146,6 +207,7 @@ export function scanDriverState(stateDir, maxFails, hqBin, run = spawnSync) {
146
207
  const hstat = mine ? get(hd, 'status', null) : null;
147
208
  let ev = null;
148
209
  if (mine && TERMINAL.includes(hstat) && get(hd, 'exit_reason', null) !== EARLY) {
210
+ clearQueuedMark(sid, ph);
149
211
  out.push(['ACCEPT', sid, ph, h]);
150
212
  }
151
213
  else if (mine && get(hd, 'exit_reason', null) === EARLY) {
@@ -153,6 +215,7 @@ export function scanDriverState(stateDir, maxFails, hqBin, run = spawnSync) {
153
215
  if (!isNum(secs))
154
216
  secs = since !== null ? Math.trunc(now() - since) : 0;
155
217
  const why = collapse(str(truthy(hd.notes) ? hd.notes : truthy(hd.summary) ? hd.summary : 'no reason given'));
218
+ clearQueuedMark(sid, ph);
156
219
  out.push(['EARLY', sid, ph, String(Math.trunc(secs)), why]);
157
220
  }
158
221
  else if ((!mine || !TERMINAL.includes(hstat)) && (ev = phaseExit(worker, sid, ph, since)) !== null) {
@@ -171,8 +234,15 @@ export function scanDriverState(stateDir, maxFails, hqBin, run = spawnSync) {
171
234
  else {
172
235
  const env = load(path.join(S, 'envelopes', `${sid}-${ph}.json`));
173
236
  const dl = get(env, 'deadline', null);
174
- if (typeof dl === 'string' && isoNow() > dl)
175
- out.push(['DEADLINE', sid, ph, dl]);
237
+ const waiting = waitingInQueue(worker, get(env, 'id', null));
238
+ if (waiting && isNum(since) && now() - since >= queueMax) {
239
+ out.push(['QUEUED_TOO_LONG', sid, ph, String(Math.trunc(now() - since)), String(get(laneRow(worker), 'lane_id', worker))]);
240
+ }
241
+ else if (typeof dl === 'string') {
242
+ const due = workDeadline(sid, ph, dl, since, waiting);
243
+ if (due !== null && isoNow() > due)
244
+ out.push(['DEADLINE', sid, ph, due]);
245
+ }
176
246
  if (since !== null)
177
247
  out.push(['INFLIGHT', sid, ph, String(Math.max(0, Math.trunc(now() - since)))]);
178
248
  }
@@ -293,7 +363,8 @@ export function checkDriverStall(stateDir, stallWindow, hqBin, run = spawnSync)
293
363
  byWorker.set(worker, r);
294
364
  for (const [sid, worker, s] of inflight) {
295
365
  const r = byWorker.get(worker);
296
- if (r && get(r.loop, 'queue_depth', 0) > 0 && !truthy(get(r.loop, 'active_envelope_id', null))) {
366
+ // a lane queued for admission (host throttle, queue position) is waiting, not stalled
367
+ if (r && get(r, 'state', null) !== 'queued' && get(r.loop, 'queue_depth', 0) > 0 && !truthy(get(r.loop, 'active_envelope_id', null))) {
297
368
  cond = true;
298
369
  stories.push(sid);
299
370
  lanes.push(get(r, 'lane_id', worker));
@@ -480,6 +551,45 @@ export async function sleep(ms, signal) {
480
551
  signal?.addEventListener("abort", finish, { once: true });
481
552
  });
482
553
  }
554
+ /**
555
+ * Move a handoff that failed validation to a numbered sibling
556
+ * (handoffs/<story>-<phase>.invalid.<n>.json) so the worker's evidence is never
557
+ * lost, and write a failed handoff in its place that names the validation
558
+ * error and the kept file. Returns both paths.
559
+ */
560
+ export function quarantineInvalidHandoff(state, storyId, phase, handoffPath, reason) {
561
+ const dir = path.dirname(handoffPath);
562
+ let n = 1;
563
+ let keptPath = path.join(dir, `${storyId}-${phase}.invalid.${n}.json`);
564
+ while (fs.existsSync(keptPath)) {
565
+ n += 1;
566
+ keptPath = path.join(dir, `${storyId}-${phase}.invalid.${n}.json`);
567
+ }
568
+ fs.renameSync(handoffPath, keptPath);
569
+ const story = readJson(path.join(state, "stories", `${storyId}.json`));
570
+ const storyRecord = story && typeof story === "object" ? story : {};
571
+ const current = Number.isInteger(storyRecord.current) ? storyRecord.current : 0;
572
+ const worker = storyRecord.phases?.[current]?.worker ?? "None";
573
+ const loaded = readJson(keptPath);
574
+ const workerId = loaded && typeof loaded === "object" && typeof loaded.worker_id === "string" && loaded.worker_id ? loaded.worker_id : String(worker);
575
+ const record = {
576
+ schema: "hq-phase-handoff/v1",
577
+ story_id: storyId,
578
+ phase,
579
+ worker_id: workerId,
580
+ status: "failed",
581
+ exit_reason: "invalid_handoff",
582
+ summary: `invalid handoff: ${reason}`,
583
+ files_changed: [],
584
+ commits: [],
585
+ back_pressure: { tests: "skip", lint: "skip", typecheck: "skip", build: "skip" },
586
+ context_for_next: `The worker handoff failed validation (${reason}). It is kept unchanged at ${keptPath}.`,
587
+ invalid_handoff_path: keptPath,
588
+ notes: reason,
589
+ };
590
+ fs.writeFileSync(handoffPath, `${JSON.stringify(record)}\n`);
591
+ return { keptPath, handoffPath };
592
+ }
483
593
  function writeEarlyHandoff(state, storyId, phase, seconds, reason) {
484
594
  const story = readJson(path.join(state, "stories", `${storyId}.json`));
485
595
  const storyRecord = story && typeof story === "object" ? story : {};
@@ -697,7 +807,21 @@ export async function runPipelineDriverNative(args, hqRoot = "") {
697
807
  writeDriverLog(logFile, `ACCEPTING ${a} ${b} ${c}`);
698
808
  const result = runPc(["accept", "--state", state, "--story", a, "--handoff", c]);
699
809
  writeDriverLog(logFile, `ACCEPT ${a} ${b} rc=${result.code}: ${result.stdout.trim()}`);
700
- if (!((result.code === 0 && (/^(NEXT|RECHECK)/.test(result.stdout))) || (result.code === 1 && /^PHASE_/.test(result.stdout)) || (result.code === 10 && /^PHASE_BLOCKED/.test(result.stdout)))) {
810
+ const invalid = /invalid handoff: (.*)/.exec(result.stderr);
811
+ if (result.code !== 0 && invalid) {
812
+ // A worker wrote a handoff that fails validation. Keep it on disk
813
+ // beside the handoff, record the phase as failed with the reason,
814
+ // and keep driving: the fail cap bounds the retries.
815
+ const reason = invalid[1].trim();
816
+ const kept = quarantineInvalidHandoff(state, a, b, c, reason);
817
+ writeDriverLog(logFile, `INVALID_HANDOFF ${a} ${b}: ${reason}; worker handoff kept at ${kept.keptPath}`);
818
+ const failed = runPc(["accept", "--state", state, "--story", a, "--handoff", kept.handoffPath]);
819
+ writeDriverLog(logFile, `ACCEPT ${a} ${b} rc=${failed.code}: ${failed.stdout.trim()}`);
820
+ if (!(failed.code === 1 && failed.stdout.startsWith("PHASE_FAILED"))) {
821
+ return finish(23, `accept invalid handoff ${a} ${b}: unexpected (rc=${failed.code}): ${failed.stdout} ${failed.stderr}`);
822
+ }
823
+ }
824
+ else if (!((result.code === 0 && (/^(NEXT|RECHECK)/.test(result.stdout))) || (result.code === 1 && /^PHASE_/.test(result.stdout)) || (result.code === 10 && /^PHASE_BLOCKED/.test(result.stdout)))) {
701
825
  return finish(23, `accept ${a} ${b}: unexpected (rc=${result.code}): ${result.stdout} ${result.stderr}`);
702
826
  }
703
827
  acted = 1;
@@ -717,6 +841,8 @@ export async function runPipelineDriverNative(args, hqRoot = "") {
717
841
  }
718
842
  else if (action === "DEADLINE")
719
843
  return finish(22, `phase ${a}/${b} passed its deadline ${c} with no handoff`);
844
+ else if (action === "QUEUED_TOO_LONG")
845
+ return finish(22, `phase ${a}/${b} waited ${c}s in the queue of lane ${d} without being picked up (limit PC_QUEUE_MAX_MIN=${queueMaxSeconds() / 60} min); check lane admission (hq lanes list), then restart the driver`);
720
846
  else if (action === "HELD")
721
847
  held = `${a} ${b}`;
722
848
  else if (action === "BLOCKED")
@@ -652,6 +652,12 @@ export function registerCoreCommands(program) {
652
652
  .allowExcessArguments()
653
653
  .helpOption(false)
654
654
  .action((target, args = [], _opts, cmd) => {
655
+ // Help must never write. `--help`/`-h` in the target slot prints the
656
+ // command usage instead of being treated as an unknown target.
657
+ if (target === "--help" || target === "-h") {
658
+ process.stdout.write(`Usage: hq core rebuild-index <target> [args...]\n\nRegenerate one index. Targets: ${validTargets.join(", ")}.\nRun \`hq core rebuild-index <target> --help\` for target options. Help performs no writes.\n`);
659
+ return;
660
+ }
655
661
  const entry = REBUILD_INDEX_TARGETS.find((candidate) => candidate.target === target);
656
662
  if (!entry) {
657
663
  throw expectedUserError(`Unknown rebuild-index target "${target}". Valid targets: ${validTargets.join(", ")}.`);
@@ -667,6 +673,12 @@ export function registerCoreCommands(program) {
667
673
  renderIndexTarget(entry.renderer, { root: "", output: (message) => process.stdout.write(message) }, rendererArgs);
668
674
  return;
669
675
  }
676
+ if (wantsHelp) {
677
+ // Every other renderer has no option grammar of its own; print usage
678
+ // and exit before resolving a root or rendering anything.
679
+ process.stdout.write(`Usage: hq core rebuild-index ${entry.target} [--accept]\n\n${entry.summary}. Help performs no writes.\n`);
680
+ return;
681
+ }
670
682
  const requestsCompanyScope = isCompaniesTarget && rendererArgs.some((arg) => arg === "--company" || arg.startsWith("--company="));
671
683
  if (requestsCompanyScope && !coreCompanyIndexScopeEnabled()) {
672
684
  throw expectedUserError("rebuild-index companies --company is disabled; enable cli.rebuild-index-company-scope for a canary.");
@@ -2,6 +2,23 @@ import chalk from "chalk";
2
2
  import { readFileSync } from "node:fs";
3
3
  import { ensureCognitoToken } from "../utils/cognito-session.js";
4
4
  import { vaultApiFetch, getEntityUid } from "../utils/vault-api.js";
5
+ import { readSessionCompanyLock } from "../lib/session-company-lock.js";
6
+ import { sessionIdFromEnv } from "../lib/core-utils/session-id.js";
7
+ import { MULTI_COMPANY_SESSION_LOCK_FLAG } from "../lib/cli-flag-keys.js";
8
+ import { fastFlagEnabledDefaultOn } from "../native-fast-route.js";
9
+ async function resolveCrmCompany(requestedCompany) {
10
+ const lock = sessionIdFromEnv(process.env)
11
+ && await fastFlagEnabledDefaultOn(MULTI_COMPANY_SESSION_LOCK_FLAG)
12
+ ? readSessionCompanyLock(process.env)
13
+ : undefined;
14
+ const company = requestedCompany ?? lock?.companies[0];
15
+ if (lock && company && !lock.companies.includes(company)) {
16
+ throw new Error(`Company '${company}' is outside this session's locked company set.`);
17
+ }
18
+ if (!company)
19
+ throw new Error("--company <slug> is required.");
20
+ return company;
21
+ }
5
22
  /**
6
23
  * `hq crm entity upsert` — agent-facing controlled entity upsert
7
24
  * (hq-native-crm US-002). Wraps `POST /crm/entities`, which fronts the ontology
@@ -19,6 +36,17 @@ const CRM_ENTITY_TYPES = [
19
36
  "invoice",
20
37
  "company",
21
38
  ];
39
+ /** Entity types the read route accepts as a `list --type` filter. */
40
+ const CRM_READ_ENTITY_TYPES = [
41
+ "person",
42
+ "project",
43
+ "company",
44
+ "concept",
45
+ "contact",
46
+ "deal",
47
+ "contract",
48
+ "invoice",
49
+ ];
22
50
  const CRM_SETTINGS_PRECHECK_TIMEOUT_MS = 3_000;
23
51
  const CRM_DISABLED_HINT_PREFIX = "CRM is not enabled for this company. Enable it first: ";
24
52
  function printCrmDisabledHint(companySlug) {
@@ -129,11 +157,64 @@ function buildEntityFromFlags(opts) {
129
157
  export function registerCrmCommand(program) {
130
158
  const crm = program
131
159
  .command("crm")
132
- .description("Native CRM — upsert canonical entities into a company vault")
160
+ .description("Read and upsert canonical CRM entities in a company vault")
133
161
  .option("--company <slug>", "Company slug (resolves to companyUid)");
134
162
  const entity = crm
135
163
  .command("entity")
136
164
  .description("CRM entity operations");
165
+ async function readEntities(path, query) {
166
+ try {
167
+ const companySlug = await resolveCrmCompany(crm.opts().company);
168
+ const token = await ensureCognitoToken();
169
+ const companyUid = await getEntityUid(token, { companySlug });
170
+ const res = await vaultApiFetch({ token, path, query: { ...query, companyUid }, signal: AbortSignal.timeout(25_000) });
171
+ const text = await res.text();
172
+ let result;
173
+ try {
174
+ result = text ? JSON.parse(text) : undefined;
175
+ }
176
+ catch {
177
+ if (res.ok)
178
+ throw new Error(`CRM read returned a non-JSON response (HTTP ${res.status})`);
179
+ result = undefined;
180
+ }
181
+ if (!res.ok) {
182
+ if (res.status === 403 && isRecord(result) && result.code === "CRM_DISABLED")
183
+ printCrmDisabledHint(companySlug);
184
+ throw new Error(isRecord(result) && typeof result.error === "string" ? result.error : `CRM read failed (HTTP ${res.status})`);
185
+ }
186
+ console.log(JSON.stringify(result, null, 2));
187
+ }
188
+ catch (error) {
189
+ console.error(chalk.red(error instanceof Error ? error.message : String(error)));
190
+ process.exit(1);
191
+ }
192
+ }
193
+ entity.command("list")
194
+ .description("List one CRM entity page as JSON; continue with nextCursor until null")
195
+ .option("--type <type>", `Filter by entity type: ${CRM_READ_ENTITY_TYPES.join(" | ")}`)
196
+ .option("--stage <stage>", "Exact pipeline stage")
197
+ .option("--name <text>", "Case-insensitive canonical name filter")
198
+ .option("--limit <number>", "Object keys examined per page (1–100)", "50")
199
+ .option("--cursor <cursor>", "Continue a prior page with the same company and filters")
200
+ .action(async (opts) => {
201
+ if (!/^\d+$/.test(opts.limit) || Number(opts.limit) < 1 || Number(opts.limit) > 100) {
202
+ console.error(chalk.red("Error: --limit must be an integer from 1 to 100."));
203
+ process.exit(1);
204
+ }
205
+ if (opts.type !== undefined && !CRM_READ_ENTITY_TYPES.includes(opts.type)) {
206
+ console.error(chalk.red(`Error: --type must be one of ${CRM_READ_ENTITY_TYPES.join(", ")}.`));
207
+ process.exit(1);
208
+ }
209
+ const query = Object.fromEntries(Object.entries(opts).filter((entry) => typeof entry[1] === "string"));
210
+ await readEntities("/crm/entities", query);
211
+ });
212
+ entity.command("get <id>")
213
+ .description("Read a canonical CRM entity as JSON")
214
+ .option("--key <path>", "Source key returned by list, for direct lookup in large vaults")
215
+ .action(async (id, opts) => {
216
+ await readEntities(`/crm/entities/${encodeURIComponent(id)}`, opts.key ? { key: opts.key } : {});
217
+ });
137
218
  entity
138
219
  .command("upsert")
139
220
  .description("Create or update a canonical CRM entity (POST /crm/entities). " +
@@ -148,11 +229,7 @@ export function registerCrmCommand(program) {
148
229
  .option("--json <file|->", "Path to a JSON file (or '-' for stdin) with a full entity object or array")
149
230
  .action(async (opts) => {
150
231
  try {
151
- const companySlug = crm.opts().company;
152
- if (!companySlug) {
153
- console.error(chalk.red("Error: --company <slug> is required."));
154
- process.exit(1);
155
- }
232
+ const companySlug = await resolveCrmCompany(crm.opts().company);
156
233
  // Resolve the entity payload: --json is the alternative to flags.
157
234
  let entities;
158
235
  if (opts.json) {
@@ -0,0 +1,33 @@
1
+ import { Command } from "commander";
2
+ /**
3
+ * `hq deploy access` reads and changes a deploy app's access policy through
4
+ * hq-deploy's GET/PUT /api/apps/:id/access-policy (the same API the HQ console
5
+ * access modal uses). Feedback bcd64202-071a-4d01-9005-3329a13ca4ae.
6
+ */
7
+ export declare const DEPLOY_ACCESS_MODES: readonly ["company", "selected", "public"];
8
+ export type DeployAccessMode = (typeof DEPLOY_ACCESS_MODES)[number];
9
+ interface Grantee {
10
+ id: string;
11
+ }
12
+ export interface AccessPolicyView {
13
+ appId: string | null;
14
+ mode: string | null;
15
+ companyUid: string | null;
16
+ users: Grantee[];
17
+ groups: Grantee[];
18
+ policyVersion: number | null;
19
+ updatedAt: string | null;
20
+ }
21
+ /**
22
+ * Terminal hooks for the approval prompt. Tests replace these; production reads
23
+ * the real TTY state and asks on stdin.
24
+ */
25
+ export declare const deployAccessPrompt: {
26
+ isInteractive: () => boolean;
27
+ ask: (question: string) => Promise<string>;
28
+ };
29
+ export declare function parseMode(raw: string): DeployAccessMode;
30
+ export declare function toView(body: Record<string, unknown>): AccessPolicyView;
31
+ export declare function registerDeployAccessCommand(deploy: Command): void;
32
+ export {};
33
+ //# sourceMappingURL=deploy-access.d.ts.map