@indigoai-us/hq-cli 5.103.7 → 5.103.9

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,30 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.103.9] — 2026-08-20
6
+
7
+ ### Fixed
8
+
9
+ - The checkpoint stop-gate no longer causes duplicated or hidden replies
10
+ around the end-of-turn checkpoint. The gate now reads the transcript to
11
+ detect whether the agent already delivered its user-facing reply this turn
12
+ and issues the matching instruction: checkpoint-then-full-reply when no
13
+ reply exists yet, or checkpoint-and-end-without-repeating when it does. The
14
+ `hq core checkpoint` reply reminder carries the same conditional contract.
15
+
16
+ ## [5.103.8] — 2026-08-19
17
+
18
+ ### Fixed
19
+
20
+ - The checkpoint stop-gate can no longer re-prompt a session forever. When the
21
+ checkpoint command keeps failing (e.g. a broken or mid-self-update `hq`
22
+ binary), the gate now fails open after 3 consecutive blocks per session and
23
+ re-arms once a checkpoint succeeds — a broken CLI cannot loop a session
24
+ indefinitely.
25
+ - A failed startup self-update now points at `hq rescue` in addition to the
26
+ manual manager command. A corrupt install layout fails the manual retry the
27
+ same way; rescue is the path that can actually rebuild the install.
28
+
5
29
  ## [5.103.7] — 2026-08-19
6
30
 
7
31
  ### Fixed
@@ -236,14 +236,16 @@ set -uo pipefail
236
236
  | [range(0; $rows | length) | select($rows[.].entry | real_user)] as $user_indexes
237
237
  | if ($user_indexes | length) == 0 then error("no real user entry") else
238
238
  $user_indexes[-1] as $user_index
