@indigoai-us/hq-cli 5.322.0 → 5.323.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 CHANGED
@@ -2,7 +2,11 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.323.0] — 2026-10-02
6
+ - Add native `hq core derive-trigger-facts` on the fast route behind the default-off `core-native-hook-helpers` flag.
7
+
5
8
  ## [5.322.0] — 2026-10-02
9
+ - Add an opt-in, default-off CLI prompt for solo companies to invite a teammate after a work-bearing sync.
6
10
 
7
11
  - Retried idempotent vault API GETs once when API Gateway returned its generic Lambda-invoke 504 response without a function request ID.
8
12
 
@@ -0,0 +1,263 @@
1
+ #!/bin/bash
2
+ # derive-trigger-facts.sh — build the fact set a policy `when:` expression is
3
+ # evaluated against, for a given hook event.
4
+ #
5
+ # Usage:
6
+ # <hook-json on stdin> | derive-trigger-facts.sh <EVENT> [--with-assistant-intent]
7
+ # EVENT in {PreToolUse, PostToolUse, UserPromptSubmit, AssistantIntent}
8
+ # -> prints a space-separated, de-duplicated fact set to stdout.
9
+ # With --with-assistant-intent (PreToolUse and UserPromptSubmit only), prints
10
+ # the primary and AssistantIntent fact sets as two newline-delimited records.
11
+ #
12
+ # Facts = event tokens + best-effort static facts (company / repo / shared_branch),
13
+ # EXCEPT AssistantIntent, which is AI-message tokens only (no static facts).
14
+ #
15
+ # Tokens are OPEN — there is NO curated vocabulary. The fact set for a text
16
+ # event is EVERY word token in that text (lowercased, letter-led, length >= 2),
17
+ # so a policy `when:` can key on any word that naturally appears when it is
18
+ # relevant (`refactor`, `monitor`, `docker`, `linear`, ...) without the engine
19
+ # having to know it in advance. On top of the literal words, a few NON-LITERAL
20
+ # / structured facts are derived (a word that is not itself present in the text):
21
+ # secret + apikey <- credential locations and key-shaped strings
22
+ # completed <- clear completion messages in tool output or assistant text
23
+ # shared_branch <- a shared branch name (main/master/staging/production/release/)
24
+ # <basename>+.ext <- a file reference (see Filename tokens below)
25
+ # /command <- a slash-command mention (see Slash-command tokens below)
26
+ # run_in_background <- PreToolUse Bash with tool_input.run_in_background: true
27
+ # Per-event source text:
28
+ # PreToolUse Bash -> the command. (`gh pr create` -> the word `pr`, etc.)
29
+ # PreToolUse other -> lowercased tool name (Glob->glob, Grep->grep, ...).
30
+ # UserPromptSubmit -> the prompt text.
31
+ # PostToolUse -> the tool OUTPUT (tool_response) text.
32
+ # AssistantIntent -> assistant message text emitted since the last user turn
33
+ # in transcript_path, AND NOTHING ELSE (no command/prompt
34
+ # tokens, no static facts). The dedicated AI-message
35
+ # channel; raw PreToolUse / UserPromptSubmit fact sets do
36
+ # NOT include look-back.
37
+ #
38
+ # Filename tokens: any file reference in the text emits a literal basename token
39
+ # and a `.ext` token (the eval-trigger grammar allows dots and slashes). So a
40
+ # policy keys on the file directly. `.claude/settings.json` -> `settings.json` +
41
+ # `.json`; `.mcp.json` -> `.mcp.json` + `.json`; `shot.png` -> `shot.png` + `.png`.
42
+ # This is what lets file-scoped policies fire from AssistantIntent (the AI naming
43
+ # the file it is about to edit/read) without the hook seeing the non-Bash
44
+ # Edit/Read tool call itself.
45
+ #
46
+ # Slash-command tokens: a `/command` mentioned in the text emits a `/command`
47
+ # token (`/brainstorm`, `/deep-plan`), so a slash-command-scoped policy fires
48
+ # when the command is invoked or referenced in a prompt.
49
+ #
50
+ # Pure read-only. Requires jq for JSON parsing (falls back to best-effort sed).
51
+
52
+ set -euo pipefail
53
+
54
+ EVENT="${1:-}"
55
+ WITH_ASSISTANT_INTENT=0
56
+ [ "${2:-}" = "--with-assistant-intent" ] && WITH_ASSISTANT_INTENT=1
57
+
58
+ # Bash can read the hook pipe directly without the separate `cat` process. An
59
+ # empty pipe retains the old best-effort empty-object behavior.
60
+ STDIN_JSON="$(</dev/stdin)" || STDIN_JSON='{}'
61
+ [ -n "$STDIN_JSON" ] || STDIN_JSON='{}'
62
+ JQ="$(command -v jq || true)"
63
+
64
+ # HQ root — used only to resolve the session's meta.yaml for the company fact
65
+ # (US-004). Prefer an explicit HQ_ROOT / CLAUDE_PROJECT_DIR; otherwise walk up
66
+ # from this script (core/scripts/derive-trigger-facts.sh -> ../.. == HQ root).
67
+ SCRIPT_PATH="${BASH_SOURCE[0]}"
68
+ case "$SCRIPT_PATH" in
69
+ */*) SCRIPT_DIR="${SCRIPT_PATH%/*}" ;;
70
+ *) SCRIPT_DIR="." ;;
71
+ esac
72
+ SCRIPT_DIR="$(cd "$SCRIPT_DIR" && pwd)"
73
+ HQ_ROOT="${HQ_ROOT:-${CLAUDE_PROJECT_DIR:-$(cd "$SCRIPT_DIR/../.." && pwd)}}"
74
+ TRIGGER_FACT_TEXT_AWK="$SCRIPT_DIR/lib/trigger-fact-text.awk"
75
+ if [ ! -r "$TRIGGER_FACT_TEXT_AWK" ]; then
76
+ printf '%s\n' 'ERROR: derive-trigger-facts: missing core/scripts/lib/trigger-fact-text.awk' >&2
77
+ exit 1
78
+ fi
79
+ TRANSCRIPT_TAIL_LIB="$SCRIPT_DIR/lib/transcript-tail.sh"
80
+ if [ ! -r "$TRANSCRIPT_TAIL_LIB" ]; then
81
+ printf '%s\n' 'ERROR: derive-trigger-facts: missing core/scripts/lib/transcript-tail.sh' >&2
82
+ exit 1
83
+ fi
84
+ . "$TRANSCRIPT_TAIL_LIB"
85
+
86
+ # Parse the hook payload once. The old per-field jget() helper launched jq for
87
+ # every scalar, then the policy hook launched this script twice for a single
88
+ # PreToolUse/UserPromptSubmit fire. NUL-delimited records preserve embedded
89
+ # newlines in commands and prompts; JSON strings containing NUL were already
90
+ # unsupported by bash command substitution on the old path.
91
+ JSON_FIELDS=()
92
+ if [ -n "$JQ" ]; then
93
+ while IFS= read -r -d '' json_field; do
94
+ JSON_FIELDS+=("$json_field")
95
+ done < <(
96
+ printf '%s' "$STDIN_JSON" | "$JQ" -rj '
97
+ def text:
98
+ if . == null then ""
99
+ elif type == "string" then .
100
+ else tostring
101
+ end;
102
+ [
103
+ (.tool_name // ""),
104
+ (.tool_input.command // ""),
105
+ (.tool_input.run_in_background // ""),
106
+ (if .tool_response == null then ""
107
+ elif (.tool_response | type) == "string" then .tool_response
108
+ else (.tool_response | tostring)
109
+ end),
110
+ (.prompt // ""),
111
+ (.cwd // ""),
112
+ (.session_id // ""),
113
+ (.transcript_path // "")
114
+ ] | .[] | text + "\u0000"
115
+ ' 2>/dev/null
116
+ )
117
+ fi
118
+
119
+ TOOL="${JSON_FIELDS[0]:-}"
120
+ COMMAND="${JSON_FIELDS[1]:-}"
121
+ RUN_IN_BACKGROUND="${JSON_FIELDS[2]:-}"
122
+ TOOL_RESPONSE="${JSON_FIELDS[3]:-}"
123
+ PROMPT="${JSON_FIELDS[4]:-}"
124
+ PAYLOAD_CWD="${JSON_FIELDS[5]:-}"
125
+ PAYLOAD_SESSION_ID="${JSON_FIELDS[6]:-}"
126
+ TRANSCRIPT_PATH="${JSON_FIELDS[7]:-}"
127
+
128
+ # match_keywords <text> — emit (newline-separated) the OPEN fact set for <text>:
129
+ # every word token present (case-insensitive), plus the non-literal derived
130
+ # tokens (secret / shared_branch) and structured filename / slash-command tokens.
131
+ match_keywords() {
132
+ printf '%s\n' "$1" | awk -v mode=all -f "$TRIGGER_FACT_TEXT_AWK"
133
+ }
134
+
135
+ # --- company: a SESSION-IDENTITY fact, resolved once per payload (US-004) ---
136
+ # Company is not look-back content; it identifies the active tenant. It is
137
+ # derived with the SAME precedence as inject-policy-on-trigger.sh DIRS —
138
+ # HQ_POLICY_COMPANY env override > cwd companies/<slug> > session-meta
139
+ # company_slug (workspace/sessions/$session_id/meta.yaml).
140
+ # A paired primary/AssistantIntent derivation shares this resolution, but each
141
+ # resulting fact set still receives the same `company` token it would alone.
142
+ CWD="$PAYLOAD_CWD"; [ -z "$CWD" ] && CWD="${CLAUDE_PROJECT_DIR:-$PWD}"
143
+ SESSION_ID="$PAYLOAD_SESSION_ID"
144
+ co_scope=""
145
+ if [ -n "${HQ_POLICY_COMPANY:-}" ]; then
146
+ co_scope="$HQ_POLICY_COMPANY"
147
+ else
148
+ case "$CWD" in
149
+ *companies/*)
150
+ co_path="${CWD##*companies/}"
151
+ co_scope="${co_path%%/*}"
152
+ ;;
153
+ esac
154
+ if [ -z "$co_scope" ] && [ -n "$SESSION_ID" ]; then
155
+ # Read the session's own meta.yaml directly (awk idiom from
156
+ # master-hook.sh:119); READ ONLY, never via hq-session.sh get.
157
+ META="$HQ_ROOT/workspace/sessions/$SESSION_ID/meta.yaml"
158
+ [ -f "$META" ] && co_scope="$(awk '$1 == "company_slug:" { sub(/^[^:]+:[[:space:]]*/, ""); gsub(/^"|"$/, ""); print; exit }' "$META")"
159
+ fi
160
+ fi
161
+ COMPANY_FACT=""
162
+ [ -n "$co_scope" ] && COMPANY_FACT="company"
163
+
164
+ FACTS=""
165
+ FACTS_OUTPUT=""
166
+ add() { FACTS="$FACTS $*"; }
167
+
168
+ # Preserve the existing splitting and first-seen ordering but do it in one awk
169
+ # process instead of awk | tr | sed. `FACTS` only contains space/newline token
170
+ # boundaries from the existing match_keywords contract.
171
+ normalize_facts() {
172
+ FACTS_OUTPUT="$(printf '%s\n' $FACTS | awk -v mode=normalize -v branch="$branch" -f "$TRIGGER_FACT_TEXT_AWK")"
173
+ }
174
+
175
+ # derive_event <event> sets FACTS_OUTPUT. It does not write stdout so paired
176
+ # mode can run both channels in this one process without a second hook launch.
177
+ derive_event() {
178
+ local event="$1" lookback_text branch=""
179
+ FACTS=""
180
+
181
+ # `always` is present in every fact set so `when: always` is the canonical
182
+ # "no condition" expression (used by SessionStart-introduced advisory policies).
183
+ add always
184
+
185
+ case "$event" in
186
+ PreToolUse|PostToolUse)
187
+ case "$TOOL" in
188
+ Bash)
189
+ if [ "$event" = "PreToolUse" ]; then
190
+ add "$(match_keywords "$COMMAND")"
191
+ # structured: a harness-tracked background task (`run_in_background:
192
+ # true`) -> `run_in_background`, so a policy can tell a backgrounded
193
+ # poll loop from the same command run in the foreground.
194
+ [ "$RUN_IN_BACKGROUND" = "true" ] && add run_in_background
195
+ else
196
+ # PostToolUse: derive from the tool OUTPUT, not the input command.
197
+ add "$(match_keywords "$TOOL_RESPONSE")"
198
+ fi
199
+ ;;
200
+ "" ) : ;;
201
+ * )
202
+ # non-Bash tool -> lowercased tool name token (PreToolUse mainly)
203
+ if [ "$event" = "PreToolUse" ]; then
204
+ add "$(printf '%s' "$TOOL" | tr '[:upper:]' '[:lower:]')"
205
+ else
206
+ add "$(match_keywords "$TOOL_RESPONSE")"
207
+ fi
208
+ ;;
209
+ esac
210
+ ;;
211
+ UserPromptSubmit)
212
+ add "$(match_keywords "$PROMPT")"
213
+ ;;
214
+ esac
215
+
216
+ [ -n "$COMPANY_FACT" ] && add "$COMPANY_FACT"
217
+
218
+ # The dedicated channel for "what the assistant said it would do" — assistant
219
+ # text emitted since the last user turn in transcript_path. No command/prompt
220
+ # tokens are mixed in; the raw PreToolUse/UserPromptSubmit fact sets deliberately
221
+ # exclude this look-back so the two channels stay crisp. (Company, above, is the
222
+ # one session-identity fact shared across channels.)
223
+ if [ "$event" = "AssistantIntent" ]; then
224
+ if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ] && [ -n "$JQ" ]; then
225
+ lookback_text="$("$JQ" -nr '
226
+ # Real Claude Code transcript: each line is {type, message:{role,content}}
227
+ # where assistant text lives at .message.content[] | select(.type=="text")
228
+ # | .text. Fall back to a flat top-level .content (string) for simple
229
+ # fixtures. (Mirrors enforce-capability-link-render.sh / capture-estimates.sh.)
230
+ reduce inputs as $e ("";
231
+ ($e.type // "") as $ty
232
+ | (($e.message.content // $e.content) as $c
233
+ | if ($c|type)=="array" then ([$c[]? | select(.type=="text") | .text] | join(" "))
234
+ elif ($c|type)=="string" then $c
235
+ else "" end) as $txt
236
+ | if $ty == "user" then ""
237
+ elif $ty == "assistant" then . + (if length > 0 then " " else "" end) + $txt
238
+ else . end
239
+ )
240
+ ' < <(hq_transcript_tail "$TRANSCRIPT_PATH") 2>/dev/null)"
241
+ [ -n "$lookback_text" ] && add "$(match_keywords "$lookback_text")"
242
+ fi
243
+ else
244
+ # Best-effort static session facts (repo / shared_branch), primary events
245
+ # only. AssistantIntent is facts-from-AI-only except for `company` above.
246
+ case "$CWD" in
247
+ *repos/public/*|*repos/private/*) add repo ;;
248
+ esac
249
+ branch="$(git -C "$CWD" rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
250
+ fi
251
+
252
+ normalize_facts
253
+ }
254
+
255
+ derive_event "$EVENT"
256
+ PRIMARY_FACTS="$FACTS_OUTPUT"
257
+ if [ "$WITH_ASSISTANT_INTENT" = "1" ] \
258
+ && { [ "$EVENT" = "PreToolUse" ] || [ "$EVENT" = "UserPromptSubmit" ]; }; then
259
+ derive_event AssistantIntent
260
+ printf '%s\n%s\n' "$PRIMARY_FACTS" "$FACTS_OUTPUT"
261
+ else
262
+ printf '%s\n' "$PRIMARY_FACTS"
263
+ fi
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env bash
2
+ # Shared bounded reads for JSONL transcripts on hot hook paths.
3
+
4
+ HQ_TRANSCRIPT_TAIL_MAX_BYTES=1048576
5
+
6
+ # hq_transcript_size <path>
7
+ # Report the file's exact byte size from filesystem metadata without reading
8
+ # its contents.
9
+ hq_transcript_size() {
10
+ [ "$#" -eq 1 ] || return 2
11
+ local path="$1" size=""
12
+ size="$(stat -L -c %s "$path" 2>/dev/null || true)"
13
+ case "$size" in
14
+ ''|*[!0-9]*)
15
+ size="$(stat -L -f %z "$path" 2>/dev/null || true)"
16
+ ;;
17
+ esac
18
+ case "$size" in
19
+ ''|*[!0-9]*) return 1 ;;
20
+ esac
21
+ printf '%s\n' "$size"
22
+ }
23
+
24
+ # hq_transcript_tail <path> [max_bytes]
25
+ # Keep a complete-line suffix of a JSONL transcript. The extra byte is a
26
+ # boundary sentinel: when the tail starts mid-record, sed drops that partial
27
+ # first line. If the byte window contains no complete line because the newest
28
+ # record is oversized, return only that final record.
29
+ hq_transcript_tail() {
30
+ [ "$#" -ge 1 ] && [ "$#" -le 2 ] || return 2
31
+ local path="$1" max_bytes="${2:-$HQ_TRANSCRIPT_TAIL_MAX_BYTES}" size
32
+ case "$max_bytes" in
33
+ ''|*[!0-9]*) return 2 ;;
34
+ esac
35
+ [ "$max_bytes" -gt 0 ] || return 2
36
+ size="$(hq_transcript_size "$path")" || {
37
+ printf '%s\n' "hq_transcript_tail: cannot read transcript size: $path" >&2
38
+ return 1
39
+ }
40
+ if [ "$size" -le "$((max_bytes + 1))" ]; then
41
+ cat "$path"
42
+ return $?
43
+ fi
44
+ local bounded_tail
45
+ bounded_tail="$(tail -c "$((max_bytes + 1))" "$path" | sed '1d'; printf '.')"
46
+ bounded_tail="${bounded_tail%.}"
47
+ if [ -n "$bounded_tail" ]; then
48
+ printf '%s' "$bounded_tail"
49
+ else
50
+ # The newest record itself may be larger than the byte window. Keep that
51
+ # complete record so safety and capture hooks can still inspect it.
52
+ tail -n 1 "$path"
53
+ fi
54
+ }
55
+
56
+ # hq_transcript_tail_with_latest_assistant <path> [max_bytes] [scan_cap_bytes]
57
+ # Return the normal bounded suffix plus the latest complete assistant record
58
+ # when later metadata records have pushed it outside that suffix. The fallback
59
+ # scan is bounded; status 3 means the latest assistant could not be verified
60
+ # within the cap and security-sensitive callers must fail closed.
61
+ hq_transcript_tail_with_latest_assistant() {
62
+ [ "$#" -ge 1 ] && [ "$#" -le 3 ] || return 2
63
+ local path="$1" max_bytes="${2:-$HQ_TRANSCRIPT_TAIL_MAX_BYTES}"
64
+ local scan_cap="${3:-16777216}" size normal_tail assistant_record scan_tail
65
+ case "$scan_cap" in ''|*[!0-9]*) return 2 ;; esac
66
+ [ "$scan_cap" -gt 0 ] || return 2
67
+
68
+ normal_tail="$(hq_transcript_tail "$path" "$max_bytes")" || return $?
69
+ assistant_record="$(printf '%s\n' "$normal_tail" | jq -Rrc 'fromjson? | select(.type == "assistant")' | tail -n 1)"
70
+ if [ -n "$assistant_record" ]; then
71
+ [ -z "$normal_tail" ] || printf '%s\n' "$normal_tail"
72
+ return 0
73
+ fi
74
+
75
+ size="$(hq_transcript_size "$path")" || {
76
+ printf '%s\n' "hq_transcript_tail_with_latest_assistant: cannot read transcript size: $path" >&2
77
+ return 1
78
+ }
79
+ if [ "$size" -le "$scan_cap" ]; then
80
+ scan_tail="$(cat "$path"; printf '.')"
81
+ else
82
+ scan_tail="$(tail -c "$((scan_cap + 1))" "$path" | sed '1d'; printf '.')"
83
+ fi
84
+ scan_tail="${scan_tail%.}"
85
+ assistant_record="$(printf '%s\n' "$scan_tail" | jq -Rrc 'fromjson? | select(.type == "assistant")' | tail -n 1)"
86
+ if [ -n "$assistant_record" ]; then
87
+ printf '%s\n' "$assistant_record"
88
+ [ -z "$normal_tail" ] || printf '%s\n' "$normal_tail"
89
+ return 0
90
+ fi
91
+
92
+ if [ "$size" -gt "$scan_cap" ]; then
93
+ printf '%s\n' "hq_transcript_tail_with_latest_assistant: latest assistant record exceeds the ${scan_cap}-byte verification window" >&2
94
+ return 3
95
+ fi
96
+
97
+ [ -z "$normal_tail" ] || printf '%s\n' "$normal_tail"
98
+ }
@@ -0,0 +1,86 @@
1
+ # Shared source for non-literal text facts derived by derive-trigger-facts.sh
2
+ # and the adapter policy-vocabulary prefilter. Keep every text-derived fact
3
+ # here so the prefilter cannot drift from the real policy trigger path.
4
+ function hq_is_shared_branch(t) {
5
+ return t ~ /(^|[^a-z0-9_])(main|master|staging|production)([^a-z0-9_]|$)/ || t ~ /release\//
6
+ }
7
+
8
+ function hq_emit_derived_text_facts(t) {
9
+ if (t ~ /(^|[^a-z0-9_])aws_profile/ || t ~ /op:\/\/|\.env([^a-z0-9]|$)/) print "secret"
10
+ if (hq_is_shared_branch(t)) print "shared_branch"
11
+ if (t ~ /(^|[^a-z0-9])sk-[a-z0-9]/ \
12
+ || t ~ /(^|[^a-z0-9])(gh[opsur]_|github_pat_)[a-z0-9_]/ \
13
+ || t ~ /(^|[^a-z0-9])akia[a-z0-9][a-z0-9]/ \
14
+ || t ~ /(^|[^a-z0-9])xox[bpsa]-[a-z0-9]/ \
15
+ || t ~ /(^|[^a-z0-9])glpat-[a-z0-9]/ \
16
+ || t ~ /-----begin[a-z -]*private key/ \
17
+ || t ~ /(^|[^a-z0-9])bearer[ ][a-z0-9._-][a-z0-9._-][a-z0-9._-]/) {
18
+ print "apikey"
19
+ print "secret"
20
+ }
21
+ if (t ~ /successfully (merged|deployed|pushed|published|created)/ \
22
+ || t ~ /(deployment|deploy|build|release) (complete|completed|succeeded|ready)/ \
23
+ || t ~ /merged pull request/ \
24
+ || t ~ /pull request #?[0-9]+ .* merged/) print "completed"
25
+ }
26
+
27
+ {
28
+ if (NR > 1) text = text "\n"
29
+ text = text $0
30
+ }
31
+
32
+ END {
33
+ t = tolower(text)
34
+ if (mode == "derived") {
35
+ hq_emit_derived_text_facts(t)
36
+ if (hq_is_shared_branch(tolower(branch))) print "shared_branch"
37
+ exit
38
+ }
39
+ if (mode == "normalize") {
40
+ rest = t
41
+ while (match(rest, /[^[:space:]]+/)) {
42
+ fact = substr(rest, RSTART, RLENGTH)
43
+ rest = substr(rest, RSTART + RLENGTH)
44
+ if (!seen[fact]++) {
45
+ if (out != "") out = out " "
46
+ out = out fact
47
+ }
48
+ }
49
+ if (hq_is_shared_branch(tolower(branch)) && !seen["shared_branch"]++) {
50
+ if (out != "") out = out " "
51
+ out = out "shared_branch"
52
+ }
53
+ printf "%s", out
54
+ exit
55
+ }
56
+
57
+ # Open tokenization: every word token in the text becomes a fact. Tokens are
58
+ # letter-led and length >= 2; underscores and internal hyphens are retained.
59
+ tw = t
60
+ while (match(tw, /[a-z][a-z0-9_-]+/)) {
61
+ print substr(tw, RSTART, RLENGTH)
62
+ tw = substr(tw, RSTART + RLENGTH)
63
+ }
64
+ hq_emit_derived_text_facts(t)
65
+
66
+ # File references emit their basename and extension as facts.
67
+ tmp = t
68
+ while (match(tmp, "\\.?[a-z0-9_][a-z0-9_./-]*\\.[a-z][a-z0-9]+")) {
69
+ fn = substr(tmp, RSTART, RLENGTH)
70
+ tmp = substr(tmp, RSTART + RLENGTH)
71
+ bn = fn
72
+ sub(/.*\//, "", bn)
73
+ ext = bn
74
+ sub(/.*\./, "", ext)
75
+ print "." ext
76
+ print bn
77
+ }
78
+
79
+ # Slash-command mentions emit /command facts; paths such as repos/public do not.
80
+ tmp2 = " " t
81
+ while (match(tmp2, " /[a-z][a-z0-9-]*")) {
82
+ sc = substr(tmp2, RSTART + 1, RLENGTH - 1)
83
+ tmp2 = substr(tmp2, RSTART + RLENGTH)
84
+ print sc
85
+ }
86
+ }
@@ -5932,6 +5932,19 @@ export declare const COMMAND_CATALOG: readonly [{
5932
5932
  }];
5933
5933
  readonly options: readonly [];
5934
5934
  readonly subcommands: readonly [];
5935
+ }, {
5936
+ readonly name: "derive-trigger-facts";
5937
+ readonly description: "Build the open fact set for a hook trigger event";
5938
+ readonly aliases: readonly [];
5939
+ readonly hidden: false;
5940
+ readonly usage: "[args...]";
5941
+ readonly arguments: readonly [{
5942
+ readonly name: "args";
5943
+ readonly required: false;
5944
+ readonly variadic: true;
5945
+ }];
5946
+ readonly options: readonly [];
5947
+ readonly subcommands: readonly [];
5935
5948
  }, {
5936
5949
  readonly name: "share-suggestion-state";
5937
5950
  readonly description: "Manage hook share suggestion state";
@@ -7633,6 +7633,22 @@ export const COMMAND_CATALOG = [
7633
7633
  "options": [],
7634
7634
  "subcommands": []
7635
7635
  },
7636
+ {
7637
+ "name": "derive-trigger-facts",
7638
+ "description": "Build the open fact set for a hook trigger event",
7639
+ "aliases": [],
7640
+ "hidden": false,
7641
+ "usage": "[args...]",
7642
+ "arguments": [
7643
+ {
7644
+ "name": "args",
7645
+ "required": false,
7646
+ "variadic": true
7647
+ }
7648
+ ],
7649
+ "options": [],
7650
+ "subcommands": []
7651
+ },
7636
7652
  {
7637
7653
  "name": "share-suggestion-state",
7638
7654
  "description": "Manage hook share suggestion state",
@@ -21,7 +21,8 @@ import { DEFAULT_HQ_ROOT, ensureCognitoToken, buildVaultConfig, } from "../utils
21
21
  import { resolveUploadAuthor } from "../utils/upload-author.js";
22
22
  import { companyFolderExceedsThreshold, emitNarrowHint, isStrictRefusal, resolveBannerLevel, resolveNarrowHintPresentationLevel, resolveNarrowHintMinBytes, } from "../lib/narrow-hint-banner.js";
23
23
  import { beginSyncHealthReport, } from "../utils/client-health.js";
24
- import { recordFirstWorkBearingSync } from "../lib/first-sync-next-step.js";
24
+ import { isWorkBearingSyncResult, recordFirstWorkBearingSync, } from "../lib/first-sync-next-step.js";
25
+ import { maybeShowCliInviteNudge } from "../lib/cli-invite-nudge.js";
25
26
  import { createPlanLimitRefusalTracker, describeRefusedCount, describeRefusedFiles, mergePlanLimitRefusals, renderPlanLimitRefusalLines, trackingProgressRecorder, } from "../lib/billing/plan-limit-refusals.js";
26
27
  import { formatPlanGateError, planGateErrorFromVaultError, } from "../utils/plan-gate-error.js";
27
28
  import { personalTargetedPushPathsEnabled } from "./personal-targeted-push-flag.js";
@@ -663,7 +664,7 @@ function readActiveCompany(hqRoot) {
663
664
  * scope. Returns `undefined` only when no company can be resolved at all (then
664
665
  * `sync()` resolves the active company itself and pulls `all`, as before).
665
666
  */
666
- async function resolveCliPullScope(client, companyRef, hqRoot) {
667
+ async function resolveCliPullScope(client, companyRef, hqRoot, onResolvedCompanyUid) {
667
668
  const ref = companyRef ?? readActiveCompany(hqRoot);
668
669
  if (!ref)
669
670
  return undefined;
@@ -676,6 +677,7 @@ async function resolveCliPullScope(client, companyRef, hqRoot) {
676
677
  const direct = memberships.find((m) => m.companyUid === ref || m.membershipKey === ref);
677
678
  if (direct) {
678
679
  companyUid = direct.companyUid;
680
+ onResolvedCompanyUid?.(companyUid);
679
681
  const ent = await client.entity.get(direct.companyUid).catch(() => null);
680
682
  if (ent?.slug)
681
683
  slug = ent.slug;
@@ -686,6 +688,7 @@ async function resolveCliPullScope(client, companyRef, hqRoot) {
686
688
  if (ent?.slug === ref) {
687
689
  companyUid = m.companyUid;
688
690
  slug = ent.slug;
691
+ onResolvedCompanyUid?.(companyUid);
689
692
  break;
690
693
  }
691
694
  }
@@ -1653,6 +1656,7 @@ async function runNowSingle(hqRoot, company, personal, message, onConflict, mode
1653
1656
  let journalSlug;
1654
1657
  let pushPaths;
1655
1658
  let pushSlug;
1659
+ let nudgeResolvedCompanyUid;
1656
1660
  if (personal) {
1657
1661
  const client = new VaultClient(vaultConfig);
1658
1662
  targetCompany = await resolveCanonicalPersonUid({
@@ -1685,7 +1689,7 @@ async function runNowSingle(hqRoot, company, personal, message, onConflict, mode
1685
1689
  let nowPushPrefixSet;
1686
1690
  if (!personalMode) {
1687
1691
  const scopeClient = new VaultClient(vaultConfig);
1688
- const pushScope = await resolveCliPullScope(scopeClient, targetCompany, hqRoot);
1692
+ const pushScope = await resolveCliPullScope(scopeClient, targetCompany ?? pushSlug, hqRoot, (companyUid) => { nudgeResolvedCompanyUid = companyUid; });
1689
1693
  nowPushPrefixSet = pushScope?.prefixSet;
1690
1694
  }
1691
1695
  const pushProgressCompany = personalMode ? null : (pushSlug ?? null);
@@ -1741,7 +1745,7 @@ async function runNowSingle(hqRoot, company, personal, message, onConflict, mode
1741
1745
  // without burning a sync.
1742
1746
  const narrowHintLevel = resolveBannerLevel();
1743
1747
  let resolvedMode;
1744
- let resolvedCompanyUid;
1748
+ let resolvedCompanyUid = nudgeResolvedCompanyUid;
1745
1749
  if (!personalMode && targetCompany) {
1746
1750
  try {
1747
1751
  const client = new VaultClient(vaultConfig);
@@ -1845,6 +1849,21 @@ async function runNowSingle(hqRoot, company, personal, message, onConflict, mode
1845
1849
  throw new SyncExitError(1, { health: "succeeded" });
1846
1850
  }
1847
1851
  console.log(chalk.green("\n✓ Sync now complete"));
1852
+ if (!personalMode &&
1853
+ nudgeResolvedCompanyUid &&
1854
+ (isWorkBearingSyncResult(pushResult) || isWorkBearingSyncResult(pullResult))) {
1855
+ try {
1856
+ await maybeShowCliInviteNudge({
1857
+ companyUid: nudgeResolvedCompanyUid,
1858
+ companyRef: pushSlug ?? targetCompany ?? "",
1859
+ accessToken,
1860
+ });
1861
+ }
1862
+ catch (error) {
1863
+ const errorClass = error instanceof Error ? error.name : "unknown";
1864
+ console.error(`hq: Could not show the teammate invite prompt (${errorClass}); sync is complete.`);
1865
+ }
1866
+ }
1848
1867
  }
1849
1868
  catch (err) {
1850
1869
  if (err instanceof SyncExitError)
@@ -0,0 +1,3 @@
1
+ import { Command } from "commander";
2
+ export declare function registerCoreDeriveTriggerFactsCommand(core: Command): void;
3
+ //# sourceMappingURL=core-derive-trigger-facts-command.d.ts.map
@@ -0,0 +1,47 @@
1
+ import { resolveLiveRoot } from "../utils/hq-roots.js";
2
+ import { runBundledScript } from "../utils/run-bundled-script.js";
3
+ import { describeCoreCommand } from "./core-command-catalog.js";
4
+ import { coreNativeHookHelpersEnabled } from "./core-native-hook-helpers-flag.js";
5
+ import { DERIVE_TRIGGER_FACTS_ASSET, runDeriveTriggerFacts, } from "./core-derive-trigger-facts-native.js";
6
+ export function registerCoreDeriveTriggerFactsCommand(core) {
7
+ const liveRoot = () => resolveLiveRoot({ hqRoot: core.opts().hqRoot });
8
+ const command = core
9
+ .command("derive-trigger-facts")
10
+ .description("Build the open fact set for a hook trigger event")
11
+ .allowUnknownOption()
12
+ .allowExcessArguments()
13
+ .helpOption(false)
14
+ .argument("[args...]")
15
+ .action(async (args = [], _options, commandContext) => {
16
+ if (!await coreNativeHookHelpersEnabled()) {
17
+ const root = liveRoot();
18
+ const result = runBundledScript({
19
+ asset: DERIVE_TRIGGER_FACTS_ASSET,
20
+ args: commandContext.args.length ? commandContext.args : args,
21
+ cwd: root,
22
+ hqRoot: root,
23
+ });
24
+ if (result.code !== 0)
25
+ process.exit(result.code);
26
+ return;
27
+ }
28
+ const chunks = [];
29
+ for await (const chunk of process.stdin)
30
+ chunks.push(chunk);
31
+ const root = liveRoot();
32
+ const result = runDeriveTriggerFacts({
33
+ argv: commandContext.args.length ? commandContext.args : args,
34
+ stdin: Buffer.concat(chunks).toString("utf8"),
35
+ cwd: process.cwd(),
36
+ hqRoot: root,
37
+ });
38
+ if (result.stdout.length)
39
+ process.stdout.write(result.stdout);
40
+ if (result.stderr.length)
41
+ process.stderr.write(result.stderr);
42
+ if (result.code !== 0)
43
+ process.exit(result.code);
44
+ });
45
+ describeCoreCommand(command, { execution: "native", asset: DERIVE_TRIGGER_FACTS_ASSET, root: "live" });
46
+ }
47
+ //# sourceMappingURL=core-derive-trigger-facts-command.js.map
@@ -0,0 +1,21 @@
1
+ export declare const DERIVE_TRIGGER_FACTS_ASSET = "core/scripts/derive-trigger-facts.sh";
2
+ export type DeriveTriggerRunOptions = {
3
+ argv: readonly string[];
4
+ stdin: string;
5
+ cwd: string;
6
+ hqRoot: string;
7
+ env?: Record<string, string | undefined>;
8
+ /** Test seam for the shell's `git -C ... rev-parse` lookup. */
9
+ gitBranch?: string;
10
+ /** Test seam for `command -v jq`; production resolves jq from PATH. */
11
+ jqAvailable?: boolean;
12
+ };
13
+ export type DeriveTriggerRunResult = {
14
+ code: number;
15
+ stdout: Buffer;
16
+ stderr: Buffer;
17
+ };
18
+ /** Run the native handler contract, retaining exact bytes for differential tests. */
19
+ export declare function runDeriveTriggerFacts(options: DeriveTriggerRunOptions): DeriveTriggerRunResult;
20
+ export declare function runDeriveTriggerFactsFast(argv: readonly string[], stdin: string, cwd: string, hqRoot: string | undefined): number;
21
+ //# sourceMappingURL=core-derive-trigger-facts-native.d.ts.map