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.
Files changed (211) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +23 -4
  4. package/dist/bin/cli.js +99 -18
  5. package/dist/marketplace/external_plugins/sequant/.claude-plugin/plugin.json +1 -1
  6. package/dist/marketplace/external_plugins/sequant/.mcp.json +1 -1
  7. package/dist/marketplace/external_plugins/sequant/README.md +58 -2
  8. package/dist/marketplace/external_plugins/sequant/hooks/parallel-marker.sh +50 -0
  9. package/dist/marketplace/external_plugins/sequant/hooks/post-tool.sh +38 -10
  10. package/dist/marketplace/external_plugins/sequant/hooks/pre-tool.sh +405 -67
  11. package/dist/marketplace/external_plugins/sequant/skills/_shared/references/trust-model.md +18 -0
  12. package/dist/marketplace/external_plugins/sequant/skills/assess/SKILL.md +250 -344
  13. package/dist/marketplace/external_plugins/sequant/skills/assess/references/predicted-collision-detection.md +10 -7
  14. package/dist/marketplace/external_plugins/sequant/skills/docs/SKILL.md +4 -4
  15. package/dist/marketplace/external_plugins/sequant/skills/exec/SKILL.md +26 -6
  16. package/dist/marketplace/external_plugins/sequant/skills/fullsolve/SKILL.md +4 -2
  17. package/dist/marketplace/external_plugins/sequant/skills/loop/SKILL.md +38 -2
  18. package/dist/marketplace/external_plugins/sequant/skills/merger/SKILL.md +2 -2
  19. package/dist/marketplace/external_plugins/sequant/skills/qa/SKILL.md +337 -57
  20. package/dist/marketplace/external_plugins/sequant/skills/qa/references/anti-pattern-detection.md +6 -6
  21. package/dist/marketplace/external_plugins/sequant/skills/qa/references/call-site-review.md +2 -2
  22. package/dist/marketplace/external_plugins/sequant/skills/qa/references/code-review-checklist.md +2 -2
  23. package/dist/marketplace/external_plugins/sequant/skills/qa/references/fixtures/injection-issue-body.md +36 -0
  24. package/dist/marketplace/external_plugins/sequant/skills/qa/references/quality-gates.md +21 -7
  25. package/dist/marketplace/external_plugins/sequant/skills/qa/references/testing-requirements.md +1 -1
  26. package/dist/marketplace/external_plugins/sequant/skills/qa/scripts/quality-checks.sh +11 -11
  27. package/dist/marketplace/external_plugins/sequant/skills/reflect/SKILL.md +27 -13
  28. package/dist/marketplace/external_plugins/sequant/skills/reflect/references/documentation-tiers.md +80 -68
  29. package/dist/marketplace/external_plugins/sequant/skills/reflect/references/phase-reflection.md +31 -15
  30. package/dist/marketplace/external_plugins/sequant/skills/release/SKILL.md +11 -3
  31. package/dist/marketplace/external_plugins/sequant/skills/setup/SKILL.md +5 -5
  32. package/dist/marketplace/external_plugins/sequant/skills/spec/SKILL.md +2 -0
  33. package/dist/marketplace/external_plugins/sequant/skills/spec/references/verification-criteria.md +1 -1
  34. package/dist/marketplace/external_plugins/sequant/skills/test/SKILL.md +1 -1
  35. package/dist/src/commands/assess-render.d.ts +23 -0
  36. package/dist/src/commands/assess-render.js +60 -0
  37. package/dist/src/commands/doctor.js +8 -13
  38. package/dist/src/commands/init.js +13 -1
  39. package/dist/src/commands/locks.js +2 -0
  40. package/dist/src/commands/logs.js +20 -6
  41. package/dist/src/commands/merge.js +154 -2
  42. package/dist/src/commands/ready-tui-adapter.js +6 -1
  43. package/dist/src/commands/ready.d.ts +25 -3
  44. package/dist/src/commands/ready.js +39 -13
  45. package/dist/src/commands/resume.d.ts +113 -0
  46. package/dist/src/commands/resume.js +193 -0
  47. package/dist/src/commands/run-display.d.ts +20 -0
  48. package/dist/src/commands/run-display.js +117 -5
  49. package/dist/src/commands/run-flags.d.ts +42 -1
  50. package/dist/src/commands/run-flags.js +53 -1
  51. package/dist/src/commands/run-progress.js +26 -1
  52. package/dist/src/commands/run.js +11 -10
  53. package/dist/src/commands/state.js +7 -0
  54. package/dist/src/commands/stats.js +47 -0
  55. package/dist/src/commands/status.d.ts +9 -0
  56. package/dist/src/commands/status.js +24 -1
  57. package/dist/src/commands/sync.js +90 -19
  58. package/dist/src/commands/update.js +28 -5
  59. package/dist/src/lib/ac-parser.d.ts +14 -0
  60. package/dist/src/lib/ac-parser.js +99 -5
  61. package/dist/src/lib/assess/index.d.ts +10 -0
  62. package/dist/src/lib/assess/index.js +9 -0
  63. package/dist/src/lib/assess/renderer.d.ts +64 -0
  64. package/dist/src/lib/assess/renderer.js +481 -0
  65. package/dist/src/lib/assess/types.d.ts +224 -0
  66. package/dist/src/lib/assess/types.js +241 -0
  67. package/dist/src/lib/assess-collision-detect.d.ts +19 -2
  68. package/dist/src/lib/assess-collision-detect.js +68 -4
  69. package/dist/src/lib/ci/outputs.d.ts +15 -2
  70. package/dist/src/lib/ci/outputs.js +17 -5
  71. package/dist/src/lib/cli-flags.d.ts +33 -0
  72. package/dist/src/lib/cli-flags.js +46 -0
  73. package/dist/src/lib/cli-ui/run-renderer-types.d.ts +21 -7
  74. package/dist/src/lib/cli-ui/run-renderer.d.ts +10 -0
  75. package/dist/src/lib/cli-ui/run-renderer.js +79 -9
  76. package/dist/src/lib/errors.d.ts +84 -6
  77. package/dist/src/lib/errors.js +142 -17
  78. package/dist/src/lib/locks/index.d.ts +3 -3
  79. package/dist/src/lib/locks/index.js +2 -2
  80. package/dist/src/lib/locks/lock-manager.d.ts +20 -2
  81. package/dist/src/lib/locks/lock-manager.js +47 -3
  82. package/dist/src/lib/locks/types.d.ts +34 -3
  83. package/dist/src/lib/locks/types.js +16 -0
  84. package/dist/src/lib/manifest.js +1 -17
  85. package/dist/src/lib/mcp-config.d.ts +45 -0
  86. package/dist/src/lib/mcp-config.js +77 -1
  87. package/dist/src/lib/merge-check/combined-branch-test.d.ts +21 -2
  88. package/dist/src/lib/merge-check/combined-branch-test.js +233 -110
  89. package/dist/src/lib/merge-check/command-result.d.ts +36 -0
  90. package/dist/src/lib/merge-check/command-result.js +56 -0
  91. package/dist/src/lib/merge-check/index.d.ts +4 -0
  92. package/dist/src/lib/merge-check/index.js +1 -1
  93. package/dist/src/lib/merge-check/report.js +5 -1
  94. package/dist/src/lib/merge-check/types.d.ts +9 -0
  95. package/dist/src/lib/merge-check/watch.d.ts +121 -0
  96. package/dist/src/lib/merge-check/watch.js +215 -0
  97. package/dist/src/lib/qa/infra-blocked-ci.d.ts +90 -0
  98. package/dist/src/lib/qa/infra-blocked-ci.js +80 -0
  99. package/dist/src/lib/settings.d.ts +14 -0
  100. package/dist/src/lib/settings.js +61 -8
  101. package/dist/src/lib/shutdown.d.ts +42 -2
  102. package/dist/src/lib/shutdown.js +75 -5
  103. package/dist/src/lib/skills-check.d.ts +26 -0
  104. package/dist/src/lib/skills-check.js +31 -0
  105. package/dist/src/lib/stacks.d.ts +110 -1
  106. package/dist/src/lib/stacks.js +244 -5
  107. package/dist/src/lib/templates.d.ts +74 -0
  108. package/dist/src/lib/templates.js +171 -16
  109. package/dist/src/lib/test-tautology-detector.d.ts +9 -2
  110. package/dist/src/lib/test-tautology-detector.js +153 -12
  111. package/dist/src/lib/version-check.d.ts +31 -0
  112. package/dist/src/lib/version-check.js +46 -7
  113. package/dist/src/lib/workflow/batch-executor.d.ts +138 -6
  114. package/dist/src/lib/workflow/batch-executor.js +550 -54
  115. package/dist/src/lib/workflow/chain-preflight.d.ts +89 -0
  116. package/dist/src/lib/workflow/chain-preflight.js +199 -0
  117. package/dist/src/lib/workflow/chain-resume.d.ts +118 -0
  118. package/dist/src/lib/workflow/chain-resume.js +170 -0
  119. package/dist/src/lib/workflow/completed-status.d.ts +52 -0
  120. package/dist/src/lib/workflow/completed-status.js +57 -0
  121. package/dist/src/lib/workflow/config-resolver.d.ts +26 -0
  122. package/dist/src/lib/workflow/config-resolver.js +54 -2
  123. package/dist/src/lib/workflow/dependency-markers.d.ts +29 -0
  124. package/dist/src/lib/workflow/dependency-markers.js +79 -0
  125. package/dist/src/lib/workflow/drivers/agent-driver.d.ts +7 -0
  126. package/dist/src/lib/workflow/drivers/aider.d.ts +2 -0
  127. package/dist/src/lib/workflow/drivers/aider.js +2 -0
  128. package/dist/src/lib/workflow/drivers/claude-code.d.ts +21 -0
  129. package/dist/src/lib/workflow/drivers/claude-code.js +50 -8
  130. package/dist/src/lib/workflow/error-classifier.d.ts +9 -2
  131. package/dist/src/lib/workflow/error-classifier.js +14 -1
  132. package/dist/src/lib/workflow/git-diff-utils.d.ts +35 -3
  133. package/dist/src/lib/workflow/git-diff-utils.js +77 -4
  134. package/dist/src/lib/workflow/heartbeat.d.ts +38 -0
  135. package/dist/src/lib/workflow/heartbeat.js +90 -0
  136. package/dist/src/lib/workflow/log-writer.d.ts +44 -1
  137. package/dist/src/lib/workflow/log-writer.js +81 -14
  138. package/dist/src/lib/workflow/metrics-schema.d.ts +42 -3
  139. package/dist/src/lib/workflow/metrics-schema.js +16 -0
  140. package/dist/src/lib/workflow/metrics-writer.d.ts +2 -1
  141. package/dist/src/lib/workflow/phase-executor.d.ts +234 -13
  142. package/dist/src/lib/workflow/phase-executor.js +533 -56
  143. package/dist/src/lib/workflow/phase-registry.js +2 -1
  144. package/dist/src/lib/workflow/platforms/github.d.ts +64 -0
  145. package/dist/src/lib/workflow/platforms/github.js +152 -0
  146. package/dist/src/lib/workflow/qa-cache.d.ts +6 -1
  147. package/dist/src/lib/workflow/qa-cache.js +22 -9
  148. package/dist/src/lib/workflow/ready-gate.d.ts +17 -4
  149. package/dist/src/lib/workflow/ready-gate.js +25 -9
  150. package/dist/src/lib/workflow/run-log-schema.d.ts +37 -0
  151. package/dist/src/lib/workflow/run-log-schema.js +92 -2
  152. package/dist/src/lib/workflow/run-orchestrator.d.ts +33 -0
  153. package/dist/src/lib/workflow/run-orchestrator.js +429 -47
  154. package/dist/src/lib/workflow/skills-preflight.d.ts +89 -0
  155. package/dist/src/lib/workflow/skills-preflight.js +112 -0
  156. package/dist/src/lib/workflow/state-cleanup.js +9 -2
  157. package/dist/src/lib/workflow/state-manager.d.ts +33 -0
  158. package/dist/src/lib/workflow/state-manager.js +83 -0
  159. package/dist/src/lib/workflow/state-schema.d.ts +26 -0
  160. package/dist/src/lib/workflow/state-schema.js +47 -0
  161. package/dist/src/lib/workflow/status-derivation.d.ts +30 -0
  162. package/dist/src/lib/workflow/status-derivation.js +27 -0
  163. package/dist/src/lib/workflow/types.d.ts +149 -4
  164. package/dist/src/lib/workflow/types.js +1 -0
  165. package/dist/src/lib/workflow/worktree-manager.d.ts +70 -2
  166. package/dist/src/lib/workflow/worktree-manager.js +201 -52
  167. package/dist/src/mcp/tools/run.d.ts +11 -1
  168. package/dist/src/mcp/tools/run.js +22 -1
  169. package/dist/src/mcp/tools/status.js +6 -0
  170. package/dist/src/ui/tui/ElapsedTimer.d.ts +12 -3
  171. package/dist/src/ui/tui/ElapsedTimer.js +12 -10
  172. package/dist/src/ui/tui/IssueBox.js +10 -2
  173. package/dist/src/ui/tui/row-cap.js +2 -1
  174. package/dist/src/ui/tui/theme.d.ts +7 -0
  175. package/dist/src/ui/tui/theme.js +9 -0
  176. package/package.json +9 -10
  177. package/templates/hooks/parallel-marker.sh +50 -0
  178. package/templates/hooks/post-tool.sh +38 -10
  179. package/templates/hooks/pre-tool.sh +405 -67
  180. package/templates/scripts/cleanup-worktree.sh +282 -30
  181. package/templates/scripts/new-feature.sh +264 -16
  182. package/templates/skills/_shared/references/trust-model.md +18 -0
  183. package/templates/skills/assess/SKILL.md +250 -344
  184. package/templates/skills/assess/references/predicted-collision-detection.md +10 -7
  185. package/templates/skills/docs/SKILL.md +4 -4
  186. package/templates/skills/exec/SKILL.md +26 -6
  187. package/templates/skills/fullsolve/SKILL.md +4 -2
  188. package/templates/skills/loop/SKILL.md +38 -2
  189. package/templates/skills/merger/SKILL.md +2 -2
  190. package/templates/skills/qa/SKILL.md +337 -57
  191. package/templates/skills/qa/references/anti-pattern-detection.md +6 -6
  192. package/templates/skills/qa/references/call-site-review.md +2 -2
  193. package/templates/skills/qa/references/code-review-checklist.md +2 -2
  194. package/templates/skills/qa/references/fixtures/injection-issue-body.md +36 -0
  195. package/templates/skills/qa/references/quality-gates.md +21 -7
  196. package/templates/skills/qa/references/testing-requirements.md +1 -1
  197. package/templates/skills/qa/scripts/quality-checks.sh +11 -11
  198. package/templates/skills/reflect/SKILL.md +27 -13
  199. package/templates/skills/reflect/references/documentation-tiers.md +80 -68
  200. package/templates/skills/reflect/references/phase-reflection.md +31 -15
  201. package/templates/skills/release/SKILL.md +11 -3
  202. package/templates/skills/setup/SKILL.md +5 -5
  203. package/templates/skills/spec/SKILL.md +2 -0
  204. package/templates/skills/spec/references/verification-criteria.md +1 -1
  205. package/templates/skills/test/SKILL.md +1 -1
  206. package/dist/src/lib/phase-spinner.d.ts +0 -146
  207. package/dist/src/lib/phase-spinner.js +0 -255
  208. package/dist/src/lib/workflow/pr-operations.d.ts +0 -86
  209. package/dist/src/lib/workflow/pr-operations.js +0 -326
  210. package/dist/src/lib/workflow/run-summary.d.ts +0 -36
  211. 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
