@indigoai-us/hq-cli 5.290.0 → 5.291.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +44 -0
- package/assets/scaffold/core/scripts/hook-lib.sh +345 -50
- package/assets/scaffold/core/scripts/hq-session.sh +182 -21
- package/assets/scaffold/core/scripts/jobs-schedule-parse.sh +310 -0
- package/assets/scaffold/core/scripts/jobs-validate.sh +451 -0
- package/assets/scaffold/core/scripts/lib/session-id.sh +3 -2
- package/assets/scaffold/core/scripts/lib/work-mesh-enqueue.sh +2 -1
- package/dist/command-catalog.generated.d.ts +59 -0
- package/dist/command-catalog.generated.js +73 -0
- package/dist/commands/cloud.d.ts +26 -14
- package/dist/commands/cloud.js +207 -56
- package/dist/commands/core-jobs-native.d.ts +8 -0
- package/dist/commands/core-jobs-native.js +624 -0
- package/dist/commands/core-native-jobs-flag.d.ts +4 -0
- package/dist/commands/core-native-jobs-flag.js +7 -0
- package/dist/commands/core-policy.js +24 -5
- package/dist/commands/core.js +7 -0
- package/dist/commands/integrations-core.js +7 -0
- package/dist/commands/lanes.js +111 -35
- package/dist/commands/members.js +5 -0
- package/dist/commands/mesh.js +46 -25
- package/dist/commands/personal-targeted-push-flag.d.ts +4 -0
- package/dist/commands/personal-targeted-push-flag.js +7 -0
- package/dist/commands/personal-targeted-push-paths.d.ts +3 -0
- package/dist/commands/personal-targeted-push-paths.js +67 -0
- package/dist/commands/secrets.js +9 -0
- package/dist/commands/skill.d.ts +3 -0
- package/dist/commands/skill.js +28 -0
- package/dist/fast-core.js +1 -0
- package/dist/lib/billing/free-month.d.ts +18 -0
- package/dist/lib/billing/free-month.js +31 -0
- package/dist/lib/billing/plan-limit-refusals.d.ts +69 -0
- package/dist/lib/billing/plan-limit-refusals.js +187 -0
- package/dist/lib/billing/plan-lock.d.ts +2 -6
- package/dist/lib/billing/plan-lock.js +5 -16
- package/dist/lib/core-utils/secret-patterns.d.ts +3 -1
- package/dist/lib/core-utils/secret-patterns.js +3 -2
- package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
- package/dist/lib/flag-registry-worker-source.generated.js +1 -1
- package/dist/lib/lanes/autopilot.d.ts +69 -0
- package/dist/lib/lanes/autopilot.js +418 -0
- package/dist/lib/lanes/mesh-events.js +2 -0
- package/dist/lib/lanes/store.js +11 -6
- package/dist/lib/mesh/api.js +4 -0
- package/dist/lib/mesh/live/daemon/state.d.ts +7 -1
- package/dist/lib/mesh/live/daemon/state.js +34 -2
- package/dist/main.js +23 -12
- package/dist/utils/plan-gate-error.d.ts +24 -0
- package/dist/utils/plan-gate-error.js +219 -9
- package/package.json +2 -2
|
@@ -27,6 +27,46 @@
|
|
|
27
27
|
|
|
28
28
|
set -euo pipefail
|
|
29
29
|
|
|
30
|
+
# Prefer the CLI-hosted implementation only when it advertises the exact-company
|
|
31
|
+
# directory check. Older CLI bundles may recognize hq-session but still accept
|
|
32
|
+
# case aliases, so they must use this in-tree implementation instead.
|
|
33
|
+
# HQ_HQ_SESSION_NO_CLI=1 forces the in-tree path.
|
|
34
|
+
if [ "${HQ_HQ_SESSION_NO_CLI:-}" != "1" ]; then
|
|
35
|
+
__hs_hq="$(command -v hq 2>/dev/null || true)"
|
|
36
|
+
if [ -n "$__hs_hq" ]; then
|
|
37
|
+
# `hq core --help` costs seconds of node startup, so probe it at most once
|
|
38
|
+
# per CLI build and cache the answer. Key by resolved path + mtime + size so
|
|
39
|
+
# a PATH switch or same-second rebuild cannot reuse a stale result; keep the
|
|
40
|
+
# cache in a private 0700 dir and trust it only when we own it; never persist
|
|
41
|
+
# a failed probe. HQ_CLI_CAPS_CACHE overrides the path (tests isolate it).
|
|
42
|
+
__hs_mt="$(stat -c %Y "$__hs_hq" 2>/dev/null || stat -f %m "$__hs_hq" 2>/dev/null || echo 0)"
|
|
43
|
+
__hs_sz="$(stat -c %s "$__hs_hq" 2>/dev/null || stat -f %z "$__hs_hq" 2>/dev/null || echo 0)"
|
|
44
|
+
__hs_key="$(printf '%s' "$__hs_hq:$__hs_mt:$__hs_sz" | cksum 2>/dev/null | cut -d' ' -f1)"
|
|
45
|
+
if [ -n "${HQ_CLI_CAPS_CACHE:-}" ]; then
|
|
46
|
+
__hs_cache="$HQ_CLI_CAPS_CACHE"
|
|
47
|
+
else
|
|
48
|
+
__hs_dir="${XDG_CACHE_HOME:-${HOME:-/tmp}/.cache}/hq-cli"
|
|
49
|
+
mkdir -p "$__hs_dir" 2>/dev/null && chmod 700 "$__hs_dir" 2>/dev/null || true
|
|
50
|
+
__hs_cache="$__hs_dir/core-caps.${__hs_key:-0}"
|
|
51
|
+
fi
|
|
52
|
+
__hs_caps=""
|
|
53
|
+
if [ -r "$__hs_cache" ] && [ -O "$__hs_cache" ]; then
|
|
54
|
+
__hs_caps="$(cat "$__hs_cache" 2>/dev/null || true)"
|
|
55
|
+
elif __hs_caps="$(hq core --help 2>/dev/null)"; then
|
|
56
|
+
[ -n "$__hs_caps" ] && (umask 077; printf '%s' "$__hs_caps" >"$__hs_cache" 2>/dev/null) || true
|
|
57
|
+
else
|
|
58
|
+
__hs_caps=""
|
|
59
|
+
fi
|
|
60
|
+
case "$__hs_caps" in
|
|
61
|
+
*hq-session*)
|
|
62
|
+
case "$__hs_caps" in
|
|
63
|
+
*exact-company-directory-match-v1*) exec hq core hq-session "$@" ;;
|
|
64
|
+
esac
|
|
65
|
+
;;
|
|
66
|
+
esac
|
|
67
|
+
fi
|
|
68
|
+
fi
|
|
69
|
+
|
|
30
70
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
31
71
|
# The DATA tree (sessions, companies, hooks) is the live HQ root. When this
|
|
32
72
|
# script is dispatched as `hq core hq-session` from inside the hq-cli package,
|
|
@@ -43,6 +83,44 @@ LIB_DIR="$SCRIPT_DIR/lib"
|
|
|
43
83
|
# shellcheck source=lib/session-id.sh
|
|
44
84
|
. "$LIB_DIR/session-id.sh"
|
|
45
85
|
|
|
86
|
+
# These checks stay local so hq-session remains usable by fixtures and bundled
|
|
87
|
+
# copies that include its capability and ID libraries but not auto-bind.
|
|
88
|
+
hq_session_has_exact_company_dir() {
|
|
89
|
+
local root="${1:-}" slug="${2:-}" company_dir
|
|
90
|
+
[ -n "$root" ] && [ -n "$slug" ] || return 1
|
|
91
|
+
for company_dir in "$root"/companies/*; do
|
|
92
|
+
[ -d "$company_dir" ] || continue
|
|
93
|
+
[ -L "$company_dir" ] && continue
|
|
94
|
+
[ "${company_dir##*/}" = "$slug" ] && return 0
|
|
95
|
+
done
|
|
96
|
+
return 1
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
hq_session_has_case_alias() {
|
|
100
|
+
local root="${1:-}" slug="${2:-}" company_dir company_slug slug_fold company_fold
|
|
101
|
+
[ -n "$root" ] && [ -n "$slug" ] || return 1
|
|
102
|
+
|
|
103
|
+
# Exact, real entries are the common case. Check them before starting any
|
|
104
|
+
# case-fold subprocesses on SessionStart or a normal company bind.
|
|
105
|
+
for company_dir in "$root"/companies/*; do
|
|
106
|
+
[ -d "$company_dir" ] || continue
|
|
107
|
+
[ -L "$company_dir" ] && continue
|
|
108
|
+
company_slug="${company_dir##*/}"
|
|
109
|
+
[ "$company_slug" = "$slug" ] && return 1
|
|
110
|
+
done
|
|
111
|
+
|
|
112
|
+
slug_fold="$(printf '%s' "$slug" | LC_ALL=C tr '[:upper:]' '[:lower:]')"
|
|
113
|
+
for company_dir in "$root"/companies/*; do
|
|
114
|
+
[ -d "$company_dir" ] || continue
|
|
115
|
+
[ -L "$company_dir" ] && continue
|
|
116
|
+
company_slug="${company_dir##*/}"
|
|
117
|
+
[ "$company_slug" = "$slug" ] && continue
|
|
118
|
+
company_fold="$(printf '%s' "$company_slug" | LC_ALL=C tr '[:upper:]' '[:lower:]')"
|
|
119
|
+
[ "$company_fold" = "$slug_fold" ] && return 0
|
|
120
|
+
done
|
|
121
|
+
return 1
|
|
122
|
+
}
|
|
123
|
+
|
|
46
124
|
# Set by the --session-id global option; empty means "resolve it".
|
|
47
125
|
SESSION_ID_OVERRIDE=""
|
|
48
126
|
|
|
@@ -78,7 +156,19 @@ cmd_get() {
|
|
|
78
156
|
[ -n "$key" ] || { echo "usage: hq-session.sh get <key>" >&2; exit 1; }
|
|
79
157
|
local meta
|
|
80
158
|
meta="$(current_meta)"
|
|
159
|
+
# Missing meta.yaml: no keys to read. Print nothing, exit 0.
|
|
160
|
+
# Unreadable meta.yaml: awk fails and this function exits non-zero under
|
|
161
|
+
# set -e. Those two are not the same case — do not default either.
|
|
81
162
|
[ -n "$meta" ] && [ -f "$meta" ] || return 0
|
|
163
|
+
# Absent key: print nothing. For `senior`, a record written before that
|
|
164
|
+
# field existed has no line; that is a successful read of a file that does
|
|
165
|
+
# not contain the key, not a failed read. §3.1 defaults `senior` to `user`
|
|
166
|
+
# at write time (the seed in cmd_set / master-hook.sh). This getter does
|
|
167
|
+
# not invent that default, so "never written" stays distinguishable from
|
|
168
|
+
# an explicit `senior: user`. Authority walkers apply the §3.1 default at
|
|
169
|
+
# the walk site. Substituting `user` here would collapse absence into the
|
|
170
|
+
# permissive value that hq-never-swallow-errors and
|
|
171
|
+
# monitor-unknown-paths-must-not-resolve-to-healthy forbid.
|
|
82
172
|
awk -v k="$key" '
|
|
83
173
|
$1 == k":" {
|
|
84
174
|
sub(/^[^:]+:[[:space:]]*/, "")
|
|
@@ -93,10 +183,9 @@ cmd_set() {
|
|
|
93
183
|
local key="${1:-}" value="${2:-}"
|
|
94
184
|
[ -n "$key" ] && [ $# -ge 2 ] || { echo "usage: hq-session.sh set <key> <value>" >&2; exit 1; }
|
|
95
185
|
|
|
96
|
-
# Reject
|
|
97
|
-
#
|
|
98
|
-
#
|
|
99
|
-
# session_scope_mint's own character-set rule; `personal` is a valid slug.
|
|
186
|
+
# Reject invalid company_slug values before writing anything. A case alias
|
|
187
|
+
# could otherwise bind the session and surface policies from a differently
|
|
188
|
+
# named company directory on a case-insensitive filesystem.
|
|
100
189
|
if [ "$key" = "company_slug" ] && [ -n "$value" ]; then
|
|
101
190
|
case "$value" in
|
|
102
191
|
*[!a-zA-Z0-9_-]*)
|
|
@@ -104,6 +193,45 @@ cmd_set() {
|
|
|
104
193
|
exit 1
|
|
105
194
|
;;
|
|
106
195
|
esac
|
|
196
|
+
if [ "$value" != "personal" ] && {
|
|
197
|
+
[ -L "$REPO_ROOT/companies/$value" ] \
|
|
198
|
+
|| hq_session_has_case_alias "$REPO_ROOT" "$value"
|
|
199
|
+
}; then
|
|
200
|
+
echo "hq-session: company_slug must use the exact real directory name: '$value'" >&2
|
|
201
|
+
exit 1
|
|
202
|
+
fi
|
|
203
|
+
fi
|
|
204
|
+
|
|
205
|
+
# Reject an invalid senior BEFORE writing anything. Otherwise a bad value
|
|
206
|
+
# lands in meta.yaml and only fails later when the authority walk reads it,
|
|
207
|
+
# leaving corrupt state that hop 2 would treat as a real parent. Same shape
|
|
208
|
+
# as the company_slug guard above.
|
|
209
|
+
#
|
|
210
|
+
# Accepted forms: user | session:<id> | lane:<id>.
|
|
211
|
+
# <id> charset is A-Za-z0-9._- — company_slug's a-z0-9_- plus `.` (hostnames
|
|
212
|
+
# contain dots) plus A-Z because lane ids on this box are ULID_hostname
|
|
213
|
+
# (e.g. 01M30KHSEX50K0G203QNV2590N_ip-172-31-47-133-ec2-internal; ULIDs are
|
|
214
|
+
# Crockford base32, typically uppercase) and session ids are UUID hex
|
|
215
|
+
# (e.g. 0026e2dd-7fff-4733-8d61-9a6fe7004908). `.` and `..` are rejected as
|
|
216
|
+
# ids: they are path segments under workspace/sessions/.
|
|
217
|
+
if [ "$key" = "senior" ]; then
|
|
218
|
+
local senior_ok=0 senior_id
|
|
219
|
+
case "$value" in
|
|
220
|
+
user)
|
|
221
|
+
senior_ok=1
|
|
222
|
+
;;
|
|
223
|
+
session:*|lane:*)
|
|
224
|
+
senior_id="${value#*:}"
|
|
225
|
+
case "$senior_id" in
|
|
226
|
+
""|.|..|*[!A-Za-z0-9._-]*) ;;
|
|
227
|
+
*) senior_ok=1 ;;
|
|
228
|
+
esac
|
|
229
|
+
;;
|
|
230
|
+
esac
|
|
231
|
+
if [ "$senior_ok" != 1 ]; then
|
|
232
|
+
echo "hq-session: invalid senior: '$value' (accepted forms: user, session:<id>, lane:<id>)" >&2
|
|
233
|
+
exit 1
|
|
234
|
+
fi
|
|
107
235
|
fi
|
|
108
236
|
|
|
109
237
|
local id meta
|
|
@@ -115,9 +243,10 @@ cmd_set() {
|
|
|
115
243
|
meta="$SESSIONS_DIR/$id/meta.yaml"
|
|
116
244
|
mkdir -p "$(dirname "$meta")"
|
|
117
245
|
# 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.
|
|
246
|
+
# has not bootstrapped yet still produces a well-formed record. `senior: user`
|
|
247
|
+
# is the §3.1 default so hop 2 of the authority walk has a field to read.
|
|
119
248
|
if [ ! -f "$meta" ]; then
|
|
120
|
-
printf 'session_id: %s\nstarted_at: "%s"\n' \
|
|
249
|
+
printf 'session_id: %s\nstarted_at: "%s"\nsenior: user\n' \
|
|
121
250
|
"$id" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$meta"
|
|
122
251
|
fi
|
|
123
252
|
|
|
@@ -159,6 +288,29 @@ cmd_set() {
|
|
|
159
288
|
spawn_work_mesh_register "$id" || true
|
|
160
289
|
fi
|
|
161
290
|
fi
|
|
291
|
+
|
|
292
|
+
# US-011: mid-session project change closes the old card and opens a new one.
|
|
293
|
+
# First bind (empty prev) only updates the live-binding marker — SessionStart
|
|
294
|
+
# already emitted session_start (or reconcile will bind without a card flip).
|
|
295
|
+
if [ "$key" = "project" ] && [ -n "$value" ] && [ "$value" != "$prev" ]; then
|
|
296
|
+
local co tk
|
|
297
|
+
co="$(cmd_get company_slug 2>/dev/null || true)"
|
|
298
|
+
tk="$(cmd_get task 2>/dev/null || true)"
|
|
299
|
+
if [ -f "$REPO_ROOT/core/scripts/lib/work-mesh-enqueue.sh" ] && \
|
|
300
|
+
[ -f "$REPO_ROOT/core/scripts/lib/work-mesh-live-rebind.sh" ]; then
|
|
301
|
+
# shellcheck source=lib/work-mesh-enqueue.sh
|
|
302
|
+
. "$REPO_ROOT/core/scripts/lib/work-mesh-enqueue.sh" 2>/dev/null || true
|
|
303
|
+
# shellcheck source=lib/work-mesh-live-hook.sh
|
|
304
|
+
. "$REPO_ROOT/core/scripts/lib/work-mesh-live-hook.sh" 2>/dev/null || true
|
|
305
|
+
# shellcheck source=lib/work-mesh-live-rebind.sh
|
|
306
|
+
. "$REPO_ROOT/core/scripts/lib/work-mesh-live-rebind.sh" 2>/dev/null || true
|
|
307
|
+
if [ -n "$prev" ] && command -v work_mesh_live_rebind >/dev/null 2>&1; then
|
|
308
|
+
work_mesh_live_rebind "$id" "$co" "$prev" "$tk" "$co" "$value" "$tk" || true
|
|
309
|
+
elif command -v work_mesh_live_write_binding_marker >/dev/null 2>&1; then
|
|
310
|
+
work_mesh_live_write_binding_marker "$id" "$co" "$value" "$tk" || true
|
|
311
|
+
fi
|
|
312
|
+
fi
|
|
313
|
+
fi
|
|
162
314
|
}
|
|
163
315
|
|
|
164
316
|
# Spawn the client-side Work Mesh registration hook, detached, for a mid-session
|
|
@@ -167,15 +319,16 @@ cmd_set() {
|
|
|
167
319
|
# and this backgrounds even its fast foreground path off cmd_set's return path.
|
|
168
320
|
spawn_work_mesh_register() {
|
|
169
321
|
local sid="$1"
|
|
170
|
-
local hook="$REPO_ROOT/core/hooks/work-mesh-
|
|
322
|
+
local hook="$REPO_ROOT/core/hooks/SessionStart/35-work-mesh-session-start.sh"
|
|
171
323
|
local logf="$REPO_ROOT/workspace/logs/work-mesh-hook.log"
|
|
172
|
-
[ -
|
|
324
|
+
[ -f "$hook" ] || return 0
|
|
173
325
|
command -v jq >/dev/null 2>&1 || return 0
|
|
174
326
|
local ev
|
|
175
327
|
ev="$(jq -nc --arg sid "$sid" --arg cwd "${PWD:-}" '{session_id:$sid,cwd:$cwd}' 2>/dev/null)" || return 0
|
|
176
328
|
[ -n "$ev" ] || return 0
|
|
177
329
|
mkdir -p "$(dirname "$logf")" 2>/dev/null || true
|
|
178
|
-
HQ_ROOT="$REPO_ROOT"
|
|
330
|
+
HQ_ROOT="$REPO_ROOT" CLAUDE_CODE_SESSION_ID="$sid" \
|
|
331
|
+
nohup bash -c 'printf "%s" "$1" | bash "$2" SessionStart' _ "$ev" "$hook" \
|
|
179
332
|
>>"$logf" 2>&1 </dev/null &
|
|
180
333
|
disown 2>/dev/null || true
|
|
181
334
|
return 0
|
|
@@ -183,15 +336,18 @@ spawn_work_mesh_register() {
|
|
|
183
336
|
|
|
184
337
|
# Print a company's hard-enforcement policies, read directly from the policy
|
|
185
338
|
# files (the pre-built digest was retired — the when/on trigger hook is now the
|
|
186
|
-
# sole policy-surfacing path). Emits one `- [hard] **
|
|
187
|
-
#
|
|
188
|
-
#
|
|
189
|
-
#
|
|
190
|
-
#
|
|
191
|
-
#
|
|
339
|
+
# sole policy-surfacing path). Emits one `- [hard] **id**: rule` line per policy,
|
|
340
|
+
# including its exact source path so IDs do not have to match filenames. Lines
|
|
341
|
+
# are deduped (digest/README/example/sync-conflict copies are skipped) and
|
|
342
|
+
# budgeted (HQ_COMPANY_BIND_POLICY_CAP lines / HQ_COMPANY_BIND_POLICY_BYTES
|
|
343
|
+
# bytes, with a non-silent pointer for the overflow). An unbounded dump —
|
|
344
|
+
# observed at ~298 lines for a large tenant — buries the rules it exists to
|
|
345
|
+
# surface; the reactive trigger hook re-injects any withheld policy when its
|
|
346
|
+
# trigger fires.
|
|
192
347
|
emit_company_hard_policies() {
|
|
193
348
|
local co="$1"
|
|
194
349
|
local dir="$REPO_ROOT/companies/$co/policies"
|
|
350
|
+
hq_session_has_exact_company_dir "$REPO_ROOT" "$co" || return 0
|
|
195
351
|
[ -d "$dir" ] || return 0
|
|
196
352
|
# Collect real policy files only. Skip the generated digest, docs, examples,
|
|
197
353
|
# and sync-conflict duplicates: policy slugs never contain spaces, so a space
|
|
@@ -209,13 +365,15 @@ emit_company_hard_policies() {
|
|
|
209
365
|
done
|
|
210
366
|
[ "${#files[@]}" -gt 0 ] || return 0
|
|
211
367
|
local lines
|
|
212
|
-
lines="$(awk '
|
|
368
|
+
lines="$(awk -v co="$co" '
|
|
213
369
|
function bn(p, n,a,b){ n=split(p,a,"/"); b=a[n]; sub(/\.md$/,"",b); return b }
|
|
214
|
-
function
|
|
370
|
+
function policy_file(p, n,a){ n=split(p,a,"/"); return a[n] }
|
|
371
|
+
function flush(){ if(enf=="hard" && rule!=""){ if(id=="")id=bn(fn); printf "- [hard] **%s**: %s Full text: `companies/%s/policies/%s`.\n", id, rule, co, policy_file(fn) } }
|
|
215
372
|
FNR==1 { if(seen) flush(); d=0;id="";enf="";rule="";rsec=0;rcap=0;fn=FILENAME;seen=1 }
|
|
216
373
|
/^---[ \t]*$/ { d++; next }
|
|
217
374
|
d==1 && /^id:/ { s=$0; sub(/^id:[ \t]*/,"",s); gsub(/^["'"'"']|["'"'"']$/,"",s); id=s; next }
|
|
218
375
|
d==1 && /^enforcement:/ { s=$0; sub(/^enforcement:[ \t]*/,"",s); gsub(/[ \t]/,"",s); enf=s; next }
|
|
376
|
+
d==1 && /^status:[ \t]*retired/ { enf="" ; next }
|
|
219
377
|
d>=2 && /^## Rule[ \t]*$/ { rsec=1; next }
|
|
220
378
|
d>=2 && rsec && /^## / { rsec=0 }
|
|
221
379
|
d>=2 && rsec && !rcap && NF { line=$0; gsub(/\*\*/,"",line); if(length(line)>160)line=substr(line,1,157)"..."; rule=line; rcap=1 }
|
|
@@ -226,11 +384,14 @@ emit_company_hard_policies() {
|
|
|
226
384
|
# (glob order), never silent — overflow is summarized with a pointer so the
|
|
227
385
|
# withheld policies stay one command away.
|
|
228
386
|
local cap="${HQ_COMPANY_BIND_POLICY_CAP:-32}"
|
|
229
|
-
|
|
387
|
+
# 2026-09-07: was 40960. Claude Code persists hook output above ~10,000
|
|
388
|
+
# bytes and shows the model a 2 KB preview, so a 40 KB digest was never
|
|
389
|
+
# read. Keep the whole digest under the host ceiling with margin.
|
|
390
|
+
local max_bytes="${HQ_COMPANY_BIND_POLICY_BYTES:-7168}"
|
|
230
391
|
printf '\n<company-policy-digest co="%s">\n' "$co"
|
|
231
392
|
printf '# %s hard-enforcement policies (auto-surfaced on company bind)\n' "$co"
|
|
232
393
|
printf '> Company context just bound mid-session. These HARD rules now apply.\n'
|
|
233
|
-
printf '> Full text: `companies/%s/policies
|
|
394
|
+
printf '> Full text: open the file path shown on each policy line. Browse `companies/%s/policies/` for the full set.\n\n' "$co"
|
|
234
395
|
printf '%s\n' "$lines" | awk -v cap="$cap" -v maxb="$max_bytes" -v co="$co" '
|
|
235
396
|
NF {
|
|
236
397
|
n++
|
|
@@ -242,8 +403,8 @@ emit_company_hard_policies() {
|
|
|
242
403
|
END {
|
|
243
404
|
dropped = n - kept
|
|
244
405
|
if (dropped > 0)
|
|
245
|
-
printf "\n> %d more hard %s not shown (bind budget: %d policies / %d bytes).
|
|
246
|
-
dropped, (dropped == 1 ? "policy" : "policies"), cap, maxb, co
|
|
406
|
+
printf "\n> %d more hard %s not shown (bind budget: %d policies / %d bytes). Browse `companies/%s/policies/` for the full set; matching rules re-surface via the policy trigger hook when they apply.\n", \
|
|
407
|
+
dropped, (dropped == 1 ? "policy" : "policies"), cap, maxb, co
|
|
247
408
|
}'
|
|
248
409
|
printf '</company-policy-digest>\n'
|
|
249
410
|
}
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# hq-core: public
|
|
3
|
+
# jobs-schedule-parse.sh — resolve natural-language (or raw cron) schedules for /schedule.
|
|
4
|
+
#
|
|
5
|
+
# Usage:
|
|
6
|
+
# core/scripts/jobs-schedule-parse.sh [--tz IANA] [--json] "<schedule text>"
|
|
7
|
+
#
|
|
8
|
+
# Prints one JSON object on stdout:
|
|
9
|
+
# {"ok":true,"cron":"0 9 * * 1-5","timezone":"America/Los_Angeles",
|
|
10
|
+
# "human":"Every weekday at 9:00 AM (America/Los_Angeles)","source":"nl"|"cron"}
|
|
11
|
+
# {"ok":false,"error":"..."}
|
|
12
|
+
#
|
|
13
|
+
# Cron is always the canonical 5-field form. NL is never echoed back as storage.
|
|
14
|
+
# Timezone defaults to the host IANA zone (macOS /etc/localtime, $TZ, or UTC).
|
|
15
|
+
#
|
|
16
|
+
# Requires: node. Optional: jq (not required for parse).
|
|
17
|
+
|
|
18
|
+
set -euo pipefail
|
|
19
|
+
|
|
20
|
+
TZ_OVERRIDE=""
|
|
21
|
+
|
|
22
|
+
usage() {
|
|
23
|
+
echo "usage: jobs-schedule-parse.sh [--tz IANA] \"<nl or cron schedule>\"" >&2
|
|
24
|
+
exit 2
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
while [ "$#" -gt 0 ]; do
|
|
28
|
+
case "$1" in
|
|
29
|
+
--tz)
|
|
30
|
+
[ "$#" -ge 2 ] || usage
|
|
31
|
+
TZ_OVERRIDE="$2"
|
|
32
|
+
shift 2
|
|
33
|
+
;;
|
|
34
|
+
--json)
|
|
35
|
+
# JSON is the only output mode; flag kept for callers/docs symmetry
|
|
36
|
+
shift
|
|
37
|
+
;;
|
|
38
|
+
-h|--help)
|
|
39
|
+
usage
|
|
40
|
+
;;
|
|
41
|
+
--)
|
|
42
|
+
shift
|
|
43
|
+
break
|
|
44
|
+
;;
|
|
45
|
+
-*)
|
|
46
|
+
echo "jobs-schedule-parse: unknown flag: $1" >&2
|
|
47
|
+
usage
|
|
48
|
+
;;
|
|
49
|
+
*)
|
|
50
|
+
break
|
|
51
|
+
;;
|
|
52
|
+
esac
|
|
53
|
+
done
|
|
54
|
+
|
|
55
|
+
[ "$#" -ge 1 ] || usage
|
|
56
|
+
SCHEDULE="$*"
|
|
57
|
+
SCHEDULE="$(printf '%s' "$SCHEDULE" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
|
|
58
|
+
[ -n "$SCHEDULE" ] || {
|
|
59
|
+
printf '%s\n' '{"ok":false,"error":"empty schedule"}'
|
|
60
|
+
exit 1
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export JOBS_SCHEDULE_PARSE_INPUT="$SCHEDULE"
|
|
64
|
+
export JOBS_SCHEDULE_PARSE_TZ="${TZ_OVERRIDE}"
|
|
65
|
+
|
|
66
|
+
node <<'JS'
|
|
67
|
+
const fs = require("fs");
|
|
68
|
+
const path = require("path");
|
|
69
|
+
const { execFileSync } = require("child_process");
|
|
70
|
+
|
|
71
|
+
const inp = (process.env.JOBS_SCHEDULE_PARSE_INPUT || "").trim();
|
|
72
|
+
const tzOverride = (process.env.JOBS_SCHEDULE_PARSE_TZ || "").trim();
|
|
73
|
+
|
|
74
|
+
function fail(msg, code = 1) {
|
|
75
|
+
process.stdout.write(JSON.stringify({ ok: false, error: msg }));
|
|
76
|
+
process.stdout.write("\n");
|
|
77
|
+
process.exit(code);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function detectTz() {
|
|
81
|
+
if (tzOverride) return tzOverride;
|
|
82
|
+
const envTz = (process.env.TZ || "").trim();
|
|
83
|
+
if (envTz && envTz.includes("/")) return envTz;
|
|
84
|
+
try {
|
|
85
|
+
const link = fs.realpathSync("/etc/localtime");
|
|
86
|
+
const parts = link.split(path.sep).filter(Boolean);
|
|
87
|
+
const i = parts.indexOf("zoneinfo");
|
|
88
|
+
if (i >= 0) {
|
|
89
|
+
const cand = parts.slice(i + 1).join("/");
|
|
90
|
+
if (cand) return cand;
|
|
91
|
+
}
|
|
92
|
+
} catch (_) {
|
|
93
|
+
/* ignore */
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
const out = execFileSync(
|
|
97
|
+
"timedatectl",
|
|
98
|
+
["show", "-p", "Timezone", "--value"],
|
|
99
|
+
{ encoding: "utf8", timeout: 2000, stdio: ["ignore", "pipe", "ignore"] }
|
|
100
|
+
).trim();
|
|
101
|
+
if (out && out.includes("/")) return out;
|
|
102
|
+
} catch (_) {
|
|
103
|
+
/* ignore */
|
|
104
|
+
}
|
|
105
|
+
return "UTC";
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function validateIana(tz) {
|
|
109
|
+
const wellFormed = /^(UTC|[A-Za-z0-9_+\-]+(\/[A-Za-z0-9_+\-]+)+)$/;
|
|
110
|
+
if (!wellFormed.test(tz)) return false;
|
|
111
|
+
try {
|
|
112
|
+
Intl.DateTimeFormat(undefined, { timeZone: tz });
|
|
113
|
+
return true;
|
|
114
|
+
} catch (_) {
|
|
115
|
+
// Intl may lack tzdata on some hosts — accept well-formed names
|
|
116
|
+
return wellFormed.test(tz);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const CRON_RE = /^(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)$/;
|
|
121
|
+
|
|
122
|
+
const DOW_NAMES = {
|
|
123
|
+
sun: 0,
|
|
124
|
+
sunday: 0,
|
|
125
|
+
mon: 1,
|
|
126
|
+
monday: 1,
|
|
127
|
+
tue: 2,
|
|
128
|
+
tues: 2,
|
|
129
|
+
tuesday: 2,
|
|
130
|
+
wed: 3,
|
|
131
|
+
wednesday: 3,
|
|
132
|
+
thu: 4,
|
|
133
|
+
thur: 4,
|
|
134
|
+
thurs: 4,
|
|
135
|
+
thursday: 4,
|
|
136
|
+
fri: 5,
|
|
137
|
+
friday: 5,
|
|
138
|
+
sat: 6,
|
|
139
|
+
saturday: 6,
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const DOW_LABEL = {
|
|
143
|
+
"0": "Sunday",
|
|
144
|
+
"1": "Monday",
|
|
145
|
+
"2": "Tuesday",
|
|
146
|
+
"3": "Wednesday",
|
|
147
|
+
"4": "Thursday",
|
|
148
|
+
"5": "Friday",
|
|
149
|
+
"6": "Saturday",
|
|
150
|
+
"1-5": "weekday",
|
|
151
|
+
"*": "day",
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
function parseClock(fragment) {
|
|
155
|
+
const s = fragment.trim().toLowerCase().replace(/ /g, "");
|
|
156
|
+
const m = /^(\d{1,2})(?::(\d{2}))?\s*(am|pm)?$/.exec(s);
|
|
157
|
+
if (!m) return null;
|
|
158
|
+
let hour = parseInt(m[1], 10);
|
|
159
|
+
const minute = parseInt(m[2] || "0", 10);
|
|
160
|
+
const ampm = m[3];
|
|
161
|
+
if (minute > 59) return null;
|
|
162
|
+
if (ampm) {
|
|
163
|
+
if (hour < 1 || hour > 12) return null;
|
|
164
|
+
if (ampm === "am") hour = hour === 12 ? 0 : hour;
|
|
165
|
+
else hour = hour === 12 ? 12 : hour + 12;
|
|
166
|
+
} else if (hour > 23) {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
return [hour, minute];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function fmtClock(hour, minute) {
|
|
173
|
+
const suffix = hour < 12 ? "AM" : "PM";
|
|
174
|
+
let h12 = hour % 12;
|
|
175
|
+
if (h12 === 0) h12 = 12;
|
|
176
|
+
return `${h12}:${String(minute).padStart(2, "0")} ${suffix}`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function humanizeCron(cron, tz) {
|
|
180
|
+
const parts = cron.split(/\s+/);
|
|
181
|
+
if (parts.length !== 5) return `Cron \`${cron}\` (${tz})`;
|
|
182
|
+
const [minute, hour, dom, month, dow] = parts;
|
|
183
|
+
if (
|
|
184
|
+
/^\d+$/.test(minute) &&
|
|
185
|
+
/^\d+$/.test(hour) &&
|
|
186
|
+
dom === "*" &&
|
|
187
|
+
month === "*" &&
|
|
188
|
+
(dow === "*" || dow === "1-5")
|
|
189
|
+
) {
|
|
190
|
+
const clock = fmtClock(parseInt(hour, 10), parseInt(minute, 10));
|
|
191
|
+
if (dow === "1-5") return `Every weekday at ${clock} (${tz})`;
|
|
192
|
+
return `Every day at ${clock} (${tz})`;
|
|
193
|
+
}
|
|
194
|
+
if (
|
|
195
|
+
/^\d+$/.test(minute) &&
|
|
196
|
+
/^\d+$/.test(hour) &&
|
|
197
|
+
dom === "*" &&
|
|
198
|
+
month === "*" &&
|
|
199
|
+
/^\d+$/.test(dow)
|
|
200
|
+
) {
|
|
201
|
+
const day = DOW_LABEL[dow] || `day ${dow}`;
|
|
202
|
+
return `Every ${day} at ${fmtClock(parseInt(hour, 10), parseInt(minute, 10))} (${tz})`;
|
|
203
|
+
}
|
|
204
|
+
if (minute.startsWith("*/") && hour === "*" && dom === "*" && month === "*" && dow === "*") {
|
|
205
|
+
return `Every ${minute.slice(2)} minutes (${tz})`;
|
|
206
|
+
}
|
|
207
|
+
if (minute === "0" && hour.startsWith("*/") && dom === "*" && month === "*" && dow === "*") {
|
|
208
|
+
return `Every ${hour.slice(2)} hours (${tz})`;
|
|
209
|
+
}
|
|
210
|
+
if (minute === "0" && hour === "*" && dom === "*" && month === "*" && dow === "*") {
|
|
211
|
+
return `Every hour on the hour (${tz})`;
|
|
212
|
+
}
|
|
213
|
+
return `Cron \`${cron}\` (${tz})`;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function parseNl(text) {
|
|
217
|
+
const s = text.trim();
|
|
218
|
+
const low = s.toLowerCase();
|
|
219
|
+
let m = /^every\s+(\d+)\s+minutes?$/.exec(low);
|
|
220
|
+
if (m) {
|
|
221
|
+
const n = parseInt(m[1], 10);
|
|
222
|
+
if (n < 1 || n > 59) return [null, "minute interval must be 1..59"];
|
|
223
|
+
return [`*/${n} * * * *`, `Every ${n} minutes`];
|
|
224
|
+
}
|
|
225
|
+
if (low === "every hour" || low === "hourly") {
|
|
226
|
+
return ["0 * * * *", "Every hour on the hour"];
|
|
227
|
+
}
|
|
228
|
+
m = /^every\s+(\d+)\s+hours?$/.exec(low);
|
|
229
|
+
if (m) {
|
|
230
|
+
const n = parseInt(m[1], 10);
|
|
231
|
+
if (n < 1 || n > 23) return [null, "hour interval must be 1..23"];
|
|
232
|
+
return [`0 */${n} * * *`, `Every ${n} hours`];
|
|
233
|
+
}
|
|
234
|
+
m = /^(?:every\s+)?(weekday|weekdays|day|daily)\s+(?:at\s+)?(.+)$/.exec(low);
|
|
235
|
+
if (m) {
|
|
236
|
+
const kind = m[1];
|
|
237
|
+
const clock = parseClock(m[2]);
|
|
238
|
+
if (!clock) return [null, `could not parse time: ${m[2]}`];
|
|
239
|
+
const [hour, minute] = clock;
|
|
240
|
+
if (kind === "weekday" || kind === "weekdays") {
|
|
241
|
+
return [`${minute} ${hour} * * 1-5`, `Every weekday at ${fmtClock(hour, minute)}`];
|
|
242
|
+
}
|
|
243
|
+
return [`${minute} ${hour} * * *`, `Every day at ${fmtClock(hour, minute)}`];
|
|
244
|
+
}
|
|
245
|
+
m =
|
|
246
|
+
/^(?:every\s+)?(sun(?:day)?|mon(?:day)?|tue(?:s|sday)?|wed(?:nesday)?|thu(?:r|rs|rsday)?|fri(?:day)?|sat(?:urday)?)s?\s+(?:at\s+)?(.+)$/.exec(
|
|
247
|
+
low
|
|
248
|
+
);
|
|
249
|
+
if (m) {
|
|
250
|
+
const dow = DOW_NAMES[m[1]];
|
|
251
|
+
const clock = parseClock(m[2]);
|
|
252
|
+
if (!clock) return [null, `could not parse time: ${m[2]}`];
|
|
253
|
+
const [hour, minute] = clock;
|
|
254
|
+
return [
|
|
255
|
+
`${minute} ${hour} * * ${dow}`,
|
|
256
|
+
`Every ${DOW_LABEL[String(dow)]} at ${fmtClock(hour, minute)}`,
|
|
257
|
+
];
|
|
258
|
+
}
|
|
259
|
+
m = /^at\s+(.+?)\s+every\s+(weekday|weekdays|day|daily)$/.exec(low);
|
|
260
|
+
if (m) {
|
|
261
|
+
const clock = parseClock(m[1]);
|
|
262
|
+
if (!clock) return [null, `could not parse time: ${m[1]}`];
|
|
263
|
+
const [hour, minute] = clock;
|
|
264
|
+
const kind = m[2];
|
|
265
|
+
if (kind === "weekday" || kind === "weekdays") {
|
|
266
|
+
return [`${minute} ${hour} * * 1-5`, `Every weekday at ${fmtClock(hour, minute)}`];
|
|
267
|
+
}
|
|
268
|
+
return [`${minute} ${hour} * * *`, `Every day at ${fmtClock(hour, minute)}`];
|
|
269
|
+
}
|
|
270
|
+
return [
|
|
271
|
+
null,
|
|
272
|
+
"unrecognized schedule — try 'every weekday at 9am', " +
|
|
273
|
+
"'every day at 14:30', 'every monday at 9am', a 5-field cron, " +
|
|
274
|
+
"or 'every 15 minutes'",
|
|
275
|
+
];
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const tz = detectTz();
|
|
279
|
+
if (!validateIana(tz)) fail(`invalid IANA timezone: ${tz}`);
|
|
280
|
+
|
|
281
|
+
if (CRON_RE.test(inp) && !/[A-Za-z]/.test(inp)) {
|
|
282
|
+
const cron = inp;
|
|
283
|
+
const human = humanizeCron(cron, tz);
|
|
284
|
+
process.stdout.write(
|
|
285
|
+
JSON.stringify({ ok: true, cron, timezone: tz, human, source: "cron" })
|
|
286
|
+
);
|
|
287
|
+
process.stdout.write("\n");
|
|
288
|
+
process.exit(0);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const named = /^(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(mon-fri|monday-friday)$/i.exec(inp);
|
|
292
|
+
if (named) {
|
|
293
|
+
const cron = `${named[1]} ${named[2]} ${named[3]} ${named[4]} 1-5`;
|
|
294
|
+
const human = humanizeCron(cron, tz);
|
|
295
|
+
process.stdout.write(
|
|
296
|
+
JSON.stringify({ ok: true, cron, timezone: tz, human, source: "cron" })
|
|
297
|
+
);
|
|
298
|
+
process.stdout.write("\n");
|
|
299
|
+
process.exit(0);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const [cron, humanOrErr] = parseNl(inp);
|
|
303
|
+
if (cron == null) fail(humanOrErr);
|
|
304
|
+
|
|
305
|
+
const human = `${humanOrErr} (${tz})`;
|
|
306
|
+
process.stdout.write(
|
|
307
|
+
JSON.stringify({ ok: true, cron, timezone: tz, human, source: "nl" })
|
|
308
|
+
);
|
|
309
|
+
process.stdout.write("\n");
|
|
310
|
+
JS
|