sequant 2.7.0 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +18 -2
  4. package/dist/bin/cli.d.ts +1 -1
  5. package/dist/bin/cli.js +12 -2
  6. package/dist/bin/preflight.d.ts +21 -0
  7. package/dist/bin/preflight.js +45 -0
  8. package/dist/marketplace/external_plugins/sequant/.claude-plugin/plugin.json +1 -1
  9. package/dist/marketplace/external_plugins/sequant/README.md +2 -0
  10. package/dist/marketplace/external_plugins/sequant/hooks/post-tool.sh +18 -3
  11. package/dist/marketplace/external_plugins/sequant/hooks/pre-tool.sh +330 -57
  12. package/dist/marketplace/external_plugins/sequant/skills/_shared/references/force-push.md +34 -0
  13. package/dist/marketplace/external_plugins/sequant/skills/assess/SKILL.md +117 -19
  14. package/dist/marketplace/external_plugins/sequant/skills/assess/references/predicted-collision-detection.md +9 -6
  15. package/dist/marketplace/external_plugins/sequant/skills/exec/SKILL.md +29 -0
  16. package/dist/marketplace/external_plugins/sequant/skills/fullsolve/SKILL.md +1 -1
  17. package/dist/marketplace/external_plugins/sequant/skills/loop/SKILL.md +100 -2
  18. package/dist/marketplace/external_plugins/sequant/skills/qa/SKILL.md +24 -0
  19. package/dist/marketplace/external_plugins/sequant/skills/qa/references/anti-pattern-detection.md +285 -0
  20. package/dist/marketplace/external_plugins/sequant/skills/qa/references/call-site-review.md +202 -0
  21. package/dist/marketplace/external_plugins/sequant/skills/qa/references/quality-gates.md +287 -0
  22. package/dist/marketplace/external_plugins/sequant/skills/qa/references/test-quality-checklist.md +272 -0
  23. package/dist/marketplace/external_plugins/sequant/skills/qa/references/testing-requirements.md +40 -0
  24. package/dist/marketplace/external_plugins/sequant/skills/qa/scripts/quality-checks.sh +95 -11
  25. package/dist/marketplace/external_plugins/sequant/skills/references/shared/framework-gotchas.md +186 -0
  26. package/dist/marketplace/external_plugins/sequant/skills/reflect/SKILL.md +27 -13
  27. package/dist/marketplace/external_plugins/sequant/skills/reflect/references/documentation-tiers.md +80 -68
  28. package/dist/marketplace/external_plugins/sequant/skills/reflect/references/phase-reflection.md +31 -15
  29. package/dist/marketplace/external_plugins/sequant/skills/release/SKILL.md +669 -0
  30. package/dist/marketplace/external_plugins/sequant/skills/spec/references/verification-criteria.md +1 -1
  31. package/dist/marketplace/external_plugins/sequant/skills/test/references/browser-testing-patterns.md +423 -0
  32. package/dist/marketplace/external_plugins/sequant/skills/upstream/SKILL.md +419 -0
  33. package/dist/src/commands/logs.js +6 -1
  34. package/dist/src/commands/run-display.d.ts +20 -0
  35. package/dist/src/commands/run-display.js +80 -1
  36. package/dist/src/commands/stats.js +47 -0
  37. package/dist/src/lib/assess-collision-detect.d.ts +19 -2
  38. package/dist/src/lib/assess-collision-detect.js +68 -4
  39. package/dist/src/lib/cli-ui/run-renderer.js +17 -9
  40. package/dist/src/lib/errors.d.ts +91 -0
  41. package/dist/src/lib/errors.js +118 -0
  42. package/dist/src/lib/manifest.js +1 -17
  43. package/dist/src/lib/version-check.d.ts +19 -0
  44. package/dist/src/lib/version-check.js +45 -5
  45. package/dist/src/lib/workflow/batch-executor.d.ts +13 -0
  46. package/dist/src/lib/workflow/batch-executor.js +142 -24
  47. package/dist/src/lib/workflow/chain-preflight.d.ts +89 -0
  48. package/dist/src/lib/workflow/chain-preflight.js +199 -0
  49. package/dist/src/lib/workflow/chain-resume.d.ts +116 -0
  50. package/dist/src/lib/workflow/chain-resume.js +166 -0
  51. package/dist/src/lib/workflow/dependency-markers.d.ts +29 -0
  52. package/dist/src/lib/workflow/dependency-markers.js +79 -0
  53. package/dist/src/lib/workflow/drivers/agent-driver.d.ts +17 -0
  54. package/dist/src/lib/workflow/drivers/claude-code.d.ts +29 -0
  55. package/dist/src/lib/workflow/drivers/claude-code.js +136 -8
  56. package/dist/src/lib/workflow/error-classifier.d.ts +9 -2
  57. package/dist/src/lib/workflow/error-classifier.js +14 -1
  58. package/dist/src/lib/workflow/log-writer.d.ts +1 -1
  59. package/dist/src/lib/workflow/log-writer.js +6 -8
  60. package/dist/src/lib/workflow/metrics-schema.d.ts +39 -0
  61. package/dist/src/lib/workflow/metrics-schema.js +16 -0
  62. package/dist/src/lib/workflow/metrics-writer.d.ts +2 -1
  63. package/dist/src/lib/workflow/phase-executor.d.ts +50 -0
  64. package/dist/src/lib/workflow/phase-executor.js +151 -17
  65. package/dist/src/lib/workflow/run-log-schema.d.ts +26 -0
  66. package/dist/src/lib/workflow/run-log-schema.js +52 -1
  67. package/dist/src/lib/workflow/run-orchestrator.d.ts +14 -0
  68. package/dist/src/lib/workflow/run-orchestrator.js +291 -30
  69. package/dist/src/lib/workflow/state-manager.d.ts +1 -0
  70. package/dist/src/lib/workflow/state-manager.js +6 -0
  71. package/dist/src/lib/workflow/state-schema.d.ts +3 -0
  72. package/dist/src/lib/workflow/state-schema.js +7 -0
  73. package/dist/src/lib/workflow/status-derivation.d.ts +30 -0
  74. package/dist/src/lib/workflow/status-derivation.js +27 -0
  75. package/dist/src/lib/workflow/types.d.ts +40 -0
  76. package/dist/src/lib/workflow/worktree-manager.d.ts +43 -1
  77. package/dist/src/lib/workflow/worktree-manager.js +103 -33
  78. package/dist/src/mcp/tools/run.d.ts +2 -0
  79. package/dist/src/mcp/tools/run.js +2 -0
  80. package/dist/src/ui/tui/theme.d.ts +18 -4
  81. package/dist/src/ui/tui/theme.js +18 -4
  82. package/package.json +5 -6
  83. package/templates/hooks/post-tool.sh +18 -3
  84. package/templates/hooks/pre-tool.sh +330 -57
  85. package/templates/scripts/cleanup-worktree.sh +103 -14
  86. package/templates/skills/_shared/references/force-push.md +34 -0
  87. package/templates/skills/assess/SKILL.md +117 -19
  88. package/templates/skills/assess/references/predicted-collision-detection.md +9 -6
  89. package/templates/skills/exec/SKILL.md +29 -0
  90. package/templates/skills/fullsolve/SKILL.md +1 -1
  91. package/templates/skills/loop/SKILL.md +100 -2
  92. package/templates/skills/qa/SKILL.md +24 -0
  93. package/templates/skills/qa/references/anti-pattern-detection.md +285 -0
  94. package/templates/skills/qa/references/call-site-review.md +202 -0
  95. package/templates/skills/qa/references/quality-gates.md +287 -0
  96. package/templates/skills/qa/references/test-quality-checklist.md +272 -0
  97. package/templates/skills/qa/references/testing-requirements.md +40 -0
  98. package/templates/skills/qa/scripts/quality-checks.sh +95 -11
  99. package/templates/skills/references/shared/framework-gotchas.md +186 -0
  100. package/templates/skills/reflect/SKILL.md +27 -13
  101. package/templates/skills/reflect/references/documentation-tiers.md +80 -68
  102. package/templates/skills/reflect/references/phase-reflection.md +31 -15
  103. package/templates/skills/release/SKILL.md +669 -0
  104. package/templates/skills/spec/references/verification-criteria.md +1 -1
  105. package/templates/skills/test/references/browser-testing-patterns.md +423 -0
  106. package/templates/skills/upstream/SKILL.md +419 -0
