@indigoai-us/hq-cli 5.98.3 → 5.99.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/assets/scaffold/core/scripts/checkpoint-stop-gate.sh +347 -0
  3. package/assets/scaffold/core/scripts/hook-lib.sh +557 -0
  4. package/assets/scaffold/core/scripts/hq-session.sh +251 -0
  5. package/assets/scaffold/core/scripts/lib/session-id.sh +96 -0
  6. package/assets/scaffold/core/scripts/lib/session-scope-capability.sh +52 -0
  7. package/dist/commands/core.js +25 -5
  8. package/dist/commands/doctor.d.ts +97 -0
  9. package/dist/commands/doctor.js +228 -0
  10. package/dist/commands/scaffold-fast.d.ts +41 -0
  11. package/dist/commands/scaffold-fast.js +57 -0
  12. package/dist/fast-core.d.ts +16 -0
  13. package/dist/fast-core.js +47 -0
  14. package/dist/index.d.ts +2 -0
  15. package/dist/index.js +10 -1
  16. package/dist/lib/doctor/__testing__/fake-hq-tree.d.ts +194 -0
  17. package/dist/lib/doctor/__testing__/fake-hq-tree.js +357 -0
  18. package/dist/lib/doctor/allowed-divergence.d.ts +72 -0
  19. package/dist/lib/doctor/allowed-divergence.js +134 -0
  20. package/dist/lib/doctor/checks/claude-wiring.d.ts +55 -0
  21. package/dist/lib/doctor/checks/claude-wiring.js +524 -0
  22. package/dist/lib/doctor/checks/codex-wiring.d.ts +45 -0
  23. package/dist/lib/doctor/checks/codex-wiring.js +376 -0
  24. package/dist/lib/doctor/checks/grok-wiring.d.ts +35 -0
  25. package/dist/lib/doctor/checks/grok-wiring.js +186 -0
  26. package/dist/lib/doctor/checks/runtime-probe.d.ts +101 -0
  27. package/dist/lib/doctor/checks/runtime-probe.js +335 -0
  28. package/dist/lib/doctor/compat.d.ts +85 -0
  29. package/dist/lib/doctor/compat.js +102 -0
  30. package/dist/lib/doctor/deep/classify.d.ts +61 -0
  31. package/dist/lib/doctor/deep/classify.js +75 -0
  32. package/dist/lib/doctor/deep/effects.d.ts +107 -0
  33. package/dist/lib/doctor/deep/effects.js +229 -0
  34. package/dist/lib/doctor/deep/executor.d.ts +112 -0
  35. package/dist/lib/doctor/deep/executor.js +369 -0
  36. package/dist/lib/doctor/deep/parity.d.ts +129 -0
  37. package/dist/lib/doctor/deep/parity.js +355 -0
  38. package/dist/lib/doctor/deep/sandbox.d.ts +190 -0
  39. package/dist/lib/doctor/deep/sandbox.js +572 -0
  40. package/dist/lib/doctor/fix/apply.d.ts +119 -0
  41. package/dist/lib/doctor/fix/apply.js +352 -0
  42. package/dist/lib/doctor/fix/backup.d.ts +40 -0
  43. package/dist/lib/doctor/fix/backup.js +64 -0
  44. package/dist/lib/doctor/fix/remediation.d.ts +71 -0
  45. package/dist/lib/doctor/fix/remediation.js +103 -0
  46. package/dist/lib/doctor/fixtures/discover.d.ts +96 -0
  47. package/dist/lib/doctor/fixtures/discover.js +287 -0
  48. package/dist/lib/doctor/fixtures/schema.d.ts +171 -0
  49. package/dist/lib/doctor/fixtures/schema.js +248 -0
  50. package/dist/lib/doctor/hook-gate-profiles.d.ts +55 -0
  51. package/dist/lib/doctor/hook-gate-profiles.js +107 -0
  52. package/dist/lib/doctor/json-output.d.ts +90 -0
  53. package/dist/lib/doctor/json-output.js +76 -0
  54. package/dist/lib/doctor/payload-shapes.d.ts +170 -0
  55. package/dist/lib/doctor/payload-shapes.js +275 -0
  56. package/dist/lib/doctor/platform.d.ts +244 -0
  57. package/dist/lib/doctor/platform.js +490 -0
  58. package/dist/lib/doctor/registry.d.ts +49 -0
  59. package/dist/lib/doctor/registry.js +176 -0
  60. package/dist/lib/doctor/report.d.ts +87 -0
  61. package/dist/lib/doctor/report.js +164 -0
  62. package/dist/lib/doctor/types.d.ts +87 -0
  63. package/dist/lib/doctor/types.js +29 -0
  64. package/dist/main.js +6 -0
  65. package/dist/utils/version-check.js +2 -2
  66. package/dist/utils/version-gate.d.ts +1 -1
  67. package/dist/utils/version-gate.js +1 -1
  68. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,47 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.99.0] — 2026-08-11
