sequant 2.9.0 → 2.11.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 (209) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +20 -5
  4. package/dist/bin/cli.js +143 -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 +56 -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 +20 -7
  10. package/dist/marketplace/external_plugins/sequant/hooks/pre-tool.sh +75 -10
  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 +215 -390
  13. package/dist/marketplace/external_plugins/sequant/skills/assess/references/predicted-collision-detection.md +1 -1
  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 +3 -1
  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/release/SKILL.md +1 -1
  28. package/dist/marketplace/external_plugins/sequant/skills/setup/SKILL.md +5 -5
  29. package/dist/marketplace/external_plugins/sequant/skills/spec/SKILL.md +2 -0
  30. package/dist/marketplace/external_plugins/sequant/skills/test/SKILL.md +1 -1
  31. package/dist/src/commands/assess-render.d.ts +23 -0
  32. package/dist/src/commands/assess-render.js +60 -0
  33. package/dist/src/commands/doctor.js +8 -13
  34. package/dist/src/commands/init.js +13 -1
  35. package/dist/src/commands/locks.d.ts +20 -1
  36. package/dist/src/commands/locks.js +208 -4
  37. package/dist/src/commands/logs.js +14 -5
  38. package/dist/src/commands/merge.js +154 -2
  39. package/dist/src/commands/ready-tui-adapter.js +6 -1
  40. package/dist/src/commands/ready.d.ts +31 -3
  41. package/dist/src/commands/ready.js +53 -13
  42. package/dist/src/commands/resume.d.ts +113 -0
  43. package/dist/src/commands/resume.js +193 -0
  44. package/dist/src/commands/run-display.js +38 -4
  45. package/dist/src/commands/run-flags.d.ts +42 -1
  46. package/dist/src/commands/run-flags.js +53 -1
  47. package/dist/src/commands/run-progress.js +26 -1
  48. package/dist/src/commands/run.js +11 -10
  49. package/dist/src/commands/state.js +7 -0
  50. package/dist/src/commands/status.d.ts +9 -0
  51. package/dist/src/commands/status.js +24 -1
  52. package/dist/src/commands/sync.js +90 -19
  53. package/dist/src/commands/update.js +28 -5
  54. package/dist/src/commands/worktree.d.ts +31 -0
  55. package/dist/src/commands/worktree.js +95 -0
  56. package/dist/src/lib/ac-parser.d.ts +14 -0
  57. package/dist/src/lib/ac-parser.js +99 -5
  58. package/dist/src/lib/assess/index.d.ts +10 -0
  59. package/dist/src/lib/assess/index.js +9 -0
  60. package/dist/src/lib/assess/renderer.d.ts +64 -0
  61. package/dist/src/lib/assess/renderer.js +481 -0
  62. package/dist/src/lib/assess/types.d.ts +224 -0
  63. package/dist/src/lib/assess/types.js +241 -0
  64. package/dist/src/lib/ci/outputs.d.ts +15 -2
  65. package/dist/src/lib/ci/outputs.js +17 -5
  66. package/dist/src/lib/cli-flags.d.ts +56 -0
  67. package/dist/src/lib/cli-flags.js +89 -0
  68. package/dist/src/lib/cli-ui/run-renderer-types.d.ts +23 -7
  69. package/dist/src/lib/cli-ui/run-renderer.d.ts +10 -0
  70. package/dist/src/lib/cli-ui/run-renderer.js +69 -1
  71. package/dist/src/lib/errors.d.ts +78 -6
  72. package/dist/src/lib/errors.js +133 -15
  73. package/dist/src/lib/locks/checkout-lock.d.ts +193 -0
  74. package/dist/src/lib/locks/checkout-lock.js +389 -0
  75. package/dist/src/lib/locks/index.d.ts +7 -4
  76. package/dist/src/lib/locks/index.js +5 -3
  77. package/dist/src/lib/locks/lock-manager.d.ts +101 -3
  78. package/dist/src/lib/locks/lock-manager.js +277 -8
  79. package/dist/src/lib/locks/types.d.ts +106 -3
  80. package/dist/src/lib/locks/types.js +44 -0
  81. package/dist/src/lib/mcp-config.d.ts +45 -0
  82. package/dist/src/lib/mcp-config.js +77 -1
  83. package/dist/src/lib/merge-check/combined-branch-test.d.ts +21 -2
  84. package/dist/src/lib/merge-check/combined-branch-test.js +233 -110
  85. package/dist/src/lib/merge-check/command-result.d.ts +36 -0
  86. package/dist/src/lib/merge-check/command-result.js +56 -0
  87. package/dist/src/lib/merge-check/index.d.ts +4 -0
  88. package/dist/src/lib/merge-check/index.js +1 -1
  89. package/dist/src/lib/merge-check/report.js +5 -1
  90. package/dist/src/lib/merge-check/types.d.ts +9 -0
  91. package/dist/src/lib/merge-check/watch.d.ts +121 -0
  92. package/dist/src/lib/merge-check/watch.js +215 -0
  93. package/dist/src/lib/qa/infra-blocked-ci.d.ts +90 -0
  94. package/dist/src/lib/qa/infra-blocked-ci.js +80 -0
  95. package/dist/src/lib/settings.d.ts +87 -0
  96. package/dist/src/lib/settings.js +106 -8
  97. package/dist/src/lib/shutdown.d.ts +42 -2
  98. package/dist/src/lib/shutdown.js +75 -5
  99. package/dist/src/lib/skills-check.d.ts +26 -0
  100. package/dist/src/lib/skills-check.js +31 -0
  101. package/dist/src/lib/stacks.d.ts +110 -1
  102. package/dist/src/lib/stacks.js +244 -5
  103. package/dist/src/lib/templates.d.ts +74 -0
  104. package/dist/src/lib/templates.js +171 -16
  105. package/dist/src/lib/test-tautology-detector.d.ts +10 -2
  106. package/dist/src/lib/test-tautology-detector.js +213 -12
  107. package/dist/src/lib/version-check.d.ts +31 -0
  108. package/dist/src/lib/version-check.js +45 -2
  109. package/dist/src/lib/workflow/batch-executor.d.ts +125 -6
  110. package/dist/src/lib/workflow/batch-executor.js +552 -60
  111. package/dist/src/lib/workflow/chain-resume.d.ts +13 -11
  112. package/dist/src/lib/workflow/chain-resume.js +24 -20
  113. package/dist/src/lib/workflow/completed-status.d.ts +52 -0
  114. package/dist/src/lib/workflow/completed-status.js +57 -0
  115. package/dist/src/lib/workflow/config-resolver.d.ts +51 -0
  116. package/dist/src/lib/workflow/config-resolver.js +143 -2
  117. package/dist/src/lib/workflow/drivers/agent-driver.d.ts +22 -0
  118. package/dist/src/lib/workflow/drivers/aider.d.ts +2 -0
  119. package/dist/src/lib/workflow/drivers/aider.js +2 -0
  120. package/dist/src/lib/workflow/drivers/claude-code.d.ts +14 -0
  121. package/dist/src/lib/workflow/drivers/claude-code.js +25 -2
  122. package/dist/src/lib/workflow/effort-escalation.d.ts +73 -0
  123. package/dist/src/lib/workflow/effort-escalation.js +82 -0
  124. package/dist/src/lib/workflow/error-classifier.d.ts +4 -1
  125. package/dist/src/lib/workflow/error-classifier.js +4 -0
  126. package/dist/src/lib/workflow/git-diff-utils.d.ts +35 -3
  127. package/dist/src/lib/workflow/git-diff-utils.js +77 -4
  128. package/dist/src/lib/workflow/heartbeat.d.ts +38 -0
  129. package/dist/src/lib/workflow/heartbeat.js +90 -0
  130. package/dist/src/lib/workflow/log-writer.d.ts +54 -2
  131. package/dist/src/lib/workflow/log-writer.js +95 -6
  132. package/dist/src/lib/workflow/metrics-schema.d.ts +52 -9
  133. package/dist/src/lib/workflow/metrics-schema.js +33 -0
  134. package/dist/src/lib/workflow/metrics-writer.d.ts +11 -0
  135. package/dist/src/lib/workflow/phase-detection.d.ts +12 -0
  136. package/dist/src/lib/workflow/phase-detection.js +5 -1
  137. package/dist/src/lib/workflow/phase-executor.d.ts +203 -14
  138. package/dist/src/lib/workflow/phase-executor.js +467 -52
  139. package/dist/src/lib/workflow/phase-registry.js +2 -1
  140. package/dist/src/lib/workflow/platforms/github.d.ts +64 -0
  141. package/dist/src/lib/workflow/platforms/github.js +152 -0
  142. package/dist/src/lib/workflow/qa-cache.d.ts +6 -1
  143. package/dist/src/lib/workflow/qa-cache.js +22 -9
  144. package/dist/src/lib/workflow/ready-gate.d.ts +45 -4
  145. package/dist/src/lib/workflow/ready-gate.js +49 -12
  146. package/dist/src/lib/workflow/run-log-schema.d.ts +69 -0
  147. package/dist/src/lib/workflow/run-log-schema.js +78 -2
  148. package/dist/src/lib/workflow/run-orchestrator.d.ts +19 -0
  149. package/dist/src/lib/workflow/run-orchestrator.js +168 -20
  150. package/dist/src/lib/workflow/skills-preflight.d.ts +89 -0
  151. package/dist/src/lib/workflow/skills-preflight.js +112 -0
  152. package/dist/src/lib/workflow/spec-recommendation.d.ts +71 -0
  153. package/dist/src/lib/workflow/spec-recommendation.js +142 -0
  154. package/dist/src/lib/workflow/state-cleanup.js +9 -2
  155. package/dist/src/lib/workflow/state-manager.d.ts +33 -0
  156. package/dist/src/lib/workflow/state-manager.js +83 -0
  157. package/dist/src/lib/workflow/state-schema.d.ts +26 -0
  158. package/dist/src/lib/workflow/state-schema.js +47 -0
  159. package/dist/src/lib/workflow/types.d.ts +190 -4
  160. package/dist/src/lib/workflow/types.js +1 -0
  161. package/dist/src/lib/workflow/worktree-manager.d.ts +36 -3
  162. package/dist/src/lib/workflow/worktree-manager.js +111 -24
  163. package/dist/src/lib/workflow/worktree-resolver.d.ts +73 -0
  164. package/dist/src/lib/workflow/worktree-resolver.js +126 -0
  165. package/dist/src/mcp/tools/run.d.ts +9 -1
  166. package/dist/src/mcp/tools/run.js +20 -1
  167. package/dist/src/mcp/tools/status.js +6 -0
  168. package/dist/src/ui/tui/ElapsedTimer.d.ts +12 -3
  169. package/dist/src/ui/tui/ElapsedTimer.js +12 -10
  170. package/dist/src/ui/tui/IssueBox.js +10 -2
  171. package/dist/src/ui/tui/row-cap.js +2 -1
  172. package/dist/src/ui/tui/theme.d.ts +7 -0
  173. package/dist/src/ui/tui/theme.js +9 -0
  174. package/package.json +10 -8
  175. package/templates/hooks/parallel-marker.sh +50 -0
  176. package/templates/hooks/post-tool.sh +20 -7
  177. package/templates/hooks/pre-tool.sh +303 -10
  178. package/templates/scripts/cleanup-worktree.sh +217 -33
  179. package/templates/scripts/new-feature.sh +289 -35
  180. package/templates/skills/_shared/references/subagent-types.md +7 -18
  181. package/templates/skills/_shared/references/trust-model.md +18 -0
  182. package/templates/skills/assess/SKILL.md +220 -391
  183. package/templates/skills/assess/references/predicted-collision-detection.md +1 -1
  184. package/templates/skills/docs/SKILL.md +4 -4
  185. package/templates/skills/exec/SKILL.md +87 -13
  186. package/templates/skills/fullsolve/SKILL.md +130 -22
  187. package/templates/skills/loop/SKILL.md +94 -13
  188. package/templates/skills/merger/SKILL.md +100 -12
  189. package/templates/skills/qa/SKILL.md +396 -63
  190. package/templates/skills/qa/references/anti-pattern-detection.md +6 -6
  191. package/templates/skills/qa/references/call-site-review.md +2 -2
  192. package/templates/skills/qa/references/code-review-checklist.md +2 -2
  193. package/templates/skills/qa/references/fixtures/injection-issue-body.md +36 -0
  194. package/templates/skills/qa/references/quality-gates.md +21 -7
  195. package/templates/skills/qa/references/testing-requirements.md +1 -1
  196. package/templates/skills/qa/scripts/quality-checks.sh +11 -11
  197. package/templates/skills/release/SKILL.md +80 -1
  198. package/templates/skills/setup/SKILL.md +5 -5
  199. package/templates/skills/spec/SKILL.md +33 -15
  200. package/templates/skills/spec/references/recommended-workflow.md +14 -1
  201. package/templates/skills/test/SKILL.md +1 -1
  202. package/templates/skills/testgen/SKILL.md +23 -6
  203. package/dist/src/lib/phase-spinner.d.ts +0 -146
  204. package/dist/src/lib/phase-spinner.js +0 -255
  205. package/dist/src/lib/workflow/pr-operations.d.ts +0 -86
  206. package/dist/src/lib/workflow/pr-operations.js +0 -326
  207. package/dist/src/lib/workflow/run-summary.d.ts +0 -36
  208. package/dist/src/lib/workflow/run-summary.js +0 -142
  209. package/templates/agents/sequant-explorer.md +0 -24
