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.
Files changed (182) hide show
  1. package/CHANGELOG.md +273 -0
  2. package/README.md +3 -2
  3. package/VERSION +1 -1
  4. package/adopt.sh +40 -5
  5. package/bin/cli.js +138 -2
  6. package/kit/AGENT_TEMPLATE.md +15 -0
  7. package/kit/CLAUDE.md +50 -18
  8. package/kit/agents/crew-backend-expert.md +4 -3
  9. package/kit/agents/crew-commit-agent.md +2 -0
  10. package/kit/agents/crew-database-expert.md +2 -1
  11. package/kit/agents/crew-devops-expert.md +1 -0
  12. package/kit/agents/crew-docs-agent.md +3 -1
  13. package/kit/agents/crew-frontend-expert.md +2 -0
  14. package/kit/agents/crew-performance-expert.md +7 -2
  15. package/kit/agents/crew-planner.md +15 -3
  16. package/kit/agents/crew-privacy-agent.md +2 -0
  17. package/kit/agents/crew-review-agent.md +8 -1
  18. package/kit/agents/crew-security-expert.md +4 -3
  19. package/kit/agents/crew-test-expert.md +6 -2
  20. package/kit/eval/doctor.sh +201 -1
  21. package/kit/eval/gate-report.sh +28 -0
  22. package/kit/eval/golden-holdout.txt +2 -0
  23. package/kit/eval/golden-routing.txt +17 -0
  24. package/kit/eval/lib/crew-map/contracts.mjs +77 -0
  25. package/kit/eval/lib/crew-map/crew-map.sh +2 -1
  26. package/kit/eval/lib/crew-map/detect.mjs +2 -1
  27. package/kit/eval/lib/crew-map/narrative.mjs +14 -3
  28. package/kit/eval/lib/crew-map/project.mjs +45 -6
  29. package/kit/eval/lib/crew-map/scan.mjs +153 -14
  30. package/kit/eval/lib/findings/findings.schema.json +11 -1
  31. package/kit/eval/lib/findings/findings.sh +24 -1
  32. package/kit/eval/lib/hook-inject.sh +159 -0
  33. package/kit/eval/lib/lockfiles.sh +71 -0
  34. package/kit/eval/lib/payload-check.sh +29 -0
  35. package/kit/eval/lib/preview.sh +3 -3
  36. package/kit/eval/lib/review-bench/bench.mjs +14 -2
  37. package/kit/eval/lib/review-prepass/prepass.mjs +32 -3
  38. package/kit/eval/lib/settings-json.awk +18 -0
  39. package/kit/eval/lib/statusline.sh +22 -5
  40. package/kit/eval/lib/todo.awk +167 -0
  41. package/kit/eval/lib/todo.sh +120 -0
  42. package/kit/eval/lib/untrusted-text.awk +38 -0
  43. package/kit/eval/scan-skill.sh +67 -20
  44. package/kit/eval/smoke-test.sh +1759 -41
  45. package/kit/eval/utilization.sh +77 -3
  46. package/kit/hooks/agent-outcome.sh +9 -1
  47. package/kit/hooks/board-sync.sh +49 -3
  48. package/kit/hooks/board.sh +8 -8
  49. package/kit/hooks/branch-fresh.sh +101 -0
  50. package/kit/hooks/floor-blocklist.txt +19 -1
  51. package/kit/hooks/guard-bash.sh +92 -8
  52. package/kit/hooks/guard-question.sh +2 -1
  53. package/kit/hooks/loop-guard.sh +445 -0
  54. package/kit/hooks/map-stale.sh +437 -0
  55. package/kit/hooks/pre-commit +48 -7
  56. package/kit/hooks/precompact-snapshot.sh +102 -6
  57. package/kit/hooks/prompt-approval.sh +50 -14
  58. package/kit/hooks/route-hint.sh +36 -7
  59. package/kit/hooks/secret-blocklist.txt +55 -1
  60. package/kit/hooks/session-guard.sh +45 -15
  61. package/kit/hooks/session-rehydrate.sh +34 -11
  62. package/kit/hooks/session-stats.sh +169 -8
  63. package/kit/hooks/session-update-check.sh +5 -1
  64. package/kit/hooks/skill-trust.sh +23 -14
  65. package/kit/hooks/turn-check.sh +190 -0
  66. package/kit/owned-blobs.tsv +4 -4
  67. package/kit/settings.json +64 -0
  68. package/kit/skills/a11y/SKILL.md +50 -3
  69. package/kit/skills/a11y/references/recipes.md +41 -0
  70. package/kit/skills/adr/SKILL.md +14 -2
  71. package/kit/skills/api-design/SKILL.md +6 -1
  72. package/kit/skills/api-design/references/idempotency.md +39 -0
  73. package/kit/skills/brainstorm/SKILL.md +4 -2
  74. package/kit/skills/brainstorm/references/asking.md +47 -0
  75. package/kit/skills/ci-pipeline/SKILL.md +11 -1
  76. package/kit/skills/ci-pipeline/references/agent-job.md +33 -0
  77. package/kit/skills/commit-message/SKILL.md +9 -3
  78. package/kit/skills/confidence-check/SKILL.md +9 -7
  79. package/kit/skills/confidence-check/references/source-check.md +37 -0
  80. package/kit/skills/crew-approve/SKILL.md +3 -0
  81. package/kit/skills/crew-audit/SKILL.md +63 -0
  82. package/kit/skills/crew-board/SKILL.md +2 -2
  83. package/kit/skills/crew-code-review/SKILL.md +27 -4
  84. package/kit/skills/crew-code-review/references/panel-mode.md +9 -9
  85. package/kit/skills/crew-code-review/references/simplify.md +28 -0
  86. package/kit/skills/crew-doctor/SKILL.md +3 -2
  87. package/kit/skills/crew-gates/SKILL.md +3 -0
  88. package/kit/skills/crew-gates/references/enforcers.md +30 -0
  89. package/kit/skills/crew-interview/SKILL.md +42 -0
  90. package/kit/skills/crew-map/SKILL.md +26 -6
  91. package/kit/skills/crew-plan/SKILL.md +10 -2
  92. package/kit/skills/crew-pr/SKILL.md +57 -0
  93. package/kit/skills/crew-pr/references/checks.md +35 -0
  94. package/kit/skills/crew-pr/references/threads.md +23 -0
  95. package/kit/skills/crew-review/SKILL.md +34 -1
  96. package/kit/skills/crew-ship/SKILL.md +5 -2
  97. package/kit/skills/crew-skill/SKILL.md +26 -1
  98. package/kit/skills/crew-todo/SKILL.md +31 -0
  99. package/kit/skills/crew-update/SKILL.md +4 -1
  100. package/kit/skills/db-migration/SKILL.md +15 -0
  101. package/kit/skills/db-migration/references/expand-contract.md +52 -0
  102. package/kit/skills/dependency-audit/SKILL.md +34 -1
  103. package/kit/skills/dependency-audit/scripts/record.sh +37 -0
  104. package/kit/skills/dependency-upgrade/SKILL.md +29 -1
  105. package/kit/skills/deploy/SKILL.md +18 -0
  106. package/kit/skills/deploy/references/topology-a.md +7 -0
  107. package/kit/skills/deploy/references/topology-b.md +2 -2
  108. package/kit/skills/docs-writer/SKILL.md +37 -2
  109. package/kit/skills/eval-grader/SKILL.md +9 -0
  110. package/kit/skills/eval-grader/references/method.md +26 -0
  111. package/kit/skills/frontend/SKILL.md +5 -2
  112. package/kit/skills/frontend/references/from-design.md +29 -0
  113. package/kit/skills/frontend/references/page-metadata.md +17 -0
  114. package/kit/skills/frontend/references/verify-contract.md +18 -1
  115. package/kit/skills/frontend/references/worst-data.md +35 -0
  116. package/kit/skills/frontend-design/SKILL.md +20 -4
  117. package/kit/skills/frontend-design/references/design-review.md +21 -0
  118. package/kit/skills/frontend-design/references/detection.md +44 -0
  119. package/kit/skills/frontend-design/references/motion.md +54 -0
  120. package/kit/skills/frontend-design/references/ui-copy.md +35 -0
  121. package/kit/skills/frontend-rn-expo/SKILL.md +3 -0
  122. package/kit/skills/handoff/SKILL.md +28 -3
  123. package/kit/skills/handoff/references/cold-reader.md +13 -6
  124. package/kit/skills/i18n-integrity/SKILL.md +3 -0
  125. package/kit/skills/iterate/SKILL.md +7 -2
  126. package/kit/skills/mcp-builder/SKILL.md +21 -4
  127. package/kit/skills/mcp-builder/references/building.md +74 -13
  128. package/kit/skills/mcp-builder/references/cli-for-agents.md +26 -0
  129. package/kit/skills/observability/SKILL.md +13 -1
  130. package/kit/skills/performance/SKILL.md +25 -1
  131. package/kit/skills/performance/references/bottlenecks.md +11 -0
  132. package/kit/skills/performance/references/web-vitals.md +31 -0
  133. package/kit/skills/red-team/SKILL.md +25 -3
  134. package/kit/skills/reflect/SKILL.md +7 -2
  135. package/kit/skills/release/SKILL.md +13 -0
  136. package/kit/skills/security-scan/SKILL.md +6 -2
  137. package/kit/skills/security-scan/references/ai-agents.md +16 -0
  138. package/kit/skills/security-scan/references/fronts.md +59 -2
  139. package/kit/skills/security-scan/references/reporting.md +13 -0
  140. package/kit/skills/security-scan/references/verify.md +9 -0
  141. package/kit/skills/sonarqube-check/SKILL.md +32 -2
  142. package/kit/skills/sonarqube-check/references/ratchet.md +32 -0
  143. package/kit/skills/spec-planning/SKILL.md +32 -8
  144. package/kit/skills/systematic-debugging/SKILL.md +15 -7
  145. package/kit/skills/systematic-debugging/references/techniques.md +5 -0
  146. package/kit/skills/teamboard/SKILL.md +3 -3
  147. package/kit/skills/testing/SKILL.md +18 -3
  148. package/kit/skills/testing/references/browser-headless.md +21 -0
  149. package/kit/skills/token-budget/SKILL.md +26 -5
  150. package/kit/skills/worktree/SKILL.md +13 -5
  151. package/kit/studio/server/lib/session.js +23 -2
  152. package/kit/studio/server/lib/worktree-changes.js +64 -0
  153. package/kit/studio/web/agent-spend.js +32 -0
  154. package/kit/studio/web/app.js +245 -236
  155. package/kit/studio/web/approvals.js +14 -13
  156. package/kit/studio/web/ask.js +10 -9
  157. package/kit/studio/web/canvas.js +56 -54
  158. package/kit/studio/web/chat.js +41 -41
  159. package/kit/studio/web/convo.js +33 -16
  160. package/kit/studio/web/dock.js +20 -19
  161. package/kit/studio/web/graph-plan.js +62 -22
  162. package/kit/studio/web/i18n-tr.js +760 -0
  163. package/kit/studio/web/i18n.js +79 -0
  164. package/kit/studio/web/index.html +1 -0
  165. package/kit/studio/web/inspect.js +27 -24
  166. package/kit/studio/web/list-plan.js +16 -15
  167. package/kit/studio/web/list.js +6 -5
  168. package/kit/studio/web/liveness.js +11 -9
  169. package/kit/studio/web/map-plan.js +86 -36
  170. package/kit/studio/web/map.js +85 -72
  171. package/kit/studio/web/models-plan.js +47 -42
  172. package/kit/studio/web/models.js +21 -20
  173. package/kit/studio/web/names.js +13 -10
  174. package/kit/studio/web/nav.js +34 -33
  175. package/kit/studio/web/newsession.js +25 -24
  176. package/kit/studio/web/style.css +2 -0
  177. package/kit/studio/web/theme.js +5 -2
  178. package/kit/studio/web/timeline-plan.js +19 -18
  179. package/kit/studio/web/timeline.js +23 -22
  180. package/kit/studio/web/usage.js +36 -32
  181. package/package.json +5 -4
  182. 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
