@indigoai-us/hq-cli 5.282.0 → 5.283.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 (52) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/assets/scaffold/.claude/hooks/validate-policy-frontmatter.sh +485 -0
  3. package/assets/scaffold/core/scripts/eval-trigger.sh +193 -0
  4. package/assets/scaffold/core/scripts/policy-age-report.sh +156 -0
  5. package/assets/scaffold/core/scripts/policy-retire.sh +199 -0
  6. package/assets/scaffold/core/scripts/read-policy-frontmatter.sh +32 -0
  7. package/dist/command-catalog.generated.d.ts +67 -0
  8. package/dist/command-catalog.generated.js +83 -0
  9. package/dist/commands/core-knowledge-prefs.d.ts +10 -0
  10. package/dist/commands/core-knowledge-prefs.js +125 -0
  11. package/dist/commands/core-native-policy-flag.d.ts +9 -0
  12. package/dist/commands/core-native-policy-flag.js +12 -0
  13. package/dist/commands/core-ontology-native.d.ts +0 -1
  14. package/dist/commands/core-ontology-native.js +2 -30
  15. package/dist/commands/core-ontology.d.ts +4 -0
  16. package/dist/commands/core-ontology.js +8 -3
  17. package/dist/commands/core-policy-frontmatter.d.ts +8 -0
  18. package/dist/commands/core-policy-frontmatter.js +52 -0
  19. package/dist/commands/core-policy.d.ts +15 -0
  20. package/dist/commands/core-policy.js +982 -0
  21. package/dist/commands/core.js +14 -1
  22. package/dist/commands/lanes-shared.d.ts +4 -1
  23. package/dist/commands/lanes-shared.js +2 -1
  24. package/dist/commands/lanes.d.ts +8 -0
  25. package/dist/commands/lanes.js +294 -86
  26. package/dist/fast-lanes-list-route.d.ts +8 -0
  27. package/dist/fast-lanes-list-route.js +30 -0
  28. package/dist/fast-lanes-list.d.ts +3 -0
  29. package/dist/fast-lanes-list.js +252 -0
  30. package/dist/fast-scaffold.d.ts +5 -0
  31. package/dist/fast-scaffold.js +10 -0
  32. package/dist/index.js +10 -1
  33. package/dist/lib/checkpoint-ontology/merge-copies.d.ts +24 -1
  34. package/dist/lib/checkpoint-ontology/merge-copies.js +70 -1
  35. package/dist/lib/lanes/flag-keys.d.ts +3 -0
  36. package/dist/lib/lanes/flag-keys.js +3 -0
  37. package/dist/lib/lanes/index.d.ts +1 -1
  38. package/dist/lib/lanes/index.js +1 -1
  39. package/dist/lib/lanes/outpost-dispatch.d.ts +1 -1
  40. package/dist/lib/lanes/outpost-dispatch.js +4 -2
  41. package/dist/lib/lanes/outpost-hire-cache.d.ts +3 -0
  42. package/dist/lib/lanes/outpost-hire-cache.js +11 -3
  43. package/dist/lib/lanes/outpost-snapshot-cache.d.ts +26 -0
  44. package/dist/lib/lanes/outpost-snapshot-cache.js +215 -0
  45. package/dist/lib/lanes/store.d.ts +4 -0
  46. package/dist/lib/lanes/store.js +39 -0
  47. package/dist/lib/policy-validator.d.ts +2 -0
  48. package/dist/lib/policy-validator.js +136 -0
  49. package/dist/native-fast-route.d.ts +2 -0
  50. package/dist/native-fast-route.js +6 -2
  51. package/dist/utils/contribution-table.d.ts +1 -1
  52. package/package.json +1 -1