@@ -35,18 +35,207 @@ fi
35
35
 
36
36
  _TMPDIR="${TMPDIR:-/tmp}"
37
37
 
38
- # Use CLAUDE_PLUGIN_DATA for persistent logs (survives plugin updates)
39
- if [[ -n "${CLAUDE_PLUGIN_DATA}" ]]; then
38
+ # Log sink (#763 AC-5c: blocked-command history must survive to be a useful
39
+ # regression corpus, so $TMPDIR which macOS purges — is a last resort only).
40
+ #
41
+ # This block MUST stay identical to the one in post-tool.sh. The two hooks
42
+ # write the *same* claude-timing.log (START here, END there) and the same
43
+ # claude-quality.log, so any divergence silently splits every START/END pair
44
+ # across two files.
45
+ #
46
+ # Every candidate is absolute and lives outside the repo. A repo-local or
47
+ # relative path is wrong three times over: it resolves against the hook's cwd
48
+ # (which Claude Code does not pin), it yields one sink per directory instead
49
+ # of the single corpus AC-5 asks for, and — worst — creating it inside a repo
50
+ # makes `git status --porcelain` non-empty, which silently defeats the
51
+ # no-changes guard further down that reads exactly that output.
52
+ if [[ -n "${CLAUDE_PLUGIN_DATA:-}" ]]; then
40
53
  _LOG_DIR="${CLAUDE_PLUGIN_DATA}/logs"
41
- mkdir -p "$_LOG_DIR"
54
+ elif [[ -n "${HOME:-}" ]]; then
55
+ _LOG_DIR="${HOME}/.sequant/logs"
42
56
  else
43
57
  _LOG_DIR="${_TMPDIR}"
44
58
  fi
59
+ mkdir -p "$_LOG_DIR" 2>/dev/null || _LOG_DIR="${_TMPDIR}"
45
60
 
46
61
  TIMING_LOG="${_LOG_DIR}/claude-timing.log"
47
62
  HOOK_LOG="${_LOG_DIR}/claude-hook.log"
48
63
  PARALLEL_MARKER_PREFIX="${_TMPDIR}/claude-parallel-"
49
64
 
