@indigoai-us/hq-cli 5.98.3 → 5.99.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +43 -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-checkpoint.js +11 -3
- package/dist/commands/core.js +60 -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/core-utils/soft-timeout.d.ts +55 -0
- package/dist/lib/core-utils/soft-timeout.js +205 -0
- package/dist/lib/core-utils/timeout-guard.d.ts +62 -0
- package/dist/lib/core-utils/timeout-guard.js +207 -0
- 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/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 +1 -1
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# hq-core: public
|
|
3
|
+
# hq-session.sh — read/write the current session's metadata.
|
|
4
|
+
#
|
|
5
|
+
# Usage:
|
|
6
|
+
# core/scripts/hq-session.sh current # print current session_id (or empty)
|
|
7
|
+
# core/scripts/hq-session.sh path # print path to current meta.yaml
|
|
8
|
+
# core/scripts/hq-session.sh get <key> # read a key from meta.yaml
|
|
9
|
+
# core/scripts/hq-session.sh set <key> <value> # set/replace a top-level key
|
|
10
|
+
#
|
|
11
|
+
# Reserved value:
|
|
12
|
+
# set company_slug personal # bind the session to no-company (personal) work
|
|
13
|
+
#
|
|
14
|
+
# Global option (before the subcommand):
|
|
15
|
+
# --session-id <id> operate on this session instead of the resolved one
|
|
16
|
+
#
|
|
17
|
+
# Session bootstrapping is owned by .claude/hooks/master-hook.sh, which
|
|
18
|
+
# writes workspace/sessions/.current and ensures
|
|
19
|
+
# workspace/sessions/<session_id>/meta.yaml exists on every hook event.
|
|
20
|
+
#
|
|
21
|
+
# "Current session" is resolved by core/scripts/lib/session-id.sh: the session id
|
|
22
|
+
# exported into this process wins, and workspace/sessions/.current is only a
|
|
23
|
+
# fallback. .current is a single global pointer rewritten by every hook event, so
|
|
24
|
+
# with concurrent sessions it can name someone else's session — trusting it made
|
|
25
|
+
# `set company_slug` write to the wrong session's meta.yaml while the calling
|
|
26
|
+
# session stayed unbound and blocked by the scope guard.
|
|
27
|
+
|
|
28
|
+
set -euo pipefail
|
|
29
|
+
|
|
30
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
31
|
+
# The DATA tree (sessions, companies, hooks) is the live HQ root. When this
|
|
32
|
+
# script is dispatched as `hq core hq-session` from inside the hq-cli package,
|
|
33
|
+
# its own location is NOT that tree — the CLI injects the live root via
|
|
34
|
+
# HQ_ROOT / CLAUDE_PROJECT_DIR. Prefer those; fall back to the self-relative
|
|
35
|
+
# root so a loose-file (in-tree) invocation keeps resolving exactly as before.
|
|
36
|
+
REPO_ROOT="${HQ_ROOT:-${CLAUDE_PROJECT_DIR:-$(cd "$SCRIPT_DIR/../.." && pwd)}}"
|
|
37
|
+
SESSIONS_DIR="$REPO_ROOT/workspace/sessions"
|
|
38
|
+
# Libs are sourced from THIS script's own directory, so a bundled dispatch loads
|
|
39
|
+
# the bundled copies and a loose-file invocation loads the tree's copies.
|
|
40
|
+
LIB_DIR="$SCRIPT_DIR/lib"
|
|
41
|
+
# shellcheck source=lib/session-scope-capability.sh
|
|
42
|
+
. "$LIB_DIR/session-scope-capability.sh"
|
|
43
|
+
# shellcheck source=lib/session-id.sh
|
|
44
|
+
. "$LIB_DIR/session-id.sh"
|
|
45
|
+
|
|
46
|
+
# Set by the --session-id global option; empty means "resolve it".
|
|
47
|
+
SESSION_ID_OVERRIDE=""
|
|
48
|
+
|
|
49
|
+
usage() {
|
|
50
|
+
sed -n '2,15p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
current_id() {
|
|
54
|
+
if [ -n "$SESSION_ID_OVERRIDE" ]; then
|
|
55
|
+
printf '%s' "$SESSION_ID_OVERRIDE"
|
|
56
|
+
return 0
|
|
57
|
+
fi
|
|
58
|
+
session_id_resolve "$REPO_ROOT"
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
current_meta() {
|
|
62
|
+
local id
|
|
63
|
+
id="$(current_id)"
|
|
64
|
+
[ -n "$id" ] || return 0
|
|
65
|
+
printf '%s/%s/meta.yaml' "$SESSIONS_DIR" "$id"
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
cmd_current() {
|
|
69
|
+
current_id
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
cmd_path() {
|
|
73
|
+
current_meta
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
cmd_get() {
|
|
77
|
+
local key="${1:-}"
|
|
78
|
+
[ -n "$key" ] || { echo "usage: hq-session.sh get <key>" >&2; exit 1; }
|
|
79
|
+
local meta
|
|
80
|
+
meta="$(current_meta)"
|
|
81
|
+
[ -n "$meta" ] && [ -f "$meta" ] || return 0
|
|
82
|
+
awk -v k="$key" '
|
|
83
|
+
$1 == k":" {
|
|
84
|
+
sub(/^[^:]+:[[:space:]]*/, "")
|
|
85
|
+
gsub(/^"|"$/, "")
|
|
86
|
+
print
|
|
87
|
+
exit
|
|
88
|
+
}
|
|
89
|
+
' "$meta"
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
cmd_set() {
|
|
93
|
+
local key="${1:-}" value="${2:-}"
|
|
94
|
+
[ -n "$key" ] && [ $# -ge 2 ] || { echo "usage: hq-session.sh set <key> <value>" >&2; exit 1; }
|
|
95
|
+
|
|
96
|
+
# Reject an invalid company_slug BEFORE writing anything. Otherwise a bad value
|
|
97
|
+
# lands in meta.yaml and only fails later at session_scope_mint, leaving
|
|
98
|
+
# corrupt state that the scope gate would read as a (bogus) binding. Mirrors
|
|
99
|
+
# session_scope_mint's own character-set rule; `personal` is a valid slug.
|
|
100
|
+
if [ "$key" = "company_slug" ] && [ -n "$value" ]; then
|
|
101
|
+
case "$value" in
|
|
102
|
+
*[!a-z0-9_-]*)
|
|
103
|
+
echo "hq-session: invalid company_slug: '$value' (allowed characters: a-z 0-9 - _)" >&2
|
|
104
|
+
exit 1
|
|
105
|
+
;;
|
|
106
|
+
esac
|
|
107
|
+
fi
|
|
108
|
+
|
|
109
|
+
local id meta
|
|
110
|
+
id="$(current_id)"
|
|
111
|
+
if [ -z "$id" ]; then
|
|
112
|
+
echo "hq-session: no current session — no session id in the environment (HQ_SESSION_ID, CLAUDE_CODE_SESSION_ID, CLAUDE_SESSION_ID, CODEX_SESSION_ID, CODEX_THREAD_ID) and workspace/sessions/.current is missing or invalid. Is master-hook installed? Pass --session-id <id> to target a session explicitly." >&2
|
|
113
|
+
exit 1
|
|
114
|
+
fi
|
|
115
|
+
meta="$SESSIONS_DIR/$id/meta.yaml"
|
|
116
|
+
mkdir -p "$(dirname "$meta")"
|
|
117
|
+
# Seed the same header master-hook.sh writes, so binding a session the hook
|
|
118
|
+
# has not bootstrapped yet still produces a well-formed record.
|
|
119
|
+
if [ ! -f "$meta" ]; then
|
|
120
|
+
printf 'session_id: %s\nstarted_at: "%s"\n' \
|
|
121
|
+
"$id" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$meta"
|
|
122
|
+
fi
|
|
123
|
+
|
|
124
|
+
# Capture the prior value so we only surface policies when it actually changes.
|
|
125
|
+
local prev=""
|
|
126
|
+
prev="$(cmd_get "$key" 2>/dev/null || true)"
|
|
127
|
+
|
|
128
|
+
local tmp
|
|
129
|
+
tmp="$(mktemp)"
|
|
130
|
+
awk -v k="$key" -v v="$value" '
|
|
131
|
+
BEGIN { found = 0 }
|
|
132
|
+
$1 == k":" { print k": " v; found = 1; next }
|
|
133
|
+
{ print }
|
|
134
|
+
END { if (!found) print k": " v }
|
|
135
|
+
' "$meta" > "$tmp"
|
|
136
|
+
mv "$tmp" "$meta"
|
|
137
|
+
|
|
138
|
+
# When a company is bound (or rebound) mid-session, surface that company's
|
|
139
|
+
# hard-enforcement policies into this Bash tool result. SessionStart only
|
|
140
|
+
# injects company policies for the company known *at start*; binding a
|
|
141
|
+
# company afterward (this path) otherwise surfaces nothing, so an agent can
|
|
142
|
+
# do company infra/deploy/credential work blind to hard rules. This closes
|
|
143
|
+
# that gap. Emits policy text only — never secrets.
|
|
144
|
+
if [ "$key" = "company_slug" ] && [ -n "$value" ] && [ "$value" != "$prev" ]; then
|
|
145
|
+
session_scope_mint "$REPO_ROOT" "$id" "$value" || {
|
|
146
|
+
echo "hq-session: failed to mint scope-capability for session $id" >&2
|
|
147
|
+
exit 1
|
|
148
|
+
}
|
|
149
|
+
# `personal` is the reserved scope for work that belongs to no company. It
|
|
150
|
+
# binds the session — satisfying the checkpoint company gate and scoping the
|
|
151
|
+
# authorizer to personal/ — but it has no tenant directory, so there are no
|
|
152
|
+
# company hard-policies to surface and nothing to register with the
|
|
153
|
+
# company-scoped Work Mesh. Everything else is a real company bind.
|
|
154
|
+
if [ "$value" != "personal" ]; then
|
|
155
|
+
emit_company_hard_policies "$value"
|
|
156
|
+
# Fire-and-forget: register this company bind with the Work Mesh (US-003).
|
|
157
|
+
# Fully silent (all output → the hook's own bounded log) and guarded so it
|
|
158
|
+
# can neither fail cmd_set under `set -euo pipefail` nor delay its return.
|
|
159
|
+
spawn_work_mesh_register "$id" || true
|
|
160
|
+
fi
|
|
161
|
+
fi
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
# Spawn the client-side Work Mesh registration hook, detached, for a mid-session
|
|
165
|
+
# company bind. No-ops silently when the hook or jq is absent (e.g. sandboxed
|
|
166
|
+
# tests copy only hq-session.sh). Never blocks: the hook itself is fire-and-forget
|
|
167
|
+
# and this backgrounds even its fast foreground path off cmd_set's return path.
|
|
168
|
+
spawn_work_mesh_register() {
|
|
169
|
+
local sid="$1"
|
|
170
|
+
local hook="$REPO_ROOT/core/hooks/work-mesh-register.sh"
|
|
171
|
+
local logf="$REPO_ROOT/workspace/logs/work-mesh-hook.log"
|
|
172
|
+
[ -x "$hook" ] || return 0
|
|
173
|
+
command -v jq >/dev/null 2>&1 || return 0
|
|
174
|
+
local ev
|
|
175
|
+
ev="$(jq -nc --arg sid "$sid" --arg cwd "${PWD:-}" '{session_id:$sid,cwd:$cwd}' 2>/dev/null)" || return 0
|
|
176
|
+
[ -n "$ev" ] || return 0
|
|
177
|
+
mkdir -p "$(dirname "$logf")" 2>/dev/null || true
|
|
178
|
+
HQ_ROOT="$REPO_ROOT" nohup bash -c 'printf "%s" "$1" | "$2" company_slug' _ "$ev" "$hook" \
|
|
179
|
+
>>"$logf" 2>&1 </dev/null &
|
|
180
|
+
disown 2>/dev/null || true
|
|
181
|
+
return 0
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
# Print a company's hard-enforcement policies, read directly from the policy
|
|
185
|
+
# files (the pre-built digest was retired — the when/on trigger hook is now the
|
|
186
|
+
# sole policy-surfacing path). Emits one `- [hard] **slug**: rule` line each.
|
|
187
|
+
emit_company_hard_policies() {
|
|
188
|
+
local co="$1"
|
|
189
|
+
local dir="$REPO_ROOT/companies/$co/policies"
|
|
190
|
+
[ -d "$dir" ] || return 0
|
|
191
|
+
# Guard the glob: with no matching files "$dir"/*.md expands literally, awk
|
|
192
|
+
# exits nonzero, and under `set -e` that would abort an otherwise fine bind.
|
|
193
|
+
local have=0 f
|
|
194
|
+
for f in "$dir"/*.md; do
|
|
195
|
+
[ -e "$f" ] && { have=1; break; }
|
|
196
|
+
done
|
|
197
|
+
[ "$have" = 1 ] || return 0
|
|
198
|
+
local lines
|
|
199
|
+
lines="$(awk '
|
|
200
|
+
function bn(p, n,a,b){ n=split(p,a,"/"); b=a[n]; sub(/\.md$/,"",b); return b }
|
|
201
|
+
function flush(){ if(enf=="hard" && rule!=""){ if(id=="")id=bn(fn); printf "- [hard] **%s**: %s\n", id, rule } }
|
|
202
|
+
FNR==1 { if(seen) flush(); d=0;id="";enf="";rule="";rsec=0;rcap=0;fn=FILENAME;seen=1 }
|
|
203
|
+
/^---[ \t]*$/ { d++; next }
|
|
204
|
+
d==1 && /^id:/ { s=$0; sub(/^id:[ \t]*/,"",s); gsub(/^["'"'"']|["'"'"']$/,"",s); id=s; next }
|
|
205
|
+
d==1 && /^enforcement:/ { s=$0; sub(/^enforcement:[ \t]*/,"",s); gsub(/[ \t]/,"",s); enf=s; next }
|
|
206
|
+
d>=2 && /^## Rule[ \t]*$/ { rsec=1; next }
|
|
207
|
+
d>=2 && rsec && /^## / { rsec=0 }
|
|
208
|
+
d>=2 && rsec && !rcap && NF { line=$0; gsub(/\*\*/,"",line); if(length(line)>160)line=substr(line,1,157)"..."; rule=line; rcap=1 }
|
|
209
|
+
END { if(seen) flush() }
|
|
210
|
+
' "$dir"/*.md 2>/dev/null)"
|
|
211
|
+
[ -z "$lines" ] && return 0
|
|
212
|
+
printf '\n<company-policy-digest co="%s">\n' "$co"
|
|
213
|
+
printf '# %s hard-enforcement policies (auto-surfaced on company bind)\n' "$co"
|
|
214
|
+
printf '> Company context just bound mid-session. These HARD rules now apply.\n'
|
|
215
|
+
printf '> Full text: `companies/%s/policies/{slug}.md` (or `qmd get -c %s {slug}`).\n\n' "$co" "$co"
|
|
216
|
+
printf '%s\n' "$lines"
|
|
217
|
+
printf '</company-policy-digest>\n'
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
while [ $# -gt 0 ]; do
|
|
221
|
+
case "${1:-}" in
|
|
222
|
+
--session-id)
|
|
223
|
+
SESSION_ID_OVERRIDE="${2:-}"
|
|
224
|
+
if ! session_id_is_valid "$SESSION_ID_OVERRIDE"; then
|
|
225
|
+
echo "hq-session: invalid --session-id: '${SESSION_ID_OVERRIDE}'" >&2
|
|
226
|
+
exit 1
|
|
227
|
+
fi
|
|
228
|
+
shift 2
|
|
229
|
+
;;
|
|
230
|
+
--session-id=*)
|
|
231
|
+
SESSION_ID_OVERRIDE="${1#--session-id=}"
|
|
232
|
+
if ! session_id_is_valid "$SESSION_ID_OVERRIDE"; then
|
|
233
|
+
echo "hq-session: invalid --session-id: '${SESSION_ID_OVERRIDE}'" >&2
|
|
234
|
+
exit 1
|
|
235
|
+
fi
|
|
236
|
+
shift
|
|
237
|
+
;;
|
|
238
|
+
*) break ;;
|
|
239
|
+
esac
|
|
240
|
+
done
|
|
241
|
+
|
|
242
|
+
sub="${1:-}"
|
|
243
|
+
shift || true
|
|
244
|
+
case "$sub" in
|
|
245
|
+
current) cmd_current "$@" ;;
|
|
246
|
+
path) cmd_path "$@" ;;
|
|
247
|
+
get) cmd_get "$@" ;;
|
|
248
|
+
set) cmd_set "$@" ;;
|
|
249
|
+
""|-h|--help|help) usage ;;
|
|
250
|
+
*) echo "hq-session: unknown subcommand '$sub'" >&2; usage >&2; exit 1 ;;
|
|
251
|
+
esac
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# hq-core: public
|
|
3
|
+
# session-id.sh — resolve the session id the CURRENT process belongs to.
|
|
4
|
+
#
|
|
5
|
+
# Sourced by core/scripts/hq-session.sh and .claude/hooks/mandatory-scope-authorizer.sh.
|
|
6
|
+
# Never execute directly.
|
|
7
|
+
#
|
|
8
|
+
# WHY THIS EXISTS
|
|
9
|
+
#
|
|
10
|
+
# workspace/sessions/.current is a single, global, last-writer-wins pointer
|
|
11
|
+
# maintained by .claude/hooks/master-hook.sh: every hook event overwrites it with
|
|
12
|
+
# the session id from that event's payload. With two sessions alive at once — an
|
|
13
|
+
# interactive session plus a scheduled/agent run, or two terminals — .current
|
|
14
|
+
# names whichever session fired a hook most recently, NOT the session the running
|
|
15
|
+
# command belongs to.
|
|
16
|
+
#
|
|
17
|
+
# That matters because the enforcement side does not use .current. The scope
|
|
18
|
+
# guard (.claude/hooks/mandatory-scope-authorizer.sh) reads the authoritative
|
|
19
|
+
# session id out of the hook payload. So a helper that resolved "this session"
|
|
20
|
+
# through .current could write company_slug into a DIFFERENT session's record,
|
|
21
|
+
# report success, and leave the calling session unbound — the guard keeps
|
|
22
|
+
# blocking, and a foreign session silently acquires a company binding it never
|
|
23
|
+
# asked for.
|
|
24
|
+
#
|
|
25
|
+
# The session id is also exported into every child process by the host, so an
|
|
26
|
+
# environment variable always describes the session that spawned this command.
|
|
27
|
+
# Prefer it, and keep .current only as a fallback for invocations that have no
|
|
28
|
+
# session environment at all (cron, CI, a bare shell).
|
|
29
|
+
#
|
|
30
|
+
# RESOLUTION ORDER (first syntactically valid, non-empty value wins)
|
|
31
|
+
# 1. HQ_SESSION_ID — explicit caller/test override
|
|
32
|
+
# 2. CLAUDE_CODE_SESSION_ID — Claude Code CLI and app
|
|
33
|
+
# 3. CLAUDE_SESSION_ID — Claude Code (alternate/legacy)
|
|
34
|
+
# 4. CODEX_SESSION_ID — Codex
|
|
35
|
+
# 5. CODEX_THREAD_ID — Codex, thread-scoped runs
|
|
36
|
+
# 6. workspace/sessions/.current — no session env; last-writer-wins fallback
|
|
37
|
+
#
|
|
38
|
+
# The env precedence list matches .claude/skills/_shared/journal.sh so every
|
|
39
|
+
# per-session artifact (journal, session meta, scope capability) keys off the
|
|
40
|
+
# same identity.
|
|
41
|
+
|
|
42
|
+
# session_id_is_valid <id>
|
|
43
|
+
# Session ids become a path segment under workspace/sessions/, so restrict them
|
|
44
|
+
# to a conservative charset and reject the traversal spellings outright.
|
|
45
|
+
session_id_is_valid() {
|
|
46
|
+
local id="${1:-}"
|
|
47
|
+
[ -n "$id" ] || return 1
|
|
48
|
+
case "$id" in
|
|
49
|
+
.|..) return 1 ;;
|
|
50
|
+
*[!A-Za-z0-9._-]*) return 1 ;;
|
|
51
|
+
esac
|
|
52
|
+
return 0
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
# session_id_from_env
|
|
56
|
+
# Print the first valid session id found in the environment, or nothing.
|
|
57
|
+
# An unset, empty, or malformed variable is skipped rather than fatal, so a
|
|
58
|
+
# stray value in one host cannot wedge every helper.
|
|
59
|
+
session_id_from_env() {
|
|
60
|
+
local var val
|
|
61
|
+
for var in HQ_SESSION_ID CLAUDE_CODE_SESSION_ID CLAUDE_SESSION_ID \
|
|
62
|
+
CODEX_SESSION_ID CODEX_THREAD_ID; do
|
|
63
|
+
val="${!var:-}"
|
|
64
|
+
val="$(printf '%s' "$val" | tr -d '[:space:]')"
|
|
65
|
+
if session_id_is_valid "$val"; then
|
|
66
|
+
printf '%s' "$val"
|
|
67
|
+
return 0
|
|
68
|
+
fi
|
|
69
|
+
done
|
|
70
|
+
return 0
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
# session_id_from_current_file <root>
|
|
74
|
+
# Print the id in workspace/sessions/.current, or nothing when absent/invalid.
|
|
75
|
+
session_id_from_current_file() {
|
|
76
|
+
local root="${1:-}" file val
|
|
77
|
+
[ -n "$root" ] || return 0
|
|
78
|
+
file="$root/workspace/sessions/.current"
|
|
79
|
+
[ -f "$file" ] || return 0
|
|
80
|
+
val="$(tr -d '[:space:]' < "$file" 2>/dev/null || true)"
|
|
81
|
+
session_id_is_valid "$val" && printf '%s' "$val"
|
|
82
|
+
return 0
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
# session_id_resolve <root>
|
|
86
|
+
# Print this process's session id: environment first, .current as fallback.
|
|
87
|
+
# Prints nothing (exit 0) when neither source yields a valid id — callers that
|
|
88
|
+
# need one must fail loudly themselves.
|
|
89
|
+
session_id_resolve() {
|
|
90
|
+
local root="${1:-}" id
|
|
91
|
+
id="$(session_id_from_env)"
|
|
92
|
+
if [ -z "$id" ]; then
|
|
93
|
+
id="$(session_id_from_current_file "$root")"
|
|
94
|
+
fi
|
|
95
|
+
printf '%s' "$id"
|
|
96
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# hq-core: public
|
|
3
|
+
# session-scope-capability.sh — mint/read workspace/sessions/<id>/scope-capability.json
|
|
4
|
+
#
|
|
5
|
+
# Sourced by core/scripts/hq-session.sh and .claude/hooks/mandatory-scope-authorizer.sh.
|
|
6
|
+
# Never execute directly.
|
|
7
|
+
|
|
8
|
+
# session_scope_capability_path <root> <session_id>
|
|
9
|
+
# Print the absolute path to scope-capability.json for a session.
|
|
10
|
+
session_scope_capability_path() {
|
|
11
|
+
local root="${1:-}" sid="${2:-}"
|
|
12
|
+
[ -n "$root" ] && [ -n "$sid" ] || return 1
|
|
13
|
+
printf '%s/workspace/sessions/%s/scope-capability.json' "$root" "$sid"
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
# session_scope_read <root> <session_id>
|
|
17
|
+
# Print company_slug from scope-capability.json, or empty when absent/invalid.
|
|
18
|
+
session_scope_read() {
|
|
19
|
+
local root="${1:-}" sid="${2:-}" cap
|
|
20
|
+
cap="$(session_scope_capability_path "$root" "$sid")" || return 0
|
|
21
|
+
[ -f "$cap" ] || return 0
|
|
22
|
+
jq -r '.company_slug // empty' "$cap" 2>/dev/null || true
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
# session_scope_mint <root> <session_id> <company_slug>
|
|
26
|
+
# Write scope-capability.json for the session. Returns 0 on success.
|
|
27
|
+
session_scope_mint() {
|
|
28
|
+
local root="${1:-}" sid="${2:-}" slug="${3:-}"
|
|
29
|
+
[ -n "$root" ] && [ -n "$sid" ] && [ -n "$slug" ] || return 1
|
|
30
|
+
|
|
31
|
+
case "$slug" in
|
|
32
|
+
''|*[!a-z0-9_-]*)
|
|
33
|
+
echo "session-scope-capability: invalid company_slug: $slug" >&2
|
|
34
|
+
return 1
|
|
35
|
+
;;
|
|
36
|
+
esac
|
|
37
|
+
|
|
38
|
+
local cap dir tmp minted_at
|
|
39
|
+
cap="$(session_scope_capability_path "$root" "$sid")" || return 1
|
|
40
|
+
dir="$(dirname "$cap")"
|
|
41
|
+
mkdir -p "$dir" || return 1
|
|
42
|
+
|
|
43
|
+
minted_at="$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u +%Y-%m-%dT%H:%M:%S'Z')"
|
|
44
|
+
tmp="$(mktemp)"
|
|
45
|
+
jq -n \
|
|
46
|
+
--arg sid "$sid" \
|
|
47
|
+
--arg slug "$slug" \
|
|
48
|
+
--arg minted_at "$minted_at" \
|
|
49
|
+
'{session_id: $sid, company_slug: $slug, minted_at: $minted_at}' >"$tmp" \
|
|
50
|
+
|| { rm -f "$tmp"; return 1; }
|
|
51
|
+
mv "$tmp" "$cap"
|
|
52
|
+
}
|
|
@@ -552,7 +552,7 @@ export function siblingArgs(backend, prompt) {
|
|
|
552
552
|
// journal helper, the queue-draining `mv` — hits an approval gate with
|
|
553
553
|
// no human attached; headless `claude -p` then denies it and the run
|
|
554
554
|
// stalls having written no report. `bypassPermissions` is the same
|
|
555
|
-
// unattended posture codex gets from `-s
|
|
555
|
+
// unattended posture codex gets from `-s danger-full-access` and grok from
|
|
556
556
|
// `--always-approve --sandbox workspace`. Hooks still fire natively —
|
|
557
557
|
// this only removes the interactive approval gate, not the hook chain.
|
|
558
558
|
"--permission-mode",
|
|
@@ -570,7 +570,7 @@ export function siblingArgs(backend, prompt) {
|
|
|
570
570
|
// scan, the journal helper — then hits an approval gate with no human
|
|
571
571
|
// attached, and grok cancels the whole run: `stopReason: cancelled,
|
|
572
572
|
// cancellationCategory: PermissionCancelled`. Unattended approval plus
|
|
573
|
-
// a sandbox is the same posture codex gets from `-s
|
|
573
|
+
// a sandbox is the same posture codex gets from `-s danger-full-access`.
|
|
574
574
|
"--always-approve",
|
|
575
575
|
"--sandbox",
|
|
576
576
|
"workspace",
|
|
@@ -581,8 +581,16 @@ export function siblingArgs(backend, prompt) {
|
|
|
581
581
|
return [
|
|
582
582
|
"exec",
|
|
583
583
|
"--skip-git-repo-check",
|
|
584
|
+
// danger-full-access, NOT workspace-write. `-s workspace-write` force-
|
|
585
|
+
// enables Codex's restrictive sandbox, which denies the temp/cache/socket
|
|
586
|
+
// writes the maintenance sibling needs on macOS (git/Xcode cache, zsh/asdf
|
|
587
|
+
// heredocs) and cannot even initialize bubblewrap on some Linux hosts
|
|
588
|
+
// (`bwrap: loopback: Failed RTM_NEWADDR`) — HQ harness finding 2.3, which
|
|
589
|
+
// wedged the sibling before it wrote any report. HQ's safety boundary is
|
|
590
|
+
// its hooks, which still fire here via --dangerously-bypass-hook-trust;
|
|
591
|
+
// the sandbox is not what guards the HQ root.
|
|
584
592
|
"-s",
|
|
585
|
-
"
|
|
593
|
+
"danger-full-access",
|
|
586
594
|
"--dangerously-bypass-hook-trust",
|
|
587
595
|
"-m",
|
|
588
596
|
CODEX_SIBLING_MODEL,
|
package/dist/commands/core.js
CHANGED
|
@@ -44,6 +44,8 @@ import { createWorktree } from "../lib/core-utils/worktree.js";
|
|
|
44
44
|
import { runCodexSkillBridgeCommand } from "../lib/core-utils/codex-skill-bridge-entry.js";
|
|
45
45
|
import { archiveOldThreads } from "../lib/core-utils/archive-old-threads.js";
|
|
46
46
|
import { qmdReindexAfterSync } from "../lib/core-utils/qmd-reindex-after-sync.js";
|
|
47
|
+
import { softTimeoutCommand } from "../lib/core-utils/soft-timeout.js";
|
|
48
|
+
import { timeoutGuardCommand } from "../lib/core-utils/timeout-guard.js";
|
|
47
49
|
/**
|
|
48
50
|
* These commands are now native implementations. Their retained shell assets
|
|
49
51
|
* are deliberately claimed in SCAFFOLD_ONLY_ASSETS: loose HQ trees still ship
|
|
@@ -164,11 +166,24 @@ export const REBUILD_INDEX_TARGETS = [
|
|
|
164
166
|
* would make the forwarders harder to audit against this table.
|
|
165
167
|
*/
|
|
166
168
|
export const SCAFFOLD_COMMANDS = [
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
//
|
|
169
|
+
// Session/harness plumbing relocated out of the HQ tree so a single published
|
|
170
|
+
// CLI owns it. Both are `root: "live"`: they read and write the live HQ tree
|
|
171
|
+
// (workspace/sessions, companies/, workspace/orchestrator/hook-state), so the
|
|
172
|
+
// CLI resolves and injects the live root, exactly as their in-tree originals
|
|
173
|
+
// derived it. Their loose-file copies remain as thin fallbacks for a tree
|
|
174
|
+
// whose installed CLI predates this command.
|
|
175
|
+
{
|
|
176
|
+
name: "checkpoint-stop-gate",
|
|
177
|
+
asset: "core/scripts/checkpoint-stop-gate.sh",
|
|
178
|
+
root: "live",
|
|
179
|
+
summary: "Stop-hook checkpoint + company-scope gate (reads the hook payload on stdin)",
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
name: "hq-session",
|
|
183
|
+
asset: "core/scripts/hq-session.sh",
|
|
184
|
+
root: "live",
|
|
185
|
+
summary: "Read/write the current session's meta.yaml (get/set/current/path)",
|
|
186
|
+
},
|
|
172
187
|
];
|
|
173
188
|
/**
|
|
174
189
|
* Bundled solely for HQ-root scaffolds, which still need these as loose files.
|
|
@@ -176,6 +191,13 @@ export const SCAFFOLD_COMMANDS = [
|
|
|
176
191
|
* direct `hq core` command.
|
|
177
192
|
*/
|
|
178
193
|
export const SCAFFOLD_ONLY_ASSETS = [
|
|
194
|
+
// Shared shell libs sourced by the two relocated scripts. Never dispatched on
|
|
195
|
+
// their own (so no `hq core` command), but they MUST ship in the tarball, or
|
|
196
|
+
// the bundled hq-session / checkpoint-stop-gate die at `source` time on a
|
|
197
|
+
// user's box. `root` is inert here — they are sourced, not run.
|
|
198
|
+
{ asset: "core/scripts/hook-lib.sh", root: "live", summary: "Shared shell primitives sourced by the checkpoint gate" },
|
|
199
|
+
{ asset: "core/scripts/lib/session-scope-capability.sh", root: "live", summary: "Session scope-capability lib sourced by hq-session" },
|
|
200
|
+
{ asset: "core/scripts/lib/session-id.sh", root: "live", summary: "Session-id resolver lib sourced by hq-session" },
|
|
179
201
|
{ asset: "core/scripts/archive-old-threads.sh", root: "live", summary: "Scaffold-only native utility companion" },
|
|
180
202
|
{ asset: "core/scripts/codex-skill-bridge.sh", root: "live", summary: "Scaffold-only native utility companion" },
|
|
181
203
|
{ asset: "core/scripts/qmd-reindex-after-sync.sh", root: "cwd", summary: "Scaffold-only native utility companion" },
|
|
@@ -401,6 +423,39 @@ export function registerCoreCommands(program) {
|
|
|
401
423
|
runCommand(entry, args, cmd);
|
|
402
424
|
});
|
|
403
425
|
}
|
|
426
|
+
// `hq core soft-timeout <interval> [--hard-cap <spec>] [--label <name>] -- <cmd…>`
|
|
427
|
+
// Async and bespoke (the NATIVE_UTILITY_COMMANDS table is synchronous), since
|
|
428
|
+
// it spawns a child and warns over time. Warn-don't-kill primitive for
|
|
429
|
+
// finding 2.1 — the CLI-hosted twin of core/scripts/lib/soft-timeout.sh.
|
|
430
|
+
core
|
|
431
|
+
.command("soft-timeout")
|
|
432
|
+
.description("Run a command under a soft (warn, don't kill) timeout")
|
|
433
|
+
// Passthrough: the wrapped command owns everything after `--`.
|
|
434
|
+
.allowUnknownOption()
|
|
435
|
+
.allowExcessArguments()
|
|
436
|
+
.helpOption(false)
|
|
437
|
+
.argument("[args...]", "<interval> [--hard-cap <spec>] [--label <name>] -- <cmd...>")
|
|
438
|
+
.action(async (args = [], _opts, cmd) => {
|
|
439
|
+
const operands = cmd.args.length > 0 ? cmd.args : args;
|
|
440
|
+
const code = await softTimeoutCommand(operands);
|
|
441
|
+
if (code !== 0)
|
|
442
|
+
process.exit(code);
|
|
443
|
+
});
|
|
444
|
+
// `hq core timeout-guard` — PreToolUse decision for the foreground-timeout
|
|
445
|
+
// guard (finding 2.1). Reads the hook JSON on stdin and exits 2 to block, 0
|
|
446
|
+
// to allow. The shipped .claude hook is a thin shim over this.
|
|
447
|
+
core
|
|
448
|
+
.command("timeout-guard")
|
|
449
|
+
.description("PreToolUse guard: block over-ceiling foreground timeouts (reads hook JSON on stdin)")
|
|
450
|
+
.helpOption(false)
|
|
451
|
+
.action(async () => {
|
|
452
|
+
const chunks = [];
|
|
453
|
+
for await (const chunk of process.stdin)
|
|
454
|
+
chunks.push(chunk);
|
|
455
|
+
const code = timeoutGuardCommand(Buffer.concat(chunks).toString("utf8"));
|
|
456
|
+
if (code !== 0)
|
|
457
|
+
process.exit(code);
|
|
458
|
+
});
|
|
404
459
|
return core;
|
|
405
460
|
}
|
|
406
461
|
//# sourceMappingURL=core.js.map
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hq doctor` — verify HQ's hook guardrails are wired and firing.
|
|
3
|
+
*
|
|
4
|
+
* US-002 builds the command skeleton and the extensible check registry:
|
|
5
|
+
* - HQ root resolution walks up from the working directory looking for the
|
|
6
|
+
* marker directories that identify an HQ tree, and exposes the resolved root
|
|
7
|
+
* to every check.
|
|
8
|
+
* - Running outside any HQ tree exits non-zero with a message naming exactly
|
|
9
|
+
* what it looked for — never a throw, never a false PASS.
|
|
10
|
+
* - The command performs no network calls and needs no authentication; it is
|
|
11
|
+
* purely a function of the on-disk shape of the tree.
|
|
12
|
+
*
|
|
13
|
+
* US-015 adds reporting, `--json`, and the exit-code contract: the exit code is
|
|
14
|
+
* 0 unless some result is FAIL or UNKNOWN (WARN/UNTESTED/NA/KNOWN-DEFECT never
|
|
15
|
+
* fail); `--verbose` also prints PASS results; `--json` emits the versioned,
|
|
16
|
+
* machine-readable document; and text output carries no ANSI when stdout is not
|
|
17
|
+
* a TTY. The rendering and contract live in ../lib/doctor/report.ts and
|
|
18
|
+
* ../lib/doctor/json-output.ts; this command wires them to the CLI.
|
|
19
|
+
*
|
|
20
|
+
* The detected platform is a placeholder (UNKNOWN_PLATFORM) until host platform
|
|
21
|
+
* detection (US-003) lands and injects the real value through `platform`.
|
|
22
|
+
*/
|
|
23
|
+
import { Command } from "commander";
|
|
24
|
+
import { type DoctorRegistry } from "../lib/doctor/registry.js";
|
|
25
|
+
import type { FamilyRun } from "../lib/doctor/types.js";
|
|
26
|
+
import { type DoctorPlatform } from "../lib/doctor/report.js";
|
|
27
|
+
/**
|
|
28
|
+
* Marker directories that identify an HQ tree root. A directory is an HQ root
|
|
29
|
+
* only when it contains ALL of these. `.claude` + `core` are present in both
|
|
30
|
+
* the real HQ tree and the synthetic fixture trees the doctor is tested
|
|
31
|
+
* against, and are absent from an arbitrary directory — so requiring both
|
|
32
|
+
* avoids a false positive on a random repo that merely carries a `.claude/`
|
|
33
|
+
* folder.
|
|
34
|
+
*/
|
|
35
|
+
export declare const HQ_ROOT_MARKERS: readonly [".claude", "core"];
|
|
36
|
+
/**
|
|
37
|
+
* Walk up from `startDir` (default: the current working directory) looking for
|
|
38
|
+
* the nearest ancestor that contains every {@link HQ_ROOT_MARKERS} entry as a
|
|
39
|
+
* directory. Returns the resolved (realpath'd) root, or `null` when no ancestor
|
|
40
|
+
* qualifies — i.e. the caller is not inside an HQ tree.
|
|
41
|
+
*/
|
|
42
|
+
export declare function resolveHqRoot(startDir?: string): string | null;
|
|
43
|
+
/** Options for {@link runDoctor}. All are injectable so the run is unit-testable. */
|
|
44
|
+
export interface RunDoctorOptions {
|
|
45
|
+
/** Directory to resolve the HQ root from. Default: `process.cwd()`. */
|
|
46
|
+
cwd?: string;
|
|
47
|
+
/** Explicit HQ root, bypassing resolution. Takes precedence over `cwd`. */
|
|
48
|
+
root?: string;
|
|
49
|
+
/** Registry to run. Default: {@link createDefaultRegistry}. */
|
|
50
|
+
registry?: DoctorRegistry;
|
|
51
|
+
/** stdout sink. Default: `process.stdout.write`. */
|
|
52
|
+
stdout?: (chunk: string) => void;
|
|
53
|
+
/** stderr sink. Default: `process.stderr.write`. */
|
|
54
|
+
stderr?: (chunk: string) => void;
|
|
55
|
+
/** Emit the machine-readable JSON document instead of text. Default: false. */
|
|
56
|
+
json?: boolean;
|
|
57
|
+
/** In text mode, also print every PASS result. Default: false. */
|
|
58
|
+
verbose?: boolean;
|
|
59
|
+
/** In text mode, emit ANSI colour. Default: false (no escape sequences). */
|
|
60
|
+
color?: boolean;
|
|
61
|
+
/**
|
|
62
|
+
* The detected platform. Defaults to {@link UNKNOWN_PLATFORM}; host platform
|
|
63
|
+
* detection (US-003) injects the real value here. It is exposed to every check
|
|
64
|
+
* so host-specific checks (the US-006 runtime probe) can key off it.
|
|
65
|
+
*/
|
|
66
|
+
platform?: DoctorPlatform;
|
|
67
|
+
/**
|
|
68
|
+
* Session id to scope the runtime probe's ledger check to (US-006
|
|
69
|
+
* `--session-id`), so an older session's ledger cannot be mistaken for the
|
|
70
|
+
* current runtime.
|
|
71
|
+
*/
|
|
72
|
+
sessionId?: string;
|
|
73
|
+
/**
|
|
74
|
+
* Additionally execute pure-guard fixtures through the real gate under all
|
|
75
|
+
* three profiles, in a sandbox (US-008 `--deep-test`). Off by default: the
|
|
76
|
+
* default registry never fires a hook, and this branch is the ONLY path that
|
|
77
|
+
* does — so a plain `hq doctor` stays purely a function of the on-disk shape.
|
|
78
|
+
*/
|
|
79
|
+
deepTest?: boolean;
|
|
80
|
+
}
|
|
81
|
+
/** The outcome of a doctor run, returned rather than thrown so it is testable. */
|
|
82
|
+
export interface RunDoctorResult {
|
|
83
|
+
/** Process exit code: 0 inside a tree, 1 when no HQ tree was found. */
|
|
84
|
+
exitCode: number;
|
|
85
|
+
/** The resolved HQ root, or null when resolution failed. */
|
|
86
|
+
hqRoot: string | null;
|
|
87
|
+
/** Grouped per-family results (empty when resolution failed). */
|
|
88
|
+
families: FamilyRun[];
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Resolve the HQ root, run every registered check family against it, and render
|
|
92
|
+
* a plain-text summary. Makes no network calls and needs no authentication.
|
|
93
|
+
*/
|
|
94
|
+
export declare function runDoctor(options?: RunDoctorOptions): Promise<RunDoctorResult>;
|
|
95
|
+
/** Register the top-level `hq doctor` command so it appears in `hq --help`. */
|
|
96
|
+
export declare function registerDoctorCommand(program: Command): void;
|
|
97
|
+
//# sourceMappingURL=doctor.d.ts.map
|