sequant 2.8.0 → 2.10.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 +23 -4
- package/dist/bin/cli.js +99 -18
- package/dist/marketplace/external_plugins/sequant/.claude-plugin/plugin.json +1 -1
- package/dist/marketplace/external_plugins/sequant/.mcp.json +1 -1
- package/dist/marketplace/external_plugins/sequant/README.md +58 -2
- package/dist/marketplace/external_plugins/sequant/hooks/parallel-marker.sh +50 -0
- package/dist/marketplace/external_plugins/sequant/hooks/post-tool.sh +38 -10
- package/dist/marketplace/external_plugins/sequant/hooks/pre-tool.sh +405 -67
- package/dist/marketplace/external_plugins/sequant/skills/_shared/references/trust-model.md +18 -0
- package/dist/marketplace/external_plugins/sequant/skills/assess/SKILL.md +250 -344
- package/dist/marketplace/external_plugins/sequant/skills/assess/references/predicted-collision-detection.md +10 -7
- package/dist/marketplace/external_plugins/sequant/skills/docs/SKILL.md +4 -4
- package/dist/marketplace/external_plugins/sequant/skills/exec/SKILL.md +26 -6
- package/dist/marketplace/external_plugins/sequant/skills/fullsolve/SKILL.md +4 -2
- package/dist/marketplace/external_plugins/sequant/skills/loop/SKILL.md +38 -2
- package/dist/marketplace/external_plugins/sequant/skills/merger/SKILL.md +2 -2
- package/dist/marketplace/external_plugins/sequant/skills/qa/SKILL.md +337 -57
- package/dist/marketplace/external_plugins/sequant/skills/qa/references/anti-pattern-detection.md +6 -6
- package/dist/marketplace/external_plugins/sequant/skills/qa/references/call-site-review.md +2 -2
- package/dist/marketplace/external_plugins/sequant/skills/qa/references/code-review-checklist.md +2 -2
- package/dist/marketplace/external_plugins/sequant/skills/qa/references/fixtures/injection-issue-body.md +36 -0
- package/dist/marketplace/external_plugins/sequant/skills/qa/references/quality-gates.md +21 -7
- package/dist/marketplace/external_plugins/sequant/skills/qa/references/testing-requirements.md +1 -1
- package/dist/marketplace/external_plugins/sequant/skills/qa/scripts/quality-checks.sh +11 -11
- 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 +11 -3
- package/dist/marketplace/external_plugins/sequant/skills/setup/SKILL.md +5 -5
- package/dist/marketplace/external_plugins/sequant/skills/spec/SKILL.md +2 -0
- package/dist/marketplace/external_plugins/sequant/skills/spec/references/verification-criteria.md +1 -1
- package/dist/marketplace/external_plugins/sequant/skills/test/SKILL.md +1 -1
- package/dist/src/commands/assess-render.d.ts +23 -0
- package/dist/src/commands/assess-render.js +60 -0
- package/dist/src/commands/doctor.js +8 -13
- package/dist/src/commands/init.js +13 -1
- package/dist/src/commands/locks.js +2 -0
- package/dist/src/commands/logs.js +20 -6
- package/dist/src/commands/merge.js +154 -2
- package/dist/src/commands/ready-tui-adapter.js +6 -1
- package/dist/src/commands/ready.d.ts +25 -3
- package/dist/src/commands/ready.js +39 -13
- package/dist/src/commands/resume.d.ts +113 -0
- package/dist/src/commands/resume.js +193 -0
- package/dist/src/commands/run-display.d.ts +20 -0
- package/dist/src/commands/run-display.js +117 -5
- package/dist/src/commands/run-flags.d.ts +42 -1
- package/dist/src/commands/run-flags.js +53 -1
- package/dist/src/commands/run-progress.js +26 -1
- package/dist/src/commands/run.js +11 -10
- package/dist/src/commands/state.js +7 -0
- package/dist/src/commands/stats.js +47 -0
- package/dist/src/commands/status.d.ts +9 -0
- package/dist/src/commands/status.js +24 -1
- package/dist/src/commands/sync.js +90 -19
- package/dist/src/commands/update.js +28 -5
- package/dist/src/lib/ac-parser.d.ts +14 -0
- package/dist/src/lib/ac-parser.js +99 -5
- package/dist/src/lib/assess/index.d.ts +10 -0
- package/dist/src/lib/assess/index.js +9 -0
- package/dist/src/lib/assess/renderer.d.ts +64 -0
- package/dist/src/lib/assess/renderer.js +481 -0
- package/dist/src/lib/assess/types.d.ts +224 -0
- package/dist/src/lib/assess/types.js +241 -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/ci/outputs.d.ts +15 -2
- package/dist/src/lib/ci/outputs.js +17 -5
- package/dist/src/lib/cli-flags.d.ts +33 -0
- package/dist/src/lib/cli-flags.js +46 -0
- package/dist/src/lib/cli-ui/run-renderer-types.d.ts +21 -7
- package/dist/src/lib/cli-ui/run-renderer.d.ts +10 -0
- package/dist/src/lib/cli-ui/run-renderer.js +79 -9
- package/dist/src/lib/errors.d.ts +84 -6
- package/dist/src/lib/errors.js +142 -17
- package/dist/src/lib/locks/index.d.ts +3 -3
- package/dist/src/lib/locks/index.js +2 -2
- package/dist/src/lib/locks/lock-manager.d.ts +20 -2
- package/dist/src/lib/locks/lock-manager.js +47 -3
- package/dist/src/lib/locks/types.d.ts +34 -3
- package/dist/src/lib/locks/types.js +16 -0
- package/dist/src/lib/manifest.js +1 -17
- package/dist/src/lib/mcp-config.d.ts +45 -0
- package/dist/src/lib/mcp-config.js +77 -1
- package/dist/src/lib/merge-check/combined-branch-test.d.ts +21 -2
- package/dist/src/lib/merge-check/combined-branch-test.js +233 -110
- package/dist/src/lib/merge-check/command-result.d.ts +36 -0
- package/dist/src/lib/merge-check/command-result.js +56 -0
- package/dist/src/lib/merge-check/index.d.ts +4 -0
- package/dist/src/lib/merge-check/index.js +1 -1
- package/dist/src/lib/merge-check/report.js +5 -1
- package/dist/src/lib/merge-check/types.d.ts +9 -0
- package/dist/src/lib/merge-check/watch.d.ts +121 -0
- package/dist/src/lib/merge-check/watch.js +215 -0
- package/dist/src/lib/qa/infra-blocked-ci.d.ts +90 -0
- package/dist/src/lib/qa/infra-blocked-ci.js +80 -0
- package/dist/src/lib/settings.d.ts +14 -0
- package/dist/src/lib/settings.js +61 -8
- package/dist/src/lib/shutdown.d.ts +42 -2
- package/dist/src/lib/shutdown.js +75 -5
- package/dist/src/lib/skills-check.d.ts +26 -0
- package/dist/src/lib/skills-check.js +31 -0
- package/dist/src/lib/stacks.d.ts +110 -1
- package/dist/src/lib/stacks.js +244 -5
- package/dist/src/lib/templates.d.ts +74 -0
- package/dist/src/lib/templates.js +171 -16
- package/dist/src/lib/test-tautology-detector.d.ts +9 -2
- package/dist/src/lib/test-tautology-detector.js +153 -12
- package/dist/src/lib/version-check.d.ts +31 -0
- package/dist/src/lib/version-check.js +46 -7
- package/dist/src/lib/workflow/batch-executor.d.ts +138 -6
- package/dist/src/lib/workflow/batch-executor.js +550 -54
- 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 +118 -0
- package/dist/src/lib/workflow/chain-resume.js +170 -0
- package/dist/src/lib/workflow/completed-status.d.ts +52 -0
- package/dist/src/lib/workflow/completed-status.js +57 -0
- package/dist/src/lib/workflow/config-resolver.d.ts +26 -0
- package/dist/src/lib/workflow/config-resolver.js +54 -2
- 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 +7 -0
- package/dist/src/lib/workflow/drivers/aider.d.ts +2 -0
- package/dist/src/lib/workflow/drivers/aider.js +2 -0
- package/dist/src/lib/workflow/drivers/claude-code.d.ts +21 -0
- package/dist/src/lib/workflow/drivers/claude-code.js +50 -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/git-diff-utils.d.ts +35 -3
- package/dist/src/lib/workflow/git-diff-utils.js +77 -4
- package/dist/src/lib/workflow/heartbeat.d.ts +38 -0
- package/dist/src/lib/workflow/heartbeat.js +90 -0
- package/dist/src/lib/workflow/log-writer.d.ts +44 -1
- package/dist/src/lib/workflow/log-writer.js +81 -14
- package/dist/src/lib/workflow/metrics-schema.d.ts +42 -3
- 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 +234 -13
- package/dist/src/lib/workflow/phase-executor.js +533 -56
- package/dist/src/lib/workflow/phase-registry.js +2 -1
- package/dist/src/lib/workflow/platforms/github.d.ts +64 -0
- package/dist/src/lib/workflow/platforms/github.js +152 -0
- package/dist/src/lib/workflow/qa-cache.d.ts +6 -1
- package/dist/src/lib/workflow/qa-cache.js +22 -9
- package/dist/src/lib/workflow/ready-gate.d.ts +17 -4
- package/dist/src/lib/workflow/ready-gate.js +25 -9
- package/dist/src/lib/workflow/run-log-schema.d.ts +37 -0
- package/dist/src/lib/workflow/run-log-schema.js +92 -2
- package/dist/src/lib/workflow/run-orchestrator.d.ts +33 -0
- package/dist/src/lib/workflow/run-orchestrator.js +429 -47
- package/dist/src/lib/workflow/skills-preflight.d.ts +89 -0
- package/dist/src/lib/workflow/skills-preflight.js +112 -0
- package/dist/src/lib/workflow/state-cleanup.js +9 -2
- package/dist/src/lib/workflow/state-manager.d.ts +33 -0
- package/dist/src/lib/workflow/state-manager.js +83 -0
- package/dist/src/lib/workflow/state-schema.d.ts +26 -0
- package/dist/src/lib/workflow/state-schema.js +47 -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 +149 -4
- package/dist/src/lib/workflow/types.js +1 -0
- package/dist/src/lib/workflow/worktree-manager.d.ts +70 -2
- package/dist/src/lib/workflow/worktree-manager.js +201 -52
- package/dist/src/mcp/tools/run.d.ts +11 -1
- package/dist/src/mcp/tools/run.js +22 -1
- package/dist/src/mcp/tools/status.js +6 -0
- package/dist/src/ui/tui/ElapsedTimer.d.ts +12 -3
- package/dist/src/ui/tui/ElapsedTimer.js +12 -10
- package/dist/src/ui/tui/IssueBox.js +10 -2
- package/dist/src/ui/tui/row-cap.js +2 -1
- package/dist/src/ui/tui/theme.d.ts +7 -0
- package/dist/src/ui/tui/theme.js +9 -0
- package/package.json +9 -10
- package/templates/hooks/parallel-marker.sh +50 -0
- package/templates/hooks/post-tool.sh +38 -10
- package/templates/hooks/pre-tool.sh +405 -67
- package/templates/scripts/cleanup-worktree.sh +282 -30
- package/templates/scripts/new-feature.sh +264 -16
- package/templates/skills/_shared/references/trust-model.md +18 -0
- package/templates/skills/assess/SKILL.md +250 -344
- package/templates/skills/assess/references/predicted-collision-detection.md +10 -7
- package/templates/skills/docs/SKILL.md +4 -4
- package/templates/skills/exec/SKILL.md +26 -6
- package/templates/skills/fullsolve/SKILL.md +4 -2
- package/templates/skills/loop/SKILL.md +38 -2
- package/templates/skills/merger/SKILL.md +2 -2
- package/templates/skills/qa/SKILL.md +337 -57
- package/templates/skills/qa/references/anti-pattern-detection.md +6 -6
- package/templates/skills/qa/references/call-site-review.md +2 -2
- package/templates/skills/qa/references/code-review-checklist.md +2 -2
- package/templates/skills/qa/references/fixtures/injection-issue-body.md +36 -0
- package/templates/skills/qa/references/quality-gates.md +21 -7
- package/templates/skills/qa/references/testing-requirements.md +1 -1
- package/templates/skills/qa/scripts/quality-checks.sh +11 -11
- 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 +11 -3
- package/templates/skills/setup/SKILL.md +5 -5
- package/templates/skills/spec/SKILL.md +2 -0
- package/templates/skills/spec/references/verification-criteria.md +1 -1
- package/templates/skills/test/SKILL.md +1 -1
- package/dist/src/lib/phase-spinner.d.ts +0 -146
- package/dist/src/lib/phase-spinner.js +0 -255
- package/dist/src/lib/workflow/pr-operations.d.ts +0 -86
- package/dist/src/lib/workflow/pr-operations.js +0 -326
- package/dist/src/lib/workflow/run-summary.d.ts +0 -36
- package/dist/src/lib/workflow/run-summary.js +0 -142
|
@@ -35,30 +35,238 @@ 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
|
+
|
|
64
|
+
# Parallel-group marker naming (#881). The scheme lives in one sourced helper
|
|
65
|
+
# next to this hook so the writer (/exec skill) and the readers (this hook and
|
|
66
|
+
# post-tool.sh) cannot drift. PARALLEL_MARKER_PREFIX is project-scoped, so a
|
|
67
|
+
# parallel group in another project no longer collides in the shared temp dir.
|
|
68
|
+
_MARKER_HELPER="$(dirname "${BASH_SOURCE[0]:-$0}")/parallel-marker.sh"
|
|
69
|
+
if [[ -f "$_MARKER_HELPER" ]]; then
|
|
70
|
+
# shellcheck source=parallel-marker.sh disable=SC1091
|
|
71
|
+
source "$_MARKER_HELPER"
|
|
72
|
+
PARALLEL_MARKER_PREFIX="$(parallel_marker_prefix)"
|
|
73
|
+
PARALLEL_MARKER_PROJECT_ROOT="$(parallel_marker_project_root)"
|
|
74
|
+
else
|
|
75
|
+
# Helper missing (unexpected): fall back to the pre-#881 global prefix so the
|
|
76
|
+
# hook still functions, accepting the old cross-project collision risk.
|
|
77
|
+
PARALLEL_MARKER_PREFIX="${_TMPDIR}/claude-parallel-"
|
|
78
|
+
PARALLEL_MARKER_PROJECT_ROOT=""
|
|
79
|
+
fi
|
|
80
|
+
|
|
81
|
+
# === HELPERS ===
|
|
82
|
+
|
|
83
|
+
# rotate_log <file> — keep the last 500 lines once a log passes 1000, to
|
|
84
|
+
# prevent unbounded growth. Shared by TIMING_LOG and HOOK_LOG (#763 AC-5b).
|
|
85
|
+
rotate_log() {
|
|
86
|
+
local f="$1"
|
|
87
|
+
if [[ -f "$f" ]]; then
|
|
88
|
+
local lc
|
|
89
|
+
lc=$(wc -l < "$f" 2>/dev/null || echo 0)
|
|
90
|
+
if [[ "$lc" -gt 1000 ]]; then
|
|
91
|
+
tail -500 "$f" > "${f}.tmp" && mv "${f}.tmp" "$f"
|
|
92
|
+
fi
|
|
93
|
+
fi
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
# redact_secrets <content> — mask known token shapes before they reach a log.
|
|
97
|
+
# Mirrors the detection patterns in check_secrets(). $TOOL_INPUT can carry
|
|
98
|
+
# tokens (a `gh` body, an inline export), and HOOK_LOG now records full
|
|
99
|
+
# command text, so redaction is required (#763 AC-5a). This covers the same
|
|
100
|
+
# six shapes check_secrets detects; other secret formats are not masked.
|
|
101
|
+
redact_secrets() {
|
|
102
|
+
printf '%s' "$1" | sed -E \
|
|
103
|
+
-e 's/sk-[a-zA-Z0-9]{32,}/[REDACTED]/g' \
|
|
104
|
+
-e 's/sk_live_[a-zA-Z0-9]{24,}/[REDACTED]/g' \
|
|
105
|
+
-e 's/AKIA[A-Z0-9]{16}/[REDACTED]/g' \
|
|
106
|
+
-e 's/ghp_[a-zA-Z0-9]{36}/[REDACTED]/g' \
|
|
107
|
+
-e 's/xoxb-[0-9]{10,}(-[a-zA-Z0-9]+)+/[REDACTED]/g' \
|
|
108
|
+
-e 's/AIza[a-zA-Z0-9_-]{35}/[REDACTED]/g'
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
# log_block <rule-id> — record which guard fired and the offending command
|
|
112
|
+
# (redacted) to a single rotated sink, so blocks can be audited and turned
|
|
113
|
+
# into a real regression corpus (#763 AC-5). Writes to HOOK_LOG only; the
|
|
114
|
+
# human-facing HOOK_BLOCKED message still goes to stderr at each call site.
|
|
115
|
+
log_block() {
|
|
116
|
+
local rule="$1" redacted
|
|
117
|
+
redacted=$(redact_secrets "$TOOL_INPUT")
|
|
118
|
+
printf '%s BLOCKED [%s] %s\n' "$(date +%s.%N)" "$rule" "$redacted" >> "$HOOK_LOG"
|
|
119
|
+
rotate_log "$HOOK_LOG"
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
# emit_segments <command> — split a shell command into the pieces the shell
|
|
123
|
+
# would actually execute, and print each (one per line) with inert text removed
|
|
124
|
+
# and leading env-assignments stripped, so guards can match a segment's
|
|
125
|
+
# *command words* without tripping on payload text carried as an argument (#763).
|
|
126
|
+
#
|
|
127
|
+
# Command-word positions (a guard MAY match here):
|
|
128
|
+
# - each segment between the operators ; && || | and newline
|
|
129
|
+
# - the body of a subshell `( ... )` and of a command substitution `$( ... )`,
|
|
130
|
+
# including `$( ... )` inside double quotes — the shell runs those.
|
|
131
|
+
#
|
|
132
|
+
# Inert (a guard must NEVER match here):
|
|
133
|
+
# - single-quoted text: the shell expands nothing inside it
|
|
134
|
+
# - double-quoted text, except for `$( ... )`
|
|
135
|
+
# - heredoc bodies: they are stdin *data*, not code. This one is load-bearing.
|
|
136
|
+
# The standard commit idiom is `git commit -m "$(cat <<'EOF' ... EOF)"`;
|
|
137
|
+
# without skipping the body, a commit message that merely *mentions*
|
|
138
|
+
# `git push --force` would be blocked — which would reintroduce the
|
|
139
|
+
# #564/#570 false-positive class through the command-substitution door.
|
|
140
|
+
#
|
|
141
|
+
# Argument positions are deliberately NOT command words, so `xargs sudo ...`
|
|
142
|
+
# and `bash -c "sudo ..."` are allowed: the guarded word is data being handed
|
|
143
|
+
# to another program, and treating it as code is what produced #564/#570.
|
|
144
|
+
#
|
|
145
|
+
# This is deliberately NOT a full shell parser: it does not emulate backslash
|
|
146
|
+
# escapes or globbing — acceptable for an accident-prevention layer (the real
|
|
147
|
+
# security boundary is Claude Code's permission system, not this hook). A
|
|
148
|
+
# crafted quoted string could in principle hide an operator; that is out of
|
|
149
|
+
# scope for a non-adversarial guard.
|
|
150
|
+
emit_segments() {
|
|
151
|
+
printf '%s' "$1" | awk '
|
|
152
|
+
function emit(s, t) {
|
|
153
|
+
t = s
|
|
154
|
+
sub(/^[ \t]+/, "", t); sub(/[ \t]+$/, "", t)
|
|
155
|
+
# Drop leading VAR=val assignments so `FOO=bar sudo ...` still keys off `sudo`.
|
|
156
|
+
while (match(t, /^[A-Za-z_][A-Za-z0-9_]*=[^ \t]*[ \t]+/)) t = substr(t, RLENGTH + 1)
|
|
157
|
+
if (length(t) > 0) print t
|
|
158
|
+
}
|
|
159
|
+
BEGIN { sq = sprintf("%c", 39); dq = sprintf("%c", 34) }
|
|
160
|
+
{ full = (NR == 1) ? $0 : full "\n" $0 }
|
|
161
|
+
END {
|
|
162
|
+
n = length(full); seg = ""; cur = ""; depth = 0; nhd = 0
|
|
163
|
+
for (i = 1; i <= n; i++) {
|
|
164
|
+
c = substr(full, i, 1)
|
|
165
|
+
nc = (i < n) ? substr(full, i + 1, 1) : ""
|
|
166
|
+
|
|
167
|
+
# Single-quoted: wholly inert until the closing quote.
|
|
168
|
+
if (cur == sq) { if (c == sq) cur = ""; continue }
|
|
169
|
+
|
|
170
|
+
# Double-quoted: inert, except that $( ... ) is live code.
|
|
171
|
+
if (cur == dq) {
|
|
172
|
+
if (c == "$" && nc == "(") {
|
|
173
|
+
emit(seg); seg = ""
|
|
174
|
+
stack[++depth] = dq; cur = ""; i++
|
|
175
|
+
continue
|
|
176
|
+
}
|
|
177
|
+
if (c == dq) cur = ""
|
|
178
|
+
continue
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
# --- live context ---
|
|
182
|
+
if (c == sq) { cur = sq; seg = seg " "; continue }
|
|
183
|
+
if (c == dq) { cur = dq; seg = seg " "; continue }
|
|
184
|
+
|
|
185
|
+
# Heredoc introducer: << or <<-, but not the <<< herestring. Record
|
|
186
|
+
# the delimiter; the body itself is skipped at the newline below.
|
|
187
|
+
if (c == "<" && nc == "<" && substr(full, i + 2, 1) != "<") {
|
|
188
|
+
j = i + 2
|
|
189
|
+
if (substr(full, j, 1) == "-") j++
|
|
190
|
+
while (j <= n && substr(full, j, 1) ~ /[ \t]/) j++
|
|
191
|
+
q = substr(full, j, 1); delim = ""
|
|
192
|
+
if (q == sq || q == dq) {
|
|
193
|
+
j++
|
|
194
|
+
while (j <= n && substr(full, j, 1) != q) { delim = delim substr(full, j, 1); j++ }
|
|
195
|
+
j++
|
|
196
|
+
} else {
|
|
197
|
+
while (j <= n && substr(full, j, 1) ~ /[A-Za-z0-9_.-]/) { delim = delim substr(full, j, 1); j++ }
|
|
198
|
+
}
|
|
199
|
+
if (length(delim) > 0) hd[++nhd] = delim
|
|
200
|
+
seg = seg " "; i = j - 1
|
|
201
|
+
continue
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
# Subshell / command substitution: the body is live code.
|
|
205
|
+
if (c == "$" && nc == "(") { emit(seg); seg = ""; stack[++depth] = cur; cur = ""; i++; continue }
|
|
206
|
+
if (c == "(") { emit(seg); seg = ""; stack[++depth] = cur; continue }
|
|
207
|
+
if (c == ")") { emit(seg); seg = ""; if (depth > 0) cur = stack[depth--]; continue }
|
|
208
|
+
|
|
209
|
+
if (c == ";" || c == "&" || c == "|") {
|
|
210
|
+
emit(seg); seg = ""
|
|
211
|
+
if ((c == "&" && nc == "&") || (c == "|" && nc == "|")) i++
|
|
212
|
+
continue
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (c == "\n") {
|
|
216
|
+
emit(seg); seg = ""
|
|
217
|
+
# Any heredocs opened on the line just ended: their bodies are
|
|
218
|
+
# data, so skip forward to each delimiter line.
|
|
219
|
+
while (nhd > 0) {
|
|
220
|
+
d = hd[1]
|
|
221
|
+
for (k = 1; k < nhd; k++) hd[k] = hd[k + 1]
|
|
222
|
+
nhd--
|
|
223
|
+
while (i < n) {
|
|
224
|
+
ls = i + 1
|
|
225
|
+
le = index(substr(full, ls), "\n")
|
|
226
|
+
if (le == 0) { line = substr(full, ls); i = n }
|
|
227
|
+
else { line = substr(full, ls, le - 1); i = ls + le - 1 }
|
|
228
|
+
t = line
|
|
229
|
+
sub(/^[ \t]+/, "", t); sub(/[ \t]+$/, "", t)
|
|
230
|
+
if (t == d) break
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
continue
|
|
234
|
+
}
|
|
235
|
+
seg = seg c
|
|
236
|
+
}
|
|
237
|
+
emit(seg)
|
|
238
|
+
}
|
|
239
|
+
'
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
# seg_match <ere> — 0 if any command segment of the current Bash command
|
|
243
|
+
# matches the extended regex. Precompute $SEGMENTS once per invocation so a
|
|
244
|
+
# dozen guards do not each re-run the splitter (#763 AC-9).
|
|
245
|
+
seg_match() {
|
|
246
|
+
[[ -n "$SEGMENTS" ]] && grep -qE "$1" <<< "$SEGMENTS"
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
# Precompute the segment list once, for Bash commands only.
|
|
250
|
+
SEGMENTS=""
|
|
251
|
+
if [[ "$TOOL_NAME" == "Bash" ]]; then
|
|
252
|
+
SEGMENTS=$(emit_segments "$TOOL_INPUT")
|
|
253
|
+
fi
|
|
49
254
|
|
|
50
255
|
# === AGENT ID DETECTION ===
|
|
51
|
-
# For parallel agents, detect group ID from marker files
|
|
52
|
-
#
|
|
256
|
+
# For parallel agents, detect group ID from marker files. The glob is scoped to
|
|
257
|
+
# the current project's marker prefix (#881) so a foreign project's marker never
|
|
258
|
+
# labels this session's timing rows.
|
|
53
259
|
AGENT_ID=""
|
|
260
|
+
_MARKER_BASE=$(basename "$PARALLEL_MARKER_PREFIX")
|
|
54
261
|
# Find marker files using find (works in both bash and zsh)
|
|
55
262
|
while IFS= read -r marker; do
|
|
56
263
|
if [[ -n "$marker" && -f "$marker" ]]; then
|
|
57
|
-
# Extract group ID
|
|
58
|
-
AGENT_ID=$(basename "$marker"
|
|
264
|
+
# Extract group ID: strip the project-scoped prefix and the suffix.
|
|
265
|
+
AGENT_ID=$(basename "$marker" .marker)
|
|
266
|
+
AGENT_ID=${AGENT_ID#"$_MARKER_BASE"}
|
|
59
267
|
break
|
|
60
268
|
fi
|
|
61
|
-
done < <(find "${_TMPDIR}" -maxdepth 1 -name "
|
|
269
|
+
done < <(find "${_TMPDIR}" -maxdepth 1 -name "${_MARKER_BASE}*.marker" 2>/dev/null)
|
|
62
270
|
|
|
63
271
|
# === TIMING START ===
|
|
64
272
|
# Include agent ID in log format if available (AC-4)
|
|
@@ -70,58 +278,138 @@ fi
|
|
|
70
278
|
|
|
71
279
|
# === LOG ROTATION ===
|
|
72
280
|
# Rotate if over 1000 lines to prevent unbounded growth
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
281
|
+
rotate_log "$TIMING_LOG"
|
|
282
|
+
|
|
283
|
+
# === PLUGIN STALENESS CHECK (#784, hardened #788) ===
|
|
284
|
+
# Claude Code pins plugin installs to a commit SHA and never auto-updates
|
|
285
|
+
# them, so an installed cache can run months-old skills/hooks while the
|
|
286
|
+
# marketplace clone on the same disk tracks main and stays current. Compare
|
|
287
|
+
# the running plugin's version against the local marketplace clone and nudge.
|
|
288
|
+
# Warn-only (never blocks, exit code untouched), rate-limited to once per
|
|
289
|
+
# day via a stamp file, zero network, grep/sed/awk only. Gated on the
|
|
290
|
+
# script's own path so repo-local dev copies (hooks/, templates/hooks/,
|
|
291
|
+
# .claude/hooks/) never warn — only real cache installs resolve under
|
|
292
|
+
# */plugins/cache/*.
|
|
293
|
+
_STALE_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd || true)"
|
|
294
|
+
if [[ "$_STALE_SCRIPT_DIR" == */plugins/cache/* ]]; then
|
|
295
|
+
_STALE_STAMP="${_LOG_DIR}/plugin-stale-warned.stamp"
|
|
296
|
+
_STALE_TODAY="$(date +%Y-%m-%d)"
|
|
297
|
+
if [[ "$(cat "$_STALE_STAMP" 2>/dev/null)" != "$_STALE_TODAY" ]]; then
|
|
298
|
+
# The plugin root is one level above hooks/; the plugins root is the
|
|
299
|
+
# prefix before /plugins/cache/, so a relocated CLAUDE_CONFIG_DIR
|
|
300
|
+
# works without hardcoding ~/.claude.
|
|
301
|
+
_STALE_RUNNING_JSON="${_STALE_SCRIPT_DIR%/hooks}/.claude-plugin/plugin.json"
|
|
302
|
+
_STALE_PLUGINS_ROOT="${_STALE_SCRIPT_DIR%%/plugins/cache/*}/plugins"
|
|
303
|
+
# Locate the marketplace clone by scanning, not by a hardcoded dir
|
|
304
|
+
# name: Claude Code names the dir after the marketplace, which may
|
|
305
|
+
# differ across versions. Pick the first clone whose JSON declares
|
|
306
|
+
# the sequant plugin. The [[ -f ]] guard also absorbs a literal
|
|
307
|
+
# unmatched glob, so "no clone present" is a silent no-op.
|
|
308
|
+
_STALE_MARKET_JSON=""
|
|
309
|
+
for _stale_mkt in "$_STALE_PLUGINS_ROOT"/marketplaces/*/.claude-plugin/marketplace.json; do
|
|
310
|
+
[[ -f "$_stale_mkt" ]] || continue
|
|
311
|
+
grep -q '"name"[[:space:]]*:[[:space:]]*"sequant"' "$_stale_mkt" 2>/dev/null || continue
|
|
312
|
+
_STALE_MARKET_JSON="$_stale_mkt"
|
|
313
|
+
break
|
|
314
|
+
done
|
|
315
|
+
if [[ -n "$_STALE_MARKET_JSON" && -f "$_STALE_RUNNING_JSON" ]]; then
|
|
316
|
+
# marketplace.json's only "version" key is the plugin entry's, so
|
|
317
|
+
# first match is correct (sequant is the sole plugin listed).
|
|
318
|
+
_STALE_RUNNING_VER=$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$_STALE_RUNNING_JSON" 2>/dev/null | head -1)
|
|
319
|
+
_STALE_MARKET_VER=$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$_STALE_MARKET_JSON" 2>/dev/null | head -1)
|
|
320
|
+
# Warn only when the install is strictly BEHIND the marketplace —
|
|
321
|
+
# a dev cache ahead of the marketplace is not stale. Dotted
|
|
322
|
+
# numeric compare in awk (no `sort -V` dependency, which is a
|
|
323
|
+
# GNU-ism unreliable on BSD/macOS); a non-numeric prerelease
|
|
324
|
+
# component is compared by its leading integer, e.g.
|
|
325
|
+
# "2.8.0-beta" sorts as 2.8.0 — precise enough for a nudge.
|
|
326
|
+
if [[ -n "$_STALE_RUNNING_VER" && -n "$_STALE_MARKET_VER" ]] && \
|
|
327
|
+
awk -v a="$_STALE_RUNNING_VER" -v b="$_STALE_MARKET_VER" '
|
|
328
|
+
function cmp(x, y, ax, ay, i, av, bv, n) {
|
|
329
|
+
n = split(x, ax, "."); split(y, ay, ".")
|
|
330
|
+
for (i = 1; i <= n || (i in ay); i++) {
|
|
331
|
+
av = (i in ax) ? ax[i] + 0 : 0
|
|
332
|
+
bv = (i in ay) ? ay[i] + 0 : 0
|
|
333
|
+
if (av < bv) return -1
|
|
334
|
+
if (av > bv) return 1
|
|
335
|
+
}
|
|
336
|
+
return 0
|
|
337
|
+
}
|
|
338
|
+
BEGIN { exit !(cmp(a, b) < 0) }
|
|
339
|
+
'; then
|
|
340
|
+
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
|
|
341
|
+
printf '%s' "$_STALE_TODAY" > "$_STALE_STAMP" 2>/dev/null || true
|
|
342
|
+
fi
|
|
343
|
+
fi
|
|
77
344
|
fi
|
|
78
345
|
fi
|
|
79
346
|
|
|
80
347
|
# === CATASTROPHIC BLOCKS ===
|
|
81
348
|
# These should NEVER run in any automated context
|
|
82
349
|
# Only check Bash commands — Write/Edit content may contain these as config strings
|
|
350
|
+
#
|
|
351
|
+
# Every guard below matches against $SEGMENTS (quote-stripped command words),
|
|
352
|
+
# not the raw command string. This is what fixes #763: body text such as
|
|
353
|
+
# `gh issue create --body "...git push --force..."` carries the token inside a
|
|
354
|
+
# quoted argument, so it never appears as a command word and cannot trip a
|
|
355
|
+
# guard — which is why the old `^gh (issue|pr) ` carve-outs are gone entirely.
|
|
356
|
+
# Conversely a real command chained after an allowed one
|
|
357
|
+
# (`gh issue list && git push --force`) is its own segment and still blocks.
|
|
83
358
|
if [[ "$TOOL_NAME" == "Bash" ]]; then
|
|
84
359
|
|
|
85
|
-
# Secrets/credentials
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
exit 2
|
|
92
|
-
fi
|
|
360
|
+
# Secrets/credentials — a file reader at command-word position
|
|
361
|
+
if seg_match '^(cat|less|head|tail|more) .*\.(env|pem|key)'; then
|
|
362
|
+
log_block "secret-file"
|
|
363
|
+
echo "HOOK_BLOCKED: Reading secret file" >&2
|
|
364
|
+
exit 2
|
|
365
|
+
fi
|
|
93
366
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
367
|
+
if seg_match '^(cat|less) .*~/\.(ssh|aws|gnupg|config/gh)'; then
|
|
368
|
+
log_block "credential-dir"
|
|
369
|
+
echo "HOOK_BLOCKED: Reading credential directory" >&2
|
|
370
|
+
exit 2
|
|
98
371
|
fi
|
|
99
372
|
|
|
100
373
|
# Bare environment dump
|
|
101
|
-
if
|
|
102
|
-
|
|
374
|
+
if seg_match '^(env|printenv|export)$'; then
|
|
375
|
+
log_block "env-dump"
|
|
376
|
+
echo "HOOK_BLOCKED: Environment dump" >&2
|
|
103
377
|
exit 2
|
|
104
378
|
fi
|
|
105
379
|
|
|
106
|
-
#
|
|
107
|
-
#
|
|
108
|
-
|
|
109
|
-
|
|
380
|
+
# Privilege escalation — `sudo` at a command-word position only (#763 AC-2).
|
|
381
|
+
# The `rm -rf /|~|$HOME` alternation that used to live here was deleted (AC-1):
|
|
382
|
+
# it was pure redundancy with Claude Code's native dangerous-rm analyzer (which
|
|
383
|
+
# still fires under bypassPermissions and covers root / top-level / home /
|
|
384
|
+
# workspace-ancestor targets), and its `rm -rf /` substring matched every
|
|
385
|
+
# absolute path, blocking ordinary worktree/scratch deletes. `sudo` is NOT
|
|
386
|
+
# natively covered, so it stays — but keyed off the command word, so
|
|
387
|
+
# `echo 'never sudo'` and `grep -r sudoku src/` are allowed.
|
|
388
|
+
#
|
|
389
|
+
# Because this guard has no native backstop, emit_segments treats every
|
|
390
|
+
# position the shell would execute as a command word: plain, chained,
|
|
391
|
+
# `( sudo ... )`, `$(sudo ...)`, and `"$(sudo ...)"` all block. Argument
|
|
392
|
+
# positions (`xargs sudo ...`, `bash -c "sudo ..."`) are an accepted gap —
|
|
393
|
+
# there the word is data handed to another program, and matching it is the
|
|
394
|
+
# mistake that produced #564/#570.
|
|
395
|
+
if seg_match '^sudo( |$)'; then
|
|
396
|
+
log_block "sudo"
|
|
397
|
+
echo "HOOK_BLOCKED: sudo command" >&2
|
|
110
398
|
exit 2
|
|
111
399
|
fi
|
|
112
400
|
|
|
113
401
|
# Deployment (should never happen in issue automation)
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
echo "HOOK_BLOCKED: Deployment command"
|
|
402
|
+
if seg_match 'vercel (deploy|--prod)|terraform (apply|destroy)|kubectl (apply|delete)'; then
|
|
403
|
+
log_block "deployment"
|
|
404
|
+
echo "HOOK_BLOCKED: Deployment command" >&2
|
|
117
405
|
exit 2
|
|
118
406
|
fi
|
|
119
407
|
|
|
120
408
|
# Force push
|
|
121
409
|
# Pattern requires -f to be a standalone flag (not part of branch name like -fix)
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
echo "HOOK_BLOCKED: Force push"
|
|
410
|
+
if seg_match 'git push.*(--force| -f($| ))'; then
|
|
411
|
+
log_block "force-push"
|
|
412
|
+
echo "HOOK_BLOCKED: Force push" >&2
|
|
125
413
|
exit 2
|
|
126
414
|
fi
|
|
127
415
|
|
|
@@ -130,11 +418,10 @@ fi
|
|
|
130
418
|
# - Unpushed commits on main/master
|
|
131
419
|
# - Uncommitted changes (staged or unstaged)
|
|
132
420
|
# - 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
|
|
421
|
+
if seg_match 'git reset.*(--hard|origin)'; then
|
|
135
422
|
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
|
|
136
423
|
BLOCK_REASONS=""
|
|
137
|
-
|
|
424
|
+
|
|
138
425
|
# Check 1: Unpushed commits (only on main/master)
|
|
139
426
|
if [[ "$CURRENT_BRANCH" == "main" || "$CURRENT_BRANCH" == "master" ]]; then
|
|
140
427
|
UNPUSHED=$(git log origin/$CURRENT_BRANCH..HEAD --oneline 2>/dev/null | wc -l | tr -d ' ')
|
|
@@ -142,21 +429,22 @@ if ! echo "$TOOL_INPUT" | grep -qE '^gh (issue|pr) ' && echo "$TOOL_INPUT" | gre
|
|
|
142
429
|
BLOCK_REASONS="${BLOCK_REASONS} - $UNPUSHED unpushed commit(s) on $CURRENT_BRANCH\n"
|
|
143
430
|
fi
|
|
144
431
|
fi
|
|
145
|
-
|
|
432
|
+
|
|
146
433
|
# Check 2: Uncommitted changes (staged or unstaged)
|
|
147
434
|
UNCOMMITTED=$(git status --porcelain 2>/dev/null | wc -l | tr -d ' ')
|
|
148
435
|
if [[ "$UNCOMMITTED" -gt 0 ]]; then
|
|
149
436
|
BLOCK_REASONS="${BLOCK_REASONS} - $UNCOMMITTED uncommitted file(s)\n"
|
|
150
437
|
fi
|
|
151
|
-
|
|
438
|
+
|
|
152
439
|
# Check 3: Unfinished merge
|
|
153
440
|
GIT_DIR=$(git rev-parse --git-dir 2>/dev/null || echo ".git")
|
|
154
441
|
if [[ -f "$GIT_DIR/MERGE_HEAD" ]]; then
|
|
155
442
|
BLOCK_REASONS="${BLOCK_REASONS} - Unfinished merge in progress\n"
|
|
156
443
|
fi
|
|
157
|
-
|
|
444
|
+
|
|
158
445
|
# Block if any reasons found
|
|
159
446
|
if [[ -n "$BLOCK_REASONS" ]]; then
|
|
447
|
+
log_block "git-reset-hard"
|
|
160
448
|
{
|
|
161
449
|
echo "HOOK_BLOCKED: git reset --hard would lose local work:"
|
|
162
450
|
echo -e "$BLOCK_REASONS"
|
|
@@ -165,15 +453,15 @@ if ! echo "$TOOL_INPUT" | grep -qE '^gh (issue|pr) ' && echo "$TOOL_INPUT" | gre
|
|
|
165
453
|
echo " git stash # save changes"
|
|
166
454
|
echo " git merge --abort # cancel merge"
|
|
167
455
|
echo " Or run directly in terminal (outside Claude Code) to bypass"
|
|
168
|
-
}
|
|
456
|
+
} >&2
|
|
169
457
|
exit 2
|
|
170
458
|
fi
|
|
171
459
|
fi
|
|
172
460
|
|
|
173
461
|
# CI/CD triggers (automation shouldn't trigger more automation)
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
echo "HOOK_BLOCKED: Workflow trigger"
|
|
462
|
+
if seg_match 'gh workflow run'; then
|
|
463
|
+
log_block "workflow-trigger"
|
|
464
|
+
echo "HOOK_BLOCKED: Workflow trigger" >&2
|
|
177
465
|
exit 2
|
|
178
466
|
fi
|
|
179
467
|
|
|
@@ -230,28 +518,29 @@ check_sensitive_files() {
|
|
|
230
518
|
|
|
231
519
|
if [[ "${CLAUDE_HOOKS_SECURITY:-true}" != "false" ]]; then
|
|
232
520
|
# 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
|
|
521
|
+
if [[ "$TOOL_NAME" == "Bash" ]] && seg_match 'git commit'; then
|
|
235
522
|
# Skip security checks if --no-verify is used
|
|
236
523
|
if ! echo "$TOOL_INPUT" | grep -qE -- '--no-verify'; then
|
|
237
524
|
# Check staged files for secrets
|
|
238
525
|
STAGED_CONTENT=$(git diff --cached 2>/dev/null || true)
|
|
239
526
|
if [[ -n "$STAGED_CONTENT" ]] && check_secrets "$STAGED_CONTENT"; then
|
|
527
|
+
log_block "staged-secret"
|
|
240
528
|
{
|
|
241
529
|
echo "HOOK_BLOCKED: Hardcoded secret detected in staged changes"
|
|
242
530
|
echo " Use 'git commit --no-verify' to bypass if this is a false positive"
|
|
243
|
-
}
|
|
531
|
+
} >&2
|
|
244
532
|
exit 2
|
|
245
533
|
fi
|
|
246
534
|
|
|
247
535
|
# Check for sensitive files in commit
|
|
248
536
|
STAGED_FILES=$(git diff --cached --name-only 2>/dev/null || true)
|
|
249
537
|
if [[ -n "$STAGED_FILES" ]] && check_sensitive_files "$STAGED_FILES"; then
|
|
538
|
+
log_block "sensitive-file"
|
|
250
539
|
{
|
|
251
540
|
echo "HOOK_BLOCKED: Sensitive file in commit (${STAGED_FILES})"
|
|
252
541
|
echo " Files like .env, *.pem, *.key should not be committed"
|
|
253
542
|
echo " Use 'git commit --no-verify' to bypass if this is intentional"
|
|
254
|
-
}
|
|
543
|
+
} >&2
|
|
255
544
|
exit 2
|
|
256
545
|
fi
|
|
257
546
|
fi
|
|
@@ -263,8 +552,7 @@ fi
|
|
|
263
552
|
# --- No-Changes Guard (AC-7) ---
|
|
264
553
|
# Block commits when there are no staged or unstaged changes (prevents empty commits)
|
|
265
554
|
# 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
|
|
555
|
+
if [[ "$TOOL_NAME" == "Bash" ]] && seg_match 'git commit'; then
|
|
268
556
|
if ! echo "$TOOL_INPUT" | grep -qE -- '--amend|--allow-empty'; then
|
|
269
557
|
# Extract target directory from cd command if present (for worktree commits)
|
|
270
558
|
# Handles: "cd /path && git commit" or "cd /path; git commit"
|
|
@@ -281,7 +569,8 @@ if [[ "$TOOL_NAME" == "Bash" ]] && ! echo "$TOOL_INPUT" | grep -qE '^gh (issue|p
|
|
|
281
569
|
fi
|
|
282
570
|
|
|
283
571
|
if [[ "$CHANGES" -eq 0 ]]; then
|
|
284
|
-
|
|
572
|
+
log_block "no-changes"
|
|
573
|
+
echo "HOOK_BLOCKED: No changes to commit. Stage files with 'git add' first." >&2
|
|
285
574
|
exit 2
|
|
286
575
|
fi
|
|
287
576
|
fi
|
|
@@ -291,8 +580,7 @@ fi
|
|
|
291
580
|
# Warn (but don't block) when committing outside a feature worktree
|
|
292
581
|
# This catches accidental commits to main repo during feature work
|
|
293
582
|
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
|
|
583
|
+
if [[ "$TOOL_NAME" == "Bash" ]] && seg_match 'git commit'; then
|
|
296
584
|
CWD=$(pwd)
|
|
297
585
|
if ! echo "$CWD" | grep -qE 'worktrees/feature/'; then
|
|
298
586
|
echo "$(date +%H:%M:%S) WORKTREE_WARNING: Committing outside feature worktree ($CWD)" >> "$QUALITY_LOG"
|
|
@@ -303,8 +591,7 @@ fi
|
|
|
303
591
|
# --- Commit Message Validation (AC-3) ---
|
|
304
592
|
# Enforce conventional commits format: type(scope): description
|
|
305
593
|
# 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
|
|
594
|
+
if [[ "$TOOL_NAME" == "Bash" ]] && seg_match 'git commit'; then
|
|
308
595
|
# Extract message from -m flag
|
|
309
596
|
MSG=""
|
|
310
597
|
|
|
@@ -331,6 +618,7 @@ if [[ "$TOOL_NAME" == "Bash" ]] && ! echo "$TOOL_INPUT" | grep -qE '^gh (issue|p
|
|
|
331
618
|
# Also accepts ! for breaking changes: feat!: or feat(scope)!:
|
|
332
619
|
PATTERN='^(feat|fix|docs|style|refactor|test|chore|ci|build|perf)(\([^)]+\))?(!)?\s*:'
|
|
333
620
|
if ! echo "$MSG" | grep -qE "$PATTERN"; then
|
|
621
|
+
log_block "commit-format"
|
|
334
622
|
{
|
|
335
623
|
echo "HOOK_BLOCKED: Commit must follow conventional commits format"
|
|
336
624
|
echo " Expected: type(scope): description"
|
|
@@ -342,12 +630,42 @@ if [[ "$TOOL_NAME" == "Bash" ]] && ! echo "$TOOL_INPUT" | grep -qE '^gh (issue|p
|
|
|
342
630
|
fi
|
|
343
631
|
echo " Types: feat|fix|docs|style|refactor|test|chore|ci|build|perf"
|
|
344
632
|
echo " Got: $MSG"
|
|
345
|
-
}
|
|
633
|
+
} >&2
|
|
346
634
|
exit 2
|
|
347
635
|
fi
|
|
348
636
|
fi
|
|
349
637
|
fi
|
|
350
638
|
|
|
639
|
+
# Resolve a path to its canonical form, tolerating components that do not exist
|
|
640
|
+
# yet. Plain `realpath` fails outright on a missing path, and BSD/macOS realpath
|
|
641
|
+
# has no `-m`. The old code fell back to the raw string on failure, which broke
|
|
642
|
+
# the comparison below whenever the worktree path crossed a symlink: a Write
|
|
643
|
+
# creating a NEW file resolved to nothing (raw "/tmp/.../x.ts") while the
|
|
644
|
+
# existing worktree dir resolved through the symlink ("/private/tmp/.../"), so
|
|
645
|
+
# the prefix test failed and writes *inside* the worktree were blocked. macOS
|
|
646
|
+
# hits this every time, since /tmp is a symlink to /private/tmp.
|
|
647
|
+
#
|
|
648
|
+
# Walk up to the nearest existing ancestor, canonicalise that, then re-append
|
|
649
|
+
# the missing tail so both sides normalise identically.
|
|
650
|
+
resolve_path_allow_missing() {
|
|
651
|
+
local p="$1"
|
|
652
|
+
local suffix=""
|
|
653
|
+
|
|
654
|
+
while [[ ! -e "$p" && "$p" != "/" && "$p" != "." && -n "$p" ]]; do
|
|
655
|
+
suffix="/$(basename "$p")$suffix"
|
|
656
|
+
local parent
|
|
657
|
+
parent="$(dirname "$p")"
|
|
658
|
+
[[ "$parent" == "$p" ]] && break
|
|
659
|
+
p="$parent"
|
|
660
|
+
done
|
|
661
|
+
|
|
662
|
+
local resolved
|
|
663
|
+
resolved="$(realpath "$p" 2>/dev/null || echo "$p")"
|
|
664
|
+
# Avoid a doubled slash when the resolved ancestor is "/"
|
|
665
|
+
[[ "$resolved" == "/" ]] && resolved=""
|
|
666
|
+
echo "${resolved}${suffix}"
|
|
667
|
+
}
|
|
668
|
+
|
|
351
669
|
# === WORKTREE PATH ENFORCEMENT ===
|
|
352
670
|
# Enforces that file operations stay within the designated worktree
|
|
353
671
|
# Sources for worktree path (in priority order):
|
|
@@ -362,10 +680,21 @@ if [[ "$TOOL_NAME" == "Edit" || "$TOOL_NAME" == "Write" ]]; then
|
|
|
362
680
|
EXPECTED_WORKTREE="$SEQUANT_WORKTREE"
|
|
363
681
|
fi
|
|
364
682
|
|
|
365
|
-
# Priority 2: Fall back to parallel marker file
|
|
683
|
+
# Priority 2: Fall back to parallel marker file. The glob prefix is already
|
|
684
|
+
# scoped to this project's hash (#881, AC-2), so a foreign project's marker
|
|
685
|
+
# is not matched here. As defense-in-depth (AC-3), also verify the marker's
|
|
686
|
+
# stored owning project root (line 2) matches the current one before trusting
|
|
687
|
+
# it — a stale or hand-placed marker with a colliding name is ignored rather
|
|
688
|
+
# than allowed to redirect enforcement.
|
|
366
689
|
if [[ -z "$EXPECTED_WORKTREE" ]]; then
|
|
367
690
|
for marker in "${PARALLEL_MARKER_PREFIX}"*.marker; do
|
|
368
691
|
if [[ -f "$marker" ]]; then
|
|
692
|
+
MARKER_PROJECT_ROOT=$(sed -n '2p' "$marker" 2>/dev/null || true)
|
|
693
|
+
if [[ -n "$MARKER_PROJECT_ROOT" && -n "$PARALLEL_MARKER_PROJECT_ROOT" \
|
|
694
|
+
&& "$MARKER_PROJECT_ROOT" != "$PARALLEL_MARKER_PROJECT_ROOT" ]]; then
|
|
695
|
+
# Foreign owner — ignore this marker entirely.
|
|
696
|
+
continue
|
|
697
|
+
fi
|
|
369
698
|
# Read expected worktree path from marker file (first line)
|
|
370
699
|
EXPECTED_WORKTREE=$(head -1 "$marker" 2>/dev/null || true)
|
|
371
700
|
break
|
|
@@ -377,7 +706,8 @@ if [[ "$TOOL_NAME" == "Edit" || "$TOOL_NAME" == "Write" ]]; then
|
|
|
377
706
|
# AC-4 (Issue #31): Check worktree directory exists before path validation
|
|
378
707
|
# Prevents Write tool from creating non-existent worktree directories
|
|
379
708
|
if [[ ! -d "$EXPECTED_WORKTREE" ]]; then
|
|
380
|
-
|
|
709
|
+
log_block "worktree-missing"
|
|
710
|
+
echo "HOOK_BLOCKED: Worktree does not exist: $EXPECTED_WORKTREE" >&2
|
|
381
711
|
exit 2
|
|
382
712
|
fi
|
|
383
713
|
|
|
@@ -390,15 +720,21 @@ if [[ "$TOOL_NAME" == "Edit" || "$TOOL_NAME" == "Write" ]]; then
|
|
|
390
720
|
fi
|
|
391
721
|
|
|
392
722
|
if [[ -n "$FILE_PATH" ]]; then
|
|
393
|
-
# Resolve to absolute
|
|
394
|
-
|
|
395
|
-
|
|
723
|
+
# Resolve to canonical absolute paths for consistent comparison.
|
|
724
|
+
# resolve_path_allow_missing (defined above) tolerates a file that
|
|
725
|
+
# does not exist yet; a bare `realpath` fallback-to-raw-string broke
|
|
726
|
+
# new-file Writes whenever the worktree path crossed a symlink
|
|
727
|
+
# (macOS /tmp -> /private/tmp): the new file stayed raw while the
|
|
728
|
+
# existing worktree dir resolved, so the prefix test always failed.
|
|
729
|
+
REAL_FILE_PATH=$(resolve_path_allow_missing "$FILE_PATH")
|
|
730
|
+
REAL_WORKTREE=$(resolve_path_allow_missing "$EXPECTED_WORKTREE")
|
|
396
731
|
|
|
397
732
|
# Check if file path is within the expected worktree
|
|
398
733
|
if [[ "$REAL_FILE_PATH" != "$REAL_WORKTREE"* ]]; then
|
|
399
734
|
echo "$(date +%H:%M:%S) WORKTREE_BLOCKED: Edit outside expected worktree" >> "$QUALITY_LOG"
|
|
400
735
|
echo " Expected: $EXPECTED_WORKTREE" >> "$QUALITY_LOG"
|
|
401
736
|
echo " Got: $FILE_PATH" >> "$QUALITY_LOG"
|
|
737
|
+
log_block "worktree-boundary"
|
|
402
738
|
{
|
|
403
739
|
echo "HOOK_BLOCKED: File operation must be within worktree"
|
|
404
740
|
echo " Worktree: $EXPECTED_WORKTREE"
|
|
@@ -406,7 +742,7 @@ if [[ "$TOOL_NAME" == "Edit" || "$TOOL_NAME" == "Write" ]]; then
|
|
|
406
742
|
if [[ -n "${SEQUANT_ISSUE:-}" ]]; then
|
|
407
743
|
echo " Issue: #$SEQUANT_ISSUE"
|
|
408
744
|
fi
|
|
409
|
-
}
|
|
745
|
+
} >&2
|
|
410
746
|
exit 2
|
|
411
747
|
fi
|
|
412
748
|
fi
|
|
@@ -437,7 +773,8 @@ if [[ "${CLAUDE_HOOKS_FILE_LOCKING:-true}" == "true" ]]; then
|
|
|
437
773
|
# macOS: use lockf
|
|
438
774
|
exec 200>"$LOCK_FILE"
|
|
439
775
|
if ! lockf -t 30 200 2>/dev/null; then
|
|
440
|
-
|
|
776
|
+
log_block "file-lock"
|
|
777
|
+
echo "HOOK_BLOCKED: File locked by another agent: $FILE_PATH" >&2
|
|
441
778
|
exit 2
|
|
442
779
|
fi
|
|
443
780
|
# Lock will be released when the file descriptor closes (process exits)
|
|
@@ -445,7 +782,8 @@ if [[ "${CLAUDE_HOOKS_FILE_LOCKING:-true}" == "true" ]]; then
|
|
|
445
782
|
# Linux: use flock
|
|
446
783
|
exec 200>"$LOCK_FILE"
|
|
447
784
|
if ! flock -w 30 200 2>/dev/null; then
|
|
448
|
-
|
|
785
|
+
log_block "file-lock"
|
|
786
|
+
echo "HOOK_BLOCKED: File locked by another agent: $FILE_PATH" >&2
|
|
449
787
|
exit 2
|
|
450
788
|
fi
|
|
451
789
|
fi
|