65
+ # === HELPERS ===
66
+
67
+ # rotate_log <file> — keep the last 500 lines once a log passes 1000, to
68
+ # prevent unbounded growth. Shared by TIMING_LOG and HOOK_LOG (#763 AC-5b).
69
+ rotate_log() {
70
+ local f="$1"
71
+ if [[ -f "$f" ]]; then
72
+ local lc
73
+ lc=$(wc -l < "$f" 2>/dev/null || echo 0)
74
+ if [[ "$lc" -gt 1000 ]]; then
75
+ tail -500 "$f" > "${f}.tmp" && mv "${f}.tmp" "$f"
76
+ fi
77
+ fi
78
+ }
79
+
80
+ # redact_secrets <content> — mask known token shapes before they reach a log.
81
+ # Mirrors the detection patterns in check_secrets(). $TOOL_INPUT can carry
82
+ # tokens (a `gh` body, an inline export), and HOOK_LOG now records full
83
+ # command text, so redaction is required (#763 AC-5a). This covers the same
84
+ # six shapes check_secrets detects; other secret formats are not masked.
85
+ redact_secrets() {
86
+ printf '%s' "$1" | sed -E \
87
+ -e 's/sk-[a-zA-Z0-9]{32,}/[REDACTED]/g' \
88
+ -e 's/sk_live_[a-zA-Z0-9]{24,}/[REDACTED]/g' \
89
+ -e 's/AKIA[A-Z0-9]{16}/[REDACTED]/g' \
90
+ -e 's/ghp_[a-zA-Z0-9]{36}/[REDACTED]/g' \
91
+ -e 's/xoxb-[0-9]{10,}(-[a-zA-Z0-9]+)+/[REDACTED]/g' \
92
+ -e 's/AIza[a-zA-Z0-9_-]{35}/[REDACTED]/g'
93
+ }
94
+
95
+ # log_block <rule-id> — record which guard fired and the offending command
96
+ # (redacted) to a single rotated sink, so blocks can be audited and turned
97
+ # into a real regression corpus (#763 AC-5). Writes to HOOK_LOG only; the
98
+ # human-facing HOOK_BLOCKED message still goes to stderr at each call site.
99
+ log_block() {
100
+ local rule="$1" redacted
101
+ redacted=$(redact_secrets "$TOOL_INPUT")
102
+ printf '%s BLOCKED [%s] %s\n' "$(date +%s.%N)" "$rule" "$redacted" >> "$HOOK_LOG"
103
+ rotate_log "$HOOK_LOG"
104
+ }
105
+
106
+ # emit_segments <command> — split a shell command into the pieces the shell
107
+ # would actually execute, and print each (one per line) with inert text removed
108
+ # and leading env-assignments stripped, so guards can match a segment's
109
+ # *command words* without tripping on payload text carried as an argument (#763).
110
+ #
111
+ # Command-word positions (a guard MAY match here):
112
+ # - each segment between the operators ; && || | and newline
113
+ # - the body of a subshell `( ... )` and of a command substitution `$( ... )`,
114
+ # including `$( ... )` inside double quotes — the shell runs those.
115
+ #
116
+ # Inert (a guard must NEVER match here):
117
+ # - single-quoted text: the shell expands nothing inside it
118
+ # - double-quoted text, except for `$( ... )`
119
+ # - heredoc bodies: they are stdin *data*, not code. This one is load-bearing.
120
+ # The standard commit idiom is `git commit -m "$(cat <<'EOF' ... EOF)"`;
121
+ # without skipping the body, a commit message that merely *mentions*
122
+ # `git push --force` would be blocked — which would reintroduce the
123
+ # #564/#570 false-positive class through the command-substitution door.
124
+ #
125
+ # Argument positions are deliberately NOT command words, so `xargs sudo ...`
126
+ # and `bash -c "sudo ..."` are allowed: the guarded word is data being handed
127
+ # to another program, and treating it as code is what produced #564/#570.
128
+ #
129
+ # This is deliberately NOT a full shell parser: it does not emulate backslash
130
+ # escapes or globbing — acceptable for an accident-prevention layer (the real
131
+ # security boundary is Claude Code's permission system, not this hook). A
132
+ # crafted quoted string could in principle hide an operator; that is out of
133
+ # scope for a non-adversarial guard.
134
+ emit_segments() {
135
+ printf '%s' "$1" | awk '
136
+ function emit(s, t) {
137
+ t = s
138
+ sub(/^[ \t]+/, "", t); sub(/[ \t]+$/, "", t)
139
+ # Drop leading VAR=val assignments so `FOO=bar sudo ...` still keys off `sudo`.
140
+ while (match(t, /^[A-Za-z_][A-Za-z0-9_]*=[^ \t]*[ \t]+/)) t = substr(t, RLENGTH + 1)
141
+ if (length(t) > 0) print t
142
+ }
143
+ BEGIN { sq = sprintf("%c", 39); dq = sprintf("%c", 34) }
144
+ { full = (NR == 1) ? $0 : full "\n" $0 }
145
+ END {
146
+ n = length(full); seg = ""; cur = ""; depth = 0; nhd = 0
147
+ for (i = 1; i <= n; i++) {
148
+ c = substr(full, i, 1)
149
+ nc = (i < n) ? substr(full, i + 1, 1) : ""
150
+
151
+ # Single-quoted: wholly inert until the closing quote.
152
+ if (cur == sq) { if (c == sq) cur = ""; continue }
153
+
154
+ # Double-quoted: inert, except that $( ... ) is live code.
155
+ if (cur == dq) {
156
+ if (c == "$" && nc == "(") {
157
+ emit(seg); seg = ""
158
+ stack[++depth] = dq; cur = ""; i++
159
+ continue
160
+ }
161
+ if (c == dq) cur = ""
162
+ continue
163
+ }
164
+
165
+ # --- live context ---
166
+ if (c == sq) { cur = sq; seg = seg " "; continue }
167
+ if (c == dq) { cur = dq; seg = seg " "; continue }
168
+
169
+ # Heredoc introducer: << or <<-, but not the <<< herestring. Record
170
+ # the delimiter; the body itself is skipped at the newline below.
171
+ if (c == "<" && nc == "<" && substr(full, i + 2, 1) != "<") {
172
+ j = i + 2
173
+ if (substr(full, j, 1) == "-") j++
174
+ while (j <= n && substr(full, j, 1) ~ /[ \t]/) j++
175
+ q = substr(full, j, 1); delim = ""
176
+ if (q == sq || q == dq) {
177
+ j++
178
+ while (j <= n && substr(full, j, 1) != q) { delim = delim substr(full, j, 1); j++ }
179
+ j++
180
+ } else {
181
+ while (j <= n && substr(full, j, 1) ~ /[A-Za-z0-9_.-]/) { delim = delim substr(full, j, 1); j++ }
182
+ }
183
+ if (length(delim) > 0) hd[++nhd] = delim
184
+ seg = seg " "; i = j - 1
185
+ continue
186
+ }
187
+
188
+ # Subshell / command substitution: the body is live code.
189
+ if (c == "$" && nc == "(") { emit(seg); seg = ""; stack[++depth] = cur; cur = ""; i++; continue }
190
+ if (c == "(") { emit(seg); seg = ""; stack[++depth] = cur; continue }
191
+ if (c == ")") { emit(seg); seg = ""; if (depth > 0) cur = stack[depth--]; continue }
192
+
193
+ if (c == ";" || c == "&" || c == "|") {
194
+ emit(seg); seg = ""
195
+ if ((c == "&" && nc == "&") || (c == "|" && nc == "|")) i++
196
+ continue
197
+ }
198
+
199
+ if (c == "\n") {
200
+ emit(seg); seg = ""
201
+ # Any heredocs opened on the line just ended: their bodies are
202
+ # data, so skip forward to each delimiter line.
203
+ while (nhd > 0) {
204
+ d = hd[1]
205
+ for (k = 1; k < nhd; k++) hd[k] = hd[k + 1]
206
+ nhd--
207
+ while (i < n) {
208
+ ls = i + 1
209
+ le = index(substr(full, ls), "\n")
210
+ if (le == 0) { line = substr(full, ls); i = n }
211
+ else { line = substr(full, ls, le - 1); i = ls + le - 1 }
212
+ t = line
213
+ sub(/^[ \t]+/, "", t); sub(/[ \t]+$/, "", t)
214
+ if (t == d) break
215
+ }
216
+ }
217
+ continue
218
+ }
219
+ seg = seg c
220
+ }
221
+ emit(seg)
222
+ }
223
+ '
224
+ }
225
+
226
+ # seg_match <ere> — 0 if any command segment of the current Bash command
227
+ # matches the extended regex. Precompute $SEGMENTS once per invocation so a
228
+ # dozen guards do not each re-run the splitter (#763 AC-9).
229
+ seg_match() {
230
+ [[ -n "$SEGMENTS" ]] && grep -qE "$1" <<< "$SEGMENTS"
231
+ }
232
+
233
+ # Precompute the segment list once, for Bash commands only.
234
+ SEGMENTS=""
235
+ if [[ "$TOOL_NAME" == "Bash" ]]; then
236
+ SEGMENTS=$(emit_segments "$TOOL_INPUT")
237
+ fi
238
+
50
239
  # === AGENT ID DETECTION ===