@@ -17,6 +17,16 @@ INPUT_JSON=$(cat)
17
17
  # Parse JSON using jq (preferred) or fallback to grep
18
18
  if command -v jq &>/dev/null; then
19
19
  TOOL_NAME=$(echo "$INPUT_JSON" | jq -r '.tool_name // empty')
20
+ # Claude Code's hook envelope carries the session id (same field
21
+ # capture-tokens.sh reads). Preferred holder identity for the checkout
22
+ # lock (#901): a skill shell's PID dies right after acquire, the session
23
+ # id does not. `// empty` keeps this safe if the field is ever absent —
24
+ # the guard then falls back to SEQUANT_ISSUE.
25
+ SESSION_ID=$(echo "$INPUT_JSON" | jq -r '.session_id // empty')
26
+ # The shell cwd the tool will run in. Distinct from CLAUDE_PROJECT_DIR,
27
+ # which stays pinned to the main checkout even while the agent works in a
28
+ # worktree — see the checkout-lock guard (#901).
29
+ HOOK_CWD=$(echo "$INPUT_JSON" | jq -r '.cwd // empty')
20
30
  # For Bash tool, extract .command from tool_input; for others, stringify the whole object
21
31
  if [[ "$(echo "$INPUT_JSON" | jq -r '.tool_name // empty')" == "Bash" ]]; then
