@indigoai-us/hq-cli 5.345.169 → 5.345.171

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 (140) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/assets/scaffold/core/scripts/hq-delegate-send.sh +1 -1
  3. package/dist/command-catalog.generated.d.ts +181 -9
  4. package/dist/command-catalog.generated.js +224 -10
  5. package/dist/commands/access.d.ts +32 -1
  6. package/dist/commands/access.js +119 -5
  7. package/dist/commands/agent-kit.d.ts +1 -1
  8. package/dist/commands/agent-kit.js +3 -1
  9. package/dist/commands/agents.js +2 -1
  10. package/dist/commands/bot.d.ts +3 -0
  11. package/dist/commands/bot.js +141 -14
  12. package/dist/commands/cloud.d.ts +78 -1
  13. package/dist/commands/cloud.js +274 -9
  14. package/dist/commands/core-company-index-scope-flag.d.ts +8 -1
  15. package/dist/commands/core-company-index-scope-flag.js +9 -2
  16. package/dist/commands/core-delegate-w1b.js +5 -2
  17. package/dist/commands/core-dm-bind-native.js +19 -4
  18. package/dist/commands/core-rebuild-index-fast.js +24 -6
  19. package/dist/commands/core.js +18 -6
  20. package/dist/commands/dm.d.ts +17 -3
  21. package/dist/commands/dm.js +121 -26
  22. package/dist/commands/feedback-triage.d.ts +43 -1
  23. package/dist/commands/feedback-triage.js +123 -23
  24. package/dist/commands/feedback.d.ts +40 -1
  25. package/dist/commands/feedback.js +184 -4
  26. package/dist/commands/files.d.ts +42 -0
  27. package/dist/commands/files.js +223 -30
  28. package/dist/commands/integrations-api.d.ts +4 -0
  29. package/dist/commands/integrations-api.js +18 -2
  30. package/dist/commands/integrations-connect.d.ts +10 -3
  31. package/dist/commands/integrations-connect.js +70 -6
  32. package/dist/commands/integrations-core.d.ts +23 -0
  33. package/dist/commands/integrations-core.js +100 -0
  34. package/dist/commands/integrations-manage.js +8 -1
  35. package/dist/commands/integrations.js +49 -5
  36. package/dist/commands/lanes-loop.d.ts +20 -0
  37. package/dist/commands/lanes-loop.js +69 -11
  38. package/dist/commands/lanes.js +9 -1
  39. package/dist/commands/mesh.js +6 -0
  40. package/dist/commands/rescue.d.ts +9 -0
  41. package/dist/commands/rescue.js +31 -0
  42. package/dist/commands/run.js +115 -40
  43. package/dist/commands/scan.d.ts +5 -1
  44. package/dist/commands/scan.js +16 -4
  45. package/dist/commands/secrets.js +50 -9
  46. package/dist/commands/sync-manifest.d.ts +16 -0
  47. package/dist/commands/sync-manifest.js +17 -1
  48. package/dist/hq-cloud-sync-runner/bin/sync-runner.js +2367 -2109
  49. package/dist/hq-cloud-sync-runner/package.json +1 -1
  50. package/dist/lib/agent-kit/inbox-state.d.ts +17 -0
  51. package/dist/lib/agent-kit/inbox-state.js +41 -20
  52. package/dist/lib/agent-kit/kit-config.d.ts +2 -0
  53. package/dist/lib/agent-kit/kit-config.js +6 -0
  54. package/dist/lib/agent-kit/mcp/tools.js +12 -5
  55. package/dist/lib/agent-kit/run/heartbeat.d.ts +6 -3
  56. package/dist/lib/agent-kit/run/heartbeat.js +6 -3
  57. package/dist/lib/agent-kit/run/inbox.d.ts +8 -3
  58. package/dist/lib/agent-kit/run/inbox.js +42 -10
  59. package/dist/lib/agent-kit/run/request-timeout.d.ts +26 -0
  60. package/dist/lib/agent-kit/run/request-timeout.js +51 -0
  61. package/dist/lib/bot/api.d.ts +1 -1
  62. package/dist/lib/bot/config.d.ts +10 -0
  63. package/dist/lib/bot/config.js +17 -1
  64. package/dist/lib/bot/decisions.d.ts +62 -0
  65. package/dist/lib/bot/decisions.js +228 -0
  66. package/dist/lib/bot/fleet-local.d.ts +127 -0
  67. package/dist/lib/bot/fleet-local.js +638 -0
  68. package/dist/lib/bot/index.d.ts +2 -0
  69. package/dist/lib/bot/index.js +2 -0
  70. package/dist/lib/bot/local-config.d.ts +3 -2
  71. package/dist/lib/bot/local-config.js +5 -2
  72. package/dist/lib/bot/promote.d.ts +10 -0
  73. package/dist/lib/bot/promote.js +3 -2
  74. package/dist/lib/bot/run.d.ts +7 -0
  75. package/dist/lib/bot/run.js +104 -1
  76. package/dist/lib/bot/runtime/index.js +8 -4
  77. package/dist/lib/bot/runtime/scratch-dir.d.ts +24 -0
  78. package/dist/lib/bot/runtime/scratch-dir.js +41 -0
  79. package/dist/lib/caller-principal.d.ts +4 -0
  80. package/dist/lib/caller-principal.js +40 -0
  81. package/dist/lib/capacity/collectors/grok.js +8 -2
  82. package/dist/lib/cli-flag-keys.d.ts +2 -0
  83. package/dist/lib/cli-flag-keys.js +2 -0
  84. package/dist/lib/daemon/catalog.d.ts +4 -0
  85. package/dist/lib/daemon/catalog.js +63 -29
  86. package/dist/lib/daemon/config.d.ts +7 -0
  87. package/dist/lib/daemon/config.js +32 -0
  88. package/dist/lib/daemon/run.d.ts +3 -2
  89. package/dist/lib/daemon/run.js +23 -3
  90. package/dist/lib/daemon/supervisor.d.ts +1 -0
  91. package/dist/lib/daemon/supervisor.js +4 -0
  92. package/dist/lib/daemon/sync-core-canary.d.ts +12 -0
  93. package/dist/lib/daemon/sync-core-canary.js +61 -0
  94. package/dist/lib/doctor/checks/release-links.d.ts +62 -1
  95. package/dist/lib/doctor/checks/release-links.js +188 -2
  96. package/dist/lib/doctor/checks/sync-health.d.ts +32 -0
  97. package/dist/lib/doctor/checks/sync-health.js +54 -1
  98. package/dist/lib/doctor/fix/apply.d.ts +26 -4
  99. package/dist/lib/doctor/fix/apply.js +149 -11
  100. package/dist/lib/doctor/fix/remediation.d.ts +8 -1
  101. package/dist/lib/doctor/fix/remediation.js +22 -0
  102. package/dist/lib/doctor/types.d.ts +1 -1
  103. package/dist/lib/doctor/types.js +1 -0
  104. package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
  105. package/dist/lib/flag-registry-worker-source.generated.js +1 -1
  106. package/dist/lib/index-render/index.d.ts +9 -0
  107. package/dist/lib/index-render/index.js +65 -5
  108. package/dist/lib/index-render/session-scope.d.ts +69 -0
  109. package/dist/lib/index-render/session-scope.js +279 -0
  110. package/dist/lib/lanes/admission.js +7 -20
  111. package/dist/lib/lanes/reconcile.js +13 -15
  112. package/dist/lib/lanes/stale-lock.d.ts +50 -0
  113. package/dist/lib/lanes/stale-lock.js +105 -0
  114. package/dist/lib/lanes/store.js +11 -76
  115. package/dist/lib/mesh/live/daemon/work-subscribe.js +31 -0
  116. package/dist/lib/notify-recipient.d.ts +10 -4
  117. package/dist/lib/notify-recipient.js +16 -4
  118. package/dist/lib/secret-input-link.d.ts +42 -0
  119. package/dist/lib/secret-input-link.js +93 -0
  120. package/dist/lib/secret-scan/range.d.ts +57 -0
  121. package/dist/lib/secret-scan/range.js +266 -0
  122. package/dist/lib/secret-scan/repo-config.d.ts +18 -3
  123. package/dist/lib/secret-scan/repo-config.js +135 -2
  124. package/dist/lib/work-context/organize.d.ts +7 -0
  125. package/dist/lib/work-context/organize.js +21 -1
  126. package/dist/run/script-bound-schema.d.ts +27 -0
  127. package/dist/run/script-bound-schema.js +97 -0
  128. package/dist/secrets-env-action.d.ts +2 -0
  129. package/dist/secrets-env-action.js +7 -2
  130. package/dist/sentry-before-send.js +1 -0
  131. package/dist/utils/cognito-session.d.ts +3 -30
  132. package/dist/utils/cognito-session.js +2 -100
  133. package/dist/utils/machine-principal-state.d.ts +38 -0
  134. package/dist/utils/machine-principal-state.js +110 -0
  135. package/dist/utils/safe-link.js +9 -0
  136. package/dist/utils/script-bound-exec.d.ts +158 -0
  137. package/dist/utils/script-bound-exec.js +568 -0
  138. package/dist/utils/sync-watchdog.d.ts +92 -0
  139. package/dist/utils/sync-watchdog.js +250 -0
  140. package/package.json +8 -8