51
240
  # For parallel agents, detect group ID from marker files
52
241
  # Format: ${_TMPDIR}/claude-parallel-<group-id>.marker
@@ -70,58 +259,138 @@ fi
70
259
 
71
260
  # === LOG ROTATION ===
72
261
  # Rotate if over 1000 lines to prevent unbounded growth
73
- if [[ -f "$TIMING_LOG" ]]; then
74
- LINE_COUNT=$(wc -l < "$TIMING_LOG" 2>/dev/null || echo 0)
75
- if [[ "$LINE_COUNT" -gt 1000 ]]; then
76
- tail -500 "$TIMING_LOG" > "${TIMING_LOG}.tmp" && mv "${TIMING_LOG}.tmp" "$TIMING_LOG"
262
+ rotate_log "$TIMING_LOG"
263
+
264
+ # === PLUGIN STALENESS CHECK (#784, hardened #788) ===
265
+ # Claude Code pins plugin installs to a commit SHA and never auto-updates
266
+ # them, so an installed cache can run months-old skills/hooks while the
267
+ # marketplace clone on the same disk tracks main and stays current. Compare
268
+ # the running plugin's version against the local marketplace clone and nudge.
269
+ # Warn-only (never blocks, exit code untouched), rate-limited to once per
270
+ # day via a stamp file, zero network, grep/sed/awk only. Gated on the
271
+ # script's own path so repo-local dev copies (hooks/, templates/hooks/,
272
+ # .claude/hooks/) never warn — only real cache installs resolve under
273
+ # */plugins/cache/*.
274
+ _STALE_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd || true)"
275
+ if [[ "$_STALE_SCRIPT_DIR" == */plugins/cache/* ]]; then
276
+ _STALE_STAMP="${_LOG_DIR}/plugin-stale-warned.stamp"
277
+ _STALE_TODAY="$(date +%Y-%m-%d)"
278
+ if [[ "$(cat "$_STALE_STAMP" 2>/dev/null)" != "$_STALE_TODAY" ]]; then
279
+ # The plugin root is one level above hooks/; the plugins root is the
280
+ # prefix before /plugins/cache/, so a relocated CLAUDE_CONFIG_DIR
281
+ # works without hardcoding ~/.claude.
282
+ _STALE_RUNNING_JSON="${_STALE_SCRIPT_DIR%/hooks}/.claude-plugin/plugin.json"
283
+ _STALE_PLUGINS_ROOT="${_STALE_SCRIPT_DIR%%/plugins/cache/*}/plugins"
284
+ # Locate the marketplace clone by scanning, not by a hardcoded dir
285
+ # name: Claude Code names the dir after the marketplace, which may
286
+ # differ across versions. Pick the first clone whose JSON declares
287
+ # the sequant plugin. The [[ -f ]] guard also absorbs a literal
288
+ # unmatched glob, so "no clone present" is a silent no-op.
289
+ _STALE_MARKET_JSON=""
290
+ for _stale_mkt in "$_STALE_PLUGINS_ROOT"/marketplaces/*/.claude-plugin/marketplace.json; do
291
+ [[ -f "$_stale_mkt" ]] || continue
292
+ grep -q '"name"[[:space:]]*:[[:space:]]*"sequant"' "$_stale_mkt" 2>/dev/null || continue
293
+ _STALE_MARKET_JSON="$_stale_mkt"
294
+ break
295
+ done
296
+ if [[ -n "$_STALE_MARKET_JSON" && -f "$_STALE_RUNNING_JSON" ]]; then
297
+ # marketplace.json's only "version" key is the plugin entry's, so
298
+ # first match is correct (sequant is the sole plugin listed).
299
+ _STALE_RUNNING_VER=$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$_STALE_RUNNING_JSON" 2>/dev/null | head -1)
300
+ _STALE_MARKET_VER=$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$_STALE_MARKET_JSON" 2>/dev/null | head -1)
301
+ # Warn only when the install is strictly BEHIND the marketplace —
302
+ # a dev cache ahead of the marketplace is not stale. Dotted
303
+ # numeric compare in awk (no `sort -V` dependency, which is a
304
+ # GNU-ism unreliable on BSD/macOS); a non-numeric prerelease
305
+ # component is compared by its leading integer, e.g.
306
+ # "2.8.0-beta" sorts as 2.8.0 — precise enough for a nudge.
307
+ if [[ -n "$_STALE_RUNNING_VER" && -n "$_STALE_MARKET_VER" ]] && \
308
+ awk -v a="$_STALE_RUNNING_VER" -v b="$_STALE_MARKET_VER" '
309
+ function cmp(x, y, ax, ay, i, av, bv, n) {
310
+ n = split(x, ax, "."); split(y, ay, ".")
311
+ for (i = 1; i <= n || (i in ay); i++) {
312
+ av = (i in ax) ? ax[i] + 0 : 0
313
+ bv = (i in ay) ? ay[i] + 0 : 0
314
+ if (av < bv) return -1
315
+ if (av > bv) return 1
316
+ }
317
+ return 0
318
+ }
319
+ BEGIN { exit !(cmp(a, b) < 0) }
320
+ '; then
321
+ echo "sequant plugin v${_STALE_RUNNING_VER} is stale (marketplace has v${_STALE_MARKET_VER}) — run: claude plugin update sequant@sequant, then restart Claude Code" >&2
322
+ printf '%s' "$_STALE_TODAY" > "$_STALE_STAMP" 2>/dev/null || true
323
+ fi
324
+ fi
77
325
  fi
78
326
  fi
79
327
 
80
328
  # === CATASTROPHIC BLOCKS ===
81
329
  # These should NEVER run in any automated context
82
330
  # Only check Bash commands — Write/Edit content may contain these as config strings
331
+ #
332
+ # Every guard below matches against $SEGMENTS (quote-stripped command words),
333
+ # not the raw command string. This is what fixes #763: body text such as
334
+ # `gh issue create --body "...git push --force..."` carries the token inside a
335
+ # quoted argument, so it never appears as a command word and cannot trip a
336
+ # guard — which is why the old `^gh (issue|pr) ` carve-outs are gone entirely.
337
+ # Conversely a real command chained after an allowed one
338
+ # (`gh issue list && git push --force`) is its own segment and still blocks.
83
339
  if [[ "$TOOL_NAME" == "Bash" ]]; then
84
340
 
85
- # Secrets/credentials
86
- # Skip check for gh commands (comment/pr bodies may contain example text)
87
- if ! echo "$TOOL_INPUT" | grep -qE '^gh (issue|pr) '; then
88
- # Pattern requires command to START with file reader (not match in quoted strings)
89
- if echo "$TOOL_INPUT" | grep -qE '^(cat|less|head|tail|more) .*\.(env|pem|key)'; then
90
- echo "HOOK_BLOCKED: Reading secret file" | tee -a "$HOOK_LOG" >&2
91
- exit 2
92
- fi
341
+ # Secrets/credentials — a file reader at command-word position
342
+ if seg_match '^(cat|less|head|tail|more) .*\.(env|pem|key)'; then
343
+ log_block "secret-file"
344
+ echo "HOOK_BLOCKED: Reading secret file" >&2
345
+ exit 2
346
+ fi
93
347
 
94
- if echo "$TOOL_INPUT" | grep -qE '^(cat|less) .*~/\.(ssh|aws|gnupg|config/gh)'; then
95
- echo "HOOK_BLOCKED: Reading credential directory" | tee -a "$HOOK_LOG" >&2
96
- exit 2
97
- fi
348
+ if seg_match '^(cat|less) .*~/\.(ssh|aws|gnupg|config/gh)'; then
349
+ log_block "credential-dir"
350
+ echo "HOOK_BLOCKED: Reading credential directory" >&2
351
+ exit 2
98
352
  fi
99
353
 
100
354
  # Bare environment dump
101
- if echo "$TOOL_INPUT" | grep -qE '^(env|printenv|export)$'; then
102
- echo "HOOK_BLOCKED: Environment dump" | tee -a "$HOOK_LOG" >&2
355
+ if seg_match '^(env|printenv|export)$'; then
356
+ log_block "env-dump"
357
+ echo "HOOK_BLOCKED: Environment dump" >&2
103
358
  exit 2
104
359
  fi
105
360
 
106
- # Destructive system commands
107
- # Skip for gh issue/pr commands body text may legitimately reference these tokens (#570)
108
- if ! echo "$TOOL_INPUT" | grep -qE '^gh (issue|pr) ' && echo "$TOOL_INPUT" | grep -qE 'sudo|rm -rf /|rm -rf ~|rm -rf \$HOME'; then
109
- echo "HOOK_BLOCKED: Destructive system command" | tee -a "$HOOK_LOG" >&2
361
+ # Privilege escalation — `sudo` at a command-word position only (#763 AC-2).
362
+ # The `rm -rf /|~|$HOME` alternation that used to live here was deleted (AC-1):
363
+ # it was pure redundancy with Claude Code's native dangerous-rm analyzer (which
364
+ # still fires under bypassPermissions and covers root / top-level / home /
365
+ # workspace-ancestor targets), and its `rm -rf /` substring matched every
366
+ # absolute path, blocking ordinary worktree/scratch deletes. `sudo` is NOT
367
+ # natively covered, so it stays — but keyed off the command word, so
368
+ # `echo 'never sudo'` and `grep -r sudoku src/` are allowed.
369
+ #
370
+ # Because this guard has no native backstop, emit_segments treats every
371
+ # position the shell would execute as a command word: plain, chained,
372
+ # `( sudo ... )`, `$(sudo ...)`, and `"$(sudo ...)"` all block. Argument
373
+ # positions (`xargs sudo ...`, `bash -c "sudo ..."`) are an accepted gap —
374
+ # there the word is data handed to another program, and matching it is the
375
+ # mistake that produced #564/#570.
376
+ if seg_match '^sudo( |$)'; then
377
+ log_block "sudo"
378
+ echo "HOOK_BLOCKED: sudo command" >&2
110
379
  exit 2
111
380
  fi
112
381
 
113
382
  # Deployment (should never happen in issue automation)
114
- # Skip for gh issue/pr commands — body text may legitimately reference these tokens (#570)
115
- if ! echo "$TOOL_INPUT" | grep -qE '^gh (issue|pr) ' && echo "$TOOL_INPUT" | grep -qE 'vercel (deploy|--prod)|terraform (apply|destroy)|kubectl (apply|delete)'; then
116
- echo "HOOK_BLOCKED: Deployment command" | tee -a "$HOOK_LOG" >&2
383
+ if seg_match 'vercel (deploy|--prod)|terraform (apply|destroy)|kubectl (apply|delete)'; then
384
+ log_block "deployment"
385
+ echo "HOOK_BLOCKED: Deployment command" >&2
117
386
  exit 2
118
387
  fi
119
388
 
120
389
  # Force push
121
390
  # Pattern requires -f to be a standalone flag (not part of branch name like -fix)
122
- # Skip for gh issue/pr commands — body text may legitimately reference these tokens (#564)
123
- if ! echo "$TOOL_INPUT" | grep -qE '^gh (issue|pr) ' && echo "$TOOL_INPUT" | grep -qE 'git push.*(--force| -f($| ))'; then
124
- echo "HOOK_BLOCKED: Force push" | tee -a "$HOOK_LOG" >&2
391
+ if seg_match 'git push.*(--force| -f($| ))'; then
392
+ log_block "force-push"
393
+ echo "HOOK_BLOCKED: Force push" >&2
125
394
  exit 2
126
395
  fi
127
396
 
@@ -130,11 +399,10 @@ fi
130
399
  # - Unpushed commits on main/master
131
400
  # - Uncommitted changes (staged or unstaged)
132
401
  # - Unfinished merge in progress
133
- # Skip for gh issue/pr commands — body text may legitimately reference these tokens (#570)
134
- if ! echo "$TOOL_INPUT" | grep -qE '^gh (issue|pr) ' && echo "$TOOL_INPUT" | grep -qE 'git reset.*(--hard|origin)'; then
402
+ if seg_match 'git reset.*(--hard|origin)'; then
135
403
  CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
136
404
  BLOCK_REASONS=""
137
-
405
+
138
406
  # Check 1: Unpushed commits (only on main/master)
139
407
  if [[ "$CURRENT_BRANCH" == "main" || "$CURRENT_BRANCH" == "master" ]]; then
140
408
  UNPUSHED=$(git log origin/$CURRENT_BRANCH..HEAD --oneline 2>/dev/null | wc -l | tr -d ' ')
@@ -142,21 +410,22 @@ if ! echo "$TOOL_INPUT" | grep -qE '^gh (issue|pr) ' && echo "$TOOL_INPUT" | gre
142
410
  BLOCK_REASONS="${BLOCK_REASONS} - $UNPUSHED unpushed commit(s) on $CURRENT_BRANCH\n"
143
411
  fi
144
412
  fi
145
-
413
+
146
414
  # Check 2: Uncommitted changes (staged or unstaged)
147
415
  UNCOMMITTED=$(git status --porcelain 2>/dev/null | wc -l | tr -d ' ')
148
416
  if [[ "$UNCOMMITTED" -gt 0 ]]; then
149
417
  BLOCK_REASONS="${BLOCK_REASONS} - $UNCOMMITTED uncommitted file(s)\n"
150
418
  fi
151
-
419
+
152
420
  # Check 3: Unfinished merge
153
421
  GIT_DIR=$(git rev-parse --git-dir 2>/dev/null || echo ".git")
154
422
  if [[ -f "$GIT_DIR/MERGE_HEAD" ]]; then
155
423
  BLOCK_REASONS="${BLOCK_REASONS} - Unfinished merge in progress\n"
156
424
  fi
157
-
425
+
158
426
  # Block if any reasons found
159
427
  if [[ -n "$BLOCK_REASONS" ]]; then
428
+ log_block "git-reset-hard"
160
429
  {
161
430
  echo "HOOK_BLOCKED: git reset --hard would lose local work:"
162
431
  echo -e "$BLOCK_REASONS"
@@ -165,15 +434,15 @@ if ! echo "$TOOL_INPUT" | grep -qE '^gh (issue|pr) ' && echo "$TOOL_INPUT" | gre
165
434
  echo " git stash # save changes"
166
435
  echo " git merge --abort # cancel merge"
167
436
  echo " Or run directly in terminal (outside Claude Code) to bypass"
168
- } | tee -a "$HOOK_LOG" >&2
437
+ } >&2
169
438
  exit 2
170
439
  fi
171
440
  fi
172
441
 
173
442
  # CI/CD triggers (automation shouldn't trigger more automation)
174
- # Skip for gh issue/pr commands — body text may legitimately reference these tokens (#570)
175
- if ! echo "$TOOL_INPUT" | grep -qE '^gh (issue|pr) ' && echo "$TOOL_INPUT" | grep -qE 'gh workflow run'; then
176
- echo "HOOK_BLOCKED: Workflow trigger" | tee -a "$HOOK_LOG" >&2
443
+ if seg_match 'gh workflow run'; then
444
+ log_block "workflow-trigger"
445
+ echo "HOOK_BLOCKED: Workflow trigger" >&2
177
446
  exit 2
178
447
  fi
179
448
 
@@ -230,28 +499,29 @@ check_sensitive_files() {
230
499
 
231
500
  if [[ "${CLAUDE_HOOKS_SECURITY:-true}" != "false" ]]; then
232
501
  # Security checks for git commit
233
- # Skip for gh issue/pr commands body text may legitimately reference these tokens (#564)
234
- if [[ "$TOOL_NAME" == "Bash" ]] && ! echo "$TOOL_INPUT" | grep -qE '^gh (issue|pr) ' && echo "$TOOL_INPUT" | grep -qE 'git commit'; then
502
+ if [[ "$TOOL_NAME" == "Bash" ]] && seg_match 'git commit'; then
235
503
  # Skip security checks if --no-verify is used
236
504
  if ! echo "$TOOL_INPUT" | grep -qE -- '--no-verify'; then
237
505
  # Check staged files for secrets
238
506
  STAGED_CONTENT=$(git diff --cached 2>/dev/null || true)
239
507
  if [[ -n "$STAGED_CONTENT" ]] && check_secrets "$STAGED_CONTENT"; then
508
+ log_block "staged-secret"
240
509
  {
241
510
  echo "HOOK_BLOCKED: Hardcoded secret detected in staged changes"
242
511
  echo " Use 'git commit --no-verify' to bypass if this is a false positive"
243
- } | tee -a "$HOOK_LOG" >&2
512
+ } >&2
244
513
  exit 2
245
514
  fi
246
515
 
247
516
  # Check for sensitive files in commit
248
517
  STAGED_FILES=$(git diff --cached --name-only 2>/dev/null || true)
249
518
  if [[ -n "$STAGED_FILES" ]] && check_sensitive_files "$STAGED_FILES"; then
519
+ log_block "sensitive-file"
250
520
  {
251
521
  echo "HOOK_BLOCKED: Sensitive file in commit (${STAGED_FILES})"
252
522
  echo " Files like .env, *.pem, *.key should not be committed"
253
523
  echo " Use 'git commit --no-verify' to bypass if this is intentional"
254
- } | tee -a "$HOOK_LOG" >&2
524
+ } >&2
255
525
  exit 2
