@indigoai-us/hq-cli 5.98.2 → 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.
- package/CHANGELOG.md +52 -0
- package/assets/scaffold/core/scripts/checkpoint-stop-gate.sh +347 -0
- package/assets/scaffold/core/scripts/hook-lib.sh +557 -0
- package/assets/scaffold/core/scripts/hq-session.sh +251 -0
- package/assets/scaffold/core/scripts/lib/session-id.sh +96 -0
- package/assets/scaffold/core/scripts/lib/session-scope-capability.sh +52 -0
- package/dist/commands/core.js +25 -5
- package/dist/commands/doctor.d.ts +97 -0
- package/dist/commands/doctor.js +228 -0
- package/dist/commands/scaffold-fast.d.ts +41 -0
- package/dist/commands/scaffold-fast.js +57 -0
- package/dist/fast-core.d.ts +16 -0
- package/dist/fast-core.js +47 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +10 -1
- package/dist/lib/doctor/__testing__/fake-hq-tree.d.ts +194 -0
- package/dist/lib/doctor/__testing__/fake-hq-tree.js +357 -0
- package/dist/lib/doctor/allowed-divergence.d.ts +72 -0
- package/dist/lib/doctor/allowed-divergence.js +134 -0
- package/dist/lib/doctor/checks/claude-wiring.d.ts +55 -0
- package/dist/lib/doctor/checks/claude-wiring.js +524 -0
- package/dist/lib/doctor/checks/codex-wiring.d.ts +45 -0
- package/dist/lib/doctor/checks/codex-wiring.js +376 -0
- package/dist/lib/doctor/checks/grok-wiring.d.ts +35 -0
- package/dist/lib/doctor/checks/grok-wiring.js +186 -0
- package/dist/lib/doctor/checks/runtime-probe.d.ts +101 -0
- package/dist/lib/doctor/checks/runtime-probe.js +335 -0
- package/dist/lib/doctor/compat.d.ts +85 -0
- package/dist/lib/doctor/compat.js +102 -0
- package/dist/lib/doctor/deep/classify.d.ts +61 -0
- package/dist/lib/doctor/deep/classify.js +75 -0
- package/dist/lib/doctor/deep/effects.d.ts +107 -0
- package/dist/lib/doctor/deep/effects.js +229 -0
- package/dist/lib/doctor/deep/executor.d.ts +112 -0
- package/dist/lib/doctor/deep/executor.js +369 -0
- package/dist/lib/doctor/deep/parity.d.ts +129 -0
- package/dist/lib/doctor/deep/parity.js +355 -0
- package/dist/lib/doctor/deep/sandbox.d.ts +190 -0
- package/dist/lib/doctor/deep/sandbox.js +572 -0
- package/dist/lib/doctor/fix/apply.d.ts +119 -0
- package/dist/lib/doctor/fix/apply.js +352 -0
- package/dist/lib/doctor/fix/backup.d.ts +40 -0
- package/dist/lib/doctor/fix/backup.js +64 -0
- package/dist/lib/doctor/fix/remediation.d.ts +71 -0
- package/dist/lib/doctor/fix/remediation.js +103 -0
- package/dist/lib/doctor/fixtures/discover.d.ts +96 -0
- package/dist/lib/doctor/fixtures/discover.js +287 -0
- package/dist/lib/doctor/fixtures/schema.d.ts +171 -0
- package/dist/lib/doctor/fixtures/schema.js +248 -0
- package/dist/lib/doctor/hook-gate-profiles.d.ts +55 -0
- package/dist/lib/doctor/hook-gate-profiles.js +107 -0
- package/dist/lib/doctor/json-output.d.ts +90 -0
- package/dist/lib/doctor/json-output.js +76 -0
- package/dist/lib/doctor/payload-shapes.d.ts +170 -0
- package/dist/lib/doctor/payload-shapes.js +275 -0
- package/dist/lib/doctor/platform.d.ts +244 -0
- package/dist/lib/doctor/platform.js +490 -0
- package/dist/lib/doctor/registry.d.ts +49 -0
- package/dist/lib/doctor/registry.js +176 -0
- package/dist/lib/doctor/report.d.ts +87 -0
- package/dist/lib/doctor/report.js +164 -0
- package/dist/lib/doctor/types.d.ts +87 -0
- package/dist/lib/doctor/types.js +29 -0
- package/dist/main.js +6 -0
- package/dist/utils/hook-trust.d.ts +10 -13
- package/dist/utils/hook-trust.js +148 -27
- package/dist/utils/version-check.js +2 -2
- package/dist/utils/version-gate.d.ts +1 -1
- package/dist/utils/version-gate.js +1 -1
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,58 @@
|
|
|
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
|
+
|
|
46
|
+
## [5.98.3] — 2026-08-11
|
|
47
|
+
|
|
48
|
+
### Fixed
|
|
49
|
+
|
|
50
|
+
- Session-log capture now records Claude/Codex/Grok transcripts even when the
|
|
51
|
+
HQ root is reached through a symlink or case-aliased path (for example,
|
|
52
|
+
`/Users/x/HQ` versus its realpath): `hq reindex` matches the recorded working
|
|
53
|
+
directory against both the lexical root and its realpath. Delivered by bumping
|
|
54
|
+
the bundled `@indigoai-us/hq-cloud` runtime to 6.14.49
|
|
55
|
+
(indigoai-us/hq-cloud#298).
|
|
56
|
+
|
|
5
57
|
## [5.98.2] — 2026-08-11
|
|
6
58
|
|
|
7
59
|
### 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
|