@@ -0,0 +1,193 @@
1
+ #!/bin/bash
2
+ # eval-trigger.sh — evaluate a policy `when:` boolean expression against a fact set.
3
+ #
4
+ # Usage:
5
+ # eval-trigger.sh "<expr>" "<space-separated facts>"
6
+ # eval-trigger.sh --check < "<id>\t<expr>" lines → "<id>\tok|malformed"
7
+ #
8
+ # Expression grammar (tiny boolean algebra over open identifiers):
9
+ # expr := or
10
+ # or := and ( '||' and )*
11
+ # and := not ( '&&' not )*
12
+ # not := '!' not | atom
13
+ # atom := '(' or ')' | identifier
14
+ # identifier := [A-Za-z0-9_./][A-Za-z0-9_./-]*
15
+ # Letters, digits, _ . / - . May start with `.` or `/`, so a
16
+ # filename (`.mcp.json`, `settings.json`) or a slash-command
17
+ # (`/brainstorm`) is a single literal token. Operators
18
+ # (`&& || ! ( )`) and whitespace are the only delimiters.
19
+ #
20
+ # Tokens are OPEN — no vocabulary. Identifiers are compared with the fact set
21
+ # case-insensitively; absent or misspelled identifiers are FALSE.
22
+ #
23
+ # Exit codes:
24
+ # 0 — expression is TRUE given the facts
25
+ # 1 — expression is FALSE given the facts
26
+ # 2 — expression is empty / malformed / unsafe → caller should FAIL OPEN
27
+ #
28
+ # Safety: each identifier is substituted with 1/0 by fact membership, then the
29
+ # result must contain ONLY `0 1 & | ! ( )` and spaces. Anything else (quotes,
30
+ # backticks, `$`, letters left over, etc.) yields exit 2 — the expression is
31
+ # never eval'd by a shell, only by awk's own boolean operators. Dots, slashes,
32
+ # and dashes are part of the identifier charset, so they are consumed by the
33
+ # substitution and never survive to the gate; quotes/backticks/$ still do, and
34
+ # still fail open.
35
+
36
+ set -euo pipefail
37
+
38
+ # --check: batch SYNTAX mode. Reads `<id><TAB><expr>` lines on stdin and prints
39
+ # `<id><TAB>ok` or `<id><TAB>malformed`, one line per input. Validity does not
40
+ # depend on the fact set — every identifier substitutes to 0 — so this is the
41
+ # same parse the single-expression mode runs, just without a process per
42
+ # expression. Callers linting a whole tree use this; there is deliberately no
43
+ # second implementation of the grammar to drift from.
44
+ if [ "${1-}" = "--check" ]; then
45
+ awk -F'\t' '
46
+ function skip() { while (substr(E, pos, 1) ~ /[ \t]/) pos++ }
47
+ function parseOr( v) {
48
+ v = parseAnd(); skip()
49
+ while (valid && substr(E, pos, 2) == "||") {
50
+ pos += 2
51
+ rhs = parseAnd()
52
+ v = (v || rhs)
53
+ skip()
54
+ }
55
+ return v
56
+ }
57
+ function parseAnd( v) {
58
+ v = parseNot(); skip()
59
+ while (valid && substr(E, pos, 2) == "&&") {
60
+ pos += 2
61
+ rhs = parseNot()
62
+ v = (v && rhs)
63
+ skip()
64
+ }
65
+ return v
66
+ }
67
+ function parseNot( c) {
68
+ skip(); c = substr(E, pos, 1)
69
+ if (c == "!") { pos++; return (parseNot() ? 0 : 1) }
70
+ return parseAtom()
71
+ }
72
+ function parseAtom( v, c) {
73
+ skip(); c = substr(E, pos, 1)
74
+ if (c == "(") {
75
+ pos++; v = parseOr(); skip()
76
+ if (substr(E, pos, 1) != ")") { valid = 0; return 0 }
77
+ pos++; return v
78
+ }
79
+ if (c == "0" || c == "1") { pos++; return (c == "1") ? 1 : 0 }
80
+ valid = 0; return 0
81
+ }
82
+ function check(expr, e, s, out, tok) {
83
+ e = expr; gsub(/[ \t]/, "", e)
84
+ if (e == "") return 0
85
+ s = expr; out = ""
86
+ while (match(s, "[A-Za-z0-9_./][A-Za-z0-9_./-]*")) {
87
+ tok = substr(s, RSTART, RLENGTH)
88
+ out = out substr(s, 1, RSTART - 1) "0"
89
+ s = substr(s, RSTART + RLENGTH)
90
+ }
91
+ out = out s
92
+ if (out ~ /[^01&|!() \t]/) return 0
93
+ E = out; pos = 1; valid = 1
94
+ parseOr()
95
+ skip()
96
+ if (!valid || pos <= length(E)) return 0 # trailing garbage / bad atom
97
+ return 1
98
+ }
99
+ # The first tab separates the caller id from the expression. Rejoin any
100
+ # remaining fields so tabs inside an expression retain their documented
101
+ # whitespace meaning instead of being silently discarded by the awk field
102
+ # separator.
103
+ {
104
+ expr = $2
105
+ for (field = 3; field <= NF; field++) expr = expr "\t" $field
106
+ print $1 "\t" (check(expr) ? "ok" : "malformed")
107
+ }
108
+ '
109
+ exit 0
110
+ fi
111
+
112
+ EXPR="${1-}"
113
+ FACTS="${2-}"
114
+
115
+ # Empty expression → nothing to evaluate; caller fails open.
116
+ case "$EXPR" in
117
+ ''|*[!\ ]*) : ;; # has at least one non-space char → continue
118
+ esac
119
+ # (the case above is a readability no-op; the real empty check is in awk)
120
+
121
+ awk -v expr="$EXPR" -v facts="$FACTS" '
122
+ function skip() { while (substr(E, pos, 1) ~ /[ \t]/) pos++ }
123
+ function parseOr( v) {
124
+ v = parseAnd(); skip()
125
+ while (valid && substr(E, pos, 2) == "||") {
126
+ pos += 2
127
+ rhs = parseAnd()
128
+ v = (v || rhs)
129
+ skip()
130
+ }
131
+ return v
132
+ }
133
+ function parseAnd( v) {
134
+ v = parseNot(); skip()
135
+ while (valid && substr(E, pos, 2) == "&&") {
136
+ pos += 2
137
+ rhs = parseNot()
138
+ v = (v && rhs)
139
+ skip()
140
+ }
141
+ return v
142
+ }
143
+ function parseNot( c) {
144
+ skip(); c = substr(E, pos, 1)
145
+ if (c == "!") { pos++; return (parseNot() ? 0 : 1) }
146
+ return parseAtom()
147
+ }
148
+ function parseAtom( v, c) {
149
+ skip(); c = substr(E, pos, 1)
150
+ if (c == "(") {
151
+ pos++; v = parseOr(); skip()
152
+ if (substr(E, pos, 1) != ")") { valid = 0; return 0 }
153
+ pos++; return v
154
+ }
155
+ if (c == "0" || c == "1") { pos++; return (c == "1") ? 1 : 0 }
156
+ valid = 0; return 0
157
+ }
158
+ BEGIN {
159
+ # empty / whitespace-only expression → fail open
160
+ e = expr; gsub(/[ \t]/, "", e)
161
+ if (e == "") exit 2
162
+
163
+ # build fact membership set
164
+ n = split(facts, fa, /[ ,]+/)
165
+ for (i = 1; i <= n; i++) if (fa[i] != "") have[fa[i]] = 1
166
+
167
+ # substitute identifiers -> 1/0 by membership
168
+ s = expr; out = ""
169
+ while (match(s, "[A-Za-z0-9_./][A-Za-z0-9_./-]*")) {
170
+ tok = substr(s, RSTART, RLENGTH)
171
+ out = out substr(s, 1, RSTART - 1) ((tolower(tok) in have) ? "1" : "0")
172
+ s = substr(s, RSTART + RLENGTH)
173
+ }
174
+ out = out s
175
+
176
+ # safety gate: only 0 1 & | ! ( ) and spaces may remain
177
+ if (out ~ /[^01&|!() \t]/) exit 2
178
+
179
+ # evaluate
180
+ E = out; pos = 1; valid = 1
181
+ v = parseOr()
182
+
183
+ # The parse must consume the WHOLE expression. Recursive descent stops at the
184
+ # first token it cannot continue from, so `merge || pull request` would parse
185
+ # as `merge || pull` and silently discard everything after the bare space —
186
+ # the policy then fires (or fails to) on a prefix nobody authored. Leftover
187
+ # input means the expression is malformed, not shorter.
188
+ skip()
189
+ if (!valid || pos <= length(E)) exit 2
190
+
191
+ exit (v ? 0 : 1)
192
+ }
193
+ '
@@ -0,0 +1,156 @@
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
+ TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
55
+
56
+ # One pass over every ledger: slug<TAB>mtime, then reduce to count + max mtime.
57
+ HAVE_LEDGERS=0
58
+ if [ -d "$LEDGERS" ] && [ -n "$(find "$LEDGERS" -name '*.txt' -type f 2>/dev/null | head -1)" ]; then
59
+ HAVE_LEDGERS=1
60
+ find "$LEDGERS" -name '*.txt' -type f 2>/dev/null | while IFS= read -r f; do
61
+ m="$(stat -c %Y "$f" 2>/dev/null || stat -f %m "$f")"
62
+ awk -v m="$m" 'NF { print $1 "\t" m }' "$f"
63
+ 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"
64
+ else
65
+ : > "$TMP/usage.tsv"
66
+ fi
67
+ # Retrieval ledger (record-policy-retrieval.sh): sessions that PULLED the full text.
68
+ RETRIEVALS="${HQ_POLICY_RETRIEVAL_LEDGER_DIR:-$HQ_ROOT/workspace/orchestrator/policy-retrieval-state}"
69
+ if [ -d "$RETRIEVALS" ]; then
70
+ cat "$RETRIEVALS"/*.txt 2>/dev/null | awk 'NF { c[$1]++ } END { for (s in c) print s "\t" c[s] }' > "$TMP/retrieved.tsv"
71
+ else
72
+ : > "$TMP/retrieved.tsv"
73
+ fi
74
+
75
+ to_epoch() { # YYYY-MM-DD -> epoch (0 on failure)
76
+ date -u -d "$1" +%s 2>/dev/null || date -u -j -f %Y-%m-%d "$1" +%s 2>/dev/null || echo 0
77
+ }
78
+
79
+ for dir in "${DIRS[@]}"; do
80
+ case "$dir" in /*) ;; *) dir="$HQ_ROOT/$dir" ;; esac
81
+ [ -d "$dir" ] || continue
82
+ for f in "$dir"/*.md; do
83
+ [ -f "$f" ] || continue
84
+ b="$(basename "$f")"
85
+ case "$b" in README.md|example-policy.md|_digest*.md|*" "*|*.conflict-*|*.sync-conflict-*) continue ;; esac
86
+ [ -L "$f" ] && continue # personal overlay mirrors live in core/ as symlinks; count the source once
87
+ grep -q '^status: retired' "$f" && continue # already retired (policy-retire.sh)
88
+ id="$(awk 'NR==1&&!/^---/{exit} /^---/{d++; if(d==2)exit; next} d==1&&/^id:/{sub(/^id:[ \t]*/,""); gsub(/^["'"'"']|["'"'"']$/,""); print; exit}' "$f")"
89
+ [ -n "$id" ] || id="${b%.md}"
90
+ enf="$(awk '/^---/{d++; if(d==2)exit; next} d==1&&/^enforcement:/{sub(/^enforcement:[ \t]*/,""); print; exit}' "$f")"
91
+ src="$(awk '/^---/{d++; if(d==2)exit; next} d==1&&/^source:/{sub(/^source:[ \t]*/,""); gsub(/"/,""); print; exit}' "$f")"
92
+ ver="$(awk '/^---/{d++; if(d==2)exit; next} d==1&&/^version:/{sub(/^version:[ \t]*/,""); print; exit}' "$f")"
93
+ created="$(awk '/^---/{d++; if(d==2)exit; next} d==1&&/^created:/{sub(/^created:[ \t]*/,""); gsub(/"/,""); print substr($0,1,10); exit}' "$f")"
94
+ if [ -n "$created" ]; then ce="$(to_epoch "$created")"; else ce=0; fi
95
+ [ "$ce" -gt 0 ] || ce="$(stat -c %Y "$f" 2>/dev/null || stat -f %m "$f")"
96
+ age_days=$(( (NOW_EPOCH - ce) / 86400 ))
97
+ fired=0; last=""
98
+ row="$(awk -F'\t' -v s="$id" '$1==s{print; exit}' "$TMP/usage.tsv")"
99
+ 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
100
+ retrieved="$(awk -F'\t' -v s="$id" '$1==s{print $2; exit}' "$TMP/retrieved.tsv")"; retrieved="${retrieved:-0}"
101
+ # stale refs: backtick paths in the Rule section that look HQ-relative and are missing
102
+ stale=""; renamed=""
103
+ while IFS= read -r ref; do
104
+ [ -n "$ref" ] || continue
105
+ # Only release-owned or company-owned paths can make a rule inaccurate:
106
+ # core/, .claude/, companies/<slug>/ (when that company exists here).
107
+ # personal/, workspace/, repos/ are user- or environment-specific and are
108
+ # never judged. A ref whose top-level dir is absent is not judged either.
109
+ case "$ref" in
110
+ .claude/state/*|.claude/worktrees/*|core/packages/*) continue ;; # runtime / install-specific
111
+ core/*|.claude/*) ;;
112
+ companies/*) co_ref="${ref#companies/}"; co_ref="${co_ref%%/*}"; [ -d "$HQ_ROOT/companies/$co_ref" ] || continue ;;
113
+ *) continue ;;
114
+ esac
115
+ # Placeholder examples in prose (`core/scripts/foo.sh`, `.claude/hooks/example.sh`) are not dependencies.
116
+ case "$(basename "$ref")" in foo*|bar*|baz*|example*|placeholder*|sample*|your-*|my-*|x.sh|dummy*) continue ;; esac
117
+ if [ ! -e "$HQ_ROOT/$ref" ]; then
118
+ # A sibling with the same stem (renamed extension: foo.sh -> foo.mjs) means
119
+ # the subject still exists and the rule needs a fix, not retirement.
120
+ stem="${ref%.*}"
121
+ if ls "$HQ_ROOT/$stem".* >/dev/null 2>&1; then renamed="${renamed:+$renamed,}$ref"; else stale="${stale:+$stale,}$ref"; fi
122
+ fi
123
+ done < <(awk '/^## Rule/{r=1;next} r&&/^## /{r=0} r' "$f" \
124
+ | 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)' \
125
+ | grep -oE '`(core|personal|companies|repos|workspace|\.claude)/[A-Za-z0-9_./-]+`' | tr -d '`' | grep -vE '\{|\}|\*' | sort -u)
126
+ stale_n=0; [ -n "$stale" ] && stale_n="$(tr ',' '\n' <<<"$stale" | wc -l | tr -d ' ')"
127
+ 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 ' ')"
128
+ single=0; case "$src" in user-correction|session-learning|back-pressure-failure) [ "${ver:-1}" = "1" ] && single=1 ;; esac
129
+ classes=""
130
+ if [ "$fired" = 0 ] && [ "$age_days" -ge "$MIN_AGE" ] && [ "$HAVE_LEDGERS" = 1 ]; then classes="never-fired"; fi
131
+ if [ "$fired" -gt 0 ] && [ "$last_age" -ge "$DORMANT" ]; then classes="${classes:+$classes,}dormant"; fi
132
+ if [ "$stale_n" -gt 0 ]; then classes="${classes:+$classes,}stale-refs"; fi
133
+ if [ -n "$renamed" ]; then classes="${classes:+$classes,}renamed-refs"; fi
134
+ # A one-off rule that keeps firing has earned its place; only flag it when it never (or once) fired.
135
+ if [ "$single" = 1 ] && [ "$age_days" -ge "$INCIDENT" ] && [ "$fired" -le 1 ]; then classes="${classes:+$classes,}single-incident-old"; fi
136
+ 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"
137
+ done
138
+ done > "$TMP/rows.tsv"
139
+
140
+ if [ "$JSON" = 1 ]; then
141
+ jq -R -s -c --argjson cand "$CAND" '
142
+ 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)})
143
+ | if $cand == 1 then map(select(.candidate_classes|length>0)) else . end' "$TMP/rows.tsv"
144
+ exit 0
145
+ fi
146
+ total="$(wc -l < "$TMP/rows.tsv" | tr -d ' ')"
147
+ never="$(awk -F'\t' '$5==0' "$TMP/rows.tsv" | wc -l | tr -d ' ')"
148
+ cands="$(awk -F'\t' '$10!=""' "$TMP/rows.tsv" | wc -l | tr -d ' ')"
149
+ stale_total="$(awk -F'\t' '$7>0' "$TMP/rows.tsv" | wc -l | tr -d ' ')"
150
+ 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)"
151
+ if [ "$CAND" = 1 ]; then
152
+ 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
153
+ else
154
+ echo " (add --candidates to list them, --json for machine-readable rows)"
155
+ fi
156
+ exit 0
@@ -0,0 +1,199 @@
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
@@ -0,0 +1,32 @@
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
+ #
6
+ # Returns only the YAML frontmatter block (between the first two --- markers),
7
+ # skipping the policy body. Used by /startwork, /plan, /brainstorm, /run commands
8
+ # to minimize context burn when scanning policy metadata (id, title, enforcement,
9
+ # when) without loading the full ## Rule and ## Rationale sections.
10
+ # (Fields id/title/enforcement/when/on live in that block.)
11
+ #
12
+ # Exit codes:
13
+ # 0 - Success, frontmatter written to stdout
14
+ # 1 - Usage error or file not readable
15
+
16
+ set -euo pipefail
17
+
18
+ if [ $# -lt 1 ]; then
19
+ echo "USAGE: read-policy-frontmatter.sh <policy-file>" >&2
20
+ exit 1
21
+ fi
22
+
23
+ FILE="$1"
24
+
25
+ if [ ! -r "$FILE" ]; then
26
+ echo "ERROR: File not readable: $FILE" >&2
27
+ exit 1
28
+ fi
29
+
30
+ # Extract frontmatter: lines between first two --- markers.
31
+ # c counts --- markers; print lines only while inside the frontmatter (c==1).
32
+ awk '/^---$/{c++; next} c==1' "$FILE"