@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.
- package/CHANGELOG.md +9 -2
- package/assets/scaffold/core/scripts/lib/work-mesh-live-hook.sh +378 -0
- package/assets/scaffold/core/scripts/lib/work-mesh-live-rebind.sh +141 -0
- package/assets/scaffold/core/scripts/migrate-policy-triggers.sh +373 -0
- package/assets/scaffold/core/scripts/register-project.sh +262 -0
- package/assets/scaffold/core/scripts/work-mesh-live-rebind.sh +59 -0
- package/dist/command-catalog.generated.d.ts +80 -0
- package/dist/command-catalog.generated.js +99 -0
- package/dist/commands/cloud.js +0 -1
- package/dist/commands/company-rename-flag.d.ts +4 -0
- package/dist/commands/company-rename-flag.js +7 -0
- package/dist/commands/company-rename.d.ts +4 -0
- package/dist/commands/company-rename.js +90 -0
- package/dist/commands/company.js +2 -0
- package/dist/commands/core-eval-trigger-native.d.ts +4 -0
- package/dist/commands/core-eval-trigger-native.js +12 -0
- package/dist/commands/core-knowledge-prefs.d.ts +0 -1
- package/dist/commands/core-knowledge-prefs.js +1 -1
- package/dist/commands/core-migrate-policy-triggers-native.d.ts +21 -0
- package/dist/commands/core-migrate-policy-triggers-native.js +378 -0
- package/dist/commands/core-native-hook-helper-commands.d.ts +4 -0
- package/dist/commands/core-native-hook-helper-commands.js +56 -0
- package/dist/commands/core-native-hook-helpers-flag.d.ts +4 -0
- package/dist/commands/core-native-hook-helpers-flag.js +7 -0
- package/dist/commands/core-register-project-native.d.ts +30 -0
- package/dist/commands/core-register-project-native.js +347 -0
- package/dist/commands/core-work-mesh-live-rebind-native.d.ts +18 -0
- package/dist/commands/core-work-mesh-live-rebind-native.js +262 -0
- package/dist/commands/core-worker-share-native.d.ts +0 -1
- package/dist/commands/core-worker-share-native.js +1 -1
- package/dist/commands/core.js +7 -0
- package/dist/commands/lanes-message.js +3 -0
- package/dist/commands/lanes-questions.js +2 -1
- package/dist/commands/lanes-resume.js +3 -0
- package/dist/commands/lanes.js +151 -21
- package/dist/fast-core.js +10 -3
- package/dist/fast-scaffold.d.ts +2 -0
- package/dist/fast-scaffold.js +11 -0
- package/dist/lib/doctor/checks/work-context.js +9 -1
- package/dist/lib/lanes/config.d.ts +3 -0
- package/dist/lib/lanes/config.js +14 -0
- package/dist/lib/lanes/disk-guard.d.ts +66 -0
- package/dist/lib/lanes/disk-guard.js +172 -0
- package/dist/lib/lanes/flag-keys.d.ts +2 -0
- package/dist/lib/lanes/flag-keys.js +2 -0
- package/dist/lib/lanes/grants.d.ts +4 -3
- package/dist/lib/lanes/grants.js +12 -7
- package/dist/lib/lanes/hire-grant-dispatch.js +2 -3
- package/dist/lib/lanes/index.d.ts +1 -1
- package/dist/lib/lanes/index.js +1 -1
- package/dist/lib/lanes/reconcile.d.ts +7 -1
- package/dist/lib/lanes/reconcile.js +142 -11
- package/dist/lib/lanes/registry-flag.d.ts +6 -0
- package/dist/lib/lanes/registry-flag.js +25 -0
- package/dist/lib/lanes/spawn.d.ts +5 -0
- package/dist/lib/lanes/spawn.js +26 -1
- package/dist/lib/lanes/store.js +11 -0
- package/dist/lib/lanes/types.d.ts +11 -1
- package/dist/lib/lanes/types.js +1 -0
- package/dist/lib/mesh/client.js +23 -5
- package/dist/lib/mesh/denial-codes.d.ts +5 -0
- package/dist/lib/mesh/denial-codes.js +14 -0
- package/dist/lib/mesh/live/daemon/doctor.d.ts +2 -0
- package/dist/lib/mesh/live/daemon/doctor.js +11 -1
- package/dist/lib/search-index/embed-lock.d.ts +2 -0
- package/dist/lib/search-index/embed-lock.js +10 -1
- package/dist/lib/work-context/contract.d.ts +3 -0
- package/dist/lib/work-context/contract.schema.json +3 -1
- package/dist/lib/work-context/outbox.d.ts +14 -5
- package/dist/lib/work-context/outbox.js +48 -5
- package/dist/lib/work-context/reconcile.js +26 -5
- package/dist/main.d.ts +20 -0
- package/dist/main.js +8 -1
- package/dist/utils/core-eval-trigger.d.ts +4 -0
- package/dist/utils/core-eval-trigger.js +88 -0
- package/dist/utils/pack-contributions.d.ts +0 -5
- package/dist/utils/pack-contributions.js +1 -1
- package/dist/utils/redact-error-text.d.ts +0 -2
- package/dist/utils/redact-error-text.js +1 -1
- package/dist/utils/sandbox-runner-client.d.ts +2 -1
- 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
|