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
@@ -0,0 +1,193 @@
1
+ /**
2
+ * CheckoutLock — working-tree-scoped lock (#901).
3
+ *
4
+ * The per-issue lock from #625 keys on issue number, so two sessions working
5
+ * different issues take different lock files and never contend. But
6
+ * `git checkout`, `switch`, `reset`, `rebase`, `merge` and `cherry-pick` are
7
+ * global to a working tree: the contended resource is the *checkout*, not the
8
+ * issue. This lock represents the checkout.
9
+ *
10
+ * Relationship to `LockManager`:
11
+ * - Stale semantics are *shared code*, not a parallel implementation — this
12
+ * class calls the same exported `classifyStaleness`, so the same-host
13
+ * dead-PID rule, the age ceiling and `SEQUANT_MAX_LOCK_AGE_MS` behave
14
+ * identically by construction (AC-4).
15
+ * - `LockManager`'s numeric key is left alone. Widening `lockPathFor` /
16
+ * `acquire` / `release` / `list` / `held` from `number` to `string` would
17
+ * ripple through `status.ts`, `merge.ts`, `resume.ts` and
18
+ * `run-orchestrator.ts`, all of which pass real issue numbers, for the
19
+ * benefit of exactly one new key. The cost of not widening is the
20
+ * duplicated `O_CREAT|O_EXCL` write below (~30 lines).
21
+ *
22
+ * Orchestrator / MCP mode: every public method is a no-op, mirroring
23
+ * `LockManager` (AC-5).
24
+ */
25
+ import { type CheckoutAcquireResult, type CheckoutHolderIdentity, type CheckoutLockFile, type CheckoutLockListing } from "./types.js";
26
+ /**
27
+ * Reserved holder id for `/release`, which mutates the main checkout but has
28
+ * no issue of its own (#911). The lock file and the `pre-tool.sh` guard both
29
+ * key on a positive integer, so the skill claims the tree under this sentinel
30
+ * rather than a symbolic label (which would require schema + CLI + hook
31
+ * changes — tracked in #911 as a follow-up).
32
+ */
33
+ export declare const RELEASE_SENTINEL_ISSUE = 999999999;
34
+ /**
35
+ * Render a checkout-lock holder's issue for CLI display. The sentinel is not
36
+ * a real issue, and printing it as `#999999999` invites readers to go looking
37
+ * for one.
38
+ */
39
+ export declare function describeCheckoutHolderIssue(issue: number): string;
40
+ export interface CheckoutLockOptions {
41
+ /** Directory holding lock files (default: `.sequant/locks`). */
42
+ locksDir?: string;
43
+ /** Age cutoff (ms) for cross-host locks. Default 2h. */
44
+ staleAgeMs?: number;
45
+ /** Age cutoff (ms) for skill-shell locks (`skipPidCheck`). Default 6h. */
46
+ skillLockTtlMs?: number;
47
+ /** Absolute age ceiling (ms). Default 24h (#856). */
48
+ maxLockAgeMs?: number;
49
+ /** Override for orchestrator detection (test seam). */
50
+ orchestratorMode?: boolean;
51
+ /** Override for `os.hostname()` (test seam). */
52
+ hostname?: string;
53
+ /** Override for current process PID (test seam). */
54
+ pid?: number;
55
+ /** Predicate: is PID alive on this host? (test seam) */
56
+ isPidAlive?: (pid: number) => boolean;
57
+ /** Clock (ms since epoch). Test seam. */
58
+ now?: () => number;
59
+ }
60
+ /**
61
+ * Does `identity` own the checkout `holder` claimed? (#906)
62
+ *
63
+ * One predicate for both `acquire`'s reentrancy check and `release`'s
64
+ * permission check, so the two cannot disagree about who the holder is: a
65
+ * session able to release by a given identity is exactly the one able to
66
+ * re-acquire by it. Exported for the hook-parity tests.
67
+ *
68
+ * The rules are ordered, and the order is load-bearing:
69
+ *
70
+ * 1. Cross-host callers never own the lock. Checked first — no weaker rule
71
+ * below may overturn it.
72
+ * 2. When *both* sides carry a `sessionId`, equality decides and nothing
73
+ * falls through: a mismatch is positive proof of non-ownership, so
74
+ * consulting a weaker signal afterwards could only overturn a stronger
75
+ * one. (Dormant in the shipped flow — no env var carries Claude Code's
76
+ * session id into a skill shell, so `acquire` never passes one. Kept
77
+ * because leaking a lock for its TTL is the safer failure.)
78
+ * 3. Same PID on the same host: a live process releasing its own lock.
79
+ * 4. `skipPidCheck` locks only: the holder's issue number. A skill shell's
80
+ * PID is dead by the time the next block runs — that is what
81
+ * `skipPidCheck` marks — so the issue is the only identity left, and the
82
+ * hook's blocking side (`pre-tool.sh`) already decides holder-ness the
83
+ * same way. Deliberately a *courtesy* check, not a security boundary:
84
+ * the issue number is readable from the lock file and `clear --force`
85
+ * exists. It defends against the accident this rule was written for — a
86
+ * *blocked* session running its release contract, which by construction
87
+ * carries a different issue.
88
+ * 5. Anything else is refused.
89
+ */
90
+ export declare function isCheckoutOwner(holder: CheckoutLockFile, identity: CheckoutHolderIdentity): boolean;
91
+ /**
92
+ * Build the refusal text for a blocked session (AC-2 + AC-3).
93
+ *
94
+ * AC-2 requires the message name the holding session and its issue; AC-3
95
+ * requires it say how to proceed rather than only reporting the block. Both
96
+ * halves are produced here, in one place, so the CLI and the hook cannot
97
+ * drift on wording.
98
+ *
99
+ * @param holder The session currently holding the checkout.
100
+ * @param blocked The issue the *refused* session is working on, when known —
101
+ * used to name the worktree it should be using instead.
102
+ * @param nowMs Clock, for the human-readable age.
103
+ */
104
+ export declare function formatCheckoutLockedMessage(holder: CheckoutLockFile, blocked?: {
105
+ issue?: number;
106
+ }, nowMs?: number): string;
107
+ export declare class CheckoutLock {
108
+ private readonly locksDir;
109
+ private readonly staleAgeMs;
110
+ private readonly skillLockTtlMs;
111
+ private readonly maxLockAgeMs;
112
+ private readonly orchestratorMode;
113
+ private readonly hostname;
114
+ private readonly pid;
115
+ private readonly isPidAlive;
116
+ private readonly now;
117
+ constructor(options?: CheckoutLockOptions);
118
+ /** True if all operations are no-ops (orchestrator/MCP mode). */
119
+ get isNoop(): boolean;
120
+ /** Absolute path to the checkout lock file. */
121
+ get lockPath(): string;
122
+ /**
123
+ * This process's identity, for callers that don't have a session id.
124
+ *
125
+ * Deliberately carries no `issue` (#906): a bare `release()` losing the
126
+ * power to remove a *skill-shell* lock is the fix working, not an omission.
127
+ * A caller that legitimately owns such a lock knows its issue and must say
128
+ * so — `release({ ...lock.selfIdentity, issue })`.
129
+ */
130
+ get selfIdentity(): CheckoutHolderIdentity;
131
+ /**
132
+ * Claim the checkout for `issue`.
133
+ *
134
+ * Re-acquiring while already the holder succeeds idempotently
135
+ * (`reentrant: true`) — a session must not be able to block itself part-way
136
+ * through its own run.
137
+ */
138
+ acquire(issue: number, command: string, options?: {
139
+ sessionId?: string;
140
+ skipPidCheck?: boolean;
141
+ }): CheckoutAcquireResult;
142
+ /**
143
+ * Release the checkout if `identity` owns it. Returns true when a lock was
144
+ * removed — `false` covers both "nothing held" and "held, but not yours".
145
+ *
146
+ * Ownership is `isCheckoutOwner`, the same predicate `acquire` uses. Before
147
+ * #906 this method took any same-host caller's word for a `skipPidCheck`
148
+ * lock, which made acquire and release asymmetric in the one scenario the
149
+ * lock exists for: a second session's *acquire* was correctly refused while
150
+ * the holder was fresh, but its *release* — which every `/fullsolve` halt
151
+ * branch runs — succeeded and handed the tree away mid-run.
152
+ *
153
+ * `LockManager.releaseExternal` keeps the looser same-host rule safely
154
+ * because its lock *file* is issue-keyed: naming the file already proves the
155
+ * caller knows the issue. `checkout.lock` has a constant filename, so that
156
+ * proof has to move into the identity — which is exactly what rule 4 of
157
+ * `isCheckoutOwner` asks for.
158
+ */
159
+ release(identity?: CheckoutHolderIdentity): boolean;
160
+ /** Read the holder without acquiring. Null when free or unparseable. */
161
+ check(): CheckoutLockFile | null;
162
+ /** Holder plus computed staleness metadata, for `locks list`. */
163
+ listing(): CheckoutLockListing | null;
164
+ /**
165
+ * Manually clear the checkout lock. With `safetyCheck` (default), refuses to
166
+ * clear a holder that is still fresh — mirrors `LockManager.clearLock`.
167
+ *
168
+ * A file that exists but does not parse is removed unconditionally (#906).
169
+ * That state is reachable: `writeAtomic` creates the file with `openSync`
170
+ * and writes to it as a second step, so a process killed in between leaves a
171
+ * zero-byte `checkout.lock` (#856 documents the group-SIGKILL that does it).
172
+ * Before this branch existed such a file was unclearable by any command —
173
+ * `clear` read it, saw `null`, and reported `no-lock` without unlinking
174
+ * (`--force` only ever reached the *staleness* check, never the read), while
175
+ * `acquire` threw raw `EEXIST` and the hook, unable to parse any field,
176
+ * blocked on it forever. `safetyCheck` is not consulted because there is no
177
+ * holder to protect: unparseable bytes name no session.
178
+ */
179
+ clear(options?: {
180
+ safetyCheck?: boolean;
181
+ }): {
182
+ cleared: boolean;
183
+ reason: string;
184
+ };
185
+ /**
186
+ * Delegates wholesale to the per-issue lock's classifier so the two locks
187
+ * cannot drift on staleness (AC-4).
188
+ */
189
+ private staleness;
190
+ private writeAtomic;
191
+ private readSafe;
192
+ private unlinkSafe;
193
+ }
@@ -0,0 +1,389 @@
1
+ /**
2
+ * CheckoutLock — working-tree-scoped lock (#901).
3
+ *
4
+ * The per-issue lock from #625 keys on issue number, so two sessions working
5
+ * different issues take different lock files and never contend. But
6
+ * `git checkout`, `switch`, `reset`, `rebase`, `merge` and `cherry-pick` are
7
+ * global to a working tree: the contended resource is the *checkout*, not the
8
+ * issue. This lock represents the checkout.
9
+ *
10
+ * Relationship to `LockManager`:
11
+ * - Stale semantics are *shared code*, not a parallel implementation — this
12
+ * class calls the same exported `classifyStaleness`, so the same-host
13
+ * dead-PID rule, the age ceiling and `SEQUANT_MAX_LOCK_AGE_MS` behave
14
+ * identically by construction (AC-4).
15
+ * - `LockManager`'s numeric key is left alone. Widening `lockPathFor` /
16
+ * `acquire` / `release` / `list` / `held` from `number` to `string` would
17
+ * ripple through `status.ts`, `merge.ts`, `resume.ts` and
18
+ * `run-orchestrator.ts`, all of which pass real issue numbers, for the
19
+ * benefit of exactly one new key. The cost of not widening is the
20
+ * duplicated `O_CREAT|O_EXCL` write below (~30 lines).
21
+ *
22
+ * Orchestrator / MCP mode: every public method is a no-op, mirroring
23
+ * `LockManager` (AC-5).
24
+ */
25
+ import { openSync, closeSync, writeSync, readFileSync, existsSync, unlinkSync, mkdirSync, } from "fs";
26
+ import { join } from "path";
27
+ import * as os from "os";
28
+ import { classifyStaleness, defaultIsPidAlive, isOrchestratorMode, resolveLocksDir, resolveMaxLockAgeMs, resolveSkillLockTtlMs, stealStaleLock, } from "./lock-manager.js";
29
+ import { CHECKOUT_LOCK_FILENAME, CheckoutLockFileSchema, DEFAULT_MAX_LOCK_AGE_MS, DEFAULT_SKILL_LOCK_TTL_MS, DEFAULT_STALE_AGE_MS, } from "./types.js";
30
+ /**
31
+ * Reserved holder id for `/release`, which mutates the main checkout but has
32
+ * no issue of its own (#911). The lock file and the `pre-tool.sh` guard both
33
+ * key on a positive integer, so the skill claims the tree under this sentinel
34
+ * rather than a symbolic label (which would require schema + CLI + hook
35
+ * changes — tracked in #911 as a follow-up).
36
+ */
37
+ export const RELEASE_SENTINEL_ISSUE = 999999999;
38
+ /**
39
+ * Render a checkout-lock holder's issue for CLI display. The sentinel is not
40
+ * a real issue, and printing it as `#999999999` invites readers to go looking
41
+ * for one.
42
+ */
43
+ export function describeCheckoutHolderIssue(issue) {
44
+ return issue === RELEASE_SENTINEL_ISSUE ? "/release (sentinel)" : `#${issue}`;
45
+ }
46
+ /**
47
+ * Does `identity` own the checkout `holder` claimed? (#906)
48
+ *
49
+ * One predicate for both `acquire`'s reentrancy check and `release`'s
50
+ * permission check, so the two cannot disagree about who the holder is: a
51
+ * session able to release by a given identity is exactly the one able to
52
+ * re-acquire by it. Exported for the hook-parity tests.
53
+ *
54
+ * The rules are ordered, and the order is load-bearing:
55
+ *
56
+ * 1. Cross-host callers never own the lock. Checked first — no weaker rule
57
+ * below may overturn it.
58
+ * 2. When *both* sides carry a `sessionId`, equality decides and nothing
59
+ * falls through: a mismatch is positive proof of non-ownership, so
60
+ * consulting a weaker signal afterwards could only overturn a stronger
61
+ * one. (Dormant in the shipped flow — no env var carries Claude Code's
62
+ * session id into a skill shell, so `acquire` never passes one. Kept
63
+ * because leaking a lock for its TTL is the safer failure.)
64
+ * 3. Same PID on the same host: a live process releasing its own lock.
65
+ * 4. `skipPidCheck` locks only: the holder's issue number. A skill shell's
66
+ * PID is dead by the time the next block runs — that is what
67
+ * `skipPidCheck` marks — so the issue is the only identity left, and the
68
+ * hook's blocking side (`pre-tool.sh`) already decides holder-ness the
69
+ * same way. Deliberately a *courtesy* check, not a security boundary:
70
+ * the issue number is readable from the lock file and `clear --force`
71
+ * exists. It defends against the accident this rule was written for — a
72
+ * *blocked* session running its release contract, which by construction
73
+ * carries a different issue.
74
+ * 5. Anything else is refused.
75
+ */
76
+ export function isCheckoutOwner(holder, identity) {
77
+ if (holder.hostname !== identity.hostname)
78
+ return false;
79
+ if (holder.sessionId && identity.sessionId) {
80
+ return holder.sessionId === identity.sessionId;
81
+ }
82
+ if (holder.pid === identity.pid)
83
+ return true;
84
+ if (holder.skipPidCheck === true &&
85
+ identity.issue !== undefined &&
86
+ identity.issue === holder.issue) {
87
+ return true;
88
+ }
89
+ return false;
90
+ }
91
+ /**
92
+ * Build the refusal text for a blocked session (AC-2 + AC-3).
93
+ *
94
+ * AC-2 requires the message name the holding session and its issue; AC-3
95
+ * requires it say how to proceed rather than only reporting the block. Both
96
+ * halves are produced here, in one place, so the CLI and the hook cannot
97
+ * drift on wording.
98
+ *
99
+ * @param holder The session currently holding the checkout.
100
+ * @param blocked The issue the *refused* session is working on, when known —
101
+ * used to name the worktree it should be using instead.
102
+ * @param nowMs Clock, for the human-readable age.
103
+ */
104
+ export function formatCheckoutLockedMessage(holder, blocked = {}, nowMs = Date.now()) {
105
+ const ageMs = nowMs - Date.parse(holder.startedAt);
106
+ const ageText = Number.isFinite(ageMs)
107
+ ? `${Math.max(0, Math.floor(ageMs / 60_000))}m ago`
108
+ : "unknown age";
109
+ const lines = [
110
+ `The working tree is held by the session working #${holder.issue} ` +
111
+ `(PID ${holder.pid} on ${holder.hostname}, started ${holder.startedAt}, ${ageText}).`,
112
+ `Command: ${holder.command}`,
113
+ "",
114
+ "Branch-mutating git operations here would race with that session.",
115
+ "",
116
+ "To proceed:",
117
+ ];
118
+ if (blocked.issue !== undefined) {
119
+ lines.push(` • Work in your own worktree instead: ../worktrees/feature/${blocked.issue}-*/`, ` (create it with: ./scripts/new-feature.sh ${blocked.issue})`);
120
+ }
121
+ else {
122
+ lines.push(" • Work in your issue's worktree instead: ../worktrees/feature/<issue>-*/", " (create it with: ./scripts/new-feature.sh <issue>)");
123
+ }
124
+ lines.push(" • Or run the command with `git -C <worktree>` so it does not touch this tree.", " • If that session is gone, clear the stale holder:",
125
+ // `--force` is not optional advice (#906). Plain `clear` refuses a holder
126
+ // that still reads fresh, and a leaked skill-shell lock reads fresh for
127
+ // the full 6h TTL — so the un-forced form fails in exactly the situation
128
+ // that sends someone here.
129
+ " sequant locks checkout clear --force");
130
+ return lines.join("\n");
131
+ }
132
+ export class CheckoutLock {
133
+ locksDir;
134
+ staleAgeMs;
135
+ skillLockTtlMs;
136
+ maxLockAgeMs;
137
+ orchestratorMode;
138
+ hostname;
139
+ pid;
140
+ isPidAlive;
141
+ now;
142
+ constructor(options = {}) {
143
+ this.locksDir = resolveLocksDir(options.locksDir);
144
+ this.staleAgeMs = options.staleAgeMs ?? DEFAULT_STALE_AGE_MS;
145
+ this.skillLockTtlMs =
146
+ options.skillLockTtlMs ??
147
+ resolveSkillLockTtlMs() ??
148
+ DEFAULT_SKILL_LOCK_TTL_MS;
149
+ this.maxLockAgeMs =
150
+ options.maxLockAgeMs ?? resolveMaxLockAgeMs() ?? DEFAULT_MAX_LOCK_AGE_MS;
151
+ this.orchestratorMode = options.orchestratorMode ?? isOrchestratorMode();
152
+ this.hostname = options.hostname ?? os.hostname();
153
+ this.pid = options.pid ?? process.pid;
154
+ this.isPidAlive = options.isPidAlive ?? defaultIsPidAlive;
155
+ this.now = options.now ?? Date.now;
156
+ }
157
+ /** True if all operations are no-ops (orchestrator/MCP mode). */
158
+ get isNoop() {
159
+ return this.orchestratorMode;
160
+ }
161
+ /** Absolute path to the checkout lock file. */
162
+ get lockPath() {
163
+ return join(this.locksDir, CHECKOUT_LOCK_FILENAME);
164
+ }
165
+ /**
166
+ * This process's identity, for callers that don't have a session id.
167
+ *
168
+ * Deliberately carries no `issue` (#906): a bare `release()` losing the
169
+ * power to remove a *skill-shell* lock is the fix working, not an omission.
170
+ * A caller that legitimately owns such a lock knows its issue and must say
171
+ * so — `release({ ...lock.selfIdentity, issue })`.
172
+ */
173
+ get selfIdentity() {
174
+ return { pid: this.pid, hostname: this.hostname };
175
+ }
176
+ /**
177
+ * Claim the checkout for `issue`.
178
+ *
179
+ * Re-acquiring while already the holder succeeds idempotently
180
+ * (`reentrant: true`) — a session must not be able to block itself part-way
181
+ * through its own run.
182
+ */
183
+ acquire(issue, command, options = {}) {
184
+ if (this.orchestratorMode) {
185
+ return { acquired: true, lockPath: "", reentrant: false };
186
+ }
187
+ const lockPath = this.lockPath;
188
+ mkdirSync(this.locksDir, { recursive: true });
189
+ const existing = this.readSafe(lockPath);
190
+ if (existing) {
191
+ // `issue` belongs in the identity for the same reason `release` needs
192
+ // it (#906): without it a session could release its own skill-shell
193
+ // lock by issue but not re-acquire it, and acquire would refuse the
194
+ // holder against its own lock part-way through a run.
195
+ const identity = {
196
+ sessionId: options.sessionId,
197
+ pid: this.pid,
198
+ hostname: this.hostname,
199
+ issue,
200
+ };
201
+ if (isCheckoutOwner(existing, identity)) {
202
+ return { acquired: true, lockPath, reentrant: true };
203
+ }
204
+ const staleReason = this.staleness(existing);
205
+ if (staleReason) {
206
+ // Compare-and-swap steal, not a blind unlink (#908): shared with
207
+ // `LockManager` via `stealStaleLock` so the two lock classes cannot
208
+ // drift. Only the classified stale inode is removed — never a fresh
209
+ // lock a racing winner created at this path. Fall through to
210
+ // `writeAtomic` regardless; its `O_CREAT|O_EXCL` picks the real holder.
211
+ stealStaleLock(lockPath, {
212
+ pid: existing.pid,
213
+ hostname: existing.hostname,
214
+ startedAt: existing.startedAt,
215
+ }, { pid: this.pid, now: this.now() });
216
+ }
217
+ else {
218
+ return {
219
+ acquired: false,
220
+ holder: existing,
221
+ lockPath,
222
+ stale: false,
223
+ staleReason: null,
224
+ };
225
+ }
226
+ }
227
+ return this.writeAtomic(lockPath, issue, command, options);
228
+ }
229
+ /**
230
+ * Release the checkout if `identity` owns it. Returns true when a lock was
231
+ * removed — `false` covers both "nothing held" and "held, but not yours".
232
+ *
233
+ * Ownership is `isCheckoutOwner`, the same predicate `acquire` uses. Before
234
+ * #906 this method took any same-host caller's word for a `skipPidCheck`
235
+ * lock, which made acquire and release asymmetric in the one scenario the
236
+ * lock exists for: a second session's *acquire* was correctly refused while
237
+ * the holder was fresh, but its *release* — which every `/fullsolve` halt
238
+ * branch runs — succeeded and handed the tree away mid-run.
239
+ *
240
+ * `LockManager.releaseExternal` keeps the looser same-host rule safely
241
+ * because its lock *file* is issue-keyed: naming the file already proves the
242
+ * caller knows the issue. `checkout.lock` has a constant filename, so that
243
+ * proof has to move into the identity — which is exactly what rule 4 of
244
+ * `isCheckoutOwner` asks for.
245
+ */
246
+ release(identity) {
247
+ if (this.orchestratorMode)
248
+ return false;
249
+ const lockPath = this.lockPath;
250
+ const current = this.readSafe(lockPath);
251
+ if (!current)
252
+ return false;
253
+ if (!isCheckoutOwner(current, identity ?? this.selfIdentity))
254
+ return false;
255
+ this.unlinkSafe(lockPath);
256
+ return true;
257
+ }
258
+ /** Read the holder without acquiring. Null when free or unparseable. */
259
+ check() {
260
+ if (this.orchestratorMode)
261
+ return null;
262
+ return this.readSafe(this.lockPath);
263
+ }
264
+ /** Holder plus computed staleness metadata, for `locks list`. */
265
+ listing() {
266
+ if (this.orchestratorMode)
267
+ return null;
268
+ const holder = this.readSafe(this.lockPath);
269
+ if (!holder)
270
+ return null;
271
+ const ageMs = this.now() - Date.parse(holder.startedAt);
272
+ const staleReason = this.staleness(holder);
273
+ return {
274
+ holder,
275
+ ageMs: Number.isFinite(ageMs) ? ageMs : 0,
276
+ stale: staleReason !== null,
277
+ staleReason,
278
+ lockPath: this.lockPath,
279
+ };
280
+ }
281
+ /**
282
+ * Manually clear the checkout lock. With `safetyCheck` (default), refuses to
283
+ * clear a holder that is still fresh — mirrors `LockManager.clearLock`.
284
+ *
285
+ * A file that exists but does not parse is removed unconditionally (#906).
286
+ * That state is reachable: `writeAtomic` creates the file with `openSync`
287
+ * and writes to it as a second step, so a process killed in between leaves a
288
+ * zero-byte `checkout.lock` (#856 documents the group-SIGKILL that does it).
289
+ * Before this branch existed such a file was unclearable by any command —
290
+ * `clear` read it, saw `null`, and reported `no-lock` without unlinking
291
+ * (`--force` only ever reached the *staleness* check, never the read), while
292
+ * `acquire` threw raw `EEXIST` and the hook, unable to parse any field,
293
+ * blocked on it forever. `safetyCheck` is not consulted because there is no
294
+ * holder to protect: unparseable bytes name no session.
295
+ */
296
+ clear(options = {}) {
297
+ if (this.orchestratorMode) {
298
+ return { cleared: false, reason: "orchestrator-mode" };
299
+ }
300
+ const safetyCheck = options.safetyCheck ?? true;
301
+ const lockPath = this.lockPath;
302
+ const holder = this.readSafe(lockPath);
303
+ if (!holder) {
304
+ if (existsSync(lockPath)) {
305
+ this.unlinkSafe(lockPath);
306
+ return { cleared: true, reason: "cleared-corrupt" };
307
+ }
308
+ return { cleared: false, reason: "no-lock" };
309
+ }
310
+ if (safetyCheck && !this.staleness(holder)) {
311
+ return { cleared: false, reason: "fresh-holder" };
312
+ }
313
+ this.unlinkSafe(lockPath);
314
+ return { cleared: true, reason: "cleared" };
315
+ }
316
+ // ── internals ────────────────────────────────────────────────────────────
317
+ /**
318
+ * Delegates wholesale to the per-issue lock's classifier so the two locks
319
+ * cannot drift on staleness (AC-4).
320
+ */
321
+ staleness(holder) {
322
+ return classifyStaleness({
323
+ holder,
324
+ myHostname: this.hostname,
325
+ now: this.now(),
326
+ staleAgeMs: this.staleAgeMs,
327
+ skillLockTtlMs: this.skillLockTtlMs,
328
+ maxLockAgeMs: this.maxLockAgeMs,
329
+ isPidAlive: this.isPidAlive,
330
+ });
331
+ }
332
+ writeAtomic(lockPath, issue, command, options) {
333
+ const payload = {
334
+ pid: this.pid,
335
+ hostname: this.hostname,
336
+ startedAt: new Date(this.now()).toISOString(),
337
+ command,
338
+ issue,
339
+ ...(options.sessionId ? { sessionId: options.sessionId } : {}),
340
+ ...(options.skipPidCheck ? { skipPidCheck: true } : {}),
341
+ };
342
+ let fd;
343
+ try {
344
+ fd = openSync(lockPath, "wx", 0o644);
345
+ }
346
+ catch (err) {
347
+ if (err.code === "EEXIST") {
348
+ const winner = this.readSafe(lockPath);
349
+ if (winner) {
350
+ return {
351
+ acquired: false,
352
+ holder: winner,
353
+ lockPath,
354
+ stale: false,
355
+ staleReason: null,
356
+ };
357
+ }
358
+ }
359
+ throw err;
360
+ }
361
+ try {
362
+ writeSync(fd, JSON.stringify(payload, null, 2));
363
+ }
364
+ finally {
365
+ closeSync(fd);
366
+ }
367
+ return { acquired: true, lockPath, reentrant: false };
368
+ }
369
+ readSafe(lockPath) {
370
+ if (!existsSync(lockPath))
371
+ return null;
372
+ try {
373
+ const parsed = CheckoutLockFileSchema.safeParse(JSON.parse(readFileSync(lockPath, "utf-8")));
374
+ return parsed.success ? parsed.data : null;
375
+ }
376
+ catch {
377
+ return null;
378
+ }
379
+ }
380
+ unlinkSafe(lockPath) {
381
+ try {
382
+ unlinkSync(lockPath);
383
+ }
384
+ catch (err) {
385
+ if (err.code !== "ENOENT")
386
+ throw err;
387
+ }
388
+ }
389
+ }
@@ -1,7 +1,10 @@
1
1
  /**
2
- * Public surface for the issue-level concurrency lock (#625).
2
+ * Public surface for the issue-level concurrency lock (#625) and the
3
+ * checkout-scoped lock (#901).
3
4
  */
