crewforth 3.4.0 → 3.5.0-rc.2
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 +273 -0
- package/README.md +3 -2
- package/VERSION +1 -1
- package/adopt.sh +40 -5
- package/bin/cli.js +138 -2
- package/kit/AGENT_TEMPLATE.md +15 -0
- package/kit/CLAUDE.md +50 -18
- package/kit/agents/crew-backend-expert.md +4 -3
- package/kit/agents/crew-commit-agent.md +2 -0
- package/kit/agents/crew-database-expert.md +2 -1
- package/kit/agents/crew-devops-expert.md +1 -0
- package/kit/agents/crew-docs-agent.md +3 -1
- package/kit/agents/crew-frontend-expert.md +2 -0
- package/kit/agents/crew-performance-expert.md +7 -2
- package/kit/agents/crew-planner.md +15 -3
- package/kit/agents/crew-privacy-agent.md +2 -0
- package/kit/agents/crew-review-agent.md +8 -1
- package/kit/agents/crew-security-expert.md +4 -3
- package/kit/agents/crew-test-expert.md +6 -2
- package/kit/eval/doctor.sh +201 -1
- package/kit/eval/gate-report.sh +28 -0
- package/kit/eval/golden-holdout.txt +2 -0
- package/kit/eval/golden-routing.txt +17 -0
- package/kit/eval/lib/crew-map/contracts.mjs +77 -0
- package/kit/eval/lib/crew-map/crew-map.sh +2 -1
- package/kit/eval/lib/crew-map/detect.mjs +2 -1
- package/kit/eval/lib/crew-map/narrative.mjs +14 -3
- package/kit/eval/lib/crew-map/project.mjs +45 -6
- package/kit/eval/lib/crew-map/scan.mjs +153 -14
- package/kit/eval/lib/findings/findings.schema.json +11 -1
- package/kit/eval/lib/findings/findings.sh +24 -1
- package/kit/eval/lib/hook-inject.sh +159 -0
- package/kit/eval/lib/lockfiles.sh +71 -0
- package/kit/eval/lib/payload-check.sh +29 -0
- package/kit/eval/lib/preview.sh +3 -3
- package/kit/eval/lib/review-bench/bench.mjs +14 -2
- package/kit/eval/lib/review-prepass/prepass.mjs +32 -3
- package/kit/eval/lib/settings-json.awk +18 -0
- package/kit/eval/lib/statusline.sh +22 -5
- package/kit/eval/lib/todo.awk +167 -0
- package/kit/eval/lib/todo.sh +120 -0
- package/kit/eval/lib/untrusted-text.awk +38 -0
- package/kit/eval/scan-skill.sh +67 -20
- package/kit/eval/smoke-test.sh +1759 -41
- package/kit/eval/utilization.sh +77 -3
- package/kit/hooks/agent-outcome.sh +9 -1
- package/kit/hooks/board-sync.sh +49 -3
- package/kit/hooks/board.sh +8 -8
- package/kit/hooks/branch-fresh.sh +101 -0
- package/kit/hooks/floor-blocklist.txt +19 -1
- package/kit/hooks/guard-bash.sh +92 -8
- package/kit/hooks/guard-question.sh +2 -1
- package/kit/hooks/loop-guard.sh +445 -0
- package/kit/hooks/map-stale.sh +437 -0
- package/kit/hooks/pre-commit +48 -7
- package/kit/hooks/precompact-snapshot.sh +102 -6
- package/kit/hooks/prompt-approval.sh +50 -14
- package/kit/hooks/route-hint.sh +36 -7
- package/kit/hooks/secret-blocklist.txt +55 -1
- package/kit/hooks/session-guard.sh +45 -15
- package/kit/hooks/session-rehydrate.sh +34 -11
- package/kit/hooks/session-stats.sh +169 -8
- package/kit/hooks/session-update-check.sh +5 -1
- package/kit/hooks/skill-trust.sh +23 -14
- package/kit/hooks/turn-check.sh +190 -0
- package/kit/owned-blobs.tsv +4 -4
- package/kit/settings.json +64 -0
- package/kit/skills/a11y/SKILL.md +50 -3
- package/kit/skills/a11y/references/recipes.md +41 -0
- package/kit/skills/adr/SKILL.md +14 -2
- package/kit/skills/api-design/SKILL.md +6 -1
- package/kit/skills/api-design/references/idempotency.md +39 -0
- package/kit/skills/brainstorm/SKILL.md +4 -2
- package/kit/skills/brainstorm/references/asking.md +47 -0
- package/kit/skills/ci-pipeline/SKILL.md +11 -1
- package/kit/skills/ci-pipeline/references/agent-job.md +33 -0
- package/kit/skills/commit-message/SKILL.md +9 -3
- package/kit/skills/confidence-check/SKILL.md +9 -7
- package/kit/skills/confidence-check/references/source-check.md +37 -0
- package/kit/skills/crew-approve/SKILL.md +3 -0
- package/kit/skills/crew-audit/SKILL.md +63 -0
- package/kit/skills/crew-board/SKILL.md +2 -2
- package/kit/skills/crew-code-review/SKILL.md +27 -4
- package/kit/skills/crew-code-review/references/panel-mode.md +9 -9
- package/kit/skills/crew-code-review/references/simplify.md +28 -0
- package/kit/skills/crew-doctor/SKILL.md +3 -2
- package/kit/skills/crew-gates/SKILL.md +3 -0
- package/kit/skills/crew-gates/references/enforcers.md +30 -0
- package/kit/skills/crew-interview/SKILL.md +42 -0
- package/kit/skills/crew-map/SKILL.md +26 -6
- package/kit/skills/crew-plan/SKILL.md +10 -2
- package/kit/skills/crew-pr/SKILL.md +57 -0
- package/kit/skills/crew-pr/references/checks.md +35 -0
- package/kit/skills/crew-pr/references/threads.md +23 -0
- package/kit/skills/crew-review/SKILL.md +34 -1
- package/kit/skills/crew-ship/SKILL.md +5 -2
- package/kit/skills/crew-skill/SKILL.md +26 -1
- package/kit/skills/crew-todo/SKILL.md +31 -0
- package/kit/skills/crew-update/SKILL.md +4 -1
- package/kit/skills/db-migration/SKILL.md +15 -0
- package/kit/skills/db-migration/references/expand-contract.md +52 -0
- package/kit/skills/dependency-audit/SKILL.md +34 -1
- package/kit/skills/dependency-audit/scripts/record.sh +37 -0
- package/kit/skills/dependency-upgrade/SKILL.md +29 -1
- package/kit/skills/deploy/SKILL.md +18 -0
- package/kit/skills/deploy/references/topology-a.md +7 -0
- package/kit/skills/deploy/references/topology-b.md +2 -2
- package/kit/skills/docs-writer/SKILL.md +37 -2
- package/kit/skills/eval-grader/SKILL.md +9 -0
- package/kit/skills/eval-grader/references/method.md +26 -0
- package/kit/skills/frontend/SKILL.md +5 -2
- package/kit/skills/frontend/references/from-design.md +29 -0
- package/kit/skills/frontend/references/page-metadata.md +17 -0
- package/kit/skills/frontend/references/verify-contract.md +18 -1
- package/kit/skills/frontend/references/worst-data.md +35 -0
- package/kit/skills/frontend-design/SKILL.md +20 -4
- package/kit/skills/frontend-design/references/design-review.md +21 -0
- package/kit/skills/frontend-design/references/detection.md +44 -0
- package/kit/skills/frontend-design/references/motion.md +54 -0
- package/kit/skills/frontend-design/references/ui-copy.md +35 -0
- package/kit/skills/frontend-rn-expo/SKILL.md +3 -0
- package/kit/skills/handoff/SKILL.md +28 -3
- package/kit/skills/handoff/references/cold-reader.md +13 -6
- package/kit/skills/i18n-integrity/SKILL.md +3 -0
- package/kit/skills/iterate/SKILL.md +7 -2
- package/kit/skills/mcp-builder/SKILL.md +21 -4
- package/kit/skills/mcp-builder/references/building.md +74 -13
- package/kit/skills/mcp-builder/references/cli-for-agents.md +26 -0
- package/kit/skills/observability/SKILL.md +13 -1
- package/kit/skills/performance/SKILL.md +25 -1
- package/kit/skills/performance/references/bottlenecks.md +11 -0
- package/kit/skills/performance/references/web-vitals.md +31 -0
- package/kit/skills/red-team/SKILL.md +25 -3
- package/kit/skills/reflect/SKILL.md +7 -2
- package/kit/skills/release/SKILL.md +13 -0
- package/kit/skills/security-scan/SKILL.md +6 -2
- package/kit/skills/security-scan/references/ai-agents.md +16 -0
- package/kit/skills/security-scan/references/fronts.md +59 -2
- package/kit/skills/security-scan/references/reporting.md +13 -0
- package/kit/skills/security-scan/references/verify.md +9 -0
- package/kit/skills/sonarqube-check/SKILL.md +32 -2
- package/kit/skills/sonarqube-check/references/ratchet.md +32 -0
- package/kit/skills/spec-planning/SKILL.md +32 -8
- package/kit/skills/systematic-debugging/SKILL.md +15 -7
- package/kit/skills/systematic-debugging/references/techniques.md +5 -0
- package/kit/skills/teamboard/SKILL.md +3 -3
- package/kit/skills/testing/SKILL.md +18 -3
- package/kit/skills/testing/references/browser-headless.md +21 -0
- package/kit/skills/token-budget/SKILL.md +26 -5
- package/kit/skills/worktree/SKILL.md +13 -5
- package/kit/studio/server/lib/session.js +23 -2
- package/kit/studio/server/lib/worktree-changes.js +64 -0
- package/kit/studio/web/agent-spend.js +32 -0
- package/kit/studio/web/app.js +245 -236
- package/kit/studio/web/approvals.js +14 -13
- package/kit/studio/web/ask.js +10 -9
- package/kit/studio/web/canvas.js +56 -54
- package/kit/studio/web/chat.js +41 -41
- package/kit/studio/web/convo.js +33 -16
- package/kit/studio/web/dock.js +20 -19
- package/kit/studio/web/graph-plan.js +62 -22
- package/kit/studio/web/i18n-tr.js +760 -0
- package/kit/studio/web/i18n.js +79 -0
- package/kit/studio/web/index.html +1 -0
- package/kit/studio/web/inspect.js +27 -24
- package/kit/studio/web/list-plan.js +16 -15
- package/kit/studio/web/list.js +6 -5
- package/kit/studio/web/liveness.js +11 -9
- package/kit/studio/web/map-plan.js +86 -36
- package/kit/studio/web/map.js +85 -72
- package/kit/studio/web/models-plan.js +47 -42
- package/kit/studio/web/models.js +21 -20
- package/kit/studio/web/names.js +13 -10
- package/kit/studio/web/nav.js +34 -33
- package/kit/studio/web/newsession.js +25 -24
- package/kit/studio/web/style.css +2 -0
- package/kit/studio/web/theme.js +5 -2
- package/kit/studio/web/timeline-plan.js +19 -18
- package/kit/studio/web/timeline.js +23 -22
- package/kit/studio/web/usage.js +36 -32
- package/package.json +5 -4
- package/start.sh +11 -2
|
@@ -38,7 +38,7 @@ if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
|
|
38
38
|
BR="$(git rev-parse --abbrev-ref HEAD 2>/dev/null)" || BR=""
|
|
39
39
|
[ -n "$BR" ] && LINES+=("branch $BR")
|
|
40
40
|
# quotePath off: a name in Turkish (or any non-ASCII) is given as written, not as octal escapes (seen on Windows).
|
|
41
|
-
CH="$(git -c core.quotePath=false status --porcelain 2>/dev/null)" || CH=""
|
|
41
|
+
CH="$(git -c core.fsmonitor= -c core.quotePath=false status --porcelain 2>/dev/null)" || CH=""
|
|
42
42
|
if [ -n "$CH" ]; then
|
|
43
43
|
n=0; names=""
|
|
44
44
|
while IFS= read -r l; do n=$((n+1)); [ "$n" -le 5 ] && names="${names:+$names, }${l:3}"; done <<< "$CH"
|
|
@@ -49,7 +49,11 @@ if [ -f "$LIB/gate-log.sh" ]; then
|
|
|
49
49
|
. "$LIB/gate-log.sh"; crew_gate_log_read
|
|
50
50
|
if [ -n "${_CREW_GL:-}" ] && [ -f "$_CREW_GL" ]; then
|
|
51
51
|
last=""; while IFS= read -r l; do case "$l" in BLOCK$'\t'*) last="$l" ;; esac; done < "$_CREW_GL"
|
|
52
|
-
|
|
52
|
+
# Head AND tail of a long reason: the cause of a refusal is often the last words of it (the path, the value), and
|
|
53
|
+
# a head-only cut dropped exactly that part.
|
|
54
|
+
if [ -n "$last" ]; then IFS=$'\t' read -r _ rule why _ <<< "$last"
|
|
55
|
+
[ "${#why}" -gt 120 ] && why="${why:0:70} … ${why: -45}"
|
|
56
|
+
LINES+=("the last refusal in the gate log: ${rule} ${why}"); fi
|
|
53
57
|
fi
|
|
54
58
|
fi
|
|
55
59
|
fnd=""
|
|
@@ -60,16 +64,108 @@ for f in .crewforth/*.json; do
|
|
|
60
64
|
done
|
|
61
65
|
[ -n "$fnd" ] && LINES+=("findings on record: $fnd")
|
|
62
66
|
[ -s docs/SESSION_STATE.md ] && LINES+=("a handover is at docs/SESSION_STATE.md")
|
|
63
|
-
[ "${#LINES[@]}" -gt 0 ] || exit 0
|
|
64
67
|
|
|
65
|
-
|
|
66
|
-
|
|
68
|
+
# ---- what the transcript says: the user's own words, and the skills in use ----
|
|
69
|
+
# A compaction summary retells the task, and a retelling can soften a hard "do not". So the first prompt and the
|
|
70
|
+
# last three are kept as the user typed them, each cut at about 300 characters. And a skill's text is not always
|
|
71
|
+
# brought back after a compaction (measured on local transcripts: 3 of 12 compactions that followed a Skill call
|
|
72
|
+
# re-attached nothing), so the skill names are kept too. ONE grep over the transcript; the rest is builtins.
|
|
73
|
+
# A prompt line in a transcript is already a JSON string, so it is stored as it is and never re-escaped; a cut
|
|
74
|
+
# that splits an escape or a multibyte character is trimmed back to a whole one. Skipped: a prompt that opens with
|
|
75
|
+
# a tag (command output, a notification, pasted content), a meta, sidechain or compaction-summary record, and any
|
|
76
|
+
# prompt a secret pattern matches. <private>…</private> becomes [redacted], as the handover does.
|
|
77
|
+
clip(){ local LC_ALL=C p="$1" t k c n o=""
|
|
78
|
+
# Left to right, one pass: each <private> to the NEXT </private> becomes [redacted]; an unclosed one hides the rest.
|
|
79
|
+
# (Cutting at the first <private> and the first </private> separately loops forever on "</private> … <private>x</private>".)
|
|
80
|
+
while case "$p" in *'<private>'*) true ;; *) false ;; esac; do
|
|
81
|
+
o="$o${p%%<private>*}[redacted]"; p="${p#*<private>}"
|
|
82
|
+
case "$p" in *'</private>'*) p="${p#*</private>}" ;; *) p="" ;; esac
|
|
83
|
+
done; p="$o$p"
|
|
84
|
+
if [ "${#p}" -gt 300 ]; then
|
|
85
|
+
p="${p:0:300}"
|
|
86
|
+
case "$p" in *\\u|*\\u?|*\\u??|*\\u???) p="${p%\\u*}" ;; esac
|
|
87
|
+
t="${p##*[!\\]}"; [ $(( ${#t} % 2 )) = 1 ] && p="${p%\\}"
|
|
88
|
+
# A UTF-8 character cut in half: walk back over continuation bytes to the lead byte, and drop the
|
|
89
|
+
# sequence only if it is shorter than its lead byte says (C locale: these compare as bytes).
|
|
90
|
+
k=0
|
|
91
|
+
while [ "$k" -lt 4 ] && [ "$k" -lt "${#p}" ]; do
|
|
92
|
+
c="${p: -$((k+1)):1}"
|
|
93
|
+
[[ "$c" < $'\x80' ]] && break
|
|
94
|
+
if [[ "$c" > $'\xbf' ]]; then
|
|
95
|
+
n=2; [[ "$c" > $'\xdf' ]] && n=3; [[ "$c" > $'\xef' ]] && n=4
|
|
96
|
+
[ $((k+1)) -lt "$n" ] && p="${p:0:$((${#p}-k-1))}"
|
|
97
|
+
break
|
|
98
|
+
fi
|
|
99
|
+
k=$((k+1))
|
|
100
|
+
done
|
|
101
|
+
p="$p ...(cut)"
|
|
102
|
+
fi
|
|
103
|
+
CLIP="$p"; }
|
|
104
|
+
TP="$(field transcript_path)" || TP=""
|
|
105
|
+
TP="${TP//\\\\//}"; TP="${TP//\\//}"
|
|
106
|
+
SKILLS=""; PROMPTS=""
|
|
107
|
+
if [ -n "$TP" ] && [ -f "$TP" ]; then
|
|
108
|
+
first=""; p1=""; p2=""; p3=""; sk=" " # " a b ": a name moves to the end each time it is called again
|
|
109
|
+
while IFS= read -r l; do
|
|
110
|
+
case "$l" in
|
|
111
|
+
*'"name":"Skill","input":{"skill":"'*)
|
|
112
|
+
s="${l#*\"name\":\"Skill\",\"input\":\{\"skill\":\"}"; s="${s%%\"*}"
|
|
113
|
+
case "$s" in ''|*[!A-Za-z0-9:._-]*) continue ;; esac
|
|
114
|
+
sk="${sk// $s / }$s " ;;
|
|
115
|
+
*'"isMeta":true'*|*'"isSidechain":true'*|*'"isCompactSummary":true'*) ;;
|
|
116
|
+
*'"type":"user","message":{"role":"user","content":"'*)
|
|
117
|
+
v="${l#*\"type\":\"user\",\"message\":\{\"role\":\"user\",\"content\":\"}"
|
|
118
|
+
# Only the first ~300 bytes are kept, so a pasted megabyte is cut to 1200 first: bash's pattern removals
|
|
119
|
+
# on the whole string grow faster than its length (measured: 1 MB took 8 s, 2 MB passed the 30 s timeout).
|
|
120
|
+
# A prompt longer than that keeps no end marker and is cut where clip cuts it.
|
|
121
|
+
long=0; [ "${#v}" -gt 1200 ] && { v="${v:0:1200}"; long=1; }
|
|
122
|
+
# The string ends where the message object closes: before more record fields, or at the line's end.
|
|
123
|
+
case "$v" in *'"},"'*) v="${v%%\"\},\"*}"; long=0 ;; *'"}}'*) v="${v%\"\}\}*}"; long=0 ;; *) [ "$long" = 1 ] || continue ;; esac
|
|
124
|
+
case "$v" in ''|'<'*|'[Request interrupted'*) continue ;; esac
|
|
125
|
+
if [ "$long" = 0 ]; then t="${v##*[!\\]}"; [ $(( ${#t} % 2 )) = 1 ] && continue; fi # the end was an escaped quote: not a whole prompt
|
|
126
|
+
[ -z "$first" ] && { first="$v"; continue; }
|
|
127
|
+
p1="$p2"; p2="$p3"; p3="$v" ;;
|
|
128
|
+
esac
|
|
129
|
+
# ONE awk picks the lines AND shortens them before bash sees them: bash's `read` takes a pipe one byte at a time,
|
|
130
|
+
# so a pasted 8 MB prompt cost 24 s on Git Bash (3 s per MB; PreCompact's timeout is 30 s) even though only the
|
|
131
|
+
# first 1200 bytes are kept. A Skill call becomes its own short piece; a prompt keeps 1400 bytes from where its
|
|
132
|
+
# record names it (more than the 1200 used, so an end marker in reach is kept), and the record flags that come
|
|
133
|
+
# after a long prompt are carried behind it, where the skip above still reads them.
|
|
134
|
+
done < <(LC_ALL=C awk '
|
|
135
|
+
{ i = index($0, "\"name\":\"Skill\",\"input\":{\"skill\":\""); if (i) print substr($0, i, 300)
|
|
136
|
+
j = index($0, "\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"")
|
|
137
|
+
if (j && substr($0, j + 50, 1) != "<") {
|
|
138
|
+
f = ""
|
|
139
|
+
if (index($0, "\"isMeta\":true")) f = f " \"isMeta\":true"
|
|
140
|
+
if (index($0, "\"isSidechain\":true")) f = f " \"isSidechain\":true"
|
|
141
|
+
if (index($0, "\"isCompactSummary\":true")) f = f " \"isCompactSummary\":true"
|
|
142
|
+
print substr($0, j, 1400) f } }' "$TP" 2>/dev/null)
|
|
143
|
+
sk="${sk# }"; sk="${sk% }"; SKILLS="${sk// /, }"
|
|
144
|
+
# The four candidates go through the secret patterns in ONE grep; a line it names is dropped whole.
|
|
145
|
+
BL="$_here/secret-blocklist.txt"; keep=(); bad=" "; i=0
|
|
146
|
+
# Fails CLOSED: no pattern file, or a grep that errors (rc 2), keeps no prompt at all.
|
|
147
|
+
hits="$(printf '%s\n' "$first" "$p1" "$p2" "$p3" | LC_ALL=C grep -nE -f <(LC_ALL=C grep -vE '^#|^$' "$BL" 2>/dev/null) 2>/dev/null)"; rc=$?
|
|
148
|
+
if [ ! -s "$BL" ] || [ "$rc" -gt 1 ]; then bad=" 1 2 3 4 "
|
|
149
|
+
else while IFS=: read -r n _; do bad="$bad$n "; done <<< "$hits"; fi
|
|
150
|
+
for v in "$first" "$p1" "$p2" "$p3"; do
|
|
151
|
+
i=$((i+1))
|
|
152
|
+
[ -n "$v" ] || continue
|
|
153
|
+
case "$bad" in *" $i "*) continue ;; esac
|
|
154
|
+
clip "$v"; keep+=("$CLIP")
|
|
155
|
+
done
|
|
156
|
+
for v in "${keep[@]+"${keep[@]}"}"; do PROMPTS="${PROMPTS:+$PROMPTS\\n}- $v"; done
|
|
157
|
+
fi
|
|
158
|
+
[ "${#LINES[@]}" -gt 0 ] || [ -n "$SKILLS$PROMPTS" ] || exit 0
|
|
159
|
+
|
|
160
|
+
SUM=""; for l in "${LINES[@]+"${LINES[@]}"}"; do SUM="${SUM:+$SUM; }$l"; done
|
|
161
|
+
# Cut at the cap, and say so: a summary that just stops reads as complete.
|
|
162
|
+
[ "${#SUM}" -gt 1800 ] && SUM="${SUM:0:1780} ...(truncated)"
|
|
67
163
|
|
|
68
164
|
# ---- where it goes ----
|
|
69
165
|
if [ -n "$GD" ] && [ -d "$GD" ]; then OUT="$GD/crewforth-session-snapshot.json"
|
|
70
166
|
else mkdir -p .claude/state 2>/dev/null || exit 0; OUT=".claude/state/session-snapshot.json"; fi
|
|
71
167
|
if [ "${BASH_VERSINFO[0]}" -ge 5 ] || { [ "${BASH_VERSINFO[0]}" -eq 4 ] && [ "${BASH_VERSINFO[1]}" -ge 2 ]; }; then printf -v NOW '%(%Y-%m-%dT%H:%M:%SZ)T' -1; else NOW="$(date -u +%Y-%m-%dT%H:%M:%SZ)"; fi
|
|
72
168
|
ESC="$(printf '%s\n' "$SUM" | LC_ALL=C awk -f "$LIB/json-string.awk" 2>/dev/null)" || exit 0
|
|
73
|
-
printf '{"session_id":"%s","trigger":"%s","at":"%s","summary":"%s"}\n' "$SID" "${TRIG//[!a-z]/}" "$NOW" "$ESC" > "$OUT.$$" 2>/dev/null && mv -f "$OUT.$$" "$OUT" 2>/dev/null
|
|
169
|
+
printf '{"session_id":"%s","trigger":"%s","at":"%s","skills":"%s","prompts":"%s","summary":"%s"}\n' "$SID" "${TRIG//[!a-z]/}" "$NOW" "$SKILLS" "$PROMPTS" "$ESC" > "$OUT.$$" 2>/dev/null && mv -f "$OUT.$$" "$OUT" 2>/dev/null
|
|
74
170
|
rm -f "$OUT.$$" 2>/dev/null
|
|
75
171
|
exit 0
|
|
@@ -69,6 +69,7 @@ _CREW_LOCALE="${LC_ALL:-${LC_CTYPE:-${LANG:-}}}"
|
|
|
69
69
|
case "$_CREW_LOCALE" in C|POSIX) _CREW_LOCALE="" ;; esac
|
|
70
70
|
export LC_ALL=C
|
|
71
71
|
# ---- /CREW-LOCALE
|
|
72
|
+
ASK_TREE=""; ASK_HEAD=""; ASK_BRANCH="" # what an approval was asked for (the question, or a refused command); _pa_record compares
|
|
72
73
|
# guard-schedule.sh SOURCES this file for one thing, _crew_appr_op: which text is an approval is decided here and
|
|
73
74
|
# nowhere else. Sourced, the file defines its functions and returns before anything is read or written.
|
|
74
75
|
_PA_LIB=0; [ "${BASH_SOURCE[0]}" != "$0" ] && _PA_LIB=1
|
|
@@ -605,11 +606,25 @@ _crew_q_pre(){ # the PreToolUse decision: `ask`, or a refusal (exit 2) that say
|
|
|
605
606
|
qno "in $PM, with a PermissionRequest hook that may be wired ($_PRH), only the typed approval counts." ;;
|
|
606
607
|
*) qno "in ${PM:-an unknown mode} this approval is not open (bypassPermissions and plan fail closed)." ;;
|
|
607
608
|
esac
|
|
608
|
-
case "${CLAUDE_CODE_SESSION_ATTENDED-}" in 0) qno "this session has nobody in front of it (it was started with -p, or
|
|
609
|
+
case "${CLAUDE_CODE_SESSION_ATTENDED-}" in 0) qno "this session has nobody in front of it (it was started with -p, by a program, or in the background with claude --bg or the agent view, which Claude Code counts as unattended even when you type into it; approve in a regular session, or commit in your own terminal)." ;; esac
|
|
609
610
|
case "$SID$_QTU" in *[!A-Za-z0-9._-]*) qno "the call carries no session or tool id this hook can bind the answer to." ;; esac
|
|
610
611
|
{ [ -n "$SID" ] && [ -n "$_QTU" ]; } || qno "the call carries no session or tool id this hook can bind the answer to."
|
|
611
612
|
_crew_prh_scan && qno "a PermissionRequest hook may be wired ($_PRH). Such a hook can answer the question before the user does, and the answer cannot be told from a click."
|
|
612
|
-
|
|
613
|
+
# WHAT THE QUESTION IS ABOUT, written down when it is asked: the tree the index writes and HEAD. The answer is
|
|
614
|
+
# recorded only if both are still the same, however long the user took (a question asked at night is answered in
|
|
615
|
+
# the morning); a change in between is a different thing from what the user was shown, so it is asked again.
|
|
616
|
+
local _qh _qt _qe _qb
|
|
617
|
+
_qh="$(git -C "$CWD" rev-parse --verify --quiet HEAD 2>/dev/null || echo NONE)"
|
|
618
|
+
_qb="$(git -C "$CWD" symbolic-ref --short -q HEAD 2>/dev/null)" || _qb=""; [ -n "$_qb" ] || _qb=-
|
|
619
|
+
case "$_qb" in *[!A-Za-z0-9._/+@-]*) qno "the branch name could not be written down." ;; esac
|
|
620
|
+
case "$_qh" in NONE) ;; *[!0-9a-f]*|'') qno "git could not name HEAD here." ;; esac
|
|
621
|
+
_qt="$(git -C "$CWD" -c core.fsmonitor= write-tree 2>/dev/null)"
|
|
622
|
+
case "$_qt" in *[!0-9a-f]*|'') qno "git could not write the tree of what is staged (an unresolved merge?)." ;; esac
|
|
623
|
+
if [ "$_qh" = NONE ]; then _qe=4b825dc642cb6eb9a060e54bf8d69288fbee4904; else _qe="$(git -C "$CWD" rev-parse --verify --quiet 'HEAD^{tree}' 2>/dev/null)"; fi
|
|
624
|
+
# NOTHING STAGED: every commit answer would be refused after the user gave it (field: 35 times in three projects,
|
|
625
|
+
# most of them "Commit + push" for a commit already made). So the question is not asked; a push alone is typed.
|
|
626
|
+
[ "$_qt" = "$_qe" ] && qno "nothing is staged, so there is no commit to approve. If the commit is already made and only the push is wanted, ask with no question: the user can type /crew-approve push as their whole message; to commit, stage the change with git add, show the commit message, then ask again." nohint
|
|
627
|
+
mkdir -p "$_QAD" 2>/dev/null && { printf 'sid=%s\ntool=%s\nts=%s\ntree=%s\nhead=%s\nbranch=%s\n' "$SID" "$_QTU" "$_QNOW" "$_qt" "$_qh" "$_qb" > "$ASKED"; } 2>/dev/null \
|
|
613
628
|
|| qno "the state folder could not be written, so the answer could not be bound to this question."
|
|
614
629
|
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"%s"}}\n' \
|
|
615
630
|
"Crewforth §4.4: only the person in front of this session answers the approval question."
|
|
@@ -653,7 +668,7 @@ _pa_record(){
|
|
|
653
668
|
# HONEST SCOPE: a session started with an emptied environment and driven through a pseudo-terminal for a second
|
|
654
669
|
# message passes both. guard-bash.sh refuses the forms of that it can read; a program that types is past it.
|
|
655
670
|
case "${CLAUDE_CODE_SESSION_ATTENDED-}" in
|
|
656
|
-
0) no "this session has nobody in front of it (it was started with -p, or
|
|
671
|
+
0) no "this session has nobody in front of it (it was started with -p, by a program, or in the background with claude --bg or the agent view, which Claude Code counts as unattended even when you type into it; approve in a regular session, or commit in your own terminal), and an approval is what a person types into their own session." ;;
|
|
657
672
|
esac
|
|
658
673
|
_json_slice "$INPUT" transcript_path >/dev/null; _json_unescape "$_JS" >/dev/null; TP="${_JU//\\//}"
|
|
659
674
|
{ [ -n "$TP" ] && [ -s "$TP" ]; } || no "this is the first message of this session, or the session keeps no transcript (one started from inside another session keeps none). An approval answers a commit message the session has shown: have it shown, then approve."
|
|
@@ -665,19 +680,26 @@ _pa_record(){
|
|
|
665
680
|
|
|
666
681
|
HEAD_NOW="$(git -C "$CWD" rev-parse --verify --quiet HEAD 2>/dev/null || echo NONE)"
|
|
667
682
|
case "$HEAD_NOW" in NONE) ;; *[!0-9a-f]*|'') no "git could not name HEAD here." ;; esac
|
|
683
|
+
[ -n "$ASK_HEAD" ] && [ "$HEAD_NOW" != "$ASK_HEAD" ] && no "HEAD moved after the approval was asked for, so the answer is not about this branch as it is now. Ask again."
|
|
684
|
+
if [ -n "$ASK_BRANCH" ]; then
|
|
685
|
+
_cb="$(git -C "$CWD" symbolic-ref --short -q HEAD 2>/dev/null)" || _cb=""; [ -n "$_cb" ] || _cb=-
|
|
686
|
+
[ "$_cb" = "$ASK_BRANCH" ] || no "the branch changed after the approval was asked for ($ASK_BRANCH, now $_cb). Ask again."
|
|
687
|
+
fi
|
|
668
688
|
TREE=""; BR=""; RM=""; URL=""
|
|
669
689
|
case "$OP" in commit*)
|
|
670
690
|
# THE TREE THE INDEX WRITES, not the text of the staged diff: what `git diff` prints is configurable
|
|
671
691
|
# (`diff.external`, a textconv filter), and with it two different staged changes can print the same text.
|
|
672
|
-
TREE="$(git -C "$CWD" write-tree 2>/dev/null)"
|
|
692
|
+
TREE="$(git -C "$CWD" -c core.fsmonitor= write-tree 2>/dev/null)"
|
|
673
693
|
case "$TREE" in *[!0-9a-f]*|'') no "git could not write the tree of what is staged (an unresolved merge?)." ;; esac
|
|
674
694
|
if [ "$HEAD_NOW" = NONE ]; then _ht=4b825dc642cb6eb9a060e54bf8d69288fbee4904 # the empty tree
|
|
675
695
|
else _ht="$(git -C "$CWD" rev-parse --verify --quiet 'HEAD^{tree}' 2>/dev/null)"; fi
|
|
676
696
|
# A commit+push with nothing staged is most often a push alone, answered with the option people pick first (field
|
|
677
697
|
# report, 3.2.1: twice in a row). It is not turned into a push approval, which the user did not give; the line
|
|
678
698
|
# names the one they can.
|
|
699
|
+
# What was asked about, or refused, is what is approved: the staged tree written down then is the one now.
|
|
700
|
+
[ -n "$ASK_TREE" ] && [ "$TREE" != "$ASK_TREE" ] && no "what is staged changed after the approval was asked for, so the answer is not about what is staged now. Show the commit message again and ask again."
|
|
679
701
|
if [ "$TREE" = "$_ht" ]; then
|
|
680
|
-
[ "$OP" = commit+push ] && no "nothing is staged, so there is no commit to approve. For a push of the branch as it is, the
|
|
702
|
+
[ "$OP" = commit+push ] && no "nothing is staged, so there is no commit to approve. For a push of the branch as it is, the user can type /crew-approve push; for a commit, stage it with git add, show the commit message, then ask again."
|
|
681
703
|
no "nothing is staged, so there is nothing to approve. Stage the change with git add, show the commit message, then ask again."
|
|
682
704
|
fi ;;
|
|
683
705
|
esac
|
|
@@ -735,20 +757,24 @@ _pa_record(){
|
|
|
735
757
|
# ---- THE APPROVAL QUESTION (PreToolUse and PostToolUse of AskUserQuestion) -----------------------------------
|
|
736
758
|
# Only the fixed question is this hook's business: any other question gets no decision from it, here or later.
|
|
737
759
|
_pa_question(){
|
|
738
|
-
local _as="" _at="" _au="" _al _an
|
|
760
|
+
local _as="" _at="" _au="" _al _an _aq="" _ah="" _ab=""
|
|
739
761
|
_crew_q_ctx
|
|
740
762
|
# PostToolUse: the user has answered. Recorded only if THIS call was asked by the half above.
|
|
741
763
|
SRC="answer to the approval question"; SRC_BY="by answering the approval question"; OP="a commit or a push"
|
|
742
764
|
[ -n "$SID" ] && [ -n "$_QTU" ] || no "the answer carries no session or tool id."
|
|
743
765
|
if [ -f "$ASKED" ]; then
|
|
744
766
|
while IFS= read -r _al || [ -n "$_al" ]; do
|
|
745
|
-
case "$_al" in sid=*) _as="${_al#sid=}" ;; tool=*) _au="${_al#tool=}" ;; ts=*) _at="${_al#ts=}" ;; esac
|
|
767
|
+
case "$_al" in sid=*) _as="${_al#sid=}" ;; tool=*) _au="${_al#tool=}" ;; ts=*) _at="${_al#ts=}" ;; tree=*) _aq="${_al#tree=}" ;; head=*) _ah="${_al#head=}" ;; branch=*) _ab="${_al#branch=}" ;; esac
|
|
746
768
|
done < "$ASKED"
|
|
747
769
|
fi
|
|
748
770
|
{ [ "$_as" = "$SID" ] && [ "$_au" = "$_QTU" ]; } || no "Crewforth did not ask this question (guard-question.sh, its PreToolUse half, is not wired or did not pass it), so the answer cannot be shown to have come from the user."
|
|
749
771
|
: > "$ASKED" 2>/dev/null # one question, one answer
|
|
750
772
|
case "$_at$_QNOW" in *[!0-9]*|'') no "the time of the question could not be read." ;; esac
|
|
751
|
-
[ $((_QNOW - _at)) -ge 0 ]
|
|
773
|
+
[ $((_QNOW - _at)) -ge 0 ] || no "the time of the question is in the future."
|
|
774
|
+
# A question written down with its tree and HEAD holds until the answer, however long that takes: _pa_record
|
|
775
|
+
# compares them. One written before that (no tree, no head) keeps the old 30 minutes.
|
|
776
|
+
if [ -n "$_aq" ] && [ -n "$_ah" ] && [ -n "$_ab" ]; then ASK_TREE="$_aq"; ASK_HEAD="$_ah"; ASK_BRANCH="$_ab"
|
|
777
|
+
else [ $((_QNOW - _at)) -le 1800 ] || no "the question was asked more than 30 minutes ago."; fi
|
|
752
778
|
_crew_q_is "$INPUT" || no "the question answered is not the fixed one."
|
|
753
779
|
[ "$PM" = auto ] || no "the approval question is recorded in auto only, and this session is in ${PM:-an unknown mode}."
|
|
754
780
|
_crew_prh_scan && no "a PermissionRequest hook may be wired ($_PRH), and it can answer the question before you do. The approval that still works is typed: the user can type /crew-approve instead."
|
|
@@ -761,7 +787,7 @@ _pa_question(){
|
|
|
761
787
|
no) [ -n "$REC" ] && { : > "$REC"; } 2>/dev/null
|
|
762
788
|
say "Crewforth: not approved - you answered No." "The user answered No to the approval question. Do not commit or push; ask what they want changed." ;;
|
|
763
789
|
esac
|
|
764
|
-
case "${CLAUDE_CODE_SESSION_ATTENDED-}" in 0) no "this session has nobody in front of it (it was started with -p, or
|
|
790
|
+
case "${CLAUDE_CODE_SESSION_ATTENDED-}" in 0) no "this session has nobody in front of it (it was started with -p, by a program, or in the background with claude --bg or the agent view, which Claude Code counts as unattended even when you type into it; approve in a regular session, or commit in your own terminal)." ;; esac
|
|
765
791
|
_json_slice "$INPUT" transcript_path >/dev/null; _json_unescape "$_JS" >/dev/null; TP="${_JU//\\//}"
|
|
766
792
|
{ [ -n "$TP" ] && [ -s "$TP" ]; } || no "the session keeps no transcript on disk (one started from inside another session keeps none)."
|
|
767
793
|
BARE=0
|
|
@@ -858,7 +884,7 @@ _crew_appr_op "$_JU"
|
|
|
858
884
|
# that class from here, so the old failures do not raise it straight back.
|
|
859
885
|
if [ "$OP" = loosen ]; then
|
|
860
886
|
nol(){ say "Crewforth: floor NOT lowered - $1" "The user sent the command only the user can type /crew-loosen, for $LO_AGENT $LO_CHANGE $LO_RISK, and Crewforth did not lower anything: $1 Tell the user exactly that."; }
|
|
861
|
-
case "${CLAUDE_CODE_SESSION_ATTENDED-}" in 0) nol "this session has nobody in front of it (it was started with -p, or
|
|
887
|
+
case "${CLAUDE_CODE_SESSION_ATTENDED-}" in 0) nol "this session has nobody in front of it (it was started with -p, by a program, or in the background with claude --bg or the agent view, which Claude Code counts as unattended even when you type into it; approve in a regular session, or commit in your own terminal)." ;; esac
|
|
862
888
|
_json_slice "$INPUT" transcript_path >/dev/null; _json_unescape "$_JS" >/dev/null; TP="${_JU//\\//}"
|
|
863
889
|
{ [ -n "$TP" ] && [ -s "$TP" ]; } || nol "this is the first message of this session, or the session keeps no transcript."
|
|
864
890
|
_lh="${BASH_SOURCE%/*}"; [ "$_lh" = "${BASH_SOURCE}" ] && _lh=.
|
|
@@ -902,7 +928,7 @@ fi
|
|
|
902
928
|
# + % - and spaces, so it can neither chain another (the gate would refuse a chain anyway) nor need escaping here.
|
|
903
929
|
if [ "$OP" = allowverify ]; then
|
|
904
930
|
noa(){ say "Crewforth: verify command NOT allowed - $1" "The user sent the command only the user can type /crew-allow-verify, and Crewforth added no rule: $1 Tell the user exactly that."; }
|
|
905
|
-
case "${CLAUDE_CODE_SESSION_ATTENDED-}" in 0) noa "this session has nobody in front of it (it was started with -p, or
|
|
931
|
+
case "${CLAUDE_CODE_SESSION_ATTENDED-}" in 0) noa "this session has nobody in front of it (it was started with -p, by a program, or in the background with claude --bg or the agent view, which Claude Code counts as unattended even when you type into it; approve in a regular session, or commit in your own terminal)." ;; esac
|
|
906
932
|
_json_slice "$INPUT" transcript_path >/dev/null; _json_unescape "$_JS" >/dev/null; TP="${_JU//\\//}"
|
|
907
933
|
{ [ -n "$TP" ] && [ -s "$TP" ]; } || noa "this is the first message of this session, or the session keeps no transcript."
|
|
908
934
|
_lh="${BASH_SOURCE%/*}"; [ "$_lh" = "${BASH_SOURCE}" ] && _lh=.
|
|
@@ -942,7 +968,8 @@ fi
|
|
|
942
968
|
# ---- `/crew-approve` with nothing after it: what this session was last refused ---------------------------------
|
|
943
969
|
# guard-bash.sh, when it refuses a commit or a push in auto / dontAsk, writes down what it refused
|
|
944
970
|
# (.claude/state/crew-approval-pending: the session, commit | push | commit+push, the second). The bare command
|
|
945
|
-
# approves THAT and nothing else: the same session's,
|
|
971
|
+
# approves THAT and nothing else: the same session's, with HEAD, the branch and (for a commit) the staged tree still
|
|
972
|
+
# as they were when it was refused — or, for a record that does not carry them, not older than 30 minutes. What is approved is therefore
|
|
946
973
|
# never read from the message and never from the model: the gate wrote it, from the command it refused. From here
|
|
947
974
|
# on the flow is the one a spelled-out approval takes, with every proof it asks for (the mode, a session that is
|
|
948
975
|
# attended, a transcript on disk) and the same record at the end.
|
|
@@ -956,7 +983,7 @@ if [ "$OP" = pending ]; then
|
|
|
956
983
|
_bs=""; _bo=""; _bt=""
|
|
957
984
|
if [ -f "$_bp" ]; then
|
|
958
985
|
while IFS= read -r _bl || [ -n "$_bl" ]; do
|
|
959
|
-
case "$_bl" in sid=*) _bs="${_bl#sid=}" ;; op=*) _bo="${_bl#op=}" ;; ts=*) _bt="${_bl#ts=}" ;; esac
|
|
986
|
+
case "$_bl" in sid=*) _bs="${_bl#sid=}" ;; op=*) _bo="${_bl#op=}" ;; ts=*) _bt="${_bl#ts=}" ;; tree=*) ASK_TREE="${_bl#tree=}" ;; head=*) ASK_HEAD="${_bl#head=}" ;; branch=*) ASK_BRANCH="${_bl#branch=}" ;; esac
|
|
960
987
|
done < "$_bp"
|
|
961
988
|
fi
|
|
962
989
|
if [ "${BASH_VERSINFO[0]}" -ge 5 ] || { [ "${BASH_VERSINFO[0]}" -eq 4 ] && [ "${BASH_VERSINFO[1]}" -ge 2 ]; }; then printf -v _bn '%(%s)T' -1; else _bn="$(date +%s)"; fi
|
|
@@ -964,7 +991,16 @@ if [ "$OP" = pending ]; then
|
|
|
964
991
|
case "$_bo" in commit|push|commit+push) ;; *) no "nothing is waiting for approval in this session: no commit or push was refused. Have the commit message shown and the commit tried first; or name it: the user can type /crew-approve commit, push or commit+push." ;; esac
|
|
965
992
|
{ [ -n "$_bsid" ] && [ "$_bs" = "$_bsid" ]; } || no "what is waiting for approval was refused in another session, not this one. Nothing is approved; have it tried here first."
|
|
966
993
|
case "$_bt$_bn" in *[!0-9]*|'') no "the time of what is waiting could not be read." ;; esac
|
|
967
|
-
[ $((_bn - _bt)) -ge 0 ]
|
|
994
|
+
[ $((_bn - _bt)) -ge 0 ] || no "the time of what is waiting is in the future."
|
|
995
|
+
# Written down with HEAD and the branch (and, for a commit, the staged tree) when it was refused: it holds until the
|
|
996
|
+
# user approves, however long that takes, as long as those are unchanged (_pa_record compares them). A record
|
|
997
|
+
# without them — an older one, or a commit whose tree could not be written — keeps 30 minutes, and compares nothing.
|
|
998
|
+
case "$_bo" in commit*) [ -n "$ASK_TREE" ] || ASK_HEAD="" ;; esac
|
|
999
|
+
[ -n "$ASK_BRANCH" ] || ASK_HEAD=""
|
|
1000
|
+
[ -n "$ASK_HEAD" ] || { ASK_TREE=""; ASK_BRANCH=""; }
|
|
1001
|
+
if [ -z "$ASK_HEAD" ]; then
|
|
1002
|
+
[ $((_bn - _bt)) -le 1800 ] || no "what was waiting for approval ($_bo) is more than 30 minutes old. Nothing is approved; have it tried again first."
|
|
1003
|
+
fi
|
|
968
1004
|
OP="$_bo" # used up further down, once the approval is on record
|
|
969
1005
|
fi
|
|
970
1006
|
|
package/kit/hooks/route-hint.sh
CHANGED
|
@@ -36,11 +36,12 @@
|
|
|
36
36
|
# nothing works" report from Windows users, and it is not a Claude Code bug: Crewforth was spending the budget.
|
|
37
37
|
#
|
|
38
38
|
# Everything below is therefore one awk invocation over the component files, with the normalisation and the
|
|
39
|
-
# matching done inside awk. Total external processes:
|
|
40
|
-
#
|
|
41
|
-
#
|
|
39
|
+
# matching done inside awk. Total external processes: FOUR — sed, sed, head, awk; stdin is read with the `read`
|
|
40
|
+
# builtin (it was a `cat`, the fifth). eval/smoke-test.sh §7y pins the shape. With no awk or sed on PATH there is
|
|
41
|
+
# nothing to match with, so the hook says nothing and exits 0 rather than printing "command not found" into a session.
|
|
42
42
|
set -uo pipefail
|
|
43
|
-
|
|
43
|
+
command -v awk >/dev/null 2>&1 && command -v sed >/dev/null 2>&1 && command -v head >/dev/null 2>&1 || exit 0
|
|
44
|
+
IN=""; IFS= read -r -d '' IN || true
|
|
44
45
|
case "$IN" in *'"hook_event_name"'*UserPromptSubmit*) ;; *) exit 0 ;; esac
|
|
45
46
|
|
|
46
47
|
# Two editions, two layouts: a full install puts the components under .claude/, the plugin ships them beside the
|
|
@@ -150,7 +151,26 @@ function readonly( w, n, f, l) {
|
|
|
150
151
|
w[l] !~ /^(oku|okur|okuyun|okuyabilir|anlat|anlatir|anlatin|acikla|aciklar|aciklayin|ozetle|ozetler|ozetleyin)$/) return 0
|
|
151
152
|
return !hasw("and|then|ve|sonra|(fix|add|updat|writ|wrote|chang|implement|creat|remov|delet|refactor|build|buil|mak|renam|edit|migrat|mov|deploy|ship|instal|upgrad|review|audit|check|test|debug|investigat|optimi[sz]|improv|translat|draft|generat|setup|set|harden|resolv|run|merg|publish|configur|propos|analy[sz])[a-z]?[a-z]?[a-z]?[a-z]?|why|bug|bugs|crash[a-z]*|fail[a-z]*|error[a-z]*|broken|duzelt[a-z]*|ekle[a-z]*|guncelle[a-z]*|yaz[a-z]*|degistir[a-z]*|olustur[a-z]*|sil[a-z]*|kaldir[a-z]*|yap[a-z]*|kur[a-z]*|tasi[a-z]*|incele[a-z]*|denetle[a-z]*|cevir[a-z]*|duzenle[a-z]*|iyilestir[a-z]*|yayinla[a-z]*|cikar[a-z]*|hizlandir[a-z]*|uygula[a-z]*|coz[a-z]*|gelistir[a-z]*|sadelestir[a-z]*|analiz[a-z]*|neden|hata[a-z]*|cokuyor|bozuk[a-z]*")
|
|
152
153
|
}
|
|
153
|
-
|
|
154
|
+
# What the user QUOTES or turns DOWN is not the request. A pasted error, a command in backticks or a quoted label can
|
|
155
|
+
# carry a trigger word ("the log says `run a security audit` failed"), and "do not review this, just push" names
|
|
156
|
+
# the reviewer it declines. So before scoring: fenced blocks, backtick spans and double-quoted spans (escaped in the
|
|
157
|
+
# payload as \") become a space, and a clause that opens with a negator (do not, dont, never, not, without, no need
|
|
158
|
+
# to; not "do not forget/skip/miss/ignore", which asks for the thing) or closes with a Turkish one (degil, yapma,
|
|
159
|
+
# etme, gerek yok) is dropped. Clauses end at . , ; ! ? or a newline. Still one awk, no process.
|
|
160
|
+
function asked(raw, n, c, i, out, x) {
|
|
161
|
+
gsub(/```[^`]*```/, " ", raw); gsub(/`[^`]*`/, " ", raw); gsub(/\\"[^"]*\\"/, " ", raw)
|
|
162
|
+
n = split(raw, c, /[.,;!?]|\\n/); out = ""
|
|
163
|
+
for (i = 1; i <= n; i++) {
|
|
164
|
+
x = norm(c[i]); if (x == "") continue
|
|
165
|
+
if (x ~ /^(don t|dont|do not|never|not|without|no need to)( |$)/ && x !~ /^(don t|dont|do not|never) (forget|skip|miss|ignore)( |$)/) continue
|
|
166
|
+
if (x ~ /(^| )(degil|yapma|etme|gerek yok)$/) continue
|
|
167
|
+
out = out " " x
|
|
168
|
+
}
|
|
169
|
+
return out
|
|
170
|
+
}
|
|
171
|
+
BEGIN { np = asked(ENVIRON["CREW_PROMPT"]) " "; bestA=0; bestS=0; bestX=0; nameA=""; nameS=""; nameX=""
|
|
172
|
+
ALSO["dependency-audit"] = 1
|
|
173
|
+
if (readonly()) exit }
|
|
154
174
|
seen[FILENAME] { next } # one Trigger-phrases line per component, the first
|
|
155
175
|
{
|
|
156
176
|
if (tolower($0) !~ /trigger phrases:/) next
|
|
@@ -187,18 +207,25 @@ seen[FILENAME] { next } # one Trigger-phrases
|
|
|
187
207
|
# keeps its own best and the preference is applied at the END, among candidates that clear the floor.
|
|
188
208
|
if (kind == "agent") { if (score > bestA) { bestA=score; nameA=name } }
|
|
189
209
|
else { if (score > bestS) { bestS=score; nameS=name } }
|
|
210
|
+
# A RIDE-ALONG skill is named BESIDE the agent instead of losing to it. Its trigger is an event inside the work
|
|
211
|
+
# of another domain: "add axios to the web app" is a request for crew-frontend-expert, and adding the package
|
|
212
|
+
# is what dependency-audit judges. Under agent-outranks-skill it was never named (one project measured 21
|
|
213
|
+
# lockfile commits against 0 audits), so it gets its own slot, scored the same way, and no process.
|
|
214
|
+
# NO APOSTROPHE anywhere in this program: it sits inside a single-quoted shell string, and one closes it.
|
|
215
|
+
if (kind == "skill" && name in ALSO && score >= 6 && score > bestX) { bestX=score; nameX=name }
|
|
190
216
|
}
|
|
191
217
|
# Silence is the default. No match, or a match too weak to be sure, means the main thread decides as before.
|
|
192
218
|
# The agent is preferred only when it is credible on its own: a weak agent match no longer buries a strong skill.
|
|
193
219
|
END {
|
|
194
|
-
if (bestA >= 6) print "agent\t" nameA
|
|
195
|
-
else if (bestS >= 6) print "skill\t" nameS
|
|
220
|
+
if (bestA >= 6) print "agent\t" nameA "\t" nameX
|
|
221
|
+
else if (bestS >= 6) print "skill\t" nameS "\t"
|
|
196
222
|
}
|
|
197
223
|
' "${FILES[@]}" 2>/dev/null)"
|
|
198
224
|
|
|
199
225
|
[ -n "$RES" ] || exit 0
|
|
200
226
|
BESTKIND="${RES%% *}"; BEST="${RES#* }"
|
|
201
227
|
[ -n "$BEST" ] && [ "$BEST" != "$RES" ] || exit 0
|
|
228
|
+
ALSO="${BEST#* }"; [ "$ALSO" = "$BEST" ] && ALSO=""; BEST="${BEST%% *}"
|
|
202
229
|
|
|
203
230
|
# The wording is an INSTRUCTION, not a suggestion, and that distinction is the whole experiment. The first
|
|
204
231
|
# version hedged — "unless it is a one-line edit", "if it is genuinely not that agent's work, say so" — which
|
|
@@ -210,5 +237,7 @@ if [ "$BESTKIND" = skill ]; then
|
|
|
210
237
|
MSG="Use the \`$BEST\` skill for this task."
|
|
211
238
|
else
|
|
212
239
|
MSG="Use the $BEST subagent for this task."
|
|
240
|
+
# Not "Use the …": that sentence names THE owner, and every reader of this hook's answer parses it as such.
|
|
241
|
+
[ -n "$ALSO" ] && MSG="$MSG Also apply the \`$ALSO\` skill: the commit gate (§4.7) asks for its record."
|
|
213
242
|
fi
|
|
214
243
|
printf '{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"%s"}}\n' "$MSG"
|
|
@@ -17,6 +17,9 @@
|
|
|
17
17
|
-----BEGIN ([A-Z]+ )?PRIVATE KEY-----
|
|
18
18
|
#test: -----BEGIN RSA PRIVATE KEY-----
|
|
19
19
|
#test: -----BEGIN PRIVATE KEY-----
|
|
20
|
+
#test: -----BEGIN ENCRYPTED PRIVATE KEY-----
|
|
21
|
+
-----BEGIN PGP PRIVATE KEY BLOCK-----
|
|
22
|
+
#test: -----BEGIN PGP PRIVATE KEY BLOCK-----
|
|
20
23
|
|
|
21
24
|
# AWS access key id
|
|
22
25
|
(AKIA|ASIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|A3T)[A-Z0-9]{16}
|
|
@@ -35,11 +38,20 @@ AIza[A-Za-z0-9_-]{35}
|
|
|
35
38
|
# Slack token
|
|
36
39
|
xox[baprs]-[A-Za-z0-9-]{10,}
|
|
37
40
|
#test: SLACK_TOKEN=xoxb-{{A12}}
|
|
41
|
+
# Slack refresh token and app-level token (the class above stops at xox[baprs])
|
|
42
|
+
xoxe(\.xox[bp])?-[A-Za-z0-9-]{20,}
|
|
43
|
+
#test: SLACK_REFRESH=xoxe-1-{{A40}}
|
|
44
|
+
xapp-[0-9]-[A-Za-z0-9-]{20,}
|
|
45
|
+
#test: SLACK_APP_TOKEN=xapp-1-{{A24}}
|
|
38
46
|
|
|
39
47
|
# Stripe live secret / restricted key
|
|
40
48
|
(sk|rk)_live_[A-Za-z0-9]{20,}
|
|
41
49
|
#test: STRIPE=sk_live_{{A24}}
|
|
42
50
|
|
|
51
|
+
# Stripe webhook signing secret
|
|
52
|
+
whsec_[A-Za-z0-9]{24,}
|
|
53
|
+
#test: STRIPE_WEBHOOK_SECRET=whsec_{{A32}}
|
|
54
|
+
|
|
43
55
|
# OpenAI / Anthropic API keys
|
|
44
56
|
sk-(ant-|proj-)?[A-Za-z0-9_-]{24,}
|
|
45
57
|
#test: API_KEY=sk-ant-{{A24}}
|
|
@@ -48,6 +60,42 @@ sk-(ant-|proj-)?[A-Za-z0-9_-]{24,}
|
|
|
48
60
|
npm_[A-Za-z0-9]{36}
|
|
49
61
|
#test: //registry.npmjs.org/:_authToken=npm_{{A36}}
|
|
50
62
|
|
|
63
|
+
# GitLab personal, deploy and runner tokens
|
|
64
|
+
gl(pat|dt|rt)-[A-Za-z0-9_-]{20,}
|
|
65
|
+
#test: GITLAB_TOKEN=glpat-{{A20}}
|
|
66
|
+
#test: CI_RUNNER=glrt-{{A24}}
|
|
67
|
+
|
|
68
|
+
# Hugging Face access token
|
|
69
|
+
hf_[A-Za-z0-9]{30,}
|
|
70
|
+
#test: HF_TOKEN=hf_{{A34}}
|
|
71
|
+
|
|
72
|
+
# Docker Hub personal access token
|
|
73
|
+
dckr_pat_[A-Za-z0-9_-]{27,}
|
|
74
|
+
#test: DOCKER_PAT=dckr_pat_{{A27}}
|
|
75
|
+
|
|
76
|
+
# DigitalOcean personal, OAuth and refresh tokens (hex; upper case accepted so the sample can be written)
|
|
77
|
+
do[por]_v1_[A-Fa-f0-9]{64}
|
|
78
|
+
#test: DO_TOKEN=dop_v1_{{A64}}
|
|
79
|
+
|
|
80
|
+
# Shopify access, custom-app, private-app tokens and shared secret
|
|
81
|
+
shp(at|ca|pa|ss)_[A-Fa-f0-9]{32}
|
|
82
|
+
#test: SHOPIFY=shpat_{{A32}}
|
|
83
|
+
|
|
84
|
+
# PyPI upload token (the macaroon always starts the same way)
|
|
85
|
+
pypi-AgE[A-Za-z0-9_-]{50,}
|
|
86
|
+
#test: password = pypi-AgE{{A60}}
|
|
87
|
+
|
|
88
|
+
# AWS secret access key, recognised by the key that names it: the value alone is 40 characters of base64 with no prefix
|
|
89
|
+
(aws_secret_access_key|AWS_SECRET_ACCESS_KEY|SecretAccessKey)[[:space:]"']*[:=][[:space:]"']*[A-Za-z0-9/+]{40}
|
|
90
|
+
#test: aws_secret_access_key = {{A40}}
|
|
91
|
+
#test: "SecretAccessKey": "{{A40}}"
|
|
92
|
+
|
|
93
|
+
# A password inside a URL: scheme://user:password@host. A placeholder ($VAR, ${VAR}, <pass>, {{x}}) starts with a
|
|
94
|
+
# character the first class excludes, so the ordinary way of writing one about stays committable.
|
|
95
|
+
[A-Za-z][A-Za-z0-9+.-]*://[^/[:space:]:@]+:[A-Za-z0-9!#%&*+,.;=?^_|~-][^/[:space:]:@<{}]{5,}@
|
|
96
|
+
#test: DATABASE_URL=postgres://app:{{A12}}@db.internal:5432/app
|
|
97
|
+
#test: https://deploy:{{A20}}@git.example.com/repo.git
|
|
98
|
+
|
|
51
99
|
# SendGrid API key
|
|
52
100
|
SG\.[A-Za-z0-9_-]{16,32}\.[A-Za-z0-9_-]{16,}
|
|
53
101
|
#test: SENDGRID_API_KEY=SG.{{A16}}.{{A20}}
|
|
@@ -56,7 +104,7 @@ SG\.[A-Za-z0-9_-]{16,32}\.[A-Za-z0-9_-]{16,}
|
|
|
56
104
|
eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{10,}
|
|
57
105
|
#test: Authorization: Bearer eyJAAAAAAAAAA.eyJAAAAAA.AAAAAAAAAA
|
|
58
106
|
|
|
59
|
-
# A credential carried in a URL query. THE CLASS EVERY PATTERN ABOVE STRUCTURALLY CANNOT SEE: all
|
|
107
|
+
# A credential carried in a URL query. THE CLASS EVERY PATTERN ABOVE STRUCTURALLY CANNOT SEE: all the others
|
|
60
108
|
# recognise a credential by its own SHAPE -- AKIA, ghp_, AIza, xox, sk_live_, sk-ant-, npm_, SG., a JWT, a PEM
|
|
61
109
|
# header -- and a token with no issuer prefix has no shape to recognise. Measured on a published install with
|
|
62
110
|
# the real hook: `http://127.0.0.1:7911/?token=<uuid>`, a bare uuid, and `token=` plus 32 hex all committed
|
|
@@ -80,3 +128,9 @@ eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{10,}
|
|
|
80
128
|
#test-clean: const apiKey = process.env.STRIPE_SECRET_KEY
|
|
81
129
|
#test-clean: # set GITHUB_TOKEN in the CI environment, never in the repo
|
|
82
130
|
#test-clean: private key handling is documented in docs/security.md
|
|
131
|
+
#test-clean: DATABASE_URL=postgres://app:${DB_PASSWORD}@db:5432/app
|
|
132
|
+
#test-clean: REDIS_URL=redis://default:$REDIS_PASSWORD@cache:6379
|
|
133
|
+
#test-clean: clone it with git@gitlab.com:group/project.git or https://user@example.com/repo
|
|
134
|
+
#test-clean: from huggingface_hub import hf_hub_download
|
|
135
|
+
#test-clean: AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
|
136
|
+
#test-clean: the dev server listens on http://localhost:3000/api
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
# Stop hook — when session fill crosses a threshold it
|
|
2
|
+
# Stop hook — when session fill crosses a threshold it prints the handoff recommendation to the USER (systemMessage).
|
|
3
3
|
# Session management = a gate at the threshold, not a wish. But the gate must not nag or burn tokens:
|
|
4
4
|
# - It surfaces the recommendation as a neutral `systemMessage` (user-facing warning), NOT a hook error.
|
|
5
5
|
# (Claude Code renders a BLOCKING Stop hook — exit 2 — as "Stop hook error: ...". We deliberately never block.)
|
|
@@ -9,15 +9,17 @@
|
|
|
9
9
|
# - It NEVER blocks the stop: every path exits 0. A measurement failure fails open, silently.
|
|
10
10
|
# context-usage.sh does the measurement. No automatic /clear — approval is the user's (§4.4).
|
|
11
11
|
# The model is nudged separately every turn by context-usage.sh (UserPromptSubmit); this hook is the
|
|
12
|
-
#
|
|
12
|
+
# USER-facing alert, and it is silent when the measurement fails. A new session (or /clear) mints a new session_id and re-arms both thresholds.
|
|
13
13
|
set -uo pipefail
|
|
14
14
|
HERE="$(cd "$(dirname "$0")" && pwd)"
|
|
15
|
-
|
|
15
|
+
# Bounded: a pipe Claude Code keeps open (a Windows pipe can close late) must not hold the stop for the hook's whole
|
|
16
|
+
# timeout. A builtin read with a deadline: the payload is one line with no newline, so it ends at EOF, normally at
|
|
17
|
+
# once. Nothing in time means nothing to measure, and this hook then stays silent, as on any other failure.
|
|
18
|
+
IN=""; IFS= read -r -d '' -t "${CREW_STDIN_TIMEOUT:-2}" IN 2>/dev/null; [ "$?" -gt 128 ] && exit 0
|
|
16
19
|
|
|
17
|
-
#
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
esac
|
|
20
|
+
# No `stop_hook_active` guard. The flag says SOME Stop hook blocked this turn — another plugin's, typically, since
|
|
21
|
+
# this one never does — and honouring it silenced the fill warning on exactly those turns. The loop it guards
|
|
22
|
+
# against cannot happen here: nothing below blocks, and each warning is deduplicated per session and tier.
|
|
21
23
|
|
|
22
24
|
# Per-session dedup key: session_id from the Stop stdin JSON (documented field), else the transcript
|
|
23
25
|
# filename. Sanitized to a safe filename fragment. Without any key we fall back to a shared marker
|
|
@@ -71,14 +73,41 @@ if [ -n "$TP" ] && [ -f "$TP" ]; then
|
|
|
71
73
|
fi
|
|
72
74
|
marker(){ printf '%s/crew-session-guard.%s.c%s.%s' "${TMPDIR:-/tmp}" "$KEY" "$COMP" "$1"; }
|
|
73
75
|
|
|
76
|
+
# What the last compaction kept, from its own record: `compactMetadata` carries `preTokens` and `postTokens`
|
|
77
|
+
# (measured on this machine's transcripts: 71 of 98 boundary records carry both; the other 27 carry no metadata at
|
|
78
|
+
# all, and then the line says nothing). Read only on the rare path that speaks about a compaction, so its two
|
|
79
|
+
# processes are not paid on an ordinary Stop. Sets CNUM ("345k → 20k tokens") and CTRIG (auto / manual).
|
|
80
|
+
CNUM=""; CTRIG=""; CINFO=""
|
|
81
|
+
compnums(){
|
|
82
|
+
local cb pre post
|
|
83
|
+
cb="$(grep '"compact_boundary"' "$TP" 2>/dev/null | tail -n 1)"
|
|
84
|
+
case "$cb" in *'"trigger":"manual"'*|*'"trigger": "manual"'*) CTRIG=manual ;; *'"trigger"'*) CTRIG=auto ;; esac
|
|
85
|
+
pre="${cb#*\"preTokens\":}"; [ "$pre" != "$cb" ] || return 0; pre="${pre# }"; pre="${pre%%[!0-9]*}"
|
|
86
|
+
post="${cb#*\"postTokens\":}"; [ "$post" != "$cb" ] || return 0; post="${post# }"; post="${post%%[!0-9]*}"
|
|
87
|
+
case "$pre$post" in ''|*[!0-9]*) return 0 ;; esac
|
|
88
|
+
[ -n "$pre" ] && [ -n "$post" ] || return 0
|
|
89
|
+
CNUM="$(( (pre + 500) / 1000 ))k → $(( (post + 500) / 1000 ))k tokens"
|
|
90
|
+
}
|
|
91
|
+
|
|
74
92
|
# An AUTO compaction is not a milestone, it is a loss: state nobody chose to drop is already gone, and the fill
|
|
75
93
|
# reading right after it is reassuringly low precisely because the context was thrown away. Announce it once per
|
|
76
94
|
# generation, independently of the fill tier — waiting for 75% would report the loss long after it happened.
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
95
|
+
# The generation is judged by ITS OWN record, the last boundary: a manual /compact after an earlier auto one opens a
|
|
96
|
+
# generation of its own, and must not re-announce the old loss with the new compaction's numbers.
|
|
97
|
+
# A MANUAL compaction is a choice, so it gets no warning — only what it kept, once per generation, so the user can
|
|
98
|
+
# see what the next stretch of the session starts from. Silent when the record carries no numbers.
|
|
99
|
+
if [ "${COMP:-0}" -gt 0 ] && [ ! -e "$(marker compacted)" ]; then
|
|
100
|
+
: > "$(marker compacted)" 2>/dev/null || true
|
|
101
|
+
compnums
|
|
102
|
+
if [ "$CTRIG" = auto ]; then
|
|
103
|
+
printf '{"systemMessage":"⚠️ Auto-compaction has fired %s time(s) this session%s — context was dropped that nobody chose to drop. Check that docs/SESSION_STATE.md still matches reality, and hand off at the next phase boundary instead of riding the fill up to another one."}\n' "$AUTOC" "${CNUM:+ (the last one: $CNUM)}"
|
|
104
|
+
exit 0
|
|
105
|
+
fi
|
|
106
|
+
[ "$CTRIG" = manual ] && [ -n "$CNUM" ] && CINFO="Compaction $COMP (manual): $CNUM."
|
|
81
107
|
fi
|
|
108
|
+
# Every exit after this point goes through bye, so the compaction line is never lost to a turn that has no fill
|
|
109
|
+
# reading, and never preempts a fill warning on the same turn: the two share one systemMessage.
|
|
110
|
+
bye(){ [ -n "$CINFO" ] && printf '{"systemMessage":"%s"}\n' "$CINFO"; exit 0; }
|
|
82
111
|
|
|
83
112
|
# Real context%, from context-usage.sh's measurement. Fail-open on any error.
|
|
84
113
|
#
|
|
@@ -104,7 +133,7 @@ if [ -z "$PCT" ]; then
|
|
|
104
133
|
# --verbose here: this line reaches the USER at most twice per session, so the raw token counts are free.
|
|
105
134
|
LINE="$(printf '%s' "$IN" | bash "$HERE/context-usage.sh" --verbose 2>/dev/null || true)"
|
|
106
135
|
LINE="${LINE%%$'\n'*}" # first line, without paying `head` a process for it
|
|
107
|
-
[ -n "$LINE" ] ||
|
|
136
|
+
[ -n "$LINE" ] || bye
|
|
108
137
|
# Pull the percentage back out of the "🔋 Session: %77.2 (...)" line. context-usage.sh pins LC_ALL=C, so the
|
|
109
138
|
# decimal separator is always '.' whatever the locale. No number -> cannot classify -> stay silent (fail open).
|
|
110
139
|
# Pure expansion rather than `sed | head`: take everything after the first '%', then stop at the first
|
|
@@ -114,7 +143,7 @@ if [ -z "$PCT" ]; then
|
|
|
114
143
|
[ "$PCT" != "$LINE" ] || PCT=""
|
|
115
144
|
PCT="${PCT%%[!0-9.]*}"
|
|
116
145
|
case "$PCT" in ''|*[!0-9.]*|.*) PCT="" ;; esac
|
|
117
|
-
[ -n "$PCT" ] ||
|
|
146
|
+
[ -n "$PCT" ] || bye
|
|
118
147
|
fi
|
|
119
148
|
|
|
120
149
|
# The reply's own 🔋 line, held against the reading. The discipline says that figure is copied from the hook's
|
|
@@ -147,7 +176,7 @@ fi
|
|
|
147
176
|
# turn bought nothing here: the thresholds are whole numbers, so truncating is exactly equivalent — 89.9 is below
|
|
148
177
|
# 90 either way — and dropping the decimal also drops the locale hazard the awk calls existed to pin down.
|
|
149
178
|
INT="${PCT%%.*}"; INT="${INT:-0}"
|
|
150
|
-
case "$INT" in ''|*[!0-9]*)
|
|
179
|
+
case "$INT" in ''|*[!0-9]*) bye ;; esac
|
|
151
180
|
TIER=""
|
|
152
181
|
[ "$INT" -ge 90 ] && TIER=90
|
|
153
182
|
[ -n "$TIER" ] || { [ "$INT" -ge 75 ] && TIER=75; }
|
|
@@ -165,11 +194,12 @@ if [ -n "$TIER" ] && [ ! -e "$(marker "$TIER")" ]; then
|
|
|
165
194
|
MSG=">75% threshold. Recommended: apply the handoff skill, write docs/SESSION_STATE.md, then /clear. Manual; your call."
|
|
166
195
|
fi
|
|
167
196
|
fi
|
|
168
|
-
[ -n "$MSG$WRONG" ] ||
|
|
197
|
+
[ -n "$MSG$WRONG" ] || bye
|
|
169
198
|
|
|
170
199
|
# JSON-escape backslash + double-quote in the measured line before embedding it.
|
|
171
200
|
SAFE="$(printf '%s' "$LINE" | sed 's/\\/\\\\/g; s/"/\\"/g')"
|
|
172
201
|
OUT="${MSG:+$SAFE | $MSG}"
|
|
202
|
+
[ -n "$CINFO" ] && OUT="$CINFO${OUT:+ · $OUT}"
|
|
173
203
|
[ -n "$WRONG" ] && OUT="⚠️ The 🔋 line in this reply says $WRONG, but the fill measured from the transcript is: $SAFE. The reply's figure is not a measurement — do not decide a handoff on it.${OUT:+ · $OUT}"
|
|
174
204
|
# exit 0 + systemMessage: a neutral, user-facing warning (NOT a hook error, does NOT force an extra turn).
|
|
175
205
|
printf '{"systemMessage":"%s"}\n' "$OUT"
|