@indigoai-us/hq-cli 5.290.0 → 5.292.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/CHANGELOG.md +66 -3
  2. package/assets/scaffold/core/scripts/handoff-finalize.sh +18 -0
  3. package/assets/scaffold/core/scripts/handoff-post.sh +71 -6
  4. package/assets/scaffold/core/scripts/hook-lib.sh +345 -50
  5. package/assets/scaffold/core/scripts/hq-delegate-bundle.sh +307 -0
  6. package/assets/scaffold/core/scripts/hq-delegate-grant.sh +226 -0
  7. package/assets/scaffold/core/scripts/hq-delegate-publish.sh +91 -0
  8. package/assets/scaffold/core/scripts/hq-delegate-send.sh +259 -0
  9. package/assets/scaffold/core/scripts/hq-delegate-verify.sh +213 -0
  10. package/assets/scaffold/core/scripts/hq-job-notify.sh +389 -0
  11. package/assets/scaffold/core/scripts/hq-job-probe.sh +835 -0
  12. package/assets/scaffold/core/scripts/hq-job-remediate.sh +359 -0
  13. package/assets/scaffold/core/scripts/hq-job-run.sh +1025 -0
  14. package/assets/scaffold/core/scripts/hq-session.sh +182 -21
  15. package/assets/scaffold/core/scripts/jobs-schedule-parse.sh +310 -0
  16. package/assets/scaffold/core/scripts/jobs-validate.sh +451 -0
  17. package/assets/scaffold/core/scripts/lib/session-id.sh +3 -2
  18. package/assets/scaffold/core/scripts/lib/work-mesh-enqueue.sh +2 -1
  19. package/assets/scaffold/core/scripts/outpost-jobs-reconcile.sh +830 -0
  20. package/dist/command-catalog.generated.d.ts +165 -1
  21. package/dist/command-catalog.generated.js +205 -1
  22. package/dist/commands/cloud.d.ts +26 -14
  23. package/dist/commands/cloud.js +207 -56
  24. package/dist/commands/core-delegate-w1b.d.ts +8 -0
  25. package/dist/commands/core-delegate-w1b.js +1049 -0
  26. package/dist/commands/core-delegate.d.ts +28 -0
  27. package/dist/commands/core-delegate.js +15 -13
  28. package/dist/commands/core-handoff.js +152 -23
  29. package/dist/commands/core-jobs-native.d.ts +23 -0
  30. package/dist/commands/core-jobs-native.js +1331 -0
  31. package/dist/commands/core-native-jobs-flag.d.ts +4 -0
  32. package/dist/commands/core-native-jobs-flag.js +7 -0
  33. package/dist/commands/core-ontology.d.ts +22 -4
  34. package/dist/commands/core-ontology.js +90 -8
  35. package/dist/commands/core-policy.js +24 -5
  36. package/dist/commands/core.js +17 -0
  37. package/dist/commands/integrations-core.js +7 -0
  38. package/dist/commands/lanes.js +111 -35
  39. package/dist/commands/members.js +5 -0
  40. package/dist/commands/mesh.js +46 -25
  41. package/dist/commands/personal-targeted-push-flag.d.ts +4 -0
  42. package/dist/commands/personal-targeted-push-flag.js +7 -0
  43. package/dist/commands/personal-targeted-push-paths.d.ts +3 -0
  44. package/dist/commands/personal-targeted-push-paths.js +67 -0
  45. package/dist/commands/secrets.js +9 -0
  46. package/dist/commands/skill.d.ts +3 -0
  47. package/dist/commands/skill.js +28 -0
  48. package/dist/fast-core.js +1 -0
  49. package/dist/lib/billing/free-month.d.ts +18 -0
  50. package/dist/lib/billing/free-month.js +31 -0
  51. package/dist/lib/billing/plan-limit-refusals.d.ts +69 -0
  52. package/dist/lib/billing/plan-limit-refusals.js +187 -0
  53. package/dist/lib/billing/plan-lock.d.ts +2 -6
  54. package/dist/lib/billing/plan-lock.js +5 -16
  55. package/dist/lib/checkpoint-ontology/drop-aliases.d.ts +41 -0
  56. package/dist/lib/checkpoint-ontology/drop-aliases.js +76 -0
  57. package/dist/lib/checkpoint-ontology/entity-file.d.ts +6 -3
  58. package/dist/lib/checkpoint-ontology/entity-file.js +12 -6
  59. package/dist/lib/checkpoint-ontology/merge-copies.d.ts +27 -8
  60. package/dist/lib/checkpoint-ontology/merge-copies.js +64 -97
  61. package/dist/lib/core-utils/secret-patterns.d.ts +3 -1
  62. package/dist/lib/core-utils/secret-patterns.js +3 -2
  63. package/dist/lib/daemon/flag.d.ts +2 -0
  64. package/dist/lib/daemon/flag.js +2 -0
  65. package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
  66. package/dist/lib/flag-registry-worker-source.generated.js +1 -1
  67. package/dist/lib/lanes/autopilot.d.ts +69 -0
  68. package/dist/lib/lanes/autopilot.js +418 -0
  69. package/dist/lib/lanes/mesh-events.js +2 -0
  70. package/dist/lib/lanes/store.js +11 -6
  71. package/dist/lib/mesh/api.js +4 -0
  72. package/dist/lib/mesh/live/daemon/state.d.ts +7 -1
  73. package/dist/lib/mesh/live/daemon/state.js +34 -2
  74. package/dist/main.js +23 -12
  75. package/dist/utils/plan-gate-error.d.ts +24 -0
  76. package/dist/utils/plan-gate-error.js +219 -9
  77. package/dist/utils/scaffold-sibling-references.js +4 -4
  78. package/dist/utils/self-update.d.ts +6 -0
  79. package/dist/utils/self-update.js +28 -6
  80. package/dist/utils/staged-install.d.ts +34 -0
  81. package/dist/utils/staged-install.js +296 -0
  82. package/package.json +2 -2
  83. package/scripts/mesh-daemon-if-stale.mjs +4 -0