4
- export { LockManager, classifyStaleness, defaultIsPidAlive, formatLockedMessage, isOrchestratorMode, resolveLocksDir, } from "./lock-manager.js";
5
+ export { CheckoutLock, RELEASE_SENTINEL_ISSUE, describeCheckoutHolderIssue, formatCheckoutLockedMessage, isCheckoutOwner, } from "./checkout-lock.js";
6
+ export type { CheckoutLockOptions } from "./checkout-lock.js";
7
+ export { LockManager, classifyStaleness, defaultIsPidAlive, formatLockedMessage, isOrchestratorMode, resolveLocksDir, resolveMaxLockAgeMs, } from "./lock-manager.js";
5
8
  export type { LockManagerOptions } from "./lock-manager.js";
6
- export { DEFAULT_LOCKS_DIR, DEFAULT_STALE_AGE_MS, LockFileSchema, } from "./types.js";
7
- export type { AcquireResult, LockFile, LockListing, SignalOtherResult, SignalReason, } from "./types.js";
9
+ export { CHECKOUT_LOCK_FILENAME, CheckoutLockFileSchema, DEFAULT_LOCKS_DIR, DEFAULT_MAX_LOCK_AGE_MS, DEFAULT_STALE_AGE_MS, LockFileSchema, } from "./types.js";
10
+ export type { AcquireResult, CheckoutAcquireResult, CheckoutHolderIdentity, CheckoutLockFile, CheckoutLockListing, LockFile, LockListing, SignalOtherResult, SignalReason, StaleReason, } from "./types.js";
@@ -1,5 +1,7 @@
1
1
  /**
2
- * Public surface for the issue-level concurrency lock (#625).
2
+ * Public surface for the issue-level concurrency lock (#625) and the
3
+ * checkout-scoped lock (#901).
3
4
  */
4
- export { LockManager, classifyStaleness, defaultIsPidAlive, formatLockedMessage, isOrchestratorMode, resolveLocksDir, } from "./lock-manager.js";
5
- export { DEFAULT_LOCKS_DIR, DEFAULT_STALE_AGE_MS, LockFileSchema, } from "./types.js";
5
+ export { CheckoutLock, RELEASE_SENTINEL_ISSUE, describeCheckoutHolderIssue, formatCheckoutLockedMessage, isCheckoutOwner, } from "./checkout-lock.js";
6
+ export { LockManager, classifyStaleness, defaultIsPidAlive, formatLockedMessage, isOrchestratorMode, resolveLocksDir, resolveMaxLockAgeMs, } from "./lock-manager.js";
7
+ export { CHECKOUT_LOCK_FILENAME, CheckoutLockFileSchema, DEFAULT_LOCKS_DIR, DEFAULT_MAX_LOCK_AGE_MS, DEFAULT_STALE_AGE_MS, LockFileSchema, } from "./types.js";