@indigoai-us/hq-cli 5.312.2-beta.0 → 5.313.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 (81) hide show
  1. package/CHANGELOG.md +9 -2
  2. package/assets/scaffold/core/scripts/lib/work-mesh-live-hook.sh +378 -0
  3. package/assets/scaffold/core/scripts/lib/work-mesh-live-rebind.sh +141 -0
  4. package/assets/scaffold/core/scripts/migrate-policy-triggers.sh +373 -0
  5. package/assets/scaffold/core/scripts/register-project.sh +262 -0
  6. package/assets/scaffold/core/scripts/work-mesh-live-rebind.sh +59 -0
  7. package/dist/command-catalog.generated.d.ts +80 -0
  8. package/dist/command-catalog.generated.js +99 -0
  9. package/dist/commands/cloud.js +0 -1
  10. package/dist/commands/company-rename-flag.d.ts +4 -0
  11. package/dist/commands/company-rename-flag.js +7 -0
  12. package/dist/commands/company-rename.d.ts +4 -0
  13. package/dist/commands/company-rename.js +90 -0
  14. package/dist/commands/company.js +2 -0
  15. package/dist/commands/core-eval-trigger-native.d.ts +4 -0
  16. package/dist/commands/core-eval-trigger-native.js +12 -0
  17. package/dist/commands/core-knowledge-prefs.d.ts +0 -1
  18. package/dist/commands/core-knowledge-prefs.js +1 -1
  19. package/dist/commands/core-migrate-policy-triggers-native.d.ts +21 -0
  20. package/dist/commands/core-migrate-policy-triggers-native.js +378 -0
  21. package/dist/commands/core-native-hook-helper-commands.d.ts +4 -0
  22. package/dist/commands/core-native-hook-helper-commands.js +56 -0
  23. package/dist/commands/core-native-hook-helpers-flag.d.ts +4 -0
  24. package/dist/commands/core-native-hook-helpers-flag.js +7 -0
  25. package/dist/commands/core-register-project-native.d.ts +30 -0
  26. package/dist/commands/core-register-project-native.js +347 -0
  27. package/dist/commands/core-work-mesh-live-rebind-native.d.ts +18 -0
  28. package/dist/commands/core-work-mesh-live-rebind-native.js +262 -0
  29. package/dist/commands/core-worker-share-native.d.ts +0 -1
  30. package/dist/commands/core-worker-share-native.js +1 -1
  31. package/dist/commands/core.js +7 -0
  32. package/dist/commands/lanes-message.js +3 -0
  33. package/dist/commands/lanes-questions.js +2 -1
  34. package/dist/commands/lanes-resume.js +3 -0
  35. package/dist/commands/lanes.js +151 -21
  36. package/dist/fast-core.js +10 -3
  37. package/dist/fast-scaffold.d.ts +2 -0
  38. package/dist/fast-scaffold.js +11 -0
  39. package/dist/lib/doctor/checks/work-context.js +9 -1
  40. package/dist/lib/lanes/config.d.ts +3 -0
  41. package/dist/lib/lanes/config.js +14 -0
  42. package/dist/lib/lanes/disk-guard.d.ts +66 -0
  43. package/dist/lib/lanes/disk-guard.js +172 -0
  44. package/dist/lib/lanes/flag-keys.d.ts +2 -0
  45. package/dist/lib/lanes/flag-keys.js +2 -0
  46. package/dist/lib/lanes/grants.d.ts +4 -3
  47. package/dist/lib/lanes/grants.js +12 -7
  48. package/dist/lib/lanes/hire-grant-dispatch.js +2 -3
  49. package/dist/lib/lanes/index.d.ts +1 -1
  50. package/dist/lib/lanes/index.js +1 -1
  51. package/dist/lib/lanes/reconcile.d.ts +7 -1
  52. package/dist/lib/lanes/reconcile.js +142 -11
  53. package/dist/lib/lanes/registry-flag.d.ts +6 -0
  54. package/dist/lib/lanes/registry-flag.js +25 -0
  55. package/dist/lib/lanes/spawn.d.ts +5 -0
  56. package/dist/lib/lanes/spawn.js +26 -1
  57. package/dist/lib/lanes/store.js +11 -0
  58. package/dist/lib/lanes/types.d.ts +11 -1
  59. package/dist/lib/lanes/types.js +1 -0
  60. package/dist/lib/mesh/client.js +23 -5
  61. package/dist/lib/mesh/denial-codes.d.ts +5 -0
  62. package/dist/lib/mesh/denial-codes.js +14 -0
  63. package/dist/lib/mesh/live/daemon/doctor.d.ts +2 -0
  64. package/dist/lib/mesh/live/daemon/doctor.js +11 -1
  65. package/dist/lib/search-index/embed-lock.d.ts +2 -0
  66. package/dist/lib/search-index/embed-lock.js +10 -1
  67. package/dist/lib/work-context/contract.d.ts +3 -0
  68. package/dist/lib/work-context/contract.schema.json +3 -1
  69. package/dist/lib/work-context/outbox.d.ts +14 -5
  70. package/dist/lib/work-context/outbox.js +48 -5
  71. package/dist/lib/work-context/reconcile.js +26 -5
  72. package/dist/main.d.ts +20 -0
  73. package/dist/main.js +8 -1
  74. package/dist/utils/core-eval-trigger.d.ts +4 -0
  75. package/dist/utils/core-eval-trigger.js +88 -0
  76. package/dist/utils/pack-contributions.d.ts +0 -5
  77. package/dist/utils/pack-contributions.js +1 -1
  78. package/dist/utils/redact-error-text.d.ts +0 -2
  79. package/dist/utils/redact-error-text.js +1 -1
  80. package/dist/utils/sandbox-runner-client.d.ts +2 -1
  81. package/package.json +1 -1
