@indigoai-us/hq-cli 5.311.4 → 5.312.1

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 (96) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/assets/scaffold/core/scripts/lib/work-mesh-live-hook.sh +378 -0
  3. package/assets/scaffold/core/scripts/lib/work-mesh-live-rebind.sh +141 -0
  4. package/assets/scaffold/core/scripts/migrate-policy-triggers.sh +373 -0
  5. package/assets/scaffold/core/scripts/register-project.sh +262 -0
  6. package/assets/scaffold/core/scripts/work-mesh-live-rebind.sh +59 -0
  7. package/dist/command-catalog.generated.d.ts +160 -0
  8. package/dist/command-catalog.generated.js +200 -0
  9. package/dist/command-registration-plan.d.ts +6 -0
  10. package/dist/command-registration-plan.js +1 -0
  11. package/dist/commands/company-rename-flag.d.ts +4 -0
  12. package/dist/commands/company-rename-flag.js +7 -0
  13. package/dist/commands/company-rename.d.ts +4 -0
  14. package/dist/commands/company-rename.js +90 -0
  15. package/dist/commands/company.js +2 -0
  16. package/dist/commands/core-checkpoint-stop-gate-native.js +12 -0
  17. package/dist/commands/core-eval-trigger-native.d.ts +4 -0
  18. package/dist/commands/core-eval-trigger-native.js +12 -0
  19. package/dist/commands/core-migrate-policy-triggers-native.d.ts +21 -0
  20. package/dist/commands/core-migrate-policy-triggers-native.js +378 -0
  21. package/dist/commands/core-native-hook-helper-commands.d.ts +4 -0
  22. package/dist/commands/core-native-hook-helper-commands.js +56 -0
  23. package/dist/commands/core-native-hook-helpers-flag.d.ts +4 -0
  24. package/dist/commands/core-native-hook-helpers-flag.js +7 -0
  25. package/dist/commands/core-register-project-native.d.ts +30 -0
  26. package/dist/commands/core-register-project-native.js +347 -0
  27. package/dist/commands/core-work-mesh-live-rebind-native.d.ts +18 -0
  28. package/dist/commands/core-work-mesh-live-rebind-native.js +262 -0
  29. package/dist/commands/core.js +7 -0
  30. package/dist/commands/daemon.js +22 -0
  31. package/dist/commands/dm.js +22 -6
  32. package/dist/commands/files-browse.js +16 -4
  33. package/dist/commands/lanes-message.js +17 -2
  34. package/dist/commands/lanes-shared.d.ts +1 -0
  35. package/dist/commands/lanes.js +96 -20
  36. package/dist/commands/vault-retention-flag.d.ts +3 -0
  37. package/dist/commands/vault-retention-flag.js +9 -0
  38. package/dist/commands/vault-retention.d.ts +11 -0
  39. package/dist/commands/vault-retention.js +69 -0
  40. package/dist/fast-core.js +10 -3
  41. package/dist/fast-scaffold.d.ts +2 -0
  42. package/dist/fast-scaffold.js +11 -0
  43. package/dist/lib/agents/model-catalog.d.ts +3 -2
  44. package/dist/lib/company-skill-wrapper.d.ts +0 -8
  45. package/dist/lib/core-utils/codex-skill-bridge.d.ts +2 -1
  46. package/dist/lib/core-utils/sentry-report.js +21 -4
  47. package/dist/lib/daemon/catalog.d.ts +2 -0
  48. package/dist/lib/daemon/catalog.js +13 -1
  49. package/dist/lib/daemon/config.d.ts +2 -0
  50. package/dist/lib/daemon/config.js +4 -0
  51. package/dist/lib/daemon/run.d.ts +2 -0
  52. package/dist/lib/daemon/run.js +5 -0
  53. package/dist/lib/deploy-api.d.ts +0 -1
  54. package/dist/lib/deploy-api.js +1 -1
  55. package/dist/lib/doctor/checks/work-context.js +9 -1
  56. package/dist/lib/lanes/autopilot.d.ts +38 -1
  57. package/dist/lib/lanes/autopilot.js +353 -5
  58. package/dist/lib/lanes/flag-keys.d.ts +9 -1
  59. package/dist/lib/lanes/flag-keys.js +9 -1
  60. package/dist/lib/lanes/keep-alive.d.ts +2 -0
  61. package/dist/lib/lanes/keep-alive.js +2 -0
  62. package/dist/lib/lanes/reconcile.d.ts +5 -0
  63. package/dist/lib/lanes/reconcile.js +57 -4
  64. package/dist/lib/lanes/watcher-main.js +20 -1
  65. package/dist/lib/lanes/watcher.d.ts +9 -0
  66. package/dist/lib/lanes/watcher.js +47 -1
  67. package/dist/lib/mesh/client.js +23 -5
  68. package/dist/lib/mesh/denial-codes.d.ts +5 -0
  69. package/dist/lib/mesh/denial-codes.js +14 -0
  70. package/dist/lib/mesh/live/daemon/doctor.d.ts +2 -0
  71. package/dist/lib/mesh/live/daemon/doctor.js +11 -1
  72. package/dist/lib/report-run.d.ts +3 -2
  73. package/dist/lib/report-templates.d.ts +0 -1
  74. package/dist/lib/report-templates.js +0 -3
  75. package/dist/lib/scaffold-cli-floor.d.ts +0 -1
  76. package/dist/lib/scaffold-cli-floor.js +1 -1
  77. package/dist/lib/search-index/embed-lock.d.ts +2 -0
  78. package/dist/lib/search-index/embed-lock.js +10 -1
  79. package/dist/lib/work-context/contract.d.ts +3 -0
  80. package/dist/lib/work-context/contract.schema.json +3 -1
  81. package/dist/lib/work-context/outbox.d.ts +14 -5
  82. package/dist/lib/work-context/outbox.js +48 -5
  83. package/dist/lib/work-context/reconcile.js +26 -5
  84. package/dist/main.d.ts +20 -0
  85. package/dist/main.js +8 -1
  86. package/dist/run/discover-schemas.d.ts +2 -1
  87. package/dist/utils/core-eval-trigger.d.ts +4 -0
  88. package/dist/utils/core-eval-trigger.js +88 -0
  89. package/dist/utils/hq-roots.d.ts +0 -2
  90. package/dist/utils/hq-roots.js +1 -8
  91. package/dist/utils/incomplete-install-error.d.ts +7 -6
  92. package/dist/utils/large-file-guard.d.ts +0 -10
  93. package/dist/utils/large-file-guard.js +1 -1
  94. package/dist/utils/package-root-diagnostics.d.ts +1 -1
  95. package/dist/utils/self-update.d.ts +2 -1
  96. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,26 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.312.1] — 2026-10-02