22
32
  TOOL_INPUT=$(echo "$INPUT_JSON" | jq -r '.tool_input.command // empty')
@@ -25,6 +35,8 @@ if command -v jq &>/dev/null; then
25
35
  fi
26
36
  else
27
37
  TOOL_NAME=$(echo "$INPUT_JSON" | grep -oE '"tool_name"\s*:\s*"[^"]+"' | head -1 | cut -d'"' -f4)
38
+ SESSION_ID=$(echo "$INPUT_JSON" | grep -oE '"session_id"\s*:\s*"[^"]+"' | head -1 | cut -d'"' -f4)
39
+ HOOK_CWD=$(echo "$INPUT_JSON" | grep -oE '"cwd"\s*:\s*"[^"]+"' | head -1 | cut -d'"' -f4)
28
40
  # For Bash tool, extract command from tool_input; for others, extract the whole object
29
41
  if [[ "$TOOL_NAME" == "Bash" ]]; then
30
42
  TOOL_INPUT=$(echo "$INPUT_JSON" | grep -oE '"command"\s*:\s*"[^"]+"' | head -1 | cut -d'"' -f4)
@@ -60,7 +72,23 @@ mkdir -p "$_LOG_DIR" 2>/dev/null || _LOG_DIR="${_TMPDIR}"
60
72
 