256
526
  fi
257
527
  fi
@@ -263,8 +533,7 @@ fi
263
533
  # --- No-Changes Guard (AC-7) ---
264
534
  # Block commits when there are no staged or unstaged changes (prevents empty commits)
265
535
  # Skips for --amend since amending doesn't require new changes
266
- # Skip for gh issue/pr commands body text may legitimately reference these tokens (#564)
267
- if [[ "$TOOL_NAME" == "Bash" ]] && ! echo "$TOOL_INPUT" | grep -qE '^gh (issue|pr) ' && echo "$TOOL_INPUT" | grep -qE 'git commit'; then
536
+ if [[ "$TOOL_NAME" == "Bash" ]] && seg_match 'git commit'; then
268
537
  if ! echo "$TOOL_INPUT" | grep -qE -- '--amend|--allow-empty'; then
269
538
  # Extract target directory from cd command if present (for worktree commits)
270
539
  # Handles: "cd /path && git commit" or "cd /path; git commit"
@@ -281,7 +550,8 @@ if [[ "$TOOL_NAME" == "Bash" ]] && ! echo "$TOOL_INPUT" | grep -qE '^gh (issue|p
281
550
  fi
282
551
 
283
552
  if [[ "$CHANGES" -eq 0 ]]; then
284
- echo "HOOK_BLOCKED: No changes to commit. Stage files with 'git add' first." | tee -a "$HOOK_LOG" >&2
553
+ log_block "no-changes"
554
+ echo "HOOK_BLOCKED: No changes to commit. Stage files with 'git add' first." >&2
285
555
  exit 2