@@ -5,7 +5,9 @@ import { resolveDestinationCompanyUid, vaultApiFetch } from "../utils/vault-api.
5
5
  import { isMachineIdentity } from "@indigoai-us/hq-cloud";
6
6
  import { peekIdToken } from "../utils/id-token.js";
7
7
  import { redactAgentInboxItem as redactInboxCredentials } from "../lib/agent-kit/inbox-redact.js";
8
- import { EMAIL_PATTERN, RECIPIENT_UID_PATTERN, defaultAudienceFor, recipientKindOf, } from "../lib/notify-recipient.js";
8
+ import { classifyAgentInboxBody } from "../lib/agent-kit/inbox-state.js";
9
+ import { EMAIL_PATTERN, RECIPIENT_UID_PATTERN, PERSON_AGENT_AUDIENCE_MESSAGE, PERSON_CANNOT_SEND_AGENT_CODE, callerMayUseAudience, defaultAudienceFor, recipientKindOf, } from "../lib/notify-recipient.js";
10
+ import { callerIsMachinePrincipal } from "../lib/caller-principal.js";
9
11
  export { defaultAudienceFor, recipientKindOf };
10
12
  import { dmForwardCliEnabled } from "./dm-forward-flag.js";
11
13
  import { attachmentScopeForChannel, formatUploadedAttachments, personalScopeForCaller, uploadDmAttachments, validateAttachmentPaths, } from "./dm-attach.js";
@@ -120,29 +122,45 @@ export function matchChannelsByName(channels, name) {
120
122
  * eba06f59). Returns null when nothing or more than one contact matches. Pure.
121
123
  */