239
+ | $rows[($user_index + 1):] as $turn
239
240
  | [
240
- $rows[($user_index + 1):][]
241
- | .entry
241
+ range(0; $turn | length) as $ti
242
+ | $turn[$ti].entry
242
243
  | if .type == "assistant" then
243
244
  .message.content?
244
245
  | if type == "array" then .[]? else empty end
245
246
  | select(.type == "tool_use")
246
247
  | {
248
+ i: $ti,
247
249
  runtime: "claude",
248
250
  id: (.id? // ""),
249
251
  name: (.name? // ""),
@@ -255,6 +257,7 @@ set -uo pipefail
255
257
  ) then
256
258
  .payload
257
259
  | {
260
+ i: $ti,
258
261
  runtime: "codex",
259
262
  id: (.call_id? // .id? // ""),
260
263
  name: (.name? // ""),
@@ -264,6 +267,30 @@ set -uo pipefail
264
267
  end
265
268
  ] as $tools
266
269
  | (if ($tools | length) > 0 then $tools[-1] else {} end) as $last_tool
270
+ # A user-visible assistant reply strictly AFTER the turn-s last tool call
271
+ # means the user has already been answered, so the block reason must
272
+ # forbid a restatement instead of demanding one. Text earlier in the turn
273
+ # (status notes between tool calls) does not count as the reply.
274
+ | (if ($tools | length) > 0 then $tools[-1].i else -1 end) as $last_tool_i
275
+ | ([
276
+ $turn[($last_tool_i + 1):][]
277
+ | .entry
278
+ | if .type == "assistant" then
279
+ .message.content?
280
+ | if type == "string" then .
281
+ elif type == "array" then ([.[]? | select(.type == "text") | (.text? // "")] | join(""))
282
+ else ""
283
+ end
284
+ elif (
285
+ .type == "response_item"
286
+ and .payload.type? == "message"
287
+ and .payload.role? == "assistant"
288
+ and ((.payload.content? | type) == "array")
289
+ ) then
290
+ ([.payload.content[]? | (.text? // "")] | join(""))
291
+ else ""
292
+ end
293
+ ] | join("") | test("\\S")) as $replied
267
294
  | ([$tools[] | select((.command | checkpoint_command) | not)] | length) as $work_tool_count
268
295
  | ([
269
296
  $rows[]
@@ -280,14 +307,19 @@ set -uo pipefail
280
307
  ) then
281
308
  if $last_tool.runtime == "claude" and ($last_result_error | not) then "1"
282
309
  elif $last_tool.runtime == "codex" then "stamp"
283
- else "0"
310
+ else (if $replied then "0-replied" else "0-unreplied" end)
284
311
  end
285
312
  elif $work_tool_count == 0 then "idle"
286
- else "0"
313
+ else (if $replied then "0-replied" else "0-unreplied" end)
287
314
  end
288
315
  end
289
316
  ' 2>/dev/null)" || exit 0
290
317
  satisfied="$parsed"
318
+ replied=0
319
+ case "$satisfied" in
320
+ 0-replied) satisfied=0; replied=1 ;;
321
+ 0-unreplied) satisfied=0 ;;
322
+ esac
291
323
  case "$satisfied" in
292
324
  0|1|stamp|idle) ;;
293
325
  *) exit 0 ;;
@@ -322,12 +354,46 @@ set -uo pipefail
322
354
  if [ "$runtime" = "codex" ]; then
323
355
  rm -f "$state_dir/codex-checkpoint-reprompt-$session_key" 2>/dev/null || true
324
356
  fi
357
+ rm -f "$state_dir/checkpoint-block-count-$session_key" 2>/dev/null || true
358
+ exit 0
359
+ fi
360
+
361
+ # Loop guard: a session whose checkpoint command keeps FAILING (a broken or
362
+ # mid-self-update `hq` binary) would otherwise be re-prompted on every Stop,
363
+ # forever — block → retry checkpoint → error → block again. Cap consecutive
364
+ # blocks per session; past the cap, fail open per this file's
365
+ # never-strand-a-session doctrine. Any successful checkpoint or idle turn
366
+ # resets the counter above.
367
+ block_count_file="$state_dir/checkpoint-block-count-$session_key"
368
+ block_count="$(tr -d '\r\n' <"$block_count_file" 2>/dev/null || true)"
369
+ case "$block_count" in
370
+ ''|*[!0-9]*) block_count=0 ;;
371
+ esac
372
+ if [ "$block_count" -ge 3 ]; then
325
373
  exit 0
326
374
  fi
375
+ block_count_tmp="$block_count_file.$$"
376
+ if (umask 077 && printf '%s' "$((block_count + 1))" >"$block_count_tmp" && mv -f "$block_count_tmp" "$block_count_file"); then
377
+ :
378
+ else
379
+ rm -f "$block_count_tmp" 2>/dev/null || true
380
+ fi
327
381
 
328
382
  # Built with printf rather than concatenation so the session id can appear in
329
383
  # both commands without re-splitting the message into fragments.
330
- reason="$(printf 'This turn changed something, so it needs an end-of-turn checkpoint. Two different audiences are involved — do not conflate them:\n\n1. THE USER reads your normal chat reply. If you owe them anything — a result, a link, an answer, a status — say it in the reply as usual. The checkpoint is invisible to them and is NOT a message to them; running it does not count as having replied.\n2. THE SIBLING (a background maintenance agent) reads the checkpoint payload. It never sees your chat reply, so anything it needs must go into the flags.\n\nORDER (this app folds any text that precedes a tool call into a collapsed sub-message — only text AFTER the last tool call renders in full): run the checkpoint FIRST, then deliver your COMPLETE user-facing reply as the final text of the turn. Every link, URL, instruction, command, and decision the user needs MUST appear in that final post-checkpoint message, restated in full even if you already wrote it earlier in the turn — earlier text is collapsed and the user will not see it.\n\n hq core checkpoint --session-id %s --trigger stop-gate --summary "<what changed, in one line>" [--file <path>] [--decision "<choice and why>"] [--learning "<reusable rule>"] [--next "<outstanding step>"]\n\nOnly --summary is required, and the repeatable flags are what the sibling uses to enrich the record, distil policies and update the indexes — a bare summary gives it almost nothing to work with. Write them as machine record, not prose for the user, and pass each one that genuinely applies:\n --file every path you created or modified this turn\n --decision a choice you made that a reader would otherwise have to reverse-engineer\n --learning a rule that changes how someone acts next time, not a restatement of what just happened\n --next work that is genuinely still outstanding\nOmit a flag rather than padding it: an empty or invented learning is worse than none.\n\nIf this turn only read or inspected things and changed no state, the correct call instead is:\n\n hq core checkpoint --session-id %s --idle' "$session_id" "$session_id")"
384
+ #
385
+ # Two variants, chosen from the transcript rather than left to the agent's
386
+ # judgment: the historical single instruction either demanded a full
387
+ # restatement (double-messaging hosts that render pre-tool text — the CLI and
388
+ # the desktop app both do) or forbade post-checkpoint text (hiding the reply
389
+ # when the agent had not yet written one). The transcript already tells us
390
+ # which case we are in, so say exactly one thing.
391
+ flags_spec=' hq core checkpoint --session-id %s --trigger stop-gate --summary "<what changed, in one line>" [--file <path>] [--decision "<choice and why>"] [--learning "<reusable rule>"] [--next "<outstanding step>"]\n\nOnly --summary is required, and the repeatable flags are what the sibling uses to enrich the record, distil policies and update the indexes — a bare summary gives it almost nothing to work with. Write them as machine record, not prose for the user, and pass each one that genuinely applies:\n --file every path you created or modified this turn\n --decision a choice you made that a reader would otherwise have to reverse-engineer\n --learning a rule that changes how someone acts next time, not a restatement of what just happened\n --next work that is genuinely still outstanding\nOmit a flag rather than padding it: an empty or invented learning is worse than none.\n\nIf this turn only read or inspected things and changed no state, the correct call instead is:\n\n hq core checkpoint --session-id %s --idle'
392
+ if [ "$replied" = 1 ]; then
393
+ reason="$(printf 'This turn changed something, so it needs an end-of-turn checkpoint before it can end.\n\nYour user-facing reply is ALREADY delivered — the message you just wrote is visible to the user. Do NOT send it again, in full or summarized: repeating it double-messages the user, which is exactly the bug this gate guards against.\n\nTHE SIBLING (a background maintenance agent) reads only the checkpoint payload, never your chat reply — anything it needs must go into the flags.\n\nRun the checkpoint now as the FINAL action of the turn and end the turn immediately after it, adding no further text:\n\n'"$flags_spec" "$session_id" "$session_id")"
394
+ else
395
+ reason="$(printf 'This turn changed something, so it needs an end-of-turn checkpoint. Two different audiences are involved — do not conflate them:\n\n1. THE USER reads your normal chat reply. You have not sent one yet this turn — everything you owe them (results, links, answers, status, decisions) must go into it. The checkpoint is invisible to them and does NOT count as having replied.\n2. THE SIBLING (a background maintenance agent) reads the checkpoint payload. It never sees your chat reply, so anything it needs must go into the flags.\n\nORDER: run the checkpoint FIRST, then deliver your complete user-facing reply as the FINAL text of the turn — final-position text is the one placement every host renders in full. Every link, URL, instruction, command, and decision the user needs must appear in that final message.\n\n'"$flags_spec" "$session_id" "$session_id")"
396
+ fi
331
397
 
332
398
  # Codex surfaces a blocked Stop reason as a synthetic user prompt. Preserve
333
399
  # the actionable instruction out-of-band, then use the stable marker covered
@@ -14,11 +14,14 @@ type SpawnableBackend = Exclude<Backend, "none">;
14
14
  * treating the checkpoint call as the end of the turn, leaving their actual
15
15
  * findings only in the checkpoint payload — which the user never sees. This
16
16
  * line rides the command output (the one channel guaranteed to reach the
17
- * calling agent) to force the user-facing reply. Deliberately conditional so
18
- * it composes with the Stop-gate prompt's "checkpoint first, reply last"
19
- * ordering: some hosts (the desktop app runtime) do not reliably display
20
- * assistant text that precedes a tool call, so the reply must come after
21
- * the checkpoint output as the final text of the turn. Mirrors the hq-core policies
17
+ * calling agent) to force the user-facing reply. Deliberately conditional on
18
+ * whether the reply was already delivered this turn: an unconditional
19
+ * "restate in full" made agents repeat a reply the user had already seen
20
+ * (both the CLI and the desktop app render pre-tool-call text), while an
21
+ * unconditional "no commentary after" hid the reply from agents that had not
22
+ * yet written one. The Stop gate makes the same distinction from the
23
+ * transcript; this line covers proactive checkpoints the gate never sees.
24
+ * Mirrors the hq-core policies
22
25
  * `checkpoint-is-bookkeeping-not-user-communication` and
23
26
  * `checkpoint-is-not-the-user-report`.
24
27
  */
@@ -45,20 +45,23 @@ class CheckpointUsageError extends Error {
45
45
  * treating the checkpoint call as the end of the turn, leaving their actual
46
46
  * findings only in the checkpoint payload — which the user never sees. This
47
47
  * line rides the command output (the one channel guaranteed to reach the
48
- * calling agent) to force the user-facing reply. Deliberately conditional so
49
- * it composes with the Stop-gate prompt's "checkpoint first, reply last"
50
- * ordering: some hosts (the desktop app runtime) do not reliably display
51
- * assistant text that precedes a tool call, so the reply must come after
52
- * the checkpoint output as the final text of the turn. Mirrors the hq-core policies
48
+ * calling agent) to force the user-facing reply. Deliberately conditional on
49
+ * whether the reply was already delivered this turn: an unconditional
50
+ * "restate in full" made agents repeat a reply the user had already seen
51
+ * (both the CLI and the desktop app render pre-tool-call text), while an
52
+ * unconditional "no commentary after" hid the reply from agents that had not
53
+ * yet written one. The Stop gate makes the same distinction from the
54
+ * transcript; this line covers proactive checkpoints the gate never sees.
55
+ * Mirrors the hq-core policies
53
56
  * `checkpoint-is-bookkeeping-not-user-communication` and
54
57
  * `checkpoint-is-not-the-user-report`.
55
58
  */
56
59
  export const CHECKPOINT_REPLY_REMINDER = "checkpoint: REMINDER — this checkpoint is invisible bookkeeping; the user never sees it " +
57
- "and it does NOT count as your reply. Now write your user-facing reply as the FINAL text " +
58
- "of the turn everything of substance from this turn (results, findings, decisions, " +
59
- "state changes, anything awaiting their input) even if you already wrote it earlier: " +
60
- "assistant text that precedes a tool call is not reliably shown to the user on all " +
61
- "hosts. Then end the turn.";
60
+ "and it does NOT count as your reply. If this turn's substance (results, findings, " +
61
+ "decisions, state changes, anything awaiting their input) has NOT yet been delivered to " +
62
+ "the user, write your user-facing reply now as the FINAL text of the turn. If you " +
63
+ "already delivered it earlier this turn, end the turn now WITHOUT repeating it the " +
64
+ "user has already seen that message, and repeating it double-messages them.";
62
65
  function printResult(line) {
63
66
  process.stdout.write(`${line}\n`);
64
67
  }
@@ -288,6 +288,14 @@ async function updateAndReexec(argv, flavor, known, deps) {
288
288
  console.error(chalk.yellow(`⚠ hq-cli ${latest} is available but the update failed` +
289
289
  `${result.detail ? `: ${result.detail}` : ""}`));
290
290
  console.error(chalk.dim(` Try manually: ${plan.cmd} ${plan.args.join(" ")}`));
291
+ // A manager-level failure often means the install layout itself is broken
292
+ // (e.g. a hand-rolled pnpm store nested inside the app's bin dir). The
293
+ // manual retry above hits the same layout and fails the same way; point at
294
+ // the recovery path that can actually rebuild the install — except from the
295
+ // rescue itself, which would just recurse.
296
+ if (flavor.noun !== "rescue") {
297
+ console.error(chalk.dim(` Or repair the install: hq rescue`));
298
+ }
291
299
  console.error(chalk.dim(` Continuing the ${flavor.noun} on ${current}.`));
292
300
  return { action: "update-failed", latest };
293
301
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.103.7",
3
+ "version": "5.103.9",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {