@indigoai-us/hq-cli 5.309.0 → 5.310.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/assets/scaffold/core/scripts/hq-dm-bind.sh +585 -0
  3. package/assets/scaffold/core/scripts/work-mesh-live-bind-trusted.sh +152 -0
  4. package/dist/command-catalog.generated.d.ts +30 -1
  5. package/dist/command-catalog.generated.js +37 -1
  6. package/dist/commands/agents.js +6 -0
  7. package/dist/commands/core-checkpoint-gate-setting.d.ts +0 -10
  8. package/dist/commands/core-checkpoint-gate-setting.js +3 -3
  9. package/dist/commands/core-dm-bind-native.d.ts +32 -0
  10. package/dist/commands/core-dm-bind-native.js +668 -0
  11. package/dist/commands/core-native-utilities.d.ts +2 -1
  12. package/dist/commands/core-native-utilities.js +4 -2
  13. package/dist/commands/core-ontology.d.ts +1 -9
  14. package/dist/commands/core-ontology.js +4 -4
  15. package/dist/commands/core-policy.d.ts +0 -3
  16. package/dist/commands/core-policy.js +3 -3
  17. package/dist/commands/core-work-mesh-bind-trusted-native.d.ts +32 -0
  18. package/dist/commands/core-work-mesh-bind-trusted-native.js +190 -0
  19. package/dist/commands/core.js +6 -0
  20. package/dist/commands/files.js +1 -1
  21. package/dist/commands/index-cmd.js +1 -1
  22. package/dist/commands/integrations-api.d.ts +9 -4
  23. package/dist/commands/integrations-api.js +9 -6
  24. package/dist/commands/integrations-manage.js +11 -3
  25. package/dist/commands/integrations.d.ts +8 -0
  26. package/dist/commands/integrations.js +83 -0
  27. package/dist/commands/lanes-checks.d.ts +0 -2
  28. package/dist/commands/lanes-checks.js +2 -2
  29. package/dist/commands/lanes.d.ts +0 -11
  30. package/dist/commands/lanes.js +6 -6
  31. package/dist/commands/mcp-registration.d.ts +5 -11
  32. package/dist/commands/mcp-registration.js +2 -6
  33. package/dist/lib/core-utils/sentry-report.d.ts +5 -11
  34. package/dist/lib/core-utils/sentry-report.js +2 -2
  35. package/dist/lib/db/local.d.ts +0 -5
  36. package/dist/lib/db/local.js +1 -1
  37. package/dist/lib/db/migrate.d.ts +0 -12
  38. package/dist/lib/db/migrate.js +5 -5
  39. package/dist/lib/db/sql.d.ts +0 -10
  40. package/dist/lib/db/sql.js +4 -8
  41. package/dist/lib/doctor/checks/codex-wiring.js +1 -33
  42. package/dist/lib/doctor/checks/grok-wiring.js +1 -34
  43. package/dist/lib/doctor/deep/effects.js +1 -3
  44. package/dist/lib/doctor/deep/sandbox.js +1 -3
  45. package/dist/lib/doctor/fixtures/schema.js +1 -3
  46. package/dist/lib/doctor/shared.d.ts +8 -0
  47. package/dist/lib/doctor/shared.js +38 -0
  48. package/dist/lib/lanes/autopilot.js +6 -4
  49. package/dist/lib/monitor.d.ts +2 -6
  50. package/dist/lib/monitor.js +5 -5
  51. package/dist/lib/report-publish.d.ts +1 -10
  52. package/dist/lib/report-publish.js +3 -9
  53. package/dist/lib/search-index/index.d.ts +7 -0
  54. package/dist/lib/search-index/index.js +21 -2
  55. package/dist/lib/search-index/qmd-exit-diagnostics.d.ts +5 -0
  56. package/dist/lib/search-index/qmd-exit-diagnostics.js +40 -0
  57. package/dist/lib/service-manager/launchd.d.ts +0 -7
  58. package/dist/lib/service-manager/launchd.js +2 -2
  59. package/dist/main.js +7 -2
  60. package/dist/startup-registration.js +9 -1
  61. package/dist/utils/client-health.d.ts +0 -4
  62. package/dist/utils/client-health.js +2 -2
  63. package/dist/utils/feedback-diagnostics.d.ts +2 -1
  64. package/dist/utils/feedback-log-bundle.d.ts +0 -25
  65. package/dist/utils/feedback-log-bundle.js +5 -5
  66. package/dist/utils/feedback-logs.d.ts +4 -38
  67. package/dist/utils/feedback-logs.js +7 -7
  68. package/dist/utils/feedback-screenshots.d.ts +0 -1
  69. package/dist/utils/feedback-screenshots.js +1 -1
  70. package/dist/utils/install-tree-torn.d.ts +19 -13
  71. package/dist/utils/install-tree-torn.js +77 -2
  72. package/dist/utils/local-files-overview.d.ts +0 -6
  73. package/dist/utils/local-files-overview.js +1 -1
  74. package/dist/utils/qmd-model-download-error.d.ts +0 -8
  75. package/dist/utils/qmd-model-download-error.js +1 -1
  76. package/dist/utils/qmd-query-document-error.d.ts +0 -6
  77. package/dist/utils/qmd-query-document-error.js +1 -1
  78. package/dist/utils/scaffold-fast-import-guard.d.ts +0 -1
  79. package/dist/utils/scaffold-fast-import-guard.js +1 -1
  80. package/dist/utils/scaffold-sibling-references.d.ts +3 -2
  81. package/dist/utils/sentry-fingerprint.js +17 -0
  82. package/dist/utils/version-gate.d.ts +4 -0
  83. package/dist/utils/version-gate.js +13 -0
  84. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.310.0] — 2026-10-01
