@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
@@ -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 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.
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-register.sh"
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
- [ -x "$hook" ] || return 0
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" nohup bash -c 'printf "%s" "$1" | "$2" company_slug' _ "$ev" "$hook" \
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] **slug**: rule` line each,
187
- # deduped (digest/README/example/sync-conflict copies are skipped) and budgeted
188
- # (HQ_COMPANY_BIND_POLICY_CAP lines / HQ_COMPANY_BIND_POLICY_BYTES bytes, with a
189
- # non-silent pointer for the overflow). An unbounded dump — observed at ~298
190
- # lines for a large tenant — buries the rules it exists to surface; the
191
- # reactive trigger hook re-injects any withheld policy when its trigger fires.
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 flush(){ if(enf=="hard" && rule!=""){ if(id=="")id=bn(fn); printf "- [hard] **%s**: %s\n", id, rule } }
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
- local max_bytes="${HQ_COMPANY_BIND_POLICY_BYTES:-40960}"
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/{slug}.md` (or `qmd get -c %s {slug}`).\n\n' "$co" "$co"
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). Full set: `companies/%s/policies/` or `qmd get -c %s {slug}`; matching rules re-surface via the policy trigger hook when they apply.\n", \
246
- dropped, (dropped == 1 ? "policy" : "policies"), cap, maxb, co, 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