@indigoai-us/hq-cli 5.345.109 → 5.345.110
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 +4 -0
- package/dist/commands/core-policy.d.ts +4 -1
- package/dist/commands/core-policy.js +21 -43
- package/dist/commands/core.js +2 -7
- package/dist/fast-scaffold.js +0 -12
- package/dist/hq-cloud-sync-runner/bin/sync-runner.js +1414 -923
- package/dist/hq-cloud-sync-runner/package.json +1 -1
- package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
- package/dist/lib/flag-registry-worker-source.generated.js +1 -1
- package/package.json +2 -2
- package/assets/scaffold/core/scripts/policy-age-report.sh +0 -199
- package/assets/scaffold/core/scripts/policy-retire.sh +0 -199
- package/assets/scaffold/core/scripts/read-policy-frontmatter.sh +0 -39
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@indigoai-us/hq-cli",
|
|
3
|
-
"version": "5.345.
|
|
3
|
+
"version": "5.345.110",
|
|
4
4
|
"description": "HQ by Indigo management CLI — modules and cloud sync",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"@aws-sdk/client-iot-data-plane": "^3.1096.0",
|
|
49
49
|
"@aws-sdk/client-s3": "^3.1049.0",
|
|
50
50
|
"@aws-sdk/dsql-signer": "^3.1146.0",
|
|
51
|
-
"@indigoai-us/hq-cloud": "~6.18.
|
|
51
|
+
"@indigoai-us/hq-cloud": "~6.18.69",
|
|
52
52
|
"@indigoai-us/hq-flags-client": "^0.1.2",
|
|
53
53
|
"@modelcontextprotocol/sdk": "1.29.0",
|
|
54
54
|
"@sentry/node": "^10.49.0",
|
|
@@ -1,199 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
# hq-core: public
|
|
3
|
-
# policy-age-report.sh — measure policy usage and flag retirement candidates
|
|
4
|
-
# (2026-09-07).
|
|
5
|
-
#
|
|
6
|
-
# Why: nothing retired a policy. About 85% of one install's 1,251 personal
|
|
7
|
-
# policies were machine-written, and the only "usage" signal was that the
|
|
8
|
-
# loader printed them — which, above the host's output ceiling, meant nothing.
|
|
9
|
-
# This report gives the fade-out its inputs. It does NOT delete anything; it
|
|
10
|
-
# names candidates with the evidence, for a human (or /learn --retire) to act on.
|
|
11
|
-
#
|
|
12
|
-
# Signals per policy (from workspace/orchestrator/policy-trigger-state/*.txt,
|
|
13
|
-
# one ledger per session, and the policy files themselves):
|
|
14
|
-
# fired number of sessions whose ledger recorded the slug (emitted)
|
|
15
|
-
# retrieved number of sessions that pulled the full text (record-policy-retrieval.sh)
|
|
16
|
-
# last_fired newest ledger mtime that contains the slug (UTC date)
|
|
17
|
-
# age_days days since frontmatter `created:` (or file mtime)
|
|
18
|
-
# stale_refs backtick-quoted HQ-relative paths in the Rule text that no
|
|
19
|
-
# longer exist on disk (an inaccuracy proxy). Only core/,
|
|
20
|
-
# .claude/, companies/<co>/ paths are judged; lines that talk
|
|
21
|
-
# about a path being deleted/removed/legacy are not dependencies;
|
|
22
|
-
# a same-stem sibling (foo.sh -> foo.mjs) counts as renamed.
|
|
23
|
-
# single_incident frontmatter source is a one-off (user-correction,
|
|
24
|
-
# session-learning, back-pressure-failure) AND version == 1
|
|
25
|
-
#
|
|
26
|
-
# Candidate classes (--candidates):
|
|
27
|
-
# never-fired fired == 0 and age_days >= --min-age (default 30)
|
|
28
|
-
# dormant fired > 0 but last_fired older than --dormant-days (default 90)
|
|
29
|
-
# stale-refs stale_refs > 0 (rule points at something that is gone)
|
|
30
|
-
# single-incident-old single_incident, age_days >= --incident-days (default 60), and fired <= 1
|
|
31
|
-
#
|
|
32
|
-
# Usage:
|
|
33
|
-
# policy-age-report.sh [--dir <policies-dir>]... [--json] [--candidates] [--min-age N] [--dormant-days N] [--incident-days N]
|
|
34
|
-
# Defaults: personal/policies and core/policies (add --dir companies/<co>/policies when bound).
|
|
35
|
-
set -uo pipefail
|
|
36
|
-
HQ_ROOT="${HQ_ROOT:-${CLAUDE_PROJECT_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}}"
|
|
37
|
-
LEDGERS="${HQ_POLICY_LEDGER_DIR:-$HQ_ROOT/workspace/orchestrator/policy-trigger-state}"
|
|
38
|
-
DIRS=(); JSON=0; CAND=0; MIN_AGE=30; DORMANT=90; INCIDENT=60
|
|
39
|
-
while [ $# -gt 0 ]; do
|
|
40
|
-
case "$1" in
|
|
41
|
-
--dir) DIRS+=("$2"); shift 2 ;;
|
|
42
|
-
--json) JSON=1; shift ;;
|
|
43
|
-
--candidates) CAND=1; shift ;;
|
|
44
|
-
--min-age) MIN_AGE="$2"; shift 2 ;;
|
|
45
|
-
--dormant-days) DORMANT="$2"; shift 2 ;;
|
|
46
|
-
--incident-days) INCIDENT="$2"; shift 2 ;;
|
|
47
|
-
-h|--help) sed -n 2,32p "$0"; exit 0 ;;
|
|
48
|
-
*) echo "policy-age-report: unknown arg $1" >&2; exit 2 ;;
|
|
49
|
-
esac
|
|
50
|
-
done
|
|
51
|
-
[ "${#DIRS[@]}" -gt 0 ] || DIRS=("$HQ_ROOT/personal/policies" "$HQ_ROOT/core/policies")
|
|
52
|
-
command -v jq >/dev/null 2>&1 || { echo "policy-age-report: jq required" >&2; exit 2; }
|
|
53
|
-
NOW_EPOCH="${HQ_POLICY_REPORT_NOW_EPOCH:-$(date +%s)}"
|
|
54
|
-
if [ -n "${HQ_POLICY_REPORT_NOW_EPOCH:-}" ]; then
|
|
55
|
-
CLOCK_INPUT="$HQ_POLICY_REPORT_NOW_EPOCH"
|
|
56
|
-
if [[ ! "$CLOCK_INPUT" =~ ^[+-]?[0-9]+$ ]]; then
|
|
57
|
-
printf 'policy-age-report: HQ_POLICY_REPORT_NOW_EPOCH must be an integer: %s\n' "$CLOCK_INPUT" >&2
|
|
58
|
-
exit 1
|
|
59
|
-
fi
|
|
60
|
-
CLOCK_NEGATIVE=0
|
|
61
|
-
CLOCK_DIGITS="$CLOCK_INPUT"
|
|
62
|
-
case "$CLOCK_DIGITS" in
|
|
63
|
-
-*) CLOCK_NEGATIVE=1; CLOCK_DIGITS="${CLOCK_DIGITS#-}" ;;
|
|
64
|
-
+*) CLOCK_DIGITS="${CLOCK_DIGITS#+}" ;;
|
|
65
|
-
esac
|
|
66
|
-
while [[ "${#CLOCK_DIGITS}" -gt 1 && "${CLOCK_DIGITS:0:1}" = 0 ]]; do
|
|
67
|
-
CLOCK_DIGITS="${CLOCK_DIGITS:1}"
|
|
68
|
-
done
|
|
69
|
-
CLOCK_LIMIT=9223372036854775807
|
|
70
|
-
[ "$CLOCK_NEGATIVE" = 1 ] && CLOCK_LIMIT=9223372036854775808
|
|
71
|
-
if [[ "${#CLOCK_DIGITS}" -gt "${#CLOCK_LIMIT}" ]] || { [[ "${#CLOCK_DIGITS}" -eq "${#CLOCK_LIMIT}" ]] && (LC_ALL=C; [[ "$CLOCK_DIGITS" > "$CLOCK_LIMIT" ]]); }; then
|
|
72
|
-
printf 'policy-age-report: HQ_POLICY_REPORT_NOW_EPOCH must be an integer: %s\n' "$CLOCK_INPUT" >&2
|
|
73
|
-
exit 1
|
|
74
|
-
fi
|
|
75
|
-
if [ "$CLOCK_NEGATIVE" = 1 ]; then
|
|
76
|
-
NOW_EPOCH=$((0 - 10#$CLOCK_DIGITS))
|
|
77
|
-
else
|
|
78
|
-
NOW_EPOCH=$((10#$CLOCK_DIGITS))
|
|
79
|
-
fi
|
|
80
|
-
fi
|
|
81
|
-
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
|
|
82
|
-
|
|
83
|
-
# One pass over every ledger: slug<TAB>mtime, then reduce to count + max mtime.
|
|
84
|
-
HAVE_LEDGERS=0
|
|
85
|
-
if [ -d "$LEDGERS" ] && [ -n "$(find "$LEDGERS" -name '*.txt' -type f 2>/dev/null | head -1)" ]; then
|
|
86
|
-
HAVE_LEDGERS=1
|
|
87
|
-
find "$LEDGERS" -name '*.txt' -type f 2>/dev/null | while IFS= read -r f; do
|
|
88
|
-
m="$(stat -c %Y "$f" 2>/dev/null || stat -f %m "$f")"
|
|
89
|
-
awk -v m="$m" 'NF { print $1 "\t" m }' "$f"
|
|
90
|
-
done | awk -F'\t' '{ c[$1]++; if ($2 > mx[$1]) mx[$1] = $2 } END { for (s in c) print s "\t" c[s] "\t" mx[s] }' > "$TMP/usage.tsv"
|
|
91
|
-
else
|
|
92
|
-
: > "$TMP/usage.tsv"
|
|
93
|
-
fi
|
|
94
|
-
# Retrieval ledger (record-policy-retrieval.sh): sessions that PULLED the full text.
|
|
95
|
-
RETRIEVALS="${HQ_POLICY_RETRIEVAL_LEDGER_DIR:-$HQ_ROOT/workspace/orchestrator/policy-retrieval-state}"
|
|
96
|
-
if [ -d "$RETRIEVALS" ]; then
|
|
97
|
-
cat "$RETRIEVALS"/*.txt 2>/dev/null | awk 'NF { c[$1]++ } END { for (s in c) print s "\t" c[s] }' > "$TMP/retrieved.tsv"
|
|
98
|
-
else
|
|
99
|
-
: > "$TMP/retrieved.tsv"
|
|
100
|
-
fi
|
|
101
|
-
|
|
102
|
-
to_epoch() { # YYYY-MM-DD -> epoch (0 on failure)
|
|
103
|
-
local epoch normalized
|
|
104
|
-
epoch="$(date -u -d "$1 00:00:00" +%s 2>/dev/null || date -u -j -f '%Y-%m-%d %H:%M:%S' "$1 00:00:00" +%s 2>/dev/null)" || { echo 0; return; }
|
|
105
|
-
[ -n "$epoch" ] || { echo 0; return; }
|
|
106
|
-
normalized="$(date -u -d "@$epoch" +%Y-%m-%d 2>/dev/null || date -u -r "$epoch" +%Y-%m-%d 2>/dev/null)" || { echo 0; return; }
|
|
107
|
-
[ "$normalized" = "$1" ] || { echo 0; return; }
|
|
108
|
-
echo "$epoch"
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
for dir in "${DIRS[@]}"; do
|
|
112
|
-
case "$dir" in /*) ;; *) dir="$HQ_ROOT/$dir" ;; esac
|
|
113
|
-
[ -d "$dir" ] || continue
|
|
114
|
-
for f in "$dir"/*.md; do
|
|
115
|
-
[ -f "$f" ] || continue
|
|
116
|
-
b="$(basename "$f")"
|
|
117
|
-
case "$b" in README.md|example-policy.md|_digest*.md|*" "*|*.conflict-*|*.sync-conflict-*) continue ;; esac
|
|
118
|
-
[ -L "$f" ] && continue # personal overlay mirrors live in core/ as symlinks; count the source once
|
|
119
|
-
grep -q '^status: retired' "$f" && continue # already retired (policy-retire.sh)
|
|
120
|
-
id="$(awk 'NR==1&&!/^---/{exit} /^---/{d++; if(d==2)exit; next} d==1&&/^id:/{sub(/^id:[ \t]*/,""); gsub(/^["'"'"']|["'"'"']$/,""); print; exit}' "$f")"
|
|
121
|
-
[ -n "$id" ] || id="${b%.md}"
|
|
122
|
-
enf="$(awk '/^---/{d++; if(d==2)exit; next} d==1&&/^enforcement:/{sub(/^enforcement:[ \t]*/,""); print; exit}' "$f")"
|
|
123
|
-
src="$(awk '/^---/{d++; if(d==2)exit; next} d==1&&/^source:/{sub(/^source:[ \t]*/,""); gsub(/"/,""); print; exit}' "$f")"
|
|
124
|
-
ver="$(awk '/^---/{d++; if(d==2)exit; next} d==1&&/^version:/{sub(/^version:[ \t]*/,""); print; exit}' "$f")"
|
|
125
|
-
created="$(awk '/^---/{d++; if(d==2)exit; next} d==1&&/^created:/{sub(/^created:[ \t]*/,""); gsub(/"/,""); print substr($0,1,10); exit}' "$f")"
|
|
126
|
-
if [ -n "$created" ]; then ce="$(to_epoch "$created")"; else ce=0; fi
|
|
127
|
-
[ "$ce" -gt 0 ] || ce="$(stat -c %Y "$f" 2>/dev/null || stat -f %m "$f")"
|
|
128
|
-
age_days=$(( (NOW_EPOCH - ce) / 86400 ))
|
|
129
|
-
fired=0; last=""
|
|
130
|
-
row="$(awk -F'\t' -v s="$id" '$1==s{print; exit}' "$TMP/usage.tsv")"
|
|
131
|
-
if [ -n "$row" ]; then fired="$(cut -f2 <<<"$row")"; last_e="$(cut -f3 <<<"$row")"; last="$(date -u -d "@$last_e" +%Y-%m-%d 2>/dev/null || date -u -r "$last_e" +%Y-%m-%d)"; last_age=$(( (NOW_EPOCH - last_e) / 86400 )); else last_age=-1; fi
|
|
132
|
-
retrieved="$(awk -F'\t' -v s="$id" '$1==s{print $2; exit}' "$TMP/retrieved.tsv")"; retrieved="${retrieved:-0}"
|
|
133
|
-
# stale refs: backtick paths in the Rule section that look HQ-relative and are missing
|
|
134
|
-
stale=""; renamed=""
|
|
135
|
-
while IFS= read -r ref; do
|
|
136
|
-
[ -n "$ref" ] || continue
|
|
137
|
-
# Only release-owned or company-owned paths can make a rule inaccurate:
|
|
138
|
-
# core/, .claude/, companies/<slug>/ (when that company exists here).
|
|
139
|
-
# personal/, workspace/, repos/ are user- or environment-specific and are
|
|
140
|
-
# never judged. A ref whose top-level dir is absent is not judged either.
|
|
141
|
-
case "$ref" in
|
|
142
|
-
.claude/state/*|.claude/worktrees/*|core/packages/*) continue ;; # runtime / install-specific
|
|
143
|
-
core/*|.claude/*) ;;
|
|
144
|
-
companies/*) co_ref="${ref#companies/}"; co_ref="${co_ref%%/*}"; [ -d "$HQ_ROOT/companies/$co_ref" ] || continue ;;
|
|
145
|
-
*) continue ;;
|
|
146
|
-
esac
|
|
147
|
-
# Placeholder examples in prose (`core/scripts/foo.sh`, `.claude/hooks/example.sh`) are not dependencies.
|
|
148
|
-
case "$(basename "$ref")" in foo*|bar*|baz*|example*|placeholder*|sample*|your-*|my-*|x.sh|dummy*) continue ;; esac
|
|
149
|
-
if [ ! -e "$HQ_ROOT/$ref" ]; then
|
|
150
|
-
# A sibling with the same stem (renamed extension: foo.sh -> foo.mjs) means
|
|
151
|
-
# the subject still exists and the rule needs a fix, not retirement.
|
|
152
|
-
ref_for_stem="${ref%/}"
|
|
153
|
-
ref_basename="${ref_for_stem##*/}"
|
|
154
|
-
case "$ref_basename" in
|
|
155
|
-
.* )
|
|
156
|
-
case "${ref_basename#.}" in
|
|
157
|
-
*.*) stem="${ref_for_stem%.*}" ;;
|
|
158
|
-
*) stem="$ref_for_stem" ;;
|
|
159
|
-
esac
|
|
160
|
-
;;
|
|
161
|
-
*.*) stem="${ref_for_stem%.*}" ;;
|
|
162
|
-
*) stem="$ref_for_stem" ;;
|
|
163
|
-
esac
|
|
164
|
-
if ls "$HQ_ROOT/$stem".* >/dev/null 2>&1; then renamed="${renamed:+$renamed,}$ref"; else stale="${stale:+$stale,}$ref"; fi
|
|
165
|
-
fi
|
|
166
|
-
done < <(awk '/^## Rule/{r=1;next} r&&/^## /{r=0} r' "$f" \
|
|
167
|
-
| grep -viE '(deleted|removed|retired|deprecated|no longer|not exist|never (call|use|run|read)|do not (call|use|run|read)|must not|instead of|was renamed|old path|legacy)' \
|
|
168
|
-
| grep -oE '`(core|personal|companies|repos|workspace|\.claude)/[A-Za-z0-9_./-]+`' | tr -d '`' | grep -vE '\{|\}|\*' | sort -u)
|
|
169
|
-
stale_n=0; [ -n "$stale" ] && stale_n="$(tr ',' '\n' <<<"$stale" | wc -l | tr -d ' ')"
|
|
170
|
-
judged_n="$(awk '/^## Rule/{r=1;next} r&&/^## /{r=0} r' "$f" | grep -viE '(deleted|removed|retired|deprecated|no longer|not exist|never (call|use|run|read)|do not (call|use|run|read)|must not|instead of|was renamed|old path|legacy)' | grep -oE '`(core|\.claude|companies)/[A-Za-z0-9_./-]+`' | tr -d '`' | grep -vE '\{|\}|\*|/(foo|bar|baz|example|placeholder|sample|your-|my-|dummy)|^\.claude/state/|^\.claude/worktrees/|^core/packages/' | sort -u | wc -l | tr -d ' ')"
|
|
171
|
-
single=0; case "$src" in user-correction|session-learning|back-pressure-failure) [ "${ver:-1}" = "1" ] && single=1 ;; esac
|
|
172
|
-
classes=""
|
|
173
|
-
if [ "$fired" = 0 ] && [ "$age_days" -ge "$MIN_AGE" ] && [ "$HAVE_LEDGERS" = 1 ]; then classes="never-fired"; fi
|
|
174
|
-
if [ "$fired" -gt 0 ] && [ "$last_age" -ge "$DORMANT" ]; then classes="${classes:+$classes,}dormant"; fi
|
|
175
|
-
if [ "$stale_n" -gt 0 ]; then classes="${classes:+$classes,}stale-refs"; fi
|
|
176
|
-
if [ -n "$renamed" ]; then classes="${classes:+$classes,}renamed-refs"; fi
|
|
177
|
-
# A one-off rule that keeps firing has earned its place; only flag it when it never (or once) fired.
|
|
178
|
-
if [ "$single" = 1 ] && [ "$age_days" -ge "$INCIDENT" ] && [ "$fired" -le 1 ]; then classes="${classes:+$classes,}single-incident-old"; fi
|
|
179
|
-
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$id" "${f#"$HQ_ROOT"/}" "${enf:-unset}" "$age_days" "$fired" "${last:--}" "$stale_n" "$stale" "$single" "$classes" "$retrieved" "$judged_n" "$renamed"
|
|
180
|
-
done
|
|
181
|
-
done > "$TMP/rows.tsv"
|
|
182
|
-
|
|
183
|
-
if [ "$JSON" = 1 ]; then
|
|
184
|
-
jq -R -s -c --argjson cand "$CAND" '
|
|
185
|
-
split("\n") | map(select(length>0) | split("\t") | {id:.[0], path:.[1], enforcement:.[2], age_days:(.[3]|tonumber), fired:(.[4]|tonumber), last_fired:(if .[5]=="-" then null else .[5] end), stale_refs:(if .[7]=="" then [] else (.[7]|split(",")) end), single_incident:(.[8]=="1"), candidate_classes:(if .[9]=="" then [] else (.[9]|split(",")) end), retrieved:((.[10] // "0")|tonumber), refs_judged:((.[11] // "0")|tonumber), renamed_refs:(if (.[12] // "")=="" then [] else (.[12]|split(",")) end)})
|
|
186
|
-
| if $cand == 1 then map(select(.candidate_classes|length>0)) else . end' "$TMP/rows.tsv" | tr -d '\r'
|
|
187
|
-
exit 0
|
|
188
|
-
fi
|
|
189
|
-
total="$(wc -l < "$TMP/rows.tsv" | tr -d ' ')"
|
|
190
|
-
never="$(awk -F'\t' '$5==0' "$TMP/rows.tsv" | wc -l | tr -d ' ')"
|
|
191
|
-
cands="$(awk -F'\t' '$10!=""' "$TMP/rows.tsv" | wc -l | tr -d ' ')"
|
|
192
|
-
stale_total="$(awk -F'\t' '$7>0' "$TMP/rows.tsv" | wc -l | tr -d ' ')"
|
|
193
|
-
echo "policy-age-report: $total policies, $never never fired, $stale_total with stale references, $cands retirement candidates (min-age ${MIN_AGE}d, dormant ${DORMANT}d, incident ${INCIDENT}d)"
|
|
194
|
-
if [ "$CAND" = 1 ]; then
|
|
195
|
-
awk -F'\t' '$10!="" { printf " %-14s %-60s fired=%-4s last=%-10s age=%-4sd %s%s\n", $10, $1, $5, $6, $4, ($7>0 ? "stale:" $8 : ""), "" }' "$TMP/rows.tsv" | sort
|
|
196
|
-
else
|
|
197
|
-
echo " (add --candidates to list them, --json for machine-readable rows)"
|
|
198
|
-
fi
|
|
199
|
-
exit 0
|
|
@@ -1,199 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
# hq-core: public
|
|
3
|
-
# policy-retire.sh — retire (or restore) a policy without deleting it (2026-09-07).
|
|
4
|
-
#
|
|
5
|
-
# Why: nothing retired policies, so the corpus only grew. Retirement is a
|
|
6
|
-
# frontmatter state, not a deletion: the file stays where it is (history,
|
|
7
|
-
# grep-ability, restore), and every consumer skips `status: retired` — the
|
|
8
|
-
# trigger loader, the company-bind digest, the age report.
|
|
9
|
-
#
|
|
10
|
-
# Usage:
|
|
11
|
-
# policy-retire.sh <slug-or-path> --reason "<why>" [--by <who>]
|
|
12
|
-
# policy-retire.sh <slug-or-path> --restore
|
|
13
|
-
# policy-retire.sh --from-report <age-report.json> --class <never-fired|dormant|stale-refs|single-incident-old> --reason "<why>" [--yes]
|
|
14
|
-
# policy-retire.sh --auto [--dir <policies-dir>]... [--classes a,b] [--report-dir <dir>] [--dry-run]
|
|
15
|
-
#
|
|
16
|
-
# --auto is the human-ON-the-loop mode (2026-09-07): it runs the age report,
|
|
17
|
-
# retires every candidate with a per-policy reason, and writes a dated report
|
|
18
|
-
# (default workspace/reports/policy-retirement-<date>.md) so a wrong call can
|
|
19
|
-
# be found and reversed with --restore. Nobody approves in advance. Classes and
|
|
20
|
-
# reasons:
|
|
21
|
-
# never-fired never emitted in any session ledger, older than 30 days
|
|
22
|
-
# dormant last emitted more than 90 days ago
|
|
23
|
-
# stale-refs its Rule text points at HQ paths that no longer exist
|
|
24
|
-
# single-incident-old a one-off correction, older than 60 days, fired at most once
|
|
25
|
-
# Defaults to personal/policies plus the bound company's policies; core/ is
|
|
26
|
-
# release-owned and is retired only in the hq-core-staging tree.
|
|
27
|
-
#
|
|
28
|
-
# <slug-or-path>: a policy file path, or a slug looked up under personal/policies,
|
|
29
|
-
# core/policies, and companies/*/policies (first match). Symlinked overlay
|
|
30
|
-
# mirrors are resolved to their source file.
|
|
31
|
-
# --from-report: batch mode over `policy-age-report.sh --json --candidates`
|
|
32
|
-
# output; lists what would change and requires --yes to write.
|
|
33
|
-
set -uo pipefail
|
|
34
|
-
HQ_ROOT="${HQ_ROOT:-${CLAUDE_PROJECT_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}}"
|
|
35
|
-
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
36
|
-
VALIDATOR_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
|
37
|
-
POLICY_VALIDATOR="$VALIDATOR_ROOT/.claude/hooks/validate-policy-frontmatter.sh"
|
|
38
|
-
TARGET=""; REASON=""; BY="${HQ_SESSION_ID:-${USER:-unknown}}"; RESTORE=0; REPORT=""; CLASS=""; YES=0
|
|
39
|
-
AUTO=0; DRY=0; AUTO_DIRS=(); REPORT_DIR=""; CLASSES=""
|
|
40
|
-
while [ $# -gt 0 ]; do
|
|
41
|
-
case "$1" in
|
|
42
|
-
--auto) AUTO=1; shift ;;
|
|
43
|
-
--dry-run) DRY=1; shift ;;
|
|
44
|
-
--dir) AUTO_DIRS+=("$2"); shift 2 ;;
|
|
45
|
-
--report-dir) REPORT_DIR="$2"; shift 2 ;;
|
|
46
|
-
--classes) CLASSES="$2"; shift 2 ;; # comma list; default: all classes
|
|
47
|
-
--reason) REASON="$2"; shift 2 ;;
|
|
48
|
-
--by) BY="$2"; shift 2 ;;
|
|
49
|
-
--restore) RESTORE=1; shift ;;
|
|
50
|
-
--from-report) REPORT="$2"; shift 2 ;;
|
|
51
|
-
--class) CLASS="$2"; shift 2 ;;
|
|
52
|
-
--yes) YES=1; shift ;;
|
|
53
|
-
-h|--help) sed -n 2,22p "$0"; exit 0 ;;
|
|
54
|
-
-*) echo "policy-retire: unknown arg $1" >&2; exit 2 ;;
|
|
55
|
-
*) TARGET="$1"; shift ;;
|
|
56
|
-
esac
|
|
57
|
-
done
|
|
58
|
-
resolve() { # slug-or-path -> real file path or empty
|
|
59
|
-
local t="$1" f
|
|
60
|
-
if [ -f "$t" ]; then f="$t"
|
|
61
|
-
else
|
|
62
|
-
for d in "$HQ_ROOT/personal/policies" "$HQ_ROOT/core/policies" "$HQ_ROOT"/companies/*/policies; do
|
|
63
|
-
[ -f "$d/$t.md" ] && { f="$d/$t.md"; break; }
|
|
64
|
-
[ -f "$d/$t" ] && { f="$d/$t"; break; }
|
|
65
|
-
done
|
|
66
|
-
fi
|
|
67
|
-
[ -n "${f:-}" ] || return 1
|
|
68
|
-
[ -L "$f" ] && f="$(cd "$(dirname "$f")" && realpath "$(readlink "$f")" 2>/dev/null || readlink "$f")"
|
|
69
|
-
printf '%s' "$f"
|
|
70
|
-
}
|
|
71
|
-
validate_policy_before_write() { # <policy-file>
|
|
72
|
-
# Do not replicate frontmatter extraction here. The authoring hook validates
|
|
73
|
-
# every resulting `when:` key (including duplicates), hard-policy limits,
|
|
74
|
-
# and the canonical trigger grammar. A retirement write must meet that exact
|
|
75
|
-
# contract or a sanctioned route could modify a policy normal authoring
|
|
76
|
-
# rejects.
|
|
77
|
-
local f="$1" payload result
|
|
78
|
-
[ -f "$POLICY_VALIDATOR" ] || { echo "policy-retire: missing policy validator: $POLICY_VALIDATOR" >&2; return 1; }
|
|
79
|
-
command -v jq >/dev/null 2>&1 || { echo "policy-retire: jq is required for canonical policy validation" >&2; return 1; }
|
|
80
|
-
payload="$(jq -nc --arg file "$f" --rawfile content "$f" '{tool_input:{file_path:$file,content:$content}}')" \
|
|
81
|
-
|| { echo "policy-retire: could not prepare policy validation input for $f" >&2; return 1; }
|
|
82
|
-
result="$(HQ_ROOT="$VALIDATOR_ROOT" CLAUDE_PROJECT_DIR="$VALIDATOR_ROOT" HQ_ALLOW_POLICY_NO_TRIGGER= \
|
|
83
|
-
bash "$POLICY_VALIDATOR" <<<"$payload" 2>&1)" || {
|
|
84
|
-
printf 'policy-retire: refusing to write %s; canonical policy validation failed:\n%s\n' "$f" "$result" >&2
|
|
85
|
-
return 1
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
set_status() { # <file> <retired|active> <reason>
|
|
89
|
-
local f="$1" st="$2" why="$3" now; now="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
90
|
-
head -1 "$f" | grep -q '^---' || { echo "policy-retire: $f has no frontmatter" >&2; return 1; }
|
|
91
|
-
validate_policy_before_write "$f" || return 1
|
|
92
|
-
awk -v st="$st" -v why="$why" -v by="$BY" -v now="$now" '
|
|
93
|
-
BEGIN { d=0; done=0 }
|
|
94
|
-
/^---[ \t]*$/ { d++; if (d==2 && !done) {
|
|
95
|
-
if (st=="retired") { print "status: retired"; print "retired_at: " now; print "retired_by: " by; print "retired_reason: \"" why "\"" }
|
|
96
|
-
done=1 }
|
|
97
|
-
print; next }
|
|
98
|
-
d==1 && /^(status|retired_at|retired_by|retired_reason):/ { next }
|
|
99
|
-
{ print }' "$f" > "$f.tmp" && mv "$f.tmp" "$f"
|
|
100
|
-
}
|
|
101
|
-
if [ "$AUTO" = 1 ]; then
|
|
102
|
-
AGE="$HQ_ROOT/core/scripts/policy-age-report.sh"; [ -x "$AGE" ] || AGE="$(dirname "${BASH_SOURCE[0]}")/policy-age-report.sh"
|
|
103
|
-
if [ "${#AUTO_DIRS[@]}" -eq 0 ]; then
|
|
104
|
-
AUTO_DIRS=("$HQ_ROOT/personal/policies")
|
|
105
|
-
co="$(bash "$HQ_ROOT/core/scripts/hq-session.sh" get company_slug 2>/dev/null || true)"
|
|
106
|
-
[ -n "$co" ] && [ "$co" != "personal" ] && [ -d "$HQ_ROOT/companies/$co/policies" ] && AUTO_DIRS+=("$HQ_ROOT/companies/$co/policies")
|
|
107
|
-
fi
|
|
108
|
-
dirargs=(); for d in "${AUTO_DIRS[@]}"; do dirargs+=(--dir "$d"); done
|
|
109
|
-
json="$(HQ_ROOT="$HQ_ROOT" bash "$AGE" "${dirargs[@]}" --json --candidates 2>/dev/null)" || json="[]"
|
|
110
|
-
n="$(jq 'length' <<<"$json")"
|
|
111
|
-
today="$(date -u +%Y-%m-%d)"; [ -n "$REPORT_DIR" ] || REPORT_DIR="$HQ_ROOT/workspace/reports"
|
|
112
|
-
out="$REPORT_DIR/policy-retirement-$today.md"; mkdir -p "$REPORT_DIR"
|
|
113
|
-
# One file per day; each run appends its own section so nothing is overwritten.
|
|
114
|
-
: > "$out.tmp"
|
|
115
|
-
if [ ! -f "$out" ]; then
|
|
116
|
-
{
|
|
117
|
-
echo "# Automatic policy retirement — $today"; echo
|
|
118
|
-
echo "Mode: human on the loop. Each policy below was retired by \`policy-retire.sh --auto\` with the reason shown."
|
|
119
|
-
echo "Reverse any wrong call with \`bash core/scripts/policy-retire.sh <slug> --restore\` and fix the rule that produced it."
|
|
120
|
-
echo
|
|
121
|
-
} > "$out.tmp"
|
|
122
|
-
fi
|
|
123
|
-
{ echo "## Run $(date -u +%H:%M:%SZ) — dirs: ${AUTO_DIRS[*]}"; echo; } >> "$out.tmp"
|
|
124
|
-
retired=0; failed=0; needfix=0; would_retire=0
|
|
125
|
-
while IFS=$' ' read -r id path classes fired last age stale judged stale_n renamed; do
|
|
126
|
-
[ -n "$path" ] || continue
|
|
127
|
-
f="$HQ_ROOT/$path"; [ -f "$f" ] || f="$path"; [ -f "$f" ] || continue
|
|
128
|
-
grep -q '^status: retired' "$f" && continue
|
|
129
|
-
if [ -n "$CLASSES" ]; then
|
|
130
|
-
keep=""; for c in $(tr ',' ' ' <<<"$classes"); do case ",$CLASSES," in *,$c,*) keep="${keep:+$keep,}$c" ;; esac; done
|
|
131
|
-
classes="$keep"; [ -n "$classes" ] || continue
|
|
132
|
-
fi
|
|
133
|
-
why=""
|
|
134
|
-
# stale-refs retires only when EVERY judged reference is gone — the rule's
|
|
135
|
-
# whole subject vanished. A partly stale rule is reported for a fix, not retired.
|
|
136
|
-
case ",$classes," in *,renamed-refs,*) printf -- '- needs-fix (not retired) **%s** (%s): referenced file was renamed: %s\n' "$id" "$path" "$renamed" >> "$out.tmp"; needfix=$((needfix+1)) ;; esac
|
|
137
|
-
case ",$classes," in *,stale-refs,*)
|
|
138
|
-
if [ "${judged:-0}" -gt 0 ] && [ "${stale_n:-0}" -eq "${judged:-0}" ]; then why="every path the rule depends on is gone ($stale)"
|
|
139
|
-
else printf -- '- needs-fix (not retired) **%s** (%s): references gone: %s\n' "$id" "$path" "$stale" >> "$out.tmp"; needfix=$((needfix+1)); fi ;;
|
|
140
|
-
esac
|
|
141
|
-
case ",$classes," in *,never-fired,*) why="${why:+$why; }never emitted in any session ledger over ${age} days" ;; esac
|
|
142
|
-
case ",$classes," in *,dormant,*) why="${why:+$why; }last emitted $last, more than the dormant window ago" ;; esac
|
|
143
|
-
case ",$classes," in *,single-incident-old,*) why="${why:+$why; }one-off correction, ${age} days old, fired ${fired}x" ;; esac
|
|
144
|
-
[ -n "$why" ] || continue
|
|
145
|
-
if [ "$DRY" = 1 ]; then
|
|
146
|
-
echo "would retire: $id — $why"
|
|
147
|
-
would_retire=$((would_retire+1))
|
|
148
|
-
continue
|
|
149
|
-
fi
|
|
150
|
-
if set_status "$f" retired "auto: $why"; then
|
|
151
|
-
echo "retired: $id — $why"
|
|
152
|
-
printf -- '- **%s** (%s): %s\n' "$id" "$path" "$why" >> "$out.tmp"
|
|
153
|
-
retired=$((retired+1))
|
|
154
|
-
else
|
|
155
|
-
echo "failed: $id ($path); policy was not retired" >&2
|
|
156
|
-
printf -- '- failed (not retired) **%s** (%s): canonical policy validation failed\n' "$id" "$path" >> "$out.tmp"
|
|
157
|
-
failed=$((failed+1))
|
|
158
|
-
fi
|
|
159
|
-
done < <(jq -r '.[] | [.id, .path, (.candidate_classes|join(",")), (.fired|tostring), (.last_fired // "never"), (.age_days|tostring), (.stale_refs|join(", ")), ((.refs_judged // 0)|tostring), ((.stale_refs|length)|tostring), ((.renamed_refs // [])|join(", "))] | @tsv' <<<"$json")
|
|
160
|
-
if [ "$DRY" = 1 ]; then rm -f "$out.tmp"; echo "policy-retire --auto: $would_retire of $n candidates would be retired (dry run)"
|
|
161
|
-
else
|
|
162
|
-
if [ "$retired" -gt 0 ] || [ "$failed" -gt 0 ] || [ "$needfix" -gt 0 ]; then cat "$out.tmp" >> "$out"; rm -f "$out.tmp"; echo "policy-retire --auto: $retired retired, $failed failed, $needfix need a fix; report ${out#"$HQ_ROOT"/}"; else rm -f "$out.tmp"; echo "policy-retire --auto: nothing to retire"; fi
|
|
163
|
-
fi
|
|
164
|
-
[ "$failed" -eq 0 ] || exit 1
|
|
165
|
-
exit 0
|
|
166
|
-
fi
|
|
167
|
-
if [ -n "$REPORT" ]; then
|
|
168
|
-
[ -n "$CLASS" ] && [ -n "$REASON" ] || { echo "--from-report needs --class and --reason" >&2; exit 2; }
|
|
169
|
-
[ -r "$REPORT" ] || { echo "cannot read $REPORT" >&2; exit 2; }
|
|
170
|
-
retired=0; failed=0; selected=0
|
|
171
|
-
while IFS=$'\t' read -r id path; do
|
|
172
|
-
[ -n "$path" ] || continue
|
|
173
|
-
f="$HQ_ROOT/$path"; [ -f "$f" ] || continue
|
|
174
|
-
grep -q '^status: retired' "$f" && continue
|
|
175
|
-
selected=$((selected+1))
|
|
176
|
-
if [ "$YES" = 1 ]; then
|
|
177
|
-
if set_status "$f" retired "$REASON [$CLASS]"; then
|
|
178
|
-
retired=$((retired+1)); echo "retired: $id ($path)"
|
|
179
|
-
else
|
|
180
|
-
failed=$((failed+1)); echo "failed: $id ($path); policy was not retired" >&2
|
|
181
|
-
fi
|
|
182
|
-
else echo "would retire: $id ($path)"; fi
|
|
183
|
-
done < <(jq -r --arg c "$CLASS" '.[] | select(.candidate_classes | index($c)) | "\(.id)\t\(.path)"' "$REPORT")
|
|
184
|
-
if [ "$YES" = 1 ]; then
|
|
185
|
-
echo "policy-retire: $retired retired [$CLASS]; $failed failed"
|
|
186
|
-
[ "$failed" -eq 0 ] || exit 1
|
|
187
|
-
else
|
|
188
|
-
echo "policy-retire: $selected would be retired [$CLASS] (add --yes)"
|
|
189
|
-
fi
|
|
190
|
-
exit 0
|
|
191
|
-
fi
|
|
192
|
-
[ -n "$TARGET" ] || { sed -n 2,22p "$0"; exit 2; }
|
|
193
|
-
F="$(resolve "$TARGET")" || { echo "policy-retire: no policy found for '$TARGET'" >&2; exit 3; }
|
|
194
|
-
if [ "$RESTORE" = 1 ]; then
|
|
195
|
-
set_status "$F" active "" && echo "policy-retire: restored ${F#"$HQ_ROOT"/}"
|
|
196
|
-
else
|
|
197
|
-
[ -n "$REASON" ] || { echo "policy-retire: --reason is required" >&2; exit 2; }
|
|
198
|
-
set_status "$F" retired "$REASON" && echo "policy-retire: retired ${F#"$HQ_ROOT"/} — $REASON"
|
|
199
|
-
fi
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
#!/bin/bash
|
|
2
|
-
# read-policy-frontmatter.sh — Extract YAML frontmatter from a policy file
|
|
3
|
-
#
|
|
4
|
-
# Usage: bash core/scripts/read-policy-frontmatter.sh <policy-file>
|
|
5
|
-
# bash core/scripts/read-policy-frontmatter.sh <policy-file> <policy-file> ...
|
|
6
|
-
#
|
|
7
|
-
# Returns only the YAML frontmatter block (between the first two --- markers),
|
|
8
|
-
# skipping the policy body. Used by /startwork, /plan, /brainstorm, /run commands
|
|
9
|
-
# to minimize context burn when scanning policy metadata (id, title, enforcement,
|
|
10
|
-
# when) without loading the full ## Rule and ## Rationale sections.
|
|
11
|
-
# (Fields id/title/enforcement/when/on live in that block.)
|
|
12
|
-
#
|
|
13
|
-
# Exit codes:
|
|
14
|
-
# 0 - Success, frontmatter written to stdout
|
|
15
|
-
# 1 - Usage error or file not readable
|
|
16
|
-
|
|
17
|
-
set -euo pipefail
|
|
18
|
-
|
|
19
|
-
if [ $# -lt 1 ]; then
|
|
20
|
-
echo "USAGE: read-policy-frontmatter.sh <policy-file>" >&2
|
|
21
|
-
exit 1
|
|
22
|
-
fi
|
|
23
|
-
|
|
24
|
-
INDEX=0
|
|
25
|
-
for FILE in "$@"; do
|
|
26
|
-
if [ ! -r "$FILE" ]; then
|
|
27
|
-
echo "ERROR: File not readable: $FILE" >&2
|
|
28
|
-
exit 1
|
|
29
|
-
fi
|
|
30
|
-
|
|
31
|
-
if [ "$INDEX" -gt 0 ]; then
|
|
32
|
-
printf '\n# --- policy-file: %s ---\n' "$FILE"
|
|
33
|
-
fi
|
|
34
|
-
INDEX=$((INDEX + 1))
|
|
35
|
-
|
|
36
|
-
# Extract frontmatter: lines between first two --- markers.
|
|
37
|
-
# c counts --- markers; print lines only while inside the frontmatter (c==1).
|
|
38
|
-
awk '/^---$/{c++; next} c==1' "$FILE"
|
|
39
|
-
done
|