@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 +4 -0
- package/assets/scaffold/core/scripts/derive-trigger-facts.sh +263 -0
- package/assets/scaffold/core/scripts/lib/transcript-tail.sh +98 -0
- package/assets/scaffold/core/scripts/lib/trigger-fact-text.awk +86 -0
- package/dist/command-catalog.generated.d.ts +13 -0
- package/dist/command-catalog.generated.js +16 -0
- package/dist/commands/cloud.js +23 -4
- package/dist/commands/core-derive-trigger-facts-command.d.ts +3 -0
- package/dist/commands/core-derive-trigger-facts-command.js +47 -0
- package/dist/commands/core-derive-trigger-facts-native.d.ts +21 -0
- package/dist/commands/core-derive-trigger-facts-native.js +492 -0
- package/dist/commands/core.js +6 -0
- package/dist/fast-scaffold.js +13 -0
- package/dist/lib/cli-invite-nudge.d.ts +25 -0
- package/dist/lib/cli-invite-nudge.js +152 -0
- package/dist/lib/first-sync-next-step.d.ts +10 -1
- package/dist/main.js +3 -1
- package/dist/utils/cli-telemetry.d.ts +11 -0
- package/dist/utils/cli-telemetry.js +76 -0
- package/dist/utils/run-bundled-script.d.ts +7 -4
- package/dist/utils/run-bundled-script.js +47 -12
- package/package.json +1 -1
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",
|
package/dist/commands/cloud.js
CHANGED
|
@@ -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,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
|