6
+ - Fixed `hq agents rm` to remove external agents through revoke rather than the hosted-agent teardown path.
7
+
8
+ - Wait for asynchronous integration approvals to reach a terminal queue state and report pending work accurately.
9
+
10
+ - Add privacy-safe qmd exit diagnostics to Sentry reports.
11
+
12
+ - `hq files delete` help now explains exact-key and wildcard-prefix matching.
13
+
14
+ - Port `hq core dm-bind` and `hq core work-mesh-bind-trusted` to native TypeScript behind `core-native-utilities`, retaining the bundled-script fallback.
15
+
16
+ - Move directly controlled Linux CI checks to ARM runners where their tools support ARM.
17
+ - Lead autopilot resolves a lead's company through its project when the lane record does not carry a company, and skips project paths registered to a different company.
18
+ - Install-tree recovery errors now report bounded sibling, target, version-gate phase, and updater-liveness enums.
19
+
5
20
  ## [5.309.0] — 2026-10-01
6
21
  - `hq lanes autopilot run` now evaluates leads based on lane ownership rather than a host label, and reports registered leads assigned to another host as skipped.
7
22
  - Allow non-overlapping story-prefix teams to share a project in lane checks and reports.
@@ -0,0 +1,585 @@
1
+ #!/usr/bin/env bash
2
+ # hq-dm-bind.sh — bind the current session to one HQ DM channel, post
3
+ # structured updates to it, and listen for replies (feedback / requests).
4
+ #
5
+ # Usage:
6
+ # core/scripts/hq-dm-bind.sh bind <channel> # bind + set the read cursor to "now"
7
+ # core/scripts/hq-dm-bind.sh status # show the binding and cursor
8
+ # core/scripts/hq-dm-bind.sh post --title <t> [--state <s>] [--line <l>]... [--next <l>]... [--ask <l>]... [--to <name>]... [--topic <t>] [--under <id>] [--new-thread]
9
+ # core/scripts/hq-dm-bind.sh post --title <t> - < body.md # body from stdin (already structured)
10
+ # core/scripts/hq-dm-bind.sh threads # list the channel's recent top-level threads (id, replies, author, text)
11
+ # core/scripts/hq-dm-bind.sh roster # print the channel members a post will @-mention
12
+ # core/scripts/hq-dm-bind.sh poll # print new messages since the cursor, advance it
13
+ # core/scripts/hq-dm-bind.sh listen [--interval 60] [--timeout 1800]
14
+ # # block until a new message arrives (or timeout)
15
+ # core/scripts/hq-dm-bind.sh unbind
16
+ #
17
+ # The binding lives in the session's meta.yaml (`dm_channel`) via hq-session.sh;
18
+ # the cursor is `workspace/sessions/<sid>/dm-bind.cursor` (the last seen sort
19
+ # key from `hq dm channel --json`). `listen` is meant to run detached
20
+ # (Bash run_in_background): it exits 0 and prints the new messages the moment
21
+ # one lands, exits 3 on timeout with nothing new.
22
+ #
23
+ # Every post opens with an @-mention of every other member of the channel, so
24
+ # the update reaches people (HQ mentions are structured: the `hq` CLI resolves
25
+ # the `@"Display Name"` tokens against the roster and sends the real mention
26
+ # list; an unresolvable name fails the post rather than posting text that looks
27
+ # addressed but is not). The CLI rewrites a resolved `@"Display Name"` token to
28
+ # `@Display Name`, so the room never shows the quotes.
29
+ #
30
+ # Posts thread by topic. The first post on a topic (`--topic`, else the title)
31
+ # is a top-level root; every later post on the same topic in this session goes
32
+ # out as a reply under it. `--under <id>` replies under an existing thread that
33
+ # someone else (or an earlier session) started — a top-level post is only for a
34
+ # subject the channel has no thread for yet. `--new-thread` starts a fresh root. The topic → root
35
+ # map is `workspace/sessions/<sid>/dm-bind.threads`.
36
+ #
37
+ # A post ALWAYS mentions someone. `--to <name>` (repeatable) narrows the line to
38
+ # the named members, for a reply to one person; without it the line names every
39
+ # other human member (bots are left out unless named with --to, because a tagged
40
+ # bot wakes and replies). There is no way to post unmentioned: an update nobody is
41
+ # notified about does not get read. `post` refuses when the roster cannot be
42
+ # read rather than sending a silent update.
43
+ #
44
+ # Post format (kept deliberately plain — see .claude/skills/dm-bind/SKILL.md):
45
+ # @"Ada Lovelace" @"Grace Hopper"
46
+ #
47
+ # <title> — <state>
48
+ #
49
+ # • line
50
+ # • line
51
+ #
52
+ # Next: line
53
+ # Need from you: line
54
+ set -euo pipefail
55
+
56
+ HQ_ROOT="${HQ_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}"
57
+ SESSION_SH="$HQ_ROOT/core/scripts/hq-session.sh"
58
+
59
+ die() { echo "hq-dm-bind: $*" >&2; exit 2; }
60
+ need() { command -v "$1" >/dev/null 2>&1 || die "missing dependency: $1"; }
61
+ need hq; need jq
62
+
63
+ session_id() { bash "$SESSION_SH" current 2>/dev/null || true; }
64
+ session_dir() {
65
+ local sid; sid="$(session_id)"
66
+ [ -n "$sid" ] || die "no current session (hq-session.sh current is empty)"
67
+ local d="$HQ_ROOT/workspace/sessions/$sid"; mkdir -p "$d"; echo "$d"
68
+ }
69
+ cursor_file() { echo "$(session_dir)/dm-bind.cursor"; }
70
+ channel() { bash "$SESSION_SH" get dm_channel 2>/dev/null | tr -d '"' || true; }
71
+ require_channel() {
72
+ local ch; ch="$(channel)"
73
+ [ -n "$ch" ] && [ "$ch" != "null" ] || die "session is not bound — run: core/scripts/hq-dm-bind.sh bind <channel>"
74
+ echo "$ch"
75
+ }
76
+
77
+ # Own address, used to skip our own posts when polling. Best effort.
78
+ self_email() {
79
+ hq whoami 2>/dev/null | grep -oE '[[:alnum:]._+-]+@[[:alnum:].-]+\.[[:alpha:]]+' | head -1 || true
80
+ }
81
+
82
+ CLI_MIN_VERSION="5.137.0"
83
+
84
+ # Human sessions have both legacy local Cognito files. Machine-principal
85
+ # sessions use the hq CLI's machine token and do not have those files.
86
+ has_cognito_session() {
87
+ [ -r "$HOME/.hq/config.json" ] && [ -r "$HOME/.hq/cognito-tokens.json" ]
88
+ }
89
+
90
+ uses_cli_auth() { ! has_cognito_session; }
91
+
92
+ cli_version() {
93
+ local output version
94
+ if ! output="$(hq --version 2>/dev/null)"; then
95
+ echo "hq-dm-bind: could not read the installed hq-cli version" >&2
96
+ return 1
97
+ fi
98
+ version="$(printf '%s\n' "$output" | sed -nE 's/^CLI[[:space:]]+([0-9]+(\.[0-9]+){2}).*$/\1/p' | head -1)"
99
+ if [ -z "$version" ]; then
100
+ version="$(printf '%s\n' "$output" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)"
101
+ fi
102
+ printf '%s' "$version"
103
+ }
104
+
105
+ version_at_least() {
106
+ local actual="$1" required="$2"
107
+ local a=0 b=0 c=0 ra=0 rb=0 rc=0
108
+ IFS=. read -r a b c <<< "$actual"
109
+ IFS=. read -r ra rb rc <<< "$required"
110
+ [ "${a:-0}" -gt "${ra:-0}" ] || {
111
+ [ "${a:-0}" -eq "${ra:-0}" ] && {
112
+ [ "${b:-0}" -gt "${rb:-0}" ] || {
113
+ [ "${b:-0}" -eq "${rb:-0}" ] && [ "${c:-0}" -ge "${rc:-0}" ]
114
+ }
115
+ }
116
+ }
117
+ }
118
+
119
+ require_cli_version() {
120
+ local actual
121
+ actual="$(cli_version)" || return 2
122
+ if [ -z "$actual" ] || ! version_at_least "$actual" "$CLI_MIN_VERSION"; then
123
+ echo "hq-dm-bind: hq-cli >= $CLI_MIN_VERSION is required for machine-principal DM binding (found ${actual:-unknown}); update hq-cli and retry." >&2
124
+ return 2
125
+ fi
126
+ }
127
+
128
+ cli_identity() {
129
+ local identity
130
+ if ! identity="$(hq whoami --json 2>/dev/null)"; then
131
+ echo "hq-dm-bind: could not read the hq-cli identity" >&2
132
+ return 1
133
+ fi
134
+ if ! printf '%s' "$identity" | jq -e 'type == "object"' >/dev/null 2>&1; then
135
+ echo "hq-dm-bind: hq-cli returned an invalid identity" >&2
136
+ return 1
137
+ fi
138
+ printf '%s' "$identity"
139
+ }
140
+
141
+ # Normalise `hq dm channel --json` to a JSON array of messages.
142
+ fetch_items() {
143
+ local ch="$1" limit="${2:-30}"
144
+ hq dm channel "$ch" --limit "$limit" --json 2>/dev/null \
145
+ | jq -c 'if type=="array" then . else (.messages // .items // []) end' 2>/dev/null \
146
+ || echo '[]'
147
+ }
148
+
149
+ latest_sk() { fetch_items "$1" 5 | jq -r 'last // empty | .sk // empty'; }
150
+ roster_file() { echo "$(session_dir)/dm-bind.roster"; }
151
+
152
+ # The channel's members other than us, one display name<TAB>uid per line.
153
+ # Machine-principal sessions use the CLI, while human sessions retain the
154
+ # local-file path for compatibility. Fails soft after logging a CLI read error.
155
+ fetch_roster() {
156
+ local ch="$1" me; me="$(self_email)"
157
+ if uses_cli_auth; then
158
+ require_cli_version || return $?
159
+ if ! hq channels members "$ch"; then
160
+ echo "hq-dm-bind: could not read the channel roster through hq-cli" >&2
161
+ fi
162
+ return 0
163
+ fi
164
+ command -v node >/dev/null 2>&1 || return 0
165
+ node - roster "$ch" "$me" <<'NODE' 2>/dev/null || true
166
+ const fs = require("fs"), os = require("os"), path = require("path");
167
+ const [, ch, me] = process.argv.slice(2);
168
+ const home = os.homedir();
169
+ const slug = (n) => String(n).trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
170
+ (async () => {
171
+ const cfg = JSON.parse(fs.readFileSync(path.join(home, ".hq", "config.json"), "utf8"));
172
+ const tok = JSON.parse(fs.readFileSync(path.join(home, ".hq", "cognito-tokens.json"), "utf8")).idToken;
173
+ const base = cfg.vaultApiUrl; const h = { Authorization: "Bearer " + tok };
174
+ const chans = await (await fetch(base + "/v1/notify/channels", { headers: h })).json();
175
+ const rows = Array.isArray(chans) ? chans : (chans.channels || []);
176
+ const hit = rows.find((c) => c.channelId === ch || slug(c.name || "") === slug(ch) || c.slug === slug(ch));
177
+ if (!hit) return;
178
+ const mem = await (await fetch(base + "/v1/notify/channels/" + encodeURIComponent(hit.channelId) + "/members", { headers: h })).json();
179
+ const list = Array.isArray(mem) ? mem : (mem.members || []);
180
+ for (const m of list) {
181
+ const email = String(m.email || "").toLowerCase();
182
+ if (me && email === me.toLowerCase()) continue;
183
+ const name = String(m.displayName || "").trim();
184
+ const uid = String(m.personUid || m.participantUid || m.uid || "").trim();
185
+ if (name) process.stdout.write(name + "\t" + uid + "\n");
186
+ }
187
+ })().catch(() => {});
188
+ NODE
189
+ }
190
+
191
+ # Refresh the cached roster; fall back to the cache when the fetch fails.
192
+ roster() {
193
+ local ch="$1" rf; rf="$(roster_file)"
194
+ if uses_cli_auth; then require_cli_version || return $?; fi
195
+ local fresh; fresh="$(fetch_roster "$ch")"
196
+ if [ -n "$fresh" ]; then printf '%s\n' "$fresh" > "$rf"; fi
197
+ cut -f1 "$rf" 2>/dev/null || true
198
+ }
199
+
200
+ # The structured `mentions` array for a newline list of display names, built
201
+ # from the cached roster rows (name<TAB>uid). `agt_*` uids are agents, every
202
+ # other uid is a human — the server 400s when the type and the prefix disagree.
203
+ mentions_json() {
204
+ local rf; rf="$(roster_file)"
205
+ jq -Rn --rawfile rows "$rf" '
206
+ ($rows | split("\n") | map(select(length>0) | split("\t")) | map({key:.[0], value:(.[1] // "")}) | from_entries) as $uid
207
+ | [inputs | select(length>0) | select(($uid[.] // "") != "")
208
+ | {participantUid:$uid[.], participantType:(if ($uid[.]|startswith("agt_")) then "agent" else "human" end), displayName:.}]'
209
+ }
210
+
211
+ threads_file() { echo "$(session_dir)/dm-bind.threads"; }
212
+ topic_slug() { printf '%s' "$1" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+|-+$//g'; }
213
+ thread_root() { awk -F'\t' -v k="$1" '$1==k{v=$2} END{if(v!="")print v}' "$(threads_file)" 2>/dev/null || true; }
214
+
215
+ # POST one message to the channel through the legacy notify API. Reads the JSON
216
+ # payload from $1 and prints the new message's eventId. Machine-principal
217
+ # sessions use send_cli_channel below.
218
+ send_channel() {
219
+ local ch="$1" payload="$2"
220
+ node - send "$ch" "$payload" <<'NODE'
221
+ const fs = require("fs"), os = require("os"), path = require("path");
222
+ const [, ch, payload] = process.argv.slice(2);
223
+ const home = os.homedir();
224
+ const slug = (n) => String(n).trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
225
+ (async () => {
226
+ const cfg = JSON.parse(fs.readFileSync(path.join(home, ".hq", "config.json"), "utf8"));
227
+ const tok = JSON.parse(fs.readFileSync(path.join(home, ".hq", "cognito-tokens.json"), "utf8")).idToken;
228
+ const base = cfg.vaultApiUrl; const h = { Authorization: "Bearer " + tok, "Content-Type": "application/json" };
229
+ const chans = await (await fetch(base + "/v1/notify/channels", { headers: h })).json();
230
+ const rows = Array.isArray(chans) ? chans : (chans.channels || []);
231
+ const hit = rows.find((c) => c.channelId === ch || slug(c.name || "") === slug(ch) || c.slug === slug(ch));
232
+ if (!hit) throw new Error("channel not found: " + ch);
233
+ const res = await fetch(base + "/v1/notify/channels/" + encodeURIComponent(hit.channelId) + "/messages", { method: "POST", headers: h, body: payload });
234
+ const text = await res.text();
235
+ if (!res.ok) throw new Error("post failed: HTTP " + res.status + " " + text.slice(0, 300));
236
+ let j = {}; try { j = JSON.parse(text); } catch {}
237
+ const m = j.message || j.event || j;
238
+ const id = m.eventId || (typeof m.sk === "string" && m.sk.includes("#") ? m.sk.split("#").pop() : "");
239
+ process.stdout.write(String(id || ""));
240
+ })().catch((e) => { process.stderr.write("hq-dm-bind: " + e.message + "\n"); process.exit(1); });
241
+ NODE
242
+ }
243
+
244
+ # The CLI prints a human confirmation rather than the created event id. Read
245
+ # the channel back and use the newest message from this CLI identity so topic
246
+ # replies can keep using the existing thread map.
247
+ latest_cli_event_id() {
248
+ local ch="$1" identity agent username person email
249
+ identity="$(cli_identity)" || return 1
250
+ agent="$(printf '%s' "$identity" | jq -r '.agentUid // empty')"
251
+ username="$(printf '%s' "$identity" | jq -r '.username // empty')"
252
+ person="$(printf '%s' "$identity" | jq -r '.personUid // empty')"
253
+ email="$(printf '%s' "$identity" | jq -r '.email // empty')"
254
+ fetch_items "$ch" 50 | jq -r \
255
+ --arg agent "$agent" --arg username "$username" --arg person "$person" --arg email "$email" '
256
+ [ .[]
257
+ | select(
258
+ if ($agent != "" or $username != "") then
259
+ (((.senderMachineUid // .fromMachineUid // "") == $agent)
260
+ or ((.senderMachineUid // .fromMachineUid // "") == $username))
261
+ elif $person != "" then
262
+ ((.fromPersonUid // "") == $person)
263
+ elif $email != "" then
264
+ ((.fromEmail // "" | ascii_downcase) == ($email | ascii_downcase))
265
+ else false
266
+ end
267
+ )
268
+ | (.eventId // .messageId // "")
269
+ ] | last // empty'
270
+ }
271
+
272
+ send_cli_channel() {
273
+ local ch="$1" body="$2" root="$3"
274
+ require_cli_version || return $?
275
+ local -a args=(dm "$ch")
276
+ if [ -n "$root" ]; then args+=(--thread "$root"); fi
277
+ args+=("$body")
278
+ if ! hq "${args[@]}" >/dev/null; then
279
+ echo "hq-dm-bind: hq dm failed — nothing was sent" >&2
280
+ return 1
281
+ fi
282
+ local event_id
283
+ event_id="$(latest_cli_event_id "$ch")" || return 1
284
+ if [ -z "$event_id" ]; then
285
+ echo "hq-dm-bind: post succeeded but its event id could not be read back" >&2
286
+ return 1
287
+ fi
288
+ printf '%s' "$event_id"
289
+ }
290
+
291
+ # The @-mention line: one quoted token per member so multi-word names resolve.
292
+ # The quotes are input syntax only — the `hq` CLI strips them once the name
293
+ # resolves, so the room reads `@Ada Lovelace`. With no extra args it names every
294
+ # other member; extra args narrow it to those members, matched case-insensitively
295
+ # against the roster (exact display name first, then a unique partial match).
296
+ # Exit 4: roster unreadable. Exit 5: a --to name matched nobody or several.
297
+ mention_line() {
298
+ local ch="$1"; shift
299
+ local names line="" n
300
+ names="$(roster "$ch")"
301
+ [ -n "$names" ] || return 4
302
+ if [ $# -eq 0 ]; then
303
+ # Default: the people, not the bots. A tagged bot wakes and answers, and a
304
+ # round of "no response needed" replies under every update is the clutter
305
+ # threads exist to prevent. Name a bot with --to when the post is for it.
306
+ local humans; humans="$(awk -F'\t' '$2 !~ /^agt_/ {print $1}' "$(roster_file)" 2>/dev/null || true)"
307
+ [ -n "$humans" ] || humans="$names"
308
+ while IFS= read -r n; do [ -n "$n" ] && line="$line @\"$n\""; done <<< "$humans"
309
+ else
310
+ local want hit count
311
+ for want in "$@"; do
312
+ want="${want#@}"
313
+ hit="$(printf '%s\n' "$names" | grep -ixF -- "$want" | head -1 || true)"
314
+ if [ -z "$hit" ]; then
315
+ count="$(printf '%s\n' "$names" | grep -icF -- "$want" || true)"
316
+ if [ "$count" = 1 ]; then hit="$(printf '%s\n' "$names" | grep -iF -- "$want")"; fi
317
+ fi
318
+ if [ -z "$hit" ]; then
319
+ echo "hq-dm-bind: --to '$want' does not match exactly one channel member. Members:" >&2
320
+ printf '%s\n' "$names" | sed 's/^/ /' >&2
321
+ return 5
322
+ fi
323
+ case "$line" in *"@\"$hit\""*) ;; *) line="$line @\"$hit\"" ;; esac
324
+ done
325
+ fi
326
+ printf '%s' "${line# }"
327
+ }
328
+
329
+ cmd_bind() {
330
+ local ch="${1:-}"; [ -n "$ch" ] || die "bind needs a channel name (see: hq channels)"
331
+ ch="${ch#\#}"
332
+ hq channels 2>/dev/null | grep -q -- "hq dm $ch " || die "no channel named '$ch' (see: hq channels)"
333
+ bash "$SESSION_SH" set dm_channel "$ch" >/dev/null
334
+ local sk; sk="$(latest_sk "$ch")"
335
+ printf '%s\n' "$sk" > "$(cursor_file)"
336
+ local names; names="$(roster "$ch")"
337
+ local n; n="$(printf '%s' "$names" | grep -c . || true)"
338
+ echo "bound session $(session_id) to #$ch (cursor: ${sk:-<empty>}; will @-mention $n member(s))"
339
+ [ "$n" -gt 0 ] || echo "hq-dm-bind: could not read the channel roster — posts will not @-mention anyone until it is readable" >&2
340
+ }
341
+
342
+ cmd_roster() {
343
+ local ch; ch="$(require_channel)"
344
+ roster "$ch"
345
+ }
346
+
347
+ # Recent top-level messages (thread roots) in the bound channel, newest last:
348
+ # "<id8> <replies> <author>: <first 90 chars>". Read this BEFORE starting a
349
+ # new topic — if a thread for the subject already exists, reply under it.
350
+ recent_roots() { fetch_items "$1" 100 | jq -c 'map(select((.rootEventId // "") == ""))'; }
351
+ cmd_threads() {
352
+ local ch; ch="$(require_channel)"
353
+ recent_roots "$ch" | jq -r '.[-25:] | .[] | [(.eventId[0:8]), ((.replyCount // 0)|tostring), ((.fromDisplayName // "?") + ": " + ((.body // "") | gsub("\\s+";" ") | .[0:90]))] | @tsv'
354
+ }
355
+ # Resolve --under: a full event id, or a unique id prefix of a recent root.
356
+ resolve_root() {
357
+ local ch="$1" want="$2" hits
358
+ hits="$(recent_roots "$ch" | jq -r --arg w "$want" '.[] | select(.eventId | startswith($w)) | .eventId')"
359
+ [ -n "$hits" ] || { echo "hq-dm-bind: --under '$want' matches no recent top-level message (see: hq-dm-bind.sh threads)" >&2; return 1; }
360
+ [ "$(printf '%s\n' "$hits" | wc -l | tr -d ' ')" = 1 ] || { echo "hq-dm-bind: --under '$want' matches several messages — use more of the id" >&2; return 1; }
361
+ printf '%s' "$hits"
362
+ }
363
+
364
+ cmd_unbind() {
365
+ bash "$SESSION_SH" set dm_channel "" >/dev/null
366
+ rm -f "$(cursor_file)" "$(roster_file)" "$(threads_file)"
367
+ echo "unbound"
368
+ }
369
+
370
+ cmd_status() {
371
+ local ch; ch="$(channel)"
372
+ echo "session: $(session_id)"
373
+ echo "channel: ${ch:-<none>}"
374
+ echo "cursor: $(cat "$(cursor_file)" 2>/dev/null || echo '<none>')"
375
+ }
376
+
377
+ cmd_post() {
378
+ local ch; ch="$(require_channel)"
379
+ local title="" state="" body_from_stdin=0 topic="" new_thread=0 under="" allow_long=0
380
+ local -a lines=() nexts=() asks=() tos=()
381
+ while [ $# -gt 0 ]; do
382
+ case "$1" in
383
+ --topic) topic="$2"; shift 2 ;;
384
+ --new-thread) new_thread=1; shift ;;
385
+ --under) [ -n "${2:-}" ] || die "post: --under needs a message id (see: threads)"; under="$2"; shift 2 ;;
386
+ --to) [ -n "${2:-}" ] || die "post: --to needs a member name"; tos+=("$2"); shift 2 ;;
387
+ --no-mention) die "post: --no-mention was removed — every post @-mentions someone. Use --to <name> to address specific members." ;;
388
+ --title) title="$2"; shift 2 ;;
389
+ --state) state="$2"; shift 2 ;;
390
+ --line) lines+=("$2"); shift 2 ;;
391
+ --next) nexts+=("$2"); shift 2 ;;
392
+ --ask) asks+=("$2"); shift 2 ;;
393
+ --allow-long) allow_long=1; shift ;;
394
+ -) body_from_stdin=1; shift ;;
395
+ *) die "post: unknown arg $1" ;;
396
+ esac
397
+ done
398
+ [ -n "$title" ] || die "post needs --title"
399
+
400
+ # Length guard — refuse before any network call so nothing partial is sent.
401
+ # Override with --allow-long only when the post genuinely must be longer.
402
+ if [ "$allow_long" != 1 ] && [ "$body_from_stdin" != 1 ]; then
403
+ local _guard_fail=0 _len _preview
404
+ if [ "${#lines[@]}" -gt 6 ]; then
405
+ echo "hq-dm-bind: too many --line bullets (${#lines[@]}, limit 6). Put extra detail in workspace/reports/<name>.md and link it in one bullet. Use --allow-long to override." >&2
406
+ _guard_fail=1
407
+ fi
408
+ if [ "${#nexts[@]}" -gt 2 ]; then
409
+ echo "hq-dm-bind: too many --next items (${#nexts[@]}, limit 2). Use --allow-long to override." >&2
410
+ _guard_fail=1
411
+ fi
412
+ if [ "${#asks[@]}" -gt 1 ]; then
413
+ echo "hq-dm-bind: too many --ask items (${#asks[@]}, limit 1). Use --allow-long to override." >&2
414
+ _guard_fail=1
415
+ fi
416
+ for _l in "${lines[@]}"; do
417
+ _len="$(printf '%s' "$_l" | wc -c | tr -d ' ')"
418
+ if [ "$_len" -gt 140 ]; then
419
+ _preview="$(printf '%s' "$_l" | cut -c1-60)"
420
+ echo "hq-dm-bind: --line too long ($_len chars, limit 140): \"$_preview\". Put the detail in workspace/reports/<name>.md and reference the path in one line. Use --allow-long to override." >&2
421
+ _guard_fail=1
422
+ fi
423
+ done
424
+ for _l in "${nexts[@]}"; do
425
+ _len="$(printf '%s' "$_l" | wc -c | tr -d ' ')"
426
+ if [ "$_len" -gt 140 ]; then
427
+ _preview="$(printf '%s' "$_l" | cut -c1-60)"
428
+ echo "hq-dm-bind: --next too long ($_len chars, limit 140): \"$_preview\". Put the detail in workspace/reports/<name>.md and reference the path in one line. Use --allow-long to override." >&2
429
+ _guard_fail=1
430
+ fi
431
+ done
432
+ for _l in "${asks[@]}"; do
433
+ _len="$(printf '%s' "$_l" | wc -c | tr -d ' ')"
434
+ if [ "$_len" -gt 140 ]; then
435
+ _preview="$(printf '%s' "$_l" | cut -c1-60)"
436
+ echo "hq-dm-bind: --ask too long ($_len chars, limit 140): \"$_preview\". Put the detail in workspace/reports/<name>.md and reference the path in one line. Use --allow-long to override." >&2
437
+ _guard_fail=1
438
+ fi
439
+ done
440
+ [ "$_guard_fail" = 0 ] || exit 2
441
+ fi
442
+ local msg="$title"; [ -n "$state" ] && msg="$title — $state"
443
+ if [ "$body_from_stdin" = 1 ]; then
444
+ msg="$msg"$'\n\n'"$(cat)"
445
+ else
446
+ if [ ${#lines[@]} -gt 0 ]; then
447
+ msg="$msg"$'\n'
448
+ for l in "${lines[@]}"; do msg="$msg"$'\n'"• $l"; done
449
+ fi
450
+ if [ ${#nexts[@]} -gt 0 ]; then
451
+ msg="$msg"$'\n'
452
+ for l in "${nexts[@]}"; do msg="$msg"$'\n'"Next: $l"; done
453
+ fi
454
+ if [ ${#asks[@]} -gt 0 ]; then
455
+ msg="$msg"$'\n'
456
+ for l in "${asks[@]}"; do msg="$msg"$'\n'"Need from you: $l"; done
457
+ fi
458
+ fi
459
+ local ml rc=0
460
+ if [ ${#tos[@]} -gt 0 ]; then ml="$(mention_line "$ch" "${tos[@]}")" || rc=$?
461
+ else ml="$(mention_line "$ch")" || rc=$?; fi
462
+ [ "$rc" != 5 ] || exit 2
463
+ [ "$rc" = 0 ] && [ -n "$ml" ] || die "could not read the channel roster — not posting an update that notifies nobody (check \`hq whoami\` and that node is on PATH, then retry)"
464
+ # The room shows `@Ada Lovelace`; the quotes only ever existed as CLI syntax
465
+ # for the hq CLI path. Keep the quoted form for CLI mention resolution.
466
+ local shown names_nl posted_msg cli_msg
467
+ shown="$(printf '%s' "$ml" | sed -E 's/@"([^"]+)"/@\1/g')"
468
+ names_nl="$(printf '%s' "$ml" | grep -oE '@"[^"]+"' | sed -E 's/^@"//; s/"$//')"
469
+ posted_msg="$shown"$'\n\n'"$msg"
470
+ cli_msg="$ml"$'\n\n'"$msg"
471
+ msg="$posted_msg"
472
+
473
+ # Total body length check (counts what the receipt prints — mention line included).
474
+ if [ "$allow_long" != 1 ]; then
475
+ local _total; _total="$(printf '%s' "$msg" | wc -c | tr -d ' ')"
476
+ if [ "$_total" -gt 600 ]; then
477
+ echo "hq-dm-bind: post body too long ($_total chars, limit 600). Put the detail in workspace/reports/<name>.md and reference the path in one line. Use --allow-long to override." >&2
478
+ exit 2
479
+ fi
480
+ fi
481
+
482
+ # One thread per topic: the first post on a topic is the root, every later
483
+ # post on it is a reply under that root, so the room stays scannable.
484
+ local key root=""
485
+ key="$(topic_slug "${topic:-$title}")"
486
+ if [ -n "$under" ]; then
487
+ # Reply under someone else's (or an earlier) thread, and keep this topic there.
488
+ root="$(resolve_root "$ch" "$under")" || exit 2
489
+ [ "$(thread_root "$key")" = "$root" ] || printf '%s\t%s\n' "$key" "$root" >> "$(threads_file)"
490
+ elif [ "$new_thread" != 1 ]; then
491
+ root="$(thread_root "$key")"
492
+ fi
493
+ local eid
494
+ if uses_cli_auth; then
495
+ eid="$(send_cli_channel "$ch" "$cli_msg" "$root")" || die "post failed — nothing was sent"
496
+ else
497
+ local payload
498
+ payload="$(printf '%s\n' "$names_nl" | mentions_json | jq -c --arg body "$msg" --arg root "$root" \
499
+ '{body:$body, mentions:.} + (if $root != "" then {rootEventId:$root} else {} end)')"
500
+ [ "$(printf '%s' "$payload" | jq '.mentions | length')" -gt 0 ] || die "no mention resolved to a channel member id — not posting an update that notifies nobody"
501
+ eid="$(send_channel "$ch" "$payload")" || die "post failed — nothing was sent"
502
+ fi
503
+ if [ -z "$root" ]; then
504
+ [ -n "$eid" ] && printf '%s\t%s\n' "$key" "$eid" >> "$(threads_file)"
505
+ echo "thread: new topic '$key'${eid:+ (root $eid)}"
506
+ else
507
+ echo "thread: reply under topic '$key'"
508
+ fi
509
+ # Our own post must not come back as "new" on the next poll.
510
+ latest_sk "$ch" > "$(cursor_file)"
511
+ echo "posted to #$ch ($(printf '%s' "$msg" | wc -c | tr -d ' ') chars)"
512
+ }
513
+
514
+ # Prints new messages (not ours) since the cursor as "<time> <name>: <body>",
515
+ # advances the cursor. Exit 0 if any printed, 3 if none.
516
+ cmd_poll() {
517
+ local ch; ch="$(require_channel)"
518
+ local cf; cf="$(cursor_file)"
519
+ local cursor; cursor="$(cat "$cf" 2>/dev/null || true)"
520
+ local items; items="$(fetch_items "$ch" 50)"
521
+ local last; last="$(printf '%s' "$items" | jq -r 'last // empty | .sk // empty')"
522
+ local msgs
523
+ if uses_cli_auth; then
524
+ local identity agent username person email
525
+ identity="$(cli_identity)" || die "could not read the hq-cli identity — not polling"
526
+ agent="$(printf '%s' "$identity" | jq -r '.agentUid // empty')"
527
+ username="$(printf '%s' "$identity" | jq -r '.username // empty')"
528
+ person="$(printf '%s' "$identity" | jq -r '.personUid // empty')"
529
+ email="$(printf '%s' "$identity" | jq -r '.email // empty')"
530
+ msgs="$(printf '%s' "$items" | jq -r \
531
+ --arg cur "$cursor" --arg agent "$agent" --arg username "$username" --arg person "$person" --arg email "$email" '
532
+ map(select((.sk // "") > $cur and
533
+ (if ($agent != "" or $username != "") then
534
+ (((.senderMachineUid // .fromMachineUid // "") != $agent)
535
+ and ((.senderMachineUid // .fromMachineUid // "") != $username))
536
+ elif $person != "" then
537
+ ((.fromPersonUid // "") != $person)
538
+ elif $email != "" then
539
+ ((.fromEmail // "" | ascii_downcase) != ($email | ascii_downcase))
540
+ else true
541
+ end)))
542
+ | .[]
543
+ | ((.sk // "")[0:16] | sub("T"; " ")) + "Z " + (.fromDisplayName // .fromEmail // "?") + ": " + ((.body // "") | gsub("^\\s+|\\s+$"; ""))')"
544
+ else
545
+ local me; me="$(self_email)"
546
+ msgs="$(printf '%s' "$items" | jq -r --arg cur "$cursor" --arg me "$me" '
547
+ map(select((.sk // "") > $cur and ($me == "" or .fromEmail != $me)))
548
+ | .[]
549
+ | ((.sk // "")[0:16] | sub("T"; " ")) + "Z " + (.fromDisplayName // .fromEmail // "?") + ": " + ((.body // "") | gsub("^\\s+|\\s+$"; ""))')"
550
+ fi
551
+ [ -n "$last" ] && printf '%s\n' "$last" > "$cf"
552
+ if [ -n "$(printf '%s' "$msgs" | tr -d '[:space:]')" ]; then printf '%s\n' "$msgs"; return 0; fi
553
+ return 3
554
+ }
555
+
556
+ cmd_listen() {
557
+ local interval=60 timeout=1800
558
+ while [ $# -gt 0 ]; do
559
+ case "$1" in
560
+ --interval) interval="$2"; shift 2 ;;
561
+ --timeout) timeout="$2"; shift 2 ;;
562
+ *) die "listen: unknown arg $1" ;;
563
+ esac
564
+ done
565
+ local ch; ch="$(require_channel)"
566
+ local deadline=$(( $(date +%s) + timeout ))
567
+ echo "listening on #$ch every ${interval}s (timeout ${timeout}s)"
568
+ while [ "$(date +%s)" -lt "$deadline" ]; do
569
+ if cmd_poll; then echo "--- new message(s) on #$ch; handle them, then re-run listen"; return 0; fi
570
+ sleep "$interval"
571
+ done
572
+ echo "no new messages on #$ch within ${timeout}s"; return 3
573
+ }
574
+
575
+ case "${1:-}" in
576
+ bind) shift; cmd_bind "$@" ;;
577
+ unbind) cmd_unbind ;;
578
+ status) cmd_status ;;
579
+ post) shift; cmd_post "$@" ;;
580
+ roster) cmd_roster ;;
581
+ threads) cmd_threads ;;
582
+ poll) cmd_poll ;;
583
+ listen) shift; cmd_listen "$@" ;;
584
+ *) sed -n '2,25p' "$0"; exit 2 ;;
585
+ esac