286
556
  fi
287
557
  fi
@@ -291,8 +561,7 @@ fi
291
561
  # Warn (but don't block) when committing outside a feature worktree
292
562
  # This catches accidental commits to main repo during feature work
293
563
  QUALITY_LOG="${_LOG_DIR}/claude-quality.log"
294
- # Skip for gh issue/pr commands body text may legitimately reference these tokens (#564)
295
- if [[ "$TOOL_NAME" == "Bash" ]] && ! echo "$TOOL_INPUT" | grep -qE '^gh (issue|pr) ' && echo "$TOOL_INPUT" | grep -qE 'git commit'; then
564
+ if [[ "$TOOL_NAME" == "Bash" ]] && seg_match 'git commit'; then
296
565
  CWD=$(pwd)
297
566
  if ! echo "$CWD" | grep -qE 'worktrees/feature/'; then
298
567
  echo "$(date +%H:%M:%S) WORKTREE_WARNING: Committing outside feature worktree ($CWD)" >> "$QUALITY_LOG"
@@ -303,8 +572,7 @@ fi
303
572
  # --- Commit Message Validation (AC-3) ---
304
573
  # Enforce conventional commits format: type(scope): description
305
574
  # Types: feat|fix|docs|style|refactor|test|chore|ci|build|perf