61
73
  TIMING_LOG="${_LOG_DIR}/claude-timing.log"
62
74
  HOOK_LOG="${_LOG_DIR}/claude-hook.log"
63
- PARALLEL_MARKER_PREFIX="${_TMPDIR}/claude-parallel-"
75
+
76
+ # Parallel-group marker naming (#881). The scheme lives in one sourced helper
77
+ # next to this hook so the writer (/exec skill) and the readers (this hook and
78
+ # post-tool.sh) cannot drift. PARALLEL_MARKER_PREFIX is project-scoped, so a
79
+ # parallel group in another project no longer collides in the shared temp dir.
80
+ _MARKER_HELPER="$(dirname "${BASH_SOURCE[0]:-$0}")/parallel-marker.sh"
81
+ if [[ -f "$_MARKER_HELPER" ]]; then
82
+ # shellcheck source=parallel-marker.sh disable=SC1091
83
+ source "$_MARKER_HELPER"
84
+ PARALLEL_MARKER_PREFIX="$(parallel_marker_prefix)"
85
+ PARALLEL_MARKER_PROJECT_ROOT="$(parallel_marker_project_root)"
86
+ else
87
+ # Helper missing (unexpected): fall back to the pre-#881 global prefix so the
88
+ # hook still functions, accepting the old cross-project collision risk.
89
+ PARALLEL_MARKER_PREFIX="${_TMPDIR}/claude-parallel-"
90
+ PARALLEL_MARKER_PROJECT_ROOT=""
91
+ fi
64
92
 
65
93
  # === HELPERS ===
66
94
 
@@ -230,6 +258,14 @@ seg_match() {
230
258
  [[ -n "$SEGMENTS" ]] && grep -qE "$1" <<< "$SEGMENTS"
231
259
  }
232
260
 
261
+ # Path of the session->issue binding the checkout guard maintains (#906).
262
+ # $1 = repo toplevel, $2 = session id. The id is opaque, so squash everything
263
+ # outside a filename-safe set — it must not be able to escape the directory.
264
+ _co_binding_path() {
265
+ printf '%s/.sequant/locks/session-%s.issue' \
266
+ "$1" "$(printf '%s' "$2" | tr -c 'A-Za-z0-9_-' '_')"
267
+ }
268
+
233
269
  # Precompute the segment list once, for Bash commands only.
234
270
  SEGMENTS=""
235
271
  if [[ "$TOOL_NAME" == "Bash" ]]; then
@@ -237,17 +273,20 @@ if [[ "$TOOL_NAME" == "Bash" ]]; then
237
273
  fi
238
274
 
239
275
  # === AGENT ID DETECTION ===
240
- # For parallel agents, detect group ID from marker files
241
- # Format: ${_TMPDIR}/claude-parallel-<group-id>.marker
276
+ # For parallel agents, detect group ID from marker files. The glob is scoped to
277
+ # the current project's marker prefix (#881) so a foreign project's marker never
278
+ # labels this session's timing rows.
242
279
  AGENT_ID=""
280
+ _MARKER_BASE=$(basename "$PARALLEL_MARKER_PREFIX")
243
281
  # Find marker files using find (works in both bash and zsh)
244
282
  while IFS= read -r marker; do
245
283
  if [[ -n "$marker" && -f "$marker" ]]; then