- # 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
- PARALLEL_MARKER_PREFIX="${_TMPDIR}/claude-parallel-"
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
- # Format: ${_TMPDIR}/claude-parallel-<group-id>.marker
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 from marker filename
58
- AGENT_ID=$(basename "$marker" | sed 's/claude-parallel-//' | sed 's/\.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 "claude-parallel-*.marker" 2>/dev/null)
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
- 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"
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
- # 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
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
- 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
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 echo "$TOOL_INPUT" | grep -qE '^(env|printenv|export)$'; then
102
- echo "HOOK_BLOCKED: Environment dump" | tee -a "$HOOK_LOG" >&2
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
- # 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
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
- # 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
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
- # 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
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
- # 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
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
- } | tee -a "$HOOK_LOG" >&2
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
- # 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
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
- # 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
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
- } | tee -a "$HOOK_LOG" >&2
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
- } | tee -a "$HOOK_LOG" >&2
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
- # 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
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
- echo "HOOK_BLOCKED: No changes to commit. Stage files with 'git add' first." | tee -a "$HOOK_LOG" >&2
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
- # 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
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
- # 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
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
- } | tee -a "$HOOK_LOG" >&2
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
- echo "HOOK_BLOCKED: Worktree does not exist: $EXPECTED_WORKTREE" | tee -a "$HOOK_LOG" >&2
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 path for consistent comparison
394
- REAL_FILE_PATH=$(realpath "$FILE_PATH" 2>/dev/null || echo "$FILE_PATH")
395
- REAL_WORKTREE=$(realpath "$EXPECTED_WORKTREE" 2>/dev/null || echo "$EXPECTED_WORKTREE")
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
- } | tee -a "$HOOK_LOG" >&2
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
- echo "HOOK_BLOCKED: File locked by another agent: $FILE_PATH" | tee -a "$HOOK_LOG" >&2
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
- echo "HOOK_BLOCKED: File locked by another agent: $FILE_PATH" | tee -a "$HOOK_LOG" >&2
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