@@ -0,0 +1,835 @@
1
+ #!/usr/bin/env bash
2
+ # hq-core: public
3
+ # hq-job-probe.sh — Outpost requirements probe → hq-pro jobs status (US-008).
4
+ #
5
+ # On the Outpost, deterministically checks a job's requirements checklist and
6
+ # POSTs readiness to POST /outpost/internal/jobs-status. Never rewrites job YAML.
7
+ #
8
+ # Usage:
9
+ # core/scripts/hq-job-probe.sh [options] <job.yaml|job-id>
10
+ # core/scripts/hq-job-probe.sh [options] --all
11
+ #
12
+ # Options:
13
+ # --hq-root DIR HQ tree root (default: HQ_ROOT or repo root above core/)
14
+ # --dry-run Run checks + print JSON; skip ingest
15
+ # --no-ingest Alias of --dry-run
16
+ # --json Force JSON on stdout (default)
17
+ # -h, --help Show this help
18
+ #
19
+ # Checks (per requirements / job fields):
20
+ # - runtime CLI auth: claude → ~/.claude/.credentials.json; codex → ~/.codex/auth.json
21
+ # - requirements.secrets[] visible via `hq secrets list` (names only; never values)
22
+ # - optional cwd exists (HQ-root-relative or absolute)
23
+ # - company path present when requirements.company / company scope set
24
+ #
25
+ # Ingest auth (box identity — same trust model as disk-status / relay-status):
26
+ # Header: x-outpost-instance-token
27
+ # Body: userId + jobId + kind=probe + readiness + probed_at + next_actions + checks
28
+ # Identity from (first match):
29
+ # 1. OUTPOST_USER_ID + OUTPOST_INSTANCE_TOKEN (+ optional OUTPOST_ID)
30
+ # 2. /etc/outpost/codex-identity.env (OUTPOST_USER_ID / OUTPOST_INSTANCE_TOKEN)
31
+ # 3. USER_ID= / INSTANCE_TOKEN= / OUTPOST_ID= lines in /usr/local/bin/outpost-runner.sh
32
+ # API base: HQ_PRO_API_URL → HQ_API_URL → HQ_VAULT_API_URL → https://hqapi.hq.computer
33
+ #
34
+ # StatusIngestError (unreachable / 5xx / retryable body):
35
+ # Bounded backoff retries; writes ~/.hq/jobs/probes/{id}/last-attempt.json;
36
+ # never reports readiness=ready solely because ingest failed; surfaces
37
+ # next_action "status ingest failed — retry probe".
38
+ #
39
+ # Env (tests / overrides):
40
+ # HQ_ROOT, HOME, HQ_PRO_API_URL, OUTPOST_USER_ID, OUTPOST_INSTANCE_TOKEN, OUTPOST_ID
41
+ # HQ_JOB_PROBE_CLAUDE_CRED, HQ_JOB_PROBE_CODEX_AUTH
42
+ # HQ_JOB_PROBE_STATE_DIR, HQ_JOB_PROBE_MAX_ATTEMPTS, HQ_JOB_PROBE_BACKOFF_BASE_SEC
43
+ # HQ_JOB_PROBE_SLEEP (set to ":" to no-op sleep), HQ_JOB_PROBE_NOW (ISO timestamp)
44
+ # HQ_JOB_PROBE_CURL (curl binary override), HQ_JOB_PROBE_RUNNER (outpost-runner path)
45
+ #
46
+ # Exit: 0 ready+ingested (or dry-run ready); 1 blocked; 2 usage/config; 3 StatusIngestError
47
+
48
+ set -euo pipefail
49
+
50
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
51
+ HQ_ROOT="${HQ_ROOT:-$(cd "$SCRIPT_DIR/../.." && pwd)}"
52
+ # shellcheck disable=SC1091
53
+ . "$SCRIPT_DIR/hook-lib.sh"
54
+ AUTH_HEADER_FILE=""
55
+
56
+ cleanup_auth_header_file() {
57
+ if [ -n "${AUTH_HEADER_FILE:-}" ]; then
58
+ rm -f -- "$AUTH_HEADER_FILE" 2>/dev/null || true
59
+ AUTH_HEADER_FILE=""
60
+ fi
61
+ }
62
+ trap cleanup_auth_header_file EXIT
63
+
64
+ DRY_RUN=0
65
+ PROBE_ALL=0
66
+ TARGET=""
67
+
68
+ usage() {
69
+ sed -n '3,48p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
70
+ }
71
+
72
+ die() {
73
+ echo "hq-job-probe: $*" >&2
74
+ exit 2
75
+ }
76
+
77
+ log() {
78
+ echo "hq-job-probe: $*" >&2
79
+ }
80
+
81
+ while [ $# -gt 0 ]; do
82
+ case "$1" in
83
+ --hq-root)
84
+ HQ_ROOT="${2:-}"
85
+ [ -n "$HQ_ROOT" ] || die "--hq-root requires a directory"
86
+ shift 2
87
+ ;;
88
+ --dry-run|--no-ingest)
89
+ DRY_RUN=1
90
+ shift
91
+ ;;
92
+ --all)
93
+ PROBE_ALL=1
94
+ shift
95
+ ;;
96
+ --json)
97
+ shift
98
+ ;;
99
+ -h|--help)
100
+ usage
101
+ exit 0
102
+ ;;
103
+ --)
104
+ shift
105
+ break
106
+ ;;
107
+ -*)
108
+ die "unknown option: $1"
109
+ ;;
110
+ *)
111
+ if [ -n "$TARGET" ]; then
112
+ die "unexpected argument: $1"
113
+ fi
114
+ TARGET="$1"
115
+ shift
116
+ ;;
117
+ esac
118
+ done
119
+
120
+ command -v yq >/dev/null 2>&1 || die "yq is required (mikefarah/yq)"
121
+ command -v jq >/dev/null 2>&1 || die "jq is required"
122
+
123
+ iso_now() {
124
+ if [ -n "${HQ_JOB_PROBE_NOW:-}" ]; then
125
+ printf '%s' "$HQ_JOB_PROBE_NOW"
126
+ return
127
+ fi
128
+ date -u +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null \
129
+ || date -u +"%Y-%m-%dT%H:%M:%S+00:00"
130
+ }
131
+
132
+ probe_sleep() {
133
+ local secs="${1:-1}"
134
+ # HQ_JOB_PROBE_SLEEP=':'|true → no-op for tests. Never eval arbitrary env (review CRITICAL 4).
135
+ if [ "${HQ_JOB_PROBE_SLEEP:-}" = ":" ] || [ "${HQ_JOB_PROBE_SLEEP:-}" = "true" ]; then
136
+ return 0
137
+ fi
138
+ if [[ "$secs" =~ ^[0-9]+([.][0-9]+)?$ ]]; then
139
+ sleep "$secs" 2>/dev/null || true
140
+ fi
141
+ }
142
+
143
+ api_base() {
144
+ local base
145
+ base="${HQ_PRO_API_URL:-${HQ_API_URL:-${HQ_VAULT_API_URL:-https://hqapi.hq.computer}}}"
146
+ base="${base%/}"
147
+ printf '%s' "$base"
148
+ }
149
+
150
+ # Load box identity into OUTPOST_USER_ID / OUTPOST_INSTANCE_TOKEN / OUTPOST_ID.
151
+ resolve_box_identity() {
152
+ local xtrace_was_set=0
153
+ case "$-" in
154
+ *x*) xtrace_was_set=1; set +x ;;
155
+ esac
156
+
157
+ if [ -n "${OUTPOST_USER_ID:-}" ] && [ -n "${OUTPOST_INSTANCE_TOKEN:-}" ]; then
158
+ [ "$xtrace_was_set" -eq 0 ] || set -x
159
+ return 0
160
+ fi
161
+
162
+ local envf="/etc/outpost/codex-identity.env"
163
+ if [ -f "$envf" ]; then
164
+ # shellcheck disable=SC1090
165
+ . "$envf" 2>/dev/null || true
166
+ if [ -n "${OUTPOST_USER_ID:-}" ] && [ -n "${OUTPOST_INSTANCE_TOKEN:-}" ]; then
167
+ [ "$xtrace_was_set" -eq 0 ] || set -x
168
+ return 0
169
+ fi
170
+ fi
171
+
172
+ local runner="${HQ_JOB_PROBE_RUNNER:-/usr/local/bin/outpost-runner.sh}"
173
+ if [ -f "$runner" ]; then
174
+ OUTPOST_USER_ID="${OUTPOST_USER_ID:-$(sed -n 's/^USER_ID="\(.*\)"$/\1/p' "$runner" 2>/dev/null | head -1)}"
175
+ OUTPOST_INSTANCE_TOKEN="${OUTPOST_INSTANCE_TOKEN:-$(sed -n 's/^INSTANCE_TOKEN="\(.*\)"$/\1/p' "$runner" 2>/dev/null | head -1)}"
176
+ OUTPOST_ID="${OUTPOST_ID:-$(sed -n 's/^OUTPOST_ID="\(.*\)"$/\1/p' "$runner" 2>/dev/null | head -1)}"
177
+ fi
178
+
179
+ if [ -n "${OUTPOST_USER_ID:-}" ] && [ -n "${OUTPOST_INSTANCE_TOKEN:-}" ]; then
180
+ [ "$xtrace_was_set" -eq 0 ] || set -x
181
+ return 0
182
+ fi
183
+ [ "$xtrace_was_set" -eq 0 ] || set -x
184
+ return 1
185
+ }
186
+
187
+ create_auth_header_file() {
188
+ local xtrace_was_set=0 write_rc=0
189
+ AUTH_HEADER_FILE="$(mktemp "${TMPDIR:-/tmp}/hq-job-probe-auth-header.XXXXXX")"
190
+ chmod 600 "$AUTH_HEADER_FILE"
191
+ case "$-" in
192
+ *x*) xtrace_was_set=1; set +x ;;
193
+ esac
194
+ if printf 'x-outpost-instance-token: %s\n' "$OUTPOST_INSTANCE_TOKEN" >"$AUTH_HEADER_FILE"; then
195
+ write_rc=0
196
+ else
197
+ write_rc=$?
198
+ fi
199
+ [ "$xtrace_was_set" -eq 0 ] || set -x
200
+ return "$write_rc"
201
+ }
202
+
203
+ find_job_file_by_id() {
204
+ local id="$1" f found=""
205
+ while IFS= read -r f; do
206
+ [ -n "$f" ] || continue
207
+ if [ "$(yq -r '.id // ""' "$f" 2>/dev/null)" = "$id" ]; then
208
+ if [ -n "$found" ]; then
209
+ die "duplicate job id '$id' in registry: $found and $f"
210
+ fi
211
+ found="$f"
212
+ fi
213
+ done < <(
214
+ {
215
+ find "$HQ_ROOT/personal/jobs" \( -name '*.yaml' -o -name '*.yml' \) -type f 2>/dev/null || true
216
+ find "$HQ_ROOT/companies" -path '*/jobs/*' \( -name '*.yaml' -o -name '*.yml' \) -type f 2>/dev/null || true
217
+ } | sort
218
+ )
219
+ [ -n "$found" ] || return 1
220
+ printf '%s' "$found"
221
+ }
222
+
223
+ collect_all_job_files() {
224
+ {
225
+ find "$HQ_ROOT/personal/jobs" \( -name '*.yaml' -o -name '*.yml' \) -type f 2>/dev/null || true
226
+ find "$HQ_ROOT/companies" -path '*/jobs/*' \( -name '*.yaml' -o -name '*.yml' \) -type f 2>/dev/null || true
227
+ } | sort
228
+ }
229
+
230
+ claude_cred_path() {
231
+ printf '%s' "${HQ_JOB_PROBE_CLAUDE_CRED:-${HOME}/.claude/.credentials.json}"
232
+ }
233
+
234
+ codex_auth_path() {
235
+ printf '%s' "${HQ_JOB_PROBE_CODEX_AUTH:-${HOME}/.codex/auth.json}"
236
+ }
237
+
238
+ check_runtime_auth() {
239
+ # prints ok|missing|no_cli ; uses global RUNTIME
240
+ local runtime="$1"
241
+ case "$runtime" in
242
+ claude)
243
+ if ! command -v claude >/dev/null 2>&1; then
244
+ printf 'no_cli'
245
+ return 1
246
+ fi
247
+ if [ -s "$(claude_cred_path)" ]; then
248
+ printf 'ok'
249
+ return 0
250
+ fi
251
+ printf 'missing'
252
+ return 1
253
+ ;;
254
+ codex)
255
+ if ! command -v codex >/dev/null 2>&1; then
256
+ printf 'no_cli'
257
+ return 1
258
+ fi
259
+ if [ -s "$(codex_auth_path)" ]; then
260
+ printf 'ok'
261
+ return 0
262
+ fi
263
+ printf 'missing'
264
+ return 1
265
+ ;;
266
+ *)
267
+ printf 'unknown_runtime'
268
+ return 1
269
+ ;;
270
+ esac
271
+ }
272
+
273
+ # List visible secret NAMES only (never values). stdout: one name per line.
274
+ list_secret_names() {
275
+ local company="${1:-}"
276
+ local out rc=0
277
+ if [ -n "$company" ]; then
278
+ out="$(hq secrets list --company "$company" 2>/dev/null)" || rc=$?
279
+ else
280
+ out="$(hq secrets list --personal 2>/dev/null)" || rc=$?
281
+ fi
282
+ if [ "$rc" -ne 0 ]; then
283
+ return "$rc"
284
+ fi
285
+ # Skip banner / header rows; emit first whitespace-delimited field when it
286
+ # looks like a vault key name (UPPER_SNAKE / alnum._:-).
287
+ printf '%s\n' "$out" | awk '
288
+ BEGIN { ignore = 1 }
289
+ /^NAME([[:space:]]|$)/ { ignore = 0; next }
290
+ ignore { next }
291
+ /^[[:space:]]*$/ { next }
292
+ /^Secrets / { next }
293
+ {
294
+ name = $1
295
+ if (name ~ /^[A-Za-z][A-Za-z0-9._:\/-]{0,127}$/) print name
296
+ }
297
+ '
298
+ }
299
+
300
+ secret_name_visible() {
301
+ local needle="$1"
302
+ local names_file="$2"
303
+ grep -Fxq -- "$needle" "$names_file" 2>/dev/null
304
+ }
305
+
306
+ state_dir_for_job() {
307
+ local id="$1"
308
+ local base="${HQ_JOB_PROBE_STATE_DIR:-${HOME}/.hq/jobs/probes}"
309
+ printf '%s/%s' "$base" "$id"
310
+ }
311
+
312
+ write_attempt_marker() {
313
+ local id="$1" payload="$2"
314
+ local dir
315
+ dir="$(state_dir_for_job "$id")"
316
+ mkdir -p "$dir"
317
+ printf '%s\n' "$payload" >"$dir/last-attempt.json"
318
+ # Never chmod secrets; marker is non-secret forensics.
319
+ chmod 600 "$dir/last-attempt.json" 2>/dev/null || true
320
+ }
321
+
322
+ # Box-local readiness cache for reconciler (US-004). Written only after a
323
+ # successful ingest (or dry-run) so timers never arm on a failed ingest.
324
+ write_status_cache() {
325
+ local id="$1" readiness="$2" source="${3:-probe-ingest}"
326
+ local base="${HQ_JOB_STATUS_CACHE_DIR:-${HOME}/.hq/jobs/status-cache}"
327
+ mkdir -p "$base"
328
+ jq -nc \
329
+ --arg job_id "$id" \
330
+ --arg readiness "$readiness" \
331
+ --arg updated_at "$(iso_now)" \
332
+ --arg source "$source" \
333
+ '{job_id:$job_id,readiness:$readiness,updated_at:$updated_at,source:$source}' \
334
+ >"$base/${id}.json"
335
+ chmod 600 "$base/${id}.json" 2>/dev/null || true
336
+ }
337
+
338
+ # POST probe ingest with bounded backoff. Sets INGEST_HTTP, INGEST_BODY,
339
+ # and INGEST_RETRYABLE.
340
+ ingest_probe() {
341
+ local payload="$1"
342
+ local max_attempts base_sec attempt delay http body curl_bin url tmp_body tmp_hdr
343
+ max_attempts="${HQ_JOB_PROBE_MAX_ATTEMPTS:-3}"
344
+ base_sec="${HQ_JOB_PROBE_BACKOFF_BASE_SEC:-1}"
345
+ curl_bin="${HQ_JOB_PROBE_CURL:-curl}"
346
+ url="$(api_base)/outpost/internal/jobs-status"
347
+
348
+ INGEST_HTTP=0
349
+ INGEST_BODY=""
350
+ INGEST_RETRYABLE=0
351
+
352
+ if ! command -v "$curl_bin" >/dev/null 2>&1 && [ "$curl_bin" = "curl" ]; then
353
+ if ! command -v curl >/dev/null 2>&1; then
354
+ INGEST_BODY='{"error":true,"code":"StatusIngestError","step":"config","message":"curl not found","retryable":true}'
355
+ INGEST_RETRYABLE=1
356
+ return 1
357
+ fi
358
+ fi
359
+
360
+ attempt=1
361
+ while [ "$attempt" -le "$max_attempts" ]; do
362
+ tmp_body="$(mktemp "${TMPDIR:-/tmp}/hq-job-probe-body.XXXXXX")"
363
+ tmp_hdr="$(mktemp "${TMPDIR:-/tmp}/hq-job-probe-hdr.XXXXXX")"
364
+ create_auth_header_file
365
+ http=0
366
+ set +e
367
+ "$curl_bin" -sS -m 20 -X POST "$url" \
368
+ -H "content-type: application/json" \
369
+ -H "@${AUTH_HEADER_FILE}" \
370
+ --data "$payload" \
371
+ -D "$tmp_hdr" \
372
+ -o "$tmp_body"
373
+ local curl_rc=$?
374
+ set -e
375
+ if [ "$curl_rc" -eq 0 ]; then
376
+ http="$(awk 'BEGIN{c=0} /^HTTP\//{c=$2} END{print c+0}' "$tmp_hdr" 2>/dev/null || echo 0)"
377
+ body="$(cat "$tmp_body" 2>/dev/null || true)"
378
+ else
379
+ http=0
380
+ body="$(printf '{"error":true,"code":"StatusIngestError","step":"lookup","message":"curl failed rc=%s (API unreachable)","retryable":true}' "$curl_rc")"
381
+ fi
382
+ rm -f "$tmp_body" "$tmp_hdr" "$AUTH_HEADER_FILE"
383
+ AUTH_HEADER_FILE=""
384
+
385
+ INGEST_HTTP="$http"
386
+ INGEST_BODY="$body"
387
+
388
+ if [ "$http" -ge 200 ] && [ "$http" -lt 300 ]; then
389
+ INGEST_RETRYABLE=0
390
+ return 0
391
+ fi
392
+
393
+ # Retry on transport failure, 5xx, or explicit retryable StatusIngestError.
394
+ local retry=0
395
+ if [ "$http" -eq 0 ] || [ "$http" -ge 500 ]; then
396
+ retry=1
397
+ elif printf '%s' "$body" | jq -e '.retryable == true' >/dev/null 2>&1; then
398
+ retry=1
399
+ elif printf '%s' "$body" | jq -e '.code == "StatusIngestError" and (.step == "persist" or .step == "lookup" or .step == "config")' >/dev/null 2>&1; then
400
+ retry=1
401
+ fi
402
+ INGEST_RETRYABLE="$retry"
403
+
404
+ if [ "$retry" -ne 1 ] || [ "$attempt" -ge "$max_attempts" ]; then
405
+ break
406
+ fi
407
+ delay=$((base_sec * (1 << (attempt - 1))))
408
+ log "ingest attempt $attempt/$max_attempts failed (http=$http); backoff ${delay}s"
409
+ probe_sleep "$delay"
410
+ attempt=$((attempt + 1))
411
+ done
412
+
413
+ return 1
414
+ }
415
+
416
+ probe_one_file() {
417
+ local job_file="$1"
418
+ [ -f "$job_file" ] || die "job file not found: $job_file"
419
+
420
+ local id name runtime req_runtime company cwd_rel
421
+ id="$(yq -r '.id // ""' "$job_file")"
422
+ name="$(yq -r '.name // ""' "$job_file")"
423
+ runtime="$(yq -r '.runtime // ""' "$job_file")"
424
+ req_runtime="$(yq -r '.requirements.runtime // ""' "$job_file")"
425
+ company="$(yq -r '.requirements.company // ""' "$job_file")"
426
+ cwd_rel="$(yq -r '.requirements.cwd // .cwd // ""' "$job_file")"
427
+
428
+ [ -n "$id" ] || die "job missing id: $job_file"
429
+ if [ -n "$req_runtime" ]; then
430
+ runtime="$req_runtime"
431
+ fi
432
+ [ -n "$runtime" ] || die "job missing runtime: $job_file"
433
+
434
+ local probed_at event_id
435
+ probed_at="$(iso_now)"
436
+ event_id="probe-${probed_at}-${id}"
437
+
438
+ # bash 3.2 + set -u: keep action/missing lists as newline strings, not arrays
439
+ local next_actions_nl=""
440
+ local auth_status secrets_status cwd_status company_status
441
+ local blocked=0
442
+ local secrets_missing_json="[]"
443
+ local names_tmp
444
+ local _action_line
445
+
446
+ # --- runtime auth ---
447
+ auth_status="$(check_runtime_auth "$runtime" || true)"
448
+ case "$auth_status" in
449
+ ok) ;;
450
+ no_cli)
451
+ blocked=1
452
+ _action_line="install ${runtime} CLI on Outpost and re-login"
453
+ next_actions_nl="${next_actions_nl:+${next_actions_nl}$'\n'}${_action_line}"
454
+ ;;
455
+ missing)
456
+ blocked=1
457
+ _action_line="device re-login for ${runtime} on Outpost (cached credentials missing)"
458
+ next_actions_nl="${next_actions_nl:+${next_actions_nl}$'\n'}${_action_line}"
459
+ ;;
460
+ *)
461
+ blocked=1
462
+ _action_line="unsupported runtime '${runtime}' — fix job requirements.runtime"
463
+ next_actions_nl="${next_actions_nl:+${next_actions_nl}$'\n'}${_action_line}"
464
+ auth_status="unknown_runtime"
465
+ ;;
466
+ esac
467
+
468
+ # --- secrets (names only via hq secrets list) ---
469
+ secrets_status="ok"
470
+ names_tmp="$(mktemp "${TMPDIR:-/tmp}/hq-job-probe-secrets.XXXXXX")"
471
+ local list_rc=0
472
+ list_secret_names "$company" >"$names_tmp" || list_rc=$?
473
+ if [ "$list_rc" -ne 0 ]; then
474
+ blocked=1
475
+ secrets_status="list_failed"
476
+ if [ -n "$company" ]; then
477
+ _action_line="hq secrets list --company ${company} failed on Outpost — check hq login / vault access"
478
+ else
479
+ _action_line="hq secrets list --personal failed on Outpost — check hq login / vault access"
480
+ fi
481
+ next_actions_nl="${next_actions_nl:+${next_actions_nl}$'\n'}${_action_line}"
482
+ else
483
+ local missing_names_nl=""
484
+ local secret_count
485
+ secret_count="$(yq -r '.requirements.secrets // [] | length' "$job_file")"
486
+ if [ "$secret_count" != "null" ] && [ "${secret_count:-0}" -gt 0 ] 2>/dev/null; then
487
+ local i secret_name
488
+ i=0
489
+ while [ "$i" -lt "$secret_count" ]; do
490
+ secret_name="$(yq -r ".requirements.secrets[$i]" "$job_file")"
491
+ i=$((i + 1))
492
+ [ -n "$secret_name" ] && [ "$secret_name" != "null" ] || continue
493
+ if ! secret_name_visible "$secret_name" "$names_tmp"; then
494
+ if [ -z "$missing_names_nl" ]; then
495
+ missing_names_nl="$secret_name"
496
+ else
497
+ missing_names_nl="${missing_names_nl}"$'\n'"${secret_name}"
498
+ fi
499
+ fi
500
+ done
501
+ fi
502
+ if [ -n "$missing_names_nl" ]; then
503
+ blocked=1
504
+ secrets_status="missing"
505
+ local sn
506
+ while IFS= read -r sn; do
507
+ [ -n "$sn" ] || continue
508
+ _action_line="hq secrets share ${sn} (grant Outpost owner visibility; names only — never paste values)"
509
+ next_actions_nl="${next_actions_nl:+${next_actions_nl}$'\n'}${_action_line}"
510
+ done <<EOF
511
+ $missing_names_nl
512
+ EOF
513
+ secrets_missing_json="$(printf '%s\n' "$missing_names_nl" | jq -R . | jq -s -c .)"
514
+ fi
515
+ fi
516
+ rm -f "$names_tmp"
517
+
518
+ # --- cwd (HQ-root-relative only; no absolute / no .. escape) ---
519
+ cwd_status="ok"
520
+ if [ -n "$cwd_rel" ] && [ "$cwd_rel" != "null" ]; then
521
+ local cwd_abs cwd_part cwd_bad=0
522
+ case "$cwd_rel" in
523
+ /*)
524
+ blocked=1
525
+ cwd_status="invalid"
526
+ next_actions_nl="${next_actions_nl:+$next_actions_nl$'\n'}fix cwd to HQ-root-relative path (absolute not allowed)"
527
+ cwd_bad=1
528
+ ;;
529
+ esac
530
+ if [ "$cwd_bad" -eq 0 ]; then
531
+ local IFS='/'
532
+ # shellcheck disable=SC2086
533
+ for cwd_part in $cwd_rel; do
534
+ if [ "$cwd_part" = ".." ]; then
535
+ blocked=1
536
+ cwd_status="invalid"
537
+ next_actions_nl="${next_actions_nl:+$next_actions_nl$'\n'}fix cwd — remove '..' path components"
538
+ cwd_bad=1
539
+ break
540
+ fi
541
+ done
542
+ unset IFS
543
+ fi
544
+ if [ "$cwd_bad" -eq 0 ]; then
545
+ # Portable resolve: GNU realpath -m, BSD realpath (existing paths), else hq_normpath.
546
+ local hq_canon=""
547
+ hq_canon="$(realpath -m "$HQ_ROOT" 2>/dev/null || realpath "$HQ_ROOT" 2>/dev/null || true)"
548
+ cwd_abs="$(realpath -m "$HQ_ROOT/$cwd_rel" 2>/dev/null || true)"
549
+ if [ -z "$cwd_abs" ] && [ -d "$HQ_ROOT/$cwd_rel" ]; then
550
+ cwd_abs="$(realpath "$HQ_ROOT/$cwd_rel" 2>/dev/null || true)"
551
+ fi
552
+ if [ -z "$hq_canon" ] || [ -z "$cwd_abs" ]; then
553
+ hq_canon="$(hq_normpath "$HQ_ROOT")"
554
+ cwd_abs="$(hq_normpath "$HQ_ROOT/$cwd_rel")"
555
+ fi
556
+ case "$cwd_abs" in
557
+ "$hq_canon"|"$hq_canon"/*) ;;
558
+ *)
559
+ blocked=1
560
+ cwd_status="invalid"
561
+ next_actions_nl="${next_actions_nl:+$next_actions_nl$'\n'}fix cwd — must resolve under HQ root"
562
+ cwd_bad=1
563
+ ;;
564
+ esac
565
+ fi
566
+ if [ "$cwd_bad" -eq 0 ]; then
567
+ if [ ! -d "$cwd_abs" ]; then
568
+ blocked=1
569
+ cwd_status="missing"
570
+ next_actions_nl="${next_actions_nl:+$next_actions_nl$'\n'}create cwd on Outpost HQ tree: ${cwd_rel}"
571
+ fi
572
+ fi
573
+ else
574
+ cwd_status="skipped"
575
+ fi
576
+
577
+ # --- company path ---
578
+ company_status="ok"
579
+ if [ -n "$company" ] && [ "$company" != "null" ]; then
580
+ if [ ! -d "$HQ_ROOT/companies/$company" ]; then
581
+ blocked=1
582
+ company_status="missing"
583
+ next_actions+=("company path missing on Outpost: companies/${company} — sync or join company")
584
+ fi
585
+ else
586
+ company_status="skipped"
587
+ fi
588
+
589
+ local readiness="ready"
590
+ if [ "$blocked" -eq 1 ]; then
591
+ readiness="blocked"
592
+ fi
593
+
594
+ local actions_json
595
+ if [ -z "$next_actions_nl" ]; then
596
+ actions_json='[]'
597
+ else
598
+ actions_json="$(printf '%s\n' "$next_actions_nl" | awk 'NF && !seen[$0]++' | jq -R . | jq -s -c .)"
599
+ fi
600
+
601
+ local checks_json
602
+ checks_json="$(jq -nc \
603
+ --arg runtime "$runtime" \
604
+ --arg auth "$auth_status" \
605
+ --arg secrets "$secrets_status" \
606
+ --argjson missing "$secrets_missing_json" \
607
+ --arg cwd "$cwd_status" \
608
+ --arg company "$company_status" \
609
+ --arg company_slug "$company" \
610
+ '{
611
+ runtime: $runtime,
612
+ auth: $auth,
613
+ secrets: {status: $secrets, missing: $missing},
614
+ cwd: $cwd,
615
+ company: (if $company_slug == "" then {status: $company} else {status: $company, slug: $company_slug} end)
616
+ }')"
617
+
618
+ local user_id="${OUTPOST_USER_ID:-}"
619
+ local outpost_id="${OUTPOST_ID:-}"
620
+
621
+ # Build ingest payload (snake_case fields accepted by US-009 handler).
622
+ local payload
623
+ payload="$(jq -nc \
624
+ --arg userId "$user_id" \
625
+ --arg jobId "$id" \
626
+ --arg readiness "$readiness" \
627
+ --arg probed_at "$probed_at" \
628
+ --arg event_id "$event_id" \
629
+ --arg outpostId "$outpost_id" \
630
+ --argjson next_actions "$actions_json" \
631
+ --argjson checks "$checks_json" \
632
+ '{
633
+ userId: $userId,
634
+ jobId: $jobId,
635
+ kind: "probe",
636
+ readiness: $readiness,
637
+ probed_at: $probed_at,
638
+ event_id: $event_id,
639
+ next_actions: $next_actions,
640
+ checks: $checks
641
+ } + (if $outpostId != "" then {outpostId: $outpostId} else {} end)')"
642
+
643
+ local result_ingest="skipped"
644
+ local result_http=0
645
+ local ingest_error=0
646
+
647
+ if [ "$DRY_RUN" -eq 1 ]; then
648
+ result_ingest="dry_run"
649
+ write_status_cache "$id" "$readiness" "probe-dry-run"
650
+ else
651
+ if ! resolve_box_identity; then
652
+ # Cannot authenticate to hq-pro — treat as StatusIngestError path.
653
+ ingest_error=1
654
+ result_ingest="StatusIngestError"
655
+ local marker
656
+ marker="$(jq -nc \
657
+ --arg job_id "$id" \
658
+ --arg probed_at "$probed_at" \
659
+ --arg local_readiness "$readiness" \
660
+ --arg message "missing Outpost identity (OUTPOST_USER_ID / OUTPOST_INSTANCE_TOKEN)" \
661
+ --argjson checks "$checks_json" \
662
+ --argjson next_actions "$(jq -nc --argjson a "$actions_json" '$a + ["status ingest failed — retry probe"]')" \
663
+ '{
664
+ job_id: $job_id,
665
+ probed_at: $probed_at,
666
+ local_readiness: $local_readiness,
667
+ ingest: {ok: false, code: "StatusIngestError", step: "config", message: $message, retryable: true},
668
+ reported_readiness: null,
669
+ next_actions: $next_actions,
670
+ checks: $checks
671
+ }')"
672
+ write_attempt_marker "$id" "$marker"
673
+ # Never claim ready when ingest cannot run.
674
+ readiness="unknown"
675
+ actions_json="$(jq -nc --argjson a "$actions_json" '
676
+ ($a + ["status ingest failed — retry probe"]) as $x
677
+ | reduce $x[] as $i ([]; if index($i) then . else . + [$i] end)
678
+ ')"
679
+ else
680
+ # Refresh payload with resolved userId
681
+ payload="$(jq -nc \
682
+ --arg userId "$OUTPOST_USER_ID" \
683
+ --arg jobId "$id" \
684
+ --arg readiness "$readiness" \
685
+ --arg probed_at "$probed_at" \
686
+ --arg event_id "$event_id" \
687
+ --arg outpostId "${OUTPOST_ID:-}" \
688
+ --argjson next_actions "$actions_json" \
689
+ --argjson checks "$checks_json" \
690
+ '{
691
+ userId: $userId,
692
+ jobId: $jobId,
693
+ kind: "probe",
694
+ readiness: $readiness,
695
+ probed_at: $probed_at,
696
+ event_id: $event_id,
697
+ next_actions: $next_actions,
698
+ checks: $checks
699
+ } + (if $outpostId != "" then {outpostId: $outpostId} else {} end)')"
700
+
701
+ local intended_readiness="$readiness"
702
+ if ingest_probe "$payload"; then
703
+ result_ingest="ok"
704
+ result_http="$INGEST_HTTP"
705
+ local marker_ok
706
+ marker_ok="$(jq -nc \
707
+ --arg job_id "$id" \
708
+ --arg probed_at "$probed_at" \
709
+ --arg local_readiness "$intended_readiness" \
710
+ --argjson http "$INGEST_HTTP" \
711
+ --argjson checks "$checks_json" \
712
+ --argjson next_actions "$actions_json" \
713
+ '{
714
+ job_id: $job_id,
715
+ probed_at: $probed_at,
716
+ local_readiness: $local_readiness,
717
+ ingest: {ok: true, http: $http},
718
+ reported_readiness: $local_readiness,
719
+ next_actions: $next_actions,
720
+ checks: $checks
721
+ }')"
722
+ write_attempt_marker "$id" "$marker_ok"
723
+ write_status_cache "$id" "$intended_readiness" "probe-ingest"
724
+ else
725
+ ingest_error=1
726
+ result_ingest="StatusIngestError"
727
+ result_http="$INGEST_HTTP"
728
+ # Leave prior API readiness unchanged (we did not successfully write).
729
+ # Never report ready solely because ingest failed.
730
+ local fail_actions body_raw
731
+ fail_actions="$(jq -nc --argjson a "$actions_json" '$a + ["status ingest failed — retry probe"]')"
732
+ body_raw="${INGEST_BODY:-}"
733
+ [ -n "$body_raw" ] || body_raw="{}"
734
+ # Prefer --arg + fromjson so nested JSON cannot break ${:-{}} brace parsing.
735
+ local marker_fail
736
+ marker_fail="$(jq -nc \
737
+ --arg job_id "$id" \
738
+ --arg probed_at "$probed_at" \
739
+ --arg local_readiness "$intended_readiness" \
740
+ --argjson http "$INGEST_HTTP" \
741
+ --arg body "$body_raw" \
742
+ --argjson checks "$checks_json" \
743
+ --argjson next_actions "$fail_actions" \
744
+ --argjson retryable "${INGEST_RETRYABLE:-1}" \
745
+ '{
746
+ job_id: $job_id,
747
+ probed_at: $probed_at,
748
+ local_readiness: $local_readiness,
749
+ ingest: {
750
+ ok: false,
751
+ code: "StatusIngestError",
752
+ http: $http,
753
+ retryable: ($retryable == 1),
754
+ body: (try ($body | fromjson) catch {raw: $body})
755
+ },
756
+ reported_readiness: null,
757
+ next_actions: $next_actions,
758
+ checks: $checks
759
+ }')"
760
+ write_attempt_marker "$id" "$marker_fail"
761
+ readiness="unknown"
762
+ actions_json="$fail_actions"
763
+ fi
764
+ fi
765
+ fi
766
+
767
+ local result
768
+ result="$(jq -nc \
769
+ --arg job_id "$id" \
770
+ --arg name "$name" \
771
+ --arg job_file "$job_file" \
772
+ --arg readiness "$readiness" \
773
+ --arg probed_at "$probed_at" \
774
+ --arg ingest "$result_ingest" \
775
+ --argjson http "$result_http" \
776
+ --argjson checks "$checks_json" \
777
+ --argjson next_actions "$actions_json" \
778
+ --argjson dry_run "$DRY_RUN" \
779
+ '{
780
+ job_id: $job_id,
781
+ name: $name,
782
+ job_file: $job_file,
783
+ readiness: $readiness,
784
+ probed_at: $probed_at,
785
+ ingest: $ingest,
786
+ http: $http,
787
+ dry_run: ($dry_run == 1),
788
+ checks: $checks,
789
+ next_actions: $next_actions
790
+ }')"
791
+
792
+ printf '%s\n' "$result"
793
+
794
+ if [ "$ingest_error" -eq 1 ]; then
795
+ return 3
796
+ fi
797
+ if [ "$blocked" -eq 1 ]; then
798
+ return 1
799
+ fi
800
+ return 0
801
+ }
802
+
803
+ # --- main ---
804
+
805
+ if [ "$PROBE_ALL" -eq 1 ]; then
806
+ [ -z "$TARGET" ] || die "--all does not take a job argument"
807
+ overall=0
808
+ any=0
809
+ while IFS= read -r f; do
810
+ [ -n "$f" ] || continue
811
+ any=1
812
+ rc=0
813
+ probe_one_file "$f" || rc=$?
814
+ if [ "$rc" -gt "$overall" ]; then
815
+ overall="$rc"
816
+ fi
817
+ done < <(collect_all_job_files)
818
+ [ "$any" -eq 1 ] || die "no job YAML found under personal/jobs or companies/*/jobs in $HQ_ROOT"
819
+ exit "$overall"
820
+ fi
821
+
822
+ [ -n "$TARGET" ] || die "usage: hq-job-probe.sh [--dry-run] <job.yaml|job-id> | --all"
823
+
824
+ JOB_FILE=""
825
+ if [ -f "$TARGET" ]; then
826
+ JOB_FILE="$TARGET"
827
+ elif [ -f "$HQ_ROOT/$TARGET" ]; then
828
+ JOB_FILE="$HQ_ROOT/$TARGET"
829
+ else
830
+ JOB_FILE="$(find_job_file_by_id "$TARGET")" || die "job id not found in registry: $TARGET"
831
+ fi
832
+
833
+ rc=0
834
+ probe_one_file "$JOB_FILE" || rc=$?
835
+ exit "$rc"