@indigoai-us/hq-cli 5.345.114 → 5.345.115

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