@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.
Files changed (73) hide show
  1. package/CHANGELOG.md +43 -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-checkpoint.js +11 -3
  8. package/dist/commands/core.js +60 -5
  9. package/dist/commands/doctor.d.ts +97 -0
  10. package/dist/commands/doctor.js +228 -0
  11. package/dist/commands/scaffold-fast.d.ts +41 -0
  12. package/dist/commands/scaffold-fast.js +57 -0
  13. package/dist/fast-core.d.ts +16 -0
  14. package/dist/fast-core.js +47 -0
  15. package/dist/index.d.ts +2 -0
  16. package/dist/index.js +10 -1
  17. package/dist/lib/core-utils/soft-timeout.d.ts +55 -0
  18. package/dist/lib/core-utils/soft-timeout.js +205 -0
  19. package/dist/lib/core-utils/timeout-guard.d.ts +62 -0
  20. package/dist/lib/core-utils/timeout-guard.js +207 -0
  21. package/dist/lib/doctor/__testing__/fake-hq-tree.d.ts +194 -0
  22. package/dist/lib/doctor/__testing__/fake-hq-tree.js +357 -0
  23. package/dist/lib/doctor/allowed-divergence.d.ts +72 -0
  24. package/dist/lib/doctor/allowed-divergence.js +134 -0
  25. package/dist/lib/doctor/checks/claude-wiring.d.ts +55 -0
  26. package/dist/lib/doctor/checks/claude-wiring.js +524 -0
  27. package/dist/lib/doctor/checks/codex-wiring.d.ts +45 -0
  28. package/dist/lib/doctor/checks/codex-wiring.js +376 -0
  29. package/dist/lib/doctor/checks/grok-wiring.d.ts +35 -0
  30. package/dist/lib/doctor/checks/grok-wiring.js +186 -0
  31. package/dist/lib/doctor/checks/runtime-probe.d.ts +101 -0
  32. package/dist/lib/doctor/checks/runtime-probe.js +335 -0
  33. package/dist/lib/doctor/compat.d.ts +85 -0
  34. package/dist/lib/doctor/compat.js +102 -0
  35. package/dist/lib/doctor/deep/classify.d.ts +61 -0
  36. package/dist/lib/doctor/deep/classify.js +75 -0
  37. package/dist/lib/doctor/deep/effects.d.ts +107 -0
  38. package/dist/lib/doctor/deep/effects.js +229 -0
  39. package/dist/lib/doctor/deep/executor.d.ts +112 -0
  40. package/dist/lib/doctor/deep/executor.js +369 -0
  41. package/dist/lib/doctor/deep/parity.d.ts +129 -0
  42. package/dist/lib/doctor/deep/parity.js +355 -0
  43. package/dist/lib/doctor/deep/sandbox.d.ts +190 -0
  44. package/dist/lib/doctor/deep/sandbox.js +572 -0
  45. package/dist/lib/doctor/fix/apply.d.ts +119 -0
  46. package/dist/lib/doctor/fix/apply.js +352 -0
  47. package/dist/lib/doctor/fix/backup.d.ts +40 -0
  48. package/dist/lib/doctor/fix/backup.js +64 -0
  49. package/dist/lib/doctor/fix/remediation.d.ts +71 -0
  50. package/dist/lib/doctor/fix/remediation.js +103 -0
  51. package/dist/lib/doctor/fixtures/discover.d.ts +96 -0
  52. package/dist/lib/doctor/fixtures/discover.js +287 -0
  53. package/dist/lib/doctor/fixtures/schema.d.ts +171 -0
  54. package/dist/lib/doctor/fixtures/schema.js +248 -0
  55. package/dist/lib/doctor/hook-gate-profiles.d.ts +55 -0
  56. package/dist/lib/doctor/hook-gate-profiles.js +107 -0
  57. package/dist/lib/doctor/json-output.d.ts +90 -0
  58. package/dist/lib/doctor/json-output.js +76 -0
  59. package/dist/lib/doctor/payload-shapes.d.ts +170 -0
  60. package/dist/lib/doctor/payload-shapes.js +275 -0
  61. package/dist/lib/doctor/platform.d.ts +244 -0
  62. package/dist/lib/doctor/platform.js +490 -0
  63. package/dist/lib/doctor/registry.d.ts +49 -0
  64. package/dist/lib/doctor/registry.js +176 -0
  65. package/dist/lib/doctor/report.d.ts +87 -0
  66. package/dist/lib/doctor/report.js +164 -0
  67. package/dist/lib/doctor/types.d.ts +87 -0
  68. package/dist/lib/doctor/types.js +29 -0
  69. package/dist/main.js +6 -0
  70. package/dist/utils/version-check.js +2 -2
  71. package/dist/utils/version-gate.d.ts +1 -1
  72. package/dist/utils/version-gate.js +1 -1
  73. 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 workspace-write` and grok from
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 workspace-write`.
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
- "workspace-write",
593
+ "danger-full-access",
586
594
  "--dangerously-bypass-hook-trust",
587
595
  "-m",
588
596
  CODEX_SIBLING_MODEL,
@@ -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
- // Empty by design. Every former entry is either native now or retired: the
168
- // two one-shot backfills (company skill mirrors, workspace mirror) were
169
- // removed outright, having served installs that predate the current layout.
170
- // `hq core worker lint|share` are the only remaining bundled dispatches and
171
- // live in WORKER_SUBCOMMANDS.
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