6
+
7
+ ### Added
8
+
9
+ - `hq core hq-session` and `hq core checkpoint-stop-gate` — the session-metadata
10
+ helper and the Stop-hook checkpoint gate are now hosted by the CLI as bundled
11
+ scaffold scripts, so a single published version owns them instead of every HQ
12
+ tree carrying its own copy. Both are `root: "live"`: the CLI injects the live
13
+ HQ root, and each script sources its shared libs (`hook-lib.sh`, the session
14
+ libs) from the bundle while reading and writing the live tree. The loose in-HQ
15
+ files remain as thin fallbacks for a tree whose installed CLI predates these
16
+ commands, so callers keep working across the upgrade.
17
+ - Checkpoint Stop gate gains an **opt-in company-scope requirement**: when
18
+ `HQ_CHECKPOINT_SCOPE_GATE_DOMAINS` lists one or more operator email domains, a
19
+ session whose caller matches (exact domain-suffix; the delegated-email claim is
20
+ preferred over the service email) must declare a scope — a real tenant, or the
21
+ reserved `personal` — before its turn can end. A binding to an unknown slug does
22
+ not satisfy it. Off by default so release-shipped scaffold stays
23
+ company-agnostic; identity resolves locally from the Cognito token cache with no
24
+ network call; `HQ_CHECKPOINT_GATE=0` disables it.
25
+ - `hq core hq-session set company_slug personal` — `personal` is now a reserved
26
+ binding for work that belongs to no company. It scopes the session (satisfying
27
+ the gate and the scope authorizer) without surfacing company hard-policies or
28
+ registering a company with the Work Mesh.
29
+ - Entrypoint fast path for the two relocated scripts. `main.ts` eagerly imports
30
+ the CLI's ~60-module command graph, which costs seconds of startup — ruinous
31
+ for `hq core hq-session` (called several times per skill/hook) and
32
+ `hq core checkpoint-stop-gate` (every turn's Stop). Those invocations now
33
+ dispatch straight to the bundled-script runner from the entrypoint, importing
34
+ only the light root resolver, so they run in roughly node's own startup time.
35
+ The commander registration remains the complete path for every other `hq core`
36
+ invocation.
37
+
38
+ ### Changed
39
+
40
+ - Version-check cache TTL cut from 24h to 1h. The self-update path reads a
41
+ cached npm `latest` instead of fetching on every command; at a 24h TTL that
42
+ cache could hold a value older than a freshly published release for most of a
43
+ day, so the CLI now refreshes it hourly and picks new versions up within the
44
+ hour. The refresh jitter is scaled to 5m to keep a fleet's checks spread out.
45
+
5
46
  ## [5.98.3] — 2026-08-11
6
47
 
7
48
  ### Fixed
@@ -0,0 +1,347 @@
1
+ #!/usr/bin/env bash
2
+ # Stop hook: require hq core checkpoint as an eligible operator's final tool
3
+ # call. Every error path is deliberately fail-open: a broken gate must never
4
+ # strand a Claude session.
5
+
6
+ set -uo pipefail
7
+
8
+ {
9
+ self_hq="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")/../.." 2>/dev/null && pwd)"
10
+ HQ="${CLAUDE_PROJECT_DIR:-${HQ_ROOT:-$self_hq}}"
11
+ HQ="${HQ:-${HOME}/Documents/HQ}"
12
+ [ -n "$self_hq" ] || exit 0
13
+ . "$self_hq/core/scripts/hook-lib.sh"
14
+
15
+ # checkpoint_gate_caller_email — best-effort caller email from the local
16
+ # Cognito token cache, with no network round-trip. Prints empty when it cannot
17
+ # be resolved, so the scope gate below simply does not apply (fail-open). Both
18
+ # the id and access tokens are inspected, and the DELEGATED email claim is
19
+ # preferred over the raw `email`, so an outpost / delegated token resolves to
20
+ # the operator identity rather than the service identity it acts as (the same
21
+ # precedence the checkpoint eligibility logic uses).
22
+ # HQ_CHECKPOINT_CALLER_EMAIL overrides the lookup for tests and trusted callers.
23
+ checkpoint_gate_caller_email() {
24
+ if [ -n "${HQ_CHECKPOINT_CALLER_EMAIL:-}" ]; then
25
+ printf '%s' "$HQ_CHECKPOINT_CALLER_EMAIL"
26
+ return 0
27
+ fi
28
+ command -v jq >/dev/null 2>&1 || return 0
29
+ local tf tok claim seg email
30
+ tf="${HQ_COGNITO_TOKENS_FILE:-${HOME:-}/.hq/cognito-tokens.json}"
31
+ [ -n "${HQ_COGNITO_TOKENS_FILE:-}${HOME:-}" ] || return 0
32
+ [ -r "$tf" ] || return 0
33
+ for claim in idToken accessToken; do
34
+ tok="$(jq -r --arg k "$claim" '.[$k] // empty' "$tf" 2>/dev/null || true)"
35
+ [ -n "$tok" ] || continue
36
+ # JWT payload is the middle segment; base64url -> base64 before decoding.
37
+ seg="$(printf '%s' "$tok" | cut -d. -f2 | tr '_-' '/+')"
38
+ case $(( ${#seg} % 4 )) in
39
+ 2) seg="${seg}==" ;;
40
+ 3) seg="${seg}=" ;;
41
+ esac
42
+ email="$(jq -rn --arg s "$seg" '
43
+ try ($s | @base64d | fromjson
44
+ | (."custom:delegatedEmail" // .email // empty)) catch empty' 2>/dev/null || true)"
45
+ case "$email" in
46
+ ?*@?*) printf '%s' "$email"; return 0 ;;
47
+ esac
48
+ done
49
+ return 0
50
+ }
51
+
52
+ input="$(cat 2>/dev/null || printf '{}')"
53
+ input_fields="$(printf '%s' "$input" | jq -r '[.transcript_path // "", .session_id // ""] | @tsv' 2>/dev/null)" || exit 0
54
+ IFS=$'\t' read -r transcript_path session_id <<<"$input_fields" || exit 0
55
+
56
+ # The sibling maintains HQ after a successful checkpoint. It must never
57
+ # recursively demand another checkpoint of itself.
58
+ [ "${HQ_CHECKPOINT_SIBLING:-}" = "1" ] && exit 0
59
+
60
+ # Company-scope gate ---------------------------------------------------------
61
+ # An OPT-IN requirement layered onto this Stop hook: for an operator whose
62
+ # email domain is listed in HQ_CHECKPOINT_SCOPE_GATE_DOMAINS (comma-separated,
63
+ # e.g. "example.ai,example.com"), a session must declare a scope — a real
64
+ # company, or the reserved `personal` — before it can end. It is off by
65
+ # default: release-shipped scaffold stays company-agnostic, and a deployment
66
+ # that wants the requirement configures the domains in its own environment. It
67
+ # is orthogonal to the checkpoint requirement (applies on every turn and
68
+ # runtime); every branch is fail-open; HQ_CHECKPOINT_GATE=0 disables it
69
+ # alongside the checkpoint gate. Binding is a single recoverable call, so the
70
+ # block loops only until the operator declares a scope.
71
+ gate_domains="${HQ_CHECKPOINT_SCOPE_GATE_DOMAINS:-}"
72
+ case "${HQ_CHECKPOINT_GATE:-}" in
73
+ 0) ;;
74
+ *)
75
+ gate_session_id="$(printf '%s' "$input" | jq -r '.session_id // ""' 2>/dev/null || true)"
76
+ if [ -n "$gate_domains" ] && [ -n "$gate_session_id" ]; then
77
+ gate_email="$(checkpoint_gate_caller_email 2>/dev/null || true)"
78
+ gate_email="$(printf '%s' "$gate_email" | tr '[:upper:]' '[:lower:]')"
79
+ # Exact domain-suffix match against the configured list: `user@example.ai`
80
+ # matches `example.ai`, but `user@example.ai.evil` and `user@sub.example.ai`
81
+ # do not. Iterate a newline split of the comma list (no lingering IFS).
82
+ gate_match=0
83
+ if [ -n "$gate_email" ]; then
84
+ while IFS= read -r gate_dom; do
85
+ gate_dom="$(printf '%s' "$gate_dom" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')"
86
+ [ -n "$gate_dom" ] || continue
87
+ case "$gate_email" in
88
+ *"@$gate_dom") gate_match=1; break ;;
89
+ esac
90
+ done <<<"$(printf '%s' "$gate_domains" | tr ',' '\n')"
91
+ fi
92
+ if [ "$gate_match" = 1 ]; then
93
+ gate_root="${HQ_ROOT:-${CLAUDE_PROJECT_DIR:-$self_hq}}"
94
+ bound_co=""
95
+ gate_cap="$gate_root/workspace/sessions/$gate_session_id/scope-capability.json"
96
+ if [ -r "$gate_cap" ]; then
97
+ bound_co="$(jq -r '.company_slug // empty' "$gate_cap" 2>/dev/null || true)"
98
+ fi
99
+ if [ -z "$bound_co" ]; then
100
+ gate_meta="$gate_root/workspace/sessions/$gate_session_id/meta.yaml"
101
+ if [ -r "$gate_meta" ]; then
102
+ bound_co="$(awk '
103
+ $1 == "company_slug:" {
104
+ sub(/^[^:]+:[[:space:]]*/, "")
105
+ gsub(/^"|"$/, "")
106
+ print
107
+ exit
108
+ }
109
+ ' "$gate_meta" 2>/dev/null || true)"
110
+ fi
111
+ fi
112
+ # A binding satisfies the gate only when it names a REAL scope: the
113
+ # reserved `personal`, or an actual tenant directory. hq-session will
114
+ # still store a typo'd or invented slug (it validates only the
115
+ # character set), so an unknown slug must not silently pass the gate.
116
+ gate_bound=0
117
+ case "$bound_co" in
118
+ "") ;;
119
+ personal) gate_bound=1 ;;
120
+ *) [ -d "$gate_root/companies/$bound_co" ] && gate_bound=1 ;;
121
+ esac
122
+ if [ "$gate_bound" = 0 ]; then
123
+ company_reason="$(printf 'This session has not declared its scope, and a scope is required before the turn can end. Decide where this work belongs, then bind the session as the FINAL action of the turn and end the turn immediately after:\n\n bash %s/core/scripts/hq-session.sh --session-id %s set company_slug <company-slug>\n\nUse a real tenant slug from companies/manifest.yaml — the company this session is actually working in, never an invented one. If this session does no company-scoped work, bind it to the reserved personal scope instead:\n\n bash %s/core/scripts/hq-session.sh --session-id %s set company_slug personal\n\nAn operator can also disable this requirement for the run by exporting HQ_CHECKPOINT_GATE=0.' "$gate_root" "$gate_session_id" "$gate_root" "$gate_session_id")"
124
+ printf '{"decision":"block","reason":%s}\n' "$(printf '%s' "$company_reason" | hq_json_encode)"
125
+ exit 0
126
+ fi
127
+ fi
128
+ fi
129
+ ;;
130
+ esac
131
+
132
+ runtime="${HQ_CHECKPOINT_RUNTIME:-claude}"
133
+
134
+ # Operator switches precede runtime and identity eligibility. A forced gate
135
+ # intentionally bypasses the rollout rules for local trials and tests.
136
+ case "${HQ_CHECKPOINT_GATE:-}" in
137
+ 0) exit 0 ;;
138
+ 1) enforce=true ;;
139
+ *)
140
+ case "$runtime" in
141
+ claude) enforce=true ;;
142
+ codex) enforce=false ;;
143
+ *) exit 0 ;;
144
+ esac
145
+ ;;
146
+ esac
147
+
148
+ state_dir="$(hq_hook_state_dir "$HQ")"
149
+ [ -d "$state_dir" ] && [ -w "$state_dir" ] || exit 0
150
+ eligibility_file="$state_dir/checkpoint-gate-eligible-$runtime"
151
+
152
+ refresh_eligibility() {
153
+ command -v hq >/dev/null 2>&1 || return 0
154
+ (nohup env HQ_CHECKPOINT_RUNTIME="$runtime" hq core checkpoint --gate-probe >/dev/null 2>&1 &)
155
+ }
156
+
157
+ if [ "$runtime" = "codex" ] && [ "$enforce" = false ]; then
158
+ if [ ! -e "$eligibility_file" ]; then
159
+ refresh_eligibility
160
+ exit 0
161
+ fi
162
+ [ -r "$eligibility_file" ] || exit 0
163
+ eligibility="$(tr -d '\r\n' <"$eligibility_file" 2>/dev/null || true)"
164
+ case "$eligibility" in
165
+ 0) exit 0 ;;
166
+ 1)
167
+ # GNU `stat -f` succeeds but prints filesystem metadata, so try its
168
+ # file-mtime form first; macOS then falls back to `stat -f %m`.
169
+ eligibility_mtime="$(stat -c %Y "$eligibility_file" 2>/dev/null || stat -f %m "$eligibility_file" 2>/dev/null || true)"
170
+ case "$eligibility_mtime" in
171
+ ''|*[!0-9]*) exit 0 ;;
172
+ esac
173
+ now="$(date +%s 2>/dev/null || true)"
174
+ case "$now" in
175
+ ''|*[!0-9]*) exit 0 ;;
176
+ esac
177
+ if [ $((now - eligibility_mtime)) -gt 86400 ]; then
178
+ refresh_eligibility
179
+ fi
180
+ enforce=true
181
+ ;;
182
+ *) exit 0 ;;
183
+ esac
184
+ fi
185
+
186
+ # Do not infer a tool or session when Claude has not supplied the fields.
187
+ command -v hq >/dev/null 2>&1 || exit 0
188
+ [ -n "$transcript_path" ] && [ -f "$transcript_path" ] && [ -r "$transcript_path" ] || exit 0
189
+ [ -n "$session_id" ] || exit 0
190
+
191
+ session_key="$(hq_hook_safe_session_key "$session_id")"
192
+ [ -n "$session_key" ] || exit 0
193
+
194
+ # Parse only the recent JSONL tail. Claude and Codex persist different row
195
+ # shapes, so normalize both to the same user/tool boundary before checking
196
+ # whether the final action was the checkpoint command.
197
+ parsed="$(tail -n 400 "$transcript_path" 2>/dev/null | jq -Rrsc '
198
+ def claude_user:
199
+ .type == "user" and (
200
+ (.message.content? | type) == "string" or
201
+ ((.message.content? | type) == "array" and any(.message.content[]?; .type == "text"))
202
+ );
203
+ def codex_user:
204
+ .type == "response_item"
205
+ and .payload.type? == "message"
206
+ and .payload.role? == "user"
207
+ and ((.payload.content? | type) == "array")
208
+ and any(.payload.content[]?; .type == "input_text");
209
+ def real_user: claude_user or codex_user;
210
+ def javascript_object_cmd:
211
+ capture("(^|[,{}])\\s*cmd\\s*:\\s*(?<quoted>\"(?:\\\\.|[^\"\\\\])*\")")
212
+ | .quoted
213
+ | fromjson;
214
+ def codex_command:
215
+ if .name? == "exec" and ((.input? | type) == "string") then
216
+ try (
217
+ .input
218
+ | capture("tools\\.exec_command\\((?<args>\\{.*\\})\\);")
219
+ | .args
220
+ | try (fromjson | .cmd // "") catch javascript_object_cmd
221
+ ) catch ""
222
+ elif (.name? == "exec_command" or .name? == "Bash") then
223
+ if ((.input? | type) == "object") then
224
+ .input.cmd? // .input.command? // ""
225
+ elif ((.input? | type) == "string") then
226
+ try (.input | fromjson | .cmd? // .command? // "") catch .input
227
+ elif ((.arguments? | type) == "string") then
228
+ try (.arguments | fromjson | .cmd? // .command? // "") catch ""
229
+ else ""
230
+ end
231
+ else ""
232
+ end;
233
+ def checkpoint_command:
234
+ test("(^|[;&|(\\s])(command\\s+)?([A-Za-z_][A-Za-z0-9_]*=[^\\s]*\\s+)*hq\\s+core\\s+checkpoint(\\s|$)");
235
+ [split("\n")[] | select(length > 0) | {line: ., entry: (fromjson)}] as $rows
236
+ | [range(0; $rows | length) | select($rows[.].entry | real_user)] as $user_indexes
237
+ | if ($user_indexes | length) == 0 then error("no real user entry") else
238
+ $user_indexes[-1] as $user_index
239
+ | [
240
+ $rows[($user_index + 1):][]
241
+ | .entry
242
+ | if .type == "assistant" then
243
+ .message.content?
244
+ | if type == "array" then .[]? else empty end
245
+ | select(.type == "tool_use")
246
+ | {
247
+ runtime: "claude",
248
+ id: (.id? // ""),
249
+ name: (.name? // ""),
250
+ command: (.input.command? // "")
251
+ }
252
+ elif (
253
+ .type == "response_item"
254
+ and (.payload.type? == "custom_tool_call" or .payload.type? == "function_call")
255
+ ) then
256
+ .payload
257
+ | {
258
+ runtime: "codex",
259
+ id: (.call_id? // .id? // ""),
260
+ name: (.name? // ""),
261
+ command: codex_command
262
+ }
263
+ else empty
264
+ end
265
+ ] as $tools
266
+ | (if ($tools | length) > 0 then $tools[-1] else {} end) as $last_tool
267
+ | ([$tools[] | select((.command | checkpoint_command) | not)] | length) as $work_tool_count
268
+ | ([
269
+ $rows[]
270
+ | .entry
271
+ | select(.type == "user")
272
+ | .message.content?
273
+ | if type == "array" then .[]? else empty end
274
+ | select(.type == "tool_result" and (.tool_use_id? == $last_tool.id?))
275
+ | (.is_error? // false)
276
+ ] | any(. == true)) as $last_result_error
277
+ | if (
278
+ ($tools | length) > 0
279
+ and ($last_tool.command | checkpoint_command)
280
+ ) then
281
+ if $last_tool.runtime == "claude" and ($last_result_error | not) then "1"
282
+ elif $last_tool.runtime == "codex" then "stamp"
283
+ else "0"
284
+ end
285
+ elif $work_tool_count == 0 then "idle"
286
+ else "0"
287
+ end
288
+ end
289
+ ' 2>/dev/null)" || exit 0
290
+ satisfied="$parsed"
291
+ case "$satisfied" in
292
+ 0|1|stamp|idle) ;;
293
+ *) exit 0 ;;
294
+ esac
295
+
296
+ # Codex tool-result envelopes do not reliably expose a nested shell exit
297
+ # status. The CLI writes this session-scoped stamp only after a successful
298
+ # checkpoint, so require a fresh stamp in addition to the final command.
299
+ if [ "$satisfied" = "stamp" ]; then
300
+ satisfied=0
301
+ stamp_file="$state_dir/checkpoint-cli-last-$session_key"
302
+ if [ -r "$stamp_file" ]; then
303
+ stamp_epoch="$(tr -d '\r\n' <"$stamp_file" 2>/dev/null || true)"
304
+ now="$(date +%s 2>/dev/null || true)"
305
+ case "$stamp_epoch:$now" in
306
+ *[!0-9:]*|:*) ;;
307
+ *)
308
+ stamp_age=$((now - stamp_epoch))
309
+ if [ "$stamp_age" -ge 0 ] && [ "$stamp_age" -le 120 ]; then
310
+ satisfied=1
311
+ fi
312
+ ;;
313
+ esac
314
+ fi
315
+ fi
316
+
317
+ # "idle" means the turn called no tool other than the checkpoint itself, so
318
+ # there is nothing to record. Demanding one anyway turns every conversational
319
+ # reply — and every background-notification wake-up — into an --idle round
320
+ # trip that writes no state and only costs a turn.
321
+ if [ "$satisfied" = "1" ] || [ "$satisfied" = "idle" ]; then
322
+ if [ "$runtime" = "codex" ]; then
323
+ rm -f "$state_dir/codex-checkpoint-reprompt-$session_key" 2>/dev/null || true
324
+ fi
325
+ exit 0
326
+ fi
327
+
328
+ # Built with printf rather than concatenation so the session id can appear in
329
+ # 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\nSo: finish whatever you owe the user in the reply first, then run the checkpoint as the FINAL action of the turn and end the turn immediately after it — no further commentary once the command has run.\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")"
331
+
332
+ # Codex surfaces a blocked Stop reason as a synthetic user prompt. Preserve
333
+ # the actionable instruction out-of-band, then use the stable marker covered
334
+ # by the guidance preloaded at SessionStart.
335
+ if [ "$runtime" = "codex" ]; then
336
+ reprompt_file="$state_dir/codex-checkpoint-reprompt-$session_key"
337
+ reprompt_tmp="$reprompt_file.$$"
338
+ if (umask 077 && printf '%s' "$reason" >"$reprompt_tmp" && mv -f "$reprompt_tmp" "$reprompt_file"); then
339
+ reason='Hook re-prompted Codex'
340
+ else
341
+ rm -f "$reprompt_tmp" 2>/dev/null || true
342
+ fi
343
+ fi
344
+ printf '{"decision":"block","reason":%s}\n' "$(printf '%s' "$reason" | hq_json_encode)"
345
+ } 2>/dev/null || true
346
+
347
+ exit 0