6
+ - Fix checkpoint Stop hook-state creation and the jq-absent early exit to match the bundled shell.
7
+ - Add native `hq core eval-trigger`, `migrate-policy-triggers`, `register-project`, and `work-mesh-live-rebind` on the fast route behind the default-off `core-native-hook-helpers` flag.
8
+ - Prevent concurrent QMD reindex workers from replacing each other's lock owner record.
9
+ - Add a default-off `hq company rename <new name>` command to update a cloud-backed company's display name.
10
+ - Preserve HTTP status and safe server denial codes in work-mesh registration diagnostics.
11
+
12
+ ## [5.312.0] — 2026-10-02
13
+ - The desktop app can turn event-driven daemon sync on or off with its Instant Sync setting. The daemon stores the preference and restarts when a running daemon needs to apply it.
14
+ - `hq dm channel` now explains empty channels and displays a readable fallback for bodyless system notifications.
15
+
16
+ - Personal vault browsing now treats slash and dot paths as the root and normalizes leading slashes on nested paths.
17
+ - Personal and company file browsing normalize root markers before listing.
18
+ - Schedule the first eligible lead keep-alive check from the lane terminal watcher when the opt-in flags are enabled.
19
+ - Add default-off KPI freshness reporting and deduplicated re-measurement nudges to lane autopilot.
20
+ - Add company-scoped Outpost owner lookup for hostless lane ULIDs used by `hq lanes message`.
21
+ - Add opt-in autopilot nudges and operator escalation for missed lead deadlines.
22
+ - Report the bounded host BASH_ENV class for hook telemetry so HQ-CLI-2C startup timing can be distinguished accurately.
23
+ - Add gated `hq vault retention get|set` commands for the per-vault default retention period.
24
+
5
25
  ## [5.311.4] — 2026-10-02
6
26
  - Preserve existing local company starter files during cloud provisioning by having the CLI request that hq-pro skip blank starter seeding; older servers are detected before company creation.
7
27
 
