@indigoai-us/hq-cli 5.289.0 → 5.290.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.
@@ -0,0 +1,670 @@
1
+ #!/bin/bash
2
+ # handoff-finalize.sh — collapse the mechanical tail of /handoff into one call.
3
+ #
4
+ # Folds steps 1/2/3b/4/5/7 of the handoff skill into a single subprocess so the
5
+ # model doesn't narrate each step. Writes the thread file, handoff.json,
6
+ # regenerates INDEX files, commits HQ via explicit paths, and launches qmd
7
+ # reindex fire-and-forget via qmd-reindex-bg.sh (agent skip / laptop single-flight).
8
+ #
9
+ # Usage (all args optional):
10
+ # handoff-finalize.sh \
11
+ # --title "Handoff: brief description" \
12
+ # --summary "What was accomplished" \
13
+ # --message "User message or autogenerated" \
14
+ # --next-steps-json '["step 1","step 2"]' \
15
+ # --files-touched-json '["path1","path2"]' \
16
+ # --files-touched-json-file workspace/threads/.changeset.json \
17
+ # --learnings-json '[]' \
18
+ # --tags-json '["handoff"]' \
19
+ # --slug "handoff-short-tag" \
20
+ # --next-command "/startwork"
21
+ #
22
+ # The changeset (files-touched) may be supplied inline via --files-touched-json
23
+ # OR from a file via --files-touched-json-file <path>. Prefer the file form for
24
+ # large changesets: on Windows Git Bash, CreateProcess caps a command line at
25
+ # ~32KB (~8KB under cmd.exe), so a big inline value would overflow argv as it
26
+ # rides through hq-status-summary.sh and jq. The file form keeps the giant JSON
27
+ # off argv at every hop. --files-touched-json-file wins if both are given; a
28
+ # missing file path is a hard error (exit 3). Relative paths resolve against the
29
+ # HQ root.
30
+ #
31
+ # --next-command is copied to the user's clipboard fail-soft (pbcopy /
32
+ # wl-copy / xclip) so the follow-up command is ready to paste into a fresh
33
+ # session. Defaults to "/resumework {thread_id}". Pass "" to skip the copy.
34
+ #
35
+ # Outputs JSON on stdout:
36
+ # {"thread_id":"...","thread_path":"...","handoff_path":"...",
37
+ # "hq_committed":true,"hq_commit_status":"committed","hq_commit_error":"",
38
+ # "committed_paths":[...],"stage_failures":[],"indexes_regen":true,
39
+ # "qmd_pid":12345,"next_command":"/resumework T-...","clipboard_copied":true}
40
+ #
41
+ # hq_commit_status is the honest reason behind hq_committed:
42
+ # committed — the handoff commit landed; committed_paths lists it
43
+ # nothing-to-commit — no staged change existed; hq_committed:false, exit 0
44
+ # failed — git refused the commit; committed_paths is [],
45
+ # hq_commit_error carries git's message, exit 4
46
+ # stage-failed — git add rejected one or more paths, so the handoff is
47
+ # incomplete even if a commit landed for the rest; exit 4.
48
+ # This is the shape a held .git/index.lock takes.
49
+ # stage_failures lists paths git add rejected for a reason other than gitignore.
50
+ #
51
+ # Exit codes: 0 ok · 2 bad argument · 3 changeset file missing · 4 handoff not
52
+ # durable (commit refused or paths unstageable).
53
+
54
+ set -euo pipefail
55
+
56
+ HQ_ROOT="${HQ_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}"
57
+ cd "$HQ_ROOT"
58
+ HQ_ROOT="$(pwd -P)"
59
+ HAS_GIT_CHECKOUT="false"
60
+ COMMON_GIT_DIR=""
61
+ GIT_PROBE_OUTPUT=""
62
+ if GIT_PROBE_OUTPUT="$(LC_ALL=C git rev-parse --git-common-dir 2>&1)"; then
63
+ COMMON_GIT_DIR="$GIT_PROBE_OUTPUT"
64
+ HAS_GIT_CHECKOUT="true"
65
+ else
66
+ GIT_PROBE_STATUS=$?
67
+ if [[ ! -e "$HQ_ROOT/.git" && ! -L "$HQ_ROOT/.git" && \
68
+ "$GIT_PROBE_OUTPUT" == *"not a git repository"* ]]; then
69
+ COMMON_GIT_DIR=""
70
+ else
71
+ printf 'handoff-finalize: Git checkout probe failed (exit %s): %s\n' \
72
+ "$GIT_PROBE_STATUS" "$GIT_PROBE_OUTPUT" >&2
73
+ exit 4
74
+ fi
75
+ fi
76
+
77
+ # -------- args --------
78
+ TITLE="Handoff: session continuation"
79
+ SUMMARY=""
80
+ MESSAGE=""
81
+ NEXT_STEPS_JSON="[]"
82
+ FILES_TOUCHED_JSON="[]"
83
+ FILES_TOUCHED_JSON_FILE=""
84
+ LEARNINGS_JSON="[]"
85
+ TAGS_JSON='["handoff"]'
86
+ SLUG="handoff"
87
+ NEXT_COMMAND="__auto__"
88
+
89
+ while [[ $# -gt 0 ]]; do
90
+ case "$1" in
91
+ --title) TITLE="$2"; shift 2 ;;
92
+ --summary) SUMMARY="$2"; shift 2 ;;
93
+ --message) MESSAGE="$2"; shift 2 ;;
94
+ --next-steps-json) NEXT_STEPS_JSON="$2"; shift 2 ;;
95
+ --files-touched-json) FILES_TOUCHED_JSON="$2"; shift 2 ;;
96
+ --files-touched-json-file) FILES_TOUCHED_JSON_FILE="$2"; shift 2 ;;
97
+ --learnings-json) LEARNINGS_JSON="$2"; shift 2 ;;
98
+ --tags-json) TAGS_JSON="$2"; shift 2 ;;
99
+ --slug) SLUG="$2"; shift 2 ;;
100
+ --next-command) NEXT_COMMAND="$2"; shift 2 ;;
101
+ *) echo "Unknown arg: $1" >&2; exit 2 ;;
102
+ esac
103
+ done
104
+
105
+ # -------- temp-file registry for off-argv JSON --------
106
+ # The Windows argv-overflow fix: any JSON value that scales with the changeset
107
+ # (the changeset itself, and the staged/skipped/committed path lists and status
108
+ # summary derived from it) is written to a temp file and fed to jq via
109
+ # --slurpfile, never via --argjson/--arg on the command line. CreateProcess caps
110
+ # a command line at ~32KB on Git Bash; even POSIX hosts cap a single argument at
111
+ # 128KB (MAX_ARG_STRLEN). A few-thousand-path changeset breaches both. Small,
112
+ # bounded values (title, ids, tags, company slugs, counts) stay inline.
113
+ #
114
+ # Temps live under workspace/threads/.handoff-tmp/ (gitignored) — not beside
115
+ # durable thread JSON — so they never flip git.dirty or baseline_noise before
116
+ # the status-summary call. Do not use /tmp (missing on some Windows Git Bash).
117
+ HANDOFF_TMP_DIR="workspace/threads/.handoff-tmp"
118
+ mkdir -p workspace/threads "$HANDOFF_TMP_DIR"
119
+ FINALIZE_TMP_FILES=()
120
+ cleanup_finalize_tmp() {
121
+ local f
122
+ for f in "${FINALIZE_TMP_FILES[@]:-}"; do
123
+ [[ -n "$f" ]] && rm -f "$f"
124
+ done
125
+ return 0
126
+ }
127
+ trap cleanup_finalize_tmp EXIT
128
+
129
+ # new_tmp NAME: create a registered temp file under .handoff-tmp/ (Git Bash safe;
130
+ # not /tmp) and assign its absolute path to NAME in the *current* shell.
131
+ # Callers must not use VAR=$(new_tmp) — command substitution runs in a subshell,
132
+ # so FINALIZE_TMP_FILES+= would never reach the EXIT trap and temps would leak.
133
+ new_tmp() {
134
+ local __outvar="$1"
135
+ local t
136
+ [[ -n "$__outvar" ]] || { echo "handoff-finalize: new_tmp requires a varname" >&2; exit 3; }
137
+ t=$(mktemp "$HANDOFF_TMP_DIR/.handoff-XXXXXX")
138
+ case "$t" in /*) : ;; *) t="$PWD/$t" ;; esac
139
+ FINALIZE_TMP_FILES+=("$t")
140
+ printf -v "$__outvar" '%s' "$t"
141
+ }
142
+
143
+ # -------- resolve changeset source to a single on-disk file --------
144
+ # Every downstream consumer of the changeset (path extraction, hq-status-summary,
145
+ # and the two jq emitters) reads it from FILES_TOUCHED_FILE, never from argv.
146
+ # --files-touched-json-file <path> wins over inline --files-touched-json; the
147
+ # inline form is materialized to a temp file so the read path is uniform.
148
+ if [[ -n "$FILES_TOUCHED_JSON_FILE" ]]; then
149
+ if [[ ! -f "$FILES_TOUCHED_JSON_FILE" ]]; then
150
+ echo "handoff-finalize: --files-touched-json-file not found: $FILES_TOUCHED_JSON_FILE" >&2
151
+ exit 3
152
+ fi
153
+ FILES_TOUCHED_FILE="$FILES_TOUCHED_JSON_FILE"
154
+ case "$FILES_TOUCHED_FILE" in
155
+ /*) : ;;
156
+ *) FILES_TOUCHED_FILE="$PWD/$FILES_TOUCHED_FILE" ;;
157
+ esac
158
+ else
159
+ new_tmp FILES_TOUCHED_FILE
160
+ printf '%s' "$FILES_TOUCHED_JSON" > "$FILES_TOUCHED_FILE"
161
+ fi
162
+
163
+ # -------- changeset path extraction / validation --------
164
+ # Skipped paths accumulate as NDJSON on disk from the first record_skip — never
165
+ # rebuild a growing JSON document via jq argv/here-string (thousands of skips
166
+ # would hit Windows CreateProcess / Linux MAX_ARG_STRLEN before emit).
167
+ new_tmp SKIPPED_NDJSON_FILE
168
+ : > "$SKIPPED_NDJSON_FILE"
169
+ new_tmp SKIPPED_PATHS_FILE
170
+ printf '[]' > "$SKIPPED_PATHS_FILE"
171
+ SAFE_STAGE_PATHS=()
172
+ MIRROR_COMPANIES_JSON="[]"
173
+ MIRRORED_COMPANIES=()
174
+ MIRRORED_COMPANIES_JSON="[]"
175
+ MIRROR_STAGE_PATHS=()
176
+
177
+ record_skip() {
178
+ local path="$1"
179
+ local reason="$2"
180
+ jq -nc --arg path "$path" --arg reason "$reason" \
181
+ '{path: $path, reason: $reason}' >> "$SKIPPED_NDJSON_FILE"
182
+ }
183
+
184
+ is_sensitive_path() {
185
+ local p="$1"
186
+ case "$p" in
187
+ core/settings/.gitkeep|core/settings/pure-ralph.json)
188
+ return 1
189
+ ;;
190
+ .git|.git/*|.env|.env.*|*.credentials.json|*.secret.*|.hq/deploy-passwords.json|.hq/cognito-tokens.json|.claude/settings.local.json|.claude/settings.local.*|companies/*/settings/*|settings/*.json|settings/**/*.json|core/settings/*.json|core/settings/**/*.json)
191
+ return 0
192
+ ;;
193
+ *)
194
+ return 1
195
+ ;;
196
+ esac
197
+ }
198
+
199
+ normalize_rel_path() {
200
+ local raw="$1"
201
+ local rel="$raw"
202
+ if [[ "$raw" == "$HQ_ROOT/"* ]]; then
203
+ rel="${raw#"$HQ_ROOT/"}"
204
+ elif [[ "$raw" == /* ]]; then
205
+ return 1
206
+ fi
207
+ rel="${rel#./}"
208
+ [[ -z "$rel" ]] && return 1
209
+ [[ "$rel" == -* || "$rel" == ".." || "$rel" == ../* || "$rel" == */../* || "$rel" == .git || "$rel" == .git/* ]] && return 1
210
+ printf '%s\n' "$rel"
211
+ }
212
+
213
+ while IFS=$'\t' read -r raw_path deleted_flag; do
214
+ [[ -z "${raw_path:-}" ]] && continue
215
+ if ! rel_path=$(normalize_rel_path "$raw_path"); then
216
+ record_skip "$raw_path" "unsafe-path"
217
+ continue
218
+ fi
219
+ if is_sensitive_path "$rel_path"; then
220
+ record_skip "$rel_path" "sensitive-path"
221
+ continue
222
+ fi
223
+ if [[ ! -e "$rel_path" ]]; then
224
+ if [[ "$HAS_GIT_CHECKOUT" == "true" && "$deleted_flag" == "true" ]] \
225
+ && git ls-files --error-unmatch "$rel_path" >/dev/null 2>&1; then
226
+ SAFE_STAGE_PATHS+=("$rel_path")
227
+ else
228
+ record_skip "$rel_path" "missing"
229
+ fi
230
+ continue
231
+ fi
232
+ SAFE_STAGE_PATHS+=("$rel_path")
233
+ done < <(
234
+ jq -r '
235
+ if type != "array" then empty else
236
+ .[] |
237
+ if type == "string" then
238
+ [., "false"]
239
+ else
240
+ [(.path // ""), (((.deleted == true) or (.status == "deleted")) | tostring)]
241
+ end | @tsv
242
+ end
243
+ ' "$FILES_TOUCHED_FILE" 2>/dev/null || true
244
+ )
245
+
246
+ # Company workspace mirrors are scoped from the already-validated changeset,
247
+ # never from ambient repository state. A handoff can legitimately touch more
248
+ # than one company, so preserve all unique slugs in durable thread metadata.
249
+ MIRROR_COMPANIES_JSON=$(printf '%s\n' "${SAFE_STAGE_PATHS[@]:-}" | jq -R -s -c '
250
+ split("\n")
251
+ | [ .[] | select(length > 0) | try (capture("^companies/(?<company>[a-z][a-z0-9_-]*)/").company) ]
252
+ | unique
253
+ ' 2>/dev/null || printf '[]')
254
+
255
+ # -------- bg git reap (if launched by caller) --------
256
+ GIT_BG_ERRORS=""
257
+ if [[ -f /tmp/handoff-git-bg.pid ]]; then
258
+ PID=$(cat /tmp/handoff-git-bg.pid 2>/dev/null || true)
259
+ if [[ -n "$PID" ]]; then
260
+ wait "$PID" 2>/dev/null || true
261
+ if [[ -f /tmp/handoff-git-bg.log ]]; then
262
+ GIT_BG_ERRORS=$(grep '^ERR:' /tmp/handoff-git-bg.log 2>/dev/null || true)
263
+ fi
264
+ fi
265
+ rm -f /tmp/handoff-git-bg.pid
266
+ fi
267
+
268
+ # -------- thread file --------
269
+ TS=$(date -u +%Y%m%dT%H%M%SZ)
270
+ DAY=$(date +%Y%m%d)
271
+ HM=$(date +%H%M%S)
272
+ THREAD_ID="T-${DAY}-${HM}-${SLUG}"
273
+ THREAD_PATH="workspace/threads/${THREAD_ID}.json"
274
+ CHANGESET_PATH="workspace/threads/${THREAD_ID}.changeset.json"
275
+
276
+ BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null || echo "DETACHED")
277
+ START_COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")
278
+ DIRTY="false"
279
+ [[ -n "$(git status --porcelain 2>/dev/null)" ]] && DIRTY="true"
280
+
281
+ # Status summary and staged-path list both scale with the changeset / repo
282
+ # noise, so they are captured to files and later slurped into jq off argv.
283
+ new_tmp STATUS_SUMMARY_FILE
284
+ printf '{}' > "$STATUS_SUMMARY_FILE"
285
+ if command -v hq >/dev/null 2>&1; then
286
+ hq core hq-status-summary --session-files-file "$FILES_TOUCHED_FILE" --json > "$STATUS_SUMMARY_FILE" 2>/dev/null \
287
+ || printf '{}' > "$STATUS_SUMMARY_FILE"
288
+ # Guard against a truncated/empty write masquerading as success.
289
+ [[ -s "$STATUS_SUMMARY_FILE" ]] || printf '{}' > "$STATUS_SUMMARY_FILE"
290
+ fi
291
+ BASELINE_NOISE_COUNT=$(jq -r '.counts.baseline_noise // 0' "$STATUS_SUMMARY_FILE" 2>/dev/null || echo 0)
292
+ new_tmp STAGED_PATHS_FILE
293
+ if [[ "$HAS_GIT_CHECKOUT" == "true" ]]; then
294
+ printf '%s\n' "${SAFE_STAGE_PATHS[@]:-}" | jq -R -s 'split("\n") | map(select(length > 0))' > "$STAGED_PATHS_FILE"
295
+ else
296
+ printf '[]' > "$STAGED_PATHS_FILE"
297
+ fi
298
+
299
+ mkdir -p workspace/threads
300
+ # Every next step carries an id and a closure status (2026-09-07). Handoffs
301
+ # used to record steps with no way to close one; an audit found a task copied
302
+ # forward through three consecutive handoffs and never started. Ids are
303
+ # `<thread_id>#<n>`; status starts "open" and is closed by
304
+ # core/scripts/handoff-open-steps.sh close. Strings and {step:...} objects are
305
+ # both accepted; already-normalized objects keep their fields.
306
+ NEXT_STEPS_NORMALIZED_JSON="$(jq -c --arg tid "$THREAD_ID" '
307
+ to_entries | map(.key as $k |
308
+ (if (.value|type) == "string" then {step: .value} else .value end)
309
+ | .id = (.id // ($tid + "#" + (($k + 1)|tostring)))
310
+ | .status = (.status // "open")
311
+ ) | map(del(.key))' <<<"$NEXT_STEPS_JSON" 2>/dev/null || printf '%s' "$NEXT_STEPS_JSON")"
312
+
313
+ jq -n \
314
+ --arg thread_id "$THREAD_ID" \
315
+ --arg ts "$TS" \
316
+ --arg root "$HQ_ROOT" \
317
+ --arg cwd "$(pwd)" \
318
+ --arg branch "$BRANCH" \
319
+ --arg commit "$START_COMMIT" \
320
+ --arg changeset_path "$CHANGESET_PATH" \
321
+ --argjson dirty "$DIRTY" \
322
+ --arg summary "$SUMMARY" \
323
+ --arg title "$TITLE" \
324
+ --argjson next_steps "$NEXT_STEPS_NORMALIZED_JSON" \
325
+ --slurpfile files_touched "$FILES_TOUCHED_FILE" \
326
+ --argjson learnings "$LEARNINGS_JSON" \
327
+ --argjson tags "$TAGS_JSON" \
328
+ --argjson companies "$MIRROR_COMPANIES_JSON" \
329
+ '{
330
+ thread_id: $thread_id,
331
+ version: 1,
332
+ type: "handoff",
333
+ created_at: $ts,
334
+ updated_at: $ts,
335
+ workspace_root: $root,
336
+ cwd: $cwd,
337
+ git: { branch: $branch, current_commit: $commit, dirty: $dirty },
338
+ changeset_path: $changeset_path,
339
+ conversation_summary: $summary,
340
+ next_steps: $next_steps,
341
+ files_touched: ($files_touched[0] // []),
342
+ learnings: $learnings,
343
+ metadata: { title: $title, tags: $tags, company: $companies }
344
+ }' > "$THREAD_PATH"
345
+
346
+ # `handoff-finalize.sh` writes from a shell subprocess, so its thread creation
347
+ # does not pass through editor PostToolUse hooks. Replay the installed mirror
348
+ # hook with Bash: hooks are source files and do not require an executable bit.
349
+ MIRROR_HOOK="$HQ_ROOT/.claude/hooks/mirror-thread-to-company.sh"
350
+ if [[ -f "$MIRROR_HOOK" ]]; then
351
+ jq -n --arg file_path "$HQ_ROOT/$THREAD_PATH" \
352
+ '{tool_name:"Write", tool_input:{file_path:$file_path}}' | \
353
+ bash "$MIRROR_HOOK" >/dev/null 2>&1 || true
354
+ fi
355
+ while IFS= read -r company; do
356
+ [[ -n "$company" ]] || continue
357
+ if [[ -f "companies/${company}/workspace/sessions/${THREAD_ID}.json" ]]; then
358
+ MIRRORED_COMPANIES+=("$company")
359
+ MIRROR_STAGE_PATHS+=(
360
+ "companies/${company}/workspace/index.jsonl"
361
+ "companies/${company}/workspace/.gitignore"
362
+ )
363
+ fi
364
+ done < <(jq -r '.[]' <<<"$MIRROR_COMPANIES_JSON")
365
+ MIRRORED_COMPANIES_JSON=$(printf '%s\n' "${MIRRORED_COMPANIES[@]:-}" | jq -R -s -c 'split("\n") | map(select(length > 0))')
366
+
367
+ # Materialize NDJSON skips into a JSON array for --slurpfile (stdin only; no
368
+ # growing argv). Empty NDJSON stays the [] written at init.
369
+ if [[ -s "$SKIPPED_NDJSON_FILE" ]]; then
370
+ jq -s '.' "$SKIPPED_NDJSON_FILE" > "$SKIPPED_PATHS_FILE"
371
+ fi
372
+
373
+ jq -n \
374
+ --arg thread_id "$THREAD_ID" \
375
+ --arg thread_path "$THREAD_PATH" \
376
+ --arg ts "$TS" \
377
+ --arg title "$TITLE" \
378
+ --arg summary "$SUMMARY" \
379
+ --arg branch "$BRANCH" \
380
+ --arg started_commit "$START_COMMIT" \
381
+ --arg finalized_commit_note "The exact handoff commit is emitted by handoff-finalize.sh as commit_after_finalize; a file cannot contain the hash of the commit that contains itself." \
382
+ --slurpfile files_touched "$FILES_TOUCHED_FILE" \
383
+ --slurpfile staged_paths "$STAGED_PATHS_FILE" \
384
+ --slurpfile skipped_paths "$SKIPPED_PATHS_FILE" \
385
+ --slurpfile status_summary "$STATUS_SUMMARY_FILE" \
386
+ '{
387
+ thread_id: $thread_id,
388
+ thread_path: $thread_path,
389
+ created_at: $ts,
390
+ intent: { title: $title, summary: $summary },
391
+ git: {
392
+ branch: $branch,
393
+ started_commit: $started_commit,
394
+ finalized_commit: null,
395
+ finalized_commit_note: $finalized_commit_note
396
+ },
397
+ files_touched: ($files_touched[0] // []),
398
+ staged_paths: ($staged_paths[0] // []),
399
+ skipped_paths: ($skipped_paths[0] // []),
400
+ verification: { notes: [], upstream_refs_compared: [] },
401
+ status_summary: ($status_summary[0] // {})
402
+ }' > "$CHANGESET_PATH"
403
+
404
+ # -------- handoff.json pointer --------
405
+ # Keep the pointer readable as one complete file at all times. The temp is in
406
+ # the destination directory so mv publishes it with a same-filesystem rename.
407
+ # The pointer path is checkout-relative so the synced handoff stays portable.
408
+ write_handoff_pointer() {
409
+ local destination="$1" thread_path="$2" destination_dir pointer_tmp
410
+ destination_dir="$(dirname "$destination")"
411
+ mkdir -p "$destination_dir"
412
+ pointer_tmp="$(mktemp "$destination_dir/.handoff-pointer.XXXXXX")"
413
+ FINALIZE_TMP_FILES+=("$pointer_tmp")
414
+ if ! jq -n --arg ts "$TS" --arg msg "$MESSAGE" --arg thread_id "$THREAD_ID" --arg thread_path "$thread_path" --arg summary "$SUMMARY" '{
415
+ created_at: $ts,
416
+ message: $msg,
417
+ last_thread: $thread_id,
418
+ thread_path: $thread_path,
419
+ context_notes: $summary
420
+ }' > "$pointer_tmp"; then
421
+ rm -f "$pointer_tmp"
422
+ return 1
423
+ fi
424
+ if ! mv -f "$pointer_tmp" "$destination"; then
425
+ rm -f "$pointer_tmp"
426
+ return 1
427
+ fi
428
+ }
429
+
430
+ copy_handoff_thread() {
431
+ local destination="$1" destination_dir thread_tmp
432
+ destination_dir="$(dirname "$destination")"
433
+ mkdir -p "$destination_dir"
434
+ thread_tmp="$(mktemp "$destination_dir/.handoff-thread.XXXXXX")"
435
+ FINALIZE_TMP_FILES+=("$thread_tmp")
436
+ if ! cp "$HQ_ROOT/$THREAD_PATH" "$thread_tmp"; then
437
+ rm -f "$thread_tmp"
438
+ return 1
439
+ fi
440
+ if ! mv -f "$thread_tmp" "$destination"; then
441
+ rm -f "$thread_tmp"
442
+ return 1
443
+ fi
444
+ }
445
+
446
+ write_handoff_pointer "workspace/threads/handoff.json" "$THREAD_PATH"
447
+ if [[ "$HAS_GIT_CHECKOUT" == "true" ]]; then
448
+ case "$COMMON_GIT_DIR" in
449
+ /*|[A-Za-z]:/*) ;;
450
+ *) COMMON_GIT_DIR="$HQ_ROOT/$COMMON_GIT_DIR" ;;
451
+ esac
452
+ MAIN_CHECKOUT_ROOT="$(cd "$COMMON_GIT_DIR/.." && pwd -P)"
453
+ if [[ "$MAIN_CHECKOUT_ROOT" != "$HQ_ROOT" ]]; then
454
+ copy_handoff_thread "$MAIN_CHECKOUT_ROOT/$THREAD_PATH"
455
+ write_handoff_pointer "$MAIN_CHECKOUT_ROOT/workspace/threads/handoff.json" "$THREAD_PATH"
456
+ fi
457
+ fi
458
+
459
+ # -------- regen INDEX files via dedicated scripts --------
460
+ # Inline INDEX regen replaced with delegations to reusable bash scripts.
461
+ # Index regen is also callable independently (e.g. /cleanup --reindex,
462
+ # handoff-post.sh). Zero Claude context either way.
463
+ hq core rebuild-index threads --both >/dev/null 2>&1 || true
464
+ hq core rebuild-index orchestrator >/dev/null 2>&1 || true
465
+
466
+ # -------- INDEX.md top-level timestamp only --------
467
+ if [[ -f INDEX.md ]]; then
468
+ # Bump 'Generated:' line in place if present, else skip
469
+ if grep -q '^Generated:' INDEX.md; then
470
+ tmpf=$(mktemp)
471
+ awk -v ts="$TS" '/^Generated:/ { print "Generated: " ts; next } { print }' INDEX.md > "$tmpf"
472
+ mv "$tmpf" INDEX.md
473
+ fi
474
+ fi
475
+
476
+ # -------- HQ commit (explicit paths only — never git add -A) --------
477
+ # A failed commit must never ride inside a success payload. hq_committed alone
478
+ # cannot say why it is false — "there was nothing to commit" and "git refused
479
+ # the commit" are the same value — so hq_commit_status carries the reason and a
480
+ # genuine failure also exits non-zero (4).
481
+ HQ_COMMITTED="false"
482
+ HQ_COMMIT_STATUS="nothing-to-commit"
483
+ if [[ "$HAS_GIT_CHECKOUT" != "true" ]]; then
484
+ HQ_COMMIT_STATUS="no-git-checkout"
485
+ fi
486
+ HQ_COMMIT_ERROR=""
487
+ EXPLICIT_PATHS=(
488
+ "${SAFE_STAGE_PATHS[@]:-}"
489
+ "${MIRROR_STAGE_PATHS[@]:-}"
490
+ "workspace/threads/${THREAD_ID}.json"
491
+ "workspace/threads/${THREAD_ID}.changeset.json"
492
+ "workspace/threads/handoff.json"
493
+ "workspace/threads/recent.md"
494
+ "workspace/threads/INDEX.md"
495
+ "workspace/orchestrator/INDEX.md"
496
+ "INDEX.md"
497
+ )
498
+ STAGED=0
499
+ # Newline-separated, not an array: `${#arr[@]}` on an empty array is an unbound
500
+ # variable under bash 3.2 + `set -u` (the same trap the SAFE_STAGE_PATHS
501
+ # regression hit), and macOS still ships bash 3.2.
502
+ STAGE_FAILURES=""
503
+ STAGE_FAILURE_COUNT=0
504
+ if [[ "$HAS_GIT_CHECKOUT" == "true" ]]; then
505
+ for p in "${EXPLICIT_PATHS[@]}"; do
506
+ [[ -n "$p" ]] || continue
507
+ if [[ -e "$p" || -n "$(git status --porcelain -- "$p" 2>/dev/null)" ]]; then
508
+ add_rc=0
509
+ git add -- "$p" >/dev/null 2>&1 || add_rc=$?
510
+ if [[ $add_rc -eq 0 ]]; then
511
+ STAGED=$((STAGED+1))
512
+ elif git ls-files --error-unmatch -- "$p" >/dev/null 2>&1; then
513
+ # The path is already TRACKED but a broad later ignore rule now shadows it
514
+ # (the scaffold's /workspace/ entry over the tracked handoff pointer/index
515
+ # files), so a plain `git add` refuses it. A tracked file must still be
516
+ # committed, so force-add it. Only a force-add that ALSO fails (e.g. a held
517
+ # .git/index.lock) is a genuine stage failure.
518
+ if git add -f -- "$p" >/dev/null 2>&1; then
519
+ STAGED=$((STAGED+1))
520
+ else
521
+ STAGE_FAILURES="${STAGE_FAILURES}${p}"$'\n'
522
+ STAGE_FAILURE_COUNT=$((STAGE_FAILURE_COUNT+1))
523
+ fi
524
+ elif git check-ignore --no-index -q -- "$p" 2>/dev/null; then
525
+ # An UNtracked, gitignored path is a deliberate exclusion, not a failed
526
+ # write. --no-index is required: plain `git check-ignore` SKIPS tracked
527
+ # files (it would wrongly report a tracked-but-ignored path as not-ignored,
528
+ # the bug that made this a stage-failure); the tracked case is handled above.
529
+ :
530
+ else
531
+ STAGE_FAILURES="${STAGE_FAILURES}${p}"$'\n'
532
+ STAGE_FAILURE_COUNT=$((STAGE_FAILURE_COUNT+1))
533
+ fi
534
+ fi
535
+ done
536
+ fi
537
+ # committed_paths scales with the staged set; capture it to a file (git piped to
538
+ # jq via stdin, then the result written to disk) so it too stays off argv.
539
+ new_tmp COMMITTED_PATHS_FILE
540
+ printf '[]' > "$COMMITTED_PATHS_FILE"
541
+ new_tmp STAGE_FAILURES_FILE
542
+ printf '[]' > "$STAGE_FAILURES_FILE"
543
+ if [[ -n "$STAGE_FAILURES" ]]; then
544
+ printf '%s' "$STAGE_FAILURES" \
545
+ | jq -R -s 'split("\n") | map(select(length > 0))' > "$STAGE_FAILURES_FILE"
546
+ fi
547
+ if [[ $STAGED -gt 0 ]] && [[ -n "$(git diff --cached --name-only 2>/dev/null)" ]]; then
548
+ # Snapshot the staged set separately. It only becomes committed_paths once the
549
+ # commit actually lands; a refused commit must not report paths as committed.
550
+ new_tmp PENDING_PATHS_FILE
551
+ git diff --cached --name-only | jq -R -s 'split("\n") | map(select(length > 0))' > "$PENDING_PATHS_FILE"
552
+ commit_out=""
553
+ commit_rc=0
554
+ commit_out="$(git commit -m "checkpoint: handoff ${THREAD_ID}" 2>&1)" || commit_rc=$?
555
+ if [[ $commit_rc -eq 0 ]]; then
556
+ HQ_COMMITTED="true"
557
+ HQ_COMMIT_STATUS="committed"
558
+ cat "$PENDING_PATHS_FILE" > "$COMMITTED_PATHS_FILE"
559
+ else
560
+ HQ_COMMIT_STATUS="failed"
561
+ HQ_COMMIT_ERROR="$(printf '%s' "$commit_out" | tr '\r\n' ' ' | sed 's/ */ /g; s/^ //; s/ $//' | cut -c1-500)"
562
+ [[ -n "$HQ_COMMIT_ERROR" ]] || HQ_COMMIT_ERROR="git commit exited ${commit_rc} with no output"
563
+ fi
564
+ fi
565
+ # A staging failure is just as fatal as a refused commit, and it is the shape a
566
+ # held .git/index.lock actually takes: every `git add` fails, STAGED stays 0, no
567
+ # commit is even attempted, and the run would otherwise look like the benign
568
+ # "nothing to commit" case while the entire handoff sits uncommitted on disk.
569
+ if [[ $STAGE_FAILURE_COUNT -gt 0 && "$HQ_COMMIT_STATUS" != "failed" ]]; then
570
+ HQ_COMMIT_STATUS="stage-failed"
571
+ if [[ -z "$HQ_COMMIT_ERROR" ]]; then
572
+ HQ_COMMIT_ERROR="git add failed for ${STAGE_FAILURE_COUNT} path(s) — see stage_failures"
573
+ fi
574
+ fi
575
+ COMMIT_AFTER=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")
576
+
577
+ # -------- qmd reindex fire-and-forget (agent: skip; laptop: single-flight) --------
578
+ # Agent boxes: managed timer owns indexing — handoff never kicks qmd.
579
+ # Laptop: qmd-reindex-bg single-flight reindex. See qmd-reindex-bg.sh.
580
+ # Helper is invoked synchronously: it is fail-soft and either prints
581
+ # skipped-agent / skipped, or launches its own detached worker and prints a PID.
582
+ # qmd_pid must never claim an agent reindex ran.
583
+ QMD_PID=""
584
+ # Prefer Core US-001 helper; fleet binary only if Core file is missing.
585
+ _QMD_BG="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/qmd-reindex-bg.sh"
586
+ if [[ ! -f "$_QMD_BG" ]] && [[ -x /usr/local/bin/hq-agent-qmd-reindex-bg ]]; then
587
+ _QMD_BG=/usr/local/bin/hq-agent-qmd-reindex-bg
588
+ fi
589
+ if [[ -x "$_QMD_BG" ]] || [[ -f "$_QMD_BG" ]]; then
590
+ # bash (not require +x) so a fresh checkout still routes through the helper.
591
+ QMD_PID="$(bash "$_QMD_BG" --log /tmp/qmd-handoff.log 2>/dev/null || true)"
592
+ elif [[ -d /var/lib/hq-agent ]]; then
593
+ # Agent box without helper yet: still do not raw-embed.
594
+ QMD_PID="skipped-agent"
595
+ fi
596
+
597
+ # -------- copy next-step command to clipboard (fail-soft) --------
598
+ [[ "$NEXT_COMMAND" == "__auto__" ]] && NEXT_COMMAND="/resumework ${THREAD_ID}"
599
+ CLIPBOARD_COPIED="false"
600
+ if [[ -n "$NEXT_COMMAND" ]]; then
601
+ if command -v pbcopy >/dev/null 2>&1; then
602
+ printf '%s' "$NEXT_COMMAND" | pbcopy 2>/dev/null && CLIPBOARD_COPIED="true"
603
+ elif command -v wl-copy >/dev/null 2>&1; then
604
+ printf '%s' "$NEXT_COMMAND" | wl-copy 2>/dev/null && CLIPBOARD_COPIED="true"
605
+ elif command -v xclip >/dev/null 2>&1; then
606
+ printf '%s' "$NEXT_COMMAND" | xclip -selection clipboard 2>/dev/null && CLIPBOARD_COPIED="true"
607
+ fi
608
+ fi
609
+
610
+ # -------- emit result --------
611
+ jq -n \
612
+ --arg thread_id "$THREAD_ID" \
613
+ --arg thread_path "$THREAD_PATH" \
614
+ --arg handoff_path "workspace/threads/handoff.json" \
615
+ --argjson hq_committed "$HQ_COMMITTED" \
616
+ --argjson indexes_regen "true" \
617
+ --arg qmd_pid "${QMD_PID:-}" \
618
+ --arg git_bg_errors "$GIT_BG_ERRORS" \
619
+ --arg changeset_path "$CHANGESET_PATH" \
620
+ --arg hq_commit_status "$HQ_COMMIT_STATUS" \
621
+ --arg hq_commit_error "$HQ_COMMIT_ERROR" \
622
+ --slurpfile committed_paths "$COMMITTED_PATHS_FILE" \
623
+ --slurpfile stage_failures "$STAGE_FAILURES_FILE" \
624
+ --slurpfile skipped_paths "$SKIPPED_PATHS_FILE" \
625
+ --argjson baseline_noise_count "$BASELINE_NOISE_COUNT" \
626
+ --arg commit_after_finalize "$COMMIT_AFTER" \
627
+ --arg next_command "$NEXT_COMMAND" \
628
+ --argjson clipboard_copied "$CLIPBOARD_COPIED" \
629
+ --argjson mirror_companies "$MIRRORED_COMPANIES_JSON" \
630
+ '{
631
+ thread_id: $thread_id,
632
+ thread_path: $thread_path,
633
+ changeset_path: $changeset_path,
634
+ handoff_path: $handoff_path,
635
+ hq_committed: $hq_committed,
636
+ hq_commit_status: $hq_commit_status,
637
+ hq_commit_error: $hq_commit_error,
638
+ commit_after_finalize: $commit_after_finalize,
639
+ committed_paths: ($committed_paths[0] // []),
640
+ stage_failures: ($stage_failures[0] // []),
641
+ skipped_paths: ($skipped_paths[0] // []),
642
+ baseline_noise_count: $baseline_noise_count,
643
+ indexes_regen: $indexes_regen,
644
+ qmd_pid: $qmd_pid,
645
+ git_bg_errors: $git_bg_errors,
646
+ next_command: $next_command,
647
+ clipboard_copied: $clipboard_copied,
648
+ mirror_companies: $mirror_companies
649
+ }'
650
+
651
+ # -------- fail loudly on a git write that did not land --------
652
+ # The payload above is always emitted first: the caller still needs the thread
653
+ # id and paths to recover. What changes is that a refused commit no longer
654
+ # leaves the process looking successful.
655
+ if [[ -n "$GIT_BG_ERRORS" ]]; then
656
+ echo "handoff-finalize: WARNING background git step reported errors: $GIT_BG_ERRORS" >&2
657
+ fi
658
+ if [[ $STAGE_FAILURE_COUNT -gt 0 ]]; then
659
+ echo "handoff-finalize: ERROR git add failed for ${STAGE_FAILURE_COUNT} path(s):" >&2
660
+ printf '%s' "$STAGE_FAILURES" | sed 's/^/handoff-finalize: /' >&2
661
+ fi
662
+ case "$HQ_COMMIT_STATUS" in
663
+ failed|stage-failed)
664
+ echo "handoff-finalize: ERROR the HQ handoff commit FAILED — thread files were written but are NOT committed." >&2
665
+ echo "handoff-finalize: git said: $HQ_COMMIT_ERROR" >&2
666
+ echo "handoff-finalize: resolve the git error and re-run /handoff before ending the session." >&2
667
+ exit 4
668
+ ;;
669
+ esac
670
+ exit 0