306
- # Skip for gh issue/pr commands body text may legitimately reference these tokens (#564)
307
- if [[ "$TOOL_NAME" == "Bash" ]] && ! echo "$TOOL_INPUT" | grep -qE '^gh (issue|pr) ' && echo "$TOOL_INPUT" | grep -qE 'git commit'; then
575
+ if [[ "$TOOL_NAME" == "Bash" ]] && seg_match 'git commit'; then
308
576
  # Extract message from -m flag
309
577
  MSG=""
310
578
 
@@ -331,6 +599,7 @@ if [[ "$TOOL_NAME" == "Bash" ]] && ! echo "$TOOL_INPUT" | grep -qE '^gh (issue|p
331
599
  # Also accepts ! for breaking changes: feat!: or feat(scope)!:
332
600
  PATTERN='^(feat|fix|docs|style|refactor|test|chore|ci|build|perf)(\([^)]+\))?(!)?\s*:'
333
601
  if ! echo "$MSG" | grep -qE "$PATTERN"; then
602
+ log_block "commit-format"
334
603
  {
335
604
  echo "HOOK_BLOCKED: Commit must follow conventional commits format"
336
605
  echo " Expected: type(scope): description"
@@ -342,7 +611,7 @@ if [[ "$TOOL_NAME" == "Bash" ]] && ! echo "$TOOL_INPUT" | grep -qE '^gh (issue|p
342
611
  fi
343
612
  echo " Types: feat|fix|docs|style|refactor|test|chore|ci|build|perf"
344
613
  echo " Got: $MSG"
345
- } | tee -a "$HOOK_LOG" >&2
614
+ } >&2
346
615
  exit 2