246
- # Extract group ID from marker filename
247
- AGENT_ID=$(basename "$marker" | sed 's/claude-parallel-//' | sed 's/\.marker//')
284
+ # Extract group ID: strip the project-scoped prefix and the suffix.
285
+ AGENT_ID=$(basename "$marker" .marker)
286
+ AGENT_ID=${AGENT_ID#"$_MARKER_BASE"}
248
287
  break
249
288
  fi
250
- done < <(find "${_TMPDIR}" -maxdepth 1 -name "claude-parallel-*.marker" 2>/dev/null)
289
+ done < <(find "${_TMPDIR}" -maxdepth 1 -name "${_MARKER_BASE}*.marker" 2>/dev/null)
251
290
 
252
291
  # === TIMING START ===
253
292
  # Include agent ID in log format if available (AC-4)
@@ -439,6 +478,214 @@ if seg_match 'git reset.*(--hard|origin)'; then
439
478
  fi
440
479
  fi
441
480
 
481
+ # --- Session -> issue binding for the checkout guard (Issue #906) ---
482
+ # `SEQUANT_ISSUE` cannot identify the holder interactively, and never could:
483
+ # PreToolUse runs OUTSIDE and BEFORE the command's shell, so nothing a skill
484
+ # bash block exports is visible here — not even an export prepended to the same
485
+ # block as the guarded command. The one path that does export it (`sequant run`)
486
+ # also sets SEQUANT_ORCHESTRATOR, where this guard stands down. So the env
487
+ # fallback below was unreachable in every real flow, and the holder was
488
+ # routinely blocked by its own lock.
489
+ #
490
+ # The hook does see both the acquire and every later command of the same
491
+ # session, and `session_id` survives the shell boundary that kills the
492
+ # acquiring PID. Record the binding when we observe the acquire; read it back
493
+ # when deciding whether the caller is the holder.
494
+ if [[ -n "${SESSION_ID:-}" ]] && seg_match 'locks +checkout +(acquire|release)'; then
495
+ _CO_SB_ROOT=$(git -C "${HOOK_CWD:-$PWD}" rev-parse --show-toplevel 2>/dev/null || echo "")
496
+ if [[ -n "$_CO_SB_ROOT" && -d "$_CO_SB_ROOT/.git" ]]; then
497
+ _CO_SB_FILE=$(_co_binding_path "$_CO_SB_ROOT" "$SESSION_ID")
498
+ _CO_SB_ISSUE=$(printf '%s' "$TOOL_INPUT" \
499
+ | grep -oE '\-\-issue[= ]+[0-9]+' | head -1 | grep -oE '[0-9]+$' || true)
500
+ if seg_match 'locks +checkout +acquire'; then
501
+ if [[ -n "$_CO_SB_ISSUE" ]]; then
502
+ mkdir -p "$(dirname "$_CO_SB_FILE")" 2>/dev/null \
503
+ && printf '%s' "$_CO_SB_ISSUE" > "$_CO_SB_FILE" 2>/dev/null || true
504
+ fi
505
+ elif [[ -f "$_CO_SB_FILE" ]]; then
506
+ # Clear only when the session releases its OWN claim. A refused
507
+ # release (wrong --issue) must not strip the real holder's identity
508
+ # and leave it blocked by its own lock.
509
+ if [[ -n "$_CO_SB_ISSUE" \
510
+ && "$_CO_SB_ISSUE" == "$(cat "$_CO_SB_FILE" 2>/dev/null)" ]]; then
511
+ rm -f "$_CO_SB_FILE" 2>/dev/null || true
512
+ fi
513
+ fi
514
+ fi
515
+ fi
516
+
517
+ # --- Checkout-scoped lock enforcement (Issue #901) ---
518
+ # The per-issue lock (#625) keys on issue number, so two sessions working
519
+ # *different* issues take different lock files and never contend. But
520
+ # `git checkout`, `switch`, `reset`, `rebase`, `merge` and `cherry-pick` are
521
+ # global to a working tree — the contended resource is the checkout, not the
522
+ # issue. `.sequant/locks/checkout.lock` represents the tree; this guard is what
523
+ # makes it binding, because the racing actor is an agent's Bash command, not
524
+ # sequant's TypeScript (which mutates git almost exclusively via `git -C
525
+ # <worktree>`).
526
+ #
527
+ # STALENESS IS A DELIBERATELY WEAKER SUBSET, NOT A MIRROR. The authoritative
528
+ # rules live in `classifyStaleness` (src/lib/locks/lock-manager.ts) and are
529
+ # shared by CheckoutLock. Transcribing them into shell would drift (#871), so
530
+ # this guard checks only the absolute age ceiling and FAILS OPEN past it. A
531
+ # lock this guard lets through is still caught by the TypeScript path; a lock
532
+ # it blocks on is always genuinely fresh. Weaker-but-honest beats a mirror.
533
+ #
534
+ # AC-5: orchestrator/MCP mode is a no-op here too, matching LockManager and
535
+ # CheckoutLock — `sequant run` drives its own worktree isolation and must not
536
+ # be blocked by a lock its own skills took.
537
+ if [[ -z "${SEQUANT_ORCHESTRATOR:-}" ]] \
538
+ && seg_match 'git (checkout|switch|reset|rebase|merge|cherry-pick)( |$)' \
539
+ && ! seg_match 'git +-C ' \
540
+ && ! seg_match 'git checkout ([^ ]+ )?--( |$)'; then
541
+
542
+ # Only the MAIN checkout is protected — a command run inside a worktree
543
+ # touches only that worktree's HEAD and must never be blocked.
544
+ #
545
+ # Resolve where the command will ACTUALLY run. This must NOT use
546
+ # CLAUDE_PROJECT_DIR / PARALLEL_MARKER_PROJECT_ROOT: those name the
547
+ # *project* directory, which stays pinned to the main checkout even while
548
+ # the agent's shell sits in a worktree. Keying off them blocked legitimate
549
+ # in-worktree work — the guard's worst failure mode, since the whole point
550
+ # of the lock is to push sessions *into* worktrees.
551
+ #
552
+ # `.cwd` is part of Claude Code's PreToolUse envelope (verified against a
553
+ # live payload alongside `session_id`), with $PWD as the fallback.
554
+ _CO_CWD="${HOOK_CWD:-$PWD}"
555
+ # Honor a leading `cd <dir>` the same way the commit guard below does.
556
+ if echo "$TOOL_INPUT" | grep -qE '^cd [^;&|]+'; then
557
+ _CO_CD=$(echo "$TOOL_INPUT" | grep -oE '^cd [^;&|]+' | head -1 | sed 's/^cd //' | sed 's/[[:space:]]*$//')
558
+ [[ -n "$_CO_CD" && -d "$_CO_CD" ]] && _CO_CWD="$_CO_CD"
559
+ fi
560
+
561
+ # A linked worktree's toplevel has `.git` as a FILE; the main checkout has
562
+ # it as a directory.
563
+ _CO_ROOT=$(git -C "$_CO_CWD" rev-parse --show-toplevel 2>/dev/null || echo "")
564
+ if [[ -n "$_CO_ROOT" && -d "$_CO_ROOT/.git" ]]; then
565
+ _CO_LOCK="$_CO_ROOT/.sequant/locks/checkout.lock"
566
+
567
+ if [[ -f "$_CO_LOCK" ]]; then
568
+ if command -v jq &>/dev/null; then
569
+ _CO_HOLDER_ISSUE=$(jq -r '.issue // empty' "$_CO_LOCK" 2>/dev/null)
570
+ _CO_HOLDER_SESSION=$(jq -r '.sessionId // empty' "$_CO_LOCK" 2>/dev/null)
571
+ _CO_HOLDER_PID=$(jq -r '.pid // empty' "$_CO_LOCK" 2>/dev/null)
572
+ _CO_HOLDER_HOST=$(jq -r '.hostname // empty' "$_CO_LOCK" 2>/dev/null)
573
+ _CO_HOLDER_STARTED=$(jq -r '.startedAt // empty' "$_CO_LOCK" 2>/dev/null)
574
+ _CO_HOLDER_CMD=$(jq -r '.command // empty' "$_CO_LOCK" 2>/dev/null)
575
+ else
576
+ _CO_HOLDER_ISSUE=$(grep -oE '"issue"[[:space:]]*:[[:space:]]*[0-9]+' "$_CO_LOCK" | head -1 | grep -oE '[0-9]+$')
577
+ _CO_HOLDER_SESSION=$(grep -oE '"sessionId"[[:space:]]*:[[:space:]]*"[^"]*"' "$_CO_LOCK" | head -1 | cut -d'"' -f4)
578
+ _CO_HOLDER_PID=$(grep -oE '"pid"[[:space:]]*:[[:space:]]*[0-9]+' "$_CO_LOCK" | head -1 | grep -oE '[0-9]+$')
579
+ _CO_HOLDER_HOST=$(grep -oE '"hostname"[[:space:]]*:[[:space:]]*"[^"]*"' "$_CO_LOCK" | head -1 | cut -d'"' -f4)
580
+ _CO_HOLDER_STARTED=$(grep -oE '"startedAt"[[:space:]]*:[[:space:]]*"[^"]*"' "$_CO_LOCK" | head -1 | cut -d'"' -f4)
581
+ _CO_HOLDER_CMD=$(grep -oE '"command"[[:space:]]*:[[:space:]]*"[^"]*"' "$_CO_LOCK" | head -1 | cut -d'"' -f4)
582
+ fi
583
+
584
+ # Staleness. These branches mirror `classifyStaleness`
585
+ # (src/lib/locks/lock-manager.ts) in the same order, because AC-4
586
+ # requires the checkout lock's stale recovery to match the per-issue
587
+ # lock's — same-host dead PID, age ceiling, and the env overrides.
588
+ # Implementing only a subset here would let a *dead* holder block
589
+ # the tree for up to 24h, which is the wedge AC-4 forbids.
590
+ #
591
+ # The three rules are plain comparisons plus one `kill -0`, so this
592
+ # is a small enough surface to keep honest; the "hook/TypeScript
593
+ # staleness parity" cases in checkout-lock.integration.test.ts pin
594
+ # both sides to the same verdict so they cannot drift silently
595
+ # (#871 — the repo's drift guard compares literal strings only and
596
+ # would not see a semantic divergence here).
597
+ _CO_MAX_AGE_MS="${SEQUANT_MAX_LOCK_AGE_MS:-86400000}" # 24h ceiling
598
+ _CO_SKILL_TTL_MS="${SEQUANT_SKILL_LOCK_TTL_MS:-21600000}" # 6h skill-shell
599
+ _CO_STALE_AGE_MS=7200000 # 2h cross-host
600
+ _CO_FRESH=true
601
+
602
+ # `startedAt` is ISO-8601 **UTC**. BSD `date -j -f` parses in LOCAL
603
+ # time, so without TZ=UTC the age comes out shifted by the UTC
604
+ # offset — west of UTC that is *negative*, and a stale lock then
605
+ # reads as fresh forever, wedging the tree. TZ=UTC pins the BSD
606
+ # branch; Linux/CI falls through to GNU `date -u -d`, which honors
607
+ # the trailing Z.
608
+ _CO_AGE_MS=""
609
+ if [[ -n "$_CO_HOLDER_STARTED" ]]; then
610
+ _CO_STARTED_EPOCH=$(TZ=UTC date -j -f "%Y-%m-%dT%H:%M:%S" "${_CO_HOLDER_STARTED%%.*}" +%s 2>/dev/null \
611
+ || date -u -d "$_CO_HOLDER_STARTED" +%s 2>/dev/null || echo "")
612
+ if [[ -n "$_CO_STARTED_EPOCH" ]]; then
613
+ _CO_AGE_MS=$(( ( $(date +%s) - _CO_STARTED_EPOCH ) * 1000 ))
614
+ # Negative age = clock skew between hosts. Treat as unknown
615
+ # rather than stale: refusing is recoverable, silently
616
+ # ignoring a live holder is not.
617
+ [[ "$_CO_AGE_MS" -lt 0 ]] && _CO_AGE_MS=""
618
+ fi
619
+ fi
620
+
621
+ _CO_SKIP_PID=false
622
+ grep -q '"skipPidCheck"[[:space:]]*:[[:space:]]*true' "$_CO_LOCK" 2>/dev/null && _CO_SKIP_PID=true
623
+
624
+ # 0. Absolute ceiling, checked first and unconditionally (#856):
625
+ # past it a PID is no longer trustworthy identity.
626
+ if [[ -n "$_CO_AGE_MS" && "$_CO_AGE_MS" -gt "$_CO_MAX_AGE_MS" ]]; then
627
+ _CO_FRESH=false
628
+ # 1. Same-host PID check is authoritative — unless the holder asked
629
+ # us to skip it (a skill shell whose PID dies after acquire).
630
+ elif [[ "$_CO_HOLDER_HOST" == "$(hostname)" && "$_CO_SKIP_PID" == "false" ]]; then
631
+ # `kill -0` is a bash builtin: no subprocess on the hot path.
632
+ if [[ -n "$_CO_HOLDER_PID" ]] && ! kill -0 "$_CO_HOLDER_PID" 2>/dev/null; then
633
+ _CO_FRESH=false
634
+ fi
635
+ # 2. Cross-host or skipPidCheck: the PID is meaningless, use age.
636
+ elif [[ -n "$_CO_AGE_MS" ]]; then
637
+ if [[ "$_CO_SKIP_PID" == "true" ]]; then
638
+ _CO_TTL_MS="$_CO_SKILL_TTL_MS"
639
+ else
640
+ _CO_TTL_MS="$_CO_STALE_AGE_MS"
641
+ fi
642
+ [[ "$_CO_AGE_MS" -gt "$_CO_TTL_MS" ]] && _CO_FRESH=false
643
+ fi
644
+
645
+ # Is this session the holder? sessionId is the only identity that
646
+ # survives a skill shell exiting between acquire and this call, so
647
+ # it wins when both sides have one. Otherwise fall back to the
648
+ # issue this session is working on.
649
+ _CO_IS_HOLDER=false
650
+ if [[ -n "$_CO_HOLDER_SESSION" && -n "$SESSION_ID" ]]; then
651
+ [[ "$_CO_HOLDER_SESSION" == "$SESSION_ID" ]] && _CO_IS_HOLDER=true
652
+ elif [[ -n "${SEQUANT_ISSUE:-}" && -n "$_CO_HOLDER_ISSUE" ]]; then
653
+ # Reachable only from a parent process that exported it — never
654
+ # from a skill bash block (#906). Kept for `sequant run`-shaped
655
+ # callers; the binding below is what works interactively.
656
+ [[ "${SEQUANT_ISSUE}" == "$_CO_HOLDER_ISSUE" ]] && _CO_IS_HOLDER=true
657
+ elif [[ -n "${SESSION_ID:-}" && -n "$_CO_HOLDER_ISSUE" ]]; then
658
+ # The binding this hook recorded when it saw THIS session run
659
+ # `locks checkout acquire --issue=N` (#906).
660
+ _CO_BIND=$(_co_binding_path "$_CO_ROOT" "$SESSION_ID")
661
+ [[ -f "$_CO_BIND" \
662
+ && "$(cat "$_CO_BIND" 2>/dev/null)" == "$_CO_HOLDER_ISSUE" ]] \
663
+ && _CO_IS_HOLDER=true
664
+ fi
665
+
666
+ if [[ "$_CO_FRESH" == "true" && "$_CO_IS_HOLDER" == "false" ]]; then
667
+ log_block "checkout-lock"
668
+ {
669
+ echo "HOOK_BLOCKED: Checkout held by another session"
670
+ echo ""
671
+ echo " The working tree is held by the session working #${_CO_HOLDER_ISSUE:-?}"
672
+ echo " (PID ${_CO_HOLDER_PID:-?} on ${_CO_HOLDER_HOST:-?}, started ${_CO_HOLDER_STARTED:-?})."
673
+ echo " Command: ${_CO_HOLDER_CMD:-?}"
674
+ echo ""
675
+ echo " Branch-mutating git here would race with that session."
676
+ echo ""
677
+ echo " To proceed:"
678
+ echo " • Work in your own worktree: ../worktrees/feature/<your-issue>-*/"
679
+ echo " (create it with: ./scripts/new-feature.sh <your-issue>)"
680
+ echo " • Or target it explicitly: git -C <worktree> <command>"
681
+ echo " • If that session is gone: sequant locks checkout clear --force"
682
+ } >&2
683
+ exit 2
684
+ fi
685
+ fi
686
+ fi
687
+ fi
688
+
442
689
  # CI/CD triggers (automation shouldn't trigger more automation)
443
690
  if seg_match 'gh workflow run'; then
444
691
  log_block "workflow-trigger"
@@ -617,6 +864,36 @@ if [[ "$TOOL_NAME" == "Bash" ]] && seg_match 'git commit'; then
617
864
  fi
618
865
  fi
619
866
 
867
+ # Resolve a path to its canonical form, tolerating components that do not exist
868
+ # yet. Plain `realpath` fails outright on a missing path, and BSD/macOS realpath
869
+ # has no `-m`. The old code fell back to the raw string on failure, which broke
870
+ # the comparison below whenever the worktree path crossed a symlink: a Write
871
+ # creating a NEW file resolved to nothing (raw "/tmp/.../x.ts") while the
872
+ # existing worktree dir resolved through the symlink ("/private/tmp/.../"), so
873
+ # the prefix test failed and writes *inside* the worktree were blocked. macOS
874
+ # hits this every time, since /tmp is a symlink to /private/tmp.
875
+ #
876
+ # Walk up to the nearest existing ancestor, canonicalise that, then re-append
877
+ # the missing tail so both sides normalise identically.
878
+ resolve_path_allow_missing() {
879
+ local p="$1"
880
+ local suffix=""
881
+
882
+ while [[ ! -e "$p" && "$p" != "/" && "$p" != "." && -n "$p" ]]; do
883
+ suffix="/$(basename "$p")$suffix"
884
+ local parent
885
+ parent="$(dirname "$p")"
886
+ [[ "$parent" == "$p" ]] && break
887
+ p="$parent"
888
+ done
889
+
890
+ local resolved
891
+ resolved="$(realpath "$p" 2>/dev/null || echo "$p")"
892
+ # Avoid a doubled slash when the resolved ancestor is "/"
893
+ [[ "$resolved" == "/" ]] && resolved=""
894
+ echo "${resolved}${suffix}"
895
+ }
896
+
620
897
  # === WORKTREE PATH ENFORCEMENT ===
621
898
  # Enforces that file operations stay within the designated worktree
622
899
  # Sources for worktree path (in priority order):
@@ -631,10 +908,21 @@ if [[ "$TOOL_NAME" == "Edit" || "$TOOL_NAME" == "Write" ]]; then
631
908
  EXPECTED_WORKTREE="$SEQUANT_WORKTREE"
632
909
  fi
633
910
 
634
- # Priority 2: Fall back to parallel marker file
911
+ # Priority 2: Fall back to parallel marker file. The glob prefix is already
912
+ # scoped to this project's hash (#881, AC-2), so a foreign project's marker
913
+ # is not matched here. As defense-in-depth (AC-3), also verify the marker's
914
+ # stored owning project root (line 2) matches the current one before trusting
915
+ # it — a stale or hand-placed marker with a colliding name is ignored rather
916
+ # than allowed to redirect enforcement.
635
917
  if [[ -z "$EXPECTED_WORKTREE" ]]; then
636
918
  for marker in "${PARALLEL_MARKER_PREFIX}"*.marker; do
637
919
  if [[ -f "$marker" ]]; then
920
+ MARKER_PROJECT_ROOT=$(sed -n '2p' "$marker" 2>/dev/null || true)
921
+ if [[ -n "$MARKER_PROJECT_ROOT" && -n "$PARALLEL_MARKER_PROJECT_ROOT" \
922
+ && "$MARKER_PROJECT_ROOT" != "$PARALLEL_MARKER_PROJECT_ROOT" ]]; then
923
+ # Foreign owner — ignore this marker entirely.
924
+ continue
925
+ fi
638
926
  # Read expected worktree path from marker file (first line)
639
927
  EXPECTED_WORKTREE=$(head -1 "$marker" 2>/dev/null || true)
640
928
  break
@@ -660,9 +948,14 @@ if [[ "$TOOL_NAME" == "Edit" || "$TOOL_NAME" == "Write" ]]; then
660
948
  fi
661
949
 
662
950
  if [[ -n "$FILE_PATH" ]]; then
663
- # Resolve to absolute path for consistent comparison
664
- REAL_FILE_PATH=$(realpath "$FILE_PATH" 2>/dev/null || echo "$FILE_PATH")
665
- REAL_WORKTREE=$(realpath "$EXPECTED_WORKTREE" 2>/dev/null || echo "$EXPECTED_WORKTREE")
951
+ # Resolve to canonical absolute paths for consistent comparison.
952
+ # resolve_path_allow_missing (defined above) tolerates a file that
953
+ # does not exist yet; a bare `realpath` fallback-to-raw-string broke
954
+ # new-file Writes whenever the worktree path crossed a symlink
955
+ # (macOS /tmp -> /private/tmp): the new file stayed raw while the
956
+ # existing worktree dir resolved, so the prefix test always failed.
957
+ REAL_FILE_PATH=$(resolve_path_allow_missing "$FILE_PATH")
958
+ REAL_WORKTREE=$(resolve_path_allow_missing "$EXPECTED_WORKTREE")
666
959
 
667
960
  # Check if file path is within the expected worktree
668
961
  if [[ "$REAL_FILE_PATH" != "$REAL_WORKTREE"* ]]; then