122
124
  export function matchContactByName(contacts, name) {
123
- const target = name.trim().toLowerCase();
124
- const targetSlug = channelSlug(name);
125
+ const unique = matchContactsByName(contacts, name);
126
+ return unique.length === 1 ? unique[0] : null;
127
+ }
128
+ /**
129
+ * Every distinct contact (person or bot) whose display name matches `name`,
130
+ * using the same rule as matchContactByName. A leading '@' is the explicit
131
+ * person/bot selector and is ignored for matching. Pure.
132
+ */
133
+ export function matchContactsByName(contacts, name) {
134
+ const bare = name.trim().replace(/^@/, "");
135
+ const target = bare.toLowerCase();
136
+ const targetSlug = channelSlug(bare);
125
137
  if (!target)
126
- return null;
138
+ return [];
127
139
  const hits = contacts.filter((c) => {
128
140
  if (!c.personUid)
129
141
  return false;
130
142
  const dn = c.displayName?.trim().toLowerCase();
131
143
  return Boolean(dn && (dn === target || (targetSlug && channelSlug(dn) === targetSlug)));
132
144
  });
133
- const unique = [...new Map(hits.map((c) => [c.personUid, c])).values()];
134
- return unique.length === 1 ? unique[0] : null;
145
+ return [...new Map(hits.map((c) => [c.personUid, c])).values()];
146
+ }
147
+ /** One line per ambiguous contact match, for a refusal message. Pure. */
148
+ export function formatContactMatches(matches) {
149
+ return matches
150
+ .map((c) => ` ${c.displayName || "(no name)"}${c.email ? ` <${c.email}>` : ""} ${c.personUid}`)
151
+ .join("\n");
135
152
  }
153
+ /** Contacts, or null when the lookup failed (so callers can say so). */
136
154
  async function fetchContacts(token) {
137
155
  try {
138
156
  const res = await vaultApiFetch({ token, path: "/v1/notify/contacts" });
139
157
  if (!res.ok)
140
- return [];
158
+ return null;
141
159
  const data = (await res.json());
142
- return Array.isArray(data.contacts) ? data.contacts : [];
160
+ return Array.isArray(data.contacts) ? data.contacts : null;
143
161
  }
144
162
  catch {
145
- return [];
163
+ return null;
146
164
  }
147
165
  }
148
166
  /**
@@ -427,6 +445,8 @@ export async function resolveDmPeerUid(token, recipient) {
427
445
  function friendlyDmError(status, code, fallback) {
428
446
  if (status === 401)
429
447
  return "Not authenticated — run `hq login` and try again.";
448
+ if (code === PERSON_CANNOT_SEND_AGENT_CODE)
449
+ return fallback?.trim() || PERSON_AGENT_AUDIENCE_MESSAGE;
430
450
  if (status === 404 || code === "RECIPIENT_NOT_FOUND") {
431
451
  return "Recipient not found or not reachable — you can only DM someone you share an active company with.";
432
452
  }
@@ -818,29 +838,47 @@ function explicitAudience(opts) {
818
838
  }
819
839
  return explicit;
820
840
  }
841
+ /** An explicit agent/both audience from a person login. */
842
+ export class PersonAudienceError extends Error {
843
+ constructor() {
844
+ super(PERSON_AGENT_AUDIENCE_MESSAGE);
845
+ this.name = "PersonAudienceError";
846
+ }
847
+ }
821
848
  /**
822
849
  * Resolve the audience for a send. The default comes from the recipient kind
823
- * (`defaultAudienceFor`); `--for-human` maps to `human`; an explicit
824
- * `--audience` wins over `--for-human` (with a warning) and is validated.
850
+ * and the caller (`defaultAudienceFor`: always `human` for a person login);
851
+ * `--for-human` maps to `human`; an explicit `--audience` wins over
852
+ * `--for-human` (with a warning) and is validated. A person login passing
853
+ * `--audience agent|both` throws PersonAudienceError.
825
854
  */