347
616
  fi
348
617
  fi
@@ -377,7 +646,8 @@ if [[ "$TOOL_NAME" == "Edit" || "$TOOL_NAME" == "Write" ]]; then
377
646
  # AC-4 (Issue #31): Check worktree directory exists before path validation
378
647
  # Prevents Write tool from creating non-existent worktree directories
379
648
  if [[ ! -d "$EXPECTED_WORKTREE" ]]; then
380
- echo "HOOK_BLOCKED: Worktree does not exist: $EXPECTED_WORKTREE" | tee -a "$HOOK_LOG" >&2
649
+ log_block "worktree-missing"
650
+ echo "HOOK_BLOCKED: Worktree does not exist: $EXPECTED_WORKTREE" >&2
381
651
  exit 2
382
652
  fi
383
653
 
@@ -399,6 +669,7 @@ if [[ "$TOOL_NAME" == "Edit" || "$TOOL_NAME" == "Write" ]]; then
399
669
  echo "$(date +%H:%M:%S) WORKTREE_BLOCKED: Edit outside expected worktree" >> "$QUALITY_LOG"
400
670
  echo " Expected: $EXPECTED_WORKTREE" >> "$QUALITY_LOG"
401
671
  echo " Got: $FILE_PATH" >> "$QUALITY_LOG"
672
+ log_block "worktree-boundary"
402
673
  {
403
674
  echo "HOOK_BLOCKED: File operation must be within worktree"
404
675
  echo " Worktree: $EXPECTED_WORKTREE"
@@ -406,7 +677,7 @@ if [[ "$TOOL_NAME" == "Edit" || "$TOOL_NAME" == "Write" ]]; then
406
677
  if [[ -n "${SEQUANT_ISSUE:-}" ]]; then
407
678
  echo " Issue: #$SEQUANT_ISSUE"
408
679
  fi
409
- } | tee -a "$HOOK_LOG" >&2
680
+ } >&2
410
681
  exit 2
411
682
  fi
412
683
  fi
@@ -437,7 +708,8 @@ if [[ "${CLAUDE_HOOKS_FILE_LOCKING:-true}" == "true" ]]; then
437
708
  # macOS: use lockf
438
709
  exec 200>"$LOCK_FILE"
439
710
  if ! lockf -t 30 200 2>/dev/null; then
440
- echo "HOOK_BLOCKED: File locked by another agent: $FILE_PATH" | tee -a "$HOOK_LOG" >&2
711
+ log_block "file-lock"
712
+ echo "HOOK_BLOCKED: File locked by another agent: $FILE_PATH" >&2
441
713
  exit 2
442
714
  fi
443
715
  # Lock will be released when the file descriptor closes (process exits)
@@ -445,7 +717,8 @@ if [[ "${CLAUDE_HOOKS_FILE_LOCKING:-true}" == "true" ]]; then
445
717
  # Linux: use flock
446
718
  exec 200>"$LOCK_FILE"
447
719
  if ! flock -w 30 200 2>/dev/null; then
448
- echo "HOOK_BLOCKED: File locked by another agent: $FILE_PATH" | tee -a "$HOOK_LOG" >&2
720
+ log_block "file-lock"
721
+ echo "HOOK_BLOCKED: File locked by another agent: $FILE_PATH" >&2
449
722
  exit 2
450
723
  fi
451
724
  fi
@@ -0,0 +1,34 @@
1
+ # Force-push handoff pattern
2
+
3
+ When you encounter `HOOK_BLOCKED: Force push` from `.claude/hooks/pre-tool.sh:106-111`, **do not attempt to bypass the hook**. The block is intentional — force-pushing rewrites history and can destroy work for others sharing the branch.
4
+
5
+ ## The pattern: hand the command to the user
6
+
7
+ When a force push is genuinely required (e.g., cleaning contamination off a feature branch after a clean rebase), present the exact command to the user prefixed with `!` so they execute it in-session:
8
+
9
+ ```
10
+ ! git push --force-with-lease origin feature/<branch>
11
+ ```
12
+
13
+ The user pastes that into the prompt; the `!` runs it in their shell, output streams back into the conversation, and you continue from there.
14
+
15
+ **Always prefer `--force-with-lease` over raw `--force`.** `--force-with-lease` refuses to overwrite the remote ref if someone else pushed in the meantime, turning a silent stomp into a clean error.
16
+
17
+ ## Why bypass attempts fail
18
+
19
+ `CLAUDE_HOOKS_DISABLED=true git push --force ...` does **not** work. The hook reads `CLAUDE_HOOKS_DISABLED` at the harness level *before* Bash executes the command, so prefixing the env var inside the command line has no effect. Setting it via `export` in a prior tool call doesn't help either — each Bash tool call is a fresh subprocess.
20
+
21
+ ## When force push is legitimate vs. not
22
+
23
+ | Legitimate | Not legitimate |
24
+ |------------|----------------|
25
+ | Cleaning rebase contamination off your own feature branch before PR | Rewriting history on `main`/`master` |
26
+ | Removing accidentally-committed secrets after rotation | "Squashing for cleanliness" on a shared branch |
27
+ | Recovering from a mistakenly-pushed merge commit | Force-pushing over someone else's work |
28
+
29
+ For shared branches, prefer `git revert` over force push.
30
+
31
+ ## Reference
32
+
33
+ - Block definition: `.claude/hooks/pre-tool.sh:106-111`
34
+ - Regex: `git push.*(--force| -f($| ))` — note this can also match the literal strings inside quoted `gh issue/pr` bodies (workaround: write the body to a file first)