@@ -0,0 +1,373 @@
1
+ #!/bin/bash
2
+ # migrate-policy-triggers.sh — add `when:`/`on:` frontmatter to every policy
3
+ # that lacks it, derived from the policy's own metadata.
4
+ #
5
+ # NOTE: `trigger:` was removed from the policy schema (superseded by when/on).
6
+ # This backfill still reads a `trigger:` line if a legacy/external policy has
7
+ # one, but newly authored policies derive `when:` from tags alone. When no
8
+ # signal can be derived, only hard-enforcement policies receive the
9
+ # `when: always` fallback; soft/unset policies remain untriggered. The
10
+ # create/edit guard validate-policy-frontmatter.sh is now the primary
11
+ # enforcement that every authored policy declares when/on.
12
+ #
13
+ # `when:` is generated from up to TWO sources, OR-combined:
14
+ # 1. trigger content — the authored `trigger:` prose, mapped to a precise
15
+ # expression for the cross-cutting actions tags don't capture (git
16
+ # sub-commands, deploy, secret/credential, migration, pr, install, ...).
17
+ # Compound expressions (`git && push`) are preserved verbatim.
18
+ # 2. tags — the `tags: [...]` frontmatter, the policy's topical vocabulary
19
+ # (git, deploy, slack, supabase, auth, refactor, ...). `vendor:x` is
20
+ # normalised to `x`; pure taxonomy/meta tags that never appear in a prompt
21
+ # or command (infrastructure, consolidated, ux, safety, ...) are dropped so
22
+ # they don't bloat the expression or over-fire on a generic word.
23
+ #
24
+ # Because fact derivation is now OPEN (derive-trigger-facts.sh emits every word
25
+ # token, not a fixed list), any surviving tag/trigger word is a live token — no
26
+ # expression is dead.
27
+ #
28
+ # `on:` = ALL hook events EXCEPT SessionStart — [PreToolUse, PostToolUse,
29
+ # UserPromptSubmit, AssistantIntent] — for any policy that gets a real `when:`.
30
+ # `on:` is the set of sites where the policy is even evaluated; the `when:`
31
+ # expression does the actual filtering, so evaluating broadly is cheap and
32
+ # correct. SessionStart is reserved for the hard-policy `when: always` fallback
33
+ # (it has no command/prompt facts — only static facts + `always`), used when
34
+ # neither tags nor trigger yield a signal. A non-hard policy with no derivable
35
+ # signal is left unchanged so it cannot silently join the SessionStart baseline.
36
+ #
37
+ # STRICTLY IDEMPOTENT: a policy that already has a `when:` line is left
38
+ # untouched — the script only ever ADDS when/on to a policy that lacks them, and
39
+ # never rewrites an existing trigger. There is no force/regenerate mode: once a
40
+ # policy declares (or a human tunes) its trigger, that is authoritative.
41
+ #
42
+ # Runs at SessionStart (registered in .claude/settings.json) so any newly
43
+ # authored policy — personal, company, or repo — is auto-backfilled with a
44
+ # trigger on the next session, with zero writes in steady state.
45
+ #
46
+ # Edits in place via this script (Bash) — NOT the Edit tool — to avoid per-file
47
+ # autosave churn.
48
+ #
49
+ # Usage: bash core/scripts/migrate-policy-triggers.sh [--dry-run] [dir ...]
50
+ # With no dir, scans core/policies plus the active company/repo policy dir
51
+ # derived from the cwd (the SessionStart behaviour). Pass dir(s) to override.
52
+ #
53
+ # COOLDOWN
54
+ #
55
+ # A full policy scan is idempotent but expensive, and this script runs on every
56
+ # SessionStart. Completed non-dry runs are therefore limited to one per
57
+ # cooldown window (default 60 minutes). The stamp records only a SUCCESSFUL
58
+ # completion — never a run in progress — so an interrupted SessionStart cannot
59
+ # suppress the next migration.
60
+ #
61
+ # Env overrides:
62
+ # HQ_MIGRATE_POLICY_TRIGGERS_COOLDOWN_SECONDS default 3600; 0 disables cooldown
63
+ # HQ_MIGRATE_POLICY_TRIGGERS_FORCE=1 run now regardless of cooldown
64
+ # HQ_MIGRATE_POLICY_TRIGGERS_STATE_DIR where the per-host stamp lives
65
+ #
66
+ # The stamp is per-host runtime state, so it lives under $XDG_STATE_HOME
67
+ # (default $HOME/.local/state), never under this repo or workspace/, which HQ
68
+ # Sync reconciles. It is scoped to both HQ_ROOT and the resolved policy
69
+ # directories, so one tenant cannot suppress another tenant's first scan. If
70
+ # neither a state override, XDG_STATE_HOME, nor HOME is available, the script
71
+ # safely runs without a cooldown. --dry-run neither consults nor updates it.
72
+
73
+ set -uo pipefail
74
+
75
+ HQ_ROOT="${HQ_ROOT:-${CLAUDE_PROJECT_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}}"
76
+ CWD="${CLAUDE_PROJECT_DIR:-$PWD}"
77
+ DRY=0; DIRS=()
78
+ for a in "$@"; do case "$a" in
79
+ --dry-run) DRY=1 ;;
80
+ *) DIRS+=("$a") ;;
81
+ esac; done
82
+
83
+ # Default scope: global core and personal policies, every personal worker
84
+ # profile, and only the active company's and repo's policies. Company worker
85
+ # profiles remain tenant-scoped to the active company.
86
+ if [ "${#DIRS[@]}" -eq 0 ]; then
87
+ # personal/policies is read DIRECTLY (the reindex symlink mirror into
88
+ # core/policies is retired). Mirrors the scope inject-policy-on-trigger reads.
89
+ DIRS=("$HQ_ROOT/core/policies" "$HQ_ROOT/personal/policies")
90
+ for d in "$HQ_ROOT"/personal/workers/*/policies; do
91
+ [ -d "$d" ] && DIRS+=("$d")
92
+ done
93
+ co="${HQ_POLICY_COMPANY:-}"
94
+ if [ -z "$co" ]; then
95
+ case "$CWD" in
96
+ *companies/*) co="$(printf '%s' "$CWD" | sed -nE 's#.*companies/([^/]+).*#\1#p')" ;;
97
+ esac
98
+ fi
99
+ case "$co" in
100
+ ''|.|..|*/*) ;;
101
+ *)
102
+ DIRS+=("$HQ_ROOT/companies/$co/policies")
103
+ for d in "$HQ_ROOT/companies/$co"/workers/*/policies; do
104
+ [ -d "$d" ] && DIRS+=("$d")
105
+ done
106
+ ;;
107
+ esac
108
+ case "$CWD" in
109
+ *repos/public/*|*repos/private/*)
110
+ rscope="$(printf '%s' "$CWD" | sed -nE 's#.*repos/(public|private)/.*#\1#p')"
111
+ rname="$(printf '%s' "$CWD" | sed -nE 's#.*repos/[^/]+/([^/]+).*#\1#p')"
112
+ [ -n "$rscope" ] && [ -n "$rname" ] && DIRS+=("$HQ_ROOT/repos/$rscope/$rname/.claude/policies") ;;
113
+ esac
114
+ fi
115
+
116
+ STATE_DIR="${HQ_MIGRATE_POLICY_TRIGGERS_STATE_DIR:-}"
117
+ if [ -z "$STATE_DIR" ]; then
118
+ if [ -n "${XDG_STATE_HOME:-}" ]; then
119
+ STATE_DIR="$XDG_STATE_HOME/hq-migrate-policy-triggers"
120
+ elif [ -n "${HOME:-}" ]; then
121
+ STATE_DIR="$HOME/.local/state/hq-migrate-policy-triggers"
122
+ fi
123
+ fi
124
+ COOLDOWN_SECONDS="${HQ_MIGRATE_POLICY_TRIGGERS_COOLDOWN_SECONDS:-3600}"
125
+ STAMP=""
126
+ LOCK=""
127
+ STATE_READY=0
128
+
129
+ # Hash the resolved scope rather than using a shared per-root stamp: the
130
+ # default scan deliberately includes tenant-specific directories.
131
+ scope_key() {
132
+ if command -v sha256sum >/dev/null 2>&1; then
133
+ printf '%s\0' "$HQ_ROOT" "${DIRS[@]}" | sha256sum | awk '{print $1}'
134
+ elif command -v shasum >/dev/null 2>&1; then
135
+ printf '%s\0' "$HQ_ROOT" "${DIRS[@]}" | shasum -a 256 | awk '{print $1}'
136
+ else
137
+ return 1
138
+ fi
139
+ }
140
+
141
+ if [ -n "$STATE_DIR" ] && SCOPE_KEY="$(scope_key)"; then
142
+ STAMP="$STATE_DIR/$SCOPE_KEY/last-success"
143
+ LOCK="$STATE_DIR/$SCOPE_KEY/migrate.lock"
144
+ mkdir -p "$(dirname "$STAMP")" 2>/dev/null && STATE_READY=1
145
+ fi
146
+
147
+ cooldown_note() {
148
+ printf 'migrate-policy-triggers: cooldown stamp %s; running\n' "$1" >&2
149
+ }
150
+
151
+ # Three stamp states are intentionally distinct:
152
+ # absent -> this scope has not completed a run; run
153
+ # present but unreadable/future -> cannot trust the clock; log and run
154
+ # present and fresh -> exit quietly
155
+ cooldown_blocks_run() {
156
+ [ "$DRY" = "1" ] && return 1
157
+ [ "$STATE_READY" = "1" ] || return 1
158
+ [ "${HQ_MIGRATE_POLICY_TRIGGERS_FORCE:-0}" = "1" ] && return 1
159
+ [ "$COOLDOWN_SECONDS" -gt 0 ] 2>/dev/null || return 1
160
+ { [ -e "$STAMP" ] || [ -L "$STAMP" ]; } || return 1
161
+
162
+ local last now age
163
+ last="$(cat "$STAMP" 2>/dev/null)"
164
+ case "$last" in
165
+ ''|*[!0-9]*) cooldown_note "is unreadable"; return 1 ;;
166
+ esac
167
+
168
+ now="$(date -u '+%s')"
169
+ age=$((now - last))
170
+ if [ "$age" -lt 0 ]; then
171
+ cooldown_note "is in the future"
172
+ return 1
173
+ fi
174
+ [ "$age" -lt "$COOLDOWN_SECONDS" ]
175
+ }
176
+
177
+ write_success_stamp() {
178
+ local stamp_tmp
179
+ [ "$DRY" = "1" ] && return 0
180
+ [ "$STATE_READY" = "1" ] || return 0
181
+ stamp_tmp="$(mktemp "$(dirname "$STAMP")/.last-success.XXXXXX")" || return 0
182
+ if printf '%s\n' "$(date -u '+%s')" >"$stamp_tmp" && mv "$stamp_tmp" "$STAMP"; then
183
+ return 0
184
+ fi
185
+ rm -f "$stamp_tmp"
186
+ }
187
+
188
+ if cooldown_blocks_run; then
189
+ exit 0
190
+ fi
191
+
192
+ # A scope-local advisory lock prevents concurrent SessionStart hooks from both
193
+ # observing the same absent/stale stamp. Recheck after acquiring it because a
194
+ # prior holder may have completed while this invocation waited to acquire it.
195
+ if [ "$STATE_READY" = "1" ] && [ "$DRY" != "1" ] && command -v flock >/dev/null 2>&1; then
196
+ if ! exec 9>"$LOCK"; then
197
+ exit 0
198
+ fi
199
+ if ! flock -n 9; then # command -v flock checked above
200
+ exit 0
201
+ fi
202
+ if cooldown_blocks_run; then
203
+ exit 0
204
+ fi
205
+ fi
206
+
207
+ ON_LIVE="[PreToolUse, PostToolUse, UserPromptSubmit, AssistantIntent]"
208
+ ON_START="[SessionStart]"
209
+
210
+ # A synthesized `when:` is written to disk without a human ever reading it, and
211
+ # the write-time validator does not see a `bash`-authored edit. So every derived
212
+ # expression is checked against the canonical grammar before it lands — a tag
213
+ # token carrying a character outside the identifier charset would otherwise
214
+ # produce a `when:` the evaluator cannot parse, and the policy would never match
215
+ # for the reason it was backfilled.
216
+ EVAL_TRIGGER="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/eval-trigger.sh"
217
+ when_parses() {
218
+ [ -f "$EVAL_TRIGGER" ] || return 0 # no evaluator -> don't block the backfill
219
+ [ "$(printf 'x\t%s\n' "$1" | bash "$EVAL_TRIGGER" --check | cut -f2)" = "ok" ]
220
+ }
221
+
222
+ # Meta / taxonomy tags that classify a policy but never appear as a word in a
223
+ # command, prompt, or AI message — dropping them keeps `when:` to live signals.
224
+ TAG_STOP=" infrastructure consolidated ux safety hq hq-core hq-cli hq-packages \
225
+ hq-discipline basic-users knowledge-repos quiet-mode capabilities narration \
226
+ voice intent delight orchestration abstraction workflow deliverable routing \
227
+ anchor rulesets data-handling shell-injection promotion docs knowledge "
228
+
229
+ # field extractor (first frontmatter occurrence)
230
+ fm() { awk -v k="$1" '/^---$/{d++;next} d==1 && $0 ~ ("^" k ":"){sub("^" k ":[[:space:]]*","");print;exit}' "$2"; }
231
+
232
+ # trigger_expr <lowercased-trigger-prose> — precise expression for a known
233
+ # action, or empty string. Mirrors the tokens derive-trigger-facts.sh emits.
234
+ trigger_expr() {
235
+ local t="$1"
236
+ case "$t" in
237
+ *deploy*) echo 'deploy' ;;
238
+ *credential*|*secret*|*op://*|*aws_profile*|*.env*) echo 'secret || credential' ;;
239
+ *"git push"*|*"gh pr merge"*|*" push "*|*"push to"*) echo 'git && push' ;;
240
+ *commitment*) echo '' ;; # "commitment(s)" is NOT a git commit — let tags drive
241
+ *commit*) echo 'git && commit' ;;
242
+ *checkout*) echo 'git && checkout' ;;
243
+ *rebase*) echo 'git && rebase' ;;
244
+ *"git stash"*|*" stash "*) echo 'git && stash' ;;
245
+ *"gh pr"*|*"pull request"*|*"pr merge"*) echo 'pr' ;;
246
+ *merge*) echo 'git && merge' ;;
247
+ *migration*|*migrate*|*schema*|*prisma*) echo 'migrate || migration || schema' ;;
248
+ *"npm install"*|*"pnpm "*|*"yarn "*|*" install "*|*"package install"*) echo 'install' ;;
249
+ *grep*) echo 'grep' ;;
250
+ *slack*) echo 'slack' ;;
251
+ *email*) echo 'email' ;;
252
+ *" git "*|"git "*|*" git") echo 'git' ;;
253
+ *) echo '' ;;
254
+ esac
255
+ }
256
+
257
+ # tag_tokens <raw-tags-line-without-brackets> — normalised, filtered tag tokens
258
+ # (one per line). vendor:x -> x; meta tags dropped; lowercased.
259
+ tag_tokens() {
260
+ printf '%s' "$1" | tr ',' '\n' | while IFS= read -r tag; do
261
+ tag="$(printf '%s' "$tag" | tr '[:upper:]' '[:lower:]' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')"
262
+ tag="${tag#vendor:}" # vendor:linear -> linear
263
+ [ -n "$tag" ] || continue
264
+ case "$TAG_STOP" in *" $tag "*) continue ;; esac
265
+ printf '%s\n' "$tag"
266
+ done
267
+ }
268
+
269
+ # build_when <trigger-prose> <raw-tags> <enforcement> — emit "WHEN<TAB>ON".
270
+ # Returns 1 without output when no signal is derivable for a non-hard policy.
271
+ build_when() {
272
+ local trig tags enforcement te t seen
273
+ trig="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')"
274
+ tags="$2"
275
+ enforcement="${3%%#*}"
276
+ enforcement="$(printf '%s' "$enforcement" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')"
277
+ enforcement="${enforcement#\"}"; enforcement="${enforcement%\"}"
278
+ enforcement="${enforcement#\'}"; enforcement="${enforcement%\'}"
279
+ te="$(trigger_expr "$trig")"
280
+
281
+ # terms: the trigger expression first (parenthesised if it has operators),
282
+ # then each tag token not already named inside the trigger expression.
283
+ local -a terms=()
284
+ seen=" "
285
+ if [ -n "$te" ]; then
286
+ # record identifiers already used so a tag doesn't loosen a precise expr
287
+ for t in $(printf '%s' "$te" | grep -oE '[a-z_][a-z0-9_./-]*'); do seen="$seen$t "; done
288
+ case "$te" in *" "*) terms+=("( $te )") ;; *) terms+=("$te") ;; esac
289
+ fi
290
+ while IFS= read -r t; do
291
+ [ -n "$t" ] || continue
292
+ case "$seen" in *" $t "*) continue ;; esac
293
+ seen="$seen$t "
294
+ terms+=("$t")
295
+ done < <(tag_tokens "$tags")
296
+
297
+ if [ "${#terms[@]}" -eq 0 ]; then
298
+ [ "$enforcement" = "hard" ] || return 1
299
+ printf 'always\t%s' "$ON_START"
300
+ else
301
+ local when=""; local i
302
+ for i in "${!terms[@]}"; do
303
+ [ -z "$when" ] && when="${terms[$i]}" || when="$when || ${terms[$i]}"
304
+ done
305
+ printf '%s\t%s' "$when" "$ON_LIVE"
306
+ fi
307
+ }
308
+
309
+ policy_has_when() {
310
+ local line
311
+ while IFS= read -r line || [ -n "$line" ]; do
312
+ case "$line" in
313
+ when:*) return 0 ;;
314
+ esac
315
+ done < "$1"
316
+ return 1
317
+ }
318
+
319
+ total=0; migrated=0; skipped=0; untriggered=0; unparseable=0
320
+ declare -i n_session=0
321
+ for dir in "${DIRS[@]}"; do
322
+ [ -d "$dir" ] || continue
323
+ for f in "$dir"/*.md; do
324
+ [ -f "$f" ] || continue
325
+ file_name="${f##*/}"
326
+ case "$file_name" in example-policy.md|README.md) continue ;; esac
327
+ total=$((total+1))
328
+ # STRICTLY IDEMPOTENT: a policy that already declares a trigger is left as-is.
329
+ if policy_has_when "$f"; then skipped=$((skipped+1)); continue; fi
330
+
331
+ trig="$(fm trigger "$f")"
332
+ tags="$(fm tags "$f" | sed 's/^\[//; s/\]$//')"
333
+ enforcement="$(fm enforcement "$f")"
334
+ if ! built="$(build_when "$trig" "$tags" "$enforcement")"; then
335
+ untriggered=$((untriggered+1))
336
+ continue
337
+ fi
338
+ IFS=$'\t' read -r WHEN ON <<< "$built"
339
+ if ! when_parses "$WHEN"; then
340
+ unparseable=$((unparseable+1))
341
+ printf 'migrate-policy-triggers: refusing to write unparseable when: %s -> `%s`\n' \
342
+ "$file_name" "$WHEN" >&2
343
+ continue
344
+ fi
345
+ [ "$ON" = "$ON_START" ] && n_session=$((n_session+1))
346
+
347
+ if [ "$DRY" = "1" ]; then
348
+ printf '%-52s when: %-44s on: %s\n' "$file_name" "$WHEN" "$ON"
349
+ migrated=$((migrated+1)); continue
350
+ fi
351
+
352
+ # insert when:/on: after trigger: (or id: if no trigger line exists)
353
+ anchor='^trigger:'; grep -q '^trigger:' "$f" || anchor='^id:'
354
+ tmp="$(mktemp)" || exit 1
355
+ if ! awk -v w="$WHEN" -v o="$ON" -v anc="$anchor" '
356
+ { print }
357
+ !ins && $0 ~ anc { print "when: " w; print "on: " o; ins=1 }
358
+ ' "$f" > "$tmp"; then
359
+ rm -f "$tmp"
360
+ exit 1
361
+ fi
362
+ if ! mv "$tmp" "$f"; then
363
+ rm -f "$tmp"
364
+ exit 1
365
+ fi
366
+ migrated=$((migrated+1))
367
+ done
368
+ done
369
+
370
+ # Quiet in steady state: only report when something was actually backfilled.
371
+ [ "$migrated" -gt 0 ] && echo "migrate-policy-triggers: backfilled $migrated policy trigger(s) ($n_session hard -> SessionStart, $untriggered non-hard triggerless left unchanged, $unparseable unparseable derivations skipped, $skipped already had when)" >&2
372
+ write_success_stamp
373
+ exit 0
@@ -0,0 +1,262 @@
1
+ #!/usr/bin/env bash
2
+ # Register one local PRD on the Work Mesh Board and record threadId/channelId
3
+ # on the company board.json. Audit and backfill never run unless asked.
4
+ set -euo pipefail
5
+
6
+ ROOT="${HQ_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}"
7
+
8
+ die() {
9
+ echo "$*" >&2
10
+ exit 1
11
+ }
12
+
13
+ resolve_uid() {
14
+ local company="$1"
15
+ if [ -n "${HQ_COMPANY_UID:-}" ]; then
16
+ printf '%s\n' "$HQ_COMPANY_UID"
17
+ return
18
+ fi
19
+ local file="$ROOT/companies/$company/.company-uid"
20
+ if [ -f "$file" ]; then
21
+ tr -d '[:space:]' < "$file"
22
+ return
23
+ fi
24
+ die "no company uid for $company (set HQ_COMPANY_UID or companies/$company/.company-uid)"
25
+ }
26
+
27
+ has_board() {
28
+ local company="$1" project="$2" uid
29
+ uid="$(resolve_uid "$company")"
30
+ [ -f "${HOME}/.hq/work-mesh/cache/projects/${uid}/${project}.json" ]
31
+ }
32
+
33
+ story_not_done() {
34
+ jq -e '[.userStories[]? | select((.status // "") != "done")] | length > 0' "$1" >/dev/null
35
+ }
36
+
37
+ recent_dir() {
38
+ local path="$1" mtime now age
39
+ case "$(uname -s)" in
40
+ Darwin) mtime="$(stat -f %m "$path")" ;;
41
+ *) mtime="$(stat -c %Y "$path")" ;;
42
+ esac
43
+ now="$(date +%s)"
44
+ age=$((now - mtime))
45
+ if [ "$age" -lt $((30 * 86400)) ]; then
46
+ printf 'yes\n'
47
+ else
48
+ printf 'no\n'
49
+ fi
50
+ }
51
+
52
+ # hq mesh project ensure shipped in hq-cli 5.139.0. Probe the subcommand,
53
+ # not the version string: a laptop can be one release behind the supply-chain
54
+ # install guard for up to 24h.
55
+ MIN_HQ_CLI="5.139.0"
56
+
57
+ cli_has_project_ensure() {
58
+ hq mesh project ensure --help >/dev/null 2>&1
59
+ }
60
+
61
+ board_path() {
62
+ printf '%s\n' "$ROOT/companies/$1/board.json"
63
+ }
64
+
65
+ mark_pending() {
66
+ local company="$1" project="$2"
67
+ local board prd rel now title tmp
68
+ board="$(board_path "$company")"
69
+ prd="$ROOT/companies/$company/projects/$project/prd.json"
70
+ rel="companies/$company/projects/$project/prd.json"
71
+ now="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
72
+ title="$(jq -r '.name // empty' "$prd" 2>/dev/null || true)"
73
+ [ -n "$title" ] || title="$project"
74
+ mkdir -p "$(dirname "$board")"
75
+ if [ ! -f "$board" ]; then
76
+ jq -n --arg company "$company" '{company:$company, projects:[]}' > "$board"
77
+ fi
78
+ tmp="$(mktemp)"
79
+ jq \
80
+ --arg id "$project" \
81
+ --arg path "$rel" \
82
+ --arg now "$now" \
83
+ --arg title "$title" \
84
+ '
85
+ .projects = (.projects // []) |
86
+ if any(.projects[]; .id == $id or .prd_path == $path or .mesh_project_id == $id) then
87
+ .projects |= map(
88
+ if .id == $id or .prd_path == $path or .mesh_project_id == $id then
89
+ . + {pending_registration:true, updated_at:$now, prd_path:(.prd_path // $path)}
90
+ else . end
91
+ )
92
+ else
93
+ .projects += [{
94
+ id:$id,
95
+ title:$title,
96
+ status:"prd_created",
97
+ scope:"company",
98
+ prd_path:$path,
99
+ pending_registration:true,
100
+ created_at:$now,
101
+ updated_at:$now
102
+ }]
103
+ end
104
+ ' "$board" > "$tmp"
105
+ mv "$tmp" "$board"
106
+ }
107
+
108
+ register_one() {
109
+ local company="$1" project="$2"
110
+ local prd="$ROOT/companies/$company/projects/$project/prd.json"
111
+ [ -f "$prd" ] || die "registration incomplete: missing $prd"
112
+ if ! cli_has_project_ensure; then
113
+ mark_pending "$company" "$project"
114
+ die "hq-cli ${MIN_HQ_CLI} or newer is required; ${company}/${project} stays local until then"
115
+ fi
116
+ local ensure_json
117
+ if ! ensure_json="$(hq mesh project ensure "$project" --company "$company" --stories-file "$prd" --json)"; then
118
+ echo "registration incomplete: hq mesh project ensure failed for $company/$project" >&2
119
+ exit 1
120
+ fi
121
+ local thread channel
122
+ thread="$(printf '%s' "$ensure_json" | jq -er '.threadId // empty')" || die "registration incomplete: ensure output missing threadId"
123
+ channel="$(printf '%s' "$ensure_json" | jq -er '.channelId // empty')" || die "registration incomplete: ensure output missing channelId"
124
+ [ -n "$thread" ] || die "registration incomplete: empty threadId"
125
+ [ -n "$channel" ] || die "registration incomplete: empty channelId"
126
+
127
+ local board="$ROOT/companies/$company/board.json"
128
+ local rel="companies/$company/projects/$project/prd.json"
129
+ local now title tmp
130
+ now="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
131
+ title="$(jq -r '.name // empty' "$prd")"
132
+ [ -n "$title" ] || title="$project"
133
+ mkdir -p "$(dirname "$board")"
134
+ if [ ! -f "$board" ]; then
135
+ jq -n --arg company "$company" '{company:$company, projects:[]}' > "$board"
136
+ fi
137
+ tmp="$(mktemp)"
138
+ jq \
139
+ --arg id "$project" \
140
+ --arg path "$rel" \
141
+ --arg thread "$thread" \
142
+ --arg channel "$channel" \
143
+ --arg now "$now" \
144
+ --arg title "$title" \
145
+ '
146
+ .projects = (.projects // []) |
147
+ if any(.projects[]; .id == $id or .prd_path == $path or .mesh_project_id == $id) then
148
+ .projects |= map(
149
+ if .id == $id or .prd_path == $path or .mesh_project_id == $id then
150
+ . + {threadId:$thread, channelId:$channel, updated_at:$now, prd_path:(.prd_path // $path), mesh_project_id:$id} | del(.pending_registration)
151
+ else . end
152
+ )
153
+ else
154
+ .projects += [{
155
+ id:$id,
156
+ title:$title,
157
+ status:"prd_created",
158
+ scope:"company",
159
+ prd_path:$path,
160
+ threadId:$thread,
161
+ channelId:$channel,
162
+ mesh_project_id:$id,
163
+ created_at:$now,
164
+ updated_at:$now
165
+ }]
166
+ end
167
+ ' "$board" > "$tmp"
168
+ mv "$tmp" "$board"
169
+ jq -e \
170
+ --arg id "$project" \
171
+ --arg path "$rel" \
172
+ --arg thread "$thread" \
173
+ --arg channel "$channel" \
174
+ 'any(.projects[]?; (.id == $id or .prd_path == $path or .mesh_project_id == $id) and .threadId == $thread and .channelId == $channel)' \
175
+ "$board" >/dev/null \
176
+ || die "registration incomplete: board.json entry for $project did not verify"
177
+ echo "registered $company/$project thread=$thread channel=$channel"
178
+ }
179
+
180
+ audit() {
181
+ local company="$1"
182
+ [ -n "$company" ] || die "usage: register-project.sh --audit <company>"
183
+ local dir="$ROOT/companies/$company/projects"
184
+ [ -d "$dir" ] || return 0
185
+ local prd name
186
+ for prd in "$dir"/*/prd.json; do
187
+ [ -f "$prd" ] || continue
188
+ name="$(basename "$(dirname "$prd")")"
189
+ if ! has_board "$company" "$name"; then
190
+ printf '%s\n' "$name"
191
+ fi
192
+ done | sort
193
+ }
194
+
195
+ backfill() {
196
+ local company="" only=0 yes=0
197
+ while [ $# -gt 0 ]; do
198
+ case "$1" in
199
+ --only-active) only=1 ;;
200
+ --yes) yes=1 ;;
201
+ --*) die "unknown flag $1" ;;
202
+ *) company="$1" ;;
203
+ esac
204
+ shift
205
+ done
206
+ [ -n "$company" ] || die "usage: register-project.sh --backfill <company> --only-active [--yes]"
207
+ [ "$only" -eq 1 ] || die "--backfill requires --only-active"
208
+ local dir="$ROOT/companies/$company/projects"
209
+ [ -d "$dir" ] || return 0
210
+ local prd name recent
211
+ local -a targets=()
212
+ for prd in "$dir"/*/prd.json; do
213
+ [ -f "$prd" ] || continue
214
+ name="$(basename "$(dirname "$prd")")"
215
+ story_not_done "$prd" || continue
216
+ recent="$(recent_dir "$(dirname "$prd")")"
217
+ [ "$recent" = "yes" ] || continue
218
+ targets+=("$name")
219
+ done
220
+ IFS=$'\n' targets=($(printf '%s\n' "${targets[@]+"${targets[@]}"}" | sed '/^$/d' | sort))
221
+ unset IFS
222
+ if [ "$yes" -ne 1 ]; then
223
+ local item
224
+ for item in "${targets[@]+"${targets[@]}"}"; do
225
+ echo "would register $company/$item"
226
+ done
227
+ return 0
228
+ fi
229
+ local item
230
+ for item in "${targets[@]+"${targets[@]}"}"; do
231
+ register_one "$company" "$item"
232
+ done
233
+ }
234
+
235
+ retry_pending() {
236
+ local company="${1:-}"
237
+ [ -n "$company" ] || die "usage: register-project.sh --retry-pending <company>"
238
+ cli_has_project_ensure || return 0
239
+ local board
240
+ board="$(board_path "$company")"
241
+ [ -f "$board" ] || return 0
242
+ local id count=0
243
+ while IFS= read -r id; do
244
+ [ -n "$id" ] || continue
245
+ count=$((count + 1))
246
+ [ "$count" -le 3 ] || break
247
+ ( register_one "$company" "$id" ) || true
248
+ done < <(jq -r '[.projects[]? | select(.pending_registration == true) | .id // empty] | .[]' "$board" 2>/dev/null || true)
249
+ }
250
+
251
+ if [ "${1:-}" = "--audit" ]; then
252
+ audit "${2:-}"
253
+ elif [ "${1:-}" = "--backfill" ]; then
254
+ shift
255
+ backfill "$@"
256
+ elif [ "${1:-}" = "--retry-pending" ]; then
257
+ retry_pending "${2:-}"
258
+ elif [ $# -eq 2 ]; then
259
+ register_one "$1" "$2"
260
+ else
261
+ die "usage: register-project.sh <company> <project> | --audit <company> | --backfill <company> --only-active [--yes] | --retry-pending <company>"
262
+ fi