826
- export function resolveSendAudience(opts, recipientKind, warn = (m) => console.error(chalk.yellow(m))) {
855
+ export function resolveSendAudience(opts, recipientKind, warn = (m) => console.error(chalk.yellow(m)), machineCaller = callerIsMachinePrincipal()) {
827
856
  const explicit = explicitAudience(opts);
857
+ if (!callerMayUseAudience(explicit, machineCaller))
858
+ throw new PersonAudienceError();
828
859
  if (explicit !== undefined) {
829
860
  if (opts.forHuman) {
830
861
  warn("Both --audience " + explicit + " and --for-human were passed; using --audience " + explicit + ".");
831
862
  }
832
863
  return explicit;
833
864
  }
834
- return opts.forHuman ? "human" : defaultAudienceFor(recipientKind);
865
+ return opts.forHuman ? "human" : defaultAudienceFor(recipientKind, machineCaller);
835
866
  }
836
867
  async function runDmSend(recipient, message, rawOpts) {
837
868
  // Validate an explicit --audience before any network call; the default is
838
869
  // applied once the recipient kind is known.
839
870
  explicitAudience(rawOpts);
871
+ // People never send agent-tagged messages: refuse before any network call.
872
+ const machineCaller = callerIsMachinePrincipal();
873
+ if (!callerMayUseAudience(rawOpts.audience, machineCaller)) {
874
+ console.error(chalk.red(PERSON_AGENT_AUDIENCE_MESSAGE));
875
+ process.exit(1);
876
+ return;
877
+ }
840
878
  const opts = { ...rawOpts };
841
879
  const withAudience = (kind) => ({
842
880
  ...rawOpts,
843
- audience: resolveSendAudience(rawOpts, kind),
881
+ audience: resolveSendAudience(rawOpts, kind, undefined, machineCaller),
844
882
  });
845
883
  // --channel <name> is an explicit channel target; the positional carries the
846
884
  // message (recipient slot), e.g. `hq dm --channel vyg-dev "hello"`.
@@ -874,16 +912,46 @@ async function runDmSend(recipient, message, rawOpts) {
874
912
  // A bare name or #hash addresses a named DM channel.
875
913
  const channelName = parseChannelName(recipient);
876
914
  if (channelName !== null) {
877
- // A bare word (no '#') may name a person as well as a channel. Prefer the
878
- // exact person and say so; '#name' or --channel always means the channel.
879
- const person = recipient.trim().startsWith("#")
880
- ? null
881
- : matchContactByName(await fetchContacts(await ensureCognitoToken()), channelName);
915
+ // A bare word may name a person or bot as well as a channel. Prefer the
916
+ // exact person/bot and say so; '#name' or --channel always means the
917
+ // channel, '@name' always means a person/bot. Several same-named
918
+ // contacts are ambiguous: refuse with the list rather than fall through
919
+ // to a channel of that name (feedback eba06f59).
920
+ const typed = recipient.trim();
921
+ const explicitPerson = typed.startsWith("@");
922
+ const bare = channelName.replace(/^@/, "");
923
+ let person = null;
924
+ if (!typed.startsWith("#")) {
925
+ const contacts = await fetchContacts(await ensureCognitoToken());
926
+ if (contacts === null) {
927
+ // Without contacts we cannot tell a person/bot from a channel, so a
928
+ // bare name must not guess the channel either.
929
+ console.error(chalk.red(`Could not load your contacts to resolve '${typed}'; nothing was sent. ` +
930
+ `Use their email or prs_/agt_ uid for a person or bot, or '#${bare}' or --channel ${bare} to post to the channel.`));
931
+ process.exit(1);
932
+ return;
933
+ }
934
+ else {
935
+ const matches = matchContactsByName(contacts, bare);
936
+ if (matches.length > 1) {
937
+ console.error(chalk.red(`'${bare}' matches ${matches.length} people or bots; nothing was sent. Use an email or uid:\n${formatContactMatches(matches)}\n` +
938
+ `Use '#${bare}' or --channel ${bare} for a channel.`));
939
+ process.exit(1);
940
+ return;
941
+ }
942
+ person = matches[0] ?? null;
943
+ }
944
+ if (!person && explicitPerson) {
945
+ console.error(chalk.red(`No person or bot named '${bare}' in your contacts; nothing was sent. Use '#${bare}' for a channel.`));
946
+ process.exit(1);
947
+ return;
948
+ }
949
+ }
882
950
  if (!person?.personUid) {
883
951
  await runChannelSend(channelName, message, withAudience("channel"));
884
952
  return;
885
953
  }
886
- console.log(chalk.dim(`Resolved '${channelName}' to person ${person.displayName || person.email || person.personUid} (${person.personUid}). Use '#${channelName}' or --channel ${channelName} to post to a channel instead.`));
954
+ console.log(chalk.dim(`Resolved '${bare}' to person ${person.displayName || person.email || person.personUid} (${person.personUid}). Use '#${bare}' or --channel ${bare} to post to a channel instead.`));
887
955
  recipient = person.personUid;
888
956
  }
889
957
  Object.assign(opts, withAudience(recipientKindOf(recipient)));
@@ -1492,23 +1560,46 @@ async function runAgentInbox(token, agentUid, opts) {
1492
1560
  console.error(chalk.red(`GET ${route} → ${res.status}`));
1493
1561
  process.exit(1);
1494
1562
  }
1495
- const data = (await res.json().catch(() => ({})));
1496
- let items = (Array.isArray(data.messages) ? data.messages : []).map(redactAgentInboxItem);
1563
+ // Same guard as `hq agent inbox` (hq-cli #2155): a 200 alone does not
1564
+ // certify the read. A decode failure stays undefined for the classifier;
1565
+ // parser errors and response bytes are never echoed.
1566
+ const classified = classifyAgentInboxBody(await res.json().catch(() => undefined));
1567
+ if (classified.status === "invalid") {
1568
+ console.error(chalk.red(`GET ${route} returned an invalid response; the inbox could not be read, and nothing was acknowledged.`));
1569
+ process.exit(1);
1570
+ return;
1571
+ }
1572
+ const partial = classified.status === "partial";
1573
+ if (partial) {
1574
+ console.error(chalk.yellow("The server inbox contained invalid messages; showing the valid ones only, which may be incomplete."));
1575
+ }
1576
+ let items = classified.items.map(redactAgentInboxItem);
1497
1577
  if (opts.limit && Number(opts.limit) > 0)
1498
1578
  items = items.slice(0, Number(opts.limit));
1499
1579
  if (opts.json) {
1500
1580
  console.log(JSON.stringify(items, null, 2));
1501
1581
  }
1502
1582
  else if (items.length === 0) {
1503
- console.log(chalk.dim("No pending inbox items."));
1583
+ console.log(chalk.dim(partial
1584
+ ? "No pending inbox items can be confirmed; the server inbox could not be read completely."
1585
+ : "No pending inbox items."));
1504
1586
  }
1505
1587
  else {
1506
1588
  console.log(chalk.green(`${items.length} pending inbox item${items.length === 1 ? "" : "s"}:`));
1507
1589
  for (const item of items)
1508
1590
  console.log(`\n${formatAgentInboxItem(item)}`);
1509
1591
  }
1510
- if (opts.markRead) {
1592
+ if (opts.markRead && partial) {
1593
+ // An incomplete read cannot show what --mark-read would hide, so ack nothing.
1594
+ const n = items.length;
1595
+ const ids = items.map((item) => item.id).join(", ");
1596
+ console.error(chalk.yellow(`--mark-read skipped: the inbox read was incomplete, so ${n} ${n === 1 ? "item was" : "items were"} not acked` +
1597
+ `${opts.limit ? " (count reflects --limit)" : ""}` +
1598
+ `${n > 0 ? `: ${ids}. Ack individually with \`hq agent inbox done <id>\`.` : "."}`));
1599
+ }
1600
+ if (opts.markRead && !partial) {
1511
1601
  let acked = 0;
1602
+ let failed = 0;
1512
1603
  for (const item of items) {
1513
1604
  const id = typeof item.messageId === "string" ? item.messageId : typeof item.id === "string" ? item.id : null;
1514
1605
  if (!id)
@@ -1516,9 +1607,13 @@ async function runAgentInbox(token, agentUid, opts) {
1516
1607
  const ack = await vaultApiFetch({ token, path: `${route}/${encodeURIComponent(id)}/ack`, method: "POST" });
1517
1608
  if (ack.ok)
1518
1609
  acked += 1;
1610
+ else
1611
+ failed += 1;
1519
1612
  }
1520
1613
  if (!opts.json && acked > 0)
1521
1614
  console.log(chalk.dim(`\nAcked ${acked}.`));
1615
+ if (failed > 0)
1616
+ console.error(chalk.yellow(`${failed} item${failed === 1 ? "" : "s"} could not be acked.`));
1522
1617
  }
1523
1618
  }
1524
1619
  async function runDmInbox(opts) {
@@ -1729,7 +1824,7 @@ async function runDmChannel(target, opts) {
1729
1824
  export function registerDmCommand(program) {
1730
1825
  const dm = program
1731
1826
  .command("dm")
1732
- .description("Send and read direct messages, and manage connection requests. Messages to a person send to them; messages to an agent, a channel or a group are tagged for agents and folded away from people by default. Pass --for-human or --audience human|agent|both to override.");
1827
+ .description("Send and read direct messages, and manage connection requests. Messages sent from a person login are always tagged for people. When an agent or bot sends, messages to an agent, a channel or a group are tagged for agents and folded away from people by default. Pass --for-human or --audience human to tag a message for people; --audience agent|both is for agents and bots only.");
1733
1828
  dm
1734
1829
  .command("send [recipient] [message]", { isDefault: true, hidden: true })
1735
1830
  .description('Send a direct message. RECIPIENT can be a person (email, personUid, or agentUid), a GROUP DM (comma-separated: "a@x.com,b@y.com"), or one of your DM CHANNELS by name — bare (hq dm vyg-dev "hi"), hash form (hq dm "#vyg-dev" "hi"), or via --channel (hq dm --channel vyg-dev "hi"). A person receives a DM as an HQ Sync notification; an agent receives it in its durable box inbox. If you aren\'t connected yet, it sends a connection request that holds your message. See your channels with `hq channels`.\n\nCHANNEL MENTIONS: HQ mentions are structured — the server never reads `@name` out of your text, so a channel message has to carry the participants alongside the body. When you write `@name` in a channel message, this command looks the name up in the channel roster (case-insensitive on display name and slug/handle; quote multi-word names as @"Jacob Posel") and sends it as a real mention, which notifies people and wakes agents. If a name is unknown or matches more than one member, the send FAILS with the candidates instead of posting something that mentions nobody. A bare "@" or an email address in your prose is never treated as a mention. Pass --no-mentions to post the text literally.\n\nCHANNEL THREADS: `hq dm <channel> --thread <eventId> "…"` replies under an existing root (the request carries rootEventId). `--thread last` replies to your most recent top-level message in that channel. Copy a root eventId from `hq dm channel <name>`, which prints each root\'s id and indents replies underneath.')
@@ -1742,7 +1837,7 @@ export function registerDmCommand(program) {
1742
1837
  .option("--details-file <path>", "Read the details from a file (1:1 DMs; channel sends also supported)")
1743
1838
  .option("--at <iso>", "Schedule delivery at an ISO8601 time (store-and-forward; delivered within ~60s of the time; 1:1 DMs only)")
1744
1839
  .option("--in <duration>", "Schedule delivery after a relative delay: 30s, 10m, 2h, 1d (1:1 DMs only)")
1745
- .option("--audience <human|agent|both>", "Tag who this message is for: human (a person must read it), agent (progress/status, hidden from humans by default), or both. Default: human for a person, agent for an agent, a channel or a group. Wins over --for-human.")
1840
+ .option("--audience <human|agent|both>", "Tag who this message is for: human (a person must read it), agent (progress/status, hidden from humans by default), or both. agent and both are for agents and bots only; a person login always sends human. Default for agents and bots: human for a person, agent for an agent, a channel or a group. Wins over --for-human.")
1746
1841
  .option("--for-human", "Tag the message for people (alias for --audience human).")
1747
1842
  .option("--attach <path>", "Upload a local file and attach it to the message (repeatable, up to 5 files, 25 MB each). 1:1 DMs and named channels; the file lands in the conversation's vault scope and the recipient can open it. Not available for group DMs or scheduled sends.", (value, previous = []) => [...previous, value])
1748
1843
  .action(async (recipient, message, opts) => {
@@ -9,6 +9,18 @@ import { Command } from "commander";
9
9
  */
10
10
  export declare const TRIAGE_KINDS: readonly ["bug", "feature", "question"];
11
11
  export type TriageKind = (typeof TRIAGE_KINDS)[number];
12
+ /**
13
+ * Internal feedback row statuses `hq feedback list --status` accepts.
14
+ * `stalled` is open work that started and then stopped: a row is in_progress
15
+ * only while it has a linked open PR or a live lane, and moves to stalled after
16
+ * 48h with no activity. It is listed apart from `new` (not started).
17
+ */
18
+ /**
19
+ * Statuses shown in `list --status` help. Help text only: the CLI does not
20
+ * validate against this list, because hq-pro owns the set and returns a 400
21
+ * for an unknown value.
22
+ */
23
+ export declare const FEEDBACK_LIST_STATUSES: readonly ["new", "triaging", "accepted", "wont_fix", "in_progress", "resolved", "waiting_on_customer", "reopened", "stalled", "needs_info"];
12
24
  export declare const MAX_TRIAGE_SUMMARY = 2000;
13
25
  export declare const DEFAULT_LIST_LIMIT = 50;
14
26
  export interface FeedbackListRow {
@@ -24,6 +36,14 @@ export type FeedbackListSelector = {
24
36
  } | {
25
37
  kind: "assignee";
26
38
  assignee: string;
39
+ }
40
+ /** Every visible row, untriaged or not. Used for `--status` with no other selector. */
41
+ | {
42
+ kind: "any";
43
+ }
44
+ /** Every row not in a terminal status, untriaged or not (`--open`). */
45
+ | {
46
+ kind: "open";
27
47
  };
28
48
  /** Email claim of the caller's staff token, used by `list --mine`. */
29
49
  export declare function emailFromToken(token: string): string | null;
@@ -36,14 +56,36 @@ export declare function formatFeedbackAge(iso: string | undefined, now?: number)
36
56
  * back as `?cursor=`.
37
57
  */
38
58
  export declare function cursorFrom(data: unknown): string | null;
59
+ /**
60
+ * Lifecycle states hq-pro treats as closed (`TERMINAL_STATUSES` in
61
+ * feedback-status.ts). `--open` lists every row outside this set; a row with
62
+ * no status counts as open.
63
+ */
64
+ export declare const TERMINAL_FEEDBACK_STATUSES: readonly string[];
65
+ /** Why a list stopped before the server ran out of rows, if it did. */
66
+ export type FeedbackListTruncation = "limit" | "page-cap" | "server";
67
+ export interface FeedbackListResult {
68
+ items: FeedbackListRow[];
69
+ /** Set when matching rows may exist beyond `items`. */
70
+ truncated?: FeedbackListTruncation;
71
+ }
39
72
  /** Page through `GET /v1/feedback` until `limit` rows (or every row with `limit: null`). */
40
73
  export declare function listFeedback(opts: {
41
74
  token: string;
42
75
  selector: FeedbackListSelector;
43
76
  status?: string;
44
77
  limit: number | null;
45
- }): Promise<FeedbackListRow[]>;
78
+ }): Promise<FeedbackListResult>;
79
+ /** One-line stderr notice for a list that stopped before the end. */
80
+ export declare function feedbackTruncationNotice(reason: FeedbackListTruncation, shown: number): string;
46
81
  export declare function formatFeedbackTable(rows: FeedbackListRow[], now?: number): string;
82
+ /** Status cell for the list table. A stalled row is flagged so it does not read as not started. */
83
+ export declare function formatFeedbackStatus(status: unknown): string;
84
+ /**
85
+ * Normalize `list --status`; accepts `in-progress` as an alias of `in_progress`.
86
+ * Any non-empty value goes to the server, which validates it.
87
+ */
88
+ export declare function parseListStatus(raw: string): string;
47
89
  export declare function parseListLimit(raw: string): number;
48
90
  export declare function buildTriagePatch(opts: {
49
91
  id: string;
@@ -11,6 +11,29 @@ import { assertFeedbackId, normalizeFeedbackAssignee, readFeedbackError, resolve
11
11
  * send them.
12
12
  */
13
13
  export const TRIAGE_KINDS = ["bug", "feature", "question"];
14
+ /**
15
+ * Internal feedback row statuses `hq feedback list --status` accepts.
16
+ * `stalled` is open work that started and then stopped: a row is in_progress
17
+ * only while it has a linked open PR or a live lane, and moves to stalled after
18
+ * 48h with no activity. It is listed apart from `new` (not started).
19
+ */
20
+ /**
21
+ * Statuses shown in `list --status` help. Help text only: the CLI does not
22
+ * validate against this list, because hq-pro owns the set and returns a 400
23
+ * for an unknown value.
24
+ */
25
+ export const FEEDBACK_LIST_STATUSES = [
26
+ "new",
27
+ "triaging",
28
+ "accepted",
29
+ "wont_fix",
30
+ "in_progress",
31
+ "resolved",
32
+ "waiting_on_customer",
33
+ "reopened",
34
+ "stalled",
35
+ "needs_info",
36
+ ];
14
37
  export const MAX_TRIAGE_SUMMARY = 2000;
15
38
  export const DEFAULT_LIST_LIMIT = 50;
16
39
  /** Safety stop for --all so a server that never ends its cursor can't loop forever. */
@@ -64,49 +87,114 @@ export function cursorFrom(data) {
64
87
  }
65
88
  return null;
66
89
  }
90
+ function isTruncated(data) {
91
+ return (!!data &&
92
+ typeof data === "object" &&
93
+ !Array.isArray(data) &&
94
+ data.truncated === true);
95
+ }
67
96
  /** Server page cap (FEEDBACK_LIST_PAGE_MAX). The plain list path rejects a cursor without a limit. */
68
97
  const LIST_PAGE_SIZE = 200;
98
+ /**
99
+ * Lifecycle states hq-pro treats as closed (`TERMINAL_STATUSES` in
100
+ * feedback-status.ts). `--open` lists every row outside this set; a row with
101
+ * no status counts as open.
102
+ */
103
+ export const TERMINAL_FEEDBACK_STATUSES = ["resolved", "wont_fix", "accepted"];
104
+ function isOpenRow(row) {
105
+ const status = row.status;
106
+ return typeof status !== "string" || !TERMINAL_FEEDBACK_STATUSES.includes(status);
107
+ }
69
108
  /** Page through `GET /v1/feedback` until `limit` rows (or every row with `limit: null`). */
70
109
  export async function listFeedback(opts) {
71
110
  const base = opts.selector.kind === "untriaged"
72
111
  ? { triage: "untriaged" }
73
- : { assignee: opts.selector.assignee };
112
+ : opts.selector.kind === "assignee"
113
+ ? { assignee: opts.selector.assignee }
114
+ : {};
74
115
  if (opts.status)
75
116
  base.status = opts.status;
117
+ const keep = opts.selector.kind === "open" ? isOpenRow : () => true;
76
118
  const rows = [];
77
119
  let cursor = null;
120
+ let serverTruncated = false;
78
121
  for (let page = 0; page < MAX_LIST_PAGES; page++) {
79
- const pageSize = opts.limit === null ? LIST_PAGE_SIZE : Math.min(LIST_PAGE_SIZE, opts.limit - rows.length);
122
+ // The untriaged queue applies `limit` after filtering, so ask for exactly
123
+ // what is still needed. The plain list path applies `limit` to the
124
+ // DynamoDB read BEFORE its filters, so a small page returns few or no
125
+ // matches per request and burns the page budget: always ask for a full
126
+ // page there and trim to the limit here.
127
+ const pageSize = opts.limit === null || opts.selector.kind !== "untriaged"
128
+ ? LIST_PAGE_SIZE
129
+ : Math.min(LIST_PAGE_SIZE, opts.limit - rows.length);
80
130
  const query = { ...base, limit: String(pageSize) };
81
131
  if (cursor)
82
132
  query.cursor = cursor;
83
133
  const res = await vaultApiFetch({ token: opts.token, path: "/v1/feedback", query });
84
134
  if (!res.ok) {
85
- throw new Error(`Failed to list feedback: ${await readFeedbackError(res)}`);
135
+ throw new Error("Failed to list feedback: " + (await readFeedbackError(res)));
86
136
  }
87
137
  const data = (await res.json());
88
- rows.push(...rowsFrom(data));
89
- if (opts.limit !== null && rows.length >= opts.limit)
90
- return rows.slice(0, opts.limit);
138
+ rows.push(...rowsFrom(data).filter(keep));
139
+ if (isTruncated(data))
140
+ serverTruncated = true;
91
141
  cursor = cursorFrom(data);
92
- if (!cursor)
93
- return rows;
142
+ if (opts.limit !== null && rows.length >= opts.limit) {
143
+ // More rows exist when this page overflowed the limit or the server
144
+ // has another page.
145
+ const more = rows.length > opts.limit || cursor !== null || serverTruncated;
146
+ return {
147
+ items: rows.slice(0, opts.limit),
148
+ ...(more ? { truncated: serverTruncated ? "server" : "limit" } : {}),
149
+ };
150
+ }
151
+ if (!cursor) {
152
+ return { items: rows, ...(serverTruncated ? { truncated: "server" } : {}) };
153
+ }
94
154
  }
95
- return rows;
155
+ return { items: rows, truncated: "page-cap" };
156
+ }
157
+ /** One-line stderr notice for a list that stopped before the end. */
158
+ export function feedbackTruncationNotice(reason, shown) {
159
+ if (reason === "limit") {
160
+ return "Showing the first " + String(shown) + " items; more may match. Use --all or a larger --limit.";
161
+ }
162
+ if (reason === "page-cap") {
163
+ return ("Stopped after " + String(MAX_LIST_PAGES) + " pages (" + String(shown) +
164
+ " items); more may match. Narrow the query, for example with --status or --assignee instead of --open.");
165
+ }
166
+ return "The server cut this read short; some matching items may be missing.";
96
167
  }
97
168
  export function formatFeedbackTable(rows, now = Date.now()) {
98
- const header = ["ID", "AGE", "PLAN", "TITLE"];
169
+ const header = ["ID", "AGE", "STATUS", "PLAN", "TITLE"];
99
170
  const body = rows.map((row) => [
100
171
  row.id,
101
172
  formatFeedbackAge(row.submittedAt ?? row.createdAt, now),
173
+ formatFeedbackStatus(row.status),
102
174
  typeof row.plan === "string" && row.plan.length > 0 ? row.plan : "-",
103
175
  typeof row.title === "string" ? row.title : "",
104
176
  ]);
105
- const widths = [0, 1, 2].map((col) => Math.max(header[col].length, ...body.map((cells) => cells[col].length)));
177
+ const widths = [0, 1, 2, 3].map((col) => Math.max(header[col].length, ...body.map((cells) => cells[col].length)));
106
178
  return [header, ...body]
107
- .map((cells) => cells.map((cell, col) => (col < 3 ? cell.padEnd(widths[col]) : cell)).join(" ").trimEnd())
179
+ .map((cells) => cells.map((cell, col) => (col < 4 ? cell.padEnd(widths[col]) : cell)).join(" ").trimEnd())
108
180
  .join("\n");
109
181
  }
182
+ /** Status cell for the list table. A stalled row is flagged so it does not read as not started. */
183
+ export function formatFeedbackStatus(status) {
184
+ if (typeof status !== "string" || status.length === 0)
185
+ return "-";
186
+ return status === "stalled" ? "stalled!" : status;
187
+ }
188
+ /**
189
+ * Normalize `list --status`; accepts `in-progress` as an alias of `in_progress`.
190
+ * Any non-empty value goes to the server, which validates it.
191
+ */
192
+ export function parseListStatus(raw) {
193
+ const value = raw.trim().toLowerCase().replace(/-/g, "_");
194
+ if (!value)
195
+ throw new Error("--status must not be empty.");
196
+ return value;
197
+ }
110
198
  export function parseListLimit(raw) {
111
199
  const n = Number(raw);
112
200
  if (!Number.isInteger(n) || n < 1) {
@@ -167,31 +255,40 @@ function registerListSubcommand(feedbackCmd) {
167
255
  .command("list")
168
256
  .description("List feedback: untriaged (default), yours, or one person's")
169
257
  .option("--untriaged", "Items nobody has triaged yet (the default)")
258
+ .option("--open", "Every item not resolved, won't-fix or accepted, triaged or not")
170
259
  .option("--mine", "Items assigned to you")
171
260
  .option("--assignee <person>", "Items assigned to this email address or prs_ uid")
172
- .option("--status <status>", "Only items with this status")
261
+ .option("--status <status>", `Only items with this status (${FEEDBACK_LIST_STATUSES.join(", ")}). Without --untriaged, --mine or --assignee it lists every item in that status`)
173
262
  .option("--limit <n>", `Stop after this many items (default ${DEFAULT_LIST_LIMIT})`)
174
263
  .option("--all", "Fetch every page")
175
- .option("--json", "Print { items, count } as JSON")
264
+ .option("--json", "Print { items, count } as JSON, plus truncated: true when more items match")
176
265
  .action(async (opts) => {
177
266
  try {
178
- const picked = [opts.untriaged, opts.mine, opts.assignee !== undefined].filter(Boolean);
267
+ const picked = [opts.untriaged, opts.open, opts.mine, opts.assignee !== undefined].filter(Boolean);
179
268
  if (picked.length > 1) {
180
- throw new Error("Use only one of --untriaged, --mine and --assignee.");
269
+ throw new Error("Use only one of --untriaged, --open, --mine and --assignee.");
270
+ }
271
+ if (opts.open && opts.status !== undefined) {
272
+ throw new Error("--open already selects every open status; use --status on its own instead.");
181
273
  }
182
274
  if (opts.all && opts.limit !== undefined) {
183
275
  throw new Error("Use --limit or --all, not both.");
184
276
  }
185
- if (opts.status !== undefined && !opts.status.trim()) {
186
- throw new Error("--status must not be empty.");
187
- }
277
+ const status = opts.status === undefined ? undefined : parseListStatus(opts.status);
188
278
  const limit = opts.all
189
279
  ? null
190
280
  : opts.limit === undefined
191
281
  ? DEFAULT_LIST_LIMIT
192
282
  : parseListLimit(opts.limit);
193
283
  const token = await resolveStaffToken();
194
- let selector = { kind: "untriaged" };
284
+ // No selector means the untriaged queue, except with --status: a
285
+ // status filter alone lists every row in that status. The queue only
286
+ // holds untriaged rows, so `--status accepted` there was nearly empty.
287
+ let selector = opts.open
288
+ ? { kind: "open" }
289
+ : opts.status !== undefined && !opts.untriaged
290
+ ? { kind: "any" }
291
+ : { kind: "untriaged" };
195
292
  if (opts.mine) {
196
293
  const email = emailFromToken(token);
197
294
  if (!email) {
@@ -206,14 +303,17 @@ function registerListSubcommand(feedbackCmd) {
206
303
  };
207
304
  }
208
305
  assertVaultApiRequestSupported({ token, path: "/v1/feedback" });
209
- const items = await listFeedback({
306
+ const { items, truncated } = await listFeedback({
210
307
  token,
211
308
  selector,
212
- status: opts.status?.trim(),
309
+ status,
213
310
  limit,
214
311
  });
312
+ if (truncated) {
313
+ console.error(chalk.yellow("Note:"), feedbackTruncationNotice(truncated, items.length));
314
+ }
215
315
  if (opts.json) {
216
- console.log(JSON.stringify({ items, count: items.length }));
316
+ console.log(JSON.stringify({ items, count: items.length, ...(truncated ? { truncated: true } : {}) }));
217
317
  }
218
318
  else if (items.length === 0) {
219
319
  console.log("No feedback items match.");
@@ -73,7 +73,7 @@ export declare function attachDebugLogs(requestBody: Record<string, unknown>, di
73
73
  }): void;
74
74
  export declare function submitFeedback(opts: FeedbackSubmitOptions): Promise<FeedbackResult>;
75
75
  /** Customer-visible states accepted by `hq feedback update --state`. */
76
- export declare const PUBLIC_TICKET_STATES: readonly ["triaged", "in-progress", "shipped", "verified", "resolved"];
76
+ export declare const PUBLIC_TICKET_STATES: readonly ["triaged", "in-progress", "stalled", "shipped", "verified", "resolved"];
77
77
  export type PublicTicketState = (typeof PUBLIC_TICKET_STATES)[number];
78
78
  export declare function assertFeedbackId(id: string): void;
79
79
  /**
@@ -88,6 +88,45 @@ export declare function fetchFeedbackLink(opts: {
88
88
  id: string;
89
89
  token: string;
90
90
  }): Promise<string>;
91
+ /**
92
+ * Work links for an in-progress update. A feedback row is in_progress only
93
+ * while it has a linked open PR or a live lane, so `--state in-progress`
94
+ * requires one of them. Field names match the hq-pro PATCH body.
95
+ */
96
+ export interface FeedbackWorkLink {
97
+ prUrl?: string;
98
+ laneId?: string;
99
+ }
100
+ /** Accept `in_progress` as an alias of the public `in-progress` state. */
101
+ export declare function normalizePublicState(raw: string): string;
102
+ /** Validate --pr/--lane for a state; in-progress needs at least one, other states take none. */
103
+ export declare function buildWorkLink(state: string, opts: {
104
+ pr?: string;
105
+ lane?: string;
106
+ }): FeedbackWorkLink | null;
107
+ /** Read a feedback row with the staff GET. Returns null when it cannot be read. */
108
+ export declare function fetchFeedbackRow(opts: {
109
+ id: string;
110
+ token: string;
111
+ }): Promise<Record<string, unknown> | null>;
112
+ /** Delays between read-back attempts: 3 reads over about 1s. */
113
+ export declare const LINK_READBACK_DELAYS_MS: readonly number[];
114
+ /** Overridable in tests so retries do not wait on real timers. */
115
+ export declare const linkReadbackTimer: {
116
+ sleep: (ms: number) => Promise<void>;
117
+ };
118
+ /**
119
+ * Store the PR or lane link before any state change, then confirm it was
120
+ * saved by reading the row back. The PATCH response does not echo prUrl or
121
+ * laneId, and an older server ignores unknown fields, so only the GET shows
122
+ * the link is stored. A link-only PATCH can itself change the row (hq-pro
123
+ * resumes a stalled row), so errors report the row's actual state.
124
+ */
125
+ export declare function storeFeedbackWorkLink(opts: {
126
+ id: string;
127
+ token: string;
128
+ link: FeedbackWorkLink;
129
+ }): Promise<void>;
91
130
  /** Post a customer-visible update; the server also mirrors it into #hq-feedback. */
92
131
  export declare function postFeedbackUpdate(opts: {
93
132
  id: string;