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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +18 -2
- package/dist/bin/cli.d.ts +1 -1
- package/dist/bin/cli.js +12 -2
- package/dist/bin/preflight.d.ts +21 -0
- package/dist/bin/preflight.js +45 -0
- package/dist/marketplace/external_plugins/sequant/.claude-plugin/plugin.json +1 -1
- package/dist/marketplace/external_plugins/sequant/README.md +2 -0
- package/dist/marketplace/external_plugins/sequant/hooks/post-tool.sh +18 -3
- package/dist/marketplace/external_plugins/sequant/hooks/pre-tool.sh +330 -57
- package/dist/marketplace/external_plugins/sequant/skills/_shared/references/force-push.md +34 -0
- package/dist/marketplace/external_plugins/sequant/skills/assess/SKILL.md +117 -19
- package/dist/marketplace/external_plugins/sequant/skills/assess/references/predicted-collision-detection.md +9 -6
- package/dist/marketplace/external_plugins/sequant/skills/exec/SKILL.md +29 -0
- package/dist/marketplace/external_plugins/sequant/skills/fullsolve/SKILL.md +1 -1
- package/dist/marketplace/external_plugins/sequant/skills/loop/SKILL.md +100 -2
- package/dist/marketplace/external_plugins/sequant/skills/qa/SKILL.md +24 -0
- package/dist/marketplace/external_plugins/sequant/skills/qa/references/anti-pattern-detection.md +285 -0
- package/dist/marketplace/external_plugins/sequant/skills/qa/references/call-site-review.md +202 -0
- package/dist/marketplace/external_plugins/sequant/skills/qa/references/quality-gates.md +287 -0
- package/dist/marketplace/external_plugins/sequant/skills/qa/references/test-quality-checklist.md +272 -0
- package/dist/marketplace/external_plugins/sequant/skills/qa/references/testing-requirements.md +40 -0
- package/dist/marketplace/external_plugins/sequant/skills/qa/scripts/quality-checks.sh +95 -11
- package/dist/marketplace/external_plugins/sequant/skills/references/shared/framework-gotchas.md +186 -0
- package/dist/marketplace/external_plugins/sequant/skills/reflect/SKILL.md +27 -13
- package/dist/marketplace/external_plugins/sequant/skills/reflect/references/documentation-tiers.md +80 -68
- package/dist/marketplace/external_plugins/sequant/skills/reflect/references/phase-reflection.md +31 -15
- package/dist/marketplace/external_plugins/sequant/skills/release/SKILL.md +669 -0
- package/dist/marketplace/external_plugins/sequant/skills/spec/references/verification-criteria.md +1 -1
- package/dist/marketplace/external_plugins/sequant/skills/test/references/browser-testing-patterns.md +423 -0
- package/dist/marketplace/external_plugins/sequant/skills/upstream/SKILL.md +419 -0
- package/dist/src/commands/logs.js +6 -1
- package/dist/src/commands/run-display.d.ts +20 -0
- package/dist/src/commands/run-display.js +80 -1
- package/dist/src/commands/stats.js +47 -0
- package/dist/src/lib/assess-collision-detect.d.ts +19 -2
- package/dist/src/lib/assess-collision-detect.js +68 -4
- package/dist/src/lib/cli-ui/run-renderer.js +17 -9
- package/dist/src/lib/errors.d.ts +91 -0
- package/dist/src/lib/errors.js +118 -0
- package/dist/src/lib/manifest.js +1 -17
- package/dist/src/lib/version-check.d.ts +19 -0
- package/dist/src/lib/version-check.js +45 -5
- package/dist/src/lib/workflow/batch-executor.d.ts +13 -0
- package/dist/src/lib/workflow/batch-executor.js +142 -24
- package/dist/src/lib/workflow/chain-preflight.d.ts +89 -0
- package/dist/src/lib/workflow/chain-preflight.js +199 -0
- package/dist/src/lib/workflow/chain-resume.d.ts +116 -0
- package/dist/src/lib/workflow/chain-resume.js +166 -0
- package/dist/src/lib/workflow/dependency-markers.d.ts +29 -0
- package/dist/src/lib/workflow/dependency-markers.js +79 -0
- package/dist/src/lib/workflow/drivers/agent-driver.d.ts +17 -0
- package/dist/src/lib/workflow/drivers/claude-code.d.ts +29 -0
- package/dist/src/lib/workflow/drivers/claude-code.js +136 -8
- package/dist/src/lib/workflow/error-classifier.d.ts +9 -2
- package/dist/src/lib/workflow/error-classifier.js +14 -1
- package/dist/src/lib/workflow/log-writer.d.ts +1 -1
- package/dist/src/lib/workflow/log-writer.js +6 -8
- package/dist/src/lib/workflow/metrics-schema.d.ts +39 -0
- package/dist/src/lib/workflow/metrics-schema.js +16 -0
- package/dist/src/lib/workflow/metrics-writer.d.ts +2 -1
- package/dist/src/lib/workflow/phase-executor.d.ts +50 -0
- package/dist/src/lib/workflow/phase-executor.js +151 -17
- package/dist/src/lib/workflow/run-log-schema.d.ts +26 -0
- package/dist/src/lib/workflow/run-log-schema.js +52 -1
- package/dist/src/lib/workflow/run-orchestrator.d.ts +14 -0
- package/dist/src/lib/workflow/run-orchestrator.js +291 -30
- package/dist/src/lib/workflow/state-manager.d.ts +1 -0
- package/dist/src/lib/workflow/state-manager.js +6 -0
- package/dist/src/lib/workflow/state-schema.d.ts +3 -0
- package/dist/src/lib/workflow/state-schema.js +7 -0
- package/dist/src/lib/workflow/status-derivation.d.ts +30 -0
- package/dist/src/lib/workflow/status-derivation.js +27 -0
- package/dist/src/lib/workflow/types.d.ts +40 -0
- package/dist/src/lib/workflow/worktree-manager.d.ts +43 -1
- package/dist/src/lib/workflow/worktree-manager.js +103 -33
- package/dist/src/mcp/tools/run.d.ts +2 -0
- package/dist/src/mcp/tools/run.js +2 -0
- package/dist/src/ui/tui/theme.d.ts +18 -4
- package/dist/src/ui/tui/theme.js +18 -4
- package/package.json +5 -6
- package/templates/hooks/post-tool.sh +18 -3
- package/templates/hooks/pre-tool.sh +330 -57
- package/templates/scripts/cleanup-worktree.sh +103 -14
- package/templates/skills/_shared/references/force-push.md +34 -0
- package/templates/skills/assess/SKILL.md +117 -19
- package/templates/skills/assess/references/predicted-collision-detection.md +9 -6
- package/templates/skills/exec/SKILL.md +29 -0
- package/templates/skills/fullsolve/SKILL.md +1 -1
- package/templates/skills/loop/SKILL.md +100 -2
- package/templates/skills/qa/SKILL.md +24 -0
- package/templates/skills/qa/references/anti-pattern-detection.md +285 -0
- package/templates/skills/qa/references/call-site-review.md +202 -0
- package/templates/skills/qa/references/quality-gates.md +287 -0
- package/templates/skills/qa/references/test-quality-checklist.md +272 -0
- package/templates/skills/qa/references/testing-requirements.md +40 -0
- package/templates/skills/qa/scripts/quality-checks.sh +95 -11
- package/templates/skills/references/shared/framework-gotchas.md +186 -0
- package/templates/skills/reflect/SKILL.md +27 -13
- package/templates/skills/reflect/references/documentation-tiers.md +80 -68
- package/templates/skills/reflect/references/phase-reflection.md +31 -15
- package/templates/skills/release/SKILL.md +669 -0
- package/templates/skills/spec/references/verification-criteria.md +1 -1
- package/templates/skills/test/references/browser-testing-patterns.md +423 -0
- package/templates/skills/upstream/SKILL.md +419 -0
|
@@ -35,18 +35,207 @@ fi
|
|
|
35
35
|
|
|
36
36
|
_TMPDIR="${TMPDIR:-/tmp}"
|
|
37
37
|
|
|
38
|
-
#
|
|
39
|
-
|
|
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
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
|
102
|
-
|
|
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
|
-
#
|
|
107
|
-
#
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
echo "HOOK_BLOCKED: Deployment command"
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
echo "HOOK_BLOCKED: Force push"
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
echo "HOOK_BLOCKED: Workflow trigger"
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
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)
|