@@ -0,0 +1,378 @@
1
+ #!/usr/bin/env bash
2
+ # hq-core: public
3
+ # work-mesh-live-hook.sh — shared helpers for Work Mesh Live enqueue-only hooks.
4
+ # Sourced by core/hooks/*/3*-work-mesh-*.sh. Never execute directly.
5
+
6
+ # work_mesh_live_disabled
7
+ # Exit 0 (true) when kill switches say these hooks must no-op.
8
+ work_mesh_live_disabled() {
9
+ case "${HQ_WORK_MESH_DISABLED:-}" in
10
+ 1|true|TRUE|yes|YES|on|ON) return 0 ;;
11
+ esac
12
+ case ",${HQ_DISABLED_HOOKS:-}," in
13
+ *,work-mesh,*|*,work-mesh-live,*|*,\*,*) return 0 ;;
14
+ esac
15
+ return 1
16
+ }
17
+
18
+ # work_mesh_live_bootstrap
19
+ # Resolve HQ_ROOT from the calling hook path; source enqueue + session-id.
20
+ # Caller must set _WM_HOOK_DIR to dirname of BASH_SOURCE[0] before calling,
21
+ # OR pass the hook file path as $1.
22
+ work_mesh_live_bootstrap() {
23
+ local hook_file="${1:-${BASH_SOURCE[1]:-}}"
24
+ local hook_dir hq
25
+ hook_dir="$(cd "$(dirname "$hook_file")" 2>/dev/null && pwd)" || return 1
26
+ # core/hooks/<Event>/<file> -> HQ root is ../../..
27
+ hq="${HQ_ROOT:-$(cd "$hook_dir/../../.." 2>/dev/null && pwd)}"
28
+ [ -n "$hq" ] || return 1
29
+ export HQ_ROOT="$hq"
30
+ # shellcheck source=core/scripts/lib/work-mesh-enqueue.sh
31
+ . "$HQ_ROOT/core/scripts/lib/work-mesh-enqueue.sh" 2>/dev/null || return 1
32
+ # shellcheck source=core/scripts/lib/session-id.sh
33
+ . "$HQ_ROOT/core/scripts/lib/session-id.sh" 2>/dev/null || true
34
+ return 0
35
+ }
36
+
37
+ # work_mesh_live_session_id <stdin-json>
38
+ # Prefer env (session_id_from_env); else payload session_id / sessionId.
39
+ work_mesh_live_session_id() {
40
+ local input="${1:-}" id=""
41
+ if command -v session_id_from_env >/dev/null 2>&1; then
42
+ id="$(session_id_from_env)"
43
+ fi
44
+ if [ -z "$id" ]; then
45
+ for var in HQ_SESSION_ID CLAUDE_CODE_SESSION_ID CLAUDE_SESSION_ID CODEX_SESSION_ID CODEX_THREAD_ID; do
46
+ id="${!var:-}"
47
+ id="$(printf '%s' "$id" | tr -d '[:space:]')"
48
+ [ -n "$id" ] && break
49
+ done
50
+ fi
51
+ if [ -z "$id" ] && [ -n "$input" ]; then
52
+ # Prefer jq when present; pure-bash fallback for the common keys.
53
+ if command -v jq >/dev/null 2>&1; then
54
+ id="$(printf '%s' "$input" | jq -r '.session_id // .sessionId // .conversation_id // .conversationId // .thread_id // .threadId // empty' 2>/dev/null || true)"
55
+ else
56
+ id="$(printf '%s' "$input" | sed -n 's/.*"session_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p; t; s/.*"sessionId"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)"
57
+ fi
58
+ id="$(printf '%s' "$id" | tr -d '[:space:]')"
59
+ fi
60
+ if command -v session_id_is_valid >/dev/null 2>&1; then
61
+ session_id_is_valid "$id" || id=""
62
+ else
63
+ case "$id" in
64
+ ""|.|..|*[!A-Za-z0-9._-]*) id="" ;;
65
+ esac
66
+ fi
67
+ printf '%s' "$id"
68
+ }
69
+
70
+ # work_mesh_live_harness
71
+ # HQ_HARNESS / HQ_WORK_MESH_HARNESS / HQ_CHECKPOINT_RUNTIME -> contract harness.
72
+ work_mesh_live_harness() {
73
+ local h="${HQ_HARNESS:-${HQ_WORK_MESH_HARNESS:-${HQ_CHECKPOINT_RUNTIME:-claude-code}}}"
74
+ case "$h" in
75
+ claude|claude-code|Claude|ClaudeCode) printf 'claude-code' ;;
76
+ codex|Codex) printf 'codex' ;;
77
+ grok|Grok) printf 'grok' ;;
78
+ hq-sessions|hq_sessions|sessions) printf 'hq-sessions' ;;
79
+ agent-box|agent_box|box) printf 'agent-box' ;;
80
+ *) printf '%s' "$h" ;;
81
+ esac
82
+ }
83
+
84
+ # work_mesh_live_adapter_version
85
+ work_mesh_live_adapter_version() {
86
+ if [ -n "${HQ_ADAPTER_CONTRACT_VERSION:-}" ]; then
87
+ printf '%s' "$HQ_ADAPTER_CONTRACT_VERSION"
88
+ return 0
89
+ fi
90
+ if [ -f "${HQ_ROOT:-}/core/scripts/lib/provider-adapter-version.sh" ]; then
91
+ # shellcheck source=core/scripts/lib/provider-adapter-version.sh
92
+ . "$HQ_ROOT/core/scripts/lib/provider-adapter-version.sh" 2>/dev/null || true
93
+ fi
94
+ printf '%s' "${HQ_ADAPTER_CONTRACT_VERSION:-1.0.0}"
95
+ }
96
+
97
+ # work_mesh_live_runtime_version
98
+ work_mesh_live_runtime_version() {
99
+ printf '%s' "${HQ_RUNTIME_VERSION:-${CLAUDE_CODE_VERSION:-${CODEX_VERSION:-${GROK_VERSION:-}}}}"
100
+ }
101
+
102
+ # work_mesh_live_next_seq <session-id>
103
+ # Monotonic seq under ~/.hq/work-mesh/seq/<sid> (or WORK_MESH_SEQ_DIR).
104
+ # Hot path: bash file read, no tr/chmod when the dir already exists.
105
+ work_mesh_live_next_seq() {
106
+ local sid="$1" dir file n
107
+ dir="${WORK_MESH_SEQ_DIR:-${HOME}/.hq/work-mesh/seq}"
108
+ if [ ! -d "$dir" ]; then
109
+ mkdir -p -- "$dir" 2>/dev/null || true
110
+ chmod 700 -- "$dir" 2>/dev/null || true
111
+ fi
112
+ file="$dir/$sid"
113
+ n=0
114
+ if [ -f "$file" ]; then
115
+ n="$(<"$file")"
116
+ n="${n//[[:space:]]/}"
117
+ case "$n" in
118
+ ""|*[!0-9]*) n=0 ;;
119
+ esac
120
+ fi
121
+ n=$((n + 1))
122
+ printf '%s\n' "$n" >"$file" 2>/dev/null || return 1
123
+ printf '%s' "$n"
124
+ }
125
+
126
+ # work_mesh_live_meta_get <root> <sid> <key>
127
+ work_mesh_live_meta_get() {
128
+ local root="$1" sid="$2" key="$3" meta
129
+ meta="$root/workspace/sessions/$sid/meta.yaml"
130
+ [ -f "$meta" ] || return 0
131
+ awk -v k="$key" '$1==k":"{ sub(/^[^:]+:[[:space:]]*/,""); gsub(/^"|"$/,""); print; exit }' "$meta" 2>/dev/null
132
+ }
133
+
134
+ # work_mesh_live_state_path <sid>
135
+ work_mesh_live_state_path() {
136
+ printf '%s/.hq/work-context/sessions/%s.json' "${WORK_MESH_HOME:-$HOME}" "$1"
137
+ }
138
+
139
+ # work_mesh_live_board_path <sid>
140
+ work_mesh_live_board_path() {
141
+ printf '%s/.hq/work-context/sessions/%s/board.md' "${WORK_MESH_HOME:-$HOME}" "$1"
142
+ }
143
+
144
+ # work_mesh_live_ask_surfaced_path <sid>
145
+ work_mesh_live_ask_surfaced_path() {
146
+ printf '%s/.hq/work-context/sessions/%s.ask-surfaced' "${WORK_MESH_HOME:-$HOME}" "$1"
147
+ }
148
+
149
+ # work_mesh_live_pending_decision_path <sid>
150
+ work_mesh_live_pending_decision_path() {
151
+ printf '%s/.hq/work-context/sessions/%s.pending-decision' "${WORK_MESH_HOME:-$HOME}" "$1"
152
+ }
153
+
154
+ # work_mesh_live_word_count <text> — count whitespace-separated tokens.
155
+ work_mesh_live_word_count() {
156
+ local t="$1"
157
+ # shellcheck disable=SC2086
158
+ set -- $t
159
+ printf '%s' "$#"
160
+ }
161
+
162
+ # work_mesh_live_is_slash_or_short <prompt>
163
+ # True (0) when slash command or fewer than 3 words.
164
+ work_mesh_live_is_slash_or_short() {
165
+ local p="$1" n
166
+ case "$p" in
167
+ /*) return 0 ;;
168
+ esac
169
+ n="$(work_mesh_live_word_count "$p")"
170
+ [ "$n" -lt 3 ]
171
+ }
172
+
173
+ # work_mesh_live_bump_tool_writes <sid>
174
+ # Atomically increment toolWrites on the state JSON (create stub if absent).
175
+ # Sets REPLY to the new count. Uses temp+mv and chmod 600.
176
+ # Merge-safe: only toolWrites and updatedAt change; every other field the
177
+ # hq-cli reconcile/ack path wrote (companyUid, companySlug, projectId, taskId,
178
+ # startedAt, bindingEpisodeId, decision, contextStatus, ...) is preserved
179
+ # byte-for-byte. Prefers jq; the no-jq fallback edits the two values in place
180
+ # in pure bash (compact or pretty-printed JSON, any key order) and only
181
+ # appends a missing key. contextStatus is set to "unresolved" only on a fresh
182
+ # stub. Bash 3.2 compatible; no sed/awk/GNU flags.
183
+ # Concurrency: hq-cli writes the same path with same-dir tmp + rename and has
184
+ # no lock convention (hq-cli src/lib/work-context/atomic.ts). Two lines of
185
+ # defense against a reconcile write landing between our read and mv:
186
+ # 1. mkdir lock at <path>.lock (Bash 3.2 safe), bounded wait ~200ms, stale
187
+ # lock reclaimed by age (>5s). If the lock cannot be taken, the bump is
188
+ # skipped (return 1) rather than risk a clobber.
189
+ # 2. Re-read immediately before mv; if the file changed since the first
190
+ # read, rebuild from the new body.
191
+ # Test seam: WORK_MESH_BUMP_TEST_SLEEP_AFTER_READ=<secs> sleeps between the
192
+ # first read and the re-read so a test can inject a concurrent writer.
193
+ work_mesh_live_bump_tool_writes() {
194
+ local sid="$1" path dir lock rc
195
+ path="$(work_mesh_live_state_path "$sid")"
196
+ dir="${path%/*}"
197
+ if [ ! -d "$dir" ]; then
198
+ mkdir -p -- "$dir" 2>/dev/null || true
199
+ chmod 700 -- "$dir" 2>/dev/null || true
200
+ fi
201
+ lock="$path.lock"
202
+ _work_mesh_live_lock_acquire "$lock" || return 1
203
+ _work_mesh_live_bump_locked "$sid" "$path"
204
+ rc=$?
205
+ rm -rf -- "$lock" 2>/dev/null || true
206
+ return $rc
207
+ }
208
+
209
+ # _work_mesh_live_lock_acquire <lockdir>
210
+ # mkdir lock; ~20 x 10ms bounded wait; reclaim when the holder's stamp is
211
+ # older than 5s (or missing after the full wait). 0 = held, 1 = give up.
212
+ _work_mesh_live_lock_acquire() {
213
+ local lock="$1" i=0 now stamp
214
+ while :; do
215
+ if mkdir -- "$lock" 2>/dev/null; then
216
+ date +%s >"$lock/ts" 2>/dev/null || true
217
+ return 0
218
+ fi
219
+ i=$((i + 1))
220
+ if [ "$i" -ge 20 ]; then
221
+ now="$(date +%s 2>/dev/null || printf '0')"
222
+ stamp="$(cat "$lock/ts" 2>/dev/null || printf '')"
223
+ case "$stamp" in ""|*[!0-9]*) stamp=0 ;; esac
224
+ if [ "$stamp" -eq 0 ] || [ $((now - stamp)) -gt 5 ]; then
225
+ rm -rf -- "$lock" 2>/dev/null || true
226
+ if mkdir -- "$lock" 2>/dev/null; then
227
+ date +%s >"$lock/ts" 2>/dev/null || true
228
+ return 0
229
+ fi
230
+ fi
231
+ return 1
232
+ fi
233
+ sleep 0.01 2>/dev/null || sleep 1
234
+ done
235
+ }
236
+
237
+ # _work_mesh_live_bump_render <sid> <path> <tmp>
238
+ # Read <path> (if any), build the bumped document into <tmp>.
239
+ # Sets _WM_SEEN to the body that was read ("" when absent) and REPLY to the
240
+ # new count. Returns 1 when the file must not be touched.
241
+ _work_mesh_live_bump_render() {
242
+ local sid="$1" path="$2" tmp="$3" cur="" next ts body="" rest have_jq=0 written=0
243
+ _WM_SEEN=""
244
+ command -v jq >/dev/null 2>&1 && have_jq=1
245
+ if [ -f "$path" ]; then
246
+ body="$(<"$path")"
247
+ _WM_SEEN="$body"
248
+ case "$body" in
249
+ *"\"toolWrites\""*)
250
+ rest="${body#*\"toolWrites\"}"; rest="${rest#*:}"
251
+ while [ "${rest#"${rest%%[![:space:]]*}"}" != "$rest" ]; do rest="${rest#?}"; done
252
+ cur="${rest%%[!0-9]*}"
253
+ ;;
254
+ esac
255
+ fi
256
+ case "$cur" in ""|*[!0-9]*) cur=0 ;; esac
257
+ next=$((cur + 1))
258
+ ts=""
259
+ # %(...)T needs Bash 4.2+; macOS Bash 3.2 falls back to date -u (BSD + GNU).
260
+ TZ=UTC printf -v ts '%(%Y-%m-%dT%H:%M:%S)T.000Z' -1 2>/dev/null || ts=""
261
+ case "$ts" in
262
+ ""|*%*) ts="$(TZ=UTC date -u '+%Y-%m-%dT%H:%M:%S.000Z' 2>/dev/null || true)" ;;
263
+ esac
264
+ [ -n "$ts" ] || ts="1970-01-01T00:00:00.000Z"
265
+ if [ ! -f "$path" ]; then
266
+ printf '{"contractVersion":1,"sessionId":"%s","contextStatus":"unresolved","toolWrites":%s,"updatedAt":"%s"}\n' \
267
+ "$sid" "$next" "$ts" >"$tmp" || return 1
268
+ REPLY="$next"
269
+ return 0
270
+ fi
271
+ if [ "$have_jq" -eq 1 ]; then
272
+ if printf '%s' "$body" | jq --argjson n "$next" --arg ts "$ts" \
273
+ '.toolWrites = $n | .updatedAt = $ts' >"$tmp" 2>/dev/null \
274
+ && [ -s "$tmp" ]; then
275
+ written=1
276
+ elif jq -n empty >/dev/null 2>&1; then
277
+ # jq works but could not parse the file (malformed/partial JSON).
278
+ # Never replace the CLI's file with a stub: leave it untouched and skip.
279
+ rm -f -- "$tmp" 2>/dev/null
280
+ return 1
281
+ fi
282
+ # else: jq on PATH is unusable; take the pure-bash path below.
283
+ fi
284
+ if [ "$written" -eq 0 ]; then
285
+ # No-jq fallback: pure-bash in-place edit of the two values (no extra
286
+ # spawns, Bash 3.2 safe). Works for compact and pretty JSON in any key
287
+ # order; every other byte of the CLI's file is left as is.
288
+ local pre digits add
289
+ case "$body" in
290
+ *"\"toolWrites\""*)
291
+ pre="${body%%\"toolWrites\"*}"
292
+ rest="${body#*\"toolWrites\"}"
293
+ while [ "${rest#"${rest%%[![:space:]:]*}"}" != "$rest" ]; do rest="${rest#?}"; done
294
+ digits="${rest%%[!0-9]*}"
295
+ rest="${rest#"$digits"}"
296
+ body="$pre\"toolWrites\": $next$rest"
297
+ ;;
298
+ esac
299
+ case "$body" in
300
+ *"\"updatedAt\""*)
301
+ pre="${body%%\"updatedAt\"*}"
302
+ rest="${body#*\"updatedAt\"}"
303
+ while [ "${rest#"${rest%%[![:space:]:]*}"}" != "$rest" ]; do rest="${rest#?}"; done
304
+ case "$rest" in
305
+ \"*) rest="${rest#\"}"; rest="${rest#*\"}" ;;
306
+ esac
307
+ body="$pre\"updatedAt\": \"$ts\"$rest"
308
+ ;;
309
+ esac
310
+ add=""
311
+ case "$body" in *"\"toolWrites\""*) ;; *) add="\"toolWrites\": $next" ;; esac
312
+ case "$body" in *"\"updatedAt\""*) ;; *) add="${add:+$add, }\"updatedAt\": \"$ts\"" ;; esac
313
+ if [ -n "$add" ]; then
314
+ # Insert right after the opening brace so the closing brace stays put.
315
+ case "$body" in
316
+ *\{*)
317
+ pre="${body%%\{*}"
318
+ rest="${body#*\{}"
319
+ local peek="$rest"
320
+ while [ "${peek#"${peek%%[![:space:]]*}"}" != "$peek" ]; do peek="${peek#?}"; done
321
+ case "$peek" in
322
+ \}*) body="$pre{$add$peek" ;;
323
+ *) body="$pre{$add, $rest" ;;
324
+ esac
325
+ ;;
326
+ *) body="{$add}" ;;
327
+ esac
328
+ fi
329
+ if printf '%s\n' "$body" >"$tmp" 2>/dev/null && [ -s "$tmp" ]; then
330
+ written=1
331
+ fi
332
+ fi
333
+ if [ "$written" -eq 0 ]; then
334
+ rm -f -- "$tmp" 2>/dev/null
335
+ return 1
336
+ fi
337
+ REPLY="$next"
338
+ return 0
339
+ }
340
+
341
+ # _work_mesh_live_bump_locked <sid> <path> (caller holds the lock)
342
+ _work_mesh_live_bump_locked() {
343
+ local sid="$1" path="$2" tmp seen again
344
+ tmp="$path.tmp.$$"
345
+ _work_mesh_live_bump_render "$sid" "$path" "$tmp" || return 1
346
+ seen="$_WM_SEEN"
347
+ if [ -n "${WORK_MESH_BUMP_TEST_SLEEP_AFTER_READ:-}" ]; then
348
+ sleep "$WORK_MESH_BUMP_TEST_SLEEP_AFTER_READ" 2>/dev/null || true
349
+ fi
350
+ # Second line of defense: re-read right before mv; if a concurrent writer
351
+ # (hq-cli reconcile/ack) changed the file since our read, rebuild from it.
352
+ again=""
353
+ [ -f "$path" ] && again="$(<"$path")"
354
+ if [ "$again" != "$seen" ]; then
355
+ rm -f -- "$tmp" 2>/dev/null
356
+ _work_mesh_live_bump_render "$sid" "$path" "$tmp" || return 1
357
+ fi
358
+ mv -f -- "$tmp" "$path" 2>/dev/null || { rm -f -- "$tmp"; return 1; }
359
+ chmod 600 -- "$path" 2>/dev/null || true
360
+ return 0
361
+ }
362
+
363
+ # work_mesh_live_is_file_write_tool <tool_name> <bash_command?>
364
+ work_mesh_live_is_file_write_tool() {
365
+ local tool="$1" cmd="${2:-}"
366
+ case "$tool" in
367
+ Edit|Write|MultiEdit|NotebookEdit|apply_patch|StrReplace|search_replace|write) return 0 ;;
368
+ Bash|Shell|run_terminal_command)
369
+ # Redirect / common file-mutating bash forms (no nested quotes in case arms).
370
+ case "$cmd" in
371
+ *\>*|*tee\ *|*mv\ *|*cp\ *|*truncate\ *) return 0 ;;
372
+ *sed\ -i*|*dd\ if=*) return 0 ;;
373
+ *) return 1 ;;
374
+ esac
375
+ ;;
376
+ *) return 1 ;;
377
+ esac
378
+ }
@@ -0,0 +1,141 @@
1
+ #!/usr/bin/env bash
2
+ # hq-core: public
3
+ # US-011 — mid-session rebind: session_end (old) + session_start (new) in spool.
4
+ # Sourced by hooks/scripts. Never execute directly.
5
+
6
+ # work_mesh_live_binding_marker_path <sid>
7
+ work_mesh_live_binding_marker_path() {
8
+ printf '%s/.hq/work-context/sessions/%s.live-binding' "${WORK_MESH_HOME:-$HOME}" "$1"
9
+ }
10
+
11
+ # work_mesh_live_read_binding_marker <sid>
12
+ # Sets REPLY to "company|project|task" (may be empty fields).
13
+ work_mesh_live_read_binding_marker() {
14
+ local sid="$1" path body
15
+ path="$(work_mesh_live_binding_marker_path "$sid")"
16
+ REPLY="||"
17
+ [ -f "$path" ] || return 0
18
+ body="$(tr -d '\r' <"$path" 2>/dev/null || true)"
19
+ # companySlug=.. project=.. task=..
20
+ local co="" pr="" tk=""
21
+ co="$(printf '%s\n' "$body" | awk -F= '/^companySlug=/{print $2; exit}')"
22
+ pr="$(printf '%s\n' "$body" | awk -F= '/^project=/{print $2; exit}')"
23
+ tk="$(printf '%s\n' "$body" | awk -F= '/^task=/{print $2; exit}')"
24
+ REPLY="${co}|${pr}|${tk}"
25
+ }
26
+
27
+ # work_mesh_live_write_binding_marker <sid> <company> <project> <task>
28
+ work_mesh_live_write_binding_marker() {
29
+ local sid="$1" co="${2:-}" pr="${3:-}" tk="${4:-}" path dir
30
+ path="$(work_mesh_live_binding_marker_path "$sid")"
31
+ dir="${path%/*}"
32
+ mkdir -p -- "$dir" 2>/dev/null || true
33
+ chmod 700 -- "$dir" 2>/dev/null || true
34
+ printf 'companySlug=%s\nproject=%s\ntask=%s\n' "$co" "$pr" "$tk" >"$path"
35
+ chmod 600 -- "$path" 2>/dev/null || true
36
+ }
37
+
38
+ # work_mesh_live_enqueue_lifecycle <kind> <sid> <harness> <adapter> <company> <project> <task>
39
+ work_mesh_live_enqueue_lifecycle() {
40
+ local kind="$1" sid="$2" harness="$3" adapter="$4"
41
+ local company="${5:-}" project="${6:-}" task="${7:-}"
42
+ local seq
43
+ if ! command -v work_mesh_enqueue >/dev/null 2>&1; then
44
+ return 1
45
+ fi
46
+ if command -v work_mesh_live_next_seq >/dev/null 2>&1; then
47
+ seq="$(work_mesh_live_next_seq "$sid")" || return 1
48
+ else
49
+ seq=1
50
+ fi
51
+ local args=(
52
+ --kind "$kind"
53
+ --session-id "$sid"
54
+ --harness "$harness"
55
+ --adapter-version "$adapter"
56
+ --seq "$seq"
57
+ --cwd "${PWD:-}"
58
+ --hq-root "${HQ_ROOT:-}"
59
+ )
60
+ [ -n "$company" ] && args+=(--company-slug "$company")
61
+ [ -n "$project" ] && args+=(--project "$project")
62
+ [ -n "$task" ] && args+=(--task "$task")
63
+ work_mesh_enqueue "${args[@]}" || true
64
+ }
65
+
66
+ # work_mesh_live_rebind <sid> <old_co> <old_pr> <old_tk> <new_co> <new_pr> <new_tk>
67
+ # Always appends session_end then session_start when old project differs from new
68
+ # and new project is non-empty. Updates the live-binding marker to the new binding.
69
+ work_mesh_live_rebind() {
70
+ local sid="$1"
71
+ local old_co="${2:-}" old_pr="${3:-}" old_tk="${4:-}"
72
+ local new_co="${5:-}" new_pr="${6:-}" new_tk="${7:-}"
73
+ local harness adapter
74
+ [ -n "$sid" ] || return 1
75
+ [ -n "$new_pr" ] || return 1
76
+ [ "$old_pr" != "$new_pr" ] || {
77
+ work_mesh_live_write_binding_marker "$sid" "$new_co" "$new_pr" "$new_tk"
78
+ return 0
79
+ }
80
+
81
+ harness="${HQ_HARNESS:-${HQ_WORK_MESH_HARNESS:-${HQ_CHECKPOINT_RUNTIME:-claude-code}}}"
82
+ case "$harness" in
83
+ claude|Claude|ClaudeCode) harness=claude-code ;;
84
+ Codex) harness=codex ;;
85
+ Grok) harness=grok ;;
86
+ esac
87
+ adapter="${HQ_ADAPTER_CONTRACT_VERSION:-1.0.0}"
88
+
89
+ # Event order: session_end then session_start. Each call takes the next seq from
90
+ # the same per-session counter, so session_start always has a strictly higher
91
+ # seq than session_end. The server REOPENs a session when a session_start with
92
+ # a higher seq and a new binding follows the session_end.
93
+ if [ -n "$old_pr" ]; then
94
+ work_mesh_live_enqueue_lifecycle session_end "$sid" "$harness" "$adapter" \
95
+ "$old_co" "$old_pr" "$old_tk"
96
+ fi
97
+ work_mesh_live_enqueue_lifecycle session_start "$sid" "$harness" "$adapter" \
98
+ "$new_co" "$new_pr" "$new_tk"
99
+ work_mesh_live_write_binding_marker "$sid" "$new_co" "$new_pr" "$new_tk"
100
+ return 0
101
+ }
102
+
103
+ # work_mesh_live_maybe_rebind_from_state <sid>
104
+ # Compare state file projectId to live-binding marker; rebind if changed.
105
+ work_mesh_live_maybe_rebind_from_state() {
106
+ local sid="$1" state co pr tk old old_co old_pr old_tk
107
+ local wc="${WORK_MESH_HOME:-$HOME}"
108
+ state="$wc/.hq/work-context/sessions/$sid.json"
109
+ [ -f "$state" ] || return 0
110
+
111
+ if command -v jq >/dev/null 2>&1; then
112
+ co="$(jq -r '.companySlug // empty' "$state" 2>/dev/null || true)"
113
+ pr="$(jq -r '.projectId // empty' "$state" 2>/dev/null || true)"
114
+ tk="$(jq -r '.taskId // empty' "$state" 2>/dev/null || true)"
115
+ else
116
+ return 0
117
+ fi
118
+ [ -n "$pr" ] || return 0
119
+
120
+ work_mesh_live_read_binding_marker "$sid"
121
+ old=$REPLY
122
+ old_co="${old%%|*}"
123
+ rest="${old#*|}"
124
+ old_pr="${rest%%|*}"
125
+ old_tk="${rest#*|}"
126
+
127
+ if [ -z "$old_pr" ]; then
128
+ # First bind observed — record marker without closing a card (session_start
129
+ # already fired at SessionStart). Still emit session_start when marker empty
130
+ # but only if organize rebound after an unbound start: treat empty old as
131
+ # "no prior card" and only update marker unless caller forces rebind.
132
+ work_mesh_live_write_binding_marker "$sid" "$co" "$pr" "$tk"
133
+ return 0
134
+ fi
135
+ if [ "$old_pr" = "$pr" ]; then
136
+ # Same project — refresh task/company on marker.
137
+ work_mesh_live_write_binding_marker "$sid" "$co" "$pr" "$tk"
138
+ return 0
139
+ fi
140
+ work_mesh_live_rebind "$sid" "$old_co" "$old_pr" "$old_tk" "$co" "$pr" "$tk"
141
+ }