- if [ -n "$last" ]; then IFS=$'\t' read -r _ rule why _ <<< "$last"; LINES+=("the last refusal in the gate log: ${rule} ${why:0:120}"); fi
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
- SUM=""; for l in "${LINES[@]}"; do SUM="${SUM:+$SUM; }$l"; done
66
- SUM="${SUM:0:1800}"
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 by a program)." ;; esac
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
- mkdir -p "$_QAD" 2>/dev/null && { printf 'sid=%s\ntool=%s\nts=%s\n' "$SID" "$_QTU" "$_QNOW" > "$ASKED"; } 2>/dev/null \
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 by a program), and an approval is what a person types into their own session." ;;
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 answer is Push only (or the user can type /crew-approve push); for a commit, stage it with git add, show the commit message, then ask again."
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 ] && [ $((_QNOW - _at)) -le 1800 ] || no "the question was asked more than 30 minutes ago."
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 by a program)." ;; esac
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 by a program)." ;; esac
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 by a program)." ;; esac
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, not older than 30 minutes. What is approved is therefore
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 ] && [ $((_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."
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
 
@@ -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: FIVE — cat, sed, sed, head, awk. (The count said four
40
- # until it was traced with `bash -x` on Windows; the `cat` on the next line reads stdin and had been read
41
- # past.) Measured end to end here: 5 spawns, ~328 ms per prompt idle. eval/smoke-test.sh §7y pins the shape.
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
- IN="$(cat)"
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
- BEGIN { np = " " norm(ENVIRON["CREW_PROMPT"]) " "; bestA=0; bestS=0; nameA=""; nameS=""; if (readonly()) exit }
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 eleven
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 GUARANTEES the handoff recommendation reaches the USER.
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
- # guaranteed USER-facing alert. A new session (or /clear) mints a new session_id and re-arms both thresholds.
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
- IN="$(cat 2>/dev/null || true)"
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
- # Loop guard: if this Stop is already a Stop-hook continuation, do nothing (defensive; we never block anyway).
18
- case "$IN" in
19
- *'"stop_hook_active":true'*|*'"stop_hook_active": true'*) exit 0 ;;
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
- if [ "${AUTOC:-0}" -gt 0 ] && [ ! -e "$(marker autocompact)" ]; then
78
- : > "$(marker autocompact)" 2>/dev/null || true
79
- printf '{"systemMessage":"⚠️ Auto-compaction has fired %s time(s) this session — 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"
80
- exit 0
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" ] || exit 0
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" ] || exit 0
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]*) exit 0 ;; esac
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" ] || exit 0
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"